@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
@@ -2,6 +2,7 @@ import { readFile, writeFile, mkdir, unlink, readdir } from "fs/promises";
2
2
  import { existsSync, mkdirSync } from "fs";
3
3
  import { join, basename, resolve } from "path";
4
4
  import { homedir } from "os";
5
+ import { resolveProjectIdentity } from "./identity.js";
5
6
 
6
7
  function resolveMemoryDir() {
7
8
  if (process.env.MEMORY_DIR) return process.env.MEMORY_DIR;
@@ -46,7 +47,6 @@ export function ensureDirSync() {
46
47
  if (!existsSync(exportsDir)) mkdirSync(exportsDir, { recursive: true });
47
48
  }
48
49
 
49
- // Canonical absolute path key: forward slashes, lowercase drive letter on win32.
50
50
  export function canonicalPath(dir) {
51
51
  let p = resolve(dir || process.cwd());
52
52
  if (process.platform === "win32") {
@@ -55,24 +55,25 @@ export function canonicalPath(dir) {
55
55
  return p;
56
56
  }
57
57
 
58
- // Project store key = full directory path. This removes basename collisions and
59
- // binds each store to the real project location.
60
- export function projectKey(worktree, directory) {
61
- return canonicalPath(worktree || directory);
58
+ export async function projectKey(worktree, directory) {
59
+ const dir = worktree || directory || process.cwd();
60
+ const identity = await resolveProjectIdentity(dir);
61
+ return identity ? identity.key : null;
62
62
  }
63
63
 
64
- // Display label for a project (basename of the resolved directory).
65
- export function projectName(worktree, directory) {
64
+ export async function projectName(worktree, directory) {
66
65
  const dir = worktree || directory || process.cwd();
67
- return dir ? basename(resolve(dir)) : "default";
66
+ const identity = await resolveProjectIdentity(dir);
67
+ return identity ? identity.name : (dir ? basename(resolve(dir)) : "default");
68
68
  }
69
69
 
70
- export function scopeKey(scope, worktree, directory) {
71
- return scope === "global" ? GLOBAL_KEY : projectKey(worktree, directory);
70
+ export async function scopeKey(scope, worktree, directory) {
71
+ return scope === "global" ? GLOBAL_KEY : await projectKey(worktree, directory);
72
72
  }
73
73
 
74
- function slugify(key) {
75
- return key.replace(/[^a-zA-Z0-9_-]/g, "_");
74
+ export function slugify(key) {
75
+ if (!key) return "null";
76
+ return String(key).replace(/[^a-zA-Z0-9_-]/g, "_");
76
77
  }
77
78
 
78
79
  function memoryPath(key) {
@@ -87,44 +88,17 @@ export function storeFilePath(key) {
87
88
  return memoryPath(key);
88
89
  }
89
90
 
90
- function parseMeta(content) {
91
- const m = content.match(/<!-- path: (.+?) -->/);
92
- return { path: m ? m[1].trim() : null };
91
+ export function parseMeta(content) {
92
+ const m = content.match(/<!-- key: (.+?) -->/) || content.match(/<!-- path: (.+?) -->/);
93
+ return { key: m ? m[1].trim() : null };
93
94
  }
94
95
 
95
96
  function isSimpleKey(key) {
96
97
  return /^[a-zA-Z0-9_-]+$/.test(key);
97
98
  }
98
99
 
99
- // Lazy migration: when reading a project path store that doesn't exist yet but a
100
- // legacy <basename>.md store (without path binding) does, claim it under the path.
101
- async function maybeMigrateLegacy(key) {
102
- if (key === GLOBAL_KEY || isSimpleKey(key)) return null;
103
- const legacyBasename = basename(key);
104
- if (!legacyBasename) return null;
105
- const legacyFp = join(MEMORY_DIR, `${legacyBasename}.md`);
106
- if (slugify(key) === legacyBasename || !existsSync(legacyFp)) return null;
107
- const content = await readFile(legacyFp, "utf-8");
108
- if (parseMeta(content).path) return null; // already bound to another project
109
- // Collision guard: a different path with the same basename is already bound,
110
- // so this legacy store is ambiguous and must not be silently claimed.
111
- const files = await readdir(MEMORY_DIR).catch(() => []);
112
- for (const f of files) {
113
- if (!f.endsWith(".md") || f === `${legacyBasename}.md` || f === `${GLOBAL_KEY}.md`) continue;
114
- try {
115
- const other = parseMeta(await readFile(join(MEMORY_DIR, f), "utf-8")).path;
116
- if (other && basename(other) === legacyBasename) return null;
117
- } catch (e) {}
118
- }
119
- const facts = content.split("\n").filter((l) => l.startsWith("- ["));
120
- await writeMemory(key, facts);
121
- try {
122
- await unlink(legacyFp);
123
- } catch (e) {}
124
- return facts;
125
- }
126
-
127
100
  export async function readMemory(key) {
101
+ if (!key) return [];
128
102
  const { getConfig } = await import("./config/config_manager.js");
129
103
  const config = getConfig();
130
104
  if (config.mode === "only-cloud") {
@@ -143,7 +117,6 @@ export async function readMemory(key) {
143
117
 
144
118
  const fp = memoryPath(key);
145
119
  if (config.mode === "hybrid-sync") {
146
- // Pull cloud state down first so cloud-only records appear locally.
147
120
  try {
148
121
  const { ensureReverseSync } = await import("./db/sync_queue.js");
149
122
  await ensureReverseSync();
@@ -155,40 +128,35 @@ export async function readMemory(key) {
155
128
  const content = await readFile(fp, "utf-8");
156
129
  return content.split("\n").filter((l) => l.startsWith("- ["));
157
130
  }
158
- const migrated = await maybeMigrateLegacy(key);
159
- return migrated || [];
131
+ return [];
160
132
  }
161
133
 
162
134
  export async function readMemoryRaw(key) {
163
135
  return (await readMemory(key)).map((e) => e.slice(2));
164
136
  }
165
137
 
166
- // Build the markdown store content for a key from a list of fact lines.
167
138
  export function buildMemoryContent(key, entries) {
168
139
  const lines = [];
169
140
  if (key === GLOBAL_KEY) {
170
141
  lines.push("# Global Memory", "");
171
142
  } else {
172
143
  lines.push(`# Memory: ${basename(key) || key}`, "");
173
- if (!isSimpleKey(key)) {
174
- lines.push(`<!-- path: ${key} -->`, "");
175
- }
144
+ lines.push(`<!-- key: ${key} -->`, "");
176
145
  }
177
146
  return lines.join("\n") + "\n" + (entries.length ? entries.join("\n") + "\n" : "");
178
147
  }
179
148
 
180
- // Extract fact lines (`- [date] ...`) from a store content string.
181
149
  export function extractFacts(content) {
182
150
  return (content || "").split("\n").filter((l) => l.startsWith("- ["));
183
151
  }
184
152
 
185
- // Write a store file directly to disk WITHOUT enqueueing a cloud sync task.
186
- // Used by the sync worker to apply pulled cloud state without re-queueing.
187
153
  export async function writeMemoryFile(key, content) {
154
+ if (!key) return;
188
155
  await writeFile(memoryPath(key), content);
189
156
  }
190
157
 
191
158
  export async function writeMemory(key, entries) {
159
+ if (!key) return;
192
160
  const content = buildMemoryContent(key, entries);
193
161
 
194
162
  const { getConfig } = await import("./config/config_manager.js");
@@ -236,11 +204,11 @@ export async function listProjectStores() {
236
204
  const meta = parseMeta(content);
237
205
  stores.push({
238
206
  key,
239
- path: meta.path || (key.includes("/") || key.includes(":") ? key : null),
240
- basename: basename(meta.path || key) || key,
207
+ path: meta.key || key,
208
+ basename: basename(meta.key || key) || key,
241
209
  file: `${slugify(key)}.md`,
242
210
  count: facts.length,
243
- legacy: !meta.path,
211
+ legacy: !meta.key || (!meta.key.startsWith("git:") && !meta.key.startsWith("git_")),
244
212
  });
245
213
  }
246
214
  stores.sort((a, b) => a.basename.localeCompare(b.basename));
@@ -264,27 +232,29 @@ export async function listProjectStores() {
264
232
  }
265
233
  const facts = content.split("\n").filter((l) => l.startsWith("- ["));
266
234
  const meta = parseMeta(content);
267
- const key = meta.path || f.slice(0, -3);
235
+ const key = meta.key || f.slice(0, -3);
268
236
  stores.push({
269
237
  key,
270
- path: meta.path,
271
- basename: basename(meta.path || key) || key,
238
+ path: meta.key,
239
+ basename: basename(meta.key || key) || key,
272
240
  file: f,
273
241
  count: facts.length,
274
- legacy: !meta.path,
242
+ legacy: !meta.key || (!meta.key.startsWith("git:") && !meta.key.startsWith("git_")),
275
243
  });
276
244
  }
277
245
  stores.sort((a, b) => a.basename.localeCompare(b.basename));
278
246
  return stores;
279
247
  }
280
248
 
281
- // Bind an unbound legacy store (e.g. "comfy-meta-viewer") to a directory path.
282
249
  export async function migrateLegacyStore(legacyKey, targetDir) {
283
250
  const legacyFp = join(MEMORY_DIR, `${legacyKey.replace(/[^a-zA-Z0-9_-]/g, "_")}.md`);
284
251
  if (!existsSync(legacyFp)) return { ok: false, reason: "not_found", key: legacyKey };
285
252
  const content = await readFile(legacyFp, "utf-8");
286
- if (parseMeta(content).path) return { ok: false, reason: "already_bound", key: legacyKey };
287
- const targetKey = projectKey(targetDir, null);
253
+ if (parseMeta(content).key) return { ok: false, reason: "already_bound", key: legacyKey };
254
+
255
+ const targetKey = await projectKey(targetDir, null);
256
+ if (!targetKey) return { ok: false, reason: "not_a_git_repo", key: legacyKey };
257
+
288
258
  const facts = content.split("\n").filter((l) => l.startsWith("- ["));
289
259
  await writeMemory(targetKey, facts);
290
260
  try {
@@ -293,6 +263,23 @@ export async function migrateLegacyStore(legacyKey, targetDir) {
293
263
  return { ok: true, key: targetKey, file: memoryPath(targetKey), facts: facts.length };
294
264
  }
295
265
 
266
+ // Mass-stamp titles onto legacy facts in a store. Returns how many lines were
267
+ // changed. Skips stores that already have titles on every fact (fast no-op).
268
+ export async function migrateStoreTitles(key) {
269
+ if (!key) return { ok: false, reason: "no_key", changed: 0 };
270
+ const { withTitle } = await import("./fact_format.js");
271
+ const facts = await readMemory(key);
272
+ let changed = 0;
273
+ const migrated = facts.map((line) => {
274
+ const next = withTitle(line);
275
+ if (next !== line) changed++;
276
+ return next;
277
+ });
278
+ if (!changed) return { ok: true, changed: 0 };
279
+ await writeMemory(key, migrated);
280
+ return { ok: true, changed };
281
+ }
282
+
296
283
  export function today() {
297
284
  const d = new Date();
298
285
  const date = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
@@ -9,7 +9,7 @@ const END_MARKER = "<!-- END MEMORY AGENT PROMPT -->";
9
9
 
10
10
  export const PROMPT_BLOCK = `${START_MARKER}
11
11
  [SYSTEM INSTRUCTION: PERSONAL CONTEXT & MEMORY AGENT]
12
- 1. MANDATORY FIRST STEP (SESSION INITIALIZATION): At the very start of every session or conversation, your VERY FIRST ACTION MUST BE to execute \`recall\` from \`memory-agent\` to load all saved facts, user preferences, personal context, and project guidelines BEFORE performing any other tasks or reading code.
12
+ 1. MANDATORY FIRST STEP (SESSION INITIALIZATION): At the very start of every session or conversation, your VERY FIRST ACTION MUST BE to execute \`recall\` from \`memory-agent\` with \`scope: "all"\` (or without restrictive query filters) to load ALL saved global facts, user preferences, personal context, and project guidelines BEFORE performing any other tasks or reading code. Do NOT filter or restrict the initial \`recall\` call with specific query keywords or scoped parameters, so that no global facts are missed.
13
13
  2. PROACTIVE SAVING DIRECTIVE: You MUST automatically and proactively call \`remember\` from \`memory-agent\` whenever the user shares durable facts, personal preferences, coding guidelines, technology choices, or project architecture decisions. Do NOT wait for explicit user commands like "remember this".
14
14
  3. SIGNAL FILTER: Save only high-signal facts (name, language, roles, constraints, tech stack preferences, architecture decisions, conventions). Translate facts into clear, concise English when saving. Do NOT save transient details or one-off conversation turns.
15
15
  ${END_MARKER}`;
@@ -15,10 +15,42 @@ export async function runSetup() {
15
15
  const doAntigravity = !hasSpecificFlag || args.includes("--antigravity") || args.includes("--gemini");
16
16
  const doCodex = !hasSpecificFlag || args.includes("--codex");
17
17
 
18
+ // Headless cloud setup: --api-key <TURSO_API_TOKEN> and/or --mode <only-local|only-cloud|hybrid-sync>
19
+ const VALID_MODES = ["only-local", "only-cloud", "hybrid-sync"];
20
+ const apiKeyArg = flagValue(args, "--api-key");
21
+ const modeArg = flagValue(args, "--mode");
22
+ if (modeArg && !VALID_MODES.includes(modeArg)) {
23
+ console.log(` [WARN] Unknown --mode "${modeArg}". Allowed: ${VALID_MODES.join(", ")}`);
24
+ }
25
+
18
26
  console.log("\nSetting up @lotargo/memory_plugin...\n");
19
27
  const home = homedir();
20
28
  let configuredCount = 0;
21
29
 
30
+ // 0. Headless cloud authentication (Google Jules / CI / VPS)
31
+ if (apiKeyArg) {
32
+ try {
33
+ const { loginWithApiToken } = await import("./admin/auth.js");
34
+ const secrets = await loginWithApiToken({ token: apiKeyArg });
35
+ if (modeArg && VALID_MODES.includes(modeArg)) {
36
+ const { updateConfig } = await import("./config/config_manager.js");
37
+ updateConfig({ mode: modeArg });
38
+ }
39
+ console.log(` [OK] Cloud: authorized as "${secrets.username}" via API token. Endpoint: ${secrets.dbUrl}`);
40
+ configuredCount++;
41
+ } catch (err) {
42
+ console.log(" [FAIL] Cloud setup failed:", err.message);
43
+ }
44
+ } else if (modeArg && VALID_MODES.includes(modeArg)) {
45
+ try {
46
+ const { updateConfig } = await import("./config/config_manager.js");
47
+ updateConfig({ mode: modeArg });
48
+ console.log(` [OK] Cloud: sync mode set to "${modeArg}"`);
49
+ } catch (err) {
50
+ console.log(" [SKIP] Cloud mode update skipped:", err.message);
51
+ }
52
+ }
53
+
22
54
  // 1. OpenCode (~/.config/opencode/opencode.json)
23
55
  if (doOpenCode) {
24
56
  try {
@@ -229,3 +261,12 @@ export async function runSetup() {
229
261
  console.log(`\nSetup complete. Configured ${configuredCount} environment(s).\n`);
230
262
  }
231
263
 
264
+ // Read the value following --flag, or null when absent / followed by another flag.
265
+ function flagValue(args, flag) {
266
+ const idx = args.indexOf(flag);
267
+ if (idx === -1 || idx + 1 >= args.length) return null;
268
+ const value = args[idx + 1];
269
+ if (value.startsWith("--")) return null;
270
+ return value;
271
+ }
272
+
@@ -0,0 +1,39 @@
1
+ import * as z from "zod/v4";
2
+ import { factMeta, factText } from "../fact_format.js";
3
+
4
+ // Optional string/number that tolerates null (some tool-call layers fill omitted
5
+ // optional args with null). Linking fields must NEVER be mandatory.
6
+ export const optStr = () => z.string().optional().nullable();
7
+ export const optNum = () => z.number().optional().nullable();
8
+ export const defStr = (fallback) =>
9
+ z
10
+ .string()
11
+ .nullish()
12
+ .transform((v) => (v === null || v === undefined || v === "" ? fallback : v));
13
+ export const defBool = (fallback) =>
14
+ z.boolean().nullish().transform((v) => (v === null || v === undefined ? fallback : v));
15
+ export const defNum = (fallback) =>
16
+ z.number().nullish().transform((v) => (v === null || v === undefined ? fallback : v));
17
+
18
+ // Project memory is git-based: outside a git repository there is no project key.
19
+ export function requireProjectKey(key) {
20
+ if (!key) {
21
+ throw new Error(
22
+ "No project memory available: this directory is not inside a git repository. " +
23
+ "Project memory is tied to a git repo; use scope: 'global' or open a git repository."
24
+ );
25
+ }
26
+ return key;
27
+ }
28
+
29
+ // Resolve a fact reference (1-based number, metadata id, or text) to an index.
30
+ export function resolveFactIndex(entries, ref) {
31
+ const trimmed = String(ref || "").trim();
32
+ if (!trimmed) return -1;
33
+ const num = parseInt(trimmed, 10);
34
+ if (/^\d+$/.test(trimmed) && num >= 1 && num <= entries.length) return num - 1;
35
+ const idIdx = entries.findIndex((e) => factMeta(e).id === trimmed);
36
+ if (idIdx !== -1) return idIdx;
37
+ const textIdx = entries.findIndex((e) => factText(e).toLowerCase().includes(trimmed.toLowerCase()));
38
+ return textIdx;
39
+ }
@@ -0,0 +1,277 @@
1
+ import * as z from "zod/v4";
2
+ import { basename } from "node:path";
3
+ import { scopeKey, canonicalPath, readMemory, writeMemory, storeFilePath } from "../memory.js";
4
+ import { factBody } from "../fact_format.js";
5
+ import { optStr, optNum, defStr, defBool, requireProjectKey } from "./helpers.js";
6
+
7
+ export function registerIdentityTools(server) {
8
+ server.registerTool(
9
+ "link_knowledge",
10
+ {
11
+ description:
12
+ "Explicitly link a Notebook memory fact to a Knowledge Base document, section, or line range. " +
13
+ "Creates Agent-driven Graph Edges connecting memory to RAG documents.",
14
+ inputSchema: z.object({
15
+ action: z.enum(["link", "list_links", "get_doc_links"]).nullish().transform((v) => v || "link").describe("Action type"),
16
+ factText: optStr().describe("Memory fact text or keyword"),
17
+ docId: optStr().describe("Document ID, title, or file path"),
18
+ scope: defStr("project").describe("'project' (default) or 'global'"),
19
+ startLine: optNum().describe("Starting line number in target document"),
20
+ endLine: optNum().describe("Ending line number in target document"),
21
+ relationType: defStr("LINKS_TO").describe("Relation type (e.g. 'RULES_FOR', 'IMPLEMENTS', 'EXPLAINS')"),
22
+ }),
23
+ },
24
+ async ({ action, factText, docId, scope, startLine, endLine, relationType }) => {
25
+ const { linkFactToDocument, getLinksForDoc, listAllLinks } = await import("../graph/knowledge_linker.js");
26
+ const key = await scopeKey(scope, null, null);
27
+
28
+ if (action === "link" || action === "list_links") {
29
+ requireProjectKey(key);
30
+ }
31
+
32
+ if (action === "link") {
33
+ if (!factText || !docId) {
34
+ throw new Error("factText and docId are required parameters for link action");
35
+ }
36
+ const res = linkFactToDocument({
37
+ factKey: key,
38
+ factText,
39
+ docId,
40
+ startLine,
41
+ endLine,
42
+ relationType,
43
+ });
44
+ return {
45
+ content: [{ type: "text", text: JSON.stringify(res, null, 2) }],
46
+ };
47
+ }
48
+
49
+ if (action === "get_doc_links") {
50
+ if (!docId) throw new Error("docId parameter is required for get_doc_links action");
51
+ const links = getLinksForDoc(docId);
52
+ return {
53
+ content: [{ type: "text", text: JSON.stringify(links, null, 2) }],
54
+ };
55
+ }
56
+
57
+ if (action === "list_links") {
58
+ const links = listAllLinks(key);
59
+ return {
60
+ content: [{ type: "text", text: JSON.stringify(links, null, 2) }],
61
+ };
62
+ }
63
+
64
+ throw new Error(`Unknown action: ${action}`);
65
+ }
66
+ );
67
+
68
+ server.registerTool(
69
+ "link_project_memory",
70
+ {
71
+ description: "Link the current directory to a Git-based project identity, register aliases, and optionally migrate legacy/path stores.",
72
+ inputSchema: z.object({
73
+ directory: optStr().describe("Directory path to link (default: current directory)"),
74
+ remote: optStr().describe("Optional explicit remote URL to use as primary identity key"),
75
+ }),
76
+ },
77
+ async ({ directory, remote }) => {
78
+ const { getDatabase } = await import("../db/database.js");
79
+ const { resolveProjectIdentity, upsertIdentity, registerAlias, normalizeRemoteUrl } = await import("../identity.js");
80
+ const db = await getDatabase();
81
+
82
+ const dir = directory || process.cwd();
83
+ const identity = await resolveProjectIdentity(dir);
84
+ if (!identity && !remote) {
85
+ throw new Error("No Git repository detected and no remote URL specified.");
86
+ }
87
+
88
+ let key = identity ? identity.key : `git:${normalizeRemoteUrl(remote)}`;
89
+ let name = identity ? identity.name : basename(dir) || "unbound";
90
+ let primaryRemote = remote ? normalizeRemoteUrl(remote) : (identity ? identity.primaryRemote : null);
91
+
92
+ await upsertIdentity(db, { key, name, primaryRemote });
93
+
94
+ const aliases = [];
95
+ if (primaryRemote) {
96
+ aliases.push({ alias: `remote:${primaryRemote}`, kind: "remote" });
97
+ }
98
+ aliases.push({ alias: `path:${canonicalPath(dir)}`, kind: "path" });
99
+ aliases.push({ alias: `basename:${name}`, kind: "basename" });
100
+
101
+ for (const a of aliases) {
102
+ await registerAlias(db, { alias: a.alias, identityKey: key, kind: a.kind });
103
+ }
104
+
105
+ let migrated = false;
106
+ const legacyPathKey = canonicalPath(dir);
107
+ const legacyEntries = await readMemory(legacyPathKey);
108
+ if (legacyEntries && legacyEntries.length > 0) {
109
+ const gitEntries = await readMemory(key);
110
+ const seen = new Set(gitEntries.map((e) => factBody(e).toLowerCase().trim()));
111
+ let mergedCount = 0;
112
+ for (const entry of legacyEntries) {
113
+ const body = factBody(entry).toLowerCase().trim();
114
+ if (!seen.has(body)) {
115
+ seen.add(body);
116
+ gitEntries.push(entry);
117
+ mergedCount++;
118
+ }
119
+ }
120
+ if (mergedCount > 0) {
121
+ await writeMemory(key, gitEntries);
122
+ migrated = true;
123
+ }
124
+ try {
125
+ const legacyFp = storeFilePath(legacyPathKey);
126
+ const { existsSync } = await import("node:fs");
127
+ if (existsSync(legacyFp)) {
128
+ const { unlink } = await import("node:fs/promises");
129
+ await unlink(legacyFp);
130
+ }
131
+ } catch (e) {}
132
+ }
133
+
134
+ return {
135
+ content: [
136
+ {
137
+ type: "text",
138
+ text: JSON.stringify(
139
+ {
140
+ status: "success",
141
+ key,
142
+ name,
143
+ primaryRemote,
144
+ aliases: aliases.map((a) => a.alias),
145
+ migrated,
146
+ },
147
+ null,
148
+ 2
149
+ ),
150
+ },
151
+ ],
152
+ };
153
+ }
154
+ );
155
+
156
+ server.registerTool(
157
+ "unlink_project_memory",
158
+ {
159
+ description: "Remove the path alias link for the specified project directory.",
160
+ inputSchema: z.object({
161
+ directory: optStr().describe("Directory path to unlink (default: current directory)"),
162
+ purge: defBool(false).describe("If true, completely purge the project identity from the SQLite store"),
163
+ }),
164
+ },
165
+ async ({ directory, purge }) => {
166
+ const { getDatabase } = await import("../db/database.js");
167
+ const { unregisterAlias, removeIdentity, resolveProjectIdentity } = await import("../identity.js");
168
+ const db = await getDatabase();
169
+
170
+ const dir = directory || process.cwd();
171
+ const alias = `path:${canonicalPath(dir)}`;
172
+ await unregisterAlias(db, alias);
173
+
174
+ let key = null;
175
+ if (purge) {
176
+ const identity = await resolveProjectIdentity(dir);
177
+ if (identity) {
178
+ key = identity.key;
179
+ await removeIdentity(db, key);
180
+ }
181
+ }
182
+
183
+ return {
184
+ content: [
185
+ {
186
+ type: "text",
187
+ text: JSON.stringify(
188
+ {
189
+ status: "success",
190
+ alias,
191
+ purgedIdentityKey: key,
192
+ },
193
+ null,
194
+ 2
195
+ ),
196
+ },
197
+ ],
198
+ };
199
+ }
200
+ );
201
+
202
+ server.registerTool(
203
+ "relink_project_memory",
204
+ {
205
+ description: "Move or merge project memories from the current identity to a new target identity.",
206
+ inputSchema: z.object({
207
+ directory: optStr().describe("Directory path to relink (default: current directory)"),
208
+ remote: z.string().describe("New target remote URL / identity key to move memories to"),
209
+ }),
210
+ },
211
+ async ({ directory, remote }) => {
212
+ const { getDatabase } = await import("../db/database.js");
213
+ const { resolveProjectIdentity, upsertIdentity, removeIdentity, normalizeRemoteUrl } = await import("../identity.js");
214
+ const db = await getDatabase();
215
+
216
+ const dir = directory || process.cwd();
217
+ const sourceIdentity = await resolveProjectIdentity(dir);
218
+ if (!sourceIdentity) {
219
+ throw new Error("Source project identity not detected.");
220
+ }
221
+
222
+ const targetKey = `git:${normalizeRemoteUrl(remote)}`;
223
+ const sourceKey = sourceIdentity.key;
224
+
225
+ if (sourceKey === targetKey) {
226
+ return { content: [{ type: "text", text: "Source and target identities are already identical." }] };
227
+ }
228
+
229
+ const sourceFacts = await readMemory(sourceKey);
230
+ const targetFacts = await readMemory(targetKey);
231
+ const seen = new Set(targetFacts.map((e) => factBody(e).toLowerCase().trim()));
232
+
233
+ let mergedCount = 0;
234
+ for (const f of sourceFacts) {
235
+ const body = factBody(f).toLowerCase().trim();
236
+ if (!seen.has(body)) {
237
+ seen.add(body);
238
+ targetFacts.push(f);
239
+ mergedCount++;
240
+ }
241
+ }
242
+
243
+ await writeMemory(targetKey, targetFacts);
244
+
245
+ await db.prepare("UPDATE project_aliases SET identity_key = ? WHERE identity_key = ?;").run(targetKey, sourceKey);
246
+ await upsertIdentity(db, { key: targetKey, name: sourceIdentity.name, primaryRemote: normalizeRemoteUrl(remote) });
247
+ await removeIdentity(db, sourceKey);
248
+
249
+ try {
250
+ const sourceFp = storeFilePath(sourceKey);
251
+ const { existsSync } = await import("node:fs");
252
+ if (existsSync(sourceFp)) {
253
+ const { unlink } = await import("node:fs/promises");
254
+ await unlink(sourceFp);
255
+ }
256
+ } catch (e) {}
257
+
258
+ return {
259
+ content: [
260
+ {
261
+ type: "text",
262
+ text: JSON.stringify(
263
+ {
264
+ status: "success",
265
+ sourceKey,
266
+ targetKey,
267
+ mergedFacts: mergedCount,
268
+ },
269
+ null,
270
+ 2
271
+ ),
272
+ },
273
+ ],
274
+ };
275
+ }
276
+ );
277
+ }
@@ -0,0 +1,9 @@
1
+ import { registerMemoryTools } from "./memory_tools.js";
2
+ import { registerIdentityTools } from "./identity_tools.js";
3
+ import { registerRagTools } from "./rag_tools.js";
4
+
5
+ export function registerAllTools(server) {
6
+ registerMemoryTools(server);
7
+ registerIdentityTools(server);
8
+ registerRagTools(server);
9
+ }