claude-code-tracker 1.2.3 → 1.4.0-beta.3
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 +38 -2
- package/bin/claude-tracker-cost.js +20 -0
- package/bin/claude-tracker-setup +10 -0
- package/install.js +21 -0
- package/install.sh +36 -2
- package/package.json +7 -3
- package/skills/view-tracking/SKILL.md +54 -0
- package/src/__pycache__/cost.cpython-312.pyc +0 -0
- package/src/__pycache__/parse_friction.cpython-312.pyc +0 -0
- package/src/__pycache__/parse_skills.cpython-312.pyc +0 -0
- package/src/__pycache__/platform_utils.cpython-312.pyc +0 -0
- package/src/__pycache__/storage.cpython-312.pyc +0 -0
- package/src/__pycache__/write-agent.cpython-312.pyc +0 -0
- package/src/__pycache__/write-turns.cpython-312.pyc +0 -0
- package/src/backfill.py +32 -52
- package/src/cost-summary.py +48 -11
- package/src/cost.py +7 -0
- package/src/export-json.py +27 -0
- package/src/generate-charts.py +567 -12
- package/src/init-templates.py +26 -0
- package/src/init-templates.sh +3 -3
- package/src/parse_friction.py +286 -0
- package/src/parse_skills.py +133 -0
- package/src/patch-durations.py +14 -114
- package/src/platform_utils.py +36 -0
- package/src/stop-hook.js +26 -0
- package/src/stop-hook.sh +21 -153
- package/src/storage.py +397 -0
- package/src/subagent-stop-hook.sh +37 -0
- package/src/update-prompts-index.py +177 -20
- package/src/write-agent.py +113 -0
- package/src/write-turns.py +130 -0
- package/uninstall.js +20 -0
- package/uninstall.sh +17 -0
package/README.md
CHANGED
|
@@ -13,6 +13,8 @@ After every session, it parses the transcript, updates a `tokens.json` ledger, r
|
|
|
13
13
|
- **Per-model breakdown**: Opus vs Sonnet cost split
|
|
14
14
|
- **Key prompts**: high-signal prompts you log manually, with category and context
|
|
15
15
|
- **Prompt efficiency**: ratio of key prompts to total human messages
|
|
16
|
+
- **Per-agent cost breakdown**: SubagentStop hook captures each spawned agent's token usage
|
|
17
|
+
separately — see which agent types (architect, quick-fixer, Explore, etc.) drive the most cost
|
|
16
18
|
|
|
17
19
|
All data lives in `<project>/.claude/tracking/` alongside your code.
|
|
18
20
|
|
|
@@ -26,7 +28,7 @@ All data lives in `<project>/.claude/tracking/` alongside your code.
|
|
|
26
28
|
npm install -g claude-code-tracker
|
|
27
29
|
```
|
|
28
30
|
|
|
29
|
-
The `postinstall` script copies the tracking scripts to `~/.claude/tracking/` and registers the Stop hook in `~/.claude/settings.json`.
|
|
31
|
+
The `postinstall` script copies the tracking scripts to `~/.claude/tracking/` and registers the Stop hook in `~/.claude/settings.json`. On Windows, the Node.js wrappers (`install.js`, `stop-hook.js`) delegate to bash automatically via Git Bash or WSL.
|
|
30
32
|
|
|
31
33
|
### Option 2 — Homebrew
|
|
32
34
|
|
|
@@ -68,7 +70,8 @@ On first use in a project, the Stop hook auto-initializes `<project>/.claude/tra
|
|
|
68
70
|
|
|
69
71
|
```
|
|
70
72
|
.claude/tracking/
|
|
71
|
-
tokens.json # session data (auto-updated)
|
|
73
|
+
tokens.json # main session data (auto-updated)
|
|
74
|
+
agents.json # per-agent invocation data (auto-updated)
|
|
72
75
|
charts.html # Chart.js dashboard (auto-updated)
|
|
73
76
|
key-prompts.md # index of logged prompts
|
|
74
77
|
key-prompts/ # one .md per day
|
|
@@ -93,6 +96,22 @@ The dashboard shows cumulative cost, cost per day, sessions, output tokens, mode
|
|
|
93
96
|
|
|
94
97
|
---
|
|
95
98
|
|
|
99
|
+
## Multi-agent tracking
|
|
100
|
+
|
|
101
|
+
When using Claude Code's Task tool to spawn background agents, each agent's cost is tracked
|
|
102
|
+
separately. The `SubagentStop` hook fires when each agent finishes and appends an entry to
|
|
103
|
+
`agents.json` with:
|
|
104
|
+
- `agent_type` — the subagent type (e.g. `architect`, `quick-fixer`, `Explore`, `Bash`)
|
|
105
|
+
- token counts summed across all internal turns
|
|
106
|
+
- `estimated_cost_usd` using the same list-price formula as main sessions
|
|
107
|
+
|
|
108
|
+
The dashboard shows an "Agents" section with cost and invocation count by agent type, letting
|
|
109
|
+
you identify which agent types are expensive relative to their output.
|
|
110
|
+
|
|
111
|
+
No configuration needed — the SubagentStop hook is registered automatically on install.
|
|
112
|
+
|
|
113
|
+
---
|
|
114
|
+
|
|
96
115
|
## Cost CLI
|
|
97
116
|
|
|
98
117
|
```bash
|
|
@@ -167,6 +186,23 @@ The uninstaller removes the scripts from `~/.claude/tracking/` and removes the S
|
|
|
167
186
|
|
|
168
187
|
---
|
|
169
188
|
|
|
189
|
+
## Skills
|
|
190
|
+
|
|
191
|
+
`install.sh` copies bundled Claude Code skills into `~/.claude/skills/` automatically. Skills are slash commands available in any Claude Code session.
|
|
192
|
+
|
|
193
|
+
### /view-tracking
|
|
194
|
+
|
|
195
|
+
Opens the tracking dashboard and today's key-prompts file for the current project.
|
|
196
|
+
|
|
197
|
+
```
|
|
198
|
+
/view-tracking
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
- **macOS**: opens files with `open`
|
|
202
|
+
- **Linux / WSL**: opens files with `xdg-open` (falls back to printing the path if unavailable)
|
|
203
|
+
|
|
204
|
+
---
|
|
205
|
+
|
|
170
206
|
## Cost note
|
|
171
207
|
|
|
172
208
|
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.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
const { spawnSync } = require('child_process');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
const scriptDir = path.dirname(path.dirname(path.resolve(__filename)));
|
|
7
|
+
const bashScript = path.join(scriptDir, 'bin', 'claude-tracker-cost.sh');
|
|
8
|
+
|
|
9
|
+
if (process.platform === 'win32') {
|
|
10
|
+
const result = spawnSync('bash', [bashScript], {
|
|
11
|
+
stdio: 'inherit',
|
|
12
|
+
shell: false,
|
|
13
|
+
});
|
|
14
|
+
process.exit(result.status || 0);
|
|
15
|
+
} else {
|
|
16
|
+
const result = spawnSync('bash', [bashScript], {
|
|
17
|
+
stdio: 'inherit',
|
|
18
|
+
});
|
|
19
|
+
process.exit(result.status || 0);
|
|
20
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
PREFIX="$(brew --prefix claude-code-tracker 2>/dev/null)" || {
|
|
5
|
+
echo "Error: claude-code-tracker not found via Homebrew." >&2
|
|
6
|
+
echo "If you installed via npm or git clone, run install.sh directly." >&2
|
|
7
|
+
exit 1
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
exec "$PREFIX/libexec/install.sh"
|
package/install.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
const { spawnSync } = require('child_process');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
|
|
7
|
+
const scriptDir = path.dirname(path.resolve(__filename));
|
|
8
|
+
const bashScript = path.join(scriptDir, 'install.sh');
|
|
9
|
+
|
|
10
|
+
if (process.platform === 'win32') {
|
|
11
|
+
const result = spawnSync('bash', [bashScript, ...process.argv.slice(2)], {
|
|
12
|
+
stdio: 'inherit',
|
|
13
|
+
shell: false,
|
|
14
|
+
});
|
|
15
|
+
process.exit(result.status || 0);
|
|
16
|
+
} else {
|
|
17
|
+
const result = spawnSync('bash', [bashScript, ...process.argv.slice(2)], {
|
|
18
|
+
stdio: 'inherit',
|
|
19
|
+
});
|
|
20
|
+
process.exit(result.status || 0);
|
|
21
|
+
}
|
package/install.sh
CHANGED
|
@@ -36,12 +36,25 @@ else
|
|
|
36
36
|
echo "Scripts installed to $INSTALL_DIR"
|
|
37
37
|
fi
|
|
38
38
|
|
|
39
|
+
SUBAGENT_HOOK_CMD="${HOOK_CMD/stop-hook.sh/subagent-stop-hook.sh}"
|
|
40
|
+
|
|
41
|
+
# Install skills to ~/.claude/skills/
|
|
42
|
+
if [[ -d "$SCRIPT_DIR/skills" ]]; then
|
|
43
|
+
for skill_dir in "$SCRIPT_DIR/skills"/*/; do
|
|
44
|
+
skill_name="$(basename "$skill_dir")"
|
|
45
|
+
mkdir -p "$HOME/.claude/skills/$skill_name"
|
|
46
|
+
cp "$skill_dir/SKILL.md" "$HOME/.claude/skills/$skill_name/SKILL.md"
|
|
47
|
+
echo "Skill installed: $skill_name"
|
|
48
|
+
done
|
|
49
|
+
fi
|
|
50
|
+
|
|
39
51
|
# Patch settings.json — add Stop hook if not already present
|
|
40
|
-
python3 - "$SETTINGS" "$HOOK_CMD" <<'PYEOF'
|
|
52
|
+
python3 - "$SETTINGS" "$HOOK_CMD" "$SUBAGENT_HOOK_CMD" <<'PYEOF'
|
|
41
53
|
import sys, json, os
|
|
42
54
|
|
|
43
55
|
settings_file = sys.argv[1]
|
|
44
56
|
hook_cmd = sys.argv[2]
|
|
57
|
+
subagent_hook_cmd = sys.argv[3]
|
|
45
58
|
|
|
46
59
|
data = {}
|
|
47
60
|
if os.path.exists(settings_file):
|
|
@@ -71,12 +84,23 @@ session_hooks[:] = [
|
|
|
71
84
|
]
|
|
72
85
|
session_hooks.append({"hooks": [{"type": "command", "command": backfill_cmd, "timeout": 60, "async": True}]})
|
|
73
86
|
|
|
87
|
+
# SubagentStop hook
|
|
88
|
+
subagent_entry = {"type": "command", "command": subagent_hook_cmd, "timeout": 30, "async": True}
|
|
89
|
+
subagent_hooks = hooks.setdefault("SubagentStop", [])
|
|
90
|
+
subagent_hooks[:] = [
|
|
91
|
+
g for g in subagent_hooks
|
|
92
|
+
if not any("subagent-stop-hook.sh" in h.get("command", "") for h in g.get("hooks", []))
|
|
93
|
+
]
|
|
94
|
+
subagent_hooks.append({"hooks": [subagent_entry]})
|
|
95
|
+
|
|
74
96
|
# permissions.allow — clean old entries and add current
|
|
75
97
|
allow_entry = f"Bash({hook_cmd}*)"
|
|
98
|
+
subagent_allow = f"Bash({subagent_hook_cmd}*)"
|
|
76
99
|
perms = data.setdefault("permissions", {})
|
|
77
100
|
allow_list = perms.setdefault("allow", [])
|
|
78
|
-
allow_list[:] = [e for e in allow_list if "stop-hook.sh" not in e]
|
|
101
|
+
allow_list[:] = [e for e in allow_list if "stop-hook.sh" not in e and "subagent-stop-hook.sh" not in e]
|
|
79
102
|
allow_list.append(allow_entry)
|
|
103
|
+
allow_list.append(subagent_allow)
|
|
80
104
|
|
|
81
105
|
os.makedirs(os.path.dirname(os.path.abspath(settings_file)), exist_ok=True)
|
|
82
106
|
with open(settings_file, 'w') as f:
|
|
@@ -111,3 +135,13 @@ if [[ "$SCRIPT_DIR" != */Cellar/* ]]; then
|
|
|
111
135
|
fi
|
|
112
136
|
|
|
113
137
|
echo "claude-code-tracker installed. Restart Claude Code to activate."
|
|
138
|
+
|
|
139
|
+
# Warn if .claude/ is not covered by .gitignore
|
|
140
|
+
if [[ -f ".gitignore" ]]; then
|
|
141
|
+
if ! grep -q '\.claude/' .gitignore && ! grep -q '^\.claude$' .gitignore; then
|
|
142
|
+
echo ""
|
|
143
|
+
echo "WARNING: .claude/ does not appear to be in your .gitignore."
|
|
144
|
+
echo "Tracking data (tokens, agent costs, session history) will be committed to git."
|
|
145
|
+
echo "Add '.claude/' or '.claude/tracking/' to .gitignore to prevent this."
|
|
146
|
+
fi
|
|
147
|
+
fi
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-tracker",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0-beta.3",
|
|
4
4
|
"description": "Automatic token, cost, and prompt tracking for Claude Code sessions",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
|
@@ -16,18 +16,22 @@
|
|
|
16
16
|
"url": "git+https://github.com/kelsi-andrewss/claude-code-tracker.git"
|
|
17
17
|
},
|
|
18
18
|
"scripts": {
|
|
19
|
-
"postinstall": "
|
|
19
|
+
"postinstall": "node ./install.js"
|
|
20
20
|
},
|
|
21
21
|
"bin": {
|
|
22
|
-
"claude-tracker-cost": "
|
|
22
|
+
"claude-tracker-cost": "bin/claude-tracker-cost.js"
|
|
23
23
|
},
|
|
24
24
|
"engines": {
|
|
25
25
|
"node": ">=14"
|
|
26
26
|
},
|
|
27
27
|
"files": [
|
|
28
28
|
"src/",
|
|
29
|
+
"bin/",
|
|
30
|
+
"skills/",
|
|
29
31
|
"install.sh",
|
|
32
|
+
"install.js",
|
|
30
33
|
"uninstall.sh",
|
|
34
|
+
"uninstall.js",
|
|
31
35
|
"README.md"
|
|
32
36
|
]
|
|
33
37
|
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: view-tracking
|
|
3
|
+
description: Open the charts dashboard and today's key-prompts file for the current project. Use when the user says "open charts", "view tracking", "show dashboard", or "/view-tracking".
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Open the tracking dashboard for the current project.
|
|
7
|
+
|
|
8
|
+
Steps:
|
|
9
|
+
|
|
10
|
+
1. Set the tracking directory: `$CLAUDE_PROJECT_DIR/.claude/tracking/`
|
|
11
|
+
|
|
12
|
+
2. Open the charts dashboard:
|
|
13
|
+
```bash
|
|
14
|
+
if [[ "$OSTYPE" == darwin* ]]; then
|
|
15
|
+
open "$CLAUDE_PROJECT_DIR/.claude/tracking/charts.html"
|
|
16
|
+
elif [[ "$OSTYPE" == msys* || "$OSTYPE" == cygwin* || -n "${WINDIR:-}" ]]; then
|
|
17
|
+
start "" "$CLAUDE_PROJECT_DIR/.claude/tracking/charts.html"
|
|
18
|
+
else
|
|
19
|
+
xdg-open "$CLAUDE_PROJECT_DIR/.claude/tracking/charts.html" 2>/dev/null || echo "Could not open charts.html automatically. Path: $CLAUDE_PROJECT_DIR/.claude/tracking/charts.html"
|
|
20
|
+
fi
|
|
21
|
+
```
|
|
22
|
+
If the file doesn't exist, report: "No charts.html found at $CLAUDE_PROJECT_DIR/.claude/tracking/charts.html"
|
|
23
|
+
|
|
24
|
+
3. Find and open today's key-prompts file. Today's date is available from the system. The file path is:
|
|
25
|
+
`$CLAUDE_PROJECT_DIR/.claude/tracking/key-prompts/YYYY-MM-DD.md` (using today's date)
|
|
26
|
+
|
|
27
|
+
- If it exists:
|
|
28
|
+
```bash
|
|
29
|
+
if [[ "$OSTYPE" == darwin* ]]; then
|
|
30
|
+
open "$CLAUDE_PROJECT_DIR/.claude/tracking/key-prompts/YYYY-MM-DD.md"
|
|
31
|
+
elif [[ "$OSTYPE" == msys* || "$OSTYPE" == cygwin* || -n "${WINDIR:-}" ]]; then
|
|
32
|
+
start "" "$CLAUDE_PROJECT_DIR/.claude/tracking/key-prompts/YYYY-MM-DD.md"
|
|
33
|
+
else
|
|
34
|
+
xdg-open "$CLAUDE_PROJECT_DIR/.claude/tracking/key-prompts/YYYY-MM-DD.md" 2>/dev/null || echo "Could not open key-prompts file automatically. Path: $CLAUDE_PROJECT_DIR/.claude/tracking/key-prompts/YYYY-MM-DD.md"
|
|
35
|
+
fi
|
|
36
|
+
```
|
|
37
|
+
- If it doesn't exist: report "No key-prompts file for today yet." then list the most recent file in that directory:
|
|
38
|
+
```bash
|
|
39
|
+
ls -t "$CLAUDE_PROJECT_DIR/.claude/tracking/key-prompts/"*.md 2>/dev/null | head -1
|
|
40
|
+
```
|
|
41
|
+
If a recent file exists, offer: "Most recent: <filename>" and ask if the user wants to open it. If they say yes:
|
|
42
|
+
```bash
|
|
43
|
+
if [[ "$OSTYPE" == darwin* ]]; then
|
|
44
|
+
open "$CLAUDE_PROJECT_DIR/.claude/tracking/key-prompts/YYYY-MM-DD.md"
|
|
45
|
+
elif [[ "$OSTYPE" == msys* || "$OSTYPE" == cygwin* || -n "${WINDIR:-}" ]]; then
|
|
46
|
+
start "" "$CLAUDE_PROJECT_DIR/.claude/tracking/key-prompts/YYYY-MM-DD.md"
|
|
47
|
+
else
|
|
48
|
+
xdg-open "$CLAUDE_PROJECT_DIR/.claude/tracking/key-prompts/YYYY-MM-DD.md" 2>/dev/null || echo "Could not open key-prompts file automatically. Path: $CLAUDE_PROJECT_DIR/.claude/tracking/key-prompts/YYYY-MM-DD.md"
|
|
49
|
+
fi
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
4. If the entire `.claude/tracking/` directory doesn't exist, report: "No tracking directory found for this project. Expected: $CLAUDE_PROJECT_DIR/.claude/tracking/"
|
|
53
|
+
|
|
54
|
+
Run the bash commands using the Bash tool. Do not ask for confirmation before opening files.
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/src/backfill.py
CHANGED
|
@@ -1,55 +1,33 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""
|
|
3
|
-
Backfill historical Claude Code sessions into
|
|
3
|
+
Backfill historical Claude Code sessions into tracking.db.
|
|
4
4
|
|
|
5
5
|
Usage:
|
|
6
6
|
python3 backfill.py <project_root>
|
|
7
7
|
|
|
8
8
|
Scans ~/.claude/projects/<slug>/*.jsonl for transcripts belonging to the
|
|
9
9
|
given project, parses token usage from each turn, and upserts entries to
|
|
10
|
-
<project_root>/.claude/tracking/
|
|
10
|
+
<project_root>/.claude/tracking/tracking.db. Sessions where all turns are
|
|
11
11
|
already present are skipped.
|
|
12
|
-
|
|
13
|
-
Old-format entries (no turn_index field) are replaced with per-turn entries.
|
|
14
12
|
"""
|
|
15
13
|
import sys, json, os, glob
|
|
16
14
|
from datetime import datetime
|
|
15
|
+
from platform_utils import get_transcripts_dir, slugify_path
|
|
16
|
+
from cost import compute_cost
|
|
17
|
+
import storage
|
|
17
18
|
|
|
18
19
|
project_root = os.path.abspath(sys.argv[1])
|
|
19
20
|
project_name = os.path.basename(project_root)
|
|
20
21
|
tracking_dir = os.path.join(project_root, ".claude", "tracking")
|
|
21
|
-
tokens_file = os.path.join(tracking_dir, "tokens.json")
|
|
22
22
|
|
|
23
23
|
# Claude Code slugifies project paths: replace "/" with "-"
|
|
24
|
-
slug = project_root
|
|
25
|
-
transcripts_dir = os.path.
|
|
24
|
+
slug = slugify_path(project_root)
|
|
25
|
+
transcripts_dir = os.path.join(get_transcripts_dir(), slug)
|
|
26
26
|
|
|
27
27
|
if not os.path.isdir(transcripts_dir):
|
|
28
28
|
print("No transcript directory found, nothing to backfill.")
|
|
29
29
|
sys.exit(0)
|
|
30
30
|
|
|
31
|
-
# Load existing data
|
|
32
|
-
data = []
|
|
33
|
-
if os.path.exists(tokens_file):
|
|
34
|
-
try:
|
|
35
|
-
with open(tokens_file) as f:
|
|
36
|
-
data = json.load(f)
|
|
37
|
-
except Exception:
|
|
38
|
-
data = []
|
|
39
|
-
|
|
40
|
-
# Remove old-format entries (no turn_index) — they will be re-processed
|
|
41
|
-
old_sessions = {e.get("session_id") for e in data if "turn_index" not in e}
|
|
42
|
-
data = [e for e in data if "turn_index" in e]
|
|
43
|
-
|
|
44
|
-
# Build index of existing (session_id, turn_index) pairs
|
|
45
|
-
existing_turns = {(e.get("session_id"), e.get("turn_index")) for e in data}
|
|
46
|
-
|
|
47
|
-
# Count turns per known session
|
|
48
|
-
turns_per_session = {}
|
|
49
|
-
for e in data:
|
|
50
|
-
sid = e.get("session_id")
|
|
51
|
-
turns_per_session[sid] = turns_per_session.get(sid, 0) + 1
|
|
52
|
-
|
|
53
31
|
def parse_turns(jf):
|
|
54
32
|
"""Parse a JSONL transcript into per-turn entries. Returns list of dicts."""
|
|
55
33
|
msgs = [] # (role, timestamp)
|
|
@@ -58,7 +36,7 @@ def parse_turns(jf):
|
|
|
58
36
|
first_ts = None
|
|
59
37
|
|
|
60
38
|
try:
|
|
61
|
-
with open(jf) as f:
|
|
39
|
+
with open(jf, encoding='utf-8') as f:
|
|
62
40
|
for line in f:
|
|
63
41
|
try:
|
|
64
42
|
obj = json.loads(line)
|
|
@@ -136,10 +114,7 @@ def compute_turns(msgs, usages, first_ts, model, session_id, project_name):
|
|
|
136
114
|
except Exception:
|
|
137
115
|
pass
|
|
138
116
|
|
|
139
|
-
|
|
140
|
-
cost = inp * 15 / 1e6 + cache_create * 18.75 / 1e6 + cache_read * 1.50 / 1e6 + out * 75 / 1e6
|
|
141
|
-
else:
|
|
142
|
-
cost = inp * 3 / 1e6 + cache_create * 3.75 / 1e6 + cache_read * 0.30 / 1e6 + out * 15 / 1e6
|
|
117
|
+
cost = compute_cost(inp, out, cache_create, cache_read, model)
|
|
143
118
|
|
|
144
119
|
# Turn timestamp = user message timestamp
|
|
145
120
|
turn_ts = user_ts
|
|
@@ -200,33 +175,38 @@ for jf in jsonl_files:
|
|
|
200
175
|
continue
|
|
201
176
|
|
|
202
177
|
expected_count = len(turn_entries)
|
|
203
|
-
existing_count =
|
|
178
|
+
existing_count = storage.count_turns_for_session(tracking_dir, session_id)
|
|
204
179
|
|
|
205
|
-
|
|
206
|
-
if existing_count >= expected_count and session_id not in old_sessions:
|
|
180
|
+
if existing_count >= expected_count:
|
|
207
181
|
continue
|
|
208
182
|
|
|
209
|
-
#
|
|
210
|
-
|
|
211
|
-
data.extend(turn_entries)
|
|
183
|
+
# Replace all turns for this session with fresh data
|
|
184
|
+
storage.replace_session_turns(tracking_dir, session_id, turn_entries)
|
|
212
185
|
new_entries.extend(turn_entries)
|
|
213
186
|
sessions_processed += 1
|
|
214
187
|
|
|
215
|
-
# Sort by (date, session_id, turn_index)
|
|
216
|
-
data.sort(key=lambda x: (x.get("date", ""), x.get("session_id", ""), x.get("turn_index", 0)))
|
|
217
|
-
|
|
218
|
-
# Write updated tokens.json
|
|
219
|
-
if new_entries:
|
|
220
|
-
os.makedirs(os.path.dirname(tokens_file), exist_ok=True)
|
|
221
|
-
with open(tokens_file, "w") as f:
|
|
222
|
-
json.dump(data, f, indent=2)
|
|
223
|
-
f.write("\n")
|
|
224
|
-
|
|
225
188
|
total_turns = len(new_entries)
|
|
226
189
|
print(f"{sessions_processed} session{'s' if sessions_processed != 1 else ''} processed, {total_turns} turn{'s' if total_turns != 1 else ''} written.")
|
|
227
190
|
|
|
191
|
+
# Backfill friction events from the same transcripts
|
|
192
|
+
from parse_friction import parse_friction, upsert_friction
|
|
193
|
+
|
|
194
|
+
friction_file = os.path.join(tracking_dir, "friction.json")
|
|
195
|
+
friction_count = 0
|
|
196
|
+
for jf in jsonl_files:
|
|
197
|
+
session_id = os.path.splitext(os.path.basename(jf))[0]
|
|
198
|
+
try:
|
|
199
|
+
events = parse_friction(jf, session_id, project_name, "main")
|
|
200
|
+
upsert_friction(friction_file, session_id, events)
|
|
201
|
+
friction_count += len(events)
|
|
202
|
+
except Exception:
|
|
203
|
+
pass
|
|
204
|
+
|
|
205
|
+
if friction_count:
|
|
206
|
+
print(f"{friction_count} friction event{'s' if friction_count != 1 else ''} backfilled.")
|
|
207
|
+
|
|
228
208
|
# Regenerate charts if we added anything
|
|
229
|
-
if new_entries:
|
|
209
|
+
if new_entries or friction_count:
|
|
230
210
|
script_dir = os.path.dirname(os.path.abspath(__file__))
|
|
231
211
|
charts_html = os.path.join(tracking_dir, "charts.html")
|
|
232
|
-
os.system(f'python3 "{script_dir}/generate-charts.py" "{
|
|
212
|
+
os.system(f'python3 "{script_dir}/generate-charts.py" "{tracking_dir}" "{charts_html}" 2>/dev/null')
|
package/src/cost-summary.py
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""
|
|
3
3
|
Usage:
|
|
4
|
-
python3 cost-summary.py
|
|
5
|
-
python3 cost-summary.py
|
|
6
|
-
python3 cost-summary.py
|
|
4
|
+
python3 cost-summary.py (auto-discover tracking dir)
|
|
5
|
+
python3 cost-summary.py /path/to/.claude/tracking (explicit tracking dir)
|
|
6
|
+
python3 cost-summary.py /path/to/tokens.json (legacy compat)
|
|
7
|
+
python3 cost-summary.py --chart (open tracking charts in browser)
|
|
7
8
|
"""
|
|
8
9
|
import sys
|
|
9
10
|
import json
|
|
@@ -12,6 +13,10 @@ import webbrowser
|
|
|
12
13
|
from collections import defaultdict
|
|
13
14
|
from datetime import date
|
|
14
15
|
|
|
16
|
+
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
17
|
+
sys.path.insert(0, SCRIPT_DIR)
|
|
18
|
+
import storage
|
|
19
|
+
|
|
15
20
|
def find_git_root():
|
|
16
21
|
root = os.getcwd()
|
|
17
22
|
while root != "/":
|
|
@@ -20,12 +25,12 @@ def find_git_root():
|
|
|
20
25
|
root = os.path.dirname(root)
|
|
21
26
|
return root
|
|
22
27
|
|
|
23
|
-
def
|
|
28
|
+
def find_tracking_dir():
|
|
24
29
|
root = find_git_root()
|
|
25
|
-
path = os.path.join(root, ".claude", "tracking"
|
|
26
|
-
if os.path.
|
|
30
|
+
path = os.path.join(root, ".claude", "tracking")
|
|
31
|
+
if os.path.isdir(path):
|
|
27
32
|
return path
|
|
28
|
-
sys.exit(f"No
|
|
33
|
+
sys.exit(f"No tracking directory found at {path}")
|
|
29
34
|
|
|
30
35
|
def format_duration(seconds):
|
|
31
36
|
if seconds <= 0:
|
|
@@ -44,10 +49,16 @@ if "--chart" in sys.argv:
|
|
|
44
49
|
webbrowser.open(f"file://{chart}")
|
|
45
50
|
sys.exit(0)
|
|
46
51
|
|
|
47
|
-
|
|
52
|
+
# Backward compat: accept tokens.json path or tracking dir
|
|
53
|
+
arg = sys.argv[1] if len(sys.argv) > 1 and sys.argv[1] != "--chart" else None
|
|
54
|
+
if arg and arg.endswith('.json'):
|
|
55
|
+
tracking_dir = os.path.dirname(os.path.abspath(arg))
|
|
56
|
+
elif arg:
|
|
57
|
+
tracking_dir = os.path.abspath(arg)
|
|
58
|
+
else:
|
|
59
|
+
tracking_dir = find_tracking_dir()
|
|
48
60
|
|
|
49
|
-
|
|
50
|
-
data = json.load(f)
|
|
61
|
+
data = storage.get_all_turns(tracking_dir)
|
|
51
62
|
|
|
52
63
|
if not data:
|
|
53
64
|
print("No sessions recorded yet.")
|
|
@@ -89,7 +100,8 @@ total_input = sum(e.get("input_tokens", 0) for e in data)
|
|
|
89
100
|
# --- Print ---
|
|
90
101
|
W = 60
|
|
91
102
|
print("=" * W)
|
|
92
|
-
|
|
103
|
+
project_name = os.path.basename(os.path.dirname(os.path.dirname(tracking_dir)))
|
|
104
|
+
print(f" Cost Summary — {project_name}")
|
|
93
105
|
print("=" * W)
|
|
94
106
|
|
|
95
107
|
print(f"\nBy date:")
|
|
@@ -125,4 +137,29 @@ days = len(by_date)
|
|
|
125
137
|
if days > 1:
|
|
126
138
|
print(f"\n Avg cost/day: ${total_cost/days:>11.2f} over {days} days")
|
|
127
139
|
|
|
140
|
+
# --- Friction summary ---
|
|
141
|
+
friction_file = os.path.join(tracking_dir, "friction.json")
|
|
142
|
+
if os.path.exists(friction_file):
|
|
143
|
+
try:
|
|
144
|
+
with open(friction_file, encoding='utf-8') as f:
|
|
145
|
+
friction_data = json.load(f)
|
|
146
|
+
if friction_data:
|
|
147
|
+
print(f"\nFriction:")
|
|
148
|
+
friction_total = len(friction_data)
|
|
149
|
+
cat_counts = defaultdict(int)
|
|
150
|
+
tool_counts = defaultdict(int)
|
|
151
|
+
for fe in friction_data:
|
|
152
|
+
cat_counts[fe.get('category', 'unknown')] += 1
|
|
153
|
+
tn = fe.get('tool_name')
|
|
154
|
+
if tn:
|
|
155
|
+
tool_counts[tn] += 1
|
|
156
|
+
top_cat = max(cat_counts, key=cat_counts.get)
|
|
157
|
+
print(f" Total events: {friction_total:>8}")
|
|
158
|
+
print(f" Top category: {top_cat:>8} ({cat_counts[top_cat]} events)")
|
|
159
|
+
if tool_counts:
|
|
160
|
+
top_tool = max(tool_counts, key=tool_counts.get)
|
|
161
|
+
print(f" Top tool: {top_tool:>8} ({tool_counts[top_tool]} events)")
|
|
162
|
+
except Exception:
|
|
163
|
+
pass
|
|
164
|
+
|
|
128
165
|
print("=" * W)
|
package/src/cost.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
def compute_cost(input_tokens, output_tokens, cache_creation, cache_read, model):
|
|
2
|
+
"""Compute estimated API list-price cost in USD."""
|
|
3
|
+
if 'opus' in model:
|
|
4
|
+
return (input_tokens * 15 / 1e6 + cache_creation * 18.75 / 1e6 +
|
|
5
|
+
cache_read * 1.50 / 1e6 + output_tokens * 75 / 1e6)
|
|
6
|
+
return (input_tokens * 3 / 1e6 + cache_creation * 3.75 / 1e6 +
|
|
7
|
+
cache_read * 0.30 / 1e6 + output_tokens * 15 / 1e6)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Export tracking.db to JSON files for portability.
|
|
3
|
+
|
|
4
|
+
Usage: python3 export-json.py [<tracking_dir>]
|
|
5
|
+
Defaults to .claude/tracking/ in the current git root.
|
|
6
|
+
"""
|
|
7
|
+
import sys, os
|
|
8
|
+
|
|
9
|
+
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
10
|
+
sys.path.insert(0, SCRIPT_DIR)
|
|
11
|
+
import storage
|
|
12
|
+
|
|
13
|
+
def find_tracking_dir():
|
|
14
|
+
root = os.getcwd()
|
|
15
|
+
while root != "/":
|
|
16
|
+
if os.path.exists(os.path.join(root, ".git")):
|
|
17
|
+
return os.path.join(root, ".claude", "tracking")
|
|
18
|
+
root = os.path.dirname(root)
|
|
19
|
+
return os.path.join(os.getcwd(), ".claude", "tracking")
|
|
20
|
+
|
|
21
|
+
tracking_dir = sys.argv[1] if len(sys.argv) > 1 else find_tracking_dir()
|
|
22
|
+
|
|
23
|
+
if not os.path.exists(os.path.join(tracking_dir, "tracking.db")):
|
|
24
|
+
sys.exit(f"No tracking.db found in {tracking_dir}")
|
|
25
|
+
|
|
26
|
+
storage.export_json(tracking_dir)
|
|
27
|
+
print(f"Exported to {tracking_dir}/tokens.json and agents.json")
|