quotopsy 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +85 -0
- package/package.json +35 -0
- package/quotopsy.mjs +651 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Impakt
|
|
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.
|
package/README.md
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# Quotopsy
|
|
2
|
+
|
|
3
|
+
Explains where your Claude Code usage went, weighted by token type.
|
|
4
|
+
|
|
5
|
+
It reads the session logs Claude Code already keeps on your machine and
|
|
6
|
+
estimates which part of the context each token belongs to: tool schemas,
|
|
7
|
+
system prompt, MCP server instructions, tool results, history, output. It then
|
|
8
|
+
lists findings the totals do not show, such as cache rebuilds after idle gaps.
|
|
9
|
+
|
|
10
|
+
- Runs locally. No network calls, no LLM calls, no dependencies.
|
|
11
|
+
- One readable file: [quotopsy.mjs](quotopsy.mjs).
|
|
12
|
+
- Every attribution is an estimate and is labeled as one.
|
|
13
|
+
|
|
14
|
+
## Run it
|
|
15
|
+
|
|
16
|
+
Needs Node 18 or newer.
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npx quotopsy
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Or download [quotopsy.mjs](quotopsy.mjs), read it, and run it directly:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
node quotopsy.mjs
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
| Option | Effect |
|
|
29
|
+
| --- | --- |
|
|
30
|
+
| `[logs-dir]` | Logs to read. Defaults to `~/.claude/projects`. |
|
|
31
|
+
| `--json` | Print the summary as JSON instead of a report. |
|
|
32
|
+
| `--redact` | Replace MCP server names, MCP tool names and prompt section headings with placeholders. |
|
|
33
|
+
| `--debug` | With `--json`, add structure-only diagnostics for requests the tool handled badly: record types, block types, sizes and counts. No message text. |
|
|
34
|
+
|
|
35
|
+
To enforce "read these logs, no network" instead of trusting the code:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
deno run --allow-read=$HOME/.claude --deny-net quotopsy.mjs $HOME/.claude/projects
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The logs path is passed explicitly so the script needs no permission beyond
|
|
42
|
+
reading that folder. Under these flags Deno refuses any network call and any
|
|
43
|
+
read outside `~/.claude`.
|
|
44
|
+
|
|
45
|
+
## Sharing a summary
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
npx quotopsy --json --redact --debug > summary.json
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The file holds token totals, counts, timestamps and model names. It holds no
|
|
52
|
+
prompts, code, file paths or tool output. Its first field, `contains`, states
|
|
53
|
+
this and whether names were redacted.
|
|
54
|
+
|
|
55
|
+
## Tests
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
npm test
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
The tests build synthetic session logs and never read real ones. When a new
|
|
62
|
+
kind of log turns up, add a case that reproduces its structure, not its
|
|
63
|
+
content.
|
|
64
|
+
|
|
65
|
+
## Limits
|
|
66
|
+
|
|
67
|
+
- Sees only sessions that ran on this machine. Cloud sessions count against
|
|
68
|
+
the same plan limits but leave no logs here, so they are not in the report.
|
|
69
|
+
- Needs Claude Code 2.1.300 or newer for attribution. Older versions do not
|
|
70
|
+
log the request records it relies on, so their usage shows as
|
|
71
|
+
"unattributed"; cache rebuilds after idle gaps are still detected from
|
|
72
|
+
usage alone. Checked on logs from 2.1.300 through 2.1.322.
|
|
73
|
+
- Usage is weighted at published API prices per model (checked 2026-11-10), so
|
|
74
|
+
shares are shares of API-equivalent cost. How weekly limits weight token
|
|
75
|
+
types and models is not published.
|
|
76
|
+
- A model missing from the price table is priced like the newest listed model
|
|
77
|
+
of its family, and the report says so.
|
|
78
|
+
- A message the log does not contain is shown as "unresolved messages" and
|
|
79
|
+
sized from the usual characters-per-token ratio.
|
|
80
|
+
- Prompt text is split at headings and standalone `<tag>` blocks. Text that
|
|
81
|
+
follows a heading with no later heading or tag is credited to that heading.
|
|
82
|
+
|
|
83
|
+
## License
|
|
84
|
+
|
|
85
|
+
[MIT](LICENSE)
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "quotopsy",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Explains where your Claude Code usage went, weighted by token type. Local only: no network, no LLM calls.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"claude-code",
|
|
7
|
+
"claude",
|
|
8
|
+
"usage",
|
|
9
|
+
"tokens",
|
|
10
|
+
"prompt-cache",
|
|
11
|
+
"cli"
|
|
12
|
+
],
|
|
13
|
+
"type": "module",
|
|
14
|
+
"scripts": {
|
|
15
|
+
"test": "node --test test/*.test.mjs"
|
|
16
|
+
},
|
|
17
|
+
"bin": {
|
|
18
|
+
"quotopsy": "quotopsy.mjs"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"quotopsy.mjs"
|
|
22
|
+
],
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=18"
|
|
25
|
+
},
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"homepage": "https://quotopsy.com",
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "git+https://github.com/Impakt-Dot-Dev/quotopsy.git"
|
|
31
|
+
},
|
|
32
|
+
"bugs": {
|
|
33
|
+
"url": "https://github.com/Impakt-Dot-Dev/quotopsy/issues"
|
|
34
|
+
}
|
|
35
|
+
}
|
package/quotopsy.mjs
ADDED
|
@@ -0,0 +1,651 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Quotopsy: reads Claude Code session logs locally and estimates which part of the
|
|
3
|
+
// context each weighted token belongs to. No network, no LLM calls, no deps.
|
|
4
|
+
//
|
|
5
|
+
// node quotopsy.mjs [logs-dir] [--json] [--redact] [--debug]
|
|
6
|
+
//
|
|
7
|
+
// --redact replaces MCP server names, MCP tool names and prompt section
|
|
8
|
+
// headings with numbered placeholders, for output that will be shared.
|
|
9
|
+
// --debug adds structure-only diagnostics to the JSON (record types, block
|
|
10
|
+
// types, sizes and counts for requests the tool handled badly). No text.
|
|
11
|
+
//
|
|
12
|
+
// Uses only node: builtins, so it also runs under
|
|
13
|
+
// deno run --allow-read=$HOME/.claude --deny-net quotopsy.mjs $HOME/.claude/projects
|
|
14
|
+
// (the path is explicit because looking up the home directory needs a
|
|
15
|
+
// further Deno permission)
|
|
16
|
+
import { createHash } from "node:crypto";
|
|
17
|
+
import { readdirSync, readFileSync, realpathSync } from "node:fs";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
import { homedir } from "node:os";
|
|
20
|
+
import process from "node:process";
|
|
21
|
+
import { fileURLToPath } from "node:url";
|
|
22
|
+
|
|
23
|
+
// HYPOTHESIS: usage is weighted at published API prices, so one unit is one
|
|
24
|
+
// API-equivalent dollar. Whether weekly limits weight token types and models
|
|
25
|
+
// the same way is unpublished.
|
|
26
|
+
// Per model: [input $/MTok, cache read $/MTok]. Output is 5x input, cache
|
|
27
|
+
// writes are 1.25x (5 min) and 2x (1 h) input. From Anthropic's pricing page,
|
|
28
|
+
// checked 2026-11-10.
|
|
29
|
+
const PRICES = {
|
|
30
|
+
"claude-fable-5-2": [8, 0.2], "claude-mythos-5-2": [8, 0.2], "claude-fable-5-1": [10, 0.25], "claude-mythos-5-1": [10, 0.25], "claude-fable-5": [10, 1], "claude-mythos-5": [10, 1],
|
|
31
|
+
"claude-opus-5-5": [4, 0.2], "claude-opus-5": [5, 0.5], "claude-opus-4-8": [5, 0.5], "claude-opus-4-7": [5, 0.5], "claude-opus-4-6": [5, 0.5], "claude-opus-4-5": [5, 0.5],
|
|
32
|
+
"claude-opus-4-1": [15, 1.5], "claude-opus-4": [15, 1.5],
|
|
33
|
+
"claude-sonnet-5-5": [2, 0.1], "claude-sonnet-5": [2, 0.2], "claude-sonnet-4-6": [3, 0.3], "claude-sonnet-4-5": [3, 0.3], "claude-sonnet-4": [3, 0.3],
|
|
34
|
+
"claude-haiku-5-5": [0.1, 0.01], "claude-haiku-4-5": [1, 0.1],
|
|
35
|
+
};
|
|
36
|
+
// Models missing from the table are priced like the newest listed model of
|
|
37
|
+
// the same family, and reported so the reader knows.
|
|
38
|
+
const FAMILY = { fable: "claude-fable-5-2", mythos: "claude-mythos-5-2", opus: "claude-opus-5-5", sonnet: "claude-sonnet-5-5", haiku: "claude-haiku-5-5" };
|
|
39
|
+
const MCP = "MCP server: ";
|
|
40
|
+
const isMcp = (name) => name.startsWith("mcp__");
|
|
41
|
+
const count = (map, key) => map.set(key, (map.get(key) ?? 0) + 1);
|
|
42
|
+
|
|
43
|
+
function findJsonl(dir) {
|
|
44
|
+
const out = [];
|
|
45
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
46
|
+
const p = join(dir, e.name);
|
|
47
|
+
if (e.isDirectory()) out.push(...findJsonl(p));
|
|
48
|
+
else if (e.name.endsWith(".jsonl")) out.push(p);
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Base64 image and document payloads are not tokenized as text. ESTIMATE:
|
|
54
|
+
// drop the payload and count each as roughly 1,500 tokens' worth of chars.
|
|
55
|
+
const MEDIA_CHARS = 4500;
|
|
56
|
+
function blockChars(b) {
|
|
57
|
+
let media = 0;
|
|
58
|
+
const text = JSON.stringify(b, (k, v) => (k === "data" && typeof v === "string" && v.length > 1000 ? (media++, "") : v));
|
|
59
|
+
return text.length + media * MEDIA_CHARS;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Blob hashes are SHA-256 of the message as compact JSON with sorted keys.
|
|
63
|
+
const sorted = (v) => Array.isArray(v) ? v.map(sorted) : v && typeof v === "object" ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, sorted(v[k])])) : v;
|
|
64
|
+
export const hashMessage = (m) => createHash("sha256").update(JSON.stringify(sorted(m))).digest("hex");
|
|
65
|
+
|
|
66
|
+
// The public model name inside a logged model id. Drops date suffixes and
|
|
67
|
+
// provider wrappers such as Bedrock ARNs, which can carry an account number.
|
|
68
|
+
const MODEL_ID = /claude-(?:fable|mythos|opus|sonnet|haiku)-\d+(?:-\d{1,2})?(?!\d)/;
|
|
69
|
+
|
|
70
|
+
function splitUsage(u) {
|
|
71
|
+
const write1h = u.cache_creation?.ephemeral_1h_input_tokens ?? 0;
|
|
72
|
+
// Older logs only have the total; treat what is not one-hour as five-minute.
|
|
73
|
+
const write5m = u.cache_creation?.ephemeral_5m_input_tokens ?? Math.max(0, (u.cache_creation_input_tokens ?? 0) - write1h);
|
|
74
|
+
const write = write1h + write5m;
|
|
75
|
+
const output = u.output_tokens ?? 0;
|
|
76
|
+
const thinking = Math.min(output, u.output_tokens_details?.thinking_tokens ?? 0);
|
|
77
|
+
return { fresh: u.input_tokens ?? 0, read: u.cache_read_input_tokens ?? 0, write, write1h, write5m, output, thinking };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Analyse every session log under `root` and return the summary object.
|
|
81
|
+
// `onProgress(done, total)` is called before each file is read.
|
|
82
|
+
export function analyze(root, { redact = false, debug: debugOn = false, onProgress = () => {} } = {}) {
|
|
83
|
+
const assumedPricing = new Map(); // model -> model whose prices were used
|
|
84
|
+
const modelName = (model) => MODEL_ID.exec(model ?? "")?.[0] ?? (model ? alias("model", String(model)) : "unknown");
|
|
85
|
+
function rates(model, usage = {}) {
|
|
86
|
+
const id = modelName(model);
|
|
87
|
+
let key = id in PRICES ? id : null;
|
|
88
|
+
if (!key) {
|
|
89
|
+
key = FAMILY[Object.keys(FAMILY).find((f) => String(model ?? "").includes(f))] ?? "claude-opus-5-5";
|
|
90
|
+
assumedPricing.set(id, key);
|
|
91
|
+
}
|
|
92
|
+
const [input, read] = PRICES[key];
|
|
93
|
+
let per = 1e-6;
|
|
94
|
+
if (usage.speed === "fast") per *= 2; // fast mode bills at 2x
|
|
95
|
+
if (usage.inference_geo === "us") per *= 1.1; // US-only inference surcharge
|
|
96
|
+
// Haiku 5.5 bills 5x on requests whose prompt is over 100,000 tokens.
|
|
97
|
+
const prompt = (usage.input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0);
|
|
98
|
+
if (key === "claude-haiku-5-5" && prompt > 100000) per *= 5;
|
|
99
|
+
return { fresh: input * per, write5m: 1.25 * input * per, write1h: 2 * input * per, read: read * per, output: 5 * input * per };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const mcpServers = new Set(); // servers the log says injected instructions
|
|
103
|
+
// Same name -> same placeholder, numbered per kind in order of appearance.
|
|
104
|
+
const aliases = new Map(), aliasCounts = new Map();
|
|
105
|
+
const PUBLIC_HEADINGS = new Set(["Environment", "Environment update"]); // written by Claude Code itself
|
|
106
|
+
function alias(kind, name) {
|
|
107
|
+
if (!redact || PUBLIC_HEADINGS.has(name)) return name;
|
|
108
|
+
const key = kind + "\0" + name;
|
|
109
|
+
if (!aliases.has(key)) {
|
|
110
|
+
aliasCounts.set(kind, (aliasCounts.get(kind) ?? 0) + 1);
|
|
111
|
+
aliases.set(key, `${kind} ${aliasCounts.get(kind)}`);
|
|
112
|
+
}
|
|
113
|
+
return aliases.get(key);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function toolGroup(name) {
|
|
117
|
+
return isMcp(name) ? MCP + alias("server", name.split("__")[1]) : "built-in tools";
|
|
118
|
+
}
|
|
119
|
+
// Built-in tool names are public; MCP tool names can describe private systems.
|
|
120
|
+
function toolLabel(name) {
|
|
121
|
+
return isMcp(name) ? alias("mcp tool", name) : name;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Split prompt text at markdown headings so a category can be broken down.
|
|
125
|
+
// Only heading text is kept, never the body.
|
|
126
|
+
function sections(text) {
|
|
127
|
+
const out = [];
|
|
128
|
+
let label = "(no heading)", inMcp = false, inFence = false, chars = 0;
|
|
129
|
+
const flush = () => { if (chars) out.push([label, chars]); chars = 0; };
|
|
130
|
+
for (const line of text.split("\n")) {
|
|
131
|
+
// A "# ..." line inside a code fence is a comment, not a heading.
|
|
132
|
+
if (/^\s*(```|~~~)/.test(line)) inFence = !inFence;
|
|
133
|
+
const top = !inFence && /^# (.+)/.exec(line), sub = !inFence && /^## (.+)/.exec(line);
|
|
134
|
+
// A <tag> on its own line opens a block; its closing tag ends the block
|
|
135
|
+
// without handing the text that follows to the previous heading.
|
|
136
|
+
const open = /^<([a-z][\w-]*)>\s*$/.exec(line), close = /^<\/([a-z][\w-]*)>\s*$/.exec(line);
|
|
137
|
+
if (open) { flush(); inMcp = false; label = alias("section", `<${open[1]}>`); }
|
|
138
|
+
else if (close) { chars += line.length + 1; flush(); label = "(no heading)"; continue; }
|
|
139
|
+
else if (top) { flush(); inMcp = top[1].startsWith("MCP Server Instructions"); label = alias("section", top[1].slice(0, 40)); }
|
|
140
|
+
// A server's own instructions may contain "## ..." lines; when the log
|
|
141
|
+
// names the servers, only those start a new one.
|
|
142
|
+
else if (sub && inMcp && (!mcpServers.size || mcpServers.has(sub[1].trim()))) { flush(); label = "MCP instructions: " + alias("server", sub[1].slice(0, 40)); }
|
|
143
|
+
chars += line.length + 1;
|
|
144
|
+
}
|
|
145
|
+
flush();
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// One logged message -> [category, sub, chars] segments, in prompt order.
|
|
150
|
+
function blobSegments(msg, toolNames) {
|
|
151
|
+
const blocks = typeof msg.content === "string" ? [{ type: "text", text: msg.content }] : (Array.isArray(msg.content) ? msg.content : []).filter((b) => b && typeof b === "object");
|
|
152
|
+
return blocks.flatMap((b) => {
|
|
153
|
+
const chars = blockChars(b);
|
|
154
|
+
if (msg.role === "system") return sections(b.text ?? "").map(([s, c]) => ["injected system messages", s, c]);
|
|
155
|
+
if (msg.role === "assistant") return [["assistant history", b.type, chars]];
|
|
156
|
+
if (b.type === "tool_result") return [["tool results", toolLabel(toolNames.get(b.tool_use_id) ?? "(unknown tool)"), chars]];
|
|
157
|
+
return [["user messages", (b.text ?? "").startsWith("<system-reminder>") ? "injected reminders" : "typed or pasted", chars]];
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function parseSession(file) {
|
|
162
|
+
const blobs = new Map(), shapes = new Map(), requests = [], responses = new Map();
|
|
163
|
+
const orphans = new Map(); // assistant messages with no logged request
|
|
164
|
+
const toolNames = new Map(); // tool_use id -> tool name
|
|
165
|
+
const turns = new Map(); // assistant message id -> merged content blocks
|
|
166
|
+
const transcript = []; // messages only logged as transcript records
|
|
167
|
+
let run = []; // user records since the last assistant record
|
|
168
|
+
const recordTypes = new Map();
|
|
169
|
+
for (const line of readFileSync(file, "utf8").split("\n")) {
|
|
170
|
+
if (!line) continue;
|
|
171
|
+
let o;
|
|
172
|
+
try { o = JSON.parse(line); } catch { continue; }
|
|
173
|
+
if (!o || typeof o !== "object") continue;
|
|
174
|
+
count(recordTypes, String(o.type));
|
|
175
|
+
if (o.type === "api-request-blob") { if (o.message && typeof o.message === "object") blobs.set(o.hash, o.message); }
|
|
176
|
+
else if (o.type === "api-request-shape") { if (o.shape && typeof o.shape === "object") shapes.set(o.shapeHash, o.shape); }
|
|
177
|
+
else if (o.type === "api-request") { if (o.id) requests.push(o); }
|
|
178
|
+
else if (o.type === "attachment") {
|
|
179
|
+
// Context Claude Code injects by itself: skill and agent listings,
|
|
180
|
+
// MCP instructions, memory and instruction files. Sizes only.
|
|
181
|
+
const a = attachments.get(o.attachment?.type ?? "unknown") ?? { count: 0, chars: 0 };
|
|
182
|
+
a.count++;
|
|
183
|
+
for (const r of Array.isArray(o.rendered) ? o.rendered : []) a.chars += typeof r?.content === "string" ? r.content.length : 0;
|
|
184
|
+
attachments.set(o.attachment?.type ?? "unknown", a);
|
|
185
|
+
if (o.attachment?.type === "mcp_instructions_delta" && Array.isArray(o.attachment.addedNames)) for (const n of o.attachment.addedNames) mcpServers.add(String(n).trim());
|
|
186
|
+
}
|
|
187
|
+
else if (o.type === "assistant" && o.message?.usage) {
|
|
188
|
+
// One record per content block, each repeating the message's usage.
|
|
189
|
+
const key = o.requestRef ?? null;
|
|
190
|
+
if (String(o.message.model ?? "").startsWith("<")) continue; // synthetic local message, not an API call
|
|
191
|
+
const content = (Array.isArray(o.message.content) ? o.message.content : []).filter((b) => b && typeof b === "object");
|
|
192
|
+
(key ? responses : orphans).set(key ?? o.message.id, { id: o.message.id, usage: o.message.usage, model: o.message.model, version: o.version, sidechain: !!o.isSidechain, ts: o.timestamp, thread: o.isSidechain ? `agent ${o.agentId ?? ""}` : "main" });
|
|
193
|
+
for (const b of content) if (b.type === "tool_use") toolNames.set(b.id, String(b.name ?? "unknown"));
|
|
194
|
+
turns.set(o.message.id, [...(turns.get(o.message.id) ?? []), ...content]);
|
|
195
|
+
run = [];
|
|
196
|
+
} else if (o.type === "user" && o.message?.content) {
|
|
197
|
+
const c = o.message.content;
|
|
198
|
+
transcript.push({ role: "user", content: c });
|
|
199
|
+
if (typeof c === "string") transcript.push({ role: "user", content: [{ type: "text", text: c }] });
|
|
200
|
+
// Results of parallel tool calls are logged one record each but sent
|
|
201
|
+
// as a single message, so also try every merge of adjacent records.
|
|
202
|
+
if (Array.isArray(c)) {
|
|
203
|
+
run.push(c);
|
|
204
|
+
for (let i = run.length - 2; i >= Math.max(0, run.length - 16); i--) transcript.push({ role: "user", content: run.slice(i).flat() });
|
|
205
|
+
} else run = [];
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
// A request only gets a blob when the wire message differs from the
|
|
209
|
+
// transcript; otherwise its hash refers to the transcript message itself.
|
|
210
|
+
for (const content of turns.values()) transcript.push({ role: "assistant", content });
|
|
211
|
+
for (const m of transcript) {
|
|
212
|
+
const h = hashMessage(m);
|
|
213
|
+
if (!blobs.has(h)) blobs.set(h, m);
|
|
214
|
+
}
|
|
215
|
+
// A response whose request was not logged is usage without a known cause.
|
|
216
|
+
const logged = new Set(requests.map((r) => r.id));
|
|
217
|
+
for (const [ref, res] of responses) if (!logged.has(ref)) { orphans.set(`ref ${ref}`, res); responses.delete(ref); }
|
|
218
|
+
return { blobs, shapes, requests, responses, orphans, toolNames, recordTypes };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const cats = new Map(); // category -> { read, write, fresh, output, weighted, subs }
|
|
222
|
+
const bump = (cat, sub, kind, tokens, weighted) => {
|
|
223
|
+
const c = cats.get(cat) ?? { read: 0, write: 0, fresh: 0, output: 0, weighted: 0, subs: new Map() };
|
|
224
|
+
c[kind] += tokens; c.weighted += weighted;
|
|
225
|
+
c.subs.set(sub, (c.subs.get(sub) ?? 0) + weighted);
|
|
226
|
+
cats.set(cat, c);
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const stats = { files: 0, failedFiles: 0, requests: 0, noResponse: 0, unattributed: 0, missingBlobs: 0, charsPerToken: [], models: new Map(), versions: new Map(), sources: new Map(), first: null, last: null };
|
|
230
|
+
// Claude Code version -> requests that could and could not be attributed
|
|
231
|
+
const coverage = (version, key) => {
|
|
232
|
+
const v = stats.versions.get(version ?? "unknown") ?? { attributed: 0, unattributed: 0 };
|
|
233
|
+
v[key]++; stats.versions.set(version ?? "unknown", v);
|
|
234
|
+
};
|
|
235
|
+
// Cost per model and per thread (main conversation or a subagent type).
|
|
236
|
+
const byModel = new Map(), byThread = new Map();
|
|
237
|
+
const spend = (map, key, input, output) => {
|
|
238
|
+
const e = map.get(key) ?? { requests: 0, cost: 0, output: 0 };
|
|
239
|
+
e.requests++; e.cost += input + output; e.output += output; map.set(key, e);
|
|
240
|
+
};
|
|
241
|
+
// Built-in subagent types are public; custom ones are named by the user.
|
|
242
|
+
const threadOf = (source = "") => {
|
|
243
|
+
if (source.startsWith("agent:builtin:")) return "subagent: " + source.slice(14);
|
|
244
|
+
if (source.startsWith("agent:custom")) return "subagent: " + alias("agent", source.slice(13) || "custom");
|
|
245
|
+
return source.startsWith("agent") ? "subagent: " + alias("agent", source) : "main thread";
|
|
246
|
+
};
|
|
247
|
+
const seen = (ts) => {
|
|
248
|
+
if (!ts) return;
|
|
249
|
+
stats.first = !stats.first || ts < stats.first ? ts : stats.first;
|
|
250
|
+
stats.last = !stats.last || ts > stats.last ? ts : stats.last;
|
|
251
|
+
};
|
|
252
|
+
const attachments = new Map(); // attachment type -> { count, chars }
|
|
253
|
+
const calls = new Map(); // tool group or tool name -> call count
|
|
254
|
+
const loadedIn = new Map(); // tool group -> requests that carried its schemas
|
|
255
|
+
const rebuilds = [];
|
|
256
|
+
|
|
257
|
+
// Structure-only diagnostics for --debug. Sizes, counts and type names; the
|
|
258
|
+
// only names are tool and section labels, which --redact replaces.
|
|
259
|
+
const DEBUG_CAP = 50;
|
|
260
|
+
const debug = { recordTypes: new Map(), unattributed: new Map(), offRequests: [], unknownRebuilds: [], offRequestCount: 0 };
|
|
261
|
+
// Content block types of a message, including those nested in tool results.
|
|
262
|
+
function blockTypes(msg, into) {
|
|
263
|
+
for (const b of Array.isArray(msg.content) ? msg.content.filter((b) => b && typeof b === "object") : [{ type: "text" }]) {
|
|
264
|
+
count(into, `${msg.role}:${b.type}`);
|
|
265
|
+
if (b.type === "tool_result" && Array.isArray(b.content)) for (const inner of b.content) count(into, `tool_result>${inner.type}`);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const files = findJsonl(root);
|
|
270
|
+
|
|
271
|
+
// Forked or copied sessions can repeat records; count each call once.
|
|
272
|
+
const counted = new Set();
|
|
273
|
+
const answered = new Map(); // request id -> what a follow-up needs to compare against
|
|
274
|
+
// Typical characters per token, from requests reconstructed in full.
|
|
275
|
+
const clean = { chars: 0, tokens: 0 };
|
|
276
|
+
const UNRESOLVED = "unresolved messages";
|
|
277
|
+
|
|
278
|
+
for (const file of files) {
|
|
279
|
+
onProgress(stats.files, files.length);
|
|
280
|
+
stats.files++;
|
|
281
|
+
// One unreadable session should not lose the rest.
|
|
282
|
+
try { readSession(file); } catch { stats.failedFiles++; }
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function readSession(file) {
|
|
286
|
+
const s = parseSession(file);
|
|
287
|
+
for (const name of s.toolNames.values()) { count(calls, toolLabel(name)); count(calls, toolGroup(name)); }
|
|
288
|
+
|
|
289
|
+
// Sessions without request records: usage is known, its cause is not.
|
|
290
|
+
for (const [type, n] of s.recordTypes) debug.recordTypes.set(type, (debug.recordTypes.get(type) ?? 0) + n);
|
|
291
|
+
const lastOrphan = new Map(); // thread -> its previous message
|
|
292
|
+
for (const { id, usage, model, version, sidechain, ts, thread } of s.orphans.values()) {
|
|
293
|
+
if (id && counted.has(id)) continue;
|
|
294
|
+
counted.add(id);
|
|
295
|
+
coverage(version, "unattributed");
|
|
296
|
+
count(stats.models, modelName(model));
|
|
297
|
+
seen(ts);
|
|
298
|
+
count(debug.unattributed, `version ${version ?? "unknown"}, ${sidechain ? "subagent" : "main"} thread, file ${s.requests.length ? "has" : "has no"} request records`);
|
|
299
|
+
const u = splitUsage(usage), w = rates(model, usage);
|
|
300
|
+
const cat = "unattributed input";
|
|
301
|
+
stats.unattributed++;
|
|
302
|
+
bump(cat, "cache read", "read", u.read, u.read * w.read);
|
|
303
|
+
bump(cat, "cache write", "write", u.write, u.write5m * w.write5m + u.write1h * w.write1h);
|
|
304
|
+
bump(cat, "fresh input", "fresh", u.fresh, u.fresh * w.fresh);
|
|
305
|
+
bump("output", "thinking", "output", u.thinking, u.thinking * w.output);
|
|
306
|
+
bump("output", "text and tool calls", "output", u.output - u.thinking, (u.output - u.thinking) * w.output);
|
|
307
|
+
const writeCost = u.write5m * w.write5m + u.write1h * w.write1h;
|
|
308
|
+
spend(byModel, modelName(model), u.fresh * w.fresh + writeCost + u.read * w.read, u.output * w.output);
|
|
309
|
+
spend(byThread, sidechain ? "subagent: (type not logged)" : "main thread", u.fresh * w.fresh + writeCost + u.read * w.read, u.output * w.output);
|
|
310
|
+
|
|
311
|
+
// Without request records, usage alone still shows a cache lost to
|
|
312
|
+
// idle time: little is read back and the prompt is written again.
|
|
313
|
+
// Other causes cannot be told apart here, so only idle is reported.
|
|
314
|
+
const prompt = u.fresh + u.write + u.read, prev = lastOrphan.get(thread);
|
|
315
|
+
const lost = prev ? Math.min(u.write, prev.prompt - u.read) : 0;
|
|
316
|
+
const gapMin = prev ? (Date.parse(ts) - Date.parse(prev.ts)) / 60000 : 0;
|
|
317
|
+
if (prev && u.read < 0.8 * prev.prompt && lost > 5000 && gapMin > prev.ttlMin) {
|
|
318
|
+
rebuilds.push({ cause: "idle longer than the cache lifetime", tokens: lost, wasted: lost * (writeCost / u.write - w.read), at: ts, gapMin: Math.round(gapMin), version: version ?? "unknown" });
|
|
319
|
+
}
|
|
320
|
+
lastOrphan.set(thread, { prompt, ts, ttlMin: u.write1h || !u.write ? 60 : 5 });
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Requests are delta-encoded: messages = base's first `keep` + `tail`.
|
|
324
|
+
const msgLists = new Map();
|
|
325
|
+
const lastOf = new Map(); // first message hash -> same, for the conversation's latest request
|
|
326
|
+
for (const req of s.requests) {
|
|
327
|
+
const baseList = msgLists.get(req.base), tail = Array.isArray(req.tail) ? req.tail : [], keep = req.keep ?? 0;
|
|
328
|
+
const baseLen = (baseList ?? []).length;
|
|
329
|
+
// Messages kept from a base request that is not in this log.
|
|
330
|
+
const lostPrefix = req.base && !baseList ? keep : 0;
|
|
331
|
+
const hashes = [...(baseList ?? []).slice(0, keep), ...tail];
|
|
332
|
+
msgLists.set(req.id, hashes);
|
|
333
|
+
const res = s.responses.get(req.id);
|
|
334
|
+
if (!res) { stats.noResponse++; continue; }
|
|
335
|
+
if (counted.has(req.id)) continue;
|
|
336
|
+
counted.add(req.id);
|
|
337
|
+
stats.requests++;
|
|
338
|
+
count(stats.models, modelName(res.model));
|
|
339
|
+
const source = redact ? threadOf(req.querySource) : req.querySource ?? "unknown";
|
|
340
|
+
count(stats.sources, source);
|
|
341
|
+
coverage(req.version, "attributed");
|
|
342
|
+
seen(req.timestamp);
|
|
343
|
+
|
|
344
|
+
const u = splitUsage(res.usage), w = rates(res.model, res.usage);
|
|
345
|
+
bump("output", "thinking", "output", u.thinking, u.thinking * w.output);
|
|
346
|
+
bump("output", "text and tool calls", "output", u.output - u.thinking, (u.output - u.thinking) * w.output);
|
|
347
|
+
const inputCost = u.fresh * w.fresh + u.write5m * w.write5m + u.write1h * w.write1h + u.read * w.read;
|
|
348
|
+
spend(byModel, modelName(res.model), inputCost, u.output * w.output);
|
|
349
|
+
spend(byThread, threadOf(req.querySource), inputCost, u.output * w.output);
|
|
350
|
+
|
|
351
|
+
// Prompt order is tools, system, messages; the cache covers a prefix of it.
|
|
352
|
+
const shape = s.shapes.get(req.shapeHash) ?? {};
|
|
353
|
+
const tools = (Array.isArray(shape.tools) ? shape.tools : []).filter((t) => t && typeof t === "object").map((t) => [String(t.name ?? "unknown"), t]);
|
|
354
|
+
const system = typeof shape.system === "string" ? [{ text: shape.system }] : Array.isArray(shape.system) ? shape.system : [];
|
|
355
|
+
for (const g of new Set(tools.map(([name]) => toolGroup(name)))) count(loadedIn, g);
|
|
356
|
+
const segs = [
|
|
357
|
+
...tools.map(([name, t]) => [toolGroup(name), toolLabel(name), JSON.stringify(t).length]),
|
|
358
|
+
...system.flatMap((b) => sections(String(b?.text ?? "")).map(([sec, c]) => ["system prompt", sec, c])),
|
|
359
|
+
];
|
|
360
|
+
// A message that cannot be found leaves a hole of unknown size.
|
|
361
|
+
let missing = lostPrefix;
|
|
362
|
+
if (lostPrefix) segs.push([UNRESOLVED, "kept from a request not in the log", null]);
|
|
363
|
+
for (const h of hashes) {
|
|
364
|
+
const m = s.blobs.get(h);
|
|
365
|
+
if (m) segs.push(...blobSegments(m, s.toolNames));
|
|
366
|
+
else { missing++; segs.push([UNRESOLVED, "message not in the log", null]); }
|
|
367
|
+
}
|
|
368
|
+
stats.missingBlobs += missing;
|
|
369
|
+
const prompt = u.fresh + u.write + u.read;
|
|
370
|
+
if (missing) {
|
|
371
|
+
// ESTIMATE: size the holes from the usual characters-per-token ratio,
|
|
372
|
+
// so their cost is not spread over the messages that were found.
|
|
373
|
+
const known = segs.reduce((n, seg) => n + (seg[2] ?? 0), 0);
|
|
374
|
+
const holes = segs.filter((seg) => seg[2] === null);
|
|
375
|
+
const gap = Math.max(0, prompt * (clean.tokens ? clean.chars / clean.tokens : 2.9) - known);
|
|
376
|
+
for (const seg of holes) seg[2] = gap / holes.length;
|
|
377
|
+
}
|
|
378
|
+
const chars = segs.reduce((n, seg) => n + seg[2], 0);
|
|
379
|
+
const attributable = chars > 0 && prompt > 0;
|
|
380
|
+
if (prompt > 0 && !attributable) {
|
|
381
|
+
// Nothing of the prompt could be rebuilt: keep its cost, without a cause.
|
|
382
|
+
bump("unattributed input", "cache read", "read", u.read, u.read * w.read);
|
|
383
|
+
bump("unattributed input", "cache write", "write", u.write, u.write5m * w.write5m + u.write1h * w.write1h);
|
|
384
|
+
bump("unattributed input", "fresh input", "fresh", u.fresh, u.fresh * w.fresh);
|
|
385
|
+
}
|
|
386
|
+
if (attributable && !missing) {
|
|
387
|
+
stats.charsPerToken.push(chars / prompt);
|
|
388
|
+
clean.chars += chars; clean.tokens += prompt;
|
|
389
|
+
}
|
|
390
|
+
if (debugOn && attributable && !missing && (chars / prompt < 2 || chars / prompt > 4.5) && debug.offRequestCount++ < DEBUG_CAP) {
|
|
391
|
+
const byCat = new Map(), types = new Map();
|
|
392
|
+
for (const [cat, , c] of segs) byCat.set(cat, (byCat.get(cat) ?? 0) + c);
|
|
393
|
+
for (const h of hashes) if (s.blobs.has(h)) blockTypes(s.blobs.get(h), types);
|
|
394
|
+
debug.offRequests.push({
|
|
395
|
+
charsPerToken: chars / prompt, version: req.version, source, model: modelName(res.model), usage: u,
|
|
396
|
+
messages: hashes.length, missingMessages: missing, keep, tail: tail.length, hasBase: !!req.base, baseKnown: !!baseList,
|
|
397
|
+
charsByCategory: Object.fromEntries(byCat), blockTypes: Object.fromEntries(types),
|
|
398
|
+
largestSegments: [...segs].sort((a, b) => b[2] - a[2]).slice(0, 3).map(([cat, sub, c]) => ({ category: cat, sub, chars: c })),
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// ESTIMATE: scale chars to the request's real token total, then charge
|
|
403
|
+
// each segment by position: first `read` tokens, then `write`, then fresh.
|
|
404
|
+
const perChar = attributable ? prompt / chars : 0;
|
|
405
|
+
const writeRate = u.write ? (u.write5m * w.write5m + u.write1h * w.write1h) / u.write : 0;
|
|
406
|
+
const bands = [["read", u.read, w.read], ["write", u.write, writeRate], ["fresh", u.fresh, w.fresh]];
|
|
407
|
+
let pos = 0;
|
|
408
|
+
for (const [cat, sub, c] of segs) {
|
|
409
|
+
const end = pos + c * perChar;
|
|
410
|
+
let lo = 0;
|
|
411
|
+
for (const [kind, size, rate] of bands) {
|
|
412
|
+
const overlap = Math.max(0, Math.min(end, lo + size) - Math.max(pos, lo));
|
|
413
|
+
if (overlap) bump(cat, sub, kind, overlap, overlap * rate);
|
|
414
|
+
lo += size;
|
|
415
|
+
}
|
|
416
|
+
pos = end;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// Reading back less than the previous prompt means the cache was lost
|
|
420
|
+
// and part of the existing conversation had to be written again. The
|
|
421
|
+
// waste is what those tokens cost above a normal cache read.
|
|
422
|
+
// Compare with the request this one extends, not the previous one in the
|
|
423
|
+
// file: subagents interleave their own conversations in the same log.
|
|
424
|
+
// A resumed session logs no base, so fall back to the conversation that
|
|
425
|
+
// starts with the same first message.
|
|
426
|
+
const prev = answered.get(req.base) ?? lastOf.get(hashes[0]);
|
|
427
|
+
const lost = prev ? Math.min(u.write, prev.prompt - u.read) : 0;
|
|
428
|
+
if (prev && u.read < 0.8 * prev.prompt && lost > 5000) {
|
|
429
|
+
const gapMin = (Date.parse(req.timestamp) - Date.parse(prev.ts)) / 60000;
|
|
430
|
+
// An expired cache explains the rebuild whatever else changed.
|
|
431
|
+
const cause = gapMin > prev.ttlMin ? "idle longer than the cache lifetime"
|
|
432
|
+
: prev.model !== res.model ? "model switched mid-session"
|
|
433
|
+
: keep < baseLen ? "conversation edited or compacted"
|
|
434
|
+
: req.shapeHash !== prev.shapeHash ? "tool list or system prompt changed"
|
|
435
|
+
: "unknown";
|
|
436
|
+
rebuilds.push({ cause, tokens: lost, wasted: lost * (writeRate - w.read), at: req.timestamp, gapMin: Math.round(gapMin), version: req.version ?? "unknown" });
|
|
437
|
+
if (debugOn && cause === "unknown" && debug.unknownRebuilds.length < DEBUG_CAP) {
|
|
438
|
+
debug.unknownRebuilds.push({
|
|
439
|
+
version: req.version, source, gapMin, previousPrompt: prev.prompt, usage: u, ttlMin: prev.ttlMin,
|
|
440
|
+
keep, baseMessages: baseLen, tail: tail.length, matchedBy: answered.has(req.base) ? "base" : "first message",
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
answered.set(req.id, { prompt, ts: req.timestamp, model: res.model, shapeHash: req.shapeHash, ttlMin: u.write1h || !u.write ? 60 : 5 });
|
|
445
|
+
lastOf.set(hashes[0], answered.get(req.id));
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const total = [...cats.values()].reduce((n, c) => n + c.weighted, 0);
|
|
450
|
+
const share = (x) => (total ? x / total : 0);
|
|
451
|
+
const rows = [...cats.entries()].map(([name, c]) => ({
|
|
452
|
+
name, read: c.read, write: c.write, fresh: c.fresh, output: c.output, weighted: c.weighted, share: share(c.weighted), calls: calls.get(name) ?? null,
|
|
453
|
+
subs: [...c.subs.entries()].map(([sub, w]) => ({ name: sub, share: share(w), calls: calls.get(sub) ?? null })).sort((a, b) => b.share - a.share),
|
|
454
|
+
})).sort((a, b) => b.weighted - a.weighted);
|
|
455
|
+
const row = (name) => rows.find((r) => r.name === name);
|
|
456
|
+
|
|
457
|
+
// Findings: things the category table does not spell out. All ESTIMATES.
|
|
458
|
+
const findings = [];
|
|
459
|
+
const byCause = new Map();
|
|
460
|
+
for (const r of rebuilds) byCause.set(r.cause, [...(byCause.get(r.cause) ?? []), r]);
|
|
461
|
+
const rebuildFix = {
|
|
462
|
+
"model switched mid-session": "Pick the model before the first message, or switch in a new session.",
|
|
463
|
+
"idle longer than the cache lifetime": "Before a long break, compact the conversation, or start a new session afterwards if you do not need the history. The saving is smaller than this figure when you do need it.",
|
|
464
|
+
"conversation edited or compacted": "Compact at a natural break, before the conversation is large.",
|
|
465
|
+
"tool list or system prompt changed": "Connect MCP servers before the session starts, not during it.",
|
|
466
|
+
"unknown": "No fix identified yet.",
|
|
467
|
+
};
|
|
468
|
+
for (const [cause, list] of byCause) {
|
|
469
|
+
const wasted = list.reduce((n, r) => n + r.wasted, 0);
|
|
470
|
+
findings.push({ effect: share(wasted), what: `Cache rebuilt ${list.length}x: ${cause}; ${Math.round(list.reduce((n, r) => n + r.tokens, 0) / 1000)}k tokens paid for twice`, fix: rebuildFix[cause] });
|
|
471
|
+
}
|
|
472
|
+
for (const r of rows.filter((r) => r.name.startsWith(MCP))) {
|
|
473
|
+
const server = r.name.slice(MCP.length);
|
|
474
|
+
const instr = row("injected system messages")?.subs.find((x) => x.name === "MCP instructions: " + server)?.share ?? 0;
|
|
475
|
+
const n = loadedIn.get(r.name) ?? 0, k = r.calls ?? 0;
|
|
476
|
+
if (k / n > 0.05) continue;
|
|
477
|
+
findings.push({ effect: r.share + instr, what: `${server}: schemas loaded in ${n} requests, called ${k}x`, fix: "Disable it, or enable it only in the projects that use it." });
|
|
478
|
+
}
|
|
479
|
+
// Servers whose instructions are injected but whose tools are not loaded up
|
|
480
|
+
// front (deferred, or named differently), so calls cannot be matched.
|
|
481
|
+
for (const sub of row("injected system messages")?.subs ?? []) {
|
|
482
|
+
const server = sub.name.replace("MCP instructions: ", "");
|
|
483
|
+
if (server === sub.name || row(MCP + server)) continue;
|
|
484
|
+
findings.push({ effect: sub.share, what: `${server}: instructions loaded in every session; whether you use it cannot be measured`, fix: "Disconnect it if you do not use it." });
|
|
485
|
+
}
|
|
486
|
+
const thinking = row("output")?.subs.find((x) => x.name === "thinking");
|
|
487
|
+
if (thinking) findings.push({ effect: thinking.share, upperBound: true, what: "Thinking tokens", fix: "Lower the effort level for routine work. Upper bound: the saving depends on how much less it thinks." });
|
|
488
|
+
const topResult = row("tool results")?.subs[0];
|
|
489
|
+
if (topResult) findings.push({ effect: topResult.share, upperBound: true, what: `Results of ${topResult.name} (${topResult.calls ?? 0} calls) kept in context`, fix: "Ask for narrower output (line ranges, filters), or hand bulky reads to a subagent." });
|
|
490
|
+
findings.sort((a, b) => b.effect - a.effect);
|
|
491
|
+
|
|
492
|
+
const ranked = (map) => [...map.entries()]
|
|
493
|
+
.map(([name, e]) => ({ name, requests: e.requests, cost: e.cost, share: share(e.cost), costPerRequest: e.cost / e.requests, outputShare: e.cost ? e.output / e.cost : 0 }))
|
|
494
|
+
.sort((a, b) => b.cost - a.cost);
|
|
495
|
+
const cpt = stats.charsPerToken.sort((a, b) => a - b);
|
|
496
|
+
const q = (p) => cpt[Math.min(cpt.length - 1, Math.floor(p * cpt.length))];
|
|
497
|
+
// Requests whose reconstructed prompt is far from the billed token count.
|
|
498
|
+
const offRatio = cpt.filter((x) => x < 2 || x > 4.5).length / (cpt.length || 1);
|
|
499
|
+
const summary = {
|
|
500
|
+
contains: "Token totals, counts, timestamps and model names. No message text or tool output. "
|
|
501
|
+
+ (redact ? "Names of MCP servers, MCP tools and custom subagents, and prompt section headings, are replaced with placeholders."
|
|
502
|
+
: "Includes names of MCP servers, tools and custom subagents, and prompt section headings (lines that start with #); rerun with --redact to replace them."),
|
|
503
|
+
redacted: redact,
|
|
504
|
+
unit: "API-equivalent US dollars at published prices (hypothesis: limits may weight usage differently)",
|
|
505
|
+
prices: PRICES, assumedPricing: Object.fromEntries(assumedPricing),
|
|
506
|
+
files: stats.files, filesFailed: stats.failedFiles, requests: stats.requests, requestsWithoutResponse: stats.noResponse, unattributedMessages: stats.unattributed, missingBlobs: stats.missingBlobs,
|
|
507
|
+
from: stats.first, to: stats.last, models: Object.fromEntries(stats.models),
|
|
508
|
+
charsPerToken: { min: cpt[0], p5: q(0.05), median: q(0.5), p95: q(0.95), max: cpt[cpt.length - 1], shareOutside2to4_5: offRatio },
|
|
509
|
+
claudeCodeVersions: Object.fromEntries(stats.versions), querySources: Object.fromEntries(stats.sources),
|
|
510
|
+
totalWeighted: total, categories: rows, cacheRebuilds: rebuilds, findings,
|
|
511
|
+
byModel: ranked(byModel), byThread: ranked(byThread),
|
|
512
|
+
injectedByType: Object.fromEntries([...attachments].sort((a, b) => b[1].chars - a[1].chars)),
|
|
513
|
+
};
|
|
514
|
+
if (debugOn) {
|
|
515
|
+
summary.debug = {
|
|
516
|
+
contains: "Structure only: record types, block types, sizes and counts. No message text.",
|
|
517
|
+
recordTypes: Object.fromEntries(debug.recordTypes), unattributed: Object.fromEntries(debug.unattributed),
|
|
518
|
+
offRequestsTotal: debug.offRequestCount, offRequests: debug.offRequests, unknownRebuilds: debug.unknownRebuilds,
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
return summary;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// Terminal styling. Progress and banner go to stderr so piped or redirected
|
|
525
|
+
// output stays clean; colour is used only on a terminal that wants it.
|
|
526
|
+
const noColor = globalThis.Deno ? globalThis.Deno.noColor : "NO_COLOR" in process.env;
|
|
527
|
+
const paint = (stream) => (code, text) => (stream.isTTY && !noColor ? `\x1b[${code}m${text}\x1b[0m` : text);
|
|
528
|
+
const out = paint(process.stdout), err = paint(process.stderr);
|
|
529
|
+
const BOLD = 1, DIM = 2, CYAN = 36, YELLOW = 33, GREEN = 32;
|
|
530
|
+
const BANNER = ["┌─┐ ┬ ┬┌─┐┌┬┐┌─┐┌─┐┌─┐┬ ┬", "│─┼┐│ ││ │ │ │ │├─┘└─┐└┬┘", "└─┘└└─┘└─┘ ┴ └─┘┴ └─┘ ┴ "];
|
|
531
|
+
const bar = (share, width = 20) => "█".repeat(Math.round(share * width)).padEnd(width, "░");
|
|
532
|
+
const num = (n) => Math.round(n).toLocaleString("en-US");
|
|
533
|
+
|
|
534
|
+
function printReport(summary, redact) {
|
|
535
|
+
const pct = (x) => (x * 100).toFixed(1) + "%";
|
|
536
|
+
const usd = (n) => "$" + n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
537
|
+
const title = (t) => t[0].toUpperCase() + t.slice(1);
|
|
538
|
+
const WIDTH = 72;
|
|
539
|
+
// Word-wrap to the report width, with a first-line prefix and a hanging indent.
|
|
540
|
+
const wrap = (text, first, hang, style = (t) => t) => {
|
|
541
|
+
let line = first;
|
|
542
|
+
for (const word of text.split(" ")) {
|
|
543
|
+
if (line.length + word.length + 1 > WIDTH + 2 && line.trim()) { console.log(style(line)); line = hang + word; }
|
|
544
|
+
else line += (line === first || line === hang ? "" : " ") + word;
|
|
545
|
+
}
|
|
546
|
+
console.log(style(line));
|
|
547
|
+
};
|
|
548
|
+
const heading = (left, right = "") => {
|
|
549
|
+
console.log(` ${out(BOLD, left)}${out(DIM, right.padStart(WIDTH - left.length))}`);
|
|
550
|
+
console.log(" " + out(DIM, "─".repeat(WIDTH)));
|
|
551
|
+
};
|
|
552
|
+
|
|
553
|
+
console.log(` ${num(summary.files)} sessions · ${num(summary.requests + summary.unattributedMessages)} requests · ${summary.from?.slice(0, 10)} to ${summary.to?.slice(0, 10)} · ${out(BOLD, usd(summary.totalWeighted))} at API prices`);
|
|
554
|
+
if (redact) console.log(out(DIM, " Names of MCP servers, MCP tools and prompt sections are replaced with placeholders"));
|
|
555
|
+
console.log();
|
|
556
|
+
|
|
557
|
+
heading("WHAT TO FIX", "estimated share of cost");
|
|
558
|
+
const shown = summary.findings.filter((f) => f.effect >= 0.005);
|
|
559
|
+
if (!shown.length) console.log(" Nothing above 0.5% of cost. Your setup looks lean.");
|
|
560
|
+
shown.forEach((f, i) => {
|
|
561
|
+
const [what, detail] = f.what.split("; ");
|
|
562
|
+
const effect = (f.upperBound ? "up to " : "") + pct(f.effect);
|
|
563
|
+
const room = WIDTH - effect.length - 6;
|
|
564
|
+
const left = `${String(i + 1).padStart(2)} ${what.length > room ? what.slice(0, room - 1) + "…" : what}`;
|
|
565
|
+
console.log(` ${out(BOLD, left)}${out(YELLOW, effect.padStart(Math.max(1, WIDTH - left.length)))}`);
|
|
566
|
+
if (detail) wrap(title(detail), " ", " ", (t) => out(DIM, t));
|
|
567
|
+
wrap(f.fix, " → ", " ");
|
|
568
|
+
});
|
|
569
|
+
console.log();
|
|
570
|
+
|
|
571
|
+
heading("WHERE IT WENT", "share API $");
|
|
572
|
+
// Keep the table short: fold small MCP servers into one row.
|
|
573
|
+
const small = summary.categories.filter((r) => r.name.startsWith(MCP) && r.share < 0.005);
|
|
574
|
+
const table = summary.categories.filter((r) => !small.includes(r));
|
|
575
|
+
if (small.length) table.push({ name: `${small.length} other MCP servers`, share: small.reduce((n, r) => n + r.share, 0), weighted: small.reduce((n, r) => n + r.weighted, 0), subs: [] });
|
|
576
|
+
for (const r of table) {
|
|
577
|
+
console.log(` ${title(r.name).slice(0, 30).padEnd(30)} ${out(CYAN, bar(r.share))} ${pct(r.share).padStart(6)} ${usd(r.weighted).padStart(10)}`);
|
|
578
|
+
if (r.share < 0.05 || r.subs.length < 2) continue;
|
|
579
|
+
for (const sub of r.subs.slice(0, 3)) {
|
|
580
|
+
console.log(out(DIM, ` ${pct(sub.share).padStart(6)} ${sub.name}${sub.calls != null ? ` (${num(sub.calls)} calls)` : ""}`));
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
console.log();
|
|
584
|
+
|
|
585
|
+
// Who spent it: shown only when there is more than one to compare.
|
|
586
|
+
for (const [name, list] of [["BY MODEL", summary.byModel], ["BY THREAD", summary.byThread]]) {
|
|
587
|
+
if (list.length < 2) continue;
|
|
588
|
+
heading(name, "requests per request share API $");
|
|
589
|
+
for (const r of list.slice(0, 8)) {
|
|
590
|
+
console.log(` ${r.name.slice(0, 28).padEnd(28)}${num(r.requests).padStart(10)}${usd(r.costPerRequest).padStart(14)}${pct(r.share).padStart(9)}${usd(r.cost).padStart(11)}`);
|
|
591
|
+
}
|
|
592
|
+
if (list.length > 8) console.log(out(DIM, ` and ${list.length - 8} more`));
|
|
593
|
+
console.log();
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
heading("HOW TO READ THIS");
|
|
597
|
+
const notes = [
|
|
598
|
+
"Every figure is an estimate. Usage is priced at published API rates per model; how weekly limits weight it is not published.",
|
|
599
|
+
...Object.entries(summary.assumedPricing).map(([model, as]) => `No published price known for ${model}; priced as ${as}.`),
|
|
600
|
+
`Check: ${summary.charsPerToken.median?.toFixed(2)} characters per token (median), ${(summary.charsPerToken.shareOutside2to4_5 * 100).toFixed(1)}% of requests reconstructed poorly, ${num(summary.unattributedMessages)} requests without a request record.`,
|
|
601
|
+
`Claude Code versions: ${Object.entries(summary.claudeCodeVersions).map(([v, c]) => `${v} (${num(c.attributed + c.unattributed)})`).join(", ")}.`,
|
|
602
|
+
...(summary.filesFailed ? [`${num(summary.filesFailed)} session files could not be read and are left out.`] : []),
|
|
603
|
+
"Nothing left this machine. For a shareable summary: npx quotopsy --json --redact",
|
|
604
|
+
];
|
|
605
|
+
for (const note of notes) wrap(note, " · ", " ", (t) => out(DIM, t));
|
|
606
|
+
console.log();
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function main() {
|
|
610
|
+
const args = process.argv.slice(2);
|
|
611
|
+
const asJson = args.includes("--json"), redact = args.includes("--redact"), debug = args.includes("--debug");
|
|
612
|
+
const root = args.find((a) => !a.startsWith("--")) ?? join(homedir(), ".claude", "projects");
|
|
613
|
+
|
|
614
|
+
const started = Date.now();
|
|
615
|
+
let lastDraw = 0, bannerShown = false;
|
|
616
|
+
const onProgress = (done, total) => {
|
|
617
|
+
if (!process.stderr.isTTY) return;
|
|
618
|
+
if (!bannerShown) {
|
|
619
|
+
bannerShown = true;
|
|
620
|
+
process.stderr.write(`\n${BANNER.map((l) => " " + err(CYAN, l)).join("\n")}\n ${err(DIM, "where did your Claude Code usage go?")}\n\n`);
|
|
621
|
+
}
|
|
622
|
+
if (Date.now() - lastDraw < 60 && done < total) return;
|
|
623
|
+
lastDraw = Date.now();
|
|
624
|
+
process.stderr.write(`\r Reading sessions ${err(CYAN, bar(done / total, 24))} ${done}/${total}`);
|
|
625
|
+
};
|
|
626
|
+
|
|
627
|
+
let summary;
|
|
628
|
+
try { summary = analyze(root, { redact, debug, onProgress }); } catch (e) {
|
|
629
|
+
if (e?.code !== "ENOENT" && e?.code !== "ENOTDIR") throw e;
|
|
630
|
+
}
|
|
631
|
+
if (!summary?.files) {
|
|
632
|
+
console.error(`No Claude Code session logs found in ${root}\nPass the folder that holds them: npx quotopsy <logs-dir>`);
|
|
633
|
+
process.exit(1);
|
|
634
|
+
}
|
|
635
|
+
if (process.stderr.isTTY) {
|
|
636
|
+
process.stderr.write(`\r\x1b[2K ${err(GREEN, "✓")} Read ${num(summary.files)} sessions and ${num(summary.requests + summary.unattributedMessages)} requests in ${((Date.now() - started) / 1000).toFixed(1)}s, on this machine only\n\n`);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
if (asJson) {
|
|
640
|
+
console.log(JSON.stringify(summary, null, 2));
|
|
641
|
+
if (process.stderr.isTTY && !process.stdout.isTTY) process.stderr.write(` Summary written${redact ? ", with names replaced by placeholders" : ""}. Have a look before you share it.\n\n`);
|
|
642
|
+
} else if (!summary.totalWeighted) {
|
|
643
|
+
console.log(" The logs contain no usage records yet.");
|
|
644
|
+
} else {
|
|
645
|
+
printReport(summary, redact);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// Run only when executed directly, not when imported by the tests.
|
|
650
|
+
const isMain = import.meta.main ?? (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url));
|
|
651
|
+
if (isMain) main();
|