troxy-cli 1.30.6 → 1.30.8

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/bin/troxy.js CHANGED
@@ -291,7 +291,7 @@ switch (command) {
291
291
  break;
292
292
  }
293
293
 
294
- // ── Pause / resume payment evaluations ───────────────────────
294
+ // ── Pause / resume policy checkpoints ───────────────────────
295
295
  case 'pause':
296
296
  await runPause();
297
297
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "troxy-cli",
3
- "version": "1.30.6",
3
+ "version": "1.30.8",
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/mcp-server.js CHANGED
@@ -21,12 +21,50 @@ const reportModelUsage = (body, apiKey) => api.reportModelUsage(body, apiKey);
21
21
  // agent whose only way to reach Troxy is through the tools listed here.
22
22
  const evaluateSecrets = (body, apiKey) => api.evaluateSecrets(body, apiKey);
23
23
 
24
+ // Just the part Claude asks for, so a recall does not put a whole output back into the
25
+ // conversation: `lines` is "120-180" or "120" (1-based), `pattern` is case-insensitive text and
26
+ // returns each matching line with `context` lines around it (default 2, at most 10), within `lines`
27
+ // when both are given. Line numbers are added to partial results.
28
+ const RECALL_MAX_LINES = 200;
29
+ export function sliceRecall(content, { lines, pattern, context } = {}) {
30
+ if ((lines === undefined || lines === null || lines === '') && !pattern) return { text: content };
31
+ const all = content.split('\n');
32
+ let from = 1, to = all.length;
33
+ if (lines !== undefined && lines !== null && lines !== '') {
34
+ const m = /^\s*(\d+)\s*(?:-\s*(\d+))?\s*$/.exec(String(lines));
35
+ if (!m) return { error: 'lines must look like "120-180" or "120"' };
36
+ from = Math.max(1, Number(m[1]));
37
+ to = Math.min(all.length, m[2] ? Number(m[2]) : from);
38
+ if (from > to) return { error: `the output has ${all.length} lines` };
39
+ }
40
+ let keep = [];
41
+ if (pattern) {
42
+ const needle = String(pattern).toLowerCase();
43
+ const ctx = Math.min(10, Math.max(0, Number.isFinite(Number(context)) ? Math.floor(Number(context)) : 2));
44
+ const shown = new Set();
45
+ for (let i = from - 1; i < to; i++) {
46
+ if (!all[i].toLowerCase().includes(needle)) continue;
47
+ for (let j = Math.max(from - 1, i - ctx); j <= Math.min(to - 1, i + ctx); j++) shown.add(j);
48
+ }
49
+ keep = [...shown].sort((a, b) => a - b);
50
+ if (!keep.length) return { text: `No line${lines ? ` in ${from}-${to}` : ''} contains "${pattern}" (the output has ${all.length} lines).` };
51
+ } else {
52
+ for (let i = from - 1; i < to; i++) keep.push(i);
53
+ }
54
+ const cut = keep.length > RECALL_MAX_LINES;
55
+ const body = keep.slice(0, RECALL_MAX_LINES).map(i => `${i + 1}: ${all[i]}`).join('\n');
56
+ const note = cut ? `\n(first ${RECALL_MAX_LINES} of ${keep.length} lines shown, narrow with lines or pattern)` : '';
57
+ return { text: `(${all.length} lines in total, line numbers added)\n${body}${note}` };
58
+ }
59
+
24
60
  export async function handleRecall(args, dir = storeDir()) {
25
61
  const content = await sandboxGet(args.handle, dir);
26
62
  if (content === null) {
27
63
  return { content: [{ type: 'text', text: `Troxy error: handle "${args.handle}" not found (it may have expired).` }], isError: true };
28
64
  }
29
- return { content: [{ type: 'text', text: content }], isError: false };
65
+ const r = sliceRecall(content, args);
66
+ if (r.error) return { content: [{ type: 'text', text: `Troxy error: ${r.error}.` }], isError: true };
67
+ return { content: [{ type: 'text', text: r.text }], isError: false };
30
68
  }
31
69
 
32
70
  export async function runMcp() {
@@ -291,14 +329,18 @@ export async function runMcp() {
291
329
  {
292
330
  name: 'troxy_recall',
293
331
  description:
294
- 'Fetch the full content behind a troxy_recall(...) handle shown after a large tool ' +
332
+ 'Fetch the content behind a troxy_recall(...) handle shown after a large tool ' +
295
333
  'output was compressed. Use this when the summary you were shown doesn\'t have the ' +
296
- 'detail you need.',
334
+ 'detail you need. Prefer lines or pattern when you only need part of it: the whole ' +
335
+ 'output goes back into the conversation and stays there.',
297
336
  inputSchema: {
298
337
  type: 'object',
299
338
  required: ['handle'],
300
339
  properties: {
301
340
  handle: { type: 'string', description: 'The handle shown in the compressed output, e.g. "a1b2c3d4e5f6a7b8"' },
341
+ lines: { type: 'string', description: 'Only these lines, 1-based: "120-180" or "120".' },
342
+ pattern: { type: 'string', description: 'Only lines containing this text (case-insensitive), with a few lines around each. Combine with lines to search inside a range.' },
343
+ context: { type: 'number', description: 'Lines around each pattern match, default 2, at most 10.' },
302
344
  },
303
345
  },
304
346
  },
package/src/mcps.js CHANGED
@@ -8,7 +8,7 @@ const HELP = {
8
8
  rename: ` troxy mcps rename --name <new-name> [--mcp <target>]\n\n Renames an MCP. Without --mcp, renames this machine's MCP.\n\n Options:\n --name New name\n --mcp Name/prefix of the MCP to rename (default: this machine)\n\n Example:\n troxy mcps rename --name "My Laptop"\n troxy mcps rename --mcp "old-name" --name "Staging Server"\n`,
9
9
  create: ` troxy mcps create --name <name>\n\n Creates a new API key for another agent/machine. The key is shown once.\n Save it, then run 'troxy init --key <key>' on that machine.\n\n Options:\n --name Label for the new key\n`,
10
10
  revoke: ` troxy mcps revoke --mcp <name>\n\n Permanently revokes an MCP's API key. That machine will stop being able\n to evaluate payments until reconnected with a new key.\n`,
11
- pause: ` troxy pause / troxy mcps pause [--mcp <name>]\n\n Pauses an MCP (blocks all its payment evaluations). Without --mcp, pauses\n this machine's MCP, same as running 'troxy pause'.\n`,
11
+ pause: ` troxy pause / troxy mcps pause [--mcp <name>]\n\n Pauses an MCP (blocks all its policy checkpoints). Without --mcp, pauses\n this machine's MCP, same as running 'troxy pause'.\n`,
12
12
  resume: ` troxy resume / troxy mcps resume [--mcp <name>]\n\n Resumes a paused MCP. Without --mcp, resumes this machine's MCP.\n`,
13
13
  budget: ` troxy mcps budget --mcp <name> [options]\n\n Sets or clears a monthly spend limit for a specific MCP.\n\n Options:\n --mcp <name> MCP to configure (required)\n --currency <cur> USD, ILS, or EUR (default: USD)\n --limit <n> Monthly limit\n --clear Remove the limit for that currency\n --action <action> block, escalate, notify, or allow: what happens once hit\n --reset-usage Reset this cycle's usage back to 0\n\n Example:\n troxy mcps budget --mcp "My Laptop" --currency USD --limit 500\n`,
14
14
  'set-default-action': ` troxy mcps set-default-action --mcp <name> --action <action>\n\n Sets the fallback decision for an MCP when no policy matches.\n\n Options:\n --mcp <name> MCP to configure (required)\n --action <action> ALLOW, BLOCK, ESCALATE, or NOTIFY\n`,
@@ -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, compressed }) {
233
+ function reportRow({ sid, usage, requested, arm, cold, score, compact, rateLimits, ts, effort, effortAsked, surfaces, compressed, recalled }) {
234
234
  const rl = {};
235
235
  for (const w of rateLimits || []) {
236
236
  if (!WINDOW_LABEL[w.kind]) continue;
@@ -244,24 +244,29 @@ function reportRow({ sid, usage, requested, arm, cold, score, compact, rateLimit
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
246
  compressed_tokens: compressed > 0 ? compressed : null,
247
+ recalled_tokens: recalled > 0 ? recalled : null,
247
248
  surface: (surfaces || []).includes('desktop') ? 'desktop' : (surfaces || []).includes('terminal') ? 'terminal' : null,
248
249
  };
249
250
  }
250
251
 
251
252
  // Tokens the compression hook kept out of this session since the last report (it appends one
252
253
  // line per compressed tool output, see sandbox/posttooluse-hook.js). Each count is reported once.
254
+ // The same hook logs the tokens Claude fetched back with troxy_recall (`recalled/`), which the
255
+ // dashboard subtracts.
253
256
  const savedSeen = new Map();
254
- async function compressedSince($, sid) {
257
+ async function compressedSince($, sid, dir = 'saved') {
255
258
  try {
256
259
  if (!/^[A-Za-z0-9-]{8,128}$/.test(String(sid))) return 0;
257
260
  const home = await $.env.get('HOME');
258
- const text = await $.fs.read(`${home}/.troxy/sandbox/saved/${sid}.log`);
261
+ const text = await $.fs.read(`${home}/.troxy/sandbox/${dir}/${sid}.log`);
259
262
  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);
263
+ const key = `${dir}:${sid}`;
264
+ const delta = Math.max(0, total - (savedSeen.get(key) || 0));
265
+ savedSeen.set(key, total);
262
266
  return delta;
263
267
  } catch { return 0; }
264
268
  }
269
+ const recalledSince = ($, sid) => compressedSince($, sid, 'recalled');
265
270
 
266
271
  async function noteStep($, result, meta) {
267
272
  const usage = result && result.usage;
@@ -271,7 +276,7 @@ async function noteStep($, result, meta) {
271
276
  let surfaces = [];
272
277
  try { surfaces = await $.session.surfaces(); } catch {}
273
278
  const sid = await $.session.id();
274
- pending.push(reportRow({ ...meta, sid, usage, compact: compacting, rateLimits, surfaces, compressed: await compressedSince($, sid), ts: await $.clock.now() }));
279
+ pending.push(reportRow({ ...meta, sid, usage, compact: compacting, rateLimits, surfaces, compressed: await compressedSince($, sid), recalled: await recalledSince($, sid), ts: await $.clock.now() }));
275
280
  if (pending.length > REPORT_KEEP_ROWS) pending = pending.slice(-REPORT_KEEP_ROWS);
276
281
  }
277
282
 
@@ -665,4 +670,4 @@ export function register(on) {
665
670
  }
666
671
 
667
672
  // Exported for tests only - not part of the mod's runtime contract.
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 };
673
+ export const _internal = { compressedSince, recalledSince, 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 };
package/src/pause.js CHANGED
@@ -18,7 +18,7 @@ export async function runPause() {
18
18
  const id = await _localTokenId(jwt, config);
19
19
  await api.pauseToken(jwt, id);
20
20
  console.log('✓');
21
- console.log(' ⏸ All payment evaluations are now blocked.');
21
+ console.log(' ⏸ Policy checkpoints are now blocked. This does not turn off model switching (use /troxy off in Claude Code).');
22
22
  console.log(' Run "troxy resume" to resume.\n');
23
23
  }
24
24
 
@@ -29,5 +29,5 @@ export async function runResume() {
29
29
  const id = await _localTokenId(jwt, config);
30
30
  await api.resumeToken(jwt, id);
31
31
  console.log('✓');
32
- console.log(' ▶ Payment evaluations are active again.\n');
32
+ console.log(' ▶ Policy checkpoints are active again.\n');
33
33
  }
@@ -41,14 +41,25 @@ function sessionArm(dir, sessionId, holdoutFraction, randomFn) {
41
41
  // One line per compression: the tokens it kept out of the conversation (about 4 characters
42
42
  // each). The troxy-guard mod reads this per session and reports it, since in the mod-only setup
43
43
  // no proxy sees the traffic. Append-only so parallel hook processes never overwrite each other.
44
- export function recordSaved(dir, sessionId, tokens) {
44
+ function recordTokens(dir, sub, sessionId, tokens) {
45
45
  if (typeof sessionId !== 'string' || !SESSION_ID_RE.test(sessionId) || !(tokens > 0)) return;
46
46
  try {
47
- const file = path.join(dir, 'saved', `${sessionId}.log`);
47
+ const file = path.join(dir, sub, `${sessionId}.log`);
48
48
  fs.mkdirSync(path.dirname(file), { recursive: true });
49
49
  fs.appendFileSync(file, `${Math.round(tokens)}\n`);
50
50
  } catch { /* fails open: a missed count never blocks the tool output */ }
51
51
  }
52
+ export const recordSaved = (dir, sessionId, tokens) => recordTokens(dir, 'saved', sessionId, tokens);
53
+ // Tokens Claude fetched back with troxy_recall: they re-enter the conversation, so the
54
+ // dashboard takes them off what compression saved.
55
+ export const recordRecalled = (dir, sessionId, tokens) => recordTokens(dir, 'recalled', sessionId, tokens);
56
+
57
+ function textChars(v) {
58
+ if (typeof v === 'string') return v.length;
59
+ if (Array.isArray(v)) return v.reduce((n, x) => n + textChars(x), 0);
60
+ if (v && typeof v === 'object') return textChars(v.text) + textChars(v.content);
61
+ return 0;
62
+ }
52
63
 
53
64
  function writeState(dir, state) {
54
65
  try {
@@ -103,6 +114,10 @@ export async function buildHookResponse(toolUseBlock, {
103
114
  storeDir = defaultStoreDir(),
104
115
  } = {}) {
105
116
  const toolResponse = toolUseBlock?.tool_response;
117
+ if (/troxy_recall$/.test(String(toolUseBlock?.tool_name || ''))) {
118
+ recordRecalled(storeDir, toolUseBlock?.session_id, textChars(toolResponse) / 4);
119
+ return undefined;
120
+ }
106
121
  const arm = sessionArm(storeDir, toolUseBlock?.session_id, holdoutFraction, randomFn);
107
122
  if (arm === 'holdout') {
108
123
  writeState(storeDir, 'holdout');
@@ -128,7 +143,7 @@ export async function buildHookResponse(toolUseBlock, {
128
143
  const summary = summarize(output);
129
144
  const { handle } = await put(output, { summary, tags: [] }, storeDir);
130
145
  writeState(storeDir, 'treated');
131
- const updatedText = `${summary}\n\n(troxy_recall("${handle}") to fetch the full output)`;
146
+ const updatedText = `${summary}\n\n(troxy_recall("${handle}") to fetch the full output; add lines or pattern for just a part)`;
132
147
  recordSaved(storeDir, toolUseBlock?.session_id, (output.length - updatedText.length) / 4);
133
148
  return {
134
149
  hookSpecificOutput: {
@@ -20,3 +20,32 @@ test('troxy_recall returns a clear error for an unknown handle', async () => {
20
20
  assert.equal(result.isError, true);
21
21
  assert.match(result.content[0].text, /not found/i);
22
22
  });
23
+
24
+ test('troxy_recall returns only the lines asked for, with line numbers', async () => {
25
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-sandbox-'));
26
+ const { handle } = await put(Array.from({ length: 50 }, (_, i) => `row ${i + 1}`).join('\n'), { summary: 'x', tags: [] }, dir);
27
+ const r = await handleRecall({ handle, lines: '10-12' }, dir);
28
+ assert.equal(r.isError, false);
29
+ assert.equal(r.content[0].text, '(50 lines in total, line numbers added)\n10: row 10\n11: row 11\n12: row 12');
30
+ assert.match((await handleRecall({ handle, lines: '49' }, dir)).content[0].text, /49: row 49$/);
31
+ assert.equal((await handleRecall({ handle, lines: 'abc' }, dir)).isError, true);
32
+ assert.equal((await handleRecall({ handle, lines: '60-70' }, dir)).isError, true);
33
+ });
34
+
35
+ test('troxy_recall finds a pattern with context, inside a range, and says when nothing matches', async () => {
36
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-sandbox-'));
37
+ const text = Array.from({ length: 30 }, (_, i) => (i === 9 || i === 24 ? `FAILED test ${i + 1}` : `ok ${i + 1}`)).join('\n');
38
+ const { handle } = await put(text, { summary: 'x', tags: [] }, dir);
39
+ const out = (await handleRecall({ handle, pattern: 'failed', context: 1 }, dir)).content[0].text;
40
+ assert.match(out, /9: ok 9\n10: FAILED test 10\n11: ok 11\n24: ok 24\n25: FAILED test 25\n26: ok 26$/);
41
+ const inRange = (await handleRecall({ handle, pattern: 'failed', context: 0, lines: '1-15' }, dir)).content[0].text;
42
+ assert.doesNotMatch(inRange, /FAILED test 25/);
43
+ assert.match((await handleRecall({ handle, pattern: 'nope' }, dir)).content[0].text, /No line contains "nope"/);
44
+ });
45
+
46
+ test('troxy_recall caps a pattern result at 200 lines and says so', async () => {
47
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-sandbox-'));
48
+ const { handle } = await put(Array.from({ length: 500 }, () => 'hit').join('\n'), { summary: 'x', tags: [] }, dir);
49
+ const out = (await handleRecall({ handle, pattern: 'hit' }, dir)).content[0].text;
50
+ assert.match(out, /first 200 of 500 lines shown/);
51
+ });
@@ -460,3 +460,17 @@ test('compressedSince reports each compression once, per session', async () => {
460
460
  assert.equal(await compressedSince({ ...$, fs: { read: async () => { throw new Error('no file'); } } }, 'dddddddd-1111-2222'), 0);
461
461
  assert.equal(await compressedSince($, '../etc/passwd'), 0);
462
462
  });
463
+
464
+ test('recalledSince reports fetched-back tokens once, and reportRow carries them', async () => {
465
+ const { recalledSince, reportRow } = _internal;
466
+ const sid = 'ffffffff-1111-2222-3333-444444444444';
467
+ let log = '2000\n';
468
+ const $ = { env: { get: async () => '/home/u' }, fs: { read: async (p) => { assert.ok(p.endsWith(`/.troxy/sandbox/recalled/${sid}.log`)); return log; } } };
469
+ assert.equal(await recalledSince($, sid), 2000);
470
+ assert.equal(await recalledSince($, sid), 0);
471
+ log += '100\n';
472
+ assert.equal(await recalledSince($, sid), 100);
473
+ const usage = { model: 'claude-sonnet-5-5', input_tokens: 1, output_tokens: 1, cache_read_input_tokens: 1, cache_creation_input_tokens: 1 };
474
+ assert.equal(reportRow({ sid: 's1', usage, recalled: 100 }).recalled_tokens, 100);
475
+ assert.equal(reportRow({ sid: 's1', usage }).recalled_tokens, null);
476
+ });
@@ -243,3 +243,15 @@ test('no session id or a small output records nothing', async () => {
243
243
  await buildHookResponse({ session_id: 'bbbbbbbb-1111-2222-3333-444444444444', tool_response: bashResult('short') }, opts);
244
244
  assert.equal(fs.existsSync(path.join(dir, 'saved')), false);
245
245
  });
246
+
247
+ test('the hook records the tokens fetched back with troxy_recall, per session, and leaves the result alone', async () => {
248
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-recalled-'));
249
+ const sid = 'eeeeeeee-1111-2222-3333-444444444444';
250
+ const out = await buildHookResponse({ tool_name: 'mcp__troxy__troxy_recall', session_id: sid, tool_response: [{ type: 'text', text: 'x'.repeat(8000) }] }, { storeDir: dir });
251
+ assert.equal(out, undefined);
252
+ assert.deepEqual(fs.readFileSync(path.join(dir, 'recalled', `${sid}.log`), 'utf8').trim().split('\n').map(Number), [2000]);
253
+ await buildHookResponse({ tool_name: 'mcp__troxy__troxy_recall', session_id: sid, tool_response: { content: [{ type: 'text', text: 'y'.repeat(400) }] } }, { storeDir: dir });
254
+ assert.deepEqual(fs.readFileSync(path.join(dir, 'recalled', `${sid}.log`), 'utf8').trim().split('\n').map(Number), [2000, 100]);
255
+ await buildHookResponse({ tool_name: 'mcp__troxy__troxy_recall', tool_response: [{ type: 'text', text: 'z'.repeat(800) }] }, { storeDir: dir });
256
+ assert.equal(fs.readdirSync(path.join(dir, 'recalled')).length, 1);
257
+ });