Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions Sources/PokeTokenBar/Core/LocalUsageCache.swift
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ actor LocalUsageCache {
// 테스트 시임 — 기본값은 실환경(실 로그 루트·Application Support·실시간).
private let claudeRoot: URL?
private let codexRoot: URL?
private let codexRoots: [URL]?
private let geminiRoot: URL?
private let grokRoot: URL?
private let fileURL: URL
Expand All @@ -98,7 +99,8 @@ actor LocalUsageCache {
private let claudeRoots: [URL]?

init(claudeRoot: URL? = nil, claudeRoots: [URL]? = nil,
codexRoot: URL? = nil, geminiRoot: URL? = nil, grokRoot: URL? = nil,
codexRoot: URL? = nil, codexRoots: [URL]? = nil,
geminiRoot: URL? = nil, grokRoot: URL? = nil,
fileURL: URL? = nil, now: @escaping @Sendable () -> Date = Date.init,
codexProbe: @escaping @Sendable (URL) throws -> String? = {
try LocalUsageReader.probeCodexRolloutSessionID(at: $0)
Expand All @@ -109,6 +111,7 @@ actor LocalUsageCache {
self.claudeRoots = claudeRoots
self.claudeRoot = claudeRoot
self.codexRoot = codexRoot
self.codexRoots = codexRoots
self.geminiRoot = geminiRoot
self.grokRoot = grokRoot
self.fileURL = fileURL ?? Self.defaultFileURL
Expand Down Expand Up @@ -143,8 +146,9 @@ actor LocalUsageCache {
func codexEntries(modifiedSince: Date) -> [LocalUsageReader.Entry] {
ensureLoaded()
let fmt = LocalUsageReader.localDayFormatter()
let roots = codexRoots ?? codexRoot.map { [$0] } ?? LocalUsageReader.codexScanRoots
let (rollouts, includedPaths) = collectCodexRollouts(
root: codexRoot ?? LocalUsageReader.codexSessionsDir,
roots: roots,
since: modifiedSince,
fmt: fmt
)
Expand Down Expand Up @@ -221,11 +225,11 @@ actor LocalUsageCache {
/// Codex는 fork 파일을 단독으로 확정할 수 없으므로 final Entry 대신 parsed rollout을 캐시.
/// 조회 범위 밖 부모도 replay 판정에는 필요해 session id로 찾아 dependency로 함께 반환.
private func collectCodexRollouts(
root: URL,
roots: [URL],
since: Date,
fmt: DateFormatter
) -> (rollouts: [LocalUsageReader.CodexParsedRollout], includedPaths: Set<String>) {
let files = LocalUsageReader.codexRolloutFiles(in: root)
let files = LocalUsageReader.codexRolloutFiles(in: roots)

func rememberSessionID(_ id: String?, of file: LocalUsageReader.CodexRolloutFile) {
codexSessionIDs[file.path] = CodexSessionProbe(
Expand Down
53 changes: 49 additions & 4 deletions Sources/PokeTokenBar/Core/LocalUsageReader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ import Foundation
/// - Claude: `~/.claude/projects/**/*.jsonl` 의 `type:"assistant"` 라인
/// (`message.usage` 4종 토큰, `message.model`, `message.id`+`requestId`, `timestamp`).
/// 세션 재개/sidechain 으로 같은 메시지가 여러 파일에 중복 → `(message.id, requestId)` 로 dedup.
/// - Codex: `~/.codex/sessions/**/rollout-*.jsonl` 의 `event_msg.payload.type:"token_count"`
/// (`info.last_token_usage` 턴 델타) 합산.
/// - Codex: `~/.codex/sessions/**/rollout-*.jsonl` 및 보관된
/// `~/.codex/archived_sessions/rollout-*.jsonl` 의
/// `event_msg.payload.type:"token_count"` (`info.last_token_usage` 턴 델타) 합산.
///
/// 성능: mtime 윈도우로 스캔 파일을 한정(범위 시작 이전에 수정된 파일은 범위 내 엔트리가 없음).
enum LocalUsageReader {
Expand Down Expand Up @@ -202,8 +203,41 @@ enum LocalUsageReader {
// 원래 순서(우선순위)를 보존해 돌려준다.
return unique.filter(kept.contains).map { URL(fileURLWithPath: $0) }
}
/// Codex 기본 경로는 이 두 상대 경로로만 정의한다.
/// `codexScanRoots`를 직접 조립하는 코드가 늘어나면 활성 세션과 보관 세션 중
/// 한쪽만 캐시·스캐너·테스트에 반영되는 회귀가 생기기 쉬우므로, 기본 목록은
/// `computeCodexScanRoots(home:)` 한 곳에서 만든다.
static let codexSessionsRelativePath = ".codex/sessions"
static let codexArchivedSessionsRelativePath = ".codex/archived_sessions"

static var codexSessionsDir: URL {
FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".codex/sessions")
FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(codexSessionsRelativePath)
}

/// Codex가 보관한 세션은 원본 rollout을 유지한 채 이 루트로 이동한다.
/// 활성 세션만 읽으면 보관 직후 당일 사용량이 감소하므로, 두 루트를 하나의 논리적
/// 세션 집합으로 읽고 아래 resolver의 안정적인 이벤트 ID로 중복을 제거해야 한다.
static var codexArchivedSessionsDir: URL {
FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(codexArchivedSessionsRelativePath)
}

/// 테스트 가능한 Codex 기본 스캔 루트 계산기.
/// 앱과 캐시는 아래의 `codexScanRoots`를 사용하고, 테스트는 가짜 home을 주입해
/// 실제 사용자 디렉터리나 로그인 환경에 의존하지 않고 두 기본 경로의 구성을 고정한다.
static func computeCodexScanRoots(
home: URL = FileManager.default.homeDirectoryForCurrentUser
) -> [URL] {
normalizedRoots([
home.appendingPathComponent(codexSessionsRelativePath),
home.appendingPathComponent(codexArchivedSessionsRelativePath),
])
}

/// 스캐너·캐시가 공유하는 Codex 기본 루트 목록.
static var codexScanRoots: [URL] {
computeCodexScanRoots()
}
static var geminiTmpDir: URL {
FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".gemini/tmp")
Expand Down Expand Up @@ -496,6 +530,16 @@ enum LocalUsageReader {
return out
}

/// 활성·보관 루트처럼 여러 디렉터리를 하나의 Codex 파일 집합으로 합친다.
/// 먼저 공통 루트 정규화로 심볼릭 링크·중첩 루트를 접고, 이동 중 같은 rollout이
/// 두 루트에 잠시 동시에 보여도 실제 이벤트 중복 제거는
/// `resolveCodexRollouts`의 session/state ID가 담당한다.
static func codexRolloutFiles(in roots: [URL]) -> [CodexRolloutFile] {
normalizedRoots(roots)
.flatMap { codexRolloutFiles(in: $0) }
.sorted { $0.path < $1.path }
}

/// 조회 윈도우 안 rollout 에서 시작해, replay 대조에 필요한 부모(그 부모의 부모까지)를 dependency 로
/// 끌어온다. Codex 는 fork 파일 하나만 보고는 자기 usage 를 확정할 수 없기 때문이다.
///
Expand Down Expand Up @@ -568,7 +612,8 @@ enum LocalUsageReader {

static func codexEntries(modifiedSince: Date, root: URL? = nil) -> [Entry] {
let fmt = localDayFormatter()
let allFiles = codexRolloutFiles(in: root ?? codexSessionsDir)
let roots = root.map { [$0] } ?? codexScanRoots
let allFiles = codexRolloutFiles(in: roots)
// 테스트/캐시 미사용 경로 — 아는 세션 id 가 없으니 파일명 힌트와 probe 만으로 부모를 찾는다.
let (rollouts, includedPaths) = expandCodexParentClosure(
windowFiles: allFiles.filter { $0.mtime >= modifiedSince },
Expand Down
60 changes: 58 additions & 2 deletions Tests/PokeTokenBarTests/LocalUsageCacheTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,16 @@ private final class Clock: @unchecked Sendable {
/// (재사용/재파싱 판정, 디스크 영속·라운드트립, 40일 prune, 60초 저장 throttle)
final class LocalUsageCacheTests: XCTestCase {
private var root: URL!
private var archivedRoot: URL!
private var cacheFile: URL!

override func setUpWithError() throws {
let base = FileManager.default.temporaryDirectory
.appendingPathComponent("ptb-cache-\(UUID().uuidString)")
root = base.appendingPathComponent("projects")
archivedRoot = base.appendingPathComponent("archived")
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
try FileManager.default.createDirectory(at: archivedRoot, withIntermediateDirectories: true)
cacheFile = base.appendingPathComponent("usage-cache.json")
}

Expand Down Expand Up @@ -119,15 +122,25 @@ final class LocalUsageCacheTests: XCTestCase {

private func makeCache(now: @escaping @Sendable () -> Date = Date.init,
probes: ProbeCounter? = nil,
probe: (@Sendable (URL) throws -> String?)? = nil) -> LocalUsageCache {
LocalUsageCache(claudeRoot: root, codexRoot: root, fileURL: cacheFile, now: now,
probe: (@Sendable (URL) throws -> String?)? = nil,
codexRoots: [URL]? = nil) -> LocalUsageCache {
LocalUsageCache(claudeRoot: root,
codexRoot: codexRoots == nil ? root : nil,
codexRoots: codexRoots,
fileURL: cacheFile, now: now,
codexProbe: { url in
probes?.bump()
if let probe { return try probe(url) }
return try LocalUsageReader.probeCodexRolloutSessionID(at: url)
})
}

private func writeLines(_ lines: [String], to directory: URL, name: String) throws -> URL {
let url = directory.appendingPathComponent(name)
try lines.joined(separator: "\n").write(to: url, atomically: true, encoding: .utf8)
return url
}

private func forkMeta(id: String, parentID: String, ts: String) -> String {
"""
{"type":"session_meta","timestamp":"\(ts)","payload":{"id":"\(id)","forked_from_id":"\(parentID)","parent_thread_id":"\(parentID)","thread_source":"user"}}
Expand Down Expand Up @@ -247,6 +260,49 @@ final class LocalUsageCacheTests: XCTestCase {
XCTAssertEqual(entries.map(\.total), [110])
}

/// 활성 세션과 보관 세션에 같은 rollout이 동시에 보이는 이동 중 상태에서도
/// 파일 경로가 아니라 session/state ID 기준으로 한 번만 집계해야 한다.
func testCodexCacheDeduplicatesRolloutAcrossActiveAndArchivedRoots() async throws {
let lines = [
sessionMeta(id: "moved-session", ts: "2026-07-29T01:00:00.000Z"),
codexStateLine(
ts: "2026-07-29T01:00:01.000Z",
cumulativeInput: 100, cumulativeOutput: 10,
lastInput: 100, lastOutput: 10),
]
try writeFile("rollout.jsonl", lines: lines)
_ = try writeLines(lines, to: archivedRoot, name: "rollout.jsonl")

let entries = await makeCache(codexRoots: [root, archivedRoot])
.codexEntries(modifiedSince: since)

XCTAssertEqual(entries.map(\.total), [110], "양쪽 루트의 같은 rollout은 한 번만 집계해야 한다")
}

/// Codex의 정상적인 sessions → archived_sessions 이동을 같은 캐시 인스턴스가
/// 따라가야 한다. 이동 전 경로의 cache blob이 남아 있어도 보관 경로의 새 파일을
/// 읽고, 이전 경로와 합산하지 않아야 한다.
func testCodexCacheFollowsRolloutWhenMovedToArchivedRoot() async throws {
let lines = [
sessionMeta(id: "archived-session", ts: "2026-07-29T01:00:00.000Z"),
codexStateLine(
ts: "2026-07-29T01:00:01.000Z",
cumulativeInput: 100, cumulativeOutput: 10,
lastInput: 100, lastOutput: 10),
]
let activeFile = try writeFile("rollout.jsonl", lines: lines)
let archivedFile = archivedRoot.appendingPathComponent("rollout.jsonl")
let cache = makeCache(codexRoots: [root, archivedRoot])

let beforeMove = await cache.codexEntries(modifiedSince: since)
XCTAssertEqual(beforeMove.map(\.total), [110])

try FileManager.default.moveItem(at: activeFile, to: archivedFile)

let afterMove = await cache.codexEntries(modifiedSince: since)
XCTAssertEqual(afterMove.map(\.total), [110], "rollout 이동 후에도 기존 집계가 유지돼야 한다")
}

func testCodexCacheKeepsSubagentFirstTurnWhenParentIsMissing() async throws {
try writeFile("rollout-subagent.jsonl", lines: [
subagentSessionMeta(ts: "2026-07-30T01:00:00.000Z"),
Expand Down
24 changes: 24 additions & 0 deletions Tests/PokeTokenBarTests/LocalUsageReaderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,30 @@ final class LocalUsageReaderTests: XCTestCase {
return destination
}

func testCodexScanRootsShareInjectableDefaultHome() {
let home = URL(fileURLWithPath: "/Users/testhome")
let roots = LocalUsageReader.computeCodexScanRoots(home: home).map(\.path)

XCTAssertEqual(roots, [
home.appendingPathComponent(LocalUsageReader.codexSessionsRelativePath).path,
home.appendingPathComponent(LocalUsageReader.codexArchivedSessionsRelativePath).path,
])
XCTAssertEqual(Set(roots).count, roots.count)
}

func testCodexRolloutFilesNormalizeSymlinkedRoots() throws {
let base = tempDir()
let real = base.appendingPathComponent("real")
try FileManager.default.createDirectory(at: real, withIntermediateDirectories: true)
write(["{}"], to: real, name: "rollout.jsonl")

let link = base.appendingPathComponent("linked")
try FileManager.default.createSymbolicLink(at: link, withDestinationURL: real)

let files = LocalUsageReader.codexRolloutFiles(in: [real, link])
XCTAssertEqual(files.count, 1, "심볼릭 링크로 같은 Codex 루트를 두 번 스캔하지 않아야 한다")
}

// MARK: ModelPricing

func testPricingExactAndFallbackAndZero() {
Expand Down
5 changes: 5 additions & 0 deletions docs/reference/provider-extension.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ read_when:
(탐색·자식 프로세스 PATH 보강이 이 단일 소스를 공유).
- **로그 스캔 루트 추가** = `LocalUsageReader.claudeProjectRoots` 같은 프로바이더별 루트 목록 한 곳에만
추가한다. 스캔(`LocalUsageReader`)·캐시(`LocalUsageCache`)·테스트가 그 단일 소스를 공유해야 한다.
Codex의 기본 목록은 활성 세션 파일이 있는 `~/.codex/sessions`와 보관된 세션 파일을 옮기는
`~/.codex/archived_sessions`를 모두 포함해야 한다. 두 경로는 서로 다른 사용량이 아니라
같은 rollout이 이동하는 위치이므로, 어느 한쪽만 읽으면 기존 사용량이 사라진 것처럼 보일 수 있다.
기본 목록을 테스트할 때는 `computeCodexScanRoots(home:)`에 가짜 home을 주입해 실제 사용자
디렉터리에 의존하지 않도록 한다.
루트가 겹쳐도 합계는 전역 dedup 이 바로잡지만, 중복 루트는 스캔 비용을 배로 늘리므로
`normalizedRoots` 로 접는다.
- **append-only SQLite 사용량 스토어** (Cursor `cursorDiskKV`, Copilot `assistant_usage_events`,
Expand Down