@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.
- package/CHANGELOG.md +148 -101
- package/README.md +436 -304
- package/mcp-server/cli/direct_commands.js +39 -0
- package/mcp-server/cli.js +16 -5
- package/mcp-server/cli_boot.js +4 -1
- package/mcp-server/client_cli.js +73 -0
- package/mcp-server/client_paths.js +44 -0
- package/mcp-server/client_registration.js +38 -0
- package/mcp-server/codex_config.js +86 -8
- package/mcp-server/db/database.js +14 -21
- package/mcp-server/db/migrations.js +66 -77
- package/mcp-server/db/rag_blob_transport.js +143 -0
- package/mcp-server/db/rag_sync.js +284 -0
- package/mcp-server/db/sync_queue.js +219 -307
- package/mcp-server/dev_link.js +142 -0
- package/mcp-server/fact_format.js +44 -12
- package/mcp-server/index.js +17 -7
- package/mcp-server/ingest/exporter.js +44 -38
- package/mcp-server/ingest/normalizer.js +1 -1
- package/mcp-server/ingest/pipeline.js +260 -248
- package/mcp-server/persona_migration.js +39 -0
- package/mcp-server/prompt_manager.js +162 -55
- package/mcp-server/retrieval/retriever.js +99 -64
- package/mcp-server/setup.js +150 -100
- package/mcp-server/storage/blob_store.js +53 -1
- package/mcp-server/tools/core/knowledge_read_core.js +163 -0
- package/mcp-server/tools/core/memory_core.js +24 -4
- package/mcp-server/tools/core/memory_routing.js +10 -0
- package/mcp-server/tools/core/note_core.js +53 -0
- package/mcp-server/tools/core/rag_query_core.js +169 -0
- package/mcp-server/tools/index.js +11 -9
- package/mcp-server/tools/memory_tools.js +4 -1
- package/mcp-server/tools/note_tools.js +35 -0
- package/mcp-server/tools/rag_tools.js +211 -364
- package/mcp-server/uninstall.js +627 -0
- package/opencode-plugin/index.js +80 -12
- package/opencode-plugin/main.js +136 -0
- package/package.json +25 -5
- package/skills/using-memory/SKILL.md +28 -19
- package/mcp-server/benchmarks/fetch_real_corpus.js +0 -351
- package/mcp-server/benchmarks/gpu_profile_benchmark.js +0 -170
- package/mcp-server/benchmarks/policy_dominance_test.js +0 -221
- package/mcp-server/benchmarks/quality_evaluator.js +0 -598
- package/mcp-server/benchmarks/raw_corpus_data.js +0 -613
- package/mcp-server/benchmarks/run_benchmarks.js +0 -366
- package/mcp-server/benchmarks/stress_ingestion.js +0 -195
- package/mcp-server/benchmarks/table_code_retrieval.js +0 -453
- 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 {
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
|
22
|
-
|
|
23
|
-
const
|
|
24
|
-
const
|
|
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 =
|
|
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(
|
|
108
|
-
const endIndex = clean.indexOf(
|
|
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
|
-
|
|
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
|
|
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
|
|
123
|
-
const
|
|
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
|
|
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
|
|
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
|
|
14
|
-
|
|
15
|
-
if (!
|
|
16
|
-
|
|
17
|
-
try {
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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,
|
|
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,
|