thumbgate 1.27.2 → 1.27.4
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/mcp/server-card.json +1 -1
- package/README.md +28 -26
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/mcp/server-stdio.js +14 -1
- package/adapters/opencode/opencode.json +1 -1
- package/bin/cli.js +75 -24
- package/package.json +3 -3
- package/public/about.html +162 -0
- package/public/compare.html +2 -2
- package/public/dashboard.html +32 -30
- package/public/guide.html +2 -2
- package/public/index.html +61 -54
- package/public/numbers.html +2 -2
- package/public/pricing.html +23 -36
- package/public/pro.html +3 -3
- package/scripts/commercial-offer.js +10 -2
- package/scripts/dashboard-chat.js +173 -72
- package/scripts/dashboard.js +17 -3
- package/scripts/gates-engine.js +43 -6
- package/scripts/oss-pr-opportunity-scout.js +35 -5
- package/scripts/rate-limiter.js +2 -2
- package/scripts/seo-gsd.js +60 -0
- package/scripts/workflow-sentinel.js +111 -68
- package/src/api/server.js +363 -154
- package/.claude-plugin/marketplace.json +0 -85
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
|
|
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: '
|
|
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,185 @@ function compactNumber(value) {
|
|
|
1518
1567
|
return Number.isFinite(n) ? n : 0;
|
|
1519
1568
|
}
|
|
1520
1569
|
|
|
1521
|
-
function
|
|
1522
|
-
|
|
1570
|
+
function isTodayScopedPrompt(prompt) {
|
|
1571
|
+
return /\btoday\b|\bthis day\b|\blast 24\b|\b24 hours\b/i.test(String(prompt || ''));
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
function getTodayGateAudit(gateAudit) {
|
|
1575
|
+
const days = gateAudit && Array.isArray(gateAudit.days) ? gateAudit.days : [];
|
|
1576
|
+
return days.length > 0 ? days[days.length - 1] : null;
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
function formatGateBuckets(byGate) {
|
|
1580
|
+
return Object.entries(byGate || {})
|
|
1581
|
+
.filter(([, count]) => compactNumber(count) > 0)
|
|
1582
|
+
.sort((a, b) => compactNumber(b[1]) - compactNumber(a[1]))
|
|
1583
|
+
.slice(0, 3)
|
|
1584
|
+
.map(([gateId, count]) => `${gateId} (${compactNumber(count)}x)`);
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
function buildTodayGateAnswer(prompt, dashboardData, gateStats, gates) {
|
|
1588
|
+
if (!isTodayScopedPrompt(prompt)) return null;
|
|
1589
|
+
const lower = String(prompt || '').toLowerCase();
|
|
1590
|
+
const gateAudit = dashboardData.gateAudit || {};
|
|
1591
|
+
const prevention = dashboardData.prevention || {};
|
|
1592
|
+
const today = getTodayGateAudit(gateAudit) || { deny: 0, warn: 0, intercepted: 0, byGate: {} };
|
|
1593
|
+
const activeGateCount = gates.length || compactNumber(gateStats.totalGates);
|
|
1594
|
+
|
|
1595
|
+
if (/\b(activated|promoted|created|enabled)\b/.test(lower)) {
|
|
1596
|
+
const promotionsToday = compactNumber(prevention.promotionsToday);
|
|
1597
|
+
const promotedIds = Array.isArray(prevention.promotionIdsToday) ? prevention.promotionIdsToday.filter(Boolean) : [];
|
|
1598
|
+
return [
|
|
1599
|
+
`Gates activated today: ${promotionsToday}.`,
|
|
1600
|
+
`Active gates now: ${activeGateCount}.`,
|
|
1601
|
+
promotedIds.length > 0 ? `Promoted today: ${promotedIds.slice(0, 3).join(', ')}.` : '',
|
|
1602
|
+
].filter(Boolean);
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
if (/\b(what|which)\b/.test(lower) && /\b(mistake|mistakes|block|blocked|prevent|prevented)\b/.test(lower)) {
|
|
1606
|
+
const gateBuckets = formatGateBuckets(today.byGate);
|
|
1607
|
+
return [
|
|
1608
|
+
`Today: ${compactNumber(today.deny)} blocked actions and ${compactNumber(today.warn)} warning checkpoints.`,
|
|
1609
|
+
gateBuckets.length > 0
|
|
1610
|
+
? `Top blocked/warned gates today: ${gateBuckets.join(', ')}.`
|
|
1611
|
+
: 'No per-gate blocked mistake names are present in today\'s local audit snapshot.',
|
|
1612
|
+
];
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
if (/\b(prevent|prevented|intercept|intercepted)\b/.test(lower)) {
|
|
1616
|
+
return [
|
|
1617
|
+
`Mistakes prevented today: ${compactNumber(today.intercepted)} interventions (${compactNumber(today.deny)} blocked, ${compactNumber(today.warn)} warned).`,
|
|
1618
|
+
`All-time blocked actions recorded: ${compactNumber(gateStats.blocked || gateStats.denied || gateStats.totalBlocked)}.`,
|
|
1619
|
+
];
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
if (/\b(block|blocked|deny|denied)\b/.test(lower)) {
|
|
1623
|
+
return [
|
|
1624
|
+
`Mistakes blocked today: ${compactNumber(today.deny)} deny decisions.`,
|
|
1625
|
+
`Warnings today: ${compactNumber(today.warn)}; all-time blocked actions recorded: ${compactNumber(gateStats.blocked || gateStats.denied || gateStats.totalBlocked)}.`,
|
|
1626
|
+
];
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
return null;
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
function buildEnterpriseChatSection(topic, dashboardData, status, prompt) {
|
|
1523
1633
|
const approval = dashboardData.approval || {};
|
|
1524
1634
|
const gates = Array.isArray(dashboardData.gates) ? dashboardData.gates : [];
|
|
1525
1635
|
const gateStats = dashboardData.gateStats || {};
|
|
1526
1636
|
const team = dashboardData.team || {};
|
|
1527
1637
|
const tokenSavings = dashboardData.tokenSavings || {};
|
|
1528
1638
|
const lessonPipeline = dashboardData.lessonPipeline || {};
|
|
1529
|
-
const lines = [];
|
|
1530
|
-
const sources = ['local dashboard data'];
|
|
1531
1639
|
|
|
1532
1640
|
if (topic === 'feedback') {
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1641
|
+
return {
|
|
1642
|
+
lines: [
|
|
1643
|
+
`Feedback total: ${compactNumber(approval.total)} (${compactNumber(approval.positive)} positive, ${compactNumber(approval.negative)} negative).`,
|
|
1644
|
+
`Lesson pipeline: ${compactNumber(lessonPipeline.lessons || lessonPipeline.generated || 0)} lessons visible in the current dashboard snapshot.`,
|
|
1645
|
+
],
|
|
1646
|
+
sources: ['feedback log', 'lesson pipeline'],
|
|
1647
|
+
};
|
|
1648
|
+
}
|
|
1649
|
+
if (topic === 'gates') {
|
|
1650
|
+
const todayGateAnswer = buildTodayGateAnswer(prompt, dashboardData, gateStats, gates);
|
|
1651
|
+
if (todayGateAnswer) {
|
|
1652
|
+
return {
|
|
1653
|
+
lines: todayGateAnswer,
|
|
1654
|
+
sources: ['gate audit', 'gate stats'],
|
|
1655
|
+
};
|
|
1656
|
+
}
|
|
1657
|
+
return {
|
|
1658
|
+
lines: [
|
|
1659
|
+
`Active gates: ${gates.length || compactNumber(gateStats.totalGates)}.`,
|
|
1660
|
+
`Blocked actions recorded: ${compactNumber(gateStats.blocked || gateStats.denied || gateStats.totalBlocked)}.`,
|
|
1661
|
+
gates[0] ? `Example gate: ${gates[0].name || gates[0].id || 'unnamed gate'}.` : '',
|
|
1662
|
+
].filter(Boolean),
|
|
1663
|
+
sources: ['gate stats'],
|
|
1664
|
+
};
|
|
1665
|
+
}
|
|
1666
|
+
if (topic === 'team') {
|
|
1667
|
+
return {
|
|
1668
|
+
lines: [
|
|
1669
|
+
'Team dashboard is available in this local Enterprise view.',
|
|
1670
|
+
`Tracked agents: ${compactNumber(team.totalAgents || team.agentCount || 0)}; risky agents: ${compactNumber(team.riskyAgents || team.highRiskAgents || 0)}.`,
|
|
1671
|
+
],
|
|
1672
|
+
sources: ['team dashboard'],
|
|
1673
|
+
};
|
|
1674
|
+
}
|
|
1675
|
+
if (topic === 'cost') {
|
|
1676
|
+
return {
|
|
1677
|
+
lines: [
|
|
1678
|
+
`Estimated token savings: ${tokenSavings.dollarsSavedDisplay || '$0.00'} from ${compactNumber(tokenSavings.blockedCalls)} blocked calls.`,
|
|
1679
|
+
'Google Cloud budget alerts are evidence for spend visibility; ThumbGate-side stop conditions must be verified separately before calling them a hard cap.',
|
|
1680
|
+
],
|
|
1681
|
+
sources: ['token savings', 'budget posture'],
|
|
1682
|
+
};
|
|
1683
|
+
}
|
|
1684
|
+
if (topic === 'cloud') {
|
|
1685
|
+
const vertexLine = status.vertex.configured
|
|
1551
1686
|
? `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
|
-
|
|
1687
|
+
: 'Vertex routing config is not present in this server environment.';
|
|
1688
|
+
const dfcxLine = status.dfcx.liveAgentConfigured
|
|
1554
1689
|
? `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
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1690
|
+
: '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.';
|
|
1691
|
+
return {
|
|
1692
|
+
lines: [vertexLine, dfcxLine],
|
|
1693
|
+
sources: ['enterprise cloud status'],
|
|
1694
|
+
};
|
|
1560
1695
|
}
|
|
1696
|
+
return {
|
|
1697
|
+
lines: [
|
|
1698
|
+
'Ask about feedback, lessons, active gates, team rollout, token savings, or Vertex/DFCX readiness.',
|
|
1699
|
+
`Current local snapshot: ${compactNumber(approval.total)} feedback events and ${gates.length || compactNumber(gateStats.totalGates)} active gates.`,
|
|
1700
|
+
],
|
|
1701
|
+
sources: [],
|
|
1702
|
+
};
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
function buildEnterpriseChatAnswer(prompt, dashboardData, status) {
|
|
1706
|
+
const topic = classifyEnterpriseChatTopic(prompt);
|
|
1707
|
+
const section = buildEnterpriseChatSection(topic, dashboardData, status, prompt);
|
|
1561
1708
|
|
|
1562
1709
|
return {
|
|
1563
1710
|
topic,
|
|
1564
|
-
answer: lines.join(' '),
|
|
1565
|
-
sources,
|
|
1711
|
+
answer: section.lines.join(' '),
|
|
1712
|
+
sources: ['local dashboard data', ...section.sources],
|
|
1566
1713
|
};
|
|
1567
1714
|
}
|
|
1568
1715
|
|
|
1569
|
-
|
|
1716
|
+
// Answer the dashboard "Chat with your data" panel LOCALLY (deterministic, no
|
|
1717
|
+
// cloud/LLM) from this install's own per-project dashboard data. Returns true if
|
|
1718
|
+
// it sent a response; false if the local snapshot was unavailable (caller then
|
|
1719
|
+
// falls through). Shared by the local-first path and the no-model fallback.
|
|
1720
|
+
async function trySendLocalDashboardChat(res, parsed, feedbackDir, prompt, suffix) {
|
|
1721
|
+
try {
|
|
1722
|
+
const dashboardResult = await buildLiveDashboardData(parsed, feedbackDir);
|
|
1723
|
+
const localChat = buildEnterpriseChatAnswer(
|
|
1724
|
+
prompt,
|
|
1725
|
+
dashboardResult.data,
|
|
1726
|
+
buildEnterpriseDataChatStatus(),
|
|
1727
|
+
);
|
|
1728
|
+
sendJson(res, 200, {
|
|
1729
|
+
ok: true,
|
|
1730
|
+
answer: suffix ? `${localChat.answer} ${suffix}` : localChat.answer,
|
|
1731
|
+
sources: (localChat.sources || []).map((title) => ({ title })),
|
|
1732
|
+
topic: localChat.topic,
|
|
1733
|
+
provider: 'local-data',
|
|
1734
|
+
llm: 'none',
|
|
1735
|
+
grounded: true,
|
|
1736
|
+
});
|
|
1737
|
+
return true;
|
|
1738
|
+
} catch (_) {
|
|
1739
|
+
return false;
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
async function answerEnterpriseDataChat({ prompt, feedbackDir, parsed }) {
|
|
1570
1744
|
const normalizedPrompt = normalizeEnterpriseChatPrompt(prompt);
|
|
1571
1745
|
if (!normalizedPrompt) {
|
|
1572
1746
|
throw createHttpError(400, 'prompt is required');
|
|
1573
1747
|
}
|
|
1574
|
-
const status =
|
|
1748
|
+
const status = buildEnterpriseDataChatStatus();
|
|
1575
1749
|
if (containsUnsafeEnterpriseChatInput(normalizedPrompt)) {
|
|
1576
1750
|
return {
|
|
1577
1751
|
ok: false,
|
|
@@ -1592,6 +1766,11 @@ async function answerEnterpriseDialogflowChat({ prompt, feedbackDir, parsed }) {
|
|
|
1592
1766
|
|
|
1593
1767
|
const dashboardResult = await buildLiveDashboardData(parsed, feedbackDir);
|
|
1594
1768
|
const dashboardData = dashboardResult.data;
|
|
1769
|
+
|
|
1770
|
+
// This guarded stats endpoint stays deterministic for API compatibility.
|
|
1771
|
+
// The dashboard's real local/open-source chatbot turn goes through /v1/chat,
|
|
1772
|
+
// which uses lesson retrieval + optional LanceDB vector search + the user's
|
|
1773
|
+
// configured local or BYO model.
|
|
1595
1774
|
const chat = buildEnterpriseChatAnswer(normalizedPrompt, dashboardData, status);
|
|
1596
1775
|
const dfcxRequest = {
|
|
1597
1776
|
fulfillmentInfo: { tag: 'chat-with-data' },
|
|
@@ -1627,6 +1806,8 @@ async function answerEnterpriseDialogflowChat({ prompt, feedbackDir, parsed }) {
|
|
|
1627
1806
|
};
|
|
1628
1807
|
}
|
|
1629
1808
|
|
|
1809
|
+
const answerEnterpriseDialogflowChat = answerEnterpriseDataChat;
|
|
1810
|
+
|
|
1630
1811
|
function buildLossAnalyticsResponse(data, summaryOptions) {
|
|
1631
1812
|
return {
|
|
1632
1813
|
window: data.analytics.window || summaryOptions,
|
|
@@ -1706,9 +1887,9 @@ function buildCheckoutIntentHref(baseUrl, metadata = {}, overrides = {}) {
|
|
|
1706
1887
|
});
|
|
1707
1888
|
}
|
|
1708
1889
|
|
|
1709
|
-
function renderCheckoutIntentPage() {
|
|
1890
|
+
function renderCheckoutIntentPage(prefilledEmail = '') {
|
|
1710
1891
|
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 <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>`;
|
|
1892
|
+
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>`;
|
|
1712
1893
|
}
|
|
1713
1894
|
|
|
1714
1895
|
function buildCheckoutBootstrapBody(parsed, req, journeyState = resolveJourneyState(req, parsed)) {
|
|
@@ -2200,6 +2381,10 @@ function readOptionalPublicTemplate(filePath) {
|
|
|
2200
2381
|
}
|
|
2201
2382
|
}
|
|
2202
2383
|
|
|
2384
|
+
function escapeJsonForInlineScript(value) {
|
|
2385
|
+
return JSON.stringify(value).replaceAll('<', String.raw`\u003c`);
|
|
2386
|
+
}
|
|
2387
|
+
|
|
2203
2388
|
function resolveLocalPageBootstrap(req, expectedApiKey) {
|
|
2204
2389
|
const forwardedHost = req.headers['x-forwarded-host'];
|
|
2205
2390
|
const hostHeader = Array.isArray(forwardedHost)
|
|
@@ -2208,7 +2393,13 @@ function resolveLocalPageBootstrap(req, expectedApiKey) {
|
|
|
2208
2393
|
const localProBootstrap = process.env.THUMBGATE_PRO_MODE === '1' && Boolean(expectedApiKey) && isLoopbackHost(hostHeader);
|
|
2209
2394
|
const devOverride = expectedApiKey === null && isLoopbackHost(hostHeader);
|
|
2210
2395
|
const bootstrapActive = localProBootstrap || devOverride;
|
|
2211
|
-
|
|
2396
|
+
let bootstrapKey = '';
|
|
2397
|
+
if (localProBootstrap) {
|
|
2398
|
+
bootstrapKey = expectedApiKey;
|
|
2399
|
+
} else if (devOverride) {
|
|
2400
|
+
bootstrapKey = process.env.THUMBGATE_API_KEY || 'dev-override';
|
|
2401
|
+
}
|
|
2402
|
+
const serializedBootstrapKey = escapeJsonForInlineScript(bootstrapKey);
|
|
2212
2403
|
|
|
2213
2404
|
return {
|
|
2214
2405
|
bootstrapActive,
|
|
@@ -2249,7 +2440,7 @@ window.THUMBGATE_DASHBOARD_BOOTSTRAP = { enabled: ${bootstrapActive ? 'true' : '
|
|
|
2249
2440
|
<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
2441
|
<div class="grid">
|
|
2251
2442
|
<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/
|
|
2443
|
+
<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
2444
|
<a class="card" href="/lessons"><strong>Lessons</strong><span>Review remembered thumbs-up/down lessons and enforcement context.</span></a>
|
|
2254
2445
|
<a class="card" href="/health"><strong>Health</strong><span>Verify the installed package version and runtime status.</span></a>
|
|
2255
2446
|
</div>
|
|
@@ -2329,6 +2520,53 @@ function normalizeLessonSignal(signal) {
|
|
|
2329
2520
|
return 'down';
|
|
2330
2521
|
}
|
|
2331
2522
|
|
|
2523
|
+
function splitLessonTitlePrefix(titleText) {
|
|
2524
|
+
const prefixMatch = /^(MISTAKE|SUCCESS|LEARNING|PREFERENCE):\s*(.*)/i.exec(titleText);
|
|
2525
|
+
if (!prefixMatch) return { prefix: '', rest: titleText };
|
|
2526
|
+
return {
|
|
2527
|
+
prefix: `${prefixMatch[1].toUpperCase()}: `,
|
|
2528
|
+
rest: prefixMatch[2],
|
|
2529
|
+
};
|
|
2530
|
+
}
|
|
2531
|
+
|
|
2532
|
+
function maybeParseJsonObject(value) {
|
|
2533
|
+
const trimmed = String(value || '').trim();
|
|
2534
|
+
if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) return null;
|
|
2535
|
+
try {
|
|
2536
|
+
return JSON.parse(trimmed);
|
|
2537
|
+
} catch (error) {
|
|
2538
|
+
debugApiFallback('lesson JSON parse skipped', error);
|
|
2539
|
+
return null;
|
|
2540
|
+
}
|
|
2541
|
+
}
|
|
2542
|
+
|
|
2543
|
+
function formatLessonJsonTitle(prefix, parsed) {
|
|
2544
|
+
const dirName = parsed.cwd ? parsed.cwd.split('/').pop() : '';
|
|
2545
|
+
const suffix = dirName ? ` inside ${dirName}` : '';
|
|
2546
|
+
if (parsed.prompt) return `${prefix}Prompt "${parsed.prompt}"${suffix}`;
|
|
2547
|
+
const hookVal = parsed.hook_event_name || parsed.hookEventName;
|
|
2548
|
+
if (hookVal) return `${prefix}Hook event ${hookVal}${suffix}`;
|
|
2549
|
+
if (parsed.signal) return `${prefix}${parsed.signal === 'up' ? 'Thumbs Up' : 'Thumbs Down'}${suffix}`;
|
|
2550
|
+
return '';
|
|
2551
|
+
}
|
|
2552
|
+
|
|
2553
|
+
function cleanLessonTitle(titleText) {
|
|
2554
|
+
if (!titleText) return 'Untitled Lesson';
|
|
2555
|
+
const { prefix, rest } = splitLessonTitlePrefix(titleText);
|
|
2556
|
+
const parsed = maybeParseJsonObject(rest);
|
|
2557
|
+
if (parsed) {
|
|
2558
|
+
const jsonTitle = formatLessonJsonTitle(prefix, parsed);
|
|
2559
|
+
if (jsonTitle) return jsonTitle;
|
|
2560
|
+
}
|
|
2561
|
+
return titleText;
|
|
2562
|
+
}
|
|
2563
|
+
|
|
2564
|
+
function formatLessonTextValue(value) {
|
|
2565
|
+
if (!value) return '';
|
|
2566
|
+
const parsed = maybeParseJsonObject(value);
|
|
2567
|
+
return parsed ? JSON.stringify(parsed, null, 2) : value;
|
|
2568
|
+
}
|
|
2569
|
+
|
|
2332
2570
|
function renderLessonDetailHtml(record, lessonId) {
|
|
2333
2571
|
if (!record) {
|
|
2334
2572
|
return `<!DOCTYPE html><html><head><meta charset="utf-8"><title>Lesson Not Found</title>
|
|
@@ -2352,58 +2590,10 @@ a{color:#22d3ee;text-decoration:none}</style></head><body>
|
|
|
2352
2590
|
const rawWhatWentWrong = merged.whatWentWrong || '';
|
|
2353
2591
|
const rawWhatWorked = merged.whatWorked || '';
|
|
2354
2592
|
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
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);
|
|
2593
|
+
const title = cleanLessonTitle(rawTitle);
|
|
2594
|
+
const context = formatLessonTextValue(rawContext);
|
|
2595
|
+
const whatWentWrong = formatLessonTextValue(rawWhatWentWrong);
|
|
2596
|
+
const whatWorked = formatLessonTextValue(rawWhatWorked);
|
|
2407
2597
|
const whatToChange = merged.whatToChange || '';
|
|
2408
2598
|
const tags = Array.isArray(merged.tags) ? merged.tags.join(', ') : (merged.tags || '');
|
|
2409
2599
|
const timestamp = merged.timestamp ? new Date(merged.timestamp).toLocaleString() : '';
|
|
@@ -4295,7 +4485,10 @@ function createApiServer() {
|
|
|
4295
4485
|
});
|
|
4296
4486
|
sendJson(res, 200, result);
|
|
4297
4487
|
} catch (e) {
|
|
4298
|
-
sendJson(res, 500, {
|
|
4488
|
+
sendJson(res, 500, {
|
|
4489
|
+
error: 'feedback submission failed',
|
|
4490
|
+
message: e?.message || 'Unable to submit dashboard feedback.',
|
|
4491
|
+
});
|
|
4299
4492
|
}
|
|
4300
4493
|
});
|
|
4301
4494
|
return;
|
|
@@ -5158,7 +5351,12 @@ async function addContext(){
|
|
|
5158
5351
|
return;
|
|
5159
5352
|
}
|
|
5160
5353
|
|
|
5161
|
-
|
|
5354
|
+
// HOTFIX 2026-06-04 — accept ANY method (GET/HEAD/POST) on /checkout/pro
|
|
5355
|
+
// to prevent the API-key guard from 401'ing real prospective customers
|
|
5356
|
+
// whose forms or fetch() calls land via POST. Audit: 69 emails submitted
|
|
5357
|
+
// → 0 paid because POST hit the auth gate. Query params still drive the
|
|
5358
|
+
// Stripe session creation; POST bodies are ignored harmlessly.
|
|
5359
|
+
if ((isGetLikeRequest || req.method === 'POST') && pathname === '/checkout/pro') {
|
|
5162
5360
|
if (isHeadRequest) {
|
|
5163
5361
|
sendHtml(res, 200, '', {}, {
|
|
5164
5362
|
headOnly: true,
|
|
@@ -5253,7 +5451,8 @@ async function addContext(){
|
|
|
5253
5451
|
isBot: botClassification.isBot ? 'true' : 'false',
|
|
5254
5452
|
reason: botClassification.reason,
|
|
5255
5453
|
}, req.headers, eventType);
|
|
5256
|
-
const
|
|
5454
|
+
const prefilledEmail = parsed?.searchParams?.get('customer_email') || '';
|
|
5455
|
+
const html = renderCheckoutIntentPage(prefilledEmail);
|
|
5257
5456
|
sendHtml(res, 200, html, responseHeaders);
|
|
5258
5457
|
return;
|
|
5259
5458
|
}
|
|
@@ -7022,82 +7221,82 @@ ${hidden}
|
|
|
7022
7221
|
const { getStatuslineMeta } = require('../../scripts/statusline-meta');
|
|
7023
7222
|
const meta = getStatuslineMeta({ env: process.env });
|
|
7024
7223
|
stats.tier = meta.tier;
|
|
7025
|
-
} catch (
|
|
7224
|
+
} catch (error) {
|
|
7225
|
+
debugApiFallback('statusline meta unavailable', error);
|
|
7026
7226
|
stats.tier = 'Pro';
|
|
7027
7227
|
}
|
|
7028
7228
|
|
|
7029
|
-
|
|
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 (_) {}
|
|
7229
|
+
const projectChatSettings = readProjectChatSettings(req, parsed);
|
|
7052
7230
|
|
|
7053
7231
|
stats.geminiConfigured = Boolean(
|
|
7054
|
-
|
|
7232
|
+
projectChatSettings.geminiKey ||
|
|
7055
7233
|
process.env.GEMINI_API_KEY ||
|
|
7056
7234
|
process.env.THUMBGATE_GEMINI_API_KEY ||
|
|
7057
7235
|
process.env.GOOGLE_API_KEY
|
|
7058
7236
|
);
|
|
7059
7237
|
stats.perplexityConfigured = Boolean(
|
|
7060
|
-
|
|
7238
|
+
projectChatSettings.perplexityKey ||
|
|
7061
7239
|
process.env.PERPLEXITY_API_KEY ||
|
|
7062
7240
|
process.env.THUMBGATE_PERPLEXITY_API_KEY
|
|
7063
7241
|
);
|
|
7064
|
-
stats.geminiValidatedAt = geminiValidatedAt;
|
|
7065
|
-
stats.geminiKeyStatus =
|
|
7242
|
+
stats.geminiValidatedAt = projectChatSettings.geminiValidatedAt;
|
|
7243
|
+
stats.geminiKeyStatus = 'none';
|
|
7244
|
+
if (projectChatSettings.geminiKey) {
|
|
7245
|
+
stats.geminiKeyStatus = 'present';
|
|
7246
|
+
}
|
|
7247
|
+
if (projectChatSettings.geminiValidatedAt) {
|
|
7248
|
+
stats.geminiKeyStatus = 'validated';
|
|
7249
|
+
}
|
|
7066
7250
|
stats.hybridInferenceAvailable = !!(stats.geminiConfigured || stats.perplexityConfigured);
|
|
7067
7251
|
sendJson(res, 200, stats);
|
|
7068
7252
|
return;
|
|
7069
7253
|
}
|
|
7070
7254
|
|
|
7071
|
-
// Chat with your data —
|
|
7072
|
-
//
|
|
7073
|
-
//
|
|
7255
|
+
// Chat with your data — LOCAL-FIRST. Powers the dashboard "Chat with your
|
|
7256
|
+
// data" panel. Factual/metric questions (gates, blocks, feedback, token
|
|
7257
|
+
// savings, team) are answered DETERMINISTICALLY from this install's own
|
|
7258
|
+
// dashboard data — no cloud, no LLM, no API key (the local-first thesis).
|
|
7259
|
+
// Only open-ended/qualitative questions fall through to lesson retrieval +
|
|
7260
|
+
// the user's configured LOCAL model (a BYO cloud key is optional, not required).
|
|
7074
7261
|
if (req.method === 'POST' && pathname === '/v1/chat') {
|
|
7075
7262
|
const body = await parseJsonBody(req);
|
|
7263
|
+
const question = body.question || body.q || body.message;
|
|
7264
|
+
const normalizedChatPrompt = normalizeEnterpriseChatPrompt(question);
|
|
7265
|
+
const chatFeedbackDir = requestFeedbackPaths.FEEDBACK_DIR;
|
|
7266
|
+
|
|
7267
|
+
// Local-first: factual/metric questions (gates, blocks, feedback, cost,
|
|
7268
|
+
// team) are answered deterministically from local data — no cloud/LLM/key.
|
|
7269
|
+
if (
|
|
7270
|
+
normalizedChatPrompt
|
|
7271
|
+
&& !containsUnsafeEnterpriseChatInput(normalizedChatPrompt)
|
|
7272
|
+
&& classifyEnterpriseChatTopic(normalizedChatPrompt) !== 'overview'
|
|
7273
|
+
&& await trySendLocalDashboardChat(res, parsed, chatFeedbackDir, normalizedChatPrompt)
|
|
7274
|
+
) {
|
|
7275
|
+
return;
|
|
7276
|
+
}
|
|
7277
|
+
|
|
7076
7278
|
const { answerDataQuestion } = require('../../scripts/dashboard-chat');
|
|
7077
7279
|
|
|
7078
|
-
|
|
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 (_) {}
|
|
7280
|
+
const projectChatSettings = readProjectChatSettings(req, parsed);
|
|
7095
7281
|
|
|
7096
|
-
const result = await answerDataQuestion(
|
|
7097
|
-
feedbackDir:
|
|
7282
|
+
const result = await answerDataQuestion(question, {
|
|
7283
|
+
feedbackDir: chatFeedbackDir,
|
|
7098
7284
|
model: typeof body.model === 'string' ? body.model : undefined,
|
|
7099
|
-
apiKey:
|
|
7285
|
+
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 || '',
|
|
7286
|
+
localEndpoint: projectChatSettings.localEndpoint || process.env.THUMBGATE_LOCAL_LLM_ENDPOINT || '',
|
|
7287
|
+
localModel: projectChatSettings.localModel || process.env.THUMBGATE_LOCAL_LLM_MODEL || '',
|
|
7100
7288
|
});
|
|
7289
|
+
|
|
7290
|
+
// Local-first guarantee: if no model is configured, never hard-fail with
|
|
7291
|
+
// "no_api_key" — fall back to a deterministic local answer. No cloud required.
|
|
7292
|
+
if (
|
|
7293
|
+
!result.ok
|
|
7294
|
+
&& (result.error === 'no_api_key' || result.error === 'no_model')
|
|
7295
|
+
&& await trySendLocalDashboardChat(res, parsed, chatFeedbackDir, normalizedChatPrompt || question, '(Connect a local model via THUMBGATE_LOCAL_LLM_ENDPOINT for open-ended analysis over your lessons.)')
|
|
7296
|
+
) {
|
|
7297
|
+
return;
|
|
7298
|
+
}
|
|
7299
|
+
|
|
7101
7300
|
sendJson(res, result.ok ? 200 : (result.error === 'no_api_key' ? 503 : 400), result);
|
|
7102
7301
|
return;
|
|
7103
7302
|
}
|
|
@@ -7122,7 +7321,7 @@ ${hidden}
|
|
|
7122
7321
|
apiKey: key,
|
|
7123
7322
|
});
|
|
7124
7323
|
} catch (e) {
|
|
7125
|
-
validation = { ok: false, error: 'validation_exception', message: String(e
|
|
7324
|
+
validation = { ok: false, error: 'validation_exception', message: String(e?.message || e) };
|
|
7126
7325
|
}
|
|
7127
7326
|
|
|
7128
7327
|
if (!validation.ok) {
|
|
@@ -7160,7 +7359,9 @@ ${hidden}
|
|
|
7160
7359
|
validatedAt: new Date().toISOString(),
|
|
7161
7360
|
validatedBy: 'dashboard-save'
|
|
7162
7361
|
}, null, 2));
|
|
7163
|
-
} catch (
|
|
7362
|
+
} catch (error) {
|
|
7363
|
+
debugApiFallback('Gemini validation marker unavailable', error);
|
|
7364
|
+
}
|
|
7164
7365
|
sendJson(res, 200, { ok: true, message: 'Key saved and validated.' });
|
|
7165
7366
|
} catch (e) {
|
|
7166
7367
|
sendJson(res, 500, { ok: false, error: 'fs_error', message: 'Failed to write to .env file: ' + e.message });
|
|
@@ -7223,14 +7424,20 @@ ${hidden}
|
|
|
7223
7424
|
return;
|
|
7224
7425
|
}
|
|
7225
7426
|
|
|
7226
|
-
if (req.method === 'GET' &&
|
|
7227
|
-
|
|
7427
|
+
if (req.method === 'GET' && (
|
|
7428
|
+
pathname === '/v1/enterprise/data-chat/status'
|
|
7429
|
+
|| pathname === '/v1/enterprise/dialogflow/status'
|
|
7430
|
+
)) {
|
|
7431
|
+
sendJson(res, 200, buildEnterpriseDataChatStatus());
|
|
7228
7432
|
return;
|
|
7229
7433
|
}
|
|
7230
7434
|
|
|
7231
|
-
if (req.method === 'POST' &&
|
|
7435
|
+
if (req.method === 'POST' && (
|
|
7436
|
+
pathname === '/v1/enterprise/data-chat/chat'
|
|
7437
|
+
|| pathname === '/v1/enterprise/dialogflow/chat'
|
|
7438
|
+
)) {
|
|
7232
7439
|
const body = await parseJsonBody(req, 16 * 1024);
|
|
7233
|
-
const result = await
|
|
7440
|
+
const result = await answerEnterpriseDataChat({
|
|
7234
7441
|
prompt: body.prompt || body.message || body.query,
|
|
7235
7442
|
feedbackDir: requestFeedbackDir,
|
|
7236
7443
|
parsed,
|
|
@@ -8440,7 +8647,7 @@ ${hidden}
|
|
|
8440
8647
|
} catch (err) {
|
|
8441
8648
|
sendJson(res, 500, {
|
|
8442
8649
|
error: 'ai_inventory_failed',
|
|
8443
|
-
message: err
|
|
8650
|
+
message: err?.message || 'Unable to scan AI component inventory.',
|
|
8444
8651
|
});
|
|
8445
8652
|
}
|
|
8446
8653
|
return;
|
|
@@ -8465,7 +8672,7 @@ ${hidden}
|
|
|
8465
8672
|
const body = await parseJsonBody(req);
|
|
8466
8673
|
const snapshot = buildReviewSnapshot(requestFeedbackDir);
|
|
8467
8674
|
// Override snapshot timestamp with client-provided one if available
|
|
8468
|
-
if (body
|
|
8675
|
+
if (body?.reviewedAt) {
|
|
8469
8676
|
snapshot.reviewedAt = body.reviewedAt;
|
|
8470
8677
|
}
|
|
8471
8678
|
writeDashboardReviewState(requestFeedbackDir, snapshot);
|
|
@@ -8783,8 +8990,10 @@ module.exports = {
|
|
|
8783
8990
|
resolveLocalPageBootstrap,
|
|
8784
8991
|
getPublicMcpTools,
|
|
8785
8992
|
getServerCardTools,
|
|
8993
|
+
buildEnterpriseDataChatStatus,
|
|
8786
8994
|
buildEnterpriseDialogflowStatus,
|
|
8787
8995
|
buildEnterpriseChatAnswer,
|
|
8996
|
+
answerEnterpriseDataChat,
|
|
8788
8997
|
answerEnterpriseDialogflowChat,
|
|
8789
8998
|
},
|
|
8790
8999
|
};
|