@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.
@@ -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 || join(ensureExportsDir(), `rag_snapshot_${Date.now()}.json.gz`);
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
- if (!existsSync(snapshotPathOrData)) {
100
- throw new Error(`Snapshot file not found: ${snapshotPathOrData}`);
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 getRelatedSymbols(db, sectionId) {
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 target_id, relation_type FROM graph_edges
91
- WHERE source_id = ? AND relation_type = 'DEFINES_SYMBOL';
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(sectionId);
94
- return rows.map((r) => r.target_id.replace("symbol:", ""));
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
+
@@ -32,7 +32,7 @@ await ensureDir();
32
32
 
33
33
  const server = new McpServer({
34
34
  name: "memory-agent",
35
- version: "1.5.0",
35
+ version: "1.5.2",
36
36
  });
37
37
 
38
38
  registerAllTools(server);
@@ -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
- if (typeof url !== "string" || !/^https?:\/\//i.test(url.trim())) {
37
- throw new Error(`Unsupported URL for ingestion: '${url}'. Only http/https URLs are supported.`);
38
- }
73
+ const parsed = validateUrlForSsrf(url);
74
+ const targetUrl = parsed.toString();
39
75
  let res;
40
76
  try {
41
- res = await fetch(url.trim(), {
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
- const microChunks = await db.prepare("SELECT id FROM micro_chunks WHERE doc_id = ?").all(existingDoc.id);
89
- for (const mc of microChunks) {
90
- try {
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
- for (const mc of microChunks) {
200
- try {
201
- await db.prepare("DELETE FROM micro_chunks_fts WHERE id = ?").run(mc.id);
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
- const seenParents = new Set();
267
- const parentLookupStmt = db.prepare(`
268
- SELECT medium_id, section_id FROM micro_chunks WHERE id = ?;
269
- `);
270
-
271
- for (const hit of fusedHits) {
272
- const row = await parentLookupStmt.get(hit.id);
273
- const parentKey = row ? (row.medium_id || row.section_id) : hit.id;
274
- if (!seenParents.has(parentKey)) {
275
- seenParents.add(parentKey);
276
- parentDeduplicatedHits.push(hit);
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
- const secStmt = db.prepare(`
284
- SELECT s.id as section_id, s.heading, s.breadcrumbs, s.content as section_content,
285
- med.content as medium_content, d.title as doc_title, d.path as doc_path
286
- FROM micro_chunks m
287
- JOIN sections s ON m.section_id = s.id
288
- JOIN documents d ON m.doc_id = d.id
289
- LEFT JOIN medium_chunks med ON m.medium_id = med.id
290
- WHERE m.id = ?;
291
- `);
292
-
293
- for (const hit of topHits) {
294
- const detail = await secStmt.get(hit.id);
295
- if (!detail) continue;
296
-
297
- let symbols = [];
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
- symbols = await getRelatedSymbols(db, detail.section_id);
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
- results.push({
303
- chunk_id: hit.id,
304
- doc_title: detail.doc_title,
305
- doc_path: detail.doc_path,
306
- heading: detail.heading,
307
- breadcrumbs: detail.breadcrumbs,
308
- snippet: hit.content,
309
- paragraph_context: detail.medium_content || hit.content,
310
- full_section_content: detail.section_content,
311
- score: parseFloat((hit.score || 0).toFixed(4)),
312
- rsf_score: hit.rsf_score ? parseFloat(hit.rsf_score.toFixed(4)) : null,
313
- rrf_score: hit.rrf_score ? parseFloat(hit.rrf_score.toFixed(4)) : null,
314
- cosine_sim: hit.cosine_sim ? parseFloat(hit.cosine_sim.toFixed(4)) : null,
315
- defined_symbols: symbols,
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.0",
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 mcp-server/test.js",
9
+ "test": "node tests/run_all.js",
10
10
  "benchmark": "node mcp-server/benchmarks/run_benchmarks.js"
11
11
  },
12
12
  "bin": {