sage-mem 0.7.2 → 0.9.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/lib/index.js CHANGED
@@ -1,951 +1,893 @@
1
- /**
2
- * sage-mem — 文件式记忆插件。
3
- *
4
- * 记忆存在本地 markdown 文件目录(frontmatter + 正文,4 类),不是数据库。
5
- * host 侧三件事:
6
- * 1. 按问题检索注入:system-prompt/assemble 时扫 memory 目录,按双字匹配
7
- * 选相关文件,读全文注入 system prompt,让 agent 第一轮就「想起」
8
- * 2. TypertRemoteService:给设置页「记忆管理」提供文件列表/读/写/删
9
- * 3. autodream(自动做梦):可选的记忆整理,见 lib/autodream.js
10
- *
11
- * 写入靠 AGENTS.md 的「记忆使用」规则引导 agent 自己判断 + 用文件工具写
12
- * memory 目录——透明、可检查、防膨胀。无 worker、无 SQLite、无端口。
13
- *
14
- * 旧 worker 版见 git 分支 `sqlite-worker`。
15
- */
16
-
17
- import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
18
- import { homedir } from 'node:os'
19
- import { readFile, readdir, writeFile, rename, unlink, stat } from 'node:fs/promises'
20
- import { readFileSync } from 'node:fs'
21
- import { join, basename, dirname } from 'node:path'
22
- import { AutodreamEngine } from './autodream.js'
23
-
24
- const MEMORY_DIR = process.env.SAGE_MEM_DIR || join(homedir(), '.sage-mem', 'memory')
25
- /**
26
- * DSH 会话记录根目录。autodream 的门控靠它数「上次整理之后有几个会话更新」,
27
- * `memory+sessions` 输入源下也会在里面做定向搜索。取不到就是空串,
28
- * 门控会退化成「会话数 0 → 不触发」,而不是报错。
29
- */
30
- const SESSIONS_ROOT = process.env.DSH_HOME ? join(process.env.DSH_HOME, 'sessions') : ''
31
-
32
- /** 读一个整数型环境变量并在范围内收敛;取不到或非法就用默认值。 */
33
- function envInt(name, dflt, lo, hi) {
34
- const raw = process.env[name]
35
- if (raw === undefined || raw === '') return dflt
36
- const n = Number(raw)
37
- if (!Number.isFinite(n)) return dflt
38
- return Math.max(lo, Math.min(hi, Math.round(n)))
39
- }
40
-
41
- /**
42
- * 一次注入最多带几条记忆。
43
- *
44
- * 5 → 10(2026-10-01):**记忆条数 ÷ 注入名额 ≈ 命中率上限**,名额 5 时那个上限低到
45
- * 的根因就在这个比值上。名额翻倍是提升召回最直接的一手,代价是每轮多约 7.5K
46
- * 字符的上下文。**这个数字该由实测决定**,所以留了 SAGE_MEM_MAX_RESULTS 覆写口
47
- * (比较两档的命中率与 token 账单,别拍脑袋)。
48
- */
49
- const MAX_RESULTS = envInt('SAGE_MEM_MAX_RESULTS', 10, 1, 30)
50
-
51
- /** 单条记忆注入的字符上限。 */
52
- const MAX_CHARS_PER_FILE = envInt('SAGE_MEM_MAX_CHARS', 1500, 200, 20000)
53
-
54
- /**
55
- * baseline 名额。
56
- *
57
- * baseline 条目通常不多,卡在 5 只会让靠后的条目永不出现——
58
- * 而 baseline 恰恰是短问题、恢复会话时唯一的兜底。现在放到 8,
59
- * 排序见下面「按显式优先级排,不按文件名」那段。
60
- */
61
- const MAX_BASELINE = envInt('SAGE_MEM_MAX_BASELINE', 8, 1, 50)
62
-
63
- /**
64
- * 单会话累计注入字节上限 —— 到顶以后这个会话就不再注入新的记忆。
65
- *
66
- * 为什么要有:条数上限(MAX_RESULTS)管的是「一次」注入多少,管不了「一场会话注
67
- * 多少次」。长会话里问题一直在换、检索签名一直在变,于是走一步注一批,累积没有
68
- * 上限 —— 实测「服务器环境」那个问题一次就注 7,700 字符,八次就到 60 KB。
69
- * Claude Code 有同一层闸门(`MAX_SESSION_BYTES = 60 * 1024`,到顶**彻底停止**预取),
70
- * 我们此前只有单条上限、没有会话上限。
71
- *
72
- * 60 KB 是与 CC 对齐的起点、不是实测出来的数字 —— 留了 SAGE_MEM_MAX_SESSION_BYTES
73
- * 覆写口,量过再定。
74
- */
75
- const MAX_SESSION_BYTES = envInt('SAGE_MEM_MAX_SESSION_BYTES', 60 * 1024, 4096, 512 * 1024)
76
-
77
- /**
78
- * 注入文本里标「保存于 N 天前」的门槛(天)。超了才标;0 = 每条都标。
79
- *
80
- * 为什么用「N 天前」而不是日期:模型不擅长日期算术,绝对时间戳几乎不触发陈旧
81
- * 推理,相对天数才会。为什么需要它:陈旧记忆被当事实引用时,「引用」这个动作
82
- * 本身会让它显得更权威、而不是更可疑。
83
- */
84
- const STALE_DAYS = envInt('SAGE_MEM_STALE_DAYS', 1, 0, 365)
85
-
86
- /**
87
- * 注入文本开头的引用纪律 —— **必须独立成节,不能压成一条 bullet**。
88
- *
89
- * Claude Code 对这条做过 A/B:同一句内容埋成 bullet 命中 0/3,独立成节 3/3,
90
- * 连标题措辞都验过。原因很实在:陈旧记忆被当事实引用时,「引用」这个动作本身
91
- * 会让它显得更权威而不是更可疑 —— 规矩得在**读这批内容之前**先立。
92
- *
93
- * 每次注入都带(约 150 字符),不按会话只带一次:它约束的是「这一批」怎么用,
94
- * 与批次绑定比与会话绑定更不容易失效。
95
- */
96
- const RECALL_DISCIPLINE = [
97
- '> **引用纪律**:以下是过去某一刻写下的记录,不是当前事实。',
98
- '> 提到文件路径先确认存在,提到函数 / 配置项先 grep,要据此动手前先核实当前代码。',
99
- '> 标了「保存于 N 天前」的尤其注意:其中的 `文件:行号` 很可能已经过期。',
100
- '> 「记忆里写着 X 存在」不等于「X 现在存在」。',
101
- ].join('\n')
102
-
103
- /**
104
- * 结构性保留名:设置页的写/删方法一律不得触碰。
105
- *
106
- * 只列**结构决定必须保护**的两个:
107
- * 1. `MEMORY.md` —— 索引。它被整份覆盖就等于索引没了(「+ 添加记忆」表单是照
108
- * buildFrontmatter() 重建 frontmatter 的,原有内容一个字都留不下)。
109
- * 2. `session-log.md` —— 追加型流水,体量最大,同样经不起一份重建。
110
- *
111
- * ⚠️ **除了这两个,别把任何具体记忆的文件名写死在这里。**
112
- * 「哪几条记忆特别要紧」是**使用者的私事**——把它编进随包发布的代码,等于把作者的
113
- * 记忆主题发给每一个装这个插件的人。
114
- * 用户自己的保护名单放在 `<状态根>/reserved.json`(JSON 字符串数组),见下。
115
- *
116
- * ⚠️ 全部小写存放,比较一律走 nameKey()(NTFS 不区分大小写,集合里放 'MEMORY.md'
117
- * 而拿 'memory.md' 去 has() 是查不到的 —— 曾经就是这个漏洞:表单里填 memory.md
118
- * 就能整份覆盖索引,deleteFile 还能不可逆删掉它)。
119
- */
120
- const STATE_ROOT = process.env.SAGE_MEM_STATE_DIR || join(dirname(MEMORY_DIR), '.sage-mem')
121
-
122
- /**
123
- * 读使用者自己声明的额外保护名单:`<状态根>/reserved.json`,形如 `["a.md","b.md"]`。
124
- *
125
- * 读不到、JSON 坏、不是数组 —— 一律当空数组返回。**一份可选的保护名单,
126
- * 绝不该有能力让插件加载失败。**
127
- */
128
- function readExtraReserved() {
129
- try {
130
- const arr = JSON.parse(readFileSync(join(STATE_ROOT, 'reserved.json'), 'utf8'))
131
- if (!Array.isArray(arr)) return []
132
- return arr
133
- .map((s) => String(s).trim().toLowerCase())
134
- .filter((s) => s.endsWith('.md') && !s.includes('/') && !s.includes('\\') && !s.startsWith('..'))
135
- } catch {
136
- return []
137
- }
138
- }
139
-
140
- const RESERVED_FILES = new Set(['memory.md', 'session-log.md', ...readExtraReserved()])
141
-
142
- /**
143
- * 非记忆条目:不参与检索 / 不进星图 / 不出现在设置页列表里,读写也一律不通。
144
- *
145
- * 是 RESERVED_FILES 的子集,区别在「列不列出来」:MEMORY.md 是索引、
146
- * session-log.md 是追加型流水,两者都不是一条记忆;
147
- * 而使用者手写维护的重要记忆是正常记忆条目(它们该被检索到)。
148
- * (要出现在列表与星图里、要能读),只是不许从表单写/删。
149
- */
150
- const NON_ENTRY_FILES = new Set([
151
- 'memory.md',
152
- 'session-log.md',
153
- ])
154
-
155
- /**
156
- * 单文件体积上限(字节)。定 512 KB 的理由:
157
- * - 单条正文通常在几十 KB 以内,索引与流水都排除在外,
158
- * 512 KB 是它的 26 倍,任何正常记忆都够用;
159
- * - 记忆是要塞进 system prompt 的,一条 512 KB 的记忆本身就等于把上下文撑爆——
160
- * 到这个量级基本可以判定是写错了目标(比如把日志、代码贴进来)。
161
- * 与 lib/typert.host.js 的 fileContentSchema.max(512 * 1024) 保持一致。
162
- */
163
- const MAX_FILE_BYTES = 512 * 1024
164
-
165
- /**
166
- * 统一告警出口。所有非 ENOENT 的失败都必须落到日志,绝不静默
167
- * (历史坑见 assemble 监听器里那段注释:表现是「插件在、记忆不再注入」,日志一字没有)。
168
- * @param ctx — 插件上下文,取 ctx.logger.warn
169
- * @param msg — 消息正文,调用方负责带上文件名 / 目录名
170
- * @param sessionId — 可选 session id,有就带
171
- */
172
- function warnLog(ctx, msg, sessionId) {
173
- const line = `sage-mem: ${msg}${sessionId ? ` (session ${sessionId})` : ''}`
174
- if (ctx?.logger?.warn) ctx.logger.warn(line)
175
- else console.warn(line)
176
- }
177
-
178
- /** 提取消息文本,兼容 content 为字符串或 text-block 数组两种形态。 */
179
- function extractText(content) {
180
- if (typeof content === 'string') return content.trim()
181
- if (!Array.isArray(content)) return ''
182
- return content
183
- .filter(b => b && b.type === 'text' && typeof b.text === 'string')
184
- .map(b => b.text)
185
- .join('\n')
186
- .trim()
187
- }
188
-
189
- /** 提取双字(2-gram)集合,中英文数字通用。 */
190
- function bigrams(s) {
191
- const clean = String(s).toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '')
192
- const set = new Set()
193
- for (let i = 0; i < clean.length - 1; i++) set.add(clean.slice(i, i + 2))
194
- return set
195
- }
196
-
197
- /** query 的双字在 text 中的覆盖率(0~1),作为相关性得分。 */
198
- function score(query, text) {
199
- const q = bigrams(query)
200
- if (q.size === 0) return 0
201
- const t = bigrams(text)
202
- let hit = 0
203
- for (const g of q) if (t.has(g)) hit++
204
- return hit / q.size
205
- }
206
-
207
- /** 扫 memory 目录,读回全部记忆文件(跳过索引与子目录),并解析 frontmatter 摘要。 */
208
- async function scanMemoryFiles(ctx, sessionId) {
209
- try {
210
- const entries = await readdir(MEMORY_DIR, { withFileTypes: true })
211
- const names = entries
212
- .filter(e => e.isFile() && isMemoryEntry(e.name))
213
- .map(e => e.name)
214
- const loaded = await Promise.all(names.map(async (name) => {
215
- try {
216
- const full = join(MEMORY_DIR, name)
217
- // stat 与 readFile 并行 —— 多要一个 mtime 不该变成串行两跳。
218
- const [content, info] = await Promise.all([readFile(full, 'utf8'), stat(full)])
219
- const meta = parseFrontmatter(content)
220
- return {
221
- file: name,
222
- content,
223
- description: meta.description || '',
224
- aliases: meta.aliases || [],
225
- baseline: parseBaseline(content),
226
- baselinePriority: meta.baselinePriority || 0,
227
- // 新鲜度的两个来源:frontmatter 的 updated(权威)与文件 mtime(退路)。
228
- updated: meta.updated || '',
229
- mtimeMs: info.mtimeMs,
230
- }
231
- } catch (err) {
232
- // 只有 ENOENT 是正常状态(扫到读之间被删)。其余——权限错、编码坏、盘掉线——
233
- // 一律出声:这里的静默化就是把「记忆不再注入」变成无声事故的那一手。
234
- if (err?.code !== 'ENOENT') warnLog(ctx, `cannot read ${name}: ${err?.message ?? err}`, sessionId)
235
- return null
236
- }
237
- }))
238
- return loaded.filter(Boolean)
239
- } catch (err) {
240
- if (err?.code !== 'ENOENT') warnLog(ctx, `cannot scan ${MEMORY_DIR}: ${err?.message ?? err}`, sessionId)
241
- return []
242
- }
243
- }
244
-
245
- /**
246
- * 相关性打分只用 frontmatter 的 description + 文件名,不扫全文。
247
- *
248
- * 旧版拿 query 的双字去全文里找,得分是「覆盖率」,分母是 query,不惩罚
249
- * 文件长度 —— 越长的记忆越容易蒙中。实测:长记忆在全文匹配下对任何问题都能
250
- * 蒙中足量双字组,连寒暄问句都能注入上千 token;而真正该命中的那条反被
251
- * 挤掉。description 是「一句话说清这条是什么」的精准摘要,用它当检索
252
- * 信号,噪音大幅下降。
253
- */
254
- function selectRelevant(query, files) {
255
- return files
256
- .map(f => {
257
- // aliases 一并进检索面:它是专门为「换个说法也能命中」准备的字段。
258
- const aliasText = Array.isArray(f.aliases) && f.aliases.length ? ' ' + f.aliases.join(' ') : ''
259
- const hay = f.description + aliasText + ' ' + f.file.replace(/\.md$/, '').replace(/[_-]/g, ' ')
260
- return { ...f, score: score(query, hay) }
261
- })
262
- .filter(f => f.score > 0)
263
- .sort((a, b) => b.score - a.score)
264
- .slice(0, MAX_RESULTS)
265
- }
266
-
267
- /**
268
- * 选 baseline 条目:按显式 `baselinePriority` 排,不按文件名。
269
- *
270
- * 文件名排序等于让 `feedback_*` 无条件压过 `user_*`,与「哪条更该出现在每场
271
- * 会话开头」毫无关系。没写这个字段的老条目一律算 0,行为与从前一致。
272
- */
273
- function selectBaseline(files) {
274
- return files
275
- .filter(f => f.baseline)
276
- .sort((a, b) => (b.baselinePriority || 0) - (a.baselinePriority || 0) || a.file.localeCompare(b.file))
277
- .slice(0, MAX_BASELINE)
278
- }
279
-
280
- /**
281
- * 解析 frontmatter 的 `tags`(自定义标签,用于列表页筛选)。
282
- *
283
- * 三种写法都收 —— 手写记忆时这三种都会自然出现,只认一种就等于「标签时灵时不灵」:
284
- * tags: [a, b] (YAML flow)
285
- * tags:\n - a\n - b (YAML block)
286
- * tags: a, b (松散的逗号串)
287
- *
288
- * 上限 20 个、每个 40 字符:标签是给人点着筛的,超过这个量级已经不是标签,
289
- * 而是一句话被塞进了 tags 字段。
290
- */
291
- function parseTags(body) {
292
- const clean = (arr) =>
293
- arr
294
- .map((s) => String(s).trim().replace(/^["']|["']$/g, '').trim())
295
- .filter(Boolean)
296
- .slice(0, 20)
297
- .map((s) => s.slice(0, 40))
298
- const flow = body.match(/^tags:[ \t]*\[([^\]]*)\]/m)
299
- if (flow) return clean(flow[1].split(','))
300
- const block = body.match(/^tags:[ \t]*\r?\n((?:[ \t]+-[^\r\n]*\r?\n?)+)/m)
301
- if (block) return clean(block[1].split(/\r?\n/).map((l) => l.replace(/^[ \t]*-[ \t]*/, '')))
302
- const inline = body.match(/^tags:[ \t]*(.+?)[ \t\r]*$/m)
303
- if (inline && inline[1].trim()) return clean(inline[1].split(','))
304
- return []
305
- }
306
-
307
- /** 解析 frontmatter 的 name/description/type(兼容顶层与 metadata 嵌套两种 type)。 */
308
- function parseFrontmatter(content) {
309
- // 换行必须容忍 CRLF:任何用 Windows 编辑器碰过一次的文件都会被换成 \r\n,
310
- // 而旧版这里写的是 /^---\n/ —— 匹配失败 → description/type 全空 →
311
- // 「baseline 走 parseBaseline 那条带 \r?\n 的正则照旧命中,但这条记忆永远检索不到」,
312
- // 症状极具欺骗性。下文定义的 parseBaseline 与 extractTitle 早已是 \r?\n。
313
- const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)
314
- if (!m) return { name: '', description: '', type: '', tags: [], aliases: [], updated: '', baselinePriority: 0 }
315
- const body = m[1]
316
- const grab = (key) => {
317
- // [ \t] / [ \t\r] 而不是 \s:\s 会连 CRLF 的 \r 一起吃进捕获组,值尾留一个 \r
318
- // 会让下面的引号剥离(["']$)失配,description 尾巴上挂一个残引号。
319
- const hit = body.match(new RegExp(`^${key}:[ \\t]*(.+?)[ \\t\\r]*$`, 'm'))
320
- if (!hit) return ''
321
- return hit[1].replace(/^["']|["']$/g, '').trim()
322
- }
323
- let type = grab('type')
324
- if (!type) {
325
- const nested = body.match(/^metadata:\s*\r?\n(?:\s+[^\r\n]+\r?\n)*?\s+type:\s*(\S+)/m)
326
- if (nested) type = nested[1]
327
- }
328
- // `updated` 踩的是与 `type` 同一个坑:它通常写在 `metadata:` 嵌套块里(缩进两格),
329
- // 顶层 `^updated:` 抓不到 —— 于是「明明写了日期却永远显示不出来」。
330
- // 抓不到就回落文件 mtime(见 ageDays):手写时间戳是权威的,文件系统时间只是退路。
331
- let updated = grab('updated')
332
- if (!updated) {
333
- const nestedUpdated = body.match(/^metadata:\s*\r?\n(?:\s+[^\r\n]+\r?\n)*?\s+updated:\s*(\S+)/m)
334
- if (nestedUpdated) updated = nestedUpdated[1]
335
- }
336
- return {
337
- name: grab('name'),
338
- description: grab('description'),
339
- type,
340
- tags: parseTags(body),
341
- aliases: parseAliases(body),
342
- updated,
343
- baselinePriority: parseBaselinePriority(body),
344
- }
345
- }
346
-
347
- /**
348
- * 解析 frontmatter 的 `aliases`(别称 / 同义词 / 我可能用的另一种叫法)。
349
- *
350
- * 为什么要有它:检索是**词面重合**的,不是语义的——问「我有什么待办」而文件里
351
- * 写的是「代办清单」,差一个字就完全不命中。`description` 回答「我会怎么问它」,
352
- * `aliases` 补上「我还可能怎么问」。写法与 tags 完全一致(flow / block / 逗号串三种都收),
353
- * 免得手写时「时灵时不灵」。
354
- */
355
- function parseAliases(body) {
356
- const clean = (arr) =>
357
- arr
358
- .map((s) => String(s).trim().replace(/^["']|["']$/g, '').trim())
359
- .filter(Boolean)
360
- .slice(0, 24)
361
- .map((s) => s.slice(0, 60))
362
- const flow = body.match(/^aliases:[ \t]*\[([^\]]*)\]/m)
363
- if (flow) return clean(flow[1].split(','))
364
- const block = body.match(/^aliases:[ \t]*\r?\n((?:[ \t]+-[^\r\n]*\r?\n?)+)/m)
365
- if (block) return clean(block[1].split(/\r?\n/).map((l) => l.replace(/^[ \t]*-[ \t]*/, '')))
366
- const inline = body.match(/^aliases:[ \t]*(.+?)[ \t\r]*$/m)
367
- if (inline && inline[1].trim()) return clean(inline[1].split(','))
368
- return []
369
- }
370
-
371
- /**
372
- * 解析 frontmatter 的 `baselinePriority`(数字,越大越先注入)。
373
- *
374
- * 旧版 baseline 是 `MAX_BASELINE = 5` + **按文件名排序**——`feedback_*` 永远排在
375
- * `user_*` 前面,与「哪条真的更该在每场会话开头出现」毫无关系。缺失当 0,
376
- * 于是没写这个字段的老条目行为不变。
377
- */
378
- function parseBaselinePriority(body) {
379
- const hit = body.match(/^baselinePriority:[ \t]*(.+?)[ \t\r]*$/m)
380
- if (!hit) return 0
381
- const n = Number(hit[1].replace(/^["']|["']$/g, '').trim())
382
- return Number.isFinite(n) ? n : 0
383
- }
384
-
385
- /** 解析 frontmatter 的 `baseline` 标记:会话第一回合无条件注入。 */
386
- function parseBaseline(content) {
387
- const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)
388
- if (!m) return false
389
- const hit = m[1].match(/^baseline:\s*(.+)$/m)
390
- if (!hit) return false
391
- return /^(true|yes|1)$/i.test(hit[1].replace(/^["']|["']$/g, '').trim())
392
- }
393
-
394
- /**
395
- * 一条记忆「多久以前写下的」(整数天;null = 无从判断)。
396
- *
397
- * **优先 frontmatter 的 `updated`,缺失才回落文件 mtime —— 顺序不能反。**
398
- * 文件系统时间会被复制、checkout、备份还原整批改掉(我们 memory 目录真被整目录
399
- * 备份/还原过),它表示的是「这个文件什么时候被动过」,不是「这条记忆什么时候
400
- * 写的」。手写的时间戳才是权威。
401
- *
402
- * 未来日期(时钟偏差 / 手写错)一律当 0:宁可标「今天」也不要标负数。
403
- */
404
- function ageDays(f, nowMs) {
405
- const fromMeta = Date.parse(f?.updated || '')
406
- const base = Number.isFinite(fromMeta) ? fromMeta : (Number.isFinite(f?.mtimeMs) ? f.mtimeMs : NaN)
407
- if (!Number.isFinite(base)) return null
408
- const days = Math.floor((nowMs - base) / 86400000)
409
- return days > 0 ? days : 0
410
- }
411
-
412
- /**
413
- * 原子写:先写同目录临时文件,再 rename 覆盖目标。
414
- *
415
- * 直写的风险是崩在中途(断电、进程被杀、磁盘满)留下半截文件 —— frontmatter
416
- * 缺半边,这条记忆从此解析不出来、检索不到,而文件看上去还在。
417
- * 同卷 rename 是原子的:读方要么看到旧全文、要么看到新全文,不存在中间态。
418
- * 任何一步失败都不碰目标文件,临时文件收尾清掉。
419
- */
420
- async function writeFileAtomic(target, content) {
421
- const tmp = `${target}.${process.pid}.tmp`
422
- try {
423
- await writeFile(tmp, content, 'utf8')
424
- await rename(tmp, target)
425
- } finally {
426
- // rename 成功后 tmp 已不存在(ENOENT),失败时把它清掉不留垃圾。
427
- await unlink(tmp).catch(() => {})
428
- }
429
- }
430
-
431
- /**
432
- * 文件名归一:只取单段文件名再小写。
433
- *
434
- * 这是**唯一**的名字比较基准(保留名、非条目名、扫描过滤全走它)。
435
- * 不能直接用原始字符串比:NTFS 不区分大小写,`memory.md` 与 `MEMORY.md`
436
- * 是同一个文件;`basename` 还顺手挡掉 `..\..\MEMORY.md` 这类带目录成分的输入。
437
- */
438
- function nameKey(raw) {
439
- return basename(String(raw ?? '')).toLowerCase()
440
- }
441
-
442
- /** 是否是保留名(写/删一律拒绝)。大小写不敏感。 */
443
- function isReserved(raw) {
444
- return RESERVED_FILES.has(nameKey(raw))
445
- }
446
-
447
- /** 是否是非记忆条目(索引与流水):列出/检索/星图/读写全部排除。大小写不敏感。 */
448
- function isNonEntry(raw) {
449
- return NON_ENTRY_FILES.has(nameKey(raw))
450
- }
451
-
452
- /** 目录扫描过滤:这个名字算不算一条记忆条目。 */
453
- function isMemoryEntry(name) {
454
- return /\.md$/i.test(String(name ?? '')) && !isNonEntry(name)
455
- }
456
-
457
- /**
458
- * 唯一的路径校验函数(防穿越 + 扩展名 + 排除非记忆条目)。
459
- *
460
- * MemoryGateway.readFile / StarmapGateway.readFile 都走这里,不再各写一套
461
- * —— 旧版 starmap 那套内联校验只排除了 'MEMORY.md'(大小写敏感)且**完全不排除
462
- * session-log.md**,于是 `starmap.readFile('session-log.md')` 能把整份流水
463
- * 整个拉进浏览器。
464
- *
465
- * 不复用 nameKey 的原因:这里要保留磁盘上的原始大小写(返回值直接 join 进路径)。
466
- * @param raw — 调用方传来的文件名(任意值)
467
- * @returns 通过校验的原始文件名;非法返回 null
468
- */
469
- function safeName(raw) {
470
- if (typeof raw !== 'string' || raw === '') return null
471
- // basename 与原文不一致=含目录成分。旧版是「悄悄取 basename 继续」,
472
- // 与 starmap 那套(要求 safe === raw)行为不一致;现在统一为拒绝。
473
- if (basename(raw) !== raw) return null
474
- if (!/\.md$/i.test(raw)) return null
475
- if (isNonEntry(raw)) return null
476
- return raw
477
- }
478
-
479
- /**
480
- * 手动 @Remote marker 注册(Node 24 不解析装饰器语法,手写 decorator context)。
481
- */
482
- function markRemote(cls, method, exportName) {
483
- const instance = Object.create(cls.prototype)
484
- Remote(exportName)(undefined, {
485
- kind: 'method',
486
- name: method,
487
- private: false,
488
- static: false,
489
- addInitializer(fn) {
490
- fn.call(instance)
491
- },
492
- })
493
- }
494
-
495
- export class MemoryGateway extends TypertRemoteService {
496
- constructor(ctx) {
497
- super(ctx, 'memory')
498
- // 一并挂载星图 remote(同一记忆目录,纯只读)—— starmap.* 服务
499
- new StarmapGateway(ctx)
500
-
501
- // autodream(夜间记忆整理):引擎 + remote 面 + 自动触发的定时器。
502
- //
503
- // 整个包在 try 里:autodream 起不来(比如 timer 服务缺失、状态目录没权限)
504
- // 不该把 sage-mem 最核心的那件事——「记忆按问题注入」——一起拖死。
505
- // 这是本插件唯一一处「降级而不是失败」的设计,理由就在这里。
506
- try {
507
- const engine = new AutodreamEngine(ctx, {
508
- memoryDir: MEMORY_DIR,
509
- sessionsRoot: SESSIONS_ROOT,
510
- reservedFiles: [...RESERVED_FILES],
511
- })
512
- new AutodreamGateway(ctx, engine)
513
- engine.start()
514
- // 卸载时把定时器与锁收干净。effect 拿不到就交给 unref 兜底(不阻止进程退出)。
515
- if (typeof ctx.effect === 'function') ctx.effect(() => () => engine.dispose())
516
- } catch (err) {
517
- warnLog(ctx, `autodream 初始化失败(记忆本体不受影响):${err?.message ?? err}`)
518
- // 异常路径额外打一行到 stderr:warnLog 走 ctx.logger,某些启动阶段它还没接上,
519
- // 而「autodream 静默不工作」正是最难查的那种故障(2026-09-27 为此绕了一大圈)。
520
- console.error('[sage-mem] autodream init failed:', err && err.stack ? err.stack : err)
521
- }
522
-
523
- // 同一轮对话里问题不变、注入内容也不变,但 DSH 每一步都会往 session 追加
524
- // 一条 context 消息——不去重的话同一份记忆会被反复追加,撑爆上下文。
525
- const recallCache = new Map()
526
-
527
- // 「本会话是否已注入过 baseline」——会话级状态,按 session id 记。
528
- //
529
- // 为什么不是消息条数:见下面 baseline 那段注释(恢复会话的历史消息早就在
530
- // session 里,条数永不为 0)。会话 id 在整场会话里稳定,与 assemble 时
531
- // 当前用户消息有没有落盘完全无关,所以新会话、恢复会话两种时序都成立。
532
- // 容量 64 与 recallCache 同理,只防长驻进程无限增长:要挤掉一个 id 得先有
533
- // 63 场别的会话开起来,那场老会话再被唤醒时最多是把同样的 baseline 再注入一次。
534
- const BASELINE_CACHE_MAX = 64
535
- const baselineInjected = new Set()
536
-
537
- // 「本会话已经注入掉多少字节」—— 会话级账本,按 session id 记。
538
- //
539
- // 为什么不扫会话记录去算:同一轮 assemble 出来的内容会被宿主合进**同一条**
540
- // user/message(运行时上下文、工作区指令、插件注入共用一条),按消息字节统计
541
- // 会把别人的内容算进我们的账;按文本找标记又太脆。自己记账是确定性最高的。
542
- //
543
- // 为什么要连 messageCount 一起记:压缩 / 清空之后上下文里的记忆已经没了,而
544
- // session id 不变 —— 只按 id 记账会让「预算用满」变成永久状态,压缩之后再也
545
- // 注不进来。消息条数变小是压缩发生过的最直接信号,用它把账本归零。
546
- const INJECT_LEDGER_MAX = 64
547
- const injectLedger = new Map()
548
-
549
- // 按问题检索注入:system-prompt/assemble 是异步 waterfall,监听器可 await。
550
- // 从 agent.session 拿当前 user message,扫 memory 目录按摘要匹配选相关
551
- // 文件,读全文作为动态 context 段注入——让 agent 第一轮就「想起」。
552
- ctx.on('system-prompt/assemble', async (assembly, context, next) => {
553
- const assembled = await next()
554
- // session id 在 try 外先取好:catch 里也要能带上它(拿到什么带什么)。
555
- const sessionId = context?.agent?.session?.id ?? ''
556
- try {
557
- const agent = context?.agent
558
- const session = agent?.session
559
- if (!session) return assembled
560
- // DSH 0.1.7 起 Session 的历史读取接口在陆续调整(snapshotEvents / eventAt /
561
- // ownEvents 已弃用)。deriveMessages 在 0.1.7-rc.1 实测仍在且仍同步,但这三行
562
- // 不是白写的:方法一旦被改名或改成异步,老写法要么静默不注入、要么把 Promise
563
- // 当数组展开成空。宁可日志里响一声,也不要"记忆不注入但日志一字没有"。
564
- if (typeof session.deriveMessages !== 'function') {
565
- warnLog(ctx, 'session.deriveMessages() 不可用 —— 记忆召回已跳过(DSH session API 变了?)', sessionId)
566
- return assembled
567
- }
568
- const derived = session.deriveMessages()
569
- const messages = [...(derived && typeof derived.then === 'function' ? await derived : derived)]
570
-
571
- // 关键:DSH 0.1.5 把工具结果(source.kind === 'tool')和注入内容
572
- // (source.kind === 'plugin')也存成 role: 'user' 的消息。只按 role 取
573
- // 「最后一条」会取到 tool-result,extractText 得到空串,下面的长度检查
574
- // 直接返回——表现就是全程静默不注入。只有 source.kind === 'user' 是真人输入。
575
- const userMessages = messages.filter(m => m?.role === 'user' && m?.source?.kind === 'user')
576
- const text = extractText(userMessages[userMessages.length - 1]?.content)
577
-
578
- const files = await scanMemoryFiles(ctx, sessionId)
579
- const picked = []
580
- const seen = new Set()
581
- const take = (f) => {
582
- if (seen.has(f.file)) return
583
- seen.add(f.file)
584
- picked.push(f)
585
- }
586
-
587
- // baseline:每个会话注入一次,**不看消息条数**。
588
- //
589
- // 旧写法 `if (userMessages.length === 0)` 依赖一个真实存在的时序:assemble
590
- // 触发时当前这条用户消息还没写进 session。已在 DSH 0.1.7-rc.1 上核实为真 ——
591
- // 宿主从 pending 队列取走本轮输入
592
- // 组装 system prompt(本监听器在这里跑)
593
- // 之后才把这条 user 消息落进 session
594
- // 所以全新会话首步 pick 到 0 条 user message,分支命中。
595
- //
596
- // 但**恢复 / 续聊会话**的历史消息早就在 session 里,条数永远 ≥ 1 → 分支永不
597
- // 命中 → 永不注入。而「继续吧」这类短问题在 description 里找不到匹配、检索
598
- // 返回 0 条,baseline 恰恰是那时唯一的兜底。这是本会话级状态要修的东西。
599
- let baselineTaken = 0
600
- const baselineKey = session.id ? session.id : session
601
- if (!baselineInjected.has(baselineKey)) {
602
- selectBaseline(files)
603
- .forEach(f => {
604
- const before = picked.length
605
- take(f)
606
- if (picked.length > before) baselineTaken++
607
- })
608
- // 只有真取到才落标记:首步若正好撞上目录读不到(scanMemoryFiles 降级返回 []),
609
- // 整场会话就再也没机会补 baseline 了。宁可下一步重算一次。
610
- if (baselineTaken > 0) {
611
- baselineInjected.add(baselineKey)
612
- if (baselineInjected.size > BASELINE_CACHE_MAX) {
613
- baselineInjected.delete(baselineInjected.values().next().value)
614
- }
615
- }
616
- }
617
-
618
- // 检索签名只覆盖「检索出来的那批」,baseline 不再掺进这个签名。
619
- // 旧版两者共用 signature,首步签名一旦与后续某步相同,baseline 会连坐被吞;
620
- // 反过来,旧版把 baseline 文件名也算进签名,会让同一步的检索结果在下一步
621
- // 因签名变化而被重复注入。现在 baseline 由会话级标记保证只来一次,
622
- // 检索由这份签名保证同一问题不重复追加,两条线互不干扰。
623
- const relevant = text && text.length >= 2 ? selectRelevant(text, files) : []
624
- const signature = text + '\u0000' + relevant.map(f => f.file).join(',')
625
- if (recallCache.get(session.id) !== signature) {
626
- recallCache.set(session.id, signature)
627
- if (recallCache.size > 8) recallCache.delete(recallCache.keys().next().value)
628
- relevant.forEach(take)
629
- }
630
-
631
- if (picked.length === 0) return assembled
632
-
633
- // ── 单会话累计预算 ──
634
- // 账记在 session id 上;消息条数变小 = 压缩/清空发生过 → 账本归零(见上面
635
- // injectLedger 的注释)。picked 里 baseline 永远排在检索结果之前,所以预算
636
- // 不够时先被挤掉的是「更晚才想起来的」那些 —— baseline 有优先权。
637
- const ledgerKey = session.id || 'no-session-id'
638
- const prevLedger = injectLedger.get(ledgerKey)
639
- let usedBytes = prevLedger && messages.length >= prevLedger.messageCount ? prevLedger.bytes : 0
640
- const nowMs = Date.now()
641
-
642
- const parts = []
643
- let skippedForBudget = 0
644
- for (const f of picked) {
645
- const body = f.content.length > MAX_CHARS_PER_FILE
646
- ? f.content.slice(0, MAX_CHARS_PER_FILE) + '\n…(截断)'
647
- : f.content
648
- const days = ageDays(f, nowMs)
649
- const stamp = days !== null && days >= STALE_DAYS ? `(保存于 ${days} 天前)` : ''
650
- const part = `### ${f.file}${stamp}\n${body}`
651
- const size = Buffer.byteLength(part, 'utf8')
652
- if (usedBytes + size > MAX_SESSION_BYTES) { skippedForBudget++; continue }
653
- usedBytes += size
654
- parts.push(part)
655
- }
656
-
657
- // 被预算挡掉了全部条目 —— 一个字都不注。本会话已经注进去的那批还在上下文里,
658
- // 每步再塞一条「预算已满」只是白花 token;真压缩过之后账本会自己归零。
659
- if (parts.length === 0) return assembled
660
-
661
- injectLedger.set(ledgerKey, { bytes: usedBytes, messageCount: messages.length })
662
- if (injectLedger.size > INJECT_LEDGER_MAX) {
663
- injectLedger.delete(injectLedger.keys().next().value)
664
- }
665
-
666
- // 状态行:让「注了几条、花了多少、被挡掉几条」在上下文里可见 ——
667
- // 「某条记忆没被注入」是最难查的一类故障,这里留一个可读的出口。
668
- const status = `(本次注入 ${parts.length} 条;本会话累计 ${Math.round(usedBytes / 1024)} KB` +
669
- ` / 上限 ${Math.round(MAX_SESSION_BYTES / 1024)} KB` +
670
- (skippedForBudget > 0 ? `;因预算省略 ${skippedForBudget} 条,需要时直接用文件工具读 ${MEMORY_DIR}` : '') +
671
- ')'
672
-
673
- return {
674
- ...assembled,
675
- contexts: [...(assembled.contexts ?? []), {
676
- name: 'sage-mem:recall',
677
- order: 1000,
678
- text: `以下是与你当前问题相关的历史记忆(文件式,来自 ${MEMORY_DIR}):\n\n` +
679
- `${RECALL_DISCIPLINE}\n\n${parts.join('\n\n')}\n\n${status}`,
680
- }],
681
- }
682
- } catch (err) {
683
- // 这是整条注入链的外壳。裸吞的代价就是 :174-193 记的那个坑:权限错、编码坏、
684
- // frontmatter 正则哪天不匹配,表现都是「插件在、但记忆不再注入」,日志一字没有。
685
- // 现在只降级(返回未注入的 assembly),但一定留痕。
686
- warnLog(ctx, `recall injection failed: ${err?.message ?? err}`, sessionId)
687
- return assembled
688
- }
689
- })
690
- }
691
-
692
- /** 列出 memory 目录全部记忆文件(文件名 + 类型 + 描述 + 大小 + 自定义标签)。 */
693
- async listFiles() {
694
- const files = await scanMemoryFiles(this.ctx, '')
695
- return files.map(f => {
696
- const meta = parseFrontmatter(f.content)
697
- return {
698
- file: f.file,
699
- type: meta.type || 'reference',
700
- description: meta.description || '',
701
- size: f.content.length,
702
- tags: meta.tags,
703
- }
704
- })
705
- }
706
-
707
- /** 读单个记忆文件全文。 */
708
- async readFile(name) {
709
- const safe = safeName(name)
710
- if (!safe) throw new Error('sage-mem: invalid file name')
711
- const content = await readFile(join(MEMORY_DIR, safe), 'utf8')
712
- return { name: safe, content }
713
- }
714
-
715
- /**
716
- * 写(新增或覆盖)单个记忆文件。
717
- *
718
- * 三道闸:保留名不可写、内容必须是字符串、体积有上限;落盘走原子写。
719
- * 拒绝一律返回 { ok: false, error }(不是抛异常)——设置页的调用方按 ok 判定。
720
- */
721
- async writeFile(name, content) {
722
- // 保留名判定放在最前面、且以 nameKey 归一:'memory.md' / 'MEMORY.MD' /
723
- // 'Session-Log.md' 在 NTFS 上都是同一个文件,必须一律走 { ok: false } 这条路
724
- // (设置页按 ok 判定,见 client.js 的 submit())。
725
- if (isReserved(name)) {
726
- return { ok: false, file: nameKey(name), error: `sage-mem: reserved file, edit it with the file tools: ${nameKey(name)}` }
727
- }
728
- const safe = safeName(name)
729
- if (!safe) throw new Error('sage-mem: invalid file name')
730
- // 不加这一层,传个对象进来会被 String() 写成 "[object Object]" 还回 { ok: true },
731
- // 原文件就此被一行垃圾顶掉,且没有任何错误信号。
732
- if (typeof content !== 'string') {
733
- return { ok: false, file: safe, error: `sage-mem: content must be a string, got ${typeof content}` }
734
- }
735
- const bytes = Buffer.byteLength(content, 'utf8')
736
- if (bytes > MAX_FILE_BYTES) {
737
- return { ok: false, file: safe, error: `sage-mem: content too large (${bytes} bytes > ${MAX_FILE_BYTES})` }
738
- }
739
- await writeFileAtomic(join(MEMORY_DIR, safe), content)
740
- return { ok: true, file: safe }
741
- }
742
-
743
- /** 删除单个记忆文件:保留名一律拒绝(unlink 不可逆)。判定同 writeFile。 */
744
- async deleteFile(name) {
745
- if (isReserved(name)) {
746
- return { ok: false, error: `sage-mem: reserved file, refusing to delete: ${nameKey(name)}` }
747
- }
748
- const safe = safeName(name)
749
- if (!safe) throw new Error('sage-mem: invalid file name')
750
- await unlink(join(MEMORY_DIR, safe))
751
- return { ok: true }
752
- }
753
- }
754
-
755
- markRemote(MemoryGateway, 'listFiles', 'listFiles')
756
- markRemote(MemoryGateway, 'readFile', 'readFile')
757
- markRemote(MemoryGateway, 'writeFile', 'writeFile')
758
- markRemote(MemoryGateway, 'deleteFile', 'deleteFile')
759
-
760
- /** 提取正文第一个 `# ` 标题作为展示标题。 */
761
- function extractTitle(content) {
762
- const m = content.match(/^---\r?\n[\s\S]*?\r?\n---/)
763
- const body = m ? content.slice(m[0].length) : content
764
- const h1 = body.match(/^#\s+(.+)$/m)
765
- return h1 ? h1[1].trim() : ''
766
- }
767
-
768
- /** 扫记忆目录,读全部 .md(跳过索引与 session-log),带体积与修改时间。 */
769
- async function scanStars(ctx) {
770
- let entries
771
- try {
772
- entries = await readdir(MEMORY_DIR, { withFileTypes: true })
773
- } catch (err) {
774
- if (err?.code !== 'ENOENT') warnLog(ctx, `cannot scan ${MEMORY_DIR}: ${err?.message ?? err}`)
775
- return []
776
- }
777
- const names = entries
778
- .filter(e => e.isFile() && isMemoryEntry(e.name))
779
- .map(e => e.name)
780
- const loaded = await Promise.all(names.map(async (name) => {
781
- try {
782
- const full = join(MEMORY_DIR, name)
783
- const [content, info] = await Promise.all([readFile(full, 'utf8'), stat(full)])
784
- return { name, content, bytes: info.size, mtimeMs: info.mtimeMs }
785
- } catch (err) {
786
- if (err?.code !== 'ENOENT') warnLog(ctx, `cannot read ${name}: ${err?.message ?? err}`)
787
- return null
788
- }
789
- }))
790
- return loaded.filter(Boolean)
791
- }
792
-
793
- /**
794
- * 记忆星图(宿主侧)—— 只读扫描记忆目录,把每条记忆解析成一颗「星」。
795
- * 通过 TypertRemoteService 暴露两个 remote 方法给界面侧:
796
- * - starmap.listStars() 全部星(元数据,不含正文)
797
- * - starmap.readFile(n) 单条记忆全文(文件名白名单校验)
798
- */
799
- export class StarmapGateway extends TypertRemoteService {
800
- constructor(ctx) { super(ctx, 'starmap') }
801
-
802
- async listStars() {
803
- const files = await scanStars(this.ctx)
804
- const stars = files.map(f => {
805
- const meta = parseFrontmatter(f.content)
806
- const title = extractTitle(f.content)
807
- return {
808
- file: f.name,
809
- kind: meta.type || 'special',
810
- title: title || meta.name || (meta.description ? meta.description.slice(0, 24) : f.name.replace(/\.md$/, '')),
811
- desc: meta.description || '',
812
- bytes: f.bytes,
813
- mtimeMs: f.mtimeMs,
814
- }
815
- })
816
- return { count: stars.length, stars }
817
- }
818
-
819
- async readFile(name) {
820
- // 与 MemoryGateway.readFile 收敛到同一个 safeName:它现在同时排除
821
- // MEMORY.md 与 session-log.md,且大小写不敏感。
822
- const safe = safeName(name)
823
- if (!safe) throw new Error('sage-starmap: invalid file name')
824
- const content = await readFile(join(MEMORY_DIR, safe), 'utf8')
825
- return { name: safe, content }
826
- }
827
- }
828
-
829
- markRemote(StarmapGateway, 'listStars', 'listStars')
830
- markRemote(StarmapGateway, 'readFile', 'readFile')
831
-
832
- /**
833
- * autodream 的 remote 面:设置页「自动做梦」标签页唯一的数据通道。
834
- *
835
- * 这一层刻意很薄——全部判断(门控、模式、白名单)都在 AutodreamEngine 里,
836
- * 这里只负责把 ctx 里的引擎接到 typert 的调用表上。想看行为就读 autodream.js。
837
- */
838
- export class AutodreamGateway extends TypertRemoteService {
839
- constructor(ctx, engine) {
840
- super(ctx, 'autodream')
841
- this.engine = engine
842
- }
843
-
844
- /** 配置 + 状态 + 路径 + 默认值(设置面板一次拿全)。 */
845
- async getConfig() {
846
- return this.engine.getConfig()
847
- }
848
-
849
- /** 改配置(补丁式)。 */
850
- async setConfig(patch) {
851
- return this.engine.setConfig(patch)
852
- }
853
-
854
- /** 轮询用:是否在跑、跑到哪一步、上次结果。 */
855
- async status() {
856
- return this.engine.status()
857
- }
858
-
859
- /**
860
- * 手动跑一趟。设置页的「立即整理」走这里。
861
- *
862
- * force 默认为 true —— 人点了按钮,就不该再被「还没到 24 小时」挡回来。
863
- *
864
- * **故意不 await**:一趟 autodream 可能跑几分钟(多轮模型调用 + 工具),
865
- * remote 调用挂在那儿等会直接撞上前端超时,界面上表现为「点了没反应」。
866
- * 所以这里只负责点火,进度与结果都从 status() 轮询 —— run() 自己会把
867
- * lastResult 落盘,界面刷新几次就能看到。
868
- */
869
- async runNow(opts) {
870
- if (this.engine.running) return { ok: false, error: '已有一趟 autodream 在跑' }
871
- const reason = typeof opts?.reason === 'string' && opts.reason.trim() ? opts.reason.trim().slice(0, 120) : '设置页手动触发'
872
- this.engine.run({ reason, force: true }).catch(() => {})
873
- return { ok: true }
874
- }
875
-
876
- /** 历史报告列表。 */
877
- async listReports() {
878
- return this.engine.listReports()
879
- }
880
-
881
- /** 读一份报告全文。 */
882
- async readReport(name) {
883
- return this.engine.readReport(name)
884
- }
885
-
886
- /** 已有快照(面板显示「可回退到哪一份」)。 */
887
- async listSnapshots() {
888
- return this.engine.listSnapshots()
889
- }
890
-
891
- /** 运行记录列表(面板的「整理声明」区)。 */
892
- async listRuns(opts) {
893
- return this.engine.listRuns(opts?.limit)
894
- }
895
-
896
- /** 读某次运行的完整整理声明(人读 markdown + 机读 manifest)。 */
897
- async readDeclaration(opts) {
898
- return this.engine.readDeclaration(opts?.runId)
899
- }
900
-
901
- /**
902
- * 回滚到某个回滚点。
903
- *
904
- * 与 runNow 相反,这里**必须 await**:回滚是几十毫秒级的文件操作,
905
- * 而「点完按钮不知道到底滚没滚」正是回滚这种动作最不能接受的体验。
906
- */
907
- async rollback(opts) {
908
- return this.engine.rollback({ snapshotId: opts?.snapshotId, scope: opts?.scope })
909
- }
910
-
911
- /** 可选的整理模型路线(设置面板的模型下拉)。 */
912
- async listModels() {
913
- return this.engine.listModels()
914
- }
915
- }
916
-
917
- markRemote(AutodreamGateway, 'getConfig', 'getConfig')
918
- markRemote(AutodreamGateway, 'setConfig', 'setConfig')
919
- markRemote(AutodreamGateway, 'status', 'status')
920
- markRemote(AutodreamGateway, 'runNow', 'runNow')
921
- markRemote(AutodreamGateway, 'listReports', 'listReports')
922
- markRemote(AutodreamGateway, 'readReport', 'readReport')
923
- markRemote(AutodreamGateway, 'listSnapshots', 'listSnapshots')
924
- markRemote(AutodreamGateway, 'listRuns', 'listRuns')
925
- markRemote(AutodreamGateway, 'readDeclaration', 'readDeclaration')
926
- markRemote(AutodreamGateway, 'rollback', 'rollback')
927
- markRemote(AutodreamGateway, 'listModels', 'listModels')
928
-
929
- export default MemoryGateway
930
-
931
- /**
932
- * **仅供测试**的只读出口。
933
- *
934
- * 检索这条链(frontmatter 解析 → 双字打分 → 名额裁剪)没有任何对外可见的调用面:
935
- * 想验证「写了 aliases 之后换个说法能不能命中」,否则只能起一个真的 DSH 端到端跑一遍。
936
- * 把这些纯函数露出来是为了能写断言,**不是给外部用的 API**,别依赖。
937
- */
938
- export const __testables = {
939
- bigrams,
940
- score,
941
- parseFrontmatter,
942
- parseAliases,
943
- parseBaselinePriority,
944
- parseBaseline,
945
- parseTags,
946
- ageDays,
947
- selectRelevant,
948
- selectBaseline,
949
- limits: { MAX_RESULTS, MAX_CHARS_PER_FILE, MAX_BASELINE, MAX_SESSION_BYTES, STALE_DAYS },
950
- recallDiscipline: RECALL_DISCIPLINE,
951
- }
1
+ /**
2
+ * sage-mem — 文件式记忆插件。
3
+ *
4
+ * 记忆存在本地 markdown 文件目录(frontmatter + 正文,4 类),不是数据库。
5
+ * host 侧三件事:
6
+ * 1. 按问题检索注入:system-prompt/assemble 时扫 memory 目录,按双字匹配
7
+ * 选相关文件,读全文注入 system prompt,让 agent 第一轮就「想起」
8
+ * 2. TypertRemoteService:给设置页「记忆管理」提供文件列表/读/写/删
9
+ * 3. autodream(自动做梦):可选的记忆整理,见 lib/autodream.js
10
+ *
11
+ * 写入靠 AGENTS.md 的「记忆使用」规则引导 agent 自己判断 + 用文件工具写
12
+ * memory 目录——透明、可检查、防膨胀。无 worker、无 SQLite、无端口。
13
+ *
14
+ * 旧 worker 版见 git 分支 `sqlite-worker`。
15
+ */
16
+
17
+ import { AutodreamEngine } from './autodream.js'
18
+ import { auditMemoryDir } from './autodream-audit.js'
19
+ import { DEFAULT_CONFIG } from './autodream/config.js'
20
+ import { readJson } from './autodream/util.js'
21
+ import { MAX_BASELINE, MAX_CHARS_PER_FILE, MAX_FILE_BYTES, MAX_RESULTS, MAX_SESSION_BYTES, MEMORY_DIR, RECALL_DISCIPLINE, RESERVED_FILES, SESSIONS_ROOT, STALE_DAYS, STATE_ROOT } from './memory/config.js'
22
+ import { parseAliases, parseBaseline, parseBaselinePriority, parseFrontmatter, parseTags } from './memory/frontmatter.js'
23
+ import { isReserved, nameKey, safeName } from './memory/naming.js'
24
+ import { ageDays, bigrams, extractText, score, selectBaseline, selectRelevant } from './memory/retrieval.js'
25
+ import { MEMORY_ARCHIVE_DIR, addArchiveMeta, extractTitle, readArchivedMeta, scanArchived, scanMemoryFiles, scanStars } from './memory/scan.js'
26
+ import { archiveMemory, restoreMemory } from './memory/archive.js'
27
+ import { flushAccess, pickArchiveCandidates, readAccessLedger, recordAccess } from './memory/access.js'
28
+ import { warnLog, writeFileAtomic } from './memory/util.js'
29
+ import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
30
+ import { mkdir, readFile, stat, unlink, writeFile } from 'node:fs/promises'
31
+ import { basename, join } from 'node:path'
32
+
33
+ /**
34
+ * `SAGE_MEM_DEBUG=1` 才出声的注入链调试出口。
35
+ *
36
+ * 一行结构化 JSON:候选数 / 命中数 / 逐条得分 / 入选与真注入的文件名 / 注入字符数 /
37
+ * 被会话预算挡掉几条。它回答的是「这条记忆为什么没被召回」——一个光看上下文永远查不出的问题。
38
+ *
39
+ * ⚠️ **不开时零输出、零额外开销**:常量的读取只在模块加载时发生一次;调用点整个包在
40
+ * `if (DEBUG_RECALL)` 里,连 payload 对象都不会被构造出来。
41
+ */
42
+ const DEBUG_RECALL = process.env.SAGE_MEM_DEBUG === '1'
43
+
44
+ function debugRecall(payload) {
45
+ if (!DEBUG_RECALL) return
46
+ console.log(`[sage-mem] recall ${JSON.stringify(payload)}`)
47
+ }
48
+
49
+ /**
50
+ * 面板可改的 5 项注入上限 —— 与 `memory/config.js` 里的 `SAGE_MEM_*` 一一对应。
51
+ * 边界值照抄那边的 envInt 调用(lo/hi 不许在这里另立一套)。
52
+ */
53
+ const LIMIT_SPECS = {
54
+ maxResults: { env: 'SAGE_MEM_MAX_RESULTS', dflt: 10, lo: 1, hi: 30 },
55
+ maxChars: { env: 'SAGE_MEM_MAX_CHARS', dflt: 1500, lo: 200, hi: 20000 },
56
+ maxBaseline: { env: 'SAGE_MEM_MAX_BASELINE', dflt: 8, lo: 1, hi: 50 },
57
+ maxSessionBytes: { env: 'SAGE_MEM_MAX_SESSION_BYTES', dflt: 60 * 1024, lo: 4096, hi: 512 * 1024 },
58
+ staleDays: { env: 'SAGE_MEM_STALE_DAYS', dflt: 1, lo: 0, hi: 365 },
59
+ }
60
+
61
+ /** 面板设置的落点:状态根下(与 autodream 的配置、台账同级)。 */
62
+ const SETTINGS_FILE = join(STATE_ROOT, 'settings.json')
63
+ const RESERVED_FILE = join(STATE_ROOT, 'reserved.json')
64
+
65
+ /**
66
+ * `writeRaw` 的体积上限(4 MB)。
67
+ *
68
+ * ⚠️ **故意不复用 `MAX_FILE_BYTES`(512 KB)**:保留名里就有 `session-log.md` 这种追加型
69
+ * 流水,它天然长得比单条记忆大得多(实测已 > 512 KB)。沿用小上限 = 用户永远存不回去。
70
+ */
71
+ const MAX_RAW_BYTES = 4 * 1024 * 1024
72
+
73
+ /** 整数收敛(与 config.js 的 envInt 同一套语义):非有限数回落默认,越界夹到边界。 */
74
+ const clampInt = (v, spec) => (Number.isFinite(v) ? Math.max(spec.lo, Math.min(spec.hi, Math.round(v))) : spec.dflt)
75
+
76
+ /** 读 `<状态根>/settings.json`;缺失、坏 JSON、不是对象都当「没设置」。 */
77
+ async function readSettingsFile() {
78
+ try {
79
+ const parsed = JSON.parse(await readFile(SETTINGS_FILE, 'utf8'))
80
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null
81
+ } catch {
82
+ return null
83
+ }
84
+ }
85
+
86
+ /** 待生效的 5 项上限:`settings.json` > 环境变量 > 内置默认。 */
87
+ function pendingLimits(fileObj) {
88
+ const out = {}
89
+ const saved = fileObj?.limits && typeof fileObj.limits === 'object' ? fileObj.limits : {}
90
+ for (const [key, spec] of Object.entries(LIMIT_SPECS)) {
91
+ const fromFile = Number(saved[key])
92
+ if (Number.isFinite(fromFile)) {
93
+ out[key] = clampInt(fromFile, spec)
94
+ continue
95
+ }
96
+ const raw = process.env[spec.env]
97
+ out[key] = clampInt(raw === undefined || raw === '' ? NaN : Number(raw), spec)
98
+ }
99
+ return out
100
+ }
101
+
102
+ /** 读 `<状态根>/reserved.json`(用户声明的保护名单)。**保留原始大小写**,好让 get 读回一致。 */
103
+ async function readReservedFile() {
104
+ try {
105
+ const arr = JSON.parse(await readFile(RESERVED_FILE, 'utf8'))
106
+ if (!Array.isArray(arr)) return []
107
+ return arr
108
+ .map((s) => String(s).trim())
109
+ .filter((s) => s.endsWith('.md') && !s.includes('/') && !s.includes('\\') && !s.startsWith('..'))
110
+ } catch {
111
+ return []
112
+ }
113
+ }
114
+
115
+ /**
116
+ * 原样读写用的名字校验:只要求「单段、`.md`」。
117
+ *
118
+ * ⚠️ **故意不走 `safeName`**:那个函数把 `MEMORY.md` / `session-log.md` 当非记忆条目排除,
119
+ * 而它们恰恰是 `readRaw` / `writeRaw` 的目标。这里只防路径成分与扩展名。
120
+ */
121
+ function rawName(raw) {
122
+ const s = typeof raw === 'string' ? raw : ''
123
+ if (!s || basename(s) !== s) return null
124
+ return /\.md$/i.test(s) ? s : null
125
+ }
126
+
127
+ /** autodream 的实际配置(缺文件/坏文件回落默认值)—— 面板要显示真实策略,不能显示想象。 */
128
+ async function autodreamConfigSnapshot() {
129
+ const parsed = await readJson(join(STATE_ROOT, 'autodream.json'))
130
+ return { ...DEFAULT_CONFIG, ...(parsed?.config ?? {}) }
131
+ }
132
+
133
+ /**
134
+ * 手动 @Remote marker 注册(Node 24 不解析装饰器语法,手写 decorator context)。
135
+ */
136
+ function markRemote(cls, method, exportName) {
137
+ const instance = Object.create(cls.prototype)
138
+ Remote(exportName)(undefined, {
139
+ kind: 'method',
140
+ name: method,
141
+ private: false,
142
+ static: false,
143
+ addInitializer(fn) {
144
+ fn.call(instance)
145
+ },
146
+ })
147
+ }
148
+
149
+ export class MemoryGateway extends TypertRemoteService {
150
+ constructor(ctx) {
151
+ super(ctx, 'memory')
152
+ // 一并挂载星图 remote(同一记忆目录,纯只读)—— starmap.* 服务
153
+ new StarmapGateway(ctx)
154
+
155
+ // autodream(夜间记忆整理):引擎 + remote 面 + 自动触发的定时器。
156
+ //
157
+ // 整个包在 try 里:autodream 起不来(比如 timer 服务缺失、状态目录没权限)
158
+ // 不该把 sage-mem 最核心的那件事——「记忆按问题注入」——一起拖死。
159
+ // 这是本插件唯一一处「降级而不是失败」的设计,理由就在这里。
160
+ try {
161
+ const engine = new AutodreamEngine(ctx, {
162
+ memoryDir: MEMORY_DIR,
163
+ sessionsRoot: SESSIONS_ROOT,
164
+ reservedFiles: [...RESERVED_FILES],
165
+ })
166
+ new AutodreamGateway(ctx, engine)
167
+ engine.start()
168
+ // 卸载时把定时器与锁收干净。effect 拿不到就交给 unref 兜底(不阻止进程退出)。
169
+ if (typeof ctx.effect === 'function') ctx.effect(() => () => engine.dispose())
170
+ } catch (err) {
171
+ warnLog(ctx, `autodream 初始化失败(记忆本体不受影响):${err?.message ?? err}`)
172
+ // 异常路径额外打一行到 stderr:warnLog 走 ctx.logger,某些启动阶段它还没接上,
173
+ // 而「autodream 静默不工作」正是最难查的那种故障(曾为此排查很久)。
174
+ console.error('[sage-mem] autodream init failed:', err && err.stack ? err.stack : err)
175
+ }
176
+
177
+ // 同一轮对话里问题不变、注入内容也不变,但 DSH 每一步都会往 session 追加
178
+ // 一条 context 消息——不去重的话同一份记忆会被反复追加,撑爆上下文。
179
+ const recallCache = new Map()
180
+
181
+ // 「本会话是否已注入过 baseline」——会话级状态,按 session id 记。
182
+ //
183
+ // 为什么不是消息条数:见下面 baseline 那段注释(恢复会话的历史消息早就在
184
+ // session 里,条数永不为 0)。会话 id 在整场会话里稳定,与 assemble 时
185
+ // 当前用户消息有没有落盘完全无关,所以新会话、恢复会话两种时序都成立。
186
+ // 容量 64 与 recallCache 同理,只防长驻进程无限增长:要挤掉一个 id 得先有
187
+ // 63 场别的会话开起来,那场老会话再被唤醒时最多是把同样的 baseline 再注入一次。
188
+ const BASELINE_CACHE_MAX = 64
189
+ const baselineInjected = new Set()
190
+
191
+ // 「本会话已经注入掉多少字节」—— 会话级账本,按 session id 记。
192
+ //
193
+ // 为什么不扫会话记录去算:同一轮 assemble 出来的内容会被宿主合进**同一条**
194
+ // user/message(运行时上下文、工作区指令、插件注入共用一条),按消息字节统计
195
+ // 会把别人的内容算进本插件的账;按文本找标记又太脆。自己记账是确定性最高的。
196
+ //
197
+ // 为什么要连 messageCount 一起记:压缩 / 清空之后上下文里的记忆已经没了,而
198
+ // session id 不变 —— 只按 id 记账会让「预算用满」变成永久状态,压缩之后再也
199
+ // 注不进来。消息条数变小是压缩发生过的最直接信号,用它把账本归零。
200
+ const INJECT_LEDGER_MAX = 64
201
+ const injectLedger = new Map()
202
+
203
+ // 按问题检索注入:system-prompt/assemble 是异步 waterfall,监听器可 await。
204
+ // 从 agent.session 拿当前 user message,扫 memory 目录按摘要匹配选相关
205
+ // 文件,读全文作为动态 context 段注入——让 agent 第一轮就「想起」。
206
+ ctx.on('system-prompt/assemble', async (assembly, context, next) => {
207
+ const assembled = await next()
208
+ // session id 在 try 外先取好:catch 里也要能带上它(拿到什么带什么)。
209
+ const sessionId = context?.agent?.session?.id ?? ''
210
+ try {
211
+ const agent = context?.agent
212
+ const session = agent?.session
213
+ if (!session) return assembled
214
+ // DSH 0.1.7 起 Session 的历史读取接口在陆续调整(snapshotEvents / eventAt /
215
+ // ownEvents 已弃用)。deriveMessages 在 0.1.7-rc.1 实测仍在且仍同步,但这三行
216
+ // 不是白写的:方法一旦被改名或改成异步,老写法要么静默不注入、要么把 Promise
217
+ // 当数组展开成空。宁可日志里响一声,也不要"记忆不注入但日志一字没有"。
218
+ if (typeof session.deriveMessages !== 'function') {
219
+ warnLog(ctx, 'session.deriveMessages() 不可用 —— 记忆召回已跳过(DSH session API 变了?)', sessionId)
220
+ return assembled
221
+ }
222
+ const derived = session.deriveMessages()
223
+ const messages = [...(derived && typeof derived.then === 'function' ? await derived : derived)]
224
+
225
+ // 关键:DSH 0.1.5 把工具结果(source.kind === 'tool')和注入内容
226
+ // (source.kind === 'plugin')也存成 role: 'user' 的消息。只按 role 取
227
+ // 「最后一条」会取到 tool-result,extractText 得到空串,下面的长度检查
228
+ // 直接返回——表现就是全程静默不注入。只有 source.kind === 'user' 是真人输入。
229
+ const userMessages = messages.filter(m => m?.role === 'user' && m?.source?.kind === 'user')
230
+ const text = extractText(userMessages[userMessages.length - 1]?.content)
231
+
232
+ const files = await scanMemoryFiles(ctx, sessionId)
233
+ const picked = []
234
+ const seen = new Set()
235
+ const take = (f) => {
236
+ if (seen.has(f.file)) return
237
+ seen.add(f.file)
238
+ picked.push(f)
239
+ }
240
+
241
+ // baseline:每个会话注入一次,**不看消息条数**。
242
+ //
243
+ // 旧写法 `if (userMessages.length === 0)` 依赖一个真实存在的时序:assemble
244
+ // 触发时当前这条用户消息还没写进 session。已在 DSH 0.1.7-rc.1 上核实为真 ——
245
+ // 宿主从 pending 队列取走本轮输入
246
+ // 组装 system prompt(本监听器在这里跑)
247
+ // 之后才把这条 user 消息落进 session
248
+ // 所以全新会话首步 pick 到 0 条 user message,分支命中。
249
+ //
250
+ // 但**恢复 / 续聊会话**的历史消息早就在 session 里,条数永远 ≥ 1 → 分支永不
251
+ // 命中 → 永不注入。而「继续吧」这类短问题在 description 里找不到匹配、检索
252
+ // 返回 0 条,baseline 恰恰是那时唯一的兜底。这是本会话级状态要修的东西。
253
+ let baselineTaken = 0
254
+ const baselineKey = session.id ? session.id : session
255
+ if (!baselineInjected.has(baselineKey)) {
256
+ selectBaseline(files)
257
+ .forEach(f => {
258
+ const before = picked.length
259
+ take(f)
260
+ if (picked.length > before) baselineTaken++
261
+ })
262
+ // 只有真取到才落标记:首步若正好撞上目录读不到(scanMemoryFiles 降级返回 []),
263
+ // 整场会话就再也没机会补 baseline 了。宁可下一步重算一次。
264
+ if (baselineTaken > 0) {
265
+ baselineInjected.add(baselineKey)
266
+ if (baselineInjected.size > BASELINE_CACHE_MAX) {
267
+ baselineInjected.delete(baselineInjected.values().next().value)
268
+ }
269
+ }
270
+ }
271
+
272
+ // 检索签名只覆盖「检索出来的那批」,baseline 不再掺进这个签名。
273
+ // 旧版两者共用 signature,首步签名一旦与后续某步相同,baseline 会连坐被吞;
274
+ // 反过来,旧版把 baseline 文件名也算进签名,会让同一步的检索结果在下一步
275
+ // 因签名变化而被重复注入。现在 baseline 由会话级标记保证只来一次,
276
+ // 检索由这份签名保证同一问题不重复追加,两条线互不干扰。
277
+ const relevant = text && text.length >= 2 ? selectRelevant(text, files) : []
278
+ const signature = text + '\u0000' + relevant.map(f => f.file).join(',')
279
+ if (recallCache.get(session.id) !== signature) {
280
+ recallCache.set(session.id, signature)
281
+ if (recallCache.size > 8) recallCache.delete(recallCache.keys().next().value)
282
+ relevant.forEach(take)
283
+ }
284
+
285
+ if (picked.length === 0) {
286
+ if (DEBUG_RECALL) debugRecall({ scanned: files.length, hits: relevant.length, scores: [], picked: [], injected: [], chars: 0, skippedByBudget: 0 })
287
+ return assembled
288
+ }
289
+
290
+ // ── 单会话累计预算 ──
291
+ // 账记在 session id 上;消息条数变小 = 压缩/清空发生过 → 账本归零(见上面
292
+ // injectLedger 的注释)。picked 里 baseline 永远排在检索结果之前,所以预算
293
+ // 不够时先被挤掉的是「更晚才想起来的」那些 —— baseline 有优先权。
294
+ const ledgerKey = session.id || 'no-session-id'
295
+ const prevLedger = injectLedger.get(ledgerKey)
296
+ let usedBytes = prevLedger && messages.length >= prevLedger.messageCount ? prevLedger.bytes : 0
297
+ const nowMs = Date.now()
298
+
299
+ const parts = []
300
+ const injectedFiles = []
301
+ let skippedForBudget = 0
302
+ for (const f of picked) {
303
+ const body = f.content.length > MAX_CHARS_PER_FILE
304
+ ? f.content.slice(0, MAX_CHARS_PER_FILE) + '\n…(截断)'
305
+ : f.content
306
+ const days = ageDays(f, nowMs)
307
+ const stamp = days !== null && days >= STALE_DAYS ? `(保存于 ${days} 天前)` : ''
308
+ const part = `### ${f.file}${stamp}\n${body}`
309
+ const size = Buffer.byteLength(part, 'utf8')
310
+ if (usedBytes + size > MAX_SESSION_BYTES) { skippedForBudget++; continue }
311
+ usedBytes += size
312
+ parts.push(part)
313
+ injectedFiles.push(f.file)
314
+ }
315
+
316
+ // 进上下文的这些才算「被用过」—— 台账是自动归档阈值的唯一数据源,记错一步阈值就偏。
317
+ // recordAccess 同步、绝不抛:台账的问题不许升级成注入失败。
318
+ recordAccess(injectedFiles, nowMs)
319
+
320
+ // 被预算挡掉了全部条目 —— 一个字都不注。本会话已经注进去的那批还在上下文里,
321
+ // 每步再塞一条「预算已满」只是白花 token;真压缩过之后账本会自己归零。
322
+ // 这条早退也要出声:一条都没注进去,正是最需要解释的那种情况。
323
+ if (parts.length === 0) {
324
+ if (DEBUG_RECALL) {
325
+ debugRecall({
326
+ scanned: files.length, hits: relevant.length,
327
+ scores: relevant.map(f => [f.file, Number(f.score.toFixed(4))]),
328
+ picked: picked.map(f => f.file), injected: [], chars: usedBytes, skippedByBudget: skippedForBudget,
329
+ })
330
+ }
331
+ return assembled
332
+ }
333
+
334
+ injectLedger.set(ledgerKey, { bytes: usedBytes, messageCount: messages.length })
335
+ if (injectLedger.size > INJECT_LEDGER_MAX) {
336
+ injectLedger.delete(injectLedger.keys().next().value)
337
+ }
338
+
339
+ // 状态行:让「注了几条、花了多少、被挡掉几条」在上下文里可见 ——
340
+ // 「某条记忆没被注入」是最难查的一类故障,这里留一个可读的出口。
341
+ const status = `(本次注入 ${parts.length} 条;本会话累计 ${Math.round(usedBytes / 1024)} KB` +
342
+ ` / 上限 ${Math.round(MAX_SESSION_BYTES / 1024)} KB` +
343
+ (skippedForBudget > 0 ? `;因预算省略 ${skippedForBudget} 条,需要时直接用文件工具读 ${MEMORY_DIR}` : '') +
344
+ ')'
345
+
346
+ if (DEBUG_RECALL) {
347
+ debugRecall({
348
+ scanned: files.length,
349
+ hits: relevant.length,
350
+ scores: relevant.map(f => [f.file, Number(f.score.toFixed(4))]),
351
+ picked: picked.map(f => f.file),
352
+ injected: injectedFiles,
353
+ chars: usedBytes,
354
+ skippedByBudget: skippedForBudget,
355
+ })
356
+ }
357
+
358
+ return {
359
+ ...assembled,
360
+ contexts: [...(assembled.contexts ?? []), {
361
+ name: 'sage-mem:recall',
362
+ order: 1000,
363
+ text: `以下是与你当前问题相关的历史记忆(文件式,来自 ${MEMORY_DIR}):\n\n` +
364
+ `${RECALL_DISCIPLINE}\n\n${parts.join('\n\n')}\n\n${status}`,
365
+ }],
366
+ }
367
+ } catch (err) {
368
+ // 这是整条注入链的外壳。裸吞的代价就是 :174-193 记的那个坑:权限错、编码坏、
369
+ // frontmatter 正则哪天不匹配,表现都是「插件在、但记忆不再注入」,日志一字没有。
370
+ // 现在只降级(返回未注入的 assembly),但一定留痕。
371
+ warnLog(ctx, `recall injection failed: ${err?.message ?? err}`, sessionId)
372
+ return assembled
373
+ }
374
+ })
375
+ }
376
+
377
+ /** 列出 memory 目录全部记忆文件(文件名 + 类型 + 描述 + 大小 + 自定义标签)。 */
378
+ async listFiles() {
379
+ const files = await scanMemoryFiles(this.ctx, '')
380
+ return files.map(f => {
381
+ const meta = parseFrontmatter(f.content)
382
+ return {
383
+ file: f.file,
384
+ type: meta.type || 'reference',
385
+ description: meta.description || '',
386
+ size: f.content.length,
387
+ tags: meta.tags,
388
+ }
389
+ })
390
+ }
391
+
392
+ /** 读单个记忆文件全文。 */
393
+ async readFile(name) {
394
+ const safe = safeName(name)
395
+ if (!safe) throw new Error('sage-mem: invalid file name')
396
+ const content = await readFile(join(MEMORY_DIR, safe), 'utf8')
397
+ return { name: safe, content }
398
+ }
399
+
400
+ /**
401
+ * 写(新增或覆盖)单个记忆文件。
402
+ *
403
+ * 三道闸:保留名不可写、内容必须是字符串、体积有上限;落盘走原子写。
404
+ * 拒绝一律返回 { ok: false, error }(不是抛异常)——设置页的调用方按 ok 判定。
405
+ */
406
+ async writeFile(name, content) {
407
+ // 保留名判定放在最前面、且以 nameKey 归一:'memory.md' / 'MEMORY.MD' /
408
+ // 'Session-Log.md' 在 NTFS 上都是同一个文件,必须一律走 { ok: false } 这条路
409
+ // (设置页按 ok 判定,见 client.js 的 submit())。
410
+ if (isReserved(name)) {
411
+ return { ok: false, file: nameKey(name), error: `sage-mem: reserved file, edit it with the file tools: ${nameKey(name)}` }
412
+ }
413
+ const safe = safeName(name)
414
+ if (!safe) throw new Error('sage-mem: invalid file name')
415
+ // 不加这一层,传个对象进来会被 String() 写成 "[object Object]" 还回 { ok: true },
416
+ // 原文件就此被一行垃圾顶掉,且没有任何错误信号。
417
+ if (typeof content !== 'string') {
418
+ return { ok: false, file: safe, error: `sage-mem: content must be a string, got ${typeof content}` }
419
+ }
420
+ const bytes = Buffer.byteLength(content, 'utf8')
421
+ if (bytes > MAX_FILE_BYTES) {
422
+ return { ok: false, file: safe, error: `sage-mem: content too large (${bytes} bytes > ${MAX_FILE_BYTES})` }
423
+ }
424
+ await writeFileAtomic(join(MEMORY_DIR, safe), content)
425
+ return { ok: true, file: safe }
426
+ }
427
+
428
+ /** 删除单个记忆文件:保留名一律拒绝(unlink 不可逆)。判定同 writeFile。 */
429
+ async deleteFile(name) {
430
+ if (isReserved(name)) {
431
+ return { ok: false, error: `sage-mem: reserved file, refusing to delete: ${nameKey(name)}` }
432
+ }
433
+ const safe = safeName(name)
434
+ if (!safe) throw new Error('sage-mem: invalid file name')
435
+ await unlink(join(MEMORY_DIR, safe))
436
+ return { ok: true }
437
+ }
438
+
439
+ /**
440
+ * 归档一条记忆:把文件移进 `memory/archive/`(**不删**),并在 frontmatter 里留下
441
+ * `archived_at` / `archived_reason`。
442
+ *
443
+ * 语义是「静默失效且可回退」:归档区不参与检索(见 memory/scan.js 的 scanMemoryFiles),
444
+ * 但列表与星图都看得见它,也能一键恢复 —— 所以这里只是移动 + 打标,绝不 unlink。
445
+ *
446
+ * 真正的实现在 `memory/archive.js`:autodream 的 `archive_memory` 工具与自动归档策略
447
+ * 走的是同一份 —— 三条入口只许有一种行为。
448
+ *
449
+ * 拒绝一律返回 `{ ok: false, error }`(不是抛):面板按 ok 判定,抛出去只会变成
450
+ * 一句没有上下文的报错。
451
+ */
452
+ async archive(name, reason) {
453
+ const res = await archiveMemory(MEMORY_DIR, name, reason)
454
+ return res.ok ? { ok: true, file: res.file } : { ok: false, error: res.error }
455
+ }
456
+
457
+ /**
458
+ * 从归档区恢复一条记忆:移回根目录,并去掉 `archived_at` / `archived_reason` 两行。
459
+ * 同样委托给 `memory/archive.js`(判断与拒绝矩阵只有一份)。
460
+ */
461
+ async restore(name) {
462
+ const res = await restoreMemory(MEMORY_DIR, name)
463
+ return res.ok ? { ok: true, file: res.file } : { ok: false, error: res.error }
464
+ }
465
+
466
+ /**
467
+ * 列出归档区的全部记忆(面板的「已归档」区)。
468
+ *
469
+ * 与 `listFiles()` 分开、形状也不同:`listFiles` 只列活动记忆、多一个字段都会破坏
470
+ * 界面与断言依赖的形状;归档条目额外带 `archivedAt` / `archivedReason`。
471
+ * 旧版 autodream 工具直接移文件、不写留痕,所以这两个字段可能是空串。
472
+ */
473
+ async listArchived() {
474
+ const files = await scanArchived(this.ctx)
475
+ const out = files.map(f => {
476
+ const meta = parseFrontmatter(f.content)
477
+ const archived = readArchivedMeta(f.content)
478
+ return {
479
+ file: f.name,
480
+ type: meta.type || 'reference',
481
+ description: meta.description || '',
482
+ size: f.content.length,
483
+ tags: meta.tags,
484
+ archivedAt: archived.archivedAt,
485
+ archivedReason: archived.archivedReason,
486
+ }
487
+ })
488
+ return { count: out.length, files: out }
489
+ }
490
+
491
+ /**
492
+ * 读一份**已归档**记忆的全文。
493
+ *
494
+ * 归档不是删除,正文就该和活跃记忆一样能看能改(使用者的要求)。名字仍然只走
495
+ * `safeName`:调用方永远不许自己拼路径,归档区也只认 `memory/archive/` 这一层。
496
+ */
497
+ async readArchived(name) {
498
+ const safe = safeName(name)
499
+ if (!safe) throw new Error('sage-mem: invalid file name')
500
+ const full = join(MEMORY_ARCHIVE_DIR, safe)
501
+ const info = await stat(full).catch(() => null)
502
+ // 找不到就抛:与 readFile 一致。静默返回空串会让面板显示一个「空的记忆」,
503
+ // 而用户改完一存 —— 就把一条真实内容顶掉了。
504
+ if (!info?.isFile()) throw new Error(`sage-mem: archived file not found: ${safe}`)
505
+ const content = await readFile(full, 'utf8')
506
+ return { name: safe, content }
507
+ }
508
+
509
+ /**
510
+ * 写一份**已归档**记忆的正文(覆盖整份文件,形状与 `writeFile` 一致)。
511
+ *
512
+ * 三道闸照抄 writeFile:名字先过 safeName、内容必须是字符串、体积上限,最后原子写。
513
+ * 额外一条:**盘上那份的归档留痕(`archived_at` / `archived_reason`)不许因为改正文而丢**。
514
+ * 调用方若交回一份连留痕都没有的内容(「重建 frontmatter」式编辑器就是这样),
515
+ * 这里按盘上原来的值补回去 —— 归档凭据不该依赖调用方是否体贴。
516
+ */
517
+ async writeArchived(name, content) {
518
+ if (isReserved(name)) {
519
+ return { ok: false, file: nameKey(name), error: `sage-mem: reserved file, edit it with the file tools: ${nameKey(name)}` }
520
+ }
521
+ const safe = safeName(name)
522
+ if (!safe) throw new Error('sage-mem: invalid file name')
523
+ const full = join(MEMORY_ARCHIVE_DIR, safe)
524
+ const info = await stat(full).catch(() => null)
525
+ // 只改归档区里**已经存在**的文件:这条 remote 不是「把任意文件写进归档区」的后门。
526
+ if (!info?.isFile()) return { ok: false, file: safe, error: `sage-mem: archived file not found: ${safe}` }
527
+ if (typeof content !== 'string') {
528
+ return { ok: false, file: safe, error: `sage-mem: content must be a string, got ${typeof content}` }
529
+ }
530
+ const bytes = Buffer.byteLength(content, 'utf8')
531
+ if (bytes > MAX_FILE_BYTES) {
532
+ return { ok: false, file: safe, error: `sage-mem: content too large (${bytes} bytes > ${MAX_FILE_BYTES})` }
533
+ }
534
+ const given = readArchivedMeta(content)
535
+ let toWrite = content
536
+ if (!given.archivedAt && !given.archivedReason) {
537
+ const onDisk = readArchivedMeta(await readFile(full, 'utf8'))
538
+ if (onDisk.archivedAt || onDisk.archivedReason) {
539
+ toWrite = addArchiveMeta(content, onDisk.archivedReason, onDisk.archivedAt)
540
+ }
541
+ }
542
+ await writeFileAtomic(full, toWrite)
543
+ return { ok: true, file: safe }
544
+ }
545
+
546
+ /**
547
+ * 记忆目录体检:结构化审计结果。
548
+ *
549
+ * **直接透出 `auditMemoryDir` 的返回**,不另写一套检查 —— 面板上的「问题」与 autodream
550
+ * 报告里的必须是同一份判断,否则用户与模型看到的健康状况会互相打架。只读。
551
+ */
552
+ async audit() {
553
+ return auditMemoryDir(MEMORY_DIR)
554
+ }
555
+
556
+ /**
557
+ * 自动归档候选 + 当前策略与阈值(面板「执行归档」前先看清单)。
558
+ *
559
+ * 复用 `memory/access.js` 的台账与候选计算,与 autodream 引擎跑的是同一份逻辑。
560
+ * 先强制 flush 台账:面板看到的候选不能比引擎少(内存里还压着的那批注入必须算进去)。
561
+ */
562
+ async archiveCandidates() {
563
+ await flushAccess(MEMORY_DIR)
564
+ const [ledger, files] = await Promise.all([readAccessLedger(), scanMemoryFiles(this.ctx, '')])
565
+ const cfg = await autodreamConfigSnapshot()
566
+ const thresholds = {
567
+ project: cfg.archiveAfterDaysProject,
568
+ reference: cfg.archiveAfterDaysReference,
569
+ user: cfg.archiveAfterDaysUser,
570
+ }
571
+ const candidates = pickArchiveCandidates(files, ledger, { thresholds })
572
+ return { count: candidates.length, candidates, autoArchive: cfg.autoArchive, thresholds }
573
+ }
574
+
575
+ /**
576
+ * 面板上的注入设置(5 项上限 + 保留名保护名单)。
577
+ *
578
+ * ⚠️ **这 5 个常量是模块加载时读的**:面板改完要**重启 DSH** 才生效。所以这里同时给出
579
+ * 「当前生效值」`limits` 与「待生效值」`pendingLimits`,外加 `restartRequired` ——
580
+ * 界面才能诚实地说「改好了,重启后生效」,而不是让用户以为立刻生效又看不到变化。
581
+ */
582
+ async getSettings() {
583
+ const file = await readSettingsFile()
584
+ const limits = {
585
+ maxResults: MAX_RESULTS,
586
+ maxChars: MAX_CHARS_PER_FILE,
587
+ maxBaseline: MAX_BASELINE,
588
+ maxSessionBytes: MAX_SESSION_BYTES,
589
+ staleDays: STALE_DAYS,
590
+ }
591
+ const pending = pendingLimits(file)
592
+ const reservedExtra = await readReservedFile()
593
+ // 模块加载时那份「用户声明的保护名」(RESERVED_FILES 里抠掉两个内置名)
594
+ const activeExtra = [...RESERVED_FILES].filter((n) => n !== 'memory.md' && n !== 'session-log.md')
595
+ const norm = (list) => JSON.stringify([...list].map((s) => String(s).toLowerCase()).sort())
596
+ const restartRequired =
597
+ JSON.stringify(limits) !== JSON.stringify(pending) || norm(activeExtra) !== norm(reservedExtra)
598
+ return {
599
+ limits,
600
+ pendingLimits: pending,
601
+ restartRequired,
602
+ // 生效的保护名单(含两个内置名)+ 用户声明的那部分原文(面板编辑的是后者)
603
+ reserved: [...new Set(['memory.md', 'session-log.md', ...reservedExtra.map((s) => s.toLowerCase())])],
604
+ reservedExtra,
605
+ }
606
+ }
607
+
608
+ /**
609
+ * 改设置:5 项上限落 `<状态根>/settings.json`,保护名单落 `<状态根>/reserved.json`。
610
+ *
611
+ * 校验**先全部过一遍再写盘**:任何一个值非法就整体 `{ ok:false }`,绝不写半份。
612
+ * 越界不是「夹到边界」(那是环境变量的宽容语义)—— 面板上手输的数字越界是笔误,
613
+ * 静默改成别的值比报错更让人困惑。
614
+ */
615
+ async setSettings(patch) {
616
+ const p = patch && typeof patch === 'object' && !Array.isArray(patch) ? patch : {}
617
+ const limitsPatch = p.limits && typeof p.limits === 'object' && !Array.isArray(p.limits) ? p.limits : null
618
+ const nextLimits = {}
619
+ if (limitsPatch) {
620
+ for (const [key, value] of Object.entries(limitsPatch)) {
621
+ const spec = LIMIT_SPECS[key]
622
+ if (!spec) return { ok: false, error: `sage-mem: unknown setting: ${key}` }
623
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
624
+ return { ok: false, error: `sage-mem: ${key} must be a number, got ${typeof value}` }
625
+ }
626
+ const rounded = Math.round(value)
627
+ if (rounded < spec.lo || rounded > spec.hi) {
628
+ return { ok: false, error: `sage-mem: ${key} must be an integer in [${spec.lo}, ${spec.hi}], got ${value}` }
629
+ }
630
+ nextLimits[key] = rounded
631
+ }
632
+ }
633
+ let nextReserved = null
634
+ if (p.reserved !== undefined) {
635
+ if (!Array.isArray(p.reserved)) return { ok: false, error: 'sage-mem: reserved must be an array of file names' }
636
+ const cleaned = []
637
+ for (const item of p.reserved) {
638
+ const s = String(item ?? '').trim()
639
+ if (!s || !s.endsWith('.md') || s.includes('/') || s.includes('\\') || s.startsWith('..')) {
640
+ return { ok: false, error: `sage-mem: invalid reserved file name: ${String(item)}` }
641
+ }
642
+ if (!cleaned.includes(s)) cleaned.push(s)
643
+ }
644
+ nextReserved = cleaned
645
+ }
646
+ if (!limitsPatch && nextReserved === null) return { ok: false, error: 'sage-mem: empty patch' }
647
+
648
+ await mkdir(STATE_ROOT, { recursive: true })
649
+ if (limitsPatch) {
650
+ const current = await readSettingsFile()
651
+ const merged = { ...(current?.limits ?? {}), ...nextLimits }
652
+ await writeFileAtomic(SETTINGS_FILE, JSON.stringify({ limits: merged, updatedAt: Date.now() }, null, 2))
653
+ }
654
+ if (nextReserved !== null) {
655
+ await writeFileAtomic(RESERVED_FILE, `${JSON.stringify(nextReserved, null, 2)}\n`)
656
+ }
657
+ // 写盘即提示重启:这些值在模块加载时就固化了,不重启它一定还没生效。
658
+ return { ok: true, settings: await this.getSettings(), restartRequired: true }
659
+ }
660
+
661
+ /**
662
+ * 原样读保留名文件(`MEMORY.md` / `session-log.md` / 用户声明的保护名)。
663
+ *
664
+ * **不解析、不重建**:索引与流水是纯文本,面板拿到什么就显示什么。
665
+ */
666
+ async readRaw(name) {
667
+ if (!isReserved(name)) {
668
+ return { ok: false, error: `sage-mem: ${String(name)} is not a reserved file; normal memories use the memory form or readFile` }
669
+ }
670
+ const safe = rawName(name)
671
+ if (!safe) return { ok: false, error: `sage-mem: invalid file name: ${String(name)}` }
672
+ // 读不到就抛(与 readFile 一致):静默返回空串会让面板显示一份「空的索引」,
673
+ // 用户一存就把真索引顶掉。
674
+ const content = await readFile(join(MEMORY_DIR, safe), 'utf8')
675
+ return { name: safe, content }
676
+ }
677
+
678
+ /**
679
+ * 原样写保留名文件:**逐字节照写,绝不重建 frontmatter**。
680
+ *
681
+ * 这就是 2026-09-25 那次 P0 数据丢失事故(表单重建 frontmatter 把 baseline /
682
+ * node_type / originSessionId 全抹掉)的解药:索引与流水只许原样进出。
683
+ */
684
+ async writeRaw(name, content) {
685
+ if (!isReserved(name)) {
686
+ return { ok: false, error: `sage-mem: ${String(name)} is not a reserved file; normal memories use the memory form or writeFile` }
687
+ }
688
+ const safe = rawName(name)
689
+ if (!safe) return { ok: false, error: `sage-mem: invalid file name: ${String(name)}` }
690
+ if (typeof content !== 'string') {
691
+ return { ok: false, file: safe, error: `sage-mem: content must be a string, got ${typeof content}` }
692
+ }
693
+ const bytes = Buffer.byteLength(content, 'utf8')
694
+ // ⚠️ 与 writeFile 的 512 KB 上限**不是同一个数**:流水天然更大(见 MAX_RAW_BYTES 的注释)。
695
+ if (bytes > MAX_RAW_BYTES) {
696
+ return {
697
+ ok: false,
698
+ file: safe,
699
+ error: `sage-mem: content too large (${bytes} bytes > ${MAX_RAW_BYTES / (1024 * 1024)} MB raw limit)`,
700
+ }
701
+ }
702
+ await writeFileAtomic(join(MEMORY_DIR, safe), content)
703
+ return { ok: true, file: safe }
704
+ }
705
+ }
706
+
707
+ markRemote(MemoryGateway, 'listFiles', 'listFiles')
708
+ markRemote(MemoryGateway, 'readFile', 'readFile')
709
+ markRemote(MemoryGateway, 'writeFile', 'writeFile')
710
+ markRemote(MemoryGateway, 'deleteFile', 'deleteFile')
711
+ markRemote(MemoryGateway, 'archive', 'archive')
712
+ markRemote(MemoryGateway, 'restore', 'restore')
713
+ markRemote(MemoryGateway, 'listArchived', 'listArchived')
714
+ markRemote(MemoryGateway, 'readArchived', 'readArchived')
715
+ markRemote(MemoryGateway, 'writeArchived', 'writeArchived')
716
+ markRemote(MemoryGateway, 'audit', 'audit')
717
+ markRemote(MemoryGateway, 'archiveCandidates', 'archiveCandidates')
718
+ markRemote(MemoryGateway, 'getSettings', 'getSettings')
719
+ markRemote(MemoryGateway, 'setSettings', 'setSettings')
720
+ markRemote(MemoryGateway, 'readRaw', 'readRaw')
721
+ markRemote(MemoryGateway, 'writeRaw', 'writeRaw')
722
+
723
+ /**
724
+ * 记忆星图(宿主侧)—— 只读扫描记忆目录,把每条记忆解析成一颗「星」。
725
+ * 通过 TypertRemoteService 暴露两个 remote 方法给界面侧:
726
+ * - starmap.listStars(includeArchived) 全部星(元数据,不含正文)
727
+ * - starmap.readFile(n) 单条记忆全文(文件名白名单校验)
728
+ */
729
+ export class StarmapGateway extends TypertRemoteService {
730
+ constructor(ctx) { super(ctx, 'starmap') }
731
+
732
+ /**
733
+ * @param {boolean} [includeArchived] — 连归档区一起给(默认 false:与从前一样只看活着的星)
734
+ */
735
+ async listStars(includeArchived) {
736
+ const files = await scanStars(this.ctx, includeArchived === true)
737
+ const stars = files.map(f => {
738
+ const meta = parseFrontmatter(f.content)
739
+ const title = extractTitle(f.content)
740
+ const star = {
741
+ file: f.name,
742
+ kind: meta.type || 'special',
743
+ title: title || meta.name || (meta.description ? meta.description.slice(0, 24) : f.name.replace(/\.md$/, '')),
744
+ desc: meta.description || '',
745
+ bytes: f.bytes,
746
+ mtimeMs: f.mtimeMs,
747
+ archived: f.archived === true,
748
+ }
749
+ // 归档留痕只对归档星有意义;活动星连键都不出现(保持与旧形状逐字段相同)。
750
+ if (star.archived) {
751
+ const a = readArchivedMeta(f.content)
752
+ star.archivedAt = a.archivedAt
753
+ star.archivedReason = a.archivedReason
754
+ }
755
+ return star
756
+ })
757
+ return { count: stars.length, stars }
758
+ }
759
+
760
+ async readFile(name) {
761
+ // 与 MemoryGateway.readFile 收敛到同一个 safeName:它现在同时排除
762
+ // MEMORY.md 与 session-log.md,且大小写不敏感。
763
+ const safe = safeName(name)
764
+ if (!safe) throw new Error('sage-starmap: invalid file name')
765
+ const content = await readFile(join(MEMORY_DIR, safe), 'utf8')
766
+ return { name: safe, content }
767
+ }
768
+ }
769
+
770
+ markRemote(StarmapGateway, 'listStars', 'listStars')
771
+ markRemote(StarmapGateway, 'readFile', 'readFile')
772
+
773
+ /**
774
+ * autodream 的 remote 面:设置页「自动做梦」标签页唯一的数据通道。
775
+ *
776
+ * 这一层刻意很薄——全部判断(门控、模式、白名单)都在 AutodreamEngine 里,
777
+ * 这里只负责把 ctx 里的引擎接到 typert 的调用表上。想看行为就读 autodream.js。
778
+ */
779
+ export class AutodreamGateway extends TypertRemoteService {
780
+ constructor(ctx, engine) {
781
+ super(ctx, 'autodream')
782
+ this.engine = engine
783
+ }
784
+
785
+ /** 配置 + 状态 + 路径 + 默认值(设置面板一次拿全)。 */
786
+ async getConfig() {
787
+ return this.engine.getConfig()
788
+ }
789
+
790
+ /** 改配置(补丁式)。 */
791
+ async setConfig(patch) {
792
+ return this.engine.setConfig(patch)
793
+ }
794
+
795
+ /** 轮询用:是否在跑、跑到哪一步、上次结果。 */
796
+ async status() {
797
+ return this.engine.status()
798
+ }
799
+
800
+ /**
801
+ * 手动跑一趟。设置页的「立即整理」走这里。
802
+ *
803
+ * force 默认为 true —— 人点了按钮,就不该再被「还没到 24 小时」挡回来。
804
+ *
805
+ * **故意不 await**:一趟 autodream 可能跑几分钟(多轮模型调用 + 工具),
806
+ * remote 调用挂在那儿等会直接撞上前端超时,界面上表现为「点了没反应」。
807
+ * 所以这里只负责点火,进度与结果都从 status() 轮询 —— run() 自己会把
808
+ * lastResult 落盘,界面刷新几次就能看到。
809
+ */
810
+ async runNow(opts) {
811
+ if (this.engine.running) return { ok: false, error: '已有一趟 autodream 在跑' }
812
+ const reason = typeof opts?.reason === 'string' && opts.reason.trim() ? opts.reason.trim().slice(0, 120) : '设置页手动触发'
813
+ this.engine.run({ reason, force: true }).catch(() => {})
814
+ return { ok: true }
815
+ }
816
+
817
+ /** 历史报告列表。 */
818
+ async listReports() {
819
+ return this.engine.listReports()
820
+ }
821
+
822
+ /** 读一份报告全文。 */
823
+ async readReport(name) {
824
+ return this.engine.readReport(name)
825
+ }
826
+
827
+ /** 已有快照(面板显示「可回退到哪一份」)。 */
828
+ async listSnapshots() {
829
+ return this.engine.listSnapshots()
830
+ }
831
+
832
+ /** 运行记录列表(面板的「整理声明」区)。 */
833
+ async listRuns(opts) {
834
+ return this.engine.listRuns(opts?.limit)
835
+ }
836
+
837
+ /** 读某次运行的完整整理声明(人读 markdown + 机读 manifest)。 */
838
+ async readDeclaration(opts) {
839
+ return this.engine.readDeclaration(opts?.runId)
840
+ }
841
+
842
+ /**
843
+ * 回滚到某个回滚点。
844
+ *
845
+ * 与 runNow 相反,这里**必须 await**:回滚是几十毫秒级的文件操作,
846
+ * 而「点完按钮不知道到底滚没滚」正是回滚这种动作最不能接受的体验。
847
+ */
848
+ async rollback(opts) {
849
+ return this.engine.rollback({ snapshotId: opts?.snapshotId, scope: opts?.scope })
850
+ }
851
+
852
+ /** 可选的整理模型路线(设置面板的模型下拉)。 */
853
+ async listModels() {
854
+ return this.engine.listModels()
855
+ }
856
+ }
857
+
858
+ markRemote(AutodreamGateway, 'getConfig', 'getConfig')
859
+ markRemote(AutodreamGateway, 'setConfig', 'setConfig')
860
+ markRemote(AutodreamGateway, 'status', 'status')
861
+ markRemote(AutodreamGateway, 'runNow', 'runNow')
862
+ markRemote(AutodreamGateway, 'listReports', 'listReports')
863
+ markRemote(AutodreamGateway, 'readReport', 'readReport')
864
+ markRemote(AutodreamGateway, 'listSnapshots', 'listSnapshots')
865
+ markRemote(AutodreamGateway, 'listRuns', 'listRuns')
866
+ markRemote(AutodreamGateway, 'readDeclaration', 'readDeclaration')
867
+ markRemote(AutodreamGateway, 'rollback', 'rollback')
868
+ markRemote(AutodreamGateway, 'listModels', 'listModels')
869
+
870
+ export default MemoryGateway
871
+
872
+ /**
873
+ * **仅供测试**的只读出口。
874
+ *
875
+ * 检索这条链(frontmatter 解析 → 双字打分 → 名额裁剪)没有任何对外可见的调用面:
876
+ * 想验证「写了 aliases 之后换个说法能不能命中」,否则只能起一个真的 DSH 端到端跑一遍。
877
+ * 把这些纯函数露出来是为了能写断言,**不是给外部用的 API**,别依赖。
878
+ */
879
+ export const __testables = {
880
+ bigrams,
881
+ score,
882
+ parseFrontmatter,
883
+ parseAliases,
884
+ parseBaselinePriority,
885
+ parseBaseline,
886
+ parseTags,
887
+ ageDays,
888
+ selectRelevant,
889
+ selectBaseline,
890
+ limits: { MAX_RESULTS, MAX_CHARS_PER_FILE, MAX_BASELINE, MAX_SESSION_BYTES, STALE_DAYS },
891
+ recallDiscipline: RECALL_DISCIPLINE,
892
+ }
893
+