thumbgate 1.29.2 → 1.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.well-known/mcp/server-card.json +1 -1
  3. package/README.md +54 -16
  4. package/adapters/claude/.mcp.json +2 -2
  5. package/adapters/forge/forge.yaml +3 -3
  6. package/adapters/mcp/server-stdio.js +143 -14
  7. package/adapters/opencode/opencode.json +1 -1
  8. package/bench/observability-eval-suite.json +2 -2
  9. package/bin/cli.js +154 -36
  10. package/config/evals/generation-quality-golden.json +95 -0
  11. package/config/evals/rag-answer-quality-golden.json +91 -0
  12. package/config/evals/retrieval-hybrid-ablation.json +66 -0
  13. package/config/evals/retrieval-ranking-golden.json +522 -0
  14. package/config/gates/default.json +217 -50
  15. package/config/mcp-allowlists.json +215 -185
  16. package/config/model-tiers.json +7 -2
  17. package/config/post-deploy-marketing-pages.json +26 -1
  18. package/glama.json +6 -0
  19. package/package.json +94 -11
  20. package/public/architecture.html +130 -0
  21. package/public/assets/diagrams/agent-integration.png +0 -0
  22. package/public/assets/diagrams/before-after.svg +22 -0
  23. package/public/assets/diagrams/decision.svg +36 -0
  24. package/public/assets/diagrams/feedback-pipeline.png +0 -0
  25. package/public/assets/diagrams/hero-thumbs.svg +68 -0
  26. package/public/assets/diagrams/loop.svg +40 -0
  27. package/public/assets/diagrams/plugin-topology.png +0 -0
  28. package/public/assets/diagrams/pre-action-gate-loop.svg +59 -0
  29. package/public/assets/diagrams/self-improving-thumbs-loop.svg +105 -0
  30. package/public/assets/diagrams/stack.svg +18 -0
  31. package/public/assets/diagrams/thumbgate-architecture.png +0 -0
  32. package/public/case-studies.html +151 -0
  33. package/public/compare.html +1 -0
  34. package/public/dashboard.html +126 -28
  35. package/public/eval-scorecard.html +195 -0
  36. package/public/eval-scorecard.json +18 -0
  37. package/public/evaluations.html +168 -0
  38. package/public/index.html +143 -13
  39. package/public/numbers.html +3 -2
  40. package/public/pricing.html +143 -30
  41. package/public/whitepaper.html +189 -0
  42. package/scripts/a-plus-evidence-scorecard.js +303 -0
  43. package/scripts/activation-quickstart.js +1 -0
  44. package/scripts/agent-outcome-monitor.js +71 -1
  45. package/scripts/async-eval-observability.js +36 -11
  46. package/scripts/audit-trail.js +37 -1
  47. package/scripts/auto-promote-gates.js +149 -34
  48. package/scripts/billing.js +3 -1
  49. package/scripts/claude-feedback-sync.js +3 -2
  50. package/scripts/cli-feedback.js +13 -7
  51. package/scripts/colbert-style-maxsim.js +236 -0
  52. package/scripts/cross-encoder-reranker.js +359 -126
  53. package/scripts/dashboard-chat.js +350 -17
  54. package/scripts/document-intake.js +283 -7
  55. package/scripts/eval-quality-suite.js +204 -0
  56. package/scripts/feedback-aggregate.js +5 -2
  57. package/scripts/feedback-loop.js +359 -189
  58. package/scripts/feedback-paths.js +32 -13
  59. package/scripts/feedback-quality.js +53 -0
  60. package/scripts/filesystem-search.js +17 -7
  61. package/scripts/gates-engine.js +98 -4
  62. package/scripts/generate-case-study-outreach.js +253 -0
  63. package/scripts/generate-eval-scorecard.js +276 -0
  64. package/scripts/growth-campaigns.js +183 -0
  65. package/scripts/harness-tool-names.js +70 -0
  66. package/scripts/hook-runtime.js +10 -3
  67. package/scripts/jsonl-watcher.js +1 -0
  68. package/scripts/lesson-db.js +16 -5
  69. package/scripts/lesson-embedding-index.js +67 -20
  70. package/scripts/lesson-embedding-maintenance.js +177 -0
  71. package/scripts/lesson-inference.js +23 -4
  72. package/scripts/lesson-reranker.js +55 -9
  73. package/scripts/lesson-retrieval.js +375 -32
  74. package/scripts/lesson-search.js +48 -11
  75. package/scripts/llm-client.js +304 -15
  76. package/scripts/mcp-config.js +26 -5
  77. package/scripts/mcp-oauth.js +37 -2
  78. package/scripts/model-eval.js +308 -0
  79. package/scripts/model-tier-router.js +593 -0
  80. package/scripts/parallel-workflow-orchestrator.js +86 -22
  81. package/scripts/pragmatic-hybrid-search.js +379 -0
  82. package/scripts/published-cli.js +11 -1
  83. package/scripts/rag-document-pipeline.js +461 -0
  84. package/scripts/rag-structured-output.js +441 -0
  85. package/scripts/ragas-style-metrics.js +351 -0
  86. package/scripts/refresh-proof-pack.js +261 -0
  87. package/scripts/request-envelope.js +178 -0
  88. package/scripts/rerank-pipeline.js +370 -0
  89. package/scripts/rerank-quality-eval.js +155 -0
  90. package/scripts/retrieval-hybrid-ablation.js +120 -0
  91. package/scripts/retrieval-quality-tier.js +118 -0
  92. package/scripts/risk-scorer.js +144 -15
  93. package/scripts/secret-scanner.js +395 -4
  94. package/scripts/self-distill-agent.js +7 -1
  95. package/scripts/self-healing-check.js +25 -0
  96. package/scripts/skill-packs.js +183 -0
  97. package/scripts/slow-loop.js +72 -0
  98. package/scripts/statusline-links.js +1 -1
  99. package/scripts/statusline-local-stats.js +1 -1
  100. package/scripts/statusline.sh +8 -1
  101. package/scripts/telemetry-analytics.js +13 -1
  102. package/scripts/thumbgate-bench.js +13 -0
  103. package/scripts/thumbgate-search.js +98 -6
  104. package/scripts/tier-budget-guard.js +186 -0
  105. package/scripts/tool-kpi-tracker.js +124 -0
  106. package/scripts/tool-registry.js +95 -1
  107. package/scripts/vector-store.js +108 -4
  108. package/scripts/verify-marketing-pages-deployed.js +85 -3
  109. package/server.json +44 -0
  110. package/smithery.yaml +17 -0
  111. package/src/api/server.js +424 -99
@@ -1,18 +1,41 @@
1
1
  'use strict';
2
2
 
3
- const fs = require('fs');
4
- const path = require('path');
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, resumeHostedJob } = launcher;
24
- const { readJobState, listJobStates } = runner;
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 || true',
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 || true',
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 || true',
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 || true',
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 || true',
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 || true',
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 || true',
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}_${Math.random().toString(36).slice(2, 6)}`,
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()}_${Math.random().toString(36).slice(2, 8)}`;
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 = launchManagedJob(task, { cwd: options.cwd });
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 = readJobState(info.jobId);
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
+ };
@@ -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
- const prefixDir = runtimePrefixDir(options.prefixDir);
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}` : ''}`;