claude-code-cache-fix 1.6.4 → 1.7.1

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/README.md CHANGED
@@ -70,6 +70,31 @@ NODE_OPTIONS="--import claude-code-cache-fix" claude
70
70
 
71
71
  > **Note**: This only works if `claude` points to the npm/Node installation. The standalone binary uses a different execution path that bypasses Node.js preloads.
72
72
 
73
+ ### Windows users
74
+
75
+ On Windows, `NODE_OPTIONS="--import ..."` doesn't work the same way as on Linux/macOS. Use the included `claude-fixed.bat` wrapper instead:
76
+
77
+ 1. After installing both packages globally:
78
+ ```bat
79
+ npm install -g claude-code-cache-fix
80
+ npm install -g @anthropic-ai/claude-code
81
+ ```
82
+
83
+ 2. Copy `claude-fixed.bat` from this package to a directory in your PATH (e.g., `C:\Users\<you>\bin\`):
84
+ ```bat
85
+ copy "%NPM_ROOT%\claude-code-cache-fix\claude-fixed.bat" C:\Users\%USERNAME%\bin\
86
+ ```
87
+ Or find the file manually at your npm global root (run `npm root -g` to locate it).
88
+
89
+ 3. Run Claude Code with the interceptor active:
90
+ ```bat
91
+ claude-fixed [any claude args...]
92
+ ```
93
+
94
+ The wrapper dynamically resolves your npm global root, constructs a `file:///` URL for the preload module (converting backslashes to forward slashes for Node.js), and launches Claude Code with the interceptor loaded. All environment variables (`CACHE_FIX_DEBUG`, `CACHE_FIX_IMAGE_KEEP_LAST`, etc.) work the same as on Linux/macOS.
95
+
96
+ Credit: [@TomTheMenace](https://github.com/anthropics/claude-code/issues/38335) contributed the Windows wrapper and validated the interceptor across a 7.5-hour, 536-call Opus 4.6 session on Windows — 98.4% cache hit rate, 81% of calls had fingerprint instability that the interceptor corrected.
97
+
73
98
  ## How it works
74
99
 
75
100
  The module intercepts `globalThis.fetch` before Claude Code makes API calls to `/v1/messages`. On each call it:
@@ -303,6 +328,51 @@ Snapshots are saved to `~/.claude/cache-fix-snapshots/` and diff reports are gen
303
328
 
304
329
  - **[@ArkNill/claude-code-hidden-problem-analysis](https://github.com/ArkNill/claude-code-hidden-problem-analysis)** — Systematic proxy-based analysis of 7 bugs including microcompact, budget enforcement, false rate limiter, and extended thinking quota impact. The monitoring features in v1.1.0 are informed by this research.
305
330
  - **[@Renvect/X-Ray-Claude-Code-Interceptor](https://github.com/Renvect/X-Ray-Claude-Code-Interceptor)** — Diagnostic HTTPS proxy with real-time dashboard, system prompt section diffing, per-tool stripping thresholds, and multi-stream JSONL logging. Works with any Claude client that supports `ANTHROPIC_BASE_URL` (CLI, VS Code extension, desktop app), complementing this package's CLI-only `NODE_OPTIONS` approach.
331
+ - **[@fgrosswig/claude-usage-dashboard](https://github.com/fgrosswig/claude-usage-dashboard)** — Self-hosted forensic dashboard with SSE live monitoring, multi-host aggregation, cache-health scoring, and forced-restart/compaction detection. Reads from Claude Code's native session JSONL files and optionally from an HTTP proxy NDJSON stream. v1.4.0 documented the forced-session-restart mechanism at quota-cap boundaries (~490K tokens per event) and the 78–91% cache-wipe pattern at compaction events. Complementary to our interceptor's in-process vantage point. See [Works with @fgrosswig's dashboard](#works-with-fgrosswigs-dashboard) below for the interop pattern.
332
+
333
+ ## Works with @fgrosswig's dashboard
334
+
335
+ This interceptor and [@fgrosswig](https://github.com/fgrosswig)'s
336
+ [claude-usage-dashboard](https://github.com/fgrosswig/claude-usage-dashboard)
337
+ solve strongly complementary problems. The interceptor captures per-call API
338
+ data from inside the Node.js process — cache metrics, quota state, TTL tier,
339
+ rewrites applied. The dashboard provides the visualization layer — historical
340
+ trending, per-day charts, multi-host aggregation, cache-health scoring.
341
+
342
+ Running both gives you the best of both tools, and the integration is a
343
+ one-liner thanks to the dashboard's tolerant NDJSON ingest and our new
344
+ `usage-to-dashboard-ndjson` translator.
345
+
346
+ ### Quick setup
347
+
348
+ ```bash
349
+ # Install both tools
350
+ npm install -g claude-code-cache-fix
351
+ # (follow fgrosswig's dashboard install: https://github.com/fgrosswig/claude-usage-dashboard)
352
+
353
+ # One-shot translation (reads ~/.claude/usage.jsonl, writes to
354
+ # ~/.claude/anthropic-proxy-logs/proxy-YYYY-MM-DD.ndjson, which his
355
+ # dashboard already watches)
356
+ node $(npm root -g)/claude-code-cache-fix/tools/usage-to-dashboard-ndjson.mjs
357
+
358
+ # Or keep it live-updating as the interceptor logs new calls
359
+ node $(npm root -g)/claude-code-cache-fix/tools/usage-to-dashboard-ndjson.mjs --follow &
360
+ ```
361
+
362
+ No configuration required on the dashboard side — fgrosswig's
363
+ `collectProxyNdjsonFiles()` auto-discovers files in
364
+ `~/.claude/anthropic-proxy-logs/` (or `$ANTHROPIC_PROXY_LOG_DIR`), and our
365
+ translator writes to exactly that path with the expected `proxy-YYYY-MM-DD.ndjson`
366
+ filename convention. The dashboard's tolerant ingestion layer ignores unknown
367
+ fields, so interceptor-specific extras (`ttl_tier`, `ephemeral_1h_input_tokens`,
368
+ `ephemeral_5m_input_tokens`, `peak_hour`, quota state) pass through cleanly
369
+ and remain available to downstream consumers that know to read them.
370
+
371
+ The `cost_factor` metric in `tools/cost-report.mjs` also comes from
372
+ fgrosswig's methodology — the `(input + output + cache_read + cache_creation) / output`
373
+ ratio that gives a single-number measure of how much context is being paid
374
+ per useful output token. A rising cost factor across a long session is the
375
+ measurable signature of cache-efficiency degradation.
306
376
 
307
377
  ## Used in production
308
378
 
@@ -316,6 +386,8 @@ Snapshots are saved to `~/.claude/cache-fix-snapshots/` and diff reports are gen
316
386
  - **[@cnighswonger](https://github.com/cnighswonger)** — Fingerprint stabilization, tool ordering fix, image stripping, monitoring features, overage TTL downgrade discovery, package maintainer
317
387
  - **[@ArkNill](https://github.com/ArkNill)** — Microcompact mechanism analysis, GrowthBook flag documentation, false rate limiter identification
318
388
  - **[@Renvect](https://github.com/Renvect)** — Image duplication discovery, cross-project directory contamination analysis
389
+ - **[@fgrosswig](https://github.com/fgrosswig)** — [claude-usage-dashboard](https://github.com/fgrosswig/claude-usage-dashboard) forensic methodology: cost-factor overhead ratio metric, `anthropic-*` header capture pattern, proxy NDJSON schema that informed our dashboard interop layer
390
+ - **[@TomTheMenace](https://github.com/TomTheMenace)** — Windows `.bat` wrapper for the interceptor, first Windows platform validation (7.5h/536-call Opus 4.6 session, 98.4% cache hit rate, 81% fingerprint instability corrected)
319
391
 
320
392
  If you contributed to the community effort on these issues and aren't listed here, please open an issue or PR — we want to credit everyone properly.
321
393
 
@@ -0,0 +1,22 @@
1
+ @echo off
2
+ REM claude-fixed.bat — Windows wrapper for Claude Code with cache-fix interceptor.
3
+ REM
4
+ REM Resolves the npm global root dynamically, constructs a file:/// URL for the
5
+ REM preload module (converting backslashes to forward slashes for Node.js), and
6
+ REM launches Claude Code with the interceptor active.
7
+ REM
8
+ REM Usage:
9
+ REM claude-fixed [any claude args...]
10
+ REM
11
+ REM Prerequisites:
12
+ REM npm install -g claude-code-cache-fix
13
+ REM npm install -g @anthropic-ai/claude-code
14
+ REM
15
+ REM Save this file somewhere in your PATH (e.g. C:\Users\<you>\bin\claude-fixed.bat).
16
+ REM
17
+ REM Credit: @TomTheMenace (https://github.com/anthropics/claude-code/issues/38335)
18
+ REM Part of claude-code-cache-fix: https://github.com/cnighswonger/claude-code-cache-fix
19
+
20
+ for /f "delims=" %%G in ('npm root -g') do set "NPM_GLOBAL=%%G"
21
+ set NODE_OPTIONS=--import file:///%NPM_GLOBAL:\=/%/claude-code-cache-fix/preload.mjs
22
+ node "%NPM_GLOBAL%\@anthropic-ai\claude-code\cli.js" %*
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "claude-code-cache-fix",
3
- "version": "1.6.4",
3
+ "version": "1.7.1",
4
4
  "description": "Fixes prompt cache regression in Claude Code that causes up to 20x cost increase on resumed sessions",
5
5
  "type": "module",
6
6
  "exports": "./preload.mjs",
7
7
  "main": "./preload.mjs",
8
8
  "files": [
9
9
  "preload.mjs",
10
- "tools/"
10
+ "tools/",
11
+ "claude-fixed.bat"
11
12
  ],
12
13
  "engines": {
13
14
  "node": ">=18"
package/preload.mjs CHANGED
@@ -1009,6 +1009,30 @@ globalThis.fetch = async function (url, options) {
1009
1009
  monitorContextDegradation(payload.messages);
1010
1010
  }
1011
1011
 
1012
+ // Diagnostic: dump full tools array (names, descriptions, schemas, sizes) to a file
1013
+ // when CACHE_FIX_DUMP_TOOLS=<path> is set. Useful for per-version tool-schema drift
1014
+ // analysis and for understanding which tools contribute prefix bloat. First used
1015
+ // during the 2026-04-11 cross-version regression investigation.
1016
+ if (process.env.CACHE_FIX_DUMP_TOOLS && payload.tools) {
1017
+ try {
1018
+ const dumpPath = process.env.CACHE_FIX_DUMP_TOOLS;
1019
+ const dump = {
1020
+ timestamp: new Date().toISOString(),
1021
+ tool_count: payload.tools.length,
1022
+ tools: payload.tools.map(t => ({
1023
+ name: t.name,
1024
+ description: t.description || "",
1025
+ desc_chars: (t.description || "").length,
1026
+ schema_chars: JSON.stringify(t.input_schema || {}).length,
1027
+ total_chars: JSON.stringify(t).length,
1028
+ })),
1029
+ system_chars: JSON.stringify(payload.system || "").length,
1030
+ total_tools_chars: JSON.stringify(payload.tools).length,
1031
+ };
1032
+ writeFileSync(dumpPath, JSON.stringify(dump, null, 2));
1033
+ } catch (e) { debugLog("DUMP ERROR:", e?.message); }
1034
+ }
1035
+
1012
1036
  // Prompt size measurement — log system prompt, tools, and injected block sizes
1013
1037
  if (DEBUG && payload.system && payload.tools && payload.messages) {
1014
1038
  const sysChars = JSON.stringify(payload.system).length;
@@ -1061,6 +1085,25 @@ globalThis.fetch = async function (url, options) {
1061
1085
  const status = response.headers.get("anthropic-ratelimit-unified-status");
1062
1086
  const overage = response.headers.get("anthropic-ratelimit-unified-overage-status");
1063
1087
 
1088
+ // Capture ALL anthropic-* and request-id/cf-ray response headers.
1089
+ // Pattern borrowed from @fgrosswig's claude-usage-dashboard proxy:
1090
+ // https://github.com/fgrosswig/claude-usage-dashboard
1091
+ // Widening beyond the specific unified-ratelimit headers above future-proofs
1092
+ // us against Anthropic adding new headers (e.g. experimental rollout flags,
1093
+ // region hints, new quota dimensions) without needing code changes.
1094
+ const allAnthropicHeaders = {};
1095
+ for (const [name, value] of response.headers.entries()) {
1096
+ const lower = name.toLowerCase();
1097
+ if (
1098
+ lower.startsWith("anthropic-") ||
1099
+ lower === "request-id" ||
1100
+ lower === "x-request-id" ||
1101
+ lower === "cf-ray"
1102
+ ) {
1103
+ allAnthropicHeaders[lower] = value;
1104
+ }
1105
+ }
1106
+
1064
1107
  if (h5 || h7d) {
1065
1108
  const quotaFile = join(homedir(), ".claude", "quota-status.json");
1066
1109
  let quota = {};
@@ -1070,6 +1113,7 @@ globalThis.fetch = async function (url, options) {
1070
1113
  quota.seven_day = h7d ? { utilization: parseFloat(h7d), pct: Math.round(parseFloat(h7d) * 100), resets_at: reset7d ? parseInt(reset7d) : null } : quota.seven_day;
1071
1114
  quota.status = status || null;
1072
1115
  quota.overage_status = overage || null;
1116
+ quota.all_headers = allAnthropicHeaders;
1073
1117
 
1074
1118
  // Peak hour detection — Anthropic applies higher quota drain rate during
1075
1119
  // weekday peak hours: 13:00–19:00 UTC (Mon–Fri).
@@ -484,6 +484,12 @@ function printJsonReport(results, summary, ratesData, adminSummary) {
484
484
  total_cost: summary.totalCost,
485
485
  avg_cost_per_call: summary.totalCost / summary.calls,
486
486
  tokens: summary.totals,
487
+ cost_factor: (function () {
488
+ // fgrosswig-style overhead ratio: gross tokens / output tokens
489
+ const gross = summary.totals.input + summary.totals.output +
490
+ summary.totals.cache_read + summary.totals.cache_1h + summary.totals.cache_5m;
491
+ return summary.totals.output > 0 ? gross / summary.totals.output : null;
492
+ })(),
487
493
  by_model: summary.byModel,
488
494
  degradation: summary.degradedCalls > 0 ? {
489
495
  degraded_calls: summary.degradedCalls,
@@ -544,6 +550,15 @@ function printMarkdownReport(results, summary, ratesData, adminSummary) {
544
550
  lines.push(`| Total cache write 5m | ${fmt(summary.totals.cache_5m)} |`);
545
551
  lines.push(`| **Total cost** | **${fmtCost(summary.totalCost)}** |`);
546
552
  lines.push(`| Avg cost per call | ${fmtCost(summary.totalCost / summary.calls)} |`);
553
+ {
554
+ // Cost factor: popularized by @fgrosswig's claude-usage-dashboard
555
+ // (https://github.com/fgrosswig/claude-usage-dashboard)
556
+ const grossTokens = summary.totals.input + summary.totals.output +
557
+ summary.totals.cache_read + summary.totals.cache_1h + summary.totals.cache_5m;
558
+ if (summary.totals.output > 0) {
559
+ lines.push(`| Cost factor (tokens/output) | ${(grossTokens / summary.totals.output).toFixed(1)}× |`);
560
+ }
561
+ }
547
562
  lines.push('');
548
563
 
549
564
  // By model
@@ -680,6 +695,22 @@ function printTextReport(results, summary, ratesData, adminSummary) {
680
695
  }
681
696
  }
682
697
  }
698
+
699
+ // ── Cost factor (overhead ratio) ──
700
+ // Credit: this metric was popularized by @fgrosswig's claude-usage-dashboard
701
+ // (https://github.com/fgrosswig/claude-usage-dashboard). It divides total
702
+ // tokens processed (input + output + cache_read + cache_creation) by useful
703
+ // output tokens, giving a single-number "how much context am I carrying
704
+ // per useful word of output" multiplier. Values climb over long sessions
705
+ // due to resume/compaction cycles; a rising curve is a signal that cache
706
+ // efficiency is degrading.
707
+ const totalCacheCreate = summary.totals.cache_1h + summary.totals.cache_5m;
708
+ const grossTokens = summary.totals.input + summary.totals.output +
709
+ summary.totals.cache_read + totalCacheCreate;
710
+ if (summary.totals.output > 0) {
711
+ const costFactor = grossTokens / summary.totals.output;
712
+ console.log(` Cost factor: ${costFactor.toFixed(1)}× (tokens/output)`);
713
+ }
683
714
  console.log('');
684
715
 
685
716
  // ── Degradation ──
@@ -0,0 +1,304 @@
1
+ #!/usr/bin/env bash
2
+ # cross-version-cache-test — replicable cache-behavior test across installed Claude Code versions.
3
+ #
4
+ # What it tests:
5
+ # Phase A (always): per-version steady-state cache behavior via 5 sequential Haiku -p calls,
6
+ # fired within seconds of each other. Captures:
7
+ # - Turn 1 prefix size (cache_creation cold start)
8
+ # - Turns 2-5 cache hit stability (should be ~100% cache_read if TTL holds)
9
+ # - Per-turn q5h_pct delta
10
+ # - TTL tier granted by server
11
+ # Phase B (optional, --include-idle): per-version idle-gap behavior via two calls 6 minutes apart.
12
+ # Captures whether the 1h TTL grant holds across a >5-minute idle, or whether
13
+ # the server flips to 5m tier and forces a rebuild.
14
+ #
15
+ # Safety:
16
+ # - Uses Haiku exclusively (~$0.006/call at Haiku 4.5 rates; full test at ~30 calls = ~$0.20)
17
+ # - No deliberate quota burn; exits gracefully if Q5h > 80% at start
18
+ # - Runs against fixed seed prompt to keep per-call overhead minimal
19
+ # - Does not trigger overage, does not pin quota state for the session
20
+ #
21
+ # Usage:
22
+ # ./cross-version-cache-test.sh # Phase A only, quick
23
+ # ./cross-version-cache-test.sh --include-idle # Phase A + Phase B (takes ~25 minutes)
24
+ # ./cross-version-cache-test.sh --output /some/path # Custom output dir
25
+ #
26
+ # Output:
27
+ # /tmp/cross-version-test-YYYYMMDD-HHMMSS/ (default) containing:
28
+ # - <version>-phase-a.jsonl # one usage.jsonl record per call
29
+ # - <version>-phase-b.jsonl # optional, only with --include-idle
30
+ # - summary.md # tabulated comparison across versions
31
+ # - raw-quota-status-*.json # quota state snapshots
32
+ #
33
+ # Part of claude-code-cache-fix. Requires:
34
+ # - ~/bin/cc-version launcher (see repo)
35
+ # - Installed versions at ~/cc-versions/<version>/ (this script checks and warns)
36
+ # - Interceptor active (the script verifies usage.jsonl grows per call)
37
+ #
38
+ # First created 2026-04-11 for the March 23 regression investigation follow-up.
39
+
40
+ set -euo pipefail
41
+
42
+ # ─── Configuration ──────────────────────────────────────────────────────────
43
+
44
+ VERSIONS=(2.1.81 2.1.83 2.1.90 2.1.101)
45
+ STEADY_STATE_TURNS=5
46
+ IDLE_GAP_SECONDS=360 # 6 minutes, crosses the 5m TTL boundary
47
+ SEED_PROMPT='Reply with exactly: ok'
48
+ MODEL='haiku'
49
+
50
+ # ─── CLI parsing ────────────────────────────────────────────────────────────
51
+
52
+ INCLUDE_IDLE=0
53
+ OUTPUT_DIR=""
54
+
55
+ while [[ $# -gt 0 ]]; do
56
+ case "$1" in
57
+ --include-idle) INCLUDE_IDLE=1; shift ;;
58
+ --output) OUTPUT_DIR="$2"; shift 2 ;;
59
+ -h|--help)
60
+ sed -n '3,34p' "$0" | sed 's/^# \?//'
61
+ exit 0
62
+ ;;
63
+ *)
64
+ echo "unknown flag: $1" >&2
65
+ exit 1
66
+ ;;
67
+ esac
68
+ done
69
+
70
+ # Default output dir
71
+ if [[ -z "$OUTPUT_DIR" ]]; then
72
+ OUTPUT_DIR="/tmp/cross-version-test-$(date +%Y%m%d-%H%M%S)"
73
+ fi
74
+
75
+ mkdir -p "$OUTPUT_DIR"
76
+ SUMMARY="$OUTPUT_DIR/summary.md"
77
+ echo "# Cross-Version Cache Test — $(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$SUMMARY"
78
+ echo "" >> "$SUMMARY"
79
+ echo "Output directory: \`$OUTPUT_DIR\`" >> "$SUMMARY"
80
+ echo "" >> "$SUMMARY"
81
+
82
+ # ─── Preflight ──────────────────────────────────────────────────────────────
83
+
84
+ echo "=== Cross-version cache test ===" | tee -a "$SUMMARY"
85
+
86
+ # Check launcher
87
+ if [[ ! -x "$HOME/bin/cc-version" ]]; then
88
+ echo "ERROR: $HOME/bin/cc-version not found or not executable" >&2
89
+ exit 1
90
+ fi
91
+
92
+ # Check installed versions
93
+ for v in "${VERSIONS[@]}"; do
94
+ if [[ ! -f "$HOME/cc-versions/$v/node_modules/@anthropic-ai/claude-code/cli.js" ]]; then
95
+ echo "ERROR: v$v not installed at ~/cc-versions/$v — run the install snippet in docs/march-23-regression-investigation.md" >&2
96
+ exit 1
97
+ fi
98
+ done
99
+
100
+ # Quota safety check — abort if Q5h is already high
101
+ Q5H=$(python3 -c "
102
+ import json
103
+ try:
104
+ q = json.load(open('$HOME/.claude/quota-status.json'))
105
+ print(q['five_hour']['pct'])
106
+ except Exception:
107
+ print(0)
108
+ " 2>/dev/null || echo 0)
109
+
110
+ if [[ "$Q5H" -gt 80 ]]; then
111
+ echo "ABORT: Q5h is at ${Q5H}% — too close to cap. Test deferred." | tee -a "$SUMMARY"
112
+ exit 2
113
+ fi
114
+
115
+ echo "Preflight OK: Q5h at ${Q5H}%, 4 versions installed, launcher present." | tee -a "$SUMMARY"
116
+ echo "" | tee -a "$SUMMARY"
117
+
118
+ # Snapshot quota state at start
119
+ cp "$HOME/.claude/quota-status.json" "$OUTPUT_DIR/raw-quota-status-start.json" 2>/dev/null || true
120
+
121
+ # ─── Phase A: steady-state per version ─────────────────────────────────────
122
+
123
+ echo "## Phase A — Steady-state" | tee -a "$SUMMARY"
124
+ echo "" | tee -a "$SUMMARY"
125
+ echo "5 sequential Haiku calls per version, fired in quick succession (<30s gap each)." | tee -a "$SUMMARY"
126
+ echo "" | tee -a "$SUMMARY"
127
+
128
+ for v in "${VERSIONS[@]}"; do
129
+ echo "--- Phase A: v$v ---"
130
+ OUTFILE="$OUTPUT_DIR/$v-phase-a.jsonl"
131
+ : > "$OUTFILE"
132
+
133
+ for i in $(seq 1 "$STEADY_STATE_TURNS"); do
134
+ USAGE_LINES_BEFORE=$(wc -l < "$HOME/.claude/usage.jsonl" 2>/dev/null || echo 0)
135
+ echo "$SEED_PROMPT" | "$HOME/bin/cc-version" "$v" -p --model "$MODEL" > /dev/null 2>&1 || {
136
+ echo "WARNING: v$v turn $i failed" | tee -a "$SUMMARY"
137
+ continue
138
+ }
139
+ USAGE_LINES_AFTER=$(wc -l < "$HOME/.claude/usage.jsonl" 2>/dev/null || echo 0)
140
+ if [[ "$USAGE_LINES_AFTER" -gt "$USAGE_LINES_BEFORE" ]]; then
141
+ # Capture the newly-added usage.jsonl line(s) for this version
142
+ tail -n "$((USAGE_LINES_AFTER - USAGE_LINES_BEFORE))" "$HOME/.claude/usage.jsonl" >> "$OUTFILE"
143
+ fi
144
+ # Tiny sleep to let the interceptor finish writing the telemetry
145
+ sleep 0.5
146
+ done
147
+
148
+ TURNS_CAPTURED=$(wc -l < "$OUTFILE")
149
+ echo " v$v: $TURNS_CAPTURED turns captured → $OUTFILE"
150
+ done
151
+
152
+ echo "" | tee -a "$SUMMARY"
153
+
154
+ # ─── Phase B: idle-gap (optional) ──────────────────────────────────────────
155
+
156
+ if [[ "$INCLUDE_IDLE" -eq 1 ]]; then
157
+ echo "## Phase B — Idle-gap behavior" | tee -a "$SUMMARY"
158
+ echo "" | tee -a "$SUMMARY"
159
+ echo "Per version: turn 1, wait ${IDLE_GAP_SECONDS}s (crosses 5m TTL), turn 2." | tee -a "$SUMMARY"
160
+ echo "" | tee -a "$SUMMARY"
161
+
162
+ for v in "${VERSIONS[@]}"; do
163
+ echo "--- Phase B: v$v ---"
164
+ OUTFILE="$OUTPUT_DIR/$v-phase-b.jsonl"
165
+ : > "$OUTFILE"
166
+
167
+ # Turn 1
168
+ USAGE_LINES_BEFORE=$(wc -l < "$HOME/.claude/usage.jsonl" 2>/dev/null || echo 0)
169
+ echo "$SEED_PROMPT" | "$HOME/bin/cc-version" "$v" -p --model "$MODEL" > /dev/null 2>&1 || true
170
+ USAGE_LINES_AFTER=$(wc -l < "$HOME/.claude/usage.jsonl" 2>/dev/null || echo 0)
171
+ if [[ "$USAGE_LINES_AFTER" -gt "$USAGE_LINES_BEFORE" ]]; then
172
+ tail -n "$((USAGE_LINES_AFTER - USAGE_LINES_BEFORE))" "$HOME/.claude/usage.jsonl" >> "$OUTFILE"
173
+ fi
174
+ echo " v$v: turn 1 done, waiting ${IDLE_GAP_SECONDS}s..."
175
+
176
+ sleep "$IDLE_GAP_SECONDS"
177
+
178
+ # Turn 2
179
+ USAGE_LINES_BEFORE=$(wc -l < "$HOME/.claude/usage.jsonl" 2>/dev/null || echo 0)
180
+ echo "$SEED_PROMPT" | "$HOME/bin/cc-version" "$v" -p --model "$MODEL" > /dev/null 2>&1 || true
181
+ USAGE_LINES_AFTER=$(wc -l < "$HOME/.claude/usage.jsonl" 2>/dev/null || echo 0)
182
+ if [[ "$USAGE_LINES_AFTER" -gt "$USAGE_LINES_BEFORE" ]]; then
183
+ tail -n "$((USAGE_LINES_AFTER - USAGE_LINES_BEFORE))" "$HOME/.claude/usage.jsonl" >> "$OUTFILE"
184
+ fi
185
+ echo " v$v: turn 2 done"
186
+ done
187
+
188
+ echo "" | tee -a "$SUMMARY"
189
+ fi
190
+
191
+ # Snapshot quota state at end
192
+ cp "$HOME/.claude/quota-status.json" "$OUTPUT_DIR/raw-quota-status-end.json" 2>/dev/null || true
193
+
194
+ # ─── Analysis ──────────────────────────────────────────────────────────────
195
+
196
+ echo "## Phase A Results" >> "$SUMMARY"
197
+ echo "" >> "$SUMMARY"
198
+
199
+ python3 <<EOF >> "$SUMMARY"
200
+ import json, os
201
+
202
+ output_dir = "$OUTPUT_DIR"
203
+ versions = ["2.1.81", "2.1.83", "2.1.90", "2.1.101"]
204
+ include_idle = $INCLUDE_IDLE
205
+
206
+ def load_jsonl(path):
207
+ if not os.path.exists(path):
208
+ return []
209
+ rows = []
210
+ with open(path) as f:
211
+ for line in f:
212
+ line = line.strip()
213
+ if line:
214
+ try:
215
+ rows.append(json.loads(line))
216
+ except Exception:
217
+ pass
218
+ return rows
219
+
220
+ # Phase A steady-state table
221
+ print("### Per-version per-turn usage (Phase A)")
222
+ print("")
223
+ print("| Version | Turn | cc (creation) | cr (read) | prefix | out | ttl | q5h% |")
224
+ print("|---|---:|---:|---:|---:|---:|---|---:|")
225
+
226
+ for v in versions:
227
+ rows = load_jsonl(os.path.join(output_dir, f"{v}-phase-a.jsonl"))
228
+ for i, r in enumerate(rows, 1):
229
+ cc = r.get("cache_creation_input_tokens", 0)
230
+ cr = r.get("cache_read_input_tokens", 0)
231
+ prefix = cc + cr
232
+ out = r.get("output_tokens", 0)
233
+ ttl = r.get("ttl_tier", "?")
234
+ q5h = r.get("q5h_pct", "?")
235
+ print(f"| v{v} | {i} | {cc:>6,} | {cr:>6,} | {prefix:>6,} | {out:>3} | {ttl} | {q5h}% |")
236
+
237
+ print("")
238
+
239
+ # Steady-state summary: turn-2-onwards averages
240
+ print("### Steady-state averages (turns 2-5)")
241
+ print("")
242
+ print("| Version | avg prefix | avg cc | avg cr | cache hit rate | Turn 1 cold cc | q5h delta turn 1→5 |")
243
+ print("|---|---:|---:|---:|---:|---:|---:|")
244
+ for v in versions:
245
+ rows = load_jsonl(os.path.join(output_dir, f"{v}-phase-a.jsonl"))
246
+ if len(rows) < 2:
247
+ print(f"| v{v} | (insufficient data) | | | | | |")
248
+ continue
249
+ turn1 = rows[0]
250
+ tail = rows[1:]
251
+ avg_prefix = sum((r.get("cache_creation_input_tokens",0) + r.get("cache_read_input_tokens",0)) for r in tail) / len(tail)
252
+ avg_cc = sum(r.get("cache_creation_input_tokens",0) for r in tail) / len(tail)
253
+ avg_cr = sum(r.get("cache_read_input_tokens",0) for r in tail) / len(tail)
254
+ hit_rate = avg_cr / avg_prefix if avg_prefix > 0 else 0
255
+ q5h_start = rows[0].get("q5h_pct", 0)
256
+ q5h_end = rows[-1].get("q5h_pct", 0)
257
+ q5h_delta = (q5h_end - q5h_start) if isinstance(q5h_start, (int, float)) and isinstance(q5h_end, (int, float)) else "?"
258
+ print(f"| v{v} | {avg_prefix:>7,.0f} | {avg_cc:>6,.0f} | {avg_cr:>6,.0f} | {hit_rate*100:.1f}% | {turn1.get('cache_creation_input_tokens',0):>7,} | {q5h_delta}% |")
259
+
260
+ print("")
261
+
262
+ if include_idle:
263
+ print("## Phase B Results (idle-gap behavior)")
264
+ print("")
265
+ print("| Version | Turn 1 prefix | Turn 1 ttl | idle (s) | Turn 2 cc | Turn 2 cr | Turn 2 ttl | rebuilt? |")
266
+ print("|---|---:|---|---:|---:|---:|---|:---:|")
267
+ for v in versions:
268
+ rows = load_jsonl(os.path.join(output_dir, f"{v}-phase-b.jsonl"))
269
+ if len(rows) < 2:
270
+ print(f"| v{v} | (incomplete) | | | | | | |")
271
+ continue
272
+ t1, t2 = rows[0], rows[1]
273
+ t1_prefix = t1.get("cache_creation_input_tokens",0) + t1.get("cache_read_input_tokens",0)
274
+ t2_cc = t2.get("cache_creation_input_tokens",0)
275
+ t2_cr = t2.get("cache_read_input_tokens",0)
276
+ # Idle gap we configured
277
+ idle_s = $IDLE_GAP_SECONDS
278
+ # Rebuilt = turn 2 had substantial cache_creation relative to turn 1 prefix
279
+ rebuilt = "✗ expired" if t2_cc > (t1_prefix * 0.5) else "✓ warm"
280
+ print(f"| v{v} | {t1_prefix:>7,} | {t1.get('ttl_tier','?')} | {idle_s} | {t2_cc:>7,} | {t2_cr:>7,} | {t2.get('ttl_tier','?')} | {rebuilt} |")
281
+ print("")
282
+
283
+ print("---")
284
+ print("")
285
+ print("*Generated by cross-version-cache-test.sh*")
286
+ EOF
287
+
288
+ echo ""
289
+ echo "=== Test complete ==="
290
+ echo "Summary written to: $SUMMARY"
291
+ echo ""
292
+ echo "Raw per-version JSONLs in: $OUTPUT_DIR"
293
+ echo ""
294
+ if [[ "$Q5H" -lt 50 ]]; then
295
+ NEW_Q5H=$(python3 -c "
296
+ import json
297
+ try:
298
+ print(json.load(open('$HOME/.claude/quota-status.json'))['five_hour']['pct'])
299
+ except Exception:
300
+ print('?')
301
+ " 2>/dev/null)
302
+ echo "Q5h at start: ${Q5H}%"
303
+ echo "Q5h at end: ${NEW_Q5H}%"
304
+ fi
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env bash
2
+ # sim-cost-reconcile — One-liner wrapper for running cost-report.mjs against
3
+ # a simulation log with admin API cross-reference enabled.
4
+ #
5
+ # Usage:
6
+ # sim-cost-reconcile <sim-dir-or-log> [extra cost-report.mjs args...]
7
+ #
8
+ # Examples:
9
+ # sim-cost-reconcile ~/git_repos/kanfei_test/kanfei-nowcast/.test_cache/simulations/realtime_sim_harnett_county_qlcs_2026_20260411_024836
10
+ # sim-cost-reconcile path/to/simulation.log --format md > report.md
11
+ #
12
+ # Reads admin key from $ANTHROPIC_ADMIN_KEY or ~/.config/anthropic/admin-key.
13
+ # If no admin key is available, runs with telemetry only and warns.
14
+ #
15
+ # NOTE on admin reconciliation: the admin API returns data at 1h-bucket
16
+ # resolution, so if multiple sims (or other API activity) overlap the same
17
+ # hour, the admin total will include all of it. For an accurate multi-sim
18
+ # aggregate, run this on each sim and sum the telemetry totals, then pull
19
+ # the admin total once over the full window.
20
+
21
+ set -euo pipefail
22
+
23
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
24
+ COST_REPORT="$SCRIPT_DIR/cost-report.mjs"
25
+
26
+ if [[ $# -lt 1 ]]; then
27
+ echo "Usage: $(basename "$0") <sim-dir-or-log> [extra cost-report args...]" >&2
28
+ exit 1
29
+ fi
30
+
31
+ TARGET="$1"
32
+ shift
33
+
34
+ # Resolve a dir to its simulation.log
35
+ if [[ -d "$TARGET" ]]; then
36
+ LOG="$TARGET/simulation.log"
37
+ if [[ ! -f "$LOG" ]]; then
38
+ echo "ERROR: no simulation.log in $TARGET" >&2
39
+ exit 1
40
+ fi
41
+ elif [[ -f "$TARGET" ]]; then
42
+ LOG="$TARGET"
43
+ else
44
+ echo "ERROR: $TARGET is neither a file nor a directory" >&2
45
+ exit 1
46
+ fi
47
+
48
+ # Load admin key
49
+ ADMIN_KEY_FILE="${HOME}/.config/anthropic/admin-key"
50
+ if [[ -n "${ANTHROPIC_ADMIN_KEY:-}" ]]; then
51
+ KEY="$ANTHROPIC_ADMIN_KEY"
52
+ elif [[ -r "$ADMIN_KEY_FILE" ]]; then
53
+ KEY="$(cat "$ADMIN_KEY_FILE")"
54
+ else
55
+ echo "WARNING: no admin key found ($ADMIN_KEY_FILE missing, ANTHROPIC_ADMIN_KEY unset)" >&2
56
+ echo " running telemetry-only — pass --admin-key or set env var to enable reconciliation" >&2
57
+ exec node "$COST_REPORT" --sim-log "$LOG" "$@"
58
+ fi
59
+
60
+ exec node "$COST_REPORT" --sim-log "$LOG" --admin-key "$KEY" "$@"
@@ -0,0 +1,352 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * usage-to-dashboard-ndjson — Translate claude-code-cache-fix's usage.jsonl
4
+ * into the proxy NDJSON format expected by @fgrosswig's claude-usage-dashboard,
5
+ * and write to the directory his dashboard already watches.
6
+ *
7
+ * https://github.com/fgrosswig/claude-usage-dashboard
8
+ *
9
+ * # Why this exists
10
+ *
11
+ * Our interceptor and fgrosswig's dashboard are strongly complementary:
12
+ * the interceptor captures per-call API data from inside the Node.js process
13
+ * (cache metrics, quota state, request rewrites), while his dashboard
14
+ * provides visualization, historical trending, and multi-host aggregation.
15
+ *
16
+ * Rather than build our own visualization layer, we translate our per-call
17
+ * usage records into the NDJSON schema his dashboard ingests. A user running
18
+ * both tools gets the best of both: the interceptor fixes what it can fix
19
+ * and emits rich per-call data, and the dashboard displays that data
20
+ * alongside whatever Claude Code's own session JSONLs already capture.
21
+ *
22
+ * # What this tool does
23
+ *
24
+ * Reads `~/.claude/usage.jsonl` (our interceptor's per-call log) and
25
+ * translates each entry into a minimal-but-compatible record in the shape
26
+ * his dashboard expects under `~/.claude/anthropic-proxy-logs/*.ndjson`.
27
+ * The output file follows the convention `proxy-YYYY-MM-DD.ndjson`, one
28
+ * file per UTC day, matching the filename pattern his `collectProxyNdjsonFiles()`
29
+ * helper discovers.
30
+ *
31
+ * # Fields emitted
32
+ *
33
+ * Mapped from our usage.jsonl to fgrosswig's proxy-core.js shape:
34
+ *
35
+ * {
36
+ * "ts_start": <our timestamp>,
37
+ * "ts_end": <our timestamp>, // single-point, no duration
38
+ * "duration_ms": null, // we don't measure this
39
+ * "method": "POST",
40
+ * "path": "/v1/messages",
41
+ * "upstream_status": 200, // implicit from usage presence
42
+ * "usage": {
43
+ * "input_tokens": <ours>,
44
+ * "output_tokens": <ours>,
45
+ * "cache_read_input_tokens": <ours>,
46
+ * "cache_creation_input_tokens": <ours>
47
+ * },
48
+ * "cache_read_ratio": <computed>,
49
+ * "cache_health": "healthy" | "affected" | "mixed",
50
+ * "request_hints": { "model": <ours> },
51
+ * "response_anthropic_headers": { // if quota fields available
52
+ * "anthropic-ratelimit-unified-5h-utilization": "<ours>",
53
+ * "anthropic-ratelimit-unified-7d-utilization": "<ours>"
54
+ * },
55
+ * "ttl_tier": <ours, interceptor-specific>,
56
+ * "ephemeral_1h_input_tokens": <ours, interceptor-specific>,
57
+ * "ephemeral_5m_input_tokens": <ours, interceptor-specific>,
58
+ * "source": "claude-code-cache-fix"
59
+ * }
60
+ *
61
+ * Extra fields beyond fgrosswig's native schema (ttl_tier, ephemeral_*,
62
+ * source) are added for forward-compatibility — his dashboard ignores
63
+ * unknown fields per its tolerant-ingest design, and our own tooling
64
+ * downstream may find them useful when consuming the same NDJSON.
65
+ *
66
+ * # Usage
67
+ *
68
+ * # One-shot translation (reads all of usage.jsonl, writes today's file)
69
+ * node tools/usage-to-dashboard-ndjson.mjs
70
+ *
71
+ * # Follow mode (tail usage.jsonl, append new records as they arrive)
72
+ * node tools/usage-to-dashboard-ndjson.mjs --follow
73
+ *
74
+ * # Custom input/output paths
75
+ * node tools/usage-to-dashboard-ndjson.mjs --input /path/to/usage.jsonl --output-dir /path/to/ndjson-dir
76
+ *
77
+ * # Dry-run: print to stdout instead of writing files
78
+ * node tools/usage-to-dashboard-ndjson.mjs --stdout
79
+ *
80
+ * # Environment
81
+ *
82
+ * ANTHROPIC_PROXY_LOG_DIR Override output directory (matches fgrosswig's
83
+ * dashboard env var so both tools stay in sync).
84
+ *
85
+ * Part of claude-code-cache-fix. MIT licensed.
86
+ * https://github.com/cnighswonger/claude-code-cache-fix
87
+ */
88
+
89
+ import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync, statSync, watch } from 'node:fs';
90
+ import { join } from 'node:path';
91
+ import { homedir } from 'node:os';
92
+
93
+ // ─── CLI parsing ────────────────────────────────────────────────────────────
94
+
95
+ function parseArgs() {
96
+ const args = process.argv.slice(2);
97
+ const opts = {
98
+ input: join(homedir(), '.claude', 'usage.jsonl'),
99
+ outputDir: process.env.ANTHROPIC_PROXY_LOG_DIR || join(homedir(), '.claude', 'anthropic-proxy-logs'),
100
+ stdout: false,
101
+ follow: false,
102
+ help: false,
103
+ };
104
+
105
+ for (let i = 0; i < args.length; i++) {
106
+ switch (args[i]) {
107
+ case '--input': opts.input = args[++i]; break;
108
+ case '--output-dir': opts.outputDir = args[++i]; break;
109
+ case '--stdout': opts.stdout = true; break;
110
+ case '--follow': opts.follow = true; break;
111
+ case '-h':
112
+ case '--help': opts.help = true; break;
113
+ default:
114
+ console.error(`unknown flag: ${args[i]}`);
115
+ opts.help = true;
116
+ }
117
+ }
118
+
119
+ return opts;
120
+ }
121
+
122
+ function printUsage() {
123
+ console.log(`usage-to-dashboard-ndjson — Translate cache-fix usage.jsonl to fgrosswig dashboard NDJSON.
124
+
125
+ Usage:
126
+ node usage-to-dashboard-ndjson.mjs One-shot: read all, write today's file
127
+ node usage-to-dashboard-ndjson.mjs --follow Tail usage.jsonl, append new records live
128
+ node usage-to-dashboard-ndjson.mjs --stdout Print NDJSON to stdout instead of files
129
+ node usage-to-dashboard-ndjson.mjs --input <path> Custom input (default: ~/.claude/usage.jsonl)
130
+ node usage-to-dashboard-ndjson.mjs --output-dir <path> Custom output dir (default: ~/.claude/anthropic-proxy-logs)
131
+
132
+ Output files follow the convention: proxy-YYYY-MM-DD.ndjson (one per UTC day).
133
+
134
+ Environment:
135
+ ANTHROPIC_PROXY_LOG_DIR Override output directory (also used by fgrosswig's dashboard).
136
+
137
+ Credit: this tool writes the NDJSON schema expected by @fgrosswig's
138
+ claude-usage-dashboard (https://github.com/fgrosswig/claude-usage-dashboard).
139
+ Running both tools together gives users per-call data from our interceptor
140
+ plus the visualization layer from his dashboard, with no coordination needed.
141
+ `);
142
+ }
143
+
144
+ // ─── Record translation ─────────────────────────────────────────────────────
145
+
146
+ /**
147
+ * Translate one claude-code-cache-fix usage.jsonl record into a
148
+ * fgrosswig-dashboard-compatible NDJSON record. Returns null if the
149
+ * record doesn't have enough fields to be usable.
150
+ */
151
+ function translateRecord(entry) {
152
+ if (!entry || !entry.timestamp || !entry.model) return null;
153
+
154
+ const inTok = entry.input_tokens || 0;
155
+ const outTok = entry.output_tokens || 0;
156
+ const crTok = entry.cache_read_input_tokens || 0;
157
+ const ccTok = entry.cache_creation_input_tokens || 0;
158
+
159
+ // Cache health (fgrosswig's semantic labels)
160
+ const totalCacheInput = crTok + ccTok;
161
+ const cacheReadRatio = totalCacheInput > 0 ? crTok / totalCacheInput : null;
162
+ let cacheHealth = 'na';
163
+ if (cacheReadRatio != null) {
164
+ if (cacheReadRatio >= 0.8) cacheHealth = 'healthy';
165
+ else if (cacheReadRatio < 0.4 && ccTok > 0) cacheHealth = 'affected';
166
+ else cacheHealth = 'mixed';
167
+ }
168
+
169
+ // Reconstruct a minimal response_anthropic_headers blob from the quota
170
+ // pct fields we captured. Not byte-identical to what the proxy would see
171
+ // on the wire, but structurally compatible for the dashboard's consumers.
172
+ const responseHeaders = {};
173
+ if (entry.q5h_pct != null) {
174
+ responseHeaders['anthropic-ratelimit-unified-5h-utilization'] = String(entry.q5h_pct / 100);
175
+ }
176
+ if (entry.q7d_pct != null) {
177
+ responseHeaders['anthropic-ratelimit-unified-7d-utilization'] = String(entry.q7d_pct / 100);
178
+ }
179
+
180
+ const rec = {
181
+ ts_start: entry.timestamp,
182
+ ts_end: entry.timestamp,
183
+ duration_ms: null,
184
+ method: 'POST',
185
+ path: '/v1/messages',
186
+ upstream_status: 200,
187
+ usage: {
188
+ input_tokens: inTok,
189
+ output_tokens: outTok,
190
+ cache_read_input_tokens: crTok,
191
+ cache_creation_input_tokens: ccTok,
192
+ },
193
+ cache_read_ratio: cacheReadRatio,
194
+ cache_health: cacheHealth,
195
+ request_hints: {
196
+ model: entry.model,
197
+ },
198
+ response_anthropic_headers: responseHeaders,
199
+ // Interceptor-specific extras — fgrosswig's dashboard ignores unknown
200
+ // fields, so these pass through without breaking ingestion.
201
+ ttl_tier: entry.ttl_tier || null,
202
+ ephemeral_1h_input_tokens: entry.ephemeral_1h_input_tokens || 0,
203
+ ephemeral_5m_input_tokens: entry.ephemeral_5m_input_tokens || 0,
204
+ peak_hour: entry.peak_hour || false,
205
+ source: 'claude-code-cache-fix',
206
+ };
207
+
208
+ // Synthesize a stable pseudo-request-id from timestamp + model for dedup
209
+ // at the dashboard layer. Not a real request ID — just a deterministic key.
210
+ rec.req_id = 'ccf_' + entry.timestamp.replace(/[^0-9]/g, '') + '_' + entry.model.slice(-6);
211
+
212
+ return rec;
213
+ }
214
+
215
+ // ─── File output ────────────────────────────────────────────────────────────
216
+
217
+ function dayFileFor(outputDir, isoTimestamp) {
218
+ // proxy-YYYY-MM-DD.ndjson from UTC date
219
+ const date = isoTimestamp.slice(0, 10);
220
+ return join(outputDir, `proxy-${date}.ndjson`);
221
+ }
222
+
223
+ function ensureDir(dir) {
224
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
225
+ }
226
+
227
+ function writeRecords(records, outputDir, useStdout) {
228
+ if (useStdout) {
229
+ for (const r of records) {
230
+ process.stdout.write(JSON.stringify(r) + '\n');
231
+ }
232
+ return records.length;
233
+ }
234
+
235
+ ensureDir(outputDir);
236
+
237
+ // Group by day for efficient appending
238
+ const byDay = new Map();
239
+ for (const r of records) {
240
+ const day = dayFileFor(outputDir, r.ts_start);
241
+ if (!byDay.has(day)) byDay.set(day, []);
242
+ byDay.get(day).push(r);
243
+ }
244
+
245
+ for (const [dayFile, dayRecords] of byDay) {
246
+ const payload = dayRecords.map(r => JSON.stringify(r)).join('\n') + '\n';
247
+ // Overwrite on one-shot mode — the tool is idempotent within a single
248
+ // input file, so rewriting today's file from a full replay is safe.
249
+ writeFileSync(dayFile, payload);
250
+ }
251
+
252
+ return records.length;
253
+ }
254
+
255
+ // ─── One-shot batch mode ────────────────────────────────────────────────────
256
+
257
+ function runBatch(opts) {
258
+ if (!existsSync(opts.input)) {
259
+ console.error(`ERROR: input file not found: ${opts.input}`);
260
+ process.exit(1);
261
+ }
262
+
263
+ const raw = readFileSync(opts.input, 'utf8');
264
+ const lines = raw.split('\n').filter(l => l.trim());
265
+ const records = [];
266
+ let skipped = 0;
267
+
268
+ for (const line of lines) {
269
+ try {
270
+ const entry = JSON.parse(line);
271
+ const rec = translateRecord(entry);
272
+ if (rec) records.push(rec);
273
+ else skipped++;
274
+ } catch {
275
+ skipped++;
276
+ }
277
+ }
278
+
279
+ const written = writeRecords(records, opts.outputDir, opts.stdout);
280
+ if (!opts.stdout) {
281
+ console.error(`usage-to-dashboard-ndjson: wrote ${written} records to ${opts.outputDir} (${skipped} skipped)`);
282
+ }
283
+ }
284
+
285
+ // ─── Follow mode ────────────────────────────────────────────────────────────
286
+
287
+ function runFollow(opts) {
288
+ if (!existsSync(opts.input)) {
289
+ console.error(`ERROR: input file not found: ${opts.input}`);
290
+ process.exit(1);
291
+ }
292
+
293
+ // First, catch up on the existing file (idempotent write)
294
+ runBatch(opts);
295
+
296
+ // Then watch for new entries
297
+ console.error(`usage-to-dashboard-ndjson: watching ${opts.input} for new records...`);
298
+ let lastSize = statSync(opts.input).size;
299
+
300
+ watch(opts.input, { persistent: true }, () => {
301
+ let currentSize;
302
+ try { currentSize = statSync(opts.input).size; } catch { return; }
303
+ if (currentSize <= lastSize) {
304
+ // File truncated or unchanged — rewind lastSize
305
+ if (currentSize < lastSize) lastSize = 0;
306
+ return;
307
+ }
308
+ // Read only the new bytes
309
+ try {
310
+ const fd = readFileSync(opts.input, 'utf8');
311
+ const newContent = fd.slice(lastSize);
312
+ lastSize = currentSize;
313
+ const newLines = newContent.split('\n').filter(l => l.trim());
314
+ const newRecs = [];
315
+ for (const line of newLines) {
316
+ try {
317
+ const entry = JSON.parse(line);
318
+ const rec = translateRecord(entry);
319
+ if (rec) newRecs.push(rec);
320
+ } catch {}
321
+ }
322
+ if (newRecs.length > 0) {
323
+ // Append to today's dayfile per record
324
+ ensureDir(opts.outputDir);
325
+ for (const r of newRecs) {
326
+ const dayFile = dayFileFor(opts.outputDir, r.ts_start);
327
+ appendFileSync(dayFile, JSON.stringify(r) + '\n');
328
+ }
329
+ console.error(`[${new Date().toISOString()}] appended ${newRecs.length} records`);
330
+ }
331
+ } catch (err) {
332
+ console.error(`watch error: ${err.message}`);
333
+ }
334
+ });
335
+
336
+ // Keep the process alive
337
+ process.stdin.resume();
338
+ }
339
+
340
+ // ─── Main ───────────────────────────────────────────────────────────────────
341
+
342
+ const opts = parseArgs();
343
+ if (opts.help) {
344
+ printUsage();
345
+ process.exit(0);
346
+ }
347
+
348
+ if (opts.follow) {
349
+ runFollow(opts);
350
+ } else {
351
+ runBatch(opts);
352
+ }