@modusensus/dsh-mneme 0.8.13 → 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.
- package/LICENSE +21 -21
- package/README.md +655 -626
- package/assets/icon.svg +3 -3
- package/bin/cli.mjs +689 -689
- package/bin/dsh-mneme-mcp.mjs +510 -510
- package/bin/dsh-mneme-serve.mjs +151 -0
- package/cordis.patch.yml +21 -21
- package/docs/CONFIGURATION.md +263 -0
- package/docs/CONTINUITY.md +124 -0
- package/docs/DAEMON.md +46 -0
- package/docs/ENTITIES.md +245 -0
- package/docs/LOCAL_MODEL.md +222 -0
- package/docs/SEMANTIC.md +256 -0
- package/docs/SLEEP.md +164 -0
- package/docs/STORAGE.md +89 -0
- package/lib/api-standalone.js +673 -572
- package/lib/api.js +1437 -1433
- package/lib/bootstrap.js +211 -211
- package/lib/client.js +5444 -5295
- package/lib/commands.js +95 -95
- package/lib/config.js +880 -836
- package/lib/content-hash.js +54 -54
- package/lib/continuity.js +220 -220
- package/lib/document-index.js +94 -94
- package/lib/document.js +303 -303
- package/lib/dream/clustering.js +118 -118
- package/lib/dream/decisions.js +674 -674
- package/lib/dream/narratives.js +44 -44
- package/lib/dream/sleep.js +819 -819
- package/lib/dream.js +1501 -1501
- package/lib/embedding.js +177 -177
- package/lib/entities/extractor.js +252 -252
- package/lib/graph/anchoring.js +179 -179
- package/lib/guide.js +74 -74
- package/lib/heat.js +144 -144
- package/lib/hot-memory.js +46 -46
- package/lib/index.js +535 -671
- package/lib/inject.js +464 -450
- package/lib/lang.js +248 -229
- package/lib/local-embedder.js +358 -358
- package/lib/maintenance.js +142 -142
- package/lib/mirror.js +330 -330
- package/lib/organize.js +450 -450
- package/lib/peak-hours.js +117 -117
- package/lib/placement.js +13 -0
- package/lib/pre-inject-gate.js +340 -0
- package/lib/quality-filter.js +126 -126
- package/lib/recall-stats.js +175 -175
- package/lib/reranker.js +269 -269
- package/lib/runtime/adopt.js +176 -176
- package/lib/runtime/closure.js +172 -172
- package/lib/runtime/download.js +247 -247
- package/lib/runtime/layout.js +265 -265
- package/lib/runtime/loader.js +252 -252
- package/lib/runtime/model-fetch.js +361 -361
- package/lib/runtime/provision.js +237 -237
- package/lib/runtime/tarball.js +130 -130
- package/lib/runtime/verify.js +223 -204
- package/lib/scope.js +146 -146
- package/lib/search/adaptive.js +60 -60
- package/lib/search/bm25.js +96 -96
- package/lib/semantic.js +229 -0
- package/lib/sensitive-scan.js +209 -209
- package/lib/serve.js +215 -0
- package/lib/service.js +2663 -2596
- package/lib/settings.js +517 -509
- package/lib/store.js +3007 -3007
- package/lib/summarize.js +891 -856
- package/lib/tools.js +743 -737
- package/lib/vector-index.js +110 -110
- package/lib/version-check.js +122 -122
- package/lib/write-admission.js +402 -402
- package/locale/en.json +6 -6
- package/locale/zh.json +6 -6
- package/package.json +8 -4
- package/runtime-manifest.json +526 -526
- package/scripts/benchmark-embed.js +201 -201
- package/scripts/benchmark-recall.js +249 -249
- package/scripts/benchmark-rerank.js +166 -166
- package/scripts/build-runtime-manifest.mjs +224 -224
- package/scripts/changelog-prep.mjs +36 -0
- package/scripts/check-sync.js +42 -42
- package/scripts/e2e-dsh.js +220 -220
- package/scripts/mneme-runtime.mjs +277 -277
- package/scripts/stress-dsh.js +255 -255
- package/scripts/sync-lib.js +47 -47
- package/scripts/sync-test-badge.mjs +69 -69
- package/scripts/test-count-sync.mjs +44 -44
- package/src/api-standalone.js +673 -572
- package/src/api.js +1437 -1433
- package/src/bootstrap.js +211 -211
- package/src/commands.js +95 -95
- package/src/config.js +880 -836
- package/src/content-hash.js +54 -54
- package/src/continuity.js +220 -220
- package/src/document-index.js +94 -94
- package/src/document.js +303 -303
- package/src/dream/clustering.js +118 -118
- package/src/dream/decisions.js +674 -674
- package/src/dream/narratives.js +44 -44
- package/src/dream/sleep.js +819 -819
- package/src/dream.js +1501 -1501
- package/src/embedding.js +177 -177
- package/src/entities/extractor.js +252 -252
- package/src/graph/anchoring.js +179 -179
- package/src/guide.js +74 -74
- package/src/heat.js +144 -144
- package/src/hot-memory.js +46 -46
- package/src/index.js +535 -671
- package/src/inject.js +464 -450
- package/src/lang.js +248 -229
- package/src/local-embedder.js +358 -358
- package/src/maintenance.js +142 -142
- package/src/mirror.js +330 -330
- package/src/organize.js +450 -450
- package/src/peak-hours.js +117 -117
- package/src/placement.js +13 -0
- package/src/pre-inject-gate.js +340 -0
- package/src/quality-filter.js +126 -126
- package/src/recall-stats.js +175 -175
- package/src/reranker.js +269 -269
- package/src/runtime/adopt.js +176 -176
- package/src/runtime/closure.js +172 -172
- package/src/runtime/download.js +247 -247
- package/src/runtime/layout.js +265 -265
- package/src/runtime/loader.js +252 -252
- package/src/runtime/model-fetch.js +361 -361
- package/src/runtime/provision.js +237 -237
- package/src/runtime/tarball.js +130 -130
- package/src/runtime/verify.js +223 -204
- package/src/scope.js +146 -146
- package/src/search/adaptive.js +60 -60
- package/src/search/bm25.js +96 -96
- package/src/semantic.js +229 -0
- package/src/sensitive-scan.js +209 -209
- package/src/serve.js +215 -0
- package/src/service.js +2663 -2596
- package/src/settings.js +517 -509
- package/src/store.js +3007 -3007
- package/src/summarize.js +891 -856
- package/src/tools.js +743 -737
- package/src/vector-index.js +110 -110
- package/src/version-check.js +122 -122
- package/src/write-admission.js +402 -402
- package/test/adaptive-inject-budget.test.js +85 -85
- package/test/api-routes-gaps.test.js +218 -218
- package/test/api.test.js +1702 -1688
- package/test/audit.test.js +448 -448
- package/test/baseline-persist.test.js +248 -248
- package/test/benchmark.test.js +94 -94
- package/test/bootstrap.test.js +220 -220
- package/test/changelog-prep.test.js +60 -0
- package/test/client.test.js +1230 -1169
- package/test/clustering.test.js +100 -100
- package/test/commands.test.js +106 -106
- package/test/config.test.js +66 -66
- package/test/conflict-freeze.test.js +300 -300
- package/test/content-hash.test.js +204 -204
- package/test/continuity.test.js +333 -333
- package/test/document-dir.test.js +233 -233
- package/test/document.test.js +671 -671
- package/test/dream-archive-guardrail.test.js +116 -116
- package/test/dream-candidate-hybrid.test.js +281 -281
- package/test/dream-coverage-degrade.test.js +86 -86
- package/test/dream-failure-backoff.test.js +173 -173
- package/test/dream-peak-hours.test.js +194 -194
- package/test/dream.test.js +1385 -1383
- package/test/embedding-ready.test.js +196 -196
- package/test/entities.test.js +627 -547
- package/test/entity-recall.test.js +179 -179
- package/test/epistemic.test.js +325 -325
- package/test/fnew-0112.test.js +311 -311
- package/test/fnew-03.test.js +422 -422
- package/test/graph-anchoring.test.js +184 -184
- package/test/graph-api.test.js +175 -175
- package/test/graph-inject-budget.test.js +103 -103
- package/test/graph-passive-confirm.test.js +91 -91
- package/test/graph-weight-evolution.test.js +104 -104
- package/test/heat.test.js +175 -175
- package/test/helpers/dream-mock.js +82 -82
- package/test/helpers/peer-worker.mjs +25 -25
- package/test/helpers/runtime-source.js +60 -60
- package/test/helpers/tar-builder.js +80 -80
- package/test/helpers/write-admission-samples.js +308 -308
- package/test/hot-memory.test.js +145 -145
- package/test/inject-content-cap.test.js +91 -91
- package/test/inject-first-round-bm25.test.js +89 -89
- package/test/inject-heat.test.js +77 -61
- package/test/inject-parent-gate.test.js +106 -106
- package/test/inject-pools.test.js +194 -194
- package/test/inject-recall.test.js +105 -105
- package/test/inject-rotation.test.js +161 -161
- package/test/inject.test.js +420 -420
- package/test/lang.test.js +87 -87
- package/test/lastrunat-persist.test.js +101 -101
- package/test/lib-smoke.test.js +96 -96
- package/test/llm-audit.test.js +598 -598
- package/test/llm-message-source.test.js +207 -207
- package/test/local-embedder.test.js +292 -292
- package/test/maintenance.test.js +391 -391
- package/test/mcp-stdio.test.js +385 -385
- package/test/mirror-dirty.test.js +424 -424
- package/test/mirror-edit-digest.test.js +187 -187
- package/test/mirror-generation.test.js +499 -499
- package/test/mirror-types.test.js +208 -208
- package/test/mirror.test.js +249 -249
- package/test/narrative-bars.test.js +162 -162
- package/test/organize.test.js +418 -418
- package/test/peer-blockers.test.js +193 -193
- package/test/placement.test.js +36 -0
- package/test/policy-epoch.test.js +259 -259
- package/test/pre-inject-gate.test.js +434 -0
- package/test/quality-filter.test.js +186 -186
- package/test/reasoning-effort.test.js +752 -752
- package/test/recall-anti-update.test.js +82 -82
- package/test/recall-evals.test.js +253 -253
- package/test/recall-layer.test.js +326 -326
- package/test/recall-stats.test.js +189 -189
- package/test/receipt-chain.test.js +451 -451
- package/test/reflection.test.js +226 -226
- package/test/reindex-backfill.test.js +62 -62
- package/test/reranker-single-logit.test.js +73 -73
- package/test/reranker.test.js +197 -197
- package/test/resident-status.test.js +83 -83
- package/test/runtime-adopt.test.js +206 -206
- package/test/runtime-cli.test.js +89 -89
- package/test/runtime-closure.test.js +193 -193
- package/test/runtime-download.test.js +302 -302
- package/test/runtime-layout.test.js +163 -163
- package/test/runtime-loader.test.js +255 -255
- package/test/runtime-manifest.test.js +159 -159
- package/test/runtime-model-fetch.test.js +348 -348
- package/test/runtime-provision.test.js +101 -101
- package/test/runtime-tarball.test.js +81 -81
- package/test/runtime-verify.test.js +319 -279
- package/test/scope-candidates.test.js +239 -239
- package/test/scope-explicit.test.js +452 -452
- package/test/scope-inject-soft.test.js +106 -106
- package/test/scope-retrieval.test.js +197 -197
- package/test/scope-storage.test.js +255 -255
- package/test/scope-strict.test.js +367 -343
- package/test/search-fusion.test.js +90 -90
- package/test/semantic.test.js +124 -124
- package/test/sensitive-scan.test.js +207 -207
- package/test/serve-bin.test.js +121 -0
- package/test/serve.test.js +196 -0
- package/test/service-search.test.js +174 -174
- package/test/service.test.js +405 -405
- package/test/settings.test.js +261 -261
- package/test/skip-invalid-restore.test.js +262 -262
- package/test/sleep-action-set.test.js +422 -422
- package/test/sleep-heat.test.js +126 -126
- package/test/sleep-scheduler-rearm.test.js +89 -89
- package/test/sleep.test.js +527 -527
- package/test/standalone-api.test.js +751 -544
- package/test/store-vector-cache.test.js +98 -98
- package/test/store.test.js +396 -396
- package/test/stress.test.js +209 -209
- package/test/summarize-occurred-at.test.js +127 -0
- package/test/summarize-reasoning-effort.test.js +189 -189
- package/test/summarize.test.js +1465 -1465
- package/test/supersede-state-tracking.test.js +394 -0
- package/test/test-count-sync.test.js +60 -60
- package/test/tools-dto-schema-matrix.test.js +325 -325
- package/test/tools-memory-get-schema.test.js +65 -65
- package/test/tools.test.js +428 -374
- package/test/updated-at-semantics.test.js +113 -113
- package/test/vector-index.test.js +221 -221
- package/test/version-check.test.js +152 -152
- package/test/webServer-optional.test.js +82 -82
- 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
|
+
}
|