#!/usr/bin/env bun /** * port-to-omp.mjs — regenerate the omp port of deepi-research from this repo. * * The omp port is "base + patch layer"; this script IS the patch layer. This * repo is the single source of truth; ~/.omp/agent/extensions/deepi-research (or * --out) is a generated artifact. Every op asserts its target and fails * loudly on base drift — never silently producing a stale port. * * Usage: * bun port-to-omp.mjs # write ~/.omp/agent/extensions/deepi-research * bun port-to-omp.mjs --out # write elsewhere (CI: the omp repo clone) * * CI: .gitea/workflows/port-to-omp.yml clones the omp-deepi-research repo and * runs this script into it, then commits + pushes when the port changed. */ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync, } from "node:fs"; import { join, relative, resolve, isAbsolute, dirname, basename } from "node:path"; import { execSync } from "node:child_process"; import { homedir } from "node:os"; const HOME = homedir(); const PI = join(HOME, ".pi", "agent", "extensions"); const OMP = join(HOME, ".omp", "agent", "extensions"); const SKIP = new Set([ "port-to-omp.mjs", ".gitea", ".github", "node_modules", ".git", ".DS_Store", ".pi-lens", "bun.lock", "package-lock.json", "dist", ]); const OMP_SDK = "17.2.12"; // ─── helpers ──────────────────────────────────────────────────────────────── function assertEdit(src, from, to, file, label = "") { const idx = src.indexOf(from); if (idx === -1) { throw new Error( `[${file}] target not found${label ? ` (${label})` : ""}:\n${from.slice(0, 300)}`, ); } return src.slice(0, idx) + to + src.slice(idx + from.length); } function replaceAll(src, from, to, file, label) { const parts = src.split(from); if (parts.length === 1) { throw new Error(`[${file}] target not found${label ? ` (${label})` : ""}:\n${from.slice(0, 300)}`); } return parts.join(to); } function reEdit(src, re, to, file, label) { const out = src.replace(re, to); if (out === src) { throw new Error(`[${file}] regex matched nothing (${label}): ${re}`); } return out; } function reAll(src, re, to, file, label) { let count = 0; const out = src.replace(re, (...args) => { count++; return typeof to === "function" ? to(...args) : to; }); if (count === 0) { throw new Error(`[${file}] regex matched nothing (${label}): ${re}`); } return out; } /** Run a list of ops over a file's text. op = {from,to} | {re,to,label}. */ function applyOps(src, ops, file) { for (const op of ops) { if ("from" in op) { src = op.all ? replaceAll(src, op.from, op.to, file, op.label) : assertEdit(src, op.from, op.to, file, op.label); } else { src = op.all ? reAll(src, op.re, op.to, file, op.label) : reEdit(src, op.re, op.to, file, op.label); } } return src; } function mirrorTree(srcDir, dstDir) { mkdirSync(dstDir, { recursive: true }); cpSync(srcDir, dstDir, { recursive: true, force: true, filter: (p) => !SKIP.has(p.split("/").pop()), }); // drop stale files in dst that no longer exist in src; keep .git* intact for (const rel of walk(dstDir)) { if (rel.startsWith(".git")) continue; if (!existsSync(join(srcDir, rel))) rmSync(join(dstDir, rel), { force: true, recursive: true }); } } function real(p) { try { return realpathSync(p); } catch { // walk to the nearest existing ancestor and realpath it, then re-append const tail = []; let cur = resolve(p); for (;;) { try { return join(realpathSync(cur), ...tail); } catch {} const parent = dirname(cur); if (parent === cur) return resolve(p); tail.unshift(basename(cur)); cur = parent; } } } function assertDstOutsideSrc(srcDir, dstDir) { const rel = relative(real(srcDir), real(dstDir)); if (rel === "" || (!rel.startsWith("..") && !isAbsolute(rel))) { throw new Error( `refusing to port into a subdirectory of the source: ${dstDir} is inside ${srcDir}` ); } }function walk(dir) { const out = []; for (const entry of readdirSync(dir, { withFileTypes: true })) { if (SKIP.has(entry.name)) continue; const p = join(dir, entry.name); if (entry.isDirectory()) out.push(...walk(p).map((r) => join(entry.name, r))); else out.push(entry.name); } return out; } const SPECIFIERS = [ ["@earendil-works/pi-coding-agent", "@oh-my-pi/pi-coding-agent"], ["@earendil-works/pi-tui", "@oh-my-pi/pi-tui"], ["@earendil-works/pi-ai", "@oh-my-pi/pi-ai"], ["@earendil-works/pi-agent-core", "@oh-my-pi/pi-agent-core"], ]; function rewriteSpecifiers(src) { for (const [from, to] of SPECIFIERS) src = src.split(from).join(to); return src; } // ─── package.json transforms ──────────────────────────────────────────────── function pkgName(piName) { if (piName.startsWith("@mikefreno/")) return piName.replace(/^@mikefreno\//, "@mikefreno/omp-"); return `@mikefreno/omp-${piName}`; } function reorder(obj, keys) { const out = {}; for (const k of keys) if (k in obj) out[k] = obj[k]; for (const k of Object.keys(obj)) if (!(k in out)) out[k] = obj[k]; return out; } const PKG_RULES = { order: ["name", "version", "description", "keywords", "license", "files", "scripts", "engines", "dependencies", "omp", "devDependencies"], transform(p) { p.name = pkgName(p.name); delete p.author; delete p.homepage; delete p.repository; delete p.bugs; p.engines.bun = ">=1.3.14"; p.dependencies = { ...(p.dependencies ?? {}), yaml: "^2.4.0" }; p.omp = p.pi; delete p.pi; delete p.peerDependencies; delete p.publishConfig; p.devDependencies = { "@oh-my-pi/pi-coding-agent": OMP_SDK, "@oh-my-pi/pi-tui": OMP_SDK, ...p.devDependencies, }; }, }; function transformPkg() { const raw = JSON.parse(readFileSync(join(import.meta.dir, "package.json"), "utf8")); if (!raw.pi) throw new Error('expected "pi" manifest key in package.json'); const rule = PKG_RULES; rule.transform(raw); const ordered = reorder(raw, rule.order); return JSON.stringify(ordered, null, 2) + "\n"; } const README_STUB = `# deepi-research (omp port) Multi-round deep web research via Firecrawl with iterative query refinement. ## Install \`\`\`sh omp install @mikefreno/omp-deepi-research \`\`\` This is the omp port of [Mike/deepi-research](https://git.freno.me/Mike/deepi-research), regenerated automatically from the source repo. See the source repo for full documentation. `; const FILE_RULES = { "src/agent.ts": [ { from: " * Uses pi's in-process `createAgentSession` for LLM subtasks", to: " * Uses omp's in-process `createAgentSession` for LLM subtasks" }, { from: " * Run a prompt through an in-process Pi agent session.", to: " * Run a prompt through an in-process omp agent session." }, { from: 'import {\n\tcreateAgentSession,\n\tDefaultResourceLoader,\n\tgetAgentDir,\n\tSessionManager,\n} from "@oh-my-pi/pi-coding-agent";', to: 'import {\n\tcreateAgentSession,\n\tAgentRegistry,\n\tSessionManager,\n} from "@oh-my-pi/pi-coding-agent";', label: "import block", }, { from: "\t\tconst loader = new DefaultResourceLoader({\n\t\t\tcwd,\n\t\t\tagentDir: getAgentDir(),\n\t\t\tnoExtensions: true,\n\t\t\tnoSkills: true,\n\t\t\tnoPromptTemplates: true,\n\t\t\tnoThemes: true,\n\t\t\tnoContextFiles: true,\n\t\t});\n\t\tawait loader.reload();\n\n\t\tconst result = await createAgentSession({\n\t\t\tcwd,\n\t\t\tsessionManager: SessionManager.inMemory(),\n\t\t\tresourceLoader: loader,\n\t\t\ttools: [\"read\", \"grep\", \"find\", \"ls\"],\n\t\t});", to: "\t\tconst result = await createAgentSession({\n\t\t\tcwd,\n\t\t\tsessionManager: SessionManager.inMemory(cwd),\n\t\t\ttoolNames: [\"read\", \"grep\", \"glob\"],\n\t\t\trestrictToolNames: true,\n\t\t\tdisableExtensionDiscovery: true,\n\t\t\tskills: [],\n\t\t\tpromptTemplates: [],\n\t\t\trules: [],\n\t\t\tcontextFiles: [],\n\t\t\tenableMCP: false,\n\t\t\tenableLsp: false,\n\t\t\tagentRegistry: new AgentRegistry(),\n\t\t});", label: "createAgentSession", }, ], "src/firecrawl.ts": [ { from: 'import * as fs from "node:fs";\nimport * as path from "node:path";', to: 'import * as fs from "node:fs";\nimport * as path from "node:path";\nimport { parse as parseYaml } from "yaml";', label: "yaml import", }, { from: " * Read and merge Firecrawl settings from pi's settings.json files.\n *\n * Resolution order (later wins):\n * 1. env vars FIRECRAWL_BASE_URL / FIRECRAWL_API_KEY\n * 2. global ~/.pi/agent/settings.json → firecrawl.*\n * 3. project .pi/settings.json → firecrawl.*\n * 4. default http://localhost:3002 (if no baseUrl configured)", to: " * Read and merge Firecrawl settings from omp's config.yml files.\n *\n * Resolution order (later wins):\n * 1. env vars FIRECRAWL_BASE_URL / FIRECRAWL_API_KEY\n * 2. global ~/.omp/agent/config.yml → firecrawl.*\n * 3. project .omp/config.yml → firecrawl.*\n * 4. default http://localhost:3002 (if no baseUrl configured)", label: "doc comment", }, { from: "\t// Helper: read a settings.json and merge its firecrawl.* keys\n\tconst tryReadSettings = (settingsPath: string): void => {\n\t\ttry {\n\t\t\tconst raw = JSON.parse(fs.readFileSync(settingsPath, \"utf-8\"));\n\t\t\tconst fc: Record = raw.firecrawl ?? {};\n\t\t\tif (typeof fc.baseUrl === \"string\" && fc.baseUrl.length > 0) {\n\t\t\t\tbaseUrl = fc.baseUrl;\n\t\t\t}\n\t\t\tif (typeof fc.apiKey === \"string\" && fc.apiKey.length > 0) {\n\t\t\t\tapiKey = fc.apiKey;\n\t\t\t}\n\t\t} catch {\n\t\t\t// File missing or unparseable — skip\n\t\t}\n\t};", to: "\t// Helper: read a config.yml and merge its firecrawl.* keys\n\tconst tryReadConfig = (configPath: string): void => {\n\t\ttry {\n\t\t\tconst raw = parseYaml(fs.readFileSync(configPath, \"utf-8\")) as Record<\n\t\t\t\tstring,\n\t\t\t\tunknown\n\t\t\t>;\n\t\t\tconst fc = (raw?.firecrawl ?? {}) as Record;\n\t\t\tif (typeof fc.baseUrl === \"string\" && fc.baseUrl.length > 0) {\n\t\t\t\tbaseUrl = fc.baseUrl;\n\t\t\t}\n\t\t\tif (typeof fc.apiKey === \"string\" && fc.apiKey.length > 0) {\n\t\t\t\tapiKey = fc.apiKey;\n\t\t\t}\n\t\t} catch {\n\t\t\t// File missing or unparseable — skip\n\t\t}\n\t};", label: "tryReadConfig helper", }, { from: '\t// 1. Global settings\n\ttryReadSettings(path.join(agentDir, "settings.json"));\n\n\t// 2. Project settings (override global)\n\ttryReadSettings(path.join(process.cwd(), ".pi", "settings.json"));', to: '\t// 1. Global config\n\ttryReadConfig(path.join(agentDir, "config.yml"));\n\n\t// 2. Project config (override global)\n\ttryReadConfig(path.join(process.cwd(), ".omp", "config.yml"));', label: "config call sites", }, ], "index.ts": [ { from: '"Deep Research: Firecrawl endpoint unreachable — searches will fail. Set firecrawl.baseUrl in settings.json (global or project) or the FIRECRAWL_BASE_URL env var."', to: '"Deep Research: Firecrawl endpoint unreachable — searches will fail. Set firecrawl.baseUrl in config.yml (global ~/.omp/agent or project .omp) or the FIRECRAWL_BASE_URL env var."', label: "notification string" }, { from: '} from "@oh-my-pi/pi-coding-agent";\nimport { Type } from "typebox";', to: '} from "@oh-my-pi/pi-coding-agent";\nimport type { TSchema } from "@oh-my-pi/pi-ai";\nimport { Type } from "typebox";', label: "TSchema import", }, { from: '\t\t\t"Supports iterative refinement and sub-question decomposition for deeper analysis.",\n\t\t\t"Parameters: question (required), depth, breadth, format, audience, details.",\n\t\t].join(" "),\n\t\tpromptSnippet:\n\t\t\t"deep_research — multi-round deep web research via Firecrawl with iterative query refinement, sub-question decomposition, source authority scoring, and numbered citations",\n\t\tpromptGuidelines: [\n\t\t\t"Use deep_research for complex, multi-faceted questions that benefit from multiple search angles and iterative refinement.",\n\t\t\t"The tool handles query generation, web search, result analysis, and report synthesis automatically.",\n\t\t\t"For simple fact-finding questions, use firecrawl_search directly instead.",\n\t\t\t"Set audience to \'executive\' for concise, action-oriented reports; \'expert\' for technical depth; \'general\' (default) for accessible reports.",\n\t\t],\n\t\tparameters: DeepResearchParams,', to: '\t\t\t"Supports iterative refinement and sub-question decomposition for deeper analysis.",\n\t\t\t"Parameters: question (required), depth, breadth, format, audience, details.",\n\t\t\t"Use for complex, multi-faceted questions that benefit from multiple search angles;",\n\t\t\t"for simple fact-finding questions use firecrawl_search directly instead.",\n\t\t\t"Set audience to \'executive\' for concise, action-oriented reports; \'expert\' for technical depth;",\n\t\t\t"\'general\' (default) for accessible reports.",\n\t\t].join(" "),\n\t\tparameters: DeepResearchParams as unknown as TSchema,', label: "tool descriptor", }, { from: "\t\trenderCall(\n\t\t\targs: {\n\t\t\t\tquestion: string;\n\t\t\t\tdepth?: number;\n\t\t\t\tbreadth?: number;\n\t\t\t\tformat?: string;\n\t\t\t\taudience?: string;\n\t\t\t},\n\t\t\ttheme: any,\n\t\t\t_context: any,\n\t\t) {", to: "\t\trenderCall(\n\t\t\targs: any,\n\t\t\t_options: any,\n\t\t\ttheme: any,\n\t\t) {", label: "renderCall signature", }, ], }; // ─── main ─────────────────────────────────────────────────────────────────── const OUT_DEFAULT = join(homedir(), ".omp", "agent", "extensions", "deepi-research"); function portExtension() { const srcDir = import.meta.dir; const dstDir = process.argv.includes("--out") ? process.argv[process.argv.indexOf("--out") + 1] : OUT_DEFAULT; if (!dstDir) throw new Error("--out requires a directory argument"); if (!existsSync(srcDir)) throw new Error(`no base extension at ${srcDir}`); assertDstOutsideSrc(srcDir, dstDir); console.log(`== ${srcDir} -> ${dstDir}`); mirrorTree(srcDir, dstDir); for (const rel of walk(dstDir)) { const p = join(dstDir, rel); if (!existsSync(p)) continue; if (rel.endsWith(".ts")) { let text = readFileSync(p, "utf8"); text = rewriteSpecifiers(text); if (FILE_RULES[rel]) text = applyOps(text, FILE_RULES[rel], rel); writeFileSync(p, text); } else if (rel === "package.json") { writeFileSync(p, transformPkg()); } else if (rel === "README.md") { writeFileSync(p, README_STUB); } else if (rel.endsWith(".md")) { let text = readFileSync(p, "utf8"); if (FILE_RULES[rel]) text = applyOps(text, FILE_RULES[rel], rel); writeFileSync(p, text); } } console.log("== bun install (regenerates bun.lock + node_modules)"); execSync("bun install", { cwd: dstDir, stdio: "inherit" }); console.log("== done"); } portExtension();