thumbgate 1.12.0 → 1.12.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.
@@ -0,0 +1,120 @@
1
+ 'use strict';
2
+
3
+ const MIN_WINDOW_HOURS = 1;
4
+ const MAX_WINDOW_HOURS = 24 * 30;
5
+ const DEFAULT_WINDOW_HOURS = 24;
6
+
7
+ function normalizeWindowHours(input) {
8
+ if (input === null || input === undefined || input === '') return DEFAULT_WINDOW_HOURS;
9
+ const n = Number(input);
10
+ if (!Number.isFinite(n)) return DEFAULT_WINDOW_HOURS;
11
+ if (n < MIN_WINDOW_HOURS) return MIN_WINDOW_HOURS;
12
+ if (n > MAX_WINDOW_HOURS) return MAX_WINDOW_HOURS;
13
+ return Math.floor(n);
14
+ }
15
+
16
+ function topNegativeTags(tags, limit = 5) {
17
+ if (!tags || typeof tags !== 'object') return [];
18
+ return Object.entries(tags)
19
+ .map(([tag, counts]) => ({
20
+ tag,
21
+ negative: (counts && counts.negative) || 0,
22
+ positive: (counts && counts.positive) || 0,
23
+ total: (counts && counts.total) || 0,
24
+ }))
25
+ .filter((row) => row.negative > 0)
26
+ .sort((a, b) => b.negative - a.negative)
27
+ .slice(0, limit);
28
+ }
29
+
30
+ function topGates(byGate, limit = 5) {
31
+ if (!byGate || typeof byGate !== 'object') return [];
32
+ return Object.entries(byGate)
33
+ .map(([gate, counts]) => ({
34
+ gate,
35
+ blocked: (counts && counts.blocked) || 0,
36
+ warned: (counts && counts.warned) || 0,
37
+ pendingApproval: (counts && counts.pendingApproval) || 0,
38
+ }))
39
+ .sort((a, b) => b.blocked - a.blocked || b.warned - a.warned)
40
+ .slice(0, limit);
41
+ }
42
+
43
+ function summarizeProvenance(events, sinceMs) {
44
+ if (!Array.isArray(events)) return { total: 0, byType: {} };
45
+ const byType = {};
46
+ let total = 0;
47
+ for (const evt of events) {
48
+ const ts = Date.parse(evt && evt.timestamp ? evt.timestamp : '');
49
+ if (!Number.isFinite(ts) || ts < sinceMs) continue;
50
+ total += 1;
51
+ const type = (evt && evt.type) || 'unknown';
52
+ byType[type] = (byType[type] || 0) + 1;
53
+ }
54
+ return { total, byType };
55
+ }
56
+
57
+ function buildSessionReport({ windowHours } = {}) {
58
+ const hours = normalizeWindowHours(windowHours);
59
+ const sinceMs = Date.now() - hours * 60 * 60 * 1000;
60
+ const report = {
61
+ generatedAt: new Date().toISOString(),
62
+ windowHours: hours,
63
+ since: new Date(sinceMs).toISOString(),
64
+ feedback: { totalPositive: 0, totalNegative: 0, topNegativeTags: [] },
65
+ gates: { blocked: 0, warned: 0, passed: 0, topGates: [] },
66
+ provenance: { total: 0, byType: {} },
67
+ errors: {},
68
+ };
69
+
70
+ try {
71
+ const { analyzeFeedback } = require('./feedback-loop');
72
+ const feedback = analyzeFeedback() || {};
73
+ report.feedback = {
74
+ totalPositive: feedback.totalPositive || 0,
75
+ totalNegative: feedback.totalNegative || 0,
76
+ topNegativeTags: topNegativeTags(feedback.tags || {}),
77
+ };
78
+ } catch (err) {
79
+ report.errors.feedback = String(err && err.message ? err.message : err);
80
+ }
81
+
82
+ try {
83
+ const { loadStats } = require('./gates-engine');
84
+ const stats = loadStats() || {};
85
+ report.gates = {
86
+ blocked: stats.blocked || 0,
87
+ warned: stats.warned || 0,
88
+ passed: stats.passed || 0,
89
+ pendingApproval: stats.pendingApproval || 0,
90
+ topGates: topGates(stats.byGate || {}),
91
+ };
92
+ } catch (err) {
93
+ report.errors.gates = String(err && err.message ? err.message : err);
94
+ }
95
+
96
+ try {
97
+ const { getProvenance } = require('./contextfs');
98
+ const events = getProvenance(500) || [];
99
+ report.provenance = summarizeProvenance(events, sinceMs);
100
+ } catch (err) {
101
+ report.errors.provenance = String(err && err.message ? err.message : err);
102
+ }
103
+
104
+ if (Object.keys(report.errors).length === 0) {
105
+ delete report.errors;
106
+ }
107
+
108
+ return report;
109
+ }
110
+
111
+ module.exports = {
112
+ buildSessionReport,
113
+ normalizeWindowHours,
114
+ topNegativeTags,
115
+ topGates,
116
+ summarizeProvenance,
117
+ MIN_WINDOW_HOURS,
118
+ MAX_WINDOW_HOURS,
119
+ DEFAULT_WINDOW_HOURS,
120
+ };
@@ -0,0 +1,81 @@
1
+ 'use strict';
2
+
3
+ const { constructContextPack, recordProvenance } = require('./contextfs');
4
+
5
+ const DEFAULT_TTL_MS = 15 * 60 * 1000;
6
+ const MAX_AGENTS = 32;
7
+
8
+ function normalizeAgents(agents) {
9
+ if (!Array.isArray(agents)) {
10
+ throw new Error('agents must be a non-empty array of agent names');
11
+ }
12
+ const normalized = [];
13
+ const seen = new Set();
14
+ for (const raw of agents) {
15
+ const name = typeof raw === 'string' ? raw.trim() : String((raw && raw.name) || '').trim();
16
+ if (!name) continue;
17
+ if (seen.has(name)) continue;
18
+ seen.add(name);
19
+ normalized.push(name);
20
+ }
21
+ if (normalized.length === 0) {
22
+ throw new Error('agents must include at least one named agent');
23
+ }
24
+ if (normalized.length > MAX_AGENTS) {
25
+ throw new Error(`agents list exceeds MAX_AGENTS (${MAX_AGENTS})`);
26
+ }
27
+ return normalized;
28
+ }
29
+
30
+ function distributeContextToAgents({
31
+ query = '',
32
+ agents,
33
+ maxItems,
34
+ maxChars,
35
+ namespaces,
36
+ ttlMs,
37
+ } = {}) {
38
+ const agentNames = normalizeAgents(agents);
39
+ const ttl = Number.isFinite(Number(ttlMs)) && Number(ttlMs) > 0 ? Number(ttlMs) : DEFAULT_TTL_MS;
40
+ const expiresAt = new Date(Date.now() + ttl).toISOString();
41
+
42
+ const pack = constructContextPack({
43
+ query,
44
+ maxItems: Number.isFinite(Number(maxItems)) && Number(maxItems) > 0 ? Number(maxItems) : undefined,
45
+ maxChars: Number.isFinite(Number(maxChars)) && Number(maxChars) > 0 ? Number(maxChars) : undefined,
46
+ namespaces: Array.isArray(namespaces) ? namespaces : [],
47
+ });
48
+
49
+ const itemCount = Array.isArray(pack.items) ? pack.items.length : 0;
50
+ const distributions = agentNames.map((agent) => {
51
+ const provenance = recordProvenance({
52
+ type: 'context_pack_distributed',
53
+ packId: pack.packId,
54
+ agent,
55
+ query: pack.query,
56
+ itemCount,
57
+ expiresAt,
58
+ });
59
+ return {
60
+ agent,
61
+ packId: pack.packId,
62
+ provenanceId: provenance.id,
63
+ expiresAt,
64
+ };
65
+ });
66
+
67
+ return {
68
+ packId: pack.packId,
69
+ query: pack.query,
70
+ totalAgents: distributions.length,
71
+ itemCount,
72
+ expiresAt,
73
+ distributions,
74
+ };
75
+ }
76
+
77
+ module.exports = {
78
+ distributeContextToAgents,
79
+ DEFAULT_TTL_MS,
80
+ MAX_AGENTS,
81
+ };
@@ -0,0 +1,179 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * token-savings.js — estimate how much money ThumbGate's prevention
5
+ * rules saved you in LLM tokens.
6
+ *
7
+ * Why this exists:
8
+ * The mission of ThumbGate is "stop paying for the same AI mistake
9
+ * twice." Every time a Pre-Action Gate blocks a known-bad tool call,
10
+ * the agent does NOT make a round-trip to the model. That's:
11
+ *
12
+ * - input tokens you didn't spend (system prompt + tool defs +
13
+ * conversation history that would have been re-sent)
14
+ * - output tokens you didn't spend (the model's failed response
15
+ * and any retry loop it would have triggered)
16
+ *
17
+ * A single blocked call typically saves 1.5k–3k input tokens and
18
+ * 400–800 output tokens, depending on context size. We surface a
19
+ * conservative estimate on the dashboard as a live counter so the
20
+ * user can see exactly what their gates are worth.
21
+ *
22
+ * Defaults are intentionally conservative — the goal is "you almost
23
+ * certainly saved at least this much," not "let's flatter ourselves."
24
+ *
25
+ * Pricing snapshot (USD per 1M tokens, retrieved 2026-04-15):
26
+ * Sonnet 4.5: $3 input, $15 output
27
+ * Opus 4.6: $15 input, $75 output
28
+ * Haiku 4.5: $0.80 input, $4 output
29
+ * GPT-4o: $2.50 input, $10 output
30
+ *
31
+ * If the caller doesn't pass a modelMix, we assume a Sonnet-heavy
32
+ * blend (80% Sonnet, 15% Opus, 5% Haiku) because that matches the
33
+ * reality of most coding-agent users in 2026.
34
+ */
35
+
36
+ const DEFAULT_MODEL_PRICES = Object.freeze({
37
+ // USD per 1M tokens
38
+ 'claude-sonnet-4-5': { input: 3.0, output: 15.0 },
39
+ 'claude-opus-4-6': { input: 15.0, output: 75.0 },
40
+ 'claude-haiku-4-5': { input: 0.80, output: 4.0 },
41
+ 'gpt-4o': { input: 2.50, output: 10.0 },
42
+ });
43
+
44
+ const DEFAULT_MODEL_MIX = Object.freeze({
45
+ 'claude-sonnet-4-5': 0.80,
46
+ 'claude-opus-4-6': 0.15,
47
+ 'claude-haiku-4-5': 0.05,
48
+ });
49
+
50
+ // Average tokens a blocked tool call would have consumed if it had
51
+ // reached the model and been retried once. Conservative.
52
+ const DEFAULT_AVG_INPUT_TOKENS_PER_BLOCK = 2000;
53
+ const DEFAULT_AVG_OUTPUT_TOKENS_PER_BLOCK = 600;
54
+
55
+ function clampNumber(value, fallback = 0) {
56
+ const n = Number(value);
57
+ if (!Number.isFinite(n) || n < 0) return fallback;
58
+ return n;
59
+ }
60
+
61
+ function blendedPricePer1M(modelMix, modelPrices) {
62
+ let input = 0;
63
+ let output = 0;
64
+ let totalWeight = 0;
65
+ for (const [model, weight] of Object.entries(modelMix)) {
66
+ const w = clampNumber(weight, 0);
67
+ if (w <= 0) continue;
68
+ const price = modelPrices[model];
69
+ if (!price) continue;
70
+ input += clampNumber(price.input, 0) * w;
71
+ output += clampNumber(price.output, 0) * w;
72
+ totalWeight += w;
73
+ }
74
+ if (totalWeight <= 0) {
75
+ return { input: 0, output: 0 };
76
+ }
77
+ return {
78
+ input: input / totalWeight,
79
+ output: output / totalWeight,
80
+ };
81
+ }
82
+
83
+ /**
84
+ * @typedef {Object} TokenSavingsInput
85
+ * @property {number} [blockedCalls=0] gate-blocked tool calls
86
+ * @property {number} [deflectedBots=0] bot checkout deflections (PR #869)
87
+ * @property {number} [avgInputTokensPerBlock]
88
+ * @property {number} [avgOutputTokensPerBlock]
89
+ * @property {Record<string, number>} [modelMix] weighted model mix
90
+ * @property {Record<string, {input:number,output:number}>} [modelPrices]
91
+ *
92
+ * @typedef {Object} TokenSavingsResult
93
+ * @property {number} blockedCalls
94
+ * @property {number} deflectedBots
95
+ * @property {number} tokensSavedInput
96
+ * @property {number} tokensSavedOutput
97
+ * @property {number} tokensSavedTotal
98
+ * @property {number} dollarsSaved
99
+ * @property {string} dollarsSavedDisplay e.g. "$0.47"
100
+ * @property {string} tokensSavedDisplay e.g. "127K"
101
+ * @property {{input:number, output:number}} blendedPricePer1M
102
+ * @property {Record<string,number>} modelMix
103
+ */
104
+
105
+ /**
106
+ * @param {TokenSavingsInput} input
107
+ * @returns {TokenSavingsResult}
108
+ */
109
+ function computeTokenSavings(input = {}) {
110
+ const blockedCalls = clampNumber(input.blockedCalls, 0);
111
+ const deflectedBots = clampNumber(input.deflectedBots, 0);
112
+ const totalEvents = blockedCalls + deflectedBots;
113
+
114
+ const avgInput = clampNumber(
115
+ input.avgInputTokensPerBlock,
116
+ DEFAULT_AVG_INPUT_TOKENS_PER_BLOCK,
117
+ );
118
+ const avgOutput = clampNumber(
119
+ input.avgOutputTokensPerBlock,
120
+ DEFAULT_AVG_OUTPUT_TOKENS_PER_BLOCK,
121
+ );
122
+
123
+ const modelMix = input.modelMix && Object.keys(input.modelMix).length
124
+ ? input.modelMix
125
+ : DEFAULT_MODEL_MIX;
126
+ const modelPrices = input.modelPrices || DEFAULT_MODEL_PRICES;
127
+ const blended = blendedPricePer1M(modelMix, modelPrices);
128
+
129
+ const tokensSavedInput = totalEvents * avgInput;
130
+ const tokensSavedOutput = totalEvents * avgOutput;
131
+ const tokensSavedTotal = tokensSavedInput + tokensSavedOutput;
132
+
133
+ const dollarsSaved = (tokensSavedInput * blended.input
134
+ + tokensSavedOutput * blended.output) / 1_000_000;
135
+
136
+ return {
137
+ blockedCalls,
138
+ deflectedBots,
139
+ tokensSavedInput,
140
+ tokensSavedOutput,
141
+ tokensSavedTotal,
142
+ dollarsSaved,
143
+ dollarsSavedDisplay: formatDollars(dollarsSaved),
144
+ tokensSavedDisplay: formatTokens(tokensSavedTotal),
145
+ blendedPricePer1M: blended,
146
+ modelMix: { ...modelMix },
147
+ };
148
+ }
149
+
150
+ function formatDollars(amount) {
151
+ const n = Number(amount);
152
+ if (!Number.isFinite(n)) return '$0.00';
153
+ if (n >= 1000) return `$${Math.round(n).toLocaleString('en-US')}`;
154
+ if (n >= 100) return `$${n.toFixed(0)}`;
155
+ if (n >= 10) return `$${n.toFixed(1)}`;
156
+ if (n >= 0.01) return `$${n.toFixed(2)}`;
157
+ if (n > 0) return `$${n.toFixed(4)}`;
158
+ return '$0.00';
159
+ }
160
+
161
+ function formatTokens(count) {
162
+ const n = Number(count);
163
+ if (!Number.isFinite(n) || n <= 0) return '0';
164
+ if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)}B`;
165
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
166
+ if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`;
167
+ return String(Math.round(n));
168
+ }
169
+
170
+ module.exports = {
171
+ computeTokenSavings,
172
+ formatDollars,
173
+ formatTokens,
174
+ blendedPricePer1M,
175
+ DEFAULT_MODEL_PRICES,
176
+ DEFAULT_MODEL_MIX,
177
+ DEFAULT_AVG_INPUT_TOKENS_PER_BLOCK,
178
+ DEFAULT_AVG_OUTPUT_TOKENS_PER_BLOCK,
179
+ };