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.
- package/.claude-plugin/plugin.json +1 -1
- package/.well-known/mcp/server-card.json +1 -1
- package/README.md +54 -16
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/forge/forge.yaml +3 -3
- package/adapters/mcp/server-stdio.js +105 -10
- package/adapters/opencode/opencode.json +1 -1
- package/bench/observability-eval-suite.json +2 -2
- package/bin/cli.js +168 -31
- package/config/evals/generation-quality-golden.json +95 -0
- package/config/evals/rag-answer-quality-golden.json +91 -0
- package/config/evals/retrieval-hybrid-ablation.json +66 -0
- package/config/evals/retrieval-ranking-golden.json +522 -0
- package/config/gates/claim-verifiers.example.json +42 -0
- package/config/gates/claim-verifiers.json +25 -0
- package/config/gates/default.json +217 -50
- package/config/mcp-allowlists.json +233 -206
- package/config/model-tiers.json +7 -2
- package/glama.json +6 -0
- package/hooks/hooks.json +1 -1
- package/package.json +69 -12
- package/public/assets/diagrams/before-after.svg +17 -16
- package/public/assets/diagrams/hero-thumbs.svg +68 -0
- package/public/assets/diagrams/loop.svg +19 -13
- package/public/assets/diagrams/self-improving-thumbs-loop.svg +105 -0
- package/public/compare.html +1 -0
- package/public/dashboard.html +126 -28
- package/public/evaluations.html +1 -1
- package/public/index.html +142 -13
- package/public/numbers.html +3 -2
- package/public/pricing.html +143 -30
- package/scripts/a-plus-evidence-scorecard.js +303 -0
- package/scripts/agent-readiness.js +110 -0
- package/scripts/async-eval-observability.js +36 -11
- package/scripts/audit-trail.js +37 -1
- package/scripts/auto-promote-gates.js +149 -34
- package/scripts/auto-wire-hooks.js +20 -8
- package/scripts/cli-schema.js +14 -0
- package/scripts/colbert-style-maxsim.js +236 -0
- package/scripts/cross-encoder-reranker.js +356 -126
- package/scripts/dashboard-chat.js +350 -17
- package/scripts/document-intake.js +283 -7
- package/scripts/eval-quality-suite.js +204 -0
- package/scripts/feedback-loop.js +115 -7
- package/scripts/feedback-paths.js +32 -13
- package/scripts/feedback-quality.js +53 -0
- package/scripts/feedback-schema.js +3 -0
- package/scripts/file-ledger-lock.js +130 -0
- package/scripts/filesystem-search.js +17 -7
- package/scripts/financial-control-plane.js +1514 -0
- package/scripts/gates-engine.js +202 -7
- package/scripts/gemini-embedding-policy.js +1 -0
- package/scripts/harness-tool-names.js +70 -0
- package/scripts/hook-runtime.js +15 -3
- package/scripts/hook-stop-anti-claim.js +63 -3
- package/scripts/human-escalation.js +353 -41
- package/scripts/lesson-db.js +16 -5
- package/scripts/lesson-embedding-index.js +67 -20
- package/scripts/lesson-embedding-maintenance.js +177 -0
- package/scripts/lesson-reranker.js +55 -9
- package/scripts/lesson-retrieval.js +305 -29
- package/scripts/lesson-search.js +22 -8
- package/scripts/llm-client.js +304 -15
- package/scripts/model-tier-router.js +593 -0
- package/scripts/pragmatic-hybrid-search.js +379 -0
- package/scripts/provider-action-normalizer.js +11 -4
- package/scripts/rag-document-pipeline.js +461 -0
- package/scripts/rag-structured-output.js +441 -0
- package/scripts/ragas-style-metrics.js +351 -0
- package/scripts/request-envelope.js +178 -0
- package/scripts/rerank-pipeline.js +370 -0
- package/scripts/rerank-quality-eval.js +155 -0
- package/scripts/retrieval-hybrid-ablation.js +120 -0
- package/scripts/retrieval-quality-tier.js +118 -0
- package/scripts/secret-scanner.js +395 -4
- package/scripts/self-distill-agent.js +7 -1
- package/scripts/self-healing-check.js +25 -0
- package/scripts/skill-packs.js +183 -0
- package/scripts/slow-loop.js +72 -0
- package/scripts/statusline-links.js +1 -1
- package/scripts/statusline.sh +8 -1
- package/scripts/telemetry-analytics.js +13 -1
- package/scripts/thumbgate-search.js +98 -6
- package/scripts/tier-budget-guard.js +186 -0
- package/scripts/tool-registry.js +141 -5
- package/scripts/universal-claim-evaluator.js +767 -0
- package/scripts/vector-store.js +154 -17
- package/scripts/verify-marketing-pages-deployed.js +85 -3
- package/scripts/workflow-sentinel.js +77 -11
- package/server.json +44 -0
- package/smithery.yaml +17 -0
- package/src/api/server.js +196 -13
|
@@ -3,13 +3,14 @@
|
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Per-action lesson retrieval.
|
|
6
|
-
* v3:
|
|
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 (
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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 —
|
|
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 —
|
|
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
|
-
|
|
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 (
|
|
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) +
|
|
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
|
-
|
|
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
|
-
* →
|
|
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.
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
|
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
|
-
|
|
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
|
};
|
package/scripts/lesson-search.js
CHANGED
|
@@ -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
|
|
414
|
-
+ substringBoost(queryText, lessonText)
|
|
415
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|