savemytokens 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 (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +164 -0
  3. package/dist/adapters/claude-code/index.js +130 -0
  4. package/dist/adapters/claude-code/merge.js +90 -0
  5. package/dist/adapters/claude-code/parse.js +642 -0
  6. package/dist/adapters/claude-code/provider.js +105 -0
  7. package/dist/adapters/codex/index.js +74 -0
  8. package/dist/adapters/codex/parse.js +389 -0
  9. package/dist/adapters/codex/provider.js +171 -0
  10. package/dist/adapters/index.js +10 -0
  11. package/dist/adapters/pending.js +29 -0
  12. package/dist/adapters/types.js +1 -0
  13. package/dist/analyze/aggregate.js +180 -0
  14. package/dist/analyze/combine.js +11 -0
  15. package/dist/analyze/detectors.js +244 -0
  16. package/dist/analyze/index.js +29 -0
  17. package/dist/analyze/score.js +20 -0
  18. package/dist/cli-options.js +149 -0
  19. package/dist/cli.js +141 -0
  20. package/dist/collect.js +62 -0
  21. package/dist/commands/audit.js +74 -0
  22. package/dist/commands/control.js +654 -0
  23. package/dist/commands/hud.js +71 -0
  24. package/dist/commands/install.js +369 -0
  25. package/dist/commands/policy.js +93 -0
  26. package/dist/commands/privacy.js +28 -0
  27. package/dist/commands/set.js +83 -0
  28. package/dist/commands/theme.js +136 -0
  29. package/dist/commands/watch.js +135 -0
  30. package/dist/core/cost.js +24 -0
  31. package/dist/core/hash.js +0 -0
  32. package/dist/core/pricing.js +63 -0
  33. package/dist/core/resource.js +1 -0
  34. package/dist/core/tokens.js +32 -0
  35. package/dist/core/types.js +1 -0
  36. package/dist/hooks/nudge.js +111 -0
  37. package/dist/hooks/rules.js +14 -0
  38. package/dist/privacy/payload.js +22 -0
  39. package/dist/report/graph.js +162 -0
  40. package/dist/report/graphs.js +61 -0
  41. package/dist/report/render.js +183 -0
  42. package/dist/report/schedule.js +143 -0
  43. package/dist/report/settings.js +237 -0
  44. package/dist/report/views.js +418 -0
  45. package/dist/runtime/hook.mjs +234 -0
  46. package/dist/runtime/kernel.mjs +1472 -0
  47. package/dist/runtime/statusline.mjs +243 -0
  48. package/dist/scheduler/keys.js +112 -0
  49. package/dist/scheduler/plan.js +287 -0
  50. package/dist/storage/cache.js +38 -0
  51. package/dist/storage/paths.js +29 -0
  52. package/dist/storage/store.js +48 -0
  53. package/dist/util/ansi.js +35 -0
  54. package/dist/util/fmt.js +76 -0
  55. package/package.json +51 -0
@@ -0,0 +1,171 @@
1
+ import path from "node:path";
2
+ import { FIVE_HOUR_MS, WINDOW_MS, addSample, commitMeter, liveWindow, loadClaimants, loadMeter, loadQuota, openBuckets, saveQuota, scanNew, upsertClaimant, } from "../../runtime/kernel.mjs";
3
+ import { codexAdapter } from "./index.js";
4
+ export const ADAPTER_ID = "codex";
5
+ const WINDOW_LABELS = {
6
+ five_hour: "5-hour window",
7
+ seven_day: "7-day window",
8
+ spend_limit: "spend limit",
9
+ };
10
+ function windowKeyFor(minutes) {
11
+ if (minutes <= 0)
12
+ return null;
13
+ return minutes <= 24 * 60 ? "five_hour" : "seven_day";
14
+ }
15
+ function readLimits(payload, into) {
16
+ for (const slot of ["primary", "secondary"]) {
17
+ const limit = payload?.rate_limits?.[slot];
18
+ if (!limit || typeof limit.used_percent !== "number")
19
+ continue;
20
+ const key = windowKeyFor(Number(limit.window_minutes ?? 0));
21
+ if (!key)
22
+ continue;
23
+ into[key] = { usedPercent: limit.used_percent, resetsAt: Number(limit.resets_at ?? 0) };
24
+ }
25
+ }
26
+ export function resourcesFor(now = Date.now()) {
27
+ const quota = loadQuota(ADAPTER_ID);
28
+ const keys = ["five_hour", "seven_day"];
29
+ return keys.map((key) => {
30
+ const window = liveWindow(quota, key, now);
31
+ return {
32
+ id: `${ADAPTER_ID}:${key}`,
33
+ adapter: ADAPTER_ID,
34
+ label: WINDOW_LABELS[key],
35
+ unit: "observed_usage",
36
+ window: { kind: "rolling", ms: WINDOW_MS[key] ?? FIVE_HOUR_MS, ...(window ? { resetsAt: window.resetsAt } : {}) },
37
+ capacity: window
38
+ ? { amount: 100, confidence: "published", asOf: quota?.at ?? now }
39
+ : { amount: 0, confidence: "unknown" },
40
+ usedPercent: window ? window.usedPercent : null,
41
+ };
42
+ });
43
+ }
44
+ export function sweep(since, now = Date.now()) {
45
+ if (!codexAdapter.detect())
46
+ return;
47
+ const known = new Map(loadClaimants(ADAPTER_ID).map((claimant) => [claimant.id, claimant]));
48
+ const windows = {};
49
+ let latestLimitAt = 0;
50
+ for (const ref of codexAdapter.discover({ since, project: null })) {
51
+ const id = path.basename(ref.file, ".jsonl");
52
+ const record = loadMeter(ADAPTER_ID, id);
53
+ const buckets = openBuckets(record);
54
+ const seen = new Set(record.seen);
55
+ const fresh = [];
56
+ scanNew(record, [ref.file], (line) => {
57
+ if (line.length < 2 || line.charCodeAt(0) !== 123)
58
+ return;
59
+ const wantsUsage = line.includes('"token_count"');
60
+ const wantsMeta = line.includes('"session_meta"');
61
+ const wantsPrompt = line.includes('"user_message"');
62
+ if (!wantsUsage && !wantsMeta && !wantsPrompt)
63
+ return;
64
+ let entry;
65
+ try {
66
+ entry = JSON.parse(line);
67
+ }
68
+ catch {
69
+ return;
70
+ }
71
+ const at = typeof entry.timestamp === "string" ? Date.parse(entry.timestamp) : NaN;
72
+ const stamp = Number.isFinite(at) ? at : now;
73
+ const payload = entry.payload ?? {};
74
+ if (entry.type === "session_meta" || payload.type === "session_meta") {
75
+ const cwd = payload.cwd ?? entry.payload?.payload?.cwd;
76
+ if (typeof cwd === "string" && !record.project)
77
+ record.project = cwd;
78
+ return;
79
+ }
80
+ if (payload.type === "user_message") {
81
+ const text = typeof payload.message === "string" ? payload.message : "";
82
+ if (text && !text.startsWith("<"))
83
+ record.prompt = text.replace(/\s+/g, " ").trim().slice(0, 120);
84
+ return;
85
+ }
86
+ if (payload.type !== "token_count")
87
+ return;
88
+ if (payload.rate_limits && stamp >= latestLimitAt) {
89
+ latestLimitAt = stamp;
90
+ readLimits(payload, windows);
91
+ }
92
+ const last = payload.info?.last_token_usage;
93
+ if (!last)
94
+ return;
95
+ const signature = `${stamp}:${last.input_tokens ?? 0}:${last.output_tokens ?? 0}:${last.cached_input_tokens ?? 0}`;
96
+ if (seen.has(signature))
97
+ return;
98
+ seen.add(signature);
99
+ fresh.push(signature);
100
+ const cached = last.cached_input_tokens ?? 0;
101
+ addSample(buckets, stamp, {
102
+ input: Math.max(0, (last.input_tokens ?? 0) - cached),
103
+ output: last.output_tokens ?? 0,
104
+ cacheWrite: 0,
105
+ cacheRead: cached,
106
+ });
107
+ if (stamp > record.lastAt)
108
+ record.lastAt = stamp;
109
+ });
110
+ commitMeter(ADAPTER_ID, id, record, buckets, fresh, now);
111
+ const existing = known.get(id);
112
+ const project = record.project || "";
113
+ upsertClaimant(ADAPTER_ID, id, {
114
+ project: existing?.project || project,
115
+ label: existing?.label || (project ? path.basename(project) : id.slice(-8)),
116
+ prompt: record.prompt || existing?.prompt || "",
117
+ ...(record.lastAt > 0 ? { lastSeen: record.lastAt } : {}),
118
+ ...(existing ? {} : { startedAt: record.buckets[0]?.[0] ?? now, state: "active" }),
119
+ });
120
+ }
121
+ if (Object.keys(windows).length > 0) {
122
+ const previous = loadQuota(ADAPTER_ID);
123
+ const reading = {
124
+ at: latestLimitAt || now,
125
+ source: "rollout",
126
+ windows,
127
+ ...(previous?.history ? { history: previous.history } : {}),
128
+ };
129
+ saveQuota(ADAPTER_ID, reading);
130
+ }
131
+ }
132
+ export const codexMeter = {
133
+ async sample(since, until = Date.now()) {
134
+ const out = [];
135
+ for (const claimant of loadClaimants(ADAPTER_ID)) {
136
+ const record = loadMeter(ADAPTER_ID, claimant.id);
137
+ for (const row of record.buckets) {
138
+ const at = row[0] ?? 0;
139
+ if (at < since || at > until)
140
+ continue;
141
+ const input = row[1] ?? 0;
142
+ const output = row[2] ?? 0;
143
+ const cacheWrite = row[3] ?? 0;
144
+ const cacheRead = row[4] ?? 0;
145
+ const weighted = input + output * 5 + cacheWrite * 1.25 + cacheRead * 0.1;
146
+ out.push({
147
+ claimantId: claimant.id,
148
+ amount: weighted,
149
+ at,
150
+ metrics: { tokens: input + output + cacheWrite + cacheRead, weighted, requests: row[5] ?? 0 },
151
+ });
152
+ }
153
+ }
154
+ return out.sort((a, b) => a.at - b.at);
155
+ },
156
+ };
157
+ export const codexEnforcer = {
158
+ supports: [],
159
+ async apply(_claimant, level) {
160
+ return { applied: false, message: `Codex has no hook to inject through, so ${level} is not available.` };
161
+ },
162
+ };
163
+ export const codexProvider = {
164
+ id: ADAPTER_ID,
165
+ label: "Codex",
166
+ detect: () => codexAdapter.detect(),
167
+ resources: (now) => resourcesFor(now),
168
+ sweep,
169
+ meter: codexMeter,
170
+ enforcer: codexEnforcer,
171
+ };
@@ -0,0 +1,10 @@
1
+ import { claudeCodeAdapter } from "./claude-code/index.js";
2
+ import { codexAdapter } from "./codex/index.js";
3
+ import { pendingAdapters } from "./pending.js";
4
+ export const adapters = [claudeCodeAdapter, codexAdapter, ...pendingAdapters];
5
+ export function activeAdapters() {
6
+ return adapters.filter((a) => a.supported && a.detect());
7
+ }
8
+ export function pendingDetected() {
9
+ return pendingAdapters.filter((a) => a.detect());
10
+ }
@@ -0,0 +1,29 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ function pendingAdapter(id, label, dataDir, reason) {
5
+ return {
6
+ id,
7
+ label,
8
+ supported: false,
9
+ dataDir,
10
+ reason,
11
+ detect() {
12
+ try {
13
+ return fs.statSync(dataDir).isDirectory();
14
+ }
15
+ catch {
16
+ return false;
17
+ }
18
+ },
19
+ discover() {
20
+ return [];
21
+ },
22
+ async parse() {
23
+ return null;
24
+ },
25
+ };
26
+ }
27
+ export const geminiAdapter = pendingAdapter("gemini", "Gemini CLI", path.join(os.homedir(), ".gemini"), "it logs prompts but no token counts, so there is nothing to measure");
28
+ export const grokAdapter = pendingAdapter("grok", "Grok", path.join(os.homedir(), ".grok"), "its local store is a title/cwd search index with no token counts");
29
+ export const pendingAdapters = [geminiAdapter, grokAdapter];
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,180 @@
1
+ import { usd } from "../core/pricing.js";
2
+ import { addUsage, emptyUsage, rawTokens, weigh } from "../core/tokens.js";
3
+ function merger() {
4
+ const map = new Map();
5
+ return {
6
+ add(key, weighted, count, chars, extra, combine) {
7
+ const existing = map.get(key);
8
+ if (!existing) {
9
+ map.set(key, { key, weighted, count, chars, sessions: 1, extra });
10
+ return;
11
+ }
12
+ existing.weighted += weighted;
13
+ existing.count += count;
14
+ existing.chars += chars;
15
+ existing.sessions += 1;
16
+ existing.extra = combine(existing.extra, extra);
17
+ },
18
+ top(limit) {
19
+ return [...map.values()].sort((a, b) => b.weighted - a.weighted).slice(0, limit);
20
+ },
21
+ };
22
+ }
23
+ export function aggregate(corpus) {
24
+ const usage = emptyUsage();
25
+ const models = new Map();
26
+ const reads = merger();
27
+ const outputs = merger();
28
+ const hooks = merger();
29
+ const writes = merger();
30
+ const failures = merger();
31
+ let tasks = 0;
32
+ let turns = 0;
33
+ let toolCalls = 0;
34
+ let bloatTurns = 0;
35
+ let bloatTokens = 0;
36
+ let bloatWeighted = 0;
37
+ let bloatSessions = 0;
38
+ let peakContext = 0;
39
+ let coldSessions = 0;
40
+ let coldWeighted = 0;
41
+ let apiErrors = 0;
42
+ let interruptions = 0;
43
+ let toolErrors = 0;
44
+ let sidechainTurns = 0;
45
+ let sidechainWeighted = 0;
46
+ let compactions = 0;
47
+ let searchChars = 0;
48
+ let rateLimitHits = 0;
49
+ let totalUsd = 0;
50
+ const allTasks = [];
51
+ const projects = new Map();
52
+ const outcomes = { completed: 0, interrupted: 0, failed: 0 };
53
+ for (const session of corpus.sessions) {
54
+ addUsage(usage, session.usage);
55
+ turns += session.turns;
56
+ toolCalls += session.toolCalls;
57
+ tasks += session.tasks.length;
58
+ apiErrors += session.apiErrors;
59
+ interruptions += session.interruptions;
60
+ toolErrors += session.toolErrors;
61
+ sidechainTurns += session.sidechainTurns;
62
+ sidechainWeighted += session.sidechainWeighted;
63
+ compactions += session.compactions;
64
+ searchChars += session.searchChars;
65
+ rateLimitHits += session.rateLimitHits ?? 0;
66
+ for (const task of session.tasks) {
67
+ allTasks.push(task);
68
+ totalUsd += task.usd;
69
+ const name = (task.project || session.project).split("/").pop() || "unknown";
70
+ const entry = projects.get(name) ?? { name, usd: 0, tasks: 0 };
71
+ entry.usd += task.usd;
72
+ entry.tasks += 1;
73
+ projects.set(name, entry);
74
+ }
75
+ bloatTurns += session.bloatTurns;
76
+ bloatTokens += session.bloatTokens;
77
+ bloatWeighted += session.bloatWeighted;
78
+ if (session.bloatTurns > 0)
79
+ bloatSessions++;
80
+ if (session.peakContext > peakContext)
81
+ peakContext = session.peakContext;
82
+ if (session.coldStart) {
83
+ coldSessions++;
84
+ coldWeighted += session.usage.cacheWrite * 1.25;
85
+ }
86
+ for (const task of session.tasks)
87
+ outcomes[task.outcome]++;
88
+ for (const model of session.models) {
89
+ let entry = models.get(model.model);
90
+ if (!entry) {
91
+ entry = { model: model.model, turns: 0, usage: emptyUsage(), weighted: 0, trivialTurns: 0, trivialWeighted: 0 };
92
+ models.set(model.model, entry);
93
+ }
94
+ entry.turns += model.turns;
95
+ addUsage(entry.usage, model.usage);
96
+ entry.weighted += model.weighted;
97
+ entry.trivialTurns += model.trivialTurns;
98
+ entry.trivialWeighted += model.trivialWeighted;
99
+ }
100
+ for (const read of session.reads) {
101
+ if (read.redundantReads === 0)
102
+ continue;
103
+ reads.add(read.path, read.redundantWeighted, read.reads, read.chars, { reads: read.reads, redundantReads: read.redundantReads, redundantChars: read.redundantChars }, (a, b) => ({
104
+ reads: a.reads + b.reads,
105
+ redundantReads: a.redundantReads + b.redundantReads,
106
+ redundantChars: a.redundantChars + b.redundantChars,
107
+ }));
108
+ }
109
+ for (const output of session.outputs) {
110
+ outputs.add(output.label, output.excessWeighted, output.calls, output.chars, { tool: output.tool, calls: output.calls, maxChars: output.maxChars, excessChars: output.excessChars }, (a, b) => ({
111
+ tool: a.tool,
112
+ calls: a.calls + b.calls,
113
+ maxChars: Math.max(a.maxChars, b.maxChars),
114
+ excessChars: a.excessChars + b.excessChars,
115
+ }));
116
+ }
117
+ for (const hook of session.hooks) {
118
+ if (hook.weighted <= 0)
119
+ continue;
120
+ hooks.add(hook.name, hook.weighted, hook.events, hook.chars, { events: hook.events, sample: hook.sample ?? "", command: hook.command ?? "" }, (x, y) => ({ events: x.events + y.events, sample: x.sample || y.sample, command: x.command || y.command }));
121
+ }
122
+ for (const write of session.writes) {
123
+ if (write.writes < 2)
124
+ continue;
125
+ writes.add(write.path, write.rewrittenWeighted, write.writes, write.rewrittenChars, { writes: write.writes, edits: write.edits, rewrittenChars: write.rewrittenChars }, (a, b) => ({
126
+ writes: a.writes + b.writes,
127
+ edits: a.edits + b.edits,
128
+ rewrittenChars: a.rewrittenChars + b.rewrittenChars,
129
+ }));
130
+ }
131
+ for (const failure of session.failures) {
132
+ failures.add(failure.label, failure.weighted, failure.failures, failure.chars, { tool: failure.tool, failures: failure.failures }, (a, b) => ({ tool: a.tool, failures: a.failures + b.failures }));
133
+ }
134
+ }
135
+ return {
136
+ totals: {
137
+ usage,
138
+ weighted: weigh(usage),
139
+ usd: totalUsd || usd("claude-opus-5", usage),
140
+ tokens: rawTokens(usage),
141
+ freshTokens: usage.input + usage.output + usage.cacheWrite,
142
+ cacheReadTokens: usage.cacheRead,
143
+ sessions: corpus.sessions.length,
144
+ tasks,
145
+ turns,
146
+ toolCalls,
147
+ },
148
+ models: [...models.values()].sort((a, b) => b.weighted - a.weighted),
149
+ reads: reads.top(12),
150
+ outputs: outputs.top(12),
151
+ hooks: hooks.top(12),
152
+ writes: writes.top(12),
153
+ failures: failures.top(12),
154
+ bloat: { turns: bloatTurns, tokens: bloatTokens, weighted: bloatWeighted, sessions: bloatSessions, peak: peakContext },
155
+ coldSessions,
156
+ coldWeighted,
157
+ apiErrors,
158
+ interruptions,
159
+ toolErrors,
160
+ sidechainTurns,
161
+ sidechainWeighted,
162
+ compactions,
163
+ outcomes,
164
+ searchChars,
165
+ rateLimitHits,
166
+ allTasks,
167
+ topTasks: allTasks.filter((t) => t.turns > 0).sort((a, b) => b.usd - a.usd).slice(0, 5),
168
+ deadCarry: (() => {
169
+ const dead = allTasks.filter((t) => t.carriedIsDead).sort((a, b) => b.carriedUsd - a.carriedUsd);
170
+ const tokens = dead.reduce((sum, t) => sum + t.carriedContext * t.turns, 0);
171
+ return {
172
+ tasks: dead.slice(0, 8),
173
+ usd: dead.reduce((sum, t) => sum + t.carriedUsd, 0),
174
+ weighted: tokens * 0.1,
175
+ tokens,
176
+ };
177
+ })(),
178
+ projects: [...projects.values()].sort((a, b) => b.usd - a.usd),
179
+ };
180
+ }
@@ -0,0 +1,11 @@
1
+ export const MAX_COMBINED_WASTE = 0.45;
2
+ export const OVERLAP_DISCOUNT = 0.5;
3
+ export function combinedWaste(findings) {
4
+ if (findings.length === 0)
5
+ return 0;
6
+ const sorted = [...findings].sort((a, b) => b.wasteRatio - a.wasteRatio);
7
+ const [first, ...rest] = sorted;
8
+ const head = first?.wasteRatio ?? 0;
9
+ const tail = rest.reduce((sum, f) => sum + f.wasteRatio, 0) * OVERLAP_DISCOUNT;
10
+ return Math.min(MAX_COMBINED_WASTE, head + tail);
11
+ }
@@ -0,0 +1,244 @@
1
+ import { DEAD_CARRY_TOKENS } from "../adapters/claude-code/parse.js";
2
+ import { bytes, compactNumber, money, plural, shortPath } from "../util/fmt.js";
3
+ const MIN_RATIO = 0.004;
4
+ const MIN_USD = 1;
5
+ const RECOVERABLE_ROUNDTRIPS = 0.4;
6
+ const MIN_TRIVIAL_TURNS = 20;
7
+ function usdPerWeighted(agg) {
8
+ return agg.totals.weighted > 0 ? agg.totals.usd / agg.totals.weighted : 0;
9
+ }
10
+ function finding(agg, input) {
11
+ const total = agg.totals.weighted;
12
+ if (total <= 0)
13
+ return null;
14
+ const wasteRatio = input.wastedWeighted / total;
15
+ const wastedUsd = input.wastedWeighted * usdPerWeighted(agg);
16
+ if (wasteRatio < MIN_RATIO || wastedUsd < MIN_USD)
17
+ return null;
18
+ return { ...input, wasteRatio, wastedUsd };
19
+ }
20
+ function receipt(task, amount, note) {
21
+ const project = (task.project || "").split("/").pop() || "unknown";
22
+ return `${money(amount).padStart(5)} ${project.padEnd(11).slice(0, 11)} ${note.padEnd(18)} "${task.prompt}"`;
23
+ }
24
+ const deadCarry = (agg) => {
25
+ const dead = agg.deadCarry;
26
+ if (dead.tasks.length === 0)
27
+ return null;
28
+ const count = agg.allTasks.filter((t) => t.carriedIsDead).length;
29
+ const worst = dead.tasks[0];
30
+ if (!worst)
31
+ return null;
32
+ return finding(agg, {
33
+ id: "dead-carry",
34
+ actor: "you",
35
+ title: "Finished work still riding along in context",
36
+ confidence: "measured",
37
+ effort: "habit",
38
+ wastedWeighted: dead.weighted,
39
+ measured: [
40
+ `${count} ${plural(count, "task")} started with more than ${compactNumber(DEAD_CARRY_TOKENS)} tokens of earlier work already in context`,
41
+ `none of them re-opened a single file from that earlier work, and their prompts named their own subject`,
42
+ `that context was re-read on every turn: ${compactNumber(dead.tokens)} tokens`,
43
+ ],
44
+ receipts: dead.tasks.slice(0, 3).map((t) => receipt(t, t.carriedUsd, `carried ${compactNumber(t.carriedContext)} × ${t.turns}t`)),
45
+ fix: `Press Ctrl+C and start a new session (or /clear) when the next thing you type is not about the last thing you did. ` +
46
+ `Those tasks paid to re-read finished work on every single turn and never once looked at it again.`,
47
+ });
48
+ };
49
+ const hookNoise = (agg) => {
50
+ if (agg.hooks.length === 0)
51
+ return null;
52
+ const wasted = agg.hooks.reduce((sum, h) => sum + h.weighted, 0);
53
+ const top = agg.hooks[0];
54
+ if (!top)
55
+ return null;
56
+ const events = agg.hooks.reduce((sum, h) => sum + h.extra.events, 0);
57
+ return finding(agg, {
58
+ id: "hook-noise",
59
+ actor: "you",
60
+ title: "Hook output injected into context",
61
+ confidence: "estimated",
62
+ effort: "one-time",
63
+ wastedWeighted: wasted,
64
+ measured: [
65
+ `${events} hook ${plural(events, "event")} printed ${bytes(agg.hooks.reduce((s, h) => s + h.chars, 0))} into context`,
66
+ `worst: ${top.key}, the same bytes repeated ${top.extra.events}×`,
67
+ ],
68
+ receipts: [
69
+ top.extra.command ? `hook command: ${top.extra.command}` : "",
70
+ top.extra.sample ? `every call pastes: ${top.extra.sample}…` : "",
71
+ "read that payload. If the model has no use for it, it is pure cost",
72
+ ].filter(Boolean),
73
+ fix: top.extra.command.includes("CLAUDE_PLUGIN_ROOT")
74
+ ? `That hook belongs to a plugin, not to your settings.json, so you cannot pipe it away there. Turn the plugin off in the enabledPlugins block of ~/.claude/settings.json, or raise it with the plugin author. Its plain-text stdout lands in the transcript and is re-read on every later turn.`
75
+ : `Hook stdout that is not structured JSON lands in the transcript, so every later turn re-reads it. If that payload is for your terminal rather than for the model, append \` >/dev/null\` to the ${top.key} hook command in ~/.claude/settings.json. Keep it if the model acts on it.`,
76
+ detail: agg.hooks.slice(0, 5).map((h) => `${h.key} · ${h.extra.events}× · ${bytes(h.chars)}`),
77
+ });
78
+ };
79
+ const repeatedReads = (agg) => {
80
+ if (agg.reads.length === 0)
81
+ return null;
82
+ const wasted = agg.reads.reduce((sum, r) => sum + r.weighted, 0);
83
+ const redundantReads = agg.reads.reduce((sum, r) => sum + r.extra.redundantReads, 0);
84
+ const redundantChars = agg.reads.reduce((sum, r) => sum + r.extra.redundantChars, 0);
85
+ const top = agg.reads[0];
86
+ if (!top || redundantReads === 0)
87
+ return null;
88
+ return finding(agg, {
89
+ id: "repeated-reads",
90
+ actor: "you",
91
+ title: "The same file sent again, unchanged",
92
+ confidence: "estimated",
93
+ effort: "one-time",
94
+ wastedWeighted: wasted,
95
+ measured: [
96
+ `${redundantReads} identical ${plural(redundantReads, "re-read")} across ${agg.reads.length} ${plural(agg.reads.length, "file")} (${bytes(redundantChars)})`,
97
+ `worst: ${shortPath(top.key)}, ${top.extra.redundantReads}× after the first read`,
98
+ ],
99
+ fix: `${shortPath(top.key)} was byte-identical every time it was re-sent. If subagents each read it, put it in CLAUDE.md instead. ` +
100
+ `that costs one cache write per session rather than one full copy per agent.`,
101
+ detail: agg.reads.slice(0, 5).map((r) => `${shortPath(r.key)} · ${r.extra.redundantReads}× · ${bytes(r.extra.redundantChars)}`),
102
+ });
103
+ };
104
+ const largeOutput = (agg) => {
105
+ if (agg.outputs.length === 0)
106
+ return null;
107
+ const wasted = agg.outputs.reduce((sum, o) => sum + o.weighted, 0);
108
+ const calls = agg.outputs.reduce((sum, o) => sum + o.extra.calls, 0);
109
+ const chars = agg.outputs.reduce((sum, o) => sum + o.chars, 0);
110
+ const top = agg.outputs[0];
111
+ if (!top)
112
+ return null;
113
+ const fixFor = (tool, label) => {
114
+ if (tool === "Read")
115
+ return `Ask for the line range you need instead of the whole file.`;
116
+ if (tool === "Grep" || tool === "Glob")
117
+ return `Match on file names only, or scope it to one directory.`;
118
+ if (tool === "Bash")
119
+ return `Pipe it down: \`${label} 2>&1 | tail -40\`, or write the log to a file and grep it.`;
120
+ if (tool === "TaskOutput" || tool === "Agent" || tool === "Workflow")
121
+ return `Ask the subagent for its conclusion, not its transcript. Delegating only pays off if the bulk stays out of your context.`;
122
+ if (tool === "WebFetch" || tool === "WebSearch")
123
+ return `Name the fact you need in the fetch prompt so the page comes back summarised.`;
124
+ return `Ask for a narrower result. The whole payload rides along for the rest of the session.`;
125
+ };
126
+ return finding(agg, {
127
+ id: "large-output",
128
+ actor: "claude",
129
+ title: "Oversized command output",
130
+ confidence: "estimated",
131
+ effort: "habit",
132
+ wastedWeighted: wasted,
133
+ measured: [
134
+ `${calls} tool ${plural(calls, "result")} over 10 KB returned ${bytes(chars)}`,
135
+ `worst: ${shortPath(top.key)}, ${top.extra.calls}×, largest ${bytes(top.extra.maxChars)}`,
136
+ ],
137
+ fix: `${shortPath(top.key)} put ${bytes(top.chars)} into context across ${top.extra.calls} ${plural(top.extra.calls, "run")}. ${fixFor(top.extra.tool, shortPath(top.key))}`,
138
+ detail: agg.outputs.slice(0, 5).map((o) => `${shortPath(o.key)} · ${o.extra.calls}× · ${bytes(o.chars)}`),
139
+ });
140
+ };
141
+ const failedTools = (agg) => {
142
+ if (agg.failures.length === 0)
143
+ return null;
144
+ const wasted = agg.failures.reduce((sum, f) => sum + f.weighted, 0);
145
+ const total = agg.failures.reduce((sum, f) => sum + f.extra.failures, 0);
146
+ const top = agg.failures[0];
147
+ if (!top || total === 0)
148
+ return null;
149
+ return finding(agg, {
150
+ id: "failed-tools",
151
+ actor: "you",
152
+ title: "Failed and interrupted commands",
153
+ confidence: "estimated",
154
+ effort: "habit",
155
+ wastedWeighted: wasted,
156
+ measured: [
157
+ `${total} failed tool ${plural(total, "call")} returned ${bytes(agg.failures.reduce((s, f) => s + f.chars, 0))} of error output`,
158
+ `worst: ${shortPath(top.key)}, failed ${top.extra.failures}×`,
159
+ ],
160
+ fix: `${shortPath(top.key)} failed ${top.extra.failures}×. Each failure paid for its error dump and then for the retry that read it. Get it green in a terminal once, or wrap it so the agent sees one line instead of a stack trace.`,
161
+ detail: agg.failures.slice(0, 5).map((f) => `${shortPath(f.key)} · ${f.extra.failures} ${plural(f.extra.failures, "failure")} · ${bytes(f.chars)}`),
162
+ });
163
+ };
164
+ const writeChurn = (agg) => {
165
+ if (agg.writes.length === 0)
166
+ return null;
167
+ const wasted = agg.writes.reduce((sum, w) => sum + w.weighted, 0);
168
+ const top = agg.writes[0];
169
+ if (!top)
170
+ return null;
171
+ return finding(agg, {
172
+ id: "write-churn",
173
+ actor: "claude",
174
+ title: "Files rewritten whole instead of edited",
175
+ confidence: "estimated",
176
+ effort: "habit",
177
+ wastedWeighted: wasted,
178
+ measured: [
179
+ `${agg.writes.length} ${plural(agg.writes.length, "file")} written end-to-end more than once (${bytes(agg.writes.reduce((s, w) => s + w.extra.rewrittenChars, 0))})`,
180
+ `worst: ${shortPath(top.key)}, ${top.extra.writes} full writes${top.extra.edits > 0 ? ` and ${top.extra.edits} edits` : ""}`,
181
+ ],
182
+ fix: `${shortPath(top.key)} was rewritten in full ${top.extra.writes}×. A full rewrite is billed as output tokens, the most expensive kind, 5× input. Ask for targeted edits once a file exists.`,
183
+ detail: agg.writes.slice(0, 5).map((w) => `${shortPath(w.key)} · ${w.extra.writes} writes · ${bytes(w.extra.rewrittenChars)}`),
184
+ });
185
+ };
186
+ const coldCache = (agg) => {
187
+ if (agg.coldSessions < 3)
188
+ return null;
189
+ return finding(agg, {
190
+ id: "cold-cache",
191
+ actor: "you",
192
+ title: "Sessions dropped before the cache paid off",
193
+ confidence: "measured",
194
+ effort: "habit",
195
+ wastedWeighted: agg.coldWeighted,
196
+ measured: [
197
+ `${agg.coldSessions} ${plural(agg.coldSessions, "session")} ended within three turns`,
198
+ `each paid a full cache write at 1.25× that was never read back at 0.1×`,
199
+ ],
200
+ fix: `Starting a session re-uploads your CLAUDE.md, tools and skills before anything useful happens. Keep quick questions in a session you already have open.`,
201
+ });
202
+ };
203
+ const highContextRoundTrips = (agg) => {
204
+ const premium = agg.models.filter((m) => m.trivialTurns > 0);
205
+ const trivialWeighted = premium.reduce((sum, m) => sum + m.trivialWeighted, 0);
206
+ const trivialTurns = premium.reduce((sum, m) => sum + m.trivialTurns, 0);
207
+ if (trivialTurns < MIN_TRIVIAL_TURNS)
208
+ return null;
209
+ const avgContext = trivialTurns > 0 ? Math.round(trivialWeighted / trivialTurns / 0.1) : 0;
210
+ return finding(agg, {
211
+ id: "roundtrips",
212
+ actor: "claude",
213
+ title: "Cheap actions taken at expensive context",
214
+ confidence: "estimated",
215
+ effort: "habit",
216
+ wastedWeighted: trivialWeighted * RECOVERABLE_ROUNDTRIPS,
217
+ measured: [
218
+ `${trivialTurns} ${plural(trivialTurns, "turn")} did nothing but run one command or read one file`,
219
+ `each still re-read the whole conversation, roughly ${compactNumber(avgContext)} tokens per turn`,
220
+ ],
221
+ fix: `This is not about the model being too good for the job. A cheap turn costs the same as a hard one because both re-read everything. ` +
222
+ `Cut the number of round trips: chain shell commands into one call, and hand multi-step digging to a subagent, which starts near-empty and returns only its answer.`,
223
+ });
224
+ };
225
+ export const detectors = [
226
+ deadCarry,
227
+ hookNoise,
228
+ repeatedReads,
229
+ largeOutput,
230
+ highContextRoundTrips,
231
+ failedTools,
232
+ writeChurn,
233
+ coldCache,
234
+ ];
235
+ export function runDetectors(agg) {
236
+ const findings = [];
237
+ for (const detector of detectors) {
238
+ const result = detector(agg);
239
+ if (result)
240
+ findings.push(result);
241
+ }
242
+ const certainty = (f) => (f.confidence === "measured" ? 1 : 0.7);
243
+ return findings.sort((a, b) => b.wastedUsd * certainty(b) - a.wastedUsd * certainty(a));
244
+ }
@@ -0,0 +1,29 @@
1
+ import { aggregate } from "./aggregate.js";
2
+ import { combinedWaste } from "./combine.js";
3
+ import { runDetectors } from "./detectors.js";
4
+ import { scoreAudit } from "./score.js";
5
+ export const AUDIT_VERSION = 1;
6
+ export function analyze(corpus, ranAt = Date.now()) {
7
+ const agg = aggregate(corpus);
8
+ const findings = runDetectors(agg);
9
+ const wasteRatio = combinedWaste(findings);
10
+ const { score, breakdown } = scoreAudit(agg, wasteRatio);
11
+ const upliftRatio = wasteRatio > 0 ? 1 / (1 - wasteRatio) - 1 : 0;
12
+ return {
13
+ version: AUDIT_VERSION,
14
+ ranAt,
15
+ scope: corpus.scope,
16
+ totals: agg.totals,
17
+ findings,
18
+ score,
19
+ scoreBreakdown: breakdown,
20
+ wasteRatio,
21
+ upliftRatio,
22
+ models: agg.models,
23
+ outcomes: agg.outcomes,
24
+ rateLimitHits: agg.rateLimitHits,
25
+ topTasks: agg.topTasks,
26
+ projects: agg.projects,
27
+ };
28
+ }
29
+ export { aggregate } from "./aggregate.js";