troxy-cli 1.30.23 → 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.23",
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
  }
@@ -365,15 +379,17 @@ let compactTimer = null;
365
379
  // later call. A/B 2026-11-06 at 350k on Sonnet: 4.0 vs 6.0 bar points for the same 9-step
366
380
  // job, the compaction included, same results. The short delay lets the turn settle; a
367
381
  // prompt typed first cancels it (scorePrompt), so it never holds the user up.
368
- // Off unless switched on (config bigCompact, or TROXY_BIG_COMPACT=1) until measured live.
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.
369
384
  const BIG_COMPACT_TOKENS = 200000;
370
385
  const BIG_COMPACT_DELAY_MS = 3000;
371
386
  function afterTurnDelay({ tokens, on, off }) {
372
387
  return on && !off && tokens >= BIG_COMPACT_TOKENS ? BIG_COMPACT_DELAY_MS : COMPACT_IDLE_MS;
373
388
  }
374
389
  async function bigCompactOn($) {
375
- if ((await $.env.get('TROXY_BIG_COMPACT')) === '1') return true;
376
- return Boolean((await troxyConfig($)).bigCompact);
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'));
377
393
  }
378
394
 
379
395
  // What to do when the idle timer fires: skip, compact now, or wait for the meter to reset.
@@ -397,7 +413,7 @@ function cancelCompact() {
397
413
  // stayAwake: hold off system sleep (the screen still sleeps) until the timer fires, so a
398
414
  // big session compacts on time instead of when the user is back. macOS caffeinate; where
399
415
  // it is missing the spawn fails quietly and compact-on-return in scorePrompt covers it.
400
- function armCompact($, ms, { stayAwake = false, persist = false } = {}) {
416
+ function armCompact($, ms, { stayAwake = false, persist = false, afterAnswer = false } = {}) {
401
417
  cancelCompact();
402
418
  if (stayAwake) keepAwake($, ms + 2 * 60 * 1000);
403
419
  // Persisted so a restart (which loses lastSent and the timer) still finds the idle start.
@@ -407,7 +423,7 @@ function armCompact($, ms, { stayAwake = false, persist = false } = {}) {
407
423
  } catch {}
408
424
  compactTimer = $.clock.after(ms, () => {
409
425
  compactTimer = null;
410
- idleCompact($).catch(() => {}).finally(stopAwake);
426
+ idleCompact($, { afterAnswer }).catch(() => {}).finally(stopAwake);
411
427
  });
412
428
  }
413
429
 
@@ -428,17 +444,18 @@ function stopAwake() {
428
444
  }
429
445
 
430
446
  async function bigSession($) {
431
- if (await $.store.get('compactOff')) return false;
447
+ if ((await $.store.get('compactOff')) || (await switchedOff($, 'idle_compact'))) return false;
432
448
  const u = await $.session.usage();
433
449
  return ((u.context && u.context.tokens) || 0) >= COMPACT_MIN_TOKENS;
434
450
  }
435
451
 
436
452
  // late: the user is back after a long idle (the Mac may have slept through the timer),
437
453
  // so compact now or not at all: never hold their prompt waiting for a meter reset.
438
- async function idleCompact($, { late = false } = {}) {
454
+ async function idleCompact($, { late = false, afterAnswer = false } = {}) {
439
455
  const u = await $.session.usage();
440
- const plan = compactPlan({ tokens: u.context && u.context.tokens, rateLimits: u.rateLimits, now: await $.clock.now(), off: await $.store.get('compactOff') });
441
- 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 });
442
459
  if (plan.action !== 'compact') return;
443
460
  // Tags the summary request so the proxy logs it as Troxy's compaction (the Activity row).
444
461
  const file = modRouteFile($.plugin.root, await $.session.id());
@@ -490,7 +507,7 @@ async function scorePrompt($, e, next) {
490
507
  turn = { start: await snapshot($), ratio: 1 };
491
508
  const file = modRouteFile($.plugin.root, sid);
492
509
  if (file && lastNote !== `${sid}:{}`) { lastNote = `${sid}:{}`; await $.fs.write(file, '{}'); } // clear the previous prompt's note
493
- if (await $.store.get('paused')) return next(e);
510
+ if (await routingOff($)) return next(e);
494
511
  const kind = e.origin && e.origin.kind;
495
512
  if (kind !== 'composer' && kind !== 'bridge' && kind !== 'sdk') return next(e);
496
513
  const r = await $.model.complete(
@@ -604,7 +621,7 @@ async function* routeStep($, e, next) {
604
621
  const want = await askedFor($, sid, e.model, switched);
605
622
  const ls = await lastSentFor($, sid);
606
623
  const isCold = !ls || now - ls.at >= WARM_MS || coldNext.has(sid);
607
- const model = (await $.store.get('paused')) ? want : stepModel({
624
+ const model = (await routingOff($)) ? want : stepModel({
608
625
  arm, requested: want, switched,
609
626
  cold: isCold,
610
627
  score: decision ? decision.score : null, warm: ls && ls.model,
@@ -625,7 +642,7 @@ async function* routeStep($, e, next) {
625
642
  try { await rememberAsked($, sid, want, model); } catch {} // a missed write only loses this on resume
626
643
  if (model !== want && turn) turn.ratio = PRICE[tierOf(want)] / PRICE[tierOf(model)];
627
644
  let effort = e.effort;
628
- if (!(await $.store.get('paused'))) {
645
+ if (!(await routingOff($))) {
629
646
  try { effort = stepEffort({ arm, requested: e.effort, score: decision ? decision.score : null, on: await effortSwapOn($) }); } catch {}
630
647
  }
631
648
  const sent = model === e.model && effort === e.effort ? e : { ...e, model, ...(effort === undefined ? {} : { effort }) };
@@ -657,7 +674,12 @@ export function register(on) {
657
674
  await $.store.set('paused', r.paused);
658
675
  if (/^\s*(on|off)\s*$/i.test(String(e.args || ''))) return { text: r.text };
659
676
  const manifest = JSON.parse(await $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`).catch(() => '{}'));
660
- 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 }) };
661
683
  });
662
684
  // Routing fails open: a scorer or hook error must never break the session,
663
685
  // it just means the requested model is used as-is.
@@ -703,10 +725,10 @@ export function register(on) {
703
725
  delay = afterTurnDelay({ tokens: ((await $.session.usage()).context || {}).tokens || 0,
704
726
  on: await bigCompactOn($), off: await $.store.get('compactOff') });
705
727
  } catch {}
706
- armCompact($, delay, { stayAwake: stayAwake && delay === COMPACT_IDLE_MS, persist: true });
728
+ armCompact($, delay, { stayAwake: stayAwake && delay === COMPACT_IDLE_MS, persist: true, afterAnswer: delay !== COMPACT_IDLE_MS });
707
729
  return r;
708
730
  });
709
731
  }
710
732
 
711
733
  // Exported for tests only - not part of the mod's runtime contract.
712
- export const _internal = { compressedSince, recalledSince, stepEffort, reportRow, noteStep, flushReport, pendingRows: () => pending, troxyConfig, askedFor, rememberAsked, asked, armCompact, cancelCompact, afterTurnDelay, 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,41 @@ 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);
114
146
  });
115
147
 
116
148
  test('afterTurnDelay: with bigCompact on, a session past 200k compacts right after the answer', () => {