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
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Pragmatic multi-stage hybrid retrieval (inspired by turbopuffer + Pragmatic Engineer playbook).
|
|
6
|
+
*
|
|
7
|
+
* We do NOT call turbopuffer SaaS. We steal the architecture patterns:
|
|
8
|
+
* 1) Multi-query first stage: lexical/BM25-ish list ⊕ dense list (when embedder exists)
|
|
9
|
+
* 2) Rank fusion (RRF) — ranks, not raw scores
|
|
10
|
+
* 3) Attribute-aware first-stage boosts (recency Decay, occurrence Saturate)
|
|
11
|
+
* 4) Field-weighted BM25 second-stage rerank
|
|
12
|
+
* 5) Diversification (limit-per domain/tool) so one theme does not monopolize top-K
|
|
13
|
+
* 6) Dual features on candidates (lexicalRank, denseRank, rrfScore, attributeBoost)
|
|
14
|
+
* 7) Continuous recall sampling hook for offline monitoring
|
|
15
|
+
*
|
|
16
|
+
* Application search logic stays here; indexes stay local (JSONL + optional LanceDB).
|
|
17
|
+
*
|
|
18
|
+
* @see https://turbopuffer.com/docs/hybrid
|
|
19
|
+
* @see https://turbopuffer.com/blog/rank-by-attribute
|
|
20
|
+
* @see https://turbopuffer.com/blog/continuous-recall
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const path = require('path');
|
|
24
|
+
const fs = require('fs');
|
|
25
|
+
const crypto = require('node:crypto');
|
|
26
|
+
|
|
27
|
+
const DEFAULT_RRF_K = 60;
|
|
28
|
+
const DEFAULT_POOL = 50;
|
|
29
|
+
const DEFAULT_TOP_K = 10;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Saturate(x) = x^e / (x^e + midpoint^e) ∈ [0, 1)
|
|
33
|
+
* Used for "higher is better" attributes (occurrence count, engagement).
|
|
34
|
+
*/
|
|
35
|
+
function saturate(value, midpoint = 3, exponent = 1) {
|
|
36
|
+
const x = Math.max(0, Number(value) || 0);
|
|
37
|
+
const m = Math.max(1e-9, Number(midpoint) || 1);
|
|
38
|
+
const e = Math.max(0.1, Number(exponent) || 1);
|
|
39
|
+
const xe = Math.pow(x, e);
|
|
40
|
+
const me = Math.pow(m, e);
|
|
41
|
+
return xe / (xe + me);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Decay(x) = midpoint^e / (x^e + midpoint^e) ∈ (0, 1]
|
|
46
|
+
* Used for distances (age in days). Recent → ~1, old → ~0.
|
|
47
|
+
*/
|
|
48
|
+
function decay(value, midpoint = 30, exponent = 1) {
|
|
49
|
+
const x = Math.max(0, Number(value) || 0);
|
|
50
|
+
const m = Math.max(1e-9, Number(midpoint) || 1);
|
|
51
|
+
const e = Math.max(0.1, Number(exponent) || 1);
|
|
52
|
+
const me = Math.pow(m, e);
|
|
53
|
+
return me / (Math.pow(x, e) + me);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function ageDays(timestamp) {
|
|
57
|
+
if (!timestamp) return null;
|
|
58
|
+
const t = new Date(timestamp).getTime();
|
|
59
|
+
if (!Number.isFinite(t)) return null;
|
|
60
|
+
return Math.max(0, (Date.now() - t) / (1000 * 60 * 60 * 24));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Attribute boost comparable in scale to a weak BM25 term (~0–1.5).
|
|
65
|
+
* turbopuffer: Sum(BM25, Product(w, Decay/Saturate(attr)))
|
|
66
|
+
*/
|
|
67
|
+
function attributeBoost(doc, options = {}) {
|
|
68
|
+
const recencyMidpointDays = options.recencyMidpointDays ?? 30;
|
|
69
|
+
const recencyWeight = options.recencyWeight ?? 0.35;
|
|
70
|
+
const occurrenceMidpoint = options.occurrenceMidpoint ?? 3;
|
|
71
|
+
const occurrenceWeight = options.occurrenceWeight ?? 0.25;
|
|
72
|
+
const negativeSignalWeight = options.negativeSignalWeight ?? 0.15;
|
|
73
|
+
|
|
74
|
+
let boost = 0;
|
|
75
|
+
const age = ageDays(doc.timestamp || doc.metadata?.timestamp);
|
|
76
|
+
if (age != null) {
|
|
77
|
+
boost += recencyWeight * decay(age, recencyMidpointDays, 1);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const occurrences = Number(
|
|
81
|
+
doc.metadata?.occurrences
|
|
82
|
+
?? doc.occurrences
|
|
83
|
+
?? doc.metadata?.count
|
|
84
|
+
?? 0,
|
|
85
|
+
);
|
|
86
|
+
if (occurrences > 0) {
|
|
87
|
+
boost += occurrenceWeight * saturate(occurrences, occurrenceMidpoint, 1);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const signal = String(doc.signal || '').toLowerCase();
|
|
91
|
+
if (signal === 'negative' || signal === 'down' || (doc.tags || []).includes('negative')) {
|
|
92
|
+
boost += negativeSignalWeight;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return boost;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* RRF over ranked id lists. Optional per-list weights (default 1).
|
|
100
|
+
* score = Σ weight_i / (k + rank_i)
|
|
101
|
+
*/
|
|
102
|
+
function reciprocalRankFusion(rankedLists, options = {}) {
|
|
103
|
+
const k = Math.max(1, Number(options.k) || DEFAULT_RRF_K);
|
|
104
|
+
const weights = options.weights || rankedLists.map(() => 1);
|
|
105
|
+
const scores = new Map();
|
|
106
|
+
|
|
107
|
+
rankedLists.forEach((list, listIndex) => {
|
|
108
|
+
const w = Number(weights[listIndex]) || 1;
|
|
109
|
+
const ids = (list || []).map((item) => (typeof item === 'string' ? item : item.id)).filter(Boolean);
|
|
110
|
+
ids.forEach((id, rank) => {
|
|
111
|
+
const add = w / (k + rank + 1);
|
|
112
|
+
scores.set(id, (scores.get(id) || 0) + add);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
return [...scores.entries()]
|
|
117
|
+
.map(([id, score]) => ({ id, score }))
|
|
118
|
+
.sort((a, b) => b.score - a.score);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Diversify top-K: at most `perLimit` docs share the same key (domain or primary tool).
|
|
123
|
+
* turbopuffer limit.per pattern.
|
|
124
|
+
*/
|
|
125
|
+
function diversifyByAttribute(rankedDocs, options = {}) {
|
|
126
|
+
const total = options.total || DEFAULT_TOP_K;
|
|
127
|
+
const perLimit = options.perLimit || 3;
|
|
128
|
+
const keyFn = options.keyFn || ((doc) => {
|
|
129
|
+
const domain = doc.metadata?.domain || doc.tags?.[0] || 'general';
|
|
130
|
+
const tool = (doc.metadata?.toolsUsed || [])[0] || 'any';
|
|
131
|
+
return `${domain}::${tool}`;
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const counts = new Map();
|
|
135
|
+
const out = [];
|
|
136
|
+
for (const doc of rankedDocs || []) {
|
|
137
|
+
const key = keyFn(doc);
|
|
138
|
+
const n = counts.get(key) || 0;
|
|
139
|
+
if (n >= perLimit) continue;
|
|
140
|
+
counts.set(key, n + 1);
|
|
141
|
+
out.push(doc);
|
|
142
|
+
if (out.length >= total) break;
|
|
143
|
+
}
|
|
144
|
+
// If diversification emptied the list too aggressively, pad with remainder
|
|
145
|
+
if (out.length < total) {
|
|
146
|
+
const seen = new Set(out.map((d) => d.id));
|
|
147
|
+
for (const doc of rankedDocs || []) {
|
|
148
|
+
if (seen.has(doc.id)) continue;
|
|
149
|
+
out.push(doc);
|
|
150
|
+
if (out.length >= total) break;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Build multi-query lists + fuse + attribute boost + BM25 rerank.
|
|
158
|
+
*
|
|
159
|
+
* @param {object} params
|
|
160
|
+
* @param {Array<object>} params.corpus - memory-shaped docs with id
|
|
161
|
+
* @param {string} params.query
|
|
162
|
+
* @param {string} [params.toolName]
|
|
163
|
+
* @param {object} [params.options]
|
|
164
|
+
*/
|
|
165
|
+
function pragmaticHybridSearch(params = {}) {
|
|
166
|
+
const {
|
|
167
|
+
corpus = [],
|
|
168
|
+
query = '',
|
|
169
|
+
toolName = 'Bash',
|
|
170
|
+
options = {},
|
|
171
|
+
} = params;
|
|
172
|
+
|
|
173
|
+
const {
|
|
174
|
+
scoreRelevance,
|
|
175
|
+
buildActionSignature,
|
|
176
|
+
reciprocalRankFusion: rrfFromLesson,
|
|
177
|
+
} = require('./lesson-retrieval');
|
|
178
|
+
const { rerankLessons } = require('./lesson-reranker');
|
|
179
|
+
|
|
180
|
+
const topK = options.topK || DEFAULT_TOP_K;
|
|
181
|
+
const pool = options.pool || DEFAULT_POOL;
|
|
182
|
+
const rrfK = options.rrfK || DEFAULT_RRF_K;
|
|
183
|
+
const diversify = options.diversify !== false;
|
|
184
|
+
const denseRankedIds = options.denseRankedIds || []; // precomputed dense order (optional)
|
|
185
|
+
const queryVariants = [...new Set(
|
|
186
|
+
[query, ...(options.queryVariants || [])]
|
|
187
|
+
.map((value) => String(value || '').trim())
|
|
188
|
+
.filter(Boolean),
|
|
189
|
+
)].slice(0, 4);
|
|
190
|
+
|
|
191
|
+
// --- Query 1: lexical / sparse (always) ---
|
|
192
|
+
// Attribute boost reorders candidates that already have lexical signal (or will
|
|
193
|
+
// enter via dense multi-query). It must NOT alone promote zero-overlap docs —
|
|
194
|
+
// that would break the lexical-vs-hybrid paraphrase contract and flood top-K
|
|
195
|
+
// with recent-but-unrelated mistakes (turbopuffer: attr is another clause, not
|
|
196
|
+
// a substitute for matching).
|
|
197
|
+
const denseIdSet = new Set((denseRankedIds || []).slice(0, pool));
|
|
198
|
+
const lexicalLists = [];
|
|
199
|
+
const bestLexicalById = new Map();
|
|
200
|
+
for (const variant of queryVariants) {
|
|
201
|
+
const actionSig = buildActionSignature(toolName, variant);
|
|
202
|
+
const scoredForVariant = corpus.map((mem) => {
|
|
203
|
+
const base = scoreRelevance(mem, toolName, variant, actionSig);
|
|
204
|
+
const attr = attributeBoost(mem, options.attribute);
|
|
205
|
+
const inDense = denseIdSet.has(mem.id);
|
|
206
|
+
const relevanceScore = (base > 0.1 || inDense) ? (base + attr) : 0;
|
|
207
|
+
return {
|
|
208
|
+
...mem,
|
|
209
|
+
lexicalScore: base,
|
|
210
|
+
attributeBoost: attr,
|
|
211
|
+
relevanceScore,
|
|
212
|
+
};
|
|
213
|
+
})
|
|
214
|
+
.filter((memory) => memory.relevanceScore > 0.05)
|
|
215
|
+
.sort((left, right) => right.relevanceScore - left.relevanceScore);
|
|
216
|
+
lexicalLists.push(scoredForVariant.slice(0, pool).map((memory) => memory.id));
|
|
217
|
+
for (const memory of scoredForVariant) {
|
|
218
|
+
const previous = bestLexicalById.get(memory.id);
|
|
219
|
+
if (!previous || memory.relevanceScore > previous.relevanceScore) {
|
|
220
|
+
bestLexicalById.set(memory.id, memory);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const lexicalScored = [...bestLexicalById.values()]
|
|
226
|
+
.sort((left, right) => right.relevanceScore - left.relevanceScore);
|
|
227
|
+
/*
|
|
228
|
+
* The best score carries candidate metadata; each query variant keeps its
|
|
229
|
+
* own ranked list for RRF so expansion cannot overwrite the original query.
|
|
230
|
+
*/
|
|
231
|
+
const lexicalRanked = lexicalLists[0] || [];
|
|
232
|
+
|
|
233
|
+
// --- Query 2: dense (optional; caller supplies ids when embedder ran) ---
|
|
234
|
+
const denseRanked = (denseRankedIds || []).slice(0, pool);
|
|
235
|
+
const denseWeight = Math.max(0.1, Number(options.denseWeight) || 1.5);
|
|
236
|
+
|
|
237
|
+
// --- Fuse multi-query ranks (RRF) ---
|
|
238
|
+
const lists = denseRanked.length > 0
|
|
239
|
+
? [...lexicalLists, denseRanked]
|
|
240
|
+
: lexicalLists;
|
|
241
|
+
const weights = denseRanked.length > 0
|
|
242
|
+
? [...lexicalLists.map(() => 1), denseWeight]
|
|
243
|
+
: lexicalLists.map(() => 1);
|
|
244
|
+
const fuse = typeof rrfFromLesson === 'function'
|
|
245
|
+
? rrfFromLesson(lists, { k: rrfK, weights })
|
|
246
|
+
: reciprocalRankFusion(lists, { k: rrfK, weights });
|
|
247
|
+
|
|
248
|
+
const byId = new Map(corpus.map((m) => [m.id, m]));
|
|
249
|
+
const lexMeta = new Map(lexicalScored.map((m, i) => [m.id, {
|
|
250
|
+
lexicalRank: i + 1,
|
|
251
|
+
lexicalScore: m.lexicalScore,
|
|
252
|
+
attributeBoost: m.attributeBoost,
|
|
253
|
+
relevanceScore: m.relevanceScore,
|
|
254
|
+
}]));
|
|
255
|
+
const denseRankMap = new Map(denseRanked.map((id, i) => [id, i + 1]));
|
|
256
|
+
|
|
257
|
+
const maxFusionScore = fuse[0]?.score || 1;
|
|
258
|
+
const candidates = fuse.slice(0, pool).map((entry) => {
|
|
259
|
+
const mem = byId.get(entry.id);
|
|
260
|
+
if (!mem) return null;
|
|
261
|
+
const meta = lexMeta.get(entry.id) || {};
|
|
262
|
+
return {
|
|
263
|
+
...mem,
|
|
264
|
+
relevanceScore: meta.relevanceScore ?? entry.score,
|
|
265
|
+
rrfScore: entry.score,
|
|
266
|
+
lexicalRank: meta.lexicalRank || null,
|
|
267
|
+
denseRank: denseRankMap.get(entry.id) || null,
|
|
268
|
+
fusionScoreNormalized: entry.score / maxFusionScore,
|
|
269
|
+
lexicalScore: meta.lexicalScore ?? 0,
|
|
270
|
+
attributeBoost: meta.attributeBoost ?? 0,
|
|
271
|
+
hybridFeatures: {
|
|
272
|
+
rrfScore: entry.score,
|
|
273
|
+
lexicalRank: meta.lexicalRank || null,
|
|
274
|
+
denseRank: denseRankMap.get(entry.id) || null,
|
|
275
|
+
attributeBoost: meta.attributeBoost ?? 0,
|
|
276
|
+
fusionScoreNormalized: entry.score / maxFusionScore,
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
}).filter(Boolean);
|
|
280
|
+
|
|
281
|
+
if (candidates.length === 0) {
|
|
282
|
+
return {
|
|
283
|
+
results: [],
|
|
284
|
+
meta: {
|
|
285
|
+
strategy: denseRanked.length ? 'hybrid-rrf' : 'lexical-attribute',
|
|
286
|
+
lexicalPool: lexicalRanked.length,
|
|
287
|
+
densePool: denseRanked.length,
|
|
288
|
+
fused: 0,
|
|
289
|
+
rerankApplied: false,
|
|
290
|
+
queryVariants,
|
|
291
|
+
},
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// --- Second stage: field-weighted BM25F rerank ---
|
|
296
|
+
let reranked = rerankLessons(query, candidates, { topK: pool, toolName });
|
|
297
|
+
|
|
298
|
+
// Light blend of attribute boost into reranked score (keeps recency after BM25)
|
|
299
|
+
reranked = reranked.map((doc) => {
|
|
300
|
+
const attr = doc.attributeBoost ?? attributeBoost(doc, options.attribute);
|
|
301
|
+
const base = doc.rerankedScore ?? doc.relevanceScore ?? 0;
|
|
302
|
+
const fusionWeight = denseRanked.length > 0
|
|
303
|
+
? Math.max(0, Math.min(1, Number(options.fusionWeight) || 0.7))
|
|
304
|
+
: 0;
|
|
305
|
+
return {
|
|
306
|
+
...doc,
|
|
307
|
+
attributeBoost: attr,
|
|
308
|
+
rerankedScore: (
|
|
309
|
+
(1 - fusionWeight) * base
|
|
310
|
+
+ fusionWeight * (doc.fusionScoreNormalized || 0)
|
|
311
|
+
+ 0.05 * attr
|
|
312
|
+
),
|
|
313
|
+
};
|
|
314
|
+
}).sort((a, b) => (b.rerankedScore || 0) - (a.rerankedScore || 0));
|
|
315
|
+
|
|
316
|
+
const diversified = diversify
|
|
317
|
+
? diversifyByAttribute(reranked, {
|
|
318
|
+
total: topK,
|
|
319
|
+
perLimit: options.perLimit || 3,
|
|
320
|
+
})
|
|
321
|
+
: reranked.slice(0, topK);
|
|
322
|
+
|
|
323
|
+
return {
|
|
324
|
+
results: diversified,
|
|
325
|
+
meta: {
|
|
326
|
+
strategy: denseRanked.length ? 'hybrid-rrf+attr+rerank' : 'lexical-attr+rerank',
|
|
327
|
+
lexicalPool: lexicalRanked.length,
|
|
328
|
+
densePool: denseRanked.length,
|
|
329
|
+
fused: fuse.length,
|
|
330
|
+
diversified: diversify,
|
|
331
|
+
rrfK,
|
|
332
|
+
denseWeight,
|
|
333
|
+
queryVariants,
|
|
334
|
+
rerankApplied: true,
|
|
335
|
+
},
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Continuous recall sampling (turbopuffer spirit): append a sample of retrieval
|
|
341
|
+
* outcomes for offline monitoring. Never throws; never blocks the hot path.
|
|
342
|
+
*/
|
|
343
|
+
function sampleRetrievalRecall(event, options = {}) {
|
|
344
|
+
try {
|
|
345
|
+
if (options.enabled === false) return { sampled: false };
|
|
346
|
+
const rate = Number(options.sampleRate ?? process.env.THUMBGATE_RETRIEVAL_RECALL_SAMPLE_RATE ?? 0.02);
|
|
347
|
+
// crypto PRNG — not security-critical sampling, but avoid Math.random for Sonar S2245
|
|
348
|
+
const roll = crypto.randomInt(0, 1_000_000) / 1_000_000;
|
|
349
|
+
if (!(rate > 0) || roll > rate) return { sampled: false };
|
|
350
|
+
|
|
351
|
+
const feedbackDir = options.feedbackDir
|
|
352
|
+
|| process.env.THUMBGATE_FEEDBACK_DIR
|
|
353
|
+
|| path.join(process.cwd(), '.thumbgate');
|
|
354
|
+
const outDir = path.join(feedbackDir, 'retrieval-recall-samples');
|
|
355
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
356
|
+
const line = JSON.stringify({
|
|
357
|
+
ts: new Date().toISOString(),
|
|
358
|
+
...event,
|
|
359
|
+
});
|
|
360
|
+
fs.appendFileSync(path.join(outDir, 'samples.jsonl'), `${line}\n`, 'utf8');
|
|
361
|
+
return { sampled: true };
|
|
362
|
+
} catch {
|
|
363
|
+
return { sampled: false, error: true };
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
module.exports = {
|
|
368
|
+
saturate,
|
|
369
|
+
decay,
|
|
370
|
+
ageDays,
|
|
371
|
+
attributeBoost,
|
|
372
|
+
reciprocalRankFusion,
|
|
373
|
+
diversifyByAttribute,
|
|
374
|
+
pragmaticHybridSearch,
|
|
375
|
+
sampleRetrievalRecall,
|
|
376
|
+
DEFAULT_RRF_K,
|
|
377
|
+
DEFAULT_POOL,
|
|
378
|
+
DEFAULT_TOP_K,
|
|
379
|
+
};
|
|
@@ -491,12 +491,19 @@ function normalizeProviderAction(input = {}) {
|
|
|
491
491
|
|
|
492
492
|
function normalizeBudget(input = {}) {
|
|
493
493
|
const budget = asObject(input);
|
|
494
|
+
const hasNumericAlias = (...keys) => keys.some((key) => (
|
|
495
|
+
Object.hasOwn(budget, key) && Number.isFinite(Number(budget[key]))
|
|
496
|
+
));
|
|
494
497
|
return {
|
|
495
498
|
maxTokensPerAction: firstNumber(budget.maxTokensPerAction, budget.perActionTokens, budget.tokenLimit),
|
|
496
499
|
remainingTokens: firstNumber(budget.remainingTokens, budget.tokensRemaining),
|
|
497
500
|
maxCostUsdPerAction: firstNumber(budget.maxCostUsdPerAction, budget.perActionCostUsd, budget.costLimitUsd),
|
|
498
501
|
remainingCostUsd: firstNumber(budget.remainingCostUsd, budget.costUsdRemaining),
|
|
499
502
|
maxParallelBranches: firstNumber(budget.maxParallelBranches, budget.parallelBranchLimit, DEFAULT_MAX_PARALLEL_BRANCHES),
|
|
503
|
+
hasMaxTokensPerAction: hasNumericAlias('maxTokensPerAction', 'perActionTokens', 'tokenLimit'),
|
|
504
|
+
hasRemainingTokens: hasNumericAlias('remainingTokens', 'tokensRemaining'),
|
|
505
|
+
hasMaxCostUsdPerAction: hasNumericAlias('maxCostUsdPerAction', 'perActionCostUsd', 'costLimitUsd'),
|
|
506
|
+
hasRemainingCostUsd: hasNumericAlias('remainingCostUsd', 'costUsdRemaining'),
|
|
500
507
|
};
|
|
501
508
|
}
|
|
502
509
|
|
|
@@ -540,16 +547,16 @@ function buildCostControl(normalizedAction = {}, budgetInput = {}) {
|
|
|
540
547
|
const totalTokens = firstNumber(usage.totalTokens);
|
|
541
548
|
const estimatedCostUsd = firstNumber(usage.estimatedCostUsd);
|
|
542
549
|
|
|
543
|
-
if (budget.
|
|
550
|
+
if (budget.hasMaxTokensPerAction && totalTokens > budget.maxTokensPerAction) {
|
|
544
551
|
reasons.push(`Token estimate ${totalTokens} exceeds per-action limit ${budget.maxTokensPerAction}.`);
|
|
545
552
|
}
|
|
546
|
-
if (budget.
|
|
553
|
+
if (budget.hasRemainingTokens && totalTokens > budget.remainingTokens) {
|
|
547
554
|
reasons.push(`Token estimate ${totalTokens} exceeds remaining budget ${budget.remainingTokens}.`);
|
|
548
555
|
}
|
|
549
|
-
if (budget.
|
|
556
|
+
if (budget.hasMaxCostUsdPerAction && estimatedCostUsd > budget.maxCostUsdPerAction) {
|
|
550
557
|
reasons.push(`Estimated cost $${estimatedCostUsd.toFixed(4)} exceeds per-action limit $${budget.maxCostUsdPerAction.toFixed(4)}.`);
|
|
551
558
|
}
|
|
552
|
-
if (budget.
|
|
559
|
+
if (budget.hasRemainingCostUsd && estimatedCostUsd > budget.remainingCostUsd) {
|
|
553
560
|
reasons.push(`Estimated cost $${estimatedCostUsd.toFixed(4)} exceeds remaining budget $${budget.remainingCostUsd.toFixed(4)}.`);
|
|
554
561
|
}
|
|
555
562
|
if (budget.maxParallelBranches > 0 && normalizedAction.workflow?.branchCount > budget.maxParallelBranches) {
|