@shanepadgett/tau-agent 0.39.1 → 0.40.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/extensions/auto-compact/README.md +1 -1
- package/extensions/auto-compact/index.ts +20 -0
- package/extensions/cost-report/README.md +32 -0
- package/extensions/cost-report/analyze.ts +423 -0
- package/extensions/cost-report/html.ts +474 -0
- package/extensions/cost-report/index.ts +181 -0
- package/extensions/cost-report/panel.ts +64 -0
- package/extensions/cost-report/range.ts +145 -0
- package/extensions/cost-report/types.ts +93 -0
- package/extensions/silent-command-runner/README.md +2 -0
- package/extensions/silent-command-runner/index.ts +50 -11
- package/extensions/tau-help/help.md +4 -0
- package/package.json +2 -2
- package/shared/events.ts +9 -0
|
@@ -6,4 +6,4 @@ Set `extensions.autoCompact.tokenLimit` to change the limit. It defaults to 175,
|
|
|
6
6
|
|
|
7
7
|
After changing this extension, run `/reload` before testing it.
|
|
8
8
|
|
|
9
|
-
Automatic compaction holds attention notifications while it runs and while the hidden continuation resumes the interrupted work. The notification is released only after that resumed work settles.
|
|
9
|
+
Automatic compaction holds attention notifications while it runs and while the hidden continuation resumes the interrupted work. The notification is released only after that resumed work settles. It also pauses silent post-edit checks across the compact so they do not run on incomplete work.
|
|
@@ -14,6 +14,7 @@ export default function autoCompactExtension(pi: ExtensionAPI): void {
|
|
|
14
14
|
let sessionVersion = 0;
|
|
15
15
|
let attentionHoldSequence = 0;
|
|
16
16
|
let attentionHoldId: string | undefined;
|
|
17
|
+
let silentCheckPauseId: string | undefined;
|
|
17
18
|
let continuationPending = false;
|
|
18
19
|
|
|
19
20
|
function releaseAttentionHold(disposition: "notify" | "discard"): void {
|
|
@@ -22,6 +23,18 @@ export default function autoCompactExtension(pi: ExtensionAPI): void {
|
|
|
22
23
|
if (holdId) emitTauEvent(pi, "tau:attention.hold.release", { id: holdId, disposition });
|
|
23
24
|
}
|
|
24
25
|
|
|
26
|
+
function pauseSilentChecks(): void {
|
|
27
|
+
if (silentCheckPauseId) return;
|
|
28
|
+
silentCheckPauseId = `auto-compact:${++attentionHoldSequence}`;
|
|
29
|
+
emitTauEvent(pi, "tau:silent-command-runner.pause", { id: silentCheckPauseId });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function resumeSilentChecks(disposition: "continue" | "settle"): void {
|
|
33
|
+
const pauseId = silentCheckPauseId;
|
|
34
|
+
silentCheckPauseId = undefined;
|
|
35
|
+
if (pauseId) emitTauEvent(pi, "tau:silent-command-runner.resume", { id: pauseId, disposition });
|
|
36
|
+
}
|
|
37
|
+
|
|
25
38
|
pi.on("session_start", async (_event, ctx) => {
|
|
26
39
|
const version = ++sessionVersion;
|
|
27
40
|
const settings = await loadTauExtensionSettings(ctx, autoCompactSettings);
|
|
@@ -31,6 +44,7 @@ export default function autoCompactExtension(pi: ExtensionAPI): void {
|
|
|
31
44
|
compacting = false;
|
|
32
45
|
attentionHoldSequence = 0;
|
|
33
46
|
attentionHoldId = undefined;
|
|
47
|
+
silentCheckPauseId = undefined;
|
|
34
48
|
continuationPending = false;
|
|
35
49
|
});
|
|
36
50
|
pi.on("session_shutdown", () => {
|
|
@@ -38,6 +52,7 @@ export default function autoCompactExtension(pi: ExtensionAPI): void {
|
|
|
38
52
|
armed = true;
|
|
39
53
|
compacting = false;
|
|
40
54
|
attentionHoldId = undefined;
|
|
55
|
+
silentCheckPauseId = undefined;
|
|
41
56
|
continuationPending = false;
|
|
42
57
|
});
|
|
43
58
|
pi.on("session_tree", () => {
|
|
@@ -64,6 +79,8 @@ export default function autoCompactExtension(pi: ExtensionAPI): void {
|
|
|
64
79
|
|
|
65
80
|
armed = false;
|
|
66
81
|
compacting = true;
|
|
82
|
+
// Pause before compact: compact aborts the agent, and that agent_end must not run checks.
|
|
83
|
+
pauseSilentChecks();
|
|
67
84
|
if (ctx.mode !== "print") {
|
|
68
85
|
attentionHoldId = `auto-compact:${++attentionHoldSequence}`;
|
|
69
86
|
emitTauEvent(pi, "tau:attention.hold.acquire", { id: attentionHoldId });
|
|
@@ -72,6 +89,8 @@ export default function autoCompactExtension(pi: ExtensionAPI): void {
|
|
|
72
89
|
const continueWork = (): void => {
|
|
73
90
|
if (version !== sessionVersion) return;
|
|
74
91
|
compacting = false;
|
|
92
|
+
// Keep the silent-check baseline across the new agent run started below.
|
|
93
|
+
resumeSilentChecks("continue");
|
|
75
94
|
continuationPending = true;
|
|
76
95
|
pi.sendMessage(
|
|
77
96
|
{
|
|
@@ -90,6 +109,7 @@ export default function autoCompactExtension(pi: ExtensionAPI): void {
|
|
|
90
109
|
if (version !== sessionVersion) return;
|
|
91
110
|
compacting = false;
|
|
92
111
|
if (error.name === "AbortError" || error.message === "Compaction cancelled") {
|
|
112
|
+
resumeSilentChecks("settle");
|
|
93
113
|
releaseAttentionHold("notify");
|
|
94
114
|
return;
|
|
95
115
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Cost Report
|
|
2
|
+
|
|
3
|
+
Generates a local HTML cost report from Pi session usage.
|
|
4
|
+
|
|
5
|
+
## Command
|
|
6
|
+
|
|
7
|
+
```text
|
|
8
|
+
/cost-report
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
TUI only. You pick a time frame and scope, then Tau scans sessions and opens the report in your browser.
|
|
12
|
+
|
|
13
|
+
## Time frames
|
|
14
|
+
|
|
15
|
+
- Past 7 days
|
|
16
|
+
- Current week (Monday–Sunday, through now)
|
|
17
|
+
- Current month (month to date)
|
|
18
|
+
- Year to date
|
|
19
|
+
- Specific month… (pick month, then year)
|
|
20
|
+
|
|
21
|
+
## Scope
|
|
22
|
+
|
|
23
|
+
- Current project
|
|
24
|
+
- All sessions
|
|
25
|
+
|
|
26
|
+
All-sessions reports include a project ranking and a project filter on the session list.
|
|
27
|
+
|
|
28
|
+
## Output
|
|
29
|
+
|
|
30
|
+
Reports are written under `~/.pi/tau/cost-reports/` and opened automatically. Empty windows get a warning instead of a file.
|
|
31
|
+
|
|
32
|
+
Costs come from stored session usage estimates, including subagent tool results on parent sessions.
|
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
import { type SessionEntry, type SessionInfo, SessionManager } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { eachLocalDay, isWeekend, localDateKey, shortDateLabel } from "./range.ts";
|
|
4
|
+
import type {
|
|
5
|
+
CostReport,
|
|
6
|
+
CostTotals,
|
|
7
|
+
DayCost,
|
|
8
|
+
ModelCost,
|
|
9
|
+
ProjectCost,
|
|
10
|
+
ReportRange,
|
|
11
|
+
ReportScope,
|
|
12
|
+
SessionCost,
|
|
13
|
+
SessionModelCost,
|
|
14
|
+
SubagentCost,
|
|
15
|
+
} from "./types.ts";
|
|
16
|
+
|
|
17
|
+
export interface BuildCostReportOptions {
|
|
18
|
+
cwd: string;
|
|
19
|
+
range: ReportRange;
|
|
20
|
+
scope: ReportScope;
|
|
21
|
+
signal?: AbortSignal;
|
|
22
|
+
onProgress?: (status: string) => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface ModelBucket {
|
|
26
|
+
key: string;
|
|
27
|
+
provider: string;
|
|
28
|
+
model: string;
|
|
29
|
+
cost: number;
|
|
30
|
+
tokens: number;
|
|
31
|
+
sessions: Set<string>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface SubagentBucket {
|
|
35
|
+
agent: string;
|
|
36
|
+
cost: number;
|
|
37
|
+
calls: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface ProjectBucket {
|
|
41
|
+
key: string;
|
|
42
|
+
label: string;
|
|
43
|
+
cost: number;
|
|
44
|
+
tokens: number;
|
|
45
|
+
sessions: Set<string>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface SessionModelBucket {
|
|
49
|
+
key: string;
|
|
50
|
+
label: string;
|
|
51
|
+
cost: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface AnalyzedSession {
|
|
55
|
+
session: SessionCost;
|
|
56
|
+
directCost: number;
|
|
57
|
+
subagentCost: number;
|
|
58
|
+
dayCosts: Map<string, number>;
|
|
59
|
+
models: ModelBucket[];
|
|
60
|
+
subagents: SubagentBucket[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function numberOrZero(value: unknown): number {
|
|
64
|
+
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
68
|
+
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function normalizeUsage(value: unknown): CostTotals {
|
|
72
|
+
const record = asRecord(value);
|
|
73
|
+
const cost = asRecord(record?.cost);
|
|
74
|
+
const input = numberOrZero(record?.input);
|
|
75
|
+
const output = numberOrZero(record?.output);
|
|
76
|
+
const cacheRead = numberOrZero(record?.cacheRead);
|
|
77
|
+
const cacheWrite = numberOrZero(record?.cacheWrite);
|
|
78
|
+
const totalTokens = numberOrZero(record?.totalTokens) || input + output + cacheRead + cacheWrite;
|
|
79
|
+
const costTotal =
|
|
80
|
+
numberOrZero(cost?.total) ||
|
|
81
|
+
numberOrZero(cost?.input) +
|
|
82
|
+
numberOrZero(cost?.output) +
|
|
83
|
+
numberOrZero(cost?.cacheRead) +
|
|
84
|
+
numberOrZero(cost?.cacheWrite);
|
|
85
|
+
return { input, output, cacheRead, cacheWrite, totalTokens, cost: costTotal };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function timestampMs(messageTimestamp: unknown, entryTimestamp: unknown): number | undefined {
|
|
89
|
+
if (typeof messageTimestamp === "number" && Number.isFinite(messageTimestamp)) return messageTimestamp;
|
|
90
|
+
if (typeof entryTimestamp === "string") {
|
|
91
|
+
const parsed = Date.parse(entryTimestamp);
|
|
92
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
93
|
+
}
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function inRange(ms: number, range: ReportRange): boolean {
|
|
98
|
+
return ms >= range.startMs && ms <= range.endMs;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function projectFromCwd(cwd: string): { key: string; label: string } {
|
|
102
|
+
const trimmed = cwd.trim();
|
|
103
|
+
if (!trimmed) return { key: "unknown", label: "unknown" };
|
|
104
|
+
const label = basename(trimmed) || trimmed;
|
|
105
|
+
return { key: trimmed, label };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function sessionDisplayName(entries: SessionEntry[], info: SessionInfo): string {
|
|
109
|
+
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
110
|
+
const entry = entries[index];
|
|
111
|
+
if (entry?.type !== "session_info") continue;
|
|
112
|
+
const name = entry.name?.trim();
|
|
113
|
+
if (name) return name;
|
|
114
|
+
}
|
|
115
|
+
const first = info.firstMessage?.trim();
|
|
116
|
+
if (first) return first.length > 80 ? `${first.slice(0, 77)}…` : first;
|
|
117
|
+
return info.id;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function shortModelLabel(provider: string, model: string): string {
|
|
121
|
+
if (model) return model;
|
|
122
|
+
if (provider) return provider;
|
|
123
|
+
return "unknown";
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function throwIfAborted(signal: AbortSignal | undefined): void {
|
|
127
|
+
if (signal?.aborted) throw new Error("cancelled");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function buildCostReport(options: BuildCostReportOptions): Promise<CostReport | undefined> {
|
|
131
|
+
const { cwd, range, scope, signal, onProgress } = options;
|
|
132
|
+
onProgress?.("Scanning sessions…");
|
|
133
|
+
throwIfAborted(signal);
|
|
134
|
+
|
|
135
|
+
const infos =
|
|
136
|
+
scope === "project"
|
|
137
|
+
? await SessionManager.list(cwd, undefined, (loaded, total) => {
|
|
138
|
+
onProgress?.(`Scanning sessions… ${loaded}/${total}`);
|
|
139
|
+
})
|
|
140
|
+
: await SessionManager.listAll((loaded, total) => {
|
|
141
|
+
onProgress?.(`Scanning sessions… ${loaded}/${total}`);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
throwIfAborted(signal);
|
|
145
|
+
onProgress?.(`Reading ${infos.length} sessions…`);
|
|
146
|
+
|
|
147
|
+
const analyzed: AnalyzedSession[] = [];
|
|
148
|
+
let index = 0;
|
|
149
|
+
for (const info of infos) {
|
|
150
|
+
index += 1;
|
|
151
|
+
if (index === 1 || index % 10 === 0 || index === infos.length) {
|
|
152
|
+
onProgress?.(`Reading sessions… ${index}/${infos.length}`);
|
|
153
|
+
}
|
|
154
|
+
throwIfAborted(signal);
|
|
155
|
+
try {
|
|
156
|
+
const result = analyzeSession(info, range);
|
|
157
|
+
if (result) analyzed.push(result);
|
|
158
|
+
} catch {
|
|
159
|
+
// Skip unreadable sessions.
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
throwIfAborted(signal);
|
|
164
|
+
if (analyzed.length === 0) return undefined;
|
|
165
|
+
|
|
166
|
+
onProgress?.("Building report…");
|
|
167
|
+
|
|
168
|
+
const dayMap = new Map<string, number>();
|
|
169
|
+
const modelMap = new Map<string, ModelBucket>();
|
|
170
|
+
const subagentMap = new Map<string, SubagentBucket>();
|
|
171
|
+
const projectMap = new Map<string, ProjectBucket>();
|
|
172
|
+
let directCost = 0;
|
|
173
|
+
let subagentCost = 0;
|
|
174
|
+
let totalTokens = 0;
|
|
175
|
+
|
|
176
|
+
for (const item of analyzed) {
|
|
177
|
+
directCost += item.directCost;
|
|
178
|
+
subagentCost += item.subagentCost;
|
|
179
|
+
totalTokens += item.session.tokens;
|
|
180
|
+
|
|
181
|
+
for (const [key, cost] of item.dayCosts) {
|
|
182
|
+
dayMap.set(key, (dayMap.get(key) ?? 0) + cost);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
for (const model of item.models) {
|
|
186
|
+
const existing = modelMap.get(model.key);
|
|
187
|
+
if (existing) {
|
|
188
|
+
existing.cost += model.cost;
|
|
189
|
+
existing.tokens += model.tokens;
|
|
190
|
+
for (const sessionId of model.sessions) existing.sessions.add(sessionId);
|
|
191
|
+
} else {
|
|
192
|
+
modelMap.set(model.key, {
|
|
193
|
+
...model,
|
|
194
|
+
sessions: new Set(model.sessions),
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
for (const agent of item.subagents) {
|
|
200
|
+
const existing = subagentMap.get(agent.agent);
|
|
201
|
+
if (existing) {
|
|
202
|
+
existing.cost += agent.cost;
|
|
203
|
+
existing.calls += agent.calls;
|
|
204
|
+
} else {
|
|
205
|
+
subagentMap.set(agent.agent, { ...agent });
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const project = projectMap.get(item.session.projectKey);
|
|
210
|
+
if (project) {
|
|
211
|
+
project.cost += item.session.cost;
|
|
212
|
+
project.tokens += item.session.tokens;
|
|
213
|
+
project.sessions.add(item.session.id);
|
|
214
|
+
} else {
|
|
215
|
+
projectMap.set(item.session.projectKey, {
|
|
216
|
+
key: item.session.projectKey,
|
|
217
|
+
label: item.session.projectLabel,
|
|
218
|
+
cost: item.session.cost,
|
|
219
|
+
tokens: item.session.tokens,
|
|
220
|
+
sessions: new Set([item.session.id]),
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const days = buildDays(range, dayMap);
|
|
226
|
+
const models: ModelCost[] = [...modelMap.values()]
|
|
227
|
+
.map((bucket) => ({
|
|
228
|
+
key: bucket.key,
|
|
229
|
+
provider: bucket.provider,
|
|
230
|
+
model: bucket.model,
|
|
231
|
+
cost: bucket.cost,
|
|
232
|
+
tokens: bucket.tokens,
|
|
233
|
+
sessions: bucket.sessions.size,
|
|
234
|
+
}))
|
|
235
|
+
.sort((a, b) => b.cost - a.cost);
|
|
236
|
+
|
|
237
|
+
const subagents: SubagentCost[] = [...subagentMap.values()].sort((a, b) => b.cost - a.cost);
|
|
238
|
+
|
|
239
|
+
const projects: ProjectCost[] = [...projectMap.values()]
|
|
240
|
+
.map((bucket) => ({
|
|
241
|
+
key: bucket.key,
|
|
242
|
+
label: bucket.label,
|
|
243
|
+
cost: bucket.cost,
|
|
244
|
+
sessions: bucket.sessions.size,
|
|
245
|
+
tokens: bucket.tokens,
|
|
246
|
+
}))
|
|
247
|
+
.sort((a, b) => b.cost - a.cost);
|
|
248
|
+
|
|
249
|
+
const sessions = analyzed.map((item) => item.session).sort((a, b) => b.cost - a.cost);
|
|
250
|
+
|
|
251
|
+
return {
|
|
252
|
+
generatedAtMs: Date.now(),
|
|
253
|
+
cwd,
|
|
254
|
+
scope,
|
|
255
|
+
range,
|
|
256
|
+
directCost,
|
|
257
|
+
subagentCost,
|
|
258
|
+
totalCost: directCost + subagentCost,
|
|
259
|
+
totalTokens,
|
|
260
|
+
sessionCount: sessions.length,
|
|
261
|
+
projectCount: projects.length,
|
|
262
|
+
days,
|
|
263
|
+
models,
|
|
264
|
+
subagents,
|
|
265
|
+
projects,
|
|
266
|
+
sessions,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function buildDays(range: ReportRange, dayMap: Map<string, number>): DayCost[] {
|
|
271
|
+
const days: DayCost[] = [];
|
|
272
|
+
for (const dayStart of eachLocalDay(range.startMs, range.endMs)) {
|
|
273
|
+
const dateKey = localDateKey(dayStart);
|
|
274
|
+
days.push({
|
|
275
|
+
dateKey,
|
|
276
|
+
label: shortDateLabel(dayStart),
|
|
277
|
+
timestampMs: dayStart,
|
|
278
|
+
cost: dayMap.get(dateKey) ?? 0,
|
|
279
|
+
weekend: isWeekend(dayStart),
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
return days;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function analyzeSession(info: SessionInfo, range: ReportRange): AnalyzedSession | undefined {
|
|
286
|
+
const manager = SessionManager.open(info.path);
|
|
287
|
+
const entries = manager.getEntries();
|
|
288
|
+
const header = manager.getHeader();
|
|
289
|
+
const cwd = header?.cwd || info.cwd || "";
|
|
290
|
+
const project = projectFromCwd(cwd);
|
|
291
|
+
const sessionId = header?.id || info.id;
|
|
292
|
+
const name = sessionDisplayName(entries, info);
|
|
293
|
+
|
|
294
|
+
const dayCosts = new Map<string, number>();
|
|
295
|
+
const modelBuckets = new Map<string, SessionModelBucket & { tokens: number; provider: string; model: string }>();
|
|
296
|
+
const subagentBuckets = new Map<string, SubagentBucket>();
|
|
297
|
+
let directCost = 0;
|
|
298
|
+
let subagentCost = 0;
|
|
299
|
+
let tokens = 0;
|
|
300
|
+
let startedAtMs = Number.POSITIVE_INFINITY;
|
|
301
|
+
|
|
302
|
+
for (const entry of entries) {
|
|
303
|
+
if (entry.type !== "message") {
|
|
304
|
+
if ((entry.type === "compaction" || entry.type === "branch_summary") && entry.usage) {
|
|
305
|
+
const ts = timestampMs(undefined, entry.timestamp);
|
|
306
|
+
if (ts === undefined || !inRange(ts, range)) continue;
|
|
307
|
+
const usage = normalizeUsage(entry.usage);
|
|
308
|
+
if (usage.cost <= 0 && usage.totalTokens <= 0) continue;
|
|
309
|
+
directCost += usage.cost;
|
|
310
|
+
tokens += usage.totalTokens;
|
|
311
|
+
dayCosts.set(localDateKey(ts), (dayCosts.get(localDateKey(ts)) ?? 0) + usage.cost);
|
|
312
|
+
if (ts < startedAtMs) startedAtMs = ts;
|
|
313
|
+
}
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const message = entry.message as {
|
|
318
|
+
role?: string;
|
|
319
|
+
timestamp?: number;
|
|
320
|
+
provider?: string;
|
|
321
|
+
model?: string;
|
|
322
|
+
usage?: unknown;
|
|
323
|
+
toolName?: string;
|
|
324
|
+
details?: unknown;
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
const ts = timestampMs(message.timestamp, entry.timestamp);
|
|
328
|
+
if (ts === undefined || !inRange(ts, range)) continue;
|
|
329
|
+
|
|
330
|
+
if (message.role === "assistant" && message.usage) {
|
|
331
|
+
const usage = normalizeUsage(message.usage);
|
|
332
|
+
if (usage.cost <= 0 && usage.totalTokens <= 0) continue;
|
|
333
|
+
directCost += usage.cost;
|
|
334
|
+
tokens += usage.totalTokens;
|
|
335
|
+
dayCosts.set(localDateKey(ts), (dayCosts.get(localDateKey(ts)) ?? 0) + usage.cost);
|
|
336
|
+
if (ts < startedAtMs) startedAtMs = ts;
|
|
337
|
+
|
|
338
|
+
const provider = typeof message.provider === "string" ? message.provider : "unknown";
|
|
339
|
+
const model = typeof message.model === "string" ? message.model : "unknown";
|
|
340
|
+
const key = `${provider}/${model}`;
|
|
341
|
+
const existing = modelBuckets.get(key);
|
|
342
|
+
if (existing) {
|
|
343
|
+
existing.cost += usage.cost;
|
|
344
|
+
existing.tokens += usage.totalTokens;
|
|
345
|
+
} else {
|
|
346
|
+
modelBuckets.set(key, {
|
|
347
|
+
key,
|
|
348
|
+
label: shortModelLabel(provider, model),
|
|
349
|
+
provider,
|
|
350
|
+
model,
|
|
351
|
+
cost: usage.cost,
|
|
352
|
+
tokens: usage.totalTokens,
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (message.role === "toolResult" && message.usage) {
|
|
359
|
+
const usage = normalizeUsage(message.usage);
|
|
360
|
+
if (usage.cost <= 0 && usage.totalTokens <= 0) continue;
|
|
361
|
+
dayCosts.set(localDateKey(ts), (dayCosts.get(localDateKey(ts)) ?? 0) + usage.cost);
|
|
362
|
+
if (ts < startedAtMs) startedAtMs = ts;
|
|
363
|
+
tokens += usage.totalTokens;
|
|
364
|
+
|
|
365
|
+
if (message.toolName === "subagent") {
|
|
366
|
+
subagentCost += usage.cost;
|
|
367
|
+
const details = asRecord(message.details);
|
|
368
|
+
const agent =
|
|
369
|
+
(typeof details?.agent === "string" && details.agent) ||
|
|
370
|
+
(typeof details?.displayName === "string" && details.displayName) ||
|
|
371
|
+
"subagent";
|
|
372
|
+
const existing = subagentBuckets.get(agent);
|
|
373
|
+
if (existing) {
|
|
374
|
+
existing.cost += usage.cost;
|
|
375
|
+
existing.calls += 1;
|
|
376
|
+
} else {
|
|
377
|
+
subagentBuckets.set(agent, { agent, cost: usage.cost, calls: 1 });
|
|
378
|
+
}
|
|
379
|
+
} else {
|
|
380
|
+
directCost += usage.cost;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const sessionCost = directCost + subagentCost;
|
|
386
|
+
if (sessionCost <= 0 && tokens <= 0) return undefined;
|
|
387
|
+
|
|
388
|
+
const models: ModelBucket[] = [...modelBuckets.values()].map((bucket) => ({
|
|
389
|
+
key: bucket.key,
|
|
390
|
+
provider: bucket.provider,
|
|
391
|
+
model: bucket.model,
|
|
392
|
+
cost: bucket.cost,
|
|
393
|
+
tokens: bucket.tokens,
|
|
394
|
+
sessions: new Set([sessionId]),
|
|
395
|
+
}));
|
|
396
|
+
|
|
397
|
+
const sessionModels: SessionModelCost[] = [...modelBuckets.values()]
|
|
398
|
+
.map((bucket) => ({
|
|
399
|
+
key: bucket.key,
|
|
400
|
+
label: bucket.label,
|
|
401
|
+
cost: bucket.cost,
|
|
402
|
+
}))
|
|
403
|
+
.sort((a, b) => b.cost - a.cost);
|
|
404
|
+
|
|
405
|
+
return {
|
|
406
|
+
session: {
|
|
407
|
+
id: sessionId,
|
|
408
|
+
path: info.path,
|
|
409
|
+
name,
|
|
410
|
+
projectKey: project.key,
|
|
411
|
+
projectLabel: project.label,
|
|
412
|
+
startedAtMs: Number.isFinite(startedAtMs) ? startedAtMs : range.startMs,
|
|
413
|
+
cost: sessionCost,
|
|
414
|
+
tokens,
|
|
415
|
+
models: sessionModels,
|
|
416
|
+
},
|
|
417
|
+
directCost,
|
|
418
|
+
subagentCost,
|
|
419
|
+
dayCosts,
|
|
420
|
+
models,
|
|
421
|
+
subagents: [...subagentBuckets.values()],
|
|
422
|
+
};
|
|
423
|
+
}
|