troxy-cli 1.30.22 → 1.30.24

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.30.22",
3
+ "version": "1.30.24",
4
4
  "description": "Make your Claude Code usage go further: lighter requests, less usage, less money",
5
5
  "homepage": "https://troxy.io",
6
6
  "bugs": {
@@ -213,6 +213,16 @@ async function troxyConfig($) {
213
213
  return { ...cfg, apiKey: (await $.env.get('TROXY_API_KEY')) || cfg.apiKey };
214
214
  }
215
215
 
216
+ // Settings switches from dash.troxy.io (routing, compact_after_answer, idle_compact), brought
217
+ // by the savings fetch after every answer and kept on disk. A missing one is on. The local
218
+ // /troxy off and /troxy compact off still work on top, for this machine.
219
+ async function switchedOff($, key) {
220
+ try { const f = await $.store.get('features'); return Boolean(f && f[key] === false); } catch { return false; }
221
+ }
222
+ async function routingOff($) {
223
+ return Boolean(await $.store.get('paused')) || switchedOff($, 'routing');
224
+ }
225
+
216
226
  async function troxyFetch($, url, init = {}) {
217
227
  const controller = new AbortController();
218
228
  const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
@@ -226,7 +236,10 @@ async function fetchSavings($) {
226
236
  const { apiKey } = await troxyConfig($);
227
237
  if (!apiKey) return null;
228
238
  const res = await troxyFetch($, `https://api.troxy.io/mcp/session-savings?session_id=${sid}`, { headers: { 'x-troxy-key': apiKey } });
229
- return res.ok ? JSON.parse(res.text) : null;
239
+ if (!res.ok) return null;
240
+ const body = JSON.parse(res.text);
241
+ if (body && body.features && typeof body.features === 'object') await $.store.set('features', body.features);
242
+ return body;
230
243
  }
231
244
 
232
245
  // Usage reporting. A Claude subscription's login token must never leave the Mac
@@ -305,7 +318,7 @@ async function flushReport($) {
305
318
  }
306
319
 
307
320
  // /troxy status: everything about this session's Troxy in one place.
308
- function statusText({ version, paused, savings, rateLimits = [], compactOff = false }) {
321
+ function statusText({ version, paused, savings, rateLimits = [], compactOff = false, settingsOff = [] }) {
309
322
  const lines = [`Troxy ${version || ''}`.trim() + ` · routing is ${paused ? 'off' : 'on'}`];
310
323
  lines.push(`This session: ${savings || 'nothing moved to a lighter model yet'}`);
311
324
  const meter = rateLimits.filter(w => WINDOW_LABEL[w.kind]).map(w => `${WINDOW_LABEL[w.kind]} ${Math.round(w.percentUsed)}%`);
@@ -313,7 +326,8 @@ function statusText({ version, paused, savings, rateLimits = [], compactOff = fa
313
326
  lines.push('The model is picked at the start, after an hour idle or after a compaction, then kept (switching would rewrite the cache). /model always wins.');
314
327
  lines.push(compactOff ? 'Idle compaction is off (/troxy compact on).'
315
328
  : `Idle compaction is on: sessions over ${COMPACT_MIN_TOKENS / 1000}k tokens are compacted after ${COMPACT_IDLE_MS / 60000} min idle (/troxy compact off).`);
316
- lines.push('All sessions: dash.troxy.io');
329
+ if (settingsOff.length) lines.push(`Turned off in Settings on dash.troxy.io: ${settingsOff.join(', ')}.`);
330
+ lines.push('All sessions and the on/off switches: dash.troxy.io');
317
331
  lines.push(paused ? 'Type /troxy on to resume.' : 'Type /troxy off to pause routing.');
318
332
  return lines.join('\n');
319
333
  }
@@ -360,6 +374,24 @@ const COMPACTED = { plugin: 'troxy-guard', key: 'compacted' }; // the band's not
360
374
  const COMPACTED_SHOW_MS = 10 * 60 * 1000; // kept through prompts for this long, so it isn't missed
361
375
  let compactTimer = null;
362
376
 
377
+ // Big-session compaction: a session past 200k compacts right after an answer, between
378
+ // tasks, while its cache is still warm, instead of re-reading the whole thing on every
379
+ // later call. A/B 2026-11-06 at 350k on Sonnet: 4.0 vs 6.0 bar points for the same 9-step
380
+ // job, the compaction included, same results. The short delay lets the turn settle; a
381
+ // prompt typed first cancels it (scorePrompt), so it never holds the user up.
382
+ // On for everyone since 1.30.24 (Settings: compact_after_answer). TROXY_BIG_COMPACT=1 or 0
383
+ // pins it for an A/B run.
384
+ const BIG_COMPACT_TOKENS = 200000;
385
+ const BIG_COMPACT_DELAY_MS = 3000;
386
+ function afterTurnDelay({ tokens, on, off }) {
387
+ return on && !off && tokens >= BIG_COMPACT_TOKENS ? BIG_COMPACT_DELAY_MS : COMPACT_IDLE_MS;
388
+ }
389
+ async function bigCompactOn($) {
390
+ const pin = await $.env.get('TROXY_BIG_COMPACT');
391
+ if (pin === '1' || pin === '0') return pin === '1';
392
+ return !(await switchedOff($, 'compact_after_answer'));
393
+ }
394
+
363
395
  // What to do when the idle timer fires: skip, compact now, or wait for the meter to reset.
364
396
  function compactPlan({ tokens, rateLimits = [], now, off }) {
365
397
  if (off || !(tokens >= COMPACT_MIN_TOKENS)) return { action: 'skip' };
@@ -381,7 +413,7 @@ function cancelCompact() {
381
413
  // stayAwake: hold off system sleep (the screen still sleeps) until the timer fires, so a
382
414
  // big session compacts on time instead of when the user is back. macOS caffeinate; where
383
415
  // it is missing the spawn fails quietly and compact-on-return in scorePrompt covers it.
384
- function armCompact($, ms, { stayAwake = false, persist = false } = {}) {
416
+ function armCompact($, ms, { stayAwake = false, persist = false, afterAnswer = false } = {}) {
385
417
  cancelCompact();
386
418
  if (stayAwake) keepAwake($, ms + 2 * 60 * 1000);
387
419
  // Persisted so a restart (which loses lastSent and the timer) still finds the idle start.
@@ -391,7 +423,7 @@ function armCompact($, ms, { stayAwake = false, persist = false } = {}) {
391
423
  } catch {}
392
424
  compactTimer = $.clock.after(ms, () => {
393
425
  compactTimer = null;
394
- idleCompact($).catch(() => {}).finally(stopAwake);
426
+ idleCompact($, { afterAnswer }).catch(() => {}).finally(stopAwake);
395
427
  });
396
428
  }
397
429
 
@@ -412,17 +444,18 @@ function stopAwake() {
412
444
  }
413
445
 
414
446
  async function bigSession($) {
415
- if (await $.store.get('compactOff')) return false;
447
+ if ((await $.store.get('compactOff')) || (await switchedOff($, 'idle_compact'))) return false;
416
448
  const u = await $.session.usage();
417
449
  return ((u.context && u.context.tokens) || 0) >= COMPACT_MIN_TOKENS;
418
450
  }
419
451
 
420
452
  // late: the user is back after a long idle (the Mac may have slept through the timer),
421
453
  // so compact now or not at all: never hold their prompt waiting for a meter reset.
422
- async function idleCompact($, { late = false } = {}) {
454
+ async function idleCompact($, { late = false, afterAnswer = false } = {}) {
423
455
  const u = await $.session.usage();
424
- const plan = compactPlan({ tokens: u.context && u.context.tokens, rateLimits: u.rateLimits, now: await $.clock.now(), off: await $.store.get('compactOff') });
425
- if (plan.action === 'wait' && !late) return armCompact($, plan.inMs);
456
+ const off = Boolean(await $.store.get('compactOff')) || await switchedOff($, afterAnswer ? 'compact_after_answer' : 'idle_compact');
457
+ const plan = compactPlan({ tokens: u.context && u.context.tokens, rateLimits: u.rateLimits, now: await $.clock.now(), off });
458
+ if (plan.action === 'wait' && !late) return armCompact($, plan.inMs, { afterAnswer });
426
459
  if (plan.action !== 'compact') return;
427
460
  // Tags the summary request so the proxy logs it as Troxy's compaction (the Activity row).
428
461
  const file = modRouteFile($.plugin.root, await $.session.id());
@@ -434,7 +467,7 @@ async function idleCompact($, { late = false } = {}) {
434
467
  if (r.skip) return;
435
468
  const k = n => `${Math.round(n / 1000)}k`;
436
469
  await $.state.set(COMPACTED, { at: await $.clock.now(), text: r.tokensBefore && r.tokensAfter
437
- ? `compacted this idle session ${k(r.tokensBefore)} → ${k(r.tokensAfter)} tokens` : 'compacted this idle session' });
470
+ ? `compacted this session ${k(r.tokensBefore)} → ${k(r.tokensAfter)} tokens` : 'compacted this session' });
438
471
  }
439
472
 
440
473
  // Where the local daemon looks for this session's routing note (see interceptor.js).
@@ -474,7 +507,7 @@ async function scorePrompt($, e, next) {
474
507
  turn = { start: await snapshot($), ratio: 1 };
475
508
  const file = modRouteFile($.plugin.root, sid);
476
509
  if (file && lastNote !== `${sid}:{}`) { lastNote = `${sid}:{}`; await $.fs.write(file, '{}'); } // clear the previous prompt's note
477
- if (await $.store.get('paused')) return next(e);
510
+ if (await routingOff($)) return next(e);
478
511
  const kind = e.origin && e.origin.kind;
479
512
  if (kind !== 'composer' && kind !== 'bridge' && kind !== 'sdk') return next(e);
480
513
  const r = await $.model.complete(
@@ -588,7 +621,7 @@ async function* routeStep($, e, next) {
588
621
  const want = await askedFor($, sid, e.model, switched);
589
622
  const ls = await lastSentFor($, sid);
590
623
  const isCold = !ls || now - ls.at >= WARM_MS || coldNext.has(sid);
591
- const model = (await $.store.get('paused')) ? want : stepModel({
624
+ const model = (await routingOff($)) ? want : stepModel({
592
625
  arm, requested: want, switched,
593
626
  cold: isCold,
594
627
  score: decision ? decision.score : null, warm: ls && ls.model,
@@ -609,7 +642,7 @@ async function* routeStep($, e, next) {
609
642
  try { await rememberAsked($, sid, want, model); } catch {} // a missed write only loses this on resume
610
643
  if (model !== want && turn) turn.ratio = PRICE[tierOf(want)] / PRICE[tierOf(model)];
611
644
  let effort = e.effort;
612
- if (!(await $.store.get('paused'))) {
645
+ if (!(await routingOff($))) {
613
646
  try { effort = stepEffort({ arm, requested: e.effort, score: decision ? decision.score : null, on: await effortSwapOn($) }); } catch {}
614
647
  }
615
648
  const sent = model === e.model && effort === e.effort ? e : { ...e, model, ...(effort === undefined ? {} : { effort }) };
@@ -641,7 +674,12 @@ export function register(on) {
641
674
  await $.store.set('paused', r.paused);
642
675
  if (/^\s*(on|off)\s*$/i.test(String(e.args || ''))) return { text: r.text };
643
676
  const manifest = JSON.parse(await $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`).catch(() => '{}'));
644
- return { text: statusText({ version: manifest.version, paused: r.paused, savings: (await $.state.get(SAVINGS)).value, rateLimits: (await $.session.usage()).rateLimits, compactOff: Boolean(await $.store.get('compactOff')) }) };
677
+ const settingsOff = [];
678
+ for (const [k, label] of [['routing', 'model routing'], ['compact_after_answer', 'compacting after an answer past 200k'], ['idle_compact', 'idle compaction']]) {
679
+ if (await switchedOff($, k)) settingsOff.push(label);
680
+ }
681
+ return { text: statusText({ version: manifest.version, paused: r.paused || await switchedOff($, 'routing'), savings: (await $.state.get(SAVINGS)).value, rateLimits: (await $.session.usage()).rateLimits,
682
+ compactOff: Boolean(await $.store.get('compactOff')) || await switchedOff($, 'idle_compact'), settingsOff }) };
645
683
  });
646
684
  // Routing fails open: a scorer or hook error must never break the session,
647
685
  // it just means the requested model is used as-is.
@@ -681,12 +719,16 @@ export function register(on) {
681
719
  if (line) await $.state.set(LINE, line);
682
720
  } catch {} // a missed turn only leaves the band as it was
683
721
  }
684
- let stayAwake = false;
722
+ let stayAwake = false, delay = COMPACT_IDLE_MS;
685
723
  try { stayAwake = await bigSession($); } catch {}
686
- armCompact($, COMPACT_IDLE_MS, { stayAwake, persist: true });
724
+ try {
725
+ delay = afterTurnDelay({ tokens: ((await $.session.usage()).context || {}).tokens || 0,
726
+ on: await bigCompactOn($), off: await $.store.get('compactOff') });
727
+ } catch {}
728
+ armCompact($, delay, { stayAwake: stayAwake && delay === COMPACT_IDLE_MS, persist: true, afterAnswer: delay !== COMPACT_IDLE_MS });
687
729
  return r;
688
730
  });
689
731
  }
690
732
 
691
733
  // Exported for tests only - not part of the mod's runtime contract.
692
- export const _internal = { compressedSince, recalledSince, stepEffort, reportRow, noteStep, flushReport, pendingRows: () => pending, troxyConfig, askedFor, rememberAsked, asked, armCompact, cancelCompact, toDomainAndFields, guardBash, failClosed, parseScore, asRequest, pickModel, troxyCommand, modRouteFile, estimateTokens, MODELS, savedLine, sessionLine, addTurn, withKept, statusText, compactPlan, savingsText, fetchSavings, scorePrompt, routeStep, stepModel, lastSent };
734
+ export const _internal = { compressedSince, recalledSince, stepEffort, reportRow, noteStep, flushReport, pendingRows: () => pending, troxyConfig, askedFor, rememberAsked, asked, armCompact, cancelCompact, afterTurnDelay, bigCompactOn, idleCompact, switchedOff, toDomainAndFields, guardBash, failClosed, parseScore, asRequest, pickModel, troxyCommand, modRouteFile, estimateTokens, MODELS, savedLine, sessionLine, addTurn, withKept, statusText, compactPlan, savingsText, fetchSavings, scorePrompt, routeStep, stepModel, lastSent };
@@ -108,9 +108,50 @@ test('statusText: version, routing, this session, plan usage, where the account
108
108
  + 'Plan usage now: 5h 23% · 7d 77%\n'
109
109
  + 'The model is picked at the start, after an hour idle or after a compaction, then kept (switching would rewrite the cache). /model always wins.\n'
110
110
  + 'Idle compaction is on: sessions over 100k tokens are compacted after 55 min idle (/troxy compact off).\n'
111
- + 'All sessions: dash.troxy.io\n'
111
+ + 'All sessions and the on/off switches: dash.troxy.io\n'
112
112
  + 'Type /troxy off to pause routing.');
113
113
  assert.match(statusText({ paused: true }), /^Troxy · routing is off\nThis session: nothing moved to a lighter model yet\n/);
114
+ assert.match(statusText({ settingsOff: ['model routing'] }), /\nTurned off in Settings on dash\.troxy\.io: model routing\.\n/);
115
+ });
116
+
117
+ test('Settings switches: kept from the savings fetch, on unless set false, env pins compact-after-answer', async () => {
118
+ const { fetchSavings, bigCompactOn, switchedOff, idleCompact } = _internal;
119
+ const disk = new Map();
120
+ const env = { HOME: '/h' };
121
+ const compacted = [];
122
+ const $ = {
123
+ session: { id: async () => 'abc-12345678', usage: async () => ({ context: { tokens: 300000 }, rateLimits: [] }),
124
+ compact: async () => { compacted.push(1); return { skip: true }; } },
125
+ env: { get: async n => env[n] },
126
+ fs: { read: async () => JSON.stringify({ apiKey: 'txy-1' }), write: async () => {} },
127
+ http: { fetch: async () => ({ ok: true, text: JSON.stringify({ calls: 1, features: { compact_after_answer: false } }) }) },
128
+ store: { get: async k => disk.get(k), set: async (k, v) => disk.set(k, v) },
129
+ clock: { now: async () => 0 },
130
+ plugin: { root: '/tmp/x' },
131
+ state: { set: async () => {} },
132
+ };
133
+ assert.equal(await bigCompactOn($), true); // nothing from Settings yet: on for everyone
134
+ await fetchSavings($);
135
+ assert.deepEqual(disk.get('features'), { compact_after_answer: false });
136
+ assert.equal(await bigCompactOn($), false);
137
+ assert.equal(await switchedOff($, 'routing'), false); // missing = on
138
+ await idleCompact($, { afterAnswer: true });
139
+ assert.equal(compacted.length, 0); // the after-answer switch is off
140
+ await idleCompact($);
141
+ assert.equal(compacted.length, 1); // idle compaction is still on
142
+ env.TROXY_BIG_COMPACT = '1';
143
+ assert.equal(await bigCompactOn($), true);
144
+ env.TROXY_BIG_COMPACT = '0'; disk.clear();
145
+ assert.equal(await bigCompactOn($), false);
146
+ });
147
+
148
+ test('afterTurnDelay: with bigCompact on, a session past 200k compacts right after the answer', () => {
149
+ const { afterTurnDelay } = _internal;
150
+ const IDLE = 55 * 60 * 1000;
151
+ assert.equal(afterTurnDelay({ tokens: 351000, on: true }), 3000);
152
+ assert.equal(afterTurnDelay({ tokens: 199000, on: true }), IDLE); // under 200k: idle path only
153
+ assert.equal(afterTurnDelay({ tokens: 351000, on: false }), IDLE); // flag off
154
+ assert.equal(afterTurnDelay({ tokens: 351000, on: true, off: true }), IDLE); // /troxy compact off wins
114
155
  });
115
156
 
116
157
  test('compactPlan: big idle sessions compact, small ones and a nearly full bar do not', () => {