claudon 0.2.0__py3-none-any.whl
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.
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: claudon
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Interactive session analytics & HTML dashboard for Claude Code
|
|
5
|
+
Author: Infenia Private Limited
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/infenia/claudon
|
|
8
|
+
Project-URL: Repository, https://github.com/infenia/claudon
|
|
9
|
+
Keywords: claude,claude-code,analytics,dashboard,anthropic,llm
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
+
Classifier: Topic :: Software Development :: User Interfaces
|
|
18
|
+
Requires-Python: >=3.11
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
<div align="center">
|
|
24
|
+
|
|
25
|
+
# Claudon
|
|
26
|
+
|
|
27
|
+
**See where your Claude Code time and money actually go.**
|
|
28
|
+
|
|
29
|
+
One command turns your session logs into an interactive HTML dashboard.<br>
|
|
30
|
+
100% local · zero dependencies · nothing leaves your machine.
|
|
31
|
+
|
|
32
|
+
[](https://github.com/infenia/claudon/actions/workflows/ci.yml)
|
|
33
|
+
[](https://pypi.org/project/claudon/)
|
|
34
|
+
[](https://www.npmjs.com/package/claudon)
|
|
35
|
+
[](https://pypi.org/project/claudon/)
|
|
36
|
+
[](LICENSE)
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
uvx claudon --open
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
<img src="docs/assets/overview.png" alt="Claudon dashboard overview: tasks, wall time, cost, time breakdown and per-project cost" width="860">
|
|
43
|
+
|
|
44
|
+
<sub>Demo data. Your own sessions stay on your machine.</sub>
|
|
45
|
+
|
|
46
|
+
</div>
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## Why Claudon?
|
|
51
|
+
|
|
52
|
+
Claude Code is fast until it isn't. Which turn burned ten minutes? Was it the model, a slow tool, or you being away? Did that failing tool call loop? What did the session really cost? Your answers are already in `~/.claude`; Claudon reads them and shows you:
|
|
53
|
+
|
|
54
|
+
- **Where time went**: model vs. tools vs. permission waits vs. idle, per task, turn and subagent.
|
|
55
|
+
- **What it cost**: token usage × list prices per model generation (override with `--pricing`).
|
|
56
|
+
- **What went wrong**: automatic detection of tool failures, slow turns and retry loops.
|
|
57
|
+
- **How hard it thought**: thinking time vs. thinking tokens.
|
|
58
|
+
- **Safe to share**: `--redact` strips prompts, paths, commands and project names.
|
|
59
|
+
|
|
60
|
+
It is a single Python file using only the standard library. No telemetry, no network, no account.
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## 🚀 Quick Start
|
|
65
|
+
|
|
66
|
+
Use whichever runner you already have; each downloads and runs the latest release on demand:
|
|
67
|
+
|
|
68
|
+
| Tool | Run once | Install globally |
|
|
69
|
+
|---|---|---|
|
|
70
|
+
| **uv** | `uvx claudon` | `uv tool install claudon` |
|
|
71
|
+
| **pipx** | `pipx run claudon` | `pipx install claudon` |
|
|
72
|
+
| **pip** | | `pip install claudon` |
|
|
73
|
+
| **npm** | `npx claudon` | `npm install -g claudon` |
|
|
74
|
+
| **pnpm** | `pnpm dlx claudon` | `pnpm add -g claudon` |
|
|
75
|
+
| **Yarn** (2+) | `yarn dlx claudon` | |
|
|
76
|
+
| **Bun** | `bunx claudon` | `bun add -g claudon` |
|
|
77
|
+
| **Shell script** | | [`install.sh`](#standalone-shell-script) |
|
|
78
|
+
| **Browser** | [WebAssembly page](#-webassembly-in-browser--zero-python) | no install |
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
claudon ~/.claude -o report.html --open # after a global install
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
> The npm-family packages (npx, pnpm, yarn, bun) are a thin launcher around the same Python script, so they need
|
|
85
|
+
> **Python 3.11+** on your `PATH` (`python3`, `python` or `py`). No Python at all? Use the browser version or a
|
|
86
|
+
> standalone binary from [GitHub Releases](https://github.com/infenia/claudon/releases).
|
|
87
|
+
> Use `@beta` / `uvx --prerelease allow` for pre-releases.
|
|
88
|
+
|
|
89
|
+
### Standalone Shell Script
|
|
90
|
+
```bash
|
|
91
|
+
curl -fsSL https://raw.githubusercontent.com/infenia/claudon/main/install.sh | sh
|
|
92
|
+
|
|
93
|
+
# recommended: pin a release (see GitHub Releases); claudon.py is checked against that release's SHA256SUMS
|
|
94
|
+
curl -fsSL https://raw.githubusercontent.com/infenia/claudon/main/install.sh | CLAUDON_VERSION=vX.Y.Z sh
|
|
95
|
+
```
|
|
96
|
+
The checksum catches corrupted or mismatched downloads. To also prove a release file was built by this repo's
|
|
97
|
+
workflow, download it and run `gh attestation verify claudon.py -R infenia/claudon`.
|
|
98
|
+
|
|
99
|
+
### Direct Clone
|
|
100
|
+
```bash
|
|
101
|
+
git clone https://github.com/infenia/claudon.git
|
|
102
|
+
cd claudon
|
|
103
|
+
python3 claudon.py ~/.claude -o report.html --open
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### ⚡ WebAssembly (In-Browser / Zero Python)
|
|
107
|
+
Run Claudon entirely in your browser without local Python installed. The page loads `../claudon.py`,
|
|
108
|
+
so it has to be served over HTTP (opening the file directly via `file://` can't load it):
|
|
109
|
+
1. From the repository root run `python3 -m http.server` (or host the repo on any static server, e.g. GitHub Pages).
|
|
110
|
+
2. Open `http://localhost:8000/wasm/`.
|
|
111
|
+
3. Drop `.jsonl` transcripts, or use **Open Folder** on `~/.claude/projects` to keep project grouping and subagents.
|
|
112
|
+
|
|
113
|
+
**Nothing is uploaded.** Files are read by the browser and analyzed on your machine; the page's only network requests
|
|
114
|
+
download the Pyodide runtime (jsDelivr) and `claudon.py`, and once it shows *Ready* it keeps working offline. Chrome and
|
|
115
|
+
Edge ask to let the page *view* the folder. Browsers without that folder API (Firefox, Safari) show their own
|
|
116
|
+
"upload" prompt for folder pickers, which only grants the page read access.
|
|
117
|
+
|
|
118
|
+
### 🛠️ Native Binary (Nuitka)
|
|
119
|
+
To compile Claudon into a single native binary locally:
|
|
120
|
+
```bash
|
|
121
|
+
pip install nuitka==4.2.2 zstandard
|
|
122
|
+
python scripts/build_nuitka.py --onefile
|
|
123
|
+
```
|
|
124
|
+
Standalone executables are output to `dist/`.
|
|
125
|
+
|
|
126
|
+
### 📦 Releases
|
|
127
|
+
Claudon follows **Semantic Versioning** and is released through an automated, reviewable pipeline: Conventional Commit PR titles drive an auto-generated release PR (version bump + [CHANGELOG](CHANGELOG.md)); merging it tags the release and publishes:
|
|
128
|
+
- **Nuitka Binaries**: Linux x86_64, macOS (arm64 and Intel) and Windows, attached to GitHub Releases with `SHA256SUMS` and build provenance attestations (`gh attestation verify <file> -R infenia/claudon`).
|
|
129
|
+
- **PyPI**: `uvx`, `pipx` or `pip` (trusted publishing).
|
|
130
|
+
- **npm**: `npx`, `pnpm dlx`, `yarn dlx` or `bunx` (with provenance).
|
|
131
|
+
|
|
132
|
+
Want pre-release builds? `uvx --prerelease allow claudon` or `npx claudon@beta`. Full process: [docs/RELEASING.md](docs/RELEASING.md).
|
|
133
|
+
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
## ⚡ Integration with Claude Code
|
|
137
|
+
|
|
138
|
+
Install Claudon as a native slash command inside Claude Code:
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
claudon --install-plugin
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Once installed, simply type `/claudon` in any active Claude Code CLI session to instantly generate and open your analytics dashboard!
|
|
145
|
+
|
|
146
|
+
---
|
|
147
|
+
|
|
148
|
+
## ⚙️ Options & CLI Usage
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
claudon [PATH] [-o FILE] [--redact] [--pricing FILE] [--open] [--install-plugin [--force]] [--version]
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
`PATH` can be a `~/.claude` directory, its `projects/` subfolder, a specific project directory, or a single `.jsonl` transcript file (default: `$CLAUDE_CONFIG_DIR` if set, else `~/.claude`).
|
|
155
|
+
|
|
156
|
+
| Flag | Description |
|
|
157
|
+
|---|---|
|
|
158
|
+
| `-o FILE`, `--out FILE` | Output HTML report file path (default: `cc_report.html`) |
|
|
159
|
+
| `--redact` | Redact prompts, titles, project names, paths, commands, session IDs and MCP server names for safe report sharing |
|
|
160
|
+
| `--pricing FILE` | Custom $/MTok rates keyed by model-id substring (longest match wins): `{"claude-opus-4-1": [input, output, cache_read, write_5m, write_1h]}` |
|
|
161
|
+
| `--open` | Automatically open the generated HTML report in your default browser |
|
|
162
|
+
| `--install-plugin` | Register `/claudon` slash command in `<config dir>/commands/` |
|
|
163
|
+
| `--force` | With `--install-plugin`: overwrite a `claudon.md` you have edited (otherwise it is left alone) |
|
|
164
|
+
| `--version` | Print the version |
|
|
165
|
+
|
|
166
|
+
---
|
|
167
|
+
|
|
168
|
+
## 📊 What's in the Report?
|
|
169
|
+
|
|
170
|
+
- **Task Breakdown**: each human prompt through to completion.
|
|
171
|
+
- **Turns & Subagents**: model API calls vs. agent delegation.
|
|
172
|
+
- **Time, Thinking & Cost**: the breakdowns described [above](#why-claudon).
|
|
173
|
+
- **Bottlenecks**: failures, slow turns and loops, surfaced automatically.
|
|
174
|
+
|
|
175
|
+
The report is one self-contained `.html` file: no server, no external assets, easy to attach to an issue (use `--redact`).
|
|
176
|
+
|
|
177
|
+
---
|
|
178
|
+
|
|
179
|
+
## 🤝 Contributing
|
|
180
|
+
|
|
181
|
+
Contributions are welcome. Please read [CONTRIBUTING.md](CONTRIBUTING.md) (PR titles must be [Conventional Commits](https://www.conventionalcommits.org/)), follow the [Code of Conduct](CODE_OF_CONDUCT.md), and report vulnerabilities privately per [SECURITY.md](SECURITY.md). See the [CHANGELOG](CHANGELOG.md) for release history.
|
|
182
|
+
|
|
183
|
+
---
|
|
184
|
+
|
|
185
|
+
## 📄 License
|
|
186
|
+
|
|
187
|
+
Developed by **Infenia Private Limited**. MIT License. See [LICENSE](LICENSE) for details.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
claudon.py,sha256=VFSBDCqwfl4x6rjYK_orln09OhIfhkJKJNqfV1wP9yM,52840
|
|
2
|
+
claudon-0.2.0.dist-info/licenses/LICENSE,sha256=4EMzk--wqE38VLrrohl19UEUAOZgJeP_b0JtVRdAzJs,1064
|
|
3
|
+
claudon-0.2.0.dist-info/METADATA,sha256=u_3e67lVhTSkkygugQejfNR22gY2GEg_ubH-3qr2nnY,8633
|
|
4
|
+
claudon-0.2.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
5
|
+
claudon-0.2.0.dist-info/entry_points.txt,sha256=dTL_k3THiFjbh2dq5sDe1LGoILf-TyuTca3ne6eMPfs,41
|
|
6
|
+
claudon-0.2.0.dist-info/top_level.txt,sha256=6XxTN9uJ4VeY5f66bktSrhp10HPdP8bm5ecUsjdiblE,8
|
|
7
|
+
claudon-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Infenia
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
claudon
|
claudon.py
ADDED
|
@@ -0,0 +1,711 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Claudon - Claude Code session analytics -> self-contained HTML dashboard.
|
|
3
|
+
|
|
4
|
+
claudon [PATH] [-o report.html] [--pricing prices.json] [--redact] [--open] [--install-plugin]
|
|
5
|
+
|
|
6
|
+
PATH: a ~/.claude dir, its projects/ dir, one project dir, or a single .jsonl
|
|
7
|
+
(default $CLAUDE_CONFIG_DIR, else ~/.claude).
|
|
8
|
+
Stdlib only, single file. Everything is derived from the transcripts; see the notes in the dashboard footer.
|
|
9
|
+
"""
|
|
10
|
+
import argparse, bisect, collections, datetime as dt, json, math, os, re, sys, webbrowser
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
__version__ = '0.2.0' # x-release-please-version
|
|
14
|
+
|
|
15
|
+
# $/MTok: input, output, cache_read, cache_write_5m, cache_write_1h (Anthropic list prices, 2026-09).
|
|
16
|
+
# Keys are model-id substrings; the longest matching key wins, so a bare family name is the
|
|
17
|
+
# fallback for its generations. Override or extend with --pricing.
|
|
18
|
+
PRICE = {
|
|
19
|
+
'haiku': (1, 5, .1, 1.25, 2), # Haiku 4.5
|
|
20
|
+
'3-5-haiku': (.8, 4, .08, 1, 1.6),
|
|
21
|
+
'3-haiku': (.25, 1.25, .03, .3, .5),
|
|
22
|
+
'sonnet': (3, 15, .3, 3.75, 6), # Sonnet 3.x / 4.x
|
|
23
|
+
'sonnet-5': (2, 10, .2, 2.5, 4), # Sonnet 5, 5.5
|
|
24
|
+
'opus': (5, 25, .5, 6.25, 10), # Opus 4.5 - 4.8, 5
|
|
25
|
+
'opus-4-0': (15, 75, 1.5, 18.75, 30), 'opus-4-20': (15, 75, 1.5, 18.75, 30),
|
|
26
|
+
'opus-4-1': (15, 75, 1.5, 18.75, 30), '3-opus': (15, 75, 1.5, 18.75, 30),
|
|
27
|
+
'opus-5-5': (4, 20, .2, 5, 8),
|
|
28
|
+
'fable': (10, 50, 1, 12.5, 20), 'mythos': (10, 50, 1, 12.5, 20), # Fable / Mythos 5
|
|
29
|
+
'fable-5-1': (10, 50, .25, 12.5, 20), 'mythos-5-1': (10, 50, .25, 12.5, 20),
|
|
30
|
+
}
|
|
31
|
+
AGENT_TOOLS = {'Task', 'Agent'}
|
|
32
|
+
USER_TOOLS = {'AskUserQuestion', 'ExitPlanMode'}
|
|
33
|
+
SKIP_PREFIX = ('<task-notification', '<local-command', 'This session is being continued',
|
|
34
|
+
'[Request interrupted', '<system-reminder', 'Caveat:')
|
|
35
|
+
# built-in slash commands that only drive the CLI UI (no model call), so they don't start a task
|
|
36
|
+
SKIP_CMDS = {'clear', 'model', 'help', 'compact', 'config', 'resume', 'exit', 'status', 'cost',
|
|
37
|
+
'login', 'logout', 'permissions', 'mcp', 'agents', 'doctor', 'hooks', 'fast', 'effort',
|
|
38
|
+
'plugin', 'reload-skills', 'context', 'usage', 'ide', 'memory', 'add-dir', 'theme', 'vim',
|
|
39
|
+
'terminal-setup', 'export', 'rename', 'tasks', 'bashes', 'rewind', 'sandbox', 'skills',
|
|
40
|
+
'privacy-settings', 'release-notes', 'upgrade', 'output-style', 'keybindings'}
|
|
41
|
+
# how Claude Code marks a tool call the user rejected or interrupted (a user decision, not a tool failure)
|
|
42
|
+
USER_STOP = re.compile(r"doesn't want to proceed|tool use was rejected|\[Request interrupted by user", re.I)
|
|
43
|
+
MAX_SEGS = 1200
|
|
44
|
+
IDLE_SPLIT = 1800 # seconds of silence that ends a task
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def price(model):
|
|
48
|
+
m = (model or '').lower()
|
|
49
|
+
key = max((k for k in PRICE if k in m), key=len, default=None)
|
|
50
|
+
return (PRICE[key], False) if key else ((0, 0, 0, 0, 0), True) # unknown: flagged as estimate
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def ts_of(r):
|
|
54
|
+
s = r.get('timestamp')
|
|
55
|
+
if not isinstance(s, str):
|
|
56
|
+
return None
|
|
57
|
+
try:
|
|
58
|
+
return dt.datetime.fromisoformat(s.replace('Z', '+00:00')).timestamp()
|
|
59
|
+
except ValueError:
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _finite(s):
|
|
64
|
+
f = float(s)
|
|
65
|
+
if not math.isfinite(f):
|
|
66
|
+
raise ValueError(f'non-finite number {s}')
|
|
67
|
+
return f
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _reject(s):
|
|
71
|
+
raise ValueError(f'invalid JSON constant {s}')
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def loads(text):
|
|
75
|
+
"""json.loads without NaN/Infinity (or floats that overflow to inf): browsers' JSON.parse rejects them."""
|
|
76
|
+
return json.loads(text, parse_float=_finite, parse_constant=_reject)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def num(x):
|
|
80
|
+
"""a usable non-negative count/amount, else 0 (strings, bools, negatives and garbage count as 0)"""
|
|
81
|
+
return x if isinstance(x, (int, float)) and not isinstance(x, bool) and x > 0 else 0
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def load(path):
|
|
85
|
+
out = []
|
|
86
|
+
with open(path, encoding='utf-8-sig', errors='replace') as f: # -sig: tolerate a UTF-8 BOM
|
|
87
|
+
for line in f:
|
|
88
|
+
try:
|
|
89
|
+
r = loads(line)
|
|
90
|
+
except ValueError:
|
|
91
|
+
continue
|
|
92
|
+
if isinstance(r, dict): # normalise shape so callers can index freely
|
|
93
|
+
r.setdefault('type', None)
|
|
94
|
+
if not isinstance(r.get('message'), dict):
|
|
95
|
+
r['message'] = {}
|
|
96
|
+
out.append(r)
|
|
97
|
+
return out
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def text_of(c):
|
|
101
|
+
if isinstance(c, str):
|
|
102
|
+
return c
|
|
103
|
+
if not isinstance(c, list):
|
|
104
|
+
return ''
|
|
105
|
+
return ' '.join(str(b.get('text', '')) for b in c if isinstance(b, dict) and b.get('type') == 'text')
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def prompt_text(r):
|
|
109
|
+
"""Human prompt that starts a task, else None."""
|
|
110
|
+
if r.get('type') != 'user' or r.get('isSidechain') or r.get('isMeta') or r.get('isCompactSummary'):
|
|
111
|
+
return None
|
|
112
|
+
origin = r.get('origin') if isinstance(r.get('origin'), dict) else {}
|
|
113
|
+
if r.get('turnOrigin') == 'task_notification' or origin.get('kind') == 'task-notification':
|
|
114
|
+
return None
|
|
115
|
+
c = (r.get('message') or {}).get('content')
|
|
116
|
+
if isinstance(c, list) and any(isinstance(b, dict) and b.get('type') == 'tool_result' for b in c):
|
|
117
|
+
return None
|
|
118
|
+
t = text_of(c).strip()
|
|
119
|
+
if not t or t.startswith(SKIP_PREFIX):
|
|
120
|
+
return None
|
|
121
|
+
# only a record that *is* a slash command (either tag may come first), not a prompt quoting the markup
|
|
122
|
+
m = re.search(r'<command-name>/?([\w:-]+)', t) if t.startswith(('<command-name>', '<command-message>')) else None
|
|
123
|
+
if m:
|
|
124
|
+
if m.group(1) in SKIP_CMDS:
|
|
125
|
+
return None
|
|
126
|
+
args = re.search(r'<command-args>(.*?)</command-args>', t, re.S)
|
|
127
|
+
return f"/{m.group(1)} {args.group(1).strip() if args else ''}".strip()
|
|
128
|
+
return t
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
TOKEN_KEYS = ('input_tokens', 'output_tokens', 'cache_read_input_tokens', 'cache_creation_input_tokens')
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def toks(d):
|
|
135
|
+
"""usage-shaped dict -> dict(i, o, cr, c5, c1) with the cache-write split into 5m / 1h."""
|
|
136
|
+
cc = d.get('cache_creation') if isinstance(d.get('cache_creation'), dict) else {}
|
|
137
|
+
cc1, cc5 = num(cc.get('ephemeral_1h_input_tokens')), num(cc.get('ephemeral_5m_input_tokens'))
|
|
138
|
+
total_cc = num(d.get('cache_creation_input_tokens'))
|
|
139
|
+
if cc1 + cc5 != total_cc:
|
|
140
|
+
cc1 = min(cc1, total_cc)
|
|
141
|
+
cc5 = total_cc - cc1
|
|
142
|
+
return dict(i=num(d.get('input_tokens')), o=num(d.get('output_tokens')),
|
|
143
|
+
cr=num(d.get('cache_read_input_tokens')), c5=cc5, c1=cc1)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def usage_of(recs):
|
|
147
|
+
usages = [u if isinstance(u := r['message'].get('usage'), dict) else {} for r in recs]
|
|
148
|
+
best = max(usages, key=lambda u: sum(num(u.get(k)) for k in TOKEN_KEYS))
|
|
149
|
+
# streamed records accumulate iterations[]; the longest list is the complete one
|
|
150
|
+
its = max([u['iterations'] for u in usages if isinstance(u.get('iterations'), list)] or [[]], key=len)
|
|
151
|
+
its = [i for i in its if isinstance(i, dict)]
|
|
152
|
+
if not sum(num(best.get(k)) for k in TOKEN_KEYS): # some logs keep real numbers only in iterations[]
|
|
153
|
+
main = [i for i in its if i.get('type', 'message') == 'message']
|
|
154
|
+
best = {k: sum(num(i.get(k)) for i in main) for k in TOKEN_KEYS} | {'cache_creation': main[-1].get('cache_creation') if main else None}
|
|
155
|
+
think = max(num(d.get('thinking_tokens')) if isinstance(d := u.get('output_tokens_details'), dict) else 0 for u in usages)
|
|
156
|
+
# advisor sub-calls are billed on top of the top-level usage, which covers only the executor
|
|
157
|
+
adv = [(i['model'] if isinstance(i.get('model'), str) else '?', toks(i)) for i in its if i.get('type') == 'advisor_message']
|
|
158
|
+
return toks(best) | dict(think=think, adv=adv, fast=best.get('speed') == 'fast')
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def cost_mix(u, model):
|
|
162
|
+
"""-> ((fresh_in, out, cache_read, cache_write) in $, is_estimate)"""
|
|
163
|
+
p, est = price(model)
|
|
164
|
+
if u.get('fast'): # fast mode: 2x list price (documented for Opus 5 / 5.5)
|
|
165
|
+
p, est = tuple(2 * x for x in p), est or 'opus-5' not in (model or '').lower()
|
|
166
|
+
return (u['i'] * p[0] / 1e6, u['o'] * p[1] / 1e6, u['cr'] * p[2] / 1e6, (u['c5'] * p[3] + u['c1'] * p[4]) / 1e6), est
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def tool_desc(inp):
|
|
170
|
+
if not isinstance(inp, dict):
|
|
171
|
+
return ''
|
|
172
|
+
for k in ('command', 'file_path', 'pattern', 'description', 'prompt', 'query', 'url', 'path'):
|
|
173
|
+
if inp.get(k):
|
|
174
|
+
return str(inp[k]).replace('\n', ' ')[:110]
|
|
175
|
+
return ''
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def ulen(iv):
|
|
179
|
+
tot, s, e = 0, None, None
|
|
180
|
+
for a, b in sorted(iv):
|
|
181
|
+
if e is None or a > e:
|
|
182
|
+
if e is not None:
|
|
183
|
+
tot += e - s
|
|
184
|
+
s, e = a, b
|
|
185
|
+
else:
|
|
186
|
+
e = max(e, b)
|
|
187
|
+
return tot + (e - s if e is not None else 0)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def tok_sum(calls, k):
|
|
191
|
+
"""token total over calls, advisor sub-calls included"""
|
|
192
|
+
return sum(c['u'][k] + sum(au[k] for _, au, _ in c['adv']) for c in calls)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def split(wall, u_m, u_t, u_a, u_u):
|
|
196
|
+
"""Rounded time split whose parts never sum past the rounded wall time."""
|
|
197
|
+
parts = dict(model_s=round(u_m, 2), tool_s=round(u_t - u_m, 2), agent_s=round(u_a - u_t, 2), user_s=round(u_u - u_a, 2))
|
|
198
|
+
busy = round(sum(parts.values()), 2)
|
|
199
|
+
w = max(round(wall, 2), busy)
|
|
200
|
+
return dict(wall=w, wait_s=round(w - busy, 2), **parts)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def analyze_session(sid, proj, files, seen_msgs):
|
|
204
|
+
"""files: [(path, is_sub)]. Returns (session dict, [task dicts])."""
|
|
205
|
+
recs = []
|
|
206
|
+
for p, is_sub in files:
|
|
207
|
+
for r in load(p):
|
|
208
|
+
r['_sub'] = is_sub or bool(r.get('isSidechain'))
|
|
209
|
+
r['_ts'] = ts_of(r)
|
|
210
|
+
recs.append(r)
|
|
211
|
+
uuid_ts = {r['uuid']: r['_ts'] for r in recs if r.get('uuid') and r['_ts']}
|
|
212
|
+
parent_of = {r['uuid']: r.get('parentUuid') for r in recs if r.get('uuid')}
|
|
213
|
+
|
|
214
|
+
# --- API calls (assistant records are streamed one content block per record) ---
|
|
215
|
+
by_msg = collections.OrderedDict()
|
|
216
|
+
for r in recs:
|
|
217
|
+
if r['type'] == 'assistant' and r['_ts'] and isinstance(r['message'].get('id'), str) and r['message'].get('model') != '<synthetic>':
|
|
218
|
+
by_msg.setdefault(r['message']['id'], []).append(r)
|
|
219
|
+
calls, results = [], {}
|
|
220
|
+
for r in recs: # tool_result lookup
|
|
221
|
+
c = (r.get('message') or {}).get('content')
|
|
222
|
+
if r['type'] == 'user' and isinstance(c, list) and r['_ts']:
|
|
223
|
+
for b in c:
|
|
224
|
+
if isinstance(b, dict) and b.get('type') == 'tool_result' and isinstance(b.get('tool_use_id'), str):
|
|
225
|
+
stopped = bool(b.get('is_error')) and bool(USER_STOP.search(str(b.get('content'))[:300]))
|
|
226
|
+
results[b.get('tool_use_id')] = (r['_ts'], bool(b.get('is_error')) and not stopped, stopped)
|
|
227
|
+
for mid, rs in by_msg.items():
|
|
228
|
+
if mid in seen_msgs: # forked/resumed sessions copy history
|
|
229
|
+
continue
|
|
230
|
+
rs.sort(key=lambda r: r['_ts'])
|
|
231
|
+
first = rs[0]
|
|
232
|
+
# request start = the parent record, skipping parents stamped as the response arrived
|
|
233
|
+
# (e.g. `deferred_tools_record` attachments), which would zero out the call's duration
|
|
234
|
+
start, u = None, first.get('parentUuid')
|
|
235
|
+
for _ in range(10):
|
|
236
|
+
t = uuid_ts.get(u)
|
|
237
|
+
if t is None or t < first['_ts'] - 0.05:
|
|
238
|
+
start = t
|
|
239
|
+
break
|
|
240
|
+
u = parent_of.get(u)
|
|
241
|
+
start = min(start or first['_ts'], first['_ts'])
|
|
242
|
+
prev, think_s, tools = start, 0.0, []
|
|
243
|
+
for r in rs:
|
|
244
|
+
blocks = r['message'].get('content') if isinstance(r['message'].get('content'), list) else []
|
|
245
|
+
if any(b.get('type') == 'thinking' for b in blocks if isinstance(b, dict)):
|
|
246
|
+
think_s += r['_ts'] - prev
|
|
247
|
+
prev = r['_ts']
|
|
248
|
+
for b in blocks:
|
|
249
|
+
if isinstance(b, dict) and b.get('type') == 'tool_use':
|
|
250
|
+
tid, name = b.get('id'), b.get('name')
|
|
251
|
+
tools.append(dict(id=tid if isinstance(tid, str) else None, name=name if isinstance(name, str) and name else '?',
|
|
252
|
+
ts=r['_ts'], desc=tool_desc(b.get('input'))))
|
|
253
|
+
u = usage_of(rs)
|
|
254
|
+
model = rs[0]['message'].get('model')
|
|
255
|
+
model = model if isinstance(model, str) else None
|
|
256
|
+
mix, est = cost_mix(u, model)
|
|
257
|
+
adv = []
|
|
258
|
+
for am, au in u['adv']:
|
|
259
|
+
amix, aest = cost_mix(au, am)
|
|
260
|
+
adv.append((am, au, sum(amix)))
|
|
261
|
+
mix, est = tuple(a + b for a, b in zip(mix, amix)), est or aest
|
|
262
|
+
calls.append(dict(adv=adv, mid=mid, start=start, end=rs[-1]['_ts'], think_s=think_s, model=model or '?', u=u, cost=sum(mix), mix=mix,
|
|
263
|
+
est=est, sub=first['_sub'], tools=tools, eff=first.get('effort'),
|
|
264
|
+
ctx=u['i'] + u['cr'] + u['c5'] + u['c1']))
|
|
265
|
+
seen_msgs.update(c['mid'] for c in calls)
|
|
266
|
+
|
|
267
|
+
# --- tasks = human prompts in the main transcript ---
|
|
268
|
+
prompts = [(r['_ts'], t) for r in recs if not r['_sub'] and r['_ts'] and (t := prompt_text(r))]
|
|
269
|
+
prompts.sort()
|
|
270
|
+
first_call = min((c['start'] for c in calls), default=None)
|
|
271
|
+
if first_call is not None and (not prompts or first_call < prompts[0][0] - 1):
|
|
272
|
+
prompts.insert(0, (first_call, '(session start / resumed without a prompt)'))
|
|
273
|
+
# activity resuming after a long gap with no new prompt (e.g. background wake-ups) is its own task
|
|
274
|
+
times = sorted(r['_ts'] for r in recs if r['_ts'] and r['type'] in ('assistant', 'user'))
|
|
275
|
+
pts = sorted(p[0] for p in prompts)
|
|
276
|
+
for a, b in zip(times, times[1:]):
|
|
277
|
+
if b - a > IDLE_SPLIT and not any(abs(b - x) < 2 for x in pts):
|
|
278
|
+
prompts.append((b, '(resumed activity, no new prompt)'))
|
|
279
|
+
prompts.sort()
|
|
280
|
+
if not prompts:
|
|
281
|
+
return None, []
|
|
282
|
+
starts = [p[0] for p in prompts]
|
|
283
|
+
idx = lambda t: max(0, bisect.bisect_right(starts, t) - 1)
|
|
284
|
+
T = [dict(start=s, prompt=t, calls=[], ends=[s], compacts=[], interrupts=0) for s, t in prompts]
|
|
285
|
+
for c in calls:
|
|
286
|
+
T[idx(c['start'])]['calls'].append(c)
|
|
287
|
+
for r in recs:
|
|
288
|
+
if r['_ts'] and r['type'] in ('assistant', 'user'):
|
|
289
|
+
T[idx(r['_ts'])]['ends'].append(r['_ts'])
|
|
290
|
+
if r['type'] == 'system' and r.get('subtype') == 'compact_boundary' and r['_ts']:
|
|
291
|
+
m = r.get('compactMetadata') or {}
|
|
292
|
+
m = m if isinstance(m, dict) else {}
|
|
293
|
+
T[idx(r['_ts'])]['compacts'].append((r['_ts'], num(m.get('durationMs')) / 1000, num(m.get('preTokens')), num(m.get('postTokens'))))
|
|
294
|
+
title = ''
|
|
295
|
+
for r in recs:
|
|
296
|
+
if r['type'] in ('ai-title', 'custom-title'):
|
|
297
|
+
title = r.get('aiTitle') or r.get('customTitle') or title
|
|
298
|
+
cwd = next((r['cwd'] for r in recs if isinstance(r.get('cwd'), str) and r['cwd']), '')
|
|
299
|
+
reported = max((num(r.get('totalCostUSD')) for r in recs if r['type'] == 'cost-state'), default=0)
|
|
300
|
+
|
|
301
|
+
tasks = []
|
|
302
|
+
for n, t in enumerate(T):
|
|
303
|
+
cs = t['calls']
|
|
304
|
+
if not cs:
|
|
305
|
+
continue
|
|
306
|
+
t0, t1 = t['start'], max(max(t['ends']), max(c['end'] for c in cs))
|
|
307
|
+
wall = max(t1 - t0, 0.01) # stays > 0 after round(wall, 2)
|
|
308
|
+
clip = lambda a, b: (max(a, t0), min(b, t1))
|
|
309
|
+
mi, ti, ai, ui, tool_stats, tcalls, segs = [], [], [], [], {}, [], []
|
|
310
|
+
for c in cs:
|
|
311
|
+
mi.append(clip(c['start'], c['end']))
|
|
312
|
+
segs.append(['ms' if c['sub'] else 'm', round(c['start'] - t0, 2), round(c['end'] - c['start'], 2),
|
|
313
|
+
c['model'], round(c['think_s'], 2), c['u']['o']])
|
|
314
|
+
for tu in c['tools']:
|
|
315
|
+
res = results.get(tu['id'])
|
|
316
|
+
st = tool_stats.setdefault(tu['name'], [0, 0, 0.0, 0.0, 0]) # n, err, sum, max, interrupted
|
|
317
|
+
st[0] += 1
|
|
318
|
+
if not res:
|
|
319
|
+
continue
|
|
320
|
+
d = max(0.0, res[0] - tu['ts'])
|
|
321
|
+
st[1] += res[1]; st[2] += d; st[3] = max(st[3], d); st[4] += res[2]
|
|
322
|
+
(ai if tu['name'] in AGENT_TOOLS else ui if tu['name'] in USER_TOOLS else ti).append(clip(tu['ts'], res[0]))
|
|
323
|
+
segs.append(['ts' if c['sub'] else 'te' if res[1] else 't', round(tu['ts'] - t0, 2), round(d, 2), tu['name'], tu['desc'], 0])
|
|
324
|
+
tcalls.append([tu['name'], round(d, 1), tu['desc'], int(res[1])])
|
|
325
|
+
for ct, dur, pre, post in t['compacts']:
|
|
326
|
+
mi.append(clip(ct - dur, ct))
|
|
327
|
+
mi, ti, ai, ui = [[x for x in l if x[1] > x[0]] for l in (mi, ti, ai, ui)]
|
|
328
|
+
u_m = ulen(mi); u_t = ulen(mi + ti); u_a = ulen(mi + ti + ai); u_u = ulen(mi + ti + ai + ui)
|
|
329
|
+
main = [c for c in cs if not c['sub']]
|
|
330
|
+
models = {}
|
|
331
|
+
for c in cs:
|
|
332
|
+
m = models.setdefault(c['model'], [0, 0.0, 0, 0, 0.0, 0.0, 0, 0, 0]) # calls,cost,out,think_tok,dur,think_s,in,cr,cc
|
|
333
|
+
m[0] += 1; m[1] += c['cost']; m[2] += c['u']['o']; m[3] += c['u']['think']; m[4] += c['end'] - c['start']
|
|
334
|
+
m[5] += c['think_s']; m[6] += c['u']['i']; m[7] += c['u']['cr']; m[8] += c['u']['c5'] + c['u']['c1']
|
|
335
|
+
m[1] -= sum(x[2] for x in c['adv']) # advisor share is booked on its own row
|
|
336
|
+
for am, au, acost in c['adv']:
|
|
337
|
+
a = models.setdefault(f'{am} (advisor)', [0, 0.0, 0, 0, 0.0, 0.0, 0, 0, 0])
|
|
338
|
+
a[0] += 1; a[1] += acost; a[2] += au['o']; a[6] += au['i']; a[7] += au['cr']; a[8] += au['c5'] + au['c1']
|
|
339
|
+
if len(segs) > MAX_SEGS:
|
|
340
|
+
segs = sorted(sorted(segs, key=lambda s: -s[2])[:MAX_SEGS], key=lambda s: s[1])
|
|
341
|
+
ctx = [[round(c['start'] - t0, 1), c['ctx']] for c in sorted(main, key=lambda c: c['start'])]
|
|
342
|
+
if len(ctx) > 200:
|
|
343
|
+
ctx = ctx[::len(ctx) // 200 + 1]
|
|
344
|
+
tcalls.sort(key=lambda x: -x[1])
|
|
345
|
+
tasks.append(dict(
|
|
346
|
+
id=f'{sid}#{n}', sid=sid, proj=proj, title=title, prompt=t['prompt'][:600], start=t0, **split(wall, u_m, u_t, u_a, u_u),
|
|
347
|
+
calls=len(main), sub_calls=len(cs) - len(main), tools=sum(len(c['tools']) for c in cs),
|
|
348
|
+
think_calls=sum(1 for c in cs if c['think_s'] > 0), think_s=round(sum(c['think_s'] for c in cs), 2),
|
|
349
|
+
think_tok=sum(c['u']['think'] for c in cs), out_tok=tok_sum(cs, 'o'),
|
|
350
|
+
in_tok=tok_sum(cs, 'i'), cr_tok=tok_sum(cs, 'cr'), cc_tok=tok_sum(cs, 'c5') + tok_sum(cs, 'c1'),
|
|
351
|
+
cost=round(sum(c['cost'] for c in cs), 5), cost_mix=[round(sum(c['mix'][i] for c in cs), 5) for i in range(4)],
|
|
352
|
+
sub_cost=round(sum(c['cost'] for c in cs if c['sub']), 5),
|
|
353
|
+
est=any(c['est'] for c in cs), ctx_max=max(c['ctx'] for c in cs), compacts=[[round(ct - t0, 1), dur, pre, post] for ct, dur, pre, post in t['compacts']],
|
|
354
|
+
models=models, tool_stats=tool_stats, slow=tcalls, segs=segs, ctx=ctx,
|
|
355
|
+
efforts=sorted({str(c['eff']) for c in cs if c['eff']})))
|
|
356
|
+
if not tasks:
|
|
357
|
+
return None, []
|
|
358
|
+
sess = dict(id=sid, proj=proj, cwd=cwd, title=title, start=min(t['start'] for t in tasks),
|
|
359
|
+
end=max(t['start'] + t['wall'] for t in tasks), tasks=len(tasks), reported_cost=round(reported, 4),
|
|
360
|
+
est_cost=round(sum(t['cost'] for t in tasks), 4))
|
|
361
|
+
return sess, tasks
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def discover(root):
|
|
365
|
+
root = Path(root).expanduser()
|
|
366
|
+
if root.is_file():
|
|
367
|
+
return root.parent, [root]
|
|
368
|
+
base = root / 'projects' if (root / 'projects').is_dir() else root
|
|
369
|
+
# is_file(): skips directories named *.jsonl and broken symlinks
|
|
370
|
+
return base, sorted(p for p in base.rglob('*.jsonl') if 'tool-results' not in p.parts and p.is_file())
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def first_session_id(path):
|
|
374
|
+
"""sessionId of the first record that has one; stops reading at the first match."""
|
|
375
|
+
try:
|
|
376
|
+
with open(path, encoding='utf-8-sig', errors='replace') as f:
|
|
377
|
+
for line in f:
|
|
378
|
+
if 'sessionId' not in line:
|
|
379
|
+
continue
|
|
380
|
+
try:
|
|
381
|
+
r = loads(line)
|
|
382
|
+
except ValueError:
|
|
383
|
+
continue
|
|
384
|
+
if isinstance(r, dict) and isinstance(r.get('sessionId'), str):
|
|
385
|
+
return r['sessionId']
|
|
386
|
+
except OSError:
|
|
387
|
+
# unreadable here -> treat as an original session; build() reports the read error when it analyses it
|
|
388
|
+
return None
|
|
389
|
+
return None
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def copy_last(item):
|
|
393
|
+
"""Sort key: originals before forked/resumed copies, so shared history is credited to the original.
|
|
394
|
+
A copy's leading records carry the source session's id; file mtime only breaks ties."""
|
|
395
|
+
(proj, sid), files = item
|
|
396
|
+
main = [p for p, is_sub in files if not is_sub]
|
|
397
|
+
is_copy = bool(main) and first_session_id(main[0]) not in (None, sid)
|
|
398
|
+
return is_copy, min(p.stat().st_mtime for p, _ in files)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def build(root):
|
|
402
|
+
base, paths = discover(root)
|
|
403
|
+
groups = collections.defaultdict(list) # (project, session id) -> files
|
|
404
|
+
for p in paths:
|
|
405
|
+
parts = p.relative_to(base).parts
|
|
406
|
+
sub = 'subagents' in parts
|
|
407
|
+
i = parts.index('subagents') if sub else None
|
|
408
|
+
sid = (parts[i - 1] if i else base.name) if sub else p.stem # i == 0: PATH is the <session>/ dir itself
|
|
409
|
+
depth = parts.index('subagents') - 1 if sub else len(parts) - 1 # dirs above the session
|
|
410
|
+
proj = parts[0] if depth >= 1 else base.name # base may itself be one project
|
|
411
|
+
groups[(proj, sid)].append((p, sub))
|
|
412
|
+
sessions, tasks, seen, keys = [], [], set(), set()
|
|
413
|
+
for (proj, sid), files in sorted(groups.items(), key=copy_last):
|
|
414
|
+
key = sid if sid not in keys else f'{sid}@{proj}' # same session id under two projects: keep task ids unique
|
|
415
|
+
keys.add(key)
|
|
416
|
+
try:
|
|
417
|
+
s, ts = analyze_session(key, proj, sorted(files, key=lambda f: f[1]), seen)
|
|
418
|
+
except Exception as e: # one unreadable/malformed session must not sink the report
|
|
419
|
+
print(f'warning: skipped session {sid} in {proj}: {type(e).__name__}: {e}', file=sys.stderr)
|
|
420
|
+
continue
|
|
421
|
+
if s:
|
|
422
|
+
sessions.append(s); tasks += ts
|
|
423
|
+
for s in sessions: # friendlier project label from cwd
|
|
424
|
+
s['label'] = Path(s['cwd'].replace('\\', '/')).name or s['proj']
|
|
425
|
+
lab = {s['id']: s['label'] for s in sessions}
|
|
426
|
+
for t in tasks:
|
|
427
|
+
t['proj'] = lab.get(t['sid'], t['proj'])
|
|
428
|
+
tasks.sort(key=lambda t: t['start'])
|
|
429
|
+
return dict(generated=dt.datetime.now().isoformat(timespec='seconds'), root=str(base), files=len(paths),
|
|
430
|
+
price=PRICE, sessions=sessions, tasks=tasks)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def redact(d):
|
|
434
|
+
names, servers, sids = {}, {}, {}
|
|
435
|
+
|
|
436
|
+
def tool(n): # mcp__<server>__<tool>: server names can be internal
|
|
437
|
+
parts = n.split('__') if isinstance(n, str) else []
|
|
438
|
+
if len(parts) < 2 or parts[0] != 'mcp':
|
|
439
|
+
return n
|
|
440
|
+
server, rest = ('__'.join(parts[1:-1]), f'__{parts[-1]}') if len(parts) > 2 else (parts[1], '')
|
|
441
|
+
return f"mcp__{servers.setdefault(server, f'server-{len(servers) + 1}')}{rest}" # server names may contain '__'
|
|
442
|
+
|
|
443
|
+
for t in d['tasks']:
|
|
444
|
+
t['proj'] = names.setdefault(t['proj'], f'project-{len(names) + 1}')
|
|
445
|
+
t['sid'] = sids.setdefault(t['sid'], f'session-{len(sids) + 1}')
|
|
446
|
+
t['id'] = f"{t['sid']}#{t['id'].rsplit('#', 1)[1]}"
|
|
447
|
+
t['prompt'] = f"Task {t['id']}"; t['title'] = ''
|
|
448
|
+
t['slow'] = [[tool(n), dur, '', e] for n, dur, _, e in t['slow']]
|
|
449
|
+
t['tool_stats'] = {tool(k): v for k, v in t['tool_stats'].items()}
|
|
450
|
+
for sg in t['segs']:
|
|
451
|
+
if sg[0] not in ('m', 'ms'):
|
|
452
|
+
sg[3], sg[4] = tool(sg[3]), ''
|
|
453
|
+
for x in d['sessions']:
|
|
454
|
+
x['cwd'] = x['title'] = ''; x['label'] = x['proj'] = names.get(x['label'], '')
|
|
455
|
+
x['id'] = sids.get(x['id'], '')
|
|
456
|
+
d['root'] = '(redacted)'
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def render_html(data):
|
|
460
|
+
# escape '<' so transcript text like '</script>' can't close the embedded JSON block
|
|
461
|
+
payload = json.dumps(data, separators=(',', ':'), allow_nan=False) # NaN would break JSON.parse
|
|
462
|
+
return TEMPLATE.replace('__DATA__', payload.replace('<', '\\u003c'))
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def config_dir():
|
|
466
|
+
return Path(os.environ.get('CLAUDE_CONFIG_DIR') or '~/.claude').expanduser()
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def load_pricing(path):
|
|
470
|
+
"""--pricing file: {"model-substring": [in, out, cache_read, write_5m, write_1h]} in $/MTok."""
|
|
471
|
+
try:
|
|
472
|
+
raw = loads(Path(path).read_text(encoding='utf-8-sig'))
|
|
473
|
+
if not isinstance(raw, dict):
|
|
474
|
+
raise ValueError('expected a JSON object')
|
|
475
|
+
out = {}
|
|
476
|
+
for k, v in raw.items():
|
|
477
|
+
if not (isinstance(v, list) and len(v) == 5
|
|
478
|
+
and all(isinstance(x, (int, float)) and not isinstance(x, bool) and 0 <= x < 1e6 for x in v)):
|
|
479
|
+
raise ValueError(f'{k!r}: expected a list of 5 prices, each a number from 0 to 1e6 $/MTok')
|
|
480
|
+
out[k.lower()] = tuple(v)
|
|
481
|
+
return out
|
|
482
|
+
except (OSError, ValueError) as e:
|
|
483
|
+
sys.exit(f'--pricing {path}: {e}')
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def install_plugin(force=False):
|
|
487
|
+
cmd_dir = config_dir() / 'commands'
|
|
488
|
+
cmd_dir.mkdir(parents=True, exist_ok=True)
|
|
489
|
+
plugin_file = cmd_dir / 'claudon.md'
|
|
490
|
+
plugin_content = (
|
|
491
|
+
"---\n"
|
|
492
|
+
"description: Generate and open interactive Claudon analytics dashboard for Claude Code sessions\n"
|
|
493
|
+
"---\n\n"
|
|
494
|
+
"Run `claudon -o cc_report.html --open` (or `npx claudon -o cc_report.html --open`) to analyze sessions.\n"
|
|
495
|
+
)
|
|
496
|
+
if plugin_file.exists():
|
|
497
|
+
if plugin_file.read_text(encoding='utf-8') == plugin_content:
|
|
498
|
+
print(f"Claudon slash command is already up to date at {plugin_file}")
|
|
499
|
+
return
|
|
500
|
+
if not force:
|
|
501
|
+
sys.exit(f"{plugin_file} already exists with different content; "
|
|
502
|
+
"re-run with --install-plugin --force to overwrite it")
|
|
503
|
+
plugin_file.write_text(plugin_content, encoding='utf-8')
|
|
504
|
+
print(f"Successfully installed Claudon slash command to {plugin_file}")
|
|
505
|
+
print("You can now type /claudon inside Claude Code to launch analytics!")
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def main():
|
|
509
|
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
510
|
+
ap.add_argument('path', nargs='?', default=str(config_dir()))
|
|
511
|
+
ap.add_argument('--version', action='version', version=f'claudon {__version__}')
|
|
512
|
+
ap.add_argument('-o', '--out', default='cc_report.html')
|
|
513
|
+
ap.add_argument('--pricing', help='JSON {"model-substring": [in, out, cache_read, write_5m, write_1h]} $/MTok; longest match wins')
|
|
514
|
+
ap.add_argument('--redact', action='store_true', help='strip prompts, titles, paths, commands and project names (safe to share)')
|
|
515
|
+
ap.add_argument('--open', action='store_true')
|
|
516
|
+
ap.add_argument('--install-plugin', action='store_true', help='install /claudon slash command into <config dir>/commands/')
|
|
517
|
+
ap.add_argument('--force', action='store_true', help='with --install-plugin: overwrite a modified claudon.md')
|
|
518
|
+
a = ap.parse_args()
|
|
519
|
+
if a.install_plugin:
|
|
520
|
+
install_plugin(a.force)
|
|
521
|
+
return
|
|
522
|
+
if a.pricing:
|
|
523
|
+
PRICE.update(load_pricing(a.pricing))
|
|
524
|
+
data = build(a.path)
|
|
525
|
+
if not data['tasks']:
|
|
526
|
+
sys.exit(f'no analysable sessions under {a.path}')
|
|
527
|
+
if a.redact:
|
|
528
|
+
redact(data)
|
|
529
|
+
out = Path(a.out)
|
|
530
|
+
try:
|
|
531
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
532
|
+
out.write_text(render_html(data), encoding='utf-8')
|
|
533
|
+
except OSError as e:
|
|
534
|
+
sys.exit(f'cannot write {out}: {e}')
|
|
535
|
+
print(f'{len(data["sessions"])} sessions, {len(data["tasks"])} tasks from {data["files"]} files -> {a.out}')
|
|
536
|
+
if a.open:
|
|
537
|
+
webbrowser.open(Path(a.out).resolve().as_uri())
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
TEMPLATE = r'''<!doctype html>
|
|
542
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
543
|
+
<title>Claudon</title>
|
|
544
|
+
<style>
|
|
545
|
+
:root{--bg:#f6f7f9;--card:#fff;--fg:#1c2330;--mute:#6b7585;--line:#e3e6ec;--model:#3b82f6;--think:#8b5cf6;--tool:#f59e0b;--agent:#14b8a6;--user:#ec4899;--wait:#cbd2dc;--err:#ef4444;--ok:#10b981}
|
|
546
|
+
@media(prefers-color-scheme:dark){:root{--bg:#10131a;--card:#181c26;--fg:#e6e9ef;--mute:#8d97a8;--line:#272d3a;--wait:#3a4252}}
|
|
547
|
+
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.45 system-ui,sans-serif}
|
|
548
|
+
header{position:sticky;top:0;z-index:5;background:var(--card);border-bottom:1px solid var(--line);padding:10px 20px;display:flex;gap:14px;align-items:center;flex-wrap:wrap}
|
|
549
|
+
h1{font-size:16px;margin:0 8px 0 0}nav button,select,input{font:inherit;color:var(--fg);background:var(--bg);border:1px solid var(--line);border-radius:6px;padding:5px 11px}
|
|
550
|
+
nav button{cursor:pointer}nav button.on{background:var(--model);border-color:var(--model);color:#fff}
|
|
551
|
+
main{padding:20px;max-width:1500px;margin:auto}
|
|
552
|
+
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:12px;margin-bottom:18px}
|
|
553
|
+
.card{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:14px 16px;margin-bottom:16px;overflow-x:auto}
|
|
554
|
+
.grid .card{margin:0}.k{color:var(--mute);font-size:12px;text-transform:uppercase;letter-spacing:.04em}.v{font-size:24px;font-weight:600}.s{color:var(--mute);font-size:12px}
|
|
555
|
+
h2{font-size:14px;margin:0 0 10px}.cols{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(420px,100%),1fr));gap:16px}
|
|
556
|
+
table{border-collapse:collapse;width:100%}th,td{padding:6px 8px;border-bottom:1px solid var(--line);text-align:left;vertical-align:middle}
|
|
557
|
+
th{color:var(--mute);font-weight:500;font-size:12px;cursor:pointer;white-space:nowrap;user-select:none}th:hover{color:var(--fg)}
|
|
558
|
+
td.n,th.n{text-align:right;font-variant-numeric:tabular-nums}tr.c{cursor:pointer}tr.c:hover{background:var(--bg)}
|
|
559
|
+
.pr{max-width:420px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
560
|
+
.bar{display:flex;height:10px;border-radius:5px;overflow:hidden;background:var(--wait);min-width:110px}.bar i{display:block}
|
|
561
|
+
.m{background:var(--model)}.th{background:var(--think)}.t{background:var(--tool)}.a{background:var(--agent)}.u{background:var(--user)}.w{background:var(--wait)}.e{background:var(--err)}
|
|
562
|
+
.leg span{margin-right:14px;font-size:12px;color:var(--mute)}.leg b{display:inline-block;width:10px;height:10px;border-radius:2px;margin-right:5px;vertical-align:-1px}
|
|
563
|
+
.hb{height:8px;background:var(--model);border-radius:4px;min-width:1px}
|
|
564
|
+
.days{display:flex;align-items:flex-end;gap:2px;height:110px}.days div{flex:1;background:var(--model);min-width:2px;border-radius:2px 2px 0 0}
|
|
565
|
+
.find{border-left:4px solid var(--tool);padding:6px 12px;margin:10px 0;background:var(--bg);border-radius:0 6px 6px 0}.find.hi{border-color:var(--err)}.find.lo{border-color:var(--model)}
|
|
566
|
+
.find b{display:block}.chip{display:inline-block;margin:4px 6px 0 0;padding:1px 8px;border:1px solid var(--line);border-radius:10px;font-size:12px;cursor:pointer;background:var(--card)}.chip:hover{border-color:var(--model)}
|
|
567
|
+
#modal{position:fixed;inset:0;background:#0008;display:none;z-index:10;overflow:auto;padding:30px}#modal>div{background:var(--card);max-width:1200px;margin:auto;border-radius:12px;padding:20px;position:relative}
|
|
568
|
+
#x{position:absolute;right:14px;top:10px;cursor:pointer;font-size:22px;color:var(--mute)}
|
|
569
|
+
.gantt{position:relative;border:1px solid var(--line);border-radius:6px;margin:8px 0 4px;background:var(--bg)}.lane{position:relative;height:24px;border-bottom:1px dashed var(--line)}
|
|
570
|
+
.lane:last-child{border:0}.lane span{position:absolute;top:3px;height:18px;border-radius:3px;min-width:2px;opacity:.9}.lane label{position:absolute;left:4px;top:4px;font-size:11px;color:var(--mute);z-index:1;pointer-events:none}
|
|
571
|
+
.axis{display:flex;justify-content:space-between;color:var(--mute);font-size:11px}.pre{white-space:pre-wrap;background:var(--bg);padding:10px;border-radius:6px;max-height:160px;overflow:auto;font-size:12px}
|
|
572
|
+
footer{color:var(--mute);font-size:12px;line-height:1.5;padding:16px 20px 40px;max-width:1500px;margin:auto;border-top:1px solid var(--line)}
|
|
573
|
+
footer .meta{margin-bottom:12px}footer .notes{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:12px 28px}
|
|
574
|
+
footer .notes>div>b:first-child{display:block;color:var(--fg);font-size:12px;margin-bottom:2px}footer b{color:var(--fg);font-weight:600}
|
|
575
|
+
footer details{margin-top:14px}footer summary{cursor:pointer;color:var(--fg)}footer details table{width:auto;margin-top:8px}footer details th,footer details td{font-size:12px;padding:3px 12px 3px 0;cursor:default}code{background:var(--bg);padding:1px 5px;border-radius:4px}
|
|
576
|
+
.more{display:flex;gap:8px;align-items:center;margin-top:8px}.more button{font:inherit;font-size:12px;color:var(--fg);background:var(--bg);border:1px solid var(--line);border-radius:6px;padding:3px 10px;cursor:pointer}.more button:hover{border-color:var(--model)}
|
|
577
|
+
.tip{color:var(--mute);cursor:help;border-bottom:1px dotted}
|
|
578
|
+
</style></head><body>
|
|
579
|
+
<header><h1>Claudon</h1>
|
|
580
|
+
<nav id="nav"></nav><span style="flex:1"></span>
|
|
581
|
+
<select id="fp"></select><input id="fq" placeholder="search prompts…" size="22"></header>
|
|
582
|
+
<main id="view"></main><div id="modal"><div><span id="x">×</span><div id="mbody"></div></div></div>
|
|
583
|
+
<footer id="foot"></footer>
|
|
584
|
+
<script id="d" type="application/json">__DATA__</script>
|
|
585
|
+
<script>
|
|
586
|
+
window.onerror=(m,u,l)=>{const e=document.createElement('pre');e.style.cssText='color:#c00;padding:16px';e.textContent='Dashboard error: '+m+' (line '+l+')';document.body.prepend(e)};
|
|
587
|
+
const D=JSON.parse(document.getElementById('d').textContent);
|
|
588
|
+
const $=s=>document.querySelector(s),esc=s=>String(s??'').replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
|
589
|
+
const T=s=>{s=Math.round(s);if(s<60)return s+'s';const m=Math.floor(s/60);if(m<60)return m+'m '+s%60+'s';return Math.floor(m/60)+'h '+m%60+'m'};
|
|
590
|
+
const N=n=>n>=1e9?(n/1e9).toFixed(2)+'B':n>=1e6?(n/1e6).toFixed(2)+'M':n>=1e3?(n/1e3).toFixed(1)+'k':String(Math.round(n));
|
|
591
|
+
const $$=n=>'$'+(n>=100?n.toFixed(0):n.toFixed(2)),P=x=>isFinite(x)?(x*100).toFixed(0)+'%':'–';
|
|
592
|
+
const sum=(a,f)=>a.reduce((s,x)=>s+f(x),0);
|
|
593
|
+
const Q=(a,p)=>{if(!a.length)return 0;a=[...a].sort((x,y)=>x-y);return a[Math.min(a.length-1,Math.floor(p*a.length))]};
|
|
594
|
+
const date=t=>new Date(t*1000).toISOString().slice(0,10);
|
|
595
|
+
const sn=m=>m.replace('claude-','').replace(/-20\d{6}$/,'');
|
|
596
|
+
const tabs=['Overview','Tasks','Tools','Models & thinking','Bottlenecks'];let cur,tab=0,sorts={},F=[];
|
|
597
|
+
$('#nav').innerHTML=tabs.map((t,i)=>`<button data-t="${i}">${t}</button>`).join('');
|
|
598
|
+
const projs=[...new Set(D.tasks.map(t=>t.proj))].sort();
|
|
599
|
+
$('#fp').innerHTML='<option value="">All projects ('+projs.length+')</option>'+projs.map(p=>`<option>${esc(p)}</option>`).join('');
|
|
600
|
+
function filt(){const p=$('#fp').value,q=$('#fq').value.toLowerCase();F=D.tasks.filter(t=>(!p||t.proj==p)&&(!q||(t.prompt+t.title).toLowerCase().includes(q)))}
|
|
601
|
+
const bar=t=>{const w=t.wall||1,seg=(k,c)=>`<i class="${c}" style="width:${t[k]/w*100}%" title="${c}"></i>`;
|
|
602
|
+
return `<div class="bar" title="model ${T(t.model_s)} · tools ${T(t.tool_s)} · subagent ${T(t.agent_s)} · user ${T(t.user_s)} · idle ${T(t.wait_s)}">${seg('model_s','m')}${seg('tool_s','t')}${seg('agent_s','a')}${seg('user_s','u')}</div>`};
|
|
603
|
+
const legend='<div class="leg"><span><b class="m"></b>model</span><span><b class="th"></b>thinking</span><span><b class="t"></b>tools</span><span><b class="a"></b>subagent wait</span><span><b class="u"></b>user prompts</span><span><b class="w"></b>idle / waiting</span></div>';
|
|
604
|
+
const shown={}; // table id -> rows currently shown ("show more" grows it by the table's page size)
|
|
605
|
+
function tbl(id,cols,rows,limit=200,click){
|
|
606
|
+
const s=sorts[id]||(sorts[id]={i:cols.findIndex(c=>c.d),d:-1}),n=shown[id]||limit;
|
|
607
|
+
if(s.i>=0){const c=cols[s.i];rows=[...rows].sort((a,b)=>{const x=c.v(a),y=c.v(b);return (x>y?1:x<y?-1:0)*s.d})}
|
|
608
|
+
return `<table><tr>${cols.map((c,i)=>`<th class="${c.n?'n':''}" data-s="${id}|${i}">${c.h}${s.i==i?(s.d<0?' ▾':' ▴'):''}</th>`).join('')}</tr>`+
|
|
609
|
+
rows.slice(0,n).map(r=>`<tr class="${click?'c':''}" ${click?`data-task="${esc(click(r))}"`:''}>${cols.map(c=>`<td class="${c.n?'n':''}">${c.f(r)}</td>`).join('')}</tr>`).join('')+'</table>'+
|
|
610
|
+
(rows.length>n?`<div class="more s">showing ${n} of ${rows.length}<button data-more="${id}|${limit}">show ${Math.min(limit,rows.length-n)} more</button><button data-more="${id}|all">show all</button></div>`:'')}
|
|
611
|
+
const kpi=(k,v,s='')=>`<div class="card"><div class="k">${k}</div><div class="v">${v}</div><div class="s">${s}</div></div>`;
|
|
612
|
+
const A=a=>({wall:sum(a,t=>t.wall),model:sum(a,t=>t.model_s),tool:sum(a,t=>t.tool_s),agent:sum(a,t=>t.agent_s),user:sum(a,t=>t.user_s),wait:sum(a,t=>t.wait_s),
|
|
613
|
+
calls:sum(a,t=>t.calls),sub:sum(a,t=>t.sub_calls),tools:sum(a,t=>t.tools),cost:sum(a,t=>t.cost),think:sum(a,t=>t.think_s),thinkTok:sum(a,t=>t.think_tok),
|
|
614
|
+
out:sum(a,t=>t.out_tok),inp:sum(a,t=>t.in_tok),cr:sum(a,t=>t.cr_tok),cc:sum(a,t=>t.cc_tok)});
|
|
615
|
+
function toolAgg(a){const m=Object.create(null);a.forEach(t=>Object.entries(t.tool_stats).forEach(([k,v])=>{const x=m[k]||(m[k]=[k,0,0,0,0,0]);x[1]+=v[0];x[2]+=v[1];x[3]+=v[2];x[4]=Math.max(x[4],v[3]);x[5]+=v[4]}));return Object.values(m)}
|
|
616
|
+
function modelAgg(a){const m=Object.create(null);a.forEach(t=>Object.entries(t.models).forEach(([k,v])=>{const x=m[k]||(m[k]=[k,0,0,0,0,0,0,0,0,0]);for(let i=0;i<9;i++)x[i+1]+=v[i]}));return Object.values(m)}
|
|
617
|
+
|
|
618
|
+
function overview(){const a=A(F),sess=new Set(F.map(t=>t.sid)).size,cacheHit=a.cr/Math.max(1,a.cr+a.inp+a.cc);
|
|
619
|
+
const days={};F.forEach(t=>days[date(t.start)]=(days[date(t.start)]||0)+t.cost);const dk=Object.keys(days).sort(),mx=Math.max(...Object.values(days),.01);
|
|
620
|
+
const dist=(h,f,fmt)=>`<tr><td>${h}</td><td class="n">${fmt(Q(F.map(f),.5))}</td><td class="n">${fmt(Q(F.map(f),.9))}</td><td class="n">${fmt(Q(F.map(f),1))}</td></tr>`;
|
|
621
|
+
const byP=Object.create(null);F.forEach(t=>(byP[t.proj]=byP[t.proj]||[]).push(t));
|
|
622
|
+
return `<div class="grid">${kpi('Tasks',F.length,sess+' sessions')}${kpi('API turns',N(a.calls),N(a.sub)+' by subagents')}${kpi('Tool calls',N(a.tools))}
|
|
623
|
+
${kpi('Wall time',T(a.wall),'sum over tasks')}${kpi('Model time',T(a.model),P(a.model/a.wall)+' of wall')}${kpi('Thinking time',T(a.think),P(a.think/Math.max(1,a.model))+' of model time')}
|
|
624
|
+
${kpi('Est. cost',$$(a.cost),$$(a.cost/F.length)+' / task')}${kpi('Output tokens',N(a.out),'thinking '+N(a.thinkTok)+' (where reported)')}${kpi('Cache hit',P(cacheHit),'of input-side tokens')}</div>
|
|
625
|
+
<div class="card"><h2>Where the wall-clock time goes</h2>${bar({wall:a.wall,model_s:a.model,tool_s:a.tool,agent_s:a.agent,user_s:a.user,wait_s:a.wait})}<div style="height:8px"></div>${legend}
|
|
626
|
+
<div class="s" style="margin-top:6px">model ${T(a.model)} (${P(a.model/a.wall)}) · tools ${T(a.tool)} (${P(a.tool/a.wall)}) · subagent wait ${T(a.agent)} · user prompts ${T(a.user)} · idle ${T(a.wait)} (${P(a.wait/a.wall)})</div></div>
|
|
627
|
+
<div class="cols"><div class="card"><h2>Per-task distribution</h2><table><tr><th></th><th class="n">median</th><th class="n">p90</th><th class="n">max</th></tr>
|
|
628
|
+
${dist('API turns',t=>t.calls,N)}${dist('Tool calls',t=>t.tools,N)}${dist('Wall time',t=>t.wall,T)}${dist('Thinking time',t=>t.think_s,T)}${dist('Cost',t=>t.cost,$$)}${dist('Peak context',t=>t.ctx_max,N)}</table></div>
|
|
629
|
+
<div class="card"><h2>Cost per day</h2><div class="days">${dk.map(d=>`<div title="${d}: ${$$(days[d])}" style="height:${days[d]/mx*100}%"></div>`).join('')}</div><div class="axis"><span>${dk[0]}</span><span>${dk.at(-1)}</span></div></div></div>
|
|
630
|
+
<div class="cols"><div class="card"><h2>Cost by project</h2>${tbl('proj',[{h:'Project',f:r=>esc(r[0]),v:r=>r[0]},{h:'Tasks',n:1,f:r=>r[1],v:r=>r[1]},{h:'Wall',n:1,f:r=>T(r[3]),v:r=>r[3]},{h:'Cost',n:1,d:1,f:r=>$$(r[2]),v:r=>r[2]}],Object.entries(byP).map(([k,v])=>[k,v.length,sum(v,t=>t.cost),sum(v,t=>t.wall)]),15)}</div>
|
|
631
|
+
<div class="card"><h2>Token cost mix</h2>${costMix()}</div></div>`}
|
|
632
|
+
function costMix(){const r=[0,1,2,3].map(i=>sum(F,t=>t.cost_mix[i])),tot=sum(r,x=>x)||1; // [fresh in, out, cache read, cache write] $
|
|
633
|
+
return [['Output',r[1]],['Cache read',r[2]],['Cache write',r[3]],['Fresh input',r[0]]].map(([k,v])=>`<div style="display:flex;gap:10px;align-items:center;margin:6px 0"><span style="width:90px">${k}</span><div class="hb" style="width:${v/tot*60}%"></div><span class="s">${P(v/tot)}</span></div>`).join('')}
|
|
634
|
+
|
|
635
|
+
function tasksView(){return `<div class="card">${legend}<div style="height:8px"></div>`+tbl('tasks',[
|
|
636
|
+
{h:'Date',f:t=>date(t.start),v:t=>t.start,d:1},{h:'Project',f:t=>esc(t.proj),v:t=>t.proj},{h:'Task',f:t=>`<div class="pr" title="${esc(t.prompt)}">${esc(t.prompt)}</div>`,v:t=>t.prompt},
|
|
637
|
+
{h:'Wall',n:1,f:t=>T(t.wall),v:t=>t.wall},{h:'<span class="tip" title="API round-trips by the main agent">Turns</span>',n:1,f:t=>t.calls+(t.sub_calls?` <span class="s">+${t.sub_calls}</span>`:''),v:t=>t.calls},
|
|
638
|
+
{h:'Tools',n:1,f:t=>t.tools,v:t=>t.tools},{h:'Think',n:1,f:t=>T(t.think_s),v:t=>t.think_s},{h:'Think tok',n:1,f:t=>N(t.think_tok),v:t=>t.think_tok},
|
|
639
|
+
{h:'Time split',f:bar,v:t=>t.wait_s/t.wall},{h:'Peak ctx',n:1,f:t=>N(t.ctx_max),v:t=>t.ctx_max},{h:'Cost',n:1,f:t=>$$(t.cost)+(t.est?'*':''),v:t=>t.cost}],F,300,t=>t.id)+'</div>'}
|
|
640
|
+
|
|
641
|
+
function toolsView(){const rows=toolAgg(F),tot=sum(rows,r=>r[3])||1,mx=Math.max(...rows.map(r=>r[3]),1);
|
|
642
|
+
const slow=F.flatMap(t=>t.slow.map(s=>[...s,t])).sort((a,b)=>b[1]-a[1]);
|
|
643
|
+
return `<div class="card"><h2>Tools by total time</h2>`+tbl('tools',[{h:'Tool',f:r=>esc(r[0]),v:r=>r[0]},{h:'Calls',n:1,f:r=>N(r[1]),v:r=>r[1]},{h:'Errors',n:1,f:r=>r[2]?`${r[2]} <span class="s">(${P(r[2]/r[1])})</span>`:'–',v:r=>r[2]/r[1]},
|
|
644
|
+
{h:'Total time',n:1,d:1,f:r=>T(r[3]),v:r=>r[3]},{h:'',f:r=>`<div class="hb t" style="width:${r[3]/mx*100}%"></div>`,v:r=>r[3]},{h:'Avg',n:1,f:r=>(r[3]/r[1]).toFixed(1)+'s',v:r=>r[3]/r[1]},{h:'Max',n:1,f:r=>T(r[4]),v:r=>r[4]},{h:'<span class="tip" title="calls the user rejected or interrupted; not counted as errors">Rejected</span>',n:1,f:r=>r[5]||'–',v:r=>r[5]},{h:'% of tool time',n:1,f:r=>P(r[3]/tot),v:r=>r[3]}],rows,60)+
|
|
645
|
+
`<div class="s">Tool duration = tool_use → tool_result timestamps, so it includes time spent waiting on permission prompts. Subagent (Task/Agent) calls are counted here but painted separately in the time split.</div></div>
|
|
646
|
+
<div class="card"><h2>Slowest individual calls</h2>`+tbl('slow',[{h:'Tool',f:r=>esc(r[0]),v:r=>r[0]},{h:'Duration',n:1,d:1,f:r=>T(r[1]),v:r=>r[1]},{h:'Input',f:r=>`<div class="pr">${esc(r[2])}</div>`,v:r=>r[2]},{h:'Err',f:r=>r[3]?'✗':'',v:r=>r[3]},{h:'Task',f:r=>`<span class="chip" data-task="${esc(r[4].id)}">${esc(r[4].prompt.slice(0,50))}</span>`,v:r=>r[4].prompt}],slow,25)+'</div>'}
|
|
647
|
+
|
|
648
|
+
function modelsView(){const rows=modelAgg(F),a=A(F);
|
|
649
|
+
return `<div class="card"><h2>Models</h2>`+tbl('models',[{h:'Model',f:r=>esc(sn(r[0])),v:r=>r[0]},{h:'Calls',n:1,f:r=>N(r[1]),v:r=>r[1]},{h:'Cost',n:1,d:1,f:r=>$$(r[2]),v:r=>r[2]},{h:'Output tok',n:1,f:r=>N(r[3]),v:r=>r[3]},
|
|
650
|
+
{h:'Thinking tok',n:1,f:r=>N(r[4]),v:r=>r[4]},{h:'Avg call',n:1,f:r=>(r[5]/r[1]).toFixed(1)+'s',v:r=>r[5]/r[1]},{h:'Out tok/s',n:1,f:r=>r[5]?(r[3]/r[5]).toFixed(0):'–',v:r=>r[3]/r[5]},
|
|
651
|
+
{h:'Thinking time',n:1,f:r=>T(r[6]),v:r=>r[6]},{h:'Think / call time',n:1,f:r=>P(r[6]/Math.max(.1,r[5])),v:r=>r[6]/r[5]},{h:'Fresh in',n:1,f:r=>N(r[7]),v:r=>r[7]},{h:'Cache read',n:1,f:r=>N(r[8]),v:r=>r[8]},{h:'Cache write',n:1,f:r=>N(r[9]),v:r=>r[9]}],rows)+'</div>'+
|
|
652
|
+
`<div class="card"><h2>Thinking</h2><div class="grid" style="margin:0">${kpi('Thinking time',T(a.think),P(a.think/Math.max(1,a.model))+' of model time')}${kpi('Thinking tokens',N(a.thinkTok),P(a.thinkTok/Math.max(1,a.out))+' of output (where reported)')}
|
|
653
|
+
${kpi('Calls with thinking',N(sum(F,t=>t.think_calls)),P(sum(F,t=>t.think_calls)/Math.max(1,a.calls+a.sub))+' of calls')}</div>
|
|
654
|
+
<div class="s" style="margin-top:8px">Thinking text is redacted in the transcripts, so thinking time is measured as the gap between a request and its thinking block being written; tokens come from <code>usage.output_tokens_details.thinking_tokens</code> when the log has it.</div>
|
|
655
|
+
<h2 style="margin-top:14px">Most thinking-heavy tasks</h2>`+tbl('think',[{h:'Task',f:t=>`<div class="pr">${esc(t.prompt)}</div>`,v:t=>t.prompt},{h:'Think time',n:1,d:1,f:t=>T(t.think_s),v:t=>t.think_s},{h:'% of model time',n:1,f:t=>P(t.think_s/Math.max(1,t.model_s)),v:t=>t.think_s/Math.max(1,t.model_s)},{h:'Think tok',n:1,f:t=>N(t.think_tok),v:t=>t.think_tok},{h:'Wall',n:1,f:t=>T(t.wall),v:t=>t.wall}],F,15,t=>t.id)+'</div>'}
|
|
656
|
+
|
|
657
|
+
function bottlenecks(){const a=A(F),out=[],ids=(l,n=6)=>l.slice(0,n).map(t=>t.id),top=(f,n=5)=>[...F].sort((x,y)=>f(y)-f(x)).slice(0,n);
|
|
658
|
+
const cats=[['model inference',a.model],['tool execution',a.tool],['subagent waits',a.agent],['idle / waiting',a.wait]].sort((x,y)=>y[1]-x[1]);
|
|
659
|
+
out.push(['lo',`Biggest time sink: ${cats[0][0]} (${P(cats[0][1]/a.wall)} of wall time)`,`Breakdown: ${cats.map(c=>c[0]+' '+P(c[1]/a.wall)).join(' · ')}.`,[]]);
|
|
660
|
+
const idle=F.filter(t=>t.wall>60&&t.wait_s/t.wall>.4);if(idle.length)out.push(['hi',`${idle.length} tasks were mostly idle (>40% of time with no model or tool activity)`,`Typically permission prompts, a person reading, background jobs, or hook/startup latency. Total idle ${T(sum(idle,t=>t.wait_s))}. Worst offenders:`,ids(top(t=>t.wait_s).filter(t=>idle.includes(t)))]);
|
|
661
|
+
const tr=toolAgg(F).sort((x,y)=>y[3]-x[3]);if(tr.length)out.push(['',`Slowest tool category: ${tr[0][0]} — ${T(tr[0][3])} total (${P(tr[0][3]/Math.max(1,a.tool+a.agent))} of tool time)`,`Avg ${(tr[0][3]/tr[0][1]).toFixed(1)}s over ${tr[0][1]} calls, max ${T(tr[0][4])}. Next: ${tr.slice(1,4).map(r=>r[0]+' '+T(r[3])).join(', ')}.`,[]]);
|
|
662
|
+
const bad=toolAgg(F).filter(r=>r[1]>=8&&r[2]/r[1]>.15).sort((x,y)=>y[2]/y[1]-x[2]/x[1]);if(bad.length)out.push(['hi','Tools with high failure rates (retry churn)',bad.slice(0,5).map(r=>`${r[0]}: ${r[2]}/${r[1]} failed (${P(r[2]/r[1])})`).join(' · ')+'. Each failure usually costs another full model round-trip.',ids(top(t=>sum(Object.values(t.tool_stats),v=>v[1]))) ]);
|
|
663
|
+
const big=F.filter(t=>t.ctx_max>400000);if(big.length)out.push(['hi',`${big.length} tasks grew context past 400k tokens`,`Every extra turn re-reads the whole context (cache reads) — these tasks cost ${$$(sum(big,t=>t.cost))} (${P(sum(big,t=>t.cost)/a.cost)} of spend). Compact or split them earlier.`,ids(top(t=>t.ctx_max).filter(t=>big.includes(t)))]);
|
|
664
|
+
const th=F.filter(t=>t.model_s>30&&t.think_s/t.model_s>.35);if(th.length)out.push(['',`${th.length} tasks spent >35% of model time thinking`,`Total ${T(sum(th,t=>t.think_s))}. Lowering effort level or giving more precise prompts would shorten these.`,ids(top(t=>t.think_s).filter(t=>th.includes(t)))]);
|
|
665
|
+
const many=top(t=>t.calls);out.push(['',`Most turns for a single task: ${many[0].calls} API round-trips`,`Median task takes ${Q(F.map(t=>t.calls),.5)} turns, p90 ${Q(F.map(t=>t.calls),.9)}. Long tails usually mean exploration loops or rework.`,ids(many)]);
|
|
666
|
+
const cmp=sum(F,t=>t.compacts.length);if(cmp)out.push(['',`${cmp} context compactions`,`Total compaction time ${T(sum(F,t=>sum(t.compacts,c=>c[1])))}; each one discards history the agent may need to re-read.`,ids(F.filter(t=>t.compacts.length))]);
|
|
667
|
+
const sorted=[...F].sort((x,y)=>y.cost-x.cost),k=Math.max(1,Math.ceil(F.length*.05)),topc=sum(sorted.slice(0,k),t=>t.cost);
|
|
668
|
+
out.push(['',`Cost is concentrated: top ${k} task${k>1?'s':''} (5%) = ${P(topc/a.cost)} of spend`,`${$$(topc)} of ${$$(a.cost)}.`,ids(sorted)]);
|
|
669
|
+
const slow=top(t=>t.wall);out.push(['',`Longest tasks by wall time`,'',ids(slow)]);
|
|
670
|
+
return `<div class="card"><h2>Automatic findings</h2>`+out.map(([c,h,b,l])=>`<div class="find ${c}"><b>${esc(h)}</b>${esc(b)}<div>${l.map(id=>{const t=D.tasks.find(x=>x.id==id);return `<span class="chip" data-task="${esc(id)}">${esc(t.prompt.slice(0,45))} · ${T(t.wall)} · ${$$(t.cost)}</span>`}).join('')}</div></div>`).join('')+'</div>'}
|
|
671
|
+
|
|
672
|
+
function detail(id){const t=D.tasks.find(x=>x.id==id),w=t.wall,pc=x=>(x/w*100).toFixed(3)+'%';
|
|
673
|
+
const lanes=['model','tools','subagent'].map((n,i)=>`<div class="lane"><label>${n}</label>`+t.segs.filter(s=>i==0?s[0]=='m':i==1?(s[0]=='t'||s[0]=='te'):(s[0]=='ms'||s[0]=='ts')).map(s=>{
|
|
674
|
+
const col=s[0]=='te'?'e':(s[0]=='m'||s[0]=='ms')?'m':s[0]=='ts'?'a':'t',extra=s[0][0]=='m'?`${esc(sn(s[3]))} · ${s[5]} out tok · think ${T(s[4])}`:esc(s[3]+': '+s[4]);
|
|
675
|
+
const th=s[0][0]=='m'&&s[4]>0?`<span class="th" style="left:${pc(s[1])};width:${pc(Math.min(s[4],s[2]))}" title="thinking ${T(s[4])}"></span>`:'';
|
|
676
|
+
return `<span class="${col}" style="left:${pc(s[1])};width:${pc(s[2])}" title="${T(s[2])} — ${extra}"></span>${th}`}).join('')+'</div>').join('');
|
|
677
|
+
const ctxMax=Math.max(...t.ctx.map(c=>c[1]),1),svg=t.ctx.length>1?`<svg viewBox="0 0 400 60" width="100%" height="60" preserveAspectRatio="none"><polyline fill="none" stroke="var(--model)" stroke-width="1.5" points="${t.ctx.map(c=>`${c[0]/w*400},${58-c[1]/ctxMax*54}`).join(' ')}"/></svg>`:'';
|
|
678
|
+
const trows=Object.entries(t.tool_stats).map(([k,v])=>[k,...v]);
|
|
679
|
+
return `<h2 style="font-size:16px">${esc(t.title||'Task')} <span class="s">· ${esc(t.proj)} · ${new Date(t.start*1000).toLocaleString()} · ${esc(t.id)}</span></h2><div class="pre">${esc(t.prompt)}</div>
|
|
680
|
+
<div class="grid" style="margin:12px 0">${kpi('Wall',T(w))}${kpi('Turns',t.calls,t.sub_calls+' subagent calls')}${kpi('Tool calls',t.tools)}${kpi('Thinking',T(t.think_s),N(t.think_tok)+' tok')}${kpi('Cost',$$(t.cost)+(t.est?'*':''),'subagents '+$$(t.sub_cost))}${kpi('Peak context',N(t.ctx_max),t.compacts.length+' compactions')}</div>
|
|
681
|
+
${bar(t)}<div style="height:6px"></div>${legend}<h2 style="margin-top:14px">Timeline <span class="s">(purple = thinking portion of a model call, red = failed tool)</span></h2><div class="gantt">${lanes}</div><div class="axis">${[0,.25,.5,.75,1].map(f=>`<span>${T(w*f)}</span>`).join('')}</div>
|
|
682
|
+
<div class="cols" style="margin-top:14px"><div><h2>Tools in this task</h2>${tbl('dt',[{h:'Tool',f:r=>esc(r[0]),v:r=>r[0]},{h:'Calls',n:1,f:r=>r[1],v:r=>r[1]},{h:'Err',n:1,f:r=>r[2],v:r=>r[2]},{h:'Total',n:1,d:1,f:r=>T(r[3]),v:r=>r[3]},{h:'Max',n:1,f:r=>T(r[4]),v:r=>r[4]}],trows,20)}</div>
|
|
683
|
+
<div><h2>Tool calls, slowest first</h2>${tbl('ds',[{h:'Tool',f:r=>esc(r[0]),v:r=>r[0]},{h:'Dur',n:1,f:r=>T(r[1]),v:r=>r[1]},{h:'Input',f:r=>`<div class="pr" style="max-width:300px">${esc(r[2])}</div>`,v:r=>r[2]}],t.slow,10)}
|
|
684
|
+
<h2 style="margin-top:12px">Context size per turn</h2>${svg}<div class="s">peak ${N(t.ctx_max)} tokens</div></div></div>`}
|
|
685
|
+
|
|
686
|
+
const views=[overview,tasksView,toolsView,modelsView,bottlenecks];
|
|
687
|
+
function render(){filt();document.querySelectorAll('#nav button').forEach((b,i)=>b.classList.toggle('on',i==tab));
|
|
688
|
+
$('#view').innerHTML=F.length?views[tab]():'<div class="card">No tasks match.</div>'}
|
|
689
|
+
const pl=(n,w)=>`${N(n)} ${w}${n==1?'':'s'}`;
|
|
690
|
+
const rates=Object.entries(D.price).sort(([a],[b])=>a.localeCompare(b)).map(([k,p])=>`<tr><td><code>${esc(k)}</code></td>${p.map(x=>`<td class="n">${x}</td>`).join('')}</tr>`).join('');
|
|
691
|
+
$('#foot').innerHTML=`<div class="meta">Generated ${esc(new Date(D.generated).toLocaleString())} from <code>${esc(D.root)}</code> · ${pl(D.files,'file')} · ${pl(D.sessions.length,'session')} · Developed by <b>Infenia Private Limited</b> under the MIT License</div>
|
|
692
|
+
<div class="notes">
|
|
693
|
+
<div><b>Definitions</b>A <b>task</b> is one human prompt through the last activity before the next prompt; a <b>turn</b> is one model API call. Subagent transcripts count toward the task that was running when they started.</div>
|
|
694
|
+
<div><b>Time split</b>Interval union (model › tools › subagent › user tools), so parallel work is not double counted. <i>Idle</i> is time with no model or tool activity.</div>
|
|
695
|
+
<div><b>Costs</b>Estimates: token usage × the rates below; <code>*</code> marks tasks that include models with unknown pricing. On real transcripts they land at a median ~95% of the CLI's own reported total. Advisor sub-calls and fast mode are priced; server-tool fees (e.g. web search) are not.</div>
|
|
696
|
+
</div>
|
|
697
|
+
<details><summary>Rates used ($ per million tokens; the longest key contained in the model name wins)</summary>
|
|
698
|
+
<table><tr><th>Model key</th><th class="n">Input</th><th class="n">Output</th><th class="n">Cache read</th><th class="n">Cache write 5m</th><th class="n">Cache write 1h</th></tr>${rates}</table></details>`;
|
|
699
|
+
document.addEventListener('click',e=>{const el=e.target.closest('[data-t],[data-s],[data-task],[data-more]');if(!el)return;
|
|
700
|
+
const modal=()=>{if(cur&&$('#modal').style.display=='block')$('#mbody').innerHTML=detail(cur)};
|
|
701
|
+
if(el.dataset.more){const[id,step]=el.dataset.more.split('|');shown[id]=step=='all'?Infinity:(shown[id]||+step)+ +step;render();modal();return}
|
|
702
|
+
if(el.dataset.t!=null){tab=+el.dataset.t;render()}else if(el.dataset.s){const[id,i]=el.dataset.s.split('|'),s=sorts[id];s.d=s.i==+i?-s.d:-1;s.i=+i;render();modal()}
|
|
703
|
+
else{cur=el.dataset.task;delete shown.dt;delete shown.ds;$('#mbody').innerHTML=detail(cur);$('#modal').style.display='block'}});
|
|
704
|
+
$('#x').onclick=()=>$('#modal').style.display='none';$('#modal').onclick=e=>{if(e.target.id=='modal')e.target.style.display='none'};
|
|
705
|
+
$('#fp').onchange=$('#fq').oninput=()=>{for(const k in shown)delete shown[k];render()};render(); // a new filter starts tables at page 1
|
|
706
|
+
</script></body></html>
|
|
707
|
+
'''
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
if __name__ == '__main__':
|
|
711
|
+
main()
|