Files
deepi-research/port-to-omp.mjs
2026-08-10 15:56:47 -04:00

344 lines
17 KiB
JavaScript

#!/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 <dir> # 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 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<string, string | undefined> = 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<string, unknown>;\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",
},
],
"README.md": [
{ from: "```bash\npi install npm:@mikefreno/deep-research\n```", to: "Deep Research is a local omp extension under `~/.omp/agent/extensions/deepi-research/`.\nOmp loads it via `omp.extensions` in `package.json` (entry `./index.ts`).", label: "install block" },
{ from: "Deep Research reads Firecrawl configuration from pi's settings.json files, with the following resolution order (later wins):", to: "Deep Research reads Firecrawl configuration from omp's config.yml files, with the following resolution order (later wins):" },
{ from: "2. Global settings (`$agentDir/settings.json`) → `firecrawl.*`", to: "2. Global config (`$agentDir/config.yml`) → `firecrawl.*`" },
{ from: "3. Project settings (`.pi/settings.json`) → `firecrawl.*`", to: "3. Project config (`.omp/config.yml`) → `firecrawl.*`" },
{ from: "The agent directory (`$agentDir`) defaults to `~/.pi/agent` and respects the `PI_CODING_AGENT_DIR` environment variable.", to: "The agent directory (`$agentDir`) defaults to `~/.omp/agent`." },
{ from: "**Global settings** (`~/.pi/agent/settings.json`):", to: "**Global config** (`~/.omp/agent/config.yml`):" },
{
from:
'```json\n{\n "firecrawl": {\n "baseUrl": "http://localhost:3002",\n }\n}\n```',
to:
"```yaml\nfirecrawl:\n baseUrl: http://localhost:3002\n```",
label: "global config example",
},
{ from: "**Project settings** (`.pi/settings.json` — overrides global):", to: "**Project config** (`.omp/config.yml` — overrides global):" },
{
from:
'```json\n{\n "firecrawl": {\n "baseUrl": "https://firecrawl.team.internal"\n "apiKey": "your-api-key"\n }\n}\n```',
to:
"```yaml\nfirecrawl:\n baseUrl: https://firecrawl.team.internal\n apiKey: your-api-key\n```",
label: "project config example",
},
],
};
// ─── 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.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();