@cairnkeep/cli 2.17.3 → 2.19.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.
- package/CHANGELOG.md +41 -1
- package/CONTRIBUTING.md +1 -1
- package/docs/harness-compatibility.md +48 -0
- package/docs/learning/CURRICULUM-MAP.md +2 -1
- package/docs/learning/FEATURE-GUIDE.md +2 -1
- package/docs/learning/lessons/L00-why-cairnkeep.md +1 -1
- package/docs/learning/lessons/L01-safe-trial.md +1 -1
- package/docs/learning/lessons/L02-installation.md +1 -1
- package/docs/learning/lessons/L03-first-project.md +1 -1
- package/docs/learning/lessons/L19-validated-skill-improvement.md +1 -1
- package/docs/learning/lessons/L20-mcp-tool-profiles.md +1 -1
- package/docs/learning/lessons/L21-context-packs.md +1 -1
- package/docs/learning/lessons/L22-native-windows.md +1 -1
- package/docs/learning/lessons/L23-guided-setup.md +18 -7
- package/docs/learning/lessons/L24-okf-exchange.md +1 -1
- package/docs/learning/lessons/L25-playbooks.md +1 -1
- package/docs/operating.md +13 -6
- package/examples/eval/bundled-fake.json +1 -1
- package/mcp-memory-server/dist/artifact-schema.js +1 -1
- package/mcp-memory-server/dist/pi-bridge-child.js +109 -0
- package/mcp-memory-server/dist/pi-mcp-bridge.js +15 -3
- package/mcp-memory-server/dist/work-evidence-schema.js +1 -1
- package/mcp-memory-server/dist/work-evidence-store.js +1 -1
- package/mcp-memory-server/package.json +5 -3
- package/omp/extensions/cairnkeep-capture.ts +226 -0
- package/omp/extensions/cairnkeep-memory.ts +288 -0
- package/package.json +77 -76
- package/schemas/artifact.schema.json +1 -1
- package/schemas/cairnkeep-setup-policy.schema.json +3 -3
- package/schemas/cairnkeep-setup.schema.json +1 -1
- package/scripts/cairn-cli.mjs +2 -0
- package/scripts/completion.sh +8 -3
- package/scripts/harness-registry.mjs +15 -3
- package/scripts/lib/omp-capture-stage.mjs +103 -0
- package/scripts/setup.mjs +10 -5
- package/scripts/sync-omp-assets.sh +96 -0
- package/scripts/test-bootstrap-untracked.sh +1 -1
- package/scripts/test-cli-dispatch.sh +2 -0
- package/scripts/test-completion.sh +4 -2
- package/scripts/test-harness-registry.sh +28 -1
- package/scripts/test-setup-output.sh +20 -1
- package/scripts/test-setup-preflight.mjs +1 -1
- package/scripts/test-work-evidence-cli.sh +1 -1
- package/scripts/uninstall.sh +1 -0
- package/scripts/verify-omp-capture.mjs +370 -0
- package/scripts/verify-omp-mcp-bridge.mjs +333 -0
- package/templates/start-omp.sh.template +47 -0
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@oh-my-pi/pi-coding-agent";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
// omp port of the claude/hooks memory-capture + memory-wakeup pair (D-03/D-04,
|
|
7
|
+
// OCP-01/OCP-05 contracts).
|
|
8
|
+
//
|
|
9
|
+
// CAPTURE: on the first `session_stop` of a main session (the event never
|
|
10
|
+
// fires for task/subagent sessions and is awaited while the process is fully
|
|
11
|
+
// alive), the session branch is converted to transcript text and piped to a
|
|
12
|
+
// DETACHED, unref'd `node scripts/lib/omp-capture-stage.mjs` child that runs
|
|
13
|
+
// the shared `extract` subcommand and stages candidates under
|
|
14
|
+
// .planning/memory-staging/ — so staging survives even if the agent process
|
|
15
|
+
// exits before the extraction call returns. Extraction candidates are staged
|
|
16
|
+
// for review only; writes to AgentFS stay agent-gated via the memory_* tools
|
|
17
|
+
// from cairnkeep-memory.ts.
|
|
18
|
+
//
|
|
19
|
+
// WAKEUP: on `session_start` of the main session (and only when the extension
|
|
20
|
+
// runtime is initialized — omp's title-generation ephemeral agents rebind
|
|
21
|
+
// extensions with uninitialized action stubs), staged candidates are surfaced
|
|
22
|
+
// via a UI notification and a `deliverAs: "nextTurn"` context message injected
|
|
23
|
+
// at the next real user turn. `/cairn-staged` lists staged files on demand.
|
|
24
|
+
//
|
|
25
|
+
// Fail-open everywhere: every guard is a cheap check and every path is
|
|
26
|
+
// try/caught so a throw can never reach the extension runner.
|
|
27
|
+
const CAIRN_ROOT = "@@INFRA_ROOT@@";
|
|
28
|
+
const SERVER_ENTRY = join(CAIRN_ROOT, "mcp-memory-server", "dist", "index.js");
|
|
29
|
+
const STAGE_ENTRY = join(CAIRN_ROOT, "scripts", "lib", "omp-capture-stage.mjs");
|
|
30
|
+
|
|
31
|
+
const MAX_TEXT_CHARS = 12000;
|
|
32
|
+
const STAGED_MAX_FILES = 5;
|
|
33
|
+
const STAGED_FILE_READ_CAP = 8192;
|
|
34
|
+
const STAGED_CUSTOM_TYPE = "sh.cairnkeep.staged-memory";
|
|
35
|
+
|
|
36
|
+
// Minimal structural view of omp's persisted SessionEntry (entry.type
|
|
37
|
+
// "message", camelCase role per docs/extensions.md); anything else is skipped.
|
|
38
|
+
type BranchEntry = {
|
|
39
|
+
type?: string;
|
|
40
|
+
message?: {
|
|
41
|
+
role?: string;
|
|
42
|
+
content?: unknown;
|
|
43
|
+
};
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
type SessionStopLike = {
|
|
47
|
+
type: "session_stop";
|
|
48
|
+
session_id?: string;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
function transcriptText(branch: BranchEntry[]): string {
|
|
52
|
+
const turns: string[] = [];
|
|
53
|
+
for (const entry of branch) {
|
|
54
|
+
if (entry?.type !== "message") continue;
|
|
55
|
+
const role = entry.message?.role;
|
|
56
|
+
if (role !== "user" && role !== "assistant") continue;
|
|
57
|
+
if (!Array.isArray(entry.message?.content)) continue;
|
|
58
|
+
const text = (entry.message.content as Array<{ type?: string; text?: unknown }>)
|
|
59
|
+
.filter((block) => block && block.type === "text" && typeof block.text === "string")
|
|
60
|
+
.map((block) => block.text as string)
|
|
61
|
+
.join("\n")
|
|
62
|
+
.trim();
|
|
63
|
+
if (!text) continue;
|
|
64
|
+
turns.push(`${role === "user" ? "USER" : "ASSISTANT"}: ${text}`);
|
|
65
|
+
}
|
|
66
|
+
let out = turns.join("\n\n");
|
|
67
|
+
if (out.length > MAX_TEXT_CHARS) out = out.slice(-MAX_TEXT_CHARS);
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Same detection as cairnkeep-memory.ts: omp rebinds extensions to ephemeral
|
|
72
|
+
// side-turn runtimes whose action methods are uninitialized throwing stubs.
|
|
73
|
+
function runtimeUninitialized(pi: ExtensionAPI): boolean {
|
|
74
|
+
try {
|
|
75
|
+
pi.getAllTools();
|
|
76
|
+
return false;
|
|
77
|
+
} catch (error) {
|
|
78
|
+
if (error instanceof Error && error.message.includes("Extension runtime not initialized")) return true;
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
type StagedCandidate = {
|
|
84
|
+
file: string;
|
|
85
|
+
count: number;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// Bounded staged-candidate scan: the STAGED_MAX_FILES most recent *.json
|
|
89
|
+
// files, each read capped, malformed JSON skipped (fail-open).
|
|
90
|
+
function stagedCandidates(repo: string): StagedCandidate[] {
|
|
91
|
+
const stagingDir = join(repo, ".planning", "memory-staging");
|
|
92
|
+
if (!existsSync(stagingDir)) return [];
|
|
93
|
+
const files = readdirSync(stagingDir)
|
|
94
|
+
.filter((name) => name.endsWith(".json"))
|
|
95
|
+
.map((name) => ({ name, mtime: statSync(join(stagingDir, name)).mtimeMs }))
|
|
96
|
+
.sort((a, b) => b.mtime - a.mtime)
|
|
97
|
+
.slice(0, STAGED_MAX_FILES)
|
|
98
|
+
.map(({ name }) => name);
|
|
99
|
+
const staged: StagedCandidate[] = [];
|
|
100
|
+
for (const file of files) {
|
|
101
|
+
try {
|
|
102
|
+
const path = join(stagingDir, file);
|
|
103
|
+
if (statSync(path).size > STAGED_FILE_READ_CAP) continue;
|
|
104
|
+
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
|
|
105
|
+
const count = Array.isArray((parsed as { candidates?: unknown })?.candidates)
|
|
106
|
+
? ((parsed as { candidates: unknown[] }).candidates.length)
|
|
107
|
+
: 0;
|
|
108
|
+
if (count > 0) staged.push({ file, count });
|
|
109
|
+
} catch {
|
|
110
|
+
// Malformed staged file — skip it, never wedge session start.
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return staged;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export default function cairnCaptureExtension(pi: ExtensionAPI): void {
|
|
117
|
+
const capturedSessions = new Set<string>();
|
|
118
|
+
|
|
119
|
+
pi.on("session_stop", async (event: SessionStopLike, ctx: ExtensionContext) => {
|
|
120
|
+
try {
|
|
121
|
+
if (ctx.agent?.kind !== "main") return;
|
|
122
|
+
const sessionId = typeof event?.session_id === "string" ? event.session_id : "";
|
|
123
|
+
// One capture attempt per session, no matter how many times
|
|
124
|
+
// session_stop fires for it (continuation cap).
|
|
125
|
+
if (!sessionId || capturedSessions.has(sessionId)) return;
|
|
126
|
+
|
|
127
|
+
const repo = ctx.cwd;
|
|
128
|
+
// Cheap guards first, mirroring the claude hook's ordering.
|
|
129
|
+
if (!existsSync(join(repo, ".agentfs", "project.db"))) return;
|
|
130
|
+
if (!existsSync(SERVER_ENTRY)) return;
|
|
131
|
+
if (!existsSync(STAGE_ENTRY)) return;
|
|
132
|
+
const apiKey = process.env.CAIRN_LLM_API_KEY;
|
|
133
|
+
const model = process.env.CAIRN_LLM_EXTRACTION_MODEL;
|
|
134
|
+
if (!apiKey || !model) return;
|
|
135
|
+
|
|
136
|
+
// Mark before the async boundary so a re-fire never double-stages.
|
|
137
|
+
capturedSessions.add(sessionId);
|
|
138
|
+
|
|
139
|
+
let text = "";
|
|
140
|
+
try {
|
|
141
|
+
text = transcriptText(ctx.sessionManager.getBranch() as BranchEntry[]);
|
|
142
|
+
} catch {
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (!text) return;
|
|
146
|
+
|
|
147
|
+
// Detached + unref'd: the staging child outlives the agent process,
|
|
148
|
+
// so the result lands even if omp exits right after settle.
|
|
149
|
+
const child = spawn(
|
|
150
|
+
process.execPath,
|
|
151
|
+
[STAGE_ENTRY, repo, SERVER_ENTRY, model],
|
|
152
|
+
{ detached: true, stdio: ["pipe", "ignore", "ignore"] },
|
|
153
|
+
);
|
|
154
|
+
child.on("error", () => undefined);
|
|
155
|
+
child.stdin.on("error", () => undefined);
|
|
156
|
+
child.stdin.write(text);
|
|
157
|
+
child.stdin.end();
|
|
158
|
+
child.unref();
|
|
159
|
+
} catch {
|
|
160
|
+
// Fail open — never block session settle because capture failed.
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
pi.on("session_start", async (_event, ctx: ExtensionContext) => {
|
|
165
|
+
try {
|
|
166
|
+
if (ctx.agent?.kind !== "main") return;
|
|
167
|
+
if (runtimeUninitialized(pi)) return;
|
|
168
|
+
const staged = stagedCandidates(ctx.cwd);
|
|
169
|
+
if (staged.length === 0) return;
|
|
170
|
+
|
|
171
|
+
const notice =
|
|
172
|
+
`${staged.length} memory candidate file(s) staged for review in ` +
|
|
173
|
+
`.planning/memory-staging/ (${staged.reduce((total, { count }) => total + count, 0)} candidate(s) from ` +
|
|
174
|
+
"the previous session's transcript). Review them and write accepted ones with the memory_write tool " +
|
|
175
|
+
"(agent-gated), then remove or rename the staged file. Do not write to AgentFS without that review.";
|
|
176
|
+
if (ctx.hasUI) ctx.ui.notify(notice, "info");
|
|
177
|
+
pi.sendMessage(
|
|
178
|
+
{
|
|
179
|
+
customType: STAGED_CUSTOM_TYPE,
|
|
180
|
+
content: notice,
|
|
181
|
+
attribution: "agent",
|
|
182
|
+
display: false,
|
|
183
|
+
},
|
|
184
|
+
{ deliverAs: "nextTurn" },
|
|
185
|
+
);
|
|
186
|
+
} catch {
|
|
187
|
+
// Fail open — never block session start because wakeup failed.
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
pi.registerCommand("cairn-staged", {
|
|
192
|
+
description: "List staged memory candidates awaiting review",
|
|
193
|
+
handler: async (_args, ctx: ExtensionContext) => {
|
|
194
|
+
try {
|
|
195
|
+
const stagingDir = join(ctx.cwd, ".planning", "memory-staging");
|
|
196
|
+
if (!existsSync(stagingDir)) {
|
|
197
|
+
if (ctx.hasUI) ctx.ui.notify("No staged memory candidates (.planning/memory-staging is absent).", "info");
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const files = readdirSync(stagingDir)
|
|
201
|
+
.filter((name) => name.endsWith(".json"))
|
|
202
|
+
.map((name) => ({ name, mtime: statSync(join(stagingDir, name)).mtimeMs }))
|
|
203
|
+
.sort((a, b) => b.mtime - a.mtime)
|
|
204
|
+
.slice(0, STAGED_MAX_FILES)
|
|
205
|
+
.map(({ name }) => name);
|
|
206
|
+
if (files.length === 0) {
|
|
207
|
+
if (ctx.hasUI) ctx.ui.notify("No staged memory candidates.", "info");
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
const sections = files.map((file) => {
|
|
211
|
+
let body: string;
|
|
212
|
+
try {
|
|
213
|
+
body = readFileSync(join(stagingDir, file), "utf8").slice(0, STAGED_FILE_READ_CAP);
|
|
214
|
+
} catch {
|
|
215
|
+
body = "(unreadable)";
|
|
216
|
+
}
|
|
217
|
+
return `### ${file}\n${body}`;
|
|
218
|
+
});
|
|
219
|
+
const listing = `Staged memory candidates — review, then accept via memory_write or discard.\n\n${sections.join("\n\n")}`;
|
|
220
|
+
if (ctx.hasUI) ctx.ui.notify(listing, "info");
|
|
221
|
+
} catch {
|
|
222
|
+
// Fail open — command inspection is best-effort.
|
|
223
|
+
}
|
|
224
|
+
},
|
|
225
|
+
});
|
|
226
|
+
}
|
|
@@ -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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
"
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
"
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
"
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
"
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
"
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
"
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
"
|
|
77
|
-
|
|
2
|
+
"name": "@cairnkeep/cli",
|
|
3
|
+
"version": "2.19.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
|
}
|