@lotargo/memory_plugin 1.5.3 → 1.6.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.
@@ -1,516 +1,123 @@
1
- import * as z from "zod/v4";
2
- import { readFile } from "node:fs/promises";
3
- import { join } from "node:path";
4
- import {
5
- readMemory,
6
- readMemoryRaw,
7
- writeMemory,
8
- today,
9
- MEMORY_DIR,
10
- GLOBAL_KEY,
11
- scopeKey,
12
- projectKey,
13
- projectName,
14
- canonicalPath,
15
- listProjectStores,
16
- storeFilePath,
17
- } from "../memory.js";
18
- import {
19
- parseFactEntry,
20
- factText,
21
- factMeta,
22
- withMeta,
23
- nextFactId,
24
- isKeepFact,
25
- isExpiredLine,
26
- isSuperseded,
27
- formatFactEntry,
28
- matchesQuery,
29
- matchesTags,
30
- inDateRange,
31
- factTitle,
32
- factBody,
33
- autoGenerateTitle,
34
- } from "../fact_format.js";
35
- import { optStr, optNum, defStr, defBool, requireProjectKey, resolveFactIndex } from "./helpers.js";
36
-
37
- export function registerMemoryTools(server) {
38
- server.registerTool(
39
- "remember",
40
- {
41
- description:
42
- "Save an important, durable fact to memory. Only use for high-signal information " +
43
- "(name, goals, constraints, tech preferences, project conventions). " +
44
- "docId/startLine/endLine/relationType are OPTIONAL and only used to link the fact to a " +
45
- "Knowledge Base document or line range; omit them when no linking is needed. " +
46
- "ttl is OPTIONAL (e.g. '90d', '2w', '24h') — expired facts are shown with [EXPIRED] but not auto-deleted. " +
47
- "keep=true protects the fact from forget deletion unless force=true. " +
48
- "tags is OPTIONAL comma-separated text for filtering. " +
49
- "supersedes is OPTIONAL: a number (from recall), id, or text of a fact this one replaces; " +
50
- "the target is then marked [SUPERSEDED]. " +
51
- "Translate the fact into English and keep it concise. " +
52
- "scope: 'project' (default) or 'global'",
53
- inputSchema: z.object({
54
- fact: z.string().describe("The fact to remember, written in English"),
55
- title: optStr().describe("Optional title for the fact. If not specified, one is auto-generated."),
56
- scope: defStr("project").describe("'project' (default) or 'global'"),
57
- docId: optStr().describe("Optional document ID, title, or path to link this fact to"),
58
- startLine: optNum().describe("Optional starting line number in target document"),
59
- endLine: optNum().describe("Optional ending line number in target document"),
60
- relationType: defStr("LINKS_TO").describe("Relation type (e.g. 'RULES_FOR', 'IMPLEMENTS', 'REFERENCES')"),
61
- ttl: optStr().describe("Optional time-to-live, e.g. '90d', '2w', '24h', '12m'"),
62
- keep: defBool(false).describe("Protect the fact from forget deletion unless force=true"),
63
- tags: optStr().describe("Optional comma-separated tags, e.g. 'pref,arch'"),
64
- supersedes: optStr().describe("Optional number, id, or text of the fact this one replaces"),
65
- }),
66
- },
67
- async ({ fact, title, scope, docId, startLine, endLine, relationType, ttl, keep, tags, supersedes }) => {
68
- const key = requireProjectKey(await scopeKey(scope, null, null));
69
- const entries = await readMemory(key);
70
-
71
- const explicitTitle = title ? title.trim() : null;
72
- let finalTitle = explicitTitle;
73
- let finalFact = fact.trim();
74
-
75
- // If fact already contains a title pattern, extract it
76
- const titleMatch = /^\*\*([^*]+)\*\*\s*(?:—|--|-|:)?\s*(.*)$/.exec(finalFact);
77
- if (titleMatch) {
78
- if (!finalTitle) {
79
- finalTitle = titleMatch[1].trim();
80
- }
81
- finalFact = titleMatch[2].trim();
82
- }
83
-
84
- if (!finalTitle) {
85
- finalTitle = autoGenerateTitle(finalFact);
86
- }
87
-
88
- const text = `**${finalTitle}** — ${finalFact}`;
89
- const factBodyNormalized = finalFact.toLowerCase();
90
- let duplicate = false;
91
- if (entries.some((e) => factBody(e).toLowerCase().trim() === factBodyNormalized)) {
92
- duplicate = true;
93
- }
94
-
95
- let supersededInfo = "";
96
- if (!duplicate) {
97
- const [date, time] = today().split(" ");
98
- const meta = { ttl, tags };
99
- if (keep) meta.keep = "1";
100
- if (supersedes) {
101
- const targetIdx = resolveFactIndex(entries, supersedes);
102
- if (targetIdx !== -1) {
103
- const newId = nextFactId(entries);
104
- const targetMeta = factMeta(entries[targetIdx]);
105
- const targetId = targetMeta.id || nextFactId(entries);
106
- entries[targetIdx] = withMeta(entries[targetIdx], { id: targetId, supersededBy: newId });
107
- meta.id = newId;
108
- meta.supersedes = targetId;
109
- supersededInfo = ` [superseded: "${factText(entries[targetIdx]).slice(0, 60)}"]`;
110
- } else {
111
- supersededInfo = " (note: supersedes target not found)";
112
- }
113
- }
114
- if (!meta.id) meta.id = nextFactId(entries);
115
- entries.push(formatFactEntry({ date, time, text, meta }));
116
- await writeMemory(key, entries);
117
- }
118
-
119
- let linkInfo = "";
120
- if (docId) {
121
- const { linkFactToDocument } = await import("../graph/knowledge_linker.js");
122
- try {
123
- const linkRes = linkFactToDocument({
124
- factKey: key,
125
- factText: finalFact,
126
- docId,
127
- startLine,
128
- endLine,
129
- relationType,
130
- });
131
- const linesStr = startLine ? `:L${startLine}${endLine ? `-${endLine}` : ""}` : "";
132
- linkInfo = ` [Linked to Doc: "${linkRes.docTitle}"${linesStr}]`;
133
- } catch (err) {
134
- linkInfo = ` (Note: Fact saved, but document link failed: ${err.message})`;
135
- }
136
- }
137
-
138
- return { content: [{ type: "text", text: `Memory updated${supersededInfo}${linkInfo}` }] };
139
- }
140
- );
141
-
142
- server.registerTool(
143
- "recall",
144
- {
145
- description:
146
- "Show saved facts with any Agent-linked Knowledge Base documents/lines. " +
147
- "scope: 'project', 'global', 'all' (default), or 'list_projects'. " +
148
- "Use project: '<directory path>' with scope 'project'/'all' to read facts of a specific project from any working directory. " +
149
- "query filters by keyword (all space-separated terms must match). " +
150
- "tags filters by comma-separated tags. since/until filter by date (YYYY-MM-DD, inclusive). " +
151
- "Expired facts are shown with [EXPIRED], protected ones with [KEEP]. The response includes the store file paths.",
152
- inputSchema: z.object({
153
- scope: defStr("all").describe("'project', 'global', 'all', or 'list_projects'"),
154
- project: optStr().describe("Directory path of the project to read facts from (e.g. 'F:/projects/plugins/memory')"),
155
- query: optStr().describe("Optional keyword filter; all space-separated terms must match"),
156
- tags: optStr().describe("Optional comma-separated tag filter (any match)"),
157
- since: optStr().describe("Optional start date filter, YYYY-MM-DD (inclusive)"),
158
- until: optStr().describe("Optional end date filter, YYYY-MM-DD (inclusive)"),
159
- mode: z.enum(["headers", "full"]).nullish().transform((v) => v || "full").describe("Result mode: 'full' (with body, default) or 'headers' (title and badges only)"),
160
- offset: optNum().describe("Pagination offset (optional)"),
161
- limit: optNum().describe("Pagination limit (optional)"),
162
- }),
163
- },
164
- async ({ scope, project, query, tags, since, until, mode, offset, limit }) => {
165
- const { getLinksForFact } = await import("../graph/knowledge_linker.js");
166
- const results = [];
167
- const now = Date.now();
168
-
169
- const formatRecallFact = async (factLine, index, key) => {
170
- const p = parseFactEntry(factLine);
171
- if (!p) return factLine;
172
-
173
- const title = factTitle(factLine);
174
- const meta = p.meta;
175
-
176
- const badges = [];
177
- if (isExpiredLine(factLine, now)) badges.push("EXPIRED");
178
- if (isKeepFact(factLine)) badges.push("KEEP");
179
- if (isSuperseded(factLine)) badges.push("SUPERSEDED");
180
- if (meta.inject === "1") badges.push("INJECT");
181
- if (meta.id) badges.push(`id:${meta.id}`);
182
- if (meta.tags) badges.push(`tags:${meta.tags}`);
183
- badges.push(`${p.date} ${p.time}`);
184
-
185
- const badgesStr = badges.length ? ` [${badges.join("] [")}]` : "";
186
-
187
- let lineText;
188
- if (mode === "headers") {
189
- lineText = `**${title}**${badgesStr}`;
190
- } else {
191
- lineText = `${p.text}${badgesStr}`;
192
- }
193
-
194
- try {
195
- const links = await getLinksForFact(key, p.text);
196
- if (links && links.length > 0) {
197
- const docStr = links
198
- .map((l) => {
199
- const range = l.start_line ? `:L${l.start_line}${l.end_line ? `-${l.end_line}` : ""}` : "";
200
- return `${l.doc_title || l.doc_path}${range}`;
201
- })
202
- .join(", ");
203
- lineText += ` 🔗 [Linked Docs: ${docStr}]`;
204
- }
205
- } catch (e) {}
206
-
207
- return `${index}. ${lineText}`;
208
- };
209
-
210
- const collect = async (entries, key) => {
211
- const matched = entries.filter(
212
- (e) => matchesQuery(e, query) && matchesTags(e, tags) && inDateRange(e, since, until)
213
- );
214
- if (!matched.length) return;
215
- if (results.length) results.push("");
216
- results.push(`--- ${key === GLOBAL_KEY ? "Global" : `Project: ${key === target ? label : key}`} ---`);
217
-
218
- const targetOffset = offset !== undefined ? offset : 0;
219
- const targetLimit = limit !== undefined ? limit : matched.length;
220
-
221
- const paginated = matched.slice(targetOffset, targetOffset + targetLimit);
222
- for (let i = 0; i < paginated.length; i++) {
223
- results.push(await formatRecallFact(paginated[i], targetOffset + i + 1, key));
224
- }
225
-
226
- if (limit !== undefined && matched.length > targetLimit) {
227
- results.push(`Showing entries ${targetOffset + 1}-${Math.min(targetOffset + targetLimit, matched.length)} of ${matched.length}`);
228
- }
229
- results.push(`Store file: ${storeFilePath(key)}`);
230
- };
231
-
232
- if (scope === "list_projects") {
233
- const stores = await listProjectStores();
234
- if (!stores.length) {
235
- return { content: [{ type: "text", text: "No project memory stores found." }] };
236
- }
237
- const lines = stores.map(
238
- (s, i) => `${i + 1}. ${s.basename} — ${s.count} fact(s) [${s.file}]${s.path ? ` (bound to ${s.path})` : " (unbound legacy store)"}`
239
- );
240
- return {
241
- content: [
242
- {
243
- type: "text",
244
- 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}`,
245
- },
246
- ],
247
- };
248
- }
249
-
250
- const resolveTargetKey = async (projectPath) => {
251
- if (!projectPath) return null;
252
- try {
253
- const { resolveProjectIdentity } = await import("../identity.js");
254
- const identity = await resolveProjectIdentity(projectPath);
255
- if (identity) return identity.key;
256
- } catch (e) {}
257
- return canonicalPath(projectPath);
258
- };
259
-
260
- const target = (await resolveTargetKey(project)) ?? (await projectKey(null, null));
261
- const label = project ? target : await projectName();
262
- if (scope !== "project") {
263
- const global = await readMemory(GLOBAL_KEY);
264
- await collect(global, GLOBAL_KEY);
265
- }
266
- if (scope !== "global") {
267
- const local = await readMemory(target);
268
- await collect(local, target);
269
- }
270
- const filtered = Boolean(query || tags || since || until);
271
- const text = results.length
272
- ? `${results.join("\n")}\n\nMemory dir: ${MEMORY_DIR}`
273
- : filtered
274
- ? "No facts match the search."
275
- : "Memory is empty.";
276
- return { content: [{ type: "text", text }] };
277
- }
278
- );
279
-
280
- server.registerTool(
281
- "get_fact",
282
- {
283
- description: "Get the full text and metadata of a single fact by its metadata id.",
284
- inputSchema: z.object({
285
- id: z.string().describe("The unique metadata id of the fact (e.g. '8f3a2c')"),
286
- scope: defStr("all").describe("'project', 'global', or 'all' (default)"),
287
- }),
288
- },
289
- async ({ id, scope }) => {
290
- const results = [];
291
- const targetId = String(id || "").trim();
292
- if (!targetId) throw new Error("ID parameter is required.");
293
-
294
- const check = async (key) => {
295
- const entries = await readMemory(key);
296
- const match = entries.find((e) => factMeta(e).id === targetId);
297
- if (match) {
298
- const title = factTitle(match);
299
- const body = factBody(match);
300
- const meta = factMeta(match);
301
- results.push({
302
- key,
303
- title,
304
- body,
305
- meta,
306
- line: match,
307
- });
308
- }
309
- };
310
-
311
- if (scope !== "project") {
312
- await check(GLOBAL_KEY);
313
- }
314
- if (scope !== "global") {
315
- const target = await projectKey(null, null);
316
- await check(target);
317
- }
318
-
319
- if (!results.length) {
320
- return { content: [{ type: "text", text: `Fact with ID "${targetId}" not found.` }] };
321
- }
322
-
323
- const lines = results.map((r) => {
324
- const metaStr = Object.entries(r.meta)
325
- .map(([k, v]) => `${k}:${v}`)
326
- .join(", ");
327
- return `[Store: ${r.key === GLOBAL_KEY ? "Global" : "Project"}]\nTitle: ${r.title}\nBody: ${r.body}\nMetadata: ${metaStr ? `<!-- ${metaStr} -->` : "none"}`;
328
- });
329
-
330
- return { content: [{ type: "text", text: lines.join("\n\n") }] };
331
- }
332
- );
333
-
334
- server.registerTool(
335
- "forget",
336
- {
337
- description:
338
- "Delete a fact by number (from recall), by range (e.g. '3-30', inclusive), or by text search. " +
339
- "Protected facts (remember with keep=true) are skipped unless force=true.",
340
- inputSchema: z.object({
341
- query: z.string().describe("Number, range like '3-30', or text to search for"),
342
- scope: defStr("project").describe("'project' (default) or 'global'"),
343
- force: defBool(false).describe("Also delete protected (keep) facts"),
344
- }),
345
- },
346
- async ({ query, scope, force }) => {
347
- const key = requireProjectKey(await scopeKey(scope, null, null));
348
- const entries = await readMemory(key);
349
- const rangeMatch = /^\s*(\d+)\s*-\s*(\d+)\s*$/.exec(query);
350
- const num = parseInt(query, 10);
351
- let indices = [];
352
- if (rangeMatch) {
353
- const from = parseInt(rangeMatch[1], 10);
354
- const to = parseInt(rangeMatch[2], 10);
355
- if (from > 0 && to >= from && to <= entries.length) {
356
- for (let i = from - 1; i < to; i++) indices.push(i);
357
- }
358
- }
359
- if (!indices.length && !isNaN(num) && num > 0 && num <= entries.length) {
360
- indices.push(num - 1);
361
- }
362
- if (!indices.length) {
363
- const q = query.toLowerCase();
364
- indices = entries.reduce((acc, e, i) => (e.toLowerCase().includes(q) ? acc.concat(i) : acc), []);
365
- }
366
- if (!indices.length) {
367
- return { content: [{ type: "text", text: "Not found." }] };
368
- }
369
-
370
- const removable = indices.filter((i) => force || !isKeepFact(entries[i]));
371
- const protectedCount = indices.length - removable.length;
372
- if (removable.length) {
373
- for (const i of removable.sort((a, b) => b - a)) entries.splice(i, 1);
374
- await writeMemory(key, entries);
375
- }
376
- let text = removable.length ? "Memory updated" : "Nothing removed.";
377
- if (protectedCount) text += ` (${protectedCount} protected fact(s) skipped; use force=true to override)`;
378
- return { content: [{ type: "text", text }] };
379
- }
380
- );
381
-
382
- server.registerTool(
383
- "update_fact",
384
- {
385
- description:
386
- "Update the text of an existing fact by number (from recall), id, or text match, " +
387
- "preserving its original date and metadata. Linked Knowledge Base documents are re-pointed to the new text.",
388
- inputSchema: z.object({
389
- id: z.string().describe("Number (from recall), metadata id, or text of the fact to update"),
390
- newText: z.string().describe("New fact text"),
391
- title: optStr().describe("Optional new title for the fact"),
392
- scope: defStr("project").describe("'project' (default) or 'global'"),
393
- }),
394
- },
395
- async ({ id, newText, title, scope }) => {
396
- const key = requireProjectKey(await scopeKey(scope, null, null));
397
- const entries = await readMemory(key);
398
- const idx = resolveFactIndex(entries, id);
399
- if (idx === -1) throw new Error(`Fact not found: ${id}`);
400
- const p = parseFactEntry(entries[idx]);
401
- const oldText = p ? p.text : entries[idx];
402
- const oldBody = factBody(entries[idx]) || oldText;
403
-
404
- const explicitTitle = title ? title.trim() : null;
405
- let finalTitle = explicitTitle;
406
- let finalFact = newText.trim();
407
-
408
- const titleMatch = /^\*\*([^*]+)\*\*\s*(?:—|--|-|:)?\s*(.*)$/.exec(finalFact);
409
- if (titleMatch) {
410
- if (!finalTitle) {
411
- finalTitle = titleMatch[1].trim();
412
- }
413
- finalFact = titleMatch[2].trim();
414
- }
415
-
416
- if (!finalTitle) {
417
- finalTitle = factTitle(entries[idx]) || autoGenerateTitle(finalFact);
418
- }
419
-
420
- const newTextFormatted = `**${finalTitle}** — ${finalFact}`;
421
- const newLine = formatFactEntry({ date: p.date, time: p.time, text: newTextFormatted, meta: p.meta });
422
- entries[idx] = newLine;
423
- await writeMemory(key, entries);
424
-
425
- let linksUpdated = 0;
426
- try {
427
- const { getDatabase } = await import("../db/database.js");
428
- const db = await getDatabase();
429
- const res = await db
430
- .prepare("UPDATE knowledge_links SET fact_text = ? WHERE fact_key = ? AND fact_text = ?")
431
- .run(finalFact, key, oldBody);
432
- linksUpdated = res.changes;
433
- } catch (e) {}
434
-
435
- return {
436
- content: [{ type: "text", text: `Fact updated${linksUpdated ? `, ${linksUpdated} doc link(s) updated` : ""}` }],
437
- };
438
- }
439
- );
440
-
441
- server.registerTool(
442
- "memory_info",
443
- {
444
- description:
445
- "Show memory storage paths (store file locations, MEMORY_DIR, SQLite DB), fact counts, " +
446
- "Knowledge Base stats, and the installed package version.",
447
- inputSchema: z.object({}),
448
- },
449
- async () => {
450
- const dbPath = join(MEMORY_DIR, "storage", "memory.sqlite");
451
- const globalFile = storeFilePath(GLOBAL_KEY);
452
- const projectFile = storeFilePath(await projectKey(null, null));
453
-
454
- let version = "unknown";
455
- try {
456
- version = JSON.parse(await readFile(new URL("../../package.json", import.meta.url), "utf-8")).version;
457
- } catch (e) {}
458
-
459
- let rag = {};
460
- try {
461
- const { getDatabase } = await import("../db/database.js");
462
- const db = await getDatabase();
463
- const docCountRow = await db.prepare("SELECT COUNT(*) AS c FROM documents").get();
464
- rag.documents = docCountRow ? docCountRow.c : 0;
465
- const secCountRow = await db.prepare("SELECT COUNT(*) AS c FROM sections").get();
466
- rag.sections = secCountRow ? secCountRow.c : 0;
467
- const chunkCountRow = await db.prepare("SELECT COUNT(*) AS c FROM micro_chunks").get();
468
- rag.chunks = chunkCountRow ? chunkCountRow.c : 0;
469
- const edgeCountRow = await db.prepare("SELECT COUNT(*) AS c FROM graph_edges").get();
470
- rag.edges = edgeCountRow ? edgeCountRow.c : 0;
471
- const linkCountRow = await db.prepare("SELECT COUNT(*) AS c FROM knowledge_links").get();
472
- rag.links = linkCountRow ? linkCountRow.c : 0;
473
- } catch (e) {
474
- rag.error = e.message;
475
- }
476
-
477
- const stores = await listProjectStores();
478
-
479
- let identityLines = [];
480
- try {
481
- const { getDatabase } = await import("../db/database.js");
482
- const { resolveProjectIdentity, listIdentities } = await import("../identity.js");
483
- const db = await getDatabase();
484
- const identity = await resolveProjectIdentity(process.cwd());
485
- const all = await listIdentities(db);
486
- identityLines.push(
487
- `Identity: ${identity ? "git" : "no-git"}` +
488
- (identity
489
- ? ` | key: ${identity.key} | name: ${identity.name}${identity.primaryRemote ? ` | remote: ${identity.primaryRemote}` : ""}`
490
- : ""),
491
- `Known identities: ${all.length}`
492
- );
493
- } catch (e) {
494
- identityLines.push(`Identity: unavailable (${e.message})`);
495
- }
496
-
497
- const lines = [
498
- `Version: ${version}`,
499
- `MEMORY_DIR: ${MEMORY_DIR}`,
500
- `SQLite DB: ${dbPath}`,
501
- `Global store: ${globalFile}`,
502
- `Project store: ${projectFile}`,
503
- `Project stores: ${stores.length}`,
504
- `Facts (global): ${(await readMemoryRaw(GLOBAL_KEY)).length}`,
505
- `Facts (project): ${(await readMemoryRaw(await projectKey(null, null))).length}`,
506
- ...identityLines,
507
- ];
508
- if (rag.error) lines.push(`RAG: unavailable (${rag.error})`);
509
- else
510
- lines.push(
511
- `RAG: ${rag.documents} doc(s), ${rag.sections} section(s), ${rag.chunks} chunk(s), ${rag.edges} edge(s), ${rag.links} memory link(s)`
512
- );
513
- return { content: [{ type: "text", text: lines.join("\n") }] };
514
- }
515
- );
516
- }
1
+ import * as z from "zod/v4";
2
+ import { optStr, optNum, defStr, defBool } from "./helpers.js";
3
+ import {
4
+ rememberFact,
5
+ recallFacts,
6
+ getFactById,
7
+ forgetFacts,
8
+ updateFactText,
9
+ memoryInfo,
10
+ } from "./core/memory_core.js";
11
+
12
+ export function registerMemoryTools(server) {
13
+ server.registerTool(
14
+ "remember",
15
+ {
16
+ description:
17
+ "Save an important, durable fact to memory. Only use for high-signal information " +
18
+ "(name, goals, constraints, tech preferences, project conventions). " +
19
+ "docId/startLine/endLine/relationType are OPTIONAL and only used to link the fact to a " +
20
+ "Knowledge Base document or line range; omit them when no linking is needed. " +
21
+ "ttl is OPTIONAL (e.g. '90d', '2w', '24h') — expired facts are shown with [EXPIRED] but not auto-deleted. " +
22
+ "keep=true protects the fact from forget deletion unless force=true. " +
23
+ "tags is OPTIONAL comma-separated text for filtering. " +
24
+ "supersedes is OPTIONAL: a number (from recall), id, or text of a fact this one replaces; " +
25
+ "the target is then marked [SUPERSEDED]. " +
26
+ "Translate the fact into English and keep it concise. " +
27
+ "scope: 'project' (default) or 'global'",
28
+ inputSchema: z.object({
29
+ fact: z.string().describe("The fact to remember, written in English"),
30
+ title: optStr().describe("Optional title for the fact. If not specified, one is auto-generated."),
31
+ scope: defStr("project").describe("'project' (default) or 'global'"),
32
+ docId: optStr().describe("Optional document ID, title, or path to link this fact to"),
33
+ startLine: optNum().describe("Optional starting line number in target document"),
34
+ endLine: optNum().describe("Optional ending line number in target document"),
35
+ relationType: defStr("LINKS_TO").describe("Relation type (e.g. 'RULES_FOR', 'IMPLEMENTS', 'REFERENCES')"),
36
+ ttl: optStr().describe("Optional time-to-live, e.g. '90d', '2w', '24h', '12m'"),
37
+ keep: defBool(false).describe("Protect the fact from forget deletion unless force=true"),
38
+ tags: optStr().describe("Optional comma-separated tags, e.g. 'pref,arch'"),
39
+ supersedes: optStr().describe("Optional number, id, or text of the fact this one replaces"),
40
+ }),
41
+ },
42
+ async (args) => ({ content: [{ type: "text", text: await rememberFact(args) }] })
43
+ );
44
+
45
+ server.registerTool(
46
+ "recall",
47
+ {
48
+ description:
49
+ "Show saved facts with any Agent-linked Knowledge Base documents/lines. " +
50
+ "scope: 'project', 'global', 'all' (default), or 'list_projects'. " +
51
+ "Use project: '<directory path>' with scope 'project'/'all' to read facts of a specific project from any working directory. " +
52
+ "query filters by keyword (all space-separated terms must match). " +
53
+ "tags filters by comma-separated tags. since/until filter by date (YYYY-MM-DD, inclusive). " +
54
+ "Expired facts are shown with [EXPIRED], protected ones with [KEEP]. The response includes the store file paths.",
55
+ inputSchema: z.object({
56
+ scope: defStr("all").describe("'project', 'global', 'all', or 'list_projects'"),
57
+ project: optStr().describe("Directory path of the project to read facts from (e.g. 'F:/projects/plugins/memory')"),
58
+ query: optStr().describe("Optional keyword filter; all space-separated terms must match"),
59
+ tags: optStr().describe("Optional comma-separated tag filter (any match)"),
60
+ since: optStr().describe("Optional start date filter, YYYY-MM-DD (inclusive)"),
61
+ until: optStr().describe("Optional end date filter, YYYY-MM-DD (inclusive)"),
62
+ mode: z.enum(["headers", "full"]).nullish().transform((v) => v || "full").describe("Result mode: 'full' (with body, default) or 'headers' (title and badges only)"),
63
+ offset: optNum().describe("Pagination offset (optional)"),
64
+ limit: optNum().describe("Pagination limit (optional)"),
65
+ }),
66
+ },
67
+ async (args) => ({ content: [{ type: "text", text: await recallFacts(args) }] })
68
+ );
69
+
70
+ server.registerTool(
71
+ "get_fact",
72
+ {
73
+ description: "Get the full text and metadata of a single fact by its metadata id.",
74
+ inputSchema: z.object({
75
+ id: z.string().describe("The unique metadata id of the fact (e.g. '8f3a2c')"),
76
+ scope: defStr("all").describe("'project', 'global', or 'all' (default)"),
77
+ }),
78
+ },
79
+ async (args) => ({ content: [{ type: "text", text: await getFactById(args) }] })
80
+ );
81
+
82
+ server.registerTool(
83
+ "forget",
84
+ {
85
+ description:
86
+ "Delete a fact by number (from recall), by range (e.g. '3-30', inclusive), or by text search. " +
87
+ "Protected facts (remember with keep=true) are skipped unless force=true.",
88
+ inputSchema: z.object({
89
+ query: z.string().describe("Number, range like '3-30', or text to search for"),
90
+ scope: defStr("project").describe("'project' (default) or 'global'"),
91
+ force: defBool(false).describe("Also delete protected (keep) facts"),
92
+ }),
93
+ },
94
+ async (args) => ({ content: [{ type: "text", text: await forgetFacts(args) }] })
95
+ );
96
+
97
+ server.registerTool(
98
+ "update_fact",
99
+ {
100
+ description:
101
+ "Update the text of an existing fact by number (from recall), id, or text match, " +
102
+ "preserving its original date and metadata. Linked Knowledge Base documents are re-pointed to the new text.",
103
+ inputSchema: z.object({
104
+ id: z.string().describe("Number (from recall), metadata id, or text of the fact to update"),
105
+ newText: z.string().describe("New fact text"),
106
+ title: optStr().describe("Optional new title for the fact"),
107
+ scope: defStr("project").describe("'project' (default) or 'global'"),
108
+ }),
109
+ },
110
+ async (args) => ({ content: [{ type: "text", text: await updateFactText(args) }] })
111
+ );
112
+
113
+ server.registerTool(
114
+ "memory_info",
115
+ {
116
+ description:
117
+ "Show memory storage paths (store file locations, MEMORY_DIR, SQLite DB), fact counts, " +
118
+ "Knowledge Base stats, and the installed package version.",
119
+ inputSchema: z.object({}),
120
+ },
121
+ async () => ({ content: [{ type: "text", text: await memoryInfo() }] })
122
+ );
123
+ }