troxy-cli 1.29.43 → 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.43",
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,6 +158,39 @@ 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
195
  function statusText({ version, paused, totals, rateLimits = [], compactOff = false }) {
162
196
  const lines = [`Troxy ${version || ''}`.trim() + ` · routing is ${paused ? 'off' : 'on'}`];
@@ -325,9 +359,10 @@ export function register(on) {
325
359
  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
326
360
  const { value: line } = await $.state.get(LINE); // subscribes the band to the line
327
361
  const { value: note } = await $.state.get(COMPACTED);
328
- if ((!line && !note) || e.props.hasSurvey) return next(e);
362
+ const { value: kept } = await $.state.get(SAVINGS);
363
+ if ((!line && !kept && !note) || e.props.hasSurvey) return next(e);
329
364
  const { Text } = $.ui.resolve(e);
330
- return h(Text, { color: TROXY_TEAL }, `Troxy ${[line, note].filter(Boolean).join(' · ')}`);
365
+ return h(Text, { color: TROXY_TEAL }, `Troxy ${[line, kept, note].filter(Boolean).join(' · ')}`);
331
366
  });
332
367
  // After the answer, so counting never delays a prompt.
333
368
  on('turn.complete', async ($, e, next) => {
@@ -343,10 +378,14 @@ export function register(on) {
343
378
  if (line) await $.state.set(LINE, line);
344
379
  } catch {} // a missed turn only leaves the band as it was
345
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
346
385
  armCompact($, COMPACT_IDLE_MS);
347
386
  return r;
348
387
  });
349
388
  }
350
389
 
351
390
  // Exported for tests only - not part of the mod's runtime contract.
352
- export const _internal = { toDomainAndFields, guardBash, failClosed, parseScore, pickModel, troxyCommand, modRouteFile, estimateTokens, MODELS, savedLine, sessionLine, addTurn, statusText, compactPlan };
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; compacted: BarLine }
6
+ 'troxy-guard': { line: BarLine; saved: SessionSaved; compacted: BarLine; savings: BarLine }
7
7
  }
8
8
  }
@@ -118,3 +118,27 @@ test('compactPlan: big idle sessions compact, small ones and a nearly full bar d
118
118
  // nearly full with no known reset: never spend it
119
119
  assert.deepEqual(compactPlan({ tokens: 210000, now, rateLimits: [{ kind: 'seven_day', percentUsed: 99 }] }), { action: 'skip' });
120
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
+ });