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 +1 -1
- package/src/init.js +3 -3
- package/src/mods/troxy-guard/hooks/register.js +24 -4
- package/src/slim.js +23 -4
- package/src/tests/mods-troxy-guard.test.js +9 -0
- package/src/tests/slim.test.js +15 -1
package/package.json
CHANGED
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
|
|
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
|
-
|
|
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
|
|
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');
|
package/src/tests/slim.test.js
CHANGED
|
@@ -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
|
});
|