dsh-prime-memory 0.22.1 → 0.24.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.
Files changed (121) hide show
  1. package/CHANGELOG.en.md +401 -0
  2. package/CHANGELOG.ja.md +401 -0
  3. package/CHANGELOG.ko.md +401 -0
  4. package/CHANGELOG.md +100 -0
  5. package/dist/client.js +1951 -1188
  6. package/dist/config.d.ts +176 -1240
  7. package/dist/config.js +113 -2
  8. package/dist/conflict-service.d.ts +12 -0
  9. package/dist/conflict-service.js +24 -0
  10. package/dist/contract.d.ts +327 -0
  11. package/dist/domain-gate.d.ts +19 -0
  12. package/dist/domain-gate.js +45 -0
  13. package/dist/graph/constraints.d.ts +4 -2
  14. package/dist/graph/constraints.js +4 -2
  15. package/dist/hooks/obsolete-notice.d.ts +26 -0
  16. package/dist/hooks/obsolete-notice.js +64 -0
  17. package/dist/hooks/recall.d.ts +41 -0
  18. package/dist/hooks/recall.js +127 -28
  19. package/dist/hooks/skill-recall.d.ts +25 -0
  20. package/dist/hooks/skill-recall.js +72 -0
  21. package/dist/index.d.ts +1 -1029
  22. package/dist/index.js +55 -2
  23. package/dist/memory-types.d.ts +69 -0
  24. package/dist/memory-types.js +165 -0
  25. package/dist/memport/format.d.ts +75 -0
  26. package/dist/memport/format.js +345 -0
  27. package/dist/memport/prefilter.d.ts +62 -0
  28. package/dist/memport/prefilter.js +132 -0
  29. package/dist/memport/scan.d.ts +13 -0
  30. package/dist/memport/scan.js +100 -0
  31. package/dist/metadata-validators.d.ts +22 -0
  32. package/dist/metadata-validators.js +45 -0
  33. package/dist/pipeline/l1.d.ts +44 -2
  34. package/dist/pipeline/l1.js +177 -9
  35. package/dist/pipeline/md-sync.d.ts +65 -0
  36. package/dist/pipeline/md-sync.js +456 -0
  37. package/dist/pipeline/ruminate.d.ts +46 -1
  38. package/dist/pipeline/ruminate.js +150 -1
  39. package/dist/pipeline/runner.d.ts +10 -0
  40. package/dist/pipeline/runner.js +7 -1
  41. package/dist/pipeline/slot-migrate.d.ts +45 -0
  42. package/dist/pipeline/slot-migrate.js +45 -0
  43. package/dist/pipeline/workspace-relink.d.ts +60 -0
  44. package/dist/pipeline/workspace-relink.js +73 -0
  45. package/dist/projection/recall.d.ts +112 -0
  46. package/dist/projection/recall.js +183 -0
  47. package/dist/projection/slots.d.ts +5 -0
  48. package/dist/prompts/examples.d.ts +73 -0
  49. package/dist/prompts/examples.js +178 -0
  50. package/dist/prompts/graph-projection.js +3 -0
  51. package/dist/prompts/l1-dedup.d.ts +9 -3
  52. package/dist/prompts/l1-dedup.js +34 -13
  53. package/dist/prompts/l1-extraction.d.ts +9 -4
  54. package/dist/prompts/l1-extraction.js +76 -8
  55. package/dist/settings.d.ts +8 -0
  56. package/dist/settings.js +15 -1
  57. package/dist/skills/crystallize.d.ts +41 -0
  58. package/dist/skills/crystallize.js +75 -0
  59. package/dist/stats.d.ts +30 -1
  60. package/dist/stats.js +504 -13
  61. package/dist/store/embedding-source.d.ts +8 -0
  62. package/dist/store/embedding-source.js +39 -2
  63. package/dist/store/file-versions.d.ts +4 -0
  64. package/dist/store/file-versions.js +4 -0
  65. package/dist/store/graph-store.d.ts +14 -0
  66. package/dist/store/graph-store.js +35 -0
  67. package/dist/store/l1-snapshot.d.ts +26 -1
  68. package/dist/store/l1-snapshot.js +53 -8
  69. package/dist/store/l1.d.ts +77 -3
  70. package/dist/store/l1.js +128 -3
  71. package/dist/store/memory-md-archive.d.ts +84 -0
  72. package/dist/store/memory-md-archive.js +155 -0
  73. package/dist/store/memory-md-disable.d.ts +23 -0
  74. package/dist/store/memory-md-disable.js +106 -0
  75. package/dist/store/memory-md-export.d.ts +50 -0
  76. package/dist/store/memory-md-export.js +200 -0
  77. package/dist/store/memory-md-ledger.d.ts +94 -0
  78. package/dist/store/memory-md-ledger.js +243 -0
  79. package/dist/store/memory-md-orphan.d.ts +27 -0
  80. package/dist/store/memory-md-orphan.js +153 -0
  81. package/dist/store/memory-md-parse.d.ts +35 -0
  82. package/dist/store/memory-md-parse.js +85 -0
  83. package/dist/store/memory-md-skeleton.d.ts +18 -0
  84. package/dist/store/memory-md-skeleton.js +61 -0
  85. package/dist/store/memory-md-types.d.ts +174 -0
  86. package/dist/store/memory-md-types.js +10 -0
  87. package/dist/store/memory-md.d.ts +108 -0
  88. package/dist/store/memory-md.js +219 -0
  89. package/dist/store/memory-sources.d.ts +59 -0
  90. package/dist/store/memory-sources.js +214 -0
  91. package/dist/store/obsolete-notices.d.ts +39 -0
  92. package/dist/store/obsolete-notices.js +153 -0
  93. package/dist/store/recall-feedback.d.ts +75 -0
  94. package/dist/store/recall-feedback.js +213 -0
  95. package/dist/store/recall-usage.d.ts +11 -0
  96. package/dist/store/recall-usage.js +29 -0
  97. package/dist/store/session-recalled.d.ts +60 -0
  98. package/dist/store/session-recalled.js +310 -0
  99. package/dist/store/skill-store.d.ts +99 -0
  100. package/dist/store/skill-store.js +330 -0
  101. package/dist/store/sqlite.d.ts +71 -0
  102. package/dist/store/sqlite.js +259 -15
  103. package/dist/store/supersede.d.ts +1 -1
  104. package/dist/store/supersede.js +1 -1
  105. package/dist/tools/index.d.ts +4 -13
  106. package/dist/tools/index.js +80 -12
  107. package/dist/tools/skills.d.ts +13 -0
  108. package/dist/tools/skills.js +130 -0
  109. package/dist/types.d.ts +30 -7
  110. package/dist/types.js +10 -2
  111. package/dist/util/maturity.d.ts +44 -0
  112. package/dist/util/maturity.js +43 -0
  113. package/dist/util/md-name.d.ts +129 -0
  114. package/dist/util/md-name.js +366 -0
  115. package/dist/util/md-normalize.d.ts +23 -0
  116. package/dist/util/md-normalize.js +131 -0
  117. package/dist/wing-backfill.js +7 -4
  118. package/dist/workspace-route.d.ts +22 -0
  119. package/dist/workspace-route.js +66 -0
  120. package/dsh.plugin.json +1 -1
  121. package/package.json +6 -5
@@ -1,7 +1,10 @@
1
+ import { resolveDataDir } from '../config.js';
1
2
  import { InProcMemoryBackend } from '../store/memory-backend.js';
3
+ import { exportRecords } from '../store/memory-md-export.js';
2
4
  import { groupPendingBySession, loadPending, } from '../store/pending.js';
3
5
  import { errDetail } from '../util/filelog.js';
4
6
  import { runSceneConsolidation } from './l2.js';
7
+ import { migrateLegacySlots } from './slot-migrate.js';
5
8
  import { relabelPass } from './relabel.js';
6
9
  import { processSceneReclusterJobs } from './recluster.js';
7
10
  import { annotateOrphanCandidates } from '../room-review.js';
@@ -48,6 +51,13 @@ export class RuminateController {
48
51
  pendingFile;
49
52
  /** Room 注册表(可选;孤儿候选预标记的合并词表来源)。 */
50
53
  roomRegistry;
54
+ /**
55
+ * 本轮起始时的库快照(id → updatedAt),供收尾算「本轮新增/变更」增量集(task_2.8)。
56
+ * `undefined` = 本轮没拍(投射门未开或拍失败)→ 收尾一律不做全库重导。
57
+ */
58
+ mdBaseline;
59
+ /** 投射旁路失败累计次数(只 warn 不抛,计数供诊断/测试读取)。 */
60
+ mdFailures = 0;
51
61
  /** 记忆后端:未注入时包 l1(进程内,行为与改造前等价)。 */
52
62
  backend() {
53
63
  return this.stores.backend ?? new InProcMemoryBackend(this.stores.l1);
@@ -89,6 +99,9 @@ export class RuminateController {
89
99
  this.sessions = groupSessions(buckets);
90
100
  this.cancelRequested = false;
91
101
  this.totalL1 = 0;
102
+ // md 投射基线(task_2.8):两条收尾路径(finalize / doLightRefresh)共用同一份起点,
103
+ // 所以必须在这里拍——放在 `sessions.length === 0` 分支之前,轻量刷新才覆盖得到。
104
+ this.snapshotMdBaseline();
92
105
  if (this.sessions.length === 0) {
93
106
  // 没有 pending 切片 → 做 L2/L3 轻量刷新
94
107
  return await this.doLightRefresh();
@@ -148,7 +161,11 @@ export class RuminateController {
148
161
  // 原先 setImmediate 连发会把全部会话一次性塞进 runner 队列(无界堆积),
149
162
  // LLM e2e 延迟波动时既拖慢 live 任务也让取消语义变钝;链式入队后
150
163
  // 进度(done)真实逐会话推进,取消即刻停止入队。
151
- this.runner.enqueue(session.sessionId, session.messages, session.mode, {
164
+ // 传空消息(与 enqueueSessionSlices 同一语义):让 runTurn 不回灌缓冲,
165
+ // 由 distillSession 直接处理该会话在内存缓冲里的既有切片。
166
+ // (旧代码传 session.messages——那是从 pending.json 读出的、已在缓冲中的消息,
167
+ // 经 runTurn 的 `bucket.push(...messages)` 会重复入桶 → 反刍后待处理计数“还原/变多”。)
168
+ this.runner.enqueue(session.sessionId, [], session.mode, {
152
169
  force: true,
153
170
  onTurnDone: (records) => {
154
171
  this.totalL1 += records;
@@ -199,7 +216,18 @@ export class RuminateController {
199
216
  if (scenes.length === 0)
200
217
  continue;
201
218
  const fstate = this.runner.states?.[family];
219
+ // 强制 L3:runPersona 自带触发判定(memoriesSinceL3>=interval 才跑),反刍是用户
220
+ // 显式整理动作,需绕过阈值真跑——置 personaRequestedReason 走“主动请求”分支。
221
+ if (fstate)
222
+ fstate.personaRequestedReason = '反刍收尾';
202
223
  await runPersona(this.ctx, this.cfg, this.stores.scenes[family], this.stores.persona[family], fstate || undefined, this.logger, family);
224
+ // 与 L2 块对称:无论 runPersona 是否因“无变化场景”早退,反刍收尾都清零 L3 计数,
225
+ // 否则“待 L3 新记忆”会永远卡在阈值下(本 bug 根因)。runPersona 成功时已自清,这里幂等兜底。
226
+ if (fstate) {
227
+ fstate.memoriesSinceL3 = 0;
228
+ fstate.lastL3At = Date.now();
229
+ fstate.personaRequestedReason = undefined;
230
+ }
203
231
  this.logger.info(`[memory] 反刍 L3 完成(family=${family})`);
204
232
  }
205
233
  catch (err) {
@@ -207,6 +235,15 @@ export class RuminateController {
207
235
  }
208
236
  }
209
237
  }
238
+ // 反刍收尾改了 L2/L3 计数(runner.states 是 StateStore 的活引用,内存态)——
239
+ // 必须显式落盘,否则重启从 state.json 读回旧值,表现为“待处理计数反复”。
240
+ // (正常蒸馏路径由 runTurn 末尾 save;反刍 finalize 是独立路径,需自己 save。)
241
+ await this.stores.state.save();
242
+ // 旧 slot 语义迁移(cfg.slots.migrateLegacy 默认关 → 直接 0 动作):
243
+ // 把“钉成常驻、内容已由活 L1 记忆承载”的 rule 槽位软退场。
244
+ if (this.stores.slots !== undefined) {
245
+ await migrateLegacySlots(this.stores.slots, this.stores.l1, this.cfg.slots.migrateLegacy === true, this.logger);
246
+ }
210
247
  // 标注校验/重标定:Wing 合法性 + 认知 hall 映射 + 未打标补标 + 涌现标签
211
248
  // 批次进度经 onProgress 写入 detail + sub 子进度(非会话阶段的进度由重标批次决定)
212
249
  this.status.phase = 'relabeling';
@@ -250,6 +287,8 @@ export class RuminateController {
250
287
  catch (err) {
251
288
  this.logger.warn(`[memory] 孤儿候选预标记失败(忽略): ${errDetail(err)}`);
252
289
  }
290
+ // md 投射收尾(task_2.8):只导本轮新增/变更的记录;旁路,失败不影响本次产物。
291
+ await this.projectMd('反刍收尾');
253
292
  this.status.phase = 'done';
254
293
  this.finish(null);
255
294
  }
@@ -333,7 +372,18 @@ export class RuminateController {
333
372
  this.status.detail = `L3 画像更新(${family})`;
334
373
  const fstate = this.runner.states?.[family];
335
374
  try {
375
+ // 强制 L3:runPersona 自带触发判定(memoriesSinceL3>=interval 才跑),反刍是显式整理动作,
376
+ // 需绕过阈值真跑——置 personaRequestedReason 走“主动请求”分支。
377
+ if (fstate)
378
+ fstate.personaRequestedReason = '反刍轻量刷新';
336
379
  await runPersona(this.ctx, this.cfg, this.stores.scenes[family], this.stores.persona[family], fstate, this.logger, family);
380
+ // 与 L2 循环对称:无论 runPersona 是否因“无变化场景”早退,都清零 L3 计数,
381
+ // 否则“待 L3 新记忆”永远卡在阈值下。runPersona 成功时已自清,这里幂等兜底。
382
+ if (fstate) {
383
+ fstate.memoriesSinceL3 = 0;
384
+ fstate.lastL3At = Date.now();
385
+ fstate.personaRequestedReason = undefined;
386
+ }
337
387
  }
338
388
  catch (err) {
339
389
  this.logger.warn(`[memory] 反刍轻量刷新 L3 失败(family=${family}): ${errDetail(err)}`);
@@ -361,6 +411,22 @@ export class RuminateController {
361
411
  this.status.sub = null;
362
412
  this.status.done++;
363
413
  }
414
+ // 轻量刷新改了 L2/L3 计数(runner.states 是 StateStore 活引用)——必须显式落盘,
415
+ // 否则重启从 state.json 读回旧值,表现为“待 L2/L3 计数反复”。
416
+ try {
417
+ await this.stores.state.save();
418
+ }
419
+ catch (err) {
420
+ this.logger.warn(`[memory] 反刍轻量刷新 state 落盘失败(计数可能不持久): ${errDetail(err)}`);
421
+ }
422
+ // 旧 slot 语义迁移:无 pending 缓冲时走的是本路径(“待 L3 12/20”那类场景),
423
+ // 与 finalize 同一动作、同样默认关;两处幂等(退场后不再命中 open)。
424
+ if (this.stores.slots !== undefined) {
425
+ await migrateLegacySlots(this.stores.slots, this.stores.l1, this.cfg.slots.migrateLegacy === true, this.logger);
426
+ }
427
+ // md 投射收尾(task_2.8):与 finalize 同一动作的两个挂载点之一——历史教训是
428
+ // `sessions.length === 0` 走本路径,只挂 finalize 等于没挂。
429
+ await this.projectMd('轻量刷新');
364
430
  this.status.running = false;
365
431
  this.status.detail = null;
366
432
  this.status.phase = this.cancelRequested ? 'cancelled' : 'done';
@@ -369,6 +435,89 @@ export class RuminateController {
369
435
  this.logger.info(`[memory] 反刍轻量刷新结束(${this.status.phase}):${this.status.done}/${this.status.total} 步,耗时 ${Math.round(cost / 1000)}s`);
370
436
  return this.getStatus();
371
437
  }
438
+ /**
439
+ * 投射侧诊断计数(只增不减;不进任何端点,避开契约五处齐改的面)。
440
+ */
441
+ getMdFailures() {
442
+ return this.mdFailures;
443
+ }
444
+ /**
445
+ * 拍本轮起始基线(id → updatedAt)。三道门任一不满足就**一个动作都不做**:
446
+ * 连 `l1.all()` 都不调,这样“投射关着”时的产物与改动前逐字一致(本仓红线)。
447
+ *
448
+ * 为什么用基线 diff 而不是 `updatedAt >= startedAt`:relabel / 退场等改写走的是
449
+ * `patchMetadata` 那类最小写回,**不碰 `updated_time`**(它在快照哈希字段表里),
450
+ * 按时间戳筛会把这批真变了的记录漏掉。
451
+ */
452
+ snapshotMdBaseline() {
453
+ this.mdBaseline = undefined;
454
+ const exp = this.cfg.memExport;
455
+ if (exp === undefined || exp.enabled !== true)
456
+ return;
457
+ if (this.stores.mdLedger === undefined)
458
+ return;
459
+ try {
460
+ const map = new Map();
461
+ for (const r of this.stores.l1.all())
462
+ map.set(r.id, r.updatedAt);
463
+ this.mdBaseline = map;
464
+ }
465
+ catch (err) {
466
+ // 拍不上基线 = 本轮不投射(而不是退回全库重导);旁路失败不拖垮反刍。
467
+ this.mdFailures++;
468
+ this.logger.warn(`[memory] 反刍 md 投射基线失败(本次不做投射): ${errDetail(err)}`);
469
+ }
470
+ }
471
+ /**
472
+ * 反刍收尾的增量 md 投射(task_2.8):库 → 文件 + 账本,**只碰本轮新增/变更的那批**。
473
+ *
474
+ * 与手动的 `dsh-memory/mem-export` 端点的区别:
475
+ * - 不跑命名回填(`assignMissingNames` 会写库,反刍是只读旁路,不能在那里改库);
476
+ * 本轮新记录的 `name` 已在写入前由管线单一真源分配,未命名的按 `fileStemFor` 回落。
477
+ * - 不建类型目录骨架(`ensureTypeSkeleton`),那是面板显式触发的动作。
478
+ * 护栏全部复用 `cfg.memExport` 既有值(`maxRecords`/`timeBudgetMs`),**不新增配置项**。
479
+ *
480
+ * 失败面:整个方法自吞异常,只 warn + 计数,绝不抛给调用方、绝不改变反刍结果。
481
+ * @param stage - 日志里的路径名(区分 finalize 与轻量刷新两条挂载点)。
482
+ */
483
+ async projectMd(stage) {
484
+ const baseline = this.mdBaseline;
485
+ if (baseline === undefined)
486
+ return;
487
+ const exp = this.cfg.memExport;
488
+ const ledger = this.stores.mdLedger;
489
+ if (exp === undefined || exp.enabled !== true || ledger === undefined)
490
+ return;
491
+ try {
492
+ const changed = [];
493
+ for (const r of this.stores.l1.all()) {
494
+ const prev = baseline.get(r.id);
495
+ // 新 id(本轮蒸馏产出)或 updatedAt 变了(合并/改写)才算变更;其余不重导。
496
+ if (prev === undefined || prev !== r.updatedAt)
497
+ changed.push(r);
498
+ }
499
+ if (changed.length === 0)
500
+ return;
501
+ const rep = await exportRecords({
502
+ records: changed,
503
+ cfgRoot: exp.root,
504
+ dataDir: resolveDataDir(this.cfg),
505
+ ledger,
506
+ maxRecords: exp.maxRecords,
507
+ timeBudgetMs: exp.timeBudgetMs,
508
+ logger: this.logger,
509
+ });
510
+ this.logger.info(`[memory] 反刍 md 投射(${stage}):增量 ${changed.length} 条,写 ${rep.written},未变 ${rep.unchanged},跳过 ${rep.skipped},落点 ${rep.root}${rep.capped ? `,护栏截断剩 ${rep.remaining} 条(下轮反刍继续)` : ''}`);
511
+ }
512
+ catch (err) {
513
+ this.mdFailures++;
514
+ this.logger.warn(`[memory] 反刍 md 投射失败(旁路忽略,不影响本次产物,累计 ${this.mdFailures} 次): ${errDetail(err)}`);
515
+ }
516
+ finally {
517
+ // 基线一次性消费:同一次运行不重投射同一批(两条挂载点互斥,这里只防异常复用)。
518
+ this.mdBaseline = undefined;
519
+ }
520
+ }
372
521
  finish(error) {
373
522
  this.status.running = false;
374
523
  this.status.detail = null;
@@ -30,6 +30,16 @@ export interface MemoryStores {
30
30
  state: StateStore;
31
31
  /** 图谱存储(可选:测试缝/未装配时图谱泵整体停用;GraphStore 自带降级 no-op)。 */
32
32
  graph?: GraphStore;
33
+ /**
34
+ * 派生数据搬运面(P0-d):merge 后把使用史与人工标注搬到新 id。
35
+ * 只声明用到的那个方法(不拉整个类入参)——未装配时不搬,行为与改动前一致。
36
+ */
37
+ usage?: {
38
+ rekey(oldId: string, newId: string): boolean;
39
+ };
40
+ feedback?: {
41
+ rekey(oldId: string, newId: string): boolean;
42
+ };
33
43
  }
34
44
  /** 管线任务(优先级调度:live > graph > rebuild)。 */
35
45
  export interface PipelineTask {
@@ -662,7 +662,13 @@ export class MemoryRunner {
662
662
  // §E 写入侧工作区:后台蒸馏手上只有 sessionId(没有 exec),经
663
663
  // `ctx.get('agents')` 宽容解析——与 §A 的多级父链解析同一招。
664
664
  // 拿不到 → undefined → 归属回落 global(pipeline 侧 `resolveRecordScope` 兜底)。
665
- sessionWorkspaceIdOf(this.ctx, sessionId), anchorMap);
665
+ sessionWorkspaceIdOf(this.ctx, sessionId), anchorMap,
666
+ // 派生数据跟着搬(P0-d):不搬等于每次合并把这条记忆的使用史与人工标注清零。
667
+ // 两个 sidecar 都是后装上的(`stores.usage` 在装配段才填),故在调用时点现取。
668
+ (oldId, newId) => {
669
+ this.stores.usage?.rekey(oldId, newId);
670
+ this.stores.feedback?.rekey(oldId, newId);
671
+ });
666
672
  if (!result.skipped) {
667
673
  this.pending[mode] = rest;
668
674
  // 重建轮(force)不是有机对话,不推进爬坡
@@ -0,0 +1,45 @@
1
+ /**
2
+ * 旧 slot 语义迁移(`cfg.slots.migrateLegacy`,默认关)。
3
+ *
4
+ * 背景:常驻槽位是"每轮硬注入"的 opt-in 约定。早期用法里有一类槽位其实是
5
+ * **把一条已经存在的 L1 记忆钉成常驻**(`refs` 直指那条记录)——内容载体还在,
6
+ * 却占了固定 token 预算、还在概要里显示成常驻清单。新取向是"记忆按相关性隐式
7
+ * 浮现,除非手动提醒激活",所以这类槽位应当退场,让那条记忆回到召回。
8
+ *
9
+ * 判据(四条同时满足,宁缺勿滥):
10
+ * 1. 开关显式开启(动的是用户手动建的约定,不自动生效);
11
+ * 2. `status='open'` 且 `pinned=true` 且 `kind='rule'`(todo/anchor/pointer 各有语义,一律不碰);
12
+ * 3. `refs` 至少一条解析到**未退场**的 L1 记录(退场不丢信息:正文仍在记忆里);
13
+ * 4. 记录本身可被召回(未退场即可检索,不再另设条件)。
14
+ *
15
+ * 动作:`close(id, 'dropped')` —— **软退场**,不删数据;记忆仍在,需要时可
16
+ * 重新 `memory_slot_write`。逐条 info 日志,便于事后核对。
17
+ */
18
+ import type { Slot } from '../store/slots.js';
19
+ import type { MemoryLogger } from '../types.js';
20
+ /** 迁移需要的槽位面。 */
21
+ export interface SlotMigrateStoreFace {
22
+ list(): Slot[];
23
+ close(id: string, status: 'done' | 'dropped'): Promise<boolean>;
24
+ }
25
+ /** 迁移需要的 L1 只读面(validTo 非空 = 已退场(epoch ms),不作为信息载体)。 */
26
+ export interface SlotMigrateL1Face {
27
+ getByIds(ids: string[]): Array<{
28
+ id: string;
29
+ validTo?: number | null;
30
+ }>;
31
+ }
32
+ /** 一条可迁移槽位(预览用,执行前即可展示)。 */
33
+ export interface MigrateCandidate {
34
+ id: string;
35
+ title: string;
36
+ /** 承载内容的活记录 id。 */
37
+ carrier: string;
38
+ }
39
+ /** 判定 + 挑选可迁移槽位(纯读,无副作用;面板预览与反刍执行共用)。 */
40
+ export declare function pickMigratableSlots(slots: readonly Slot[], l1: SlotMigrateL1Face | undefined): MigrateCandidate[];
41
+ /**
42
+ * 执行迁移:软退场命中的槽位。
43
+ * @returns 实际退场条数(close 返回 false 的不计)。
44
+ */
45
+ export declare function migrateLegacySlots(slots: SlotMigrateStoreFace, l1: SlotMigrateL1Face | undefined, enabled: boolean, logger?: MemoryLogger): Promise<number>;
@@ -0,0 +1,45 @@
1
+ import { errDetail } from '../util/filelog.js';
2
+ /** 判定 + 挑选可迁移槽位(纯读,无副作用;面板预览与反刍执行共用)。 */
3
+ export function pickMigratableSlots(slots, l1) {
4
+ if (l1 === undefined)
5
+ return [];
6
+ const open = slots.filter((s) => s.status === 'open' && s.pinned === true && s.kind === 'rule');
7
+ if (open.length === 0)
8
+ return [];
9
+ // 一次批量取全部 refs,避免 N 次查询。
10
+ const allRefs = [...new Set(open.flatMap((s) => s.refs ?? []))];
11
+ const alive = new Set(l1.getByIds(allRefs).filter((r) => r.validTo === undefined || r.validTo === null).map((r) => r.id));
12
+ const out = [];
13
+ for (const s of open) {
14
+ const carrier = (s.refs ?? []).find((r) => alive.has(r));
15
+ if (carrier !== undefined)
16
+ out.push({ id: s.id, title: s.title, carrier });
17
+ }
18
+ return out;
19
+ }
20
+ /**
21
+ * 执行迁移:软退场命中的槽位。
22
+ * @returns 实际退场条数(close 返回 false 的不计)。
23
+ */
24
+ export async function migrateLegacySlots(slots, l1, enabled, logger) {
25
+ if (enabled !== true)
26
+ return 0;
27
+ const candidates = pickMigratableSlots(slots.list(), l1);
28
+ if (candidates.length === 0)
29
+ return 0;
30
+ let done = 0;
31
+ for (const c of candidates) {
32
+ try {
33
+ if (await slots.close(c.id, 'dropped')) {
34
+ done++;
35
+ logger?.info(`[memory] 旧槽位迁移:「${c.title}」常驻退场,内容改由召回承载(${c.carrier})`);
36
+ }
37
+ }
38
+ catch (err) {
39
+ logger?.warn(`[memory] 旧槽位迁移失败(${c.id}): ${errDetail(err)}`);
40
+ }
41
+ }
42
+ if (done > 0)
43
+ logger?.info(`[memory] 旧槽位迁移完成: 常驻约定退场 ${done} 条`);
44
+ return done;
45
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * 存量记忆的 workspace re-link(内容路由回填)。
3
+ *
4
+ * 为什么需要它:新写入的路由只覆盖此后产生的记忆,而库里已有的记录归属列全空
5
+ * (实测 1279 条活跃记录里 `workspace_id` 非空为 0)。不回填,「按项目看记忆」这件事
6
+ * 就永远只有增量、没有存量。
7
+ *
8
+ * 三条设计约束:
9
+ * 1. **只写 `workspace_id`,不动 `scope`、不动 `updated_time`**。前者保可见性零变化
10
+ * (`cfg.scope='global'` 下 `isScopeVisible` 直接放行),后者保快照哈希零失配
11
+ * (见 `store/sqlite.ts` 的 `setL1WorkspaceIds` 注释,三条硬约束都在那儿)。
12
+ * 2. **宁缺勿猜**:路由不命中的记录保持原样,绝不给一个"默认归属"。归错项目会让记忆
13
+ * 在错误的工作区可见/不可见,比不归难查得多。
14
+ * 3. **有界可重入**:照 `wing-backfill` 的双护栏 + 扫描阶段也查墙钟 + 剩余留待再次触发;
15
+ * 且因不写 `updated_time`,按更新时间倒序分页**不会边扫边漂**。
16
+ */
17
+ import type { MemoryRecord } from '../types.js';
18
+ import type { WorkspaceRoute } from '../workspace-route.js';
19
+ /** re-link 需要的存储面(结构化声明,便于单测注入)。 */
20
+ export interface RelinkStoreFace {
21
+ list(opts: {
22
+ limit: number;
23
+ offset: number;
24
+ }): {
25
+ items: MemoryRecord[];
26
+ total: number;
27
+ };
28
+ setWorkspaceIds(items: ReadonlyArray<{
29
+ id: string;
30
+ workspaceId: string;
31
+ }>): number;
32
+ }
33
+ export interface RelinkReport {
34
+ dryRun: boolean;
35
+ /** 扫过的记录数(含未命中)。 */
36
+ scanned: number;
37
+ /** 路由命中的记录数。 */
38
+ matched: number;
39
+ /** 归属已是目标值、无需写的记录数(幂等重跑时等于 matched)。 */
40
+ unchanged: number;
41
+ /** 干跑时“若真跑会写多少条”;真写时恒为 0(那一步看 written)。 */
42
+ toWrite: number;
43
+ /** 实际写入条数(dryRun 时恒为 0)。 */
44
+ written: number;
45
+ /** 因预算未处理、留待下次触发的条数。 */
46
+ remaining: number;
47
+ /** 命中记录按归属的分布(先看这个数就知道路由表合不合口味)。 */
48
+ byWorkspace: Record<string, number>;
49
+ }
50
+ export declare function relinkWorkspaces(deps: {
51
+ l1: RelinkStoreFace;
52
+ routes: readonly WorkspaceRoute[];
53
+ dryRun?: boolean;
54
+ maxRecords?: number;
55
+ timeBudgetMs?: number;
56
+ logger?: {
57
+ info: (m: string) => void;
58
+ warn: (m: string) => void;
59
+ };
60
+ }): Promise<RelinkReport>;
@@ -0,0 +1,73 @@
1
+ import { routeWorkspace } from '../workspace-route.js';
2
+ import { yieldLoop } from '../util/yield.js';
3
+ const PAGE = 200;
4
+ /** 攒够一批再写:一批一个事务,避免逐条开事务的 fsync 开销。 */
5
+ const FLUSH = 500;
6
+ export async function relinkWorkspaces(deps) {
7
+ const { l1, routes, logger } = deps;
8
+ const dryRun = deps.dryRun !== false; // 缺省干跑:先看报告,再决定真写
9
+ const maxRecords = Math.max(1, Math.floor(deps.maxRecords ?? 300));
10
+ const budgetMs = Math.max(1000, Math.floor(deps.timeBudgetMs ?? 120_000));
11
+ const started = Date.now();
12
+ const overBudget = () => Date.now() - started > budgetMs;
13
+ const report = {
14
+ dryRun, scanned: 0, matched: 0, unchanged: 0, toWrite: 0, written: 0, remaining: 0, byWorkspace: {},
15
+ };
16
+ if (routes.length === 0)
17
+ return report;
18
+ let offset = 0;
19
+ let batch = [];
20
+ // 计数在此结算:`flush()` 会清空 batch,调用点再拿 `batch.length` 必然归零。
21
+ const flush = () => {
22
+ if (batch.length === 0)
23
+ return;
24
+ if (dryRun)
25
+ report.toWrite += batch.length;
26
+ else
27
+ report.written += l1.setWorkspaceIds(batch);
28
+ batch = [];
29
+ };
30
+ for (;;) {
31
+ if (report.scanned >= maxRecords)
32
+ break;
33
+ // 扫描阶段本身也查墙钟:1279 条不慢,但同一个护栏要能扛住更大的库。
34
+ if (overBudget())
35
+ break;
36
+ const page = l1.list({ limit: PAGE, offset });
37
+ if (page.items.length === 0)
38
+ break;
39
+ for (const rec of page.items) {
40
+ // 上限检查必须在**循环顶部**:放在末尾时,未命中的记录会先 `continue` 掉,
41
+ // 于是 maxRecords=1 实际能扫 2 条(实测翻出)——有界性得对所有分支都成立。
42
+ if (report.scanned >= maxRecords)
43
+ break;
44
+ report.scanned++;
45
+ const routed = routeWorkspace(`${rec.content}\n${rec.scene_name}`, routes);
46
+ if (routed === undefined)
47
+ continue;
48
+ report.matched++;
49
+ report.byWorkspace[routed] = (report.byWorkspace[routed] ?? 0) + 1;
50
+ if (rec.workspaceId === routed) {
51
+ report.unchanged++;
52
+ continue;
53
+ }
54
+ batch.push({ id: rec.id, workspaceId: routed });
55
+ if (batch.length >= FLUSH)
56
+ flush();
57
+ }
58
+ offset += page.items.length;
59
+ if (offset >= page.total)
60
+ break;
61
+ await yieldLoop(); // 让出事件循环:面板 RPC / 状态查询优先
62
+ }
63
+ flush();
64
+ // 剩余 = 总数 - 实扫条数。**不能用 offset 算**:中途按 maxRecords/预算 break 时,
65
+ // offset 已推进整个页(一页 PAGE 条),会把“还剩 2 条没扫”算成 0,
66
+ // 于是报告谎称已扫完——下次也就不会再触发续扫了。
67
+ const totalRows = l1.list({ limit: 1, offset: 0 }).total;
68
+ report.remaining = Math.max(0, totalRows - report.scanned);
69
+ logger?.info(`[memory] workspace re-link${dryRun ? '(干跑)' : ''}:扫 ${report.scanned} 条,命中 ${report.matched} 条,`
70
+ + `待写 ${report.toWrite} 条,实写 ${report.written} 条,`
71
+ + `剩 ${report.remaining} 条未扫(下次触发续);分布 ${JSON.stringify(report.byWorkspace)}`);
72
+ return report;
73
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * 会话「活召回集」服务端投影(memoryRecall)。
3
+ *
4
+ * 语义:本会话**已注入**的记忆,以及其中「此刻仍在上下文里」的那部分。
5
+ * 数据源是 `SessionRecalledStore`(sessionKey 键、写穿持久化),所以**旧会话重开也读得到**。
6
+ *
7
+ * 压缩/清空对这张表的作用不同(2026-10-13 用户口径):
8
+ * - `clear`(主动清空)整表清,view 随之空;
9
+ * - `compact`(摘要替换)**不删条目**,只把当时在列表里的那批标成 `superseded`(正文已被
10
+ * 摘要取代、已不在上下文里)。读侧据此把旧召回与当前召回区分开,而不是继续谎报“还在”。
11
+ * 已退场(`retired`)的条目必须留在列表里(人的裁决凭证),取代标记与它正交。
12
+ *
13
+ * 与 memorySlots 的分工(两条轴,互不替代):
14
+ * - `memorySlots` = 用户手动 pin 的**常驻约定**(每轮硬注入,注册表语义);
15
+ * - `memoryRecall` = 按相关性**隐式浮现**的召回结果(非常驻,本投影)。
16
+ * 召回记录绝不灌进 memorySlots:那会把"非常驻"伪装成槽位注册表。
17
+ *
18
+ * 会话感知靠 `init(header)`:宿主在 `session/created` 与冷加载 restore 两处都传
19
+ * `session.header`(`dsh-session/lib/index.js` 实证形状 `{ version, id, createdAt, … }`),
20
+ * `header.id` 即会话 id,与召回记账的 sessionKey(`agent.session?.id ?? agent.id`)同源。
21
+ * state 里带上 sessionId,apply 无 session 参数也能按本会话重读 store。
22
+ *
23
+ * 消费方是 dsh-brief-sidebar 的「本会话召回」分区(`useProjectionValue(ctx, sessionId, key)`)。
24
+ */
25
+ import type { Context } from '@deepseek-ai/cordis';
26
+ import type { SessionRecalledStore } from '../store/session-recalled.js';
27
+ import type { MemoryRecord } from '../types.js';
28
+ export declare const MEMORY_RECALL_KEY = "memoryRecall";
29
+ /** view 最多透出条数(最近在上;超出部分只计入 count,防 wire 体量失控)。 */
30
+ export declare const RECALL_VIEW_CAP = 20;
31
+ /** 一条召回记录在 wire view 上的呈现。 */
32
+ export interface RecallItemView {
33
+ id: string;
34
+ type: string;
35
+ /** 正文首行(截断至 TITLE_MAX);全文由消费方按需走面板读。 */
36
+ title: string;
37
+ priority: number;
38
+ /** 使用场景(metadata.dsh_usage_scenario);无则缺席(附加字段,旧帧没有也整条通过)。 */
39
+ scenario?: string[];
40
+ /**
41
+ * 人给的最近一次打分(-1..5);未标注则缺席。
42
+ * 卡片靠它把已打的那一笔显示成选中态;均值等细粒度数据走 `session-recalled` 端点。
43
+ */
44
+ rating?: number;
45
+ /**
46
+ * 正文全文(截断至 BODY_MAX);消费方悬停泡泡直接读它,不再回查端点。
47
+ * 缺席 = 旧帧(读侧退化成只显示 title)。
48
+ */
49
+ body?: string;
50
+ /**
51
+ * 该记录已被判定过时(`isRetired`)。只写 true:未退场时字段缺席,
52
+ * 读侧按「缺席即活跃」处理,旧帧自然兼容。
53
+ */
54
+ retired?: true;
55
+ /**
56
+ * 该条目的正文**已被摘要取代**(压缩发生时时它就在列表里),即不在模型上下文里了。
57
+ * 只写 true(同 `retired` 的兼容手法);与 `retired` 正交,两者可共存。
58
+ * 读侧据此把「已被摘要取代的旧召回」与「此刻仍在上下文里的召回」区分开。
59
+ */
60
+ superseded?: true;
61
+ }
62
+ /** 持久态:本会话的活召回 id 集 + 判脏序号。 */
63
+ export interface RecallProjectionState {
64
+ rev: number;
65
+ /**
66
+ * 反馈 sidecar 的序号(打分也会让 view 变:条目要显示已打的那一笔)。
67
+ * 可选:旧 checkpoint 帧里没这个字段,读侧必须容忍。
68
+ */
69
+ fb?: number;
70
+ sessionId: string;
71
+ /** 注入序(旧→新),由 store 保证。 */
72
+ ids: string[];
73
+ /**
74
+ * 已被摘要取代的 id(压缩水位之上的那批)。可选:旧 checkpoint 没有这个字段,
75
+ * 读侧必须容忍(缺席 = 无取代,与改动前逐字一致)。
76
+ */
77
+ superseded?: string[];
78
+ }
79
+ /** wire view:count 是总数,items 是**最近在上**的截断视图。 */
80
+ export interface RecallView {
81
+ rev: number;
82
+ sessionId: string;
83
+ count: number;
84
+ items: RecallItemView[];
85
+ }
86
+ /** 零依赖 schema(同 slots 投影的理由:registry 只调 `parse`,且按 Object.is 比较引用)。 */
87
+ interface SchemaLike<T> {
88
+ parse(value: unknown): T;
89
+ }
90
+ /** state 校验(registry 用它挡掉损坏/过期的 checkpoint 行)。 */
91
+ export declare const stateSchema: SchemaLike<RecallProjectionState>;
92
+ /** view 校验(出网前调用;未知键容忍,同 slots 投影的语义边界)。 */
93
+ export declare const viewSchema: SchemaLike<RecallView>;
94
+ /** 投影需要的 L1 只读面(结构化声明,避免跨模块类型耦合)。
95
+ * 判退场要 `validTo`/`metadata`,故直接 Pick 自 MemoryRecord,不在投影里自写时间比较。 */
96
+ export interface RecallL1Face {
97
+ getByIds(ids: string[]): Array<Pick<MemoryRecord, 'id' | 'type' | 'content' | 'priority' | 'metadata' | 'validTo'>>;
98
+ }
99
+ /** 投影需要的反馈只读面(缺席 = view 不带 rating,旧行为)。 */
100
+ export interface RecallFeedbackFace {
101
+ revision(): number;
102
+ lastOf(id: string): number | undefined;
103
+ }
104
+ /**
105
+ * 注册 memoryRecall 投影。
106
+ *
107
+ * 缺席降级同 slots 投影:`ctx.inject` 在服务未提供时不回调;形状不符静默不注册。
108
+ * apply 是 O(1) 判脏(store.revision() 与反馈序号比对),任意已提交事件都会带进来看一眼。
109
+ * @param feedback - 相关度反馈只读面;缺席时 view 不带 rating(旧行为)。
110
+ */
111
+ export declare function registerRecallProjection(ctx: Context, store: SessionRecalledStore, l1?: RecallL1Face, feedback?: RecallFeedbackFace): void;
112
+ export {};