troxy-cli 1.27.0 → 1.28.0

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.27.0",
3
+ "version": "1.28.0",
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": {
@@ -42,6 +42,11 @@ export function extractUsage(entry) {
42
42
  // dollar math server-side from the model id; this file only sums what
43
43
  // the transcript says was used.
44
44
  tokens: inputTokens + outputTokens + cacheCreation + cacheRead,
45
+ // Lives on the transcript ENTRY itself, not inside message.usage -
46
+ // confirmed live 2026-09-03 against a real session file (values seen:
47
+ // 'high', 'max'). Whatever string Claude Code actually used, passed
48
+ // through as-is; nothing here assumes a fixed low/medium/high vocabulary.
49
+ effort: entry.effort || null,
45
50
  };
46
51
  }
47
52
 
@@ -94,14 +99,16 @@ export function usageForCurrentTurn(transcriptPath) {
94
99
  turnLines.push(entry);
95
100
  }
96
101
 
97
- const seen = new Map(); // messageId -> {model, tokens}
102
+ const seen = new Map(); // messageId -> {model, tokens, effort}
98
103
  let lastModel = null;
104
+ let lastEffort = null;
99
105
  for (const entry of turnLines) {
100
106
  const usage = extractUsage(entry);
101
107
  if (!usage || !usage.messageId) continue;
102
108
  if (!seen.has(usage.messageId)) {
103
109
  seen.set(usage.messageId, usage);
104
110
  lastModel = lastModel || usage.model;
111
+ lastEffort = lastEffort || usage.effort;
105
112
  }
106
113
  }
107
114
 
@@ -130,7 +137,7 @@ export function usageForCurrentTurn(transcriptPath) {
130
137
  }
131
138
  const contentExcerpt = replyText ? replyText.slice(0, MAX_EXCERPT_LEN) : null;
132
139
 
133
- return { tokens: totalTokens, model: lastModel, turnKey, contentExcerpt };
140
+ return { tokens: totalTokens, model: lastModel, effort: lastEffort, turnKey, contentExcerpt };
134
141
  }
135
142
 
136
143
  function withTimeout(promise, ms) {
package/src/mcp-server.js CHANGED
@@ -336,8 +336,7 @@ export async function runMcp() {
336
336
  },
337
337
  effort: {
338
338
  type: 'string',
339
- enum: ['low', 'medium', 'high'],
340
- description: 'How much work this task warrants. Report it honestly: "high effort on a simple task" is one of the things Troxy flags as waste.',
339
+ description: 'How much work this task warrants - your own real effort/thinking level for this turn (e.g. low, medium, high, or whatever your model calls it). Report it honestly: "high effort on a simple task" is one of the things Troxy flags as waste.',
341
340
  },
342
341
  task: {
343
342
  type: 'string',
@@ -379,7 +378,6 @@ export async function runMcp() {
379
378
  },
380
379
  effort: {
381
380
  type: 'string',
382
- enum: ['low', 'medium', 'high'],
383
381
  description: 'The effort level the task actually took, if it differed from what you declared up front (optional).',
384
382
  },
385
383
  },
@@ -91,8 +91,15 @@ export async function runPreToolUseHook() {
91
91
  const apiKey = process.env.TROXY_API_KEY || config?.apiKey;
92
92
  if (!apiKey) return allow();
93
93
 
94
+ const body = { model: usage.model, task: payload.tool_name || '' };
95
+ // Real effort straight off the transcript entry (see hook-report.js's
96
+ // extractUsage) - whatever Claude Code actually used, not coerced into a
97
+ // guessed low/medium/high. Omitted when the transcript doesn't carry one
98
+ // (an older Claude Code build, or a turn before any assistant reply yet).
99
+ if (usage.effort) body.effort = usage.effort;
100
+
94
101
  const result = await withTimeout(
95
- api.evaluateModel({ model: usage.model, task: payload.tool_name || '' }, apiKey),
102
+ api.evaluateModel(body, apiKey),
96
103
  GATE_TIMEOUT_MS,
97
104
  );
98
105
 
@@ -30,8 +30,10 @@ function writeLines(lines) {
30
30
  fs.writeFileSync(transcriptPath(), lines.map(l => JSON.stringify(l)).join('\n') + '\n');
31
31
  }
32
32
 
33
- function assistantLine(messageId, usage, model = 'claude-sonnet-5') {
34
- return { type: 'assistant', message: { id: messageId, model, usage } };
33
+ function assistantLine(messageId, usage, model = 'claude-sonnet-5', effort = undefined) {
34
+ const line = { type: 'assistant', message: { id: messageId, model, usage } };
35
+ if (effort !== undefined) line.effort = effort;
36
+ return line;
35
37
  }
36
38
 
37
39
  function assistantLineWithText(messageId, usage, text, model = 'claude-sonnet-5') {
@@ -41,9 +43,10 @@ function assistantLineWithText(messageId, usage, text, model = 'claude-sonnet-5'
41
43
  const USAGE_A = { input_tokens: 2, output_tokens: 1176, cache_creation_input_tokens: 4134, cache_read_input_tokens: 244103 };
42
44
 
43
45
  describe('extractUsage', () => {
44
- it('only reads usage/model/id fields, never message.content', () => {
46
+ it('only reads usage/model/id/effort fields, never message.content', () => {
45
47
  const entry = {
46
48
  type: 'assistant',
49
+ effort: 'high',
47
50
  message: {
48
51
  id: 'msg_1',
49
52
  model: 'claude-sonnet-5',
@@ -52,10 +55,19 @@ describe('extractUsage', () => {
52
55
  },
53
56
  };
54
57
  const result = extractUsage(entry);
55
- assert.deepEqual(Object.keys(result).sort(), ['messageId', 'model', 'tokens']);
58
+ assert.deepEqual(Object.keys(result).sort(), ['effort', 'messageId', 'model', 'tokens']);
59
+ assert.equal(result.effort, 'high');
56
60
  assert.ok(!JSON.stringify(result).includes('actual conversation'));
57
61
  });
58
62
 
63
+ it('effort is null when the entry does not carry one (older Claude Code build)', () => {
64
+ const entry = {
65
+ type: 'assistant',
66
+ message: { id: 'msg_1', model: 'claude-sonnet-5', usage: { input_tokens: 10, output_tokens: 5 } },
67
+ };
68
+ assert.equal(extractUsage(entry).effort, null);
69
+ });
70
+
59
71
  it('returns null for a non-assistant line', () => {
60
72
  assert.equal(extractUsage({ type: 'user', message: { usage: { input_tokens: 1 } } }), null);
61
73
  });
@@ -112,6 +124,24 @@ describe('usageForCurrentTurn', () => {
112
124
  assert.equal(result.model, 'claude-opus-4.5');
113
125
  });
114
126
 
127
+ it('returns the effort from the assistant lines in the current turn', () => {
128
+ writeLines([
129
+ { type: 'user', message: { content: [{ type: 'text', text: 'hi' }] } },
130
+ assistantLine('msg_1', { input_tokens: 1, output_tokens: 1 }, 'claude-opus-5', 'max'),
131
+ ]);
132
+ const result = usageForCurrentTurn(transcriptPath());
133
+ assert.equal(result.effort, 'max');
134
+ });
135
+
136
+ it('effort is null, not an error, on a transcript with no effort field (older Claude Code build)', () => {
137
+ writeLines([
138
+ { type: 'user', message: { content: [{ type: 'text', text: 'hi' }] } },
139
+ assistantLine('msg_1', { input_tokens: 1, output_tokens: 1 }),
140
+ ]);
141
+ const result = usageForCurrentTurn(transcriptPath());
142
+ assert.equal(result.effort, null);
143
+ });
144
+
115
145
  it('returns a stable turn_key for the same set of message ids, order-independent', () => {
116
146
  writeLines([
117
147
  { type: 'user', message: { content: [] } },