opencode-mnemon 0.0.0-stage → 0.1.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/mnemon.js ADDED
@@ -0,0 +1,1542 @@
1
+ /**
2
+ * Mnemon plugin for OpenCode V2 (@opencode/plugin 2.0.8).
3
+ *
4
+ * V1 → V2 映射:
5
+ * - "shell.env"(MNEMON_OPENCODE=1)
6
+ * → ctx.shell.hook("create.before"),input.env 可直接改写
7
+ * - "experimental.chat.messages.transform"(recall 上下文注入)
8
+ * → ctx.session.hook("prompt"),input.prompt.text 可直接改写;
9
+ * 会话 cwd 经 ctx.session.get 解析(与 open-code-review.ts 的 resolveSessionCwd 同款)
10
+ * - "experimental.session.compacting"(压缩前记忆提示)
11
+ * → ctx.session.hook("compaction"),向 input.system 追加 system part
12
+ * - event "session.idle" 仅打日志,无实质功能,V2 不再订阅
13
+ * (V2 Context.app 无 log 通道;如需 idle 自动 remember 属于功能增强,另行规划)
14
+ *
15
+ * 兼容性:默认导出同时带 setup(V2)与 server(V1),与
16
+ * open-code-review.ts / herdr-agent-state.js 的双版本模式一致。
17
+ * Mnemon 调用由 Bun.spawnSync 改为 node:child_process,便于 bun/node 双 runtime 加载。
18
+ */
19
+
20
+ import { spawn, spawnSync } from "node:child_process"
21
+ import { createHash } from "node:crypto"
22
+ import {
23
+ appendFileSync,
24
+ closeSync,
25
+ existsSync,
26
+ mkdirSync,
27
+ openSync,
28
+ readdirSync,
29
+ realpathSync,
30
+ readFileSync,
31
+ renameSync,
32
+ rmSync,
33
+ statSync,
34
+ writeFileSync,
35
+ } from "node:fs"
36
+ import { createRequire } from "node:module"
37
+ import { basename, dirname, join } from "node:path"
38
+
39
+ const AUTOMEM_DEBUG_LOG = "/tmp/trellis-plugin-debug.log"
40
+ // Append-only with no bound reached 63 MB, and every debugging session greps
41
+ // this file. Rotate on size, keeping the previous generations for post-mortem
42
+ // context -- a rotation often lands mid-investigation. Generations past
43
+ // DEBUG_LOG_KEEP_GENERATIONS are not pruned: the names are never created, so a
44
+ // higher-numbered file can only predate a smaller KEEP value.
45
+ const DEBUG_LOG_MAX_BYTES = 8 * 1024 * 1024
46
+ const DEBUG_LOG_KEEP_GENERATIONS = 2
47
+
48
+ function rotateDebugLog() {
49
+ try {
50
+ const stats = statSync(AUTOMEM_DEBUG_LOG, { throwIfNoEntry: false })
51
+ if (!stats || stats.size < DEBUG_LOG_MAX_BYTES) return
52
+ for (let generation = DEBUG_LOG_KEEP_GENERATIONS - 1; generation >= 1; generation--) {
53
+ const from = `${AUTOMEM_DEBUG_LOG}.${generation}`
54
+ const to = `${AUTOMEM_DEBUG_LOG}.${generation + 1}`
55
+ if (existsSync(from)) renameSync(from, to)
56
+ }
57
+ renameSync(AUTOMEM_DEBUG_LOG, `${AUTOMEM_DEBUG_LOG}.1`)
58
+ } catch {
59
+ // Rotation is best-effort: never let housekeeping break the caller.
60
+ }
61
+ }
62
+
63
+ function debugLog(prefix, ...args) {
64
+ const line = `[${new Date().toISOString()}] [${prefix}] ${args.map((a) => typeof a === "object" ? JSON.stringify(a) : String(a)).join(" ")}\n`
65
+ try {
66
+ rotateDebugLog()
67
+ appendFileSync(AUTOMEM_DEBUG_LOG, line)
68
+ } catch {
69
+ // ignore
70
+ }
71
+ }
72
+
73
+ const MAX_RECALL_CHARS = 4000
74
+ // Recall quality gate (task 10-11-mnemon-recall-gate). Measured against this
75
+ // store: scores all cluster in 0.25-0.38 (ceiling 0.38), so no absolute
76
+ // threshold alone filters anything -- hence the combination of floor + band
77
+ // relative to the top score + lexical-signal padding check (gateRecallResults).
78
+ // mnemon recall has no --min-score, so the gate has to live here.
79
+ const RECALL_LIMIT = 12 // oversample, mirrors dsh-mnemon candidateMultiplier=3
80
+ const RECALL_MIN_SCORE = 0.25 // dsh-mnemon lowScoreThreshold
81
+ const RECALL_BAND = 0.15 // drop score < topScore - band (trims the tail)
82
+ const RECALL_MAX = 4 // dsh-mnemon maxMediumResults
83
+ const RECALL_MIN_QCHARS = 6 // below this a query recalls only padding (measured: "删" → 5 padding items)
84
+ const STATUS_TTL_MS = 60_000
85
+ const RECALL_SECTION_MAX_CHARS = 900
86
+ const RECALL_EXCERPT_CHARS = 120
87
+ const STATUS_SECTION_MAX_CHARS = 200
88
+ const RECALL_RETRY_MS = 150
89
+ const MARKER = "<mnemon_context>"
90
+ let SETUP_SEQ = 0
91
+ // A 方案的一次性生成入口,setupV2 里按运行时能力赋值(旧版 OpenCode 无 ctx.generate 时为 null)。
92
+ let llmGenerate = null
93
+ // 模型目录解析器,setupV2 从 ctx.model.list() 注入;拿不到目录时按空目录处理。
94
+ let llmCatalog = null
95
+
96
+ const COMPACTION_TEXT = `## Mnemon Memory
97
+
98
+ Before compaction completes, preserve durable preferences, decisions, insights, facts, or context with mnemon remember/link when they will improve future continuity. Do not store secrets, credentials, or short-lived operational noise.`
99
+
100
+ const USAGE_HINT =
101
+ "Use mnemon when it materially improves continuity. After responding, decide whether durable preferences, decisions, insights, facts, or context should be stored with mnemon remember/link." +
102
+ ' The recall above is a shallow pre-filter, not the whole store. For deeper retrieval run mnemon recall "<focused query>" --intent WHY|WHEN|ENTITY --limit 10 --verbose.'
103
+
104
+ function resolveProjectDataDir(cwd) {
105
+ if (typeof cwd !== "string" || cwd === "") return null
106
+ const dir = join(cwd, ".mnemon")
107
+ try {
108
+ if (!existsSync(dir)) return null
109
+ } catch {
110
+ return null
111
+ }
112
+ return dir
113
+ }
114
+
115
+ function npmBinary(entry) {
116
+ // `mnemon` on PATH is usually npm's JS launcher, which itself spawns the
117
+ // platform binary. Resolve the native target directly so a timeout kills the
118
+ // real process instead of orphaning it, and so .cmd shims work on Windows.
119
+ // Targets are read from the package manifest rather than duplicated here.
120
+ const root = dirname(dirname(entry))
121
+ const targets = JSON.parse(readFileSync(join(root, "targets.json"), "utf8"))
122
+ const target = targets.find((item) => item.platform === process.platform && item.arch === process.arch)
123
+ if (!target) throw new Error("Unsupported Mnemon platform")
124
+ const require = createRequire(entry)
125
+ const native = dirname(require.resolve(`${target.alias}/package.json`))
126
+ return join(native, target.binary)
127
+ }
128
+
129
+ let MNEMON_BINARY
130
+ function mnemonCommand() {
131
+ if (MNEMON_BINARY) return MNEMON_BINARY
132
+ MNEMON_BINARY = "mnemon"
133
+ const delimiter = process.platform === "win32" ? ";" : ":"
134
+ for (const directory of (process.env.PATH || "").split(delimiter)) {
135
+ if (!directory) continue
136
+ const binary = join(directory, process.platform === "win32" ? "mnemon.exe" : "mnemon")
137
+ if (existsSync(binary)) {
138
+ const resolved = realpathSync(binary)
139
+ const launcher = resolved.endsWith(`${process.platform === "win32" ? "\\" : "/"}bin${process.platform === "win32" ? "\\" : "/"}mnemon.js`)
140
+ if (launcher) {
141
+ try {
142
+ MNEMON_BINARY = npmBinary(resolved)
143
+ } catch {
144
+ MNEMON_BINARY = binary
145
+ }
146
+ } else {
147
+ MNEMON_BINARY = resolved
148
+ }
149
+ break
150
+ }
151
+ if (process.platform === "win32") {
152
+ // Node cannot execute npm's .cmd shim. Locate the launcher without
153
+ // invoking a shell or interpreting user-supplied text.
154
+ for (const modules of [join(directory, "node_modules"), dirname(directory)]) {
155
+ const entry = join(modules, "@mnemon-dev", "mnemon", "bin", "mnemon.js")
156
+ if (existsSync(entry)) {
157
+ try {
158
+ MNEMON_BINARY = npmBinary(entry)
159
+ } catch {
160
+ MNEMON_BINARY = binary
161
+ }
162
+ break
163
+ }
164
+ }
165
+ if (MNEMON_BINARY !== "mnemon") break
166
+ }
167
+ }
168
+ return MNEMON_BINARY
169
+ }
170
+
171
+ function mnemonInvocation(args, options = {}) {
172
+ const cwd = options.cwd || process.cwd()
173
+ const dataDir = options.dataDir || resolveProjectDataDir(cwd)
174
+ const finalArgs = dataDir ? ["--data-dir", dataDir, ...args] : args
175
+ return { cwd, finalArgs }
176
+ }
177
+
178
+ function runMnemon(args, options = {}) {
179
+ const { cwd, finalArgs } = mnemonInvocation(args, options)
180
+ try {
181
+ const proc = spawnSync(mnemonCommand(), finalArgs, {
182
+ cwd,
183
+ encoding: "utf8",
184
+ timeout: options.timeout || 15_000,
185
+ })
186
+ if (proc.status !== 0) return ""
187
+ return (proc.stdout || "").trim()
188
+ } catch {
189
+ return ""
190
+ }
191
+ }
192
+
193
+ // Async variant for background remember: spawnSync would block the event consumer.
194
+ function runMnemonAsync(args, options = {}) {
195
+ const { cwd, finalArgs } = mnemonInvocation(args, options)
196
+ return new Promise((resolve) => {
197
+ let stdout = ""
198
+ let stderr = ""
199
+ let settled = false
200
+ const finish = (status, extra) => {
201
+ if (settled) return
202
+ settled = true
203
+ resolve({ status, stdout, stderr: stderr || String(extra || "") })
204
+ }
205
+ let child
206
+ try {
207
+ child = spawn(mnemonCommand(), finalArgs, { cwd, encoding: "utf8" })
208
+ } catch (error) {
209
+ finish(-1, error)
210
+ return
211
+ }
212
+ const timer = setTimeout(() => {
213
+ try {
214
+ child.kill("SIGKILL")
215
+ } catch {
216
+ // already gone
217
+ }
218
+ }, options.timeout || 30_000)
219
+ child.stdout.on("data", (chunk) => {
220
+ stdout += String(chunk)
221
+ })
222
+ child.stderr.on("data", (chunk) => {
223
+ stderr += String(chunk)
224
+ })
225
+ child.on("error", (error) => {
226
+ clearTimeout(timer)
227
+ finish(-1, error)
228
+ })
229
+ child.on("close", (status) => {
230
+ clearTimeout(timer)
231
+ finish(typeof status === "number" ? status : -1)
232
+ })
233
+ })
234
+ }
235
+
236
+ // ---------------------------------------------------------------------------
237
+ // 读侧:运行时记忆(本插件写)+ documents 索引(只读,仅给相对路径)
238
+ // ---------------------------------------------------------------------------
239
+
240
+ const MAX_RUNTIME_SECTION_CHARS = 2048
241
+ const RUNTIME_RECENT_COUNT = 5
242
+ const DOCS_MAX_SECTION_CHARS = 700
243
+ const DOCS_MAX_LIST = 8
244
+ // Below this a hit is usually a coincidental 2-gram (e.g. "协议" in an unrelated doc).
245
+ const MIN_DOC_SCORE = 3
246
+ const STOPWORDS = new Set([
247
+ "the", "and", "for", "with", "from", "this", "that", "what", "how", "why",
248
+ "继续", "推进", "验证", "确认", "现在", "目前", "一下", "什么", "怎么", "如何", "可以", "请",
249
+ ])
250
+
251
+ function clipLine(value, max) {
252
+ if (typeof value !== "string") return ""
253
+ const flat = value.replace(/\s+/g, " ").trim()
254
+ if (flat.length === 0) return ""
255
+ return flat.length <= max ? flat : `${flat.slice(0, max - 1)}…`
256
+ }
257
+
258
+ // CJK phrases have no whitespace; add 2-grams so short titles can still match.
259
+ function queryTokens(query) {
260
+ const raw = query
261
+ .toLowerCase()
262
+ .split(/[^a-z0-9_\u4e00-\u9fff-]+/)
263
+ .filter((token) => token.length >= 2 && !STOPWORDS.has(token))
264
+ const out = []
265
+ for (const token of new Set(raw)) {
266
+ out.push(token)
267
+ if (/[\u4e00-\u9fff]/.test(token) && token.length >= 3) {
268
+ for (let i = 0; i + 2 <= token.length; i++) out.push(token.slice(i, i + 2))
269
+ }
270
+ }
271
+ return [...new Set(out)].slice(0, 24)
272
+ }
273
+
274
+ function readRuntimeEntries(root) {
275
+ try {
276
+ const doc = JSON.parse(readFileSync(join(root, "runtime", RUNTIME_FILE), "utf8"))
277
+ if (!doc || !Array.isArray(doc.entries)) return []
278
+ return doc.entries.filter(
279
+ (entry) => entry && typeof entry.assistant === "string" && entry.assistant.trim().length > 0,
280
+ )
281
+ } catch {
282
+ return []
283
+ }
284
+ }
285
+
286
+ function buildRuntimeSection(root, budget) {
287
+ const entries = readRuntimeEntries(root).slice(-RUNTIME_RECENT_COUNT)
288
+ if (entries.length === 0 || budget < 80) return ""
289
+ const head = "Recent runtime turns (workspace-scoped, oldest first):"
290
+ const lines = [head]
291
+ let used = head.length + 1
292
+ for (const entry of entries) {
293
+ const parts = []
294
+ const user = clipLine(entry.user, 260)
295
+ const assistant = clipLine(entry.assistant, 420)
296
+ if (user) parts.push(`user: ${user}`)
297
+ if (assistant) parts.push(`assistant: ${assistant}`)
298
+ if (parts.length === 0) continue
299
+ const line = `- ${clipLine(entry.ts, 19)} ${parts.join(" | ")}`
300
+ if (used + line.length + 1 > budget) break
301
+ lines.push(line)
302
+ used += line.length + 1
303
+ }
304
+ return lines.length > 1 ? lines.join("\n") : ""
305
+ }
306
+
307
+ function readDocumentsIndex(root) {
308
+ try {
309
+ const doc = JSON.parse(readFileSync(join(root, "documents", "index.json"), "utf8"))
310
+ if (!doc || !Array.isArray(doc.documents)) return []
311
+ return doc.documents.filter((item) => item && typeof item.relativePath === "string" && item.relativePath !== "")
312
+ } catch {
313
+ return []
314
+ }
315
+ }
316
+
317
+ function docScore(doc, tokens) {
318
+ if (tokens.length === 0) return 0
319
+ const title = (doc.title || "").toLowerCase()
320
+ const description = (doc.description || "").toLowerCase()
321
+ const paths = (Array.isArray(doc.sourcePaths) ? doc.sourcePaths.join(" ") : "").toLowerCase()
322
+ let score = 0
323
+ for (const token of tokens) {
324
+ if (title.includes(token)) score += 3
325
+ if (description.includes(token)) score += 2
326
+ if (paths.includes(token)) score += 1
327
+ }
328
+ return score
329
+ }
330
+
331
+ function buildDocsSection(root, query, budget) {
332
+ if (budget < 80) return ""
333
+ const docs = readDocumentsIndex(root)
334
+ if (docs.length === 0) return ""
335
+ const tokens = queryTokens(query)
336
+ const hits = docs
337
+ .map((doc) => ({ doc, score: docScore(doc, tokens) }))
338
+ .filter((hit) => hit.score >= MIN_DOC_SCORE)
339
+ .sort((a, b) => b.score - a.score)
340
+ .slice(0, DOCS_MAX_LIST)
341
+ if (hits.length === 0) return ""
342
+ const lines = ["Workspace documents (read-only; relative paths, read on demand):"]
343
+ let used = lines[0].length + 1
344
+ for (const { doc, score } of hits) {
345
+ const line = `- ${doc.relativePath}${doc.title ? ` — ${clipLine(doc.title, 60)}` : ""} (${score})`
346
+ if (used + line.length + 1 > budget) break
347
+ lines.push(line)
348
+ used += line.length + 1
349
+ }
350
+ return lines.length > 1 ? lines.join("\n") : ""
351
+ }
352
+
353
+ function buildRecallContext(query, cwd) {
354
+ const started = Date.now()
355
+ const root = resolveMemoryRoot(cwd)
356
+ const sections = []
357
+
358
+ const status = getStatusSection(root)
359
+ if (status.text) sections.push(`Status:\n${status.text}`)
360
+
361
+ // Recall gate stats, emitted as one greppable debug line below so "the gate
362
+ // fired" is proven from the log, not inferred from a missing section.
363
+ let kept = 0
364
+ let dropped = 0
365
+ let padding = 0
366
+ let reason = "ok"
367
+ let recallChars = 0
368
+ const text = typeof query === "string" ? query : ""
369
+ if (!recallEnabled()) {
370
+ reason = "recall-disabled"
371
+ } else if (!gateQuery(text)) {
372
+ reason = "short-query"
373
+ sections.push("(short message — recall skipped)")
374
+ } else {
375
+ const payload = runRecall(text, cwd)
376
+ if (!payload) {
377
+ reason = "empty|parse"
378
+ debugLog("mnemon-auto", "recall gate: skipped reason=empty|parse")
379
+ } else {
380
+ const gated = gateRecallResults(payload)
381
+ kept = gated.kept.length
382
+ dropped = gated.dropped
383
+ padding = gated.padding
384
+ reason = gated.reason
385
+ const rendered = renderRecallSection(gated)
386
+ if (rendered) {
387
+ sections.push(rendered)
388
+ recallChars = rendered.length
389
+ } else {
390
+ // R4: an empty recall still leaves the on-demand retrieval hint, not a blank.
391
+ sections.push(`(no confident match — run mnemon recall "${clipLine(text, 100)}" --intent WHY|WHEN|ENTITY yourself)`)
392
+ }
393
+ }
394
+ }
395
+
396
+ const docs = buildDocsSection(root, text, DOCS_MAX_SECTION_CHARS)
397
+ if (docs) sections.push(docs)
398
+ const runtime = buildRuntimeSection(root, MAX_RUNTIME_SECTION_CHARS - docs.length)
399
+ if (runtime) sections.push(runtime)
400
+ sections.push(USAGE_HINT)
401
+ const injected = `\n\n<mnemon_context>\n${sections.join("\n\n")}\n</mnemon_context>\n\n`
402
+ debugLog(
403
+ "mnemon-auto",
404
+ `recall gate: kept=${kept} dropped=${dropped} padding=${padding} reason=${reason} ms=${Date.now() - started} chars=${injected.length} status=${status.source} statusChars=${status.text ? status.text.length + "Status:\n".length : 0} recallChars=${recallChars} total=${injected.length}`,
405
+ )
406
+ return injected
407
+ }
408
+
409
+ // ---------------------------------------------------------------------------
410
+ // Recall 质量闸门(task 10-11):纯函数 + spawn 接线
411
+ // ---------------------------------------------------------------------------
412
+
413
+ // Process-level A/B switch. Only the recall section is gated off; status,
414
+ // docs, runtime, and the hint stay (README Switches table documents this).
415
+ function recallEnabled() {
416
+ return process.env.MNEMON_RECALL !== "0"
417
+ }
418
+
419
+ // Non-whitespace character count. Single-char messages ("删") and fillers
420
+ // ("继续") measured zero evidence hits in this store, so they skip the spawn.
421
+ function gateQuery(query) {
422
+ if (typeof query !== "string") return false
423
+ return query.replace(/\s+/g, "").length >= RECALL_MIN_QCHARS
424
+ }
425
+
426
+ // `recall --verbose` items look like { insight: {...}, score, intent, via,
427
+ // signals }; accept a bare insight object too so shape drift degrades instead
428
+ // of breaking. lexical = keyword+entity+similarity: the only measured signal
429
+ // that separates a real hit from graph/temporal padding.
430
+ function normalizeRecallItem(item) {
431
+ if (!item || typeof item !== "object") return null
432
+ const insight = item.insight && typeof item.insight === "object" ? item.insight : item
433
+ const signals = item.signals && typeof item.signals === "object" ? item.signals : null
434
+ const lexical = signals
435
+ ? (Number(signals.keyword) || 0) + (Number(signals.entity) || 0) + (Number(signals.similarity) || 0)
436
+ : 0
437
+ const rawScore = item.score
438
+ const score = Number(rawScore)
439
+ const content = typeof insight.content === "string" ? insight.content : ""
440
+ return {
441
+ content,
442
+ category: typeof insight.category === "string" && insight.category !== "" ? insight.category : "insight",
443
+ score,
444
+ scoreValid: rawScore !== null && rawScore !== undefined && rawScore !== "" &&
445
+ Number.isFinite(score) && score <= 1,
446
+ padding: lexical === 0,
447
+ }
448
+ }
449
+
450
+ // Three-layer gate (design §3): invalid score → absolute floor → band relative
451
+ // to the top score → lexical-signal padding check. Scores alone cannot tell an
452
+ // 0.30 padding item from an 0.31 hit; the signals can.
453
+ function gateRecallResults(payload, opts = {}) {
454
+ const max = Number.isInteger(opts.max) && opts.max > 0 ? opts.max : RECALL_MAX
455
+ const band = Number.isFinite(opts.band) ? opts.band : RECALL_BAND
456
+ const minScore = Number.isFinite(opts.minScore) ? opts.minScore : RECALL_MIN_SCORE
457
+ const rawItems = Array.isArray(payload)
458
+ ? payload
459
+ : Array.isArray(payload?.results)
460
+ ? payload.results
461
+ : Array.isArray(payload?.insights)
462
+ ? payload.insights
463
+ : []
464
+ const reasons = { invalid: 0, score: 0, band: 0, signal: 0, dedup: 0 }
465
+ const candidates = []
466
+ for (const raw of rawItems) {
467
+ const item = normalizeRecallItem(raw)
468
+ if (!item || !item.scoreValid) {
469
+ reasons.invalid++
470
+ continue
471
+ }
472
+ if (item.score < minScore) {
473
+ reasons.score++
474
+ continue
475
+ }
476
+ candidates.push(item)
477
+ }
478
+ candidates.sort((a, b) => b.score - a.score)
479
+ const topScore = candidates.length > 0 ? candidates[0].score : NaN
480
+ const inBand = []
481
+ for (const item of candidates) {
482
+ if (item.score < topScore - band) reasons.band++
483
+ else inBand.push(item)
484
+ }
485
+ const paddingCount = inBand.filter((item) => item.padding).length
486
+ const lexicalHits = inBand.filter((item) => !item.padding)
487
+ // 内容去重:同一记忆可能以多条重复记录命中(同源 content 多 ID,文本有
488
+ // 细微差异如首行空格/换行),用规范化键(去分类前缀、压缩空白、取前 100
489
+ // 字符)只保留最高分那条,避免同一段内容在上下文里出现两次。
490
+ const seen = new Set()
491
+ const deduped = lexicalHits.filter((item) => {
492
+ const key = item.content
493
+ .replace(/^\s*\[[a-z]+\]\s*/i, "")
494
+ .replace(/\s+/g, " ")
495
+ .trim()
496
+ .slice(0, 100)
497
+ if (seen.has(key)) {
498
+ reasons.dedup++
499
+ return false
500
+ }
501
+ seen.add(key)
502
+ return true
503
+ })
504
+ // All padding (graph/temporal neighbours only): keep nothing rather than
505
+ // shipping noise -- the whole point of the gate.
506
+ const kept = deduped.length > 0 ? deduped.slice(0, max) : []
507
+ // signal = 词法 padding 丢弃 + cap 截断(去重已单独计为 dedup)。
508
+ reasons.signal = paddingCount + Math.max(0, deduped.length - max)
509
+ const dropped = reasons.invalid + reasons.score + reasons.band + reasons.signal + reasons.dedup
510
+ const reason = ["invalid", "score", "band", "signal", "dedup"].filter((key) => reasons[key] > 0).join("|")
511
+ return { kept, dropped, padding: paddingCount, topScore, total: rawItems.length, reason: reason || "none" }
512
+ }
513
+
514
+ // Compact lines, never the raw CLI JSON: the full --verbose payload (~30KB at
515
+ // limit 12) stays in the parse layer; only the cheap conclusion reaches the model.
516
+ function renderRecallSection(gated) {
517
+ const kept = Array.isArray(gated?.kept) ? gated.kept : []
518
+ if (kept.length === 0) return null
519
+ const total = Number.isInteger(gated?.total) ? gated.total : kept.length
520
+ const lines = [`Relevant recall (${kept.length}/${total} kept, scores are weak evidence):`]
521
+ let used = lines[0].length + 1
522
+ for (const item of kept) {
523
+ const excerpt = clipLine(item.content, RECALL_EXCERPT_CHARS)
524
+ if (excerpt === "") continue
525
+ const score = Number.isFinite(item.score) ? item.score.toFixed(2) : "?"
526
+ const category = typeof item.category === "string" && item.category !== "" ? item.category : "insight"
527
+ const line = `— [${score}·${category}] ${excerpt}`
528
+ if (used + line.length + 1 > RECALL_SECTION_MAX_CHARS) break
529
+ lines.push(line)
530
+ used += line.length + 1
531
+ }
532
+ return lines.length > 1 ? lines.join("\n") : null
533
+ }
534
+
535
+ // `mnemon status` JSON → one compact line (category counts + edges + store
536
+ // size + db name). Parse failure falls back to the raw text truncated to the
537
+ // budget: the section degrades, it never disappears on an error.
538
+ function compressStatus(jsonText) {
539
+ const raw = typeof jsonText === "string" ? jsonText.trim() : ""
540
+ if (raw === "") return ""
541
+ const fallback = () => clipLine(raw, STATUS_SECTION_MAX_CHARS)
542
+ let doc
543
+ try {
544
+ doc = JSON.parse(raw)
545
+ } catch {
546
+ return fallback()
547
+ }
548
+ if (!doc || typeof doc !== "object" || Array.isArray(doc)) return fallback()
549
+ const parts = []
550
+ const byCategory = doc.by_category && typeof doc.by_category === "object" ? doc.by_category : {}
551
+ for (const [name, count] of Object.entries(byCategory)) {
552
+ const value = Number(count)
553
+ if (Number.isFinite(value) && value > 0) parts.push(`${value} ${name}${value === 1 ? "" : "s"}`)
554
+ }
555
+ const edges = Number(doc.edge_count)
556
+ if (Number.isFinite(edges) && edges > 0) parts.push(`${edges} edges`)
557
+ const bytes = Number(doc.db_size_bytes)
558
+ if (Number.isFinite(bytes) && bytes > 0) parts.push(`${Math.max(1, Math.round(bytes / 1024))}KB`)
559
+ const dbPath = typeof doc.db_path === "string" ? doc.db_path : ""
560
+ if (dbPath !== "") parts.push(dbPath.split(/[\\/]/).filter(Boolean).slice(-2).join("/"))
561
+ if (parts.length === 0) return fallback()
562
+ return clipLine(parts.join(" · "), STATUS_SECTION_MAX_CHARS)
563
+ }
564
+
565
+ // Status changes slowly; paying a spawn per message for it was pure waste.
566
+ // Keyed by memory root because one OpenCode server serves every project.
567
+ const STATUS_CACHE = new Map()
568
+
569
+ function getStatusSection(root) {
570
+ const cached = STATUS_CACHE.get(root)
571
+ if (cached && Date.now() - cached.ts < STATUS_TTL_MS) return { text: cached.text, source: "cached" }
572
+ // A failed spawn caches the empty string too: one spawn per TTL, never one per message.
573
+ const status = compressStatus(runMnemon(["status"], { dataDir: root }))
574
+ STATUS_CACHE.set(root, { ts: Date.now(), text: status })
575
+ return { text: status, source: "spawn" }
576
+ }
577
+
578
+ // Oversample with signals so the gate has something to judge. The full JSON
579
+ // only feeds JSON.parse, never the model. Empty or unparseable output
580
+ // (observed: `SQLITE_BUSY: database is locked`) gets one retry, then null --
581
+ // the caller drops the section; injection never fails.
582
+ function runRecall(query, cwd) {
583
+ const args = ["recall", query, "--limit", String(RECALL_LIMIT), "--verbose"]
584
+ const parse = (output) => {
585
+ if (typeof output !== "string" || output.trim() === "") return null
586
+ try {
587
+ return JSON.parse(output)
588
+ } catch {
589
+ return null
590
+ }
591
+ }
592
+ const first = parse(runMnemon(args, { cwd }))
593
+ if (first !== null) return first
594
+ sleep(RECALL_RETRY_MS)
595
+ return parse(runMnemon(args, { cwd }))
596
+ }
597
+
598
+ async function resolveSessionCwd(ctx, sessionID, fallback) {
599
+ try {
600
+ const session = await ctx.session.get({ sessionID })
601
+ const directory = session?.location?.directory ?? session?.directory ?? fallback
602
+ const subpath = session?.subpath
603
+ if (typeof directory !== "string" || directory === "") return fallback
604
+ if (typeof subpath !== "string" || subpath === "") return directory
605
+ return `${directory}/${subpath}`.replace(/\/+/g, "/")
606
+ } catch {
607
+ return fallback
608
+ }
609
+ }
610
+
611
+ // ---------------------------------------------------------------------------
612
+ // V1 server hooks(保留兼容)
613
+ // ---------------------------------------------------------------------------
614
+
615
+ const MnemonPlugin = async ({ directory, client }) => {
616
+ if (hooksDisabled()) {
617
+ await client?.app?.log?.({
618
+ body: {
619
+ service: "mnemon",
620
+ level: "info",
621
+ message: "Mnemon OpenCode plugin skipped - TRELLIS_HOOKS disabled",
622
+ },
623
+ })
624
+ return {}
625
+ }
626
+ await client?.app?.log?.({
627
+ body: {
628
+ service: "mnemon",
629
+ level: "info",
630
+ message: "Mnemon OpenCode plugin loaded",
631
+ },
632
+ })
633
+
634
+ return {
635
+ "shell.env": async (_input, output) => {
636
+ if (!output.env) output.env = {}
637
+ output.env.MNEMON_OPENCODE = "1"
638
+ },
639
+
640
+ "experimental.chat.messages.transform": async (_input, output) => {
641
+ const current = lastUserMessage(output)
642
+ if (!current) return
643
+ if (current.text.includes(MARKER)) return
644
+ prependText(current.parts, buildRecallContext(current.text, directory))
645
+ },
646
+
647
+ "experimental.session.compacting": async (_input, output) => {
648
+ if (!Array.isArray(output.context)) output.context = []
649
+ output.context.push(COMPACTION_TEXT)
650
+ },
651
+
652
+ event: async ({ event }) => {
653
+ if (event?.type !== "session.idle") return
654
+ await client?.app?.log?.({
655
+ body: {
656
+ service: "mnemon",
657
+ level: "info",
658
+ message: "OpenCode session idle; evaluate whether durable memory should be written with mnemon",
659
+ },
660
+ })
661
+ },
662
+ }
663
+ }
664
+
665
+ function textFromPart(part) {
666
+ if (!part || typeof part !== "object") return ""
667
+ if (part.type === "text" && typeof part.text === "string") return part.text
668
+ if (typeof part.content === "string") return part.content
669
+ return ""
670
+ }
671
+
672
+ function lastUserMessage(output) {
673
+ const messages = Array.isArray(output?.messages) ? output.messages : []
674
+ for (let i = messages.length - 1; i >= 0; i--) {
675
+ const msg = messages[i]
676
+ const role = msg?.info?.role || msg?.role
677
+ if (role !== "user") continue
678
+ const parts = Array.isArray(msg.parts) ? msg.parts : []
679
+ return { msg, parts, text: parts.map(textFromPart).filter(Boolean).join("\n") }
680
+ }
681
+ return null
682
+ }
683
+
684
+ function prependText(parts, text) {
685
+ if (!Array.isArray(parts) || text.trim() === "") return
686
+ parts.unshift({ type: "text", text })
687
+ }
688
+
689
+ // ---------------------------------------------------------------------------
690
+ // V2 setup
691
+ // ---------------------------------------------------------------------------
692
+
693
+ async function setupV2(ctx) {
694
+ if (hooksDisabled()) {
695
+ debugLog("mnemon-auto", "setup: skipped - TRELLIS_HOOKS disabled (read + write off)")
696
+ return
697
+ }
698
+ // A 方案(步骤 5):一次性文本生成入口。ctx.generate.text 不经过 session prompt,
699
+ // 因此不触发本插件的 prompt hook;写侧另用 LLM_EXTRACT_MARKER 兜底防递归。
700
+ setLlmGenerate(ctx.generate && typeof ctx.generate.text === "function" ? (input) => ctx.generate.text(input) : null)
701
+ // 模型目录:ctx.model.list() 返回 { location, data }。只取 data 数组,解析失败按空目录处理。
702
+ setLlmCatalog(ctx.model && typeof ctx.model.list === "function" ? () => ctx.model.list().then((value) => value?.data).catch(() => []) : null)
703
+ // 1. shell 环境变量:替代 V1 "shell.env"
704
+ await ctx.shell.hook("create.before", (input) => {
705
+ input.env.MNEMON_OPENCODE = "1"
706
+ })
707
+
708
+ // 2. recall 上下文注入:替代 V1 "experimental.chat.messages.transform"
709
+ // 同时记录本轮 user 文本,供 text.ended 配对写运行时记忆
710
+ const setupId = ++SETUP_SEQ
711
+ const lastUserPrompt = new Map()
712
+ await ctx.session.hook("prompt", async (input) => {
713
+ const text = input?.prompt?.text ?? ""
714
+ const sid = input?.sessionID
715
+ if (typeof sid !== "string" || sid === "" || typeof text !== "string") {
716
+ debugLog(
717
+ "mnemon-auto",
718
+ "prompt no-stash setup:",
719
+ setupId,
720
+ "sessionID:",
721
+ typeof sid,
722
+ "text:",
723
+ typeof text,
724
+ )
725
+ return
726
+ }
727
+ const cwd = await resolveSessionCwd(ctx, sid, ctx.location.directory)
728
+ const userText = unescapeUserText(text)
729
+ lastUserPrompt.set(sid, userText)
730
+ if (lastUserPrompt.size > 128) lastUserPrompt.delete(lastUserPrompt.keys().next().value)
731
+ if (autOMEMEnabled()) writePendingPrompt(resolveMemoryRoot(cwd), sid, userText)
732
+ debugLog(
733
+ "mnemon-auto",
734
+ "prompt stash setup:",
735
+ setupId,
736
+ "session:",
737
+ sid,
738
+ "chars:",
739
+ userText.length,
740
+ "marker:",
741
+ text.includes(MARKER),
742
+ "keys:",
743
+ Object.keys(input).join(","),
744
+ "promptKeys:",
745
+ Object.keys(input.prompt ?? {}).join(","),
746
+ )
747
+ if (text.includes(MARKER)) return
748
+ const injected = buildRecallContext(userText, cwd)
749
+ input.prompt.text = injected + userText
750
+ debugLog(
751
+ "mnemon-auto",
752
+ "prompt inject setup:",
753
+ setupId,
754
+ "session:",
755
+ sid,
756
+ "injectedChars:",
757
+ injected.length,
758
+ )
759
+ })
760
+
761
+ // 3. 压缩前记忆提示:替代 V1 "experimental.session.compacting"
762
+ await ctx.session.hook("compaction", (input) => {
763
+ if (!Array.isArray(input.system)) return
764
+ input.system.push({ type: "text", text: COMPACTION_TEXT })
765
+ })
766
+
767
+ // 4. 自动记录:后台订阅会话事件(对标 dsh 主动记录;步骤 2 先落文件层)
768
+ // MNEMON_AUTOMEM=0 只关写侧(运行时记忆 + remember),读侧 recall 注入保留
769
+ if (!autOMEMEnabled()) {
770
+ debugLog("mnemon-auto", "setup:", setupId, "autOMEM disabled (MNEMON_AUTOMEM=0): write side off, read side kept")
771
+ return () => {}
772
+ }
773
+ const controller = new AbortController()
774
+ consumeSessionEvents(ctx, controller.signal, lastUserPrompt, setupId).catch((error) => {
775
+ debugLog("mnemon-auto", "event consumer exited:", error?.message ?? String(error))
776
+ })
777
+ return () => controller.abort()
778
+ }
779
+
780
+ const MAX_RUNTIME_ENTRIES = 50
781
+ const MAX_RUNTIME_TEXT_CHARS = 2000
782
+ const MIN_RUNTIME_TEXT_CHARS = 20
783
+ // OpenCode keeps its own runtime file; dsh-mnemon owns runtime/memories.json (different schema).
784
+ const RUNTIME_FILE = "opencode-memories.json"
785
+ const RUNTIME_LOCK = ".opencode-memories.lock"
786
+ const PENDING_DIR = "pending"
787
+ // A 方案生成的文本自带此标记。写侧据此跳过,兜底防递归(LLM 抽取误回灌成本轮记忆)。
788
+ const LLM_EXTRACT_MARKER = "[mnemon-extract]"
789
+
790
+ function resolveMemoryRoot(cwd) {
791
+ const project = resolveProjectDataDir(cwd)
792
+ if (project) return project
793
+ return join(process.env.HOME || "", ".mnemon")
794
+ }
795
+
796
+ // V2 prompt hooks deliver the prompt as a JSON-encoded string ("..."); unwrap it once.
797
+ function unescapeUserText(value) {
798
+ if (typeof value !== "string") return ""
799
+ if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
800
+ try {
801
+ const parsed = JSON.parse(value)
802
+ if (typeof parsed === "string") return parsed
803
+ } catch {
804
+ // not valid JSON: keep raw
805
+ }
806
+ }
807
+ return value
808
+ }
809
+
810
+ function pendingPromptFile(root, sessionID) {
811
+ return join(root, "runtime", PENDING_DIR, `${sessionID}.json`)
812
+ }
813
+
814
+ // One file per session; drop anything older than a day so it cannot grow unbounded.
815
+ function prunePendingDir(dir) {
816
+ try {
817
+ const cutoff = Date.now() - 86_400_000
818
+ for (const ent of readdirSync(dir, { withFileTypes: true })) {
819
+ if (!ent.isFile()) continue
820
+ try {
821
+ const file = join(dir, ent.name)
822
+ if (statSync(file).mtimeMs < cutoff) rmSync(file, { force: true })
823
+ } catch {
824
+ // ignore
825
+ }
826
+ }
827
+ } catch {
828
+ // ignore
829
+ }
830
+ }
831
+
832
+ // Shared across OpenCode processes, so the single writer always has the user prompt.
833
+ function writePendingPrompt(root, sessionID, user) {
834
+ try {
835
+ const file = pendingPromptFile(root, sessionID)
836
+ mkdirSync(dirname(file), { recursive: true })
837
+ writeJsonAtomic(file, { sessionID, user, ts: new Date().toISOString() })
838
+ prunePendingDir(dirname(file))
839
+ } catch {
840
+ // best effort
841
+ }
842
+ }
843
+
844
+ function takePendingPrompt(root, sessionID) {
845
+ try {
846
+ const file = pendingPromptFile(root, sessionID)
847
+ const info = JSON.parse(readFileSync(file, "utf8"))
848
+ rmSync(file, { force: true })
849
+ return typeof info?.user === "string" ? info.user : ""
850
+ } catch {
851
+ return null // no pending prompt for this session
852
+ }
853
+ }
854
+
855
+ const RUNTIME_LOCK_STALE_MS = 10_000
856
+ const RUNTIME_LOCK_RETRY_MS = 50
857
+ const RUNTIME_LOCK_MAX_ATTEMPTS = 20
858
+
859
+ function sleep(ms) {
860
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
861
+ }
862
+
863
+ function writeJsonAtomic(file, doc) {
864
+ const tmp = `${file}.${process.pid}.tmp`
865
+ writeFileSync(tmp, JSON.stringify(doc, null, 2))
866
+ renameSync(tmp, file)
867
+ }
868
+
869
+ function pidAlive(pid) {
870
+ if (!Number.isInteger(pid) || pid <= 0) return false
871
+ try {
872
+ process.kill(pid, 0)
873
+ return true
874
+ } catch {
875
+ return false
876
+ }
877
+ }
878
+
879
+ function lockOwnerStale(lockFile) {
880
+ try {
881
+ const info = JSON.parse(readFileSync(lockFile, "utf8"))
882
+ const age = Date.now() - (Number(info?.ts) || 0)
883
+ if (age < 0 || age > RUNTIME_LOCK_STALE_MS) return true
884
+ return !pidAlive(info?.pid)
885
+ } catch {
886
+ return true // unreadable lock: take over
887
+ }
888
+ }
889
+
890
+ // OpenCode runs the plugin once per process (TUI + server + each `opencode run`), and
891
+ // every process subscribes to the same event bus. Locking is per-append and released
892
+ // after the write, so a busy session keeps writing turn after turn.
893
+ function acquireWriterLock(runtimeDir) {
894
+ const lockFile = join(runtimeDir, RUNTIME_LOCK)
895
+ const take = () => {
896
+ try {
897
+ const fd = openSync(lockFile, "wx")
898
+ writeFileSync(fd, JSON.stringify({ pid: process.pid, ts: Date.now() }))
899
+ closeSync(fd)
900
+ return true
901
+ } catch {
902
+ return false
903
+ }
904
+ }
905
+ for (let attempt = 0; attempt < RUNTIME_LOCK_MAX_ATTEMPTS; attempt++) {
906
+ if (take()) return true
907
+ if (lockOwnerStale(lockFile)) {
908
+ rmSync(lockFile, { force: true })
909
+ continue
910
+ }
911
+ sleep(RUNTIME_LOCK_RETRY_MS)
912
+ }
913
+ return false
914
+ }
915
+
916
+ function releaseWriterLock(runtimeDir) {
917
+ try {
918
+ const lockFile = join(runtimeDir, RUNTIME_LOCK)
919
+ const info = JSON.parse(readFileSync(lockFile, "utf8"))
920
+ if (info?.pid === process.pid) rmSync(lockFile, { force: true })
921
+ } catch {
922
+ // not ours or already gone
923
+ }
924
+ }
925
+
926
+ function appendRuntimeMemory(root, entry) {
927
+ if (!entry || typeof entry.sessionID !== "string" || typeof entry.assistant !== "string") return false
928
+ // A 方案生成的文本不得回流成本轮记忆(防递归)
929
+ if (entry.assistant.includes(LLM_EXTRACT_MARKER)) return false
930
+ const runtimeDir = join(root, "runtime")
931
+ try {
932
+ mkdirSync(runtimeDir, { recursive: true })
933
+ } catch {
934
+ return false
935
+ }
936
+ if (!acquireWriterLock(runtimeDir)) return false
937
+ const file = join(runtimeDir, RUNTIME_FILE)
938
+ try {
939
+ let doc = { version: 1, entries: [] }
940
+ try {
941
+ const parsed = JSON.parse(readFileSync(file, "utf8"))
942
+ if (parsed && Array.isArray(parsed.entries)) doc = parsed
943
+ } catch {
944
+ // missing or corrupt: start fresh
945
+ }
946
+ // Consume the pending prompt inside the locked region: a peer that loses the lock
947
+ // race must not delete a pending file whose user text it will never get to write.
948
+ const pending = entry.user === "" ? takePendingPrompt(root, entry.sessionID) : null
949
+ entry.user = entry.user || (pending ?? "")
950
+ const startsTurn = entry.user !== "" || pending !== null
951
+
952
+ const existing = doc.entries.find(
953
+ (item) => item.sessionID === entry.sessionID && item.assistant === entry.assistant,
954
+ )
955
+ if (existing) {
956
+ if (!existing.user && entry.user) {
957
+ existing.user = entry.user // a peer wrote the entry first without the prompt
958
+ writeJsonAtomic(file, doc)
959
+ }
960
+ return true // duplicate delivery from a peer process: skip
961
+ }
962
+ if (!startsTurn) {
963
+ const last = doc.entries[doc.entries.length - 1]
964
+ if (last && last.sessionID === entry.sessionID) {
965
+ if (!last.assistant.includes(entry.assistant)) {
966
+ // another streamed segment of the same turn: fold it in instead of adding noise
967
+ last.assistant = `${last.assistant || ""}\n${entry.assistant}`.slice(0, MAX_RUNTIME_TEXT_CHARS)
968
+ writeJsonAtomic(file, doc)
969
+ }
970
+ return true // continuation segment: merged, or already present from a peer
971
+ }
972
+ }
973
+ doc.entries.push(entry)
974
+ if (doc.entries.length > MAX_RUNTIME_ENTRIES) {
975
+ doc.entries = doc.entries.slice(doc.entries.length - MAX_RUNTIME_ENTRIES)
976
+ }
977
+ writeJsonAtomic(file, doc)
978
+ return true
979
+ } catch {
980
+ return false
981
+ } finally {
982
+ releaseWriterLock(runtimeDir)
983
+ }
984
+ }
985
+ function clipText(value, limit = MAX_RUNTIME_TEXT_CHARS) {
986
+ if (typeof value !== "string") return ""
987
+ if (value.length <= limit) return value
988
+ return value.slice(0, limit)
989
+ }
990
+
991
+ // ---------------------------------------------------------------------------
992
+ // 写侧(方案 B,规则抽取):已定稿的运行时条目 → mnemon remember
993
+ // ---------------------------------------------------------------------------
994
+
995
+ const REMEMBER_MIN_AGE_MS = 60_000
996
+ const REMEMBER_BATCH = 3
997
+ const REMEMBER_MIN_TOTAL_CHARS = 24
998
+ const REMEMBER_SUBSTANTIVE_CHARS = 60
999
+ const REMEMBER_MAX_FENCE_RATIO = 0.7
1000
+ const REMEMBER_ASSISTANT_CHARS = 700
1001
+ const REMEMBER_USER_CHARS = 150
1002
+ const REMEMBER_HEADLINE_CHARS = 110
1003
+ const REMEMBER_MAX_ENTITIES = 8
1004
+ const REMEMBER_MIN_IMP = 3
1005
+ const REMEMBER_BUSY_RETRY_MS = 350
1006
+ // The final turn of a session never gets a follow-up event; revisit after it has aged past the claim window.
1007
+ const REMEMBER_TAIL_DELAY_MS = 75_000
1008
+ // A claim is a lock that can be lost if the process dies mid-sweep. Past this lease another process may steal it.
1009
+ // Well above the worst case (REMEMBER_BATCH x attempts x CLI timeout) so a live sweep is never stolen.
1010
+ const REMEMBER_CLAIM_LEASE_MS = 300_000
1011
+
1012
+ // A 方案(步骤 5):规则没抽到时的 LLM 补抽。默认关闭,两条开关任一生效即开:
1013
+ // 1. <root>/opencode-llm-extract 标记文件 — 按项目生效,touch 即开、删除即关,无需重启进程。
1014
+ // 文件内容非空时同时作为模型 ID(touch 成空文件则用默认模型)。
1015
+ // 2. MNEMON_LLM_EXTRACT=1 — 进程级 env,需重启 OpenCode 才生效。
1016
+ // MNEMON_LLM_EXTRACT=0 是显式总闸,优先级最高,可压过标记文件。
1017
+ // 只在 B 方案明确判空时补一次,网络调用不进写者锁,失败静默回退到规则结果。
1018
+ const LLM_ENABLE_FLAG = "opencode-llm-extract"
1019
+ const LLM_ENABLE_ENV = "MNEMON_LLM_EXTRACT"
1020
+ const LLM_MODEL_ENV = "MNEMON_LLM_MODEL"
1021
+ // ctx.generate.text 要 { id, modelID, providerID } 且必须在模型目录里;"provider/model" 字符串不认,
1022
+ // 只给 { id } 会报 Missing key at ["model"]["providerID"]。按顺序取第一个目录里存在的模型,
1023
+ // 最后一项是唯一实测可用的兜底(探测确认 opencode-go/longcat-2.5-preview-free 返回 {"text":"OK"})。
1024
+ const LLM_MODEL_CHAIN = [
1025
+ "opencode-go/qwen3.8-flash",
1026
+ "opencode-go/deepseek-v4-flash",
1027
+ "opencode-gemini-3.5-flash-lite",
1028
+ "opencode-go/longcat-2.5-preview-free",
1029
+ ]
1030
+ const LLM_TIMEOUT_MS = 25_000
1031
+ const LLM_SUMMARY_CHARS = REMEMBER_HEADLINE_CHARS
1032
+ const LLM_DETAIL_CHARS = REMEMBER_ASSISTANT_CHARS
1033
+ const LLM_MAX_ENTITIES = 6
1034
+
1035
+ // Higher entries win; a turn is filed as the strongest signal it carries.
1036
+ const REMEMBER_RULES = [
1037
+ {
1038
+ cat: "preference",
1039
+ imp: 5,
1040
+ words: ["偏好", "不要", "别再", "以后都", "总是用", "一贯", "prefer", "always", "never", "instead of"],
1041
+ },
1042
+ {
1043
+ cat: "decision",
1044
+ imp: 5,
1045
+ words: ["决定", "结论是", "采用", "改用", "换成", "定为", "decided", "decision", "go with", "will use"],
1046
+ },
1047
+ {
1048
+ cat: "insight",
1049
+ imp: 4,
1050
+ words: ["根因", "坑", "注意", "避免", "否则会", "会导致", "之所以", "root cause", "lesson", "gotcha", "turns out"],
1051
+ },
1052
+ {
1053
+ cat: "fact",
1054
+ imp: 4,
1055
+ words: ["实测", "验证", "报错", "HTTP", "版本", "端口", "verified", "returns", "version", "port"],
1056
+ },
1057
+ ]
1058
+
1059
+ function autOMEMEnabled() {
1060
+ return process.env.MNEMON_AUTOMEM !== "0"
1061
+ }
1062
+
1063
+ // 与 BalanceDeck 的 session-start / inject-workflow-state / inject-subagent-context 同款总闸。
1064
+ // 关掉时读写两侧都不注册;MNEMON_AUTOMEM=0 只关写侧、保留读侧 recall 注入。
1065
+ function hooksDisabled() {
1066
+ return process.env.TRELLIS_HOOKS === "0" || process.env.TRELLIS_DISABLE_HOOKS === "1"
1067
+ }
1068
+
1069
+ function collapseText(value) {
1070
+ if (typeof value !== "string") return ""
1071
+ return value.replace(/\r/g, "").replace(/[ \t]+/g, " ").replace(/\n{3,}/g, "\n\n").trim()
1072
+ }
1073
+
1074
+ function stripCodeBlocks(text) {
1075
+ return text.replace(/```[\s\S]*?(?:```|$)/g, " [代码块] ").replace(/`([^`\n]{1,64})`/g, "$1")
1076
+ }
1077
+
1078
+ function fenceRatio(text) {
1079
+ const total = text.length
1080
+ if (total < 40) return 0
1081
+ const fences = text.match(/```[\s\S]*?(?:```|$)/g)
1082
+ if (!fences) return 0
1083
+ return fences.reduce((sum, fence) => sum + fence.length, 0) / total
1084
+ }
1085
+
1086
+ function headlineOf(text) {
1087
+ const firstLine = (text.split("\n").map((line) => line.trim()).find((line) => line.length > 0) || "").replace(/^(?:[-*]+\s+|\d+[.)]\s+|\d+、)/, "")
1088
+ // Guard dots inside identifiers and version numbers ("0.2.10", "src/a.ts") so they are not read as sentence ends.
1089
+ const guarded = firstLine.replace(/(?<=\w)\.(?=\w)/g, "\u0000")
1090
+ const firstSentence = (guarded.split(/[。!?.!?\n]/)[0] || guarded).replace(/\u0000/g, ".")
1091
+ return clipText(firstSentence.trim(), REMEMBER_HEADLINE_CHARS)
1092
+ }
1093
+
1094
+ function extractEntities(text) {
1095
+ const found = []
1096
+ const push = (value) => {
1097
+ const candidate = value.trim()
1098
+ if (candidate.length < 2 || candidate.length > 64) return
1099
+ if (/^[\W_]+$/.test(candidate)) return
1100
+ if (found.includes(candidate)) return
1101
+ found.push(candidate)
1102
+ }
1103
+ for (const match of text.matchAll(/`([^`\n]{1,64})`/g)) push(match[1])
1104
+ for (const match of text.matchAll(/https?:\/\/[^\s)`]+/g)) push(match[0].replace(/[).,;]+$/, ""))
1105
+ for (const match of text.matchAll(/(?:[A-Za-z0-9._-]+\/){1,4}[A-Za-z0-9._-]+\.[A-Za-z0-9]{1,8}/g)) push(match[0])
1106
+ for (const match of text.matchAll(/\b[A-Z][a-z0-9]{1,}(?:[A-Z][a-z0-9]{0,}){1,4}\b/g)) push(match[0])
1107
+ for (const match of text.matchAll(/\b[A-Z]{3,}\b/g)) push(match[0])
1108
+ for (const match of text.matchAll(/\b\d+\.\d+(?:\.\d+)+\b/g)) push(match[0])
1109
+ // A lower-case tool or flag named inside CJK prose ("用 tabs 而不是 spaces") is a concrete anchor too.
1110
+ for (const match of text.matchAll(/[\u4e00-\u9fff]\s+([A-Za-z][A-Za-z0-9._-]{2,})(?!\s*:\/\/)\b/g)) push(match[1])
1111
+ return found.slice(0, REMEMBER_MAX_ENTITIES)
1112
+ }
1113
+
1114
+ // ASCII words match on word boundaries ("port" must not fire inside "important"); CJK words stay substring matches.
1115
+ function hasWord(lowered, word) {
1116
+ const needle = word.toLowerCase()
1117
+ if (!/[a-z]/.test(needle)) return lowered.includes(needle)
1118
+ return new RegExp(`\\b${needle}\\b`).test(lowered)
1119
+ }
1120
+
1121
+ function classifyMemory(text) {
1122
+ const lowered = text.toLowerCase()
1123
+ for (const rule of REMEMBER_RULES) {
1124
+ if (rule.words.some((word) => hasWord(lowered, word))) return rule
1125
+ }
1126
+ return null
1127
+ }
1128
+
1129
+ function hashText(text) {
1130
+ return createHash("sha1").update(text).digest("hex").slice(0, 12)
1131
+ }
1132
+
1133
+ // Returns null when the turn is not worth a durable memory.
1134
+ function extractMemory(entry, root) {
1135
+ const user = collapseText(entry?.user || "")
1136
+ const assistant = collapseText(entry?.assistant || "")
1137
+ const rule = classifyMemory(`${user}\n${assistant}`)
1138
+ if (!rule || rule.imp < REMEMBER_MIN_IMP) return null
1139
+ if (fenceRatio(assistant) > REMEMBER_MAX_FENCE_RATIO) return null
1140
+ const total = user.length + assistant.length
1141
+ if (total < REMEMBER_MIN_TOTAL_CHARS) return null
1142
+ const entities = extractEntities(`${user}\n${assistant}`)
1143
+ // Without something concrete to point at, only a substantive passage is worth keeping.
1144
+ if (entities.length === 0 && total < REMEMBER_SUBSTANTIVE_CHARS) return null
1145
+ const prose = stripCodeBlocks(assistant)
1146
+ const fromUser = rule.words.some((word) => hasWord(user.toLowerCase(), word))
1147
+ const userLine = user ? `\n用户:${clipText(user, REMEMBER_USER_CHARS)}` : ""
1148
+ const content = `[${rule.cat}] ${headlineOf(fromUser ? user : prose)}${userLine}\n要点:${clipText(prose, REMEMBER_ASSISTANT_CHARS)}`
1149
+ return {
1150
+ content,
1151
+ cat: rule.cat,
1152
+ imp: rule.imp,
1153
+ entities,
1154
+ tags: ["opencode", basename(dirname(root)) || "workspace"],
1155
+ key: hashText(content),
1156
+ }
1157
+ }
1158
+
1159
+ // Resolves the switch and the model once per candidate entry. Returns null when the LLM path is off.
1160
+ // Computed from disk each time so `touch`/`rm` of the flag file takes effect on the next sweep,
1161
+ // without restarting the process that holds the env.
1162
+ function llmExtractConfig(root) {
1163
+ const env = process.env[LLM_ENABLE_ENV]
1164
+ if (env === "0") return null
1165
+ const flagFile = join(root, LLM_ENABLE_FLAG)
1166
+ const fileOn = existsSync(flagFile)
1167
+ let flagModel = ""
1168
+ if (fileOn) {
1169
+ try {
1170
+ flagModel = readFileSync(flagFile, "utf8").trim()
1171
+ } catch {
1172
+ flagModel = ""
1173
+ }
1174
+ }
1175
+ if (env !== "1" && !fileOn) return null
1176
+ return {
1177
+ source: env === "1" ? "env" : "file",
1178
+ model: (process.env[LLM_MODEL_ENV] ?? "").trim() || flagModel,
1179
+ }
1180
+ }
1181
+
1182
+ // ctx.model.list() 返回 { location, data },这里只取目录数组;拿不到就返回空数组。
1183
+ function setLlmCatalog(fn) {
1184
+ llmCatalog = typeof fn === "function" ? fn : null
1185
+ }
1186
+
1187
+ // "provider/model" → { id, modelID, providerID }。id 是裸 modelID:传完整名会被拼成 provider/provider/model。
1188
+ function modelRefFromName(name) {
1189
+ if (typeof name !== "string") return null
1190
+ const value = name.trim()
1191
+ const slash = value.indexOf("/")
1192
+ if (slash <= 0 || slash === value.length - 1) return null
1193
+ const providerID = value.slice(0, slash)
1194
+ const modelID = value.slice(slash + 1)
1195
+ return { id: modelID, providerID, modelID }
1196
+ }
1197
+
1198
+ // 配置值可能指向目录里不存在的模型(项目级 provider 不在全局目录),逐个降级。
1199
+ // 目录为空时跳过校验直接信任配置;全都不中返回 null,由调用侧静默放弃。
1200
+ function pickModelRef(models, wanted) {
1201
+ const list = Array.isArray(models) ? models : []
1202
+ for (const name of [wanted, ...LLM_MODEL_CHAIN]) {
1203
+ if (!name) continue
1204
+ const ref = modelRefFromName(name)
1205
+ if (!ref) continue
1206
+ if (list.length === 0 || list.some((model) => model?.providerID === ref.providerID && model?.modelID === ref.modelID)) {
1207
+ return ref
1208
+ }
1209
+ }
1210
+ return null
1211
+ }
1212
+
1213
+ // Single write path for the generation seam: setupV2 sets it from ctx.generate, tests inject a fake.
1214
+ function setLlmGenerate(fn) {
1215
+ llmGenerate = typeof fn === "function" ? fn : null
1216
+ }
1217
+
1218
+ function withTimeoutMs(promise, ms, label) {
1219
+ const guard = new Promise((_, reject) => {
1220
+ const timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms)
1221
+ if (typeof timer.unref === "function") timer.unref()
1222
+ })
1223
+ return Promise.race([promise, guard])
1224
+ }
1225
+
1226
+ function buildLlmPrompt(user, assistant) {
1227
+ return [
1228
+ `${LLM_EXTRACT_MARKER} 你是对话记忆的抽取器。判断下面这一轮对话是否值得作为长期记忆保存。`,
1229
+ "",
1230
+ "<对话>",
1231
+ `用户:${user}`,
1232
+ `助手:${assistant}`,
1233
+ "</对话>",
1234
+ "",
1235
+ "判断标准:",
1236
+ "- 值得保存:明确的偏好或决定;踩过的坑与根因结论;环境事实(工具、版本、路径、端口、开关);后续必须记住的约束。",
1237
+ "- 不值得保存:寒暄与确认;纯执行过程的复述;报错堆栈;没有结论的尝试;重新查证就能拿到的临时状态。",
1238
+ "",
1239
+ "输出要求:只输出一个 JSON 对象,不要代码围栏,不要任何解释。",
1240
+ "- 不保存时输出 {\"keep\":false} 。",
1241
+ `- 保存时 summary 用一句话概括(${LLM_SUMMARY_CHARS} 字以内),detail 给出支撑细节(${LLM_DETAIL_CHARS} 字以内)。`,
1242
+ "- 关键实体原文必须保留:命令、路径、URL、版本号、开关名一字不改,不翻译不改写。",
1243
+ "- cat 取 fact / preference / insight / decision 之一。",
1244
+ "- imp 取 1-5:5 明确的偏好或决定,4 重要结论或事实,3 一般事实。",
1245
+ `- entities 只填正文里真实出现过的实体,最多 ${LLM_MAX_ENTITIES} 个。`,
1246
+ "",
1247
+ '{"keep":true,"cat":"preference","imp":5,"summary":"...","detail":"...","entities":["..."]}',
1248
+ ].join("\n")
1249
+ }
1250
+
1251
+ // The model may wrap the JSON in prose or a fence; keep only the outermost object.
1252
+ function parseLlmReply(text) {
1253
+ if (typeof text !== "string") return null
1254
+ const start = text.indexOf("{")
1255
+ const end = text.lastIndexOf("}")
1256
+ if (start < 0 || end <= start) return null
1257
+ try {
1258
+ const parsed = JSON.parse(text.slice(start, end + 1))
1259
+ return parsed && typeof parsed === "object" ? parsed : null
1260
+ } catch {
1261
+ return null
1262
+ }
1263
+ }
1264
+
1265
+ // Cheap material check shared with the rules path: is there enough readable text for a model to judge?
1266
+ function llmWorthyEntry(entry) {
1267
+ const user = collapseText(entry?.user || "")
1268
+ const assistant = collapseText(entry?.assistant || "")
1269
+ if (assistant.includes(LLM_EXTRACT_MARKER)) return false
1270
+ if (user.length + assistant.length < REMEMBER_MIN_TOTAL_CHARS) return false
1271
+ if (fenceRatio(assistant) > REMEMBER_MAX_FENCE_RATIO) return false
1272
+ return true
1273
+ }
1274
+
1275
+ // A 方案:same memo shape as extractMemory, so the writer, the dedupe key and the CLI args stay unchanged.
1276
+ async function llmExtractMemory(entry, root) {
1277
+ const config = llmExtractConfig(root)
1278
+ if (!config || typeof llmGenerate !== "function") return null
1279
+ if (!llmWorthyEntry(entry)) return null
1280
+ const model = pickModelRef(typeof llmCatalog === "function" ? await llmCatalog() : [], config.model)
1281
+ if (!model) {
1282
+ debugLog("mnemon-auto", "llm extract skipped via:", config.source, "reason: no model resolvable from catalog")
1283
+ return null
1284
+ }
1285
+ const modelName = `${model.providerID}/${model.modelID}`
1286
+ const user = clipText(collapseText(entry.user || ""), REMEMBER_USER_CHARS)
1287
+ const assistant = clipText(collapseText(entry.assistant || ""), REMEMBER_ASSISTANT_CHARS)
1288
+ let reply
1289
+ try {
1290
+ reply = await withTimeoutMs(
1291
+ llmGenerate({ prompt: buildLlmPrompt(user, assistant), model }),
1292
+ LLM_TIMEOUT_MS,
1293
+ "llm-extract",
1294
+ )
1295
+ } catch (error) {
1296
+ debugLog("mnemon-auto", "llm remember skipped via:", config.source, "model:", modelName, String(error?.message ?? error).replace(/\s+/g, " ").slice(0, 120))
1297
+ return null
1298
+ }
1299
+ const raw = parseLlmReply(typeof reply?.text === "string" ? reply.text : "")
1300
+ if (!raw || raw.keep !== true) {
1301
+ // Visible so a production run can show the model actually declined, not silently no-oped.
1302
+ debugLog("mnemon-auto", "llm extract drop via:", config.source, "model:", modelName, "session:", entry.sessionID, "keep:", String(raw?.keep ?? "unparsed"))
1303
+ return null
1304
+ }
1305
+ const summary = clipText(String(raw.summary ?? "").trim(), LLM_SUMMARY_CHARS)
1306
+ const detail = clipText(String(raw.detail ?? "").trim(), LLM_DETAIL_CHARS)
1307
+ // The floor applies to the model's total material, not to the headline alone:
1308
+ // a one-line summary is expected to be short, the rules path gates the same way on combined length.
1309
+ if (summary.length === 0 || summary.length + detail.length < REMEMBER_MIN_TOTAL_CHARS) return null
1310
+ const categories = ["fact", "preference", "insight", "decision"]
1311
+ const cat = categories.includes(raw.cat) ? raw.cat : "fact"
1312
+ const imp = Number.isFinite(Number(raw.imp))
1313
+ ? Math.max(REMEMBER_MIN_IMP, Math.min(5, Math.round(Number(raw.imp))))
1314
+ : REMEMBER_MIN_IMP
1315
+ const entities = (Array.isArray(raw.entities) ? raw.entities : [])
1316
+ .map((value) => clipText(String(value).trim(), 64))
1317
+ .filter((value) => value.length >= 2 && !/^[\W_]+$/.test(value))
1318
+ .slice(0, LLM_MAX_ENTITIES)
1319
+ const userLine = user ? `\n用户:${user}` : ""
1320
+ const content = `[${cat}] ${summary}${userLine}\n要点:${detail}`
1321
+ debugLog("mnemon-auto", "llm extract ok via:", config.source, "model:", modelName, "session:", entry.sessionID, "cat:", cat, "imp:", imp, "chars:", content.length)
1322
+ return {
1323
+ content,
1324
+ cat,
1325
+ imp,
1326
+ entities,
1327
+ tags: ["opencode", basename(dirname(root)) || "workspace"],
1328
+ key: hashText(content),
1329
+ }
1330
+ }
1331
+
1332
+ function rememberToMnemon(root, memo) {
1333
+ const args = ["remember", memo.content, "--cat", memo.cat, "--imp", String(memo.imp), "--source", "agent", "--tags", memo.tags.join(",")]
1334
+ if (memo.entities.length > 0) args.push("--entities", memo.entities.join(","))
1335
+ return (async () => {
1336
+ for (let attempt = 1; attempt <= 2; attempt++) {
1337
+ const detail = await runMnemonAsync(args, { dataDir: root, timeout: 30_000 })
1338
+ if (detail.status === 0) return { ok: true, output: detail.stdout.trim().slice(0, 200) }
1339
+ const message = (detail.stderr || detail.stdout || "").trim()
1340
+ if (attempt === 1 && /locked|busy/i.test(message)) {
1341
+ await new Promise((resolve) => setTimeout(resolve, REMEMBER_BUSY_RETRY_MS))
1342
+ continue
1343
+ }
1344
+ return { ok: false, error: (message || `exit ${detail.status}`).slice(0, 200) }
1345
+ }
1346
+ return { ok: false, error: "busy after retry" }
1347
+ })()
1348
+ }
1349
+
1350
+ function readRuntimeDoc(root) {
1351
+ try {
1352
+ const doc = JSON.parse(readFileSync(join(root, "runtime", RUNTIME_FILE), "utf8"))
1353
+ if (doc && Array.isArray(doc.entries)) return doc
1354
+ } catch {
1355
+ // missing or corrupt
1356
+ }
1357
+ return { version: 1, entries: [] }
1358
+ }
1359
+
1360
+ function entryAgeOk(entry) {
1361
+ const parsed = Date.parse(entry?.ts)
1362
+ if (!Number.isFinite(parsed)) return false
1363
+ return parsed < Date.now() - REMEMBER_MIN_AGE_MS
1364
+ }
1365
+
1366
+ // A claim without a fresh lease belongs to a process that died mid-sweep; reclaim it rather than letting it stall forever.
1367
+ function claimStale(entry) {
1368
+ if (entry?.remembered !== "claimed") return false
1369
+ const at = Date.parse(entry.claimedAt)
1370
+ return !Number.isFinite(at) || at < Date.now() - REMEMBER_CLAIM_LEASE_MS
1371
+ }
1372
+
1373
+ // Claim finalized entries under the writer lock so exactly one process files each one.
1374
+ function claimRememberable(root) {
1375
+ const runtimeDir = join(root, "runtime")
1376
+ if (!acquireWriterLock(runtimeDir)) return null
1377
+ const file = join(runtimeDir, RUNTIME_FILE)
1378
+ try {
1379
+ const doc = readRuntimeDoc(root)
1380
+ const seenKeys = new Set(
1381
+ doc.entries.filter((entry) => typeof entry.rememberKey === "string" && entry.rememberKey !== "").map((entry) => entry.rememberKey),
1382
+ )
1383
+ const picked = []
1384
+ for (const entry of doc.entries) {
1385
+ if (picked.length >= REMEMBER_BATCH) break
1386
+ if (entry.remembered && !claimStale(entry)) continue
1387
+ if (entryAgeOk(entry)) picked.push(entry)
1388
+ }
1389
+ if (picked.length === 0) return null
1390
+ const now = new Date().toISOString()
1391
+ for (const entry of picked) {
1392
+ entry.remembered = "claimed"
1393
+ entry.claimedAt = now
1394
+ }
1395
+ writeJsonAtomic(file, doc)
1396
+ return { picked, seenKeys }
1397
+ } catch {
1398
+ return null
1399
+ } finally {
1400
+ releaseWriterLock(runtimeDir)
1401
+ }
1402
+ }
1403
+
1404
+ function markRemembered(root, entry, remembered, key) {
1405
+ const runtimeDir = join(root, "runtime")
1406
+ if (!acquireWriterLock(runtimeDir)) return false
1407
+ const file = join(runtimeDir, RUNTIME_FILE)
1408
+ try {
1409
+ const doc = readRuntimeDoc(root)
1410
+ const target = doc.entries.find((item) => item.sessionID === entry.sessionID && item.ts === entry.ts)
1411
+ if (!target) return false
1412
+ target.remembered = remembered
1413
+ if (typeof key === "string" && key !== "") target.rememberKey = key
1414
+ writeJsonAtomic(file, doc)
1415
+ return true
1416
+ } catch {
1417
+ return false
1418
+ } finally {
1419
+ releaseWriterLock(runtimeDir)
1420
+ }
1421
+ }
1422
+
1423
+ async function sweepAndRemember(root, setupId) {
1424
+ const claim = claimRememberable(root)
1425
+ if (!claim) return 0
1426
+ let written = 0
1427
+ for (const entry of claim.picked) {
1428
+ try {
1429
+ const memo = extractMemory(entry, root) ?? (await llmExtractMemory(entry, root))
1430
+ if (!memo) {
1431
+ markRemembered(root, entry, true)
1432
+ debugLog("mnemon-auto", "remember skip setup:", setupId, "session:", entry.sessionID, "reason: below threshold")
1433
+ continue
1434
+ }
1435
+ if (claim.seenKeys.has(memo.key)) {
1436
+ markRemembered(root, entry, true, memo.key)
1437
+ debugLog("mnemon-auto", "remember skip setup:", setupId, "session:", entry.sessionID, "reason: duplicate")
1438
+ continue
1439
+ }
1440
+ const result = await rememberToMnemon(root, memo)
1441
+ markRemembered(root, entry, result.ok, memo.key)
1442
+ if (result.ok) {
1443
+ claim.seenKeys.add(memo.key) // dedupe later entries of this same batch
1444
+ written++
1445
+ }
1446
+ debugLog(
1447
+ "mnemon-auto",
1448
+ "remember setup:",
1449
+ setupId,
1450
+ "session:",
1451
+ entry.sessionID,
1452
+ result.ok ? "ok" : "fail",
1453
+ "cat:",
1454
+ memo.cat,
1455
+ "imp:",
1456
+ memo.imp,
1457
+ "chars:",
1458
+ memo.content.length,
1459
+ result.ok ? "" : result.error,
1460
+ )
1461
+ } catch (error) {
1462
+ // Release the claim back to retryable so one bad entry can never stall it forever.
1463
+ markRemembered(root, entry, false)
1464
+ debugLog("mnemon-auto", "remember error setup:", setupId, "session:", entry.sessionID, error?.message ?? String(error))
1465
+ }
1466
+ }
1467
+ return written
1468
+ }
1469
+
1470
+ const ACTIVE_SWEEPS = new Set()
1471
+
1472
+ function scheduleSweep(root, setupId) {
1473
+ if (ACTIVE_SWEEPS.has(root)) return
1474
+ ACTIVE_SWEEPS.add(root)
1475
+ sweepAndRemember(root, setupId)
1476
+ .catch((error) => debugLog("mnemon-auto", "sweep error:", error?.message ?? String(error)))
1477
+ .finally(() => ACTIVE_SWEEPS.delete(root))
1478
+ // Covers the tail turn: a sweep already running at that moment would have claimed the same entry.
1479
+ const tailTimer = setTimeout(() => scheduleSweep(root, setupId), REMEMBER_TAIL_DELAY_MS)
1480
+ tailTimer.unref?.()
1481
+ }
1482
+
1483
+ async function consumeSessionEvents(ctx, signal, lastUserPrompt, setupId) {
1484
+ for await (const event of ctx.event.subscribe({ signal })) {
1485
+ try {
1486
+ if (signal.aborted) return
1487
+ if (setupId !== SETUP_SEQ) return // a newer setup hot-reloaded this plugin: retire stale consumers
1488
+ const data = event?.data ?? {}
1489
+ if (event?.type === "session.text.ended") {
1490
+ const sessionID = data.sessionID
1491
+ const text = typeof data.text === "string" ? data.text : ""
1492
+ if (
1493
+ typeof sessionID === "string" &&
1494
+ sessionID !== "" &&
1495
+ text.trim().length >= MIN_RUNTIME_TEXT_CHARS
1496
+ ) {
1497
+ const cwd = await resolveSessionCwd(ctx, sessionID, ctx.location.directory)
1498
+ const root = resolveMemoryRoot(cwd)
1499
+ // Pending prompts are consumed inside appendRuntimeMemory, under the writer lock.
1500
+ const user = lastUserPrompt.get(sessionID) || ""
1501
+ lastUserPrompt.delete(sessionID)
1502
+ const ok = appendRuntimeMemory(root, {
1503
+ ts: new Date().toISOString(),
1504
+ sessionID,
1505
+ user: clipText(user),
1506
+ assistant: clipText(text),
1507
+ })
1508
+ debugLog(
1509
+ "mnemon-auto",
1510
+ "runtime append:",
1511
+ ok ? "ok" : "skip",
1512
+ "setup:",
1513
+ setupId,
1514
+ "session:",
1515
+ sessionID,
1516
+ "userChars:",
1517
+ user.length,
1518
+ )
1519
+ if (ok) scheduleSweep(root, setupId)
1520
+ }
1521
+ continue
1522
+ }
1523
+ debugLog(
1524
+ "mnemon-auto",
1525
+ "event:",
1526
+ event?.type ?? "unknown",
1527
+ "session:",
1528
+ data.sessionID ?? data.sessionId ?? "",
1529
+ "keys:",
1530
+ Object.keys(data).join(","),
1531
+ )
1532
+ } catch (error) {
1533
+ debugLog("mnemon-auto", "event error:", error?.message ?? String(error))
1534
+ }
1535
+ }
1536
+ }
1537
+
1538
+ export default {
1539
+ id: "mnemon",
1540
+ setup: setupV2,
1541
+ server: MnemonPlugin,
1542
+ }