thumbgate 1.26.8 → 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/.claude-plugin/plugin.json +1 -1
- package/.well-known/agentic-verify.txt +1 -0
- package/.well-known/llms.txt +2 -0
- package/.well-known/mcp/server-card.json +1 -1
- package/README.md +44 -31
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/gcp/dfcx-webhook-gate.js +295 -0
- package/adapters/mcp/server-stdio.js +41 -1
- package/adapters/opencode/opencode.json +1 -1
- package/bench/thumbgate-bench.json +2 -2
- package/bin/cli.js +184 -8
- package/bin/dashboard-cli.js +7 -0
- package/config/gate-classifier-routing.json +98 -0
- package/config/gate-templates.json +60 -0
- package/config/mcp-allowlists.json +8 -7
- package/config/model-candidates.json +71 -6
- package/package.json +28 -12
- package/public/about.html +162 -0
- package/public/chatgpt-app.html +330 -0
- package/public/codex-plugin.html +66 -14
- package/public/compare.html +2 -2
- package/public/dashboard.html +224 -36
- package/public/guide.html +2 -2
- package/public/index.html +122 -40
- package/public/learn.html +70 -0
- package/public/lessons.html +129 -6
- package/public/numbers.html +2 -2
- package/public/pricing.html +28 -23
- package/public/pro.html +3 -3
- package/scripts/agent-operations-planner.js +621 -0
- package/scripts/agent-reward-model.js +53 -1
- package/scripts/ai-component-inventory.js +367 -0
- package/scripts/classifier-routing.js +130 -0
- package/scripts/cli-schema.js +26 -0
- package/scripts/commercial-offer.js +10 -2
- package/scripts/dashboard-chat.js +199 -51
- package/scripts/feedback-sanitizer.js +105 -0
- package/scripts/gates-engine.js +301 -67
- package/scripts/hybrid-feedback-context.js +141 -7
- package/scripts/memory-scope-readiness.js +159 -0
- package/scripts/oss-pr-opportunity-scout.js +35 -5
- package/scripts/parallel-workflow-orchestrator.js +293 -0
- package/scripts/plausible-domain-config.js +86 -0
- package/scripts/plausible-server-events.js +4 -2
- package/scripts/proxy-pointer-rag-guardrails.js +42 -1
- package/scripts/qa-scenario-planner.js +136 -0
- package/scripts/rate-limiter.js +2 -2
- package/scripts/repeat-metric.js +28 -12
- package/scripts/secret-fixture-tokens.js +61 -0
- package/scripts/secret-scanner.js +44 -5
- package/scripts/security-scanner.js +80 -0
- package/scripts/seo-gsd.js +113 -0
- package/scripts/thumbgate-bench.js +16 -1
- package/scripts/tool-registry.js +37 -0
- package/scripts/workflow-sentinel.js +282 -54
- package/src/api/server.js +466 -60
- package/.claude-plugin/marketplace.json +0 -85
package/src/api/server.js
CHANGED
|
@@ -96,6 +96,9 @@ const {
|
|
|
96
96
|
const {
|
|
97
97
|
recordCheckoutFunnelEvent,
|
|
98
98
|
} = require('../../scripts/plausible-server-events');
|
|
99
|
+
const {
|
|
100
|
+
resolvePlausibleDataDomain,
|
|
101
|
+
} = require('../../scripts/plausible-domain-config');
|
|
99
102
|
const {
|
|
100
103
|
buildCloudflareSandboxPlan,
|
|
101
104
|
} = require('../../scripts/cloudflare-dynamic-sandbox');
|
|
@@ -221,6 +224,7 @@ const PRO_PAGE_PATH = path.resolve(__dirname, '../../public/pro.html');
|
|
|
221
224
|
const DASHBOARD_PAGE_PATH = path.resolve(__dirname, '../../public/dashboard.html');
|
|
222
225
|
const LESSONS_PAGE_PATH = path.resolve(__dirname, '../../public/lessons.html');
|
|
223
226
|
const GUIDE_PAGE_PATH = path.resolve(__dirname, '../../public/guide.html');
|
|
227
|
+
const CHATGPT_APP_PAGE_PATH = path.resolve(__dirname, '../../public/chatgpt-app.html');
|
|
224
228
|
const CODEX_PLUGIN_PAGE_PATH = path.resolve(__dirname, '../../public/codex-plugin.html');
|
|
225
229
|
const COMPARE_PAGE_PATH = path.resolve(__dirname, '../../public/compare.html');
|
|
226
230
|
const LEARN_PAGE_PATH = path.resolve(__dirname, '../../public/learn.html');
|
|
@@ -674,6 +678,51 @@ function resolveRequestProjectDir(req, parsed) {
|
|
|
674
678
|
});
|
|
675
679
|
}
|
|
676
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
|
+
|
|
677
726
|
function shouldPreferProjectScopedFeedback(req, parsed) {
|
|
678
727
|
const explicitProject = getEffectiveRequestedProjectSelection(req, parsed);
|
|
679
728
|
if (explicitProject) return true;
|
|
@@ -1451,7 +1500,7 @@ async function loadLiveDashboardDataOrRespondProblem(res, parsed, feedbackDir, i
|
|
|
1451
1500
|
}
|
|
1452
1501
|
}
|
|
1453
1502
|
|
|
1454
|
-
function
|
|
1503
|
+
function buildEnterpriseDataChatStatus(env = process.env) {
|
|
1455
1504
|
const vertexProject = normalizeNullableText(env.VERTEX_PROJECT_ID)
|
|
1456
1505
|
|| normalizeNullableText(env.GOOGLE_VERTEX_PROJECT);
|
|
1457
1506
|
const vertexLocation = normalizeNullableText(env.GOOGLE_VERTEX_LOCATION)
|
|
@@ -1483,12 +1532,16 @@ function buildEnterpriseDialogflowStatus(env = process.env) {
|
|
|
1483
1532
|
},
|
|
1484
1533
|
chat: {
|
|
1485
1534
|
available: true,
|
|
1486
|
-
source: 'local ThumbGate dashboard data',
|
|
1487
|
-
guard: '
|
|
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)),
|
|
1488
1539
|
},
|
|
1489
1540
|
};
|
|
1490
1541
|
}
|
|
1491
1542
|
|
|
1543
|
+
const buildEnterpriseDialogflowStatus = buildEnterpriseDataChatStatus;
|
|
1544
|
+
|
|
1492
1545
|
function normalizeEnterpriseChatPrompt(value) {
|
|
1493
1546
|
const text = normalizeNullableText(value);
|
|
1494
1547
|
if (!text) return null;
|
|
@@ -1514,60 +1567,116 @@ function compactNumber(value) {
|
|
|
1514
1567
|
return Number.isFinite(n) ? n : 0;
|
|
1515
1568
|
}
|
|
1516
1569
|
|
|
1517
|
-
function
|
|
1518
|
-
const topic = classifyEnterpriseChatTopic(prompt);
|
|
1570
|
+
function buildEnterpriseChatSection(topic, dashboardData, status) {
|
|
1519
1571
|
const approval = dashboardData.approval || {};
|
|
1520
1572
|
const gates = Array.isArray(dashboardData.gates) ? dashboardData.gates : [];
|
|
1521
1573
|
const gateStats = dashboardData.gateStats || {};
|
|
1522
1574
|
const team = dashboardData.team || {};
|
|
1523
1575
|
const tokenSavings = dashboardData.tokenSavings || {};
|
|
1524
1576
|
const lessonPipeline = dashboardData.lessonPipeline || {};
|
|
1525
|
-
const lines = [];
|
|
1526
|
-
const sources = ['local dashboard data'];
|
|
1527
1577
|
|
|
1528
1578
|
if (topic === 'feedback') {
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
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
|
|
1547
1617
|
? `Vertex routing config is present for project ${status.vertex.projectId} (${status.vertex.location}).`
|
|
1548
|
-
: 'Vertex routing config is not present in this server environment.'
|
|
1549
|
-
|
|
1618
|
+
: 'Vertex routing config is not present in this server environment.';
|
|
1619
|
+
const dfcxLine = status.dfcx.liveAgentConfigured
|
|
1550
1620
|
? `DFCX env has agent ${status.dfcx.agentId} in ${status.dfcx.location}; verify it with REST/console before production claims.`
|
|
1551
|
-
: '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.'
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
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
|
+
};
|
|
1556
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);
|
|
1557
1639
|
|
|
1558
1640
|
return {
|
|
1559
1641
|
topic,
|
|
1560
|
-
answer: lines.join(' '),
|
|
1561
|
-
sources,
|
|
1642
|
+
answer: section.lines.join(' '),
|
|
1643
|
+
sources: ['local dashboard data', ...section.sources],
|
|
1562
1644
|
};
|
|
1563
1645
|
}
|
|
1564
1646
|
|
|
1565
|
-
|
|
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 }) {
|
|
1566
1675
|
const normalizedPrompt = normalizeEnterpriseChatPrompt(prompt);
|
|
1567
1676
|
if (!normalizedPrompt) {
|
|
1568
1677
|
throw createHttpError(400, 'prompt is required');
|
|
1569
1678
|
}
|
|
1570
|
-
const status =
|
|
1679
|
+
const status = buildEnterpriseDataChatStatus();
|
|
1571
1680
|
if (containsUnsafeEnterpriseChatInput(normalizedPrompt)) {
|
|
1572
1681
|
return {
|
|
1573
1682
|
ok: false,
|
|
@@ -1588,6 +1697,11 @@ async function answerEnterpriseDialogflowChat({ prompt, feedbackDir, parsed }) {
|
|
|
1588
1697
|
|
|
1589
1698
|
const dashboardResult = await buildLiveDashboardData(parsed, feedbackDir);
|
|
1590
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.
|
|
1591
1705
|
const chat = buildEnterpriseChatAnswer(normalizedPrompt, dashboardData, status);
|
|
1592
1706
|
const dfcxRequest = {
|
|
1593
1707
|
fulfillmentInfo: { tag: 'chat-with-data' },
|
|
@@ -1623,6 +1737,8 @@ async function answerEnterpriseDialogflowChat({ prompt, feedbackDir, parsed }) {
|
|
|
1623
1737
|
};
|
|
1624
1738
|
}
|
|
1625
1739
|
|
|
1740
|
+
const answerEnterpriseDialogflowChat = answerEnterpriseDataChat;
|
|
1741
|
+
|
|
1626
1742
|
function buildLossAnalyticsResponse(data, summaryOptions) {
|
|
1627
1743
|
return {
|
|
1628
1744
|
window: data.analytics.window || summaryOptions,
|
|
@@ -1702,8 +1818,9 @@ function buildCheckoutIntentHref(baseUrl, metadata = {}, overrides = {}) {
|
|
|
1702
1818
|
});
|
|
1703
1819
|
}
|
|
1704
1820
|
|
|
1705
|
-
function renderCheckoutIntentPage() {
|
|
1706
|
-
|
|
1821
|
+
function renderCheckoutIntentPage(prefilledEmail = '') {
|
|
1822
|
+
const plausibleDomain = escapeHtmlAttribute(resolvePlausibleDataDomain({ host: 'thumbgate.ai' }));
|
|
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 <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>`;
|
|
1707
1824
|
}
|
|
1708
1825
|
|
|
1709
1826
|
function buildCheckoutBootstrapBody(parsed, req, journeyState = resolveJourneyState(req, parsed)) {
|
|
@@ -2119,9 +2236,10 @@ function normalizePublicMarketingHtml(html, runtimeConfig) {
|
|
|
2119
2236
|
output = output.replaceAll(DEFAULT_PUBLIC_APP_ORIGIN, appOrigin);
|
|
2120
2237
|
try {
|
|
2121
2238
|
const host = new URL(appOrigin).host;
|
|
2239
|
+
const plausibleDomain = resolvePlausibleDataDomain({ host });
|
|
2122
2240
|
output = output.replaceAll(
|
|
2123
2241
|
'data-domain="thumbgate-production.up.railway.app"',
|
|
2124
|
-
`data-domain="${escapeHtmlAttribute(
|
|
2242
|
+
`data-domain="${escapeHtmlAttribute(plausibleDomain)}"`
|
|
2125
2243
|
);
|
|
2126
2244
|
} catch {
|
|
2127
2245
|
// appOrigin is normalized by hosted-config; leave static analytics domains
|
|
@@ -2194,6 +2312,10 @@ function readOptionalPublicTemplate(filePath) {
|
|
|
2194
2312
|
}
|
|
2195
2313
|
}
|
|
2196
2314
|
|
|
2315
|
+
function escapeJsonForInlineScript(value) {
|
|
2316
|
+
return JSON.stringify(value).replaceAll('<', String.raw`\u003c`);
|
|
2317
|
+
}
|
|
2318
|
+
|
|
2197
2319
|
function resolveLocalPageBootstrap(req, expectedApiKey) {
|
|
2198
2320
|
const forwardedHost = req.headers['x-forwarded-host'];
|
|
2199
2321
|
const hostHeader = Array.isArray(forwardedHost)
|
|
@@ -2202,7 +2324,13 @@ function resolveLocalPageBootstrap(req, expectedApiKey) {
|
|
|
2202
2324
|
const localProBootstrap = process.env.THUMBGATE_PRO_MODE === '1' && Boolean(expectedApiKey) && isLoopbackHost(hostHeader);
|
|
2203
2325
|
const devOverride = expectedApiKey === null && isLoopbackHost(hostHeader);
|
|
2204
2326
|
const bootstrapActive = localProBootstrap || devOverride;
|
|
2205
|
-
|
|
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);
|
|
2206
2334
|
|
|
2207
2335
|
return {
|
|
2208
2336
|
bootstrapActive,
|
|
@@ -2243,7 +2371,7 @@ window.THUMBGATE_DASHBOARD_BOOTSTRAP = { enabled: ${bootstrapActive ? 'true' : '
|
|
|
2243
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>
|
|
2244
2372
|
<div class="grid">
|
|
2245
2373
|
<a class="card" href="/v1/dashboard"><strong>Dashboard JSON</strong><span>Inspect feedback totals, lesson counts, and Reliability Gateway health.</span></a>
|
|
2246
|
-
<a class="card" href="/v1/enterprise/
|
|
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>
|
|
2247
2375
|
<a class="card" href="/lessons"><strong>Lessons</strong><span>Review remembered thumbs-up/down lessons and enforcement context.</span></a>
|
|
2248
2376
|
<a class="card" href="/health"><strong>Health</strong><span>Verify the installed package version and runtime status.</span></a>
|
|
2249
2377
|
</div>
|
|
@@ -2323,6 +2451,53 @@ function normalizeLessonSignal(signal) {
|
|
|
2323
2451
|
return 'down';
|
|
2324
2452
|
}
|
|
2325
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
|
+
|
|
2326
2501
|
function renderLessonDetailHtml(record, lessonId) {
|
|
2327
2502
|
if (!record) {
|
|
2328
2503
|
return `<!DOCTYPE html><html><head><meta charset="utf-8"><title>Lesson Not Found</title>
|
|
@@ -2341,10 +2516,15 @@ a{color:#22d3ee;text-decoration:none}</style></head><body>
|
|
|
2341
2516
|
const signal = normalizeLessonSignal(merged.signal);
|
|
2342
2517
|
const emoji = signal === 'up' ? '👍' : '👎';
|
|
2343
2518
|
const signalColor = signal === 'up' ? '#4ade80' : '#f87171';
|
|
2344
|
-
const
|
|
2345
|
-
const
|
|
2346
|
-
const
|
|
2347
|
-
const
|
|
2519
|
+
const rawTitle = merged.title || merged.context || 'Untitled Lesson';
|
|
2520
|
+
const rawContext = merged.context || '';
|
|
2521
|
+
const rawWhatWentWrong = merged.whatWentWrong || '';
|
|
2522
|
+
const rawWhatWorked = merged.whatWorked || '';
|
|
2523
|
+
|
|
2524
|
+
const title = cleanLessonTitle(rawTitle);
|
|
2525
|
+
const context = formatLessonTextValue(rawContext);
|
|
2526
|
+
const whatWentWrong = formatLessonTextValue(rawWhatWentWrong);
|
|
2527
|
+
const whatWorked = formatLessonTextValue(rawWhatWorked);
|
|
2348
2528
|
const whatToChange = merged.whatToChange || '';
|
|
2349
2529
|
const tags = Array.isArray(merged.tags) ? merged.tags.join(', ') : (merged.tags || '');
|
|
2350
2530
|
const timestamp = merged.timestamp ? new Date(merged.timestamp).toLocaleString() : '';
|
|
@@ -2742,6 +2922,7 @@ function renderSitemapXml(runtimeConfig) {
|
|
|
2742
2922
|
{ path: '/pro', changefreq: 'weekly', priority: '0.9' },
|
|
2743
2923
|
{ path: '/agent-manager', changefreq: 'weekly', priority: '0.9' },
|
|
2744
2924
|
{ path: '/llm-context.md', changefreq: 'weekly', priority: '0.8' },
|
|
2925
|
+
{ path: '/chatgpt-app', changefreq: 'weekly', priority: '0.85' },
|
|
2745
2926
|
{ path: '/codex-plugin', changefreq: 'weekly', priority: '0.75' },
|
|
2746
2927
|
{ path: '/codex-enterprise', changefreq: 'weekly', priority: '0.85' },
|
|
2747
2928
|
{ path: '/agents-cost-savings', changefreq: 'weekly', priority: '0.85' },
|
|
@@ -2749,6 +2930,11 @@ function renderSitemapXml(runtimeConfig) {
|
|
|
2749
2930
|
{ path: '/learn/background-agent-control-layer', changefreq: 'weekly', priority: '0.85' },
|
|
2750
2931
|
{ path: '/learn/ac-dc-runtime-enforcement', changefreq: 'weekly', priority: '0.85' },
|
|
2751
2932
|
{ path: '/learn/feedback-loop-vs-decision-layer', changefreq: 'weekly', priority: '0.9' },
|
|
2933
|
+
{ path: '/learn/agentic-enterprise-context-brain', changefreq: 'weekly', priority: '0.85' },
|
|
2934
|
+
{ path: '/learn/deterministic-agent-workflows', changefreq: 'weekly', priority: '0.85' },
|
|
2935
|
+
{ path: '/learn/codex-role-plugins-need-governance', changefreq: 'weekly', priority: '0.85' },
|
|
2936
|
+
{ path: '/learn/agentic-os-team-governance', changefreq: 'weekly', priority: '0.85' },
|
|
2937
|
+
{ path: '/learn/cost-aware-agent-gate-routing', changefreq: 'weekly', priority: '0.85' },
|
|
2752
2938
|
{ path: '/compare/claude-code-hooks', changefreq: 'weekly', priority: '0.85' },
|
|
2753
2939
|
{ path: '/compare/bumblebee', changefreq: 'weekly', priority: '0.85' },
|
|
2754
2940
|
{ path: '/compare/anthropic-containment', changefreq: 'weekly', priority: '0.85' },
|
|
@@ -4230,7 +4416,10 @@ function createApiServer() {
|
|
|
4230
4416
|
});
|
|
4231
4417
|
sendJson(res, 200, result);
|
|
4232
4418
|
} catch (e) {
|
|
4233
|
-
sendJson(res, 500, {
|
|
4419
|
+
sendJson(res, 500, {
|
|
4420
|
+
error: 'feedback submission failed',
|
|
4421
|
+
message: e?.message || 'Unable to submit dashboard feedback.',
|
|
4422
|
+
});
|
|
4234
4423
|
}
|
|
4235
4424
|
});
|
|
4236
4425
|
return;
|
|
@@ -4430,6 +4619,17 @@ function createApiServer() {
|
|
|
4430
4619
|
return;
|
|
4431
4620
|
}
|
|
4432
4621
|
|
|
4622
|
+
if (isGetLikeRequest && pathname === '/.well-known/agentic-verify.txt') {
|
|
4623
|
+
const agenticVerifyPath = path.join(__dirname, '..', '..', '.well-known', 'agentic-verify.txt');
|
|
4624
|
+
try {
|
|
4625
|
+
const content = fs.readFileSync(agenticVerifyPath, 'utf8');
|
|
4626
|
+
sendText(res, 200, content, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'public, max-age=3600' }, { headOnly: isHeadRequest });
|
|
4627
|
+
} catch {
|
|
4628
|
+
sendJson(res, 404, { error: 'agentic verification file not found' });
|
|
4629
|
+
}
|
|
4630
|
+
return;
|
|
4631
|
+
}
|
|
4632
|
+
|
|
4433
4633
|
if (isGetLikeRequest && pathname === '/sitemap.xml') {
|
|
4434
4634
|
sendText(res, 200, renderSitemapXml(hostedConfig), {
|
|
4435
4635
|
'Content-Type': 'application/xml; charset=utf-8',
|
|
@@ -4742,6 +4942,28 @@ async function addContext(){
|
|
|
4742
4942
|
return;
|
|
4743
4943
|
}
|
|
4744
4944
|
|
|
4945
|
+
if (isGetLikeRequest && (
|
|
4946
|
+
pathname === '/chatgpt-app'
|
|
4947
|
+
|| pathname === '/chatgpt-app.html'
|
|
4948
|
+
|| pathname === '/chatgpt-plugin'
|
|
4949
|
+
|| pathname === '/chatgpt-plugin.html'
|
|
4950
|
+
)) {
|
|
4951
|
+
try {
|
|
4952
|
+
servePublicMarketingPage({
|
|
4953
|
+
req,
|
|
4954
|
+
res,
|
|
4955
|
+
parsed,
|
|
4956
|
+
hostedConfig,
|
|
4957
|
+
isHeadRequest,
|
|
4958
|
+
renderHtml: () => fs.readFileSync(CHATGPT_APP_PAGE_PATH, 'utf-8'),
|
|
4959
|
+
extraTelemetry: { pageType: 'chatgpt_app' },
|
|
4960
|
+
});
|
|
4961
|
+
} catch {
|
|
4962
|
+
sendJson(res, 404, { error: 'ChatGPT app page not found' });
|
|
4963
|
+
}
|
|
4964
|
+
return;
|
|
4965
|
+
}
|
|
4966
|
+
|
|
4745
4967
|
if (isGetLikeRequest && (pathname === '/compare' || pathname === '/compare.html')) {
|
|
4746
4968
|
try {
|
|
4747
4969
|
const html = fs.readFileSync(COMPARE_PAGE_PATH, 'utf-8');
|
|
@@ -5035,7 +5257,7 @@ async function addContext(){
|
|
|
5035
5257
|
version: pkg.version,
|
|
5036
5258
|
status: 'ok',
|
|
5037
5259
|
docs: 'https://github.com/IgorGanapolsky/ThumbGate',
|
|
5038
|
-
endpoints: ['/health', '/dashboard', '/guide', '/codex-plugin', '/compare', '/learn', '/pricing', '/v1/feedback/capture', '/v1/feedback/stats', '/v1/feedback/summary', '/v1/lessons/search', '/v1/search', '/v1/documents', '/v1/documents/import', '/v1/documents/{documentId}', '/v1/dashboard', '/v1/dashboard/render-spec', '/v1/decisions/evaluate', '/v1/decisions/outcome', '/v1/decisions/metrics', '/v1/settings/status', '/v1/dpo/export', '/v1/jobs', '/v1/jobs/harness', '/v1/analytics/databricks/export'],
|
|
5260
|
+
endpoints: ['/health', '/dashboard', '/guide', '/chatgpt-app', '/codex-plugin', '/compare', '/learn', '/pricing', '/v1/feedback/capture', '/v1/feedback/stats', '/v1/feedback/summary', '/v1/lessons/search', '/v1/search', '/v1/documents', '/v1/documents/import', '/v1/documents/{documentId}', '/v1/dashboard', '/v1/dashboard/ai-inventory', '/v1/dashboard/render-spec', '/v1/decisions/evaluate', '/v1/decisions/outcome', '/v1/decisions/metrics', '/v1/settings/status', '/v1/dpo/export', '/v1/jobs', '/v1/jobs/harness', '/v1/analytics/databricks/export'],
|
|
5039
5261
|
}, {}, {
|
|
5040
5262
|
headOnly: isHeadRequest,
|
|
5041
5263
|
});
|
|
@@ -5060,7 +5282,12 @@ async function addContext(){
|
|
|
5060
5282
|
return;
|
|
5061
5283
|
}
|
|
5062
5284
|
|
|
5063
|
-
|
|
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') {
|
|
5064
5291
|
if (isHeadRequest) {
|
|
5065
5292
|
sendHtml(res, 200, '', {}, {
|
|
5066
5293
|
headOnly: true,
|
|
@@ -5155,7 +5382,8 @@ async function addContext(){
|
|
|
5155
5382
|
isBot: botClassification.isBot ? 'true' : 'false',
|
|
5156
5383
|
reason: botClassification.reason,
|
|
5157
5384
|
}, req.headers, eventType);
|
|
5158
|
-
const
|
|
5385
|
+
const prefilledEmail = parsed?.searchParams?.get('customer_email') || '';
|
|
5386
|
+
const html = renderCheckoutIntentPage(prefilledEmail);
|
|
5159
5387
|
sendHtml(res, 200, html, responseHeaders);
|
|
5160
5388
|
return;
|
|
5161
5389
|
}
|
|
@@ -6919,24 +7147,159 @@ ${hidden}
|
|
|
6919
7147
|
|
|
6920
7148
|
try {
|
|
6921
7149
|
if (req.method === 'GET' && pathname === '/v1/feedback/stats') {
|
|
6922
|
-
|
|
7150
|
+
const stats = analyzeFeedback(requestFeedbackPaths.FEEDBACK_LOG_PATH);
|
|
7151
|
+
try {
|
|
7152
|
+
const { getStatuslineMeta } = require('../../scripts/statusline-meta');
|
|
7153
|
+
const meta = getStatuslineMeta({ env: process.env });
|
|
7154
|
+
stats.tier = meta.tier;
|
|
7155
|
+
} catch (error) {
|
|
7156
|
+
debugApiFallback('statusline meta unavailable', error);
|
|
7157
|
+
stats.tier = 'Pro';
|
|
7158
|
+
}
|
|
7159
|
+
|
|
7160
|
+
const projectChatSettings = readProjectChatSettings(req, parsed);
|
|
7161
|
+
|
|
7162
|
+
stats.geminiConfigured = Boolean(
|
|
7163
|
+
projectChatSettings.geminiKey ||
|
|
7164
|
+
process.env.GEMINI_API_KEY ||
|
|
7165
|
+
process.env.THUMBGATE_GEMINI_API_KEY ||
|
|
7166
|
+
process.env.GOOGLE_API_KEY
|
|
7167
|
+
);
|
|
7168
|
+
stats.perplexityConfigured = Boolean(
|
|
7169
|
+
projectChatSettings.perplexityKey ||
|
|
7170
|
+
process.env.PERPLEXITY_API_KEY ||
|
|
7171
|
+
process.env.THUMBGATE_PERPLEXITY_API_KEY
|
|
7172
|
+
);
|
|
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
|
+
}
|
|
7181
|
+
stats.hybridInferenceAvailable = !!(stats.geminiConfigured || stats.perplexityConfigured);
|
|
7182
|
+
sendJson(res, 200, stats);
|
|
6923
7183
|
return;
|
|
6924
7184
|
}
|
|
6925
7185
|
|
|
6926
|
-
// Chat with your data —
|
|
6927
|
-
//
|
|
6928
|
-
//
|
|
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).
|
|
6929
7192
|
if (req.method === 'POST' && pathname === '/v1/chat') {
|
|
6930
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
|
+
|
|
6931
7209
|
const { answerDataQuestion } = require('../../scripts/dashboard-chat');
|
|
6932
|
-
|
|
6933
|
-
|
|
7210
|
+
|
|
7211
|
+
const projectChatSettings = readProjectChatSettings(req, parsed);
|
|
7212
|
+
|
|
7213
|
+
const result = await answerDataQuestion(question, {
|
|
7214
|
+
feedbackDir: chatFeedbackDir,
|
|
6934
7215
|
model: typeof body.model === 'string' ? body.model : undefined,
|
|
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 || '',
|
|
6935
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
|
+
|
|
6936
7231
|
sendJson(res, result.ok ? 200 : (result.error === 'no_api_key' ? 503 : 400), result);
|
|
6937
7232
|
return;
|
|
6938
7233
|
}
|
|
6939
7234
|
|
|
7235
|
+
// Save Gemini API key from the dashboard UI
|
|
7236
|
+
if (req.method === 'POST' && pathname === '/v1/settings/gemini-key') {
|
|
7237
|
+
const body = await parseJsonBody(req);
|
|
7238
|
+
const key = String(body.key || '').trim();
|
|
7239
|
+
if (!key) {
|
|
7240
|
+
sendJson(res, 400, { ok: false, error: 'missing_key', message: 'No API key provided.' });
|
|
7241
|
+
return;
|
|
7242
|
+
}
|
|
7243
|
+
|
|
7244
|
+
// Validate the candidate key using the *exact* same code path as /v1/chat
|
|
7245
|
+
// (project-scoped .env read + RAG + Gemini call). This prevents saving a
|
|
7246
|
+
// key that will later produce the confusing "API key not valid" error in chat.
|
|
7247
|
+
let validation;
|
|
7248
|
+
try {
|
|
7249
|
+
const { answerDataQuestion } = require('../../scripts/dashboard-chat');
|
|
7250
|
+
validation = await answerDataQuestion('Reply with the single word: PONG', {
|
|
7251
|
+
feedbackDir: requestFeedbackPaths.FEEDBACK_DIR,
|
|
7252
|
+
apiKey: key,
|
|
7253
|
+
});
|
|
7254
|
+
} catch (e) {
|
|
7255
|
+
validation = { ok: false, error: 'validation_exception', message: String(e?.message || e) };
|
|
7256
|
+
}
|
|
7257
|
+
|
|
7258
|
+
if (!validation.ok) {
|
|
7259
|
+
const detail = validation.error === 'gemini_error'
|
|
7260
|
+
? (validation.message || 'Gemini rejected the key')
|
|
7261
|
+
: (validation.message || validation.error || 'unknown error');
|
|
7262
|
+
sendJson(res, 400, {
|
|
7263
|
+
ok: false,
|
|
7264
|
+
error: 'invalid_key',
|
|
7265
|
+
message: 'Key validation failed: ' + detail + '. Get a fresh key from https://aistudio.google.com/app/apikey (or run `npx thumbgate setup-vertex` for Vertex) and try again.'
|
|
7266
|
+
});
|
|
7267
|
+
return;
|
|
7268
|
+
}
|
|
7269
|
+
|
|
7270
|
+
try {
|
|
7271
|
+
const projectDir = resolveRequestProjectDir(req, parsed);
|
|
7272
|
+
const envPath = path.join(projectDir, '.env');
|
|
7273
|
+
let content = '';
|
|
7274
|
+
if (fs.existsSync(envPath)) {
|
|
7275
|
+
content = fs.readFileSync(envPath, 'utf8');
|
|
7276
|
+
}
|
|
7277
|
+
const regex = /^GEMINI_API_KEY=.*$/m;
|
|
7278
|
+
if (regex.test(content)) {
|
|
7279
|
+
content = content.replace(regex, `GEMINI_API_KEY=${key}`);
|
|
7280
|
+
} else {
|
|
7281
|
+
content = content.trim() + `\nGEMINI_API_KEY=${key}\n`;
|
|
7282
|
+
}
|
|
7283
|
+
fs.writeFileSync(envPath, content, 'utf8');
|
|
7284
|
+
// Also set it in the current process so it takes effect immediately without restart
|
|
7285
|
+
process.env.GEMINI_API_KEY = key;
|
|
7286
|
+
// Persist validation success for reliable "configured" status in stats/hints
|
|
7287
|
+
try {
|
|
7288
|
+
const statusPath = path.join(projectDir, '.gemini-validated.json');
|
|
7289
|
+
fs.writeFileSync(statusPath, JSON.stringify({
|
|
7290
|
+
validatedAt: new Date().toISOString(),
|
|
7291
|
+
validatedBy: 'dashboard-save'
|
|
7292
|
+
}, null, 2));
|
|
7293
|
+
} catch (error) {
|
|
7294
|
+
debugApiFallback('Gemini validation marker unavailable', error);
|
|
7295
|
+
}
|
|
7296
|
+
sendJson(res, 200, { ok: true, message: 'Key saved and validated.' });
|
|
7297
|
+
} catch (e) {
|
|
7298
|
+
sendJson(res, 500, { ok: false, error: 'fs_error', message: 'Failed to write to .env file: ' + e.message });
|
|
7299
|
+
}
|
|
7300
|
+
return;
|
|
7301
|
+
}
|
|
7302
|
+
|
|
6940
7303
|
// Server-Sent Events stream of live feedback / rule-regen / gate events.
|
|
6941
7304
|
// Dashboard clients subscribe once (with the same Bearer auth already
|
|
6942
7305
|
// required for /v1/feedback/stats) and receive pushed events as they
|
|
@@ -6992,14 +7355,20 @@ ${hidden}
|
|
|
6992
7355
|
return;
|
|
6993
7356
|
}
|
|
6994
7357
|
|
|
6995
|
-
if (req.method === 'GET' &&
|
|
6996
|
-
|
|
7358
|
+
if (req.method === 'GET' && (
|
|
7359
|
+
pathname === '/v1/enterprise/data-chat/status'
|
|
7360
|
+
|| pathname === '/v1/enterprise/dialogflow/status'
|
|
7361
|
+
)) {
|
|
7362
|
+
sendJson(res, 200, buildEnterpriseDataChatStatus());
|
|
6997
7363
|
return;
|
|
6998
7364
|
}
|
|
6999
7365
|
|
|
7000
|
-
if (req.method === 'POST' &&
|
|
7366
|
+
if (req.method === 'POST' && (
|
|
7367
|
+
pathname === '/v1/enterprise/data-chat/chat'
|
|
7368
|
+
|| pathname === '/v1/enterprise/dialogflow/chat'
|
|
7369
|
+
)) {
|
|
7001
7370
|
const body = await parseJsonBody(req, 16 * 1024);
|
|
7002
|
-
const result = await
|
|
7371
|
+
const result = await answerEnterpriseDataChat({
|
|
7003
7372
|
prompt: body.prompt || body.message || body.query,
|
|
7004
7373
|
feedbackDir: requestFeedbackDir,
|
|
7005
7374
|
parsed,
|
|
@@ -7292,6 +7661,7 @@ ${hidden}
|
|
|
7292
7661
|
summary: body.summary,
|
|
7293
7662
|
allowedPaths: body.allowedPaths,
|
|
7294
7663
|
protectedPaths: body.protectedPaths,
|
|
7664
|
+
workflowContract: body.workflowContract,
|
|
7295
7665
|
repoPath: body.repoPath,
|
|
7296
7666
|
localOnly: body.localOnly === true,
|
|
7297
7667
|
clear: body.clear === true,
|
|
@@ -8180,6 +8550,40 @@ ${hidden}
|
|
|
8180
8550
|
return;
|
|
8181
8551
|
}
|
|
8182
8552
|
|
|
8553
|
+
// GET /v1/dashboard/ai-inventory -- Enterprise AI inventory evidence
|
|
8554
|
+
if (req.method === 'GET' && pathname === '/v1/dashboard/ai-inventory') {
|
|
8555
|
+
try {
|
|
8556
|
+
const {
|
|
8557
|
+
scanAiComponents,
|
|
8558
|
+
buildCycloneDxMlBom,
|
|
8559
|
+
} = require('../../scripts/ai-component-inventory');
|
|
8560
|
+
const requestedRoot = parsed.searchParams.get('root');
|
|
8561
|
+
const serverRoot = process.cwd();
|
|
8562
|
+
const rootDir = requestedRoot ? path.resolve(requestedRoot) : serverRoot;
|
|
8563
|
+
const rootRel = path.relative(serverRoot, rootDir);
|
|
8564
|
+
if (rootRel.startsWith('..') || path.isAbsolute(rootRel)) {
|
|
8565
|
+
sendJson(res, 400, {
|
|
8566
|
+
error: 'ai_inventory_root_out_of_scope',
|
|
8567
|
+
message: 'Dashboard AI inventory root must stay within the server working directory. Use the CLI for explicit cross-project scans.',
|
|
8568
|
+
});
|
|
8569
|
+
return;
|
|
8570
|
+
}
|
|
8571
|
+
const inventory = scanAiComponents({
|
|
8572
|
+
rootDir,
|
|
8573
|
+
maxFiles: parsed.searchParams.get('maxFiles') ? Number(parsed.searchParams.get('maxFiles')) : undefined,
|
|
8574
|
+
includeSnippets: parsed.searchParams.get('snippets') !== '0',
|
|
8575
|
+
});
|
|
8576
|
+
const format = String(parsed.searchParams.get('format') || 'json').toLowerCase();
|
|
8577
|
+
sendJson(res, 200, format === 'cyclonedx' ? buildCycloneDxMlBom(inventory, { version: pkg.version }) : inventory);
|
|
8578
|
+
} catch (err) {
|
|
8579
|
+
sendJson(res, 500, {
|
|
8580
|
+
error: 'ai_inventory_failed',
|
|
8581
|
+
message: err?.message || 'Unable to scan AI component inventory.',
|
|
8582
|
+
});
|
|
8583
|
+
}
|
|
8584
|
+
return;
|
|
8585
|
+
}
|
|
8586
|
+
|
|
8183
8587
|
// GET /v1/dashboard/review-state -- incremental review baseline and deltas
|
|
8184
8588
|
if (req.method === 'GET' && pathname === '/v1/dashboard/review-state') {
|
|
8185
8589
|
const reviewState = readDashboardReviewState(requestFeedbackDir);
|
|
@@ -8199,7 +8603,7 @@ ${hidden}
|
|
|
8199
8603
|
const body = await parseJsonBody(req);
|
|
8200
8604
|
const snapshot = buildReviewSnapshot(requestFeedbackDir);
|
|
8201
8605
|
// Override snapshot timestamp with client-provided one if available
|
|
8202
|
-
if (body
|
|
8606
|
+
if (body?.reviewedAt) {
|
|
8203
8607
|
snapshot.reviewedAt = body.reviewedAt;
|
|
8204
8608
|
}
|
|
8205
8609
|
writeDashboardReviewState(requestFeedbackDir, snapshot);
|
|
@@ -8517,8 +8921,10 @@ module.exports = {
|
|
|
8517
8921
|
resolveLocalPageBootstrap,
|
|
8518
8922
|
getPublicMcpTools,
|
|
8519
8923
|
getServerCardTools,
|
|
8924
|
+
buildEnterpriseDataChatStatus,
|
|
8520
8925
|
buildEnterpriseDialogflowStatus,
|
|
8521
8926
|
buildEnterpriseChatAnswer,
|
|
8927
|
+
answerEnterpriseDataChat,
|
|
8522
8928
|
answerEnterpriseDialogflowChat,
|
|
8523
8929
|
},
|
|
8524
8930
|
};
|