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.
- 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 +143 -14
- package/adapters/opencode/opencode.json +1 -1
- package/bench/observability-eval-suite.json +2 -2
- package/bin/cli.js +154 -36
- 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/default.json +217 -50
- package/config/mcp-allowlists.json +215 -185
- package/config/model-tiers.json +7 -2
- package/config/post-deploy-marketing-pages.json +26 -1
- package/glama.json +6 -0
- package/package.json +94 -11
- package/public/architecture.html +130 -0
- package/public/assets/diagrams/agent-integration.png +0 -0
- package/public/assets/diagrams/before-after.svg +22 -0
- package/public/assets/diagrams/decision.svg +36 -0
- package/public/assets/diagrams/feedback-pipeline.png +0 -0
- package/public/assets/diagrams/hero-thumbs.svg +68 -0
- package/public/assets/diagrams/loop.svg +40 -0
- package/public/assets/diagrams/plugin-topology.png +0 -0
- package/public/assets/diagrams/pre-action-gate-loop.svg +59 -0
- package/public/assets/diagrams/self-improving-thumbs-loop.svg +105 -0
- package/public/assets/diagrams/stack.svg +18 -0
- package/public/assets/diagrams/thumbgate-architecture.png +0 -0
- package/public/case-studies.html +151 -0
- package/public/compare.html +1 -0
- package/public/dashboard.html +126 -28
- package/public/eval-scorecard.html +195 -0
- package/public/eval-scorecard.json +18 -0
- package/public/evaluations.html +168 -0
- package/public/index.html +143 -13
- package/public/numbers.html +3 -2
- package/public/pricing.html +143 -30
- package/public/whitepaper.html +189 -0
- package/scripts/a-plus-evidence-scorecard.js +303 -0
- package/scripts/activation-quickstart.js +1 -0
- package/scripts/agent-outcome-monitor.js +71 -1
- 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/billing.js +3 -1
- package/scripts/claude-feedback-sync.js +3 -2
- package/scripts/cli-feedback.js +13 -7
- package/scripts/colbert-style-maxsim.js +236 -0
- package/scripts/cross-encoder-reranker.js +359 -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-aggregate.js +5 -2
- package/scripts/feedback-loop.js +359 -189
- package/scripts/feedback-paths.js +32 -13
- package/scripts/feedback-quality.js +53 -0
- package/scripts/filesystem-search.js +17 -7
- package/scripts/gates-engine.js +98 -4
- package/scripts/generate-case-study-outreach.js +253 -0
- package/scripts/generate-eval-scorecard.js +276 -0
- package/scripts/growth-campaigns.js +183 -0
- package/scripts/harness-tool-names.js +70 -0
- package/scripts/hook-runtime.js +10 -3
- package/scripts/jsonl-watcher.js +1 -0
- 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-inference.js +23 -4
- package/scripts/lesson-reranker.js +55 -9
- package/scripts/lesson-retrieval.js +375 -32
- package/scripts/lesson-search.js +48 -11
- package/scripts/llm-client.js +304 -15
- package/scripts/mcp-config.js +26 -5
- package/scripts/mcp-oauth.js +37 -2
- package/scripts/model-eval.js +308 -0
- package/scripts/model-tier-router.js +593 -0
- package/scripts/parallel-workflow-orchestrator.js +86 -22
- package/scripts/pragmatic-hybrid-search.js +379 -0
- package/scripts/published-cli.js +11 -1
- 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/refresh-proof-pack.js +261 -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/risk-scorer.js +144 -15
- 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-local-stats.js +1 -1
- package/scripts/statusline.sh +8 -1
- package/scripts/telemetry-analytics.js +13 -1
- package/scripts/thumbgate-bench.js +13 -0
- package/scripts/thumbgate-search.js +98 -6
- package/scripts/tier-budget-guard.js +186 -0
- package/scripts/tool-kpi-tracker.js +124 -0
- package/scripts/tool-registry.js +95 -1
- package/scripts/vector-store.js +108 -4
- package/scripts/verify-marketing-pages-deployed.js +85 -3
- package/server.json +44 -0
- package/smithery.yaml +17 -0
- package/src/api/server.js +424 -99
|
@@ -3,20 +3,216 @@
|
|
|
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;
|
|
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 =
|
|
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 —
|
|
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 —
|
|
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
|
-
|
|
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 (
|
|
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) +
|
|
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
|
|
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
|
-
|
|
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
|
-
* →
|
|
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.
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
|
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
|
-
|
|
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
|
};
|