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
|
@@ -1,18 +1,41 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const
|
|
4
|
-
const
|
|
3
|
+
const crypto = require('node:crypto');
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const { spawn } = require('node:child_process');
|
|
5
7
|
const { getFeedbackPaths } = require('./feedback-loop');
|
|
6
8
|
const { ensureDir } = require('./fs-utils');
|
|
7
9
|
const { loadOptionalModule } = require('./private-core-boundary');
|
|
8
10
|
|
|
11
|
+
const RUNNER_SCRIPT_PATH = path.join(__dirname, 'async-job-runner.js');
|
|
12
|
+
|
|
13
|
+
function launchPublicManagedJob(jobSpec, options = {}) {
|
|
14
|
+
const publicRunner = require('./async-job-runner');
|
|
15
|
+
const jobId = options.jobId || jobSpec.id || `job_${Date.now()}_${crypto.randomBytes(4).toString('hex')}`;
|
|
16
|
+
const { jobDir } = publicRunner.getJobRuntimePaths(jobId);
|
|
17
|
+
ensureDir(jobDir);
|
|
18
|
+
const jobFilePath = path.join(jobDir, 'job.json');
|
|
19
|
+
const finalSpec = { ...jobSpec, id: jobId };
|
|
20
|
+
fs.writeFileSync(jobFilePath, `${JSON.stringify(finalSpec, null, 2)}\n`, 'utf8');
|
|
21
|
+
publicRunner.queueJob({ ...finalSpec, jobFilePath });
|
|
22
|
+
const child = spawn(process.execPath, [RUNNER_SCRIPT_PATH, `--run-file=${jobFilePath}`], {
|
|
23
|
+
cwd: options.cwd || process.cwd(),
|
|
24
|
+
env: process.env,
|
|
25
|
+
detached: true,
|
|
26
|
+
stdio: 'ignore',
|
|
27
|
+
});
|
|
28
|
+
child.unref();
|
|
29
|
+
return {
|
|
30
|
+
jobId,
|
|
31
|
+
jobFilePath,
|
|
32
|
+
launchMode: 'public-background',
|
|
33
|
+
pid: child.pid || null,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
9
37
|
const launcher = loadOptionalModule(path.join(__dirname, 'hosted-job-launcher'), () => ({
|
|
10
|
-
launchManagedJob:
|
|
11
|
-
throw new Error('Managed jobs require ThumbGate-Core.');
|
|
12
|
-
},
|
|
13
|
-
resumeHostedJob: () => {
|
|
14
|
-
throw new Error('Resuming hosted jobs requires ThumbGate-Core.');
|
|
15
|
-
},
|
|
38
|
+
launchManagedJob: launchPublicManagedJob,
|
|
16
39
|
}));
|
|
17
40
|
|
|
18
41
|
const runner = loadOptionalModule(path.join(__dirname, 'async-job-runner'), () => ({
|
|
@@ -20,8 +43,8 @@ const runner = loadOptionalModule(path.join(__dirname, 'async-job-runner'), () =
|
|
|
20
43
|
listJobStates: () => [],
|
|
21
44
|
}));
|
|
22
45
|
|
|
23
|
-
const { launchManagedJob
|
|
24
|
-
const { readJobState
|
|
46
|
+
const { launchManagedJob } = launcher;
|
|
47
|
+
const { readJobState } = runner;
|
|
25
48
|
|
|
26
49
|
const DEFAULT_CONCURRENCY = 3;
|
|
27
50
|
const POLL_INTERVAL_MS = 200;
|
|
@@ -45,7 +68,7 @@ function planWorkflow(objective) {
|
|
|
45
68
|
stages: [
|
|
46
69
|
{
|
|
47
70
|
name: 'secret_scan',
|
|
48
|
-
command: 'node scripts/secret-scanner.js --json
|
|
71
|
+
command: 'node scripts/secret-scanner.js --json',
|
|
49
72
|
}
|
|
50
73
|
]
|
|
51
74
|
});
|
|
@@ -55,7 +78,7 @@ function planWorkflow(objective) {
|
|
|
55
78
|
stages: [
|
|
56
79
|
{
|
|
57
80
|
name: 'npm_audit',
|
|
58
|
-
command: 'npm audit --json
|
|
81
|
+
command: 'npm audit --json',
|
|
59
82
|
}
|
|
60
83
|
]
|
|
61
84
|
});
|
|
@@ -65,7 +88,7 @@ function planWorkflow(objective) {
|
|
|
65
88
|
stages: [
|
|
66
89
|
{
|
|
67
90
|
name: 'credential_gate_check',
|
|
68
|
-
command: 'node scripts/single-use-credential-gate.js plan
|
|
91
|
+
command: 'node scripts/single-use-credential-gate.js plan',
|
|
69
92
|
}
|
|
70
93
|
]
|
|
71
94
|
});
|
|
@@ -76,7 +99,7 @@ function planWorkflow(objective) {
|
|
|
76
99
|
stages: [
|
|
77
100
|
{
|
|
78
101
|
name: 'run_bench',
|
|
79
|
-
command: 'npx thumbgate bench --json --min-score=90
|
|
102
|
+
command: 'npx thumbgate bench --json --min-score=90',
|
|
80
103
|
}
|
|
81
104
|
]
|
|
82
105
|
});
|
|
@@ -86,7 +109,7 @@ function planWorkflow(objective) {
|
|
|
86
109
|
stages: [
|
|
87
110
|
{
|
|
88
111
|
name: 'budget_status',
|
|
89
|
-
command: 'node scripts/budget-guard.js --status
|
|
112
|
+
command: 'node scripts/budget-guard.js --status',
|
|
90
113
|
}
|
|
91
114
|
]
|
|
92
115
|
});
|
|
@@ -98,7 +121,7 @@ function planWorkflow(objective) {
|
|
|
98
121
|
stages: [
|
|
99
122
|
{
|
|
100
123
|
name: 'search_fs',
|
|
101
|
-
command: 'node scripts/filesystem-search.js --query="pretool" --limit=5
|
|
124
|
+
command: 'node scripts/filesystem-search.js --query="pretool" --limit=5',
|
|
102
125
|
}
|
|
103
126
|
]
|
|
104
127
|
});
|
|
@@ -108,7 +131,7 @@ function planWorkflow(objective) {
|
|
|
108
131
|
stages: [
|
|
109
132
|
{
|
|
110
133
|
name: 'ops_integrity',
|
|
111
|
-
command: 'node scripts/operational-integrity.js --ci
|
|
134
|
+
command: 'node scripts/operational-integrity.js --ci',
|
|
112
135
|
}
|
|
113
136
|
]
|
|
114
137
|
});
|
|
@@ -119,7 +142,7 @@ function planWorkflow(objective) {
|
|
|
119
142
|
plannedAt: nowIso(),
|
|
120
143
|
subtasks: subtasks.map((task, idx) => ({
|
|
121
144
|
...task,
|
|
122
|
-
id: `subtask_${Date.now()}_${idx}_${
|
|
145
|
+
id: `subtask_${Date.now()}_${idx}_${crypto.randomBytes(3).toString('hex')}`,
|
|
123
146
|
autoImprove: false,
|
|
124
147
|
verificationMode: 'none',
|
|
125
148
|
recordFeedback: false,
|
|
@@ -132,12 +155,14 @@ function planWorkflow(objective) {
|
|
|
132
155
|
* Polls active jobs until all complete, then consolidates the results.
|
|
133
156
|
*/
|
|
134
157
|
async function executeWorkflow(objective, options = {}) {
|
|
135
|
-
const plan = planWorkflow(objective);
|
|
158
|
+
const plan = options.plan || planWorkflow(objective);
|
|
136
159
|
const concurrency = Number(options.concurrency) || DEFAULT_CONCURRENCY;
|
|
137
160
|
const timeoutMs = Number(options.timeoutMs) || 60000; // 60s timeout safety
|
|
161
|
+
const launchJob = options.launchManagedJob || launchManagedJob;
|
|
162
|
+
const getJobState = options.readJobState || readJobState;
|
|
138
163
|
|
|
139
164
|
const { FEEDBACK_DIR } = getFeedbackPaths();
|
|
140
|
-
const workflowId = `wf_${Date.now()}_${
|
|
165
|
+
const workflowId = `wf_${Date.now()}_${crypto.randomBytes(4).toString('hex')}`;
|
|
141
166
|
const workflowDir = path.join(FEEDBACK_DIR, 'workflows', workflowId);
|
|
142
167
|
ensureDir(workflowDir);
|
|
143
168
|
|
|
@@ -145,17 +170,33 @@ async function executeWorkflow(objective, options = {}) {
|
|
|
145
170
|
const queue = [...plan.subtasks];
|
|
146
171
|
const results = [];
|
|
147
172
|
const start = Date.now();
|
|
173
|
+
const statePath = path.join(workflowDir, 'state.json');
|
|
174
|
+
|
|
175
|
+
const persistState = (status) => {
|
|
176
|
+
fs.writeFileSync(statePath, `${JSON.stringify({
|
|
177
|
+
workflowId,
|
|
178
|
+
objective,
|
|
179
|
+
status,
|
|
180
|
+
updatedAt: nowIso(),
|
|
181
|
+
queue: queue.map((task) => ({ id: task.id, name: task.name })),
|
|
182
|
+
activeJobs: [...activeJobs.entries()].map(([taskId, info]) => ({ taskId, ...info })),
|
|
183
|
+
results,
|
|
184
|
+
}, null, 2)}\n`, 'utf8');
|
|
185
|
+
};
|
|
148
186
|
|
|
149
187
|
const runNext = () => {
|
|
150
188
|
while (activeJobs.size < concurrency && queue.length > 0) {
|
|
151
189
|
const task = queue.shift();
|
|
152
|
-
const launched =
|
|
190
|
+
const launched = launchJob(task, { cwd: options.cwd });
|
|
153
191
|
activeJobs.set(task.id, {
|
|
154
192
|
jobId: launched.jobId,
|
|
155
193
|
taskName: task.name,
|
|
156
194
|
launchedAt: Date.now(),
|
|
195
|
+
pid: launched.pid || null,
|
|
196
|
+
launchMode: launched.launchMode || 'managed',
|
|
157
197
|
});
|
|
158
198
|
}
|
|
199
|
+
persistState('running');
|
|
159
200
|
};
|
|
160
201
|
|
|
161
202
|
runNext();
|
|
@@ -166,7 +207,7 @@ async function executeWorkflow(objective, options = {}) {
|
|
|
166
207
|
let allDone = true;
|
|
167
208
|
|
|
168
209
|
for (const [taskId, info] of activeJobs.entries()) {
|
|
169
|
-
const jobState =
|
|
210
|
+
const jobState = getJobState(info.jobId);
|
|
170
211
|
if (!jobState) {
|
|
171
212
|
allDone = false;
|
|
172
213
|
continue;
|
|
@@ -193,11 +234,21 @@ async function executeWorkflow(objective, options = {}) {
|
|
|
193
234
|
const elapsed = Date.now() - start;
|
|
194
235
|
if (allDone && queue.length === 0) {
|
|
195
236
|
clearInterval(interval);
|
|
237
|
+
persistState(results.every((result) => result.status === 'completed')
|
|
238
|
+
? 'completed'
|
|
239
|
+
: 'completed_with_failures');
|
|
196
240
|
resolve();
|
|
197
241
|
} else if (elapsed >= timeoutMs) {
|
|
198
242
|
clearInterval(interval);
|
|
199
243
|
// Timeout remaining active tasks
|
|
200
244
|
for (const [taskId, info] of activeJobs.entries()) {
|
|
245
|
+
if (info.pid) {
|
|
246
|
+
try {
|
|
247
|
+
process.kill(process.platform === 'win32' ? info.pid : -info.pid, 'SIGTERM');
|
|
248
|
+
} catch {
|
|
249
|
+
// The worker may have exited between the last poll and timeout.
|
|
250
|
+
}
|
|
251
|
+
}
|
|
201
252
|
results.push({
|
|
202
253
|
taskId,
|
|
203
254
|
taskName: info.taskName,
|
|
@@ -206,6 +257,17 @@ async function executeWorkflow(objective, options = {}) {
|
|
|
206
257
|
lastError: { message: `Subtask timed out after ${timeoutMs}ms`, code: 'TIMEOUT' },
|
|
207
258
|
});
|
|
208
259
|
}
|
|
260
|
+
for (const task of queue.splice(0)) {
|
|
261
|
+
results.push({
|
|
262
|
+
taskId: task.id,
|
|
263
|
+
taskName: task.name,
|
|
264
|
+
jobId: null,
|
|
265
|
+
status: 'timeout',
|
|
266
|
+
lastError: { message: `Subtask was not launched before ${timeoutMs}ms`, code: 'TIMEOUT' },
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
activeJobs.clear();
|
|
270
|
+
persistState('timed_out');
|
|
209
271
|
resolve();
|
|
210
272
|
}
|
|
211
273
|
}, POLL_INTERVAL_MS);
|
|
@@ -234,6 +296,7 @@ async function executeWorkflow(objective, options = {}) {
|
|
|
234
296
|
durationMs,
|
|
235
297
|
reportPath,
|
|
236
298
|
results,
|
|
299
|
+
statePath,
|
|
237
300
|
};
|
|
238
301
|
}
|
|
239
302
|
|
|
@@ -290,4 +353,5 @@ module.exports = {
|
|
|
290
353
|
planWorkflow,
|
|
291
354
|
executeWorkflow,
|
|
292
355
|
compileWorkflowReport,
|
|
356
|
+
launchPublicManagedJob,
|
|
293
357
|
};
|
|
@@ -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
|
+
};
|
package/scripts/published-cli.js
CHANGED
|
@@ -13,6 +13,14 @@ function runtimePrefixDir(prefixDir) {
|
|
|
13
13
|
return prefixDir || path.join(os.homedir(), '.thumbgate', 'runtime');
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
// For GENERATED SHELL COMMANDS only. Shell command strings land in shared, committed config
|
|
17
|
+
// (.mcp.json entries, hook command lines), so expanding os.homedir() at generation time bakes
|
|
18
|
+
// the generating machine's home into files other machines execute — /Users/alice/.thumbgate
|
|
19
|
+
// fails with a permission error on bob's machine. shellQuote uses double quotes, so a literal
|
|
20
|
+
// $HOME expands at RUNTIME on whichever machine runs the command. Non-shell consumers
|
|
21
|
+
// (execFileSync paths) must keep using runtimePrefixDir, which returns a real filesystem path.
|
|
22
|
+
const SHELL_RUNTIME_PREFIX = '$HOME/.thumbgate/runtime';
|
|
23
|
+
|
|
16
24
|
function installedRuntimeBin(prefixDir) {
|
|
17
25
|
return path.join(runtimePrefixDir(prefixDir), 'node_modules', '.bin', 'thumbgate');
|
|
18
26
|
}
|
|
@@ -32,7 +40,9 @@ function publishedCliArgs(pkgVersion, commandArgs = [], options = {}) {
|
|
|
32
40
|
}
|
|
33
41
|
|
|
34
42
|
function publishedCliShellCommand(pkgVersion, commandArgs = [], options = {}) {
|
|
35
|
-
|
|
43
|
+
// Default to the runtime-expanded $HOME form; an explicit options.prefixDir (tests,
|
|
44
|
+
// throwaway prefixes) is honoured verbatim.
|
|
45
|
+
const prefixDir = options.prefixDir || SHELL_RUNTIME_PREFIX;
|
|
36
46
|
const runtimeBin = installedRuntimeBin(prefixDir);
|
|
37
47
|
const escapedArgs = commandArgs.map(shellQuote).join(' ');
|
|
38
48
|
const fastPath = `[ -x ${shellQuote(runtimeBin)} ] && exec ${shellQuote(runtimeBin)}${escapedArgs ? ` ${escapedArgs}` : ''}`;
|