@lotargo/memory_plugin 1.2.901 → 1.2.902

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.
@@ -32,6 +32,18 @@ const server = new McpServer({
32
32
  version: "1.0.0",
33
33
  });
34
34
 
35
+ // Optional string/number that tolerates null (some tool-call layers fill omitted
36
+ // optional args with null). Linking fields must NEVER be mandatory.
37
+ const optStr = () => z.string().optional().nullable();
38
+ const optNum = () => z.number().optional().nullable();
39
+ const defStr = (fallback) =>
40
+ z
41
+ .string()
42
+ .nullish()
43
+ .transform((v) => (v === null || v === undefined || v === "" ? fallback : v));
44
+ const defBool = (fallback) => z.boolean().nullish().transform((v) => (v === null || v === undefined ? fallback : v));
45
+ const defNum = (fallback) => z.number().nullish().transform((v) => (v === null || v === undefined ? fallback : v));
46
+
35
47
  // --- Legacy Key-Value Memory Tools ---
36
48
 
37
49
  // --- Legacy Key-Value Memory Tools & Agent Graph Linking ---
@@ -42,16 +54,17 @@ server.registerTool(
42
54
  description:
43
55
  "Save an important, durable fact to memory. Only use for high-signal information " +
44
56
  "(name, goals, constraints, tech preferences, project conventions). " +
45
- "Optionally link the fact to a Knowledge Base document or exact line range (docId, startLine, endLine). " +
57
+ "docId/startLine/endLine/relationType are OPTIONAL and only used to link the fact to a " +
58
+ "Knowledge Base document or line range; omit them when no linking is needed. " +
46
59
  "Translate the fact into English and keep it concise. " +
47
60
  "scope: 'project' (default) or 'global'",
48
61
  inputSchema: z.object({
49
62
  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')"),
63
+ scope: defStr("project").describe("'project' (default) or 'global'"),
64
+ docId: optStr().describe("Optional document ID, title, or path to link this fact to"),
65
+ startLine: optNum().describe("Optional starting line number in target document"),
66
+ endLine: optNum().describe("Optional ending line number in target document"),
67
+ relationType: defStr("LINKS_TO").describe("Relation type (e.g. 'RULES_FOR', 'IMPLEMENTS', 'REFERENCES')"),
55
68
  }),
56
69
  },
57
70
  async ({ fact, scope, docId, startLine, endLine, relationType }) => {
@@ -97,8 +110,8 @@ server.registerTool(
97
110
  "scope: 'project', 'global', 'all' (default), or 'list_projects'. " +
98
111
  "Use project: '<directory path>' with scope 'project'/'all' to read facts of a specific project from any working directory.",
99
112
  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')"),
113
+ scope: defStr("all").describe("'project', 'global', 'all', or 'list_projects'"),
114
+ project: optStr().describe("Directory path of the project to read facts from (e.g. 'F:/projects/plugins/memory')"),
102
115
  }),
103
116
  },
104
117
  async ({ scope, project }) => {
@@ -169,7 +182,7 @@ server.registerTool(
169
182
  "Delete a fact by number (from recall), by range (e.g. '3-30', inclusive), or by text search",
170
183
  inputSchema: z.object({
171
184
  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'"),
185
+ scope: defStr("project").describe("'project' (default) or 'global'"),
173
186
  }),
174
187
  },
175
188
  async ({ query, scope }) => {
@@ -207,13 +220,13 @@ server.registerTool(
207
220
  "Explicitly link a Notebook memory fact to a Knowledge Base document, section, or line range. " +
208
221
  "Creates Agent-driven Graph Edges connecting memory to RAG documents.",
209
222
  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')"),
223
+ action: z.enum(["link", "list_links", "get_doc_links"]).nullish().transform((v) => v || "link").describe("Action type"),
224
+ factText: optStr().describe("Memory fact text or keyword"),
225
+ docId: optStr().describe("Document ID, title, or file path"),
226
+ scope: defStr("project").describe("'project' (default) or 'global'"),
227
+ startLine: optNum().describe("Starting line number in target document"),
228
+ endLine: optNum().describe("Ending line number in target document"),
229
+ relationType: defStr("LINKS_TO").describe("Relation type (e.g. 'RULES_FOR', 'IMPLEMENTS', 'EXPLAINS')"),
217
230
  }),
218
231
  },
219
232
  async ({ action, factText, docId, scope, startLine, endLine, relationType }) => {
@@ -264,14 +277,15 @@ server.registerTool(
264
277
  description:
265
278
  "Ingest a document into the RAG knowledge base. " +
266
279
  "Accepts local file paths, web URLs, or raw Markdown/text content. " +
280
+ "For type='url' the page is fetched and its content is indexed (not just the URL). " +
267
281
  "Processes document through 3-tier hierarchy chunking (Big/Medium/Small), " +
268
282
  "computes dense vectors, and extracts GraphRAG code symbols.",
269
283
  inputSchema: z.object({
270
284
  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"),
285
+ type: z.enum(["text", "file", "url"]).nullish().transform((v) => v || "text").describe("Input content type: 'text', 'file', or 'url' (url fetches the page content)"),
286
+ title: optStr().describe("Document title"),
287
+ path: optStr().describe("Original document file path"),
288
+ generateEmbeddings: defBool(true).describe("Compute dense vector embeddings"),
275
289
  }),
276
290
  },
277
291
  async ({ content, type, title, path, generateEmbeddings }) => {
@@ -313,15 +327,12 @@ server.registerTool(
313
327
  "Returns top-ranked candidate document sections with breadcrumbs, GraphRAG defined code symbols, and relevance scores.",
314
328
  inputSchema: z.object({
315
329
  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"),
330
+ limit: defNum(5).describe("Maximum number of sections to return"),
331
+ instruction: optStr().describe(
332
+ "Optional task-specific retrieval instruction shaping embedding focus (e.g. 'Retrieve code snippets', 'Find user preferences'). " +
333
+ "Recommended when using E5/BGE models for domain-specific queries."
334
+ ),
335
+ generateEmbeddings: defBool(true).describe("Use vector search alongside BM25"),
325
336
  }),
326
337
  },
327
338
  async ({ query, limit, instruction, generateEmbeddings }) => {
@@ -374,8 +385,8 @@ server.registerTool(
374
385
  "Manage the RAG knowledge base: inspect stats, list documents, read full raw document, delete documents, or export/import snapshots.",
375
386
  inputSchema: z.object({
376
387
  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"),
388
+ docId: optStr().describe("Document ID, title, or path (required for read_document and delete)"),
389
+ snapshotPath: optStr().describe("File path for snapshot export/import"),
379
390
  }),
380
391
  },
381
392
  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
  }
@@ -240,7 +240,7 @@ const MCP_SERVERS = [
240
240
 
241
241
  export const MemoryPlugin = async ({ directory, worktree, client }) => {
242
242
  await ensureDir();
243
- const projectKey = scopeKey("project", worktree, directory);
243
+ const activeProjectKey = scopeKey("project", worktree, directory);
244
244
 
245
245
  return {
246
246
  "experimental.chat.messages.transform": async (_input, output) => {
@@ -252,10 +252,10 @@ export const MemoryPlugin = async ({ directory, worktree, client }) => {
252
252
 
253
253
  const [globalFacts, projectFacts] = await Promise.all([
254
254
  readMemoryRaw(GLOBAL_KEY),
255
- readMemoryRaw(projectKey),
255
+ readMemoryRaw(activeProjectKey),
256
256
  ]);
257
257
 
258
- const context = buildMemoryContext(globalFacts, projectFacts, projectKey);
258
+ const context = buildMemoryContext(globalFacts, projectFacts, activeProjectKey);
259
259
  const ref = firstUser.parts[0];
260
260
  firstUser.parts.unshift({ ...ref, type: "text", text: context });
261
261
  },
@@ -288,7 +288,8 @@ export const MemoryPlugin = async ({ directory, worktree, client }) => {
288
288
  description:
289
289
  "Save an important, durable fact to memory. Only use for high-signal information " +
290
290
  "(name, goals, constraints, tech preferences, project conventions). " +
291
- "Optionally link the fact to a Knowledge Base document or exact line range (docId, startLine, endLine). " +
291
+ "docId/startLine/endLine/relationType are OPTIONAL and only used to link the fact to a " +
292
+ "Knowledge Base document or line range; omit them when no linking is needed. " +
292
293
  "Translate the fact into English and keep it concise. " +
293
294
  "scope: 'project' (default) or 'global'",
294
295
  args: {
@@ -511,11 +512,12 @@ export const MemoryPlugin = async ({ directory, worktree, client }) => {
511
512
  description:
512
513
  "Ingest a document into the RAG knowledge base. " +
513
514
  "Accepts local file paths, web URLs, or raw Markdown/text content. " +
515
+ "For type='url' the page is fetched and its content is indexed (not just the URL). " +
514
516
  "Processes document through 3-tier hierarchy chunking (Big/Medium/Small), " +
515
517
  "computes dense vectors, and extracts GraphRAG code symbols.",
516
518
  args: {
517
519
  content: { type: "string", description: "Raw text content, file path, or web URL" },
518
- type: { type: "string", description: "Input content type: 'text', 'file', 'url'", default: "text" },
520
+ type: { type: "string", description: "Input content type: 'text', 'file', 'url' (url fetches the page content)", default: "text" },
519
521
  title: { type: "string", description: "Document title" },
520
522
  path: { type: "string", description: "Original document file path" },
521
523
  generateEmbeddings: { type: "boolean", description: "Compute dense vector embeddings", default: true },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotargo/memory_plugin",
3
- "version": "1.2.901",
3
+ "version": "1.2.902",
4
4
  "description": "Persistent memory agent for coding AI tools — remembers user preferences and project context across sessions. Works with Antigravity, OpenCode, Claude Code, and Codex.",
5
5
  "type": "module",
6
6
  "main": "opencode-plugin/index.js",
@@ -59,11 +59,12 @@ Use this tool when adding technical documentation, API specs, architectural docu
59
59
  - **Hierarchy Chunking**: The engine automatically creates 3-tier chunks (Big Document -> Medium Section -> Small Micro-Chunk) and extracts GraphRAG code symbols.
60
60
  - **Auto Vector Embeddings**: Dense ONNX vectors (`multilingual-e5-small`) are automatically computed and indexed in SQLite.
61
61
  - **CRITICAL Schema Usage & Parameters**:
62
- - `content` (required, string): Must be the **actual raw text or markdown content** of the document, NOT just a file path!
63
- - `type` (optional, enum: `"text"`, `"file"`, `"url"`): Set to `"text"` (default) or `"file"`.
64
- - `path` (optional, string): Provide the absolute file path (e.g. `f:\projects\plugins\memory\README.md`).
65
- - `title` (optional, string): Provide document title (e.g. `README.md`).
66
- - **Correct Example**: `ingest_document(content: "<full text content>", path: "f:/path/to/file.md", title: "file.md", type: "file")`
62
+ - `content` (required, string): For `type: "text"`/`"file"` it must be the **actual raw text or markdown content** of the document, NOT just a file path! For `type: "url"` it must be the **page URL** — the page is fetched automatically and its content is indexed (not just the URL).
63
+ - `type` (optional, enum: `"text"`, `"file"`, `"url"`): `"text"` (default), `"file"`, or `"url"` (fetches the web page and indexes its content).
64
+ - `path` (optional, string): Provide the absolute file path (e.g. `f:\projects\plugins\memory\README.md`). For URLs the final URL is used for deduplication.
65
+ - `title` (optional, string): Provide document title (e.g. `README.md`). If omitted for a URL, the page `<title>` is used.
66
+ - **Correct Example (URL)**: `ingest_document(content: "https://docs.example.com/guide", type: "url", title: "Example Guide")`
67
+ - **Correct Example (text)**: `ingest_document(content: "<full text content>", path: "f:/path/to/file.md", title: "file.md", type: "file")`
67
68
  - ❌ **Common Error**: `ingest_document(content: "f:/path/to/file.md")` — this causes validation failures because `content` is missing the text content.
68
69
 
69
70
  - **CLI/Script Execution Note**: When writing batch node scripts to call `ingestDocument`, remember that `@lotargo/memory_plugin` uses ES Modules (`"type": "module"`). Use `import` syntax instead of `require()`.