@lotargo/memory_plugin 1.2.902 → 1.3.1

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
 
@@ -56,6 +83,11 @@ server.registerTool(
56
83
  "(name, goals, constraints, tech preferences, project conventions). " +
57
84
  "docId/startLine/endLine/relationType are OPTIONAL and only used to link the fact to a " +
58
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]. " +
59
91
  "Translate the fact into English and keep it concise. " +
60
92
  "scope: 'project' (default) or 'global'",
61
93
  inputSchema: z.object({
@@ -65,17 +97,42 @@ server.registerTool(
65
97
  startLine: optNum().describe("Optional starting line number in target document"),
66
98
  endLine: optNum().describe("Optional ending line number in target document"),
67
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"),
68
104
  }),
69
105
  },
70
- async ({ fact, scope, docId, startLine, endLine, relationType }) => {
106
+ async ({ fact, scope, docId, startLine, endLine, relationType, ttl, keep, tags, supersedes }) => {
71
107
  const key = scopeKey(scope, null, null);
72
108
  const entries = await readMemory(key);
73
109
  const factNormalized = fact.toLowerCase().trim();
74
- if (!entries.some((e) => {
75
- const idx = e.indexOf("] ");
76
- return idx !== -1 && e.slice(idx + 2).toLowerCase().trim() === factNormalized;
77
- })) {
78
- 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 }));
79
136
  await writeMemory(key, entries);
80
137
  }
81
138
 
@@ -98,7 +155,7 @@ server.registerTool(
98
155
  }
99
156
  }
100
157
 
101
- return { content: [{ type: "text", text: `Memory updated${linkInfo}` }] };
158
+ return { content: [{ type: "text", text: `Memory updated${supersededInfo}${linkInfo}` }] };
102
159
  }
103
160
  );
104
161
 
@@ -108,20 +165,27 @@ server.registerTool(
108
165
  description:
109
166
  "Show saved facts with any Agent-linked Knowledge Base documents/lines. " +
110
167
  "scope: 'project', 'global', 'all' (default), or 'list_projects'. " +
111
- "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.",
112
172
  inputSchema: z.object({
113
173
  scope: defStr("all").describe("'project', 'global', 'all', or 'list_projects'"),
114
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)"),
115
179
  }),
116
180
  },
117
- async ({ scope, project }) => {
181
+ async ({ scope, project, query, tags, since, until }) => {
118
182
  const { getLinksForFact } = await import("./graph/knowledge_linker.js");
119
183
  const results = [];
120
184
 
121
- const formatFactWithLinks = (factText, key) => {
122
- let line = factText;
185
+ const formatFactWithLinks = (factLine, key) => {
186
+ let line = displayFact(factLine);
123
187
  try {
124
- const links = getLinksForFact(key, factText);
188
+ const links = getLinksForFact(key, factText(factLine));
125
189
  if (links && links.length > 0) {
126
190
  const docStr = links
127
191
  .map((l) => {
@@ -135,6 +199,17 @@ server.registerTool(
135
199
  return line;
136
200
  };
137
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
+
138
213
  if (scope === "list_projects") {
139
214
  const stores = await listProjectStores();
140
215
  if (!stores.length) {
@@ -147,7 +222,7 @@ server.registerTool(
147
222
  content: [
148
223
  {
149
224
  type: "text",
150
- 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}`,
151
226
  },
152
227
  ],
153
228
  };
@@ -156,21 +231,19 @@ server.registerTool(
156
231
  const target = project ? canonicalPath(project) : projectKey(null, null);
157
232
  const label = project ? target : projectName();
158
233
  if (scope !== "project") {
159
- const global = await readMemoryRaw(GLOBAL_KEY);
160
- if (global.length) {
161
- results.push("--- Global ---");
162
- global.forEach((e, i) => results.push(`${i + 1}. ${formatFactWithLinks(e, GLOBAL_KEY)}`));
163
- }
234
+ const global = await readMemory(GLOBAL_KEY);
235
+ collect(global, GLOBAL_KEY);
164
236
  }
165
237
  if (scope !== "global") {
166
- const local = await readMemoryRaw(target);
167
- if (local.length) {
168
- if (results.length) results.push("");
169
- results.push(`--- Project: ${label} ---`);
170
- local.forEach((e, i) => results.push(`${i + 1}. ${formatFactWithLinks(e, target)}`));
171
- }
238
+ const local = await readMemory(target);
239
+ collect(local, target);
172
240
  }
173
- 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.";
174
247
  return { content: [{ type: "text", text }] };
175
248
  }
176
249
  );
@@ -179,40 +252,143 @@ server.registerTool(
179
252
  "forget",
180
253
  {
181
254
  description:
182
- "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.",
183
257
  inputSchema: z.object({
184
258
  query: z.string().describe("Number, range like '3-30', or text to search for"),
185
259
  scope: defStr("project").describe("'project' (default) or 'global'"),
260
+ force: defBool(false).describe("Also delete protected (keep) facts"),
186
261
  }),
187
262
  },
188
- async ({ query, scope }) => {
263
+ async ({ query, scope, force }) => {
189
264
  const key = scopeKey(scope, null, null);
190
265
  const entries = await readMemory(key);
191
266
  const rangeMatch = /^\s*(\d+)\s*-\s*(\d+)\s*$/.exec(query);
192
267
  const num = parseInt(query, 10);
193
- let removed;
268
+ let indices = [];
194
269
  if (rangeMatch) {
195
270
  const from = parseInt(rangeMatch[1], 10);
196
271
  const to = parseInt(rangeMatch[2], 10);
197
272
  if (from > 0 && to >= from && to <= entries.length) {
198
- removed = entries.splice(from - 1, to - from + 1);
273
+ for (let i = from - 1; i < to; i++) indices.push(i);
199
274
  }
200
275
  }
201
- if (!removed && !isNaN(num) && num > 0 && num <= entries.length) {
202
- removed = entries.splice(num - 1, 1);
276
+ if (!indices.length && !isNaN(num) && num > 0 && num <= entries.length) {
277
+ indices.push(num - 1);
203
278
  }
204
- if (!removed) {
205
- const filtered = entries.filter((e) => !e.toLowerCase().includes(query.toLowerCase()));
206
- removed = entries.filter((e) => e.toLowerCase().includes(query.toLowerCase()));
207
- entries.length = 0;
208
- 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), []);
209
282
  }
210
- await writeMemory(key, entries);
211
- 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)`;
212
295
  return { content: [{ type: "text", text }] };
213
296
  }
214
297
  );
215
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
+
216
392
  server.registerTool(
217
393
  "link_knowledge",
218
394
  {
@@ -277,12 +453,13 @@ server.registerTool(
277
453
  description:
278
454
  "Ingest a document into the RAG knowledge base. " +
279
455
  "Accepts local file paths, web URLs, or raw Markdown/text content. " +
456
+ "For type='file' the file is read from disk and indexed with a code-block wrapper. " +
280
457
  "For type='url' the page is fetched and its content is indexed (not just the URL). " +
281
458
  "Processes document through 3-tier hierarchy chunking (Big/Medium/Small), " +
282
459
  "computes dense vectors, and extracts GraphRAG code symbols.",
283
460
  inputSchema: z.object({
284
- content: z.string().describe("Raw text content, file path, or web URL"),
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)"),
461
+ content: z.string().describe("Raw text content, file path, or web URL. For type='file' this can be the file path (reads from disk) or the file content directly"),
462
+ type: z.enum(["text", "file", "url"]).nullish().transform((v) => v || "text").describe("Input content type: 'text' (raw content), 'file' (reads from disk, wraps in code block), or 'url' (fetches page content)"),
286
463
  title: optStr().describe("Document title"),
287
464
  path: optStr().describe("Original document file path"),
288
465
  generateEmbeddings: defBool(true).describe("Compute dense vector embeddings"),
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { readFile } from "node:fs/promises";
2
3
  import { getDatabase, BLOBS_DIR } from "../db/database.js";
3
4
  import { saveBlob, deleteBlob } from "../storage/blob_store.js";
4
5
  import { normalizeContent, fetchUrlContent } from "./normalizer.js";
@@ -28,6 +29,13 @@ export async function ingestDocument({
28
29
  effectiveType = "text";
29
30
  effectiveTitle = title || fetched.title;
30
31
  effectivePath = path || fetched.finalUrl || content;
32
+ } else if (type === "file") {
33
+ const filePath = effectivePath || content;
34
+ const needsRead = !content || content === filePath;
35
+ if (needsRead && filePath) {
36
+ content = await readFile(filePath, "utf-8");
37
+ effectivePath = filePath;
38
+ }
31
39
  }
32
40
 
33
41
  const { markdown, title: docTitle, metadata } = normalizeContent({ content, type: effectiveType, path: effectivePath, title: effectiveTitle });