@lotargo/memory_plugin 1.4.621 → 1.5.1
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 -366
- package/mcp-server/admin/auth.js +31 -4
- package/mcp-server/admin/snapshot.js +19 -7
- 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 -2085
- package/mcp-server/config/auth_store.js +56 -9
- package/mcp-server/config/config_manager.js +1 -0
- package/mcp-server/db/database.js +14 -1
- package/mcp-server/db/migrations.js +28 -0
- package/mcp-server/fact_format.js +244 -177
- package/mcp-server/graph/graph_extractor.js +20 -5
- package/mcp-server/identity.js +152 -0
- package/mcp-server/index.js +42 -679
- package/mcp-server/ingest/normalizer.js +40 -4
- package/mcp-server/ingest/pipeline.js +6 -13
- package/mcp-server/memory.js +50 -63
- package/mcp-server/prompt_manager.js +1 -1
- package/mcp-server/retrieval/retriever.js +59 -42
- 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
|
@@ -30,15 +30,51 @@ export function cleanHtml(html) {
|
|
|
30
30
|
return cleaned;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
export function validateUrlForSsrf(urlStr) {
|
|
34
|
+
if (typeof urlStr !== "string" || !urlStr.trim()) {
|
|
35
|
+
throw new Error(`Unsupported URL for ingestion: '${urlStr}'. Only http/https URLs are supported.`);
|
|
36
|
+
}
|
|
37
|
+
let parsed;
|
|
38
|
+
try {
|
|
39
|
+
parsed = new URL(urlStr.trim());
|
|
40
|
+
} catch {
|
|
41
|
+
throw new Error(`Invalid URL format for ingestion: '${urlStr}'`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
45
|
+
throw new Error(`Unsupported URL scheme '${parsed.protocol}'. Only http/https are allowed.`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const hostname = parsed.hostname.toLowerCase();
|
|
49
|
+
|
|
50
|
+
const isBlocked =
|
|
51
|
+
hostname === "localhost" ||
|
|
52
|
+
hostname === "127.0.0.1" ||
|
|
53
|
+
hostname === "::1" ||
|
|
54
|
+
hostname === "169.254.169.254" ||
|
|
55
|
+
hostname === "metadata.google.internal" ||
|
|
56
|
+
/^127\./.test(hostname) ||
|
|
57
|
+
/^10\./.test(hostname) ||
|
|
58
|
+
/^192\.168\./.test(hostname) ||
|
|
59
|
+
/^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(hostname) ||
|
|
60
|
+
/^169\.254\./.test(hostname) ||
|
|
61
|
+
/^0\./.test(hostname);
|
|
62
|
+
|
|
63
|
+
if (isBlocked) {
|
|
64
|
+
throw new Error(`Ingestion blocked: URL '${urlStr}' targets a private/local IP address or metadata service.`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return parsed;
|
|
68
|
+
}
|
|
69
|
+
|
|
33
70
|
// Fetch a web page and convert it to Markdown/text. Used by the 'url' ingestion type
|
|
34
71
|
// so the RAG store gets the page CONTENT, not just the URL string.
|
|
35
72
|
export async function fetchUrlContent(url) {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
}
|
|
73
|
+
const parsed = validateUrlForSsrf(url);
|
|
74
|
+
const targetUrl = parsed.toString();
|
|
39
75
|
let res;
|
|
40
76
|
try {
|
|
41
|
-
res = await fetch(
|
|
77
|
+
res = await fetch(targetUrl, {
|
|
42
78
|
headers: {
|
|
43
79
|
"User-Agent": "memory-agent-rag/1.0",
|
|
44
80
|
Accept: "text/html,application/xhtml+xml,application/json,text/plain,*/*",
|
|
@@ -85,12 +85,9 @@ export async function ingestDocument({
|
|
|
85
85
|
try {
|
|
86
86
|
const existingDoc = await db.prepare("SELECT id FROM documents WHERE path = ?").get(docPath);
|
|
87
87
|
if (existingDoc) {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
await db.prepare("DELETE FROM micro_chunks_fts WHERE id = ?").run(mc.id);
|
|
92
|
-
} catch {}
|
|
93
|
-
}
|
|
88
|
+
try {
|
|
89
|
+
await db.prepare("DELETE FROM micro_chunks_fts WHERE id IN (SELECT id FROM micro_chunks WHERE doc_id = ?);").run(existingDoc.id);
|
|
90
|
+
} catch {}
|
|
94
91
|
await db.prepare("DELETE FROM graph_edges WHERE source_id = ? OR target_id = ?").run(existingDoc.id, existingDoc.id);
|
|
95
92
|
await db.prepare("DELETE FROM documents WHERE id = ?").run(existingDoc.id);
|
|
96
93
|
}
|
|
@@ -184,8 +181,6 @@ export async function deleteDocument(docIdOrPath, customDb = null, customBlobDir
|
|
|
184
181
|
return { deleted: false, reason: "Document not found" };
|
|
185
182
|
}
|
|
186
183
|
|
|
187
|
-
const microChunks = await db.prepare("SELECT id FROM micro_chunks WHERE doc_id = ?").all(doc.id);
|
|
188
|
-
|
|
189
184
|
// Collect every id owned by this document so we can purge dangling graph edges
|
|
190
185
|
// (graph_edges has no FK constraints, so section/chunk/doc references would otherwise leak).
|
|
191
186
|
const ownedIds = [doc.id];
|
|
@@ -196,11 +191,9 @@ export async function deleteDocument(docIdOrPath, customDb = null, customBlobDir
|
|
|
196
191
|
|
|
197
192
|
await db.exec("BEGIN IMMEDIATE;");
|
|
198
193
|
try {
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
} catch {}
|
|
203
|
-
}
|
|
194
|
+
try {
|
|
195
|
+
await db.prepare("DELETE FROM micro_chunks_fts WHERE id IN (SELECT id FROM micro_chunks WHERE doc_id = ?);").run(doc.id);
|
|
196
|
+
} catch {}
|
|
204
197
|
|
|
205
198
|
// Auto-clean Agent knowledge graph links pointing at this document.
|
|
206
199
|
await db.prepare("DELETE FROM knowledge_links WHERE doc_id = ?").run(doc.id);
|
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}`;
|
|
@@ -263,57 +263,74 @@ export async function hybridQuery({
|
|
|
263
263
|
|
|
264
264
|
// Parent-Child Rollup: Deduplicate hits sharing the same medium_id or section_id to prevent noise
|
|
265
265
|
const parentDeduplicatedHits = [];
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
const
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
266
|
+
if (fusedHits.length > 0) {
|
|
267
|
+
const hitIds = fusedHits.map((h) => h.id);
|
|
268
|
+
const placeholders = hitIds.map(() => "?").join(",");
|
|
269
|
+
const rows = await db.prepare(`
|
|
270
|
+
SELECT id, medium_id, section_id FROM micro_chunks WHERE id IN (${placeholders});
|
|
271
|
+
`).all(...hitIds);
|
|
272
|
+
const parentMap = new Map(rows.map((r) => [r.id, r]));
|
|
273
|
+
|
|
274
|
+
const seenParents = new Set();
|
|
275
|
+
for (const hit of fusedHits) {
|
|
276
|
+
const row = parentMap.get(hit.id);
|
|
277
|
+
const parentKey = row ? (row.medium_id || row.section_id) : hit.id;
|
|
278
|
+
if (!seenParents.has(parentKey)) {
|
|
279
|
+
seenParents.add(parentKey);
|
|
280
|
+
parentDeduplicatedHits.push(hit);
|
|
281
|
+
}
|
|
277
282
|
}
|
|
278
283
|
}
|
|
279
284
|
|
|
280
285
|
const topHits = parentDeduplicatedHits.slice(0, limit);
|
|
281
286
|
const results = [];
|
|
282
287
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
288
|
+
if (topHits.length > 0) {
|
|
289
|
+
const topIds = topHits.map((h) => h.id);
|
|
290
|
+
const placeholders = topIds.map(() => "?").join(",");
|
|
291
|
+
const details = await db.prepare(`
|
|
292
|
+
SELECT m.id as micro_id, s.id as section_id, s.heading, s.breadcrumbs, s.content as section_content,
|
|
293
|
+
med.content as medium_content, d.title as doc_title, d.path as doc_path
|
|
294
|
+
FROM micro_chunks m
|
|
295
|
+
JOIN sections s ON m.section_id = s.id
|
|
296
|
+
JOIN documents d ON m.doc_id = d.id
|
|
297
|
+
LEFT JOIN medium_chunks med ON m.medium_id = med.id
|
|
298
|
+
WHERE m.id IN (${placeholders});
|
|
299
|
+
`).all(...topIds);
|
|
300
|
+
|
|
301
|
+
const detailMap = new Map(details.map((d) => [d.micro_id, d]));
|
|
302
|
+
|
|
303
|
+
let symbolsBySection = new Map();
|
|
298
304
|
if (includeGraphContext) {
|
|
299
|
-
|
|
305
|
+
const sectionIds = [...new Set(details.map((d) => d.section_id).filter(Boolean))];
|
|
306
|
+
if (sectionIds.length > 0) {
|
|
307
|
+
const { getRelatedSymbolsBatch } = await import("../graph/graph_extractor.js");
|
|
308
|
+
symbolsBySection = await getRelatedSymbolsBatch(db, sectionIds);
|
|
309
|
+
}
|
|
300
310
|
}
|
|
301
311
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
312
|
+
for (const hit of topHits) {
|
|
313
|
+
const detail = detailMap.get(hit.id);
|
|
314
|
+
if (!detail) continue;
|
|
315
|
+
|
|
316
|
+
const symbols = symbolsBySection.get(detail.section_id) || [];
|
|
317
|
+
|
|
318
|
+
results.push({
|
|
319
|
+
chunk_id: hit.id,
|
|
320
|
+
doc_title: detail.doc_title,
|
|
321
|
+
doc_path: detail.doc_path,
|
|
322
|
+
heading: detail.heading,
|
|
323
|
+
breadcrumbs: detail.breadcrumbs,
|
|
324
|
+
snippet: hit.content,
|
|
325
|
+
paragraph_context: detail.medium_content || hit.content,
|
|
326
|
+
full_section_content: detail.section_content,
|
|
327
|
+
score: parseFloat((hit.score || 0).toFixed(4)),
|
|
328
|
+
rsf_score: hit.rsf_score ? parseFloat(hit.rsf_score.toFixed(4)) : null,
|
|
329
|
+
rrf_score: hit.rrf_score ? parseFloat(hit.rrf_score.toFixed(4)) : null,
|
|
330
|
+
cosine_sim: hit.cosine_sim ? parseFloat(hit.cosine_sim.toFixed(4)) : null,
|
|
331
|
+
defined_symbols: symbols,
|
|
332
|
+
});
|
|
333
|
+
}
|
|
317
334
|
}
|
|
318
335
|
|
|
319
336
|
return results;
|
|
@@ -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
|
+
}
|