11import 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" ;
33import { appendFile , mkdir , readdir , readFile , writeFile } from "node:fs/promises" ;
44import { homedir } from "node:os" ;
55import { basename , join } from "node:path" ;
66
77const 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" ) ;
1010const GRAPH_FILE = join ( CACHE_DIR , "spend.html" ) ;
1111const SESSIONS_DIR = join ( homedir ( ) , ".pi" , "agent" , "sessions" ) ;
1212const MIN_TIMESTAMP = Date . UTC ( 2000 , 0 , 1 ) ;
1313const MAX_TIMESTAMP = Date . UTC ( 2100 , 0 , 1 ) ;
14+ const MIN_MODEL_COST = 1 ;
15+
16+ type SpendKind = "assistant" | "tool" | "compaction" | "branch_summary" ;
1417
1518export 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
3540export 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
155202function 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