troxy-cli 1.29.50 → 1.29.52
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.52",
|
|
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": {
|
package/src/interceptor.js
CHANGED
|
@@ -118,9 +118,9 @@ function _readModRoute(dir, sessionId) {
|
|
|
118
118
|
const arm = n.arm === 'treated' || n.arm === 'holdout' ? n.arm : null;
|
|
119
119
|
const isFresh = Date.now() - n.ts < _MOD_ROUTE_MAX_AGE_MS;
|
|
120
120
|
const routed = isFresh && typeof n.from === 'string' && typeof n.to === 'string';
|
|
121
|
-
return { arm, from: routed ? n.from : null, to: routed ? n.to : null };
|
|
121
|
+
return { arm, from: routed ? n.from : null, to: routed ? n.to : null, compact: isFresh && n.compact === true };
|
|
122
122
|
} catch {
|
|
123
|
-
return { arm: null, from: null, to: null };
|
|
123
|
+
return { arm: null, from: null, to: null, compact: false };
|
|
124
124
|
}
|
|
125
125
|
}
|
|
126
126
|
|
|
@@ -181,6 +181,8 @@ export function buildTroxyForwardHeaders(incomingHeaders, troxyKey, sandboxState
|
|
|
181
181
|
}
|
|
182
182
|
// The mod is this session's one router: the proxy must not route it, and logs the arm.
|
|
183
183
|
if (modRoute && modRoute.arm) headers['x-troxy-mod-arm'] = modRoute.arm;
|
|
184
|
+
// The mod's idle compaction is in flight: this is its summary request.
|
|
185
|
+
if (modRoute && modRoute.compact) headers['x-troxy-mod-compact'] = '1';
|
|
184
186
|
headers.authorization = `Bearer ${troxyKey}`;
|
|
185
187
|
return headers;
|
|
186
188
|
}
|
|
@@ -256,7 +256,8 @@ const COMPACT_MIN_TOKENS = 100000;
|
|
|
256
256
|
const COMPACT_LIMIT_PCT = 95;
|
|
257
257
|
const COMPACT_AFTER_RESET_MS = 60 * 1000;
|
|
258
258
|
const COMPACT_INSTRUCTIONS = 'Keep the current task, the files involved, decisions made, and open to-dos.';
|
|
259
|
-
const COMPACTED = { plugin: 'troxy-guard', key: 'compacted' }; // the band's note
|
|
259
|
+
const COMPACTED = { plugin: 'troxy-guard', key: 'compacted' }; // the band's note: { text, at }
|
|
260
|
+
const COMPACTED_SHOW_MS = 10 * 60 * 1000; // kept through prompts for this long, so it isn't missed
|
|
260
261
|
let compactTimer = null;
|
|
261
262
|
|
|
262
263
|
// What to do when the idle timer fires: skip, compact now, or wait for the meter to reset.
|
|
@@ -280,9 +281,14 @@ function cancelCompact() {
|
|
|
280
281
|
// stayAwake: hold off system sleep (the screen still sleeps) until the timer fires, so a
|
|
281
282
|
// big session compacts on time instead of when the user is back. macOS caffeinate; where
|
|
282
283
|
// it is missing the spawn fails quietly and compact-on-return in scorePrompt covers it.
|
|
283
|
-
function armCompact($, ms, { stayAwake = false } = {}) {
|
|
284
|
+
function armCompact($, ms, { stayAwake = false, persist = false } = {}) {
|
|
284
285
|
cancelCompact();
|
|
285
286
|
if (stayAwake) keepAwake($, ms + 2 * 60 * 1000);
|
|
287
|
+
// Persisted so a restart (which loses lastSent and the timer) still finds the idle start.
|
|
288
|
+
if (persist) try {
|
|
289
|
+
Promise.all([$.session.id(), $.clock.now()])
|
|
290
|
+
.then(([sid, at]) => $.store.set(`idle_started_${sid}`, at)).catch(() => {});
|
|
291
|
+
} catch {}
|
|
286
292
|
compactTimer = $.clock.after(ms, () => {
|
|
287
293
|
compactTimer = null;
|
|
288
294
|
idleCompact($).catch(() => {}).finally(stopAwake);
|
|
@@ -318,11 +324,16 @@ async function idleCompact($, { late = false } = {}) {
|
|
|
318
324
|
const plan = compactPlan({ tokens: u.context && u.context.tokens, rateLimits: u.rateLimits, now: await $.clock.now(), off: await $.store.get('compactOff') });
|
|
319
325
|
if (plan.action === 'wait' && !late) return armCompact($, plan.inMs);
|
|
320
326
|
if (plan.action !== 'compact') return;
|
|
321
|
-
|
|
327
|
+
// Tags the summary request so the proxy logs it as Troxy's compaction (the Activity row).
|
|
328
|
+
const file = modRouteFile($.plugin.root, await $.session.id());
|
|
329
|
+
if (file) { lastNote = null; await $.fs.write(file, JSON.stringify({ compact: true, ts: await $.clock.now() })); }
|
|
330
|
+
let r;
|
|
331
|
+
try { r = await $.session.compact({ instructions: COMPACT_INSTRUCTIONS }); }
|
|
332
|
+
finally { if (file) await $.fs.write(file, '{}'); }
|
|
322
333
|
if (r.skip) return;
|
|
323
334
|
const k = n => `${Math.round(n / 1000)}k`;
|
|
324
|
-
await $.state.set(COMPACTED, r.tokensBefore && r.tokensAfter
|
|
325
|
-
? `compacted this idle session ${k(r.tokensBefore)} → ${k(r.tokensAfter)} tokens` : 'compacted this idle session');
|
|
335
|
+
await $.state.set(COMPACTED, { at: await $.clock.now(), text: r.tokensBefore && r.tokensAfter
|
|
336
|
+
? `compacted this idle session ${k(r.tokensBefore)} → ${k(r.tokensAfter)} tokens` : 'compacted this idle session' });
|
|
326
337
|
}
|
|
327
338
|
|
|
328
339
|
// Where the local daemon looks for this session's routing note (see interceptor.js).
|
|
@@ -344,12 +355,19 @@ let decision = null;
|
|
|
344
355
|
async function scorePrompt($, e, next) {
|
|
345
356
|
decision = null;
|
|
346
357
|
cancelCompact();
|
|
347
|
-
if ((await $.state.get(COMPACTED)).value) await $.state.set(COMPACTED, null);
|
|
348
358
|
const sid = await $.session.id();
|
|
349
|
-
// Idle past the timer by the wall clock: a sleeping Mac freezes the timer
|
|
350
|
-
// compaction it missed runs here, before this prompt goes out
|
|
359
|
+
// Idle past the timer by the wall clock: a sleeping Mac freezes the timer and a restart
|
|
360
|
+
// loses it, so the compaction it missed runs here, before this prompt goes out.
|
|
361
|
+
const now = await $.clock.now();
|
|
362
|
+
const { value: shown } = await $.state.get(COMPACTED);
|
|
363
|
+
if (shown && !(now - shown.at < COMPACTED_SHOW_MS)) await $.state.set(COMPACTED, null);
|
|
351
364
|
const ls = lastSent.get(sid);
|
|
352
|
-
|
|
365
|
+
let idleSince = ls ? ls.at : null;
|
|
366
|
+
try {
|
|
367
|
+
const stored = await $.store.get(`idle_started_${sid}`);
|
|
368
|
+
if (typeof stored === 'number' && (idleSince == null || stored > idleSince)) idleSince = stored;
|
|
369
|
+
} catch {}
|
|
370
|
+
if (idleSince != null && now - idleSince >= COMPACT_IDLE_MS) {
|
|
353
371
|
try { await idleCompact($, { late: true }); } catch {} // fail open: send the prompt as-is
|
|
354
372
|
}
|
|
355
373
|
turn = { start: await snapshot($), ratio: 1 };
|
|
@@ -397,13 +415,25 @@ async function rememberAsked($, sid, want, ran) {
|
|
|
397
415
|
await $.store.set(`asked:${sid}`, { asked: want, ran });
|
|
398
416
|
}
|
|
399
417
|
|
|
400
|
-
//
|
|
401
|
-
//
|
|
402
|
-
//
|
|
403
|
-
//
|
|
404
|
-
|
|
418
|
+
// Warm switches: a new model re-writes the cache once, then every request on it is
|
|
419
|
+
// cheaper, so a switch pays back in about one or two typed prompts if the model then
|
|
420
|
+
// holds. Switch at most once per HOLD_PROMPTS prompts, and only while the re-write is
|
|
421
|
+
// small enough to pay back.
|
|
422
|
+
const HOLD_PROMPTS = 2;
|
|
423
|
+
const WARM_SWITCH_MAX_TOKENS = 200000;
|
|
424
|
+
// Per session: the last prompt's decision and how many prompts ran on the current model.
|
|
425
|
+
const held = new Map();
|
|
426
|
+
|
|
427
|
+
// Which model a step runs on. At a cold moment (first step, 55+ min idle, after a
|
|
428
|
+
// compaction) any model is free to pick. While warm, hold the model, except at the
|
|
429
|
+
// first step of a new prompt once it has held HOLD_PROMPTS prompts: then re-pick by
|
|
430
|
+
// score, down or up. The user's own /model always wins and becomes the held model.
|
|
431
|
+
function stepModel({ arm, requested, switched, cold, score, warm, newPrompt, heldPrompts, tokens }) {
|
|
405
432
|
if (arm !== 'treated' || switched) return requested;
|
|
406
433
|
if (cold) return pickModel(score, requested, 0, null);
|
|
434
|
+
if (newPrompt && score !== null && heldPrompts >= HOLD_PROMPTS && tokens <= WARM_SWITCH_MAX_TOKENS) {
|
|
435
|
+
return pickModel(score, requested, 0, null);
|
|
436
|
+
}
|
|
407
437
|
const w = tierOf(warm), rq = tierOf(requested);
|
|
408
438
|
return w !== null && rq !== null && RANK[w] <= RANK[rq] ? warm : requested;
|
|
409
439
|
}
|
|
@@ -420,11 +450,19 @@ async function* routeStep($, e, next) {
|
|
|
420
450
|
const switched = Boolean(prev && prev !== e.model);
|
|
421
451
|
const want = await askedFor($, sid, e.model, switched);
|
|
422
452
|
const ls = lastSent.get(sid);
|
|
453
|
+
const h = held.get(sid) || { decision: null, prompts: 0 };
|
|
454
|
+
const newPrompt = Boolean(decision) && decision !== h.decision;
|
|
455
|
+
if (newPrompt) h.decision = decision;
|
|
423
456
|
const model = (await $.store.get('paused')) ? want : stepModel({
|
|
424
457
|
arm, requested: want, switched,
|
|
425
458
|
cold: !ls || now - ls.at >= WARM_MS || coldNext.has(sid),
|
|
426
459
|
score: decision ? decision.score : null, warm: ls && ls.model,
|
|
460
|
+
newPrompt, heldPrompts: h.prompts, tokens: decision ? decision.tokens : Infinity,
|
|
427
461
|
});
|
|
462
|
+
// Count prompts on the current model; a change starts the count over.
|
|
463
|
+
if (!ls || model !== ls.model) h.prompts = newPrompt ? 1 : 0;
|
|
464
|
+
else if (newPrompt) h.prompts++;
|
|
465
|
+
held.set(sid, h);
|
|
428
466
|
coldNext.delete(sid);
|
|
429
467
|
lastSent.set(sid, { model, at: now });
|
|
430
468
|
// The daemon tags every call with the arm, plus from/to when the model changed.
|
|
@@ -480,7 +518,7 @@ export function register(on) {
|
|
|
480
518
|
const { value: kept } = await $.state.get(SAVINGS);
|
|
481
519
|
if ((!line && !kept && !note) || e.props.hasSurvey) return next(e);
|
|
482
520
|
const { Text } = $.ui.resolve(e);
|
|
483
|
-
return h(Text, { color: TROXY_TEAL }, `Troxy ${[line, kept, note].filter(Boolean).join(' · ')}`);
|
|
521
|
+
return h(Text, { color: TROXY_TEAL }, `Troxy ${[line, kept, note && note.text].filter(Boolean).join(' · ')}`);
|
|
484
522
|
});
|
|
485
523
|
// After the answer, so counting never delays a prompt.
|
|
486
524
|
on('turn.complete', async ($, e, next) => {
|
|
@@ -502,10 +540,10 @@ export function register(on) {
|
|
|
502
540
|
} catch {} // a missed fetch only leaves the band as it was
|
|
503
541
|
let stayAwake = false;
|
|
504
542
|
try { stayAwake = await bigSession($); } catch {}
|
|
505
|
-
armCompact($, COMPACT_IDLE_MS, { stayAwake });
|
|
543
|
+
armCompact($, COMPACT_IDLE_MS, { stayAwake, persist: true });
|
|
506
544
|
return r;
|
|
507
545
|
});
|
|
508
546
|
}
|
|
509
547
|
|
|
510
548
|
// Exported for tests only - not part of the mod's runtime contract.
|
|
511
|
-
export const _internal = { askedFor, rememberAsked, asked, armCompact, cancelCompact, toDomainAndFields, guardBash, failClosed, parseScore, asRequest, pickModel, troxyCommand, modRouteFile, estimateTokens, MODELS, savedLine, sessionLine, addTurn, statusText, compactPlan, savingsText, fetchSavings, scorePrompt, routeStep, stepModel };
|
|
549
|
+
export const _internal = { askedFor, rememberAsked, asked, armCompact, cancelCompact, toDomainAndFields, guardBash, failClosed, parseScore, asRequest, pickModel, troxyCommand, modRouteFile, estimateTokens, MODELS, savedLine, sessionLine, addTurn, statusText, compactPlan, savingsText, fetchSavings, scorePrompt, routeStep, stepModel, HOLD_PROMPTS };
|
|
@@ -163,6 +163,16 @@ describe('buildTroxyForwardHeaders', () => {
|
|
|
163
163
|
note(dir, { arm: 'bogus', ts: Date.now() });
|
|
164
164
|
assert.equal(build(dir)['x-troxy-mod-arm'], undefined);
|
|
165
165
|
});
|
|
166
|
+
|
|
167
|
+
it('tags the idle-compaction summary request only while the note is fresh', () => {
|
|
168
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-modroute-'));
|
|
169
|
+
note(dir, { compact: true, ts: Date.now() });
|
|
170
|
+
assert.equal(build(dir)['x-troxy-mod-compact'], '1');
|
|
171
|
+
note(dir, { compact: true, ts: Date.now() - 31 * 60 * 1000 });
|
|
172
|
+
assert.equal(build(dir)['x-troxy-mod-compact'], undefined);
|
|
173
|
+
note(dir, {});
|
|
174
|
+
assert.equal(build(dir)['x-troxy-mod-compact'], undefined);
|
|
175
|
+
});
|
|
166
176
|
});
|
|
167
177
|
|
|
168
178
|
it('omits x-troxy-tool-sandbox header when no state file exists', () => {
|
|
@@ -189,6 +189,18 @@ test('stepModel: decide only at cold moments, hold in between, holdout never rou
|
|
|
189
189
|
assert.equal(stepModel({ ...base, switched: true, cold: false, score: 0.1 }), M.opus);
|
|
190
190
|
});
|
|
191
191
|
|
|
192
|
+
test('stepModel: warm switch at a new prompt only after the hold, and under the size cap', () => {
|
|
193
|
+
const { stepModel, MODELS: M, HOLD_PROMPTS } = _internal;
|
|
194
|
+
const base = { arm: 'treated', requested: M.opus, switched: false, warm: M.opus, cold: false, score: 0.1, tokens: 80000 };
|
|
195
|
+
assert.equal(stepModel({ ...base, newPrompt: true, heldPrompts: HOLD_PROMPTS }), M.haiku); // held long enough: switch down
|
|
196
|
+
assert.equal(stepModel({ ...base, newPrompt: true, heldPrompts: HOLD_PROMPTS - 1 }), M.opus); // still paying back: hold
|
|
197
|
+
assert.equal(stepModel({ ...base, newPrompt: false, heldPrompts: HOLD_PROMPTS }), M.opus); // mid-prompt tool step: hold
|
|
198
|
+
assert.equal(stepModel({ ...base, newPrompt: true, heldPrompts: HOLD_PROMPTS, tokens: 250000 }), M.opus); // re-write too big
|
|
199
|
+
assert.equal(stepModel({ ...base, newPrompt: true, heldPrompts: HOLD_PROMPTS, score: null }), M.opus); // no score: hold
|
|
200
|
+
assert.equal(stepModel({ ...base, warm: M.haiku, score: 0.9, newPrompt: true, heldPrompts: HOLD_PROMPTS }), M.opus); // hard prompt: back up
|
|
201
|
+
assert.equal(stepModel({ ...base, arm: 'holdout', newPrompt: true, heldPrompts: HOLD_PROMPTS }), M.opus);
|
|
202
|
+
});
|
|
203
|
+
|
|
192
204
|
test('pickModel: past the floor, stay on the warm model unless the prompt needs a stronger one', () => {
|
|
193
205
|
const { pickModel, MODELS } = _internal;
|
|
194
206
|
const big = 80000;
|
|
@@ -233,6 +245,28 @@ test('scorePrompt: back after 55+ min by the wall clock (Mac slept through the t
|
|
|
233
245
|
assert.ok(await runTurn($, opus)); // a failed compaction never blocks the prompt
|
|
234
246
|
});
|
|
235
247
|
|
|
248
|
+
test('scorePrompt: idle 55+ min across a Claude Code restart still compacts, from the saved idle start', async () => {
|
|
249
|
+
const { armCompact, cancelCompact } = _internal;
|
|
250
|
+
const $ = routingApi();
|
|
251
|
+
const db = new Map();
|
|
252
|
+
let now = 1000, compacts = 0;
|
|
253
|
+
$.store = { get: async k => db.get(k), set: async (k, v) => { db.set(k, v); } };
|
|
254
|
+
$.clock.now = async () => now;
|
|
255
|
+
$.clock.after = () => ({ cancel() {} });
|
|
256
|
+
$.session.usage = async () => ({ context: { tokens: 174000 }, rateLimits: [] });
|
|
257
|
+
$.session.compact = async () => { compacts++; return { tokensBefore: 174000, tokensAfter: 20000 }; };
|
|
258
|
+
armCompact($, 55 * 60 * 1000, { persist: true }); // end of the last turn before the restart
|
|
259
|
+
await new Promise(r => setImmediate(r));
|
|
260
|
+
assert.equal(typeof [...db.values()][0], 'number');
|
|
261
|
+
now += 40 * 60 * 1000;
|
|
262
|
+
armCompact($, 55 * 60 * 1000); // session.start after the restart: must not reset it
|
|
263
|
+
await new Promise(r => setImmediate(r));
|
|
264
|
+
now += 16 * 60 * 1000;
|
|
265
|
+
await runTurn($, _internal.MODELS.opus); // fresh process: no lastSent, only the store
|
|
266
|
+
cancelCompact();
|
|
267
|
+
assert.equal(compacts, 1);
|
|
268
|
+
});
|
|
269
|
+
|
|
236
270
|
test('armCompact: a big session keeps the Mac awake until the timer fires, then lets it sleep', async () => {
|
|
237
271
|
const { armCompact, cancelCompact } = _internal;
|
|
238
272
|
const spawned = [], stopped = [];
|