troxy-cli 1.30.4 → 1.30.5

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.30.4",
3
+ "version": "1.30.5",
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": {
@@ -230,7 +230,7 @@ const REPORT_KEEP_ROWS = 1000; // kept for the next try while Troxy is unreachab
230
230
  let pending = [];
231
231
  let compacting = false;
232
232
 
233
- function reportRow({ sid, usage, requested, arm, cold, score, compact, rateLimits, ts, effort, effortAsked, surfaces }) {
233
+ function reportRow({ sid, usage, requested, arm, cold, score, compact, rateLimits, ts, effort, effortAsked, surfaces, compressed }) {
234
234
  const rl = {};
235
235
  for (const w of rateLimits || []) {
236
236
  if (!WINDOW_LABEL[w.kind]) continue;
@@ -243,10 +243,26 @@ function reportRow({ sid, usage, requested, arm, cold, score, compact, rateLimit
243
243
  compact: Boolean(compact), input_tokens: usage.input_tokens, output_tokens: usage.output_tokens,
244
244
  cache_read_tokens: usage.cache_read_input_tokens, cache_creation_tokens: usage.cache_creation_input_tokens,
245
245
  rate_limits: rl, ts, effort: effort || null, effort_requested: effortAsked || null,
246
+ compressed_tokens: compressed > 0 ? compressed : null,
246
247
  surface: (surfaces || []).includes('desktop') ? 'desktop' : (surfaces || []).includes('terminal') ? 'terminal' : null,
247
248
  };
248
249
  }
249
250
 
251
+ // Tokens the compression hook kept out of this session since the last report (it appends one
252
+ // line per compressed tool output, see sandbox/posttooluse-hook.js). Each count is reported once.
253
+ const savedSeen = new Map();
254
+ async function compressedSince($, sid) {
255
+ try {
256
+ if (!/^[A-Za-z0-9-]{8,128}$/.test(String(sid))) return 0;
257
+ const home = await $.env.get('HOME');
258
+ const text = await $.fs.read(`${home}/.troxy/sandbox/saved/${sid}.log`);
259
+ const total = String(text).split('\n').reduce((n, l) => n + (Number(l) || 0), 0);
260
+ const delta = Math.max(0, total - (savedSeen.get(sid) || 0));
261
+ savedSeen.set(sid, total);
262
+ return delta;
263
+ } catch { return 0; }
264
+ }
265
+
250
266
  async function noteStep($, result, meta) {
251
267
  const usage = result && result.usage;
252
268
  if (!usage || !usage.model) return;
@@ -254,7 +270,8 @@ async function noteStep($, result, meta) {
254
270
  try { rateLimits = (await $.session.usage()).rateLimits; } catch {}
255
271
  let surfaces = [];
256
272
  try { surfaces = await $.session.surfaces(); } catch {}
257
- pending.push(reportRow({ ...meta, sid: await $.session.id(), usage, compact: compacting, rateLimits, surfaces, ts: await $.clock.now() }));
273
+ const sid = await $.session.id();
274
+ pending.push(reportRow({ ...meta, sid, usage, compact: compacting, rateLimits, surfaces, compressed: await compressedSince($, sid), ts: await $.clock.now() }));
258
275
  if (pending.length > REPORT_KEEP_ROWS) pending = pending.slice(-REPORT_KEEP_ROWS);
259
276
  }
260
277
 
@@ -648,4 +665,4 @@ export function register(on) {
648
665
  }
649
666
 
650
667
  // Exported for tests only - not part of the mod's runtime contract.
651
- export const _internal = { 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 };
668
+ export const _internal = { compressedSince, 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 };
@@ -38,6 +38,18 @@ function sessionArm(dir, sessionId, holdoutFraction, randomFn) {
38
38
  return arm;
39
39
  }
40
40
 
41
+ // One line per compression: the tokens it kept out of the conversation (about 4 characters
42
+ // each). The troxy-guard mod reads this per session and reports it, since in the mod-only setup
43
+ // no proxy sees the traffic. Append-only so parallel hook processes never overwrite each other.
44
+ export function recordSaved(dir, sessionId, tokens) {
45
+ if (typeof sessionId !== 'string' || !SESSION_ID_RE.test(sessionId) || !(tokens > 0)) return;
46
+ try {
47
+ const file = path.join(dir, 'saved', `${sessionId}.log`);
48
+ fs.mkdirSync(path.dirname(file), { recursive: true });
49
+ fs.appendFileSync(file, `${Math.round(tokens)}\n`);
50
+ } catch { /* fails open: a missed count never blocks the tool output */ }
51
+ }
52
+
41
53
  function writeState(dir, state) {
42
54
  try {
43
55
  fs.mkdirSync(dir, { recursive: true });
@@ -117,6 +129,7 @@ export async function buildHookResponse(toolUseBlock, {
117
129
  const { handle } = await put(output, { summary, tags: [] }, storeDir);
118
130
  writeState(storeDir, 'treated');
119
131
  const updatedText = `${summary}\n\n(troxy_recall("${handle}") to fetch the full output)`;
132
+ recordSaved(storeDir, toolUseBlock?.session_id, (output.length - updatedText.length) / 4);
120
133
  return {
121
134
  hookSpecificOutput: {
122
135
  hookEventName: 'PostToolUse',
@@ -439,3 +439,24 @@ test('reportRow: surface is desktop when the app draws the session, terminal oth
439
439
  assert.equal(reportRow({ ...base, surfaces: ['terminal'] }).surface, 'terminal');
440
440
  assert.equal(reportRow({ ...base, surfaces: [] }).surface, null);
441
441
  });
442
+
443
+ test('reportRow carries the tokens compression kept out, null when none', () => {
444
+ const { reportRow } = _internal;
445
+ const usage = { model: 'claude-sonnet-5-5', input_tokens: 1, output_tokens: 1 };
446
+ assert.equal(reportRow({ sid: 's1', usage, compressed: 4200 }).compressed_tokens, 4200);
447
+ assert.equal(reportRow({ sid: 's1', usage, compressed: 0 }).compressed_tokens, null);
448
+ assert.equal(reportRow({ sid: 's1', usage }).compressed_tokens, null);
449
+ });
450
+
451
+ test('compressedSince reports each compression once, per session', async () => {
452
+ const { compressedSince } = _internal;
453
+ const sid = 'cccccccc-1111-2222-3333-444444444444';
454
+ let log = '1000\n500\n';
455
+ const $ = { env: { get: async () => '/home/u' }, fs: { read: async (p) => { assert.ok(p.endsWith(`/.troxy/sandbox/saved/${sid}.log`)); return log; } } };
456
+ assert.equal(await compressedSince($, sid), 1500);
457
+ assert.equal(await compressedSince($, sid), 0);
458
+ log += '250\n';
459
+ assert.equal(await compressedSince($, sid), 250);
460
+ assert.equal(await compressedSince({ ...$, fs: { read: async () => { throw new Error('no file'); } } }, 'dddddddd-1111-2222'), 0);
461
+ assert.equal(await compressedSince($, '../etc/passwd'), 0);
462
+ });
@@ -223,3 +223,23 @@ test('session_id with path characters is ignored (no file outside the store)', a
223
223
  { holdoutFraction: 0.2, randomFn: () => 0, storeDir: dir });
224
224
  assert.equal(fs.existsSync(path.join(dir, 'sessions')), false);
225
225
  });
226
+
227
+ test('a compression records the tokens it kept out, per session, one line each', async () => {
228
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-sandbox-'));
229
+ const sid = 'aaaaaaaa-1111-2222-3333-444444444444';
230
+ const opts = { sandboxFraction: 1, holdoutFraction: 0, randomFn: () => 0.5, storeDir: dir };
231
+ const big = 'x'.repeat(8000);
232
+ await buildHookResponse({ session_id: sid, tool_response: bashResult(big) }, opts);
233
+ await buildHookResponse({ session_id: sid, tool_response: bashResult(big) }, opts);
234
+ const lines = fs.readFileSync(path.join(dir, 'saved', `${sid}.log`), 'utf8').trim().split('\n').map(Number);
235
+ assert.equal(lines.length, 2);
236
+ assert.ok(lines.every(n => n > 1000 && n <= 2000)); // about 8000 chars / 4, minus the excerpt
237
+ });
238
+
239
+ test('no session id or a small output records nothing', async () => {
240
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-sandbox-'));
241
+ const opts = { sandboxFraction: 1, holdoutFraction: 0, randomFn: () => 0.5, storeDir: dir };
242
+ await buildHookResponse({ tool_response: bashResult('x'.repeat(8000)) }, opts);
243
+ await buildHookResponse({ session_id: 'bbbbbbbb-1111-2222-3333-444444444444', tool_response: bashResult('short') }, opts);
244
+ assert.equal(fs.existsSync(path.join(dir, 'saved')), false);
245
+ });