import Foundation import SQLite3 public actor TokenUsageScanner { public let homeDirectory: URL public let cacheURL: URL private let chunkSize = 256 * 1024 private var lastSignature: FileSignature? private var lastSummary: UsageSummary? public init( homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser, cacheURL: URL? = nil ) { self.homeDirectory = homeDirectory self.cacheURL = cacheURL ?? homeDirectory .appending(path: "Library/Application Support/TokenLens") .appending(path: "usage-cache.sqlite") } public func scan(now: Date = Date()) async -> UsageSummary { defer { MemoryPressure.relieve() } if let summary = scanWithCache(now: now) { return summary } var accumulator = UsageSummaryAccumulator(now: now) scanClaude(into: &accumulator) scanCodex(into: &accumulator) scanGemini(into: &accumulator) scanHermes(into: &accumulator) scanOpenCode(into: &accumulator) return accumulator.makeSummary() } private func scanWithCache(now: Date) -> UsageSummary? { do { let snapshots = try logFileSnapshots() let signature = FileSignature(snapshots: snapshots) if signature == lastSignature, let lastSummary, isSameDisplayWindow(lastSummary.generatedAt, now) { let updated = lastSummary.updatingGeneratedAt(now) self.lastSummary = updated return updated } let cache = try SQLiteUsageCache(url: cacheURL) let currentPaths = try refreshCache(cache, snapshots: snapshots) try cache.pruneMissingFiles(currentPaths: currentPaths) let summary = try cache.makeSummary(now: now) lastSignature = signature lastSummary = summary return summary } catch { return nil } } private func refreshCache(_ cache: SQLiteUsageCache, snapshots: [LogFileSnapshot]) throws -> Set { var currentPaths = Set() for snapshot in snapshots { currentPaths.insert(snapshot.path) let state = try cache.fileState(path: snapshot.path) let isCurrent = try cache.isFileCurrent( path: snapshot.path, size: snapshot.size, modifiedAt: snapshot.modifiedAt ) if isCurrent && (snapshot.scanner.supportsZeroRecordCache || (state?.recordCount ?? 0) > 0) { continue } if let state, snapshot.size > state.size, state.size == state.scannedOffset, state.scannedOffset > 0, snapshot.scanner.supportsIncrementalScan { try cache.appendFileRecords( path: snapshot.path, size: snapshot.size, modifiedAt: snapshot.modifiedAt, startingRecordCount: state.recordCount ) { insert in try scanFile( snapshot.url, startingAt: UInt64(state.scannedOffset), scanner: snapshot.scanner, consume: insert ).scannedOffset } } else { try cache.replaceFileRecords( path: snapshot.path, size: snapshot.size, modifiedAt: snapshot.modifiedAt ) { insert in try scanFile(snapshot.url, scanner: snapshot.scanner, consume: insert).scannedOffset } } } return currentPaths } private func scanClaude(into accumulator: inout UsageSummaryAccumulator) { scanJSONLFiles( root: homeDirectory.appending(path: ".claude/projects"), include: { $0.pathExtension == "jsonl" }, parse: TokenUsageParser.parseClaudeLine, into: &accumulator ) } private func scanCodex(into accumulator: inout UsageSummaryAccumulator) { scanJSONLFiles( root: homeDirectory.appending(path: ".codex/sessions"), include: { $0.pathExtension == "jsonl" }, parse: TokenUsageParser.parseCodexLine, into: &accumulator ) } private func scanGemini(into accumulator: inout UsageSummaryAccumulator) { scanJSONLFiles( root: homeDirectory.appending(path: ".gemini"), include: { url in url.pathExtension == "jsonl" && url.pathComponents.contains("chats") }, parse: TokenUsageParser.parseGeminiLine, into: &accumulator ) } private func scanHermes(into accumulator: inout UsageSummaryAccumulator) { for file in files(root: homeDirectory.appending(path: ".hermes"), include: { $0.lastPathComponent == "state.db" }) { _ = try? scanHermesStateDB(file) { record in accumulator.add(record) } } } private func scanOpenCode(into accumulator: inout UsageSummaryAccumulator) { for file in files(root: homeDirectory.appending(path: ".local/share/opencode"), include: { $0.lastPathComponent == "opencode.db" }) { _ = try? scanOpenCodeDB(file) { record in accumulator.add(record) } } } private func scanJSONLFiles( root: URL, include: (URL) -> Bool, parse: (String, String) -> UsageRecord?, into accumulator: inout UsageSummaryAccumulator ) { for file in files(root: root, include: include) { _ = try? scanJSONLFile(file, parse: parse) { record in accumulator.add(record) } } } private func scanFile( _ file: URL, startingAt offset: UInt64 = 0, scanner: SourceScanner, consume: (UsageRecord) throws -> Void ) throws -> ScanProgress { switch scanner { case let .jsonl(parse): return try scanJSONLFile(file, startingAt: offset, parse: parse, consume: consume) case .hermesStateDB: return try scanHermesStateDB(file, consume: consume) case .openCodeDB: return try scanOpenCodeDB(file, consume: consume) } } private func scanJSONLFile( _ file: URL, startingAt offset: UInt64 = 0, parse: (String, String) -> UsageRecord?, consume: (UsageRecord) throws -> Void ) throws -> ScanProgress { guard let handle = try? FileHandle(forReadingFrom: file) else { return ScanProgress(scannedOffset: Int64(offset), recordCount: 0) } defer { try? handle.close() } try handle.seek(toOffset: offset) var pendingLine = Data() var scannedOffset = Int64(offset) var recordCount = 0 while true { let chunk = (try? handle.read(upToCount: chunkSize)) ?? Data() if chunk.isEmpty { break } var lineStart = chunk.startIndex for index in chunk.indices where chunk[index] == 0x0A { let lineData = chunk[lineStart..( from lineData: LineData, sourcePath: String, parse: (String, String) -> UsageRecord?, consume: (UsageRecord) throws -> Void ) throws -> Bool where LineData.Element == UInt8 { guard !lineData.isEmpty, let line = String(bytes: lineData, encoding: .utf8), let record = parse(line, sourcePath) else { return false } try consume(record) return true } private func isCompleteJSONLine(_ lineData: Data) -> Bool { guard !lineData.isEmpty else { return true } return (try? JSONSerialization.jsonObject(with: lineData)) != nil } private func scanHermesStateDB( _ file: URL, consume: (UsageRecord) throws -> Void ) throws -> ScanProgress { let sql = """ SELECT id, started_at, COALESCE(model, ''), input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, reasoning_tokens FROM sessions WHERE input_tokens + output_tokens + cache_read_tokens + cache_write_tokens + reasoning_tokens > 0 """ return try scanSQLiteRows(file, sql: sql) { statement in UsageRecord( provider: .hermes, source: "state.db/sessions", sourcePath: file.path, timestamp: Date(timeIntervalSince1970: sqlite3_column_double(statement, 1)), sessionID: textColumn(statement, 0), model: textColumn(statement, 2).isEmpty ? "Hermes" : textColumn(statement, 2), inputTokens: intColumn(statement, 3), outputTokens: intColumn(statement, 4), cachedTokens: intColumn(statement, 5) + intColumn(statement, 6), reasoningTokens: intColumn(statement, 7), toolTokens: 0 ) } consume: { try consume($0) } } private func scanOpenCodeDB( _ file: URL, consume: (UsageRecord) throws -> Void ) throws -> ScanProgress { let sql = """ SELECT id, COALESCE(NULLIF(time_updated, 0), time_created), COALESCE(model, ''), tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write FROM session WHERE tokens_input + tokens_output + tokens_reasoning + tokens_cache_read + tokens_cache_write > 0 """ return try scanSQLiteRows(file, sql: sql) { statement in UsageRecord( provider: .opencode, source: "opencode.db/session", sourcePath: file.path, timestamp: Date(timeIntervalSince1970: sqlite3_column_double(statement, 1) / 1000), sessionID: textColumn(statement, 0), model: openCodeModelName(textColumn(statement, 2)), inputTokens: intColumn(statement, 3), outputTokens: intColumn(statement, 4), cachedTokens: intColumn(statement, 6) + intColumn(statement, 7), reasoningTokens: intColumn(statement, 5), toolTokens: 0 ) } consume: { try consume($0) } } private func scanSQLiteRows( _ file: URL, sql: String, makeRecord: (OpaquePointer?) -> UsageRecord, consume: (UsageRecord) throws -> Void ) throws -> ScanProgress { var database: OpaquePointer? let flags = SQLITE_OPEN_READWRITE | SQLITE_OPEN_FULLMUTEX guard sqlite3_open_v2(file.path, &database, flags, nil) == SQLITE_OK else { sqlite3_close(database) return ScanProgress(scannedOffset: fileSize(file), recordCount: 0) } defer { sqlite3_close(database) } sqlite3_busy_timeout(database, 1000) var statement: OpaquePointer? guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK else { sqlite3_finalize(statement) return ScanProgress(scannedOffset: fileSize(file), recordCount: 0) } defer { sqlite3_finalize(statement) } var recordCount = 0 while sqlite3_step(statement) == SQLITE_ROW { try consume(makeRecord(statement)) recordCount += 1 } return ScanProgress(scannedOffset: fileSize(file), recordCount: recordCount) } private func fileSize(_ file: URL) -> Int64 { ((try? file.resourceValues(forKeys: [.fileSizeKey]).fileSize).flatMap { Int64($0) }) ?? 0 } private func textColumn(_ statement: OpaquePointer?, _ index: Int32) -> String { guard let text = sqlite3_column_text(statement, index) else { return "" } return String(cString: UnsafeRawPointer(text).assumingMemoryBound(to: CChar.self)) } private func intColumn(_ statement: OpaquePointer?, _ index: Int32) -> Int { Int(sqlite3_column_int64(statement, index)) } private func openCodeModelName(_ rawModel: String) -> String { guard !rawModel.isEmpty, let data = rawModel.data(using: .utf8), let object = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] else { return rawModel.isEmpty ? "OpenCode" : rawModel } let provider = object["providerID"] as? String let model = object["id"] as? String var parts: [String] = [] if let provider, !provider.isEmpty { parts.append(provider) } if let model, !model.isEmpty { parts.append(model) } return parts.isEmpty ? "OpenCode" : parts.joined(separator: "/") } private func files(root: URL, include: (URL) -> Bool) -> [URL] { guard let enumerator = FileManager.default.enumerator( at: root, includingPropertiesForKeys: [.isRegularFileKey], options: [.skipsHiddenFiles] ) else { return [] } var result: [URL] = [] for case let file as URL in enumerator where include(file) { result.append(file) } return result } private var logSources: [LogSource] { [ LogSource( root: homeDirectory.appending(path: ".claude/projects"), include: { $0.pathExtension == "jsonl" }, scanner: .jsonl(TokenUsageParser.parseClaudeLine) ), LogSource( root: homeDirectory.appending(path: ".codex/sessions"), include: { $0.pathExtension == "jsonl" }, scanner: .jsonl(TokenUsageParser.parseCodexLine) ), LogSource( root: homeDirectory.appending(path: ".gemini"), include: { url in url.pathExtension == "jsonl" && url.pathComponents.contains("chats") }, scanner: .jsonl(TokenUsageParser.parseGeminiLine) ), LogSource( root: homeDirectory.appending(path: ".hermes"), include: { $0.lastPathComponent == "state.db" && $0.deletingLastPathComponent().lastPathComponent == ".hermes" }, scanner: .hermesStateDB ), LogSource( root: homeDirectory.appending(path: ".local/share/opencode"), include: { $0.lastPathComponent == "opencode.db" && $0.deletingLastPathComponent().lastPathComponent == "opencode" }, scanner: .openCodeDB ) ] } private func logFileSnapshots() throws -> [LogFileSnapshot] { var snapshots: [LogFileSnapshot] = [] for source in logSources { for file in files(root: source.root, include: source.include) { let metadata = try fileMetadata(file) snapshots.append(LogFileSnapshot( url: file, path: file.path, size: metadata.size, modifiedAt: metadata.modifiedAt, scanner: source.scanner )) } } return snapshots.sorted { $0.path < $1.path } } private func fileMetadata(_ file: URL) throws -> FileMetadata { let values = try file.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey]) return FileMetadata( size: Int64(values.fileSize ?? 0), modifiedAt: values.contentModificationDate?.timeIntervalSince1970 ?? 0 ) } private func isSameDisplayWindow(_ lhs: Date, _ rhs: Date) -> Bool { var calendar = Calendar.current calendar.firstWeekday = 2 return calendar.isDate(lhs, equalTo: rhs, toGranularity: .hour) } } private struct LogSource { let root: URL let include: (URL) -> Bool let scanner: SourceScanner } private enum SourceScanner { case jsonl((String, String) -> UsageRecord?) case hermesStateDB case openCodeDB var supportsIncrementalScan: Bool { if case .jsonl = self { return true } return false } var supportsZeroRecordCache: Bool { if case .jsonl = self { return true } return false } } private struct ScanProgress { let scannedOffset: Int64 let recordCount: Int } private struct FileMetadata { let size: Int64 let modifiedAt: TimeInterval } private struct LogFileSnapshot { let url: URL let path: String let size: Int64 let modifiedAt: TimeInterval let scanner: SourceScanner } private struct FileSignature: Equatable { let entries: [Entry] init(snapshots: [LogFileSnapshot]) { entries = snapshots.map { Entry(path: $0.path, size: $0.size, modifiedAt: $0.modifiedAt) } } struct Entry: Equatable { let path: String let size: Int64 let modifiedAt: TimeInterval } }