@cairnkeep/cli 2.19.1 → 2.20.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 (41) hide show
  1. package/CHANGELOG.md +21 -1
  2. package/docs/README.md +3 -0
  3. package/docs/ecosystem.md +33 -0
  4. package/docs/harness-compatibility.md +1 -1
  5. package/docs/learning/CURRICULUM-MAP.md +4 -2
  6. package/docs/learning/FEATURE-GUIDE.md +15 -1
  7. package/docs/learning/README.md +1 -0
  8. package/docs/learning/lessons/L00-why-cairnkeep.md +1 -1
  9. package/docs/learning/lessons/L01-safe-trial.md +1 -1
  10. package/docs/learning/lessons/L02-installation.md +1 -1
  11. package/docs/learning/lessons/L03-first-project.md +1 -1
  12. package/docs/learning/lessons/L19-validated-skill-improvement.md +1 -1
  13. package/docs/learning/lessons/L20-mcp-tool-profiles.md +1 -1
  14. package/docs/learning/lessons/L21-context-packs.md +1 -1
  15. package/docs/learning/lessons/L22-native-windows.md +1 -1
  16. package/docs/learning/lessons/L23-guided-setup.md +1 -1
  17. package/docs/learning/lessons/L24-okf-exchange.md +1 -1
  18. package/docs/learning/lessons/L25-playbooks.md +1 -1
  19. package/docs/learning/lessons/L27-ecosystem-evidence.md +93 -0
  20. package/docs/learning/video-scripts/V27-ecosystem-evidence.md +80 -0
  21. package/docs/operating.md +10 -4
  22. package/docs/privacy-and-data-flow.md +13 -2
  23. package/docs/research/ecosystem-evidence-2026-10.md +113 -0
  24. package/examples/eval/README.md +7 -0
  25. package/examples/eval/bundled-fake.json +1 -1
  26. package/examples/eval/ecosystem-decisions.json +42 -0
  27. package/mcp-memory-server/dist/compaction-normalize.js +37 -6
  28. package/mcp-memory-server/scripts/fixtures/compaction-opencode-2.0.24.json +40 -0
  29. package/opencode/plugins/memory-capture.ts +354 -120
  30. package/opencode/plugins/memory-recall.ts +176 -57
  31. package/opencode/plugins/memory-wakeup.ts +147 -73
  32. package/package.json +1 -1
  33. package/scripts/lib/opencode-v2-plugin-harness.mjs +156 -0
  34. package/scripts/lib/trajectory-opencode-plugin-harness.mjs +6 -2
  35. package/scripts/spikes/run-clm-pi-evaluation.mjs +212 -0
  36. package/scripts/spikes/run-memfork-evaluation.mjs +153 -0
  37. package/scripts/spikes/run-ollaya-decision-evaluation.mjs +108 -0
  38. package/scripts/test-compaction-hooks.sh +6 -2
  39. package/scripts/test-ecosystem-spikes.sh +36 -0
  40. package/scripts/test-opencode-v2-plugins.sh +76 -0
  41. package/scripts/test-package-install.sh +9 -2
@@ -1,4 +1,5 @@
1
- import type { Plugin } from "@opencode-ai/plugin"
1
+ import type { Plugin as V1Plugin } from "@opencode-ai/plugin"
2
+ import type { Plugin as V2 } from "@opencode/plugin"
2
3
  import { spawn } from "node:child_process"
3
4
  import fs from "node:fs"
4
5
  import path from "node:path"
@@ -7,8 +8,8 @@ import path from "node:path"
7
8
  // hook (OCP-01, D-08). OpenCode has no `session.end` event; per 04-RESEARCH.md
8
9
  // the best-fit trigger is `session.idle` (D-09), filtered to top-level
9
10
  // sessions and de-duplicated so one working session stages at most one
10
- // capture (Pitfall 2). The session transcript is obtained via the SDK
11
- // `client.session.messages()` call (04-SPIKE-INJECTION.md confirmed shape),
11
+ // capture (Pitfall 2). The session transcript is obtained via the supported
12
+ // client API (V1: SDK `client.session.messages()`, V2: `ctx.session.context()`),
12
13
  // never by reading OpenCode's undocumented internal storage layout directly
13
14
  // (Anti-Pattern).
14
15
  //
@@ -23,6 +24,17 @@ import path from "node:path"
23
24
  // must never wedge an OpenCode session — the whole handler is try/catch, and
24
25
  // a missing staging file after a fire-and-forget event handler is a tolerated
25
26
  // degraded outcome, not a retry loop (Pitfall 3).
27
+ //
28
+ // V1/V2 dual entrypoint (2026-09-08): see memory-wakeup.ts's header — V2
29
+ // (opencode2) requires the default-exported `{ id, setup }` definition
30
+ // (server log ref err_128a87db); V1 >= 1.18.29 uses `server()` on the same
31
+ // object. The V2 event stream carries `session.idle` / `session.compacted`
32
+ // under `event.data` (V1 used `event.properties`), and V2 transcripts are a
33
+ // message union (user/assistant/compaction variants), not the V1
34
+ // { info, parts } shape. Trajectory/extraction capture maps that union to the
35
+ // legacy transcript contract, while native compaction capture passes the V2
36
+ // shape unchanged to the exact 2.0.24 adapter. Unknown future shapes still
37
+ // fail open in the version-pinned CLI normalizer.
26
38
 
27
39
  const SERVER_ENTRY = "@@INFRA_ROOT@@/mcp-memory-server/dist/index.js"
28
40
  const TRAJECTORY_ENTRY = "@@INFRA_ROOT@@/mcp-memory-server/dist/trajectory-cli.js"
@@ -31,8 +43,8 @@ const MAX_CHARS = 12000
31
43
  const RETENTION_CAP = 5
32
44
 
33
45
  type SessionMessage = {
34
- info?: { role?: string }
35
- parts?: Array<{ type?: string; text?: string }>
46
+ info?: { role?: string; [key: string]: unknown }
47
+ parts?: Array<{ type?: string; text?: string; [key: string]: unknown }>
36
48
  }
37
49
 
38
50
  // Runs `node <serverEntry> extract <model>` piping `input` on stdin via
@@ -100,8 +112,9 @@ function compactionCaptureEnabled(): boolean {
100
112
 
101
113
  // Reimplements scripts/transcript-to-text.mjs's behavior (skip tool/reasoning/
102
114
  // file noise, join user/assistant text in order, cap total length keeping the
103
- // most recent turns) against OpenCode's { info, parts } message shape rather
104
- // than Claude's JSONL transcript shape (Pattern 3).
115
+ // most recent turns) against the { info, parts } message shape rather than
116
+ // Claude's JSONL transcript shape (Pattern 3). Both runtimes feed this the
117
+ // normalized legacy shape (V1 natively, V2 via toLegacyMessages below).
105
118
  function messagesToText(messages: SessionMessage[]): string {
106
119
  const turns: string[] = []
107
120
  for (const message of messages) {
@@ -120,135 +133,356 @@ function messagesToText(messages: SessionMessage[]): string {
120
133
  return out
121
134
  }
122
135
 
123
- export const MemoryCapturePlugin: Plugin = async ({ client, directory }) => {
124
- const processed = new Set<string>()
136
+ // V2 (opencode2) `session.context()` returns a message union (user/assistant/
137
+ // compaction/…) whose shape does not match the { info, parts } transcript
138
+ // contract the mcp-memory-server normalizers parse (and the CLI contract is
139
+ // deliberately pinned there). Map V2 messages back to the legacy shape,
140
+ // preserving role, id, sessionID, timing, finish, text and tool-call fields.
141
+ function toLegacyMessages(sessionID: string, messages: unknown[]): SessionMessage[] {
142
+ const out: SessionMessage[] = []
143
+ for (const raw of messages) {
144
+ const msg = raw as {
145
+ type?: string
146
+ id?: string
147
+ text?: string
148
+ finish?: string
149
+ time?: { created?: number; completed?: number }
150
+ content?: Array<{
151
+ type?: string
152
+ text?: string
153
+ id?: string
154
+ name?: string
155
+ time?: { created?: number; ran?: number; completed?: number }
156
+ state?: { status?: string; input?: unknown; content?: unknown; error?: unknown }
157
+ }>
158
+ }
159
+ if (!msg || typeof msg !== "object") continue
160
+ if (msg.type === "user") {
161
+ out.push({
162
+ info: { role: "user", id: msg.id, sessionID, time: msg.time },
163
+ parts: typeof msg.text === "string" && msg.text ? [{ type: "text", text: msg.text, messageID: msg.id }] : [],
164
+ })
165
+ } else if (msg.type === "assistant") {
166
+ const parts: NonNullable<SessionMessage["parts"]> = []
167
+ for (const item of msg.content ?? []) {
168
+ if (!item || typeof item !== "object") continue
169
+ if (item.type === "text" && typeof item.text === "string") {
170
+ parts.push({ type: "text", text: item.text, messageID: msg.id })
171
+ } else if (item.type === "reasoning") {
172
+ parts.push({ type: "reasoning", text: typeof item.text === "string" ? item.text : "" })
173
+ } else if (item.type === "tool") {
174
+ parts.push({
175
+ type: "tool",
176
+ id: item.id,
177
+ tool: item.name,
178
+ callID: item.id,
179
+ state: {
180
+ status: item.state?.status,
181
+ input: item.state?.input,
182
+ output: item.state?.content,
183
+ error: item.state?.error,
184
+ time: { start: item.time?.ran ?? item.time?.created, end: item.time?.completed },
185
+ },
186
+ })
187
+ }
188
+ }
189
+ out.push({ info: { role: "assistant", id: msg.id, sessionID, time: msg.time, finish: msg.finish }, parts })
190
+ } else if (msg.type === "compaction") {
191
+ out.push({
192
+ info: { role: "user", id: msg.id, sessionID, time: msg.time },
193
+ parts: [{ type: "compaction" }],
194
+ })
195
+ }
196
+ }
197
+ return out
198
+ }
199
+
200
+ // Promise client unwrapping is defensive: methods currently resolve to bare
201
+ // values, but { data } envelopes would be a tolerated alternative shape.
202
+ async function fetchSession(ctx: V2.Context, sessionID: string) {
203
+ const res = (await ctx.session.get({ sessionID })) as unknown
204
+ return ((res as { data?: unknown })?.data ?? res) as
205
+ | { id?: string; parentID?: string; time?: Record<string, unknown>; location?: { directory?: string } }
206
+ | undefined
207
+ }
208
+
209
+ async function fetchV2Messages(ctx: V2.Context, sessionID: string): Promise<unknown[]> {
210
+ const res = (await ctx.session.context({ sessionID })) as unknown
211
+ const list = Array.isArray(res) ? res : ((res as { data?: unknown })?.data ?? [])
212
+ return Array.isArray(list) ? list : []
213
+ }
214
+
215
+ async function fetchLegacyMessages(ctx: V2.Context, sessionID: string): Promise<SessionMessage[]> {
216
+ return toLegacyMessages(sessionID, await fetchV2Messages(ctx, sessionID))
217
+ }
218
+
219
+ // Shared V1/V2 staging contract (identical to claude/hooks/memory-capture.sh,
220
+ // D-08): one file per session, same candidate JSON shape, same UTC timestamp
221
+ // filename, bounded by RETENTION_CAP.
222
+ function stageCandidates(repo: string, candidatesJson: string): void {
223
+ const stagingDir = path.join(repo, ".planning", "memory-staging")
224
+ fs.mkdirSync(stagingDir, { recursive: true })
225
+ const ts = new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z")
226
+ const stageFile = path.join(stagingDir, `${ts}.json`)
227
+ fs.writeFileSync(stageFile, `${candidatesJson}\n`)
228
+
229
+ // Keep the staging dir bounded — drop the oldest beyond 5 sessions.
230
+ const staged = fs
231
+ .readdirSync(stagingDir)
232
+ .filter((f) => f.endsWith(".json"))
233
+ .map((f) => ({ f, mtime: fs.statSync(path.join(stagingDir, f)).mtimeMs }))
234
+ .sort((a, b) => b.mtime - a.mtime)
235
+ for (const { f } of staged.slice(RETENTION_CAP)) {
236
+ fs.unlinkSync(path.join(stagingDir, f))
237
+ }
238
+ }
239
+
240
+ // V2 (opencode2) event handling. Event types keep their V1 names
241
+ // (`session.idle`, `session.compacted`) but carry the payload under `data`
242
+ // instead of V1's `properties`, and the stream is server-global — sessions
243
+ // from other locations/projects appear here, so every event is filtered by
244
+ // the session's own location before it can stage anything into this repo.
245
+ async function handleV2Event(
246
+ ctx: V2.Context,
247
+ repo: string,
248
+ processed: Set<string>,
249
+ event: { type?: string; data?: { sessionID?: string } },
250
+ ): Promise<void> {
251
+ try {
252
+ if (event.type === "session.compacted") {
253
+ if (!compactionCaptureEnabled()) return
254
+ const sessionID = event.data?.sessionID
255
+ if (!sessionID) return
256
+
257
+ const session = await fetchSession(ctx, sessionID)
258
+ if (!session || session.parentID) return
259
+ if (session.location?.directory && session.location.directory !== repo) return
260
+
261
+ const messages = await fetchV2Messages(ctx, sessionID)
262
+ if (!fs.existsSync(ARTIFACT_ENTRY)) return
263
+
264
+ const capture = await runNode(
265
+ ARTIFACT_ENTRY,
266
+ ["capture-opencode", repo],
267
+ JSON.stringify({ event, session, messages, harness_version: "2.0.24" }),
268
+ 3000,
269
+ )
270
+ if (capture.stderr.trim()) {
271
+ console.warn("cairn compaction capture skipped: local capture failed")
272
+ }
273
+ return
274
+ }
275
+
276
+ if (event.type !== "session.idle") return
277
+ const sessionID = event.data?.sessionID
278
+ if (!sessionID) return
279
+ // De-dupe: one attempt per real top-level session, no matter how
280
+ // many times session.idle fires within it (Pitfall 2).
281
+ if (processed.has(sessionID)) return
282
+
283
+ const agentfsDb = path.join(repo, ".agentfs", "project.db")
284
+ const trajectoryEnabled = trajectoryCaptureEnabled()
285
+
286
+ // Preserve the original disabled path: all pre-existing guards run
287
+ // before any client request or subprocess when trajectory capture is off.
288
+ if (!trajectoryEnabled) {
289
+ if (!fs.existsSync(agentfsDb)) return
290
+ if (!fs.existsSync(SERVER_ENTRY)) return
291
+ }
292
+ const apiKey = process.env.CAIRN_LLM_API_KEY
293
+ const model = process.env.CAIRN_LLM_EXTRACTION_MODEL
294
+ if (!trajectoryEnabled && (!apiKey || !model)) return
295
+
296
+ // session.idle's event payload only carries sessionID (no parentID) —
297
+ // fetch the full session record to filter out subagent subsessions
298
+ // (Pitfall 2: subagent tool calls also idle and would otherwise
299
+ // over-stage past the 5-session retention cap's intent).
300
+ const session = await fetchSession(ctx, sessionID)
301
+ if (session?.parentID) return
302
+ if (session?.location?.directory && session.location.directory !== repo) return
303
+
304
+ // Mark processed before doing the (possibly slow) extract call so a
305
+ // second session.idle fire for this session never double-attempts,
306
+ // even if this attempt ultimately yields no staged file.
307
+ processed.add(sessionID)
308
+
309
+ const messages = await fetchLegacyMessages(ctx, sessionID)
310
+
311
+ if (trajectoryEnabled && fs.existsSync(TRAJECTORY_ENTRY)) {
312
+ const payload = JSON.stringify({
313
+ session: { ...session, id: session?.id ?? sessionID },
314
+ messages,
315
+ })
316
+ const capture = await runNode(TRAJECTORY_ENTRY, ["capture-opencode", repo], payload, 3000)
317
+ if (capture.stderr.trim()) {
318
+ console.warn("cairn trajectory capture skipped: local capture failed")
319
+ }
320
+ }
321
+
322
+ // Durable-memory extraction retains its independent project/model
323
+ // gates and behavior when trajectory capture is enabled.
324
+ if (!fs.existsSync(agentfsDb)) return
325
+ if (!fs.existsSync(SERVER_ENTRY)) return
326
+ if (!apiKey || !model) return
327
+
328
+ const text = messagesToText(messages)
329
+ if (!text) return
330
+
331
+ // Pipe the session text into the shared `extract` subcommand via
332
+ // stdin (T-04-01 mitigation) — never string-interpolate arbitrary
333
+ // session/message content into the shell command.
334
+ const res = await runNode(SERVER_ENTRY, ["extract", model], text)
335
+ const candidatesJson = String(res.stdout ?? "").trim()
336
+ if (!candidatesJson) return
337
+
338
+ let parsed: { candidates?: unknown[] }
339
+ try {
340
+ parsed = JSON.parse(candidatesJson)
341
+ } catch {
342
+ return
343
+ }
344
+ if (!Array.isArray(parsed.candidates) || parsed.candidates.length === 0) return
345
+
346
+ stageCandidates(repo, candidatesJson)
347
+ } catch {
348
+ // Fail open — never block a session because capture failed.
349
+ }
350
+ }
351
+
352
+ export default {
353
+ id: "cairnkeep.memory-capture",
354
+
355
+ // V2 (opencode2) entrypoint: consume the public event stream for the
356
+ // lifetime of the plugin instance; abort the subscription on cleanup.
357
+ async setup(ctx: V2.Context) {
358
+ const repo = ctx.location.directory
359
+ const processed = new Set<string>()
360
+ const controller = new AbortController()
125
361
 
126
- return {
127
- event: async ({ event }) => {
362
+ void (async () => {
128
363
  try {
129
- if (event.type === "session.compacted") {
130
- if (!compactionCaptureEnabled()) return
364
+ for await (const rawEvent of ctx.event.subscribe({ signal: controller.signal })) {
365
+ await handleV2Event(ctx, repo, processed, rawEvent as { type?: string; data?: { sessionID?: string } })
366
+ }
367
+ } catch {
368
+ // Event stream failed — fail open (D-03); capture is best-effort.
369
+ }
370
+ })()
371
+
372
+ return () => controller.abort()
373
+ },
374
+
375
+ // V1 (opencode >= 1.18.29 documented object form) entrypoint — legacy
376
+ // implementation kept verbatim on its own API (`client` is the V1 SDK).
377
+ async server(input: Parameters<V1Plugin>[0]) {
378
+ const { client, directory } = input
379
+ const processed = new Set<string>()
380
+
381
+ return {
382
+ event: async ({ event }: { event: { type?: string; properties?: { sessionID?: string } } }) => {
383
+ try {
384
+ if (event.type === "session.compacted") {
385
+ if (!compactionCaptureEnabled()) return
386
+ const sessionID = event.properties?.sessionID
387
+ if (!sessionID) return
388
+
389
+ const sessionRes = await client.session.get({ path: { id: sessionID } })
390
+ const session = (sessionRes as { data?: { id?: string; parentID?: string; version?: string } })?.data
391
+ if (!session || session.parentID) return
392
+
393
+ const messagesRes = await client.session.messages({ path: { id: sessionID } })
394
+ const messages = ((messagesRes as { data?: unknown[] })?.data ?? []) as unknown[]
395
+ if (!fs.existsSync(ARTIFACT_ENTRY)) return
396
+
397
+ const capture = await runNode(
398
+ ARTIFACT_ENTRY,
399
+ ["capture-opencode", directory],
400
+ JSON.stringify({ event, session, messages, harness_version: "1.17.20" }),
401
+ 3000,
402
+ )
403
+ if (capture.stderr.trim()) {
404
+ console.warn("cairn compaction capture skipped: local capture failed")
405
+ }
406
+ return
407
+ }
408
+
409
+ if (event.type !== "session.idle") return
131
410
  const sessionID = event.properties?.sessionID
132
411
  if (!sessionID) return
412
+ // De-dupe: one attempt per real top-level session, no matter how
413
+ // many times session.idle fires within it (Pitfall 2).
414
+ if (processed.has(sessionID)) return
133
415
 
134
- const sessionRes = await client.session.get({ path: { id: sessionID } })
135
- const session = (sessionRes as { data?: { id?: string; parentID?: string; version?: string } })?.data
136
- if (!session || session.parentID) return
416
+ const repo = directory
417
+ const agentfsDb = path.join(repo, ".agentfs", "project.db")
418
+ const trajectoryEnabled = trajectoryCaptureEnabled()
137
419
 
138
- const messagesRes = await client.session.messages({ path: { id: sessionID } })
139
- const messages = ((messagesRes as { data?: unknown[] })?.data ?? []) as unknown[]
140
- if (!fs.existsSync(ARTIFACT_ENTRY)) return
141
-
142
- const capture = await runNode(
143
- ARTIFACT_ENTRY,
144
- ["capture-opencode", directory],
145
- JSON.stringify({ event, session, messages, harness_version: "1.17.20" }),
146
- 3000,
147
- )
148
- if (capture.stderr.trim()) {
149
- console.warn("cairn compaction capture skipped: local capture failed")
420
+ // Preserve the original disabled path: all pre-existing guards run
421
+ // before any SDK request or subprocess when trajectory capture is off.
422
+ if (!trajectoryEnabled) {
423
+ if (!fs.existsSync(agentfsDb)) return
424
+ if (!fs.existsSync(SERVER_ENTRY)) return
150
425
  }
151
- return
152
- }
426
+ const apiKey = process.env.CAIRN_LLM_API_KEY
427
+ const model = process.env.CAIRN_LLM_EXTRACTION_MODEL
428
+ if (!trajectoryEnabled && (!apiKey || !model)) return
429
+
430
+ // session.idle's event payload only carries sessionID (no parentID) —
431
+ // fetch the full session record to filter out subagent subsessions
432
+ // (Pitfall 2: subagent tool calls also idle and would otherwise
433
+ // over-stage past the 5-session retention cap's intent).
434
+ const sessionRes = await client.session.get({ path: { id: sessionID } })
435
+ const session = (sessionRes as { data?: { id?: string; parentID?: string; time?: unknown } })?.data
436
+ if (session?.parentID) return
153
437
 
154
- if (event.type !== "session.idle") return
155
- const sessionID = event.properties?.sessionID
156
- if (!sessionID) return
157
- // De-dupe: one attempt per real top-level session, no matter how
158
- // many times session.idle fires within it (Pitfall 2).
159
- if (processed.has(sessionID)) return
438
+ // Mark processed before doing the (possibly slow) extract call so a
439
+ // second session.idle fire for this session never double-attempts,
440
+ // even if this attempt ultimately yields no staged file.
441
+ processed.add(sessionID)
160
442
 
161
- const repo = directory
162
- const agentfsDb = path.join(repo, ".agentfs", "project.db")
163
- const trajectoryEnabled = trajectoryCaptureEnabled()
443
+ const messagesRes = await client.session.messages({ path: { id: sessionID } })
444
+ const messages = ((messagesRes as { data?: SessionMessage[] })?.data ?? []) as SessionMessage[]
445
+
446
+ if (trajectoryEnabled && fs.existsSync(TRAJECTORY_ENTRY)) {
447
+ const payload = JSON.stringify({
448
+ session: { ...session, id: session?.id ?? sessionID },
449
+ messages,
450
+ })
451
+ const capture = await runNode(TRAJECTORY_ENTRY, ["capture-opencode", repo], payload, 3000)
452
+ if (capture.stderr.trim()) {
453
+ console.warn("cairn trajectory capture skipped: local capture failed")
454
+ }
455
+ }
164
456
 
165
- // Preserve the original disabled path: all pre-existing guards run
166
- // before any SDK request or subprocess when trajectory capture is off.
167
- if (!trajectoryEnabled) {
457
+ // Durable-memory extraction retains its independent project/model
458
+ // gates and behavior when trajectory capture is enabled.
168
459
  if (!fs.existsSync(agentfsDb)) return
169
460
  if (!fs.existsSync(SERVER_ENTRY)) return
170
- }
171
- const apiKey = process.env.CAIRN_LLM_API_KEY
172
- const model = process.env.CAIRN_LLM_EXTRACTION_MODEL
173
- if (!trajectoryEnabled && (!apiKey || !model)) return
174
-
175
- // session.idle's event payload only carries sessionID (no parentID) —
176
- // fetch the full session record to filter out subagent subsessions
177
- // (Pitfall 2: subagent tool calls also idle and would otherwise
178
- // over-stage past the 5-session retention cap's intent).
179
- const sessionRes = await client.session.get({ path: { id: sessionID } })
180
- const session = (sessionRes as { data?: { id?: string; parentID?: string; time?: unknown } })?.data
181
- if (session?.parentID) return
182
-
183
- // Mark processed before doing the (possibly slow) extract call so a
184
- // second session.idle fire for this session never double-attempts,
185
- // even if this attempt ultimately yields no staged file.
186
- processed.add(sessionID)
187
-
188
- const messagesRes = await client.session.messages({ path: { id: sessionID } })
189
- const messages = ((messagesRes as { data?: SessionMessage[] })?.data ?? []) as SessionMessage[]
190
-
191
- if (trajectoryEnabled && fs.existsSync(TRAJECTORY_ENTRY)) {
192
- const payload = JSON.stringify({
193
- session: { ...session, id: session?.id ?? sessionID },
194
- messages,
195
- })
196
- const capture = await runNode(
197
- TRAJECTORY_ENTRY,
198
- ["capture-opencode", repo],
199
- payload,
200
- 3000,
201
- )
202
- if (capture.stderr.trim()) {
203
- console.warn("cairn trajectory capture skipped: local capture failed")
204
- }
205
- }
461
+ if (!apiKey || !model) return
206
462
 
207
- // Durable-memory extraction retains its independent project/model
208
- // gates and behavior when trajectory capture is enabled.
209
- if (!fs.existsSync(agentfsDb)) return
210
- if (!fs.existsSync(SERVER_ENTRY)) return
211
- if (!apiKey || !model) return
463
+ const text = messagesToText(messages)
464
+ if (!text) return
212
465
 
213
- const text = messagesToText(messages)
214
- if (!text) return
466
+ // Pipe the session text into the shared `extract` subcommand via
467
+ // stdin (T-04-01 mitigation) — never string-interpolate arbitrary
468
+ // session/message content into the shell command.
469
+ const res = await runNode(SERVER_ENTRY, ["extract", model], text)
470
+ const candidatesJson = String(res.stdout ?? "").trim()
471
+ if (!candidatesJson) return
215
472
 
216
- // Pipe the session text into the shared `extract` subcommand via
217
- // stdin (T-04-01 mitigation) — never string-interpolate arbitrary
218
- // session/message content into the shell command.
219
- const res = await runNode(SERVER_ENTRY, ["extract", model], text)
220
- const candidatesJson = String(res.stdout ?? "").trim()
221
- if (!candidatesJson) return
473
+ let parsed: { candidates?: unknown[] }
474
+ try {
475
+ parsed = JSON.parse(candidatesJson)
476
+ } catch {
477
+ return
478
+ }
479
+ if (!Array.isArray(parsed.candidates) || parsed.candidates.length === 0) return
222
480
 
223
- let parsed: { candidates?: unknown[] }
224
- try {
225
- parsed = JSON.parse(candidatesJson)
481
+ stageCandidates(repo, candidatesJson)
226
482
  } catch {
227
- return
228
- }
229
- if (!Array.isArray(parsed.candidates) || parsed.candidates.length === 0) return
230
-
231
- // Stage for the next session's accept gate — identical contract to
232
- // claude/hooks/memory-capture.sh (D-08): one file per session, same
233
- // candidate JSON shape, same UTC timestamp filename.
234
- const stagingDir = path.join(repo, ".planning", "memory-staging")
235
- fs.mkdirSync(stagingDir, { recursive: true })
236
- const ts = new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z")
237
- const stageFile = path.join(stagingDir, `${ts}.json`)
238
- fs.writeFileSync(stageFile, `${candidatesJson}\n`)
239
-
240
- // Keep the staging dir bounded — drop the oldest beyond 5 sessions.
241
- const staged = fs
242
- .readdirSync(stagingDir)
243
- .filter((f) => f.endsWith(".json"))
244
- .map((f) => ({ f, mtime: fs.statSync(path.join(stagingDir, f)).mtimeMs }))
245
- .sort((a, b) => b.mtime - a.mtime)
246
- for (const { f } of staged.slice(RETENTION_CAP)) {
247
- fs.unlinkSync(path.join(stagingDir, f))
483
+ // Fail open — never block a session because capture failed.
248
484
  }
249
- } catch {
250
- // Fail open — never block a session because capture failed.
251
- }
252
- },
253
- }
485
+ },
486
+ }
487
+ },
254
488
  }