@lotargo/memory_plugin 1.6.1 → 1.6.3

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.
@@ -1,5 +1,5 @@
1
1
  import { getDatabase } from "../db/database.js";
2
- import { embedText, cosineSimilarity, rerankHits } from "../ml/model_manager.js";
2
+ import { embedText, embedBatch, cosineSimilarity, rerankHits } from "../ml/model_manager.js";
3
3
  import { getConfig } from "../config/config_manager.js";
4
4
 
5
5
  export function sanitizeFtsQuery(query) {
@@ -220,6 +220,72 @@ export function rsfFusion(bm25Hits, vectorHits, alpha = 0.5, scoreThreshold = 0.
220
220
  return merged.filter((item) => item.rsf_score >= scoreThreshold);
221
221
  }
222
222
 
223
+ /**
224
+ * Execute multiple hybrid queries in a single batch.
225
+ * Optimizes ONNX inference by embedding all queries in one pass.
226
+ * Each query still gets independent BM25 + vector search + fusion.
227
+ *
228
+ * @param {string[]} queries - Array of query strings
229
+ * @param {object} options - Same as hybridQuery, applied to all queries
230
+ * @returns {Promise<Array<Array>>} - Array of result arrays (one per query)
231
+ */
232
+ export async function batchHybridQuery(queries, options = {}) {
233
+ if (!Array.isArray(queries) || queries.length === 0) return [];
234
+
235
+ const {
236
+ limit = 5,
237
+ scoreThreshold = 0.01,
238
+ customDb = null,
239
+ includeGraphContext = true,
240
+ fusionAlgorithm = null,
241
+ alpha = null,
242
+ embeddingModel = null,
243
+ rerankerModel = null,
244
+ rerankerEnabled = null,
245
+ instruction = null,
246
+ generateEmbeddings = true,
247
+ policyExpansion = null,
248
+ } = options;
249
+
250
+ const db = customDb || await getDatabase();
251
+ const activeConfig = getConfig();
252
+
253
+ const algo = fusionAlgorithm || activeConfig.fusionAlgorithm || "rsf";
254
+ const alphaWeight = alpha !== null && alpha !== undefined ? alpha : (activeConfig.alpha ?? 0.5);
255
+ const embModel = embeddingModel || activeConfig.embeddingModel || "Xenova/multilingual-e5-small";
256
+ const usePolicyExpansion = policyExpansion !== null && policyExpansion !== undefined ? policyExpansion : (activeConfig.policyExpansion ?? true);
257
+ const useReranker = rerankerEnabled !== null ? rerankerEnabled : (activeConfig.rerankerEnabled ?? false);
258
+
259
+ // Batch embed all queries in one ONNX pass (major latency saving)
260
+ const queryVectors = generateEmbeddings
261
+ ? await embedBatch(queries.map((q) => q), true, embModel, null, instruction)
262
+ : queries.map(() => null);
263
+
264
+ // Execute all queries in parallel (BM25 + vector search are independent per query)
265
+ const results = await Promise.all(
266
+ queries.map((query, i) =>
267
+ hybridQuery({
268
+ query,
269
+ limit,
270
+ scoreThreshold,
271
+ customDb: db,
272
+ includeGraphContext,
273
+ fusionAlgorithm: algo,
274
+ alpha: alphaWeight,
275
+ embeddingModel: embModel,
276
+ rerankerModel,
277
+ rerankerEnabled: useReranker,
278
+ instruction,
279
+ generateEmbeddings,
280
+ policyExpansion: usePolicyExpansion,
281
+ _precomputedVector: queryVectors[i] || null,
282
+ })
283
+ )
284
+ );
285
+
286
+ return results;
287
+ }
288
+
223
289
  export async function hybridQuery({
224
290
  query,
225
291
  limit = 5,
@@ -233,6 +299,8 @@ export async function hybridQuery({
233
299
  rerankerEnabled = null,
234
300
  instruction = null,
235
301
  generateEmbeddings = true,
302
+ policyExpansion = null, // null = use config default
303
+ _precomputedVector = null, // internal: skip embedText if batch already computed
236
304
  }) {
237
305
  const db = customDb || await getDatabase();
238
306
  const activeConfig = getConfig();
@@ -248,6 +316,13 @@ export async function hybridQuery({
248
316
  const embModel = embeddingModel || activeConfig.embeddingModel || "Xenova/multilingual-e5-small";
249
317
  const useReranker = rerankerEnabled !== null ? rerankerEnabled : (activeConfig.rerankerEnabled ?? false);
250
318
  const rerankModelName = rerankerModel || activeConfig.rerankerModel || "Xenova/bge-reranker-base";
319
+ const usePolicyExpansion = policyExpansion !== null && policyExpansion !== undefined ? policyExpansion : (activeConfig.policyExpansion ?? true);
320
+
321
+ // Resolve query vector: use precomputed (from batch) or embed on demand
322
+ const getQueryVector = async () => {
323
+ if (_precomputedVector) return _precomputedVector;
324
+ return await embedText(query, true, embModel, null, instruction);
325
+ };
251
326
 
252
327
  let fusedHits = [];
253
328
 
@@ -258,7 +333,7 @@ export async function hybridQuery({
258
333
  score: 1.0 / hit.bm25_rank,
259
334
  }));
260
335
  } else if (algo === "semantic_only" || algo === "vector_only") {
261
- const queryVector = await embedText(query, true, embModel, null, instruction);
336
+ const queryVector = await getQueryVector();
262
337
  const vectorHits = await vectorSearch(db, queryVector, limit * 4, 0.10);
263
338
  fusedHits = vectorHits.map((hit) => ({
264
339
  ...hit,
@@ -266,13 +341,13 @@ export async function hybridQuery({
266
341
  }));
267
342
  } else if (algo === "rrf") {
268
343
  const bm25Hits = await bm25Search(db, query, 30);
269
- const queryVector = await embedText(query, true, embModel, null, instruction);
344
+ const queryVector = await getQueryVector();
270
345
  const vectorHits = await vectorSearch(db, queryVector, 30, 0.10);
271
346
  fusedHits = rrfFusion(bm25Hits, vectorHits, 60, scoreThreshold);
272
347
  } else {
273
348
  // Default: RSF
274
349
  const bm25Hits = await bm25Search(db, query, 30);
275
- const queryVector = await embedText(query, true, embModel, null, instruction);
350
+ const queryVector = await getQueryVector();
276
351
  const vectorHits = await vectorSearch(db, queryVector, 30, 0.10);
277
352
  fusedHits = rsfFusion(bm25Hits, vectorHits, alphaWeight, scoreThreshold);
278
353
  }
@@ -281,35 +356,60 @@ export async function hybridQuery({
281
356
  fusedHits = await rerankHits(query, fusedHits, rerankModelName);
282
357
  }
283
358
 
284
- // Parent-Child Rollup: Deduplicate hits sharing the same medium_id or section_id to prevent noise
359
+ // Parent-Child Rollup: Deduplicate hits sharing the same medium_id or section_id to prevent noise.
360
+ // Policy chunks (table_summary, code_signature) get a distinct parent key so they coexist
361
+ // with micro_chunks that share the same medium_id — otherwise BM25 would lose raw rows.
285
362
  const parentDeduplicatedHits = [];
286
363
  if (fusedHits.length > 0) {
287
364
  const hitIds = fusedHits.map((h) => h.id);
288
365
  const placeholders = hitIds.map(() => "?").join(",");
289
366
  const rows = await db.prepare(`
290
- SELECT id, medium_id, section_id FROM micro_chunks WHERE id IN (${placeholders});
367
+ SELECT id, medium_id, section_id, retrieval_policy, policy_source_id FROM micro_chunks WHERE id IN (${placeholders});
291
368
  `).all(...hitIds);
292
369
  const parentMap = new Map(rows.map((r) => [r.id, r]));
293
370
 
294
371
  const seenParents = new Set();
295
372
  for (const hit of fusedHits) {
296
373
  const row = parentMap.get(hit.id);
297
- const parentKey = row ? (row.medium_id || row.section_id) : hit.id;
374
+ const isPolicy = usePolicyExpansion && (row?.retrieval_policy === "table_summary" || row?.retrieval_policy === "code_signature");
375
+ const baseKey = row ? (row.medium_id || row.section_id) : hit.id;
376
+ const parentKey = isPolicy ? `policy:${baseKey}` : `micro:${baseKey}`;
298
377
  if (!seenParents.has(parentKey)) {
299
378
  seenParents.add(parentKey);
300
- parentDeduplicatedHits.push(hit);
379
+ parentDeduplicatedHits.push({ ...hit, retrieval_policy: isPolicy ? row?.retrieval_policy : "micro_chunk", policy_source_id: isPolicy ? (row?.policy_source_id || null) : null });
301
380
  }
302
381
  }
303
382
  }
304
383
 
305
- const topHits = parentDeduplicatedHits.slice(0, limit);
384
+ // Policy-Based Deduplication: if multiple hits resolve to the same policy_source_id, keep only the highest-scored one
385
+ // When policy expansion is disabled, skip — all chunks are already treated as micro_chunk
386
+ const policyDeduplicatedHits = usePolicyExpansion
387
+ ? (() => {
388
+ const result = [];
389
+ const seenPolicySources = new Set();
390
+ for (const hit of parentDeduplicatedHits) {
391
+ if (hit.policy_source_id && (hit.retrieval_policy === "table_summary" || hit.retrieval_policy === "code_signature")) {
392
+ if (!seenPolicySources.has(hit.policy_source_id)) {
393
+ seenPolicySources.add(hit.policy_source_id);
394
+ result.push(hit);
395
+ }
396
+ } else {
397
+ result.push(hit);
398
+ }
399
+ }
400
+ return result;
401
+ })()
402
+ : parentDeduplicatedHits;
403
+
404
+ const topHits = policyDeduplicatedHits.slice(0, limit);
306
405
  const results = [];
307
406
 
308
407
  if (topHits.length > 0) {
309
408
  const topIds = topHits.map((h) => h.id);
310
409
  const placeholders = topIds.map(() => "?").join(",");
311
410
  const details = await db.prepare(`
312
- SELECT m.id as micro_id, s.id as section_id, s.heading, s.breadcrumbs, s.content as section_content,
411
+ SELECT m.id as micro_id, m.retrieval_policy, m.policy_source_id,
412
+ s.id as section_id, s.heading, s.breadcrumbs, s.content as section_content,
313
413
  med.content as medium_content, d.title as doc_title, d.path as doc_path
314
414
  FROM micro_chunks m
315
415
  JOIN sections s ON m.section_id = s.id
@@ -329,26 +429,56 @@ export async function hybridQuery({
329
429
  }
330
430
  }
331
431
 
432
+ // Fetch full content for policy-based expansion (table_summary → full table, code_signature → full code)
433
+ // When policy expansion is disabled, skip — snippets stay as micro_chunk content
434
+ const policySourceIds = usePolicyExpansion
435
+ ? details
436
+ .filter((d) => d.policy_source_id && (d.retrieval_policy === "table_summary" || d.retrieval_policy === "code_signature"))
437
+ .map((d) => d.policy_source_id)
438
+ : [];
439
+ const uniquePolicySourceIds = [...new Set(policySourceIds)];
440
+
441
+ let expandedContentMap = new Map();
442
+ if (uniquePolicySourceIds.length > 0) {
443
+ const policyPlaceholders = uniquePolicySourceIds.map(() => "?").join(",");
444
+ const expandedRows = await db.prepare(`
445
+ SELECT id, content, block_type FROM medium_chunks WHERE id IN (${policyPlaceholders});
446
+ `).all(...uniquePolicySourceIds);
447
+ expandedContentMap = new Map(expandedRows.map((r) => [r.id, r]));
448
+ }
449
+
332
450
  for (const hit of topHits) {
333
451
  const detail = detailMap.get(hit.id);
334
452
  if (!detail) continue;
335
453
 
336
454
  const symbols = symbolsBySection.get(detail.section_id) || [];
337
455
 
456
+ let snippet = hit.content;
457
+ let paragraphContext = detail.medium_content || hit.content;
458
+
459
+ if (usePolicyExpansion && detail.policy_source_id && (detail.retrieval_policy === "table_summary" || detail.retrieval_policy === "code_signature")) {
460
+ const expanded = expandedContentMap.get(detail.policy_source_id);
461
+ if (expanded) {
462
+ snippet = expanded.content;
463
+ paragraphContext = expanded.content;
464
+ }
465
+ }
466
+
338
467
  results.push({
339
468
  chunk_id: hit.id,
340
469
  doc_title: detail.doc_title,
341
470
  doc_path: detail.doc_path,
342
471
  heading: detail.heading,
343
472
  breadcrumbs: detail.breadcrumbs,
344
- snippet: hit.content,
345
- paragraph_context: detail.medium_content || hit.content,
473
+ snippet,
474
+ paragraph_context: paragraphContext,
346
475
  full_section_content: detail.section_content,
347
476
  score: parseFloat((hit.score || 0).toFixed(4)),
348
477
  rsf_score: hit.rsf_score ? parseFloat(hit.rsf_score.toFixed(4)) : null,
349
478
  rrf_score: hit.rrf_score ? parseFloat(hit.rrf_score.toFixed(4)) : null,
350
479
  cosine_sim: hit.cosine_sim ? parseFloat(hit.cosine_sim.toFixed(4)) : null,
351
480
  defined_symbols: symbols,
481
+ retrieval_policy: (usePolicyExpansion ? (detail.retrieval_policy || "micro_chunk") : "micro_chunk"),
352
482
  });
353
483
  }
354
484
  }
@@ -187,27 +187,43 @@ export async function runSetup() {
187
187
  }
188
188
  }
189
189
 
190
- // 4. Codex (~/.codex/config.toml)
191
- if (doCodex) {
192
- try {
193
- const codexDir = join(home, ".codex");
194
- const codexConfig = join(codexDir, "config.toml");
195
- if (existsSync(codexDir)) {
196
- let content = existsSync(codexConfig) ? await readFile(codexConfig, "utf-8") : "";
197
- if (!content.includes("memory-agent")) {
198
- const tomlSnippet = `\n[mcp_servers.memory-agent]\ncommand = "npx"\nargs = ["-y", "@lotargo/memory_plugin"]\n`;
199
- content += tomlSnippet;
200
- await writeFile(codexConfig, content);
201
- console.log(" [OK] Codex: added mcp_servers.memory-agent to ~/.codex/config.toml");
202
- configuredCount++;
203
- } else {
204
- console.log(" [INFO] Codex: already configured");
205
- }
206
- }
207
- } catch (err) {
208
- console.log(" [SKIP] Codex setup skipped:", err.message);
209
- }
210
- }
190
+ // 4. Codex (~/.codex/config.toml)
191
+ if (doCodex) {
192
+ try {
193
+ const {
194
+ updateCodexMemoryAgentConfig,
195
+ validateCodexRuntime,
196
+ } = await import("./codex_config.js");
197
+ const codexDir = join(home, ".codex");
198
+ const codexConfig = join(codexDir, "config.toml");
199
+ const nodePath = process.execPath;
200
+ const bootPath = fileURLToPath(new URL("./boot.js", import.meta.url));
201
+ const runtime = validateCodexRuntime({ nodePath, nodeVersion: process.versions.node, bootPath });
202
+ if (!runtime.ok) {
203
+ throw new Error(`Codex direct launcher validation failed: ${runtime.errors.join("; ")}`);
204
+ }
205
+
206
+ await mkdir(codexDir, { recursive: true });
207
+ const content = existsSync(codexConfig) ? await readFile(codexConfig, "utf-8") : "";
208
+ const update = updateCodexMemoryAgentConfig(content, { nodePath, bootPath });
209
+ if (update.status === "conflict") {
210
+ throw new Error(update.reason);
211
+ }
212
+ if (update.changed) {
213
+ await writeFile(codexConfig, update.content, "utf-8");
214
+ console.log(
215
+ update.status === "added"
216
+ ? " [OK] Codex: added direct Node.js memory-agent launcher to ~/.codex/config.toml"
217
+ : " [OK] Codex: migrated memory-agent to a direct Node.js launcher in ~/.codex/config.toml"
218
+ );
219
+ configuredCount++;
220
+ } else {
221
+ console.log(" [INFO] Codex: direct Node.js memory-agent launcher already configured");
222
+ }
223
+ } catch (err) {
224
+ console.log(" [FAIL] Codex setup failed:", err.message);
225
+ }
226
+ }
211
227
 
212
228
  // 5. Global Prompt Instructions (Antigravity, Codex, Claude Code)
213
229
  try {