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/LICENSE +21 -0
- package/README.md +57 -0
- package/dist/cli.js +2608 -0
- package/dist/cli.js.map +1 -0
- package/dist/hook-QKTYBOHD.js +114 -0
- package/dist/hook-QKTYBOHD.js.map +1 -0
- package/package.json +52 -0
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
|