@cairnkeep/cli 2.3.1 → 2.5.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 (151) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +232 -6
  3. package/bin/cairn +43 -2
  4. package/claude/capability-contract/hooks/capability-command-finish.sh +81 -0
  5. package/claude/capability-contract/hooks/capability-command-start.sh +76 -0
  6. package/claude/hooks/compaction-capture.sh +34 -0
  7. package/claude/hooks/memory-capture.sh +47 -5
  8. package/claude/hooks/memory-wakeup.sh +55 -2
  9. package/docs/building-an-overlay.md +4 -3
  10. package/docs/design/meta-agent.md +439 -0
  11. package/docs/harness-compatibility.md +117 -0
  12. package/docs/learning/CURRICULUM-MAP.md +91 -0
  13. package/docs/learning/FEATURE-GUIDE.md +86 -0
  14. package/docs/learning/PRODUCTION-PLAN.md +45 -14
  15. package/docs/learning/README.md +20 -1
  16. package/docs/learning/instructor-guide.md +6 -0
  17. package/docs/learning/lessons/L00-why-cairnkeep.md +20 -8
  18. package/docs/learning/lessons/L01-safe-trial.md +3 -3
  19. package/docs/learning/lessons/L02-installation.md +23 -5
  20. package/docs/learning/lessons/L03-first-project.md +7 -2
  21. package/docs/learning/lessons/L04-memory-fundamentals.md +4 -0
  22. package/docs/learning/lessons/L05-daily-workflow.md +4 -0
  23. package/docs/learning/lessons/L07-privacy-and-storage.md +3 -0
  24. package/docs/learning/lessons/L10-context-exploration.md +4 -2
  25. package/docs/learning/lessons/L12-managed-overlays.md +2 -0
  26. package/docs/learning/lessons/L13-session-evidence.md +71 -0
  27. package/docs/learning/lessons/L14-typed-memory.md +51 -0
  28. package/docs/learning/lessons/L15-capability-governance.md +56 -0
  29. package/docs/learning/lessons/L16-evaluation.md +67 -0
  30. package/docs/learning/lessons/L17-agent-boundary.md +55 -0
  31. package/docs/learning/templates/lesson-template.md +4 -1
  32. package/docs/learning/tracks/evidence-and-evaluation.md +20 -0
  33. package/docs/learning/tracks/operator.md +8 -2
  34. package/docs/learning/tracks/practitioner.md +6 -1
  35. package/docs/learning/tracks/quickstart.md +3 -0
  36. package/docs/learning/video-scripts/L00-why-cairnkeep.md +21 -17
  37. package/docs/learning/video-scripts/L02-installation.md +9 -2
  38. package/docs/learning/video-scripts/L03-first-project.md +3 -0
  39. package/docs/operating.md +567 -7
  40. package/docs/privacy-and-data-flow.md +417 -0
  41. package/docs/storage.md +252 -0
  42. package/examples/eval/README.md +42 -0
  43. package/examples/eval/adapter.json +12 -0
  44. package/examples/eval/bundled-fake.json +1 -0
  45. package/examples/eval/task-set.json +1 -0
  46. package/mcp-memory-server/dist/artifact-cli.js +184 -0
  47. package/mcp-memory-server/dist/artifact-schema.js +218 -0
  48. package/mcp-memory-server/dist/artifact-store.js +812 -0
  49. package/mcp-memory-server/dist/capability-adapter.js +286 -0
  50. package/mcp-memory-server/dist/capability-cli.js +361 -0
  51. package/mcp-memory-server/dist/capability-config.js +310 -0
  52. package/mcp-memory-server/dist/capability-harness.js +496 -0
  53. package/mcp-memory-server/dist/capability-registry.js +123 -0
  54. package/mcp-memory-server/dist/capability-schema.js +65 -0
  55. package/mcp-memory-server/dist/capability-store.js +431 -0
  56. package/mcp-memory-server/dist/compaction-normalize.js +267 -0
  57. package/mcp-memory-server/dist/eval-cli.js +523 -0
  58. package/mcp-memory-server/dist/eval-plan.js +346 -0
  59. package/mcp-memory-server/dist/eval-process.js +273 -0
  60. package/mcp-memory-server/dist/eval-report.js +609 -0
  61. package/mcp-memory-server/dist/eval-runner.js +827 -0
  62. package/mcp-memory-server/dist/eval-schema.js +368 -0
  63. package/mcp-memory-server/dist/eval-statistics.js +161 -0
  64. package/mcp-memory-server/dist/eval-workspace.js +313 -0
  65. package/mcp-memory-server/dist/failure-signature.js +141 -0
  66. package/mcp-memory-server/dist/index.js +600 -55
  67. package/mcp-memory-server/dist/node-cli.js +84 -0
  68. package/mcp-memory-server/dist/node-schema.js +170 -0
  69. package/mcp-memory-server/dist/node-store.js +455 -0
  70. package/mcp-memory-server/dist/note-cli.js +235 -0
  71. package/mcp-memory-server/dist/note-distiller.js +177 -0
  72. package/mcp-memory-server/dist/note-enrichment.js +93 -0
  73. package/mcp-memory-server/dist/note-schema.js +4 -0
  74. package/mcp-memory-server/dist/note-store.js +985 -0
  75. package/mcp-memory-server/dist/trajectory-cli.js +141 -0
  76. package/mcp-memory-server/dist/trajectory-normalize.js +448 -0
  77. package/mcp-memory-server/dist/trajectory-redaction.js +155 -0
  78. package/mcp-memory-server/dist/trajectory-schema.js +73 -0
  79. package/mcp-memory-server/dist/trajectory-store.js +339 -0
  80. package/mcp-memory-server/package.json +24 -2
  81. package/mcp-memory-server/scripts/fixtures/compaction-claude-code-2.1.219.json +9 -0
  82. package/mcp-memory-server/scripts/fixtures/compaction-claude-code-2.1.220.json +9 -0
  83. package/mcp-memory-server/scripts/fixtures/compaction-opencode-1.17.20-event.json +6 -0
  84. package/mcp-memory-server/scripts/fixtures/compaction-opencode-1.17.20-messages.json +119 -0
  85. package/mcp-memory-server/scripts/fixtures/compaction-unknown-version.json +17 -0
  86. package/opencode/capability-contract/plugins/capability-command.ts +319 -0
  87. package/opencode/command/graphify.md +25 -14
  88. package/opencode/plugins/memory-capture.ts +76 -10
  89. package/opencode/plugins/memory-wakeup.ts +58 -6
  90. package/package.json +8 -4
  91. package/pi/extensions/cairnkeep-trajectory.ts +55 -0
  92. package/schemas/artifact.schema.json +133 -0
  93. package/schemas/capability-callback.schema.json +68 -0
  94. package/schemas/capability-contract.schema.json +126 -0
  95. package/schemas/eval-adapter.schema.json +43 -0
  96. package/schemas/eval-protocol.schema.json +114 -0
  97. package/schemas/eval-report.schema.json +423 -0
  98. package/schemas/eval-task-set.schema.json +114 -0
  99. package/schemas/memory-node.schema.json +90 -0
  100. package/schemas/note.schema.json +112 -0
  101. package/schemas/trajectory-redaction.schema.json +25 -0
  102. package/schemas/trajectory.schema.json +82 -0
  103. package/scripts/bootstrap.sh +6 -0
  104. package/scripts/completion.sh +119 -5
  105. package/scripts/doctor.sh +284 -0
  106. package/scripts/fake-eval-adapter.mjs +120 -0
  107. package/scripts/fixtures/capability-harness-contracts.json +408 -0
  108. package/scripts/install-audit-timer.sh +3 -1
  109. package/scripts/lib/capability-opencode-plugin-harness.mjs +291 -0
  110. package/scripts/lib/trajectory-opencode-plugin-harness.mjs +25 -0
  111. package/scripts/lib/trajectory-pi-extension-harness.mjs +42 -0
  112. package/scripts/memory-wiki-audit.sh +49 -0
  113. package/scripts/smoke-bash32.sh +9 -0
  114. package/scripts/sync-claude-assets.sh +99 -6
  115. package/scripts/sync-opencode-graphify-assets.sh +24 -3
  116. package/scripts/sync-opencode-plugin-assets.sh +31 -5
  117. package/scripts/sync-opencode-security-assets.sh +24 -3
  118. package/scripts/sync-opencode-wiki-assets.sh +24 -3
  119. package/scripts/sync-pi-assets.sh +72 -0
  120. package/scripts/test-audit-timer.sh +98 -0
  121. package/scripts/test-bootstrap-untracked.sh +49 -0
  122. package/scripts/test-cli-dispatch.sh +41 -2
  123. package/scripts/test-compaction-hooks.sh +495 -0
  124. package/scripts/test-completion.sh +77 -0
  125. package/scripts/test-doctor.sh +348 -1
  126. package/scripts/test-eval-cli.sh +175 -0
  127. package/scripts/test-launcher-hooks.sh +630 -3
  128. package/scripts/test-learning-docs.sh +20 -4
  129. package/scripts/test-notes-cli.sh +114 -0
  130. package/scripts/test-package-install.sh +254 -0
  131. package/scripts/test-phase16-node-lifecycle.sh +50 -0
  132. package/scripts/test-phase17-artifact-lifecycle.sh +370 -0
  133. package/scripts/test-phase18-capability-lifecycle.sh +504 -0
  134. package/scripts/test-phase18-harness-boundary.sh +1060 -0
  135. package/scripts/test-phase19-eval-lifecycle.sh +1015 -0
  136. package/scripts/test-trajectory-cli.sh +87 -0
  137. package/scripts/test-trajectory-hooks.sh +142 -0
  138. package/scripts/test-uninstall.sh +231 -5
  139. package/scripts/uninstall.sh +38 -5
  140. package/scripts/verify-dependency-security.mjs +121 -0
  141. package/scripts/verify-docs-parity.sh +527 -1
  142. package/scripts/verify-phase18-runtime-evidence.mjs +895 -0
  143. package/scripts/verify-phase19-runtime-evidence.mjs +947 -0
  144. package/templates/capabilities.json.template +7 -0
  145. package/templates/env.example.template +51 -0
  146. package/templates/memory-wiki-audit.service.template +1 -1
  147. package/templates/start-claude.sh.template +36 -0
  148. package/templates/start-kimi.sh.template +39 -0
  149. package/templates/start-opencode.sh.template +47 -5
  150. package/templates/start-pi.sh.template +30 -0
  151. package/templates/trajectory-redaction.json.template +4 -0
@@ -0,0 +1,455 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { copyFileSync, existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { AgentFS } from "agentfs-sdk";
6
+ import { NODE_SCHEMA_VERSION, memoryImportEnvelopeSchema, nodeMetadataSchema, } from "./node-schema.js";
7
+ const METADATA_TABLE = "cairn_node_metadata_v1";
8
+ const REPLAY_TABLE = "cairn_node_import_replays_v1";
9
+ const HISTORY_NAMESPACE = "__history__";
10
+ const REVIEWED_NAMESPACE = "__reviewed__";
11
+ const DEFAULT_METADATA = { schema_version: NODE_SCHEMA_VERSION, node_type: "memory", tags: [] };
12
+ let lastMutationTime = 0;
13
+ function mutationTimestamp() {
14
+ const now = Date.now();
15
+ lastMutationTime = Math.max(now, lastMutationTime + 1);
16
+ return new Date(lastMutationTime).toISOString();
17
+ }
18
+ function normalizeValue(value) {
19
+ if (typeof value === "string")
20
+ return value;
21
+ if (value === undefined || value === null)
22
+ return "";
23
+ return JSON.stringify(value);
24
+ }
25
+ function normalizedMetadata(nodeType = "memory", tags = []) {
26
+ return nodeMetadataSchema.parse({ schema_version: NODE_SCHEMA_VERSION, node_type: nodeType, tags });
27
+ }
28
+ function historyPrefix(key) {
29
+ return `${HISTORY_NAMESPACE}/${key}/`;
30
+ }
31
+ function historyKey(key, at) {
32
+ return `${historyPrefix(key)}${at}-${randomUUID()}`;
33
+ }
34
+ function injectMutationFailure(stage) {
35
+ if (process.env.CAIRN_TEST_FAIL_NODE_MUTATION === stage)
36
+ throw new Error(`Injected node mutation failure at ${stage}.`);
37
+ }
38
+ async function tableExists(agent, table) {
39
+ const row = await agent.getDatabase().prepare("SELECT name FROM sqlite_master WHERE type = ? AND name = ?").get("table", table);
40
+ return row?.name === table;
41
+ }
42
+ async function ensureTables(agent) {
43
+ await agent.getDatabase().exec(`
44
+ CREATE TABLE IF NOT EXISTS ${METADATA_TABLE} (
45
+ key TEXT PRIMARY KEY,
46
+ schema_version INTEGER NOT NULL,
47
+ node_type TEXT NOT NULL,
48
+ tags_json TEXT NOT NULL,
49
+ updated_at TEXT NOT NULL
50
+ );
51
+ CREATE TABLE IF NOT EXISTS ${REPLAY_TABLE} (
52
+ import_id TEXT PRIMARY KEY,
53
+ batch_digest TEXT NOT NULL,
54
+ committed_at TEXT NOT NULL
55
+ );
56
+ `);
57
+ }
58
+ async function storedMetadata(agent, key) {
59
+ if (!await tableExists(agent, METADATA_TABLE))
60
+ return undefined;
61
+ const row = await agent.getDatabase().prepare(`SELECT schema_version, node_type, tags_json FROM ${METADATA_TABLE} WHERE key = ?`).get(key);
62
+ if (!row)
63
+ return undefined;
64
+ return nodeMetadataSchema.parse({
65
+ schema_version: Number(row.schema_version),
66
+ node_type: row.node_type,
67
+ tags: JSON.parse(String(row.tags_json)),
68
+ });
69
+ }
70
+ async function setMetadata(agent, key, metadata, at) {
71
+ await agent.getDatabase().prepare(`
72
+ INSERT INTO ${METADATA_TABLE}(key, schema_version, node_type, tags_json, updated_at)
73
+ VALUES (?, ?, ?, ?, ?)
74
+ ON CONFLICT(key) DO UPDATE SET
75
+ schema_version = excluded.schema_version,
76
+ node_type = excluded.node_type,
77
+ tags_json = excluded.tags_json,
78
+ updated_at = excluded.updated_at
79
+ `).run(key, metadata.schema_version, metadata.node_type, JSON.stringify(metadata.tags), at);
80
+ }
81
+ export async function inImmediateNodeTransaction(agent, operation) {
82
+ const transaction = agent.getDatabase().transaction(operation);
83
+ const immediate = transaction.immediate;
84
+ return immediate();
85
+ }
86
+ export async function getNodeMetadata(agent, key) {
87
+ return await storedMetadata(agent, key) ?? { ...DEFAULT_METADATA, tags: [] };
88
+ }
89
+ export async function attachNodeMetadata(agent, scope, entry) {
90
+ const metadata = await getNodeMetadata(agent, entry.key);
91
+ return {
92
+ schema_version: NODE_SCHEMA_VERSION,
93
+ address_space: "memory",
94
+ scope,
95
+ key: entry.key,
96
+ value: normalizeValue(entry.value),
97
+ node_type: metadata.node_type,
98
+ tags: metadata.tags,
99
+ };
100
+ }
101
+ export async function getTypedNode(agent, scope, key) {
102
+ const value = await agent.kv.get(key);
103
+ return value === undefined ? null : attachNodeMetadata(agent, scope, { key, value });
104
+ }
105
+ export async function createTypedNode(options) {
106
+ const metadata = normalizedMetadata(options.node_type, options.tags);
107
+ return inImmediateNodeTransaction(options.agent, async () => {
108
+ await ensureTables(options.agent);
109
+ const current = await options.agent.kv.get(options.key);
110
+ if (current !== undefined) {
111
+ const currentNode = await attachNodeMetadata(options.agent, options.scope, { key: options.key, value: current });
112
+ if (currentNode.value === options.value && currentNode.node_type === metadata.node_type && JSON.stringify(currentNode.tags) === JSON.stringify(metadata.tags)) {
113
+ return { ok: true, scope: options.scope, key: options.key, created: false, snapshot_key: null };
114
+ }
115
+ throw new Error(`NODE_EXISTS: ${options.key} differs; use memory_supersede.`);
116
+ }
117
+ const at = mutationTimestamp();
118
+ await options.agent.kv.set(options.key, options.value);
119
+ injectMutationFailure("after-value");
120
+ await setMetadata(options.agent, options.key, metadata, at);
121
+ return { ok: true, scope: options.scope, key: options.key, created: true, snapshot_key: null };
122
+ });
123
+ }
124
+ async function supersedeTypedNodeMutation(options) {
125
+ await ensureTables(options.agent);
126
+ const current = await options.agent.kv.get(options.key);
127
+ if (current === undefined) {
128
+ const metadata = normalizedMetadata(options.node_type, options.tags);
129
+ const value = options.value ?? "";
130
+ const at = mutationTimestamp();
131
+ await options.agent.kv.set(options.key, value);
132
+ injectMutationFailure("after-value");
133
+ await setMetadata(options.agent, options.key, metadata, at);
134
+ return { ok: true, scope: options.scope, key: options.key, created: true, snapshot_key: null };
135
+ }
136
+ const previous = await attachNodeMetadata(options.agent, options.scope, { key: options.key, value: current });
137
+ const metadata = normalizedMetadata(options.node_type ?? previous.node_type, options.tags ?? previous.tags);
138
+ const value = options.value ?? previous.value;
139
+ const at = mutationTimestamp();
140
+ const snapshot = {
141
+ schema_version: NODE_SCHEMA_VERSION,
142
+ event: "supersede",
143
+ value: previous.value,
144
+ node_type: previous.node_type,
145
+ tags: previous.tags,
146
+ at,
147
+ reason: options.reason ?? null,
148
+ };
149
+ const snapshot_key = historyKey(options.key, at);
150
+ await options.agent.kv.set(snapshot_key, snapshot);
151
+ await options.agent.kv.set(options.key, value);
152
+ injectMutationFailure("after-value");
153
+ await setMetadata(options.agent, options.key, metadata, at);
154
+ return { ok: true, scope: options.scope, key: options.key, created: false, snapshot_key, previous_value: previous.value };
155
+ }
156
+ export async function supersedeTypedNode(options) {
157
+ return inImmediateNodeTransaction(options.agent, () => supersedeTypedNodeMutation(options));
158
+ }
159
+ async function deleteTypedNodeMutation(options) {
160
+ await ensureTables(options.agent);
161
+ const current = await options.agent.kv.get(options.key);
162
+ if (current === undefined)
163
+ return { ok: true, scope: options.scope, key: options.key, deleted: false, missing: true, snapshot_key: null };
164
+ const node = await attachNodeMetadata(options.agent, options.scope, { key: options.key, value: current });
165
+ const at = mutationTimestamp();
166
+ const snapshot = {
167
+ schema_version: NODE_SCHEMA_VERSION,
168
+ event: "delete",
169
+ value: node.value,
170
+ node_type: node.node_type,
171
+ tags: node.tags,
172
+ at,
173
+ reason: options.reason ?? null,
174
+ };
175
+ const snapshot_key = historyKey(options.key, at);
176
+ await options.agent.kv.set(snapshot_key, snapshot);
177
+ await options.agent.kv.delete(options.key);
178
+ injectMutationFailure("after-value");
179
+ await options.agent.getDatabase().prepare(`DELETE FROM ${METADATA_TABLE} WHERE key = ?`).run(options.key);
180
+ return { ok: true, scope: options.scope, key: options.key, deleted: true, missing: false, snapshot_key, final_snapshot: snapshot };
181
+ }
182
+ export async function deleteTypedNode(options) {
183
+ return inImmediateNodeTransaction(options.agent, () => deleteTypedNodeMutation(options));
184
+ }
185
+ export async function listTypedHistory(agent, key) {
186
+ const entries = await agent.kv.list(historyPrefix(key));
187
+ return entries.map((entry) => {
188
+ const value = entry.value;
189
+ if (value?.schema_version === NODE_SCHEMA_VERSION && (value.event === "supersede" || value.event === "delete")) {
190
+ return { key: entry.key, ...value };
191
+ }
192
+ return {
193
+ key: entry.key,
194
+ schema_version: NODE_SCHEMA_VERSION,
195
+ event: "supersede",
196
+ value: normalizeValue(value?.value ?? entry.value),
197
+ node_type: "memory",
198
+ tags: [],
199
+ at: String(value?.superseded_at ?? ""),
200
+ reason: value?.superseded_reason ?? null,
201
+ };
202
+ }).sort((left, right) => left.key.localeCompare(right.key));
203
+ }
204
+ export async function applyReviewedTypedNode(options) {
205
+ const mutation = { ...options, reason: `reviewed memory ${options.review_id}` };
206
+ return options.in_transaction ? supersedeTypedNodeMutation(mutation) : supersedeTypedNode(mutation);
207
+ }
208
+ export async function invalidateReviewedTypedNode(options) {
209
+ const mutation = { ...options, reason: options.reason ?? `reviewed memory ${options.review_id} invalidated` };
210
+ return options.in_transaction ? deleteTypedNodeMutation(mutation) : deleteTypedNode(mutation);
211
+ }
212
+ function canonicalBatch(envelope) {
213
+ return JSON.stringify({
214
+ schema_version: envelope.schema_version,
215
+ scope: envelope.scope,
216
+ address_space: envelope.address_space,
217
+ nodes: envelope.nodes.map((node) => ({
218
+ key: node.key,
219
+ value: node.value,
220
+ node_type: node.node_type,
221
+ tags: node.tags,
222
+ ...(node.note ? { note: node.note } : {}),
223
+ })).sort((left, right) => left.key.localeCompare(right.key)),
224
+ });
225
+ }
226
+ function batchDigest(envelope) {
227
+ return createHash("sha256").update("cairnkeep:memory-import:v1\0").update(canonicalBatch(envelope)).digest("hex");
228
+ }
229
+ async function classifyImportNode(agent, scope, node, policy) {
230
+ if (!agent)
231
+ return { key: node.key, action: "would_create" };
232
+ const current = await getTypedNode(agent, scope, node.key);
233
+ if (!current)
234
+ return { key: node.key, action: "would_create" };
235
+ const same = current.value === node.value && current.node_type === node.node_type && JSON.stringify(current.tags) === JSON.stringify(node.tags);
236
+ if (same)
237
+ return { key: node.key, action: "unchanged" };
238
+ return policy === "supersede"
239
+ ? { key: node.key, action: "would_replace" }
240
+ : { key: node.key, action: "rejected", code: "CONFLICT" };
241
+ }
242
+ export async function planMemoryImport(agent, input) {
243
+ const envelope = memoryImportEnvelopeSchema.parse(input);
244
+ if (envelope.address_space !== "memory")
245
+ throw new Error("ADDRESS_SPACE_UNSUPPORTED: node-store accepts memory imports only.");
246
+ const actions = [];
247
+ for (const node of [...envelope.nodes].sort((left, right) => left.key.localeCompare(right.key))) {
248
+ actions.push(await classifyImportNode(agent, envelope.scope, node, envelope.conflict_policy));
249
+ }
250
+ return { envelope, batch_digest: batchDigest(envelope), actions, conflict: actions.some((action) => action.action === "rejected") };
251
+ }
252
+ function counts(actions, dryRun) {
253
+ const result = { unchanged: 0, rejected: 0 };
254
+ if (dryRun) {
255
+ result.would_create = 0;
256
+ result.would_replace = 0;
257
+ }
258
+ else {
259
+ result.created = 0;
260
+ result.replaced = 0;
261
+ }
262
+ for (const action of actions) {
263
+ if (action.action === "would_create")
264
+ result.would_create = (result.would_create ?? 0) + 1;
265
+ else if (action.action === "would_replace")
266
+ result.would_replace = (result.would_replace ?? 0) + 1;
267
+ else if (action.action === "created")
268
+ result.created = (result.created ?? 0) + 1;
269
+ else if (action.action === "replaced")
270
+ result.replaced = (result.replaced ?? 0) + 1;
271
+ else if (action.action === "unchanged")
272
+ result.unchanged += 1;
273
+ else
274
+ result.rejected += 1;
275
+ }
276
+ return result;
277
+ }
278
+ export async function commitMemoryImport(agent, inputPlan) {
279
+ if (inputPlan.envelope.dry_run) {
280
+ return {
281
+ schema_version: NODE_SCHEMA_VERSION,
282
+ scope: inputPlan.envelope.scope,
283
+ address_space: inputPlan.envelope.address_space,
284
+ batch_digest: inputPlan.batch_digest,
285
+ ...(inputPlan.envelope.import_id ? { import_id: inputPlan.envelope.import_id } : {}),
286
+ dry_run: true,
287
+ conflict_policy: inputPlan.envelope.conflict_policy,
288
+ committed: false,
289
+ counts: counts(inputPlan.actions, true),
290
+ actions: inputPlan.actions,
291
+ };
292
+ }
293
+ if (!agent)
294
+ throw new Error("STORE_REQUIRED: a concrete AgentFS store is required for import commit.");
295
+ return inImmediateNodeTransaction(agent, async () => {
296
+ await ensureTables(agent);
297
+ const { envelope, batch_digest } = inputPlan;
298
+ if (envelope.import_id) {
299
+ const replay = await agent.getDatabase().prepare(`SELECT batch_digest FROM ${REPLAY_TABLE} WHERE import_id = ?`).get(envelope.import_id);
300
+ if (replay && replay.batch_digest !== batch_digest)
301
+ throw new Error(`IMPORT_ID_REUSE: ${envelope.import_id}`);
302
+ if (replay) {
303
+ const actions = envelope.nodes.map((node) => ({ key: node.key, action: "unchanged" })).sort((a, b) => a.key.localeCompare(b.key));
304
+ return {
305
+ schema_version: NODE_SCHEMA_VERSION,
306
+ scope: envelope.scope,
307
+ address_space: envelope.address_space,
308
+ batch_digest,
309
+ import_id: envelope.import_id,
310
+ dry_run: false,
311
+ conflict_policy: envelope.conflict_policy,
312
+ committed: true,
313
+ replayed: true,
314
+ counts: counts(actions, false),
315
+ actions,
316
+ };
317
+ }
318
+ }
319
+ const currentPlan = await planMemoryImport(agent, envelope);
320
+ if (currentPlan.conflict)
321
+ throw new Error("CONFLICT: import contains a differing live node.");
322
+ const committedActions = [];
323
+ let mutations = 0;
324
+ for (const planned of currentPlan.actions) {
325
+ const node = envelope.nodes.find((candidate) => candidate.key === planned.key);
326
+ if (planned.action === "unchanged") {
327
+ committedActions.push(planned);
328
+ continue;
329
+ }
330
+ const existing = await getTypedNode(agent, envelope.scope, node.key);
331
+ if (existing) {
332
+ const at = mutationTimestamp();
333
+ const snapshot = { schema_version: 1, event: "supersede", value: existing.value, node_type: existing.node_type, tags: existing.tags, at, reason: `import ${envelope.import_id ?? batch_digest}` };
334
+ await agent.kv.set(historyKey(node.key, at), snapshot);
335
+ await agent.kv.set(node.key, node.value);
336
+ await setMetadata(agent, node.key, normalizedMetadata(node.node_type, node.tags), at);
337
+ committedActions.push({ key: node.key, action: "replaced" });
338
+ }
339
+ else {
340
+ const at = mutationTimestamp();
341
+ await agent.kv.set(node.key, node.value);
342
+ await setMetadata(agent, node.key, normalizedMetadata(node.node_type, node.tags), at);
343
+ committedActions.push({ key: node.key, action: "created" });
344
+ }
345
+ mutations += 1;
346
+ if (Number(process.env.CAIRN_TEST_FAIL_IMPORT_AFTER) === mutations)
347
+ throw new Error("Injected import failure.");
348
+ }
349
+ if (envelope.import_id) {
350
+ await agent.getDatabase().prepare(`INSERT INTO ${REPLAY_TABLE}(import_id, batch_digest, committed_at) VALUES (?, ?, ?)`).run(envelope.import_id, batch_digest, new Date().toISOString());
351
+ }
352
+ committedActions.sort((left, right) => left.key.localeCompare(right.key));
353
+ return {
354
+ schema_version: NODE_SCHEMA_VERSION,
355
+ scope: envelope.scope,
356
+ address_space: envelope.address_space,
357
+ batch_digest,
358
+ ...(envelope.import_id ? { import_id: envelope.import_id } : {}),
359
+ dry_run: false,
360
+ conflict_policy: envelope.conflict_policy,
361
+ committed: true,
362
+ counts: counts(committedActions, false),
363
+ actions: committedActions,
364
+ };
365
+ });
366
+ }
367
+ export async function doctorTypedNodeStore(options) {
368
+ const dbPath = join(options.baseDir, `${options.scope}.db`);
369
+ if (!existsSync(dbPath))
370
+ return { schema_version: 1, exists: false, ok: true, repaired: false, issues: [] };
371
+ // AgentFS opens databases in read-write WAL mode, which can checkpoint the
372
+ // WAL even when the caller only reads. Inspect an exact disposable copy so
373
+ // a non-repair doctor run is byte-pure for the authoritative store.
374
+ const scratch = mkdtempSync(join(tmpdir(), "cairn-node-doctor-"));
375
+ const scratchPath = join(scratch, `${options.scope}.db`);
376
+ for (const suffix of ["", "-wal", "-shm"]) {
377
+ if (existsSync(`${dbPath}${suffix}`))
378
+ copyFileSync(`${dbPath}${suffix}`, `${scratchPath}${suffix}`);
379
+ }
380
+ const inspectionAgent = await AgentFS.open({ id: options.scope, path: scratchPath });
381
+ const issues = [];
382
+ const orphanKeys = [];
383
+ try {
384
+ if (await tableExists(inspectionAgent, METADATA_TABLE)) {
385
+ const rows = await inspectionAgent.getDatabase().prepare(`SELECT key, schema_version, node_type, tags_json FROM ${METADATA_TABLE} ORDER BY key`).all();
386
+ for (const row of rows) {
387
+ try {
388
+ nodeMetadataSchema.parse({ schema_version: Number(row.schema_version), node_type: row.node_type, tags: JSON.parse(String(row.tags_json)) });
389
+ }
390
+ catch {
391
+ issues.push(`Invalid authoritative metadata for ${String(row.key)}.`);
392
+ continue;
393
+ }
394
+ if (await inspectionAgent.kv.get(String(row.key)) === undefined) {
395
+ issues.push(`Orphan metadata for ${String(row.key)}.`);
396
+ orphanKeys.push(String(row.key));
397
+ }
398
+ }
399
+ }
400
+ if (await tableExists(inspectionAgent, REPLAY_TABLE)) {
401
+ const rows = await inspectionAgent.getDatabase().prepare(`SELECT import_id, batch_digest FROM ${REPLAY_TABLE} ORDER BY import_id`).all();
402
+ for (const row of rows) {
403
+ if (!/^[a-f0-9]{64}$/.test(String(row.batch_digest)))
404
+ issues.push(`Invalid authoritative replay digest for ${String(row.import_id)}.`);
405
+ }
406
+ }
407
+ }
408
+ finally {
409
+ await inspectionAgent.close();
410
+ rmSync(scratch, { recursive: true, force: true });
411
+ }
412
+ const onlyOrphans = issues.length > 0 && issues.every((issue) => issue.startsWith("Orphan metadata"));
413
+ if (options.repair && onlyOrphans) {
414
+ const agent = await AgentFS.open({ id: options.scope, path: dbPath });
415
+ try {
416
+ await inImmediateNodeTransaction(agent, async () => {
417
+ for (const key of orphanKeys)
418
+ await agent.getDatabase().prepare(`DELETE FROM ${METADATA_TABLE} WHERE key = ?`).run(key);
419
+ });
420
+ }
421
+ finally {
422
+ await agent.close();
423
+ }
424
+ return { schema_version: 1, exists: true, ok: true, repaired: true, issues: [] };
425
+ }
426
+ return { schema_version: 1, exists: true, ok: issues.length === 0, repaired: false, issues };
427
+ }
428
+ export const doctorNodeStore = doctorTypedNodeStore;
429
+ export async function createNodeDoctorFixture(options) {
430
+ mkdirSync(options.root, { recursive: true });
431
+ const agent = await AgentFS.open({ id: "identity", path: join(options.root, "identity.db") });
432
+ try {
433
+ await ensureTables(agent);
434
+ if (options.kind !== "orphan_metadata")
435
+ await agent.kv.set("knowledge/doctor", "doctor fixture");
436
+ if (options.kind === "malformed_type") {
437
+ await agent.getDatabase().prepare(`INSERT INTO ${METADATA_TABLE}(key,schema_version,node_type,tags_json,updated_at) VALUES (?,?,?,?,?)`).run("knowledge/doctor", 1, "invalid", "[]", new Date().toISOString());
438
+ }
439
+ else if (options.kind === "malformed_tags") {
440
+ await agent.getDatabase().prepare(`INSERT INTO ${METADATA_TABLE}(key,schema_version,node_type,tags_json,updated_at) VALUES (?,?,?,?,?)`).run("knowledge/doctor", 1, "knowledge", "not-json", new Date().toISOString());
441
+ }
442
+ else if (options.kind === "orphan_metadata") {
443
+ await agent.getDatabase().prepare(`INSERT INTO ${METADATA_TABLE}(key,schema_version,node_type,tags_json,updated_at) VALUES (?,?,?,?,?)`).run("knowledge/orphan", 1, "knowledge", "[]", new Date().toISOString());
444
+ }
445
+ else {
446
+ await agent.getDatabase().prepare(`INSERT INTO ${REPLAY_TABLE}(import_id,batch_digest,committed_at) VALUES (?,?,?)`).run("doctor-replay", "invalid", new Date().toISOString());
447
+ }
448
+ }
449
+ finally {
450
+ await agent.close();
451
+ }
452
+ return { root: options.root };
453
+ }
454
+ export const INTERNAL_NODE_TABLES = { metadata: METADATA_TABLE, replays: REPLAY_TABLE };
455
+ export const INTERNAL_NODE_NAMESPACES = { history: HISTORY_NAMESPACE, reviewed: REVIEWED_NAMESPACE };
@@ -0,0 +1,235 @@
1
+ #!/usr/bin/env node
2
+ import { readdirSync, statSync } from "node:fs";
3
+ import { join, resolve } from "node:path";
4
+ import { startOperatingCapability, withCapability } from "./capability-adapter.js";
5
+ import { isCapabilityContractEnabled, resolveCapabilityStatus } from "./capability-config.js";
6
+ import { distillProject } from "./note-distiller.js";
7
+ import { isNoteDistillationEnabled } from "./note-schema.js";
8
+ import { doctorNoteStore, promoteNotes, searchHindsight } from "./note-store.js";
9
+ process.stdout.on("error", (error) => {
10
+ if (error.code === "EPIPE")
11
+ process.exit(0);
12
+ throw error;
13
+ });
14
+ function usage() {
15
+ return `cairn notes — deterministic local hindsight notes
16
+
17
+ Usage:
18
+ cairn notes distill [--project PATH] [--session ID] [--json]
19
+ cairn notes distill --all-projects --para-root PATH [--json]
20
+ cairn notes search-error [--text TEXT] [--project PATH] [--component VALUE] [--json]
21
+ cairn notes promote NOTE-ID --with NOTE-ID --confirm [--json]
22
+ cairn notes doctor [--repair] [--json]
23
+ `;
24
+ }
25
+ function valueAfter(args, flag) {
26
+ const index = args.indexOf(flag);
27
+ if (index < 0)
28
+ return undefined;
29
+ const value = args[index + 1];
30
+ if (!value || value.startsWith("--"))
31
+ throw new Error(`${flag} requires a value.`);
32
+ return value;
33
+ }
34
+ function assertKnown(args, flags, positionals = 0) {
35
+ const valueFlags = new Set(["--project", "--session", "--para-root", "--text", "--component", "--with"]);
36
+ let positionalCount = 0;
37
+ for (let index = 0; index < args.length; index += 1) {
38
+ const arg = args[index];
39
+ if (arg.startsWith("--")) {
40
+ if (!flags.includes(arg))
41
+ throw new Error(`unknown option "${arg}".`);
42
+ if (valueFlags.has(arg))
43
+ index += 1;
44
+ }
45
+ else
46
+ positionalCount += 1;
47
+ }
48
+ if (positionalCount !== positionals)
49
+ throw new Error(`expected ${positionals} positional argument(s).`);
50
+ }
51
+ async function readStdin() {
52
+ const chunks = [];
53
+ for await (const chunk of process.stdin)
54
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
55
+ return Buffer.concat(chunks).toString("utf8");
56
+ }
57
+ function findTrajectoryProjects(root) {
58
+ const projects = [];
59
+ const walk = (directory, depth) => {
60
+ if (depth > 8)
61
+ return;
62
+ let entries;
63
+ try {
64
+ entries = readdirSync(directory, { withFileTypes: true });
65
+ }
66
+ catch {
67
+ return;
68
+ }
69
+ for (const entry of entries) {
70
+ if (!entry.isDirectory() || entry.isSymbolicLink())
71
+ continue;
72
+ const child = join(directory, entry.name);
73
+ if (entry.name === ".agentfs") {
74
+ try {
75
+ if (statSync(join(child, "trajectory.db")).isFile())
76
+ projects.push(directory);
77
+ }
78
+ catch { /* no trajectory store */ }
79
+ continue;
80
+ }
81
+ if (entry.name.startsWith(".") || ["node_modules", "vendor"].includes(entry.name))
82
+ continue;
83
+ walk(child, depth + 1);
84
+ }
85
+ };
86
+ walk(resolve(root), 0);
87
+ return [...new Set(projects.map((project) => resolve(project)))].sort();
88
+ }
89
+ function disabled(json) {
90
+ const value = { schema_version: 1, enabled: false, reason: "CAIRN_NOTE_DISTILLATION is disabled" };
91
+ process.stdout.write(`${json ? JSON.stringify(value) : value.reason}\n`);
92
+ }
93
+ function output(value, json, human) {
94
+ process.stdout.write(`${json ? JSON.stringify(value) : human}\n`);
95
+ }
96
+ function validCorrelationId(value) {
97
+ return value !== undefined
98
+ && value !== "unknown"
99
+ && /^(?:cairn:)?[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/.test(value)
100
+ ? value
101
+ : undefined;
102
+ }
103
+ async function withEnabledCompatibility(callback) {
104
+ if (isNoteDistillationEnabled())
105
+ return callback();
106
+ const previous = process.env.CAIRN_NOTE_DISTILLATION;
107
+ process.env.CAIRN_NOTE_DISTILLATION = "1";
108
+ try {
109
+ return await callback();
110
+ }
111
+ finally {
112
+ if (previous === undefined)
113
+ delete process.env.CAIRN_NOTE_DISTILLATION;
114
+ else
115
+ process.env.CAIRN_NOTE_DISTILLATION = previous;
116
+ }
117
+ }
118
+ async function main() {
119
+ const [command = "help", ...args] = process.argv.slice(2);
120
+ if (["help", "--help", "-h"].includes(command)) {
121
+ process.stdout.write(usage());
122
+ return;
123
+ }
124
+ const json = args.includes("--json");
125
+ const contractEnabled = isCapabilityContractEnabled();
126
+ if (!contractEnabled && !isNoteDistillationEnabled()) {
127
+ disabled(json);
128
+ return;
129
+ }
130
+ if (command === "distill") {
131
+ assertKnown(args, ["--project", "--session", "--all-projects", "--para-root", "--json"]);
132
+ const allProjects = args.includes("--all-projects");
133
+ const project = valueAfter(args, "--project");
134
+ const sessionId = valueAfter(args, "--session");
135
+ const paraRoot = valueAfter(args, "--para-root");
136
+ if (allProjects && (!paraRoot || project || sessionId)) {
137
+ throw new Error("--all-projects requires --para-root and cannot be combined with --project or --session.");
138
+ }
139
+ if (!allProjects && paraRoot)
140
+ throw new Error("--para-root is valid only with --all-projects.");
141
+ const capabilityProjectRoot = resolve(project ?? process.cwd());
142
+ const correlationId = validCorrelationId(sessionId);
143
+ const snapshot = contractEnabled
144
+ ? await resolveCapabilityStatus({ projectRoot: capabilityProjectRoot })
145
+ : undefined;
146
+ const capabilityOptions = snapshot === undefined ? undefined : {
147
+ projectRoot: capabilityProjectRoot,
148
+ snapshot,
149
+ capabilityId: "notes.distill",
150
+ classification: {
151
+ harness: "other",
152
+ source: "notes-cli",
153
+ transport: "local-process",
154
+ },
155
+ ...(correlationId === undefined ? {} : { correlationId }),
156
+ };
157
+ const capability = snapshot?.capabilities.find(({ id }) => id === "notes.distill");
158
+ if (contractEnabled && capability?.enabled !== true) {
159
+ if (capabilityOptions)
160
+ await startOperatingCapability(capabilityOptions);
161
+ disabled(json);
162
+ return;
163
+ }
164
+ if (allProjects) {
165
+ const run = async () => {
166
+ const projects = findTrajectoryProjects(paraRoot);
167
+ const results = [];
168
+ for (const projectRoot of projects) {
169
+ try {
170
+ results.push(await distillProject({ projectRoot }));
171
+ }
172
+ catch (error) {
173
+ results.push({
174
+ schema_version: 1,
175
+ enabled: true,
176
+ project_root: projectRoot,
177
+ created: [], updated: [], already_processed: [], enrichment_skipped: [], enrichment_failed: [],
178
+ failed: [{ session_id: "*", error: error instanceof Error ? error.message : String(error) }],
179
+ });
180
+ }
181
+ }
182
+ return { schema_version: 1, enabled: true, projects_scanned: projects.length, results };
183
+ };
184
+ const execute = () => contractEnabled ? withEnabledCompatibility(run) : run();
185
+ const value = await (capabilityOptions ? withCapability(capabilityOptions, execute)() : execute());
186
+ output(value, json, `Scanned ${value.projects_scanned} project(s).`);
187
+ return;
188
+ }
189
+ const distill = () => distillProject({ projectRoot: capabilityProjectRoot, ...(sessionId ? { sessionId } : {}) });
190
+ const run = () => contractEnabled ? withEnabledCompatibility(distill) : distill();
191
+ const value = await (capabilityOptions ? withCapability(capabilityOptions, run)() : run());
192
+ output(value, json, `Created ${value.created.length}, updated ${value.updated.length}, already processed ${value.already_processed.length}.`);
193
+ return;
194
+ }
195
+ if (!isNoteDistillationEnabled()) {
196
+ disabled(json);
197
+ return;
198
+ }
199
+ if (command === "search-error") {
200
+ assertKnown(args, ["--project", "--text", "--component", "--json"]);
201
+ const text = valueAfter(args, "--text") ?? await readStdin();
202
+ if (!text.trim())
203
+ throw new Error("search-error requires --text or error text on stdin.");
204
+ const projectRoot = resolve(valueAfter(args, "--project") ?? process.cwd());
205
+ const value = await searchHindsight({ projectRoot, text, component: valueAfter(args, "--component") });
206
+ output(value, json, value.results.length > 0 ? value.results.map((item) => `${item.status ?? "note"}\t${item.id}\t${item.path}`).join("\n") : "No exact hindsight match found.");
207
+ return;
208
+ }
209
+ if (command === "promote") {
210
+ assertKnown(args, ["--with", "--confirm", "--json"], 1);
211
+ const source = args.find((arg) => !arg.startsWith("--") && args[args.indexOf(arg) - 1] !== "--with");
212
+ const corroborating = valueAfter(args, "--with");
213
+ if (!source || !corroborating)
214
+ throw new Error("promote requires NOTE-ID --with NOTE-ID.");
215
+ const value = await promoteNotes({ sourceNoteId: source, corroboratingNoteId: corroborating, confirm: args.includes("--confirm") });
216
+ output(value, json, `Promoted ${source} and ${corroborating} to ${value.shared_id}.`);
217
+ return;
218
+ }
219
+ if (command === "doctor") {
220
+ assertKnown(args, ["--repair", "--json"]);
221
+ const value = doctorNoteStore(args.includes("--repair"));
222
+ output(value, json, value.ok ? "Note store is healthy." : value.issues.join("\n"));
223
+ if (!value.ok)
224
+ process.exitCode = 2;
225
+ return;
226
+ }
227
+ throw new Error(`unknown notes command "${command}".`);
228
+ }
229
+ try {
230
+ await main();
231
+ }
232
+ catch (error) {
233
+ process.stderr.write(`cairn notes: ${error instanceof Error ? error.message : String(error)}\n`);
234
+ process.exitCode = 2;
235
+ }