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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.well-known/mcp/server-card.json +1 -1
- package/README.md +62 -31
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/mcp/server-stdio.js +83 -6
- package/adapters/opencode/opencode.json +1 -1
- package/bin/cli.js +390 -14
- package/config/mcp-allowlists.json +3 -0
- package/package.json +13 -2
- package/public/agents-cost-savings.html +2 -0
- package/public/index.html +10 -2
- package/public/numbers.html +2 -2
- package/scripts/action-receipts.js +324 -0
- package/scripts/cli-schema.js +24 -0
- package/scripts/dashboard.js +6 -1
- package/scripts/gates-engine.js +28 -9
- package/scripts/llm-client.js +90 -4
- package/scripts/local-model-profile.js +15 -8
- package/scripts/meta-agent-loop.js +9 -5
- package/scripts/noop-detect.js +285 -0
- package/scripts/operational-dashboard.js +160 -0
- package/scripts/operational-summary.js +178 -0
- package/scripts/plan-gate.js +11 -0
- package/scripts/repeat-metric.js +121 -0
- package/scripts/silent-failure-cluster.js +22 -3
- package/scripts/tool-registry.js +50 -0
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.
|
|
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.
|
|
1597
|
+
<span class="footer-copy">© 2026 ThumbGate · MIT License · npm v1.26.2</span>
|
|
1590
1598
|
</div>
|
|
1591
1599
|
</footer>
|
|
1592
1600
|
|
package/public/numbers.html
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
+
};
|
package/scripts/cli-schema.js
CHANGED
|
@@ -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
|
/**
|
package/scripts/dashboard.js
CHANGED
|
@@ -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
|
-
|
|
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);
|
package/scripts/gates-engine.js
CHANGED
|
@@ -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 {
|
|
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 =
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
package/scripts/llm-client.js
CHANGED
|
@@ -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
|
|
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 (
|
|
23
|
+
if (_anthropicClient) return _anthropicClient;
|
|
23
24
|
if (!isAvailable()) return null;
|
|
24
25
|
try {
|
|
25
26
|
const Anthropic = require('@anthropic-ai/sdk');
|
|
26
|
-
|
|
27
|
-
return
|
|
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 === '
|
|
152
|
-
? '
|
|
153
|
-
:
|
|
154
|
-
?
|
|
155
|
-
:
|
|
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 === '
|
|
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
|
|
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 };
|