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
@@ -0,0 +1,227 @@
1
+ /**
2
+ * md 投射的导出与对账(P1 / N1 + N9)。
3
+ *
4
+ * 两个函数共享同一套路径纪律,但**权限面完全不同**:
5
+ * - `exportRecords` 写文件、写账本,**一字不改库**(导出前后 `hashRecords` 必须相同,
6
+ * 用例钉住这条);
7
+ * - `reconcileLedger` 什么都不写,只按账本与磁盘现状出四态报告——"看看差在哪"
8
+ * 不该需要高权限,`memoryMutate` 的门留给 P2 的真同步。
9
+ *
10
+ * 有界性照 `wing-backfill` / `workspace-relink` 的既有口径,但**配额数的是「动作」不是「遍历」**:
11
+ * `maxRecords` 只约束真碰了磁盘的条数(写入 / 补账 / 失败),判为「未变且已记账」的不吃配额。
12
+ * 原来的写法把未变也计入配额,于是**第二次点击扫的还是同一批前 N 条**,`remaining` 恒等于
13
+ * 同一个数——真机实测「已写 9 · 未变 291 · 剩余 866」点多少次都停在 300。扫描总量改由
14
+ * `timeBudgetMs` 兜底(只数条数不看时间,一条超大记录就能把一次点击挂死,这条理由不变),
15
+ * 剩余条数照旧如实回报 `remaining`。
16
+ */
17
+ import { dirname } from 'node:path';
18
+ import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
19
+ import { digest, disablePathOf, isRetiredRecord, recordDigest, renderRecord, safeJoin, targetFor, resolveMemoriesRoot } from './memory-md.js';
20
+ // 路径纪律已收到 `memory-md.ts`(N10 后导出/对账/失效/归档四处共用);这里再导一次
21
+ // 只为不拆已有调用方(stats.ts 与用例都从本模块拿它们)。
22
+ export { resolveMemoriesRoot, safeJoin };
23
+ /** 让出事件循环一次:投射是长任务,不能独占 tick(面板会僵)。 */
24
+ function tick() {
25
+ return new Promise((resolveTick) => { setImmediate(resolveTick); });
26
+ }
27
+ /**
28
+ * 单向导出:库 → 文件 + 账本。
29
+ *
30
+ * 每次**全量重写**该写的:增量 patch 与内容指纹判据互相打脸(patch 的前提是"我知道
31
+ * 差在哪",而那正是指纹要回答的)。指纹一致就跳过,不刷 mtime——刷了会让下一轮
32
+ * 对账把"我们没动过"误判成"文件被改过"。
33
+ */
34
+ export async function exportRecords(deps) {
35
+ const { records, cfgRoot, dataDir, ledger, logger } = deps;
36
+ const startedAt = (deps.now ?? Date.now)();
37
+ const root = resolveMemoriesRoot(cfgRoot, dataDir, logger);
38
+ const report = { root, inWorkspace: 0, scanned: 0, written: 0, unchanged: 0, skipped: 0, remaining: 0, capped: false };
39
+ // 吃配额的只有**碰过磁盘**的条数:写入、未变但补账、以及失败的那条(它已经碰过盘)。
40
+ let worked = 0;
41
+ for (let i = 0; i < records.length; i++) {
42
+ // 两条护栏都在**循环顶部**查:写在末尾会被 `continue` 跳过(自家测试抓过这个 bug)。
43
+ if (worked >= deps.maxRecords || (deps.now ?? Date.now)() - startedAt >= deps.timeBudgetMs) {
44
+ report.capped = true;
45
+ report.remaining = records.length - i;
46
+ break;
47
+ }
48
+ const r = records[i];
49
+ // 防御:`records` 来自 `store.all()`,类型上是满的,但 noUncheckedIndexedAccess
50
+ // 下的 client 程序会把它当 `| undefined`——这里不猜,直接跳过。
51
+ if (r === undefined) {
52
+ report.skipped++;
53
+ continue;
54
+ }
55
+ report.scanned++;
56
+ // N10:落点由 `targetFor` 一个函数定(workspace 优先),账本键 = 绝对路径。
57
+ const target = targetFor(r, cfgRoot, dataDir, logger);
58
+ if (target.absPath === undefined) {
59
+ report.skipped++;
60
+ logger?.warn(`[memory] md 投射跳过(路径越界): ${r.id} → ${target.relPath}`);
61
+ continue;
62
+ }
63
+ const { text } = renderRecord(r);
64
+ const abs = target.absPath;
65
+ if (target.inWorkspace)
66
+ report.inWorkspace++;
67
+ const dbDg = recordDigest(r);
68
+ const fileDg = digest(text);
69
+ try {
70
+ const existing = await readFile(abs, 'utf8').catch(() => undefined);
71
+ if (existing !== undefined && digest(existing) === fileDg) {
72
+ report.unchanged++;
73
+ // 内容没变也要补账:首轮导出前账本是空的,不补就永远判不出"文件被改过"。
74
+ // 补账要吃配额(它写盘),纯未变不吃——否则「再点一次」永远在同一批上打转。
75
+ if (ledger.get(abs) === undefined) {
76
+ try {
77
+ await stamp(ledger, r, target.root, target.relPath, abs, dbDg, fileDg);
78
+ worked++;
79
+ }
80
+ catch (err) {
81
+ // 文件与库都没动,只是没记上账:不能算 skipped(它没失败),也不能反回退成未计。
82
+ logger?.warn(`[memory] md 投射补账失败(下轮仍会重补): ${abs} - ${err instanceof Error ? err.message : String(err)}`);
83
+ }
84
+ }
85
+ await tick();
86
+ continue;
87
+ }
88
+ // 退场记录的投射可能已经在 `.disable` 上(后缀不变式,见 `memory-md-disable.ts`)。
89
+ // 这时“非退场路径上没文件”**不等于**“从没导出过”:拿 `.disable` 那份比指纹,
90
+ // 一致就记未变。不拦这个的话,每轮导出都会把刚改完名的文件写回非退场路径,
91
+ // 收敛再改一次名——两轮之间来回倒腾,`written` 永远不归零。
92
+ if (existing === undefined && isRetiredRecord(r)) {
93
+ const dis = disablePathOf(abs);
94
+ const disText = await readFile(dis, 'utf8').catch(() => undefined);
95
+ // 两种形状都得认(都是合法盘态):
96
+ // · 先导出后退场 → 改名分支不重写内容,盘上是 live 渲染(既有用例钉着);
97
+ // · 生下来就退场、首次导出就是那份 → 盘上是带 `retired: true` 的当前渲染。
98
+ // 拿其中任一份对上就记未变;都对不上(正文被改过)才落到下面重写,由收敛再改名。
99
+ const liveDg = digest(renderRecord({ ...r, validTo: undefined }).text);
100
+ if (disText !== undefined && (digest(disText) === liveDg || digest(disText) === fileDg)) {
101
+ report.unchanged++;
102
+ if (ledger.get(dis) === undefined) {
103
+ try {
104
+ await stamp(ledger, r, target.root, target.relPath, dis, dbDg, digest(disText));
105
+ worked++;
106
+ }
107
+ catch (err) {
108
+ logger?.warn(`[memory] md 投射退场凭证补账失败(下轮仍会重补): ${dis} - ${err instanceof Error ? err.message : String(err)}`);
109
+ }
110
+ }
111
+ await tick();
112
+ continue;
113
+ }
114
+ }
115
+ await mkdir(dirname(abs), { recursive: true });
116
+ await writeFile(abs, text, 'utf8');
117
+ report.written++;
118
+ worked++;
119
+ // 盘已经写成功了,之后记账失败**不能再算成 skipped**:同一条既 written 又 skipped
120
+ // 会让三个计数加起来大于 scanned(自家基准测出 1200 条同时报「写 1200 跳 1200」),
121
+ // 而报告是给人判断「这批要不要重来」的依据,自洽性比分类精细更要紧。
122
+ try {
123
+ await stamp(ledger, r, target.root, target.relPath, abs, dbDg, fileDg);
124
+ }
125
+ catch (err) {
126
+ logger?.warn(`[memory] md 投射已落盘但记账失败(下轮仍会重写这条): ${abs} - ${err instanceof Error ? err.message : String(err)}`);
127
+ }
128
+ }
129
+ catch (err) {
130
+ report.skipped++;
131
+ worked++;
132
+ logger?.warn(`[memory] md 投射单条失败(跳过,不影响其余): ${abs} - ${err instanceof Error ? err.message : String(err)}`);
133
+ }
134
+ await tick();
135
+ }
136
+ await ledger.flush();
137
+ return report;
138
+ }
139
+ /** 写账:文件侧字段取**落盘后**的真实 stat,不取内存值(否则对账基线就是假的)。 */
140
+ async function stamp(ledger, r, root, relPath, absPath, dbDg, fileDg) {
141
+ const st = await stat(absPath).catch(() => undefined);
142
+ const entry = {
143
+ id: r.id,
144
+ extKey: r.extKey ?? '',
145
+ root,
146
+ relPath,
147
+ dbDigest: dbDg,
148
+ fileDigest: fileDg,
149
+ fileMtimeMs: st?.mtimeMs ?? 0,
150
+ fileSize: st?.size ?? 0,
151
+ syncedAt: new Date().toISOString(),
152
+ };
153
+ ledger.set(absPath, entry);
154
+ }
155
+ /**
156
+ * 只读对账:按账本基线判每条记录落在哪个象限。
157
+ *
158
+ * 判据是**三方**的(基线 = 账本里的两个 digest):
159
+ * `dbDigest ≠ 现在库内容` → 库动过;`fileDigest ≠ 现在文件内容` → 动过文件。
160
+ * 两方比较必吃一边,这就是账本要同时存两个 digest 的原因。
161
+ */
162
+ export async function reconcileLedger(deps) {
163
+ const { records, cfgRoot, dataDir, ledger, logger } = deps;
164
+ const startedAt = (deps.now ?? Date.now)();
165
+ const root = resolveMemoriesRoot(cfgRoot, dataDir, logger);
166
+ const counts = { firstTime: 0, same: 0, dbOnly: 0, fileOnly: 0, both: 0, missingFile: 0 };
167
+ const samples = [];
168
+ const sampleCap = deps.sampleCap ?? 10;
169
+ let scanned = 0;
170
+ // 与导出同口径:`maxRecords` 数的是**需要人处理差异**的条数,`same` 不吃配额。
171
+ // 否则一个已经收敛的库会把配额全花在“确认没差”上,真正要看的差异反而被护到窗外。
172
+ let worked = 0;
173
+ let capped = false;
174
+ for (let i = 0; i < records.length; i++) {
175
+ if (worked >= deps.maxRecords || (deps.now ?? Date.now)() - startedAt >= deps.timeBudgetMs) {
176
+ capped = true;
177
+ break;
178
+ }
179
+ const r = records[i];
180
+ scanned++;
181
+ if (r === undefined)
182
+ continue;
183
+ // 与导出同一个 `targetFor`:否则“库侧算出的落点”与“导出时写的落点”会不一致,
184
+ // 对账就只会报“文件缺失”。
185
+ const target = targetFor(r, cfgRoot, dataDir, logger);
186
+ const abs = target.absPath;
187
+ const entry = abs === undefined ? undefined : ledger.get(abs);
188
+ const push = (state) => {
189
+ counts[state]++;
190
+ if (state !== 'same')
191
+ worked++;
192
+ if (samples.length < sampleCap)
193
+ samples.push({ relPath: target.relPath, id: r.id, state });
194
+ };
195
+ if (abs === undefined) {
196
+ push('missingFile');
197
+ logger?.warn(`[memory] 对账遇到越界路径: ${target.relPath}`);
198
+ await tick();
199
+ continue;
200
+ }
201
+ if (entry === undefined) {
202
+ push('firstTime');
203
+ await tick();
204
+ continue;
205
+ }
206
+ const existing = await readFile(abs, 'utf8').catch(() => undefined);
207
+ if (existing === undefined) {
208
+ push('missingFile');
209
+ await tick();
210
+ continue;
211
+ }
212
+ // 两个“变了”都是**相对账本基线**判的,不能拿“与现在会渲染出的文本不同”当依据:
213
+ // 库一变渲染就变,那会把纯粹的 dbOnly 误判成“文件也被人改了”,象限表全盘跑偏。
214
+ const dbChanged = entry.dbDigest !== recordDigest(r);
215
+ const fileChanged = digest(existing) !== entry.fileDigest;
216
+ if (dbChanged && fileChanged)
217
+ push('both');
218
+ else if (dbChanged)
219
+ push('dbOnly');
220
+ else if (fileChanged)
221
+ push('fileOnly');
222
+ else
223
+ push('same');
224
+ await tick();
225
+ }
226
+ return { root, capped, scanned, ...counts, samples };
227
+ }
@@ -0,0 +1,94 @@
1
+ import type { MemoryLogger } from '../types.js';
2
+ /** 看起来就是绝对路径(Windows 盘符形状或 POSIX 以 `/` 开头)。归档侧也要用它挡坏数据。 */
3
+ export declare function looksAbsolute(p: string): boolean;
4
+ /**
5
+ * 由「绝对键 + 条目记的 root」推出相对形状(正斜杠)。
6
+ *
7
+ * 这是 relPath 的唯一正确算法:键才是事实源(N10 以后它是绝对路径),root 只是
8
+ * 其中一个字段。`root` 缺失、键不在 root 下、或推出 `..` 开头都返回 `undefined`——不猜。
9
+ *
10
+ * @param root - 投射根(绝对)。
11
+ * @param key - 账本键 = 文件绝对路径。
12
+ * @returns `<类型>/<名>` 的相对形状,推不出时 `undefined`。
13
+ */
14
+ export declare function deriveRelPath(root: string, key: string): string | undefined;
15
+ /** 一条投射记录的指纹账。`db*` 是库侧、`file*` 是磁盘侧,两边成对才能判象限。 */
16
+ export interface MdLedgerEntry {
17
+ /** 记录 id(写入时刻的那一个;merge 后可能变,故只当线索不当键)。 */
18
+ id: string;
19
+ /** 外部导入幂等键(有则可用于跨 id 认人)。 */
20
+ extKey: string;
21
+ /** 投射根(workspace 根或单根),绝对路径。 */
22
+ root: string;
23
+ /** 相对根的路径(正斜杠),展示与归档分目录用。 */
24
+ relPath: string;
25
+ /** 上次投射时**库侧**的内容指纹。 */
26
+ dbDigest: string;
27
+ /** 上次投射时**文件侧**的内容指纹。 */
28
+ fileDigest: string;
29
+ fileMtimeMs: number;
30
+ fileSize: number;
31
+ /** 写入时刻(ISO)。只作展示,不参与任何判据——参与就会让渲染不纯。 */
32
+ syncedAt: string;
33
+ }
34
+ export declare class MdLedgerStore {
35
+ private readonly logger?;
36
+ private readonly file;
37
+ private entries;
38
+ private loaded;
39
+ private degraded;
40
+ private degradedLogged;
41
+ private writeChain;
42
+ /** 有未落盘的变更。 */
43
+ private dirty;
44
+ /** 已提醒过的 relPath 修正数(防上千条条目刷屏)。 */
45
+ private mismatchWarned;
46
+ /** 已发生的整本落盘次数(见 `writeCount`)。 */
47
+ private writes;
48
+ /** 已排上的合批定时器(undefined = 窗口外)。 */
49
+ private timer;
50
+ constructor(dataDir: string, logger?: MemoryLogger | undefined);
51
+ init(): Promise<void>;
52
+ /** 逐条收窄:形状不符的条目丢弃,不让一条坏数据拖垮整表。 */
53
+ private normalize;
54
+ private ensureLoaded;
55
+ get(absPath: string): MdLedgerEntry | undefined;
56
+ all(): MdLedgerEntry[];
57
+ /**
58
+ * 带键遍历:**账本的键(绝对路径)是唯一认身份的口径**,而 `all()` 把它抹掉了。
59
+ *
60
+ * 为什么需要这个口:孤儿扫描要回答“这个**路径**现在还有没有记录认领”,而条目里的
61
+ * `root`/`relPath` 只是归档时恢复相对形状用的——手工编辑过账本时两者可以不一致,
62
+ * 拿它们当路径就会把“键”与“内容”两套事实源弄混。只读,不暴露可变 Map。
63
+ */
64
+ allWithKeys(): Array<{
65
+ path: string;
66
+ entry: MdLedgerEntry;
67
+ }>;
68
+ set(absPath: string, entry: MdLedgerEntry): void;
69
+ delete(absPath: string): boolean;
70
+ /** 条目上限护栏:投射是"文件 ↔ 库"的一一对应,超量只可能是脏数据堆积。 */
71
+ private prune;
72
+ private schedulePersist;
73
+ /** 串行落盘:并发 set 不能交叠写同一个临时文件。 */
74
+ private enqueuePersist;
75
+ private persistNow;
76
+ /**
77
+ * 已发生的整本落盘次数。
78
+ *
79
+ * 留着它是因为这个类的性能形状就是它的全部意义:账本每次落盘都重写整本 JSON,
80
+ * 所以「这个数是不是跟 `set()` 次数一起涨」直接就能区分 O(n) 与 O(n²)。合批前 200 次
81
+ * `set()` 会落盘 200 次;合批后应该恒为 1。没这个计数器就只能靠时间差或磁盘状态去猜,
82
+ * 那种断言在旧实现上也能绿(自家刚踩过)。
83
+ */
84
+ get writeCount(): number;
85
+ private persist;
86
+ /**
87
+ * 等待在途写完成(测试/停机/导出收尾)。
88
+ *
89
+ * 合批之后这一步是**语义上的“现在就要落盘”**:清掉定时器并强制一次写,所以写完
90
+ * `set()` 不 `flush()` 就不能假设磁盘已经可信。`persistNow` 自带脏位判定,
91
+ * 没变更时不会空写一轮。
92
+ */
93
+ flush(): Promise<void>;
94
+ }
@@ -0,0 +1,243 @@
1
+ /**
2
+ * md 投射的指纹账本(N9)。
3
+ *
4
+ * 它存在的唯一理由:**库里没有任何 hash 列**(spec F26),三处现成 hash 又全不落库
5
+ * (F27:快照是整集粗粒度、memport 每次现算、reconcile 只活在状态文件里)。没有这张
6
+ * 账本就无从回答"这个文件是被人改的,还是被我们上次写出去的"——而那是双向同步的
7
+ * 全部难点:两方比较必吃一边。
8
+ *
9
+ * 三条口径:
10
+ * - **键是文件的绝对路径**(N10 后根不止一个:workspace 根 + 单根,同名不同 workspace 会
11
+ * 在 relPath 上撞车)。绝对路径由 `targetFor` 一函数算出,读写对账改名四处共用,
12
+ * 所以键是确定性的(Windows 下 `workspace_id` 是小写形态,这一点已写进 `memory-md.ts`)。
13
+ * - **派生态**:删了这张表最坏是"下次全量重写一遍文件",不会丢任何记忆。所以它
14
+ * **不进**快照哈希投影(`canonicalRecords` 不动),也不需要迁移。
15
+ * - **写穿失败只降级为只读并 warn 一次**,绝不抛给调用方:投射是旁路,旁路不能弄挂主路。
16
+ */
17
+ import { basename, dirname, join, relative, sep } from 'node:path';
18
+ import { atomicWriteText, ensureDir, readJsonStrict } from '../util/io.js';
19
+ import { MEM_MD_LEDGER_FILE_VERSION } from './file-versions.js';
20
+ /** 看起来就是绝对路径(Windows 盘符形状或 POSIX 以 `/` 开头)。归档侧也要用它挡坏数据。 */
21
+ export function looksAbsolute(p) {
22
+ return /^[A-Za-z]:[\\/]/.test(p) || p.startsWith('/');
23
+ }
24
+ /**
25
+ * 由「绝对键 + 条目记的 root」推出相对形状(正斜杠)。
26
+ *
27
+ * 这是 relPath 的唯一正确算法:键才是事实源(N10 以后它是绝对路径),root 只是
28
+ * 其中一个字段。`root` 缺失、键不在 root 下、或推出 `..` 开头都返回 `undefined`——不猜。
29
+ *
30
+ * @param root - 投射根(绝对)。
31
+ * @param key - 账本键 = 文件绝对路径。
32
+ * @returns `<类型>/<名>` 的相对形状,推不出时 `undefined`。
33
+ */
34
+ export function deriveRelPath(root, key) {
35
+ if (root.length === 0 || looksAbsolute(key) === false)
36
+ return undefined;
37
+ const rel = relative(root, key);
38
+ if (rel.length === 0 || rel.startsWith('..'))
39
+ return undefined;
40
+ return rel.split(sep).join('/');
41
+ }
42
+ /**
43
+ * 合批窗口(ms)。
44
+ *
45
+ * 账本是**整本 JSON 的原子重写**(临时文件 + rename),所以“每 set 一条就落盘”把导出
46
+ * 成本变成 O(n²):本机实测 1200 条记录 9.4 秒,其中只碰文件面只需 1.6 秒。改为窗口内
47
+ * 只记脏、由 `flush()` 收尾落一次,1200 次 set 就是一次落盘。
48
+ * 窗口只是**兜底**:三个写方(导出 / 失效 / 同步)本来就在收尾 `await flush()`。
49
+ */
50
+ const PERSIST_COALESCE_MS = 250;
51
+ export class MdLedgerStore {
52
+ logger;
53
+ file;
54
+ entries = new Map();
55
+ loaded = false;
56
+ degraded;
57
+ degradedLogged = false;
58
+ writeChain = Promise.resolve();
59
+ /** 有未落盘的变更。 */
60
+ dirty = false;
61
+ /** 已提醒过的 relPath 修正数(防上千条条目刷屏)。 */
62
+ mismatchWarned = 0;
63
+ /** 已发生的整本落盘次数(见 `writeCount`)。 */
64
+ writes = 0;
65
+ /** 已排上的合批定时器(undefined = 窗口外)。 */
66
+ timer;
67
+ constructor(dataDir, logger) {
68
+ this.logger = logger;
69
+ this.file = join(dataDir, 'mem-md-ledger.json');
70
+ }
71
+ async init() {
72
+ await ensureDir(dirname(this.file));
73
+ const read = await readJsonStrict(this.file);
74
+ if (!read.ok) {
75
+ if (read.reason !== 'missing') {
76
+ this.degraded = `${read.reason}${'detail' in read && read.detail ? `: ${read.detail}` : ''}`;
77
+ this.logger?.warn(`[memory] md 投射账本只读降级: ${this.degraded}`);
78
+ }
79
+ this.loaded = true;
80
+ return;
81
+ }
82
+ for (const [key, e] of Object.entries(read.value.entries ?? {})) {
83
+ const parsed = this.normalize(e);
84
+ if (parsed === undefined)
85
+ continue;
86
+ // 键是**绝对路径**(N10),relPath 是 `<类型>/<名>` 的相对形状——两者本来就不该相等。
87
+ // 旧写法是“键与 relPath 不一致就以键为准重写”,那是键还叫 relPath 时代的逻辑;
88
+ // N10 之后它每次加载都必然判定不一致,于是相对形状被绝对键吃掉(真机 1167/1241 条命中)。
89
+ // 危害不是难看而是下游:孤儿归档拼出 `<批次>/home/C:\Users\...` 这种含冒号的非法
90
+ // 目录,75 条搬家全 ENOENT。现在规整判据换成“键 == join(root, relPath)”:
91
+ // 只有真推不出相对形状(root 缺失/越界)才退回存下来的值。
92
+ const derived = deriveRelPath(parsed.root, key);
93
+ let relPath = derived ?? parsed.relPath;
94
+ if (looksAbsolute(relPath)) {
95
+ // 坏数据兼容:以前被绝对键污染过的条目,若 root 也推不出来,退到文件名——
96
+ // 至少归档不会拼出带冒号的非法路径。名字重复由 freeSlot 的数字尾巴兼着。
97
+ relPath = basename(relPath);
98
+ }
99
+ if (relPath !== parsed.relPath && this.mismatchWarned < 8) {
100
+ this.mismatchWarned++;
101
+ this.logger?.warn(`[memory] md 账本 relPath 已按键修正: ${key} → ${relPath}`);
102
+ }
103
+ this.entries.set(key, { ...parsed, relPath });
104
+ }
105
+ this.loaded = true;
106
+ }
107
+ /** 逐条收窄:形状不符的条目丢弃,不让一条坏数据拖垮整表。 */
108
+ normalize(e) {
109
+ if (!e || typeof e !== 'object')
110
+ return undefined;
111
+ const raw = e;
112
+ if (typeof raw.relPath !== 'string' || raw.relPath.length === 0)
113
+ return undefined;
114
+ if (typeof raw.dbDigest !== 'string' || typeof raw.fileDigest !== 'string')
115
+ return undefined;
116
+ return {
117
+ id: typeof raw.id === 'string' ? raw.id : '',
118
+ extKey: typeof raw.extKey === 'string' ? raw.extKey : '',
119
+ root: typeof raw.root === 'string' ? raw.root : '',
120
+ relPath: raw.relPath,
121
+ dbDigest: raw.dbDigest,
122
+ fileDigest: raw.fileDigest,
123
+ fileMtimeMs: typeof raw.fileMtimeMs === 'number' ? raw.fileMtimeMs : 0,
124
+ fileSize: typeof raw.fileSize === 'number' ? raw.fileSize : 0,
125
+ syncedAt: typeof raw.syncedAt === 'string' ? raw.syncedAt : '',
126
+ };
127
+ }
128
+ ensureLoaded() {
129
+ if (!this.loaded)
130
+ throw new Error('md 投射账本未初始化(先 await init())');
131
+ }
132
+ get(absPath) {
133
+ this.ensureLoaded();
134
+ return this.entries.get(absPath);
135
+ }
136
+ all() {
137
+ this.ensureLoaded();
138
+ return [...this.entries.values()];
139
+ }
140
+ /**
141
+ * 带键遍历:**账本的键(绝对路径)是唯一认身份的口径**,而 `all()` 把它抹掉了。
142
+ *
143
+ * 为什么需要这个口:孤儿扫描要回答“这个**路径**现在还有没有记录认领”,而条目里的
144
+ * `root`/`relPath` 只是归档时恢复相对形状用的——手工编辑过账本时两者可以不一致,
145
+ * 拿它们当路径就会把“键”与“内容”两套事实源弄混。只读,不暴露可变 Map。
146
+ */
147
+ allWithKeys() {
148
+ this.ensureLoaded();
149
+ return [...this.entries.entries()].map(([path, entry]) => ({ path, entry }));
150
+ }
151
+ set(absPath, entry) {
152
+ this.ensureLoaded();
153
+ this.entries.set(absPath, entry);
154
+ this.schedulePersist();
155
+ }
156
+ delete(absPath) {
157
+ this.ensureLoaded();
158
+ if (!this.entries.delete(absPath))
159
+ return false;
160
+ this.schedulePersist();
161
+ return true;
162
+ }
163
+ /** 条目上限护栏:投射是"文件 ↔ 库"的一一对应,超量只可能是脏数据堆积。 */
164
+ prune() {
165
+ const MAX = 50_000;
166
+ if (this.entries.size <= MAX)
167
+ return;
168
+ const sorted = [...this.entries.entries()].sort((a, b) => (a[1].syncedAt < b[1].syncedAt ? -1 : 1));
169
+ for (const [rel] of sorted.slice(0, this.entries.size - MAX))
170
+ this.entries.delete(rel);
171
+ }
172
+ schedulePersist() {
173
+ if (this.degraded)
174
+ return;
175
+ this.dirty = true;
176
+ // 已在窗口内就不再排:一个 burst 只留一次落盘。
177
+ if (this.timer !== undefined)
178
+ return;
179
+ this.timer = setTimeout(() => {
180
+ this.timer = undefined;
181
+ void this.enqueuePersist();
182
+ }, PERSIST_COALESCE_MS);
183
+ // 不让一个兜底定时器拖住进程退出(宿主常驻时无影响,测试收尾时干净)。
184
+ this.timer.unref?.();
185
+ }
186
+ /** 串行落盘:并发 set 不能交叠写同一个临时文件。 */
187
+ enqueuePersist() {
188
+ this.writeChain = this.writeChain.then(() => this.persistNow(), () => this.persistNow());
189
+ return this.writeChain;
190
+ }
191
+ async persistNow() {
192
+ if (!this.dirty || this.degraded)
193
+ return;
194
+ this.dirty = false;
195
+ this.writes += 1;
196
+ await this.persist();
197
+ }
198
+ /**
199
+ * 已发生的整本落盘次数。
200
+ *
201
+ * 留着它是因为这个类的性能形状就是它的全部意义:账本每次落盘都重写整本 JSON,
202
+ * 所以「这个数是不是跟 `set()` 次数一起涨」直接就能区分 O(n) 与 O(n²)。合批前 200 次
203
+ * `set()` 会落盘 200 次;合批后应该恒为 1。没这个计数器就只能靠时间差或磁盘状态去猜,
204
+ * 那种断言在旧实现上也能绿(自家刚踩过)。
205
+ */
206
+ get writeCount() {
207
+ return this.writes;
208
+ }
209
+ async persist() {
210
+ if (this.degraded)
211
+ return;
212
+ this.prune();
213
+ const data = {
214
+ version: MEM_MD_LEDGER_FILE_VERSION,
215
+ entries: Object.fromEntries(this.entries),
216
+ };
217
+ try {
218
+ await ensureDir(dirname(this.file));
219
+ await atomicWriteText(this.file, JSON.stringify(data, null, 2));
220
+ }
221
+ catch (err) {
222
+ this.degraded = err instanceof Error ? err.message : String(err);
223
+ if (!this.degradedLogged) {
224
+ this.degradedLogged = true;
225
+ this.logger?.warn(`[memory] md 投射账本写入失败,只读降级: ${this.degraded}`);
226
+ }
227
+ }
228
+ }
229
+ /**
230
+ * 等待在途写完成(测试/停机/导出收尾)。
231
+ *
232
+ * 合批之后这一步是**语义上的“现在就要落盘”**:清掉定时器并强制一次写,所以写完
233
+ * `set()` 不 `flush()` 就不能假设磁盘已经可信。`persistNow` 自带脏位判定,
234
+ * 没变更时不会空写一轮。
235
+ */
236
+ flush() {
237
+ if (this.timer !== undefined) {
238
+ clearTimeout(this.timer);
239
+ this.timer = undefined;
240
+ }
241
+ return this.enqueuePersist();
242
+ }
243
+ }
@@ -0,0 +1,27 @@
1
+ import { type MdLedgerStore } from './memory-md-ledger.js';
2
+ import type { MemoryLogger, MemoryRecord } from '../types.js';
3
+ import type { OrphanReport } from './memory-md-types.js';
4
+ export interface OrphanDeps {
5
+ /** 当前全部记录(含退场行——退场行仍认领自己的文件,那不是孤儿)。 */
6
+ records: readonly MemoryRecord[];
7
+ /** `cfg.memExport.root`(空 = 默认单根)。 */
8
+ cfgRoot: string | undefined;
9
+ dataDir: string;
10
+ ledger: MdLedgerStore;
11
+ /** true = 只报告不搬(默认值由端点侧兜死,这里不设默认)。 */
12
+ dryRun: boolean;
13
+ maxFiles: number;
14
+ timeBudgetMs: number;
15
+ logger?: MemoryLogger;
16
+ /** 归档根;缺省 `<dataDir>/memories-orphans`。测试注入临时目录用。 */
17
+ archiveRoot?: string;
18
+ /** 注入时钟(测试用)。 */
19
+ now?: () => number;
20
+ }
21
+ /**
22
+ * 扫一遍账本,把无人认领的投射文件搬进归档目录。
23
+ *
24
+ * @param deps - 见 `OrphanDeps`。
25
+ * @returns 报告;`dir` 在干跑时是空串(还没建批次目录)。任何单条失败只计数并留痕,绝不抛挂整轮。
26
+ */
27
+ export declare function sweepOrphanFiles(deps: OrphanDeps): Promise<OrphanReport>;