@@ -50,6 +50,15 @@ import {
5050 createCustomMessage ,
5151} from "./messages.ts" ;
5252
53+ export interface UsageTotals {
54+ input : number ;
55+ output : number ;
56+ cacheRead : number ;
57+ cacheWrite : number ;
58+ cost : number ;
59+ latestCacheHitRate : number | undefined ;
60+ }
61+
5362export const CURRENT_SESSION_VERSION = 3 ;
5463
5564export interface SessionHeader {
@@ -873,6 +882,24 @@ export class SessionManager {
873882 private labelTimestampsById : Map < string , string > = new Map ( ) ;
874883 private leafId : string | null = null ;
875884 private residentStore = new ResidentStringStore ( ) ;
885+ // Monotonic counter bumped by every mutator; memoized materialized views are
886+ // keyed on it so read hot paths (footer, RPC) never re-materialize unchanged sessions.
887+ private mutationCount = 0 ;
888+ private entriesCache : { mutation : number ; entries : SessionEntry [ ] } | null = null ;
889+ private branchCache : { leafId : string | null ; mutation : number ; entries : SessionEntry [ ] } | null = null ;
890+ private sessionNameCache : string | undefined = undefined ;
891+ // Running usage totals over ALL entries (not branch-scoped), maintained
892+ // incrementally on assistant-message append and rebuilt from scratch in
893+ // _buildIndex()/newSession. Usage fields are numeric and unaffected by
894+ // string externalization, so the resident form can be read directly.
895+ private usageTotals : UsageTotals = {
896+ input : 0 ,
897+ output : 0 ,
898+ cacheRead : 0 ,
899+ cacheWrite : 0 ,
900+ cost : 0 ,
901+ latestCacheHitRate : undefined ,
902+ } ;
876903
877904 private constructor (
878905 cwd : string ,
@@ -925,6 +952,7 @@ export class SessionManager {
925952
926953 this . fileEntries = this . fileEntries . map ( ( entry ) => this . residentStore . externalize ( entry ) ) ;
927954 this . _buildIndex ( ) ;
955+ this . mutationCount ++ ;
928956 this . flushed = true ;
929957 } else {
930958 const explicitPath = this . sessionFile ;
@@ -953,6 +981,16 @@ export class SessionManager {
953981 this . labelsById . clear ( ) ;
954982 this . labelTimestampsById . clear ( ) ;
955983 this . leafId = null ;
984+ this . sessionNameCache = undefined ;
985+ this . usageTotals = {
986+ input : 0 ,
987+ output : 0 ,
988+ cacheRead : 0 ,
989+ cacheWrite : 0 ,
990+ cost : 0 ,
991+ latestCacheHitRate : undefined ,
992+ } ;
993+ this . mutationCount ++ ;
956994 this . flushed = false ;
957995
958996 if ( this . persist ) {
@@ -967,10 +1005,24 @@ export class SessionManager {
9671005 this . labelsById . clear ( ) ;
9681006 this . labelTimestampsById . clear ( ) ;
9691007 this . leafId = null ;
1008+ this . sessionNameCache = undefined ;
1009+ this . usageTotals = {
1010+ input : 0 ,
1011+ output : 0 ,
1012+ cacheRead : 0 ,
1013+ cacheWrite : 0 ,
1014+ cost : 0 ,
1015+ latestCacheHitRate : undefined ,
1016+ } ;
9701017 for ( const entry of this . fileEntries ) {
9711018 if ( entry . type === "session" ) continue ;
9721019 this . byId . set ( entry . id , entry ) ;
9731020 this . leafId = entry . id ;
1021+ this . _accumulateUsage ( entry ) ;
1022+ if ( entry . type === "session_info" ) {
1023+ // Empty names explicitly clear the session title.
1024+ this . sessionNameCache = entry . name ?. trim ( ) || undefined ;
1025+ }
9741026 if ( entry . type === "label" ) {
9751027 if ( entry . label ) {
9761028 this . labelsById . set ( entry . targetId , entry . label ) ;
@@ -1058,9 +1110,39 @@ export class SessionManager {
10581110 this . fileEntries . push ( residentEntry ) ;
10591111 this . byId . set ( residentEntry . id , residentEntry ) ;
10601112 this . leafId = residentEntry . id ;
1113+ this . _accumulateUsage ( residentEntry ) ;
1114+ this . mutationCount ++ ;
10611115 this . _persist ( residentEntry ) ;
10621116 }
10631117
1118+ /**
1119+ * Fold one entry into the running usage totals. Totals iterate ALL entries
1120+ * (not branch-scoped), matching the footer hot path's historical semantics.
1121+ */
1122+ private _accumulateUsage ( entry : SessionEntry ) : void {
1123+ if ( entry . type !== "message" || entry . message . role !== "assistant" ) return ;
1124+ const usage = entry . message . usage ;
1125+ // Assistant messages persisted without usage (e.g. aborted/error turns in
1126+ // older session files) contribute nothing to the running totals.
1127+ if ( ! usage ) return ;
1128+ this . usageTotals . input += usage . input ;
1129+ this . usageTotals . output += usage . output ;
1130+ this . usageTotals . cacheRead += usage . cacheRead ;
1131+ this . usageTotals . cacheWrite += usage . cacheWrite ;
1132+ this . usageTotals . cost += usage . cost ?. total ?? 0 ;
1133+ const latestPromptTokens = usage . input + usage . cacheRead + usage . cacheWrite ;
1134+ this . usageTotals . latestCacheHitRate =
1135+ latestPromptTokens > 0 ? ( usage . cacheRead / latestPromptTokens ) * 100 : undefined ;
1136+ }
1137+
1138+ /**
1139+ * O(1) running usage totals across ALL session entries (not branch-scoped).
1140+ * Maintained incrementally; identical to summing usage over getEntries().
1141+ */
1142+ getUsageTotals ( ) : UsageTotals {
1143+ return this . usageTotals ;
1144+ }
1145+
10641146 /** Append a message as child of current leaf, then advance leaf. Returns entry id.
10651147 * Does not allow writing CompactionSummaryMessage and BranchSummaryMessage directly.
10661148 * Reason: we want these to be top-level entries in the session, not message session entries,
@@ -1162,22 +1244,17 @@ export class SessionManager {
11621244 timestamp : new Date ( ) . toISOString ( ) ,
11631245 name : sanitizedName ,
11641246 } ;
1247+ this . sessionNameCache = sanitizedName || undefined ;
11651248 this . _appendEntry ( entry ) ;
11661249 return entry . id ;
11671250 }
11681251
1169- /** Get the current session name from the latest session_info entry, if any. */
1252+ /**
1253+ * Get the current session name from the latest session_info entry, if any.
1254+ * O(1): the value is maintained incrementally on appendSessionInfo() and index rebuilds.
1255+ */
11701256 getSessionName ( ) : string | undefined {
1171- // Walk entries in reverse to find the latest session_info entry.
1172- // Empty names explicitly clear the session title.
1173- const entries = this . getEntries ( ) ;
1174- for ( let i = entries . length - 1 ; i >= 0 ; i -- ) {
1175- const entry = entries [ i ] ;
1176- if ( entry . type === "session_info" ) {
1177- return entry . name ?. trim ( ) || undefined ;
1178- }
1179- }
1180- return undefined ;
1257+ return this . sessionNameCache ;
11811258 }
11821259
11831260 /**
@@ -1280,13 +1357,26 @@ export class SessionManager {
12801357 * Use buildSessionContext() to get the resolved messages for the LLM.
12811358 */
12821359 getBranch ( fromId ?: string ) : SessionEntry [ ] {
1360+ // No-arg reads (the common hot path) are memoized on (leafId, mutationCount);
1361+ // explicit fromId lookups bypass the cache.
1362+ if (
1363+ fromId === undefined &&
1364+ this . branchCache !== null &&
1365+ this . branchCache . leafId === this . leafId &&
1366+ this . branchCache . mutation === this . mutationCount
1367+ ) {
1368+ return this . branchCache . entries ;
1369+ }
12831370 const path : SessionEntry [ ] = [ ] ;
12841371 const startId = fromId ?? this . leafId ;
12851372 let current = startId ? this . byId . get ( startId ) : undefined ;
12861373 while ( current ) {
12871374 path . unshift ( this . residentStore . materialize ( current ) ) ;
12881375 current = current . parentId ? this . byId . get ( current . parentId ) : undefined ;
12891376 }
1377+ if ( fromId === undefined ) {
1378+ this . branchCache = { leafId : this . leafId , mutation : this . mutationCount , entries : path } ;
1379+ }
12901380 return path ;
12911381 }
12921382
@@ -1346,14 +1436,24 @@ export class SessionManager {
13461436 }
13471437
13481438 /**
1349- * Get all session entries (excludes header). Returns a shallow copy.
1439+ * Get all session entries (excludes header).
13501440 * The session is append-only: use appendXXX() to add entries, branch() to
13511441 * change the leaf pointer. Entries cannot be modified or deleted.
1442+ *
1443+ * The result is memoized behind the session mutation counter: repeated calls
1444+ * without an intervening mutation return the SAME shared array instance.
1445+ * Callers must not mutate the returned array or its entries; copy first if
1446+ * you need to filter/reorder.
13521447 */
13531448 getEntries ( ) : SessionEntry [ ] {
1354- return this . fileEntries
1449+ if ( this . entriesCache !== null && this . entriesCache . mutation === this . mutationCount ) {
1450+ return this . entriesCache . entries ;
1451+ }
1452+ const entries = this . fileEntries
13551453 . filter ( ( e ) : e is SessionEntry => e . type !== "session" )
13561454 . map ( ( entry ) => this . residentStore . materialize ( entry ) ) ;
1455+ this . entriesCache = { mutation : this . mutationCount , entries } ;
1456+ return entries ;
13571457 }
13581458
13591459 /**
@@ -1416,6 +1516,7 @@ export class SessionManager {
14161516 throw new Error ( `Entry ${ branchFromId } not found` ) ;
14171517 }
14181518 this . leafId = branchFromId ;
1519+ this . mutationCount ++ ;
14191520 }
14201521
14211522 /**
@@ -1425,6 +1526,7 @@ export class SessionManager {
14251526 */
14261527 resetLeaf ( ) : void {
14271528 this . leafId = null ;
1529+ this . mutationCount ++ ;
14281530 }
14291531
14301532 /**
@@ -1523,6 +1625,7 @@ export class SessionManager {
15231625 this . sessionId = newSessionId ;
15241626 this . sessionFile = newSessionFile ;
15251627 this . _buildIndex ( ) ;
1628+ this . mutationCount ++ ;
15261629
15271630 // Only write the file now if it contains an assistant message.
15281631 // Otherwise defer to _persist(), which creates the file on the
@@ -1561,6 +1664,7 @@ export class SessionManager {
15611664 ) ;
15621665 this . sessionId = newSessionId ;
15631666 this . _buildIndex ( ) ;
1667+ this . mutationCount ++ ;
15641668 return undefined ;
15651669 }
15661670
0 commit comments