troxy-cli 1.29.51 → 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.
|
|
@@ -323,11 +324,16 @@ async function idleCompact($, { late = false } = {}) {
|
|
|
323
324
|
const plan = compactPlan({ tokens: u.context && u.context.tokens, rateLimits: u.rateLimits, now: await $.clock.now(), off: await $.store.get('compactOff') });
|
|
324
325
|
if (plan.action === 'wait' && !late) return armCompact($, plan.inMs);
|
|
325
326
|
if (plan.action !== 'compact') return;
|
|
326
|
-
|
|
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, '{}'); }
|
|
327
333
|
if (r.skip) return;
|
|
328
334
|
const k = n => `${Math.round(n / 1000)}k`;
|
|
329
|
-
await $.state.set(COMPACTED, r.tokensBefore && r.tokensAfter
|
|
330
|
-
? `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' });
|
|
331
337
|
}
|
|
332
338
|
|
|
333
339
|
// Where the local daemon looks for this session's routing note (see interceptor.js).
|
|
@@ -349,11 +355,12 @@ let decision = null;
|
|
|
349
355
|
async function scorePrompt($, e, next) {
|
|
350
356
|
decision = null;
|
|
351
357
|
cancelCompact();
|
|
352
|
-
if ((await $.state.get(COMPACTED)).value) await $.state.set(COMPACTED, null);
|
|
353
358
|
const sid = await $.session.id();
|
|
354
359
|
// Idle past the timer by the wall clock: a sleeping Mac freezes the timer and a restart
|
|
355
360
|
// loses it, so the compaction it missed runs here, before this prompt goes out.
|
|
356
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);
|
|
357
364
|
const ls = lastSent.get(sid);
|
|
358
365
|
let idleSince = ls ? ls.at : null;
|
|
359
366
|
try {
|
|
@@ -408,13 +415,25 @@ async function rememberAsked($, sid, want, ran) {
|
|
|
408
415
|
await $.store.set(`asked:${sid}`, { asked: want, ran });
|
|
409
416
|
}
|
|
410
417
|
|
|
411
|
-
//
|
|
412
|
-
//
|
|
413
|
-
//
|
|
414
|
-
//
|
|
415
|
-
|
|
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 }) {
|
|
416
432
|
if (arm !== 'treated' || switched) return requested;
|
|
417
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
|
+
}
|
|
418
437
|
const w = tierOf(warm), rq = tierOf(requested);
|
|
419
438
|
return w !== null && rq !== null && RANK[w] <= RANK[rq] ? warm : requested;
|
|
420
439
|
}
|
|
@@ -431,11 +450,19 @@ async function* routeStep($, e, next) {
|
|
|
431
450
|
const switched = Boolean(prev && prev !== e.model);
|
|
432
451
|
const want = await askedFor($, sid, e.model, switched);
|
|
433
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;
|
|
434
456
|
const model = (await $.store.get('paused')) ? want : stepModel({
|
|
435
457
|
arm, requested: want, switched,
|
|
436
458
|
cold: !ls || now - ls.at >= WARM_MS || coldNext.has(sid),
|
|
437
459
|
score: decision ? decision.score : null, warm: ls && ls.model,
|
|
460
|
+
newPrompt, heldPrompts: h.prompts, tokens: decision ? decision.tokens : Infinity,
|
|
438
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);
|
|
439
466
|
coldNext.delete(sid);
|
|
440
467
|
lastSent.set(sid, { model, at: now });
|
|
441
468
|
// The daemon tags every call with the arm, plus from/to when the model changed.
|
|
@@ -491,7 +518,7 @@ export function register(on) {
|
|
|
491
518
|
const { value: kept } = await $.state.get(SAVINGS);
|
|
492
519
|
if ((!line && !kept && !note) || e.props.hasSurvey) return next(e);
|
|
493
520
|
const { Text } = $.ui.resolve(e);
|
|
494
|
-
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(' · ')}`);
|
|
495
522
|
});
|
|
496
523
|
// After the answer, so counting never delays a prompt.
|
|
497
524
|
on('turn.complete', async ($, e, next) => {
|
|
@@ -519,4 +546,4 @@ export function register(on) {
|
|
|
519
546
|
}
|
|
520
547
|
|
|
521
548
|
// Exported for tests only - not part of the mod's runtime contract.
|
|
522
|
-
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;
|