@lotargo/memory_plugin 1.2.9 → 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 }) => {
@@ -165,20 +178,30 @@ server.registerTool(
165
178
  server.registerTool(
166
179
  "forget",
167
180
  {
168
- description: "Delete a fact by number (from recall) or text search",
181
+ description:
182
+ "Delete a fact by number (from recall), by range (e.g. '3-30', inclusive), or by text search",
169
183
  inputSchema: z.object({
170
- query: z.string().describe("Number or text to search for"),
171
- scope: z.string().default("project").describe("'project' (default) or 'global'"),
184
+ query: z.string().describe("Number, range like '3-30', or text to search for"),
185
+ scope: defStr("project").describe("'project' (default) or 'global'"),
172
186
  }),
173
187
  },
174
188
  async ({ query, scope }) => {
175
189
  const key = scopeKey(scope, null, null);
176
190
  const entries = await readMemory(key);
191
+ const rangeMatch = /^\s*(\d+)\s*-\s*(\d+)\s*$/.exec(query);
177
192
  const num = parseInt(query, 10);
178
193
  let removed;
179
- if (!isNaN(num) && num > 0 && num <= entries.length) {
194
+ if (rangeMatch) {
195
+ const from = parseInt(rangeMatch[1], 10);
196
+ const to = parseInt(rangeMatch[2], 10);
197
+ if (from > 0 && to >= from && to <= entries.length) {
198
+ removed = entries.splice(from - 1, to - from + 1);
199
+ }
200
+ }
201
+ if (!removed && !isNaN(num) && num > 0 && num <= entries.length) {
180
202
  removed = entries.splice(num - 1, 1);
181
- } else {
203
+ }
204
+ if (!removed) {
182
205
  const filtered = entries.filter((e) => !e.toLowerCase().includes(query.toLowerCase()));
183
206
  removed = entries.filter((e) => e.toLowerCase().includes(query.toLowerCase()));
184
207
  entries.length = 0;
@@ -197,13 +220,13 @@ server.registerTool(
197
220
  "Explicitly link a Notebook memory fact to a Knowledge Base document, section, or line range. " +
198
221
  "Creates Agent-driven Graph Edges connecting memory to RAG documents.",
199
222
  inputSchema: z.object({
200
- action: z.enum(["link", "list_links", "get_doc_links"]).default("link").describe("Action type"),
201
- factText: z.string().optional().describe("Memory fact text or keyword"),
202
- docId: z.string().optional().describe("Document ID, title, or file path"),
203
- scope: z.string().default("project").describe("'project' (default) or 'global'"),
204
- startLine: z.number().optional().describe("Starting line number in target document"),
205
- endLine: z.number().optional().describe("Ending line number in target document"),
206
- 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')"),
207
230
  }),
208
231
  },
209
232
  async ({ action, factText, docId, scope, startLine, endLine, relationType }) => {
@@ -254,14 +277,15 @@ server.registerTool(
254
277
  description:
255
278
  "Ingest a document into the RAG knowledge base. " +
256
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). " +
257
281
  "Processes document through 3-tier hierarchy chunking (Big/Medium/Small), " +
258
282
  "computes dense vectors, and extracts GraphRAG code symbols.",
259
283
  inputSchema: z.object({
260
284
  content: z.string().describe("Raw text content, file path, or web URL"),
261
- type: z.enum(["text", "file", "url"]).default("text").describe("Input content type"),
262
- title: z.string().optional().describe("Document title"),
263
- path: z.string().optional().describe("Original document file path"),
264
- 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"),
265
289
  }),
266
290
  },
267
291
  async ({ content, type, title, path, generateEmbeddings }) => {
@@ -303,15 +327,12 @@ server.registerTool(
303
327
  "Returns top-ranked candidate document sections with breadcrumbs, GraphRAG defined code symbols, and relevance scores.",
304
328
  inputSchema: z.object({
305
329
  query: z.string().describe("Search query in natural language or symbol name"),
306
- limit: z.number().default(5).describe("Maximum number of sections to return"),
307
- instruction: z
308
- .string()
309
- .optional()
310
- .describe(
311
- "Optional task-specific retrieval instruction shaping embedding focus (e.g. 'Retrieve code snippets', 'Find user preferences'). " +
312
- "Recommended when using E5/BGE models for domain-specific queries."
313
- ),
314
- 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"),
315
336
  }),
316
337
  },
317
338
  async ({ query, limit, instruction, generateEmbeddings }) => {
@@ -364,8 +385,8 @@ server.registerTool(
364
385
  "Manage the RAG knowledge base: inspect stats, list documents, read full raw document, delete documents, or export/import snapshots.",
365
386
  inputSchema: z.object({
366
387
  action: z.enum(["stats", "list", "read_document", "delete", "export_snapshot", "import_snapshot"]).describe("Management action"),
367
- docId: z.string().optional().describe("Document ID, title, or path (required for read_document and delete)"),
368
- 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"),
369
390
  }),
370
391
  },
371
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: {
@@ -415,9 +416,9 @@ export const MemoryPlugin = async ({ directory, worktree, client }) => {
415
416
  },
416
417
  },
417
418
  "forget": {
418
- description: "Удалить факт по номеру (см. recall) или тексту",
419
+ description: "Удалить факт по номеру (см. recall), по диапазону (например '3-30', включительно) или тексту",
419
420
  args: {
420
- query: { type: "string", description: "Номер факта или текст для поиска" },
421
+ query: { type: "string", description: "Номер факта, диапазон вида '3-30' или текст для поиска" },
421
422
  scope: {
422
423
  type: "string",
423
424
  description: "project (по умолчанию) или global",
@@ -427,11 +428,20 @@ export const MemoryPlugin = async ({ directory, worktree, client }) => {
427
428
  async execute({ query, scope }, { worktree, directory }) {
428
429
  const key = scopeKey(scope || "project", worktree, directory);
429
430
  const entries = await readMemory(key);
431
+ const rangeMatch = /^\s*(\d+)\s*-\s*(\d+)\s*$/.exec(query);
430
432
  const num = parseInt(query, 10);
431
433
  let removed;
432
- if (!isNaN(num) && num > 0 && num <= entries.length) {
434
+ if (rangeMatch) {
435
+ const from = parseInt(rangeMatch[1], 10);
436
+ const to = parseInt(rangeMatch[2], 10);
437
+ if (from > 0 && to >= from && to <= entries.length) {
438
+ removed = entries.splice(from - 1, to - from + 1);
439
+ }
440
+ }
441
+ if (!removed && !isNaN(num) && num > 0 && num <= entries.length) {
433
442
  removed = entries.splice(num - 1, 1);
434
- } else {
443
+ }
444
+ if (!removed) {
435
445
  const filtered = entries.filter((e) => !e.toLowerCase().includes(query.toLowerCase()));
436
446
  removed = entries.filter((e) => e.toLowerCase().includes(query.toLowerCase()));
437
447
  entries.length = 0;
@@ -502,11 +512,12 @@ export const MemoryPlugin = async ({ directory, worktree, client }) => {
502
512
  description:
503
513
  "Ingest a document into the RAG knowledge base. " +
504
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). " +
505
516
  "Processes document through 3-tier hierarchy chunking (Big/Medium/Small), " +
506
517
  "computes dense vectors, and extracts GraphRAG code symbols.",
507
518
  args: {
508
519
  content: { type: "string", description: "Raw text content, file path, or web URL" },
509
- 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" },
510
521
  title: { type: "string", description: "Document title" },
511
522
  path: { type: "string", description: "Original document file path" },
512
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.9",
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()`.