@@ -44,9 +44,39 @@ public enum ClaudeUsageScanner {
4444 }
4545 }
4646
47+ /// Per-file incremental parse state. Transcripts are append-only, so each
48+ /// rescan reads only the bytes past `consumedBytes` — a day-long multi-MB
49+ /// transcript is never re-read in full. Value semantics: the caller owns a
50+ /// copy, hands it to the background scan, and stores the returned state.
51+ public struct FileCache : Sendable {
52+ struct CachedMessage : Sendable , Equatable {
53+ let timestamp : Date
54+ let usage : ClaudeUsageTotals
55+ }
56+ struct FileEntry : Sendable {
57+ var consumedBytes : UInt64 = 0
58+ var entries : [ CachedMessage ] = [ ]
59+ // Dedupe is per file: an assistant message's continuation lines
60+ // repeat its id within the same transcript; ids never straddle files.
61+ var seenIds : Set < String > = [ ]
62+ }
63+ var files : [ String : FileEntry ] = [ : ]
64+ public init ( ) { }
65+ }
66+
67+ /// One-shot convenience (tests, callers without persistent state).
4768 public static func scan(
4869 claudeHome: String = NSHomeDirectory ( ) + " /.claude " ,
4970 now: Date = Date ( )
71+ ) -> Snapshot {
72+ var cache = FileCache ( )
73+ return scan ( claudeHome: claudeHome, now: now, cache: & cache)
74+ }
75+
76+ public static func scan(
77+ claudeHome: String = NSHomeDirectory ( ) + " /.claude " ,
78+ now: Date = Date ( ) ,
79+ cache: inout FileCache
5080 ) -> Snapshot {
5181 let fiveHoursAgo = now. addingTimeInterval ( - 5 * 3600 )
5282 let midnight = Calendar . current. startOfDay ( for: now)
@@ -56,7 +86,7 @@ public enum ClaudeUsageScanner {
5686 var last5h = ClaudeUsageTotals ( )
5787 var today = ClaudeUsageTotals ( )
5888 var hourly = [ Int] ( repeating: 0 , count: sparklineHours)
59- var seenMessageIds = Set < String > ( )
89+ var activeFiles = Set < String > ( )
6090
6191 let fm = FileManager . default
6292 let projectsDir = claudeHome + " /projects "
@@ -70,25 +100,55 @@ public enum ClaudeUsageScanner {
70100 guard let attrs = try ? fm. attributesOfItem ( atPath: path) ,
71101 let mtime = attrs [ . modificationDate] as? Date ,
72102 mtime >= cutoff else { continue }
73- guard let contents = try ? String ( contentsOfFile: path, encoding: . utf8) else { continue }
74-
75- for line in contents. split ( separator: " \n " ) {
76- guard let parsed = parseAssistantUsage ( String ( line) ) ,
77- parsed. timestamp >= cutoff, parsed. timestamp <= now,
78- !seenMessageIds. contains ( parsed. messageId) else { continue }
79- seenMessageIds. insert ( parsed. messageId)
80- if parsed. timestamp >= fiveHoursAgo { last5h. add ( parsed. usage) }
81- if parsed. timestamp >= midnight { today. add ( parsed. usage) }
82- let hoursAgo = Int ( now. timeIntervalSince ( parsed. timestamp) / 3600 )
103+ activeFiles. insert ( path)
104+ let size = ( attrs [ . size] as? NSNumber ) ? . uint64Value ?? 0
105+
106+ var entry = cache. files [ path] ?? FileCache . FileEntry ( )
107+ if size < entry. consumedBytes {
108+ // Truncated or replaced — start over.
109+ entry = FileCache . FileEntry ( )
110+ }
111+ if size > entry. consumedBytes {
112+ consumeNewLines ( path: path, into: & entry)
113+ }
114+ entry. entries. removeAll { $0. timestamp < cutoff }
115+ cache. files [ path] = entry
116+
117+ for message in entry. entries where message. timestamp <= now {
118+ if message. timestamp >= fiveHoursAgo { last5h. add ( message. usage) }
119+ if message. timestamp >= midnight { today. add ( message. usage) }
120+ let hoursAgo = Int ( now. timeIntervalSince ( message. timestamp) / 3600 )
83121 if hoursAgo >= 0 && hoursAgo < sparklineHours {
84- hourly [ sparklineHours - 1 - hoursAgo] += parsed . usage. outputTokens
122+ hourly [ sparklineHours - 1 - hoursAgo] += message . usage. outputTokens
85123 }
86124 }
87125 }
88126 }
127+ // Files that fell out of the mtime window carry no in-window entries.
128+ cache. files = cache. files. filter { activeFiles. contains ( $0. key) }
89129 return Snapshot ( last5h: last5h, today: today, hourlyOutputTokens: hourly, scannedAt: now)
90130 }
91131
132+ /// Read bytes past `entry.consumedBytes` and parse the COMPLETE lines only —
133+ /// a partial trailing line (writer mid-append) is left for the next scan.
134+ private static func consumeNewLines( path: String , into entry: inout FileCache . FileEntry ) {
135+ guard let handle = FileHandle ( forReadingAtPath: path) else { return }
136+ defer { handle. closeFile ( ) }
137+ handle. seek ( toFileOffset: entry. consumedBytes)
138+ let data = handle. readDataToEndOfFile ( )
139+ guard let lastNewline = data. lastIndex ( of: UInt8 ( ascii: " \n " ) ) else { return }
140+ let consumable = data [ data. startIndex... lastNewline]
141+ entry. consumedBytes += UInt64 ( consumable. count)
142+ guard let text = String ( data: consumable, encoding: . utf8) else { return }
143+
144+ for line in text. split ( separator: " \n " ) {
145+ guard let parsed = parseAssistantUsage ( String ( line) ) ,
146+ !entry. seenIds. contains ( parsed. messageId) else { continue }
147+ entry. seenIds. insert ( parsed. messageId)
148+ entry. entries. append ( . init( timestamp: parsed. timestamp, usage: parsed. usage) )
149+ }
150+ }
151+
92152 /// Parse one transcript line into (timestamp, message id, usage) — nil for
93153 /// non-assistant lines and lines without usage.
94154 static func parseAssistantUsage( _ line: String ) -> ( timestamp: Date , messageId: String , usage: ClaudeUsageTotals ) ? {
@@ -124,15 +184,17 @@ public enum ClaudeUsageScanner {
124184 fractionalFormatter. date ( from: raw) ?? plainFormatter. date ( from: raw)
125185 }
126186
127- /// Compact human token count: 950, 32.5K, 1.4M.
187+ /// Compact human token count: 950, 32.5K, 1.4M. Unit selection uses the
188+ /// rounded value so 999,950 rolls over to "1M" instead of "1000K".
128189 public static func formatTokens( _ count: Int ) -> String {
129- switch count {
130- case ..< 1000 :
131- return " \( count) "
132- case ..< 1_000_000 :
133- return String ( format: " %.1fK " , Double ( count) / 1000 ) . replacingOccurrences ( of: " .0K " , with: " K " )
134- default :
135- return String ( format: " %.1fM " , Double ( count) / 1_000_000 ) . replacingOccurrences ( of: " .0M " , with: " M " )
190+ if count < 1000 { return " \( count) " }
191+ func fmt( _ value: Double , _ unit: String ) -> String {
192+ String ( format: " %.1f \( unit) " , value) . replacingOccurrences ( of: " .0 \( unit) " , with: unit)
136193 }
194+ let thousands = Double ( count) / 1000
195+ if thousands < 999.95 { return fmt ( thousands, " K " ) }
196+ let millions = Double ( count) / 1_000_000
197+ if millions < 999.95 { return fmt ( millions, " M " ) }
198+ return fmt ( Double ( count) / 1_000_000_000 , " B " )
137199 }
138200}
0 commit comments