@lotargo/memory_plugin 1.6.3 → 1.6.5

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,393 +1,465 @@
1
- import { readFile } from "node:fs/promises";
2
- import { join } from "node:path";
3
- import {
4
- readMemory,
5
- readMemoryRaw,
6
- writeMemory,
7
- today,
8
- MEMORY_DIR,
9
- GLOBAL_KEY,
10
- scopeKey,
11
- projectKey,
12
- projectName,
13
- canonicalPath,
14
- listProjectStores,
15
- storeFilePath,
16
- } from "../../memory.js";
17
- import {
18
- parseFactEntry,
19
- factText,
20
- factMeta,
21
- withMeta,
22
- nextFactId,
23
- isKeepFact,
24
- isExpiredLine,
25
- isSuperseded,
26
- formatFactEntry,
27
- matchesQuery,
28
- matchesTags,
29
- inDateRange,
30
- factTitle,
31
- factBody,
32
- autoGenerateTitle,
33
- } from "../../fact_format.js";
34
- import { requireProjectKey, resolveFactIndex } from "../helpers.js";
35
-
36
- // Single implementation of the Notebook tools, shared by the MCP server
37
- // (mcp-server/tools/memory_tools.js) and the OpenCode plugin
38
- // (opencode-plugin/index.js). Both used to carry their own copy, so bug fixes
39
- // in one never reached the other. Every function returns a plain string; the
40
- // callers wrap it in whatever envelope their host expects.
41
- //
42
- // `ctx` carries the host's notion of the current location:
43
- // { worktree, directory } — the MCP server passes nothing and falls back to cwd.
44
-
45
- const TITLE_PATTERN = /^\*\*([^*]+)\*\*\s*(?:—|--|-|:)?\s*(.*)$/;
46
-
47
- function splitTitle(rawText, explicitTitle) {
48
- let finalTitle = explicitTitle ? explicitTitle.trim() : null;
49
- let finalFact = String(rawText || "").trim();
50
- const match = TITLE_PATTERN.exec(finalFact);
51
- if (match) {
52
- if (!finalTitle) finalTitle = match[1].trim();
53
- finalFact = match[2].trim();
54
- }
55
- return { finalTitle, finalFact };
56
- }
57
-
58
- async function resolveScopeKey(scope, ctx = {}) {
59
- return await scopeKey(scope || "project", ctx.worktree ?? null, ctx.directory ?? null);
60
- }
61
-
62
- export async function rememberFact(
63
- { fact, title, scope, docId, startLine, endLine, relationType, ttl, keep, tags, supersedes },
64
- ctx = {}
65
- ) {
66
- const key = requireProjectKey(await resolveScopeKey(scope, ctx));
67
- const entries = await readMemory(key);
68
-
69
- let { finalTitle, finalFact } = splitTitle(fact, title);
70
- if (!finalTitle) finalTitle = autoGenerateTitle(finalFact);
71
-
72
- const text = `**${finalTitle}** — ${finalFact}`;
73
- const factBodyNormalized = finalFact.toLowerCase();
74
- const duplicate = entries.some((e) => factBody(e).toLowerCase().trim() === factBodyNormalized);
75
-
76
- let supersededInfo = "";
77
- if (!duplicate) {
78
- const [date, time] = today().split(" ");
79
- const meta = { ttl, tags };
80
- if (keep) meta.keep = "1";
81
- if (supersedes) {
82
- const targetIdx = resolveFactIndex(entries, supersedes);
83
- if (targetIdx !== -1) {
84
- const newId = nextFactId(entries);
85
- const targetId = factMeta(entries[targetIdx]).id || nextFactId(entries);
86
- entries[targetIdx] = withMeta(entries[targetIdx], { id: targetId, supersededBy: newId });
87
- meta.id = newId;
88
- meta.supersedes = targetId;
89
- supersededInfo = ` [superseded: "${factText(entries[targetIdx]).slice(0, 60)}"]`;
90
- } else {
91
- supersededInfo = " (note: supersedes target not found)";
92
- }
93
- }
94
- if (!meta.id) meta.id = nextFactId(entries);
95
- entries.push(formatFactEntry({ date, time, text, meta }));
96
- await writeMemory(key, entries);
97
- }
98
-
99
- let linkInfo = "";
100
- if (docId) {
101
- try {
102
- const { linkFactToDocument } = await import("../../graph/knowledge_linker.js");
103
- const linkRes = await linkFactToDocument({
104
- factKey: key,
105
- factText: finalFact,
106
- docId,
107
- startLine,
108
- endLine,
109
- relationType: relationType || "LINKS_TO",
110
- });
111
- const linesStr = startLine ? `:L${startLine}${endLine ? `-${endLine}` : ""}` : "";
112
- linkInfo = ` [Linked to Doc: "${linkRes.docTitle}"${linesStr}]`;
113
- } catch (err) {
114
- linkInfo = ` (Note: Fact saved, but document link failed: ${err.message})`;
115
- }
116
- }
117
-
118
- return `Memory updated${supersededInfo}${linkInfo}`;
119
- }
120
-
121
- async function resolveTargetKey(projectPath) {
122
- if (!projectPath) return null;
123
- try {
124
- const { resolveProjectIdentity } = await import("../../identity.js");
125
- const identity = await resolveProjectIdentity(projectPath);
126
- if (identity) return identity.key;
127
- } catch {}
128
- return canonicalPath(projectPath);
129
- }
130
-
131
- export async function recallFacts(
132
- { scope, project, query, tags, since, until, mode, offset, limit },
133
- ctx = {}
134
- ) {
135
- const results = [];
136
- const now = Date.now();
137
- const targetMode = mode || "full";
138
- const targetOffset = offset !== undefined && offset !== null ? offset : 0;
139
-
140
- if (scope === "list_projects") {
141
- const stores = await listProjectStores();
142
- if (!stores.length) return "No project memory stores found.";
143
- const lines = stores.map(
144
- (s, i) =>
145
- `${i + 1}. ${s.basename} — ${s.count} fact(s) [${s.file}]${
146
- s.path ? ` (bound to ${s.path})` : " (unbound legacy store)"
147
- }`
148
- );
149
- return `Project Memory Stores:\n${lines.join(
150
- "\n"
151
- )}\n\nUse recall(scope: "project", project: "<path>") to read a specific store.\n\nMemory dir: ${MEMORY_DIR}`;
152
- }
153
-
154
- let getLinksForFact = null;
155
- try {
156
- ({ getLinksForFact } = await import("../../graph/knowledge_linker.js"));
157
- } catch {}
158
-
159
- const target =
160
- (await resolveTargetKey(project)) ?? (await projectKey(ctx.worktree ?? null, ctx.directory ?? null));
161
- const label = project ? target : await projectName(ctx.worktree ?? null, ctx.directory ?? null);
162
-
163
- const formatFactWithLinks = async (factLine, index, key) => {
164
- const p = parseFactEntry(factLine);
165
- if (!p) return factLine;
166
-
167
- const meta = p.meta;
168
- const badges = [];
169
- if (isExpiredLine(factLine, now)) badges.push("EXPIRED");
170
- if (isKeepFact(factLine)) badges.push("KEEP");
171
- if (isSuperseded(factLine)) badges.push("SUPERSEDED");
172
- if (meta.inject === "1") badges.push("INJECT");
173
- if (meta.id) badges.push(`id:${meta.id}`);
174
- if (meta.tags) badges.push(`tags:${meta.tags}`);
175
- badges.push(`${p.date} ${p.time}`);
176
- const badgesStr = badges.length ? ` [${badges.join("] [")}]` : "";
177
-
178
- let lineText =
179
- targetMode === "headers" ? `**${factTitle(factLine)}**${badgesStr}` : `${p.text}${badgesStr}`;
180
-
181
- if (getLinksForFact) {
182
- try {
183
- const links = await getLinksForFact(key, p.text);
184
- if (links && links.length > 0) {
185
- const docStr = links
186
- .map((l) => {
187
- const range = l.start_line ? `:L${l.start_line}${l.end_line ? `-${l.end_line}` : ""}` : "";
188
- return `${l.doc_title || l.doc_path}${range}`;
189
- })
190
- .join(", ");
191
- lineText += ` 🔗 [Linked Docs: ${docStr}]`;
192
- }
193
- } catch {}
194
- }
195
- return `${index}. ${lineText}`;
196
- };
197
-
198
- const collect = async (entries, key) => {
199
- const matched = entries.filter(
200
- (e) => matchesQuery(e, query) && matchesTags(e, tags) && inDateRange(e, since, until)
201
- );
202
- if (!matched.length) return;
203
- if (results.length) results.push("");
204
- results.push(`--- ${key === GLOBAL_KEY ? "Global" : `Project: ${key === target ? label : key}`} ---`);
205
-
206
- const hasLimit = limit !== undefined && limit !== null;
207
- const targetLimit = hasLimit ? limit : matched.length;
208
- const paginated = matched.slice(targetOffset, targetOffset + targetLimit);
209
- for (let i = 0; i < paginated.length; i++) {
210
- results.push(await formatFactWithLinks(paginated[i], targetOffset + i + 1, key));
211
- }
212
- if (hasLimit && matched.length > targetLimit) {
213
- results.push(
214
- `Showing entries ${targetOffset + 1}-${Math.min(targetOffset + targetLimit, matched.length)} of ${matched.length}`
215
- );
216
- }
217
- results.push(`Store file: ${storeFilePath(key)}`);
218
- };
219
-
220
- if (scope !== "project") await collect(await readMemory(GLOBAL_KEY), GLOBAL_KEY);
221
- if (scope !== "global") await collect(await readMemory(target), target);
222
-
223
- const filtered = Boolean(query || tags || since || until);
224
- if (!results.length) return filtered ? "No facts match the search." : "Memory is empty.";
225
- return `${results.join("\n")}\n\nMemory dir: ${MEMORY_DIR}`;
226
- }
227
-
228
- export async function getFactById({ id, scope }, ctx = {}) {
229
- const targetId = String(id || "").trim();
230
- if (!targetId) throw new Error("ID parameter is required.");
231
-
232
- const results = [];
233
- const check = async (key) => {
234
- const entries = await readMemory(key);
235
- const match = entries.find((e) => factMeta(e).id === targetId);
236
- if (match) {
237
- results.push({ key, title: factTitle(match), body: factBody(match), meta: factMeta(match), line: match });
238
- }
239
- };
240
-
241
- if (scope !== "project") await check(GLOBAL_KEY);
242
- if (scope !== "global") await check(await projectKey(ctx.worktree ?? null, ctx.directory ?? null));
243
-
244
- if (!results.length) return `Fact with ID "${targetId}" not found.`;
245
-
246
- return results
247
- .map((r) => {
248
- const metaStr = Object.entries(r.meta)
249
- .map(([k, v]) => `${k}:${v}`)
250
- .join(", ");
251
- return `[Store: ${r.key === GLOBAL_KEY ? "Global" : "Project"}]\nTitle: ${r.title}\nBody: ${r.body}\nMetadata: ${
252
- metaStr ? `<!-- ${metaStr} -->` : "none"
253
- }`;
254
- })
255
- .join("\n\n");
256
- }
257
-
258
- export async function forgetFacts({ query, scope, force }, ctx = {}) {
259
- const key = requireProjectKey(await resolveScopeKey(scope, ctx));
260
- const entries = await readMemory(key);
261
-
262
- const rangeMatch = /^\s*(\d+)\s*-\s*(\d+)\s*$/.exec(query);
263
- let indices = [];
264
- if (rangeMatch) {
265
- const from = parseInt(rangeMatch[1], 10);
266
- const to = parseInt(rangeMatch[2], 10);
267
- if (from > 0 && to >= from && to <= entries.length) {
268
- for (let i = from - 1; i < to; i++) indices.push(i);
269
- }
270
- }
271
- if (!indices.length && /^\s*\d+\s*$/.test(String(query))) {
272
- const num = parseInt(query, 10);
273
- if (num > 0 && num <= entries.length) indices.push(num - 1);
274
- }
275
- if (!indices.length) {
276
- const q = String(query).toLowerCase();
277
- indices = entries.reduce((acc, e, i) => (e.toLowerCase().includes(q) ? acc.concat(i) : acc), []);
278
- }
279
- if (!indices.length) return "Not found.";
280
-
281
- const removable = indices.filter((i) => force || !isKeepFact(entries[i]));
282
- const protectedCount = indices.length - removable.length;
283
- if (removable.length) {
284
- for (const i of removable.sort((a, b) => b - a)) entries.splice(i, 1);
285
- await writeMemory(key, entries);
286
- }
287
- let text = removable.length ? "Memory updated" : "Nothing removed.";
288
- if (protectedCount) text += ` (${protectedCount} protected fact(s) skipped; use force=true to override)`;
289
- return text;
290
- }
291
-
292
- export async function updateFactText({ id, newText, title, scope }, ctx = {}) {
293
- const key = requireProjectKey(await resolveScopeKey(scope, ctx));
294
- const entries = await readMemory(key);
295
- const idx = resolveFactIndex(entries, id);
296
- if (idx === -1) throw new Error(`Fact not found: ${id}`);
297
-
298
- const p = parseFactEntry(entries[idx]);
299
- const oldText = p ? p.text : entries[idx];
300
- const oldBody = factBody(entries[idx]) || oldText;
301
-
302
- let { finalTitle, finalFact } = splitTitle(newText, title);
303
- if (!finalTitle) finalTitle = factTitle(entries[idx]) || autoGenerateTitle(finalFact);
304
-
305
- entries[idx] = formatFactEntry({
306
- date: p.date,
307
- time: p.time,
308
- text: `**${finalTitle}** — ${finalFact}`,
309
- meta: p.meta,
310
- });
311
- await writeMemory(key, entries);
312
-
313
- let linksUpdated = 0;
314
- try {
315
- const { getDatabase } = await import("../../db/database.js");
316
- const db = await getDatabase();
317
- const res = await db
318
- .prepare("UPDATE knowledge_links SET fact_text = ? WHERE fact_key = ? AND fact_text = ?")
319
- .run(finalFact, key, oldBody);
320
- linksUpdated = res.changes;
321
- } catch {}
322
-
323
- return `Fact updated${linksUpdated ? `, ${linksUpdated} doc link(s) updated` : ""}`;
324
- }
325
-
326
- export async function memoryInfo(_args = {}, ctx = {}) {
327
- const dbPath = join(MEMORY_DIR, "storage", "memory.sqlite");
328
- const activeKey = await projectKey(ctx.worktree ?? null, ctx.directory ?? null);
329
- const globalFile = storeFilePath(GLOBAL_KEY);
330
- const projectFile = storeFilePath(activeKey);
331
-
332
- let version = "unknown";
333
- try {
334
- version = JSON.parse(await readFile(new URL("../../../package.json", import.meta.url), "utf-8")).version;
335
- } catch {}
336
-
337
- const rag = {};
338
- try {
339
- const { getDatabase } = await import("../../db/database.js");
340
- const db = await getDatabase();
341
- const count = async (table) => {
342
- const row = await db.prepare(`SELECT COUNT(*) AS c FROM ${table}`).get();
343
- return row ? row.c : 0;
344
- };
345
- rag.documents = await count("documents");
346
- rag.sections = await count("sections");
347
- rag.chunks = await count("micro_chunks");
348
- rag.edges = await count("graph_edges");
349
- rag.links = await count("knowledge_links");
350
- } catch (e) {
351
- rag.error = e.message;
352
- }
353
-
354
- const stores = await listProjectStores();
355
-
356
- const identityLines = [];
357
- try {
358
- const { getDatabase } = await import("../../db/database.js");
359
- const { resolveProjectIdentity, listIdentities } = await import("../../identity.js");
360
- const db = await getDatabase();
361
- const identity = await resolveProjectIdentity(ctx.worktree || ctx.directory || process.cwd());
362
- const all = await listIdentities(db);
363
- identityLines.push(
364
- `Identity: ${identity ? "git" : "no-git"}` +
365
- (identity
366
- ? ` | key: ${identity.key} | name: ${identity.name}${
367
- identity.primaryRemote ? ` | remote: ${identity.primaryRemote}` : ""
368
- }`
369
- : ""),
370
- `Known identities: ${all.length}`
371
- );
372
- } catch (e) {
373
- identityLines.push(`Identity: unavailable (${e.message})`);
374
- }
375
-
376
- const lines = [
377
- `Version: ${version}`,
378
- `MEMORY_DIR: ${MEMORY_DIR}`,
379
- `SQLite DB: ${dbPath}`,
380
- `Global store: ${globalFile}`,
381
- `Project store: ${projectFile}`,
382
- `Project stores: ${stores.length}`,
383
- `Facts (global): ${(await readMemoryRaw(GLOBAL_KEY)).length}`,
384
- `Facts (project): ${(await readMemoryRaw(activeKey)).length}`,
385
- ...identityLines,
386
- ];
387
- if (rag.error) lines.push(`RAG: unavailable (${rag.error})`);
388
- else
389
- lines.push(
390
- `RAG: ${rag.documents} doc(s), ${rag.sections} section(s), ${rag.chunks} chunk(s), ${rag.edges} edge(s), ${rag.links} memory link(s)`
391
- );
392
- return lines.join("\n");
393
- }
1
+ import { readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import {
4
+ readMemory,
5
+ readMemoryRaw,
6
+ writeMemory,
7
+ today,
8
+ MEMORY_DIR,
9
+ GLOBAL_KEY,
10
+ scopeKey,
11
+ projectKey,
12
+ projectName,
13
+ canonicalPath,
14
+ listProjectStores,
15
+ storeFilePath,
16
+ } from "../../memory.js";
17
+ import {
18
+ parseFactEntry,
19
+ factText,
20
+ factMeta,
21
+ withMeta,
22
+ nextFactId,
23
+ isKeepFact,
24
+ isExpiredLine,
25
+ isSuperseded,
26
+ formatFactEntry,
27
+ matchesQuery,
28
+ matchesTags,
29
+ inDateRange,
30
+ factTitle,
31
+ factBody,
32
+ autoGenerateTitle,
33
+ } from "../../fact_format.js";
34
+ import { requireProjectKey, resolveFactIndex } from "../helpers.js";
35
+
36
+ // Single implementation of the Notebook tools, shared by the MCP server
37
+ // (mcp-server/tools/memory_tools.js) and the OpenCode plugin
38
+ // (opencode-plugin/index.js). Both used to carry their own copy, so bug fixes
39
+ // in one never reached the other. Every function returns a plain string; the
40
+ // callers wrap it in whatever envelope their host expects.
41
+ //
42
+ // `ctx` carries the host's notion of the current location:
43
+ // { worktree, directory } — the MCP server passes nothing and falls back to cwd.
44
+
45
+ const TITLE_PATTERN = /^\*\*([^*]+)\*\*\s*(?:—|--|-|:)?\s*(.*)$/;
46
+
47
+ function splitTitle(rawText, explicitTitle) {
48
+ let finalTitle = explicitTitle ? explicitTitle.trim() : null;
49
+ let finalFact = String(rawText || "").trim();
50
+ const match = TITLE_PATTERN.exec(finalFact);
51
+ if (match) {
52
+ if (!finalTitle) finalTitle = match[1].trim();
53
+ finalFact = match[2].trim();
54
+ }
55
+ return { finalTitle, finalFact };
56
+ }
57
+
58
+ function extractEffectiveDir(args = {}, ctx = {}) {
59
+ return (
60
+ args?.directory ||
61
+ args?.project ||
62
+ ctx?.directory ||
63
+ ctx?.worktree ||
64
+ null
65
+ );
66
+ }
67
+
68
+ async function resolveScopeKey(scope, args = {}, ctx = {}) {
69
+ const dir = extractEffectiveDir(args, ctx);
70
+ return await scopeKey(scope || "project", ctx?.worktree ?? null, dir);
71
+ }
72
+
73
+ export async function rememberFact(
74
+ { fact, title, scope, directory, project, docId, startLine, endLine, relationType, ttl, keep, tags, supersedes },
75
+ ctx = {}
76
+ ) {
77
+ const key = requireProjectKey(await resolveScopeKey(scope, { directory, project }, ctx));
78
+ const entries = await readMemory(key);
79
+
80
+ let { finalTitle, finalFact } = splitTitle(fact, title);
81
+ if (!finalTitle) finalTitle = autoGenerateTitle(finalFact);
82
+
83
+ const text = `**${finalTitle}** — ${finalFact}`;
84
+ const factBodyNormalized = finalFact.toLowerCase();
85
+ const duplicate = entries.some((e) => factBody(e).toLowerCase().trim() === factBodyNormalized);
86
+
87
+ let supersededInfo = "";
88
+ if (!duplicate) {
89
+ const [date, time] = today().split(" ");
90
+ const meta = { ttl, tags };
91
+ if (keep) meta.keep = "1";
92
+ if (supersedes) {
93
+ const targetIdx = resolveFactIndex(entries, supersedes);
94
+ if (targetIdx !== -1) {
95
+ let targetId = factMeta(entries[targetIdx]).id;
96
+ if (!targetId) {
97
+ targetId = nextFactId(entries);
98
+ entries[targetIdx] = withMeta(entries[targetIdx], { id: targetId });
99
+ }
100
+ const newId = nextFactId(entries);
101
+ entries[targetIdx] = withMeta(entries[targetIdx], { id: targetId, supersededBy: newId });
102
+ meta.id = newId;
103
+ meta.supersedes = targetId;
104
+ supersededInfo = ` [superseded: "${factText(entries[targetIdx]).slice(0, 60)}"]`;
105
+ } else {
106
+ supersededInfo = " (note: supersedes target not found)";
107
+ }
108
+ }
109
+ if (!meta.id) meta.id = nextFactId(entries);
110
+ entries.push(formatFactEntry({ date, time, text, meta }));
111
+ await writeMemory(key, entries);
112
+ }
113
+
114
+ let linkInfo = "";
115
+ if (docId) {
116
+ try {
117
+ const { linkFactToDocument } = await import("../../graph/knowledge_linker.js");
118
+ const linkRes = await linkFactToDocument({
119
+ factKey: key,
120
+ factText: finalFact,
121
+ docId,
122
+ startLine,
123
+ endLine,
124
+ relationType: relationType || "LINKS_TO",
125
+ });
126
+ const linesStr = startLine ? `:L${startLine}${endLine ? `-${endLine}` : ""}` : "";
127
+ linkInfo = ` [Linked to Doc: "${linkRes.docTitle}"${linesStr}]`;
128
+ } catch (err) {
129
+ linkInfo = ` (Note: Fact saved, but document link failed: ${err.message})`;
130
+ }
131
+ }
132
+
133
+ return `Memory updated${supersededInfo}${linkInfo}`;
134
+ }
135
+
136
+ async function resolveTargetKey(projectPath) {
137
+ if (!projectPath) return null;
138
+ if (typeof projectPath === "string" && (projectPath.startsWith("git:") || projectPath.startsWith("git_") || projectPath === GLOBAL_KEY)) {
139
+ return projectPath;
140
+ }
141
+ try {
142
+ const { resolveProjectIdentity } = await import("../../identity.js");
143
+ const identity = await resolveProjectIdentity(projectPath);
144
+ if (identity) return identity.key;
145
+ } catch {}
146
+ return canonicalPath(projectPath);
147
+ }
148
+
149
+ export async function recallFacts(
150
+ { scope, directory, project, query, tags, since, until, mode, offset, limit, includeSuperseded = false },
151
+ ctx = {}
152
+ ) {
153
+ const results = [];
154
+ const now = Date.now();
155
+ const targetMode = mode || "full";
156
+ const targetOffset = offset !== undefined && offset !== null ? offset : 0;
157
+ const targetProjectInput = directory || project || ctx.directory || ctx.worktree || null;
158
+
159
+ if (scope === "list_projects") {
160
+ const stores = await listProjectStores();
161
+ if (!stores.length) return "No project memory stores found.";
162
+ const lines = stores.map(
163
+ (s, i) =>
164
+ `${i + 1}. ${s.basename} — ${s.count} fact(s) [${s.file}]${
165
+ s.path ? ` (bound to ${s.path})` : " (unbound legacy store)"
166
+ }`
167
+ );
168
+ return `Project Memory Stores:\n${lines.join(
169
+ "\n"
170
+ )}\n\nUse recall(scope: "project", directory: "<path>") to read a specific store.\n\nMemory dir: ${MEMORY_DIR}`;
171
+ }
172
+
173
+ let getLinksForFact = null;
174
+ try {
175
+ ({ getLinksForFact } = await import("../../graph/knowledge_linker.js"));
176
+ } catch {}
177
+
178
+ const target =
179
+ (await resolveTargetKey(targetProjectInput)) ?? (await projectKey(ctx.worktree ?? null, targetProjectInput));
180
+ const label = targetProjectInput ? target : await projectName(ctx.worktree ?? null, targetProjectInput);
181
+
182
+ const formatFactWithLinks = async (factLine, index, key) => {
183
+ const p = parseFactEntry(factLine);
184
+ if (!p) return factLine;
185
+
186
+ const meta = p.meta;
187
+ const badges = [];
188
+ if (isExpiredLine(factLine, now)) badges.push("EXPIRED");
189
+ if (isKeepFact(factLine)) badges.push("KEEP");
190
+ if (isSuperseded(factLine)) badges.push("SUPERSEDED");
191
+ if (meta.inject === "1") badges.push("INJECT");
192
+ if (meta.id) badges.push(`id:${meta.id}`);
193
+ if (meta.tags) badges.push(`tags:${meta.tags}`);
194
+ badges.push(`${p.date} ${p.time}`);
195
+ const badgesStr = badges.length ? ` [${badges.join("] [")}]` : "";
196
+
197
+ let lineText =
198
+ targetMode === "headers" ? `**${factTitle(factLine)}**${badgesStr}` : `${p.text}${badgesStr}`;
199
+
200
+ if (getLinksForFact) {
201
+ try {
202
+ const links = await getLinksForFact(key, p.text);
203
+ if (links && links.length > 0) {
204
+ const docStr = links
205
+ .map((l) => {
206
+ const range = l.start_line ? `:L${l.start_line}${l.end_line ? `-${l.end_line}` : ""}` : "";
207
+ return `${l.doc_title || l.doc_path}${range}`;
208
+ })
209
+ .join(", ");
210
+ lineText += ` 🔗 [Linked Docs: ${docStr}]`;
211
+ }
212
+ } catch {}
213
+ }
214
+ return `${index}. ${lineText}`;
215
+ };
216
+
217
+ const collect = async (entries, key) => {
218
+ const matched = entries
219
+ .map((entry, storageIndex) => ({ entry, storageIndex }))
220
+ .filter(
221
+ ({ entry }) =>
222
+ (includeSuperseded || !isSuperseded(entry)) &&
223
+ matchesQuery(entry, query) &&
224
+ matchesTags(entry, tags) &&
225
+ inDateRange(entry, since, until)
226
+ );
227
+ if (!matched.length) return;
228
+ if (results.length) results.push("");
229
+ results.push(`--- ${key === GLOBAL_KEY ? "Global" : `Project: ${key === target ? label : key}`} ---`);
230
+
231
+ const hasLimit = limit !== undefined && limit !== null;
232
+ const targetLimit = hasLimit ? limit : matched.length;
233
+ const paginated = matched.slice(targetOffset, targetOffset + targetLimit);
234
+ for (let i = 0; i < paginated.length; i++) {
235
+ results.push(
236
+ await formatFactWithLinks(paginated[i].entry, paginated[i].storageIndex + 1, key)
237
+ );
238
+ }
239
+ if (hasLimit && matched.length > targetLimit) {
240
+ results.push(
241
+ `Showing entries ${targetOffset + 1}-${Math.min(targetOffset + targetLimit, matched.length)} of ${matched.length}`
242
+ );
243
+ }
244
+ results.push(`Store file: ${storeFilePath(key)}`);
245
+ };
246
+
247
+ if (scope !== "project") await collect(await readMemory(GLOBAL_KEY), GLOBAL_KEY);
248
+ if (scope !== "global" && target) await collect(await readMemory(target), target);
249
+
250
+ const filtered = Boolean(query || tags || since || until);
251
+ if (!results.length) return filtered ? "No facts match the search." : "Memory is empty.";
252
+ return `${results.join("\n")}\n\nMemory dir: ${MEMORY_DIR}`;
253
+ }
254
+
255
+ export async function getFactById({ id, scope, directory, project }, ctx = {}) {
256
+ const targetId = String(id || "").trim();
257
+ if (!targetId) throw new Error("ID parameter is required.");
258
+
259
+ const targetDir = extractEffectiveDir({ directory, project }, ctx);
260
+ const results = [];
261
+ const check = async (key) => {
262
+ const entries = await readMemory(key);
263
+ const match = entries.find((e) => factMeta(e).id === targetId);
264
+ if (match) {
265
+ results.push({ key, title: factTitle(match), body: factBody(match), meta: factMeta(match), line: match });
266
+ }
267
+ };
268
+
269
+ if (scope !== "project") await check(GLOBAL_KEY);
270
+ if (scope !== "global") {
271
+ const projKey = await projectKey(ctx.worktree ?? null, targetDir);
272
+ if (projKey) await check(projKey);
273
+ }
274
+
275
+ if (!results.length) return `Fact with ID "${targetId}" not found.`;
276
+
277
+ return results
278
+ .map((r) => {
279
+ const metaStr = Object.entries(r.meta)
280
+ .map(([k, v]) => `${k}:${v}`)
281
+ .join(", ");
282
+ return `[Store: ${r.key === GLOBAL_KEY ? "Global" : "Project"}]\nTitle: ${r.title}\nBody: ${r.body}\nMetadata: ${
283
+ metaStr ? `<!-- ${metaStr} -->` : "none"
284
+ }`;
285
+ })
286
+ .join("\n\n");
287
+ }
288
+
289
+ export async function forgetFacts({ query, scope, force, directory, project }, ctx = {}) {
290
+ const key = requireProjectKey(await resolveScopeKey(scope, { directory, project }, ctx));
291
+ const entries = await readMemory(key);
292
+
293
+ const rangeMatch = /^\s*(\d+)\s*-\s*(\d+)\s*$/.exec(query);
294
+ let indices = [];
295
+ if (rangeMatch) {
296
+ const from = parseInt(rangeMatch[1], 10);
297
+ const to = parseInt(rangeMatch[2], 10);
298
+ if (from > 0 && to >= from && to <= entries.length) {
299
+ for (let i = from - 1; i < to; i++) indices.push(i);
300
+ }
301
+ }
302
+ if (!indices.length && /^\s*\d+\s*$/.test(String(query))) {
303
+ const num = parseInt(query, 10);
304
+ if (num > 0 && num <= entries.length) indices.push(num - 1);
305
+ }
306
+ if (!indices.length) {
307
+ const q = String(query).toLowerCase();
308
+ indices = entries.reduce((acc, e, i) => (e.toLowerCase().includes(q) ? acc.concat(i) : acc), []);
309
+ }
310
+ if (!indices.length) return "Not found.";
311
+
312
+ const removable = indices.filter((i) => force || !isKeepFact(entries[i]));
313
+ const protectedCount = indices.length - removable.length;
314
+ if (removable.length) {
315
+ const removedBodies = removable.map((i) => factBody(entries[i]) || factText(entries[i]));
316
+ for (const i of removable.sort((a, b) => b - a)) entries.splice(i, 1);
317
+ await writeMemory(key, entries);
318
+ try {
319
+ const { getDatabase } = await import("../../db/database.js");
320
+ const { deleteLinksForFacts } = await import("../../graph/knowledge_linker.js");
321
+ await deleteLinksForFacts(await getDatabase(), key, removedBodies);
322
+ } catch {}
323
+ }
324
+ let text = removable.length ? "Memory updated" : "Nothing removed.";
325
+ if (protectedCount) text += ` (${protectedCount} protected fact(s) skipped; use force=true to override)`;
326
+ return text;
327
+ }
328
+
329
+ export async function updateFactText({ id, newText, title, scope, directory, project }, ctx = {}) {
330
+ const key = requireProjectKey(await resolveScopeKey(scope, { directory, project }, ctx));
331
+ const entries = await readMemory(key);
332
+ const idx = resolveFactIndex(entries, id);
333
+ if (idx === -1) throw new Error(`Fact not found: ${id}`);
334
+
335
+ const p = parseFactEntry(entries[idx]);
336
+ const oldText = p ? p.text : entries[idx];
337
+ const oldBody = factBody(entries[idx]) || oldText;
338
+
339
+ let { finalTitle, finalFact } = splitTitle(newText, title);
340
+ if (!finalTitle) finalTitle = factTitle(entries[idx]) || autoGenerateTitle(finalFact);
341
+
342
+ entries[idx] = formatFactEntry({
343
+ date: p.date,
344
+ time: p.time,
345
+ text: `**${finalTitle}** ${finalFact}`,
346
+ meta: p.meta,
347
+ });
348
+ await writeMemory(key, entries);
349
+
350
+ let linksUpdated = 0;
351
+ try {
352
+ const { getDatabase } = await import("../../db/database.js");
353
+ const db = await getDatabase();
354
+ const linkedRows = await db
355
+ .prepare("SELECT * FROM knowledge_links WHERE fact_key = ? AND fact_text = ?")
356
+ .all(key, oldBody);
357
+ const res = await db
358
+ .prepare("UPDATE knowledge_links SET fact_text = ? WHERE fact_key = ? AND fact_text = ?")
359
+ .run(finalFact, key, oldBody);
360
+ linksUpdated = res.changes;
361
+ if (linksUpdated) {
362
+ const { queueDocumentSyncIfNeeded } = await import("../../graph/knowledge_linker.js");
363
+ const docIds = new Set();
364
+ for (const link of linkedRows) {
365
+ const targetSpec = link.start_line
366
+ ? `${link.doc_id}:L${link.start_line}-${link.end_line || link.start_line}`
367
+ : link.doc_id;
368
+ await db.prepare(
369
+ "DELETE FROM graph_edges WHERE source_id = ? AND target_id = ? AND relation_type = ?"
370
+ ).run(
371
+ `fact:${key}:${oldBody.substring(0, 30)}`,
372
+ targetSpec,
373
+ link.relation_type || "LINKS_TO"
374
+ );
375
+ await db.prepare(`
376
+ INSERT OR IGNORE INTO graph_edges (source_id, target_id, relation_type, metadata_json, created_at)
377
+ VALUES (?, ?, ?, ?, ?)
378
+ `).run(
379
+ `fact:${key}:${finalFact.substring(0, 30)}`,
380
+ targetSpec,
381
+ link.relation_type || "LINKS_TO",
382
+ link.metadata_json || JSON.stringify({ linkId: link.id }),
383
+ link.created_at || Date.now()
384
+ );
385
+ docIds.add(link.doc_id);
386
+ }
387
+ for (const docId of docIds) await queueDocumentSyncIfNeeded(db, docId);
388
+ }
389
+ } catch {}
390
+
391
+ return `Fact updated${linksUpdated ? `, ${linksUpdated} doc link(s) updated` : ""}`;
392
+ }
393
+
394
+ export async function memoryInfo(_args = {}, ctx = {}) {
395
+ const effectiveDir = extractEffectiveDir(_args, ctx) || process.cwd();
396
+ const dbPath = join(MEMORY_DIR, "storage", "memory.sqlite");
397
+ const activeKey = await projectKey(ctx.worktree ?? null, effectiveDir);
398
+ const globalFile = storeFilePath(GLOBAL_KEY);
399
+ const projectFile = activeKey ? storeFilePath(activeKey) : null;
400
+
401
+ let version = "unknown";
402
+ try {
403
+ version = JSON.parse(await readFile(new URL("../../../package.json", import.meta.url), "utf-8")).version;
404
+ } catch {}
405
+
406
+ const rag = {};
407
+ try {
408
+ const { getDatabase } = await import("../../db/database.js");
409
+ const db = await getDatabase();
410
+ const count = async (table) => {
411
+ const row = await db.prepare(`SELECT COUNT(*) AS c FROM ${table}`).get();
412
+ return row ? row.c : 0;
413
+ };
414
+ rag.documents = await count("documents");
415
+ rag.sections = await count("sections");
416
+ rag.chunks = await count("micro_chunks");
417
+ rag.edges = await count("graph_edges");
418
+ rag.links = await count("knowledge_links");
419
+ } catch (e) {
420
+ rag.error = e.message;
421
+ }
422
+
423
+ const stores = await listProjectStores();
424
+
425
+ const identityLines = [];
426
+ try {
427
+ const { getDatabase } = await import("../../db/database.js");
428
+ const { resolveProjectIdentity, listIdentities } = await import("../../identity.js");
429
+ const db = await getDatabase();
430
+ const identity = await resolveProjectIdentity(effectiveDir);
431
+ const all = await listIdentities(db);
432
+ const registered = identity ? all.find((item) => item.key === identity.key) : null;
433
+ identityLines.push(
434
+ `Identity: ${identity ? "git" : "no-git"}` +
435
+ (identity
436
+ ? ` | key: ${identity.key} | name: ${identity.name}${
437
+ identity.primaryRemote ? ` | remote: ${identity.primaryRemote}` : ""
438
+ }`
439
+ : ""),
440
+ `Registry: ${identity ? (registered ? "linked" : "unlinked") : "not-applicable"}` +
441
+ (registered ? ` | aliases: ${registered.aliases.length}` : ""),
442
+ `Known identities: ${all.length}`
443
+ );
444
+ } catch (e) {
445
+ identityLines.push(`Identity: unavailable (${e.message})`);
446
+ }
447
+
448
+ const lines = [
449
+ `Version: ${version}`,
450
+ `MEMORY_DIR: ${MEMORY_DIR}`,
451
+ `SQLite DB: ${dbPath}`,
452
+ `Global store: ${globalFile}`,
453
+ `Project store: ${projectFile || "not applicable (outside Git)"}`,
454
+ `Project stores: ${stores.length}`,
455
+ `Facts (global): ${(await readMemoryRaw(GLOBAL_KEY)).length}`,
456
+ `Facts (project): ${(activeKey ? await readMemoryRaw(activeKey) : []).length}`,
457
+ ...identityLines,
458
+ ];
459
+ if (rag.error) lines.push(`RAG: unavailable (${rag.error})`);
460
+ else
461
+ lines.push(
462
+ `RAG: ${rag.documents} doc(s), ${rag.sections} section(s), ${rag.chunks} chunk(s), ${rag.edges} edge(s), ${rag.links} memory link(s)`
463
+ );
464
+ return lines.join("\n");
465
+ }