From 0f0efd7d7c35a0d1467123338b16599325a9d421 Mon Sep 17 00:00:00 2001 From: ulinzeng Date: Mon, 20 Apr 2026 15:15:20 +0800 Subject: [PATCH] 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. --- scripts/hooks/ecc-metrics-bridge.js | 185 +++++++++++++++++++++++++ tests/hooks/ecc-metrics-bridge.test.js | 166 ++++++++++++++++++++++ 2 files changed, 351 insertions(+) create mode 100644 scripts/hooks/ecc-metrics-bridge.js create mode 100644 tests/hooks/ecc-metrics-bridge.test.js diff --git a/scripts/hooks/ecc-metrics-bridge.js b/scripts/hooks/ecc-metrics-bridge.js new file mode 100644 index 00000000..3ba3f81e --- /dev/null +++ b/scripts/hooks/ecc-metrics-bridge.js @@ -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 }; diff --git a/tests/hooks/ecc-metrics-bridge.test.js b/tests/hooks/ecc-metrics-bridge.test.js new file mode 100644 index 00000000..fc200099 --- /dev/null +++ b/tests/hooks/ecc-metrics-bridge.test.js @@ -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);