@lotargo/memory_plugin 1.5.2 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,14 @@
1
1
  import * as z from "zod/v4";
2
- import { optStr, defBool, defNum } from "./helpers.js";
2
+ import { optStr, defBool, defNum, optNum } from "./helpers.js";
3
+ import { MEMORY_DIR } from "../memory.js";
4
+ import { registerSnapshotDir } from "../admin/snapshot.js";
5
+ import { ensureExportsDir } from "../ingest/exporter.js";
3
6
 
4
7
  export function registerRagTools(server) {
8
+ // Restrict snapshot export/import paths to the plugin's own data directories.
9
+ registerSnapshotDir(ensureExportsDir());
10
+ registerSnapshotDir(MEMORY_DIR);
11
+
5
12
  server.registerTool(
6
13
  "ingest_document",
7
14
  {
@@ -118,6 +125,47 @@ export function registerRagTools(server) {
118
125
  }
119
126
  );
120
127
 
128
+ server.registerTool(
129
+ "reindex_knowledge_base",
130
+ {
131
+ description:
132
+ "Re-embed all existing documents in the RAG knowledge base with the active (or specified) embedding model and vector dimension. " +
133
+ "Use after switching the embedding model or vector dimension so previously stored vectors match the new configuration. " +
134
+ "Preserves documents, sections, FTS index, graph edges, and fact links.",
135
+ inputSchema: z.object({
136
+ model: optStr().describe("Embedding model to use (defaults to active config.embeddingModel)"),
137
+ dimension: optNum().describe(
138
+ "Fixed vector dimension (defaults to active config.vectorDimension; auto-detect if unset)"
139
+ ),
140
+ }),
141
+ },
142
+ async ({ model, dimension }) => {
143
+ const { reindexEmbeddings } = await import("../ingest/pipeline.js");
144
+ const result = await reindexEmbeddings({
145
+ model: model || null,
146
+ dimension: dimension !== undefined && dimension !== null ? dimension : null,
147
+ });
148
+ return {
149
+ content: [
150
+ {
151
+ type: "text",
152
+ text: JSON.stringify(
153
+ {
154
+ status: "success",
155
+ reindexed: result.reindexed,
156
+ documentsAffected: result.documentsAffected,
157
+ model: result.model,
158
+ dimension: result.dimension || "auto",
159
+ },
160
+ null,
161
+ 2
162
+ ),
163
+ },
164
+ ],
165
+ };
166
+ }
167
+ );
168
+
121
169
  server.registerTool(
122
170
  "manage_knowledge_base",
123
171
  {
@@ -1,51 +1,56 @@
1
- const { mkdir, cp, readdir } = await import("fs/promises");
2
- const { existsSync } = await import("fs");
3
- const { join, dirname } = await import("path");
4
- const { homedir } = await import("os");
5
- const { fileURLToPath } = await import("url");
6
- const {
1
+ // Static ESM imports: top-level `await import(...)` blocked module evaluation
2
+ // and made this file an async module for every consumer.
3
+ import { mkdir, cp, readdir } from "node:fs/promises";
4
+ import { existsSync } from "node:fs";
5
+ import { join, dirname } from "node:path";
6
+ import { homedir } from "node:os";
7
+ import { fileURLToPath } from "node:url";
8
+ import {
7
9
  parseFactEntry,
8
10
  factText,
9
11
  factMeta,
10
- withMeta,
11
- nextFactId,
12
- isKeepFact,
13
12
  isSuperseded,
14
13
  displayFact,
15
- formatFactEntry,
16
- matchesQuery,
17
- matchesTags,
18
- inDateRange,
19
14
  factTitle,
20
15
  factBody,
21
- autoGenerateTitle,
22
16
  metaBadges,
23
- } = await import("../mcp-server/fact_format.js");
17
+ } from "../mcp-server/fact_format.js";
24
18
 
25
- const {
19
+ import {
26
20
  MEMORY_DIR,
27
21
  GLOBAL_KEY,
28
22
  canonicalPath,
29
- projectName,
30
23
  projectKey,
31
24
  scopeKey,
32
25
  readMemory,
33
26
  writeMemory,
34
- listProjectStores,
35
27
  storeFilePath,
36
- today,
37
- } = await import("../mcp-server/memory.js");
38
-
39
- // Resolve a fact reference (1-based number, metadata id, or text) to an index.
40
- function resolveFactIndex(entries, ref) {
41
- const trimmed = String(ref || "").trim();
42
- if (!trimmed) return -1;
43
- const num = parseInt(trimmed, 10);
44
- if (/^\d+$/.test(trimmed) && num >= 1 && num <= entries.length) return num - 1;
45
- const idIdx = entries.findIndex((e) => factMeta(e).id === trimmed);
46
- if (idIdx !== -1) return idIdx;
47
- const textIdx = entries.findIndex((e) => factText(e).toLowerCase().includes(trimmed.toLowerCase()));
48
- return textIdx;
28
+ } from "../mcp-server/memory.js";
29
+
30
+ import { closeDatabase } from "../mcp-server/db/database.js";
31
+ import { requireProjectKey } from "../mcp-server/tools/helpers.js";
32
+ // Shared Notebook tool implementations — the same code the MCP server runs, so
33
+ // a fix in one surface can no longer miss the other.
34
+ import {
35
+ rememberFact,
36
+ recallFacts,
37
+ getFactById,
38
+ forgetFacts,
39
+ updateFactText,
40
+ memoryInfo,
41
+ } from "../mcp-server/tools/core/memory_core.js";
42
+
43
+ // Registered once when the plugin is instantiated, never at import time:
44
+ // importing this module repeatedly used to stack duplicate "exit" listeners.
45
+ let exitHookInstalled = false;
46
+ function installExitHook() {
47
+ if (exitHookInstalled) return;
48
+ exitHookInstalled = true;
49
+ process.on("exit", () => {
50
+ try {
51
+ closeDatabase();
52
+ } catch {}
53
+ });
49
54
  }
50
55
 
51
56
  const CONFIG_DIR = process.env.OPENCODE_CONFIG_DIR || join(homedir(), ".config", "opencode");
@@ -109,16 +114,6 @@ const MEMORY_INSTRUCTION =
109
114
  "When saving, translate the fact into clear, concise English.\n" +
110
115
  "Use `scope: \"global\"` for personal facts, `scope: \"project\"` for project-specific facts.";
111
116
 
112
- function requireProjectKey(key) {
113
- if (!key) {
114
- throw new Error(
115
- "No project memory available: this directory is not inside a git repository. " +
116
- "Project memory is tied to a git repo; use scope: 'global' or open a git repository."
117
- );
118
- }
119
- return key;
120
- }
121
-
122
117
  function sortNewestFirst(entries) {
123
118
  return [...entries].sort((a, b) => {
124
119
  const pa = parseFactEntry(a);
@@ -205,6 +200,7 @@ const MCP_SERVERS = [
205
200
  ];
206
201
 
207
202
  export const MemoryPlugin = async ({ directory, worktree, client }) => {
203
+ installExitHook();
208
204
  await ensureDir();
209
205
  let activeProjectKey = await scopeKey("project", worktree, directory);
210
206
  let identityResolveAt = 0;
@@ -302,79 +298,13 @@ export const MemoryPlugin = async ({ directory, worktree, client }) => {
302
298
  tags: { type: "string", description: "Optional comma-separated tags, e.g. \x27pref,arch\x27" },
303
299
  supersedes: { type: "string", description: "Optional number, id, or text of the fact this one replaces" },
304
300
  },
305
- async execute({ fact, title, scope, docId, startLine, endLine, relationType, ttl, keep, tags, supersedes }, { worktree, directory }) {
306
- const key = requireProjectKey(await scopeKey(scope || "project", worktree, directory));
307
- const entries = await readMemory(key);
308
-
309
- const explicitTitle = title ? title.trim() : null;
310
- let finalTitle = explicitTitle;
311
- let finalFact = fact.trim();
312
-
313
- // If fact already contains a title pattern, extract it
314
- const titleMatch = /^\\*\\*([^\x2a]+)\\*\\*\\s*(?:—|--|-|:)?\\s*(.*)$/.exec(finalFact);
315
- if (titleMatch) {
316
- if (!finalTitle) {
317
- finalTitle = titleMatch[1].trim();
318
- }
319
- finalFact = titleMatch[2].trim();
320
- }
321
-
322
- if (!finalTitle) {
323
- finalTitle = autoGenerateTitle(finalFact);
324
- }
325
-
326
- const text = `**${finalTitle}** — ${finalFact}`;
327
- const factBodyNormalized = finalFact.toLowerCase();
328
- const duplicate = entries.some((e) => factBody(e).toLowerCase().trim() === factBodyNormalized);
329
-
330
- let supersededInfo = "";
331
- if (!duplicate) {
332
- const [date, time] = today().split(" ");
333
- const meta = { ttl, tags };
334
- if (keep) meta.keep = "1";
335
- if (supersedes) {
336
- const targetIdx = resolveFactIndex(entries, supersedes);
337
- if (targetIdx !== -1) {
338
- const newId = nextFactId(entries);
339
- const targetMeta = factMeta(entries[targetIdx]);
340
- const targetId = targetMeta.id || nextFactId(entries);
341
- entries[targetIdx] = withMeta(entries[targetIdx], { id: targetId, supersededBy: newId });
342
- meta.id = newId;
343
- meta.supersedes = targetId;
344
- supersededInfo = ` [superseded: "${factText(entries[targetIdx]).slice(0, 60)}"]`;
345
- } else {
346
- supersededInfo = " (note: supersedes target not found)";
347
- }
348
- }
349
- if (!meta.id) meta.id = nextFactId(entries);
350
- entries.push(formatFactEntry({ date, time, text, meta }));
351
- await writeMemory(key, entries);
352
- }
353
-
354
- let linkInfo = "";
355
- if (docId) {
356
- try {
357
- const { linkFactToDocument } = await import("../mcp-server/graph/knowledge_linker.js");
358
- const linkRes = linkFactToDocument({
359
- factKey: key,
360
- factText: finalFact,
361
- docId,
362
- startLine,
363
- endLine,
364
- relationType: relationType || "LINKS_TO",
365
- });
366
- const linesStr = startLine ? `:L${startLine}${endLine ? `-${endLine}` : ""}` : "";
367
- linkInfo = ` [Linked to Doc: "${linkRes.docTitle}"${linesStr}]`;
368
- } catch (err) {
369
- linkInfo = ` (Note: Fact saved, but document link failed: ${err.message})`;
370
- }
371
- }
372
-
373
- const result = "Memory updated" + supersededInfo + linkInfo;
301
+ async execute(args, { worktree, directory }) {
302
+ const result = await rememberFact(args, { worktree, directory });
374
303
  await notify(client, result);
375
304
  return result;
376
305
  },
377
306
  },
307
+
378
308
  "recall": {
379
309
  description:
380
310
  "Show saved facts with any Agent-linked Knowledge Base documents/lines. " +
@@ -397,154 +327,19 @@ export const MemoryPlugin = async ({ directory, worktree, client }) => {
397
327
  offset: { type: "number", description: "Pagination offset (optional)" },
398
328
  limit: { type: "number", description: "Pagination limit (optional)" },
399
329
  },
400
- async execute({ scope, project, query, tags, since, until, mode, offset, limit }, { worktree, directory }) {
401
- const results = [];
402
- const now = Date.now();
403
- const targetMode = mode || "full";
404
- const targetOffset = offset !== undefined ? offset : 0;
405
-
406
- let getLinksForFact;
407
- try {
408
- const linker = await import("../mcp-server/graph/knowledge_linker.js");
409
- getLinksForFact = linker.getLinksForFact;
410
- } catch (e) {}
411
-
412
- const formatFactWithLinks = async (factLine, index, key) => {
413
- const p = parseFactEntry(factLine);
414
- if (!p) return factLine;
415
-
416
- const title = factTitle(factLine);
417
- const body = factBody(factLine);
418
- const meta = p.meta;
419
-
420
- const badges = [];
421
- if (isExpiredLine(factLine, now)) badges.push("EXPIRED");
422
- if (isKeepFact(factLine)) badges.push("KEEP");
423
- if (isSuperseded(factLine)) badges.push("SUPERSEDED");
424
- if (meta.inject === "1") badges.push("INJECT");
425
- if (meta.id) badges.push(`id:${meta.id}`);
426
- if (meta.tags) badges.push(`tags:${meta.tags}`);
427
- badges.push(`${p.date} ${p.time}`);
428
-
429
- const badgesStr = badges.length ? ` [${badges.join("] [")}]` : "";
430
-
431
- let lineText;
432
- if (targetMode === "headers") {
433
- lineText = `**${title}**${badgesStr}`;
434
- } else {
435
- lineText = p.text;
436
- }
437
-
438
- if (getLinksForFact) {
439
- try {
440
- const links = await getLinksForFact(key, p.text);
441
- if (links && links.length > 0) {
442
- const docStr = links
443
- .map((l) => {
444
- const range = l.start_line ? `:L${l.start_line}${l.end_line ? `-${l.end_line}` : ""}` : "";
445
- return `${l.doc_title || l.doc_path}${range}`;
446
- })
447
- .join(", ");
448
- lineText += ` 🔗 [Linked Docs: ${docStr}]`;
449
- }
450
- } catch (e) {}
451
- }
452
- return `${index}. ${lineText}`;
453
- };
454
-
455
- const target = project ? canonicalPath(project) : await projectKey(worktree, directory);
456
- const label = project ? target : await projectName(worktree, directory);
457
-
458
- const collect = async (entries, key) => {
459
- const matched = entries.filter(
460
- (e) => matchesQuery(e, query) && matchesTags(e, tags) && inDateRange(e, since, until)
461
- );
462
- if (!matched.length) return;
463
- if (results.length) results.push("");
464
- results.push(`--- ${key === GLOBAL_KEY ? "Global" : `Project: ${key === target ? label : key}`} ---`);
465
-
466
- const targetLimit = limit !== undefined ? limit : matched.length;
467
-
468
- const paginated = matched.slice(targetOffset, targetOffset + targetLimit);
469
- for (let i = 0; i < paginated.length; i++) {
470
- results.push(await formatFactWithLinks(paginated[i], targetOffset + i + 1, key));
471
- }
472
-
473
- if (limit !== undefined && matched.length > targetLimit) {
474
- results.push(`Showing entries ${targetOffset + 1}-${Math.min(targetOffset + targetLimit, matched.length)} of ${matched.length}`);
475
- }
476
- results.push(`Store file: ${storeFilePath(key)}`);
477
- };
478
-
479
- if (scope === "list_projects") {
480
- return listProjectStores().then((stores) => {
481
- if (!stores.length) return "No project memory stores found.";
482
- const lines = stores.map(
483
- (s, i) => `${i + 1}. ${s.basename} — ${s.count} fact(s) [${s.file}]${s.path ? ` (bound to ${s.path})` : " (unbound legacy store)"}`
484
- );
485
- return `Project Memory Stores:\n${lines.join("\n")}\n\nUse recall(scope: "project", project: "<path>") to read a specific store.\n\nMemory dir: ${MEMORY_DIR}`;
486
- });
487
- }
488
-
489
- if (scope !== "project") {
490
- const global = await readMemory(GLOBAL_KEY);
491
- await collect(global, GLOBAL_KEY);
492
- }
493
- if (scope !== "global") {
494
- const local = await readMemory(target);
495
- await collect(local, target);
496
- }
497
- const filtered = Boolean(query || tags || since || until);
498
- if (!results.length) return filtered ? "No facts match the search." : "Memory is empty.";
499
- return results.join("\n") + `\n\nMemory dir: ${MEMORY_DIR}`;
330
+ async execute(args, { worktree, directory }) {
331
+ return await recallFacts(args, { worktree, directory });
500
332
  },
501
333
  },
334
+
502
335
  "get_fact": {
503
336
  description: "Get the full text and metadata of a single fact by its metadata id.",
504
337
  args: {
505
338
  id: { type: "string", description: "The unique metadata id of the fact (e.g. \x278f3a2c\x27)" },
506
339
  scope: { type: "string", description: "\x27project\x27, \x27global\x27, or \x27all\x27 (default)", default: "all" },
507
340
  },
508
- async execute({ id, scope }, { worktree, directory }) {
509
- const results = [];
510
- const targetId = String(id || "").trim();
511
- if (!targetId) throw new Error("ID parameter is required.");
512
-
513
- const check = async (key) => {
514
- const entries = await readMemory(key);
515
- const match = entries.find((e) => factMeta(e).id === targetId);
516
- if (match) {
517
- const title = factTitle(match);
518
- const body = factBody(match);
519
- const meta = factMeta(match);
520
- results.push({
521
- key,
522
- title,
523
- body,
524
- meta,
525
- line: match
526
- });
527
- }
528
- };
529
-
530
- if (scope !== "project") {
531
- await check(GLOBAL_KEY);
532
- }
533
- if (scope !== "global") {
534
- const target = await projectKey(worktree, directory);
535
- await check(target);
536
- }
537
-
538
- if (!results.length) {
539
- return `Fact with ID "${targetId}" not found.`;
540
- }
541
-
542
- const lines = results.map((r) => {
543
- const metaStr = Object.entries(r.meta).map(([k, v]) => `${k}:${v}`).join(", ");
544
- return `[Store: ${r.key === GLOBAL_KEY ? "Global" : "Project"}]\nTitle: ${r.title}\nBody: ${r.body}\nMetadata: ${metaStr ? `<!-- ${metaStr} -->` : "none"}`;
545
- });
546
-
547
- return lines.join("\n\n");
341
+ async execute(args, { worktree, directory }) {
342
+ return await getFactById(args, { worktree, directory });
548
343
  },
549
344
  },
550
345
  "forget": {
@@ -558,37 +353,9 @@ export const MemoryPlugin = async ({ directory, worktree, client }) => {
558
353
  },
559
354
  force: { type: "boolean", description: "Удалить также защищённые (keep) факты" },
560
355
  },
561
- async execute({ query, scope, force }, { worktree, directory }) {
562
- const key = requireProjectKey(await scopeKey(scope || "project", worktree, directory));
563
- const entries = await readMemory(key);
564
- const rangeMatch = /^\s*(\d+)\s*-\s*(\d+)\s*$/.exec(query);
565
- const num = parseInt(query, 10);
566
- let indices = [];
567
- if (rangeMatch) {
568
- const from = parseInt(rangeMatch[1], 10);
569
- const to = parseInt(rangeMatch[2], 10);
570
- if (from > 0 && to >= from && to <= entries.length) {
571
- for (let i = from - 1; i < to; i++) indices.push(i);
572
- }
573
- }
574
- if (!indices.length && !isNaN(num) && num > 0 && num <= entries.length) {
575
- indices.push(num - 1);
576
- }
577
- if (!indices.length) {
578
- const q = query.toLowerCase();
579
- indices = entries.reduce((acc, e, i) => (e.toLowerCase().includes(q) ? acc.concat(i) : acc), []);
580
- }
581
- if (!indices.length) return "Not found.";
582
-
583
- const removable = indices.filter((i) => force || !isKeepFact(entries[i]));
584
- const protectedCount = indices.length - removable.length;
585
- if (removable.length) {
586
- for (const i of removable.sort((a, b) => b - a)) entries.splice(i, 1);
587
- await writeMemory(key, entries);
588
- }
589
- let result = removable.length ? "Memory updated" : "Nothing removed.";
590
- if (protectedCount) result += ` (${protectedCount} protected fact(s) skipped; use force=true to override)`;
591
- if (removable.length) await notify(client, result);
356
+ async execute(args, { worktree, directory }) {
357
+ const result = await forgetFacts(args, { worktree, directory });
358
+ if (result.startsWith("Memory updated")) await notify(client, result);
592
359
  return result;
593
360
  },
594
361
  },
@@ -602,109 +369,18 @@ export const MemoryPlugin = async ({ directory, worktree, client }) => {
602
369
  title: { type: "string", description: "Optional new title for the fact" },
603
370
  scope: { type: "string", description: "\x27project\x27 (default) or \x27global\x27", default: "project" },
604
371
  },
605
- async execute({ id, newText, title, scope }, { worktree, directory }) {
606
- const key = requireProjectKey(await scopeKey(scope || "project", worktree, directory));
607
- const entries = await readMemory(key);
608
- const idx = resolveFactIndex(entries, id);
609
- if (idx === -1) throw new Error(`Fact not found: ${id}`);
610
- const p = parseFactEntry(entries[idx]);
611
- const oldText = p ? p.text : entries[idx];
612
- const oldBody = factBody(entries[idx]) || oldText;
613
-
614
- const explicitTitle = title ? title.trim() : null;
615
- let finalTitle = explicitTitle;
616
- let finalFact = newText.trim();
617
-
618
- // Check if newText has a title
619
- const titleMatch = /^\\*\\*([^\x2a]+)\\*\\*\\s*(?:—|--|-|:)?\\s*(.*)$/.exec(finalFact);
620
- if (titleMatch) {
621
- if (!finalTitle) {
622
- finalTitle = titleMatch[1].trim();
623
- }
624
- finalFact = titleMatch[2].trim();
625
- }
626
-
627
- // If no new title is specified, preserve the old title
628
- if (!finalTitle) {
629
- finalTitle = factTitle(entries[idx]) || autoGenerateTitle(finalFact);
630
- }
631
-
632
- const newTextFormatted = `**${finalTitle}** — ${finalFact}`;
633
- const newLine = formatFactEntry({ date: p.date, time: p.time, text: newTextFormatted, meta: p.meta });
634
- entries[idx] = newLine;
635
- await writeMemory(key, entries);
636
-
637
- let linksUpdated = 0;
638
- try {
639
- const { getDatabase } = await import("../mcp-server/db/database.js");
640
- const db = await getDatabase();
641
- const res = db
642
- .prepare("UPDATE knowledge_links SET fact_text = ? WHERE fact_key = ? AND fact_text = ?")
643
- .run(finalFact, key, oldBody);
644
- linksUpdated = res.changes;
645
- } catch (e) {}
646
-
647
- const result = `Fact updated${linksUpdated ? `, ${linksUpdated} doc link(s) updated` : ""}`;
372
+ async execute(args, { worktree, directory }) {
373
+ const result = await updateFactText(args, { worktree, directory });
648
374
  await notify(client, result);
649
375
  return result;
650
376
  },
651
377
  },
378
+
652
379
  "memory_info": {
653
380
  description: "Show memory storage paths (store files, MEMORY_DIR, SQLite DB), fact counts, and Knowledge Base stats.",
654
381
  args: {},
655
- async execute() {
656
- const dbPath = join(MEMORY_DIR, "storage", "memory.sqlite");
657
- let version = "unknown";
658
- try {
659
- const { readFile } = await import("fs/promises");
660
- version = JSON.parse(
661
- await readFile(new URL("../package.json", import.meta.url), "utf-8")
662
- ).version;
663
- } catch (e) {}
664
-
665
- let rag = {};
666
- try {
667
- const { getDatabase } = await import("../mcp-server/db/database.js");
668
- const db = getDatabase();
669
- rag.documents = db.prepare("SELECT COUNT(*) AS c FROM documents").get().c;
670
- rag.sections = db.prepare("SELECT COUNT(*) AS c FROM sections").get().c;
671
- rag.chunks = db.prepare("SELECT COUNT(*) AS c FROM micro_chunks").get().c;
672
- rag.edges = db.prepare("SELECT COUNT(*) AS c FROM graph_edges").get().c;
673
- rag.links = db.prepare("SELECT COUNT(*) AS c FROM knowledge_links").get().c;
674
- } catch (e) {
675
- rag.error = e.message;
676
- }
677
-
678
- let identityLines = [];
679
- try {
680
- const { getDatabase } = await import("../mcp-server/db/database.js");
681
- const { resolveProjectIdentity, listIdentities } = await import("../mcp-server/identity.js");
682
- const db = getDatabase();
683
- const identity = await resolveProjectIdentity(directory || process.cwd());
684
- const all = await listIdentities(db);
685
- identityLines.push(
686
- `Identity: ${identity ? "git" : "no-git"}` +
687
- (identity ? ` | key: ${identity.key} | name: ${identity.name}${identity.primaryRemote ? ` | remote: ${identity.primaryRemote}` : ""}` : ""),
688
- `Known identities: ${all.length}`
689
- );
690
- } catch (e) {
691
- identityLines.push(`Identity: unavailable (${e.message})`);
692
- }
693
-
694
- const lines = [
695
- `Version: ${version}`,
696
- `MEMORY_DIR: ${MEMORY_DIR}`,
697
- `SQLite DB: ${dbPath}`,
698
- `Global store: ${storeFilePath(GLOBAL_KEY)}`,
699
- `Project store: ${storeFilePath(activeProjectKey)}`,
700
- ...identityLines,
701
- ];
702
- if (rag.error) lines.push(`RAG: unavailable (${rag.error})`);
703
- else
704
- lines.push(
705
- `RAG: ${rag.documents} doc(s), ${rag.sections} section(s), ${rag.chunks} chunk(s), ${rag.edges} edge(s), ${rag.links} memory link(s)`
706
- );
707
- return lines.join("\n");
382
+ async execute(_args, ctx = {}) {
383
+ return await memoryInfo({}, { worktree: ctx.worktree ?? worktree, directory: ctx.directory ?? directory });
708
384
  },
709
385
  },
710
386
  "link_knowledge": {
@@ -741,7 +417,7 @@ export const MemoryPlugin = async ({ directory, worktree, client }) => {
741
417
  if (!factText || !docId) {
742
418
  throw new Error("factText and docId are required parameters for link action");
743
419
  }
744
- const res = linkFactToDocument({
420
+ const res = await linkFactToDocument({
745
421
  factKey: key,
746
422
  factText,
747
423
  docId,
@@ -754,12 +430,12 @@ export const MemoryPlugin = async ({ directory, worktree, client }) => {
754
430
 
755
431
  if (act === "get_doc_links") {
756
432
  if (!docId) throw new Error("docId parameter is required for get_doc_links action");
757
- const links = getLinksForDoc(docId);
433
+ const links = await getLinksForDoc(docId);
758
434
  return JSON.stringify(links, null, 2);
759
435
  }
760
436
 
761
437
  if (act === "list_links") {
762
- const links = listAllLinks(key);
438
+ const links = await listAllLinks(key);
763
439
  return JSON.stringify(links, null, 2);
764
440
  }
765
441
 
@@ -864,13 +540,17 @@ export const MemoryPlugin = async ({ directory, worktree, client }) => {
864
540
  },
865
541
  async execute({ action, docId, snapshotPath }) {
866
542
  const { getDatabase } = await import("../mcp-server/db/database.js");
867
- const db = getDatabase();
543
+ const db = await getDatabase();
868
544
 
869
545
  if (action === "stats") {
870
- const docCount = db.prepare("SELECT COUNT(*) as cnt FROM documents").get().cnt;
871
- const secCount = db.prepare("SELECT COUNT(*) as cnt FROM sections").get().cnt;
872
- const chunkCount = db.prepare("SELECT COUNT(*) as cnt FROM micro_chunks").get().cnt;
873
- const edgeCount = db.prepare("SELECT COUNT(*) as cnt FROM graph_edges").get().cnt;
546
+ const docCountRow = await db.prepare("SELECT COUNT(*) as cnt FROM documents").get();
547
+ const docCount = docCountRow ? docCountRow.cnt : 0;
548
+ const secCountRow = await db.prepare("SELECT COUNT(*) as cnt FROM sections").get();
549
+ const secCount = secCountRow ? secCountRow.cnt : 0;
550
+ const chunkCountRow = await db.prepare("SELECT COUNT(*) as cnt FROM micro_chunks").get();
551
+ const chunkCount = chunkCountRow ? chunkCountRow.cnt : 0;
552
+ const edgeCountRow = await db.prepare("SELECT COUNT(*) as cnt FROM graph_edges").get();
553
+ const edgeCount = edgeCountRow ? edgeCountRow.cnt : 0;
874
554
  return JSON.stringify(
875
555
  {
876
556
  documents: docCount,
@@ -884,7 +564,7 @@ export const MemoryPlugin = async ({ directory, worktree, client }) => {
884
564
  }
885
565
 
886
566
  if (action === "list") {
887
- const docs = db
567
+ const docs = await db
888
568
  .prepare("SELECT id, title, path, blob_hash, created_at FROM documents ORDER BY created_at DESC")
889
569
  .all();
890
570
  return JSON.stringify(docs, null, 2);
@@ -892,7 +572,7 @@ export const MemoryPlugin = async ({ directory, worktree, client }) => {
892
572
 
893
573
  if (action === "read_document") {
894
574
  if (!docId) throw new Error("docId parameter is required for read_document action");
895
- const doc = db
575
+ const doc = await db
896
576
  .prepare("SELECT id, title, path, blob_hash, created_at FROM documents WHERE id = ? OR path = ? OR title = ?")
897
577
  .get(docId, docId, docId);
898
578
  if (!doc) {
@@ -938,6 +618,34 @@ export const MemoryPlugin = async ({ directory, worktree, client }) => {
938
618
  throw new Error(`Unknown action: ${action}`);
939
619
  },
940
620
  },
621
+ "reindex_knowledge_base": {
622
+ description:
623
+ "Re-embed all existing documents in the RAG knowledge base with the active (or specified) embedding model and vector dimension. " +
624
+ "Use after switching the embedding model or vector dimension so previously stored vectors match the new configuration. " +
625
+ "Preserves documents, sections, FTS index, graph edges, and fact links.",
626
+ args: {
627
+ model: { type: "string", description: "Embedding model to use (defaults to active config.embeddingModel)" },
628
+ dimension: { type: "number", description: "Fixed vector dimension (defaults to active config.vectorDimension; auto-detect if unset)" },
629
+ },
630
+ async execute({ model, dimension }) {
631
+ const { reindexEmbeddings } = await import("../mcp-server/ingest/pipeline.js");
632
+ const result = await reindexEmbeddings({
633
+ model: model || null,
634
+ dimension: dimension !== undefined && dimension !== null ? dimension : null,
635
+ });
636
+ return JSON.stringify(
637
+ {
638
+ status: "success",
639
+ reindexed: result.reindexed,
640
+ documentsAffected: result.documentsAffected,
641
+ model: result.model,
642
+ dimension: result.dimension || "auto",
643
+ },
644
+ null,
645
+ 2
646
+ );
647
+ },
648
+ },
941
649
  "link_project_memory": {
942
650
  description: "Link the current directory to a Git-based project identity, register aliases, and optionally migrate legacy/path stores.",
943
651
  args: {