@lotargo/memory_plugin 1.6.6 → 1.6.8

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 (48) hide show
  1. package/CHANGELOG.md +148 -101
  2. package/README.md +436 -304
  3. package/mcp-server/cli/direct_commands.js +39 -0
  4. package/mcp-server/cli.js +16 -5
  5. package/mcp-server/cli_boot.js +4 -1
  6. package/mcp-server/client_cli.js +73 -0
  7. package/mcp-server/client_paths.js +44 -0
  8. package/mcp-server/client_registration.js +38 -0
  9. package/mcp-server/codex_config.js +86 -8
  10. package/mcp-server/db/database.js +14 -21
  11. package/mcp-server/db/migrations.js +66 -77
  12. package/mcp-server/db/rag_blob_transport.js +143 -0
  13. package/mcp-server/db/rag_sync.js +284 -0
  14. package/mcp-server/db/sync_queue.js +219 -307
  15. package/mcp-server/dev_link.js +142 -0
  16. package/mcp-server/fact_format.js +44 -12
  17. package/mcp-server/index.js +17 -7
  18. package/mcp-server/ingest/exporter.js +44 -38
  19. package/mcp-server/ingest/normalizer.js +1 -1
  20. package/mcp-server/ingest/pipeline.js +260 -248
  21. package/mcp-server/persona_migration.js +39 -0
  22. package/mcp-server/prompt_manager.js +162 -55
  23. package/mcp-server/retrieval/retriever.js +99 -64
  24. package/mcp-server/setup.js +150 -100
  25. package/mcp-server/storage/blob_store.js +53 -1
  26. package/mcp-server/tools/core/knowledge_read_core.js +163 -0
  27. package/mcp-server/tools/core/memory_core.js +24 -4
  28. package/mcp-server/tools/core/memory_routing.js +10 -0
  29. package/mcp-server/tools/core/note_core.js +53 -0
  30. package/mcp-server/tools/core/rag_query_core.js +169 -0
  31. package/mcp-server/tools/index.js +11 -9
  32. package/mcp-server/tools/memory_tools.js +4 -1
  33. package/mcp-server/tools/note_tools.js +35 -0
  34. package/mcp-server/tools/rag_tools.js +211 -364
  35. package/mcp-server/uninstall.js +627 -0
  36. package/opencode-plugin/index.js +80 -12
  37. package/opencode-plugin/main.js +136 -0
  38. package/package.json +25 -5
  39. package/skills/using-memory/SKILL.md +28 -19
  40. package/mcp-server/benchmarks/fetch_real_corpus.js +0 -351
  41. package/mcp-server/benchmarks/gpu_profile_benchmark.js +0 -170
  42. package/mcp-server/benchmarks/policy_dominance_test.js +0 -221
  43. package/mcp-server/benchmarks/quality_evaluator.js +0 -598
  44. package/mcp-server/benchmarks/raw_corpus_data.js +0 -613
  45. package/mcp-server/benchmarks/run_benchmarks.js +0 -366
  46. package/mcp-server/benchmarks/stress_ingestion.js +0 -195
  47. package/mcp-server/benchmarks/table_code_retrieval.js +0 -453
  48. package/mcp-server/benchmarks/test_dual_layer.js +0 -141
@@ -0,0 +1,39 @@
1
+ import { factMeta, factTitle, isDirectiveFact, withMeta } from "./fact_format.js";
2
+ import { GLOBAL_KEY, readMemory, writeMemory } from "./memory.js";
3
+ import { syncPersonaPrompts } from "./prompt_manager.js";
4
+
5
+ // Convert compatibility-only persona detection into explicit semantic metadata.
6
+ // Explicit kind values are authoritative and are therefore never changed.
7
+ export function markLegacyPersonaDirectives(entries) {
8
+ const migrated = [];
9
+ const nextEntries = entries.map((entry) => {
10
+ const meta = factMeta(entry);
11
+ if (meta.kind || !isDirectiveFact(entry)) return entry;
12
+
13
+ migrated.push({
14
+ id: meta.id || null,
15
+ title: factTitle(entry) || "Untitled directive",
16
+ });
17
+ return withMeta(entry, { kind: "directive" });
18
+ });
19
+
20
+ return {
21
+ entries: nextEntries,
22
+ migrated,
23
+ changed: migrated.length,
24
+ };
25
+ }
26
+
27
+ export async function migrateLegacyPersonaDirectives({
28
+ dryRun = false,
29
+ readGlobal = () => readMemory(GLOBAL_KEY),
30
+ writeGlobal = (entries) => writeMemory(GLOBAL_KEY, entries),
31
+ syncPersona = () => syncPersonaPrompts(),
32
+ } = {}) {
33
+ const result = markLegacyPersonaDirectives(await readGlobal());
34
+ if (!dryRun) {
35
+ if (result.changed > 0) await writeGlobal(result.entries);
36
+ await syncPersona();
37
+ }
38
+ return { ...result, dryRun };
39
+ }
@@ -1,27 +1,35 @@
1
- import { readFile, writeFile, mkdir, unlink, rename, copyFile } from "fs/promises";
2
- import { existsSync } from "fs";
3
- import { join } from "path";
4
- import { homedir } from "os";
5
- import { createHash } from "crypto";
6
-
7
- const START_MARKER = "<!-- START MEMORY AGENT PROMPT -->";
8
- const END_MARKER = "<!-- END MEMORY AGENT PROMPT -->";
9
-
10
- export const PROMPT_BLOCK = `${START_MARKER}
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\` 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
- 2. PROJECT IDENTITY INITIALIZATION: After the initial recall, call \`memory_info\` for the current workspace. If it reports a Git identity with \`Registry: unlinked\`, immediately call \`link_project_memory\` for the current directory. If linking migrated legacy facts, repeat \`recall(scope: "all")\`. Outside a Git repository, do not create project memory; use global memory only.
14
- 3. 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".
15
- 4. 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.
16
- 5. QUERY OPTIMIZATION: When using \`query_knowledge_base\` from \`memory-agent\`, transform the user's natural language question into concept-dense search queries. Do NOT send raw conversational questions ("как мне сделать X?", "что такое Y?"). Instead, extract key terms and use concise factual phrases. For multi-part queries or comparisons, use \`batch_query_knowledge_base\` with multiple targeted queries this is more efficient (single API call, single ONNX inference pass) and yields higher retrieval quality than separate calls.
17
- 6. SELECTIVE RAG CURATION: When web research or current technical documentation yields reliable project knowledge likely to be needed again, ingest the relevant source or excerpt with project scope and link it to the project-scoped Notebook fact it supports. Use global RAG scope only for sources intentionally reusable across projects. Prioritize authoritative documentation and knowledge newer than model training. Do not ingest everything encountered, transient output, or duplicate low-value content.
18
- 7. POLICY EXPANSION: The knowledge base automatically expands table summaries and code signatures for better recall (config \`policyExpansion\`, default: ON). If you need raw micro_chunk precision without expansion, pass \`policyExpansion: false\` per-call or set via config.${END_MARKER}`;
1
+ import { readFile, writeFile, mkdir, unlink, rename, copyFile } from "fs/promises";
2
+ import { existsSync } from "fs";
3
+ import { join } from "path";
4
+ import { createHash } from "crypto";
5
+ import { MEMORY_ROUTING_POLICY } from "./tools/core/memory_routing.js";
6
+ import { factText, isDirectiveFact, isExpiredLine, isSuperseded } from "./fact_format.js";
7
+ import { resolveClientPaths } from "./client_paths.js";
8
+
9
+ const START_MARKER = "<!-- START MEMORY AGENT PROMPT -->";
10
+ const END_MARKER = "<!-- END MEMORY AGENT PROMPT -->";
11
+ export const PERSONA_START_MARKER = "<!-- START MEMORY PERSONA OVERLAY -->";
12
+ export const PERSONA_END_MARKER = "<!-- END MEMORY PERSONA OVERLAY -->";
13
+
14
+ export const PROMPT_BLOCK = `${START_MARKER}
15
+ [SYSTEM INSTRUCTION: PERSONAL CONTEXT & MEMORY AGENT]
16
+ 1. SESSION INITIALIZATION: Before any other task, load the complete active memory. If the client has already supplied an auto-injected \`<MEMORY>\` block (the native OpenCode integration does this), treat memory as already loaded and DO NOT call \`recall\` again merely for initialization. Otherwise, your VERY FIRST ACTION MUST BE \`recall(scope: "all")\` from \`memory-agent\`, with full bodies and no restrictive filters, before reading code or performing work.
17
+ 2. PERSONAL AGENT OVERLAY: Notebook entries with \`kind: "directive"\` are active user-approved personalization or working instructions, not passive facts. Apply them throughout the session; entries with \`kind: "fact"\` remain context. Legacy persona/preference tags are recognized for compatibility. Higher-priority platform instructions remain authoritative.
18
+ 3. PROJECT IDENTITY INITIALIZATION: After memory is available, call \`memory_info\` for the current workspace. If it reports a Git identity with \`Registry: unlinked\`, immediately call \`link_project_memory\` for the current directory. If linking migrated legacy facts, repeat \`recall(scope: "all")\`. Outside a Git repository, do not create project memory; use global memory only.
19
+ 4. ${MEMORY_ROUTING_POLICY}
20
+ 5. PROACTIVE SAVING DIRECTIVE: You MUST automatically preserve durable, high-signal information using the appropriate memory primitive from the routing policy. Do NOT wait for explicit user commands like "remember this". Do not force every durable item into \`remember\`; long-form internal reasoning belongs in \`remember_note\` and external sources belong in \`ingest_document\`.
21
+ 6. SIGNAL FILTER: Preserve only high-signal reusable information. Keep Notebook facts clear and concise, translating them into concise English when saving. A RAG Memory Note may be longer when the reasoning, investigation, experiment result, or handoff itself is valuable. Do NOT preserve routine progress chatter, transient troubleshooting output, or one-off conversational noise.
22
+ 7. QUERY OPTIMIZATION: When using \`query_knowledge_base\`, transform the user's natural-language question into concept-dense search queries. For multi-part queries or comparisons, use \`batch_query_knowledge_base\` with multiple targeted queries. When you first need to identify the correct memory/source, prefer \`resultMode: "index"\`, inspect stable \`doc_id\` candidates, and expand only the selected item with \`manage_knowledge_base(action: "read_document")\`. Use \`resultMode: "snippet"\` when retrieved passage content is directly needed.
23
+ 8. SELECTIVE RAG CURATION: When web research or current technical documentation yields reliable project knowledge likely to be needed again, ingest the relevant source or excerpt with project scope and link it to the project-scoped Notebook fact it supports. Use global RAG scope only for sources intentionally reusable across projects. Prioritize authoritative documentation and knowledge newer than model training. Do not ingest everything encountered, transient output, or duplicate low-value content.
24
+ 9. HOT + COLD LINKING: When a decision needs both a concise always-visible orientation point and detailed historical reasoning, save the concise point with \`remember\`, save the detailed record with \`remember_note\`, then connect the Notebook fact to the note using its returned \`docId\` via \`link_knowledge\` (or the optional document-link fields on \`remember\`). Never duplicate the full note body into Notebook memory.
25
+ 10. POLICY EXPANSION: The knowledge base automatically expands table summaries and code signatures for content-rich retrieval (config \`policyExpansion\`, default: ON). Semantic TOC/index retrieval disables large policy expansion automatically. If you need raw micro_chunk precision in normal snippet retrieval, pass \`policyExpansion: false\` per-call or set it via config.${END_MARKER}`;
19
26
 
20
27
  // Plugin-owned files live here so we never destroy user-owned config content.
21
- const AGENT_CONFIG_DIR = join(homedir(), ".config", "memory-agent");
22
- export const PROMPT_FILE = join(AGENT_CONFIG_DIR, "prompt.md");
23
- const BACKUP_DIR = join(AGENT_CONFIG_DIR, "backups");
24
- const STATE_FILE = join(AGENT_CONFIG_DIR, "prompt-state.json");
28
+ const CLIENT_PATHS = resolveClientPaths();
29
+ const AGENT_CONFIG_DIR = CLIENT_PATHS.agentConfigDir;
30
+ export const PROMPT_FILE = CLIENT_PATHS.promptFile;
31
+ const BACKUP_DIR = CLIENT_PATHS.promptBackupDir;
32
+ const STATE_FILE = CLIENT_PATHS.promptStateFile;
25
33
 
26
34
  function sha256(content) {
27
35
  return createHash("sha256").update(content).digest("hex");
@@ -55,15 +63,21 @@ async function saveState(state) {
55
63
  await atomicWrite(STATE_FILE, JSON.stringify(state, null, 2) + "\n");
56
64
  }
57
65
 
58
- export function getGlobalPromptTargets() {
59
- const home = homedir();
60
- return [
61
- {
62
- name: "Antigravity",
63
- filePath: join(home, ".gemini", "config", "AGENTS.md"),
64
- // Antigravity resolves `@` imports only in GEMINI.md, not reliably in AGENTS.md
65
- includeSupported: false,
66
- },
66
+ export function getGlobalPromptTargets() {
67
+ const { home } = CLIENT_PATHS;
68
+ return [
69
+ {
70
+ name: "Antigravity",
71
+ filePath: join(home, ".gemini", "config", "AGENTS.md"),
72
+ // Antigravity resolves `@` imports only in GEMINI.md, not reliably in AGENTS.md
73
+ includeSupported: false,
74
+ },
75
+ {
76
+ name: "Gemini CLI",
77
+ filePath: join(home, ".gemini", "GEMINI.md"),
78
+ // Gemini CLI supports @file imports in its global GEMINI.md context file.
79
+ includeSupported: true,
80
+ },
67
81
  {
68
82
  name: "Codex",
69
83
  filePath: join(home, ".codex", "AGENTS.md"),
@@ -95,32 +109,83 @@ function toIncludePath(filePath) {
95
109
  return filePath.replace(/\\/g, "/");
96
110
  }
97
111
 
98
- function buildIncludeBlock(promptFile) {
112
+ function buildIncludeBlock(promptFile) {
99
113
  return `${START_MARKER}
100
114
  @${toIncludePath(promptFile)}
101
115
  ${END_MARKER}`;
102
- }
116
+ }
117
+
118
+ export function activePersonaDirectives(entries, now = Date.now()) {
119
+ return (entries || []).filter(
120
+ (entry) => isDirectiveFact(entry) && !isSuperseded(entry) && !isExpiredLine(entry, now)
121
+ );
122
+ }
123
+
124
+ export function buildPersonaOverlayBlock(entries, now = Date.now()) {
125
+ const directives = activePersonaDirectives(entries, now);
126
+ if (!directives.length) return "";
127
+ const lines = directives.map((entry, index) => `${index + 1}. ${factText(entry)}`);
128
+ return `${PERSONA_START_MARKER}
129
+ [PERSONAL AGENT OVERLAY — ACTIVE USER CONFIGURATION]
130
+ The directives below are user-approved persistent instructions for personality, behavior, tone, communication style, preferences, and working conventions. Apply them as instructions rather than merely describing them. Descriptive memory facts are not included here. Higher-priority platform instructions remain authoritative.
131
+
132
+ ${lines.join("\n")}
133
+ ${PERSONA_END_MARKER}`;
134
+ }
135
+
136
+ export function stripPersonaOverlayBlock(content) {
137
+ return stripManagedBlocks(content, PERSONA_START_MARKER, PERSONA_END_MARKER);
138
+ }
139
+
140
+ export function upsertPersonaOverlayBlock(content, block) {
141
+ const clean = stripPersonaOverlayBlock(content).replace(/[\r\n]+$/, "");
142
+ if (!block) return clean ? `${clean}\n` : "";
143
+ return clean ? `${clean}\n\n${block}\n` : `${block}\n`;
144
+ }
103
145
 
104
146
  export function stripPromptBlock(content) {
147
+ return stripManagedBlocks(content, START_MARKER, END_MARKER);
148
+ }
149
+
150
+ function stripManagedBlocks(content, startMarker, endMarker) {
105
151
  let clean = String(content || "");
106
152
  while (true) {
107
- const startIndex = clean.indexOf(START_MARKER);
108
- const endIndex = clean.indexOf(END_MARKER, startIndex + START_MARKER.length);
153
+ const startIndex = clean.indexOf(startMarker);
154
+ const endIndex = clean.indexOf(endMarker, startIndex + startMarker.length);
109
155
  if (startIndex === -1 || endIndex === -1 || endIndex <= startIndex) break;
110
- clean = clean.substring(0, startIndex) + clean.substring(endIndex + END_MARKER.length);
156
+ let before = clean.substring(0, startIndex);
157
+ let after = clean.substring(endIndex + endMarker.length);
158
+
159
+ // Remove only the separator newlines inserted around the managed block.
160
+ // Do not collapse or trim whitespace elsewhere in the user's file.
161
+ if (after.trim() === "") {
162
+ before = before.replace(/(?:\r?\n){1,2}$/, "");
163
+ after = "";
164
+ } else if (before.trim() === "") {
165
+ before = "";
166
+ after = after.replace(/^(?:\r?\n){1,2}/, "");
167
+ } else {
168
+ const eol = clean.includes("\r\n") ? "\r\n" : "\n";
169
+ before = before.replace(/(?:\r?\n){1,2}$/, "");
170
+ after = after.replace(/^(?:\r?\n){1,2}/, "");
171
+ before += `${eol}${eol}`;
172
+ }
173
+ clean = before + after;
111
174
  }
112
- return clean.replace(/\r?\n(?:[ \t]*\r?\n){2,}/g, "\n\n").trim();
175
+ return clean;
113
176
  }
114
-
177
+
115
178
  export function upsertPromptBlock(content, block = PROMPT_BLOCK) {
116
- const clean = stripPromptBlock(content);
117
- return clean ? `${clean}\n\n${block}\n` : `${block}\n`;
118
- }
179
+ const clean = stripPromptBlock(content).replace(/[\r\n]+$/, "");
180
+ return clean ? `${clean}\n\n${block}\n` : `${block}\n`;
181
+ }
119
182
 
120
183
  export async function enableGlobalPrompt(targetNames = null) {
121
184
  const promptFile = await syncPromptFile();
122
- const requested = Array.isArray(targetNames) ? new Set(targetNames) : null;
123
- const targets = getGlobalPromptTargets().filter((target) => !requested || requested.has(target.name));
185
+ const { readMemory, GLOBAL_KEY } = await import("./memory.js");
186
+ const personaBlock = buildPersonaOverlayBlock(await readMemory(GLOBAL_KEY));
187
+ const requested = Array.isArray(targetNames) ? new Set(targetNames) : null;
188
+ const targets = getGlobalPromptTargets().filter((target) => !requested || requested.has(target.name));
124
189
  const state = await loadState();
125
190
  const results = [];
126
191
 
@@ -133,10 +198,10 @@ export async function enableGlobalPrompt(targetNames = null) {
133
198
 
134
199
  const existed = existsSync(target.filePath);
135
200
  const existing = existed ? await readFile(target.filePath, "utf-8") : "";
136
- const block = target.includeSupported
137
- ? buildIncludeBlock(promptFile)
138
- : PROMPT_BLOCK;
139
- const updated = upsertPromptBlock(existing, block);
201
+ const block = target.includeSupported
202
+ ? buildIncludeBlock(promptFile)
203
+ : PROMPT_BLOCK;
204
+ const updated = upsertPersonaOverlayBlock(upsertPromptBlock(existing, block), personaBlock);
140
205
 
141
206
  const key = target.filePath;
142
207
  const prev = state[key];
@@ -168,8 +233,9 @@ export async function enableGlobalPrompt(targetNames = null) {
168
233
  return results;
169
234
  }
170
235
 
171
- export async function disableGlobalPrompt() {
172
- const targets = getGlobalPromptTargets();
236
+ export async function disableGlobalPrompt(targetNames = null) {
237
+ const requested = Array.isArray(targetNames) ? new Set(targetNames) : null;
238
+ const targets = getGlobalPromptTargets().filter((target) => !requested || requested.has(target.name));
173
239
  const state = await loadState();
174
240
  const results = [];
175
241
 
@@ -181,12 +247,12 @@ export async function disableGlobalPrompt() {
181
247
  }
182
248
 
183
249
  const existing = await readFile(target.filePath, "utf-8");
184
- if (!existing.includes(START_MARKER)) {
250
+ if (!existing.includes(START_MARKER) && !existing.includes(PERSONA_START_MARKER)) {
185
251
  results.push({ name: target.name, filePath: target.filePath, status: "skipped" });
186
252
  continue;
187
253
  }
188
254
 
189
- const clean = stripPromptBlock(existing);
255
+ const clean = stripPersonaOverlayBlock(stripPromptBlock(existing));
190
256
  const key = target.filePath;
191
257
  const prev = state[key];
192
258
 
@@ -212,17 +278,58 @@ export async function disableGlobalPrompt() {
212
278
 
213
279
  await saveState(state);
214
280
  return results;
215
- }
281
+ }
282
+
283
+ export async function syncPersonaPrompts(targetNames = null, entries = null) {
284
+ if (!entries) {
285
+ const { readMemory, GLOBAL_KEY } = await import("./memory.js");
286
+ entries = await readMemory(GLOBAL_KEY);
287
+ }
288
+ const block = buildPersonaOverlayBlock(entries);
289
+ const requested = Array.isArray(targetNames) ? new Set(targetNames) : null;
290
+ const targets = getGlobalPromptTargets().filter((target) => !requested || requested.has(target.name));
291
+ const state = await loadState();
292
+ const results = [];
293
+
294
+ for (const target of targets) {
295
+ try {
296
+ const existed = existsSync(target.filePath);
297
+ if (!existed && !block) {
298
+ results.push({ name: target.name, filePath: target.filePath, status: "skipped" });
299
+ continue;
300
+ }
301
+ await mkdir(join(target.filePath, ".."), { recursive: true });
302
+ const existing = existed ? await readFile(target.filePath, "utf-8") : "";
303
+ const updated = upsertPersonaOverlayBlock(existing, block);
304
+ if (existing === updated) {
305
+ results.push({ name: target.name, filePath: target.filePath, status: "up_to_date" });
306
+ continue;
307
+ }
308
+ const prev = state[target.filePath];
309
+ if (existed && prev?.hash && prev.hash !== sha256(existing)) await backupFile(target.filePath);
310
+ await atomicWrite(target.filePath, updated);
311
+ state[target.filePath] = { hash: sha256(updated), existedBefore: existed };
312
+ results.push({ name: target.name, filePath: target.filePath, status: block ? "synced" : "removed" });
313
+ } catch (err) {
314
+ results.push({ name: target.name, filePath: target.filePath, status: "failed", error: err.message });
315
+ }
316
+ }
317
+
318
+ await saveState(state);
319
+ return results;
320
+ }
216
321
 
217
- export async function getGlobalPromptStatus() {
218
- const targets = getGlobalPromptTargets();
322
+ export async function getGlobalPromptStatus(targetNames = null) {
323
+ const requested = Array.isArray(targetNames) ? new Set(targetNames) : null;
324
+ const targets = getGlobalPromptTargets().filter((target) => !requested || requested.has(target.name));
219
325
  const status = [];
220
326
 
221
327
  for (const target of targets) {
222
328
  let enabled = false;
223
329
  if (existsSync(target.filePath)) {
224
330
  const content = await readFile(target.filePath, "utf-8");
225
- enabled = content.includes(START_MARKER) && content.includes(END_MARKER);
331
+ enabled = (content.includes(START_MARKER) && content.includes(END_MARKER))
332
+ || (content.includes(PERSONA_START_MARKER) && content.includes(PERSONA_END_MARKER));
226
333
  }
227
334
  status.push({ name: target.name, filePath: target.filePath, enabled });
228
335
  }
@@ -10,29 +10,51 @@ export function sanitizeFtsQuery(query) {
10
10
  return words.join(" OR ");
11
11
  }
12
12
 
13
- export async function bm25Search(db, query, limit = 30, scopeKeys = null) {
14
- const ftsQuery = sanitizeFtsQuery(query);
15
- if (!ftsQuery) return [];
16
-
17
- try {
18
- const scoped = Array.isArray(scopeKeys) && scopeKeys.length > 0;
19
- const scopeClause = scoped
20
- ? `AND EXISTS (
21
- SELECT 1 FROM micro_chunks scoped_m
22
- JOIN document_scopes scoped_ds ON scoped_ds.doc_id = scoped_m.doc_id
23
- WHERE scoped_m.id = micro_chunks_fts.id
24
- AND scoped_ds.scope_key IN (${scopeKeys.map(() => "?").join(",")})
25
- )`
26
- : "";
27
- const stmt = db.prepare(`
28
- SELECT id, content, breadcrumbs, rank
29
- FROM micro_chunks_fts
30
- WHERE micro_chunks_fts MATCH ?
31
- ${scopeClause}
32
- ORDER BY rank
33
- LIMIT ?;
34
- `);
35
- const rows = await stmt.all(ftsQuery, ...(scoped ? scopeKeys : []), limit);
13
+ export function parseDocumentMetadata(value) {
14
+ if (!value) return {};
15
+ if (typeof value === "object" && !Array.isArray(value)) return value;
16
+ if (typeof value !== "string") return {};
17
+ try {
18
+ const parsed = JSON.parse(value);
19
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
20
+ } catch {
21
+ return {};
22
+ }
23
+ }
24
+
25
+ function normalizeRetrievedTags(tags) {
26
+ if (Array.isArray(tags)) {
27
+ return [...new Set(tags.map((tag) => String(tag).trim().toLowerCase()).filter(Boolean))].sort();
28
+ }
29
+ if (typeof tags === "string") {
30
+ return [...new Set(tags.split(",").map((tag) => tag.trim().toLowerCase()).filter(Boolean))].sort();
31
+ }
32
+ return [];
33
+ }
34
+
35
+ export async function bm25Search(db, query, limit = 30, scopeKeys = null) {
36
+ const ftsQuery = sanitizeFtsQuery(query);
37
+ if (!ftsQuery) return [];
38
+
39
+ try {
40
+ const scoped = Array.isArray(scopeKeys) && scopeKeys.length > 0;
41
+ const scopeClause = scoped
42
+ ? `AND EXISTS (
43
+ SELECT 1 FROM micro_chunks scoped_m
44
+ JOIN document_scopes scoped_ds ON scoped_ds.doc_id = scoped_m.doc_id
45
+ WHERE scoped_m.id = micro_chunks_fts.id
46
+ AND scoped_ds.scope_key IN (${scopeKeys.map(() => "?").join(",")})
47
+ )`
48
+ : "";
49
+ const stmt = db.prepare(`
50
+ SELECT id, content, breadcrumbs, rank
51
+ FROM micro_chunks_fts
52
+ WHERE micro_chunks_fts MATCH ?
53
+ ${scopeClause}
54
+ ORDER BY rank
55
+ LIMIT ?;
56
+ `);
57
+ const rows = await stmt.all(ftsQuery, ...(scoped ? scopeKeys : []), limit);
36
58
  return rows.map((r, i) => ({
37
59
  id: r.id,
38
60
  content: r.content,
@@ -60,7 +82,7 @@ export function toVectorBytes(value) {
60
82
  return null;
61
83
  }
62
84
 
63
- export async function vectorSearch(db, queryVector, limit = 30, minSim = 0.25, scopeKeys = null) {
85
+ export async function vectorSearch(db, queryVector, limit = 30, minSim = 0.25, scopeKeys = null) {
64
86
  if (!queryVector || queryVector.length === 0) return [];
65
87
 
66
88
  const vectorDim = queryVector.length;
@@ -69,32 +91,32 @@ export async function vectorSearch(db, queryVector, limit = 30, minSim = 0.25, s
69
91
  const tempVec = new Float32Array(tempBuf);
70
92
 
71
93
  const scanLimit = Number(getConfig().vectorScanLimit) || 0;
72
- const scoped = Array.isArray(scopeKeys) && scopeKeys.length > 0;
73
- const scopeClause = scoped
74
- ? `WHERE EXISTS (
75
- SELECT 1 FROM document_scopes scoped_ds
76
- WHERE scoped_ds.doc_id = m.doc_id
77
- AND scoped_ds.scope_key IN (${scopeKeys.map(() => "?").join(",")})
78
- )`
79
- : "";
80
- const scanSql = scanLimit > 0
81
- ? `
82
- SELECT m.id, m.section_id, m.doc_id, m.content, m.vector, s.breadcrumbs
83
- FROM micro_chunks m
84
- JOIN sections s ON m.section_id = s.id
85
- ${scopeClause}
86
- LIMIT ?;
87
- `
88
- : `
89
- SELECT m.id, m.section_id, m.doc_id, m.content, m.vector, s.breadcrumbs
90
- FROM micro_chunks m
91
- JOIN sections s ON m.section_id = s.id
92
- ${scopeClause};
93
- `;
94
-
95
- const stmt = db.prepare(scanSql);
96
- const scopeParams = scoped ? scopeKeys : [];
97
- const rows = scanLimit > 0 ? await stmt.all(...scopeParams, scanLimit) : await stmt.all(...scopeParams);
94
+ const scoped = Array.isArray(scopeKeys) && scopeKeys.length > 0;
95
+ const scopeClause = scoped
96
+ ? `WHERE EXISTS (
97
+ SELECT 1 FROM document_scopes scoped_ds
98
+ WHERE scoped_ds.doc_id = m.doc_id
99
+ AND scoped_ds.scope_key IN (${scopeKeys.map(() => "?").join(",")})
100
+ )`
101
+ : "";
102
+ const scanSql = scanLimit > 0
103
+ ? `
104
+ SELECT m.id, m.section_id, m.doc_id, m.content, m.vector, s.breadcrumbs
105
+ FROM micro_chunks m
106
+ JOIN sections s ON m.section_id = s.id
107
+ ${scopeClause}
108
+ LIMIT ?;
109
+ `
110
+ : `
111
+ SELECT m.id, m.section_id, m.doc_id, m.content, m.vector, s.breadcrumbs
112
+ FROM micro_chunks m
113
+ JOIN sections s ON m.section_id = s.id
114
+ ${scopeClause};
115
+ `;
116
+
117
+ const stmt = db.prepare(scanSql);
118
+ const scopeParams = scoped ? scopeKeys : [];
119
+ const rows = scanLimit > 0 ? await stmt.all(...scopeParams, scanLimit) : await stmt.all(...scopeParams);
98
120
  const scored = [];
99
121
  for (const r of rows) {
100
122
  // node:sqlite returns BLOBs as plain Uint8Array (NOT Buffer), the Turso
@@ -265,8 +287,8 @@ export async function batchHybridQuery(queries, options = {}) {
265
287
  rerankerEnabled = null,
266
288
  instruction = null,
267
289
  generateEmbeddings = true,
268
- policyExpansion = null,
269
- scopeKeys = null,
290
+ policyExpansion = null,
291
+ scopeKeys = null,
270
292
  } = options;
271
293
 
272
294
  const db = customDb || await getDatabase();
@@ -299,8 +321,8 @@ export async function batchHybridQuery(queries, options = {}) {
299
321
  rerankerEnabled: useReranker,
300
322
  instruction,
301
323
  generateEmbeddings,
302
- policyExpansion: usePolicyExpansion,
303
- scopeKeys,
324
+ policyExpansion: usePolicyExpansion,
325
+ scopeKeys,
304
326
  _precomputedVector: queryVectors[i] || null,
305
327
  })
306
328
  )
@@ -322,8 +344,8 @@ export async function hybridQuery({
322
344
  rerankerEnabled = null,
323
345
  instruction = null,
324
346
  generateEmbeddings = true,
325
- policyExpansion = null, // null = use config default
326
- scopeKeys = null, // null = all documents; tool surfaces pass global/current-project keys
347
+ policyExpansion = null, // null = use config default
348
+ scopeKeys = null, // null = all documents; tool surfaces pass global/current-project keys
327
349
  _precomputedVector = null, // internal: skip embedText if batch already computed
328
350
  }) {
329
351
  const db = customDb || await getDatabase();
@@ -351,28 +373,28 @@ export async function hybridQuery({
351
373
  let fusedHits = [];
352
374
 
353
375
  if (algo === "lexical_only" || algo === "bm25_only") {
354
- const bm25Hits = await bm25Search(db, query, limit * 4, scopeKeys);
376
+ const bm25Hits = await bm25Search(db, query, limit * 4, scopeKeys);
355
377
  fusedHits = bm25Hits.map((hit) => ({
356
378
  ...hit,
357
379
  score: 1.0 / hit.bm25_rank,
358
380
  }));
359
381
  } else if (algo === "semantic_only" || algo === "vector_only") {
360
382
  const queryVector = await getQueryVector();
361
- const vectorHits = await vectorSearch(db, queryVector, limit * 4, 0.10, scopeKeys);
383
+ const vectorHits = await vectorSearch(db, queryVector, limit * 4, 0.10, scopeKeys);
362
384
  fusedHits = vectorHits.map((hit) => ({
363
385
  ...hit,
364
386
  score: hit.cosine_sim,
365
387
  }));
366
388
  } else if (algo === "rrf") {
367
- const bm25Hits = await bm25Search(db, query, 30, scopeKeys);
389
+ const bm25Hits = await bm25Search(db, query, 30, scopeKeys);
368
390
  const queryVector = await getQueryVector();
369
- const vectorHits = await vectorSearch(db, queryVector, 30, 0.10, scopeKeys);
391
+ const vectorHits = await vectorSearch(db, queryVector, 30, 0.10, scopeKeys);
370
392
  fusedHits = rrfFusion(bm25Hits, vectorHits, 60, scoreThreshold);
371
393
  } else {
372
394
  // Default: RSF
373
- const bm25Hits = await bm25Search(db, query, 30, scopeKeys);
395
+ const bm25Hits = await bm25Search(db, query, 30, scopeKeys);
374
396
  const queryVector = await getQueryVector();
375
- const vectorHits = await vectorSearch(db, queryVector, 30, 0.10, scopeKeys);
397
+ const vectorHits = await vectorSearch(db, queryVector, 30, 0.10, scopeKeys);
376
398
  fusedHits = rsfFusion(bm25Hits, vectorHits, alphaWeight, scoreThreshold);
377
399
  }
378
400
 
@@ -432,9 +454,11 @@ export async function hybridQuery({
432
454
  const topIds = topHits.map((h) => h.id);
433
455
  const placeholders = topIds.map(() => "?").join(",");
434
456
  const details = await db.prepare(`
435
- SELECT m.id as micro_id, m.retrieval_policy, m.policy_source_id,
457
+ SELECT m.id as micro_id, m.doc_id as doc_id, m.retrieval_policy, m.policy_source_id,
436
458
  s.id as section_id, s.heading, s.breadcrumbs, s.content as section_content,
437
- med.content as medium_content, d.title as doc_title, d.path as doc_path
459
+ med.content as medium_content,
460
+ d.title as doc_title, d.path as doc_path, d.metadata_json,
461
+ d.created_at as doc_created_at, d.updated_at as doc_updated_at
438
462
  FROM micro_chunks m
439
463
  JOIN sections s ON m.section_id = s.id
440
464
  JOIN documents d ON m.doc_id = d.id
@@ -476,6 +500,10 @@ export async function hybridQuery({
476
500
  if (!detail) continue;
477
501
 
478
502
  const symbols = symbolsBySection.get(detail.section_id) || [];
503
+ const metadata = parseDocumentMetadata(detail.metadata_json);
504
+ const sourceType = metadata.source_type || (String(detail.doc_path || "").startsWith("memory://note/") ? "note" : null);
505
+ const noteKind = sourceType === "note" ? (metadata.note_kind || "note") : null;
506
+ const tags = normalizeRetrievedTags(metadata.tags);
479
507
 
480
508
  let snippet = hit.content;
481
509
  let paragraphContext = detail.medium_content || hit.content;
@@ -490,8 +518,15 @@ export async function hybridQuery({
490
518
 
491
519
  results.push({
492
520
  chunk_id: hit.id,
521
+ doc_id: detail.doc_id,
493
522
  doc_title: detail.doc_title,
494
523
  doc_path: detail.doc_path,
524
+ source_type: sourceType,
525
+ note_kind: noteKind,
526
+ tags,
527
+ metadata,
528
+ doc_created_at: detail.doc_created_at ?? null,
529
+ doc_updated_at: detail.doc_updated_at ?? null,
495
530
  heading: detail.heading,
496
531
  breadcrumbs: detail.breadcrumbs,
497
532
  snippet,