troxy-cli 1.29.42 → 1.29.44
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "troxy-cli",
|
|
3
|
-
"version": "1.29.
|
|
3
|
+
"version": "1.29.44",
|
|
4
4
|
"description": "A secure control layer for AI agents: policies across payments, messages, logins, destructive actions, model usage, and secrets, all enforceable from the CLI",
|
|
5
5
|
"homepage": "https://troxy.io",
|
|
6
6
|
"bugs": {
|
|
@@ -24,7 +24,7 @@ async function guardBash($, e, next) {
|
|
|
24
24
|
body: JSON.stringify({ domain: mapped.domain, fields: mapped.fields }),
|
|
25
25
|
signal: controller.signal,
|
|
26
26
|
});
|
|
27
|
-
result = res.ok ?
|
|
27
|
+
result = res.ok ? JSON.parse(res.text) : { decision: 'ESCALATE', reason: 'policy service returned an error' };
|
|
28
28
|
} finally {
|
|
29
29
|
clearTimeout(timer);
|
|
30
30
|
}
|
|
@@ -121,6 +121,7 @@ const PRICE = { haiku: 1, sonnet: 2, opus: 4 }; // list $/MTok in; output keeps
|
|
|
121
121
|
const WINDOW_LABEL = { five_hour: '5h', seven_day: '7d' };
|
|
122
122
|
// Drawn in Troxy's teal: the status line can't be colored and shows as a yellow warning.
|
|
123
123
|
const LINE = { plugin: 'troxy-guard', key: 'line' };
|
|
124
|
+
const SAVINGS = { plugin: 'troxy-guard', key: 'savings' }; // the band's compression + trimming text
|
|
124
125
|
const SAVED = { plugin: 'troxy-guard', key: 'saved' }; // { paid, full, meter }; survives a troxy update
|
|
125
126
|
const TROXY_TEAL = '#0a9e88';
|
|
126
127
|
|
|
@@ -157,14 +158,49 @@ function sessionLine(t) {
|
|
|
157
158
|
Object.entries(t.meter).map(([kind, percentUsed]) => ({ kind, percentUsed })));
|
|
158
159
|
}
|
|
159
160
|
|
|
161
|
+
// Tokens Troxy kept out of the requests of this session (compression + trimming), counted
|
|
162
|
+
// by the server. undefined until something was saved.
|
|
163
|
+
function savingsText(s) {
|
|
164
|
+
const c = (s && s.compressed_tokens) || 0, t = (s && s.trimmed_tokens) || 0;
|
|
165
|
+
if (!(c + t > 0)) return undefined;
|
|
166
|
+
const k = n => n >= 1000 ? `${Math.round(n / 1000)}k` : String(n);
|
|
167
|
+
return `${k(c + t)} tokens saved by compression and trimming`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// This session's savings from the server, or null when there is no key, no session id or
|
|
171
|
+
// the call fails (the band then keeps what it showed).
|
|
172
|
+
async function fetchSavings($) {
|
|
173
|
+
const sid = await $.session.id();
|
|
174
|
+
if (!/^[A-Za-z0-9-]{8,64}$/.test(String(sid))) return null;
|
|
175
|
+
let key = await $.env.get('TROXY_API_KEY');
|
|
176
|
+
if (!key) {
|
|
177
|
+
const home = await $.env.get('HOME');
|
|
178
|
+
if (!home) return null;
|
|
179
|
+
key = JSON.parse(await $.fs.read(`${home}/.troxy/config.json`)).apiKey;
|
|
180
|
+
}
|
|
181
|
+
if (!key) return null;
|
|
182
|
+
const controller = new AbortController();
|
|
183
|
+
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
|
184
|
+
try {
|
|
185
|
+
const res = await $.http.fetch(`https://api.troxy.io/mcp/session-savings?session_id=${sid}`, {
|
|
186
|
+
headers: { 'x-troxy-key': key }, signal: controller.signal,
|
|
187
|
+
});
|
|
188
|
+
return res.ok ? JSON.parse(res.text) : null;
|
|
189
|
+
} finally {
|
|
190
|
+
clearTimeout(timer);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
160
194
|
// /troxy status: everything about this session's Troxy in one place.
|
|
161
|
-
function statusText({ version, paused, totals, rateLimits = [] }) {
|
|
195
|
+
function statusText({ version, paused, totals, rateLimits = [], compactOff = false }) {
|
|
162
196
|
const lines = [`Troxy ${version || ''}`.trim() + ` · routing is ${paused ? 'off' : 'on'}`];
|
|
163
197
|
lines.push(sessionLine(totals) ? `This session: ${sessionLine(totals).replace(/^.*?This session: /, '')}`
|
|
164
198
|
: 'This session: nothing moved to a cheaper model yet');
|
|
165
199
|
const meter = rateLimits.filter(w => WINDOW_LABEL[w.kind]).map(w => `${WINDOW_LABEL[w.kind]} ${Math.round(w.percentUsed)}%`);
|
|
166
200
|
if (meter.length) lines.push(`Plan usage now: ${meter.join(' · ')}`);
|
|
167
201
|
lines.push(`Prompts with over ${ROUTE_CONTEXT_FLOOR_TOKENS / 1000}k tokens of context stay on your model (switching would rewrite the cache).`);
|
|
202
|
+
lines.push(compactOff ? 'Idle compaction is off (/troxy compact on).'
|
|
203
|
+
: `Idle compaction is on: sessions over ${COMPACT_MIN_TOKENS / 1000}k tokens are compacted after ${COMPACT_IDLE_MS / 60000} min idle (/troxy compact off).`);
|
|
168
204
|
lines.push('All sessions: dash.troxy.io');
|
|
169
205
|
lines.push(paused ? 'Type /troxy on to resume.' : 'Type /troxy off to pause routing.');
|
|
170
206
|
return lines.join('\n');
|
|
@@ -190,6 +226,50 @@ function addTurn(totals, start, end, ratio) {
|
|
|
190
226
|
return { paid: t.paid + paid, full: t.full + paid * ratio, meter };
|
|
191
227
|
}
|
|
192
228
|
|
|
229
|
+
// Idle compaction: shrink a big session just before its prompt cache expires (1h), so
|
|
230
|
+
// the summary reads the conversation from cache (~0.1x) and the user comes back to a
|
|
231
|
+
// small session instead of re-sending the whole thing. Never while the plan meter is
|
|
232
|
+
// nearly full: then it waits for the window to reset and compacts on a fresh bar.
|
|
233
|
+
const COMPACT_IDLE_MS = 55 * 60 * 1000;
|
|
234
|
+
const COMPACT_MIN_TOKENS = 100000;
|
|
235
|
+
const COMPACT_LIMIT_PCT = 95;
|
|
236
|
+
const COMPACT_AFTER_RESET_MS = 60 * 1000;
|
|
237
|
+
const COMPACT_INSTRUCTIONS = 'Keep the current task, the files involved, decisions made, and open to-dos.';
|
|
238
|
+
const COMPACTED = { plugin: 'troxy-guard', key: 'compacted' }; // the band's note until the next prompt
|
|
239
|
+
let compactTimer = null;
|
|
240
|
+
|
|
241
|
+
// What to do when the idle timer fires: skip, compact now, or wait for the meter to reset.
|
|
242
|
+
function compactPlan({ tokens, rateLimits = [], now, off }) {
|
|
243
|
+
if (off || !(tokens >= COMPACT_MIN_TOKENS)) return { action: 'skip' };
|
|
244
|
+
const full = rateLimits.filter(w => WINDOW_LABEL[w.kind] && w.percentUsed >= COMPACT_LIMIT_PCT);
|
|
245
|
+
if (!full.length) return { action: 'compact' };
|
|
246
|
+
const resets = full.map(w => Date.parse(w.resetsAt)).filter(Number.isFinite);
|
|
247
|
+
if (resets.length < full.length) return { action: 'skip' }; // unknown reset: never spend a nearly full bar
|
|
248
|
+
return { action: 'wait', inMs: Math.max(0, Math.max(...resets) - now) + COMPACT_AFTER_RESET_MS };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function cancelCompact() {
|
|
252
|
+
if (compactTimer) compactTimer.cancel();
|
|
253
|
+
compactTimer = null;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function armCompact($, ms) {
|
|
257
|
+
cancelCompact();
|
|
258
|
+
compactTimer = $.clock.after(ms, () => { compactTimer = null; idleCompact($).catch(() => {}); });
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function idleCompact($) {
|
|
262
|
+
const u = await $.session.usage();
|
|
263
|
+
const plan = compactPlan({ tokens: u.context && u.context.tokens, rateLimits: u.rateLimits, now: await $.clock.now(), off: await $.store.get('compactOff') });
|
|
264
|
+
if (plan.action === 'wait') return armCompact($, plan.inMs);
|
|
265
|
+
if (plan.action !== 'compact') return;
|
|
266
|
+
const r = await $.session.compact({ instructions: COMPACT_INSTRUCTIONS });
|
|
267
|
+
if (r.skip) return;
|
|
268
|
+
const k = n => `${Math.round(n / 1000)}k`;
|
|
269
|
+
await $.state.set(COMPACTED, r.tokensBefore && r.tokensAfter
|
|
270
|
+
? `compacted this idle session ${k(r.tokensBefore)} → ${k(r.tokensAfter)} tokens` : 'compacted this idle session');
|
|
271
|
+
}
|
|
272
|
+
|
|
193
273
|
// Where the local daemon looks for this session's routing note (see interceptor.js).
|
|
194
274
|
// Only when installed under ~/.claude/skills, so a --plugin-dir test run never writes
|
|
195
275
|
// into a real user's folder.
|
|
@@ -208,6 +288,8 @@ let decision = null;
|
|
|
208
288
|
|
|
209
289
|
async function scorePrompt($, e, next) {
|
|
210
290
|
decision = null;
|
|
291
|
+
cancelCompact();
|
|
292
|
+
if ((await $.state.get(COMPACTED)).value) await $.state.set(COMPACTED, null);
|
|
211
293
|
turn = { start: await snapshot($), ratio: 1 };
|
|
212
294
|
const sid = await $.session.id();
|
|
213
295
|
const file = modRouteFile($.plugin.root, sid);
|
|
@@ -254,15 +336,21 @@ export function register(on) {
|
|
|
254
336
|
const file = modRouteFile($.plugin.root, sid);
|
|
255
337
|
if (file) { lastNote = `${sid}:{}`; await $.fs.write(file, '{}'); }
|
|
256
338
|
$.ui.status(undefined); // the bar moved to the band above the prompt
|
|
339
|
+
armCompact($, COMPACT_IDLE_MS); // a hot reload cancels the old timer
|
|
257
340
|
return next(e);
|
|
258
341
|
});
|
|
259
342
|
on('command.run', { command: 'troxy' }, async ($, e) => {
|
|
343
|
+
const c = /^\s*compact\s+(on|off)\s*$/i.exec(String(e.args || ''));
|
|
344
|
+
if (c) {
|
|
345
|
+
await $.store.set('compactOff', c[1].toLowerCase() === 'off');
|
|
346
|
+
return { text: c[1].toLowerCase() === 'off' ? 'Troxy idle compaction is off.' : 'Troxy idle compaction is on.' };
|
|
347
|
+
}
|
|
260
348
|
const r = troxyCommand(e.args, Boolean(await $.store.get('paused')));
|
|
261
349
|
await $.store.set('paused', r.paused);
|
|
262
350
|
if (/^\s*(on|off)\s*$/i.test(String(e.args || ''))) return { text: r.text };
|
|
263
351
|
const manifest = JSON.parse(await $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`).catch(() => '{}'));
|
|
264
352
|
const { value: totals } = await $.state.get(SAVED);
|
|
265
|
-
return { text: statusText({ version: manifest.version, paused: r.paused, totals, rateLimits: (await $.session.usage()).rateLimits }) };
|
|
353
|
+
return { text: statusText({ version: manifest.version, paused: r.paused, totals, rateLimits: (await $.session.usage()).rateLimits, compactOff: Boolean(await $.store.get('compactOff')) }) };
|
|
266
354
|
});
|
|
267
355
|
// Routing fails open: a scorer or hook error must never break the session,
|
|
268
356
|
// it just means the requested model is used as-is.
|
|
@@ -270,9 +358,11 @@ export function register(on) {
|
|
|
270
358
|
on('turn.step', routeStep).catch(async function* ($, e, next) { return yield* next(e); });
|
|
271
359
|
on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
|
|
272
360
|
const { value: line } = await $.state.get(LINE); // subscribes the band to the line
|
|
273
|
-
|
|
361
|
+
const { value: note } = await $.state.get(COMPACTED);
|
|
362
|
+
const { value: kept } = await $.state.get(SAVINGS);
|
|
363
|
+
if ((!line && !kept && !note) || e.props.hasSurvey) return next(e);
|
|
274
364
|
const { Text } = $.ui.resolve(e);
|
|
275
|
-
return h(Text, { color: TROXY_TEAL }, `Troxy ${line}`);
|
|
365
|
+
return h(Text, { color: TROXY_TEAL }, `Troxy ${[line, kept, note].filter(Boolean).join(' · ')}`);
|
|
276
366
|
});
|
|
277
367
|
// After the answer, so counting never delays a prompt.
|
|
278
368
|
on('turn.complete', async ($, e, next) => {
|
|
@@ -288,9 +378,14 @@ export function register(on) {
|
|
|
288
378
|
if (line) await $.state.set(LINE, line);
|
|
289
379
|
} catch {} // a missed turn only leaves the band as it was
|
|
290
380
|
}
|
|
381
|
+
try {
|
|
382
|
+
const text = savingsText(await fetchSavings($));
|
|
383
|
+
if (text) await $.state.set(SAVINGS, text);
|
|
384
|
+
} catch {} // a missed fetch only leaves the band as it was
|
|
385
|
+
armCompact($, COMPACT_IDLE_MS);
|
|
291
386
|
return r;
|
|
292
387
|
});
|
|
293
388
|
}
|
|
294
389
|
|
|
295
390
|
// Exported for tests only - not part of the mod's runtime contract.
|
|
296
|
-
export const _internal = { toDomainAndFields, guardBash, failClosed, parseScore, pickModel, troxyCommand, modRouteFile, estimateTokens, MODELS, savedLine, sessionLine, addTurn, statusText };
|
|
391
|
+
export const _internal = { toDomainAndFields, guardBash, failClosed, parseScore, pickModel, troxyCommand, modRouteFile, estimateTokens, MODELS, savedLine, sessionLine, addTurn, statusText, compactPlan, savingsText, fetchSavings };
|
|
@@ -3,6 +3,6 @@ export type SessionSaved = { paid: number; full: number; meter: Record<string, n
|
|
|
3
3
|
|
|
4
4
|
declare module 'claude-code' {
|
|
5
5
|
interface PluginState {
|
|
6
|
-
'troxy-guard': { line: BarLine; saved: SessionSaved }
|
|
6
|
+
'troxy-guard': { line: BarLine; saved: SessionSaved; compacted: BarLine; savings: BarLine }
|
|
7
7
|
}
|
|
8
8
|
}
|
|
@@ -99,7 +99,46 @@ test('statusText: version, routing, this session, plan usage, where the account
|
|
|
99
99
|
+ 'This session: your bar lasts 4.0x longer · $0.09 saved of $0.12 at list price · estimate\n'
|
|
100
100
|
+ 'Plan usage now: 5h 23% · 7d 77%\n'
|
|
101
101
|
+ 'Prompts with over 50k tokens of context stay on your model (switching would rewrite the cache).\n'
|
|
102
|
+
+ 'Idle compaction is on: sessions over 100k tokens are compacted after 55 min idle (/troxy compact off).\n'
|
|
102
103
|
+ 'All sessions: dash.troxy.io\n'
|
|
103
104
|
+ 'Type /troxy off to pause routing.');
|
|
104
105
|
assert.match(statusText({ paused: true }), /^Troxy · routing is off\nThis session: nothing moved to a cheaper model yet\n/);
|
|
105
106
|
});
|
|
107
|
+
|
|
108
|
+
test('compactPlan: big idle sessions compact, small ones and a nearly full bar do not', () => {
|
|
109
|
+
const { compactPlan } = _internal;
|
|
110
|
+
const now = Date.parse('2026-10-08T15:00:00Z');
|
|
111
|
+
assert.deepEqual(compactPlan({ tokens: 210000, rateLimits: [{ kind: 'five_hour', percentUsed: 40 }], now }), { action: 'compact' });
|
|
112
|
+
assert.deepEqual(compactPlan({ tokens: 40000, now }), { action: 'skip' });
|
|
113
|
+
assert.deepEqual(compactPlan({ tokens: 210000, now, off: true }), { action: 'skip' });
|
|
114
|
+
// 5h at 95%, resets in 2h; 7d at 95%, resets in 3h: wait for the later one, plus a minute
|
|
115
|
+
assert.deepEqual(compactPlan({ tokens: 210000, now, rateLimits: [
|
|
116
|
+
{ kind: 'five_hour', percentUsed: 95, resetsAt: '2026-10-08T17:00:00Z' },
|
|
117
|
+
{ kind: 'seven_day', percentUsed: 95, resetsAt: '2026-10-08T18:00:00Z' }] }), { action: 'wait', inMs: 3 * 3600000 + 60000 });
|
|
118
|
+
// nearly full with no known reset: never spend it
|
|
119
|
+
assert.deepEqual(compactPlan({ tokens: 210000, now, rateLimits: [{ kind: 'seven_day', percentUsed: 99 }] }), { action: 'skip' });
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('savingsText: shows compression + trimming together, nothing when nothing was saved', () => {
|
|
123
|
+
const { savingsText } = _internal;
|
|
124
|
+
assert.equal(savingsText({ compressed_tokens: 5200, trimmed_tokens: 40100 }), '45k tokens saved by compression and trimming');
|
|
125
|
+
assert.equal(savingsText({ compressed_tokens: 300, trimmed_tokens: 0 }), '300 tokens saved by compression and trimming');
|
|
126
|
+
assert.equal(savingsText({ compressed_tokens: 0, trimmed_tokens: 0 }), undefined);
|
|
127
|
+
assert.equal(savingsText(null), undefined);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('fetchSavings: sends the saved key and the session id; null without a key or on an error', async () => {
|
|
131
|
+
const { fetchSavings } = _internal;
|
|
132
|
+
const calls = [];
|
|
133
|
+
const mk = ({ env = {}, config, res = { ok: true, text: '{"compressed_tokens":1}' } } = {}) => ({
|
|
134
|
+
session: { id: async () => 'abc-12345678' },
|
|
135
|
+
env: { get: async n => env[n] },
|
|
136
|
+
fs: { read: async p => { if (!config) throw new Error('missing'); calls.push(p); return JSON.stringify(config); } },
|
|
137
|
+
http: { fetch: async (url, init) => { calls.push([url, init.headers]); return res; } },
|
|
138
|
+
});
|
|
139
|
+
assert.deepEqual(await fetchSavings(mk({ env: { HOME: '/h' }, config: { apiKey: 'txy-1' } })), { compressed_tokens: 1 });
|
|
140
|
+
assert.deepEqual(calls, ['/h/.troxy/config.json', ['https://api.troxy.io/mcp/session-savings?session_id=abc-12345678', { 'x-troxy-key': 'txy-1' }]]);
|
|
141
|
+
assert.equal(await fetchSavings(mk({ env: { TROXY_API_KEY: 'txy-2' }, res: { ok: false } })), null);
|
|
142
|
+
assert.equal(await fetchSavings(mk({ env: { HOME: '/h' }, config: {} })), null);
|
|
143
|
+
assert.equal(await fetchSavings({ ...mk(), session: { id: async () => 'x' } }), null);
|
|
144
|
+
});
|