thumbgate 1.30.0 → 1.34.0

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 (92) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.well-known/mcp/server-card.json +1 -1
  3. package/README.md +54 -16
  4. package/adapters/claude/.mcp.json +2 -2
  5. package/adapters/forge/forge.yaml +3 -3
  6. package/adapters/mcp/server-stdio.js +105 -10
  7. package/adapters/opencode/opencode.json +1 -1
  8. package/bench/observability-eval-suite.json +2 -2
  9. package/bin/cli.js +168 -31
  10. package/config/evals/generation-quality-golden.json +95 -0
  11. package/config/evals/rag-answer-quality-golden.json +91 -0
  12. package/config/evals/retrieval-hybrid-ablation.json +66 -0
  13. package/config/evals/retrieval-ranking-golden.json +522 -0
  14. package/config/gates/claim-verifiers.example.json +42 -0
  15. package/config/gates/claim-verifiers.json +25 -0
  16. package/config/gates/default.json +217 -50
  17. package/config/mcp-allowlists.json +233 -206
  18. package/config/model-tiers.json +7 -2
  19. package/glama.json +6 -0
  20. package/hooks/hooks.json +1 -1
  21. package/package.json +69 -12
  22. package/public/assets/diagrams/before-after.svg +17 -16
  23. package/public/assets/diagrams/hero-thumbs.svg +68 -0
  24. package/public/assets/diagrams/loop.svg +19 -13
  25. package/public/assets/diagrams/self-improving-thumbs-loop.svg +105 -0
  26. package/public/compare.html +1 -0
  27. package/public/dashboard.html +126 -28
  28. package/public/evaluations.html +1 -1
  29. package/public/index.html +142 -13
  30. package/public/numbers.html +3 -2
  31. package/public/pricing.html +143 -30
  32. package/scripts/a-plus-evidence-scorecard.js +303 -0
  33. package/scripts/agent-readiness.js +110 -0
  34. package/scripts/async-eval-observability.js +36 -11
  35. package/scripts/audit-trail.js +37 -1
  36. package/scripts/auto-promote-gates.js +149 -34
  37. package/scripts/auto-wire-hooks.js +20 -8
  38. package/scripts/cli-schema.js +14 -0
  39. package/scripts/colbert-style-maxsim.js +236 -0
  40. package/scripts/cross-encoder-reranker.js +356 -126
  41. package/scripts/dashboard-chat.js +350 -17
  42. package/scripts/document-intake.js +283 -7
  43. package/scripts/eval-quality-suite.js +204 -0
  44. package/scripts/feedback-loop.js +115 -7
  45. package/scripts/feedback-paths.js +32 -13
  46. package/scripts/feedback-quality.js +53 -0
  47. package/scripts/feedback-schema.js +3 -0
  48. package/scripts/file-ledger-lock.js +130 -0
  49. package/scripts/filesystem-search.js +17 -7
  50. package/scripts/financial-control-plane.js +1514 -0
  51. package/scripts/gates-engine.js +202 -7
  52. package/scripts/gemini-embedding-policy.js +1 -0
  53. package/scripts/harness-tool-names.js +70 -0
  54. package/scripts/hook-runtime.js +15 -3
  55. package/scripts/hook-stop-anti-claim.js +63 -3
  56. package/scripts/human-escalation.js +353 -41
  57. package/scripts/lesson-db.js +16 -5
  58. package/scripts/lesson-embedding-index.js +67 -20
  59. package/scripts/lesson-embedding-maintenance.js +177 -0
  60. package/scripts/lesson-reranker.js +55 -9
  61. package/scripts/lesson-retrieval.js +305 -29
  62. package/scripts/lesson-search.js +22 -8
  63. package/scripts/llm-client.js +304 -15
  64. package/scripts/model-tier-router.js +593 -0
  65. package/scripts/pragmatic-hybrid-search.js +379 -0
  66. package/scripts/provider-action-normalizer.js +11 -4
  67. package/scripts/rag-document-pipeline.js +461 -0
  68. package/scripts/rag-structured-output.js +441 -0
  69. package/scripts/ragas-style-metrics.js +351 -0
  70. package/scripts/request-envelope.js +178 -0
  71. package/scripts/rerank-pipeline.js +370 -0
  72. package/scripts/rerank-quality-eval.js +155 -0
  73. package/scripts/retrieval-hybrid-ablation.js +120 -0
  74. package/scripts/retrieval-quality-tier.js +118 -0
  75. package/scripts/secret-scanner.js +395 -4
  76. package/scripts/self-distill-agent.js +7 -1
  77. package/scripts/self-healing-check.js +25 -0
  78. package/scripts/skill-packs.js +183 -0
  79. package/scripts/slow-loop.js +72 -0
  80. package/scripts/statusline-links.js +1 -1
  81. package/scripts/statusline.sh +8 -1
  82. package/scripts/telemetry-analytics.js +13 -1
  83. package/scripts/thumbgate-search.js +98 -6
  84. package/scripts/tier-budget-guard.js +186 -0
  85. package/scripts/tool-registry.js +141 -5
  86. package/scripts/universal-claim-evaluator.js +767 -0
  87. package/scripts/vector-store.js +154 -17
  88. package/scripts/verify-marketing-pages-deployed.js +85 -3
  89. package/scripts/workflow-sentinel.js +77 -11
  90. package/server.json +44 -0
  91. package/smithery.yaml +17 -0
  92. package/src/api/server.js +196 -13
@@ -3,13 +3,14 @@
3
3
 
4
4
  /**
5
5
  * Per-action lesson retrieval.
6
- * v3: bi-encoder retrieval → cross-encoder reranking
6
+ * v3: first-stage retrieval → field-aware BM25F reranking
7
7
  *
8
8
  * Stage 1 (bi-encoder): score all memories independently using token overlap,
9
9
  * bigram Jaccard, tool-name matching, and recency decay. Retrieve top-50.
10
- * Stage 2 (cross-encoder): rerank the top-50 candidates by computing a
11
- * field-weighted BM25 score that processes (query, lesson) jointly, then
12
- * blend with the original bi-encoder score. Return top-maxResults.
10
+ * Stage 2 (BM25F): rerank the top-50 candidates with field-weighted lexical
11
+ * evidence, then blend with the original retrieval score. This stage is not
12
+ * a neural cross-encoder; neural/late-interaction/LLM stages live in the
13
+ * explicit reranking cascade.
13
14
  */
14
15
 
15
16
  const RECENCY_DECAY_DAYS = 30;
@@ -69,11 +70,149 @@ function selectRetrievalMemories(memories = [], options = {}) {
69
70
  includeShared: options.includeShared !== false,
70
71
  }).allowed;
71
72
  }
72
- return selected;
73
+ return selected.filter((memory) => matchesMetadataFilters(
74
+ memory,
75
+ options.metadataFilters || options.filters,
76
+ ));
77
+ }
78
+
79
+ function normalizedValues(value) {
80
+ const values = Array.isArray(value) ? value : value == null ? [] : [value];
81
+ return values.map((item) => String(item).trim().toLowerCase()).filter(Boolean);
82
+ }
83
+
84
+ function memoryValues(memory, field) {
85
+ if (field === 'toolsUsed') {
86
+ return normalizedValues([
87
+ ...(memory.metadata?.toolsUsed || []),
88
+ ...(memory.structuredRule?.metadata?.toolsUsed || []),
89
+ ]);
90
+ }
91
+ if (field === 'tags') return normalizedValues(memory.tags);
92
+ if (field === 'signal') {
93
+ return normalizedValues([
94
+ memory.signal,
95
+ memory.feedback,
96
+ ...(memory.tags || []),
97
+ ]);
98
+ }
99
+ return normalizedValues(memory.metadata?.[field] ?? memory[field]);
100
+ }
101
+
102
+ function matchesMetadataFilters(memory, filters = {}) {
103
+ if (!filters || typeof filters !== 'object') return true;
104
+ for (const field of ['domain', 'signal', 'source', 'toolsUsed']) {
105
+ const required = normalizedValues(filters[field]);
106
+ if (required.length === 0) continue;
107
+ const actual = memoryValues(memory, field);
108
+ if (!required.some((value) => actual.includes(value))) return false;
109
+ }
110
+ const requiredTags = normalizedValues(filters.tags);
111
+ if (requiredTags.length > 0) {
112
+ const actualTags = memoryValues(memory, 'tags');
113
+ const matches = filters.requireAllTags === false
114
+ ? requiredTags.some((tag) => actualTags.includes(tag))
115
+ : requiredTags.every((tag) => actualTags.includes(tag));
116
+ if (!matches) return false;
117
+ }
118
+ return true;
119
+ }
120
+
121
+ function buildQueryVariants(query, options = {}) {
122
+ const original = String(query || '').trim();
123
+ if (!original || options.queryRewrite === false) return original ? [original] : [];
124
+ const { tokenize, expandTerms } = require('./lesson-reranker');
125
+ const originalTerms = tokenize(original);
126
+ const originalSet = new Set(originalTerms);
127
+ const additions = expandTerms(originalTerms)
128
+ .filter((term) => !originalSet.has(term))
129
+ .slice(0, Math.max(1, Math.min(12, Number(options.maxRewriteTerms) || 8)));
130
+ if (additions.length === 0) return [original];
131
+ const expanded = `${original} ${additions.join(' ')}`.slice(0, 500);
132
+ const focused = `failure prevention ${[
133
+ ...originalTerms.filter((term) => term.length >= 3),
134
+ ...additions,
135
+ ].filter((term, index, all) => all.indexOf(term) === index).slice(0, 18).join(' ')}`.slice(0, 500);
136
+ return [...new Set([original, expanded, focused])];
137
+ }
138
+
139
+ /**
140
+ * Build an auditable query-transformation plan. Deterministic multi-query is
141
+ * always local. HyDE is opt-in via a caller-supplied generator so sensitive
142
+ * action text is never sent to a cloud model implicitly.
143
+ */
144
+ async function buildQueryPlan(query, options = {}) {
145
+ const variants = buildQueryVariants(query, options);
146
+ const plan = {
147
+ variants,
148
+ strategy: variants.length > 1 ? 'deterministic-multi-query' : 'original-only',
149
+ hydeApplied: false,
150
+ hydeProvider: null,
151
+ fallbacks: [],
152
+ };
153
+ if (typeof options.hydeGenerator !== 'function' || !variants.length) return plan;
154
+
155
+ try {
156
+ const generated = await options.hydeGenerator(variants[0], {
157
+ maxChars: 700,
158
+ instruction: 'Write a concise hypothetical prevention lesson that would answer this action query. Do not issue commands.',
159
+ });
160
+ const text = String(generated?.text ?? generated ?? '').trim().slice(0, 700);
161
+ if (!text || variants.includes(text)) {
162
+ plan.fallbacks.push('hyde-empty-or-duplicate');
163
+ return plan;
164
+ }
165
+ plan.variants = [...variants, text].slice(0, 4);
166
+ plan.strategy = 'deterministic-multi-query+hyde';
167
+ plan.hydeApplied = true;
168
+ plan.hydeProvider = String(generated?.provider || options.hydeProvider || 'caller-supplied').slice(0, 80);
169
+ return plan;
170
+ } catch {
171
+ plan.fallbacks.push('hyde-generator-failed');
172
+ return plan;
173
+ }
73
174
  }
74
175
 
75
176
  function retrieveRelevantLessons(toolName, actionContext, options = {}) {
76
177
  const { maxResults = 5, feedbackDir } = options;
178
+
179
+ // Prefer pragmatic path (attribute-aware first stage + diversify) when enabled.
180
+ // Scope / requireScope / maxMemoryChars must flow into loadMemories so we never
181
+ // bypass four-field memory scope filters.
182
+ if (options.pragmatic !== false) {
183
+ try {
184
+ const { pragmaticHybridSearch, sampleRetrievalRecall } = require('./pragmatic-hybrid-search');
185
+ const memories = loadMemories(feedbackDir, options);
186
+ if (memories.length === 0) return [];
187
+ const { results, meta } = pragmaticHybridSearch({
188
+ corpus: memories,
189
+ query: actionContext,
190
+ toolName,
191
+ options: {
192
+ topK: Math.max(maxResults * 2, maxResults),
193
+ pool: RERANK_CANDIDATE_POOL,
194
+ diversify: options.diversify !== false,
195
+ perLimit: options.perLimit || 3,
196
+ attribute: options.attribute,
197
+ },
198
+ });
199
+ sampleRetrievalRecall({
200
+ toolName,
201
+ queryPreview: String(actionContext || '').slice(0, 200),
202
+ strategy: meta.strategy,
203
+ topIds: results.slice(0, maxResults).map((r) => r.id),
204
+ mode: 'sync',
205
+ }, { feedbackDir });
206
+ return filterTopP(
207
+ dedupeSupersededLessons(results),
208
+ resolveTopP(options),
209
+ { minKeep: options.minKeep },
210
+ ).slice(0, maxResults).map(shapeLesson);
211
+ } catch {
212
+ // fall through to classic path
213
+ }
214
+ }
215
+
77
216
  const { getFeedbackPaths, readJSONL } = require('./feedback-loop');
78
217
  const { rerankLessons } = require('./lesson-reranker');
79
218
  const pathMod = require('path');
@@ -89,7 +228,7 @@ function retrieveRelevantLessons(toolName, actionContext, options = {}) {
89
228
 
90
229
  const actionSig = buildActionSignature(toolName, actionContext);
91
230
 
92
- // Stage 1 — bi-encoder: score all memories independently, take top-50 candidates
231
+ // Stage 1 — local first-stage score, take top-50 candidates
93
232
  const candidates = memories
94
233
  .map((mem) => ({
95
234
  ...mem,
@@ -101,7 +240,7 @@ function retrieveRelevantLessons(toolName, actionContext, options = {}) {
101
240
 
102
241
  if (candidates.length === 0) return [];
103
242
 
104
- // Stage 2 — cross-encoder reranker: rerank candidates by joint (query, lesson) score
243
+ // Stage 2 — field-aware BM25F reranker (not a neural cross-encoder)
105
244
  const reranked = rerankLessons(actionContext, candidates, {
106
245
  topK: maxResults,
107
246
  toolName,
@@ -112,7 +251,7 @@ function retrieveRelevantLessons(toolName, actionContext, options = {}) {
112
251
  const deduped = dedupeSupersededLessons(reranked);
113
252
  const selected = filterTopP(deduped, resolveTopP(options), { minKeep: options.minKeep });
114
253
 
115
- return selected.map((m) => ({
254
+ const shaped = selected.map((m) => ({
116
255
  id: m.id,
117
256
  title: m.title,
118
257
  content: m.content,
@@ -121,6 +260,28 @@ function retrieveRelevantLessons(toolName, actionContext, options = {}) {
121
260
  relevanceScore: m.rerankedScore ?? m.relevanceScore,
122
261
  timestamp: m.timestamp,
123
262
  }));
263
+
264
+ // Attach retrieval quality tier once (non-enumerable-ish via property on array)
265
+ try {
266
+ const { probeEmbeddingQuality } = require('./retrieval-quality-tier');
267
+ const quality = probeEmbeddingQuality({
268
+ indexUpdatedAtMs: options.indexUpdatedAtMs ?? null,
269
+ });
270
+ Object.defineProperty(shaped, 'retrievalMeta', {
271
+ value: {
272
+ strategy: 'lexical+bm25',
273
+ qualityTier: quality.qualityTier,
274
+ semanticClaimsAllowed: quality.semanticClaimsAllowed,
275
+ degradedReasons: quality.degradedReasons,
276
+ count: shaped.length,
277
+ },
278
+ enumerable: false,
279
+ configurable: true,
280
+ });
281
+ } catch {
282
+ // optional
283
+ }
284
+ return shaped;
124
285
  }
125
286
 
126
287
  /**
@@ -138,13 +299,16 @@ function retrieveRelevantLessons(toolName, actionContext, options = {}) {
138
299
  */
139
300
  function reciprocalRankFusion(rankedLists = [], options = {}) {
140
301
  const k = Number.isFinite(options.k) ? options.k : 60;
302
+ const weights = Array.isArray(options.weights) ? options.weights : [];
141
303
  const scores = new Map();
142
- for (const list of rankedLists) {
304
+ for (let listIndex = 0; listIndex < rankedLists.length; listIndex += 1) {
305
+ const list = rankedLists[listIndex];
143
306
  if (!Array.isArray(list)) continue;
307
+ const weight = Number(weights[listIndex]) || 1;
144
308
  list.forEach((id, index) => {
145
309
  if (id === undefined || id === null) return;
146
310
  const rank = index + 1;
147
- scores.set(id, (scores.get(id) || 0) + 1 / (k + rank));
311
+ scores.set(id, (scores.get(id) || 0) + weight / (k + rank));
148
312
  });
149
313
  }
150
314
  return [...scores.entries()]
@@ -164,8 +328,8 @@ function loadMemories(feedbackDir, options = {}) {
164
328
  );
165
329
  }
166
330
 
167
- function shapeLesson(m) {
168
- return {
331
+ function shapeLesson(m, retrieval = null) {
332
+ const shaped = {
169
333
  id: m.id,
170
334
  title: m.title,
171
335
  content: m.content,
@@ -174,6 +338,33 @@ function shapeLesson(m) {
174
338
  relevanceScore: m.rerankedScore ?? m.relevanceScore,
175
339
  timestamp: m.timestamp,
176
340
  };
341
+ if (retrieval) shaped.retrieval = retrieval;
342
+ return shaped;
343
+ }
344
+
345
+ /** Attach non-enumerable retrieval quality meta onto a result array. */
346
+ function attachArrayRetrievalMeta(rows, options = {}) {
347
+ if (!Array.isArray(rows)) return rows;
348
+ try {
349
+ const { probeEmbeddingQuality } = require('./retrieval-quality-tier');
350
+ const quality = probeEmbeddingQuality({
351
+ indexUpdatedAtMs: options.indexUpdatedAtMs ?? null,
352
+ });
353
+ Object.defineProperty(rows, 'retrievalMeta', {
354
+ value: {
355
+ strategy: options.strategy || 'hybrid',
356
+ qualityTier: quality.qualityTier,
357
+ semanticClaimsAllowed: quality.semanticClaimsAllowed,
358
+ degradedReasons: quality.degradedReasons,
359
+ count: rows.length,
360
+ },
361
+ enumerable: false,
362
+ configurable: true,
363
+ });
364
+ } catch {
365
+ // optional
366
+ }
367
+ return rows;
177
368
  }
178
369
 
179
370
  /**
@@ -181,7 +372,7 @@ function shapeLesson(m) {
181
372
  * retrieveRelevantLessons. Used by the async gate path (gates-engine runAsync).
182
373
  *
183
374
  * Pipeline: lexical ranking ⊕ dense (embedding) ranking → Reciprocal Rank Fusion
184
- * → cross-encoder rerank → top-K. Dense recall surfaces past mistakes that share
375
+ * → BM25F rerank → top-K. Dense recall surfaces past mistakes that share
185
376
  * no keywords with the action (paraphrase/synonym) — the value lexical alone misses.
186
377
  *
187
378
  * HONEST DEGRADATION: if no real embedder is available, or embedding errors, this
@@ -214,7 +405,6 @@ async function retrieveRelevantLessonsAsync(toolName, actionContext, options = {
214
405
  .filter((m) => m.relevanceScore > 0.1)
215
406
  .sort((a, b) => b.relevanceScore - a.relevanceScore);
216
407
  const lexicalRanked = lexicalScored.slice(0, RERANK_CANDIDATE_POOL).map((m) => m.id);
217
-
218
408
  // Check if any lexical match is conclusive (exact/regex match on structured rule or high relevance)
219
409
  let conclusive = false;
220
410
  for (const candidate of lexicalScored) {
@@ -248,6 +438,17 @@ async function retrieveRelevantLessonsAsync(toolName, actionContext, options = {
248
438
  return filterTopP(dedupeSupersededLessons(reranked), resolveTopP(options), { minKeep: options.minKeep }).map(shapeLesson);
249
439
  }
250
440
 
441
+ const queryPlan = lexicalScored[0]?.relevanceScore >= (options.rewriteBelowScore ?? 0.6)
442
+ ? {
443
+ variants: [String(actionContext || '')],
444
+ strategy: 'original-only-conclusive-lexical',
445
+ hydeApplied: false,
446
+ hydeProvider: null,
447
+ fallbacks: [],
448
+ }
449
+ : await buildQueryPlan(actionContext, options);
450
+ const queryVariants = queryPlan.variants;
451
+
251
452
  // WHERE-clause pruning: filter memories before vector search to only include
252
453
  // memories relevant to the current toolName or context.
253
454
  const prunedMemories = memories.filter((mem) => {
@@ -255,14 +456,18 @@ async function retrieveRelevantLessonsAsync(toolName, actionContext, options = {
255
456
  const score = scoreRelevance(mem, toolName, actionContext, actionSig);
256
457
  if (score > 0.1) return true;
257
458
 
258
- // 2. Otherwise, check tool compatibility: if toolsUsed is specified, it must contain our tool
259
- const memTools = mem.metadata?.toolsUsed || [];
260
- if (memTools.length > 0 && !memTools.some(t => t.toLowerCase() === toolName.toLowerCase())) {
261
- return false;
262
- }
263
- const ruleTools = mem.structuredRule?.metadata?.toolsUsed || [];
264
- if (ruleTools.length > 0 && !ruleTools.some(t => t.toLowerCase() === toolName.toLowerCase())) {
265
- return false;
459
+ // 2. Tool compatibility is opt-in. A lesson learned through Bash can still
460
+ // matter while a Read/agent tool diagnoses the same incident. Callers that
461
+ // need a hard WHERE clause can use strictToolFilter or metadataFilters.
462
+ if (options.strictToolFilter === true) {
463
+ const memTools = mem.metadata?.toolsUsed || [];
464
+ if (memTools.length > 0 && !memTools.some(t => t.toLowerCase() === toolName.toLowerCase())) {
465
+ return false;
466
+ }
467
+ const ruleTools = mem.structuredRule?.metadata?.toolsUsed || [];
468
+ if (ruleTools.length > 0 && !ruleTools.some(t => t.toLowerCase() === toolName.toLowerCase())) {
469
+ return false;
470
+ }
266
471
  }
267
472
  return true;
268
473
  });
@@ -271,17 +476,80 @@ async function retrieveRelevantLessonsAsync(toolName, actionContext, options = {
271
476
  let semanticRanked = [];
272
477
  if (prunedMemories.length > 0) {
273
478
  try {
274
- const dense = await embeddingIndex.semanticRank(actionContext, prunedMemories, {
275
- feedbackDir,
276
- embedder: options.embedder,
277
- });
278
- semanticRanked = dense.slice(0, RERANK_CANDIDATE_POOL).map((d) => d.id);
479
+ const denseLists = [];
480
+ for (const variant of queryVariants) {
481
+ const dense = await embeddingIndex.semanticRank(variant, prunedMemories, {
482
+ feedbackDir,
483
+ embedder: options.embedder,
484
+ embedderId: options.embedderId,
485
+ strictToolFilter: options.strictToolFilter,
486
+ });
487
+ const topScore = dense[0]?.score ?? 0;
488
+ const minimum = Math.max(
489
+ Number(options.minSemanticScore) || 0.15,
490
+ topScore - (Number(options.semanticScoreWindow) || 0.2),
491
+ );
492
+ denseLists.push(
493
+ dense
494
+ .filter((entry) => entry.score >= minimum)
495
+ .slice(0, RERANK_CANDIDATE_POOL)
496
+ .map((entry) => entry.id),
497
+ );
498
+ }
499
+ semanticRanked = reciprocalRankFusion(denseLists)
500
+ .slice(0, RERANK_CANDIDATE_POOL)
501
+ .map((entry) => entry.id);
279
502
  } catch {
280
503
  // Embedding failed at runtime → fall back to pure lexical.
281
504
  return retrieveRelevantLessons(toolName, actionContext, options);
282
505
  }
283
506
  }
284
507
 
508
+ // Pragmatic multi-stage path (turbopuffer hybrid playbook, local-only):
509
+ // multi-query (lexical ⊕ dense) → RRF → attribute-aware BM25 rerank → diversify.
510
+ // Falls back to legacy fuse+rerank if pragmatic module is unavailable.
511
+ try {
512
+ const { pragmaticHybridSearch, sampleRetrievalRecall } = require('./pragmatic-hybrid-search');
513
+ const { results, meta } = pragmaticHybridSearch({
514
+ corpus: memories,
515
+ query: actionContext,
516
+ toolName,
517
+ options: {
518
+ topK: Math.max(maxResults * 2, maxResults),
519
+ pool: RERANK_CANDIDATE_POOL,
520
+ denseRankedIds: semanticRanked,
521
+ queryVariants,
522
+ denseWeight: options.denseWeight,
523
+ fusionWeight: options.fusionWeight,
524
+ diversify: options.diversify !== false,
525
+ perLimit: options.perLimit || 3,
526
+ attribute: options.attribute,
527
+ },
528
+ });
529
+ sampleRetrievalRecall({
530
+ toolName,
531
+ queryPreview: String(actionContext || '').slice(0, 200),
532
+ strategy: meta.strategy,
533
+ topIds: results.slice(0, maxResults).map((r) => r.id),
534
+ densePool: meta.densePool,
535
+ lexicalPool: meta.lexicalPool,
536
+ }, { feedbackDir });
537
+ const cut = filterTopP(
538
+ dedupeSupersededLessons(results),
539
+ resolveTopP(options),
540
+ { minKeep: options.minKeep },
541
+ ).slice(0, maxResults);
542
+ const retrieval = options.includeRetrievalMeta ? {
543
+ ...meta,
544
+ queryVariants,
545
+ queryTransformation: queryPlan,
546
+ semanticProvider: options.embedderId || 'configured',
547
+ } : null;
548
+ return cut.map((lesson) => shapeLesson(lesson, retrieval));
549
+ } catch {
550
+ // Legacy fuse path below
551
+ }
552
+
285
553
  // Fuse. Candidate pool is the union — dense can introduce lessons lexical missed.
286
554
  const fused = reciprocalRankFusion([lexicalRanked, semanticRanked]);
287
555
  if (fused.length === 0) return [];
@@ -295,7 +563,7 @@ async function retrieveRelevantLessonsAsync(toolName, actionContext, options = {
295
563
  .map((entry) => {
296
564
  const mem = byId.get(entry.id);
297
565
  if (!mem) return null;
298
- // Carry a relevanceScore the cross-encoder can blend against. Prefer the
566
+ // Carry a relevanceScore the BM25F reranker can blend against. Prefer the
299
567
  // lexical score when present; otherwise use the normalized fusion score so
300
568
  // dense-only candidates still rank sensibly.
301
569
  const relevanceScore = lexById.has(entry.id)
@@ -309,7 +577,11 @@ async function retrieveRelevantLessonsAsync(toolName, actionContext, options = {
309
577
 
310
578
  const { rerankLessons } = require('./lesson-reranker');
311
579
  const reranked = rerankLessons(actionContext, candidates, { topK: maxResults, toolName });
312
- return filterTopP(dedupeSupersededLessons(reranked), resolveTopP(options), { minKeep: options.minKeep }).map(shapeLesson);
580
+ const rows = filterTopP(dedupeSupersededLessons(reranked), resolveTopP(options), { minKeep: options.minKeep }).map(shapeLesson);
581
+ return attachArrayRetrievalMeta(rows, {
582
+ strategy: 'hybrid-rrf+bm25',
583
+ indexUpdatedAtMs: options.indexUpdatedAtMs ?? null,
584
+ });
313
585
  }
314
586
 
315
587
  function buildActionSignature(toolName, actionContext) {
@@ -547,6 +819,10 @@ module.exports = {
547
819
  dedupeSupersededLessons,
548
820
  isRetrievableMemory,
549
821
  selectRetrievalMemories,
822
+ matchesMetadataFilters,
823
+ buildQueryVariants,
824
+ buildQueryPlan,
825
+ attachArrayRetrievalMeta,
550
826
  MAX_RETRIEVAL_MEMORY_CHARS,
551
827
  MAX_RETRIEVAL_MEMORY_LINES,
552
828
  };
@@ -403,6 +403,8 @@ function scoreLesson(queryText, memory, parsed, sourceFeedback) {
403
403
  if (!queryText) {
404
404
  return {
405
405
  score: recencyScore(memory.timestamp),
406
+ evidenceScore: 0,
407
+ priorScore: recencyScore(memory.timestamp),
406
408
  matchedTokens: [],
407
409
  };
408
410
  }
@@ -410,14 +412,16 @@ function scoreLesson(queryText, memory, parsed, sourceFeedback) {
410
412
  const lessonText = buildLessonQuery(memory, parsed, sourceFeedback);
411
413
  const queryTokens = tokenize(queryText);
412
414
  const lessonTokens = tokenize(lessonText);
413
- const score = jaccardSimilarity(queryTokens, lessonTokens)
414
- + substringBoost(queryText, lessonText)
415
- + recencyScore(memory.timestamp)
415
+ const evidenceScore = jaccardSimilarity(queryTokens, lessonTokens)
416
+ + substringBoost(queryText, lessonText);
417
+ const priorScore = recencyScore(memory.timestamp)
416
418
  + (memory.category === 'error' ? 0.05 : 0)
417
419
  + Math.min(0.2, scoreHybridMemoryMatch(queryText, memory).score * 0.1);
418
420
 
419
421
  return {
420
- score,
422
+ score: evidenceScore + priorScore,
423
+ evidenceScore,
424
+ priorScore,
421
425
  matchedTokens: unique(queryTokens.filter((token) => lessonTokens.includes(token))),
422
426
  };
423
427
  }
@@ -427,7 +431,7 @@ function buildLessonResult(memory, sourceFeedback, options = {}) {
427
431
  const lessonQuery = buildLessonQuery(memory, parsed, sourceFeedback);
428
432
  const ruleMatches = readPreventionRuleMatches(lessonQuery, Number(options.ruleLimit || 3), options);
429
433
  const gateMatches = buildGateMatches(memory, parsed, Number(options.gateLimit || 3), options);
430
- const { score, matchedTokens } = scoreLesson(options.query || '', memory, parsed, sourceFeedback);
434
+ const { score, evidenceScore, priorScore, matchedTokens } = scoreLesson(options.query || '', memory, parsed, sourceFeedback);
431
435
  const harnessRecommendations = buildHarnessRecommendations(memory, parsed, sourceFeedback, ruleMatches, gateMatches);
432
436
  const lifecycle = buildLifecycle(memory, parsed, sourceFeedback, ruleMatches, gateMatches, harnessRecommendations);
433
437
  const memoryLifecycle = buildMemoryLifecycleView(memory, { query: options.query || '' });
@@ -441,6 +445,8 @@ function buildLessonResult(memory, sourceFeedback, options = {}) {
441
445
  timestamp: memory.timestamp || null,
442
446
  sourceFeedbackId: memory.sourceFeedbackId || null,
443
447
  score: Number(score.toFixed(4)),
448
+ evidenceScore: Number(evidenceScore.toFixed(4)),
449
+ priorScore: Number(priorScore.toFixed(4)),
444
450
  matchedTokens,
445
451
  lesson: {
446
452
  summary: parsed.summary,
@@ -510,7 +516,10 @@ function searchLessons(query = '', options = {}) {
510
516
  results = results.filter((entry) => requiredTags.every((tag) => entry.tags.includes(tag)));
511
517
  }
512
518
  if (query) {
513
- results = results.filter((entry) => entry.score > 0);
519
+ // Recency, error severity, and lifecycle priors can reorder candidates,
520
+ // but they are never evidence that a lesson answers the query. Returning a
521
+ // recent unrelated incident is worse than an honest empty result.
522
+ results = results.filter((entry) => entry.evidenceScore > 0);
514
523
  }
515
524
 
516
525
  results.sort((a, b) => {
@@ -518,7 +527,7 @@ function searchLessons(query = '', options = {}) {
518
527
  return String(b.timestamp || '').localeCompare(String(a.timestamp || ''));
519
528
  });
520
529
 
521
- // Cross-encoder reranking: when a query is present, rerank the top-50 bi-encoder
530
+ // Field-aware BM25F reranking: when a query is present, rerank the top-50 first-stage
522
531
  // candidates using field-weighted BM25 so the most relevant lessons surface first.
523
532
  if (query && results.length > 1) {
524
533
  const { rerankLessons } = loadOptionalModule('./lesson-reranker', () => ({
@@ -558,9 +567,10 @@ function tryFts5Search(query, options) {
558
567
  // silently searching across tenants or sessions.
559
568
  if (options.scope || options.requireScope) return null;
560
569
  if (!process.env.LESSON_DB_SEARCH && !options.useFts5) return null;
570
+ let db = null;
561
571
  try {
562
572
  const { initDB, searchLessons: fts5Search, getStats } = require('./lesson-db');
563
- const db = initDB();
573
+ db = initDB();
564
574
  const stats = getStats(db);
565
575
 
566
576
  // If DB is empty, skip (not yet backfilled)
@@ -629,6 +639,10 @@ function tryFts5Search(query, options) {
629
639
  };
630
640
  } catch (_err) {
631
641
  return null; // SQLite unavailable — fall through to JSONL
642
+ } finally {
643
+ if (db) {
644
+ try { db.close(); } catch { /* best-effort close */ }
645
+ }
632
646
  }
633
647
  }
634
648