quotopsy 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/package.json +1 -1
- package/quotopsy.mjs +60 -11
package/README.md
CHANGED
|
@@ -7,6 +7,10 @@ estimates which part of the context each token belongs to: tool schemas,
|
|
|
7
7
|
system prompt, MCP server instructions, tool results, history, output. It then
|
|
8
8
|
lists findings the totals do not show, such as cache rebuilds after idle gaps.
|
|
9
9
|
|
|
10
|
+
The report has five parts: what to fix (ranked), where it went (by category),
|
|
11
|
+
by tool (each tool or MCP server's schema, calls and results added up), by
|
|
12
|
+
model, and by thread (the main conversation and each subagent).
|
|
13
|
+
|
|
10
14
|
- Runs locally. No network calls, no LLM calls, no dependencies.
|
|
11
15
|
- One readable file: [quotopsy.mjs](quotopsy.mjs).
|
|
12
16
|
- Every attribution is an estimate and is labeled as one.
|
package/package.json
CHANGED
package/quotopsy.mjs
CHANGED
|
@@ -116,6 +116,11 @@ export function analyze(root, { redact = false, debug: debugOn = false, onProgre
|
|
|
116
116
|
function toolGroup(name) {
|
|
117
117
|
return isMcp(name) ? MCP + alias("server", name.split("__")[1]) : "built-in tools";
|
|
118
118
|
}
|
|
119
|
+
// The row a tool's costs add up under: its own for a built-in tool, its
|
|
120
|
+
// server's for an MCP tool.
|
|
121
|
+
function culpritOf(name) {
|
|
122
|
+
return isMcp(name) ? toolGroup(name) : name;
|
|
123
|
+
}
|
|
119
124
|
// Built-in tool names are public; MCP tool names can describe private systems.
|
|
120
125
|
function toolLabel(name) {
|
|
121
126
|
return isMcp(name) ? alias("mcp tool", name) : name;
|
|
@@ -146,14 +151,18 @@ export function analyze(root, { redact = false, debug: debugOn = false, onProgre
|
|
|
146
151
|
return out;
|
|
147
152
|
}
|
|
148
153
|
|
|
149
|
-
// One logged message -> [category, sub, chars] segments, in prompt
|
|
154
|
+
// One logged message -> [category, sub, chars, culprit] segments, in prompt
|
|
155
|
+
// order. `culprit` is [tool or server, which of its costs], when there is one.
|
|
150
156
|
function blobSegments(msg, toolNames) {
|
|
151
157
|
const blocks = typeof msg.content === "string" ? [{ type: "text", text: msg.content }] : (Array.isArray(msg.content) ? msg.content : []).filter((b) => b && typeof b === "object");
|
|
152
158
|
return blocks.flatMap((b) => {
|
|
153
159
|
const chars = blockChars(b);
|
|
154
160
|
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")
|
|
161
|
+
if (msg.role === "assistant") return [["assistant history", b.type, chars, b.type === "tool_use" ? [culpritOf(String(b.name ?? "unknown")), "calling"] : null]];
|
|
162
|
+
if (b.type === "tool_result") {
|
|
163
|
+
const name = toolNames.get(b.tool_use_id) ?? "(unknown tool)";
|
|
164
|
+
return [["tool results", toolLabel(name), chars, [culpritOf(name), "results"]]];
|
|
165
|
+
}
|
|
157
166
|
return [["user messages", (b.text ?? "").startsWith("<system-reminder>") ? "injected reminders" : "typed or pasted", chars]];
|
|
158
167
|
});
|
|
159
168
|
}
|
|
@@ -215,7 +224,7 @@ export function analyze(root, { redact = false, debug: debugOn = false, onProgre
|
|
|
215
224
|
// A response whose request was not logged is usage without a known cause.
|
|
216
225
|
const logged = new Set(requests.map((r) => r.id));
|
|
217
226
|
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 };
|
|
227
|
+
return { blobs, shapes, requests, responses, orphans, toolNames, turns, recordTypes };
|
|
219
228
|
}
|
|
220
229
|
|
|
221
230
|
const cats = new Map(); // category -> { read, write, fresh, output, weighted, subs }
|
|
@@ -253,6 +262,20 @@ export function analyze(root, { redact = false, debug: debugOn = false, onProgre
|
|
|
253
262
|
const calls = new Map(); // tool group or tool name -> call count
|
|
254
263
|
const loadedIn = new Map(); // tool group -> requests that carried its schemas
|
|
255
264
|
const rebuilds = [];
|
|
265
|
+
// Per tool or MCP server: what its schema, writing and re-reading its calls,
|
|
266
|
+
// and keeping its results in context cost. ESTIMATES, like every attribution.
|
|
267
|
+
const culprits = new Map();
|
|
268
|
+
const blame = ([who, field], weighted) => {
|
|
269
|
+
const c = culprits.get(who) ?? { schema: 0, calling: 0, results: 0 };
|
|
270
|
+
c[field] += weighted; culprits.set(who, c);
|
|
271
|
+
};
|
|
272
|
+
// Output spent writing tool calls: the response's non-thinking output, split
|
|
273
|
+
// across its text and tool-call blocks by size.
|
|
274
|
+
const blameOutput = (blocks = [], cost) => {
|
|
275
|
+
const sized = blocks.filter((b) => b.type !== "thinking" && b.type !== "redacted_thinking").map((b) => [b, blockChars(b)]);
|
|
276
|
+
const all = sized.reduce((n, [, c]) => n + c, 0);
|
|
277
|
+
for (const [b, c] of sized) if (b.type === "tool_use" && all) blame([culpritOf(String(b.name ?? "unknown")), "calling"], cost * c / all);
|
|
278
|
+
};
|
|
256
279
|
|
|
257
280
|
// Structure-only diagnostics for --debug. Sizes, counts and type names; the
|
|
258
281
|
// only names are tool and section labels, which --redact replaces.
|
|
@@ -304,6 +327,7 @@ export function analyze(root, { redact = false, debug: debugOn = false, onProgre
|
|
|
304
327
|
bump(cat, "fresh input", "fresh", u.fresh, u.fresh * w.fresh);
|
|
305
328
|
bump("output", "thinking", "output", u.thinking, u.thinking * w.output);
|
|
306
329
|
bump("output", "text and tool calls", "output", u.output - u.thinking, (u.output - u.thinking) * w.output);
|
|
330
|
+
blameOutput(s.turns.get(id), (u.output - u.thinking) * w.output);
|
|
307
331
|
const writeCost = u.write5m * w.write5m + u.write1h * w.write1h;
|
|
308
332
|
spend(byModel, modelName(model), u.fresh * w.fresh + writeCost + u.read * w.read, u.output * w.output);
|
|
309
333
|
spend(byThread, sidechain ? "subagent: (type not logged)" : "main thread", u.fresh * w.fresh + writeCost + u.read * w.read, u.output * w.output);
|
|
@@ -344,6 +368,7 @@ export function analyze(root, { redact = false, debug: debugOn = false, onProgre
|
|
|
344
368
|
const u = splitUsage(res.usage), w = rates(res.model, res.usage);
|
|
345
369
|
bump("output", "thinking", "output", u.thinking, u.thinking * w.output);
|
|
346
370
|
bump("output", "text and tool calls", "output", u.output - u.thinking, (u.output - u.thinking) * w.output);
|
|
371
|
+
blameOutput(s.turns.get(res.id), (u.output - u.thinking) * w.output);
|
|
347
372
|
const inputCost = u.fresh * w.fresh + u.write5m * w.write5m + u.write1h * w.write1h + u.read * w.read;
|
|
348
373
|
spend(byModel, modelName(res.model), inputCost, u.output * w.output);
|
|
349
374
|
spend(byThread, threadOf(req.querySource), inputCost, u.output * w.output);
|
|
@@ -353,8 +378,9 @@ export function analyze(root, { redact = false, debug: debugOn = false, onProgre
|
|
|
353
378
|
const tools = (Array.isArray(shape.tools) ? shape.tools : []).filter((t) => t && typeof t === "object").map((t) => [String(t.name ?? "unknown"), t]);
|
|
354
379
|
const system = typeof shape.system === "string" ? [{ text: shape.system }] : Array.isArray(shape.system) ? shape.system : [];
|
|
355
380
|
for (const g of new Set(tools.map(([name]) => toolGroup(name)))) count(loadedIn, g);
|
|
381
|
+
for (const [name] of tools) if (!isMcp(name)) count(loadedIn, name);
|
|
356
382
|
const segs = [
|
|
357
|
-
...tools.map(([name, t]) => [toolGroup(name), toolLabel(name), JSON.stringify(t).length]),
|
|
383
|
+
...tools.map(([name, t]) => [toolGroup(name), toolLabel(name), JSON.stringify(t).length, [culpritOf(name), "schema"]]),
|
|
358
384
|
...system.flatMap((b) => sections(String(b?.text ?? "")).map(([sec, c]) => ["system prompt", sec, c])),
|
|
359
385
|
];
|
|
360
386
|
// A message that cannot be found leaves a hole of unknown size.
|
|
@@ -405,12 +431,13 @@ export function analyze(root, { redact = false, debug: debugOn = false, onProgre
|
|
|
405
431
|
const writeRate = u.write ? (u.write5m * w.write5m + u.write1h * w.write1h) / u.write : 0;
|
|
406
432
|
const bands = [["read", u.read, w.read], ["write", u.write, writeRate], ["fresh", u.fresh, w.fresh]];
|
|
407
433
|
let pos = 0;
|
|
408
|
-
for (const [cat, sub, c] of segs) {
|
|
434
|
+
for (const [cat, sub, c, culprit] of segs) {
|
|
409
435
|
const end = pos + c * perChar;
|
|
410
436
|
let lo = 0;
|
|
411
437
|
for (const [kind, size, rate] of bands) {
|
|
412
438
|
const overlap = Math.max(0, Math.min(end, lo + size) - Math.max(pos, lo));
|
|
413
439
|
if (overlap) bump(cat, sub, kind, overlap, overlap * rate);
|
|
440
|
+
if (overlap && culprit) blame(culprit, overlap * rate);
|
|
414
441
|
lo += size;
|
|
415
442
|
}
|
|
416
443
|
pos = end;
|
|
@@ -492,6 +519,13 @@ export function analyze(root, { redact = false, debug: debugOn = false, onProgre
|
|
|
492
519
|
const ranked = (map) => [...map.entries()]
|
|
493
520
|
.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
521
|
.sort((a, b) => b.cost - a.cost);
|
|
522
|
+
const byTool = [...culprits.entries()].map(([who, c]) => {
|
|
523
|
+
const cost = c.schema + c.calling + c.results;
|
|
524
|
+
return {
|
|
525
|
+
name: who.startsWith(MCP) ? who.slice(MCP.length) : who, kind: who.startsWith(MCP) ? "mcp server" : "tool",
|
|
526
|
+
calls: calls.get(who) ?? 0, loadedIn: loadedIn.get(who) ?? 0, schema: c.schema, calling: c.calling, results: c.results, cost, share: share(cost),
|
|
527
|
+
};
|
|
528
|
+
}).sort((a, b) => b.cost - a.cost);
|
|
495
529
|
const cpt = stats.charsPerToken.sort((a, b) => a - b);
|
|
496
530
|
const q = (p) => cpt[Math.min(cpt.length - 1, Math.floor(p * cpt.length))];
|
|
497
531
|
// Requests whose reconstructed prompt is far from the billed token count.
|
|
@@ -508,7 +542,7 @@ export function analyze(root, { redact = false, debug: debugOn = false, onProgre
|
|
|
508
542
|
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
543
|
claudeCodeVersions: Object.fromEntries(stats.versions), querySources: Object.fromEntries(stats.sources),
|
|
510
544
|
totalWeighted: total, categories: rows, cacheRebuilds: rebuilds, findings,
|
|
511
|
-
byModel: ranked(byModel), byThread: ranked(byThread),
|
|
545
|
+
byModel: ranked(byModel), byThread: ranked(byThread), byTool,
|
|
512
546
|
injectedByType: Object.fromEntries([...attachments].sort((a, b) => b[1].chars - a[1].chars)),
|
|
513
547
|
};
|
|
514
548
|
if (debugOn) {
|
|
@@ -526,7 +560,8 @@ export function analyze(root, { redact = false, debug: debugOn = false, onProgre
|
|
|
526
560
|
const noColor = globalThis.Deno ? globalThis.Deno.noColor : "NO_COLOR" in process.env;
|
|
527
561
|
const paint = (stream) => (code, text) => (stream.isTTY && !noColor ? `\x1b[${code}m${text}\x1b[0m` : text);
|
|
528
562
|
const out = paint(process.stdout), err = paint(process.stderr);
|
|
529
|
-
|
|
563
|
+
// Same accents as quotopsy.com: green for the brand and bars, amber for costs.
|
|
564
|
+
const BOLD = 1, DIM = 2, YELLOW = 33, GREEN = 32;
|
|
530
565
|
const BANNER = ["┌─┐ ┬ ┬┌─┐┌┬┐┌─┐┌─┐┌─┐┬ ┬", "│─┼┐│ ││ │ │ │ │├─┘└─┐└┬┘", "└─┘└└─┘└─┘ ┴ └─┘┴ └─┘ ┴ "];
|
|
531
566
|
const bar = (share, width = 20) => "█".repeat(Math.round(share * width)).padEnd(width, "░");
|
|
532
567
|
const num = (n) => Math.round(n).toLocaleString("en-US");
|
|
@@ -574,7 +609,7 @@ function printReport(summary, redact) {
|
|
|
574
609
|
const table = summary.categories.filter((r) => !small.includes(r));
|
|
575
610
|
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
611
|
for (const r of table) {
|
|
577
|
-
console.log(` ${title(r.name).slice(0, 30).padEnd(30)} ${out(
|
|
612
|
+
console.log(` ${title(r.name).slice(0, 30).padEnd(30)} ${out(GREEN, bar(r.share))} ${pct(r.share).padStart(6)} ${usd(r.weighted).padStart(10)}`);
|
|
578
613
|
if (r.share < 0.05 || r.subs.length < 2) continue;
|
|
579
614
|
for (const sub of r.subs.slice(0, 3)) {
|
|
580
615
|
console.log(out(DIM, ` ${pct(sub.share).padStart(6)} ${sub.name}${sub.calls != null ? ` (${num(sub.calls)} calls)` : ""}`));
|
|
@@ -582,6 +617,19 @@ function printReport(summary, redact) {
|
|
|
582
617
|
}
|
|
583
618
|
console.log();
|
|
584
619
|
|
|
620
|
+
// One row per tool or MCP server: everything it cost, wherever it shows up
|
|
621
|
+
// in the table above.
|
|
622
|
+
if (summary.byTool.length) {
|
|
623
|
+
heading("BY TOOL", "calls schema calling results share API $");
|
|
624
|
+
const shown = summary.byTool.filter((r) => r.share >= 0.001).slice(0, 8);
|
|
625
|
+
for (const r of shown) {
|
|
626
|
+
const name = r.kind === "mcp server" ? `${r.name} (MCP)` : r.name;
|
|
627
|
+
console.log(` ${(name.length > 20 ? name.slice(0, 19) + "…" : name).padEnd(20)}${num(r.calls).padStart(7)}${usd(r.schema).padStart(9)}${usd(r.calling).padStart(9)}${usd(r.results).padStart(9)}${pct(r.share).padStart(9)}${usd(r.cost).padStart(11)}`);
|
|
628
|
+
}
|
|
629
|
+
if (summary.byTool.length > shown.length) console.log(out(DIM, ` and ${summary.byTool.length - shown.length} smaller ones`));
|
|
630
|
+
console.log();
|
|
631
|
+
}
|
|
632
|
+
|
|
585
633
|
// Who spent it: shown only when there is more than one to compare.
|
|
586
634
|
for (const [name, list] of [["BY MODEL", summary.byModel], ["BY THREAD", summary.byThread]]) {
|
|
587
635
|
if (list.length < 2) continue;
|
|
@@ -596,6 +644,7 @@ function printReport(summary, redact) {
|
|
|
596
644
|
heading("HOW TO READ THIS");
|
|
597
645
|
const notes = [
|
|
598
646
|
"Every figure is an estimate. Usage is priced at published API rates per model; how weekly limits weight it is not published.",
|
|
647
|
+
"By tool: schema is its definition loaded in every request; calling is writing its calls and re-reading them later; results is its output kept in context.",
|
|
599
648
|
...Object.entries(summary.assumedPricing).map(([model, as]) => `No published price known for ${model}; priced as ${as}.`),
|
|
600
649
|
`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
650
|
`Claude Code versions: ${Object.entries(summary.claudeCodeVersions).map(([v, c]) => `${v} (${num(c.attributed + c.unattributed)})`).join(", ")}.`,
|
|
@@ -617,11 +666,11 @@ function main() {
|
|
|
617
666
|
if (!process.stderr.isTTY) return;
|
|
618
667
|
if (!bannerShown) {
|
|
619
668
|
bannerShown = true;
|
|
620
|
-
process.stderr.write(`\n${BANNER.map((l) => " " + err(
|
|
669
|
+
process.stderr.write(`\n${BANNER.map((l) => " " + err(GREEN, l)).join("\n")}\n ${err(DIM, "where did your Claude Code usage go?")}\n\n`);
|
|
621
670
|
}
|
|
622
671
|
if (Date.now() - lastDraw < 60 && done < total) return;
|
|
623
672
|
lastDraw = Date.now();
|
|
624
|
-
process.stderr.write(`\r Reading sessions ${err(
|
|
673
|
+
process.stderr.write(`\r Reading sessions ${err(GREEN, bar(done / total, 24))} ${done}/${total}`);
|
|
625
674
|
};
|
|
626
675
|
|
|
627
676
|
let summary;
|