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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.well-known/mcp/server-card.json +1 -1
- package/adapters/README.md +1 -1
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/mcp/server-stdio.js +1 -1
- package/adapters/opencode/opencode.json +1 -1
- package/package.json +9 -3
- package/public/index.html +2 -2
- package/scripts/context-engine.js +710 -0
- package/scripts/durability/step.js +171 -0
- package/scripts/gates-engine.js +81 -2
- package/scripts/hf-papers.js +317 -0
- package/scripts/mcp-config.js +3 -3
- package/scripts/session-report.js +120 -0
- package/scripts/swarm-coordinator.js +81 -0
- package/scripts/token-savings.js +179 -0
|
@@ -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
|
+
};
|