@lotargo/memory_plugin 1.5.0 → 1.5.2
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/mcp-server/admin/snapshot.js +19 -7
- package/mcp-server/graph/graph_extractor.js +20 -5
- package/mcp-server/index.js +1 -1
- package/mcp-server/ingest/normalizer.js +40 -4
- package/mcp-server/ingest/pipeline.js +6 -13
- package/mcp-server/retrieval/retriever.js +59 -42
- package/mcp-server/tools/identity_tools.js +3 -3
- package/package.json +2 -2
|
@@ -1,10 +1,24 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync, existsSync, readdirSync, rmSync, statSync } from "node:fs";
|
|
2
2
|
import { gzipSync, gunzipSync } from "node:zlib";
|
|
3
|
-
import { join } from "node:path";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
4
|
import { getDatabase, BLOBS_DIR } from "../db/database.js";
|
|
5
5
|
import { readBlob, saveBlob } from "../storage/blob_store.js";
|
|
6
6
|
import { ensureExportsDir } from "../ingest/exporter.js";
|
|
7
7
|
|
|
8
|
+
export function validateSnapshotPath(pathStr, isExport = false) {
|
|
9
|
+
if (typeof pathStr !== "string" || !pathStr.trim()) {
|
|
10
|
+
throw new Error("Snapshot path cannot be empty.");
|
|
11
|
+
}
|
|
12
|
+
const resolved = resolve(pathStr.trim());
|
|
13
|
+
if (!resolved.endsWith(".json") && !resolved.endsWith(".json.gz")) {
|
|
14
|
+
throw new Error(`Invalid snapshot file extension for path '${pathStr}'. Path must end with .json or .json.gz`);
|
|
15
|
+
}
|
|
16
|
+
if (!isExport && !existsSync(resolved)) {
|
|
17
|
+
throw new Error(`Snapshot file not found: ${pathStr}`);
|
|
18
|
+
}
|
|
19
|
+
return resolved;
|
|
20
|
+
}
|
|
21
|
+
|
|
8
22
|
export function listAvailableSnapshots() {
|
|
9
23
|
const exportsDir = ensureExportsDir();
|
|
10
24
|
if (!existsSync(exportsDir)) return [];
|
|
@@ -79,7 +93,7 @@ export async function exportSnapshot({ customDb = null, customBlobDir = BLOBS_DI
|
|
|
79
93
|
};
|
|
80
94
|
|
|
81
95
|
const jsonStr = JSON.stringify(snapshot, null, 2);
|
|
82
|
-
const targetPath = outputPath
|
|
96
|
+
const targetPath = outputPath ? validateSnapshotPath(outputPath, true) : join(ensureExportsDir(), `rag_snapshot_${Date.now()}.json.gz`);
|
|
83
97
|
|
|
84
98
|
if (targetPath.endsWith(".gz")) {
|
|
85
99
|
const gzipped = gzipSync(Buffer.from(jsonStr, "utf-8"));
|
|
@@ -96,11 +110,9 @@ export async function importSnapshot({ customDb = null, customBlobDir = BLOBS_DI
|
|
|
96
110
|
let snapshot;
|
|
97
111
|
|
|
98
112
|
if (typeof snapshotPathOrData === "string") {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
const raw = readFileSync(snapshotPathOrData);
|
|
103
|
-
if (snapshotPathOrData.endsWith(".gz")) {
|
|
113
|
+
const validPath = validateSnapshotPath(snapshotPathOrData, false);
|
|
114
|
+
const raw = readFileSync(validPath);
|
|
115
|
+
if (validPath.endsWith(".gz")) {
|
|
104
116
|
const decompressed = gunzipSync(raw);
|
|
105
117
|
snapshot = JSON.parse(decompressed.toString("utf-8"));
|
|
106
118
|
} else {
|
|
@@ -85,11 +85,26 @@ export async function saveGraphEdges(db, edges) {
|
|
|
85
85
|
}
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
export async function
|
|
88
|
+
export async function getRelatedSymbolsBatch(db, sectionIds) {
|
|
89
|
+
if (!sectionIds || sectionIds.length === 0) return new Map();
|
|
90
|
+
const placeholders = sectionIds.map(() => "?").join(",");
|
|
89
91
|
const stmt = db.prepare(`
|
|
90
|
-
SELECT
|
|
91
|
-
WHERE source_id
|
|
92
|
+
SELECT source_id, target_id FROM graph_edges
|
|
93
|
+
WHERE source_id IN (${placeholders}) AND relation_type = 'DEFINES_SYMBOL';
|
|
92
94
|
`);
|
|
93
|
-
const rows = await stmt.all(
|
|
94
|
-
|
|
95
|
+
const rows = await stmt.all(...sectionIds);
|
|
96
|
+
const result = new Map();
|
|
97
|
+
for (const r of rows) {
|
|
98
|
+
const list = result.get(r.source_id) || [];
|
|
99
|
+
list.push(r.target_id.replace("symbol:", ""));
|
|
100
|
+
result.set(r.source_id, list);
|
|
101
|
+
}
|
|
102
|
+
return result;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function getRelatedSymbols(db, sectionId) {
|
|
106
|
+
if (!sectionId) return [];
|
|
107
|
+
const map = await getRelatedSymbolsBatch(db, [sectionId]);
|
|
108
|
+
return map.get(sectionId) || [];
|
|
95
109
|
}
|
|
110
|
+
|
package/mcp-server/index.js
CHANGED
|
@@ -30,15 +30,51 @@ export function cleanHtml(html) {
|
|
|
30
30
|
return cleaned;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
export function validateUrlForSsrf(urlStr) {
|
|
34
|
+
if (typeof urlStr !== "string" || !urlStr.trim()) {
|
|
35
|
+
throw new Error(`Unsupported URL for ingestion: '${urlStr}'. Only http/https URLs are supported.`);
|
|
36
|
+
}
|
|
37
|
+
let parsed;
|
|
38
|
+
try {
|
|
39
|
+
parsed = new URL(urlStr.trim());
|
|
40
|
+
} catch {
|
|
41
|
+
throw new Error(`Invalid URL format for ingestion: '${urlStr}'`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
45
|
+
throw new Error(`Unsupported URL scheme '${parsed.protocol}'. Only http/https are allowed.`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const hostname = parsed.hostname.toLowerCase();
|
|
49
|
+
|
|
50
|
+
const isBlocked =
|
|
51
|
+
hostname === "localhost" ||
|
|
52
|
+
hostname === "127.0.0.1" ||
|
|
53
|
+
hostname === "::1" ||
|
|
54
|
+
hostname === "169.254.169.254" ||
|
|
55
|
+
hostname === "metadata.google.internal" ||
|
|
56
|
+
/^127\./.test(hostname) ||
|
|
57
|
+
/^10\./.test(hostname) ||
|
|
58
|
+
/^192\.168\./.test(hostname) ||
|
|
59
|
+
/^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(hostname) ||
|
|
60
|
+
/^169\.254\./.test(hostname) ||
|
|
61
|
+
/^0\./.test(hostname);
|
|
62
|
+
|
|
63
|
+
if (isBlocked) {
|
|
64
|
+
throw new Error(`Ingestion blocked: URL '${urlStr}' targets a private/local IP address or metadata service.`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return parsed;
|
|
68
|
+
}
|
|
69
|
+
|
|
33
70
|
// Fetch a web page and convert it to Markdown/text. Used by the 'url' ingestion type
|
|
34
71
|
// so the RAG store gets the page CONTENT, not just the URL string.
|
|
35
72
|
export async function fetchUrlContent(url) {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
}
|
|
73
|
+
const parsed = validateUrlForSsrf(url);
|
|
74
|
+
const targetUrl = parsed.toString();
|
|
39
75
|
let res;
|
|
40
76
|
try {
|
|
41
|
-
res = await fetch(
|
|
77
|
+
res = await fetch(targetUrl, {
|
|
42
78
|
headers: {
|
|
43
79
|
"User-Agent": "memory-agent-rag/1.0",
|
|
44
80
|
Accept: "text/html,application/xhtml+xml,application/json,text/plain,*/*",
|
|
@@ -85,12 +85,9 @@ export async function ingestDocument({
|
|
|
85
85
|
try {
|
|
86
86
|
const existingDoc = await db.prepare("SELECT id FROM documents WHERE path = ?").get(docPath);
|
|
87
87
|
if (existingDoc) {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
await db.prepare("DELETE FROM micro_chunks_fts WHERE id = ?").run(mc.id);
|
|
92
|
-
} catch {}
|
|
93
|
-
}
|
|
88
|
+
try {
|
|
89
|
+
await db.prepare("DELETE FROM micro_chunks_fts WHERE id IN (SELECT id FROM micro_chunks WHERE doc_id = ?);").run(existingDoc.id);
|
|
90
|
+
} catch {}
|
|
94
91
|
await db.prepare("DELETE FROM graph_edges WHERE source_id = ? OR target_id = ?").run(existingDoc.id, existingDoc.id);
|
|
95
92
|
await db.prepare("DELETE FROM documents WHERE id = ?").run(existingDoc.id);
|
|
96
93
|
}
|
|
@@ -184,8 +181,6 @@ export async function deleteDocument(docIdOrPath, customDb = null, customBlobDir
|
|
|
184
181
|
return { deleted: false, reason: "Document not found" };
|
|
185
182
|
}
|
|
186
183
|
|
|
187
|
-
const microChunks = await db.prepare("SELECT id FROM micro_chunks WHERE doc_id = ?").all(doc.id);
|
|
188
|
-
|
|
189
184
|
// Collect every id owned by this document so we can purge dangling graph edges
|
|
190
185
|
// (graph_edges has no FK constraints, so section/chunk/doc references would otherwise leak).
|
|
191
186
|
const ownedIds = [doc.id];
|
|
@@ -196,11 +191,9 @@ export async function deleteDocument(docIdOrPath, customDb = null, customBlobDir
|
|
|
196
191
|
|
|
197
192
|
await db.exec("BEGIN IMMEDIATE;");
|
|
198
193
|
try {
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
} catch {}
|
|
203
|
-
}
|
|
194
|
+
try {
|
|
195
|
+
await db.prepare("DELETE FROM micro_chunks_fts WHERE id IN (SELECT id FROM micro_chunks WHERE doc_id = ?);").run(doc.id);
|
|
196
|
+
} catch {}
|
|
204
197
|
|
|
205
198
|
// Auto-clean Agent knowledge graph links pointing at this document.
|
|
206
199
|
await db.prepare("DELETE FROM knowledge_links WHERE doc_id = ?").run(doc.id);
|
|
@@ -263,57 +263,74 @@ export async function hybridQuery({
|
|
|
263
263
|
|
|
264
264
|
// Parent-Child Rollup: Deduplicate hits sharing the same medium_id or section_id to prevent noise
|
|
265
265
|
const parentDeduplicatedHits = [];
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
const
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
266
|
+
if (fusedHits.length > 0) {
|
|
267
|
+
const hitIds = fusedHits.map((h) => h.id);
|
|
268
|
+
const placeholders = hitIds.map(() => "?").join(",");
|
|
269
|
+
const rows = await db.prepare(`
|
|
270
|
+
SELECT id, medium_id, section_id FROM micro_chunks WHERE id IN (${placeholders});
|
|
271
|
+
`).all(...hitIds);
|
|
272
|
+
const parentMap = new Map(rows.map((r) => [r.id, r]));
|
|
273
|
+
|
|
274
|
+
const seenParents = new Set();
|
|
275
|
+
for (const hit of fusedHits) {
|
|
276
|
+
const row = parentMap.get(hit.id);
|
|
277
|
+
const parentKey = row ? (row.medium_id || row.section_id) : hit.id;
|
|
278
|
+
if (!seenParents.has(parentKey)) {
|
|
279
|
+
seenParents.add(parentKey);
|
|
280
|
+
parentDeduplicatedHits.push(hit);
|
|
281
|
+
}
|
|
277
282
|
}
|
|
278
283
|
}
|
|
279
284
|
|
|
280
285
|
const topHits = parentDeduplicatedHits.slice(0, limit);
|
|
281
286
|
const results = [];
|
|
282
287
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
288
|
+
if (topHits.length > 0) {
|
|
289
|
+
const topIds = topHits.map((h) => h.id);
|
|
290
|
+
const placeholders = topIds.map(() => "?").join(",");
|
|
291
|
+
const details = await db.prepare(`
|
|
292
|
+
SELECT m.id as micro_id, s.id as section_id, s.heading, s.breadcrumbs, s.content as section_content,
|
|
293
|
+
med.content as medium_content, d.title as doc_title, d.path as doc_path
|
|
294
|
+
FROM micro_chunks m
|
|
295
|
+
JOIN sections s ON m.section_id = s.id
|
|
296
|
+
JOIN documents d ON m.doc_id = d.id
|
|
297
|
+
LEFT JOIN medium_chunks med ON m.medium_id = med.id
|
|
298
|
+
WHERE m.id IN (${placeholders});
|
|
299
|
+
`).all(...topIds);
|
|
300
|
+
|
|
301
|
+
const detailMap = new Map(details.map((d) => [d.micro_id, d]));
|
|
302
|
+
|
|
303
|
+
let symbolsBySection = new Map();
|
|
298
304
|
if (includeGraphContext) {
|
|
299
|
-
|
|
305
|
+
const sectionIds = [...new Set(details.map((d) => d.section_id).filter(Boolean))];
|
|
306
|
+
if (sectionIds.length > 0) {
|
|
307
|
+
const { getRelatedSymbolsBatch } = await import("../graph/graph_extractor.js");
|
|
308
|
+
symbolsBySection = await getRelatedSymbolsBatch(db, sectionIds);
|
|
309
|
+
}
|
|
300
310
|
}
|
|
301
311
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
312
|
+
for (const hit of topHits) {
|
|
313
|
+
const detail = detailMap.get(hit.id);
|
|
314
|
+
if (!detail) continue;
|
|
315
|
+
|
|
316
|
+
const symbols = symbolsBySection.get(detail.section_id) || [];
|
|
317
|
+
|
|
318
|
+
results.push({
|
|
319
|
+
chunk_id: hit.id,
|
|
320
|
+
doc_title: detail.doc_title,
|
|
321
|
+
doc_path: detail.doc_path,
|
|
322
|
+
heading: detail.heading,
|
|
323
|
+
breadcrumbs: detail.breadcrumbs,
|
|
324
|
+
snippet: hit.content,
|
|
325
|
+
paragraph_context: detail.medium_content || hit.content,
|
|
326
|
+
full_section_content: detail.section_content,
|
|
327
|
+
score: parseFloat((hit.score || 0).toFixed(4)),
|
|
328
|
+
rsf_score: hit.rsf_score ? parseFloat(hit.rsf_score.toFixed(4)) : null,
|
|
329
|
+
rrf_score: hit.rrf_score ? parseFloat(hit.rrf_score.toFixed(4)) : null,
|
|
330
|
+
cosine_sim: hit.cosine_sim ? parseFloat(hit.cosine_sim.toFixed(4)) : null,
|
|
331
|
+
defined_symbols: symbols,
|
|
332
|
+
});
|
|
333
|
+
}
|
|
317
334
|
}
|
|
318
335
|
|
|
319
336
|
return results;
|
|
@@ -33,7 +33,7 @@ export function registerIdentityTools(server) {
|
|
|
33
33
|
if (!factText || !docId) {
|
|
34
34
|
throw new Error("factText and docId are required parameters for link action");
|
|
35
35
|
}
|
|
36
|
-
const res = linkFactToDocument({
|
|
36
|
+
const res = await linkFactToDocument({
|
|
37
37
|
factKey: key,
|
|
38
38
|
factText,
|
|
39
39
|
docId,
|
|
@@ -48,14 +48,14 @@ export function registerIdentityTools(server) {
|
|
|
48
48
|
|
|
49
49
|
if (action === "get_doc_links") {
|
|
50
50
|
if (!docId) throw new Error("docId parameter is required for get_doc_links action");
|
|
51
|
-
const links = getLinksForDoc(docId);
|
|
51
|
+
const links = await getLinksForDoc(docId);
|
|
52
52
|
return {
|
|
53
53
|
content: [{ type: "text", text: JSON.stringify(links, null, 2) }],
|
|
54
54
|
};
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
if (action === "list_links") {
|
|
58
|
-
const links = listAllLinks(key);
|
|
58
|
+
const links = await listAllLinks(key);
|
|
59
59
|
return {
|
|
60
60
|
content: [{ type: "text", text: JSON.stringify(links, null, 2) }],
|
|
61
61
|
};
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotargo/memory_plugin",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.2",
|
|
4
4
|
"description": "100% local hybrid RAG memory for AI coding agents (OpenCode, Claude Code, Codex, Antigravity). MCP server + plugin: persistent user facts, document ingestion, vector + SQLite FTS5 retrieval across sessions.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "opencode-plugin/index.js",
|
|
7
7
|
"scripts": {
|
|
8
8
|
"preinstall": "node mcp-server/preinstall.js || true",
|
|
9
|
-
"test": "node
|
|
9
|
+
"test": "node tests/run_all.js",
|
|
10
10
|
"benchmark": "node mcp-server/benchmarks/run_benchmarks.js"
|
|
11
11
|
},
|
|
12
12
|
"bin": {
|