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.42",
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 ? await res.json() : { decision: 'ESCALATE', reason: 'policy service returned an error' };
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
- if (!line || e.props.hasSurvey) return next(e);
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
+ });