mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-06-10 10:13:49 +08:00
feat: add ecc-metrics-bridge PostToolUse hook
Maintains a running session aggregate in /tmp/ecc-metrics-{session}.json
with cost, tool count, files modified, and recent tool ring buffer for
loop detection. Bridge file is read by ecc-statusline and ecc-context-monitor.
This commit is contained in:
185
scripts/hooks/ecc-metrics-bridge.js
Normal file
185
scripts/hooks/ecc-metrics-bridge.js
Normal file
@@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* ECC Metrics Bridge — PostToolUse hook
|
||||
*
|
||||
* Maintains a running session aggregate in /tmp/ecc-metrics-{session}.json.
|
||||
* This bridge file is read by ecc-statusline.js and ecc-context-monitor.js,
|
||||
* avoiding the need to scan large JSONL logs on every invocation.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { estimateCost } = require('../lib/cost-estimate');
|
||||
const { sanitizeSessionId, readBridge, writeBridgeAtomic } = require('../lib/session-bridge');
|
||||
const { getClaudeDir } = require('../lib/utils');
|
||||
|
||||
const MAX_STDIN = 1024 * 1024;
|
||||
const MAX_FILES_TRACKED = 200;
|
||||
const RECENT_TOOLS_SIZE = 5;
|
||||
|
||||
function toNumber(value) {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash tool call for loop detection.
|
||||
* Uses tool name + a key parameter (file_path for Edit/Write, first 80 chars of command for Bash).
|
||||
*/
|
||||
function hashToolCall(toolName, toolInput) {
|
||||
const name = String(toolName || '');
|
||||
let key = '';
|
||||
if (name === 'Bash') {
|
||||
key = String(toolInput?.command || '').slice(0, 80);
|
||||
} else {
|
||||
key = String(toolInput?.file_path || '');
|
||||
}
|
||||
return crypto.createHash('sha256').update(`${name}:${key}`).digest('hex').slice(0, 8);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract modified file paths from tool input.
|
||||
*/
|
||||
function extractFilePaths(toolName, toolInput) {
|
||||
const paths = [];
|
||||
if (!toolInput || typeof toolInput !== 'object') return paths;
|
||||
|
||||
const fp = toolInput.file_path;
|
||||
if (fp && typeof fp === 'string') paths.push(fp);
|
||||
|
||||
const edits = toolInput.edits;
|
||||
if (Array.isArray(edits)) {
|
||||
for (const edit of edits) {
|
||||
if (edit?.file_path && typeof edit.file_path === 'string') {
|
||||
paths.push(edit.file_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return paths;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read cumulative cost for a session from the tail of costs.jsonl.
|
||||
* Reads last 8KB to avoid scanning entire file.
|
||||
*/
|
||||
function readSessionCost(sessionId) {
|
||||
try {
|
||||
const costsPath = path.join(getClaudeDir(), 'metrics', 'costs.jsonl');
|
||||
const stat = fs.statSync(costsPath);
|
||||
const readSize = Math.min(stat.size, 8192);
|
||||
const fd = fs.openSync(costsPath, 'r');
|
||||
try {
|
||||
const buf = Buffer.alloc(readSize);
|
||||
fs.readSync(fd, buf, 0, readSize, Math.max(0, stat.size - readSize));
|
||||
const lines = buf.toString('utf8').split('\n').filter(Boolean);
|
||||
|
||||
let totalCost = 0;
|
||||
let totalIn = 0;
|
||||
let totalOut = 0;
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const row = JSON.parse(line);
|
||||
if (row.session_id === sessionId || row.session_id === 'default') {
|
||||
totalCost += toNumber(row.estimated_cost_usd);
|
||||
totalIn += toNumber(row.input_tokens);
|
||||
totalOut += toNumber(row.output_tokens);
|
||||
}
|
||||
} catch {
|
||||
/* skip malformed lines */
|
||||
}
|
||||
}
|
||||
return { totalCost, totalIn, totalOut };
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
} catch {
|
||||
return { totalCost: 0, totalIn: 0, totalOut: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} rawInput - Raw JSON string from stdin
|
||||
* @returns {string} Pass-through
|
||||
*/
|
||||
function run(rawInput) {
|
||||
try {
|
||||
const input = rawInput.trim() ? JSON.parse(rawInput) : {};
|
||||
const toolName = String(input.tool_name || '');
|
||||
const toolInput = input.tool_input || {};
|
||||
|
||||
const sessionId = sanitizeSessionId(input.session_id) || sanitizeSessionId(process.env.ECC_SESSION_ID) || sanitizeSessionId(process.env.CLAUDE_SESSION_ID);
|
||||
|
||||
if (!sessionId) return rawInput;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const bridge = readBridge(sessionId) || {
|
||||
session_id: sessionId,
|
||||
total_cost_usd: 0,
|
||||
total_input_tokens: 0,
|
||||
total_output_tokens: 0,
|
||||
tool_count: 0,
|
||||
files_modified_count: 0,
|
||||
files_modified: [],
|
||||
recent_tools: [],
|
||||
first_timestamp: now,
|
||||
last_timestamp: now,
|
||||
context_remaining_pct: null
|
||||
};
|
||||
|
||||
// Increment tool count
|
||||
bridge.tool_count = (bridge.tool_count || 0) + 1;
|
||||
bridge.last_timestamp = now;
|
||||
if (!bridge.first_timestamp) bridge.first_timestamp = now;
|
||||
|
||||
// Track modified files (Write/Edit/MultiEdit only)
|
||||
const isWriteOp = /^(Write|Edit|MultiEdit)$/i.test(toolName);
|
||||
if (isWriteOp) {
|
||||
const newPaths = extractFilePaths(toolName, toolInput);
|
||||
const existing = new Set(bridge.files_modified || []);
|
||||
for (const p of newPaths) {
|
||||
if (existing.size < MAX_FILES_TRACKED && !existing.has(p)) {
|
||||
existing.add(p);
|
||||
}
|
||||
}
|
||||
bridge.files_modified = [...existing];
|
||||
bridge.files_modified_count = existing.size;
|
||||
}
|
||||
|
||||
// Ring buffer for loop detection
|
||||
const recent = bridge.recent_tools || [];
|
||||
recent.push({ tool: toolName, hash: hashToolCall(toolName, toolInput) });
|
||||
if (recent.length > RECENT_TOOLS_SIZE) recent.shift();
|
||||
bridge.recent_tools = recent;
|
||||
|
||||
// Update cost from costs.jsonl tail
|
||||
const envSessionId = String(process.env.ECC_SESSION_ID || process.env.CLAUDE_SESSION_ID || 'default');
|
||||
const costs = readSessionCost(envSessionId);
|
||||
bridge.total_cost_usd = Math.round(costs.totalCost * 1e6) / 1e6;
|
||||
bridge.total_input_tokens = costs.totalIn;
|
||||
bridge.total_output_tokens = costs.totalOut;
|
||||
|
||||
writeBridgeAtomic(sessionId, bridge);
|
||||
} catch {
|
||||
// Never block tool execution
|
||||
}
|
||||
|
||||
return rawInput;
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
let data = '';
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', chunk => {
|
||||
if (data.length < MAX_STDIN) data += chunk.substring(0, MAX_STDIN - data.length);
|
||||
});
|
||||
process.stdin.on('end', () => {
|
||||
process.stdout.write(run(data));
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { run, hashToolCall, extractFilePaths, readSessionCost };
|
||||
166
tests/hooks/ecc-metrics-bridge.test.js
Normal file
166
tests/hooks/ecc-metrics-bridge.test.js
Normal file
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Tests for scripts/hooks/ecc-metrics-bridge.js
|
||||
*
|
||||
* Run with: node tests/hooks/ecc-metrics-bridge.test.js
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
|
||||
const { run, hashToolCall, extractFilePaths, readSessionCost } = require('../../scripts/hooks/ecc-metrics-bridge');
|
||||
|
||||
// Test helper
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` \u2713 ${name}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.log(` \u2717 ${name}`);
|
||||
console.log(` Error: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log('\n=== Testing ecc-metrics-bridge.js ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
// hashToolCall tests
|
||||
console.log('hashToolCall:');
|
||||
|
||||
if (
|
||||
test('returns 8-char hex string', () => {
|
||||
const hash = hashToolCall('Bash', { command: 'ls' });
|
||||
assert.strictEqual(hash.length, 8);
|
||||
assert.ok(/^[0-9a-f]{8}$/.test(hash), `Expected hex, got: ${hash}`);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('different Bash commands produce different hashes', () => {
|
||||
const h1 = hashToolCall('Bash', { command: 'ls' });
|
||||
const h2 = hashToolCall('Bash', { command: 'pwd' });
|
||||
assert.notStrictEqual(h1, h2);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('different Edit file_paths produce different hashes', () => {
|
||||
const h1 = hashToolCall('Edit', { file_path: 'a.js' });
|
||||
const h2 = hashToolCall('Edit', { file_path: 'b.js' });
|
||||
assert.notStrictEqual(h1, h2);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('same inputs produce same hash (deterministic)', () => {
|
||||
const h1 = hashToolCall('Write', { file_path: 'x.txt' });
|
||||
const h2 = hashToolCall('Write', { file_path: 'x.txt' });
|
||||
assert.strictEqual(h1, h2);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
// extractFilePaths tests
|
||||
console.log('\nextractFilePaths:');
|
||||
|
||||
if (
|
||||
test('Edit with file_path returns [file_path]', () => {
|
||||
const paths = extractFilePaths('Edit', { file_path: 'a.js' });
|
||||
assert.deepStrictEqual(paths, ['a.js']);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('MultiEdit with edits array returns all file_paths', () => {
|
||||
const paths = extractFilePaths('MultiEdit', {
|
||||
edits: [{ file_path: 'a.js' }, { file_path: 'b.js' }]
|
||||
});
|
||||
assert.deepStrictEqual(paths, ['a.js', 'b.js']);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('Bash with command returns empty array', () => {
|
||||
const paths = extractFilePaths('Bash', { command: 'ls' });
|
||||
assert.deepStrictEqual(paths, []);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('null toolInput returns empty array', () => {
|
||||
const paths = extractFilePaths('Edit', null);
|
||||
assert.deepStrictEqual(paths, []);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
// readSessionCost tests
|
||||
console.log('\nreadSessionCost:');
|
||||
|
||||
if (
|
||||
test('nonexistent session returns object with numeric fields', () => {
|
||||
const result = readSessionCost('nonexistent-session-cost-test-xyz-999');
|
||||
assert.strictEqual(typeof result.totalCost, 'number');
|
||||
assert.strictEqual(typeof result.totalIn, 'number');
|
||||
assert.strictEqual(typeof result.totalOut, 'number');
|
||||
assert.ok(result.totalCost >= 0, 'totalCost should be non-negative');
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
// run tests
|
||||
console.log('\nrun:');
|
||||
|
||||
if (
|
||||
test('empty input returns empty input without crashing', () => {
|
||||
const result = run('');
|
||||
assert.strictEqual(result, '');
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('whitespace-only input returns input unchanged', () => {
|
||||
const result = run(' ');
|
||||
assert.strictEqual(result, ' ');
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('input without session_id returns input unchanged', () => {
|
||||
const input = JSON.stringify({ tool_name: 'Bash', tool_input: { command: 'ls' } });
|
||||
const result = run(input);
|
||||
assert.strictEqual(result, input);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
// Summary
|
||||
console.log(`\nResults: ${passed} passed, ${failed} failed\n`);
|
||||
return { passed, failed };
|
||||
}
|
||||
|
||||
const { failed } = runTests();
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
Reference in New Issue
Block a user