@modusensus/dsh-mneme 0.8.14 → 0.8.15

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 (271) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +655 -654
  3. package/assets/icon.svg +3 -3
  4. package/bin/cli.mjs +689 -689
  5. package/bin/dsh-mneme-mcp.mjs +510 -510
  6. package/bin/dsh-mneme-serve.mjs +151 -151
  7. package/cordis.patch.yml +21 -21
  8. package/docs/CONFIGURATION.md +263 -0
  9. package/docs/CONTINUITY.md +124 -0
  10. package/docs/DAEMON.md +46 -0
  11. package/docs/ENTITIES.md +245 -0
  12. package/docs/LOCAL_MODEL.md +222 -0
  13. package/docs/SEMANTIC.md +256 -0
  14. package/docs/SLEEP.md +164 -0
  15. package/docs/STORAGE.md +89 -0
  16. package/lib/api-standalone.js +673 -673
  17. package/lib/api.js +1437 -1433
  18. package/lib/bootstrap.js +211 -211
  19. package/lib/client.js +5444 -5295
  20. package/lib/commands.js +95 -95
  21. package/lib/config.js +880 -843
  22. package/lib/content-hash.js +54 -54
  23. package/lib/continuity.js +220 -220
  24. package/lib/document-index.js +94 -94
  25. package/lib/document.js +303 -303
  26. package/lib/dream/clustering.js +118 -118
  27. package/lib/dream/decisions.js +674 -674
  28. package/lib/dream/narratives.js +44 -44
  29. package/lib/dream/sleep.js +819 -819
  30. package/lib/dream.js +1501 -1501
  31. package/lib/embedding.js +177 -177
  32. package/lib/entities/extractor.js +252 -252
  33. package/lib/graph/anchoring.js +179 -179
  34. package/lib/guide.js +74 -74
  35. package/lib/heat.js +144 -144
  36. package/lib/hot-memory.js +46 -46
  37. package/lib/index.js +535 -503
  38. package/lib/inject.js +464 -450
  39. package/lib/lang.js +248 -229
  40. package/lib/local-embedder.js +358 -358
  41. package/lib/maintenance.js +142 -142
  42. package/lib/mirror.js +330 -330
  43. package/lib/organize.js +450 -450
  44. package/lib/peak-hours.js +117 -117
  45. package/lib/placement.js +13 -0
  46. package/lib/pre-inject-gate.js +340 -0
  47. package/lib/quality-filter.js +126 -126
  48. package/lib/recall-stats.js +175 -175
  49. package/lib/reranker.js +269 -269
  50. package/lib/runtime/adopt.js +176 -176
  51. package/lib/runtime/closure.js +172 -172
  52. package/lib/runtime/download.js +247 -247
  53. package/lib/runtime/layout.js +265 -265
  54. package/lib/runtime/loader.js +252 -252
  55. package/lib/runtime/model-fetch.js +361 -361
  56. package/lib/runtime/provision.js +237 -237
  57. package/lib/runtime/tarball.js +130 -130
  58. package/lib/runtime/verify.js +223 -204
  59. package/lib/scope.js +146 -146
  60. package/lib/search/adaptive.js +60 -60
  61. package/lib/search/bm25.js +96 -96
  62. package/lib/semantic.js +229 -229
  63. package/lib/sensitive-scan.js +209 -209
  64. package/lib/serve.js +215 -215
  65. package/lib/service.js +2663 -2605
  66. package/lib/settings.js +517 -511
  67. package/lib/store.js +3007 -3007
  68. package/lib/summarize.js +891 -856
  69. package/lib/tools.js +743 -737
  70. package/lib/vector-index.js +110 -110
  71. package/lib/version-check.js +122 -122
  72. package/lib/write-admission.js +402 -402
  73. package/locale/en.json +6 -6
  74. package/locale/zh.json +6 -6
  75. package/package.json +118 -118
  76. package/runtime-manifest.json +526 -526
  77. package/scripts/benchmark-embed.js +201 -201
  78. package/scripts/benchmark-recall.js +249 -249
  79. package/scripts/benchmark-rerank.js +166 -166
  80. package/scripts/build-runtime-manifest.mjs +224 -224
  81. package/scripts/changelog-prep.mjs +36 -36
  82. package/scripts/check-sync.js +42 -42
  83. package/scripts/e2e-dsh.js +220 -220
  84. package/scripts/mneme-runtime.mjs +277 -277
  85. package/scripts/stress-dsh.js +255 -255
  86. package/scripts/sync-lib.js +47 -47
  87. package/scripts/sync-test-badge.mjs +69 -69
  88. package/scripts/test-count-sync.mjs +44 -44
  89. package/src/api-standalone.js +673 -673
  90. package/src/api.js +1437 -1433
  91. package/src/bootstrap.js +211 -211
  92. package/src/commands.js +95 -95
  93. package/src/config.js +880 -843
  94. package/src/content-hash.js +54 -54
  95. package/src/continuity.js +220 -220
  96. package/src/document-index.js +94 -94
  97. package/src/document.js +303 -303
  98. package/src/dream/clustering.js +118 -118
  99. package/src/dream/decisions.js +674 -674
  100. package/src/dream/narratives.js +44 -44
  101. package/src/dream/sleep.js +819 -819
  102. package/src/dream.js +1501 -1501
  103. package/src/embedding.js +177 -177
  104. package/src/entities/extractor.js +252 -252
  105. package/src/graph/anchoring.js +179 -179
  106. package/src/guide.js +74 -74
  107. package/src/heat.js +144 -144
  108. package/src/hot-memory.js +46 -46
  109. package/src/index.js +535 -503
  110. package/src/inject.js +464 -450
  111. package/src/lang.js +248 -229
  112. package/src/local-embedder.js +358 -358
  113. package/src/maintenance.js +142 -142
  114. package/src/mirror.js +330 -330
  115. package/src/organize.js +450 -450
  116. package/src/peak-hours.js +117 -117
  117. package/src/placement.js +13 -0
  118. package/src/pre-inject-gate.js +340 -0
  119. package/src/quality-filter.js +126 -126
  120. package/src/recall-stats.js +175 -175
  121. package/src/reranker.js +269 -269
  122. package/src/runtime/adopt.js +176 -176
  123. package/src/runtime/closure.js +172 -172
  124. package/src/runtime/download.js +247 -247
  125. package/src/runtime/layout.js +265 -265
  126. package/src/runtime/loader.js +252 -252
  127. package/src/runtime/model-fetch.js +361 -361
  128. package/src/runtime/provision.js +237 -237
  129. package/src/runtime/tarball.js +130 -130
  130. package/src/runtime/verify.js +223 -204
  131. package/src/scope.js +146 -146
  132. package/src/search/adaptive.js +60 -60
  133. package/src/search/bm25.js +96 -96
  134. package/src/semantic.js +229 -229
  135. package/src/sensitive-scan.js +209 -209
  136. package/src/serve.js +215 -215
  137. package/src/service.js +2663 -2605
  138. package/src/settings.js +517 -511
  139. package/src/store.js +3007 -3007
  140. package/src/summarize.js +891 -856
  141. package/src/tools.js +743 -737
  142. package/src/vector-index.js +110 -110
  143. package/src/version-check.js +122 -122
  144. package/src/write-admission.js +402 -402
  145. package/test/adaptive-inject-budget.test.js +85 -85
  146. package/test/api-routes-gaps.test.js +218 -218
  147. package/test/api.test.js +1702 -1690
  148. package/test/audit.test.js +448 -448
  149. package/test/baseline-persist.test.js +248 -248
  150. package/test/benchmark.test.js +94 -94
  151. package/test/bootstrap.test.js +220 -220
  152. package/test/changelog-prep.test.js +60 -60
  153. package/test/client.test.js +1230 -1169
  154. package/test/clustering.test.js +100 -100
  155. package/test/commands.test.js +106 -106
  156. package/test/config.test.js +66 -66
  157. package/test/conflict-freeze.test.js +300 -300
  158. package/test/content-hash.test.js +204 -204
  159. package/test/continuity.test.js +333 -333
  160. package/test/document-dir.test.js +233 -233
  161. package/test/document.test.js +671 -671
  162. package/test/dream-archive-guardrail.test.js +116 -116
  163. package/test/dream-candidate-hybrid.test.js +281 -281
  164. package/test/dream-coverage-degrade.test.js +86 -86
  165. package/test/dream-failure-backoff.test.js +173 -173
  166. package/test/dream-peak-hours.test.js +194 -194
  167. package/test/dream.test.js +1385 -1383
  168. package/test/embedding-ready.test.js +196 -196
  169. package/test/entities.test.js +627 -547
  170. package/test/entity-recall.test.js +179 -179
  171. package/test/epistemic.test.js +325 -325
  172. package/test/fnew-0112.test.js +311 -311
  173. package/test/fnew-03.test.js +422 -422
  174. package/test/graph-anchoring.test.js +184 -184
  175. package/test/graph-api.test.js +175 -175
  176. package/test/graph-inject-budget.test.js +103 -103
  177. package/test/graph-passive-confirm.test.js +91 -91
  178. package/test/graph-weight-evolution.test.js +104 -104
  179. package/test/heat.test.js +175 -175
  180. package/test/helpers/dream-mock.js +82 -82
  181. package/test/helpers/peer-worker.mjs +25 -25
  182. package/test/helpers/runtime-source.js +60 -60
  183. package/test/helpers/tar-builder.js +80 -80
  184. package/test/helpers/write-admission-samples.js +308 -308
  185. package/test/hot-memory.test.js +145 -145
  186. package/test/inject-content-cap.test.js +91 -91
  187. package/test/inject-first-round-bm25.test.js +89 -89
  188. package/test/inject-heat.test.js +77 -77
  189. package/test/inject-parent-gate.test.js +106 -106
  190. package/test/inject-pools.test.js +194 -194
  191. package/test/inject-recall.test.js +105 -105
  192. package/test/inject-rotation.test.js +161 -161
  193. package/test/inject.test.js +420 -420
  194. package/test/lang.test.js +87 -87
  195. package/test/lastrunat-persist.test.js +101 -101
  196. package/test/lib-smoke.test.js +96 -96
  197. package/test/llm-audit.test.js +598 -598
  198. package/test/llm-message-source.test.js +207 -207
  199. package/test/local-embedder.test.js +292 -292
  200. package/test/maintenance.test.js +391 -391
  201. package/test/mcp-stdio.test.js +385 -385
  202. package/test/mirror-dirty.test.js +424 -424
  203. package/test/mirror-edit-digest.test.js +187 -187
  204. package/test/mirror-generation.test.js +499 -499
  205. package/test/mirror-types.test.js +208 -208
  206. package/test/mirror.test.js +249 -249
  207. package/test/narrative-bars.test.js +162 -162
  208. package/test/organize.test.js +418 -418
  209. package/test/peer-blockers.test.js +193 -193
  210. package/test/placement.test.js +36 -0
  211. package/test/policy-epoch.test.js +259 -259
  212. package/test/pre-inject-gate.test.js +434 -0
  213. package/test/quality-filter.test.js +186 -186
  214. package/test/reasoning-effort.test.js +752 -752
  215. package/test/recall-anti-update.test.js +82 -82
  216. package/test/recall-evals.test.js +253 -253
  217. package/test/recall-layer.test.js +326 -326
  218. package/test/recall-stats.test.js +189 -189
  219. package/test/receipt-chain.test.js +451 -451
  220. package/test/reflection.test.js +226 -226
  221. package/test/reindex-backfill.test.js +62 -62
  222. package/test/reranker-single-logit.test.js +73 -73
  223. package/test/reranker.test.js +197 -197
  224. package/test/resident-status.test.js +83 -83
  225. package/test/runtime-adopt.test.js +206 -206
  226. package/test/runtime-cli.test.js +89 -89
  227. package/test/runtime-closure.test.js +193 -193
  228. package/test/runtime-download.test.js +302 -302
  229. package/test/runtime-layout.test.js +163 -163
  230. package/test/runtime-loader.test.js +255 -255
  231. package/test/runtime-manifest.test.js +159 -159
  232. package/test/runtime-model-fetch.test.js +348 -348
  233. package/test/runtime-provision.test.js +101 -101
  234. package/test/runtime-tarball.test.js +81 -81
  235. package/test/runtime-verify.test.js +319 -279
  236. package/test/scope-candidates.test.js +239 -239
  237. package/test/scope-explicit.test.js +452 -452
  238. package/test/scope-inject-soft.test.js +106 -106
  239. package/test/scope-retrieval.test.js +197 -197
  240. package/test/scope-storage.test.js +255 -255
  241. package/test/scope-strict.test.js +367 -367
  242. package/test/search-fusion.test.js +90 -90
  243. package/test/semantic.test.js +124 -124
  244. package/test/sensitive-scan.test.js +207 -207
  245. package/test/serve-bin.test.js +121 -121
  246. package/test/serve.test.js +196 -196
  247. package/test/service-search.test.js +174 -174
  248. package/test/service.test.js +405 -405
  249. package/test/settings.test.js +261 -261
  250. package/test/skip-invalid-restore.test.js +262 -262
  251. package/test/sleep-action-set.test.js +422 -422
  252. package/test/sleep-heat.test.js +126 -126
  253. package/test/sleep-scheduler-rearm.test.js +89 -89
  254. package/test/sleep.test.js +527 -527
  255. package/test/standalone-api.test.js +751 -751
  256. package/test/store-vector-cache.test.js +98 -98
  257. package/test/store.test.js +396 -396
  258. package/test/stress.test.js +209 -209
  259. package/test/summarize-occurred-at.test.js +127 -0
  260. package/test/summarize-reasoning-effort.test.js +189 -189
  261. package/test/summarize.test.js +1465 -1465
  262. package/test/supersede-state-tracking.test.js +394 -0
  263. package/test/test-count-sync.test.js +60 -60
  264. package/test/tools-dto-schema-matrix.test.js +325 -325
  265. package/test/tools-memory-get-schema.test.js +65 -65
  266. package/test/tools.test.js +428 -374
  267. package/test/updated-at-semantics.test.js +113 -113
  268. package/test/vector-index.test.js +221 -221
  269. package/test/version-check.test.js +152 -152
  270. package/test/webServer-optional.test.js +82 -82
  271. package/test/write-admission.test.js +668 -668
package/src/mirror.js CHANGED
@@ -1,330 +1,330 @@
1
- import { mkdirSync, readFileSync, writeFileSync, existsSync, rmSync } from "node:fs";
2
- import { createHash } from "node:crypto";
3
- import { join } from "node:path";
4
- import { STR, langOf } from "./lang.js";
5
- import { PACKAGE_VERSION } from "./version-check.js";
6
-
7
- export const TYPE_FILE = {
8
- preference: "preferences.md",
9
- project: "projects.md",
10
- decision: "decisions.md",
11
- history: "history.md",
12
- summary: "summary.md",
13
- pitfall: "pitfalls.md",
14
- constraint: "constraints.md",
15
- rejected_solution: "rejected-solutions.md",
16
- pattern: "patterns.md",
17
- document: "documents.md"
18
- };
19
-
20
- // 不落镜像的 type。现在是空集:document 由 #296 第二批收进来——第一版排除它的
21
- // 理由是「镜像块渲染的字段里没有 doc_path,落一个 documents.md 会得到看着完整、
22
- // 其实找不到文件的视图」,doc_path 进指针行以后这条理由不再成立。这个集合由
23
- // test/mirror-types.test.js 钉死(TYPES \ TYPE_FILE 必须恰好等于本集合):将来
24
- // 新增 type 时必须显式决定它落不落镜像,静默漏掉才是 #278 要修的那种盲区。
25
- export const MIRROR_EXCLUDED_TYPES = new Set([]);
26
-
27
- // 只读视图(#296):document 的镜像只有指针行(id + 标题 + 摘要首句 + doc_path),
28
- // 没有可编辑的正文,所以它不参与 readHumanEdits 的人改合并,手工改动一律被下次
29
- // 同步覆盖;导出/导入也不带它(那两个是 round-trip 通道,见 api.js)。
30
- export const MIRROR_READONLY_TYPES = new Set(["document"]);
31
-
32
- const ESCAPE = /([\\`*_[\]{}()#+.!|>~-])/g;
33
- const UNESCAPE = new RegExp("\\\\" + ESCAPE.source, "g");
34
- // 只读视图里摘要首句的字符上限(#296):够定位就行,长摘要不该把一行撑成一段。
35
- const POINTER_MAX = 120;
36
-
37
- function esc(text) {
38
- return String(text).replace(ESCAPE, "\\$1");
39
- }
40
-
41
- function unescape(text) {
42
- return String(text).replace(UNESCAPE, "$1");
43
- }
44
-
45
- // 元数据标签的唯一来源是 STR.mirrorLabel(lang.js):渲染按实例语言取,解析两套
46
- // 都认。下面的正则从那张表现算——旧实现把手写正则与 lang.js 的两处文案分开维护,
47
- // 加一个字段要改三处,漏改一处那一行就会随 /import 写进 entry 的 content。
48
- const META_LABELS = [];
49
- for (const language of Object.keys(STR.mirrorLabel)) META_LABELS.push(...Object.values(STR.mirrorLabel[language]));
50
-
51
- function reEsc(text) {
52
- return String(text).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
53
- }
54
-
55
- // 元数据行的值必须单行:值里带换行会多出一行,轻则破坏「整段元数据行」的解析契约,
56
- // 重则伪造出一个条目头(sensitivity 是调用方传入的任意字符串,属信任边界)。
57
- function oneLine(text) {
58
- return String(text).replace(/\s+/g, " ").trim();
59
- }
60
-
61
- // 结构头锚:ID 行紧跟类型行才算条目头(正文里机器格式的 ID 行不会切开块)。
62
- const ANCHOR_RE = new RegExp(
63
- "^- \\*\\*ID\\*\\*: `([^`]+)`\\n- \\*\\*(?:" +
64
- [STR.mirrorLabel.zh.type, STR.mirrorLabel.en.type].map(reEsc).join("|") +
65
- ")\\*\\*:", "gm"
66
- );
67
- // 渲染出的整段元数据行(加字段只需改 lang.js 的 mirrorLabel)。
68
- const META_RUN_RE = new RegExp(`^(- \\*\\*(?:${META_LABELS.map(reEsc).join("|")})\\*\\*:.*\\n?)+`);
69
- // 三方合并用的版本令牌(service.syncMirror 比对并发写)。
70
- const UPDATED_RE = new RegExp(
71
- "- \\*\\*(?:" + [STR.mirrorLabel.zh.updated, STR.mirrorLabel.en.updated].map(reEsc).join("|") +
72
- ")\\*\\*: ([^\\n]+)"
73
- );
74
-
75
- function renderMemory(m, language = "zh") {
76
- // last-rendered digest baseline: sha256(title \x00 content). service.js
77
- // compares the file hash against this to tell "untouched by a human" (machine
78
- // write wins) apart from a real human edit, so a not-yet-re-rendered store
79
- // update is not misread as a concurrent human edit.
80
- const digest = createHash("sha256")
81
- .update(`${m.title}\x00${m.content}`)
82
- .digest("hex");
83
- const lines = [];
84
- lines.push(`## ${esc(m.title)}`);
85
- lines.push("");
86
- lines.push(`- **ID**: \`${m.id}\``);
87
- const ML = STR.mirrorLabel[language];
88
- lines.push(`- **${ML.type}**: ${m.type}`);
89
- lines.push(`- **${ML.importance}**: ${m.importance}`);
90
- lines.push(`- **${ML.tags}**: ${m.tags.map((t) => `\`${esc(t)}\``).join(" ")}`);
91
- lines.push(`- **${ML.updated}**: ${m.updated_at}`);
92
- if (m.source) lines.push(`- **${ML.source}**: ${esc(m.source)}`);
93
- // scope / sensitivity 是只读展示字段(#278:sensitivity 的过滤语义另有归属,见
94
- // 那条讨论;这里只把值摆出来)。只在真有值时渲染——本机 2843 条里只有 37 条带
95
- // scope 标签,无条件渲染会给每条目多添两行空字段。
96
- const scopeText = [m.agent_scope, m.workspace_scope].filter(Boolean).map(oneLine).join(" / ");
97
- if (scopeText) lines.push(`- **${ML.scope}**: ${scopeText}`);
98
- if (m.sensitivity) lines.push(`- **${ML.sensitivity}**: ${oneLine(m.sensitivity)}`);
99
- lines.push("");
100
- lines.push(`<!-- mirror-digest: ${digest} -->`);
101
- lines.push(m.content);
102
- lines.push("");
103
- lines.push("---");
104
- lines.push("");
105
- return lines.join("\n");
106
- }
107
-
108
- /**
109
- * 文件头 frontmatter(#278 第一批)。按 type 分文件时一个文件装 N 条,所以
110
- * frontmatter 只能落在文件级;条目级元数据仍是块内的 `- **字段**:` 行(见
111
- * renderMemory)。
112
- *
113
- * `covered` / `coverage` 是覆盖范围的自述,必须与这份文本**实际包含的行集**一致:
114
- * 磁盘镜像写活跃集(active-only),/export 的文档写全表(all)。说一套写一套比
115
- * 没有这个字段更糟——外部工具会照着它聚合条数与标签。
116
- *
117
- * 键名对齐 OKF v0.2:`type` 是 §4.1 唯一必填键,`tags` 是 §4.1 的推荐键,
118
- * `generated.by` / `generated.at` 是 §5.2 的 trust 族。`generated.by` 按 §7 的
119
- * actor 约定写 `<producer>/<version>`(所以带版本号);`generated.at` 按 §5.2 的
120
- * 语义只记「内容上次真正变化」,sync 因此只在正文变化时才落盘。v0.2 已用
121
- * `generated.at` 取代 v0.1 的 `timestamp`,没有 `updated` 这个顶层键。
122
- * `covered` / `coverage` 是 §4.1 允许的 producer 自定义扩展。
123
- */
124
- export function renderFileHeader(type, items, coverage = "active-only") {
125
- const tags = [...new Set(items.flatMap((m) => (m.tags ?? []).map(String)))].sort();
126
- return [
127
- "---",
128
- `type: ${type}`,
129
- `generated.by: dsh-mneme/${PACKAGE_VERSION}`,
130
- `generated.at: ${new Date().toISOString()}`,
131
- `covered: ${items.length}`,
132
- `coverage: ${coverage}`,
133
- `tags: [${tags.map((t) => JSON.stringify(t)).join(", ")}]`,
134
- "---",
135
- ""
136
- ].join("\n");
137
- }
138
-
139
- /**
140
- * document 的只读视图一行(#296):一句话定位 + 文件指针,正文按 doc_path 去读。
141
- * 摘要首句按句末标点切,切不出来就整段(超长再按字符数兜底),保证「一行一个
142
- * 文档」的形态不被长摘要撑破。
143
- */
144
- function renderPointer(m) {
145
- const flat = String(m.content ?? "").replace(/\s+/g, " ").trim();
146
- // 句末标点切首句:中文的。!? 后面通常不跟空格,不能像英文那样要求一个空白
147
- // (要求了就一句都切不出来,整段摘要被当成「首句」塞进视图)。英文的 . 仍要求
148
- // 后接空白或结尾,免得把 e.g. / v1.2 这类点号当成句末。
149
- const first = flat.match(/^.*?(?:[。!?]|[!?]|[.](?=\s|$))|^.*$/s)?.[0] ?? flat;
150
- // 按码点切,不按 UTF-16 码元:后者会在 emoji / 代理对中间落刀,落盘成 U+FFFD。
151
- const chars = [...first];
152
- const summary = chars.length > POINTER_MAX ? `${chars.slice(0, POINTER_MAX).join("")}…` : first;
153
- const where = m.doc_path ? ` · \`${oneLine(m.doc_path)}\`` : "";
154
- return `- \`${m.id}\` **${oneLine(m.title)}**:${oneLine(summary)}${where}\n`;
155
- }
156
-
157
- /**
158
- * Render one type's memories into exactly the mirror-file text (header +
159
- * per-memory blocks, updated_at DESC like sync). sync() writes this to disk;
160
- * the /export endpoint reuses the same entry blocks for its per-type sections,
161
- * so an exported markdown round-trips back through parseHumanEdits →
162
- * mergeHumanEdits. Unknown type → undefined.
163
- *
164
- * `fileHeader: false` 供 /export 用:一个文档只能有一个 frontmatter 块(在文档
165
- * 最前,见 api.js 的 /export),所以分节不再各自带文件头。只读 type(#296 的
166
- * document)只走磁盘镜像这一条:渲染指针行 + 只读文件头,导出侧不取它。
167
- */
168
- export function renderMirrorText(type, memories, language = "zh", { fileHeader = true } = {}) {
169
- const name = TYPE_FILE[type];
170
- if (!name) return undefined;
171
- const items = (memories ?? [])
172
- .slice()
173
- .sort((a, b) => (a.updated_at < b.updated_at ? 1 : -1));
174
- const readonly = MIRROR_READONLY_TYPES.has(type);
175
- const header = readonly ? STR.mirrorReadonlyHeader[language](name) : STR.mirrorHeader[language](name);
176
- const body = items.map((m) => (readonly ? renderPointer(m) : renderMemory(m, language))).join("\n");
177
- return (fileHeader ? renderFileHeader(type, items) : "") + header + body;
178
- }
179
-
180
- /**
181
- * Parse mirror text back into {id, title, content} entries for human edits.
182
- * Pure text-in/edits-out core: readHumanEdits feeds it mirror file contents
183
- * and the /import endpoint feeds it user-pasted markdown, so both paths share
184
- * one parsing implementation (行为一致是硬约束——import 必须能吃回 export 与
185
- * 磁盘镜像)。Entries are anchored on "- **ID**: `...`" lines that are followed
186
- * by the "- **类型**:" metadata line (structural entry head): each entry's
187
- * block spans from its ID line up to the next ID line (or end of text). The
188
- * block head (the ID line plus the generated metadata run) and the trailing
189
- * structural "---" separator are stripped; everything in between is the entry
190
- * body, so user content containing "---", metadata-like lines, or even a
191
- * machine-format "- **ID**: `x`" line is preserved. The title is the "## "
192
- * heading preceding the ID line.
193
- */
194
- export function parseHumanEdits(text) {
195
- // CRLF 归一化(readHumanEdits 原有的读取侧处理移入纯函数,Windows 手工编辑
196
- // 的文件与导入文本都能正确解析)。
197
- const normalized = String(text ?? "").replace(/\r\n/g, "\n");
198
- const edits = [];
199
- // Anchor on the ID line only when it is a structural entry head: the
200
- // machine-rendered ID line is always followed by the "- **类型**:" line.
201
- // A body line like "- **ID**: `x`" is not, so it never splits the block
202
- // or produces a ghost entry.
203
- // 解析同时接受两种语言的标签:切换语言前渲染的镜像文件仍能合并。
204
- const anchors = [...normalized.matchAll(ANCHOR_RE)];
205
- let prevEnd = 0;
206
- for (let i = 0; i < anchors.length; i++) {
207
- const anchor = anchors[i];
208
- const blockStart = anchor.index;
209
- const blockEnd = i + 1 < anchors.length ? anchors[i + 1].index : normalized.length;
210
-
211
- // Title: last "## " heading before this ID line (file header region /
212
- // previous block tail). Body headings of earlier entries come before
213
- // the structural "---" + "## " of this entry, so the last match wins.
214
- const titleMatches = [...normalized.slice(prevEnd, blockStart).matchAll(/^## (.+)$/gm)];
215
- const titleMatch = titleMatches[titleMatches.length - 1];
216
-
217
- // Body: the ID line and the generated metadata run are structural head;
218
- // everything after them up to the trailing "---" separator is the body.
219
- let body = normalized
220
- .slice(blockStart, blockEnd)
221
- .replace(/^- \*\*ID\*\*: `[^`]+`\n?/, "")
222
- .replace(META_RUN_RE, "")
223
- .replace(/^<!-- mirror-digest: [a-f0-9]+ -->\n?/m, "");
224
- const separators = [...body.matchAll(/^---\s*$/gm)];
225
- const lastSep = separators[separators.length - 1];
226
- if (lastSep) body = body.slice(0, lastSep.index);
227
- body = body.trim();
228
-
229
- // The machine-written "更新时间" line records the store's updated_at at
230
- // render time — the version token for detecting a concurrent store write
231
- // during a three-way merge of human edits (see service.syncMirror).
232
- const block = normalized.slice(blockStart, blockEnd);
233
- const updatedMatch = block.match(UPDATED_RE);
234
- const digestMatch = block.match(/<!-- mirror-digest: ([a-f0-9]+) -->/);
235
- edits.push({
236
- id: anchor[1],
237
- title: titleMatch ? unescape(titleMatch[1]).trim() : undefined,
238
- content: body,
239
- updated_at: updatedMatch ? updatedMatch[1].trim() : undefined,
240
- digest: digestMatch ? digestMatch[1] : undefined
241
- });
242
-
243
- const lineEnd = normalized.indexOf("\n", blockStart);
244
- prevEnd = lineEnd === -1 ? normalized.length : lineEnd + 1;
245
- }
246
- return edits;
247
- }
248
-
249
- /**
250
- * 判断「这份文本的内容有没有变」时忽略 generated.at:它每次渲染都是新时间戳,
251
- * 带上它就永远判为变化。其余字节必须逐字相同才算没变——镜像的幂等性(同样的库
252
- * 渲染两次得到同样的文件)才是可断言的。
253
- */
254
- function stripGeneratedAt(text) {
255
- return String(text).replace(/^generated\.at: .*$/m, "");
256
- }
257
-
258
- export function createMirror(dir, language = "zh") {
259
- mkdirSync(dir, { recursive: true });
260
-
261
- function filePath(type) {
262
- const name = TYPE_FILE[type];
263
- return name ? join(dir, name) : undefined;
264
- }
265
-
266
- /**
267
- * Read the mirror files and parse them back into human edits. The pure
268
- * parsing logic lives in the exported parseHumanEdits (shared with /import);
269
- * this wrapper only owns the "read file → text" side.
270
- */
271
- function readHumanEdits(type = undefined) {
272
- // 只读 type 不参与人改合并(#296):它们的文件里没有可编辑的正文,解析出来的
273
- // 指针行文本若被当成「人改」写回库,会污染摘要。整类跳过,包括 type 指定的
274
- // 那条调用路径。
275
- const types = (type ? [type] : Object.keys(TYPE_FILE)).filter((t) => !MIRROR_READONLY_TYPES.has(t));
276
- const edits = [];
277
- for (const t of types) {
278
- const file = filePath(t);
279
- if (!file || !existsSync(file)) continue;
280
- edits.push(...parseHumanEdits(readFileSync(file, "utf8")));
281
- }
282
- return edits;
283
- }
284
-
285
- function sync(memories) {
286
- const byType = {};
287
- for (const m of memories) {
288
- (byType[m.type] ??= []).push(m);
289
- }
290
- // Per-type physical outcomes (audit peer D): a failed write for one type
291
- // must not abort the whole render. Each type is written (or pruned) in its
292
- // own try/catch and the result reported so the caller can persist per-type
293
- // committed/failed receipts — a file that was already written is a real
294
- // physical commit even when a sibling type errors.
295
- const results = {};
296
- for (const type of Object.keys(TYPE_FILE)) {
297
- try {
298
- const file = filePath(type);
299
- const items = (byType[type] ?? [])
300
- .slice()
301
- .sort((a, b) => (a.updated_at < b.updated_at ? 1 : -1));
302
- if (items.length === 0) {
303
- // no memories of this type: drop any stale mirror file so deleted
304
- // memories do not "resurrect" via readHumanEdits
305
- rmSync(file, { force: true });
306
- } else {
307
- // 渲染走 renderMirrorText(与 /export 共用同一条渲染路径)。
308
- const text = renderMirrorText(type, items, language);
309
- // 内容没变就不落盘。generated.at 每次渲染都新,无脑写会让编辑器、同步盘
310
- // 与 git 在每次业务写后看到全部镜像文件「被外部修改」,而内容其实一字未
311
- // 变;按 OKF 的语义它只该记内容变化,未变就保留旧值。这也是同步读 9 个
312
- // 小文件的代价,比无谓写 9 次盘便宜。
313
- let prev = null;
314
- try {
315
- prev = readFileSync(file, "utf8");
316
- } catch { /* 首次写:读不到就是没有旧文件 */ }
317
- if (prev === null || stripGeneratedAt(prev) !== stripGeneratedAt(text)) {
318
- writeFileSync(file, text, "utf8");
319
- }
320
- }
321
- results[type] = { ok: true };
322
- } catch (error) {
323
- results[type] = { ok: false, error: error?.message ?? String(error) };
324
- }
325
- }
326
- return results;
327
- }
328
-
329
- return { filePath, sync, readHumanEdits };
330
- }
1
+ import { mkdirSync, readFileSync, writeFileSync, existsSync, rmSync } from "node:fs";
2
+ import { createHash } from "node:crypto";
3
+ import { join } from "node:path";
4
+ import { STR, langOf } from "./lang.js";
5
+ import { PACKAGE_VERSION } from "./version-check.js";
6
+
7
+ export const TYPE_FILE = {
8
+ preference: "preferences.md",
9
+ project: "projects.md",
10
+ decision: "decisions.md",
11
+ history: "history.md",
12
+ summary: "summary.md",
13
+ pitfall: "pitfalls.md",
14
+ constraint: "constraints.md",
15
+ rejected_solution: "rejected-solutions.md",
16
+ pattern: "patterns.md",
17
+ document: "documents.md"
18
+ };
19
+
20
+ // 不落镜像的 type。现在是空集:document 由 #296 第二批收进来——第一版排除它的
21
+ // 理由是「镜像块渲染的字段里没有 doc_path,落一个 documents.md 会得到看着完整、
22
+ // 其实找不到文件的视图」,doc_path 进指针行以后这条理由不再成立。这个集合由
23
+ // test/mirror-types.test.js 钉死(TYPES \ TYPE_FILE 必须恰好等于本集合):将来
24
+ // 新增 type 时必须显式决定它落不落镜像,静默漏掉才是 #278 要修的那种盲区。
25
+ export const MIRROR_EXCLUDED_TYPES = new Set([]);
26
+
27
+ // 只读视图(#296):document 的镜像只有指针行(id + 标题 + 摘要首句 + doc_path),
28
+ // 没有可编辑的正文,所以它不参与 readHumanEdits 的人改合并,手工改动一律被下次
29
+ // 同步覆盖;导出/导入也不带它(那两个是 round-trip 通道,见 api.js)。
30
+ export const MIRROR_READONLY_TYPES = new Set(["document"]);
31
+
32
+ const ESCAPE = /([\\`*_[\]{}()#+.!|>~-])/g;
33
+ const UNESCAPE = new RegExp("\\\\" + ESCAPE.source, "g");
34
+ // 只读视图里摘要首句的字符上限(#296):够定位就行,长摘要不该把一行撑成一段。
35
+ const POINTER_MAX = 120;
36
+
37
+ function esc(text) {
38
+ return String(text).replace(ESCAPE, "\\$1");
39
+ }
40
+
41
+ function unescape(text) {
42
+ return String(text).replace(UNESCAPE, "$1");
43
+ }
44
+
45
+ // 元数据标签的唯一来源是 STR.mirrorLabel(lang.js):渲染按实例语言取,解析两套
46
+ // 都认。下面的正则从那张表现算——旧实现把手写正则与 lang.js 的两处文案分开维护,
47
+ // 加一个字段要改三处,漏改一处那一行就会随 /import 写进 entry 的 content。
48
+ const META_LABELS = [];
49
+ for (const language of Object.keys(STR.mirrorLabel)) META_LABELS.push(...Object.values(STR.mirrorLabel[language]));
50
+
51
+ function reEsc(text) {
52
+ return String(text).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
53
+ }
54
+
55
+ // 元数据行的值必须单行:值里带换行会多出一行,轻则破坏「整段元数据行」的解析契约,
56
+ // 重则伪造出一个条目头(sensitivity 是调用方传入的任意字符串,属信任边界)。
57
+ function oneLine(text) {
58
+ return String(text).replace(/\s+/g, " ").trim();
59
+ }
60
+
61
+ // 结构头锚:ID 行紧跟类型行才算条目头(正文里机器格式的 ID 行不会切开块)。
62
+ const ANCHOR_RE = new RegExp(
63
+ "^- \\*\\*ID\\*\\*: `([^`]+)`\\n- \\*\\*(?:" +
64
+ [STR.mirrorLabel.zh.type, STR.mirrorLabel.en.type].map(reEsc).join("|") +
65
+ ")\\*\\*:", "gm"
66
+ );
67
+ // 渲染出的整段元数据行(加字段只需改 lang.js 的 mirrorLabel)。
68
+ const META_RUN_RE = new RegExp(`^(- \\*\\*(?:${META_LABELS.map(reEsc).join("|")})\\*\\*:.*\\n?)+`);
69
+ // 三方合并用的版本令牌(service.syncMirror 比对并发写)。
70
+ const UPDATED_RE = new RegExp(
71
+ "- \\*\\*(?:" + [STR.mirrorLabel.zh.updated, STR.mirrorLabel.en.updated].map(reEsc).join("|") +
72
+ ")\\*\\*: ([^\\n]+)"
73
+ );
74
+
75
+ function renderMemory(m, language = "zh") {
76
+ // last-rendered digest baseline: sha256(title \x00 content). service.js
77
+ // compares the file hash against this to tell "untouched by a human" (machine
78
+ // write wins) apart from a real human edit, so a not-yet-re-rendered store
79
+ // update is not misread as a concurrent human edit.
80
+ const digest = createHash("sha256")
81
+ .update(`${m.title}\x00${m.content}`)
82
+ .digest("hex");
83
+ const lines = [];
84
+ lines.push(`## ${esc(m.title)}`);
85
+ lines.push("");
86
+ lines.push(`- **ID**: \`${m.id}\``);
87
+ const ML = STR.mirrorLabel[language];
88
+ lines.push(`- **${ML.type}**: ${m.type}`);
89
+ lines.push(`- **${ML.importance}**: ${m.importance}`);
90
+ lines.push(`- **${ML.tags}**: ${m.tags.map((t) => `\`${esc(t)}\``).join(" ")}`);
91
+ lines.push(`- **${ML.updated}**: ${m.updated_at}`);
92
+ if (m.source) lines.push(`- **${ML.source}**: ${esc(m.source)}`);
93
+ // scope / sensitivity 是只读展示字段(#278:sensitivity 的过滤语义另有归属,见
94
+ // 那条讨论;这里只把值摆出来)。只在真有值时渲染——本机 2843 条里只有 37 条带
95
+ // scope 标签,无条件渲染会给每条目多添两行空字段。
96
+ const scopeText = [m.agent_scope, m.workspace_scope].filter(Boolean).map(oneLine).join(" / ");
97
+ if (scopeText) lines.push(`- **${ML.scope}**: ${scopeText}`);
98
+ if (m.sensitivity) lines.push(`- **${ML.sensitivity}**: ${oneLine(m.sensitivity)}`);
99
+ lines.push("");
100
+ lines.push(`<!-- mirror-digest: ${digest} -->`);
101
+ lines.push(m.content);
102
+ lines.push("");
103
+ lines.push("---");
104
+ lines.push("");
105
+ return lines.join("\n");
106
+ }
107
+
108
+ /**
109
+ * 文件头 frontmatter(#278 第一批)。按 type 分文件时一个文件装 N 条,所以
110
+ * frontmatter 只能落在文件级;条目级元数据仍是块内的 `- **字段**:` 行(见
111
+ * renderMemory)。
112
+ *
113
+ * `covered` / `coverage` 是覆盖范围的自述,必须与这份文本**实际包含的行集**一致:
114
+ * 磁盘镜像写活跃集(active-only),/export 的文档写全表(all)。说一套写一套比
115
+ * 没有这个字段更糟——外部工具会照着它聚合条数与标签。
116
+ *
117
+ * 键名对齐 OKF v0.2:`type` 是 §4.1 唯一必填键,`tags` 是 §4.1 的推荐键,
118
+ * `generated.by` / `generated.at` 是 §5.2 的 trust 族。`generated.by` 按 §7 的
119
+ * actor 约定写 `<producer>/<version>`(所以带版本号);`generated.at` 按 §5.2 的
120
+ * 语义只记「内容上次真正变化」,sync 因此只在正文变化时才落盘。v0.2 已用
121
+ * `generated.at` 取代 v0.1 的 `timestamp`,没有 `updated` 这个顶层键。
122
+ * `covered` / `coverage` 是 §4.1 允许的 producer 自定义扩展。
123
+ */
124
+ export function renderFileHeader(type, items, coverage = "active-only") {
125
+ const tags = [...new Set(items.flatMap((m) => (m.tags ?? []).map(String)))].sort();
126
+ return [
127
+ "---",
128
+ `type: ${type}`,
129
+ `generated.by: dsh-mneme/${PACKAGE_VERSION}`,
130
+ `generated.at: ${new Date().toISOString()}`,
131
+ `covered: ${items.length}`,
132
+ `coverage: ${coverage}`,
133
+ `tags: [${tags.map((t) => JSON.stringify(t)).join(", ")}]`,
134
+ "---",
135
+ ""
136
+ ].join("\n");
137
+ }
138
+
139
+ /**
140
+ * document 的只读视图一行(#296):一句话定位 + 文件指针,正文按 doc_path 去读。
141
+ * 摘要首句按句末标点切,切不出来就整段(超长再按字符数兜底),保证「一行一个
142
+ * 文档」的形态不被长摘要撑破。
143
+ */
144
+ function renderPointer(m) {
145
+ const flat = String(m.content ?? "").replace(/\s+/g, " ").trim();
146
+ // 句末标点切首句:中文的。!? 后面通常不跟空格,不能像英文那样要求一个空白
147
+ // (要求了就一句都切不出来,整段摘要被当成「首句」塞进视图)。英文的 . 仍要求
148
+ // 后接空白或结尾,免得把 e.g. / v1.2 这类点号当成句末。
149
+ const first = flat.match(/^.*?(?:[。!?]|[!?]|[.](?=\s|$))|^.*$/s)?.[0] ?? flat;
150
+ // 按码点切,不按 UTF-16 码元:后者会在 emoji / 代理对中间落刀,落盘成 U+FFFD。
151
+ const chars = [...first];
152
+ const summary = chars.length > POINTER_MAX ? `${chars.slice(0, POINTER_MAX).join("")}…` : first;
153
+ const where = m.doc_path ? ` · \`${oneLine(m.doc_path)}\`` : "";
154
+ return `- \`${m.id}\` **${oneLine(m.title)}**:${oneLine(summary)}${where}\n`;
155
+ }
156
+
157
+ /**
158
+ * Render one type's memories into exactly the mirror-file text (header +
159
+ * per-memory blocks, updated_at DESC like sync). sync() writes this to disk;
160
+ * the /export endpoint reuses the same entry blocks for its per-type sections,
161
+ * so an exported markdown round-trips back through parseHumanEdits →
162
+ * mergeHumanEdits. Unknown type → undefined.
163
+ *
164
+ * `fileHeader: false` 供 /export 用:一个文档只能有一个 frontmatter 块(在文档
165
+ * 最前,见 api.js 的 /export),所以分节不再各自带文件头。只读 type(#296 的
166
+ * document)只走磁盘镜像这一条:渲染指针行 + 只读文件头,导出侧不取它。
167
+ */
168
+ export function renderMirrorText(type, memories, language = "zh", { fileHeader = true } = {}) {
169
+ const name = TYPE_FILE[type];
170
+ if (!name) return undefined;
171
+ const items = (memories ?? [])
172
+ .slice()
173
+ .sort((a, b) => (a.updated_at < b.updated_at ? 1 : -1));
174
+ const readonly = MIRROR_READONLY_TYPES.has(type);
175
+ const header = readonly ? STR.mirrorReadonlyHeader[language](name) : STR.mirrorHeader[language](name);
176
+ const body = items.map((m) => (readonly ? renderPointer(m) : renderMemory(m, language))).join("\n");
177
+ return (fileHeader ? renderFileHeader(type, items) : "") + header + body;
178
+ }
179
+
180
+ /**
181
+ * Parse mirror text back into {id, title, content} entries for human edits.
182
+ * Pure text-in/edits-out core: readHumanEdits feeds it mirror file contents
183
+ * and the /import endpoint feeds it user-pasted markdown, so both paths share
184
+ * one parsing implementation (行为一致是硬约束——import 必须能吃回 export 与
185
+ * 磁盘镜像)。Entries are anchored on "- **ID**: `...`" lines that are followed
186
+ * by the "- **类型**:" metadata line (structural entry head): each entry's
187
+ * block spans from its ID line up to the next ID line (or end of text). The
188
+ * block head (the ID line plus the generated metadata run) and the trailing
189
+ * structural "---" separator are stripped; everything in between is the entry
190
+ * body, so user content containing "---", metadata-like lines, or even a
191
+ * machine-format "- **ID**: `x`" line is preserved. The title is the "## "
192
+ * heading preceding the ID line.
193
+ */
194
+ export function parseHumanEdits(text) {
195
+ // CRLF 归一化(readHumanEdits 原有的读取侧处理移入纯函数,Windows 手工编辑
196
+ // 的文件与导入文本都能正确解析)。
197
+ const normalized = String(text ?? "").replace(/\r\n/g, "\n");
198
+ const edits = [];
199
+ // Anchor on the ID line only when it is a structural entry head: the
200
+ // machine-rendered ID line is always followed by the "- **类型**:" line.
201
+ // A body line like "- **ID**: `x`" is not, so it never splits the block
202
+ // or produces a ghost entry.
203
+ // 解析同时接受两种语言的标签:切换语言前渲染的镜像文件仍能合并。
204
+ const anchors = [...normalized.matchAll(ANCHOR_RE)];
205
+ let prevEnd = 0;
206
+ for (let i = 0; i < anchors.length; i++) {
207
+ const anchor = anchors[i];
208
+ const blockStart = anchor.index;
209
+ const blockEnd = i + 1 < anchors.length ? anchors[i + 1].index : normalized.length;
210
+
211
+ // Title: last "## " heading before this ID line (file header region /
212
+ // previous block tail). Body headings of earlier entries come before
213
+ // the structural "---" + "## " of this entry, so the last match wins.
214
+ const titleMatches = [...normalized.slice(prevEnd, blockStart).matchAll(/^## (.+)$/gm)];
215
+ const titleMatch = titleMatches[titleMatches.length - 1];
216
+
217
+ // Body: the ID line and the generated metadata run are structural head;
218
+ // everything after them up to the trailing "---" separator is the body.
219
+ let body = normalized
220
+ .slice(blockStart, blockEnd)
221
+ .replace(/^- \*\*ID\*\*: `[^`]+`\n?/, "")
222
+ .replace(META_RUN_RE, "")
223
+ .replace(/^<!-- mirror-digest: [a-f0-9]+ -->\n?/m, "");
224
+ const separators = [...body.matchAll(/^---\s*$/gm)];
225
+ const lastSep = separators[separators.length - 1];
226
+ if (lastSep) body = body.slice(0, lastSep.index);
227
+ body = body.trim();
228
+
229
+ // The machine-written "更新时间" line records the store's updated_at at
230
+ // render time — the version token for detecting a concurrent store write
231
+ // during a three-way merge of human edits (see service.syncMirror).
232
+ const block = normalized.slice(blockStart, blockEnd);
233
+ const updatedMatch = block.match(UPDATED_RE);
234
+ const digestMatch = block.match(/<!-- mirror-digest: ([a-f0-9]+) -->/);
235
+ edits.push({
236
+ id: anchor[1],
237
+ title: titleMatch ? unescape(titleMatch[1]).trim() : undefined,
238
+ content: body,
239
+ updated_at: updatedMatch ? updatedMatch[1].trim() : undefined,
240
+ digest: digestMatch ? digestMatch[1] : undefined
241
+ });
242
+
243
+ const lineEnd = normalized.indexOf("\n", blockStart);
244
+ prevEnd = lineEnd === -1 ? normalized.length : lineEnd + 1;
245
+ }
246
+ return edits;
247
+ }
248
+
249
+ /**
250
+ * 判断「这份文本的内容有没有变」时忽略 generated.at:它每次渲染都是新时间戳,
251
+ * 带上它就永远判为变化。其余字节必须逐字相同才算没变——镜像的幂等性(同样的库
252
+ * 渲染两次得到同样的文件)才是可断言的。
253
+ */
254
+ function stripGeneratedAt(text) {
255
+ return String(text).replace(/^generated\.at: .*$/m, "");
256
+ }
257
+
258
+ export function createMirror(dir, language = "zh") {
259
+ mkdirSync(dir, { recursive: true });
260
+
261
+ function filePath(type) {
262
+ const name = TYPE_FILE[type];
263
+ return name ? join(dir, name) : undefined;
264
+ }
265
+
266
+ /**
267
+ * Read the mirror files and parse them back into human edits. The pure
268
+ * parsing logic lives in the exported parseHumanEdits (shared with /import);
269
+ * this wrapper only owns the "read file → text" side.
270
+ */
271
+ function readHumanEdits(type = undefined) {
272
+ // 只读 type 不参与人改合并(#296):它们的文件里没有可编辑的正文,解析出来的
273
+ // 指针行文本若被当成「人改」写回库,会污染摘要。整类跳过,包括 type 指定的
274
+ // 那条调用路径。
275
+ const types = (type ? [type] : Object.keys(TYPE_FILE)).filter((t) => !MIRROR_READONLY_TYPES.has(t));
276
+ const edits = [];
277
+ for (const t of types) {
278
+ const file = filePath(t);
279
+ if (!file || !existsSync(file)) continue;
280
+ edits.push(...parseHumanEdits(readFileSync(file, "utf8")));
281
+ }
282
+ return edits;
283
+ }
284
+
285
+ function sync(memories) {
286
+ const byType = {};
287
+ for (const m of memories) {
288
+ (byType[m.type] ??= []).push(m);
289
+ }
290
+ // Per-type physical outcomes (audit peer D): a failed write for one type
291
+ // must not abort the whole render. Each type is written (or pruned) in its
292
+ // own try/catch and the result reported so the caller can persist per-type
293
+ // committed/failed receipts — a file that was already written is a real
294
+ // physical commit even when a sibling type errors.
295
+ const results = {};
296
+ for (const type of Object.keys(TYPE_FILE)) {
297
+ try {
298
+ const file = filePath(type);
299
+ const items = (byType[type] ?? [])
300
+ .slice()
301
+ .sort((a, b) => (a.updated_at < b.updated_at ? 1 : -1));
302
+ if (items.length === 0) {
303
+ // no memories of this type: drop any stale mirror file so deleted
304
+ // memories do not "resurrect" via readHumanEdits
305
+ rmSync(file, { force: true });
306
+ } else {
307
+ // 渲染走 renderMirrorText(与 /export 共用同一条渲染路径)。
308
+ const text = renderMirrorText(type, items, language);
309
+ // 内容没变就不落盘。generated.at 每次渲染都新,无脑写会让编辑器、同步盘
310
+ // 与 git 在每次业务写后看到全部镜像文件「被外部修改」,而内容其实一字未
311
+ // 变;按 OKF 的语义它只该记内容变化,未变就保留旧值。这也是同步读 9 个
312
+ // 小文件的代价,比无谓写 9 次盘便宜。
313
+ let prev = null;
314
+ try {
315
+ prev = readFileSync(file, "utf8");
316
+ } catch { /* 首次写:读不到就是没有旧文件 */ }
317
+ if (prev === null || stripGeneratedAt(prev) !== stripGeneratedAt(text)) {
318
+ writeFileSync(file, text, "utf8");
319
+ }
320
+ }
321
+ results[type] = { ok: true };
322
+ } catch (error) {
323
+ results[type] = { ok: false, error: error?.message ?? String(error) };
324
+ }
325
+ }
326
+ return results;
327
+ }
328
+
329
+ return { filePath, sync, readHumanEdits };
330
+ }