@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.
- package/CHANGELOG.md +38 -1
- package/docs/harness-compatibility.md +47 -0
- package/docs/learning/CURRICULUM-MAP.md +2 -1
- package/docs/learning/FEATURE-GUIDE.md +1 -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 +1 -1
- package/docs/learning/lessons/L24-okf-exchange.md +1 -1
- package/docs/learning/lessons/L25-playbooks.md +1 -1
- package/docs/operating.md +2 -0
- package/examples/eval/bundled-fake.json +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/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/scripts/cairn-cli.mjs +2 -0
- package/scripts/completion.sh +8 -3
- package/scripts/lib/omp-capture-stage.mjs +103 -0
- package/scripts/sync-omp-assets.sh +96 -0
- package/scripts/test-cli-dispatch.sh +2 -0
- package/scripts/test-completion.sh +3 -1
- package/scripts/verify-omp-capture.mjs +370 -0
- package/scripts/verify-omp-mcp-bridge.mjs +333 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,42 @@ All notable user-facing changes are documented here. This project follows
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [2.18.0] - 2026-09-28
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- oh-my-pi (omp) integration: `omp/extensions/cairnkeep-memory.ts` registers
|
|
13
|
+
the cairn-memory MCP tools as native omp tools (collision-guarded), and
|
|
14
|
+
`omp/extensions/cairnkeep-capture.ts` stages durable-memory candidates at
|
|
15
|
+
session stop and surfaces them on the next session start, with a
|
|
16
|
+
`/cairn-staged` review command. The bridge runs as a node child process so
|
|
17
|
+
it also works under compiled Bun standalone binaries.
|
|
18
|
+
- `cairn sync-omp` command, bash/zsh/fish completion entries, and
|
|
19
|
+
`verify-omp-mcp-bridge.mjs` / `verify-omp-capture.mjs` verification gates.
|
|
20
|
+
- Smoke gates for the pi-mcp-bridge child environment: Bun node-shim PATH
|
|
21
|
+
stripping, `MCP_HTTP_PORT` isolation, and the node child bridge protocol.
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- `pi-mcp-bridge` strips Bun `/tmp/bun-node-*` shim dirs from the stdio child
|
|
26
|
+
environment; grandchildren like `cairn memory-server` spawn a real node.
|
|
27
|
+
- Work-evidence smoke used fixed calendar dates that aged past the retention
|
|
28
|
+
window; timestamps are relative to the run.
|
|
29
|
+
|
|
30
|
+
### Documentation
|
|
31
|
+
|
|
32
|
+
- `docs/harness-compatibility.md`: oh-my-pi (omp) section.
|
|
33
|
+
- `docs/operating.md`: `CAIRN_PI_BRIDGE_COMMAND` / `CAIRN_PI_BRIDGE_ARGS`.
|
|
34
|
+
|
|
35
|
+
## [2.17.3] - 2026-09-03
|
|
36
|
+
|
|
37
|
+
### Security
|
|
38
|
+
|
|
39
|
+
- Refresh the production dependency locks to `fast-uri` 3.1.7 and `qs`
|
|
40
|
+
6.16.0 so published server and workspace containers do not retain the URI
|
|
41
|
+
parsing, SSRF, host-confusion, or denial-of-service advisories affecting the
|
|
42
|
+
versions locked by 2.17.2. Runtime APIs and command behavior are unchanged.
|
|
43
|
+
|
|
8
44
|
## [2.17.2] - 2026-09-02
|
|
9
45
|
|
|
10
46
|
### Fixed
|
|
@@ -525,7 +561,8 @@ stored memories.
|
|
|
525
561
|
- Preserve executable permissions and Bash 3.2 portability on macOS.
|
|
526
562
|
- Add backup-first uninstall and SQLite-safe memory export/import guidance.
|
|
527
563
|
|
|
528
|
-
[Unreleased]: https://github.com/cairnkeep/cairnkeep/compare/v2.
|
|
564
|
+
[Unreleased]: https://github.com/cairnkeep/cairnkeep/compare/v2.18.0...HEAD
|
|
565
|
+
[2.18.0]: https://github.com/cairnkeep/cairnkeep/compare/v2.17.3...v2.18.0
|
|
529
566
|
[2.15.1]: https://github.com/cairnkeep/cairnkeep/compare/v2.15.0...v2.15.1
|
|
530
567
|
[2.15.0]: https://github.com/cairnkeep/cairnkeep/compare/v2.14.0...v2.15.0
|
|
531
568
|
[2.14.0]: https://github.com/cairnkeep/cairnkeep/compare/v2.13.1...v2.14.0
|
|
@@ -19,6 +19,7 @@ Cairnkeep commands, hooks or native trajectory capture.
|
|
|
19
19
|
| Kimi Code | Memory MCP, `AGENTS.md`, launcher, opt-in graph and cairn-work Skills | Launcher tested; remote MCP tested with Kimi Code 0.30.0; Skills contract-tested |
|
|
20
20
|
| Qwen Code | Memory MCP, launcher, project `AGENTS.md` durable-context and playbook guidance | Launcher tested; stdio and remote MCP tested with Qwen Code 0.21.1 |
|
|
21
21
|
| Pi | Memory MCP through maintained local stdio extension, native opt-in trajectory extension, launcher, graph and cairn-work prompts | Pi 0.84.1 validated minimum; deterministic and real bridge/lifecycle tests |
|
|
22
|
+
| oh-my-pi (omp) | Memory MCP through maintained local stdio extension (Pi-successor API) | Smoke-tested against the local omp checkout with the deterministic bridge verification |
|
|
22
23
|
| Codex CLI | Project-scoped memory MCP, launcher, and project `AGENTS.md` durable-context and playbook guidance | Setup and launcher contract-tested on POSIX and simulated native Windows; project trust remains operator-controlled |
|
|
23
24
|
| Other MCP clients | Memory plus optional domain-knowledge and context-pack tools | Protocol-compatible; not automatically runtime-tested |
|
|
24
25
|
|
|
@@ -77,6 +78,52 @@ release, together with Node.js 22/24/26, Bash 3.2, and native Windows. The
|
|
|
77
78
|
executable paths were distinct; both reported 0.84.1 because that was also the
|
|
78
79
|
registry-current release.
|
|
79
80
|
|
|
81
|
+
## oh-my-pi (omp)
|
|
82
|
+
|
|
83
|
+
Run `cairn sync-omp --apply` explicitly to install
|
|
84
|
+
`extensions/cairnkeep-memory.ts` and `extensions/cairnkeep-capture.ts` under
|
|
85
|
+
the omp agent root (default
|
|
86
|
+
`~/.omp/agent`; `PI_CODING_AGENT_DIR` overrides it, matching omp's agent
|
|
87
|
+
directory resolution). Use `--check` for drift. Uninstall removes only that
|
|
88
|
+
owned path, backup-first.
|
|
89
|
+
|
|
90
|
+
The extension is the Pi memory extension ported to omp's successor extension
|
|
91
|
+
API: it supervises `cairn memory-server` as a local stdio child through the
|
|
92
|
+
same bridge, discovers the effective MCP catalog dynamically, and registers
|
|
93
|
+
each tool natively with omp under its bare MCP name. omp accepts the MCP input
|
|
94
|
+
schema as a plain JSON Schema document and normalizes results to its
|
|
95
|
+
`AgentToolResult` shape, preserving the trusted call metadata in `details`. A
|
|
96
|
+
`cairn-memory` entry in the omp MCP config coexists without collision:
|
|
97
|
+
config-served MCP tools are minted with an `mcp__` name prefix, so the two
|
|
98
|
+
surfaces never claim the same tool name. Server feature gates, capability
|
|
99
|
+
state, and least-authority profiles continue to decide which tools exist.
|
|
100
|
+
|
|
101
|
+
Startup, calls, results, stderr retention, cancellation, and shutdown are
|
|
102
|
+
bounded. The extension adds tools only: it does not run prompts, activate
|
|
103
|
+
context-pack skills, create an autonomous loop, or add a remote transport.
|
|
104
|
+
`bun scripts/verify-omp-mcp-bridge.mjs` renders, loads, and exercises the
|
|
105
|
+
installed extension against a supervised memory server, including catalog
|
|
106
|
+
parity, the result shape, cancellation, shutdown, orphan-freedom, and
|
|
107
|
+
tool-name collision refusal.
|
|
108
|
+
|
|
109
|
+
Sync also installs `extensions/cairnkeep-capture.ts`, the omp port of the
|
|
110
|
+
Claude SessionEnd/SessionStart `memory-capture` + `memory-wakeup` hook pair.
|
|
111
|
+
On the first `session_stop` of a main session (the event never fires for
|
|
112
|
+
task/subagent sessions), it converts the persisted branch to transcript text
|
|
113
|
+
(user/assistant text only, capped at 12000 characters keeping the most recent
|
|
114
|
+
turns) and pipes it to a detached staging child that runs the shared
|
|
115
|
+
extraction and writes candidates to `.planning/memory-staging/` — one file
|
|
116
|
+
per session, retention capped at 5 — so staging completes even if the agent
|
|
117
|
+
process exits first. Candidates are staged for review only: writes to AgentFS
|
|
118
|
+
stay agent-gated via the `memory_write` tool. On `session_start` of the main
|
|
119
|
+
session, staged candidates are surfaced through a UI notification and a
|
|
120
|
+
`deliverAs: "nextTurn"` context message injected at the next user turn; the
|
|
121
|
+
`/cairn-staged` command lists the staged files on demand. Both halves are
|
|
122
|
+
fail-open and skip ephemeral side-turn runtimes whose extension actions are
|
|
123
|
+
not initialized. `bun scripts/verify-omp-capture.mjs` exercises the guards,
|
|
124
|
+
transcript shaping, staging contract, retention cap, session de-dupe, wakeup
|
|
125
|
+
injection, and the skip paths.
|
|
126
|
+
|
|
80
127
|
## Kimi Code
|
|
81
128
|
|
|
82
129
|
Kimi Code reads three MCP files, in increasing precedence:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Curriculum Coverage Map
|
|
2
2
|
|
|
3
|
-
**Baseline:** Cairnkeep 2.
|
|
3
|
+
**Baseline:** Cairnkeep 2.18.0
|
|
4
4
|
**Last reviewed:** 2026-08-19
|
|
5
5
|
|
|
6
6
|
This map prevents the course from drifting behind the product. It assigns each
|
|
@@ -91,6 +91,7 @@ against `cairn help` so a new command cannot bypass a curriculum decision.
|
|
|
91
91
|
| `cairn sync` | L02 |
|
|
92
92
|
| `cairn sync-pi` | L13 and L23 |
|
|
93
93
|
| `cairn sync-kimi` | L02 |
|
|
94
|
+
| `cairn sync-omp` | L02 |
|
|
94
95
|
| `cairn doctor` | L03; advanced repair in L13-L14 |
|
|
95
96
|
| `cairn playbook` | L25 |
|
|
96
97
|
| `cairn trajectory` | L13 |
|
package/docs/operating.md
CHANGED
|
@@ -461,6 +461,8 @@ vendor or host.
|
|
|
461
461
|
| `CAIRN_LLM_API_KEY` | API key for the extraction / embeddings endpoint (unset → substring-only memory) |
|
|
462
462
|
| `CAIRN_LLM_API_URL` | Base URL of the OpenAI-compatible endpoint |
|
|
463
463
|
| `CAIRN_LLM_EXTRACTION_MODEL` | Chat model for `memory-capture` extraction |
|
|
464
|
+
| `CAIRN_PI_BRIDGE_COMMAND` | Memory-server command for the `pi-bridge-child` stdio host (default `cairn`) |
|
|
465
|
+
| `CAIRN_PI_BRIDGE_ARGS` | JSON argument array for the bridge child server command (default `["memory-server"]`) |
|
|
464
466
|
| `CAIRN_MEMORY_EMBEDDING_URL` | Embeddings endpoint (falls back to `CAIRN_LLM_API_URL`) |
|
|
465
467
|
| `CAIRN_MEMORY_EMBEDDING_MODEL` | Embedding model name (required for semantic search) |
|
|
466
468
|
| `CAIRN_MEMORY_EMBEDDING_TIMEOUT_MS` | Embedding request timeout before substring fallback (default `15000`) |
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"identifier":"cairn-offline-fake-v1","package_version":"2.
|
|
1
|
+
{"identifier":"cairn-offline-fake-v1","package_version":"2.18.0","schema_version":1,"task_set_digest":"13e95379f35839593047ec34521a65a8fa3ab92bd3993bca9dc8bf5c4a9ff36f"}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { childEnvironment, connectCairnPiBridge } from "./pi-mcp-bridge.js";
|
|
2
|
+
const STDERR_LIMIT_BYTES = 16 * 1024;
|
|
3
|
+
let stderrTail = "";
|
|
4
|
+
const stderrWrite = process.stderr.write.bind(process.stderr);
|
|
5
|
+
process.stderr.write = ((chunk, ...rest) => {
|
|
6
|
+
const text = typeof chunk === "string" ? chunk : String(chunk);
|
|
7
|
+
const next = stderrTail + text;
|
|
8
|
+
stderrTail = next.length <= STDERR_LIMIT_BYTES ? next : next.slice(next.length - STDERR_LIMIT_BYTES);
|
|
9
|
+
return stderrWrite(chunk, ...rest);
|
|
10
|
+
});
|
|
11
|
+
function send(message) {
|
|
12
|
+
process.stdout.write(`${JSON.stringify(message)}\n`);
|
|
13
|
+
}
|
|
14
|
+
function fatal(error) {
|
|
15
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
16
|
+
process.stderr.write(`${JSON.stringify({ type: "error", error: message })}\n`);
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
process.on("uncaughtException", fatal);
|
|
20
|
+
process.on("unhandledRejection", (reason) => fatal(reason));
|
|
21
|
+
async function main() {
|
|
22
|
+
const cwd = process.argv[2] ?? process.cwd();
|
|
23
|
+
const command = process.env.CAIRN_PI_BRIDGE_COMMAND ?? "cairn";
|
|
24
|
+
const args = JSON.parse(process.env.CAIRN_PI_BRIDGE_ARGS ?? '["memory-server"]');
|
|
25
|
+
const bridge = await connectCairnPiBridge({ cwd, command, args, env: childEnvironment(process.env) });
|
|
26
|
+
const controllers = new Map();
|
|
27
|
+
let stdinBuffer = "";
|
|
28
|
+
let closing = false;
|
|
29
|
+
const close = async () => {
|
|
30
|
+
if (closing)
|
|
31
|
+
return;
|
|
32
|
+
closing = true;
|
|
33
|
+
await bridge.close().catch(() => undefined);
|
|
34
|
+
process.exit(0);
|
|
35
|
+
};
|
|
36
|
+
process.on("SIGTERM", () => void close());
|
|
37
|
+
process.on("SIGINT", () => void close());
|
|
38
|
+
process.stdin.on("end", () => void close());
|
|
39
|
+
async function handle(request) {
|
|
40
|
+
try {
|
|
41
|
+
switch (request.type) {
|
|
42
|
+
case "list": {
|
|
43
|
+
send({ type: "result", id: request.id, ok: true, tools: await bridge.listAllTools() });
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
case "call": {
|
|
47
|
+
const controller = new AbortController();
|
|
48
|
+
controllers.set(request.id, controller);
|
|
49
|
+
try {
|
|
50
|
+
const result = await bridge.call(request.tool, request.args ?? {}, { signal: controller.signal });
|
|
51
|
+
send({ type: "result", id: request.id, ok: true, result });
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
send({
|
|
55
|
+
type: "result",
|
|
56
|
+
id: request.id,
|
|
57
|
+
ok: false,
|
|
58
|
+
error: error instanceof Error ? error.message : String(error),
|
|
59
|
+
stderr: stderrTail,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
finally {
|
|
63
|
+
controllers.delete(request.id);
|
|
64
|
+
}
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
case "cancel": {
|
|
68
|
+
controllers.get(request.id)?.abort(new Error("cancelled"));
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
case "close": {
|
|
72
|
+
await close();
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
const id = typeof request.id === "number" ? request.id : -1;
|
|
79
|
+
send({
|
|
80
|
+
type: "result",
|
|
81
|
+
id,
|
|
82
|
+
ok: false,
|
|
83
|
+
error: error instanceof Error ? error.message : String(error),
|
|
84
|
+
stderr: stderrTail,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
process.stdin.on("data", (chunk) => {
|
|
89
|
+
stdinBuffer += chunk.toString("utf8");
|
|
90
|
+
let newline = stdinBuffer.indexOf("\n");
|
|
91
|
+
while (newline >= 0) {
|
|
92
|
+
const line = stdinBuffer.slice(0, newline);
|
|
93
|
+
stdinBuffer = stdinBuffer.slice(newline + 1);
|
|
94
|
+
newline = stdinBuffer.indexOf("\n");
|
|
95
|
+
if (!line.trim())
|
|
96
|
+
continue;
|
|
97
|
+
let request;
|
|
98
|
+
try {
|
|
99
|
+
request = JSON.parse(line);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
void handle(request);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
send({ type: "ready" });
|
|
108
|
+
}
|
|
109
|
+
main().catch(fatal);
|
|
@@ -11,11 +11,23 @@ function positiveInteger(value, fallback, name) {
|
|
|
11
11
|
throw new Error(`${name} must be a positive integer.`);
|
|
12
12
|
return selected;
|
|
13
13
|
}
|
|
14
|
-
|
|
14
|
+
// Bun-hosted agents (pi, omp) prepend a /tmp/bun-node-* dir whose `node` is
|
|
15
|
+
// Bun itself; grandchildren like `cairn memory-server` spawn real node
|
|
16
|
+
// subprocesses, so those shim dirs must not shadow it.
|
|
17
|
+
const BUN_NODE_SHIM = /\/tmp\/bun-node-[^/:]+$/;
|
|
18
|
+
export function childEnvironment(source) {
|
|
15
19
|
const env = {};
|
|
16
20
|
for (const [name, value] of Object.entries(source)) {
|
|
17
|
-
if (value
|
|
18
|
-
|
|
21
|
+
if (value === undefined || name === "MCP_HTTP_PORT")
|
|
22
|
+
continue;
|
|
23
|
+
if (name === "PATH") {
|
|
24
|
+
env[name] = value
|
|
25
|
+
.split(":")
|
|
26
|
+
.filter((entry) => !BUN_NODE_SHIM.test(entry))
|
|
27
|
+
.join(":");
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
env[name] = value;
|
|
19
31
|
}
|
|
20
32
|
return env;
|
|
21
33
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@cairnkeep/memory-server",
|
|
3
3
|
"version": "0.1.0",
|
|
4
4
|
"private": true,
|
|
5
|
-
"description": "Durable, harness-agnostic memory MCP server for coding agents
|
|
5
|
+
"description": "Durable, harness-agnostic memory MCP server for coding agents \u2014 the core of Cairnkeep.",
|
|
6
6
|
"license": "Apache-2.0",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"engines": {
|
|
@@ -61,10 +61,12 @@
|
|
|
61
61
|
"check:playbook": "node scripts/smoke-playbook.mjs",
|
|
62
62
|
"check:notes": "npm run build && npm run check:note-signatures && npm run check:note-distillation && npm run check:note-enrichment",
|
|
63
63
|
"test": "npm run test:smoke",
|
|
64
|
-
"test:smoke": "npm run build && npm run check:embeddings && npm run check:extract && npm run check:scope-guard && npm run check:http-guard && npm run check:remote-context && npm run check:explore-guard && npm run check:explore-cache && npm run check:explore-crossref && npm run check:route-guard && npm run check:reviewed-memory && npm run check:memory-proposals && npm run check:trajectory-roundtrip && npm run check:trajectory-redaction && npm run check:trajectory-retention && npm run check:note-signatures && npm run check:note-distillation && npm run check:note-enrichment && npm run check:node-compat && npm run check:typed-nodes && npm run check:memory-import && npm run check:note-mcp && npm run check:node-doctor && npm run check:artifact-store && npm run check:artifact-mcp && npm run check:compaction-capture && npm run check:capability-contract && npm run check:capability-logging && npm run check:capability-harness && npm run check:capability-mcp && npm run check:eval-schema && npm run check:eval-process && npm run check:eval-statistics && npm run check:skill-lifecycle && npm run check:graph-cli && npm run check:mcp-trust && npm run check:pi-mcp-bridge && npm run check:context-pack && npm run check:context-pack-retrieval && npm run check:context-pack-v216-compat && npm run check:context-usage && npm run check:domain-retrieval && npm run check:retrieval-benchmark && npm run check:work-evidence && npm run check:okf && npm run check:playbook",
|
|
64
|
+
"test:smoke": "npm run build && npm run check:embeddings && npm run check:extract && npm run check:scope-guard && npm run check:http-guard && npm run check:remote-context && npm run check:explore-guard && npm run check:explore-cache && npm run check:explore-crossref && npm run check:route-guard && npm run check:reviewed-memory && npm run check:memory-proposals && npm run check:trajectory-roundtrip && npm run check:trajectory-redaction && npm run check:trajectory-retention && npm run check:note-signatures && npm run check:note-distillation && npm run check:note-enrichment && npm run check:node-compat && npm run check:typed-nodes && npm run check:memory-import && npm run check:note-mcp && npm run check:node-doctor && npm run check:artifact-store && npm run check:artifact-mcp && npm run check:compaction-capture && npm run check:capability-contract && npm run check:capability-logging && npm run check:capability-harness && npm run check:capability-mcp && npm run check:eval-schema && npm run check:eval-process && npm run check:eval-statistics && npm run check:skill-lifecycle && npm run check:graph-cli && npm run check:mcp-trust && npm run check:pi-mcp-bridge && npm run check:pi-bridge-env && npm run check:pi-bridge-child && npm run check:context-pack && npm run check:context-pack-retrieval && npm run check:context-pack-v216-compat && npm run check:context-usage && npm run check:domain-retrieval && npm run check:retrieval-benchmark && npm run check:work-evidence && npm run check:okf && npm run check:playbook",
|
|
65
65
|
"test:search": "npm run build && npm run check:search",
|
|
66
66
|
"test:memory": "npm run build && npm run check:embeddings && npm run check:extract && npm run check:search",
|
|
67
|
-
"test:all": "npm run test:memory"
|
|
67
|
+
"test:all": "npm run test:memory",
|
|
68
|
+
"check:pi-bridge-env": "node scripts/smoke-pi-bridge-env.mjs",
|
|
69
|
+
"check:pi-bridge-child": "node scripts/smoke-pi-bridge-child.mjs"
|
|
68
70
|
},
|
|
69
71
|
"dependencies": {
|
|
70
72
|
"@modelcontextprotocol/sdk": "^1.30.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
|
+
}
|