diff --git a/Sources/PokeTokenBar/Core/LocalUsageCache.swift b/Sources/PokeTokenBar/Core/LocalUsageCache.swift index ee6da8fe..5e30f8cf 100644 --- a/Sources/PokeTokenBar/Core/LocalUsageCache.swift +++ b/Sources/PokeTokenBar/Core/LocalUsageCache.swift @@ -8,7 +8,25 @@ import Foundation actor LocalUsageCache { static let shared = LocalUsageCache() - private struct Blob: Codable { let mtime: Date; let size: Int; let entries: [LocalUsageReader.Entry] } + private struct Blob: Codable { + let mtime: Date + let size: Int + let entries: [LocalUsageReader.Entry] + /// Bytes already parsed, for append-only logs. Nil on blobs from older cache files. + let offset: Int? + + init(mtime: Date, size: Int, entries: [LocalUsageReader.Entry], offset: Int? = nil) { + self.mtime = mtime; self.size = size; self.entries = entries; self.offset = offset + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + mtime = try c.decode(Date.self, forKey: .mtime) + size = try c.decode(Int.self, forKey: .size) + entries = try c.decode([LocalUsageReader.Entry].self, forKey: .entries) + offset = try c.decodeIfPresent(Int.self, forKey: .offset) + } + } private struct CodexBlob: Codable { let mtime: Date let size: Int @@ -165,7 +183,10 @@ actor LocalUsageCache { let roots = claudeRoots ?? claudeRoot.map { [$0] } ?? LocalUsageReader.claudeProjectRoots var all: [LocalUsageReader.Entry] = [] for root in roots { - all += collect(root: root, since: modifiedSince, cache: &claudeCache) { + all += collect(root: root, since: modifiedSince, cache: &claudeCache, + incremental: { url, offset in + LocalUsageReader.parseClaudeFile(url, fromOffset: offset, fmt: fmt) + }) { LocalUsageReader.parseClaudeFile($0, fmt: fmt) } } @@ -266,8 +287,12 @@ actor LocalUsageCache { /// `include` 는 blob 캐시 조회 **전에** 평가된다 — 파일 밖 상태(옆 파일 등)에 의존하는 판정을 /// 캐시에 굳히지 않기 위해서다. + /// `incremental` parses from a byte offset for append-only logs. When the cached blob has an + /// offset and the file grew, only the new tail is read and merged with the cached entries; + /// otherwise the file is parsed from the start and the resulting offset recorded. private func collect(root: URL, since: Date, cache: inout [String: Blob], allowJSON: Bool = false, include: ((URL) -> Bool)? = nil, + incremental: ((URL, Int) -> (entries: [LocalUsageReader.Entry], newOffset: Int)?)? = nil, parse: (URL) -> [LocalUsageReader.Entry]?) -> [LocalUsageReader.Entry] { let fm = FileManager.default guard let en = fm.enumerator( @@ -286,6 +311,21 @@ actor LocalUsageCache { let key = url.path if let blob = cache[key], blob.mtime == mtime, blob.size == size { result.append(contentsOf: blob.entries) // 변경 없음 → 재파싱 안 함 + } else if let incremental { + let prior = cache[key] + // Tail-parse only when the file strictly grew; a same-size rewrite or a + // truncation is re-read from the start. + let from = prior.flatMap { p in size > p.size ? p.offset : nil } ?? 0 + if let tail = incremental(url, from) { + let entries = from > 0 + ? LocalUsageReader.dedupKeepMax((prior?.entries ?? []) + tail.entries) + : tail.entries + cache[key] = Blob(mtime: mtime, size: size, entries: entries, offset: tail.newOffset) + dirty = true + result.append(contentsOf: entries) + } else if let prior { + result.append(contentsOf: prior.entries) + } } else if let entries = parse(url) { cache[key] = Blob(mtime: mtime, size: size, entries: entries) dirty = true diff --git a/Sources/PokeTokenBar/Core/LocalUsageReader.swift b/Sources/PokeTokenBar/Core/LocalUsageReader.swift index 51011ce0..598d7e43 100644 --- a/Sources/PokeTokenBar/Core/LocalUsageReader.swift +++ b/Sources/PokeTokenBar/Core/LocalUsageReader.swift @@ -368,17 +368,55 @@ enum LocalUsageReader { /// Claude 파일 하나를 파싱(파일 내 dedup). 캐시가 파일 단위로 호출. static func parseClaudeFile(_ url: URL, fmt: DateFormatter) -> [Entry] { - guard let text = try? String(contentsOf: url, encoding: .utf8) else { return [] } + parseClaudeFile(url, fromOffset: 0, fmt: fmt)?.entries ?? [] + } + + /// Parses only the bytes after `fromOffset`. Session logs are append-only and the active + /// one can be hundreds of MB, so the cache re-reads just the new tail each refresh instead + /// of the whole file. `newOffset` stops at the last complete line so a half-written record + /// is retried next time. Nil means the file could not be read. + static func parseClaudeFile(_ url: URL, fromOffset: Int, fmt: DateFormatter) + -> (entries: [Entry], newOffset: Int)? + { + guard let handle = try? FileHandle(forReadingFrom: url) else { return nil } + defer { try? handle.close() } + guard (try? handle.seek(toOffset: UInt64(fromOffset))) != nil, + let data = try? handle.readToEnd() else { return nil } + let parsed = parseClaudeData(data, fmt: fmt) + return (parsed.entries, fromOffset + parsed.consumed) + } + + private static let usageNeedle = Data("\"usage\"".utf8) + private static let assistantNeedle = Data("\"assistant\"".utf8) + + /// Byte-level line scan: no whole-file String materialization or per-line Substring + /// allocation. `consumed` is the byte count up to and including the last newline. + static func parseClaudeData(_ data: Data, fmt: DateFormatter) -> (entries: [Entry], consumed: Int) { var out: [Entry] = [] - for line in text.split(separator: "\n", omittingEmptySubsequences: true) { - guard line.contains("\"usage\""), line.contains("\"assistant\"") else { continue } + var consumed = 0 + var lineStart = data.startIndex + func parse(_ line: Data.SubSequence) { + guard !line.isEmpty, + line.range(of: usageNeedle) != nil, + line.range(of: assistantNeedle) != nil, + let text = String(data: line, encoding: .utf8) else { return } // 라인마다 autoreleasepool — JSONSerialization 이 만드는 autoreleased NSDictionary/NSString 가 // 수천 파일·수만 라인에 걸쳐 배출 없이 누적돼 콜드 파싱 피크를 키우던 것을 즉시 배출. autoreleasepool { - if let e = parseClaudeLine(String(line), fmt: fmt) { out.append(e) } + if let e = parseClaudeLine(text, fmt: fmt) { out.append(e) } } } - return dedupKeepMax(out) + while lineStart < data.endIndex { + guard let nl = data[lineStart...].firstIndex(of: 0x0A) else { break } + parse(data[lineStart.. Date? { - let fractional = ISO8601DateFormatter() - fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - if let d = fractional.date(from: string) { return d } - // 소수점 자릿수가 3자리가 아니면 3자리로 절단 후 재시도 - if let dotIndex = string.firstIndex(of: ".") { - let afterDot = string.index(after: dotIndex) - if let tzIndex = string[afterDot...].firstIndex(where: { $0 == "+" || $0 == "-" || $0 == "Z" }) { - let frac = String(string[afterDot.. Date? { + lock.lock() + defer { lock.unlock() } + if let d = fractional.date(from: string) { return d } + // 소수점 자릿수가 3자리가 아니면 3자리로 절단 후 재시도 + if let dotIndex = string.firstIndex(of: ".") { + let afterDot = string.index(after: dotIndex) + if let tzIndex = string[afterDot...].firstIndex(where: { $0 == "+" || $0 == "-" || $0 == "Z" }) { + let frac = String(string[afterDot..