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
@@ -97,6 +97,14 @@ export declare class EmbeddingManager {
97
97
  private applyBusy;
98
98
  private reindex;
99
99
  private reindexCancel;
100
+ /**
101
+ * 上一次重建的留痕(类型就地取自契约,不另开 import)。
102
+ *
103
+ * 为什么必须存:进度条只在 `running` 期间存在,而“待补 0 条”的重建会瞬间跑完,
104
+ * 界面上与“点了没反应”长得一模一样——真机反馈就是这么读的。留痕存**前后待补数**,
105
+ * 让“效果”是两个可比数字而不是一句“我转了一下”。
106
+ */
107
+ private reindexLast;
100
108
  /**
101
109
  * 向量计数缓存(分级 TTL)。
102
110
  *
@@ -187,6 +187,14 @@ export class EmbeddingManager {
187
187
  cancelled: false,
188
188
  };
189
189
  reindexCancel = false;
190
+ /**
191
+ * 上一次重建的留痕(类型就地取自契约,不另开 import)。
192
+ *
193
+ * 为什么必须存:进度条只在 `running` 期间存在,而“待补 0 条”的重建会瞬间跑完,
194
+ * 界面上与“点了没反应”长得一模一样——真机反馈就是这么读的。留痕存**前后待补数**,
195
+ * 让“效果”是两个可比数字而不是一句“我转了一下”。
196
+ */
197
+ reindexLast;
190
198
  /**
191
199
  * 向量计数缓存(分级 TTL)。
192
200
  *
@@ -471,6 +479,27 @@ export class EmbeddingManager {
471
479
  async reindexNow() {
472
480
  this.reindexCancel = false;
473
481
  this.reindex = { running: true, l1Done: 0, l1Total: 0, l0Done: 0, l0Total: 0, startedAt: Date.now(), cancelled: false };
482
+ // 重建**前**的待补数:先破缓存再取,否则拿到的可能是 TTL 里的旧值(那会让“前后差”假报 0)。
483
+ // 口径故意与界面用的同一个(相减法),不拿精确口径去对展示数字,否则两边永远对不上。
484
+ this.invalidateVecCache();
485
+ const v0 = this.vectorCounts();
486
+ const missingBefore = { l1: v0.l1.missing, l0: v0.l0.missing };
487
+ const finish = (cancelled, error) => {
488
+ this.invalidateVecCache();
489
+ const v1 = this.vectorCounts();
490
+ const at = Date.now();
491
+ this.reindexLast = {
492
+ finishedAt: at,
493
+ durationMs: Math.max(0, at - this.reindex.startedAt),
494
+ processed: this.reindex.l1Done + this.reindex.l0Done,
495
+ planned: this.reindex.l1Total + this.reindex.l0Total,
496
+ failed: failedTotal,
497
+ cancelled,
498
+ ...(error === undefined ? {} : { error }),
499
+ missingBefore,
500
+ missingAfter: { l1: v1.l1.missing, l0: v1.l0.missing },
501
+ };
502
+ };
474
503
  let failedTotal = 0;
475
504
  try {
476
505
  const r1 = await this.deps.l1.reindex({
@@ -493,6 +522,7 @@ export class EmbeddingManager {
493
522
  this.reindex.cancelled = cancelled;
494
523
  this.reindex.running = false;
495
524
  this.invalidateVecCache(); // 收尾:计数必须立刻反映重建结果(TTL 可能还压着 1s 旧值)
525
+ finish(cancelled);
496
526
  return { cancelled, failedTotal };
497
527
  }
498
528
  catch (err) {
@@ -500,6 +530,7 @@ export class EmbeddingManager {
500
530
  this.reindex.running = false;
501
531
  this.reindex.error = message;
502
532
  this.invalidateVecCache();
533
+ finish(false, message);
503
534
  return { cancelled: false, failedTotal, error: `重嵌入失败: ${message}` };
504
535
  }
505
536
  }
@@ -538,7 +569,7 @@ export class EmbeddingManager {
538
569
  download: this.downloader.getProgress(),
539
570
  apply: { phase: this.applyPhase, message: this.applyMessage, startedAt: this.applyStartedAt, busy: this.applyBusy },
540
571
  local: this.localSvc ? { state: this.localSvc.getState(), error: this.localSvc.getLoadError() } : null,
541
- reindex: { ...this.reindex },
572
+ reindex: { ...this.reindex, last: this.reindexLast },
542
573
  vectors: this.vectorsCached(),
543
574
  activeNote: this.activeNote,
544
575
  };
@@ -562,7 +593,13 @@ export class EmbeddingManager {
562
593
  const count = (kind) => {
563
594
  const skip = this.deps.db.getVecSkipSet(kind);
564
595
  const embedded = kind === 'l1' ? this.deps.db.countL1Vec() : this.deps.db.countL0Vec();
565
- const total = kind === 'l1' ? this.deps.db.countL1() : this.deps.db.countL0();
596
+ // 相减法还有第二条前提,之前漏了:**分母必须是活跃行**。退场行按设计就合法地没有
597
+ // 向量(撤检索面时 vec 行被 detach),把它们算进 total 会让 missing 恒等于退场数、
598
+ // 永不归零;而真正驱动补齐的 `countVecMissing`/`getL1ForReindex` 都排除退场行,于是
599
+ // 界面上同时出现“待补 114 条”与“重建索引无事可做”。本机实测:1280 总 / 114 退场 /
600
+ // 1166 活跃且全部已嵌,正是这个形状。`countL1Retired` 失败给 -1,按 0 处理退回旧行为。
601
+ const retired = kind === 'l1' ? Math.max(0, this.deps.db.countL1Retired()) : 0;
602
+ const total = (kind === 'l1' ? this.deps.db.countL1() : this.deps.db.countL0()) - retired;
566
603
  // 缺失数走**相减**而非 LEFT JOIN:见下方 vectorsCached 上方的说明。
567
604
  const missing = embedded < 0 || total < 0 ? -1 : Math.max(0, total - embedded - skip.size);
568
605
  return { embedded, total, missing, skipped: skip.size };
@@ -21,7 +21,11 @@ export declare const SESSION_MODES_FILE_VERSION = 1;
21
21
  export declare const OCCUPANCY_FILE_VERSION = 1;
22
22
  /** `recall-dedupe.json`。 */
23
23
  export declare const RECALL_DEDUPE_FILE_VERSION = 1;
24
+ /** `session-recalled.json`(G-live 活召回集,持久化以令旧会话重开可读)。 */
25
+ export declare const SESSION_RECALLED_FILE_VERSION = 1;
24
26
  /** `pending.json`。 */
25
27
  export declare const PENDING_FILE_VERSION = 1;
26
28
  /** `reconcile-state.json`。 */
27
29
  export declare const RECONCILE_STATE_FILE_VERSION = 1;
30
+ /** `mem-md-ledger.json`(md 投射指纹账本,N9;派生态,删了可重建)。*/
31
+ export declare const MEM_MD_LEDGER_FILE_VERSION = 1;
@@ -21,7 +21,11 @@ export const SESSION_MODES_FILE_VERSION = 1;
21
21
  export const OCCUPANCY_FILE_VERSION = 1;
22
22
  /** `recall-dedupe.json`。 */
23
23
  export const RECALL_DEDUPE_FILE_VERSION = 1;
24
+ /** `session-recalled.json`(G-live 活召回集,持久化以令旧会话重开可读)。 */
25
+ export const SESSION_RECALLED_FILE_VERSION = 1;
24
26
  /** `pending.json`。 */
25
27
  export const PENDING_FILE_VERSION = 1;
26
28
  /** `reconcile-state.json`。 */
27
29
  export const RECONCILE_STATE_FILE_VERSION = 1;
30
+ /** `mem-md-ledger.json`(md 投射指纹账本,N9;派生态,删了可重建)。*/
31
+ export const MEM_MD_LEDGER_FILE_VERSION = 1;
@@ -106,6 +106,20 @@ export declare class GraphStore {
106
106
  * 记录跳过。返回实际新建的 job 数。
107
107
  */
108
108
  queueGraphProjection(recordIds: readonly string[], priority: number): number;
109
+ /**
110
+ * 记录被物理删除后,把两张**索引表**里指向它的行一并抹掉。
111
+ *
112
+ * 为什么必需(F19):`graph_projected_records` 之前全仓**只增不减**。记录行删了而投影行还在,
113
+ * 就留下两类静默问题:① 投影进度表里挂着一批查不到主人的 record_id;② 从快照**恢复**回来的
114
+ * 记录因为投影行已存在而被当成“已投影”,**永远不会重投影**(检索图里就这么缺一条,不报错)。
115
+ *
116
+ * 只删索引行,**不动图节点/边**:节点是跨记录聚合出来的事实,归图自己的生命周期管。
117
+ * 失败只 warn 并返回已删数——清理的既成事实不应被旁路索引拖挂。
118
+ *
119
+ * @param ids - 已被物理删除的记录 id。
120
+ * @returns 从投影表删掉的行数。
121
+ */
122
+ forgetRecords(ids: readonly string[]): number;
109
123
  /**
110
124
  * 取下一个可执行 job 并置 running(attempts +1):只认当前 projectorVersion,
111
125
  * attempts 封顶与退避窗口在 WHERE 里过滤;来源全缺失 → job 判 dead 返 null
@@ -514,6 +514,41 @@ export class GraphStore {
514
514
  return 0;
515
515
  }
516
516
  }
517
+ /**
518
+ * 记录被物理删除后,把两张**索引表**里指向它的行一并抹掉。
519
+ *
520
+ * 为什么必需(F19):`graph_projected_records` 之前全仓**只增不减**。记录行删了而投影行还在,
521
+ * 就留下两类静默问题:① 投影进度表里挂着一批查不到主人的 record_id;② 从快照**恢复**回来的
522
+ * 记录因为投影行已存在而被当成“已投影”,**永远不会重投影**(检索图里就这么缺一条,不报错)。
523
+ *
524
+ * 只删索引行,**不动图节点/边**:节点是跨记录聚合出来的事实,归图自己的生命周期管。
525
+ * 失败只 warn 并返回已删数——清理的既成事实不应被旁路索引拖挂。
526
+ *
527
+ * @param ids - 已被物理删除的记录 id。
528
+ * @returns 从投影表删掉的行数。
529
+ */
530
+ forgetRecords(ids) {
531
+ if (!this.db || ids.length === 0)
532
+ return 0;
533
+ const db = this.db;
534
+ let removed = 0;
535
+ try {
536
+ // 分批:IN 列表不能无限长(与入队/去重查询同一个口径)
537
+ for (let i = 0; i < ids.length; i += 500) {
538
+ const chunk = ids.slice(i, i + 500);
539
+ const ph = chunk.map(() => '?').join(',');
540
+ db.prepare(`DELETE FROM graph_projected_records WHERE record_id IN (${ph})`).run(...chunk);
541
+ const jobInfo = db.prepare(`DELETE FROM graph_job_records WHERE record_id IN (${ph})`).run(...chunk);
542
+ removed += chunk.length;
543
+ void jobInfo;
544
+ }
545
+ return removed;
546
+ }
547
+ catch (err) {
548
+ this.logger?.warn(`${TAG} 图索引遗录清理失败(忽略): ${err instanceof Error ? err.message : String(err)}`);
549
+ return removed;
550
+ }
551
+ }
517
552
  /**
518
553
  * 取下一个可执行 job 并置 running(attempts +1):只认当前 projectorVersion,
519
554
  * attempts 封顶与退避窗口在 WHERE 里过滤;来源全缺失 → job 判 dead 返 null
@@ -1,3 +1,4 @@
1
+ import { type ArchiveFileOutcome, type FilesByRecord } from './memory-md-archive.js';
1
2
  import type { MemoryLogger, MemoryRecord } from '../types.js';
2
3
  import type { ConflictPair } from './conflicts.js';
3
4
  export declare const SNAPSHOT_VERSION = 1;
@@ -246,6 +247,30 @@ export interface ExportThenPurgeResult {
246
247
  purged: number;
247
248
  /** 校验差异(仅 aborted 时非空)。 */
248
249
  diffs: string[];
250
+ /**
251
+ * 本批之外发生漂移的记录数(仅计数、**不中止**)。
252
+ *
253
+ * 旧实现拿整库哈希当门禁:并发蒸馏/另一处写入改过任何一条与本批无关的记录,
254
+ * 本次清理就会永远中止(而它要删的那批逐字未动、完全可恢复)。漂移信息仍要报,
255
+ * 但它不是删除本批的安全障碍。
256
+ */
257
+ otherChanged: number;
258
+ /** 归档正文路径(未写 = 空串)。 */
259
+ archiveMd: string;
260
+ /** 投射文件搬运计数(N12)。 */
261
+ files: ArchiveFileOutcome;
262
+ }
263
+ /** 清理时的可选动作(P3)。 */
264
+ export interface PurgeOptions {
265
+ /**
266
+ * 投射文件映射(由 `collectProjectionFiles` 产出)。
267
+ *
268
+ * **不传 = 只删库,行为与改动前逐字一致**——投射面默认关,归档搬运不能变成它的前置条
269
+ * 件。传了就在全部门禁通过后把文件搬进快照目录,失败只 warn。
270
+ */
271
+ files?: FilesByRecord;
272
+ /** 是否写 `retired.md` 归档正文(默认 true)。 */
273
+ archiveMd?: boolean;
249
274
  }
250
275
  /**
251
276
  * **先导出,后清理**——把这句话变成调用方绕不过去的一步。
@@ -263,4 +288,4 @@ export interface ExportThenPurgeResult {
263
288
  export declare function exportThenPurge(db: PurgeDbLike, dataDir: string, ids: readonly string[], reason: string, logger?: {
264
289
  info: (m: string) => void;
265
290
  warn: (m: string) => void;
266
- }, now?: Date): Promise<ExportThenPurgeResult>;
291
+ }, now?: Date, opts?: PurgeOptions): Promise<ExportThenPurgeResult>;
@@ -33,6 +33,7 @@
33
33
  * 等于把证据链悄悄拆了。这条有专门用例。
34
34
  */
35
35
  import { createHash } from 'node:crypto';
36
+ import { moveProjectionFiles, writeArchiveMarkdown } from './memory-md-archive.js';
36
37
  import { readdir } from 'node:fs/promises';
37
38
  import { atomicWriteJson, readJsonStrict } from '../util/io.js';
38
39
  export const SNAPSHOT_VERSION = 1;
@@ -368,9 +369,10 @@ export async function snapshotBeforeClear(db, dataDir, reason, now = new Date())
368
369
  * 而清理一旦没有可信的导出物,就只剩 `records/*.jsonl` 事实源这一条后路,
369
370
  * 且那条路只覆盖 L1 记录、不覆盖 receipts/conflicts 的当时快照。
370
371
  */
371
- export async function exportThenPurge(db, dataDir, ids, reason, logger, now = new Date()) {
372
+ export async function exportThenPurge(db, dataDir, ids, reason, logger, now = new Date(), opts = {}) {
373
+ const empty = { moved: 0, failed: 0, absent: 0 };
372
374
  if (ids.length === 0)
373
- return { ok: true, aborted: false, dir: '', name: '', purged: 0, diffs: [] };
375
+ return { ok: true, aborted: false, dir: '', name: '', purged: 0, diffs: [], otherChanged: 0, archiveMd: '', files: empty };
374
376
  let dir = '';
375
377
  let name = '';
376
378
  try {
@@ -381,14 +383,57 @@ export async function exportThenPurge(db, dataDir, ids, reason, logger, now = ne
381
383
  catch (err) {
382
384
  const msg = err instanceof Error ? err.message : String(err);
383
385
  logger?.warn(`[memory] 清理中止:快照写入失败(${msg})——未删除任何记录`);
384
- return { ok: false, aborted: true, dir, name, purged: 0, diffs: [`快照写入失败:${msg}`] };
386
+ return { ok: false, aborted: true, dir, name, purged: 0, diffs: [`快照写入失败:${msg}`], otherChanged: 0, archiveMd: '', files: empty };
385
387
  }
386
- const verdict = await verifySnapshot(db, dir);
387
- if (!verdict.ok) {
388
- logger?.warn(`[memory] 清理中止:快照校验未通过(${verdict.diffs.join(';')})——未删除任何记录`);
389
- return { ok: false, aborted: true, dir, name, purged: 0, diffs: verdict.diffs };
388
+ // 门禁第二半——**批次级投影**。不再拿整库哈希当门禁:那会把“本批逐字未动、但库里有
389
+ // 别的东西被并发改过”误判成不安全,而事实恰好相反——要删的那批在快照里完整可信,
390
+ // 删错了能原样回灌。整库漂移仍计数(它是要给人看的信息),但它不拦住删除。
391
+ let subset = [];
392
+ let otherChanged = 0;
393
+ try {
394
+ const want = new Set(ids);
395
+ const snapAll = await readSnapshotRecords(dir);
396
+ subset = snapAll.filter((r) => want.has(r.id));
397
+ const current = listAllL1(db);
398
+ const curSubset = current.filter((r) => want.has(r.id));
399
+ const snapRest = snapAll.filter((r) => !want.has(r.id));
400
+ const curRest = current.filter((r) => !want.has(r.id));
401
+ otherChanged = hashRecords(snapRest) === hashRecords(curRest) ? 0 : curRest.length;
402
+ const diffs = [];
403
+ if (subset.length !== want.size)
404
+ diffs.push(`快照里本批只有 ${subset.length}/${want.size} 条`);
405
+ if (hashRecords(subset) !== hashRecords(curSubset))
406
+ diffs.push('本批内容与快照不一致(快照后这批被改过)');
407
+ if (diffs.length > 0) {
408
+ logger?.warn(`[memory] 清理中止:批次校验未通过(${diffs.join(';')})——未删除任何记录`);
409
+ return { ok: false, aborted: true, dir, name, purged: 0, diffs, otherChanged, archiveMd: '', files: empty };
410
+ }
411
+ if (otherChanged > 0)
412
+ logger?.warn(`[memory] 清理放行:本批之外有 ${otherChanged} 条处于差异状态(不影响本批可恢复性)`);
413
+ }
414
+ catch (err) {
415
+ const msg = err instanceof Error ? err.message : String(err);
416
+ logger?.warn(`[memory] 清理中止:快照读回失败(${msg})——未删除任何记录`);
417
+ return { ok: false, aborted: true, dir, name, purged: 0, diffs: [`快照读回失败:${msg}`], otherChanged, files: empty, archiveMd: '' };
390
418
  }
391
419
  const purged = db.deleteL1Batch([...ids]);
392
420
  logger?.info(`[memory] 已物理清理 ${purged} 条已退场记录(快照:${dir})`);
393
- return { ok: true, aborted: false, dir, name, purged, diffs: [] };
421
+ // 归档两面:先写给人读的正文,再搬投射文件。两者失败都**不回滚已完成的删除**
422
+ // (不对称的方向特别重要:文件可重生成,库删了不可逆,而快照已经先落盘了)。
423
+ let archiveMd = '';
424
+ if (opts.archiveMd !== false) {
425
+ try {
426
+ archiveMd = await writeArchiveMarkdown(dir, subset, { reason, at: now });
427
+ }
428
+ catch (err) {
429
+ logger?.warn(`[memory] 归档正文写入失败(记录已删,快照仍在): ${dir} - ${err instanceof Error ? err.message : String(err)}`);
430
+ }
431
+ }
432
+ const files = opts.files === undefined
433
+ ? empty
434
+ : await moveProjectionFiles(dir, subset, opts.files, logger).catch((err) => {
435
+ logger?.warn(`[memory] 归档搬运异常(文件留在原地): ${err instanceof Error ? err.message : String(err)}`);
436
+ return empty;
437
+ });
438
+ return { ok: true, aborted: false, dir, name, purged, diffs: [], otherChanged, archiveMd, files };
394
439
  }
@@ -3,7 +3,7 @@ import type { GraphNodeSearchResult } from '../graph/types.js';
3
3
  import type { L1Receipt, ReceiptQuery } from './receipts.js';
4
4
  import type { ConflictClaimGroup, ConflictPair, ConflictRejected, ConflictResolution, ConflictType } from './conflicts.js';
5
5
  import { type SupersedeInfo } from './supersede.js';
6
- import { type ExportThenPurgeResult, type RestoreResult, type SnapshotRestorePlan, type SnapshotSummary } from './l1-snapshot.js';
6
+ import { type ExportThenPurgeResult, type PurgeOptions, type RestoreResult, type SnapshotRestorePlan, type SnapshotSummary } from './l1-snapshot.js';
7
7
  import { type EmbeddingService } from './embedding.js';
8
8
  import { type L1MetaLite, type MemoryDb } from './sqlite.js';
9
9
  export type RecallStrategy = 'keyword' | 'embedding' | 'hybrid';
@@ -50,6 +50,14 @@ export interface SnapshotRestoreOutcome extends RestoreResult {
50
50
  export declare class L1Store {
51
51
  private readonly db;
52
52
  private readonly strategy;
53
+ /**
54
+ * “该不该给没名字的新记录起名”的实时判据(装配层递 `() => cfg.memExport.enabled`)。
55
+ *
56
+ * 用函数而不是布尔:设置面可热改,存下快照就会与当前开关漂移。
57
+ * 不传 = 不主动起名(“投射关着时零动作”这条既有承诺不改),但**别人占着的名字照样修**
58
+ * ——那是防丢数据,与开关无关。
59
+ */
60
+ private readonly autoName?;
53
61
  /** 记忆库根目录(`records/` 与 `snapshots/` 都在它下面)。 */
54
62
  private readonly dataDir;
55
63
  private readonly recordsDir;
@@ -69,7 +77,15 @@ export declare class L1Store {
69
77
  /** 时效衰减半衰期(天;0=关)。缺省 30 与 config 默认一致。 */
70
78
  decayHalfLifeDays?: number,
71
79
  /** 图谱路提供者(§D 第 3 路);不传则该路不存在,融合退回双路。 */
72
- graphLane?: GraphLaneProvider);
80
+ graphLane?: GraphLaneProvider,
81
+ /**
82
+ * “该不该给没名字的新记录起名”的实时判据(装配层递 `() => cfg.memExport.enabled`)。
83
+ *
84
+ * 用函数而不是布尔:设置面可热改,存下快照就会与当前开关漂移。
85
+ * 不传 = 不主动起名(“投射关着时零动作”这条既有承诺不改),但**别人占着的名字照样修**
86
+ * ——那是防丢数据,与开关无关。
87
+ */
88
+ autoName?: (() => boolean) | undefined);
73
89
  /** 读路径时效半衰期(天;0=关)。老化权重展示与召回衰减同一事实源。 */
74
90
  get decayHalfLife(): number;
75
91
  init(): Promise<void>;
@@ -97,6 +113,62 @@ export declare class L1Store {
97
113
  repoKeyName?: string;
98
114
  repoKeyOwner?: string;
99
115
  }, expectRepoKeyName?: string): number;
116
+ /**
117
+ * 批量写内容路由得出的 workspace 归属(不触发场景重聚类——归属变化不影响场景划分,
118
+ * 与 repoKey 那条不一样)。具体约束(不碰 updated_time、同步改 l1_fts)在
119
+ * `store/sqlite.ts` 的 `setL1WorkspaceIds` 里,那里是硬约束的**唯一**落点。
120
+ */
121
+ setWorkspaceIds(items: ReadonlyArray<{
122
+ id: string;
123
+ workspaceId: string;
124
+ }>): number;
125
+ /**
126
+ * 改一条记忆的文件身份名(N1)。形状校验在调用方(`normalizeName`),唯一性由
127
+ * 存储层的部分唯一索引挡——撞名就返 `{ ok: false, reason: 'dup' }`,**不自动加后缀**。
128
+ * 改名不触发场景重聚类,也不动 `updated_time`(约束在 `renameL1` 里)。
129
+ */
130
+ rename(id: string, name: string): {
131
+ ok: boolean;
132
+ reason?: string;
133
+ };
134
+ /** 按名字反查 id(重名预检 / md 回灌);未命中 ⇒ undefined。 */
135
+ findIdByName(name: string): string | undefined;
136
+ /**
137
+ * 给一批**名字缺失或被别人占着**的记录分配文件身份名(N1 的机器起名,全库唯一判据的唯一真源)。
138
+ *
139
+ * 为什么必须做:`deriveName` 只取正文首行前 24 字,同一段话的多条记录必然洗出同名。
140
+ * 真机实测 1166 条记录 `name` 全空、42 条挤进 17 个同名投射文件(最狠一个路径 6 条),
141
+ * 后果是导出每轮重刷被覆盖的那几条、反向同步会把一份内容回灌给六条记录。不回填 name,
142
+ * P0 加的这一列与部分唯一索引就一直是空转。
143
+ *
144
+ * “要起名”的判据不是“name 为空”,而是两种都要管:
145
+ * - **空名**(存量与“抽取时没起名”的新行)——只在 `autoName()` 为真时补;
146
+ * - **名字属于库里另一条**——无条件重命。这一类不是想象:调用方拿 `{ ...旧记录, id: 新 id }`
147
+ * 复制新记录(去重 merge / 取代路径就这么写),旧记录刚被本函数填上 name;不重命的话
148
+ * 新记录拿着别人占着的名字去写库,**撞部分唯一索引 → 整条写不进检索库**(旧记录已退场、
149
+ * 取代者根本不存,两个自家用例就是这么挂的)。
150
+ *
151
+ * 三条约束:
152
+ * - **不覆写属于它自己的名字**:人的命名是权威;
153
+ * - **只改 name**:不碰内容/类型/退场态,也不进快照哈希字段表(`canonicalRecords` 里没有
154
+ * `name`),所以这一步不会弄挂「导出不改检索面」的护栏;
155
+ * - **按 id 排序后再分配**:同一个基底谁先拿到裸名由顺序决定,不钉死会给同一条记录
156
+ * 两次分到不同名字,文件在盘上跳来跳去。
157
+ *
158
+ * @param records - 待起名的记录(**就地改 `.name`**,内存与库保持一致)。
159
+ * @param opts.persist - true 走 `renameL1Batch` 落库(存量回填);false 只算名不落库
160
+ * (新增记录还没进库,名字由随后的 upsert 一并写)。
161
+ * @param opts.fillEmpty - 是否给**空名**起名(默认按 `autoName()` 判)。“名字属于别人”那类
162
+ * 不受此参数影响,永远修。
163
+ * @returns `named` 本轮起名条数,`failed` 落库失败条数(persist=false 时恒为 0)。
164
+ */
165
+ assignMissingNames(records: MemoryRecord[], opts?: {
166
+ persist?: boolean;
167
+ fillEmpty?: boolean;
168
+ }): {
169
+ named: number;
170
+ failed: number;
171
+ };
100
172
  /**
101
173
  * repo 归属变化 → 受影响场景的重聚类作业(治理 W3,repo-change 触发源)。
102
174
  * 归属变了,记忆对场景的归属度就变了——受影响场景的摘要必须由当前事实重算
@@ -142,6 +214,8 @@ export declare class L1Store {
142
214
  };
143
215
  /** 按 id 精确取记录(去重决策的版本号查询用,避免全表扫描)。 */
144
216
  getByIds(ids: string[]): MemoryRecord[];
217
+ /** 外部导入幂等 pre-check(轨3-P0):ext_key 命中已存在记录则返回其 id,否则 undefined。 */
218
+ findIdByExtKey(extKey: string): string | undefined;
145
219
  /**
146
220
  * §B 决策凭证落盘(L1Store 的薄缝)。
147
221
  * 刻意放在 store 上:`runExtraction` 已经持有 L1Store,凭证写入因此无需新增
@@ -253,7 +327,7 @@ export declare class L1Store {
253
327
  * 门禁本体在 `l1-snapshot.exportThenPurge`(与"重建前必快照"同一套设施);
254
328
  * 这里只把 L1Store 已知的 dataDir 与 logger 接上去,避免端点层自己去推路径。
255
329
  */
256
- purgeRetired(ids: string[], reason: string): Promise<ExportThenPurgeResult>;
330
+ purgeRetired(ids: string[], reason: string, opts?: PurgeOptions): Promise<ExportThenPurgeResult>;
257
331
  /** 可用快照列表(按时间倒序;面板/工具据此选一份来恢复)。 */
258
332
  listSnapshots(opts?: {
259
333
  limit?: number;
package/dist/store/l1.js CHANGED
@@ -14,6 +14,7 @@ import { isRetired } from './supersede.js';
14
14
  import { exportThenPurge, readSnapshotManifest, readSnapshotRecords, restoreL1Snapshot, selectSnapshotTargets, snapshotDirFor, listSnapshots as listSnapshotsIn } from './l1-snapshot.js';
15
15
  import { EmbedHelper, NoopEmbeddingService } from './embedding.js';
16
16
  import { appendJsonl, dayKey, ensureDir, readJsonl } from '../util/io.js';
17
+ import { allocUniqueName, deriveName } from '../util/md-name.js';
17
18
  import { applyDecayWeight, normalizeRrf, rrfMerge, markDedupPath } from './search-utils.js';
18
19
  import { isZeroVector } from './sqlite.js';
19
20
  /** Room 计数缓存 TTL(tags 只在反刍标签段变化;面板常开也不必每次敲库)。 */
@@ -23,6 +24,7 @@ const CANDIDATE_MULTIPLIER = 3;
23
24
  export class L1Store {
24
25
  db;
25
26
  strategy;
27
+ autoName;
26
28
  /** 记忆库根目录(`records/` 与 `snapshots/` 都在它下面)。 */
27
29
  dataDir;
28
30
  recordsDir;
@@ -44,9 +46,18 @@ export class L1Store {
44
46
  /** 时效衰减半衰期(天;0=关)。缺省 30 与 config 默认一致。 */
45
47
  decayHalfLifeDays,
46
48
  /** 图谱路提供者(§D 第 3 路);不传则该路不存在,融合退回双路。 */
47
- graphLane) {
49
+ graphLane,
50
+ /**
51
+ * “该不该给没名字的新记录起名”的实时判据(装配层递 `() => cfg.memExport.enabled`)。
52
+ *
53
+ * 用函数而不是布尔:设置面可热改,存下快照就会与当前开关漂移。
54
+ * 不传 = 不主动起名(“投射关着时零动作”这条既有承诺不改),但**别人占着的名字照样修**
55
+ * ——那是防丢数据,与开关无关。
56
+ */
57
+ autoName) {
48
58
  this.db = db;
49
59
  this.strategy = strategy;
60
+ this.autoName = autoName;
50
61
  this.dataDir = dataDir;
51
62
  this.recordsDir = path.join(dataDir, 'records');
52
63
  this.legacyFile = path.join(dataDir, 'l1', 'records.jsonl');
@@ -128,6 +139,111 @@ export class L1Store {
128
139
  this.enqueueRepoChangeRecluster([id]);
129
140
  return changed;
130
141
  }
142
+ /**
143
+ * 批量写内容路由得出的 workspace 归属(不触发场景重聚类——归属变化不影响场景划分,
144
+ * 与 repoKey 那条不一样)。具体约束(不碰 updated_time、同步改 l1_fts)在
145
+ * `store/sqlite.ts` 的 `setL1WorkspaceIds` 里,那里是硬约束的**唯一**落点。
146
+ */
147
+ setWorkspaceIds(items) {
148
+ return this.db.setL1WorkspaceIds(items);
149
+ }
150
+ /**
151
+ * 改一条记忆的文件身份名(N1)。形状校验在调用方(`normalizeName`),唯一性由
152
+ * 存储层的部分唯一索引挡——撞名就返 `{ ok: false, reason: 'dup' }`,**不自动加后缀**。
153
+ * 改名不触发场景重聚类,也不动 `updated_time`(约束在 `renameL1` 里)。
154
+ */
155
+ rename(id, name) {
156
+ const r = this.db.renameL1(id, name);
157
+ if (r === 'ok')
158
+ return { ok: true };
159
+ return { ok: false, reason: r };
160
+ }
161
+ /** 按名字反查 id(重名预检 / md 回灌);未命中 ⇒ undefined。 */
162
+ findIdByName(name) {
163
+ return this.db.findRecordIdByName(name);
164
+ }
165
+ /**
166
+ * 给一批**名字缺失或被别人占着**的记录分配文件身份名(N1 的机器起名,全库唯一判据的唯一真源)。
167
+ *
168
+ * 为什么必须做:`deriveName` 只取正文首行前 24 字,同一段话的多条记录必然洗出同名。
169
+ * 真机实测 1166 条记录 `name` 全空、42 条挤进 17 个同名投射文件(最狠一个路径 6 条),
170
+ * 后果是导出每轮重刷被覆盖的那几条、反向同步会把一份内容回灌给六条记录。不回填 name,
171
+ * P0 加的这一列与部分唯一索引就一直是空转。
172
+ *
173
+ * “要起名”的判据不是“name 为空”,而是两种都要管:
174
+ * - **空名**(存量与“抽取时没起名”的新行)——只在 `autoName()` 为真时补;
175
+ * - **名字属于库里另一条**——无条件重命。这一类不是想象:调用方拿 `{ ...旧记录, id: 新 id }`
176
+ * 复制新记录(去重 merge / 取代路径就这么写),旧记录刚被本函数填上 name;不重命的话
177
+ * 新记录拿着别人占着的名字去写库,**撞部分唯一索引 → 整条写不进检索库**(旧记录已退场、
178
+ * 取代者根本不存,两个自家用例就是这么挂的)。
179
+ *
180
+ * 三条约束:
181
+ * - **不覆写属于它自己的名字**:人的命名是权威;
182
+ * - **只改 name**:不碰内容/类型/退场态,也不进快照哈希字段表(`canonicalRecords` 里没有
183
+ * `name`),所以这一步不会弄挂「导出不改检索面」的护栏;
184
+ * - **按 id 排序后再分配**:同一个基底谁先拿到裸名由顺序决定,不钉死会给同一条记录
185
+ * 两次分到不同名字,文件在盘上跳来跳去。
186
+ *
187
+ * @param records - 待起名的记录(**就地改 `.name`**,内存与库保持一致)。
188
+ * @param opts.persist - true 走 `renameL1Batch` 落库(存量回填);false 只算名不落库
189
+ * (新增记录还没进库,名字由随后的 upsert 一并写)。
190
+ * @param opts.fillEmpty - 是否给**空名**起名(默认按 `autoName()` 判)。“名字属于别人”那类
191
+ * 不受此参数影响,永远修。
192
+ * @returns `named` 本轮起名条数,`failed` 落库失败条数(persist=false 时恒为 0)。
193
+ */
194
+ assignMissingNames(records, opts = {}) {
195
+ const fillEmpty = opts.fillEmpty ?? (this.autoName?.() === true);
196
+ // 名字属于库里另一条的(含被 `{...旧记录}` 复制走的),一律重新分配。
197
+ const foreignName = (r) => {
198
+ const n = (r.name ?? '').trim();
199
+ if (n.length === 0)
200
+ return false;
201
+ const owner = this.db.findRecordIdByName(n);
202
+ return owner !== undefined && owner !== r.id;
203
+ };
204
+ const emptyName = (r) => (r.name ?? '').trim().length === 0;
205
+ const needSet = new Set();
206
+ for (const r of records) {
207
+ if (foreignName(r) || (fillEmpty && emptyName(r)))
208
+ needSet.add(r);
209
+ }
210
+ const need = records.filter((r) => needSet.has(r)).sort((a, b) => a.id.localeCompare(b.id));
211
+ if (need.length === 0)
212
+ return { named: 0, failed: 0 };
213
+ // 占用集:本批里**不需要重新分配**的那些名字(它们确实是自己的)+ 本轮刚分出去的。
214
+ const reserved = new Set();
215
+ for (const r of records) {
216
+ if (needSet.has(r))
217
+ continue;
218
+ const n = (r.name ?? '').trim();
219
+ if (n.length > 0)
220
+ reserved.add(n);
221
+ }
222
+ const needIds = new Set(need.map((r) => r.id));
223
+ const isFree = (name) => {
224
+ const owner = this.db.findRecordIdByName(name);
225
+ // 持者是本轮正要起名的某条自己(存量回填时它们已在库里),不算占。
226
+ return owner === undefined || needIds.has(owner);
227
+ };
228
+ const items = [];
229
+ for (const r of need) {
230
+ // 基底始终取正文,而不是取那个“属于别人的旧名字”。
231
+ const name = allocUniqueName(deriveName(r.content), r.id, isFree, reserved);
232
+ r.name = name;
233
+ items.push({ id: r.id, name });
234
+ }
235
+ if (opts.persist !== true)
236
+ return { named: items.length, failed: 0 };
237
+ const { done, failed } = this.db.renameL1Batch(items);
238
+ // 落库失败的条目把内存侧名字收回:否则内存与库不一致,下一轮又当“已有名字”而永不重试。
239
+ if (failed.length > 0) {
240
+ const bad = new Set(failed);
241
+ for (const r of need)
242
+ if (bad.has(r.id))
243
+ r.name = '';
244
+ }
245
+ return { named: done.length, failed: failed.length };
246
+ }
131
247
  /**
132
248
  * repo 归属变化 → 受影响场景的重聚类作业(治理 W3,repo-change 触发源)。
133
249
  * 归属变了,记忆对场景的归属度就变了——受影响场景的摘要必须由当前事实重算
@@ -229,6 +345,10 @@ export class L1Store {
229
345
  getByIds(ids) {
230
346
  return this.db.getL1ByIds(ids);
231
347
  }
348
+ /** 外部导入幂等 pre-check(轨3-P0):ext_key 命中已存在记录则返回其 id,否则 undefined。 */
349
+ findIdByExtKey(extKey) {
350
+ return this.db.findRecordIdByExtKey(extKey);
351
+ }
232
352
  /**
233
353
  * §B 决策凭证落盘(L1Store 的薄缝)。
234
354
  * 刻意放在 store 上:`runExtraction` 已经持有 L1Store,凭证写入因此无需新增
@@ -310,6 +430,9 @@ export class L1Store {
310
430
  if (!r.family)
311
431
  r.family = familyForType(r.type);
312
432
  }
433
+ // 起名必须在**追加事实源之前**:名字是文件身份键,进了 JSONL 才能跟记录一起重生。
434
+ // 这批还没进库,所以只算名字不落库(persist=false),由下面的 upsert 一并写。
435
+ this.assignMissingNames(records, { persist: false });
313
436
  const byDay = new Map();
314
437
  for (const r of records) {
315
438
  const k = dayKey(r.createdAt || Date.now());
@@ -334,6 +457,8 @@ export class L1Store {
334
457
  async upsert(record) {
335
458
  if (!record.family)
336
459
  record.family = familyForType(record.type);
460
+ // merge 分支会继承胜方的 name;两边都没名字时在这里补上,别让新铸的行继续带空名。
461
+ this.assignMissingNames([record], { persist: false });
337
462
  const vec = (await this.helper.batch([record.content]))[0];
338
463
  if (!this.db.upsertL1(record, vec)) {
339
464
  this.logger?.error(`[memory] L1 检索库写入失败 id=${record.id}(JSONL 事实源完好),该记忆暂不可检索,重建可修复`);
@@ -400,13 +525,13 @@ export class L1Store {
400
525
  * 门禁本体在 `l1-snapshot.exportThenPurge`(与"重建前必快照"同一套设施);
401
526
  * 这里只把 L1Store 已知的 dataDir 与 logger 接上去,避免端点层自己去推路径。
402
527
  */
403
- async purgeRetired(ids, reason) {
528
+ async purgeRetired(ids, reason, opts = {}) {
404
529
  // **只清理确实处于退场态的记录**。这道复核必须在删除发生的地方(而不是调用方):
405
530
  // 否则任何调用方传一个 id 列表就能绕过软删、把活动记忆直接物理抹掉——
406
531
  // 那等于给"先导出后清理"留了一条硬删后门。
407
532
  const known = new Map(this.db.getL1ByIds(ids).map((r) => [r.id, r]));
408
533
  const retiredIds = ids.filter((id) => known.get(id)?.validTo !== undefined);
409
- return exportThenPurge(this.db, this.dataDir, retiredIds, reason, this.logger);
534
+ return exportThenPurge(this.db, this.dataDir, retiredIds, reason, this.logger, new Date(), opts);
410
535
  }
411
536
  // ───────────────────────────────────────────────────────────────────────────
412
537
  // 快照的**读与回灌**(task_27):`purgeRetired` 会先落快照,但只落不接等于