everything-claude-code/scripts/hooks/ecc-metrics-bridge.js
Jamkris 4f21ed2acf fix(hooks): use last cumulative row for session cost in metrics bridge
`ecc-metrics-bridge.js#readSessionCost` summed the
`estimated_cost_usd`, `input_tokens`, and `output_tokens` of
every matching row in `~/.claude/metrics/costs.jsonl`. That breaks
the documented contract of `scripts/hooks/cost-tracker.js`, which
explicitly states (in its module docblock):

  Cumulative behavior: Stop fires per assistant response, not
  per session. Each row therefore represents the cumulative
  session total up to that point. To get per-session cost, take
  the last row per session_id.

Summing N cumulative rows over-counts by roughly (N+1)/2 ×. For a
session with 3 rows at 0.01, 0.02, 0.03 USD (true running total
0.03), the bridge today reports 0.06 USD. The over-counted value
feeds `ecc-context-monitor.js`, which then trips its
COST_NOTICE_USD / COST_WARNING_USD / COST_CRITICAL_USD thresholds
on phantom spend AND injects the inflated number as
`additionalContext` into the live model turn — so the agent
itself is told a wrong cost.

Reproduced on `main` before this commit:

  $ cat > /tmp/eccc/.claude/metrics/costs.jsonl <<EOF
  {"session_id":"S1","estimated_cost_usd":0.01,"input_tokens":333,"output_tokens":166}
  {"session_id":"S1","estimated_cost_usd":0.02,"input_tokens":666,"output_tokens":333}
  {"session_id":"S1","estimated_cost_usd":0.03,"input_tokens":1000,"output_tokens":500}
  EOF

  $ HOME=/tmp/eccc node -e 'const m = require("./scripts/hooks/ecc-metrics-bridge.js"); \
      console.log(JSON.stringify(m.readSessionCost("S1")))'
  {"totalCost":0.06,"totalIn":1999,"totalOut":999}

Expected: `{"totalCost":0.03,"totalIn":1000,"totalOut":500}` (the
last cumulative row).
Actual: 2× over-count.

Fix: replace `+=` with `=` in the matching branch so the assigned
values reflect the most recent row encountered. The iteration
order is file order, which is also event time order, so the last
assignment wins — exactly the contract cost-tracker writes
against.

After this commit the reproduction above returns
`{"totalCost":0.03,"totalIn":1000,"totalOut":500}`.

Regression test in `tests/hooks/ecc-metrics-bridge.test.js`:
`readSessionCost returns the LAST cumulative row, not the sum
(cost-tracker contract)`. The existing
`readSessionCost does not include unrelated default-session rows`
test happened to pass even with the bug because it only had one
target-session row — single-row sessions are coincidentally
correct under both formulas. The new test uses three rows so the
two formulas diverge.

A second issue in the same function — the 8 KiB tail-only read
silently drops older rows once a session's recent cumulative
totals scroll past that window — is fixed in the next commit.
2026-05-17 21:41:24 -04:00

206 lines
6.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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 { 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;
const HASH_INPUT_LIMIT = 2048;
function toNumber(value) {
const n = Number(value);
return Number.isFinite(n) ? n : 0;
}
function stableStringify(value, depth = 0) {
if (depth > 4) return '[depth-limit]';
if (value === null || typeof value !== 'object') return JSON.stringify(value);
if (Array.isArray(value)) {
return `[${value.map(item => stableStringify(item, depth + 1)).join(',')}]`;
}
return `{${Object.keys(value)
.sort()
.map(key => `${JSON.stringify(key)}:${stableStringify(value[key], depth + 1)}`)
.join(',')}}`;
}
/**
* Hash tool call for loop detection.
* Uses tool name + a key parameter when available, otherwise a stable input digest.
*/
function hashToolCall(toolName, toolInput) {
const name = String(toolName || '');
let key = '';
if (name === 'Bash') {
key = String(toolInput?.command || '').slice(0, 160);
} else if (toolInput?.file_path) {
key = String(toolInput.file_path);
} else {
key = stableStringify(toolInput || {}).slice(0, HASH_INPUT_LIMIT);
}
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);
// Each row in costs.jsonl is *already* a cumulative session total — see
// scripts/hooks/cost-tracker.js: "Each row therefore represents the
// cumulative session total up to that point. To get per-session cost,
// take the last row per session_id." Summing every matching row
// therefore double-counts: for N rows of the same session it over-
// reports by roughly N(N+1)/2 / N = (N+1)/2 ×. Take the last matching
// row instead.
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) {
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 costs = readSessionCost(sessionId);
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, stableStringify };