troxy-cli 1.30.5 → 1.30.7
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 +0 -7
- package/package.json +1 -1
- package/src/init.js +14 -9
- package/src/mcp-server.js +45 -3
- package/src/mods/troxy-guard/hooks/register.js +12 -7
- package/src/sandbox/posttooluse-hook.js +18 -3
- package/src/tests/claude-code-hook.test.js +12 -0
- package/src/tests/mcp-recall.test.js +29 -0
- package/src/tests/mods-troxy-guard.test.js +14 -0
- package/src/tests/posttooluse-hook.test.js +12 -0
- package/src/install-mod.js +0 -50
- package/src/usage-bar.js +0 -25
package/bin/troxy.js
CHANGED
|
@@ -284,13 +284,6 @@ switch (command) {
|
|
|
284
284
|
break;
|
|
285
285
|
}
|
|
286
286
|
|
|
287
|
-
// usage-bar: Show live % savings in Claude UI
|
|
288
|
-
case 'usage-bar': {
|
|
289
|
-
const { showUsageBar } = await import('../src/usage-bar.js');
|
|
290
|
-
await showUsageBar();
|
|
291
|
-
break;
|
|
292
|
-
}
|
|
293
|
-
|
|
294
287
|
// ── Heartbeat daemon (background service) ─────────────────────
|
|
295
288
|
case 'daemon': {
|
|
296
289
|
const { runDaemon } = await import('../src/daemon.js');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "troxy-cli",
|
|
3
|
-
"version": "1.30.
|
|
3
|
+
"version": "1.30.7",
|
|
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/init.js
CHANGED
|
@@ -570,14 +570,6 @@ export async function runInit({ key, name, proxy, concise } = {}) {
|
|
|
570
570
|
// Tool detection is a bonus, never a blocker for a successful init.
|
|
571
571
|
}
|
|
572
572
|
|
|
573
|
-
// Install Troxy usage bar MOD into Claude Code (CLI + desktop)
|
|
574
|
-
try {
|
|
575
|
-
const { installTroxySavingsModToClaudeCode } = await import('./install-mod.js');
|
|
576
|
-
await installTroxySavingsModToClaudeCode();
|
|
577
|
-
} catch {
|
|
578
|
-
// MOD installation is optional - init succeeds even if it fails
|
|
579
|
-
}
|
|
580
|
-
|
|
581
573
|
console.log('\n Troxy is on. Restart Claude Code, then work as usual.');
|
|
582
574
|
console.log(' Your savings → https://dash.troxy.io (Usage and Savings)');
|
|
583
575
|
console.log('\n For more information, visit https://docs.troxy.io\n');
|
|
@@ -906,6 +898,19 @@ export function patchClaudeCodeHooks(
|
|
|
906
898
|
if (postIdx >= 0) config.hooks.PostToolUse[postIdx] = postToolUseEntry;
|
|
907
899
|
else config.hooks.PostToolUse.push(postToolUseEntry);
|
|
908
900
|
|
|
901
|
+
return stripLegacyUsageBarHooks(config);
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// Older installs added Notification/MessageDisplay hooks that ran `troxy usage-bar`,
|
|
905
|
+
// which printed placeholder numbers. Remove them (and only them) on every patch and unpatch.
|
|
906
|
+
export function stripLegacyUsageBarHooks(config) {
|
|
907
|
+
for (const event of ['Notification', 'MessageDisplay']) {
|
|
908
|
+
if (!Array.isArray(config.hooks?.[event])) continue;
|
|
909
|
+
config.hooks[event] = config.hooks[event]
|
|
910
|
+
.map(e => ({ ...e, hooks: (e.hooks || []).filter(h => !/\btroxy usage-bar\s*$/.test(h.command || '')) }))
|
|
911
|
+
.filter(e => e.hooks.length);
|
|
912
|
+
if (!config.hooks[event].length) delete config.hooks[event];
|
|
913
|
+
}
|
|
909
914
|
return config;
|
|
910
915
|
}
|
|
911
916
|
|
|
@@ -922,7 +927,7 @@ export function unpatchClaudeCodeHooks(config) {
|
|
|
922
927
|
if (Array.isArray(config.hooks?.PostToolUse)) {
|
|
923
928
|
config.hooks.PostToolUse = config.hooks.PostToolUse.filter(e => !_isTroxyPostToolUseEntry(e));
|
|
924
929
|
}
|
|
925
|
-
return config;
|
|
930
|
+
return stripLegacyUsageBarHooks(config);
|
|
926
931
|
}
|
|
927
932
|
|
|
928
933
|
// File-based wrappers (for call sites that operate directly on file paths,
|
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
|
-
|
|
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
|
|
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
|
},
|
|
@@ -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
|
|
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
|
|
261
|
-
|
|
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 };
|
|
@@ -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
|
-
|
|
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,
|
|
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: {
|
|
@@ -271,3 +271,15 @@ describe('unpatchClaudeCodeHooks', () => {
|
|
|
271
271
|
assert.doesNotThrow(() => unpatchClaudeCodeHooks(cfg));
|
|
272
272
|
});
|
|
273
273
|
});
|
|
274
|
+
|
|
275
|
+
describe('stripLegacyUsageBarHooks', () => {
|
|
276
|
+
it('removes only the old placeholder usage-bar hooks, on patch and unpatch', () => {
|
|
277
|
+
const legacy = { matcher: '*', hooks: [{ type: 'command', command: '/opt/homebrew/bin/troxy usage-bar', timeout: 1 }] };
|
|
278
|
+
const mine = { matcher: '*', hooks: [{ type: 'command', command: 'echo mine' }] };
|
|
279
|
+
const cfg = () => ({ hooks: { Notification: [legacy, mine], MessageDisplay: [legacy] } });
|
|
280
|
+
for (const out of [patchClaudeCodeHooks(cfg(), {}, 'troxy hook-report'), unpatchClaudeCodeHooks(cfg())]) {
|
|
281
|
+
assert.deepEqual(out.hooks.Notification, [mine]);
|
|
282
|
+
assert.equal(out.hooks.MessageDisplay, undefined);
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
});
|
|
@@ -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
|
+
});
|
package/src/install-mod.js
DELETED
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
import fs from 'fs';
|
|
2
|
-
import path from 'path';
|
|
3
|
-
import os from 'os';
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Install Troxy policy MOD into Claude Code
|
|
7
|
-
* Adds/updates hooks to show live usage bar with % context saved
|
|
8
|
-
*/
|
|
9
|
-
export async function installTroxySavingsModToClaudeCode() {
|
|
10
|
-
const claudeHome = path.join(os.homedir(), '.claude');
|
|
11
|
-
const settingsPath = path.join(claudeHome, 'settings.json');
|
|
12
|
-
|
|
13
|
-
if (!fs.existsSync(settingsPath)) {
|
|
14
|
-
console.error(' Error: Claude Code not found. Install Claude Code first.\n');
|
|
15
|
-
return false;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
try {
|
|
19
|
-
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
20
|
-
|
|
21
|
-
// Add Notification hook to show usage bar after each response
|
|
22
|
-
if (!settings.hooks) settings.hooks = {};
|
|
23
|
-
if (!settings.hooks.Notification) settings.hooks.Notification = [];
|
|
24
|
-
|
|
25
|
-
// Check if already installed
|
|
26
|
-
const alreadyInstalled = settings.hooks.Notification.some(h =>
|
|
27
|
-
h.hooks?.[0]?.command?.includes('troxy usage-bar')
|
|
28
|
-
);
|
|
29
|
-
|
|
30
|
-
if (!alreadyInstalled) {
|
|
31
|
-
settings.hooks.Notification.push({
|
|
32
|
-
matcher: '*',
|
|
33
|
-
hooks: [
|
|
34
|
-
{
|
|
35
|
-
type: 'command',
|
|
36
|
-
command: '/opt/homebrew/bin/troxy usage-bar',
|
|
37
|
-
timeout: 1
|
|
38
|
-
}
|
|
39
|
-
]
|
|
40
|
-
});
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
|
44
|
-
console.log(' ✓ Troxy usage bar MOD installed');
|
|
45
|
-
return true;
|
|
46
|
-
} catch (err) {
|
|
47
|
-
console.error(` Error installing MOD: ${err.message}`);
|
|
48
|
-
return false;
|
|
49
|
-
}
|
|
50
|
-
}
|
package/src/usage-bar.js
DELETED
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Show live usage bar with % context saved
|
|
3
|
-
* Displays in Claude UI as AbovePrompt band
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
export async function showUsageBar() {
|
|
7
|
-
// Read current session usage from Claude's session context
|
|
8
|
-
// Format for AbovePrompt hook output: simple text that Claude displays
|
|
9
|
-
|
|
10
|
-
// Placeholder data - would be fetched from real session in production
|
|
11
|
-
const contextUsed = 45; // %
|
|
12
|
-
const saved = 15; // % saved by Troxy routing/trimming
|
|
13
|
-
|
|
14
|
-
const barLength = 20;
|
|
15
|
-
const filledLength = Math.round((contextUsed / 100) * barLength);
|
|
16
|
-
const emptyLength = barLength - filledLength;
|
|
17
|
-
|
|
18
|
-
const bar = '█'.repeat(filledLength) + '░'.repeat(emptyLength);
|
|
19
|
-
|
|
20
|
-
// Output format for Claude's hook system
|
|
21
|
-
// This gets displayed in the AbovePrompt area
|
|
22
|
-
const output = `[${bar}] ${contextUsed}% used · ${saved}% saved by Troxy`;
|
|
23
|
-
|
|
24
|
-
console.log(output);
|
|
25
|
-
}
|