From cb35ee044c28391b0024632c20f6863bfc7fd0e6 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Mon, 10 Aug 2026 12:24:15 -0400 Subject: [PATCH] add port-to-omp.mjs + Gitea Actions workflow (regenerate omp port on push) --- .gitea/workflows/port-to-omp.yml | 63 +++++++ port-to-omp.mjs | 294 +++++++++++++++++++++++++++++++ 2 files changed, 357 insertions(+) create mode 100644 .gitea/workflows/port-to-omp.yml create mode 100644 port-to-omp.mjs diff --git a/.gitea/workflows/port-to-omp.yml b/.gitea/workflows/port-to-omp.yml new file mode 100644 index 0000000..fd03178 --- /dev/null +++ b/.gitea/workflows/port-to-omp.yml @@ -0,0 +1,63 @@ +name: port-to-omp + +# Regenerate the omp port of ralpi from this repo and push it to Mike/omp-ralpi. +# +# 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-ralpi + 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..6d21710 --- /dev/null +++ b/port-to-omp.mjs @@ -0,0 +1,294 @@ +#!/usr/bin/env bun +/** + * port-to-omp.mjs — regenerate the omp port of ralpi 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/ralpi (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/ralpi + * bun port-to-omp.mjs --out # write elsewhere (CI: the omp repo clone) + * + * CI: .gitea/workflows/port-to-omp.yml clones the omp-ralpi 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", "author", "license", "homepage", "repository", "bugs", "files", "scripts", "engines", "omp", "dependencies", "publishConfig", "devDependencies"], + transform(p) { + p.name = pkgName(p.name); + p.keywords = ["omp", "omp-extension", ...p.keywords.slice(2)]; + delete p.scripts.prepublishOnly; + p.engines.bun = ">=1.3.14"; + p.omp = p.pi; + delete p.pi; + delete p.omp.prompts; + delete p.peerDependencies; + 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/utils.ts": [ + { + from: + 'import type { AgentSessionEvent } from "@oh-my-pi/pi-coding-agent";\nimport {\n createAgentSession,\n DefaultResourceLoader,\n getAgentDir,\n SessionManager,\n SettingsManager,\n type ModelRuntime,\n} from "@oh-my-pi/pi-coding-agent";', + to: + 'import type { AgentSessionEvent } from "@oh-my-pi/pi-coding-agent";\nimport type { ModelRegistry } from "@oh-my-pi/pi-coding-agent";\nimport {\n AgentRegistry,\n createAgentSession,\n getAgentDir,\n SessionManager,\n Settings,\n} from "@oh-my-pi/pi-coding-agent";', + label: "import block", + }, + { from: "/** Path to the global ralpi config under the user's Pi home directory. */", to: "/** Path to the global ralpi config under the user's omp home directory. */" }, + { from: ' process.env.HOME || "/tmp",\n ".pi",\n "ralpi",', to: ' process.env.HOME || "/tmp",\n ".omp",\n "ralpi",', label: "GLOBAL_CONFIG_PATH" }, + { from: "~/.pi/ralpi/config.yaml", to: "~/.omp/ralpi/config.yaml", all: true, label: "config path mentions" }, + { + from: + " /** Parent session's model runtime. Must be passed so extension-registered\n * providers (e.g., neuralwatt with its streamSimple wrapper for 429\n * rate-limit normalization) are available. When omitted, the SDK creates\n * a fresh runtime from models.json only — extension providers are lost. */\n modelRuntime?: ModelRuntime,", + to: + " /** Parent session's model registry. Must be passed so extension-registered\n * providers (e.g., neuralwatt with its streamSimple wrapper for 429\n * rate-limit normalization) are available. When omitted, the SDK creates\n * a fresh registry from models.json only — extension providers are lost. */\n modelRegistry?: ModelRegistry,", + label: "modelRuntime signature", + }, + { + from: + " try {\n // Loop sessions load the full normal pi context: extensions (so all\n // extension-provided tools register), skills, and project context\n // (AGENTS.md / CLAUDE.md)\n const loader = new DefaultResourceLoader({\n cwd,\n agentDir: getAgentDir(),\n noSkills,\n noPromptTemplates: true,\n noThemes: true,\n noExtensions: false,\n noContextFiles: false,\n });\n await loader.reload();\n\n const result = await createAgentSession({\n cwd,\n sessionManager: SessionManager.inMemory(),\n resourceLoader: loader,\n settingsManager: SettingsManager.create(cwd, getAgentDir()),\n modelRuntime,\n // No `tools` allowlist: matches a normal pi session's tool set.\n model: model as any,\n thinkingLevel: thinkingLevel as any,\n });", + to: + " try {\n // Loop sessions load the full normal omp context: extensions (so all\n // extension-provided tools register) and project context (AGENTS.md).\n const result = await createAgentSession({\n cwd,\n sessionManager: SessionManager.inMemory(cwd),\n settingsManager: Settings.init({ cwd, agentDir: getAgentDir() }),\n // Loop sessions intentionally load extensions (no disableExtensionDiscovery),\n // plus skills and project context via default discovery.\n skills: noSkills ? [] : undefined,\n promptTemplates: [],\n // No `tools` allowlist: matches a normal omp session's tool set.\n model: model as any,\n thinkingLevel: thinkingLevel as any,\n modelRegistry,\n agentRegistry: new AgentRegistry(),\n });", + label: "runAgentSession body", + }, + ], + "index.ts": [ + { + from: + 'import type {\n\tExtensionAPI,\n\tExtensionContext,\n} from "@oh-my-pi/pi-coding-agent";', + to: + 'import type {\n\tExtensionAPI,\n\tExtensionContext,\n\tSessionStartEvent,\n} from "@oh-my-pi/pi-coding-agent";', + label: "SessionStartEvent import", + }, + { + from: '\tpi.on("session_start", async (event, ctx) => {\n\t\tif (event.reason !== "reload") return;', + to: + '\tpi.on("session_start", async (event: SessionStartEvent, ctx) => {\n\t\t// omp\'s SessionStartEvent has no reason/reload field; the in_progress-task\n\t\t// check below already scopes recovery to genuinely interrupted loops (a\n\t\t// completed loop has no in_progress tasks), so recovery runs on any start\n\t\t// where a stalled loop marker exists.', + label: "session_start handler", + }, + ], + "src/executor.ts": [ + { + from: + 'import type {\n\tExtensionContext,\n\tModelRuntime,\n\tAgentSessionEvent,\n} from "@oh-my-pi/pi-coding-agent";', + to: + 'import type {\n\tExtensionContext,\n\tAgentSessionEvent,\n} from "@oh-my-pi/pi-coding-agent";', + label: "ModelRuntime import removal", + }, + { from: "(ctx.modelRegistry as any).runtime as ModelRuntime,", to: "ctx.modelRegistry,", all: true, label: "modelRegistry pass-through" }, + { from: "\t// Pi's built-in retry (via SettingsManager) handles transient HTTP errors\n\t// with exponential backoff WITHIN a single prompt. Ralpi adds two layers on", to: "\t// The agent's built-in retry handles transient HTTP errors with exponential\n\t// backoff WITHIN a single prompt. Ralpi adds two layers on", label: "retry comment" }, + { + from: + '\t\tcase "find":\n\t\t\treturn sanitizeLabel(`${a.path ?? "."} — ${a.glob ?? "*"}`);\n\t\tcase "ls":\n\t\t\treturn sanitizeLabel(truncateMiddle(String(a.path ?? "."), 60));', + to: '\t\tcase "glob":\n\t\t\treturn sanitizeLabel(`${a.path ?? "."} — ${a.glob ?? "*"}`);', + label: "tool labeler find/ls", + }, + ], + "src/task-manager-prompt.ts": [ + { + from: + 'import * as fs from "node:fs";\nimport * as path from "node:path";\nimport { stripFrontmatter } from "@oh-my-pi/pi-coding-agent";\n\nconst TEMPLATE_REL = path.join("prompts", "task-manager.md");', + to: + 'import * as fs from "node:fs";\nimport * as path from "node:path";\n\nconst TEMPLATE_REL = path.join("prompts", "task-manager.md");\n\n/**\n * Strip leading YAML frontmatter (--- delimited) from template content.\n * Local port of the helper omp does not export from the package root.\n */\nfunction stripFrontmatter(content: string): string {\n const m = /^---\\r?\\n[\\s\\S]*?\\r?\\n---\\r?\\n/.exec(content);\n return m ? content.slice(m[0].length) : content;\n}', + label: "vendor stripFrontmatter", + }, + ], + "README.md": [ + { + from: "pi install npm:@mikefreno/ralpi", + to: "# omp auto-discovers extensions in ~/.omp/agent/extensions//\ngit clone ~/.omp/agent/extensions/ralpi/", + label: "install block", + }, + { from: "~/.pi/ralpi/config.yaml", to: "~/.omp/ralpi/config.yaml", all: true, label: "config path" }, + ], + }; + +// ─── main ─────────────────────────────────────────────────────────────────── + +const OUT_DEFAULT = join(homedir(), ".omp", "agent", "extensions", "ralpi"); + +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();