thumbgate 1.27.2 → 1.27.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.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/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 +294 -154
- package/.claude-plugin/marketplace.json +0 -85
package/public/pricing.html
CHANGED
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
6
|
__GOOGLE_SITE_VERIFICATION_META__
|
|
7
7
|
<title>Pricing — ThumbGate</title>
|
|
8
|
-
<meta name="description" content="ThumbGate pricing: free CLI forever, $19/mo Pro dashboard, and
|
|
8
|
+
<meta name="description" content="ThumbGate pricing: free CLI forever, $19/mo Pro dashboard, and custom Enterprise enforcement scoped after intake. One clear subscription path.">
|
|
9
9
|
<meta property="og:title" content="Pricing — ThumbGate">
|
|
10
|
-
<meta property="og:description" content="Free CLI, Pro self-serve, and
|
|
10
|
+
<meta property="og:description" content="Free CLI, Pro self-serve, and Enterprise after workflow scope. No mixed consulting checkout maze.">
|
|
11
11
|
<meta property="og:type" content="website">
|
|
12
12
|
<meta property="og:url" content="__APP_ORIGIN__/pricing">
|
|
13
13
|
<meta property="og:image" content="/og.png">
|
|
@@ -40,7 +40,7 @@ __GA_BOOTSTRAP__
|
|
|
40
40
|
{ "@type": "Offer", "name": "ThumbGate CLI (Free)", "price": "0", "priceCurrency": "USD" },
|
|
41
41
|
{ "@type": "Offer", "name": "ThumbGate Pro Monthly", "price": "__PRO_PRICE_DOLLARS__", "priceCurrency": "USD", "url": "__APP_ORIGIN__/checkout/pro?plan_id=pro&billing_cycle=monthly&landing_path=%2Fpricing" },
|
|
42
42
|
{ "@type": "Offer", "name": "ThumbGate Pro Annual", "price": "149", "priceCurrency": "USD", "url": "__APP_ORIGIN__/checkout/pro?plan_id=pro&billing_cycle=annual&landing_path=%2Fpricing" },
|
|
43
|
-
{ "@type": "Offer", "name": "ThumbGate
|
|
43
|
+
{ "@type": "Offer", "name": "ThumbGate Enterprise", "priceCurrency": "USD", "url": "__APP_ORIGIN__/#workflow-sprint-intake" }
|
|
44
44
|
]
|
|
45
45
|
}
|
|
46
46
|
</script>
|
|
@@ -51,9 +51,9 @@ __GA_BOOTSTRAP__
|
|
|
51
51
|
"@type": "FAQPage",
|
|
52
52
|
"mainEntity": [
|
|
53
53
|
{ "@type": "Question", "name": "What does Pro add over the free CLI?", "acceptedAnswer": { "@type": "Answer", "text": "Free gives you 5 captures/day and 3 active rules, running entirely on your machine. Pro is the hosted layer: unlimited captures, unlimited rules, lesson sync across machines, a dashboard without self-hosting, managed adapter updates, and DPO export. You're paying for infrastructure we run, not features we hide." } },
|
|
54
|
-
{ "@type": "Question", "name": "Does ThumbGate send my code to the cloud?", "acceptedAnswer": { "@type": "Answer", "text": "No. The CLI is local-first — no data leaves your machine. Pro and
|
|
55
|
-
{ "@type": "Question", "name": "When should I pick
|
|
56
|
-
{ "@type": "Question", "name": "Can I cancel anytime?", "acceptedAnswer": { "@type": "Answer", "text": "Yes. Pro and
|
|
54
|
+
{ "@type": "Question", "name": "Does ThumbGate send my code to the cloud?", "acceptedAnswer": { "@type": "Answer", "text": "No. The CLI is local-first — no data leaves your machine. Pro and Enterprise add hosted sync for dashboards and shared lessons, but your source code stays local." } },
|
|
55
|
+
{ "@type": "Question", "name": "When should I pick Enterprise over Pro?", "acceptedAnswer": { "@type": "Answer", "text": "When one engineer's correction should protect the whole team. Enterprise shares the lesson database across the org so a fix in one repo prevents the same mistake in every repo." } },
|
|
56
|
+
{ "@type": "Question", "name": "Can I cancel anytime?", "acceptedAnswer": { "@type": "Answer", "text": "Yes. Pro and Enterprise are month-to-month with a 7-day refund window on the first charge. Cancel from the billing portal and your subscription ends at the period close." } }
|
|
57
57
|
]
|
|
58
58
|
}
|
|
59
59
|
</script>
|
|
@@ -266,42 +266,29 @@ __GA_BOOTSTRAP__
|
|
|
266
266
|
<p class="btn-sub">or $149/year (save 35%)</p>
|
|
267
267
|
</div>
|
|
268
268
|
|
|
269
|
-
<div class="price-card
|
|
270
|
-
<div class="tier">
|
|
271
|
-
<div class="price"
|
|
272
|
-
<div class="price-sub">Shared enforcement
|
|
269
|
+
<div class="price-card enterprise-card" id="enterprise">
|
|
270
|
+
<div class="tier">Enterprise</div>
|
|
271
|
+
<div class="price">Custom<span> / scoped after intake</span></div>
|
|
272
|
+
<div class="price-sub">Shared enforcement for the whole team and regulated workflows. One engineer's save protects every agent.</div>
|
|
273
273
|
<ul>
|
|
274
|
-
<li>Everything in Pro for
|
|
274
|
+
<li>Everything in Pro, for every developer and agent</li>
|
|
275
275
|
<li><strong>Shared lesson database</strong> — one engineer's fix protects every agent on the team</li>
|
|
276
276
|
<li><strong>Org dashboard</strong> — visibility across all agent surfaces and developers</li>
|
|
277
|
-
<li><strong>
|
|
278
|
-
<li>
|
|
279
|
-
<li>Email support during pilot rollout</li>
|
|
280
|
-
<li>3-seat minimum after scope; rollout starts only after workflow and proof review are explicit</li>
|
|
281
|
-
</ul>
|
|
282
|
-
<a class="btn-team" href="/?utm_source=pricing&utm_medium=team_card&utm_campaign=team_intake&cta_id=pricing_team_intake&cta_placement=pricing&plan_id=team#workflow-sprint-intake" onclick="try{posthog.capture('pricing_cta_click',{cta:'team_intake',tier:'team',placement:'pricing_page',price:0})}catch(_){};try{plausible('pricing_cta_click',{props:{cta:'team_intake',tier:'team'}})}catch(_){}">Send team workflow first</a>
|
|
283
|
-
<p class="btn-sub">Rollout starts through intake so the workflow is scoped before checkout.</p>
|
|
284
|
-
</div>
|
|
285
|
-
|
|
286
|
-
</div>
|
|
287
|
-
|
|
288
|
-
<div class="enterprise-band" style="max-width:920px;margin:0 auto 8px;padding:24px 28px;border:1px solid rgba(34,211,238,0.24);border-radius:10px;background:#090d12;display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:16px;">
|
|
289
|
-
<div style="flex:1 1 460px;text-align:left;">
|
|
290
|
-
<div class="tier" style="color:var(--cyan);">Enterprise</div>
|
|
291
|
-
<div class="price-sub" style="margin:4px 0 10px;">For regulated teams that need agent checks routed through their own cloud — talk to us.</div>
|
|
292
|
-
<ul style="margin:0;padding-left:18px;font-size:13px;color:var(--text-muted);line-height:1.7;">
|
|
293
|
-
<li><strong>Vertex AI / VPC gating</strong> — route agent checks through Gemini models in your Google Cloud project (<code>npx thumbgate setup-vertex</code>)</li>
|
|
277
|
+
<li><strong>Dialogflow CX fulfillment guard</strong> — put ThumbGate's pre-action gate in front of your Dialogflow CX webhook fulfillment, in your own GCP tenant, so risky or repeat turns are blocked before they touch a DB, CRM, or billing system (white-glove design-partner pilot)</li>
|
|
278
|
+
<li><strong>Vertex AI / VPC gating</strong> — route agent checks through Gemini in your own Google Cloud project (<code>npx thumbgate setup-vertex</code>)</li>
|
|
294
279
|
<li><strong>Regulatory gate templates</strong> — legal intake, financial compliance, healthcare</li>
|
|
295
|
-
<li>Custom policy layers
|
|
296
|
-
<li>
|
|
280
|
+
<li>Custom policy layers, compliance audit export, approval boundaries, SSO, and dedicated onboarding with SLA</li>
|
|
281
|
+
<li>Rollout starts only after workflow and proof review are explicit</li>
|
|
297
282
|
</ul>
|
|
283
|
+
<a class="btn-team" href="/?utm_source=pricing&utm_medium=enterprise_card&utm_campaign=enterprise_intake&cta_id=pricing_enterprise_intake&cta_placement=pricing&plan_id=enterprise#workflow-sprint-intake" onclick="try{posthog.capture('pricing_cta_click',{cta:'enterprise_intake',tier:'enterprise',placement:'pricing_page',price:0})}catch(_){};try{plausible('pricing_cta_click',{props:{cta:'enterprise_intake',tier:'enterprise'}})}catch(_){}">Talk to us</a>
|
|
284
|
+
<p class="btn-sub">Custom pricing, scoped through intake so the workflow is explicit before checkout.</p>
|
|
298
285
|
</div>
|
|
299
|
-
|
|
286
|
+
|
|
300
287
|
</div>
|
|
301
288
|
|
|
302
289
|
<div style="text-align:center;margin:32px 0;color:var(--text-muted);font-size:14px;">
|
|
303
290
|
Need founder help? Do not buy a blind diagnostic from a pricing table.
|
|
304
|
-
<a href="/?utm_source=pricing&utm_medium=scope_first&utm_campaign=
|
|
291
|
+
<a href="/?utm_source=pricing&utm_medium=scope_first&utm_campaign=enterprise_intake&cta_id=pricing_scope_first&cta_placement=pricing_note&plan_id=enterprise#workflow-sprint-intake" style="color:var(--cyan);text-decoration:none;font-weight:600;" onclick="try{posthog.capture('pricing_cta_click',{cta:'scope_first',tier:'enterprise',price:0})}catch(_){};try{plausible('pricing_cta_click',{props:{cta:'scope_first',tier:'enterprise'}})}catch(_){}">Send the workflow first</a> — then we scope the smallest paid rollout that can prove one repeated failure is blocked.
|
|
305
292
|
</div>
|
|
306
293
|
</section>
|
|
307
294
|
|
|
@@ -319,15 +306,15 @@ __GA_BOOTSTRAP__
|
|
|
319
306
|
</div>
|
|
320
307
|
<div class="faq-item">
|
|
321
308
|
<div class="faq-q">Does ThumbGate send my code to the cloud?</div>
|
|
322
|
-
<div class="faq-a">No. The CLI is local-first and no source code leaves your machine. Pro and
|
|
309
|
+
<div class="faq-a">No. The CLI is local-first and no source code leaves your machine. Pro and Enterprise add hosted sync for dashboards and shared lessons, but your code stays local.</div>
|
|
323
310
|
</div>
|
|
324
311
|
<div class="faq-item">
|
|
325
|
-
<div class="faq-q">When should I pick
|
|
326
|
-
<div class="faq-a">When one engineer's correction should protect the whole team.
|
|
312
|
+
<div class="faq-q">When should I pick Enterprise over Pro?</div>
|
|
313
|
+
<div class="faq-a">When one engineer's correction should protect the whole team. Enterprise shares the lesson database across the org so a fix in one repo prevents the same mistake in every repo.</div>
|
|
327
314
|
</div>
|
|
328
315
|
<div class="faq-item">
|
|
329
316
|
<div class="faq-q">Can I cancel anytime?</div>
|
|
330
|
-
<div class="faq-a">Yes. Pro and
|
|
317
|
+
<div class="faq-a">Yes. Pro and Enterprise are month-to-month with a 7-day refund window on the first charge. Cancel from the billing portal and your subscription ends at the period close.</div>
|
|
331
318
|
</div>
|
|
332
319
|
<div class="faq-item">
|
|
333
320
|
<div class="faq-q">What happens if I stop paying?</div>
|
package/public/pro.html
CHANGED
|
@@ -924,11 +924,11 @@ __GA_BOOTSTRAP__
|
|
|
924
924
|
|
|
925
925
|
<div class="pricing-sidebar">
|
|
926
926
|
<div class="team-card">
|
|
927
|
-
<div class="section-label" style="text-align:left;margin-bottom:8px;">When
|
|
927
|
+
<div class="section-label" style="text-align:left;margin-bottom:8px;">When Enterprise is better</div>
|
|
928
928
|
<h3>Need shared enforcement?</h3>
|
|
929
|
-
<p>Choose
|
|
929
|
+
<p>Choose Enterprise when one correction must protect multiple developers or agents across shared repositories, CI, approval policies, and audit trails. Enterprise is custom pricing, scoped after intake.</p>
|
|
930
930
|
<div class="hero-actions" style="margin-top:18px;">
|
|
931
|
-
<a class="btn-secondary" href="/#workflow-sprint-intake">Book
|
|
931
|
+
<a class="btn-secondary" href="/#workflow-sprint-intake">Book an Enterprise Pilot Call</a>
|
|
932
932
|
</div>
|
|
933
933
|
</div>
|
|
934
934
|
<div class="team-card">
|
|
@@ -14,7 +14,12 @@ const TEAM_ANNUAL_PRICE_DOLLARS = 588;
|
|
|
14
14
|
const TEAM_MIN_SEATS = 3;
|
|
15
15
|
|
|
16
16
|
const PRO_PRICE_LABEL = '$19/mo or $149/yr (individual)';
|
|
17
|
-
|
|
17
|
+
// Enterprise is the contact-sales tier (absorbs the former Team workflow + the
|
|
18
|
+
// regulated-industry lane). Pricing is scoped after intake — no self-serve seat
|
|
19
|
+
// price is surfaced. The dormant TEAM_* Stripe constants below remain only as
|
|
20
|
+
// inert billing plumbing pending a dedicated cleanup; they are no longer a
|
|
21
|
+
// customer-facing tier.
|
|
22
|
+
const ENTERPRISE_PRICE_LABEL = 'Custom pricing, scoped after intake — Enterprise agent governance';
|
|
18
23
|
|
|
19
24
|
function normalizePlanId(value) {
|
|
20
25
|
const text = String(value || '').trim().toLowerCase();
|
|
@@ -68,6 +73,8 @@ function buildCaptureReceipt({ signal, feedbackId, memoryId, actionType } = {})
|
|
|
68
73
|
'',
|
|
69
74
|
` Solo Pro : ${PRO_PRICE_LABEL} for hosted sync, search, dashboard, and exports`,
|
|
70
75
|
` Upgrade : ${trackedProUrl('cli_capture_receipt', actionType || normalizedSignal.toLowerCase())}`,
|
|
76
|
+
` Enterprise : ${ENTERPRISE_PRICE_LABEL}; start with one repeated workflow failure`,
|
|
77
|
+
' https://thumbgate.ai/#workflow-sprint-intake',
|
|
71
78
|
'',
|
|
72
79
|
];
|
|
73
80
|
return lines.join('\n');
|
|
@@ -100,6 +107,7 @@ function buildStatsReceipt(stats = {}) {
|
|
|
100
107
|
lines.push(' Show the buyer : npx thumbgate cost');
|
|
101
108
|
lines.push(' Pro sync value : keep these lessons/rules visible across laptops, CI, containers, and agent runtimes');
|
|
102
109
|
lines.push(` Solo Pro : ${trackedProUrl('cli_stats_receipt', 'proof_seen')}`);
|
|
110
|
+
lines.push(' Enterprise : https://thumbgate.ai/#workflow-sprint-intake');
|
|
103
111
|
lines.push('');
|
|
104
112
|
return lines.join('\n');
|
|
105
113
|
}
|
|
@@ -116,7 +124,7 @@ module.exports = {
|
|
|
116
124
|
TEAM_ANNUAL_PRICE_DOLLARS,
|
|
117
125
|
TEAM_MIN_SEATS,
|
|
118
126
|
PRO_PRICE_LABEL,
|
|
119
|
-
|
|
127
|
+
ENTERPRISE_PRICE_LABEL,
|
|
120
128
|
normalizePlanId,
|
|
121
129
|
normalizeBillingCycle,
|
|
122
130
|
normalizeSeatCount,
|
|
@@ -2,15 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
// scripts/dashboard-chat.js
|
|
4
4
|
// -----------------------------------------------------------------------------
|
|
5
|
-
// "Chat with your data" — the dashboard chat backend.
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
5
|
+
// "Chat with your data" — the dashboard chat backend. Local-first RAG over
|
|
6
|
+
// this install's ThumbGate data (lessons, raw feedback memories via LanceDB
|
|
7
|
+
// vectors, receipts, gate stats). Retrieval is local (lesson search + optional
|
|
8
|
+
// vector-store.searchSimilar). Generation uses your configured LLM: a local
|
|
9
|
+
// OpenAI-compatible endpoint first, then Gemini or Perplexity when explicitly
|
|
10
|
+
// configured.
|
|
10
11
|
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
// customer connects their own DFCX agent + the ThumbGate webhook gate.)
|
|
12
|
+
// Dialogflow/Google is not the dashboard chatbot brain. It remains an optional
|
|
13
|
+
// guard-adapter path for buyers who already run their own Google agent tenancy.
|
|
14
14
|
// -----------------------------------------------------------------------------
|
|
15
15
|
|
|
16
16
|
const path = require('path');
|
|
@@ -40,7 +40,7 @@ function resolveModel(requested) {
|
|
|
40
40
|
|
|
41
41
|
function resolveApiKey(opts = {}) {
|
|
42
42
|
let key = '';
|
|
43
|
-
if (Object.
|
|
43
|
+
if (Object.hasOwn(opts, 'apiKey')) {
|
|
44
44
|
key = opts.apiKey || '';
|
|
45
45
|
} else {
|
|
46
46
|
key = opts.apiKey || process.env.GEMINI_API_KEY || process.env.THUMBGATE_GEMINI_API_KEY || process.env.GOOGLE_API_KEY || process.env.PERPLEXITY_API_KEY || process.env.THUMBGATE_PERPLEXITY_API_KEY || '';
|
|
@@ -49,31 +49,91 @@ function resolveApiKey(opts = {}) {
|
|
|
49
49
|
return key.trim().replace(/^["']|["']$/g, '');
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
52
|
+
function debugChatFallback(label, err) {
|
|
53
|
+
if (process.env.THUMBGATE_DEBUG_CHAT !== '1') return;
|
|
54
|
+
const detail = err?.message ? err.message : String(err);
|
|
55
|
+
console.warn(`[dashboard-chat] ${label}: ${detail}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function loadLessonSearcher() {
|
|
55
59
|
try {
|
|
56
|
-
|
|
57
|
-
} catch (
|
|
58
|
-
|
|
60
|
+
return require(path.join(__dirname, 'lesson-search')).searchLessons;
|
|
61
|
+
} catch (err) {
|
|
62
|
+
debugChatFallback('lesson search unavailable', err);
|
|
63
|
+
return null;
|
|
59
64
|
}
|
|
60
|
-
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function lessonToContextItem(lesson) {
|
|
68
|
+
return {
|
|
69
|
+
id: lesson.id,
|
|
70
|
+
signal: lesson.signal || lesson.feedback || '',
|
|
71
|
+
title: (lesson.title || '').replace(/^(?:MISTAKE|SUCCESS):\s*/i, '').slice(0, 160),
|
|
72
|
+
content: String(lesson.content || lesson.context || '').replace(/\s+/g, ' ').trim().slice(0, 600),
|
|
73
|
+
tags: lesson.tags || [],
|
|
74
|
+
source: 'lessons',
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function vectorMatchToContextItem(match, index) {
|
|
79
|
+
return {
|
|
80
|
+
id: match.id || `vec-${index}`,
|
|
81
|
+
signal: match.signal || '',
|
|
82
|
+
title: String(match.context || match.text || '').slice(0, 100),
|
|
83
|
+
content: match.text || match.context || '',
|
|
84
|
+
tags: match.tags ? String(match.tags).split(',').filter(Boolean) : [],
|
|
85
|
+
source: 'lancedb-vector',
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function dedupeContextItems(items, limit = MAX_CONTEXT_LESSONS + 3) {
|
|
90
|
+
const seen = new Set();
|
|
91
|
+
return items.filter((item) => {
|
|
92
|
+
if (!(item.content || item.title)) return false;
|
|
93
|
+
const key = item.id || item.content.slice(0, 80);
|
|
94
|
+
if (seen.has(key)) return false;
|
|
95
|
+
seen.add(key);
|
|
96
|
+
return true;
|
|
97
|
+
}).slice(0, limit);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function retrieveLessonContext(question, opts = {}) {
|
|
101
|
+
const searchLessons = loadLessonSearcher();
|
|
102
|
+
if (!searchLessons) return [];
|
|
61
103
|
try {
|
|
62
|
-
res = searchLessons(String(question || ''), {
|
|
104
|
+
const res = searchLessons(String(question || ''), {
|
|
63
105
|
limit: MAX_CONTEXT_LESSONS,
|
|
64
106
|
feedbackDir: opts.feedbackDir,
|
|
65
107
|
});
|
|
66
|
-
|
|
108
|
+
const rows = res?.results || res?.lessons || [];
|
|
109
|
+
return rows.slice(0, MAX_CONTEXT_LESSONS).map(lessonToContextItem);
|
|
110
|
+
} catch (err) {
|
|
111
|
+
debugChatFallback('lesson retrieval failed', err);
|
|
112
|
+
return [];
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function retrieveVectorContext(question, opts = {}) {
|
|
117
|
+
if (opts.useVectorSearch === false) return [];
|
|
118
|
+
try {
|
|
119
|
+
const vectorStore = require(path.join(__dirname, 'vector-store'));
|
|
120
|
+
const vecResults = vectorStore.searchSimilar
|
|
121
|
+
? await vectorStore.searchSimilar(String(question || ''), opts.vectorLimit || 4)
|
|
122
|
+
: [];
|
|
123
|
+
return vecResults
|
|
124
|
+
.filter((match) => match?.text)
|
|
125
|
+
.map(vectorMatchToContextItem);
|
|
126
|
+
} catch (err) {
|
|
127
|
+
debugChatFallback('vector retrieval failed', err);
|
|
67
128
|
return [];
|
|
68
129
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
})).filter((l) => l.content || l.title);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Retrieve relevant stored lessons and optional raw feedback vector matches.
|
|
133
|
+
async function retrieveContext(question, opts = {}) {
|
|
134
|
+
const lessons = retrieveLessonContext(question, opts);
|
|
135
|
+
const vectors = await retrieveVectorContext(question, opts);
|
|
136
|
+
return dedupeContextItems([...lessons, ...vectors]);
|
|
77
137
|
}
|
|
78
138
|
|
|
79
139
|
// Build a grounded RAG prompt. Pure function (testable).
|
|
@@ -97,15 +157,87 @@ function buildChatPrompt(question, lessons) {
|
|
|
97
157
|
|
|
98
158
|
// Parse the Gemini generateContent response into plain text. Pure (testable).
|
|
99
159
|
function parseGeminiAnswer(body) {
|
|
100
|
-
const parts = body
|
|
101
|
-
&& body.candidates
|
|
102
|
-
&& body.candidates[0]
|
|
103
|
-
&& body.candidates[0].content
|
|
104
|
-
&& body.candidates[0].content.parts;
|
|
160
|
+
const parts = body?.candidates?.[0]?.content?.parts;
|
|
105
161
|
if (!Array.isArray(parts)) return '';
|
|
106
162
|
return parts.map((p) => (p && typeof p.text === 'string' ? p.text : '')).join('').trim();
|
|
107
163
|
}
|
|
108
164
|
|
|
165
|
+
function buildOpenAiChatPayload(prompt, model) {
|
|
166
|
+
return JSON.stringify({
|
|
167
|
+
model,
|
|
168
|
+
messages: [{ role: 'user', content: prompt }],
|
|
169
|
+
temperature: 0.2,
|
|
170
|
+
max_tokens: 1024,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function parseOpenAiChatAnswer(json) {
|
|
175
|
+
return json?.choices?.[0]?.message?.content || '';
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function parseModelError(json, status) {
|
|
179
|
+
return json?.error?.message ? String(json.error.message).split('\n')[0] : `HTTP ${status}`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function trimTrailingSlashes(value) {
|
|
183
|
+
let text = String(value || '');
|
|
184
|
+
while (text.endsWith('/')) {
|
|
185
|
+
text = text.slice(0, -1);
|
|
186
|
+
}
|
|
187
|
+
return text;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function callLocalOpenAiEndpoint({ endpoint, apiKey, model, prompt, fetchImpl, sources }) {
|
|
191
|
+
const url = endpoint.includes('/chat/completions')
|
|
192
|
+
? endpoint
|
|
193
|
+
: `${trimTrailingSlashes(endpoint)}/chat/completions`;
|
|
194
|
+
const res = await fetchImpl(url, {
|
|
195
|
+
method: 'POST',
|
|
196
|
+
headers: {
|
|
197
|
+
'content-type': 'application/json',
|
|
198
|
+
'Authorization': `Bearer ${apiKey || 'local'}`
|
|
199
|
+
},
|
|
200
|
+
body: buildOpenAiChatPayload(prompt, model),
|
|
201
|
+
});
|
|
202
|
+
const json = await res.json().catch(() => ({}));
|
|
203
|
+
if (!res.ok) {
|
|
204
|
+
return { ok: false, error: 'local_llm_error', status: res.status, message: parseModelError(json, res.status), sources };
|
|
205
|
+
}
|
|
206
|
+
const answer = parseOpenAiChatAnswer(json);
|
|
207
|
+
return { ok: true, answer: answer.trim() || '(no answer returned)', sources, model: json.model || model };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function callPerplexityEndpoint({ apiKey, prompt, fetchImpl, sources }) {
|
|
211
|
+
const res = await fetchImpl(PERPLEXITY_ENDPOINT, {
|
|
212
|
+
method: 'POST',
|
|
213
|
+
headers: { 'content-type': 'application/json', 'Authorization': `Bearer ${apiKey}` },
|
|
214
|
+
body: buildOpenAiChatPayload(prompt, 'sonar'),
|
|
215
|
+
});
|
|
216
|
+
const json = await res.json().catch(() => ({}));
|
|
217
|
+
if (!res.ok) {
|
|
218
|
+
return { ok: false, error: 'perplexity_error', status: res.status, message: parseModelError(json, res.status), sources };
|
|
219
|
+
}
|
|
220
|
+
const answer = parseOpenAiChatAnswer(json);
|
|
221
|
+
return { ok: true, answer: answer.trim() || '(no answer returned)', sources, model: json.model || 'perplexity-hybrid' };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function callGeminiEndpoint({ apiKey, model, prompt, fetchImpl, sources }) {
|
|
225
|
+
const res = await fetchImpl(`${GEMINI_ENDPOINT}/${encodeURIComponent(model)}:generateContent`, {
|
|
226
|
+
method: 'POST',
|
|
227
|
+
headers: { 'content-type': 'application/json', 'x-goog-api-key': apiKey },
|
|
228
|
+
body: JSON.stringify({
|
|
229
|
+
contents: [{ role: 'user', parts: [{ text: prompt }] }],
|
|
230
|
+
generationConfig: { temperature: 0.2, maxOutputTokens: 1024 },
|
|
231
|
+
}),
|
|
232
|
+
});
|
|
233
|
+
const json = await res.json().catch(() => ({}));
|
|
234
|
+
if (!res.ok) {
|
|
235
|
+
return { ok: false, error: 'gemini_error', status: res.status, message: parseModelError(json, res.status), sources };
|
|
236
|
+
}
|
|
237
|
+
const answer = parseGeminiAnswer(json);
|
|
238
|
+
return { ok: true, answer: answer || '(no answer returned)', sources, model: json.modelVersion || model };
|
|
239
|
+
}
|
|
240
|
+
|
|
109
241
|
// Answer a question grounded in this install's lessons. Returns
|
|
110
242
|
// { ok, answer, sources, model } or { ok:false, error, ... }.
|
|
111
243
|
async function answerDataQuestion(question, opts = {}) {
|
|
@@ -115,15 +247,17 @@ async function answerDataQuestion(question, opts = {}) {
|
|
|
115
247
|
return { ok: false, error: 'question_too_long', message: `Question exceeds ${MAX_QUESTION_CHARS} characters.` };
|
|
116
248
|
}
|
|
117
249
|
|
|
250
|
+
const localEndpoint = opts.localEndpoint || process.env.THUMBGATE_LOCAL_LLM_ENDPOINT || '';
|
|
251
|
+
const localModel = opts.localModel || process.env.THUMBGATE_LOCAL_LLM_MODEL || 'llama3';
|
|
118
252
|
const apiKey = resolveApiKey(opts);
|
|
119
|
-
const lessons = retrieveContext(q, opts);
|
|
253
|
+
const lessons = await retrieveContext(q, opts);
|
|
120
254
|
const sources = lessons.map((l) => ({ id: l.id, title: l.title, signal: l.signal }));
|
|
121
255
|
|
|
122
|
-
if (!apiKey) {
|
|
256
|
+
if (!apiKey && !localEndpoint) {
|
|
123
257
|
return {
|
|
124
258
|
ok: false,
|
|
125
259
|
error: 'no_api_key',
|
|
126
|
-
message: 'Chat is not configured. Set a valid GEMINI_API_KEY
|
|
260
|
+
message: 'Chat is not configured. Set a valid GEMINI_API_KEY, PERPLEXITY_API_KEY, or THUMBGATE_LOCAL_LLM_ENDPOINT in the project .env.',
|
|
127
261
|
sources,
|
|
128
262
|
};
|
|
129
263
|
}
|
|
@@ -131,47 +265,14 @@ async function answerDataQuestion(question, opts = {}) {
|
|
|
131
265
|
const model = resolveModel(opts.model);
|
|
132
266
|
const prompt = buildChatPrompt(q, lessons);
|
|
133
267
|
const fetchImpl = opts.fetch || globalThis.fetch;
|
|
134
|
-
const isPerplexity = apiKey.startsWith('pplx-') || apiKey.includes('perplexity');
|
|
268
|
+
const isPerplexity = apiKey && (apiKey.startsWith('pplx-') || apiKey.includes('perplexity'));
|
|
135
269
|
|
|
136
270
|
try {
|
|
137
|
-
if (
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
method: 'POST',
|
|
141
|
-
headers: { 'content-type': 'application/json', 'Authorization': `Bearer ${apiKey}` },
|
|
142
|
-
body: JSON.stringify({
|
|
143
|
-
model: 'sonar', // or llama-3.1 etc for hybrid
|
|
144
|
-
messages: [{ role: 'user', content: prompt }],
|
|
145
|
-
temperature: 0.2,
|
|
146
|
-
max_tokens: 1024,
|
|
147
|
-
}),
|
|
148
|
-
});
|
|
149
|
-
const json = await res.json().catch(() => ({}));
|
|
150
|
-
if (!res.ok) {
|
|
151
|
-
const msg = (json && json.error && json.error.message) ? String(json.error.message).split('\n')[0] : `HTTP ${res.status}`;
|
|
152
|
-
return { ok: false, error: 'perplexity_error', status: res.status, message: msg, sources };
|
|
153
|
-
}
|
|
154
|
-
const answer = (json.choices && json.choices[0] && json.choices[0].message && json.choices[0].message.content) || '';
|
|
155
|
-
return { ok: true, answer: answer.trim() || '(no answer returned)', sources, model: json.model || 'perplexity-hybrid' };
|
|
156
|
-
} else {
|
|
157
|
-
const res = await fetchImpl(`${GEMINI_ENDPOINT}/${encodeURIComponent(model)}:generateContent`, {
|
|
158
|
-
method: 'POST',
|
|
159
|
-
headers: { 'content-type': 'application/json', 'x-goog-api-key': apiKey },
|
|
160
|
-
body: JSON.stringify({
|
|
161
|
-
contents: [{ role: 'user', parts: [{ text: prompt }] }],
|
|
162
|
-
generationConfig: { temperature: 0.2, maxOutputTokens: 1024 },
|
|
163
|
-
}),
|
|
164
|
-
});
|
|
165
|
-
const json = await res.json().catch(() => ({}));
|
|
166
|
-
if (!res.ok) {
|
|
167
|
-
const msg = (json && json.error && json.error.message) ? String(json.error.message).split('\n')[0] : `HTTP ${res.status}`;
|
|
168
|
-
return { ok: false, error: 'gemini_error', status: res.status, message: msg, sources };
|
|
169
|
-
}
|
|
170
|
-
const answer = parseGeminiAnswer(json);
|
|
171
|
-
return { ok: true, answer: answer || '(no answer returned)', sources, model: json.modelVersion || model };
|
|
172
|
-
}
|
|
271
|
+
if (localEndpoint) return await callLocalOpenAiEndpoint({ endpoint: localEndpoint, apiKey, model: localModel, prompt, fetchImpl, sources });
|
|
272
|
+
if (isPerplexity) return await callPerplexityEndpoint({ apiKey, prompt, fetchImpl, sources });
|
|
273
|
+
return await callGeminiEndpoint({ apiKey, model, prompt, fetchImpl, sources });
|
|
173
274
|
} catch (err) {
|
|
174
|
-
return { ok: false, error: 'network', message: err
|
|
275
|
+
return { ok: false, error: 'network', message: err?.message || String(err), sources };
|
|
175
276
|
}
|
|
176
277
|
}
|
|
177
278
|
|
package/scripts/gates-engine.js
CHANGED
|
@@ -121,6 +121,43 @@ const BOOSTED_RISK_MIN_EXAMPLES = 3;
|
|
|
121
121
|
const PR_THREAD_RESOLUTION_ACTION = 'pr_thread_resolution_verified_after_commit';
|
|
122
122
|
const KNOWLEDGE_ENTROPY_THRESHOLD = 0.7;
|
|
123
123
|
const KNOWLEDGE_CONFLICT_STRICT_BASH_PATTERN = /\b(?:git\s+push\b|gh\s+pr\s+merge\b|gh\s+release\s+(?:create|delete|edit|upload)\b|(?:npm|yarn|pnpm)\s+publish\b|rm\s+-rf\b|git\s+reset\s+--hard\b|git\s+clean\s+-f|railway\s+(?:deploy|up)\b|gcloud\s+(?:run\s+deploy|app\s+deploy)\b|firebase\s+deploy\b|vercel\s+--prod\b|kubectl\s+(?:apply|delete)\b|terraform\s+(?:apply|destroy)\b)\b/i;
|
|
124
|
+
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
// Enforcement posture (CEO decision 2026-06-04): warn-by-default.
|
|
127
|
+
// The firewall ALWAYS fires and logs every decision, but most gates WARN rather
|
|
128
|
+
// than hard-block — only TRULY CATASTROPHIC, irreversible actions hard-block:
|
|
129
|
+
// - secret exfiltration (handled on its own deny path; never downgraded)
|
|
130
|
+
// - security-vulnerability / supply-chain denies (own deny path; not downgraded)
|
|
131
|
+
// - irreversibly destructive filesystem commands (rm -rf class, mkfs, dd to disk,
|
|
132
|
+
// fork bomb) — kept as hard deny via DESTRUCTIVE_FS_PATTERN below.
|
|
133
|
+
// Everything else (memory-high-risk, workflow-sequence, off-scope, git push, deploy,
|
|
134
|
+
// approval gates) downgrades deny/approve -> warn so legitimate work is never blocked.
|
|
135
|
+
// Opt back into full hard enforcement with THUMBGATE_STRICT_ENFORCEMENT=1.
|
|
136
|
+
// Enforcement posture (CEO decision 2026-06-04): WARN + AUDIT by default.
|
|
137
|
+
// The firewall fires and LOGS every decision, but downgrades deny/approve -> warn so
|
|
138
|
+
// legitimate work is never hard-blocked. We deliberately do NOT try to hard-block
|
|
139
|
+
// arbitrary destructive commands here: a regex "catastrophic floor" is unwinnable
|
|
140
|
+
// (sudo / bash -c / find -exec / eval / base64|sh all evade it) and gives false confidence.
|
|
141
|
+
// HARD enforcement is an explicit opt-in via THUMBGATE_STRICT_ENFORCEMENT=1, which keeps
|
|
142
|
+
// the engine's FULL gate set (its high-risk-command gates catch prefixed/obfuscated forms
|
|
143
|
+
// far better than any single regex). Secret exfiltration and the security-vulnerability
|
|
144
|
+
// scan hard-deny on their OWN paths before this runs, so irreversible data-leak / supply
|
|
145
|
+
// chain risks stay blocked regardless of posture.
|
|
146
|
+
function applyEnforcementPosture(result) {
|
|
147
|
+
if (!result || (result.decision !== 'deny' && result.decision !== 'approve')) return result;
|
|
148
|
+
// Full hard enforcement opt-in: keep every deny.
|
|
149
|
+
if (process.env.THUMBGATE_STRICT_ENFORCEMENT === '1') return result;
|
|
150
|
+
// Honor the explicit strict-knowledge-conflict opt-in for that gate.
|
|
151
|
+
if (process.env.THUMBGATE_STRICT_KNOWLEDGE_CONFLICT === '1' && result.gate === 'knowledge-conflict-gate') return result;
|
|
152
|
+
// Warn-by-default: the gate still fired and is recorded; the action is allowed through
|
|
153
|
+
// with the warning surfaced instead of hard-blocked, so legitimate work is never blocked.
|
|
154
|
+
return {
|
|
155
|
+
...result,
|
|
156
|
+
decision: 'warn',
|
|
157
|
+
warnByDefault: true,
|
|
158
|
+
message: `${result.message}\n\n⚠️ ThumbGate is in warn-by-default mode — this was flagged and logged, not blocked. Set THUMBGATE_STRICT_ENFORCEMENT=1 to hard-block, or THUMBGATE_HOTFIX_BYPASS=1 to disable checks entirely.`,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
124
161
|
const BREAK_GLASS_CONDITION = 'thumbgate_break_glass';
|
|
125
162
|
const BREAK_GLASS_SETTINGS_GLOBS = [
|
|
126
163
|
'.claude/settings.local.json',
|
|
@@ -2671,7 +2708,7 @@ async function runAsync(input) {
|
|
|
2671
2708
|
|
|
2672
2709
|
const sequenceGuard = evaluateSequenceState(toolName, toolInput);
|
|
2673
2710
|
if (sequenceGuard && sequenceGuard.decision === 'deny') {
|
|
2674
|
-
return formatOutput(sequenceGuard);
|
|
2711
|
+
return formatOutput(applyEnforcementPosture(sequenceGuard));
|
|
2675
2712
|
}
|
|
2676
2713
|
|
|
2677
2714
|
const result = await evaluateGatesAsync(toolName, toolInput);
|
|
@@ -2691,12 +2728,12 @@ async function runAsync(input) {
|
|
|
2691
2728
|
const lessonContext = safeSecretStorageWrite ? null : await buildRelevantLessonContextAsync(toolName, toolInput);
|
|
2692
2729
|
|
|
2693
2730
|
if (lessonContext && lessonContext.decision === "deny") {
|
|
2694
|
-
return formatOutput(lessonContext);
|
|
2731
|
+
return formatOutput(applyEnforcementPosture(lessonContext));
|
|
2695
2732
|
}
|
|
2696
2733
|
|
|
2697
2734
|
const recentContext = buildRecentCorrectiveActionsContext();
|
|
2698
2735
|
const combinedContext = mergeContextStrings(lessonContext, recentContext, behavioralContext);
|
|
2699
|
-
return formatOutput(result, combinedContext);
|
|
2736
|
+
return formatOutput(applyEnforcementPosture(result), combinedContext);
|
|
2700
2737
|
|
|
2701
2738
|
}
|
|
2702
2739
|
|
|
@@ -2718,7 +2755,7 @@ function run(input) {
|
|
|
2718
2755
|
|
|
2719
2756
|
const sequenceGuard = evaluateSequenceState(toolName, toolInput);
|
|
2720
2757
|
if (sequenceGuard && sequenceGuard.decision === 'deny') {
|
|
2721
|
-
return formatOutput(sequenceGuard);
|
|
2758
|
+
return formatOutput(applyEnforcementPosture(sequenceGuard));
|
|
2722
2759
|
}
|
|
2723
2760
|
|
|
2724
2761
|
const result = evaluateGates(toolName, toolInput);
|
|
@@ -2738,12 +2775,12 @@ function run(input) {
|
|
|
2738
2775
|
const lessonContext = safeSecretStorageWrite ? null : buildRelevantLessonContext(toolName, toolInput);
|
|
2739
2776
|
|
|
2740
2777
|
if (lessonContext && lessonContext.decision === "deny") {
|
|
2741
|
-
return formatOutput(lessonContext);
|
|
2778
|
+
return formatOutput(applyEnforcementPosture(lessonContext));
|
|
2742
2779
|
}
|
|
2743
2780
|
|
|
2744
2781
|
const recentContext = buildRecentCorrectiveActionsContext();
|
|
2745
2782
|
const combinedContext = mergeContextStrings(lessonContext, recentContext, behavioralContext);
|
|
2746
|
-
return formatOutput(result, combinedContext);
|
|
2783
|
+
return formatOutput(applyEnforcementPosture(result), combinedContext);
|
|
2747
2784
|
|
|
2748
2785
|
}
|
|
2749
2786
|
|