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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,14 @@ providers' own usage endpoints.
calendar using logs from every supported provider, regardless of which
providers are selected for the usage pills. Click a provider in the calendar
legend to filter its history; click it again to show all providers.
- **Usage history that stays yours.** CodexIsland saves captured token counts
in its own local database, including older records still available when it
scans. Provider log cleanup no longer erases captured history. Repeated
scans update the same calls instead of counting them again. See
[usage-history storage](docs/USAGE-HISTORY.md) for coverage details. Open
**Settings → General → Recover Claude usage…** to preview verified counts
from surviving logs, backups, and old daily snapshots before importing them.
A [terminal script](docs/USAGE-HISTORY.md#recover-your-claude-usage) is also included.
- **Used or remaining quota.** Display provider windows as usage consumed or
quota remaining.
- **Approaching-limit alerts.** Optional warning and critical thresholds tint
Expand Down
51 changes: 51 additions & 0 deletions Resources/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -226,3 +226,54 @@
"%@ account options" = "%@ account options";
"Sign in with Claude" = "Sign in with Claude";
"Open Claude Code and run /login." = "Open Claude Code and run /login.";

"Usage history could not be saved. Check available disk space and try refreshing." = "Usage history could not be saved. Check available disk space and try refreshing.";

"Recover Claude usage" = "Recover Claude usage";
"Recover Claude usage…" = "Recover Claude usage…";
"Usage history" = "Usage history";
"Find older Claude counts." = "Find older Claude counts.";
"Find saved counts from Claude Code, Cowork, and older local records." = "Find saved counts from Claude Code, Cowork, and older local records.";
"Scan details" = "Scan details";
"A backup is saved before importing. Original Claude files stay untouched." = "A backup is saved before importing. Original Claude files stay untouched.";
"Cancel" = "Cancel";
"Done" = "Done";
"Scan" = "Scan";
"Scan again" = "Scan again";
"Import recovery" = "Import recovery";
"Saving recovered usage…" = "Saving recovered usage…";
"Scanning saved records…" = "Scanning saved records…";
"Recovery could not finish" = "Recovery could not finish";
"Recovered usage saved" = "Recovered usage saved";
"This usage is already saved" = "This usage is already saved";
"%@ additional tokens saved." = "%@ additional tokens saved.";
"Your usage history now includes the recovered records." = "Your usage history now includes the recovered records.";
"Show backup in Finder" = "Show backup in Finder";
"Review your recovered usage" = "Review your recovered usage";
"No additional usage found" = "No additional usage found";
"Already saved" = "Already saved";
"After recovery" = "After recovery";
"Additional tokens" = "Additional tokens";
"Messages checked: %@ · Daily records: %@" = "Messages checked: %@ · Daily records: %@";
"Totals include cache tokens. Daily records without model details remain unpriced." = "Totals include cache tokens. Daily records without model details remain unpriced.";
"Scan your selection to preview what can be recovered." = "Scan your selection to preview what can be recovered.";
"Original timezone" = "Original timezone";
"Why recover usage?" = "Why recover usage?";
"Older usage can be missing when Claude logs have been deleted or moved. Recovery checks the records still on your Mac and any backups you add. It saves only missing counts, so scanning again won't duplicate your usage." = "Older usage can be missing when Claude logs have been deleted or moved. Recovery checks the records still on your Mac and any backups you add. It saves only missing counts, so scanning again won't duplicate your usage.";
"Why is the original timezone needed?" = "Why is the original timezone needed?";
"Older daily totals used your Mac's timezone without saving its name. Choose the timezone used when those totals were recorded so recovery keeps the correct dates and avoids counting usage twice. Individual message records don't need this." = "Older daily totals used your Mac's timezone without saving its name. Choose the timezone used when those totals were recorded so recovery keeps the correct dates and avoids counting usage twice. Individual message records don't need this.";
"Not specified" = "Not specified";
"Some old daily totals need their original timezone. Choose where they were recorded, then scan again." = "Some old daily totals need their original timezone. Choose where they were recorded, then scan again.";
"Only needed for old daily totals. Leave unspecified if you don't know where they were recorded." = "Only needed for old daily totals. Leave unspecified if you don't know where they were recorded.";
"Older backups (optional)" = "Older backups (optional)";
"Add an extracted Claude projects folder or an old CodexIsland preferences file." = "Add an extracted Claude projects folder or an old CodexIsland preferences file.";
"Add projects folder…" = "Add projects folder…";
"Add preferences file…" = "Add preferences file…";
"Remove %@" = "Remove %@";
"Choose old CodexIsland preferences" = "Choose old CodexIsland preferences";
"Choose a Claude projects folder" = "Choose a Claude projects folder";
"Choose a valid timezone, then scan again." = "Choose a valid timezone, then scan again.";
"Could not read or save usage history. Check file access and available disk space, then scan again." = "Could not read or save usage history. Check file access and available disk space, then scan again.";
"The usage archive changed. Scan again before saving." = "The usage archive changed. Scan again before saving.";
"A selected recovery source is no longer available. Choose it again, then scan." = "A selected recovery source is no longer available. Choose it again, then scan.";
"Saved token counts exceed the supported range. Check the recovery files before trying again." = "Saved token counts exceed the supported range. Check the recovery files before trying again.";
51 changes: 51 additions & 0 deletions Resources/zh-Hans.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -226,3 +226,54 @@
"%@ account options" = "%@ 账户选项";
"Sign in with Claude" = "登录 Claude";
"Open Claude Code and run /login." = "打开 Claude Code 并运行 /login。";

"Usage history could not be saved. Check available disk space and try refreshing." = "无法保存用量历史。请检查可用磁盘空间,然后尝试刷新。";

"Recover Claude usage" = "恢复 Claude 用量";
"Recover Claude usage…" = "恢复 Claude 用量…";
"Usage history" = "用量历史";
"Find older Claude counts." = "查找较早的 Claude 用量记录。";
"Find saved counts from Claude Code, Cowork, and older local records." = "从 Claude Code、Cowork 和较早的本地记录中找回用量。";
"Scan details" = "扫描详情";
"A backup is saved before importing. Original Claude files stay untouched." = "导入前会保存备份,原始 Claude 文件不会被修改。";
"Cancel" = "取消";
"Done" = "完成";
"Scan" = "扫描";
"Scan again" = "重新扫描";
"Import recovery" = "导入恢复记录";
"Saving recovered usage…" = "正在保存恢复的用量…";
"Scanning saved records…" = "正在扫描已有记录…";
"Recovery could not finish" = "无法完成恢复";
"Recovered usage saved" = "已保存恢复的用量";
"This usage is already saved" = "这些用量已经保存";
"%@ additional tokens saved." = "额外保存了 %@ 个 token。";
"Your usage history now includes the recovered records." = "用量历史现已包含恢复的记录。";
"Show backup in Finder" = "在访达中显示备份";
"Review your recovered usage" = "核对恢复的用量";
"No additional usage found" = "未发现额外用量";
"Already saved" = "已保存";
"After recovery" = "恢复后";
"Additional tokens" = "新增 token";
"Messages checked: %@ · Daily records: %@" = "已检查消息:%@ · 每日记录:%@";
"Totals include cache tokens. Daily records without model details remain unpriced." = "总量包含缓存 token。缺少模型信息的每日记录不会估算价格。";
"Scan your selection to preview what can be recovered." = "扫描所选来源,预览可恢复的用量。";
"Original timezone" = "原始时区";
"Why recover usage?" = "为什么需要恢复用量?";
"Older usage can be missing when Claude logs have been deleted or moved. Recovery checks the records still on your Mac and any backups you add. It saves only missing counts, so scanning again won't duplicate your usage." = "Claude 日志被删除或移动后,较早的用量可能会缺失。恢复功能会检查 Mac 上保留的记录以及你添加的备份,只保存缺失的用量,因此重复扫描不会重复计数。";
"Why is the original timezone needed?" = "为什么需要原始时区?";
"Older daily totals used your Mac's timezone without saving its name. Choose the timezone used when those totals were recorded so recovery keeps the correct dates and avoids counting usage twice. Individual message records don't need this." = "较早的每日汇总按当时 Mac 的时区保存,但未记录时区名称。请选择记录这些汇总时所用的时区,以保留正确的日期并避免重复计数。单条消息记录不需要填写时区。";
"Not specified" = "未指定";
"Some old daily totals need their original timezone. Choose where they were recorded, then scan again." = "部分较早的每日记录需要原始时区。请选择记录时所在的时区,然后重新扫描。";
"Only needed for old daily totals. Leave unspecified if you don't know where they were recorded." = "仅较早的每日记录需要填写。如果不清楚记录时所在的时区,请保持未指定。";
"Older backups (optional)" = "较早的备份(可选)";
"Add an extracted Claude projects folder or an old CodexIsland preferences file." = "添加已解压的 Claude projects 文件夹,或较早的 CodexIsland 偏好设置文件。";
"Add projects folder…" = "添加 projects 文件夹…";
"Add preferences file…" = "添加偏好设置文件…";
"Remove %@" = "移除 %@";
"Choose old CodexIsland preferences" = "选择较早的 CodexIsland 偏好设置";
"Choose a Claude projects folder" = "选择 Claude projects 文件夹";
"Choose a valid timezone, then scan again." = "请选择有效的时区,然后重新扫描。";
"Could not read or save usage history. Check file access and available disk space, then scan again." = "无法读取或保存用量历史。请检查文件访问权限和可用磁盘空间,然后重新扫描。";
"The usage archive changed. Scan again before saving." = "用量存档已更改。请重新扫描后再保存。";
"A selected recovery source is no longer available. Choose it again, then scan." = "所选恢复来源已不可用。请重新选择后再扫描。";
"Saved token counts exceed the supported range. Check the recovery files before trying again." = "保存的 token 数量超出了支持范围。请检查恢复文件后重试。";
10 changes: 10 additions & 0 deletions Sources/App.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@ import SwiftUI
import AppKit

@main
enum CodexIslandEntryPoint {
@MainActor
static func main() {
if CommandLine.arguments.dropFirst().first == "--recover-claude" {
exit(ClaudeUsageRecovery.run(arguments: Array(CommandLine.arguments.dropFirst(2))))
}
CodexIslandApp.main()
}
}

struct CodexIslandApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
var body: some Scene {
Expand Down
6 changes: 3 additions & 3 deletions Sources/Cost/AntigravityLogReader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ enum AntigravityLogReader {
let event: TokenEvent
}

static func scan(lookbackDays: Int = 30, root: URL? = nil, now: Date = Date()) -> LocalCostScan {
static func scan(lookbackDays: Int? = 30, root: URL? = nil, now: Date = Date()) -> LocalCostScan {
let root = root ?? FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".gemini/antigravity-cli/conversations", isDirectory: true)
var result = LocalCostScan()
Expand All @@ -16,7 +16,7 @@ enum AntigravityLogReader {
result.unreadableFiles = 1
return result
}
let cutoff = now.addingTimeInterval(-Double(lookbackDays) * 86400)
let cutoff = lookbackDays.map { now.addingTimeInterval(-Double($0) * 86400) } ?? .distantPast
var seen = Set<String>()
for file in files.filter({ $0.pathExtension == "db" }).sorted(by: { $0.path < $1.path }) {
do {
Expand Down Expand Up @@ -49,7 +49,7 @@ enum AntigravityLogReader {
let id = messageID.flatMap { $0.isEmpty ? nil : $0 } ?? fallbackID
return Record(id: id, event: TokenEvent(provider: .antigravity, timestamp: date,
model: model, inputTokens: input, outputTokens: output,
cacheCreationTokens: cacheWrite, cacheReadTokens: cacheRead))
cacheCreationTokens: cacheWrite, cacheReadTokens: cacheRead, recordID: id))
}

private enum ReadError: Error { case database }
Expand Down
61 changes: 45 additions & 16 deletions Sources/Cost/ClaudeLogReader.swift
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import Foundation

/// Walks the local Claude Code session JSONL files and emits TokenEvents for
/// every assistant message that recorded usage. Mirrors ccusage's data path:
/// every assistant message that recorded usage:
/// - reads from ~/.claude/projects/**/*.jsonl AND ~/.config/claude/projects/**/*.jsonl
/// - honors CLAUDE_CONFIG_DIR (comma-separated) when set
/// - includes locally mirrored Cowork project logs in the default search
/// - dedupes by `messageId:requestId`
/// - skips synthetic placeholder models
///
Expand All @@ -15,38 +16,51 @@ enum ClaudeLogReader {
/// Walk the configured project roots and return every usage-bearing
/// assistant turn from the last `lookbackDays` days. Pure file IO; no
/// network. Safe to call from a background thread.
static func scan(lookbackDays: Int = 30) -> [TokenEvent] {
let cutoff = Date().addingTimeInterval(-Double(lookbackDays) * 86400)
var seen = Set<String>()
static func scan(lookbackDays: Int? = 30, roots: [URL]? = nil, requireStableIDs: Bool = false) -> [TokenEvent] {
let cutoff = lookbackDays.map { Date().addingTimeInterval(-Double($0) * 86400) } ?? .distantPast
var positions: [String: Int] = [:]
var occurrences: [String: Int] = [:]
var out: [TokenEvent] = []

LogParseCache.walk(
roots: projectRoots(),
roots: roots ?? projectRoots(),
cutoff: cutoff,
cacheFilename: "claude-parse-cache.v1.json",
cacheVersion: cacheVersion,
useCache: roots == nil,
parse: parseFile(at:),
emit: { (ev: CachedEvent) in
guard ev.timestamp >= cutoff else { return }
if !ev.dedupKey.isEmpty {
if seen.contains(ev.dedupKey) { return }
seen.insert(ev.dedupKey)
}
out.append(TokenEvent(
emit: { (ev: CachedEvent, file: URL) in
guard ev.timestamp >= cutoff, !requireStableIDs || !ev.dedupKey.isEmpty else { return }
let event = TokenEvent(
provider: .claude,
timestamp: ev.timestamp,
model: ev.model,
inputTokens: ev.inputTokens,
outputTokens: ev.outputTokens,
cacheCreationTokens: ev.cacheCreationTokens,
cacheReadTokens: ev.cacheReadTokens
))
cacheReadTokens: ev.cacheReadTokens,
recordID: ev.dedupKey.isEmpty
? LogParseCache.recordID(file: file, timestamp: ev.timestamp, occurrences: &occurrences)
: ev.dedupKey
)
if !ev.dedupKey.isEmpty, let position = positions[ev.dedupKey] {
let previous = out[position]
// Streaming rows can repeat a call with more complete usage. Keep an actual row, never a synthetic sum.
if event.model == previous.model,
event.inputTokens >= previous.inputTokens, event.outputTokens >= previous.outputTokens,
event.cacheCreationTokens >= previous.cacheCreationTokens, event.cacheReadTokens >= previous.cacheReadTokens {
out[position] = event
}
} else {
if !ev.dedupKey.isEmpty { positions[ev.dedupKey] = out.count }
out.append(event)
}
}
)
return out
}

private static func projectRoots() -> [URL] {
static func projectRoots() -> [URL] {
if let env = ProcessInfo.processInfo.environment["CLAUDE_CONFIG_DIR"], !env.isEmpty {
return env.split(separator: ",").map {
URL(fileURLWithPath: String($0).trimmingCharacters(in: .whitespaces))
Expand All @@ -57,7 +71,22 @@ enum ClaudeLogReader {
return [
home.appendingPathComponent(".claude/projects", isDirectory: true),
home.appendingPathComponent(".config/claude/projects", isDirectory: true),
].filter { FileManager.default.fileExists(atPath: $0.path) }
].filter { FileManager.default.fileExists(atPath: $0.path) } + desktopProjectRoots(home: home)
}

private static func desktopProjectRoots(home: URL) -> [URL] {
let root = home.appendingPathComponent("Library/Application Support/Claude/local-agent-mode-sessions", isDirectory: true)
guard let enumerator = FileManager.default.enumerator(at: root,
includingPropertiesForKeys: [.isDirectoryKey],
options: [.skipsPackageDescendants]) else { return [] }
var roots: [URL] = []
for case let url as URL in enumerator where url.lastPathComponent == "projects"
&& url.deletingLastPathComponent().lastPathComponent == ".claude" {
guard (try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true else { continue }
roots.append(url)
enumerator.skipDescendants()
}
return roots
}

/// Parse a single file end-to-end. Caller is responsible for cutoff
Expand Down
13 changes: 8 additions & 5 deletions Sources/Cost/CodexLogReader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,20 @@ import Foundation
/// keyed by (path, mtime, size). Between two 5/15/30-minute polls almost no
/// rollout file has changed, so the steady-state refresh skips re-parsing.
enum CodexLogReader {
static func scan(lookbackDays: Int = 30) -> [TokenEvent] {
let cutoff = Date().addingTimeInterval(-Double(lookbackDays) * 86400)
static func scan(lookbackDays: Int? = 30, root: URL? = nil) -> [TokenEvent] {
let cutoff = lookbackDays.map { Date().addingTimeInterval(-Double($0) * 86400) } ?? .distantPast
var out: [TokenEvent] = []
var occurrences: [String: Int] = [:]

LogParseCache.walk(
roots: [sessionsRoot()],
roots: [root ?? sessionsRoot()],
cutoff: cutoff,
cacheFilename: "codex-parse-cache.v1.json",
cacheVersion: cacheVersion,
useCache: root == nil,
fileFilter: { $0.lastPathComponent.hasPrefix("rollout-") },
parse: parseFile(at:),
emit: { (ev: CachedEvent) in
emit: { (ev: CachedEvent, file: URL) in
guard ev.timestamp >= cutoff else { return }
out.append(TokenEvent(
provider: .codex,
Expand All @@ -33,7 +35,8 @@ enum CodexLogReader {
inputTokens: ev.inputTokens,
outputTokens: ev.outputTokens,
cacheCreationTokens: 0,
cacheReadTokens: ev.cacheReadTokens
cacheReadTokens: ev.cacheReadTokens,
recordID: LogParseCache.recordID(file: file, timestamp: ev.timestamp, occurrences: &occurrences)
))
}
)
Expand Down
Loading
Loading