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
@@ -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>;
@@ -0,0 +1,153 @@
1
+ /**
2
+ * 孤儿投射文件的归档(P4.4)。
3
+ *
4
+ * 孤儿是怎么长出来的:投射路径 = `<type>/<name>.memory.md`,而 `name` 会变——本轮的
5
+ * 撞车消歧(42 条挤 17 个同名文件)、下一次的身份改名、以及记录被物理删除,都会让
6
+ * **磁盘上的旧路径不再属于任何记录**。它们既不报错也不碍事,只是越积越多,而人无法分辨
7
+ * 哪份是历史、哪份是刚被覆盖的。
8
+ *
9
+ * 只归档**有账可查**的那部分,判据是一条硬边界:
10
+ * - 账本里记着这个绝对路径(= 确认是我们自己写出去的),且**现在没有记录映射到它** → 孤儿,搬走;
11
+ * - 账本里没有 = 来路不明。那可能是人自己放进投射目录、等着被同步进来的输入,
12
+ * **绝对不许动**(md 同步侧对这类文件只计数不入库,同一个纪律)。
13
+ *
14
+ * 搬 ≠ 删:整批进 `<dataDir>/memories-orphans/<批次时间戳>/`,保留 `<ws|home>/<类型>/<名>` 的
15
+ * 相对形状。删是 cleanup 的活,这里只负责别再挡在活档案里。账本条目跟着删——文件已经搬走,
16
+ * 留着基线下一轮对账就会一直报「文件缺失」。
17
+ */
18
+ import { mkdir, rename, stat } from 'node:fs/promises';
19
+ import { dirname, join } from 'node:path';
20
+ import { disablePathOf, resolveMemoriesRoot, safeJoin, targetFor } from './memory-md.js';
21
+ import { deriveRelPath, looksAbsolute } from './memory-md-ledger.js';
22
+ /** 样本条数上限:孤儿可能上千,面板不需要全量。 */
23
+ const SAMPLE_CAP = 20;
24
+ /**
25
+ * 扫一遍账本,把无人认领的投射文件搬进归档目录。
26
+ *
27
+ * @param deps - 见 `OrphanDeps`。
28
+ * @returns 报告;`dir` 在干跑时是空串(还没建批次目录)。任何单条失败只计数并留痕,绝不抛挂整轮。
29
+ */
30
+ export async function sweepOrphanFiles(deps) {
31
+ const { records, cfgRoot, dataDir, ledger, logger } = deps;
32
+ const now = deps.now ?? Date.now;
33
+ const startedAt = now();
34
+ const report = {
35
+ dryRun: deps.dryRun,
36
+ scanned: 0,
37
+ claimed: 0,
38
+ orphans: 0,
39
+ moved: 0,
40
+ failed: 0,
41
+ missing: 0,
42
+ remaining: 0,
43
+ capped: false,
44
+ dir: '',
45
+ samples: [],
46
+ };
47
+ const sample = (path, reason) => {
48
+ if (report.samples.length < SAMPLE_CAP)
49
+ report.samples.push({ path, reason });
50
+ };
51
+ // 认领集:每条记录现在的路径 + 它的 `.disable` 兄弟。后者必须在集合里,否则 N11 的失效
52
+ // 凭证会被当成孤儿搬走——那个后缀就是“这条已被判过时”的唯一文件侧表达。
53
+ const claimed = new Set();
54
+ for (const r of records) {
55
+ const abs = targetFor(r, cfgRoot, dataDir, logger).absPath;
56
+ if (abs === undefined)
57
+ continue;
58
+ claimed.add(abs);
59
+ claimed.add(disablePathOf(abs));
60
+ }
61
+ const homeRoot = resolveMemoriesRoot(cfgRoot, dataDir, logger);
62
+ const stamp = new Date(now()).toISOString().replace(/[:.]/g, '-');
63
+ const archiveBase = deps.archiveRoot ?? join(dataDir, 'memories-orphans');
64
+ const archiveDir = join(archiveBase, stamp);
65
+ // 走的是**带键**的遍历:账本的键才是绝对路径,条目里的 root/relPath 只供恢复归档形状。
66
+ const rows = ledger.allWithKeys();
67
+ // 配额只数**动作**(搬走/失败的孤儿),确认过“仍被认领”的不吃配额——与导出侧同一口径。
68
+ // 旧写法在这里会把配额花在“翻了一页全是认领”上,于是后面的孤儿永远扫不到,
69
+ // 报告却说“没孤儿”:那是把谎报写进了护栏。
70
+ let worked = 0;
71
+ for (let i = 0; i < rows.length; i++) {
72
+ // 两条护栏都在循环顶部查(写在末尾会被 continue 跳过,导出侧栽过)。
73
+ if (worked >= deps.maxFiles || now() - startedAt >= deps.timeBudgetMs) {
74
+ report.capped = true;
75
+ report.remaining = rows.length - i;
76
+ break;
77
+ }
78
+ const { path, entry: e } = rows[i];
79
+ report.scanned++;
80
+ if (path.length === 0) {
81
+ report.failed++;
82
+ worked++;
83
+ continue;
84
+ }
85
+ if (claimed.has(path)) {
86
+ report.claimed++;
87
+ continue;
88
+ }
89
+ report.orphans++;
90
+ if (deps.dryRun) {
91
+ // 干跑里“报出一个孤儿”就是本轮将要做的一个动作,所以它吃配额。
92
+ worked++;
93
+ sample(path, 'orphan');
94
+ continue;
95
+ }
96
+ // 账本说有文件、盘上没有:那是“文件缺失”,对账已经在报了,这里不重复处理,
97
+ // 也**不删账本条目**(删了就把“人把文件移走了”这个事实一起抹掉)。
98
+ if ((await stat(path).catch(() => undefined)) === undefined) {
99
+ report.missing++;
100
+ continue;
101
+ }
102
+ const side = e.root === homeRoot ? 'home' : 'ws';
103
+ // **不直接信存下来的 relPath**:它曾经被加载器污染成绝对路径(真机 1167/1241 条),
104
+ // 而 `safeJoin` 拦不住它——`join(归档根, 'home/C:\\Users\\…')` 的结果仍以归档根开头,
105
+ // 于是拼出含冒号的非法目录、75 条搬家全 ENOENT。键才是事实源:按 root+键重推,
106
+ // 推不出再退回存值(存值看着像绝对路径就一律不走)。
107
+ const rel = deriveRelPath(e.root, path) ?? (looksAbsolute(e.relPath) ? undefined : e.relPath);
108
+ const dest = rel === undefined ? undefined : safeJoin(archiveDir, `${side}/${rel}`);
109
+ if (rel === undefined || dest === undefined) {
110
+ // 账本被人手工编辑过(relPath 越界或 root 丢失):宁可不动,也不要借这道手续把文件写到目录外。
111
+ report.failed++;
112
+ worked++;
113
+ sample(path, 'unsafe-relPath');
114
+ logger?.warn(`[memory] 孤儿投射跳过(推不出安全的归档相对路径): ${path} → ${e.relPath}`);
115
+ continue;
116
+ }
117
+ try {
118
+ await mkdir(dirname(dest), { recursive: true });
119
+ await rename(path, await freeSlot(dest));
120
+ ledger.delete(path);
121
+ report.moved++;
122
+ worked++;
123
+ sample(path, 'moved');
124
+ }
125
+ catch (err) {
126
+ report.failed++;
127
+ worked++;
128
+ sample(path, 'failed');
129
+ logger?.warn(`[memory] 孤儿投射搬家失败: ${path} - ${err instanceof Error ? err.message : String(err)}`);
130
+ }
131
+ }
132
+ if (!deps.dryRun && (report.moved > 0 || report.failed > 0))
133
+ report.dir = archiveDir;
134
+ await ledger.flush();
135
+ return report;
136
+ }
137
+ /**
138
+ * 目标位已被占用时加数字尾巴。
139
+ *
140
+ * 正常不会撞(每次跑都用新的批次目录),但账本里可能有两条指向同一 relPath 的不同绝对路径
141
+ * (不同 workspace 根),落进同一个 `ws/<类型>/<名>` 就会叠。叠了也不能覆盖——
142
+ * 那些文件是彼此不同的历史版本。
143
+ */
144
+ async function freeSlot(dest) {
145
+ if ((await stat(dest).catch(() => undefined)) === undefined)
146
+ return dest;
147
+ for (let n = 2; n <= 999; n++) {
148
+ const candidate = `${dest}.${n}`;
149
+ if ((await stat(candidate).catch(() => undefined)) === undefined)
150
+ return candidate;
151
+ }
152
+ return `${dest}.${Date.now()}`;
153
+ }
@@ -0,0 +1,35 @@
1
+ /** 解析出的文件形状(值已按渲染时的类型还原)。 */
2
+ export interface MdParsed {
3
+ /** 文件名干(= 渲染时写入的 `name` 键)。 */
4
+ name: string;
5
+ type: string;
6
+ priority: number;
7
+ /** ISO 串(渲染时就是 ISO;空串 = 没有)。 */
8
+ createdAt: string;
9
+ updatedAt: string;
10
+ usageScenario: string[];
11
+ extKey: string;
12
+ /** 文件里记的 record_id,只当线索。 */
13
+ id: string;
14
+ /** 文件声明的正文指纹(`hash` 键原文);空串 = 老文件没带(未签名)。 */
15
+ hash: string;
16
+ /**
17
+ * 声明指纹与正文实算的比对:`true` 一致、`false` 不符、`undefined` = 文件没声明(无从判)。
18
+ *
19
+ * `false` **不是**拒收理由:正文被人改过恰恰是 P2 要回灌的正常输入;它只说明「文件不是
20
+ * 我们上次写出去的那份字节」,交给调用方计入报告并重盖指纹。结构性拒收仍在更上面
21
+ * (形状不符 / 正文为空)。
22
+ */
23
+ hashValid: boolean | undefined;
24
+ /** frontmatter 带 `retired: true`。 */
25
+ retired: boolean;
26
+ /** 正文(已去首尾空白)。 */
27
+ content: string;
28
+ }
29
+ /**
30
+ * 解析一个投射文件。
31
+ *
32
+ * @param text - 文件全文。
33
+ * @returns 解析结果;形状不符(无 frontmatter / 无正文 / 键全不认识)时 `undefined`。
34
+ */
35
+ export declare function parseMdFile(text: string): MdParsed | undefined;
@@ -0,0 +1,85 @@
1
+ /**
2
+ * md 投射文件的解析(P2 反向同步的读侧)。
3
+ *
4
+ * 只认 `memory-md.ts` 自己渲染出来的那种 frontmatter:`key: <JSON 值>`,正文在第二个
5
+ * `---` 之后。**不做通用 YAML**——通用解析器会接受我们从不产出的形状,那些形状进到
6
+ * 回灌路径就是"看起来能读、读回来是垃圾"。解析不出来就返回 `undefined`,调用方跳过并
7
+ * 计数;绝不猜、绝不抛。
8
+ *
9
+ * 一条刻意的不对称:文件里的 `id` 只当**线索**,不当身份。id 每轮新铸(F1),一次合并
10
+ * 就换;真正认人靠账本键(绝对路径)与 `name`。
11
+ *
12
+ * 另有一条自校验:frontmatter 里的 `hash` 是投射时盖下的正文指纹(N13)。这里只**核对**它
13
+ * 与正文本体是否一致(`hashValid`),不替调用方决定怎么处置——那属于同步侧的裁决。
14
+ */
15
+ import { contentHash } from './memory-md.js';
16
+ /** 取 frontmatter 块:首行必须是 `---`,到下一个 `---` 为止。 */
17
+ function splitFrontmatter(text) {
18
+ // BOM 与 CRLF 都要先吃掉:人在记事本里改一行就会引入 \r,而 `\r` 会让 key 匹配不上。
19
+ const normalized = text.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n').replace(/\r/g, '\n');
20
+ const lines = normalized.split('\n');
21
+ if (lines.length < 2 || lines[0].trim() !== '---')
22
+ return undefined;
23
+ const end = lines.findIndex((l, i) => i > 0 && l.trim() === '---');
24
+ if (end < 0)
25
+ return undefined;
26
+ return { meta: lines.slice(1, end), body: lines.slice(end + 1).join('\n').trim() };
27
+ }
28
+ /** 一行 `key: <JSON>` → [key, 值];值解析失败则退回原始字符串。 */
29
+ function readLine(line) {
30
+ const m = /^([A-Za-z_][\w-]*)\s*:\s*(.*)$/.exec(line.trim());
31
+ if (m === null)
32
+ return undefined;
33
+ const raw = m[2];
34
+ if (raw.length === 0)
35
+ return [m[1], ''];
36
+ try {
37
+ return [m[1], JSON.parse(raw)];
38
+ }
39
+ catch {
40
+ return [m[1], raw];
41
+ }
42
+ }
43
+ function str(v) {
44
+ return typeof v === 'string' ? v : '';
45
+ }
46
+ /**
47
+ * 解析一个投射文件。
48
+ *
49
+ * @param text - 文件全文。
50
+ * @returns 解析结果;形状不符(无 frontmatter / 无正文 / 键全不认识)时 `undefined`。
51
+ */
52
+ export function parseMdFile(text) {
53
+ const parts = splitFrontmatter(text);
54
+ if (parts === undefined)
55
+ return undefined;
56
+ const fields = new Map();
57
+ for (const line of parts.meta) {
58
+ const kv = readLine(line);
59
+ if (kv !== undefined)
60
+ fields.set(kv[0], kv[1]);
61
+ }
62
+ // 正文为空 = 这条记忆没内容了。人手工清空正文不该被回灌成"一条空记忆",
63
+ // 那既不是有效输入,也会让对账以为文件是权威。
64
+ if (parts.body.length === 0)
65
+ return undefined;
66
+ const scenario = Array.isArray(fields.get('usageScenario'))
67
+ ? fields.get('usageScenario').filter((s) => typeof s === 'string' && s.length > 0)
68
+ : [];
69
+ const prio = Number(fields.get('priority'));
70
+ const declaredHash = str(fields.get('hash'));
71
+ return {
72
+ name: str(fields.get('name')),
73
+ type: str(fields.get('type')),
74
+ priority: Number.isFinite(prio) ? prio : 60,
75
+ createdAt: str(fields.get('createdAt')),
76
+ updatedAt: str(fields.get('updatedAt')),
77
+ usageScenario: scenario,
78
+ extKey: str(fields.get('extKey')),
79
+ id: str(fields.get('id')),
80
+ hash: declaredHash,
81
+ hashValid: declaredHash.length === 0 ? undefined : declaredHash === contentHash(parts.body),
82
+ retired: fields.get('retired') === true,
83
+ content: parts.body,
84
+ };
85
+ }
@@ -0,0 +1,18 @@
1
+ import type { MemoryLogger } from '../types.js';
2
+ /** 每类目录下的索引文件名(不带 `.memory.md`,因此天然落在投射/扫描面之外)。 */
3
+ export declare const SKELETON_INDEX_FILE = "_index.md";
4
+ export interface SkeletonDeps {
5
+ /** 要补骨架的投射根(绝对路径)列表。单根必补、workspace 根按需由调用方决定。 */
6
+ roots: readonly string[];
7
+ /** 目录名(= 类型)→ 库里条数;缺省按 0 计(0 也写,否则"没目录"与"0 条"仍无法区分)。 */
8
+ countsByType: ReadonlyMap<string, number>;
9
+ /** 诊断口:任何单目录写失败只走它留痕,绝不抛挂整轮。 */
10
+ logger?: MemoryLogger;
11
+ }
12
+ /**
13
+ * 往每个投射根补齐 37 类目录及各自的 `_index.md`。
14
+ *
15
+ * @param deps - 见 `SkeletonDeps`。
16
+ * @returns 无(副作用为落盘)。任何单目录失败只 warn,不抛。
17
+ */
18
+ export declare function ensureTypeSkeleton(deps: SkeletonDeps): Promise<void>;
@@ -0,0 +1,61 @@
1
+ /**
2
+ * 类型目录骨架 + `_index.md`(pm-name-distill Phase 4 / task_4.1)。
3
+ *
4
+ * 投射根(`<dataDir>/memories/` 或 `cfg.memExport.root`)过去只有"库里出现过记录"的类型目录
5
+ * (实测 6 个);词表已扩到 37 类。空目录会被 git/压缩包吐掉,而"没这个目录"与"这类 0 条"
6
+ * 在人眼里长得一样——所以每类目录固定放一个 `_index.md`(类名 + 库里条数),目录集合从
7
+ * **词表真源** `memoryTypeDirs()` 派生,不再手抄。
8
+ *
9
+ * 三条纪律:
10
+ * 1. **幂等**:内容不变就不重写(也不刷 mtime)。投射是"库 → 文件"的旁路,重刷 mtime 会让
11
+ * 下一轮对账把"我们没动过"误判成"文件被改过"——与 `exportRecords` 的 mtime 纪律同口径。
12
+ * 2. **失败只 warn 不抛**:旁路写挂不能弄挂记忆本体(照 `resolveMemoriesRoot` / 导出侧的降级口径)。
13
+ * 3. **原子落盘**:临时文件 + rename,走仓内既有范式 `atomicWriteText`(带 fsync / 安全路径校验 /
14
+ * 失败清理);根不存在由它内部 `ensureDir` recursive 建出。
15
+ *
16
+ * `_index.md` 刻意**不是**投射文件:md-sync 只认 `.memory.md` / `.memory.md.disable` 后缀
17
+ * (`listProjectionFiles`),孤儿扫描只遍历账本键(`sweepOrphanFiles`)——本模块从不把
18
+ * `_index.md` 写进账本,故两条通路都不会碰它(防误伤用例把这两条钉成断言)。
19
+ */
20
+ import { join } from 'node:path';
21
+ import { readFile } from 'node:fs/promises';
22
+ import { MEMORY_TYPE_LABELS, memoryTypeDirs } from '../memory-types.js';
23
+ import { atomicWriteText } from '../util/io.js';
24
+ /** 每类目录下的索引文件名(不带 `.memory.md`,因此天然落在投射/扫描面之外)。 */
25
+ export const SKELETON_INDEX_FILE = '_index.md';
26
+ /**
27
+ * 渲染一个目录的索引文本(纯函数:同一 (dir,label,count) 永远同一字节,幂等判据靠它)。
28
+ * 含类名标签、类型 id、条数三项——条数为 0 也如实写"0"。
29
+ */
30
+ function renderIndex(dir, label, count) {
31
+ return `# ${label}\n\n- 类型: ${dir}\n- 条数: ${count}\n`;
32
+ }
33
+ /**
34
+ * 往每个投射根补齐 37 类目录及各自的 `_index.md`。
35
+ *
36
+ * @param deps - 见 `SkeletonDeps`。
37
+ * @returns 无(副作用为落盘)。任何单目录失败只 warn,不抛。
38
+ */
39
+ export async function ensureTypeSkeleton(deps) {
40
+ const { roots, countsByType, logger } = deps;
41
+ // 目录集合来自词表真源,杜绝手抄漂移(换词表不改这里也不会静默少目录)。
42
+ const dirs = memoryTypeDirs();
43
+ for (const root of roots) {
44
+ for (const dir of dirs) {
45
+ const abs = join(root, dir, SKELETON_INDEX_FILE);
46
+ const count = countsByType.get(dir) ?? 0;
47
+ const label = MEMORY_TYPE_LABELS[dir] ?? dir;
48
+ const text = renderIndex(dir, label, count);
49
+ try {
50
+ // 读现内容比对:一致就跳过——既不重写,也不碰 mtime(atomicWriteText 会 rename 换 inode 刷 mtime)。
51
+ const existing = await readFile(abs, 'utf8').catch(() => undefined);
52
+ if (existing === text)
53
+ continue;
54
+ await atomicWriteText(abs, text, { logger });
55
+ }
56
+ catch (err) {
57
+ logger?.warn(`[memory] 类型骨架写入失败(旁路,不影响投射本体): ${abs} - ${err instanceof Error ? err.message : String(err)}`);
58
+ }
59
+ }
60
+ }
61
+ }