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.
|
|
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
|
-
|
|
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
|
+
});
|