@modusensus/dsh-mneme 0.8.3 → 0.8.5
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 +727 -636
- package/bin/cli.mjs +0 -0
- package/bin/dsh-mneme-mcp.mjs +510 -0
- package/cordis.patch.yml +21 -21
- package/lib/api-standalone.js +506 -316
- package/lib/api.js +1403 -1316
- package/lib/bootstrap.js +211 -0
- package/lib/client.js +4657 -4366
- package/lib/commands.js +95 -95
- package/lib/config.js +615 -510
- package/lib/document.js +221 -0
- package/lib/dream/clustering.js +118 -118
- package/lib/dream/decisions.js +658 -658
- package/lib/dream/narratives.js +44 -0
- package/lib/dream/sleep.js +702 -700
- package/lib/dream.js +147 -16
- package/lib/embedding.js +177 -177
- package/lib/entities/extractor.js +249 -249
- package/lib/guide.js +44 -0
- package/lib/heat.js +144 -136
- package/lib/hot-memory.js +46 -46
- package/lib/index.js +515 -515
- package/lib/inject.js +375 -295
- package/lib/lang.js +208 -179
- package/lib/local-embedder.js +320 -320
- package/lib/mirror.js +198 -198
- package/lib/organize.js +450 -0
- package/lib/quality-filter.js +126 -126
- package/lib/recall-stats.js +125 -0
- 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 -235
- package/lib/runtime/loader.js +252 -249
- 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 +204 -191
- package/lib/scope.js +118 -118
- package/lib/search/adaptive.js +60 -22
- package/lib/search/bm25.js +96 -96
- package/lib/service.js +2324 -2087
- package/lib/settings.js +459 -420
- package/lib/store.js +2549 -2367
- package/lib/summarize.js +820 -449
- package/lib/tools.js +702 -574
- package/lib/vector-index.js +110 -110
- package/lib/version-check.js +122 -122
- package/package.json +109 -108
- package/runtime-manifest.json +526 -596
- package/scripts/benchmark-embed.js +201 -201
- package/scripts/benchmark-recall.js +199 -199
- package/scripts/benchmark-rerank.js +166 -166
- package/scripts/build-runtime-manifest.mjs +224 -217
- 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 +67 -61
- package/src/api-standalone.js +506 -316
- package/src/api.js +1403 -1316
- package/src/bootstrap.js +211 -0
- package/src/commands.js +95 -95
- package/src/config.js +615 -510
- package/src/document.js +221 -0
- package/src/dream/clustering.js +118 -118
- package/src/dream/decisions.js +658 -658
- package/src/dream/narratives.js +44 -0
- package/src/dream/sleep.js +702 -700
- package/src/dream.js +147 -16
- package/src/embedding.js +177 -177
- package/src/entities/extractor.js +249 -249
- package/src/guide.js +44 -0
- package/src/heat.js +144 -136
- package/src/hot-memory.js +46 -46
- package/src/index.js +515 -515
- package/src/inject.js +375 -295
- package/src/lang.js +208 -179
- package/src/local-embedder.js +320 -320
- package/src/mirror.js +198 -198
- package/src/organize.js +450 -0
- package/src/quality-filter.js +126 -126
- package/src/recall-stats.js +125 -0
- 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 -235
- package/src/runtime/loader.js +252 -249
- 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 +204 -191
- package/src/scope.js +118 -118
- package/src/search/adaptive.js +60 -22
- package/src/search/bm25.js +96 -96
- package/src/service.js +2324 -2087
- package/src/settings.js +459 -420
- package/src/store.js +2549 -2367
- package/src/summarize.js +820 -449
- package/src/tools.js +702 -574
- package/src/vector-index.js +110 -110
- package/src/version-check.js +122 -122
- package/test/adaptive-inject-budget.test.js +85 -0
- package/test/api-routes-gaps.test.js +218 -218
- package/test/api.test.js +1640 -1482
- package/test/audit.test.js +448 -448
- package/test/benchmark.test.js +94 -94
- package/test/bootstrap.test.js +220 -0
- package/test/client.test.js +860 -736
- 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/document.test.js +523 -0
- 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.test.js +1308 -1254
- package/test/embedding-ready.test.js +196 -196
- package/test/entities.test.js +547 -547
- package/test/entity-recall.test.js +179 -0
- package/test/epistemic.test.js +298 -298
- package/test/fnew-0112.test.js +311 -311
- package/test/fnew-03.test.js +422 -422
- package/test/graph-api.test.js +175 -175
- package/test/heat.test.js +175 -148
- 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/hot-memory.test.js +145 -145
- package/test/inject-content-cap.test.js +91 -0
- package/test/inject-first-round-bm25.test.js +89 -89
- package/test/inject-heat.test.js +61 -0
- package/test/inject-pools.test.js +170 -0
- package/test/inject-recall.test.js +105 -0
- package/test/inject-rotation.test.js +161 -161
- package/test/inject.test.js +283 -249
- package/test/lang.test.js +87 -87
- package/test/lib-smoke.test.js +96 -96
- package/test/llm-audit.test.js +462 -309
- package/test/llm-message-source.test.js +177 -177
- package/test/local-embedder.test.js +249 -249
- package/test/mcp-stdio.test.js +385 -0
- 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.test.js +249 -249
- package/test/narrative-bars.test.js +162 -0
- package/test/organize.test.js +418 -0
- package/test/peer-blockers.test.js +193 -193
- package/test/policy-epoch.test.js +259 -259
- package/test/quality-filter.test.js +186 -186
- package/test/reasoning-effort.test.js +746 -746
- package/test/recall-evals.test.js +235 -235
- package/test/recall-layer.test.js +326 -326
- package/test/recall-stats.test.js +123 -0
- 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 -0
- 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 -237
- package/test/runtime-manifest.test.js +159 -158
- 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 +279 -214
- package/test/scope-candidates.test.js +239 -239
- package/test/scope-explicit.test.js +452 -452
- package/test/scope-retrieval.test.js +197 -197
- package/test/scope-storage.test.js +255 -255
- package/test/scope-strict.test.js +231 -231
- package/test/search-fusion.test.js +90 -90
- package/test/semantic.test.js +124 -124
- 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 -125
- package/test/sleep-scheduler-rearm.test.js +89 -89
- package/test/sleep.test.js +410 -373
- package/test/standalone-api.test.js +544 -359
- package/test/store-vector-cache.test.js +98 -98
- package/test/store.test.js +396 -383
- package/test/stress.test.js +209 -209
- package/test/summarize.test.js +1288 -575
- package/test/tools-dto-schema-matrix.test.js +325 -0
- package/test/tools-memory-get-schema.test.js +65 -65
- package/test/tools.test.js +374 -341
- 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/lib/mirror.js
CHANGED
|
@@ -1,198 +1,198 @@
|
|
|
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
|
-
|
|
6
|
-
export const TYPE_FILE = {
|
|
7
|
-
preference: "preferences.md",
|
|
8
|
-
project: "projects.md",
|
|
9
|
-
decision: "decisions.md",
|
|
10
|
-
history: "history.md",
|
|
11
|
-
summary: "summary.md"
|
|
12
|
-
};
|
|
13
|
-
|
|
14
|
-
const ESCAPE = /([\\`*_[\]{}()#+.!|>~-])/g;
|
|
15
|
-
const UNESCAPE = new RegExp("\\\\" + ESCAPE.source, "g");
|
|
16
|
-
|
|
17
|
-
function esc(text) {
|
|
18
|
-
return String(text).replace(ESCAPE, "\\$1");
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
function unescape(text) {
|
|
22
|
-
return String(text).replace(UNESCAPE, "$1");
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function renderMemory(m, language = "zh") {
|
|
26
|
-
// last-rendered digest baseline: sha256(title \x00 content). service.js
|
|
27
|
-
// compares the file hash against this to tell "untouched by a human" (machine
|
|
28
|
-
// write wins) apart from a real human edit, so a not-yet-re-rendered store
|
|
29
|
-
// update is not misread as a concurrent human edit.
|
|
30
|
-
const digest = createHash("sha256")
|
|
31
|
-
.update(`${m.title}\x00${m.content}`)
|
|
32
|
-
.digest("hex");
|
|
33
|
-
const lines = [];
|
|
34
|
-
lines.push(`## ${esc(m.title)}`);
|
|
35
|
-
lines.push("");
|
|
36
|
-
lines.push(`- **ID**: \`${m.id}\``);
|
|
37
|
-
const ML = STR.mirrorLabel[language];
|
|
38
|
-
lines.push(`- **${ML.type}**: ${m.type}`);
|
|
39
|
-
lines.push(`- **${ML.importance}**: ${m.importance}`);
|
|
40
|
-
lines.push(`- **${ML.tags}**: ${m.tags.map((t) => `\`${esc(t)}\``).join(" ")}`);
|
|
41
|
-
lines.push(`- **${ML.updated}**: ${m.updated_at}`);
|
|
42
|
-
if (m.source) lines.push(`- **${ML.source}**: ${esc(m.source)}`);
|
|
43
|
-
lines.push("");
|
|
44
|
-
lines.push(`<!-- mirror-digest: ${digest} -->`);
|
|
45
|
-
lines.push(m.content);
|
|
46
|
-
lines.push("");
|
|
47
|
-
lines.push("---");
|
|
48
|
-
lines.push("");
|
|
49
|
-
return lines.join("\n");
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Render one type's memories into exactly the mirror-file text (header +
|
|
54
|
-
* per-memory blocks, updated_at DESC like sync). sync() writes this to disk;
|
|
55
|
-
* the /export endpoint returns the same text, so an exported markdown is
|
|
56
|
-
* byte-compatible with a mirror file and can be fed straight back through
|
|
57
|
-
* parseHumanEdits → mergeHumanEdits. Unknown type → undefined.
|
|
58
|
-
*/
|
|
59
|
-
export function renderMirrorText(type, memories, language = "zh") {
|
|
60
|
-
const name = TYPE_FILE[type];
|
|
61
|
-
if (!name) return undefined;
|
|
62
|
-
const items = (memories ?? [])
|
|
63
|
-
.slice()
|
|
64
|
-
.sort((a, b) => (a.updated_at < b.updated_at ? 1 : -1));
|
|
65
|
-
const header = STR.mirrorHeader[language](name);
|
|
66
|
-
const body = items.map((m) => renderMemory(m, language)).join("\n");
|
|
67
|
-
return header + body;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Parse mirror text back into {id, title, content} entries for human edits.
|
|
72
|
-
* Pure text-in/edits-out core: readHumanEdits feeds it mirror file contents
|
|
73
|
-
* and the /import endpoint feeds it user-pasted markdown, so both paths share
|
|
74
|
-
* one parsing implementation (行为一致是硬约束——import 必须能吃回 export 与
|
|
75
|
-
* 磁盘镜像)。Entries are anchored on "- **ID**: `...`" lines that are followed
|
|
76
|
-
* by the "- **类型**:" metadata line (structural entry head): each entry's
|
|
77
|
-
* block spans from its ID line up to the next ID line (or end of text). The
|
|
78
|
-
* block head (the ID line plus the generated metadata run) and the trailing
|
|
79
|
-
* structural "---" separator are stripped; everything in between is the entry
|
|
80
|
-
* body, so user content containing "---", metadata-like lines, or even a
|
|
81
|
-
* machine-format "- **ID**: `x`" line is preserved. The title is the "## "
|
|
82
|
-
* heading preceding the ID line.
|
|
83
|
-
*/
|
|
84
|
-
export function parseHumanEdits(text) {
|
|
85
|
-
// CRLF 归一化(readHumanEdits 原有的读取侧处理移入纯函数,Windows 手工编辑
|
|
86
|
-
// 的文件与导入文本都能正确解析)。
|
|
87
|
-
const normalized = String(text ?? "").replace(/\r\n/g, "\n");
|
|
88
|
-
const edits = [];
|
|
89
|
-
// Anchor on the ID line only when it is a structural entry head: the
|
|
90
|
-
// machine-rendered ID line is always followed by the "- **类型**:" line.
|
|
91
|
-
// A body line like "- **ID**: `x`" is not, so it never splits the block
|
|
92
|
-
// or produces a ghost entry.
|
|
93
|
-
// 解析同时接受两种语言的标签:切换语言前渲染的镜像文件仍能合并。
|
|
94
|
-
const anchors = [...normalized.matchAll(/^- \*\*ID\*\*: `([^`]+)`\n- \*\*(?:类型|Type)\*\*:/gm)];
|
|
95
|
-
let prevEnd = 0;
|
|
96
|
-
for (let i = 0; i < anchors.length; i++) {
|
|
97
|
-
const anchor = anchors[i];
|
|
98
|
-
const blockStart = anchor.index;
|
|
99
|
-
const blockEnd = i + 1 < anchors.length ? anchors[i + 1].index : normalized.length;
|
|
100
|
-
|
|
101
|
-
// Title: last "## " heading before this ID line (file header region /
|
|
102
|
-
// previous block tail). Body headings of earlier entries come before
|
|
103
|
-
// the structural "---" + "## " of this entry, so the last match wins.
|
|
104
|
-
const titleMatches = [...normalized.slice(prevEnd, blockStart).matchAll(/^## (.+)$/gm)];
|
|
105
|
-
const titleMatch = titleMatches[titleMatches.length - 1];
|
|
106
|
-
|
|
107
|
-
// Body: the ID line and the generated metadata run are structural head;
|
|
108
|
-
// everything after them up to the trailing "---" separator is the body.
|
|
109
|
-
let body = normalized
|
|
110
|
-
.slice(blockStart, blockEnd)
|
|
111
|
-
.replace(/^- \*\*ID\*\*: `[^`]+`\n?/, "")
|
|
112
|
-
.replace(/^(- \*\*(?:类型|重要性|标签|更新时间|来源|Type|Importance|Tags|Updated|Source)\*\*:.*\n?)+/, "")
|
|
113
|
-
.replace(/^<!-- mirror-digest: [a-f0-9]+ -->\n?/m, "");
|
|
114
|
-
const separators = [...body.matchAll(/^---\s*$/gm)];
|
|
115
|
-
const lastSep = separators[separators.length - 1];
|
|
116
|
-
if (lastSep) body = body.slice(0, lastSep.index);
|
|
117
|
-
body = body.trim();
|
|
118
|
-
|
|
119
|
-
// The machine-written "更新时间" line records the store's updated_at at
|
|
120
|
-
// render time — the version token for detecting a concurrent store write
|
|
121
|
-
// during a three-way merge of human edits (see service.syncMirror).
|
|
122
|
-
const block = normalized.slice(blockStart, blockEnd);
|
|
123
|
-
const updatedMatch = block.match(/- \*\*(?:更新时间|Updated)\*\*: ([^\n]+)/);
|
|
124
|
-
const digestMatch = block.match(/<!-- mirror-digest: ([a-f0-9]+) -->/);
|
|
125
|
-
edits.push({
|
|
126
|
-
id: anchor[1],
|
|
127
|
-
title: titleMatch ? unescape(titleMatch[1]).trim() : undefined,
|
|
128
|
-
content: body,
|
|
129
|
-
updated_at: updatedMatch ? updatedMatch[1].trim() : undefined,
|
|
130
|
-
digest: digestMatch ? digestMatch[1] : undefined
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
const lineEnd = normalized.indexOf("\n", blockStart);
|
|
134
|
-
prevEnd = lineEnd === -1 ? normalized.length : lineEnd + 1;
|
|
135
|
-
}
|
|
136
|
-
return edits;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
export function createMirror(dir, language = "zh") {
|
|
140
|
-
mkdirSync(dir, { recursive: true });
|
|
141
|
-
|
|
142
|
-
function filePath(type) {
|
|
143
|
-
const name = TYPE_FILE[type];
|
|
144
|
-
return name ? join(dir, name) : undefined;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
/**
|
|
148
|
-
* Read the mirror files and parse them back into human edits. The pure
|
|
149
|
-
* parsing logic lives in the exported parseHumanEdits (shared with /import);
|
|
150
|
-
* this wrapper only owns the "read file → text" side.
|
|
151
|
-
*/
|
|
152
|
-
function readHumanEdits(type = undefined) {
|
|
153
|
-
const types = type ? [type] : Object.keys(TYPE_FILE);
|
|
154
|
-
const edits = [];
|
|
155
|
-
for (const t of types) {
|
|
156
|
-
const file = filePath(t);
|
|
157
|
-
if (!file || !existsSync(file)) continue;
|
|
158
|
-
edits.push(...parseHumanEdits(readFileSync(file, "utf8")));
|
|
159
|
-
}
|
|
160
|
-
return edits;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
function sync(memories) {
|
|
164
|
-
const byType = {};
|
|
165
|
-
for (const m of memories) {
|
|
166
|
-
(byType[m.type] ??= []).push(m);
|
|
167
|
-
}
|
|
168
|
-
// Per-type physical outcomes (audit peer D): a failed write for one type
|
|
169
|
-
// must not abort the whole render. Each type is written (or pruned) in its
|
|
170
|
-
// own try/catch and the result reported so the caller can persist per-type
|
|
171
|
-
// committed/failed receipts — a file that was already written is a real
|
|
172
|
-
// physical commit even when a sibling type errors.
|
|
173
|
-
const results = {};
|
|
174
|
-
for (const type of Object.keys(TYPE_FILE)) {
|
|
175
|
-
try {
|
|
176
|
-
const file = filePath(type);
|
|
177
|
-
const items = (byType[type] ?? [])
|
|
178
|
-
.slice()
|
|
179
|
-
.sort((a, b) => (a.updated_at < b.updated_at ? 1 : -1));
|
|
180
|
-
if (items.length === 0) {
|
|
181
|
-
// no memories of this type: drop any stale mirror file so deleted
|
|
182
|
-
// memories do not "resurrect" via readHumanEdits
|
|
183
|
-
rmSync(file, { force: true });
|
|
184
|
-
} else {
|
|
185
|
-
// 渲染走 renderMirrorText(与 /export 共用同一条渲染路径),磁盘镜像
|
|
186
|
-
// 与导出文本永远同构。
|
|
187
|
-
writeFileSync(file, renderMirrorText(type, items, language), "utf8");
|
|
188
|
-
}
|
|
189
|
-
results[type] = { ok: true };
|
|
190
|
-
} catch (error) {
|
|
191
|
-
results[type] = { ok: false, error: error?.message ?? String(error) };
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
return results;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
return { filePath, sync, readHumanEdits };
|
|
198
|
-
}
|
|
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
|
+
|
|
6
|
+
export const TYPE_FILE = {
|
|
7
|
+
preference: "preferences.md",
|
|
8
|
+
project: "projects.md",
|
|
9
|
+
decision: "decisions.md",
|
|
10
|
+
history: "history.md",
|
|
11
|
+
summary: "summary.md"
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const ESCAPE = /([\\`*_[\]{}()#+.!|>~-])/g;
|
|
15
|
+
const UNESCAPE = new RegExp("\\\\" + ESCAPE.source, "g");
|
|
16
|
+
|
|
17
|
+
function esc(text) {
|
|
18
|
+
return String(text).replace(ESCAPE, "\\$1");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function unescape(text) {
|
|
22
|
+
return String(text).replace(UNESCAPE, "$1");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function renderMemory(m, language = "zh") {
|
|
26
|
+
// last-rendered digest baseline: sha256(title \x00 content). service.js
|
|
27
|
+
// compares the file hash against this to tell "untouched by a human" (machine
|
|
28
|
+
// write wins) apart from a real human edit, so a not-yet-re-rendered store
|
|
29
|
+
// update is not misread as a concurrent human edit.
|
|
30
|
+
const digest = createHash("sha256")
|
|
31
|
+
.update(`${m.title}\x00${m.content}`)
|
|
32
|
+
.digest("hex");
|
|
33
|
+
const lines = [];
|
|
34
|
+
lines.push(`## ${esc(m.title)}`);
|
|
35
|
+
lines.push("");
|
|
36
|
+
lines.push(`- **ID**: \`${m.id}\``);
|
|
37
|
+
const ML = STR.mirrorLabel[language];
|
|
38
|
+
lines.push(`- **${ML.type}**: ${m.type}`);
|
|
39
|
+
lines.push(`- **${ML.importance}**: ${m.importance}`);
|
|
40
|
+
lines.push(`- **${ML.tags}**: ${m.tags.map((t) => `\`${esc(t)}\``).join(" ")}`);
|
|
41
|
+
lines.push(`- **${ML.updated}**: ${m.updated_at}`);
|
|
42
|
+
if (m.source) lines.push(`- **${ML.source}**: ${esc(m.source)}`);
|
|
43
|
+
lines.push("");
|
|
44
|
+
lines.push(`<!-- mirror-digest: ${digest} -->`);
|
|
45
|
+
lines.push(m.content);
|
|
46
|
+
lines.push("");
|
|
47
|
+
lines.push("---");
|
|
48
|
+
lines.push("");
|
|
49
|
+
return lines.join("\n");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Render one type's memories into exactly the mirror-file text (header +
|
|
54
|
+
* per-memory blocks, updated_at DESC like sync). sync() writes this to disk;
|
|
55
|
+
* the /export endpoint returns the same text, so an exported markdown is
|
|
56
|
+
* byte-compatible with a mirror file and can be fed straight back through
|
|
57
|
+
* parseHumanEdits → mergeHumanEdits. Unknown type → undefined.
|
|
58
|
+
*/
|
|
59
|
+
export function renderMirrorText(type, memories, language = "zh") {
|
|
60
|
+
const name = TYPE_FILE[type];
|
|
61
|
+
if (!name) return undefined;
|
|
62
|
+
const items = (memories ?? [])
|
|
63
|
+
.slice()
|
|
64
|
+
.sort((a, b) => (a.updated_at < b.updated_at ? 1 : -1));
|
|
65
|
+
const header = STR.mirrorHeader[language](name);
|
|
66
|
+
const body = items.map((m) => renderMemory(m, language)).join("\n");
|
|
67
|
+
return header + body;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Parse mirror text back into {id, title, content} entries for human edits.
|
|
72
|
+
* Pure text-in/edits-out core: readHumanEdits feeds it mirror file contents
|
|
73
|
+
* and the /import endpoint feeds it user-pasted markdown, so both paths share
|
|
74
|
+
* one parsing implementation (行为一致是硬约束——import 必须能吃回 export 与
|
|
75
|
+
* 磁盘镜像)。Entries are anchored on "- **ID**: `...`" lines that are followed
|
|
76
|
+
* by the "- **类型**:" metadata line (structural entry head): each entry's
|
|
77
|
+
* block spans from its ID line up to the next ID line (or end of text). The
|
|
78
|
+
* block head (the ID line plus the generated metadata run) and the trailing
|
|
79
|
+
* structural "---" separator are stripped; everything in between is the entry
|
|
80
|
+
* body, so user content containing "---", metadata-like lines, or even a
|
|
81
|
+
* machine-format "- **ID**: `x`" line is preserved. The title is the "## "
|
|
82
|
+
* heading preceding the ID line.
|
|
83
|
+
*/
|
|
84
|
+
export function parseHumanEdits(text) {
|
|
85
|
+
// CRLF 归一化(readHumanEdits 原有的读取侧处理移入纯函数,Windows 手工编辑
|
|
86
|
+
// 的文件与导入文本都能正确解析)。
|
|
87
|
+
const normalized = String(text ?? "").replace(/\r\n/g, "\n");
|
|
88
|
+
const edits = [];
|
|
89
|
+
// Anchor on the ID line only when it is a structural entry head: the
|
|
90
|
+
// machine-rendered ID line is always followed by the "- **类型**:" line.
|
|
91
|
+
// A body line like "- **ID**: `x`" is not, so it never splits the block
|
|
92
|
+
// or produces a ghost entry.
|
|
93
|
+
// 解析同时接受两种语言的标签:切换语言前渲染的镜像文件仍能合并。
|
|
94
|
+
const anchors = [...normalized.matchAll(/^- \*\*ID\*\*: `([^`]+)`\n- \*\*(?:类型|Type)\*\*:/gm)];
|
|
95
|
+
let prevEnd = 0;
|
|
96
|
+
for (let i = 0; i < anchors.length; i++) {
|
|
97
|
+
const anchor = anchors[i];
|
|
98
|
+
const blockStart = anchor.index;
|
|
99
|
+
const blockEnd = i + 1 < anchors.length ? anchors[i + 1].index : normalized.length;
|
|
100
|
+
|
|
101
|
+
// Title: last "## " heading before this ID line (file header region /
|
|
102
|
+
// previous block tail). Body headings of earlier entries come before
|
|
103
|
+
// the structural "---" + "## " of this entry, so the last match wins.
|
|
104
|
+
const titleMatches = [...normalized.slice(prevEnd, blockStart).matchAll(/^## (.+)$/gm)];
|
|
105
|
+
const titleMatch = titleMatches[titleMatches.length - 1];
|
|
106
|
+
|
|
107
|
+
// Body: the ID line and the generated metadata run are structural head;
|
|
108
|
+
// everything after them up to the trailing "---" separator is the body.
|
|
109
|
+
let body = normalized
|
|
110
|
+
.slice(blockStart, blockEnd)
|
|
111
|
+
.replace(/^- \*\*ID\*\*: `[^`]+`\n?/, "")
|
|
112
|
+
.replace(/^(- \*\*(?:类型|重要性|标签|更新时间|来源|Type|Importance|Tags|Updated|Source)\*\*:.*\n?)+/, "")
|
|
113
|
+
.replace(/^<!-- mirror-digest: [a-f0-9]+ -->\n?/m, "");
|
|
114
|
+
const separators = [...body.matchAll(/^---\s*$/gm)];
|
|
115
|
+
const lastSep = separators[separators.length - 1];
|
|
116
|
+
if (lastSep) body = body.slice(0, lastSep.index);
|
|
117
|
+
body = body.trim();
|
|
118
|
+
|
|
119
|
+
// The machine-written "更新时间" line records the store's updated_at at
|
|
120
|
+
// render time — the version token for detecting a concurrent store write
|
|
121
|
+
// during a three-way merge of human edits (see service.syncMirror).
|
|
122
|
+
const block = normalized.slice(blockStart, blockEnd);
|
|
123
|
+
const updatedMatch = block.match(/- \*\*(?:更新时间|Updated)\*\*: ([^\n]+)/);
|
|
124
|
+
const digestMatch = block.match(/<!-- mirror-digest: ([a-f0-9]+) -->/);
|
|
125
|
+
edits.push({
|
|
126
|
+
id: anchor[1],
|
|
127
|
+
title: titleMatch ? unescape(titleMatch[1]).trim() : undefined,
|
|
128
|
+
content: body,
|
|
129
|
+
updated_at: updatedMatch ? updatedMatch[1].trim() : undefined,
|
|
130
|
+
digest: digestMatch ? digestMatch[1] : undefined
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const lineEnd = normalized.indexOf("\n", blockStart);
|
|
134
|
+
prevEnd = lineEnd === -1 ? normalized.length : lineEnd + 1;
|
|
135
|
+
}
|
|
136
|
+
return edits;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function createMirror(dir, language = "zh") {
|
|
140
|
+
mkdirSync(dir, { recursive: true });
|
|
141
|
+
|
|
142
|
+
function filePath(type) {
|
|
143
|
+
const name = TYPE_FILE[type];
|
|
144
|
+
return name ? join(dir, name) : undefined;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Read the mirror files and parse them back into human edits. The pure
|
|
149
|
+
* parsing logic lives in the exported parseHumanEdits (shared with /import);
|
|
150
|
+
* this wrapper only owns the "read file → text" side.
|
|
151
|
+
*/
|
|
152
|
+
function readHumanEdits(type = undefined) {
|
|
153
|
+
const types = type ? [type] : Object.keys(TYPE_FILE);
|
|
154
|
+
const edits = [];
|
|
155
|
+
for (const t of types) {
|
|
156
|
+
const file = filePath(t);
|
|
157
|
+
if (!file || !existsSync(file)) continue;
|
|
158
|
+
edits.push(...parseHumanEdits(readFileSync(file, "utf8")));
|
|
159
|
+
}
|
|
160
|
+
return edits;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function sync(memories) {
|
|
164
|
+
const byType = {};
|
|
165
|
+
for (const m of memories) {
|
|
166
|
+
(byType[m.type] ??= []).push(m);
|
|
167
|
+
}
|
|
168
|
+
// Per-type physical outcomes (audit peer D): a failed write for one type
|
|
169
|
+
// must not abort the whole render. Each type is written (or pruned) in its
|
|
170
|
+
// own try/catch and the result reported so the caller can persist per-type
|
|
171
|
+
// committed/failed receipts — a file that was already written is a real
|
|
172
|
+
// physical commit even when a sibling type errors.
|
|
173
|
+
const results = {};
|
|
174
|
+
for (const type of Object.keys(TYPE_FILE)) {
|
|
175
|
+
try {
|
|
176
|
+
const file = filePath(type);
|
|
177
|
+
const items = (byType[type] ?? [])
|
|
178
|
+
.slice()
|
|
179
|
+
.sort((a, b) => (a.updated_at < b.updated_at ? 1 : -1));
|
|
180
|
+
if (items.length === 0) {
|
|
181
|
+
// no memories of this type: drop any stale mirror file so deleted
|
|
182
|
+
// memories do not "resurrect" via readHumanEdits
|
|
183
|
+
rmSync(file, { force: true });
|
|
184
|
+
} else {
|
|
185
|
+
// 渲染走 renderMirrorText(与 /export 共用同一条渲染路径),磁盘镜像
|
|
186
|
+
// 与导出文本永远同构。
|
|
187
|
+
writeFileSync(file, renderMirrorText(type, items, language), "utf8");
|
|
188
|
+
}
|
|
189
|
+
results[type] = { ok: true };
|
|
190
|
+
} catch (error) {
|
|
191
|
+
results[type] = { ok: false, error: error?.message ?? String(error) };
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return results;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return { filePath, sync, readHumanEdits };
|
|
198
|
+
}
|