troxy-cli 1.19.0 → 1.20.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.19.0",
3
+ "version": "1.20.0",
4
4
  "description": "Control layer for AI agents: check payments, emails, logins and destructive actions against your policies",
5
5
  "homepage": "https://troxy.io",
6
6
  "bugs": {
@@ -15,6 +15,7 @@ import { api } from './api.js';
15
15
  // happening here than the conversation that just finished.
16
16
 
17
17
  const REPORT_TIMEOUT_MS = 2500;
18
+ const MAX_EXCERPT_LEN = 4000; // matches classify.py's MAX_EXCERPT_LEN
18
19
 
19
20
  // Fields this file is allowed to touch from a transcript entry. Deliberately
20
21
  // narrow: extractUsage() below only ever reads these keys off a transcript
@@ -44,6 +45,24 @@ export function extractUsage(entry) {
44
45
  };
45
46
  }
46
47
 
48
+ // A second, equally narrow allowlist function, same discipline as
49
+ // extractUsage above: this is the ONLY place in the whole hook that reads
50
+ // message text at all, and it exists on purpose for the transient
51
+ // classifier (classify.py on the backend) - the excerpt it returns is sent
52
+ // once, classified once, and never stored anywhere, on either side of the
53
+ // wire. Everywhere else in this file continues to only ever touch usage
54
+ // numbers. Returns the LAST text block of an entry, since a single
55
+ // assistant turn can carry several content blocks (thinking, tool_use,
56
+ // text) and only the final text block is what the user actually saw as
57
+ // the reply.
58
+ export function extractReplyText(entry) {
59
+ if (!entry || entry.type !== 'assistant') return null;
60
+ const content = entry.message?.content;
61
+ if (!Array.isArray(content)) return null;
62
+ const textBlock = content.find(b => b?.type === 'text' && typeof b.text === 'string');
63
+ return textBlock ? textBlock.text : null;
64
+ }
65
+
47
66
  // Claude Code writes one JSONL line per content block (thinking/tool_use/
48
67
  // text), not one per turn or even one per model call - a single real API
49
68
  // response is split across several consecutive lines that all share the
@@ -99,7 +118,19 @@ export function usageForCurrentTurn(transcriptPath) {
99
118
  // genuinely different turns.
100
119
  const turnKey = Array.from(seen.keys()).sort().join(',').slice(0, 200);
101
120
 
102
- return { tokens: totalTokens, model: lastModel, turnKey };
121
+ // turnLines is most-recent-first (built by walking the transcript
122
+ // backward), so the first entry with a text block is the final reply the
123
+ // user actually saw - not an earlier "thinking" block from mid-turn tool
124
+ // use. Truncated client-side too so an oversized excerpt never leaves
125
+ // this machine in the first place, not just rejected on arrival.
126
+ let replyText = null;
127
+ for (const entry of turnLines) {
128
+ replyText = extractReplyText(entry);
129
+ if (replyText) break;
130
+ }
131
+ const contentExcerpt = replyText ? replyText.slice(0, MAX_EXCERPT_LEN) : null;
132
+
133
+ return { tokens: totalTokens, model: lastModel, turnKey, contentExcerpt };
103
134
  }
104
135
 
105
136
  function withTimeout(promise, ms) {
@@ -147,11 +178,15 @@ export async function runHookReport() {
147
178
  const sessionId = payload?.session_id || '';
148
179
  const turnKey = sessionId ? `${sessionId}:${usage.turnKey}` : null;
149
180
 
181
+ const body = { model: usage.model, actual_tokens: usage.tokens, turn_key: turnKey };
182
+ // content_excerpt is optional and additive - the backend classifies it
183
+ // if present, samples/rate-limits on its own, and silently skips when
184
+ // it isn't. Sending it is not required for the usage report itself to
185
+ // succeed.
186
+ if (usage.contentExcerpt) body.content_excerpt = usage.contentExcerpt;
187
+
150
188
  await withTimeout(
151
- api.reportVerifiedUsage(
152
- { model: usage.model, actual_tokens: usage.tokens, turn_key: turnKey },
153
- apiKey,
154
- ),
189
+ api.reportVerifiedUsage(body, apiKey),
155
190
  REPORT_TIMEOUT_MS,
156
191
  );
157
192
  } catch {
@@ -16,7 +16,7 @@ import fs from 'node:fs';
16
16
  import os from 'node:os';
17
17
  import path from 'node:path';
18
18
 
19
- import { usageForCurrentTurn, extractUsage } from '../hook-report.js';
19
+ import { usageForCurrentTurn, extractUsage, extractReplyText } from '../hook-report.js';
20
20
 
21
21
  let dir;
22
22
  beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-transcript-')); });
@@ -34,6 +34,10 @@ function assistantLine(messageId, usage, model = 'claude-sonnet-5') {
34
34
  return { type: 'assistant', message: { id: messageId, model, usage } };
35
35
  }
36
36
 
37
+ function assistantLineWithText(messageId, usage, text, model = 'claude-sonnet-5') {
38
+ return { type: 'assistant', message: { id: messageId, model, usage, content: [{ type: 'text', text }] } };
39
+ }
40
+
37
41
  const USAGE_A = { input_tokens: 2, output_tokens: 1176, cache_creation_input_tokens: 4134, cache_read_input_tokens: 244103 };
38
42
 
39
43
  describe('extractUsage', () => {
@@ -137,3 +141,64 @@ describe('usageForCurrentTurn', () => {
137
141
  assert.doesNotThrow(() => usageForCurrentTurn(transcriptPath()));
138
142
  });
139
143
  });
144
+
145
+ describe('extractReplyText', () => {
146
+ it('reads the text block from an assistant entry', () => {
147
+ const entry = assistantLineWithText('msg_1', { input_tokens: 1, output_tokens: 1 }, 'hello there');
148
+ assert.equal(extractReplyText(entry), 'hello there');
149
+ });
150
+
151
+ it('returns null for a non-assistant entry', () => {
152
+ assert.equal(extractReplyText({ type: 'user', message: { content: [{ type: 'text', text: 'hi' }] } }), null);
153
+ });
154
+
155
+ it('returns null when there is no text block (e.g. a tool_use-only entry)', () => {
156
+ const entry = { type: 'assistant', message: { content: [{ type: 'tool_use', name: 'Read' }] } };
157
+ assert.equal(extractReplyText(entry), null);
158
+ });
159
+
160
+ it('returns null when content is missing entirely', () => {
161
+ assert.equal(extractReplyText({ type: 'assistant', message: {} }), null);
162
+ });
163
+ });
164
+
165
+ describe('usageForCurrentTurn: content excerpt', () => {
166
+ it('returns the final text reply as contentExcerpt', () => {
167
+ writeLines([
168
+ { type: 'user', message: { content: [{ type: 'text', text: 'hi' }] } },
169
+ assistantLineWithText('msg_1', { input_tokens: 1, output_tokens: 1 }, 'the actual reply'),
170
+ ]);
171
+ const result = usageForCurrentTurn(transcriptPath());
172
+ assert.equal(result.contentExcerpt, 'the actual reply');
173
+ });
174
+
175
+ it('picks the LAST text block when the turn has several (e.g. thinking then a tool call then a final reply)', () => {
176
+ writeLines([
177
+ { type: 'user', message: { content: [{ type: 'text', text: 'hi' }] } },
178
+ assistantLineWithText('msg_1', { input_tokens: 1, output_tokens: 1 }, 'intermediate note'),
179
+ { type: 'assistant', message: { id: 'msg_1', usage: { input_tokens: 1, output_tokens: 1 }, content: [{ type: 'tool_use', name: 'Read' }] } },
180
+ assistantLineWithText('msg_2', { input_tokens: 1, output_tokens: 1 }, 'final reply to the user'),
181
+ ]);
182
+ const result = usageForCurrentTurn(transcriptPath());
183
+ assert.equal(result.contentExcerpt, 'final reply to the user');
184
+ });
185
+
186
+ it('is null when the turn produced no text block at all', () => {
187
+ writeLines([
188
+ { type: 'user', message: { content: [{ type: 'text', text: 'hi' }] } },
189
+ assistantLine('msg_1', { input_tokens: 1, output_tokens: 1 }),
190
+ ]);
191
+ const result = usageForCurrentTurn(transcriptPath());
192
+ assert.equal(result.contentExcerpt, null);
193
+ });
194
+
195
+ it('truncates an oversized reply before it ever leaves usageForCurrentTurn', () => {
196
+ const longText = 'x'.repeat(10000);
197
+ writeLines([
198
+ { type: 'user', message: { content: [{ type: 'text', text: 'hi' }] } },
199
+ assistantLineWithText('msg_1', { input_tokens: 1, output_tokens: 1 }, longText),
200
+ ]);
201
+ const result = usageForCurrentTurn(transcriptPath());
202
+ assert.equal(result.contentExcerpt.length, 4000);
203
+ });
204
+ });