@modusensus/dsh-mneme 0.1.6 → 0.2.0
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 +239 -207
- package/cordis.patch.yml +15 -15
- package/lib/api.js +277 -245
- package/lib/client.js +461 -461
- package/lib/commands.js +64 -64
- package/lib/config.js +49 -15
- package/lib/dream/clustering.js +118 -0
- package/lib/dream/decisions.js +128 -121
- package/lib/dream.js +428 -209
- package/lib/embedding.js +97 -97
- package/lib/index.js +176 -120
- package/lib/inject.js +47 -47
- package/lib/local-embedder.js +265 -0
- package/lib/mirror.js +131 -131
- package/lib/reranker.js +203 -0
- package/lib/service.js +268 -174
- package/lib/settings.js +142 -142
- package/lib/store.js +409 -310
- package/lib/summarize.js +171 -171
- package/lib/tools.js +230 -241
- package/lib/vector-index.js +106 -0
- package/package.json +7 -3
- package/src/api.js +277 -245
- package/src/commands.js +64 -64
- package/src/config.js +49 -15
- package/src/dream/clustering.js +118 -0
- package/src/dream/decisions.js +128 -121
- package/src/dream.js +428 -209
- package/src/embedding.js +97 -97
- package/src/index.js +176 -120
- package/src/inject.js +47 -47
- package/src/local-embedder.js +265 -0
- package/src/mirror.js +131 -131
- package/src/reranker.js +203 -0
- package/src/service.js +268 -174
- package/src/settings.js +142 -142
- package/src/store.js +409 -310
- package/src/summarize.js +171 -171
- package/src/tools.js +230 -241
- package/src/vector-index.js +106 -0
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
// Fully-local embedding backends for dsh-mneme: ONNX via transformers.js,
|
|
2
|
+
// Ollama's HTTP API, and the OpenAI-compatible HTTP API (extracted from the
|
|
3
|
+
// old embedding.js logic). All classes share one interface so the orchestrator
|
|
4
|
+
// can pick a backend by provider name and degrade gracefully on failure.
|
|
5
|
+
// Methods throw on error — the caller decides the fallback chain.
|
|
6
|
+
const DEFAULT_TIMEOUT_MS = 15000;
|
|
7
|
+
|
|
8
|
+
/** djb2 — stable, fast fingerprint for a provider/model string. */
|
|
9
|
+
function hashString(s) {
|
|
10
|
+
let h = 5381;
|
|
11
|
+
for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) >>> 0;
|
|
12
|
+
return h.toString(16);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Full provider+model fingerprint used for index-consistency checks. */
|
|
16
|
+
function modelHash(model) {
|
|
17
|
+
return `${model}#${hashString(model)}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Lazy default loader: dynamic import keeps module load cheap. */
|
|
21
|
+
async function defaultPipelineLoader(task, model, options) {
|
|
22
|
+
const { pipeline } = await import("@huggingface/transformers");
|
|
23
|
+
return pipeline(task, model, options);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Flatten a transformers.js Tensor [batch, dim] into number[][]. */
|
|
27
|
+
function tensorToRows(tensor) {
|
|
28
|
+
const { data, dims } = tensor;
|
|
29
|
+
const rowLen = dims[dims.length - 1] || 0;
|
|
30
|
+
const rows = [];
|
|
31
|
+
for (let i = 0; i < data.length; i += rowLen) {
|
|
32
|
+
rows.push(Array.from(data.subarray(i, i + rowLen)));
|
|
33
|
+
}
|
|
34
|
+
// Single-text input may come back without the batch axis.
|
|
35
|
+
if (rows.length === 0 && rowLen > 0) rows.push(Array.from(data));
|
|
36
|
+
return rows;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* ONNX text embedder backed by transformers.js (onnxruntime-node underneath).
|
|
41
|
+
* Runs fully offline with mean pooling + L2 normalization for BERT-style
|
|
42
|
+
* models like bge-small-zh. `engineFactory` is injectable for tests.
|
|
43
|
+
*/
|
|
44
|
+
export class LocalEmbedder {
|
|
45
|
+
constructor(opts = {}) {
|
|
46
|
+
this.model = opts.model || "Xenova/bge-small-zh-v1.5";
|
|
47
|
+
this._dimension = opts.dimension || 512;
|
|
48
|
+
this.device = opts.device || "cpu";
|
|
49
|
+
this.batchSize = opts.batchSize || 8;
|
|
50
|
+
this.cacheDir = String(opts.cacheDir ?? "").trim();
|
|
51
|
+
this.useDtype = opts.useDtype || "q8";
|
|
52
|
+
this.logger = opts.logger ?? null;
|
|
53
|
+
// Test hook: replace the pipeline factory without touching modules.
|
|
54
|
+
this.engineFactory = opts.engineFactory || defaultPipelineLoader;
|
|
55
|
+
this.extractor = null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Load the model; throws when it cannot be loaded. */
|
|
59
|
+
async init() {
|
|
60
|
+
const options = {
|
|
61
|
+
dtype: this.useDtype,
|
|
62
|
+
device: this.device
|
|
63
|
+
};
|
|
64
|
+
if (this.cacheDir) options.cache_dir = this.cacheDir;
|
|
65
|
+
this.extractor = await this.engineFactory("feature-extraction", this.model, options);
|
|
66
|
+
this.logger?.info?.(
|
|
67
|
+
`[dsh-mneme] local embedder ready: ${this.model} (dim=${this._dimension}, device=${this.device})`
|
|
68
|
+
);
|
|
69
|
+
return this;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Embed many texts with mean pooling; chunks at batchSize. */
|
|
73
|
+
async embed(texts) {
|
|
74
|
+
if (!Array.isArray(texts)) throw new TypeError("embed expects an array of strings");
|
|
75
|
+
if (!this.extractor) throw new Error("LocalEmbedder not initialized");
|
|
76
|
+
const out = [];
|
|
77
|
+
for (let i = 0; i < texts.length; i += this.batchSize) {
|
|
78
|
+
const chunk = texts.slice(i, i + this.batchSize);
|
|
79
|
+
const tensor = await this.extractor(chunk, { pooling: "mean", normalize: true });
|
|
80
|
+
out.push(...tensorToRows(tensor));
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async embedSingle(text) {
|
|
86
|
+
const rows = await this.embed([String(text)]);
|
|
87
|
+
return rows[0];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
get dimension() {
|
|
91
|
+
return this._dimension;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
get modelHash() {
|
|
95
|
+
return modelHash(this.model);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
dispose() {
|
|
99
|
+
try {
|
|
100
|
+
this.extractor?.dispose?.();
|
|
101
|
+
} catch {
|
|
102
|
+
// best-effort: some engines free resources on GC
|
|
103
|
+
}
|
|
104
|
+
this.extractor = null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Chunk texts into batches of at most `size`. */
|
|
109
|
+
function chunk(texts, size) {
|
|
110
|
+
const out = [];
|
|
111
|
+
for (let i = 0; i < texts.length; i += size) out.push(texts.slice(i, i + size));
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Ollama embedder over its native HTTP API. `dimension` is inferred from the
|
|
117
|
+
* first response. init() verifies reachability and that the model exists.
|
|
118
|
+
*/
|
|
119
|
+
export class OllamaEmbedder {
|
|
120
|
+
constructor(opts = {}) {
|
|
121
|
+
this.baseUrl = String(opts.baseUrl ?? "http://localhost:11434").trim().replace(/\/+$/, "");
|
|
122
|
+
this.model = String(opts.model ?? "nomic-embed-text").trim();
|
|
123
|
+
this.logger = opts.logger ?? null;
|
|
124
|
+
this._dimension = null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async _post(body) {
|
|
128
|
+
return fetch(`${this.baseUrl}/api/embeddings`, {
|
|
129
|
+
method: "POST",
|
|
130
|
+
headers: { "Content-Type": "application/json" },
|
|
131
|
+
body: JSON.stringify(body),
|
|
132
|
+
signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS)
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Probe the server with a tiny prompt; throws when unreachable/missing. */
|
|
137
|
+
async init() {
|
|
138
|
+
const res = await this._post({ model: this.model, prompt: "ping" });
|
|
139
|
+
if (!res.ok) throw new Error(`Ollama ${this.model} unavailable: HTTP ${res.status}`);
|
|
140
|
+
const body = await res.json();
|
|
141
|
+
if (!Array.isArray(body?.embedding)) throw new Error(`Ollama ${this.model} returned no embedding`);
|
|
142
|
+
this._dimension = body.embedding.length;
|
|
143
|
+
this.logger?.info?.(
|
|
144
|
+
`[dsh-mneme] ollama embedder ready: ${this.model} (dim=${this._dimension})`
|
|
145
|
+
);
|
|
146
|
+
return this;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async embed(texts) {
|
|
150
|
+
if (!Array.isArray(texts)) throw new TypeError("embed expects an array of strings");
|
|
151
|
+
const out = [];
|
|
152
|
+
for (const text of texts) out.push(await this.embedSingle(text));
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async embedSingle(text) {
|
|
157
|
+
const res = await this._post({ model: this.model, prompt: String(text).slice(0, 8000) });
|
|
158
|
+
if (!res.ok) throw new Error(`Ollama embed failed: HTTP ${res.status}`);
|
|
159
|
+
const body = await res.json();
|
|
160
|
+
const vec = body?.embedding;
|
|
161
|
+
if (!Array.isArray(vec) || !vec.length) throw new Error("Ollama returned no embedding");
|
|
162
|
+
if (this._dimension == null) this._dimension = vec.length;
|
|
163
|
+
return Array.from(vec);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
get dimension() {
|
|
167
|
+
return this._dimension ?? 0;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
get modelHash() {
|
|
171
|
+
return modelHash(this.model);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
dispose() {
|
|
175
|
+
this._dimension = null;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* OpenAI-compatible embedder (OpenAI, SiliconFlow, Zhipu, local proxies).
|
|
181
|
+
* Backward-compatible behavior lifted from embedding.js, but batchable and
|
|
182
|
+
* throwing on failure instead of returning null.
|
|
183
|
+
*/
|
|
184
|
+
export class OpenAIEmbedder {
|
|
185
|
+
constructor(opts = {}) {
|
|
186
|
+
this.baseUrl = String(opts.baseUrl ?? "").trim().replace(/\/+$/, "");
|
|
187
|
+
this.apiKey = String(opts.apiKey ?? "").trim();
|
|
188
|
+
this.model = String(opts.model ?? "").trim();
|
|
189
|
+
this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
190
|
+
this.logger = opts.logger ?? null;
|
|
191
|
+
this._dimension = null;
|
|
192
|
+
// Accept both "https://host/v1" and a full path ending in /embeddings.
|
|
193
|
+
this._url = /\/embeddings$/i.test(this.baseUrl)
|
|
194
|
+
? this.baseUrl
|
|
195
|
+
: this.baseUrl ? `${this.baseUrl}/embeddings` : "";
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async init() {
|
|
199
|
+
if (!this._url || !this.apiKey || !this.model) {
|
|
200
|
+
throw new Error("OpenAI embedder requires baseUrl, apiKey and model");
|
|
201
|
+
}
|
|
202
|
+
this.logger?.info?.(`[dsh-mneme] openai embedder ready: ${this.model}`);
|
|
203
|
+
return this;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async embed(texts) {
|
|
207
|
+
if (!Array.isArray(texts)) throw new TypeError("embed expects an array of strings");
|
|
208
|
+
const out = [];
|
|
209
|
+
for (const batch of chunk(texts, 32)) {
|
|
210
|
+
const res = await fetch(this._url, {
|
|
211
|
+
method: "POST",
|
|
212
|
+
headers: {
|
|
213
|
+
"Content-Type": "application/json",
|
|
214
|
+
"Authorization": `Bearer ${this.apiKey}`
|
|
215
|
+
},
|
|
216
|
+
body: JSON.stringify({ model: this.model, input: batch.map((t) => String(t).slice(0, 8000)) }),
|
|
217
|
+
signal: AbortSignal.timeout(this.timeoutMs)
|
|
218
|
+
});
|
|
219
|
+
if (!res.ok) throw new Error(`Embedding API failed: HTTP ${res.status}`);
|
|
220
|
+
const body = await res.json();
|
|
221
|
+
const list = body?.data;
|
|
222
|
+
if (!Array.isArray(list) || list.length !== batch.length) {
|
|
223
|
+
throw new Error("Embedding API returned unexpected payload");
|
|
224
|
+
}
|
|
225
|
+
for (const item of list) {
|
|
226
|
+
const vec = item?.embedding;
|
|
227
|
+
if (!Array.isArray(vec) || !vec.length) throw new Error("Embedding API returned empty vector");
|
|
228
|
+
if (this._dimension == null) this._dimension = vec.length;
|
|
229
|
+
out.push(Array.from(vec));
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return out;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async embedSingle(text) {
|
|
236
|
+
const rows = await this.embed([String(text)]);
|
|
237
|
+
return rows[0];
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
get dimension() {
|
|
241
|
+
return this._dimension ?? 0;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
get modelHash() {
|
|
245
|
+
return modelHash(this.model);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
dispose() {
|
|
249
|
+
this._dimension = null;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Pick a backend instance by provider name. Throws on unknown providers. */
|
|
254
|
+
export function createEmbedderByProvider(provider, opts) {
|
|
255
|
+
switch (String(provider ?? "").toLowerCase()) {
|
|
256
|
+
case "local":
|
|
257
|
+
return new LocalEmbedder(opts);
|
|
258
|
+
case "ollama":
|
|
259
|
+
return new OllamaEmbedder(opts);
|
|
260
|
+
case "openai":
|
|
261
|
+
return new OpenAIEmbedder(opts);
|
|
262
|
+
default:
|
|
263
|
+
throw new Error(`Unknown embedding provider: ${provider}`);
|
|
264
|
+
}
|
|
265
|
+
}
|
package/src/mirror.js
CHANGED
|
@@ -1,131 +1,131 @@
|
|
|
1
|
-
import { mkdirSync, readFileSync, writeFileSync, existsSync, rmSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
|
|
4
|
-
export const TYPE_FILE = {
|
|
5
|
-
preference: "preferences.md",
|
|
6
|
-
project: "projects.md",
|
|
7
|
-
decision: "decisions.md",
|
|
8
|
-
history: "history.md",
|
|
9
|
-
summary: "summary.md"
|
|
10
|
-
};
|
|
11
|
-
|
|
12
|
-
const ESCAPE = /([\\`*_[\]{}()#+.!|>~-])/g;
|
|
13
|
-
const UNESCAPE = new RegExp("\\\\" + ESCAPE.source, "g");
|
|
14
|
-
|
|
15
|
-
function esc(text) {
|
|
16
|
-
return String(text).replace(ESCAPE, "\\$1");
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
function unescape(text) {
|
|
20
|
-
return String(text).replace(UNESCAPE, "$1");
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function renderMemory(m) {
|
|
24
|
-
const lines = [];
|
|
25
|
-
lines.push(`## ${esc(m.title)}`);
|
|
26
|
-
lines.push("");
|
|
27
|
-
lines.push(`- **ID**: \`${m.id}\``);
|
|
28
|
-
lines.push(`- **类型**: ${m.type}`);
|
|
29
|
-
lines.push(`- **重要性**: ${m.importance}`);
|
|
30
|
-
lines.push(`- **标签**: ${m.tags.map((t) => `\`${esc(t)}\``).join(" ")}`);
|
|
31
|
-
lines.push(`- **更新时间**: ${m.updated_at}`);
|
|
32
|
-
if (m.source) lines.push(`- **来源**: ${esc(m.source)}`);
|
|
33
|
-
lines.push("");
|
|
34
|
-
lines.push(m.content);
|
|
35
|
-
lines.push("");
|
|
36
|
-
lines.push("---");
|
|
37
|
-
lines.push("");
|
|
38
|
-
return lines.join("\n");
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export function createMirror(dir) {
|
|
42
|
-
mkdirSync(dir, { recursive: true });
|
|
43
|
-
|
|
44
|
-
function filePath(type) {
|
|
45
|
-
const name = TYPE_FILE[type];
|
|
46
|
-
return name ? join(dir, name) : undefined;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Parse a mirror file back into {id, title, content} entries for human edits.
|
|
51
|
-
* Entries are anchored on "- **ID**: `...`" lines that are followed by the
|
|
52
|
-
* "- **类型**:" metadata line (structural entry head): each entry's block
|
|
53
|
-
* spans from its ID line up to the next ID line (or end of file). The block
|
|
54
|
-
* head (the ID line plus the generated metadata run) and the trailing
|
|
55
|
-
* structural "---" separator are stripped; everything in between is the entry
|
|
56
|
-
* body, so user content containing "---", metadata-like lines, or even a
|
|
57
|
-
* machine-format "- **ID**: `x`" line is preserved. The title is the "## "
|
|
58
|
-
* heading preceding the ID line.
|
|
59
|
-
*/
|
|
60
|
-
function readHumanEdits(type = undefined) {
|
|
61
|
-
const types = type ? [type] : Object.keys(TYPE_FILE);
|
|
62
|
-
const edits = [];
|
|
63
|
-
for (const t of types) {
|
|
64
|
-
const file = filePath(t);
|
|
65
|
-
if (!file || !existsSync(file)) continue;
|
|
66
|
-
const text = readFileSync(file, "utf8").replace(/\r\n/g, "\n");
|
|
67
|
-
// Anchor on the ID line only when it is a structural entry head: the
|
|
68
|
-
// machine-rendered ID line is always followed by the "- **类型**:" line.
|
|
69
|
-
// A body line like "- **ID**: `x`" is not, so it never splits the block
|
|
70
|
-
// or produces a ghost entry.
|
|
71
|
-
const anchors = [...text.matchAll(/^- \*\*ID\*\*: `([^`]+)`\n- \*\*类型\*\*:/gm)];
|
|
72
|
-
let prevEnd = 0;
|
|
73
|
-
for (let i = 0; i < anchors.length; i++) {
|
|
74
|
-
const anchor = anchors[i];
|
|
75
|
-
const blockStart = anchor.index;
|
|
76
|
-
const blockEnd = i + 1 < anchors.length ? anchors[i + 1].index : text.length;
|
|
77
|
-
|
|
78
|
-
// Title: last "## " heading before this ID line (file header region /
|
|
79
|
-
// previous block tail). Body headings of earlier entries come before
|
|
80
|
-
// the structural "---" + "## " of this entry, so the last match wins.
|
|
81
|
-
const titleMatches = [...text.slice(prevEnd, blockStart).matchAll(/^## (.+)$/gm)];
|
|
82
|
-
const titleMatch = titleMatches[titleMatches.length - 1];
|
|
83
|
-
|
|
84
|
-
// Body: the ID line and the generated metadata run are structural head;
|
|
85
|
-
// everything after them up to the trailing "---" separator is the body.
|
|
86
|
-
let body = text
|
|
87
|
-
.slice(blockStart, blockEnd)
|
|
88
|
-
.replace(/^- \*\*ID\*\*: `[^`]+`\n?/, "")
|
|
89
|
-
.replace(/^(- \*\*(类型|重要性|标签|更新时间|来源)\*\*:.*\n?)+/, "");
|
|
90
|
-
const separators = [...body.matchAll(/^---\s*$/gm)];
|
|
91
|
-
const lastSep = separators[separators.length - 1];
|
|
92
|
-
if (lastSep) body = body.slice(0, lastSep.index);
|
|
93
|
-
body = body.trim();
|
|
94
|
-
|
|
95
|
-
edits.push({
|
|
96
|
-
id: anchor[1],
|
|
97
|
-
title: titleMatch ? unescape(titleMatch[1]).trim() : undefined,
|
|
98
|
-
content: body
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
const lineEnd = text.indexOf("\n", blockStart);
|
|
102
|
-
prevEnd = lineEnd === -1 ? text.length : lineEnd + 1;
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
return edits;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function sync(memories) {
|
|
109
|
-
const byType = {};
|
|
110
|
-
for (const m of memories) {
|
|
111
|
-
(byType[m.type] ??= []).push(m);
|
|
112
|
-
}
|
|
113
|
-
for (const type of Object.keys(TYPE_FILE)) {
|
|
114
|
-
const file = filePath(type);
|
|
115
|
-
const items = (byType[type] ?? [])
|
|
116
|
-
.slice()
|
|
117
|
-
.sort((a, b) => (a.updated_at < b.updated_at ? 1 : -1));
|
|
118
|
-
if (items.length === 0) {
|
|
119
|
-
// no memories of this type: drop any stale mirror file so deleted
|
|
120
|
-
// memories do not "resurrect" via readHumanEdits
|
|
121
|
-
rmSync(file, { force: true });
|
|
122
|
-
continue;
|
|
123
|
-
}
|
|
124
|
-
const header = `# ${TYPE_FILE[type]} — dsh-mneme 镜像\n\n<!-- 手工编辑此文件会被合并回记忆库(人工优先)。 -->\n\n`;
|
|
125
|
-
const body = items.map(renderMemory).join("\n");
|
|
126
|
-
writeFileSync(file, header + body, "utf8");
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
return { filePath, sync, readHumanEdits };
|
|
131
|
-
}
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync, existsSync, rmSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
export const TYPE_FILE = {
|
|
5
|
+
preference: "preferences.md",
|
|
6
|
+
project: "projects.md",
|
|
7
|
+
decision: "decisions.md",
|
|
8
|
+
history: "history.md",
|
|
9
|
+
summary: "summary.md"
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const ESCAPE = /([\\`*_[\]{}()#+.!|>~-])/g;
|
|
13
|
+
const UNESCAPE = new RegExp("\\\\" + ESCAPE.source, "g");
|
|
14
|
+
|
|
15
|
+
function esc(text) {
|
|
16
|
+
return String(text).replace(ESCAPE, "\\$1");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function unescape(text) {
|
|
20
|
+
return String(text).replace(UNESCAPE, "$1");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function renderMemory(m) {
|
|
24
|
+
const lines = [];
|
|
25
|
+
lines.push(`## ${esc(m.title)}`);
|
|
26
|
+
lines.push("");
|
|
27
|
+
lines.push(`- **ID**: \`${m.id}\``);
|
|
28
|
+
lines.push(`- **类型**: ${m.type}`);
|
|
29
|
+
lines.push(`- **重要性**: ${m.importance}`);
|
|
30
|
+
lines.push(`- **标签**: ${m.tags.map((t) => `\`${esc(t)}\``).join(" ")}`);
|
|
31
|
+
lines.push(`- **更新时间**: ${m.updated_at}`);
|
|
32
|
+
if (m.source) lines.push(`- **来源**: ${esc(m.source)}`);
|
|
33
|
+
lines.push("");
|
|
34
|
+
lines.push(m.content);
|
|
35
|
+
lines.push("");
|
|
36
|
+
lines.push("---");
|
|
37
|
+
lines.push("");
|
|
38
|
+
return lines.join("\n");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function createMirror(dir) {
|
|
42
|
+
mkdirSync(dir, { recursive: true });
|
|
43
|
+
|
|
44
|
+
function filePath(type) {
|
|
45
|
+
const name = TYPE_FILE[type];
|
|
46
|
+
return name ? join(dir, name) : undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Parse a mirror file back into {id, title, content} entries for human edits.
|
|
51
|
+
* Entries are anchored on "- **ID**: `...`" lines that are followed by the
|
|
52
|
+
* "- **类型**:" metadata line (structural entry head): each entry's block
|
|
53
|
+
* spans from its ID line up to the next ID line (or end of file). The block
|
|
54
|
+
* head (the ID line plus the generated metadata run) and the trailing
|
|
55
|
+
* structural "---" separator are stripped; everything in between is the entry
|
|
56
|
+
* body, so user content containing "---", metadata-like lines, or even a
|
|
57
|
+
* machine-format "- **ID**: `x`" line is preserved. The title is the "## "
|
|
58
|
+
* heading preceding the ID line.
|
|
59
|
+
*/
|
|
60
|
+
function readHumanEdits(type = undefined) {
|
|
61
|
+
const types = type ? [type] : Object.keys(TYPE_FILE);
|
|
62
|
+
const edits = [];
|
|
63
|
+
for (const t of types) {
|
|
64
|
+
const file = filePath(t);
|
|
65
|
+
if (!file || !existsSync(file)) continue;
|
|
66
|
+
const text = readFileSync(file, "utf8").replace(/\r\n/g, "\n");
|
|
67
|
+
// Anchor on the ID line only when it is a structural entry head: the
|
|
68
|
+
// machine-rendered ID line is always followed by the "- **类型**:" line.
|
|
69
|
+
// A body line like "- **ID**: `x`" is not, so it never splits the block
|
|
70
|
+
// or produces a ghost entry.
|
|
71
|
+
const anchors = [...text.matchAll(/^- \*\*ID\*\*: `([^`]+)`\n- \*\*类型\*\*:/gm)];
|
|
72
|
+
let prevEnd = 0;
|
|
73
|
+
for (let i = 0; i < anchors.length; i++) {
|
|
74
|
+
const anchor = anchors[i];
|
|
75
|
+
const blockStart = anchor.index;
|
|
76
|
+
const blockEnd = i + 1 < anchors.length ? anchors[i + 1].index : text.length;
|
|
77
|
+
|
|
78
|
+
// Title: last "## " heading before this ID line (file header region /
|
|
79
|
+
// previous block tail). Body headings of earlier entries come before
|
|
80
|
+
// the structural "---" + "## " of this entry, so the last match wins.
|
|
81
|
+
const titleMatches = [...text.slice(prevEnd, blockStart).matchAll(/^## (.+)$/gm)];
|
|
82
|
+
const titleMatch = titleMatches[titleMatches.length - 1];
|
|
83
|
+
|
|
84
|
+
// Body: the ID line and the generated metadata run are structural head;
|
|
85
|
+
// everything after them up to the trailing "---" separator is the body.
|
|
86
|
+
let body = text
|
|
87
|
+
.slice(blockStart, blockEnd)
|
|
88
|
+
.replace(/^- \*\*ID\*\*: `[^`]+`\n?/, "")
|
|
89
|
+
.replace(/^(- \*\*(类型|重要性|标签|更新时间|来源)\*\*:.*\n?)+/, "");
|
|
90
|
+
const separators = [...body.matchAll(/^---\s*$/gm)];
|
|
91
|
+
const lastSep = separators[separators.length - 1];
|
|
92
|
+
if (lastSep) body = body.slice(0, lastSep.index);
|
|
93
|
+
body = body.trim();
|
|
94
|
+
|
|
95
|
+
edits.push({
|
|
96
|
+
id: anchor[1],
|
|
97
|
+
title: titleMatch ? unescape(titleMatch[1]).trim() : undefined,
|
|
98
|
+
content: body
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const lineEnd = text.indexOf("\n", blockStart);
|
|
102
|
+
prevEnd = lineEnd === -1 ? text.length : lineEnd + 1;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return edits;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function sync(memories) {
|
|
109
|
+
const byType = {};
|
|
110
|
+
for (const m of memories) {
|
|
111
|
+
(byType[m.type] ??= []).push(m);
|
|
112
|
+
}
|
|
113
|
+
for (const type of Object.keys(TYPE_FILE)) {
|
|
114
|
+
const file = filePath(type);
|
|
115
|
+
const items = (byType[type] ?? [])
|
|
116
|
+
.slice()
|
|
117
|
+
.sort((a, b) => (a.updated_at < b.updated_at ? 1 : -1));
|
|
118
|
+
if (items.length === 0) {
|
|
119
|
+
// no memories of this type: drop any stale mirror file so deleted
|
|
120
|
+
// memories do not "resurrect" via readHumanEdits
|
|
121
|
+
rmSync(file, { force: true });
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
const header = `# ${TYPE_FILE[type]} — dsh-mneme 镜像\n\n<!-- 手工编辑此文件会被合并回记忆库(人工优先)。 -->\n\n`;
|
|
125
|
+
const body = items.map(renderMemory).join("\n");
|
|
126
|
+
writeFileSync(file, header + body, "utf8");
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return { filePath, sync, readHumanEdits };
|
|
131
|
+
}
|