@lotargo/memory_plugin 1.6.4 → 1.6.6

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,81 +1,92 @@
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(" ");
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(" ");
79
90
  const meta = { ttl, tags };
80
91
  if (keep) meta.keep = "1";
81
92
  if (supersedes) {
@@ -89,117 +100,121 @@ export async function rememberFact(
89
100
  const newId = nextFactId(entries);
90
101
  entries[targetIdx] = withMeta(entries[targetIdx], { id: targetId, supersededBy: newId });
91
102
  meta.id = newId;
92
- meta.supersedes = targetId;
93
- supersededInfo = ` [superseded: "${factText(entries[targetIdx]).slice(0, 60)}"]`;
94
- } else {
95
- supersededInfo = " (note: supersedes target not found)";
96
- }
97
- }
98
- if (!meta.id) meta.id = nextFactId(entries);
99
- entries.push(formatFactEntry({ date, time, text, meta }));
100
- await writeMemory(key, entries);
101
- }
102
-
103
- let linkInfo = "";
104
- if (docId) {
105
- try {
106
- const { linkFactToDocument } = await import("../../graph/knowledge_linker.js");
107
- const linkRes = await linkFactToDocument({
108
- factKey: key,
109
- factText: finalFact,
110
- docId,
111
- startLine,
112
- endLine,
113
- relationType: relationType || "LINKS_TO",
114
- });
115
- const linesStr = startLine ? `:L${startLine}${endLine ? `-${endLine}` : ""}` : "";
116
- linkInfo = ` [Linked to Doc: "${linkRes.docTitle}"${linesStr}]`;
117
- } catch (err) {
118
- linkInfo = ` (Note: Fact saved, but document link failed: ${err.message})`;
119
- }
120
- }
121
-
122
- return `Memory updated${supersededInfo}${linkInfo}`;
123
- }
124
-
125
- async function resolveTargetKey(projectPath) {
126
- if (!projectPath) return null;
127
- try {
128
- const { resolveProjectIdentity } = await import("../../identity.js");
129
- const identity = await resolveProjectIdentity(projectPath);
130
- if (identity) return identity.key;
131
- } catch {}
132
- return canonicalPath(projectPath);
133
- }
134
-
135
- export async function recallFacts(
136
- { scope, project, query, tags, since, until, mode, offset, limit, includeSuperseded = false },
137
- ctx = {}
138
- ) {
139
- const results = [];
140
- const now = Date.now();
141
- const targetMode = mode || "full";
142
- const targetOffset = offset !== undefined && offset !== null ? offset : 0;
143
-
144
- if (scope === "list_projects") {
145
- const stores = await listProjectStores();
146
- if (!stores.length) return "No project memory stores found.";
147
- const lines = stores.map(
148
- (s, i) =>
149
- `${i + 1}. ${s.basename} — ${s.count} fact(s) [${s.file}]${
150
- s.path ? ` (bound to ${s.path})` : " (unbound legacy store)"
151
- }`
152
- );
153
- return `Project Memory Stores:\n${lines.join(
154
- "\n"
155
- )}\n\nUse recall(scope: "project", project: "<path>") to read a specific store.\n\nMemory dir: ${MEMORY_DIR}`;
156
- }
157
-
158
- let getLinksForFact = null;
159
- try {
160
- ({ getLinksForFact } = await import("../../graph/knowledge_linker.js"));
161
- } catch {}
162
-
163
- const target =
164
- (await resolveTargetKey(project)) ?? (await projectKey(ctx.worktree ?? null, ctx.directory ?? null));
165
- const label = project ? target : await projectName(ctx.worktree ?? null, ctx.directory ?? null);
166
-
167
- const formatFactWithLinks = async (factLine, index, key) => {
168
- const p = parseFactEntry(factLine);
169
- if (!p) return factLine;
170
-
171
- const meta = p.meta;
172
- const badges = [];
173
- if (isExpiredLine(factLine, now)) badges.push("EXPIRED");
174
- if (isKeepFact(factLine)) badges.push("KEEP");
175
- if (isSuperseded(factLine)) badges.push("SUPERSEDED");
176
- if (meta.inject === "1") badges.push("INJECT");
177
- if (meta.id) badges.push(`id:${meta.id}`);
178
- if (meta.tags) badges.push(`tags:${meta.tags}`);
179
- badges.push(`${p.date} ${p.time}`);
180
- const badgesStr = badges.length ? ` [${badges.join("] [")}]` : "";
181
-
182
- let lineText =
183
- targetMode === "headers" ? `**${factTitle(factLine)}**${badgesStr}` : `${p.text}${badgesStr}`;
184
-
185
- if (getLinksForFact) {
186
- try {
187
- const links = await getLinksForFact(key, p.text);
188
- if (links && links.length > 0) {
189
- const docStr = links
190
- .map((l) => {
191
- const range = l.start_line ? `:L${l.start_line}${l.end_line ? `-${l.end_line}` : ""}` : "";
192
- return `${l.doc_title || l.doc_path}${range}`;
193
- })
194
- .join(", ");
195
- lineText += ` 🔗 [Linked Docs: ${docStr}]`;
196
- }
197
- } catch {}
198
- }
199
- return `${index}. ${lineText}`;
200
- };
201
-
202
- const collect = async (entries, key) => {
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) => {
203
218
  const matched = entries
204
219
  .map((entry, storageIndex) => ({ entry, storageIndex }))
205
220
  .filter(
@@ -209,88 +224,92 @@ export async function recallFacts(
209
224
  matchesTags(entry, tags) &&
210
225
  inDateRange(entry, since, until)
211
226
  );
212
- if (!matched.length) return;
213
- if (results.length) results.push("");
214
- results.push(`--- ${key === GLOBAL_KEY ? "Global" : `Project: ${key === target ? label : key}`} ---`);
215
-
216
- const hasLimit = limit !== undefined && limit !== null;
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;
217
232
  const targetLimit = hasLimit ? limit : matched.length;
218
233
  const paginated = matched.slice(targetOffset, targetOffset + targetLimit);
219
234
  for (let i = 0; i < paginated.length; i++) {
220
235
  results.push(
221
236
  await formatFactWithLinks(paginated[i].entry, paginated[i].storageIndex + 1, key)
222
237
  );
223
- }
224
- if (hasLimit && matched.length > targetLimit) {
225
- results.push(
226
- `Showing entries ${targetOffset + 1}-${Math.min(targetOffset + targetLimit, matched.length)} of ${matched.length}`
227
- );
228
- }
229
- results.push(`Store file: ${storeFilePath(key)}`);
230
- };
231
-
232
- if (scope !== "project") await collect(await readMemory(GLOBAL_KEY), GLOBAL_KEY);
233
- if (scope !== "global") await collect(await readMemory(target), target);
234
-
235
- const filtered = Boolean(query || tags || since || until);
236
- if (!results.length) return filtered ? "No facts match the search." : "Memory is empty.";
237
- return `${results.join("\n")}\n\nMemory dir: ${MEMORY_DIR}`;
238
- }
239
-
240
- export async function getFactById({ id, scope }, ctx = {}) {
241
- const targetId = String(id || "").trim();
242
- if (!targetId) throw new Error("ID parameter is required.");
243
-
244
- const results = [];
245
- const check = async (key) => {
246
- const entries = await readMemory(key);
247
- const match = entries.find((e) => factMeta(e).id === targetId);
248
- if (match) {
249
- results.push({ key, title: factTitle(match), body: factBody(match), meta: factMeta(match), line: match });
250
- }
251
- };
252
-
253
- if (scope !== "project") await check(GLOBAL_KEY);
254
- if (scope !== "global") await check(await projectKey(ctx.worktree ?? null, ctx.directory ?? null));
255
-
256
- if (!results.length) return `Fact with ID "${targetId}" not found.`;
257
-
258
- return results
259
- .map((r) => {
260
- const metaStr = Object.entries(r.meta)
261
- .map(([k, v]) => `${k}:${v}`)
262
- .join(", ");
263
- return `[Store: ${r.key === GLOBAL_KEY ? "Global" : "Project"}]\nTitle: ${r.title}\nBody: ${r.body}\nMetadata: ${
264
- metaStr ? `<!-- ${metaStr} -->` : "none"
265
- }`;
266
- })
267
- .join("\n\n");
268
- }
269
-
270
- export async function forgetFacts({ query, scope, force }, ctx = {}) {
271
- const key = requireProjectKey(await resolveScopeKey(scope, ctx));
272
- const entries = await readMemory(key);
273
-
274
- const rangeMatch = /^\s*(\d+)\s*-\s*(\d+)\s*$/.exec(query);
275
- let indices = [];
276
- if (rangeMatch) {
277
- const from = parseInt(rangeMatch[1], 10);
278
- const to = parseInt(rangeMatch[2], 10);
279
- if (from > 0 && to >= from && to <= entries.length) {
280
- for (let i = from - 1; i < to; i++) indices.push(i);
281
- }
282
- }
283
- if (!indices.length && /^\s*\d+\s*$/.test(String(query))) {
284
- const num = parseInt(query, 10);
285
- if (num > 0 && num <= entries.length) indices.push(num - 1);
286
- }
287
- if (!indices.length) {
288
- const q = String(query).toLowerCase();
289
- indices = entries.reduce((acc, e, i) => (e.toLowerCase().includes(q) ? acc.concat(i) : acc), []);
290
- }
291
- if (!indices.length) return "Not found.";
292
-
293
- const removable = indices.filter((i) => force || !isKeepFact(entries[i]));
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]));
294
313
  const protectedCount = indices.length - removable.length;
295
314
  if (removable.length) {
296
315
  const removedBodies = removable.map((i) => factBody(entries[i]) || factText(entries[i]));
@@ -302,34 +321,34 @@ export async function forgetFacts({ query, scope, force }, ctx = {}) {
302
321
  await deleteLinksForFacts(await getDatabase(), key, removedBodies);
303
322
  } catch {}
304
323
  }
305
- let text = removable.length ? "Memory updated" : "Nothing removed.";
306
- if (protectedCount) text += ` (${protectedCount} protected fact(s) skipped; use force=true to override)`;
307
- return text;
308
- }
309
-
310
- export async function updateFactText({ id, newText, title, scope }, ctx = {}) {
311
- const key = requireProjectKey(await resolveScopeKey(scope, ctx));
312
- const entries = await readMemory(key);
313
- const idx = resolveFactIndex(entries, id);
314
- if (idx === -1) throw new Error(`Fact not found: ${id}`);
315
-
316
- const p = parseFactEntry(entries[idx]);
317
- const oldText = p ? p.text : entries[idx];
318
- const oldBody = factBody(entries[idx]) || oldText;
319
-
320
- let { finalTitle, finalFact } = splitTitle(newText, title);
321
- if (!finalTitle) finalTitle = factTitle(entries[idx]) || autoGenerateTitle(finalFact);
322
-
323
- entries[idx] = formatFactEntry({
324
- date: p.date,
325
- time: p.time,
326
- text: `**${finalTitle}** — ${finalFact}`,
327
- meta: p.meta,
328
- });
329
- await writeMemory(key, entries);
330
-
331
- let linksUpdated = 0;
332
- try {
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 {
333
352
  const { getDatabase } = await import("../../db/database.js");
334
353
  const db = await getDatabase();
335
354
  const linkedRows = await db
@@ -368,78 +387,79 @@ export async function updateFactText({ id, newText, title, scope }, ctx = {}) {
368
387
  for (const docId of docIds) await queueDocumentSyncIfNeeded(db, docId);
369
388
  }
370
389
  } catch {}
371
-
372
- return `Fact updated${linksUpdated ? `, ${linksUpdated} doc link(s) updated` : ""}`;
373
- }
374
-
375
- export async function memoryInfo(_args = {}, ctx = {}) {
376
- const dbPath = join(MEMORY_DIR, "storage", "memory.sqlite");
377
- const activeKey = await projectKey(ctx.worktree ?? null, ctx.directory ?? null);
378
- const globalFile = storeFilePath(GLOBAL_KEY);
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);
379
399
  const projectFile = activeKey ? storeFilePath(activeKey) : null;
380
-
381
- let version = "unknown";
382
- try {
383
- version = JSON.parse(await readFile(new URL("../../../package.json", import.meta.url), "utf-8")).version;
384
- } catch {}
385
-
386
- const rag = {};
387
- try {
388
- const { getDatabase } = await import("../../db/database.js");
389
- const db = await getDatabase();
390
- const count = async (table) => {
391
- const row = await db.prepare(`SELECT COUNT(*) AS c FROM ${table}`).get();
392
- return row ? row.c : 0;
393
- };
394
- rag.documents = await count("documents");
395
- rag.sections = await count("sections");
396
- rag.chunks = await count("micro_chunks");
397
- rag.edges = await count("graph_edges");
398
- rag.links = await count("knowledge_links");
399
- } catch (e) {
400
- rag.error = e.message;
401
- }
402
-
403
- const stores = await listProjectStores();
404
-
405
- const identityLines = [];
406
- try {
407
- const { getDatabase } = await import("../../db/database.js");
408
- const { resolveProjectIdentity, listIdentities } = await import("../../identity.js");
409
- const db = await getDatabase();
410
- const identity = await resolveProjectIdentity(ctx.worktree || ctx.directory || process.cwd());
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);
411
431
  const all = await listIdentities(db);
412
432
  const registered = identity ? all.find((item) => item.key === identity.key) : null;
413
433
  identityLines.push(
414
434
  `Identity: ${identity ? "git" : "no-git"}` +
415
- (identity
416
- ? ` | key: ${identity.key} | name: ${identity.name}${
417
- identity.primaryRemote ? ` | remote: ${identity.primaryRemote}` : ""
435
+ (identity
436
+ ? ` | key: ${identity.key} | name: ${identity.name}${
437
+ identity.primaryRemote ? ` | remote: ${identity.primaryRemote}` : ""
418
438
  }`
419
439
  : ""),
420
440
  `Registry: ${identity ? (registered ? "linked" : "unlinked") : "not-applicable"}` +
421
441
  (registered ? ` | aliases: ${registered.aliases.length}` : ""),
422
442
  `Known identities: ${all.length}`
423
443
  );
424
- } catch (e) {
425
- identityLines.push(`Identity: unavailable (${e.message})`);
426
- }
427
-
428
- const lines = [
429
- `Version: ${version}`,
430
- `MEMORY_DIR: ${MEMORY_DIR}`,
431
- `SQLite DB: ${dbPath}`,
432
- `Global store: ${globalFile}`,
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}`,
433
453
  `Project store: ${projectFile || "not applicable (outside Git)"}`,
434
- `Project stores: ${stores.length}`,
435
- `Facts (global): ${(await readMemoryRaw(GLOBAL_KEY)).length}`,
436
- `Facts (project): ${(await readMemoryRaw(activeKey)).length}`,
437
- ...identityLines,
438
- ];
439
- if (rag.error) lines.push(`RAG: unavailable (${rag.error})`);
440
- else
441
- lines.push(
442
- `RAG: ${rag.documents} doc(s), ${rag.sections} section(s), ${rag.chunks} chunk(s), ${rag.edges} edge(s), ${rag.links} memory link(s)`
443
- );
444
- return lines.join("\n");
445
- }
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
+ }