thumbgate 1.26.0 → 1.26.2

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/public/index.html CHANGED
@@ -20,7 +20,7 @@ __GOOGLE_SITE_VERIFICATION_META__
20
20
  <meta property="og:image" content="https://thumbgate.ai/og.png">
21
21
  <meta name="twitter:card" content="summary_large_image">
22
22
  <meta name="twitter:image" content="https://thumbgate.ai/og.png">
23
- <meta name="thumbgate-version" content="1.26.0">
23
+ <meta name="thumbgate-version" content="1.26.2">
24
24
  <meta name="keywords" content="ThumbGate, thumbgate, AI agent orchestration, AI experience orchestration, agentic development cycle, AC/DC framework, Guide Generate Verify Solve, agent enforcement layer, save LLM tokens, reduce Claude API cost, reduce OpenAI cost, AI agent token savings, prevent LLM retries, prevent hallucination retries, stop AI token waste, pre-action checks, agent governance, Claude Code, Cursor, Codex, Gemini, Amp, Cline, OpenCode, workflow hardening, context engineering, AI authenticity, brand authenticity AI">
25
25
  <link rel="canonical" href="__APP_ORIGIN__/">
26
26
  <link rel="alternate" type="text/markdown" title="ThumbGate LLM context" href="__APP_ORIGIN__/llm-context.md">
@@ -1536,6 +1536,14 @@ __GA_BOOTSTRAP__
1536
1536
  <div class="faq-q" role="button" tabindex="0" aria-expanded="false" onclick="toggleFaq(this)" onkeydown="handleFaqKeydown(event)">What does Pro cost?</div>
1537
1537
  <div class="faq-a">Pro is $19/mo or $149/yr for individual operators and bills immediately through Stripe. Team is $49/seat/mo with a 3-seat minimum and starts through the workflow intake so scope, shared rules, and rollout proof are explicit before a team rollout.</div>
1538
1538
  </div>
1539
+ <div class="faq-item">
1540
+ <div class="faq-q" role="button" tabindex="0" aria-expanded="false" onclick="toggleFaq(this)" onkeydown="handleFaqKeydown(event)">Does ThumbGate support enterprise Google Cloud / Vertex AI?</div>
1541
+ <div class="faq-a">Yes! ThumbGate features a zero-friction enterprise setup path via <code>npx thumbgate setup-vertex</code>. This command automatically detects your active gcloud session, enables the Vertex AI API on your Google Cloud project, and configures secure Application Default Credentials (ADC) to route all evaluations within your corporate VPC.</div>
1542
+ </div>
1543
+ <div class="faq-item">
1544
+ <div class="faq-q" role="button" tabindex="0" aria-expanded="false" onclick="toggleFaq(this)" onkeydown="handleFaqKeydown(event)">How does ThumbGate contain enterprise API costs?</div>
1545
+ <div class="faq-a">ThumbGate prevents runaway API costs through a local client-side token ledger (FrontierBudget) that enforces strict cost-containment limits (such as keeping monthly costs under $10/mo). Because GCP billing console alerts are delayed, our local circuit breaker halts runaway agent loops in milliseconds to guarantee budget protection.</div>
1546
+ </div>
1539
1547
  </div>
1540
1548
  </div>
1541
1549
  </section>
@@ -1586,7 +1594,7 @@ __GA_BOOTSTRAP__
1586
1594
  <a href="https://www.linkedin.com/in/igorganapolsky" target="_blank" rel="noopener">LinkedIn</a>
1587
1595
  <a href="/blog">Blog</a>
1588
1596
  </div>
1589
- <span class="footer-copy">© 2026 ThumbGate · MIT License · npm v1.26.0</span>
1597
+ <span class="footer-copy">© 2026 ThumbGate · MIT License · npm v1.26.2</span>
1590
1598
  </div>
1591
1599
  </footer>
1592
1600
 
@@ -25,7 +25,7 @@
25
25
  "alternateName": "thumbgate",
26
26
  "applicationCategory": "DeveloperApplication",
27
27
  "operatingSystem": "Cross-platform, Node.js >=18.18.0",
28
- "softwareVersion": "1.26.0",
28
+ "softwareVersion": "1.26.2",
29
29
  "url": "https://thumbgate.ai/numbers",
30
30
  "dateModified": "2026-05-07",
31
31
  "creator": {
@@ -202,7 +202,7 @@
202
202
  <main class="container">
203
203
  <h1>The Numbers</h1>
204
204
  <p class="subtitle">Generated first-party operational snapshot from the ThumbGate runtime. This is not customer traction, install volume, revenue, or proof that a configured gate has fired.</p>
205
- <div class="freshness">Updated: 2026-05-07 · Version 1.26.0</div>
205
+ <div class="freshness">Updated: 2026-05-07 · Version 1.26.2</div>
206
206
  <div class="truth-note"><strong>Read this first:</strong> configured checks are inventory. Recorded blocks and warnings are usage evidence. This snapshot currently reports 0 recorded hard-block event(s) and 0 recorded warning event(s).</div>
207
207
 
208
208
  <h2>Gate enforcement</h2>
@@ -0,0 +1,324 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * Action Receipts — outcome-paired lessons.
6
+ *
7
+ * Pairs each tracked tool call with its concrete result (diff / exit code /
8
+ * test outcome / state hash) so that a promoted prevention rule can encode
9
+ * "this action -> this outcome" rather than only a bare thumbs signal.
10
+ *
11
+ * Receipts are persisted as JSONL beside the other feedback artifacts
12
+ * (FEEDBACK_DIR/action-receipts.jsonl) using the same project-scoped
13
+ * resolution as the rest of the feedback pipeline (feedback-paths).
14
+ *
15
+ * This module is self-contained: it only depends on feedback-paths + fs and
16
+ * makes no edits to shared files. It is consumed from the MCP adapter wiring
17
+ * step (record_action_receipt / get_action_receipts tools) and threads into
18
+ * capture_feedback's lesson pipeline + construct_context_pack.
19
+ */
20
+
21
+ const fs = require('fs');
22
+ const path = require('path');
23
+ const { getFeedbackPaths } = require('./feedback-paths');
24
+
25
+ const RECEIPTS_FILE = 'action-receipts.jsonl';
26
+
27
+ /**
28
+ * Resolve the absolute path to the receipts JSONL for the active project.
29
+ * @param {object} [options] - Passed through to getFeedbackPaths (e.g. for tests).
30
+ * @returns {string}
31
+ */
32
+ function getReceiptsPath(options = {}) {
33
+ const { FEEDBACK_DIR } = getFeedbackPaths(options);
34
+ return path.join(FEEDBACK_DIR, RECEIPTS_FILE);
35
+ }
36
+
37
+ function ensureDirFor(filePath) {
38
+ try {
39
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
40
+ } catch {
41
+ // best-effort; write will surface a real error if the dir truly cannot exist
42
+ }
43
+ }
44
+
45
+ function safeString(value) {
46
+ if (value === null || value === undefined) return '';
47
+ if (typeof value === 'string') return value;
48
+ try {
49
+ return String(value);
50
+ } catch {
51
+ return '';
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Build a short, human-readable summary of a tool input for the paired-lesson
57
+ * string. Never throws; clamps length so the lesson stays compact.
58
+ * @param {*} toolInput
59
+ * @returns {string}
60
+ */
61
+ function summarizeInput(toolInput) {
62
+ if (toolInput === null || toolInput === undefined) return '';
63
+ if (typeof toolInput === 'string') return clampText(toolInput, 120);
64
+
65
+ if (typeof toolInput === 'object') {
66
+ // Prefer the most lesson-relevant keys when present.
67
+ const preferredKeys = ['file', 'filePath', 'path', 'command', 'cmd', 'query', 'pattern'];
68
+ for (const key of preferredKeys) {
69
+ if (toolInput[key]) {
70
+ return `${key}=${clampText(safeString(toolInput[key]), 100)}`;
71
+ }
72
+ }
73
+ try {
74
+ return clampText(JSON.stringify(toolInput), 120);
75
+ } catch {
76
+ return '';
77
+ }
78
+ }
79
+
80
+ return clampText(safeString(toolInput), 120);
81
+ }
82
+
83
+ function clampText(text, max) {
84
+ const str = safeString(text);
85
+ if (str.length <= max) return str;
86
+ return `${str.slice(0, Math.max(0, max - 1))}…`;
87
+ }
88
+
89
+ /**
90
+ * Derive a compact outcome descriptor from an outcome object.
91
+ * @param {object} outcome
92
+ * @returns {string}
93
+ */
94
+ function summarizeOutcome(outcome) {
95
+ if (!outcome || typeof outcome !== 'object') return 'unknown outcome';
96
+ const parts = [];
97
+ if (outcome.testOutcome) parts.push(`tests:${clampText(safeString(outcome.testOutcome), 40)}`);
98
+ if (outcome.exitCode !== undefined && outcome.exitCode !== null) {
99
+ parts.push(`exit:${outcome.exitCode}`);
100
+ }
101
+ if (outcome.diff) {
102
+ const diffStr = safeString(outcome.diff);
103
+ parts.push(`diff:${diffStr.length}b`);
104
+ }
105
+ if (outcome.stateHash) parts.push(`hash:${clampText(safeString(outcome.stateHash), 12)}`);
106
+ return parts.length > 0 ? parts.join(' ') : 'no outcome fields';
107
+ }
108
+
109
+ /**
110
+ * Normalize a raw record_action_receipt payload into a stored receipt object.
111
+ * Accepts either a nested { outcome: {...} } shape or flat top-level fields
112
+ * (diff / exitCode / testOutcome / stateHash) as the MCP tool surfaces them.
113
+ * @param {object} params
114
+ * @returns {object}
115
+ */
116
+ function normalizeReceipt(params = {}) {
117
+ const outcomeSource = (params.outcome && typeof params.outcome === 'object')
118
+ ? params.outcome
119
+ : params;
120
+
121
+ const outcome = {
122
+ diff: outcomeSource.diff !== undefined ? outcomeSource.diff : null,
123
+ exitCode: (outcomeSource.exitCode !== undefined && outcomeSource.exitCode !== null)
124
+ ? Number(outcomeSource.exitCode)
125
+ : null,
126
+ testOutcome: outcomeSource.testOutcome !== undefined ? outcomeSource.testOutcome : null,
127
+ stateHash: outcomeSource.stateHash !== undefined ? outcomeSource.stateHash : null,
128
+ };
129
+
130
+ return {
131
+ actionId: safeString(params.actionId) || null,
132
+ toolName: params.toolName !== undefined ? safeString(params.toolName) : null,
133
+ toolInput: params.toolInput !== undefined ? params.toolInput : null,
134
+ outcome,
135
+ recordedAt: new Date().toISOString(),
136
+ };
137
+ }
138
+
139
+ /**
140
+ * Append a receipt to the JSONL ledger.
141
+ * @param {object} params - { actionId, toolName, toolInput, outcome:{ diff, exitCode, testOutcome, stateHash } }
142
+ * @param {object} [options] - feedback-paths options (e.g. for tests).
143
+ * @returns {object} The stored receipt record (with recorded:true).
144
+ */
145
+ function recordReceipt(params = {}, options = {}) {
146
+ const receipt = normalizeReceipt(params);
147
+ const receiptsPath = getReceiptsPath(options);
148
+ ensureDirFor(receiptsPath);
149
+ fs.appendFileSync(receiptsPath, `${JSON.stringify(receipt)}\n`, 'utf8');
150
+ return { recorded: true, ...receipt };
151
+ }
152
+
153
+ /**
154
+ * Read all receipts from the ledger (oldest first). Tolerates malformed lines.
155
+ * @param {object} [options]
156
+ * @returns {object[]}
157
+ */
158
+ function readAllReceipts(options = {}) {
159
+ const receiptsPath = getReceiptsPath(options);
160
+ let raw;
161
+ try {
162
+ raw = fs.readFileSync(receiptsPath, 'utf8');
163
+ } catch {
164
+ return [];
165
+ }
166
+ const receipts = [];
167
+ for (const line of raw.split('\n')) {
168
+ const trimmed = line.trim();
169
+ if (!trimmed) continue;
170
+ try {
171
+ receipts.push(JSON.parse(trimmed));
172
+ } catch {
173
+ // skip malformed line
174
+ }
175
+ }
176
+ return receipts;
177
+ }
178
+
179
+ /**
180
+ * Return the most recent receipt for a given actionId, or null.
181
+ * @param {string} actionId
182
+ * @param {object} [options]
183
+ * @returns {object|null}
184
+ */
185
+ function getReceiptForAction(actionId, options = {}) {
186
+ if (!actionId) return null;
187
+ const target = safeString(actionId);
188
+ const receipts = readAllReceipts(options);
189
+ for (let i = receipts.length - 1; i >= 0; i -= 1) {
190
+ if (safeString(receipts[i].actionId) === target) return receipts[i];
191
+ }
192
+ return null;
193
+ }
194
+
195
+ /**
196
+ * Return the last n receipts (most recent last, preserving chronological order).
197
+ * @param {number} [n=20]
198
+ * @param {object} [options]
199
+ * @returns {object[]}
200
+ */
201
+ function getRecentReceipts(n = 20, options = {}) {
202
+ const limit = Number.isFinite(Number(n)) && Number(n) > 0 ? Math.floor(Number(n)) : 20;
203
+ const receipts = readAllReceipts(options);
204
+ return receipts.slice(-limit);
205
+ }
206
+
207
+ /**
208
+ * Build the "action -> outcome" lesson string for a matched receipt.
209
+ * @param {object} receipt
210
+ * @returns {string}
211
+ */
212
+ function buildOutcomePairedLesson(receipt) {
213
+ if (!receipt) return '';
214
+ const toolName = safeString(receipt.toolName) || 'action';
215
+ const inputSummary = summarizeInput(receipt.toolInput);
216
+ const outcomeSummary = summarizeOutcome(receipt.outcome);
217
+ return `${toolName}(${inputSummary}) -> ${outcomeSummary}`;
218
+ }
219
+
220
+ /**
221
+ * Resolve which actionId a feedback payload refers to. Supports both the
222
+ * legacy `lastAction` shape (object or string) and a flat `actionId` field.
223
+ * @param {object} feedbackParams
224
+ * @returns {string|null}
225
+ */
226
+ function resolveFeedbackActionId(feedbackParams = {}) {
227
+ if (feedbackParams.actionId) return safeString(feedbackParams.actionId);
228
+
229
+ const lastAction = feedbackParams.lastAction;
230
+ if (!lastAction) return null;
231
+ if (typeof lastAction === 'string') return safeString(lastAction);
232
+ if (typeof lastAction === 'object') {
233
+ if (lastAction.actionId) return safeString(lastAction.actionId);
234
+ if (lastAction.id) return safeString(lastAction.id);
235
+ }
236
+ return null;
237
+ }
238
+
239
+ /**
240
+ * Enrich a capture_feedback payload with the most recent matching receipt's
241
+ * outcome so the lesson pipeline encodes action->outcome. If no receipt
242
+ * matches, the original payload is returned unchanged (never throws).
243
+ * @param {object} feedbackParams
244
+ * @param {object} [options]
245
+ * @returns {object}
246
+ */
247
+ function pairFeedbackWithReceipt(feedbackParams = {}, options = {}) {
248
+ const actionId = resolveFeedbackActionId(feedbackParams);
249
+ if (!actionId) return feedbackParams;
250
+
251
+ let receipt = null;
252
+ try {
253
+ receipt = getReceiptForAction(actionId, options);
254
+ } catch {
255
+ return feedbackParams;
256
+ }
257
+ if (!receipt) return feedbackParams;
258
+
259
+ const outcomePairedLesson = buildOutcomePairedLesson(receipt);
260
+
261
+ return {
262
+ ...feedbackParams,
263
+ outcome: { ...receipt.outcome },
264
+ outcomePairedLesson,
265
+ receiptActionId: actionId,
266
+ };
267
+ }
268
+
269
+ /**
270
+ * Build construct_context_pack-shaped candidate entries from receipts that
271
+ * match a free-text query. Returns [{ namespace, text, score }].
272
+ * @param {string} query
273
+ * @param {number} [limit=5]
274
+ * @param {object} [options]
275
+ * @returns {Array<{namespace:string, text:string, score:number}>}
276
+ */
277
+ function buildReceiptContextEntries(query, limit = 5, options = {}) {
278
+ const cap = Number.isFinite(Number(limit)) && Number(limit) > 0 ? Math.floor(Number(limit)) : 5;
279
+ const receipts = readAllReceipts(options);
280
+ if (receipts.length === 0) return [];
281
+
282
+ const queryTokens = safeString(query)
283
+ .toLowerCase()
284
+ .split(/[^a-z0-9]+/i)
285
+ .filter(Boolean);
286
+
287
+ const scored = receipts.map((receipt) => {
288
+ const lesson = buildOutcomePairedLesson(receipt);
289
+ const haystack = `${lesson} ${safeString(receipt.toolName)} ${summarizeInput(receipt.toolInput)}`.toLowerCase();
290
+ let score = 0;
291
+ for (const token of queryTokens) {
292
+ if (haystack.includes(token)) score += 1;
293
+ }
294
+ const text = `${lesson} [outcome: ${summarizeOutcome(receipt.outcome)}]`;
295
+ return { namespace: 'action-receipts', text, score };
296
+ });
297
+
298
+ // When the query is empty, surface the most recent receipts (score 0 but
299
+ // still useful for the pack); otherwise rank by token overlap.
300
+ const ranked = queryTokens.length === 0
301
+ ? scored.slice(-cap).reverse()
302
+ : scored
303
+ .filter((entry) => entry.score > 0)
304
+ .sort((a, b) => b.score - a.score)
305
+ .slice(0, cap);
306
+
307
+ return ranked;
308
+ }
309
+
310
+ module.exports = {
311
+ RECEIPTS_FILE,
312
+ getReceiptsPath,
313
+ recordReceipt,
314
+ readAllReceipts,
315
+ getReceiptForAction,
316
+ getRecentReceipts,
317
+ buildOutcomePairedLesson,
318
+ pairFeedbackWithReceipt,
319
+ buildReceiptContextEntries,
320
+ // exposed for testing / reuse
321
+ summarizeInput,
322
+ summarizeOutcome,
323
+ resolveFeedbackActionId,
324
+ };
@@ -51,6 +51,20 @@ const CLI_COMMANDS = [
51
51
  { name: 'json', type: 'boolean', description: 'Output as JSON' },
52
52
  ],
53
53
  },
54
+ {
55
+ name: 'feedback-self-test',
56
+ aliases: ['dogfood'],
57
+ description: 'Prove thumbs feedback capture works in the current runtime',
58
+ group: 'capture',
59
+ mcpTool: 'capture_feedback',
60
+ flags: [
61
+ { name: 'feedback', type: 'string', description: 'Signal to test: up or down (default down)' },
62
+ { name: 'context', type: 'string', description: 'Context to store in the test capture' },
63
+ { name: 'persist', type: 'boolean', description: 'Use the active ThumbGate store instead of an isolated test store' },
64
+ { name: 'feedback-dir', type: 'string', description: 'Explicit feedback directory for the self-test' },
65
+ { name: 'json', type: 'boolean', description: 'Output as JSON' },
66
+ ],
67
+ },
54
68
 
55
69
  // -------------------------------------------------------------------------
56
70
  // Discovery
@@ -590,6 +604,16 @@ const CLI_COMMANDS = [
590
604
  { name: 'info', type: 'boolean', description: 'Show Pro feature list' },
591
605
  ],
592
606
  },
607
+ {
608
+ name: 'brain',
609
+ description: 'Build the agent-readable context brain (lessons + rules + gates + project context)',
610
+ group: 'ops',
611
+ flags: [
612
+ { name: 'write', type: 'boolean', description: 'Save to .thumbgate/BRAIN.md (versioned, deterministic)' },
613
+ { name: 'limit', type: 'number', description: 'Max lessons to include (default 15)' },
614
+ { name: 'json', type: 'boolean', description: 'Output the structured model as JSON' },
615
+ ],
616
+ },
593
617
  ];
594
618
 
595
619
  /**
@@ -17,6 +17,7 @@ const { filterEntriesForWindow, resolveAnalyticsWindow } = require('./analytics-
17
17
  const { resolveHostedBillingConfig } = require('./hosted-config');
18
18
  const { generateAgentReadinessReport } = require('./agent-readiness');
19
19
  const { summarizeGateTemplates } = require('./gate-templates');
20
+ const { mergeRepeatMetricIntoGateStats } = require('./repeat-metric');
20
21
  const { buildPredictiveInsights } = loadOptionalModule('./predictive-insights', () => ({
21
22
  buildPredictiveInsights: () => ({
22
23
  upgradePropensity: {
@@ -1613,7 +1614,11 @@ function generateDashboard(feedbackDir, options = {}) {
1613
1614
  const billingSummary = options.billingSummary || getBillingSummary(analyticsWindow);
1614
1615
 
1615
1616
  const approval = computeApprovalStats(entries);
1616
- const gateStats = computeGateStats();
1617
+ // Surface the "repeat-attempts blocked before execution" metric on the
1618
+ // dashboard JSON and the /v1/dashboard HTTP route. Use the non-mutating
1619
+ // helper (mirrors server-stdio.js) instead of mutating computeGateStats()'s
1620
+ // return value. (mergeRepeatMetricIntoGateStats is imported at top of file.)
1621
+ const gateStats = mergeRepeatMetricIntoGateStats(computeGateStats());
1617
1622
  const prevention = computePreventionImpact(feedbackDir, gateStats);
1618
1623
  const trend = computeSessionTrend(entries, 10);
1619
1624
  const health = computeSystemHealth(feedbackDir, gateStats);
@@ -3,8 +3,9 @@
3
3
 
4
4
  const fs = require('fs');
5
5
  const path = require('path');
6
+ const os = require('os');
6
7
  const crypto = require('crypto');
7
- const { execSync, execFileSync } = require('child_process');
8
+ const { execFileSync } = require('child_process');
8
9
  const { loadOptionalModule } = require('./private-core-boundary');
9
10
 
10
11
  const { isProTier, isInTrialPeriod, FREE_TIER_MAX_GATES, FREE_TIER_DAILY_BLOCKS, todayKey } = require('./rate-limiter');
@@ -30,10 +31,12 @@ function computeExecutableHash(command) {
30
31
  const firstWord = command.trim().split(/\s+/)[0];
31
32
  if (!firstWord) return null;
32
33
 
33
- // Resolve absolute path using 'which'
34
+ // Resolve absolute path using 'which'. Use execFileSync (no shell) and pass
35
+ // firstWord as an argv element, never interpolated into a command string, so
36
+ // a hostile `command` value cannot inject shell metacharacters here.
34
37
  let fullPath;
35
38
  try {
36
- fullPath = execSync(`which ${firstWord}`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
39
+ fullPath = execFileSync('which', [firstWord], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
37
40
  } catch (e) {
38
41
  // If 'which' fails, it might be an absolute path or a non-existent command
39
42
  fullPath = path.isAbsolute(firstWord) ? firstWord : null;
@@ -65,12 +68,28 @@ const { recordAuditEvent, auditToFeedback } = require('./audit-trail');
65
68
 
66
69
  const DEFAULT_CONFIG_PATH = path.join(__dirname, '..', 'config', 'gates', 'default.json');
67
70
  const DEFAULT_CLAIM_GATES_PATH = path.join(__dirname, '..', 'config', 'gates', 'claim-verification.json');
68
- const STATE_PATH = path.join(process.env.HOME || '/tmp', '.thumbgate', 'gate-state.json');
69
- const CONSTRAINTS_PATH = path.join(process.env.HOME || '/tmp', '.thumbgate', 'session-constraints.json');
70
- const STATS_PATH = path.join(process.env.HOME || '/tmp', '.thumbgate', 'gate-stats.json');
71
- const SESSION_ACTIONS_PATH = path.join(process.env.HOME || '/tmp', '.thumbgate', 'session-actions.json');
72
- const CUSTOM_CLAIM_GATES_PATH = path.join(process.env.HOME || '/tmp', '.thumbgate', 'claim-verification.json');
73
- const GOVERNANCE_STATE_PATH = path.join(process.env.HOME || '/tmp', '.thumbgate', 'governance-state.json');
71
+
72
+ function resolveThumbgateStateDir() {
73
+ if (process.env.THUMBGATE_STATE_DIR) return process.env.THUMBGATE_STATE_DIR;
74
+
75
+ if (process.env.XDG_STATE_HOME) {
76
+ return path.join(process.env.XDG_STATE_HOME, 'thumbgate');
77
+ }
78
+
79
+ if (process.env.CODEX_SANDBOX) {
80
+ return path.join(os.tmpdir(), 'thumbgate');
81
+ }
82
+
83
+ return path.join(process.env.HOME || os.tmpdir(), '.thumbgate');
84
+ }
85
+
86
+ const STATE_DIR = resolveThumbgateStateDir();
87
+ const STATE_PATH = path.join(STATE_DIR, 'gate-state.json');
88
+ const CONSTRAINTS_PATH = path.join(STATE_DIR, 'session-constraints.json');
89
+ const STATS_PATH = path.join(STATE_DIR, 'gate-stats.json');
90
+ const SESSION_ACTIONS_PATH = path.join(STATE_DIR, 'session-actions.json');
91
+ const CUSTOM_CLAIM_GATES_PATH = path.join(STATE_DIR, 'claim-verification.json');
92
+ const GOVERNANCE_STATE_PATH = path.join(STATE_DIR, 'governance-state.json');
74
93
  const TTL_MS = 5 * 60 * 1000; // 5 minutes
75
94
  const SESSION_ACTION_TTL_MS = 60 * 60 * 1000; // 1 hour
76
95
  const PROTECTED_APPROVAL_TTL_MS = 60 * 60 * 1000; // 1 hour
@@ -12,19 +12,20 @@ const DEFAULT_MODEL = MODELS.FAST;
12
12
  const DEFAULT_MAX_TOKENS = 1024;
13
13
  const DEFAULT_CACHE_TTL = '5m';
14
14
 
15
- let _client = null;
15
+ let _anthropicClient = null;
16
+ let _geminiClient = null;
16
17
 
17
18
  function isAvailable() {
18
19
  return Boolean(process.env.ANTHROPIC_API_KEY);
19
20
  }
20
21
 
21
22
  function getClient() {
22
- if (_client) return _client;
23
+ if (_anthropicClient) return _anthropicClient;
23
24
  if (!isAvailable()) return null;
24
25
  try {
25
26
  const Anthropic = require('@anthropic-ai/sdk');
26
- _client = new Anthropic();
27
- return _client;
27
+ _anthropicClient = new Anthropic();
28
+ return _anthropicClient;
28
29
  } catch {
29
30
  return null;
30
31
  }
@@ -138,7 +139,92 @@ function parseClaudeJson(text) {
138
139
  }
139
140
  }
140
141
 
142
+ async function callGeminiInternal(options = {}) {
143
+ const env = process.env;
144
+ const { detectInferenceBackend } = require('./local-model-profile');
145
+ const providerMode = detectInferenceBackend(env).providerMode;
146
+
147
+ try {
148
+ const { GoogleGenAI } = require('@google/genai');
149
+ if (!_geminiClient) {
150
+ if (providerMode === 'vertex') {
151
+ _geminiClient = new GoogleGenAI({
152
+ enterprise: true,
153
+ project: env.VERTEX_PROJECT_ID || 'ai-revenue28-webhook',
154
+ location: env.VERTEX_LOCATION || 'us-central1',
155
+ });
156
+ } else {
157
+ _geminiClient = new GoogleGenAI({
158
+ apiKey: env.GEMINI_API_KEY,
159
+ });
160
+ }
161
+ }
162
+
163
+ const contents = convertMessagesToGemini(options.messages, options.userPrompt);
164
+ const config = {};
165
+ if (options.systemPrompt) {
166
+ config.systemInstruction = options.systemPrompt;
167
+ }
168
+ if (Number.isFinite(options.temperature)) {
169
+ config.temperature = options.temperature;
170
+ }
171
+ if (options.maxTokens) {
172
+ config.maxOutputTokens = options.maxTokens;
173
+ }
174
+
175
+ const response = await runStep('llm.callGemini', {
176
+ retries: 2,
177
+ logger: (msg) => console.warn(msg),
178
+ }, async () => _geminiClient.models.generateContent({
179
+ model: options.model,
180
+ contents,
181
+ config,
182
+ }));
183
+
184
+ return {
185
+ text: response.text || '',
186
+ usage: response.usageMetadata ? {
187
+ input_tokens: response.usageMetadata.promptTokenCount,
188
+ output_tokens: response.usageMetadata.candidatesTokenCount,
189
+ } : null,
190
+ stopReason: response.candidates?.[0]?.finishReason || null,
191
+ id: null,
192
+ model: options.model,
193
+ };
194
+ } catch (err) {
195
+ console.error('Gemini/Vertex AI execution error:', err);
196
+ return null;
197
+ }
198
+ }
199
+
200
+ function convertMessagesToGemini(messages, userPrompt) {
201
+ const list = Array.isArray(messages) && messages.length > 0
202
+ ? messages
203
+ : [{ role: 'user', content: userPrompt }];
204
+
205
+ return list.map((msg) => {
206
+ const role = msg.role === 'assistant' ? 'model' : 'user';
207
+ let text = '';
208
+ if (typeof msg.content === 'string') {
209
+ text = msg.content;
210
+ } else if (Array.isArray(msg.content)) {
211
+ text = msg.content.map((c) => c.text || '').join('');
212
+ } else if (msg.content && typeof msg.content === 'object') {
213
+ text = msg.content.text || JSON.stringify(msg.content);
214
+ }
215
+ return {
216
+ role,
217
+ parts: [{ text }],
218
+ };
219
+ });
220
+ }
221
+
141
222
  async function callClaudeInternal(options = {}) {
223
+ const modelName = options.model || '';
224
+ if (modelName.startsWith('gemini') || modelName.startsWith('vertex')) {
225
+ return callGeminiInternal(options);
226
+ }
227
+
142
228
  const client = getClient();
143
229
  if (!client) return null;
144
230
 
@@ -111,7 +111,8 @@ function isSparseAttentionFamily(modelFamily) {
111
111
 
112
112
  function resolveProviderMode(env = process.env) {
113
113
  const explicit = normalizeSlug(env.THUMBGATE_PROVIDER_MODE || env.THUMBGATE_MODEL_PROVIDER_MODE);
114
- if (explicit === 'local' || explicit === 'managed') return explicit;
114
+ if (explicit === 'local' || explicit === 'managed' || explicit === 'vertex') return explicit;
115
+ if (env.VERTEX_PROJECT_ID || env.VERTEX_API_ENDPOINT) return 'vertex';
115
116
  if (env.THUMBGATE_LOCAL_MODEL_FAMILY || env.THUMBGATE_LOCAL_MODEL_SERVER) return 'local';
116
117
  return 'managed';
117
118
  }
@@ -133,6 +134,7 @@ function resolveModelFamily(env = process.env) {
133
134
  }
134
135
 
135
136
  function buildBackendLabel(providerMode, modelFamily) {
137
+ if (providerMode === 'vertex') return 'Vertex AI secure cloud backend';
136
138
  if (providerMode === 'managed') return 'Managed API backend';
137
139
  if (modelFamily.startsWith('deepseek')) return 'Local DeepSeek sparse backend';
138
140
  if (modelFamily.startsWith('glm')) return 'Local GLM sparse backend';
@@ -148,14 +150,18 @@ function detectInferenceBackend(env = process.env) {
148
150
  && supportsSparseAttention
149
151
  && INDEXCACHE_SERVER_ENGINES.has(serverEngine);
150
152
  const indexCacheEnabled = indexCacheEligible && parseBoolean(env.THUMBGATE_INDEXCACHE_ENABLED, false);
151
- const id = providerMode === 'managed'
152
- ? 'managed-api'
153
- : supportsSparseAttention
154
- ? `local-${modelFamily}-sparse`
155
- : 'local-dense';
153
+ const id = providerMode === 'vertex'
154
+ ? 'vertex-api'
155
+ : providerMode === 'managed'
156
+ ? 'managed-api'
157
+ : supportsSparseAttention
158
+ ? `local-${modelFamily}-sparse`
159
+ : 'local-dense';
156
160
 
157
161
  let rationale = 'Baseline backend with no sparse-attention acceleration.';
158
- if (providerMode === 'managed') {
162
+ if (providerMode === 'vertex') {
163
+ rationale = 'Vertex AI secure cloud backend providing compliant enterprise Gemini models inside VPC boundary.';
164
+ } else if (providerMode === 'managed') {
159
165
  rationale = 'Managed API path does not expose sparse-attention kernel controls like IndexCache.';
160
166
  } else if (indexCacheEnabled) {
161
167
  rationale = `Local ${modelFamily} backend is sparse-attention capable and IndexCache-ready on ${serverEngine}.`;
@@ -336,7 +342,8 @@ function resolveModelRole(role, env) {
336
342
  const envKey = `THUMBGATE_MODEL_ROLE_${normalized.toUpperCase()}`;
337
343
  const modelFamily = resolveModelFamily(e);
338
344
  const isLocalGlm = modelFamily.startsWith('glm');
339
- const provider = isLocalGlm ? 'local' : 'gemini';
345
+ const providerMode = resolveProviderMode(e);
346
+ const provider = isLocalGlm ? 'local' : (providerMode === 'vertex' ? 'vertex' : 'gemini');
340
347
  const defaultModel = isLocalGlm ? (GLM_MODEL_ROLES[normalized] || MODEL_ROLES[normalized]) : MODEL_ROLES[normalized];
341
348
  const model = (e[envKey] && String(e[envKey]).trim()) || defaultModel;
342
349
  return { role: normalized, model, provider, envKey };