@cairnkeep/cli 2.19.1 → 2.20.1
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 +31 -1
- package/docs/README.md +3 -0
- package/docs/ecosystem.md +33 -0
- package/docs/harness-compatibility.md +1 -1
- package/docs/learning/CURRICULUM-MAP.md +4 -2
- package/docs/learning/FEATURE-GUIDE.md +15 -1
- package/docs/learning/README.md +1 -0
- 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/learning/lessons/L27-ecosystem-evidence.md +93 -0
- package/docs/learning/video-scripts/V27-ecosystem-evidence.md +80 -0
- package/docs/operating.md +10 -4
- package/docs/privacy-and-data-flow.md +13 -2
- package/docs/research/ecosystem-evidence-2026-10.md +113 -0
- package/examples/eval/README.md +7 -0
- package/examples/eval/bundled-fake.json +1 -1
- package/examples/eval/ecosystem-decisions.json +42 -0
- package/mcp-memory-server/dist/compaction-normalize.js +37 -6
- package/mcp-memory-server/package.json +3 -3
- package/mcp-memory-server/scripts/fixtures/compaction-opencode-2.0.24.json +40 -0
- package/opencode/plugins/memory-capture.ts +354 -120
- package/opencode/plugins/memory-recall.ts +176 -57
- package/opencode/plugins/memory-wakeup.ts +147 -73
- package/package.json +3 -3
- package/scripts/lib/opencode-v2-plugin-harness.mjs +156 -0
- package/scripts/lib/trajectory-opencode-plugin-harness.mjs +6 -2
- package/scripts/spikes/run-clm-pi-evaluation.mjs +212 -0
- package/scripts/spikes/run-memfork-evaluation.mjs +153 -0
- package/scripts/spikes/run-ollaya-decision-evaluation.mjs +108 -0
- package/scripts/test-compaction-hooks.sh +6 -2
- package/scripts/test-ecosystem-spikes.sh +36 -0
- package/scripts/test-opencode-v2-plugins.sh +76 -0
- 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
|
|
11
|
-
// `client.session.messages()
|
|
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
|
|
104
|
-
//
|
|
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
|
-
|
|
124
|
-
|
|
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
|
-
|
|
127
|
-
event: async ({ event }) => {
|
|
362
|
+
void (async () => {
|
|
128
363
|
try {
|
|
129
|
-
|
|
130
|
-
|
|
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
|
|
135
|
-
const
|
|
136
|
-
|
|
416
|
+
const repo = directory
|
|
417
|
+
const agentfsDb = path.join(repo, ".agentfs", "project.db")
|
|
418
|
+
const trajectoryEnabled = trajectoryCaptureEnabled()
|
|
137
419
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
if (!
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
166
|
-
|
|
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
|
-
|
|
208
|
-
|
|
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
|
-
|
|
214
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
224
|
-
try {
|
|
225
|
-
parsed = JSON.parse(candidatesJson)
|
|
481
|
+
stageCandidates(repo, candidatesJson)
|
|
226
482
|
} catch {
|
|
227
|
-
|
|
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
|
-
}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
},
|
|
253
|
-
}
|
|
485
|
+
},
|
|
486
|
+
}
|
|
487
|
+
},
|
|
254
488
|
}
|