@cairnkeep/cli 2.19.0 → 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.
- package/CHANGELOG.md +33 -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 +1 -1
- 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 +2 -2
- 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
- package/scripts/verify-dependency-security.mjs +20 -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):
|
|
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
|
-
|
|
47
|
-
|
|
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
|
-
|
|
50
|
-
|
|
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 (
|
|
150
|
+
if (event.tool !== "edit" && event.tool !== "write") return
|
|
53
151
|
|
|
54
|
-
const
|
|
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
|
|
58
|
-
const
|
|
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 = `${
|
|
169
|
+
const dedupeKey = `${event.sessionID}:${filePath}`
|
|
74
170
|
if (surfaced.has(dedupeKey)) return
|
|
75
171
|
|
|
76
|
-
|
|
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
|
-
|
|
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
|
-
|
|
94
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
84
|
+
if (agentfsMemory !== null) {
|
|
85
|
+
sections.push("## Project memory (AgentFS)")
|
|
86
|
+
if (agentfsMemory) sections.push(agentfsMemory)
|
|
87
|
+
}
|
|
86
88
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
122
|
-
|
|
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
|
-
|
|
136
|
-
|
|
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
|
-
|
|
144
|
-
|
|
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)
|
|
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.
|
|
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",
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"node": ">=22"
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
41
|
+
"@modelcontextprotocol/sdk": "^1.32.1",
|
|
42
42
|
"agentfs-sdk": "^0.6.4",
|
|
43
43
|
"yaml": "2.9.0",
|
|
44
44
|
"zod": "^4.4.3"
|