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/LICENSE +21 -0
- package/README.md +174 -2
- package/README.zh-CN.md +175 -0
- package/mnemon.js +1542 -0
- package/package.json +52 -4
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
|
+
}
|