chore: add TokenLens sources and ignore rules

This commit is contained in:
2026-06-12 15:45:58 +08:00
parent 0b48e618d8
commit 887b75b790
37 changed files with 13907 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
import Darwin
public enum MemoryPressure {
public static func relieve() {
_ = malloc_zone_pressure_relief(nil, 0)
}
}

View File

@@ -0,0 +1,206 @@
import Foundation
public enum Provider: String, CaseIterable, Codable, Hashable, Sendable {
case claude = "Claude"
case codex = "Codex"
case gemini = "Gemini"
case hermes = "Hermes"
case opencode = "OpenCode"
}
public struct UsageRecord: Identifiable, Hashable, Sendable {
public let id: UUID
public let provider: Provider
public let source: String
public let sourcePath: String
public let timestamp: Date
public let sessionID: String
public let model: String
public let inputTokens: Int
public let outputTokens: Int
public let cachedTokens: Int
public let reasoningTokens: Int
public let toolTokens: Int
public let totalTokens: Int
public init(
id: UUID = UUID(),
provider: Provider,
source: String,
sourcePath: String,
timestamp: Date,
sessionID: String,
model: String,
inputTokens: Int,
outputTokens: Int,
cachedTokens: Int,
reasoningTokens: Int,
toolTokens: Int,
explicitTotal: Int? = nil
) {
self.id = id
self.provider = provider
self.source = source
self.sourcePath = sourcePath
self.timestamp = timestamp
self.sessionID = sessionID
self.model = model
self.inputTokens = inputTokens
self.outputTokens = outputTokens
self.cachedTokens = cachedTokens
self.reasoningTokens = reasoningTokens
self.toolTokens = toolTokens
self.totalTokens = explicitTotal ?? inputTokens + outputTokens + cachedTokens + reasoningTokens + toolTokens
}
}
public struct TokenBucket: Hashable, Sendable {
public let provider: Provider?
public var inputTokens: Int = 0
public var outputTokens: Int = 0
public var cachedTokens: Int = 0
public var reasoningTokens: Int = 0
public var toolTokens: Int = 0
public var totalTokens: Int = 0
public var count: Int = 0
public init(provider: Provider? = nil) {
self.provider = provider
}
public mutating func add(_ record: UsageRecord) {
add(
inputTokens: record.inputTokens,
outputTokens: record.outputTokens,
cachedTokens: record.cachedTokens,
reasoningTokens: record.reasoningTokens,
toolTokens: record.toolTokens,
totalTokens: record.totalTokens
)
}
public mutating func add(
inputTokens: Int,
outputTokens: Int,
cachedTokens: Int,
reasoningTokens: Int,
toolTokens: Int,
totalTokens: Int
) {
self.inputTokens += inputTokens
self.outputTokens += outputTokens
self.cachedTokens += cachedTokens
self.reasoningTokens += reasoningTokens
self.toolTokens += toolTokens
self.totalTokens += totalTokens
count += 1
}
}
public struct SummaryCards: Hashable, Sendable {
public let todayTokens: Int
public let monthTokens: Int
public let totalTokens: Int
public let cacheHitRate: Int
public let sessionCount: Int
}
public struct DailyPoint: Identifiable, Hashable, Sendable {
public let id = UUID()
public let date: Date
public let values: [Provider: Int]
}
public enum TrendMode: String, CaseIterable, Sendable {
case daily = "每日"
case weekly = "每周"
case monthly = "每月"
case yearly = "每年"
}
public struct ToolRow: Identifiable, Hashable, Sendable {
public let id = UUID()
public let provider: Provider
public let inputTokens: Int
public let outputTokens: Int
public let cachedTokens: Int
public let totalTokens: Int
public let trendTokens: [Int]
}
public struct DailyUsageDetail: Identifiable, Hashable, Sendable {
public var id: Date { date }
public let date: Date
public var inputTokens: Int
public var outputTokens: Int
public var cachedTokens: Int
public var totalTokens: Int
public var providerTotals: [Provider: Int]
public init(
date: Date,
inputTokens: Int = 0,
outputTokens: Int = 0,
cachedTokens: Int = 0,
totalTokens: Int = 0,
providerTotals: [Provider: Int] = [:]
) {
self.date = date
self.inputTokens = inputTokens
self.outputTokens = outputTokens
self.cachedTokens = cachedTokens
self.totalTokens = totalTokens
self.providerTotals = providerTotals
}
public mutating func add(_ record: UsageRecord) {
add(
provider: record.provider,
inputTokens: record.inputTokens,
outputTokens: record.outputTokens,
cachedTokens: record.cachedTokens,
totalTokens: record.totalTokens
)
}
public mutating func add(
provider: Provider,
inputTokens: Int,
outputTokens: Int,
cachedTokens: Int,
totalTokens: Int
) {
self.inputTokens += inputTokens
self.outputTokens += outputTokens
self.cachedTokens += cachedTokens
self.totalTokens += totalTokens
providerTotals[provider, default: 0] += totalTokens
}
}
public struct Insight: Identifiable, Hashable, Sendable {
public enum Tone: String, Sendable {
case warning
case positive
case accent
}
public let id = UUID()
public let tone: Tone
public let title: String
public let message: String
}
public struct UsageSummary: Sendable {
public let generatedAt: Date
public let records: [UsageRecord]
public let cards: SummaryCards
public let providerTotals: [Provider: TokenBucket]
public let todayProviderTotals: [Provider: TokenBucket]
public let dailyTrend: [DailyPoint]
public let trendPointsByMode: [TrendMode: [DailyPoint]]
public let dailyDetails: [DailyUsageDetail]
public let toolRows: [ToolRow]
public let insights: [Insight]
}

View File

@@ -0,0 +1,581 @@
import Foundation
import SQLite3
enum SQLiteUsageCacheError: Error {
case openFailed(String)
case prepareFailed(String)
case executeFailed(String)
case bindFailed(String)
case stepFailed(String)
}
final class SQLiteUsageCache {
private static let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self)
private let database: OpaquePointer?
private var calendar: Calendar = {
var calendar = Calendar.current
calendar.firstWeekday = 2
return calendar
}()
init(url: URL) throws {
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(),
withIntermediateDirectories: true
)
var database: OpaquePointer?
let flags = SQLITE_OPEN_CREATE | SQLITE_OPEN_READWRITE | SQLITE_OPEN_FULLMUTEX
guard sqlite3_open_v2(url.path, &database, flags, nil) == SQLITE_OK else {
let message = database.flatMap { String(cString: sqlite3_errmsg($0)) } ?? "无法打开 SQLite"
sqlite3_close(database)
throw SQLiteUsageCacheError.openFailed(message)
}
self.database = database
try configure()
try migrate()
}
deinit {
sqlite3_close(database)
}
func isFileCurrent(path: String, size: Int64, modifiedAt: TimeInterval) throws -> Bool {
let statement = try prepare("SELECT size, modified_at, scanned_offset FROM files WHERE path = ? LIMIT 1")
defer { sqlite3_finalize(statement) }
try bind(path, to: statement, at: 1)
guard sqlite3_step(statement) == SQLITE_ROW else {
return false
}
let cachedSize = sqlite3_column_int64(statement, 0)
let cachedModifiedAt = sqlite3_column_double(statement, 1)
let scannedOffset = sqlite3_column_int64(statement, 2)
return cachedSize == size && scannedOffset == size && abs(cachedModifiedAt - modifiedAt) < 0.001
}
func fileState(path: String) throws -> CachedFileState? {
let statement = try prepare("""
SELECT size, modified_at, scanned_offset, record_count
FROM files
WHERE path = ?
LIMIT 1
""")
defer { sqlite3_finalize(statement) }
try bind(path, to: statement, at: 1)
guard sqlite3_step(statement) == SQLITE_ROW else {
return nil
}
return CachedFileState(
size: sqlite3_column_int64(statement, 0),
modifiedAt: sqlite3_column_double(statement, 1),
scannedOffset: sqlite3_column_int64(statement, 2),
recordCount: Int(sqlite3_column_int64(statement, 3))
)
}
func replaceFileRecords(
path: String,
size: Int64,
modifiedAt: TimeInterval,
scan: (_ insert: (UsageRecord) throws -> Void) throws -> Int64
) throws {
try execute("BEGIN IMMEDIATE TRANSACTION")
do {
try deleteRecords(path: path)
let insertStatement = try prepare("""
INSERT INTO records (
id, provider, source_path, timestamp, session_id, model,
input_tokens, output_tokens, cached_tokens, reasoning_tokens,
tool_tokens, total_tokens, day_start, hour_start, month_start
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""")
defer { sqlite3_finalize(insertStatement) }
var recordCount = 0
let scannedOffset = try scan { record in
recordCount += 1
try self.insert(record, id: "\(path)#\(recordCount)", statement: insertStatement)
}
try upsertFile(
path: path,
size: size,
modifiedAt: modifiedAt,
scannedOffset: scannedOffset,
recordCount: recordCount
)
try execute("COMMIT")
} catch {
try? execute("ROLLBACK")
throw error
}
}
func appendFileRecords(
path: String,
size: Int64,
modifiedAt: TimeInterval,
startingRecordCount: Int,
scan: (_ insert: (UsageRecord) throws -> Void) throws -> Int64
) throws {
try execute("BEGIN IMMEDIATE TRANSACTION")
do {
let insertStatement = try prepare("""
INSERT INTO records (
id, provider, source_path, timestamp, session_id, model,
input_tokens, output_tokens, cached_tokens, reasoning_tokens,
tool_tokens, total_tokens, day_start, hour_start, month_start
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""")
defer { sqlite3_finalize(insertStatement) }
var appendedCount = 0
let scannedOffset = try scan { record in
appendedCount += 1
let nextRecordIndex = startingRecordCount + appendedCount
try self.insert(record, id: "\(path)#\(nextRecordIndex)", statement: insertStatement)
}
try upsertFile(
path: path,
size: size,
modifiedAt: modifiedAt,
scannedOffset: scannedOffset,
recordCount: startingRecordCount + appendedCount
)
try execute("COMMIT")
} catch {
try? execute("ROLLBACK")
throw error
}
}
func pruneMissingFiles(currentPaths: Set<String>) throws {
let statement = try prepare("SELECT path FROM files")
defer { sqlite3_finalize(statement) }
var cachedPaths: [String] = []
while sqlite3_step(statement) == SQLITE_ROW {
cachedPaths.append(columnText(statement, at: 0))
}
for path in cachedPaths where !currentPaths.contains(path) {
try deleteRecords(path: path)
let deleteFile = try prepare("DELETE FROM files WHERE path = ?")
defer { sqlite3_finalize(deleteFile) }
try bind(path, to: deleteFile, at: 1)
try stepDone(deleteFile)
}
}
func makeSummary(now: Date = Date(), calendar: Calendar = Calendar.current) throws -> UsageSummary {
self.calendar = calendarWithMondayStart(calendar)
let providerTotals = try providerBuckets()
let todayProviderTotals = try providerBuckets(
whereClause: "timestamp >= ? AND timestamp < ?",
bindings: dayBounds(for: now).bindings
)
let dailyDetails = try dailyUsageDetails()
let dailyTrend = dailyDetails
.sorted { $0.date < $1.date }
.suffix(30)
.map { detail in
DailyPoint(date: detail.date, values: detail.providerTotals)
}
let trendPointsByMode = try Dictionary(uniqueKeysWithValues: TrendMode.allCases.map { mode in
(mode, try trendPoints(mode: mode, now: now))
})
let toolRows = Provider.allCases.compactMap { provider -> ToolRow? in
guard let bucket = providerTotals[provider], bucket.totalTokens > 0 else { return nil }
return ToolRow(
provider: provider,
inputTokens: bucket.inputTokens,
outputTokens: bucket.outputTokens,
cachedTokens: bucket.cachedTokens,
totalTokens: bucket.totalTokens,
trendTokens: dailyTrend.map { $0.values[provider, default: 0] }
)
}
.sorted { $0.totalTokens > $1.totalTokens }
let todayTokens = todayProviderTotals.values.reduce(0) { $0 + $1.totalTokens }
let monthTokens = try tokenSum(whereClause: "timestamp >= ? AND timestamp < ?", bindings: monthBounds(for: now).bindings)
let totalTokens = providerTotals.values.reduce(0) { $0 + $1.totalTokens }
let cachedTokens = providerTotals.values.reduce(0) { $0 + $1.cachedTokens }
let cacheHitRate = totalTokens > 0 ? Int((Double(cachedTokens) / Double(totalTokens) * 100).rounded()) : 0
let cards = SummaryCards(
todayTokens: todayTokens,
monthTokens: monthTokens,
totalTokens: totalTokens,
cacheHitRate: cacheHitRate,
sessionCount: try sessionCount()
)
return UsageSummary(
generatedAt: now,
records: [],
cards: cards,
providerTotals: providerTotals,
todayProviderTotals: todayProviderTotals,
dailyTrend: Array(dailyTrend),
trendPointsByMode: trendPointsByMode,
dailyDetails: dailyDetails.sorted { $0.date > $1.date },
toolRows: toolRows,
insights: UsageSummary.makeInsights(
providerTotals: providerTotals,
todayTokens: todayTokens,
cacheHitRate: cacheHitRate
)
)
}
private func configure() throws {
try execute("PRAGMA journal_mode = WAL")
try execute("PRAGMA synchronous = NORMAL")
try execute("PRAGMA temp_store = MEMORY")
try execute("PRAGMA busy_timeout = 3000")
}
private func migrate() throws {
if try recordsTableNeedsRebuild() {
try execute("DROP TABLE IF EXISTS records")
try execute("DROP TABLE IF EXISTS files")
}
try execute("""
CREATE TABLE IF NOT EXISTS files (
path TEXT PRIMARY KEY,
size INTEGER NOT NULL,
modified_at REAL NOT NULL,
scanned_offset INTEGER NOT NULL DEFAULT 0,
record_count INTEGER NOT NULL DEFAULT 0,
scanned_at REAL NOT NULL
)
""")
try addColumnIfMissing(
table: "files",
column: "scanned_offset",
definition: "scanned_offset INTEGER NOT NULL DEFAULT 0"
)
try addColumnIfMissing(
table: "files",
column: "record_count",
definition: "record_count INTEGER NOT NULL DEFAULT 0"
)
try execute("""
CREATE TABLE IF NOT EXISTS records (
id TEXT PRIMARY KEY,
provider TEXT NOT NULL,
source_path TEXT NOT NULL,
timestamp REAL NOT NULL,
session_id TEXT NOT NULL,
model TEXT NOT NULL,
input_tokens INTEGER NOT NULL,
output_tokens INTEGER NOT NULL,
cached_tokens INTEGER NOT NULL,
reasoning_tokens INTEGER NOT NULL,
tool_tokens INTEGER NOT NULL,
total_tokens INTEGER NOT NULL,
day_start REAL NOT NULL,
hour_start REAL NOT NULL,
month_start REAL NOT NULL
)
""")
try execute("CREATE INDEX IF NOT EXISTS records_provider_idx ON records(provider)")
try execute("CREATE INDEX IF NOT EXISTS records_source_path_idx ON records(source_path)")
try execute("CREATE INDEX IF NOT EXISTS records_timestamp_idx ON records(timestamp)")
try execute("CREATE INDEX IF NOT EXISTS records_day_provider_idx ON records(day_start, provider)")
try execute("CREATE INDEX IF NOT EXISTS records_hour_provider_idx ON records(hour_start, provider)")
try execute("CREATE INDEX IF NOT EXISTS records_month_provider_idx ON records(month_start, provider)")
}
private func deleteRecords(path: String) throws {
let statement = try prepare("DELETE FROM records WHERE source_path = ?")
defer { sqlite3_finalize(statement) }
try bind(path, to: statement, at: 1)
try stepDone(statement)
}
private func upsertFile(
path: String,
size: Int64,
modifiedAt: TimeInterval,
scannedOffset: Int64,
recordCount: Int
) throws {
let statement = try prepare("""
INSERT INTO files (path, size, modified_at, scanned_offset, record_count, scanned_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(path) DO UPDATE SET
size = excluded.size,
modified_at = excluded.modified_at,
scanned_offset = excluded.scanned_offset,
record_count = excluded.record_count,
scanned_at = excluded.scanned_at
""")
defer { sqlite3_finalize(statement) }
try bind(path, to: statement, at: 1)
sqlite3_bind_int64(statement, 2, size)
sqlite3_bind_double(statement, 3, modifiedAt)
sqlite3_bind_int64(statement, 4, scannedOffset)
sqlite3_bind_int64(statement, 5, Int64(recordCount))
sqlite3_bind_double(statement, 6, Date().timeIntervalSince1970)
try stepDone(statement)
}
private func insert(_ record: UsageRecord, id: String, statement: OpaquePointer?) throws {
sqlite3_reset(statement)
sqlite3_clear_bindings(statement)
try bind(id, to: statement, at: 1)
try bind(record.provider.rawValue, to: statement, at: 2)
try bind(record.sourcePath, to: statement, at: 3)
sqlite3_bind_double(statement, 4, record.timestamp.timeIntervalSince1970)
try bind(record.sessionID, to: statement, at: 5)
try bind(record.model, to: statement, at: 6)
sqlite3_bind_int64(statement, 7, Int64(record.inputTokens))
sqlite3_bind_int64(statement, 8, Int64(record.outputTokens))
sqlite3_bind_int64(statement, 9, Int64(record.cachedTokens))
sqlite3_bind_int64(statement, 10, Int64(record.reasoningTokens))
sqlite3_bind_int64(statement, 11, Int64(record.toolTokens))
sqlite3_bind_int64(statement, 12, Int64(record.totalTokens))
sqlite3_bind_double(statement, 13, calendar.startOfDay(for: record.timestamp).timeIntervalSince1970)
sqlite3_bind_double(statement, 14, hourStart(for: record.timestamp).timeIntervalSince1970)
sqlite3_bind_double(statement, 15, monthStart(for: record.timestamp).timeIntervalSince1970)
try stepDone(statement)
}
private func providerBuckets(
whereClause: String? = nil,
bindings: [TimeInterval] = []
) throws -> [Provider: TokenBucket] {
var buckets = Dictionary(uniqueKeysWithValues: Provider.allCases.map {
($0, TokenBucket(provider: $0))
})
var sql = """
SELECT provider,
SUM(input_tokens), SUM(output_tokens), SUM(cached_tokens),
SUM(reasoning_tokens), SUM(tool_tokens), SUM(total_tokens), COUNT(*)
FROM records
"""
if let whereClause {
sql += " WHERE \(whereClause)"
}
sql += " GROUP BY provider"
let statement = try prepare(sql)
defer { sqlite3_finalize(statement) }
try bindIntervals(bindings, to: statement)
while sqlite3_step(statement) == SQLITE_ROW {
guard let provider = Provider(rawValue: columnText(statement, at: 0)) else { continue }
var bucket = TokenBucket(provider: provider)
bucket.inputTokens = Int(sqlite3_column_int64(statement, 1))
bucket.outputTokens = Int(sqlite3_column_int64(statement, 2))
bucket.cachedTokens = Int(sqlite3_column_int64(statement, 3))
bucket.reasoningTokens = Int(sqlite3_column_int64(statement, 4))
bucket.toolTokens = Int(sqlite3_column_int64(statement, 5))
bucket.totalTokens = Int(sqlite3_column_int64(statement, 6))
bucket.count = Int(sqlite3_column_int64(statement, 7))
buckets[provider] = bucket
}
return buckets
}
private func dailyUsageDetails() throws -> [DailyUsageDetail] {
let statement = try prepare("""
SELECT day_start, provider,
SUM(input_tokens), SUM(output_tokens), SUM(cached_tokens), SUM(total_tokens)
FROM records
GROUP BY day_start, provider
ORDER BY day_start DESC
""")
defer { sqlite3_finalize(statement) }
var details: [Date: DailyUsageDetail] = [:]
while sqlite3_step(statement) == SQLITE_ROW {
guard let provider = Provider(rawValue: columnText(statement, at: 1)) else { continue }
let date = Date(timeIntervalSince1970: sqlite3_column_double(statement, 0))
if details[date] == nil {
details[date] = DailyUsageDetail(date: date)
}
details[date]?.add(
provider: provider,
inputTokens: Int(sqlite3_column_int64(statement, 2)),
outputTokens: Int(sqlite3_column_int64(statement, 3)),
cachedTokens: Int(sqlite3_column_int64(statement, 4)),
totalTokens: Int(sqlite3_column_int64(statement, 5))
)
}
return details.values.sorted { $0.date > $1.date }
}
private func trendPoints(mode: TrendMode, now: Date) throws -> [DailyPoint] {
let reference = mode.periodStart(for: now, calendar: calendar)
let start = mode.startDate(reference: reference, calendar: calendar)
let column = mode.periodColumn
let statement = try prepare("""
SELECT \(column), provider, SUM(total_tokens)
FROM records
WHERE \(column) >= ? AND \(column) <= ?
GROUP BY \(column), provider
""")
defer { sqlite3_finalize(statement) }
sqlite3_bind_double(statement, 1, start.timeIntervalSince1970)
sqlite3_bind_double(statement, 2, reference.timeIntervalSince1970)
var buckets: [Date: [Provider: Int]] = [:]
while sqlite3_step(statement) == SQLITE_ROW {
guard let provider = Provider(rawValue: columnText(statement, at: 1)) else { continue }
let date = Date(timeIntervalSince1970: sqlite3_column_double(statement, 0))
buckets[date, default: Provider.zeroTokenMap][provider, default: 0] = Int(sqlite3_column_int64(statement, 2))
}
var points: [DailyPoint] = []
var cursor = start
while cursor <= reference {
points.append(DailyPoint(date: cursor, values: buckets[cursor, default: Provider.zeroTokenMap]))
guard let next = calendar.date(byAdding: mode.calendarComponent, value: 1, to: cursor) else { break }
cursor = next
}
return points
}
private func tokenSum(whereClause: String, bindings: [TimeInterval]) throws -> Int {
let statement = try prepare("SELECT COALESCE(SUM(total_tokens), 0) FROM records WHERE \(whereClause)")
defer { sqlite3_finalize(statement) }
try bindIntervals(bindings, to: statement)
guard sqlite3_step(statement) == SQLITE_ROW else { return 0 }
return Int(sqlite3_column_int64(statement, 0))
}
private func sessionCount() throws -> Int {
let statement = try prepare("""
SELECT COUNT(DISTINCT CASE WHEN session_id = '' THEN source_path ELSE session_id END)
FROM records
""")
defer { sqlite3_finalize(statement) }
guard sqlite3_step(statement) == SQLITE_ROW else { return 0 }
return Int(sqlite3_column_int64(statement, 0))
}
private func recordsTableNeedsRebuild() throws -> Bool {
let columns = try tableColumns("records")
return !columns.isEmpty && !columns.contains("day_start")
}
private func addColumnIfMissing(table: String, column: String, definition: String) throws {
let columns = try tableColumns(table)
guard !columns.isEmpty, !columns.contains(column) else { return }
try execute("ALTER TABLE \(table) ADD COLUMN \(definition)")
}
private func tableColumns(_ table: String) throws -> Set<String> {
let statement = try prepare("PRAGMA table_info(\(table))")
defer { sqlite3_finalize(statement) }
var columns = Set<String>()
while sqlite3_step(statement) == SQLITE_ROW {
columns.insert(columnText(statement, at: 1))
}
return columns
}
private func bindIntervals(_ values: [TimeInterval], to statement: OpaquePointer?) throws {
for (index, value) in values.enumerated() {
sqlite3_bind_double(statement, Int32(index + 1), value)
}
}
private func dayBounds(for date: Date) -> QueryBounds {
let start = calendar.startOfDay(for: date)
let end = calendar.date(byAdding: .day, value: 1, to: start) ?? date
return QueryBounds(start: start, end: end)
}
private func monthBounds(for date: Date) -> QueryBounds {
let start = monthStart(for: date)
let end = calendar.date(byAdding: .month, value: 1, to: start) ?? date
return QueryBounds(start: start, end: end)
}
private func hourStart(for date: Date) -> Date {
let components = calendar.dateComponents([.year, .month, .day, .hour], from: date)
return calendar.date(from: components) ?? calendar.startOfDay(for: date)
}
private func monthStart(for date: Date) -> Date {
let components = calendar.dateComponents([.year, .month], from: date)
return calendar.date(from: components) ?? calendar.startOfDay(for: date)
}
private func calendarWithMondayStart(_ calendar: Calendar) -> Calendar {
var copy = calendar
copy.firstWeekday = 2
return copy
}
private func prepare(_ sql: String) throws -> OpaquePointer? {
var statement: OpaquePointer?
guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK else {
throw SQLiteUsageCacheError.prepareFailed(errorMessage)
}
return statement
}
private func execute(_ sql: String) throws {
guard sqlite3_exec(database, sql, nil, nil, nil) == SQLITE_OK else {
throw SQLiteUsageCacheError.executeFailed(errorMessage)
}
}
private func bind(_ text: String, to statement: OpaquePointer?, at index: Int32) throws {
let result = text.withCString {
sqlite3_bind_text(statement, index, $0, -1, Self.transient)
}
guard result == SQLITE_OK else {
throw SQLiteUsageCacheError.bindFailed(errorMessage)
}
}
private func stepDone(_ statement: OpaquePointer?) throws {
guard sqlite3_step(statement) == SQLITE_DONE else {
throw SQLiteUsageCacheError.stepFailed(errorMessage)
}
}
private func columnText(_ statement: OpaquePointer?, at index: Int32) -> String {
guard let text = sqlite3_column_text(statement, index) else {
return ""
}
return String(cString: UnsafeRawPointer(text).assumingMemoryBound(to: CChar.self))
}
private var errorMessage: String {
database.flatMap { String(cString: sqlite3_errmsg($0)) } ?? "SQLite 操作失败"
}
}
struct CachedFileState {
let size: Int64
let modifiedAt: TimeInterval
let scannedOffset: Int64
let recordCount: Int
}
private struct QueryBounds {
let start: Date
let end: Date
var bindings: [TimeInterval] {
[start.timeIntervalSince1970, end.timeIntervalSince1970]
}
}

View File

@@ -0,0 +1,19 @@
import Foundation
public enum TokenFormatter {
public static func format(_ value: Int) -> String {
if value >= 100_000_000 {
return adaptive(Double(value) / 100_000_000) + "亿"
}
if value >= 10_000 {
return adaptive(Double(value) / 10_000) + ""
}
return String(value)
}
private static func adaptive(_ value: Double) -> String {
let integerDigits = String(Int(abs(value.rounded(.towardZero)))).count
let digits = integerDigits > 3 ? 1 : 3
return String(format: "%.\(digits)f", value)
}
}

View File

@@ -0,0 +1,105 @@
import Foundation
public enum TokenUsageParser {
public static func parseClaudeLine(_ line: String, sourcePath: String) -> UsageRecord? {
guard let object = parseJSONObject(line) else { return nil }
let message = object["message"] as? [String: Any]
let usage = message?["usage"] as? [String: Any] ?? object["usage"] as? [String: Any]
guard let usage else { return nil }
guard let timestamp = parseDate(object["timestamp"] ?? object["createdAt"] ?? object["date"]) else { return nil }
return UsageRecord(
provider: .claude,
source: sourceLabel(sourcePath: sourcePath, provider: .claude),
sourcePath: sourcePath,
timestamp: timestamp,
sessionID: stringValue(object["sessionId"]),
model: stringValue(message?["model"]) == "" ? "Claude" : stringValue(message?["model"]),
inputTokens: intValue(usage["input_tokens"]),
outputTokens: intValue(usage["output_tokens"]),
cachedTokens: intValue(usage["cache_read_input_tokens"]) + intValue(usage["cache_creation_input_tokens"]),
reasoningTokens: 0,
toolTokens: 0
)
}
public static func parseCodexLine(_ line: String, sourcePath: String) -> UsageRecord? {
guard let object = parseJSONObject(line),
let payload = object["payload"] as? [String: Any],
let info = payload["info"] as? [String: Any],
let usage = info["last_token_usage"] as? [String: Any],
let timestamp = parseDate(object["timestamp"])
else { return nil }
return UsageRecord(
provider: .codex,
source: sourceLabel(sourcePath: sourcePath, provider: .codex),
sourcePath: sourcePath,
timestamp: timestamp,
sessionID: stringValue(payload["id"] ?? payload["thread_id"]),
model: stringValue(payload["model"]) == "" ? "Codex" : stringValue(payload["model"]),
inputTokens: intValue(usage["input_tokens"]),
outputTokens: intValue(usage["output_tokens"]),
cachedTokens: intValue(usage["cached_input_tokens"]),
reasoningTokens: intValue(usage["reasoning_output_tokens"]),
toolTokens: 0
)
}
public static func parseGeminiLine(_ line: String, sourcePath: String) -> UsageRecord? {
guard let object = parseJSONObject(line),
let tokens = object["tokens"] as? [String: Any],
let timestamp = parseDate(object["timestamp"])
else { return nil }
return UsageRecord(
provider: .gemini,
source: sourceLabel(sourcePath: sourcePath, provider: .gemini),
sourcePath: sourcePath,
timestamp: timestamp,
sessionID: stringValue(object["id"]),
model: stringValue(object["model"]) == "" ? "Gemini" : stringValue(object["model"]),
inputTokens: intValue(tokens["input"]),
outputTokens: intValue(tokens["output"]),
cachedTokens: intValue(tokens["cached"]),
reasoningTokens: intValue(tokens["thoughts"]),
toolTokens: intValue(tokens["tool"]),
explicitTotal: intValue(tokens["total"]) == 0 ? nil : intValue(tokens["total"])
)
}
static func parseDate(_ value: Any?) -> Date? {
guard let text = value as? String else { return nil }
let isoFormatter = ISO8601DateFormatter()
isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
let fallbackISOFormatter = ISO8601DateFormatter()
fallbackISOFormatter.formatOptions = [.withInternetDateTime]
return isoFormatter.date(from: text) ?? fallbackISOFormatter.date(from: text)
}
private static func parseJSONObject(_ line: String) -> [String: Any]? {
guard let data = line.data(using: .utf8) else { return nil }
return (try? JSONSerialization.jsonObject(with: data)) as? [String: Any]
}
private static func stringValue(_ value: Any?) -> String {
value as? String ?? ""
}
private static func intValue(_ value: Any?) -> Int {
if let int = value as? Int { return int }
if let double = value as? Double { return Int(double) }
if let string = value as? String { return Int(string) ?? 0 }
return 0
}
private static func sourceLabel(sourcePath: String, provider: Provider) -> String {
switch provider {
case .claude: return "projects/*.jsonl"
case .codex: return "sessions/*.jsonl"
case .gemini: return "tmp/chats/*.jsonl"
case .hermes: return "state.db/sessions"
case .opencode: return "opencode.db/session"
}
}
}

View File

@@ -0,0 +1,525 @@
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<String> {
var currentPaths = Set<String>()
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..<index]
if pendingLine.isEmpty {
scannedOffset += Int64(lineData.count + 1)
try autoreleasepool {
if try appendRecord(from: lineData, sourcePath: file.path, parse: parse, consume: consume) {
recordCount += 1
}
}
} else {
pendingLine.append(contentsOf: lineData)
scannedOffset += Int64(pendingLine.count + 1)
try autoreleasepool {
if try appendRecord(from: pendingLine, sourcePath: file.path, parse: parse, consume: consume) {
recordCount += 1
}
}
pendingLine.removeAll(keepingCapacity: true)
}
lineStart = chunk.index(after: index)
}
if lineStart < chunk.endIndex {
pendingLine.append(contentsOf: chunk[lineStart..<chunk.endIndex])
}
}
if !pendingLine.isEmpty {
try autoreleasepool {
if try appendRecord(from: pendingLine, sourcePath: file.path, parse: parse, consume: consume) {
recordCount += 1
scannedOffset += Int64(pendingLine.count)
} else if isCompleteJSONLine(pendingLine) {
scannedOffset += Int64(pendingLine.count)
}
}
}
return ScanProgress(scannedOffset: scannedOffset, recordCount: recordCount)
}
private func appendRecord<LineData: Collection>(
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
}
}

View File

@@ -0,0 +1,330 @@
import Foundation
public struct UsageSummaryAccumulator {
private let now: Date
private var calendar: Calendar
private let today: Date
private let month: DateComponents
private let trendWindows: [TrendMode: TrendWindow]
private var providerTotals: [Provider: TokenBucket]
private var todayProviderTotals: [Provider: TokenBucket]
private var dailyBuckets: [Date: [Provider: Int]] = [:]
private var dailyDetails: [Date: DailyUsageDetail] = [:]
private var trendBuckets: [TrendMode: [Date: [Provider: Int]]] = [:]
private var todayTokens = 0
private var monthTokens = 0
private var totalTokens = 0
private var cachedTokens = 0
private var sessions = Set<String>()
public init(
now: Date = Date(),
calendar baseCalendar: Calendar = Calendar.current
) {
self.now = now
var calendar = baseCalendar
calendar.firstWeekday = 2
self.calendar = calendar
today = calendar.startOfDay(for: now)
month = calendar.dateComponents([.year, .month], from: now)
providerTotals = Dictionary(uniqueKeysWithValues: Provider.allCases.map {
($0, TokenBucket(provider: $0))
})
todayProviderTotals = Dictionary(uniqueKeysWithValues: Provider.allCases.map {
($0, TokenBucket(provider: $0))
})
trendWindows = Dictionary(uniqueKeysWithValues: TrendMode.allCases.map { mode in
let reference = mode.periodStart(for: now, calendar: calendar)
return (mode, TrendWindow(
reference: reference,
start: mode.startDate(reference: reference, calendar: calendar),
component: mode.calendarComponent
))
})
}
public mutating func add(_ record: UsageRecord) {
add(
provider: record.provider,
sourcePath: record.sourcePath,
timestamp: record.timestamp,
sessionID: record.sessionID,
inputTokens: record.inputTokens,
outputTokens: record.outputTokens,
cachedTokens: record.cachedTokens,
reasoningTokens: record.reasoningTokens,
toolTokens: record.toolTokens,
totalTokens: record.totalTokens
)
}
public mutating func add(
provider: Provider,
sourcePath: String,
timestamp: Date,
sessionID: String,
inputTokens: Int,
outputTokens: Int,
cachedTokens: Int,
reasoningTokens: Int,
toolTokens: Int,
totalTokens: Int
) {
self.totalTokens += totalTokens
self.cachedTokens += cachedTokens
sessions.insert(sessionID.isEmpty ? sourcePath : sessionID)
providerTotals[provider, default: TokenBucket(provider: provider)].add(
inputTokens: inputTokens,
outputTokens: outputTokens,
cachedTokens: cachedTokens,
reasoningTokens: reasoningTokens,
toolTokens: toolTokens,
totalTokens: totalTokens
)
let recordDay = calendar.startOfDay(for: timestamp)
dailyBuckets[recordDay, default: Provider.zeroTokenMap][provider, default: 0] += totalTokens
if dailyDetails[recordDay] == nil {
dailyDetails[recordDay] = DailyUsageDetail(date: recordDay)
}
dailyDetails[recordDay]?.add(
provider: provider,
inputTokens: inputTokens,
outputTokens: outputTokens,
cachedTokens: cachedTokens,
totalTokens: totalTokens
)
if recordDay == today {
todayTokens += totalTokens
todayProviderTotals[provider, default: TokenBucket(provider: provider)].add(
inputTokens: inputTokens,
outputTokens: outputTokens,
cachedTokens: cachedTokens,
reasoningTokens: reasoningTokens,
toolTokens: toolTokens,
totalTokens: totalTokens
)
}
let recordMonth = calendar.dateComponents([.year, .month], from: timestamp)
if recordMonth.year == month.year && recordMonth.month == month.month {
monthTokens += totalTokens
}
for mode in TrendMode.allCases {
guard let window = trendWindows[mode] else { continue }
let period = mode.periodStart(for: timestamp, calendar: calendar)
guard period >= window.start && period <= window.reference else { continue }
trendBuckets[mode, default: [:]][period, default: Provider.zeroTokenMap][provider, default: 0] += totalTokens
}
}
public func makeSummary() -> UsageSummary {
let dailyTrend = dailyBuckets
.sorted { $0.key < $1.key }
.suffix(30)
.map { DailyPoint(date: $0.key, values: $0.value) }
let sortedDailyDetails = dailyDetails.values.sorted { $0.date > $1.date }
let trendPointsByMode = Dictionary(uniqueKeysWithValues: TrendMode.allCases.map { mode in
(mode, makeTrendPoints(mode: mode))
})
let toolRows = Provider.allCases.compactMap { provider -> ToolRow? in
guard let bucket = providerTotals[provider], bucket.totalTokens > 0 else { return nil }
let trendTokens = dailyTrend.map { $0.values[provider, default: 0] }
return ToolRow(
provider: provider,
inputTokens: bucket.inputTokens,
outputTokens: bucket.outputTokens,
cachedTokens: bucket.cachedTokens,
totalTokens: bucket.totalTokens,
trendTokens: trendTokens
)
}
.sorted { $0.totalTokens > $1.totalTokens }
let cacheHitRate = totalTokens > 0 ? Int((Double(cachedTokens) / Double(totalTokens) * 100).rounded()) : 0
let cards = SummaryCards(
todayTokens: todayTokens,
monthTokens: monthTokens,
totalTokens: totalTokens,
cacheHitRate: cacheHitRate,
sessionCount: sessions.count
)
return UsageSummary(
generatedAt: now,
records: [],
cards: cards,
providerTotals: providerTotals,
todayProviderTotals: todayProviderTotals,
dailyTrend: Array(dailyTrend),
trendPointsByMode: trendPointsByMode,
dailyDetails: sortedDailyDetails,
toolRows: toolRows,
insights: UsageSummary.makeInsights(
providerTotals: providerTotals,
todayTokens: todayTokens,
cacheHitRate: cacheHitRate
)
)
}
private func makeTrendPoints(mode: TrendMode) -> [DailyPoint] {
guard let window = trendWindows[mode] else { return [] }
let buckets = trendBuckets[mode, default: [:]]
var points: [DailyPoint] = []
var cursor = window.start
while cursor <= window.reference {
points.append(DailyPoint(date: cursor, values: buckets[cursor, default: Provider.zeroTokenMap]))
guard let next = calendar.date(byAdding: window.component, value: 1, to: cursor) else { break }
cursor = next
}
return points
}
}
private struct TrendWindow {
let reference: Date
let start: Date
let component: Calendar.Component
}
public extension UsageSummary {
static func make(
records: [UsageRecord],
now: Date = Date(),
calendar baseCalendar: Calendar = Calendar.current
) -> UsageSummary {
var accumulator = UsageSummaryAccumulator(now: now, calendar: baseCalendar)
for record in records {
accumulator.add(record)
}
return accumulator.makeSummary()
}
static func makeInsights(
providerTotals: [Provider: TokenBucket],
todayTokens: Int,
cacheHitRate: Int
) -> [Insight] {
let top = providerTotals.values
.filter { $0.totalTokens > 0 }
.sorted { $0.totalTokens > $1.totalTokens }
.first
var insights: [Insight] = []
if let top, let provider = top.provider {
insights.append(Insight(
tone: .warning,
title: "\(provider.rawValue) 是当前主要用量来源",
message: "已从本地日志统计 \(TokenFormatter.format(top.totalTokens)) Tokens。"
))
}
insights.append(Insight(
tone: .positive,
title: "检测到缓存 Tokens",
message: "当前 \(cacheHitRate)% 的统计用量来自缓存上下文。"
))
insights.append(Insight(
tone: .accent,
title: "今日用量快照已更新",
message: "当前本地日期已统计 \(TokenFormatter.format(todayTokens)) Tokens。"
))
return insights
}
func trendPoints(
mode: TrendMode,
calendar baseCalendar: Calendar = Calendar.current
) -> [DailyPoint] {
if let points = trendPointsByMode[mode] {
return points
}
return []
}
func dailyUsageDetails(calendar baseCalendar: Calendar = Calendar.current) -> [DailyUsageDetail] {
dailyDetails
}
func updatingGeneratedAt(_ date: Date) -> UsageSummary {
UsageSummary(
generatedAt: date,
records: records,
cards: cards,
providerTotals: providerTotals,
todayProviderTotals: todayProviderTotals,
dailyTrend: dailyTrend,
trendPointsByMode: trendPointsByMode,
dailyDetails: dailyDetails,
toolRows: toolRows,
insights: insights
)
}
}
extension Provider {
static var zeroTokenMap: [Provider: Int] {
Dictionary(uniqueKeysWithValues: Provider.allCases.map { ($0, 0) })
}
}
extension TrendMode {
var calendarComponent: Calendar.Component {
switch self {
case .daily:
return .hour
case .weekly:
return .day
case .monthly:
return .day
case .yearly:
return .month
}
}
var periodColumn: String {
switch self {
case .daily:
return "hour_start"
case .weekly, .monthly:
return "day_start"
case .yearly:
return "month_start"
}
}
func periodStart(for date: Date, calendar: Calendar) -> Date {
switch self {
case .daily:
let components = calendar.dateComponents([.year, .month, .day, .hour], from: date)
return calendar.date(from: components) ?? calendar.startOfDay(for: date)
case .weekly:
return calendar.startOfDay(for: date)
case .monthly:
return calendar.startOfDay(for: date)
case .yearly:
let components = calendar.dateComponents([.year, .month], from: date)
return calendar.date(from: components) ?? calendar.startOfDay(for: date)
}
}
func startDate(reference: Date, calendar: Calendar) -> Date {
switch self {
case .daily:
return calendar.startOfDay(for: reference)
case .weekly:
return calendar.dateInterval(of: .weekOfYear, for: reference)?.start ?? calendar.startOfDay(for: reference)
case .monthly:
let components = calendar.dateComponents([.year, .month], from: reference)
return calendar.date(from: components) ?? calendar.startOfDay(for: reference)
case .yearly:
let components = calendar.dateComponents([.year], from: reference)
return calendar.date(from: components) ?? calendar.startOfDay(for: reference)
}
}
}