@lotargo/memory_plugin 1.2.901 → 1.3.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.
@@ -2,7 +2,34 @@
2
2
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
4
  import * as z from "zod/v4";
5
- import { ensureDir, readMemory, readMemoryRaw, writeMemory, today, MEMORY_DIR, GLOBAL_KEY, scopeKey, projectKey, projectName, canonicalPath, listProjectStores } from "./memory.js";
5
+ import { ensureDir, readMemory, readMemoryRaw, writeMemory, today, MEMORY_DIR, GLOBAL_KEY, scopeKey, projectKey, projectName, canonicalPath, listProjectStores, storeFilePath } from "./memory.js";
6
+ import {
7
+ parseFactEntry,
8
+ factText,
9
+ factMeta,
10
+ withMeta,
11
+ nextFactId,
12
+ isKeepFact,
13
+ displayFact,
14
+ formatFactEntry,
15
+ matchesQuery,
16
+ matchesTags,
17
+ inDateRange,
18
+ } from "./fact_format.js";
19
+ import { readFile } from "node:fs/promises";
20
+ import { join } from "node:path";
21
+
22
+ // Resolve a fact reference (1-based number, metadata id, or text) to an index.
23
+ function resolveFactIndex(entries, ref) {
24
+ const trimmed = String(ref || "").trim();
25
+ if (!trimmed) return -1;
26
+ const num = parseInt(trimmed, 10);
27
+ if (/^\d+$/.test(trimmed) && num >= 1 && num <= entries.length) return num - 1;
28
+ const idIdx = entries.findIndex((e) => factMeta(e).id === trimmed);
29
+ if (idIdx !== -1) return idIdx;
30
+ const textIdx = entries.findIndex((e) => factText(e).toLowerCase().includes(trimmed.toLowerCase()));
31
+ return textIdx;
32
+ }
6
33
 
7
34
  const cliArgs = process.argv.slice(2);
8
35
 
@@ -32,6 +59,18 @@ const server = new McpServer({
32
59
  version: "1.0.0",
33
60
  });
34
61
 
62
+ // Optional string/number that tolerates null (some tool-call layers fill omitted
63
+ // optional args with null). Linking fields must NEVER be mandatory.
64
+ const optStr = () => z.string().optional().nullable();
65
+ const optNum = () => z.number().optional().nullable();
66
+ const defStr = (fallback) =>
67
+ z
68
+ .string()
69
+ .nullish()
70
+ .transform((v) => (v === null || v === undefined || v === "" ? fallback : v));
71
+ const defBool = (fallback) => z.boolean().nullish().transform((v) => (v === null || v === undefined ? fallback : v));
72
+ const defNum = (fallback) => z.number().nullish().transform((v) => (v === null || v === undefined ? fallback : v));
73
+
35
74
  // --- Legacy Key-Value Memory Tools ---
36
75
 
37
76
  // --- Legacy Key-Value Memory Tools & Agent Graph Linking ---
@@ -42,27 +81,58 @@ server.registerTool(
42
81
  description:
43
82
  "Save an important, durable fact to memory. Only use for high-signal information " +
44
83
  "(name, goals, constraints, tech preferences, project conventions). " +
45
- "Optionally link the fact to a Knowledge Base document or exact line range (docId, startLine, endLine). " +
84
+ "docId/startLine/endLine/relationType are OPTIONAL and only used to link the fact to a " +
85
+ "Knowledge Base document or line range; omit them when no linking is needed. " +
86
+ "ttl is OPTIONAL (e.g. '90d', '2w', '24h') — expired facts are shown with [EXPIRED] but not auto-deleted. " +
87
+ "keep=true protects the fact from forget deletion unless force=true. " +
88
+ "tags is OPTIONAL comma-separated text for filtering. " +
89
+ "supersedes is OPTIONAL: a number (from recall), id, or text of a fact this one replaces; " +
90
+ "the target is then marked [SUPERSEDED]. " +
46
91
  "Translate the fact into English and keep it concise. " +
47
92
  "scope: 'project' (default) or 'global'",
48
93
  inputSchema: z.object({
49
94
  fact: z.string().describe("The fact to remember, written in English"),
50
- scope: z.string().default("project").describe("'project' (default) or 'global'"),
51
- docId: z.string().optional().describe("Optional document ID, title, or path to link this fact to"),
52
- startLine: z.number().optional().describe("Optional starting line number in target document"),
53
- endLine: z.number().optional().describe("Optional ending line number in target document"),
54
- relationType: z.string().default("LINKS_TO").describe("Relation type (e.g. 'RULES_FOR', 'IMPLEMENTS', 'REFERENCES')"),
95
+ scope: defStr("project").describe("'project' (default) or 'global'"),
96
+ docId: optStr().describe("Optional document ID, title, or path to link this fact to"),
97
+ startLine: optNum().describe("Optional starting line number in target document"),
98
+ endLine: optNum().describe("Optional ending line number in target document"),
99
+ relationType: defStr("LINKS_TO").describe("Relation type (e.g. 'RULES_FOR', 'IMPLEMENTS', 'REFERENCES')"),
100
+ ttl: optStr().describe("Optional time-to-live, e.g. '90d', '2w', '24h', '12m'"),
101
+ keep: defBool(false).describe("Protect the fact from forget deletion unless force=true"),
102
+ tags: optStr().describe("Optional comma-separated tags, e.g. 'pref,arch'"),
103
+ supersedes: optStr().describe("Optional number, id, or text of the fact this one replaces"),
55
104
  }),
56
105
  },
57
- async ({ fact, scope, docId, startLine, endLine, relationType }) => {
106
+ async ({ fact, scope, docId, startLine, endLine, relationType, ttl, keep, tags, supersedes }) => {
58
107
  const key = scopeKey(scope, null, null);
59
108
  const entries = await readMemory(key);
60
109
  const factNormalized = fact.toLowerCase().trim();
61
- if (!entries.some((e) => {
62
- const idx = e.indexOf("] ");
63
- return idx !== -1 && e.slice(idx + 2).toLowerCase().trim() === factNormalized;
64
- })) {
65
- entries.push(`- [${today()}] ${fact}`);
110
+ let duplicate = false;
111
+ if (entries.some((e) => factText(e).toLowerCase().trim() === factNormalized)) {
112
+ duplicate = true;
113
+ }
114
+
115
+ let supersededInfo = "";
116
+ if (!duplicate) {
117
+ const [date, time] = today().split(" ");
118
+ const meta = { ttl, tags };
119
+ if (keep) meta.keep = "1";
120
+ if (supersedes) {
121
+ const targetIdx = resolveFactIndex(entries, supersedes);
122
+ if (targetIdx !== -1) {
123
+ const newId = nextFactId(entries);
124
+ const targetMeta = factMeta(entries[targetIdx]);
125
+ const targetId = targetMeta.id || nextFactId(entries);
126
+ entries[targetIdx] = withMeta(entries[targetIdx], { id: targetId, supersededBy: newId });
127
+ meta.id = newId;
128
+ meta.supersedes = targetId;
129
+ supersededInfo = ` [superseded: "${factText(entries[targetIdx]).slice(0, 60)}"]`;
130
+ } else {
131
+ supersededInfo = " (note: supersedes target not found)";
132
+ }
133
+ }
134
+ if (!meta.id) meta.id = nextFactId(entries);
135
+ entries.push(formatFactEntry({ date, time, text: fact, meta }));
66
136
  await writeMemory(key, entries);
67
137
  }
68
138
 
@@ -85,7 +155,7 @@ server.registerTool(
85
155
  }
86
156
  }
87
157
 
88
- return { content: [{ type: "text", text: `Memory updated${linkInfo}` }] };
158
+ return { content: [{ type: "text", text: `Memory updated${supersededInfo}${linkInfo}` }] };
89
159
  }
90
160
  );
91
161
 
@@ -95,20 +165,27 @@ server.registerTool(
95
165
  description:
96
166
  "Show saved facts with any Agent-linked Knowledge Base documents/lines. " +
97
167
  "scope: 'project', 'global', 'all' (default), or 'list_projects'. " +
98
- "Use project: '<directory path>' with scope 'project'/'all' to read facts of a specific project from any working directory.",
168
+ "Use project: '<directory path>' with scope 'project'/'all' to read facts of a specific project from any working directory. " +
169
+ "query filters by keyword (all space-separated terms must match). " +
170
+ "tags filters by comma-separated tags. since/until filter by date (YYYY-MM-DD, inclusive). " +
171
+ "Expired facts are shown with [EXPIRED], protected ones with [KEEP]. The response includes the store file paths.",
99
172
  inputSchema: z.object({
100
- scope: z.string().default("all").describe("'project', 'global', 'all', or 'list_projects'"),
101
- project: z.string().optional().describe("Directory path of the project to read facts from (e.g. 'F:/projects/plugins/memory')"),
173
+ scope: defStr("all").describe("'project', 'global', 'all', or 'list_projects'"),
174
+ project: optStr().describe("Directory path of the project to read facts from (e.g. 'F:/projects/plugins/memory')"),
175
+ query: optStr().describe("Optional keyword filter; all space-separated terms must match"),
176
+ tags: optStr().describe("Optional comma-separated tag filter (any match)"),
177
+ since: optStr().describe("Optional start date filter, YYYY-MM-DD (inclusive)"),
178
+ until: optStr().describe("Optional end date filter, YYYY-MM-DD (inclusive)"),
102
179
  }),
103
180
  },
104
- async ({ scope, project }) => {
181
+ async ({ scope, project, query, tags, since, until }) => {
105
182
  const { getLinksForFact } = await import("./graph/knowledge_linker.js");
106
183
  const results = [];
107
184
 
108
- const formatFactWithLinks = (factText, key) => {
109
- let line = factText;
185
+ const formatFactWithLinks = (factLine, key) => {
186
+ let line = displayFact(factLine);
110
187
  try {
111
- const links = getLinksForFact(key, factText);
188
+ const links = getLinksForFact(key, factText(factLine));
112
189
  if (links && links.length > 0) {
113
190
  const docStr = links
114
191
  .map((l) => {
@@ -122,6 +199,17 @@ server.registerTool(
122
199
  return line;
123
200
  };
124
201
 
202
+ const collect = (entries, key) => {
203
+ const matched = entries.filter(
204
+ (e) => matchesQuery(e, query) && matchesTags(e, tags) && inDateRange(e, since, until)
205
+ );
206
+ if (!matched.length) return;
207
+ if (results.length) results.push("");
208
+ results.push(`--- ${key === GLOBAL_KEY ? "Global" : `Project: ${key === target ? label : key}`} ---`);
209
+ matched.forEach((e, i) => results.push(`${i + 1}. ${formatFactWithLinks(e, key)}`));
210
+ results.push(`Store file: ${storeFilePath(key)}`);
211
+ };
212
+
125
213
  if (scope === "list_projects") {
126
214
  const stores = await listProjectStores();
127
215
  if (!stores.length) {
@@ -134,7 +222,7 @@ server.registerTool(
134
222
  content: [
135
223
  {
136
224
  type: "text",
137
- text: `Project Memory Stores:\n${lines.join("\n")}\n\nUse recall(scope: "project", project: "<path>") to read a specific store.`,
225
+ text: `Project Memory Stores:\n${lines.join("\n")}\n\nUse recall(scope: "project", project: "<path>") to read a specific store.\n\nMemory dir: ${MEMORY_DIR}`,
138
226
  },
139
227
  ],
140
228
  };
@@ -143,21 +231,19 @@ server.registerTool(
143
231
  const target = project ? canonicalPath(project) : projectKey(null, null);
144
232
  const label = project ? target : projectName();
145
233
  if (scope !== "project") {
146
- const global = await readMemoryRaw(GLOBAL_KEY);
147
- if (global.length) {
148
- results.push("--- Global ---");
149
- global.forEach((e, i) => results.push(`${i + 1}. ${formatFactWithLinks(e, GLOBAL_KEY)}`));
150
- }
234
+ const global = await readMemory(GLOBAL_KEY);
235
+ collect(global, GLOBAL_KEY);
151
236
  }
152
237
  if (scope !== "global") {
153
- const local = await readMemoryRaw(target);
154
- if (local.length) {
155
- if (results.length) results.push("");
156
- results.push(`--- Project: ${label} ---`);
157
- local.forEach((e, i) => results.push(`${i + 1}. ${formatFactWithLinks(e, target)}`));
158
- }
238
+ const local = await readMemory(target);
239
+ collect(local, target);
159
240
  }
160
- const text = results.length ? results.join("\n") : "Memory is empty.";
241
+ const filtered = Boolean(query || tags || since || until);
242
+ const text = results.length
243
+ ? `${results.join("\n")}\n\nMemory dir: ${MEMORY_DIR}`
244
+ : filtered
245
+ ? "No facts match the search."
246
+ : "Memory is empty.";
161
247
  return { content: [{ type: "text", text }] };
162
248
  }
163
249
  );
@@ -166,40 +252,143 @@ server.registerTool(
166
252
  "forget",
167
253
  {
168
254
  description:
169
- "Delete a fact by number (from recall), by range (e.g. '3-30', inclusive), or by text search",
255
+ "Delete a fact by number (from recall), by range (e.g. '3-30', inclusive), or by text search. " +
256
+ "Protected facts (remember with keep=true) are skipped unless force=true.",
170
257
  inputSchema: z.object({
171
258
  query: z.string().describe("Number, range like '3-30', or text to search for"),
172
- scope: z.string().default("project").describe("'project' (default) or 'global'"),
259
+ scope: defStr("project").describe("'project' (default) or 'global'"),
260
+ force: defBool(false).describe("Also delete protected (keep) facts"),
173
261
  }),
174
262
  },
175
- async ({ query, scope }) => {
263
+ async ({ query, scope, force }) => {
176
264
  const key = scopeKey(scope, null, null);
177
265
  const entries = await readMemory(key);
178
266
  const rangeMatch = /^\s*(\d+)\s*-\s*(\d+)\s*$/.exec(query);
179
267
  const num = parseInt(query, 10);
180
- let removed;
268
+ let indices = [];
181
269
  if (rangeMatch) {
182
270
  const from = parseInt(rangeMatch[1], 10);
183
271
  const to = parseInt(rangeMatch[2], 10);
184
272
  if (from > 0 && to >= from && to <= entries.length) {
185
- removed = entries.splice(from - 1, to - from + 1);
273
+ for (let i = from - 1; i < to; i++) indices.push(i);
186
274
  }
187
275
  }
188
- if (!removed && !isNaN(num) && num > 0 && num <= entries.length) {
189
- removed = entries.splice(num - 1, 1);
276
+ if (!indices.length && !isNaN(num) && num > 0 && num <= entries.length) {
277
+ indices.push(num - 1);
190
278
  }
191
- if (!removed) {
192
- const filtered = entries.filter((e) => !e.toLowerCase().includes(query.toLowerCase()));
193
- removed = entries.filter((e) => e.toLowerCase().includes(query.toLowerCase()));
194
- entries.length = 0;
195
- entries.push(...filtered);
279
+ if (!indices.length) {
280
+ const q = query.toLowerCase();
281
+ indices = entries.reduce((acc, e, i) => (e.toLowerCase().includes(q) ? acc.concat(i) : acc), []);
196
282
  }
197
- await writeMemory(key, entries);
198
- const text = removed.length ? "Memory updated" : "Not found.";
283
+ if (!indices.length) {
284
+ return { content: [{ type: "text", text: "Not found." }] };
285
+ }
286
+
287
+ const removable = indices.filter((i) => force || !isKeepFact(entries[i]));
288
+ const protectedCount = indices.length - removable.length;
289
+ if (removable.length) {
290
+ for (const i of removable.sort((a, b) => b - a)) entries.splice(i, 1);
291
+ await writeMemory(key, entries);
292
+ }
293
+ let text = removable.length ? "Memory updated" : "Nothing removed.";
294
+ if (protectedCount) text += ` (${protectedCount} protected fact(s) skipped; use force=true to override)`;
199
295
  return { content: [{ type: "text", text }] };
200
296
  }
201
297
  );
202
298
 
299
+ server.registerTool(
300
+ "update_fact",
301
+ {
302
+ description:
303
+ "Update the text of an existing fact by number (from recall), id, or text match, " +
304
+ "preserving its original date and metadata. Linked Knowledge Base documents are re-pointed to the new text.",
305
+ inputSchema: z.object({
306
+ id: z.string().describe("Number (from recall), metadata id, or text of the fact to update"),
307
+ newText: z.string().describe("New fact text"),
308
+ scope: defStr("project").describe("'project' (default) or 'global'"),
309
+ }),
310
+ },
311
+ async ({ id, newText, scope }) => {
312
+ const key = scopeKey(scope, null, null);
313
+ const entries = await readMemory(key);
314
+ const idx = resolveFactIndex(entries, id);
315
+ if (idx === -1) throw new Error(`Fact not found: ${id}`);
316
+ const p = parseFactEntry(entries[idx]);
317
+ const oldText = p ? p.text : entries[idx];
318
+ const newLine = formatFactEntry({ date: p.date, time: p.time, text: newText, meta: p.meta });
319
+ entries[idx] = newLine;
320
+ await writeMemory(key, entries);
321
+
322
+ let linksUpdated = 0;
323
+ try {
324
+ const { getDatabase } = await import("./db/database.js");
325
+ const db = getDatabase();
326
+ const res = db
327
+ .prepare(
328
+ "UPDATE knowledge_links SET fact_text = ? WHERE fact_key = ? AND fact_text = ?"
329
+ )
330
+ .run(newText, key, oldText);
331
+ linksUpdated = res.changes;
332
+ } catch (e) {}
333
+
334
+ return {
335
+ content: [
336
+ { type: "text", text: `Fact updated${linksUpdated ? `, ${linksUpdated} doc link(s) updated` : ""}` },
337
+ ],
338
+ };
339
+ }
340
+ );
341
+
342
+ server.registerTool(
343
+ "memory_info",
344
+ {
345
+ description:
346
+ "Show memory storage paths (store file locations, MEMORY_DIR, SQLite DB), fact counts, " +
347
+ "Knowledge Base stats, and the installed package version.",
348
+ inputSchema: z.object({}),
349
+ },
350
+ async () => {
351
+ const dbPath = join(MEMORY_DIR, "storage", "memory.sqlite");
352
+ const globalFile = storeFilePath(GLOBAL_KEY);
353
+ const projectFile = storeFilePath(projectKey(null, null));
354
+
355
+ let version = "unknown";
356
+ try {
357
+ version = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf-8")).version;
358
+ } catch (e) {}
359
+
360
+ let rag = {};
361
+ try {
362
+ const { getDatabase } = await import("./db/database.js");
363
+ const db = getDatabase();
364
+ rag.documents = db.prepare("SELECT COUNT(*) AS c FROM documents").get().c;
365
+ rag.sections = db.prepare("SELECT COUNT(*) AS c FROM sections").get().c;
366
+ rag.chunks = db.prepare("SELECT COUNT(*) AS c FROM micro_chunks").get().c;
367
+ rag.edges = db.prepare("SELECT COUNT(*) AS c FROM graph_edges").get().c;
368
+ rag.links = db.prepare("SELECT COUNT(*) AS c FROM knowledge_links").get().c;
369
+ } catch (e) {
370
+ rag.error = e.message;
371
+ }
372
+
373
+ const stores = await listProjectStores();
374
+ const lines = [
375
+ `Version: ${version}`,
376
+ `MEMORY_DIR: ${MEMORY_DIR}`,
377
+ `SQLite DB: ${dbPath}`,
378
+ `Global store: ${globalFile}`,
379
+ `Project store: ${projectFile}`,
380
+ `Project stores: ${stores.length}`,
381
+ `Facts (global): ${(await readMemoryRaw(GLOBAL_KEY)).length}`,
382
+ `Facts (project): ${(await readMemoryRaw(projectKey(null, null))).length}`,
383
+ ];
384
+ if (rag.error) lines.push(`RAG: unavailable (${rag.error})`);
385
+ else lines.push(
386
+ `RAG: ${rag.documents} doc(s), ${rag.sections} section(s), ${rag.chunks} chunk(s), ${rag.edges} edge(s), ${rag.links} memory link(s)`
387
+ );
388
+ return { content: [{ type: "text", text: lines.join("\n") }] };
389
+ }
390
+ );
391
+
203
392
  server.registerTool(
204
393
  "link_knowledge",
205
394
  {
@@ -207,13 +396,13 @@ server.registerTool(
207
396
  "Explicitly link a Notebook memory fact to a Knowledge Base document, section, or line range. " +
208
397
  "Creates Agent-driven Graph Edges connecting memory to RAG documents.",
209
398
  inputSchema: z.object({
210
- action: z.enum(["link", "list_links", "get_doc_links"]).default("link").describe("Action type"),
211
- factText: z.string().optional().describe("Memory fact text or keyword"),
212
- docId: z.string().optional().describe("Document ID, title, or file path"),
213
- scope: z.string().default("project").describe("'project' (default) or 'global'"),
214
- startLine: z.number().optional().describe("Starting line number in target document"),
215
- endLine: z.number().optional().describe("Ending line number in target document"),
216
- relationType: z.string().default("LINKS_TO").describe("Relation type (e.g. 'RULES_FOR', 'IMPLEMENTS', 'EXPLAINS')"),
399
+ action: z.enum(["link", "list_links", "get_doc_links"]).nullish().transform((v) => v || "link").describe("Action type"),
400
+ factText: optStr().describe("Memory fact text or keyword"),
401
+ docId: optStr().describe("Document ID, title, or file path"),
402
+ scope: defStr("project").describe("'project' (default) or 'global'"),
403
+ startLine: optNum().describe("Starting line number in target document"),
404
+ endLine: optNum().describe("Ending line number in target document"),
405
+ relationType: defStr("LINKS_TO").describe("Relation type (e.g. 'RULES_FOR', 'IMPLEMENTS', 'EXPLAINS')"),
217
406
  }),
218
407
  },
219
408
  async ({ action, factText, docId, scope, startLine, endLine, relationType }) => {
@@ -264,14 +453,15 @@ server.registerTool(
264
453
  description:
265
454
  "Ingest a document into the RAG knowledge base. " +
266
455
  "Accepts local file paths, web URLs, or raw Markdown/text content. " +
456
+ "For type='url' the page is fetched and its content is indexed (not just the URL). " +
267
457
  "Processes document through 3-tier hierarchy chunking (Big/Medium/Small), " +
268
458
  "computes dense vectors, and extracts GraphRAG code symbols.",
269
459
  inputSchema: z.object({
270
460
  content: z.string().describe("Raw text content, file path, or web URL"),
271
- type: z.enum(["text", "file", "url"]).default("text").describe("Input content type"),
272
- title: z.string().optional().describe("Document title"),
273
- path: z.string().optional().describe("Original document file path"),
274
- generateEmbeddings: z.boolean().default(true).describe("Compute dense vector embeddings"),
461
+ type: z.enum(["text", "file", "url"]).nullish().transform((v) => v || "text").describe("Input content type: 'text', 'file', or 'url' (url fetches the page content)"),
462
+ title: optStr().describe("Document title"),
463
+ path: optStr().describe("Original document file path"),
464
+ generateEmbeddings: defBool(true).describe("Compute dense vector embeddings"),
275
465
  }),
276
466
  },
277
467
  async ({ content, type, title, path, generateEmbeddings }) => {
@@ -313,15 +503,12 @@ server.registerTool(
313
503
  "Returns top-ranked candidate document sections with breadcrumbs, GraphRAG defined code symbols, and relevance scores.",
314
504
  inputSchema: z.object({
315
505
  query: z.string().describe("Search query in natural language or symbol name"),
316
- limit: z.number().default(5).describe("Maximum number of sections to return"),
317
- instruction: z
318
- .string()
319
- .optional()
320
- .describe(
321
- "Optional task-specific retrieval instruction shaping embedding focus (e.g. 'Retrieve code snippets', 'Find user preferences'). " +
322
- "Recommended when using E5/BGE models for domain-specific queries."
323
- ),
324
- generateEmbeddings: z.boolean().default(true).describe("Use vector search alongside BM25"),
506
+ limit: defNum(5).describe("Maximum number of sections to return"),
507
+ instruction: optStr().describe(
508
+ "Optional task-specific retrieval instruction shaping embedding focus (e.g. 'Retrieve code snippets', 'Find user preferences'). " +
509
+ "Recommended when using E5/BGE models for domain-specific queries."
510
+ ),
511
+ generateEmbeddings: defBool(true).describe("Use vector search alongside BM25"),
325
512
  }),
326
513
  },
327
514
  async ({ query, limit, instruction, generateEmbeddings }) => {
@@ -374,8 +561,8 @@ server.registerTool(
374
561
  "Manage the RAG knowledge base: inspect stats, list documents, read full raw document, delete documents, or export/import snapshots.",
375
562
  inputSchema: z.object({
376
563
  action: z.enum(["stats", "list", "read_document", "delete", "export_snapshot", "import_snapshot"]).describe("Management action"),
377
- docId: z.string().optional().describe("Document ID, title, or path (required for read_document and delete)"),
378
- snapshotPath: z.string().optional().describe("File path for snapshot export/import"),
564
+ docId: optStr().describe("Document ID, title, or path (required for read_document and delete)"),
565
+ snapshotPath: optStr().describe("File path for snapshot export/import"),
379
566
  }),
380
567
  },
381
568
  async ({ action, docId, snapshotPath }) => {
@@ -27,6 +27,48 @@ export function cleanHtml(html) {
27
27
  return cleaned;
28
28
  }
29
29
 
30
+ // Fetch a web page and convert it to Markdown/text. Used by the 'url' ingestion type
31
+ // so the RAG store gets the page CONTENT, not just the URL string.
32
+ export async function fetchUrlContent(url) {
33
+ if (typeof url !== "string" || !/^https?:\/\//i.test(url.trim())) {
34
+ throw new Error(`Unsupported URL for ingestion: '${url}'. Only http/https URLs are supported.`);
35
+ }
36
+ let res;
37
+ try {
38
+ res = await fetch(url.trim(), {
39
+ headers: {
40
+ "User-Agent": "memory-agent-rag/1.0",
41
+ Accept: "text/html,application/xhtml+xml,application/json,text/plain,*/*",
42
+ },
43
+ redirect: "follow",
44
+ signal: AbortSignal.timeout(15000),
45
+ });
46
+ } catch (err) {
47
+ throw new Error(`Failed to fetch URL '${url}': ${err.message}`);
48
+ }
49
+ if (!res.ok) {
50
+ throw new Error(`Failed to fetch URL '${url}': HTTP ${res.status} ${res.statusText}`);
51
+ }
52
+ const raw = await res.text();
53
+ const contentType = (res.headers.get("content-type") || "").toLowerCase();
54
+ const looksLikeHtml = /<html|<body|<div|<article|<main|<!doctype/i.test(raw.slice(0, 4096));
55
+ let markdown;
56
+ if (contentType.includes("html") || looksLikeHtml) {
57
+ markdown = cleanHtml(raw);
58
+ } else if (contentType.includes("json") || /^[\[{]/.test(raw.trim())) {
59
+ try {
60
+ markdown = JSON.stringify(JSON.parse(raw), null, 2);
61
+ } catch {
62
+ markdown = raw.trim();
63
+ }
64
+ } else {
65
+ markdown = raw.trim();
66
+ }
67
+ const titleMatch = raw.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
68
+ const title = titleMatch ? titleMatch[1].replace(/\s+/g, " ").trim() : null;
69
+ return { markdown, title: title || null, finalUrl: res.url || url.trim() };
70
+ }
71
+
30
72
  export function extractTitle(markdown, fallbackName = "Untitled Document") {
31
73
  const h1Match = markdown.match(/^#\s+(.+)$/m);
32
74
  if (h1Match && h1Match[1].trim()) {
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { getDatabase, BLOBS_DIR } from "../db/database.js";
3
3
  import { saveBlob, deleteBlob } from "../storage/blob_store.js";
4
- import { normalizeContent } from "./normalizer.js";
4
+ import { normalizeContent, fetchUrlContent } from "./normalizer.js";
5
5
  import { buildTripleHierarchy } from "./chunker.js";
6
6
  import { embedText, embedBatch, vectorToBuffer } from "../ml/model_manager.js";
7
7
  import { buildGraphEdges, saveGraphEdges } from "../graph/graph_extractor.js";
@@ -18,13 +18,26 @@ export async function ingestDocument({
18
18
  }) {
19
19
  const db = customDb || getDatabase();
20
20
 
21
- const { markdown, title: docTitle, metadata } = normalizeContent({ content, type, path, title });
21
+ let effectiveType = type;
22
+ let effectivePath = path;
23
+ let effectiveTitle = title;
24
+
25
+ if (type === "url") {
26
+ const fetched = await fetchUrlContent(String(content));
27
+ content = fetched.markdown;
28
+ effectiveType = "text";
29
+ effectiveTitle = title || fetched.title;
30
+ effectivePath = path || fetched.finalUrl || content;
31
+ }
32
+
33
+ const { markdown, title: docTitle, metadata } = normalizeContent({ content, type: effectiveType, path: effectivePath, title: effectiveTitle });
34
+ if (type === "url") metadata.source_type = "url";
22
35
 
23
36
  const blobRes = await saveBlob(markdown, customBlobDir);
24
37
  const blobHash = blobRes.hash;
25
38
 
26
39
  const docId = `doc_${randomUUID().replace(/-/g, "").substring(0, 12)}`;
27
- const docPath = path || `virtual://${type}/${docId}`;
40
+ const docPath = effectivePath || `virtual://${type}/${docId}`;
28
41
  const now = Date.now();
29
42
 
30
43
  const hierarchy = buildTripleHierarchy(markdown, docId, docTitle);
@@ -151,6 +164,14 @@ export async function deleteDocument(docIdOrPath, customDb = null, customBlobDir
151
164
 
152
165
  const microChunks = db.prepare("SELECT id FROM micro_chunks WHERE doc_id = ?").all(doc.id);
153
166
 
167
+ // Collect every id owned by this document so we can purge dangling graph edges
168
+ // (graph_edges has no FK constraints, so section/chunk/doc references would otherwise leak).
169
+ const ownedIds = [doc.id];
170
+ for (const table of ["sections", "medium_chunks", "micro_chunks"]) {
171
+ const rows = db.prepare(`SELECT id FROM ${table} WHERE doc_id = ?`).all(doc.id);
172
+ for (const r of rows) ownedIds.push(r.id);
173
+ }
174
+
154
175
  db.exec("BEGIN IMMEDIATE;");
155
176
  try {
156
177
  for (const mc of microChunks) {
@@ -159,7 +180,16 @@ export async function deleteDocument(docIdOrPath, customDb = null, customBlobDir
159
180
  } catch {}
160
181
  }
161
182
 
162
- db.prepare("DELETE FROM graph_edges WHERE source_id = ? OR target_id = ?").run(doc.id, doc.id);
183
+ // Auto-clean Agent knowledge graph links pointing at this document.
184
+ db.prepare("DELETE FROM knowledge_links WHERE doc_id = ?").run(doc.id);
185
+
186
+ for (const id of ownedIds) {
187
+ // GLOB: '*' suffix is exact (unlike LIKE, '_' stays literal in ids like doc_xxx).
188
+ db.prepare(
189
+ "DELETE FROM graph_edges WHERE source_id = ? OR target_id = ? OR source_id GLOB ? OR target_id GLOB ?"
190
+ ).run(id, id, `${id}*`, `${id}*`);
191
+ }
192
+
163
193
  db.prepare("DELETE FROM documents WHERE id = ?").run(doc.id);
164
194
 
165
195
  db.exec("COMMIT;");
@@ -175,5 +205,5 @@ export async function deleteDocument(docIdOrPath, customDb = null, customBlobDir
175
205
  }
176
206
  }
177
207
 
178
- return { deleted: true, docId: doc.id, title: doc.title };
208
+ return { deleted: true, docId: doc.id, title: doc.title, linksCleaned: true };
179
209
  }
@@ -72,6 +72,10 @@ export function memoryFileName(key) {
72
72
  return basename(memoryPath(key));
73
73
  }
74
74
 
75
+ export function storeFilePath(key) {
76
+ return memoryPath(key);
77
+ }
78
+
75
79
  function parseMeta(content) {
76
80
  const m = content.match(/<!-- path: (.+?) -->/);
77
81
  return { path: m ? m[1].trim() : null };