thumbgate 1.30.0 → 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 (77) 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 +66 -8
  7. package/adapters/opencode/opencode.json +1 -1
  8. package/bench/observability-eval-suite.json +2 -2
  9. package/bin/cli.js +147 -31
  10. package/config/evals/generation-quality-golden.json +95 -0
  11. package/config/evals/rag-answer-quality-golden.json +91 -0
  12. package/config/evals/retrieval-hybrid-ablation.json +66 -0
  13. package/config/evals/retrieval-ranking-golden.json +522 -0
  14. package/config/gates/default.json +217 -50
  15. package/config/mcp-allowlists.json +212 -206
  16. package/config/model-tiers.json +7 -2
  17. package/glama.json +6 -0
  18. package/package.json +60 -8
  19. package/public/assets/diagrams/before-after.svg +17 -16
  20. package/public/assets/diagrams/hero-thumbs.svg +68 -0
  21. package/public/assets/diagrams/loop.svg +19 -13
  22. package/public/assets/diagrams/self-improving-thumbs-loop.svg +105 -0
  23. package/public/compare.html +1 -0
  24. package/public/dashboard.html +126 -28
  25. package/public/evaluations.html +1 -1
  26. package/public/index.html +142 -13
  27. package/public/numbers.html +3 -2
  28. package/public/pricing.html +143 -30
  29. package/scripts/a-plus-evidence-scorecard.js +303 -0
  30. package/scripts/async-eval-observability.js +36 -11
  31. package/scripts/audit-trail.js +37 -1
  32. package/scripts/auto-promote-gates.js +149 -34
  33. package/scripts/colbert-style-maxsim.js +236 -0
  34. package/scripts/cross-encoder-reranker.js +356 -126
  35. package/scripts/dashboard-chat.js +350 -17
  36. package/scripts/document-intake.js +283 -7
  37. package/scripts/eval-quality-suite.js +204 -0
  38. package/scripts/feedback-loop.js +115 -7
  39. package/scripts/feedback-paths.js +32 -13
  40. package/scripts/feedback-quality.js +53 -0
  41. package/scripts/filesystem-search.js +17 -7
  42. package/scripts/gates-engine.js +17 -0
  43. package/scripts/harness-tool-names.js +70 -0
  44. package/scripts/hook-runtime.js +10 -3
  45. package/scripts/lesson-db.js +16 -5
  46. package/scripts/lesson-embedding-index.js +67 -20
  47. package/scripts/lesson-embedding-maintenance.js +177 -0
  48. package/scripts/lesson-reranker.js +55 -9
  49. package/scripts/lesson-retrieval.js +305 -29
  50. package/scripts/lesson-search.js +22 -8
  51. package/scripts/llm-client.js +304 -15
  52. package/scripts/model-tier-router.js +593 -0
  53. package/scripts/pragmatic-hybrid-search.js +379 -0
  54. package/scripts/rag-document-pipeline.js +461 -0
  55. package/scripts/rag-structured-output.js +441 -0
  56. package/scripts/ragas-style-metrics.js +351 -0
  57. package/scripts/request-envelope.js +178 -0
  58. package/scripts/rerank-pipeline.js +370 -0
  59. package/scripts/rerank-quality-eval.js +155 -0
  60. package/scripts/retrieval-hybrid-ablation.js +120 -0
  61. package/scripts/retrieval-quality-tier.js +118 -0
  62. package/scripts/secret-scanner.js +395 -4
  63. package/scripts/self-distill-agent.js +7 -1
  64. package/scripts/self-healing-check.js +25 -0
  65. package/scripts/skill-packs.js +183 -0
  66. package/scripts/slow-loop.js +72 -0
  67. package/scripts/statusline-links.js +1 -1
  68. package/scripts/statusline.sh +8 -1
  69. package/scripts/telemetry-analytics.js +13 -1
  70. package/scripts/thumbgate-search.js +98 -6
  71. package/scripts/tier-budget-guard.js +186 -0
  72. package/scripts/tool-registry.js +46 -0
  73. package/scripts/vector-store.js +108 -4
  74. package/scripts/verify-marketing-pages-deployed.js +85 -3
  75. package/server.json +44 -0
  76. package/smithery.yaml +17 -0
  77. package/src/api/server.js +194 -13
package/src/api/server.js CHANGED
@@ -210,7 +210,7 @@ const {
210
210
  getSettingsStatus,
211
211
  } = require('../../scripts/settings-hierarchy');
212
212
  const {
213
- searchThumbgate,
213
+ searchThumbgateAsync,
214
214
  } = require('../../scripts/thumbgate-search');
215
215
  const {
216
216
  appendTelemetryPing,
@@ -480,6 +480,25 @@ const TRACKED_LINK_TARGETS = Object.freeze({
480
480
  },
481
481
  allowCustomerEmail: true,
482
482
  },
483
+ // Human skip from Pro interstitial: same-origin hop so crawlers never see buy.stripe.com
484
+ // in the interstitial HTML (bot-guard contract). Bots are deflected to /checkout/pro.
485
+ 'pro-direct': {
486
+ fallbackHref: PRO_CHECKOUT_URL,
487
+ external: true,
488
+ botDeflectPath: '/checkout/pro',
489
+ ctaId: 'pro_checkout_direct_stripe',
490
+ ctaPlacement: 'checkout_interstitial',
491
+ eventType: 'pro_checkout_direct_stripe',
492
+ defaults: {
493
+ utm_source: 'website',
494
+ utm_medium: 'checkout_interstitial',
495
+ utm_campaign: 'pro_upgrade',
496
+ plan_id: 'pro',
497
+ billing_cycle: 'monthly',
498
+ },
499
+ allowCustomerEmail: true,
500
+ prefillStripeEmail: true,
501
+ },
483
502
  // 2026-06-02: Teams/Aiventyx deprecated. Redirect legacy links to Pro.
484
503
  teams: {
485
504
  path: '/go/pro?utm_source=legacy_teams&utm_medium=redirect',
@@ -837,6 +856,67 @@ function getRequestFeedbackPaths(req, parsed) {
837
856
  });
838
857
  }
839
858
 
859
+ const DATA_IDENTITY_CACHE = new Map();
860
+ const DATA_IDENTITY_CACHE_LIMIT = 2048;
861
+
862
+ function hashDataIdentity(prefix, value) {
863
+ const normalized = String(value || '');
864
+ const cacheKey = `${prefix}\0${normalized}`;
865
+ const cached = DATA_IDENTITY_CACHE.get(cacheKey);
866
+ if (cached) return cached;
867
+
868
+ // Billing identifiers can be email-like and API keys must never be made
869
+ // guessable through a fast digest. A memory-hard KDF gives us a stable,
870
+ // path-safe pseudonym without persisting the source identifier.
871
+ const derived = `${prefix}_${crypto.scryptSync(
872
+ normalized,
873
+ `thumbgate-${prefix}-data-identity-v1`,
874
+ 16,
875
+ { N: 16384, r: 8, p: 1 }
876
+ ).toString('hex').slice(0, 24)}`;
877
+
878
+ if (DATA_IDENTITY_CACHE.size >= DATA_IDENTITY_CACHE_LIMIT) {
879
+ DATA_IDENTITY_CACHE.clear();
880
+ }
881
+ DATA_IDENTITY_CACHE.set(cacheKey, derived);
882
+ return derived;
883
+ }
884
+
885
+ function buildHostedTenantIdentity(validation = {}) {
886
+ if (!validation || validation.valid !== true || !validation.customerId) return null;
887
+ const tenantSeed = validation.customerId;
888
+ const principalSeed = validation.installId || tenantSeed;
889
+ const tenantId = hashDataIdentity('tenant', tenantSeed);
890
+ return {
891
+ partition: tenantId,
892
+ accessContext: {
893
+ tenantId,
894
+ principalId: hashDataIdentity('principal', principalSeed),
895
+ isAdmin: false,
896
+ },
897
+ };
898
+ }
899
+
900
+ function resolveRequestDataIdentity(req, expectedApiKey) {
901
+ const token = extractApiKey(req);
902
+ if (!token || (expectedApiKey && safeKeyEqual(token, expectedApiKey))) {
903
+ return { partition: null, accessContext: null };
904
+ }
905
+ const validation = validateApiKey(token);
906
+ const identity = buildHostedTenantIdentity(validation);
907
+ if (validation.valid === true && !identity) {
908
+ return { partition: null, accessContext: null, invalidHostedIdentity: true };
909
+ }
910
+ return identity || { partition: null, accessContext: null };
911
+ }
912
+
913
+ function partitionFeedbackPaths(paths, dataIdentity) {
914
+ if (!dataIdentity?.partition) return paths;
915
+ return getFeedbackPaths({
916
+ feedbackDir: path.join(paths.FEEDBACK_DIR, 'tenants', dataIdentity.partition),
917
+ });
918
+ }
919
+
840
920
  function getSafeDataDir(req, parsed) {
841
921
  const { FEEDBACK_LOG_PATH } = getRequestFeedbackPaths(req, parsed);
842
922
  return path.resolve(path.dirname(FEEDBACK_LOG_PATH));
@@ -2501,8 +2581,9 @@ ${hiddenInputs}
2501
2581
  <p class="email-note">Required so the checkout has attributable buyer intent and your receipt can be recovered.</p>
2502
2582
  <button type="submit" class="primary">Pay $19/mo with Stripe →</button>
2503
2583
  </form>
2584
+ <a class="secondary" data-i="pro_checkout_direct_stripe" rel="nofollow" href="/go/pro-direct?utm_source=website&amp;utm_medium=checkout_interstitial&amp;utm_campaign=pro_upgrade&amp;utm_content=skip_form&amp;cta_id=pro_checkout_direct_stripe&amp;cta_placement=checkout_interstitial">Skip form — open Stripe checkout directly →</a>
2504
2585
  <a class="secondary" data-i="workflow_sprint_intake" href="/#workflow-sprint-intake">Not sure yet? Send the workflow first</a>
2505
- <p class="choice-note">Cancel anytime. 7-day refund, no questions. Diagnostics and sprints have their own pages.</p>
2586
+ <p class="choice-note">Cancel anytime. 7-day refund, no questions. Diagnostics and sprints have their own pages. Email path keeps receipts attributable; skip uses a same-origin hop that bot-deflects crawlers.</p>
2506
2587
  <div class="objection-box" aria-label="Checkout feedback">
2507
2588
  <strong>Not buying today?</strong>
2508
2589
  <p class="objection-note">Tap one reason so we know what to fix. This does not sign you up.</p>
@@ -2825,6 +2906,37 @@ function serveTrackedLinkRedirect({ req, res, parsed, hostedConfig, isHeadReques
2825
2906
 
2826
2907
  const { FEEDBACK_DIR } = getFeedbackPaths();
2827
2908
  const journeyState = resolveJourneyState(req, parsed);
2909
+ const botClassification = classifyRequester(req.headers);
2910
+ if (target.botDeflectPath && botClassification.isBot) {
2911
+ const deflectUrl = new URL(target.botDeflectPath, hostedConfig.appOrigin);
2912
+ appendTrackedLinkQueryParams(deflectUrl, parsed, target);
2913
+ if (!isHeadRequest) {
2914
+ appendBestEffortTelemetry(
2915
+ FEEDBACK_DIR,
2916
+ {
2917
+ eventType: 'checkout_bot_deflected',
2918
+ clientType: 'web',
2919
+ page: `/go/${target.slug}`,
2920
+ reason: botClassification.reason || 'bot',
2921
+ destinationSlug: target.slug,
2922
+ acquisitionId: journeyState.acquisitionId,
2923
+ visitorId: journeyState.visitorId,
2924
+ sessionId: journeyState.sessionId,
2925
+ },
2926
+ req.headers,
2927
+ `tracked_link_bot_deflect:${target.slug}`
2928
+ );
2929
+ }
2930
+ res.writeHead(302, {
2931
+ ...(journeyState.setCookieHeaders.length ? { 'Set-Cookie': journeyState.setCookieHeaders } : {}),
2932
+ 'Cache-Control': 'no-store',
2933
+ 'X-Robots-Tag': 'noindex,nofollow',
2934
+ 'X-ThumbGate-Link-Slug': target.slug,
2935
+ Location: deflectUrl.toString(),
2936
+ });
2937
+ res.end();
2938
+ return;
2939
+ }
2828
2940
  const destinationUrl = buildTrackedLinkDestination(target, hostedConfig, parsed);
2829
2941
  const attribution = buildTrackedLinkAttribution(target, parsed, req, journeyState, destinationUrl);
2830
2942
  if (target.external) {
@@ -5224,9 +5336,10 @@ function createApiServer() {
5224
5336
  sendJson(res, 403, { error: 'project selection is only available on localhost requests' });
5225
5337
  return;
5226
5338
  }
5227
- const requestFeedbackPaths = getRequestFeedbackPaths(req, parsed);
5228
- const requestFeedbackDir = requestFeedbackPaths.FEEDBACK_DIR;
5229
- const requestSafeDataDir = getSafeDataDir(req, parsed);
5339
+ let requestFeedbackPaths = getRequestFeedbackPaths(req, parsed);
5340
+ let requestFeedbackDir = requestFeedbackPaths.FEEDBACK_DIR;
5341
+ let requestSafeDataDir = getSafeDataDir(req, parsed);
5342
+ let requestDocumentAccessContext = null;
5230
5343
 
5231
5344
  // PostHog reverse proxy -- bypasses ad blockers.
5232
5345
  // Only allow known PostHog API paths to prevent SSRF (CodeQL js/request-forgery).
@@ -8645,6 +8758,25 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
8645
8758
  return;
8646
8759
  }
8647
8760
 
8761
+ // Provisioned hosted keys are authenticated identities, not merely valid
8762
+ // booleans. Bind every private data path to a hash of the billing customer
8763
+ // before any endpoint reads or writes feedback, documents, traces, or jobs.
8764
+ // Static admin/local operation retains the existing single-tenant path.
8765
+ const requestDataIdentity = resolveRequestDataIdentity(req, expectedApiKey);
8766
+ if (requestDataIdentity.invalidHostedIdentity) {
8767
+ sendProblem(res, {
8768
+ type: PROBLEM_TYPES.UNAUTHORIZED,
8769
+ title: 'Unauthorized',
8770
+ status: 403,
8771
+ detail: 'The hosted API key is not bound to a customer identity.',
8772
+ });
8773
+ return;
8774
+ }
8775
+ requestFeedbackPaths = partitionFeedbackPaths(requestFeedbackPaths, requestDataIdentity);
8776
+ requestFeedbackDir = requestFeedbackPaths.FEEDBACK_DIR;
8777
+ requestSafeDataDir = path.resolve(requestFeedbackDir);
8778
+ requestDocumentAccessContext = requestDataIdentity.accessContext;
8779
+
8648
8780
  // Usage metering — record request for billing keys (not static THUMBGATE_API_KEY)
8649
8781
  const _token = extractBearerToken(req);
8650
8782
  if (_token && _token !== expectedApiKey) {
@@ -8665,6 +8797,33 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
8665
8797
  stats.tier = 'Pro';
8666
8798
  }
8667
8799
 
8800
+ // Active gates for the dashboard hero cards. Connect only fetches
8801
+ // /v1/feedback/stats first; without these fields Active Gates stays
8802
+ // stuck on the HTML placeholder "—" until a later /v1/dashboard call
8803
+ // succeeds (and never updates if that call fails).
8804
+ try {
8805
+ const { computeGateStats } = require('../../scripts/dashboard');
8806
+ const gateStats = computeGateStats();
8807
+ stats.activeGateCount = Number(gateStats.totalGates) || 0;
8808
+ stats.gateStats = {
8809
+ totalGates: Number(gateStats.totalGates) || 0,
8810
+ manualCount: Number(gateStats.manualCount) || 0,
8811
+ autoCount: Number(gateStats.autoCount) || 0,
8812
+ blocked: Number(gateStats.blocked) || 0,
8813
+ warned: Number(gateStats.warned) || 0,
8814
+ };
8815
+ } catch (error) {
8816
+ debugApiFallback('gate stats unavailable for feedback/stats', error);
8817
+ stats.activeGateCount = 0;
8818
+ stats.gateStats = {
8819
+ totalGates: 0,
8820
+ manualCount: 0,
8821
+ autoCount: 0,
8822
+ blocked: 0,
8823
+ warned: 0,
8824
+ };
8825
+ }
8826
+
8668
8827
  const projectChatSettings = readProjectChatSettings(req, parsed);
8669
8828
 
8670
8829
  stats.geminiConfigured = Boolean(
@@ -9364,13 +9523,19 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
9364
9523
  const signal = parsed.searchParams.get('signal') || null;
9365
9524
  let results;
9366
9525
  try {
9367
- const requestFeedbackPaths = getRequestFeedbackPaths(req, parsed);
9368
- results = searchThumbgate({
9526
+ results = await searchThumbgateAsync({
9369
9527
  query,
9370
9528
  limit: Number.isFinite(limit) ? limit : 10,
9371
9529
  source,
9372
9530
  signal,
9373
- feedbackDir: requestFeedbackPaths.FEEDBACK_DIR,
9531
+ feedbackDir: requestFeedbackDir,
9532
+ metadataFilters: {
9533
+ tags: (parsed.searchParams.get('tags') || '').split(',').map((tag) => tag.trim()).filter(Boolean),
9534
+ sourceFormat: parsed.searchParams.get('sourceFormat') || undefined,
9535
+ sourceType: parsed.searchParams.get('sourceType') || undefined,
9536
+ },
9537
+ queryRewrite: parsed.searchParams.get('queryRewrite') !== 'false',
9538
+ accessContext: requestDocumentAccessContext,
9374
9539
  });
9375
9540
  } catch (err) {
9376
9541
  throw createHttpError(400, err.message || 'Invalid ThumbGate search request');
@@ -9388,13 +9553,15 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
9388
9553
  const body = await parseJsonBody(req);
9389
9554
  let results;
9390
9555
  try {
9391
- const requestFeedbackPaths = getRequestFeedbackPaths(req, parsed);
9392
- results = searchThumbgate({
9556
+ results = await searchThumbgateAsync({
9393
9557
  query: body.query || body.q || '',
9394
9558
  limit: body.limit,
9395
9559
  source: body.source,
9396
9560
  signal: body.signal,
9397
- feedbackDir: requestFeedbackPaths.FEEDBACK_DIR,
9561
+ feedbackDir: requestFeedbackDir,
9562
+ metadataFilters: body.filters,
9563
+ queryRewrite: body.queryRewrite !== false,
9564
+ accessContext: requestDocumentAccessContext,
9398
9565
  });
9399
9566
  } catch (err) {
9400
9567
  throw createHttpError(400, err.message || 'Invalid ThumbGate search request');
@@ -9412,6 +9579,7 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
9412
9579
  limit: Number.isFinite(limit) ? limit : 20,
9413
9580
  query,
9414
9581
  tag,
9582
+ accessContext: requestDocumentAccessContext,
9415
9583
  });
9416
9584
  sendJson(res, 200, results);
9417
9585
  return;
@@ -9425,6 +9593,7 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
9425
9593
  }
9426
9594
  const document = readImportedDocument(documentId, {
9427
9595
  feedbackDir: requestFeedbackDir,
9596
+ accessContext: requestDocumentAccessContext,
9428
9597
  });
9429
9598
  if (!document) {
9430
9599
  throw createHttpError(404, `Imported document not found: ${escapeHtml(documentId)}`);
@@ -9557,6 +9726,11 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
9557
9726
  tags: extractTags(body.tags),
9558
9727
  proposeGates: body.proposeGates !== false,
9559
9728
  feedbackDir: requestFeedbackDir,
9729
+ accessContext: requestDocumentAccessContext,
9730
+ visibility: body.visibility === 'private' ? 'private' : 'tenant',
9731
+ allowedPrincipals: Array.isArray(body.allowedPrincipals)
9732
+ ? body.allowedPrincipals.map((value) => String(value || '').trim()).filter(Boolean)
9733
+ : [],
9560
9734
  });
9561
9735
  sendJson(res, 201, {
9562
9736
  ok: true,
@@ -9607,14 +9781,19 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
9607
9781
  fs.writeFileSync(paths.outputPath, result.jsonl);
9608
9782
  }
9609
9783
 
9610
- sendJson(res, 200, {
9784
+ const response = {
9611
9785
  pairs: result.pairs.length,
9786
+ pairCount: result.pairs.length,
9612
9787
  errors: result.errors.length,
9613
9788
  learnings: result.learnings.length,
9614
9789
  unpairedErrors: result.unpairedErrors.length,
9615
9790
  unpairedLearnings: result.unpairedLearnings.length,
9616
9791
  outputPath: paths.outputPath,
9617
- });
9792
+ };
9793
+ if (body.includePairs === true) {
9794
+ response.records = result.pairs;
9795
+ }
9796
+ sendJson(res, 200, response);
9618
9797
  return;
9619
9798
  }
9620
9799
 
@@ -10640,6 +10819,8 @@ module.exports = {
10640
10819
  buildLossAnalyticsResponse,
10641
10820
  buildIntakeAlertRateLimitKey,
10642
10821
  sanitizeHtmlUnsafeJsonValue,
10822
+ buildHostedTenantIdentity,
10823
+ partitionFeedbackPaths,
10643
10824
  },
10644
10825
  };
10645
10826