@modusensus/dsh-mneme 0.8.14 → 0.8.16

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 (279) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +660 -654
  3. package/assets/icon.svg +3 -3
  4. package/bin/cli.mjs +691 -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 +275 -0
  9. package/docs/CONTINUITY.md +124 -0
  10. package/docs/DAEMON.md +46 -0
  11. package/docs/DISTILL.md +162 -0
  12. package/docs/ENTITIES.md +245 -0
  13. package/docs/LOCAL_MODEL.md +222 -0
  14. package/docs/SEMANTIC.md +256 -0
  15. package/docs/SLEEP.md +164 -0
  16. package/docs/STORAGE.md +98 -0
  17. package/lib/api-standalone.js +656 -673
  18. package/lib/api.js +1605 -1433
  19. package/lib/bootstrap.js +211 -211
  20. package/lib/client.js +6354 -5296
  21. package/lib/commands.js +95 -95
  22. package/lib/config.js +947 -843
  23. package/lib/content-hash.js +54 -54
  24. package/lib/continuity.js +220 -220
  25. package/lib/document-index.js +94 -94
  26. package/lib/document.js +303 -303
  27. package/lib/dream/clustering.js +118 -118
  28. package/lib/dream/decisions.js +694 -674
  29. package/lib/dream/narratives.js +44 -44
  30. package/lib/dream/sleep.js +819 -819
  31. package/lib/dream.js +1503 -1501
  32. package/lib/embedding.js +177 -177
  33. package/lib/entities/extractor.js +252 -252
  34. package/lib/graph/anchoring.js +179 -179
  35. package/lib/guide.js +74 -74
  36. package/lib/heat.js +144 -144
  37. package/lib/hot-memory.js +108 -46
  38. package/lib/index.js +552 -503
  39. package/lib/inject.js +428 -450
  40. package/lib/lang.js +248 -229
  41. package/lib/local-embedder.js +358 -358
  42. package/lib/maintenance.js +162 -142
  43. package/lib/mirror.js +330 -330
  44. package/lib/organize.js +450 -450
  45. package/lib/peak-hours.js +117 -117
  46. package/lib/placement.js +13 -0
  47. package/lib/pre-inject-gate.js +340 -0
  48. package/lib/quality-filter.js +126 -126
  49. package/lib/recall-stats.js +190 -175
  50. package/lib/reclaim-http.js +150 -0
  51. package/lib/reranker.js +269 -269
  52. package/lib/runtime/adopt.js +176 -176
  53. package/lib/runtime/closure.js +172 -172
  54. package/lib/runtime/download.js +247 -247
  55. package/lib/runtime/layout.js +265 -265
  56. package/lib/runtime/loader.js +252 -252
  57. package/lib/runtime/model-fetch.js +361 -361
  58. package/lib/runtime/provision.js +237 -237
  59. package/lib/runtime/tarball.js +130 -130
  60. package/lib/runtime/verify.js +223 -204
  61. package/lib/safe-slice.js +18 -0
  62. package/lib/scope.js +146 -146
  63. package/lib/search/adaptive.js +60 -60
  64. package/lib/search/bm25.js +96 -96
  65. package/lib/semantic.js +229 -229
  66. package/lib/sensitive-scan.js +209 -209
  67. package/lib/serve.js +215 -215
  68. package/lib/service.js +2688 -2605
  69. package/lib/settings.js +528 -511
  70. package/lib/store.js +3196 -3007
  71. package/lib/summarize.js +1039 -856
  72. package/lib/tools.js +743 -737
  73. package/lib/vector-index.js +110 -110
  74. package/lib/version-check.js +122 -122
  75. package/lib/write-admission.js +402 -402
  76. package/locale/en.json +6 -6
  77. package/locale/zh.json +6 -6
  78. package/package.json +118 -118
  79. package/runtime-manifest.json +526 -526
  80. package/scripts/benchmark-embed.js +201 -201
  81. package/scripts/benchmark-recall.js +249 -249
  82. package/scripts/benchmark-rerank.js +166 -166
  83. package/scripts/build-runtime-manifest.mjs +224 -224
  84. package/scripts/changelog-prep.mjs +36 -36
  85. package/scripts/check-sync.js +42 -42
  86. package/scripts/e2e-dsh.js +220 -220
  87. package/scripts/mneme-runtime.mjs +277 -277
  88. package/scripts/stress-dsh.js +255 -255
  89. package/scripts/sync-lib.js +47 -47
  90. package/scripts/sync-test-badge.mjs +69 -69
  91. package/scripts/test-count-sync.mjs +44 -44
  92. package/src/api-standalone.js +656 -673
  93. package/src/api.js +1605 -1433
  94. package/src/bootstrap.js +211 -211
  95. package/src/commands.js +95 -95
  96. package/src/config.js +947 -843
  97. package/src/content-hash.js +54 -54
  98. package/src/continuity.js +220 -220
  99. package/src/document-index.js +94 -94
  100. package/src/document.js +303 -303
  101. package/src/dream/clustering.js +118 -118
  102. package/src/dream/decisions.js +694 -674
  103. package/src/dream/narratives.js +44 -44
  104. package/src/dream/sleep.js +819 -819
  105. package/src/dream.js +1503 -1501
  106. package/src/embedding.js +177 -177
  107. package/src/entities/extractor.js +252 -252
  108. package/src/graph/anchoring.js +179 -179
  109. package/src/guide.js +74 -74
  110. package/src/heat.js +144 -144
  111. package/src/hot-memory.js +108 -46
  112. package/src/index.js +552 -503
  113. package/src/inject.js +428 -450
  114. package/src/lang.js +248 -229
  115. package/src/local-embedder.js +358 -358
  116. package/src/maintenance.js +162 -142
  117. package/src/mirror.js +330 -330
  118. package/src/organize.js +450 -450
  119. package/src/peak-hours.js +117 -117
  120. package/src/placement.js +13 -0
  121. package/src/pre-inject-gate.js +340 -0
  122. package/src/quality-filter.js +126 -126
  123. package/src/recall-stats.js +190 -175
  124. package/src/reclaim-http.js +150 -0
  125. package/src/reranker.js +269 -269
  126. package/src/runtime/adopt.js +176 -176
  127. package/src/runtime/closure.js +172 -172
  128. package/src/runtime/download.js +247 -247
  129. package/src/runtime/layout.js +265 -265
  130. package/src/runtime/loader.js +252 -252
  131. package/src/runtime/model-fetch.js +361 -361
  132. package/src/runtime/provision.js +237 -237
  133. package/src/runtime/tarball.js +130 -130
  134. package/src/runtime/verify.js +223 -204
  135. package/src/safe-slice.js +18 -0
  136. package/src/scope.js +146 -146
  137. package/src/search/adaptive.js +60 -60
  138. package/src/search/bm25.js +96 -96
  139. package/src/semantic.js +229 -229
  140. package/src/sensitive-scan.js +209 -209
  141. package/src/serve.js +215 -215
  142. package/src/service.js +2688 -2605
  143. package/src/settings.js +528 -511
  144. package/src/store.js +3196 -3007
  145. package/src/summarize.js +1039 -856
  146. package/src/tools.js +743 -737
  147. package/src/vector-index.js +110 -110
  148. package/src/version-check.js +122 -122
  149. package/src/write-admission.js +402 -402
  150. package/test/adaptive-inject-budget.test.js +85 -85
  151. package/test/api-routes-gaps.test.js +899 -218
  152. package/test/api.test.js +1725 -1690
  153. package/test/audit.test.js +448 -448
  154. package/test/baseline-persist.test.js +248 -248
  155. package/test/benchmark.test.js +94 -94
  156. package/test/bootstrap.test.js +220 -220
  157. package/test/changelog-prep.test.js +60 -60
  158. package/test/client.test.js +1582 -1169
  159. package/test/clustering.test.js +100 -100
  160. package/test/commands.test.js +106 -106
  161. package/test/config.test.js +66 -66
  162. package/test/conflict-freeze.test.js +300 -300
  163. package/test/content-hash.test.js +204 -204
  164. package/test/continuity.test.js +333 -333
  165. package/test/contrast.test.js +314 -0
  166. package/test/document-dir.test.js +233 -233
  167. package/test/document.test.js +671 -671
  168. package/test/dream-archive-guardrail.test.js +116 -116
  169. package/test/dream-candidate-hybrid.test.js +281 -281
  170. package/test/dream-coverage-degrade.test.js +86 -86
  171. package/test/dream-failure-backoff.test.js +173 -173
  172. package/test/dream-merge-guardrail.test.js +219 -0
  173. package/test/dream-peak-hours.test.js +194 -194
  174. package/test/dream.test.js +1385 -1383
  175. package/test/embedding-ready.test.js +196 -196
  176. package/test/entities.test.js +627 -547
  177. package/test/entity-recall.test.js +179 -179
  178. package/test/epistemic.test.js +325 -325
  179. package/test/fnew-0112.test.js +311 -311
  180. package/test/fnew-03.test.js +422 -422
  181. package/test/graph-anchoring.test.js +184 -184
  182. package/test/graph-api.test.js +175 -175
  183. package/test/graph-inject-budget.test.js +103 -103
  184. package/test/graph-passive-confirm.test.js +91 -91
  185. package/test/graph-weight-evolution.test.js +104 -104
  186. package/test/heat.test.js +175 -175
  187. package/test/helpers/dream-mock.js +82 -82
  188. package/test/helpers/peer-worker.mjs +25 -25
  189. package/test/helpers/runtime-source.js +60 -60
  190. package/test/helpers/tar-builder.js +80 -80
  191. package/test/helpers/write-admission-samples.js +308 -308
  192. package/test/hot-memory.test.js +211 -145
  193. package/test/inject-content-cap.test.js +91 -91
  194. package/test/inject-first-round-bm25.test.js +89 -89
  195. package/test/inject-heat.test.js +77 -77
  196. package/test/inject-parent-gate.test.js +106 -106
  197. package/test/inject-pools.test.js +194 -194
  198. package/test/inject-recall.test.js +144 -105
  199. package/test/inject-rotation.test.js +161 -161
  200. package/test/inject.test.js +443 -420
  201. package/test/lang.test.js +87 -87
  202. package/test/lastrunat-persist.test.js +101 -101
  203. package/test/lib-smoke.test.js +96 -96
  204. package/test/llm-audit.test.js +696 -598
  205. package/test/llm-message-source.test.js +207 -207
  206. package/test/local-embedder.test.js +292 -292
  207. package/test/maintenance.test.js +501 -391
  208. package/test/mcp-stdio.test.js +385 -385
  209. package/test/mirror-dirty.test.js +424 -424
  210. package/test/mirror-edit-digest.test.js +187 -187
  211. package/test/mirror-generation.test.js +499 -499
  212. package/test/mirror-types.test.js +208 -208
  213. package/test/mirror.test.js +249 -249
  214. package/test/narrative-bars.test.js +162 -162
  215. package/test/organize.test.js +418 -418
  216. package/test/peer-blockers.test.js +193 -193
  217. package/test/placement.test.js +36 -0
  218. package/test/policy-epoch.test.js +259 -259
  219. package/test/pre-inject-gate.test.js +444 -0
  220. package/test/quality-filter.test.js +186 -186
  221. package/test/reasoning-effort.test.js +752 -752
  222. package/test/recall-anti-update.test.js +82 -82
  223. package/test/recall-evals.test.js +253 -253
  224. package/test/recall-layer.test.js +326 -326
  225. package/test/recall-stats.test.js +274 -189
  226. package/test/receipt-chain.test.js +451 -451
  227. package/test/reclaim-http.test.js +209 -0
  228. package/test/reflection.test.js +226 -226
  229. package/test/reindex-backfill.test.js +62 -62
  230. package/test/reranker-single-logit.test.js +73 -73
  231. package/test/reranker.test.js +197 -197
  232. package/test/resident-status.test.js +83 -83
  233. package/test/runtime-adopt.test.js +206 -206
  234. package/test/runtime-cli.test.js +89 -89
  235. package/test/runtime-closure.test.js +193 -193
  236. package/test/runtime-download.test.js +302 -302
  237. package/test/runtime-layout.test.js +163 -163
  238. package/test/runtime-loader.test.js +255 -255
  239. package/test/runtime-manifest.test.js +159 -159
  240. package/test/runtime-model-fetch.test.js +348 -348
  241. package/test/runtime-provision.test.js +101 -101
  242. package/test/runtime-tarball.test.js +81 -81
  243. package/test/runtime-verify.test.js +319 -279
  244. package/test/scope-candidates.test.js +239 -239
  245. package/test/scope-explicit.test.js +452 -452
  246. package/test/scope-inject-soft.test.js +106 -106
  247. package/test/scope-retrieval.test.js +197 -197
  248. package/test/scope-storage.test.js +255 -255
  249. package/test/scope-strict.test.js +367 -367
  250. package/test/search-fusion.test.js +90 -90
  251. package/test/semantic.test.js +124 -124
  252. package/test/sensitive-scan.test.js +207 -207
  253. package/test/serve-bin.test.js +121 -121
  254. package/test/serve.test.js +196 -196
  255. package/test/service-search.test.js +174 -174
  256. package/test/service.test.js +410 -405
  257. package/test/settings.test.js +269 -261
  258. package/test/skip-invalid-restore.test.js +262 -262
  259. package/test/sleep-action-set.test.js +422 -422
  260. package/test/sleep-heat.test.js +126 -126
  261. package/test/sleep-scheduler-rearm.test.js +89 -89
  262. package/test/sleep.test.js +527 -527
  263. package/test/standalone-api.test.js +751 -751
  264. package/test/store-vector-cache.test.js +98 -98
  265. package/test/store.test.js +504 -396
  266. package/test/stress.test.js +209 -209
  267. package/test/summarize-occurred-at.test.js +127 -0
  268. package/test/summarize-reasoning-effort.test.js +189 -189
  269. package/test/summarize.test.js +1639 -1465
  270. package/test/supersede-state-tracking.test.js +394 -0
  271. package/test/test-count-sync.test.js +60 -60
  272. package/test/tools-dto-schema-matrix.test.js +325 -325
  273. package/test/tools-memory-get-schema.test.js +65 -65
  274. package/test/tools.test.js +428 -374
  275. package/test/updated-at-semantics.test.js +113 -113
  276. package/test/vector-index.test.js +221 -221
  277. package/test/version-check.test.js +152 -152
  278. package/test/webServer-optional.test.js +82 -82
  279. package/test/write-admission.test.js +668 -668
package/lib/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
+ }