@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,6 @@
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"
3
+ import { spawn } from "node:child_process"
2
4
  import fs from "node:fs"
3
5
  import path from "node:path"
4
6
 
@@ -13,9 +15,12 @@ import path from "node:path"
13
15
  // The only confirmed mechanism is to `throw new Error(text)`, which the model
14
16
  // sees as the tool call's result and must retry — this blocks the first
15
17
  // attempt at a matched edit. A once-per-file-per-session guard prevents
16
- // re-throwing (and re-blocking) the same file forever (T-04-09).
18
+ // re-throwing (and re-blocking) the same file forever (T-04-09). The same
19
+ // throw-to-surface mechanism is used by the V2 `ctx.tool.hook
20
+ // ("execute.before")` registration; the V2 hook fails the tool call with the
21
+ // thrown message, which the model also sees as the call's result.
17
22
  //
18
- // Known OpenCode limitation (Pitfall 4, anomalyco/opencode#5894): this hook
23
+ // Known OpenCode limitation (Pitfall 4, anomalyco/opencode#5894): the V1 hook
19
24
  // does not fire for tool calls issued by subagents spawned via the `task`
20
25
  // tool — subagent-issued edits silently bypass recall injection. Documented
21
26
  // scope limitation, not addressed by this phase.
@@ -27,6 +32,11 @@ import path from "node:path"
27
32
  //
28
33
  // Fail-open everywhere (D-03): a missing server binary, missing
29
34
  // .agentfs/.planning, or any unexpected error must never block an edit.
35
+ //
36
+ // V1/V2 dual entrypoint (2026-09-08): see memory-wakeup.ts's header — V2
37
+ // (opencode2) requires the default-exported `{ id, setup }` definition
38
+ // (server log ref err_128a87db); V1 >= 1.18.29 uses `server()` on the same
39
+ // object.
30
40
 
31
41
  const SERVER_ENTRY = "@@INFRA_ROOT@@/mcp-memory-server/dist/index.js"
32
42
  const MIN_STEM_LENGTH = 4
@@ -43,22 +53,108 @@ function isContained(baseDir: string, candidate: string): boolean {
43
53
  return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel)
44
54
  }
45
55
 
46
- export const MemoryRecallPlugin: Plugin = async ({ $, directory }) => {
47
- const surfaced = new Set<string>()
56
+ // node's own child_process (never the plugin runtime's `$` — V2's context has
57
+ // no BunShell at all, and `$` had a broken stdin writer under this V1 build
58
+ // anyway; the OCP-06 defect note in memory-capture.ts applies).
59
+ function runNode(entry: string, args: string[], timeoutMs = 3000): Promise<string> {
60
+ return new Promise((resolvePromise) => {
61
+ let stdout = ""
62
+ let settled = false
63
+ const finish = () => {
64
+ if (settled) return
65
+ settled = true
66
+ clearTimeout(timer)
67
+ resolvePromise(stdout)
68
+ }
69
+ const child = spawn("node", [entry, ...args], {
70
+ stdio: ["ignore", "pipe", "ignore"],
71
+ })
72
+ const timer = setTimeout(() => {
73
+ try {
74
+ child.kill("SIGKILL")
75
+ } catch {
76
+ // best-effort kill only
77
+ }
78
+ }, timeoutMs)
79
+ child.stdout?.on("data", (chunk: Buffer) => {
80
+ stdout += chunk.toString("utf8")
81
+ if (stdout.length > 262144) {
82
+ stdout = ""
83
+ try {
84
+ child.kill("SIGKILL")
85
+ } catch {
86
+ // best-effort kill only
87
+ }
88
+ }
89
+ })
90
+ child.on("close", finish)
91
+ child.on("error", finish)
92
+ })
93
+ }
94
+
95
+ // Shared V1/V2 matching rules (OCP-02, D-10): `wakeupIndex` is the compact
96
+ // AgentFS wakeup listing ("" when unavailable). Returns the bounded
97
+ // surface-context text, or null when nothing specific matched (routine edits
98
+ // proceed silently).
99
+ function buildRecallContext(repo: string, base: string, stem: string, wakeupIndex: string): string | null {
100
+ const sections: string[] = []
101
+
102
+ // 1. AgentFS project memory: filter the compact wakeup index by stem.
103
+ if (wakeupIndex) {
104
+ const hitLines = wakeupIndex
105
+ .split("\n")
106
+ .filter((line) => line.toLowerCase().includes(stem.toLowerCase()))
107
+ .slice(0, MAX_MEMORY_HITS)
108
+ if (hitLines.length > 0) {
109
+ sections.push(`## Relevant project memory for ${base}`, "", hitLines.join("\n"))
110
+ }
111
+ }
48
112
 
49
- return {
50
- "tool.execute.before": async (input, output) => {
113
+ // 2. Wiki source pages (top level only) whose content mentions the
114
+ // stem. Reads are confined to wikiSourcesDir via isContained() —
115
+ // filePath (untrusted) only supplied the stem grep token above, it
116
+ // is never used to build a read path here.
117
+ const wikiSourcesDir = path.join(repo, ".planning", "wiki", "sources")
118
+ if (fs.existsSync(wikiSourcesDir)) {
119
+ const wikiHits: string[] = []
120
+ const entries = fs.readdirSync(wikiSourcesDir, { withFileTypes: true })
121
+ for (const entry of entries) {
122
+ if (!entry.isFile() || !entry.name.endsWith(".md")) continue
123
+ const pagePath = path.join(wikiSourcesDir, entry.name)
124
+ if (!isContained(wikiSourcesDir, pagePath)) continue
125
+ const content = fs.readFileSync(pagePath, "utf8")
126
+ if (!content.toLowerCase().includes(stem.toLowerCase())) continue
127
+ const teaserMatch = content.match(/^- \*\*.*$/m)
128
+ const teaser = teaserMatch ? teaserMatch[0].slice(0, 160) : ""
129
+ wikiHits.push(`- [${entry.name}] ${teaser}`)
130
+ }
131
+ if (wikiHits.length > 0) {
132
+ sections.push(`## Relevant wiki pages for ${base}`, "", wikiHits.join("\n"))
133
+ }
134
+ }
135
+
136
+ if (sections.length === 0) return null
137
+ return sections.join("\n\n").split("\n").slice(0, MAX_CONTEXT_LINES).join("\n")
138
+ }
139
+
140
+ export default {
141
+ id: "cairnkeep.memory-recall",
142
+
143
+ // V2 (opencode2) entrypoint.
144
+ async setup(ctx: V2.Context) {
145
+ const repo = ctx.location.directory
146
+ const surfaced = new Set<string>()
147
+
148
+ await ctx.tool.hook("execute.before", async (event) => {
51
149
  try {
52
- if (input.tool !== "edit" && input.tool !== "write") return
150
+ if (event.tool !== "edit" && event.tool !== "write") return
53
151
 
54
- const filePath: string | undefined = output.args?.filePath ?? output.args?.path
152
+ const args = event.input as { filePath?: string; path?: string } | undefined
153
+ const filePath = args?.filePath ?? args?.path
55
154
  if (!filePath) return
56
155
 
57
- const repo = directory
58
- const agentfsDb = path.join(repo, ".agentfs", "project.db")
59
- const wikiSourcesDir = path.join(repo, ".planning", "wiki", "sources")
60
- const hasAgentfs = fs.existsSync(agentfsDb)
61
- const hasWiki = fs.existsSync(wikiSourcesDir)
156
+ const hasAgentfs = fs.existsSync(path.join(repo, ".agentfs", "project.db"))
157
+ const hasWiki = fs.existsSync(path.join(repo, ".planning", "wiki", "sources"))
62
158
  if (!hasAgentfs && !hasWiki) return
63
159
 
64
160
  const base = path.basename(filePath)
@@ -70,54 +166,18 @@ export const MemoryRecallPlugin: Plugin = async ({ $, directory }) => {
70
166
  // Once-per-file-per-session guard (T-04-09): after surfacing for
71
167
  // this file once in this session, let subsequent edits/retries of
72
168
  // the same file proceed unmodified — never re-block in a loop.
73
- const dedupeKey = `${input.sessionID}:${filePath}`
169
+ const dedupeKey = `${event.sessionID}:${filePath}`
74
170
  if (surfaced.has(dedupeKey)) return
75
171
 
76
- const sections: string[] = []
77
-
78
- // 1. AgentFS project memory: filter the compact wakeup index by stem.
172
+ let wakeupIndex = ""
79
173
  if (hasAgentfs && fs.existsSync(SERVER_ENTRY)) {
80
- const res = await $`node ${SERVER_ENTRY} wakeup`.quiet().nothrow()
81
- const idx = String(res.stdout ?? "").trim()
82
- if (idx) {
83
- const hitLines = idx
84
- .split("\n")
85
- .filter((line) => line.toLowerCase().includes(stem.toLowerCase()))
86
- .slice(0, MAX_MEMORY_HITS)
87
- if (hitLines.length > 0) {
88
- sections.push(`## Relevant project memory for ${base}`, "", hitLines.join("\n"))
89
- }
90
- }
174
+ wakeupIndex = (await runNode(SERVER_ENTRY, ["wakeup"])).trim()
91
175
  }
92
176
 
93
- // 2. Wiki source pages (top level only) whose content mentions the
94
- // stem. Reads are confined to wikiSourcesDir via isContained() —
95
- // filePath (untrusted) only supplied the stem grep token above, it
96
- // is never used to build a read path here.
97
- if (hasWiki) {
98
- const wikiHits: string[] = []
99
- const entries = fs.readdirSync(wikiSourcesDir, { withFileTypes: true })
100
- for (const entry of entries) {
101
- if (!entry.isFile() || !entry.name.endsWith(".md")) continue
102
- const pagePath = path.join(wikiSourcesDir, entry.name)
103
- if (!isContained(wikiSourcesDir, pagePath)) continue
104
- const content = fs.readFileSync(pagePath, "utf8")
105
- if (!content.toLowerCase().includes(stem.toLowerCase())) continue
106
- const teaserMatch = content.match(/^- \*\*.*$/m)
107
- const teaser = teaserMatch ? teaserMatch[0].slice(0, 160) : ""
108
- wikiHits.push(`- [${entry.name}] ${teaser}`)
109
- }
110
- if (wikiHits.length > 0) {
111
- sections.push(`## Relevant wiki pages for ${base}`, "", wikiHits.join("\n"))
112
- }
113
- }
114
-
115
- // Inject only when there is something specific (high-signal /
116
- // low-noise) — routine edits with no match proceed silently.
117
- if (sections.length === 0) return
177
+ const context = buildRecallContext(repo, base, stem, wakeupIndex)
178
+ if (!context) return
118
179
 
119
180
  surfaced.add(dedupeKey)
120
- const context = sections.join("\n\n").split("\n").slice(0, MAX_CONTEXT_LINES).join("\n")
121
181
  throw new Error(`Memory recall (auto-injected for this file edit):\n\n${context}`)
122
182
  } catch (err) {
123
183
  // Re-throw our own intentional surface-context error (it carries the
@@ -127,6 +187,65 @@ export const MemoryRecallPlugin: Plugin = async ({ $, directory }) => {
127
187
  throw err
128
188
  }
129
189
  }
130
- },
131
- }
190
+ })
191
+ },
192
+
193
+ // V1 (opencode >= 1.18.29 documented object form) entrypoint — legacy
194
+ // implementation kept verbatim on its own API (`$` is V1's BunShell).
195
+ async server(input: Parameters<V1Plugin>[0]) {
196
+ const { $, directory } = input
197
+ const surfaced = new Set<string>()
198
+
199
+ return {
200
+ "tool.execute.before": async (
201
+ hookInput: { tool: string; sessionID: string },
202
+ hookOutput: { args?: { filePath?: string; path?: string } },
203
+ ) => {
204
+ try {
205
+ if (hookInput.tool !== "edit" && hookInput.tool !== "write") return
206
+
207
+ const filePath = hookOutput.args?.filePath ?? hookOutput.args?.path
208
+ if (!filePath) return
209
+
210
+ const repo = directory
211
+ const agentfsDb = path.join(repo, ".agentfs", "project.db")
212
+ const wikiSourcesDir = path.join(repo, ".planning", "wiki", "sources")
213
+ const hasAgentfs = fs.existsSync(agentfsDb)
214
+ const hasWiki = fs.existsSync(wikiSourcesDir)
215
+ if (!hasAgentfs && !hasWiki) return
216
+
217
+ const base = path.basename(filePath)
218
+ const stem = base.slice(0, base.length - path.extname(base).length)
219
+ // Low-noise rule (D-10): skip tiny/generic stems that would match
220
+ // too broadly and turn this into noise on routine edits.
221
+ if (stem.length < MIN_STEM_LENGTH) return
222
+
223
+ // Once-per-file-per-session guard (T-04-09): after surfacing for
224
+ // this file once in this session, let subsequent edits/retries of
225
+ // the same file proceed unmodified — never re-block in a loop.
226
+ const dedupeKey = `${hookInput.sessionID}:${filePath}`
227
+ if (surfaced.has(dedupeKey)) return
228
+
229
+ let wakeupIndex = ""
230
+ if (hasAgentfs && fs.existsSync(SERVER_ENTRY)) {
231
+ const res = await $`node ${SERVER_ENTRY} wakeup`.quiet().nothrow()
232
+ wakeupIndex = String(res.stdout ?? "").trim()
233
+ }
234
+
235
+ const context = buildRecallContext(repo, base, stem, wakeupIndex)
236
+ if (!context) return
237
+
238
+ surfaced.add(dedupeKey)
239
+ throw new Error(`Memory recall (auto-injected for this file edit):\n\n${context}`)
240
+ } catch (err) {
241
+ // Re-throw our own intentional surface-context error (it carries the
242
+ // "Memory recall" prefix); swallow everything else to fail open —
243
+ // a lookup failure must never block an edit (D-03).
244
+ if (err instanceof Error && err.message.startsWith("Memory recall (auto-injected")) {
245
+ throw err
246
+ }
247
+ }
248
+ },
249
+ }
250
+ },
132
251
  }
@@ -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"
@@ -18,10 +19,21 @@ import path from "node:path"
18
19
  // Fail-open everywhere (D-03): a missing server binary, missing
19
20
  // .agentfs/.planning, or a failed subcommand must never wedge an OpenCode
20
21
  // session.
22
+ //
23
+ // V1/V2 dual entrypoint (2026-09-08): opencode2 (V2) schema-decodes the
24
+ // module `default` export as a definition with `id` + `setup`/`effect` — the
25
+ // old named `export const MemoryWakeupPlugin: Plugin` shape fails V2 loading
26
+ // with `SchemaError(Missing key at ["default"])` (server log ref
27
+ // err_128a87db). V1 >= 1.18.29 (installed on deep and fast) accepts the same
28
+ // default-export object and calls `server()` for the legacy hooks. Each side
29
+ // stays on its own API; V2 ignores `server()` and never translates V1 hooks.
21
30
 
22
31
  const SERVER_ENTRY = "@@INFRA_ROOT@@/mcp-memory-server/dist/index.js"
23
32
  const ARTIFACT_ENTRY = "@@INFRA_ROOT@@/mcp-memory-server/dist/artifact-cli.js"
24
33
 
34
+ const WAKEUP_NOTICE =
35
+ "Session-start context (auto-surfaced by the memory-wakeup plugin — use it; do not ask the user to recall anything it contains):"
36
+
25
37
  function compactionCaptureEnabled(): boolean {
26
38
  return /^(1|true|yes|on)$/i.test(process.env.CAIRN_COMPACTION_CAPTURE ?? "")
27
39
  }
@@ -62,93 +74,155 @@ function runNode(entry: string, args: string[], timeoutMs = 3000): Promise<strin
62
74
  })
63
75
  }
64
76
 
65
- export const MemoryWakeupPlugin: Plugin = async ({ $, directory }) => {
66
- return {
67
- // No per-session dedupe: this hook fires more than once per session
68
- // (including OpenCode's internal title-generation call, which happens
69
- // before the first real agent turn and shares the same sessionID). A
70
- // "surface once per session" Set keyed on sessionID would mark the
71
- // session surfaced on that throwaway title-gen call and silently skip
72
- // every real turn afterward — the OCP-05 acceptance gate never sees the
73
- // injected context. `output.system` is a fresh array per call, so
74
- // re-pushing on every invocation is both correct and required.
75
- "experimental.chat.system.transform": async (input, output) => {
76
- try {
77
- const repo = directory
78
- const agentfsDb = path.join(repo, ".agentfs", "project.db")
79
- const wikiIndex = path.join(repo, ".planning", "wiki", "index.md")
80
- const hasAgentfs = fs.existsSync(agentfsDb)
81
- const hasWiki = fs.existsSync(wikiIndex)
82
- const compactionEnabled = compactionCaptureEnabled()
83
- if (!hasAgentfs && !hasWiki && !compactionEnabled) return
77
+ // Shared V1/V2 surfacing rules (OCP-05, D-04) — one source of truth for the
78
+ // sections so both runtimes inject byte-identical context.
79
+ // `agentfsMemory === null` means "no AgentFS store present" (section omitted);
80
+ // a string means the store exists, even when the wakeup call returned "".
81
+ function collectSections(repo: string, agentfsMemory: string | null): string[] {
82
+ const sections: string[] = []
84
83
 
85
- const sections: string[] = []
84
+ if (agentfsMemory !== null) {
85
+ sections.push("## Project memory (AgentFS)")
86
+ if (agentfsMemory) sections.push(agentfsMemory)
87
+ }
86
88
 
87
- if (hasAgentfs) {
88
- const res = await $`node ${SERVER_ENTRY} wakeup`.quiet().nothrow()
89
- const memory = String(res.stdout ?? "").trim()
90
- sections.push("## Project memory (AgentFS)")
91
- if (memory) sections.push(memory)
92
- }
89
+ const wikiIndex = path.join(repo, ".planning", "wiki", "index.md")
90
+ if (fs.existsSync(wikiIndex)) {
91
+ const wiki = fs.readFileSync(wikiIndex, "utf8").trim()
92
+ sections.push("## Wiki index")
93
+ if (wiki) sections.push(wiki)
94
+ }
93
95
 
94
- if (hasWiki) {
95
- const wiki = fs.readFileSync(wikiIndex, "utf8").trim()
96
- sections.push("## Wiki index")
97
- if (wiki) sections.push(wiki)
98
- }
96
+ // Surface open HARD wiki contradictions so the agent (and user) see
97
+ // them at session start without anyone having to remember to scan the
98
+ // register. Hard entries cannot both be correct and must be resolved
99
+ // before dependent work.
100
+ const contradictionsPath = path.join(repo, ".planning", "wiki", "CONTRADICTIONS.md")
101
+ if (fs.existsSync(contradictionsPath)) {
102
+ const raw = fs.readFileSync(contradictionsPath, "utf8")
103
+ const start = raw.indexOf("<!-- wiki:contradictions:open:start -->")
104
+ const end = raw.indexOf("<!-- wiki:contradictions:open:end -->")
105
+ if (start !== -1 && end !== -1 && end > start) {
106
+ const region = raw.slice(start, end)
107
+ const hardLines = region
108
+ .split("\n")
109
+ .filter((line) => /severity:\s*hard/i.test(line))
110
+ if (hardLines.length > 0) {
111
+ sections.push("## Open HARD contradictions — resolve before dependent work")
112
+ sections.push(hardLines.join("\n"))
113
+ }
114
+ }
115
+ }
99
116
 
100
- // Surface open HARD wiki contradictions so the agent (and user) see
101
- // them at session start without anyone having to remember to scan the
102
- // register. Hard entries cannot both be correct and must be resolved
103
- // before dependent work.
104
- const contradictionsPath = path.join(repo, ".planning", "wiki", "CONTRADICTIONS.md")
105
- if (fs.existsSync(contradictionsPath)) {
106
- const raw = fs.readFileSync(contradictionsPath, "utf8")
107
- const start = raw.indexOf("<!-- wiki:contradictions:open:start -->")
108
- const end = raw.indexOf("<!-- wiki:contradictions:open:end -->")
109
- if (start !== -1 && end !== -1 && end > start) {
110
- const region = raw.slice(start, end)
111
- const hardLines = region
112
- .split("\n")
113
- .filter((line) => /severity:\s*hard/i.test(line))
114
- if (hardLines.length > 0) {
115
- sections.push("## Open HARD contradictions — resolve before dependent work")
116
- sections.push(hardLines.join("\n"))
117
- }
118
- }
119
- }
117
+ // Surface staged memory candidates captured by the session-end
118
+ // capture plugin. These are extracted automatically from the last
119
+ // session but NOT yet written to AgentFS — /memory-review is the
120
+ // accept gate.
121
+ const stagingDir = path.join(repo, ".planning", "memory-staging")
122
+ if (fs.existsSync(stagingDir)) {
123
+ const staged = fs.readdirSync(stagingDir).filter((f) => f.endsWith(".json"))
124
+ if (staged.length > 0) {
125
+ sections.push(`## Staged memory candidates (${staged.length} session(s)) — UNREVIEWED`)
126
+ sections.push("Run /memory-review to accept (→ AgentFS) or discard these before doing other work.")
127
+ }
128
+ }
120
129
 
121
- // Surface staged memory candidates captured by the session-end
122
- // capture plugin. These are extracted automatically from the last
123
- // session but NOT yet written to AgentFS — /memory-review is the
124
- // accept gate.
125
- const stagingDir = path.join(repo, ".planning", "memory-staging")
126
- if (fs.existsSync(stagingDir)) {
127
- const staged = fs.readdirSync(stagingDir).filter((f) => f.endsWith(".json"))
128
- if (staged.length > 0) {
129
- sections.push(`## Staged memory candidates (${staged.length} session(s)) — UNREVIEWED`)
130
- sections.push("Run /memory-review to accept (→ AgentFS) or discard these before doing other work.")
131
- }
132
- }
130
+ return sections
131
+ }
133
132
 
133
+ export default {
134
+ id: "cairnkeep.memory-wakeup",
135
+
136
+ // V2 (opencode2) entrypoint. `session.hook("context")` fires per model
137
+ // dispatch for this location's sessions — NOT for title requests, but it
138
+ // does run again for tool continuations, compaction and transient generate
139
+ // calls. `event.system` is a fresh SystemPart array per call, so
140
+ // re-pushing on every invocation is both correct and required (a
141
+ // per-session dedupe Set would key on the throwaway title-gen call under
142
+ // V1 and silently skip real turns — the original OCP-05 pitfall).
143
+ async setup(ctx: V2.Context) {
144
+ const repo = ctx.location.directory
145
+ await ctx.session.hook("context", async (event) => {
146
+ try {
147
+ const hasAgentfs = fs.existsSync(path.join(repo, ".agentfs", "project.db"))
148
+ const hasWiki = fs.existsSync(path.join(repo, ".planning", "wiki", "index.md"))
149
+ const compactionEnabled = compactionCaptureEnabled()
150
+ if (!hasAgentfs && !hasWiki && !compactionEnabled) return
151
+
152
+ // No `$` BunShell in the V2 context — use node's own child_process
153
+ // (same helper the V1 compaction path already relies on).
154
+ let agentfsMemory: string | null = null
155
+ if (hasAgentfs) agentfsMemory = (await runNode(SERVER_ENTRY, ["wakeup"])).trim()
156
+
157
+ const sections = collectSections(repo, agentfsMemory)
134
158
  if (sections.length > 0) {
135
- output.system.push(
136
- "Session-start context (auto-surfaced by the memory-wakeup plugin — use it; do not ask the user to recall anything it contains):",
137
- )
138
- output.system.push(sections.join("\n\n"))
159
+ event.system.push({ type: "text", text: WAKEUP_NOTICE })
160
+ event.system.push({ type: "text", text: sections.join("\n\n") })
139
161
  }
140
162
 
141
163
  if (compactionEnabled && fs.existsSync(ARTIFACT_ENTRY)) {
142
164
  const args = ["recover", repo]
143
- if (typeof input.sessionID === "string" && input.sessionID) {
144
- args.push("--session-ref", `opencode:${input.sessionID}`)
165
+ const sessionID = (event as { sessionID?: unknown }).sessionID
166
+ if (typeof sessionID === "string" && sessionID) {
167
+ args.push("--session-ref", `opencode:${sessionID}`)
145
168
  }
146
169
  const recovery = (await runNode(ARTIFACT_ENTRY, args, 3000)).trim()
147
- if (recovery) output.system.push(recovery)
170
+ if (recovery) event.system.push({ type: "text", text: recovery })
148
171
  }
149
172
  } catch {
150
173
  // Fail open — never block a session because context surfacing failed.
151
174
  }
152
- },
153
- }
175
+ })
176
+ },
177
+
178
+ // V1 (opencode >= 1.18.29 documented object form) entrypoint — legacy
179
+ // implementation kept verbatim on its own API (`$` is V1's BunShell).
180
+ async server(input: Parameters<V1Plugin>[0]) {
181
+ const { $, directory } = input
182
+ return {
183
+ // No per-session dedupe: this hook fires more than once per session
184
+ // (including OpenCode's internal title-generation call, which happens
185
+ // before the first real agent turn and shares the same sessionID). A
186
+ // "surface once per session" Set keyed on sessionID would mark the
187
+ // session surfaced on that throwaway title-gen call and silently skip
188
+ // every real turn afterward — the OCP-05 acceptance gate never sees the
189
+ // injected context. `output.system` is a fresh array per call, so
190
+ // re-pushing on every invocation is both correct and required.
191
+ "experimental.chat.system.transform": async (
192
+ hookInput: { sessionID?: unknown },
193
+ output: { system: string[] },
194
+ ) => {
195
+ try {
196
+ const repo = directory
197
+ const hasAgentfs = fs.existsSync(path.join(repo, ".agentfs", "project.db"))
198
+ const hasWiki = fs.existsSync(path.join(repo, ".planning", "wiki", "index.md"))
199
+ const compactionEnabled = compactionCaptureEnabled()
200
+ if (!hasAgentfs && !hasWiki && !compactionEnabled) return
201
+
202
+ let agentfsMemory: string | null = null
203
+ if (hasAgentfs) {
204
+ const res = await $`node ${SERVER_ENTRY} wakeup`.quiet().nothrow()
205
+ agentfsMemory = String(res.stdout ?? "").trim()
206
+ }
207
+
208
+ const sections = collectSections(repo, agentfsMemory)
209
+ if (sections.length > 0) {
210
+ output.system.push(WAKEUP_NOTICE)
211
+ output.system.push(sections.join("\n\n"))
212
+ }
213
+
214
+ if (compactionEnabled && fs.existsSync(ARTIFACT_ENTRY)) {
215
+ const args = ["recover", repo]
216
+ if (typeof hookInput.sessionID === "string" && hookInput.sessionID) {
217
+ args.push("--session-ref", `opencode:${hookInput.sessionID}`)
218
+ }
219
+ const recovery = (await runNode(ARTIFACT_ENTRY, args, 3000)).trim()
220
+ if (recovery) output.system.push(recovery)
221
+ }
222
+ } catch {
223
+ // Fail open — never block a session because context surfacing failed.
224
+ }
225
+ },
226
+ }
227
+ },
154
228
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cairnkeep/cli",
3
- "version": "2.19.1",
3
+ "version": "2.20.0",
4
4
  "description": "A durable, harness-agnostic memory + context layer for coding agents and MCP clients.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",