@modusensus/dsh-mneme 0.6.7 → 0.6.8
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 +463 -463
- package/cordis.patch.yml +15 -15
- package/lib/api.js +783 -783
- package/lib/client.js +1757 -1757
- package/lib/commands.js +64 -64
- package/lib/config.js +288 -288
- package/lib/dream/clustering.js +118 -118
- package/lib/dream/decisions.js +439 -439
- package/lib/dream/sleep.js +561 -554
- package/lib/dream/tag-extractor.js +156 -156
- package/lib/dream.js +935 -929
- package/lib/embedding.js +154 -154
- package/lib/entities/extractor.js +242 -242
- package/lib/hot-memory.js +53 -53
- package/lib/index.js +361 -361
- package/lib/inject.js +208 -208
- package/lib/local-embedder.js +282 -282
- package/lib/mirror.js +170 -170
- package/lib/parser/tag.js +59 -59
- package/lib/parser/wiki-link.js +38 -38
- package/lib/quality-filter.js +123 -123
- package/lib/reranker.js +218 -218
- package/lib/search/adaptive.js +22 -22
- package/lib/search/bm25.js +96 -96
- package/lib/search/tag-boost.js +61 -61
- package/lib/service.js +1726 -1726
- package/lib/settings.js +172 -172
- package/lib/store.js +2238 -2238
- package/lib/summarize.js +236 -236
- package/lib/tools.js +290 -290
- package/lib/vector-index.js +116 -116
- package/package.json +80 -80
- package/scripts/benchmark-embed.js +201 -201
- package/scripts/benchmark-recall.js +133 -133
- package/scripts/benchmark-rerank.js +166 -166
- package/scripts/e2e-dsh.js +218 -218
- package/scripts/stress-dsh.js +255 -255
- package/scripts/sync-lib.js +52 -52
- package/src/api.js +783 -783
- package/src/commands.js +64 -64
- package/src/config.js +288 -288
- package/src/dream/clustering.js +118 -118
- package/src/dream/decisions.js +439 -439
- package/src/dream/sleep.js +561 -554
- package/src/dream/tag-extractor.js +156 -156
- package/src/dream.js +935 -929
- package/src/embedding.js +154 -154
- package/src/entities/extractor.js +242 -242
- package/src/hot-memory.js +53 -53
- package/src/index.js +361 -361
- package/src/inject.js +208 -208
- package/src/local-embedder.js +282 -282
- package/src/mirror.js +170 -170
- package/src/parser/tag.js +59 -59
- package/src/parser/wiki-link.js +38 -38
- package/src/quality-filter.js +123 -123
- package/src/reranker.js +218 -218
- package/src/search/adaptive.js +22 -22
- package/src/search/bm25.js +96 -96
- package/src/search/tag-boost.js +61 -61
- package/src/service.js +1726 -1726
- package/src/settings.js +172 -172
- package/src/store.js +2238 -2238
- package/src/summarize.js +236 -236
- package/src/tools.js +290 -290
- package/src/vector-index.js +116 -116
- package/test/api.test.js +594 -594
- package/test/audit.test.js +448 -448
- package/test/benchmark.test.js +35 -35
- package/test/boundary-v0625.test.js +82 -82
- package/test/client.test.js +368 -368
- package/test/clustering.test.js +100 -100
- package/test/commands.test.js +69 -69
- package/test/config.test.js +50 -50
- package/test/conflict-freeze.test.js +290 -290
- package/test/directory.test.js +134 -134
- package/test/dream.test.js +903 -901
- package/test/entities.test.js +522 -522
- 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/helpers/dream-mock.js +82 -82
- package/test/hot-memory.test.js +174 -174
- package/test/inject.test.js +103 -103
- package/test/llm-audit.test.js +279 -279
- package/test/local-embedder.test.js +227 -227
- 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/normalize-decisions.test.js +120 -120
- package/test/peer-blockers.test.js +190 -190
- package/test/policy-epoch.test.js +259 -259
- package/test/provenance.test.js +103 -103
- package/test/quality-filter.test.js +118 -118
- package/test/reasoning-effort.test.js +199 -199
- package/test/recall-evals.test.js +235 -235
- package/test/recall-layer.test.js +315 -315
- package/test/receipt-chain.test.js +451 -451
- package/test/reflection.test.js +226 -226
- package/test/reranker.test.js +240 -240
- package/test/search-fusion.test.js +90 -90
- package/test/semantic.test.js +124 -124
- package/test/service-search.test.js +199 -199
- package/test/service.test.js +435 -435
- package/test/settings.test.js +118 -118
- package/test/sleep.test.js +365 -365
- package/test/store.test.js +436 -436
- package/test/stress.test.js +209 -209
- package/test/summarize.test.js +191 -191
- package/test/tag-boost.test.js +125 -125
- package/test/tag.test.js +312 -312
- package/test/tools.test.js +285 -285
- package/test/vector-index.test.js +221 -221
- package/test/wiki-link.test.js +332 -332
package/src/mirror.js
CHANGED
|
@@ -1,170 +1,170 @@
|
|
|
1
|
-
import { mkdirSync, readFileSync, writeFileSync, existsSync, rmSync } from "node:fs";
|
|
2
|
-
import { createHash } from "node:crypto";
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
-
|
|
5
|
-
export const TYPE_FILE = {
|
|
6
|
-
preference: "preferences.md",
|
|
7
|
-
project: "projects.md",
|
|
8
|
-
decision: "decisions.md",
|
|
9
|
-
history: "history.md",
|
|
10
|
-
summary: "summary.md"
|
|
11
|
-
};
|
|
12
|
-
|
|
13
|
-
const ESCAPE = /([\\`*_[\]{}()#+.!|>~-])/g;
|
|
14
|
-
const UNESCAPE = new RegExp("\\\\" + ESCAPE.source, "g");
|
|
15
|
-
|
|
16
|
-
function esc(text) {
|
|
17
|
-
return String(text).replace(ESCAPE, "\\$1");
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
function unescape(text) {
|
|
21
|
-
return String(text).replace(UNESCAPE, "$1");
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
function renderMemory(m) {
|
|
25
|
-
// last-rendered digest baseline: sha256(title \x00 content). service.js
|
|
26
|
-
// compares the file hash against this to tell "untouched by a human" (machine
|
|
27
|
-
// write wins) apart from a real human edit, so a not-yet-re-rendered store
|
|
28
|
-
// update is not misread as a concurrent human edit.
|
|
29
|
-
const digest = createHash("sha256")
|
|
30
|
-
.update(`${m.title}\x00${m.content}`)
|
|
31
|
-
.digest("hex");
|
|
32
|
-
const lines = [];
|
|
33
|
-
lines.push(`## ${esc(m.title)}`);
|
|
34
|
-
// v0.6.2 tag line: entity_attrs-backed tags (attached by the service as
|
|
35
|
-
// `entityTags`) rendered as `#tag` space-separated under the title. No tags
|
|
36
|
-
// → no line. Legacy `- **标签**:` metadata below keeps the memories.tags
|
|
37
|
-
// column (still written by save/update) readable.
|
|
38
|
-
const entityTags = Array.isArray(m.entityTags) ? m.entityTags : [];
|
|
39
|
-
if (entityTags.length) {
|
|
40
|
-
lines.push("");
|
|
41
|
-
lines.push(entityTags.map((t) => `#${esc(t)}`).join(" "));
|
|
42
|
-
}
|
|
43
|
-
lines.push("");
|
|
44
|
-
lines.push(`- **ID**: \`${m.id}\``);
|
|
45
|
-
lines.push(`- **类型**: ${m.type}`);
|
|
46
|
-
lines.push(`- **重要性**: ${m.importance}`);
|
|
47
|
-
lines.push(`- **标签**: ${m.tags.map((t) => `\`${esc(t)}\``).join(" ")}`);
|
|
48
|
-
lines.push(`- **更新时间**: ${m.updated_at}`);
|
|
49
|
-
if (m.source) lines.push(`- **来源**: ${esc(m.source)}`);
|
|
50
|
-
lines.push("");
|
|
51
|
-
lines.push(`<!-- mirror-digest: ${digest} -->`);
|
|
52
|
-
lines.push(m.content);
|
|
53
|
-
lines.push("");
|
|
54
|
-
lines.push("---");
|
|
55
|
-
lines.push("");
|
|
56
|
-
return lines.join("\n");
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export function createMirror(dir) {
|
|
60
|
-
mkdirSync(dir, { recursive: true });
|
|
61
|
-
|
|
62
|
-
function filePath(type) {
|
|
63
|
-
const name = TYPE_FILE[type];
|
|
64
|
-
return name ? join(dir, name) : undefined;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* Parse a mirror file back into {id, title, content} entries for human edits.
|
|
69
|
-
* Entries are anchored on "- **ID**: `...`" lines that are followed by the
|
|
70
|
-
* "- **类型**:" metadata line (structural entry head): each entry's block
|
|
71
|
-
* spans from its ID line up to the next ID line (or end of file). The block
|
|
72
|
-
* head (the ID line plus the generated metadata run) and the trailing
|
|
73
|
-
* structural "---" separator are stripped; everything in between is the entry
|
|
74
|
-
* body, so user content containing "---", metadata-like lines, or even a
|
|
75
|
-
* machine-format "- **ID**: `x`" line is preserved. The title is the "## "
|
|
76
|
-
* heading preceding the ID line.
|
|
77
|
-
*/
|
|
78
|
-
function readHumanEdits(type = undefined) {
|
|
79
|
-
const types = type ? [type] : Object.keys(TYPE_FILE);
|
|
80
|
-
const edits = [];
|
|
81
|
-
for (const t of types) {
|
|
82
|
-
const file = filePath(t);
|
|
83
|
-
if (!file || !existsSync(file)) continue;
|
|
84
|
-
const text = readFileSync(file, "utf8").replace(/\r\n/g, "\n");
|
|
85
|
-
// Anchor on the ID line only when it is a structural entry head: the
|
|
86
|
-
// machine-rendered ID line is always followed by the "- **类型**:" line.
|
|
87
|
-
// A body line like "- **ID**: `x`" is not, so it never splits the block
|
|
88
|
-
// or produces a ghost entry.
|
|
89
|
-
const anchors = [...text.matchAll(/^- \*\*ID\*\*: `([^`]+)`\n- \*\*类型\*\*:/gm)];
|
|
90
|
-
let prevEnd = 0;
|
|
91
|
-
for (let i = 0; i < anchors.length; i++) {
|
|
92
|
-
const anchor = anchors[i];
|
|
93
|
-
const blockStart = anchor.index;
|
|
94
|
-
const blockEnd = i + 1 < anchors.length ? anchors[i + 1].index : text.length;
|
|
95
|
-
|
|
96
|
-
// Title: last "## " heading before this ID line (file header region /
|
|
97
|
-
// previous block tail). Body headings of earlier entries come before
|
|
98
|
-
// the structural "---" + "## " of this entry, so the last match wins.
|
|
99
|
-
const titleMatches = [...text.slice(prevEnd, blockStart).matchAll(/^## (.+)$/gm)];
|
|
100
|
-
const titleMatch = titleMatches[titleMatches.length - 1];
|
|
101
|
-
|
|
102
|
-
// Body: the ID line and the generated metadata run are structural head;
|
|
103
|
-
// everything after them up to the trailing "---" separator is the body.
|
|
104
|
-
let body = text
|
|
105
|
-
.slice(blockStart, blockEnd)
|
|
106
|
-
.replace(/^- \*\*ID\*\*: `[^`]+`\n?/, "")
|
|
107
|
-
.replace(/^(- \*\*(类型|重要性|标签|更新时间|来源)\*\*:.*\n?)+/, "")
|
|
108
|
-
.replace(/^<!-- mirror-digest: [a-f0-9]+ -->\n?/m, "");
|
|
109
|
-
const separators = [...body.matchAll(/^---\s*$/gm)];
|
|
110
|
-
const lastSep = separators[separators.length - 1];
|
|
111
|
-
if (lastSep) body = body.slice(0, lastSep.index);
|
|
112
|
-
body = body.trim();
|
|
113
|
-
|
|
114
|
-
// The machine-written "更新时间" line records the store's updated_at at
|
|
115
|
-
// render time — the version token for detecting a concurrent store write
|
|
116
|
-
// during a three-way merge of human edits (see service.syncMirror).
|
|
117
|
-
const block = text.slice(blockStart, blockEnd);
|
|
118
|
-
const updatedMatch = block.match(/- \*\*更新时间\*\*: ([^\n]+)/);
|
|
119
|
-
const digestMatch = block.match(/<!-- mirror-digest: ([a-f0-9]+) -->/);
|
|
120
|
-
edits.push({
|
|
121
|
-
id: anchor[1],
|
|
122
|
-
title: titleMatch ? unescape(titleMatch[1]).trim() : undefined,
|
|
123
|
-
content: body,
|
|
124
|
-
updated_at: updatedMatch ? updatedMatch[1].trim() : undefined,
|
|
125
|
-
digest: digestMatch ? digestMatch[1] : undefined
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
const lineEnd = text.indexOf("\n", blockStart);
|
|
129
|
-
prevEnd = lineEnd === -1 ? text.length : lineEnd + 1;
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
return edits;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
function sync(memories) {
|
|
136
|
-
const byType = {};
|
|
137
|
-
for (const m of memories) {
|
|
138
|
-
(byType[m.type] ??= []).push(m);
|
|
139
|
-
}
|
|
140
|
-
// Per-type physical outcomes (audit peer D): a failed write for one type
|
|
141
|
-
// must not abort the whole render. Each type is written (or pruned) in its
|
|
142
|
-
// own try/catch and the result reported so the caller can persist per-type
|
|
143
|
-
// committed/failed receipts — a file that was already written is a real
|
|
144
|
-
// physical commit even when a sibling type errors.
|
|
145
|
-
const results = {};
|
|
146
|
-
for (const type of Object.keys(TYPE_FILE)) {
|
|
147
|
-
try {
|
|
148
|
-
const file = filePath(type);
|
|
149
|
-
const items = (byType[type] ?? [])
|
|
150
|
-
.slice()
|
|
151
|
-
.sort((a, b) => (a.updated_at < b.updated_at ? 1 : -1));
|
|
152
|
-
if (items.length === 0) {
|
|
153
|
-
// no memories of this type: drop any stale mirror file so deleted
|
|
154
|
-
// memories do not "resurrect" via readHumanEdits
|
|
155
|
-
rmSync(file, { force: true });
|
|
156
|
-
} else {
|
|
157
|
-
const header = `# ${TYPE_FILE[type]} — dsh-mneme 镜像\n\n<!-- 手工编辑此文件会被合并回记忆库(人工优先)。 -->\n\n`;
|
|
158
|
-
const body = items.map(renderMemory).join("\n");
|
|
159
|
-
writeFileSync(file, header + body, "utf8");
|
|
160
|
-
}
|
|
161
|
-
results[type] = { ok: true };
|
|
162
|
-
} catch (error) {
|
|
163
|
-
results[type] = { ok: false, error: error?.message ?? String(error) };
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
return results;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
return { filePath, sync, readHumanEdits };
|
|
170
|
-
}
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync, existsSync, rmSync } from "node:fs";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
export const TYPE_FILE = {
|
|
6
|
+
preference: "preferences.md",
|
|
7
|
+
project: "projects.md",
|
|
8
|
+
decision: "decisions.md",
|
|
9
|
+
history: "history.md",
|
|
10
|
+
summary: "summary.md"
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const ESCAPE = /([\\`*_[\]{}()#+.!|>~-])/g;
|
|
14
|
+
const UNESCAPE = new RegExp("\\\\" + ESCAPE.source, "g");
|
|
15
|
+
|
|
16
|
+
function esc(text) {
|
|
17
|
+
return String(text).replace(ESCAPE, "\\$1");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function unescape(text) {
|
|
21
|
+
return String(text).replace(UNESCAPE, "$1");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function renderMemory(m) {
|
|
25
|
+
// last-rendered digest baseline: sha256(title \x00 content). service.js
|
|
26
|
+
// compares the file hash against this to tell "untouched by a human" (machine
|
|
27
|
+
// write wins) apart from a real human edit, so a not-yet-re-rendered store
|
|
28
|
+
// update is not misread as a concurrent human edit.
|
|
29
|
+
const digest = createHash("sha256")
|
|
30
|
+
.update(`${m.title}\x00${m.content}`)
|
|
31
|
+
.digest("hex");
|
|
32
|
+
const lines = [];
|
|
33
|
+
lines.push(`## ${esc(m.title)}`);
|
|
34
|
+
// v0.6.2 tag line: entity_attrs-backed tags (attached by the service as
|
|
35
|
+
// `entityTags`) rendered as `#tag` space-separated under the title. No tags
|
|
36
|
+
// → no line. Legacy `- **标签**:` metadata below keeps the memories.tags
|
|
37
|
+
// column (still written by save/update) readable.
|
|
38
|
+
const entityTags = Array.isArray(m.entityTags) ? m.entityTags : [];
|
|
39
|
+
if (entityTags.length) {
|
|
40
|
+
lines.push("");
|
|
41
|
+
lines.push(entityTags.map((t) => `#${esc(t)}`).join(" "));
|
|
42
|
+
}
|
|
43
|
+
lines.push("");
|
|
44
|
+
lines.push(`- **ID**: \`${m.id}\``);
|
|
45
|
+
lines.push(`- **类型**: ${m.type}`);
|
|
46
|
+
lines.push(`- **重要性**: ${m.importance}`);
|
|
47
|
+
lines.push(`- **标签**: ${m.tags.map((t) => `\`${esc(t)}\``).join(" ")}`);
|
|
48
|
+
lines.push(`- **更新时间**: ${m.updated_at}`);
|
|
49
|
+
if (m.source) lines.push(`- **来源**: ${esc(m.source)}`);
|
|
50
|
+
lines.push("");
|
|
51
|
+
lines.push(`<!-- mirror-digest: ${digest} -->`);
|
|
52
|
+
lines.push(m.content);
|
|
53
|
+
lines.push("");
|
|
54
|
+
lines.push("---");
|
|
55
|
+
lines.push("");
|
|
56
|
+
return lines.join("\n");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function createMirror(dir) {
|
|
60
|
+
mkdirSync(dir, { recursive: true });
|
|
61
|
+
|
|
62
|
+
function filePath(type) {
|
|
63
|
+
const name = TYPE_FILE[type];
|
|
64
|
+
return name ? join(dir, name) : undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Parse a mirror file back into {id, title, content} entries for human edits.
|
|
69
|
+
* Entries are anchored on "- **ID**: `...`" lines that are followed by the
|
|
70
|
+
* "- **类型**:" metadata line (structural entry head): each entry's block
|
|
71
|
+
* spans from its ID line up to the next ID line (or end of file). The block
|
|
72
|
+
* head (the ID line plus the generated metadata run) and the trailing
|
|
73
|
+
* structural "---" separator are stripped; everything in between is the entry
|
|
74
|
+
* body, so user content containing "---", metadata-like lines, or even a
|
|
75
|
+
* machine-format "- **ID**: `x`" line is preserved. The title is the "## "
|
|
76
|
+
* heading preceding the ID line.
|
|
77
|
+
*/
|
|
78
|
+
function readHumanEdits(type = undefined) {
|
|
79
|
+
const types = type ? [type] : Object.keys(TYPE_FILE);
|
|
80
|
+
const edits = [];
|
|
81
|
+
for (const t of types) {
|
|
82
|
+
const file = filePath(t);
|
|
83
|
+
if (!file || !existsSync(file)) continue;
|
|
84
|
+
const text = readFileSync(file, "utf8").replace(/\r\n/g, "\n");
|
|
85
|
+
// Anchor on the ID line only when it is a structural entry head: the
|
|
86
|
+
// machine-rendered ID line is always followed by the "- **类型**:" line.
|
|
87
|
+
// A body line like "- **ID**: `x`" is not, so it never splits the block
|
|
88
|
+
// or produces a ghost entry.
|
|
89
|
+
const anchors = [...text.matchAll(/^- \*\*ID\*\*: `([^`]+)`\n- \*\*类型\*\*:/gm)];
|
|
90
|
+
let prevEnd = 0;
|
|
91
|
+
for (let i = 0; i < anchors.length; i++) {
|
|
92
|
+
const anchor = anchors[i];
|
|
93
|
+
const blockStart = anchor.index;
|
|
94
|
+
const blockEnd = i + 1 < anchors.length ? anchors[i + 1].index : text.length;
|
|
95
|
+
|
|
96
|
+
// Title: last "## " heading before this ID line (file header region /
|
|
97
|
+
// previous block tail). Body headings of earlier entries come before
|
|
98
|
+
// the structural "---" + "## " of this entry, so the last match wins.
|
|
99
|
+
const titleMatches = [...text.slice(prevEnd, blockStart).matchAll(/^## (.+)$/gm)];
|
|
100
|
+
const titleMatch = titleMatches[titleMatches.length - 1];
|
|
101
|
+
|
|
102
|
+
// Body: the ID line and the generated metadata run are structural head;
|
|
103
|
+
// everything after them up to the trailing "---" separator is the body.
|
|
104
|
+
let body = text
|
|
105
|
+
.slice(blockStart, blockEnd)
|
|
106
|
+
.replace(/^- \*\*ID\*\*: `[^`]+`\n?/, "")
|
|
107
|
+
.replace(/^(- \*\*(类型|重要性|标签|更新时间|来源)\*\*:.*\n?)+/, "")
|
|
108
|
+
.replace(/^<!-- mirror-digest: [a-f0-9]+ -->\n?/m, "");
|
|
109
|
+
const separators = [...body.matchAll(/^---\s*$/gm)];
|
|
110
|
+
const lastSep = separators[separators.length - 1];
|
|
111
|
+
if (lastSep) body = body.slice(0, lastSep.index);
|
|
112
|
+
body = body.trim();
|
|
113
|
+
|
|
114
|
+
// The machine-written "更新时间" line records the store's updated_at at
|
|
115
|
+
// render time — the version token for detecting a concurrent store write
|
|
116
|
+
// during a three-way merge of human edits (see service.syncMirror).
|
|
117
|
+
const block = text.slice(blockStart, blockEnd);
|
|
118
|
+
const updatedMatch = block.match(/- \*\*更新时间\*\*: ([^\n]+)/);
|
|
119
|
+
const digestMatch = block.match(/<!-- mirror-digest: ([a-f0-9]+) -->/);
|
|
120
|
+
edits.push({
|
|
121
|
+
id: anchor[1],
|
|
122
|
+
title: titleMatch ? unescape(titleMatch[1]).trim() : undefined,
|
|
123
|
+
content: body,
|
|
124
|
+
updated_at: updatedMatch ? updatedMatch[1].trim() : undefined,
|
|
125
|
+
digest: digestMatch ? digestMatch[1] : undefined
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const lineEnd = text.indexOf("\n", blockStart);
|
|
129
|
+
prevEnd = lineEnd === -1 ? text.length : lineEnd + 1;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return edits;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function sync(memories) {
|
|
136
|
+
const byType = {};
|
|
137
|
+
for (const m of memories) {
|
|
138
|
+
(byType[m.type] ??= []).push(m);
|
|
139
|
+
}
|
|
140
|
+
// Per-type physical outcomes (audit peer D): a failed write for one type
|
|
141
|
+
// must not abort the whole render. Each type is written (or pruned) in its
|
|
142
|
+
// own try/catch and the result reported so the caller can persist per-type
|
|
143
|
+
// committed/failed receipts — a file that was already written is a real
|
|
144
|
+
// physical commit even when a sibling type errors.
|
|
145
|
+
const results = {};
|
|
146
|
+
for (const type of Object.keys(TYPE_FILE)) {
|
|
147
|
+
try {
|
|
148
|
+
const file = filePath(type);
|
|
149
|
+
const items = (byType[type] ?? [])
|
|
150
|
+
.slice()
|
|
151
|
+
.sort((a, b) => (a.updated_at < b.updated_at ? 1 : -1));
|
|
152
|
+
if (items.length === 0) {
|
|
153
|
+
// no memories of this type: drop any stale mirror file so deleted
|
|
154
|
+
// memories do not "resurrect" via readHumanEdits
|
|
155
|
+
rmSync(file, { force: true });
|
|
156
|
+
} else {
|
|
157
|
+
const header = `# ${TYPE_FILE[type]} — dsh-mneme 镜像\n\n<!-- 手工编辑此文件会被合并回记忆库(人工优先)。 -->\n\n`;
|
|
158
|
+
const body = items.map(renderMemory).join("\n");
|
|
159
|
+
writeFileSync(file, header + body, "utf8");
|
|
160
|
+
}
|
|
161
|
+
results[type] = { ok: true };
|
|
162
|
+
} catch (error) {
|
|
163
|
+
results[type] = { ok: false, error: error?.message ?? String(error) };
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return results;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return { filePath, sync, readHumanEdits };
|
|
170
|
+
}
|
package/src/parser/tag.js
CHANGED
|
@@ -1,59 +1,59 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tag parser (v0.6.2). Given a memory's content, extract explicit hashtags of
|
|
3
|
-
* the form `#标签`. The allowed character set is `[a-zA-Z0-9_一-龥-]+` (ASCII
|
|
4
|
-
* letters/digits/underscore, CJK characters, and hyphen), so `#linux`,
|
|
5
|
-
* `#deepseek` and `#考研` are all valid tags.
|
|
6
|
-
*
|
|
7
|
-
* Rules:
|
|
8
|
-
* - multiple tags on one line are all extracted, in source order;
|
|
9
|
-
* - duplicates collapse to the first occurrence;
|
|
10
|
-
* - tags longer than MAX_TAG_LENGTH (20) characters are dropped as illegal;
|
|
11
|
-
* - edge hyphens (leading/trailing, e.g. a sentence-terminating `#tag-`) are
|
|
12
|
-
* stripped; a tag that collapses to empty is dropped;
|
|
13
|
-
* - markdown `## heading` never matches (the second `#`/space is not in the
|
|
14
|
-
* allowed set).
|
|
15
|
-
*
|
|
16
|
-
* Pure module: no store, no side effects — persistence (store.setMemoryTags)
|
|
17
|
-
* is a separate step. sanitizeTags shares the same validation so LLM-extracted
|
|
18
|
-
* tag arrays (autoDream tag-extractor) are filtered identically.
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
export const MAX_TAG_LENGTH = 20;
|
|
22
|
-
|
|
23
|
-
// `一` (U+4E00) .. `龥` (U+9FA5) is the CJK Unified Ideographs block (the
|
|
24
|
-
// rule's `一-龥`). Matching requires a tag character right after `#` so a bare
|
|
25
|
-
// `#` and markdown `## heading` are naturally excluded (#/space not in set).
|
|
26
|
-
const TAG_RE = /#[a-zA-Z0-9_一-龥-]+/g;
|
|
27
|
-
const EDGE_HYPHEN = /^-+|-+$/g;
|
|
28
|
-
const VALID_TAG = /^[a-zA-Z0-9_一-龥-]+$/;
|
|
29
|
-
|
|
30
|
-
/** Validate + dedupe an array of tag strings. Non-strings, blanks, over-long
|
|
31
|
-
* and illegal-character tags are dropped (fail-safe). A leading `#` is
|
|
32
|
-
* tolerated so raw LLM output can be passed through directly. */
|
|
33
|
-
export function sanitizeTags(tags) {
|
|
34
|
-
if (!Array.isArray(tags)) return [];
|
|
35
|
-
const seen = new Set();
|
|
36
|
-
const out = [];
|
|
37
|
-
for (const t of tags) {
|
|
38
|
-
if (typeof t !== "string") continue;
|
|
39
|
-
let tag = t.trim();
|
|
40
|
-
if (tag.startsWith("#")) tag = tag.slice(1);
|
|
41
|
-
tag = tag.replace(EDGE_HYPHEN, "");
|
|
42
|
-
if (!tag || tag.length > MAX_TAG_LENGTH) continue;
|
|
43
|
-
if (!VALID_TAG.test(tag)) continue;
|
|
44
|
-
if (seen.has(tag)) continue; // 去重(保留首次出现顺序)
|
|
45
|
-
seen.add(tag);
|
|
46
|
-
out.push(tag);
|
|
47
|
-
}
|
|
48
|
-
return out;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/** @returns {string[]} validated tags in source order, deduplicated. */
|
|
52
|
-
export function parseTags(content) {
|
|
53
|
-
if (typeof content !== "string" || content.length === 0) return [];
|
|
54
|
-
const raw = [];
|
|
55
|
-
TAG_RE.lastIndex = 0;
|
|
56
|
-
let m;
|
|
57
|
-
while ((m = TAG_RE.exec(content)) !== null) raw.push(m[0]);
|
|
58
|
-
return sanitizeTags(raw);
|
|
59
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Tag parser (v0.6.2). Given a memory's content, extract explicit hashtags of
|
|
3
|
+
* the form `#标签`. The allowed character set is `[a-zA-Z0-9_一-龥-]+` (ASCII
|
|
4
|
+
* letters/digits/underscore, CJK characters, and hyphen), so `#linux`,
|
|
5
|
+
* `#deepseek` and `#考研` are all valid tags.
|
|
6
|
+
*
|
|
7
|
+
* Rules:
|
|
8
|
+
* - multiple tags on one line are all extracted, in source order;
|
|
9
|
+
* - duplicates collapse to the first occurrence;
|
|
10
|
+
* - tags longer than MAX_TAG_LENGTH (20) characters are dropped as illegal;
|
|
11
|
+
* - edge hyphens (leading/trailing, e.g. a sentence-terminating `#tag-`) are
|
|
12
|
+
* stripped; a tag that collapses to empty is dropped;
|
|
13
|
+
* - markdown `## heading` never matches (the second `#`/space is not in the
|
|
14
|
+
* allowed set).
|
|
15
|
+
*
|
|
16
|
+
* Pure module: no store, no side effects — persistence (store.setMemoryTags)
|
|
17
|
+
* is a separate step. sanitizeTags shares the same validation so LLM-extracted
|
|
18
|
+
* tag arrays (autoDream tag-extractor) are filtered identically.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export const MAX_TAG_LENGTH = 20;
|
|
22
|
+
|
|
23
|
+
// `一` (U+4E00) .. `龥` (U+9FA5) is the CJK Unified Ideographs block (the
|
|
24
|
+
// rule's `一-龥`). Matching requires a tag character right after `#` so a bare
|
|
25
|
+
// `#` and markdown `## heading` are naturally excluded (#/space not in set).
|
|
26
|
+
const TAG_RE = /#[a-zA-Z0-9_一-龥-]+/g;
|
|
27
|
+
const EDGE_HYPHEN = /^-+|-+$/g;
|
|
28
|
+
const VALID_TAG = /^[a-zA-Z0-9_一-龥-]+$/;
|
|
29
|
+
|
|
30
|
+
/** Validate + dedupe an array of tag strings. Non-strings, blanks, over-long
|
|
31
|
+
* and illegal-character tags are dropped (fail-safe). A leading `#` is
|
|
32
|
+
* tolerated so raw LLM output can be passed through directly. */
|
|
33
|
+
export function sanitizeTags(tags) {
|
|
34
|
+
if (!Array.isArray(tags)) return [];
|
|
35
|
+
const seen = new Set();
|
|
36
|
+
const out = [];
|
|
37
|
+
for (const t of tags) {
|
|
38
|
+
if (typeof t !== "string") continue;
|
|
39
|
+
let tag = t.trim();
|
|
40
|
+
if (tag.startsWith("#")) tag = tag.slice(1);
|
|
41
|
+
tag = tag.replace(EDGE_HYPHEN, "");
|
|
42
|
+
if (!tag || tag.length > MAX_TAG_LENGTH) continue;
|
|
43
|
+
if (!VALID_TAG.test(tag)) continue;
|
|
44
|
+
if (seen.has(tag)) continue; // 去重(保留首次出现顺序)
|
|
45
|
+
seen.add(tag);
|
|
46
|
+
out.push(tag);
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** @returns {string[]} validated tags in source order, deduplicated. */
|
|
52
|
+
export function parseTags(content) {
|
|
53
|
+
if (typeof content !== "string" || content.length === 0) return [];
|
|
54
|
+
const raw = [];
|
|
55
|
+
TAG_RE.lastIndex = 0;
|
|
56
|
+
let m;
|
|
57
|
+
while ((m = TAG_RE.exec(content)) !== null) raw.push(m[0]);
|
|
58
|
+
return sanitizeTags(raw);
|
|
59
|
+
}
|
package/src/parser/wiki-link.js
CHANGED
|
@@ -1,38 +1,38 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Wiki-Link parser (v0.6.1). Given a memory's content, extract explicit
|
|
3
|
-
* cross-memory links of the form [[target]] or [[显示|target]]:
|
|
4
|
-
* [[target]] → { display: "target", target: "target" }
|
|
5
|
-
* [[显示|target]] → { display: "显示", target: "target" }
|
|
6
|
-
*
|
|
7
|
-
* Unclosed / empty-target / multi-pipe / bracket-nested markers are treated as
|
|
8
|
-
* illegal and ignored. Pure module: no store, no side effects — resolution is a
|
|
9
|
-
* separate step (resolveWikiLink) that needs a store handle.
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
/** @returns {{display: string, target: string}[]} in source order. */
|
|
13
|
-
export function parseWikiLinks(content) {
|
|
14
|
-
if (typeof content !== "string" || content.length === 0) return [];
|
|
15
|
-
const links = [];
|
|
16
|
-
// [^\[\]]* keeps a single match from crossing `]]`; an unclosed `[[` never
|
|
17
|
-
// matches, and `[[a [[b]] c]]` only yields the inner `[[b]]`.
|
|
18
|
-
const re = /\[\[([^\[\]]*)\]\]/g;
|
|
19
|
-
let m;
|
|
20
|
-
while ((m = re.exec(content)) !== null) {
|
|
21
|
-
const inner = m[1];
|
|
22
|
-
const parts = inner.split("|");
|
|
23
|
-
if (parts.length > 2) continue; // 多管道 → 非法,忽略
|
|
24
|
-
const rawTarget = (parts.length === 2 ? parts[1] : parts[0]).trim();
|
|
25
|
-
if (!rawTarget) continue; // 空目标 → 非法,忽略
|
|
26
|
-
const rawDisplay = parts[0].trim();
|
|
27
|
-
links.push({ display: rawDisplay || rawTarget, target: rawTarget });
|
|
28
|
-
}
|
|
29
|
-
return links;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/** Resolve a wiki-link target title to a memory row via a case-insensitive
|
|
33
|
-
* exact title match (store.findByTitle). Returns undefined when absent or the
|
|
34
|
-
* store exposes no such lookup. */
|
|
35
|
-
export function resolveWikiLink(store, title) {
|
|
36
|
-
if (!store || typeof title !== "string" || !title.trim()) return undefined;
|
|
37
|
-
return store.findByTitle?.(title.trim());
|
|
38
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Wiki-Link parser (v0.6.1). Given a memory's content, extract explicit
|
|
3
|
+
* cross-memory links of the form [[target]] or [[显示|target]]:
|
|
4
|
+
* [[target]] → { display: "target", target: "target" }
|
|
5
|
+
* [[显示|target]] → { display: "显示", target: "target" }
|
|
6
|
+
*
|
|
7
|
+
* Unclosed / empty-target / multi-pipe / bracket-nested markers are treated as
|
|
8
|
+
* illegal and ignored. Pure module: no store, no side effects — resolution is a
|
|
9
|
+
* separate step (resolveWikiLink) that needs a store handle.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** @returns {{display: string, target: string}[]} in source order. */
|
|
13
|
+
export function parseWikiLinks(content) {
|
|
14
|
+
if (typeof content !== "string" || content.length === 0) return [];
|
|
15
|
+
const links = [];
|
|
16
|
+
// [^\[\]]* keeps a single match from crossing `]]`; an unclosed `[[` never
|
|
17
|
+
// matches, and `[[a [[b]] c]]` only yields the inner `[[b]]`.
|
|
18
|
+
const re = /\[\[([^\[\]]*)\]\]/g;
|
|
19
|
+
let m;
|
|
20
|
+
while ((m = re.exec(content)) !== null) {
|
|
21
|
+
const inner = m[1];
|
|
22
|
+
const parts = inner.split("|");
|
|
23
|
+
if (parts.length > 2) continue; // 多管道 → 非法,忽略
|
|
24
|
+
const rawTarget = (parts.length === 2 ? parts[1] : parts[0]).trim();
|
|
25
|
+
if (!rawTarget) continue; // 空目标 → 非法,忽略
|
|
26
|
+
const rawDisplay = parts[0].trim();
|
|
27
|
+
links.push({ display: rawDisplay || rawTarget, target: rawTarget });
|
|
28
|
+
}
|
|
29
|
+
return links;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Resolve a wiki-link target title to a memory row via a case-insensitive
|
|
33
|
+
* exact title match (store.findByTitle). Returns undefined when absent or the
|
|
34
|
+
* store exposes no such lookup. */
|
|
35
|
+
export function resolveWikiLink(store, title) {
|
|
36
|
+
if (!store || typeof title !== "string" || !title.trim()) return undefined;
|
|
37
|
+
return store.findByTitle?.(title.trim());
|
|
38
|
+
}
|