@lotargo/memory_plugin 1.2.902 → 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.
package/mcp-server/cli.js CHANGED
@@ -6,6 +6,7 @@ import { hybridQuery } from "./retrieval/retriever.js";
6
6
  import { getDatabase } from "./db/database.js";
7
7
  import { deleteDocument } from "./ingest/pipeline.js";
8
8
  import { readMemoryRaw, readMemory, writeMemory, GLOBAL_KEY, projectName, projectKey, listProjectStores, migrateLegacyStore, memoryFileName } from "./memory.js";
9
+ import { parseFactEntry, factText, withMeta, displayFact, nextFactId, isKeepFact, isSuperseded, formatFactEntry, metaBadges } from "./fact_format.js";
9
10
  import { getCorpusCacheSize, clearCorpusCache } from "./benchmarks/fetch_real_corpus.js";
10
11
  import { SMOKE_DOC_IDS } from "./benchmarks/quality_evaluator.js";
11
12
  import { getModelStorageInfo, deleteModelCache, listAllCachedModels } from "./ml/model_manager.js";
@@ -628,6 +629,16 @@ function waitForEnter() {
628
629
  });
629
630
  }
630
631
 
632
+ function promptText(question) {
633
+ return new Promise((resolve) => {
634
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
635
+ rl.question(`\n ${question}\n > `, (answer) => {
636
+ rl.close();
637
+ resolve(answer.trim());
638
+ });
639
+ });
640
+ }
641
+
631
642
  export async function runCli() {
632
643
  const cliArgs = process.argv.slice(2);
633
644
  if (cliArgs.includes("--enable-prompt") || cliArgs.includes("enable-prompt")) {
@@ -1035,11 +1046,15 @@ export async function runCli() {
1035
1046
  }
1036
1047
 
1037
1048
  const file = memoryFileName(key);
1038
- const factItems = factList.map((fact, idx) => ({
1039
- label: `${idx + 1}. ${fact}`,
1040
- value: idx,
1041
- info: `Select to delete this fact from ${file}`,
1042
- }));
1049
+ const factItems = factList.map((fact, idx) => {
1050
+ const badges = metaBadges(fact);
1051
+ return {
1052
+ label: `${idx + 1}. ${factText(fact)}`,
1053
+ value: idx,
1054
+ badge: badges.length ? badges.join(" ") : undefined,
1055
+ info: `Select to manage this fact from ${file}`,
1056
+ };
1057
+ });
1043
1058
  factItems.push({ label: "< Back", value: "back" });
1044
1059
 
1045
1060
  const factRes = await selectSimpleMenu({
@@ -1053,22 +1068,58 @@ export async function runCli() {
1053
1068
  }
1054
1069
 
1055
1070
  const selectedIdx = factRes.value;
1056
- const selectedFact = factList[selectedIdx];
1071
+ const selectedEntry = rawEntries[selectedIdx];
1072
+ const selDisplay = displayFact(selectedEntry);
1073
+ const selBadges = metaBadges(selectedEntry);
1074
+
1075
+ const actionItems = [
1076
+ { label: "[UPDATE] Edit fact text", value: "update", info: "Rewrite the fact, keeping its date and metadata" },
1077
+ ];
1078
+ if (isKeepFact(selectedEntry)) {
1079
+ actionItems.push({ label: "[UNPROTECT] Remove keep protection", value: "unprotect", info: "Allow forget to delete it without force" });
1080
+ } else {
1081
+ actionItems.push({ label: "[PROTECT] Mark as important (keep)", value: "protect", info: "forget will skip it unless force=true" });
1082
+ }
1083
+ actionItems.push({ label: "[DELETE] Delete this fact from store", value: "delete", info: "Remove fact permanently" });
1084
+ actionItems.push({ label: "< Cancel / Back", value: "cancel" });
1057
1085
 
1058
1086
  const actionRes = await selectSimpleMenu({
1059
- title: `FACT ACTION`,
1060
- subtitle: `Fact: "${selectedFact}"`,
1061
- items: [
1062
- { label: "[DELETE] Delete this fact from store", value: "delete", info: "Remove fact permanently" },
1063
- { label: "< Cancel / Back", value: "cancel" },
1064
- ],
1087
+ title: "FACT ACTION",
1088
+ subtitle: `Fact: "${selDisplay}"${selBadges.length ? " [" + selBadges.join("] [") + "]" : ""}`,
1089
+ items: actionItems,
1065
1090
  });
1066
1091
 
1067
1092
  if (actionRes.action === "back" || actionRes.value === "cancel") {
1068
1093
  return;
1069
1094
  }
1070
1095
 
1071
- if (actionRes.action === "select" && actionRes.value === "delete") {
1096
+ if (actionRes.action === "select" && actionRes.value === "update") {
1097
+ const p = parseFactEntry(selectedEntry);
1098
+ const newText = await promptText(`New text for fact #${selectedIdx + 1}:`);
1099
+ if (!newText) continue;
1100
+ const newLine = formatFactEntry({ date: p.date, time: p.time, text: newText, meta: p.meta });
1101
+ const updated = [...rawEntries];
1102
+ updated[selectedIdx] = newLine;
1103
+ await writeMemory(key, updated);
1104
+ let links = 0;
1105
+ try {
1106
+ const db = getDatabase();
1107
+ links = db
1108
+ .prepare("UPDATE knowledge_links SET fact_text = ? WHERE fact_key = ? AND fact_text = ?")
1109
+ .run(newText, key, factText(selectedEntry)).changes;
1110
+ } catch (e) {}
1111
+ console.clear();
1112
+ console.log(`\n [OK] Fact updated successfully${links ? `, ${links} doc link(s) updated` : ""}.\n`);
1113
+ await waitForEnter();
1114
+ } else if (actionRes.action === "select" && (actionRes.value === "protect" || actionRes.value === "unprotect")) {
1115
+ const updated = [...rawEntries];
1116
+ updated[selectedIdx] =
1117
+ actionRes.value === "protect" ? withMeta(selectedEntry, { keep: "1" }) : withMeta(selectedEntry, { keep: null });
1118
+ await writeMemory(key, updated);
1119
+ console.clear();
1120
+ console.log(`\n [OK] Fact ${actionRes.value === "protect" ? "protected" : "unprotected"} successfully.\n`);
1121
+ await waitForEnter();
1122
+ } else if (actionRes.action === "select" && actionRes.value === "delete") {
1072
1123
  const updated = [...rawEntries];
1073
1124
  updated.splice(selectedIdx, 1);
1074
1125
  await writeMemory(key, updated);
@@ -0,0 +1,177 @@
1
+ // Shared fact-line format + metadata parsing for the Notebook Layer 1 store.
2
+ //
3
+ // A fact line looks like:
4
+ // - [2026-08-02 06:08] text here <!-- id:8f3a2c, ttl:90d, keep:1, supersedes:a1b2c3, tags:pref,arch -->
5
+ // The trailing HTML comment carries optional metadata. It is invisible in
6
+ // Markdown, so the store stays a single plain Markdown file. Older lines
7
+ // without a comment parse to empty metadata and stay fully compatible.
8
+ //
9
+ // Metadata keys:
10
+ // id short random id (e.g. "8f3a2c")
11
+ // ttl time-to-live, e.g. "90d", "2w", "24h", "12m" (m=month ~30d)
12
+ // keep "1" = protected fact: forget refuses to delete without force
13
+ // supersedes id (or number/text) this fact replaces
14
+ // supersededBy id of the fact that replaced this one
15
+ // tags comma-separated free-form tags for recall filtering
16
+
17
+ const META_KEYS = ["id", "ttl", "keep", "supersedes", "supersededBy", "tags"];
18
+
19
+ const ENTRY_RE = /^- \[(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2})\]\s+(.*)$/;
20
+
21
+ // Parse a raw entry line into { line, date, time, text, meta }.
22
+ // Returns null if the line is not a fact entry.
23
+ export function parseFactEntry(line) {
24
+ const m = ENTRY_RE.exec(line);
25
+ if (!m) return null;
26
+ const [, date, time, rest] = m;
27
+ let text = rest;
28
+ const meta = {};
29
+ const cm = /^(.*?)\s*<!--\s*(.*?)\s*-->$/.exec(rest);
30
+ if (cm) {
31
+ // Values may themselves contain commas (e.g. "tags:pref,arch"), so scan
32
+ // for known "key:" tokens instead of splitting the comment on commas.
33
+ const raw = cm[2];
34
+ const keyRe = new RegExp(`(${META_KEYS.join("|")})\\s*:`, "g");
35
+ const found = [];
36
+ let fm;
37
+ while ((fm = keyRe.exec(raw))) {
38
+ found.push({ key: fm[1], valStart: fm.index + fm[0].length, tokenStart: fm.index });
39
+ }
40
+ for (let i = 0; i < found.length; i++) {
41
+ const valEnd = i + 1 < found.length ? found[i + 1].tokenStart : raw.length;
42
+ const v = raw.slice(found[i].valStart, valEnd).replace(/[,\s]+$/, "").trim();
43
+ if (v !== "") {
44
+ meta[found[i].key] = v;
45
+ }
46
+ }
47
+ if (Object.keys(meta).length) text = cm[1];
48
+ }
49
+ return { line, date, time, text, meta };
50
+ }
51
+
52
+ // Display text of a fact line (metadata comment stripped).
53
+ export function factText(line) {
54
+ const p = parseFactEntry(line);
55
+ return p ? p.text : line;
56
+ }
57
+
58
+ // Metadata object of a fact line (empty if none / unparsable).
59
+ export function factMeta(line) {
60
+ const p = parseFactEntry(line);
61
+ return p ? p.meta : {};
62
+ }
63
+
64
+ // Format a fact line from parts. `meta` entries with falsy values are omitted.
65
+ export function formatFactEntry({ date, time, text, meta = {} }) {
66
+ let out = `- [${date} ${time}] ${text}`;
67
+ const pairs = [];
68
+ for (const k of META_KEYS) {
69
+ if (meta[k]) pairs.push(`${k}:${meta[k]}`);
70
+ }
71
+ if (pairs.length) out += ` <!-- ${pairs.join(", ")} -->`;
72
+ return out;
73
+ }
74
+
75
+ // Return a new line with `patch` applied to the metadata. Patch values that are
76
+ // null/undefined/"" remove the corresponding key. Non-fact lines are returned
77
+ // unchanged.
78
+ export function withMeta(line, patch) {
79
+ const p = parseFactEntry(line);
80
+ if (!p) return line;
81
+ const meta = { ...p.meta };
82
+ for (const k of Object.keys(patch)) {
83
+ const v = patch[k];
84
+ if (v === null || v === undefined || v === "") delete meta[k];
85
+ else meta[k] = String(v);
86
+ }
87
+ return formatFactEntry({ date: p.date, time: p.time, text: p.text, meta });
88
+ }
89
+
90
+ // Generate a short random id that is unique within the given entries.
91
+ export function nextFactId(entries) {
92
+ let id;
93
+ do {
94
+ id = Math.random().toString(36).slice(2, 8);
95
+ } while (entries.some((e) => factMeta(e).id === id));
96
+ return id;
97
+ }
98
+
99
+ const TTL_UNITS = { h: 3600e3, d: 86400e3, w: 7 * 86400e3, m: 30 * 86400e3 };
100
+
101
+ // Parse "90d" | "2w" | "24h" | "12m" (m = ~30 days) into milliseconds. Null if invalid.
102
+ export function ttlMs(ttl) {
103
+ const m = /^(\d+)\s*([hdwm])?$/.exec(String(ttl || "").trim());
104
+ if (!m) return null;
105
+ const n = parseInt(m[1], 10);
106
+ const u = m[2] || "d";
107
+ return n * (TTL_UNITS[u] || TTL_UNITS.d);
108
+ }
109
+
110
+ // True if the fact line has a ttl and it has elapsed relative to `now` (ms).
111
+ export function isExpiredLine(line, now = Date.now()) {
112
+ const p = parseFactEntry(line);
113
+ if (!p || !p.meta.ttl) return false;
114
+ const ms = ttlMs(p.meta.ttl);
115
+ if (!ms) return false;
116
+ const ts = new Date(`${p.date}T${p.time}:00`).getTime();
117
+ if (Number.isNaN(ts)) return false;
118
+ return now > ts + ms;
119
+ }
120
+
121
+ // True if the fact is protected from deletion (keep:1).
122
+ export function isKeepFact(line) {
123
+ return factMeta(line).keep === "1";
124
+ }
125
+
126
+ // True if the fact has been superseded by another fact.
127
+ export function isSuperseded(line) {
128
+ return Boolean(factMeta(line).supersededBy);
129
+ }
130
+
131
+ // Keyword match: space-separated terms, all must be present (case-insensitive).
132
+ // Also matches against the fact's id and tags.
133
+ export function matchesQuery(factLine, query) {
134
+ const q = String(query || "").trim();
135
+ if (!q) return true;
136
+ const p = parseFactEntry(factLine);
137
+ if (!p) return false;
138
+ const terms = q.toLowerCase().split(/\s+/).filter(Boolean);
139
+ const haystack = `${p.text} ${p.meta.id || ""} ${p.meta.tags || ""} ${p.date}`.toLowerCase();
140
+ return terms.every((t) => haystack.includes(t));
141
+ }
142
+
143
+ // Tag filter: comma-separated requested tags; match if ANY fact tag equals or
144
+ // contains a requested tag (case-insensitive).
145
+ export function matchesTags(factLine, tagsStr) {
146
+ const want = String(tagsStr || "").split(",").map((t) => t.trim().toLowerCase()).filter(Boolean);
147
+ if (!want.length) return true;
148
+ const have = (factMeta(factLine).tags || "").split(",").map((t) => t.trim().toLowerCase()).filter(Boolean);
149
+ if (!have.length) return false;
150
+ return want.some((w) => have.some((h) => h === w || h.includes(w) || w.includes(h)));
151
+ }
152
+
153
+ // Date-range filter. `since`/`until` are "YYYY-MM-DD" (inclusive).
154
+ export function inDateRange(factLine, since, until) {
155
+ const p = parseFactEntry(factLine);
156
+ if (!p) return false;
157
+ if (since && p.date < since) return false;
158
+ if (until && p.date > until) return false;
159
+ return true;
160
+ }
161
+
162
+ // Human-readable badges for a fact line, e.g. ["EXPIRED", "KEEP", "SUPERSEDED"].
163
+ export function metaBadges(factLine, now = Date.now()) {
164
+ const badges = [];
165
+ if (isExpiredLine(factLine, now)) badges.push("EXPIRED");
166
+ if (isKeepFact(factLine)) badges.push("KEEP");
167
+ if (isSuperseded(factLine)) badges.push("SUPERSEDED");
168
+ return badges;
169
+ }
170
+
171
+ // Display text of a fact line with badges appended, e.g.
172
+ // "user prefers TS [EXPIRED] [KEEP]"
173
+ export function displayFact(factLine, now = Date.now()) {
174
+ const text = factText(factLine);
175
+ const badges = metaBadges(factLine, now);
176
+ return badges.length ? `${text} [${badges.join("] [")}]` : text;
177
+ }
@@ -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
  {
@@ -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 };
@@ -3,6 +3,32 @@ const { existsSync } = await import("fs");
3
3
  const { join, basename, dirname, resolve } = await import("path");
4
4
  const { homedir } = await import("os");
5
5
  const { fileURLToPath } = await import("url");
6
+ const {
7
+ parseFactEntry,
8
+ factText,
9
+ factMeta,
10
+ withMeta,
11
+ nextFactId,
12
+ isKeepFact,
13
+ isSuperseded,
14
+ displayFact,
15
+ formatFactEntry,
16
+ matchesQuery,
17
+ matchesTags,
18
+ inDateRange,
19
+ } = await import("../mcp-server/fact_format.js");
20
+
21
+ // Resolve a fact reference (1-based number, metadata id, or text) to an index.
22
+ function resolveFactIndex(entries, ref) {
23
+ const trimmed = String(ref || "").trim();
24
+ if (!trimmed) return -1;
25
+ const num = parseInt(trimmed, 10);
26
+ if (/^\d+$/.test(trimmed) && num >= 1 && num <= entries.length) return num - 1;
27
+ const idIdx = entries.findIndex((e) => factMeta(e).id === trimmed);
28
+ if (idIdx !== -1) return idIdx;
29
+ const textIdx = entries.findIndex((e) => factText(e).toLowerCase().includes(trimmed.toLowerCase()));
30
+ return textIdx;
31
+ }
6
32
 
7
33
  const CONFIG_DIR = process.env.OPENCODE_CONFIG_DIR || join(homedir(), ".config", "opencode");
8
34
  const MEMORY_DIR = join(CONFIG_DIR, "memory");
@@ -214,13 +240,18 @@ const MEMORY_INSTRUCTION =
214
240
  "When saving, translate the fact into clear, concise English.\n" +
215
241
  "Use `scope: \"global\"` for personal facts, `scope: \"project\"` for project-specific facts.";
216
242
 
217
- function buildMemoryContext(globalFacts, projectFacts, projectKey) {
243
+ function buildMemoryContext(globalFacts, projectFacts, projectKey, now = Date.now()) {
218
244
  const parts = [MEMORY_INSTRUCTION];
245
+ const fmt = (entries) =>
246
+ entries
247
+ .filter((e) => !isSuperseded(e))
248
+ .map((e, i) => `${i + 1}. ${displayFact(e, now)}`)
249
+ .join("\n");
219
250
  if (globalFacts.length) {
220
- parts.push("## Global\n" + globalFacts.map((f, i) => `${i + 1}. ${f}`).join("\n"));
251
+ parts.push("## Global\n" + fmt(globalFacts));
221
252
  }
222
253
  if (projectFacts.length) {
223
- parts.push(`## Project: ${projectKey}\n` + projectFacts.map((f, i) => `${i + 1}. ${f}`).join("\n"));
254
+ parts.push(`## Project: ${projectKey}\n` + fmt(projectFacts));
224
255
  }
225
256
  return `<MEMORY>\n${parts.join("\n\n")}\n</MEMORY>`;
226
257
  }
@@ -251,8 +282,8 @@ export const MemoryPlugin = async ({ directory, worktree, client }) => {
251
282
  if (firstUser.parts.some((p) => p.type === "text" && p.text.includes("<MEMORY>"))) return;
252
283
 
253
284
  const [globalFacts, projectFacts] = await Promise.all([
254
- readMemoryRaw(GLOBAL_KEY),
255
- readMemoryRaw(activeProjectKey),
285
+ readMemory(GLOBAL_KEY),
286
+ readMemory(activeProjectKey),
256
287
  ]);
257
288
 
258
289
  const context = buildMemoryContext(globalFacts, projectFacts, activeProjectKey);
@@ -290,6 +321,10 @@ export const MemoryPlugin = async ({ directory, worktree, client }) => {
290
321
  "(name, goals, constraints, tech preferences, project conventions). " +
291
322
  "docId/startLine/endLine/relationType are OPTIONAL and only used to link the fact to a " +
292
323
  "Knowledge Base document or line range; omit them when no linking is needed. " +
324
+ "ttl is OPTIONAL (e.g. '90d', '2w', '24h') — expired facts are shown with [EXPIRED] but not auto-deleted. " +
325
+ "keep=true protects the fact from forget deletion unless force=true. " +
326
+ "tags is OPTIONAL comma-separated text for filtering. " +
327
+ "supersedes is OPTIONAL: a number, id, or text of a fact this one replaces. " +
293
328
  "Translate the fact into English and keep it concise. " +
294
329
  "scope: 'project' (default) or 'global'",
295
330
  args: {
@@ -307,16 +342,38 @@ export const MemoryPlugin = async ({ directory, worktree, client }) => {
307
342
  description: "Relation type (e.g. 'RULES_FOR', 'IMPLEMENTS', 'REFERENCES')",
308
343
  default: "LINKS_TO",
309
344
  },
345
+ ttl: { type: "string", description: "Optional time-to-live, e.g. '90d', '2w', '24h', '12m'" },
346
+ keep: { type: "boolean", description: "Protect the fact from forget deletion unless force=true" },
347
+ tags: { type: "string", description: "Optional comma-separated tags, e.g. 'pref,arch'" },
348
+ supersedes: { type: "string", description: "Optional number, id, or text of the fact this one replaces" },
310
349
  },
311
- async execute({ fact, scope, docId, startLine, endLine, relationType }, { worktree, directory }) {
350
+ async execute({ fact, scope, docId, startLine, endLine, relationType, ttl, keep, tags, supersedes }, { worktree, directory }) {
312
351
  const key = scopeKey(scope || "project", worktree, directory);
313
352
  const entries = await readMemory(key);
314
353
  const factNormalized = fact.toLowerCase().trim();
315
- if (!entries.some((e) => {
316
- const idx = e.indexOf("] ");
317
- return idx !== -1 && e.slice(idx + 2).toLowerCase().trim() === factNormalized;
318
- })) {
319
- entries.push(`- [${today()}] ${fact}`);
354
+ const duplicate = entries.some((e) => factText(e).toLowerCase().trim() === factNormalized);
355
+
356
+ let supersededInfo = "";
357
+ if (!duplicate) {
358
+ const [date, time] = today().split(" ");
359
+ const meta = { ttl, tags };
360
+ if (keep) meta.keep = "1";
361
+ if (supersedes) {
362
+ const targetIdx = resolveFactIndex(entries, supersedes);
363
+ if (targetIdx !== -1) {
364
+ const newId = nextFactId(entries);
365
+ const targetMeta = factMeta(entries[targetIdx]);
366
+ const targetId = targetMeta.id || nextFactId(entries);
367
+ entries[targetIdx] = withMeta(entries[targetIdx], { id: targetId, supersededBy: newId });
368
+ meta.id = newId;
369
+ meta.supersedes = targetId;
370
+ supersededInfo = ` [superseded: "${factText(entries[targetIdx]).slice(0, 60)}"]`;
371
+ } else {
372
+ supersededInfo = " (note: supersedes target not found)";
373
+ }
374
+ }
375
+ if (!meta.id) meta.id = nextFactId(entries);
376
+ entries.push(formatFactEntry({ date, time, text: fact, meta }));
320
377
  await writeMemory(key, entries);
321
378
  }
322
379
 
@@ -339,15 +396,18 @@ export const MemoryPlugin = async ({ directory, worktree, client }) => {
339
396
  }
340
397
  }
341
398
 
342
- await notify(client, "Memory updated" + linkInfo);
343
- return "Memory updated" + linkInfo;
399
+ const result = "Memory updated" + supersededInfo + linkInfo;
400
+ await notify(client, result);
401
+ return result;
344
402
  },
345
403
  },
346
404
  "recall": {
347
405
  description:
348
406
  "Show saved facts with any Agent-linked Knowledge Base documents/lines. " +
349
407
  "scope: 'project', 'global', 'all' (default), or 'list_projects'. " +
350
- "Use project: '<directory path>' to read facts of a specific project from any working directory.",
408
+ "Use project: '<directory path>' to read facts of a specific project from any working directory. " +
409
+ "query filters by keyword, tags by comma-separated tags, since/until by date (YYYY-MM-DD). " +
410
+ "The response includes the store file paths.",
351
411
  args: {
352
412
  scope: {
353
413
  type: "string",
@@ -355,8 +415,12 @@ export const MemoryPlugin = async ({ directory, worktree, client }) => {
355
415
  default: "all",
356
416
  },
357
417
  project: { type: "string", description: "Directory path of the project to read facts from (e.g. 'F:/projects/plugins/memory')" },
418
+ query: { type: "string", description: "Optional keyword filter; all space-separated terms must match" },
419
+ tags: { type: "string", description: "Optional comma-separated tag filter (any match)" },
420
+ since: { type: "string", description: "Optional start date filter, YYYY-MM-DD (inclusive)" },
421
+ until: { type: "string", description: "Optional end date filter, YYYY-MM-DD (inclusive)" },
358
422
  },
359
- async execute({ scope, project }, { worktree, directory }) {
423
+ async execute({ scope, project, query, tags, since, until }, { worktree, directory }) {
360
424
  const results = [];
361
425
 
362
426
  let getLinksForFact;
@@ -365,11 +429,11 @@ export const MemoryPlugin = async ({ directory, worktree, client }) => {
365
429
  getLinksForFact = linker.getLinksForFact;
366
430
  } catch (e) {}
367
431
 
368
- const formatFactWithLinks = (factText, key) => {
369
- let line = factText;
432
+ const formatFactWithLinks = (factLine, key) => {
433
+ let line = displayFact(factLine);
370
434
  if (getLinksForFact) {
371
435
  try {
372
- const links = getLinksForFact(key, factText);
436
+ const links = getLinksForFact(key, factText(factLine));
373
437
  if (links && links.length > 0) {
374
438
  const docStr = links
375
439
  .map((l) => {
@@ -384,39 +448,45 @@ export const MemoryPlugin = async ({ directory, worktree, client }) => {
384
448
  return line;
385
449
  };
386
450
 
451
+ const target = project ? canonicalPath(project) : projectKey(worktree, directory);
452
+ const label = project ? target : projectName(worktree, directory);
453
+
454
+ const collect = (entries, key) => {
455
+ const matched = entries.filter(
456
+ (e) => matchesQuery(e, query) && matchesTags(e, tags) && inDateRange(e, since, until)
457
+ );
458
+ if (!matched.length) return;
459
+ if (results.length) results.push("");
460
+ results.push(`--- ${key === GLOBAL_KEY ? "Global" : `Project: ${key === target ? label : key}`} ---`);
461
+ matched.forEach((e, i) => results.push(`${i + 1}. ${formatFactWithLinks(e, key)}`));
462
+ results.push(`Store file: ${memoryPath(key)}`);
463
+ };
464
+
387
465
  if (scope === "list_projects") {
388
466
  return listProjectStores().then((stores) => {
389
467
  if (!stores.length) return "No project memory stores found.";
390
468
  const lines = stores.map(
391
469
  (s, i) => `${i + 1}. ${s.basename} — ${s.count} fact(s) [${s.file}]${s.path ? ` (bound to ${s.path})` : " (unbound legacy store)"}`
392
470
  );
393
- return `Project Memory Stores:\n${lines.join("\n")}\n\nUse recall(scope: "project", project: "<path>") to read a specific store.`;
471
+ return `Project Memory Stores:\n${lines.join("\n")}\n\nUse recall(scope: "project", project: "<path>") to read a specific store.\n\nMemory dir: ${MEMORY_DIR}`;
394
472
  });
395
473
  }
396
474
 
397
- const target = project ? canonicalPath(project) : projectKey(worktree, directory);
398
- const label = project ? target : projectName(worktree, directory);
399
-
400
475
  if (scope !== "project") {
401
- const global = await readMemoryRaw(GLOBAL_KEY);
402
- if (global.length) {
403
- results.push("--- Global ---");
404
- global.forEach((e, i) => results.push(`${i + 1}. ${formatFactWithLinks(e, GLOBAL_KEY)}`));
405
- }
476
+ const global = await readMemory(GLOBAL_KEY);
477
+ collect(global, GLOBAL_KEY);
406
478
  }
407
479
  if (scope !== "global") {
408
- const local = await readMemoryRaw(target);
409
- if (local.length) {
410
- if (results.length) results.push("");
411
- results.push(`--- Project: ${label} ---`);
412
- local.forEach((e, i) => results.push(`${i + 1}. ${formatFactWithLinks(e, target)}`));
413
- }
480
+ const local = await readMemory(target);
481
+ collect(local, target);
414
482
  }
415
- return results.length ? results.join("\n") : "Memory is empty.";
483
+ const filtered = Boolean(query || tags || since || until);
484
+ if (!results.length) return filtered ? "No facts match the search." : "Memory is empty.";
485
+ return results.join("\n") + `\n\nMemory dir: ${MEMORY_DIR}`;
416
486
  },
417
487
  },
418
488
  "forget": {
419
- description: "Удалить факт по номеру (см. recall), по диапазону (например '3-30', включительно) или тексту",
489
+ description: "Удалить факт по номеру (см. recall), по диапазону (например '3-30', включительно) или тексту. Защищённые факты (remember с keep=true) пропускаются, если не передан force=true",
420
490
  args: {
421
491
  query: { type: "string", description: "Номер факта, диапазон вида '3-30' или текст для поиска" },
422
492
  scope: {
@@ -424,35 +494,118 @@ export const MemoryPlugin = async ({ directory, worktree, client }) => {
424
494
  description: "project (по умолчанию) или global",
425
495
  default: "project",
426
496
  },
497
+ force: { type: "boolean", description: "Удалить также защищённые (keep) факты" },
427
498
  },
428
- async execute({ query, scope }, { worktree, directory }) {
499
+ async execute({ query, scope, force }, { worktree, directory }) {
429
500
  const key = scopeKey(scope || "project", worktree, directory);
430
501
  const entries = await readMemory(key);
431
502
  const rangeMatch = /^\s*(\d+)\s*-\s*(\d+)\s*$/.exec(query);
432
503
  const num = parseInt(query, 10);
433
- let removed;
504
+ let indices = [];
434
505
  if (rangeMatch) {
435
506
  const from = parseInt(rangeMatch[1], 10);
436
507
  const to = parseInt(rangeMatch[2], 10);
437
508
  if (from > 0 && to >= from && to <= entries.length) {
438
- removed = entries.splice(from - 1, to - from + 1);
509
+ for (let i = from - 1; i < to; i++) indices.push(i);
439
510
  }
440
511
  }
441
- if (!removed && !isNaN(num) && num > 0 && num <= entries.length) {
442
- removed = entries.splice(num - 1, 1);
512
+ if (!indices.length && !isNaN(num) && num > 0 && num <= entries.length) {
513
+ indices.push(num - 1);
514
+ }
515
+ if (!indices.length) {
516
+ const q = query.toLowerCase();
517
+ indices = entries.reduce((acc, e, i) => (e.toLowerCase().includes(q) ? acc.concat(i) : acc), []);
443
518
  }
444
- if (!removed) {
445
- const filtered = entries.filter((e) => !e.toLowerCase().includes(query.toLowerCase()));
446
- removed = entries.filter((e) => e.toLowerCase().includes(query.toLowerCase()));
447
- entries.length = 0;
448
- entries.push(...filtered);
519
+ if (!indices.length) return "Not found.";
520
+
521
+ const removable = indices.filter((i) => force || !isKeepFact(entries[i]));
522
+ const protectedCount = indices.length - removable.length;
523
+ if (removable.length) {
524
+ for (const i of removable.sort((a, b) => b - a)) entries.splice(i, 1);
525
+ await writeMemory(key, entries);
449
526
  }
527
+ let result = removable.length ? "Memory updated" : "Nothing removed.";
528
+ if (protectedCount) result += ` (${protectedCount} protected fact(s) skipped; use force=true to override)`;
529
+ if (removable.length) await notify(client, result);
530
+ return result;
531
+ },
532
+ },
533
+ "update_fact": {
534
+ description:
535
+ "Update the text of an existing fact by number (from recall), id, or text match, " +
536
+ "preserving its original date and metadata. Linked Knowledge Base documents are re-pointed to the new text.",
537
+ args: {
538
+ id: { type: "string", description: "Number (from recall), metadata id, or text of the fact to update" },
539
+ newText: { type: "string", description: "New fact text" },
540
+ scope: { type: "string", description: "'project' (default) or 'global'", default: "project" },
541
+ },
542
+ async execute({ id, newText, scope }, { worktree, directory }) {
543
+ const key = scopeKey(scope || "project", worktree, directory);
544
+ const entries = await readMemory(key);
545
+ const idx = resolveFactIndex(entries, id);
546
+ if (idx === -1) throw new Error(`Fact not found: ${id}`);
547
+ const p = parseFactEntry(entries[idx]);
548
+ const oldText = p ? p.text : entries[idx];
549
+ const newLine = formatFactEntry({ date: p.date, time: p.time, text: newText, meta: p.meta });
550
+ entries[idx] = newLine;
450
551
  await writeMemory(key, entries);
451
- const result = removed.length ? "Memory updated" : "Not found.";
452
- if (removed.length) await notify(client, "Memory updated");
552
+
553
+ let linksUpdated = 0;
554
+ try {
555
+ const { getDatabase } = await import("../mcp-server/db/database.js");
556
+ const db = getDatabase();
557
+ const res = db
558
+ .prepare("UPDATE knowledge_links SET fact_text = ? WHERE fact_key = ? AND fact_text = ?")
559
+ .run(newText, key, oldText);
560
+ linksUpdated = res.changes;
561
+ } catch (e) {}
562
+
563
+ const result = `Fact updated${linksUpdated ? `, ${linksUpdated} doc link(s) updated` : ""}`;
564
+ await notify(client, result);
453
565
  return result;
454
566
  },
455
567
  },
568
+ "memory_info": {
569
+ description: "Show memory storage paths (store files, MEMORY_DIR, SQLite DB), fact counts, and Knowledge Base stats.",
570
+ args: {},
571
+ async execute() {
572
+ const dbPath = join(MEMORY_DIR, "storage", "memory.sqlite");
573
+ let version = "unknown";
574
+ try {
575
+ const { readFile } = await import("fs/promises");
576
+ version = JSON.parse(
577
+ await readFile(new URL("../package.json", import.meta.url), "utf-8")
578
+ ).version;
579
+ } catch (e) {}
580
+
581
+ let rag = {};
582
+ try {
583
+ const { getDatabase } = await import("../mcp-server/db/database.js");
584
+ const db = getDatabase();
585
+ rag.documents = db.prepare("SELECT COUNT(*) AS c FROM documents").get().c;
586
+ rag.sections = db.prepare("SELECT COUNT(*) AS c FROM sections").get().c;
587
+ rag.chunks = db.prepare("SELECT COUNT(*) AS c FROM micro_chunks").get().c;
588
+ rag.edges = db.prepare("SELECT COUNT(*) AS c FROM graph_edges").get().c;
589
+ rag.links = db.prepare("SELECT COUNT(*) AS c FROM knowledge_links").get().c;
590
+ } catch (e) {
591
+ rag.error = e.message;
592
+ }
593
+
594
+ const lines = [
595
+ `Version: ${version}`,
596
+ `MEMORY_DIR: ${MEMORY_DIR}`,
597
+ `SQLite DB: ${dbPath}`,
598
+ `Global store: ${memoryPath(GLOBAL_KEY)}`,
599
+ `Project store: ${memoryPath(activeProjectKey)}`,
600
+ ];
601
+ if (rag.error) lines.push(`RAG: unavailable (${rag.error})`);
602
+ else
603
+ lines.push(
604
+ `RAG: ${rag.documents} doc(s), ${rag.sections} section(s), ${rag.chunks} chunk(s), ${rag.edges} edge(s), ${rag.links} memory link(s)`
605
+ );
606
+ return lines.join("\n");
607
+ },
608
+ },
456
609
  "link_knowledge": {
457
610
  description:
458
611
  "Explicitly link a Notebook memory fact to a Knowledge Base document, section, or line range. " +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotargo/memory_plugin",
3
- "version": "1.2.902",
3
+ "version": "1.3.0",
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",
@@ -25,6 +25,7 @@
25
25
  "mcp-server/storage",
26
26
  "mcp-server/cli.js",
27
27
  "mcp-server/index.js",
28
+ "mcp-server/fact_format.js",
28
29
  "mcp-server/memory.js",
29
30
  "mcp-server/setup.js",
30
31
  "mcp-server/preinstall.js",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: using-memory
3
- description: Comprehensive guide for using the Memory & Hybrid RAG Knowledge Engine tools (remember, recall, forget, link_knowledge, ingest_document, query_knowledge_base, manage_knowledge_base). Trigger proactively whenever user preferences, project conventions, technology stack choices, or architecture decisions are introduced, or when querying ingested documentation, indexing files/repos, or managing persistent knowledge.
3
+ description: Comprehensive guide for using the Memory & Hybrid RAG Knowledge Engine tools (remember, recall, forget, update_fact, memory_info, link_knowledge, ingest_document, query_knowledge_base, manage_knowledge_base). Trigger proactively whenever user preferences, project conventions, technology stack choices, or architecture decisions are introduced, or when querying ingested documentation, indexing files/repos, or managing persistent knowledge.
4
4
  ---
5
5
 
6
6
  # Using Memory & Hybrid RAG Knowledge Engine
@@ -17,8 +17,13 @@ You have access to a persistent dual-layer memory engine supercharged with an **
17
17
  | Scenario / Intent | Target Tool | Key Parameters |
18
18
  |-------------------|-------------|----------------|
19
19
  | User shares identity, tech stack preference, or workflow rule | `remember` | `fact` (English), `scope`, optional `docId`, `startLine`, `endLine` |
20
- | User asks what you remember about them, the project, or linked docs | `recall` | `scope` ("all", "global", or "project") |
21
- | User corrects/updates an old saved fact | `forget` then `remember` | `query` (text or index number) |
20
+ | User asks what you remember about them, the project, or linked docs | `recall` | `scope` ("all", "global", or "project"), optional `query`, `tags`, `since`, `until`, `project` |
21
+ | User corrects/updates an old saved fact | `update_fact` | `id` (number/id/text), `newText`, `scope` |
22
+ | Replace a fact but keep a version trail | `remember` | `fact`, `supersedes` (number/id/text) |
23
+ | Protect a fact from accidental `forget` | `remember` | `keep: true` |
24
+ | Set a time-to-live on a fact | `remember` | `ttl` ("90d", "2w", "24h", "12m") |
25
+ | Filter facts by keyword / tags / date | `recall` | `query`, `tags`, `since`, `until` |
26
+ | Show storage paths, versions, fact & RAG stats | `memory_info` | — |
22
27
  | Connect a Notebook fact to a document, section, or line range | `link_knowledge` | `factText`, `docId`, `startLine`, `endLine`, `relationType` |
23
28
  | User asks to index a documentation URL, file, or repository | `ingest_document` | `content` or `source_path`, `title`, `metadata` |
24
29
  | User asks a complex question about indexed docs or code | `query_knowledge_base` | `query`, `limit`, `generateEmbeddings` |
@@ -27,7 +32,7 @@ You have access to a persistent dual-layer memory engine supercharged with an **
27
32
 
28
33
  ---
29
34
 
30
- ## 2. Layer 1 & 3: Notebook Store & Agent-Driven Knowledge Graph (`remember`, `recall`, `link_knowledge`)
35
+ ## 2. Layer 1 & 3: Notebook Store & Agent-Driven Knowledge Graph (`remember`, `recall`, `update_fact`, `forget`, `memory_info`, `link_knowledge`)
31
36
 
32
37
  ### Agent-Driven Knowledge Graph Architecture
33
38
  Automatic regex/heuristic algorithms alone CANNOT infer high-level semantic intent or cross-document relationships. **You (the AI Agent) are the primary architect of the Knowledge Graph.**
@@ -50,6 +55,43 @@ When `recall` is invoked, the engine returns saved facts along with their Agent-
50
55
  2. PostgreSQL 16 is primary database 🔗 [Linked Docs: database_guide.md:L20-35]
51
56
  ```
52
57
 
58
+ ### Fact Line Format & Metadata
59
+ Each fact is stored as a single Markdown line with an optional invisible HTML comment carrying metadata:
60
+ ```
61
+ - [2026-08-02 06:08] user prefers TypeScript <!-- id:8f3a2c, ttl:90d, keep:1, tags:pref,arch -->
62
+ ```
63
+ Supported metadata keys (set via `remember`, rendered as badges by `recall`):
64
+ - `id` — auto-generated short id; stable reference for `update_fact` / `forget` / `supersedes`.
65
+ - `ttl` — time-to-live ("90d", "2w", "24h", "12m", bare number = days). Expired facts are marked `[EXPIRED]` but never auto-deleted.
66
+ - `keep` — protection flag; `forget` skips it unless `force: true`.
67
+ - `tags` — comma-separated free-form tags for filtering.
68
+ - `supersedes` / `supersededBy` — versioning: the old fact gets `[SUPERSEDED]` and is excluded from the injected memory block while staying in the store for history.
69
+
70
+ ### Remember Options (`remember`)
71
+ - `ttl`: "90d", "2w", "24h", "12m" — mark the fact for expiry; it will show `[EXPIRED]` once past.
72
+ - `keep: true`: protect the fact from `forget` (unless `force: true`).
73
+ - `tags`: comma-separated tags for later filtering, e.g. `"pref,arch"`.
74
+ - `supersedes`: number (as listed by `recall`), metadata `id`, or text of the fact this one replaces.
75
+
76
+ ### Filtering Facts (`recall`)
77
+ - `query`: all space-separated terms must match (case-insensitive); searches text, id, tags, and date.
78
+ - `tags`: comma-separated; returns facts with ANY matching tag.
79
+ - `since` / `until`: "YYYY-MM-DD" (inclusive) to filter by fact date.
80
+ - `project`: read a specific project's store from any working directory.
81
+ - Output shows `[EXPIRED]`, `[KEEP]`, `[SUPERSEDED]` badges and the `Store file:` path.
82
+
83
+ ### Updating Facts (`update_fact`)
84
+ When the user corrects an old fact, prefer `update_fact` over `forget`+`remember` — it rewrites the text while preserving the original date and all metadata (`ttl`, `keep`, `tags`, `supersedes`), and re-points any linked Knowledge Base documents.
85
+ - `id`: recall index number, metadata `id`, or text of the fact.
86
+ - `newText`: replacement text.
87
+ - `scope`: "project" (default) or "global".
88
+
89
+ ### Protecting Facts (`forget` with `keep`)
90
+ `forget` refuses to delete facts saved with `keep: true`; pass `force: true` to override. It still supports deleting by index number, range ("3-30"), or text.
91
+
92
+ ### Storage Diagnostics (`memory_info`)
93
+ `memory_info` returns the package version, `MEMORY_DIR`, SQLite DB path, store-file locations, fact counts per store, and RAG stats (documents, sections, chunks, graph edges, links).
94
+
53
95
  ---
54
96
 
55
97
  ## 3. Layer 2: RAG Knowledge Base (`ingest_document`, `query_knowledge_base`, `manage_knowledge_base`)
@@ -121,4 +163,4 @@ In such cases, use the **Full Raw Document Reading** mechanism:
121
163
  2. **Be Proactive**: When the user mentions a durable preference, personal fact, or constraint, save it immediately using `remember`. Do not wait for explicit user commands.
122
164
  3. **Check Knowledge Base First**: If a user asks how a specific module, API, or project architecture works, call `query_knowledge_base` using concept-dense search phrases.
123
165
  4. **Inspect Ambiguous Docs Directly**: If querying produces low relevance scores on abstractly-named documents, call `manage_knowledge_base(action: "read_document")` to inspect the full text directly.
124
- 5. **Keep Memory Clean**: If a preference changes, call `forget` on the outdated entry before saving the new one.
166
+ 5. **Keep Memory Clean**: If a preference changes, call `update_fact` to edit it in place, or `remember` with `supersedes` to keep a version trail. Use `keep: true` for facts that must survive an accidental `forget`, and give ephemeral facts a `ttl` so stale ones surface as `[EXPIRED]`.