savemytokens 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +164 -0
- package/dist/adapters/claude-code/index.js +130 -0
- package/dist/adapters/claude-code/merge.js +90 -0
- package/dist/adapters/claude-code/parse.js +642 -0
- package/dist/adapters/claude-code/provider.js +105 -0
- package/dist/adapters/codex/index.js +74 -0
- package/dist/adapters/codex/parse.js +389 -0
- package/dist/adapters/codex/provider.js +171 -0
- package/dist/adapters/index.js +10 -0
- package/dist/adapters/pending.js +29 -0
- package/dist/adapters/types.js +1 -0
- package/dist/analyze/aggregate.js +180 -0
- package/dist/analyze/combine.js +11 -0
- package/dist/analyze/detectors.js +244 -0
- package/dist/analyze/index.js +29 -0
- package/dist/analyze/score.js +20 -0
- package/dist/cli-options.js +149 -0
- package/dist/cli.js +141 -0
- package/dist/collect.js +62 -0
- package/dist/commands/audit.js +74 -0
- package/dist/commands/control.js +654 -0
- package/dist/commands/hud.js +71 -0
- package/dist/commands/install.js +369 -0
- package/dist/commands/policy.js +93 -0
- package/dist/commands/privacy.js +28 -0
- package/dist/commands/set.js +83 -0
- package/dist/commands/theme.js +136 -0
- package/dist/commands/watch.js +135 -0
- package/dist/core/cost.js +24 -0
- package/dist/core/hash.js +0 -0
- package/dist/core/pricing.js +63 -0
- package/dist/core/resource.js +1 -0
- package/dist/core/tokens.js +32 -0
- package/dist/core/types.js +1 -0
- package/dist/hooks/nudge.js +111 -0
- package/dist/hooks/rules.js +14 -0
- package/dist/privacy/payload.js +22 -0
- package/dist/report/graph.js +162 -0
- package/dist/report/graphs.js +61 -0
- package/dist/report/render.js +183 -0
- package/dist/report/schedule.js +143 -0
- package/dist/report/settings.js +237 -0
- package/dist/report/views.js +418 -0
- package/dist/runtime/hook.mjs +234 -0
- package/dist/runtime/kernel.mjs +1472 -0
- package/dist/runtime/statusline.mjs +243 -0
- package/dist/scheduler/keys.js +112 -0
- package/dist/scheduler/plan.js +287 -0
- package/dist/storage/cache.js +38 -0
- package/dist/storage/paths.js +29 -0
- package/dist/storage/store.js +48 -0
- package/dist/util/ansi.js +35 -0
- package/dist/util/fmt.js +76 -0
- package/package.json +51 -0
|
@@ -0,0 +1,642 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import readline from "node:readline";
|
|
5
|
+
import { LifetimeCost } from "../../core/cost.js";
|
|
6
|
+
import { hash32 } from "../../core/hash.js";
|
|
7
|
+
import { usd } from "../../core/pricing.js";
|
|
8
|
+
import { addUsage, emptyUsage, estimateTokens, weigh } from "../../core/tokens.js";
|
|
9
|
+
import { EVIDENCE_SCHEMA, } from "../../core/types.js";
|
|
10
|
+
export const LARGE_OUTPUT_CHARS = 10_000;
|
|
11
|
+
export const USEFUL_OUTPUT_CHARS = 2_000;
|
|
12
|
+
export const HIGH_CONTEXT_TOKENS = 120_000;
|
|
13
|
+
export const DEAD_CARRY_TOKENS = 80_000;
|
|
14
|
+
export const DEAD_CARRY_MIN_TURNS = 5;
|
|
15
|
+
const ANAPHORIC_OPENER = /^\s*(ok|okay|now|also|and|then|next|again|yes|no|nope|yep|same|do the same|the other|these|those|them|it|that|this|continue|carry on|keep going|go on|more|another|fix (it|that|this)|try again|redo|revert|undo|hmm|wait|great|nice|thanks|perfect|good)\b/i;
|
|
16
|
+
export function isSelfContained(prompt) {
|
|
17
|
+
const text = prompt.trim();
|
|
18
|
+
if (text.length < 40)
|
|
19
|
+
return false;
|
|
20
|
+
if (ANAPHORIC_OPENER.test(text))
|
|
21
|
+
return false;
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
export const PREMIUM_MODELS = /opus/i;
|
|
25
|
+
const MECHANICAL_TOOLS = new Set(["Bash", "Grep", "Glob", "Read", "WebFetch", "WebSearch"]);
|
|
26
|
+
const SEARCH_TOOLS = new Set(["Grep", "Glob", "WebSearch"]);
|
|
27
|
+
const MAX_BUCKETS = 24;
|
|
28
|
+
const MAX_PENDING_TOOLS = 4_000;
|
|
29
|
+
function bucket(map, key, tool) {
|
|
30
|
+
let b = map.get(key);
|
|
31
|
+
if (!b) {
|
|
32
|
+
b = { key, tool, count: 0, chars: 0, maxChars: 0, edits: 0, signature: "", sample: "", command: "", wastedChars: 0, wastedCount: 0, cost: new LifetimeCost() };
|
|
33
|
+
map.set(key, b);
|
|
34
|
+
}
|
|
35
|
+
return b;
|
|
36
|
+
}
|
|
37
|
+
function blockChars(content) {
|
|
38
|
+
if (typeof content === "string")
|
|
39
|
+
return content.length;
|
|
40
|
+
if (Array.isArray(content)) {
|
|
41
|
+
let total = 0;
|
|
42
|
+
for (const item of content) {
|
|
43
|
+
if (typeof item === "string")
|
|
44
|
+
total += item.length;
|
|
45
|
+
else if (item && typeof item === "object") {
|
|
46
|
+
const rec = item;
|
|
47
|
+
if (typeof rec.text === "string")
|
|
48
|
+
total += rec.text.length;
|
|
49
|
+
else if (typeof rec.content === "string")
|
|
50
|
+
total += rec.content.length;
|
|
51
|
+
else if (rec.type === "image")
|
|
52
|
+
total += 4_000;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return total;
|
|
56
|
+
}
|
|
57
|
+
if (content && typeof content === "object")
|
|
58
|
+
return JSON.stringify(content).length;
|
|
59
|
+
return 0;
|
|
60
|
+
}
|
|
61
|
+
function isStructuredOutput(text) {
|
|
62
|
+
const trimmed = text.trim();
|
|
63
|
+
if (!trimmed.startsWith("{") && !trimmed.startsWith("["))
|
|
64
|
+
return false;
|
|
65
|
+
try {
|
|
66
|
+
JSON.parse(trimmed);
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
function injectedHookOutput(attachment) {
|
|
74
|
+
const content = String(attachment.content || "");
|
|
75
|
+
if (content)
|
|
76
|
+
return content;
|
|
77
|
+
const stdout = String(attachment.stdout || "");
|
|
78
|
+
return isStructuredOutput(stdout) ? "" : stdout;
|
|
79
|
+
}
|
|
80
|
+
function blockText(content) {
|
|
81
|
+
if (typeof content === "string")
|
|
82
|
+
return content;
|
|
83
|
+
if (Array.isArray(content)) {
|
|
84
|
+
const parts = [];
|
|
85
|
+
for (const item of content) {
|
|
86
|
+
if (typeof item === "string")
|
|
87
|
+
parts.push(item);
|
|
88
|
+
else if (item && typeof item === "object") {
|
|
89
|
+
const rec = item;
|
|
90
|
+
if (typeof rec.text === "string")
|
|
91
|
+
parts.push(rec.text);
|
|
92
|
+
else if (typeof rec.content === "string")
|
|
93
|
+
parts.push(rec.content);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return parts.join("\n");
|
|
97
|
+
}
|
|
98
|
+
return "";
|
|
99
|
+
}
|
|
100
|
+
const NOISE_COMMANDS = new Set(["cd", "export", "set", "source", ".", "echo", "true", "sudo", "time", "env"]);
|
|
101
|
+
export function commandLabel(command) {
|
|
102
|
+
const segments = command.split("\n")[0]?.split(/&&|\|\||;|\|/) ?? [];
|
|
103
|
+
for (const segment of segments) {
|
|
104
|
+
const words = [];
|
|
105
|
+
for (const raw of segment.trim().split(/\s+/)) {
|
|
106
|
+
const word = raw.replace(/^["'`(]+|["'`)]+$/g, "");
|
|
107
|
+
if (!word)
|
|
108
|
+
continue;
|
|
109
|
+
if (word.startsWith("-"))
|
|
110
|
+
continue;
|
|
111
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(word))
|
|
112
|
+
continue;
|
|
113
|
+
if (words.length === 0 && NOISE_COMMANDS.has(word))
|
|
114
|
+
break;
|
|
115
|
+
const clean = /[/~]/.test(word) && words.length > 0 ? path.basename(word) : word;
|
|
116
|
+
words.push(clean.slice(0, 28));
|
|
117
|
+
if (words.length === 2)
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
if (words.length > 0)
|
|
121
|
+
return words.join(" ");
|
|
122
|
+
}
|
|
123
|
+
return "shell command";
|
|
124
|
+
}
|
|
125
|
+
function displayPath(filePath, cwd) {
|
|
126
|
+
if (cwd && filePath.startsWith(cwd + path.sep))
|
|
127
|
+
return filePath.slice(cwd.length + 1);
|
|
128
|
+
const home = os.homedir();
|
|
129
|
+
if (home && filePath.startsWith(home + path.sep))
|
|
130
|
+
return "~/" + filePath.slice(home.length + 1);
|
|
131
|
+
return filePath;
|
|
132
|
+
}
|
|
133
|
+
function median(values) {
|
|
134
|
+
if (values.length === 0)
|
|
135
|
+
return 0;
|
|
136
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
137
|
+
const mid = Math.floor(sorted.length / 2);
|
|
138
|
+
if (sorted.length % 2 === 1)
|
|
139
|
+
return sorted[mid] ?? 0;
|
|
140
|
+
return Math.round(((sorted[mid - 1] ?? 0) + (sorted[mid] ?? 0)) / 2);
|
|
141
|
+
}
|
|
142
|
+
function topBuckets(map, segmentEnds) {
|
|
143
|
+
return [...map.values()]
|
|
144
|
+
.map((b) => ({ ...b, weighted: b.cost.resolve(segmentEnds) }))
|
|
145
|
+
.sort((a, b) => b.weighted - a.weighted || b.chars - a.chars)
|
|
146
|
+
.slice(0, MAX_BUCKETS);
|
|
147
|
+
}
|
|
148
|
+
export async function parseClaudeSession(file, stat) {
|
|
149
|
+
const stream = fs.createReadStream(file, { encoding: "utf8" });
|
|
150
|
+
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
151
|
+
const usage = emptyUsage();
|
|
152
|
+
const models = new Map();
|
|
153
|
+
const seenMessages = new Set();
|
|
154
|
+
const pending = new Map();
|
|
155
|
+
const readHashes = new Map();
|
|
156
|
+
const hookHashes = new Map();
|
|
157
|
+
const reads = new Map();
|
|
158
|
+
const outputs = new Map();
|
|
159
|
+
const hooks = new Map();
|
|
160
|
+
const writes = new Map();
|
|
161
|
+
const failures = new Map();
|
|
162
|
+
const attachments = new Map();
|
|
163
|
+
const tasks = [];
|
|
164
|
+
const contexts = [];
|
|
165
|
+
const segmentEnds = [];
|
|
166
|
+
let sessionId = path.basename(file, ".jsonl");
|
|
167
|
+
let cwd = "";
|
|
168
|
+
let agentVersion = "";
|
|
169
|
+
let startedAt = 0;
|
|
170
|
+
let endedAt = 0;
|
|
171
|
+
let turn = 0;
|
|
172
|
+
let segment = 0;
|
|
173
|
+
let humanPrompts = 0;
|
|
174
|
+
let toolCalls = 0;
|
|
175
|
+
let toolErrors = 0;
|
|
176
|
+
let apiErrors = 0;
|
|
177
|
+
let rateLimitHits = 0;
|
|
178
|
+
let lastRateLimitAt = 0;
|
|
179
|
+
let interruptions = 0;
|
|
180
|
+
let sidechainTurns = 0;
|
|
181
|
+
let sidechainWeighted = 0;
|
|
182
|
+
let peakContext = 0;
|
|
183
|
+
let bloatTurns = 0;
|
|
184
|
+
let bloatTokens = 0;
|
|
185
|
+
let bloatWeighted = 0;
|
|
186
|
+
let searchChars = 0;
|
|
187
|
+
let current = null;
|
|
188
|
+
const openTask = (id, ts, prompt) => {
|
|
189
|
+
const task = {
|
|
190
|
+
id,
|
|
191
|
+
sessionId,
|
|
192
|
+
project: cwd,
|
|
193
|
+
prompt: prompt.replace(/\s+/g, " ").trim().slice(0, 120),
|
|
194
|
+
startedAt: ts,
|
|
195
|
+
endedAt: ts,
|
|
196
|
+
promptChars: prompt.length,
|
|
197
|
+
turns: 0,
|
|
198
|
+
toolCalls: 0,
|
|
199
|
+
models: [],
|
|
200
|
+
modelSet: new Set(),
|
|
201
|
+
fileSet: new Set(),
|
|
202
|
+
usage: emptyUsage(),
|
|
203
|
+
weighted: 0,
|
|
204
|
+
usd: 0,
|
|
205
|
+
peakContext: 0,
|
|
206
|
+
carriedContext: 0,
|
|
207
|
+
carriedUsd: 0,
|
|
208
|
+
carriedIsDead: false,
|
|
209
|
+
touchedPriorFiles: false,
|
|
210
|
+
selfContained: isSelfContained(prompt),
|
|
211
|
+
outcome: "completed",
|
|
212
|
+
toolErrors: 0,
|
|
213
|
+
};
|
|
214
|
+
tasks.push(task);
|
|
215
|
+
return task;
|
|
216
|
+
};
|
|
217
|
+
for await (const line of rl) {
|
|
218
|
+
if (!line || line.charCodeAt(0) !== 123)
|
|
219
|
+
continue;
|
|
220
|
+
let record;
|
|
221
|
+
try {
|
|
222
|
+
record = JSON.parse(line);
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
const ts = typeof record.timestamp === "string" ? Date.parse(record.timestamp) : NaN;
|
|
228
|
+
if (Number.isFinite(ts)) {
|
|
229
|
+
if (!startedAt || ts < startedAt)
|
|
230
|
+
startedAt = ts;
|
|
231
|
+
if (ts > endedAt)
|
|
232
|
+
endedAt = ts;
|
|
233
|
+
if (current)
|
|
234
|
+
current.endedAt = ts;
|
|
235
|
+
}
|
|
236
|
+
if (!cwd && typeof record.cwd === "string")
|
|
237
|
+
cwd = record.cwd;
|
|
238
|
+
if (!agentVersion && typeof record.version === "string")
|
|
239
|
+
agentVersion = record.version;
|
|
240
|
+
if (typeof record.sessionId === "string")
|
|
241
|
+
sessionId = record.sessionId;
|
|
242
|
+
switch (record.type) {
|
|
243
|
+
case "assistant": {
|
|
244
|
+
const message = record.message;
|
|
245
|
+
if (!message)
|
|
246
|
+
break;
|
|
247
|
+
const messageId = message.id ?? record.uuid ?? String(turn);
|
|
248
|
+
const model = message.model ?? "unknown";
|
|
249
|
+
const fresh = !seenMessages.has(messageId);
|
|
250
|
+
if (fresh) {
|
|
251
|
+
seenMessages.add(messageId);
|
|
252
|
+
const u = message.usage ?? {};
|
|
253
|
+
const turnUsage = {
|
|
254
|
+
input: u.input_tokens ?? 0,
|
|
255
|
+
output: u.output_tokens ?? 0,
|
|
256
|
+
cacheWrite: u.cache_creation_input_tokens ?? 0,
|
|
257
|
+
cacheRead: u.cache_read_input_tokens ?? 0,
|
|
258
|
+
};
|
|
259
|
+
const turnWeighted = weigh(turnUsage);
|
|
260
|
+
const context = turnUsage.input + turnUsage.cacheRead + turnUsage.cacheWrite;
|
|
261
|
+
if (context > 0) {
|
|
262
|
+
turn++;
|
|
263
|
+
contexts.push(context);
|
|
264
|
+
if (context > peakContext)
|
|
265
|
+
peakContext = context;
|
|
266
|
+
if (context > HIGH_CONTEXT_TOKENS) {
|
|
267
|
+
bloatTurns++;
|
|
268
|
+
const excess = context - HIGH_CONTEXT_TOKENS;
|
|
269
|
+
bloatTokens += excess;
|
|
270
|
+
bloatWeighted += excess * 0.1;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
addUsage(usage, turnUsage);
|
|
274
|
+
if (record.isSidechain) {
|
|
275
|
+
sidechainTurns++;
|
|
276
|
+
sidechainWeighted += turnWeighted;
|
|
277
|
+
}
|
|
278
|
+
let entry = models.get(model);
|
|
279
|
+
if (!entry) {
|
|
280
|
+
entry = { model, turns: 0, usage: emptyUsage(), weighted: 0, trivialTurns: 0, trivialWeighted: 0 };
|
|
281
|
+
models.set(model, entry);
|
|
282
|
+
}
|
|
283
|
+
entry.turns++;
|
|
284
|
+
addUsage(entry.usage, turnUsage);
|
|
285
|
+
entry.weighted += turnWeighted;
|
|
286
|
+
const blocks = Array.isArray(message.content) ? message.content : [];
|
|
287
|
+
const onlyMechanical = blocks.length > 0 &&
|
|
288
|
+
blocks.every((b) => b.type !== "text") &&
|
|
289
|
+
blocks.some((b) => b.type === "tool_use" && MECHANICAL_TOOLS.has(b.name));
|
|
290
|
+
if (PREMIUM_MODELS.test(model) && onlyMechanical && turnUsage.output < 600) {
|
|
291
|
+
entry.trivialTurns++;
|
|
292
|
+
entry.trivialWeighted += turnWeighted;
|
|
293
|
+
}
|
|
294
|
+
if (current) {
|
|
295
|
+
if (current.turns === 0)
|
|
296
|
+
current.carriedContext = turnUsage.cacheRead + turnUsage.input;
|
|
297
|
+
current.turns++;
|
|
298
|
+
addUsage(current.usage, turnUsage);
|
|
299
|
+
current.weighted += turnWeighted;
|
|
300
|
+
current.usd += usd(model, turnUsage);
|
|
301
|
+
current.modelSet.add(model);
|
|
302
|
+
if (context > current.peakContext)
|
|
303
|
+
current.peakContext = context;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
if (record.isApiErrorMessage) {
|
|
307
|
+
apiErrors++;
|
|
308
|
+
const text = Array.isArray(message.content)
|
|
309
|
+
? message.content.map((b) => (typeof b?.text === "string" ? b.text : "")).join(" ")
|
|
310
|
+
: "";
|
|
311
|
+
if (/\blimit\b/i.test(text) || record.quotaLimits?.status === "rejected") {
|
|
312
|
+
const at = Number.isFinite(ts) ? ts : lastRateLimitAt;
|
|
313
|
+
if (at - lastRateLimitAt > 5 * 60 * 1000 || lastRateLimitAt === 0)
|
|
314
|
+
rateLimitHits++;
|
|
315
|
+
lastRateLimitAt = at;
|
|
316
|
+
}
|
|
317
|
+
if (current)
|
|
318
|
+
current.outcome = "failed";
|
|
319
|
+
}
|
|
320
|
+
for (const block of Array.isArray(message.content) ? message.content : []) {
|
|
321
|
+
if (block?.type !== "tool_use")
|
|
322
|
+
continue;
|
|
323
|
+
toolCalls++;
|
|
324
|
+
if (pending.size > MAX_PENDING_TOOLS)
|
|
325
|
+
pending.clear();
|
|
326
|
+
const input = block.input ?? {};
|
|
327
|
+
if (current) {
|
|
328
|
+
current.toolCalls++;
|
|
329
|
+
if (typeof input.file_path === "string")
|
|
330
|
+
current.fileSet.add(input.file_path);
|
|
331
|
+
}
|
|
332
|
+
pending.set(block.id, {
|
|
333
|
+
name: block.name ?? "unknown",
|
|
334
|
+
turn,
|
|
335
|
+
segment,
|
|
336
|
+
filePath: typeof input.file_path === "string" ? input.file_path : undefined,
|
|
337
|
+
command: typeof input.command === "string" ? input.command : undefined,
|
|
338
|
+
pattern: typeof input.pattern === "string" ? input.pattern : undefined,
|
|
339
|
+
offset: typeof input.offset === "number" ? input.offset : undefined,
|
|
340
|
+
limit: typeof input.limit === "number" ? input.limit : undefined,
|
|
341
|
+
contentChars: typeof input.content === "string"
|
|
342
|
+
? input.content.length
|
|
343
|
+
: typeof input.new_string === "string"
|
|
344
|
+
? input.new_string.length
|
|
345
|
+
: undefined,
|
|
346
|
+
});
|
|
347
|
+
if (block.name === "Write" && typeof input.file_path === "string") {
|
|
348
|
+
const key = displayPath(input.file_path, cwd);
|
|
349
|
+
const b = bucket(writes, key, "Write");
|
|
350
|
+
b.count++;
|
|
351
|
+
const chars = typeof input.content === "string" ? input.content.length : 0;
|
|
352
|
+
b.chars += chars;
|
|
353
|
+
if (b.count > 1) {
|
|
354
|
+
b.wastedCount++;
|
|
355
|
+
b.wastedChars += chars;
|
|
356
|
+
b.cost.add(segment, turn, estimateTokens(chars));
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
else if (block.name === "Edit" && typeof input.file_path === "string") {
|
|
360
|
+
bucket(writes, displayPath(input.file_path, cwd), "Write").edits++;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
break;
|
|
364
|
+
}
|
|
365
|
+
case "user": {
|
|
366
|
+
if (record.interruptedMessageId) {
|
|
367
|
+
interruptions++;
|
|
368
|
+
if (current && current.outcome === "completed")
|
|
369
|
+
current.outcome = "interrupted";
|
|
370
|
+
}
|
|
371
|
+
const content = record.message?.content;
|
|
372
|
+
if (typeof content === "string") {
|
|
373
|
+
const isHuman = record.origin?.kind === "human" || record.promptSource === "typed";
|
|
374
|
+
if (isHuman && content.length > 0) {
|
|
375
|
+
humanPrompts++;
|
|
376
|
+
current = openTask(record.promptId ?? record.uuid ?? `task-${tasks.length}`, Number.isFinite(ts) ? ts : 0, content);
|
|
377
|
+
}
|
|
378
|
+
break;
|
|
379
|
+
}
|
|
380
|
+
if (!Array.isArray(content))
|
|
381
|
+
break;
|
|
382
|
+
for (const block of content) {
|
|
383
|
+
if (block?.type !== "tool_result")
|
|
384
|
+
continue;
|
|
385
|
+
const meta = pending.get(block.tool_use_id);
|
|
386
|
+
pending.delete(block.tool_use_id);
|
|
387
|
+
const tool = meta?.name ?? "unknown";
|
|
388
|
+
const atTurn = meta?.turn ?? turn;
|
|
389
|
+
const atSegment = meta?.segment ?? segment;
|
|
390
|
+
const chars = blockChars(block.content);
|
|
391
|
+
const result = record.toolUseResult;
|
|
392
|
+
const isError = block.is_error === true || result?.interrupted === true;
|
|
393
|
+
if (SEARCH_TOOLS.has(tool))
|
|
394
|
+
searchChars += chars;
|
|
395
|
+
if (isError) {
|
|
396
|
+
toolErrors++;
|
|
397
|
+
if (current)
|
|
398
|
+
current.toolErrors++;
|
|
399
|
+
const label = meta?.command ? commandLabel(meta.command) : tool;
|
|
400
|
+
const b = bucket(failures, label, tool);
|
|
401
|
+
b.count++;
|
|
402
|
+
b.chars += chars;
|
|
403
|
+
b.wastedChars += chars;
|
|
404
|
+
b.wastedCount++;
|
|
405
|
+
b.cost.add(atSegment, atTurn, estimateTokens(chars));
|
|
406
|
+
}
|
|
407
|
+
const fileResult = result?.file;
|
|
408
|
+
const readPath = typeof fileResult?.filePath === "string" ? fileResult.filePath : tool === "Read" ? meta?.filePath : undefined;
|
|
409
|
+
if (readPath) {
|
|
410
|
+
const range = fileResult
|
|
411
|
+
? `${fileResult.startLine ?? 1}-${fileResult.numLines ?? fileResult.totalLines ?? 0}`
|
|
412
|
+
: `${meta?.offset ?? 1}-${meta?.limit ?? 0}`;
|
|
413
|
+
const key = displayPath(readPath, cwd);
|
|
414
|
+
const payload = fileResult?.content != null ? String(fileResult.content) : blockText(block.content);
|
|
415
|
+
const fingerprint = `${key}#${range}#${hash32(payload)}`;
|
|
416
|
+
const seen = readHashes.get(fingerprint) ?? 0;
|
|
417
|
+
readHashes.set(fingerprint, seen + 1);
|
|
418
|
+
const b = bucket(reads, key, "Read");
|
|
419
|
+
if (!b.signature)
|
|
420
|
+
b.signature = fingerprint;
|
|
421
|
+
b.count++;
|
|
422
|
+
b.chars += chars;
|
|
423
|
+
if (seen > 0) {
|
|
424
|
+
b.wastedCount++;
|
|
425
|
+
b.wastedChars += chars;
|
|
426
|
+
b.cost.add(atSegment, atTurn, estimateTokens(chars));
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
if (chars > LARGE_OUTPUT_CHARS) {
|
|
430
|
+
const label = meta?.command
|
|
431
|
+
? commandLabel(meta.command)
|
|
432
|
+
: meta?.filePath
|
|
433
|
+
? `${tool} ${path.basename(meta.filePath)}`
|
|
434
|
+
: meta?.pattern
|
|
435
|
+
? `${tool} ${meta.pattern.slice(0, 24)}`
|
|
436
|
+
: tool;
|
|
437
|
+
const b = bucket(outputs, label, tool);
|
|
438
|
+
b.count++;
|
|
439
|
+
b.chars += chars;
|
|
440
|
+
if (chars > b.maxChars)
|
|
441
|
+
b.maxChars = chars;
|
|
442
|
+
const excess = chars - USEFUL_OUTPUT_CHARS;
|
|
443
|
+
b.wastedChars += excess;
|
|
444
|
+
b.wastedCount++;
|
|
445
|
+
b.cost.add(atSegment, atTurn, estimateTokens(excess));
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
break;
|
|
449
|
+
}
|
|
450
|
+
case "attachment": {
|
|
451
|
+
const attachment = record.attachment;
|
|
452
|
+
if (!attachment?.type)
|
|
453
|
+
break;
|
|
454
|
+
const type = attachment.type;
|
|
455
|
+
let chars = 0;
|
|
456
|
+
if (type === "hook_success") {
|
|
457
|
+
const payload = injectedHookOutput(attachment);
|
|
458
|
+
chars = payload.length;
|
|
459
|
+
if (chars > 0) {
|
|
460
|
+
const name = String(attachment.hookName ?? "hook");
|
|
461
|
+
const fingerprint = `${name}#${hash32(payload)}`;
|
|
462
|
+
const seen = hookHashes.get(fingerprint) ?? 0;
|
|
463
|
+
hookHashes.set(fingerprint, seen + 1);
|
|
464
|
+
const b = bucket(hooks, name, "hook");
|
|
465
|
+
if (!b.sample) {
|
|
466
|
+
b.sample = payload.replace(/\s+/g, " ").slice(0, 110);
|
|
467
|
+
b.command = String(attachment.command ?? "");
|
|
468
|
+
}
|
|
469
|
+
b.count++;
|
|
470
|
+
b.chars += chars;
|
|
471
|
+
if (seen > 0) {
|
|
472
|
+
b.wastedCount++;
|
|
473
|
+
b.wastedChars += chars;
|
|
474
|
+
b.cost.add(segment, turn, estimateTokens(chars));
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
else if (type === "file" || type === "already_read_file") {
|
|
479
|
+
const inner = attachment.content;
|
|
480
|
+
chars = blockChars(inner?.file?.content ?? inner);
|
|
481
|
+
const filePath = attachment.filename ?? inner?.file?.filePath;
|
|
482
|
+
if (typeof filePath === "string" && chars > 0) {
|
|
483
|
+
const key = displayPath(filePath, cwd);
|
|
484
|
+
const fingerprint = `${key}#attach#${hash32(String(inner?.file?.content ?? ""))}`;
|
|
485
|
+
const seen = readHashes.get(fingerprint) ?? 0;
|
|
486
|
+
readHashes.set(fingerprint, seen + 1);
|
|
487
|
+
const b = bucket(reads, key, "attachment");
|
|
488
|
+
if (!b.signature)
|
|
489
|
+
b.signature = fingerprint;
|
|
490
|
+
b.count++;
|
|
491
|
+
b.chars += chars;
|
|
492
|
+
if (seen > 0) {
|
|
493
|
+
b.wastedCount++;
|
|
494
|
+
b.wastedChars += chars;
|
|
495
|
+
b.cost.add(segment, turn, estimateTokens(chars));
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
else if (type === "edited_text_file") {
|
|
500
|
+
chars = String(attachment.snippet ?? "").length;
|
|
501
|
+
const filePath = attachment.filename;
|
|
502
|
+
if (typeof filePath === "string" && chars > 0) {
|
|
503
|
+
const key = displayPath(filePath, cwd);
|
|
504
|
+
const fingerprint = `${key}#edit#${hash32(String(attachment.snippet ?? ""))}`;
|
|
505
|
+
const seen = readHashes.get(fingerprint) ?? 0;
|
|
506
|
+
readHashes.set(fingerprint, seen + 1);
|
|
507
|
+
const b = bucket(reads, key, "editor");
|
|
508
|
+
if (!b.signature)
|
|
509
|
+
b.signature = fingerprint;
|
|
510
|
+
b.count++;
|
|
511
|
+
b.chars += chars;
|
|
512
|
+
if (seen > 0) {
|
|
513
|
+
b.wastedCount++;
|
|
514
|
+
b.wastedChars += chars;
|
|
515
|
+
b.cost.add(segment, turn, estimateTokens(chars));
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
else {
|
|
520
|
+
chars = blockChars(attachment.content ?? attachment.text ?? "");
|
|
521
|
+
}
|
|
522
|
+
const acc = attachments.get(type) ?? { events: 0, chars: 0 };
|
|
523
|
+
acc.events++;
|
|
524
|
+
acc.chars += chars;
|
|
525
|
+
attachments.set(type, acc);
|
|
526
|
+
break;
|
|
527
|
+
}
|
|
528
|
+
case "system": {
|
|
529
|
+
if (record.subtype === "compact_boundary") {
|
|
530
|
+
segmentEnds[segment] = turn;
|
|
531
|
+
segment++;
|
|
532
|
+
}
|
|
533
|
+
break;
|
|
534
|
+
}
|
|
535
|
+
default:
|
|
536
|
+
break;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
if (turn === 0)
|
|
540
|
+
return null;
|
|
541
|
+
segmentEnds[segment] = turn;
|
|
542
|
+
const resolved = {
|
|
543
|
+
reads: topBuckets(reads, segmentEnds),
|
|
544
|
+
outputs: topBuckets(outputs, segmentEnds),
|
|
545
|
+
hooks: topBuckets(hooks, segmentEnds),
|
|
546
|
+
writes: topBuckets(writes, segmentEnds),
|
|
547
|
+
failures: topBuckets(failures, segmentEnds),
|
|
548
|
+
};
|
|
549
|
+
const seenFiles = new Set();
|
|
550
|
+
const finishedTasks = tasks.map((t) => {
|
|
551
|
+
const { modelSet, fileSet, ...rest } = t;
|
|
552
|
+
const models = [...modelSet];
|
|
553
|
+
const touchedPriorFiles = [...fileSet].some((f) => seenFiles.has(f));
|
|
554
|
+
for (const f of fileSet)
|
|
555
|
+
seenFiles.add(f);
|
|
556
|
+
const carriedUsd = usd(models[0] ?? "claude-opus-5", {
|
|
557
|
+
input: 0,
|
|
558
|
+
output: 0,
|
|
559
|
+
cacheWrite: 0,
|
|
560
|
+
cacheRead: rest.carriedContext * rest.turns,
|
|
561
|
+
});
|
|
562
|
+
const carriedIsDead = rest.carriedContext >= DEAD_CARRY_TOKENS &&
|
|
563
|
+
rest.turns >= DEAD_CARRY_MIN_TURNS &&
|
|
564
|
+
rest.selfContained &&
|
|
565
|
+
!touchedPriorFiles;
|
|
566
|
+
return { ...rest, models, touchedPriorFiles, carriedUsd, carriedIsDead, project: cwd };
|
|
567
|
+
});
|
|
568
|
+
return {
|
|
569
|
+
schema: EVIDENCE_SCHEMA,
|
|
570
|
+
adapter: "claude-code",
|
|
571
|
+
sessionId,
|
|
572
|
+
project: cwd || "unknown",
|
|
573
|
+
sourceFile: file,
|
|
574
|
+
sourceSize: stat.size,
|
|
575
|
+
sourceMtimeMs: stat.mtimeMs,
|
|
576
|
+
agentVersion,
|
|
577
|
+
startedAt,
|
|
578
|
+
endedAt,
|
|
579
|
+
turns: turn,
|
|
580
|
+
humanPrompts,
|
|
581
|
+
usage,
|
|
582
|
+
weighted: weigh(usage),
|
|
583
|
+
peakContext,
|
|
584
|
+
contextP50: median(contexts),
|
|
585
|
+
compactions: segment,
|
|
586
|
+
coldStart: turn <= 3 && usage.cacheWrite > usage.cacheRead,
|
|
587
|
+
bloatTurns,
|
|
588
|
+
bloatTokens,
|
|
589
|
+
bloatWeighted,
|
|
590
|
+
apiErrors,
|
|
591
|
+
rateLimitHits,
|
|
592
|
+
interruptions,
|
|
593
|
+
toolCalls,
|
|
594
|
+
toolErrors,
|
|
595
|
+
sidechainTurns,
|
|
596
|
+
sidechainWeighted,
|
|
597
|
+
searchChars,
|
|
598
|
+
models: [...models.values()],
|
|
599
|
+
tasks: finishedTasks,
|
|
600
|
+
reads: resolved.reads.map((b) => ({
|
|
601
|
+
path: b.key,
|
|
602
|
+
signature: b.signature,
|
|
603
|
+
reads: b.count,
|
|
604
|
+
chars: b.chars,
|
|
605
|
+
redundantReads: b.wastedCount,
|
|
606
|
+
redundantChars: b.wastedChars,
|
|
607
|
+
redundantWeighted: b.weighted,
|
|
608
|
+
})),
|
|
609
|
+
outputs: resolved.outputs.map((b) => ({
|
|
610
|
+
label: b.key,
|
|
611
|
+
tool: b.tool,
|
|
612
|
+
calls: b.count,
|
|
613
|
+
chars: b.chars,
|
|
614
|
+
maxChars: b.maxChars,
|
|
615
|
+
excessChars: b.wastedChars,
|
|
616
|
+
excessWeighted: b.weighted,
|
|
617
|
+
})),
|
|
618
|
+
hooks: resolved.hooks.map((b) => ({
|
|
619
|
+
name: b.key,
|
|
620
|
+
events: b.count,
|
|
621
|
+
chars: b.chars,
|
|
622
|
+
weighted: b.weighted,
|
|
623
|
+
sample: b.sample,
|
|
624
|
+
command: b.command,
|
|
625
|
+
})),
|
|
626
|
+
attachments: [...attachments.entries()].map(([type, v]) => ({ type, events: v.events, chars: v.chars })),
|
|
627
|
+
writes: resolved.writes.map((b) => ({
|
|
628
|
+
path: b.key,
|
|
629
|
+
writes: b.count,
|
|
630
|
+
edits: b.edits,
|
|
631
|
+
rewrittenChars: b.wastedChars,
|
|
632
|
+
rewrittenWeighted: b.weighted,
|
|
633
|
+
})),
|
|
634
|
+
failures: resolved.failures.map((b) => ({
|
|
635
|
+
label: b.key,
|
|
636
|
+
tool: b.tool,
|
|
637
|
+
failures: b.count,
|
|
638
|
+
chars: b.chars,
|
|
639
|
+
weighted: b.weighted,
|
|
640
|
+
})),
|
|
641
|
+
};
|
|
642
|
+
}
|