From b3268fef801814340dbd9307dda792bff79efbda Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 18 Jun 2026 16:49:58 -0400 Subject: [PATCH] fix: resolve four bug reports (#2290, #2282, #2276, #2272) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #2290 suggest-compact: honor ECC_CONTEXT_WINDOW_TOKENS / CLAUDE_CODE_AUTO_COMPACT_WINDOW so 400k-window models (Opus 4.x) no longer report ~double context usage; add override + isolation tests in transcript-context.test.js. - #2282 install: bare-language syntax is legacy-only by design, but the error now distinguishes a supported-but-wrong-mode target (gemini/codex/…) from a genuinely unknown one and points to --profile/--modules/--skills. - #2276 cost-report: the command + cost-tracking skill targeted a SQLite DB no tracker writes. Repoint both at the real ~/.claude/metrics/costs.jsonl (JSONL, estimated_cost_usd), reduce cumulative-per-session snapshots to latest-per-session, and use node instead of sqlite3 for cross-platform support. - #2272 gateguard: make the 'confirm no existing file' checklist item tool-agnostic (Glob/Grep or find/grep via Bash) so hosts without a Glob tool don't get a dead tool call. Full suite 2839/2839; lint green. --- commands/cost-report.md | 148 ++++++------- docs/COMMAND-REGISTRY.json | 2 +- scripts/hooks/gateguard-fact-force.js | 51 ++--- scripts/lib/install-executor.js | 286 ++++++++++++-------------- scripts/lib/transcript-context.js | 29 ++- skills/cost-tracking/SKILL.md | 165 +++++---------- tests/lib/transcript-context.test.js | 68 +++--- 7 files changed, 325 insertions(+), 424 deletions(-) diff --git a/commands/cost-report.md b/commands/cost-report.md index adf2d252..f482b593 100644 --- a/commands/cost-report.md +++ b/commands/cost-report.md @@ -1,107 +1,81 @@ --- -description: Generate a local Claude Code cost report from a cost-tracker SQLite database. +description: Generate a local Claude Code cost report from the ECC cost-tracker metrics log. argument-hint: [csv] --- # Cost Report -Query the local cost-tracking database and present a spending report by day, -project, tool, and session. This command assumes a cost-tracking hook or plugin -is already writing usage rows to `~/.claude-cost-tracker/usage.db`. +Summarize local Claude Code spend by day, model, and session from the metrics +log that ECC's `stop:cost-tracker` hook writes. -## What This Command Does +## Where the data lives -1. Check that `sqlite3` is available. -2. Check that `~/.claude-cost-tracker/usage.db` exists. -3. Run aggregate queries against the `usage` table. -4. Present a compact report, or export recent rows as CSV when the argument is - `csv`. +The tracker appends one JSON object per session-stop to +`~/.claude/metrics/costs.jsonl`. Each row is a **cumulative snapshot for that +session**, so the report takes the **latest row per `session_id`** and sums +across sessions (summing every row would multiply-count). -## Prerequisites +Row schema: +`{ timestamp, session_id, transcript_path, model, input_tokens, output_tokens, cache_write_tokens, cache_read_tokens, estimated_cost_usd }` -The database must be populated by a local cost tracker. If the file is missing, -tell the user the tracker is not set up and suggest installing or enabling a -trusted Claude Code cost-tracking hook/plugin first. +## What this command does + +1. Check that `~/.claude/metrics/costs.jsonl` exists. If it does not, tell the + user the tracker is not set up yet (it populates after the first session ends + with the `stop:cost-tracker` hook enabled). +2. Reduce rows to the latest snapshot per session and aggregate. +3. Present a compact report, or export recent rows as CSV when the argument is `csv`. + +`node` is used instead of `sqlite3`/`jq` so this works identically on macOS, +Linux, and Windows. + +## Report ```bash -test -f ~/.claude-cost-tracker/usage.db && echo "Database found" || echo "Database not found" +node -e ' +const fs=require("fs"),os=require("os"),path=require("path"); +const f=path.join(os.homedir(),".claude","metrics","costs.jsonl"); +if(!fs.existsSync(f)){console.log("Cost tracker not set up: "+f+" not found. Enable the stop:cost-tracker hook and finish a session first.");process.exit(0);} +const rows=fs.readFileSync(f,"utf8").split(/\r?\n/).filter(Boolean).map(l=>{try{return JSON.parse(l)}catch{return null}}).filter(Boolean); +const bySession=new Map(); +for(const r of rows){const k=r.session_id||r.transcript_path||r.timestamp;const p=bySession.get(k);if(!p||String(r.timestamp)>String(p.timestamp))bySession.set(k,r);} +const latest=[...bySession.values()]; +const cost=r=>Number(r.estimated_cost_usd)||0; +const day=r=>String(r.timestamp||"").slice(0,10); +const today=new Date().toISOString().slice(0,10); +const d=new Date(Date.now()-864e5).toISOString().slice(0,10); +const sum=a=>a.reduce((s,r)=>s+cost(r),0); +const f4=n=>"$"+n.toFixed(4); +console.log("=== Cost summary ==="); +console.log("today: "+f4(sum(latest.filter(r=>day(r)===today)))); +console.log("yesterday: "+f4(sum(latest.filter(r=>day(r)===d)))); +console.log("total: "+f4(sum(latest))+" ("+latest.length+" sessions)"); +const by=(key)=>{const m=new Map();for(const r of latest){const k=key(r)||"(unknown)";m.set(k,(m.get(k)||0)+cost(r));}return [...m.entries()].sort((a,b)=>b[1]-a[1]);}; +console.log("\n=== By model ===");for(const [k,v] of by(r=>r.model))console.log(f4(v).padStart(12)+" "+k); +console.log("\n=== Last 7 days ==="); +const days=new Map();for(const r of latest){const k=day(r);days.set(k,(days.get(k)||0)+cost(r));} +[...days.entries()].sort((a,b)=>b[0]console.log(k+" "+f4(v))); +' ``` -## Summary Query +## CSV export (`/cost-report csv`) ```bash -sqlite3 -header -column ~/.claude-cost-tracker/usage.db " - SELECT - ROUND(COALESCE(SUM(CASE WHEN date(timestamp) = date('now') THEN cost_usd END), 0), 4) AS today_cost, - ROUND(COALESCE(SUM(CASE WHEN date(timestamp) = date('now', '-1 day') THEN cost_usd END), 0), 4) AS yesterday_cost, - ROUND(COALESCE(SUM(cost_usd), 0), 4) AS total_cost, - COUNT(*) AS total_calls, - COUNT(DISTINCT session_id) AS sessions - FROM usage; -" +node -e ' +const fs=require("fs"),os=require("os"),path=require("path"); +const f=path.join(os.homedir(),".claude","metrics","costs.jsonl"); +if(!fs.existsSync(f)){console.error("no data");process.exit(0);} +const rows=fs.readFileSync(f,"utf8").split(/\r?\n/).filter(Boolean).map(l=>{try{return JSON.parse(l)}catch{return null}}).filter(Boolean).slice(-100); +console.log("timestamp,session_id,model,input_tokens,output_tokens,cache_write_tokens,cache_read_tokens,estimated_cost_usd"); +for(const r of rows)console.log([r.timestamp,r.session_id,r.model,r.input_tokens,r.output_tokens,r.cache_write_tokens,r.cache_read_tokens,r.estimated_cost_usd].join(",")); +' ``` -## Project Breakdown +## Report format -```bash -sqlite3 -header -column ~/.claude-cost-tracker/usage.db " - SELECT project, ROUND(SUM(cost_usd), 4) AS cost, COUNT(*) AS calls - FROM usage - GROUP BY project - ORDER BY cost DESC; -" -``` +1. Summary: today, yesterday, total, session count. +2. By model: models ranked by total cost. +3. Last seven days: date and cost. -## Tool Breakdown - -```bash -sqlite3 -header -column ~/.claude-cost-tracker/usage.db " - SELECT tool_name, ROUND(SUM(cost_usd), 4) AS cost, COUNT(*) AS calls - FROM usage - GROUP BY tool_name - ORDER BY cost DESC; -" -``` - -## Last Seven Days - -```bash -sqlite3 -header -column ~/.claude-cost-tracker/usage.db " - SELECT date(timestamp) AS date, ROUND(SUM(cost_usd), 4) AS cost, COUNT(*) AS calls - FROM usage - GROUP BY date(timestamp) - ORDER BY date DESC - LIMIT 7; -" -``` - -## CSV Export - -If the user asks for `/cost-report csv`, export the most recent usage rows with -an explicit column list: - -```bash -sqlite3 -csv -header ~/.claude-cost-tracker/usage.db " - SELECT timestamp, project, tool_name, input_tokens, output_tokens, cost_usd, session_id, model - FROM usage - ORDER BY timestamp DESC - LIMIT 100; -" -``` - -## Report Format - -Format the response as: - -1. Summary: today, yesterday, total, calls, sessions. -2. By project: projects ranked by total cost. -3. By tool: tools ranked by total cost. -4. Last seven days: date, cost, call count. - -Use four decimal places for sub-dollar amounts. Do not estimate pricing from raw -tokens in this command; rely on the precomputed `cost_usd` values written by the -tracker. - -## Source - -Salvaged from stale community PR #1304 by `MayurBhavsar`. +Rely on the precomputed `estimated_cost_usd` values written by the tracker; do +not re-estimate pricing from raw tokens here. diff --git a/docs/COMMAND-REGISTRY.json b/docs/COMMAND-REGISTRY.json index 056287b4..ace19851 100644 --- a/docs/COMMAND-REGISTRY.json +++ b/docs/COMMAND-REGISTRY.json @@ -49,7 +49,7 @@ }, { "command": "cost-report", - "description": "Generate a local Claude Code cost report from a cost-tracker SQLite database.", + "description": "Generate a local Claude Code cost report from the ECC cost-tracker metrics log.", "type": "testing", "primaryAgents": [], "allAgents": [], diff --git a/scripts/hooks/gateguard-fact-force.js b/scripts/hooks/gateguard-fact-force.js index 19d8b01a..ccfd8f44 100644 --- a/scripts/hooks/gateguard-fact-force.js +++ b/scripts/hooks/gateguard-fact-force.js @@ -25,11 +25,7 @@ const crypto = require('crypto'); const fs = require('fs'); const path = require('path'); -const { - extractCommandSubstitutions, - extractSubshellGroups, - extractBraceGroups -} = require('../lib/shell-substitution'); +const { extractCommandSubstitutions, extractSubshellGroups, extractBraceGroups } = require('../lib/shell-substitution'); // Session state — scoped per session to avoid cross-session races. const STATE_DIR = process.env.GATEGUARD_STATE_DIR || path.join(process.env.HOME || process.env.USERPROFILE || '/tmp', '.gateguard'); @@ -88,10 +84,10 @@ function getExtraDestructiveRegex() { extraDestructiveCacheRegex = null; if (!extraDestructiveWarnLogged) { try { - process.stderr.write( - `[gateguard-fact-force] ignoring invalid GATEGUARD_BASH_EXTRA_DESTRUCTIVE regex: ${err.message}\n` - ); - } catch (_) { /* stderr write failure is non-fatal */ } + process.stderr.write(`[gateguard-fact-force] ignoring invalid GATEGUARD_BASH_EXTRA_DESTRUCTIVE regex: ${err.message}\n`); + } catch (_) { + /* stderr write failure is non-fatal */ + } extraDestructiveWarnLogged = true; } } @@ -112,9 +108,7 @@ function isRoutineBashGateDisabled() { * @returns {string} */ function stripQuotedStrings(input) { - return input - .replace(/'(?:[^'\\]|\\.)*'/g, "''") - .replace(/"(?:[^"\\]|\\.)*"/g, '""'); + return input.replace(/'(?:[^'\\]|\\.)*'/g, "''").replace(/"(?:[^"\\]|\\.)*"/g, '""'); } /** @@ -168,7 +162,6 @@ function tokenize(segment) { return segment.split(/\s+/).filter(Boolean); } - /** * Tokenize a short allowlisted shell command while preserving quoted * arguments. This is intentionally smaller than a full shell parser: the @@ -236,7 +229,10 @@ function tokenizeAllowlistedShellWords(input) { */ function commandBasename(token) { if (!token) return ''; - return token.replace(/^.*[\\/]/, '').replace(/\.exe$/i, '').toLowerCase(); + return token + .replace(/^.*[\\/]/, '') + .replace(/\.exe$/i, '') + .toLowerCase(); } /** @@ -553,7 +549,10 @@ function isDestructiveBash(command) { // that false-negative while also catching `&&`, `;`, `|`, and `||` compound forms. const bodies = collectExecutableBodies(raw); for (const body of bodies) { - for (const rawSeg of body.split(/[;|&]+/).map(s => s.trim()).filter(Boolean)) { + for (const rawSeg of body + .split(/[;|&]+/) + .map(s => s.trim()) + .filter(Boolean)) { if (isDestructiveFindExec(rawSeg)) return true; } } @@ -573,7 +572,9 @@ function isDestructiveBash(command) { // --- State management (per-session, atomic writes, bounded) --- function normalizeEnvValue(value) { - return String(value || '').trim().toLowerCase(); + return String(value || '') + .trim() + .toLowerCase(); } function isGateGuardDisabled() { @@ -886,8 +887,7 @@ function isReadOnlyGitIntrospection(command) { if (args.length === 2) { const [first, second] = args; // ref + flag - if (!first.startsWith('--') && /^[a-zA-Z0-9._:/ -]+$/.test(first) && - (second === '--stat' || second === '--name-only')) { + if (!first.startsWith('--') && /^[a-zA-Z0-9._:/ -]+$/.test(first) && (second === '--stat' || second === '--name-only')) { return true; } return false; @@ -932,7 +932,7 @@ function writeGateMsg(filePath) { `Before creating ${safe}, present these facts:`, '', '1. Name the file(s) and line(s) that will call this new file', - '2. Confirm no existing file serves the same purpose (use Glob)', + '2. Confirm no existing file serves the same purpose (search the tree — Glob/Grep, or find/grep via Bash)', '3. If this file reads/writes data files, show field names, structure, and date format (use redacted or synthetic values, not raw production data)', "4. Quote the user's current instruction verbatim", '', @@ -983,11 +983,7 @@ function routineBashMsg() { function withRecoveryHint(message, hookIds = [EDIT_WRITE_HOOK_ID]) { const disableTargets = hookIds.map(hookId => `\`${hookId}\``).join(' or '); - return [ - message, - '', - `Recovery: if GateGuard is blocking setup or repair work, run this session with \`ECC_GATEGUARD=off\` or add ${disableTargets} to \`ECC_DISABLED_HOOKS\`.` - ].join('\n'); + return [message, '', `Recovery: if GateGuard is blocking setup or repair work, run this session with \`ECC_GATEGUARD=off\` or add ${disableTargets} to \`ECC_DISABLED_HOOKS\`.`].join('\n'); } function isSubagentInvocation(data) { @@ -995,12 +991,7 @@ function isSubagentInvocation(data) { return false; } - const candidates = [ - data.agent_id, - data.agentId, - data.parent_tool_use_id, - data.parentToolUseId - ]; + const candidates = [data.agent_id, data.agentId, data.parent_tool_use_id, data.parentToolUseId]; return candidates.some(candidate => typeof candidate === 'string' && candidate.trim()); } diff --git a/scripts/lib/install-executor.js b/scripts/lib/install-executor.js index ce2274ea..e5855ecc 100644 --- a/scripts/lib/install-executor.js +++ b/scripts/lib/install-executor.js @@ -5,20 +5,12 @@ const { execFileSync } = require('child_process'); const { toCursorAgentRelativePath } = require('./cursor-agent-names'); const { LEGACY_INSTALL_TARGETS, parseInstallArgs } = require('./install/request'); -const { - SUPPORTED_INSTALL_TARGETS, - listLegacyCompatibilityLanguages, - resolveLegacyCompatibilitySelection, - resolveInstallPlan, -} = require('./install-manifests'); +const { SUPPORTED_INSTALL_TARGETS, listLegacyCompatibilityLanguages, resolveLegacyCompatibilitySelection, resolveInstallPlan } = require('./install-manifests'); const { getInstallTargetAdapter } = require('./install-targets/registry'); const LANGUAGE_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/; const CLAUDE_ECC_NAMESPACE = 'ecc'; -const EXCLUDED_GENERATED_SOURCE_SUFFIXES = [ - '/ecc-install-state.json', - '/ecc/install-state.json', -]; +const EXCLUDED_GENERATED_SOURCE_SUFFIXES = ['/ecc-install-state.json', '/ecc/install-state.json']; function getSourceRoot() { return path.join(__dirname, '../..'); @@ -26,9 +18,7 @@ function getSourceRoot() { function getPackageVersion(sourceRoot) { try { - const packageJson = JSON.parse( - fs.readFileSync(path.join(sourceRoot, 'package.json'), 'utf8') - ); + const packageJson = JSON.parse(fs.readFileSync(path.join(sourceRoot, 'package.json'), 'utf8')); return packageJson.version || null; } catch (_error) { return null; @@ -37,9 +27,7 @@ function getPackageVersion(sourceRoot) { function getManifestVersion(sourceRoot) { try { - const modulesManifest = JSON.parse( - fs.readFileSync(path.join(sourceRoot, 'manifests', 'install-modules.json'), 'utf8') - ); + const modulesManifest = JSON.parse(fs.readFileSync(path.join(sourceRoot, 'manifests', 'install-modules.json'), 'utf8')); return modulesManifest.version || 1; } catch (_error) { return 1; @@ -52,7 +40,7 @@ function getRepoCommit(sourceRoot) { cwd: sourceRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], - timeout: 5000, + timeout: 5000 }).trim(); } catch (_error) { return null; @@ -64,32 +52,35 @@ function readDirectoryNames(dirPath) { return []; } - return fs.readdirSync(dirPath, { withFileTypes: true }) + return fs + .readdirSync(dirPath, { withFileTypes: true }) .filter(entry => entry.isDirectory()) .map(entry => entry.name) .sort(); } function listAvailableLanguages(sourceRoot = getSourceRoot()) { - return [...new Set([ - ...listLegacyCompatibilityLanguages(), - ...readDirectoryNames(path.join(sourceRoot, 'rules')) - .filter(name => name !== 'common'), - ])].sort(); + return [...new Set([...listLegacyCompatibilityLanguages(), ...readDirectoryNames(path.join(sourceRoot, 'rules')).filter(name => name !== 'common')])].sort(); } function validateLegacyTarget(target) { - if (!LEGACY_INSTALL_TARGETS.includes(target)) { + if (LEGACY_INSTALL_TARGETS.includes(target)) { + return; + } + // A target can be fully supported yet not installable via the bare-language + // positional syntax (which is legacy-only). Guide the user to the right mode + // instead of implying the target is unknown (#2282). + if (SUPPORTED_INSTALL_TARGETS.includes(target)) { throw new Error( - `Unknown install target: ${target}. Expected one of ${LEGACY_INSTALL_TARGETS.join(', ')}` + `Target '${target}' is supported, but the bare-language install syntax only accepts ${LEGACY_INSTALL_TARGETS.join(', ')}. ` + + `Install '${target}' with a component selection instead, e.g. \`install.sh --target ${target} --profile full\` ` + + `(or --modules / --skills ).` ); } + throw new Error(`Unknown install target: ${target}. Expected one of ${SUPPORTED_INSTALL_TARGETS.join(', ')}`); } -const IGNORED_DIRECTORY_NAMES = new Set([ - 'node_modules', - '.git', -]); +const IGNORED_DIRECTORY_NAMES = new Set(['node_modules', '.git']); function listFilesRecursive(dirPath) { if (!fs.existsSync(dirPath)) { @@ -141,7 +132,7 @@ function buildCopyFileOperation({ moduleId, sourcePath, sourceRelativePath, dest destinationPath, strategy, ownership: 'managed', - scaffoldOnly: false, + scaffoldOnly: false }; } @@ -156,20 +147,20 @@ function addRecursiveCopyOperations(operations, options) { for (const relativeFile of relativeFiles) { const sourceRelativePath = path.join(options.sourceRelativeDir, relativeFile); const sourcePath = path.join(options.sourceRoot, sourceRelativePath); - const destinationRelativePath = typeof options.destinationRelativePathTransform === 'function' - ? options.destinationRelativePathTransform(relativeFile, sourceRelativePath) - : relativeFile; + const destinationRelativePath = typeof options.destinationRelativePathTransform === 'function' ? options.destinationRelativePathTransform(relativeFile, sourceRelativePath) : relativeFile; if (!destinationRelativePath) { continue; } const destinationPath = path.join(options.destinationDir, destinationRelativePath); - operations.push(buildCopyFileOperation({ - moduleId: options.moduleId, - sourcePath, - sourceRelativePath, - destinationPath, - strategy: options.strategy || 'preserve-relative-path', - })); + operations.push( + buildCopyFileOperation({ + moduleId: options.moduleId, + sourcePath, + sourceRelativePath, + destinationPath, + strategy: options.strategy || 'preserve-relative-path' + }) + ); } return relativeFiles.length; @@ -181,13 +172,15 @@ function addFileCopyOperation(operations, options) { return false; } - operations.push(buildCopyFileOperation({ - moduleId: options.moduleId, - sourcePath, - sourceRelativePath: options.sourceRelativePath, - destinationPath: options.destinationPath, - strategy: options.strategy || 'preserve-relative-path', - })); + operations.push( + buildCopyFileOperation({ + moduleId: options.moduleId, + sourcePath, + sourceRelativePath: options.sourceRelativePath, + destinationPath: options.destinationPath, + strategy: options.strategy || 'preserve-relative-path' + }) + ); return true; } @@ -218,7 +211,7 @@ function addCursorAgentDataScaffoldOperations(operations, options) { sourceRoot: options.sourceRoot, sourceRelativePath: path.join('scaffolds', 'cursor', 'ecc-agent-data.json'), destinationPath: path.join(options.targetRoot, 'ecc-agent-data.json'), - strategy: 'preserve-relative-path', + strategy: 'preserve-relative-path' }); addFileCopyOperation(operations, { @@ -226,21 +219,17 @@ function addCursorAgentDataScaffoldOperations(operations, options) { sourceRoot: options.sourceRoot, sourceRelativePath: path.join('scaffolds', 'cursor', 'rules', 'ecc-agent-data-home.mdc'), destinationPath: path.join(options.targetRoot, 'rules', 'ecc-agent-data-home.mdc'), - strategy: 'preserve-relative-path', + strategy: 'preserve-relative-path' }); addJsonMergeOperation(operations, { moduleId: options.moduleId, sourceRoot: options.sourceRoot, sourceRelativePath: path.join('scaffolds', 'cursor', 'hooks.json'), - destinationPath: path.join(options.targetRoot, 'hooks.json'), + destinationPath: path.join(options.targetRoot, 'hooks.json') }); - const cursorSessionHookDeps = [ - path.join('scripts', 'hooks', 'cursor-session-env.js'), - path.join('scripts', 'lib', 'agent-data-home.js'), - path.join('scripts', 'lib', 'utils.js'), - ]; + const cursorSessionHookDeps = [path.join('scripts', 'hooks', 'cursor-session-env.js'), path.join('scripts', 'lib', 'agent-data-home.js'), path.join('scripts', 'lib', 'utils.js')]; for (const sourceRelativePath of cursorSessionHookDeps) { addFileCopyOperation(operations, { @@ -248,7 +237,7 @@ function addCursorAgentDataScaffoldOperations(operations, options) { sourceRoot: options.sourceRoot, sourceRelativePath, destinationPath: path.join(options.targetRoot, sourceRelativePath), - strategy: 'preserve-relative-path', + strategy: 'preserve-relative-path' }); } } @@ -267,7 +256,7 @@ function addJsonMergeOperation(operations, options) { strategy: 'merge-json', ownership: 'managed', scaffoldOnly: false, - mergePayload: readJsonObject(sourcePath, options.sourceRelativePath), + mergePayload: readJsonObject(sourcePath, options.sourceRelativePath) }); return true; @@ -279,7 +268,8 @@ function addMatchingRuleOperations(operations, options) { return 0; } - const files = fs.readdirSync(sourceDir, { withFileTypes: true }) + const files = fs + .readdirSync(sourceDir, { withFileTypes: true }) .filter(entry => entry.isFile() && options.matcher(entry.name)) .map(entry => entry.name) .sort(); @@ -287,18 +277,17 @@ function addMatchingRuleOperations(operations, options) { for (const fileName of files) { const sourceRelativePath = path.join(options.sourceRelativeDir, fileName); const sourcePath = path.join(options.sourceRoot, sourceRelativePath); - const destinationPath = path.join( - options.destinationDir, - options.rename ? options.rename(fileName) : fileName - ); + const destinationPath = path.join(options.destinationDir, options.rename ? options.rename(fileName) : fileName); - operations.push(buildCopyFileOperation({ - moduleId: options.moduleId, - sourcePath, - sourceRelativePath, - destinationPath, - strategy: options.strategy || 'flatten-copy', - })); + operations.push( + buildCopyFileOperation({ + moduleId: options.moduleId, + sourcePath, + sourceRelativePath, + destinationPath, + strategy: options.strategy || 'flatten-copy' + }) + ); } return files.length; @@ -324,7 +313,7 @@ function planClaudeStyleLegacyInstall(context, { adapterId, adapterRootInput, ru moduleId: 'legacy-claude-rules', sourceRoot: context.sourceRoot, sourceRelativeDir: path.join('rules', 'common'), - destinationDir: path.join(rulesDir, 'common'), + destinationDir: path.join(rulesDir, 'common') }); for (const language of context.languages) { @@ -343,7 +332,7 @@ function planClaudeStyleLegacyInstall(context, { adapterId, adapterRootInput, ru moduleId: 'legacy-claude-rules', sourceRoot: context.sourceRoot, sourceRelativeDir: path.join('rules', language), - destinationDir: path.join(rulesDir, language), + destinationDir: path.join(rulesDir, language) }); } @@ -356,7 +345,7 @@ function planClaudeStyleLegacyInstall(context, { adapterId, adapterRootInput, ru installStatePath, operations, warnings, - selectedModules: ['legacy-claude-rules'], + selectedModules: ['legacy-claude-rules'] }; } @@ -364,7 +353,7 @@ function planClaudeLegacyInstall(context) { return planClaudeStyleLegacyInstall(context, { adapterId: 'claude', adapterRootInput: { homeDir: context.homeDir }, - rulesDir: context.claudeRulesDir || null, + rulesDir: context.claudeRulesDir || null }); } @@ -372,7 +361,7 @@ function planClaudeProjectLegacyInstall(context) { return planClaudeStyleLegacyInstall(context, { adapterId: 'claude-project', adapterRootInput: { repoRoot: context.projectRoot }, - rulesDir: null, + rulesDir: null }); } @@ -388,14 +377,12 @@ function planCursorLegacyInstall(context) { sourceRoot: context.sourceRoot, sourceRelativeDir: path.join('.cursor', 'rules'), destinationDir: path.join(targetRoot, 'rules'), - matcher: fileName => /^common-.*\.md$/.test(fileName), + matcher: fileName => /^common-.*\.md$/.test(fileName) }); for (const language of context.languages) { if (!LANGUAGE_NAME_PATTERN.test(language)) { - warnings.push( - `Invalid language name '${language}'. Only alphanumeric, dash, and underscore are allowed` - ); + warnings.push(`Invalid language name '${language}'. Only alphanumeric, dash, and underscore are allowed`); continue; } @@ -404,7 +391,7 @@ function planCursorLegacyInstall(context) { sourceRoot: context.sourceRoot, sourceRelativeDir: path.join('.cursor', 'rules'), destinationDir: path.join(targetRoot, 'rules'), - matcher: fileName => fileName.startsWith(`${language}-`) && fileName.endsWith('.md'), + matcher: fileName => fileName.startsWith(`${language}-`) && fileName.endsWith('.md') }); if (matches === 0) { @@ -417,44 +404,44 @@ function planCursorLegacyInstall(context) { sourceRoot: context.sourceRoot, sourceRelativeDir: path.join('.cursor', 'agents'), destinationDir: path.join(targetRoot, 'agents'), - destinationRelativePathTransform: toCursorAgentRelativePath, + destinationRelativePathTransform: toCursorAgentRelativePath }); addRecursiveCopyOperations(operations, { moduleId: 'legacy-cursor-install', sourceRoot: context.sourceRoot, sourceRelativeDir: path.join('.cursor', 'skills'), - destinationDir: path.join(targetRoot, 'skills'), + destinationDir: path.join(targetRoot, 'skills') }); addRecursiveCopyOperations(operations, { moduleId: 'legacy-cursor-install', sourceRoot: context.sourceRoot, sourceRelativeDir: path.join('.cursor', 'commands'), - destinationDir: path.join(targetRoot, 'commands'), + destinationDir: path.join(targetRoot, 'commands') }); addRecursiveCopyOperations(operations, { moduleId: 'legacy-cursor-install', sourceRoot: context.sourceRoot, sourceRelativeDir: path.join('.cursor', 'hooks'), - destinationDir: path.join(targetRoot, 'hooks'), + destinationDir: path.join(targetRoot, 'hooks') }); addFileCopyOperation(operations, { moduleId: 'legacy-cursor-install', sourceRoot: context.sourceRoot, sourceRelativePath: path.join('.cursor', 'hooks.json'), - destinationPath: path.join(targetRoot, 'hooks.json'), + destinationPath: path.join(targetRoot, 'hooks.json') }); addJsonMergeOperation(operations, { moduleId: 'legacy-cursor-install', sourceRoot: context.sourceRoot, sourceRelativePath: '.mcp.json', - destinationPath: path.join(targetRoot, 'mcp.json'), + destinationPath: path.join(targetRoot, 'mcp.json') }); addCursorAgentDataScaffoldOperations(operations, { moduleId: 'legacy-cursor-install', sourceRoot: context.sourceRoot, - targetRoot, + targetRoot }); return { @@ -466,7 +453,7 @@ function planCursorLegacyInstall(context) { installStatePath, operations, warnings, - selectedModules: ['legacy-cursor-install'], + selectedModules: ['legacy-cursor-install'] }; } @@ -478,9 +465,7 @@ function planAntigravityLegacyInstall(context) { const warnings = []; if (isDirectoryNonEmpty(path.join(targetRoot, 'rules'))) { - warnings.push( - `Destination ${path.join(targetRoot, 'rules')}/ already exists and files may be overwritten` - ); + warnings.push(`Destination ${path.join(targetRoot, 'rules')}/ already exists and files may be overwritten`); } addMatchingRuleOperations(operations, { @@ -489,14 +474,12 @@ function planAntigravityLegacyInstall(context) { sourceRelativeDir: path.join('rules', 'common'), destinationDir: path.join(targetRoot, 'rules'), matcher: fileName => fileName.endsWith('.md'), - rename: fileName => `common-${fileName}`, + rename: fileName => `common-${fileName}` }); for (const language of context.languages) { if (!LANGUAGE_NAME_PATTERN.test(language)) { - warnings.push( - `Invalid language name '${language}'. Only alphanumeric, dash, and underscore are allowed` - ); + warnings.push(`Invalid language name '${language}'. Only alphanumeric, dash, and underscore are allowed`); continue; } @@ -512,7 +495,7 @@ function planAntigravityLegacyInstall(context) { sourceRelativeDir: path.join('rules', language), destinationDir: path.join(targetRoot, 'rules'), matcher: fileName => fileName.endsWith('.md'), - rename: fileName => `${language}-${fileName}`, + rename: fileName => `${language}-${fileName}` }); } @@ -520,19 +503,19 @@ function planAntigravityLegacyInstall(context) { moduleId: 'legacy-antigravity-install', sourceRoot: context.sourceRoot, sourceRelativeDir: 'commands', - destinationDir: path.join(targetRoot, 'workflows'), + destinationDir: path.join(targetRoot, 'workflows') }); addRecursiveCopyOperations(operations, { moduleId: 'legacy-antigravity-install', sourceRoot: context.sourceRoot, sourceRelativeDir: 'agents', - destinationDir: path.join(targetRoot, 'skills'), + destinationDir: path.join(targetRoot, 'skills') }); addRecursiveCopyOperations(operations, { moduleId: 'legacy-antigravity-install', sourceRoot: context.sourceRoot, sourceRelativeDir: 'skills', - destinationDir: path.join(targetRoot, 'skills'), + destinationDir: path.join(targetRoot, 'skills') }); return { @@ -544,7 +527,7 @@ function planAntigravityLegacyInstall(context) { installStatePath, operations, warnings, - selectedModules: ['legacy-antigravity-install'], + selectedModules: ['legacy-antigravity-install'] }; } @@ -561,7 +544,7 @@ function createLegacyInstallPlan(options = {}) { projectRoot, homeDir, languages: Array.isArray(options.languages) ? options.languages : [], - claudeRulesDir: options.claudeRulesDir || process.env.CLAUDE_RULES_DIR || null, + claudeRulesDir: options.claudeRulesDir || process.env.CLAUDE_RULES_DIR || null }; let plan; @@ -578,7 +561,7 @@ function createLegacyInstallPlan(options = {}) { const source = { repoVersion: getPackageVersion(sourceRoot), repoCommit: getRepoCommit(sourceRoot), - manifestVersion: getManifestVersion(sourceRoot), + manifestVersion: getManifestVersion(sourceRoot) }; const statePreview = createStatePreview({ @@ -589,14 +572,14 @@ function createLegacyInstallPlan(options = {}) { profile: null, modules: [], legacyLanguages: context.languages, - legacyMode: true, + legacyMode: true }, resolution: { selectedModules: plan.selectedModules, - skippedModules: [], + skippedModules: [] }, operations: plan.operations, - source, + source }); return { @@ -605,7 +588,7 @@ function createLegacyInstallPlan(options = {}) { adapter: { id: plan.adapter.id, target: plan.adapter.target, - kind: plan.adapter.kind, + kind: plan.adapter.kind }, targetRoot: plan.targetRoot, installRoot: plan.installRoot, @@ -613,7 +596,7 @@ function createLegacyInstallPlan(options = {}) { warnings: plan.warnings, languages: context.languages, operations: plan.operations, - statePreview, + statePreview }; } @@ -621,19 +604,15 @@ function createLegacyCompatInstallPlan(options = {}) { const sourceRoot = options.sourceRoot || getSourceRoot(); const projectRoot = options.projectRoot || process.cwd(); const target = options.target || 'claude'; - const includeComponentIds = Array.isArray(options.includeComponentIds) - ? [...options.includeComponentIds] - : []; - const excludeComponentIds = Array.isArray(options.excludeComponentIds) - ? [...options.excludeComponentIds] - : []; + const includeComponentIds = Array.isArray(options.includeComponentIds) ? [...options.includeComponentIds] : []; + const excludeComponentIds = Array.isArray(options.excludeComponentIds) ? [...options.excludeComponentIds] : []; validateLegacyTarget(target); const selection = resolveLegacyCompatibilitySelection({ repoRoot: sourceRoot, target, - legacyLanguages: options.legacyLanguages || [], + legacyLanguages: options.legacyLanguages || [] }); return createManifestInstallPlan({ @@ -651,25 +630,24 @@ function createLegacyCompatInstallPlan(options = {}) { requestModuleIds: [], requestIncludeComponentIds: includeComponentIds, requestExcludeComponentIds: excludeComponentIds, - mode: 'legacy-compat', + mode: 'legacy-compat' }); } function materializeScaffoldOperation(sourceRoot, operation) { if (operation.kind === 'merge-json') { - return [{ - kind: 'merge-json', - moduleId: operation.moduleId, - sourceRelativePath: operation.sourceRelativePath, - destinationPath: operation.destinationPath, - strategy: operation.strategy || 'merge-json', - ownership: operation.ownership || 'managed', - scaffoldOnly: Object.hasOwn(operation, 'scaffoldOnly') ? operation.scaffoldOnly : false, - mergePayload: readJsonObject( - path.join(sourceRoot, operation.sourceRelativePath), - operation.sourceRelativePath - ), - }]; + return [ + { + kind: 'merge-json', + moduleId: operation.moduleId, + sourceRelativePath: operation.sourceRelativePath, + destinationPath: operation.destinationPath, + strategy: operation.strategy || 'merge-json', + ownership: operation.ownership || 'managed', + scaffoldOnly: Object.hasOwn(operation, 'scaffoldOnly') ? operation.scaffoldOnly : false, + mergePayload: readJsonObject(path.join(sourceRoot, operation.sourceRelativePath), operation.sourceRelativePath) + } + ]; } const sourcePath = path.join(sourceRoot, operation.sourceRelativePath); @@ -683,13 +661,15 @@ function materializeScaffoldOperation(sourceRoot, operation) { const stat = fs.statSync(sourcePath); if (stat.isFile()) { - return [buildCopyFileOperation({ - moduleId: operation.moduleId, - sourcePath, - sourceRelativePath: operation.sourceRelativePath, - destinationPath: operation.destinationPath, - strategy: operation.strategy, - })]; + return [ + buildCopyFileOperation({ + moduleId: operation.moduleId, + sourcePath, + sourceRelativePath: operation.sourceRelativePath, + destinationPath: operation.destinationPath, + strategy: operation.strategy + }) + ]; } const relativeFiles = listFilesRecursive(sourcePath).filter(relativeFile => { @@ -703,7 +683,7 @@ function materializeScaffoldOperation(sourceRoot, operation) { sourcePath: path.join(sourcePath, relativeFile), sourceRelativePath, destinationPath: path.join(operation.destinationPath, relativeFile), - strategy: operation.strategy, + strategy: operation.strategy }); }); } @@ -712,21 +692,19 @@ function createManifestInstallPlan(options = {}) { const sourceRoot = options.sourceRoot || getSourceRoot(); const projectRoot = options.projectRoot || process.cwd(); const target = options.target || 'claude'; - const legacyLanguages = Array.isArray(options.legacyLanguages) - ? [...options.legacyLanguages] - : []; - const requestProfileId = Object.hasOwn(options, 'requestProfileId') - ? options.requestProfileId - : (options.profileId || null); - const requestModuleIds = Object.hasOwn(options, 'requestModuleIds') - ? [...options.requestModuleIds] - : (Array.isArray(options.moduleIds) ? [...options.moduleIds] : []); + const legacyLanguages = Array.isArray(options.legacyLanguages) ? [...options.legacyLanguages] : []; + const requestProfileId = Object.hasOwn(options, 'requestProfileId') ? options.requestProfileId : options.profileId || null; + const requestModuleIds = Object.hasOwn(options, 'requestModuleIds') ? [...options.requestModuleIds] : Array.isArray(options.moduleIds) ? [...options.moduleIds] : []; const requestIncludeComponentIds = Object.hasOwn(options, 'requestIncludeComponentIds') ? [...options.requestIncludeComponentIds] - : (Array.isArray(options.includeComponentIds) ? [...options.includeComponentIds] : []); + : Array.isArray(options.includeComponentIds) + ? [...options.includeComponentIds] + : []; const requestExcludeComponentIds = Object.hasOwn(options, 'requestExcludeComponentIds') ? [...options.requestExcludeComponentIds] - : (Array.isArray(options.excludeComponentIds) ? [...options.excludeComponentIds] : []); + : Array.isArray(options.excludeComponentIds) + ? [...options.excludeComponentIds] + : []; const plan = resolveInstallPlan({ repoRoot: sourceRoot, projectRoot, @@ -735,14 +713,14 @@ function createManifestInstallPlan(options = {}) { moduleIds: options.moduleIds || [], includeComponentIds: options.includeComponentIds || [], excludeComponentIds: options.excludeComponentIds || [], - target, + target }); const adapter = getInstallTargetAdapter(target); const operations = plan.operations.flatMap(operation => materializeScaffoldOperation(sourceRoot, operation)); const source = { repoVersion: getPackageVersion(sourceRoot), repoCommit: getRepoCommit(sourceRoot), - manifestVersion: getManifestVersion(sourceRoot), + manifestVersion: getManifestVersion(sourceRoot) }; const statePreview = createStatePreview({ adapter, @@ -754,14 +732,14 @@ function createManifestInstallPlan(options = {}) { includeComponents: requestIncludeComponentIds, excludeComponents: requestExcludeComponentIds, legacyLanguages, - legacyMode: Boolean(options.legacyMode), + legacyMode: Boolean(options.legacyMode) }, resolution: { selectedModules: plan.selectedModuleIds, - skippedModules: plan.skippedModuleIds, + skippedModules: plan.skippedModuleIds }, operations, - source, + source }); return { @@ -770,7 +748,7 @@ function createManifestInstallPlan(options = {}) { adapter: { id: adapter.id, target: adapter.target, - kind: adapter.kind, + kind: adapter.kind }, targetRoot: plan.targetRoot, installRoot: plan.targetRoot, @@ -787,7 +765,7 @@ function createManifestInstallPlan(options = {}) { skippedModuleIds: plan.skippedModuleIds, excludedModuleIds: plan.excludedModuleIds, operations, - statePreview, + statePreview }; } @@ -800,5 +778,5 @@ module.exports = { createLegacyInstallPlan, getSourceRoot, listAvailableLanguages, - parseInstallArgs, + parseInstallArgs }; diff --git a/scripts/lib/transcript-context.js b/scripts/lib/transcript-context.js index 4a00a551..c499523f 100644 --- a/scripts/lib/transcript-context.js +++ b/scripts/lib/transcript-context.js @@ -98,9 +98,7 @@ function readLatestContextTokens(transcriptPath, options = {}) { return null; } - const tailBytes = Number.isInteger(options.tailBytes) && options.tailBytes > 0 - ? options.tailBytes - : DEFAULT_TRANSCRIPT_TAIL_BYTES; + const tailBytes = Number.isInteger(options.tailBytes) && options.tailBytes > 0 ? options.tailBytes : DEFAULT_TRANSCRIPT_TAIL_BYTES; const tail = readFileTail(transcriptPath, tailBytes); if (!tail) { @@ -124,9 +122,7 @@ function readLatestContextTokens(transcriptPath, options = {}) { const tokens = extractUsageTokens(record); if (tokens > 0) { - const model = record.message && typeof record.message.model === 'string' - ? record.message.model - : ''; + const model = record.message && typeof record.message.model === 'string' ? record.message.model : ''; return { tokens, model }; } } @@ -141,6 +137,15 @@ function readLatestContextTokens(transcriptPath, options = {}) { * suffix); otherwise the standard 200k window. */ function resolveContextWindowTokens(tokens, model) { + // Explicit window override wins: 400k models (e.g. Opus 4.x) match neither the + // 200k default nor the 1M marker and would otherwise report ~double usage (#2290). + // Honor ECC's own knob and Claude Code's native CLAUDE_CODE_AUTO_COMPACT_WINDOW. + const env = (typeof process !== 'undefined' && process.env) || {}; + const envWindow = Number.parseInt(env.ECC_CONTEXT_WINDOW_TOKENS || env.CLAUDE_CODE_AUTO_COMPACT_WINDOW || '', 10); + if (Number.isInteger(envWindow) && envWindow > 0) { + return envWindow; + } + if (typeof model === 'string' && model.includes(LARGE_WINDOW_MODEL_MARKER)) { return LARGE_CONTEXT_WINDOW_TOKENS; } @@ -169,9 +174,7 @@ function resolveContextThreshold(env, windowTokens) { } } - return windowTokens >= LARGE_CONTEXT_WINDOW_TOKENS - ? DEFAULT_CONTEXT_THRESHOLD_LARGE - : DEFAULT_CONTEXT_THRESHOLD_STANDARD; + return windowTokens >= LARGE_CONTEXT_WINDOW_TOKENS ? DEFAULT_CONTEXT_THRESHOLD_LARGE : DEFAULT_CONTEXT_THRESHOLD_STANDARD; } /** @@ -181,9 +184,7 @@ function resolveContextThreshold(env, windowTokens) { function resolveContextInterval(env) { const raw = env && env.COMPACT_CONTEXT_INTERVAL; const parsed = Number.parseInt(raw, 10); - return Number.isInteger(parsed) && parsed > 0 && parsed <= MAX_TOKEN_SETTING - ? parsed - : DEFAULT_CONTEXT_INTERVAL_TOKENS; + return Number.isInteger(parsed) && parsed > 0 && parsed <= MAX_TOKEN_SETTING ? parsed : DEFAULT_CONTEXT_INTERVAL_TOKENS; } /** @@ -205,9 +206,7 @@ function computeContextBucket(tokens, threshold, interval) { * Human-readable label for a context window size (e.g. "200k", "1M"). */ function formatWindowLabel(windowTokens) { - return windowTokens >= LARGE_CONTEXT_WINDOW_TOKENS - ? '1M' - : `${Math.round(windowTokens / 1000)}k`; + return windowTokens >= LARGE_CONTEXT_WINDOW_TOKENS ? '1M' : `${Math.round(windowTokens / 1000)}k`; } module.exports = { diff --git a/skills/cost-tracking/SKILL.md b/skills/cost-tracking/SKILL.md index b07713f1..36fa80ab 100644 --- a/skills/cost-tracking/SKILL.md +++ b/skills/cost-tracking/SKILL.md @@ -1,148 +1,97 @@ --- name: cost-tracking -description: Track and report Claude Code token usage, spending, and budgets from a local cost-tracking database. Use when the user asks about costs, spending, usage, tokens, budgets, or cost breakdowns by project, tool, session, or date. +description: Track and report Claude Code token usage, spending, and budgets from the local ECC cost-tracker metrics log. Use when the user asks about costs, spending, usage, tokens, budgets, or cost breakdowns by model, session, or date. metadata: origin: community --- # Cost Tracking -Use this skill to analyze Claude Code cost and usage history from a local SQLite -database. It is intended for users who already have a cost-tracking hook or -plugin writing usage rows to `~/.claude-cost-tracker/usage.db`. +Use this skill to analyze Claude Code cost and usage history from the metrics log +that ECC's `stop:cost-tracker` hook writes. -Source: salvaged from stale community PR #1304 by `MayurBhavsar`. +## Where the data lives + +The tracker appends one JSON object per session-stop to +`~/.claude/metrics/costs.jsonl`. Each row is a **cumulative snapshot for that +session**, so to total spend you take the **latest row per `session_id`** and +sum across sessions — summing every row multiply-counts. + +Row schema: + +| Field | Meaning | +| --- | --- | +| `timestamp` | ISO timestamp of the snapshot | +| `session_id` | Claude Code session identifier | +| `transcript_path` | Path to the session transcript | +| `model` | Model used | +| `input_tokens` / `output_tokens` | Token counts | +| `cache_write_tokens` / `cache_read_tokens` | Prompt-cache token counts | +| `estimated_cost_usd` | Precomputed cumulative cost in USD for the session | + +Prefer `estimated_cost_usd` over hand-calculating pricing — model and cache +prices change, and the tracker is the source of truth. ## When to Use - The user asks "how much have I spent?", "what did this session cost?", or "what is my token usage?" - The user mentions budgets, spending limits, overruns, or cost controls. -- The user wants a cost breakdown by project, tool, session, model, or date. -- The user wants to compare today against yesterday or inspect a recent trend. -- The user asks for a CSV export of recent usage records. +- The user wants a cost breakdown by model, session, or date, or a CSV export. ## How It Works -First verify prerequisites: +First verify the log exists (use `node`, not `sqlite3` — the tracker writes +JSONL, and `node` is cross-platform): ```bash -command -v sqlite3 >/dev/null && echo "sqlite3 available" || echo "sqlite3 missing" -test -f ~/.claude-cost-tracker/usage.db && echo "Database found" || echo "Database not found" +node -e 'const fs=require("fs"),os=require("os"),p=require("path");const f=p.join(os.homedir(),".claude","metrics","costs.jsonl");console.log(fs.existsSync(f)?"cost log found":"cost log not found: "+f)' ``` -If the database is missing, do not fabricate usage data. Tell the user that cost -tracking is not configured and suggest installing or enabling a trusted local -cost-tracking hook/plugin. +If the log is missing, do not fabricate usage data. Tell the user that cost +tracking populates after the first session ends with the `stop:cost-tracker` +hook enabled. -The expected `usage` table usually contains one row per tool call or model -interaction. Column names vary by tracker, but the examples below assume: - -| Column | Meaning | -| --- | --- | -| `timestamp` | ISO timestamp for the usage event | -| `project` | Project or repository name | -| `tool_name` | Tool or event name | -| `input_tokens` | Input token count, when recorded | -| `output_tokens` | Output token count, when recorded | -| `cost_usd` | Precomputed cost in USD | -| `session_id` | Claude Code session identifier | -| `model` | Model used for the event | - -Prefer `cost_usd` over hand-calculating pricing. Model prices and cache pricing -change over time, and the tracker should be the source of truth for how each row -was priced. - -## Examples - -### Quick Summary +## Example — summary, by model, last 7 days ```bash -sqlite3 ~/.claude-cost-tracker/usage.db " - SELECT - 'Today: $' || ROUND(COALESCE(SUM(CASE WHEN date(timestamp) = date('now') THEN cost_usd END), 0), 4) || - ' | Total: $' || ROUND(COALESCE(SUM(cost_usd), 0), 4) || - ' | Calls: ' || COUNT(*) || - ' | Sessions: ' || COUNT(DISTINCT session_id) - FROM usage; -" +node -e ' +const fs=require("fs"),os=require("os"),path=require("path"); +const f=path.join(os.homedir(),".claude","metrics","costs.jsonl"); +if(!fs.existsSync(f)){console.log("cost log not found: "+f);process.exit(0);} +const rows=fs.readFileSync(f,"utf8").split(/\r?\n/).filter(Boolean).map(l=>{try{return JSON.parse(l)}catch{return null}}).filter(Boolean); +const bySession=new Map(); +for(const r of rows){const k=r.session_id||r.transcript_path||r.timestamp;const p=bySession.get(k);if(!p||String(r.timestamp)>String(p.timestamp))bySession.set(k,r);} +const latest=[...bySession.values()]; +const cost=r=>Number(r.estimated_cost_usd)||0, day=r=>String(r.timestamp||"").slice(0,10), sum=a=>a.reduce((s,r)=>s+cost(r),0), f4=n=>"$"+n.toFixed(4); +const today=new Date().toISOString().slice(0,10), yest=new Date(Date.now()-864e5).toISOString().slice(0,10); +console.log("today: "+f4(sum(latest.filter(r=>day(r)===today)))+" | yesterday: "+f4(sum(latest.filter(r=>day(r)===yest)))+" | total: "+f4(sum(latest))+" ("+latest.length+" sessions)"); +const m=new Map();for(const r of latest){const k=r.model||"(unknown)";m.set(k,(m.get(k)||0)+cost(r));} +console.log("by model:");[...m.entries()].sort((a,b)=>b[1]-a[1]).forEach(([k,v])=>console.log(" "+f4(v)+" "+k)); +' ``` -### Cost By Project - -```bash -sqlite3 -header -column ~/.claude-cost-tracker/usage.db " - SELECT project, ROUND(SUM(cost_usd), 4) AS cost, COUNT(*) AS calls - FROM usage - GROUP BY project - ORDER BY cost DESC; -" -``` - -### Cost By Tool - -```bash -sqlite3 -header -column ~/.claude-cost-tracker/usage.db " - SELECT tool_name, ROUND(SUM(cost_usd), 4) AS cost, COUNT(*) AS calls - FROM usage - GROUP BY tool_name - ORDER BY cost DESC; -" -``` - -### Last Seven Days - -```bash -sqlite3 -header -column ~/.claude-cost-tracker/usage.db " - SELECT date(timestamp) AS date, ROUND(SUM(cost_usd), 4) AS cost, COUNT(*) AS calls - FROM usage - GROUP BY date(timestamp) - ORDER BY date DESC - LIMIT 7; -" -``` - -### Session Drilldown - -```bash -sqlite3 -header -column ~/.claude-cost-tracker/usage.db " - SELECT session_id, - MIN(timestamp) AS started, - MAX(timestamp) AS ended, - ROUND(SUM(cost_usd), 4) AS cost, - COUNT(*) AS calls - FROM usage - GROUP BY session_id - ORDER BY started DESC - LIMIT 10; -" -``` +For a session drilldown or CSV export, iterate the same `latest` set (or the raw +rows for CSV) and print the fields you need. ## Reporting Guidance -When presenting cost data, include: - -1. Today's spend and yesterday comparison. -2. Total spend across the tracked database. -3. Top projects ranked by cost. -4. Top tools ranked by cost. -5. Session count and average cost per session when enough data exists. - -For small amounts, format currency with four decimal places. For larger amounts, -two decimals are enough. +When presenting cost data, include today's spend vs yesterday, total across all +sessions, a by-model breakdown, and session count. Format sub-dollar amounts +with four decimals, larger amounts with two. ## Anti-Patterns -- Do not estimate costs from raw token counts when `cost_usd` is present. -- Do not assume the database exists without checking. -- Do not run unbounded `SELECT *` exports on large databases. +- Do not sum every row — they are cumulative per session; reduce to the latest + row per `session_id` first. +- Do not estimate costs from raw token counts when `estimated_cost_usd` is present. +- Do not assume the log exists without checking. - Do not hard-code current model pricing in user-facing answers. -- Do not recommend installing unreviewed hooks or plugins that execute arbitrary - code. +- Do not recommend installing unreviewed hooks or plugins that execute arbitrary code. ## Related -- `/cost-report` - Command-form report using the same database. +- `/cost-report` - Command-form report over the same metrics log. - `cost-aware-llm-pipeline` - Model-routing and budget-design patterns. - `token-budget-advisor` - Context and token-budget planning. - `strategic-compact` - Context compaction to reduce repeated token spend. diff --git a/tests/lib/transcript-context.test.js b/tests/lib/transcript-context.test.js index e6df6565..96615fe4 100644 --- a/tests/lib/transcript-context.test.js +++ b/tests/lib/transcript-context.test.js @@ -78,47 +78,32 @@ function tracked(filePath) { console.log('readLatestContextTokens:'); test('sums input + cache_read + cache_creation from the latest usage record', () => { - const file = tracked(writeTranscript([ - usageRecord({ input: 10, cacheRead: 20, cacheCreation: 5 }), - usageRecord({ input: 100, cacheRead: 150000, cacheCreation: 7000 }) - ])); + const file = tracked(writeTranscript([usageRecord({ input: 10, cacheRead: 20, cacheCreation: 5 }), usageRecord({ input: 100, cacheRead: 150000, cacheCreation: 7000 })])); const result = readLatestContextTokens(file); assert.ok(result, 'Expected a usage result'); assert.strictEqual(result.tokens, 157100); }); test('returns the model id alongside the token count', () => { - const file = tracked(writeTranscript([ - usageRecord({ input: 1000 }, 'claude-opus-4-5[1m]') - ])); + const file = tracked(writeTranscript([usageRecord({ input: 1000 }, 'claude-opus-4-5[1m]')])); const result = readLatestContextTokens(file); assert.strictEqual(result.model, 'claude-opus-4-5[1m]'); }); test('skips trailing records without usage (e.g. tool results)', () => { - const file = tracked(writeTranscript([ - usageRecord({ input: 5000 }), - JSON.stringify({ type: 'user', message: { content: 'tool result' } }), - JSON.stringify({ type: 'system', subtype: 'info' }) - ])); + const file = tracked(writeTranscript([usageRecord({ input: 5000 }), JSON.stringify({ type: 'user', message: { content: 'tool result' } }), JSON.stringify({ type: 'system', subtype: 'info' })])); const result = readLatestContextTokens(file); assert.strictEqual(result.tokens, 5000); }); test('skips malformed JSONL lines without throwing', () => { - const file = tracked(writeTranscript([ - usageRecord({ input: 4200 }), - '{not json at all', - '' - ])); + const file = tracked(writeTranscript([usageRecord({ input: 4200 }), '{not json at all', ''])); const result = readLatestContextTokens(file); assert.strictEqual(result.tokens, 4200); }); test('returns null for a transcript with no usage records', () => { - const file = tracked(writeTranscript([ - JSON.stringify({ type: 'user', message: { content: 'hello' } }) - ])); + const file = tracked(writeTranscript([JSON.stringify({ type: 'user', message: { content: 'hello' } })])); assert.strictEqual(readLatestContextTokens(file), null); }); @@ -132,10 +117,7 @@ test('returns null for empty or non-string paths', () => { }); test('ignores zero-token usage records', () => { - const file = tracked(writeTranscript([ - usageRecord({ input: 999 }), - usageRecord({ input: 0 }) - ])); + const file = tracked(writeTranscript([usageRecord({ input: 999 }), usageRecord({ input: 0 })])); const result = readLatestContextTokens(file); assert.strictEqual(result.tokens, 999); }); @@ -154,10 +136,42 @@ test('only scans the transcript tail (latest records win on large files)', () => // ── resolveContextWindowTokens ── console.log('\nresolveContextWindowTokens:'); +// Isolation: an env-set window override (either knob) otherwise leaks into the +// default-window assertions below and fails them (#2290). +delete process.env.ECC_CONTEXT_WINDOW_TOKENS; +delete process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW; + test('defaults to the standard 200k window', () => { assert.strictEqual(resolveContextWindowTokens(50000, 'claude-sonnet-4-6'), STANDARD_CONTEXT_WINDOW_TOKENS); }); +test('honors an explicit ECC_CONTEXT_WINDOW_TOKENS override (e.g. 400k models, #2290)', () => { + process.env.ECC_CONTEXT_WINDOW_TOKENS = '400000'; + try { + assert.strictEqual(resolveContextWindowTokens(50000, 'claude-opus-4-x'), 400000); + } finally { + delete process.env.ECC_CONTEXT_WINDOW_TOKENS; + } +}); + +test('honors Claude Code native CLAUDE_CODE_AUTO_COMPACT_WINDOW override', () => { + process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = '400000'; + try { + assert.strictEqual(resolveContextWindowTokens(50000, 'claude-opus-4-x'), 400000); + } finally { + delete process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW; + } +}); + +test('ignores a non-positive / invalid window override', () => { + process.env.ECC_CONTEXT_WINDOW_TOKENS = 'not-a-number'; + try { + assert.strictEqual(resolveContextWindowTokens(50000, 'claude-sonnet-4-6'), STANDARD_CONTEXT_WINDOW_TOKENS); + } finally { + delete process.env.ECC_CONTEXT_WINDOW_TOKENS; + } +}); + test('detects a 1M window from the [1m] model marker', () => { assert.strictEqual(resolveContextWindowTokens(50000, 'claude-opus-4-5[1m]'), LARGE_CONTEXT_WINDOW_TOKENS); }); @@ -191,11 +205,7 @@ test('COMPACT_CONTEXT_THRESHOLD=0 disables the signal', () => { test('invalid COMPACT_CONTEXT_THRESHOLD falls back to the default', () => { for (const bad of ['-5', 'abc', '99999999999']) { - assert.strictEqual( - resolveContextThreshold({ COMPACT_CONTEXT_THRESHOLD: bad }, STANDARD_CONTEXT_WINDOW_TOKENS), - DEFAULT_CONTEXT_THRESHOLD_STANDARD, - `Expected fallback for ${bad}` - ); + assert.strictEqual(resolveContextThreshold({ COMPACT_CONTEXT_THRESHOLD: bad }, STANDARD_CONTEXT_WINDOW_TOKENS), DEFAULT_CONTEXT_THRESHOLD_STANDARD, `Expected fallback for ${bad}`); } });