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.51",
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": {
@@ -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 until the next prompt
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
- const r = await $.session.compact({ instructions: COMPACT_INSTRUCTIONS });
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
- // Which model a step runs on. Decide only at a cold moment (first step, 55+ min
412
- // idle, after a compaction): the cache is cold anyway, so any model is free to
413
- // pick, at any context size. In between, hold the warm model: no switch, no
414
- // cache rewrite. The user's own /model always wins and becomes the held model.
415
- function stepModel({ arm, requested, switched, cold, score, warm }) {
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;