add port-to-omp.mjs + Gitea Actions workflow (regenerate omp port on push)
Some checks failed
port-to-omp / port (push) Failing after 5s
Some checks failed
port-to-omp / port (push) Failing after 5s
This commit is contained in:
64
.gitea/workflows/port-to-omp.yml
Normal file
64
.gitea/workflows/port-to-omp.yml
Normal file
@@ -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:<token>` 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
|
||||
315
port-to-omp.mjs
Normal file
315
port-to-omp.mjs
Normal file
@@ -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 <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();
|
||||
Reference in New Issue
Block a user