316 lines
16 KiB
JavaScript
316 lines
16 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,
|
|
rmSync,
|
|
writeFileSync,
|
|
} from "node:fs";
|
|
import { join } 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 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}`);
|
|
|
|
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();
|