@lotargo/memory_plugin 1.6.3 → 1.6.4

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.
@@ -76,15 +76,19 @@ export async function rememberFact(
76
76
  let supersededInfo = "";
77
77
  if (!duplicate) {
78
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;
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
+ let targetId = factMeta(entries[targetIdx]).id;
85
+ if (!targetId) {
86
+ targetId = nextFactId(entries);
87
+ entries[targetIdx] = withMeta(entries[targetIdx], { id: targetId });
88
+ }
89
+ const newId = nextFactId(entries);
90
+ entries[targetIdx] = withMeta(entries[targetIdx], { id: targetId, supersededBy: newId });
91
+ meta.id = newId;
88
92
  meta.supersedes = targetId;
89
93
  supersededInfo = ` [superseded: "${factText(entries[targetIdx]).slice(0, 60)}"]`;
90
94
  } else {
@@ -129,7 +133,7 @@ async function resolveTargetKey(projectPath) {
129
133
  }
130
134
 
131
135
  export async function recallFacts(
132
- { scope, project, query, tags, since, until, mode, offset, limit },
136
+ { scope, project, query, tags, since, until, mode, offset, limit, includeSuperseded = false },
133
137
  ctx = {}
134
138
  ) {
135
139
  const results = [];
@@ -196,18 +200,26 @@ export async function recallFacts(
196
200
  };
197
201
 
198
202
  const collect = async (entries, key) => {
199
- const matched = entries.filter(
200
- (e) => matchesQuery(e, query) && matchesTags(e, tags) && inDateRange(e, since, until)
201
- );
203
+ const matched = entries
204
+ .map((entry, storageIndex) => ({ entry, storageIndex }))
205
+ .filter(
206
+ ({ entry }) =>
207
+ (includeSuperseded || !isSuperseded(entry)) &&
208
+ matchesQuery(entry, query) &&
209
+ matchesTags(entry, tags) &&
210
+ inDateRange(entry, since, until)
211
+ );
202
212
  if (!matched.length) return;
203
213
  if (results.length) results.push("");
204
214
  results.push(`--- ${key === GLOBAL_KEY ? "Global" : `Project: ${key === target ? label : key}`} ---`);
205
215
 
206
216
  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));
217
+ const targetLimit = hasLimit ? limit : matched.length;
218
+ const paginated = matched.slice(targetOffset, targetOffset + targetLimit);
219
+ for (let i = 0; i < paginated.length; i++) {
220
+ results.push(
221
+ await formatFactWithLinks(paginated[i].entry, paginated[i].storageIndex + 1, key)
222
+ );
211
223
  }
212
224
  if (hasLimit && matched.length > targetLimit) {
213
225
  results.push(
@@ -279,11 +291,17 @@ export async function forgetFacts({ query, scope, force }, ctx = {}) {
279
291
  if (!indices.length) return "Not found.";
280
292
 
281
293
  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
- }
294
+ const protectedCount = indices.length - removable.length;
295
+ if (removable.length) {
296
+ const removedBodies = removable.map((i) => factBody(entries[i]) || factText(entries[i]));
297
+ for (const i of removable.sort((a, b) => b - a)) entries.splice(i, 1);
298
+ await writeMemory(key, entries);
299
+ try {
300
+ const { getDatabase } = await import("../../db/database.js");
301
+ const { deleteLinksForFacts } = await import("../../graph/knowledge_linker.js");
302
+ await deleteLinksForFacts(await getDatabase(), key, removedBodies);
303
+ } catch {}
304
+ }
287
305
  let text = removable.length ? "Memory updated" : "Nothing removed.";
288
306
  if (protectedCount) text += ` (${protectedCount} protected fact(s) skipped; use force=true to override)`;
289
307
  return text;
@@ -312,13 +330,44 @@ export async function updateFactText({ id, newText, title, scope }, ctx = {}) {
312
330
 
313
331
  let linksUpdated = 0;
314
332
  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 {}
333
+ const { getDatabase } = await import("../../db/database.js");
334
+ const db = await getDatabase();
335
+ const linkedRows = await db
336
+ .prepare("SELECT * FROM knowledge_links WHERE fact_key = ? AND fact_text = ?")
337
+ .all(key, oldBody);
338
+ const res = await db
339
+ .prepare("UPDATE knowledge_links SET fact_text = ? WHERE fact_key = ? AND fact_text = ?")
340
+ .run(finalFact, key, oldBody);
341
+ linksUpdated = res.changes;
342
+ if (linksUpdated) {
343
+ const { queueDocumentSyncIfNeeded } = await import("../../graph/knowledge_linker.js");
344
+ const docIds = new Set();
345
+ for (const link of linkedRows) {
346
+ const targetSpec = link.start_line
347
+ ? `${link.doc_id}:L${link.start_line}-${link.end_line || link.start_line}`
348
+ : link.doc_id;
349
+ await db.prepare(
350
+ "DELETE FROM graph_edges WHERE source_id = ? AND target_id = ? AND relation_type = ?"
351
+ ).run(
352
+ `fact:${key}:${oldBody.substring(0, 30)}`,
353
+ targetSpec,
354
+ link.relation_type || "LINKS_TO"
355
+ );
356
+ await db.prepare(`
357
+ INSERT OR IGNORE INTO graph_edges (source_id, target_id, relation_type, metadata_json, created_at)
358
+ VALUES (?, ?, ?, ?, ?)
359
+ `).run(
360
+ `fact:${key}:${finalFact.substring(0, 30)}`,
361
+ targetSpec,
362
+ link.relation_type || "LINKS_TO",
363
+ link.metadata_json || JSON.stringify({ linkId: link.id }),
364
+ link.created_at || Date.now()
365
+ );
366
+ docIds.add(link.doc_id);
367
+ }
368
+ for (const docId of docIds) await queueDocumentSyncIfNeeded(db, docId);
369
+ }
370
+ } catch {}
322
371
 
323
372
  return `Fact updated${linksUpdated ? `, ${linksUpdated} doc link(s) updated` : ""}`;
324
373
  }
@@ -327,7 +376,7 @@ export async function memoryInfo(_args = {}, ctx = {}) {
327
376
  const dbPath = join(MEMORY_DIR, "storage", "memory.sqlite");
328
377
  const activeKey = await projectKey(ctx.worktree ?? null, ctx.directory ?? null);
329
378
  const globalFile = storeFilePath(GLOBAL_KEY);
330
- const projectFile = storeFilePath(activeKey);
379
+ const projectFile = activeKey ? storeFilePath(activeKey) : null;
331
380
 
332
381
  let version = "unknown";
333
382
  try {
@@ -358,17 +407,20 @@ export async function memoryInfo(_args = {}, ctx = {}) {
358
407
  const { getDatabase } = await import("../../db/database.js");
359
408
  const { resolveProjectIdentity, listIdentities } = await import("../../identity.js");
360
409
  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"}` +
410
+ const identity = await resolveProjectIdentity(ctx.worktree || ctx.directory || process.cwd());
411
+ const all = await listIdentities(db);
412
+ const registered = identity ? all.find((item) => item.key === identity.key) : null;
413
+ identityLines.push(
414
+ `Identity: ${identity ? "git" : "no-git"}` +
365
415
  (identity
366
416
  ? ` | key: ${identity.key} | name: ${identity.name}${
367
417
  identity.primaryRemote ? ` | remote: ${identity.primaryRemote}` : ""
368
- }`
369
- : ""),
370
- `Known identities: ${all.length}`
371
- );
418
+ }`
419
+ : ""),
420
+ `Registry: ${identity ? (registered ? "linked" : "unlinked") : "not-applicable"}` +
421
+ (registered ? ` | aliases: ${registered.aliases.length}` : ""),
422
+ `Known identities: ${all.length}`
423
+ );
372
424
  } catch (e) {
373
425
  identityLines.push(`Identity: unavailable (${e.message})`);
374
426
  }
@@ -378,7 +430,7 @@ export async function memoryInfo(_args = {}, ctx = {}) {
378
430
  `MEMORY_DIR: ${MEMORY_DIR}`,
379
431
  `SQLite DB: ${dbPath}`,
380
432
  `Global store: ${globalFile}`,
381
- `Project store: ${projectFile}`,
433
+ `Project store: ${projectFile || "not applicable (outside Git)"}`,
382
434
  `Project stores: ${stores.length}`,
383
435
  `Facts (global): ${(await readMemoryRaw(GLOBAL_KEY)).length}`,
384
436
  `Facts (project): ${(await readMemoryRaw(activeKey)).length}`,
@@ -1,6 +1,6 @@
1
1
  import * as z from "zod/v4";
2
2
  import { basename } from "node:path";
3
- import { scopeKey, canonicalPath, readMemory, writeMemory, storeFilePath } from "../memory.js";
3
+ import { GLOBAL_KEY, scopeKey, canonicalPath, readMemory, writeMemory, storeFilePath } from "../memory.js";
4
4
  import { factBody } from "../fact_format.js";
5
5
  import { optStr, optNum, defStr, defBool, requireProjectKey } from "./helpers.js";
6
6
 
@@ -33,9 +33,18 @@ export function registerIdentityTools(server) {
33
33
  if (!factText || !docId) {
34
34
  throw new Error("factText and docId are required parameters for link action");
35
35
  }
36
+ const facts = await readMemory(key);
37
+ const needle = factText.toLowerCase().trim();
38
+ const matches = facts.filter((entry) => {
39
+ const body = factBody(entry).toLowerCase();
40
+ return body === needle || body.includes(needle) || entry.toLowerCase().includes(needle);
41
+ });
42
+ if (matches.length === 0) throw new Error(`Notebook fact not found for link: ${factText}`);
43
+ if (matches.length > 1) throw new Error(`Notebook fact match is ambiguous; use a more specific factText: ${factText}`);
44
+ const resolvedFactText = factBody(matches[0]);
36
45
  const res = await linkFactToDocument({
37
46
  factKey: key,
38
- factText,
47
+ factText: resolvedFactText,
39
48
  docId,
40
49
  startLine,
41
50
  endLine,
@@ -48,7 +57,8 @@ export function registerIdentityTools(server) {
48
57
 
49
58
  if (action === "get_doc_links") {
50
59
  if (!docId) throw new Error("docId parameter is required for get_doc_links action");
51
- const links = await getLinksForDoc(docId);
60
+ const allowedScopes = key === GLOBAL_KEY ? [GLOBAL_KEY] : [GLOBAL_KEY, key];
61
+ const links = await getLinksForDoc(docId, allowedScopes);
52
62
  return {
53
63
  content: [{ type: "text", text: JSON.stringify(links, null, 2) }],
54
64
  };
@@ -130,6 +140,9 @@ export function registerIdentityTools(server) {
130
140
  }
131
141
  } catch (e) {}
132
142
  }
143
+ const { moveKnowledgeScope } = await import("../graph/knowledge_linker.js");
144
+ const migratedKnowledge = await moveKnowledgeScope(db, legacyPathKey, key);
145
+ if (migratedKnowledge.movedLinks > 0 || migratedKnowledge.movedDocuments > 0) migrated = true;
133
146
 
134
147
  return {
135
148
  content: [
@@ -242,8 +255,10 @@ export function registerIdentityTools(server) {
242
255
 
243
256
  await writeMemory(targetKey, targetFacts);
244
257
 
245
- await db.prepare("UPDATE project_aliases SET identity_key = ? WHERE identity_key = ?;").run(targetKey, sourceKey);
246
258
  await upsertIdentity(db, { key: targetKey, name: sourceIdentity.name, primaryRemote: normalizeRemoteUrl(remote) });
259
+ await db.prepare("UPDATE project_aliases SET identity_key = ? WHERE identity_key = ?;").run(targetKey, sourceKey);
260
+ const { moveKnowledgeScope } = await import("../graph/knowledge_linker.js");
261
+ const movedKnowledge = await moveKnowledgeScope(db, sourceKey, targetKey);
247
262
  await removeIdentity(db, sourceKey);
248
263
 
249
264
  try {
@@ -265,6 +280,8 @@ export function registerIdentityTools(server) {
265
280
  sourceKey,
266
281
  targetKey,
267
282
  mergedFacts: mergedCount,
283
+ movedKnowledgeLinks: movedKnowledge.movedLinks,
284
+ movedRagDocuments: movedKnowledge.movedDocuments,
268
285
  },
269
286
  null,
270
287
  2
@@ -51,7 +51,8 @@ export function registerMemoryTools(server) {
51
51
  "Use project: '<directory path>' with scope 'project'/'all' to read facts of a specific project from any working directory. " +
52
52
  "query filters by keyword (all space-separated terms must match). " +
53
53
  "tags filters by comma-separated tags. since/until filter by date (YYYY-MM-DD, inclusive). " +
54
- "Expired facts are shown with [EXPIRED], protected ones with [KEEP]. The response includes the store file paths.",
54
+ "Superseded facts are excluded by default; pass includeSuperseded=true to inspect history. " +
55
+ "Expired facts are shown with [EXPIRED], protected ones with [KEEP]. The response includes the store file paths.",
55
56
  inputSchema: z.object({
56
57
  scope: defStr("all").describe("'project', 'global', 'all', or 'list_projects'"),
57
58
  project: optStr().describe("Directory path of the project to read facts from (e.g. 'F:/projects/plugins/memory')"),
@@ -61,7 +62,8 @@ export function registerMemoryTools(server) {
61
62
  until: optStr().describe("Optional end date filter, YYYY-MM-DD (inclusive)"),
62
63
  mode: z.enum(["headers", "full"]).nullish().transform((v) => v || "full").describe("Result mode: 'full' (with body, default) or 'headers' (title and badges only)"),
63
64
  offset: optNum().describe("Pagination offset (optional)"),
64
- limit: optNum().describe("Pagination limit (optional)"),
65
+ limit: optNum().describe("Pagination limit (optional)"),
66
+ includeSuperseded: defBool(false).describe("Include superseded historical facts (excluded by default)"),
65
67
  }),
66
68
  },
67
69
  async (args) => ({ content: [{ type: "text", text: await recallFacts(args) }] })
@@ -2,7 +2,8 @@ import * as z from "zod/v4";
2
2
  import { optStr, defBool, defNum, optNum } from "./helpers.js";
3
3
  import { MEMORY_DIR } from "../memory.js";
4
4
  import { registerSnapshotDir } from "../admin/snapshot.js";
5
- import { ensureExportsDir } from "../ingest/exporter.js";
5
+ import { ensureExportsDir } from "../ingest/exporter.js";
6
+ import { resolveRagScopeKey, resolveRagScopeKeys, resolveManageRagScopeKeys, removeDocumentScopes } from "../rag_scope.js";
6
7
 
7
8
  export function registerRagTools(server) {
8
9
  // Restrict snapshot export/import paths to the plugin's own data directories.
@@ -12,8 +13,8 @@ export function registerRagTools(server) {
12
13
  server.registerTool(
13
14
  "ingest_document",
14
15
  {
15
- description:
16
- "Ingest a document into the RAG knowledge base. " +
16
+ description:
17
+ "Selectively preserve a reliable, reusable source in the RAG knowledge base; do not ingest everything encountered. " +
17
18
  "Accepts local file paths, web URLs, or raw Markdown/text content. " +
18
19
  "For type='file' the file is read from disk and indexed with a code-block wrapper. " +
19
20
  "For type='url' the page is fetched and its content is indexed (not just the URL). " +
@@ -31,18 +32,21 @@ export function registerRagTools(server) {
31
32
  .transform((v) => v || "text")
32
33
  .describe("Input content type: 'text' (raw content), 'file' (reads from disk, wraps in code block), or 'url' (fetches page content)"),
33
34
  title: optStr().describe("Document title"),
34
- path: optStr().describe("Original document file path"),
35
- generateEmbeddings: defBool(true).describe("Compute dense vector embeddings"),
36
- }),
37
- },
38
- async ({ content, type, title, path, generateEmbeddings }) => {
39
- const { ingestDocument } = await import("../ingest/pipeline.js");
40
- const result = await ingestDocument({
35
+ path: optStr().describe("Original document file path"),
36
+ scope: z.enum(["project", "global"]).nullish().transform((v) => v || "project").describe("RAG visibility: current Git project (default) or global"),
37
+ generateEmbeddings: defBool(true).describe("Compute dense vector embeddings"),
38
+ }),
39
+ },
40
+ async ({ content, type, title, path, scope, generateEmbeddings }) => {
41
+ const { ingestDocument } = await import("../ingest/pipeline.js");
42
+ const projectScope = await resolveRagScopeKey(scope);
43
+ const result = await ingestDocument({
41
44
  content,
42
45
  type,
43
46
  title: title || null,
44
47
  path: path || null,
45
- generateEmbeddings,
48
+ generateEmbeddings,
49
+ projectScope,
46
50
  });
47
51
  return {
48
52
  content: [
@@ -55,7 +59,8 @@ export function registerRagTools(server) {
55
59
  title: result.title,
56
60
  sectionsCount: result.sectionsCount,
57
61
  microChunksCount: result.microChunksCount,
58
- deduplicated: result.deduplicated,
62
+ deduplicated: result.deduplicated,
63
+ scope: result.projectScope,
59
64
  },
60
65
  null,
61
66
  2
@@ -69,8 +74,8 @@ export function registerRagTools(server) {
69
74
  server.registerTool(
70
75
  "query_knowledge_base",
71
76
  {
72
- description:
73
- "Perform hybrid search (RSF/RRF BM25 full-text + dense vector similarity) across the RAG knowledge base. " +
77
+ description:
78
+ "Perform project-isolated hybrid search (RSF/RRF BM25 full-text + dense vector similarity) across the RAG knowledge base. " +
74
79
  "Returns top-ranked candidate document sections with breadcrumbs, GraphRAG defined code symbols, and relevance scores.",
75
80
  inputSchema: z.object({
76
81
  query: z.string().describe("Search query in natural language or symbol name"),
@@ -79,19 +84,22 @@ export function registerRagTools(server) {
79
84
  "Optional task-specific retrieval instruction shaping embedding focus (e.g. 'Retrieve code snippets', 'Find user preferences'). " +
80
85
  "Recommended when using E5/BGE models for domain-specific queries."
81
86
  ),
82
- generateEmbeddings: defBool(true).describe("Use vector search alongside BM25"),
83
- }),
84
- },
85
- async ({ query, limit, instruction, generateEmbeddings }) => {
87
+ generateEmbeddings: defBool(true).describe("Use vector search alongside BM25"),
88
+ scope: z.enum(["all", "project", "global"]).nullish().transform((v) => v || "all").describe("Search global + current project (default), project only, or global only"),
89
+ }),
90
+ },
91
+ async ({ query, limit, instruction, generateEmbeddings, scope }) => {
86
92
  const { hybridQuery } = await import("../retrieval/retriever.js");
87
93
  const { getConfig } = await import("../config/config_manager.js");
88
- const activeConfig = getConfig();
94
+ const activeConfig = getConfig();
95
+ const scopeKeys = await resolveRagScopeKeys(scope);
89
96
 
90
97
  const results = await hybridQuery({
91
98
  query,
92
99
  limit,
93
100
  generateEmbeddings,
94
- instruction: instruction || null,
101
+ instruction: instruction || null,
102
+ scopeKeys,
95
103
  });
96
104
 
97
105
  if (!results || results.length === 0) {
@@ -128,8 +136,9 @@ export function registerRagTools(server) {
128
136
  server.registerTool(
129
137
  "batch_query_knowledge_base",
130
138
  {
131
- description:
132
- "Execute multiple hybrid search queries in a single batch call. " +
139
+ description:
140
+ "Execute multiple hybrid search queries in a single batch call. " +
141
+ "Search is isolated to global plus the current project unless another scope is requested. " +
133
142
  "More efficient than separate query_knowledge_base calls: all query embeddings computed in one ONNX pass. " +
134
143
  "Returns one result set per query, in the same order as input.",
135
144
  inputSchema: z.object({
@@ -140,18 +149,21 @@ export function registerRagTools(server) {
140
149
  instruction: optStr().describe(
141
150
  "Optional task-specific retrieval instruction shaping embedding focus. Applied to all queries."
142
151
  ),
143
- generateEmbeddings: defBool(true).describe("Use vector search alongside BM25"),
144
- }),
145
- },
146
- async ({ queries, limit, instruction, generateEmbeddings }) => {
152
+ generateEmbeddings: defBool(true).describe("Use vector search alongside BM25"),
153
+ scope: z.enum(["all", "project", "global"]).nullish().transform((v) => v || "all").describe("Search global + current project (default), project only, or global only"),
154
+ }),
155
+ },
156
+ async ({ queries, limit, instruction, generateEmbeddings, scope }) => {
147
157
  const { batchHybridQuery } = await import("../retrieval/retriever.js");
148
158
  const { getConfig } = await import("../config/config_manager.js");
149
- const activeConfig = getConfig();
159
+ const activeConfig = getConfig();
160
+ const scopeKeys = await resolveRagScopeKeys(scope);
150
161
 
151
162
  const allResults = await batchHybridQuery(queries, {
152
163
  limit,
153
164
  generateEmbeddings,
154
- instruction: instruction || null,
165
+ instruction: instruction || null,
166
+ scopeKeys,
155
167
  });
156
168
 
157
169
  const formatted = allResults
@@ -230,27 +242,48 @@ export function registerRagTools(server) {
230
242
  server.registerTool(
231
243
  "manage_knowledge_base",
232
244
  {
233
- description:
234
- "Manage the RAG knowledge base: inspect stats, list documents, read full raw document, delete documents, or export/import snapshots.",
245
+ description:
246
+ "Manage the project-isolated RAG knowledge base: inspect stats, list documents, read full raw document, unlink/delete documents, or export/import complete snapshots.",
235
247
  inputSchema: z.object({
236
248
  action: z.enum(["stats", "list", "read_document", "delete", "export_snapshot", "import_snapshot"]).describe("Management action"),
237
249
  docId: optStr().describe("Document ID, title, or path (required for read_document and delete)"),
238
- snapshotPath: optStr().describe("File path for snapshot export/import"),
239
- }),
240
- },
241
- async ({ action, docId, snapshotPath }) => {
242
- const { getDatabase } = await import("../db/database.js");
243
- const db = await getDatabase();
244
-
245
- if (action === "stats") {
246
- const docCountRow = await db.prepare("SELECT COUNT(*) as cnt FROM documents").get();
247
- const docCount = docCountRow ? docCountRow.cnt : 0;
248
- const secCountRow = await db.prepare("SELECT COUNT(*) as cnt FROM sections").get();
249
- const secCount = secCountRow ? secCountRow.cnt : 0;
250
- const chunkCountRow = await db.prepare("SELECT COUNT(*) as cnt FROM micro_chunks").get();
251
- const chunkCount = chunkCountRow ? chunkCountRow.cnt : 0;
252
- const edgeCountRow = await db.prepare("SELECT COUNT(*) as cnt FROM graph_edges").get();
253
- const edgeCount = edgeCountRow ? edgeCountRow.cnt : 0;
250
+ snapshotPath: optStr().describe("File path for snapshot export/import"),
251
+ scope: z.enum(["all", "project", "global"]).nullish().describe("For stats/list/read: global + current project by default. Delete defaults to the current project (or global outside Git); pass all/global explicitly for broader removal"),
252
+ }),
253
+ },
254
+ async ({ action, docId, snapshotPath, scope }) => {
255
+ const { getDatabase } = await import("../db/database.js");
256
+ const db = await getDatabase();
257
+ const scopeKeys = ["stats", "list", "read_document", "delete"].includes(action)
258
+ ? await resolveManageRagScopeKeys(action, scope)
259
+ : null;
260
+ const placeholders = scopeKeys ? scopeKeys.map(() => "?").join(",") : "";
261
+ const visibleDocWhere = scopeKeys
262
+ ? `EXISTS (SELECT 1 FROM document_scopes ds WHERE ds.doc_id = d.id AND ds.scope_key IN (${placeholders}))`
263
+ : "1=1";
264
+
265
+ if (action === "stats") {
266
+ const docCountRow = await db.prepare(`SELECT COUNT(*) as cnt FROM documents d WHERE ${visibleDocWhere}`).get(...scopeKeys);
267
+ const docCount = docCountRow ? docCountRow.cnt : 0;
268
+ const secCountRow = await db.prepare(`SELECT COUNT(*) as cnt FROM sections s JOIN documents d ON d.id = s.doc_id WHERE ${visibleDocWhere}`).get(...scopeKeys);
269
+ const secCount = secCountRow ? secCountRow.cnt : 0;
270
+ const chunkCountRow = await db.prepare(`SELECT COUNT(*) as cnt FROM micro_chunks m JOIN documents d ON d.id = m.doc_id WHERE ${visibleDocWhere}`).get(...scopeKeys);
271
+ const chunkCount = chunkCountRow ? chunkCountRow.cnt : 0;
272
+ const visibleDocIds = await db.prepare(`SELECT d.id FROM documents d WHERE ${visibleDocWhere}`).all(...scopeKeys);
273
+ let edgeCount = 0;
274
+ if (visibleDocIds.length > 0) {
275
+ const docIds = visibleDocIds.map((row) => row.id);
276
+ const docPlaceholders = docIds.map(() => "?").join(",");
277
+ const ownedRows = await db.prepare(`
278
+ SELECT id FROM sections WHERE doc_id IN (${docPlaceholders})
279
+ UNION SELECT id FROM medium_chunks WHERE doc_id IN (${docPlaceholders})
280
+ UNION SELECT id FROM micro_chunks WHERE doc_id IN (${docPlaceholders})
281
+ `).all(...docIds, ...docIds, ...docIds);
282
+ const ownedIds = [...docIds, ...ownedRows.map((row) => row.id)];
283
+ const edgePlaceholders = ownedIds.map(() => "?").join(",");
284
+ const edgeCountRow = await db.prepare(`SELECT COUNT(*) as cnt FROM graph_edges WHERE source_id IN (${edgePlaceholders}) OR target_id IN (${edgePlaceholders})`).get(...ownedIds, ...ownedIds);
285
+ edgeCount = edgeCountRow ? edgeCountRow.cnt : 0;
286
+ }
254
287
  return {
255
288
  content: [
256
289
  {
@@ -270,8 +303,15 @@ export function registerRagTools(server) {
270
303
  };
271
304
  }
272
305
 
273
- if (action === "list") {
274
- const docs = await db.prepare("SELECT id, title, path, blob_hash, created_at FROM documents ORDER BY created_at DESC").all();
306
+ if (action === "list") {
307
+ const docs = await db.prepare(`
308
+ SELECT d.id, d.title, d.path, d.blob_hash, d.created_at,
309
+ GROUP_CONCAT(ds.scope_key) AS scopes
310
+ FROM documents d
311
+ JOIN document_scopes ds ON ds.doc_id = d.id AND ds.scope_key IN (${placeholders})
312
+ GROUP BY d.id, d.title, d.path, d.blob_hash, d.created_at
313
+ ORDER BY d.created_at DESC
314
+ `).all(...scopeKeys);
275
315
  return {
276
316
  content: [{ type: "text", text: JSON.stringify(docs, null, 2) }],
277
317
  };
@@ -279,9 +319,9 @@ export function registerRagTools(server) {
279
319
 
280
320
  if (action === "read_document") {
281
321
  if (!docId) throw new Error("docId parameter is required for read_document action");
282
- const doc = await db
283
- .prepare("SELECT id, title, path, blob_hash, created_at FROM documents WHERE id = ? OR path = ? OR title = ?")
284
- .get(docId, docId, docId);
322
+ const doc = await db
323
+ .prepare(`SELECT d.id, d.title, d.path, d.blob_hash, d.created_at FROM documents d WHERE (d.id = ? OR d.path = ? OR d.title = ?) AND ${visibleDocWhere}`)
324
+ .get(docId, docId, docId, ...scopeKeys);
285
325
  if (!doc) {
286
326
  throw new Error(`Document not found in knowledge base for docId: ${docId}`);
287
327
  }
@@ -307,10 +347,29 @@ export function registerRagTools(server) {
307
347
  };
308
348
  }
309
349
 
310
- if (action === "delete") {
311
- if (!docId) throw new Error("docId parameter is required for delete action");
312
- const { deleteDocument } = await import("../ingest/pipeline.js");
313
- const result = await deleteDocument(docId, db);
350
+ if (action === "delete") {
351
+ if (!docId) throw new Error("docId parameter is required for delete action");
352
+ const visible = await db
353
+ .prepare(`SELECT d.id FROM documents d WHERE (d.id = ? OR d.path = ? OR d.title = ?) AND ${visibleDocWhere}`)
354
+ .get(docId, docId, docId, ...scopeKeys);
355
+ if (!visible) throw new Error(`Document not found in the selected RAG scope for docId: ${docId}`);
356
+ const scopeRemoval = await removeDocumentScopes(db, visible.id, scopeKeys);
357
+ if (scopeRemoval.remainingScopes > 0) {
358
+ return {
359
+ content: [{
360
+ type: "text",
361
+ text: JSON.stringify({
362
+ deleted: false,
363
+ unlinked: true,
364
+ docId: visible.id,
365
+ removedScopes: scopeRemoval.removedScopes,
366
+ remainingScopes: scopeRemoval.remainingScopes,
367
+ }, null, 2),
368
+ }],
369
+ };
370
+ }
371
+ const { deleteDocument } = await import("../ingest/pipeline.js");
372
+ const result = await deleteDocument(visible.id, db);
314
373
  return {
315
374
  content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
316
375
  };