@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.
@@ -8,13 +8,11 @@ import { fileURLToPath } from "node:url";
8
8
  import {
9
9
  parseFactEntry,
10
10
  factText,
11
- factMeta,
12
- isSuperseded,
13
- displayFact,
14
- factTitle,
15
- factBody,
16
- metaBadges,
17
- } from "../mcp-server/fact_format.js";
11
+ factMeta,
12
+ isSuperseded,
13
+ displayFact,
14
+ factBody,
15
+ } from "../mcp-server/fact_format.js";
18
16
 
19
17
  import {
20
18
  MEMORY_DIR,
@@ -28,7 +26,8 @@ import {
28
26
  } from "../mcp-server/memory.js";
29
27
 
30
28
  import { closeDatabase } from "../mcp-server/db/database.js";
31
- import { requireProjectKey } from "../mcp-server/tools/helpers.js";
29
+ import { requireProjectKey } from "../mcp-server/tools/helpers.js";
30
+ import { resolveRagScopeKey, resolveRagScopeKeys, resolveManageRagScopeKeys, removeDocumentScopes } from "../mcp-server/rag_scope.js";
32
31
  // Shared Notebook tool implementations — the same code the MCP server runs, so
33
32
  // a fix in one surface can no longer miss the other.
34
33
  import {
@@ -103,16 +102,20 @@ async function notify(client, message, variant = "success") {
103
102
 
104
103
  const MEMORY_INSTRUCTION =
105
104
  "MANDATORY FIRST STEP (READ MEMORIES FIRST):\n" +
106
- "At the start of every session, you MUST thoroughly review all saved memories provided below BEFORE performing any user task or editing code.\n" +
107
- "If calling `recall` manually, your very first action MUST BE to request ALL global memories (`scope: \"all\"` without restrictive query filters) to ensure no global facts or preferences are missed.\n" +
108
- "PROACTIVE MEMORY DIRECTIVE:\n" +
105
+ "At the start of every session, you MUST thoroughly review all saved memories provided below BEFORE performing any user task or editing code.\n" +
106
+ "If calling `recall` manually, your very first action MUST BE to request ALL global memories (`scope: \"all\"` without restrictive query filters) to ensure no global facts or preferences are missed.\n" +
107
+ "PROJECT IDENTITY DIRECTIVE:\n" +
108
+ "After reviewing the injected memories, call `memory_info`. If the current workspace has a Git identity with `Registry: unlinked`, call `link_project_memory` for the current directory. Re-read memories only when linking migrated legacy facts. Outside Git, use global memory only.\n" +
109
+ "PROACTIVE MEMORY DIRECTIVE:\n" +
109
110
  "You MUST automatically and proactively call `remember` whenever the user shares durable facts, personal preferences, coding guidelines, tech stack choices, architecture decisions, or project conventions.\n" +
110
111
  "Do NOT wait for explicit user commands like \"remember this\". Automatically capture high-signal facts in real time.\n" +
111
112
  "Use `remember` only for important, durable facts about the user and project.\n" +
112
113
  "Save high-signal items: user role, goals, constraints, tech stack preferences, architecture decisions, project conventions.\n" +
113
114
  "DO NOT save: transient details, one-off statements, full conversation turns, or anything unlikely to be useful in future sessions.\n" +
114
- "When saving, translate the fact into clear, concise English.\n" +
115
- "Use `scope: \"global\"` for personal facts, `scope: \"project\"` for project-specific facts.";
115
+ "When saving, translate the fact into clear, concise English.\n" +
116
+ "Use `scope: \"global\"` for personal facts, `scope: \"project\"` for project-specific facts.\n" +
117
+ "SELECTIVE RAG DIRECTIVE:\n" +
118
+ "When web research or current technical documentation yields reliable project knowledge likely to be reused, ingest only the relevant source or excerpt with project scope and link it to the project Notebook fact it supports. Use global RAG only for intentionally cross-project sources. Prefer authoritative and newer-than-training documentation; do not dump everything encountered into RAG.";
116
119
 
117
120
  function sortNewestFirst(entries) {
118
121
  return [...entries].sort((a, b) => {
@@ -126,7 +129,7 @@ function sortNewestFirst(entries) {
126
129
  });
127
130
  }
128
131
 
129
- function formatInjectedFacts(entries, limit, now = Date.now()) {
132
+ export function formatInjectedFacts(entries, limit, now = Date.now()) {
130
133
  const activeEntries = entries.filter((e) => !isSuperseded(e));
131
134
  const sorted = sortNewestFirst(activeEntries);
132
135
 
@@ -143,414 +146,508 @@ function formatInjectedFacts(entries, limit, now = Date.now()) {
143
146
  }
144
147
 
145
148
  const combined = [...injectPriority, ...normalPriority];
146
- const sliced = combined.slice(0, limit);
147
-
148
- const formattedLines = [];
149
- for (let i = 0; i < sliced.length; i++) {
150
- const entry = sliced[i];
151
- const meta = factMeta(entry);
152
- const isPriority = meta.inject === "1";
153
-
154
- let contentStr;
155
- if (isPriority) {
156
- contentStr = displayFact(entry, now);
157
- } else {
158
- const title = factTitle(entry);
159
- const badges = metaBadges(entry, now);
160
- const badgesStr = badges.length ? ` [${badges.join("] [")}]` : "";
161
- contentStr = `${title}${badgesStr}`;
162
- }
163
-
164
- formattedLines.push(`${i + 1}. ${contentStr}`);
165
- }
166
-
167
- if (activeEntries.length > limit) {
168
- const remaining = activeEntries.length - limit;
169
- formattedLines.push(`... and ${remaining} more of ${activeEntries.length} memories (use recall tool to fetch all)`);
170
- }
171
-
172
- return formattedLines.join("\n");
173
- }
174
-
175
- function buildMemoryContext(globalFacts, projectFacts, projectKey, injectLimit, now = Date.now()) {
176
- const parts = [MEMORY_INSTRUCTION];
177
-
178
- if (globalFacts.length) {
179
- const formatted = formatInjectedFacts(globalFacts, injectLimit, now);
180
- if (formatted) parts.push("## Global\n" + formatted);
181
- }
182
- if (projectFacts.length) {
183
- const formatted = formatInjectedFacts(projectFacts, injectLimit, now);
184
- if (formatted) parts.push(`## Project: ${projectKey}\n` + formatted);
185
- }
186
- return `<MEMORY>\n${parts.join("\n\n")}\n</MEMORY>`;
187
- }
188
-
189
- const MCP_SERVERS = [
190
- { id: "context7", desc: "Документация библиотек и фреймворков (Context7)" },
191
- { id: "supabase", desc: "БД Supabase — SQL, миграции, edge functions" },
192
- { id: "stitch", desc: "UI дизайн — генерация и редактирование экранов" },
193
- { id: "neon", desc: "БД Neon — PostgreSQL, схемы, миграции" },
194
- { id: "linear", desc: "Linear — задачи, проекты, документы" },
195
- { id: "grep", desc: "Поиск примеров кода на GitHub" },
196
- { id: "skills-anthropic", desc: "Скиллы Anthropic — дизайн, доки, MCP, PDF/PPTX/XLSX" },
197
- { id: "skills-vercel", desc: "Скиллы mattpocock — engineering workflow (grill, tdd, triage, architecture)" },
198
- { id: "playwright", desc: "Браузерные тесты — навигация, скриншоты, клики" },
199
- { id: "github", desc: "GitHub API — PRs, issues, репозитории" },
200
- ];
201
-
202
- export const MemoryPlugin = async ({ directory, worktree, client }) => {
203
- installExitHook();
204
- await ensureDir();
205
- let activeProjectKey = await scopeKey("project", worktree, directory);
206
- let identityResolveAt = 0;
207
-
208
- const currentProjectKey = async () => {
209
- const now = Date.now();
210
- if (now < identityResolveAt) return activeProjectKey;
211
- identityResolveAt = now + 2000;
212
- try {
213
- const path = client?.path?.get ? await client.path.get() : null;
214
- const wt = path?.worktree || worktree;
215
- const dir = path?.directory || directory;
216
- const key = await scopeKey("project", wt, dir);
217
- if (key !== activeProjectKey) activeProjectKey = key;
218
- } catch (e) {}
219
- return activeProjectKey;
220
- };
221
-
222
- return {
223
- "experimental.chat.messages.transform": async (_input, output) => {
224
- if (!output.messages?.length) return;
225
- const firstUser = output.messages.find((m) => m?.info?.role === "user");
226
- if (!firstUser?.parts?.length) return;
227
-
228
- if (firstUser.parts.some((p) => p.type === "text" && p.text.includes("<MEMORY>"))) return;
229
-
230
- const [globalFacts, projectFacts] = await Promise.all([
231
- readMemory(GLOBAL_KEY),
232
- readMemory(await currentProjectKey()),
233
- ]);
234
-
235
- const { getConfig } = await import("../mcp-server/config/config_manager.js");
236
- const config = getConfig();
237
- const injectLimit = config.injectLimit !== undefined ? config.injectLimit : 100;
238
-
239
- const context = buildMemoryContext(globalFacts, projectFacts, activeProjectKey, injectLimit);
240
- const ref = firstUser.parts[0];
241
- firstUser.parts.unshift({ ...ref, type: "text", text: context });
242
- },
243
-
244
- tool: {
245
- "list-mcp-tools": {
246
- description: "Показать список всех подключённых MCP серверов и их назначение",
247
- args: {},
248
- async execute() {
249
- const lines = MCP_SERVERS.map((s) => ` ${s.id.padEnd(20)} ${s.desc}`);
250
- return "Доступные MCP серверы:\n" + lines.join("\n");
251
- },
252
- },
253
- "mcp-reminder": {
254
- description: "Напомнить какие MCP инструменты подходят для текущей задачи. Вызови когда сомневаешься что выбрать.",
255
- args: {
256
- task: {
257
- type: "string",
258
- description: "Описание того что собираешься делать (опционально)",
259
- },
260
- },
261
- async execute({ task }) {
262
- if (task) {
263
- return `Для задачи "${task}" рекомендую посмотреть список через list-mcp-tools. Основные сценарии:\n- Работа с кодом → skills-vercel (grill, tdd, review), github\n- UI/дизайн → stitch, skills-anthropic (frontend-design, webapp-testing)\n- База данных → supabase, neon\n- Документы → skills-anthropic (docx, pdf, pptx, xlsx)\n- Поиск примеров → grep`;
264
- }
265
- return "Вызови list-mcp-tools чтобы увидеть все доступные MCP серверы";
266
- },
267
- },
268
- "remember": {
269
- description:
270
- "Save an important, durable fact to memory. Only use for high-signal information " +
271
- "(name, goals, constraints, tech preferences, project conventions). " +
272
- "docId/startLine/endLine/relationType are OPTIONAL and only used to link the fact to a " +
273
- "Knowledge Base document or line range; omit them when no linking is needed. " +
274
- "ttl is OPTIONAL (e.g. \x2790d\x27, \x272w\x27, \x2724h\x27) expired facts are shown with [EXPIRED] but not auto-deleted. " +
275
- "keep=true protects the fact from forget deletion unless force=true. " +
276
- "tags is OPTIONAL comma-separated text for filtering. " +
277
- "supersedes is OPTIONAL: a number, id, or text of a fact this one replaces. " +
278
- "Translate the fact into English and keep it concise. " +
279
- "scope: \x27project\x27 (default) or \x27global\x27",
280
- args: {
281
- fact: { type: "string", description: "The fact to remember, written in English" },
282
- title: { type: "string", description: "Optional title for the fact" },
283
- scope: {
284
- type: "string",
285
- description: "\x27project\x27 (default) or \x27global\x27",
286
- default: "project",
287
- },
288
- docId: { type: "string", description: "Optional document ID, title, or path to link this fact to" },
289
- startLine: { type: "number", description: "Optional starting line number in target document" },
290
- endLine: { type: "number", description: "Optional ending line number in target document" },
291
- relationType: {
292
- type: "string",
293
- description: "Relation type (e.g. \x27RULES_FOR\x27, \x27IMPLEMENTS\x27, \x27REFERENCES\x27)",
294
- default: "LINKS_TO",
295
- },
296
- ttl: { type: "string", description: "Optional time-to-live, e.g. \x2790d\x27, \x272w\x27, \x2724h\x27, \x2712m\x27" },
297
- keep: { type: "boolean", description: "Protect the fact from forget deletion unless force=true" },
298
- tags: { type: "string", description: "Optional comma-separated tags, e.g. \x27pref,arch\x27" },
299
- supersedes: { type: "string", description: "Optional number, id, or text of the fact this one replaces" },
300
- },
301
- async execute(args, { worktree, directory }) {
302
- const result = await rememberFact(args, { worktree, directory });
303
- await notify(client, result);
304
- return result;
305
- },
306
- },
307
-
308
- "recall": {
309
- description:
310
- "Show saved facts with any Agent-linked Knowledge Base documents/lines. " +
311
- "scope: \x27project\x27, \x27global\x27, \x27all\x27 (default), or \x27list_projects\x27. " +
312
- "Use project: \x27<directory path>\x27 to read facts of a specific project from any working directory. " +
313
- "query filters by keyword, tags by comma-separated tags, since/until by date (YYYY-MM-DD). " +
314
- "The response includes the store file paths.",
315
- args: {
316
- scope: {
317
- type: "string",
318
- description: "project, global, all (по умолчанию) или list_projects",
319
- default: "all",
320
- },
321
- project: { type: "string", description: "Directory path of the project to read facts from (e.g. \x27F:/projects/plugins/memory\x27)" },
322
- query: { type: "string", description: "Optional keyword filter; all space-separated terms must match" },
323
- tags: { type: "string", description: "Optional comma-separated tag filter (any match)" },
324
- since: { type: "string", description: "Optional start date filter, YYYY-MM-DD (inclusive)" },
325
- until: { type: "string", description: "Optional end date filter, YYYY-MM-DD (inclusive)" },
326
- mode: { type: "string", description: "Result mode: 'full' (with body, default) or 'headers' (title and badges only)", default: "full" },
327
- offset: { type: "number", description: "Pagination offset (optional)" },
328
- limit: { type: "number", description: "Pagination limit (optional)" },
329
- },
330
- async execute(args, { worktree, directory }) {
331
- return await recallFacts(args, { worktree, directory });
332
- },
333
- },
334
-
335
- "get_fact": {
336
- description: "Get the full text and metadata of a single fact by its metadata id.",
337
- args: {
338
- id: { type: "string", description: "The unique metadata id of the fact (e.g. \x278f3a2c\x27)" },
339
- scope: { type: "string", description: "\x27project\x27, \x27global\x27, or \x27all\x27 (default)", default: "all" },
340
- },
341
- async execute(args, { worktree, directory }) {
342
- return await getFactById(args, { worktree, directory });
343
- },
344
- },
345
- "forget": {
346
- description: "Удалить факт по номеру (см. recall), по диапазону (например '3-30', включительно) или тексту. Защищённые факты (remember с keep=true) пропускаются, если не передан force=true",
347
- args: {
348
- query: { type: "string", description: "Номер факта, диапазон вида '3-30' или текст для поиска" },
349
- scope: {
350
- type: "string",
351
- description: "project (по умолчанию) или global",
352
- default: "project",
353
- },
354
- force: { type: "boolean", description: "Удалить также защищённые (keep) факты" },
355
- },
356
- async execute(args, { worktree, directory }) {
357
- const result = await forgetFacts(args, { worktree, directory });
358
- if (result.startsWith("Memory updated")) await notify(client, result);
359
- return result;
360
- },
361
- },
362
- "update_fact": {
363
- description:
364
- "Update the text of an existing fact by number (from recall), id, or text match, " +
365
- "preserving its original date and metadata. Linked Knowledge Base documents are re-pointed to the new text.",
366
- args: {
367
- id: { type: "string", description: "Number (from recall), metadata id, or text of the fact to update" },
368
- newText: { type: "string", description: "New fact text" },
369
- title: { type: "string", description: "Optional new title for the fact" },
370
- scope: { type: "string", description: "\x27project\x27 (default) or \x27global\x27", default: "project" },
371
- },
372
- async execute(args, { worktree, directory }) {
373
- const result = await updateFactText(args, { worktree, directory });
374
- await notify(client, result);
375
- return result;
376
- },
377
- },
378
-
379
- "memory_info": {
380
- description: "Show memory storage paths (store files, MEMORY_DIR, SQLite DB), fact counts, and Knowledge Base stats.",
381
- args: {},
382
- async execute(_args, ctx = {}) {
383
- return await memoryInfo({}, { worktree: ctx.worktree ?? worktree, directory: ctx.directory ?? directory });
384
- },
385
- },
386
- "link_knowledge": {
387
- description:
388
- "Explicitly link a Notebook memory fact to a Knowledge Base document, section, or line range. " +
389
- "Creates Agent-driven Graph Edges connecting memory to RAG documents.",
390
- args: {
391
- action: {
392
- type: "string",
393
- description: "Action type: 'link' (default), 'list_links', 'get_doc_links'",
394
- default: "link",
395
- },
396
- factText: { type: "string", description: "Memory fact text or keyword" },
397
- docId: { type: "string", description: "Document ID, title, or file path" },
398
- scope: { type: "string", description: "'project' (default) or 'global'", default: "project" },
399
- startLine: { type: "number", description: "Starting line number in target document" },
400
- endLine: { type: "number", description: "Ending line number in target document" },
401
- relationType: {
402
- type: "string",
403
- description: "Relation type (e.g. 'RULES_FOR', 'IMPLEMENTS', 'EXPLAINS')",
404
- default: "LINKS_TO",
405
- },
406
- },
407
- async execute({ action, factText, docId, scope, startLine, endLine, relationType }, { worktree, directory }) {
408
- const { linkFactToDocument, getLinksForDoc, listAllLinks } = await import("../mcp-server/graph/knowledge_linker.js");
409
- const key = await scopeKey(scope || "project", worktree, directory);
410
- const act = action || "link";
411
-
412
- if (act === "link" || act === "list_links") {
413
- requireProjectKey(key);
414
- }
415
-
416
- if (act === "link") {
417
- if (!factText || !docId) {
418
- throw new Error("factText and docId are required parameters for link action");
419
- }
420
- const res = await linkFactToDocument({
421
- factKey: key,
422
- factText,
423
- docId,
424
- startLine,
425
- endLine,
426
- relationType: relationType || "LINKS_TO",
427
- });
428
- return JSON.stringify(res, null, 2);
429
- }
430
-
431
- if (act === "get_doc_links") {
432
- if (!docId) throw new Error("docId parameter is required for get_doc_links action");
433
- const links = await getLinksForDoc(docId);
434
- return JSON.stringify(links, null, 2);
435
- }
436
-
437
- if (act === "list_links") {
438
- const links = await listAllLinks(key);
439
- return JSON.stringify(links, null, 2);
440
- }
441
-
442
- throw new Error(`Unknown action: ${act}`);
443
- },
444
- },
445
- "ingest_document": {
446
- description:
447
- "Ingest a document into the RAG knowledge base. " +
448
- "Accepts local file paths, web URLs, or raw Markdown/text content. " +
449
- "For type='url' the page is fetched and its content is indexed (not just the URL). " +
450
- "Processes document through 3-tier hierarchy chunking (Big/Medium/Small), " +
451
- "computes dense vectors, and extracts GraphRAG code symbols.",
452
- args: {
453
- content: { type: "string", description: "Raw text content, file path, or web URL" },
454
- type: { type: "string", description: "Input content type: 'text', 'file', 'url' (url fetches the page content)", default: "text" },
455
- title: { type: "string", description: "Document title" },
456
- path: { type: "string", description: "Original document file path" },
457
- generateEmbeddings: { type: "boolean", description: "Compute dense vector embeddings", default: true },
458
- },
459
- async execute({ content, type, title, path, generateEmbeddings }) {
460
- const { ingestDocument } = await import("../mcp-server/ingest/pipeline.js");
461
- const result = await ingestDocument({
462
- content,
463
- type: type || "text",
464
- title: title || null,
465
- path: path || null,
466
- generateEmbeddings: generateEmbeddings !== false,
467
- });
468
- return JSON.stringify(
469
- {
470
- status: "success",
471
- docId: result.docId,
472
- title: result.title,
473
- sectionsCount: result.sectionsCount,
474
- microChunksCount: result.microChunksCount,
475
- deduplicated: result.deduplicated,
476
- },
477
- null,
478
- 2
479
- );
480
- },
481
- },
482
- "query_knowledge_base": {
483
- description:
484
- "Perform hybrid search (RSF/RRF BM25 full-text + dense vector similarity) across the RAG knowledge base. " +
485
- "Returns top-ranked candidate document sections with breadcrumbs, GraphRAG defined code symbols, and relevance scores.",
486
- args: {
487
- query: { type: "string", description: "Search query in natural language or symbol name" },
488
- limit: { type: "number", description: "Maximum number of sections to return", default: 5 },
489
- instruction: {
490
- type: "string",
491
- description: "Optional task-specific retrieval instruction shaping embedding focus",
492
- },
493
- generateEmbeddings: { type: "boolean", description: "Use vector search alongside BM25", default: true },
494
- },
495
- async execute({ query, limit, instruction, generateEmbeddings }) {
496
- const { hybridQuery } = await import("../mcp-server/retrieval/retriever.js");
497
- const { getConfig } = await import("../mcp-server/config/config_manager.js");
498
- const activeConfig = getConfig();
499
-
500
- const results = await hybridQuery({
501
- query,
502
- limit: limit || 5,
503
- generateEmbeddings: generateEmbeddings !== false,
504
- instruction: instruction || null,
505
- });
506
-
507
- if (!results || results.length === 0) {
508
- return `[Active Model: ${activeConfig.embeddingModel}]\nNo matching knowledge found for query.`;
509
- }
510
-
511
- const headerNote = `[Active Model: ${activeConfig.embeddingModel} | Fusion: ${activeConfig.fusionAlgorithm.toUpperCase()}]\n\n`;
512
-
513
- const formatted = results
514
- .map((r, i) => {
515
- let header = `### [${i + 1}] ${r.doc_title || "Untitled"}`;
516
- if (r.heading) header += ` > ${r.heading}`;
517
- if (r.breadcrumbs) header += ` (${r.breadcrumbs})`;
518
- let body = `Score: ${(r.score || 0).toFixed(4)}\n`;
519
- if (r.defined_symbols && r.defined_symbols.length > 0) {
520
- body += `Defined Symbols: ${r.defined_symbols.join(", ")}\n`;
521
- }
522
- body += `\n${r.snippet || r.full_section_content || ""}`;
523
- return `${header}\n${body}`;
524
- })
525
- .join("\n\n---\n\n");
526
-
527
- return headerNote + formatted;
528
- },
529
- },
530
- "manage_knowledge_base": {
531
- description:
532
- "Manage the RAG knowledge base: inspect stats, list documents, read full raw document, delete documents, or export/import snapshots.",
533
- args: {
534
- action: {
535
- type: "string",
536
- description: "Management action: 'stats', 'list', 'read_document', 'delete', 'export_snapshot', 'import_snapshot'",
537
- },
538
- docId: { type: "string", description: "Document ID, title, or path (required for read_document and delete)" },
539
- snapshotPath: { type: "string", description: "File path for snapshot export/import" },
540
- },
541
- async execute({ action, docId, snapshotPath }) {
542
- const { getDatabase } = await import("../mcp-server/db/database.js");
543
- const db = await getDatabase();
544
-
545
- if (action === "stats") {
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;
149
+ const hasLimit = Number.isFinite(Number(limit)) && Number(limit) > 0;
150
+ const sliced = hasLimit ? combined.slice(0, Number(limit)) : combined;
151
+
152
+ const formattedLines = [];
153
+ for (let i = 0; i < sliced.length; i++) {
154
+ formattedLines.push(`${i + 1}. ${displayFact(sliced[i], now)}`);
155
+ }
156
+
157
+ if (hasLimit && activeEntries.length > Number(limit)) {
158
+ const remaining = activeEntries.length - Number(limit);
159
+ formattedLines.push(`... and ${remaining} more of ${activeEntries.length} memories (use recall tool to fetch all)`);
160
+ }
161
+
162
+ return formattedLines.join("\n");
163
+ }
164
+
165
+ export function buildMemoryContext(globalFacts, projectFacts, projectKey, injectLimit, now = Date.now()) {
166
+ const parts = [MEMORY_INSTRUCTION];
167
+
168
+ if (globalFacts.length) {
169
+ const formatted = formatInjectedFacts(globalFacts, injectLimit, now);
170
+ if (formatted) parts.push("## Global\n" + formatted);
171
+ }
172
+ if (projectFacts.length) {
173
+ const formatted = formatInjectedFacts(projectFacts, injectLimit, now);
174
+ if (formatted) parts.push(`## Project: ${projectKey}\n` + formatted);
175
+ }
176
+ return `<MEMORY>\n${parts.join("\n\n")}\n</MEMORY>`;
177
+ }
178
+
179
+ const MCP_SERVERS = [
180
+ { id: "context7", desc: "Документация библиотек и фреймворков (Context7)" },
181
+ { id: "supabase", desc: "БД Supabase — SQL, миграции, edge functions" },
182
+ { id: "stitch", desc: "UI дизайн — генерация и редактирование экранов" },
183
+ { id: "neon", desc: "БД Neon — PostgreSQL, схемы, миграции" },
184
+ { id: "linear", desc: "Linear — задачи, проекты, документы" },
185
+ { id: "grep", desc: "Поиск примеров кода на GitHub" },
186
+ { id: "skills-anthropic", desc: "Скиллы Anthropic — дизайн, доки, MCP, PDF/PPTX/XLSX" },
187
+ { id: "skills-vercel", desc: "Скиллы mattpocock — engineering workflow (grill, tdd, triage, architecture)" },
188
+ { id: "playwright", desc: "Браузерные тесты — навигация, скриншоты, клики" },
189
+ { id: "github", desc: "GitHub API — PRs, issues, репозитории" },
190
+ ];
191
+
192
+ export const MemoryPlugin = async ({ directory, worktree, client }) => {
193
+ installExitHook();
194
+ await ensureDir();
195
+ let activeProjectKey = await scopeKey("project", worktree, directory);
196
+ let identityResolveAt = 0;
197
+
198
+ const currentProjectKey = async () => {
199
+ const now = Date.now();
200
+ if (now < identityResolveAt) return activeProjectKey;
201
+ identityResolveAt = now + 2000;
202
+ try {
203
+ const path = client?.path?.get ? await client.path.get() : null;
204
+ const wt = path?.worktree || worktree;
205
+ const dir = path?.directory || directory;
206
+ const key = await scopeKey("project", wt, dir);
207
+ if (key !== activeProjectKey) activeProjectKey = key;
208
+ } catch (e) {}
209
+ return activeProjectKey;
210
+ };
211
+
212
+ return {
213
+ "experimental.chat.messages.transform": async (_input, output) => {
214
+ if (!output.messages?.length) return;
215
+ const firstUser = output.messages.find((m) => m?.info?.role === "user");
216
+ if (!firstUser?.parts?.length) return;
217
+
218
+ if (firstUser.parts.some((p) => p.type === "text" && p.text.includes("<MEMORY>"))) return;
219
+
220
+ const [globalFacts, projectFacts] = await Promise.all([
221
+ readMemory(GLOBAL_KEY),
222
+ readMemory(await currentProjectKey()),
223
+ ]);
224
+
225
+ const context = buildMemoryContext(globalFacts, projectFacts, activeProjectKey, null);
226
+ const ref = firstUser.parts[0];
227
+ firstUser.parts.unshift({ ...ref, type: "text", text: context });
228
+ },
229
+
230
+ tool: {
231
+ "list-mcp-tools": {
232
+ description: "Показать список всех подключённых MCP серверов и их назначение",
233
+ args: {},
234
+ async execute() {
235
+ const lines = MCP_SERVERS.map((s) => ` ${s.id.padEnd(20)} ${s.desc}`);
236
+ return "Доступные MCP серверы:\n" + lines.join("\n");
237
+ },
238
+ },
239
+ "mcp-reminder": {
240
+ description: "Напомнить какие MCP инструменты подходят для текущей задачи. Вызови когда сомневаешься что выбрать.",
241
+ args: {
242
+ task: {
243
+ type: "string",
244
+ description: "Описание того что собираешься делать (опционально)",
245
+ },
246
+ },
247
+ async execute({ task }) {
248
+ if (task) {
249
+ return `Для задачи "${task}" рекомендую посмотреть список через list-mcp-tools. Основные сценарии:\n- Работа с кодом → skills-vercel (grill, tdd, review), github\n- UI/дизайн → stitch, skills-anthropic (frontend-design, webapp-testing)\n- База данных → supabase, neon\n- Документы → skills-anthropic (docx, pdf, pptx, xlsx)\n- Поиск примеров → grep`;
250
+ }
251
+ return "Вызови list-mcp-tools чтобы увидеть все доступные MCP серверы";
252
+ },
253
+ },
254
+ "remember": {
255
+ description:
256
+ "Save an important, durable fact to memory. Only use for high-signal information " +
257
+ "(name, goals, constraints, tech preferences, project conventions). " +
258
+ "docId/startLine/endLine/relationType are OPTIONAL and only used to link the fact to a " +
259
+ "Knowledge Base document or line range; omit them when no linking is needed. " +
260
+ "ttl is OPTIONAL (e.g. \x2790d\x27, \x272w\x27, \x2724h\x27) — expired facts are shown with [EXPIRED] but not auto-deleted. " +
261
+ "keep=true protects the fact from forget deletion unless force=true. " +
262
+ "tags is OPTIONAL comma-separated text for filtering. " +
263
+ "supersedes is OPTIONAL: a number, id, or text of a fact this one replaces. " +
264
+ "Translate the fact into English and keep it concise. " +
265
+ "scope: \x27project\x27 (default) or \x27global\x27",
266
+ args: {
267
+ fact: { type: "string", description: "The fact to remember, written in English" },
268
+ title: { type: "string", description: "Optional title for the fact" },
269
+ scope: {
270
+ type: "string",
271
+ description: "\x27project\x27 (default) or \x27global\x27",
272
+ default: "project",
273
+ },
274
+ directory: { type: "string", description: "Optional workspace/project directory path to target when scope='project' (e.g. 'F:/projects/my-app')" },
275
+ project: { type: "string", description: "Alias for directory" },
276
+ docId: { type: "string", description: "Optional document ID, title, or path to link this fact to" },
277
+ startLine: { type: "number", description: "Optional starting line number in target document" },
278
+ endLine: { type: "number", description: "Optional ending line number in target document" },
279
+ relationType: {
280
+ type: "string",
281
+ description: "Relation type (e.g. \x27RULES_FOR\x27, \x27IMPLEMENTS\x27, \x27REFERENCES\x27)",
282
+ default: "LINKS_TO",
283
+ },
284
+ ttl: { type: "string", description: "Optional time-to-live, e.g. \x2790d\x27, \x272w\x27, \x2724h\x27, \x2712m\x27" },
285
+ keep: { type: "boolean", description: "Protect the fact from forget deletion unless force=true" },
286
+ tags: { type: "string", description: "Optional comma-separated tags, e.g. \x27pref,arch\x27" },
287
+ supersedes: { type: "string", description: "Optional number, id, or text of the fact this one replaces" },
288
+ },
289
+ async execute(args, { worktree, directory }) {
290
+ const result = await rememberFact(args, { worktree, directory });
291
+ await notify(client, result);
292
+ return result;
293
+ },
294
+ },
295
+
296
+ "recall": {
297
+ description:
298
+ "Show saved facts with any Agent-linked Knowledge Base documents/lines. " +
299
+ "scope: \x27project\x27, \x27global\x27, \x27all\x27 (default), or \x27list_projects\x27. " +
300
+ "Use directory: \x27<directory path>\x27 to read facts of a specific project from any working directory. " +
301
+ "query filters by keyword, tags by comma-separated tags, since/until by date (YYYY-MM-DD). " +
302
+ "The response includes the store file paths.",
303
+ args: {
304
+ scope: {
305
+ type: "string",
306
+ description: "project, global, all (по умолчанию) или list_projects",
307
+ default: "all",
308
+ },
309
+ directory: { type: "string", description: "Directory path of the project to read facts from (e.g. \x27F:/projects/plugins/memory\x27)" },
310
+ project: { type: "string", description: "Alias for directory" },
311
+ query: { type: "string", description: "Optional keyword filter; all space-separated terms must match" },
312
+ tags: { type: "string", description: "Optional comma-separated tag filter (any match)" },
313
+ since: { type: "string", description: "Optional start date filter, YYYY-MM-DD (inclusive)" },
314
+ until: { type: "string", description: "Optional end date filter, YYYY-MM-DD (inclusive)" },
315
+ mode: { type: "string", description: "Result mode: 'full' (with body, default) or 'headers' (title and badges only)", default: "full" },
316
+ offset: { type: "number", description: "Pagination offset (optional)" },
317
+ limit: { type: "number", description: "Pagination limit (optional)" },
318
+ includeSuperseded: { type: "boolean", description: "Include superseded historical facts (excluded by default)", default: false },
319
+ },
320
+ async execute(args, { worktree, directory }) {
321
+ return await recallFacts(args, { worktree, directory });
322
+ },
323
+ },
324
+
325
+ "get_fact": {
326
+ description: "Get the full text and metadata of a single fact by its metadata id.",
327
+ args: {
328
+ id: { type: "string", description: "The unique metadata id of the fact (e.g. \x278f3a2c\x27)" },
329
+ scope: { type: "string", description: "\x27project\x27, \x27global\x27, or \x27all\x27 (default)", default: "all" },
330
+ directory: { type: "string", description: "Optional workspace/project directory path" },
331
+ project: { type: "string", description: "Alias for directory" },
332
+ },
333
+ async execute(args, { worktree, directory }) {
334
+ return await getFactById(args, { worktree, directory });
335
+ },
336
+ },
337
+ "forget": {
338
+ description: "Удалить факт по номеру (см. recall), по диапазону (например '3-30', включительно) или тексту. Защищённые факты (remember с keep=true) пропускаются, если не передан force=true",
339
+ args: {
340
+ query: { type: "string", description: "Номер факта, диапазон вида '3-30' или текст для поиска" },
341
+ scope: {
342
+ type: "string",
343
+ description: "project (по умолчанию) или global",
344
+ default: "project",
345
+ },
346
+ directory: { type: "string", description: "Optional workspace/project directory path" },
347
+ project: { type: "string", description: "Alias for directory" },
348
+ force: { type: "boolean", description: "Удалить также защищённые (keep) факты" },
349
+ },
350
+ async execute(args, { worktree, directory }) {
351
+ const result = await forgetFacts(args, { worktree, directory });
352
+ if (result.startsWith("Memory updated")) await notify(client, result);
353
+ return result;
354
+ },
355
+ },
356
+ "update_fact": {
357
+ description:
358
+ "Update the text of an existing fact by number (from recall), id, or text match, " +
359
+ "preserving its original date and metadata. Linked Knowledge Base documents are re-pointed to the new text.",
360
+ args: {
361
+ id: { type: "string", description: "Number (from recall), metadata id, or text of the fact to update" },
362
+ newText: { type: "string", description: "New fact text" },
363
+ title: { type: "string", description: "Optional new title for the fact" },
364
+ scope: { type: "string", description: "\x27project\x27 (default) or \x27global\x27", default: "project" },
365
+ directory: { type: "string", description: "Optional workspace/project directory path" },
366
+ project: { type: "string", description: "Alias for directory" },
367
+ },
368
+ async execute(args, { worktree, directory }) {
369
+ const result = await updateFactText(args, { worktree, directory });
370
+ await notify(client, result);
371
+ return result;
372
+ },
373
+ },
374
+
375
+ "memory_info": {
376
+ description: "Show memory storage paths (store files, MEMORY_DIR, SQLite DB), fact counts, and Knowledge Base stats.",
377
+ args: {
378
+ directory: { type: "string", description: "Optional workspace/project directory path to inspect (default: current directory)" },
379
+ project: { type: "string", description: "Alias for directory" },
380
+ },
381
+ async execute(args, ctx = {}) {
382
+ return await memoryInfo(args, { worktree: ctx.worktree ?? worktree, directory: ctx.directory ?? directory });
383
+ },
384
+ },
385
+ "link_knowledge": {
386
+ description:
387
+ "Explicitly link a Notebook memory fact to a Knowledge Base document, section, or line range. " +
388
+ "Creates Agent-driven Graph Edges connecting memory to RAG documents.",
389
+ args: {
390
+ action: {
391
+ type: "string",
392
+ description: "Action type: 'link' (default), 'list_links', 'get_doc_links'",
393
+ default: "link",
394
+ },
395
+ factText: { type: "string", description: "Memory fact text or keyword" },
396
+ docId: { type: "string", description: "Document ID, title, or file path" },
397
+ scope: { type: "string", description: "'project' (default) or 'global'", default: "project" },
398
+ directory: { type: "string", description: "Optional workspace/project directory path" },
399
+ project: { type: "string", description: "Alias for directory" },
400
+ startLine: { type: "number", description: "Starting line number in target document" },
401
+ endLine: { type: "number", description: "Ending line number in target document" },
402
+ relationType: {
403
+ type: "string",
404
+ description: "Relation type (e.g. 'RULES_FOR', 'IMPLEMENTS', 'EXPLAINS')",
405
+ default: "LINKS_TO",
406
+ },
407
+ },
408
+ async execute({ action, factText, docId, scope, directory, project, startLine, endLine, relationType }, { worktree, directory: ctxDir }) {
409
+ const { linkFactToDocument, getLinksForDoc, listAllLinks } = await import("../mcp-server/graph/knowledge_linker.js");
410
+ const effectiveDir = directory || project || ctxDir;
411
+ const key = await scopeKey(scope || "project", worktree, effectiveDir);
412
+ const act = action || "link";
413
+
414
+ if (act === "link" || act === "list_links") {
415
+ requireProjectKey(key);
416
+ }
417
+
418
+ if (act === "link") {
419
+ if (!factText || !docId) {
420
+ throw new Error("factText and docId are required parameters for link action");
421
+ }
422
+ const facts = await readMemory(key);
423
+ const needle = factText.toLowerCase().trim();
424
+ const matches = facts.filter((entry) => {
425
+ const body = factBody(entry).toLowerCase();
426
+ return body === needle || body.includes(needle) || entry.toLowerCase().includes(needle);
427
+ });
428
+ if (matches.length === 0) throw new Error(`Notebook fact not found for link: ${factText}`);
429
+ if (matches.length > 1) throw new Error(`Notebook fact match is ambiguous; use a more specific factText: ${factText}`);
430
+ const resolvedFactText = factBody(matches[0]);
431
+ const res = await linkFactToDocument({
432
+ factKey: key,
433
+ factText: resolvedFactText,
434
+ docId,
435
+ startLine,
436
+ endLine,
437
+ relationType: relationType || "LINKS_TO",
438
+ });
439
+ return JSON.stringify(res, null, 2);
440
+ }
441
+
442
+ if (act === "get_doc_links") {
443
+ if (!docId) throw new Error("docId parameter is required for get_doc_links action");
444
+ const allowedScopes = key === GLOBAL_KEY ? [GLOBAL_KEY] : [GLOBAL_KEY, key];
445
+ const links = await getLinksForDoc(docId, allowedScopes);
446
+ return JSON.stringify(links, null, 2);
447
+ }
448
+
449
+ if (act === "list_links") {
450
+ const links = await listAllLinks(key);
451
+ return JSON.stringify(links, null, 2);
452
+ }
453
+
454
+ throw new Error(`Unknown action: ${act}`);
455
+ },
456
+ },
457
+ "ingest_document": {
458
+ description:
459
+ "Selectively preserve a reliable, reusable source in the RAG knowledge base; do not ingest everything encountered. " +
460
+ "Accepts local file paths, web URLs, or raw Markdown/text content. " +
461
+ "For type='url' the page is fetched and its content is indexed (not just the URL). " +
462
+ "Processes document through 3-tier hierarchy chunking (Big/Medium/Small), " +
463
+ "computes dense vectors, and extracts GraphRAG code symbols.",
464
+ args: {
465
+ content: { type: "string", description: "Raw text content, file path, or web URL" },
466
+ type: { type: "string", description: "Input content type: 'text', 'file', 'url' (url fetches the page content)", default: "text" },
467
+ title: { type: "string", description: "Document title" },
468
+ path: { type: "string", description: "Original document file path" },
469
+ scope: { type: "string", description: "RAG visibility: current Git project (default) or global", default: "project" },
470
+ directory: { type: "string", description: "Optional workspace/project directory path to target" },
471
+ project: { type: "string", description: "Alias for directory" },
472
+ generateEmbeddings: { type: "boolean", description: "Compute dense vector embeddings", default: true },
473
+ },
474
+ async execute({ content, type, title, path, scope, directory, project, generateEmbeddings }, { worktree, directory: ctxDir }) {
475
+ const { ingestDocument } = await import("../mcp-server/ingest/pipeline.js");
476
+ const effectiveDir = directory || project || ctxDir;
477
+ const projectScope = await resolveRagScopeKey(scope || "project", { worktree, directory: effectiveDir });
478
+ const result = await ingestDocument({
479
+ content,
480
+ type: type || "text",
481
+ title: title || null,
482
+ path: path || null,
483
+ generateEmbeddings: generateEmbeddings !== false,
484
+ projectScope,
485
+ });
486
+ return JSON.stringify(
487
+ {
488
+ status: "success",
489
+ docId: result.docId,
490
+ title: result.title,
491
+ sectionsCount: result.sectionsCount,
492
+ microChunksCount: result.microChunksCount,
493
+ deduplicated: result.deduplicated,
494
+ scope: result.projectScope,
495
+ },
496
+ null,
497
+ 2
498
+ );
499
+ },
500
+ },
501
+ "query_knowledge_base": {
502
+ description:
503
+ "Perform project-isolated hybrid search (RSF/RRF BM25 full-text + dense vector similarity) across the RAG knowledge base. " +
504
+ "Returns top-ranked candidate document sections with breadcrumbs, GraphRAG defined code symbols, and relevance scores.",
505
+ args: {
506
+ query: { type: "string", description: "Search query in natural language or symbol name" },
507
+ limit: { type: "number", description: "Maximum number of sections to return", default: 5 },
508
+ instruction: {
509
+ type: "string",
510
+ description: "Optional task-specific retrieval instruction shaping embedding focus",
511
+ },
512
+ generateEmbeddings: { type: "boolean", description: "Use vector search alongside BM25", default: true },
513
+ scope: { type: "string", description: "Search global + current project (default), project only, or global only", default: "all" },
514
+ directory: { type: "string", description: "Optional workspace/project directory path to target" },
515
+ project: { type: "string", description: "Alias for directory" },
516
+ },
517
+ async execute({ query, limit, instruction, generateEmbeddings, scope, directory, project }, { worktree, directory: ctxDir }) {
518
+ const { hybridQuery } = await import("../mcp-server/retrieval/retriever.js");
519
+ const { getConfig } = await import("../mcp-server/config/config_manager.js");
520
+ const activeConfig = getConfig();
521
+ const effectiveDir = directory || project || ctxDir;
522
+ const scopeKeys = await resolveRagScopeKeys(scope || "all", { worktree, directory: effectiveDir });
523
+
524
+ const results = await hybridQuery({
525
+ query,
526
+ limit: limit || 5,
527
+ generateEmbeddings: generateEmbeddings !== false,
528
+ instruction: instruction || null,
529
+ scopeKeys,
530
+ });
531
+
532
+ if (!results || results.length === 0) {
533
+ return `[Active Model: ${activeConfig.embeddingModel}]\nNo matching knowledge found for query.`;
534
+ }
535
+
536
+ const headerNote = `[Active Model: ${activeConfig.embeddingModel} | Fusion: ${activeConfig.fusionAlgorithm.toUpperCase()}]\n\n`;
537
+
538
+ const formatted = results
539
+ .map((r, i) => {
540
+ let header = `### [${i + 1}] ${r.doc_title || "Untitled"}`;
541
+ if (r.heading) header += ` > ${r.heading}`;
542
+ if (r.breadcrumbs) header += ` (${r.breadcrumbs})`;
543
+ let body = `Score: ${(r.score || 0).toFixed(4)}\n`;
544
+ if (r.defined_symbols && r.defined_symbols.length > 0) {
545
+ body += `Defined Symbols: ${r.defined_symbols.join(", ")}\n`;
546
+ }
547
+ body += `\n${r.snippet || r.full_section_content || ""}`;
548
+ return `${header}\n${body}`;
549
+ })
550
+ .join("\n\n---\n\n");
551
+
552
+ return headerNote + formatted;
553
+ },
554
+ },
555
+ "batch_query_knowledge_base": {
556
+ description:
557
+ "Execute multiple project-isolated hybrid searches in one call. " +
558
+ "All query embeddings are computed in one ONNX pass and results are returned in input order.",
559
+ args: {
560
+ queries: { type: "array", items: { type: "string" }, description: "Search queries to execute in one batch" },
561
+ limit: { type: "number", description: "Maximum sections per query", default: 5 },
562
+ instruction: { type: "string", description: "Optional retrieval instruction applied to every query" },
563
+ generateEmbeddings: { type: "boolean", description: "Use vector search alongside BM25", default: true },
564
+ scope: { type: "string", description: "Search global + current project (default), project only, or global only", default: "all" },
565
+ directory: { type: "string", description: "Optional workspace/project directory path to target" },
566
+ project: { type: "string", description: "Alias for directory" },
567
+ },
568
+ async execute({ queries, limit, instruction, generateEmbeddings, scope, directory, project }, { worktree, directory: ctxDir }) {
569
+ const { batchHybridQuery } = await import("../mcp-server/retrieval/retriever.js");
570
+ const { getConfig } = await import("../mcp-server/config/config_manager.js");
571
+ const activeConfig = getConfig();
572
+ const effectiveDir = directory || project || ctxDir;
573
+ const scopeKeys = await resolveRagScopeKeys(scope || "all", { worktree, directory: effectiveDir });
574
+ const allResults = await batchHybridQuery(queries, {
575
+ limit: limit || 5,
576
+ generateEmbeddings: generateEmbeddings !== false,
577
+ instruction: instruction || null,
578
+ scopeKeys,
579
+ });
580
+
581
+ const formatted = allResults.map((results, queryIndex) => {
582
+ const header = `## Query ${queryIndex + 1}: "${queries[queryIndex]}"\n`;
583
+ if (!results || results.length === 0) return `${header}_No results found._`;
584
+ return header + results.map((result, resultIndex) => {
585
+ let itemHeader = `### [${resultIndex + 1}] ${result.doc_title || "Untitled"}`;
586
+ if (result.heading) itemHeader += ` > ${result.heading}`;
587
+ if (result.breadcrumbs) itemHeader += ` (${result.breadcrumbs})`;
588
+ let body = `Score: ${(result.score || 0).toFixed(4)}`;
589
+ if (result.retrieval_policy && result.retrieval_policy !== "micro_chunk") {
590
+ body += ` [${result.retrieval_policy}]`;
591
+ }
592
+ if (result.defined_symbols && result.defined_symbols.length > 0) {
593
+ body += `\nDefined Symbols: ${result.defined_symbols.join(", ")}`;
594
+ }
595
+ body += `\n\n${result.snippet || result.full_section_content || ""}`;
596
+ return `${itemHeader}\n${body}`;
597
+ }).join("\n\n---\n\n");
598
+ }).join("\n\n===\n\n");
599
+
600
+ return `[Active Model: ${activeConfig.embeddingModel} | Fusion: ${activeConfig.fusionAlgorithm.toUpperCase()} | ${queries.length} queries]\n\n${formatted}`;
601
+ },
602
+ },
603
+ "manage_knowledge_base": {
604
+ description:
605
+ "Manage the project-isolated RAG knowledge base: inspect stats, list documents, read full raw document, unlink/delete documents, or export/import complete snapshots.",
606
+ args: {
607
+ action: {
608
+ type: "string",
609
+ description: "Management action: 'stats', 'list', 'read_document', 'delete', 'export_snapshot', 'import_snapshot'",
610
+ },
611
+ docId: { type: "string", description: "Document ID, title, or path (required for read_document and delete)" },
612
+ snapshotPath: { type: "string", description: "File path for snapshot export/import" },
613
+ scope: { type: "string", description: "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" },
614
+ directory: { type: "string", description: "Optional workspace/project directory path to target" },
615
+ project: { type: "string", description: "Alias for directory" },
616
+ },
617
+ async execute({ action, docId, snapshotPath, scope, directory, project }, { worktree, directory: ctxDir }) {
618
+ const { getDatabase } = await import("../mcp-server/db/database.js");
619
+ const db = await getDatabase();
620
+ const effectiveDir = directory || project || ctxDir;
621
+ const scopeKeys = ["stats", "list", "read_document", "delete"].includes(action)
622
+ ? await resolveManageRagScopeKeys(action, scope, { worktree, directory: effectiveDir })
623
+ : null;
624
+ const placeholders = scopeKeys ? scopeKeys.map(() => "?").join(",") : "";
625
+ const visibleDocWhere = scopeKeys
626
+ ? `EXISTS (SELECT 1 FROM document_scopes ds WHERE ds.doc_id = d.id AND ds.scope_key IN (${placeholders}))`
627
+ : "1=1";
628
+
629
+ if (action === "stats") {
630
+ const docCountRow = await db.prepare(`SELECT COUNT(*) as cnt FROM documents d WHERE ${visibleDocWhere}`).get(...scopeKeys);
631
+ const docCount = docCountRow ? docCountRow.cnt : 0;
632
+ 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);
633
+ const secCount = secCountRow ? secCountRow.cnt : 0;
634
+ 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);
635
+ const chunkCount = chunkCountRow ? chunkCountRow.cnt : 0;
636
+ const visibleDocIds = await db.prepare(`SELECT d.id FROM documents d WHERE ${visibleDocWhere}`).all(...scopeKeys);
637
+ let edgeCount = 0;
638
+ if (visibleDocIds.length > 0) {
639
+ const docIds = visibleDocIds.map((row) => row.id);
640
+ const docPlaceholders = docIds.map(() => "?").join(",");
641
+ const ownedRows = await db.prepare(`
642
+ SELECT id FROM sections WHERE doc_id IN (${docPlaceholders})
643
+ UNION SELECT id FROM medium_chunks WHERE doc_id IN (${docPlaceholders})
644
+ UNION SELECT id FROM micro_chunks WHERE doc_id IN (${docPlaceholders})
645
+ `).all(...docIds, ...docIds, ...docIds);
646
+ const ownedIds = [...docIds, ...ownedRows.map((row) => row.id)];
647
+ const edgePlaceholders = ownedIds.map(() => "?").join(",");
648
+ 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);
649
+ edgeCount = edgeCountRow ? edgeCountRow.cnt : 0;
650
+ }
554
651
  return JSON.stringify(
555
652
  {
556
653
  documents: docCount,
@@ -563,18 +660,18 @@ export const MemoryPlugin = async ({ directory, worktree, client }) => {
563
660
  );
564
661
  }
565
662
 
566
- if (action === "list") {
567
- const docs = await db
568
- .prepare("SELECT id, title, path, blob_hash, created_at FROM documents ORDER BY created_at DESC")
569
- .all();
663
+ if (action === "list") {
664
+ const docs = await db
665
+ .prepare(`SELECT d.id, d.title, d.path, d.blob_hash, d.created_at FROM documents d WHERE ${visibleDocWhere} ORDER BY d.created_at DESC`)
666
+ .all(...scopeKeys);
570
667
  return JSON.stringify(docs, null, 2);
571
668
  }
572
669
 
573
670
  if (action === "read_document") {
574
671
  if (!docId) throw new Error("docId parameter is required for read_document action");
575
- const doc = await db
576
- .prepare("SELECT id, title, path, blob_hash, created_at FROM documents WHERE id = ? OR path = ? OR title = ?")
577
- .get(docId, docId, docId);
672
+ const doc = await db
673
+ .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}`)
674
+ .get(docId, docId, docId, ...scopeKeys);
578
675
  if (!doc) {
579
676
  throw new Error(`Document not found in knowledge base for docId: ${docId}`);
580
677
  }
@@ -593,10 +690,24 @@ export const MemoryPlugin = async ({ directory, worktree, client }) => {
593
690
  );
594
691
  }
595
692
 
596
- if (action === "delete") {
597
- if (!docId) throw new Error("docId parameter is required for delete action");
598
- const { deleteDocument } = await import("../mcp-server/ingest/pipeline.js");
599
- const result = await deleteDocument(docId, db);
693
+ if (action === "delete") {
694
+ if (!docId) throw new Error("docId parameter is required for delete action");
695
+ const visible = await db
696
+ .prepare(`SELECT d.id FROM documents d WHERE (d.id = ? OR d.path = ? OR d.title = ?) AND ${visibleDocWhere}`)
697
+ .get(docId, docId, docId, ...scopeKeys);
698
+ if (!visible) throw new Error(`Document not found in the selected RAG scope for docId: ${docId}`);
699
+ const scopeRemoval = await removeDocumentScopes(db, visible.id, scopeKeys);
700
+ if (scopeRemoval.remainingScopes > 0) {
701
+ return JSON.stringify({
702
+ deleted: false,
703
+ unlinked: true,
704
+ docId: visible.id,
705
+ removedScopes: scopeRemoval.removedScopes,
706
+ remainingScopes: scopeRemoval.remainingScopes,
707
+ }, null, 2);
708
+ }
709
+ const { deleteDocument } = await import("../mcp-server/ingest/pipeline.js");
710
+ const result = await deleteDocument(visible.id, db);
600
711
  return JSON.stringify(result, null, 2);
601
712
  }
602
713
 
@@ -683,7 +794,7 @@ export const MemoryPlugin = async ({ directory, worktree, client }) => {
683
794
  let migrated = false;
684
795
  const legacyPathKey = canonicalPath(dir);
685
796
  const legacyEntries = await readMemory(legacyPathKey);
686
- if (legacyEntries && legacyEntries.length > 0) {
797
+ if (legacyEntries && legacyEntries.length > 0) {
687
798
  const gitEntries = await readMemory(key);
688
799
  const seen = new Set(gitEntries.map((e) => factBody(e).toLowerCase().trim()));
689
800
  let mergedCount = 0;
@@ -706,8 +817,11 @@ export const MemoryPlugin = async ({ directory, worktree, client }) => {
706
817
  const { unlink } = await import("fs/promises");
707
818
  await unlink(legacyFp);
708
819
  }
709
- } catch (e) {}
710
- }
820
+ } catch (e) {}
821
+ }
822
+ const { moveKnowledgeScope } = await import("../mcp-server/graph/knowledge_linker.js");
823
+ const migratedKnowledge = await moveKnowledgeScope(db, legacyPathKey, key);
824
+ if (migratedKnowledge.movedLinks > 0 || migratedKnowledge.movedDocuments > 0) migrated = true;
711
825
 
712
826
  const res = {
713
827
  status: "success",
@@ -794,9 +908,11 @@ export const MemoryPlugin = async ({ directory, worktree, client }) => {
794
908
 
795
909
  await writeMemory(targetKey, targetFacts);
796
910
 
797
- await db.prepare("UPDATE project_aliases SET identity_key = ? WHERE identity_key = ?;").run(targetKey, sourceKey);
798
- await upsertIdentity(db, { key: targetKey, name: sourceIdentity.name, primaryRemote: normalizeRemoteUrl(remote) });
799
- await removeIdentity(db, sourceKey);
911
+ await upsertIdentity(db, { key: targetKey, name: sourceIdentity.name, primaryRemote: normalizeRemoteUrl(remote) });
912
+ await db.prepare("UPDATE project_aliases SET identity_key = ? WHERE identity_key = ?;").run(targetKey, sourceKey);
913
+ const { moveKnowledgeScope } = await import("../mcp-server/graph/knowledge_linker.js");
914
+ const movedKnowledge = await moveKnowledgeScope(db, sourceKey, targetKey);
915
+ await removeIdentity(db, sourceKey);
800
916
 
801
917
  try {
802
918
  const sourceFp = storeFilePath(sourceKey);
@@ -811,7 +927,9 @@ export const MemoryPlugin = async ({ directory, worktree, client }) => {
811
927
  status: "success",
812
928
  sourceKey,
813
929
  targetKey,
814
- mergedFacts: mergedCount
930
+ mergedFacts: mergedCount,
931
+ movedKnowledgeLinks: movedKnowledge.movedLinks,
932
+ movedRagDocuments: movedKnowledge.movedDocuments
815
933
  };
816
934
  await notify(client, "Project memory relinked");
817
935
  return JSON.stringify(res, null, 2);