thumbgate 1.27.2 → 1.27.3

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.
package/src/api/server.js CHANGED
@@ -678,6 +678,51 @@ function resolveRequestProjectDir(req, parsed) {
678
678
  });
679
679
  }
680
680
 
681
+ function debugApiFallback(label, error) {
682
+ if (process.env.THUMBGATE_DEBUG_API !== '1') return;
683
+ console.warn(`[api] ${label}: ${error?.message || String(error)}`);
684
+ }
685
+
686
+ function stripEnvQuotes(value) {
687
+ return String(value || '').trim().replace(/^["']|["']$/g, '');
688
+ }
689
+
690
+ function extractEnvValue(content, names) {
691
+ const pattern = new RegExp(`^(?:${names.join('|')})=(.*)$`, 'm');
692
+ const match = pattern.exec(content);
693
+ return match ? stripEnvQuotes(match[1]) : '';
694
+ }
695
+
696
+ function readProjectChatSettings(req, parsed) {
697
+ const settings = {
698
+ geminiKey: '',
699
+ perplexityKey: '',
700
+ localEndpoint: '',
701
+ localModel: '',
702
+ geminiValidatedAt: null,
703
+ };
704
+ try {
705
+ const projectDir = resolveRequestProjectDir(req, parsed);
706
+ const envPath = path.join(projectDir, '.env');
707
+ if (fs.existsSync(envPath)) {
708
+ const content = fs.readFileSync(envPath, 'utf8');
709
+ settings.geminiKey = extractEnvValue(content, ['GEMINI_API_KEY', 'GOOGLE_API_KEY', 'THUMBGATE_GEMINI_API_KEY']);
710
+ settings.perplexityKey = extractEnvValue(content, ['PERPLEXITY_API_KEY', 'THUMBGATE_PERPLEXITY_API_KEY']);
711
+ settings.localEndpoint = extractEnvValue(content, ['THUMBGATE_LOCAL_LLM_ENDPOINT']);
712
+ settings.localModel = extractEnvValue(content, ['THUMBGATE_LOCAL_LLM_MODEL']);
713
+ }
714
+
715
+ const statusPath = path.join(projectDir, '.gemini-validated.json');
716
+ if (fs.existsSync(statusPath)) {
717
+ const status = JSON.parse(fs.readFileSync(statusPath, 'utf8'));
718
+ settings.geminiValidatedAt = status.validatedAt || null;
719
+ }
720
+ } catch (error) {
721
+ debugApiFallback('project chat settings unavailable', error);
722
+ }
723
+ return settings;
724
+ }
725
+
681
726
  function shouldPreferProjectScopedFeedback(req, parsed) {
682
727
  const explicitProject = getEffectiveRequestedProjectSelection(req, parsed);
683
728
  if (explicitProject) return true;
@@ -1455,7 +1500,7 @@ async function loadLiveDashboardDataOrRespondProblem(res, parsed, feedbackDir, i
1455
1500
  }
1456
1501
  }
1457
1502
 
1458
- function buildEnterpriseDialogflowStatus(env = process.env) {
1503
+ function buildEnterpriseDataChatStatus(env = process.env) {
1459
1504
  const vertexProject = normalizeNullableText(env.VERTEX_PROJECT_ID)
1460
1505
  || normalizeNullableText(env.GOOGLE_VERTEX_PROJECT);
1461
1506
  const vertexLocation = normalizeNullableText(env.GOOGLE_VERTEX_LOCATION)
@@ -1487,12 +1532,16 @@ function buildEnterpriseDialogflowStatus(env = process.env) {
1487
1532
  },
1488
1533
  chat: {
1489
1534
  available: true,
1490
- source: 'local ThumbGate dashboard data',
1491
- guard: 'DFCX-compatible pre-action gate adapter',
1535
+ source: 'local ThumbGate dashboard data with LanceDB-backed retrieval',
1536
+ guard: 'local data-access guard; DFCX adapter optional for customer Dialogflow deployments',
1537
+ providerRequired: false,
1538
+ localLlmEndpointConfigured: Boolean(normalizeNullableText(env.THUMBGATE_LOCAL_LLM_ENDPOINT)),
1492
1539
  },
1493
1540
  };
1494
1541
  }
1495
1542
 
1543
+ const buildEnterpriseDialogflowStatus = buildEnterpriseDataChatStatus;
1544
+
1496
1545
  function normalizeEnterpriseChatPrompt(value) {
1497
1546
  const text = normalizeNullableText(value);
1498
1547
  if (!text) return null;
@@ -1518,60 +1567,116 @@ function compactNumber(value) {
1518
1567
  return Number.isFinite(n) ? n : 0;
1519
1568
  }
1520
1569
 
1521
- function buildEnterpriseChatAnswer(prompt, dashboardData, status) {
1522
- const topic = classifyEnterpriseChatTopic(prompt);
1570
+ function buildEnterpriseChatSection(topic, dashboardData, status) {
1523
1571
  const approval = dashboardData.approval || {};
1524
1572
  const gates = Array.isArray(dashboardData.gates) ? dashboardData.gates : [];
1525
1573
  const gateStats = dashboardData.gateStats || {};
1526
1574
  const team = dashboardData.team || {};
1527
1575
  const tokenSavings = dashboardData.tokenSavings || {};
1528
1576
  const lessonPipeline = dashboardData.lessonPipeline || {};
1529
- const lines = [];
1530
- const sources = ['local dashboard data'];
1531
1577
 
1532
1578
  if (topic === 'feedback') {
1533
- lines.push(`Feedback total: ${compactNumber(approval.total)} (${compactNumber(approval.positive)} positive, ${compactNumber(approval.negative)} negative).`);
1534
- lines.push(`Lesson pipeline: ${compactNumber(lessonPipeline.lessons || lessonPipeline.generated || 0)} lessons visible in the current dashboard snapshot.`);
1535
- sources.push('feedback log', 'lesson pipeline');
1536
- } else if (topic === 'gates') {
1537
- lines.push(`Active gates: ${gates.length || compactNumber(gateStats.totalGates)}.`);
1538
- lines.push(`Blocked actions recorded: ${compactNumber(gateStats.blocked || gateStats.denied || gateStats.totalBlocked)}.`);
1539
- if (gates[0]) lines.push(`Example gate: ${gates[0].name || gates[0].id || 'unnamed gate'}.`);
1540
- sources.push('gate stats');
1541
- } else if (topic === 'team') {
1542
- lines.push(`Team dashboard is available in this local Enterprise view.`);
1543
- lines.push(`Tracked agents: ${compactNumber(team.totalAgents || team.agentCount || 0)}; risky agents: ${compactNumber(team.riskyAgents || team.highRiskAgents || 0)}.`);
1544
- sources.push('team dashboard');
1545
- } else if (topic === 'cost') {
1546
- lines.push(`Estimated token savings: ${tokenSavings.dollarsSavedDisplay || '$0.00'} from ${compactNumber(tokenSavings.blockedCalls)} blocked calls.`);
1547
- lines.push('Google Cloud budget alerts are evidence for spend visibility; ThumbGate-side stop conditions must be verified separately before calling them a hard cap.');
1548
- sources.push('token savings', 'budget posture');
1549
- } else if (topic === 'cloud') {
1550
- lines.push(status.vertex.configured
1579
+ return {
1580
+ lines: [
1581
+ `Feedback total: ${compactNumber(approval.total)} (${compactNumber(approval.positive)} positive, ${compactNumber(approval.negative)} negative).`,
1582
+ `Lesson pipeline: ${compactNumber(lessonPipeline.lessons || lessonPipeline.generated || 0)} lessons visible in the current dashboard snapshot.`,
1583
+ ],
1584
+ sources: ['feedback log', 'lesson pipeline'],
1585
+ };
1586
+ }
1587
+ if (topic === 'gates') {
1588
+ return {
1589
+ lines: [
1590
+ `Active gates: ${gates.length || compactNumber(gateStats.totalGates)}.`,
1591
+ `Blocked actions recorded: ${compactNumber(gateStats.blocked || gateStats.denied || gateStats.totalBlocked)}.`,
1592
+ gates[0] ? `Example gate: ${gates[0].name || gates[0].id || 'unnamed gate'}.` : '',
1593
+ ].filter(Boolean),
1594
+ sources: ['gate stats'],
1595
+ };
1596
+ }
1597
+ if (topic === 'team') {
1598
+ return {
1599
+ lines: [
1600
+ 'Team dashboard is available in this local Enterprise view.',
1601
+ `Tracked agents: ${compactNumber(team.totalAgents || team.agentCount || 0)}; risky agents: ${compactNumber(team.riskyAgents || team.highRiskAgents || 0)}.`,
1602
+ ],
1603
+ sources: ['team dashboard'],
1604
+ };
1605
+ }
1606
+ if (topic === 'cost') {
1607
+ return {
1608
+ lines: [
1609
+ `Estimated token savings: ${tokenSavings.dollarsSavedDisplay || '$0.00'} from ${compactNumber(tokenSavings.blockedCalls)} blocked calls.`,
1610
+ 'Google Cloud budget alerts are evidence for spend visibility; ThumbGate-side stop conditions must be verified separately before calling them a hard cap.',
1611
+ ],
1612
+ sources: ['token savings', 'budget posture'],
1613
+ };
1614
+ }
1615
+ if (topic === 'cloud') {
1616
+ const vertexLine = status.vertex.configured
1551
1617
  ? `Vertex routing config is present for project ${status.vertex.projectId} (${status.vertex.location}).`
1552
- : 'Vertex routing config is not present in this server environment.');
1553
- lines.push(status.dfcx.liveAgentConfigured
1618
+ : 'Vertex routing config is not present in this server environment.';
1619
+ const dfcxLine = status.dfcx.liveAgentConfigured
1554
1620
  ? `DFCX env has agent ${status.dfcx.agentId} in ${status.dfcx.location}; verify it with REST/console before production claims.`
1555
- : 'No live DFCX agent is configured in env. Do not use the old alpha gcloud CX command group; verify agents with the Dialogflow CX REST API or console.');
1556
- sources.push('enterprise cloud status');
1557
- } else {
1558
- lines.push('Ask about feedback, lessons, active gates, team rollout, token savings, or Vertex/DFCX readiness.');
1559
- lines.push(`Current local snapshot: ${compactNumber(approval.total)} feedback events and ${gates.length || compactNumber(gateStats.totalGates)} active gates.`);
1621
+ : 'No live DFCX agent is configured in env. Do not use the old alpha gcloud CX command group; verify agents with the Dialogflow CX REST API or console.';
1622
+ return {
1623
+ lines: [vertexLine, dfcxLine],
1624
+ sources: ['enterprise cloud status'],
1625
+ };
1560
1626
  }
1627
+ return {
1628
+ lines: [
1629
+ 'Ask about feedback, lessons, active gates, team rollout, token savings, or Vertex/DFCX readiness.',
1630
+ `Current local snapshot: ${compactNumber(approval.total)} feedback events and ${gates.length || compactNumber(gateStats.totalGates)} active gates.`,
1631
+ ],
1632
+ sources: [],
1633
+ };
1634
+ }
1635
+
1636
+ function buildEnterpriseChatAnswer(prompt, dashboardData, status) {
1637
+ const topic = classifyEnterpriseChatTopic(prompt);
1638
+ const section = buildEnterpriseChatSection(topic, dashboardData, status);
1561
1639
 
1562
1640
  return {
1563
1641
  topic,
1564
- answer: lines.join(' '),
1565
- sources,
1642
+ answer: section.lines.join(' '),
1643
+ sources: ['local dashboard data', ...section.sources],
1566
1644
  };
1567
1645
  }
1568
1646
 
1569
- async function answerEnterpriseDialogflowChat({ prompt, feedbackDir, parsed }) {
1647
+ // Answer the dashboard "Chat with your data" panel LOCALLY (deterministic, no
1648
+ // cloud/LLM) from this install's own per-project dashboard data. Returns true if
1649
+ // it sent a response; false if the local snapshot was unavailable (caller then
1650
+ // falls through). Shared by the local-first path and the no-model fallback.
1651
+ async function trySendLocalDashboardChat(res, parsed, feedbackDir, prompt, suffix) {
1652
+ try {
1653
+ const dashboardResult = await buildLiveDashboardData(parsed, feedbackDir);
1654
+ const localChat = buildEnterpriseChatAnswer(
1655
+ prompt,
1656
+ dashboardResult.data,
1657
+ buildEnterpriseDataChatStatus(),
1658
+ );
1659
+ sendJson(res, 200, {
1660
+ ok: true,
1661
+ answer: suffix ? `${localChat.answer} ${suffix}` : localChat.answer,
1662
+ sources: (localChat.sources || []).map((title) => ({ title })),
1663
+ topic: localChat.topic,
1664
+ provider: 'local-data',
1665
+ llm: 'none',
1666
+ grounded: true,
1667
+ });
1668
+ return true;
1669
+ } catch (_) {
1670
+ return false;
1671
+ }
1672
+ }
1673
+
1674
+ async function answerEnterpriseDataChat({ prompt, feedbackDir, parsed }) {
1570
1675
  const normalizedPrompt = normalizeEnterpriseChatPrompt(prompt);
1571
1676
  if (!normalizedPrompt) {
1572
1677
  throw createHttpError(400, 'prompt is required');
1573
1678
  }
1574
- const status = buildEnterpriseDialogflowStatus();
1679
+ const status = buildEnterpriseDataChatStatus();
1575
1680
  if (containsUnsafeEnterpriseChatInput(normalizedPrompt)) {
1576
1681
  return {
1577
1682
  ok: false,
@@ -1592,6 +1697,11 @@ async function answerEnterpriseDialogflowChat({ prompt, feedbackDir, parsed }) {
1592
1697
 
1593
1698
  const dashboardResult = await buildLiveDashboardData(parsed, feedbackDir);
1594
1699
  const dashboardData = dashboardResult.data;
1700
+
1701
+ // This guarded stats endpoint stays deterministic for API compatibility.
1702
+ // The dashboard's real local/open-source chatbot turn goes through /v1/chat,
1703
+ // which uses lesson retrieval + optional LanceDB vector search + the user's
1704
+ // configured local or BYO model.
1595
1705
  const chat = buildEnterpriseChatAnswer(normalizedPrompt, dashboardData, status);
1596
1706
  const dfcxRequest = {
1597
1707
  fulfillmentInfo: { tag: 'chat-with-data' },
@@ -1627,6 +1737,8 @@ async function answerEnterpriseDialogflowChat({ prompt, feedbackDir, parsed }) {
1627
1737
  };
1628
1738
  }
1629
1739
 
1740
+ const answerEnterpriseDialogflowChat = answerEnterpriseDataChat;
1741
+
1630
1742
  function buildLossAnalyticsResponse(data, summaryOptions) {
1631
1743
  return {
1632
1744
  window: data.analytics.window || summaryOptions,
@@ -1706,9 +1818,9 @@ function buildCheckoutIntentHref(baseUrl, metadata = {}, overrides = {}) {
1706
1818
  });
1707
1819
  }
1708
1820
 
1709
- function renderCheckoutIntentPage() {
1821
+ function renderCheckoutIntentPage(prefilledEmail = '') {
1710
1822
  const plausibleDomain = escapeHtmlAttribute(resolvePlausibleDataDomain({ host: 'thumbgate.ai' }));
1711
- return `<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Confirm — ThumbGate Pro</title><script defer data-domain="${plausibleDomain}" src="https://plausible.io/js/script.tagged-events.js"></script><script>window.plausible=window.plausible||function(){(window.plausible.q=window.plausible.q||[]).push(arguments)};</script><style>body{background:#0a0a0a;color:#eee;font-family:system-ui,-apple-system,sans-serif;line-height:1.5}main{max-width:520px;margin:8vh auto;padding:0 20px}.brand{display:flex;align-items:center;gap:10px;margin-bottom:24px;font-size:14px;color:#94a3b8}.brand-mark{width:24px;height:24px;background:#22d3ee;border-radius:6px;display:inline-block}h1{font-size:24px;margin:0 0 8px;color:#fff}.price{font-size:32px;font-weight:700;color:#22d3ee;margin:8px 0 4px}.price small{font-size:14px;color:#94a3b8;font-weight:400}p{color:#cbd5e1;margin:8px 0}form{margin:0}input[type=email]{width:100%;box-sizing:border-box;padding:14px 16px;border:1px solid #374151;border-radius:8px;background:#111827;color:#fff;font-size:15px;margin:16px 0 0;outline:none}input[type=email]:focus{border-color:#22d3ee}input[type=email]::placeholder{color:#64748b}button.primary{background:#22d3ee;color:#000;padding:16px;text-align:center;border-radius:8px;font-weight:700;font-size:16px;margin:10px 0;border:none;cursor:pointer;width:100%}a{display:block;text-decoration:none}a.secondary{border:1px solid #374151;color:#cbd5e1;padding:12px;text-align:center;border-radius:8px;margin:8px 0 0;font-size:14px}.trust{margin:24px 0;padding:16px;border:1px solid #1f2937;border-radius:8px;background:#0f172a}.trust-item{font-size:13px;color:#cbd5e1;padding:4px 0;display:flex;gap:8px}.trust-item::before{content:"✓";color:#22d3ee;font-weight:700}.choice-note{font-size:13px;color:#94a3b8;margin-top:14px}.back{text-align:center;color:#64748b;font-size:12px;margin-top:24px}.back a{color:#64748b;display:inline}.email-note{font-size:12px;color:#64748b;margin:4px 0 0}</style><main><div class="brand"><span class="brand-mark"></span><span>ThumbGate</span></div><h1>Start ThumbGate Pro</h1><div class="price">$19<small>/mo</small></div><p>The npm package runs your gates locally. <strong>Pro</strong> is what keeps them working across every machine, every agent runtime, and every breaking-change week.</p><form action="/checkout/pro" method="GET" data-i="pro_checkout_confirmed"><input type="hidden" name="confirm" value="1"><input type="email" name="customer_email" placeholder="you@company.com" required autocomplete="email"><p class="email-note">Pre-fills your Stripe receipt. We only email if you ask.</p><button type="submit" class="primary">Pay $19/mo with Stripe →</button></form><a class="secondary" data-i="workflow_sprint_intake" href="/#workflow-sprint-intake">Not sure yet? Send the workflow first</a><p class="choice-note">Cancel anytime. 7-day refund, no questions. Diagnostics and sprints have their own pages.</p><div class="trust"><div class="trust-item">Lessons synced across all your machines — no local SQLite to babysit</div><div class="trust-item">Adapter matrix kept current for Claude Code, Cursor, Codex, Gemini, Amp, Cline, OpenCode — version drift is our problem, not yours</div><div class="trust-item">Hosted dashboard: gate stats, DPO export, org-wide rule library</div><div class="trust-item">24×7 ops on the rule engine — SonarCloud regressions fixed in &lt;24h</div></div><p class="back"><a href="/">← Back to thumbgate.ai</a></p></main><script>document.querySelector('form').addEventListener('submit',e=>{if(navigator.sendBeacon)navigator.sendBeacon('/v1/telemetry/ping',new Blob([JSON.stringify({eventType:'checkout_interstitial_cta_clicked',clientType:'web',page:'/checkout/pro',ctaId:'pro_checkout_confirmed',ctaPlacement:'checkout_interstitial',customerEmail:document.querySelector('input[name=customer_email]').value})],{type:'application/json'}));try{window.plausible&&window.plausible('Checkout Pro Email Submitted',{props:{page:'/checkout/pro',source:'interstitial'}})}catch(_){}})</script></html>`;
1823
+ return `<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Confirm — ThumbGate Pro</title><script defer data-domain="${plausibleDomain}" src="https://plausible.io/js/script.tagged-events.js"></script><script>window.plausible=window.plausible||function(){(window.plausible.q=window.plausible.q||[]).push(arguments)};</script><style>body{background:#0a0a0a;color:#eee;font-family:system-ui,-apple-system,sans-serif;line-height:1.5}main{max-width:520px;margin:8vh auto;padding:0 20px}.brand{display:flex;align-items:center;gap:10px;margin-bottom:24px;font-size:14px;color:#94a3b8}.brand-mark{width:24px;height:24px;background:#22d3ee;border-radius:6px;display:inline-block}h1{font-size:24px;margin:0 0 8px;color:#fff}.price{font-size:32px;font-weight:700;color:#22d3ee;margin:8px 0 4px}.price small{font-size:14px;color:#94a3b8;font-weight:400}p{color:#cbd5e1;margin:8px 0}form{margin:0}input[type=email]{width:100%;box-sizing:border-box;padding:14px 16px;border:1px solid #374151;border-radius:8px;background:#111827;color:#fff;font-size:15px;margin:16px 0 0;outline:none}input[type=email]:focus{border-color:#22d3ee}input[type=email]::placeholder{color:#64748b}button.primary{background:#22d3ee;color:#000;padding:16px;text-align:center;border-radius:8px;font-weight:700;font-size:16px;margin:10px 0;border:none;cursor:pointer;width:100%}a{display:block;text-decoration:none}a.secondary{border:1px solid #374151;color:#cbd5e1;padding:12px;text-align:center;border-radius:8px;margin:8px 0 0;font-size:14px}.trust{margin:24px 0;padding:16px;border:1px solid #1f2937;border-radius:8px;background:#0f172a}.trust-item{font-size:13px;color:#cbd5e1;padding:4px 0;display:flex;gap:8px}.trust-item::before{content:"✓";color:#22d3ee;font-weight:700}.choice-note{font-size:13px;color:#94a3b8;margin-top:14px}.back{text-align:center;color:#64748b;font-size:12px;margin-top:24px}.back a{color:#64748b;display:inline}.email-note{font-size:12px;color:#64748b;margin:4px 0 0}</style><main><div class="brand"><span class="brand-mark"></span><span>ThumbGate</span></div><h1>Start ThumbGate Pro</h1><div class="price">$19<small>/mo</small></div><p>The npm package runs your gates locally. <strong>Pro</strong> is what keeps them working across every machine, every agent runtime, and every breaking-change week.</p><form action="/checkout/pro" method="GET" data-i="pro_checkout_confirmed"><input type="hidden" name="confirm" value="1"><input type="email" name="customer_email" value="${escapeHtmlAttribute(prefilledEmail)}" placeholder="you@company.com" required autocomplete="email"><p class="email-note">Pre-fills your Stripe receipt. We only email if you ask.</p><button type="submit" class="primary">Pay $19/mo with Stripe →</button></form><a class="secondary" data-i="workflow_sprint_intake" href="/#workflow-sprint-intake">Not sure yet? Send the workflow first</a><p class="choice-note">Cancel anytime. 7-day refund, no questions. Diagnostics and sprints have their own pages.</p><div class="trust"><div class="trust-item">Lessons synced across all your machines — no local SQLite to babysit</div><div class="trust-item">Adapter matrix kept current for Claude Code, Cursor, Codex, Gemini, Amp, Cline, OpenCode — version drift is our problem, not yours</div><div class="trust-item">Hosted dashboard: gate stats, DPO export, org-wide rule library</div><div class="trust-item">24×7 ops on the rule engine — SonarCloud regressions fixed in &lt;24h</div></div><p class="back"><a href="/">← Back to thumbgate.ai</a></p></main><script>document.querySelector('form').addEventListener('submit',e=>{if(navigator.sendBeacon)navigator.sendBeacon('/v1/telemetry/ping',new Blob([JSON.stringify({eventType:'checkout_interstitial_cta_clicked',clientType:'web',page:'/checkout/pro',ctaId:'pro_checkout_confirmed',ctaPlacement:'checkout_interstitial',customerEmail:document.querySelector('input[name=customer_email]').value})],{type:'application/json'}));try{window.plausible&&window.plausible('Checkout Pro Email Submitted',{props:{page:'/checkout/pro',source:'interstitial'}})}catch(_){}})</script></html>`;
1712
1824
  }
1713
1825
 
1714
1826
  function buildCheckoutBootstrapBody(parsed, req, journeyState = resolveJourneyState(req, parsed)) {
@@ -2200,6 +2312,10 @@ function readOptionalPublicTemplate(filePath) {
2200
2312
  }
2201
2313
  }
2202
2314
 
2315
+ function escapeJsonForInlineScript(value) {
2316
+ return JSON.stringify(value).replaceAll('<', String.raw`\u003c`);
2317
+ }
2318
+
2203
2319
  function resolveLocalPageBootstrap(req, expectedApiKey) {
2204
2320
  const forwardedHost = req.headers['x-forwarded-host'];
2205
2321
  const hostHeader = Array.isArray(forwardedHost)
@@ -2208,7 +2324,13 @@ function resolveLocalPageBootstrap(req, expectedApiKey) {
2208
2324
  const localProBootstrap = process.env.THUMBGATE_PRO_MODE === '1' && Boolean(expectedApiKey) && isLoopbackHost(hostHeader);
2209
2325
  const devOverride = expectedApiKey === null && isLoopbackHost(hostHeader);
2210
2326
  const bootstrapActive = localProBootstrap || devOverride;
2211
- const serializedBootstrapKey = JSON.stringify(localProBootstrap ? expectedApiKey : devOverride ? (process.env.THUMBGATE_API_KEY || 'dev-override') : '').replace(/</g, '\\u003c');
2327
+ let bootstrapKey = '';
2328
+ if (localProBootstrap) {
2329
+ bootstrapKey = expectedApiKey;
2330
+ } else if (devOverride) {
2331
+ bootstrapKey = process.env.THUMBGATE_API_KEY || 'dev-override';
2332
+ }
2333
+ const serializedBootstrapKey = escapeJsonForInlineScript(bootstrapKey);
2212
2334
 
2213
2335
  return {
2214
2336
  bootstrapActive,
@@ -2249,7 +2371,7 @@ window.THUMBGATE_DASHBOARD_BOOTSTRAP = { enabled: ${bootstrapActive ? 'true' : '
2249
2371
  <p>This lightweight npm dashboard is bundled without marketing assets, so installs stay small while core feedback, lessons, and API routes remain available.</p>
2250
2372
  <div class="grid">
2251
2373
  <a class="card" href="/v1/dashboard"><strong>Dashboard JSON</strong><span>Inspect feedback totals, lesson counts, and Reliability Gateway health.</span></a>
2252
- <a class="card" href="/v1/enterprise/dialogflow/status"><strong>Enterprise Data Chat</strong><span>Check Vertex/DFCX readiness and use /v1/enterprise/dialogflow/chat to query local ThumbGate data through the data-access guard. This does not claim a live Dialogflow CX agent unless deployment evidence is configured.</span></a>
2374
+ <a class="card" href="/v1/enterprise/data-chat/status"><strong>Governed Data Chat</strong><span>Local RAG (LanceDB vectors + lessons) over your data plus your LLM. Guard simulation is available; Dialogflow/Vertex are optional adapters for customer-owned agent deployments.</span></a>
2253
2375
  <a class="card" href="/lessons"><strong>Lessons</strong><span>Review remembered thumbs-up/down lessons and enforcement context.</span></a>
2254
2376
  <a class="card" href="/health"><strong>Health</strong><span>Verify the installed package version and runtime status.</span></a>
2255
2377
  </div>
@@ -2329,6 +2451,53 @@ function normalizeLessonSignal(signal) {
2329
2451
  return 'down';
2330
2452
  }
2331
2453
 
2454
+ function splitLessonTitlePrefix(titleText) {
2455
+ const prefixMatch = /^(MISTAKE|SUCCESS|LEARNING|PREFERENCE):\s*(.*)/i.exec(titleText);
2456
+ if (!prefixMatch) return { prefix: '', rest: titleText };
2457
+ return {
2458
+ prefix: `${prefixMatch[1].toUpperCase()}: `,
2459
+ rest: prefixMatch[2],
2460
+ };
2461
+ }
2462
+
2463
+ function maybeParseJsonObject(value) {
2464
+ const trimmed = String(value || '').trim();
2465
+ if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) return null;
2466
+ try {
2467
+ return JSON.parse(trimmed);
2468
+ } catch (error) {
2469
+ debugApiFallback('lesson JSON parse skipped', error);
2470
+ return null;
2471
+ }
2472
+ }
2473
+
2474
+ function formatLessonJsonTitle(prefix, parsed) {
2475
+ const dirName = parsed.cwd ? parsed.cwd.split('/').pop() : '';
2476
+ const suffix = dirName ? ` inside ${dirName}` : '';
2477
+ if (parsed.prompt) return `${prefix}Prompt "${parsed.prompt}"${suffix}`;
2478
+ const hookVal = parsed.hook_event_name || parsed.hookEventName;
2479
+ if (hookVal) return `${prefix}Hook event ${hookVal}${suffix}`;
2480
+ if (parsed.signal) return `${prefix}${parsed.signal === 'up' ? 'Thumbs Up' : 'Thumbs Down'}${suffix}`;
2481
+ return '';
2482
+ }
2483
+
2484
+ function cleanLessonTitle(titleText) {
2485
+ if (!titleText) return 'Untitled Lesson';
2486
+ const { prefix, rest } = splitLessonTitlePrefix(titleText);
2487
+ const parsed = maybeParseJsonObject(rest);
2488
+ if (parsed) {
2489
+ const jsonTitle = formatLessonJsonTitle(prefix, parsed);
2490
+ if (jsonTitle) return jsonTitle;
2491
+ }
2492
+ return titleText;
2493
+ }
2494
+
2495
+ function formatLessonTextValue(value) {
2496
+ if (!value) return '';
2497
+ const parsed = maybeParseJsonObject(value);
2498
+ return parsed ? JSON.stringify(parsed, null, 2) : value;
2499
+ }
2500
+
2332
2501
  function renderLessonDetailHtml(record, lessonId) {
2333
2502
  if (!record) {
2334
2503
  return `<!DOCTYPE html><html><head><meta charset="utf-8"><title>Lesson Not Found</title>
@@ -2352,58 +2521,10 @@ a{color:#22d3ee;text-decoration:none}</style></head><body>
2352
2521
  const rawWhatWentWrong = merged.whatWentWrong || '';
2353
2522
  const rawWhatWorked = merged.whatWorked || '';
2354
2523
 
2355
- function cleanTitle(titleText) {
2356
- if (!titleText) return 'Untitled Lesson';
2357
- let prefix = '';
2358
- let rest = titleText;
2359
- const match = titleText.match(/^(MISTAKE|SUCCESS|LEARNING|PREFERENCE):\s*(.*)/i);
2360
- if (match) {
2361
- prefix = match[1].toUpperCase() + ': ';
2362
- rest = match[2];
2363
- }
2364
-
2365
- const trimmed = rest.trim();
2366
- if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
2367
- try {
2368
- const parsed = JSON.parse(trimmed);
2369
- const promptVal = parsed.prompt;
2370
- const hookVal = parsed.hook_event_name || parsed.hookEventName;
2371
- if (promptVal) {
2372
- const dirName = parsed.cwd ? parsed.cwd.split('/').pop() : '';
2373
- return prefix + `Prompt "${promptVal}"` + (dirName ? ` inside ${dirName}` : '');
2374
- }
2375
- if (hookVal) {
2376
- const dirName = parsed.cwd ? parsed.cwd.split('/').pop() : '';
2377
- return prefix + `Hook event ${hookVal}` + (dirName ? ` inside ${dirName}` : '');
2378
- }
2379
- if (parsed.signal) {
2380
- const dirName = parsed.cwd ? parsed.cwd.split('/').pop() : '';
2381
- return prefix + (parsed.signal === 'up' ? 'Thumbs Up' : 'Thumbs Down') + (dirName ? ` inside ${dirName}` : '');
2382
- }
2383
- } catch (e) {
2384
- // ignore
2385
- }
2386
- }
2387
- return titleText;
2388
- }
2389
-
2390
- function formatTextValue(value) {
2391
- if (!value) return '';
2392
- const trimmed = String(value).trim();
2393
- if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
2394
- try {
2395
- return JSON.stringify(JSON.parse(trimmed), null, 2);
2396
- } catch (e) {
2397
- // ignore
2398
- }
2399
- }
2400
- return value;
2401
- }
2402
-
2403
- const title = cleanTitle(rawTitle);
2404
- const context = formatTextValue(rawContext);
2405
- const whatWentWrong = formatTextValue(rawWhatWentWrong);
2406
- const whatWorked = formatTextValue(rawWhatWorked);
2524
+ const title = cleanLessonTitle(rawTitle);
2525
+ const context = formatLessonTextValue(rawContext);
2526
+ const whatWentWrong = formatLessonTextValue(rawWhatWentWrong);
2527
+ const whatWorked = formatLessonTextValue(rawWhatWorked);
2407
2528
  const whatToChange = merged.whatToChange || '';
2408
2529
  const tags = Array.isArray(merged.tags) ? merged.tags.join(', ') : (merged.tags || '');
2409
2530
  const timestamp = merged.timestamp ? new Date(merged.timestamp).toLocaleString() : '';
@@ -4295,7 +4416,10 @@ function createApiServer() {
4295
4416
  });
4296
4417
  sendJson(res, 200, result);
4297
4418
  } catch (e) {
4298
- sendJson(res, 500, { error: 'feedback submission failed' });
4419
+ sendJson(res, 500, {
4420
+ error: 'feedback submission failed',
4421
+ message: e?.message || 'Unable to submit dashboard feedback.',
4422
+ });
4299
4423
  }
4300
4424
  });
4301
4425
  return;
@@ -5158,7 +5282,12 @@ async function addContext(){
5158
5282
  return;
5159
5283
  }
5160
5284
 
5161
- if (isGetLikeRequest && pathname === '/checkout/pro') {
5285
+ // HOTFIX 2026-06-04 accept ANY method (GET/HEAD/POST) on /checkout/pro
5286
+ // to prevent the API-key guard from 401'ing real prospective customers
5287
+ // whose forms or fetch() calls land via POST. Audit: 69 emails submitted
5288
+ // → 0 paid because POST hit the auth gate. Query params still drive the
5289
+ // Stripe session creation; POST bodies are ignored harmlessly.
5290
+ if ((isGetLikeRequest || req.method === 'POST') && pathname === '/checkout/pro') {
5162
5291
  if (isHeadRequest) {
5163
5292
  sendHtml(res, 200, '', {}, {
5164
5293
  headOnly: true,
@@ -5253,7 +5382,8 @@ async function addContext(){
5253
5382
  isBot: botClassification.isBot ? 'true' : 'false',
5254
5383
  reason: botClassification.reason,
5255
5384
  }, req.headers, eventType);
5256
- const html = renderCheckoutIntentPage();
5385
+ const prefilledEmail = parsed?.searchParams?.get('customer_email') || '';
5386
+ const html = renderCheckoutIntentPage(prefilledEmail);
5257
5387
  sendHtml(res, 200, html, responseHeaders);
5258
5388
  return;
5259
5389
  }
@@ -7022,82 +7152,82 @@ ${hidden}
7022
7152
  const { getStatuslineMeta } = require('../../scripts/statusline-meta');
7023
7153
  const meta = getStatuslineMeta({ env: process.env });
7024
7154
  stats.tier = meta.tier;
7025
- } catch (_) {
7155
+ } catch (error) {
7156
+ debugApiFallback('statusline meta unavailable', error);
7026
7157
  stats.tier = 'Pro';
7027
7158
  }
7028
7159
 
7029
- let projectGeminiKey = '';
7030
- let projectPerplexityKey = '';
7031
- let geminiValidatedAt = null;
7032
- try {
7033
- const projectDir = resolveRequestProjectDir(req, parsed);
7034
- const envPath = path.join(projectDir, '.env');
7035
- if (fs.existsSync(envPath)) {
7036
- const content = fs.readFileSync(envPath, 'utf8');
7037
- const geminiMatch = content.match(/^(?:GEMINI_API_KEY|GOOGLE_API_KEY|THUMBGATE_GEMINI_API_KEY)=(.*)$/m);
7038
- if (geminiMatch) {
7039
- projectGeminiKey = geminiMatch[1].trim().replace(/^["']|["']$/g, '');
7040
- }
7041
- const perplexityMatch = content.match(/^(?:PERPLEXITY_API_KEY|THUMBGATE_PERPLEXITY_API_KEY)=(.*)$/m);
7042
- if (perplexityMatch) {
7043
- projectPerplexityKey = perplexityMatch[1].trim().replace(/^["']|["']$/g, '');
7044
- }
7045
- }
7046
- const statusPath = path.join(projectDir, '.gemini-validated.json');
7047
- if (fs.existsSync(statusPath)) {
7048
- const st = JSON.parse(fs.readFileSync(statusPath, 'utf8'));
7049
- geminiValidatedAt = st.validatedAt || null;
7050
- }
7051
- } catch (_) {}
7160
+ const projectChatSettings = readProjectChatSettings(req, parsed);
7052
7161
 
7053
7162
  stats.geminiConfigured = Boolean(
7054
- projectGeminiKey ||
7163
+ projectChatSettings.geminiKey ||
7055
7164
  process.env.GEMINI_API_KEY ||
7056
7165
  process.env.THUMBGATE_GEMINI_API_KEY ||
7057
7166
  process.env.GOOGLE_API_KEY
7058
7167
  );
7059
7168
  stats.perplexityConfigured = Boolean(
7060
- projectPerplexityKey ||
7169
+ projectChatSettings.perplexityKey ||
7061
7170
  process.env.PERPLEXITY_API_KEY ||
7062
7171
  process.env.THUMBGATE_PERPLEXITY_API_KEY
7063
7172
  );
7064
- stats.geminiValidatedAt = geminiValidatedAt;
7065
- stats.geminiKeyStatus = geminiValidatedAt ? 'validated' : (projectGeminiKey ? 'present' : 'none');
7173
+ stats.geminiValidatedAt = projectChatSettings.geminiValidatedAt;
7174
+ stats.geminiKeyStatus = 'none';
7175
+ if (projectChatSettings.geminiKey) {
7176
+ stats.geminiKeyStatus = 'present';
7177
+ }
7178
+ if (projectChatSettings.geminiValidatedAt) {
7179
+ stats.geminiKeyStatus = 'validated';
7180
+ }
7066
7181
  stats.hybridInferenceAvailable = !!(stats.geminiConfigured || stats.perplexityConfigured);
7067
7182
  sendJson(res, 200, stats);
7068
7183
  return;
7069
7184
  }
7070
7185
 
7071
- // Chat with your data — RAG over this install's captured lessons, answered
7072
- // by Gemini grounded only in the retrieved context. Powers the dashboard
7073
- // "Chat with your data" panel.
7186
+ // Chat with your data — LOCAL-FIRST. Powers the dashboard "Chat with your
7187
+ // data" panel. Factual/metric questions (gates, blocks, feedback, token
7188
+ // savings, team) are answered DETERMINISTICALLY from this install's own
7189
+ // dashboard data — no cloud, no LLM, no API key (the local-first thesis).
7190
+ // Only open-ended/qualitative questions fall through to lesson retrieval +
7191
+ // the user's configured LOCAL model (a BYO cloud key is optional, not required).
7074
7192
  if (req.method === 'POST' && pathname === '/v1/chat') {
7075
7193
  const body = await parseJsonBody(req);
7194
+ const question = body.question || body.q || body.message;
7195
+ const normalizedChatPrompt = normalizeEnterpriseChatPrompt(question);
7196
+ const chatFeedbackDir = requestFeedbackPaths.FEEDBACK_DIR;
7197
+
7198
+ // Local-first: factual/metric questions (gates, blocks, feedback, cost,
7199
+ // team) are answered deterministically from local data — no cloud/LLM/key.
7200
+ if (
7201
+ normalizedChatPrompt
7202
+ && !containsUnsafeEnterpriseChatInput(normalizedChatPrompt)
7203
+ && classifyEnterpriseChatTopic(normalizedChatPrompt) !== 'overview'
7204
+ && await trySendLocalDashboardChat(res, parsed, chatFeedbackDir, normalizedChatPrompt)
7205
+ ) {
7206
+ return;
7207
+ }
7208
+
7076
7209
  const { answerDataQuestion } = require('../../scripts/dashboard-chat');
7077
7210
 
7078
- let projectGeminiKey = '';
7079
- let projectPerplexityKey = '';
7080
- try {
7081
- const projectDir = resolveRequestProjectDir(req, parsed);
7082
- const envPath = path.join(projectDir, '.env');
7083
- if (fs.existsSync(envPath)) {
7084
- const content = fs.readFileSync(envPath, 'utf8');
7085
- const geminiMatch = content.match(/^(?:GEMINI_API_KEY|GOOGLE_API_KEY|THUMBGATE_GEMINI_API_KEY)=(.*)$/m);
7086
- if (geminiMatch) {
7087
- projectGeminiKey = geminiMatch[1].trim().replace(/^["']|["']$/g, '');
7088
- }
7089
- const perplexityMatch = content.match(/^(?:PERPLEXITY_API_KEY|THUMBGATE_PERPLEXITY_API_KEY)=(.*)$/m);
7090
- if (perplexityMatch) {
7091
- projectPerplexityKey = perplexityMatch[1].trim().replace(/^["']|["']$/g, '');
7092
- }
7093
- }
7094
- } catch (_) {}
7211
+ const projectChatSettings = readProjectChatSettings(req, parsed);
7095
7212
 
7096
- const result = await answerDataQuestion(body.question || body.q || body.message, {
7097
- feedbackDir: requestFeedbackPaths.FEEDBACK_DIR,
7213
+ const result = await answerDataQuestion(question, {
7214
+ feedbackDir: chatFeedbackDir,
7098
7215
  model: typeof body.model === 'string' ? body.model : undefined,
7099
- apiKey: projectPerplexityKey || projectGeminiKey || process.env.PERPLEXITY_API_KEY || process.env.THUMBGATE_PERPLEXITY_API_KEY || process.env.GEMINI_API_KEY || process.env.THUMBGATE_GEMINI_API_KEY || process.env.GOOGLE_API_KEY || '',
7216
+ apiKey: projectChatSettings.perplexityKey || projectChatSettings.geminiKey || process.env.PERPLEXITY_API_KEY || process.env.THUMBGATE_PERPLEXITY_API_KEY || process.env.GEMINI_API_KEY || process.env.THUMBGATE_GEMINI_API_KEY || process.env.GOOGLE_API_KEY || '',
7217
+ localEndpoint: projectChatSettings.localEndpoint || process.env.THUMBGATE_LOCAL_LLM_ENDPOINT || '',
7218
+ localModel: projectChatSettings.localModel || process.env.THUMBGATE_LOCAL_LLM_MODEL || '',
7100
7219
  });
7220
+
7221
+ // Local-first guarantee: if no model is configured, never hard-fail with
7222
+ // "no_api_key" — fall back to a deterministic local answer. No cloud required.
7223
+ if (
7224
+ !result.ok
7225
+ && (result.error === 'no_api_key' || result.error === 'no_model')
7226
+ && await trySendLocalDashboardChat(res, parsed, chatFeedbackDir, normalizedChatPrompt || question, '(Connect a local model via THUMBGATE_LOCAL_LLM_ENDPOINT for open-ended analysis over your lessons.)')
7227
+ ) {
7228
+ return;
7229
+ }
7230
+
7101
7231
  sendJson(res, result.ok ? 200 : (result.error === 'no_api_key' ? 503 : 400), result);
7102
7232
  return;
7103
7233
  }
@@ -7122,7 +7252,7 @@ ${hidden}
7122
7252
  apiKey: key,
7123
7253
  });
7124
7254
  } catch (e) {
7125
- validation = { ok: false, error: 'validation_exception', message: String(e && e.message || e) };
7255
+ validation = { ok: false, error: 'validation_exception', message: String(e?.message || e) };
7126
7256
  }
7127
7257
 
7128
7258
  if (!validation.ok) {
@@ -7160,7 +7290,9 @@ ${hidden}
7160
7290
  validatedAt: new Date().toISOString(),
7161
7291
  validatedBy: 'dashboard-save'
7162
7292
  }, null, 2));
7163
- } catch (_) { /* non-fatal */ }
7293
+ } catch (error) {
7294
+ debugApiFallback('Gemini validation marker unavailable', error);
7295
+ }
7164
7296
  sendJson(res, 200, { ok: true, message: 'Key saved and validated.' });
7165
7297
  } catch (e) {
7166
7298
  sendJson(res, 500, { ok: false, error: 'fs_error', message: 'Failed to write to .env file: ' + e.message });
@@ -7223,14 +7355,20 @@ ${hidden}
7223
7355
  return;
7224
7356
  }
7225
7357
 
7226
- if (req.method === 'GET' && pathname === '/v1/enterprise/dialogflow/status') {
7227
- sendJson(res, 200, buildEnterpriseDialogflowStatus());
7358
+ if (req.method === 'GET' && (
7359
+ pathname === '/v1/enterprise/data-chat/status'
7360
+ || pathname === '/v1/enterprise/dialogflow/status'
7361
+ )) {
7362
+ sendJson(res, 200, buildEnterpriseDataChatStatus());
7228
7363
  return;
7229
7364
  }
7230
7365
 
7231
- if (req.method === 'POST' && pathname === '/v1/enterprise/dialogflow/chat') {
7366
+ if (req.method === 'POST' && (
7367
+ pathname === '/v1/enterprise/data-chat/chat'
7368
+ || pathname === '/v1/enterprise/dialogflow/chat'
7369
+ )) {
7232
7370
  const body = await parseJsonBody(req, 16 * 1024);
7233
- const result = await answerEnterpriseDialogflowChat({
7371
+ const result = await answerEnterpriseDataChat({
7234
7372
  prompt: body.prompt || body.message || body.query,
7235
7373
  feedbackDir: requestFeedbackDir,
7236
7374
  parsed,
@@ -8440,7 +8578,7 @@ ${hidden}
8440
8578
  } catch (err) {
8441
8579
  sendJson(res, 500, {
8442
8580
  error: 'ai_inventory_failed',
8443
- message: err && err.message ? err.message : 'Unable to scan AI component inventory.',
8581
+ message: err?.message || 'Unable to scan AI component inventory.',
8444
8582
  });
8445
8583
  }
8446
8584
  return;
@@ -8465,7 +8603,7 @@ ${hidden}
8465
8603
  const body = await parseJsonBody(req);
8466
8604
  const snapshot = buildReviewSnapshot(requestFeedbackDir);
8467
8605
  // Override snapshot timestamp with client-provided one if available
8468
- if (body && body.reviewedAt) {
8606
+ if (body?.reviewedAt) {
8469
8607
  snapshot.reviewedAt = body.reviewedAt;
8470
8608
  }
8471
8609
  writeDashboardReviewState(requestFeedbackDir, snapshot);
@@ -8783,8 +8921,10 @@ module.exports = {
8783
8921
  resolveLocalPageBootstrap,
8784
8922
  getPublicMcpTools,
8785
8923
  getServerCardTools,
8924
+ buildEnterpriseDataChatStatus,
8786
8925
  buildEnterpriseDialogflowStatus,
8787
8926
  buildEnterpriseChatAnswer,
8927
+ answerEnterpriseDataChat,
8788
8928
  answerEnterpriseDialogflowChat,
8789
8929
  },
8790
8930
  };