troxy-cli 1.29.42 → 1.29.43

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.43",
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": {
@@ -158,13 +158,15 @@ function sessionLine(t) {
158
158
  }
159
159
 
160
160
  // /troxy status: everything about this session's Troxy in one place.
161
- function statusText({ version, paused, totals, rateLimits = [] }) {
161
+ function statusText({ version, paused, totals, rateLimits = [], compactOff = false }) {
162
162
  const lines = [`Troxy ${version || ''}`.trim() + ` · routing is ${paused ? 'off' : 'on'}`];
163
163
  lines.push(sessionLine(totals) ? `This session: ${sessionLine(totals).replace(/^.*?This session: /, '')}`
164
164
  : 'This session: nothing moved to a cheaper model yet');
165
165
  const meter = rateLimits.filter(w => WINDOW_LABEL[w.kind]).map(w => `${WINDOW_LABEL[w.kind]} ${Math.round(w.percentUsed)}%`);
166
166
  if (meter.length) lines.push(`Plan usage now: ${meter.join(' · ')}`);
167
167
  lines.push(`Prompts with over ${ROUTE_CONTEXT_FLOOR_TOKENS / 1000}k tokens of context stay on your model (switching would rewrite the cache).`);
168
+ lines.push(compactOff ? 'Idle compaction is off (/troxy compact on).'
169
+ : `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
170
  lines.push('All sessions: dash.troxy.io');
169
171
  lines.push(paused ? 'Type /troxy on to resume.' : 'Type /troxy off to pause routing.');
170
172
  return lines.join('\n');
@@ -190,6 +192,50 @@ function addTurn(totals, start, end, ratio) {
190
192
  return { paid: t.paid + paid, full: t.full + paid * ratio, meter };
191
193
  }
192
194
 
195
+ // Idle compaction: shrink a big session just before its prompt cache expires (1h), so
196
+ // the summary reads the conversation from cache (~0.1x) and the user comes back to a
197
+ // small session instead of re-sending the whole thing. Never while the plan meter is
198
+ // nearly full: then it waits for the window to reset and compacts on a fresh bar.
199
+ const COMPACT_IDLE_MS = 55 * 60 * 1000;
200
+ const COMPACT_MIN_TOKENS = 100000;
201
+ const COMPACT_LIMIT_PCT = 95;
202
+ const COMPACT_AFTER_RESET_MS = 60 * 1000;
203
+ const COMPACT_INSTRUCTIONS = 'Keep the current task, the files involved, decisions made, and open to-dos.';
204
+ const COMPACTED = { plugin: 'troxy-guard', key: 'compacted' }; // the band's note until the next prompt
205
+ let compactTimer = null;
206
+
207
+ // What to do when the idle timer fires: skip, compact now, or wait for the meter to reset.
208
+ function compactPlan({ tokens, rateLimits = [], now, off }) {
209
+ if (off || !(tokens >= COMPACT_MIN_TOKENS)) return { action: 'skip' };
210
+ const full = rateLimits.filter(w => WINDOW_LABEL[w.kind] && w.percentUsed >= COMPACT_LIMIT_PCT);
211
+ if (!full.length) return { action: 'compact' };
212
+ const resets = full.map(w => Date.parse(w.resetsAt)).filter(Number.isFinite);
213
+ if (resets.length < full.length) return { action: 'skip' }; // unknown reset: never spend a nearly full bar
214
+ return { action: 'wait', inMs: Math.max(0, Math.max(...resets) - now) + COMPACT_AFTER_RESET_MS };
215
+ }
216
+
217
+ function cancelCompact() {
218
+ if (compactTimer) compactTimer.cancel();
219
+ compactTimer = null;
220
+ }
221
+
222
+ function armCompact($, ms) {
223
+ cancelCompact();
224
+ compactTimer = $.clock.after(ms, () => { compactTimer = null; idleCompact($).catch(() => {}); });
225
+ }
226
+
227
+ async function idleCompact($) {
228
+ const u = await $.session.usage();
229
+ const plan = compactPlan({ tokens: u.context && u.context.tokens, rateLimits: u.rateLimits, now: await $.clock.now(), off: await $.store.get('compactOff') });
230
+ if (plan.action === 'wait') return armCompact($, plan.inMs);
231
+ if (plan.action !== 'compact') return;
232
+ const r = await $.session.compact({ instructions: COMPACT_INSTRUCTIONS });
233
+ if (r.skip) return;
234
+ const k = n => `${Math.round(n / 1000)}k`;
235
+ await $.state.set(COMPACTED, r.tokensBefore && r.tokensAfter
236
+ ? `compacted this idle session ${k(r.tokensBefore)} → ${k(r.tokensAfter)} tokens` : 'compacted this idle session');
237
+ }
238
+
193
239
  // Where the local daemon looks for this session's routing note (see interceptor.js).
194
240
  // Only when installed under ~/.claude/skills, so a --plugin-dir test run never writes
195
241
  // into a real user's folder.
@@ -208,6 +254,8 @@ let decision = null;
208
254
 
209
255
  async function scorePrompt($, e, next) {
210
256
  decision = null;
257
+ cancelCompact();
258
+ if ((await $.state.get(COMPACTED)).value) await $.state.set(COMPACTED, null);
211
259
  turn = { start: await snapshot($), ratio: 1 };
212
260
  const sid = await $.session.id();
213
261
  const file = modRouteFile($.plugin.root, sid);
@@ -254,15 +302,21 @@ export function register(on) {
254
302
  const file = modRouteFile($.plugin.root, sid);
255
303
  if (file) { lastNote = `${sid}:{}`; await $.fs.write(file, '{}'); }
256
304
  $.ui.status(undefined); // the bar moved to the band above the prompt
305
+ armCompact($, COMPACT_IDLE_MS); // a hot reload cancels the old timer
257
306
  return next(e);
258
307
  });
259
308
  on('command.run', { command: 'troxy' }, async ($, e) => {
309
+ const c = /^\s*compact\s+(on|off)\s*$/i.exec(String(e.args || ''));
310
+ if (c) {
311
+ await $.store.set('compactOff', c[1].toLowerCase() === 'off');
312
+ return { text: c[1].toLowerCase() === 'off' ? 'Troxy idle compaction is off.' : 'Troxy idle compaction is on.' };
313
+ }
260
314
  const r = troxyCommand(e.args, Boolean(await $.store.get('paused')));
261
315
  await $.store.set('paused', r.paused);
262
316
  if (/^\s*(on|off)\s*$/i.test(String(e.args || ''))) return { text: r.text };
263
317
  const manifest = JSON.parse(await $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`).catch(() => '{}'));
264
318
  const { value: totals } = await $.state.get(SAVED);
265
- return { text: statusText({ version: manifest.version, paused: r.paused, totals, rateLimits: (await $.session.usage()).rateLimits }) };
319
+ return { text: statusText({ version: manifest.version, paused: r.paused, totals, rateLimits: (await $.session.usage()).rateLimits, compactOff: Boolean(await $.store.get('compactOff')) }) };
266
320
  });
267
321
  // Routing fails open: a scorer or hook error must never break the session,
268
322
  // it just means the requested model is used as-is.
@@ -270,9 +324,10 @@ export function register(on) {
270
324
  on('turn.step', routeStep).catch(async function* ($, e, next) { return yield* next(e); });
271
325
  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
272
326
  const { value: line } = await $.state.get(LINE); // subscribes the band to the line
273
- if (!line || e.props.hasSurvey) return next(e);
327
+ const { value: note } = await $.state.get(COMPACTED);
328
+ if ((!line && !note) || e.props.hasSurvey) return next(e);
274
329
  const { Text } = $.ui.resolve(e);
275
- return h(Text, { color: TROXY_TEAL }, `Troxy ${line}`);
330
+ return h(Text, { color: TROXY_TEAL }, `Troxy ${[line, note].filter(Boolean).join(' · ')}`);
276
331
  });
277
332
  // After the answer, so counting never delays a prompt.
278
333
  on('turn.complete', async ($, e, next) => {
@@ -288,9 +343,10 @@ export function register(on) {
288
343
  if (line) await $.state.set(LINE, line);
289
344
  } catch {} // a missed turn only leaves the band as it was
290
345
  }
346
+ armCompact($, COMPACT_IDLE_MS);
291
347
  return r;
292
348
  });
293
349
  }
294
350
 
295
351
  // 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 };
352
+ export const _internal = { toDomainAndFields, guardBash, failClosed, parseScore, pickModel, troxyCommand, modRouteFile, estimateTokens, MODELS, savedLine, sessionLine, addTurn, statusText, compactPlan };
@@ -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 }
7
7
  }
8
8
  }
@@ -99,7 +99,22 @@ 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
+ });