diff --git a/.gitea/workflows/port-to-omp.yml b/.gitea/workflows/port-to-omp.yml new file mode 100644 index 0000000..c088191 --- /dev/null +++ b/.gitea/workflows/port-to-omp.yml @@ -0,0 +1,64 @@ +name: port-to-omp + +# Regenerate the omp port of pygenium from this repo and push it to +# Mike/omp-pygenium. +# +# 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-pygenium + 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..ced35d4 --- /dev/null +++ b/port-to-omp.mjs @@ -0,0 +1,293 @@ +#!/usr/bin/env bun +/** + * port-to-omp.mjs — regenerate the omp port of pygenium 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/pygenium (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/pygenium + * bun port-to-omp.mjs --out # write elsewhere (CI: the omp repo clone) + * + * CI: .gitea/workflows/port-to-omp.yml clones the omp-pygenium 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", "type", "engines", "omp", "devDependencies", "scripts"], + transform(p) { + p.name = pkgName(p.name); + p.description = p.description.replace("for pi —", "for omp (port of the pi extension) —"); + p.keywords = ["omp", "omp-extension", ...p.keywords.slice(2)]; + p.engines.bun = ">=1.3.14"; + p.omp = p.pi; + delete p.pi; + delete p.peerDependencies; + delete p.peerDependenciesMeta; + 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/index.ts": [ + { from: " * Read the pygienium chat style from pi's settings.json.", to: " * Read the pygienium chat style from omp's settings.json." }, + { from: " * Looks for `pygienium.chatStyle` under `~/.pi/agent/settings.json`.", to: " * Looks for `pygienium.chatStyle` under `~/.omp/agent/settings.json`." }, + { from: '\t\t\tjoin(homedir(), ".pi", "agent", "settings.json"),', to: '\t\t\tjoin(homedir(), ".omp", "agent", "settings.json"),' }, + { + from: + "\t\t\t\tconst pygieniumCtx: PygieniumCtx = {\n\t\t\t\t\tcwd: ctx.cwd,\n\t\t\t\t\tmode: ctx.mode,\n\t\t\t\t\thasUI: ctx.hasUI,", + to: + "\t\t\t\tconst pygieniumCtx: PygieniumCtx = {\n\t\t\t\t\tcwd: ctx.cwd,\n\t\t\t\t\thasUI: ctx.hasUI,", + label: "ctx.mode removal", + }, + ], + "src/commands.ts": [ + { from: " * names. Each handler accepts the narrow context slice it needs (`cwd`, `mode`,", to: " * names. Each handler accepts the narrow context slice it needs (`cwd`," }, + { from: '\t"cwd" | "mode" | "hasUI" | "ui"', to: '\t"cwd" | "hasUI" | "ui"', label: "PygieniumCtx Pick" }, + { + from: "\t// In TUI mode, also surface the first line as a notification.\n\tif (ctx.mode === \"tui\" && ctx.ui?.notify) {", + to: "\t// With a dialog-capable UI, also surface the first line as a notification.\n\tif (ctx.hasUI && ctx.ui?.notify) {", + label: "print notify", + }, + ], + "src/agent-runner.ts": [ + { + from: + "\t// Lazily import the SDK so the rest of the module graph (and tests using the\n\t// fake runner) never resolve the heavy pi-coding-agent package.\n\tconst {\n\t\tcreateAgentSession,\n\t\tDefaultResourceLoader,\n\t\tgetAgentDir,\n\t\tSessionManager,\n\t} = await import(\"@oh-my-pi/pi-coding-agent\");\n\tconst loader = new DefaultResourceLoader({\n\t\tcwd: opts.cwd,\n\t\tagentDir: getAgentDir(),\n\t\tsystemPromptOverride: () => agent.systemPrompt,\n\t\t// Keep the sub-agent isolated: no nested extensions/skills/themes/etc.\n\t\tnoExtensions: true,\n\t\tnoSkills: true,\n\t\tnoThemes: true,\n\t\tnoPromptTemplates: true,\n\t});\n\tawait loader.reload();", + to: + "\t// Lazily import the SDK so the rest of the module graph (and tests using the\n\t// fake runner) never resolve the heavy pi-coding-agent package. The specifier\n\t// is static by intent (dynamic-import exception: module is intentionally\n\t// excluded from the import-time graph to keep fake-runner tests SDK-free).\n\tconst { createAgentSession, AgentRegistry, SessionManager } = await import(\n\t\t\"@oh-my-pi/pi-coding-agent\"\n\t);", + label: "SDK import + loader removal", + }, + { from: '\t\tagent.allowedTools ?? ["read", "bash", "grep", "find"];', to: '\t\tagent.allowedTools ?? ["read", "bash", "grep", "glob"];', label: "default tools" }, + { + from: + "\tconst { session } = await createAgentSession({\n\t\tcwd: opts.cwd,\n\t\ttools,\n\t\tsessionManager: SessionManager.inMemory(opts.cwd),\n\t\tresourceLoader: loader,\n\t});", + to: + "\tconst { session } = await createAgentSession({\n\t\tcwd: opts.cwd,\n\t\ttoolNames: tools,\n\t\t// `tools` is an allowlist, not a request list.\n\t\trestrictToolNames: true,\n\t\tsessionManager: SessionManager.inMemory(opts.cwd),\n\t\t// Replace the fully rendered default prompt with the agent body.\n\t\tsystemPrompt: agent.systemPrompt,\n\t\t// Keep the sub-agent isolated: no nested extensions/skills/prompts/etc.\n\t\tdisableExtensionDiscovery: true,\n\t\tskills: [],\n\t\tpromptTemplates: [],\n\t\trules: [],\n\t\tcontextFiles: [],\n\t\tenableMCP: false,\n\t\tenableLsp: false,\n\t\t// Private registry: the host session owns the process-global \"Main\"\n\t\t// identity, so a per-run registry keeps these in-process workers\n\t\t// disjoint from the main agent.\n\t\tagentRegistry: new AgentRegistry(),\n\t});", + label: "createAgentSession", + }, + ], + "src/agents.ts": [{ from: " - find", to: " - glob", label: "frontmatter example" }], + "README.md": [ + { from: "[pi](https://github.com/earendil-works/pi-coding-agent)", to: "[omp](https://github.com/oh-my-pi)" }, + { from: "local pi extension under `~/.pi/agent/extensions/pygienium/`.", to: "local omp extension under `~/.omp/agent/extensions/pygienium/`." }, + { from: "Pi loads it via `pi.extensions`", to: "Omp loads it via `omp.extensions`" }, + { from: "Pi auto-discovers the extension from this location via the `pi.extensions` entry", to: "Omp auto-discovers the extension from this location via the `omp.extensions` entry" }, + { from: "pi's `settings.json` (`~/.pi/agent/settings.json`)", to: "omp's `settings.json` (`~/.omp/agent/settings.json`)" }, + { from: "Reload pi (or `/reload`)", to: "Reload omp (or `/reload`)" }, + ], + }; +const AGENT_MD_GLOB = { re: /`find`(?! \.)/g, to: "`glob`", label: "find token", all: true }; +const AGENT_MD_FRONTMATTER = { from: " - find", to: " - glob", label: "frontmatter tools", all: true }; + +const PYGENIUM_TSCONFIG = `{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "types": ["node"], + "noEmit": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} +`; + + + +// ─── main ─────────────────────────────────────────────────────────────────── + +const OUT_DEFAULT = join(homedir(), ".omp", "agent", "extensions", "pygenium"); + +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); + if (rel.startsWith("agents/")) { + text = applyOps(text, [AGENT_MD_FRONTMATTER, AGENT_MD_GLOB], rel); + } + writeFileSync(p, text); + } + } + writeFileSync(join(dstDir, "tsconfig.json"), PYGENIUM_TSCONFIG); + console.log("== bun install (regenerates bun.lock + node_modules)"); + execSync("bun install", { cwd: dstDir, stdio: "inherit" }); + console.log("== done"); +} + +portExtension();