thumbgate 1.29.2 → 1.31.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 (111) 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 +143 -14
  7. package/adapters/opencode/opencode.json +1 -1
  8. package/bench/observability-eval-suite.json +2 -2
  9. package/bin/cli.js +154 -36
  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/default.json +217 -50
  15. package/config/mcp-allowlists.json +215 -185
  16. package/config/model-tiers.json +7 -2
  17. package/config/post-deploy-marketing-pages.json +26 -1
  18. package/glama.json +6 -0
  19. package/package.json +94 -11
  20. package/public/architecture.html +130 -0
  21. package/public/assets/diagrams/agent-integration.png +0 -0
  22. package/public/assets/diagrams/before-after.svg +22 -0
  23. package/public/assets/diagrams/decision.svg +36 -0
  24. package/public/assets/diagrams/feedback-pipeline.png +0 -0
  25. package/public/assets/diagrams/hero-thumbs.svg +68 -0
  26. package/public/assets/diagrams/loop.svg +40 -0
  27. package/public/assets/diagrams/plugin-topology.png +0 -0
  28. package/public/assets/diagrams/pre-action-gate-loop.svg +59 -0
  29. package/public/assets/diagrams/self-improving-thumbs-loop.svg +105 -0
  30. package/public/assets/diagrams/stack.svg +18 -0
  31. package/public/assets/diagrams/thumbgate-architecture.png +0 -0
  32. package/public/case-studies.html +151 -0
  33. package/public/compare.html +1 -0
  34. package/public/dashboard.html +126 -28
  35. package/public/eval-scorecard.html +195 -0
  36. package/public/eval-scorecard.json +18 -0
  37. package/public/evaluations.html +168 -0
  38. package/public/index.html +143 -13
  39. package/public/numbers.html +3 -2
  40. package/public/pricing.html +143 -30
  41. package/public/whitepaper.html +189 -0
  42. package/scripts/a-plus-evidence-scorecard.js +303 -0
  43. package/scripts/activation-quickstart.js +1 -0
  44. package/scripts/agent-outcome-monitor.js +71 -1
  45. package/scripts/async-eval-observability.js +36 -11
  46. package/scripts/audit-trail.js +37 -1
  47. package/scripts/auto-promote-gates.js +149 -34
  48. package/scripts/billing.js +3 -1
  49. package/scripts/claude-feedback-sync.js +3 -2
  50. package/scripts/cli-feedback.js +13 -7
  51. package/scripts/colbert-style-maxsim.js +236 -0
  52. package/scripts/cross-encoder-reranker.js +359 -126
  53. package/scripts/dashboard-chat.js +350 -17
  54. package/scripts/document-intake.js +283 -7
  55. package/scripts/eval-quality-suite.js +204 -0
  56. package/scripts/feedback-aggregate.js +5 -2
  57. package/scripts/feedback-loop.js +359 -189
  58. package/scripts/feedback-paths.js +32 -13
  59. package/scripts/feedback-quality.js +53 -0
  60. package/scripts/filesystem-search.js +17 -7
  61. package/scripts/gates-engine.js +98 -4
  62. package/scripts/generate-case-study-outreach.js +253 -0
  63. package/scripts/generate-eval-scorecard.js +276 -0
  64. package/scripts/growth-campaigns.js +183 -0
  65. package/scripts/harness-tool-names.js +70 -0
  66. package/scripts/hook-runtime.js +10 -3
  67. package/scripts/jsonl-watcher.js +1 -0
  68. package/scripts/lesson-db.js +16 -5
  69. package/scripts/lesson-embedding-index.js +67 -20
  70. package/scripts/lesson-embedding-maintenance.js +177 -0
  71. package/scripts/lesson-inference.js +23 -4
  72. package/scripts/lesson-reranker.js +55 -9
  73. package/scripts/lesson-retrieval.js +375 -32
  74. package/scripts/lesson-search.js +48 -11
  75. package/scripts/llm-client.js +304 -15
  76. package/scripts/mcp-config.js +26 -5
  77. package/scripts/mcp-oauth.js +37 -2
  78. package/scripts/model-eval.js +308 -0
  79. package/scripts/model-tier-router.js +593 -0
  80. package/scripts/parallel-workflow-orchestrator.js +86 -22
  81. package/scripts/pragmatic-hybrid-search.js +379 -0
  82. package/scripts/published-cli.js +11 -1
  83. package/scripts/rag-document-pipeline.js +461 -0
  84. package/scripts/rag-structured-output.js +441 -0
  85. package/scripts/ragas-style-metrics.js +351 -0
  86. package/scripts/refresh-proof-pack.js +261 -0
  87. package/scripts/request-envelope.js +178 -0
  88. package/scripts/rerank-pipeline.js +370 -0
  89. package/scripts/rerank-quality-eval.js +155 -0
  90. package/scripts/retrieval-hybrid-ablation.js +120 -0
  91. package/scripts/retrieval-quality-tier.js +118 -0
  92. package/scripts/risk-scorer.js +144 -15
  93. package/scripts/secret-scanner.js +395 -4
  94. package/scripts/self-distill-agent.js +7 -1
  95. package/scripts/self-healing-check.js +25 -0
  96. package/scripts/skill-packs.js +183 -0
  97. package/scripts/slow-loop.js +72 -0
  98. package/scripts/statusline-links.js +1 -1
  99. package/scripts/statusline-local-stats.js +1 -1
  100. package/scripts/statusline.sh +8 -1
  101. package/scripts/telemetry-analytics.js +13 -1
  102. package/scripts/thumbgate-bench.js +13 -0
  103. package/scripts/thumbgate-search.js +98 -6
  104. package/scripts/tier-budget-guard.js +186 -0
  105. package/scripts/tool-kpi-tracker.js +124 -0
  106. package/scripts/tool-registry.js +95 -1
  107. package/scripts/vector-store.js +108 -4
  108. package/scripts/verify-marketing-pages-deployed.js +85 -3
  109. package/server.json +44 -0
  110. package/smithery.yaml +17 -0
  111. package/src/api/server.js +424 -99
@@ -3,20 +3,216 @@
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;
16
17
  const RERANK_CANDIDATE_POOL = 50; // bi-encoder retrieves this many; reranker picks topK
18
+ const MAX_RETRIEVAL_MEMORY_CHARS = 20000;
19
+
20
+ // Line cap for reading the memory log during retrieval.
21
+ //
22
+ // This was 200, which quietly made relevance irrelevant. Retrieval scores memories and keeps
23
+ // anything over 0.1, but it only ever SAW the newest 200 entries — so once 200 newer lessons
24
+ // existed, the single most relevant lesson in the corpus became unreachable no matter how well
25
+ // it matched. Measured on a synthetic corpus where the best-scoring lesson (0.183, threshold
26
+ // 0.1) is the oldest entry:
27
+ //
28
+ // corpus 150 -> found
29
+ // corpus 201 -> NOT found <- cliff, purely from recency
30
+ // corpus 2,000 -> NOT found
31
+ //
32
+ // A firewall that forgets its oldest lessons forgets the ones it learned the hard way.
33
+ //
34
+ // The cap exists for cost, so it is set from measurement rather than taste. Worst case (every
35
+ // entry scoring above threshold, so nothing filters out early):
36
+ //
37
+ // 200 entries 2.6 ms/call | 5,000 entries 2.6 ms/call | 20,000 entries 4.2 ms/call
38
+ //
39
+ // 5,000 therefore costs nothing measurable against the old 200 while covering realistic
40
+ // corpora with wide headroom. Override with THUMBGATE_RETRIEVAL_MAX_LINES if a machine ever
41
+ // grows past it.
42
+ const MAX_RETRIEVAL_MEMORY_LINES = Math.max(
43
+ 1,
44
+ Number(process.env.THUMBGATE_RETRIEVAL_MAX_LINES) || 5000,
45
+ );
46
+
47
+ function isRetrievableMemory(memory, options = {}) {
48
+ if (!memory || typeof memory !== 'object') return false;
49
+ const { looksLikeTransportBlob } = require('./feedback-sanitizer');
50
+ const title = String(memory.title || '');
51
+ const content = String(memory.content || '');
52
+ const combined = `${title}\n${content}`.trim();
53
+ const maxChars = Number.isFinite(options.maxMemoryChars)
54
+ ? Math.max(1, options.maxMemoryChars)
55
+ : MAX_RETRIEVAL_MEMORY_CHARS;
56
+ if (!combined || combined.length > maxChars) return false;
57
+ return !looksLikeTransportBlob(title)
58
+ && !looksLikeTransportBlob(content)
59
+ && !looksLikeTransportBlob(combined);
60
+ }
61
+
62
+ function selectRetrievalMemories(memories = [], options = {}) {
63
+ let selected = memories.filter((memory) => isRetrievableMemory(memory, options));
64
+ if (options.requireScope && !options.scope) {
65
+ throw new Error('Scoped lesson retrieval requires scope');
66
+ }
67
+ if (options.scope) {
68
+ const { selectRecordsForScope } = require('./memory-scope-readiness');
69
+ selected = selectRecordsForScope(selected, options.scope, {
70
+ includeShared: options.includeShared !== false,
71
+ }).allowed;
72
+ }
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
+ }
174
+ }
17
175
 
18
176
  function retrieveRelevantLessons(toolName, actionContext, options = {}) {
19
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
+
20
216
  const { getFeedbackPaths, readJSONL } = require('./feedback-loop');
21
217
  const { rerankLessons } = require('./lesson-reranker');
22
218
  const pathMod = require('path');
@@ -24,12 +220,15 @@ function retrieveRelevantLessons(toolName, actionContext, options = {}) {
24
220
  ? { MEMORY_LOG_PATH: pathMod.join(feedbackDir, 'memory-log.jsonl') }
25
221
  : getFeedbackPaths();
26
222
 
27
- const memories = readJSONL(paths.MEMORY_LOG_PATH, { maxLines: 200 });
223
+ const memories = selectRetrievalMemories(
224
+ readJSONL(paths.MEMORY_LOG_PATH, { maxLines: MAX_RETRIEVAL_MEMORY_LINES }),
225
+ options,
226
+ );
28
227
  if (memories.length === 0) return [];
29
228
 
30
229
  const actionSig = buildActionSignature(toolName, actionContext);
31
230
 
32
- // Stage 1 — bi-encoder: score all memories independently, take top-50 candidates
231
+ // Stage 1 — local first-stage score, take top-50 candidates
33
232
  const candidates = memories
34
233
  .map((mem) => ({
35
234
  ...mem,
@@ -41,7 +240,7 @@ function retrieveRelevantLessons(toolName, actionContext, options = {}) {
41
240
 
42
241
  if (candidates.length === 0) return [];
43
242
 
44
- // Stage 2 — cross-encoder reranker: rerank candidates by joint (query, lesson) score
243
+ // Stage 2 — field-aware BM25F reranker (not a neural cross-encoder)
45
244
  const reranked = rerankLessons(actionContext, candidates, {
46
245
  topK: maxResults,
47
246
  toolName,
@@ -52,7 +251,7 @@ function retrieveRelevantLessons(toolName, actionContext, options = {}) {
52
251
  const deduped = dedupeSupersededLessons(reranked);
53
252
  const selected = filterTopP(deduped, resolveTopP(options), { minKeep: options.minKeep });
54
253
 
55
- return selected.map((m) => ({
254
+ const shaped = selected.map((m) => ({
56
255
  id: m.id,
57
256
  title: m.title,
58
257
  content: m.content,
@@ -61,6 +260,28 @@ function retrieveRelevantLessons(toolName, actionContext, options = {}) {
61
260
  relevanceScore: m.rerankedScore ?? m.relevanceScore,
62
261
  timestamp: m.timestamp,
63
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;
64
285
  }
65
286
 
66
287
  /**
@@ -78,13 +299,16 @@ function retrieveRelevantLessons(toolName, actionContext, options = {}) {
78
299
  */
79
300
  function reciprocalRankFusion(rankedLists = [], options = {}) {
80
301
  const k = Number.isFinite(options.k) ? options.k : 60;
302
+ const weights = Array.isArray(options.weights) ? options.weights : [];
81
303
  const scores = new Map();
82
- for (const list of rankedLists) {
304
+ for (let listIndex = 0; listIndex < rankedLists.length; listIndex += 1) {
305
+ const list = rankedLists[listIndex];
83
306
  if (!Array.isArray(list)) continue;
307
+ const weight = Number(weights[listIndex]) || 1;
84
308
  list.forEach((id, index) => {
85
309
  if (id === undefined || id === null) return;
86
310
  const rank = index + 1;
87
- scores.set(id, (scores.get(id) || 0) + 1 / (k + rank));
311
+ scores.set(id, (scores.get(id) || 0) + weight / (k + rank));
88
312
  });
89
313
  }
90
314
  return [...scores.entries()]
@@ -92,17 +316,20 @@ function reciprocalRankFusion(rankedLists = [], options = {}) {
92
316
  .sort((a, b) => b.score - a.score);
93
317
  }
94
318
 
95
- function loadMemories(feedbackDir) {
319
+ function loadMemories(feedbackDir, options = {}) {
96
320
  const { getFeedbackPaths, readJSONL } = require('./feedback-loop');
97
321
  const pathMod = require('path');
98
322
  const paths = feedbackDir
99
323
  ? { MEMORY_LOG_PATH: pathMod.join(feedbackDir, 'memory-log.jsonl') }
100
324
  : getFeedbackPaths();
101
- return readJSONL(paths.MEMORY_LOG_PATH, { maxLines: 200 });
325
+ return selectRetrievalMemories(
326
+ readJSONL(paths.MEMORY_LOG_PATH, { maxLines: MAX_RETRIEVAL_MEMORY_LINES }),
327
+ options,
328
+ );
102
329
  }
103
330
 
104
- function shapeLesson(m) {
105
- return {
331
+ function shapeLesson(m, retrieval = null) {
332
+ const shaped = {
106
333
  id: m.id,
107
334
  title: m.title,
108
335
  content: m.content,
@@ -111,6 +338,33 @@ function shapeLesson(m) {
111
338
  relevanceScore: m.rerankedScore ?? m.relevanceScore,
112
339
  timestamp: m.timestamp,
113
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;
114
368
  }
115
369
 
116
370
  /**
@@ -118,7 +372,7 @@ function shapeLesson(m) {
118
372
  * retrieveRelevantLessons. Used by the async gate path (gates-engine runAsync).
119
373
  *
120
374
  * Pipeline: lexical ranking ⊕ dense (embedding) ranking → Reciprocal Rank Fusion
121
- * → cross-encoder rerank → top-K. Dense recall surfaces past mistakes that share
375
+ * → BM25F rerank → top-K. Dense recall surfaces past mistakes that share
122
376
  * no keywords with the action (paraphrase/synonym) — the value lexical alone misses.
123
377
  *
124
378
  * HONEST DEGRADATION: if no real embedder is available, or embedding errors, this
@@ -140,7 +394,7 @@ async function retrieveRelevantLessonsAsync(toolName, actionContext, options = {
140
394
  return retrieveRelevantLessons(toolName, actionContext, options);
141
395
  }
142
396
 
143
- const memories = loadMemories(feedbackDir);
397
+ const memories = loadMemories(feedbackDir, options);
144
398
  if (memories.length === 0) return [];
145
399
 
146
400
  const actionSig = buildActionSignature(toolName, actionContext);
@@ -151,7 +405,6 @@ async function retrieveRelevantLessonsAsync(toolName, actionContext, options = {
151
405
  .filter((m) => m.relevanceScore > 0.1)
152
406
  .sort((a, b) => b.relevanceScore - a.relevanceScore);
153
407
  const lexicalRanked = lexicalScored.slice(0, RERANK_CANDIDATE_POOL).map((m) => m.id);
154
-
155
408
  // Check if any lexical match is conclusive (exact/regex match on structured rule or high relevance)
156
409
  let conclusive = false;
157
410
  for (const candidate of lexicalScored) {
@@ -185,6 +438,17 @@ async function retrieveRelevantLessonsAsync(toolName, actionContext, options = {
185
438
  return filterTopP(dedupeSupersededLessons(reranked), resolveTopP(options), { minKeep: options.minKeep }).map(shapeLesson);
186
439
  }
187
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
+
188
452
  // WHERE-clause pruning: filter memories before vector search to only include
189
453
  // memories relevant to the current toolName or context.
190
454
  const prunedMemories = memories.filter((mem) => {
@@ -192,14 +456,18 @@ async function retrieveRelevantLessonsAsync(toolName, actionContext, options = {
192
456
  const score = scoreRelevance(mem, toolName, actionContext, actionSig);
193
457
  if (score > 0.1) return true;
194
458
 
195
- // 2. Otherwise, check tool compatibility: if toolsUsed is specified, it must contain our tool
196
- const memTools = mem.metadata?.toolsUsed || [];
197
- if (memTools.length > 0 && !memTools.some(t => t.toLowerCase() === toolName.toLowerCase())) {
198
- return false;
199
- }
200
- const ruleTools = mem.structuredRule?.metadata?.toolsUsed || [];
201
- if (ruleTools.length > 0 && !ruleTools.some(t => t.toLowerCase() === toolName.toLowerCase())) {
202
- 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
+ }
203
471
  }
204
472
  return true;
205
473
  });
@@ -208,17 +476,80 @@ async function retrieveRelevantLessonsAsync(toolName, actionContext, options = {
208
476
  let semanticRanked = [];
209
477
  if (prunedMemories.length > 0) {
210
478
  try {
211
- const dense = await embeddingIndex.semanticRank(actionContext, prunedMemories, {
212
- feedbackDir,
213
- embedder: options.embedder,
214
- });
215
- 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);
216
502
  } catch {
217
503
  // Embedding failed at runtime → fall back to pure lexical.
218
504
  return retrieveRelevantLessons(toolName, actionContext, options);
219
505
  }
220
506
  }
221
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
+
222
553
  // Fuse. Candidate pool is the union — dense can introduce lessons lexical missed.
223
554
  const fused = reciprocalRankFusion([lexicalRanked, semanticRanked]);
224
555
  if (fused.length === 0) return [];
@@ -232,7 +563,7 @@ async function retrieveRelevantLessonsAsync(toolName, actionContext, options = {
232
563
  .map((entry) => {
233
564
  const mem = byId.get(entry.id);
234
565
  if (!mem) return null;
235
- // Carry a relevanceScore the cross-encoder can blend against. Prefer the
566
+ // Carry a relevanceScore the BM25F reranker can blend against. Prefer the
236
567
  // lexical score when present; otherwise use the normalized fusion score so
237
568
  // dense-only candidates still rank sensibly.
238
569
  const relevanceScore = lexById.has(entry.id)
@@ -246,7 +577,11 @@ async function retrieveRelevantLessonsAsync(toolName, actionContext, options = {
246
577
 
247
578
  const { rerankLessons } = require('./lesson-reranker');
248
579
  const reranked = rerankLessons(actionContext, candidates, { topK: maxResults, toolName });
249
- 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
+ });
250
585
  }
251
586
 
252
587
  function buildActionSignature(toolName, actionContext) {
@@ -482,4 +817,12 @@ module.exports = {
482
817
  filterTopP,
483
818
  resolveTopP,
484
819
  dedupeSupersededLessons,
820
+ isRetrievableMemory,
821
+ selectRetrievalMemories,
822
+ matchesMetadataFilters,
823
+ buildQueryVariants,
824
+ buildQueryPlan,
825
+ attachArrayRetrievalMeta,
826
+ MAX_RETRIEVAL_MEMORY_CHARS,
827
+ MAX_RETRIEVAL_MEMORY_LINES,
485
828
  };