diff --git a/.gitea/workflows/port-to-omp.yml b/.gitea/workflows/port-to-omp.yml new file mode 100644 index 0000000..633c2c5 --- /dev/null +++ b/.gitea/workflows/port-to-omp.yml @@ -0,0 +1,64 @@ +name: port-to-omp + +# Regenerate the omp port of deepi-research from this repo and push it to +# Mike/omp-deepi-research. +# +# Prerequisites on git.freno.me: +# - an access token with write:repository scope, stored as the repo/org +# secret GITEA_TOKEN (the workflow authenticates as `oauth2:` over +# https) +# - a registered Actions runner (act_runner) for this repo +# +# Safe by construction: the port commit lands in the omp repo, never here, so +# this workflow cannot re-trigger itself. + +on: + push: + branches: [master] + +jobs: + port: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install bun + uses: oven-sh/setup-bun@v2 + + - name: Port to omp + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + OMP_REPO: omp-deepi-research + run: | + set -euo pipefail + URL="https://oauth2:${GITEA_TOKEN}@git.freno.me/Mike/${OMP_REPO}.git" + + if git ls-remote "$URL" HEAD >/dev/null 2>&1; then + git clone --depth 1 "$URL" omp-port + git -C omp-port config user.name "omp-port" + git -C omp-port config user.email "omp-port@freno.me" + else + git init -b main omp-port + git -C omp-port remote add origin "$URL" + git -C omp-port config user.name "omp-port" + git -C omp-port config user.email "omp-port@freno.me" + fi + + # Regenerate the port directly into the omp checkout. The script + # preserves .git, asserts every patch rule, and runs `bun install` + # (refreshing bun.lock + node_modules). + bun "$GITHUB_WORKSPACE/port-to-omp.mjs" --out "$PWD/omp-port" + + cd omp-port + # The port must compile against the pinned @oh-my-pi SDK before it + # ships to users. + bun run typecheck + + if git diff --quiet HEAD; then + echo "port unchanged; nothing to push" + exit 0 + fi + git add -A + git commit -m "port: sync from ${GITHUB_REPOSITORY}@${GITHUB_SHA::8}" + git push origin HEAD:main diff --git a/port-to-omp.mjs b/port-to-omp.mjs new file mode 100644 index 0000000..eda5282 --- /dev/null +++ b/port-to-omp.mjs @@ -0,0 +1,315 @@ +#!/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, + 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 = 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", + }, + ], + "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();