troxy-cli 1.30.21 → 1.30.23

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.21",
3
+ "version": "1.30.23",
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": {
package/src/init.js CHANGED
@@ -325,14 +325,14 @@ async function printHostedAgentSetup(key) {
325
325
  // thing as a bare ~/AGENTS.md, which (per our own testing) a project working
326
326
  // directory does not walk up to. This is the one path that actually applies
327
327
  // everywhere, so it's the file the "all projects" option below writes to.
328
- function globalClaudeMdPath() {
328
+ export function globalClaudeMdPath() {
329
329
  return path.join(os.homedir(), '.claude', 'CLAUDE.md');
330
330
  }
331
331
 
332
332
  // Same merge algorithm as writeAgentsFile (marker-delimited, never clobbers
333
333
  // content outside its own span), generalized to any file path so both the
334
334
  // per-project AGENTS.md and the global CLAUDE.md can share it.
335
- function writeMarkedBlock(file, text, startMarker, endMarker) {
335
+ export function writeMarkedBlock(file, text, startMarker, endMarker) {
336
336
  const block = `${startMarker}\n${text}\n${endMarker}`;
337
337
  let existing = '';
338
338
  try { existing = fs.readFileSync(file, 'utf8'); } catch {}
@@ -352,7 +352,7 @@ function writeMarkedBlock(file, text, startMarker, endMarker) {
352
352
  return file;
353
353
  }
354
354
 
355
- function removeMarkedBlock(file, startMarker, endMarker) {
355
+ export function removeMarkedBlock(file, startMarker, endMarker) {
356
356
  let existing;
357
357
  try { existing = fs.readFileSync(file, 'utf8'); } catch { return false; }
358
358
  if (!existing.includes(startMarker) || !existing.includes(endMarker)) return false;
@@ -360,6 +360,22 @@ const COMPACTED = { plugin: 'troxy-guard', key: 'compacted' }; // the band's not
360
360
  const COMPACTED_SHOW_MS = 10 * 60 * 1000; // kept through prompts for this long, so it isn't missed
361
361
  let compactTimer = null;
362
362
 
363
+ // Big-session compaction: a session past 200k compacts right after an answer, between
364
+ // tasks, while its cache is still warm, instead of re-reading the whole thing on every
365
+ // later call. A/B 2026-11-06 at 350k on Sonnet: 4.0 vs 6.0 bar points for the same 9-step
366
+ // job, the compaction included, same results. The short delay lets the turn settle; a
367
+ // 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.
369
+ const BIG_COMPACT_TOKENS = 200000;
370
+ const BIG_COMPACT_DELAY_MS = 3000;
371
+ function afterTurnDelay({ tokens, on, off }) {
372
+ return on && !off && tokens >= BIG_COMPACT_TOKENS ? BIG_COMPACT_DELAY_MS : COMPACT_IDLE_MS;
373
+ }
374
+ async function bigCompactOn($) {
375
+ if ((await $.env.get('TROXY_BIG_COMPACT')) === '1') return true;
376
+ return Boolean((await troxyConfig($)).bigCompact);
377
+ }
378
+
363
379
  // What to do when the idle timer fires: skip, compact now, or wait for the meter to reset.
364
380
  function compactPlan({ tokens, rateLimits = [], now, off }) {
365
381
  if (off || !(tokens >= COMPACT_MIN_TOKENS)) return { action: 'skip' };
@@ -434,7 +450,7 @@ async function idleCompact($, { late = false } = {}) {
434
450
  if (r.skip) return;
435
451
  const k = n => `${Math.round(n / 1000)}k`;
436
452
  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' });
453
+ ? `compacted this session ${k(r.tokensBefore)} → ${k(r.tokensAfter)} tokens` : 'compacted this session' });
438
454
  }
439
455
 
440
456
  // Where the local daemon looks for this session's routing note (see interceptor.js).
@@ -681,12 +697,16 @@ export function register(on) {
681
697
  if (line) await $.state.set(LINE, line);
682
698
  } catch {} // a missed turn only leaves the band as it was
683
699
  }
684
- let stayAwake = false;
700
+ let stayAwake = false, delay = COMPACT_IDLE_MS;
685
701
  try { stayAwake = await bigSession($); } catch {}
686
- armCompact($, COMPACT_IDLE_MS, { stayAwake, persist: true });
702
+ try {
703
+ delay = afterTurnDelay({ tokens: ((await $.session.usage()).context || {}).tokens || 0,
704
+ on: await bigCompactOn($), off: await $.store.get('compactOff') });
705
+ } catch {}
706
+ armCompact($, delay, { stayAwake: stayAwake && delay === COMPACT_IDLE_MS, persist: true });
687
707
  return r;
688
708
  });
689
709
  }
690
710
 
691
711
  // 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 };
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 };
package/src/slim.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import fs from 'fs';
2
2
  import os from 'os';
3
3
  import path from 'path';
4
- import { claudeCodeSettingsPath } from './init.js';
4
+ import { claudeCodeSettingsPath, globalClaudeMdPath, writeMarkedBlock, removeMarkedBlock } from './init.js';
5
5
  import { loadConfig, saveConfig } from './config.js';
6
6
  import { api } from './api.js';
7
7
 
@@ -21,9 +21,9 @@ import { api } from './api.js';
21
21
  // of the tool in a transcript; tool definitions are in transcripts too, so
22
22
  // matching the bare name would find every session.
23
23
  export const TOOLS = [
24
- { id: 'chrome', label: 'Claude in Chrome (lets Claude control your browser)', tokens: 7200,
24
+ { id: 'chrome', label: 'Claude in Chrome (lets Claude control your browser)', tokens: 7200, asks: 'to use or control a web browser',
25
25
  used: /"type":"tool_use","id":"[^"]+","name":"mcp__claude-in-chrome__/ },
26
- { id: 'artifact', label: 'Artifacts (lets Claude publish pages to claude.ai)', tokens: 7500,
26
+ { id: 'artifact', label: 'Artifacts (lets Claude publish pages to claude.ai)', tokens: 7500, asks: 'for an artifact or a page published to claude.ai',
27
27
  used: /"type":"tool_use","id":"[^"]+","name":"Artifact"/ },
28
28
  ];
29
29
 
@@ -57,6 +57,23 @@ export function setToolOff(id, off, file = claudeCodeSettingsPath()) {
57
57
  throw new Error(`Unknown tool "${id}". Try: ${TOOLS.map(t => t.id).join(', ')}`);
58
58
  }
59
59
  write(file, s);
60
+ if (file === claudeCodeSettingsPath()) syncOffNote(file);
61
+ }
62
+
63
+ const NOTE_START = '<!-- troxy:slim:start -->';
64
+ const NOTE_END = '<!-- troxy:slim:end -->';
65
+
66
+ // A turned-off tool is simply gone from Claude's tool list, so nothing errors:
67
+ // Claude would only say it can't do that. This note in the global CLAUDE.md
68
+ // tells Claude why and what to tell the user. About 50 tokens per tool, vs
69
+ // the ~7k the tool itself costs.
70
+ export function syncOffNote(file = claudeCodeSettingsPath(), noteFile = globalClaudeMdPath()) {
71
+ const off = TOOLS.filter(t => toolIsOff(t.id, file));
72
+ if (!off.length) { removeMarkedBlock(noteFile, NOTE_START, NOTE_END); return; }
73
+ writeMarkedBlock(noteFile, '## Turned off by Troxy\n\n' + off.map(t =>
74
+ `- ${t.label.split(' (')[0]} is turned off by Troxy to make every call smaller. If the user asks you ${t.asks}, ` +
75
+ `tell them it is off and that they can turn it back on by running \`troxy slim ${t.id} --undo\` in a terminal and then starting a new Claude Code session.`
76
+ ).join('\n'), NOTE_START, NOTE_END);
60
77
  }
61
78
 
62
79
  export function printToolsStatus(file = claudeCodeSettingsPath()) {
@@ -102,7 +119,9 @@ export async function reportSlim(apiKey = loadConfig()?.apiKey, file = claudeCod
102
119
  // back on with --undo stays on.
103
120
  export async function autoSlim({ file = claudeCodeSettingsPath(), projectsDir } = {}) {
104
121
  const cfg = loadConfig();
105
- if (!cfg || cfg.slimChecked) return;
122
+ if (!cfg) return;
123
+ if (file === claudeCodeSettingsPath()) syncOffNote(file); // also for tools turned off before the note existed
124
+ if (cfg.slimChecked) return;
106
125
  const used = usedTools(projectsDir);
107
126
  for (const t of TOOLS) {
108
127
  if (used.has(t.id) || toolIsOff(t.id, file)) continue;
@@ -113,6 +113,15 @@ test('statusText: version, routing, this session, plan usage, where the account
113
113
  assert.match(statusText({ paused: true }), /^Troxy · routing is off\nThis session: nothing moved to a lighter model yet\n/);
114
114
  });
115
115
 
116
+ test('afterTurnDelay: with bigCompact on, a session past 200k compacts right after the answer', () => {
117
+ const { afterTurnDelay } = _internal;
118
+ const IDLE = 55 * 60 * 1000;
119
+ assert.equal(afterTurnDelay({ tokens: 351000, on: true }), 3000);
120
+ assert.equal(afterTurnDelay({ tokens: 199000, on: true }), IDLE); // under 200k: idle path only
121
+ assert.equal(afterTurnDelay({ tokens: 351000, on: false }), IDLE); // flag off
122
+ assert.equal(afterTurnDelay({ tokens: 351000, on: true, off: true }), IDLE); // /troxy compact off wins
123
+ });
124
+
116
125
  test('compactPlan: big idle sessions compact, small ones and a nearly full bar do not', () => {
117
126
  const { compactPlan } = _internal;
118
127
  const now = Date.parse('2026-10-08T15:00:00Z');
@@ -4,7 +4,7 @@ import fs from 'node:fs';
4
4
  import os from 'node:os';
5
5
  import path from 'node:path';
6
6
 
7
- import { TOOLS, toolIsOff, setToolOff, usedTools } from '../slim.js';
7
+ import { TOOLS, toolIsOff, setToolOff, usedTools, syncOffNote } from '../slim.js';
8
8
 
9
9
  // Turning a tool off and back on must leave the user's own settings.json
10
10
  // exactly as it was, including their own env vars and deny rules.
@@ -53,4 +53,18 @@ describe('troxy slim', () => {
53
53
  assert.deepEqual([...usedTools(path.join(dir, 'projects'))], ['chrome']);
54
54
  assert.deepEqual([...usedTools(path.join(dir, 'missing'))], []);
55
55
  });
56
+
57
+ it('keeps a note for Claude listing only the tools that are off, and keeps the user text', () => {
58
+ const note = path.join(dir, 'CLAUDE.md');
59
+ fs.writeFileSync(note, '# mine\n');
60
+ setToolOff('chrome', true, file);
61
+ syncOffNote(file, note);
62
+ let text = fs.readFileSync(note, 'utf8');
63
+ assert.match(text, /^# mine/);
64
+ assert.match(text, /troxy slim chrome --undo/);
65
+ assert.doesNotMatch(text, /troxy slim artifact/);
66
+ setToolOff('chrome', false, file);
67
+ syncOffNote(file, note);
68
+ assert.equal(fs.readFileSync(note, 'utf8').trim(), '# mine');
69
+ });
56
70
  });