@lotargo/memory_plugin 1.6.5 → 1.6.7

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 +34 -0
  2. package/README.md +576 -443
  3. package/mcp-server/benchmarks/fetch_real_corpus.js +351 -0
  4. package/mcp-server/benchmarks/gpu_profile_benchmark.js +170 -0
  5. package/mcp-server/benchmarks/policy_dominance_test.js +221 -0
  6. package/mcp-server/benchmarks/quality_evaluator.js +598 -0
  7. package/mcp-server/benchmarks/raw_corpus_data.js +613 -0
  8. package/mcp-server/benchmarks/run_benchmarks.js +366 -0
  9. package/mcp-server/benchmarks/stress_ingestion.js +195 -0
  10. package/mcp-server/benchmarks/table_code_retrieval.js +453 -0
  11. package/mcp-server/benchmarks/test_dual_layer.js +141 -0
  12. package/mcp-server/cli/direct_commands.js +39 -0
  13. package/mcp-server/cli.js +16 -5
  14. package/mcp-server/cli_boot.js +4 -1
  15. package/mcp-server/client_cli.js +73 -0
  16. package/mcp-server/client_paths.js +44 -0
  17. package/mcp-server/client_registration.js +38 -0
  18. package/mcp-server/codex_config.js +86 -8
  19. package/mcp-server/db/database.js +14 -21
  20. package/mcp-server/db/migrations.js +66 -77
  21. package/mcp-server/db/rag_blob_transport.js +143 -0
  22. package/mcp-server/db/rag_sync.js +284 -0
  23. package/mcp-server/db/sync_queue.js +219 -307
  24. package/mcp-server/dev_link.js +142 -0
  25. package/mcp-server/fact_format.js +44 -12
  26. package/mcp-server/index.js +17 -7
  27. package/mcp-server/ingest/exporter.js +44 -38
  28. package/mcp-server/ingest/pipeline.js +260 -248
  29. package/mcp-server/persona_migration.js +39 -0
  30. package/mcp-server/prompt_manager.js +162 -55
  31. package/mcp-server/rag_scope.js +83 -0
  32. package/mcp-server/retrieval/retriever.js +99 -64
  33. package/mcp-server/setup.js +150 -100
  34. package/mcp-server/storage/blob_store.js +53 -1
  35. package/mcp-server/tools/core/knowledge_read_core.js +163 -0
  36. package/mcp-server/tools/core/memory_core.js +24 -4
  37. package/mcp-server/tools/core/memory_routing.js +10 -0
  38. package/mcp-server/tools/core/note_core.js +53 -0
  39. package/mcp-server/tools/core/rag_query_core.js +169 -0
  40. package/mcp-server/tools/index.js +11 -9
  41. package/mcp-server/tools/memory_tools.js +4 -1
  42. package/mcp-server/tools/note_tools.js +35 -0
  43. package/mcp-server/tools/rag_tools.js +211 -364
  44. package/mcp-server/uninstall.js +627 -0
  45. package/opencode-plugin/index.js +80 -12
  46. package/opencode-plugin/main.js +136 -0
  47. package/package.json +17 -34
  48. package/skills/using-memory/SKILL.md +28 -19
@@ -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
  }
@@ -0,0 +1,83 @@
1
+ import { GLOBAL_KEY, projectKey } from "./memory.js";
2
+
3
+ export async function resolveRagScopeKey(scope = "project", ctx = {}) {
4
+ if (scope === "global") return GLOBAL_KEY;
5
+ const dir = ctx.directory || ctx.project || null;
6
+ const key = await projectKey(ctx.worktree ?? null, dir);
7
+ if (!key) {
8
+ if (scope === "project") {
9
+ throw new Error("Project-scoped RAG requires a Git repository. Use scope='global' outside Git.");
10
+ }
11
+ return null;
12
+ }
13
+ return key;
14
+ }
15
+
16
+ export async function resolveRagScopeKeys(scope = "all", ctx = {}) {
17
+ if (scope === "global") return [GLOBAL_KEY];
18
+ const dir = ctx.directory || ctx.project || null;
19
+ const project = await projectKey(ctx.worktree ?? null, dir);
20
+ if (scope === "project") {
21
+ if (!project) {
22
+ throw new Error("Project-scoped RAG requires a Git repository. Use scope='global' outside Git.");
23
+ }
24
+ return [project];
25
+ }
26
+ return project ? [GLOBAL_KEY, project] : [GLOBAL_KEY];
27
+ }
28
+
29
+ export async function resolveManageRagScopeKeys(action, scope, ctx = {}) {
30
+ if (action !== "delete" || scope) {
31
+ return await resolveRagScopeKeys(scope || "all", ctx);
32
+ }
33
+
34
+ // Browsing defaults to the combined visible view, but a destructive action
35
+ // defaults to the narrowest current ownership boundary. Inside Git that is
36
+ // the project; outside Git the only visible boundary is global.
37
+ const visible = await resolveRagScopeKeys("all", ctx);
38
+ return visible.length > 1 ? [visible[visible.length - 1]] : visible;
39
+ }
40
+
41
+ export async function addDocumentScope(db, docId, scopeKey) {
42
+ const key = scopeKey || GLOBAL_KEY;
43
+ await db
44
+ .prepare(
45
+ "INSERT OR IGNORE INTO document_scopes (doc_id, scope_key, created_at) VALUES (?, ?, ?);"
46
+ )
47
+ .run(docId, key, Date.now());
48
+ return key;
49
+ }
50
+
51
+ export async function removeDocumentScopes(db, docId, scopeKeys) {
52
+ const keys = Array.isArray(scopeKeys) ? [...new Set(scopeKeys.filter(Boolean))] : [];
53
+ if (keys.length === 0) return { removedScopes: [], remainingScopes: 0 };
54
+ const placeholders = keys.map(() => "?").join(",");
55
+ const existing = await db
56
+ .prepare(`SELECT scope_key FROM document_scopes WHERE doc_id = ? AND scope_key IN (${placeholders})`)
57
+ .all(docId, ...keys);
58
+ await db
59
+ .prepare(`DELETE FROM document_scopes WHERE doc_id = ? AND scope_key IN (${placeholders})`)
60
+ .run(docId, ...keys);
61
+ const remaining = await db
62
+ .prepare("SELECT COUNT(*) AS cnt FROM document_scopes WHERE doc_id = ?")
63
+ .get(docId);
64
+ const remainingScopes = remaining?.cnt || 0;
65
+ if (existing.length > 0 && remainingScopes > 0) {
66
+ const { queueDocumentSyncIfNeeded } = await import("./graph/knowledge_linker.js");
67
+ await queueDocumentSyncIfNeeded(db, docId);
68
+ }
69
+ return {
70
+ removedScopes: existing.map((row) => row.scope_key),
71
+ remainingScopes,
72
+ };
73
+ }
74
+
75
+ export function scopeFilterSql(scopeKeys, column = "ds.scope_key") {
76
+ if (!Array.isArray(scopeKeys) || scopeKeys.length === 0) {
77
+ return { clause: "", params: [] };
78
+ }
79
+ return {
80
+ clause: `${column} IN (${scopeKeys.map(() => "?").join(",")})`,
81
+ params: scopeKeys,
82
+ };
83
+ }