@cairnkeep/cli 2.17.2 → 2.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/CHANGELOG.md +38 -1
  2. package/docs/harness-compatibility.md +47 -0
  3. package/docs/learning/CURRICULUM-MAP.md +2 -1
  4. package/docs/learning/FEATURE-GUIDE.md +1 -1
  5. package/docs/learning/lessons/L00-why-cairnkeep.md +1 -1
  6. package/docs/learning/lessons/L01-safe-trial.md +1 -1
  7. package/docs/learning/lessons/L02-installation.md +1 -1
  8. package/docs/learning/lessons/L03-first-project.md +1 -1
  9. package/docs/learning/lessons/L19-validated-skill-improvement.md +1 -1
  10. package/docs/learning/lessons/L20-mcp-tool-profiles.md +1 -1
  11. package/docs/learning/lessons/L21-context-packs.md +1 -1
  12. package/docs/learning/lessons/L22-native-windows.md +1 -1
  13. package/docs/learning/lessons/L23-guided-setup.md +1 -1
  14. package/docs/learning/lessons/L24-okf-exchange.md +1 -1
  15. package/docs/learning/lessons/L25-playbooks.md +1 -1
  16. package/docs/operating.md +2 -0
  17. package/examples/eval/bundled-fake.json +1 -1
  18. package/mcp-memory-server/dist/pi-bridge-child.js +109 -0
  19. package/mcp-memory-server/dist/pi-mcp-bridge.js +15 -3
  20. package/mcp-memory-server/package.json +5 -3
  21. package/omp/extensions/cairnkeep-capture.ts +226 -0
  22. package/omp/extensions/cairnkeep-memory.ts +288 -0
  23. package/package.json +77 -76
  24. package/scripts/cairn-cli.mjs +2 -0
  25. package/scripts/completion.sh +8 -3
  26. package/scripts/lib/omp-capture-stage.mjs +103 -0
  27. package/scripts/sync-omp-assets.sh +96 -0
  28. package/scripts/test-cli-dispatch.sh +2 -0
  29. package/scripts/test-completion.sh +3 -1
  30. package/scripts/verify-omp-capture.mjs +370 -0
  31. package/scripts/verify-omp-mcp-bridge.mjs +333 -0
@@ -0,0 +1,288 @@
1
+ import type { AgentToolResult, ExtensionAPI, ExtensionContext, ToolDefinition } from "@oh-my-pi/pi-coding-agent";
2
+ import { spawn, type ChildProcess } from "node:child_process";
3
+ import { appendFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+
6
+ // The bridge runs as a node child process because compiled Bun standalone
7
+ // binaries cannot resolve node_modules for runtime-imported files.
8
+ const CAIRN_ROOT = "@@INFRA_ROOT@@";
9
+ const CHILD_ENTRY = join(CAIRN_ROOT, "mcp-memory-server", "dist", "pi-bridge-child.js");
10
+
11
+ const STARTUP_TIMEOUT_MS = 10_000;
12
+ const CALL_TIMEOUT_MS = 30_000;
13
+ const STDERR_LIMIT_BYTES = 16 * 1024;
14
+ const SHUTDOWN_GRACE_MS = 3_000;
15
+
16
+ type CatalogTool = {
17
+ name: string;
18
+ title?: string;
19
+ description?: string;
20
+ inputSchema: Record<string, unknown>;
21
+ };
22
+
23
+ type ChildResult =
24
+ | { type: "ready" }
25
+ | { type: "result"; id: number; ok: true; tools?: CatalogTool[]; result?: unknown }
26
+ | { type: "result"; id: number; ok: false; error: string; stderr?: string };
27
+
28
+ type PendingRequest = {
29
+ resolve: (value: unknown) => void;
30
+ reject: (error: Error) => void;
31
+ timer: ReturnType<typeof setTimeout>;
32
+ };
33
+
34
+ // Minimal line-delimited JSON client for pi-bridge-child.js: requests carry
35
+ // an id, results route back by id, and an unexpected child exit rejects
36
+ // everything pending with the captured stderr tail.
37
+ class BridgeChildClient {
38
+ #child: ChildProcess;
39
+ #nextId = 1;
40
+ #stdoutBuffer = "";
41
+ #stderrTail = "";
42
+ #pending = new Map<number, PendingRequest>();
43
+ #readyPending: PendingRequest | undefined;
44
+ #exitPromise: Promise<void>;
45
+ #closed = false;
46
+
47
+ private constructor(child: ChildProcess) {
48
+ this.#child = child;
49
+ this.#exitPromise = new Promise((resolvePromise) => {
50
+ child.once("exit", () => resolvePromise());
51
+ child.once("error", () => resolvePromise());
52
+ });
53
+ child.stdout?.on("data", (chunk: Buffer | string) => this.#onStdout(chunk));
54
+ child.stderr?.on("data", (chunk: Buffer | string) => this.#appendStderr(chunk));
55
+ child.on("error", () => this.#failAll(new Error("Cairnkeep bridge child failed to start.")));
56
+ child.once("exit", (code, signal) => {
57
+ const detail = this.#stderrTail.trim();
58
+ const suffix = detail ? ` (child stderr: ${detail})` : "";
59
+ this.#failAll(new Error(`Cairnkeep bridge child exited (${signal ?? code}).${suffix}`));
60
+ this.#readyPending?.reject(new Error(`Cairnkeep bridge child exited before ready (${signal ?? code}).${suffix}`));
61
+ this.#readyPending = undefined;
62
+ });
63
+ const pidLog = process.env.CAIRN_BRIDGE_CHILD_PID_LOG;
64
+ if (pidLog && child.pid) appendFileSync(pidLog, `${child.pid}\n`);
65
+ }
66
+
67
+ static spawn(cwd: string): BridgeChildClient {
68
+ const child = spawn("node", [CHILD_ENTRY, cwd], { stdio: ["pipe", "pipe", "pipe"] });
69
+ child.stdin.on("error", () => undefined);
70
+ return new BridgeChildClient(child);
71
+ }
72
+
73
+ ready(): Promise<void> {
74
+ return new Promise((resolvePromise, rejectPromise) => {
75
+ const timer = setTimeout(() => {
76
+ this.#readyPending = undefined;
77
+ rejectPromise(new Error("Cairnkeep bridge child timed out waiting for ready."));
78
+ }, STARTUP_TIMEOUT_MS);
79
+ this.#readyPending = {
80
+ resolve: () => {
81
+ clearTimeout(timer);
82
+ resolvePromise();
83
+ },
84
+ reject: (error) => {
85
+ clearTimeout(timer);
86
+ rejectPromise(error);
87
+ },
88
+ timer,
89
+ };
90
+ });
91
+ }
92
+
93
+ request(message: Omit<{ type: "list" | "close" }, "id"> | { type: "call"; tool: unknown; args: Record<string, unknown> }, timeoutMs: number): Promise<unknown> {
94
+ const id = this.#nextId++;
95
+ return new Promise((resolvePromise, rejectPromise) => {
96
+ const timer = setTimeout(() => {
97
+ this.#pending.delete(id);
98
+ rejectPromise(new Error(`Cairnkeep bridge child request ${message.type} timed out after ${timeoutMs}ms.`));
99
+ }, timeoutMs);
100
+ this.#pending.set(id, { resolve: resolvePromise, reject: rejectPromise, timer });
101
+ this.#write({ ...message, id });
102
+ });
103
+ }
104
+
105
+ // One MCP tool call: 30s backstop timer plus cancellation forwarded to the
106
+ // child as a protocol-level cancel.
107
+ call(tool: unknown, args: Record<string, unknown>, signal: AbortSignal | undefined): Promise<unknown> {
108
+ const id = this.#nextId++;
109
+ return new Promise((resolvePromise, rejectPromise) => {
110
+ let timer: ReturnType<typeof setTimeout> | undefined;
111
+ const cancel = (): void => {
112
+ if (timer) clearTimeout(timer);
113
+ this.#pending.delete(id);
114
+ this.#write({ type: "cancel", id });
115
+ rejectPromise(new Error("cancelled"));
116
+ };
117
+ timer = setTimeout(() => {
118
+ this.#pending.delete(id);
119
+ this.#write({ type: "cancel", id });
120
+ rejectPromise(new Error(`Cairnkeep MCP call timed out after ${CALL_TIMEOUT_MS}ms.`));
121
+ }, CALL_TIMEOUT_MS);
122
+ this.#pending.set(id, { resolve: resolvePromise, reject: rejectPromise, timer });
123
+ if (signal) {
124
+ if (signal.aborted) {
125
+ cancel();
126
+ return;
127
+ }
128
+ signal.addEventListener("abort", cancel, { once: true });
129
+ }
130
+ this.#write({ type: "call", id, tool, args });
131
+ });
132
+ }
133
+
134
+ async close(): Promise<void> {
135
+ if (this.#closed) return;
136
+ this.#closed = true;
137
+ try {
138
+ this.#write({ type: "close" });
139
+ } catch {
140
+ // Child already gone — the exit handler rejected everything pending.
141
+ }
142
+ const grace = setTimeout(() => {
143
+ try {
144
+ this.#child.kill("SIGKILL");
145
+ } catch {
146
+ // best-effort kill only
147
+ }
148
+ }, SHUTDOWN_GRACE_MS);
149
+ await this.#exitPromise;
150
+ clearTimeout(grace);
151
+ }
152
+
153
+ #write(message: unknown): void {
154
+ this.#child.stdin.write(`${JSON.stringify(message)}\n`);
155
+ }
156
+
157
+ #appendStderr(chunk: Buffer | string): void {
158
+ const next = this.#stderrTail + (Buffer.isBuffer(chunk) ? chunk.toString("utf8") : chunk);
159
+ this.#stderrTail = next.length <= STDERR_LIMIT_BYTES ? next : next.slice(next.length - STDERR_LIMIT_BYTES);
160
+ }
161
+
162
+ #onStdout(chunk: Buffer | string): void {
163
+ this.#stdoutBuffer += Buffer.isBuffer(chunk) ? chunk.toString("utf8") : chunk;
164
+ let newline = this.#stdoutBuffer.indexOf("\n");
165
+ while (newline >= 0) {
166
+ const line = this.#stdoutBuffer.slice(0, newline);
167
+ this.#stdoutBuffer = this.#stdoutBuffer.slice(newline + 1);
168
+ newline = this.#stdoutBuffer.indexOf("\n");
169
+ if (!line.trim()) continue;
170
+ let message: ChildResult;
171
+ try {
172
+ message = JSON.parse(line) as ChildResult;
173
+ } catch {
174
+ continue;
175
+ }
176
+ if (message.type === "ready") {
177
+ this.#readyPending?.resolve(undefined);
178
+ this.#readyPending = undefined;
179
+ continue;
180
+ }
181
+ if (message.type !== "result") continue;
182
+ const pending = this.#pending.get(message.id);
183
+ if (!pending) continue;
184
+ this.#pending.delete(message.id);
185
+ clearTimeout(pending.timer);
186
+ if (message.ok) pending.resolve(message.tools ?? message.result);
187
+ else {
188
+ const detail = typeof message.stderr === "string" && message.stderr.trim() ? ` (child stderr: ${message.stderr.trim()})` : "";
189
+ pending.reject(new Error(`${message.error}${detail}`));
190
+ }
191
+ }
192
+ }
193
+
194
+ #failAll(error: Error): void {
195
+ for (const pending of this.#pending.values()) {
196
+ clearTimeout(pending.timer);
197
+ pending.reject(error);
198
+ }
199
+ this.#pending.clear();
200
+ }
201
+ }
202
+
203
+ // AgentToolResult.content accepts text and base64 image blocks; the bridge
204
+ // child already reduces MCP content to exactly those two shapes.
205
+ function toAgentToolResult(result: unknown): AgentToolResult {
206
+ const { content, details } = result as {
207
+ content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
208
+ details?: unknown;
209
+ };
210
+ return {
211
+ content: content.map((item) =>
212
+ item.type === "text"
213
+ ? { type: "text" as const, text: item.text ?? "" }
214
+ : { type: "image" as const, data: item.data ?? "", mimeType: item.mimeType ?? "" },
215
+ ),
216
+ details,
217
+ };
218
+ }
219
+
220
+ // omp rebinds extensions to ephemeral side-turn runtimes (e.g. title
221
+ // generation) whose action methods are uninitialized stubs that throw; bridge
222
+ // startup is skipped there and the real session starts it later.
223
+ function runtimeUninitialized(pi: ExtensionAPI): boolean {
224
+ try {
225
+ pi.getAllTools();
226
+ return false;
227
+ } catch (error) {
228
+ if (error instanceof Error && error.message.includes("Extension runtime not initialized")) return true;
229
+ throw error;
230
+ }
231
+ }
232
+
233
+ function existingToolNames(pi: ExtensionAPI): Set<string> {
234
+ return new Set(pi.getAllTools().map((tool) => tool.name));
235
+ }
236
+
237
+ export default function cairnMemoryExtension(pi: ExtensionAPI): void {
238
+ let client: BridgeChildClient | undefined;
239
+ let startPromise: Promise<void> | undefined;
240
+ let shutdownPromise: Promise<void> | undefined;
241
+
242
+ const start = async (ctx: ExtensionContext): Promise<void> => {
243
+ const connected = BridgeChildClient.spawn(ctx.cwd);
244
+ try {
245
+ await connected.ready();
246
+ const tools = (await connected.request({ type: "list" }, STARTUP_TIMEOUT_MS)) as CatalogTool[];
247
+ const existing = existingToolNames(pi);
248
+ const collisions = tools.map(({ name }) => name).filter((name) => existing.has(name));
249
+ if (collisions.length) {
250
+ throw new Error(`Cairnkeep MCP tool collision would override an existing omp tool: ${collisions.join(", ")}.`);
251
+ }
252
+ for (const tool of tools) {
253
+ pi.registerTool({
254
+ name: tool.name,
255
+ label: tool.title ?? tool.name,
256
+ description: tool.description ?? tool.title ?? tool.name,
257
+ // omp accepts a plain JSON Schema document (TJsonSchema) for
258
+ // extension-authored tools; MCP inputSchema is already one.
259
+ parameters: tool.inputSchema as ToolDefinition["parameters"],
260
+ strict: false,
261
+ execute: async (_toolCallId, args, signal) =>
262
+ toAgentToolResult(await connected.call(tool, args as Record<string, unknown>, signal)),
263
+ });
264
+ }
265
+ client = connected;
266
+ } catch (error) {
267
+ await connected.close().catch(() => undefined);
268
+ throw error;
269
+ }
270
+ };
271
+
272
+ pi.on("session_start", async (_event, ctx: ExtensionContext) => {
273
+ if (shutdownPromise) throw new Error("Cairnkeep MCP bridge is already shutting down.");
274
+ if (runtimeUninitialized(pi)) return;
275
+ if (!startPromise) startPromise = start(ctx);
276
+ await startPromise;
277
+ });
278
+
279
+ pi.on("session_shutdown", async () => {
280
+ if (!shutdownPromise) {
281
+ shutdownPromise = (async () => {
282
+ await startPromise?.catch(() => undefined);
283
+ await client?.close();
284
+ })();
285
+ }
286
+ await shutdownPromise;
287
+ });
288
+ }
package/package.json CHANGED
@@ -1,78 +1,79 @@
1
1
  {
2
- "name": "@cairnkeep/cli",
3
- "version": "2.17.2",
4
- "description": "A durable, harness-agnostic memory + context layer for coding agents and MCP clients.",
5
- "license": "Apache-2.0",
6
- "type": "module",
7
- "publishConfig": {
8
- "access": "public"
9
- },
10
- "bin": {
11
- "cairn": "bin/cairn",
12
- "cairn-container": "bin/cairn-container"
13
- },
14
- "files": [
15
- "bin/",
16
- "scripts/",
17
- "templates/",
18
- "claude/",
19
- "opencode/",
20
- "pi/",
21
- "kimi/",
22
- "examples/anythingllm/",
23
- "examples/overlay-distribution/",
24
- "examples/eval/",
25
- "containers/",
26
- "schemas/",
27
- "docs/",
28
- "CHANGELOG.md",
29
- "CONTRIBUTING.md",
30
- "SECURITY.md",
31
- "mcp-memory-server/dist/",
32
- "mcp-memory-server/scripts/fixtures/compaction-*.json",
33
- "mcp-memory-server/package.json",
34
- "AGENTS.md"
35
- ],
36
- "engines": {
37
- "node": ">=22"
38
- },
39
- "dependencies": {
40
- "@modelcontextprotocol/sdk": "^1.30.0",
41
- "agentfs-sdk": "^0.6.4",
42
- "yaml": "2.9.0",
43
- "zod": "^4.4.3"
44
- },
45
- "scripts": {
46
- "build:server": "npm --prefix mcp-memory-server ci --include=dev --global=false --dry-run=false && npm --prefix mcp-memory-server run build --global=false --dry-run=false",
47
- "fix:perms": "node scripts/fix-permissions.mjs",
48
- "prepack": "npm run build:server && npm run fix:perms",
49
- "test": "node scripts/run-repository-tests.mjs",
50
- "test:windows": "node scripts/test-windows-native.mjs",
51
- "audit:runtime": "node scripts/verify-dependency-security.mjs && npm audit --omit=dev --audit-level=high && npm --prefix mcp-memory-server audit --omit=dev --audit-level=high",
52
- "check:public": "scripts/verify-no-private-references.sh && scripts/verify-docs-parity.sh && npm run audit:runtime && npm run build:server && npm test",
53
- "prepublishOnly": "npm run check:public && npm --prefix mcp-memory-server test --global=false --dry-run=false"
54
- },
55
- "repository": {
56
- "type": "git",
57
- "url": "git+https://github.com/cairnkeep/cairnkeep.git"
58
- },
59
- "homepage": "https://github.com/cairnkeep/cairnkeep#readme",
60
- "bugs": {
61
- "url": "https://github.com/cairnkeep/cairnkeep/issues"
62
- },
63
- "keywords": [
64
- "mcp",
65
- "memory",
66
- "agent",
67
- "claude-code",
68
- "codex-cli",
69
- "opencode",
70
- "qwen-code",
71
- "pi-coding-agent",
72
- "context",
73
- "rag"
74
- ],
75
- "devDependencies": {
76
- "@cyclonedx/cyclonedx-npm": "6.0.1"
77
- }
2
+ "name": "@cairnkeep/cli",
3
+ "version": "2.18.0",
4
+ "description": "A durable, harness-agnostic memory + context layer for coding agents and MCP clients.",
5
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "publishConfig": {
8
+ "access": "public"
9
+ },
10
+ "bin": {
11
+ "cairn": "bin/cairn",
12
+ "cairn-container": "bin/cairn-container"
13
+ },
14
+ "files": [
15
+ "bin/",
16
+ "scripts/",
17
+ "templates/",
18
+ "claude/",
19
+ "opencode/",
20
+ "pi/",
21
+ "kimi/",
22
+ "omp/",
23
+ "examples/anythingllm/",
24
+ "examples/overlay-distribution/",
25
+ "examples/eval/",
26
+ "containers/",
27
+ "schemas/",
28
+ "docs/",
29
+ "CHANGELOG.md",
30
+ "CONTRIBUTING.md",
31
+ "SECURITY.md",
32
+ "mcp-memory-server/dist/",
33
+ "mcp-memory-server/scripts/fixtures/compaction-*.json",
34
+ "mcp-memory-server/package.json",
35
+ "AGENTS.md"
36
+ ],
37
+ "engines": {
38
+ "node": ">=22"
39
+ },
40
+ "dependencies": {
41
+ "@modelcontextprotocol/sdk": "^1.30.0",
42
+ "agentfs-sdk": "^0.6.4",
43
+ "yaml": "2.9.0",
44
+ "zod": "^4.4.3"
45
+ },
46
+ "scripts": {
47
+ "build:server": "npm --prefix mcp-memory-server ci --include=dev --global=false --dry-run=false && npm --prefix mcp-memory-server run build --global=false --dry-run=false",
48
+ "fix:perms": "node scripts/fix-permissions.mjs",
49
+ "prepack": "npm run build:server && npm run fix:perms",
50
+ "test": "node scripts/run-repository-tests.mjs",
51
+ "test:windows": "node scripts/test-windows-native.mjs",
52
+ "audit:runtime": "node scripts/verify-dependency-security.mjs && npm audit --omit=dev --audit-level=high && npm --prefix mcp-memory-server audit --omit=dev --audit-level=high",
53
+ "check:public": "scripts/verify-no-private-references.sh && scripts/verify-docs-parity.sh && npm run audit:runtime && npm run build:server && npm test",
54
+ "prepublishOnly": "npm run check:public && npm --prefix mcp-memory-server test --global=false --dry-run=false"
55
+ },
56
+ "repository": {
57
+ "type": "git",
58
+ "url": "git+https://github.com/cairnkeep/cairnkeep.git"
59
+ },
60
+ "homepage": "https://github.com/cairnkeep/cairnkeep#readme",
61
+ "bugs": {
62
+ "url": "https://github.com/cairnkeep/cairnkeep/issues"
63
+ },
64
+ "keywords": [
65
+ "mcp",
66
+ "memory",
67
+ "agent",
68
+ "claude-code",
69
+ "codex-cli",
70
+ "opencode",
71
+ "qwen-code",
72
+ "pi-coding-agent",
73
+ "context",
74
+ "rag"
75
+ ],
76
+ "devDependencies": {
77
+ "@cyclonedx/cyclonedx-npm": "6.0.1"
78
+ }
78
79
  }
@@ -30,6 +30,7 @@ const POSIX_COMMANDS = new Map([
30
30
  ["sync", "sync-claude-assets.sh"],
31
31
  ["sync-pi", "sync-pi-assets.sh"],
32
32
  ["sync-kimi", "sync-kimi-assets.sh"],
33
+ ["sync-omp", "sync-omp-assets.sh"],
33
34
  ["audit-timer", "install-audit-timer.sh"],
34
35
  ["completion", "completion.sh"],
35
36
  ]);
@@ -44,6 +45,7 @@ Usage:
44
45
  cairn sync [--check|--apply] [--live-root DIR]
45
46
  cairn sync-pi [--check|--apply] [--live-root DIR]
46
47
  cairn sync-kimi [--check|--apply] [--live-root DIR]
48
+ cairn sync-omp [--check|--apply] [--live-root DIR]
47
49
  cairn doctor [--repair]
48
50
  cairn trajectory <list|show|prune>
49
51
  cairn proposals <create|list|show|apply|doctor>
@@ -13,7 +13,7 @@ _cairn_complete() {
13
13
  COMPREPLY=()
14
14
  current=${COMP_WORDS[COMP_CWORD]}
15
15
  previous=${COMP_WORDS[COMP_CWORD-1]:-}
16
- commands="bootstrap setup memory-server sync sync-pi sync-kimi doctor trajectory artifact evidence playbook capabilities mcp-tools pack proposals notes eval skill graph memory audit-timer uninstall completion version help"
16
+ commands="bootstrap setup memory-server sync sync-pi sync-kimi sync-omp doctor trajectory artifact evidence playbook capabilities mcp-tools pack proposals notes eval skill graph memory audit-timer uninstall completion version help"
17
17
  if (( COMP_CWORD == 1 )); then
18
18
  COMPREPLY=( $(compgen -W "$commands" -- "$current") )
19
19
  return
@@ -24,6 +24,7 @@ _cairn_complete() {
24
24
  sync) COMPREPLY=( $(compgen -W "--apply --live-root" -- "$current") ) ;;
25
25
  sync-pi) COMPREPLY=( $(compgen -W "--apply --live-root" -- "$current") ) ;;
26
26
  sync-kimi) COMPREPLY=( $(compgen -W "--apply --live-root" -- "$current") ) ;;
27
+ sync-omp) COMPREPLY=( $(compgen -W "--apply --live-root" -- "$current") ) ;;
27
28
  doctor) COMPREPLY=( $(compgen -W "--repair" -- "$current") ) ;;
28
29
  trajectory) COMPREPLY=( $(compgen -W "list show prune --json --dry-run" -- "$current") ) ;;
29
30
  artifact) COMPREPLY=( $(compgen -W "list show delete prune --kind --session --json --dry-run --include-protected" -- "$current") ) ;;
@@ -94,6 +95,7 @@ _cairn() {
94
95
  'sync:install the Claude operating layer'
95
96
  'sync-pi:install the Pi trajectory extension and graph prompt'
96
97
  'sync-kimi:install the Kimi graph Skill'
98
+ 'sync-omp:install the oh-my-pi memory extension'
97
99
  'doctor:check runtime dependencies and endpoints'
98
100
  'trajectory:inspect and prune local session trajectories'
99
101
  'artifact:inspect, delete, and prune local artifacts'
@@ -124,6 +126,7 @@ _cairn() {
124
126
  sync) _arguments '--apply[apply changes]' '--live-root[project Claude root]:directory:_files -/' ;;
125
127
  sync-pi) _arguments '--apply[apply changes]' '--live-root[Pi agent root]:directory:_files -/' ;;
126
128
  sync-kimi) _arguments '--apply[apply changes]' '--live-root[Kimi Code root]:directory:_files -/' ;;
129
+ sync-omp) _arguments '--apply[apply changes]' '--live-root[omp agent root]:directory:_files -/' ;;
127
130
  doctor) _arguments '--repair[repair trajectory metadata and indexes]' ;;
128
131
  trajectory) _values 'trajectory command' list show prune ;;
129
132
  artifact)
@@ -188,8 +191,8 @@ EOF
188
191
  fish)
189
192
  sed "s/__CAIRN_HARNESSES__/$harnesses/g" <<'EOF'
190
193
  complete -c cairn -f
191
- for command in bootstrap setup memory-server sync sync-pi sync-kimi doctor trajectory artifact evidence playbook capabilities mcp-tools pack proposals notes eval skill graph memory audit-timer uninstall completion version help
192
- complete -c cairn -n "not __fish_seen_subcommand_from bootstrap setup memory-server sync sync-pi sync-kimi doctor trajectory artifact evidence playbook capabilities mcp-tools pack proposals notes eval skill graph memory audit-timer uninstall completion version help" -a $command
194
+ for command in bootstrap setup memory-server sync sync-pi sync-kimi sync-omp doctor trajectory artifact evidence playbook capabilities mcp-tools pack proposals notes eval skill graph memory audit-timer uninstall completion version help
195
+ complete -c cairn -n "not __fish_seen_subcommand_from bootstrap setup memory-server sync sync-pi sync-kimi sync-omp doctor trajectory artifact evidence playbook capabilities mcp-tools pack proposals notes eval skill graph memory audit-timer uninstall completion version help" -a $command
193
196
  end
194
197
  complete -c cairn -n "__fish_seen_subcommand_from sync" -l apply
195
198
  complete -c cairn -n "__fish_seen_subcommand_from sync" -l live-root -r
@@ -197,6 +200,8 @@ complete -c cairn -n "__fish_seen_subcommand_from sync-pi" -l apply
197
200
  complete -c cairn -n "__fish_seen_subcommand_from sync-pi" -l live-root -r
198
201
  complete -c cairn -n "__fish_seen_subcommand_from sync-kimi" -l apply
199
202
  complete -c cairn -n "__fish_seen_subcommand_from sync-kimi" -l live-root -r
203
+ complete -c cairn -n "__fish_seen_subcommand_from sync-omp" -l apply
204
+ complete -c cairn -n "__fish_seen_subcommand_from sync-omp" -l live-root -r
200
205
  complete -c cairn -n "__fish_seen_subcommand_from bootstrap" -l untracked
201
206
  complete -c cairn -n "__fish_seen_subcommand_from setup" -l git -r -a "init existing none"
202
207
  complete -c cairn -n "__fish_seen_subcommand_from setup" -l harness -r -a "__CAIRN_HARNESSES__"
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env node
2
+ // omp capture staging child for the cairnkeep-capture extension.
3
+ //
4
+ // Reads transcript text on stdin, runs the shared extraction
5
+ // (`node <server-entry> extract <model>`, stdin-piped — transcript text is
6
+ // never interpolated into a shell line), and stages the candidate JSON under
7
+ // <repo>/.planning/memory-staging/ with the exact same filename, JSON, and
8
+ // retention-cap-5 contract as stageCandidates in opencode/plugins/memory-capture.ts.
9
+ //
10
+ // Invoked detached + unref'd by the extension so staging completes even when
11
+ // the agent process exits before extraction returns. Fail-open: always exits 0.
12
+ //
13
+ // Usage: node omp-capture-stage.mjs <repo> <server-entry> <model>
14
+
15
+ import { spawn } from "node:child_process";
16
+ import { mkdirSync, readdirSync, writeFileSync, unlinkSync, statSync, existsSync } from "node:fs";
17
+ import { join } from "node:path";
18
+
19
+ const RETENTION_CAP = 5;
20
+ const EXTRACT_TIMEOUT_MS = 120_000;
21
+
22
+ const [, , repo, serverEntry, model] = process.argv;
23
+
24
+ function stageCandidates(repoRoot, candidatesJson) {
25
+ const stagingDir = join(repoRoot, ".planning", "memory-staging");
26
+ mkdirSync(stagingDir, { recursive: true });
27
+ const ts = new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z");
28
+ const stageFile = join(stagingDir, `${ts}.json`);
29
+ writeFileSync(stageFile, `${candidatesJson}\n`);
30
+
31
+ // Keep the staging dir bounded — drop the oldest beyond 5 sessions.
32
+ const staged = readdirSync(stagingDir)
33
+ .filter((f) => f.endsWith(".json"))
34
+ .map((f) => ({ f, mtime: statSync(join(stagingDir, f)).mtimeMs }))
35
+ .sort((a, b) => b.mtime - a.mtime);
36
+ for (const { f } of staged.slice(RETENTION_CAP)) {
37
+ unlinkSync(join(stagingDir, f));
38
+ }
39
+ return stageFile;
40
+ }
41
+
42
+ function runExtract(entry, extractModel, input) {
43
+ return new Promise((resolvePromise) => {
44
+ let stdout = "";
45
+ let settled = false;
46
+ const finish = () => {
47
+ if (settled) return;
48
+ settled = true;
49
+ clearTimeout(timer);
50
+ resolvePromise(stdout);
51
+ };
52
+ const child = spawn("node", [entry, "extract", extractModel], { stdio: ["pipe", "pipe", "ignore"] });
53
+ const timer = setTimeout(() => {
54
+ try { child.kill("SIGKILL"); } catch { /* best-effort kill only */ }
55
+ }, EXTRACT_TIMEOUT_MS);
56
+ child.stdout?.on("data", (chunk) => {
57
+ stdout += chunk.toString("utf8");
58
+ if (stdout.length > 1024 * 1024) {
59
+ stdout = "";
60
+ try { child.kill("SIGKILL"); } catch { /* best-effort kill only */ }
61
+ }
62
+ });
63
+ child.on("close", finish);
64
+ child.on("error", finish);
65
+ child.stdin.on("error", () => {
66
+ // EPIPE after a failed/terminated child is part of the fail-open path.
67
+ });
68
+ child.stdin.write(input);
69
+ child.stdin.end();
70
+ });
71
+ }
72
+
73
+ async function main() {
74
+ if (!repo || !serverEntry || !model || !existsSync(serverEntry)) return;
75
+ let input = "";
76
+ try {
77
+ for await (const chunk of process.stdin) input += chunk.toString("utf8");
78
+ } catch {
79
+ return;
80
+ }
81
+ input = input.trim();
82
+ if (!input) return;
83
+
84
+ const candidatesJson = (await runExtract(serverEntry, model, input)).trim();
85
+ if (!candidatesJson) return;
86
+
87
+ let parsed;
88
+ try {
89
+ parsed = JSON.parse(candidatesJson);
90
+ } catch {
91
+ return;
92
+ }
93
+ if (!Array.isArray(parsed.candidates) || parsed.candidates.length === 0) return;
94
+
95
+ stageCandidates(repo, candidatesJson);
96
+ }
97
+
98
+ try {
99
+ await main();
100
+ } catch {
101
+ // Fail open — staging is best-effort.
102
+ }
103
+ process.exit(0);