vectiscode 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +46 -2
- package/dist/checks.js +301 -0
- package/dist/claude.js +142 -0
- package/dist/cli.js +56 -0
- package/dist/codex.js +114 -0
- package/dist/files.js +33 -0
- package/dist/model.js +18 -0
- package/dist/prices.js +61 -0
- package/dist/report.js +82 -0
- package/package.json +39 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 vectiscode
|
|
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
CHANGED
|
@@ -1,3 +1,47 @@
|
|
|
1
|
-
#
|
|
1
|
+
# vectiscode
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Find out why your Claude Code and Codex limits drain, and what to change.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npx vectiscode
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
vectiscode reads the session logs that Claude Code and Codex already keep on your computer and tells you where your usage went:
|
|
10
|
+
|
|
11
|
+
- **Long chats.** How much ran with more than 150k tokens of context, which chats grew biggest, and how often they were compacted. Every step in a chat re-reads the whole chat, so late steps cost far more than early ones.
|
|
12
|
+
- **Always-on context.** How big a new chat is before you type anything, what that setup is made of (tool definitions, system prompt, skills list, CLAUDE.md), and which tools were sent with every request but never called.
|
|
13
|
+
- **Cache after breaks.** What it cost to come back to a big chat after its cache expired.
|
|
14
|
+
- **Helpers and subagents.** How much went to each helper type and to workflows.
|
|
15
|
+
- **Codex limits.** Your latest 5-hour and weekly readings, and how much of a weekly limit each chat used, taken from the real percentages Codex logs after every turn.
|
|
16
|
+
|
|
17
|
+
Each finding comes with a fix.
|
|
18
|
+
|
|
19
|
+
Nothing leaves your computer. There is no account, no telemetry and no network call, and the logs are only read, never changed.
|
|
20
|
+
|
|
21
|
+
## Options
|
|
22
|
+
|
|
23
|
+
| Option | |
|
|
24
|
+
|---|---|
|
|
25
|
+
| `--days <n>` | look at the last n days (default 30) |
|
|
26
|
+
| `--since <date>` | look at everything since a date, like `2026-10-01` |
|
|
27
|
+
| `--json` | print the full report as JSON |
|
|
28
|
+
| `--claude-dir <dir>` | Claude Code folder (default `$CLAUDE_CONFIG_DIR` or `~/.claude`) |
|
|
29
|
+
| `--codex-dir <dir>` | Codex folder (default `$CODEX_HOME` or `~/.codex`) |
|
|
30
|
+
|
|
31
|
+
## How the numbers work
|
|
32
|
+
|
|
33
|
+
- **Claude Code** logs the tokens of every request. vectiscode weighs them with API list prices and calls the result *units* (API-equivalent dollars). When Claude Code's own cost records are in the logs, the prices are checked against them, so the units stay right when prices change. Plans are not billed this way; units are a common scale for comparing requests, chats and habits.
|
|
34
|
+
- **Codex** logs how much of your 5-hour and weekly limits you have used after every turn. vectiscode credits each turn with the change, so its Codex numbers are real percentages, not estimates. If you switch between ChatGPT accounts, each account's window is counted separately.
|
|
35
|
+
- **Setup sizes** are estimated from the characters in the logs, so they are approximate.
|
|
36
|
+
|
|
37
|
+
## Requirements
|
|
38
|
+
|
|
39
|
+
Node 22 or newer. Built and tested on Windows; Claude Code and Codex write the same logs on macOS and Linux.
|
|
40
|
+
|
|
41
|
+
## Status
|
|
42
|
+
|
|
43
|
+
Early. If a number looks wrong for your logs, please open an issue.
|
|
44
|
+
|
|
45
|
+
## License
|
|
46
|
+
|
|
47
|
+
MIT
|
package/dist/checks.js
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import { limitDeltas, openWindows } from "./codex.js";
|
|
2
|
+
import { costParts, readPrice } from "./prices.js";
|
|
3
|
+
const WEEK = 10_080;
|
|
4
|
+
const LONG = 150_000;
|
|
5
|
+
const VERY_LONG = 300_000;
|
|
6
|
+
export function analyze(claude, codex, since, now = Date.now()) {
|
|
7
|
+
const report = { since, findings: [] };
|
|
8
|
+
const weekly = codex ? turnPoints(codex.limits, since) : new Map();
|
|
9
|
+
if (claude?.requests.length) {
|
|
10
|
+
const reqs = claude.requests;
|
|
11
|
+
const total = sum(reqs, (r) => r.cost) || 1;
|
|
12
|
+
const parts = reqs.map(costParts);
|
|
13
|
+
report.claude = {
|
|
14
|
+
units: total,
|
|
15
|
+
requests: reqs.length,
|
|
16
|
+
chats: new Set(reqs.filter((r) => r.agent === "main").map((r) => r.session)).size,
|
|
17
|
+
split: {
|
|
18
|
+
read: sum(parts, (p) => p.read) / total,
|
|
19
|
+
write: sum(parts, (p) => p.write) / total,
|
|
20
|
+
output: sum(parts, (p) => p.output) / total,
|
|
21
|
+
input: sum(parts, (p) => p.input) / total,
|
|
22
|
+
},
|
|
23
|
+
agents: shares(reqs, (r) => r.agent, total),
|
|
24
|
+
projects: shares(reqs, (r) => r.project, total).slice(0, 4),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
if (codex?.requests.length) {
|
|
28
|
+
const latest = new Map();
|
|
29
|
+
for (const s of codex.limits)
|
|
30
|
+
latest.set(`${s.limit}:${s.windowMinutes}`, s);
|
|
31
|
+
const bySession = new Map();
|
|
32
|
+
for (const r of codex.requests)
|
|
33
|
+
bySession.set(r.session, (bySession.get(r.session) ?? 0) + (weekly.get(r.id) ?? 0));
|
|
34
|
+
report.codex = {
|
|
35
|
+
turns: codex.requests.length,
|
|
36
|
+
chats: codex.sessions.size,
|
|
37
|
+
openWeekly: openWindows(codex.limits, WEEK, now),
|
|
38
|
+
limits: [...latest.values()].map((s) => ({
|
|
39
|
+
limit: s.limit,
|
|
40
|
+
windowMinutes: s.windowMinutes,
|
|
41
|
+
used: s.resetsAt <= now ? 0 : s.used,
|
|
42
|
+
resetsAt: s.resetsAt,
|
|
43
|
+
plan: s.plan,
|
|
44
|
+
})),
|
|
45
|
+
topChats: [...bySession]
|
|
46
|
+
.filter(([, points]) => points > 0)
|
|
47
|
+
.map(([id, points]) => ({ project: codex.sessions.get(id)?.project ?? "unknown", start: codex.sessions.get(id)?.start ?? 0, points }))
|
|
48
|
+
.sort((a, b) => b.points - a.points)
|
|
49
|
+
.slice(0, 3),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
const checks = [longChats(claude, codex, weekly), alwaysOn(claude, codex), cacheBreaks(claude), helpers(claude)];
|
|
53
|
+
const order = ["high", "medium", "low", "ok"];
|
|
54
|
+
report.findings = checks
|
|
55
|
+
.filter((f) => f !== null)
|
|
56
|
+
.sort((a, b) => order.indexOf(a.level) - order.indexOf(b.level) || b.share - a.share);
|
|
57
|
+
return report;
|
|
58
|
+
}
|
|
59
|
+
function longChats(claude, codex, weekly) {
|
|
60
|
+
const facts = [];
|
|
61
|
+
let share = 0;
|
|
62
|
+
const claudeTotal = sum(claude?.requests ?? [], (r) => r.cost);
|
|
63
|
+
if (claude && claudeTotal > 0) {
|
|
64
|
+
const main = claude.requests.filter((r) => r.agent === "main");
|
|
65
|
+
const over = sum(main.filter((r) => r.context > LONG), (r) => r.cost) / claudeTotal;
|
|
66
|
+
const overVery = sum(main.filter((r) => r.context > VERY_LONG), (r) => r.cost) / claudeTotal;
|
|
67
|
+
const beyond = sum(main, (r) => Math.max(0, r.context - LONG) * readPrice(r.model));
|
|
68
|
+
share = over;
|
|
69
|
+
facts.push(`${pct(over)} of your Claude usage ran in chats past ${k(LONG)} tokens of context, ${pct(overVery)} past ${k(VERY_LONG)}.`);
|
|
70
|
+
facts.push(`Re-reading the part beyond ${k(LONG)} alone cost ${units(beyond)} units, ${pct(beyond / claudeTotal)} of everything.`);
|
|
71
|
+
const chats = chatStats(main, claude.sessions);
|
|
72
|
+
const veryLong = chats.filter((c) => c.peak > VERY_LONG);
|
|
73
|
+
if (veryLong.length) {
|
|
74
|
+
const compactions = sum(veryLong, (c) => c.compactions);
|
|
75
|
+
facts.push(`${veryLong.length} ${plural(veryLong.length, "chat")} grew past ${k(VERY_LONG)}${compactions ? ` and ${plural(compactions, "was", "were")} compacted ${compactions} ${plural(compactions, "time")}` : ""}.`);
|
|
76
|
+
}
|
|
77
|
+
for (const c of chats.slice(0, 3)) {
|
|
78
|
+
facts.push(` ${c.project}, ${day(c.start)}: ${units(c.cost)} units, peaked at ${k(c.peak)}${c.compactions ? `, ${c.compactions} ${plural(c.compactions, "compaction")}` : ""}.`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (codex?.requests.length) {
|
|
82
|
+
const points = sum(codex.requests, (r) => weekly.get(r.id) ?? 0);
|
|
83
|
+
if (points > 0) {
|
|
84
|
+
const over = sum(codex.requests.filter((r) => r.context > LONG), (r) => weekly.get(r.id) ?? 0) / points;
|
|
85
|
+
share = Math.max(share, over);
|
|
86
|
+
facts.push(`Codex: ${pct(over)} of the weekly limit you used went to turns past ${k(LONG)} tokens of context.`);
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
const input = sum(codex.requests, (r) => r.context) || 1;
|
|
90
|
+
const over = sum(codex.requests.filter((r) => r.context > LONG), (r) => r.context) / input;
|
|
91
|
+
share = Math.max(share, over);
|
|
92
|
+
facts.push(`Codex: ${pct(over)} of the tokens you sent were in turns past ${k(LONG)} tokens of context.`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (!facts.length)
|
|
96
|
+
return null;
|
|
97
|
+
const level = grade(share, 0.5, 0.25, 0.1);
|
|
98
|
+
return {
|
|
99
|
+
id: "long-chats",
|
|
100
|
+
level,
|
|
101
|
+
title: level === "ok" ? "Chat length" : `Long chats: ${pct(share)} of your usage ran past ${k(LONG)} tokens of context`,
|
|
102
|
+
share,
|
|
103
|
+
facts,
|
|
104
|
+
fix: "Every step in a chat re-reads the whole chat, so late steps cost several times what early ones do. Start a fresh chat from a short handoff note once a chat passes about 150k, instead of letting it grow and compact.",
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
function alwaysOn(claude, codex) {
|
|
108
|
+
const facts = [];
|
|
109
|
+
let share = 0;
|
|
110
|
+
const claudeTotal = sum(claude?.requests ?? [], (r) => r.cost);
|
|
111
|
+
if (claude && claudeTotal > 0) {
|
|
112
|
+
const firsts = firstContexts(claude.requests.filter((r) => r.agent === "main"));
|
|
113
|
+
if (firsts.size) {
|
|
114
|
+
const floor = sum(claude.requests.filter((r) => r.agent === "main" && firsts.has(r.session)), (r) => Math.min(r.context, firsts.get(r.session)) * readPrice(r.model));
|
|
115
|
+
share = floor / claudeTotal;
|
|
116
|
+
facts.push(`A new Claude Code chat starts at about ${k(median([...firsts.values()]))} tokens (median of ${firsts.size} ${plural(firsts.size, "chat")}), and most of that goes out again with every request.`);
|
|
117
|
+
facts.push(`Re-sending it cost ${units(floor)} units, ${pct(share)} of your Claude usage.`);
|
|
118
|
+
}
|
|
119
|
+
// The logs give characters, not tokens, for each part, so these are estimates; the rest is what isn't itemized.
|
|
120
|
+
const logged = [...claude.sessions.values()].filter((s) => s.setup?.has("tools") && firsts.has(s.id)).sort((a, b) => b.end - a.end);
|
|
121
|
+
const latest = logged[0];
|
|
122
|
+
if (latest) {
|
|
123
|
+
const first = firsts.get(latest.id);
|
|
124
|
+
const parts = new Map();
|
|
125
|
+
for (const [part, chars] of latest.setup) {
|
|
126
|
+
const name = SETUP_PARTS[part] ?? "other";
|
|
127
|
+
parts.set(name, (parts.get(name) ?? 0) + chars / (part === "tools" ? JSON_CHARS : TEXT_CHARS));
|
|
128
|
+
}
|
|
129
|
+
const rest = first - sum([...parts.values()], (t) => t);
|
|
130
|
+
if (rest > 0)
|
|
131
|
+
parts.set("not itemized", rest);
|
|
132
|
+
const top = [...parts].filter(([, tokens]) => tokens >= 500).sort((a, b) => b[1] - a[1]);
|
|
133
|
+
facts.push(`Where it comes from in your latest chat (${k(first)}): ${top.map(([name, tokens]) => `${name} ${k(tokens)}`).join(", ")}.`);
|
|
134
|
+
const withTools = [...claude.sessions.values()].filter((s) => s.tools);
|
|
135
|
+
const unused = unusedTools(withTools, 1 / JSON_CHARS);
|
|
136
|
+
if (unused.length) {
|
|
137
|
+
const list = unused.slice(0, 5).map((t) => `${t.name} (${k(t.tokens)})`).join(", ");
|
|
138
|
+
facts.push(`Never called in the ${withTools.length} ${plural(withTools.length, "chat")} that logged their tools: ${list}${unused.length > 5 ? ` and ${unused.length - 5} more` : ""}, about ${k(sum(unused, (t) => t.tokens))} per request.`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (codex?.requests.length) {
|
|
143
|
+
const firsts = firstContexts(codex.requests);
|
|
144
|
+
if (firsts.size)
|
|
145
|
+
facts.push(`A new Codex chat starts at about ${k(median([...firsts.values()]))} tokens.`);
|
|
146
|
+
}
|
|
147
|
+
if (!facts.length)
|
|
148
|
+
return null;
|
|
149
|
+
const level = grade(share, 0.2, 0.1, 0.03);
|
|
150
|
+
return {
|
|
151
|
+
id: "always-on",
|
|
152
|
+
level,
|
|
153
|
+
title: level === "ok" ? "Always-on context" : `Always-on context: ${pct(share)} of your Claude usage is re-sending the same setup`,
|
|
154
|
+
share,
|
|
155
|
+
facts,
|
|
156
|
+
fix: "The system prompt, every tool definition, MCP servers, skills and CLAUDE.md files go out with every single request. Turn off MCP servers and plugins a project doesn't use, deny unused built-in tools with permissions.deny in settings.json, and keep instruction files short.",
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
function cacheBreaks(claude) {
|
|
160
|
+
const total = sum(claude?.requests ?? [], (r) => r.cost);
|
|
161
|
+
if (!claude || total <= 0)
|
|
162
|
+
return null;
|
|
163
|
+
const cold = [];
|
|
164
|
+
const last = new Map();
|
|
165
|
+
for (const r of claude.requests) {
|
|
166
|
+
const prev = last.get(r.thread);
|
|
167
|
+
last.set(r.thread, r);
|
|
168
|
+
if (!prev)
|
|
169
|
+
continue;
|
|
170
|
+
const ttl = r.cacheWrite1h > 0 ? 3_600_000 : 300_000;
|
|
171
|
+
const written = r.cacheWrite5m + r.cacheWrite1h;
|
|
172
|
+
const gap = r.time - prev.time;
|
|
173
|
+
if (gap > ttl && written > 20_000)
|
|
174
|
+
cold.push({ r, gap, written, cost: costParts(r).write });
|
|
175
|
+
}
|
|
176
|
+
const spent = sum(cold, (c) => c.cost);
|
|
177
|
+
const share = spent / total;
|
|
178
|
+
const level = grade(share, 0.1, 0.04, 0.01);
|
|
179
|
+
const facts = [
|
|
180
|
+
`${cold.length} ${plural(cold.length, "time")} a chat was picked up after its cache had expired, so the whole chat was written to the cache again.`,
|
|
181
|
+
`That cost ${units(spent)} units, ${pct(share)} of your Claude usage.`,
|
|
182
|
+
];
|
|
183
|
+
const big = cold.sort((a, b) => b.written - a.written)[0];
|
|
184
|
+
if (big)
|
|
185
|
+
facts.push(`Biggest: ${big.r.project} on ${day(big.r.time)}, ${k(big.written)} tokens rewritten after a ${duration(big.gap)} break.`);
|
|
186
|
+
return {
|
|
187
|
+
id: "cache-breaks",
|
|
188
|
+
level,
|
|
189
|
+
title: level === "ok" ? "Cache after breaks" : `Coming back to big chats after a break cost ${pct(share)} of your Claude usage`,
|
|
190
|
+
share,
|
|
191
|
+
facts,
|
|
192
|
+
fix: "The cache keeps a chat for about an hour (5 minutes for some requests). After that, the next step pays to write the whole chat again. Before a long break, finish the step and continue in a fresh chat afterwards, or run /compact first so the rewrite is small.",
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
function helpers(claude) {
|
|
196
|
+
const total = sum(claude?.requests ?? [], (r) => r.cost);
|
|
197
|
+
if (!claude || total <= 0)
|
|
198
|
+
return null;
|
|
199
|
+
const types = shares(claude.requests.filter((r) => r.agent !== "main"), (r) => r.agent, total);
|
|
200
|
+
const share = sum(types, (t) => t.share);
|
|
201
|
+
if (!types.length)
|
|
202
|
+
return null;
|
|
203
|
+
const level = grade(share, 0.4, 0.25, 0.15);
|
|
204
|
+
return {
|
|
205
|
+
id: "helpers",
|
|
206
|
+
level,
|
|
207
|
+
title: level === "ok" ? "Helpers and subagents" : `Helpers and subagents: ${pct(share)} of your Claude usage`,
|
|
208
|
+
share,
|
|
209
|
+
facts: [`By type: ${types.slice(0, 5).map((t) => `${t.name} ${pct(t.share)}`).join(", ")}.`],
|
|
210
|
+
fix: "Each helper starts its own context with its own tools and instructions. Keep the types that save main-chat work, give them a cheaper model where the result holds up, and cap how many a single workflow can start.",
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
/** Points of the weekly Codex limit each turn used. */
|
|
214
|
+
function turnPoints(limits, since) {
|
|
215
|
+
const out = new Map();
|
|
216
|
+
for (const [s, d] of limitDeltas(limits, WEEK, since))
|
|
217
|
+
if (s.turn && s.limit === "codex")
|
|
218
|
+
out.set(s.turn, (out.get(s.turn) ?? 0) + d);
|
|
219
|
+
return out;
|
|
220
|
+
}
|
|
221
|
+
function chatStats(main, sessions) {
|
|
222
|
+
const chats = new Map();
|
|
223
|
+
for (const r of main) {
|
|
224
|
+
const c = chats.get(r.session) ?? { project: r.project, start: r.time, cost: 0, peak: 0, compactions: sessions.get(r.session)?.compactions.length ?? 0 };
|
|
225
|
+
c.cost += r.cost;
|
|
226
|
+
c.peak = Math.max(c.peak, r.context);
|
|
227
|
+
chats.set(r.session, c);
|
|
228
|
+
}
|
|
229
|
+
return [...chats.values()].sort((a, b) => b.cost - a.cost);
|
|
230
|
+
}
|
|
231
|
+
/** Context of the first request of each chat with at least three requests. */
|
|
232
|
+
function firstContexts(reqs) {
|
|
233
|
+
const count = new Map();
|
|
234
|
+
const first = new Map();
|
|
235
|
+
for (const r of reqs) {
|
|
236
|
+
count.set(r.session, (count.get(r.session) ?? 0) + 1);
|
|
237
|
+
if (!first.has(r.session))
|
|
238
|
+
first.set(r.session, r.context);
|
|
239
|
+
}
|
|
240
|
+
for (const [id, n] of count)
|
|
241
|
+
if (n < 3)
|
|
242
|
+
first.delete(id);
|
|
243
|
+
return first;
|
|
244
|
+
}
|
|
245
|
+
function unusedTools(sessions, perChar) {
|
|
246
|
+
const called = new Set(sessions.flatMap((s) => [...s.toolsCalled]));
|
|
247
|
+
const tools = new Map();
|
|
248
|
+
for (const s of sessions)
|
|
249
|
+
for (const t of s.tools)
|
|
250
|
+
if (!called.has(t.name))
|
|
251
|
+
tools.set(t.name, Math.max(tools.get(t.name) ?? 0, t.chars * perChar));
|
|
252
|
+
return [...tools].map(([name, tokens]) => ({ name, tokens })).sort((a, b) => b.tokens - a.tokens);
|
|
253
|
+
}
|
|
254
|
+
// Rough characters per token: prose, and JSON tool schemas.
|
|
255
|
+
const TEXT_CHARS = 3.5;
|
|
256
|
+
const JSON_CHARS = 3;
|
|
257
|
+
const SETUP_PARTS = {
|
|
258
|
+
tools: "tool definitions",
|
|
259
|
+
system: "system prompt",
|
|
260
|
+
instructions: "CLAUDE.md and rules",
|
|
261
|
+
skill_listing: "skills list",
|
|
262
|
+
invoked_skills: "skills in use",
|
|
263
|
+
deferred_tools_delta: "deferred tool list",
|
|
264
|
+
deferred_tools_record: "deferred tool list",
|
|
265
|
+
agent_listing_delta: "subagent list",
|
|
266
|
+
mcp_instructions_delta: "MCP instructions",
|
|
267
|
+
file: "attached files",
|
|
268
|
+
"first-message": "your first message",
|
|
269
|
+
};
|
|
270
|
+
function shares(reqs, key, total) {
|
|
271
|
+
const m = new Map();
|
|
272
|
+
for (const r of reqs)
|
|
273
|
+
m.set(key(r), (m.get(key(r)) ?? 0) + r.cost);
|
|
274
|
+
return [...m].map(([name, c]) => ({ name, share: c / total })).sort((a, b) => b.share - a.share);
|
|
275
|
+
}
|
|
276
|
+
function grade(share, high, medium, low) {
|
|
277
|
+
return share >= high ? "high" : share >= medium ? "medium" : share >= low ? "low" : "ok";
|
|
278
|
+
}
|
|
279
|
+
export function sum(xs, f) {
|
|
280
|
+
let s = 0;
|
|
281
|
+
for (const x of xs)
|
|
282
|
+
s += f(x);
|
|
283
|
+
return s;
|
|
284
|
+
}
|
|
285
|
+
function median(xs) {
|
|
286
|
+
const s = [...xs].sort((a, b) => a - b);
|
|
287
|
+
return s[Math.floor(s.length / 2)] ?? 0;
|
|
288
|
+
}
|
|
289
|
+
const plural = (n, one, many = `${one}s`) => (n === 1 ? one : many);
|
|
290
|
+
export const pct = (x) => (x > 0 && x < 0.005 ? "<1%" : `${Math.round(x * 100)}%`);
|
|
291
|
+
export const k = (n) => (n < 1000 ? String(Math.round(n)) : `${Math.round(n / 1000)}k`);
|
|
292
|
+
export const units = (x) => (x >= 100 ? x.toFixed(0) : x.toFixed(1));
|
|
293
|
+
export const day = (t) => new Date(t).toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
|
294
|
+
function duration(ms) {
|
|
295
|
+
const h = Math.floor(ms / 3_600_000);
|
|
296
|
+
if (h >= 48)
|
|
297
|
+
return `${Math.round(h / 24)}-day`;
|
|
298
|
+
if (h >= 1)
|
|
299
|
+
return `${h}-hour`;
|
|
300
|
+
return `${Math.round(ms / 60_000)}-minute`;
|
|
301
|
+
}
|
package/dist/claude.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { basename, join, sep } from "node:path";
|
|
3
|
+
import { jsonlFiles, readLines } from "./files.js";
|
|
4
|
+
import { projectName, sessionFor } from "./model.js";
|
|
5
|
+
import { calibrate, cost } from "./prices.js";
|
|
6
|
+
const NEEDLES = ['"type":"assistant"', '"compact_boundary"', '"api-request-shape"', '"type":"cost-state"'];
|
|
7
|
+
// Until a chat's first request, also read what Claude Code put into it.
|
|
8
|
+
const SETUP_NEEDLES = ['"type":"attachment"', '"type":"user"'];
|
|
9
|
+
/** Reads Claude Code's logs: <root>/projects/<folder>/<session>.jsonl plus <session>/subagents/**.jsonl for helpers. */
|
|
10
|
+
export async function readClaude(root, since) {
|
|
11
|
+
const projects = join(root, "projects");
|
|
12
|
+
const requests = new Map();
|
|
13
|
+
const sessions = new Map();
|
|
14
|
+
const costs = [];
|
|
15
|
+
for (const file of jsonlFiles(projects, since)) {
|
|
16
|
+
const parts = file.slice(projects.length + 1).split(sep);
|
|
17
|
+
const folder = parts[0] ?? "";
|
|
18
|
+
const isHelperFile = parts.length > 2;
|
|
19
|
+
const sessionId = isHelperFile ? parts[1] : basename(file, ".jsonl");
|
|
20
|
+
const helper = isHelperFile ? helperType(file, parts) : null;
|
|
21
|
+
let started = false;
|
|
22
|
+
let costState = null;
|
|
23
|
+
const keep = (line) => NEEDLES.some((n) => line.includes(n)) || (!started && !helper && SETUP_NEEDLES.some((n) => line.includes(n)));
|
|
24
|
+
for await (const r of readLines(file, keep)) {
|
|
25
|
+
if (r.type === "cost-state") {
|
|
26
|
+
costState = r;
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
const usage = r.type === "assistant" ? r.message?.usage : undefined;
|
|
30
|
+
const wasStarted = started;
|
|
31
|
+
if (usage)
|
|
32
|
+
started = true;
|
|
33
|
+
const time = Date.parse(r.timestamp);
|
|
34
|
+
if (!(time >= since))
|
|
35
|
+
continue;
|
|
36
|
+
const session = sessionFor(sessions, "claude", sessionId, projectName(r.cwd, folder), time);
|
|
37
|
+
if (r.cwd)
|
|
38
|
+
session.project = projectName(r.cwd, folder);
|
|
39
|
+
if (r.type === "assistant") {
|
|
40
|
+
const m = r.message ?? {};
|
|
41
|
+
for (const block of Array.isArray(m.content) ? m.content : []) {
|
|
42
|
+
if (block?.type === "tool_use" && block.name)
|
|
43
|
+
session.toolsCalled.add(block.name);
|
|
44
|
+
}
|
|
45
|
+
if (!usage || m.model === "<synthetic>")
|
|
46
|
+
continue;
|
|
47
|
+
const id = r.requestId ?? m.id;
|
|
48
|
+
const known = requests.get(id);
|
|
49
|
+
if (known) {
|
|
50
|
+
// Claude Code writes one record per content block, all carrying the same usage.
|
|
51
|
+
known.output = Math.max(known.output, usage.output_tokens ?? 0);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
const write = usage.cache_creation_input_tokens ?? 0;
|
|
55
|
+
const write1h = Math.min(write, usage.cache_creation?.ephemeral_1h_input_tokens ?? 0);
|
|
56
|
+
const req = {
|
|
57
|
+
source: "claude",
|
|
58
|
+
id,
|
|
59
|
+
session: sessionId,
|
|
60
|
+
thread: helper ? file : r.isSidechain ? `${sessionId}:side` : sessionId,
|
|
61
|
+
project: session.project,
|
|
62
|
+
time,
|
|
63
|
+
model: m.model ?? "unknown",
|
|
64
|
+
agent: helper ?? (r.isSidechain ? "helper" : "main"),
|
|
65
|
+
input: usage.input_tokens ?? 0,
|
|
66
|
+
cacheRead: usage.cache_read_input_tokens ?? 0,
|
|
67
|
+
cacheWrite5m: write - write1h,
|
|
68
|
+
cacheWrite1h: write1h,
|
|
69
|
+
output: usage.output_tokens ?? 0,
|
|
70
|
+
context: 0,
|
|
71
|
+
cost: 0,
|
|
72
|
+
};
|
|
73
|
+
req.context = req.input + req.cacheRead + write;
|
|
74
|
+
requests.set(id, req);
|
|
75
|
+
}
|
|
76
|
+
else if (r.type === "system" && r.subtype === "compact_boundary" && !helper) {
|
|
77
|
+
if (!session.compactions.some((c) => c.time === time))
|
|
78
|
+
session.compactions.push({ time, tokens: r.compactMetadata?.preTokens ?? 0 });
|
|
79
|
+
}
|
|
80
|
+
else if (r.type === "api-request-shape" && !helper && r.shape && !session.tools) {
|
|
81
|
+
session.tools = (r.shape.tools ?? []).map((t) => ({ name: String(t.name), chars: JSON.stringify(t).length }));
|
|
82
|
+
if (!wasStarted) {
|
|
83
|
+
addSetup(session, "system", JSON.stringify(r.shape.system ?? []).length);
|
|
84
|
+
addSetup(session, "tools", JSON.stringify(r.shape.tools ?? []).length);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
else if (r.type === "attachment" && !wasStarted) {
|
|
88
|
+
addSetup(session, r.attachment?.type ?? "other", textLength(r.rendered));
|
|
89
|
+
}
|
|
90
|
+
else if (r.type === "user" && !wasStarted) {
|
|
91
|
+
addSetup(session, "first-message", textLength(r.message?.content));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
for (const [model, u] of Object.entries(costState?.modelUsage ?? {})) {
|
|
95
|
+
costs.push({
|
|
96
|
+
model,
|
|
97
|
+
input: u.inputTokens ?? 0,
|
|
98
|
+
output: u.outputTokens ?? 0,
|
|
99
|
+
read: u.cacheReadInputTokens ?? 0,
|
|
100
|
+
write: u.cacheCreationInputTokens ?? 0,
|
|
101
|
+
searches: u.webSearchRequests ?? 0,
|
|
102
|
+
usd: u.costUSD ?? 0,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
calibrate(costs);
|
|
107
|
+
const list = [...requests.values()].sort((a, b) => a.time - b.time);
|
|
108
|
+
for (const r of list)
|
|
109
|
+
r.cost = cost(r);
|
|
110
|
+
return { requests: list, sessions };
|
|
111
|
+
}
|
|
112
|
+
function addSetup(session, part, chars) {
|
|
113
|
+
session.setup ??= new Map();
|
|
114
|
+
session.setup.set(part, (session.setup.get(part) ?? 0) + chars);
|
|
115
|
+
}
|
|
116
|
+
/** Characters of text in a message or a rendered attachment: a string, or a list of text blocks. Images don't count. */
|
|
117
|
+
function textLength(content) {
|
|
118
|
+
if (typeof content === "string")
|
|
119
|
+
return content.length;
|
|
120
|
+
if (!Array.isArray(content))
|
|
121
|
+
return 0;
|
|
122
|
+
let n = 0;
|
|
123
|
+
for (const b of content)
|
|
124
|
+
n += typeof b?.text === "string" ? b.text.length : typeof b?.content === "string" ? b.content.length : 0;
|
|
125
|
+
return n;
|
|
126
|
+
}
|
|
127
|
+
const metaCache = new Map();
|
|
128
|
+
function helperType(file, parts) {
|
|
129
|
+
if (parts.includes("workflows"))
|
|
130
|
+
return "workflow";
|
|
131
|
+
let type = metaCache.get(file);
|
|
132
|
+
if (!type) {
|
|
133
|
+
try {
|
|
134
|
+
type = String(JSON.parse(readFileSync(file.replace(/\.jsonl$/, ".meta.json"), "utf8")).agentType || "helper");
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
type = "helper";
|
|
138
|
+
}
|
|
139
|
+
metaCache.set(file, type);
|
|
140
|
+
}
|
|
141
|
+
return type;
|
|
142
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { parseArgs } from "node:util";
|
|
6
|
+
import { analyze } from "./checks.js";
|
|
7
|
+
import { readClaude } from "./claude.js";
|
|
8
|
+
import { readCodex } from "./codex.js";
|
|
9
|
+
import { render } from "./report.js";
|
|
10
|
+
const HELP = `vectiscode: find out why your Claude Code and Codex limits drain, and what to change.
|
|
11
|
+
|
|
12
|
+
Usage: npx vectiscode [options]
|
|
13
|
+
|
|
14
|
+
--days <n> look at the last n days (default 30)
|
|
15
|
+
--since <date> look at everything since a date, like 2026-10-01
|
|
16
|
+
--json print the full report as JSON
|
|
17
|
+
--claude-dir <dir> Claude Code folder (default $CLAUDE_CONFIG_DIR or ~/.claude)
|
|
18
|
+
--codex-dir <dir> Codex folder (default $CODEX_HOME or ~/.codex)
|
|
19
|
+
-v, --version print the version
|
|
20
|
+
-h, --help print this help
|
|
21
|
+
|
|
22
|
+
Everything runs on this computer. Your logs are read, never changed and never sent anywhere.`;
|
|
23
|
+
const { values } = parseArgs({
|
|
24
|
+
options: {
|
|
25
|
+
days: { type: "string" },
|
|
26
|
+
since: { type: "string" },
|
|
27
|
+
json: { type: "boolean" },
|
|
28
|
+
"claude-dir": { type: "string" },
|
|
29
|
+
"codex-dir": { type: "string" },
|
|
30
|
+
version: { type: "boolean", short: "v" },
|
|
31
|
+
help: { type: "boolean", short: "h" },
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
if (values.help) {
|
|
35
|
+
console.log(HELP);
|
|
36
|
+
}
|
|
37
|
+
else if (values.version) {
|
|
38
|
+
console.log(JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version);
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
const days = values.since ? null : Number(values.days ?? 30);
|
|
42
|
+
const since = values.since ? Date.parse(values.since) : Date.now() - days * 86_400_000;
|
|
43
|
+
if (!Number.isFinite(since)) {
|
|
44
|
+
console.error(`Could not read the date or number of days. Try --since 2026-10-01 or --days 7.`);
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
const claudeDir = values["claude-dir"] ?? process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude");
|
|
48
|
+
const codexDir = values["codex-dir"] ?? process.env.CODEX_HOME ?? join(homedir(), ".codex");
|
|
49
|
+
if (process.stderr.isTTY && !values.json)
|
|
50
|
+
process.stderr.write("Reading your logs...\r");
|
|
51
|
+
const [claude, codex] = await Promise.all([readClaude(claudeDir, since), readCodex(codexDir, since)]);
|
|
52
|
+
if (process.stderr.isTTY && !values.json)
|
|
53
|
+
process.stderr.write(" \r");
|
|
54
|
+
const report = analyze(claude, codex, since);
|
|
55
|
+
console.log(values.json ? JSON.stringify(report, null, 2) : render(report, days));
|
|
56
|
+
}
|
package/dist/codex.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { basename, join } from "node:path";
|
|
2
|
+
import { jsonlFiles, readLines } from "./files.js";
|
|
3
|
+
import { projectName, sessionFor } from "./model.js";
|
|
4
|
+
const NEEDLES = ['"session_meta"', '"turn_context"', '"token_count"'];
|
|
5
|
+
const keep = (line) => NEEDLES.some((n) => line.includes(n));
|
|
6
|
+
const JITTER = 3 * 3_600_000;
|
|
7
|
+
/** Reads Codex's logs: <root>/sessions/YYYY/MM/DD/rollout-*.jsonl. */
|
|
8
|
+
export async function readCodex(root, since) {
|
|
9
|
+
const requests = [];
|
|
10
|
+
const sessions = new Map();
|
|
11
|
+
const limits = [];
|
|
12
|
+
const seen = new Set();
|
|
13
|
+
for (const file of jsonlFiles(join(root, "sessions"), since)) {
|
|
14
|
+
let id = basename(file, ".jsonl");
|
|
15
|
+
let project = "unknown";
|
|
16
|
+
let model = "unknown";
|
|
17
|
+
let lastTotal = -1;
|
|
18
|
+
for await (const r of readLines(file, keep)) {
|
|
19
|
+
const p = r.payload ?? {};
|
|
20
|
+
if (r.type === "session_meta") {
|
|
21
|
+
id = p.id ?? p.session_id ?? id;
|
|
22
|
+
project = projectName(p.cwd, project);
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
if (r.type === "turn_context") {
|
|
26
|
+
model = p.model ?? model;
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
const time = Date.parse(r.timestamp);
|
|
30
|
+
if (p.type !== "token_count" || !(time >= since))
|
|
31
|
+
continue;
|
|
32
|
+
const session = sessionFor(sessions, "codex", id, project, time);
|
|
33
|
+
let turn;
|
|
34
|
+
const total = p.info?.total_token_usage?.total_tokens;
|
|
35
|
+
const last = p.info?.last_token_usage;
|
|
36
|
+
// The running total repeats when nothing new was sent, and resumed chats copy earlier events.
|
|
37
|
+
if (last && total !== lastTotal && !seen.has(`${id}:${total}`)) {
|
|
38
|
+
seen.add(`${id}:${total}`);
|
|
39
|
+
turn = `${id}:${total}`;
|
|
40
|
+
const input = last.input_tokens ?? 0;
|
|
41
|
+
const cached = Math.min(input, last.cached_input_tokens ?? 0);
|
|
42
|
+
requests.push({
|
|
43
|
+
source: "codex",
|
|
44
|
+
id: turn,
|
|
45
|
+
session: id,
|
|
46
|
+
thread: id,
|
|
47
|
+
project: session.project,
|
|
48
|
+
time,
|
|
49
|
+
model,
|
|
50
|
+
agent: "main",
|
|
51
|
+
input: input - cached,
|
|
52
|
+
cacheRead: cached,
|
|
53
|
+
cacheWrite5m: last.cache_write_input_tokens ?? 0,
|
|
54
|
+
cacheWrite1h: 0,
|
|
55
|
+
output: last.output_tokens ?? 0,
|
|
56
|
+
context: input,
|
|
57
|
+
cost: 0,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
lastTotal = total ?? lastTotal;
|
|
61
|
+
const rl = p.rate_limits;
|
|
62
|
+
for (const w of [rl?.primary, rl?.secondary]) {
|
|
63
|
+
if (!w || typeof w.used_percent !== "number")
|
|
64
|
+
continue;
|
|
65
|
+
limits.push({
|
|
66
|
+
time,
|
|
67
|
+
session: id,
|
|
68
|
+
turn,
|
|
69
|
+
limit: rl.limit_id ?? "codex",
|
|
70
|
+
windowMinutes: w.window_minutes,
|
|
71
|
+
used: w.used_percent,
|
|
72
|
+
resetsAt: w.resets_at * 1000,
|
|
73
|
+
plan: rl.plan_type ?? undefined,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
requests.sort((a, b) => a.time - b.time);
|
|
79
|
+
limits.sort((a, b) => a.time - b.time);
|
|
80
|
+
return { requests, sessions, limits };
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* How many percentage points of a limit window each turn used. A window is known by its reset time: people who
|
|
84
|
+
* switch between ChatGPT accounts have several running at once, and parallel chats report the same window with
|
|
85
|
+
* reset times up to an hour apart, sometimes with an older, lower reading. So each window counts up from its peak.
|
|
86
|
+
*/
|
|
87
|
+
export function limitDeltas(limits, windowMinutes, since) {
|
|
88
|
+
const deltas = new Map();
|
|
89
|
+
const windows = new Map();
|
|
90
|
+
for (const s of limits) {
|
|
91
|
+
if (s.windowMinutes !== windowMinutes)
|
|
92
|
+
continue;
|
|
93
|
+
const list = windows.get(s.limit) ?? [];
|
|
94
|
+
windows.set(s.limit, list);
|
|
95
|
+
let w = list.find((x) => Math.abs(x.resetsAt - s.resetsAt) <= JITTER);
|
|
96
|
+
if (!w) {
|
|
97
|
+
// A window that started before the logs being read began already has usage nobody here can place.
|
|
98
|
+
w = { resetsAt: s.resetsAt, peak: s.resetsAt - windowMinutes * 60_000 < since ? s.used : 0 };
|
|
99
|
+
list.push(w);
|
|
100
|
+
}
|
|
101
|
+
deltas.set(s, Math.max(0, s.used - w.peak));
|
|
102
|
+
w.peak = Math.max(w.peak, s.used);
|
|
103
|
+
}
|
|
104
|
+
return deltas;
|
|
105
|
+
}
|
|
106
|
+
/** How many different windows of this length were still open at `now`. */
|
|
107
|
+
export function openWindows(limits, windowMinutes, now) {
|
|
108
|
+
const open = [];
|
|
109
|
+
for (const s of limits) {
|
|
110
|
+
if (s.windowMinutes === windowMinutes && s.limit === "codex" && s.resetsAt > now && !open.some((r) => Math.abs(r - s.resetsAt) <= JITTER))
|
|
111
|
+
open.push(s.resetsAt);
|
|
112
|
+
}
|
|
113
|
+
return open.length;
|
|
114
|
+
}
|
package/dist/files.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { createReadStream, readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { basename, join } from "node:path";
|
|
3
|
+
import { createInterface } from "node:readline";
|
|
4
|
+
/** Every .jsonl log under `dir` changed since `since`, oldest first, so forks lose to the chat they copied. */
|
|
5
|
+
export function jsonlFiles(dir, since) {
|
|
6
|
+
let names;
|
|
7
|
+
try {
|
|
8
|
+
names = readdirSync(dir, { recursive: true, encoding: "utf8" });
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return [];
|
|
12
|
+
}
|
|
13
|
+
return names
|
|
14
|
+
.filter((n) => n.endsWith(".jsonl") && basename(n) !== "journal.jsonl")
|
|
15
|
+
.map((n) => ({ path: join(dir, n), stat: statSync(join(dir, n)) }))
|
|
16
|
+
.filter((f) => f.stat.mtimeMs >= since)
|
|
17
|
+
.sort((a, b) => a.stat.birthtimeMs - b.stat.birthtimeMs)
|
|
18
|
+
.map((f) => f.path);
|
|
19
|
+
}
|
|
20
|
+
/** Parses only the lines `keep` accepts, which skips most of a multi-gigabyte log unparsed. */
|
|
21
|
+
export async function* readLines(path, keep) {
|
|
22
|
+
const lines = createInterface({ input: createReadStream(path, { encoding: "utf8" }), crlfDelay: Infinity });
|
|
23
|
+
for await (const line of lines) {
|
|
24
|
+
if (!keep(line))
|
|
25
|
+
continue;
|
|
26
|
+
try {
|
|
27
|
+
yield JSON.parse(line);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// a line still being written
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
package/dist/model.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export function projectName(cwd, fallback) {
|
|
2
|
+
if (!cwd)
|
|
3
|
+
return fallback;
|
|
4
|
+
// Split on both separators: logs written on Windows can be read anywhere.
|
|
5
|
+
return cwd.replace(/[\\/]\.claude[\\/]worktrees[\\/].*$/, "").split(/[\\/]/).filter(Boolean).pop() || fallback;
|
|
6
|
+
}
|
|
7
|
+
export function sessionFor(sessions, source, id, project, time) {
|
|
8
|
+
let s = sessions.get(id);
|
|
9
|
+
if (!s) {
|
|
10
|
+
s = { source, id, project, start: time, end: time, compactions: [], toolsCalled: new Set() };
|
|
11
|
+
sessions.set(id, s);
|
|
12
|
+
}
|
|
13
|
+
if (time < s.start)
|
|
14
|
+
s.start = time;
|
|
15
|
+
if (time > s.end)
|
|
16
|
+
s.end = time;
|
|
17
|
+
return s;
|
|
18
|
+
}
|
package/dist/prices.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// API list prices, used to weigh tokens against each other. Subscriptions are not billed this way.
|
|
2
|
+
// The 5.5 rates reconcile with the cost records Claude Code writes into its own logs.
|
|
3
|
+
const CLAUDE = {
|
|
4
|
+
"opus-5-5": [4, 20, 0.2, 5, 8],
|
|
5
|
+
"sonnet-5-5": [2, 10, 0.2, 2.5, 4],
|
|
6
|
+
"haiku-5-5": [0.1, 0.5, 0.01, 0.125, 0.2],
|
|
7
|
+
"opus-4-6": [5, 25, 0.5, 6.25, 10],
|
|
8
|
+
"opus-4-5": [5, 25, 0.5, 6.25, 10],
|
|
9
|
+
"opus-4-1": [15, 75, 1.5, 18.75, 30],
|
|
10
|
+
"opus-4": [15, 75, 1.5, 18.75, 30],
|
|
11
|
+
"sonnet-4-5": [3, 15, 0.3, 3.75, 6],
|
|
12
|
+
"sonnet-4": [3, 15, 0.3, 3.75, 6],
|
|
13
|
+
"haiku-4-5": [1, 5, 0.1, 1.25, 2],
|
|
14
|
+
"3-7-sonnet": [3, 15, 0.3, 3.75, 6],
|
|
15
|
+
"3-5-sonnet": [3, 15, 0.3, 3.75, 6],
|
|
16
|
+
"3-5-haiku": [0.8, 4, 0.08, 1, 1.6],
|
|
17
|
+
};
|
|
18
|
+
// Unknown versions fall back to the newest price of their family.
|
|
19
|
+
const FAMILY = { opus: CLAUDE["opus-5-5"], sonnet: CLAUDE["sonnet-5-5"], haiku: CLAUDE["haiku-5-5"] };
|
|
20
|
+
export function priceOf(model) {
|
|
21
|
+
const id = model.replace(/^.*claude-/, "").replace(/\[.*\]$/, "").replace(/-\d{8}(-v\d+(:\d+)?)?$/, "");
|
|
22
|
+
return CLAUDE[id] ?? Object.entries(FAMILY).find(([family]) => id.includes(family))?.[1];
|
|
23
|
+
}
|
|
24
|
+
const scale = new Map();
|
|
25
|
+
/** Scales each model's prices to match the costs Claude Code recorded, so the numbers keep up when prices change. */
|
|
26
|
+
export function calibrate(records) {
|
|
27
|
+
const sums = new Map();
|
|
28
|
+
for (const c of records) {
|
|
29
|
+
const p = priceOf(c.model);
|
|
30
|
+
if (!p)
|
|
31
|
+
continue;
|
|
32
|
+
const s = sums.get(c.model) ?? { real: 0, table: 0 };
|
|
33
|
+
s.real += c.usd - c.searches * 0.01;
|
|
34
|
+
s.table += (c.input * p[0] + c.output * p[1] + c.read * p[2] + c.write * p[4]) / 1e6;
|
|
35
|
+
sums.set(c.model, s);
|
|
36
|
+
}
|
|
37
|
+
for (const [model, s] of sums)
|
|
38
|
+
if (s.table > 1)
|
|
39
|
+
scale.set(model, Math.min(5, Math.max(0.2, s.real / s.table)));
|
|
40
|
+
}
|
|
41
|
+
/** API-equivalent USD of each part of a request. */
|
|
42
|
+
export function costParts(r) {
|
|
43
|
+
const p = priceOf(r.model);
|
|
44
|
+
if (!p || r.source !== "claude")
|
|
45
|
+
return { input: 0, output: 0, read: 0, write: 0 };
|
|
46
|
+
const f = (scale.get(r.model) ?? 1) / 1e6;
|
|
47
|
+
return {
|
|
48
|
+
input: r.input * p[0] * f,
|
|
49
|
+
output: r.output * p[1] * f,
|
|
50
|
+
read: r.cacheRead * p[2] * f,
|
|
51
|
+
write: (r.cacheWrite5m * p[3] + r.cacheWrite1h * p[4]) * f,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
export function cost(r) {
|
|
55
|
+
const c = costParts(r);
|
|
56
|
+
return c.input + c.output + c.read + c.write;
|
|
57
|
+
}
|
|
58
|
+
/** USD per token of reading context from the cache. */
|
|
59
|
+
export function readPrice(model) {
|
|
60
|
+
return ((priceOf(model)?.[2] ?? 0) * (scale.get(model) ?? 1)) / 1e6;
|
|
61
|
+
}
|
package/dist/report.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { day, pct, units } from "./checks.js";
|
|
2
|
+
const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
3
|
+
const paint = (code) => (s) => (useColor ? `\x1b[${code}m${s}\x1b[0m` : s);
|
|
4
|
+
const bold = paint("1");
|
|
5
|
+
const dim = paint("2");
|
|
6
|
+
const LEVEL = { high: paint("31"), medium: paint("33"), low: paint("36"), ok: paint("32") };
|
|
7
|
+
const WIDTH = Math.min(process.stdout.columns || 100, 100);
|
|
8
|
+
const PAD = 13;
|
|
9
|
+
export function render(report, days) {
|
|
10
|
+
const out = [];
|
|
11
|
+
const range = days ? `last ${days} ${days === 1 ? "day" : "days"}` : `since ${day(report.since)}`;
|
|
12
|
+
out.push("", `${bold("vectiscode")} ${dim(`usage doctor · ${range}`)}`, "");
|
|
13
|
+
const c = report.claude;
|
|
14
|
+
if (c) {
|
|
15
|
+
out.push(row("Claude Code", `${units(c.units)} units over ${c.requests.toLocaleString("en-US")} requests in ${c.chats} chats`));
|
|
16
|
+
out.push(row("", `re-reading the chat ${pct(c.split.read)} · writing to cache ${pct(c.split.write)} · output ${pct(c.split.output)} · new input ${pct(c.split.input)}`));
|
|
17
|
+
out.push(row("", `by project: ${c.projects.map((p) => `${p.name} ${pct(p.share)}`).join(", ")}`));
|
|
18
|
+
}
|
|
19
|
+
const x = report.codex;
|
|
20
|
+
if (x) {
|
|
21
|
+
const plan = x.limits.find((l) => l.plan)?.plan;
|
|
22
|
+
out.push(row("Codex", `${x.turns.toLocaleString("en-US")} turns in ${x.chats} chats${plan ? ` · ${plan} plan` : ""}`));
|
|
23
|
+
const limits = [...x.limits].sort((a, b) => b.windowMinutes - a.windowMinutes || a.limit.localeCompare(b.limit));
|
|
24
|
+
const text = limits.map((l) => `${l.limit === "codex" ? "" : `${l.limit} `}${windowName(l.windowMinutes)} ${l.used}%${l.used ? ` used, resets ${when(l.resetsAt)}` : ""}`);
|
|
25
|
+
if (text.length)
|
|
26
|
+
out.push(row("", `latest reading: ${text.join(" · ")}`));
|
|
27
|
+
if (x.openWeekly > 1)
|
|
28
|
+
out.push(row("", dim(`Readings came from ${x.openWeekly} different weekly windows, usually a sign of more than one ChatGPT account. The reading above is the latest one.`)));
|
|
29
|
+
if (x.topChats.length)
|
|
30
|
+
out.push(row("", `biggest chats: ${x.topChats.map((s) => `${s.project} (${day(s.start)}) ${Math.round(s.points)}%`).join(", ")} of a weekly limit`));
|
|
31
|
+
}
|
|
32
|
+
if (!c && !x)
|
|
33
|
+
return `${out.join("\n")}No Claude Code or Codex logs found in that time.\n`;
|
|
34
|
+
if (c)
|
|
35
|
+
out.push("", dim(wrap("Units are API-equivalent dollars, used to weigh tokens against each other. They are not your bill.", 0)));
|
|
36
|
+
const problems = report.findings.filter((f) => f.level !== "ok");
|
|
37
|
+
const fine = report.findings.filter((f) => f.level === "ok");
|
|
38
|
+
if (problems.length)
|
|
39
|
+
out.push("", bold("FINDINGS"));
|
|
40
|
+
for (const f of problems) {
|
|
41
|
+
out.push("", `${LEVEL[f.level](`● ${f.level.toUpperCase().padEnd(6)}`)} ${bold(f.title)}`);
|
|
42
|
+
for (const fact of f.facts)
|
|
43
|
+
out.push(wrap(fact, 9));
|
|
44
|
+
out.push(wrap(`${bold("Fix:")} ${f.fix}`, 9));
|
|
45
|
+
}
|
|
46
|
+
if (fine.length) {
|
|
47
|
+
out.push("", bold("LOOKS FINE"), "");
|
|
48
|
+
for (const f of fine)
|
|
49
|
+
out.push(`${LEVEL.ok("✓")} ${f.title} ${dim(`(${pct(f.share)})`)}`);
|
|
50
|
+
}
|
|
51
|
+
out.push("");
|
|
52
|
+
return out.join("\n");
|
|
53
|
+
}
|
|
54
|
+
function row(label, text) {
|
|
55
|
+
return wrap(text, PAD, label ? bold(label.padEnd(PAD)) : " ".repeat(PAD));
|
|
56
|
+
}
|
|
57
|
+
/** Word-wraps `text` to the terminal width with a hanging indent. */
|
|
58
|
+
function wrap(text, indent, first = " ".repeat(indent)) {
|
|
59
|
+
const lines = [];
|
|
60
|
+
let line = "";
|
|
61
|
+
for (const word of text.split(" ")) {
|
|
62
|
+
if (line && visible(line).length + 1 + visible(word).length > WIDTH - indent) {
|
|
63
|
+
lines.push(line);
|
|
64
|
+
line = word;
|
|
65
|
+
}
|
|
66
|
+
else
|
|
67
|
+
line = line ? `${line} ${word}` : word;
|
|
68
|
+
}
|
|
69
|
+
lines.push(line);
|
|
70
|
+
return lines.map((l, i) => (i ? " ".repeat(indent) : first) + l).join("\n");
|
|
71
|
+
}
|
|
72
|
+
const visible = (s) => s.replace(/\x1b\[\d+m/g, "");
|
|
73
|
+
function windowName(minutes) {
|
|
74
|
+
if (minutes === 10_080)
|
|
75
|
+
return "weekly limit";
|
|
76
|
+
if (minutes % 60 === 0)
|
|
77
|
+
return `${minutes / 60}-hour limit`;
|
|
78
|
+
return `${minutes}-minute limit`;
|
|
79
|
+
}
|
|
80
|
+
function when(t) {
|
|
81
|
+
return new Date(t).toLocaleString("en-US", { weekday: "short", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", hour12: false });
|
|
82
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,41 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vectiscode",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Find out why your Claude Code and Codex limits drain, and what to change. Runs locally on your own logs.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"claude-code",
|
|
7
|
+
"codex",
|
|
8
|
+
"usage",
|
|
9
|
+
"rate-limits",
|
|
10
|
+
"tokens",
|
|
11
|
+
"cli"
|
|
12
|
+
],
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/vectiscode/vectiscode-cli.git"
|
|
17
|
+
},
|
|
18
|
+
"homepage": "https://github.com/vectiscode/vectiscode-cli#readme",
|
|
19
|
+
"bugs": "https://github.com/vectiscode/vectiscode-cli/issues",
|
|
20
|
+
"type": "module",
|
|
21
|
+
"bin": {
|
|
22
|
+
"vectiscode": "dist/cli.js"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"dist",
|
|
26
|
+
"!dist/**/*.test.js"
|
|
27
|
+
],
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=22"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"build": "tsc",
|
|
33
|
+
"test": "tsc && node --test \"dist/**/*.test.js\"",
|
|
34
|
+
"doctor": "tsc && node dist/cli.js",
|
|
35
|
+
"prepublishOnly": "npm test"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@types/node": "^22.20.5",
|
|
39
|
+
"typescript": "^7.0.2"
|
|
40
|
+
}
|
|
41
|
+
}
|