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/LICENSE +202 -202
- package/NOTICE +8 -8
- package/README.md +312 -284
- package/cordis.patch.yml +4 -4
- package/lib/autodream/config.js +83 -0
- package/lib/autodream/engine.js +524 -0
- package/lib/autodream/gate.js +78 -0
- package/lib/autodream/llm-loop.js +248 -0
- package/lib/autodream/report.js +285 -0
- package/lib/autodream/rollback.js +221 -0
- package/lib/autodream/settings.js +289 -0
- package/lib/autodream/snapshot.js +142 -0
- package/lib/autodream/util.js +112 -0
- package/lib/autodream-audit.js +270 -229
- package/lib/autodream-prompt.js +129 -129
- package/lib/autodream-tools.js +497 -498
- package/lib/autodream.js +8 -1722
- package/lib/client.js +4277 -2854
- package/lib/index.js +893 -951
- package/lib/memory/access.js +220 -0
- package/lib/memory/archive.js +91 -0
- 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 +204 -0
- package/lib/memory/util.js +37 -0
- package/lib/typert.host.js +747 -461
- package/package.json +7 -2
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 {
|
|
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
|
-
|
|
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
|
-
function
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
await
|
|
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
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
new
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
//
|
|
503
|
-
//
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
const
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
}
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
const
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
}
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
file:
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
}
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
/**
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
*/
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
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
|
+
|