sage-mem 0.6.3 → 0.7.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
@@ -6,7 +6,7 @@
6
6
  * 1. 按问题检索注入:system-prompt/assemble 时扫 memory 目录,按双字匹配
7
7
  * 选相关文件,读全文注入 system prompt,让 agent 第一轮就「想起」
8
8
  * 2. TypertRemoteService:给设置页「记忆管理」提供文件列表/读/写/删
9
- * 3. dream(做梦):可选的记忆整理,见 lib/dream.js
9
+ * 3. autodream(自动做梦):可选的记忆整理,见 lib/autodream.js
10
10
  *
11
11
  * 写入靠 AGENTS.md 的「记忆使用」规则引导 agent 自己判断 + 用文件工具写
12
12
  * memory 目录——透明、可检查、防膨胀。无 worker、无 SQLite、无端口。
@@ -17,54 +17,94 @@
17
17
  import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
18
18
  import { homedir } from 'node:os'
19
19
  import { readFile, readdir, writeFile, rename, unlink, stat } from 'node:fs/promises'
20
- import { join, basename } from 'node:path'
21
- import { DreamEngine } from './dream.js'
20
+ import { readFileSync } from 'node:fs'
21
+ import { join, basename, dirname } from 'node:path'
22
+ import { AutodreamEngine } from './autodream.js'
22
23
 
23
24
  const MEMORY_DIR = process.env.SAGE_MEM_DIR || join(homedir(), '.sage-mem', 'memory')
24
25
  /**
25
- * DSH 会话记录根目录。dream 的门控靠它数「上次整理之后有几个会话更新」,
26
+ * DSH 会话记录根目录。autodream 的门控靠它数「上次整理之后有几个会话更新」,
26
27
  * `memory+sessions` 输入源下也会在里面做定向搜索。取不到就是空串,
27
28
  * 门控会退化成「会话数 0 → 不触发」,而不是报错。
28
29
  */
29
30
  const SESSIONS_ROOT = process.env.DSH_HOME ? join(process.env.DSH_HOME, 'sessions') : ''
30
- const MAX_RESULTS = 5
31
- const MAX_CHARS_PER_FILE = 1500
32
- const MAX_BASELINE = 5
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)
33
62
 
34
63
  /**
35
- * 保留名:设置页的写/删方法一律不得触碰。
64
+ * 结构性保留名:设置页的写/删方法一律不得触碰。
36
65
  *
37
- * 列进来的两类文件:
38
- * 1. MEMORY.md / session-log.md —— 不是记忆条目:不进检索、不进星图、不出现在
39
- * 设置页列表里,它们是索引与追加型流水(session-log.md 已 130+ KB)。
40
- * 「+ 添加记忆」表单里手打这两个名字就能整份覆盖,而这个表单是照
41
- * buildFrontmatter() 重建 frontmatter 的,原有内容一个字都留不下。
42
- * 2. project_heartscape.md / project_self-cognition.md —— 人格连续性的手写载体
43
- * (AGENTS.md 指定的落盘目标),单副本、纯人工维护;表单写还会把
44
- * frontmatter 里的 originSessionId / birthday 等字段一并抹掉。
66
+ * 只列**结构决定必须保护**的两个:
67
+ * 1. `MEMORY.md` —— 索引。它被整份覆盖就等于索引没了(「+ 添加记忆」表单是照
68
+ * buildFrontmatter() 重建 frontmatter 的,原有内容一个字都留不下)。
69
+ * 2. `session-log.md` —— 追加型流水,体量最大,同样经不起一份重建。
45
70
  *
46
- * 判定依据是「丢了能不能从别处重建」:memory 目录**不在任何版本控制下**
47
- * (该目录里没有 .git,`git rev-parse` 直接报 not a git repository),
48
- * deleteFile 是 unlink、没有回收站,所以宁严勿松。
49
- * 这几个文件要改,用文件工具直接编辑。
71
+ * ⚠️ **除了这两个,别把任何具体记忆的文件名写死在这里。**
72
+ * 「哪几条记忆特别要紧」是**使用者的私事**——把它编进随包发布的代码,等于把作者的
73
+ * 记忆主题发给每一个装这个插件的人。
74
+ * 用户自己的保护名单放在 `<状态根>/reserved.json`(JSON 字符串数组),见下。
50
75
  *
51
76
  * ⚠️ 全部小写存放,比较一律走 nameKey()(NTFS 不区分大小写,集合里放 'MEMORY.md'
52
77
  * 而拿 'memory.md' 去 has() 是查不到的 —— 曾经就是这个漏洞:表单里填 memory.md
53
- * 就能整份覆盖 19 KB 的索引,deleteFile 还能不可逆删掉它)。
78
+ * 就能整份覆盖索引,deleteFile 还能不可逆删掉它)。
54
79
  */
55
- const RESERVED_FILES = new Set([
56
- 'memory.md',
57
- 'session-log.md',
58
- 'project_heartscape.md',
59
- 'project_self-cognition.md',
60
- ])
80
+ const STATE_ROOT = process.env.SAGE_MEM_STATE_DIR || join(dirname(MEMORY_DIR), '.sage-mem')
81
+
82
+ /**
83
+ * 读使用者自己声明的额外保护名单:`<状态根>/reserved.json`,形如 `["a.md","b.md"]`。
84
+ *
85
+ * 读不到、JSON 坏、不是数组 —— 一律当空数组返回。**一份可选的保护名单,
86
+ * 绝不该有能力让插件加载失败。**
87
+ */
88
+ function readExtraReserved() {
89
+ try {
90
+ const arr = JSON.parse(readFileSync(join(STATE_ROOT, 'reserved.json'), 'utf8'))
91
+ if (!Array.isArray(arr)) return []
92
+ return arr
93
+ .map((s) => String(s).trim().toLowerCase())
94
+ .filter((s) => s.endsWith('.md') && !s.includes('/') && !s.includes('\\') && !s.startsWith('..'))
95
+ } catch {
96
+ return []
97
+ }
98
+ }
99
+
100
+ const RESERVED_FILES = new Set(['memory.md', 'session-log.md', ...readExtraReserved()])
61
101
 
62
102
  /**
63
103
  * 非记忆条目:不参与检索 / 不进星图 / 不出现在设置页列表里,读写也一律不通。
64
104
  *
65
105
  * 是 RESERVED_FILES 的子集,区别在「列不列出来」:MEMORY.md 是索引、
66
- * session-log.md 是追加型流水(已 390+ KB),两者都不是一条记忆;
67
- * 而 project_heartscape.md / project_self-cognition.md 是正常记忆条目
106
+ * session-log.md 是追加型流水,两者都不是一条记忆;
107
+ * 而使用者手写维护的重要记忆是正常记忆条目(它们该被检索到)。
68
108
  * (要出现在列表与星图里、要能读),只是不许从表单写/删。
69
109
  */
70
110
  const NON_ENTRY_FILES = new Set([
@@ -74,7 +114,7 @@ const NON_ENTRY_FILES = new Set([
74
114
 
75
115
  /**
76
116
  * 单文件体积上限(字节)。定 512 KB 的理由:
77
- * - 当前 memory 目录最大的一条正文 19 KB(project_guikit.md),索引与流水都排除在外,
117
+ * - 单条正文通常在几十 KB 以内,索引与流水都排除在外,
78
118
  * 512 KB 是它的 26 倍,任何正常记忆都够用;
79
119
  * - 记忆是要塞进 system prompt 的,一条 512 KB 的记忆本身就等于把上下文撑爆——
80
120
  * 到这个量级基本可以判定是写错了目标(比如把日志、代码贴进来)。
@@ -139,7 +179,9 @@ async function scanMemoryFiles(ctx, sessionId) {
139
179
  file: name,
140
180
  content,
141
181
  description: meta.description || '',
182
+ aliases: meta.aliases || [],
142
183
  baseline: parseBaseline(content),
184
+ baselinePriority: meta.baselinePriority || 0,
143
185
  }
144
186
  } catch (err) {
145
187
  // 只有 ENOENT 是正常状态(扫到读之间被删)。其余——权限错、编码坏、盘掉线——
@@ -159,16 +201,17 @@ async function scanMemoryFiles(ctx, sessionId) {
159
201
  * 相关性打分只用 frontmatter 的 description + 文件名,不扫全文。
160
202
  *
161
203
  * 旧版拿 query 的双字去全文里找,得分是「覆盖率」,分母是 query,不惩罚
162
- * 文件长度 —— 越长的记忆越容易蒙中。实测 8 个典型问题:project_heartscape
163
- * (5.9KB) 与 project_self-cognition (6.8KB) 命中 7 个,连「今天天气不错」都
164
- * 注入 2300 token;而「论文写得怎么样了」该命中的 project_position-paper
165
- * 反被挤掉。description 是「一句话说清这条是什么」的精准摘要,用它当检索
204
+ * 文件长度 —— 越长的记忆越容易蒙中。实测:长记忆在全文匹配下对任何问题都能
205
+ * 蒙中足量双字组,连寒暄问句都能注入上千 token;而真正该命中的那条反被
206
+ * 挤掉。description 是「一句话说清这条是什么」的精准摘要,用它当检索
166
207
  * 信号,噪音大幅下降。
167
208
  */
168
209
  function selectRelevant(query, files) {
169
210
  return files
170
211
  .map(f => {
171
- const hay = f.description + ' ' + f.file.replace(/\.md$/, '').replace(/[_-]/g, ' ')
212
+ // aliases 一并进检索面:它是专门为「换个说法也能命中」准备的字段。
213
+ const aliasText = Array.isArray(f.aliases) && f.aliases.length ? ' ' + f.aliases.join(' ') : ''
214
+ const hay = f.description + aliasText + ' ' + f.file.replace(/\.md$/, '').replace(/[_-]/g, ' ')
172
215
  return { ...f, score: score(query, hay) }
173
216
  })
174
217
  .filter(f => f.score > 0)
@@ -176,6 +219,19 @@ function selectRelevant(query, files) {
176
219
  .slice(0, MAX_RESULTS)
177
220
  }
178
221
 
222
+ /**
223
+ * 选 baseline 条目:按显式 `baselinePriority` 排,不按文件名。
224
+ *
225
+ * 文件名排序等于让 `feedback_*` 无条件压过 `user_*`,与「哪条更该出现在每场
226
+ * 会话开头」毫无关系。没写这个字段的老条目一律算 0,行为与从前一致。
227
+ */
228
+ function selectBaseline(files) {
229
+ return files
230
+ .filter(f => f.baseline)
231
+ .sort((a, b) => (b.baselinePriority || 0) - (a.baselinePriority || 0) || a.file.localeCompare(b.file))
232
+ .slice(0, MAX_BASELINE)
233
+ }
234
+
179
235
  /**
180
236
  * 解析 frontmatter 的 `tags`(自定义标签,用于列表页筛选)。
181
237
  *
@@ -208,9 +264,9 @@ function parseFrontmatter(content) {
208
264
  // 换行必须容忍 CRLF:任何用 Windows 编辑器碰过一次的文件都会被换成 \r\n,
209
265
  // 而旧版这里写的是 /^---\n/ —— 匹配失败 → description/type 全空 →
210
266
  // 「baseline 走 parseBaseline 那条带 \r?\n 的正则照旧命中,但这条记忆永远检索不到」,
211
- // 症状极具欺骗性。:123 的 parseBaseline 与 :277 的 extractTitle 早已是 \r?\n。
267
+ // 症状极具欺骗性。下文定义的 parseBaseline 与 extractTitle 早已是 \r?\n。
212
268
  const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)
213
- if (!m) return { name: '', description: '', type: '', tags: [] }
269
+ if (!m) return { name: '', description: '', type: '', tags: [], aliases: [], baselinePriority: 0 }
214
270
  const body = m[1]
215
271
  const grab = (key) => {
216
272
  // [ \t] / [ \t\r] 而不是 \s:\s 会连 CRLF 的 \r 一起吃进捕获组,值尾留一个 \r
@@ -224,7 +280,52 @@ function parseFrontmatter(content) {
224
280
  const nested = body.match(/^metadata:\s*\r?\n(?:\s+[^\r\n]+\r?\n)*?\s+type:\s*(\S+)/m)
225
281
  if (nested) type = nested[1]
226
282
  }
227
- return { name: grab('name'), description: grab('description'), type, tags: parseTags(body) }
283
+ return {
284
+ name: grab('name'),
285
+ description: grab('description'),
286
+ type,
287
+ tags: parseTags(body),
288
+ aliases: parseAliases(body),
289
+ baselinePriority: parseBaselinePriority(body),
290
+ }
291
+ }
292
+
293
+ /**
294
+ * 解析 frontmatter 的 `aliases`(别称 / 同义词 / 我可能用的另一种叫法)。
295
+ *
296
+ * 为什么要有它:检索是**词面重合**的,不是语义的——问「我有什么待办」而文件里
297
+ * 写的是「代办清单」,差一个字就完全不命中。`description` 回答「我会怎么问它」,
298
+ * `aliases` 补上「我还可能怎么问」。写法与 tags 完全一致(flow / block / 逗号串三种都收),
299
+ * 免得手写时「时灵时不灵」。
300
+ */
301
+ function parseAliases(body) {
302
+ const clean = (arr) =>
303
+ arr
304
+ .map((s) => String(s).trim().replace(/^["']|["']$/g, '').trim())
305
+ .filter(Boolean)
306
+ .slice(0, 24)
307
+ .map((s) => s.slice(0, 60))
308
+ const flow = body.match(/^aliases:[ \t]*\[([^\]]*)\]/m)
309
+ if (flow) return clean(flow[1].split(','))
310
+ const block = body.match(/^aliases:[ \t]*\r?\n((?:[ \t]+-[^\r\n]*\r?\n?)+)/m)
311
+ if (block) return clean(block[1].split(/\r?\n/).map((l) => l.replace(/^[ \t]*-[ \t]*/, '')))
312
+ const inline = body.match(/^aliases:[ \t]*(.+?)[ \t\r]*$/m)
313
+ if (inline && inline[1].trim()) return clean(inline[1].split(','))
314
+ return []
315
+ }
316
+
317
+ /**
318
+ * 解析 frontmatter 的 `baselinePriority`(数字,越大越先注入)。
319
+ *
320
+ * 旧版 baseline 是 `MAX_BASELINE = 5` + **按文件名排序**——`feedback_*` 永远排在
321
+ * `user_*` 前面,与「哪条真的更该在每场会话开头出现」毫无关系。缺失当 0,
322
+ * 于是没写这个字段的老条目行为不变。
323
+ */
324
+ function parseBaselinePriority(body) {
325
+ const hit = body.match(/^baselinePriority:[ \t]*(.+?)[ \t\r]*$/m)
326
+ if (!hit) return 0
327
+ const n = Number(hit[1].replace(/^["']|["']$/g, '').trim())
328
+ return Number.isFinite(n) ? n : 0
228
329
  }
229
330
 
230
331
  /** 解析 frontmatter 的 `baseline` 标记:会话第一回合无条件注入。 */
@@ -286,7 +387,7 @@ function isMemoryEntry(name) {
286
387
  *
287
388
  * MemoryGateway.readFile / StarmapGateway.readFile 都走这里,不再各写一套
288
389
  * —— 旧版 starmap 那套内联校验只排除了 'MEMORY.md'(大小写敏感)且**完全不排除
289
- * session-log.md**,于是 `starmap.readFile('session-log.md')` 能把 390+ KB 的流水
390
+ * session-log.md**,于是 `starmap.readFile('session-log.md')` 能把整份流水
290
391
  * 整个拉进浏览器。
291
392
  *
292
393
  * 不复用 nameKey 的原因:这里要保留磁盘上的原始大小写(返回值直接 join 进路径)。
@@ -325,22 +426,26 @@ export class MemoryGateway extends TypertRemoteService {
325
426
  // 一并挂载星图 remote(同一记忆目录,纯只读)—— starmap.* 服务
326
427
  new StarmapGateway(ctx)
327
428
 
328
- // dream(夜间记忆整理):引擎 + remote 面 + 自动触发的定时器。
429
+ // autodream(夜间记忆整理):引擎 + remote 面 + 自动触发的定时器。
329
430
  //
330
- // 整个包在 try 里:dream 起不来(比如 timer 服务缺失、状态目录没权限)
431
+ // 整个包在 try 里:autodream 起不来(比如 timer 服务缺失、状态目录没权限)
331
432
  // 不该把 sage-mem 最核心的那件事——「记忆按问题注入」——一起拖死。
332
433
  // 这是本插件唯一一处「降级而不是失败」的设计,理由就在这里。
333
434
  try {
334
- const engine = new DreamEngine(ctx, { memoryDir: MEMORY_DIR, sessionsRoot: SESSIONS_ROOT })
335
- new DreamGateway(ctx, engine)
435
+ const engine = new AutodreamEngine(ctx, {
436
+ memoryDir: MEMORY_DIR,
437
+ sessionsRoot: SESSIONS_ROOT,
438
+ reservedFiles: [...RESERVED_FILES],
439
+ })
440
+ new AutodreamGateway(ctx, engine)
336
441
  engine.start()
337
442
  // 卸载时把定时器与锁收干净。effect 拿不到就交给 unref 兜底(不阻止进程退出)。
338
443
  if (typeof ctx.effect === 'function') ctx.effect(() => () => engine.dispose())
339
444
  } catch (err) {
340
- warnLog(ctx, `dream 初始化失败(记忆本体不受影响):${err?.message ?? err}`)
445
+ warnLog(ctx, `autodream 初始化失败(记忆本体不受影响):${err?.message ?? err}`)
341
446
  // 异常路径额外打一行到 stderr:warnLog 走 ctx.logger,某些启动阶段它还没接上,
342
- // 而「dream 静默不工作」正是最难查的那种故障(2026-09-27 为此绕了一大圈)。
343
- console.error('[sage-mem] dream init failed:', err && err.stack ? err.stack : err)
447
+ // 而「autodream 静默不工作」正是最难查的那种故障(2026-09-27 为此绕了一大圈)。
448
+ console.error('[sage-mem] autodream init failed:', err && err.stack ? err.stack : err)
344
449
  }
345
450
 
346
451
  // 同一轮对话里问题不变、注入内容也不变,但 DSH 每一步都会往 session 追加
@@ -398,7 +503,7 @@ export class MemoryGateway extends TypertRemoteService {
398
503
  // baseline:每个会话注入一次,**不看消息条数**。
399
504
  //
400
505
  // 旧写法 `if (userMessages.length === 0)` 依赖一个真实存在的时序:assemble
401
- // 触发时当前这条用户消息还没写进 session。已在 DSH 检出里核实为真 ——
506
+ // 触发时当前这条用户消息还没写进 session。已在 DSH 0.1.7-rc.1 上核实为真 ——
402
507
  // dsh-agent-loop/lib/index.js:906 const claimed = this.inbox.claim(...) // 只从 pending 队列取走
403
508
  // dsh-agent-loop/lib/index.js:907 await this.loopCtx.systemPrompt.assemble(...) // ← 本监听器在这里跑
404
509
  // dsh-agent-loop/lib/index.js:1046 this.session.append("user/message", ...) // 落 session 在 preStep 之后
@@ -410,10 +515,7 @@ export class MemoryGateway extends TypertRemoteService {
410
515
  let baselineTaken = 0
411
516
  const baselineKey = session.id ? session.id : session
412
517
  if (!baselineInjected.has(baselineKey)) {
413
- files
414
- .filter(f => f.baseline)
415
- .sort((a, b) => a.file.localeCompare(b.file))
416
- .slice(0, MAX_BASELINE)
518
+ selectBaseline(files)
417
519
  .forEach(f => {
418
520
  const before = picked.length
419
521
  take(f)
@@ -610,14 +712,14 @@ markRemote(StarmapGateway, 'listStars', 'listStars')
610
712
  markRemote(StarmapGateway, 'readFile', 'readFile')
611
713
 
612
714
  /**
613
- * dream 的 remote 面:设置页「做梦」标签页唯一的数据通道。
715
+ * autodream 的 remote 面:设置页「自动做梦」标签页唯一的数据通道。
614
716
  *
615
- * 这一层刻意很薄——全部判断(门控、模式、白名单)都在 DreamEngine 里,
616
- * 这里只负责把 ctx 里的引擎接到 typert 的调用表上。想看行为就读 dream.js。
717
+ * 这一层刻意很薄——全部判断(门控、模式、白名单)都在 AutodreamEngine 里,
718
+ * 这里只负责把 ctx 里的引擎接到 typert 的调用表上。想看行为就读 autodream.js。
617
719
  */
618
- export class DreamGateway extends TypertRemoteService {
720
+ export class AutodreamGateway extends TypertRemoteService {
619
721
  constructor(ctx, engine) {
620
- super(ctx, 'dream')
722
+ super(ctx, 'autodream')
621
723
  this.engine = engine
622
724
  }
623
725
 
@@ -641,14 +743,15 @@ export class DreamGateway extends TypertRemoteService {
641
743
  *
642
744
  * force 默认为 true —— 人点了按钮,就不该再被「还没到 24 小时」挡回来。
643
745
  *
644
- * **故意不 await**:一趟 dream 可能跑几分钟(多轮模型调用 + 工具),
746
+ * **故意不 await**:一趟 autodream 可能跑几分钟(多轮模型调用 + 工具),
645
747
  * remote 调用挂在那儿等会直接撞上前端超时,界面上表现为「点了没反应」。
646
748
  * 所以这里只负责点火,进度与结果都从 status() 轮询 —— run() 自己会把
647
749
  * lastResult 落盘,界面刷新几次就能看到。
648
750
  */
649
- async runNow(force) {
650
- if (this.engine.running) return { ok: false, error: '已有一趟 dream 在跑' }
651
- this.engine.run({ reason: '设置页手动触发', force: force !== false }).catch(() => {})
751
+ async runNow(opts) {
752
+ if (this.engine.running) return { ok: false, error: '已有一趟 autodream 在跑' }
753
+ const reason = typeof opts?.reason === 'string' && opts.reason.trim() ? opts.reason.trim().slice(0, 120) : '设置页手动触发'
754
+ this.engine.run({ reason, force: true }).catch(() => {})
652
755
  return { ok: true }
653
756
  }
654
757
 
@@ -666,14 +769,63 @@ export class DreamGateway extends TypertRemoteService {
666
769
  async listSnapshots() {
667
770
  return this.engine.listSnapshots()
668
771
  }
772
+
773
+ /** 运行记录列表(面板的「整理声明」区)。 */
774
+ async listRuns(opts) {
775
+ return this.engine.listRuns(opts?.limit)
776
+ }
777
+
778
+ /** 读某次运行的完整整理声明(人读 markdown + 机读 manifest)。 */
779
+ async readDeclaration(opts) {
780
+ return this.engine.readDeclaration(opts?.runId)
781
+ }
782
+
783
+ /**
784
+ * 回滚到某个回滚点。
785
+ *
786
+ * 与 runNow 相反,这里**必须 await**:回滚是几十毫秒级的文件操作,
787
+ * 而「点完按钮不知道到底滚没滚」正是回滚这种动作最不能接受的体验。
788
+ */
789
+ async rollback(opts) {
790
+ return this.engine.rollback({ snapshotId: opts?.snapshotId, scope: opts?.scope })
791
+ }
792
+
793
+ /** 可选的整理模型路线(设置面板的模型下拉)。 */
794
+ async listModels() {
795
+ return this.engine.listModels()
796
+ }
669
797
  }
670
798
 
671
- markRemote(DreamGateway, 'getConfig', 'getConfig')
672
- markRemote(DreamGateway, 'setConfig', 'setConfig')
673
- markRemote(DreamGateway, 'status', 'status')
674
- markRemote(DreamGateway, 'runNow', 'runNow')
675
- markRemote(DreamGateway, 'listReports', 'listReports')
676
- markRemote(DreamGateway, 'readReport', 'readReport')
677
- markRemote(DreamGateway, 'listSnapshots', 'listSnapshots')
799
+ markRemote(AutodreamGateway, 'getConfig', 'getConfig')
800
+ markRemote(AutodreamGateway, 'setConfig', 'setConfig')
801
+ markRemote(AutodreamGateway, 'status', 'status')
802
+ markRemote(AutodreamGateway, 'runNow', 'runNow')
803
+ markRemote(AutodreamGateway, 'listReports', 'listReports')
804
+ markRemote(AutodreamGateway, 'readReport', 'readReport')
805
+ markRemote(AutodreamGateway, 'listSnapshots', 'listSnapshots')
806
+ markRemote(AutodreamGateway, 'listRuns', 'listRuns')
807
+ markRemote(AutodreamGateway, 'readDeclaration', 'readDeclaration')
808
+ markRemote(AutodreamGateway, 'rollback', 'rollback')
809
+ markRemote(AutodreamGateway, 'listModels', 'listModels')
678
810
 
679
811
  export default MemoryGateway
812
+
813
+ /**
814
+ * **仅供测试**的只读出口。
815
+ *
816
+ * 检索这条链(frontmatter 解析 → 双字打分 → 名额裁剪)没有任何对外可见的调用面:
817
+ * 想验证「写了 aliases 之后换个说法能不能命中」,否则只能起一个真的 DSH 端到端跑一遍。
818
+ * 把这些纯函数露出来是为了能写断言,**不是给外部用的 API**,别依赖。
819
+ */
820
+ export const __testables = {
821
+ bigrams,
822
+ score,
823
+ parseFrontmatter,
824
+ parseAliases,
825
+ parseBaselinePriority,
826
+ parseBaseline,
827
+ parseTags,
828
+ selectRelevant,
829
+ selectBaseline,
830
+ limits: { MAX_RESULTS, MAX_CHARS_PER_FILE, MAX_BASELINE },
831
+ }