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.
Files changed (3) hide show
  1. package/README.md +4 -0
  2. package/package.json +1 -1
  3. 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "quotopsy",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Explains where your Claude Code usage went, weighted by token type. Local only: no network, no LLM calls.",
5
5
  "keywords": [
6
6
  "claude-code",
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 order.
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") return [["tool results", toolLabel(toolNames.get(b.tool_use_id) ?? "(unknown tool)"), chars]];
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
- const BOLD = 1, DIM = 2, CYAN = 36, YELLOW = 33, GREEN = 32;
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(CYAN, bar(r.share))} ${pct(r.share).padStart(6)} ${usd(r.weighted).padStart(10)}`);
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(CYAN, l)).join("\n")}\n ${err(DIM, "where did your Claude Code usage go?")}\n\n`);
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(CYAN, bar(done / total, 24))} ${done}/${total}`);
673
+ process.stderr.write(`\r Reading sessions ${err(GREEN, bar(done / total, 24))} ${done}/${total}`);
625
674
  };
626
675
 
627
676
  let summary;