@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.
Files changed (39) hide show
  1. package/README.md +352 -366
  2. package/mcp-server/admin/auth.js +31 -4
  3. package/mcp-server/admin/snapshot.js +19 -7
  4. package/mcp-server/cli/direct_commands.js +313 -0
  5. package/mcp-server/cli/handlers/cloud_actions.js +138 -0
  6. package/mcp-server/cli/handlers/diagnostics_actions.js +107 -0
  7. package/mcp-server/cli/handlers/engine_actions.js +214 -0
  8. package/mcp-server/cli/handlers/prompt_actions.js +24 -0
  9. package/mcp-server/cli/handlers/storage_actions.js +749 -0
  10. package/mcp-server/cli/quick_stats.js +39 -0
  11. package/mcp-server/cli/ui.js +565 -0
  12. package/mcp-server/cli.js +324 -2085
  13. package/mcp-server/config/auth_store.js +56 -9
  14. package/mcp-server/config/config_manager.js +1 -0
  15. package/mcp-server/db/database.js +14 -1
  16. package/mcp-server/db/migrations.js +28 -0
  17. package/mcp-server/fact_format.js +244 -177
  18. package/mcp-server/graph/graph_extractor.js +20 -5
  19. package/mcp-server/identity.js +152 -0
  20. package/mcp-server/index.js +42 -679
  21. package/mcp-server/ingest/normalizer.js +40 -4
  22. package/mcp-server/ingest/pipeline.js +6 -13
  23. package/mcp-server/memory.js +50 -63
  24. package/mcp-server/prompt_manager.js +1 -1
  25. package/mcp-server/retrieval/retriever.js +59 -42
  26. package/mcp-server/tools/helpers.js +39 -0
  27. package/mcp-server/tools/identity_tools.js +277 -0
  28. package/mcp-server/tools/index.js +9 -0
  29. package/mcp-server/tools/memory_tools.js +506 -0
  30. package/mcp-server/tools/rag_tools.js +235 -0
  31. package/opencode-plugin/index.js +460 -48
  32. package/package.json +7 -3
  33. package/skills/using-memory/SKILL.md +31 -14
  34. package/mcp-server/benchmarks/fetch_real_corpus.js +0 -351
  35. package/mcp-server/benchmarks/gpu_profile_benchmark.js +0 -170
  36. package/mcp-server/benchmarks/quality_evaluator.js +0 -600
  37. package/mcp-server/benchmarks/run_benchmarks.js +0 -347
  38. package/mcp-server/benchmarks/stress_ingestion.js +0 -195
  39. 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
- if (typeof url !== "string" || !/^https?:\/\//i.test(url.trim())) {
37
- throw new Error(`Unsupported URL for ingestion: '${url}'. Only http/https URLs are supported.`);
38
- }
73
+ const parsed = validateUrlForSsrf(url);
74
+ const targetUrl = parsed.toString();
39
75
  let res;
40
76
  try {
41
- res = await fetch(url.trim(), {
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
- const microChunks = await db.prepare("SELECT id FROM micro_chunks WHERE doc_id = ?").all(existingDoc.id);
89
- for (const mc of microChunks) {
90
- try {
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
- for (const mc of microChunks) {
200
- try {
201
- await db.prepare("DELETE FROM micro_chunks_fts WHERE id = ?").run(mc.id);
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);
@@ -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}`;
@@ -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
- const seenParents = new Set();
267
- const parentLookupStmt = db.prepare(`
268
- SELECT medium_id, section_id FROM micro_chunks WHERE id = ?;
269
- `);
270
-
271
- for (const hit of fusedHits) {
272
- const row = await parentLookupStmt.get(hit.id);
273
- const parentKey = row ? (row.medium_id || row.section_id) : hit.id;
274
- if (!seenParents.has(parentKey)) {
275
- seenParents.add(parentKey);
276
- parentDeduplicatedHits.push(hit);
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
- const secStmt = db.prepare(`
284
- SELECT s.id as section_id, s.heading, s.breadcrumbs, s.content as section_content,
285
- med.content as medium_content, d.title as doc_title, d.path as doc_path
286
- FROM micro_chunks m
287
- JOIN sections s ON m.section_id = s.id
288
- JOIN documents d ON m.doc_id = d.id
289
- LEFT JOIN medium_chunks med ON m.medium_id = med.id
290
- WHERE m.id = ?;
291
- `);
292
-
293
- for (const hit of topHits) {
294
- const detail = await secStmt.get(hit.id);
295
- if (!detail) continue;
296
-
297
- let symbols = [];
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
- symbols = await getRelatedSymbols(db, detail.section_id);
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
- results.push({
303
- chunk_id: hit.id,
304
- doc_title: detail.doc_title,
305
- doc_path: detail.doc_path,
306
- heading: detail.heading,
307
- breadcrumbs: detail.breadcrumbs,
308
- snippet: hit.content,
309
- paragraph_context: detail.medium_content || hit.content,
310
- full_section_content: detail.section_content,
311
- score: parseFloat((hit.score || 0).toFixed(4)),
312
- rsf_score: hit.rsf_score ? parseFloat(hit.rsf_score.toFixed(4)) : null,
313
- rrf_score: hit.rrf_score ? parseFloat(hit.rrf_score.toFixed(4)) : null,
314
- cosine_sim: hit.cosine_sim ? parseFloat(hit.cosine_sim.toFixed(4)) : null,
315
- defined_symbols: symbols,
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
+ }