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
@@ -14,12 +14,21 @@
14
14
  // -----------------------------------------------------------------------------
15
15
 
16
16
  const path = require('path');
17
+ const {
18
+ parseModelStructuredAnswer,
19
+ structuredOutputInstruction,
20
+ } = require('./rag-structured-output');
17
21
 
18
22
  const GEMINI_ENDPOINT = 'https://generativelanguage.googleapis.com/v1beta/models';
19
23
  const PERPLEXITY_ENDPOINT = 'https://api.perplexity.ai/chat/completions';
20
24
  const DEFAULT_MODEL = 'gemini-2.5-flash';
21
25
  const MAX_QUESTION_CHARS = 2000;
22
26
  const MAX_CONTEXT_LESSONS = 8;
27
+ const GEMINI_MODEL_BY_TIER = Object.freeze({
28
+ nano: 'gemini-2.5-flash-lite',
29
+ mini: 'gemini-2.5-flash',
30
+ frontier: 'gemini-2.5-pro',
31
+ });
23
32
 
24
33
  // Allowlist the model so a user-supplied `model` cannot route the call to an
25
34
  // arbitrary / unexpected (or more expensive) endpoint. Anything not on the list
@@ -49,6 +58,63 @@ function resolveApiKey(opts = {}) {
49
58
  return key.trim().replace(/^["']|["']$/g, '');
50
59
  }
51
60
 
61
+ function resolveBudgetedProviderRoute({
62
+ localEndpoint,
63
+ localModel,
64
+ isPerplexity,
65
+ requestedModel,
66
+ budgetTier,
67
+ perplexityFrontierModel,
68
+ } = {}) {
69
+ const tier = String(budgetTier || 'mini');
70
+ if (localEndpoint) {
71
+ return {
72
+ allowed: true,
73
+ provider: 'local',
74
+ model: localModel,
75
+ tier: 'localFrontier',
76
+ };
77
+ }
78
+
79
+ if (isPerplexity) {
80
+ if (tier === 'mini') {
81
+ return { allowed: true, provider: 'perplexity', model: 'sonar', tier };
82
+ }
83
+ if (tier === 'frontier' && String(perplexityFrontierModel || '').trim()) {
84
+ return {
85
+ allowed: true,
86
+ provider: 'perplexity',
87
+ model: String(perplexityFrontierModel).trim(),
88
+ tier,
89
+ };
90
+ }
91
+ return {
92
+ allowed: false,
93
+ provider: 'perplexity',
94
+ model: null,
95
+ tier,
96
+ reason: `no_perplexity_model_for_tier:${tier}`,
97
+ };
98
+ }
99
+
100
+ const mappedModel = GEMINI_MODEL_BY_TIER[tier];
101
+ if (!mappedModel) {
102
+ return {
103
+ allowed: false,
104
+ provider: 'gemini',
105
+ model: null,
106
+ tier,
107
+ reason: `no_gemini_model_for_tier:${tier}`,
108
+ };
109
+ }
110
+ const requested = String(requestedModel || '');
111
+ const requestedMatchesTier = (tier === 'frontier' && /pro/i.test(requested))
112
+ || (tier === 'mini' && /flash/i.test(requested) && !/lite/i.test(requested))
113
+ || (tier === 'nano' && /lite/i.test(requested));
114
+ const model = requestedMatchesTier ? requested : mappedModel;
115
+ return { allowed: true, provider: 'gemini', model, tier };
116
+ }
117
+
52
118
  function debugChatFallback(label, err) {
53
119
  if (process.env.THUMBGATE_DEBUG_CHAT !== '1') return;
54
120
  const detail = err?.message ? err.message : String(err);
@@ -168,20 +234,60 @@ function retrieveMetricsContext() {
168
234
  return snapshot;
169
235
  }
170
236
 
237
+ /**
238
+ * Hybrid lesson retrieval (lexical + dense RRF + rerank) when available.
239
+ * Falls back to lesson-search. Always merges optional LanceDB vector hits.
240
+ */
241
+ async function retrieveHybridLessonContext(question, opts = {}) {
242
+ try {
243
+ const {
244
+ retrieveRelevantLessonsAsync,
245
+ retrieveRelevantLessons,
246
+ } = require(path.join(__dirname, 'lesson-retrieval'));
247
+ const actionContext = String(question || '');
248
+ const toolName = opts.toolName || 'dashboard_chat';
249
+ let rows = [];
250
+ if (typeof retrieveRelevantLessonsAsync === 'function' && opts.useHybrid !== false) {
251
+ rows = await retrieveRelevantLessonsAsync(toolName, actionContext, {
252
+ maxResults: MAX_CONTEXT_LESSONS,
253
+ feedbackDir: opts.feedbackDir,
254
+ embedder: opts.embedder,
255
+ });
256
+ } else if (typeof retrieveRelevantLessons === 'function') {
257
+ rows = retrieveRelevantLessons(toolName, actionContext, {
258
+ maxResults: MAX_CONTEXT_LESSONS,
259
+ feedbackDir: opts.feedbackDir,
260
+ });
261
+ }
262
+ return (rows || []).map((lesson) => lessonToContextItem({
263
+ id: lesson.id || lesson.memoryId,
264
+ signal: lesson.signal || lesson.feedback,
265
+ title: lesson.title || lesson.summary || '',
266
+ content: lesson.content || lesson.whatWentWrong || lesson.rule || lesson.summary || '',
267
+ tags: lesson.tags || [],
268
+ }));
269
+ } catch (err) {
270
+ debugChatFallback('hybrid lesson retrieval unavailable', err);
271
+ return [];
272
+ }
273
+ }
274
+
171
275
  // Retrieve relevant stored lessons and optional raw feedback vector matches.
172
276
  async function retrieveContext(question, opts = {}) {
173
- const lessons = retrieveLessonContext(question, opts);
277
+ const hybrid = await retrieveHybridLessonContext(question, opts);
278
+ const lessons = hybrid.length ? hybrid : retrieveLessonContext(question, opts);
174
279
  const vectors = await retrieveVectorContext(question, opts);
175
280
  return dedupeContextItems([...lessons, ...vectors]);
176
281
  }
177
282
 
178
283
  // Build a grounded RAG prompt. Pure function (testable).
179
- function buildChatPrompt(question, lessons, metrics) {
284
+ function buildChatPrompt(question, lessons, metrics, options = {}) {
180
285
  const q = String(question || '').slice(0, MAX_QUESTION_CHARS).trim();
181
286
  const context = (lessons || []).map((l, i) => {
182
287
  const mark = /pos|up/i.test(l.signal) ? 'WORKED' : (/neg|down/i.test(l.signal) ? 'MISTAKE' : 'NOTE');
183
288
  const tags = (l.tags || []).length ? ` [tags: ${l.tags.join(', ')}]` : '';
184
- return `(${i + 1}) [${mark}] ${l.title || ''}${tags}\n ${l.content}`;
289
+ const idHint = l.id ? ` id=${l.id}` : '';
290
+ return `(${i + 1}) [${mark}]${idHint} ${l.title || ''}${tags}\n ${l.content}`;
185
291
  }).join('\n');
186
292
 
187
293
  const metricsBlock = metrics && Object.keys(metrics).length
@@ -196,7 +302,11 @@ function buildChatPrompt(question, lessons, metrics) {
196
302
  'If neither source contains the answer, say so plainly — do not invent facts.',
197
303
  ].join(' ');
198
304
 
199
- return `${system}\n\n=== Captured lessons (your data) ===\n${context || '(no relevant lessons found)'}\n${metricsBlock}\n=== Question ===\n${q}`;
305
+ const structured = options.structured !== false
306
+ ? `\n${structuredOutputInstruction()}\n`
307
+ : '';
308
+
309
+ return `${system}${structured}\n=== Captured lessons (your data) ===\n${context || '(no relevant lessons found)'}\n${metricsBlock}\n=== Question ===\n${q}`;
200
310
  }
201
311
 
202
312
  // Parse the Gemini generateContent response into plain text. Pure (testable).
@@ -251,18 +361,18 @@ async function callLocalOpenAiEndpoint({ endpoint, apiKey, model, prompt, fetchI
251
361
  return { ok: true, answer: answer.trim() || '(no answer returned)', sources, model: json.model || model };
252
362
  }
253
363
 
254
- async function callPerplexityEndpoint({ apiKey, prompt, fetchImpl, sources }) {
364
+ async function callPerplexityEndpoint({ apiKey, model, prompt, fetchImpl, sources }) {
255
365
  const res = await fetchImpl(PERPLEXITY_ENDPOINT, {
256
366
  method: 'POST',
257
367
  headers: { 'content-type': 'application/json', 'Authorization': `Bearer ${apiKey}` },
258
- body: buildOpenAiChatPayload(prompt, 'sonar'),
368
+ body: buildOpenAiChatPayload(prompt, model),
259
369
  });
260
370
  const json = await res.json().catch(() => ({}));
261
371
  if (!res.ok) {
262
372
  return { ok: false, error: 'perplexity_error', status: res.status, message: parseModelError(json, res.status), sources };
263
373
  }
264
374
  const answer = parseOpenAiChatAnswer(json);
265
- return { ok: true, answer: answer.trim() || '(no answer returned)', sources, model: json.model || 'perplexity-hybrid' };
375
+ return { ok: true, answer: answer.trim() || '(no answer returned)', sources, model: json.model || model };
266
376
  }
267
377
 
268
378
  async function callGeminiEndpoint({ apiKey, model, prompt, fetchImpl, sources }) {
@@ -282,20 +392,81 @@ async function callGeminiEndpoint({ apiKey, model, prompt, fetchImpl, sources })
282
392
  return { ok: true, answer: answer || '(no answer returned)', sources, model: json.modelVersion || model };
283
393
  }
284
394
 
395
+ function attachStructured(result, sources) {
396
+ if (!result || !result.ok) return result;
397
+ const structured = parseModelStructuredAnswer(result.answer, sources);
398
+ return {
399
+ ...result,
400
+ // Keep plain answer string for existing dashboard clients.
401
+ answer: structured.value?.answer || result.answer,
402
+ structured: structured.value,
403
+ structuredMode: structured.mode,
404
+ structuredValid: structured.ok,
405
+ structuredErrors: structured.errors || [],
406
+ sources,
407
+ };
408
+ }
409
+
285
410
  // Answer a question grounded in this install's lessons. Returns
286
- // { ok, answer, sources, model } or { ok:false, error, ... }.
411
+ // { ok, answer, sources, model, structured?, envelope? } or { ok:false, error, ... }.
287
412
  async function answerDataQuestion(question, opts = {}) {
413
+ const {
414
+ createRequestEnvelope,
415
+ finalizeRequestEnvelope,
416
+ summarizeRetrieval,
417
+ estimateTokensFromText,
418
+ estimateCostCents,
419
+ hashSensitiveText,
420
+ } = require('./request-envelope');
421
+ const { probeEmbeddingQuality } = require('./retrieval-quality-tier');
422
+ const { classifyTask } = require('./model-tier-router');
423
+ const {
424
+ enforceTierBudgets,
425
+ recordFrontierInvocation,
426
+ } = require('./tier-budget-guard');
427
+
288
428
  const q = String(question || '').trim();
289
- if (!q) return { ok: false, error: 'empty_question', message: 'Ask a question about your data.' };
429
+ const envelope = createRequestEnvelope({
430
+ surface: 'dashboard_chat',
431
+ startedAt: Date.now(),
432
+ promptHash: hashSensitiveText(q),
433
+ });
434
+
435
+ if (!q) {
436
+ return {
437
+ ok: false,
438
+ error: 'empty_question',
439
+ message: 'Ask a question about your data.',
440
+ envelope: finalizeRequestEnvelope(envelope, { outcome: 'error', error: 'empty_question' }),
441
+ };
442
+ }
290
443
  if (q.length > MAX_QUESTION_CHARS) {
291
- return { ok: false, error: 'question_too_long', message: `Question exceeds ${MAX_QUESTION_CHARS} characters.` };
444
+ return {
445
+ ok: false,
446
+ error: 'question_too_long',
447
+ message: `Question exceeds ${MAX_QUESTION_CHARS} characters.`,
448
+ envelope: finalizeRequestEnvelope(envelope, { outcome: 'error', error: 'question_too_long' }),
449
+ };
292
450
  }
293
451
 
294
452
  const localEndpoint = opts.localEndpoint || process.env.THUMBGATE_LOCAL_LLM_ENDPOINT || '';
295
453
  const localModel = opts.localModel || process.env.THUMBGATE_LOCAL_LLM_MODEL || 'llama3';
296
454
  const apiKey = resolveApiKey(opts);
455
+ const quality = typeof opts.qualityTier === 'object' && opts.qualityTier
456
+ ? opts.qualityTier
457
+ : probeEmbeddingQuality({ indexUpdatedAtMs: opts.indexUpdatedAtMs ?? null });
458
+
297
459
  const lessons = await retrieveContext(q, opts);
298
- const sources = lessons.map((l) => ({ id: l.id, title: l.title, signal: l.signal }));
460
+ const sources = lessons.map((l, i) => ({
461
+ id: l.id || `lesson-${i + 1}`,
462
+ title: l.title,
463
+ signal: l.signal,
464
+ }));
465
+ const retrievalSummary = summarizeRetrieval(lessons, {
466
+ strategy: 'dashboard_hybrid',
467
+ qualityTier: quality.qualityTier,
468
+ degradedReasons: quality.degradedReasons,
469
+ });
299
470
 
300
471
  if (!apiKey && !localEndpoint) {
301
472
  return {
@@ -303,22 +474,182 @@ async function answerDataQuestion(question, opts = {}) {
303
474
  error: 'no_api_key',
304
475
  message: 'Chat is not configured. Set a valid GEMINI_API_KEY, PERPLEXITY_API_KEY, or THUMBGATE_LOCAL_LLM_ENDPOINT in the project .env.',
305
476
  sources,
477
+ qualityTier: quality.qualityTier,
478
+ semanticClaimsAllowed: quality.semanticClaimsAllowed,
479
+ envelope: finalizeRequestEnvelope(envelope, {
480
+ outcome: 'error',
481
+ error: 'no_api_key',
482
+ retrieval: retrievalSummary,
483
+ qualityTier: quality.qualityTier,
484
+ }),
485
+ };
486
+ }
487
+
488
+ // Risk-aware tier + hard budgets (cost / frontier daily / session)
489
+ const classification = classifyTask({
490
+ type: opts.taskType || 'review',
491
+ contextTokens: estimateTokensFromText(q) + lessons.length * 200,
492
+ riskLevel: opts.riskLevel || 'low',
493
+ tags: opts.tags || [],
494
+ });
495
+ const budgetedTokens = estimateTokensFromText(q) + 2500;
496
+ const budgetDecision = enforceTierBudgets(
497
+ {
498
+ type: opts.taskType || 'review',
499
+ contextTokens: classification.contextTokens,
500
+ riskLevel: opts.riskLevel || 'low',
501
+ tags: opts.tags || [],
502
+ reason: 'dashboard_chat',
503
+ expectedLatencyMs: opts.expectedLatencyMs,
504
+ },
505
+ {
506
+ classification,
507
+ frontierBudget: opts.frontierBudget || null,
508
+ estimatedTokens: budgetedTokens,
509
+ },
510
+ );
511
+
512
+ if (!budgetDecision.allowed) {
513
+ return {
514
+ ok: false,
515
+ error: 'budget_exceeded',
516
+ message: `Request denied by cost/latency budget: ${budgetDecision.reasons.join('; ')}`,
517
+ sources,
518
+ qualityTier: quality.qualityTier,
519
+ semanticClaimsAllowed: quality.semanticClaimsAllowed,
520
+ envelope: finalizeRequestEnvelope(envelope, {
521
+ outcome: 'denied',
522
+ error: 'budget_exceeded',
523
+ tier: budgetDecision.tier,
524
+ budget: budgetDecision,
525
+ retrieval: retrievalSummary,
526
+ qualityTier: quality.qualityTier,
527
+ estimatedCostCents: budgetDecision.estimatedCostCents,
528
+ }),
306
529
  };
307
530
  }
308
531
 
309
- const model = resolveModel(opts.model);
532
+ const requestedModel = resolveModel(opts.model);
310
533
  const metrics = retrieveMetricsContext();
311
- const prompt = buildChatPrompt(q, lessons, metrics);
534
+ const prompt = buildChatPrompt(q, lessons, metrics, { structured: opts.structured !== false });
312
535
  const fetchImpl = opts.fetch || globalThis.fetch;
313
536
  const isPerplexity = apiKey && (apiKey.startsWith('pplx-') || apiKey.includes('perplexity'));
537
+ const route = resolveBudgetedProviderRoute({
538
+ localEndpoint,
539
+ localModel,
540
+ isPerplexity,
541
+ requestedModel,
542
+ budgetTier: budgetDecision.tier,
543
+ perplexityFrontierModel: opts.perplexityFrontierModel
544
+ || process.env.THUMBGATE_PERPLEXITY_FRONTIER_MODEL,
545
+ });
546
+ const inputTokens = estimateTokensFromText(prompt);
547
+
548
+ if (!route.allowed) {
549
+ return {
550
+ ok: false,
551
+ error: 'budget_route_unavailable',
552
+ message: `Request denied: ${route.reason}`,
553
+ sources,
554
+ qualityTier: quality.qualityTier,
555
+ semanticClaimsAllowed: quality.semanticClaimsAllowed,
556
+ envelope: finalizeRequestEnvelope(envelope, {
557
+ outcome: 'denied',
558
+ error: 'budget_route_unavailable',
559
+ provider: route.provider,
560
+ tier: route.tier,
561
+ budget: { ...budgetDecision, route },
562
+ retrieval: retrievalSummary,
563
+ qualityTier: quality.qualityTier,
564
+ }),
565
+ };
566
+ }
314
567
 
315
568
  try {
316
- if (localEndpoint) return await callLocalOpenAiEndpoint({ endpoint: localEndpoint, apiKey, model: localModel, prompt, fetchImpl, sources });
317
- if (isPerplexity) return await callPerplexityEndpoint({ apiKey, prompt, fetchImpl, sources });
318
- return await callGeminiEndpoint({ apiKey, model, prompt, fetchImpl, sources });
569
+ let result;
570
+ if (localEndpoint) {
571
+ result = await callLocalOpenAiEndpoint({
572
+ endpoint: localEndpoint,
573
+ apiKey,
574
+ model: route.model,
575
+ prompt,
576
+ fetchImpl,
577
+ sources,
578
+ });
579
+ } else if (isPerplexity) {
580
+ result = await callPerplexityEndpoint({
581
+ apiKey,
582
+ model: route.model,
583
+ prompt,
584
+ fetchImpl,
585
+ sources,
586
+ });
587
+ } else {
588
+ result = await callGeminiEndpoint({
589
+ apiKey,
590
+ model: route.model,
591
+ prompt,
592
+ fetchImpl,
593
+ sources,
594
+ });
595
+ }
596
+
597
+ let frontierSessionCharge = null;
598
+ if (route.tier === 'frontier' && result?.ok) {
599
+ recordFrontierInvocation();
600
+ if (opts.frontierBudget && typeof opts.frontierBudget.spend === 'function') {
601
+ frontierSessionCharge = opts.frontierBudget.spend(budgetedTokens, 'dashboard_chat');
602
+ }
603
+ }
604
+
605
+ const structured = attachStructured(result, sources);
606
+ const outputTokens = estimateTokensFromText(structured.answer || '');
607
+ const estimatedCostCents = estimateCostCents({ inputTokens, outputTokens });
608
+ const finalized = finalizeRequestEnvelope(envelope, {
609
+ outcome: structured.ok ? 'ok' : 'error',
610
+ error: structured.ok ? null : structured.error,
611
+ model: structured.model || route.model,
612
+ tier: route.tier,
613
+ provider: route.provider,
614
+ inputTokens,
615
+ outputTokens,
616
+ estimatedCostCents,
617
+ budget: { ...budgetDecision, route, frontierSessionCharge },
618
+ retrieval: retrievalSummary,
619
+ qualityTier: quality.qualityTier,
620
+ structured: {
621
+ ok: structured.structuredValid !== false && structured.ok !== false,
622
+ mode: structured.structuredMode || null,
623
+ grounded: structured.structured?.grounded ?? null,
624
+ },
625
+ });
626
+
627
+ return {
628
+ ...structured,
629
+ qualityTier: quality.qualityTier,
630
+ semanticClaimsAllowed: quality.semanticClaimsAllowed,
631
+ degradedReasons: quality.degradedReasons,
632
+ envelope: finalized,
633
+ };
319
634
  } catch (err) {
320
635
  const safeMessage = (err && err.message) ? String(err.message).split('\n')[0].slice(0, 100) : 'An unexpected error occurred.';
321
- return { ok: false, error: 'network', message: safeMessage, sources };
636
+ return {
637
+ ok: false,
638
+ error: 'network',
639
+ message: safeMessage,
640
+ sources,
641
+ qualityTier: quality.qualityTier,
642
+ semanticClaimsAllowed: quality.semanticClaimsAllowed,
643
+ envelope: finalizeRequestEnvelope(envelope, {
644
+ outcome: 'error',
645
+ error: 'network',
646
+ retrieval: retrievalSummary,
647
+ qualityTier: quality.qualityTier,
648
+ budget: { ...budgetDecision, route },
649
+ tier: route.tier,
650
+ provider: route.provider,
651
+ }),
652
+ };
322
653
  }
323
654
  }
324
655
 
@@ -327,6 +658,8 @@ module.exports = {
327
658
  buildChatPrompt,
328
659
  parseGeminiAnswer,
329
660
  retrieveContext,
661
+ retrieveHybridLessonContext,
662
+ resolveBudgetedProviderRoute,
330
663
  DEFAULT_MODEL,
331
664
  MAX_QUESTION_CHARS,
332
665
  };