dsh-prime-memory 0.22.2 → 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 (98) 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 +1180 -845
  6. package/dist/config.d.ts +128 -1341
  7. package/dist/config.js +80 -4
  8. package/dist/conflict-service.d.ts +4 -0
  9. package/dist/conflict-service.js +6 -1
  10. package/dist/contract.d.ts +196 -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 +30 -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 +43 -1
  28. package/dist/pipeline/ruminate.js +100 -0
  29. package/dist/pipeline/runner.d.ts +10 -0
  30. package/dist/pipeline/runner.js +7 -1
  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 +12 -1
  43. package/dist/stats.d.ts +12 -0
  44. package/dist/stats.js +327 -15
  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 +75 -3
  54. package/dist/store/l1.js +124 -3
  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 +23 -0
  58. package/dist/store/memory-md-disable.js +106 -0
  59. package/dist/store/memory-md-export.d.ts +50 -0
  60. package/dist/store/memory-md-export.js +200 -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-skeleton.d.ts +18 -0
  68. package/dist/store/memory-md-skeleton.js +61 -0
  69. package/dist/store/memory-md-types.d.ts +174 -0
  70. package/dist/store/memory-md-types.js +10 -0
  71. package/dist/store/memory-md.d.ts +108 -0
  72. package/dist/store/memory-md.js +219 -0
  73. package/dist/store/obsolete-notices.d.ts +39 -0
  74. package/dist/store/obsolete-notices.js +153 -0
  75. package/dist/store/recall-feedback.d.ts +75 -0
  76. package/dist/store/recall-feedback.js +213 -0
  77. package/dist/store/recall-usage.d.ts +11 -0
  78. package/dist/store/recall-usage.js +29 -0
  79. package/dist/store/session-recalled.d.ts +38 -3
  80. package/dist/store/session-recalled.js +156 -9
  81. package/dist/store/sqlite.d.ts +66 -0
  82. package/dist/store/sqlite.js +233 -15
  83. package/dist/store/supersede.d.ts +1 -1
  84. package/dist/store/supersede.js +1 -1
  85. package/dist/tools/index.d.ts +4 -0
  86. package/dist/tools/index.js +23 -6
  87. package/dist/types.d.ts +24 -7
  88. package/dist/types.js +10 -2
  89. package/dist/util/maturity.d.ts +20 -3
  90. package/dist/util/maturity.js +18 -1
  91. package/dist/util/md-name.d.ts +129 -0
  92. package/dist/util/md-name.js +366 -0
  93. package/dist/util/md-normalize.d.ts +23 -0
  94. package/dist/util/md-normalize.js +131 -0
  95. package/dist/workspace-route.d.ts +22 -0
  96. package/dist/workspace-route.js +66 -0
  97. package/dsh.plugin.json +1 -1
  98. package/package.json +6 -5
@@ -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
  * 归属变了,记忆对场景的归属度就变了——受影响场景的摘要必须由当前事实重算
@@ -255,7 +327,7 @@ export declare class L1Store {
255
327
  * 门禁本体在 `l1-snapshot.exportThenPurge`(与"重建前必快照"同一套设施);
256
328
  * 这里只把 L1Store 已知的 dataDir 与 logger 接上去,避免端点层自己去推路径。
257
329
  */
258
- purgeRetired(ids: string[], reason: string): Promise<ExportThenPurgeResult>;
330
+ purgeRetired(ids: string[], reason: string, opts?: PurgeOptions): Promise<ExportThenPurgeResult>;
259
331
  /** 可用快照列表(按时间倒序;面板/工具据此选一份来恢复)。 */
260
332
  listSnapshots(opts?: {
261
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
  * 归属变了,记忆对场景的归属度就变了——受影响场景的摘要必须由当前事实重算
@@ -314,6 +430,9 @@ export class L1Store {
314
430
  if (!r.family)
315
431
  r.family = familyForType(r.type);
316
432
  }
433
+ // 起名必须在**追加事实源之前**:名字是文件身份键,进了 JSONL 才能跟记录一起重生。
434
+ // 这批还没进库,所以只算名字不落库(persist=false),由下面的 upsert 一并写。
435
+ this.assignMissingNames(records, { persist: false });
317
436
  const byDay = new Map();
318
437
  for (const r of records) {
319
438
  const k = dayKey(r.createdAt || Date.now());
@@ -338,6 +457,8 @@ export class L1Store {
338
457
  async upsert(record) {
339
458
  if (!record.family)
340
459
  record.family = familyForType(record.type);
460
+ // merge 分支会继承胜方的 name;两边都没名字时在这里补上,别让新铸的行继续带空名。
461
+ this.assignMissingNames([record], { persist: false });
341
462
  const vec = (await this.helper.batch([record.content]))[0];
342
463
  if (!this.db.upsertL1(record, vec)) {
343
464
  this.logger?.error(`[memory] L1 检索库写入失败 id=${record.id}(JSONL 事实源完好),该记忆暂不可检索,重建可修复`);
@@ -404,13 +525,13 @@ export class L1Store {
404
525
  * 门禁本体在 `l1-snapshot.exportThenPurge`(与"重建前必快照"同一套设施);
405
526
  * 这里只把 L1Store 已知的 dataDir 与 logger 接上去,避免端点层自己去推路径。
406
527
  */
407
- async purgeRetired(ids, reason) {
528
+ async purgeRetired(ids, reason, opts = {}) {
408
529
  // **只清理确实处于退场态的记录**。这道复核必须在删除发生的地方(而不是调用方):
409
530
  // 否则任何调用方传一个 id 列表就能绕过软删、把活动记忆直接物理抹掉——
410
531
  // 那等于给"先导出后清理"留了一条硬删后门。
411
532
  const known = new Map(this.db.getL1ByIds(ids).map((r) => [r.id, r]));
412
533
  const retiredIds = ids.filter((id) => known.get(id)?.validTo !== undefined);
413
- return exportThenPurge(this.db, this.dataDir, retiredIds, reason, this.logger);
534
+ return exportThenPurge(this.db, this.dataDir, retiredIds, reason, this.logger, new Date(), opts);
414
535
  }
415
536
  // ───────────────────────────────────────────────────────────────────────────
416
537
  // 快照的**读与回灌**(task_27):`purgeRetired` 会先落快照,但只落不接等于
@@ -0,0 +1,84 @@
1
+ /**
2
+ * P3 归档面:退场批次被物理删除时,把它的人类可读归档正文与投射文件一起收进**快照目录**。
3
+ *
4
+ * 两个刻意的决定:
5
+ *
6
+ * ① **归档不另起目录树,就住在快照目录里**(`<dataDir>/snapshots/<name>/`)。计划里原本写
7
+ * `<dataDir>/archives/<name>/`,但那样一次清理就有两个"批次"事实源(快照一份、归档一份),
8
+ * 名字对不上时没人能回答"这批到底删了什么"。住在一起之后:`manifest.json` 是库内容快照、
9
+ * `retired.md` 是给人读的归档正文、`files/` 是搬进来的投射文件——同一个批次名寻址,
10
+ * `snapshot-restore` 天然还能用得上。名字白名单也因此**直接继承** `isSnapshotName`
11
+ * (长度上限 / 固定前缀 / 禁 `/ \ ..`),不需要再造一个 `isArchiveName`,更不吃调用方的绝对路径。
12
+ *
13
+ * ② **搬运在物理删除之后**,且失败只 warn。库删了是既成事实,文件留在原地是可修的不对称
14
+ * (下一次导出会按 `retired` 把它们重写回去);反过来先搬文件再删库,一旦删除中止就变成
15
+ * "记录还活着、文件已经不见了",那才是真的说不清。
16
+ */
17
+ import type { MemoryLogger, MemoryRecord } from '../types.js';
18
+ /** 归档正文文件名(人在归档目录里第一个该看见的东西)。 */
19
+ export declare const ARCHIVE_MD = "retired.md";
20
+ /** 投射文件在归档里的落点子目录。 */
21
+ export declare const ARCHIVE_FILES_DIR = "files";
22
+ /** 一次清理的归档元信息。 */
23
+ export interface ArchiveMeta {
24
+ /** 批次原因(`cleanup-retired` / `pre-rebuild`)。 */
25
+ reason: string;
26
+ /** 归档时刻。 */
27
+ at: Date;
28
+ /** 操作者可选附的一句话(进正文头部,不参与任何判据)。 */
29
+ note?: string;
30
+ }
31
+ /** 一个磁盘上真实存在的投射文件(侧别在收集时就算好,搬运时不猜)。 */
32
+ export interface ProjectionFile {
33
+ absPath: string;
34
+ /** 落在 workspace 根下(true)还是单根下(false)——N10 同一个 `targetFor` 的结论。 */
35
+ inWorkspace: boolean;
36
+ }
37
+ /** 记录 id → 该记录在磁盘上真实存在的投射文件(含 `.disable` 兄弟)。 */
38
+ export type FilesByRecord = ReadonlyMap<string, ProjectionFile[]>;
39
+ /** 归档搬运的结果(进清理响应,便于人核对"文件到底动没动")。 */
40
+ export interface ArchiveFileOutcome {
41
+ moved: number;
42
+ failed: number;
43
+ /** 该记录在磁盘上没有投射文件(没开过投射/从没导出过)。 */
44
+ absent: number;
45
+ }
46
+ /**
47
+ * 收集这批记录在磁盘上存在的投射文件。
48
+ *
49
+ * 只 `stat`,不读内容——归档要的是"把原物搬走",不是"按当前库重渲染一份"。
50
+ *
51
+ * @param records - 待清理的记录(已退场那批)。
52
+ * @param cfgRoot - `cfg.memExport.root`。
53
+ * @param dataDir - 插件数据目录。
54
+ * @param logger - 宿主日志接口。
55
+ * @returns `recordId → 文件[]`;没有任何文件时返回**空 Map**(调用方据此走旧行为)。
56
+ */
57
+ export declare function collectProjectionFiles(records: readonly MemoryRecord[], cfgRoot: string | undefined, dataDir: string, logger?: MemoryLogger): Promise<FilesByRecord>;
58
+ /**
59
+ * 渲染归档正文:按 `type` 分片的单一 Markdown,每条一节。
60
+ *
61
+ * frontmatter 与投射文件同构(同一份 `renderRecord`),只往里插两行退场信息——这样
62
+ * "归档里读到的"和"当初磁盘上的"是同一个形状,人不需要在两套元数据格式之间换算。
63
+ */
64
+ export declare function renderArchiveMarkdown(records: readonly MemoryRecord[], meta: ArchiveMeta): string;
65
+ /**
66
+ * 写归档正文。失败**抛出**:正文是这批数据的唯一人可读物,写不成得让调用方知道
67
+ * (搬运失败则相反——那只影响便利度,不影响事实)。
68
+ */
69
+ export declare function writeArchiveMarkdown(dir: string, records: readonly MemoryRecord[], meta: ArchiveMeta): Promise<string>;
70
+ /**
71
+ * 把这批记录的投射文件搬进归档目录。
72
+ *
73
+ * 目标形状 `<归档根>/files/<ws|home>/<type>/<原文件名>`(自顶向下建目录)。失败只计数并
74
+ * 留痕——绝不因为文件搬不动而回滚库删除(不对称的方向是"文件可重生成,库删了不可逆")。
75
+ *
76
+ * @param dir - 归档根(= 快照目录)。
77
+ * @param records - 该批记录(用 id 与 `files` 配对)。
78
+ * @param files - `collectProjectionFiles` 的产物。
79
+ * @param logger - 只需要 warn(清理侧的 logger 形状比 MemoryLogger 狭,这里不装宽)。
80
+ * @returns 搬运计数。
81
+ */
82
+ export declare function moveProjectionFiles(dir: string, records: readonly MemoryRecord[], files: FilesByRecord, logger?: {
83
+ warn: (m: string) => void;
84
+ }): Promise<ArchiveFileOutcome>;
@@ -0,0 +1,155 @@
1
+ /**
2
+ * P3 归档面:退场批次被物理删除时,把它的人类可读归档正文与投射文件一起收进**快照目录**。
3
+ *
4
+ * 两个刻意的决定:
5
+ *
6
+ * ① **归档不另起目录树,就住在快照目录里**(`<dataDir>/snapshots/<name>/`)。计划里原本写
7
+ * `<dataDir>/archives/<name>/`,但那样一次清理就有两个"批次"事实源(快照一份、归档一份),
8
+ * 名字对不上时没人能回答"这批到底删了什么"。住在一起之后:`manifest.json` 是库内容快照、
9
+ * `retired.md` 是给人读的归档正文、`files/` 是搬进来的投射文件——同一个批次名寻址,
10
+ * `snapshot-restore` 天然还能用得上。名字白名单也因此**直接继承** `isSnapshotName`
11
+ * (长度上限 / 固定前缀 / 禁 `/ \ ..`),不需要再造一个 `isArchiveName`,更不吃调用方的绝对路径。
12
+ *
13
+ * ② **搬运在物理删除之后**,且失败只 warn。库删了是既成事实,文件留在原地是可修的不对称
14
+ * (下一次导出会按 `retired` 把它们重写回去);反过来先搬文件再删库,一旦删除中止就变成
15
+ * "记录还活着、文件已经不见了",那才是真的说不清。
16
+ */
17
+ import { mkdir, rename, stat, writeFile } from 'node:fs/promises';
18
+ import { basename, join } from 'node:path';
19
+ import { disablePathOf, renderRecord, targetFor } from './memory-md.js';
20
+ /** 归档正文文件名(人在归档目录里第一个该看见的东西)。 */
21
+ export const ARCHIVE_MD = 'retired.md';
22
+ /** 投射文件在归档里的落点子目录。 */
23
+ export const ARCHIVE_FILES_DIR = 'files';
24
+ /** 换行符(取字符码,避开任何转义写法)。 */
25
+ const NL = String.fromCharCode(10);
26
+ /**
27
+ * 收集这批记录在磁盘上存在的投射文件。
28
+ *
29
+ * 只 `stat`,不读内容——归档要的是"把原物搬走",不是"按当前库重渲染一份"。
30
+ *
31
+ * @param records - 待清理的记录(已退场那批)。
32
+ * @param cfgRoot - `cfg.memExport.root`。
33
+ * @param dataDir - 插件数据目录。
34
+ * @param logger - 宿主日志接口。
35
+ * @returns `recordId → 文件[]`;没有任何文件时返回**空 Map**(调用方据此走旧行为)。
36
+ */
37
+ export async function collectProjectionFiles(records, cfgRoot, dataDir, logger) {
38
+ const map = new Map();
39
+ for (const r of records) {
40
+ const target = targetFor(r, cfgRoot, dataDir, logger);
41
+ if (target.absPath === undefined)
42
+ continue;
43
+ // `.disable` 优先:退场记录在文件侧的标准形态就是它(N11)。两个都在就都搬。
44
+ const candidates = [
45
+ { absPath: disablePathOf(target.absPath), inWorkspace: target.inWorkspace },
46
+ { absPath: target.absPath, inWorkspace: target.inWorkspace },
47
+ ];
48
+ const found = [];
49
+ for (const c of candidates) {
50
+ try {
51
+ const st = await stat(c.absPath);
52
+ if (st.isFile())
53
+ found.push(c);
54
+ }
55
+ catch {
56
+ // 没文件是常态(没开过投射、或这条从没导出过):静默跳过,不刷日志
57
+ }
58
+ }
59
+ if (found.length > 0)
60
+ map.set(r.id, found);
61
+ }
62
+ return map;
63
+ }
64
+ /**
65
+ * 渲染归档正文:按 `type` 分片的单一 Markdown,每条一节。
66
+ *
67
+ * frontmatter 与投射文件同构(同一份 `renderRecord`),只往里插两行退场信息——这样
68
+ * "归档里读到的"和"当初磁盘上的"是同一个形状,人不需要在两套元数据格式之间换算。
69
+ */
70
+ export function renderArchiveMarkdown(records, meta) {
71
+ const head = [
72
+ `# 退场记忆归档(${records.length} 条)`,
73
+ '',
74
+ `- 批次原因:${meta.reason}`,
75
+ `- 归档时刻:${meta.at.toISOString()}`,
76
+ ];
77
+ if (meta.note !== undefined && meta.note.trim().length > 0)
78
+ head.push(`- 备注:${meta.note.trim()}`);
79
+ head.push('');
80
+ const byType = new Map();
81
+ for (const r of records) {
82
+ const key = r.type.length > 0 ? r.type : 'unsorted';
83
+ const bucket = byType.get(key);
84
+ if (bucket === undefined)
85
+ byType.set(key, [r]);
86
+ else
87
+ bucket.push(r);
88
+ }
89
+ const sections = [];
90
+ for (const type of [...byType.keys()].sort()) {
91
+ const rows = [`## ${type}`, ''];
92
+ for (const r of byType.get(type) ?? []) {
93
+ const retiredAt = r.validTo === undefined ? meta.at.toISOString() : new Date(r.validTo).toISOString();
94
+ // 只往开栏后插两行,闭栏与正文一律不动:插进闭栏之后就成了正文的一部分。
95
+ // 这里刻意不用转义写法拼字符串(同一份产物里换行被展开过一次,正则字面量直接断法),
96
+ // 改成找第一个换行位置做切片——形状依赖的是文件真实结构,不是写法。
97
+ const { text } = renderRecord(r);
98
+ const at = text.indexOf(NL);
99
+ const extra = `retiredAt: ${JSON.stringify(retiredAt)}${NL}retireReason: ${JSON.stringify(meta.reason)}`;
100
+ rows.push(at < 0 ? text : `${text.slice(0, at)}${NL}${extra}${text.slice(at)}`);
101
+ rows.push('');
102
+ }
103
+ sections.push(rows.join('\n'));
104
+ }
105
+ return `${head.join('\n')}\n${sections.join('\n')}`;
106
+ }
107
+ /**
108
+ * 写归档正文。失败**抛出**:正文是这批数据的唯一人可读物,写不成得让调用方知道
109
+ * (搬运失败则相反——那只影响便利度,不影响事实)。
110
+ */
111
+ export async function writeArchiveMarkdown(dir, records, meta) {
112
+ const path = join(dir, ARCHIVE_MD);
113
+ await mkdir(dir, { recursive: true });
114
+ await writeFile(path, renderArchiveMarkdown(records, meta), 'utf8');
115
+ return path;
116
+ }
117
+ /**
118
+ * 把这批记录的投射文件搬进归档目录。
119
+ *
120
+ * 目标形状 `<归档根>/files/<ws|home>/<type>/<原文件名>`(自顶向下建目录)。失败只计数并
121
+ * 留痕——绝不因为文件搬不动而回滚库删除(不对称的方向是"文件可重生成,库删了不可逆")。
122
+ *
123
+ * @param dir - 归档根(= 快照目录)。
124
+ * @param records - 该批记录(用 id 与 `files` 配对)。
125
+ * @param files - `collectProjectionFiles` 的产物。
126
+ * @param logger - 只需要 warn(清理侧的 logger 形状比 MemoryLogger 狭,这里不装宽)。
127
+ * @returns 搬运计数。
128
+ */
129
+ export async function moveProjectionFiles(dir, records, files, logger) {
130
+ const out = { moved: 0, failed: 0, absent: 0 };
131
+ if (files.size === 0)
132
+ return out;
133
+ for (const r of records) {
134
+ const entry = files.get(r.id);
135
+ if (entry === undefined) {
136
+ out.absent += 1;
137
+ continue;
138
+ }
139
+ const type = r.type.length > 0 ? r.type : 'unsorted';
140
+ for (const f of entry) {
141
+ const side = f.inWorkspace ? 'ws' : 'home';
142
+ const toDir = join(dir, ARCHIVE_FILES_DIR, side, type);
143
+ try {
144
+ await mkdir(toDir, { recursive: true });
145
+ await rename(f.absPath, join(toDir, basename(f.absPath)));
146
+ out.moved += 1;
147
+ }
148
+ catch (err) {
149
+ out.failed += 1;
150
+ logger?.warn(`[memory] 归档搬运失败(库已删,文件留在原地): ${f.absPath} - ${err instanceof Error ? err.message : String(err)}`);
151
+ }
152
+ }
153
+ }
154
+ return out;
155
+ }