@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.
- package/README.md +352 -334
- package/mcp-server/admin/auth.js +293 -42
- package/mcp-server/cli/direct_commands.js +313 -0
- package/mcp-server/cli/handlers/cloud_actions.js +138 -0
- package/mcp-server/cli/handlers/diagnostics_actions.js +107 -0
- package/mcp-server/cli/handlers/engine_actions.js +214 -0
- package/mcp-server/cli/handlers/prompt_actions.js +24 -0
- package/mcp-server/cli/handlers/storage_actions.js +749 -0
- package/mcp-server/cli/quick_stats.js +39 -0
- package/mcp-server/cli/ui.js +565 -0
- package/mcp-server/cli.js +324 -1945
- package/mcp-server/config/auth_store.js +178 -19
- package/mcp-server/config/config_manager.js +1 -0
- package/mcp-server/db/database.js +18 -3
- package/mcp-server/db/migrations.js +28 -0
- package/mcp-server/fact_format.js +244 -177
- package/mcp-server/identity.js +152 -0
- package/mcp-server/index.js +42 -679
- package/mcp-server/memory.js +50 -63
- package/mcp-server/prompt_manager.js +1 -1
- package/mcp-server/setup.js +41 -0
- package/mcp-server/tools/helpers.js +39 -0
- package/mcp-server/tools/identity_tools.js +277 -0
- package/mcp-server/tools/index.js +9 -0
- package/mcp-server/tools/memory_tools.js +506 -0
- package/mcp-server/tools/rag_tools.js +235 -0
- package/opencode-plugin/index.js +460 -48
- package/package.json +7 -3
- package/skills/using-memory/SKILL.md +31 -14
- package/mcp-server/benchmarks/fetch_real_corpus.js +0 -351
- package/mcp-server/benchmarks/gpu_profile_benchmark.js +0 -170
- package/mcp-server/benchmarks/quality_evaluator.js +0 -600
- package/mcp-server/benchmarks/run_benchmarks.js +0 -347
- package/mcp-server/benchmarks/stress_ingestion.js +0 -195
- package/mcp-server/benchmarks/test_dual_layer.js +0 -140
package/mcp-server/memory.js
CHANGED
|
@@ -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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
return
|
|
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
|
-
|
|
65
|
-
export function projectName(worktree, directory) {
|
|
64
|
+
export async function projectName(worktree, directory) {
|
|
66
65
|
const dir = worktree || directory || process.cwd();
|
|
67
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
240
|
-
basename: basename(meta.
|
|
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.
|
|
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.
|
|
235
|
+
const key = meta.key || f.slice(0, -3);
|
|
268
236
|
stores.push({
|
|
269
237
|
key,
|
|
270
|
-
path: meta.
|
|
271
|
-
basename: basename(meta.
|
|
238
|
+
path: meta.key,
|
|
239
|
+
basename: basename(meta.key || key) || key,
|
|
272
240
|
file: f,
|
|
273
241
|
count: facts.length,
|
|
274
|
-
legacy: !meta.
|
|
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).
|
|
287
|
-
|
|
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
|
|
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}`;
|
package/mcp-server/setup.js
CHANGED
|
@@ -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
|
+
}
|