thumbgate 1.27.18 → 1.27.20

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 (170) hide show
  1. package/.claude/commands/dashboard.md +15 -0
  2. package/.claude/commands/thumbgate-blocked.md +27 -0
  3. package/.claude/commands/thumbgate-dashboard.md +15 -0
  4. package/.claude/commands/thumbgate-doctor.md +30 -0
  5. package/.claude/commands/thumbgate-guard.md +36 -0
  6. package/.claude/commands/thumbgate-protect.md +30 -0
  7. package/.claude/commands/thumbgate-rules.md +30 -0
  8. package/.claude-plugin/plugin.json +4 -3
  9. package/.well-known/agentic-verify.txt +1 -0
  10. package/.well-known/llms.txt +33 -12
  11. package/.well-known/mcp/server-card.json +8 -8
  12. package/README.md +246 -30
  13. package/adapters/claude/.mcp.json +2 -2
  14. package/adapters/codex/config.toml +2 -2
  15. package/adapters/gcp/dfcx-webhook-gate.js +295 -0
  16. package/adapters/gemini/function-declarations.json +1 -0
  17. package/adapters/letta/README.md +41 -0
  18. package/adapters/letta/thumbgate-letta-adapter.js +133 -0
  19. package/adapters/mcp/server-stdio.js +263 -11
  20. package/adapters/opencode/opencode.json +1 -1
  21. package/adapters/policy-engine/ethicore-guardian-client.js +68 -0
  22. package/adapters/policy-engine/thumbgate-policy-engine-adapter.js +260 -0
  23. package/bench/observability-eval-suite.json +26 -0
  24. package/bench/thumbgate-bench.json +2 -2
  25. package/bin/cli.js +1456 -122
  26. package/bin/dashboard-cli.js +7 -0
  27. package/bin/postinstall.js +11 -27
  28. package/commands/dashboard.md +15 -0
  29. package/commands/thumbgate-dashboard.md +15 -0
  30. package/config/gate-classifier-routing.json +98 -0
  31. package/config/gate-templates.json +216 -0
  32. package/config/gates/claim-verification.json +12 -0
  33. package/config/gates/default.json +31 -2
  34. package/config/github-about.json +2 -2
  35. package/config/mcp-allowlists.json +23 -13
  36. package/config/merge-quality-checks.json +0 -1
  37. package/config/model-candidates.json +121 -6
  38. package/config/post-deploy-marketing-pages.json +80 -0
  39. package/config/tessl-tiles.json +1 -3
  40. package/openapi/openapi.yaml +12 -0
  41. package/package.json +225 -100
  42. package/public/about.html +162 -0
  43. package/public/agent-manager.html +179 -0
  44. package/public/agents-cost-savings.html +153 -0
  45. package/public/ai-malpractice-prevention.html +818 -0
  46. package/public/assets/brand/github-social-preview.png +0 -0
  47. package/public/assets/brand/thumbgate-icon-512.png +0 -0
  48. package/public/assets/brand/thumbgate-icon-pro-512.png +0 -0
  49. package/public/assets/brand/thumbgate-icon-team-512.png +0 -0
  50. package/public/assets/brand/thumbgate-logo-1200x360.png +0 -0
  51. package/public/assets/brand/thumbgate-logo-transparent.svg +28 -0
  52. package/public/assets/brand/thumbgate-mark-inline-v3.svg +18 -0
  53. package/public/assets/brand/thumbgate-mark-pro.svg +23 -0
  54. package/public/assets/brand/thumbgate-mark-team.svg +26 -0
  55. package/public/assets/brand/thumbgate-mark.svg +21 -0
  56. package/public/assets/brand/thumbgate-wordmark.svg +20 -0
  57. package/public/assets/claude-thumbgate-statusbar.svg +8 -0
  58. package/public/assets/codex-thumbgate-statusbar-test.svg +9 -0
  59. package/public/assets/legal-intake-control-flow.svg +66 -0
  60. package/public/blog.html +4 -4
  61. package/public/brand/thumbgate-mark.svg +19 -0
  62. package/public/brand/thumbgate-og.svg +16 -0
  63. package/public/chatgpt-app.html +330 -0
  64. package/public/codex-enterprise.html +123 -0
  65. package/public/codex-plugin.html +72 -20
  66. package/public/compare.html +31 -8
  67. package/public/dashboard.html +930 -166
  68. package/public/diagnostic.html +345 -0
  69. package/public/federal.html +2 -2
  70. package/public/guide.html +33 -13
  71. package/public/index.html +469 -111
  72. package/public/install.html +193 -0
  73. package/public/js/buyer-intent.js +672 -0
  74. package/public/learn.html +183 -18
  75. package/public/lessons.html +168 -10
  76. package/public/numbers.html +7 -7
  77. package/public/pricing.html +399 -0
  78. package/public/pro.html +34 -11
  79. package/scripts/action-receipts.js +324 -0
  80. package/scripts/activation-quickstart.js +187 -0
  81. package/scripts/agent-memory-lifecycle.js +211 -0
  82. package/scripts/agent-operations-planner.js +621 -0
  83. package/scripts/agent-readiness.js +20 -3
  84. package/scripts/agent-reward-model.js +53 -1
  85. package/scripts/ai-component-inventory.js +367 -0
  86. package/scripts/async-eval-observability.js +236 -0
  87. package/scripts/audit.js +65 -0
  88. package/scripts/auto-promote-gates.js +82 -10
  89. package/scripts/auto-wire-hooks.js +14 -0
  90. package/scripts/aws-blocks-guardrails.js +272 -0
  91. package/scripts/billing.js +93 -1
  92. package/scripts/bot-detection.js +61 -3
  93. package/scripts/build-metadata.js +50 -10
  94. package/scripts/classifier-routing.js +130 -0
  95. package/scripts/cli-feedback.js +4 -2
  96. package/scripts/cli-schema.js +97 -0
  97. package/scripts/cli-telemetry.js +6 -1
  98. package/scripts/commercial-offer.js +82 -2
  99. package/scripts/context-manager.js +74 -6
  100. package/scripts/dashboard-chat.js +332 -0
  101. package/scripts/dashboard.js +68 -2
  102. package/scripts/export-databricks-bundle.js +5 -1
  103. package/scripts/export-dpo-pairs.js +7 -2
  104. package/scripts/feedback-aggregate.js +281 -0
  105. package/scripts/feedback-loop.js +123 -1
  106. package/scripts/feedback-quality.js +87 -0
  107. package/scripts/feedback-sanitizer.js +105 -0
  108. package/scripts/filesystem-search.js +35 -10
  109. package/scripts/gate-stats.js +89 -0
  110. package/scripts/gates-engine.js +1176 -85
  111. package/scripts/gemini-embedding-policy.js +2 -1
  112. package/scripts/hook-runtime.js +20 -14
  113. package/scripts/hook-stop-anti-claim.js +301 -0
  114. package/scripts/hook-thumbgate-cache-updater.js +18 -2
  115. package/scripts/hybrid-feedback-context.js +142 -7
  116. package/scripts/install-shim.js +87 -0
  117. package/scripts/lesson-inference.js +8 -3
  118. package/scripts/lesson-search.js +17 -1
  119. package/scripts/license.js +10 -10
  120. package/scripts/llm-client.js +169 -4
  121. package/scripts/local-model-profile.js +15 -8
  122. package/scripts/mcp-config.js +7 -1
  123. package/scripts/mcp-oauth.js +293 -0
  124. package/scripts/memory-scope-readiness.js +159 -0
  125. package/scripts/meta-agent-loop.js +36 -0
  126. package/scripts/noop-detect.js +285 -0
  127. package/scripts/operational-integrity.js +39 -5
  128. package/scripts/oss-pr-opportunity-scout.js +35 -5
  129. package/scripts/parallel-workflow-orchestrator.js +293 -0
  130. package/scripts/plan-gate.js +243 -0
  131. package/scripts/plausible-domain-config.js +99 -0
  132. package/scripts/plausible-server-events.js +9 -6
  133. package/scripts/pro-local-dashboard.js +4 -4
  134. package/scripts/proxy-pointer-rag-guardrails.js +42 -1
  135. package/scripts/published-cli.js +0 -8
  136. package/scripts/qa-scenario-planner.js +136 -0
  137. package/scripts/rate-limiter.js +64 -13
  138. package/scripts/repeat-metric.js +137 -0
  139. package/scripts/secret-fixture-tokens.js +61 -0
  140. package/scripts/secret-redaction.js +166 -0
  141. package/scripts/secret-scanner.js +44 -5
  142. package/scripts/security-scanner.js +260 -10
  143. package/scripts/self-distill-agent.js +3 -1
  144. package/scripts/self-harness-optimizer.js +141 -0
  145. package/scripts/self-healing-check.js +193 -0
  146. package/scripts/self-protection.js +90 -0
  147. package/scripts/seo-gsd.js +916 -7
  148. package/scripts/silent-failure-cluster.js +531 -0
  149. package/scripts/statusline-cache-path.js +17 -2
  150. package/scripts/statusline-cache-read.js +57 -0
  151. package/scripts/statusline-local-stats.js +9 -1
  152. package/scripts/statusline-meta.js +28 -2
  153. package/scripts/statusline.sh +20 -4
  154. package/scripts/sync-telemetry-from-prod.js +374 -0
  155. package/scripts/telemetry-analytics.js +357 -0
  156. package/scripts/thompson-sampling.js +31 -10
  157. package/scripts/thumbgate-bench.js +16 -1
  158. package/scripts/thumbgate-search.js +85 -19
  159. package/scripts/tool-contract-validator.js +76 -0
  160. package/scripts/tool-registry.js +169 -1
  161. package/scripts/trajectory-scorer.js +63 -0
  162. package/scripts/vector-store.js +45 -0
  163. package/scripts/verify-marketing-pages-deployed.js +212 -0
  164. package/scripts/visitor-journey.js +172 -0
  165. package/scripts/workflow-sentinel.js +286 -53
  166. package/scripts/workspace-evolver.js +62 -2
  167. package/src/api/server.js +2683 -319
  168. package/.claude-plugin/marketplace.json +0 -85
  169. package/adapters/chatgpt/openapi.yaml +0 -1695
  170. package/scripts/bot-detector.js +0 -50
@@ -75,6 +75,13 @@ function normalizeInteger(value) {
75
75
  return Number.isFinite(parsed) ? Math.trunc(parsed) : null;
76
76
  }
77
77
 
78
+ function normalizeRate(value) {
79
+ if (value === undefined || value === null || value === '') return null;
80
+ const parsed = Number(value);
81
+ if (!Number.isFinite(parsed)) return null;
82
+ return Math.max(0, Math.min(parsed, 1));
83
+ }
84
+
78
85
  function safeRate(num, den) {
79
86
  if (!den) return 0;
80
87
  return Number((num / den).toFixed(4));
@@ -255,6 +262,149 @@ function inferTrafficChannel(raw = {}, referrerHost = null) {
255
262
  return 'referral';
256
263
  }
257
264
 
265
+ function normalizeBoolean(value) {
266
+ if (value === true || value === false) return value;
267
+ const text = normalizeText(value, 32);
268
+ if (!text) return false;
269
+ return ['1', 'true', 'yes', 'y', 'bot'].includes(text.toLowerCase());
270
+ }
271
+
272
+ function includesAnyToken(values, tokens) {
273
+ const haystack = values
274
+ .map((value) => normalizeText(value, 512))
275
+ .filter(Boolean)
276
+ .join(' ')
277
+ .toLowerCase();
278
+ if (!haystack) return false;
279
+ return tokens.some((token) => haystack.includes(token));
280
+ }
281
+
282
+ function addAudienceReason(reasons, code) {
283
+ if (!reasons.includes(code)) reasons.push(code);
284
+ }
285
+
286
+ function classifyTelemetryAudience(entry = {}, raw = {}) {
287
+ const reasons = [];
288
+ const identityValues = [
289
+ raw.email,
290
+ raw.customerEmail,
291
+ raw.buyerEmail,
292
+ raw.userEmail,
293
+ entry.visitorId,
294
+ entry.sessionId,
295
+ entry.traceId,
296
+ entry.acquisitionId,
297
+ entry.installId,
298
+ ];
299
+ const attributionValues = [
300
+ entry.source,
301
+ entry.utmSource,
302
+ entry.utmMedium,
303
+ entry.utmCampaign,
304
+ entry.utmContent,
305
+ entry.creator,
306
+ entry.offerCode,
307
+ entry.campaignVariant,
308
+ entry.referrerHost,
309
+ ];
310
+ const eventValues = [
311
+ entry.eventType,
312
+ entry.event,
313
+ entry.page,
314
+ entry.landingPath,
315
+ entry.ctaId,
316
+ entry.reasonCode,
317
+ entry.reasonDetail,
318
+ ];
319
+ const userAgent = normalizeText(entry.userAgent || raw.userAgent, 512) || '';
320
+ const hostValues = [raw.host, raw.hostname, raw.origin, raw.url, raw.pageUrl, entry.referrerHost];
321
+
322
+ if (normalizeBoolean(entry.isBot || raw.isBot) || includesAnyToken([userAgent], [
323
+ 'bot',
324
+ 'crawler',
325
+ 'spider',
326
+ 'headless',
327
+ 'playwright',
328
+ 'puppeteer',
329
+ 'curl/',
330
+ 'wget/',
331
+ 'lighthouse',
332
+ ])) {
333
+ addAudienceReason(reasons, 'bot_or_automation_user_agent');
334
+ }
335
+
336
+ if (includesAnyToken(identityValues, [
337
+ '@example.com',
338
+ 'buyer@example.com',
339
+ 'test@example.com',
340
+ 'codex-verification',
341
+ 'audit@thumbgate.ai',
342
+ ])) {
343
+ addAudienceReason(reasons, 'test_identity');
344
+ }
345
+
346
+ if (includesAnyToken([...attributionValues, ...eventValues], [
347
+ 'codex',
348
+ 'audit',
349
+ 'verification',
350
+ 'synthetic',
351
+ 'smoke',
352
+ 'probe',
353
+ 'test',
354
+ ])) {
355
+ addAudienceReason(reasons, 'test_or_audit_attribution');
356
+ }
357
+
358
+ if (includesAnyToken(hostValues, [
359
+ 'localhost',
360
+ '127.0.0.1',
361
+ '::1',
362
+ 'thumbgate-production.up.railway.app',
363
+ ])) {
364
+ addAudienceReason(reasons, 'internal_host');
365
+ }
366
+
367
+ if (includesAnyToken([userAgent, ...identityValues], [
368
+ 'codex',
369
+ 'github-actions',
370
+ 'node-fetch',
371
+ 'thumbgate-analytics',
372
+ 'thumbgate-verification',
373
+ ])) {
374
+ addAudienceReason(reasons, 'internal_operator_or_ci');
375
+ }
376
+
377
+ if (
378
+ (entry.clientType || entry.client) === 'unknown' &&
379
+ !pickFirstText(entry.visitorId, entry.sessionId, entry.acquisitionId, entry.installId, entry.traceId) &&
380
+ !userAgent &&
381
+ includesAnyToken([entry.source, entry.utmSource], ['direct', 'website'])
382
+ ) {
383
+ addAudienceReason(reasons, 'unknown_direct_no_identity');
384
+ }
385
+
386
+ const audience = reasons.includes('test_identity') || reasons.includes('test_or_audit_attribution')
387
+ ? 'test'
388
+ : (reasons.includes('bot_or_automation_user_agent')
389
+ ? 'bot'
390
+ : (
391
+ reasons.includes('internal_host') ||
392
+ reasons.includes('internal_operator_or_ci') ||
393
+ reasons.includes('unknown_direct_no_identity')
394
+ ? 'internal'
395
+ : 'external'
396
+ ));
397
+
398
+ return {
399
+ audience,
400
+ reasons,
401
+ isExternal: audience === 'external',
402
+ isInternal: audience === 'internal',
403
+ isTest: audience === 'test',
404
+ isBot: audience === 'bot',
405
+ };
406
+ }
407
+
258
408
  function sanitizeTelemetryPayload(payload = {}, headers = {}) {
259
409
  const raw = payload && typeof payload === 'object' ? payload : {};
260
410
  const clientType = inferClientType(raw);
@@ -313,6 +463,12 @@ function sanitizeTelemetryPayload(payload = {}, headers = {}) {
313
463
  pipelineStatus: pickFirstText(raw.pipelineStatus, raw.workflowSprintStatus, raw.status),
314
464
  reasonCode,
315
465
  reasonDetail: pickFirstText(raw.reasonDetail, raw.reasonText, raw.otherReason, raw.notes),
466
+ integration: pickFirstText(raw.integration, raw.actionIntegration),
467
+ actionOperation: pickFirstText(raw.actionOperation, raw.operationId),
468
+ endpoint: pickFirstText(raw.endpoint, raw.apiPath),
469
+ decisionMode: pickFirstText(raw.decisionMode, raw.executionMode),
470
+ actionStatus: pickFirstText(raw.actionStatus, raw.status),
471
+ accepted: raw.accepted === undefined || raw.accepted === null ? null : Boolean(raw.accepted),
316
472
  pricingInterest: pickFirstText(raw.pricingInterest, raw.interestLevel),
317
473
  seoQuery: pickFirstText(raw.seoQuery, raw.query),
318
474
  seoSurface: pickFirstText(raw.seoSurface, raw.searchSurface, raw.surface),
@@ -331,11 +487,22 @@ function sanitizeTelemetryPayload(payload = {}, headers = {}) {
331
487
  httpStatus: normalizeInteger(raw.httpStatus),
332
488
  userAgent: pickFirstText(raw.userAgent, headers['user-agent']),
333
489
  isBot: pickFirstText(raw.isBot),
490
+ interstitialSampled: pickFirstText(raw.interstitialSampled),
491
+ interstitialSampleRate: normalizeRate(raw.interstitialSampleRate),
334
492
  attributionTagged: Boolean(
335
493
  pickFirstText(raw.utmSource, raw.utmMedium, raw.utmCampaign, raw.utmContent, raw.utmTerm)
336
494
  ),
337
495
  };
338
496
 
497
+ const audience = classifyTelemetryAudience(entry, raw);
498
+ entry.audience = audience.audience;
499
+ entry.trafficAudience = audience.audience;
500
+ entry.audienceReasons = audience.reasons;
501
+ entry.isExternal = audience.isExternal;
502
+ entry.isInternal = audience.isInternal;
503
+ entry.isTest = audience.isTest;
504
+ entry.isBotTraffic = audience.isBot;
505
+
339
506
  return entry;
340
507
  }
341
508
 
@@ -426,9 +593,162 @@ function summarizeRecentEvents(events) {
426
593
  community: entry.community || null,
427
594
  offerCode: entry.offerCode || null,
428
595
  campaignVariant: entry.campaignVariant || null,
596
+ audience: entry.audience || 'external',
597
+ audienceReasons: entry.audienceReasons || [],
429
598
  }));
430
599
  }
431
600
 
601
+ function summarizeExternalTrafficQuality(events) {
602
+ const byAudience = {};
603
+ const byExclusionReason = {};
604
+ const externalVisitors = new Set();
605
+ const externalSessions = new Set();
606
+ const externalCheckoutStarters = new Set();
607
+ const pageViewsBySource = {};
608
+ const pageViewsByPath = {};
609
+ const pageViewsByTrafficChannel = {};
610
+ const checkoutStartsBySource = {};
611
+ const checkoutStartsByTrafficChannel = {};
612
+ const buyerLossReasons = {};
613
+ const visitorPaths = new Map();
614
+ let externalEvents = 0;
615
+ let excludedEvents = 0;
616
+ let externalWebEvents = 0;
617
+ let externalPageViews = 0;
618
+ let externalCtaClicks = 0;
619
+ let externalCheckoutStarts = 0;
620
+ let externalBuyerLossSignals = 0;
621
+
622
+ for (const entry of events) {
623
+ const audience = entry.audience || 'external';
624
+ incrementCounter(byAudience, audience);
625
+ if (audience !== 'external') {
626
+ excludedEvents += 1;
627
+ const reasons = Array.isArray(entry.audienceReasons) && entry.audienceReasons.length > 0
628
+ ? entry.audienceReasons
629
+ : [`${audience}_traffic`];
630
+ for (const reason of reasons) incrementCounter(byExclusionReason, reason);
631
+ continue;
632
+ }
633
+
634
+ externalEvents += 1;
635
+ if ((entry.clientType || entry.client) !== 'web') continue;
636
+
637
+ externalWebEvents += 1;
638
+ const visitorKey = pickFirstText(entry.visitorId, entry.installId, entry.sessionId);
639
+ if (visitorKey) externalVisitors.add(visitorKey);
640
+ if (entry.sessionId) externalSessions.add(entry.sessionId);
641
+
642
+ const eventType = entry.eventType || entry.event;
643
+ if (visitorKey) {
644
+ const pathRow = visitorPaths.get(visitorKey) || {
645
+ visitorKey,
646
+ firstSeenAt: entry.receivedAt || null,
647
+ lastSeenAt: entry.receivedAt || null,
648
+ source: entry.source || null,
649
+ trafficChannel: entry.trafficChannel || null,
650
+ events: [],
651
+ pages: [],
652
+ checkoutStarts: 0,
653
+ };
654
+ if (!pathRow.firstSeenAt || String(entry.receivedAt || '') < pathRow.firstSeenAt) {
655
+ pathRow.firstSeenAt = entry.receivedAt || null;
656
+ }
657
+ if (!pathRow.lastSeenAt || String(entry.receivedAt || '') > pathRow.lastSeenAt) {
658
+ pathRow.lastSeenAt = entry.receivedAt || null;
659
+ }
660
+ pathRow.events.push({
661
+ at: entry.receivedAt || null,
662
+ eventType,
663
+ page: entry.page || entry.landingPath || null,
664
+ ctaId: entry.ctaId || null,
665
+ });
666
+ if ((entry.page || entry.landingPath) && !pathRow.pages.includes(entry.page || entry.landingPath)) {
667
+ pathRow.pages.push(entry.page || entry.landingPath);
668
+ }
669
+ if (eventType === 'checkout_start' || eventType === 'checkout_bootstrap') {
670
+ pathRow.checkoutStarts += 1;
671
+ }
672
+ visitorPaths.set(visitorKey, pathRow);
673
+ }
674
+
675
+ if (eventType === 'landing_page_view') {
676
+ externalPageViews += 1;
677
+ incrementCounter(pageViewsBySource, entry.source);
678
+ incrementCounter(pageViewsByPath, entry.page);
679
+ incrementCounter(pageViewsByTrafficChannel, entry.trafficChannel);
680
+ }
681
+
682
+ if (isMarketingClickEvent(eventType)) {
683
+ externalCtaClicks += 1;
684
+ }
685
+
686
+ if (eventType === 'checkout_start' || eventType === 'checkout_bootstrap') {
687
+ externalCheckoutStarts += 1;
688
+ incrementCounter(checkoutStartsBySource, entry.source);
689
+ incrementCounter(checkoutStartsByTrafficChannel, entry.trafficChannel);
690
+ const starterKey = pickFirstText(
691
+ entry.acquisitionId,
692
+ entry.visitorId,
693
+ entry.sessionId,
694
+ entry.installId,
695
+ entry.traceId
696
+ );
697
+ if (starterKey) externalCheckoutStarters.add(starterKey);
698
+ }
699
+
700
+ if (eventType === 'checkout_cancelled' || eventType === 'checkout_abandoned' || eventType === 'reason_not_buying') {
701
+ externalBuyerLossSignals += 1;
702
+ incrementCounter(buyerLossReasons, entry.reasonCode);
703
+ }
704
+ }
705
+
706
+ const topPath = getTopCounterEntry(pageViewsByPath);
707
+ const topSource = getTopCounterEntry(pageViewsBySource);
708
+ const topTrafficChannel = getTopCounterEntry(pageViewsByTrafficChannel);
709
+ const topExclusionReason = getTopCounterEntry(byExclusionReason);
710
+
711
+ return {
712
+ rawEvents: events.length,
713
+ externalEvents,
714
+ excludedEvents,
715
+ internalEvents: byAudience.internal || 0,
716
+ testEvents: byAudience.test || 0,
717
+ botEvents: byAudience.bot || 0,
718
+ exclusionRate: safeRate(excludedEvents, events.length),
719
+ byAudience,
720
+ byExclusionReason,
721
+ topExclusionReason: topExclusionReason ? { key: topExclusionReason[0], count: topExclusionReason[1] } : null,
722
+ external: {
723
+ totalEvents: externalWebEvents,
724
+ uniqueVisitors: externalVisitors.size,
725
+ uniqueSessions: externalSessions.size,
726
+ uniqueCheckoutStarters: externalCheckoutStarters.size,
727
+ pageViews: externalPageViews,
728
+ ctaClicks: externalCtaClicks,
729
+ checkoutStarts: externalCheckoutStarts,
730
+ buyerLossSignals: externalBuyerLossSignals,
731
+ pageViewToCheckoutRate: safeRate(externalCheckoutStarts, externalPageViews),
732
+ visitorToCheckoutRate: safeRate(externalCheckoutStarts, externalVisitors.size),
733
+ bySource: pageViewsBySource,
734
+ byPath: pageViewsByPath,
735
+ byTrafficChannel: pageViewsByTrafficChannel,
736
+ checkoutStartsBySource,
737
+ checkoutStartsByTrafficChannel,
738
+ buyerLossReasons,
739
+ topSource: topSource ? { key: topSource[0], count: topSource[1] } : null,
740
+ topPath: topPath ? { key: topPath[0], count: topPath[1] } : null,
741
+ topTrafficChannel: topTrafficChannel ? { key: topTrafficChannel[0], count: topTrafficChannel[1] } : null,
742
+ visitorPaths: Array.from(visitorPaths.values())
743
+ .sort((a, b) => String(a.firstSeenAt || '').localeCompare(String(b.firstSeenAt || '')))
744
+ .slice(0, 50),
745
+ },
746
+ verdict: externalEvents > 0
747
+ ? (excludedEvents > externalEvents ? 'polluted_but_usable' : 'usable')
748
+ : (events.length > 0 ? 'polluted_no_external_signal' : 'missing'),
749
+ };
750
+ }
751
+
432
752
  function getTelemetrySummary(feedbackDir, options = {}) {
433
753
  const analyticsWindow = resolveAnalyticsWindow(options);
434
754
  const telemetryLoadOptions = analyticsWindow.bounded
@@ -439,6 +759,7 @@ function getTelemetrySummary(feedbackDir, options = {}) {
439
759
  analyticsWindow,
440
760
  (entry) => entry && (entry.receivedAt || entry.timestamp)
441
761
  );
762
+ const trafficQuality = summarizeExternalTrafficQuality(events);
442
763
  const byClientType = {};
443
764
  const byEventType = {};
444
765
  const webVisitors = new Set();
@@ -526,6 +847,12 @@ function getTelemetrySummary(feedbackDir, options = {}) {
526
847
  let exitEngagementMsCount = 0;
527
848
  let exitScrollPercentTotal = 0;
528
849
  let exitScrollPercentCount = 0;
850
+ let chatgptActionCalls = 0;
851
+ let chatgptActionAccepted = 0;
852
+ const chatgptActionsByOperation = {};
853
+ const chatgptActionsByEndpoint = {};
854
+ const chatgptActionsByStatus = {};
855
+ const chatgptActionsByDecisionMode = {};
529
856
 
530
857
  for (const entry of events) {
531
858
  incrementCounter(byClientType, entry.clientType || entry.client || 'unknown');
@@ -714,6 +1041,18 @@ function getTelemetrySummary(feedbackDir, options = {}) {
714
1041
  }
715
1042
  }
716
1043
 
1044
+ if (
1045
+ String(entry.eventType || entry.event || '').startsWith('chatgpt_action_') ||
1046
+ entry.integration === 'chatgpt_gpt'
1047
+ ) {
1048
+ chatgptActionCalls += 1;
1049
+ if (entry.accepted === true) chatgptActionAccepted += 1;
1050
+ incrementCounter(chatgptActionsByOperation, entry.actionOperation);
1051
+ incrementCounter(chatgptActionsByEndpoint, entry.endpoint);
1052
+ incrementCounter(chatgptActionsByStatus, entry.actionStatus);
1053
+ incrementCounter(chatgptActionsByDecisionMode, entry.decisionMode);
1054
+ }
1055
+
717
1056
  if ((entry.clientType || entry.client) === 'cli') {
718
1057
  if (entry.installId) cliInstalls.add(entry.installId);
719
1058
  incrementCounter(cliByPlatform, entry.platform);
@@ -764,12 +1103,14 @@ function getTelemetrySummary(feedbackDir, options = {}) {
764
1103
  window: serializeAnalyticsWindow(analyticsWindow),
765
1104
  totalEvents: events.length,
766
1105
  latestSeenAt,
1106
+ trafficQuality,
767
1107
  byClientType,
768
1108
  byEventType,
769
1109
  conversionFunnel: {
770
1110
  landingViews: pageViews,
771
1111
  installCopies,
772
1112
  gptOpens,
1113
+ gptActionCalls: chatgptActionCalls,
773
1114
  checkoutStarts,
774
1115
  checkoutInterstitialViews,
775
1116
  checkoutInterstitialClicks,
@@ -777,6 +1118,7 @@ function getTelemetrySummary(feedbackDir, options = {}) {
777
1118
  proConversions,
778
1119
  landingToInstallCopyRate: safeRate(installCopies, pageViews),
779
1120
  landingToGptOpenRate: safeRate(gptOpens, pageViews),
1121
+ gptOpenToActionRate: safeRate(chatgptActionCalls, gptOpens),
780
1122
  landingToCheckoutRate: safeRate(checkoutStarts, pageViews),
781
1123
  checkoutInterstitialClickRate: safeRate(checkoutInterstitialClicks, checkoutInterstitialViews),
782
1124
  checkoutInterstitialProConfirmRate: safeRate(checkoutInterstitialProConfirms, checkoutInterstitialViews),
@@ -835,6 +1177,17 @@ function getTelemetrySummary(feedbackDir, options = {}) {
835
1177
  byPlatform: cliByPlatform,
836
1178
  byVersion: cliByVersion,
837
1179
  },
1180
+ chatgpt: {
1181
+ gptOpens,
1182
+ actionCalls: chatgptActionCalls,
1183
+ acceptedActionCalls: chatgptActionAccepted,
1184
+ openToActionRate: safeRate(chatgptActionCalls, gptOpens),
1185
+ acceptedActionRate: safeRate(chatgptActionAccepted, chatgptActionCalls),
1186
+ byOperation: chatgptActionsByOperation,
1187
+ byEndpoint: chatgptActionsByEndpoint,
1188
+ byStatus: chatgptActionsByStatus,
1189
+ byDecisionMode: chatgptActionsByDecisionMode,
1190
+ },
838
1191
  marketing: {
839
1192
  pageViewsBySource,
840
1193
  pageViewsByCampaign,
@@ -926,9 +1279,12 @@ function getTelemetryAnalytics(feedbackDir, options = {}) {
926
1279
  window: summary.window,
927
1280
  totalEvents: summary.totalEvents,
928
1281
  latestSeenAt: summary.latestSeenAt,
1282
+ trafficQuality: summary.trafficQuality,
1283
+ qualified: summary.trafficQuality.external,
929
1284
  byClientType: summary.byClientType,
930
1285
  byEventType: summary.byEventType,
931
1286
  conversionFunnel: summary.conversionFunnel,
1287
+ chatgpt: summary.chatgpt,
932
1288
  visitors: {
933
1289
  totalEvents: summary.web.totalEvents,
934
1290
  uniqueVisitors: summary.web.uniqueVisitors,
@@ -1104,6 +1460,7 @@ const appendTelemetryPing = appendTelemetryEvent;
1104
1460
  module.exports = {
1105
1461
  TELEMETRY_FILE_NAME,
1106
1462
  sanitizeTelemetryPayload,
1463
+ classifyTelemetryAudience,
1107
1464
  appendTelemetryPing,
1108
1465
  appendTelemetryEvent,
1109
1466
  getTelemetrySourceDiagnostics,
@@ -302,24 +302,44 @@ function getCalibration(model) {
302
302
  // ---------------------------------------------------------------------------
303
303
 
304
304
  /**
305
- * Draw one sample from the Beta posterior for each category via the
306
- * Marsaglia-Tsang (2000) gamma ratio method. No external library needed.
305
+ * Return the Beta posterior parameters after applying Thompson temperature
306
+ * scaling. The posterior mean is preserved while precision changes:
307
+ * lower temperatures sharpen the posterior, higher temperatures flatten it.
307
308
  *
308
- * betaSample(alpha, beta) = gammaSample(alpha) / (gammaSample(alpha) + gammaSample(beta))
309
+ * @param {Object} params - Category posterior parameters
310
+ * @param {number} temperature - Scaling factor (default 1.0)
311
+ * @returns {{ alpha: number, beta: number }}
312
+ */
313
+ function getTemperatureScaledPosteriorParams(params, temperature = 1.0) {
314
+ const T = Math.max(0.01, Number(temperature) || 1.0);
315
+ const invT = 1.0 / T;
316
+ return {
317
+ alpha: Math.max(params.alpha * invT, 0.01),
318
+ beta: Math.max(params.beta * invT, 0.01),
319
+ };
320
+ }
321
+
322
+ /**
323
+ * Draw one sample from the Beta posterior for each category.
324
+ * Supports temperature scaling to adjust exploitation vs exploration.
309
325
  *
310
- * This is the JS equivalent of Python's random.betavariate(alpha, beta).
311
- * Used for Thompson Sampling action selection (explore via uncertainty).
326
+ * Temperature (T):
327
+ * T = 1.0 (default) Standard Thompson Sampling.
328
+ * T < 1.0 — Sharper distribution, favors high-reliability categories (exploit).
329
+ * T > 1.0 — Flatter distribution, increases uncertainty and exploration.
330
+ *
331
+ * Implementation: Scales alpha and beta by 1/T.
312
332
  *
313
333
  * @param {Object} model - Model object containing categories
334
+ * @param {number} temperature - Scaling factor (default 1.0)
314
335
  * @returns {Object} Map of category → float sample in [0, 1]
315
336
  */
316
- function samplePosteriors(model) {
337
+ function samplePosteriors(model, temperature = 1.0) {
317
338
  const samples = {};
339
+
318
340
  for (const [cat, params] of Object.entries(model.categories || {})) {
319
- samples[cat] = betaSample(
320
- Math.max(params.alpha, 0.01),
321
- Math.max(params.beta, 0.01),
322
- );
341
+ const { alpha, beta } = getTemperatureScaledPosteriorParams(params, temperature);
342
+ samples[cat] = betaSample(alpha, beta);
323
343
  }
324
344
  return samples;
325
345
  }
@@ -451,6 +471,7 @@ module.exports = {
451
471
  getReliability,
452
472
  isCalibrated,
453
473
  getCalibration,
474
+ getTemperatureScaledPosteriorParams,
454
475
  samplePosteriors,
455
476
  argmaxPosteriors,
456
477
  pickBestCategory,
@@ -4,6 +4,7 @@
4
4
  const fs = require('node:fs');
5
5
  const os = require('node:os');
6
6
  const path = require('node:path');
7
+ const { expandFixturePlaceholders } = require('./secret-fixture-tokens');
7
8
 
8
9
  const ROOT = path.join(__dirname, '..');
9
10
  const DEFAULT_SUITE_PATH = path.join(ROOT, 'bench', 'thumbgate-bench.json');
@@ -180,6 +181,20 @@ function assertObject(value, label) {
180
181
  }
181
182
  }
182
183
 
184
+ function expandScenarioFixturePlaceholders(value) {
185
+ if (typeof value === 'string') return expandFixturePlaceholders(value);
186
+ if (Array.isArray(value)) return value.map(expandScenarioFixturePlaceholders);
187
+ if (value && typeof value === 'object') {
188
+ return Object.fromEntries(
189
+ Object.entries(value).map(([key, nestedValue]) => [
190
+ key,
191
+ expandScenarioFixturePlaceholders(nestedValue),
192
+ ]),
193
+ );
194
+ }
195
+ return value;
196
+ }
197
+
183
198
  function loadScenarioSuite(filePath = DEFAULT_SUITE_PATH) {
184
199
  const suite = readJson(filePath);
185
200
  assertObject(suite, 'Scenario suite');
@@ -202,7 +217,7 @@ function loadScenarioSuite(filePath = DEFAULT_SUITE_PATH) {
202
217
  throw new Error(`Scenario ${id} has invalid expectedDecision`);
203
218
  }
204
219
  return {
205
- ...scenario,
220
+ ...expandScenarioFixturePlaceholders(scenario),
206
221
  id,
207
222
  unsafe: Boolean(scenario.unsafe),
208
223
  positivePattern: Boolean(scenario.positivePattern),
@@ -151,8 +151,67 @@ function sortResults(results) {
151
151
  });
152
152
  }
153
153
 
154
- function getFeedbackResults(query, limit, signal) {
155
- const results = searchFeedbackLog(query, Math.max(limit * 3, limit));
154
+ function extractFeedbackId(str) {
155
+ if (!str) return null;
156
+ const match = str.match(/fb[_-]\d+[_-][a-z0-9]+/i);
157
+ return match ? match[0].replace(/-/g, '_').toLowerCase() : null;
158
+ }
159
+
160
+ function deduplicateResults(results) {
161
+ const bestByFeedbackId = new Map();
162
+
163
+ for (const r of results) {
164
+ const feedId = extractFeedbackId(r.id || r.title || r.file || '');
165
+ if (feedId) {
166
+ const existing = bestByFeedbackId.get(feedId);
167
+ if (!existing) {
168
+ bestByFeedbackId.set(feedId, r);
169
+ } else {
170
+ const sourceOrder = { feedback: 4, contextfs: 3, prevention_rule: 2, document: 1 };
171
+ const existingOrder = sourceOrder[existing.source] || 0;
172
+ const currentOrder = sourceOrder[r.source] || 0;
173
+ if (currentOrder > existingOrder) {
174
+ bestByFeedbackId.set(feedId, r);
175
+ } else if (currentOrder === existingOrder && (r.score || 0) > (existing.score || 0)) {
176
+ bestByFeedbackId.set(feedId, r);
177
+ }
178
+ }
179
+ }
180
+ }
181
+
182
+ const finalResults = [];
183
+ const seenContent = new Set();
184
+ const seenIds = new Set();
185
+
186
+ for (const r of results) {
187
+ const feedId = extractFeedbackId(r.id || r.title || r.file || '');
188
+ let recordToUse = r;
189
+ if (feedId) {
190
+ recordToUse = bestByFeedbackId.get(feedId);
191
+ if (seenIds.has(recordToUse.id)) continue;
192
+ } else {
193
+ if (r.id && r.id !== 'null' && String(r.id).trim() !== '') {
194
+ if (seenIds.has(r.id)) continue;
195
+ }
196
+ }
197
+
198
+ const normTitle = String(recordToUse.title || '').trim().toLowerCase();
199
+ const normContext = String(recordToUse.context || '').trim().toLowerCase();
200
+ const contentKey = `${normTitle}|${normContext}`;
201
+ if (seenContent.has(contentKey)) continue;
202
+
203
+ if (recordToUse.id && recordToUse.id !== 'null' && String(recordToUse.id).trim() !== '') {
204
+ seenIds.add(recordToUse.id);
205
+ }
206
+ seenContent.add(contentKey);
207
+ finalResults.push(recordToUse);
208
+ }
209
+
210
+ return finalResults;
211
+ }
212
+
213
+ function getFeedbackResults(query, limit, signal, feedbackDir) {
214
+ const results = searchFeedbackLog(query, Math.max(limit * 3, limit), { feedbackDir });
156
215
  const normalizedSignal = normalizeSignal(signal);
157
216
  const filtered = normalizedSignal
158
217
  ? results.filter((record) => normalizeRecordSignal(record.signal) === normalizedSignal)
@@ -160,19 +219,19 @@ function getFeedbackResults(query, limit, signal) {
160
219
  return filtered.slice(0, limit).map(mapFeedbackResult);
161
220
  }
162
221
 
163
- function getContextResults(query, limit) {
164
- return searchContextFs(query, limit).map(mapContextResult);
222
+ function getContextResults(query, limit, feedbackDir) {
223
+ return searchContextFs(query, limit, { feedbackDir }).map(mapContextResult);
165
224
  }
166
225
 
167
- function getRuleResults(query, limit) {
168
- return searchPreventionRulesSync(query, limit).map(mapRuleResult);
226
+ function getRuleResults(query, limit, feedbackDir) {
227
+ return searchPreventionRulesSync(query, limit, { feedbackDir }).map(mapRuleResult);
169
228
  }
170
229
 
171
- function getDocumentResults(query, limit) {
172
- return searchImportedDocuments({ query, limit }).map(mapDocumentResult);
230
+ function getDocumentResults(query, limit, feedbackDir) {
231
+ return searchImportedDocuments({ query, limit, feedbackDir }).map(mapDocumentResult);
173
232
  }
174
233
 
175
- function searchThumbgate({ query, source = 'all', limit = 10, signal = null } = {}) {
234
+ function searchThumbgate({ query, source = 'all', limit = 10, signal = null, feedbackDir = null } = {}) {
176
235
  const trimmedQuery = String(query || '').trim();
177
236
  if (!trimmedQuery) {
178
237
  throw new Error('query is required');
@@ -182,22 +241,29 @@ function searchThumbgate({ query, source = 'all', limit = 10, signal = null } =
182
241
  const normalizedSignal = normalizeSignal(signal);
183
242
  const normalizedLimit = normalizeLimit(limit);
184
243
 
244
+ const fetchLimit = Math.max(100, normalizedLimit * 5);
245
+
185
246
  let results = [];
186
247
  if (normalizedSource === 'feedback') {
187
- results = getFeedbackResults(trimmedQuery, normalizedLimit, normalizedSignal);
248
+ const raw = getFeedbackResults(trimmedQuery, fetchLimit, normalizedSignal, feedbackDir);
249
+ results = deduplicateResults(raw).slice(0, normalizedLimit);
188
250
  } else if (normalizedSource === 'context') {
189
- results = getContextResults(trimmedQuery, normalizedLimit);
251
+ const raw = getContextResults(trimmedQuery, fetchLimit, feedbackDir);
252
+ results = deduplicateResults(raw).slice(0, normalizedLimit);
190
253
  } else if (normalizedSource === 'rules') {
191
- results = getRuleResults(trimmedQuery, normalizedLimit);
254
+ const raw = getRuleResults(trimmedQuery, fetchLimit, feedbackDir);
255
+ results = deduplicateResults(raw).slice(0, normalizedLimit);
192
256
  } else if (normalizedSource === 'documents') {
193
- results = getDocumentResults(trimmedQuery, normalizedLimit);
257
+ const raw = getDocumentResults(trimmedQuery, fetchLimit, feedbackDir);
258
+ results = deduplicateResults(raw).slice(0, normalizedLimit);
194
259
  } else {
195
- results = sortResults([
196
- ...getFeedbackResults(trimmedQuery, normalizedLimit, normalizedSignal),
197
- ...getContextResults(trimmedQuery, normalizedLimit),
198
- ...getRuleResults(trimmedQuery, normalizedLimit),
199
- ...getDocumentResults(trimmedQuery, normalizedLimit),
200
- ]).slice(0, normalizedLimit);
260
+ const combined = [
261
+ ...getFeedbackResults(trimmedQuery, fetchLimit, normalizedSignal, feedbackDir),
262
+ ...getContextResults(trimmedQuery, fetchLimit, feedbackDir),
263
+ ...getRuleResults(trimmedQuery, fetchLimit, feedbackDir),
264
+ ...getDocumentResults(trimmedQuery, fetchLimit, feedbackDir),
265
+ ];
266
+ results = deduplicateResults(sortResults(combined)).slice(0, normalizedLimit);
201
267
  }
202
268
 
203
269
  return {