Skip to content

Commit d22889e

Browse files
committed
fix spend
1 parent 53f2b98 commit d22889e

3 files changed

Lines changed: 203 additions & 126 deletions

File tree

pi/extensions/spend.ts

Lines changed: 117 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,25 @@
11
import type { AssistantMessage } from "@earendil-works/pi-ai";
2-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
33
import { appendFile, mkdir, readdir, readFile, writeFile } from "node:fs/promises";
44
import { homedir } from "node:os";
55
import { basename, join } from "node:path";
66

77
const CACHE_DIR = join(process.env.XDG_CACHE_HOME || join(homedir(), ".cache"), "pi");
8-
const LEGACY_LEDGER_FILE = join(CACHE_DIR, "spend-v1.jsonl");
9-
const LEDGER_FILE = join(CACHE_DIR, "spend-v2.jsonl");
8+
const LEGACY_LEDGER_FILES = [join(CACHE_DIR, "spend-v1.jsonl"), join(CACHE_DIR, "spend-v2.jsonl")];
9+
const LEDGER_FILE = join(CACHE_DIR, "spend-v3.jsonl");
1010
const GRAPH_FILE = join(CACHE_DIR, "spend.html");
1111
const SESSIONS_DIR = join(homedir(), ".pi", "agent", "sessions");
1212
const MIN_TIMESTAMP = Date.UTC(2000, 0, 1);
1313
const MAX_TIMESTAMP = Date.UTC(2100, 0, 1);
14+
const MIN_MODEL_COST = 1;
15+
16+
type SpendKind = "assistant" | "tool" | "compaction" | "branch_summary";
1417

1518
export type SpendRecord = {
16-
v: 2;
19+
v: 3;
1720
key: string;
21+
kind: SpendKind;
22+
entryId: string;
1823
sessionId: string;
1924
cwd?: string;
2025
timestamp: number;
@@ -27,43 +32,60 @@ export type SpendRecord = {
2732
cacheWrite: number;
2833
};
2934

30-
type SessionFile = { path: string; id: string; cwd?: string };
31-
export function recordKey(sessionId: string, entryId: string): string {
32-
return `${sessionId}:${entryId}`;
35+
type SessionFile = { id: string; cwd?: string };
36+
export function recordKey(kind: SpendKind, entryId: string, timestamp: number): string {
37+
return `${kind}:${entryId}:${timestamp}`;
3338
}
3439

3540
export function isValidTimestamp(value: unknown): value is number {
3641
return typeof value === "number" && Number.isFinite(value) && value >= MIN_TIMESTAMP && value <= MAX_TIMESTAMP;
3742
}
3843

39-
function asRecord(entry: any, session: SessionFile): SpendRecord | undefined {
40-
const message = entry?.message as AssistantMessage | undefined;
41-
if (
42-
entry?.type !== "message" ||
43-
typeof entry.id !== "string" ||
44-
!entry.id ||
45-
message?.role !== "assistant" ||
46-
!message.usage ||
47-
!isValidTimestamp(message.timestamp)
48-
)
44+
export function asRecord(entry: any, session: SessionFile): SpendRecord | undefined {
45+
if (typeof entry?.id !== "string" || !entry.id) return;
46+
47+
let kind: SpendKind;
48+
let usage;
49+
let timestamp: number;
50+
let provider = "Tools";
51+
let model = "summaries";
52+
if (entry.type === "message" && entry.message?.role === "assistant") {
53+
const message = entry.message as AssistantMessage;
54+
kind = "assistant";
55+
usage = message.usage;
56+
timestamp = message.timestamp;
57+
provider = message.provider || "unknown";
58+
model = message.responseModel || message.model || "unknown";
59+
} else if (entry.type === "message" && entry.message?.role === "toolResult" && entry.message.usage) {
60+
kind = "tool";
61+
usage = entry.message.usage;
62+
timestamp = entry.message.timestamp;
63+
} else if ((entry.type === "compaction" || entry.type === "branch_summary") && entry.usage) {
64+
kind = entry.type;
65+
usage = entry.usage;
66+
timestamp = Date.parse(entry.timestamp);
67+
} else {
4968
return;
69+
}
5070

51-
const cost = message.usage.cost?.total;
52-
if (!Number.isFinite(cost)) return;
71+
const cost = usage?.cost?.total;
72+
if (!isValidTimestamp(timestamp) || !Number.isFinite(cost)) return;
5373

5474
return {
55-
v: 2,
56-
key: recordKey(session.id, entry.id),
75+
v: 3,
76+
key: recordKey(kind, entry.id, timestamp),
77+
kind,
78+
entryId: entry.id,
5779
sessionId: session.id,
5880
...(session.cwd === undefined ? {} : { cwd: session.cwd }),
59-
timestamp: message.timestamp,
60-
provider: message.provider || "unknown",
61-
model: message.model || "unknown",
81+
timestamp,
82+
provider,
83+
model,
6284
cost,
63-
input: message.usage.input || 0,
64-
output: message.usage.output || 0,
65-
cacheRead: message.usage.cacheRead || 0,
66-
cacheWrite: message.usage.cacheWrite || 0,
85+
input: usage.input || 0,
86+
output: usage.output || 0,
87+
cacheRead: usage.cacheRead || 0,
88+
cacheWrite: usage.cacheWrite || 0,
6789
};
6890
}
6991

@@ -97,7 +119,7 @@ async function parseSession(path: string): Promise<{ session: SessionFile; recor
97119
try {
98120
const entry = JSON.parse(line);
99121
if (entry.type === "session" && typeof entry.id === "string" && entry.id) {
100-
session = { path, id: entry.id, cwd: typeof entry.cwd === "string" ? entry.cwd : undefined };
122+
session = { id: entry.id, cwd: typeof entry.cwd === "string" ? entry.cwd : undefined };
101123
} else if (session) {
102124
const record = asRecord(entry, session);
103125
if (record) records.push(record);
@@ -135,8 +157,35 @@ export function parseLedgerRecord(value: unknown): SpendRecord | undefined {
135157
)
136158
return;
137159

138-
const base = {
139-
v: 2 as const,
160+
let kind: SpendKind = "assistant";
161+
let entryId: string;
162+
if (candidate.v === 3) {
163+
if (
164+
(candidate.kind !== "assistant" &&
165+
candidate.kind !== "tool" &&
166+
candidate.kind !== "compaction" &&
167+
candidate.kind !== "branch_summary") ||
168+
typeof candidate.entryId !== "string" ||
169+
!candidate.entryId
170+
)
171+
return;
172+
kind = candidate.kind;
173+
entryId = candidate.entryId;
174+
if (key !== recordKey(kind, entryId, timestamp)) return;
175+
} else if (candidate.v === 2 && key.startsWith(`${sessionId}:`)) {
176+
entryId = key.slice(sessionId.length + 1);
177+
if (!entryId) return;
178+
} else if (candidate.v === 1) {
179+
entryId = key;
180+
} else {
181+
return;
182+
}
183+
184+
return {
185+
v: 3,
186+
key: recordKey(kind, entryId, timestamp),
187+
kind,
188+
entryId,
140189
sessionId,
141190
...(typeof candidate.cwd === "string" ? { cwd: candidate.cwd } : {}),
142191
timestamp,
@@ -148,8 +197,6 @@ export function parseLedgerRecord(value: unknown): SpendRecord | undefined {
148197
cacheRead,
149198
cacheWrite,
150199
};
151-
if (candidate.v === 2 && key.startsWith(`${sessionId}:`)) return { ...base, key };
152-
if (candidate.v === 1) return { ...base, key: recordKey(sessionId, key) };
153200
}
154201

155202
function formatCost(cost: number): string {
@@ -177,7 +224,7 @@ function summary(records: Iterable<SpendRecord>): string {
177224
return [
178225
`Pi spend: ${formatCost(total)} across ${bySession.size} sessions (${all.length} responses)`,
179226
"By model:",
180-
...top(byModel),
227+
...top(new Map([...byModel].filter(([, cost]) => cost >= MIN_MODEL_COST))),
181228
"By session:",
182229
...top(bySession),
183230
].join("\n");
@@ -189,7 +236,12 @@ export function graphHtml(records: Iterable<SpendRecord>): string {
189236
const all = [...unique.values()]
190237
.filter((record) => isValidTimestamp(record.timestamp))
191238
.sort((a, b) => a.timestamp - b.timestamp);
192-
const modelNames = [...new Set(all.map((record) => `${record.provider}/${record.model}`))];
239+
const modelTotals = new Map<string, number>();
240+
for (const record of all) {
241+
const name = `${record.provider}/${record.model}`;
242+
modelTotals.set(name, (modelTotals.get(name) || 0) + record.cost);
243+
}
244+
const modelNames = [...modelTotals].filter(([, cost]) => cost >= MIN_MODEL_COST).map(([name]) => name);
193245
const observedDates = [...new Set(all.map((record) => new Date(record.timestamp).toISOString().slice(0, 10)))].sort();
194246
const dates: string[] = [];
195247
const firstObservedDate = observedDates[0];
@@ -389,25 +441,24 @@ export default function spendExtension(pi: ExtensionAPI): void {
389441
async function loadLedger(): Promise<void> {
390442
if (initialized) return;
391443
initialized = true;
392-
try {
393-
for (const file of [LEGACY_LEDGER_FILE, LEDGER_FILE]) {
394-
try {
395-
for (const record of parseLedger(await readFile(file, "utf8"))) records.set(record.key, record);
396-
} catch {
397-
/* ignore missing or malformed cache entries */
398-
}
444+
for (const file of [...LEGACY_LEDGER_FILES, LEDGER_FILE]) {
445+
try {
446+
for (const record of parseLedger(await readFile(file, "utf8"))) records.set(record.key, record);
447+
} catch {
448+
/* ignore missing or malformed cache entries */
399449
}
400-
} catch {
401-
/* the ledger is created on first write */
402450
}
403451
}
404452

405453
async function save(newRecords: SpendRecord[]): Promise<void> {
406-
const fresh = newRecords.filter((record) => !records.has(record.key));
407-
if (!fresh.length) return;
454+
const fresh = new Map<string, SpendRecord>();
455+
for (const record of newRecords) {
456+
if (!records.has(record.key)) fresh.set(record.key, record);
457+
}
458+
if (!fresh.size) return;
408459
await mkdir(CACHE_DIR, { recursive: true });
409-
await appendFile(LEDGER_FILE, fresh.map((record) => JSON.stringify(record)).join("\n") + "\n");
410-
for (const record of fresh) records.set(record.key, record);
460+
await appendFile(LEDGER_FILE, [...fresh.values()].map((record) => JSON.stringify(record)).join("\n") + "\n");
461+
for (const [key, record] of fresh) records.set(key, record);
411462
}
412463

413464
async function importSessions(): Promise<void> {
@@ -416,45 +467,45 @@ export default function spendExtension(pi: ExtensionAPI): void {
416467
await save(parsed.flatMap((result) => result?.records || []));
417468
}
418469

419-
pi.on("session_start", async (_event, ctx) => {
470+
pi.on("session_start", async () => {
420471
await loadLedger();
421-
const file = ctx.sessionManager.getSessionFile();
422-
if (file) {
423-
const parsed = await parseSession(file);
424-
if (parsed) await save(parsed.records);
425-
}
472+
await importSessions();
426473
});
427474

428475
pi.on("message_end", async (event, ctx) => {
429-
if (event.message.role !== "assistant") return;
476+
if (event.message.role !== "assistant" && event.message.role !== "toolResult") return;
430477
await loadLedger();
431-
const entries = ctx.sessionManager.getEntries();
432-
const entry = [...entries]
478+
const entry = [...ctx.sessionManager.getEntries()]
433479
.reverse()
434480
.find(
435481
(candidate: any) =>
436482
candidate.type === "message" &&
437-
candidate.message.role === "assistant" &&
483+
candidate.message.role === event.message.role &&
438484
candidate.message.timestamp === event.message.timestamp,
439485
);
440486
const header = ctx.sessionManager.getHeader();
441487
if (entry && header) {
442-
const record = asRecord(entry, {
443-
path: ctx.sessionManager.getSessionFile() || "",
444-
id: header.id,
445-
cwd: header.cwd,
446-
});
488+
const record = asRecord(entry, { id: header.id, cwd: header.cwd });
447489
if (record) await save([record]);
448490
}
449491
});
450492

493+
const saveCurrentSession = async (_event: unknown, ctx: ExtensionContext) => {
494+
await loadLedger();
495+
const file = ctx.sessionManager.getSessionFile();
496+
if (!file) return;
497+
const parsed = await parseSession(file);
498+
if (parsed) await save(parsed.records);
499+
};
500+
pi.on("session_compact", saveCurrentSession);
501+
pi.on("session_tree", saveCurrentSession);
502+
451503
pi.registerCommand("spend", {
452504
description: "Open Pi spend graphs in the browser (use /spend text for the report)",
453505
handler: async (args, ctx) => {
454506
await loadLedger();
455-
const action = args.trim();
456-
if (action === "import") await importSessions();
457-
if (action === "text") {
507+
await importSessions();
508+
if (args.trim() === "text") {
458509
const report = summary(records.values());
459510
if (ctx.hasUI) await ctx.ui.editor("Pi spend", report);
460511
else console.log(report);

0 commit comments

Comments
 (0)