@lotargo/memory_plugin 1.4.620 → 1.5.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.
Files changed (35) hide show
  1. package/README.md +352 -334
  2. package/mcp-server/admin/auth.js +293 -42
  3. package/mcp-server/cli/direct_commands.js +313 -0
  4. package/mcp-server/cli/handlers/cloud_actions.js +138 -0
  5. package/mcp-server/cli/handlers/diagnostics_actions.js +107 -0
  6. package/mcp-server/cli/handlers/engine_actions.js +214 -0
  7. package/mcp-server/cli/handlers/prompt_actions.js +24 -0
  8. package/mcp-server/cli/handlers/storage_actions.js +749 -0
  9. package/mcp-server/cli/quick_stats.js +39 -0
  10. package/mcp-server/cli/ui.js +565 -0
  11. package/mcp-server/cli.js +324 -1945
  12. package/mcp-server/config/auth_store.js +178 -19
  13. package/mcp-server/config/config_manager.js +1 -0
  14. package/mcp-server/db/database.js +18 -3
  15. package/mcp-server/db/migrations.js +28 -0
  16. package/mcp-server/fact_format.js +244 -177
  17. package/mcp-server/identity.js +152 -0
  18. package/mcp-server/index.js +42 -679
  19. package/mcp-server/memory.js +50 -63
  20. package/mcp-server/prompt_manager.js +1 -1
  21. package/mcp-server/setup.js +41 -0
  22. package/mcp-server/tools/helpers.js +39 -0
  23. package/mcp-server/tools/identity_tools.js +277 -0
  24. package/mcp-server/tools/index.js +9 -0
  25. package/mcp-server/tools/memory_tools.js +506 -0
  26. package/mcp-server/tools/rag_tools.js +235 -0
  27. package/opencode-plugin/index.js +460 -48
  28. package/package.json +7 -3
  29. package/skills/using-memory/SKILL.md +31 -14
  30. package/mcp-server/benchmarks/fetch_real_corpus.js +0 -351
  31. package/mcp-server/benchmarks/gpu_profile_benchmark.js +0 -170
  32. package/mcp-server/benchmarks/quality_evaluator.js +0 -600
  33. package/mcp-server/benchmarks/run_benchmarks.js +0 -347
  34. package/mcp-server/benchmarks/stress_ingestion.js +0 -195
  35. package/mcp-server/benchmarks/test_dual_layer.js +0 -140
@@ -16,6 +16,10 @@ const {
16
16
  matchesQuery,
17
17
  matchesTags,
18
18
  inDateRange,
19
+ factTitle,
20
+ factBody,
21
+ autoGenerateTitle,
22
+ metaBadges,
19
23
  } = await import("../mcp-server/fact_format.js");
20
24
 
21
25
  const {
@@ -95,6 +99,7 @@ async function notify(client, message, variant = "success") {
95
99
  const MEMORY_INSTRUCTION =
96
100
  "MANDATORY FIRST STEP (READ MEMORIES FIRST):\n" +
97
101
  "At the start of every session, you MUST thoroughly review all saved memories provided below BEFORE performing any user task or editing code.\n" +
102
+ "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" +
98
103
  "PROACTIVE MEMORY DIRECTIVE:\n" +
99
104
  "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" +
100
105
  "Do NOT wait for explicit user commands like \"remember this\". Automatically capture high-signal facts in real time.\n" +
@@ -104,18 +109,84 @@ const MEMORY_INSTRUCTION =
104
109
  "When saving, translate the fact into clear, concise English.\n" +
105
110
  "Use `scope: \"global\"` for personal facts, `scope: \"project\"` for project-specific facts.";
106
111
 
107
- function buildMemoryContext(globalFacts, projectFacts, projectKey, now = Date.now()) {
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
+ function sortNewestFirst(entries) {
123
+ return [...entries].sort((a, b) => {
124
+ const pa = parseFactEntry(a);
125
+ const pb = parseFactEntry(b);
126
+ if (!pa) return 1;
127
+ if (!pb) return -1;
128
+ const timeA = new Date(`${pa.date}T${pa.time}:00`).getTime();
129
+ const timeB = new Date(`${pb.date}T${pb.time}:00`).getTime();
130
+ return timeB - timeA;
131
+ });
132
+ }
133
+
134
+ function formatInjectedFacts(entries, limit, now = Date.now()) {
135
+ const activeEntries = entries.filter((e) => !isSuperseded(e));
136
+ const sorted = sortNewestFirst(activeEntries);
137
+
138
+ const injectPriority = [];
139
+ const normalPriority = [];
140
+
141
+ for (const entry of sorted) {
142
+ const meta = factMeta(entry);
143
+ if (meta.inject === "1") {
144
+ injectPriority.push(entry);
145
+ } else {
146
+ normalPriority.push(entry);
147
+ }
148
+ }
149
+
150
+ const combined = [...injectPriority, ...normalPriority];
151
+ const sliced = combined.slice(0, limit);
152
+
153
+ const formattedLines = [];
154
+ for (let i = 0; i < sliced.length; i++) {
155
+ const entry = sliced[i];
156
+ const meta = factMeta(entry);
157
+ const isPriority = meta.inject === "1";
158
+
159
+ let contentStr;
160
+ if (isPriority) {
161
+ contentStr = displayFact(entry, now);
162
+ } else {
163
+ const title = factTitle(entry);
164
+ const badges = metaBadges(entry, now);
165
+ const badgesStr = badges.length ? ` [${badges.join("] [")}]` : "";
166
+ contentStr = `${title}${badgesStr}`;
167
+ }
168
+
169
+ formattedLines.push(`${i + 1}. ${contentStr}`);
170
+ }
171
+
172
+ if (activeEntries.length > limit) {
173
+ const remaining = activeEntries.length - limit;
174
+ formattedLines.push(`... and ${remaining} more of ${activeEntries.length} memories (use recall tool to fetch all)`);
175
+ }
176
+
177
+ return formattedLines.join("\n");
178
+ }
179
+
180
+ function buildMemoryContext(globalFacts, projectFacts, projectKey, injectLimit, now = Date.now()) {
108
181
  const parts = [MEMORY_INSTRUCTION];
109
- const fmt = (entries) =>
110
- entries
111
- .filter((e) => !isSuperseded(e))
112
- .map((e, i) => `${i + 1}. ${displayFact(e, now)}`)
113
- .join("\n");
182
+
114
183
  if (globalFacts.length) {
115
- parts.push("## Global\n" + fmt(globalFacts));
184
+ const formatted = formatInjectedFacts(globalFacts, injectLimit, now);
185
+ if (formatted) parts.push("## Global\n" + formatted);
116
186
  }
117
187
  if (projectFacts.length) {
118
- parts.push(`## Project: ${projectKey}\n` + fmt(projectFacts));
188
+ const formatted = formatInjectedFacts(projectFacts, injectLimit, now);
189
+ if (formatted) parts.push(`## Project: ${projectKey}\n` + formatted);
119
190
  }
120
191
  return `<MEMORY>\n${parts.join("\n\n")}\n</MEMORY>`;
121
192
  }
@@ -135,7 +206,22 @@ const MCP_SERVERS = [
135
206
 
136
207
  export const MemoryPlugin = async ({ directory, worktree, client }) => {
137
208
  await ensureDir();
138
- const activeProjectKey = scopeKey("project", worktree, directory);
209
+ let activeProjectKey = await scopeKey("project", worktree, directory);
210
+ let identityResolveAt = 0;
211
+
212
+ const currentProjectKey = async () => {
213
+ const now = Date.now();
214
+ if (now < identityResolveAt) return activeProjectKey;
215
+ identityResolveAt = now + 2000;
216
+ try {
217
+ const path = client?.path?.get ? await client.path.get() : null;
218
+ const wt = path?.worktree || worktree;
219
+ const dir = path?.directory || directory;
220
+ const key = await scopeKey("project", wt, dir);
221
+ if (key !== activeProjectKey) activeProjectKey = key;
222
+ } catch (e) {}
223
+ return activeProjectKey;
224
+ };
139
225
 
140
226
  return {
141
227
  "experimental.chat.messages.transform": async (_input, output) => {
@@ -147,10 +233,14 @@ export const MemoryPlugin = async ({ directory, worktree, client }) => {
147
233
 
148
234
  const [globalFacts, projectFacts] = await Promise.all([
149
235
  readMemory(GLOBAL_KEY),
150
- readMemory(activeProjectKey),
236
+ readMemory(await currentProjectKey()),
151
237
  ]);
152
238
 
153
- const context = buildMemoryContext(globalFacts, projectFacts, activeProjectKey);
239
+ const { getConfig } = await import("../mcp-server/config/config_manager.js");
240
+ const config = getConfig();
241
+ const injectLimit = config.injectLimit !== undefined ? config.injectLimit : 100;
242
+
243
+ const context = buildMemoryContext(globalFacts, projectFacts, activeProjectKey, injectLimit);
154
244
  const ref = firstUser.parts[0];
155
245
  firstUser.parts.unshift({ ...ref, type: "text", text: context });
156
246
  },
@@ -185,17 +275,18 @@ export const MemoryPlugin = async ({ directory, worktree, client }) => {
185
275
  "(name, goals, constraints, tech preferences, project conventions). " +
186
276
  "docId/startLine/endLine/relationType are OPTIONAL and only used to link the fact to a " +
187
277
  "Knowledge Base document or line range; omit them when no linking is needed. " +
188
- "ttl is OPTIONAL (e.g. '90d', '2w', '24h') — expired facts are shown with [EXPIRED] but not auto-deleted. " +
278
+ "ttl is OPTIONAL (e.g. \x2790d\x27, \x272w\x27, \x2724h\x27) — expired facts are shown with [EXPIRED] but not auto-deleted. " +
189
279
  "keep=true protects the fact from forget deletion unless force=true. " +
190
280
  "tags is OPTIONAL comma-separated text for filtering. " +
191
281
  "supersedes is OPTIONAL: a number, id, or text of a fact this one replaces. " +
192
282
  "Translate the fact into English and keep it concise. " +
193
- "scope: 'project' (default) or 'global'",
283
+ "scope: \x27project\x27 (default) or \x27global\x27",
194
284
  args: {
195
285
  fact: { type: "string", description: "The fact to remember, written in English" },
286
+ title: { type: "string", description: "Optional title for the fact" },
196
287
  scope: {
197
288
  type: "string",
198
- description: "'project' (default) or 'global'",
289
+ description: "\x27project\x27 (default) or \x27global\x27",
199
290
  default: "project",
200
291
  },
201
292
  docId: { type: "string", description: "Optional document ID, title, or path to link this fact to" },
@@ -203,19 +294,38 @@ export const MemoryPlugin = async ({ directory, worktree, client }) => {
203
294
  endLine: { type: "number", description: "Optional ending line number in target document" },
204
295
  relationType: {
205
296
  type: "string",
206
- description: "Relation type (e.g. 'RULES_FOR', 'IMPLEMENTS', 'REFERENCES')",
297
+ description: "Relation type (e.g. \x27RULES_FOR\x27, \x27IMPLEMENTS\x27, \x27REFERENCES\x27)",
207
298
  default: "LINKS_TO",
208
299
  },
209
- ttl: { type: "string", description: "Optional time-to-live, e.g. '90d', '2w', '24h', '12m'" },
300
+ ttl: { type: "string", description: "Optional time-to-live, e.g. \x2790d\x27, \x272w\x27, \x2724h\x27, \x2712m\x27" },
210
301
  keep: { type: "boolean", description: "Protect the fact from forget deletion unless force=true" },
211
- tags: { type: "string", description: "Optional comma-separated tags, e.g. 'pref,arch'" },
302
+ tags: { type: "string", description: "Optional comma-separated tags, e.g. \x27pref,arch\x27" },
212
303
  supersedes: { type: "string", description: "Optional number, id, or text of the fact this one replaces" },
213
304
  },
214
- async execute({ fact, scope, docId, startLine, endLine, relationType, ttl, keep, tags, supersedes }, { worktree, directory }) {
215
- const key = scopeKey(scope || "project", worktree, directory);
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));
216
307
  const entries = await readMemory(key);
217
- const factNormalized = fact.toLowerCase().trim();
218
- const duplicate = entries.some((e) => factText(e).toLowerCase().trim() === factNormalized);
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);
219
329
 
220
330
  let supersededInfo = "";
221
331
  if (!duplicate) {
@@ -237,7 +347,7 @@ export const MemoryPlugin = async ({ directory, worktree, client }) => {
237
347
  }
238
348
  }
239
349
  if (!meta.id) meta.id = nextFactId(entries);
240
- entries.push(formatFactEntry({ date, time, text: fact, meta }));
350
+ entries.push(formatFactEntry({ date, time, text, meta }));
241
351
  await writeMemory(key, entries);
242
352
  }
243
353
 
@@ -247,7 +357,7 @@ export const MemoryPlugin = async ({ directory, worktree, client }) => {
247
357
  const { linkFactToDocument } = await import("../mcp-server/graph/knowledge_linker.js");
248
358
  const linkRes = linkFactToDocument({
249
359
  factKey: key,
250
- factText: fact,
360
+ factText: finalFact,
251
361
  docId,
252
362
  startLine,
253
363
  endLine,
@@ -265,11 +375,11 @@ export const MemoryPlugin = async ({ directory, worktree, client }) => {
265
375
  return result;
266
376
  },
267
377
  },
268
- "recall": {
378
+ "recall": {
269
379
  description:
270
380
  "Show saved facts with any Agent-linked Knowledge Base documents/lines. " +
271
- "scope: 'project', 'global', 'all' (default), or 'list_projects'. " +
272
- "Use project: '<directory path>' to read facts of a specific project from any working directory. " +
381
+ "scope: \x27project\x27, \x27global\x27, \x27all\x27 (default), or \x27list_projects\x27. " +
382
+ "Use project: \x27<directory path>\x27 to read facts of a specific project from any working directory. " +
273
383
  "query filters by keyword, tags by comma-separated tags, since/until by date (YYYY-MM-DD). " +
274
384
  "The response includes the store file paths.",
275
385
  args: {
@@ -278,14 +388,20 @@ export const MemoryPlugin = async ({ directory, worktree, client }) => {
278
388
  description: "project, global, all (по умолчанию) или list_projects",
279
389
  default: "all",
280
390
  },
281
- project: { type: "string", description: "Directory path of the project to read facts from (e.g. 'F:/projects/plugins/memory')" },
391
+ project: { type: "string", description: "Directory path of the project to read facts from (e.g. \x27F:/projects/plugins/memory\x27)" },
282
392
  query: { type: "string", description: "Optional keyword filter; all space-separated terms must match" },
283
393
  tags: { type: "string", description: "Optional comma-separated tag filter (any match)" },
284
394
  since: { type: "string", description: "Optional start date filter, YYYY-MM-DD (inclusive)" },
285
395
  until: { type: "string", description: "Optional end date filter, YYYY-MM-DD (inclusive)" },
396
+ mode: { type: "string", description: "Result mode: 'full' (with body, default) or 'headers' (title and badges only)", default: "full" },
397
+ offset: { type: "number", description: "Pagination offset (optional)" },
398
+ limit: { type: "number", description: "Pagination limit (optional)" },
286
399
  },
287
- async execute({ scope, project, query, tags, since, until }, { worktree, directory }) {
400
+ async execute({ scope, project, query, tags, since, until, mode, offset, limit }, { worktree, directory }) {
288
401
  const results = [];
402
+ const now = Date.now();
403
+ const targetMode = mode || "full";
404
+ const targetOffset = offset !== undefined ? offset : 0;
289
405
 
290
406
  let getLinksForFact;
291
407
  try {
@@ -293,11 +409,35 @@ export const MemoryPlugin = async ({ directory, worktree, client }) => {
293
409
  getLinksForFact = linker.getLinksForFact;
294
410
  } catch (e) {}
295
411
 
296
- const formatFactWithLinks = (factLine, key) => {
297
- let line = displayFact(factLine);
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
+
298
438
  if (getLinksForFact) {
299
439
  try {
300
- const links = getLinksForFact(key, factText(factLine));
440
+ const links = await getLinksForFact(key, p.text);
301
441
  if (links && links.length > 0) {
302
442
  const docStr = links
303
443
  .map((l) => {
@@ -305,24 +445,34 @@ export const MemoryPlugin = async ({ directory, worktree, client }) => {
305
445
  return `${l.doc_title || l.doc_path}${range}`;
306
446
  })
307
447
  .join(", ");
308
- line += ` 🔗 [Linked Docs: ${docStr}]`;
448
+ lineText += ` 🔗 [Linked Docs: ${docStr}]`;
309
449
  }
310
450
  } catch (e) {}
311
451
  }
312
- return line;
452
+ return `${index}. ${lineText}`;
313
453
  };
314
454
 
315
- const target = project ? canonicalPath(project) : projectKey(worktree, directory);
316
- const label = project ? target : projectName(worktree, directory);
455
+ const target = project ? canonicalPath(project) : await projectKey(worktree, directory);
456
+ const label = project ? target : await projectName(worktree, directory);
317
457
 
318
- const collect = (entries, key) => {
458
+ const collect = async (entries, key) => {
319
459
  const matched = entries.filter(
320
460
  (e) => matchesQuery(e, query) && matchesTags(e, tags) && inDateRange(e, since, until)
321
461
  );
322
462
  if (!matched.length) return;
323
463
  if (results.length) results.push("");
324
464
  results.push(`--- ${key === GLOBAL_KEY ? "Global" : `Project: ${key === target ? label : key}`} ---`);
325
- matched.forEach((e, i) => results.push(`${i + 1}. ${formatFactWithLinks(e, 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
+ }
326
476
  results.push(`Store file: ${storeFilePath(key)}`);
327
477
  };
328
478
 
@@ -338,17 +488,65 @@ export const MemoryPlugin = async ({ directory, worktree, client }) => {
338
488
 
339
489
  if (scope !== "project") {
340
490
  const global = await readMemory(GLOBAL_KEY);
341
- collect(global, GLOBAL_KEY);
491
+ await collect(global, GLOBAL_KEY);
342
492
  }
343
493
  if (scope !== "global") {
344
494
  const local = await readMemory(target);
345
- collect(local, target);
495
+ await collect(local, target);
346
496
  }
347
497
  const filtered = Boolean(query || tags || since || until);
348
498
  if (!results.length) return filtered ? "No facts match the search." : "Memory is empty.";
349
499
  return results.join("\n") + `\n\nMemory dir: ${MEMORY_DIR}`;
350
500
  },
351
501
  },
502
+ "get_fact": {
503
+ description: "Get the full text and metadata of a single fact by its metadata id.",
504
+ args: {
505
+ id: { type: "string", description: "The unique metadata id of the fact (e.g. \x278f3a2c\x27)" },
506
+ scope: { type: "string", description: "\x27project\x27, \x27global\x27, or \x27all\x27 (default)", default: "all" },
507
+ },
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");
548
+ },
549
+ },
352
550
  "forget": {
353
551
  description: "Удалить факт по номеру (см. recall), по диапазону (например '3-30', включительно) или тексту. Защищённые факты (remember с keep=true) пропускаются, если не передан force=true",
354
552
  args: {
@@ -361,7 +559,7 @@ export const MemoryPlugin = async ({ directory, worktree, client }) => {
361
559
  force: { type: "boolean", description: "Удалить также защищённые (keep) факты" },
362
560
  },
363
561
  async execute({ query, scope, force }, { worktree, directory }) {
364
- const key = scopeKey(scope || "project", worktree, directory);
562
+ const key = requireProjectKey(await scopeKey(scope || "project", worktree, directory));
365
563
  const entries = await readMemory(key);
366
564
  const rangeMatch = /^\s*(\d+)\s*-\s*(\d+)\s*$/.exec(query);
367
565
  const num = parseInt(query, 10);
@@ -401,26 +599,48 @@ export const MemoryPlugin = async ({ directory, worktree, client }) => {
401
599
  args: {
402
600
  id: { type: "string", description: "Number (from recall), metadata id, or text of the fact to update" },
403
601
  newText: { type: "string", description: "New fact text" },
404
- scope: { type: "string", description: "'project' (default) or 'global'", default: "project" },
602
+ title: { type: "string", description: "Optional new title for the fact" },
603
+ scope: { type: "string", description: "\x27project\x27 (default) or \x27global\x27", default: "project" },
405
604
  },
406
- async execute({ id, newText, scope }, { worktree, directory }) {
407
- const key = scopeKey(scope || "project", worktree, directory);
605
+ async execute({ id, newText, title, scope }, { worktree, directory }) {
606
+ const key = requireProjectKey(await scopeKey(scope || "project", worktree, directory));
408
607
  const entries = await readMemory(key);
409
608
  const idx = resolveFactIndex(entries, id);
410
609
  if (idx === -1) throw new Error(`Fact not found: ${id}`);
411
610
  const p = parseFactEntry(entries[idx]);
412
611
  const oldText = p ? p.text : entries[idx];
413
- const newLine = formatFactEntry({ date: p.date, time: p.time, text: newText, meta: p.meta });
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 });
414
634
  entries[idx] = newLine;
415
635
  await writeMemory(key, entries);
416
636
 
417
637
  let linksUpdated = 0;
418
638
  try {
419
639
  const { getDatabase } = await import("../mcp-server/db/database.js");
420
- const db = getDatabase();
640
+ const db = await getDatabase();
421
641
  const res = db
422
642
  .prepare("UPDATE knowledge_links SET fact_text = ? WHERE fact_key = ? AND fact_text = ?")
423
- .run(newText, key, oldText);
643
+ .run(finalFact, key, oldBody);
424
644
  linksUpdated = res.changes;
425
645
  } catch (e) {}
426
646
 
@@ -429,7 +649,7 @@ export const MemoryPlugin = async ({ directory, worktree, client }) => {
429
649
  return result;
430
650
  },
431
651
  },
432
- "memory_info": {
652
+ "memory_info": {
433
653
  description: "Show memory storage paths (store files, MEMORY_DIR, SQLite DB), fact counts, and Knowledge Base stats.",
434
654
  args: {},
435
655
  async execute() {
@@ -455,12 +675,29 @@ export const MemoryPlugin = async ({ directory, worktree, client }) => {
455
675
  rag.error = e.message;
456
676
  }
457
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
+
458
694
  const lines = [
459
695
  `Version: ${version}`,
460
696
  `MEMORY_DIR: ${MEMORY_DIR}`,
461
697
  `SQLite DB: ${dbPath}`,
462
698
  `Global store: ${storeFilePath(GLOBAL_KEY)}`,
463
699
  `Project store: ${storeFilePath(activeProjectKey)}`,
700
+ ...identityLines,
464
701
  ];
465
702
  if (rag.error) lines.push(`RAG: unavailable (${rag.error})`);
466
703
  else
@@ -493,9 +730,13 @@ export const MemoryPlugin = async ({ directory, worktree, client }) => {
493
730
  },
494
731
  async execute({ action, factText, docId, scope, startLine, endLine, relationType }, { worktree, directory }) {
495
732
  const { linkFactToDocument, getLinksForDoc, listAllLinks } = await import("../mcp-server/graph/knowledge_linker.js");
496
- const key = scopeKey(scope || "project", worktree, directory);
733
+ const key = await scopeKey(scope || "project", worktree, directory);
497
734
  const act = action || "link";
498
735
 
736
+ if (act === "link" || act === "list_links") {
737
+ requireProjectKey(key);
738
+ }
739
+
499
740
  if (act === "link") {
500
741
  if (!factText || !docId) {
501
742
  throw new Error("factText and docId are required parameters for link action");
@@ -697,6 +938,177 @@ export const MemoryPlugin = async ({ directory, worktree, client }) => {
697
938
  throw new Error(`Unknown action: ${action}`);
698
939
  },
699
940
  },
941
+ "link_project_memory": {
942
+ description: "Link the current directory to a Git-based project identity, register aliases, and optionally migrate legacy/path stores.",
943
+ args: {
944
+ directory: { type: "string", description: "Directory path to link (default: current directory)" },
945
+ remote: { type: "string", description: "Optional explicit remote URL to use as primary identity key" },
946
+ },
947
+ async execute({ directory, remote }, { worktree, directory: contextDir }) {
948
+ const { getDatabase } = await import("../mcp-server/db/database.js");
949
+ const { resolveProjectIdentity, upsertIdentity, registerAlias, normalizeRemoteUrl } = await import("../mcp-server/identity.js");
950
+ const db = await getDatabase();
951
+
952
+ const dir = directory || contextDir || process.cwd();
953
+ const identity = await resolveProjectIdentity(dir);
954
+ if (!identity && !remote) {
955
+ throw new Error("No Git repository detected and no remote URL specified.");
956
+ }
957
+
958
+ let key = identity ? identity.key : `git:${normalizeRemoteUrl(remote)}`;
959
+ let name = identity ? identity.name : basename(dir) || "unbound";
960
+ let primaryRemote = remote ? normalizeRemoteUrl(remote) : (identity ? identity.primaryRemote : null);
961
+
962
+ await upsertIdentity(db, { key, name, primaryRemote });
963
+
964
+ const aliases = [];
965
+ if (primaryRemote) {
966
+ aliases.push({ alias: `remote:${primaryRemote}`, kind: "remote" });
967
+ }
968
+ aliases.push({ alias: `path:${canonicalPath(dir)}`, kind: "path" });
969
+ aliases.push({ alias: `basename:${name}`, kind: "basename" });
970
+
971
+ for (const a of aliases) {
972
+ await registerAlias(db, { alias: a.alias, identityKey: key, kind: a.kind });
973
+ }
974
+
975
+ let migrated = false;
976
+ const legacyPathKey = canonicalPath(dir);
977
+ const legacyEntries = await readMemory(legacyPathKey);
978
+ if (legacyEntries && legacyEntries.length > 0) {
979
+ const gitEntries = await readMemory(key);
980
+ const seen = new Set(gitEntries.map((e) => factBody(e).toLowerCase().trim()));
981
+ let mergedCount = 0;
982
+ for (const entry of legacyEntries) {
983
+ const body = factBody(entry).toLowerCase().trim();
984
+ if (!seen.has(body)) {
985
+ seen.add(body);
986
+ gitEntries.push(entry);
987
+ mergedCount++;
988
+ }
989
+ }
990
+ if (mergedCount > 0) {
991
+ await writeMemory(key, gitEntries);
992
+ migrated = true;
993
+ }
994
+ try {
995
+ const legacyFp = storeFilePath(legacyPathKey);
996
+ const { existsSync } = await import("node:fs");
997
+ if (existsSync(legacyFp)) {
998
+ const { unlink } = await import("fs/promises");
999
+ await unlink(legacyFp);
1000
+ }
1001
+ } catch (e) {}
1002
+ }
1003
+
1004
+ const res = {
1005
+ status: "success",
1006
+ key,
1007
+ name,
1008
+ primaryRemote,
1009
+ aliases: aliases.map((a) => a.alias),
1010
+ migrated
1011
+ };
1012
+ await notify(client, "Project memory linked successfully");
1013
+ return JSON.stringify(res, null, 2);
1014
+ },
1015
+ },
1016
+ "unlink_project_memory": {
1017
+ description: "Remove the path alias link for the specified project directory.",
1018
+ args: {
1019
+ directory: { type: "string", description: "Directory path to unlink (default: current directory)" },
1020
+ purge: { type: "boolean", description: "If true, completely purge the project identity from the SQLite store" },
1021
+ },
1022
+ async execute({ directory, purge }, { worktree, directory: contextDir }) {
1023
+ const { getDatabase } = await import("../mcp-server/db/database.js");
1024
+ const { unregisterAlias, removeIdentity, resolveProjectIdentity } = await import("../mcp-server/identity.js");
1025
+ const db = await getDatabase();
1026
+
1027
+ const dir = directory || contextDir || process.cwd();
1028
+ const alias = `path:${canonicalPath(dir)}`;
1029
+ await unregisterAlias(db, alias);
1030
+
1031
+ let key = null;
1032
+ if (purge) {
1033
+ const identity = await resolveProjectIdentity(dir);
1034
+ if (identity) {
1035
+ key = identity.key;
1036
+ await removeIdentity(db, key);
1037
+ }
1038
+ }
1039
+
1040
+ const res = {
1041
+ status: "success",
1042
+ alias,
1043
+ purgedIdentityKey: key
1044
+ };
1045
+ await notify(client, "Project memory unlinked");
1046
+ return JSON.stringify(res, null, 2);
1047
+ },
1048
+ },
1049
+ "relink_project_memory": {
1050
+ description: "Move or merge project memories from the current identity to a new target identity.",
1051
+ args: {
1052
+ directory: { type: "string", description: "Directory path to relink (default: current directory)" },
1053
+ remote: { type: "string", description: "New target remote URL / identity key to move memories to" },
1054
+ },
1055
+ async execute({ directory, remote }, { worktree, directory: contextDir }) {
1056
+ const { getDatabase } = await import("../mcp-server/db/database.js");
1057
+ const { resolveProjectIdentity, upsertIdentity, removeIdentity, normalizeRemoteUrl } = await import("../mcp-server/identity.js");
1058
+ const db = await getDatabase();
1059
+
1060
+ const dir = directory || contextDir || process.cwd();
1061
+ const sourceIdentity = await resolveProjectIdentity(dir);
1062
+ if (!sourceIdentity) {
1063
+ throw new Error("Source project identity not detected.");
1064
+ }
1065
+
1066
+ const targetKey = `git:${normalizeRemoteUrl(remote)}`;
1067
+ const sourceKey = sourceIdentity.key;
1068
+
1069
+ if (sourceKey === targetKey) {
1070
+ return "Source and target identities are already identical.";
1071
+ }
1072
+
1073
+ const sourceFacts = await readMemory(sourceKey);
1074
+ const targetFacts = await readMemory(targetKey);
1075
+ const seen = new Set(targetFacts.map((e) => factBody(e).toLowerCase().trim()));
1076
+
1077
+ let mergedCount = 0;
1078
+ for (const f of sourceFacts) {
1079
+ const body = factBody(f).toLowerCase().trim();
1080
+ if (!seen.has(body)) {
1081
+ seen.add(body);
1082
+ targetFacts.push(f);
1083
+ mergedCount++;
1084
+ }
1085
+ }
1086
+
1087
+ await writeMemory(targetKey, targetFacts);
1088
+
1089
+ await db.prepare("UPDATE project_aliases SET identity_key = ? WHERE identity_key = ?;").run(targetKey, sourceKey);
1090
+ await upsertIdentity(db, { key: targetKey, name: sourceIdentity.name, primaryRemote: normalizeRemoteUrl(remote) });
1091
+ await removeIdentity(db, sourceKey);
1092
+
1093
+ try {
1094
+ const sourceFp = storeFilePath(sourceKey);
1095
+ const { existsSync } = await import("node:fs");
1096
+ if (existsSync(sourceFp)) {
1097
+ const { unlink } = await import("fs/promises");
1098
+ await unlink(sourceFp);
1099
+ }
1100
+ } catch (e) {}
1101
+
1102
+ const res = {
1103
+ status: "success",
1104
+ sourceKey,
1105
+ targetKey,
1106
+ mergedFacts: mergedCount
1107
+ };
1108
+ await notify(client, "Project memory relinked");
1109
+ return JSON.stringify(res, null, 2);
1110
+ },
1111
+ },
700
1112
  },
701
1113
  };
702
1114
  };