dsh-prime-memory 0.22.2 → 0.24.1

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 (104) hide show
  1. package/CHANGELOG.en.md +421 -0
  2. package/CHANGELOG.ja.md +416 -0
  3. package/CHANGELOG.ko.md +416 -0
  4. package/CHANGELOG.md +120 -0
  5. package/dist/client.js +1266 -845
  6. package/dist/config.d.ts +131 -1341
  7. package/dist/config.js +89 -4
  8. package/dist/conflict-service.d.ts +7 -1
  9. package/dist/conflict-service.js +7 -1
  10. package/dist/contract.d.ts +211 -0
  11. package/dist/domain-gate.d.ts +11 -0
  12. package/dist/domain-gate.js +15 -0
  13. package/dist/hooks/obsolete-notice.d.ts +26 -0
  14. package/dist/hooks/obsolete-notice.js +64 -0
  15. package/dist/hooks/recall.d.ts +39 -0
  16. package/dist/hooks/recall.js +110 -30
  17. package/dist/index.d.ts +1 -1127
  18. package/dist/index.js +52 -2
  19. package/dist/memory-types.d.ts +69 -0
  20. package/dist/memory-types.js +165 -0
  21. package/dist/memport/format.d.ts +6 -1
  22. package/dist/memport/format.js +42 -2
  23. package/dist/pipeline/l1.d.ts +44 -2
  24. package/dist/pipeline/l1.js +163 -9
  25. package/dist/pipeline/md-sync.d.ts +65 -0
  26. package/dist/pipeline/md-sync.js +456 -0
  27. package/dist/pipeline/ruminate.d.ts +46 -1
  28. package/dist/pipeline/ruminate.js +109 -0
  29. package/dist/pipeline/runner.d.ts +10 -0
  30. package/dist/pipeline/runner.js +13 -2
  31. package/dist/pipeline/workspace-relink.d.ts +60 -0
  32. package/dist/pipeline/workspace-relink.js +73 -0
  33. package/dist/projection/recall.d.ts +51 -13
  34. package/dist/projection/recall.js +47 -12
  35. package/dist/prompts/examples.d.ts +73 -0
  36. package/dist/prompts/examples.js +178 -0
  37. package/dist/prompts/l1-dedup.d.ts +9 -3
  38. package/dist/prompts/l1-dedup.js +34 -13
  39. package/dist/prompts/l1-extraction.d.ts +5 -3
  40. package/dist/prompts/l1-extraction.js +49 -4
  41. package/dist/settings.d.ts +8 -0
  42. package/dist/settings.js +16 -1
  43. package/dist/stats.d.ts +12 -0
  44. package/dist/stats.js +352 -17
  45. package/dist/store/embedding-source.d.ts +8 -0
  46. package/dist/store/embedding-source.js +39 -2
  47. package/dist/store/file-versions.d.ts +2 -0
  48. package/dist/store/file-versions.js +2 -0
  49. package/dist/store/graph-store.d.ts +14 -0
  50. package/dist/store/graph-store.js +35 -0
  51. package/dist/store/l1-snapshot.d.ts +26 -1
  52. package/dist/store/l1-snapshot.js +53 -8
  53. package/dist/store/l1.d.ts +94 -3
  54. package/dist/store/l1.js +169 -4
  55. package/dist/store/memory-md-archive.d.ts +84 -0
  56. package/dist/store/memory-md-archive.js +155 -0
  57. package/dist/store/memory-md-disable.d.ts +74 -0
  58. package/dist/store/memory-md-disable.js +302 -0
  59. package/dist/store/memory-md-export.d.ts +50 -0
  60. package/dist/store/memory-md-export.js +227 -0
  61. package/dist/store/memory-md-ledger.d.ts +94 -0
  62. package/dist/store/memory-md-ledger.js +243 -0
  63. package/dist/store/memory-md-orphan.d.ts +27 -0
  64. package/dist/store/memory-md-orphan.js +153 -0
  65. package/dist/store/memory-md-parse.d.ts +35 -0
  66. package/dist/store/memory-md-parse.js +85 -0
  67. package/dist/store/memory-md-run.d.ts +48 -0
  68. package/dist/store/memory-md-run.js +151 -0
  69. package/dist/store/memory-md-skeleton.d.ts +18 -0
  70. package/dist/store/memory-md-skeleton.js +61 -0
  71. package/dist/store/memory-md-sync.d.ts +59 -0
  72. package/dist/store/memory-md-sync.js +152 -0
  73. package/dist/store/memory-md-types.d.ts +243 -0
  74. package/dist/store/memory-md-types.js +10 -0
  75. package/dist/store/memory-md.d.ts +117 -0
  76. package/dist/store/memory-md.js +230 -0
  77. package/dist/store/obsolete-notices.d.ts +39 -0
  78. package/dist/store/obsolete-notices.js +153 -0
  79. package/dist/store/recall-feedback.d.ts +75 -0
  80. package/dist/store/recall-feedback.js +213 -0
  81. package/dist/store/recall-usage.d.ts +11 -0
  82. package/dist/store/recall-usage.js +29 -0
  83. package/dist/store/session-recalled.d.ts +38 -3
  84. package/dist/store/session-recalled.js +156 -9
  85. package/dist/store/sqlite.d.ts +66 -0
  86. package/dist/store/sqlite.js +233 -15
  87. package/dist/store/supersede.d.ts +1 -1
  88. package/dist/store/supersede.js +1 -1
  89. package/dist/tools/index.d.ts +4 -0
  90. package/dist/tools/index.js +26 -7
  91. package/dist/tools/slots.d.ts +2 -1
  92. package/dist/tools/slots.js +2 -1
  93. package/dist/types.d.ts +24 -7
  94. package/dist/types.js +10 -2
  95. package/dist/util/maturity.d.ts +20 -3
  96. package/dist/util/maturity.js +18 -1
  97. package/dist/util/md-name.d.ts +129 -0
  98. package/dist/util/md-name.js +366 -0
  99. package/dist/util/md-normalize.d.ts +23 -0
  100. package/dist/util/md-normalize.js +131 -0
  101. package/dist/workspace-route.d.ts +22 -0
  102. package/dist/workspace-route.js +66 -0
  103. package/dsh.plugin.json +1 -1
  104. package/package.json +6 -5
@@ -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,9 +3,10 @@ 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
+ import type { L1FileSyncHook } from './memory-md-sync.js';
9
10
  export type RecallStrategy = 'keyword' | 'embedding' | 'hybrid';
10
11
  /**
11
12
  * 图谱路提供者(§D 第 3 路):按查询返回图谱命中(已按 score 降序)。
@@ -50,6 +51,14 @@ export interface SnapshotRestoreOutcome extends RestoreResult {
50
51
  export declare class L1Store {
51
52
  private readonly db;
52
53
  private readonly strategy;
54
+ /**
55
+ * “该不该给没名字的新记录起名”的实时判据(装配层递 `() => cfg.memExport.enabled`)。
56
+ *
57
+ * 用函数而不是布尔:设置面可热改,存下快照就会与当前开关漂移。
58
+ * 不传 = 不主动起名(“投射关着时零动作”这条既有承诺不改),但**别人占着的名字照样修**
59
+ * ——那是防丢数据,与开关无关。
60
+ */
61
+ private readonly autoName?;
53
62
  /** 记忆库根目录(`records/` 与 `snapshots/` 都在它下面)。 */
54
63
  private readonly dataDir;
55
64
  private readonly recordsDir;
@@ -65,13 +74,39 @@ export declare class L1Store {
65
74
  untaggedL1Count(): number;
66
75
  /** Room 计数缓存(见 `listRooms()`:tags 仅随反刍变化,不必每次敲库)。 */
67
76
  private roomCache;
77
+ /**
78
+ * 库→文件投射的跟随钩子(收口点;未注入 = 零文件动作)。
79
+ *
80
+ * 为什么不放在调用方:改变记录有效性/身份的写路径全部汇流在本类的 `appendNew` /
81
+ * `retire` / `restore` / `rename` 四个方法上,而上游有 8 处 `retire()` 调用点——事件挂钩
82
+ * 已被真机证伪(920 条退场只有 8 个 `.disable`)。放这里意味着新增调用点不会漏。
83
+ * 与构造函数里的 `autoName: () => cfg.memExport.enabled` 同一先例:只拿判据,不拿配置快照。
84
+ */
85
+ private fileSync?;
68
86
  constructor(dataDir: string, db: MemoryDb, embed?: EmbeddingService, strategy?: RecallStrategy, logger?: MemoryLogger,
69
87
  /** 时效衰减半衰期(天;0=关)。缺省 30 与 config 默认一致。 */
70
88
  decayHalfLifeDays?: number,
71
89
  /** 图谱路提供者(§D 第 3 路);不传则该路不存在,融合退回双路。 */
72
- graphLane?: GraphLaneProvider);
90
+ graphLane?: GraphLaneProvider,
91
+ /**
92
+ * “该不该给没名字的新记录起名”的实时判据(装配层递 `() => cfg.memExport.enabled`)。
93
+ *
94
+ * 用函数而不是布尔:设置面可热改,存下快照就会与当前开关漂移。
95
+ * 不传 = 不主动起名(“投射关着时零动作”这条既有承诺不改),但**别人占着的名字照样修**
96
+ * ——那是防丢数据,与开关无关。
97
+ */
98
+ autoName?: (() => boolean) | undefined);
73
99
  /** 读路径时效半衰期(天;0=关)。老化权重展示与召回衰减同一事实源。 */
74
100
  get decayHalfLife(): number;
101
+ /**
102
+ * 装配层晚绑定投射跟随钩子。
103
+ *
104
+ * 必须晚于构造:`stores.l1` 先建,md 账本(`stores.mdLedger`)后装,构造期拿不到 ledger。
105
+ * 未注入时四个汇流口行为与注入前逐字一致(投射面关着也是这条)。
106
+ */
107
+ setFileSyncHook(hook: L1FileSyncHook): void;
108
+ /** 有效性变更点火(增/退场/恢复共用一个入口:钩子内部自己跑「导出→后缀收敛」)。 */
109
+ private fireFileSync;
75
110
  init(): Promise<void>;
76
111
  /** 旧版单文件 records.jsonl 一次性导入检索库,成功后改名 .imported。 */
77
112
  private importLegacy;
@@ -97,6 +132,62 @@ export declare class L1Store {
97
132
  repoKeyName?: string;
98
133
  repoKeyOwner?: string;
99
134
  }, expectRepoKeyName?: string): number;
135
+ /**
136
+ * 批量写内容路由得出的 workspace 归属(不触发场景重聚类——归属变化不影响场景划分,
137
+ * 与 repoKey 那条不一样)。具体约束(不碰 updated_time、同步改 l1_fts)在
138
+ * `store/sqlite.ts` 的 `setL1WorkspaceIds` 里,那里是硬约束的**唯一**落点。
139
+ */
140
+ setWorkspaceIds(items: ReadonlyArray<{
141
+ id: string;
142
+ workspaceId: string;
143
+ }>): number;
144
+ /**
145
+ * 改一条记忆的文件身份名(N1)。形状校验在调用方(`normalizeName`),唯一性由
146
+ * 存储层的部分唯一索引挡——撞名就返 `{ ok: false, reason: 'dup' }`,**不自动加后缀**。
147
+ * 改名不触发场景重聚类,也不动 `updated_time`(约束在 `renameL1` 里)。
148
+ */
149
+ rename(id: string, name: string): {
150
+ ok: boolean;
151
+ reason?: string;
152
+ };
153
+ /** 按名字反查 id(重名预检 / md 回灌);未命中 ⇒ undefined。 */
154
+ findIdByName(name: string): string | undefined;
155
+ /**
156
+ * 给一批**名字缺失或被别人占着**的记录分配文件身份名(N1 的机器起名,全库唯一判据的唯一真源)。
157
+ *
158
+ * 为什么必须做:`deriveName` 只取正文首行前 24 字,同一段话的多条记录必然洗出同名。
159
+ * 真机实测 1166 条记录 `name` 全空、42 条挤进 17 个同名投射文件(最狠一个路径 6 条),
160
+ * 后果是导出每轮重刷被覆盖的那几条、反向同步会把一份内容回灌给六条记录。不回填 name,
161
+ * P0 加的这一列与部分唯一索引就一直是空转。
162
+ *
163
+ * “要起名”的判据不是“name 为空”,而是两种都要管:
164
+ * - **空名**(存量与“抽取时没起名”的新行)——只在 `autoName()` 为真时补;
165
+ * - **名字属于库里另一条**——无条件重命。这一类不是想象:调用方拿 `{ ...旧记录, id: 新 id }`
166
+ * 复制新记录(去重 merge / 取代路径就这么写),旧记录刚被本函数填上 name;不重命的话
167
+ * 新记录拿着别人占着的名字去写库,**撞部分唯一索引 → 整条写不进检索库**(旧记录已退场、
168
+ * 取代者根本不存,两个自家用例就是这么挂的)。
169
+ *
170
+ * 三条约束:
171
+ * - **不覆写属于它自己的名字**:人的命名是权威;
172
+ * - **只改 name**:不碰内容/类型/退场态,也不进快照哈希字段表(`canonicalRecords` 里没有
173
+ * `name`),所以这一步不会弄挂「导出不改检索面」的护栏;
174
+ * - **按 id 排序后再分配**:同一个基底谁先拿到裸名由顺序决定,不钉死会给同一条记录
175
+ * 两次分到不同名字,文件在盘上跳来跳去。
176
+ *
177
+ * @param records - 待起名的记录(**就地改 `.name`**,内存与库保持一致)。
178
+ * @param opts.persist - true 走 `renameL1Batch` 落库(存量回填);false 只算名不落库
179
+ * (新增记录还没进库,名字由随后的 upsert 一并写)。
180
+ * @param opts.fillEmpty - 是否给**空名**起名(默认按 `autoName()` 判)。“名字属于别人”那类
181
+ * 不受此参数影响,永远修。
182
+ * @returns `named` 本轮起名条数,`failed` 落库失败条数(persist=false 时恒为 0)。
183
+ */
184
+ assignMissingNames(records: MemoryRecord[], opts?: {
185
+ persist?: boolean;
186
+ fillEmpty?: boolean;
187
+ }): {
188
+ named: number;
189
+ failed: number;
190
+ };
100
191
  /**
101
192
  * repo 归属变化 → 受影响场景的重聚类作业(治理 W3,repo-change 触发源)。
102
193
  * 归属变了,记忆对场景的归属度就变了——受影响场景的摘要必须由当前事实重算
@@ -255,7 +346,7 @@ export declare class L1Store {
255
346
  * 门禁本体在 `l1-snapshot.exportThenPurge`(与"重建前必快照"同一套设施);
256
347
  * 这里只把 L1Store 已知的 dataDir 与 logger 接上去,避免端点层自己去推路径。
257
348
  */
258
- purgeRetired(ids: string[], reason: string): Promise<ExportThenPurgeResult>;
349
+ purgeRetired(ids: string[], reason: string, opts?: PurgeOptions): Promise<ExportThenPurgeResult>;
259
350
  /** 可用快照列表(按时间倒序;面板/工具据此选一份来恢复)。 */
260
351
  listSnapshots(opts?: {
261
352
  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;
@@ -40,13 +42,31 @@ export class L1Store {
40
42
  }
41
43
  /** Room 计数缓存(见 `listRooms()`:tags 仅随反刍变化,不必每次敲库)。 */
42
44
  roomCache = null;
45
+ /**
46
+ * 库→文件投射的跟随钩子(收口点;未注入 = 零文件动作)。
47
+ *
48
+ * 为什么不放在调用方:改变记录有效性/身份的写路径全部汇流在本类的 `appendNew` /
49
+ * `retire` / `restore` / `rename` 四个方法上,而上游有 8 处 `retire()` 调用点——事件挂钩
50
+ * 已被真机证伪(920 条退场只有 8 个 `.disable`)。放这里意味着新增调用点不会漏。
51
+ * 与构造函数里的 `autoName: () => cfg.memExport.enabled` 同一先例:只拿判据,不拿配置快照。
52
+ */
53
+ fileSync;
43
54
  constructor(dataDir, db, embed = new NoopEmbeddingService(), strategy = 'hybrid', logger,
44
55
  /** 时效衰减半衰期(天;0=关)。缺省 30 与 config 默认一致。 */
45
56
  decayHalfLifeDays,
46
57
  /** 图谱路提供者(§D 第 3 路);不传则该路不存在,融合退回双路。 */
47
- graphLane) {
58
+ graphLane,
59
+ /**
60
+ * “该不该给没名字的新记录起名”的实时判据(装配层递 `() => cfg.memExport.enabled`)。
61
+ *
62
+ * 用函数而不是布尔:设置面可热改,存下快照就会与当前开关漂移。
63
+ * 不传 = 不主动起名(“投射关着时零动作”这条既有承诺不改),但**别人占着的名字照样修**
64
+ * ——那是防丢数据,与开关无关。
65
+ */
66
+ autoName) {
48
67
  this.db = db;
49
68
  this.strategy = strategy;
69
+ this.autoName = autoName;
50
70
  this.dataDir = dataDir;
51
71
  this.recordsDir = path.join(dataDir, 'records');
52
72
  this.legacyFile = path.join(dataDir, 'l1', 'records.jsonl');
@@ -60,6 +80,21 @@ export class L1Store {
60
80
  get decayHalfLife() {
61
81
  return this.decayHalfLifeDays;
62
82
  }
83
+ /**
84
+ * 装配层晚绑定投射跟随钩子。
85
+ *
86
+ * 必须晚于构造:`stores.l1` 先建,md 账本(`stores.mdLedger`)后装,构造期拿不到 ledger。
87
+ * 未注入时四个汇流口行为与注入前逐字一致(投射面关着也是这条)。
88
+ */
89
+ setFileSyncHook(hook) {
90
+ this.fileSync = hook;
91
+ }
92
+ /** 有效性变更点火(增/退场/恢复共用一个入口:钩子内部自己跑「导出→后缀收敛」)。 */
93
+ fireFileSync(ids, why) {
94
+ if (this.fileSync === undefined || ids.length === 0)
95
+ return;
96
+ this.fileSync.sync(ids, why);
97
+ }
63
98
  async init() {
64
99
  await ensureDir(this.recordsDir);
65
100
  await this.importLegacy();
@@ -128,6 +163,118 @@ export class L1Store {
128
163
  this.enqueueRepoChangeRecluster([id]);
129
164
  return changed;
130
165
  }
166
+ /**
167
+ * 批量写内容路由得出的 workspace 归属(不触发场景重聚类——归属变化不影响场景划分,
168
+ * 与 repoKey 那条不一样)。具体约束(不碰 updated_time、同步改 l1_fts)在
169
+ * `store/sqlite.ts` 的 `setL1WorkspaceIds` 里,那里是硬约束的**唯一**落点。
170
+ */
171
+ setWorkspaceIds(items) {
172
+ return this.db.setL1WorkspaceIds(items);
173
+ }
174
+ /**
175
+ * 改一条记忆的文件身份名(N1)。形状校验在调用方(`normalizeName`),唯一性由
176
+ * 存储层的部分唯一索引挡——撞名就返 `{ ok: false, reason: 'dup' }`,**不自动加后缀**。
177
+ * 改名不触发场景重聚类,也不动 `updated_time`(约束在 `renameL1` 里)。
178
+ */
179
+ rename(id, name) {
180
+ // 改名前的身份要先把住:投射文件名就是 `name`(`targetFor` 算路径),库里改了而盘上
181
+ // 没跟进就会长期对不上,后缀收敛器也修不了(它按**当前** name 算路径)。
182
+ const oldName = this.db.getL1ByIds([id])[0]?.name;
183
+ const r = this.db.renameL1(id, name);
184
+ if (r === 'ok') {
185
+ if (oldName !== undefined && oldName !== '' && oldName !== name && this.fileSync !== undefined) {
186
+ this.fileSync.renamed(new Map([[id, oldName]]), 'rename');
187
+ }
188
+ return { ok: true };
189
+ }
190
+ return { ok: false, reason: r };
191
+ }
192
+ /** 按名字反查 id(重名预检 / md 回灌);未命中 ⇒ undefined。 */
193
+ findIdByName(name) {
194
+ return this.db.findRecordIdByName(name);
195
+ }
196
+ /**
197
+ * 给一批**名字缺失或被别人占着**的记录分配文件身份名(N1 的机器起名,全库唯一判据的唯一真源)。
198
+ *
199
+ * 为什么必须做:`deriveName` 只取正文首行前 24 字,同一段话的多条记录必然洗出同名。
200
+ * 真机实测 1166 条记录 `name` 全空、42 条挤进 17 个同名投射文件(最狠一个路径 6 条),
201
+ * 后果是导出每轮重刷被覆盖的那几条、反向同步会把一份内容回灌给六条记录。不回填 name,
202
+ * P0 加的这一列与部分唯一索引就一直是空转。
203
+ *
204
+ * “要起名”的判据不是“name 为空”,而是两种都要管:
205
+ * - **空名**(存量与“抽取时没起名”的新行)——只在 `autoName()` 为真时补;
206
+ * - **名字属于库里另一条**——无条件重命。这一类不是想象:调用方拿 `{ ...旧记录, id: 新 id }`
207
+ * 复制新记录(去重 merge / 取代路径就这么写),旧记录刚被本函数填上 name;不重命的话
208
+ * 新记录拿着别人占着的名字去写库,**撞部分唯一索引 → 整条写不进检索库**(旧记录已退场、
209
+ * 取代者根本不存,两个自家用例就是这么挂的)。
210
+ *
211
+ * 三条约束:
212
+ * - **不覆写属于它自己的名字**:人的命名是权威;
213
+ * - **只改 name**:不碰内容/类型/退场态,也不进快照哈希字段表(`canonicalRecords` 里没有
214
+ * `name`),所以这一步不会弄挂「导出不改检索面」的护栏;
215
+ * - **按 id 排序后再分配**:同一个基底谁先拿到裸名由顺序决定,不钉死会给同一条记录
216
+ * 两次分到不同名字,文件在盘上跳来跳去。
217
+ *
218
+ * @param records - 待起名的记录(**就地改 `.name`**,内存与库保持一致)。
219
+ * @param opts.persist - true 走 `renameL1Batch` 落库(存量回填);false 只算名不落库
220
+ * (新增记录还没进库,名字由随后的 upsert 一并写)。
221
+ * @param opts.fillEmpty - 是否给**空名**起名(默认按 `autoName()` 判)。“名字属于别人”那类
222
+ * 不受此参数影响,永远修。
223
+ * @returns `named` 本轮起名条数,`failed` 落库失败条数(persist=false 时恒为 0)。
224
+ */
225
+ assignMissingNames(records, opts = {}) {
226
+ const fillEmpty = opts.fillEmpty ?? (this.autoName?.() === true);
227
+ // 名字属于库里另一条的(含被 `{...旧记录}` 复制走的),一律重新分配。
228
+ const foreignName = (r) => {
229
+ const n = (r.name ?? '').trim();
230
+ if (n.length === 0)
231
+ return false;
232
+ const owner = this.db.findRecordIdByName(n);
233
+ return owner !== undefined && owner !== r.id;
234
+ };
235
+ const emptyName = (r) => (r.name ?? '').trim().length === 0;
236
+ const needSet = new Set();
237
+ for (const r of records) {
238
+ if (foreignName(r) || (fillEmpty && emptyName(r)))
239
+ needSet.add(r);
240
+ }
241
+ const need = records.filter((r) => needSet.has(r)).sort((a, b) => a.id.localeCompare(b.id));
242
+ if (need.length === 0)
243
+ return { named: 0, failed: 0 };
244
+ // 占用集:本批里**不需要重新分配**的那些名字(它们确实是自己的)+ 本轮刚分出去的。
245
+ const reserved = new Set();
246
+ for (const r of records) {
247
+ if (needSet.has(r))
248
+ continue;
249
+ const n = (r.name ?? '').trim();
250
+ if (n.length > 0)
251
+ reserved.add(n);
252
+ }
253
+ const needIds = new Set(need.map((r) => r.id));
254
+ const isFree = (name) => {
255
+ const owner = this.db.findRecordIdByName(name);
256
+ // 持者是本轮正要起名的某条自己(存量回填时它们已在库里),不算占。
257
+ return owner === undefined || needIds.has(owner);
258
+ };
259
+ const items = [];
260
+ for (const r of need) {
261
+ // 基底始终取正文,而不是取那个“属于别人的旧名字”。
262
+ const name = allocUniqueName(deriveName(r.content), r.id, isFree, reserved);
263
+ r.name = name;
264
+ items.push({ id: r.id, name });
265
+ }
266
+ if (opts.persist !== true)
267
+ return { named: items.length, failed: 0 };
268
+ const { done, failed } = this.db.renameL1Batch(items);
269
+ // 落库失败的条目把内存侧名字收回:否则内存与库不一致,下一轮又当“已有名字”而永不重试。
270
+ if (failed.length > 0) {
271
+ const bad = new Set(failed);
272
+ for (const r of need)
273
+ if (bad.has(r.id))
274
+ r.name = '';
275
+ }
276
+ return { named: done.length, failed: failed.length };
277
+ }
131
278
  /**
132
279
  * repo 归属变化 → 受影响场景的重聚类作业(治理 W3,repo-change 触发源)。
133
280
  * 归属变了,记忆对场景的归属度就变了——受影响场景的摘要必须由当前事实重算
@@ -314,6 +461,9 @@ export class L1Store {
314
461
  if (!r.family)
315
462
  r.family = familyForType(r.type);
316
463
  }
464
+ // 起名必须在**追加事实源之前**:名字是文件身份键,进了 JSONL 才能跟记录一起重生。
465
+ // 这批还没进库,所以只算名字不落库(persist=false),由下面的 upsert 一并写。
466
+ this.assignMissingNames(records, { persist: false });
317
467
  const byDay = new Map();
318
468
  for (const r of records) {
319
469
  const k = dayKey(r.createdAt || Date.now());
@@ -333,11 +483,15 @@ export class L1Store {
333
483
  this.logger?.error(`[memory] L1 检索库批量写入失败(${records.length} 条,JSONL 事实源完好),` +
334
484
  '这批记忆暂不可检索;可在设置页运行「重建记忆」修复');
335
485
  }
486
+ // 写对称:新增也即时上盘(不阻塞、失败只 warn)——否则「文件树 = 库当前状态」只对删成立。
487
+ this.fireFileSync(records.map((r) => r.id), 'appendNew');
336
488
  }
337
489
  /** 去重 update/merge 产出的记录:只更新检索库(JSONL 事实源不改写,官方语义)。 */
338
490
  async upsert(record) {
339
491
  if (!record.family)
340
492
  record.family = familyForType(record.type);
493
+ // merge 分支会继承胜方的 name;两边都没名字时在这里补上,别让新铸的行继续带空名。
494
+ this.assignMissingNames([record], { persist: false });
341
495
  const vec = (await this.helper.batch([record.content]))[0];
342
496
  if (!this.db.upsertL1(record, vec)) {
343
497
  this.logger?.error(`[memory] L1 检索库写入失败 id=${record.id}(JSONL 事实源完好),该记忆暂不可检索,重建可修复`);
@@ -367,7 +521,11 @@ export class L1Store {
367
521
  * 而那种不一致只有在误删发生时才暴露。
368
522
  */
369
523
  retire(ids, info) {
370
- return this.db.retireL1Batch(ids, info);
524
+ const n = this.db.retireL1Batch(ids, info);
525
+ // 退场汇流口(人工软删 / 合并取代 / 冲突判负 都经这里)——所以机器侧自动退场也自动有文件跟随。
526
+ // 不 await:库侧结果先成立,投射是旁路(见 `setFileSyncHook` 的注释)。
527
+ this.fireFileSync(ids, 'retire');
528
+ return n;
371
529
  }
372
530
  /** 已退场(可恢复)记录列表(面板用)。 */
373
531
  listRetired(opts) {
@@ -396,6 +554,8 @@ export class L1Store {
396
554
  vectorsWritten++;
397
555
  this.db.upsertL1(rec, vec);
398
556
  }
557
+ // 恢复活跃 = 有效性变更,后缀该从 `.disable` 掉回去(与 `retire` 对称的那一半)。
558
+ this.fireFileSync(records.map((r) => r.id), 'restore');
399
559
  return { restored: records.length, vectorsWritten };
400
560
  }
401
561
  /**
@@ -404,13 +564,13 @@ export class L1Store {
404
564
  * 门禁本体在 `l1-snapshot.exportThenPurge`(与"重建前必快照"同一套设施);
405
565
  * 这里只把 L1Store 已知的 dataDir 与 logger 接上去,避免端点层自己去推路径。
406
566
  */
407
- async purgeRetired(ids, reason) {
567
+ async purgeRetired(ids, reason, opts = {}) {
408
568
  // **只清理确实处于退场态的记录**。这道复核必须在删除发生的地方(而不是调用方):
409
569
  // 否则任何调用方传一个 id 列表就能绕过软删、把活动记忆直接物理抹掉——
410
570
  // 那等于给"先导出后清理"留了一条硬删后门。
411
571
  const known = new Map(this.db.getL1ByIds(ids).map((r) => [r.id, r]));
412
572
  const retiredIds = ids.filter((id) => known.get(id)?.validTo !== undefined);
413
- return exportThenPurge(this.db, this.dataDir, retiredIds, reason, this.logger);
573
+ return exportThenPurge(this.db, this.dataDir, retiredIds, reason, this.logger, new Date(), opts);
414
574
  }
415
575
  // ───────────────────────────────────────────────────────────────────────────
416
576
  // 快照的**读与回灌**(task_27):`purgeRetired` 会先落快照,但只落不接等于
@@ -503,6 +663,11 @@ export class L1Store {
503
663
  unretired = back.restored;
504
664
  stillRetired = [];
505
665
  }
666
+ // 回到主表就等于“库里多一份可投射的份”:无论带不带退场标记都要上盘
667
+ // (带标记的那批由钩子内部的收敛器接成 `.disable`)。走 `restore` 的那批已点火,不重发。
668
+ if (r.restored > 0 && unretired === 0) {
669
+ this.fireFileSync(targetIds, 'restoreFromSnapshot');
670
+ }
506
671
  return { ...r, dir: resolved.dir, found: true, missing, unretired, stillRetired };
507
672
  }
508
673
  /** 这批 id 里当前**在库**的集合(分块查,避免一次 IN 太多参数)。 */