tktuner 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/dist/cli.js ADDED
@@ -0,0 +1,2608 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli/index.ts
4
+ import { Command } from "commander";
5
+
6
+ // package.json
7
+ var package_default = {
8
+ name: "tktuner",
9
+ version: "0.2.0",
10
+ description: "Local-first token usage analytics and cost optimization for Claude Code power users. Stop paying Fable prices for Haiku work.",
11
+ license: "MIT",
12
+ type: "module",
13
+ bin: {
14
+ tktuner: "dist/cli.js"
15
+ },
16
+ files: [
17
+ "dist",
18
+ "README.md",
19
+ "LICENSE"
20
+ ],
21
+ engines: {
22
+ node: ">=20"
23
+ },
24
+ scripts: {
25
+ build: "tsup",
26
+ dev: "tsx src/cli/index.ts",
27
+ test: "vitest run",
28
+ "test:watch": "vitest",
29
+ typecheck: "tsc --noEmit",
30
+ prepublishOnly: "pnpm test && pnpm build"
31
+ },
32
+ keywords: [
33
+ "claude-code",
34
+ "claude",
35
+ "tokens",
36
+ "usage",
37
+ "analytics",
38
+ "cost",
39
+ "cli"
40
+ ],
41
+ repository: {
42
+ type: "git",
43
+ url: "https://github.com/tktuner/tktuner.git"
44
+ },
45
+ dependencies: {
46
+ "better-sqlite3": "^12.2.0",
47
+ commander: "^14.0.0",
48
+ picocolors: "^1.1.1"
49
+ },
50
+ devDependencies: {
51
+ "@tktuner/core": "workspace:*",
52
+ "@types/better-sqlite3": "^7.6.13",
53
+ "@types/node": "^24.0.0",
54
+ tsup: "^8.5.0",
55
+ tsx: "^4.20.0",
56
+ typescript: "^5.6.3",
57
+ vitest: "^3.2.0"
58
+ }
59
+ };
60
+
61
+ // src/lib/version.ts
62
+ var CLI_VERSION = package_default.version;
63
+
64
+ // src/cli/login.ts
65
+ import { spawn } from "child_process";
66
+ import { hostname, userInfo } from "os";
67
+ import pc from "picocolors";
68
+
69
+ // src/lib/api-client.ts
70
+ import { gzipSync } from "zlib";
71
+ var ApiError = class extends Error {
72
+ constructor(status, code, message) {
73
+ super(message);
74
+ this.status = status;
75
+ this.code = code;
76
+ }
77
+ status;
78
+ code;
79
+ };
80
+ async function apiRequest(method, apiUrl, path, opts = {}) {
81
+ const headers = { accept: "application/json" };
82
+ if (opts.token) headers.authorization = `Bearer ${opts.token}`;
83
+ let body;
84
+ if (opts.body !== void 0) {
85
+ headers["content-type"] = "application/json";
86
+ const raw = JSON.stringify(opts.body);
87
+ if (opts.gzip) {
88
+ headers["content-encoding"] = "gzip";
89
+ body = gzipSync(Buffer.from(raw, "utf8"));
90
+ } else {
91
+ body = raw;
92
+ }
93
+ }
94
+ const res = await fetch(`${apiUrl.replace(/\/$/, "")}${path}`, { method, headers, body });
95
+ let json = {};
96
+ try {
97
+ json = await res.json();
98
+ } catch {
99
+ }
100
+ return { status: res.status, json };
101
+ }
102
+ async function apiCall(method, apiUrl, path, opts = {}) {
103
+ const { status, json } = await apiRequest(method, apiUrl, path, opts);
104
+ if (status >= 200 && status < 300) return json;
105
+ const err = json.error ?? {};
106
+ throw new ApiError(status, err.code ?? "UNKNOWN", err.message ?? `HTTP ${status}`);
107
+ }
108
+
109
+ // src/lib/credentials.ts
110
+ import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
111
+ import { join as join2 } from "path";
112
+
113
+ // src/lib/paths.ts
114
+ import { homedir } from "os";
115
+ import { join } from "path";
116
+ function dataDir() {
117
+ const xdg = process.env.XDG_DATA_HOME;
118
+ const base = xdg && xdg.trim() !== "" ? xdg : join(homedir(), ".local", "share");
119
+ return join(base, "tktuner");
120
+ }
121
+
122
+ // src/lib/credentials.ts
123
+ function credentialsPath() {
124
+ return join2(dataDir(), "credentials.json");
125
+ }
126
+ function readCredentials() {
127
+ try {
128
+ const raw = readFileSync(credentialsPath(), "utf8");
129
+ const parsed = JSON.parse(raw);
130
+ if (!parsed.apiUrl || !parsed.deviceToken || !parsed.deviceId) return null;
131
+ return parsed;
132
+ } catch {
133
+ return null;
134
+ }
135
+ }
136
+ function writeCredentials(creds) {
137
+ mkdirSync(dataDir(), { recursive: true });
138
+ const path = credentialsPath();
139
+ writeFileSync(path, `${JSON.stringify(creds, null, 2)}
140
+ `, { mode: 384 });
141
+ chmodSync(path, 384);
142
+ }
143
+ function deleteCredentials() {
144
+ if (existsSync(credentialsPath())) rmSync(credentialsPath());
145
+ }
146
+
147
+ // src/cli/login.ts
148
+ var DEFAULT_API_URL = "https://tktuner.com";
149
+ var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
150
+ function openBrowser(url) {
151
+ const candidates = process.platform === "darwin" ? [["open", url]] : process.platform === "win32" ? [["cmd", "/c", "start", "", url]] : [
152
+ ["wslview", url],
153
+ ["xdg-open", url]
154
+ ];
155
+ for (const [cmd, ...args] of candidates) {
156
+ try {
157
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true });
158
+ child.on("error", () => {
159
+ });
160
+ child.unref();
161
+ return;
162
+ } catch {
163
+ }
164
+ }
165
+ }
166
+ async function login(opts) {
167
+ const existing = readCredentials();
168
+ if (existing) {
169
+ console.log(
170
+ `Already logged in (device ${pc.bold(existing.deviceName)} \u2192 ${existing.apiUrl}).
171
+ Run ${pc.bold("tktuner logout")} first to switch accounts.`
172
+ );
173
+ return;
174
+ }
175
+ const apiUrl = opts.api ?? DEFAULT_API_URL;
176
+ const deviceName = `${userInfo().username}@${hostname()}`.slice(0, 80);
177
+ const start = await apiCall("POST", apiUrl, "/v1/device/code", {
178
+ body: { deviceName, cliVersion: CLI_VERSION }
179
+ });
180
+ const deviceCode = start.device_code;
181
+ const userCode = start.user_code;
182
+ const verifyUrl = start.verification_uri_complete ?? start.verification_uri;
183
+ let intervalMs = (start.interval ?? 5) * 1e3;
184
+ const deadline = Date.now() + (start.expires_in ?? 900) * 1e3;
185
+ console.log(`
186
+ Open ${pc.bold(pc.cyan(verifyUrl))}`);
187
+ console.log(`and confirm this code: ${pc.bold(userCode)}
188
+ `);
189
+ openBrowser(verifyUrl);
190
+ process.stdout.write("Waiting for approval");
191
+ while (Date.now() < deadline) {
192
+ await sleep(intervalMs);
193
+ process.stdout.write(".");
194
+ const { status, json } = await apiRequest("POST", apiUrl, "/v1/device/token", {
195
+ body: { device_code: deviceCode }
196
+ });
197
+ if (status === 200) {
198
+ writeCredentials({
199
+ apiUrl,
200
+ deviceToken: json.device_token,
201
+ deviceId: json.device_id,
202
+ deviceName
203
+ });
204
+ console.log(`
205
+
206
+ ${pc.green("Logged in.")} Device ${pc.bold(deviceName)} is connected.`);
207
+ console.log(`Run ${pc.bold("tktuner sync")} to upload your usage metadata.`);
208
+ return;
209
+ }
210
+ if (status === 202) continue;
211
+ const code = (json.error ?? {}).code;
212
+ if (code === "SLOW_DOWN") {
213
+ intervalMs += 2e3;
214
+ continue;
215
+ }
216
+ const message = (json.error ?? {}).message ?? `HTTP ${status}`;
217
+ console.error(`
218
+ ${pc.red("Login failed:")} ${message}`);
219
+ process.exitCode = 1;
220
+ return;
221
+ }
222
+ console.error(`
223
+ ${pc.red("Login timed out.")} Run tktuner login again.`);
224
+ process.exitCode = 1;
225
+ }
226
+
227
+ // src/cli/logout.ts
228
+ import pc2 from "picocolors";
229
+ async function logout() {
230
+ const creds = readCredentials();
231
+ if (!creds) {
232
+ console.log("Not logged in.");
233
+ return;
234
+ }
235
+ try {
236
+ await apiCall("POST", creds.apiUrl, "/v1/device/revoke-self", { token: creds.deviceToken });
237
+ } catch {
238
+ }
239
+ deleteCredentials();
240
+ console.log(`${pc2.green("Logged out.")} Device ${pc2.bold(creds.deviceName)} was disconnected.`);
241
+ }
242
+
243
+ // ../core/src/analysis/waste.ts
244
+ function isPremiumModel(model) {
245
+ return model.startsWith("claude-opus") || model.startsWith("claude-fable") || model.startsWith("claude-mythos");
246
+ }
247
+ var IDEAL_MODEL_BY_PHASE = {
248
+ exploration: "claude-haiku-4-5",
249
+ execution: "claude-sonnet-4-5"
250
+ };
251
+ function rootSessionId(turn) {
252
+ return turn.parentSessionId ?? turn.sessionId;
253
+ }
254
+ function computeWaste(classified, prices) {
255
+ const report2 = {
256
+ totalCostUsd: 0,
257
+ totalWasteUsd: 0,
258
+ wasteByPhase: { exploration: 0, execution: 0 },
259
+ fastModeExtraUsd: 0,
260
+ unpricedTurns: 0,
261
+ unpricedModels: [],
262
+ matrix: /* @__PURE__ */ new Map(),
263
+ perSession: /* @__PURE__ */ new Map(),
264
+ perProject: /* @__PURE__ */ new Map()
265
+ };
266
+ const unpricedModels = /* @__PURE__ */ new Set();
267
+ for (const { turn, phaseSmoothed } of classified) {
268
+ const cost = prices.turnCostUsd(turn);
269
+ if (cost === null) {
270
+ report2.unpricedTurns++;
271
+ unpricedModels.add(turn.model);
272
+ continue;
273
+ }
274
+ report2.totalCostUsd += cost;
275
+ if (turn.speed === "fast") {
276
+ const standard = prices.costAtModel(turn.model, turn);
277
+ if (standard !== null) report2.fastModeExtraUsd += Math.max(0, cost - standard);
278
+ }
279
+ let wasteUsd = 0;
280
+ const idealModel = IDEAL_MODEL_BY_PHASE[phaseSmoothed];
281
+ if (idealModel && isPremiumModel(turn.model)) {
282
+ const idealCost = prices.costAtModel(idealModel, turn);
283
+ if (idealCost !== null) {
284
+ wasteUsd = Math.max(0, cost - idealCost);
285
+ report2.totalWasteUsd += wasteUsd;
286
+ if (phaseSmoothed === "exploration") report2.wasteByPhase.exploration += wasteUsd;
287
+ if (phaseSmoothed === "execution") report2.wasteByPhase.execution += wasteUsd;
288
+ }
289
+ }
290
+ let phaseMap = report2.matrix.get(turn.model);
291
+ if (!phaseMap) {
292
+ phaseMap = /* @__PURE__ */ new Map();
293
+ report2.matrix.set(turn.model, phaseMap);
294
+ }
295
+ let cell = phaseMap.get(phaseSmoothed);
296
+ if (!cell) {
297
+ cell = { turns: 0, costUsd: 0, wasteUsd: 0, tokens: 0 };
298
+ phaseMap.set(phaseSmoothed, cell);
299
+ }
300
+ cell.turns++;
301
+ cell.costUsd += cost;
302
+ cell.wasteUsd += wasteUsd;
303
+ cell.tokens += turn.usage.input + turn.usage.output + turn.usage.cacheRead + turn.usage.cacheWrite;
304
+ const sid = rootSessionId(turn);
305
+ let sess = report2.perSession.get(sid);
306
+ if (!sess) {
307
+ sess = {
308
+ sessionId: sid,
309
+ project: "",
310
+ slug: void 0,
311
+ firstTs: Infinity,
312
+ lastTs: 0,
313
+ turns: 0,
314
+ costUsd: 0,
315
+ wasteUsd: 0,
316
+ wasteExplorationUsd: 0,
317
+ wasteExecutionUsd: 0,
318
+ premiumTurns: 0,
319
+ models: /* @__PURE__ */ new Set()
320
+ };
321
+ report2.perSession.set(sid, sess);
322
+ }
323
+ sess.turns++;
324
+ sess.costUsd += cost;
325
+ sess.wasteUsd += wasteUsd;
326
+ if (phaseSmoothed === "exploration") sess.wasteExplorationUsd += wasteUsd;
327
+ if (phaseSmoothed === "execution") sess.wasteExecutionUsd += wasteUsd;
328
+ if (isPremiumModel(turn.model)) sess.premiumTurns++;
329
+ sess.models.add(turn.model);
330
+ if (turn.timestamp > 0) {
331
+ sess.firstTs = Math.min(sess.firstTs, turn.timestamp);
332
+ sess.lastTs = Math.max(sess.lastTs, turn.timestamp);
333
+ }
334
+ if (!sess.project && turn.projectPath) sess.project = turn.projectPath;
335
+ if (!sess.slug && !turn.isSidechain && turn.slug) sess.slug = turn.slug;
336
+ const project = turn.projectPath || "(unknown)";
337
+ let proj = report2.perProject.get(project);
338
+ if (!proj) {
339
+ proj = { costUsd: 0, wasteUsd: 0, turns: 0 };
340
+ report2.perProject.set(project, proj);
341
+ }
342
+ proj.costUsd += cost;
343
+ proj.wasteUsd += wasteUsd;
344
+ proj.turns++;
345
+ }
346
+ report2.unpricedModels = [...unpricedModels].sort();
347
+ return report2;
348
+ }
349
+
350
+ // ../core/src/analysis/cache.ts
351
+ function cacheStats(turns) {
352
+ const s = { turns: 0, input: 0, cacheRead: 0, cacheWrite: 0, hitRate: null };
353
+ for (const t of turns) {
354
+ s.turns++;
355
+ s.input += t.usage.input;
356
+ s.cacheRead += t.usage.cacheRead;
357
+ s.cacheWrite += t.usage.cacheWrite;
358
+ }
359
+ const denom = s.input + s.cacheRead + s.cacheWrite;
360
+ s.hitRate = denom > 0 ? s.cacheRead / denom : null;
361
+ return s;
362
+ }
363
+ function cacheByProject(turns) {
364
+ return groupStats(turns, (t) => t.projectPath || "(unknown)");
365
+ }
366
+ function cacheBySession(turns) {
367
+ return groupStats(turns, rootSessionId);
368
+ }
369
+ function groupStats(turns, key) {
370
+ const groups = /* @__PURE__ */ new Map();
371
+ for (const t of turns) {
372
+ const k = key(t);
373
+ const list = groups.get(k);
374
+ if (list) list.push(t);
375
+ else groups.set(k, [t]);
376
+ }
377
+ const out = /* @__PURE__ */ new Map();
378
+ for (const [k, list] of groups) out.set(k, cacheStats(list));
379
+ return out;
380
+ }
381
+ function summarizeCompactions(events) {
382
+ const out = { total: 0, bySession: /* @__PURE__ */ new Map(), tokensCompacted: 0 };
383
+ for (const e of events) {
384
+ if (e.kind !== "compact_boundary") continue;
385
+ out.total++;
386
+ out.bySession.set(e.sessionId, (out.bySession.get(e.sessionId) ?? 0) + 1);
387
+ const pre = typeof e.data.preTokens === "number" ? e.data.preTokens : 0;
388
+ const post = typeof e.data.postTokens === "number" ? e.data.postTokens : 0;
389
+ if (pre > post) out.tokensCompacted += pre - post;
390
+ }
391
+ return out;
392
+ }
393
+
394
+ // ../core/src/analysis/classify.ts
395
+ var WRITE_TOOLS = /* @__PURE__ */ new Set([
396
+ "Edit",
397
+ "Write",
398
+ "NotebookEdit",
399
+ "Bash",
400
+ "MultiEdit",
401
+ "mcp__ide__executeCode"
402
+ ]);
403
+ var READ_TOOLS = /* @__PURE__ */ new Set([
404
+ "Read",
405
+ "Grep",
406
+ "Glob",
407
+ "LS",
408
+ "WebFetch",
409
+ "WebSearch",
410
+ "ToolSearch",
411
+ "TaskOutput",
412
+ "BashOutput",
413
+ "NotebookRead",
414
+ "Monitor"
415
+ ]);
416
+ var DELEGATION_TOOLS = /* @__PURE__ */ new Set(["Agent", "Workflow", "Task"]);
417
+ var PLANNING_TOOLS = /* @__PURE__ */ new Set(["ExitPlanMode", "EnterPlanMode", "exit_plan_mode"]);
418
+ var THINKING_PLANNING_MIN_CHARS = 1500;
419
+ var SMOOTHING_WINDOW = 5;
420
+ function buildPlanSpans(events) {
421
+ const bySession = /* @__PURE__ */ new Map();
422
+ for (const e of events) {
423
+ if (e.kind !== "plan_mode" && e.kind !== "plan_mode_exit") continue;
424
+ if (e.timestamp === void 0) continue;
425
+ const list = bySession.get(e.sessionId);
426
+ if (list) list.push(e);
427
+ else bySession.set(e.sessionId, [e]);
428
+ }
429
+ const spans = /* @__PURE__ */ new Map();
430
+ for (const [sessionId, list] of bySession) {
431
+ list.sort((a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0));
432
+ const out = [];
433
+ let openStart;
434
+ for (const e of list) {
435
+ if (e.kind === "plan_mode") {
436
+ openStart ??= e.timestamp;
437
+ } else if (openStart !== void 0) {
438
+ out.push({ start: openStart, end: e.timestamp ?? openStart });
439
+ openStart = void 0;
440
+ }
441
+ }
442
+ if (openStart !== void 0) out.push({ start: openStart, end: Infinity });
443
+ spans.set(sessionId, out);
444
+ }
445
+ return spans;
446
+ }
447
+ function rawPhase(turn, inPlanSpan) {
448
+ if (inPlanSpan || turn.tools.some((t) => PLANNING_TOOLS.has(t))) return "planning";
449
+ if (turn.tools.some((t) => DELEGATION_TOOLS.has(t))) return "delegation";
450
+ if (turn.tools.some((t) => WRITE_TOOLS.has(t))) return "execution";
451
+ if (turn.tools.some((t) => READ_TOOLS.has(t))) return "exploration";
452
+ if (turn.numToolUses === 0 && turn.thinkingChars >= THINKING_PLANNING_MIN_CHARS) {
453
+ return "planning";
454
+ }
455
+ return "chat";
456
+ }
457
+ function classifyTurns(turns, events) {
458
+ const spans = buildPlanSpans(events);
459
+ const bySession = /* @__PURE__ */ new Map();
460
+ for (const t of turns) {
461
+ const list = bySession.get(t.sessionId);
462
+ if (list) list.push(t);
463
+ else bySession.set(t.sessionId, [t]);
464
+ }
465
+ const out = [];
466
+ for (const [sessionId, sessionTurns] of bySession) {
467
+ sessionTurns.sort((a, b) => a.timestamp - b.timestamp || (a.uuid < b.uuid ? -1 : 1));
468
+ const sessionSpans = spans.get(sessionId) ?? [];
469
+ const raw = sessionTurns.map(
470
+ (t) => rawPhase(
471
+ t,
472
+ sessionSpans.some((s) => t.timestamp >= s.start && t.timestamp <= s.end)
473
+ )
474
+ );
475
+ const smoothed = smoothPhases(raw);
476
+ for (let i = 0; i < sessionTurns.length; i++) {
477
+ out.push({
478
+ turn: sessionTurns[i],
479
+ phase: raw[i],
480
+ phaseSmoothed: smoothed[i]
481
+ });
482
+ }
483
+ }
484
+ return out;
485
+ }
486
+ function smoothPhases(raw) {
487
+ const half = Math.floor(SMOOTHING_WINDOW / 2);
488
+ return raw.map((phase, i) => {
489
+ const counts = /* @__PURE__ */ new Map();
490
+ const lo = Math.max(0, i - half);
491
+ const hi = Math.min(raw.length - 1, i + half);
492
+ for (let j = lo; j <= hi; j++) {
493
+ const p = raw[j];
494
+ counts.set(p, (counts.get(p) ?? 0) + 1);
495
+ }
496
+ let maxN = 0;
497
+ for (const n of counts.values()) maxN = Math.max(maxN, n);
498
+ const winners = [...counts.entries()].filter(([, n]) => n === maxN).map(([p]) => p);
499
+ return winners.length === 1 ? winners[0] : phase;
500
+ });
501
+ }
502
+
503
+ // ../core/src/analysis/quota.ts
504
+ var WINDOW_MS = 5 * 60 * 60 * 1e3;
505
+ function build5hWindows(turns, prices) {
506
+ const sorted = turns.filter((t) => t.timestamp > 0).sort((a, b) => a.timestamp - b.timestamp);
507
+ const windows = [];
508
+ let current;
509
+ for (const t of sorted) {
510
+ if (!current || t.timestamp >= current.end) {
511
+ current = {
512
+ start: t.timestamp,
513
+ end: t.timestamp + WINDOW_MS,
514
+ turns: 0,
515
+ totalTokens: 0,
516
+ outputTokens: 0,
517
+ costUsd: 0,
518
+ unpricedTurns: 0
519
+ };
520
+ windows.push(current);
521
+ }
522
+ current.turns++;
523
+ current.totalTokens += t.usage.input + t.usage.output + t.usage.cacheRead + t.usage.cacheWrite;
524
+ current.outputTokens += t.usage.output;
525
+ const cost = prices.turnCostUsd(t);
526
+ if (cost === null) current.unpricedTurns++;
527
+ else current.costUsd += cost;
528
+ }
529
+ return windows;
530
+ }
531
+ function weeklyRollup(turns, prices) {
532
+ const byWeek = /* @__PURE__ */ new Map();
533
+ for (const t of turns) {
534
+ if (t.timestamp <= 0) continue;
535
+ const weekKey = isoWeekKey(t.timestamp);
536
+ let bucket = byWeek.get(weekKey);
537
+ if (!bucket) {
538
+ bucket = { weekKey, start: t.timestamp, turns: 0, totalTokens: 0, costUsd: 0 };
539
+ byWeek.set(weekKey, bucket);
540
+ }
541
+ bucket.start = Math.min(bucket.start, t.timestamp);
542
+ bucket.turns++;
543
+ bucket.totalTokens += t.usage.input + t.usage.output + t.usage.cacheRead + t.usage.cacheWrite;
544
+ const cost = prices.turnCostUsd(t);
545
+ if (cost !== null) bucket.costUsd += cost;
546
+ }
547
+ return [...byWeek.values()].sort((a, b) => a.start - b.start);
548
+ }
549
+ function isoWeekKey(ts) {
550
+ const d = new Date(ts);
551
+ const thursday = new Date(d.getFullYear(), d.getMonth(), d.getDate());
552
+ const weekday = (thursday.getDay() + 6) % 7;
553
+ thursday.setDate(thursday.getDate() - weekday + 3);
554
+ const year = thursday.getFullYear();
555
+ const jan4 = new Date(year, 0, 4);
556
+ const week1Monday = new Date(year, 0, 4 - (jan4.getDay() + 6) % 7);
557
+ const week = Math.round((thursday.getTime() - week1Monday.getTime()) / (7 * 864e5)) + 1;
558
+ return `${year}-W${String(week).padStart(2, "0")}`;
559
+ }
560
+
561
+ // ../core/src/merge.ts
562
+ function mergeTurnLines(lines) {
563
+ const groups = /* @__PURE__ */ new Map();
564
+ const order = [];
565
+ for (const line of lines) {
566
+ const key = line.messageId ?? line.uuid;
567
+ const group = groups.get(key);
568
+ if (group) group.push(line);
569
+ else {
570
+ groups.set(key, [line]);
571
+ order.push(key);
572
+ }
573
+ }
574
+ return order.map((key) => {
575
+ const group = groups.get(key);
576
+ const first = group[0];
577
+ if (group.length === 1) return first;
578
+ const timestamps = group.map((l) => l.timestamp).filter((t) => t > 0);
579
+ return {
580
+ ...first,
581
+ usage: {
582
+ input: Math.max(...group.map((l) => l.usage.input)),
583
+ output: Math.max(...group.map((l) => l.usage.output)),
584
+ cacheRead: Math.max(...group.map((l) => l.usage.cacheRead)),
585
+ cacheWrite: Math.max(...group.map((l) => l.usage.cacheWrite)),
586
+ cacheWrite5m: Math.max(...group.map((l) => l.usage.cacheWrite5m)),
587
+ cacheWrite1h: Math.max(...group.map((l) => l.usage.cacheWrite1h))
588
+ },
589
+ tools: group.flatMap((l) => l.tools),
590
+ numToolUses: group.reduce((n, l) => n + l.numToolUses, 0),
591
+ thinkingChars: group.reduce((n, l) => n + l.thinkingChars, 0),
592
+ textChars: group.reduce((n, l) => n + l.textChars, 0),
593
+ timestamp: timestamps.length > 0 ? Math.min(...timestamps) : first.timestamp,
594
+ stopReason: [...group].reverse().find((l) => l.stopReason)?.stopReason,
595
+ speed: group.find((l) => l.speed)?.speed,
596
+ inferenceGeo: group.find((l) => l.inferenceGeo)?.inferenceGeo
597
+ };
598
+ });
599
+ }
600
+
601
+ // ../core/src/pricing/prices.json
602
+ var prices_default = {
603
+ version: "2026-07-12",
604
+ source: "https://platform.claude.com/docs/en/about-claude/pricing",
605
+ currency: "USD",
606
+ per_mtok: {
607
+ "claude-fable-5": {
608
+ input: 10,
609
+ output: 50,
610
+ cache_read: 1,
611
+ cache_write_5m: 12.5,
612
+ cache_write_1h: 20
613
+ },
614
+ "claude-mythos-5": {
615
+ input: 10,
616
+ output: 50,
617
+ cache_read: 1,
618
+ cache_write_5m: 12.5,
619
+ cache_write_1h: 20
620
+ },
621
+ "claude-opus-4-8": {
622
+ input: 5,
623
+ output: 25,
624
+ cache_read: 0.5,
625
+ cache_write_5m: 6.25,
626
+ cache_write_1h: 10,
627
+ fast_input: 10,
628
+ fast_output: 50
629
+ },
630
+ "claude-opus-4-7": {
631
+ input: 5,
632
+ output: 25,
633
+ cache_read: 0.5,
634
+ cache_write_5m: 6.25,
635
+ cache_write_1h: 10,
636
+ fast_input: 30,
637
+ fast_output: 150
638
+ },
639
+ "claude-opus-4-6": {
640
+ input: 5,
641
+ output: 25,
642
+ cache_read: 0.5,
643
+ cache_write_5m: 6.25,
644
+ cache_write_1h: 10
645
+ },
646
+ "claude-opus-4-5": {
647
+ input: 5,
648
+ output: 25,
649
+ cache_read: 0.5,
650
+ cache_write_5m: 6.25,
651
+ cache_write_1h: 10
652
+ },
653
+ "claude-opus-4-1": {
654
+ input: 15,
655
+ output: 75,
656
+ cache_read: 1.5,
657
+ cache_write_5m: 18.75,
658
+ cache_write_1h: 30
659
+ },
660
+ "claude-opus-4": {
661
+ input: 15,
662
+ output: 75,
663
+ cache_read: 1.5,
664
+ cache_write_5m: 18.75,
665
+ cache_write_1h: 30
666
+ },
667
+ "claude-sonnet-5": [
668
+ {
669
+ until: "2026-09-01",
670
+ input: 2,
671
+ output: 10,
672
+ cache_read: 0.2,
673
+ cache_write_5m: 2.5,
674
+ cache_write_1h: 4
675
+ },
676
+ {
677
+ from: "2026-09-01",
678
+ input: 3,
679
+ output: 15,
680
+ cache_read: 0.3,
681
+ cache_write_5m: 3.75,
682
+ cache_write_1h: 6
683
+ }
684
+ ],
685
+ "claude-sonnet-4-6": {
686
+ input: 3,
687
+ output: 15,
688
+ cache_read: 0.3,
689
+ cache_write_5m: 3.75,
690
+ cache_write_1h: 6
691
+ },
692
+ "claude-sonnet-4-5": {
693
+ input: 3,
694
+ output: 15,
695
+ cache_read: 0.3,
696
+ cache_write_5m: 3.75,
697
+ cache_write_1h: 6
698
+ },
699
+ "claude-sonnet-4": {
700
+ input: 3,
701
+ output: 15,
702
+ cache_read: 0.3,
703
+ cache_write_5m: 3.75,
704
+ cache_write_1h: 6
705
+ },
706
+ "claude-3-7-sonnet": {
707
+ input: 3,
708
+ output: 15,
709
+ cache_read: 0.3,
710
+ cache_write_5m: 3.75,
711
+ cache_write_1h: 6
712
+ },
713
+ "claude-3-5-sonnet": {
714
+ input: 3,
715
+ output: 15,
716
+ cache_read: 0.3,
717
+ cache_write_5m: 3.75,
718
+ cache_write_1h: 6
719
+ },
720
+ "claude-haiku-4-5": {
721
+ input: 1,
722
+ output: 5,
723
+ cache_read: 0.1,
724
+ cache_write_5m: 1.25,
725
+ cache_write_1h: 2
726
+ },
727
+ "claude-haiku-3-5": {
728
+ input: 0.8,
729
+ output: 4,
730
+ cache_read: 0.08,
731
+ cache_write_5m: 1,
732
+ cache_write_1h: 1.6
733
+ },
734
+ "claude-3-5-haiku": {
735
+ input: 0.8,
736
+ output: 4,
737
+ cache_read: 0.08,
738
+ cache_write_5m: 1,
739
+ cache_write_1h: 1.6
740
+ },
741
+ "claude-3-haiku": {
742
+ input: 0.25,
743
+ output: 1.25,
744
+ cache_read: 0.03,
745
+ cache_write_5m: 0.3,
746
+ cache_write_1h: 0.5
747
+ },
748
+ "claude-3-opus": {
749
+ input: 15,
750
+ output: 75,
751
+ cache_read: 1.5,
752
+ cache_write_5m: 18.75,
753
+ cache_write_1h: 30
754
+ }
755
+ }
756
+ };
757
+
758
+ // ../core/src/pricing/pricing.ts
759
+ var US_GEO_MULTIPLIER = 1.1;
760
+ var PriceTable = class _PriceTable {
761
+ constructor(file) {
762
+ this.file = file;
763
+ this.keysByLength = Object.keys(file.per_mtok).sort((a, b) => b.length - a.length);
764
+ }
765
+ file;
766
+ keysByLength;
767
+ static bundled() {
768
+ return new _PriceTable(prices_default);
769
+ }
770
+ /** Bundled table with a user's partial table merged on top (per-model override). */
771
+ static withOverrideData(raw) {
772
+ const base = prices_default;
773
+ return new _PriceTable({
774
+ ...base,
775
+ ...raw,
776
+ per_mtok: { ...base.per_mtok, ...raw.per_mtok ?? {} }
777
+ });
778
+ }
779
+ get version() {
780
+ return this.file.version;
781
+ }
782
+ /**
783
+ * Model id -> price entry via exact match, then longest-prefix match (covers
784
+ * date-stamped ids like claude-haiku-4-5-20251001). Null when unknown.
785
+ */
786
+ resolve(model, ctx = {}) {
787
+ const entry = this.file.per_mtok[model] ?? this.prefixMatch(model);
788
+ if (!entry) return null;
789
+ const variant = pickVariant(entry, ctx.ts);
790
+ if (!variant || variant.input == null || variant.output == null) return null;
791
+ let price;
792
+ if (ctx.speed === "fast" && variant.fast_input != null && variant.fast_output != null) {
793
+ price = {
794
+ input: variant.fast_input,
795
+ output: variant.fast_output,
796
+ cacheRead: variant.fast_input * 0.1,
797
+ cacheWrite5m: variant.fast_input * 1.25,
798
+ cacheWrite1h: variant.fast_input * 2
799
+ };
800
+ } else {
801
+ price = {
802
+ input: variant.input,
803
+ output: variant.output,
804
+ cacheRead: variant.cache_read ?? variant.input * 0.1,
805
+ cacheWrite5m: variant.cache_write_5m ?? variant.input * 1.25,
806
+ cacheWrite1h: variant.cache_write_1h ?? variant.input * 2
807
+ };
808
+ }
809
+ if (ctx.inferenceGeo === "us") {
810
+ price = {
811
+ input: price.input * US_GEO_MULTIPLIER,
812
+ output: price.output * US_GEO_MULTIPLIER,
813
+ cacheRead: price.cacheRead * US_GEO_MULTIPLIER,
814
+ cacheWrite5m: price.cacheWrite5m * US_GEO_MULTIPLIER,
815
+ cacheWrite1h: price.cacheWrite1h * US_GEO_MULTIPLIER
816
+ };
817
+ }
818
+ return price;
819
+ }
820
+ /** Cost of one turn in USD. 0 for zero-usage turns; null when unpriceable. */
821
+ turnCostUsd(turn) {
822
+ return this.costAtModel(turn.model, turn, { keepSpeed: true });
823
+ }
824
+ /**
825
+ * Cost of a turn's token counts billed as `model` — used for the "what if
826
+ * this ran on the ideal model" comparison. Keeps the turn's inference geo
827
+ * (switching model would not change it) but assumes standard speed unless
828
+ * `keepSpeed` is set.
829
+ */
830
+ costAtModel(model, turn, opts = {}) {
831
+ const u = turn.usage;
832
+ if (u.input + u.output + u.cacheRead + u.cacheWrite === 0) return 0;
833
+ const price = this.resolve(model, {
834
+ ts: turn.timestamp > 0 ? turn.timestamp : void 0,
835
+ speed: opts.keepSpeed ? turn.speed : void 0,
836
+ inferenceGeo: turn.inferenceGeo
837
+ });
838
+ if (!price) return null;
839
+ let write5m = u.cacheWrite5m;
840
+ let write1h = u.cacheWrite1h;
841
+ if (write5m + write1h !== u.cacheWrite) {
842
+ write5m = u.cacheWrite;
843
+ write1h = 0;
844
+ }
845
+ return (u.input * price.input + u.output * price.output + u.cacheRead * price.cacheRead + write5m * price.cacheWrite5m + write1h * price.cacheWrite1h) / 1e6;
846
+ }
847
+ prefixMatch(model) {
848
+ for (const key of this.keysByLength) {
849
+ if (model.startsWith(key)) return this.file.per_mtok[key];
850
+ }
851
+ return void 0;
852
+ }
853
+ };
854
+ function pickVariant(entry, ts) {
855
+ if (!Array.isArray(entry)) return entry;
856
+ if (entry.length === 0) return void 0;
857
+ const at = ts ?? Date.now();
858
+ for (const v of entry) {
859
+ const from = v.from ? Date.parse(v.from) : -Infinity;
860
+ const until = v.until ? Date.parse(v.until) : Infinity;
861
+ if (at >= from && at < until) return v;
862
+ }
863
+ return entry[entry.length - 1];
864
+ }
865
+
866
+ // ../core/src/report/format.ts
867
+ function fmtUsd(n) {
868
+ if (n === null || n === void 0) return "n/a";
869
+ if (n === 0) return "$0.00";
870
+ if (n > 0 && n < 0.01) return "<$0.01";
871
+ return `$${n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
872
+ }
873
+ function fmtTokens(n) {
874
+ if (n < 1e3) return String(n);
875
+ if (n < 1e6) return `${(n / 1e3).toFixed(1)}k`;
876
+ if (n < 1e9) return `${(n / 1e6).toFixed(1)}M`;
877
+ return `${(n / 1e9).toFixed(2)}B`;
878
+ }
879
+ function fmtPct(x) {
880
+ if (x === null || x === void 0) return "\u2014";
881
+ return `${Math.round(x * 100)}%`;
882
+ }
883
+ function fmtDate(ts) {
884
+ if (!Number.isFinite(ts) || ts <= 0) return "\u2014";
885
+ const d = new Date(ts);
886
+ const mm = String(d.getMonth() + 1).padStart(2, "0");
887
+ const dd = String(d.getDate()).padStart(2, "0");
888
+ return `${d.getFullYear()}-${mm}-${dd}`;
889
+ }
890
+ function fmtDateTime(ts) {
891
+ if (!Number.isFinite(ts) || ts <= 0) return "\u2014";
892
+ const d = new Date(ts);
893
+ const mm = String(d.getMonth() + 1).padStart(2, "0");
894
+ const dd = String(d.getDate()).padStart(2, "0");
895
+ const hh = String(d.getHours()).padStart(2, "0");
896
+ const mi = String(d.getMinutes()).padStart(2, "0");
897
+ return `${mm}-${dd} ${hh}:${mi}`;
898
+ }
899
+ function fmtTime(ts) {
900
+ if (!Number.isFinite(ts) || ts <= 0) return "\u2014";
901
+ const d = new Date(ts);
902
+ return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}:${String(d.getSeconds()).padStart(2, "0")}`;
903
+ }
904
+ function shortId(id) {
905
+ return id.length > 8 ? id.slice(0, 8) : id;
906
+ }
907
+ function truncate(s, max) {
908
+ return s.length <= max ? s : `${s.slice(0, Math.max(0, max - 1))}\u2026`;
909
+ }
910
+ function basename(p) {
911
+ if (!p) return "\u2014";
912
+ const parts = p.split("/").filter(Boolean);
913
+ return parts.length > 0 ? parts[parts.length - 1] : p;
914
+ }
915
+ function prettyModel(model) {
916
+ return model.replace(/^claude-/, "").replace(/-\d{8}$/, "");
917
+ }
918
+
919
+ // ../core/src/types.ts
920
+ var PHASES = ["exploration", "planning", "execution", "delegation", "chat"];
921
+
922
+ // ../core/src/report/model.ts
923
+ var WASTE_EPSILON = 5e-3;
924
+ var QUOTA_NOTE = "Estimated reconstruction of Anthropic's rolling 5h usage windows \u2014 not official quota accounting.";
925
+ var WASTE_ASSUMPTION = "Waste assumes the cheaper model would consume the same token counts; actual savings vary.";
926
+ function buildReport(turns, events, prices, range) {
927
+ const sessionIds = new Set(turns.map((t) => t.sessionId));
928
+ const relevantEvents = events.filter((e) => sessionIds.has(e.sessionId));
929
+ const classified = classifyTurns(turns, events);
930
+ const waste = computeWaste(classified, prices);
931
+ const rootSessions = /* @__PURE__ */ new Set();
932
+ const projects = /* @__PURE__ */ new Set();
933
+ const tokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
934
+ let subagentTurns = 0;
935
+ let minTs = Infinity;
936
+ let maxTs = 0;
937
+ for (const t of turns) {
938
+ rootSessions.add(rootSessionId(t));
939
+ if (t.projectPath) projects.add(t.projectPath);
940
+ tokens.input += t.usage.input;
941
+ tokens.output += t.usage.output;
942
+ tokens.cacheRead += t.usage.cacheRead;
943
+ tokens.cacheWrite += t.usage.cacheWrite;
944
+ if (t.isSidechain) subagentTurns++;
945
+ if (t.timestamp > 0) {
946
+ minTs = Math.min(minTs, t.timestamp);
947
+ maxTs = Math.max(maxTs, t.timestamp);
948
+ }
949
+ }
950
+ const totalTokens = tokens.input + tokens.output + tokens.cacheRead + tokens.cacheWrite;
951
+ const byModelMap = /* @__PURE__ */ new Map();
952
+ for (const t of turns) {
953
+ const u = t.usage;
954
+ if (u.input + u.output + u.cacheRead + u.cacheWrite === 0) continue;
955
+ let agg = byModelMap.get(t.model);
956
+ if (!agg) {
957
+ agg = {
958
+ turns: 0,
959
+ input: 0,
960
+ output: 0,
961
+ cacheRead: 0,
962
+ cacheWrite: 0,
963
+ costUsd: 0,
964
+ unpriced: false
965
+ };
966
+ byModelMap.set(t.model, agg);
967
+ }
968
+ agg.turns++;
969
+ agg.input += u.input;
970
+ agg.output += u.output;
971
+ agg.cacheRead += u.cacheRead;
972
+ agg.cacheWrite += u.cacheWrite;
973
+ const cost = prices.turnCostUsd(t);
974
+ if (cost === null) agg.unpriced = true;
975
+ else agg.costUsd += cost;
976
+ }
977
+ const byModel = [...byModelMap.entries()].map(([model, a]) => ({
978
+ model,
979
+ turns: a.turns,
980
+ input: a.input,
981
+ output: a.output,
982
+ cacheRead: a.cacheRead,
983
+ cacheWrite: a.cacheWrite,
984
+ costUsd: a.unpriced ? null : a.costUsd,
985
+ costShare: !a.unpriced && waste.totalCostUsd > 0 ? a.costUsd / waste.totalCostUsd : null
986
+ })).sort((a, b) => (b.costUsd ?? 0) - (a.costUsd ?? 0) || b.turns - a.turns);
987
+ const phaseMatrix = [...waste.matrix.entries()].map(([model, phases]) => {
988
+ const row = {};
989
+ let total = 0;
990
+ for (const phase of PHASES) {
991
+ const cell = phases.get(phase);
992
+ row[phase] = {
993
+ turns: cell?.turns ?? 0,
994
+ costUsd: cell?.costUsd ?? 0,
995
+ wasteUsd: cell?.wasteUsd ?? 0
996
+ };
997
+ total += row[phase].costUsd;
998
+ }
999
+ return { model, phases: row, total };
1000
+ }).sort((a, b) => b.total - a.total).map(({ model, phases }) => ({ model, phases }));
1001
+ const overallCache = cacheStats(turns);
1002
+ const cacheProjects = [...cacheByProject(turns).entries()].map(([project, s]) => ({
1003
+ // basename keeps the model identical whether projectPath is the real cwd
1004
+ // (local CLI) or the hashed id/name pair (synced server data).
1005
+ project: basename(project),
1006
+ turns: s.turns,
1007
+ hitRate: s.hitRate,
1008
+ cacheRead: s.cacheRead,
1009
+ cacheWrite: s.cacheWrite
1010
+ })).filter((p) => p.turns >= 10).sort((a, b) => (a.hitRate ?? 1) - (b.hitRate ?? 1)).slice(0, 8);
1011
+ const compactions = summarizeCompactions(relevantEvents);
1012
+ const cachePerSession = cacheBySession(turns);
1013
+ const topSessions = [...waste.perSession.values()].sort((a, b) => b.wasteUsd - a.wasteUsd || b.costUsd - a.costUsd).slice(0, 5).map((s) => ({
1014
+ sessionId: s.sessionId,
1015
+ label: s.slug ?? shortId(s.sessionId),
1016
+ project: basename(s.project),
1017
+ date: fmtDate(s.firstTs === Infinity ? 0 : s.firstTs),
1018
+ turns: s.turns,
1019
+ costUsd: s.costUsd,
1020
+ wasteUsd: s.wasteUsd,
1021
+ antiPattern: dominantAntiPattern(s, cachePerSession.get(s.sessionId)?.hitRate ?? null)
1022
+ }));
1023
+ const agentTypeById = /* @__PURE__ */ new Map();
1024
+ for (const e of relevantEvents) {
1025
+ if (e.kind !== "agent_result") continue;
1026
+ const id = typeof e.data.agentId === "string" ? e.data.agentId : void 0;
1027
+ const type = typeof e.data.agentType === "string" ? e.data.agentType : void 0;
1028
+ if (id && type) agentTypeById.set(id, type);
1029
+ }
1030
+ const children = /* @__PURE__ */ new Map();
1031
+ for (const { turn } of classified) {
1032
+ if (!turn.agentId) continue;
1033
+ let child = children.get(turn.agentId);
1034
+ if (!child) {
1035
+ child = { costUsd: 0, premiumModel: null };
1036
+ children.set(turn.agentId, child);
1037
+ }
1038
+ const cost = prices.turnCostUsd(turn);
1039
+ if (cost !== null) child.costUsd += cost;
1040
+ if (!child.premiumModel && isPremiumModel(turn.model)) child.premiumModel = turn.model;
1041
+ }
1042
+ const leakGroups = /* @__PURE__ */ new Map();
1043
+ for (const [agentId, child] of children) {
1044
+ if (!child.premiumModel) continue;
1045
+ const agentType = agentTypeById.get(agentId) ?? "subagent";
1046
+ const key = `${agentType}\0${child.premiumModel}`;
1047
+ let g = leakGroups.get(key);
1048
+ if (!g) {
1049
+ g = { agentType, model: child.premiumModel, runs: 0, costUsd: 0 };
1050
+ leakGroups.set(key, g);
1051
+ }
1052
+ g.runs++;
1053
+ g.costUsd += child.costUsd;
1054
+ }
1055
+ const subagentLeaks = [...leakGroups.values()].sort((a, b) => b.costUsd - a.costUsd);
1056
+ const windows = build5hWindows(turns, prices);
1057
+ const last7dStart = maxTs > 0 ? maxTs - 7 * 864e5 : 0;
1058
+ const last7d = windows.filter((w) => w.start >= last7dStart).map((w) => ({
1059
+ start: fmtDateTime(w.start),
1060
+ startTs: w.start,
1061
+ turns: w.turns,
1062
+ totalTokens: w.totalTokens,
1063
+ costUsd: w.costUsd
1064
+ }));
1065
+ const weekly = weeklyRollup(turns, prices).map((w) => ({
1066
+ week: w.weekKey,
1067
+ turns: w.turns,
1068
+ totalTokens: w.totalTokens,
1069
+ costUsd: w.costUsd
1070
+ }));
1071
+ const spanDays = Math.max(1, (maxTs - (minTs === Infinity ? maxTs : minTs)) / 864e5);
1072
+ const monthly = (x) => x / spanDays * 30;
1073
+ const recommendations = [];
1074
+ if (monthly(waste.wasteByPhase.exploration) >= 1) {
1075
+ recommendations.push(
1076
+ `Delegate exploration to a Haiku-pinned subagent (frontmatter: model: haiku, effort: low) \u2014 roughly ${fmtUsd(monthly(waste.wasteByPhase.exploration))}/month of premium-model exploration to reclaim.`
1077
+ );
1078
+ }
1079
+ if (monthly(waste.wasteByPhase.execution) >= 1) {
1080
+ recommendations.push(
1081
+ `Run edit/build-heavy work on Sonnet (settings "model": "opusplan", or a sonnet implementer subagent) \u2014 roughly ${fmtUsd(monthly(waste.wasteByPhase.execution))}/month spent running premium models in execution.`
1082
+ );
1083
+ }
1084
+ const leakRuns = subagentLeaks.reduce((n, l) => n + l.runs, 0);
1085
+ if (leakRuns > 0) {
1086
+ recommendations.push(
1087
+ `${leakRuns} subagent run(s) executed on a premium model \u2014 pin model: (haiku/sonnet) in .claude/agents/*.md frontmatter so delegation actually saves money.`
1088
+ );
1089
+ }
1090
+ if (overallCache.hitRate !== null && overallCache.hitRate < 0.7) {
1091
+ recommendations.push(
1092
+ `Cache hit rate is ${fmtPct(overallCache.hitRate)} \u2014 cold starts repay your context at full price. Prefer resuming sessions (claude --continue) over starting fresh ones.`
1093
+ );
1094
+ }
1095
+ if (monthly(waste.fastModeExtraUsd) >= 1) {
1096
+ recommendations.push(
1097
+ `Fast mode premium added ${fmtUsd(waste.fastModeExtraUsd)} in this window \u2014 reserve /fast for latency-critical work.`
1098
+ );
1099
+ }
1100
+ if (compactions.total >= 5) {
1101
+ recommendations.push(
1102
+ `${compactions.total} context compaction(s) re-summarized ~${fmtTokens(compactions.tokensCompacted)} tokens \u2014 split long sessions or push exploration into subagents to keep the main context lean.`
1103
+ );
1104
+ }
1105
+ return {
1106
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1107
+ priceTableVersion: prices.version,
1108
+ range: {
1109
+ days: range.days,
1110
+ since: minTs === Infinity ? null : new Date(minTs).toISOString(),
1111
+ until: new Date(maxTs > 0 ? maxTs : Date.now()).toISOString(),
1112
+ project: range.project
1113
+ },
1114
+ overview: {
1115
+ sessions: rootSessions.size,
1116
+ turns: turns.length,
1117
+ subagentTurns,
1118
+ projects: projects.size,
1119
+ totalTokens,
1120
+ tokens,
1121
+ totalCostUsd: waste.totalCostUsd,
1122
+ unpricedTurns: waste.unpricedTurns,
1123
+ unpricedModels: waste.unpricedModels
1124
+ },
1125
+ byModel,
1126
+ phaseMatrix,
1127
+ waste: {
1128
+ totalUsd: waste.totalWasteUsd,
1129
+ explorationUsd: waste.wasteByPhase.exploration,
1130
+ executionUsd: waste.wasteByPhase.execution,
1131
+ pctOfSpend: waste.totalCostUsd > 0 ? waste.totalWasteUsd / waste.totalCostUsd : null,
1132
+ fastModeExtraUsd: waste.fastModeExtraUsd,
1133
+ assumption: WASTE_ASSUMPTION
1134
+ },
1135
+ cache: {
1136
+ overall: {
1137
+ hitRate: overallCache.hitRate,
1138
+ input: overallCache.input,
1139
+ cacheRead: overallCache.cacheRead,
1140
+ cacheWrite: overallCache.cacheWrite
1141
+ },
1142
+ byProject: cacheProjects,
1143
+ compactions: {
1144
+ total: compactions.total,
1145
+ tokensCompacted: compactions.tokensCompacted,
1146
+ sessionsAffected: compactions.bySession.size
1147
+ }
1148
+ },
1149
+ topSessions,
1150
+ subagentLeaks,
1151
+ quota: { windowHours: WINDOW_MS / 36e5, last7d, weekly, note: QUOTA_NOTE },
1152
+ recommendations
1153
+ };
1154
+ }
1155
+ function dominantAntiPattern(s, hitRate) {
1156
+ const parts = [];
1157
+ if (s.wasteExplorationUsd > WASTE_EPSILON && s.wasteExplorationUsd >= s.wasteExecutionUsd) {
1158
+ parts.push(`premium model on exploration (${fmtUsd(s.wasteExplorationUsd)})`);
1159
+ } else if (s.wasteExecutionUsd > WASTE_EPSILON) {
1160
+ parts.push(`premium model on execution (${fmtUsd(s.wasteExecutionUsd)})`);
1161
+ }
1162
+ if (parts.length === 0 && hitRate !== null && hitRate < 0.5 && s.turns >= 20) {
1163
+ parts.push(`low cache reuse (${fmtPct(hitRate)} hit rate)`);
1164
+ }
1165
+ return parts.length > 0 ? parts[0] : "\u2014";
1166
+ }
1167
+
1168
+ // ../core/src/sync.ts
1169
+ var SYNC_MAX_TURNS_PER_BATCH = 2e3;
1170
+ var SYNC_MAX_EVENTS_PER_BATCH = 5e3;
1171
+
1172
+ // src/cli/report.ts
1173
+ import pc4 from "picocolors";
1174
+
1175
+ // src/lib/prices-io.ts
1176
+ import { readFileSync as readFileSync2 } from "fs";
1177
+ function loadPriceTable(overridePath) {
1178
+ if (!overridePath) return PriceTable.bundled();
1179
+ const raw = JSON.parse(readFileSync2(overridePath, "utf8"));
1180
+ return PriceTable.withOverrideData(raw);
1181
+ }
1182
+
1183
+ // src/render/colors.ts
1184
+ import pc3 from "picocolors";
1185
+ var c = pc3;
1186
+ function setColors(enabled) {
1187
+ c = pc3.createColors(enabled);
1188
+ }
1189
+
1190
+ // src/render/table.ts
1191
+ var MAX_CELL_WIDTH = 44;
1192
+ function renderTable(cols, rows, indent = " ") {
1193
+ const plain = (cell) => typeof cell === "string" ? cell : cell.text;
1194
+ const widths = cols.map((col, i) => {
1195
+ let w = col.header.length;
1196
+ for (const row of rows) {
1197
+ const cell = row[i];
1198
+ if (cell !== void 0) w = Math.max(w, Math.min(plain(cell).length, MAX_CELL_WIDTH));
1199
+ }
1200
+ return w;
1201
+ });
1202
+ const renderRow = (cells, painter) => {
1203
+ const parts = cols.map((col, i) => {
1204
+ const cell = cells[i] ?? "";
1205
+ let text = plain(cell);
1206
+ if (text.length > MAX_CELL_WIDTH) text = `${text.slice(0, MAX_CELL_WIDTH - 1)}\u2026`;
1207
+ const w = widths[i] ?? 0;
1208
+ const padded = col.align === "right" ? text.padStart(w) : text.padEnd(w);
1209
+ const paint = typeof cell === "object" ? cell.paint : void 0;
1210
+ const chosen = paint ?? painter;
1211
+ return chosen ? chosen(padded) : padded;
1212
+ });
1213
+ return indent + parts.join(" ");
1214
+ };
1215
+ const lines = [];
1216
+ lines.push(renderRow(cols.map((c2) => c2.header)));
1217
+ lines.push(indent + widths.map((w) => "\u2500".repeat(w)).join("\u2500\u2500"));
1218
+ for (const row of rows) lines.push(renderRow(row));
1219
+ return lines.join("\n");
1220
+ }
1221
+
1222
+ // src/render/terminal.ts
1223
+ function renderReport(m) {
1224
+ const out = [];
1225
+ const rangeLabel = m.range.days === null ? "all history" : `last ${m.range.days} days`;
1226
+ const span = `${m.range.since ? fmtDate(Date.parse(m.range.since)) : "\u2014"} \u2192 ${fmtDate(Date.parse(m.range.until))}`;
1227
+ const filter = m.range.project ? ` \xB7 project ~ "${m.range.project}"` : "";
1228
+ out.push(`${c.bold("TkTuner report")} \u2014 ${rangeLabel} (${span})${filter}`);
1229
+ const o = m.overview;
1230
+ const lines = [];
1231
+ lines.push(
1232
+ `Sessions ${c.bold(String(o.sessions))} \xB7 assistant turns ${c.bold(o.turns.toLocaleString("en-US"))}` + (o.subagentTurns > 0 ? ` (${o.subagentTurns.toLocaleString("en-US")} in subagents)` : "") + ` \xB7 projects ${c.bold(String(o.projects))}`
1233
+ );
1234
+ lines.push(
1235
+ `Est. cost (API-equivalent): ${c.bold(c.green(fmtUsd(o.totalCostUsd)))} tokens: ${c.bold(fmtTokens(o.totalTokens))} \u2014 in ${fmtTokens(o.tokens.input)} \xB7 out ${fmtTokens(o.tokens.output)} \xB7 cache read ${fmtTokens(o.tokens.cacheRead)} \xB7 cache write ${fmtTokens(o.tokens.cacheWrite)}`
1236
+ );
1237
+ if (o.unpricedTurns > 0) {
1238
+ lines.push(
1239
+ c.yellow(
1240
+ `${o.unpricedTurns} turn(s) on unpriced model(s) excluded from cost: ${o.unpricedModels.join(", ")} \u2014 update prices.json or use --prices`
1241
+ )
1242
+ );
1243
+ }
1244
+ out.push(`${header("Overview")}
1245
+ ${lines.join("\n")}`);
1246
+ out.push(
1247
+ `${header("By model")}
1248
+ ${renderTable(
1249
+ [
1250
+ { header: "Model" },
1251
+ { header: "Turns", align: "right" },
1252
+ { header: "Input", align: "right" },
1253
+ { header: "Output", align: "right" },
1254
+ { header: "Cache read", align: "right" },
1255
+ { header: "Cache write", align: "right" },
1256
+ { header: "Est. cost", align: "right" },
1257
+ { header: "%", align: "right" }
1258
+ ],
1259
+ m.byModel.map((r) => [
1260
+ prettyModel(r.model),
1261
+ String(r.turns),
1262
+ fmtTokens(r.input),
1263
+ fmtTokens(r.output),
1264
+ fmtTokens(r.cacheRead),
1265
+ fmtTokens(r.cacheWrite),
1266
+ { text: fmtUsd(r.costUsd), paint: r.costUsd === null ? c.yellow : void 0 },
1267
+ fmtPct(r.costShare)
1268
+ ])
1269
+ )}`
1270
+ );
1271
+ const matrixRows = m.phaseMatrix.map((row) => {
1272
+ const cells = [prettyModel(row.model)];
1273
+ for (const phase of PHASES) {
1274
+ const cell = row.phases[phase];
1275
+ if (cell.turns === 0) cells.push({ text: "\xB7", paint: c.dim });
1276
+ else if (cell.wasteUsd > WASTE_EPSILON) {
1277
+ cells.push({ text: fmtUsd(cell.costUsd), paint: c.red });
1278
+ } else cells.push(fmtUsd(cell.costUsd));
1279
+ }
1280
+ return cells;
1281
+ });
1282
+ const wasteLines = [];
1283
+ wasteLines.push(
1284
+ `${c.bold(c.red("\u26A0 waste"))} (premium model on cheap work): ${c.bold(c.red(fmtUsd(m.waste.totalUsd)))}` + (m.waste.pctOfSpend !== null ? ` = ${c.bold(fmtPct(m.waste.pctOfSpend))} of spend` : "")
1285
+ );
1286
+ wasteLines.push(
1287
+ ` exploration on premium: ${fmtUsd(m.waste.explorationUsd)} \xB7 execution on premium: ${fmtUsd(m.waste.executionUsd)}` + (m.waste.fastModeExtraUsd > WASTE_EPSILON ? ` \xB7 fast-mode premium: ${fmtUsd(m.waste.fastModeExtraUsd)}` : "")
1288
+ );
1289
+ out.push(
1290
+ `${header("Cost by phase \xD7 model")}
1291
+ ${renderTable(
1292
+ [{ header: "Model" }, ...PHASES.map((p) => ({ header: cap(p), align: "right" }))],
1293
+ matrixRows
1294
+ )}
1295
+
1296
+ ${wasteLines.join("\n")}`
1297
+ );
1298
+ const ch = m.cache;
1299
+ const cacheLines = [
1300
+ `Overall hit rate: ${paintHitRate(ch.overall.hitRate)} (read ${fmtTokens(ch.overall.cacheRead)} \xB7 write ${fmtTokens(ch.overall.cacheWrite)} \xB7 uncached input ${fmtTokens(ch.overall.input)})`
1301
+ ];
1302
+ if (ch.compactions.total > 0) {
1303
+ cacheLines.push(
1304
+ `Compactions: ${ch.compactions.total} across ${ch.compactions.sessionsAffected} session(s) \xB7 ~${fmtTokens(ch.compactions.tokensCompacted)} tokens re-summarized`
1305
+ );
1306
+ }
1307
+ const cacheTable = ch.byProject.length > 0 ? `
1308
+ ${renderTable(
1309
+ [
1310
+ { header: "Project (worst hit rate first)" },
1311
+ { header: "Turns", align: "right" },
1312
+ { header: "Hit rate", align: "right" },
1313
+ { header: "Read", align: "right" },
1314
+ { header: "Write", align: "right" }
1315
+ ],
1316
+ ch.byProject.map((p) => [
1317
+ basename(p.project),
1318
+ String(p.turns),
1319
+ {
1320
+ text: fmtPct(p.hitRate),
1321
+ paint: p.hitRate !== null && p.hitRate < 0.6 ? c.yellow : void 0
1322
+ },
1323
+ fmtTokens(p.cacheRead),
1324
+ fmtTokens(p.cacheWrite)
1325
+ ])
1326
+ )}` : "";
1327
+ out.push(`${header("Cache health")}
1328
+ ${cacheLines.join("\n")}${cacheTable}`);
1329
+ if (m.topSessions.length > 0) {
1330
+ out.push(
1331
+ `${header("Top 5 most wasteful sessions")}
1332
+ ${renderTable(
1333
+ [
1334
+ { header: "#", align: "right" },
1335
+ { header: "Session" },
1336
+ { header: "Project" },
1337
+ { header: "Date" },
1338
+ { header: "Turns", align: "right" },
1339
+ { header: "Cost", align: "right" },
1340
+ { header: "Waste", align: "right" },
1341
+ { header: "Dominant anti-pattern" }
1342
+ ],
1343
+ m.topSessions.map((s, i) => [
1344
+ String(i + 1),
1345
+ s.label,
1346
+ s.project,
1347
+ s.date,
1348
+ String(s.turns),
1349
+ fmtUsd(s.costUsd),
1350
+ { text: fmtUsd(s.wasteUsd), paint: s.wasteUsd > WASTE_EPSILON ? c.red : void 0 },
1351
+ s.antiPattern
1352
+ ])
1353
+ )}
1354
+ ${c.dim(` drill down: tktuner report --debug-session <session>`)}`
1355
+ );
1356
+ }
1357
+ if (m.subagentLeaks.length > 0) {
1358
+ out.push(
1359
+ `${header("Subagent leaks (premium model in delegated runs)")}
1360
+ ${renderTable(
1361
+ [
1362
+ { header: "Agent type" },
1363
+ { header: "Model" },
1364
+ { header: "Runs", align: "right" },
1365
+ { header: "Est. cost", align: "right" }
1366
+ ],
1367
+ m.subagentLeaks.map((l) => [
1368
+ l.agentType,
1369
+ prettyModel(l.model),
1370
+ String(l.runs),
1371
+ { text: fmtUsd(l.costUsd), paint: c.red }
1372
+ ])
1373
+ )}
1374
+ ${c.dim(" fix: pin model: haiku|sonnet in the agent frontmatter (.claude/agents/*.md)")}`
1375
+ );
1376
+ } else {
1377
+ out.push(`${header("Subagent leaks")}
1378
+ ${c.green("No subagent runs on premium models.")}`);
1379
+ }
1380
+ const maxWindowCost = Math.max(...m.quota.last7d.map((w) => w.costUsd), 0);
1381
+ const quotaParts = [];
1382
+ if (m.quota.last7d.length > 0) {
1383
+ quotaParts.push(
1384
+ renderTable(
1385
+ [
1386
+ { header: `5h window start (last 7d)` },
1387
+ { header: "Turns", align: "right" },
1388
+ { header: "Tokens", align: "right" },
1389
+ { header: "Cost-equivalent", align: "right" },
1390
+ { header: "" }
1391
+ ],
1392
+ m.quota.last7d.map((w) => [
1393
+ w.start,
1394
+ String(w.turns),
1395
+ fmtTokens(w.totalTokens),
1396
+ fmtUsd(w.costUsd),
1397
+ { text: bar(w.costUsd, maxWindowCost, 14), paint: c.cyan }
1398
+ ])
1399
+ )
1400
+ );
1401
+ }
1402
+ const maxWeekCost = Math.max(...m.quota.weekly.map((w) => w.costUsd), 0);
1403
+ if (m.quota.weekly.length > 0) {
1404
+ quotaParts.push(
1405
+ renderTable(
1406
+ [
1407
+ { header: "Week" },
1408
+ { header: "Turns", align: "right" },
1409
+ { header: "Tokens", align: "right" },
1410
+ { header: "Cost-equivalent", align: "right" },
1411
+ { header: "" }
1412
+ ],
1413
+ m.quota.weekly.map((w) => [
1414
+ w.week,
1415
+ String(w.turns),
1416
+ fmtTokens(w.totalTokens),
1417
+ fmtUsd(w.costUsd),
1418
+ { text: bar(w.costUsd, maxWeekCost, 14), paint: c.magenta }
1419
+ ])
1420
+ )
1421
+ );
1422
+ }
1423
+ quotaParts.push(c.dim(` ${m.quota.note}`));
1424
+ out.push(`${header("Quota pressure")}
1425
+ ${quotaParts.join("\n\n")}`);
1426
+ if (m.recommendations.length > 0) {
1427
+ out.push(
1428
+ `${header("Recommendations")}
1429
+ ${m.recommendations.map((r, i) => ` ${c.green(`${i + 1}.`)} ${r}`).join("\n")}`
1430
+ );
1431
+ }
1432
+ out.push(
1433
+ c.dim(
1434
+ [
1435
+ m.waste.assumption,
1436
+ `Prices v${m.priceTableVersion} (platform.claude.com) \u2014 override with --prices <file>.`
1437
+ ].join("\n")
1438
+ )
1439
+ );
1440
+ return `
1441
+ ${out.join("\n\n")}
1442
+ `;
1443
+ }
1444
+ function renderDebugSession(turns, events, prices, idPrefix) {
1445
+ const match = turns.filter(
1446
+ (t) => t.sessionId.startsWith(idPrefix) || rootSessionId(t).startsWith(idPrefix) || t.slug?.startsWith(idPrefix)
1447
+ );
1448
+ if (match.length === 0) {
1449
+ return c.yellow(`No turns found for session prefix "${idPrefix}".`);
1450
+ }
1451
+ const classified = classifyTurns(match, events).sort(
1452
+ (a, b) => a.turn.timestamp - b.turn.timestamp
1453
+ );
1454
+ const first = classified[0];
1455
+ const totalCost = classified.reduce((acc, ct) => acc + (prices.turnCostUsd(ct.turn) ?? 0), 0);
1456
+ const sessions = new Set(match.map((t) => t.sessionId));
1457
+ const head = `${c.bold("Session debug")} \u2014 ${idPrefix} (${sessions.size} session file(s), ${match.length} turns)
1458
+ project ${first.turn.projectPath || "\u2014"} \xB7 ${fmtDate(first.turn.timestamp)} \xB7 est. cost ${c.bold(fmtUsd(totalCost))}`;
1459
+ const table = renderTable(
1460
+ [
1461
+ { header: "Time" },
1462
+ { header: "Model" },
1463
+ { header: "Raw", align: "left" },
1464
+ { header: "Smoothed", align: "left" },
1465
+ { header: "Think", align: "right" },
1466
+ { header: "Out", align: "right" },
1467
+ { header: "Cost", align: "right" },
1468
+ { header: "Tools" }
1469
+ ],
1470
+ classified.map((ct) => [
1471
+ fmtTime(ct.turn.timestamp),
1472
+ prettyModel(ct.turn.model),
1473
+ ct.phase,
1474
+ ct.phase === ct.phaseSmoothed ? ct.phaseSmoothed : { text: ct.phaseSmoothed, paint: c.yellow },
1475
+ fmtTokens(ct.turn.thinkingChars),
1476
+ fmtTokens(ct.turn.usage.output),
1477
+ fmtUsd(prices.turnCostUsd(ct.turn)),
1478
+ truncate(ct.turn.tools.join(","), 40) || c.dim("\u2014")
1479
+ ])
1480
+ );
1481
+ const sessionEvents = events.filter((e) => sessions.has(e.sessionId) && e.kind !== "turn_duration" && e.timestamp).sort((a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0)).map((e) => ` ${fmtTime(e.timestamp ?? 0)} ${e.kind}`).join("\n");
1482
+ return `
1483
+ ${head}
1484
+
1485
+ ${table}${sessionEvents ? `
1486
+
1487
+ ${c.bold("Events")}
1488
+ ${sessionEvents}` : ""}
1489
+ `;
1490
+ }
1491
+ function header(title) {
1492
+ const line = "\u2501".repeat(Math.max(4, 62 - title.length));
1493
+ return c.bold(c.cyan(`\u2501\u2501 ${title} ${line.slice(0, Math.max(2, 58 - title.length))}`));
1494
+ }
1495
+ function cap(s) {
1496
+ return s.charAt(0).toUpperCase() + s.slice(1);
1497
+ }
1498
+ function paintHitRate(rate) {
1499
+ const text = fmtPct(rate);
1500
+ if (rate === null) return text;
1501
+ if (rate >= 0.8) return c.green(c.bold(text));
1502
+ if (rate >= 0.6) return c.yellow(c.bold(text));
1503
+ return c.red(c.bold(text));
1504
+ }
1505
+ function bar(value, max, width) {
1506
+ if (max <= 0 || value <= 0) return "";
1507
+ return "\u2588".repeat(Math.max(1, Math.round(value / max * width)));
1508
+ }
1509
+
1510
+ // src/store/db.ts
1511
+ import { mkdirSync as mkdirSync2 } from "fs";
1512
+ import { dirname, join as join3 } from "path";
1513
+ import Database from "better-sqlite3";
1514
+
1515
+ // src/store/schema.ts
1516
+ var SCHEMA_VERSION = 1;
1517
+ var DDL = `
1518
+ CREATE TABLE IF NOT EXISTS files (
1519
+ path TEXT PRIMARY KEY,
1520
+ mtime_ms INTEGER NOT NULL,
1521
+ size INTEGER NOT NULL,
1522
+ byte_offset INTEGER NOT NULL DEFAULT 0,
1523
+ kind TEXT NOT NULL,
1524
+ session_id TEXT,
1525
+ agent_id TEXT,
1526
+ parent_session_id TEXT,
1527
+ last_scanned_at INTEGER NOT NULL
1528
+ );
1529
+
1530
+ CREATE TABLE IF NOT EXISTS turns (
1531
+ uuid TEXT PRIMARY KEY,
1532
+ message_id TEXT,
1533
+ request_id TEXT,
1534
+ session_id TEXT NOT NULL,
1535
+ agent_id TEXT,
1536
+ parent_session_id TEXT,
1537
+ is_sidechain INTEGER NOT NULL DEFAULT 0,
1538
+ ts INTEGER NOT NULL DEFAULT 0,
1539
+ model TEXT NOT NULL,
1540
+ input_tokens INTEGER NOT NULL DEFAULT 0,
1541
+ output_tokens INTEGER NOT NULL DEFAULT 0,
1542
+ cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
1543
+ cache_read_tokens INTEGER NOT NULL DEFAULT 0,
1544
+ cache_5m_tokens INTEGER NOT NULL DEFAULT 0,
1545
+ cache_1h_tokens INTEGER NOT NULL DEFAULT 0,
1546
+ tools TEXT NOT NULL DEFAULT '[]',
1547
+ num_tool_uses INTEGER NOT NULL DEFAULT 0,
1548
+ thinking_chars INTEGER NOT NULL DEFAULT 0,
1549
+ text_chars INTEGER NOT NULL DEFAULT 0,
1550
+ stop_reason TEXT,
1551
+ cwd TEXT,
1552
+ slug TEXT,
1553
+ git_branch TEXT,
1554
+ version TEXT,
1555
+ speed TEXT,
1556
+ inference_geo TEXT,
1557
+ file_path TEXT NOT NULL
1558
+ );
1559
+ CREATE INDEX IF NOT EXISTS idx_turns_session ON turns(session_id);
1560
+ CREATE INDEX IF NOT EXISTS idx_turns_ts ON turns(ts);
1561
+ CREATE INDEX IF NOT EXISTS idx_turns_file ON turns(file_path);
1562
+ CREATE INDEX IF NOT EXISTS idx_turns_msg ON turns(message_id);
1563
+
1564
+ CREATE TABLE IF NOT EXISTS events (
1565
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1566
+ session_id TEXT NOT NULL,
1567
+ ts INTEGER,
1568
+ kind TEXT NOT NULL,
1569
+ data TEXT NOT NULL DEFAULT '{}',
1570
+ file_path TEXT NOT NULL
1571
+ );
1572
+ CREATE INDEX IF NOT EXISTS idx_events_session ON events(session_id, ts);
1573
+ CREATE INDEX IF NOT EXISTS idx_events_file ON events(file_path);
1574
+
1575
+ CREATE TABLE IF NOT EXISTS meta (
1576
+ key TEXT PRIMARY KEY,
1577
+ value TEXT NOT NULL
1578
+ );
1579
+ `;
1580
+
1581
+ // src/store/db.ts
1582
+ function defaultDbPath() {
1583
+ return join3(dataDir(), "tktuner.db");
1584
+ }
1585
+ function openDb(path) {
1586
+ const dbPath = path ?? defaultDbPath();
1587
+ mkdirSync2(dirname(dbPath), { recursive: true });
1588
+ const db = new Database(dbPath);
1589
+ db.pragma("journal_mode = WAL");
1590
+ migrate(db);
1591
+ return db;
1592
+ }
1593
+ function migrate(db) {
1594
+ rebuildIfPreRelease(db);
1595
+ db.exec(DDL);
1596
+ const row = db.prepare(`SELECT value FROM meta WHERE key = 'schema_version'`).get();
1597
+ const current = row ? Number(row.value) : 0;
1598
+ if (current > SCHEMA_VERSION) {
1599
+ throw new Error(
1600
+ `Database schema v${current} is newer than this tktuner build supports (v${SCHEMA_VERSION}). Upgrade tktuner.`
1601
+ );
1602
+ }
1603
+ db.prepare(
1604
+ `INSERT INTO meta (key, value) VALUES ('schema_version', ?)
1605
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`
1606
+ ).run(String(SCHEMA_VERSION));
1607
+ }
1608
+ function rebuildIfPreRelease(db) {
1609
+ const table = db.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'turns'`).get();
1610
+ if (!table) return;
1611
+ const cols = db.prepare(`PRAGMA table_info(turns)`).all();
1612
+ if (cols.some((c2) => c2.name === "message_id")) return;
1613
+ db.exec(`DROP TABLE IF EXISTS turns; DROP TABLE IF EXISTS events; DROP TABLE IF EXISTS files;`);
1614
+ }
1615
+
1616
+ // src/store/queries.ts
1617
+ var Store = class {
1618
+ constructor(db) {
1619
+ this.db = db;
1620
+ this.stmtGetFile = db.prepare(
1621
+ `SELECT path, mtime_ms AS mtimeMs, size, byte_offset AS byteOffset, kind
1622
+ FROM files WHERE path = ?`
1623
+ );
1624
+ this.stmtUpsertFile = db.prepare(
1625
+ `INSERT INTO files (path, mtime_ms, size, byte_offset, kind, session_id, agent_id, parent_session_id, last_scanned_at)
1626
+ VALUES (@path, @mtimeMs, @size, @byteOffset, @kind, @sessionId, @agentId, @parentSessionId, @scannedAt)
1627
+ ON CONFLICT(path) DO UPDATE SET
1628
+ mtime_ms = excluded.mtime_ms,
1629
+ size = excluded.size,
1630
+ byte_offset = excluded.byte_offset,
1631
+ last_scanned_at = excluded.last_scanned_at`
1632
+ );
1633
+ this.stmtInsertTurn = db.prepare(
1634
+ `INSERT OR IGNORE INTO turns (
1635
+ uuid, message_id, request_id, session_id, agent_id, parent_session_id, is_sidechain, ts, model,
1636
+ input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens,
1637
+ cache_5m_tokens, cache_1h_tokens, tools, num_tool_uses, thinking_chars,
1638
+ text_chars, stop_reason, cwd, slug, git_branch, version, speed, inference_geo, file_path
1639
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
1640
+ );
1641
+ this.stmtInsertEvent = db.prepare(
1642
+ `INSERT INTO events (session_id, ts, kind, data, file_path) VALUES (?, ?, ?, ?, ?)`
1643
+ );
1644
+ this.txIngest = db.transaction((file, batch) => {
1645
+ let newTurns = 0;
1646
+ for (const t of batch.turns) {
1647
+ const info = this.stmtInsertTurn.run(
1648
+ t.uuid,
1649
+ t.messageId ?? null,
1650
+ t.requestId ?? null,
1651
+ t.sessionId,
1652
+ t.agentId ?? null,
1653
+ t.parentSessionId ?? null,
1654
+ t.isSidechain ? 1 : 0,
1655
+ t.timestamp,
1656
+ t.model,
1657
+ t.usage.input,
1658
+ t.usage.output,
1659
+ t.usage.cacheWrite,
1660
+ t.usage.cacheRead,
1661
+ t.usage.cacheWrite5m,
1662
+ t.usage.cacheWrite1h,
1663
+ JSON.stringify(t.tools),
1664
+ t.numToolUses,
1665
+ t.thinkingChars,
1666
+ t.textChars,
1667
+ t.stopReason ?? null,
1668
+ t.projectPath || null,
1669
+ t.slug ?? null,
1670
+ t.gitBranch ?? null,
1671
+ t.cliVersion ?? null,
1672
+ t.speed ?? null,
1673
+ t.inferenceGeo ?? null,
1674
+ t.filePath
1675
+ );
1676
+ newTurns += info.changes;
1677
+ }
1678
+ for (const e of batch.events) {
1679
+ this.stmtInsertEvent.run(
1680
+ e.sessionId,
1681
+ e.timestamp ?? null,
1682
+ e.kind,
1683
+ JSON.stringify(e.data),
1684
+ e.filePath
1685
+ );
1686
+ }
1687
+ this.stmtUpsertFile.run({
1688
+ path: file.path,
1689
+ mtimeMs: file.mtimeMs,
1690
+ size: file.sizeBytes,
1691
+ byteOffset: batch.bytesConsumed,
1692
+ kind: file.kind,
1693
+ sessionId: file.sessionId ?? null,
1694
+ agentId: file.agentId ?? null,
1695
+ parentSessionId: file.parentSessionId ?? null,
1696
+ scannedAt: Date.now()
1697
+ });
1698
+ return { newTurns, newEvents: batch.events.length };
1699
+ });
1700
+ }
1701
+ db;
1702
+ stmtGetFile;
1703
+ stmtUpsertFile;
1704
+ stmtInsertTurn;
1705
+ stmtInsertEvent;
1706
+ txIngest;
1707
+ getFileState(path) {
1708
+ return this.stmtGetFile.get(path);
1709
+ }
1710
+ /** Remove everything ingested from one file so it can be reparsed cleanly. */
1711
+ deleteFileData(path) {
1712
+ this.db.prepare(`DELETE FROM turns WHERE file_path = ?`).run(path);
1713
+ this.db.prepare(`DELETE FROM events WHERE file_path = ?`).run(path);
1714
+ this.db.prepare(`DELETE FROM files WHERE path = ?`).run(path);
1715
+ }
1716
+ ingestBatch(file, batch) {
1717
+ return this.txIngest(file, batch);
1718
+ }
1719
+ /** Merge unknown line-type counts into the persistent parser-evolution log. */
1720
+ bumpUnknownTypes(counts) {
1721
+ if (Object.keys(counts).length === 0) return;
1722
+ const merged = { ...this.getUnknownTypes() };
1723
+ for (const [k, v] of Object.entries(counts)) merged[k] = (merged[k] ?? 0) + v;
1724
+ this.setMeta("unknown_line_types", JSON.stringify(merged));
1725
+ }
1726
+ getUnknownTypes() {
1727
+ const raw = this.getMeta("unknown_line_types");
1728
+ if (!raw) return {};
1729
+ try {
1730
+ return JSON.parse(raw);
1731
+ } catch {
1732
+ return {};
1733
+ }
1734
+ }
1735
+ getMeta(key) {
1736
+ const row = this.db.prepare(`SELECT value FROM meta WHERE key = ?`).get(key);
1737
+ return row?.value;
1738
+ }
1739
+ setMeta(key, value) {
1740
+ this.db.prepare(
1741
+ `INSERT INTO meta (key, value) VALUES (?, ?)
1742
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`
1743
+ ).run(key, value);
1744
+ }
1745
+ /** Real (merged) turn count — content-block lines collapse by message_id. */
1746
+ countTurns() {
1747
+ const row = this.db.prepare(`SELECT COUNT(DISTINCT COALESCE(message_id, uuid)) AS n FROM turns`).get();
1748
+ return row.n;
1749
+ }
1750
+ /**
1751
+ * Loads billable turns. Raw rows are one-per-content-block (Claude Code
1752
+ * splits an API response across JSONL lines, repeating the same usage), so
1753
+ * rows sharing a message_id are merged here into a single TurnEvent.
1754
+ *
1755
+ * The project filter matches the project NAME (basename of cwd), case-
1756
+ * insensitive — the same semantics as the dashboard, which only ever sees
1757
+ * hashed ids + names. Full-path matching would also catch unrelated
1758
+ * directories that merely contain the string.
1759
+ */
1760
+ loadTurns(filter = {}) {
1761
+ const clauses = [];
1762
+ const params = [];
1763
+ if (filter.sinceTs !== void 0) {
1764
+ clauses.push(`ts >= ?`);
1765
+ params.push(filter.sinceTs);
1766
+ }
1767
+ const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
1768
+ let rows = this.db.prepare(`SELECT * FROM turns ${where} ORDER BY ts ASC, uuid ASC`).all(...params);
1769
+ if (filter.projectLike) {
1770
+ const needle = filter.projectLike.toLowerCase();
1771
+ rows = rows.filter(
1772
+ (r) => basename(r.cwd ?? "").toLowerCase().includes(needle)
1773
+ );
1774
+ }
1775
+ return mergeTurnLines(rows.map(rowToTurn));
1776
+ }
1777
+ /** Distinct turnKeys touched by raw lines after `rowid` + the new watermark. */
1778
+ newTurnKeysSince(rowid) {
1779
+ const rows = this.db.prepare(`SELECT rowid AS rid, COALESCE(message_id, uuid) AS k FROM turns WHERE rowid > ?`).all(rowid);
1780
+ const keys = /* @__PURE__ */ new Set();
1781
+ let maxRowid = rowid;
1782
+ for (const r of rows) {
1783
+ keys.add(r.k);
1784
+ if (r.rid > maxRowid) maxRowid = r.rid;
1785
+ }
1786
+ return { keys: [...keys], maxRowid };
1787
+ }
1788
+ /**
1789
+ * Loads and merges the ENTIRE line group of each turnKey (late-arriving
1790
+ * content blocks re-push the whole merged turn — the server upsert
1791
+ * overwrites). Each merged turn carries its group's max rowid so the sync
1792
+ * watermark can advance per confirmed batch.
1793
+ */
1794
+ loadTurnGroups(keys) {
1795
+ if (keys.length === 0) return [];
1796
+ const lines = [];
1797
+ const maxRowidByKey = /* @__PURE__ */ new Map();
1798
+ for (let i = 0; i < keys.length; i += 500) {
1799
+ const chunk = keys.slice(i, i + 500);
1800
+ const placeholders = chunk.map(() => "?").join(",");
1801
+ const rows = this.db.prepare(
1802
+ `SELECT rowid AS rid, * FROM turns
1803
+ WHERE COALESCE(message_id, uuid) IN (${placeholders})
1804
+ ORDER BY ts ASC, uuid ASC`
1805
+ ).all(...chunk);
1806
+ for (const r of rows) {
1807
+ const key = r.message_id ?? r.uuid;
1808
+ const prev = maxRowidByKey.get(key) ?? 0;
1809
+ if (r.rid > prev) maxRowidByKey.set(key, r.rid);
1810
+ lines.push(rowToTurn(r));
1811
+ }
1812
+ }
1813
+ return mergeTurnLines(lines).map((turn) => ({
1814
+ turn,
1815
+ maxRowid: maxRowidByKey.get(turn.messageId ?? turn.uuid) ?? 0
1816
+ })).sort((a, b) => a.maxRowid - b.maxRowid);
1817
+ }
1818
+ /** Raw events after `id`, oldest first (insert-only; id is the watermark). */
1819
+ eventsSince(id) {
1820
+ const rows = this.db.prepare(
1821
+ `SELECT id, session_id, ts, kind, data, file_path FROM events WHERE id > ? ORDER BY id ASC`
1822
+ ).all(id);
1823
+ return rows.map((r) => {
1824
+ let data = {};
1825
+ try {
1826
+ data = JSON.parse(r.data);
1827
+ } catch {
1828
+ }
1829
+ return {
1830
+ id: r.id,
1831
+ event: {
1832
+ kind: r.kind,
1833
+ sessionId: r.session_id,
1834
+ timestamp: r.ts ?? void 0,
1835
+ filePath: r.file_path,
1836
+ data
1837
+ }
1838
+ };
1839
+ });
1840
+ }
1841
+ loadEvents() {
1842
+ const rows = this.db.prepare(`SELECT session_id, ts, kind, data, file_path FROM events ORDER BY ts ASC`).all();
1843
+ return rows.map((r) => {
1844
+ let data = {};
1845
+ try {
1846
+ data = JSON.parse(r.data);
1847
+ } catch {
1848
+ }
1849
+ return {
1850
+ kind: r.kind,
1851
+ sessionId: r.session_id,
1852
+ timestamp: r.ts ?? void 0,
1853
+ filePath: r.file_path,
1854
+ data
1855
+ };
1856
+ });
1857
+ }
1858
+ };
1859
+ function rowToTurn(r) {
1860
+ let tools = [];
1861
+ try {
1862
+ const parsed = JSON.parse(r.tools);
1863
+ if (Array.isArray(parsed)) tools = parsed.filter((x) => typeof x === "string");
1864
+ } catch {
1865
+ }
1866
+ return {
1867
+ agent: "claude-code",
1868
+ uuid: r.uuid,
1869
+ messageId: r.message_id ?? void 0,
1870
+ requestId: r.request_id ?? void 0,
1871
+ sessionId: r.session_id,
1872
+ parentSessionId: r.parent_session_id ?? void 0,
1873
+ agentId: r.agent_id ?? void 0,
1874
+ isSidechain: r.is_sidechain === 1,
1875
+ projectPath: r.cwd ?? "",
1876
+ slug: r.slug ?? void 0,
1877
+ timestamp: r.ts,
1878
+ model: r.model,
1879
+ usage: {
1880
+ input: r.input_tokens,
1881
+ output: r.output_tokens,
1882
+ cacheRead: r.cache_read_tokens,
1883
+ cacheWrite: r.cache_creation_tokens,
1884
+ cacheWrite5m: r.cache_5m_tokens,
1885
+ cacheWrite1h: r.cache_1h_tokens
1886
+ },
1887
+ tools,
1888
+ numToolUses: r.num_tool_uses,
1889
+ thinkingChars: r.thinking_chars,
1890
+ textChars: r.text_chars,
1891
+ stopReason: r.stop_reason ?? void 0,
1892
+ gitBranch: r.git_branch ?? void 0,
1893
+ cliVersion: r.version ?? void 0,
1894
+ speed: r.speed ?? void 0,
1895
+ inferenceGeo: r.inference_geo ?? void 0,
1896
+ filePath: r.file_path
1897
+ };
1898
+ }
1899
+
1900
+ // src/cli/report.ts
1901
+ async function report(opts) {
1902
+ if (opts.color === false || opts.json) setColors(false);
1903
+ const days = opts.all ? null : Number(opts.days);
1904
+ if (days !== null && (!Number.isFinite(days) || days <= 0)) {
1905
+ console.error(pc4.red(`Invalid --days value: ${opts.days}`));
1906
+ process.exitCode = 1;
1907
+ return;
1908
+ }
1909
+ const db = openDb(opts.db);
1910
+ try {
1911
+ const store = new Store(db);
1912
+ if (store.countTurns() === 0) {
1913
+ console.log(`No data yet \u2014 run ${pc4.bold("tktuner scan")} first.`);
1914
+ return;
1915
+ }
1916
+ let prices;
1917
+ try {
1918
+ prices = loadPriceTable(opts.prices);
1919
+ } catch (err) {
1920
+ console.error(pc4.red(`Could not load price override ${opts.prices}: ${String(err)}`));
1921
+ process.exitCode = 1;
1922
+ return;
1923
+ }
1924
+ const sinceTs = days === null ? void 0 : Date.now() - days * 864e5;
1925
+ const turns = store.loadTurns({ sinceTs, projectLike: opts.project });
1926
+ if (turns.length === 0) {
1927
+ console.log(
1928
+ `No turns in the selected window${opts.project ? ` for project filter "${opts.project}"` : ""}. Try --all or a larger --days.`
1929
+ );
1930
+ return;
1931
+ }
1932
+ const events = store.loadEvents();
1933
+ if (opts.debugSession) {
1934
+ console.log(renderDebugSession(turns, events, prices, opts.debugSession));
1935
+ return;
1936
+ }
1937
+ const model = buildReport(turns, events, prices, {
1938
+ days,
1939
+ project: opts.project ?? null
1940
+ });
1941
+ if (opts.json) {
1942
+ console.log(JSON.stringify(model, null, 2));
1943
+ } else {
1944
+ console.log(renderReport(model));
1945
+ }
1946
+ } finally {
1947
+ db.close();
1948
+ }
1949
+ }
1950
+
1951
+ // src/cli/scan.ts
1952
+ import pc5 from "picocolors";
1953
+
1954
+ // src/adapters/claude-code/discover.ts
1955
+ import { existsSync as existsSync2, readdirSync, statSync } from "fs";
1956
+ import { homedir as homedir2 } from "os";
1957
+ import { join as join4, relative, sep } from "path";
1958
+ var SESSION_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.jsonl$/i;
1959
+ var AGENT_RE = /^agent-([0-9a-z_-]+)\.jsonl$/i;
1960
+ var SKIP_DIRS = /* @__PURE__ */ new Set(["memory", "tool-results"]);
1961
+ function detectClaudeRoot(override) {
1962
+ const root = override ?? join4(homedir2(), ".claude", "projects");
1963
+ return existsSync2(root) ? { found: true, rootDirs: [root] } : { found: false, rootDirs: [] };
1964
+ }
1965
+ function listLogFiles(rootDirs) {
1966
+ const out = [];
1967
+ for (const root of rootDirs) {
1968
+ let entries;
1969
+ try {
1970
+ entries = readdirSync(root, { recursive: true, withFileTypes: true });
1971
+ } catch {
1972
+ continue;
1973
+ }
1974
+ for (const ent of entries) {
1975
+ if (!ent.isFile() || !ent.name.endsWith(".jsonl")) continue;
1976
+ if (ent.name === "journal.jsonl") continue;
1977
+ const dirent = ent;
1978
+ const parent = dirent.parentPath ?? dirent.path ?? root;
1979
+ const full = join4(parent, ent.name);
1980
+ const segs = relative(root, full).split(sep);
1981
+ if (segs.some((s) => SKIP_DIRS.has(s))) continue;
1982
+ let st;
1983
+ try {
1984
+ st = statSync(full);
1985
+ } catch {
1986
+ continue;
1987
+ }
1988
+ const base = { path: full, sizeBytes: st.size, mtimeMs: Math.trunc(st.mtimeMs) };
1989
+ const agentMatch = AGENT_RE.exec(ent.name);
1990
+ const subIdx = segs.indexOf("subagents");
1991
+ if (agentMatch && subIdx > 0) {
1992
+ out.push({
1993
+ ...base,
1994
+ kind: "subagent",
1995
+ agentId: agentMatch[1],
1996
+ parentSessionId: segs[subIdx - 1]
1997
+ });
1998
+ } else if (segs.length === 2 && SESSION_RE.test(ent.name)) {
1999
+ out.push({ ...base, kind: "session", sessionId: ent.name.slice(0, -".jsonl".length) });
2000
+ } else {
2001
+ out.push({ ...base, kind: "other" });
2002
+ }
2003
+ }
2004
+ }
2005
+ out.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
2006
+ return out;
2007
+ }
2008
+
2009
+ // src/adapters/claude-code/jsonl.ts
2010
+ import { createReadStream } from "fs";
2011
+ var NL = 10;
2012
+ var CR = 13;
2013
+ async function* iterateJsonlLines(path, fromOffset = 0) {
2014
+ const stream = createReadStream(path, { start: fromOffset });
2015
+ let pending = [];
2016
+ let pendingBytes = 0;
2017
+ let offset = fromOffset;
2018
+ for await (const chunk of stream) {
2019
+ let start = 0;
2020
+ while (start < chunk.length) {
2021
+ const idx = chunk.indexOf(NL, start);
2022
+ if (idx === -1) {
2023
+ pending.push(chunk.subarray(start));
2024
+ pendingBytes += chunk.length - start;
2025
+ break;
2026
+ }
2027
+ const slice = chunk.subarray(start, idx);
2028
+ const lineBuf = pending.length > 0 ? Buffer.concat([...pending, slice]) : slice;
2029
+ pending = [];
2030
+ pendingBytes = 0;
2031
+ offset += lineBuf.length + 1;
2032
+ yield { text: lineText(lineBuf), endOffset: offset, terminated: true };
2033
+ start = idx + 1;
2034
+ }
2035
+ }
2036
+ if (pendingBytes > 0) {
2037
+ const lineBuf = Buffer.concat(pending);
2038
+ yield { text: lineText(lineBuf), endOffset: offset + lineBuf.length, terminated: false };
2039
+ }
2040
+ }
2041
+ function lineText(buf) {
2042
+ const end = buf.length > 0 && buf[buf.length - 1] === CR ? buf.length - 1 : buf.length;
2043
+ return buf.toString("utf8", 0, end);
2044
+ }
2045
+
2046
+ // src/adapters/claude-code/parse.ts
2047
+ var SIDECAR_TYPES = /* @__PURE__ */ new Set([
2048
+ "ai-title",
2049
+ "mode",
2050
+ "permission-mode",
2051
+ "last-prompt",
2052
+ "agent-name",
2053
+ "file-history-snapshot",
2054
+ "queue-operation",
2055
+ "queued-command",
2056
+ "started",
2057
+ "result",
2058
+ "frame-link",
2059
+ "summary"
2060
+ ]);
2061
+ function parseLine(raw, file) {
2062
+ const text = raw.trim();
2063
+ if (text === "") return {};
2064
+ let obj;
2065
+ try {
2066
+ const parsed = JSON.parse(text);
2067
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
2068
+ return { parseError: true };
2069
+ }
2070
+ obj = parsed;
2071
+ } catch {
2072
+ return { parseError: true };
2073
+ }
2074
+ const type = obj.type;
2075
+ if (typeof type !== "string") return { parseError: true };
2076
+ switch (type) {
2077
+ case "assistant":
2078
+ return assistantToTurn(obj, file);
2079
+ case "user":
2080
+ return userToEvent(obj, file);
2081
+ case "system":
2082
+ return systemToEvent(obj, file);
2083
+ case "attachment":
2084
+ return attachmentToEvent(obj, file);
2085
+ default:
2086
+ return SIDECAR_TYPES.has(type) ? {} : { unknownType: type };
2087
+ }
2088
+ }
2089
+ function assistantToTurn(obj, file) {
2090
+ const uuid = str(obj.uuid);
2091
+ const msg = obj.message;
2092
+ if (!uuid || typeof msg !== "object" || msg === null) return { parseError: true };
2093
+ const message = msg;
2094
+ const usage = asRecord(message.usage);
2095
+ const cacheCreation = asRecord(usage.cache_creation);
2096
+ const write5m = num(cacheCreation.ephemeral_5m_input_tokens);
2097
+ const write1h = num(cacheCreation.ephemeral_1h_input_tokens);
2098
+ const cacheWrite = usage.cache_creation_input_tokens !== void 0 ? num(usage.cache_creation_input_tokens) : write5m + write1h;
2099
+ const tools = [];
2100
+ let thinkingChars = 0;
2101
+ let textChars = 0;
2102
+ const content = Array.isArray(message.content) ? message.content : [];
2103
+ for (const rawBlock of content) {
2104
+ if (typeof rawBlock !== "object" || rawBlock === null) continue;
2105
+ const block = rawBlock;
2106
+ if (block.type === "tool_use") {
2107
+ const name = str(block.name);
2108
+ if (name) tools.push(name);
2109
+ } else if (block.type === "thinking") {
2110
+ thinkingChars += typeof block.thinking === "string" ? block.thinking.length : 0;
2111
+ } else if (block.type === "text") {
2112
+ textChars += typeof block.text === "string" ? block.text.length : 0;
2113
+ }
2114
+ }
2115
+ const turn = {
2116
+ agent: "claude-code",
2117
+ uuid,
2118
+ messageId: str(message.id),
2119
+ requestId: str(obj.requestId),
2120
+ sessionId: str(obj.sessionId) ?? file.sessionId ?? "unknown",
2121
+ parentSessionId: file.parentSessionId,
2122
+ agentId: str(obj.agentId) ?? file.agentId,
2123
+ isSidechain: obj.isSidechain === true,
2124
+ projectPath: str(obj.cwd) ?? "",
2125
+ slug: str(obj.slug),
2126
+ timestamp: parseTs(obj.timestamp) ?? 0,
2127
+ model: str(message.model) ?? "unknown",
2128
+ usage: {
2129
+ input: num(usage.input_tokens),
2130
+ output: num(usage.output_tokens),
2131
+ cacheRead: num(usage.cache_read_input_tokens),
2132
+ cacheWrite,
2133
+ cacheWrite5m: write5m,
2134
+ cacheWrite1h: write1h
2135
+ },
2136
+ tools,
2137
+ numToolUses: tools.length,
2138
+ thinkingChars,
2139
+ textChars,
2140
+ stopReason: str(message.stop_reason),
2141
+ gitBranch: str(obj.gitBranch),
2142
+ cliVersion: str(obj.version),
2143
+ speed: str(usage.speed),
2144
+ inferenceGeo: str(usage.inference_geo),
2145
+ filePath: file.path
2146
+ };
2147
+ return { turn };
2148
+ }
2149
+ function userToEvent(obj, file) {
2150
+ const tur = obj.toolUseResult;
2151
+ if (typeof tur !== "object" || tur === null || Array.isArray(tur)) return {};
2152
+ const r = tur;
2153
+ const agentId = str(r.agentId);
2154
+ const looksLikeAgentResult = agentId !== void 0 && ("resolvedModel" in r || "agentType" in r || "totalTokens" in r);
2155
+ if (!looksLikeAgentResult) return {};
2156
+ return {
2157
+ event: {
2158
+ kind: "agent_result",
2159
+ sessionId: str(obj.sessionId) ?? file.sessionId ?? "unknown",
2160
+ timestamp: parseTs(obj.timestamp),
2161
+ filePath: file.path,
2162
+ data: compact({
2163
+ agentId,
2164
+ agentType: str(r.agentType),
2165
+ resolvedModel: str(r.resolvedModel),
2166
+ status: str(r.status),
2167
+ totalDurationMs: numOrU(r.totalDurationMs),
2168
+ totalTokens: numOrU(r.totalTokens),
2169
+ totalToolUseCount: numOrU(r.totalToolUseCount)
2170
+ })
2171
+ }
2172
+ };
2173
+ }
2174
+ function systemToEvent(obj, file) {
2175
+ const subtype = str(obj.subtype);
2176
+ const sessionId = str(obj.sessionId) ?? file.sessionId ?? "unknown";
2177
+ const timestamp = parseTs(obj.timestamp);
2178
+ if (subtype === "compact_boundary") {
2179
+ const meta = asRecord(obj.compactMetadata);
2180
+ return {
2181
+ event: {
2182
+ kind: "compact_boundary",
2183
+ sessionId,
2184
+ timestamp,
2185
+ filePath: file.path,
2186
+ data: compact({
2187
+ trigger: str(meta.trigger),
2188
+ preTokens: numOrU(meta.preTokens),
2189
+ postTokens: numOrU(meta.postTokens),
2190
+ cumulativeDroppedTokens: numOrU(meta.cumulativeDroppedTokens),
2191
+ durationMs: numOrU(meta.durationMs)
2192
+ })
2193
+ }
2194
+ };
2195
+ }
2196
+ if (subtype === "turn_duration") {
2197
+ return {
2198
+ event: {
2199
+ kind: "turn_duration",
2200
+ sessionId,
2201
+ timestamp,
2202
+ filePath: file.path,
2203
+ data: compact({
2204
+ durationMs: numOrU(obj.durationMs),
2205
+ messageCount: numOrU(obj.messageCount)
2206
+ })
2207
+ }
2208
+ };
2209
+ }
2210
+ return {};
2211
+ }
2212
+ function attachmentToEvent(obj, file) {
2213
+ const att = asRecord(obj.attachment);
2214
+ const attType = str(att.type);
2215
+ if (attType !== "plan_mode" && attType !== "plan_mode_reentry" && attType !== "plan_mode_exit") {
2216
+ return {};
2217
+ }
2218
+ return {
2219
+ event: {
2220
+ kind: attType === "plan_mode_exit" ? "plan_mode_exit" : "plan_mode",
2221
+ sessionId: str(obj.sessionId) ?? file.sessionId ?? "unknown",
2222
+ timestamp: parseTs(obj.timestamp),
2223
+ filePath: file.path,
2224
+ data: {}
2225
+ }
2226
+ };
2227
+ }
2228
+ function str(v) {
2229
+ return typeof v === "string" ? v : void 0;
2230
+ }
2231
+ function num(v) {
2232
+ return typeof v === "number" && Number.isFinite(v) ? v : 0;
2233
+ }
2234
+ function numOrU(v) {
2235
+ return typeof v === "number" && Number.isFinite(v) ? v : void 0;
2236
+ }
2237
+ function parseTs(v) {
2238
+ if (typeof v !== "string") return void 0;
2239
+ const ts = Date.parse(v);
2240
+ return Number.isFinite(ts) ? ts : void 0;
2241
+ }
2242
+ function asRecord(v) {
2243
+ return typeof v === "object" && v !== null && !Array.isArray(v) ? v : {};
2244
+ }
2245
+ function compact(data) {
2246
+ const out = {};
2247
+ for (const [k, v] of Object.entries(data)) if (v !== void 0) out[k] = v;
2248
+ return out;
2249
+ }
2250
+
2251
+ // src/adapters/claude-code/adapter.ts
2252
+ var BATCH_LINES = 2e3;
2253
+ var ClaudeCodeAdapter = class {
2254
+ kind = "claude-code";
2255
+ detect(rootOverride) {
2256
+ return detectClaudeRoot(rootOverride);
2257
+ }
2258
+ listLogFiles(rootDirs) {
2259
+ return listLogFiles(rootDirs);
2260
+ }
2261
+ async *parseFile(file, fromOffset) {
2262
+ let batch = emptyBatch(fromOffset);
2263
+ for await (const line of iterateJsonlLines(file.path, fromOffset)) {
2264
+ const outcome = parseLine(line.text, file);
2265
+ batch.linesParsed++;
2266
+ if (line.terminated) {
2267
+ batch.bytesConsumed = line.endOffset;
2268
+ if (outcome.turn) batch.turns.push(outcome.turn);
2269
+ if (outcome.event) batch.events.push(outcome.event);
2270
+ if (outcome.unknownType) {
2271
+ batch.unknownTypes[outcome.unknownType] = (batch.unknownTypes[outcome.unknownType] ?? 0) + 1;
2272
+ }
2273
+ if (outcome.parseError) batch.parseErrors++;
2274
+ } else {
2275
+ if (outcome.turn) batch.turns.push(outcome.turn);
2276
+ }
2277
+ if (batch.linesParsed >= BATCH_LINES) {
2278
+ yield batch;
2279
+ batch = emptyBatch(batch.bytesConsumed);
2280
+ }
2281
+ }
2282
+ yield batch;
2283
+ }
2284
+ };
2285
+ function emptyBatch(offset) {
2286
+ return {
2287
+ turns: [],
2288
+ events: [],
2289
+ bytesConsumed: offset,
2290
+ linesParsed: 0,
2291
+ parseErrors: 0,
2292
+ unknownTypes: {}
2293
+ };
2294
+ }
2295
+
2296
+ // src/cli/scan.ts
2297
+ async function scan(opts) {
2298
+ const adapter = new ClaudeCodeAdapter();
2299
+ const det = adapter.detect(opts.claudeDir);
2300
+ if (!det.found) {
2301
+ console.error(
2302
+ pc5.red(
2303
+ `No Claude Code data found at ${opts.claudeDir ?? "~/.claude/projects"}. Use --claude-dir to point at your Claude projects directory.`
2304
+ )
2305
+ );
2306
+ process.exitCode = 1;
2307
+ return null;
2308
+ }
2309
+ const t0 = performance.now();
2310
+ const db = openDb(opts.db);
2311
+ const store = new Store(db);
2312
+ const result = {
2313
+ files: 0,
2314
+ skipped: 0,
2315
+ newTurns: 0,
2316
+ newEvents: 0,
2317
+ parseErrors: 0,
2318
+ unknownTypes: {},
2319
+ seconds: 0
2320
+ };
2321
+ try {
2322
+ const files = adapter.listLogFiles(det.rootDirs);
2323
+ for (const file of files) {
2324
+ const state = store.getFileState(file.path);
2325
+ let fromOffset = 0;
2326
+ if (opts.full) {
2327
+ store.deleteFileData(file.path);
2328
+ } else if (state) {
2329
+ if (state.mtimeMs === file.mtimeMs && state.size === file.sizeBytes) {
2330
+ result.skipped++;
2331
+ continue;
2332
+ }
2333
+ if (file.sizeBytes >= state.byteOffset) {
2334
+ fromOffset = state.byteOffset;
2335
+ } else {
2336
+ store.deleteFileData(file.path);
2337
+ }
2338
+ }
2339
+ for await (const batch of adapter.parseFile(file, fromOffset)) {
2340
+ const ingested = store.ingestBatch(file, batch);
2341
+ result.newTurns += ingested.newTurns;
2342
+ result.newEvents += ingested.newEvents;
2343
+ result.parseErrors += batch.parseErrors;
2344
+ for (const [k, v] of Object.entries(batch.unknownTypes)) {
2345
+ result.unknownTypes[k] = (result.unknownTypes[k] ?? 0) + v;
2346
+ }
2347
+ }
2348
+ result.files++;
2349
+ if (opts.verbose) console.log(pc5.dim(` ${file.kind.padEnd(8)} ${file.path}`));
2350
+ }
2351
+ store.bumpUnknownTypes(result.unknownTypes);
2352
+ } finally {
2353
+ db.close();
2354
+ }
2355
+ result.seconds = (performance.now() - t0) / 1e3;
2356
+ if (!opts.silent) {
2357
+ console.log(
2358
+ `${pc5.bold("scan complete")} \u2014 ${result.files} file(s) parsed, ${result.skipped} unchanged, ${pc5.green(String(result.newTurns))} new assistant line(s), ${result.newEvents} event(s) in ${result.seconds.toFixed(1)}s`
2359
+ );
2360
+ if (result.parseErrors > 0) {
2361
+ console.log(pc5.yellow(` ${result.parseErrors} unparseable line(s) skipped`));
2362
+ }
2363
+ const unknown = Object.entries(result.unknownTypes);
2364
+ if (unknown.length > 0) {
2365
+ const summary = unknown.map(([k, v]) => `${k}\xD7${v}`).join(", ");
2366
+ console.log(
2367
+ pc5.yellow(` unknown line type(s) skipped (parser may need an update): ${summary}`)
2368
+ );
2369
+ }
2370
+ }
2371
+ return result;
2372
+ }
2373
+
2374
+ // src/cli/sync.ts
2375
+ import { appendFileSync, mkdirSync as mkdirSync3 } from "fs";
2376
+ import { join as join5 } from "path";
2377
+ import pc6 from "picocolors";
2378
+
2379
+ // src/lib/privacy.ts
2380
+ import { createHash } from "crypto";
2381
+ function projectIdOf(cwd) {
2382
+ return createHash("sha256").update(cwd).digest("hex").slice(0, 16);
2383
+ }
2384
+ function projectNameOf(cwd) {
2385
+ return cwd ? basename(cwd).slice(0, 120) : "unknown";
2386
+ }
2387
+ function toSyncTurn(t) {
2388
+ return {
2389
+ turnKey: t.messageId ?? t.uuid,
2390
+ uuid: t.uuid,
2391
+ messageId: t.messageId,
2392
+ requestId: t.requestId,
2393
+ sessionId: t.sessionId,
2394
+ agentId: t.agentId,
2395
+ parentSessionId: t.parentSessionId,
2396
+ isSidechain: t.isSidechain,
2397
+ projectId: projectIdOf(t.projectPath),
2398
+ projectName: projectNameOf(t.projectPath),
2399
+ slug: t.slug?.slice(0, 120),
2400
+ ts: t.timestamp,
2401
+ model: t.model.slice(0, 120),
2402
+ usage: t.usage,
2403
+ tools: t.tools.slice(0, 500).map((name) => name.slice(0, 100)),
2404
+ numToolUses: t.numToolUses,
2405
+ thinkingChars: t.thinkingChars,
2406
+ textChars: t.textChars,
2407
+ stopReason: t.stopReason?.slice(0, 60),
2408
+ version: t.cliVersion?.slice(0, 40),
2409
+ speed: t.speed?.slice(0, 20),
2410
+ inferenceGeo: t.inferenceGeo?.slice(0, 20)
2411
+ };
2412
+ }
2413
+ function toSyncEvent(id, e) {
2414
+ return {
2415
+ localEventId: id,
2416
+ sessionId: e.sessionId,
2417
+ ts: e.timestamp,
2418
+ kind: e.kind,
2419
+ data: e.data
2420
+ // already whitelisted at parse time — never prompt/content
2421
+ };
2422
+ }
2423
+
2424
+ // src/cli/sync.ts
2425
+ function logFailure(err) {
2426
+ try {
2427
+ mkdirSync3(dataDir(), { recursive: true });
2428
+ const line = `${(/* @__PURE__ */ new Date()).toISOString()} sync failed: ${err instanceof Error ? err.message : String(err)}
2429
+ `;
2430
+ appendFileSync(join5(dataDir(), "sync.log"), line);
2431
+ } catch {
2432
+ }
2433
+ }
2434
+ async function sync(opts) {
2435
+ const say = (msg) => {
2436
+ if (!opts.quiet) console.log(msg);
2437
+ };
2438
+ try {
2439
+ const creds = readCredentials();
2440
+ if (!creds) {
2441
+ if (opts.quiet) return null;
2442
+ console.log(`Not logged in. Run ${pc6.bold("tktuner login")} first.`);
2443
+ process.exitCode = 1;
2444
+ return null;
2445
+ }
2446
+ const apiUrl = opts.api ?? creds.apiUrl;
2447
+ if (!opts.noScan) {
2448
+ await scan({ db: opts.db, claudeDir: opts.claudeDir, silent: opts.quiet });
2449
+ }
2450
+ const db = openDb(opts.db);
2451
+ try {
2452
+ const store = new Store(db);
2453
+ const boundDevice = store.getMeta("sync_device_id");
2454
+ const boundApi = store.getMeta("sync_api_url");
2455
+ if (opts.full || boundDevice !== creds.deviceId || boundApi !== apiUrl) {
2456
+ store.setMeta("sync_last_turn_rowid", "0");
2457
+ store.setMeta("sync_last_event_id", "0");
2458
+ store.setMeta("sync_device_id", creds.deviceId);
2459
+ store.setMeta("sync_api_url", apiUrl);
2460
+ }
2461
+ const turnWm = Number(store.getMeta("sync_last_turn_rowid") ?? "0");
2462
+ const eventWm = Number(store.getMeta("sync_last_event_id") ?? "0");
2463
+ const { keys } = store.newTurnKeysSince(turnWm);
2464
+ const groups = store.loadTurnGroups(keys);
2465
+ const pendingEvents = store.eventsSince(eventWm);
2466
+ if (opts.dryRun) {
2467
+ say(
2468
+ `${pc6.bold("dry run")} \u2014 would push ${pc6.green(String(groups.length))} turn(s) and ${pendingEvents.length} event(s) to ${apiUrl}`
2469
+ );
2470
+ for (const g of groups.slice(0, 2)) {
2471
+ say(pc6.dim(JSON.stringify(toSyncTurn(g.turn), null, 2)));
2472
+ }
2473
+ return { turnsPushed: 0, eventsPushed: 0, batches: 0 };
2474
+ }
2475
+ if (groups.length === 0 && pendingEvents.length === 0) {
2476
+ say(`${pc6.bold("sync")} \u2014 already up to date (${apiUrl})`);
2477
+ return { turnsPushed: 0, eventsPushed: 0, batches: 0 };
2478
+ }
2479
+ const t0 = performance.now();
2480
+ let turnCursor = 0;
2481
+ let eventCursor = 0;
2482
+ let turnsPushed = 0;
2483
+ let eventsPushed = 0;
2484
+ let batches = 0;
2485
+ while (turnCursor < groups.length || eventCursor < pendingEvents.length) {
2486
+ const turnBatch = groups.slice(turnCursor, turnCursor + SYNC_MAX_TURNS_PER_BATCH);
2487
+ const eventBatch = pendingEvents.slice(
2488
+ eventCursor,
2489
+ eventCursor + SYNC_MAX_EVENTS_PER_BATCH
2490
+ );
2491
+ const eventWires = eventBatch.map((e) => toSyncEvent(e.id, e.event));
2492
+ await apiCall("POST", apiUrl, "/v1/sync", {
2493
+ token: creds.deviceToken,
2494
+ gzip: true,
2495
+ body: {
2496
+ turns: turnBatch.map((g) => toSyncTurn(g.turn)),
2497
+ events: eventWires,
2498
+ cliVersion: CLI_VERSION
2499
+ }
2500
+ });
2501
+ if (turnBatch.length > 0) {
2502
+ const maxRowid = turnBatch[turnBatch.length - 1]?.maxRowid ?? turnWm;
2503
+ store.setMeta("sync_last_turn_rowid", String(maxRowid));
2504
+ }
2505
+ if (eventBatch.length > 0) {
2506
+ const maxId = eventBatch[eventBatch.length - 1]?.id ?? eventWm;
2507
+ store.setMeta("sync_last_event_id", String(maxId));
2508
+ }
2509
+ turnCursor += turnBatch.length;
2510
+ eventCursor += eventBatch.length;
2511
+ turnsPushed += turnBatch.length;
2512
+ eventsPushed += eventWires.length;
2513
+ batches++;
2514
+ }
2515
+ const seconds = ((performance.now() - t0) / 1e3).toFixed(1);
2516
+ say(
2517
+ `${pc6.bold("sync complete")} \u2014 ${pc6.green(String(turnsPushed))} turn(s), ${eventsPushed} event(s) in ${batches} batch(es), ${seconds}s \u2192 ${apiUrl}`
2518
+ );
2519
+ return { turnsPushed, eventsPushed, batches };
2520
+ } finally {
2521
+ db.close();
2522
+ }
2523
+ } catch (err) {
2524
+ if (opts.quiet) {
2525
+ logFailure(err);
2526
+ return null;
2527
+ }
2528
+ console.error(pc6.red(err instanceof Error ? err.message : String(err)));
2529
+ process.exitCode = 1;
2530
+ return null;
2531
+ }
2532
+ }
2533
+
2534
+ // src/cli/whoami.ts
2535
+ import pc7 from "picocolors";
2536
+ async function whoami(opts) {
2537
+ const creds = readCredentials();
2538
+ if (!creds) {
2539
+ console.log(`Not logged in. Run ${pc7.bold("tktuner login")}.`);
2540
+ return;
2541
+ }
2542
+ try {
2543
+ const status = await apiCall("GET", creds.apiUrl, "/v1/sync/status", {
2544
+ token: creds.deviceToken
2545
+ });
2546
+ console.log(`${pc7.bold(String(status.email))} \xB7 device ${creds.deviceName} \u2192 ${creds.apiUrl}`);
2547
+ console.log(
2548
+ `server: ${pc7.bold(String(status.serverTurnCount))} turns \xB7 last sync ${status.lastSyncedAt ? String(status.lastSyncedAt) : "never"}`
2549
+ );
2550
+ } catch (err) {
2551
+ if (err instanceof ApiError && err.status === 401) {
2552
+ console.error(pc7.red(`Device token was revoked. Run ${pc7.bold("tktuner login")} again.`));
2553
+ process.exitCode = 1;
2554
+ return;
2555
+ }
2556
+ throw err;
2557
+ }
2558
+ const db = openDb(opts.db);
2559
+ try {
2560
+ const store = new Store(db);
2561
+ const wm = store.getMeta("sync_last_turn_rowid") ?? "0";
2562
+ console.log(`local: ${store.countTurns()} turns \xB7 sync watermark @ row ${wm}`);
2563
+ } finally {
2564
+ db.close();
2565
+ }
2566
+ }
2567
+
2568
+ // src/cli/index.ts
2569
+ var program = new Command();
2570
+ program.name("tktuner").description(
2571
+ "Local-first token usage analytics for Claude Code power users.\nScans ~/.claude/projects session logs and shows where you waste tokens and quota."
2572
+ ).version(CLI_VERSION);
2573
+ program.command("scan").description("Incrementally parse Claude Code session logs into the local database").option("--claude-dir <path>", "Claude projects directory (default: ~/.claude/projects)").option("--db <path>", "database file (default: ~/.local/share/tktuner/tktuner.db)").option("--full", "ignore incremental state and reparse every file").option("--verbose", "print each file as it is parsed").action(async (opts) => {
2574
+ await scan(opts);
2575
+ });
2576
+ program.command("report").description("Analyze scanned data: cost by model/phase, waste, cache health, quota pressure").option("--days <n>", "analysis window in days", "30").option("--all", "analyze the entire history").option("--project <filter>", "only sessions whose project name contains this substring").option("--json", "machine-readable JSON output").option("--no-color", "disable colors").option("--db <path>", "database file (default: ~/.local/share/tktuner/tktuner.db)").option("--prices <path>", "JSON file overriding the bundled model price table").option("--debug-session <id>", "per-turn phase breakdown for one session (id or slug prefix)").action(async (opts) => {
2577
+ await report(opts);
2578
+ });
2579
+ program.command("login").description("Connect this machine to your tktuner.com account (device flow)").option("--api <url>", "API base URL (default: https://tktuner.com)").action(async (opts) => {
2580
+ await login(opts);
2581
+ });
2582
+ program.command("logout").description("Disconnect this machine and delete local credentials").action(async () => {
2583
+ await logout();
2584
+ });
2585
+ program.command("whoami").description("Show the connected account, device and sync state").option("--db <path>", "database file (default: ~/.local/share/tktuner/tktuner.db)").action(async (opts) => {
2586
+ await whoami(opts);
2587
+ });
2588
+ program.command("sync").description("Scan locally, then upload new usage metadata (never content) to your dashboard").option("--quiet", "no output, always exit 0 (for the SessionEnd hook)").option("--full", "re-push the entire local database (idempotent server-side)").option("--dry-run", "show what would be uploaded without sending anything").option("--no-scan", "skip the local scan step").option("--db <path>", "database file (default: ~/.local/share/tktuner/tktuner.db)").option("--claude-dir <path>", "Claude projects directory (default: ~/.claude/projects)").option("--api <url>", "override the API base URL from credentials").action(async (opts) => {
2589
+ await sync({ ...opts, noScan: opts.scan === false });
2590
+ });
2591
+ var hook = program.command("hook").description("Manage the opt-in Claude Code SessionEnd hook (auto-sync when a session ends)");
2592
+ hook.command("install").description("Add the SessionEnd hook to ~/.claude/settings.json (merge-safe, backed up)").option("--yes", "skip the confirmation prompt").action(async (opts) => {
2593
+ const { hookInstall } = await import("./hook-QKTYBOHD.js");
2594
+ await hookInstall(opts);
2595
+ });
2596
+ hook.command("uninstall").description("Remove only tktuner's hook entry, leaving everything else untouched").action(async () => {
2597
+ const { hookUninstall } = await import("./hook-QKTYBOHD.js");
2598
+ await hookUninstall();
2599
+ });
2600
+ hook.command("status").description("Show whether the auto-sync hook is installed").action(async () => {
2601
+ const { hookStatus } = await import("./hook-QKTYBOHD.js");
2602
+ hookStatus();
2603
+ });
2604
+ program.parseAsync(process.argv).catch((err) => {
2605
+ console.error(err instanceof Error ? err.message : String(err));
2606
+ process.exitCode = 1;
2607
+ });
2608
+ //# sourceMappingURL=cli.js.map