@yandy0725/memory-core 2.0.1 → 2.1.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 (114) hide show
  1. package/dist/config.d.ts +66 -0
  2. package/dist/config.d.ts.map +1 -1
  3. package/dist/config.js +69 -0
  4. package/dist/config.js.map +1 -1
  5. package/dist/index.d.ts +28 -7
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +28 -5
  8. package/dist/index.js.map +1 -1
  9. package/dist/injection.d.ts.map +1 -1
  10. package/dist/injection.js +1 -1
  11. package/dist/injection.js.map +1 -1
  12. package/dist/orchestration/conversation.d.ts.map +1 -1
  13. package/dist/orchestration/conversation.js +1 -2
  14. package/dist/orchestration/conversation.js.map +1 -1
  15. package/dist/orchestration/ports.d.ts.map +1 -1
  16. package/dist/orchestration/task.d.ts.map +1 -1
  17. package/dist/orchestration/task.js +1 -1
  18. package/dist/orchestration/task.js.map +1 -1
  19. package/dist/store/filename.d.ts +43 -0
  20. package/dist/store/filename.d.ts.map +1 -1
  21. package/dist/store/filename.js +72 -0
  22. package/dist/store/filename.js.map +1 -1
  23. package/dist/store/fs-lock.d.ts.map +1 -1
  24. package/dist/store/fs-lock.js +1 -3
  25. package/dist/store/fs-lock.js.map +1 -1
  26. package/dist/store/memory-store.d.ts +37 -0
  27. package/dist/store/memory-store.d.ts.map +1 -1
  28. package/dist/store/memory-store.js +169 -17
  29. package/dist/store/memory-store.js.map +1 -1
  30. package/dist/store/paths.d.ts.map +1 -1
  31. package/dist/store/paths.js +8 -2
  32. package/dist/store/paths.js.map +1 -1
  33. package/dist/store/process-lock.d.ts.map +1 -1
  34. package/dist/store/process-lock.js.map +1 -1
  35. package/dist/sync/backend-git.d.ts +27 -0
  36. package/dist/sync/backend-git.d.ts.map +1 -0
  37. package/dist/sync/backend-git.js +815 -0
  38. package/dist/sync/backend-git.js.map +1 -0
  39. package/dist/sync/backend.d.ts +213 -0
  40. package/dist/sync/backend.d.ts.map +1 -0
  41. package/dist/sync/backend.js +31 -0
  42. package/dist/sync/backend.js.map +1 -0
  43. package/dist/sync/conflict.d.ts +74 -0
  44. package/dist/sync/conflict.d.ts.map +1 -0
  45. package/dist/sync/conflict.js +164 -0
  46. package/dist/sync/conflict.js.map +1 -0
  47. package/dist/sync/device.d.ts +45 -0
  48. package/dist/sync/device.d.ts.map +1 -0
  49. package/dist/sync/device.js +227 -0
  50. package/dist/sync/device.js.map +1 -0
  51. package/dist/sync/diff.d.ts +78 -0
  52. package/dist/sync/diff.d.ts.map +1 -0
  53. package/dist/sync/diff.js +115 -0
  54. package/dist/sync/diff.js.map +1 -0
  55. package/dist/sync/engine.d.ts +142 -0
  56. package/dist/sync/engine.d.ts.map +1 -0
  57. package/dist/sync/engine.js +960 -0
  58. package/dist/sync/engine.js.map +1 -0
  59. package/dist/sync/hash.d.ts +11 -0
  60. package/dist/sync/hash.d.ts.map +1 -0
  61. package/dist/sync/hash.js +14 -0
  62. package/dist/sync/hash.js.map +1 -0
  63. package/dist/sync/manifest.d.ts +63 -0
  64. package/dist/sync/manifest.d.ts.map +1 -0
  65. package/dist/sync/manifest.js +117 -0
  66. package/dist/sync/manifest.js.map +1 -0
  67. package/dist/sync/paths.d.ts +70 -0
  68. package/dist/sync/paths.d.ts.map +1 -0
  69. package/dist/sync/paths.js +111 -0
  70. package/dist/sync/paths.js.map +1 -0
  71. package/dist/sync/scan.d.ts +44 -0
  72. package/dist/sync/scan.d.ts.map +1 -0
  73. package/dist/sync/scan.js +102 -0
  74. package/dist/sync/scan.js.map +1 -0
  75. package/dist/sync/scheduler.d.ts +81 -0
  76. package/dist/sync/scheduler.d.ts.map +1 -0
  77. package/dist/sync/scheduler.js +183 -0
  78. package/dist/sync/scheduler.js.map +1 -0
  79. package/dist/sync/types.d.ts +21 -0
  80. package/dist/sync/types.d.ts.map +1 -0
  81. package/dist/sync/types.js +8 -0
  82. package/dist/sync/types.js.map +1 -0
  83. package/dist/tool/handler.d.ts.map +1 -1
  84. package/dist/tool/handler.js +1 -3
  85. package/dist/tool/handler.js.map +1 -1
  86. package/package.json +13 -3
  87. package/src/config.ts +121 -0
  88. package/src/index.ts +101 -5
  89. package/src/injection.ts +1 -3
  90. package/src/orchestration/conversation.ts +1 -2
  91. package/src/orchestration/dream.ts +0 -1
  92. package/src/orchestration/extract.ts +0 -1
  93. package/src/orchestration/ports.ts +1 -5
  94. package/src/orchestration/prompts.ts +0 -1
  95. package/src/orchestration/surfacing.ts +0 -1
  96. package/src/orchestration/task.ts +2 -10
  97. package/src/store/filename.ts +71 -0
  98. package/src/store/fs-lock.ts +2 -6
  99. package/src/store/memory-store.ts +311 -134
  100. package/src/store/paths.ts +12 -3
  101. package/src/store/process-lock.ts +4 -1
  102. package/src/sync/backend-git.ts +902 -0
  103. package/src/sync/backend.ts +220 -0
  104. package/src/sync/conflict.ts +247 -0
  105. package/src/sync/device.ts +266 -0
  106. package/src/sync/diff.ts +152 -0
  107. package/src/sync/engine.ts +1258 -0
  108. package/src/sync/hash.ts +14 -0
  109. package/src/sync/manifest.ts +148 -0
  110. package/src/sync/paths.ts +117 -0
  111. package/src/sync/scan.ts +115 -0
  112. package/src/sync/scheduler.ts +241 -0
  113. package/src/sync/types.ts +22 -0
  114. package/src/tool/handler.ts +1 -3
@@ -1,16 +1,31 @@
1
1
  import { mkdir, readdir, readFile, stat, unlink, writeFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
- import { deriveDescription, type EntryType, parseEntryFile, serializeEntryFile } from "./entry-file.js";
3
+ import {
4
+ deriveDescription,
5
+ type EntryMeta,
6
+ type EntryType,
7
+ isEntryType,
8
+ parseEntryFile,
9
+ serializeEntryFile,
10
+ } from "./entry-file.js";
4
11
  import { formatIndexLine, indexCapacity, parseEntryIndex, removeIndexLine, upsertIndexLine } from "./entry-index.js";
5
- import { entryFileName, resolveUniqueFileName } from "./filename.js";
12
+ import {
13
+ ENTRY_INDEX_FILE,
14
+ entryFileName,
15
+ foldFileName,
16
+ isEntryFileNameForRead,
17
+ isEntryFileNameForWrite,
18
+ resolveUniqueFileName,
19
+ } from "./filename.js";
6
20
  import { withLock } from "./fs-lock.js";
7
21
  import { withFsRetry } from "./fs-retry.js";
8
22
  import { withFileMutationQueue } from "./mutation-queue.js";
9
23
  import { isProcessLockActive, tryWithProcessLock, withProcessLock } from "./process-lock.js";
10
24
  import { createSnapshot } from "./snapshot.js";
11
- import { isReservedWindowsName } from "./windows-names.js";
12
25
 
13
- export const INDEX_FILE = "MEMORY.md";
26
+ /** 索引文件名。字面值定义在 `filename.ts`(两个方向的文件名谓词也要用它,反向 import 会成环),
27
+ * 这里保持既有的公开出口不变。 */
28
+ export const INDEX_FILE = ENTRY_INDEX_FILE;
14
29
  export const LOCK_FILE = ".lock";
15
30
  export const BACKUP_DIR = ".backups";
16
31
 
@@ -58,6 +73,22 @@ export interface WriteOptions {
58
73
  skipSnapshot?: boolean;
59
74
  }
60
75
 
76
+ /** `applyEntryFiles` 的一项写入:`file` 是 memory 目录内的**单层**文件名,`raw` 是逐字落盘的全部内容。 */
77
+ export interface ApplyChange {
78
+ file: string;
79
+ raw: string;
80
+ }
81
+
82
+ /**
83
+ * `applyEntryFiles` 的结果清单。`rejected` 带原因词(稳定词汇,调用方拿去写 status),
84
+ * 因为「跳过一份远端文件」必须可见:静默跳过就是静默丢记忆。
85
+ */
86
+ export interface ApplyResult {
87
+ written: string[];
88
+ deleted: string[];
89
+ rejected: Array<{ file: string; reason: string }>;
90
+ }
91
+
61
92
  export interface StoreConfig {
62
93
  memoryDir: string;
63
94
  indexMaxLines: number;
@@ -125,18 +156,19 @@ export class MemoryStore {
125
156
  constructor(readonly cfg: StoreConfig) {}
126
157
 
127
158
  /** 读取路径与 `#savingQueue` 的 `mkdir` **刻意不重试**:读失败不改变磁盘状态,
128
- * 目录创建失败会在获取锁阶段就干净地报错(无残留);重试只会拖慢 fail-closed。 */
159
+ * 目录创建失败会在获取锁阶段就干净地报错(无残留);重试只会拖慢 fail-closed。
160
+ * 过滤口径住在 `filename.ts` 的**读方向**谓词(`isEntryFileNameForRead`):同步的本地扫描必须用同一份,
161
+ * 两处漂移会造成「store 看得到、同步看不到」的记忆。
162
+ *
163
+ * 这里的 `readdir(...).catch(() => [])` 可以宽松,而同步扫描里同形状的那一句必须上抛 ——
164
+ * 两者服务的是不同的东西:`#entryFiles` 只喂**读路径**(列条目、搜索、重建索引)与写路径的
165
+ * 占用名集合,一次读不到的最坏表现是「少显示一条记忆」,下一次调用就恢复;而同步扫描的结果是
166
+ * 三方合并的 **ours 一侧**,把「读不出来」压成「目录是空的」,差集就会读成「用户删光了全部记忆」
167
+ * 并把删除传播到远端。所以这个不对称是**有意**的,别把两边「统一」成同一个失败口径 ——
168
+ * 往宽松统一是丢数据,往严格统一会让一次目录抖动把 `listEntries()` 变成抛错。 */
129
169
  async #entryFiles(): Promise<string[]> {
130
170
  const names = await readdir(this.cfg.memoryDir).catch(() => []);
131
- return (
132
- names
133
- .filter((n) => n.endsWith(".md") && n !== INDEX_FILE && !n.startsWith("."))
134
- // win32:按名打开保留设备名文件会命中设备(CON 等),`readFile` 会阻塞在控制台而不是
135
- // 返回内容。修复前的版本或外部工具可能留下过这种文件,这里把它从清单里剔除 ——
136
- // 最坏表现是「该条记忆在本机不可见」,而不是卡住整个扫描。
137
- .filter((n) => !(this.#platform === "win32" && isReservedWindowsName(n)))
138
- .sort()
139
- );
171
+ return names.filter((n) => isEntryFileNameForRead(n, this.#platform)).sort();
140
172
  }
141
173
 
142
174
  /** 已被占用的文件名:磁盘上的条目文件 + 索引文件本身。
@@ -358,41 +390,41 @@ export class MemoryStore {
358
390
  if (!body) throw new Error("body is required");
359
391
 
360
392
  return this.#pipeline("add", options, async () => {
361
- const summaries = await this.listEntries();
362
- const existing = summaries.find((s) => s.name === name);
363
- const file = existing?.file ?? (await this.#resolveTargetFile(name));
364
- // 覆盖同名 entry 时**永远**保留磁盘上的 created(幂等覆盖的关键);
365
- // 只有新建时才接受调用方传入的值,缺省是今天。
366
- const created = existing
367
- ? ((await this.readEntry(existing.file))?.created ?? isoDate(new Date()))
368
- : (requestedCreated || isoDate(new Date()));
369
- // `|| name` 是必需的:deriveDescription 可能返回 "",而空 description 会让该 entry 对侧查询不可见。
370
- const description = requestedDescription || deriveDescription(body) || name;
371
- const now = new Date();
372
-
373
- await this.#maybeSnapshot(options, "write", [INDEX_FILE, file]);
374
- await this.#retry(() =>
375
- writeFile(
376
- join(this.cfg.memoryDir, file),
377
- serializeEntryFile(
378
- {
379
- name,
380
- description,
381
- type: input.type ?? existing?.type ?? "feedback",
382
- created,
383
- modified: now.toISOString(),
384
- },
385
- body,
386
- ),
387
- "utf8",
393
+ const summaries = await this.listEntries();
394
+ const existing = summaries.find((s) => s.name === name);
395
+ const file = existing?.file ?? (await this.#resolveTargetFile(name));
396
+ // 覆盖同名 entry 时**永远**保留磁盘上的 created(幂等覆盖的关键);
397
+ // 只有新建时才接受调用方传入的值,缺省是今天。
398
+ const created = existing
399
+ ? ((await this.readEntry(existing.file))?.created ?? isoDate(new Date()))
400
+ : requestedCreated || isoDate(new Date());
401
+ // `|| name` 是必需的:deriveDescription 可能返回 "",而空 description 会让该 entry 对侧查询不可见。
402
+ const description = requestedDescription || deriveDescription(body) || name;
403
+ const now = new Date();
404
+
405
+ await this.#maybeSnapshot(options, "write", [INDEX_FILE, file]);
406
+ await this.#retry(() =>
407
+ writeFile(
408
+ join(this.cfg.memoryDir, file),
409
+ serializeEntryFile(
410
+ {
411
+ name,
412
+ description,
413
+ type: input.type ?? existing?.type ?? "feedback",
414
+ created,
415
+ modified: now.toISOString(),
416
+ },
417
+ body,
388
418
  ),
389
- );
419
+ "utf8",
420
+ ),
421
+ );
390
422
 
391
- const next = upsertIndexLine(await this.readIndex(), { name, file, description });
392
- await this.#retry(() => writeFile(this.#indexPath(), next, "utf8"));
393
- this.#cache.delete(file);
423
+ const next = upsertIndexLine(await this.readIndex(), { name, file, description });
424
+ await this.#retry(() => writeFile(this.#indexPath(), next, "utf8"));
425
+ this.#cache.delete(file);
394
426
 
395
- return { file, capacityWarning: this.#capacityWarning(next) };
427
+ return { file, capacityWarning: this.#capacityWarning(next) };
396
428
  });
397
429
  }
398
430
 
@@ -402,86 +434,82 @@ export class MemoryStore {
402
434
  options?: WriteOptions,
403
435
  ): Promise<{ file: string; capacityWarning?: string }> {
404
436
  return this.#pipeline("replace", options, async () => {
405
- const current = await this.readEntry(ref);
406
- if (!current) throw new Error(`Entry "${ref}" not found`);
407
-
408
- const name = (patch.name ?? current.name).trim();
409
- this.#validateName(name);
410
- const requestedDescription = patch.description?.trim();
411
- this.#validateDescription(requestedDescription);
412
- const body = patch.body === undefined ? current.body : patch.body.trim();
413
- const description =
414
- requestedDescription ||
415
- (patch.body === undefined ? current.description : deriveDescription(body)) ||
416
- current.description ||
417
- name;
418
- // 改名不得撞名。`resolveUniqueFileName` 只防**文件名**冲突,所以不在这里拦下的话,
419
- // rename 到已存在的 name 会写出第二个同名 entry(索引里两行都写着 [A]),之后 readEntry /
420
- // addEntry 都会命中其中较旧的那个 —— 「精确同名即幂等」的硬约束就断了。
421
- // 选择报错而不是合并或自动加后缀:合并会静默覆盖对方的内容,加后缀则会篡改调用方给的 name,
422
- // 两者都在用户没要求的地方动记忆。
423
- if (name !== current.name && (await this.listEntries()).some((s) => s.name === name)) {
424
- throw new Error(`Entry "${name}" already exists`);
425
- }
426
- // 自碰撞(如 "A B" → "A-B" 派生出同一个文件名)由 #resolveTargetFile 的 exclude 参数复用原文件
427
- const file = name === current.name ? current.file : await this.#resolveTargetFile(name, current.file);
428
-
429
- await this.#maybeSnapshot(options, "write", [INDEX_FILE, current.file, file]);
430
- await this.#retry(() =>
431
- writeFile(
432
- join(this.cfg.memoryDir, file),
433
- serializeEntryFile(
434
- {
435
- name,
436
- description,
437
- type: patch.type ?? current.type,
438
- created: current.created,
439
- modified: new Date().toISOString(),
440
- },
441
- body,
442
- ),
443
- "utf8",
437
+ const current = await this.readEntry(ref);
438
+ if (!current) throw new Error(`Entry "${ref}" not found`);
439
+
440
+ const name = (patch.name ?? current.name).trim();
441
+ this.#validateName(name);
442
+ const requestedDescription = patch.description?.trim();
443
+ this.#validateDescription(requestedDescription);
444
+ const body = patch.body === undefined ? current.body : patch.body.trim();
445
+ const description =
446
+ requestedDescription ||
447
+ (patch.body === undefined ? current.description : deriveDescription(body)) ||
448
+ current.description ||
449
+ name;
450
+ // 改名不得撞名。`resolveUniqueFileName` 只防**文件名**冲突,所以不在这里拦下的话,
451
+ // rename 到已存在的 name 会写出第二个同名 entry(索引里两行都写着 [A]),之后 readEntry /
452
+ // addEntry 都会命中其中较旧的那个 —— 「精确同名即幂等」的硬约束就断了。
453
+ // 选择报错而不是合并或自动加后缀:合并会静默覆盖对方的内容,加后缀则会篡改调用方给的 name,
454
+ // 两者都在用户没要求的地方动记忆。
455
+ if (name !== current.name && (await this.listEntries()).some((s) => s.name === name)) {
456
+ throw new Error(`Entry "${name}" already exists`);
457
+ }
458
+ // 自碰撞(如 "A B" → "A-B" 派生出同一个文件名)由 #resolveTargetFile 的 exclude 参数复用原文件
459
+ const file = name === current.name ? current.file : await this.#resolveTargetFile(name, current.file);
460
+
461
+ await this.#maybeSnapshot(options, "write", [INDEX_FILE, current.file, file]);
462
+ await this.#retry(() =>
463
+ writeFile(
464
+ join(this.cfg.memoryDir, file),
465
+ serializeEntryFile(
466
+ {
467
+ name,
468
+ description,
469
+ type: patch.type ?? current.type,
470
+ created: current.created,
471
+ modified: new Date().toISOString(),
472
+ },
473
+ body,
444
474
  ),
445
- );
446
- if (file !== current.file) {
447
- // 大小写不敏感 / 做 Unicode 规范化的文件系统上,两个不同的字符串可能指向同一 inode;
448
- // 那种情况下上面的 writeFile 已经写穿了原文件,再 unlink 会把刚写入的文件删掉。
449
- const target = join(this.cfg.memoryDir, file);
450
- const source = join(this.cfg.memoryDir, current.file);
451
- if (!(await sameFile(target, source))) await this.#retry(() => unlinkStrict(source));
452
- }
453
-
454
- const raw = await this.readIndex();
455
- // 目标文件名可能残留一条陈旧索引行(手工删了文件却没删行)。不先清掉的话,
456
- // 下面的 atLineNo 覆盖会与它并存 → 同一个 file 出现两条索引行。
457
- // 必须先清再重新解析行号:删除会让后面的行号前移。
458
- // 仅在真正换文件时清理:未改名(含 "A B" → "A-B" 派生同名文件)时 file === current.file,
459
- // removeIndexLine 会删掉我们要保留位置的那一行,它会被 upsert 追加到索引末尾。
460
- const cleaned = file === current.file ? raw : removeIndexLine(raw, file);
461
- const line = parseEntryIndex(cleaned).entries.find((e) => e.file === current.file);
462
- const next = upsertIndexLine(
463
- cleaned,
464
- { name, file, description },
465
- line ? { atLineNo: line.lineNo } : undefined,
466
- );
467
- await this.#retry(() => writeFile(this.#indexPath(), next, "utf8"));
468
- this.#cache.delete(current.file);
469
- this.#cache.delete(file);
475
+ "utf8",
476
+ ),
477
+ );
478
+ if (file !== current.file) {
479
+ // 大小写不敏感 / 做 Unicode 规范化的文件系统上,两个不同的字符串可能指向同一 inode;
480
+ // 那种情况下上面的 writeFile 已经写穿了原文件,再 unlink 会把刚写入的文件删掉。
481
+ const target = join(this.cfg.memoryDir, file);
482
+ const source = join(this.cfg.memoryDir, current.file);
483
+ if (!(await sameFile(target, source))) await this.#retry(() => unlinkStrict(source));
484
+ }
470
485
 
471
- return { file, capacityWarning: this.#capacityWarning(next) };
486
+ const raw = await this.readIndex();
487
+ // 目标文件名可能残留一条陈旧索引行(手工删了文件却没删行)。不先清掉的话,
488
+ // 下面的 atLineNo 覆盖会与它并存 → 同一个 file 出现两条索引行。
489
+ // 必须先清再重新解析行号:删除会让后面的行号前移。
490
+ // 仅在真正换文件时清理:未改名(含 "A B" → "A-B" 派生同名文件)时 file === current.file,
491
+ // removeIndexLine 会删掉我们要保留位置的那一行,它会被 upsert 追加到索引末尾。
492
+ const cleaned = file === current.file ? raw : removeIndexLine(raw, file);
493
+ const line = parseEntryIndex(cleaned).entries.find((e) => e.file === current.file);
494
+ const next = upsertIndexLine(cleaned, { name, file, description }, line ? { atLineNo: line.lineNo } : undefined);
495
+ await this.#retry(() => writeFile(this.#indexPath(), next, "utf8"));
496
+ this.#cache.delete(current.file);
497
+ this.#cache.delete(file);
498
+
499
+ return { file, capacityWarning: this.#capacityWarning(next) };
472
500
  });
473
501
  }
474
502
 
475
503
  async removeEntry(ref: string, options?: WriteOptions): Promise<void> {
476
504
  await this.#pipeline("remove", options, async () => {
477
- const current = await this.readEntry(ref);
478
- if (!current) throw new Error(`Entry "${ref}" not found`);
479
-
480
- await this.#maybeSnapshot(options, "write", [INDEX_FILE, current.file]);
481
- await this.#retry(() => unlinkStrict(join(this.cfg.memoryDir, current.file)));
482
- const next = removeIndexLine(await this.readIndex(), current.file);
483
- await this.#retry(() => writeFile(this.#indexPath(), next, "utf8"));
484
- this.#cache.delete(current.file);
505
+ const current = await this.readEntry(ref);
506
+ if (!current) throw new Error(`Entry "${ref}" not found`);
507
+
508
+ await this.#maybeSnapshot(options, "write", [INDEX_FILE, current.file]);
509
+ await this.#retry(() => unlinkStrict(join(this.cfg.memoryDir, current.file)));
510
+ const next = removeIndexLine(await this.readIndex(), current.file);
511
+ await this.#retry(() => writeFile(this.#indexPath(), next, "utf8"));
512
+ this.#cache.delete(current.file);
485
513
  });
486
514
  }
487
515
 
@@ -490,28 +518,177 @@ export class MemoryStore {
490
518
  return { file };
491
519
  }
492
520
 
521
+ /**
522
+ * 写路径的文件名守卫:不合格就**整批抛错**(fail-closed),而不是把单份记进 `rejected`。
523
+ *
524
+ * 与 `#entryFiles` 的读口径刻意不同,因此这里问的是**写方向**谓词(`isEntryFileNameForWrite`,
525
+ * 恒为全平台最严的那一套):读路径只在 win32 上剔除 `con.md`,因为在 Linux 上它是一条合法记忆,
526
+ * 剔除等于静默丢记忆;写路径必须更严 —— 写下一个另一台设备读不出来的名字,就是把一条记忆推到
527
+ * 对端的黑洞里(在 win32 上按名打开还会命中控制台而不是文件)。
528
+ * 抛错而不是拒收:畸形路径是远端清单坏了(越目录写入的尝试),该让整轮同步停下来报错。
529
+ */
530
+ #assertEntryFileName(file: string): void {
531
+ if (!isEntryFileNameForWrite(file)) {
532
+ throw new Error(`Refusing to apply "${file}": not a single-level entry file name`);
533
+ }
534
+ }
535
+
536
+ /**
537
+ * frontmatter 能解析 ≠ 能落盘:name / created / type 还要过既有校验器。返回拒收原因(undefined = 通过)。
538
+ *
539
+ * 校验必须搬进这条原语:逐字写入绕过了 `addEntry`,而一个 `name` 里含 `](` 的远端条目落盘后
540
+ * 经 `rebuildIndex()` 会直接伪造索引行(`removeIndexLine` 删错行、真条目变孤儿)—— 那正是
541
+ * `#validateName` 存在的唯一后果。非法 `created` 同样致命:写出去会让 `parseEntryFile` 返回 null,
542
+ * 整条记忆对 store 静默不可见。
543
+ */
544
+ #rejectReason(meta: EntryMeta): string | undefined {
545
+ try {
546
+ this.#validateName(meta.name);
547
+ } catch {
548
+ return "invalid name";
549
+ }
550
+ try {
551
+ this.#validateCreated(meta.created);
552
+ } catch {
553
+ return "invalid created";
554
+ }
555
+ // 纵深防御:`parseEntryFile` 已经要求 type ∈ ENTRY_TYPES,认不出来直接返回 null(原因落在
556
+ // "unparsable frontmatter")。若哪天解析口径放宽,这一层拦住「写下一份类型不认识的文件」。
557
+ if (!isEntryType(meta.type)) return "invalid type";
558
+ return undefined;
559
+ }
560
+
561
+ /**
562
+ * 名字折叠碰撞:返回这一批里必须**跳过**的文件名。两层判据,缺一不可:
563
+ *
564
+ * - **折叠 key**(`foldFileName`,跟平台):`Foo.md` 与 `foo.md` 在 APFS / NTFS 上是同一个名字,
565
+ * 按名逐字直写会让后写的那份穿掉先写的那份 —— 远端两条记忆在本机只剩一条,而且不报错。
566
+ * 在 Linux 上它们是两条合法记忆,因此这一层按平台分支(与读口径同源)。
567
+ * - **inode 探测**(`sameFile`):字符串折叠不知道本地卷的真实语义(casefold 挂载、硬链接别名、
568
+ * 会做规范化的文件系统)。名字不同但指向同一个 inode 时,写下去就是写穿别人的文件 —— 以磁盘为准。
569
+ *
570
+ * 与既有文件**同名**(字符串相等)不算碰撞:那是更新,正是这条原语的常规用法。
571
+ */
572
+ async #collidingWrites(writes: ApplyChange[]): Promise<Set<string>> {
573
+ const skipped = new Set<string>();
574
+ const inBatch = new Set<string>();
575
+ for (const { file } of writes) {
576
+ const key = foldFileName(file, this.#platform);
577
+ // 批内撞车 → 后出现的那份跳过(不静默地后者覆盖前者,而是报出去让调用方进 status)。
578
+ if (inBatch.has(key)) skipped.add(file);
579
+ else inBatch.add(key);
580
+ }
581
+
582
+ const existing = await this.#entryFiles();
583
+ const byFold = new Map(existing.map((name) => [foldFileName(name, this.#platform), name]));
584
+ // `win32` 口径(NFC + 小写)与宿主平台无关,这里只拿它**挑探测候选**:真判据是紧随其后的
585
+ // `sameFile`。候选集通常为空,因此探测的 stat 开销只在真存在大小写别名时才付。
586
+ const byCaseFold = new Map<string, string[]>();
587
+ for (const name of existing) {
588
+ const key = foldFileName(name, "win32");
589
+ byCaseFold.set(key, [...(byCaseFold.get(key) ?? []), name]);
590
+ }
591
+
592
+ for (const { file } of writes) {
593
+ if (skipped.has(file)) continue;
594
+ const alias = byFold.get(foldFileName(file, this.#platform));
595
+ if (alias !== undefined && alias !== file) {
596
+ skipped.add(file);
597
+ continue;
598
+ }
599
+ for (const name of byCaseFold.get(foldFileName(file, "win32")) ?? []) {
600
+ if (name === file) continue;
601
+ if (await sameFile(join(this.cfg.memoryDir, file), join(this.cfg.memoryDir, name))) {
602
+ skipped.add(file);
603
+ break;
604
+ }
605
+ }
606
+ }
607
+ return skipped;
608
+ }
609
+
610
+ /**
611
+ * 逐字落盘一批 entry 文件 + 删除一批 entry 文件:**不**重新派生 description、**不**改
612
+ * created / modified、**不**碰 MEMORY.md(调用方之后自己 `rebuildIndex()`)。
613
+ *
614
+ * 为什么不能用 `addEntry` / `replaceEntry`:两者都会把 `modified` 重打成 `now`,而 `modified` 是跨设备
615
+ * 唯一的排序 / 冲突键 —— 重打一次就把远端的旧版本抬成「刚刚改过」,下次同步会把它反推回去
616
+ * 盖掉真正较新的那一份。因此需要一条不重新盖章的写入原语,而不是让同步引擎绕开 store 直接
617
+ * `writeFile`:`MemoryStore` 是 memory 目录的唯一写入通道,绕开它就同时绕开了锁、mutation 队列、
618
+ * 快照与重试。
619
+ *
620
+ * 逐字不等于不校验:每份落盘前过 `parseEntryFile` 与既有校验器,不合格的那份进 `rejected`(带原因)
621
+ * 而不落盘;路径不合格则**整批抛错**(畸形路径不得逃出目录)。走同一条 `#pipeline`,因此与所有
622
+ * 既有写入互斥。
623
+ */
624
+ async applyEntryFiles(
625
+ changes: { writes: ApplyChange[]; deletes: string[] },
626
+ options?: WriteOptions,
627
+ ): Promise<ApplyResult> {
628
+ return this.#pipeline("apply", options, async () => {
629
+ // 路径校验在快照与任何写入之前:不合格就一个文件都不写、也不留快照。
630
+ for (const { file } of changes.writes) this.#assertEntryFileName(file);
631
+ for (const file of changes.deletes) this.#assertEntryFileName(file);
632
+
633
+ const skipped = await this.#collidingWrites(changes.writes);
634
+ const rejected: ApplyResult["rejected"] = [];
635
+ for (const { file } of changes.writes) {
636
+ if (skipped.has(file)) rejected.push({ file, reason: "filename collision" });
637
+ }
638
+
639
+ await this.#maybeSnapshot(options, "apply", [INDEX_FILE, ...changes.writes.map((w) => w.file), ...changes.deletes]);
640
+
641
+ const written: string[] = [];
642
+ for (const { file, raw } of changes.writes) {
643
+ if (skipped.has(file)) continue;
644
+ const parsed = parseEntryFile(raw);
645
+ if (!parsed) {
646
+ rejected.push({ file, reason: "unparsable frontmatter" });
647
+ continue;
648
+ }
649
+ const reason = this.#rejectReason(parsed.meta);
650
+ if (reason) {
651
+ rejected.push({ file, reason });
652
+ continue;
653
+ }
654
+ // 写 `raw` 本身,不重新 serialize:serializeEntryFile 会改行尾、trim 正文、丢掉未知字段,
655
+ // 而那些字节正是内容 hash 与对端比对的基准。
656
+ await this.#retry(() => writeFile(join(this.cfg.memoryDir, file), raw, "utf8"));
657
+ written.push(file);
658
+ }
659
+
660
+ const deleted: string[] = [];
661
+ for (const file of changes.deletes) {
662
+ // `unlinkStrict`:ENOENT 视为已删(远端说它没了、本机也没了 = 目标已达成),其余错误上抛。
663
+ await this.#retry(() => unlinkStrict(join(this.cfg.memoryDir, file)));
664
+ deleted.push(file);
665
+ }
666
+
667
+ // 被写 / 被删 / 被拒的文件都要失效缓存:auto-surfacing 的注入清单来自这个 mtime 缓存,
668
+ // 漏掉会让本会话继续用旧正文(被拒的那份也可能曾在盘上)。
669
+ for (const file of [...written, ...deleted, ...rejected.map((r) => r.file)]) this.#cache.delete(file);
670
+
671
+ return { written, deleted, rejected };
672
+ });
673
+ }
674
+
493
675
  /** 从磁盘全量重建索引;保留第一条索引行之前的手写块,其余无法识别行丢弃。 */
494
676
  async rebuildIndex(options?: WriteOptions): Promise<{ entries: number; headerLines: number }> {
495
677
  return this.#pipeline("rebuild", options, async () => {
496
- await this.#maybeSnapshot(options, "index", [INDEX_FILE]);
678
+ await this.#maybeSnapshot(options, "index", [INDEX_FILE]);
497
679
 
498
- const summaries = await this.listEntries();
499
- const { lines, entries } = parseEntryIndex(await this.readIndex());
680
+ const summaries = await this.listEntries();
681
+ const { lines, entries } = parseEntryIndex(await this.readIndex());
500
682
 
501
- const header = lines.slice(0, entries[0]?.lineNo ?? lines.length);
502
- while (header.length > 0 && header[header.length - 1].trim() === "") header.pop();
503
- const effectiveHeader = header.length > 0 ? header : ["# Memory Index", ""];
683
+ const header = lines.slice(0, entries[0]?.lineNo ?? lines.length);
684
+ while (header.length > 0 && header[header.length - 1].trim() === "") header.pop();
685
+ const effectiveHeader = header.length > 0 ? header : ["# Memory Index", ""];
504
686
 
505
- const rebuilt = [
506
- ...effectiveHeader,
507
- ...summaries.map((s) => formatIndexLine(s.name, s.file, s.description)),
508
- ];
509
- await this.#retry(() =>
510
- writeFile(this.#indexPath(), rebuilt.length === 0 ? "" : `${rebuilt.join("\n")}\n`, "utf8"),
511
- );
512
- this.#cache.clear();
687
+ const rebuilt = [...effectiveHeader, ...summaries.map((s) => formatIndexLine(s.name, s.file, s.description))];
688
+ await this.#retry(() => writeFile(this.#indexPath(), rebuilt.length === 0 ? "" : `${rebuilt.join("\n")}\n`, "utf8"));
689
+ this.#cache.clear();
513
690
 
514
- return { entries: summaries.length, headerLines: effectiveHeader.length };
691
+ return { entries: summaries.length, headerLines: effectiveHeader.length };
515
692
  });
516
693
  }
517
694
  }
@@ -134,7 +134,10 @@ export function normalizeRemoteUrl(url: string, platform: NodeJS.Platform = proc
134
134
  } catch {
135
135
  return null;
136
136
  }
137
- const scheme = parsed.protocol.slice(0, -1).toLowerCase().replace(/^git\+/, "");
137
+ const scheme = parsed.protocol
138
+ .slice(0, -1)
139
+ .toLowerCase()
140
+ .replace(/^git\+/, "");
138
141
  if (!GIT_PROTOCOLS.has(scheme)) return null;
139
142
  // Non-special schemes (ssh, git) keep an opaque, un-normalized host.
140
143
  host = normalizeHost(parsed.hostname);
@@ -155,7 +158,10 @@ export function normalizeRemoteUrl(url: string, platform: NodeJS.Platform = proc
155
158
  }
156
159
 
157
160
  if (!host) return null;
158
- path = path.replace(/^\/+/, "").replace(/\/+$/, "").replace(/\.git$/i, "");
161
+ path = path
162
+ .replace(/^\/+/, "")
163
+ .replace(/\/+$/, "")
164
+ .replace(/\.git$/i, "");
159
165
  if (!path) return null;
160
166
  return `${host}/${path}`;
161
167
  }
@@ -202,7 +208,10 @@ async function gitRemoteUrls(cwd: string): Promise<RemoteEntry[]> {
202
208
  *
203
209
  * `platform` 决定盘符远程的归类(win32 上当本地路径,见 `normalizeRemoteUrl`);其余平台行为不变。
204
210
  */
205
- export async function projectIdentity(cwd: string, platform: NodeJS.Platform = process.platform): Promise<ProjectIdentity> {
211
+ export async function projectIdentity(
212
+ cwd: string,
213
+ platform: NodeJS.Platform = process.platform,
214
+ ): Promise<ProjectIdentity> {
206
215
  const toplevel = await gitToplevel(cwd);
207
216
  if (!toplevel) return { kind: "local", key: resolve(cwd) };
208
217
  for (const { url } of await gitRemoteUrls(cwd)) {
@@ -62,7 +62,10 @@ async function acquire(key: string, timeoutMs: number): Promise<() => void> {
62
62
  const gate = new Promise<void>((resolve) => {
63
63
  release = resolve;
64
64
  });
65
- state.tail = previous.then(() => gate, () => gate);
65
+ state.tail = previous.then(
66
+ () => gate,
67
+ () => gate,
68
+ );
66
69
  state.waiters += 1;
67
70
 
68
71
  let timer: ReturnType<typeof setTimeout> | undefined;