claude-code-tracker 1.0.0
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 +169 -0
- package/install.sh +52 -0
- package/package.json +26 -0
- package/src/cost-summary.py +102 -0
- package/src/generate-charts.py +497 -0
- package/src/init-templates.sh +83 -0
- package/src/stop-hook.sh +113 -0
- package/src/update-prompts-index.py +60 -0
- package/uninstall.sh +61 -0
package/README.md
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# claude-code-tracker
|
|
2
|
+
|
|
3
|
+
Automatic token usage, cost estimation, and prompt quality tracking for [Claude Code](https://claude.ai/claude-code) sessions.
|
|
4
|
+
|
|
5
|
+
After every session, it parses the transcript, updates a `tokens.json` ledger, regenerates a Chart.js dashboard (`charts.html`), and rebuilds the key-prompts index. Zero external dependencies — pure bash and Python stdlib.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## What it tracks
|
|
10
|
+
|
|
11
|
+
- **Token usage** per session: input, cache write, cache read, output
|
|
12
|
+
- **Estimated API cost** (list-price equivalent — not a subscription charge)
|
|
13
|
+
- **Per-model breakdown**: Opus vs Sonnet cost split
|
|
14
|
+
- **Key prompts**: high-signal prompts you log manually, with category and context
|
|
15
|
+
- **Prompt efficiency**: ratio of key prompts to total human messages
|
|
16
|
+
|
|
17
|
+
All data lives in `<project>/.claude/tracking/` alongside your code.
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
### Option 1 — npm (global)
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npm install -g claude-code-tracker
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The `postinstall` script copies the tracking scripts to `~/.claude/tracking/` and registers the Stop hook in `~/.claude/settings.json`.
|
|
30
|
+
|
|
31
|
+
### Option 2 — Homebrew
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
brew tap ${YOUR_USER}/claude-code-tracker
|
|
35
|
+
brew install claude-code-tracker
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### Option 3 — git clone
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
git clone https://github.com/kelsi-andrewss/claude-code-tracker.git
|
|
42
|
+
cd claude-code-tracker
|
|
43
|
+
./install.sh
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Restart Claude Code after any install method.
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## What gets created
|
|
51
|
+
|
|
52
|
+
On first use in a project, the Stop hook auto-initializes `<project>/.claude/tracking/`:
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
.claude/tracking/
|
|
56
|
+
tokens.json # session data (auto-updated)
|
|
57
|
+
charts.html # Chart.js dashboard (auto-updated)
|
|
58
|
+
key-prompts.md # index of logged prompts
|
|
59
|
+
key-prompts/ # one .md per day
|
|
60
|
+
2026-02-18.md
|
|
61
|
+
cost-analysis.md # template for manual notes
|
|
62
|
+
ai-dev-log.md # template for dev log
|
|
63
|
+
sessions.md # session log template
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## View the dashboard
|
|
69
|
+
|
|
70
|
+
Open `charts.html` in a browser:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
open .claude/tracking/charts.html # macOS
|
|
74
|
+
xdg-open .claude/tracking/charts.html # Linux
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
The dashboard shows cumulative cost, cost per day, sessions, output tokens, model breakdown, and prompt analytics — all updated automatically after each session.
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## Cost CLI
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
claude-tracker-cost
|
|
85
|
+
# or
|
|
86
|
+
python3 ~/.claude/tracking/cost-summary.py
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Prints a cost summary table for the current project.
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
## Logging key prompts
|
|
94
|
+
|
|
95
|
+
Add entries to `<project>/.claude/tracking/key-prompts/YYYY-MM-DD.md` (today's date). The index and charts update automatically on the next session end.
|
|
96
|
+
|
|
97
|
+
Entry format:
|
|
98
|
+
|
|
99
|
+
```markdown
|
|
100
|
+
## 2026-02-18 — Short title
|
|
101
|
+
|
|
102
|
+
**Category**: breakthrough | bug-resolution | architecture | feature
|
|
103
|
+
**Context**: What problem was being solved?
|
|
104
|
+
**The Prompt**: (exact or close paraphrase)
|
|
105
|
+
**Why It Worked**: (what made the phrasing/framing effective)
|
|
106
|
+
**Prior Attempts That Failed**: (for bugs: what didn't work; otherwise: N/A)
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Auto-logging with CLAUDE.md
|
|
110
|
+
|
|
111
|
+
Add the following to your project's `CLAUDE.md` to have Claude log prompts automatically:
|
|
112
|
+
|
|
113
|
+
```markdown
|
|
114
|
+
## Tracking
|
|
115
|
+
After completing significant work (bug fix, feature, refactor, plan approval), append a prompt
|
|
116
|
+
assessment entry to `<project>/.claude/tracking/key-prompts/YYYY-MM-DD.md` (today's date).
|
|
117
|
+
Create the file if it doesn't exist, using the same header format as existing files.
|
|
118
|
+
|
|
119
|
+
Use this format:
|
|
120
|
+
## [date] — [short title]
|
|
121
|
+
**Category**: breakthrough | bug-resolution | architecture | feature
|
|
122
|
+
**Context**: What problem was being solved?
|
|
123
|
+
**The Prompt**: (exact or close paraphrase)
|
|
124
|
+
**Why It Worked**: (what made the phrasing/framing effective)
|
|
125
|
+
**Prior Attempts That Failed**: (for bugs: what didn't work; otherwise: N/A)
|
|
126
|
+
|
|
127
|
+
Only write entries for genuinely high-signal prompts. Skip routine exchanges.
|
|
128
|
+
Do not ask permission — just append after significant work.
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
## Uninstall
|
|
134
|
+
|
|
135
|
+
### npm
|
|
136
|
+
```bash
|
|
137
|
+
npm uninstall -g claude-code-tracker
|
|
138
|
+
~/.claude/tracking/uninstall.sh
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### Homebrew
|
|
142
|
+
```bash
|
|
143
|
+
brew uninstall claude-code-tracker
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### Manual
|
|
147
|
+
```bash
|
|
148
|
+
./uninstall.sh
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
The uninstaller removes the scripts from `~/.claude/tracking/` and removes the Stop hook from `~/.claude/settings.json`. Your project tracking data (tokens.json, charts.html, key-prompts/) is left intact.
|
|
152
|
+
|
|
153
|
+
---
|
|
154
|
+
|
|
155
|
+
## Cost note
|
|
156
|
+
|
|
157
|
+
Figures shown are **API list-price equivalents** — what pay-as-you-go API customers would be charged at current Anthropic pricing. If you are on a Max subscription, these are not amounts billed to you.
|
|
158
|
+
|
|
159
|
+
Current rates used:
|
|
160
|
+
| Model | Input | Cache write | Cache read | Output |
|
|
161
|
+
|-------|-------|-------------|------------|--------|
|
|
162
|
+
| Sonnet | $3/M | $3.75/M | $0.30/M | $15/M |
|
|
163
|
+
| Opus | $15/M | $18.75/M | $1.50/M | $75/M |
|
|
164
|
+
|
|
165
|
+
---
|
|
166
|
+
|
|
167
|
+
## License
|
|
168
|
+
|
|
169
|
+
MIT
|
package/install.sh
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
5
|
+
INSTALL_DIR="$HOME/.claude/tracking"
|
|
6
|
+
SETTINGS="$HOME/.claude/settings.json"
|
|
7
|
+
|
|
8
|
+
echo "Installing claude-code-tracker..."
|
|
9
|
+
|
|
10
|
+
# Copy scripts
|
|
11
|
+
mkdir -p "$INSTALL_DIR"
|
|
12
|
+
cp "$SCRIPT_DIR/src/"*.sh "$SCRIPT_DIR/src/"*.py "$INSTALL_DIR/"
|
|
13
|
+
chmod +x "$INSTALL_DIR/"*.sh "$INSTALL_DIR/"*.py
|
|
14
|
+
|
|
15
|
+
echo "Scripts installed to $INSTALL_DIR"
|
|
16
|
+
|
|
17
|
+
# Patch settings.json — add Stop hook if not already present
|
|
18
|
+
python3 - "$SETTINGS" "$INSTALL_DIR/stop-hook.sh" <<'PYEOF'
|
|
19
|
+
import sys, json, os
|
|
20
|
+
|
|
21
|
+
settings_file = sys.argv[1]
|
|
22
|
+
hook_cmd = sys.argv[2]
|
|
23
|
+
|
|
24
|
+
data = {}
|
|
25
|
+
if os.path.exists(settings_file):
|
|
26
|
+
try:
|
|
27
|
+
with open(settings_file) as f:
|
|
28
|
+
data = json.load(f)
|
|
29
|
+
except Exception:
|
|
30
|
+
data = {}
|
|
31
|
+
|
|
32
|
+
hook_entry = {"type": "command", "command": hook_cmd, "timeout": 30, "async": True}
|
|
33
|
+
hooks = data.setdefault("hooks", {})
|
|
34
|
+
stop_hooks = hooks.setdefault("Stop", [])
|
|
35
|
+
|
|
36
|
+
# Check if already registered
|
|
37
|
+
for group in stop_hooks:
|
|
38
|
+
for h in group.get("hooks", []):
|
|
39
|
+
if h.get("command") == hook_cmd:
|
|
40
|
+
print("Hook already registered.")
|
|
41
|
+
sys.exit(0)
|
|
42
|
+
|
|
43
|
+
stop_hooks.append({"hooks": [hook_entry]})
|
|
44
|
+
|
|
45
|
+
os.makedirs(os.path.dirname(os.path.abspath(settings_file)), exist_ok=True)
|
|
46
|
+
with open(settings_file, 'w') as f:
|
|
47
|
+
json.dump(data, f, indent=2)
|
|
48
|
+
f.write('\n')
|
|
49
|
+
print("Hook registered in", settings_file)
|
|
50
|
+
PYEOF
|
|
51
|
+
|
|
52
|
+
echo "claude-code-tracker installed. Restart Claude Code to activate."
|
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "claude-code-tracker",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Automatic token, cost, and prompt tracking for Claude Code sessions",
|
|
5
|
+
"keywords": ["claude", "claude-code", "anthropic", "tracking", "cost", "tokens"],
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/kelsi-andrewss/claude-code-tracker"
|
|
10
|
+
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"postinstall": "bash ./install.sh"
|
|
13
|
+
},
|
|
14
|
+
"bin": {
|
|
15
|
+
"claude-tracker-cost": "./src/cost-summary.py"
|
|
16
|
+
},
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=14"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"src/",
|
|
22
|
+
"install.sh",
|
|
23
|
+
"uninstall.sh",
|
|
24
|
+
"README.md"
|
|
25
|
+
]
|
|
26
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Usage:
|
|
4
|
+
python3 cost-summary.py <tokens.json>
|
|
5
|
+
python3 cost-summary.py (defaults to .claude/tracking/tokens.json in cwd's git root)
|
|
6
|
+
"""
|
|
7
|
+
import sys
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
from collections import defaultdict
|
|
11
|
+
from datetime import date
|
|
12
|
+
|
|
13
|
+
def find_tokens_file():
|
|
14
|
+
cwd = os.getcwd()
|
|
15
|
+
root = cwd
|
|
16
|
+
while root != "/":
|
|
17
|
+
if os.path.isdir(os.path.join(root, ".git")):
|
|
18
|
+
break
|
|
19
|
+
root = os.path.dirname(root)
|
|
20
|
+
path = os.path.join(root, ".claude", "tracking", "tokens.json")
|
|
21
|
+
if os.path.exists(path):
|
|
22
|
+
return path
|
|
23
|
+
sys.exit(f"No tokens.json found at {path}")
|
|
24
|
+
|
|
25
|
+
tokens_file = sys.argv[1] if len(sys.argv) > 1 else find_tokens_file()
|
|
26
|
+
|
|
27
|
+
with open(tokens_file) as f:
|
|
28
|
+
data = json.load(f)
|
|
29
|
+
|
|
30
|
+
if not data:
|
|
31
|
+
print("No sessions recorded yet.")
|
|
32
|
+
sys.exit(0)
|
|
33
|
+
|
|
34
|
+
# --- Aggregate ---
|
|
35
|
+
by_date = defaultdict(lambda: {"cost": 0, "sessions": 0, "output": 0, "cache_read": 0, "cache_create": 0, "input": 0})
|
|
36
|
+
by_model = defaultdict(lambda: {"cost": 0, "sessions": 0})
|
|
37
|
+
total_cost = 0
|
|
38
|
+
total_sessions = len(data)
|
|
39
|
+
sessions_with_tokens = 0
|
|
40
|
+
|
|
41
|
+
for e in data:
|
|
42
|
+
d = e.get("date", "unknown")
|
|
43
|
+
cost = e.get("estimated_cost_usd", 0)
|
|
44
|
+
model = e.get("model", "unknown")
|
|
45
|
+
short_model = model.split("-20")[0] if "-20" in model else model
|
|
46
|
+
|
|
47
|
+
by_date[d]["cost"] += cost
|
|
48
|
+
by_date[d]["sessions"] += 1
|
|
49
|
+
by_date[d]["output"] += e.get("output_tokens", 0)
|
|
50
|
+
by_date[d]["cache_read"] += e.get("cache_read_tokens", 0)
|
|
51
|
+
by_date[d]["cache_create"] += e.get("cache_creation_tokens", 0)
|
|
52
|
+
by_date[d]["input"] += e.get("input_tokens", 0)
|
|
53
|
+
|
|
54
|
+
by_model[short_model]["cost"] += cost
|
|
55
|
+
by_model[short_model]["sessions"] += 1
|
|
56
|
+
|
|
57
|
+
total_cost += cost
|
|
58
|
+
if e.get("total_tokens", 0) > 0:
|
|
59
|
+
sessions_with_tokens += 1
|
|
60
|
+
|
|
61
|
+
total_output = sum(e.get("output_tokens", 0) for e in data)
|
|
62
|
+
total_cache_read = sum(e.get("cache_read_tokens", 0) for e in data)
|
|
63
|
+
total_cache_create = sum(e.get("cache_creation_tokens", 0) for e in data)
|
|
64
|
+
total_input = sum(e.get("input_tokens", 0) for e in data)
|
|
65
|
+
|
|
66
|
+
# --- Print ---
|
|
67
|
+
W = 60
|
|
68
|
+
print("=" * W)
|
|
69
|
+
print(f" Cost Summary — {os.path.basename(os.path.dirname(os.path.dirname(tokens_file)))}")
|
|
70
|
+
print("=" * W)
|
|
71
|
+
|
|
72
|
+
print(f"\nBy date:")
|
|
73
|
+
print(f" {'Date':<12} {'Sessions':>8} {'Output':>10} {'Cache Read':>12} {'Cost':>10}")
|
|
74
|
+
print(f" {'-'*12} {'-'*8} {'-'*10} {'-'*12} {'-'*10}")
|
|
75
|
+
for d in sorted(by_date):
|
|
76
|
+
r = by_date[d]
|
|
77
|
+
print(f" {d:<12} {r['sessions']:>8} {r['output']:>10,} {r['cache_read']:>12,} ${r['cost']:>9.2f}")
|
|
78
|
+
|
|
79
|
+
print(f"\nBy model:")
|
|
80
|
+
print(f" {'Model':<30} {'Sessions':>8} {'Cost':>10}")
|
|
81
|
+
print(f" {'-'*30} {'-'*8} {'-'*10}")
|
|
82
|
+
for m in sorted(by_model, key=lambda x: -by_model[x]["cost"]):
|
|
83
|
+
r = by_model[m]
|
|
84
|
+
print(f" {m:<30} {r['sessions']:>8} ${r['cost']:>9.2f}")
|
|
85
|
+
|
|
86
|
+
print(f"\nTotals:")
|
|
87
|
+
print(f" Sessions: {total_sessions:>8} ({sessions_with_tokens} with token data)")
|
|
88
|
+
print(f" Input tokens: {total_input:>12,}")
|
|
89
|
+
print(f" Cache write: {total_cache_create:>12,}")
|
|
90
|
+
print(f" Cache read: {total_cache_read:>12,}")
|
|
91
|
+
print(f" Output tokens: {total_output:>12,}")
|
|
92
|
+
print(f" Estimated cost: ${total_cost:>11.2f}")
|
|
93
|
+
|
|
94
|
+
if total_output > 0:
|
|
95
|
+
cache_pct = total_cache_read / (total_input + total_cache_create + total_cache_read + total_output) * 100
|
|
96
|
+
print(f" Cache read share: {cache_pct:>10.1f}% of all tokens")
|
|
97
|
+
|
|
98
|
+
days = len(by_date)
|
|
99
|
+
if days > 1:
|
|
100
|
+
print(f"\n Avg cost/day: ${total_cost/days:>11.2f} over {days} days")
|
|
101
|
+
|
|
102
|
+
print("=" * W)
|
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Generates tracking/charts.html from tokens.json + key-prompts/ folder.
|
|
4
|
+
Called by stop-hook.sh after each session update.
|
|
5
|
+
|
|
6
|
+
Usage: python3 generate-charts.py <tokens.json> <output.html>
|
|
7
|
+
"""
|
|
8
|
+
import sys, json, os, re, glob
|
|
9
|
+
from collections import defaultdict
|
|
10
|
+
|
|
11
|
+
tokens_file = sys.argv[1]
|
|
12
|
+
output_file = sys.argv[2]
|
|
13
|
+
|
|
14
|
+
with open(tokens_file) as f:
|
|
15
|
+
data = json.load(f)
|
|
16
|
+
|
|
17
|
+
if not data:
|
|
18
|
+
sys.exit(0)
|
|
19
|
+
|
|
20
|
+
# --- Aggregate by date ---
|
|
21
|
+
by_date = defaultdict(lambda: {"cost": 0, "sessions": 0, "output": 0,
|
|
22
|
+
"cache_read": 0, "cache_create": 0, "input": 0,
|
|
23
|
+
"opus_cost": 0, "sonnet_cost": 0})
|
|
24
|
+
by_model = defaultdict(lambda: {"cost": 0, "sessions": 0})
|
|
25
|
+
cumulative = []
|
|
26
|
+
|
|
27
|
+
running_cost = 0
|
|
28
|
+
for e in sorted(data, key=lambda x: (x.get("date", ""), x.get("session_id", ""))):
|
|
29
|
+
d = e.get("date", "unknown")
|
|
30
|
+
cost = e.get("estimated_cost_usd", 0)
|
|
31
|
+
model = e.get("model", "unknown")
|
|
32
|
+
short = model.split("-20")[0] if "-20" in model else model
|
|
33
|
+
|
|
34
|
+
by_date[d]["cost"] += cost
|
|
35
|
+
by_date[d]["sessions"] += 1
|
|
36
|
+
by_date[d]["output"] += e.get("output_tokens", 0)
|
|
37
|
+
by_date[d]["cache_read"] += e.get("cache_read_tokens", 0)
|
|
38
|
+
by_date[d]["cache_create"] += e.get("cache_creation_tokens", 0)
|
|
39
|
+
by_date[d]["input"] += e.get("input_tokens", 0)
|
|
40
|
+
if "opus" in model:
|
|
41
|
+
by_date[d]["opus_cost"] += cost
|
|
42
|
+
else:
|
|
43
|
+
by_date[d]["sonnet_cost"] += cost
|
|
44
|
+
|
|
45
|
+
by_model[short]["cost"] += cost
|
|
46
|
+
by_model[short]["sessions"] += 1
|
|
47
|
+
|
|
48
|
+
running_cost += cost
|
|
49
|
+
cumulative.append({"date": d, "cumulative_cost": round(running_cost, 4),
|
|
50
|
+
"session_id": e.get("session_id", "")[:8]})
|
|
51
|
+
|
|
52
|
+
dates = sorted(by_date.keys())
|
|
53
|
+
total_cost = sum(e.get("estimated_cost_usd", 0) for e in data)
|
|
54
|
+
total_sessions = len(data)
|
|
55
|
+
sessions_with_data = sum(1 for e in data if e.get("total_tokens", 0) > 0)
|
|
56
|
+
total_output = sum(e.get("output_tokens", 0) for e in data)
|
|
57
|
+
total_cache_read = sum(e.get("cache_read_tokens", 0) for e in data)
|
|
58
|
+
total_all_tokens = sum(e.get("total_tokens", 0) for e in data)
|
|
59
|
+
cache_pct = round(total_cache_read / total_all_tokens * 100, 1) if total_all_tokens > 0 else 0
|
|
60
|
+
|
|
61
|
+
project_name = data[0].get("project", "Project") if data else "Project"
|
|
62
|
+
|
|
63
|
+
# --- Count total human messages per date from JSONL transcripts ---
|
|
64
|
+
project_dir = os.path.dirname(os.path.dirname(os.path.dirname(tokens_file))) # project root
|
|
65
|
+
# Claude Code slugifies paths as: replace every "/" with "-" (keeping leading slash → leading dash)
|
|
66
|
+
transcripts_dir = os.path.expanduser(
|
|
67
|
+
"~/.claude/projects/" + project_dir.replace("/", "-")
|
|
68
|
+
)
|
|
69
|
+
human_by_date = defaultdict(int)
|
|
70
|
+
|
|
71
|
+
if os.path.isdir(transcripts_dir):
|
|
72
|
+
for jf in glob.glob(os.path.join(transcripts_dir, "*.jsonl")):
|
|
73
|
+
# Use session date from tokens.json if available, else file mtime
|
|
74
|
+
sid = os.path.splitext(os.path.basename(jf))[0]
|
|
75
|
+
session_date = None
|
|
76
|
+
for e in data:
|
|
77
|
+
if e.get("session_id") == sid:
|
|
78
|
+
session_date = e.get("date")
|
|
79
|
+
break
|
|
80
|
+
if not session_date:
|
|
81
|
+
import datetime
|
|
82
|
+
session_date = datetime.datetime.fromtimestamp(
|
|
83
|
+
os.path.getmtime(jf)).strftime("%Y-%m-%d")
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
with open(jf) as f:
|
|
87
|
+
for line in f:
|
|
88
|
+
try:
|
|
89
|
+
obj = json.loads(line)
|
|
90
|
+
# Human messages have type="user" and userType="human" at the top level
|
|
91
|
+
if obj.get("type") != "user":
|
|
92
|
+
continue
|
|
93
|
+
if obj.get("userType") not in ("human", "external", None):
|
|
94
|
+
continue
|
|
95
|
+
if obj.get("isSidechain"):
|
|
96
|
+
continue
|
|
97
|
+
content = obj.get("message", {}).get("content", "")
|
|
98
|
+
if isinstance(content, list):
|
|
99
|
+
# Skip pure tool-result messages
|
|
100
|
+
has_real_text = any(
|
|
101
|
+
isinstance(c, dict) and c.get("type") == "text"
|
|
102
|
+
and not str(c.get("text", "")).strip().startswith("<")
|
|
103
|
+
for c in content
|
|
104
|
+
)
|
|
105
|
+
if has_real_text:
|
|
106
|
+
human_by_date[session_date] += 1
|
|
107
|
+
elif isinstance(content, str):
|
|
108
|
+
text = content.strip()
|
|
109
|
+
# Skip slash commands and empty
|
|
110
|
+
if text and not text.startswith("<") and not text.startswith("/"):
|
|
111
|
+
human_by_date[session_date] += 1
|
|
112
|
+
except:
|
|
113
|
+
pass
|
|
114
|
+
except:
|
|
115
|
+
pass
|
|
116
|
+
|
|
117
|
+
total_human_msgs = sum(human_by_date.values())
|
|
118
|
+
|
|
119
|
+
# --- Aggregate prompt data from key-prompts/ folder ---
|
|
120
|
+
prompts_dir = os.path.join(os.path.dirname(tokens_file), "key-prompts")
|
|
121
|
+
prompt_files = sorted(glob.glob(os.path.join(prompts_dir, "????-??-??.md")))
|
|
122
|
+
|
|
123
|
+
prompt_by_date = {} # date -> {total, by_category}
|
|
124
|
+
all_categories = set()
|
|
125
|
+
|
|
126
|
+
for f in prompt_files:
|
|
127
|
+
date = os.path.splitext(os.path.basename(f))[0]
|
|
128
|
+
content = open(f).read()
|
|
129
|
+
cats = re.findall(r'^\*\*Category\*\*: (\S+)', content, re.MULTILINE)
|
|
130
|
+
by_cat = defaultdict(int)
|
|
131
|
+
for c in cats:
|
|
132
|
+
by_cat[c] += 1
|
|
133
|
+
all_categories.add(c)
|
|
134
|
+
prompt_by_date[date] = {"total": len(cats), "by_category": dict(by_cat)}
|
|
135
|
+
|
|
136
|
+
all_categories = sorted(all_categories)
|
|
137
|
+
prompt_dates = sorted(prompt_by_date.keys())
|
|
138
|
+
total_prompts = sum(v["total"] for v in prompt_by_date.values())
|
|
139
|
+
|
|
140
|
+
# Build JS data structures
|
|
141
|
+
dates_js = json.dumps(dates)
|
|
142
|
+
cost_by_date_js = json.dumps([round(by_date[d]["cost"], 4) for d in dates])
|
|
143
|
+
sessions_by_date_js = json.dumps([by_date[d]["sessions"] for d in dates])
|
|
144
|
+
output_by_date_js = json.dumps([by_date[d]["output"] for d in dates])
|
|
145
|
+
cache_read_by_date_js = json.dumps([by_date[d]["cache_read"] for d in dates])
|
|
146
|
+
opus_by_date_js = json.dumps([round(by_date[d]["opus_cost"], 4) for d in dates])
|
|
147
|
+
sonnet_by_date_js = json.dumps([round(by_date[d]["sonnet_cost"], 4) for d in dates])
|
|
148
|
+
|
|
149
|
+
cumul_labels_js = json.dumps([f"{c['date']} #{i+1}" for i, c in enumerate(cumulative)])
|
|
150
|
+
cumul_values_js = json.dumps([c["cumulative_cost"] for c in cumulative])
|
|
151
|
+
|
|
152
|
+
model_labels_js = json.dumps(list(by_model.keys()))
|
|
153
|
+
model_costs_js = json.dumps([round(by_model[m]["cost"], 4) for m in by_model])
|
|
154
|
+
model_sessions_js = json.dumps([by_model[m]["sessions"] for m in by_model])
|
|
155
|
+
|
|
156
|
+
# All dates union for prompts vs total chart
|
|
157
|
+
all_prompt_dates = sorted(set(list(prompt_by_date.keys()) + list(human_by_date.keys())))
|
|
158
|
+
all_prompt_dates_js = json.dumps(all_prompt_dates)
|
|
159
|
+
total_msgs_by_date_js = json.dumps([human_by_date.get(d, 0) for d in all_prompt_dates])
|
|
160
|
+
key_prompts_by_date_js = json.dumps([prompt_by_date.get(d, {}).get("total", 0) for d in all_prompt_dates])
|
|
161
|
+
|
|
162
|
+
# Efficiency ratio per date (key / total * 100), None if no messages
|
|
163
|
+
efficiency_by_date = []
|
|
164
|
+
for d in all_prompt_dates:
|
|
165
|
+
total = human_by_date.get(d, 0)
|
|
166
|
+
key = prompt_by_date.get(d, {}).get("total", 0)
|
|
167
|
+
efficiency_by_date.append(round(key / total * 100, 1) if total > 0 else None)
|
|
168
|
+
efficiency_by_date_js = json.dumps(efficiency_by_date)
|
|
169
|
+
|
|
170
|
+
overall_efficiency = round(total_prompts / total_human_msgs * 100, 1) if total_human_msgs > 0 else 0
|
|
171
|
+
|
|
172
|
+
# Prompt chart data
|
|
173
|
+
prompt_dates_js = json.dumps(prompt_dates)
|
|
174
|
+
prompt_totals_js = json.dumps([prompt_by_date[d]["total"] for d in prompt_dates])
|
|
175
|
+
|
|
176
|
+
CAT_COLORS = {
|
|
177
|
+
"bug-resolution": "#f87171",
|
|
178
|
+
"architecture": "#6366f1",
|
|
179
|
+
"feature": "#34d399",
|
|
180
|
+
"breakthrough": "#f59e0b",
|
|
181
|
+
}
|
|
182
|
+
DEFAULT_COLOR = "#94a3b8"
|
|
183
|
+
|
|
184
|
+
cat_datasets = []
|
|
185
|
+
for cat in all_categories:
|
|
186
|
+
cat_datasets.append({
|
|
187
|
+
"label": cat,
|
|
188
|
+
"data": [prompt_by_date[d]["by_category"].get(cat, 0) for d in prompt_dates],
|
|
189
|
+
"backgroundColor": CAT_COLORS.get(cat, DEFAULT_COLOR),
|
|
190
|
+
"borderRadius": 2,
|
|
191
|
+
})
|
|
192
|
+
cat_datasets_js = json.dumps(cat_datasets)
|
|
193
|
+
|
|
194
|
+
# Category totals for doughnut
|
|
195
|
+
cat_totals = {c: sum(prompt_by_date[d]["by_category"].get(c, 0) for d in prompt_dates)
|
|
196
|
+
for c in all_categories}
|
|
197
|
+
donut_labels_js = json.dumps(list(cat_totals.keys()))
|
|
198
|
+
donut_values_js = json.dumps(list(cat_totals.values()))
|
|
199
|
+
donut_colors_js = json.dumps([CAT_COLORS.get(c, DEFAULT_COLOR) for c in cat_totals])
|
|
200
|
+
|
|
201
|
+
html = f"""<!DOCTYPE html>
|
|
202
|
+
<html lang="en">
|
|
203
|
+
<head>
|
|
204
|
+
<meta charset="UTF-8">
|
|
205
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
206
|
+
<title>Claude Code — {project_name} tracking</title>
|
|
207
|
+
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
|
208
|
+
<style>
|
|
209
|
+
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
|
|
210
|
+
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
211
|
+
background: #0f1117; color: #e2e8f0; padding: 24px; }}
|
|
212
|
+
h1 {{ font-size: 1.25rem; font-weight: 600; margin-bottom: 4px; color: #f8fafc; }}
|
|
213
|
+
.subtitle {{ font-size: 0.8rem; color: #64748b; margin-bottom: 24px; }}
|
|
214
|
+
.stats {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
|
215
|
+
gap: 12px; margin-bottom: 28px; }}
|
|
216
|
+
.stat {{ background: #1e2330; border: 1px solid #2d3748; border-radius: 10px;
|
|
217
|
+
padding: 14px 16px; }}
|
|
218
|
+
.stat-label {{ font-size: 0.7rem; color: #64748b; text-transform: uppercase;
|
|
219
|
+
letter-spacing: 0.05em; margin-bottom: 4px; }}
|
|
220
|
+
.stat-value {{ font-size: 1.4rem; font-weight: 700; color: #f8fafc; }}
|
|
221
|
+
.stat-sub {{ font-size: 0.7rem; color: #94a3b8; margin-top: 2px; }}
|
|
222
|
+
.grid {{ display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }}
|
|
223
|
+
.card {{ background: #1e2330; border: 1px solid #2d3748; border-radius: 10px;
|
|
224
|
+
padding: 16px; }}
|
|
225
|
+
.card.wide {{ grid-column: 1 / -1; }}
|
|
226
|
+
.card h2 {{ font-size: 0.8rem; font-weight: 600; color: #94a3b8;
|
|
227
|
+
text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 14px; }}
|
|
228
|
+
canvas {{ max-height: 220px; }}
|
|
229
|
+
.wide canvas {{ max-height: 180px; }}
|
|
230
|
+
.notice {{ font-size: 0.78rem; color: #94a3b8; background: #1e2330;
|
|
231
|
+
border: 1px solid #3b4a6b; border-left: 3px solid #6366f1;
|
|
232
|
+
border-radius: 6px; padding: 10px 14px; margin-bottom: 20px; }}
|
|
233
|
+
.notice strong {{ color: #e2e8f0; }}
|
|
234
|
+
@media (max-width: 700px) {{ .grid {{ grid-template-columns: 1fr; }} }}
|
|
235
|
+
</style>
|
|
236
|
+
</head>
|
|
237
|
+
<body>
|
|
238
|
+
<h1>Claude Code — {project_name}</h1>
|
|
239
|
+
<p class="subtitle">Updated after every session — open in browser to view</p>
|
|
240
|
+
<p class="notice">ⓘ Cost figures are <strong>API list-price equivalents</strong> (what pay-as-you-go API customers would be charged). If you are on a Max subscription, these are <em>not</em> amounts billed to you.</p>
|
|
241
|
+
|
|
242
|
+
<div class="stats">
|
|
243
|
+
<div class="stat">
|
|
244
|
+
<div class="stat-label">API list-price equivalent</div>
|
|
245
|
+
<div class="stat-value">${total_cost:.2f}</div>
|
|
246
|
+
<div class="stat-sub">across {len(dates)} day{"s" if len(dates) != 1 else ""} (not billed)</div>
|
|
247
|
+
</div>
|
|
248
|
+
<div class="stat">
|
|
249
|
+
<div class="stat-label">Sessions</div>
|
|
250
|
+
<div class="stat-value">{total_sessions}</div>
|
|
251
|
+
<div class="stat-sub">{sessions_with_data} with token data</div>
|
|
252
|
+
</div>
|
|
253
|
+
<div class="stat">
|
|
254
|
+
<div class="stat-label">Output tokens</div>
|
|
255
|
+
<div class="stat-value">{total_output:,}</div>
|
|
256
|
+
<div class="stat-sub"> </div>
|
|
257
|
+
</div>
|
|
258
|
+
<div class="stat">
|
|
259
|
+
<div class="stat-label">Cache read share</div>
|
|
260
|
+
<div class="stat-value">{cache_pct}%</div>
|
|
261
|
+
<div class="stat-sub">of all tokens</div>
|
|
262
|
+
</div>
|
|
263
|
+
<div class="stat">
|
|
264
|
+
<div class="stat-label">Key prompts captured</div>
|
|
265
|
+
<div class="stat-value">{total_prompts}</div>
|
|
266
|
+
<div class="stat-sub">of {total_human_msgs:,} total prompts</div>
|
|
267
|
+
</div>
|
|
268
|
+
<div class="stat">
|
|
269
|
+
<div class="stat-label">Prompt efficiency</div>
|
|
270
|
+
<div class="stat-value">{overall_efficiency}%</div>
|
|
271
|
+
<div class="stat-sub">key / total (higher = better)</div>
|
|
272
|
+
</div>
|
|
273
|
+
</div>
|
|
274
|
+
|
|
275
|
+
<div class="grid">
|
|
276
|
+
|
|
277
|
+
<div class="card wide">
|
|
278
|
+
<h2>Cumulative cost over sessions</h2>
|
|
279
|
+
<canvas id="cumul"></canvas>
|
|
280
|
+
</div>
|
|
281
|
+
|
|
282
|
+
<div class="card">
|
|
283
|
+
<h2>Cost per day</h2>
|
|
284
|
+
<canvas id="costDay"></canvas>
|
|
285
|
+
</div>
|
|
286
|
+
|
|
287
|
+
<div class="card">
|
|
288
|
+
<h2>Sessions per day</h2>
|
|
289
|
+
<canvas id="sessDay"></canvas>
|
|
290
|
+
</div>
|
|
291
|
+
|
|
292
|
+
<div class="card">
|
|
293
|
+
<h2>Cost by model (stacked per day)</h2>
|
|
294
|
+
<canvas id="modelStack"></canvas>
|
|
295
|
+
</div>
|
|
296
|
+
|
|
297
|
+
<div class="card">
|
|
298
|
+
<h2>Output tokens per day</h2>
|
|
299
|
+
<canvas id="outputDay"></canvas>
|
|
300
|
+
</div>
|
|
301
|
+
|
|
302
|
+
</div>
|
|
303
|
+
|
|
304
|
+
<h2 style="font-size:0.85rem;font-weight:600;color:#94a3b8;text-transform:uppercase;
|
|
305
|
+
letter-spacing:0.05em;margin:32px 0 16px">Key Prompts</h2>
|
|
306
|
+
|
|
307
|
+
<div class="grid">
|
|
308
|
+
|
|
309
|
+
<div class="card wide">
|
|
310
|
+
<h2>Total prompts vs key prompts per day</h2>
|
|
311
|
+
<canvas id="promptsVsTotal"></canvas>
|
|
312
|
+
</div>
|
|
313
|
+
|
|
314
|
+
<div class="card">
|
|
315
|
+
<h2>Prompt efficiency per day (%)</h2>
|
|
316
|
+
<canvas id="promptEfficiency"></canvas>
|
|
317
|
+
</div>
|
|
318
|
+
|
|
319
|
+
<div class="card">
|
|
320
|
+
<h2>Category breakdown (all time)</h2>
|
|
321
|
+
<canvas id="promptDonut"></canvas>
|
|
322
|
+
</div>
|
|
323
|
+
|
|
324
|
+
<div class="card wide">
|
|
325
|
+
<h2>Category breakdown per day (stacked)</h2>
|
|
326
|
+
<canvas id="promptStack"></canvas>
|
|
327
|
+
</div>
|
|
328
|
+
|
|
329
|
+
</div>
|
|
330
|
+
|
|
331
|
+
<script>
|
|
332
|
+
const DATES = {dates_js};
|
|
333
|
+
const COST_BY_DATE = {cost_by_date_js};
|
|
334
|
+
const SESSIONS_BY_DATE = {sessions_by_date_js};
|
|
335
|
+
const OUTPUT_BY_DATE = {output_by_date_js};
|
|
336
|
+
const OPUS_BY_DATE = {opus_by_date_js};
|
|
337
|
+
const SONNET_BY_DATE = {sonnet_by_date_js};
|
|
338
|
+
const CUMUL_LABELS = {cumul_labels_js};
|
|
339
|
+
const CUMUL_VALUES = {cumul_values_js};
|
|
340
|
+
const MODEL_LABELS = {model_labels_js};
|
|
341
|
+
const MODEL_COSTS = {model_costs_js};
|
|
342
|
+
const MODEL_SESSIONS = {model_sessions_js};
|
|
343
|
+
const PROMPT_DATES = {prompt_dates_js};
|
|
344
|
+
const PROMPT_TOTALS = {prompt_totals_js};
|
|
345
|
+
const PROMPT_CAT_DATASETS = {cat_datasets_js};
|
|
346
|
+
const DONUT_LABELS = {donut_labels_js};
|
|
347
|
+
const DONUT_VALUES = {donut_values_js};
|
|
348
|
+
const DONUT_COLORS = {donut_colors_js};
|
|
349
|
+
const ALL_PROMPT_DATES = {all_prompt_dates_js};
|
|
350
|
+
const TOTAL_MSGS_BY_DATE = {total_msgs_by_date_js};
|
|
351
|
+
const KEY_PROMPTS_BY_DATE = {key_prompts_by_date_js};
|
|
352
|
+
const EFFICIENCY_BY_DATE = {efficiency_by_date_js};
|
|
353
|
+
|
|
354
|
+
const GRID = '#2d3748';
|
|
355
|
+
const TEXT = '#94a3b8';
|
|
356
|
+
const baseOpts = {{
|
|
357
|
+
responsive: true,
|
|
358
|
+
maintainAspectRatio: true,
|
|
359
|
+
plugins: {{ legend: {{ labels: {{ color: TEXT, boxWidth: 12, font: {{ size: 11 }} }} }} }},
|
|
360
|
+
scales: {{
|
|
361
|
+
x: {{ ticks: {{ color: TEXT, font: {{ size: 10 }} }}, grid: {{ color: GRID }} }},
|
|
362
|
+
y: {{ ticks: {{ color: TEXT, font: {{ size: 10 }} }}, grid: {{ color: GRID }} }}
|
|
363
|
+
}}
|
|
364
|
+
}};
|
|
365
|
+
|
|
366
|
+
// Cumulative cost line
|
|
367
|
+
new Chart(document.getElementById('cumul'), {{
|
|
368
|
+
type: 'line',
|
|
369
|
+
data: {{
|
|
370
|
+
labels: CUMUL_LABELS,
|
|
371
|
+
datasets: [{{ label: 'Cumulative cost ($)', data: CUMUL_VALUES,
|
|
372
|
+
borderColor: '#6366f1', backgroundColor: 'rgba(99,102,241,0.15)',
|
|
373
|
+
fill: true, tension: 0.3, pointRadius: 2 }}]
|
|
374
|
+
}},
|
|
375
|
+
options: {{ ...baseOpts, plugins: {{ ...baseOpts.plugins,
|
|
376
|
+
tooltip: {{ callbacks: {{ label: ctx => ' $' + ctx.parsed.y.toFixed(2) }} }} }} }}
|
|
377
|
+
}});
|
|
378
|
+
|
|
379
|
+
// Cost per day bar
|
|
380
|
+
new Chart(document.getElementById('costDay'), {{
|
|
381
|
+
type: 'bar',
|
|
382
|
+
data: {{
|
|
383
|
+
labels: DATES,
|
|
384
|
+
datasets: [{{ label: 'Cost ($)', data: COST_BY_DATE,
|
|
385
|
+
backgroundColor: '#6366f1', borderRadius: 4 }}]
|
|
386
|
+
}},
|
|
387
|
+
options: {{ ...baseOpts, plugins: {{ ...baseOpts.plugins,
|
|
388
|
+
tooltip: {{ callbacks: {{ label: ctx => ' $' + ctx.parsed.y.toFixed(2) }} }} }} }}
|
|
389
|
+
}});
|
|
390
|
+
|
|
391
|
+
// Sessions per day
|
|
392
|
+
new Chart(document.getElementById('sessDay'), {{
|
|
393
|
+
type: 'bar',
|
|
394
|
+
data: {{
|
|
395
|
+
labels: DATES,
|
|
396
|
+
datasets: [{{ label: 'Sessions', data: SESSIONS_BY_DATE,
|
|
397
|
+
backgroundColor: '#22d3ee', borderRadius: 4 }}]
|
|
398
|
+
}},
|
|
399
|
+
options: baseOpts
|
|
400
|
+
}});
|
|
401
|
+
|
|
402
|
+
// Model stacked per day
|
|
403
|
+
new Chart(document.getElementById('modelStack'), {{
|
|
404
|
+
type: 'bar',
|
|
405
|
+
data: {{
|
|
406
|
+
labels: DATES,
|
|
407
|
+
datasets: [
|
|
408
|
+
{{ label: 'Opus', data: OPUS_BY_DATE, backgroundColor: '#f59e0b', borderRadius: 2 }},
|
|
409
|
+
{{ label: 'Sonnet', data: SONNET_BY_DATE, backgroundColor: '#6366f1', borderRadius: 2 }}
|
|
410
|
+
]
|
|
411
|
+
}},
|
|
412
|
+
options: {{ ...baseOpts, scales: {{ ...baseOpts.scales, x: {{ ...baseOpts.scales.x, stacked: true }},
|
|
413
|
+
y: {{ ...baseOpts.scales.y, stacked: true }} }},
|
|
414
|
+
plugins: {{ ...baseOpts.plugins,
|
|
415
|
+
tooltip: {{ callbacks: {{ label: ctx => ' $' + ctx.parsed.y.toFixed(2) }} }} }} }}
|
|
416
|
+
}});
|
|
417
|
+
|
|
418
|
+
// Output tokens per day
|
|
419
|
+
new Chart(document.getElementById('outputDay'), {{
|
|
420
|
+
type: 'bar',
|
|
421
|
+
data: {{
|
|
422
|
+
labels: DATES,
|
|
423
|
+
datasets: [{{ label: 'Output tokens', data: OUTPUT_BY_DATE,
|
|
424
|
+
backgroundColor: '#34d399', borderRadius: 4 }}]
|
|
425
|
+
}},
|
|
426
|
+
options: baseOpts
|
|
427
|
+
}});
|
|
428
|
+
|
|
429
|
+
// Total vs key prompts per day
|
|
430
|
+
new Chart(document.getElementById('promptsVsTotal'), {{
|
|
431
|
+
type: 'bar',
|
|
432
|
+
data: {{
|
|
433
|
+
labels: ALL_PROMPT_DATES,
|
|
434
|
+
datasets: [
|
|
435
|
+
{{ label: 'Total prompts', data: TOTAL_MSGS_BY_DATE,
|
|
436
|
+
backgroundColor: 'rgba(148,163,184,0.35)', borderRadius: 4 }},
|
|
437
|
+
{{ label: 'Key prompts', data: KEY_PROMPTS_BY_DATE,
|
|
438
|
+
backgroundColor: '#a78bfa', borderRadius: 4 }}
|
|
439
|
+
]
|
|
440
|
+
}},
|
|
441
|
+
options: baseOpts
|
|
442
|
+
}});
|
|
443
|
+
|
|
444
|
+
// Efficiency % per day
|
|
445
|
+
new Chart(document.getElementById('promptEfficiency'), {{
|
|
446
|
+
type: 'line',
|
|
447
|
+
data: {{
|
|
448
|
+
labels: ALL_PROMPT_DATES,
|
|
449
|
+
datasets: [{{ label: 'Efficiency (%)', data: EFFICIENCY_BY_DATE,
|
|
450
|
+
borderColor: '#f59e0b', backgroundColor: 'rgba(245,158,11,0.15)',
|
|
451
|
+
fill: true, tension: 0.3, pointRadius: 3, spanGaps: true }}]
|
|
452
|
+
}},
|
|
453
|
+
options: {{ ...baseOpts, plugins: {{ ...baseOpts.plugins,
|
|
454
|
+
tooltip: {{ callbacks: {{ label: ctx => ' ' + ctx.parsed.y + '%' }} }} }},
|
|
455
|
+
scales: {{ ...baseOpts.scales,
|
|
456
|
+
y: {{ ...baseOpts.scales.y, min: 0, max: 100,
|
|
457
|
+
ticks: {{ ...baseOpts.scales.y.ticks, callback: v => v + '%' }} }} }} }}
|
|
458
|
+
}});
|
|
459
|
+
|
|
460
|
+
// Category doughnut
|
|
461
|
+
new Chart(document.getElementById('promptDonut'), {{
|
|
462
|
+
type: 'doughnut',
|
|
463
|
+
data: {{
|
|
464
|
+
labels: DONUT_LABELS,
|
|
465
|
+
datasets: [{{ data: DONUT_VALUES, backgroundColor: DONUT_COLORS,
|
|
466
|
+
borderWidth: 2, borderColor: '#1e2330' }}]
|
|
467
|
+
}},
|
|
468
|
+
options: {{
|
|
469
|
+
responsive: true,
|
|
470
|
+
maintainAspectRatio: true,
|
|
471
|
+
plugins: {{
|
|
472
|
+
legend: {{ position: 'right', labels: {{ color: TEXT, boxWidth: 12, font: {{ size: 11 }} }} }}
|
|
473
|
+
}}
|
|
474
|
+
}}
|
|
475
|
+
}});
|
|
476
|
+
|
|
477
|
+
// Category stacked per day
|
|
478
|
+
new Chart(document.getElementById('promptStack'), {{
|
|
479
|
+
type: 'bar',
|
|
480
|
+
data: {{
|
|
481
|
+
labels: PROMPT_DATES,
|
|
482
|
+
datasets: PROMPT_CAT_DATASETS
|
|
483
|
+
}},
|
|
484
|
+
options: {{ ...baseOpts,
|
|
485
|
+
scales: {{
|
|
486
|
+
x: {{ ...baseOpts.scales.x, stacked: true }},
|
|
487
|
+
y: {{ ...baseOpts.scales.y, stacked: true }}
|
|
488
|
+
}}
|
|
489
|
+
}}
|
|
490
|
+
}});
|
|
491
|
+
</script>
|
|
492
|
+
</body>
|
|
493
|
+
</html>
|
|
494
|
+
"""
|
|
495
|
+
|
|
496
|
+
with open(output_file, "w") as f:
|
|
497
|
+
f.write(html)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
TRACKING_DIR="$1"
|
|
4
|
+
mkdir -p "$TRACKING_DIR"
|
|
5
|
+
mkdir -p "$TRACKING_DIR/key-prompts"
|
|
6
|
+
|
|
7
|
+
cat > "$TRACKING_DIR/tokens.json" <<'EOF'
|
|
8
|
+
[]
|
|
9
|
+
EOF
|
|
10
|
+
|
|
11
|
+
cat > "$TRACKING_DIR/key-prompts.md" <<'EOF'
|
|
12
|
+
# Prompt Journal
|
|
13
|
+
|
|
14
|
+
High-signal prompts organized by day.
|
|
15
|
+
|
|
16
|
+
| File | Entries | Highlights |
|
|
17
|
+
|------|---------|------------|
|
|
18
|
+
|
|
19
|
+
**Total**: 0 entries
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
New entries go in `key-prompts/YYYY-MM-DD.md` for today's date. Create the file if it doesn't exist — use the same header format as existing files.
|
|
24
|
+
EOF
|
|
25
|
+
|
|
26
|
+
cat > "$TRACKING_DIR/sessions.md" <<'EOF'
|
|
27
|
+
# Session Log
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
EOF
|
|
31
|
+
|
|
32
|
+
cat > "$TRACKING_DIR/cost-analysis.md" <<'EOF'
|
|
33
|
+
# AI Cost Analysis
|
|
34
|
+
|
|
35
|
+
## Development Costs
|
|
36
|
+
|
|
37
|
+
| Date | Session Summary | Input | Cache Write | Cache Read | Output | Cost (USD) |
|
|
38
|
+
|------|----------------|-------|-------------|------------|--------|------------|
|
|
39
|
+
| | **Total** | | | | | **$0.00** |
|
|
40
|
+
|
|
41
|
+
*Token counts include prompt caching. Pricing: Sonnet 4.5 -- input $3/M, cache write $3.75/M, cache read $0.30/M, output $15/M. Opus 4.6 -- input $15/M, cache write $18.75/M, cache read $1.50/M, output $75/M.*
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## Anthropic Pricing Reference
|
|
46
|
+
|
|
47
|
+
| Model | Input (per M) | Output (per M) | Cache Write | Cache Read |
|
|
48
|
+
|-------|--------------|----------------|-------------|------------|
|
|
49
|
+
| Claude Opus 4.6 | $15.00 | $75.00 | $18.75 | $1.50 |
|
|
50
|
+
| Claude Sonnet 4.5 | $3.00 | $15.00 | $3.75 | $0.30 |
|
|
51
|
+
| Claude Haiku 4.5 | $0.80 | $4.00 | $1.00 | $0.08 |
|
|
52
|
+
EOF
|
|
53
|
+
|
|
54
|
+
cat > "$TRACKING_DIR/ai-dev-log.md" <<'EOF'
|
|
55
|
+
# AI Development Log
|
|
56
|
+
|
|
57
|
+
**Period**: Started $(date +%Y-%m-%d)
|
|
58
|
+
**Primary AI Tool**: Claude Code
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## Tools & Workflow
|
|
63
|
+
|
|
64
|
+
*Document your AI workflow here.*
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## Effective Prompts
|
|
69
|
+
|
|
70
|
+
See `key-prompts.md` for the full journal with context and analysis.
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## Code Analysis
|
|
75
|
+
|
|
76
|
+
*Document AI contribution estimates here.*
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## Key Learnings
|
|
81
|
+
|
|
82
|
+
*Document key learnings as you go.*
|
|
83
|
+
EOF
|
package/src/stop-hook.sh
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
5
|
+
INPUT="$(cat)"
|
|
6
|
+
|
|
7
|
+
# Prevent loops
|
|
8
|
+
STOP_ACTIVE="$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('stop_hook_active', False))" 2>/dev/null || echo "False")"
|
|
9
|
+
if [[ "$STOP_ACTIVE" == "True" ]]; then exit 0; fi
|
|
10
|
+
|
|
11
|
+
# Extract fields
|
|
12
|
+
CWD="$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('cwd',''))" 2>/dev/null || true)"
|
|
13
|
+
TRANSCRIPT="$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('transcript_path',''))" 2>/dev/null || true)"
|
|
14
|
+
SESSION_ID="$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('session_id',''))" 2>/dev/null || true)"
|
|
15
|
+
|
|
16
|
+
if [[ -z "$CWD" || -z "$TRANSCRIPT" || ! -f "$TRANSCRIPT" ]]; then exit 0; fi
|
|
17
|
+
|
|
18
|
+
# Find project root (walk up to .git)
|
|
19
|
+
PROJECT_ROOT="$CWD"
|
|
20
|
+
while [[ "$PROJECT_ROOT" != "/" ]]; do
|
|
21
|
+
[[ -d "$PROJECT_ROOT/.git" ]] && break
|
|
22
|
+
PROJECT_ROOT="$(dirname "$PROJECT_ROOT")"
|
|
23
|
+
done
|
|
24
|
+
if [[ "$PROJECT_ROOT" == "/" ]]; then exit 0; fi
|
|
25
|
+
|
|
26
|
+
TRACKING_DIR="$PROJECT_ROOT/.claude/tracking"
|
|
27
|
+
|
|
28
|
+
# Auto-initialize if missing
|
|
29
|
+
if [[ ! -d "$TRACKING_DIR" ]]; then
|
|
30
|
+
bash "$SCRIPT_DIR/init-templates.sh" "$TRACKING_DIR"
|
|
31
|
+
fi
|
|
32
|
+
|
|
33
|
+
# Parse token usage from JSONL and update tokens.json
|
|
34
|
+
python3 - "$TRANSCRIPT" "$TRACKING_DIR/tokens.json" "$SESSION_ID" "$(basename "$PROJECT_ROOT")" <<'PYEOF'
|
|
35
|
+
import sys, json, os
|
|
36
|
+
from datetime import date
|
|
37
|
+
|
|
38
|
+
transcript_path = sys.argv[1]
|
|
39
|
+
tokens_file = sys.argv[2]
|
|
40
|
+
session_id = sys.argv[3]
|
|
41
|
+
project_name = sys.argv[4]
|
|
42
|
+
today = date.today().isoformat()
|
|
43
|
+
|
|
44
|
+
# Sum all token usage from assistant messages in this session
|
|
45
|
+
inp = out = cache_create = cache_read = 0
|
|
46
|
+
model = "unknown"
|
|
47
|
+
with open(transcript_path) as f:
|
|
48
|
+
for line in f:
|
|
49
|
+
try:
|
|
50
|
+
obj = json.loads(line)
|
|
51
|
+
msg = obj.get('message', {})
|
|
52
|
+
if isinstance(msg, dict) and msg.get('role') == 'assistant':
|
|
53
|
+
usage = msg.get('usage', {})
|
|
54
|
+
if usage:
|
|
55
|
+
inp += usage.get('input_tokens', 0)
|
|
56
|
+
out += usage.get('output_tokens', 0)
|
|
57
|
+
cache_create += usage.get('cache_creation_input_tokens', 0)
|
|
58
|
+
cache_read += usage.get('cache_read_input_tokens', 0)
|
|
59
|
+
m = msg.get('model', '')
|
|
60
|
+
if m:
|
|
61
|
+
model = m
|
|
62
|
+
except:
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
total = inp + cache_create + cache_read + out
|
|
66
|
+
if 'opus' in model:
|
|
67
|
+
cost = inp * 15 / 1e6 + cache_create * 18.75 / 1e6 + cache_read * 1.50 / 1e6 + out * 75 / 1e6
|
|
68
|
+
else:
|
|
69
|
+
cost = inp * 3 / 1e6 + cache_create * 3.75 / 1e6 + cache_read * 0.30 / 1e6 + out * 15 / 1e6
|
|
70
|
+
|
|
71
|
+
# Load or create tokens.json
|
|
72
|
+
data = []
|
|
73
|
+
if os.path.exists(tokens_file):
|
|
74
|
+
try:
|
|
75
|
+
with open(tokens_file) as f:
|
|
76
|
+
data = json.load(f)
|
|
77
|
+
except:
|
|
78
|
+
data = []
|
|
79
|
+
|
|
80
|
+
# Build entry
|
|
81
|
+
entry = {
|
|
82
|
+
"date": today,
|
|
83
|
+
"project": project_name,
|
|
84
|
+
"session_id": session_id,
|
|
85
|
+
"input_tokens": inp,
|
|
86
|
+
"cache_creation_tokens": cache_create,
|
|
87
|
+
"cache_read_tokens": cache_read,
|
|
88
|
+
"output_tokens": out,
|
|
89
|
+
"total_tokens": total,
|
|
90
|
+
"estimated_cost_usd": round(cost, 4),
|
|
91
|
+
"model": model
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
# Update existing or append new
|
|
95
|
+
found = False
|
|
96
|
+
for i, e in enumerate(data):
|
|
97
|
+
if e.get('session_id') == session_id:
|
|
98
|
+
data[i] = entry
|
|
99
|
+
found = True
|
|
100
|
+
break
|
|
101
|
+
if not found:
|
|
102
|
+
data.append(entry)
|
|
103
|
+
|
|
104
|
+
with open(tokens_file, 'w') as f:
|
|
105
|
+
json.dump(data, f, indent=2)
|
|
106
|
+
f.write('\n')
|
|
107
|
+
PYEOF
|
|
108
|
+
|
|
109
|
+
# Regenerate charts
|
|
110
|
+
python3 "$SCRIPT_DIR/generate-charts.py" "$TRACKING_DIR/tokens.json" "$TRACKING_DIR/charts.html" 2>/dev/null || true
|
|
111
|
+
|
|
112
|
+
# Regenerate key-prompts index
|
|
113
|
+
python3 "$SCRIPT_DIR/update-prompts-index.py" "$TRACKING_DIR" 2>/dev/null || true
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Regenerates <tracking_dir>/key-prompts.md index from files in key-prompts/ folder.
|
|
4
|
+
Called by stop-hook.sh after each session.
|
|
5
|
+
|
|
6
|
+
Usage: python3 update-prompts-index.py <tracking_dir>
|
|
7
|
+
"""
|
|
8
|
+
import sys
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import glob
|
|
12
|
+
|
|
13
|
+
tracking_dir = sys.argv[1]
|
|
14
|
+
prompts_dir = os.path.join(tracking_dir, "key-prompts")
|
|
15
|
+
index_file = os.path.join(tracking_dir, "key-prompts.md")
|
|
16
|
+
|
|
17
|
+
if not os.path.isdir(prompts_dir):
|
|
18
|
+
sys.exit(0)
|
|
19
|
+
|
|
20
|
+
files = sorted(glob.glob(os.path.join(prompts_dir, "????-??-??.md")))
|
|
21
|
+
if not files:
|
|
22
|
+
sys.exit(0)
|
|
23
|
+
|
|
24
|
+
rows = []
|
|
25
|
+
total_entries = 0
|
|
26
|
+
|
|
27
|
+
for f in files:
|
|
28
|
+
date = os.path.splitext(os.path.basename(f))[0]
|
|
29
|
+
with open(f) as fh:
|
|
30
|
+
content = fh.read()
|
|
31
|
+
|
|
32
|
+
# Count entries (## headings that are not the title line)
|
|
33
|
+
entries = len(re.findall(r'^## (?!Key Prompts)', content, re.MULTILINE))
|
|
34
|
+
|
|
35
|
+
# Extract first 3 entry titles for highlights
|
|
36
|
+
titles = re.findall(r'^## (.+)', content, re.MULTILINE)
|
|
37
|
+
# Skip the file title (first line if it matches "Key Prompts — ...")
|
|
38
|
+
titles = [t for t in titles if not t.startswith("Key Prompts")]
|
|
39
|
+
highlights = ", ".join(titles[:3])
|
|
40
|
+
if len(titles) > 3:
|
|
41
|
+
highlights += "..."
|
|
42
|
+
|
|
43
|
+
rows.append((date, entries, highlights))
|
|
44
|
+
total_entries += entries
|
|
45
|
+
|
|
46
|
+
lines = ["# Prompt Journal\n",
|
|
47
|
+
"\nHigh-signal prompts organized by day.\n",
|
|
48
|
+
"\n| File | Entries | Highlights |\n",
|
|
49
|
+
"|------|---------|------------|\n"]
|
|
50
|
+
|
|
51
|
+
for date, entries, highlights in rows:
|
|
52
|
+
lines.append(f"| [{date}](key-prompts/{date}.md) | {entries} | {highlights} |\n")
|
|
53
|
+
|
|
54
|
+
lines.append(f"\n**Total**: {total_entries} entries across {len(rows)} day{'s' if len(rows) != 1 else ''}\n")
|
|
55
|
+
lines.append("\n---\n")
|
|
56
|
+
lines.append("\nNew entries go in `key-prompts/YYYY-MM-DD.md` for today's date. "
|
|
57
|
+
"Create the file if it doesn't exist — use the same header format as existing files.\n")
|
|
58
|
+
|
|
59
|
+
with open(index_file, "w") as f:
|
|
60
|
+
f.writelines(lines)
|
package/uninstall.sh
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
INSTALL_DIR="$HOME/.claude/tracking"
|
|
5
|
+
SETTINGS="$HOME/.claude/settings.json"
|
|
6
|
+
HOOK_CMD="$INSTALL_DIR/stop-hook.sh"
|
|
7
|
+
|
|
8
|
+
echo "Uninstalling claude-code-tracker..."
|
|
9
|
+
|
|
10
|
+
# Remove scripts
|
|
11
|
+
if [[ -d "$INSTALL_DIR" ]]; then
|
|
12
|
+
rm -f "$INSTALL_DIR/"*.sh "$INSTALL_DIR/"*.py
|
|
13
|
+
echo "Scripts removed from $INSTALL_DIR"
|
|
14
|
+
else
|
|
15
|
+
echo "Nothing to remove at $INSTALL_DIR"
|
|
16
|
+
fi
|
|
17
|
+
|
|
18
|
+
# Remove hook entry from settings.json
|
|
19
|
+
if [[ -f "$SETTINGS" ]]; then
|
|
20
|
+
python3 - "$SETTINGS" "$HOOK_CMD" <<'PYEOF'
|
|
21
|
+
import sys, json, os
|
|
22
|
+
|
|
23
|
+
settings_file = sys.argv[1]
|
|
24
|
+
hook_cmd = sys.argv[2]
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
with open(settings_file) as f:
|
|
28
|
+
data = json.load(f)
|
|
29
|
+
except Exception:
|
|
30
|
+
sys.exit(0)
|
|
31
|
+
|
|
32
|
+
hooks = data.get("hooks", {})
|
|
33
|
+
stop_hooks = hooks.get("Stop", [])
|
|
34
|
+
|
|
35
|
+
new_stop_hooks = []
|
|
36
|
+
removed = False
|
|
37
|
+
for group in stop_hooks:
|
|
38
|
+
new_group_hooks = [h for h in group.get("hooks", []) if h.get("command") != hook_cmd]
|
|
39
|
+
if len(new_group_hooks) < len(group.get("hooks", [])):
|
|
40
|
+
removed = True
|
|
41
|
+
if new_group_hooks:
|
|
42
|
+
new_stop_hooks.append({"hooks": new_group_hooks})
|
|
43
|
+
elif not removed:
|
|
44
|
+
new_stop_hooks.append(group)
|
|
45
|
+
|
|
46
|
+
if removed:
|
|
47
|
+
hooks["Stop"] = new_stop_hooks
|
|
48
|
+
if not hooks["Stop"]:
|
|
49
|
+
del hooks["Stop"]
|
|
50
|
+
if not hooks:
|
|
51
|
+
del data["hooks"]
|
|
52
|
+
with open(settings_file, 'w') as f:
|
|
53
|
+
json.dump(data, f, indent=2)
|
|
54
|
+
f.write('\n')
|
|
55
|
+
print("Hook removed from", settings_file)
|
|
56
|
+
else:
|
|
57
|
+
print("Hook not found in", settings_file)
|
|
58
|
+
PYEOF
|
|
59
|
+
fi
|
|
60
|
+
|
|
61
|
+
echo "claude-code-tracker uninstalled."
|