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.
@@ -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 intake-led Team enforcement at $49/seat after scope. One clear subscription path.">
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 Team after workflow scope. No mixed consulting checkout maze.">
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 Team", "price": "49", "priceCurrency": "USD", "url": "__APP_ORIGIN__/#workflow-sprint-intake" }
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 Team add hosted sync for dashboards and shared lessons, but your source code stays local." } },
55
- { "@type": "Question", "name": "When should I pick Team over Pro?", "acceptedAnswer": { "@type": "Answer", "text": "When one engineer's correction should protect the whole team. Team shares the lesson database across seats 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 Team 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." } }
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 team-card" id="team">
270
- <div class="tier">Team</div>
271
- <div class="price">$49<span>/seat/mo</span></div>
272
- <div class="price-sub">Shared enforcement memory for the whole team. One engineer's save protects every agent.</div>
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 each seat</li>
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>Enterprise Data Chat</strong> — query local ThumbGate feedback, lessons, gates, and rollout readiness; Dialogflow CX / Vertex integration is enabled only when deployment evidence is configured</li>
278
- <li>Check template library for deploys, publish, and DB operations</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 scoped to firm / practice area</li>
296
- <li>Compliance audit export + dedicated onboarding with SLA</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
- <a class="btn-team" style="white-space:nowrap;" href="/?utm_source=pricing&utm_medium=enterprise_band&utm_campaign=enterprise_intake&cta_id=pricing_enterprise&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>
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=team_intake&cta_id=pricing_scope_first&cta_placement=pricing_note&plan_id=team#workflow-sprint-intake" style="color:var(--cyan);text-decoration:none;font-weight:600;" onclick="try{posthog.capture('pricing_cta_click',{cta:'scope_first',tier:'team',price:0})}catch(_){};try{plausible('pricing_cta_click',{props:{cta:'scope_first',tier:'team'}})}catch(_){}">Send the workflow first</a> — then we scope the smallest paid rollout that can prove one repeated failure is blocked.
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 Team add hosted sync for dashboards and shared lessons, but your code stays local.</div>
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 Team over Pro?</div>
326
- <div class="faq-a">When one engineer's correction should protect the whole team. Team shares the lesson database across seats so a fix in one repo prevents the same mistake in every repo.</div>
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 Team 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>
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 Team is better</div>
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 Team when one correction must protect multiple developers or agents across shared repositories, CI, approval policies, and audit trails. Team is $49/seat/mo with a 3-seat minimum after qualification.</p>
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 a Team Pilot Call</a>
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
- const TEAM_PRICE_LABEL = '$49/seat/mo Agent governance for engineering teams';
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
- TEAM_PRICE_LABEL,
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. Answers a natural-language
6
- // question about THIS install's ThumbGate data (captured lessons + prevention
7
- // rules) by retrieving the most relevant lessons and asking Gemini to answer
8
- // grounded ONLY in that retrieved context (RAG). No data leaves the box except
9
- // the retrieved snippets + the question, sent to the configured Gemini endpoint.
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
- // Enterprise framing: this is the in-product "chat with your governed data"
12
- // experience. (The Dialogflow CX messenger widget is the separate path where a
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.prototype.hasOwnProperty.call(opts, 'apiKey')) {
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
- // Retrieve the most relevant stored lessons for the question.
53
- function retrieveContext(question, opts = {}) {
54
- let searchLessons;
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
- ({ searchLessons } = require(path.join(__dirname, 'lesson-search')));
57
- } catch (_) {
58
- return [];
60
+ return require(path.join(__dirname, 'lesson-search')).searchLessons;
61
+ } catch (err) {
62
+ debugChatFallback('lesson search unavailable', err);
63
+ return null;
59
64
  }
60
- let res;
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
- } catch (_) {
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
- const rows = (res && (res.results || res.lessons)) || [];
70
- return rows.slice(0, MAX_CONTEXT_LESSONS).map((l) => ({
71
- id: l.id,
72
- signal: l.signal || l.feedback || '',
73
- title: (l.title || '').replace(/^(?:MISTAKE|SUCCESS):\s*/i, '').slice(0, 160),
74
- content: String(l.content || l.context || '').replace(/\s+/g, ' ').trim().slice(0, 600),
75
- tags: l.tags || [],
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 or PERPLEXITY_API_KEY (for hybrid local-cloud) in the project .env or via dashboard Save. See adapters/perplexity/HYBRID.md.',
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 (isPerplexity) {
138
- // Use Perplexity hybrid-capable API (OpenAI compatible) for RAG chat with your data
139
- const res = await fetchImpl(PERPLEXITY_ENDPOINT, {
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 && err.message ? err.message : String(err), sources };
275
+ return { ok: false, error: 'network', message: err?.message || String(err), sources };
175
276
  }
176
277
  }
177
278
 
@@ -449,9 +449,13 @@ function computeGateAuditSeries(feedbackDir, options = {}) {
449
449
  const dayKey = toLocalDayKey(entry.timestamp);
450
450
  if (!dayKey) continue;
451
451
  if (!countsByDay.has(dayKey)) {
452
- countsByDay.set(dayKey, { allow: 0, deny: 0, warn: 0 });
452
+ countsByDay.set(dayKey, { allow: 0, deny: 0, warn: 0, byGate: {} });
453
+ }
454
+ const bucket = countsByDay.get(dayKey);
455
+ bucket[entry.decision] += 1;
456
+ if ((entry.decision === 'deny' || entry.decision === 'warn') && entry.gateId) {
457
+ bucket.byGate[entry.gateId] = (bucket.byGate[entry.gateId] || 0) + 1;
453
458
  }
454
- countsByDay.get(dayKey)[entry.decision] += 1;
455
459
  }
456
460
 
457
461
  const days = [];
@@ -461,7 +465,7 @@ function computeGateAuditSeries(feedbackDir, options = {}) {
461
465
  const day = new Date(today);
462
466
  day.setDate(today.getDate() - offset);
463
467
  const dayKey = toLocalDayKey(day);
464
- const record = countsByDay.get(dayKey) || { allow: 0, deny: 0, warn: 0 };
468
+ const record = countsByDay.get(dayKey) || { allow: 0, deny: 0, warn: 0, byGate: {} };
465
469
  const intercepted = record.deny + record.warn;
466
470
  const total = intercepted + record.allow;
467
471
  const summary = {
@@ -471,6 +475,7 @@ function computeGateAuditSeries(feedbackDir, options = {}) {
471
475
  warn: record.warn,
472
476
  intercepted,
473
477
  total,
478
+ byGate: record.byGate || {},
474
479
  };
475
480
  totals.allow += record.allow;
476
481
  totals.deny += record.deny;
@@ -525,7 +530,14 @@ function computePreventionImpact(feedbackDir, gateStats) {
525
530
  // Last auto-promotion
526
531
  const autoGates = readJsonFile(autoGatesPath);
527
532
  let lastPromotion = null;
533
+ let promotionsToday = 0;
534
+ let promotionIdsToday = [];
528
535
  if (autoGates && Array.isArray(autoGates.promotionLog) && autoGates.promotionLog.length > 0) {
536
+ const todayKey = toLocalDayKey(new Date());
537
+ promotionIdsToday = autoGates.promotionLog
538
+ .filter((p) => p && p.timestamp && toLocalDayKey(p.timestamp) === todayKey)
539
+ .map((p) => p.gateId || p.id || 'unknown');
540
+ promotionsToday = promotionIdsToday.length;
529
541
  const sorted = autoGates.promotionLog
530
542
  .filter((p) => p.timestamp)
531
543
  .sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
@@ -540,6 +552,8 @@ function computePreventionImpact(feedbackDir, gateStats) {
540
552
  estimatedHoursSaved,
541
553
  ruleCount,
542
554
  lastPromotion,
555
+ promotionsToday,
556
+ promotionIdsToday: promotionIdsToday.slice(0, 5),
543
557
  };
544
558
  }
545
559
 
@@ -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