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.
Files changed (92) 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 +105 -10
  7. package/adapters/opencode/opencode.json +1 -1
  8. package/bench/observability-eval-suite.json +2 -2
  9. package/bin/cli.js +168 -31
  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/claim-verifiers.example.json +42 -0
  15. package/config/gates/claim-verifiers.json +25 -0
  16. package/config/gates/default.json +217 -50
  17. package/config/mcp-allowlists.json +233 -206
  18. package/config/model-tiers.json +7 -2
  19. package/glama.json +6 -0
  20. package/hooks/hooks.json +1 -1
  21. package/package.json +69 -12
  22. package/public/assets/diagrams/before-after.svg +17 -16
  23. package/public/assets/diagrams/hero-thumbs.svg +68 -0
  24. package/public/assets/diagrams/loop.svg +19 -13
  25. package/public/assets/diagrams/self-improving-thumbs-loop.svg +105 -0
  26. package/public/compare.html +1 -0
  27. package/public/dashboard.html +126 -28
  28. package/public/evaluations.html +1 -1
  29. package/public/index.html +142 -13
  30. package/public/numbers.html +3 -2
  31. package/public/pricing.html +143 -30
  32. package/scripts/a-plus-evidence-scorecard.js +303 -0
  33. package/scripts/agent-readiness.js +110 -0
  34. package/scripts/async-eval-observability.js +36 -11
  35. package/scripts/audit-trail.js +37 -1
  36. package/scripts/auto-promote-gates.js +149 -34
  37. package/scripts/auto-wire-hooks.js +20 -8
  38. package/scripts/cli-schema.js +14 -0
  39. package/scripts/colbert-style-maxsim.js +236 -0
  40. package/scripts/cross-encoder-reranker.js +356 -126
  41. package/scripts/dashboard-chat.js +350 -17
  42. package/scripts/document-intake.js +283 -7
  43. package/scripts/eval-quality-suite.js +204 -0
  44. package/scripts/feedback-loop.js +115 -7
  45. package/scripts/feedback-paths.js +32 -13
  46. package/scripts/feedback-quality.js +53 -0
  47. package/scripts/feedback-schema.js +3 -0
  48. package/scripts/file-ledger-lock.js +130 -0
  49. package/scripts/filesystem-search.js +17 -7
  50. package/scripts/financial-control-plane.js +1514 -0
  51. package/scripts/gates-engine.js +202 -7
  52. package/scripts/gemini-embedding-policy.js +1 -0
  53. package/scripts/harness-tool-names.js +70 -0
  54. package/scripts/hook-runtime.js +15 -3
  55. package/scripts/hook-stop-anti-claim.js +63 -3
  56. package/scripts/human-escalation.js +353 -41
  57. package/scripts/lesson-db.js +16 -5
  58. package/scripts/lesson-embedding-index.js +67 -20
  59. package/scripts/lesson-embedding-maintenance.js +177 -0
  60. package/scripts/lesson-reranker.js +55 -9
  61. package/scripts/lesson-retrieval.js +305 -29
  62. package/scripts/lesson-search.js +22 -8
  63. package/scripts/llm-client.js +304 -15
  64. package/scripts/model-tier-router.js +593 -0
  65. package/scripts/pragmatic-hybrid-search.js +379 -0
  66. package/scripts/provider-action-normalizer.js +11 -4
  67. package/scripts/rag-document-pipeline.js +461 -0
  68. package/scripts/rag-structured-output.js +441 -0
  69. package/scripts/ragas-style-metrics.js +351 -0
  70. package/scripts/request-envelope.js +178 -0
  71. package/scripts/rerank-pipeline.js +370 -0
  72. package/scripts/rerank-quality-eval.js +155 -0
  73. package/scripts/retrieval-hybrid-ablation.js +120 -0
  74. package/scripts/retrieval-quality-tier.js +118 -0
  75. package/scripts/secret-scanner.js +395 -4
  76. package/scripts/self-distill-agent.js +7 -1
  77. package/scripts/self-healing-check.js +25 -0
  78. package/scripts/skill-packs.js +183 -0
  79. package/scripts/slow-loop.js +72 -0
  80. package/scripts/statusline-links.js +1 -1
  81. package/scripts/statusline.sh +8 -1
  82. package/scripts/telemetry-analytics.js +13 -1
  83. package/scripts/thumbgate-search.js +98 -6
  84. package/scripts/tier-budget-guard.js +186 -0
  85. package/scripts/tool-registry.js +141 -5
  86. package/scripts/universal-claim-evaluator.js +767 -0
  87. package/scripts/vector-store.js +154 -17
  88. package/scripts/verify-marketing-pages-deployed.js +85 -3
  89. package/scripts/workflow-sentinel.js +77 -11
  90. package/server.json +44 -0
  91. package/smithery.yaml +17 -0
  92. package/src/api/server.js +196 -13
@@ -0,0 +1,177 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('node:fs');
5
+ const path = require('node:path');
6
+ const {
7
+ semanticRank,
8
+ isEmbedderAvailable,
9
+ lessonText,
10
+ hashText,
11
+ readCache,
12
+ } = require('./lesson-embedding-index');
13
+ const {
14
+ selectRetrievalMemories,
15
+ MAX_RETRIEVAL_MEMORY_LINES,
16
+ } = require('./lesson-retrieval');
17
+
18
+ function loadLessonCorpus(feedbackDir) {
19
+ const { readJSONL, getFeedbackPaths } = require('./feedback-loop');
20
+ const memoryPath = feedbackDir
21
+ ? path.join(feedbackDir, 'memory-log.jsonl')
22
+ : getFeedbackPaths().MEMORY_LOG_PATH;
23
+ const lessons = selectRetrievalMemories(
24
+ readJSONL(memoryPath, { maxLines: MAX_RETRIEVAL_MEMORY_LINES }),
25
+ );
26
+ return { lessons, memoryPath };
27
+ }
28
+
29
+ function evaluateEmbeddingIndexDrift(options = {}) {
30
+ const feedbackDir = options.feedbackDir || process.env.THUMBGATE_FEEDBACK_DIR;
31
+ const { lessons, memoryPath } = loadLessonCorpus(feedbackDir);
32
+ const cachePath = path.join(
33
+ feedbackDir || path.dirname(memoryPath),
34
+ options.cacheFile || 'lesson-embeddings.json',
35
+ );
36
+ const cache = readCache(cachePath);
37
+ let indexedCount = 0;
38
+ const missingIds = [];
39
+ const staleIds = [];
40
+ const providers = {};
41
+ const dimensions = {};
42
+ const corpusIds = new Set(lessons.map((lesson) => lesson.id));
43
+
44
+ for (const lesson of lessons) {
45
+ const entry = cache[lesson.id];
46
+ const valid = entry
47
+ && entry.hash === hashText(lessonText(lesson))
48
+ && Array.isArray(entry.vector)
49
+ && entry.vector.length > 0
50
+ && entry.dimension === entry.vector.length
51
+ && typeof entry.provider === 'string';
52
+ if (valid) {
53
+ indexedCount += 1;
54
+ providers[entry.provider] = (providers[entry.provider] || 0) + 1;
55
+ dimensions[entry.dimension] = (dimensions[entry.dimension] || 0) + 1;
56
+ } else if (entry) {
57
+ staleIds.push(lesson.id);
58
+ } else {
59
+ missingIds.push(lesson.id);
60
+ }
61
+ }
62
+
63
+ const orphanedIds = Object.keys(cache)
64
+ .filter((id) => !corpusIds.has(id))
65
+ .sort((left, right) => left.localeCompare(right));
66
+ missingIds.sort((left, right) => left.localeCompare(right));
67
+ staleIds.sort((left, right) => left.localeCompare(right));
68
+
69
+ const corpusCount = lessons.length;
70
+ const coverage = corpusCount ? indexedCount / corpusCount : 1;
71
+ const semanticProviderAvailable = options.embedder
72
+ ? true
73
+ : isEmbedderAvailable();
74
+ const minCoverage = Math.max(0, Math.min(1, Number(options.minCoverage) || 0.95));
75
+ const requireExact = options.requireExact !== false;
76
+ const enabled = semanticProviderAvailable || Object.keys(cache).length > 0;
77
+ const exactIdSet = missingIds.length === 0
78
+ && staleIds.length === 0
79
+ && orphanedIds.length === 0;
80
+ const status = !enabled
81
+ ? 'not_configured'
82
+ : coverage >= minCoverage
83
+ && staleIds.length === 0
84
+ && orphanedIds.length === 0
85
+ && (!requireExact || exactIdSet)
86
+ ? 'healthy'
87
+ : 'unhealthy';
88
+ return {
89
+ ok: status !== 'unhealthy',
90
+ status,
91
+ semanticProviderAvailable,
92
+ corpusCount,
93
+ indexedCount,
94
+ missingCount: missingIds.length,
95
+ missingIds,
96
+ staleCount: staleIds.length,
97
+ staleIds,
98
+ orphanedCount: orphanedIds.length,
99
+ orphanedIds,
100
+ exactIdSet,
101
+ requireExact,
102
+ coverage: Number(coverage.toFixed(4)),
103
+ minCoverage,
104
+ providers,
105
+ dimensions,
106
+ memoryPath,
107
+ cachePath,
108
+ };
109
+ }
110
+
111
+ async function backfillLessonEmbeddings(options = {}) {
112
+ const feedbackDir = options.feedbackDir || process.env.THUMBGATE_FEEDBACK_DIR;
113
+ if (!options.embedder && !isEmbedderAvailable()) {
114
+ const error = new Error(
115
+ 'No semantic embedding provider configured. Set THUMBGATE_OLLAMA_EMBED_MODEL or configure another real provider.',
116
+ );
117
+ error.code = 'THUMBGATE_SEMANTIC_PROVIDER_UNAVAILABLE';
118
+ throw error;
119
+ }
120
+ const { lessons } = loadLessonCorpus(feedbackDir);
121
+ if (lessons.length > 0) {
122
+ await semanticRank('ThumbGate lesson embedding backfill health query', lessons, {
123
+ feedbackDir,
124
+ embedder: options.embedder,
125
+ embedderId: options.embedderId,
126
+ cacheFile: options.cacheFile,
127
+ });
128
+ }
129
+ return evaluateEmbeddingIndexDrift({
130
+ ...options,
131
+ feedbackDir,
132
+ embedder: options.embedder,
133
+ });
134
+ }
135
+
136
+ function parseArgs(argv) {
137
+ const args = { json: false, backfill: false, requireSemantic: false };
138
+ for (let index = 0; index < argv.length; index += 1) {
139
+ const arg = argv[index];
140
+ if (arg === '--json') args.json = true;
141
+ else if (arg === '--backfill') args.backfill = true;
142
+ else if (arg === '--require-semantic') args.requireSemantic = true;
143
+ else if (arg === '--feedback-dir') args.feedbackDir = argv[++index];
144
+ else if (arg === '--min-coverage') args.minCoverage = Number(argv[++index]);
145
+ }
146
+ return args;
147
+ }
148
+
149
+ async function runCli() {
150
+ const options = parseArgs(process.argv.slice(2));
151
+ let report;
152
+ try {
153
+ report = options.backfill
154
+ ? await backfillLessonEmbeddings(options)
155
+ : evaluateEmbeddingIndexDrift(options);
156
+ } catch (error) {
157
+ report = { ok: false, status: 'error', error: error.message, code: error.code || 'ERROR' };
158
+ }
159
+ const output = options.json
160
+ ? JSON.stringify(report, null, 2)
161
+ : `Lesson embeddings: ${report.status}; ${report.indexedCount || 0}/${report.corpusCount || 0} indexed; coverage=${report.coverage || 0}`;
162
+ process.stdout.write(`${output}\n`);
163
+ if (!report.ok || (options.requireSemantic && !report.semanticProviderAvailable)) {
164
+ process.exitCode = 1;
165
+ }
166
+ }
167
+
168
+ if (!module.parent) {
169
+ runCli();
170
+ }
171
+
172
+ module.exports = {
173
+ loadLessonCorpus,
174
+ evaluateEmbeddingIndexDrift,
175
+ backfillLessonEmbeddings,
176
+ parseArgs,
177
+ };
@@ -1,11 +1,11 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * Cross-encoder reranker for lesson retrieval.
4
+ * Field-aware BM25F reranker for lesson retrieval.
5
5
  *
6
- * Unlike the bi-encoders already in use (Jaccard + bigram Jaccard), a
7
- * cross-encoder processes the (query, lesson) pair jointly so it can
8
- * catch relevance signals that independent scoring misses:
6
+ * This is a deterministic lexical second stage, not a neural cross-encoder.
7
+ * It scores query terms against lesson fields jointly and catches relevance
8
+ * signals that the first-stage overlap score can miss:
9
9
  *
10
10
  * - Field-weighted BM25: a query term in `whatWentWrong` is worth more
11
11
  * than the same term in `tags`
@@ -55,6 +55,11 @@ const SYNONYM_GROUPS = [
55
55
  ['auth', 'authentication', 'authorization', 'token', 'api key', 'credential'],
56
56
  ['delete', 'remove', 'rm', 'drop', 'destroy', 'wipe'],
57
57
  ['merge', 'pull request', 'pr', 'rebase', 'squash'],
58
+ ['tablet', 'ipad', 'ios device'],
59
+ ['overlay', 'tailscale', 'mesh network', 'vpn'],
60
+ ['laptop', 'macbook', 'mac'],
61
+ ['phone', 'mobile', 'android', 'device'],
62
+ ['conversation', 'session', 'thread', 'chat'],
58
63
  ];
59
64
 
60
65
  // Regex patterns that indicate the query is about a failure/mistake.
@@ -83,7 +88,13 @@ function expandTerms(terms) {
83
88
  const expanded = new Set(terms);
84
89
  for (const term of terms) {
85
90
  for (const group of SYNONYM_GROUPS) {
86
- if (group.some((syn) => syn.split(/\s+/).some((w) => w === term || term.includes(w)))) {
91
+ if (group.some((syn) => tokenize(syn).some((word) => (
92
+ word === term
93
+ || (word.length >= 4 && term.length >= 4 && (
94
+ word.startsWith(term)
95
+ || term.startsWith(word)
96
+ ))
97
+ )))) {
87
98
  group.forEach((syn) => tokenize(syn).forEach((t) => expanded.add(t)));
88
99
  }
89
100
  }
@@ -103,6 +114,28 @@ function getField(candidate, field) {
103
114
  return String(val);
104
115
  }
105
116
 
117
+ /**
118
+ * Score exact entity-channel overlap separately from prose relevance.
119
+ *
120
+ * Tags, categories, and tool identities are low-cardinality metadata. Keeping
121
+ * this score visible prevents a tag-only hit from being swallowed by BM25
122
+ * field-length normalization and makes reranking traces explainable.
123
+ */
124
+ function scoreEntityOverlap(queryTerms, candidate) {
125
+ const entityText = [
126
+ getField(candidate, 'tags'),
127
+ getField(candidate, 'category'),
128
+ getField(candidate, 'toolUsed'),
129
+ getField(candidate, 'toolName'),
130
+ ...(candidate.metadata?.toolsUsed || []),
131
+ ].filter(Boolean).join(' ');
132
+ const entityTokens = new Set(expandTerms(tokenize(entityText)));
133
+ const uniqueQueryTerms = [...new Set(queryTerms)];
134
+ if (uniqueQueryTerms.length === 0 || entityTokens.size === 0) return 0;
135
+ const hits = uniqueQueryTerms.filter((term) => entityTokens.has(term)).length;
136
+ return Number((hits / uniqueQueryTerms.length).toFixed(6));
137
+ }
138
+
106
139
  /**
107
140
  * Compute field-weighted BM25 scores for a list of candidates (BM25F variant).
108
141
  *
@@ -189,7 +222,7 @@ function fieldWeightedBM25(queryTerms, candidates) {
189
222
  }
190
223
 
191
224
  /**
192
- * Rerank a list of lesson candidates using a cross-encoder approach.
225
+ * Rerank a list of lesson candidates using field-aware BM25F.
193
226
  *
194
227
  * @param {string} query - The original retrieval query / action context
195
228
  * @param {Array} candidates - Lesson objects from the bi-encoder stage
@@ -224,12 +257,15 @@ function rerankLessons(query, candidates, options = {}) {
224
257
 
225
258
  const reranked = bm25Results.map(({ candidate, bm25Score }) => {
226
259
  const normBm25 = bm25Score / maxBm25;
260
+ const entityScore = scoreEntityOverlap(queryTerms, candidate);
227
261
 
228
262
  // Original bi-encoder score (field name differs between retrieval paths)
229
263
  const origScore = candidate.relevanceScore ?? candidate.score ?? 0;
230
264
 
231
265
  // Blend BM25 with original score
232
- let finalScore = blendWeight * normBm25 + (1 - blendWeight) * origScore;
266
+ let finalScore = 0.85 * (
267
+ blendWeight * normBm25 + (1 - blendWeight) * origScore
268
+ ) + 0.15 * entityScore;
233
269
 
234
270
  // Signal coherence bonus: failure queries → negative lessons rank higher
235
271
  const candidateSignal =
@@ -252,7 +288,11 @@ function rerankLessons(query, candidates, options = {}) {
252
288
  }
253
289
  }
254
290
 
255
- return { ...candidate, rerankedScore: Number(finalScore.toFixed(6)) };
291
+ return {
292
+ ...candidate,
293
+ entityScore,
294
+ rerankedScore: Number(finalScore.toFixed(6)),
295
+ };
256
296
  });
257
297
 
258
298
  return reranked
@@ -260,4 +300,10 @@ function rerankLessons(query, candidates, options = {}) {
260
300
  .slice(0, topK);
261
301
  }
262
302
 
263
- module.exports = { rerankLessons, fieldWeightedBM25, tokenize, expandTerms };
303
+ module.exports = {
304
+ rerankLessons,
305
+ fieldWeightedBM25,
306
+ scoreEntityOverlap,
307
+ tokenize,
308
+ expandTerms,
309
+ };