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,441 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * Structured output for dashboard RAG answers.
6
+ * Schema: { answer, citations[], grounded, confidence, abstain_reason? }
7
+ */
8
+
9
+ const STRUCTURED_ANSWER_SCHEMA = Object.freeze({
10
+ type: 'object',
11
+ additionalProperties: false,
12
+ required: ['answer', 'citations', 'grounded', 'confidence'],
13
+ properties: {
14
+ answer: { type: 'string' },
15
+ citations: {
16
+ type: 'array',
17
+ items: {
18
+ type: 'object',
19
+ additionalProperties: false,
20
+ required: ['id'],
21
+ properties: {
22
+ id: { type: 'string' },
23
+ title: { type: 'string' },
24
+ index: { type: 'number' },
25
+ },
26
+ },
27
+ },
28
+ grounded: { type: 'boolean' },
29
+ confidence: { type: 'number', minimum: 0, maximum: 1 },
30
+ abstain_reason: { type: 'string' },
31
+ },
32
+ });
33
+
34
+ function clampConfidence(value) {
35
+ const n = Number(value);
36
+ if (!Number.isFinite(n)) return 0;
37
+ return Math.max(0, Math.min(1, n));
38
+ }
39
+
40
+ function extractJsonObject(text) {
41
+ const raw = String(text || '').trim();
42
+ if (!raw) return null;
43
+ // Strip markdown fences if present.
44
+ const openingFence = raw.indexOf('```');
45
+ let contentStart = openingFence >= 0 ? openingFence + 3 : -1;
46
+ if (contentStart >= 0 && raw.slice(contentStart, contentStart + 4).toLowerCase() === 'json') {
47
+ contentStart += 4;
48
+ }
49
+ while (contentStart >= 0 && contentStart < raw.length && /\s/.test(raw[contentStart])) {
50
+ contentStart += 1;
51
+ }
52
+ const closingFence = contentStart >= 0 ? raw.indexOf('```', contentStart) : -1;
53
+ const candidate = closingFence >= 0
54
+ ? raw.slice(contentStart, closingFence).trim()
55
+ : raw;
56
+ try {
57
+ return JSON.parse(candidate);
58
+ } catch {
59
+ const start = candidate.indexOf('{');
60
+ const end = candidate.lastIndexOf('}');
61
+ if (start >= 0 && end > start) {
62
+ try {
63
+ return JSON.parse(candidate.slice(start, end + 1));
64
+ } catch {
65
+ return null;
66
+ }
67
+ }
68
+ return null;
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Validate and normalise a structured RAG answer against retrieved sources.
74
+ */
75
+ function validateStructuredAnswer(payload, sources = []) {
76
+ const errors = [];
77
+ if (!payload || typeof payload !== 'object') {
78
+ return { ok: false, errors: ['not_an_object'], value: null };
79
+ }
80
+
81
+ const answer = typeof payload.answer === 'string' ? payload.answer.trim() : '';
82
+ if (!answer) errors.push('missing_answer');
83
+
84
+ const citationsIn = Array.isArray(payload.citations) ? payload.citations : null;
85
+ if (!citationsIn) errors.push('missing_citations');
86
+
87
+ const sourceIds = new Set(
88
+ (sources || []).map((s) => String(s.id || s)).filter(Boolean),
89
+ );
90
+ const sourceIndexes = new Set(
91
+ (sources || []).map((_, i) => String(i + 1)),
92
+ );
93
+
94
+ if (payload.confidence === undefined || payload.confidence === null) {
95
+ errors.push('missing_confidence');
96
+ }
97
+
98
+ const citations = [];
99
+ for (const c of citationsIn || []) {
100
+ if (!c || typeof c !== 'object') {
101
+ errors.push('citation_not_object');
102
+ continue;
103
+ }
104
+ const id = String(c.id || c.sourceId || '').trim();
105
+ const index = c.index != null ? Number(c.index) : null;
106
+ if (!id && !Number.isFinite(index)) {
107
+ errors.push('citation_missing_id');
108
+ continue;
109
+ }
110
+ // Bracket form "[n]" is only valid when n is a real 1-based source index.
111
+ let bracketOk = false;
112
+ const bracketMatch = id && id.match(/^\[(\d+)\]$/);
113
+ if (bracketMatch) {
114
+ const n = Number(bracketMatch[1]);
115
+ bracketOk = Number.isFinite(n) && n >= 1 && n <= sources.length;
116
+ }
117
+ const idOk = Boolean(id && (sourceIds.has(id) || sourceIndexes.has(id) || bracketOk));
118
+ const indexOk = Number.isFinite(index) && index >= 1 && index <= sources.length;
119
+ if (sources.length > 0 && !idOk && !indexOk) {
120
+ errors.push(`citation_unknown:${id || index}`);
121
+ continue; // do not accept citations that point outside the retrieved set
122
+ }
123
+ citations.push({
124
+ id: id || String(index),
125
+ title: typeof c.title === 'string' ? c.title : undefined,
126
+ index: Number.isFinite(index) ? index : (bracketMatch ? Number(bracketMatch[1]) : undefined),
127
+ });
128
+ }
129
+
130
+ let grounded = payload.grounded;
131
+ if (typeof grounded !== 'boolean') {
132
+ grounded = citations.length > 0 && sources.length > 0;
133
+ errors.push('grounded_coerced');
134
+ }
135
+
136
+ // Consistency: if no sources, grounded must be false.
137
+ if (sources.length === 0 && grounded === true) {
138
+ grounded = false;
139
+ errors.push('grounded_forced_false_empty_sources');
140
+ }
141
+ if (grounded === true && citations.length === 0) {
142
+ grounded = false;
143
+ errors.push('grounded_forced_false_no_valid_citations');
144
+ }
145
+
146
+ const confidence = clampConfidence(payload.confidence);
147
+ const abstain_reason = typeof payload.abstain_reason === 'string'
148
+ ? payload.abstain_reason
149
+ : undefined;
150
+
151
+ const hardErrors = errors.filter((e) => !e.startsWith('grounded_') && e !== 'grounded_coerced');
152
+ const value = {
153
+ answer,
154
+ citations,
155
+ grounded,
156
+ confidence,
157
+ ...(abstain_reason ? { abstain_reason } : {}),
158
+ };
159
+
160
+ return {
161
+ ok: hardErrors.length === 0 && Boolean(answer),
162
+ errors,
163
+ value,
164
+ schema: STRUCTURED_ANSWER_SCHEMA,
165
+ };
166
+ }
167
+
168
+ /**
169
+ * Build a free-text answer into structured form when the model ignored JSON.
170
+ */
171
+ function coerceFreeTextToStructured(text, sources = []) {
172
+ const answer = String(text || '').trim();
173
+ const citeMatches = [...answer.matchAll(/\[(\d+)\]/g)].map((m) => Number(m[1]));
174
+ const citations = [];
175
+ for (const idx of new Set(citeMatches)) {
176
+ const src = sources[idx - 1];
177
+ if (src) {
178
+ citations.push({ id: String(src.id || idx), title: src.title, index: idx });
179
+ } else {
180
+ citations.push({ id: String(idx), index: idx });
181
+ }
182
+ }
183
+ const grounded = citations.length > 0;
184
+ return validateStructuredAnswer({
185
+ answer: answer || 'No answer generated.',
186
+ citations,
187
+ grounded: Boolean(grounded && sources.length > 0),
188
+ confidence: citations.length ? 0.6 : (sources.length ? 0.4 : 0.2),
189
+ abstain_reason: sources.length
190
+ ? (citations.length ? undefined : 'model_output_missing_valid_citation')
191
+ : 'no_sources_retrieved',
192
+ }, sources);
193
+ }
194
+
195
+ /**
196
+ * Parse model text into structured answer (JSON preferred, free-text fallback).
197
+ */
198
+ function parseModelStructuredAnswer(text, sources = []) {
199
+ const json = extractJsonObject(text);
200
+ if (json) {
201
+ const validated = validateStructuredAnswer(json, sources);
202
+ if (validated.ok) return { ...validated, mode: 'json' };
203
+ // Partial JSON — still return validated value with mode
204
+ if (validated.value?.answer) return { ...validated, mode: 'json_partial' };
205
+ }
206
+ const coerced = coerceFreeTextToStructured(text, sources);
207
+ return { ...coerced, mode: 'free_text_coerced' };
208
+ }
209
+
210
+ function structuredOutputInstruction() {
211
+ return [
212
+ 'Respond with ONLY valid JSON (no markdown fences) matching this schema:',
213
+ '{"answer": string, "citations": [{"id": string, "index"?: number, "title"?: string}],',
214
+ '"grounded": boolean, "confidence": number (0-1), "abstain_reason"?: string}.',
215
+ 'citations[].index is the 1-based lesson number from the context block.',
216
+ 'If the context is insufficient, set grounded=false, confidence low, and explain in answer.',
217
+ ].join(' ');
218
+ }
219
+
220
+ // ---------------------------------------------------------------------------
221
+ // Deterministic answer-quality proxies for RAG regression gates.
222
+ // ---------------------------------------------------------------------------
223
+
224
+ const ANSWER_STOP_WORDS = new Set([
225
+ 'a', 'an', 'and', 'are', 'as', 'at', 'be', 'before', 'by', 'for', 'from',
226
+ 'has', 'have', 'how', 'i', 'in', 'is', 'it', 'of', 'on', 'or', 'our',
227
+ 'should', 'that', 'the', 'their', 'this', 'to', 'was', 'we', 'what', 'when',
228
+ 'where', 'which', 'with', 'you', 'your',
229
+ ]);
230
+
231
+ const ANSWER_NEGATION_PATTERN = /\b(?:no|not|never|avoid|without|cannot|can't|don't|do not|mustn't|prohibited|blocked)\b/i;
232
+
233
+ function roundMetric(value) {
234
+ return Number(Number(value || 0).toFixed(6));
235
+ }
236
+
237
+ function answerTokens(text) {
238
+ return String(text || '')
239
+ .toLowerCase()
240
+ .replace(/\[[a-z0-9:_-]+\]/gi, ' ')
241
+ .replace(/[^a-z0-9]+/g, ' ')
242
+ .trim()
243
+ .split(/\s+/)
244
+ .filter((token) => token.length >= 2 && !ANSWER_STOP_WORDS.has(token));
245
+ }
246
+
247
+ function answerTokenSet(text) {
248
+ return new Set(answerTokens(text));
249
+ }
250
+
251
+ function tokenF1(left, right) {
252
+ const a = answerTokenSet(left);
253
+ const b = answerTokenSet(right);
254
+ if (!a.size || !b.size) return 0;
255
+ let overlap = 0;
256
+ for (const token of a) if (b.has(token)) overlap += 1;
257
+ if (!overlap) return 0;
258
+ const precision = overlap / a.size;
259
+ const recall = overlap / b.size;
260
+ return (2 * precision * recall) / (precision + recall);
261
+ }
262
+
263
+ function queryCoverage(query, answer) {
264
+ const queryTokens = answerTokenSet(query);
265
+ const answerSet = answerTokenSet(answer);
266
+ if (!queryTokens.size) return 0;
267
+ let covered = 0;
268
+ for (const token of queryTokens) if (answerSet.has(token)) covered += 1;
269
+ return covered / queryTokens.size;
270
+ }
271
+
272
+ function splitAnswerClaims(answer) {
273
+ return String(answer || '')
274
+ .split(/(?:\n+|(?<=[.!?])\s+)/)
275
+ .map((claim) => claim.replace(/^[-*]\s*/, '').trim())
276
+ .filter((claim) => answerTokens(claim).length > 0);
277
+ }
278
+
279
+ function normalizeAnswerContexts(contexts = []) {
280
+ return (Array.isArray(contexts) ? contexts : [])
281
+ .map((context, index) => ({
282
+ id: String(context?.id || context?.sourceId || context?.documentId || `context-${index}`),
283
+ text: String(context?.text || context?.content || context?.rawContent || ''),
284
+ }))
285
+ .filter((context) => context.text.trim());
286
+ }
287
+
288
+ function numericTokens(text) {
289
+ return String(text || '')
290
+ .replace(/\[[a-z0-9:_-]+\]/gi, ' ')
291
+ .match(/\b\d+(?:\.\d+)?%?\b/g) || [];
292
+ }
293
+
294
+ function claimSupportScore(claim, contextText) {
295
+ const claimText = String(claim || '').trim();
296
+ const evidenceText = String(contextText || '').trim();
297
+ if (!claimText || !evidenceText) return 0;
298
+
299
+ const claimNumbers = numericTokens(claimText);
300
+ if (claimNumbers.some((number) => !numericTokens(evidenceText).includes(number))) return 0;
301
+
302
+ let score = tokenF1(claimText, evidenceText);
303
+ const normalizedClaim = claimText.toLowerCase().replace(/\s+/g, ' ');
304
+ const normalizedEvidence = evidenceText.toLowerCase().replace(/\s+/g, ' ');
305
+ if (normalizedEvidence.includes(normalizedClaim)) score = 1;
306
+
307
+ const claimNegated = ANSWER_NEGATION_PATTERN.test(claimText);
308
+ const evidenceNegated = ANSWER_NEGATION_PATTERN.test(evidenceText);
309
+ if (claimNegated !== evidenceNegated && score >= 0.35) score *= 0.2;
310
+ return roundMetric(Math.max(0, Math.min(1, score)));
311
+ }
312
+
313
+ function extractCitationIds(answer, citations = []) {
314
+ const ids = [];
315
+ for (const citation of Array.isArray(citations) ? citations : []) {
316
+ const id = typeof citation === 'string'
317
+ ? citation
318
+ : citation?.id || citation?.sourceId || citation?.documentId;
319
+ if (id) ids.push(String(id));
320
+ }
321
+ const pattern = /\[([a-z0-9:_-]+)\]/gi;
322
+ let match;
323
+ while ((match = pattern.exec(String(answer || ''))) !== null) ids.push(match[1]);
324
+ return [...new Set(ids)];
325
+ }
326
+
327
+ function evaluateAnswerQuality(sample = {}, options = {}) {
328
+ const answer = String(sample.answer || sample.response || '');
329
+ const query = String(sample.query || sample.question || '');
330
+ const contexts = normalizeAnswerContexts(sample.contexts || sample.sources);
331
+ const claims = splitAnswerClaims(answer);
332
+ const supportThreshold = Number(options.supportThreshold ?? 0.4);
333
+
334
+ const claimResults = claims.map((claim) => {
335
+ const scored = contexts
336
+ .map((context) => ({
337
+ contextId: context.id,
338
+ score: claimSupportScore(claim, context.text),
339
+ }))
340
+ .sort((left, right) => right.score - left.score);
341
+ const best = scored[0] || { contextId: null, score: 0 };
342
+ return {
343
+ claim,
344
+ supported: best.score >= supportThreshold,
345
+ supportScore: best.score,
346
+ contextId: best.contextId,
347
+ };
348
+ });
349
+
350
+ const faithfulness = claims.length
351
+ ? claimResults.filter((claim) => claim.supported).length / claims.length
352
+ : 0;
353
+ const citationIds = extractCitationIds(answer, sample.citations);
354
+ const knownIds = new Set(contexts.map((context) => context.id));
355
+ const validCitationIds = citationIds.filter((id) => knownIds.has(id));
356
+ const citationPrecision = citationIds.length ? validCitationIds.length / citationIds.length : 0;
357
+ const groundedness = faithfulness * (0.7 + 0.3 * citationPrecision);
358
+ const referenceScore = sample.referenceAnswer
359
+ ? tokenF1(answer, sample.referenceAnswer)
360
+ : null;
361
+ const answerRelevance = referenceScore === null
362
+ ? queryCoverage(query, answer)
363
+ : (0.3 * queryCoverage(query, answer)) + (0.7 * referenceScore);
364
+
365
+ const thresholds = {
366
+ minFaithfulness: Number(options.minFaithfulness ?? 0.8),
367
+ minGroundedness: Number(options.minGroundedness ?? 0.75),
368
+ minAnswerRelevance: Number(options.minAnswerRelevance ?? 0.3),
369
+ };
370
+ const failures = [];
371
+ if (faithfulness < thresholds.minFaithfulness) failures.push('faithfulness');
372
+ if (groundedness < thresholds.minGroundedness) failures.push('groundedness');
373
+ if (answerRelevance < thresholds.minAnswerRelevance) failures.push('answer_relevance');
374
+
375
+ return {
376
+ mode: 'deterministic-lexical-proxy',
377
+ limitations: [
378
+ 'Lexical support is not semantic entailment.',
379
+ 'A calibrated judge or human holdout is still required for nuanced claims.',
380
+ ],
381
+ metrics: {
382
+ faithfulness: roundMetric(faithfulness),
383
+ groundedness: roundMetric(groundedness),
384
+ answerRelevance: roundMetric(answerRelevance),
385
+ citationPrecision: roundMetric(citationPrecision),
386
+ },
387
+ claims: claimResults,
388
+ citations: {
389
+ cited: citationIds,
390
+ valid: validCitationIds,
391
+ invalid: citationIds.filter((id) => !knownIds.has(id)),
392
+ },
393
+ thresholds,
394
+ failures,
395
+ passed: failures.length === 0,
396
+ };
397
+ }
398
+
399
+ function normalizeJudgeDiagnostic(result) {
400
+ const source = result?.metrics || result;
401
+ if (!source || typeof source !== 'object') return null;
402
+ const metrics = {
403
+ faithfulness: Number(source.faithfulness),
404
+ groundedness: Number(source.groundedness),
405
+ answerRelevance: Number(source.answerRelevance ?? source.answer_relevance),
406
+ };
407
+ if (Object.values(metrics).some((value) => !Number.isFinite(value) || value < 0 || value > 1)) return null;
408
+ return { metrics, rationale: String(result.rationale || '').slice(0, 500) };
409
+ }
410
+
411
+ async function evaluateAnswerQualityWithJudge(sample = {}, options = {}) {
412
+ const deterministic = evaluateAnswerQuality(sample, options);
413
+ if (typeof options.judge !== 'function') {
414
+ return { ...deterministic, judgeDiagnostic: null };
415
+ }
416
+ try {
417
+ const judgeDiagnostic = normalizeJudgeDiagnostic(await options.judge(sample));
418
+ return { ...deterministic, judgeDiagnostic };
419
+ } catch {
420
+ return { ...deterministic, judgeDiagnostic: null };
421
+ }
422
+ }
423
+
424
+ module.exports = {
425
+ STRUCTURED_ANSWER_SCHEMA,
426
+ extractJsonObject,
427
+ validateStructuredAnswer,
428
+ coerceFreeTextToStructured,
429
+ parseModelStructuredAnswer,
430
+ structuredOutputInstruction,
431
+ clampConfidence,
432
+ answerTokens,
433
+ tokenF1,
434
+ queryCoverage,
435
+ splitAnswerClaims,
436
+ claimSupportScore,
437
+ extractCitationIds,
438
+ evaluateAnswerQuality,
439
+ evaluateAnswerQualityWithJudge,
440
+ normalizeJudgeDiagnostic,
441
+ };