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
@@ -4,6 +4,7 @@ const path = require('node:path');
4
4
  const { readJSONL, getFeedbackPaths } = require('./feedback-loop');
5
5
  const { buildMemoryLifecycleView, scoreHybridMemoryMatch } = require('./agent-memory-lifecycle');
6
6
  const { loadOptionalModule } = require('./private-core-boundary');
7
+ const { selectRetrievalMemories } = require('./lesson-retrieval');
7
8
 
8
9
  const HIGH_RISK_TAGS = new Set([
9
10
  'billing',
@@ -402,6 +403,8 @@ function scoreLesson(queryText, memory, parsed, sourceFeedback) {
402
403
  if (!queryText) {
403
404
  return {
404
405
  score: recencyScore(memory.timestamp),
406
+ evidenceScore: 0,
407
+ priorScore: recencyScore(memory.timestamp),
405
408
  matchedTokens: [],
406
409
  };
407
410
  }
@@ -409,14 +412,16 @@ function scoreLesson(queryText, memory, parsed, sourceFeedback) {
409
412
  const lessonText = buildLessonQuery(memory, parsed, sourceFeedback);
410
413
  const queryTokens = tokenize(queryText);
411
414
  const lessonTokens = tokenize(lessonText);
412
- const score = jaccardSimilarity(queryTokens, lessonTokens)
413
- + substringBoost(queryText, lessonText)
414
- + recencyScore(memory.timestamp)
415
+ const evidenceScore = jaccardSimilarity(queryTokens, lessonTokens)
416
+ + substringBoost(queryText, lessonText);
417
+ const priorScore = recencyScore(memory.timestamp)
415
418
  + (memory.category === 'error' ? 0.05 : 0)
416
419
  + Math.min(0.2, scoreHybridMemoryMatch(queryText, memory).score * 0.1);
417
420
 
418
421
  return {
419
- score,
422
+ score: evidenceScore + priorScore,
423
+ evidenceScore,
424
+ priorScore,
420
425
  matchedTokens: unique(queryTokens.filter((token) => lessonTokens.includes(token))),
421
426
  };
422
427
  }
@@ -426,7 +431,7 @@ function buildLessonResult(memory, sourceFeedback, options = {}) {
426
431
  const lessonQuery = buildLessonQuery(memory, parsed, sourceFeedback);
427
432
  const ruleMatches = readPreventionRuleMatches(lessonQuery, Number(options.ruleLimit || 3), options);
428
433
  const gateMatches = buildGateMatches(memory, parsed, Number(options.gateLimit || 3), options);
429
- const { score, matchedTokens } = scoreLesson(options.query || '', memory, parsed, sourceFeedback);
434
+ const { score, evidenceScore, priorScore, matchedTokens } = scoreLesson(options.query || '', memory, parsed, sourceFeedback);
430
435
  const harnessRecommendations = buildHarnessRecommendations(memory, parsed, sourceFeedback, ruleMatches, gateMatches);
431
436
  const lifecycle = buildLifecycle(memory, parsed, sourceFeedback, ruleMatches, gateMatches, harnessRecommendations);
432
437
  const memoryLifecycle = buildMemoryLifecycleView(memory, { query: options.query || '' });
@@ -440,6 +445,8 @@ function buildLessonResult(memory, sourceFeedback, options = {}) {
440
445
  timestamp: memory.timestamp || null,
441
446
  sourceFeedbackId: memory.sourceFeedbackId || null,
442
447
  score: Number(score.toFixed(4)),
448
+ evidenceScore: Number(evidenceScore.toFixed(4)),
449
+ priorScore: Number(priorScore.toFixed(4)),
443
450
  matchedTokens,
444
451
  lesson: {
445
452
  summary: parsed.summary,
@@ -481,7 +488,8 @@ function searchLessons(query = '', options = {}) {
481
488
  const sqliteResults = tryFts5Search(query, options);
482
489
  if (sqliteResults) return sqliteResults;
483
490
 
484
- const memories = readJSONL(MEMORY_LOG_PATH);
491
+ const allMemories = readJSONL(MEMORY_LOG_PATH);
492
+ const memories = selectRetrievalMemories(allMemories, options);
485
493
  const feedbackEntries = readJSONL(FEEDBACK_LOG_PATH);
486
494
  const feedbackById = new Map(feedbackEntries.map((entry) => [entry.id, entry]));
487
495
  const parsedLimit = Number(options.limit || 10);
@@ -508,7 +516,10 @@ function searchLessons(query = '', options = {}) {
508
516
  results = results.filter((entry) => requiredTags.every((tag) => entry.tags.includes(tag)));
509
517
  }
510
518
  if (query) {
511
- results = results.filter((entry) => entry.score > 0);
519
+ // Recency, error severity, and lifecycle priors can reorder candidates,
520
+ // but they are never evidence that a lesson answers the query. Returning a
521
+ // recent unrelated incident is worse than an honest empty result.
522
+ results = results.filter((entry) => entry.evidenceScore > 0);
512
523
  }
513
524
 
514
525
  results.sort((a, b) => {
@@ -516,7 +527,7 @@ function searchLessons(query = '', options = {}) {
516
527
  return String(b.timestamp || '').localeCompare(String(a.timestamp || ''));
517
528
  });
518
529
 
519
- // Cross-encoder reranking: when a query is present, rerank the top-50 bi-encoder
530
+ // Field-aware BM25F reranking: when a query is present, rerank the top-50 first-stage
520
531
  // candidates using field-weighted BM25 so the most relevant lessons surface first.
521
532
  if (query && results.length > 1) {
522
533
  const { rerankLessons } = loadOptionalModule('./lesson-reranker', () => ({
@@ -534,9 +545,12 @@ function searchLessons(query = '', options = {}) {
534
545
  filters: {
535
546
  category: category || null,
536
547
  tags: requiredTags,
548
+ scope: options.scope || null,
549
+ requireScope: options.requireScope === true,
537
550
  },
538
551
  feedbackDir: FEEDBACK_DIR,
539
552
  totalLessons: memories.length,
553
+ excludedLessons: allMemories.length - memories.length,
540
554
  returned: Math.min(limit, results.length),
541
555
  results: results.slice(0, limit),
542
556
  backend: 'jsonl-jaccard',
@@ -548,10 +562,15 @@ function searchLessons(query = '', options = {}) {
548
562
  * or not opted in. Set LESSON_DB_SEARCH=1 to enable FTS5 as primary backend.
549
563
  */
550
564
  function tryFts5Search(query, options) {
565
+ // The SQLite index does not currently carry the complete four-field scope
566
+ // contract. Fall back to JSONL whenever isolation is requested rather than
567
+ // silently searching across tenants or sessions.
568
+ if (options.scope || options.requireScope) return null;
551
569
  if (!process.env.LESSON_DB_SEARCH && !options.useFts5) return null;
570
+ let db = null;
552
571
  try {
553
572
  const { initDB, searchLessons: fts5Search, getStats } = require('./lesson-db');
554
- const db = initDB();
573
+ db = initDB();
555
574
  const stats = getStats(db);
556
575
 
557
576
  // If DB is empty, skip (not yet backfilled)
@@ -567,11 +586,24 @@ function tryFts5Search(query, options) {
567
586
  .map((tag) => tag.trim())
568
587
  .filter(Boolean);
569
588
 
570
- const rows = fts5Search(db, query || '', {
571
- limit,
589
+ const candidateRows = fts5Search(db, query || '', {
590
+ limit: Math.max(limit * 5, 50),
572
591
  signal,
573
592
  tags: requiredTags.length > 0 ? requiredTags : undefined,
574
593
  });
594
+ const retrievableRows = selectRetrievalMemories(
595
+ candidateRows.map((row) => ({
596
+ ...row,
597
+ title: row.context || '',
598
+ content: [
599
+ row.whatWentWrong,
600
+ row.whatToChange,
601
+ row.whatWorked,
602
+ ].filter(Boolean).join('\n'),
603
+ })),
604
+ options,
605
+ );
606
+ const rows = retrievableRows.slice(0, limit);
575
607
 
576
608
  return {
577
609
  query: String(query || ''),
@@ -581,6 +613,7 @@ function tryFts5Search(query, options) {
581
613
  tags: requiredTags,
582
614
  },
583
615
  totalLessons: stats.total,
616
+ excludedLessons: candidateRows.length - retrievableRows.length,
584
617
  returned: rows.length,
585
618
  results: rows.map((row) => ({
586
619
  id: row.id,
@@ -606,6 +639,10 @@ function tryFts5Search(query, options) {
606
639
  };
607
640
  } catch (_err) {
608
641
  return null; // SQLite unavailable — fall through to JSONL
642
+ } finally {
643
+ if (db) {
644
+ try { db.close(); } catch { /* best-effort close */ }
645
+ }
609
646
  }
610
647
  }
611
648
 
@@ -14,12 +14,51 @@ const DEFAULT_MAX_TOKENS = 1024;
14
14
  const DEFAULT_CACHE_TTL = '5m';
15
15
  const DEFAULT_ZAI_BASE_URL = 'https://api.z.ai/api/paas/v4';
16
16
  const DEFAULT_ZAI_MODEL = 'glm-5.2-flash';
17
+ const DEFAULT_GATEWAY_MODEL = 'glm-5.2';
18
+ const GATEWAY_TIMEOUT_MS = 30000;
17
19
 
18
20
  let _anthropicClient = null;
19
21
  let _geminiClient = null;
20
22
 
21
- function isAvailable() {
22
- return Boolean(process.env.ANTHROPIC_API_KEY);
23
+ function getGatewayConfig(env = process.env) {
24
+ const baseUrl = String(env.THUMBGATE_LLM_GATEWAY_URL || '').trim();
25
+ if (!baseUrl) return null;
26
+ return {
27
+ baseUrl: baseUrl.replace(/\/+$/, ''),
28
+ model: String(env.THUMBGATE_LLM_GATEWAY_MODEL || DEFAULT_GATEWAY_MODEL).trim(),
29
+ credentialEnvVar: 'THUMBGATE_LLM_GATEWAY_TOKEN',
30
+ };
31
+ }
32
+
33
+ function isGatewayConfigured(env = process.env) {
34
+ return Boolean(getGatewayConfig(env));
35
+ }
36
+
37
+ function resolveGatewayModel(options = {}, config = getGatewayConfig()) {
38
+ const explicitGatewayModel = String(options.gatewayModel || '').trim();
39
+ if (explicitGatewayModel) return explicitGatewayModel;
40
+ return String(config?.model || DEFAULT_GATEWAY_MODEL).trim();
41
+ }
42
+
43
+ function describeInferenceAvailability(env = process.env) {
44
+ if (env.ANTHROPIC_API_KEY) return { available: true, provider: 'anthropic' };
45
+ const gateway = getGatewayConfig(env);
46
+ if (gateway) {
47
+ return {
48
+ available: true,
49
+ provider: 'gateway',
50
+ model: gateway.model,
51
+ };
52
+ }
53
+ return {
54
+ available: false,
55
+ provider: 'none',
56
+ reason: 'no ANTHROPIC_API_KEY and no THUMBGATE_LLM_GATEWAY_URL',
57
+ };
58
+ }
59
+
60
+ function isAvailable(env = process.env) {
61
+ return describeInferenceAvailability(env).available;
23
62
  }
24
63
 
25
64
  function getClient() {
@@ -155,6 +194,144 @@ function buildSafeProviderError(error) {
155
194
  return summary;
156
195
  }
157
196
 
197
+ function normalizeUsageTelemetry(usage = null) {
198
+ if (!usage || typeof usage !== 'object') {
199
+ return {
200
+ inputTokens: null,
201
+ outputTokens: null,
202
+ cacheReadInputTokens: null,
203
+ cacheWriteInputTokens: null,
204
+ };
205
+ }
206
+ const finiteOrNull = (...values) => {
207
+ const value = values.map(Number).find(Number.isFinite);
208
+ return value === undefined ? null : value;
209
+ };
210
+ return {
211
+ inputTokens: finiteOrNull(usage.input_tokens, usage.prompt_tokens, usage.promptTokenCount),
212
+ outputTokens: finiteOrNull(usage.output_tokens, usage.completion_tokens, usage.candidatesTokenCount),
213
+ cacheReadInputTokens: finiteOrNull(usage.cache_read_input_tokens),
214
+ cacheWriteInputTokens: finiteOrNull(usage.cache_creation_input_tokens),
215
+ };
216
+ }
217
+
218
+ function buildLlmTrace(options = {}, event = {}) {
219
+ const usage = normalizeUsageTelemetry(event.usage);
220
+ return {
221
+ timestamp: new Date().toISOString(),
222
+ traceId: String(options.traceId || options.metadata?.traceId || '').trim() || null,
223
+ provider: event.provider || 'unknown',
224
+ model: event.model || options.model || null,
225
+ outcome: event.outcome || 'unknown',
226
+ latencyMs: Number.isFinite(event.latencyMs) ? Math.max(0, event.latencyMs) : null,
227
+ ...usage,
228
+ stopReason: event.stopReason || null,
229
+ requestId: event.requestId || null,
230
+ httpStatus: Number.isFinite(event.httpStatus) ? event.httpStatus : null,
231
+ errorCode: event.errorCode == null ? null : String(event.errorCode).slice(0, 80),
232
+ };
233
+ }
234
+
235
+ async function emitLlmTrace(options = {}, event = {}) {
236
+ if (typeof options.onTrace !== 'function') return;
237
+ try {
238
+ await options.onTrace(buildLlmTrace(options, event));
239
+ } catch {
240
+ // Observability must never break the generation path.
241
+ }
242
+ }
243
+
244
+ function gatewayRequestHeaders(config, env = process.env) {
245
+ const headers = { 'Content-Type': 'application/json' };
246
+ const token = env[config.credentialEnvVar];
247
+ if (token) headers.Authorization = `Bearer ${token}`;
248
+ return headers;
249
+ }
250
+
251
+ function buildGatewayMessages(options = {}) {
252
+ const supplied = Array.isArray(options.messages)
253
+ ? options.messages.filter(Boolean)
254
+ : [];
255
+ const messages = [];
256
+ if (options.systemPrompt) {
257
+ messages.push({ role: 'system', content: options.systemPrompt });
258
+ }
259
+ if (supplied.length > 0) messages.push(...supplied);
260
+ else messages.push({ role: 'user', content: options.userPrompt || '' });
261
+ return messages;
262
+ }
263
+
264
+ async function callGatewayInternal(options = {}, env = process.env) {
265
+ const config = getGatewayConfig(env);
266
+ if (!config || typeof fetch !== 'function') return null;
267
+ const model = resolveGatewayModel(options, config);
268
+ const controller = new AbortController();
269
+ const timer = setTimeout(() => controller.abort(), GATEWAY_TIMEOUT_MS);
270
+ const startedAt = Date.now();
271
+ try {
272
+ const response = await fetch(`${config.baseUrl}/chat/completions`, {
273
+ method: 'POST',
274
+ headers: gatewayRequestHeaders(config, env),
275
+ body: JSON.stringify({
276
+ model,
277
+ messages: buildGatewayMessages(options),
278
+ max_tokens: options.maxTokens || DEFAULT_MAX_TOKENS,
279
+ temperature: Number.isFinite(options.temperature) ? options.temperature : 0,
280
+ }),
281
+ signal: controller.signal,
282
+ });
283
+ if (!response.ok) {
284
+ await emitLlmTrace(options, {
285
+ provider: 'gateway',
286
+ model,
287
+ outcome: 'error',
288
+ latencyMs: Date.now() - startedAt,
289
+ httpStatus: response.status,
290
+ errorCode: 'http_error',
291
+ });
292
+ return null;
293
+ }
294
+ const payload = await response.json();
295
+ const choice = payload?.choices?.[0] || {};
296
+ const message = choice.message || {};
297
+ const truncated = choice.finish_reason === 'length';
298
+ const raw = message.content || (truncated ? '' : message.reasoning_content) || '';
299
+ const text = stripCodeFences(raw);
300
+ if (!text) return null;
301
+ const result = {
302
+ text,
303
+ usage: payload?.usage || null,
304
+ stopReason: choice.finish_reason || null,
305
+ id: payload?.id || null,
306
+ model: payload?.model || model,
307
+ provider: 'gateway',
308
+ };
309
+ await emitLlmTrace(options, {
310
+ provider: 'gateway',
311
+ model: result.model,
312
+ outcome: 'success',
313
+ latencyMs: Date.now() - startedAt,
314
+ usage: result.usage,
315
+ stopReason: result.stopReason,
316
+ requestId: result.id,
317
+ });
318
+ return result;
319
+ } catch (error) {
320
+ const safe = buildSafeProviderError(error);
321
+ await emitLlmTrace(options, {
322
+ provider: 'gateway',
323
+ model,
324
+ outcome: 'error',
325
+ latencyMs: Date.now() - startedAt,
326
+ httpStatus: safe.status,
327
+ errorCode: safe.code || safe.name,
328
+ });
329
+ return null;
330
+ } finally {
331
+ clearTimeout(timer);
332
+ }
333
+ }
334
+
158
335
  function getZaiApiKey(env = process.env) {
159
336
  return env.ZAI_API_KEY || env.THUMBGATE_ZAI_API_KEY || '';
160
337
  }
@@ -169,7 +346,18 @@ function getZaiModel(env = process.env) {
169
346
 
170
347
  async function callZaiInternal(options = {}, env = process.env) {
171
348
  const apiKey = getZaiApiKey(env);
172
- if (!apiKey || typeof fetch !== 'function') return null;
349
+ const model = options.model || getZaiModel(env);
350
+ const startedAt = Date.now();
351
+ if (!apiKey || typeof fetch !== 'function') {
352
+ await emitLlmTrace(options, {
353
+ provider: 'zai',
354
+ model,
355
+ outcome: 'unavailable',
356
+ latencyMs: Date.now() - startedAt,
357
+ errorCode: !apiKey ? 'missing_api_key' : 'fetch_unavailable',
358
+ });
359
+ return null;
360
+ }
173
361
 
174
362
  const messages = Array.isArray(options.messages) && options.messages.length > 0
175
363
  ? options.messages
@@ -186,23 +374,52 @@ async function callZaiInternal(options = {}, env = process.env) {
186
374
  'Content-Type': 'application/json',
187
375
  },
188
376
  body: JSON.stringify({
189
- model: options.model || getZaiModel(env),
377
+ model,
190
378
  messages,
191
379
  max_tokens: options.maxTokens || DEFAULT_MAX_TOKENS,
192
380
  temperature: Number.isFinite(options.temperature) ? options.temperature : 0,
193
381
  }),
194
382
  });
195
383
 
196
- if (!response.ok) return null;
384
+ if (!response.ok) {
385
+ await emitLlmTrace(options, {
386
+ provider: 'zai',
387
+ model,
388
+ outcome: 'error',
389
+ latencyMs: Date.now() - startedAt,
390
+ httpStatus: response.status,
391
+ errorCode: 'http_error',
392
+ });
393
+ return null;
394
+ }
197
395
  const json = await response.json();
198
- return {
396
+ const result = {
199
397
  text: stripCodeFences(json?.choices?.[0]?.message?.content || ''),
200
398
  usage: json?.usage || null,
201
399
  stopReason: json?.choices?.[0]?.finish_reason || null,
202
400
  id: json?.id || null,
203
- model: json?.model || options.model || getZaiModel(env),
401
+ model: json?.model || model,
204
402
  };
205
- } catch {
403
+ await emitLlmTrace(options, {
404
+ provider: 'zai',
405
+ model: result.model,
406
+ outcome: 'success',
407
+ latencyMs: Date.now() - startedAt,
408
+ usage: result.usage,
409
+ stopReason: result.stopReason,
410
+ requestId: result.id,
411
+ });
412
+ return result;
413
+ } catch (error) {
414
+ const safe = buildSafeProviderError(error);
415
+ await emitLlmTrace(options, {
416
+ provider: 'zai',
417
+ model,
418
+ outcome: 'error',
419
+ latencyMs: Date.now() - startedAt,
420
+ httpStatus: safe.status,
421
+ errorCode: safe.code || safe.name,
422
+ });
206
423
  return null;
207
424
  }
208
425
  }
@@ -211,8 +428,19 @@ async function callGeminiInternal(options = {}) {
211
428
  const env = process.env;
212
429
  const { detectInferenceBackend } = require('./local-model-profile');
213
430
  const providerMode = detectInferenceBackend(env).providerMode;
431
+ const provider = providerMode === 'vertex' ? 'vertex' : 'gemini';
432
+ const startedAt = Date.now();
214
433
 
215
- if (providerMode !== 'vertex' && !env.GEMINI_API_KEY) return null;
434
+ if (providerMode !== 'vertex' && !env.GEMINI_API_KEY) {
435
+ await emitLlmTrace(options, {
436
+ provider,
437
+ model: options.model,
438
+ outcome: 'unavailable',
439
+ latencyMs: Date.now() - startedAt,
440
+ errorCode: 'missing_api_key',
441
+ });
442
+ return null;
443
+ }
216
444
 
217
445
  try {
218
446
  const { GoogleGenAI } = require('@google/genai');
@@ -251,7 +479,7 @@ async function callGeminiInternal(options = {}) {
251
479
  config,
252
480
  }));
253
481
 
254
- return {
482
+ const result = {
255
483
  text: response.text || '',
256
484
  usage: response.usageMetadata ? {
257
485
  input_tokens: response.usageMetadata.promptTokenCount,
@@ -261,8 +489,27 @@ async function callGeminiInternal(options = {}) {
261
489
  id: null,
262
490
  model: options.model,
263
491
  };
492
+ await emitLlmTrace(options, {
493
+ provider,
494
+ model: result.model,
495
+ outcome: 'success',
496
+ latencyMs: Date.now() - startedAt,
497
+ usage: result.usage,
498
+ stopReason: result.stopReason,
499
+ requestId: result.id,
500
+ });
501
+ return result;
264
502
  } catch (err) {
265
- console.error('Gemini/Vertex AI execution error:', JSON.stringify(buildSafeProviderError(err)));
503
+ const safe = buildSafeProviderError(err);
504
+ console.error('Gemini/Vertex AI execution error:', JSON.stringify(safe));
505
+ await emitLlmTrace(options, {
506
+ provider,
507
+ model: options.model,
508
+ outcome: 'error',
509
+ latencyMs: Date.now() - startedAt,
510
+ httpStatus: safe.status,
511
+ errorCode: safe.code || safe.name,
512
+ });
266
513
  return null;
267
514
  }
268
515
  }
@@ -296,7 +543,21 @@ async function callClaudeInternal(options = {}) {
296
543
  }
297
544
 
298
545
  const client = getClient();
299
- if (!client) return null;
546
+ const model = options.model || DEFAULT_MODEL;
547
+ const startedAt = Date.now();
548
+ if (!client) {
549
+ if (isGatewayConfigured()) {
550
+ return callGatewayInternal(options);
551
+ }
552
+ await emitLlmTrace(options, {
553
+ provider: 'anthropic',
554
+ model,
555
+ outcome: 'unavailable',
556
+ latencyMs: Date.now() - startedAt,
557
+ errorCode: 'missing_client_or_api_key',
558
+ });
559
+ return null;
560
+ }
300
561
 
301
562
  try {
302
563
  const response = await runStep('llm.callClaude', {
@@ -305,14 +566,33 @@ async function callClaudeInternal(options = {}) {
305
566
  }, async () => client.messages.create(buildClaudeRequest(options)));
306
567
 
307
568
  const text = stripCodeFences(extractTextContent(response));
308
- return {
569
+ const result = {
309
570
  text,
310
571
  usage: response?.usage || null,
311
572
  stopReason: response?.stop_reason || null,
312
573
  id: response?.id || null,
313
- model: response?.model || options.model || DEFAULT_MODEL,
574
+ model: response?.model || model,
314
575
  };
315
- } catch {
576
+ await emitLlmTrace(options, {
577
+ provider: 'anthropic',
578
+ model: result.model,
579
+ outcome: 'success',
580
+ latencyMs: Date.now() - startedAt,
581
+ usage: result.usage,
582
+ stopReason: result.stopReason,
583
+ requestId: result.id,
584
+ });
585
+ return result;
586
+ } catch (error) {
587
+ const safe = buildSafeProviderError(error);
588
+ await emitLlmTrace(options, {
589
+ provider: 'anthropic',
590
+ model,
591
+ outcome: 'error',
592
+ latencyMs: Date.now() - startedAt,
593
+ httpStatus: safe.status,
594
+ errorCode: safe.code || safe.name,
595
+ });
316
596
  return null;
317
597
  }
318
598
  }
@@ -372,6 +652,12 @@ async function callZaiJson(options = {}) {
372
652
 
373
653
  module.exports = {
374
654
  isAvailable,
655
+ getGatewayConfig,
656
+ isGatewayConfigured,
657
+ resolveGatewayModel,
658
+ describeInferenceAvailability,
659
+ callGatewayInternal,
660
+ buildGatewayMessages,
375
661
  callClaude,
376
662
  callClaudeJson,
377
663
  callZaiJson,
@@ -383,5 +669,8 @@ module.exports = {
383
669
  normalizeCacheOptions,
384
670
  buildClaudeRequest,
385
671
  buildSafeProviderError,
672
+ normalizeUsageTelemetry,
673
+ buildLlmTrace,
674
+ emitLlmTrace,
386
675
  MODELS,
387
676
  };
@@ -193,17 +193,37 @@ function publishedCliAvailable(pkgVersion) {
193
193
  return cliAvailabilityCache.get(pkgVersion);
194
194
  }
195
195
 
196
+ /**
197
+ * Project-scope entries land in COMMITTED, SHARED config (.mcp.json / .cursor/mcp.json —
198
+ * init's own banner says the file serves every agent on the repo). A machine-absolute path
199
+ * there is a bug by construction: run init on machine A (or a Cowork sandbox with a home
200
+ * like /Users/busy-clever-newton) and the committed config breaks for every other machine,
201
+ * teammate, and CI runner. Observed for real on 2026-07-29.
202
+ *
203
+ * So: absolute paths may only ever go to HOME-scope config (machine-local by definition).
204
+ * Project scope gets a repo-relative path when the project IS the ThumbGate checkout
205
+ * (dogfooding unpublished source still works — project MCP servers launch with cwd at the
206
+ * project root), and the portable npx launcher otherwise.
207
+ */
208
+ function relativeLocalMcpEntry(pkgRoot, targetDir) {
209
+ const rel = path.relative(targetDir, resolveLocalServerPath(pkgRoot, 'project'));
210
+ // Committed config must be separator-portable too.
211
+ return { command: 'node', args: [rel.split(path.sep).join('/')] };
212
+ }
213
+
196
214
  function resolveMcpEntry({ pkgRoot, pkgVersion, scope = 'project', targetDir = pkgRoot }) {
197
215
  if (!isSourceCheckout(pkgRoot)) {
198
216
  return codexAutoUpdateMcpEntry();
199
217
  }
200
- if (scope === 'home' && publishedCliAvailable(pkgVersion)) {
201
- return codexAutoUpdateMcpEntry();
218
+ if (scope === 'home') {
219
+ if (publishedCliAvailable(pkgVersion)) return codexAutoUpdateMcpEntry();
220
+ return localMcpEntry(pkgRoot, scope);
202
221
  }
203
- if (scope === 'project' && !isSameCheckoutFamily(pkgRoot, targetDir) && publishedCliAvailable(pkgVersion)) {
204
- return codexAutoUpdateMcpEntry();
222
+ // scope === 'project': this is going into shared, committed config.
223
+ if (isSameCheckoutFamily(pkgRoot, targetDir)) {
224
+ return relativeLocalMcpEntry(pkgRoot, targetDir);
205
225
  }
206
- return localMcpEntry(pkgRoot, scope);
226
+ return codexAutoUpdateMcpEntry();
207
227
  }
208
228
 
209
229
  module.exports = {
@@ -214,6 +234,7 @@ module.exports = {
214
234
  localMcpEntry,
215
235
  parseWorktreePaths,
216
236
  portableMcpEntry,
237
+ relativeLocalMcpEntry,
217
238
  resolveGitCommonDir,
218
239
  resolveLocalServerPath,
219
240
  resolveMcpEntry,