sage-mem 0.7.2 → 0.8.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 +202 -202
- package/NOTICE +8 -8
- package/README.md +284 -284
- package/cordis.patch.yml +4 -4
- package/lib/autodream/config.js +70 -0
- package/lib/autodream/engine.js +447 -0
- package/lib/autodream/gate.js +78 -0
- package/lib/autodream/llm-loop.js +248 -0
- package/lib/autodream/report.js +256 -0
- package/lib/autodream/rollback.js +221 -0
- package/lib/autodream/settings.js +287 -0
- package/lib/autodream/snapshot.js +142 -0
- package/lib/autodream/util.js +112 -0
- package/lib/autodream-audit.js +229 -229
- package/lib/autodream-prompt.js +129 -129
- package/lib/autodream-tools.js +498 -498
- package/lib/autodream.js +8 -1722
- package/lib/client.js +2856 -2854
- package/lib/index.js +468 -951
- package/lib/memory/config.js +151 -0
- package/lib/memory/frontmatter.js +117 -0
- package/lib/memory/naming.js +54 -0
- package/lib/memory/retrieval.js +86 -0
- package/lib/memory/scan.js +81 -0
- package/lib/memory/util.js +37 -0
- package/lib/typert.host.js +461 -461
- package/package.json +5 -2
package/lib/index.js
CHANGED
|
@@ -1,951 +1,468 @@
|
|
|
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 {
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
}
|
|
231
|
-
} catch (err) {
|
|
232
|
-
//
|
|
233
|
-
//
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
const
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
*
|
|
350
|
-
*
|
|
351
|
-
*
|
|
352
|
-
*
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
/**
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
/**
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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 { MAX_BASELINE, MAX_CHARS_PER_FILE, MAX_FILE_BYTES, MAX_RESULTS, MAX_SESSION_BYTES, MEMORY_DIR, RECALL_DISCIPLINE, RESERVED_FILES, SESSIONS_ROOT, STALE_DAYS } from './memory/config.js'
|
|
19
|
+
import { parseAliases, parseBaseline, parseBaselinePriority, parseFrontmatter, parseTags } from './memory/frontmatter.js'
|
|
20
|
+
import { isReserved, nameKey, safeName } from './memory/naming.js'
|
|
21
|
+
import { ageDays, bigrams, extractText, score, selectBaseline, selectRelevant } from './memory/retrieval.js'
|
|
22
|
+
import { extractTitle, scanMemoryFiles, scanStars } from './memory/scan.js'
|
|
23
|
+
import { warnLog, writeFileAtomic } from './memory/util.js'
|
|
24
|
+
import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
|
|
25
|
+
import { readFile, unlink, writeFile } from 'node:fs/promises'
|
|
26
|
+
import { join } from 'node:path'
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 手动 @Remote marker 注册(Node 24 不解析装饰器语法,手写 decorator context)。
|
|
30
|
+
*/
|
|
31
|
+
function markRemote(cls, method, exportName) {
|
|
32
|
+
const instance = Object.create(cls.prototype)
|
|
33
|
+
Remote(exportName)(undefined, {
|
|
34
|
+
kind: 'method',
|
|
35
|
+
name: method,
|
|
36
|
+
private: false,
|
|
37
|
+
static: false,
|
|
38
|
+
addInitializer(fn) {
|
|
39
|
+
fn.call(instance)
|
|
40
|
+
},
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export class MemoryGateway extends TypertRemoteService {
|
|
45
|
+
constructor(ctx) {
|
|
46
|
+
super(ctx, 'memory')
|
|
47
|
+
// 一并挂载星图 remote(同一记忆目录,纯只读)—— starmap.* 服务
|
|
48
|
+
new StarmapGateway(ctx)
|
|
49
|
+
|
|
50
|
+
// autodream(夜间记忆整理):引擎 + remote 面 + 自动触发的定时器。
|
|
51
|
+
//
|
|
52
|
+
// 整个包在 try 里:autodream 起不来(比如 timer 服务缺失、状态目录没权限)
|
|
53
|
+
// 不该把 sage-mem 最核心的那件事——「记忆按问题注入」——一起拖死。
|
|
54
|
+
// 这是本插件唯一一处「降级而不是失败」的设计,理由就在这里。
|
|
55
|
+
try {
|
|
56
|
+
const engine = new AutodreamEngine(ctx, {
|
|
57
|
+
memoryDir: MEMORY_DIR,
|
|
58
|
+
sessionsRoot: SESSIONS_ROOT,
|
|
59
|
+
reservedFiles: [...RESERVED_FILES],
|
|
60
|
+
})
|
|
61
|
+
new AutodreamGateway(ctx, engine)
|
|
62
|
+
engine.start()
|
|
63
|
+
// 卸载时把定时器与锁收干净。effect 拿不到就交给 unref 兜底(不阻止进程退出)。
|
|
64
|
+
if (typeof ctx.effect === 'function') ctx.effect(() => () => engine.dispose())
|
|
65
|
+
} catch (err) {
|
|
66
|
+
warnLog(ctx, `autodream 初始化失败(记忆本体不受影响):${err?.message ?? err}`)
|
|
67
|
+
// 异常路径额外打一行到 stderr:warnLog 走 ctx.logger,某些启动阶段它还没接上,
|
|
68
|
+
// 而「autodream 静默不工作」正是最难查的那种故障(曾为此排查很久)。
|
|
69
|
+
console.error('[sage-mem] autodream init failed:', err && err.stack ? err.stack : err)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 同一轮对话里问题不变、注入内容也不变,但 DSH 每一步都会往 session 追加
|
|
73
|
+
// 一条 context 消息——不去重的话同一份记忆会被反复追加,撑爆上下文。
|
|
74
|
+
const recallCache = new Map()
|
|
75
|
+
|
|
76
|
+
// 「本会话是否已注入过 baseline」——会话级状态,按 session id 记。
|
|
77
|
+
//
|
|
78
|
+
// 为什么不是消息条数:见下面 baseline 那段注释(恢复会话的历史消息早就在
|
|
79
|
+
// session 里,条数永不为 0)。会话 id 在整场会话里稳定,与 assemble 时
|
|
80
|
+
// 当前用户消息有没有落盘完全无关,所以新会话、恢复会话两种时序都成立。
|
|
81
|
+
// 容量 64 与 recallCache 同理,只防长驻进程无限增长:要挤掉一个 id 得先有
|
|
82
|
+
// 63 场别的会话开起来,那场老会话再被唤醒时最多是把同样的 baseline 再注入一次。
|
|
83
|
+
const BASELINE_CACHE_MAX = 64
|
|
84
|
+
const baselineInjected = new Set()
|
|
85
|
+
|
|
86
|
+
// 「本会话已经注入掉多少字节」—— 会话级账本,按 session id 记。
|
|
87
|
+
//
|
|
88
|
+
// 为什么不扫会话记录去算:同一轮 assemble 出来的内容会被宿主合进**同一条**
|
|
89
|
+
// user/message(运行时上下文、工作区指令、插件注入共用一条),按消息字节统计
|
|
90
|
+
// 会把别人的内容算进本插件的账;按文本找标记又太脆。自己记账是确定性最高的。
|
|
91
|
+
//
|
|
92
|
+
// 为什么要连 messageCount 一起记:压缩 / 清空之后上下文里的记忆已经没了,而
|
|
93
|
+
// session id 不变 —— 只按 id 记账会让「预算用满」变成永久状态,压缩之后再也
|
|
94
|
+
// 注不进来。消息条数变小是压缩发生过的最直接信号,用它把账本归零。
|
|
95
|
+
const INJECT_LEDGER_MAX = 64
|
|
96
|
+
const injectLedger = new Map()
|
|
97
|
+
|
|
98
|
+
// 按问题检索注入:system-prompt/assemble 是异步 waterfall,监听器可 await。
|
|
99
|
+
// 从 agent.session 拿当前 user message,扫 memory 目录按摘要匹配选相关
|
|
100
|
+
// 文件,读全文作为动态 context 段注入——让 agent 第一轮就「想起」。
|
|
101
|
+
ctx.on('system-prompt/assemble', async (assembly, context, next) => {
|
|
102
|
+
const assembled = await next()
|
|
103
|
+
// session id 在 try 外先取好:catch 里也要能带上它(拿到什么带什么)。
|
|
104
|
+
const sessionId = context?.agent?.session?.id ?? ''
|
|
105
|
+
try {
|
|
106
|
+
const agent = context?.agent
|
|
107
|
+
const session = agent?.session
|
|
108
|
+
if (!session) return assembled
|
|
109
|
+
// DSH 0.1.7 起 Session 的历史读取接口在陆续调整(snapshotEvents / eventAt /
|
|
110
|
+
// ownEvents 已弃用)。deriveMessages 在 0.1.7-rc.1 实测仍在且仍同步,但这三行
|
|
111
|
+
// 不是白写的:方法一旦被改名或改成异步,老写法要么静默不注入、要么把 Promise
|
|
112
|
+
// 当数组展开成空。宁可日志里响一声,也不要"记忆不注入但日志一字没有"。
|
|
113
|
+
if (typeof session.deriveMessages !== 'function') {
|
|
114
|
+
warnLog(ctx, 'session.deriveMessages() 不可用 —— 记忆召回已跳过(DSH session API 变了?)', sessionId)
|
|
115
|
+
return assembled
|
|
116
|
+
}
|
|
117
|
+
const derived = session.deriveMessages()
|
|
118
|
+
const messages = [...(derived && typeof derived.then === 'function' ? await derived : derived)]
|
|
119
|
+
|
|
120
|
+
// 关键:DSH 0.1.5 把工具结果(source.kind === 'tool')和注入内容
|
|
121
|
+
// (source.kind === 'plugin')也存成 role: 'user' 的消息。只按 role 取
|
|
122
|
+
// 「最后一条」会取到 tool-result,extractText 得到空串,下面的长度检查
|
|
123
|
+
// 直接返回——表现就是全程静默不注入。只有 source.kind === 'user' 是真人输入。
|
|
124
|
+
const userMessages = messages.filter(m => m?.role === 'user' && m?.source?.kind === 'user')
|
|
125
|
+
const text = extractText(userMessages[userMessages.length - 1]?.content)
|
|
126
|
+
|
|
127
|
+
const files = await scanMemoryFiles(ctx, sessionId)
|
|
128
|
+
const picked = []
|
|
129
|
+
const seen = new Set()
|
|
130
|
+
const take = (f) => {
|
|
131
|
+
if (seen.has(f.file)) return
|
|
132
|
+
seen.add(f.file)
|
|
133
|
+
picked.push(f)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// baseline:每个会话注入一次,**不看消息条数**。
|
|
137
|
+
//
|
|
138
|
+
// 旧写法 `if (userMessages.length === 0)` 依赖一个真实存在的时序:assemble
|
|
139
|
+
// 触发时当前这条用户消息还没写进 session。已在 DSH 0.1.7-rc.1 上核实为真 ——
|
|
140
|
+
// 宿主从 pending 队列取走本轮输入
|
|
141
|
+
// 组装 system prompt(本监听器在这里跑)
|
|
142
|
+
// 之后才把这条 user 消息落进 session
|
|
143
|
+
// 所以全新会话首步 pick 到 0 条 user message,分支命中。
|
|
144
|
+
//
|
|
145
|
+
// 但**恢复 / 续聊会话**的历史消息早就在 session 里,条数永远 ≥ 1 → 分支永不
|
|
146
|
+
// 命中 → 永不注入。而「继续吧」这类短问题在 description 里找不到匹配、检索
|
|
147
|
+
// 返回 0 条,baseline 恰恰是那时唯一的兜底。这是本会话级状态要修的东西。
|
|
148
|
+
let baselineTaken = 0
|
|
149
|
+
const baselineKey = session.id ? session.id : session
|
|
150
|
+
if (!baselineInjected.has(baselineKey)) {
|
|
151
|
+
selectBaseline(files)
|
|
152
|
+
.forEach(f => {
|
|
153
|
+
const before = picked.length
|
|
154
|
+
take(f)
|
|
155
|
+
if (picked.length > before) baselineTaken++
|
|
156
|
+
})
|
|
157
|
+
// 只有真取到才落标记:首步若正好撞上目录读不到(scanMemoryFiles 降级返回 []),
|
|
158
|
+
// 整场会话就再也没机会补 baseline 了。宁可下一步重算一次。
|
|
159
|
+
if (baselineTaken > 0) {
|
|
160
|
+
baselineInjected.add(baselineKey)
|
|
161
|
+
if (baselineInjected.size > BASELINE_CACHE_MAX) {
|
|
162
|
+
baselineInjected.delete(baselineInjected.values().next().value)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// 检索签名只覆盖「检索出来的那批」,baseline 不再掺进这个签名。
|
|
168
|
+
// 旧版两者共用 signature,首步签名一旦与后续某步相同,baseline 会连坐被吞;
|
|
169
|
+
// 反过来,旧版把 baseline 文件名也算进签名,会让同一步的检索结果在下一步
|
|
170
|
+
// 因签名变化而被重复注入。现在 baseline 由会话级标记保证只来一次,
|
|
171
|
+
// 检索由这份签名保证同一问题不重复追加,两条线互不干扰。
|
|
172
|
+
const relevant = text && text.length >= 2 ? selectRelevant(text, files) : []
|
|
173
|
+
const signature = text + '\u0000' + relevant.map(f => f.file).join(',')
|
|
174
|
+
if (recallCache.get(session.id) !== signature) {
|
|
175
|
+
recallCache.set(session.id, signature)
|
|
176
|
+
if (recallCache.size > 8) recallCache.delete(recallCache.keys().next().value)
|
|
177
|
+
relevant.forEach(take)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (picked.length === 0) return assembled
|
|
181
|
+
|
|
182
|
+
// ── 单会话累计预算 ──
|
|
183
|
+
// 账记在 session id 上;消息条数变小 = 压缩/清空发生过 → 账本归零(见上面
|
|
184
|
+
// injectLedger 的注释)。picked 里 baseline 永远排在检索结果之前,所以预算
|
|
185
|
+
// 不够时先被挤掉的是「更晚才想起来的」那些 —— baseline 有优先权。
|
|
186
|
+
const ledgerKey = session.id || 'no-session-id'
|
|
187
|
+
const prevLedger = injectLedger.get(ledgerKey)
|
|
188
|
+
let usedBytes = prevLedger && messages.length >= prevLedger.messageCount ? prevLedger.bytes : 0
|
|
189
|
+
const nowMs = Date.now()
|
|
190
|
+
|
|
191
|
+
const parts = []
|
|
192
|
+
let skippedForBudget = 0
|
|
193
|
+
for (const f of picked) {
|
|
194
|
+
const body = f.content.length > MAX_CHARS_PER_FILE
|
|
195
|
+
? f.content.slice(0, MAX_CHARS_PER_FILE) + '\n…(截断)'
|
|
196
|
+
: f.content
|
|
197
|
+
const days = ageDays(f, nowMs)
|
|
198
|
+
const stamp = days !== null && days >= STALE_DAYS ? `(保存于 ${days} 天前)` : ''
|
|
199
|
+
const part = `### ${f.file}${stamp}\n${body}`
|
|
200
|
+
const size = Buffer.byteLength(part, 'utf8')
|
|
201
|
+
if (usedBytes + size > MAX_SESSION_BYTES) { skippedForBudget++; continue }
|
|
202
|
+
usedBytes += size
|
|
203
|
+
parts.push(part)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// 被预算挡掉了全部条目 —— 一个字都不注。本会话已经注进去的那批还在上下文里,
|
|
207
|
+
// 每步再塞一条「预算已满」只是白花 token;真压缩过之后账本会自己归零。
|
|
208
|
+
if (parts.length === 0) return assembled
|
|
209
|
+
|
|
210
|
+
injectLedger.set(ledgerKey, { bytes: usedBytes, messageCount: messages.length })
|
|
211
|
+
if (injectLedger.size > INJECT_LEDGER_MAX) {
|
|
212
|
+
injectLedger.delete(injectLedger.keys().next().value)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// 状态行:让「注了几条、花了多少、被挡掉几条」在上下文里可见 ——
|
|
216
|
+
// 「某条记忆没被注入」是最难查的一类故障,这里留一个可读的出口。
|
|
217
|
+
const status = `(本次注入 ${parts.length} 条;本会话累计 ${Math.round(usedBytes / 1024)} KB` +
|
|
218
|
+
` / 上限 ${Math.round(MAX_SESSION_BYTES / 1024)} KB` +
|
|
219
|
+
(skippedForBudget > 0 ? `;因预算省略 ${skippedForBudget} 条,需要时直接用文件工具读 ${MEMORY_DIR}` : '') +
|
|
220
|
+
')'
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
...assembled,
|
|
224
|
+
contexts: [...(assembled.contexts ?? []), {
|
|
225
|
+
name: 'sage-mem:recall',
|
|
226
|
+
order: 1000,
|
|
227
|
+
text: `以下是与你当前问题相关的历史记忆(文件式,来自 ${MEMORY_DIR}):\n\n` +
|
|
228
|
+
`${RECALL_DISCIPLINE}\n\n${parts.join('\n\n')}\n\n${status}`,
|
|
229
|
+
}],
|
|
230
|
+
}
|
|
231
|
+
} catch (err) {
|
|
232
|
+
// 这是整条注入链的外壳。裸吞的代价就是 :174-193 记的那个坑:权限错、编码坏、
|
|
233
|
+
// frontmatter 正则哪天不匹配,表现都是「插件在、但记忆不再注入」,日志一字没有。
|
|
234
|
+
// 现在只降级(返回未注入的 assembly),但一定留痕。
|
|
235
|
+
warnLog(ctx, `recall injection failed: ${err?.message ?? err}`, sessionId)
|
|
236
|
+
return assembled
|
|
237
|
+
}
|
|
238
|
+
})
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** 列出 memory 目录全部记忆文件(文件名 + 类型 + 描述 + 大小 + 自定义标签)。 */
|
|
242
|
+
async listFiles() {
|
|
243
|
+
const files = await scanMemoryFiles(this.ctx, '')
|
|
244
|
+
return files.map(f => {
|
|
245
|
+
const meta = parseFrontmatter(f.content)
|
|
246
|
+
return {
|
|
247
|
+
file: f.file,
|
|
248
|
+
type: meta.type || 'reference',
|
|
249
|
+
description: meta.description || '',
|
|
250
|
+
size: f.content.length,
|
|
251
|
+
tags: meta.tags,
|
|
252
|
+
}
|
|
253
|
+
})
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** 读单个记忆文件全文。 */
|
|
257
|
+
async readFile(name) {
|
|
258
|
+
const safe = safeName(name)
|
|
259
|
+
if (!safe) throw new Error('sage-mem: invalid file name')
|
|
260
|
+
const content = await readFile(join(MEMORY_DIR, safe), 'utf8')
|
|
261
|
+
return { name: safe, content }
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* 写(新增或覆盖)单个记忆文件。
|
|
266
|
+
*
|
|
267
|
+
* 三道闸:保留名不可写、内容必须是字符串、体积有上限;落盘走原子写。
|
|
268
|
+
* 拒绝一律返回 { ok: false, error }(不是抛异常)——设置页的调用方按 ok 判定。
|
|
269
|
+
*/
|
|
270
|
+
async writeFile(name, content) {
|
|
271
|
+
// 保留名判定放在最前面、且以 nameKey 归一:'memory.md' / 'MEMORY.MD' /
|
|
272
|
+
// 'Session-Log.md' 在 NTFS 上都是同一个文件,必须一律走 { ok: false } 这条路
|
|
273
|
+
// (设置页按 ok 判定,见 client.js 的 submit())。
|
|
274
|
+
if (isReserved(name)) {
|
|
275
|
+
return { ok: false, file: nameKey(name), error: `sage-mem: reserved file, edit it with the file tools: ${nameKey(name)}` }
|
|
276
|
+
}
|
|
277
|
+
const safe = safeName(name)
|
|
278
|
+
if (!safe) throw new Error('sage-mem: invalid file name')
|
|
279
|
+
// 不加这一层,传个对象进来会被 String() 写成 "[object Object]" 还回 { ok: true },
|
|
280
|
+
// 原文件就此被一行垃圾顶掉,且没有任何错误信号。
|
|
281
|
+
if (typeof content !== 'string') {
|
|
282
|
+
return { ok: false, file: safe, error: `sage-mem: content must be a string, got ${typeof content}` }
|
|
283
|
+
}
|
|
284
|
+
const bytes = Buffer.byteLength(content, 'utf8')
|
|
285
|
+
if (bytes > MAX_FILE_BYTES) {
|
|
286
|
+
return { ok: false, file: safe, error: `sage-mem: content too large (${bytes} bytes > ${MAX_FILE_BYTES})` }
|
|
287
|
+
}
|
|
288
|
+
await writeFileAtomic(join(MEMORY_DIR, safe), content)
|
|
289
|
+
return { ok: true, file: safe }
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** 删除单个记忆文件:保留名一律拒绝(unlink 不可逆)。判定同 writeFile。 */
|
|
293
|
+
async deleteFile(name) {
|
|
294
|
+
if (isReserved(name)) {
|
|
295
|
+
return { ok: false, error: `sage-mem: reserved file, refusing to delete: ${nameKey(name)}` }
|
|
296
|
+
}
|
|
297
|
+
const safe = safeName(name)
|
|
298
|
+
if (!safe) throw new Error('sage-mem: invalid file name')
|
|
299
|
+
await unlink(join(MEMORY_DIR, safe))
|
|
300
|
+
return { ok: true }
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
markRemote(MemoryGateway, 'listFiles', 'listFiles')
|
|
305
|
+
markRemote(MemoryGateway, 'readFile', 'readFile')
|
|
306
|
+
markRemote(MemoryGateway, 'writeFile', 'writeFile')
|
|
307
|
+
markRemote(MemoryGateway, 'deleteFile', 'deleteFile')
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* 记忆星图(宿主侧)—— 只读扫描记忆目录,把每条记忆解析成一颗「星」。
|
|
311
|
+
* 通过 TypertRemoteService 暴露两个 remote 方法给界面侧:
|
|
312
|
+
* - starmap.listStars() 全部星(元数据,不含正文)
|
|
313
|
+
* - starmap.readFile(n) 单条记忆全文(文件名白名单校验)
|
|
314
|
+
*/
|
|
315
|
+
export class StarmapGateway extends TypertRemoteService {
|
|
316
|
+
constructor(ctx) { super(ctx, 'starmap') }
|
|
317
|
+
|
|
318
|
+
async listStars() {
|
|
319
|
+
const files = await scanStars(this.ctx)
|
|
320
|
+
const stars = files.map(f => {
|
|
321
|
+
const meta = parseFrontmatter(f.content)
|
|
322
|
+
const title = extractTitle(f.content)
|
|
323
|
+
return {
|
|
324
|
+
file: f.name,
|
|
325
|
+
kind: meta.type || 'special',
|
|
326
|
+
title: title || meta.name || (meta.description ? meta.description.slice(0, 24) : f.name.replace(/\.md$/, '')),
|
|
327
|
+
desc: meta.description || '',
|
|
328
|
+
bytes: f.bytes,
|
|
329
|
+
mtimeMs: f.mtimeMs,
|
|
330
|
+
}
|
|
331
|
+
})
|
|
332
|
+
return { count: stars.length, stars }
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async readFile(name) {
|
|
336
|
+
// 与 MemoryGateway.readFile 收敛到同一个 safeName:它现在同时排除
|
|
337
|
+
// MEMORY.md 与 session-log.md,且大小写不敏感。
|
|
338
|
+
const safe = safeName(name)
|
|
339
|
+
if (!safe) throw new Error('sage-starmap: invalid file name')
|
|
340
|
+
const content = await readFile(join(MEMORY_DIR, safe), 'utf8')
|
|
341
|
+
return { name: safe, content }
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
markRemote(StarmapGateway, 'listStars', 'listStars')
|
|
346
|
+
markRemote(StarmapGateway, 'readFile', 'readFile')
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* autodream 的 remote 面:设置页「自动做梦」标签页唯一的数据通道。
|
|
350
|
+
*
|
|
351
|
+
* 这一层刻意很薄——全部判断(门控、模式、白名单)都在 AutodreamEngine 里,
|
|
352
|
+
* 这里只负责把 ctx 里的引擎接到 typert 的调用表上。想看行为就读 autodream.js。
|
|
353
|
+
*/
|
|
354
|
+
export class AutodreamGateway extends TypertRemoteService {
|
|
355
|
+
constructor(ctx, engine) {
|
|
356
|
+
super(ctx, 'autodream')
|
|
357
|
+
this.engine = engine
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** 配置 + 状态 + 路径 + 默认值(设置面板一次拿全)。 */
|
|
361
|
+
async getConfig() {
|
|
362
|
+
return this.engine.getConfig()
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** 改配置(补丁式)。 */
|
|
366
|
+
async setConfig(patch) {
|
|
367
|
+
return this.engine.setConfig(patch)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** 轮询用:是否在跑、跑到哪一步、上次结果。 */
|
|
371
|
+
async status() {
|
|
372
|
+
return this.engine.status()
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* 手动跑一趟。设置页的「立即整理」走这里。
|
|
377
|
+
*
|
|
378
|
+
* force 默认为 true —— 人点了按钮,就不该再被「还没到 24 小时」挡回来。
|
|
379
|
+
*
|
|
380
|
+
* **故意不 await**:一趟 autodream 可能跑几分钟(多轮模型调用 + 工具),
|
|
381
|
+
* remote 调用挂在那儿等会直接撞上前端超时,界面上表现为「点了没反应」。
|
|
382
|
+
* 所以这里只负责点火,进度与结果都从 status() 轮询 —— run() 自己会把
|
|
383
|
+
* lastResult 落盘,界面刷新几次就能看到。
|
|
384
|
+
*/
|
|
385
|
+
async runNow(opts) {
|
|
386
|
+
if (this.engine.running) return { ok: false, error: '已有一趟 autodream 在跑' }
|
|
387
|
+
const reason = typeof opts?.reason === 'string' && opts.reason.trim() ? opts.reason.trim().slice(0, 120) : '设置页手动触发'
|
|
388
|
+
this.engine.run({ reason, force: true }).catch(() => {})
|
|
389
|
+
return { ok: true }
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** 历史报告列表。 */
|
|
393
|
+
async listReports() {
|
|
394
|
+
return this.engine.listReports()
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** 读一份报告全文。 */
|
|
398
|
+
async readReport(name) {
|
|
399
|
+
return this.engine.readReport(name)
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** 已有快照(面板显示「可回退到哪一份」)。 */
|
|
403
|
+
async listSnapshots() {
|
|
404
|
+
return this.engine.listSnapshots()
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** 运行记录列表(面板的「整理声明」区)。 */
|
|
408
|
+
async listRuns(opts) {
|
|
409
|
+
return this.engine.listRuns(opts?.limit)
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** 读某次运行的完整整理声明(人读 markdown + 机读 manifest)。 */
|
|
413
|
+
async readDeclaration(opts) {
|
|
414
|
+
return this.engine.readDeclaration(opts?.runId)
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* 回滚到某个回滚点。
|
|
419
|
+
*
|
|
420
|
+
* 与 runNow 相反,这里**必须 await**:回滚是几十毫秒级的文件操作,
|
|
421
|
+
* 而「点完按钮不知道到底滚没滚」正是回滚这种动作最不能接受的体验。
|
|
422
|
+
*/
|
|
423
|
+
async rollback(opts) {
|
|
424
|
+
return this.engine.rollback({ snapshotId: opts?.snapshotId, scope: opts?.scope })
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** 可选的整理模型路线(设置面板的模型下拉)。 */
|
|
428
|
+
async listModels() {
|
|
429
|
+
return this.engine.listModels()
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
markRemote(AutodreamGateway, 'getConfig', 'getConfig')
|
|
434
|
+
markRemote(AutodreamGateway, 'setConfig', 'setConfig')
|
|
435
|
+
markRemote(AutodreamGateway, 'status', 'status')
|
|
436
|
+
markRemote(AutodreamGateway, 'runNow', 'runNow')
|
|
437
|
+
markRemote(AutodreamGateway, 'listReports', 'listReports')
|
|
438
|
+
markRemote(AutodreamGateway, 'readReport', 'readReport')
|
|
439
|
+
markRemote(AutodreamGateway, 'listSnapshots', 'listSnapshots')
|
|
440
|
+
markRemote(AutodreamGateway, 'listRuns', 'listRuns')
|
|
441
|
+
markRemote(AutodreamGateway, 'readDeclaration', 'readDeclaration')
|
|
442
|
+
markRemote(AutodreamGateway, 'rollback', 'rollback')
|
|
443
|
+
markRemote(AutodreamGateway, 'listModels', 'listModels')
|
|
444
|
+
|
|
445
|
+
export default MemoryGateway
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* **仅供测试**的只读出口。
|
|
449
|
+
*
|
|
450
|
+
* 检索这条链(frontmatter 解析 → 双字打分 → 名额裁剪)没有任何对外可见的调用面:
|
|
451
|
+
* 想验证「写了 aliases 之后换个说法能不能命中」,否则只能起一个真的 DSH 端到端跑一遍。
|
|
452
|
+
* 把这些纯函数露出来是为了能写断言,**不是给外部用的 API**,别依赖。
|
|
453
|
+
*/
|
|
454
|
+
export const __testables = {
|
|
455
|
+
bigrams,
|
|
456
|
+
score,
|
|
457
|
+
parseFrontmatter,
|
|
458
|
+
parseAliases,
|
|
459
|
+
parseBaselinePriority,
|
|
460
|
+
parseBaseline,
|
|
461
|
+
parseTags,
|
|
462
|
+
ageDays,
|
|
463
|
+
selectRelevant,
|
|
464
|
+
selectBaseline,
|
|
465
|
+
limits: { MAX_RESULTS, MAX_CHARS_PER_FILE, MAX_BASELINE, MAX_SESSION_BYTES, STALE_DAYS },
|
|
466
|
+
recallDiscipline: RECALL_DISCIPLINE,
|
|
467
|
+
}
|
|
468
|
+
|