diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 8b25174..988dec5 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -8,6 +8,9 @@ on:
jobs:
build:
runs-on: macos-15
+ env:
+ # Test fixtures encode day boundaries in this zone.
+ TOKENSTEP_TIMEZONE: Asia/Shanghai
steps:
- uses: actions/checkout@v4
- name: Check release safety contract
@@ -20,5 +23,11 @@ jobs:
run: ./script/test_codex_cumulative_collector.sh
- name: Run accounting migration checks
run: ./script/test_usage_recalibration_migration.sh
+ - name: Run time zone collector checks
+ run: ./script/test_time_zone_collector.sh
+ - name: Run Claude incremental collector checks
+ run: ./script/test_claude_incremental_collector.sh
+ - name: Run settings Codable checks
+ run: ./script/test_settings_codable.sh
- name: Build TokenStep
run: ./script/build_swiftui_and_run.sh --no-launch
diff --git a/.gitignore b/.gitignore
index f6f52bd..8b501fd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,14 +2,15 @@
__pycache__/
*.pyc
-logs/
-data/
-dashboard.html
-config/settings.json
-config/autostart-default-applied
+# Runtime output of the legacy Python prototype (see legacy/README.md).
+legacy/logs/
+legacy/data/
+legacy/dashboard.html
+legacy/config/settings.json
+legacy/config/autostart-default-applied
+legacy/TokenUsageMenuApp/dist/
TokenStepSwift/.build/
TokenStepSwift/dist/
-TokenUsageMenuApp/dist/
release/
docs/validation/
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..8f1f400
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,131 @@
+# 更新日志
+
+TokenStep 各版本的更新内容,新版本在前。每个版本的完整说明在 [docs/RELEASE_NOTES_<版本号>.md](docs/)。
+
+## 0.2.15 更新:按本地时区统计,采集更稳更省电
+
+每天的用量改为按 Mac 的系统时区切分,更换时区后自动重新统计;修复 Codex 从本地数据库回退统计时可能卡住的问题;Claude Code 长会话只读取新增内容,读取本地数据库不再启动额外进程;设置中填写的密钥不再出现在进程列表里;隐私页列出每一个联网请求。详见 [0.2.15 发布说明](docs/RELEASE_NOTES_0.2.15.md)。
+
+## 0.2.14 更新:恢复 AgentWork 榜单关联
+
+兼容 Token Rank 新版状态文件中的账号信息,同时保留旧版格式支持。已绑定的用户升级后可重新识别身份,无需删除状态文件或重新绑定。详见 [0.2.14 发布说明](docs/RELEASE_NOTES_0.2.14.md)。
+
+## 0.2.13 更新:修复自动更新的 Apple 公证门禁
+
+0.2.13 修复了 0.2.12 因发布包遗漏 Apple 公证票据而无法自动安装的问题。新版已经过 Developer ID 签名、Apple 公证、App/DMG 票据装订、macOS 分发策略检查和隔离更新安装验证;不需要关闭 Gatekeeper 或更改系统安全设置。发布流程也改为“先草稿上传、回下载验收,再公开”,未公证产物无法进入公开 Release。完整说明见 [0.2.13 发布说明](docs/RELEASE_NOTES_0.2.13.md)。
+
+## 0.2.12 更新:找回特洛伊火海的火焰动效
+
+0.2.12 找回了 0.2.11 中意外丢失的「特洛伊火海」火焰动效:火焰 shader 改由场景更新循环驱动的自定义时钟供能,不再依赖在常驻渲染架构下会冻结的内置时间变量。火焰在每次打开浮层时持续燃烧,多次开关后依然保持动画,0.2.11 修复的开浮层闪烁不会回归。完整说明见 [0.2.12 发布说明](docs/RELEASE_NOTES_0.2.12.md)。
+
+## 0.2.11 更新:修复特洛伊火海浮层闪烁
+
+0.2.11 修复了奥德赛「特洛伊火海」篇章打开菜单栏浮层时偶发的一帧闪烁。动态渲染层现在常驻并随窗口可见性暂停、恢复,不再在点击浮层后临时插入;截图稳定性、省电暂停以及其他主题行为保持不变。完整说明见 [0.2.11 发布说明](docs/RELEASE_NOTES_0.2.11.md)。
+
+## 0.2.10 更新:坠入黑洞的运镜方向
+
+0.2.10 修正了坠落模式的逼近方向:膨胀锚点从画面中部移到**黑洞本体**(左上视界阴影处),让黑洞成为整个画面的放射原点——所有元素从它向外流走,右侧弧线不断放大、冲出画面右缘,弧顶顺流右移,读起来就是"视角正在被吸进去",而不是画面整体向上顶。
+
+
+
+
+
+速度与强度维持 0.2.9 的节奏(9 秒吞没、亮度渐强 +16%),帧差指标进一步提升:前 3 秒全画面 31.5%、全程 69.1% 的像素发生明显变化。其余模式、主题与数据口径不变。完整说明见 [0.2.10 发布说明](docs/RELEASE_NOTES_0.2.10.md)。
+
+## 0.2.9 重大更新:引力动效实验室
+
+0.2.9 给「引力边界」主题装上了真正的引力动效。黑洞不再是一张静态壁纸:打开浮层或仪表盘的瞬间,你就在朝它坠落。
+
+
+
+
+
+- **三档签名动效,浮层右上角随时切换**:静谧(只剩环境光呼吸)、轨道(白热等离子光斑拖着彗尾沿吸积盘弧线绕行,星尘坠落)、坠落(黑洞以自身为中心原地膨胀、越来越亮,9 秒内把你吞进去后悬置)。
+- **Token 坠落脉冲**:坠落模式下,今日 Token 增长或点击浮层波形按钮,会从视界方向涌出一圈进食波。
+- **每次打开都是一次新的坠落**:浮层关闭即复位,重新打开从头开始逼近;窗口失焦、被遮挡时动效自动暂停省电。
+- **低成本有底线**:最高 24 fps;低电量模式降到 12 fps 并冻结逼近;截图和 macOS「减少动态效果」下保持完全静态;所有动效由纯函数采样驱动,可用固定时间离线渲染逐帧校验。
+- **可感但克制**:静谧档保持接近静态的呼吸;轨道档持续有可察觉的流动;只有坠落档全力逼近。
+
+
+
+
+
+本次不改变 Token、金额、额度、排行榜及本地采集口径,经典与奥德赛主题不受影响。完整说明见 [0.2.9 发布说明](docs/RELEASE_NOTES_0.2.9.md)。
+
+## 0.2.8 更新:引力边界图标精修
+
+0.2.8 精修了「设置 → 通用 → 主题皮肤包」中的引力边界预览图标:黑洞、吸积盘和上下引力透镜弧重新居中并收进图标安全区,同时增加圆角裁切,修复图形穿出卡片边界的问题。
+
+本次只调整主题选择图标,不改变首页、浮层和其他界面的黑洞主视觉,也不改变 Token、金额、额度、排行榜及本地采集口径。完整说明见 [0.2.8 发布说明](docs/RELEASE_NOTES_0.2.8.md)。
+
+## 0.2.7 重大更新:引力边界黑洞主题包
+
+0.2.7 新增第三套完整皮肤包「引力边界」。它不是给首页换一张壁纸,而是把近距事件视界、象牙白吸积盘和低速引力光流接进 TokenStep 的整套界面。
+
+
+
+
+
+- **第三套主题皮肤包**:在 `设置 → 通用 → 主题皮肤包` 中,可在经典、奥德赛和引力边界之间随时切换。
+- **黑洞是第一视觉层**:巨大事件视界占据左上,象牙白吸积盘横贯界面,并保留上方透镜弧和更暗的下方反转弧。
+- **全界面统一换肤**:菜单栏浮层、今日、历史、隐私、设置、更新窗口、Token Island 和两类分享卡全部覆盖。
+- **低成本动态光流**:吸积盘有缓慢相位漂移和轻微呼吸;窗口失焦、截图和 macOS“减少动态效果”状态会自动暂停,最高 24 fps。
+- **原创应用内标记**:主题启用时,TokenStep 的应用内 Logo 会切换为事件视界与吸积盘标记。
+- **更新检查更稳**:GitHub API 被限流时自动使用非 API 的最新 Release 通道;手动检查增加 10 秒防连点,并在双通道都失败时显示可理解的限流恢复时间。
+
+主题使用原创生成背景和通用黑洞科学结构,不包含电影 Logo、演员、剧照、飞船或第三方水印。完整说明见 [0.2.7 发布说明](docs/RELEASE_NOTES_0.2.7.md) 与 [引力边界主题实现说明](docs/INTERSTELLAR_THEME_PACK_0.2.7.md)。
+
+## 0.2.6 重大更新:检查更新后立即进入安装闭环
+
+0.2.6 修复了从浮层主动检查更新时“查到了,但没有继续弹出更新窗口”的断点,并把下载、验证、替换与自动重启做成可追踪的完整链路。
+
+- **发现新版立即弹窗**:点击浮层或主窗口的更新按钮后,若检测到新版,会立刻打开独立更新窗口。
+- **更新窗口确保可见**:菜单栏瞬时浮层自动收起,更新窗口切到当前桌面并保持在前台,不再被浮层遮住。
+- **一次点击完成升级**:点击“安装并重启”后自动下载 DMG、校验签名、公证与版本,备份旧 App、替换 `/Applications/TokenStep.app` 并重新打开。
+- **全过程可诊断**:检查、弹窗、安装启动和失败都会写入生命周期日志;新增隔离安装验证脚本,避免把“能下载”等同于“能升级”。
+- **不改变自动检查策略**:后台检查仍采用非打扰提醒;只有用户主动点击检查并发现新版时才强制弹出窗口。
+
+本次不改变 Token、金额、额度、排行榜及本地采集口径。完整说明见 [0.2.6 发布说明](docs/RELEASE_NOTES_0.2.6.md)。
+
+## 0.2.5 重大更新:动态特洛伊火焰、模型用量与更新提醒
+
+0.2.5 让奥德赛主题从静态电影画面进入动态状态,同时把菜单栏浮层和更新体验补得更完整。
+
+- **特洛伊火焰动起来了**:特洛伊火海篇章会在浮层可见时呈现火焰明暗、烟雾、火星和余烬;关闭浮层后立即暂停,截图仍保持稳定静态画面。
+- **浮层增加今日模型用量**:在 Agent 来源下方展示今日模型;模型较多时自动整理为 Top 3 +「其他」,不改变统计口径。
+- **更新入口覆盖整个 App**:菜单栏浮层、主窗口和设置共用同一套检查状态;启动、定时和回到前台时可自动检查新版。
+- **更新提醒更明确**:发现新版后,菜单栏出现标记,浮层和主窗口显示更新卡;仍由用户确认后安装,不做静默强制更新。
+- **修复检查动画不停止**:检查完成后立即显示结果,不再残留一直旋转的图标。
+
+经典主题、其他奥德赛篇章、Token/金额/额度/排行榜口径及本地优先原则保持不变。完整说明见 [0.2.5 发布说明](docs/RELEASE_NOTES_0.2.5.md)。
+
+## 0.2.4 重大更新:奥德赛主题包
+
+TokenStep 第一次从“更换配色”升级为完整的**主题皮肤包系统**。你可以继续使用熟悉的经典界面,也可以切换到更有电影质感的奥德赛主题;数据、统计口径与本地优先原则保持不变。
+
+
+
+
+
+### 两套皮肤包,随时切换
+
+- **经典**:保留青绿、海蓝、紫藤、琥珀和石墨五种原版配色。
+- **奥德赛**:新增导演剪辑、爱琴海冷雾、特洛伊火海和灰烬神像四个视觉篇章。
+- TokenStep 会分别记住你上次使用的经典配色和奥德赛篇章,来回切换不用重新设置。
+
+| 视觉篇章 | 核心元素 |
+| --- | --- |
+| 导演剪辑 | 根据不同界面自动组合冷雾头盔、特洛伊木马与灰烬神像 |
+| 爱琴海冷雾 | 深海、冷雾、青铜头盔与竖向骨节冠 |
+| 特洛伊火海 | 焦黑木马、火焰背光、烟尘与余烬 |
+| 灰烬神像 | 破损大理石战士、裂纹、玄武岩与希腊回纹 |
+
+### 不只是换一张背景图
+
+- 菜单栏浮层重构为“今日用量 / Agent 用量 / 订阅额度”三段式电影构图。
+- Agent 消耗榜收为底部横向信息带,不再形成第五列或把浮层横向撑宽。
+- Today、历史、隐私、设置、更新窗口、分享卡与 Token Island 全面换肤。
+- 新增奥德赛弓箭阶梯 Logo;用量采用骨金、冷金或余烬橙,绿色只保留给同步成功等状态反馈。
+- 关闭排行榜后浮层会自动收短,多来源额度则使用紧凑布局完整展示。
+
+打开 `设置 → 通用 → 主题皮肤包` 即可切换。完整说明见 [0.2.4 发布说明](docs/RELEASE_NOTES_0.2.4.md),或直接[下载已签名并通过 Apple 公证的最新版](https://github.com/Backtthefuture/TokenStep/releases/latest/download/TokenStep-0.2.14.dmg)。
diff --git a/README.md b/README.md
index 3864ead..9fdb25e 100644
--- a/README.md
+++ b/README.md
@@ -22,7 +22,7 @@ TokenStep 是一个 macOS 菜单栏 App,用来本地统计你在 Codex、Claud
下载最新版 DMG,打开后把 `TokenStep.app` 拖进「应用程序」即可使用:
-[下载 TokenStep 最新版](https://github.com/Backtthefuture/TokenStep/releases/latest/download/TokenStep-0.2.14.dmg)
+[下载 TokenStep 最新版](https://github.com/Backtthefuture/TokenStep/releases/latest/download/TokenStep-0.2.15.dmg)
也可以从 Release 页面查看所有版本:
@@ -32,129 +32,11 @@ TokenStep 已使用 Developer ID 签名并通过 Apple 公证。首次打开时
Windows版本由十七做了移植,欢迎大家前往使用:https://github.com/canyexuanfan/TokenStep-Windows/releases
-## 0.2.14 更新:恢复 AgentWork 榜单关联
+## 最新版本:0.2.15
-兼容 Token Rank 新版状态文件中的账号信息,同时保留旧版格式支持。已绑定的用户升级后可重新识别身份,无需删除状态文件或重新绑定。详见 [0.2.14 发布说明](docs/RELEASE_NOTES_0.2.14.md)。
+按 Mac 的系统时区统计每天的用量,修复 Codex 回退统计时可能卡住的问题,后台采集更省电,隐私页列出每一个联网请求。详见 [0.2.15 发布说明](docs/RELEASE_NOTES_0.2.15.md)。
-## 0.2.13 更新:修复自动更新的 Apple 公证门禁
-
-0.2.13 修复了 0.2.12 因发布包遗漏 Apple 公证票据而无法自动安装的问题。新版已经过 Developer ID 签名、Apple 公证、App/DMG 票据装订、macOS 分发策略检查和隔离更新安装验证;不需要关闭 Gatekeeper 或更改系统安全设置。发布流程也改为“先草稿上传、回下载验收,再公开”,未公证产物无法进入公开 Release。完整说明见 [0.2.13 发布说明](docs/RELEASE_NOTES_0.2.13.md)。
-
-## 0.2.12 更新:找回特洛伊火海的火焰动效
-
-0.2.12 找回了 0.2.11 中意外丢失的「特洛伊火海」火焰动效:火焰 shader 改由场景更新循环驱动的自定义时钟供能,不再依赖在常驻渲染架构下会冻结的内置时间变量。火焰在每次打开浮层时持续燃烧,多次开关后依然保持动画,0.2.11 修复的开浮层闪烁不会回归。完整说明见 [0.2.12 发布说明](docs/RELEASE_NOTES_0.2.12.md)。
-
-## 0.2.11 更新:修复特洛伊火海浮层闪烁
-
-0.2.11 修复了奥德赛「特洛伊火海」篇章打开菜单栏浮层时偶发的一帧闪烁。动态渲染层现在常驻并随窗口可见性暂停、恢复,不再在点击浮层后临时插入;截图稳定性、省电暂停以及其他主题行为保持不变。完整说明见 [0.2.11 发布说明](docs/RELEASE_NOTES_0.2.11.md)。
-
-## 0.2.10 更新:坠入黑洞的运镜方向
-
-0.2.10 修正了坠落模式的逼近方向:膨胀锚点从画面中部移到**黑洞本体**(左上视界阴影处),让黑洞成为整个画面的放射原点——所有元素从它向外流走,右侧弧线不断放大、冲出画面右缘,弧顶顺流右移,读起来就是"视角正在被吸进去",而不是画面整体向上顶。
-
-
-
-
-
-速度与强度维持 0.2.9 的节奏(9 秒吞没、亮度渐强 +16%),帧差指标进一步提升:前 3 秒全画面 31.5%、全程 69.1% 的像素发生明显变化。其余模式、主题与数据口径不变。完整说明见 [0.2.10 发布说明](docs/RELEASE_NOTES_0.2.10.md)。
-
-## 0.2.9 重大更新:引力动效实验室
-
-0.2.9 给「引力边界」主题装上了真正的引力动效。黑洞不再是一张静态壁纸:打开浮层或仪表盘的瞬间,你就在朝它坠落。
-
-
-
-
-
-- **三档签名动效,浮层右上角随时切换**:静谧(只剩环境光呼吸)、轨道(白热等离子光斑拖着彗尾沿吸积盘弧线绕行,星尘坠落)、坠落(黑洞以自身为中心原地膨胀、越来越亮,9 秒内把你吞进去后悬置)。
-- **Token 坠落脉冲**:坠落模式下,今日 Token 增长或点击浮层波形按钮,会从视界方向涌出一圈进食波。
-- **每次打开都是一次新的坠落**:浮层关闭即复位,重新打开从头开始逼近;窗口失焦、被遮挡时动效自动暂停省电。
-- **低成本有底线**:最高 24 fps;低电量模式降到 12 fps 并冻结逼近;截图和 macOS「减少动态效果」下保持完全静态;所有动效由纯函数采样驱动,可用固定时间离线渲染逐帧校验。
-- **可感但克制**:静谧档保持接近静态的呼吸;轨道档持续有可察觉的流动;只有坠落档全力逼近。
-
-
-
-
-
-本次不改变 Token、金额、额度、排行榜及本地采集口径,经典与奥德赛主题不受影响。完整说明见 [0.2.9 发布说明](docs/RELEASE_NOTES_0.2.9.md)。
-
-## 0.2.8 更新:引力边界图标精修
-
-0.2.8 精修了「设置 → 通用 → 主题皮肤包」中的引力边界预览图标:黑洞、吸积盘和上下引力透镜弧重新居中并收进图标安全区,同时增加圆角裁切,修复图形穿出卡片边界的问题。
-
-本次只调整主题选择图标,不改变首页、浮层和其他界面的黑洞主视觉,也不改变 Token、金额、额度、排行榜及本地采集口径。完整说明见 [0.2.8 发布说明](docs/RELEASE_NOTES_0.2.8.md)。
-
-## 0.2.7 重大更新:引力边界黑洞主题包
-
-0.2.7 新增第三套完整皮肤包「引力边界」。它不是给首页换一张壁纸,而是把近距事件视界、象牙白吸积盘和低速引力光流接进 TokenStep 的整套界面。
-
-
-
-
-
-- **第三套主题皮肤包**:在 `设置 → 通用 → 主题皮肤包` 中,可在经典、奥德赛和引力边界之间随时切换。
-- **黑洞是第一视觉层**:巨大事件视界占据左上,象牙白吸积盘横贯界面,并保留上方透镜弧和更暗的下方反转弧。
-- **全界面统一换肤**:菜单栏浮层、今日、历史、隐私、设置、更新窗口、Token Island 和两类分享卡全部覆盖。
-- **低成本动态光流**:吸积盘有缓慢相位漂移和轻微呼吸;窗口失焦、截图和 macOS“减少动态效果”状态会自动暂停,最高 24 fps。
-- **原创应用内标记**:主题启用时,TokenStep 的应用内 Logo 会切换为事件视界与吸积盘标记。
-- **更新检查更稳**:GitHub API 被限流时自动使用非 API 的最新 Release 通道;手动检查增加 10 秒防连点,并在双通道都失败时显示可理解的限流恢复时间。
-
-主题使用原创生成背景和通用黑洞科学结构,不包含电影 Logo、演员、剧照、飞船或第三方水印。完整说明见 [0.2.7 发布说明](docs/RELEASE_NOTES_0.2.7.md) 与 [引力边界主题实现说明](docs/INTERSTELLAR_THEME_PACK_0.2.7.md)。
-
-## 0.2.6 重大更新:检查更新后立即进入安装闭环
-
-0.2.6 修复了从浮层主动检查更新时“查到了,但没有继续弹出更新窗口”的断点,并把下载、验证、替换与自动重启做成可追踪的完整链路。
-
-- **发现新版立即弹窗**:点击浮层或主窗口的更新按钮后,若检测到新版,会立刻打开独立更新窗口。
-- **更新窗口确保可见**:菜单栏瞬时浮层自动收起,更新窗口切到当前桌面并保持在前台,不再被浮层遮住。
-- **一次点击完成升级**:点击“安装并重启”后自动下载 DMG、校验签名、公证与版本,备份旧 App、替换 `/Applications/TokenStep.app` 并重新打开。
-- **全过程可诊断**:检查、弹窗、安装启动和失败都会写入生命周期日志;新增隔离安装验证脚本,避免把“能下载”等同于“能升级”。
-- **不改变自动检查策略**:后台检查仍采用非打扰提醒;只有用户主动点击检查并发现新版时才强制弹出窗口。
-
-本次不改变 Token、金额、额度、排行榜及本地采集口径。完整说明见 [0.2.6 发布说明](docs/RELEASE_NOTES_0.2.6.md)。
-
-## 0.2.5 重大更新:动态特洛伊火焰、模型用量与更新提醒
-
-0.2.5 让奥德赛主题从静态电影画面进入动态状态,同时把菜单栏浮层和更新体验补得更完整。
-
-- **特洛伊火焰动起来了**:特洛伊火海篇章会在浮层可见时呈现火焰明暗、烟雾、火星和余烬;关闭浮层后立即暂停,截图仍保持稳定静态画面。
-- **浮层增加今日模型用量**:在 Agent 来源下方展示今日模型;模型较多时自动整理为 Top 3 +「其他」,不改变统计口径。
-- **更新入口覆盖整个 App**:菜单栏浮层、主窗口和设置共用同一套检查状态;启动、定时和回到前台时可自动检查新版。
-- **更新提醒更明确**:发现新版后,菜单栏出现标记,浮层和主窗口显示更新卡;仍由用户确认后安装,不做静默强制更新。
-- **修复检查动画不停止**:检查完成后立即显示结果,不再残留一直旋转的图标。
-
-经典主题、其他奥德赛篇章、Token/金额/额度/排行榜口径及本地优先原则保持不变。完整说明见 [0.2.5 发布说明](docs/RELEASE_NOTES_0.2.5.md)。
-
-## 0.2.4 重大更新:奥德赛主题包
-
-TokenStep 第一次从“更换配色”升级为完整的**主题皮肤包系统**。你可以继续使用熟悉的经典界面,也可以切换到更有电影质感的奥德赛主题;数据、统计口径与本地优先原则保持不变。
-
-
-
-
-
-### 两套皮肤包,随时切换
-
-- **经典**:保留青绿、海蓝、紫藤、琥珀和石墨五种原版配色。
-- **奥德赛**:新增导演剪辑、爱琴海冷雾、特洛伊火海和灰烬神像四个视觉篇章。
-- TokenStep 会分别记住你上次使用的经典配色和奥德赛篇章,来回切换不用重新设置。
-
-| 视觉篇章 | 核心元素 |
-| --- | --- |
-| 导演剪辑 | 根据不同界面自动组合冷雾头盔、特洛伊木马与灰烬神像 |
-| 爱琴海冷雾 | 深海、冷雾、青铜头盔与竖向骨节冠 |
-| 特洛伊火海 | 焦黑木马、火焰背光、烟尘与余烬 |
-| 灰烬神像 | 破损大理石战士、裂纹、玄武岩与希腊回纹 |
-
-### 不只是换一张背景图
-
-- 菜单栏浮层重构为“今日用量 / Agent 用量 / 订阅额度”三段式电影构图。
-- Agent 消耗榜收为底部横向信息带,不再形成第五列或把浮层横向撑宽。
-- Today、历史、隐私、设置、更新窗口、分享卡与 Token Island 全面换肤。
-- 新增奥德赛弓箭阶梯 Logo;用量采用骨金、冷金或余烬橙,绿色只保留给同步成功等状态反馈。
-- 关闭排行榜后浮层会自动收短,多来源额度则使用紧凑布局完整展示。
-
-打开 `设置 → 通用 → 主题皮肤包` 即可切换。完整说明见 [0.2.4 发布说明](docs/RELEASE_NOTES_0.2.4.md),或直接[下载已签名并通过 Apple 公证的最新版](https://github.com/Backtthefuture/TokenStep/releases/latest/download/TokenStep-0.2.14.dmg)。
+历次更新(主题包、引力动效、自动更新等)见 [CHANGELOG.md](CHANGELOG.md)。
## TokenStep 适合谁?
@@ -213,7 +95,7 @@ TokenStep 默认只做本地统计。
## 安装方式
-1. 下载 [TokenStep 最新版 DMG](https://github.com/Backtthefuture/TokenStep/releases/latest/download/TokenStep-0.2.14.dmg)。
+1. 下载 [TokenStep 最新版 DMG](https://github.com/Backtthefuture/TokenStep/releases/latest/download/TokenStep-0.2.15.dmg)。
2. 打开 DMG。
3. 把 `TokenStep.app` 拖到「应用程序」。
4. 启动 TokenStep。
@@ -268,12 +150,23 @@ python3 script/github_download_stats.py
TokenStepSwift/dist/TokenStep.app
```
+运行全部测试:
+
+```bash
+./script/test_all.sh
+```
+
+- 采集器 fixture 检查只依赖 `swiftc`,装 Command Line Tools 就能跑。
+- XCTest 单元测试需要完整的 Xcode(Command Line Tools 不带 XCTest);没装 Xcode 时脚本会跳过这一步并提示。
+- 测试会固定 `TOKENSTEP_TIMEZONE=Asia/Shanghai`,因为 fixture 的日期边界按这个时区编写。App 本身按系统时区切分每天的用量。
+- 如果 `swift build` / `swift test` 在解析 `Package.swift` 时报 `PackageDescription.Package.__allocating_init` 链接错误,说明 Command Line Tools 安装里残留了旧版本的 `PackageDescription` 私有接口文件,重新安装 Command Line Tools 即可。
+
## 发布打包
公开发布强制执行 Developer ID 签名、Apple 公证、票据装订、系统分发检查和隔离安装验证。不再生成可被误上传的“仅签名、未公证”发布包:
```bash
-TOKENSTEP_VERSION=0.2.14 \
+TOKENSTEP_VERSION=0.2.15 \
CODE_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" \
TOKENSTEP_NOTARY_PROFILE="tokenstep-notary" \
./script/package_release.sh --notarize
@@ -289,7 +182,7 @@ release/TokenStep--SHA256SUMS.txt
维护者说明见 [docs/RELEASE.md](docs/RELEASE.md)。
-0.2.13 发布说明见 [docs/RELEASE_NOTES_0.2.13.md](docs/RELEASE_NOTES_0.2.13.md);0.2.12 发布说明见 [docs/RELEASE_NOTES_0.2.12.md](docs/RELEASE_NOTES_0.2.12.md);0.2.11 发布说明见 [docs/RELEASE_NOTES_0.2.11.md](docs/RELEASE_NOTES_0.2.11.md);0.2.10 发布说明见 [docs/RELEASE_NOTES_0.2.10.md](docs/RELEASE_NOTES_0.2.10.md);0.2.9 发布说明见 [docs/RELEASE_NOTES_0.2.9.md](docs/RELEASE_NOTES_0.2.9.md);0.2.8 发布说明见 [docs/RELEASE_NOTES_0.2.8.md](docs/RELEASE_NOTES_0.2.8.md);0.2.7 发布说明见 [docs/RELEASE_NOTES_0.2.7.md](docs/RELEASE_NOTES_0.2.7.md);引力边界实现说明见 [docs/INTERSTELLAR_THEME_PACK_0.2.7.md](docs/INTERSTELLAR_THEME_PACK_0.2.7.md);0.2.6 更新闭环说明见 [docs/RELEASE_NOTES_0.2.6.md](docs/RELEASE_NOTES_0.2.6.md);0.2.4 奥德赛主题包说明见 [docs/ODYSSEY_THEME_PACK_0.2.4.md](docs/ODYSSEY_THEME_PACK_0.2.4.md)。
+各版本发布说明见 [CHANGELOG.md](CHANGELOG.md)。
## 开源协议
diff --git a/TokenStepSwift/Sources/TokenStepSwift/App/TokenStepApp.swift b/TokenStepSwift/Sources/TokenStepSwift/App/TokenStepApp.swift
index cf5bd7f..35144d1 100644
--- a/TokenStepSwift/Sources/TokenStepSwift/App/TokenStepApp.swift
+++ b/TokenStepSwift/Sources/TokenStepSwift/App/TokenStepApp.swift
@@ -13,6 +13,9 @@ final class TokenStepAppDelegate: NSObject, NSApplicationDelegate {
LifecycleLogger.log(
"Application launched pid=\(ProcessInfo.processInfo.processIdentifier), version=\(UpdateService.currentVersion), bundle=\(Bundle.main.bundleURL.path)."
)
+ DispatchQueue.global(qos: .utility).async {
+ DataService.removeStaleAtomicWriteLeftovers()
+ }
if let url = Bundle.main.url(forResource: "TokenStepIcon", withExtension: "icns"),
let icon = NSImage(contentsOf: url) {
NSApp.applicationIconImage = icon
diff --git a/TokenStepSwift/Sources/TokenStepSwift/Models/UsageModels.swift b/TokenStepSwift/Sources/TokenStepSwift/Models/UsageModels.swift
index d76fe9b..b75357d 100644
--- a/TokenStepSwift/Sources/TokenStepSwift/Models/UsageModels.swift
+++ b/TokenStepSwift/Sources/TokenStepSwift/Models/UsageModels.swift
@@ -68,7 +68,7 @@ struct UsageSnapshot: Codable {
static let empty = UsageSnapshot(
generatedAt: nil,
- timezone: "Asia/Shanghai",
+ timezone: TokenStepClock.identifier,
totals: UsageTotals(tokens: 0, cost: 0, activeDays: 0),
daily: [],
rhythms: [],
diff --git a/TokenStepSwift/Sources/TokenStepSwift/Services/Collector/CodexIncrementalStore.swift b/TokenStepSwift/Sources/TokenStepSwift/Services/Collector/CodexIncrementalStore.swift
new file mode 100644
index 0000000..0219d14
--- /dev/null
+++ b/TokenStepSwift/Sources/TokenStepSwift/Services/Collector/CodexIncrementalStore.swift
@@ -0,0 +1,749 @@
+import Foundation
+import SQLite3
+
+enum CodexIncrementalStoreError: LocalizedError {
+ case sqlite(String)
+ case corruptPayload(String)
+ case unstableSource(String)
+ case incompleteCache(expected: Int, actual: Int)
+
+ var errorDescription: String? {
+ switch self {
+ case let .sqlite(message):
+ return "Incremental cache error: \(message)"
+ case let .corruptPayload(context):
+ return "Incremental cache payload is corrupt: \(context)"
+ case .unstableSource:
+ return "A Codex session changed while it was being collected."
+ case let .incompleteCache(expected, actual):
+ return "Incremental cache is incomplete (expected \(expected), got \(actual))."
+ }
+ }
+
+ var shouldRebuildCache: Bool {
+ switch self {
+ case .corruptPayload:
+ return true
+ case let .sqlite(message):
+ let normalized = message.lowercased()
+ return normalized.contains("not a database")
+ || normalized.contains("database disk image is malformed")
+ || normalized.contains("database malformed")
+ case .incompleteCache:
+ return true
+ case .unstableSource:
+ return false
+ }
+ }
+}
+
+final class CodexIncrementalStore {
+ static let schemaVersion: Int32 = 6
+ static let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self)
+
+ private var database: OpaquePointer?
+ private var stagingTransactionActive = false
+
+ static func discardDatabase(at url: URL) {
+ let fileManager = FileManager.default
+ for path in [url.path, url.path + "-wal", url.path + "-shm"] {
+ guard fileManager.fileExists(atPath: path) else { continue }
+ try? fileManager.removeItem(atPath: path)
+ }
+ }
+
+ init(url: URL) throws {
+ try FileManager.default.createDirectory(
+ at: url.deletingLastPathComponent(),
+ withIntermediateDirectories: true
+ )
+ let flags = SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_FULLMUTEX
+ guard sqlite3_open_v2(url.path, &database, flags, nil) == SQLITE_OK else {
+ let message = database.map { String(cString: sqlite3_errmsg($0)) } ?? "open failed"
+ if let database { sqlite3_close(database) }
+ database = nil
+ throw CodexIncrementalStoreError.sqlite(message)
+ }
+ do {
+ sqlite3_busy_timeout(database, 2_000)
+ try execute("PRAGMA journal_mode=WAL")
+ try execute("PRAGMA synchronous=NORMAL")
+ try migrateIfNeeded()
+ try resetIfTimeZoneChanged()
+ } catch {
+ if let database {
+ sqlite3_close(database)
+ }
+ database = nil
+ throw error
+ }
+ }
+
+ deinit {
+ if let database {
+ sqlite3_close(database)
+ }
+ }
+
+ func metadataByPath() throws -> [String: StoredCodexSessionMetadata] {
+ let statement = try prepare(
+ """
+ SELECT path, size, modification_time, fingerprint,
+ validation_fingerprint, session_id
+ FROM codex_sessions
+ """
+ )
+ defer { sqlite3_finalize(statement) }
+ var result = [String: StoredCodexSessionMetadata]()
+ while sqlite3_step(statement) == SQLITE_ROW {
+ guard let path = columnText(statement, index: 0),
+ let fingerprint = columnText(statement, index: 3),
+ let sessionID = columnText(statement, index: 5)
+ else { continue }
+ result[path] = StoredCodexSessionMetadata(
+ size: UInt64(max(0, sqlite3_column_int64(statement, 1))),
+ modificationTime: sqlite3_column_double(statement, 2),
+ fingerprint: fingerprint,
+ validationFingerprint: columnText(statement, index: 4),
+ sessionID: sessionID
+ )
+ }
+ try checkFinalStep(statement)
+ return result
+ }
+
+ func childPaths(parentSessionID: String) throws -> [String] {
+ let statement = try prepare(
+ "SELECT path FROM codex_sessions WHERE parent_session_id = ? ORDER BY path"
+ )
+ defer { sqlite3_finalize(statement) }
+ bind(parentSessionID, to: statement, index: 1)
+ var result = [String]()
+ while sqlite3_step(statement) == SQLITE_ROW {
+ if let path = columnText(statement, index: 0) {
+ result.append(path)
+ }
+ }
+ try checkFinalStep(statement)
+ return result
+ }
+
+ func childPaths(
+ parentSessionID: String,
+ createdAtOnOrAfter timestamp: TimeInterval
+ ) throws -> [String] {
+ let statement = try prepare(
+ """
+ SELECT path FROM codex_sessions
+ WHERE parent_session_id = ?
+ AND (created_at_epoch IS NULL OR created_at_epoch >= ?)
+ ORDER BY path
+ """
+ )
+ defer { sqlite3_finalize(statement) }
+ bind(parentSessionID, to: statement, index: 1)
+ sqlite3_bind_double(statement, 2, timestamp)
+ var result = [String]()
+ while sqlite3_step(statement) == SQLITE_ROW {
+ if let path = columnText(statement, index: 0) {
+ result.append(path)
+ }
+ }
+ try checkFinalStep(statement)
+ return result
+ }
+
+ func anchors(sessionID: String) throws -> [CodexAnchor]? {
+ let statement = try prepare(
+ "SELECT anchors FROM codex_sessions WHERE session_id = ? ORDER BY path LIMIT 1"
+ )
+ defer { sqlite3_finalize(statement) }
+ bind(sessionID, to: statement, index: 1)
+ let status = sqlite3_step(statement)
+ if status == SQLITE_DONE { return nil }
+ guard status == SQLITE_ROW,
+ let data = columnData(statement, index: 0)
+ else {
+ throw currentError()
+ }
+ return try decode([CodexAnchor].self, from: data, context: "anchors")
+ }
+
+ func session(path: String) throws -> CodexCachedSession? {
+ let statement = try prepare(
+ """
+ SELECT size, modification_time, fingerprint, validation_fingerprint,
+ session_id, created_at_epoch, parent_session_id, anchors,
+ records, COALESCE(summary_records, records), cursor, diagnostics
+ FROM codex_sessions WHERE path = ? LIMIT 1
+ """
+ )
+ defer { sqlite3_finalize(statement) }
+ bind(path, to: statement, index: 1)
+ let status = sqlite3_step(statement)
+ if status == SQLITE_DONE { return nil }
+ guard status == SQLITE_ROW,
+ let fingerprint = columnText(statement, index: 2),
+ let sessionID = columnText(statement, index: 4),
+ let anchorsData = columnData(statement, index: 7),
+ let recordsData = columnData(statement, index: 8),
+ let summaryData = columnData(statement, index: 9),
+ let cursorData = columnData(statement, index: 10),
+ let diagnosticsData = columnData(statement, index: 11)
+ else { return nil }
+ return CodexCachedSession(
+ path: path,
+ size: UInt64(max(0, sqlite3_column_int64(statement, 0))),
+ modificationTime: sqlite3_column_double(statement, 1),
+ fingerprint: fingerprint,
+ validationFingerprint: columnText(statement, index: 3),
+ sessionID: sessionID,
+ createdAtEpoch: sqlite3_column_type(statement, 5) == SQLITE_NULL
+ ? nil : sqlite3_column_double(statement, 5),
+ parentSessionID: columnText(statement, index: 6),
+ anchors: try decode([CodexAnchor].self, from: anchorsData, context: "session anchors"),
+ records: try decode([UsageRecord].self, from: recordsData, context: "session records"),
+ summaryRecords: try decode([UsageRecord].self, from: summaryData, context: "session summaries"),
+ cursor: try decode(CodexSessionCursor.self, from: cursorData, context: "session cursor"),
+ diagnostics: try decode(
+ CodexCollectionDiagnostics.self,
+ from: diagnosticsData,
+ context: "session diagnostics"
+ )
+ )
+ }
+
+ func beginStaging() throws {
+ guard !stagingTransactionActive else {
+ throw CodexIncrementalStoreError.sqlite("staging transaction already active")
+ }
+ try execute("BEGIN IMMEDIATE TRANSACTION")
+ do {
+ try execute("DELETE FROM codex_staged_scans")
+ try execute("DELETE FROM codex_staged_sessions")
+ stagingTransactionActive = true
+ } catch {
+ try? execute("ROLLBACK")
+ throw error
+ }
+ }
+
+ func abortStaging() {
+ guard stagingTransactionActive else { return }
+ try? execute("ROLLBACK")
+ stagingTransactionActive = false
+ }
+
+ func updateValidationFingerprint(_ fingerprint: String, path: String) throws {
+ guard stagingTransactionActive else {
+ throw CodexIncrementalStoreError.sqlite("staging transaction is not active")
+ }
+ let statement = try prepare(
+ "UPDATE codex_sessions SET validation_fingerprint = ? WHERE path = ?"
+ )
+ defer { sqlite3_finalize(statement) }
+ bind(fingerprint, to: statement, index: 1)
+ bind(path, to: statement, index: 2)
+ try requireDone(statement)
+ }
+
+ func stage(
+ scan item: PendingCodexSession,
+ anchors: [CodexAnchor],
+ createdAtEpoch: TimeInterval?
+ ) throws {
+ guard stagingTransactionActive else {
+ throw CodexIncrementalStoreError.sqlite("staging transaction is not active")
+ }
+ let encoder = PropertyListEncoder()
+ encoder.outputFormat = .binary
+ let anchors = try encoder.encode(anchors)
+ let scan = try encoder.encode(item.scan)
+ let statement = try prepare(
+ """
+ INSERT OR REPLACE INTO codex_staged_scans (
+ path, size, modification_time, fingerprint, validation_fingerprint,
+ session_id, created_at_epoch, parent_session_id, anchors, scan
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ """
+ )
+ defer { sqlite3_finalize(statement) }
+ bind(item.path.path, to: statement, index: 1)
+ sqlite3_bind_int64(statement, 2, sqlite3_int64(item.metadata.size))
+ sqlite3_bind_double(statement, 3, item.metadata.modificationTime)
+ bind(item.fingerprint, to: statement, index: 4)
+ bind(item.validationFingerprint, to: statement, index: 5)
+ bind(item.scan.canonicalSessionID, to: statement, index: 6)
+ bind(createdAtEpoch, to: statement, index: 7)
+ bind(item.scan.parentSessionID, to: statement, index: 8)
+ bind(anchors, to: statement, index: 9)
+ bind(scan, to: statement, index: 10)
+ try requireDone(statement)
+ }
+
+ func stagedScanPaths() throws -> [String] {
+ let statement = try prepare("SELECT path FROM codex_staged_scans ORDER BY path")
+ defer { sqlite3_finalize(statement) }
+ var paths = [String]()
+ while sqlite3_step(statement) == SQLITE_ROW {
+ if let path = columnText(statement, index: 0) {
+ paths.append(path)
+ }
+ }
+ try checkFinalStep(statement)
+ return paths
+ }
+
+ func stagedScan(path: String) throws -> PendingCodexSession? {
+ let statement = try prepare(
+ """
+ SELECT size, modification_time, fingerprint, validation_fingerprint, scan
+ FROM codex_staged_scans WHERE path = ? LIMIT 1
+ """
+ )
+ defer { sqlite3_finalize(statement) }
+ bind(path, to: statement, index: 1)
+ let status = sqlite3_step(statement)
+ if status == SQLITE_DONE { return nil }
+ guard status == SQLITE_ROW,
+ let fingerprint = columnText(statement, index: 2),
+ let scanData = columnData(statement, index: 4)
+ else { throw currentError() }
+ return PendingCodexSession(
+ path: URL(fileURLWithPath: path),
+ metadata: (
+ size: UInt64(max(0, sqlite3_column_int64(statement, 0))),
+ modificationTime: sqlite3_column_double(statement, 1)
+ ),
+ fingerprint: fingerprint,
+ validationFingerprint: columnText(statement, index: 3),
+ scan: try decode(CodexSessionScan.self, from: scanData, context: "staged scan")
+ )
+ }
+
+ func stagedAnchors(sessionID: String) throws -> [CodexAnchor]? {
+ for table in ["codex_staged_scans", "codex_staged_sessions"] {
+ let statement = try prepare(
+ "SELECT anchors FROM \(table) WHERE session_id = ? ORDER BY path LIMIT 1"
+ )
+ defer { sqlite3_finalize(statement) }
+ bind(sessionID, to: statement, index: 1)
+ let status = sqlite3_step(statement)
+ if status == SQLITE_DONE { continue }
+ guard status == SQLITE_ROW,
+ let data = columnData(statement, index: 0)
+ else { throw currentError() }
+ return try decode([CodexAnchor].self, from: data, context: "staged anchors")
+ }
+ return nil
+ }
+
+ func stage(session: CodexCachedSession) throws {
+ guard stagingTransactionActive else {
+ throw CodexIncrementalStoreError.sqlite("staging transaction is not active")
+ }
+ let encoder = PropertyListEncoder()
+ encoder.outputFormat = .binary
+ let anchors = try encoder.encode(session.anchors)
+ let records = try encoder.encode(session.records)
+ let summaryRecords = try encoder.encode(session.summaryRecords)
+ let cursor = try encoder.encode(session.cursor)
+ let diagnostics = try encoder.encode(session.diagnostics)
+ let statement = try prepare(
+ """
+ INSERT OR REPLACE INTO codex_staged_sessions (
+ path, size, modification_time, fingerprint, validation_fingerprint,
+ session_id, created_at_epoch, parent_session_id, anchors, records,
+ summary_records, record_count, cursor, diagnostics
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ """
+ )
+ defer { sqlite3_finalize(statement) }
+ bind(session.path, to: statement, index: 1)
+ sqlite3_bind_int64(statement, 2, sqlite3_int64(session.size))
+ sqlite3_bind_double(statement, 3, session.modificationTime)
+ bind(session.fingerprint, to: statement, index: 4)
+ bind(session.validationFingerprint, to: statement, index: 5)
+ bind(session.sessionID, to: statement, index: 6)
+ bind(session.createdAtEpoch, to: statement, index: 7)
+ bind(session.parentSessionID, to: statement, index: 8)
+ bind(anchors, to: statement, index: 9)
+ bind(records, to: statement, index: 10)
+ bind(summaryRecords, to: statement, index: 11)
+ sqlite3_bind_int64(statement, 12, sqlite3_int64(session.records.count))
+ bind(cursor, to: statement, index: 13)
+ bind(diagnostics, to: statement, index: 14)
+ try requireDone(statement)
+ }
+
+ func commitStaged(deletedPaths: Set) throws {
+ guard stagingTransactionActive else {
+ throw CodexIncrementalStoreError.sqlite("staging transaction is not active")
+ }
+ do {
+ let stagedCount = try stagedSessionCount()
+ let stagedPayloadBytes = try stagedSessionPayloadBytes()
+ if !deletedPaths.isEmpty {
+ let statement = try prepare("DELETE FROM codex_sessions WHERE path = ?")
+ defer { sqlite3_finalize(statement) }
+ for path in deletedPaths {
+ sqlite3_reset(statement)
+ sqlite3_clear_bindings(statement)
+ bind(path, to: statement, index: 1)
+ try requireDone(statement)
+ }
+ }
+ if stagedCount > 0 {
+ try execute(
+ """
+ INSERT OR REPLACE INTO codex_sessions (
+ path, size, modification_time, fingerprint, validation_fingerprint,
+ session_id, created_at_epoch, parent_session_id, anchors, records,
+ summary_records, record_count, cursor, diagnostics
+ )
+ SELECT path, size, modification_time, fingerprint,
+ validation_fingerprint, session_id, created_at_epoch,
+ parent_session_id, anchors, records, summary_records,
+ record_count, cursor, diagnostics
+ FROM codex_staged_sessions
+ """
+ )
+ }
+ if stagedCount > 0 || !deletedPaths.isEmpty {
+ try execute(
+ """
+ INSERT INTO cache_meta(key, value) VALUES ('generation', '1')
+ ON CONFLICT(key) DO UPDATE SET value = CAST(value AS INTEGER) + 1
+ """
+ )
+ let logicalWriteBytes = stagedPayloadBytes * 2
+ try execute(
+ """
+ INSERT INTO cache_meta(key, value)
+ VALUES ('last_logical_write_bytes', '\(logicalWriteBytes)')
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value
+ """
+ )
+ }
+ try execute("DELETE FROM codex_staged_scans")
+ try execute("DELETE FROM codex_staged_sessions")
+ try execute("COMMIT")
+ stagingTransactionActive = false
+ } catch {
+ try? execute("ROLLBACK")
+ stagingTransactionActive = false
+ throw error
+ }
+ }
+
+ private func stagedSessionCount() throws -> Int {
+ let statement = try prepare("SELECT COUNT(*) FROM codex_staged_sessions")
+ defer { sqlite3_finalize(statement) }
+ guard sqlite3_step(statement) == SQLITE_ROW else { throw currentError() }
+ return Int(sqlite3_column_int64(statement, 0))
+ }
+
+ private func stagedSessionPayloadBytes() throws -> Int {
+ let statement = try prepare(
+ """
+ SELECT COALESCE(SUM(
+ LENGTH(anchors) + LENGTH(records) + LENGTH(summary_records)
+ + LENGTH(cursor) + LENGTH(diagnostics)
+ ), 0)
+ FROM codex_staged_sessions
+ """
+ )
+ defer { sqlite3_finalize(statement) }
+ guard sqlite3_step(statement) == SQLITE_ROW else { throw currentError() }
+ return Int(sqlite3_column_int64(statement, 0))
+ }
+
+ func sessionCount() throws -> Int {
+ let statement = try prepare("SELECT COUNT(*) FROM codex_sessions")
+ defer { sqlite3_finalize(statement) }
+ guard sqlite3_step(statement) == SQLITE_ROW else { throw currentError() }
+ return Int(sqlite3_column_int64(statement, 0))
+ }
+
+ func forEachContribution(
+ detailed: Bool,
+ _ body: (CodexCachedContribution) throws -> Void
+ ) throws {
+ let statement = try prepare(
+ detailed
+ ? "SELECT records, diagnostics, record_count FROM codex_sessions ORDER BY path"
+ : """
+ SELECT CASE
+ WHEN session_id IN (
+ SELECT session_id FROM codex_sessions
+ GROUP BY session_id HAVING COUNT(*) > 1
+ ) THEN records
+ ELSE COALESCE(summary_records, records)
+ END,
+ diagnostics,
+ record_count
+ FROM codex_sessions
+ ORDER BY path
+ """
+ )
+ defer { sqlite3_finalize(statement) }
+ while sqlite3_step(statement) == SQLITE_ROW {
+ guard let recordsData = columnData(statement, index: 0),
+ let diagnosticsData = columnData(statement, index: 1)
+ else { throw currentError() }
+ try body(
+ CodexCachedContribution(
+ records: try decode(
+ [UsageRecord].self,
+ from: recordsData,
+ context: "contribution records"
+ ),
+ recordCount: Int(sqlite3_column_int64(statement, 2)),
+ diagnostics: try decode(
+ CodexCollectionDiagnostics.self,
+ from: diagnosticsData,
+ context: "contribution diagnostics"
+ )
+ )
+ )
+ }
+ try checkFinalStep(statement)
+ }
+
+ func stats() throws -> CodexIncrementalCacheStats {
+ let statement = try prepare(
+ """
+ SELECT
+ COALESCE((SELECT CAST(value AS INTEGER) FROM cache_meta WHERE key = 'generation'), 0),
+ COUNT(*),
+ COALESCE(SUM(record_count), 0),
+ COALESCE((
+ SELECT CAST(value AS INTEGER) FROM cache_meta
+ WHERE key = 'last_logical_write_bytes'
+ ), 0)
+ FROM codex_sessions
+ """
+ )
+ defer { sqlite3_finalize(statement) }
+ guard sqlite3_step(statement) == SQLITE_ROW else { throw currentError() }
+ return CodexIncrementalCacheStats(
+ generation: Int(sqlite3_column_int64(statement, 0)),
+ sessions: Int(sqlite3_column_int64(statement, 1)),
+ records: Int(sqlite3_column_int64(statement, 2)),
+ lastLogicalWriteBytes: Int(sqlite3_column_int64(statement, 3))
+ )
+ }
+
+ private func migrateIfNeeded() throws {
+ guard database != nil else { throw CodexIncrementalStoreError.sqlite("database closed") }
+ let current = userVersion()
+ guard current >= 0, current <= Self.schemaVersion else {
+ throw CodexIncrementalStoreError.sqlite("unsupported schema version \(current)")
+ }
+ try execute(
+ """
+ CREATE TABLE IF NOT EXISTS cache_meta (
+ key TEXT PRIMARY KEY NOT NULL,
+ value TEXT NOT NULL
+ )
+ """
+ )
+ if current > 0, current < Self.schemaVersion {
+ // v0.1.48 is the first public incremental-cache release. Recreate
+ // older development schemas so interim payloads cannot survive.
+ try execute("DROP TABLE IF EXISTS codex_sessions")
+ try execute("DROP TABLE IF EXISTS codex_staged_scans")
+ try execute("DROP TABLE IF EXISTS codex_staged_sessions")
+ try execute("DELETE FROM cache_meta")
+ }
+ try execute(
+ """
+ CREATE TABLE IF NOT EXISTS codex_sessions (
+ path TEXT PRIMARY KEY NOT NULL,
+ size INTEGER NOT NULL,
+ modification_time REAL NOT NULL,
+ fingerprint TEXT NOT NULL,
+ validation_fingerprint TEXT,
+ session_id TEXT NOT NULL,
+ created_at_epoch REAL,
+ parent_session_id TEXT,
+ anchors BLOB NOT NULL,
+ records BLOB NOT NULL,
+ summary_records BLOB,
+ record_count INTEGER NOT NULL,
+ cursor BLOB,
+ diagnostics BLOB NOT NULL
+ )
+ """
+ )
+ try execute(
+ "CREATE INDEX IF NOT EXISTS codex_sessions_session_id ON codex_sessions(session_id)"
+ )
+ try execute(
+ "CREATE INDEX IF NOT EXISTS codex_sessions_parent_id ON codex_sessions(parent_session_id)"
+ )
+ try execute(
+ """
+ CREATE TABLE IF NOT EXISTS codex_staged_scans (
+ path TEXT PRIMARY KEY NOT NULL,
+ size INTEGER NOT NULL,
+ modification_time REAL NOT NULL,
+ fingerprint TEXT NOT NULL,
+ validation_fingerprint TEXT,
+ session_id TEXT NOT NULL,
+ created_at_epoch REAL,
+ parent_session_id TEXT,
+ anchors BLOB NOT NULL,
+ scan BLOB NOT NULL
+ )
+ """
+ )
+ try execute(
+ "CREATE INDEX IF NOT EXISTS codex_staged_scans_session_id ON codex_staged_scans(session_id)"
+ )
+ try execute(
+ """
+ CREATE TABLE IF NOT EXISTS codex_staged_sessions (
+ path TEXT PRIMARY KEY NOT NULL,
+ size INTEGER NOT NULL,
+ modification_time REAL NOT NULL,
+ fingerprint TEXT NOT NULL,
+ validation_fingerprint TEXT,
+ session_id TEXT NOT NULL,
+ created_at_epoch REAL,
+ parent_session_id TEXT,
+ anchors BLOB NOT NULL,
+ records BLOB NOT NULL,
+ summary_records BLOB NOT NULL,
+ record_count INTEGER NOT NULL,
+ cursor BLOB NOT NULL,
+ diagnostics BLOB NOT NULL
+ )
+ """
+ )
+ try execute("PRAGMA user_version = \(Self.schemaVersion)")
+ }
+
+ /// Stored records and hourly summaries are bucketed into days, so they are
+ /// dropped whenever the collection zone differs from the one that wrote them.
+ private func resetIfTimeZoneChanged() throws {
+ let current = UsageCollector.timezone.identifier
+ let select = try prepare("SELECT value FROM cache_meta WHERE key = 'time_zone'")
+ let stored = sqlite3_step(select) == SQLITE_ROW ? columnText(select, index: 0) : nil
+ sqlite3_finalize(select)
+ let storedZone = stored ?? TokenStepClock.legacyTimeZoneIdentifier
+ // Also runs when the marker is missing, so legacy caches get one written.
+ guard stored == nil || storedZone != current else { return }
+ try execute("BEGIN IMMEDIATE")
+ do {
+ if storedZone != current {
+ try execute("DELETE FROM codex_sessions")
+ try execute("DELETE FROM codex_staged_scans")
+ try execute("DELETE FROM codex_staged_sessions")
+ }
+ let upsert = try prepare(
+ """
+ INSERT INTO cache_meta(key, value) VALUES ('time_zone', ?)
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value
+ """
+ )
+ defer { sqlite3_finalize(upsert) }
+ sqlite3_bind_text(upsert, 1, current, -1, Self.transient)
+ guard sqlite3_step(upsert) == SQLITE_DONE else {
+ throw CodexIncrementalStoreError.sqlite(String(cString: sqlite3_errmsg(database)))
+ }
+ try execute("COMMIT")
+ } catch {
+ try? execute("ROLLBACK")
+ throw error
+ }
+ }
+
+ private func userVersion() -> Int32 {
+ guard let statement = try? prepare("PRAGMA user_version") else { return -1 }
+ defer { sqlite3_finalize(statement) }
+ guard sqlite3_step(statement) == SQLITE_ROW else { return -1 }
+ return sqlite3_column_int(statement, 0)
+ }
+
+ private func execute(_ sql: String) throws {
+ guard let database else { throw CodexIncrementalStoreError.sqlite("database closed") }
+ var error: UnsafeMutablePointer?
+ guard sqlite3_exec(database, sql, nil, nil, &error) == SQLITE_OK else {
+ let message = error.map { String(cString: $0) }
+ ?? String(cString: sqlite3_errmsg(database))
+ sqlite3_free(error)
+ throw CodexIncrementalStoreError.sqlite(message)
+ }
+ }
+
+ private func prepare(_ sql: String) throws -> OpaquePointer {
+ guard let database else { throw CodexIncrementalStoreError.sqlite("database closed") }
+ var statement: OpaquePointer?
+ guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK,
+ let statement
+ else { throw currentError() }
+ return statement
+ }
+
+ private func bind(_ value: String?, to statement: OpaquePointer, index: Int32) {
+ guard let value else {
+ sqlite3_bind_null(statement, index)
+ return
+ }
+ sqlite3_bind_text(statement, index, value, -1, Self.transient)
+ }
+
+ private func bind(_ value: TimeInterval?, to statement: OpaquePointer, index: Int32) {
+ guard let value else {
+ sqlite3_bind_null(statement, index)
+ return
+ }
+ sqlite3_bind_double(statement, index, value)
+ }
+
+ private func bind(_ data: Data, to statement: OpaquePointer, index: Int32) {
+ _ = data.withUnsafeBytes { bytes in
+ sqlite3_bind_blob(statement, index, bytes.baseAddress, Int32(bytes.count), Self.transient)
+ }
+ }
+
+ private func decode(
+ _ type: T.Type,
+ from data: Data,
+ context: String
+ ) throws -> T {
+ do {
+ return try PropertyListDecoder().decode(type, from: data)
+ } catch {
+ throw CodexIncrementalStoreError.corruptPayload(context)
+ }
+ }
+
+ private func columnText(_ statement: OpaquePointer, index: Int32) -> String? {
+ guard let value = sqlite3_column_text(statement, index) else { return nil }
+ return String(cString: value)
+ }
+
+ private func columnData(_ statement: OpaquePointer, index: Int32) -> Data? {
+ let count = Int(sqlite3_column_bytes(statement, index))
+ guard count >= 0 else { return nil }
+ if count == 0 { return Data() }
+ guard let bytes = sqlite3_column_blob(statement, index) else { return nil }
+ return Data(bytes: bytes, count: count)
+ }
+
+ private func requireDone(_ statement: OpaquePointer) throws {
+ guard sqlite3_step(statement) == SQLITE_DONE else { throw currentError() }
+ }
+
+ private func checkFinalStep(_ statement: OpaquePointer) throws {
+ let status = sqlite3_errcode(database)
+ guard status == SQLITE_OK || status == SQLITE_DONE else { throw currentError() }
+ }
+
+ private func currentError() -> CodexIncrementalStoreError {
+ guard let database else { return .sqlite("database closed") }
+ return .sqlite(String(cString: sqlite3_errmsg(database)))
+ }
+}
diff --git a/TokenStepSwift/Sources/TokenStepSwift/Services/Collector/UsageCollector+AgentSources.swift b/TokenStepSwift/Sources/TokenStepSwift/Services/Collector/UsageCollector+AgentSources.swift
new file mode 100644
index 0000000..11f34e4
--- /dev/null
+++ b/TokenStepSwift/Sources/TokenStepSwift/Services/Collector/UsageCollector+AgentSources.swift
@@ -0,0 +1,542 @@
+import Foundation
+
+extension UsageCollector {
+ static func collectCCSwitchProxyUsage(databaseURL: URL? = nil) -> CollectorResult {
+ let database = databaseURL ?? FileManager.default.homeDirectoryForCurrentUser
+ .appendingPathComponent(".cc-switch/cc-switch.db")
+
+ guard FileManager.default.fileExists(atPath: database.path) else {
+ return CollectorResult(
+ records: [],
+ source: SourceInfo(status: "missing_db", files: 0, records: 0)
+ )
+ }
+
+ guard FileManager.default.isReadableFile(atPath: database.path) else {
+ return CollectorResult(
+ records: [],
+ source: SourceInfo(status: "unreadable_db", files: 1, records: 0)
+ )
+ }
+
+ guard let columns = sqliteJSONRows(
+ database: database,
+ query: "pragma table_info(proxy_request_logs)"
+ ) else {
+ return CollectorResult(
+ records: [],
+ source: SourceInfo(status: "schema_unreadable", files: 1, records: 0)
+ )
+ }
+
+ guard !columns.isEmpty else {
+ return CollectorResult(
+ records: [],
+ source: SourceInfo(status: "missing_table", files: 1, records: 0)
+ )
+ }
+
+ let availableColumns = Set(columns.compactMap { $0["name"] as? String })
+ let requiredColumns: Set = [
+ "request_id",
+ "app_type",
+ "provider_id",
+ "model",
+ "request_model",
+ "pricing_model",
+ "input_tokens",
+ "output_tokens",
+ "cache_read_tokens",
+ "cache_creation_tokens",
+ "total_cost_usd",
+ "status_code",
+ "created_at"
+ ]
+ guard requiredColumns.isSubset(of: availableColumns) else {
+ return CollectorResult(
+ records: [],
+ source: SourceInfo(status: "schema_mismatch", files: 1, records: 0)
+ )
+ }
+ guard availableColumns.contains("data_source") else {
+ return CollectorResult(
+ records: [],
+ source: SourceInfo(status: "schema_missing_data_source", files: 1, records: 0)
+ )
+ }
+
+ let sessionColumn = availableColumns.contains("session_id") ? "session_id" : "null"
+ let inputSemanticsColumn = availableColumns.contains("input_token_semantics")
+ ? "coalesce(input_token_semantics, 0)"
+ : "0"
+ let query = """
+ select
+ request_id,
+ \(sessionColumn) as session_id,
+ data_source,
+ created_at,
+ app_type,
+ coalesce(nullif(pricing_model, ''), nullif(model, ''), nullif(request_model, ''), 'unknown') as display_model,
+ coalesce(input_tokens, 0) as input_tokens,
+ coalesce(output_tokens, 0) as output_tokens,
+ coalesce(cache_read_tokens, 0) as cache_read_tokens,
+ coalesce(cache_creation_tokens, 0) as cache_creation_tokens,
+ \(inputSemanticsColumn) as input_token_semantics,
+ cast(coalesce(nullif(total_cost_usd, ''), '0') as real) as total_cost_usd
+ from proxy_request_logs
+ where status_code >= 200
+ and status_code < 300
+ and lower(data_source) = 'proxy'
+ and (
+ coalesce(input_tokens, 0)
+ + coalesce(output_tokens, 0)
+ + coalesce(cache_read_tokens, 0)
+ + coalesce(cache_creation_tokens, 0)
+ ) > 0
+ order by created_at, request_id
+ """
+
+ guard let rows = sqliteJSONRows(database: database, query: query) else {
+ return CollectorResult(
+ records: [],
+ source: SourceInfo(status: "query_failed", files: 1, records: 0)
+ )
+ }
+
+ let records = rows.compactMap { row -> UsageRecord? in
+ guard let day = dayString(fromEpoch: row["created_at"] as Any) else {
+ return nil
+ }
+
+ let appType = row["app_type"] as? String
+ let rawInputTokens = integerValue(row["input_tokens"] as Any)
+ let cacheReadTokens = integerValue(row["cache_read_tokens"] as Any)
+ let cacheCreationTokens = integerValue(row["cache_creation_tokens"] as Any)
+ let freshInputTokens = ccSwitchFreshInputTokens(
+ rawInputTokens: rawInputTokens,
+ cacheReadTokens: cacheReadTokens,
+ cacheCreationTokens: cacheCreationTokens,
+ appType: appType,
+ inputTokenSemantics: integerValue(row["input_token_semantics"] as Any)
+ )
+ let usage = canonicalUsageCounts(
+ rawInputTokens: freshInputTokens,
+ outputTokens: integerValue(row["output_tokens"] as Any),
+ cacheCreationInputTokens: cacheCreationTokens,
+ cacheReadInputTokens: cacheReadTokens,
+ inputIncludesCachedTokens: false
+ )
+ guard usage.totalTokens > 0 else { return nil }
+
+ return UsageRecord(
+ date: day,
+ timestamp: isoString(fromEpoch: row["created_at"] as Any),
+ tool: ccSwitchToolName(appType: appType),
+ model: modelKey(row["display_model"] as? String),
+ usage: usage,
+ costUSD: doubleValue(row["total_cost_usd"] as Any),
+ source: .ccSwitchProxy,
+ requestID: nonEmptyString(row["request_id"] as? String),
+ sessionID: nonEmptyString(row["session_id"] as? String),
+ dataSource: nonEmptyString(row["data_source"] as? String)
+ )
+ }
+
+ return CollectorResult(
+ records: records,
+ source: SourceInfo(
+ status: records.isEmpty ? "missing_valid_rows" : "ok",
+ files: 1,
+ records: records.count
+ )
+ )
+ }
+
+ static func collectZCodeUsage(databaseURL: URL? = nil) -> CollectorResult {
+ let database = databaseURL ?? FileManager.default.homeDirectoryForCurrentUser
+ .appendingPathComponent(".zcode/cli/db/db.sqlite")
+
+ guard FileManager.default.fileExists(atPath: database.path) else {
+ return CollectorResult(records: [], source: SourceInfo(status: "missing_db", files: 0, records: 0))
+ }
+ guard FileManager.default.isReadableFile(atPath: database.path) else {
+ return CollectorResult(records: [], source: SourceInfo(status: "unreadable_db", files: 1, records: 0))
+ }
+ guard let columns = sqliteJSONRows(database: database, query: "pragma table_info(model_usage)") else {
+ return CollectorResult(records: [], source: SourceInfo(status: "schema_unreadable", files: 1, records: 0))
+ }
+ guard !columns.isEmpty else {
+ return CollectorResult(records: [], source: SourceInfo(status: "missing_table", files: 1, records: 0))
+ }
+
+ let availableColumns = Set(columns.compactMap { $0["name"] as? String })
+ let requiredColumns: Set = [
+ "id",
+ "session_id",
+ "status",
+ "started_at",
+ "model_id",
+ "input_tokens",
+ "output_tokens",
+ "reasoning_tokens",
+ "cache_creation_input_tokens",
+ "cache_read_input_tokens",
+ "computed_total_tokens",
+ "tool_call_count"
+ ]
+ guard requiredColumns.isSubset(of: availableColumns) else {
+ return CollectorResult(records: [], source: SourceInfo(status: "schema_mismatch", files: 1, records: 0))
+ }
+
+ let providerTotalExpression = availableColumns.contains("provider_total_tokens")
+ ? "coalesce(provider_total_tokens, 0)"
+ : "0"
+ let query = """
+ select
+ id,
+ session_id,
+ started_at,
+ coalesce(nullif(model_id, ''), 'unknown') as display_model,
+ coalesce(input_tokens, 0) as input_tokens,
+ coalesce(output_tokens, 0) as output_tokens,
+ coalesce(reasoning_tokens, 0) as reasoning_tokens,
+ coalesce(cache_creation_input_tokens, 0) as cache_creation_input_tokens,
+ coalesce(cache_read_input_tokens, 0) as cache_read_input_tokens,
+ coalesce(computed_total_tokens, 0) as computed_total_tokens,
+ \(providerTotalExpression) as provider_total_tokens,
+ coalesce(tool_call_count, 0) as tool_call_count
+ from model_usage
+ where status = 'completed'
+ and (
+ coalesce(computed_total_tokens, 0) > 0
+ or \(providerTotalExpression) > 0
+ or (
+ coalesce(input_tokens, 0)
+ + coalesce(output_tokens, 0)
+ + coalesce(reasoning_tokens, 0)
+ + coalesce(cache_creation_input_tokens, 0)
+ + coalesce(cache_read_input_tokens, 0)
+ ) > 0
+ )
+ order by started_at, id
+ """
+
+ guard let rows = sqliteJSONRows(database: database, query: query) else {
+ return CollectorResult(records: [], source: SourceInfo(status: "query_failed", files: 1, records: 0))
+ }
+
+ let records = rows.compactMap { row -> UsageRecord? in
+ guard let day = dayString(fromEpoch: row["started_at"] as Any) else { return nil }
+ let computedTotal = integerValue(row["computed_total_tokens"] as Any)
+ let providerTotal = integerValue(row["provider_total_tokens"] as Any)
+ let usage = canonicalUsageCounts(
+ rawInputTokens: integerValue(row["input_tokens"] as Any),
+ outputTokens: integerValue(row["output_tokens"] as Any),
+ cacheCreationInputTokens: integerValue(row["cache_creation_input_tokens"] as Any),
+ cacheReadInputTokens: integerValue(row["cache_read_input_tokens"] as Any),
+ reasoningOutputTokens: integerValue(row["reasoning_tokens"] as Any),
+ inputIncludesCachedTokens: true,
+ explicitTotalTokens: computedTotal > 0 ? computedTotal : providerTotal
+ )
+ guard usage.totalTokens > 0 else { return nil }
+
+ return UsageRecord(
+ date: day,
+ timestamp: isoString(fromEpoch: row["started_at"] as Any),
+ tool: "ZCode",
+ model: modelKey(row["display_model"] as? String),
+ usage: usage,
+ source: .zcode,
+ requestID: nonEmptyString(row["id"] as? String),
+ sessionID: nonEmptyString(row["session_id"] as? String),
+ modelRequestCount: 1,
+ toolCallCount: integerValue(row["tool_call_count"] as Any)
+ )
+ }
+
+ return CollectorResult(
+ records: records,
+ source: SourceInfo(status: records.isEmpty ? "missing_valid_rows" : "ok", files: 1, records: records.count)
+ )
+ }
+
+ static func collectHermesUsage(databaseURL: URL? = nil) -> CollectorResult {
+ let database = databaseURL ?? FileManager.default.homeDirectoryForCurrentUser
+ .appendingPathComponent(".hermes/state.db")
+
+ guard FileManager.default.fileExists(atPath: database.path) else {
+ return CollectorResult(records: [], source: SourceInfo(status: "missing_db", files: 0, records: 0))
+ }
+ guard FileManager.default.isReadableFile(atPath: database.path) else {
+ return CollectorResult(records: [], source: SourceInfo(status: "unreadable_db", files: 1, records: 0))
+ }
+ guard let columns = sqliteJSONRows(database: database, query: "pragma table_info(sessions)") else {
+ return CollectorResult(records: [], source: SourceInfo(status: "schema_unreadable", files: 1, records: 0))
+ }
+ guard !columns.isEmpty else {
+ return CollectorResult(records: [], source: SourceInfo(status: "missing_table", files: 1, records: 0))
+ }
+
+ let availableColumns = Set(columns.compactMap { $0["name"] as? String })
+ let requiredColumns: Set = [
+ "id",
+ "source",
+ "model",
+ "started_at",
+ "input_tokens",
+ "output_tokens",
+ "cache_read_tokens",
+ "cache_write_tokens",
+ "reasoning_tokens",
+ "tool_call_count",
+ "api_call_count",
+ "actual_cost_usd",
+ "estimated_cost_usd",
+ "cost_status"
+ ]
+ guard requiredColumns.isSubset(of: availableColumns) else {
+ return CollectorResult(records: [], source: SourceInfo(status: "schema_mismatch", files: 1, records: 0))
+ }
+
+ let query = """
+ select
+ id,
+ source,
+ model,
+ started_at,
+ coalesce(input_tokens, 0) as input_tokens,
+ coalesce(output_tokens, 0) as output_tokens,
+ coalesce(cache_read_tokens, 0) as cache_read_tokens,
+ coalesce(cache_write_tokens, 0) as cache_write_tokens,
+ coalesce(reasoning_tokens, 0) as reasoning_tokens,
+ coalesce(tool_call_count, 0) as tool_call_count,
+ coalesce(api_call_count, 0) as api_call_count,
+ coalesce(actual_cost_usd, 0) as actual_cost_usd,
+ coalesce(estimated_cost_usd, 0) as estimated_cost_usd,
+ coalesce(cost_status, '') as cost_status
+ from sessions
+ where (
+ coalesce(input_tokens, 0)
+ + coalesce(output_tokens, 0)
+ + coalesce(cache_read_tokens, 0)
+ + coalesce(cache_write_tokens, 0)
+ + coalesce(reasoning_tokens, 0)
+ ) > 0
+ order by started_at, id
+ """
+
+ guard let rows = sqliteJSONRows(database: database, query: query) else {
+ return CollectorResult(records: [], source: SourceInfo(status: "query_failed", files: 1, records: 0))
+ }
+
+ let records = rows.compactMap { row -> UsageRecord? in
+ guard let day = dayString(fromEpoch: row["started_at"] as Any) else { return nil }
+ let usage = canonicalUsageCounts(
+ rawInputTokens: integerValue(row["input_tokens"] as Any),
+ outputTokens: integerValue(row["output_tokens"] as Any),
+ cacheCreationInputTokens: integerValue(row["cache_write_tokens"] as Any),
+ cacheReadInputTokens: integerValue(row["cache_read_tokens"] as Any),
+ reasoningOutputTokens: integerValue(row["reasoning_tokens"] as Any),
+ inputIncludesCachedTokens: false
+ )
+ guard usage.totalTokens > 0 else { return nil }
+
+ let actualCost = doubleValue(row["actual_cost_usd"] as Any)
+ let estimatedCost = doubleValue(row["estimated_cost_usd"] as Any)
+ let cost: Double?
+ if actualCost > 0 {
+ cost = actualCost
+ } else if estimatedCost > 0 {
+ cost = estimatedCost
+ } else {
+ cost = nil
+ }
+ let requestCount = integerValue(row["api_call_count"] as Any)
+
+ return UsageRecord(
+ date: day,
+ timestamp: isoString(fromEpoch: row["started_at"] as Any),
+ tool: "Hermes Agent",
+ model: modelKey(row["model"] as? String),
+ usage: usage,
+ costUSD: cost,
+ source: .hermes,
+ requestID: nonEmptyString(row["id"] as? String),
+ sessionID: nonEmptyString(row["id"] as? String),
+ dataSource: nonEmptyString(row["source"] as? String),
+ modelRequestCount: requestCount,
+ toolCallCount: integerValue(row["tool_call_count"] as Any)
+ )
+ }
+
+ return CollectorResult(
+ records: records,
+ source: SourceInfo(status: records.isEmpty ? "missing_valid_rows" : "ok", files: 1, records: records.count)
+ )
+ }
+
+ static func collectWorkBuddyUsage(
+ rootURLs: [URL]? = nil,
+ modifiedSince cutoffDate: Date?
+ ) -> CollectorResult {
+ let home = FileManager.default.homeDirectoryForCurrentUser
+ let roots = rootURLs ?? [
+ home.appendingPathComponent(".workbuddy/projects", isDirectory: true),
+ home.appendingPathComponent("Library/Application Support/WorkBuddyExtension", isDirectory: true)
+ ]
+ let discoveredRoots = roots.filter { FileManager.default.fileExists(atPath: $0.path) }
+ let files = discoveredRoots.flatMap { jsonlFiles(under: $0, modifiedSince: cutoffDate) }
+ var records: [UsageRecord] = []
+
+ for file in files {
+ var lineNumber = 0
+ try? forEachLine(in: file, matchingAny: ["\"usage\"", "\"rawUsage\""]) { line in
+ lineNumber += 1
+ guard let data = line.data(using: .utf8),
+ let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ let timestamp = object["timestamp"],
+ let day = dayString(fromEpoch: timestamp),
+ let usage = workBuddyUsage(from: object),
+ usage.totalTokens > 0
+ else {
+ return
+ }
+
+ let providerData = object["providerData"] as? [String: Any]
+ let recordType = object["type"] as? String
+ records.append(UsageRecord(
+ date: day,
+ timestamp: isoString(fromEpoch: timestamp),
+ tool: "WorkBuddy",
+ model: modelKey(
+ providerData?["requestModelId"] as? String
+ ?? providerData?["requestModelName"] as? String
+ ?? providerData?["model"] as? String
+ ),
+ usage: usage,
+ source: .workbuddy,
+ requestID: nonEmptyString(providerData?["conversationRequestId"] as? String),
+ sessionID: nonEmptyString(object["sessionId"] as? String),
+ sourcePath: file.path,
+ lineNumber: lineNumber,
+ modelRequestCount: 1,
+ toolCallCount: recordType == "function_call" ? 1 : 0
+ ))
+ }
+ }
+
+ let status: String
+ if discoveredRoots.isEmpty {
+ status = "missing"
+ } else if files.isEmpty {
+ status = "discovered_no_usage"
+ } else if records.isEmpty {
+ status = "missing_valid_rows"
+ } else {
+ status = "ok"
+ }
+ return CollectorResult(
+ records: records,
+ source: SourceInfo(
+ status: status,
+ files: files.count,
+ records: records.count
+ )
+ )
+ }
+
+ static func workBuddyUsage(from object: [String: Any]) -> TokenUsageCounts? {
+ let message = object["message"] as? [String: Any]
+ let providerData = object["providerData"] as? [String: Any]
+ let usage = message?["usage"] as? [String: Any]
+ ?? providerData?["rawUsage"] as? [String: Any]
+ ?? providerData?["usage"] as? [String: Any]
+ guard let usage else { return nil }
+
+ let rawInput = firstIntegerValue(
+ in: usage,
+ keys: ["input_tokens", "inputTokens", "prompt_tokens"]
+ )
+ let output = firstIntegerValue(
+ in: usage,
+ keys: ["output_tokens", "outputTokens", "completion_tokens"]
+ )
+ let cacheRead = firstIntegerValue(
+ in: usage,
+ keys: ["cache_read_input_tokens", "cached_tokens", "prompt_cache_hit_tokens"]
+ )
+ let reasoning = firstIntegerValue(
+ in: usage,
+ keys: ["reasoning_tokens", "completion_thinking_tokens"]
+ )
+ let explicitTotal = firstIntegerValue(
+ in: usage,
+ keys: ["total_tokens", "totalTokens"]
+ )
+ return canonicalUsageCounts(
+ rawInputTokens: rawInput,
+ outputTokens: output,
+ cacheReadInputTokens: cacheRead,
+ reasoningOutputTokens: reasoning,
+ inputIncludesCachedTokens: true,
+ explicitTotalTokens: explicitTotal,
+ explicitTotalIsAuthoritative: true
+ )
+ }
+
+ static func firstIntegerValue(in object: [String: Any], keys: [String]) -> Int {
+ for key in keys where object.keys.contains(key) {
+ return max(0, integerValue(object[key] as Any))
+ }
+ return 0
+ }
+
+ static func ccSwitchToolName(appType: String?) -> String {
+ let value = (appType ?? "unknown").trimmingCharacters(in: .whitespacesAndNewlines)
+ let normalized = value.lowercased()
+ switch normalized {
+ case "claude":
+ return "Claude Code via CC Switch"
+ case "codex":
+ return "Codex via CC Switch"
+ case "gemini":
+ return "Gemini via CC Switch"
+ default:
+ return "\(value.isEmpty ? "unknown" : value) via CC Switch (experimental)"
+ }
+ }
+
+ static func ccSwitchFreshInputTokens(
+ rawInputTokens: Int,
+ cacheReadTokens: Int,
+ cacheCreationTokens: Int,
+ appType: String?,
+ inputTokenSemantics: Int
+ ) -> Int {
+ let rawInput = max(0, rawInputTokens)
+ let cacheRead = max(0, cacheReadTokens)
+ let cacheCreation = max(0, cacheCreationTokens)
+ let normalizedAppType = (appType ?? "")
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ .lowercased()
+ let cacheInclusiveAppTypes: Set = ["codex", "gemini", "grokbuild"]
+ guard cacheInclusiveAppTypes.contains(normalizedAppType) else {
+ return rawInput
+ }
+
+ switch inputTokenSemantics {
+ case 2:
+ // FRESH: input excludes both cache-read and cache-write buckets.
+ return rawInput
+ case 1 where rawInput >= cacheRead + cacheCreation:
+ // TOTAL: input already includes both cache buckets.
+ return rawInput - cacheRead - cacheCreation
+ case 0 where rawInput >= cacheRead:
+ // LEGACY: cache reads were included, cache writes were separate.
+ return rawInput - cacheRead
+ default:
+ // Malformed or future semantics stay conservative instead of going negative.
+ return rawInput
+ }
+ }
+}
diff --git a/TokenStepSwift/Sources/TokenStepSwift/Services/Collector/UsageCollector+Aggregate.swift b/TokenStepSwift/Sources/TokenStepSwift/Services/Collector/UsageCollector+Aggregate.swift
new file mode 100644
index 0000000..424128b
--- /dev/null
+++ b/TokenStepSwift/Sources/TokenStepSwift/Services/Collector/UsageCollector+Aggregate.swift
@@ -0,0 +1,110 @@
+import Foundation
+
+extension UsageCollector {
+ static func aggregate(records: [UsageRecord], sources: [String: SourceInfo]) -> UsageSnapshot {
+ var daily = [String: DailyAccumulator]()
+ var rhythms = [String: RhythmAccumulator]()
+ var agentWork = [String: AgentWorkAccumulator]()
+ var tools = [String: UsageAccumulator]()
+ var models = [ModelKey: UsageAccumulator]()
+
+ for record in records {
+ let cost = record.costUSD ?? estimateCost(usage: record.usage, tool: record.tool, model: record.model)
+ daily[record.date, default: DailyAccumulator(date: record.date)].add(record: record, cost: cost)
+ let recordHour = record.timestampEpoch.map(hour(fromEpoch:))
+ ?? hour(fromISO: record.timestamp)
+ if let hour = recordHour {
+ rhythms[record.date, default: RhythmAccumulator(date: record.date)]
+ .add(tokens: record.usage.totalTokens, hour: hour)
+ }
+ if isAgentWorkRecord(record) {
+ agentWork[record.date, default: AgentWorkAccumulator(date: record.date)]
+ .add(record: record, hour: recordHour)
+ }
+ tools[record.tool, default: UsageAccumulator()].add(record.usage, cost: cost)
+ models[ModelKey(tool: record.tool, model: record.model), default: UsageAccumulator()].add(record.usage, cost: cost)
+ }
+
+ let totalTokens = tools.values.map(\.usage.totalTokens).reduce(0, +)
+ let totalCost = tools.values.map(\.cost).reduce(0, +)
+
+ let dailyRows = daily.values
+ .sorted { $0.date < $1.date }
+ .map { item in
+ DailyUsage(
+ date: item.date,
+ tools: item.tools,
+ models: item.models,
+ modelCosts: item.modelCosts.mapValues { rounded($0, digits: 4) },
+ totalTokens: item.totalTokens,
+ cost: rounded(item.cost, digits: 4)
+ )
+ }
+
+ let rhythmRows = rhythms.values
+ .map(\.dailyRhythm)
+ .filter { $0.totalTokens > 0 }
+ .sorted { $0.date < $1.date }
+
+ let agentWorkRows = agentWork.values
+ .map(\.dailyAgentWork)
+ .filter { $0.totalTokens > 0 }
+ .sorted { $0.date < $1.date }
+
+ let toolRows = tools
+ .sorted { $0.value.usage.totalTokens > $1.value.usage.totalTokens }
+ .map { tool, item in
+ ToolUsage(
+ tool: tool,
+ tokens: item.usage.totalTokens,
+ percent: percent(item.usage.totalTokens, of: totalTokens)
+ )
+ }
+
+ let modelRows = models
+ .sorted { $0.value.usage.totalTokens > $1.value.usage.totalTokens }
+ .map { key, item in
+ ModelUsage(
+ model: key.model,
+ tool: key.tool,
+ tokens: item.usage.totalTokens,
+ percent: percent(item.usage.totalTokens, of: totalTokens)
+ )
+ }
+
+ return UsageSnapshot(
+ generatedAt: isoFormatter.string(from: Date()),
+ timezone: timezone.identifier,
+ totals: UsageTotals(
+ tokens: totalTokens,
+ cost: rounded(totalCost, digits: 2),
+ activeDays: dailyRows.filter { $0.totalTokens > 0 }.count
+ ),
+ daily: dailyRows,
+ rhythms: rhythmRows,
+ agentWork: agentWorkRows,
+ tools: toolRows,
+ models: modelRows,
+ sources: sources
+ )
+ }
+
+ static func isAgentWorkRecord(_ record: UsageRecord) -> Bool {
+ switch record.source {
+ case .nativeCodex, .nativeCodexSQLite, .nativeClaudeCode, .ccSwitchProxy, .zcode, .hermes, .workbuddy:
+ return true
+ case .unknown:
+ return false
+ }
+ }
+
+ static func percent(_ value: Int, of total: Int) -> Double {
+ guard total > 0 else { return 0 }
+ return rounded(Double(value) / Double(total) * 100, digits: 2)
+ }
+
+ static func rounded(_ value: Double, digits: Int) -> Double {
+ let multiplier = pow(10.0, Double(digits))
+ return (value * multiplier).rounded() / multiplier
+ }
+}
diff --git a/TokenStepSwift/Sources/TokenStepSwift/Services/Collector/UsageCollector+Cache.swift b/TokenStepSwift/Sources/TokenStepSwift/Services/Collector/UsageCollector+Cache.swift
new file mode 100644
index 0000000..4422ec5
--- /dev/null
+++ b/TokenStepSwift/Sources/TokenStepSwift/Services/Collector/UsageCollector+Cache.swift
@@ -0,0 +1,259 @@
+import CryptoKit
+import Foundation
+
+extension UsageCollector {
+ static func jsonlFiles(under root: URL, modifiedSince cutoffDate: Date? = nil) -> [URL] {
+ guard FileManager.default.fileExists(atPath: root.path),
+ let enumerator = FileManager.default.enumerator(
+ at: root,
+ includingPropertiesForKeys: [.isRegularFileKey, .contentModificationDateKey],
+ options: [.skipsHiddenFiles]
+ )
+ else {
+ return []
+ }
+
+ return enumerator.compactMap { item in
+ guard let url = item as? URL,
+ url.pathExtension == "jsonl",
+ let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .contentModificationDateKey]),
+ values.isRegularFile == true
+ else {
+ return nil
+ }
+ if let cutoffDate,
+ let modificationDate = values.contentModificationDate,
+ modificationDate < cutoffDate {
+ return nil
+ }
+ return url
+ }
+ }
+
+ static func cachedRecords(for url: URL, tool: String, cache: CollectorCache) -> [UsageRecord]? {
+ guard let metadata = fileMetadata(for: url),
+ let fingerprint = contentFingerprint(for: url, size: metadata.size),
+ let cached = cache.files[url.path],
+ cached.tool == tool,
+ cached.size == metadata.size,
+ abs(cached.modificationTime - metadata.modificationTime) < 0.001,
+ cached.contentFingerprint == fingerprint
+ else {
+ return nil
+ }
+ return cached.records
+ }
+
+ static func cachedCodexScan(for url: URL, cache: CollectorCache) -> CodexSessionScan? {
+ guard let metadata = fileMetadata(for: url),
+ let fingerprint = contentFingerprint(for: url, size: metadata.size),
+ let cached = cache.files[url.path],
+ cached.tool == "Codex",
+ cached.size == metadata.size,
+ abs(cached.modificationTime - metadata.modificationTime) < 0.001,
+ cached.contentFingerprint == fingerprint
+ else {
+ return nil
+ }
+ return cached.codexScan
+ }
+
+ static func updateCache(
+ path: URL,
+ tool: String,
+ records: [UsageRecord],
+ claudeState: ClaudeFileState? = nil,
+ cache: inout CollectorCache
+ ) {
+ guard let metadata = fileMetadata(for: path),
+ let fingerprint = contentFingerprint(for: path, size: metadata.size)
+ else {
+ return
+ }
+ cache.files[path.path] = CachedUsageFile(
+ tool: tool,
+ size: metadata.size,
+ modificationTime: metadata.modificationTime,
+ records: records,
+ contentFingerprint: fingerprint,
+ claudeState: claudeState
+ )
+ }
+
+ static func updateCodexCache(
+ path: URL,
+ scan: CodexSessionScan,
+ metadata: (size: UInt64, modificationTime: TimeInterval),
+ cache: inout CollectorCache
+ ) {
+ guard let currentMetadata = fileMetadata(for: path),
+ UsageCollector.metadata(metadata, matches: currentMetadata),
+ let fingerprint = contentFingerprint(for: path, size: currentMetadata.size),
+ let finalMetadata = fileMetadata(for: path),
+ UsageCollector.metadata(currentMetadata, matches: finalMetadata)
+ else {
+ return
+ }
+ cache.files[path.path] = CachedUsageFile(
+ tool: "Codex",
+ size: finalMetadata.size,
+ modificationTime: finalMetadata.modificationTime,
+ records: [],
+ codexScan: scan,
+ contentFingerprint: fingerprint
+ )
+ }
+
+ static func fileMetadata(for url: URL) -> (size: UInt64, modificationTime: TimeInterval)? {
+ guard let values = try? url.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey]),
+ let size = values.fileSize,
+ let modificationDate = values.contentModificationDate
+ else {
+ return nil
+ }
+ return (UInt64(max(0, size)), modificationDate.timeIntervalSince1970)
+ }
+
+ static func metadata(
+ _ lhs: (size: UInt64, modificationTime: TimeInterval),
+ matches rhs: (size: UInt64, modificationTime: TimeInterval)
+ ) -> Bool {
+ lhs.size == rhs.size && abs(lhs.modificationTime - rhs.modificationTime) < 0.001
+ }
+
+ static func contentFingerprint(for url: URL, size: UInt64) -> String? {
+ guard let handle = try? FileHandle(forReadingFrom: url) else { return nil }
+ defer { try? handle.close() }
+
+ let chunkSize = 4_096
+ var hash: UInt64 = 14_695_981_039_346_656_037
+ func include(_ data: Data) {
+ for byte in data {
+ hash ^= UInt64(byte)
+ hash &*= 1_099_511_628_211
+ }
+ }
+
+ do {
+ include(withUnsafeBytes(of: size.littleEndian) { Data($0) })
+ let leadingCount = min(chunkSize, Int(clamping: size))
+ include(try handle.read(upToCount: leadingCount) ?? Data())
+ if size > UInt64(leadingCount) {
+ let trailingCount = min(chunkSize, Int(clamping: size))
+ try handle.seek(toOffset: size - UInt64(trailingCount))
+ include(try handle.read(upToCount: trailingCount) ?? Data())
+ }
+ return String(format: "%016llx", hash)
+ } catch {
+ return nil
+ }
+ }
+
+ static func fullContentFingerprint(for url: URL, size: UInt64) -> String? {
+ guard let handle = try? FileHandle(forReadingFrom: url) else { return nil }
+ defer { try? handle.close() }
+
+ var hasher = SHA256()
+ hasher.update(data: withUnsafeBytes(of: size.littleEndian) { Data($0) })
+ var remaining = size
+ do {
+ while remaining > 0 {
+ let requested = min(1_048_576, Int(clamping: remaining))
+ guard let chunk = try autoreleasepool(invoking: {
+ try handle.read(upToCount: requested)
+ }), !chunk.isEmpty else {
+ return nil
+ }
+ hasher.update(data: chunk)
+ remaining -= UInt64(chunk.count)
+ }
+ return hasher.finalize().map { String(format: "%02x", $0) }.joined()
+ } catch {
+ return nil
+ }
+ }
+
+ static func loadCache() -> CollectorCacheLoad {
+ loadCache(at: AppPaths.collectorCacheJSON)
+ }
+
+ static func loadCache(at url: URL) -> CollectorCacheLoad {
+ guard let data = try? Data(contentsOf: url),
+ let decoded = try? JSONDecoder().decode(CollectorCache.self, from: data)
+ else {
+ return CollectorCacheLoad(cache: CollectorCache(), recalibratedFromRevision: nil)
+ }
+ guard decoded.version == CollectorCache.currentVersion else {
+ return CollectorCacheLoad(
+ cache: CollectorCache(),
+ recalibratedFromRevision: decoded.version < CollectorCache.currentVersion ? decoded.version : nil
+ )
+ }
+ guard TokenStepClock.matchesCurrent(decoded.timeZone) else {
+ return CollectorCacheLoad(cache: CollectorCache(), recalibratedFromRevision: nil)
+ }
+ return CollectorCacheLoad(cache: decoded, recalibratedFromRevision: nil)
+ }
+
+ static func saveCache(_ cache: CollectorCache) {
+ saveCache(cache, to: AppPaths.collectorCacheJSON)
+ }
+
+ static func loadCurrentCache(at url: URL) -> CollectorCache {
+ guard let data = try? Data(contentsOf: url),
+ let cache = try? JSONDecoder().decode(CollectorCache.self, from: data),
+ cache.version == CollectorCache.currentVersion,
+ TokenStepClock.matchesCurrent(cache.timeZone)
+ else {
+ return CollectorCache()
+ }
+ return cache
+ }
+
+ static func saveCache(_ cache: CollectorCache, to url: URL) {
+ do {
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.sortedKeys]
+ let data = try encoder.encode(cache)
+ try FileManager.default.createDirectory(
+ at: url.deletingLastPathComponent(),
+ withIntermediateDirectories: true
+ )
+ if let attributes = try? FileManager.default.attributesOfItem(atPath: url.path),
+ let existingSize = (attributes[.size] as? NSNumber)?.intValue,
+ existingSize == data.count,
+ let existing = try? Data(contentsOf: url),
+ existing == data {
+ return
+ }
+ try data.write(to: url, options: .atomic)
+ } catch {
+ // Cache misses should never prevent the app from showing fresh usage.
+ }
+ }
+
+ static func sourceFileCutoffDate(historyDays: Int) -> Date? {
+ calendar.date(byAdding: .day, value: -max(7, historyDays + 1), to: Date())
+ }
+
+ static func recordsInHistoryWindow(
+ _ records: [UsageRecord],
+ historyDays: Int,
+ now: Date
+ ) -> [UsageRecord] {
+ let inclusiveDays = max(1, historyDays)
+ let today = calendar.startOfDay(for: now)
+ guard let firstDay = calendar.date(
+ byAdding: .day,
+ value: -(inclusiveDays - 1),
+ to: today
+ ) else {
+ return records
+ }
+ let firstDayString = dayFormatter.string(from: firstDay)
+ let todayString = dayFormatter.string(from: today)
+ return records.filter {
+ $0.date >= firstDayString && $0.date <= todayString
+ }
+ }
+}
diff --git a/TokenStepSwift/Sources/TokenStepSwift/Services/Collector/UsageCollector+Claude.swift b/TokenStepSwift/Sources/TokenStepSwift/Services/Collector/UsageCollector+Claude.swift
new file mode 100644
index 0000000..335c4bb
--- /dev/null
+++ b/TokenStepSwift/Sources/TokenStepSwift/Services/Collector/UsageCollector+Claude.swift
@@ -0,0 +1,210 @@
+import Foundation
+
+extension UsageCollector {
+ static func collectClaudeCode(
+ cache: inout CollectorCache,
+ livePaths: inout Set,
+ rootURL: URL = FileManager.default.homeDirectoryForCurrentUser
+ .appendingPathComponent(".claude/projects", isDirectory: true),
+ modifiedSince cutoffDate: Date?,
+ forceFullValidation: Bool = false
+ ) -> CollectorResult {
+ let root = rootURL
+ let paths = jsonlFiles(under: root, modifiedSince: cutoffDate)
+ var records: [UsageRecord] = []
+
+ for path in paths.sorted(by: { $0.path < $1.path }) {
+ livePaths.insert(path.path)
+ if let cached = cachedRecords(for: path, tool: "Claude Code", cache: cache) {
+ records.append(contentsOf: cached)
+ continue
+ }
+ guard FileManager.default.isReadableFile(atPath: path.path) else { continue }
+
+ // Active sessions only ever grow, so resume after the last complete line
+ // instead of re-reading the whole transcript on every collection.
+ var state = forceFullValidation
+ ? ClaudeFileState()
+ : resumableClaudeState(for: path, cache: cache) ?? ClaudeFileState()
+ let scan = scanClaudeFile(at: path, state: &state)
+ records.append(contentsOf: scan.records)
+ updateCache(
+ path: path,
+ tool: "Claude Code",
+ records: scan.records,
+ claudeState: scan.stateIsComplete ? state : nil,
+ cache: &cache
+ )
+ }
+
+ return CollectorResult(
+ records: records,
+ source: SourceInfo(
+ status: records.isEmpty ? "missing" : "ok",
+ files: paths.count,
+ records: records.count
+ )
+ )
+ }
+
+ static func resumableClaudeState(for path: URL, cache: CollectorCache) -> ClaudeFileState? {
+ guard let cached = cache.files[path.path],
+ cached.tool == "Claude Code",
+ let state = cached.claudeState,
+ let metadata = fileMetadata(for: path),
+ metadata.size >= state.processedBytes,
+ let prefix = contentFingerprint(for: path, size: state.processedBytes),
+ prefix == state.prefixFingerprint
+ else {
+ return nil
+ }
+ return state
+ }
+
+ /// Reads complete lines after `state.processedBytes` into `state`. A trailing line
+ /// that is still being written is counted in the returned records but not stored
+ /// in `state`, so the next scan reads it again once it is complete.
+ static func scanClaudeFile(
+ at path: URL,
+ state: inout ClaudeFileState
+ ) -> (records: [UsageRecord], stateIsComplete: Bool) {
+ var lineNumber = state.usageLineCount
+ var candidates = state.candidates
+ let processedBytes: UInt64?
+ do {
+ processedBytes = try forEachCompleteLine(
+ in: path,
+ fromOffset: state.processedBytes,
+ matchingAny: ["usage"]
+ ) { line in
+ autoreleasepool {
+ lineNumber += 1
+ mergeClaudeLine(line, path: path, lineNumber: lineNumber, into: &candidates)
+ }
+ }
+ } catch {
+ processedBytes = nil
+ }
+ guard let processedBytes else {
+ return (orderedClaudeRecords(candidates), false)
+ }
+
+ state.processedBytes = processedBytes
+ state.usageLineCount = lineNumber
+ state.candidates = candidates
+ state.prefixFingerprint = contentFingerprint(for: path, size: processedBytes)
+
+ if let trailing = unterminatedTail(of: path, from: processedBytes),
+ let line = String(data: trailing, encoding: .utf8),
+ line.contains("usage") {
+ mergeClaudeLine(line, path: path, lineNumber: lineNumber + 1, into: &candidates)
+ }
+ return (orderedClaudeRecords(candidates), state.prefixFingerprint != nil)
+ }
+
+ /// File order keeps downstream floating-point cost sums identical no matter
+ /// how the candidate dictionary happens to iterate.
+ static func orderedClaudeRecords(_ candidates: [String: ClaudeUsageCandidate]) -> [UsageRecord] {
+ candidates.values
+ .sorted { $0.lineNumber < $1.lineNumber }
+ .map(\.record)
+ }
+
+ static func mergeClaudeLine(
+ _ line: String,
+ path: URL,
+ lineNumber: Int,
+ into candidates: inout [String: ClaudeUsageCandidate]
+ ) {
+ guard let obj = jsonObject(line),
+ obj["type"] as? String == "assistant",
+ let message = obj["message"] as? [String: Any]
+ else {
+ return
+ }
+
+ let usage = normalizeUsage(message["usage"] as? [String: Any])
+ guard usage.totalTokens > 0,
+ let timestamp = obj["timestamp"] as? String,
+ let day = dayString(fromISO: timestamp)
+ else {
+ return
+ }
+
+ let identity = claudeIdentity(obj: obj, message: message, path: path, lineNumber: lineNumber)
+ let candidate = ClaudeUsageCandidate(
+ date: day,
+ timestamp: timestamp,
+ model: modelKey(message["model"] as? String),
+ usage: usage,
+ hasStopReason: hasStopReason(message["stop_reason"]),
+ lineNumber: lineNumber,
+ requestID: identity.requestID,
+ responseID: identity.responseID,
+ sessionID: identity.sessionID,
+ sourcePath: path.path
+ )
+ if let existing = candidates[identity.deduplicationKey],
+ !candidate.isPreferred(over: existing) {
+ return
+ }
+ candidates[identity.deduplicationKey] = candidate
+ }
+
+ static func unterminatedTail(of path: URL, from offset: UInt64) -> Data? {
+ guard let handle = try? FileHandle(forReadingFrom: path) else { return nil }
+ defer { try? handle.close() }
+ guard (try? handle.seek(toOffset: offset)) != nil,
+ let data = try? handle.read(upToCount: maxRelevantLineBytes + 1),
+ !data.isEmpty,
+ data.count <= maxRelevantLineBytes,
+ !data.contains(0x0A)
+ else {
+ return nil
+ }
+ return data
+ }
+
+ static func claudeIdentity(
+ obj: [String: Any],
+ message: [String: Any],
+ path: URL,
+ lineNumber: Int
+ ) -> ClaudeIdentity {
+ let responseID = nonEmptyString(message["id"] as? String)
+ let requestID = [
+ obj["requestId"] as? String,
+ obj["request_id"] as? String,
+ message["requestId"] as? String,
+ message["request_id"] as? String
+ ].compactMap(nonEmptyString).first
+ let sessionID = [
+ obj["sessionId"] as? String,
+ obj["session_id"] as? String,
+ obj["sessionID"] as? String
+ ].compactMap(nonEmptyString).first
+ let uuid = nonEmptyString(obj["uuid"] as? String)
+
+ let deduplicationKey: String
+ if let responseID {
+ deduplicationKey = "response:\(responseID)"
+ } else if let requestID {
+ deduplicationKey = "request:\(requestID)"
+ } else if let uuid {
+ deduplicationKey = "uuid:\(uuid)"
+ } else {
+ deduplicationKey = "line:\(path.path):\(lineNumber)"
+ }
+ return ClaudeIdentity(
+ deduplicationKey: deduplicationKey,
+ requestID: requestID,
+ responseID: responseID,
+ sessionID: sessionID
+ )
+ }
+
+ static func hasStopReason(_ value: Any?) -> Bool {
+ guard let text = value as? String else { return false }
+ return !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ }
+}
diff --git a/TokenStepSwift/Sources/TokenStepSwift/Services/Collector/UsageCollector+Codex.swift b/TokenStepSwift/Sources/TokenStepSwift/Services/Collector/UsageCollector+Codex.swift
new file mode 100644
index 0000000..6850697
--- /dev/null
+++ b/TokenStepSwift/Sources/TokenStepSwift/Services/Collector/UsageCollector+Codex.swift
@@ -0,0 +1,1169 @@
+import Foundation
+
+extension UsageCollector {
+ static func collectCodex(
+ cache: inout CollectorCache,
+ livePaths: inout Set,
+ modifiedSince cutoffDate: Date?,
+ databaseURL: URL,
+ forceFullValidation: Bool,
+ requiresDetailedRecords: Bool,
+ homeURL: URL = FileManager.default.homeDirectoryForCurrentUser
+ ) -> CodexCollectionOutcome {
+ func runIncremental() throws -> CollectorResult {
+ try collectCodexIncrementally(
+ modifiedSince: cutoffDate,
+ databaseURL: databaseURL,
+ forceFullValidation: forceFullValidation,
+ homeURL: homeURL,
+ requiresDetailedRecords: requiresDetailedRecords,
+ legacyCache: cache
+ )
+ }
+
+ do {
+ let incremental = try runIncremental()
+ if incremental.source.status == "ok" {
+ return CodexCollectionOutcome(result: incremental, usedIncrementalStore: true)
+ }
+ } catch {
+ if let cacheError = error as? CodexIncrementalStoreError,
+ cacheError.shouldRebuildCache {
+ CodexIncrementalStore.discardDatabase(at: databaseURL)
+ if let rebuilt = try? runIncremental(), rebuilt.source.status == "ok" {
+ return CodexCollectionOutcome(result: rebuilt, usedIncrementalStore: true)
+ }
+ }
+ }
+
+ let jsonlResult = collectCodexFromJSONL(
+ cache: &cache,
+ livePaths: &livePaths,
+ modifiedSince: cutoffDate,
+ homeURL: homeURL
+ )
+ if jsonlResult.source.status == "ok" {
+ return CodexCollectionOutcome(result: jsonlResult, usedIncrementalStore: false)
+ }
+ return CodexCollectionOutcome(
+ result: collectCodexFromSQLite() ?? jsonlResult,
+ usedIncrementalStore: false
+ )
+ }
+
+ static func collectCodexIncrementally(
+ modifiedSince cutoffDate: Date?,
+ databaseURL: URL,
+ forceFullValidation: Bool,
+ homeURL: URL = FileManager.default.homeDirectoryForCurrentUser,
+ requiresDetailedRecords: Bool = false,
+ legacyCache: CollectorCache? = nil,
+ roots: [URL]? = nil
+ ) throws -> CollectorResult {
+ let paths = (roots ?? defaultCodexSessionRoots(homeURL: homeURL))
+ .flatMap { jsonlFiles(under: $0, modifiedSince: cutoffDate) }
+ .sorted { $0.path < $1.path }
+ guard !paths.isEmpty else {
+ return CollectorResult(
+ records: [],
+ source: SourceInfo(status: "missing", files: 0, records: 0)
+ )
+ }
+
+ let store = try CodexIncrementalStore(url: databaseURL)
+ let storedMetadata = try store.metadataByPath()
+ let currentPaths = Set(paths.map(\.path))
+ let deletedPaths = Set(storedMetadata.keys).subtracting(currentPaths)
+ var fullyAffectedParentIDs = Set(
+ deletedPaths.compactMap { storedMetadata[$0]?.sessionID }
+ )
+ var appendedParentAnchorThresholds = [String: TimeInterval]()
+ var stagedPaths = Set()
+ try store.beginStaging()
+ var committed = false
+ defer {
+ if !committed {
+ store.abortStaging()
+ }
+ }
+
+ func validatedScan(
+ at path: URL,
+ metadata: (size: UInt64, modificationTime: TimeInterval)
+ ) throws -> PendingCodexSession {
+ if !forceFullValidation,
+ let legacyCache,
+ let scan = cachedCodexScan(for: path, cache: legacyCache),
+ let fingerprint = contentFingerprint(for: path, size: metadata.size) {
+ return PendingCodexSession(
+ path: path,
+ metadata: metadata,
+ fingerprint: fingerprint,
+ scan: scan
+ )
+ }
+
+ guard var stable = stableCodexScan(at: path) else {
+ throw CodexIncrementalStoreError.unstableSource(path.path)
+ }
+ if !stable.isStable, let retry = stableCodexScan(at: path) {
+ stable = retry
+ }
+ guard stable.isStable,
+ let fingerprint = contentFingerprint(for: path, size: stable.metadata.size)
+ else {
+ throw CodexIncrementalStoreError.unstableSource(path.path)
+ }
+ return PendingCodexSession(
+ path: path,
+ metadata: stable.metadata,
+ fingerprint: fingerprint,
+ scan: stable.scan
+ )
+ }
+
+ for path in paths {
+ guard let metadata = fileMetadata(for: path) else { continue }
+ let stored = storedMetadata[path.path]
+ var validatedFullFingerprint: String?
+ let metadataMatches = stored?.size == metadata.size
+ && abs((stored?.modificationTime ?? -1) - metadata.modificationTime) < 0.001
+ if metadataMatches {
+ if !forceFullValidation {
+ continue
+ }
+ let fingerprint = contentFingerprint(for: path, size: metadata.size)
+ if fingerprint == stored?.fingerprint {
+ guard let fullFingerprint = fullContentFingerprint(
+ for: path,
+ size: metadata.size
+ ),
+ let afterValidation = fileMetadata(for: path),
+ UsageCollector.metadata(metadata, matches: afterValidation)
+ else {
+ throw CodexIncrementalStoreError.unstableSource(path.path)
+ }
+ if fullFingerprint == stored?.validationFingerprint {
+ continue
+ }
+ validatedFullFingerprint = fullFingerprint
+ }
+ }
+
+ if !forceFullValidation,
+ let stored,
+ metadata.size > stored.size,
+ contentFingerprint(for: path, size: stored.size) == stored.fingerprint,
+ let cachedSession = try store.session(path: path.path),
+ let appended = incrementalCodexAppend(at: path, cached: cachedSession) {
+ try store.stage(session: appended)
+ if let earliestNewAnchor = appended.anchors
+ .dropFirst(cachedSession.anchors.count)
+ .first?.timestamp {
+ appendedParentAnchorThresholds[appended.sessionID] = min(
+ appendedParentAnchorThresholds[appended.sessionID] ?? earliestNewAnchor,
+ earliestNewAnchor
+ )
+ }
+ continue
+ }
+
+ var pending = try validatedScan(at: path, metadata: metadata)
+ if validatedFullFingerprint != nil {
+ pending.validationFingerprint = validatedFullFingerprint
+ } else if forceFullValidation, stored != nil, metadataMatches {
+ guard let fullFingerprint = fullContentFingerprint(
+ for: path,
+ size: pending.metadata.size
+ ),
+ let afterValidation = fileMetadata(for: path),
+ UsageCollector.metadata(pending.metadata, matches: afterValidation)
+ else {
+ throw CodexIncrementalStoreError.unstableSource(path.path)
+ }
+ pending.validationFingerprint = fullFingerprint
+ }
+ try store.stage(
+ scan: pending,
+ anchors: codexAnchors(for: pending.scan),
+ createdAtEpoch: pending.scan.createdAt.flatMap(parseISO)?.timeIntervalSince1970
+ )
+ stagedPaths.insert(path.path)
+ fullyAffectedParentIDs.insert(pending.scan.canonicalSessionID)
+ if let previousID = stored?.sessionID {
+ fullyAffectedParentIDs.insert(previousID)
+ }
+ }
+
+ func stageChild(at childPath: String) throws {
+ guard currentPaths.contains(childPath), !stagedPaths.contains(childPath) else { return }
+ let url = URL(fileURLWithPath: childPath)
+ guard let metadata = fileMetadata(for: url) else {
+ throw CodexIncrementalStoreError.unstableSource(childPath)
+ }
+ var pending = try validatedScan(at: url, metadata: metadata)
+ if let stored = storedMetadata[childPath],
+ stored.size == metadata.size,
+ abs(stored.modificationTime - metadata.modificationTime) < 0.001,
+ contentFingerprint(for: url, size: metadata.size) == stored.fingerprint {
+ pending.validationFingerprint = stored.validationFingerprint
+ }
+ try store.stage(
+ scan: pending,
+ anchors: codexAnchors(for: pending.scan),
+ createdAtEpoch: pending.scan.createdAt.flatMap(parseISO)?.timeIntervalSince1970
+ )
+ stagedPaths.insert(childPath)
+ }
+
+ for parentID in fullyAffectedParentIDs {
+ for childPath in try store.childPaths(parentSessionID: parentID) {
+ try stageChild(at: childPath)
+ }
+ }
+ for (parentID, earliestNewAnchor) in appendedParentAnchorThresholds
+ where !fullyAffectedParentIDs.contains(parentID) {
+ for childPath in try store.childPaths(
+ parentSessionID: parentID,
+ createdAtOnOrAfter: earliestNewAnchor
+ ) {
+ try stageChild(at: childPath)
+ }
+ }
+
+ for stagedPath in try store.stagedScanPaths() {
+ guard let item = try store.stagedScan(path: stagedPath) else {
+ throw CodexIncrementalStoreError.sqlite("missing staged scan for \(stagedPath)")
+ }
+ let parentAnchors: [CodexAnchor]?
+ if let parentID = item.scan.parentSessionID {
+ if let pendingParent = try store.stagedAnchors(sessionID: parentID) {
+ parentAnchors = pendingParent
+ } else {
+ parentAnchors = try store.anchors(sessionID: parentID)
+ }
+ } else {
+ parentAnchors = nil
+ }
+ let childCreatedAt = item.scan.createdAt.flatMap(parseISO)?.timeIntervalSince1970
+ let parentAnchor = childCreatedAt.flatMap { timestamp in
+ parentAnchors.flatMap { codexAnchor(atOrBefore: timestamp, anchors: $0) }
+ }
+ var seenRequestIDs = Set()
+ let result = codexDeltaRecords(
+ from: item.scan,
+ parentAnchor: parentAnchor,
+ seenRequestIDs: &seenRequestIDs
+ )
+ let candidate = CodexCachedSession(
+ path: item.path.path,
+ size: item.metadata.size,
+ modificationTime: item.metadata.modificationTime,
+ fingerprint: item.fingerprint,
+ validationFingerprint: item.validationFingerprint,
+ sessionID: item.scan.canonicalSessionID,
+ createdAtEpoch: childCreatedAt,
+ parentSessionID: item.scan.parentSessionID,
+ anchors: try store.stagedAnchors(
+ sessionID: item.scan.canonicalSessionID
+ ) ?? [],
+ records: result.records,
+ summaryRecords: summarizeCodexRecords(result.records),
+ cursor: CodexSessionCursor(
+ currentModel: item.scan.finalModel ?? item.scan.events.last?.model ?? "unknown",
+ relevantLineNumber: item.scan.relevantLineCount ?? item.scan.events.count,
+ hasCumulativeSchema: result.cursor.hasCumulativeSchema,
+ previousCumulative: result.cursor.previousCumulative,
+ epoch: result.cursor.epoch
+ ),
+ diagnostics: result.diagnostics
+ )
+ if let existing = try store.session(path: item.path.path),
+ candidate.hasSameStoredAccounting(as: existing) {
+ if let validationFingerprint = candidate.validationFingerprint,
+ validationFingerprint != existing.validationFingerprint {
+ try store.updateValidationFingerprint(
+ validationFingerprint,
+ path: item.path.path
+ )
+ }
+ } else {
+ try store.stage(session: candidate)
+ }
+ }
+
+ try store.commitStaged(deletedPaths: deletedPaths)
+ committed = true
+ let cachedSessionCount = try store.sessionCount()
+ guard cachedSessionCount == paths.count else {
+ throw CodexIncrementalStoreError.incompleteCache(
+ expected: paths.count,
+ actual: cachedSessionCount
+ )
+ }
+
+ var seenRequestIDs = Set()
+ var records = [UsageRecord]()
+ var summaries = [CodexSummaryKey: CodexSummaryAccumulator]()
+ var diagnostics = CodexCollectionDiagnostics()
+ var sourceRecordCount = 0
+ try store.forEachContribution(detailed: requiresDetailedRecords) { contribution in
+ sourceRecordCount += contribution.recordCount
+ diagnostics.add(contribution.diagnostics)
+ for record in contribution.records {
+ if let requestID = record.requestID,
+ !seenRequestIDs.insert(requestID).inserted {
+ diagnostics.duplicateRecords += 1
+ continue
+ }
+ if requiresDetailedRecords {
+ records.append(record)
+ } else {
+ addCodexSummary(record, to: &summaries)
+ }
+ }
+ }
+ if !requiresDetailedRecords {
+ records = codexSummaryRecords(summaries)
+ }
+ return codexCollectorResult(
+ records: records,
+ diagnostics: diagnostics,
+ fileCount: paths.count,
+ sourceRecordCount: sourceRecordCount
+ )
+ }
+
+ static func collectCodexFromSQLite() -> CollectorResult? {
+ let home = FileManager.default.homeDirectoryForCurrentUser
+ let candidates = [
+ home.appendingPathComponent(".codex/state_5.sqlite"),
+ home.appendingPathComponent(".codex/sqlite/state_5.sqlite")
+ ]
+ guard let database = candidates.first(where: { FileManager.default.fileExists(atPath: $0.path) }) else {
+ return nil
+ }
+
+ // Route through SQLiteReadonly: it streams sqlite3 output to a file, so large
+ // result sets cannot fill a pipe buffer and deadlock the child process.
+ let query = "select created_at, model, tokens_used from threads where tokens_used > 0"
+ guard let rows = sqliteJSONRows(database: database, query: query) else {
+ return nil
+ }
+
+ let records = rows.compactMap { row -> UsageRecord? in
+ let tokens = integerValue(row["tokens_used"] as Any)
+ guard tokens > 0,
+ let day = dayString(fromEpoch: row["created_at"] as Any)
+ else {
+ return nil
+ }
+ var usage = TokenUsageCounts()
+ usage.totalTokens = tokens
+ return UsageRecord(
+ date: day,
+ timestamp: nil,
+ tool: "Codex",
+ model: modelKey(row["model"] as? String),
+ usage: usage,
+ source: .nativeCodexSQLite
+ )
+ }
+
+ guard !records.isEmpty else { return nil }
+ return CollectorResult(
+ records: records,
+ source: SourceInfo(
+ status: "ok_sqlite",
+ files: 1,
+ records: records.count
+ )
+ )
+ }
+
+ static func collectCodexFromJSONL(
+ cache: inout CollectorCache,
+ livePaths: inout Set,
+ modifiedSince cutoffDate: Date?,
+ homeURL: URL = FileManager.default.homeDirectoryForCurrentUser,
+ roots: [URL]? = nil
+ ) -> CollectorResult {
+ let roots = roots ?? defaultCodexSessionRoots(homeURL: homeURL)
+ let paths = roots
+ .flatMap { jsonlFiles(under: $0, modifiedSince: cutoffDate) }
+ .sorted { $0.path < $1.path }
+ var scans: [CodexSessionScan] = []
+
+ for path in paths {
+ livePaths.insert(path.path)
+ if let cached = cachedCodexScan(for: path, cache: cache) {
+ scans.append(cached)
+ continue
+ }
+
+ guard var result = stableCodexScan(at: path) else { continue }
+ if !result.isStable, let retry = stableCodexScan(at: path) {
+ result = retry
+ }
+ scans.append(result.scan)
+ if result.isStable {
+ updateCodexCache(path: path, scan: result.scan, metadata: result.metadata, cache: &cache)
+ }
+ }
+
+ let scansBySessionID = Dictionary(
+ scans.map { ($0.canonicalSessionID, $0) },
+ uniquingKeysWith: { first, _ in first }
+ )
+ let anchorsBySessionID = scansBySessionID.mapValues(codexAnchors)
+ var records: [UsageRecord] = []
+ var diagnostics = CodexCollectionDiagnostics()
+ var seenRequestIDs = Set()
+ for scan in scans.sorted(by: { $0.sourcePath < $1.sourcePath }) {
+ let parentAnchor = codexForkAnchor(for: scan, anchorsBySessionID: anchorsBySessionID)
+ let result = codexDeltaRecords(
+ from: scan,
+ parentAnchor: parentAnchor,
+ seenRequestIDs: &seenRequestIDs
+ )
+ records.append(contentsOf: result.records)
+ diagnostics.add(result.diagnostics)
+ }
+
+ return codexCollectorResult(
+ records: records,
+ diagnostics: diagnostics,
+ fileCount: paths.count
+ )
+ }
+
+ static func codexCollectorResult(
+ records: [UsageRecord],
+ diagnostics: CodexCollectionDiagnostics,
+ fileCount: Int,
+ sourceRecordCount: Int? = nil
+ ) -> CollectorResult {
+ let breakdown = records.reduce(into: TokenUsageCounts()) { partial, record in
+ partial.add(record.usage)
+ }
+ return CollectorResult(
+ records: records,
+ source: SourceInfo(
+ status: records.isEmpty ? "missing" : "ok",
+ files: fileCount,
+ records: sourceRecordCount ?? records.count,
+ rawRecords: diagnostics.rawRecords,
+ dedupedRecords: diagnostics.duplicateRecords + diagnostics.inheritedRecords,
+ skippedRecords: diagnostics.skippedRecords,
+ strategy: "total_token_usage_delta_v6_with_incremental_cache",
+ exactRecords: diagnostics.exactRecords,
+ legacyRecords: diagnostics.legacyRecords,
+ duplicateRecords: diagnostics.duplicateRecords,
+ counterResets: diagnostics.counterResets,
+ inheritedRecords: diagnostics.inheritedRecords,
+ inheritedTokens: diagnostics.inheritedTokens,
+ unknownBreakdownRecords: diagnostics.unknownBreakdownRecords,
+ accountingRevision: codexAccountingRevision,
+ tokenBreakdown: SourceTokenBreakdown(
+ processedTokens: breakdown.totalTokens,
+ inputTokens: breakdown.inputTokens,
+ cachedInputTokens: breakdown.cacheReadInputTokens,
+ uncachedInputTokens: max(
+ 0,
+ breakdown.inputTokens
+ - breakdown.cacheReadInputTokens
+ - breakdown.cacheCreationInputTokens
+ ),
+ outputTokens: breakdown.outputTokens,
+ reasoningTokens: breakdown.reasoningOutputTokens
+ )
+ )
+ )
+ }
+
+ static func summarizeCodexRecords(_ records: [UsageRecord]) -> [UsageRecord] {
+ var summaries = [CodexSummaryKey: CodexSummaryAccumulator]()
+ for record in records {
+ addCodexSummary(record, to: &summaries)
+ }
+ return codexSummaryRecords(summaries)
+ }
+
+ static func addCodexSummary(
+ _ record: UsageRecord,
+ to summaries: inout [CodexSummaryKey: CodexSummaryAccumulator]
+ ) {
+ let hour = record.timestampEpoch.map(hour(fromEpoch:))
+ ?? hour(fromISO: record.timestamp)
+ let key = CodexSummaryKey(date: record.date, model: record.model, hour: hour)
+ summaries[key, default: CodexSummaryAccumulator()].add(record)
+ }
+
+ static func codexSummaryRecords(
+ _ summaries: [CodexSummaryKey: CodexSummaryAccumulator]
+ ) -> [UsageRecord] {
+ summaries.map { key, value in
+ UsageRecord(
+ date: key.date,
+ timestamp: value.timestamp,
+ timestampEpoch: value.timestampEpoch,
+ tool: "Codex",
+ model: key.model,
+ usage: value.usage,
+ source: .nativeCodex,
+ dataSource: "codex_incremental_summary",
+ modelRequestCount: value.modelRequestCount,
+ toolCallCount: value.toolCallCount
+ )
+ }.sorted {
+ if $0.date != $1.date { return $0.date < $1.date }
+ if $0.model != $1.model { return $0.model < $1.model }
+ return ($0.timestampEpoch ?? -1) < ($1.timestampEpoch ?? -1)
+ }
+ }
+
+ static func stableCodexScan(
+ at path: URL
+ ) -> (scan: CodexSessionScan, isStable: Bool, metadata: (size: UInt64, modificationTime: TimeInterval))? {
+ guard let before = fileMetadata(for: path),
+ let scan = scanCodexSessionFile(at: path),
+ let after = fileMetadata(for: path)
+ else {
+ return nil
+ }
+ return (scan, metadata(before, matches: after), after)
+ }
+
+ static func incrementalCodexAppend(
+ at path: URL,
+ cached: CodexCachedSession
+ ) -> CodexCachedSession? {
+ guard let tail = scanCodexSessionTail(
+ at: path,
+ fromOffset: cached.size,
+ cursor: cached.cursor
+ ) else {
+ return nil
+ }
+
+ var records = cached.records
+ var diagnostics = cached.diagnostics
+ var cursor = cached.cursor
+ diagnostics.rawRecords += tail.events.count
+ var seenRequestIDs = Set(records.compactMap(\.requestID))
+ let scan = CodexSessionScan(
+ canonicalSessionID: cached.sessionID,
+ createdAt: cached.createdAtEpoch.map { isoFormatter.string(from: Date(timeIntervalSince1970: $0)) },
+ parentSessionID: cached.parentSessionID,
+ sourcePath: cached.path,
+ events: tail.events,
+ finalModel: tail.currentModel,
+ relevantLineCount: tail.relevantLineNumber
+ )
+
+ if cursor.hasCumulativeSchema {
+ var previous = cursor.previousCumulative
+ var epoch = cursor.epoch
+ for index in tail.events.indices {
+ let event = tail.events[index]
+ guard event.cumulativePresent else {
+ diagnostics.skippedRecords += 1
+ continue
+ }
+ guard let current = event.cumulative,
+ current.totalTokens > 0,
+ let day = dayString(for: event)
+ else {
+ diagnostics.skippedRecords += 1
+ continue
+ }
+
+ let deltaTotal: Int
+ let isReset: Bool
+ if let previous {
+ if current.totalTokens == previous.totalTokens {
+ diagnostics.duplicateRecords += 1
+ continue
+ }
+ if current.totalTokens > previous.totalTokens {
+ deltaTotal = current.totalTokens - previous.totalTokens
+ isReset = false
+ } else if isCodexContextWindowSentinel(event) {
+ diagnostics.skippedRecords += 1
+ continue
+ } else if isCredibleCodexReset(
+ at: index,
+ events: tail.events,
+ current: current,
+ previous: previous
+ ) {
+ epoch += 1
+ diagnostics.counterResets += 1
+ deltaTotal = current.totalTokens
+ isReset = true
+ } else {
+ // Re-read the complete session so an ambiguous reset can be
+ // reconsidered when a following cumulative event arrives.
+ return nil
+ }
+ } else {
+ deltaTotal = current.totalTokens
+ isReset = false
+ }
+
+ guard deltaTotal > 0 else { continue }
+ let componentResult = codexIncrementUsage(
+ current: current,
+ previous: isReset ? nil : previous,
+ last: event.last,
+ total: deltaTotal
+ )
+ let requestID = "codex:cumulative:\(cached.sessionID):\(epoch):\(current.totalTokens)"
+ guard seenRequestIDs.insert(requestID).inserted else {
+ diagnostics.duplicateRecords += 1
+ previous = current
+ continue
+ }
+ records.append(
+ codexUsageRecord(
+ scan: scan,
+ event: event,
+ day: day,
+ usage: componentResult.usage,
+ requestID: requestID,
+ dataSource: componentResult.hasKnownBreakdown
+ ? "codex_total_usage_delta"
+ : "codex_total_usage_delta_unknown_breakdown"
+ )
+ )
+ diagnostics.exactRecords += 1
+ if !componentResult.hasKnownBreakdown {
+ diagnostics.unknownBreakdownRecords += 1
+ }
+ previous = current
+ }
+ cursor.previousCumulative = previous
+ cursor.epoch = epoch
+ } else {
+ guard !tail.events.contains(where: \.cumulativePresent) else {
+ return nil
+ }
+ for event in tail.events {
+ guard let usage = event.last,
+ usage.totalTokens > 0,
+ let timestamp = event.timestamp,
+ let day = dayString(for: event)
+ else {
+ diagnostics.skippedRecords += 1
+ continue
+ }
+ let requestID = "codex:legacy:\(cached.sessionID):\(timestamp):\(usage.fingerprint)"
+ guard seenRequestIDs.insert(requestID).inserted else {
+ diagnostics.duplicateRecords += 1
+ continue
+ }
+ records.append(
+ codexUsageRecord(
+ scan: scan,
+ event: event,
+ day: day,
+ usage: usage,
+ requestID: requestID,
+ dataSource: "codex_last_usage_legacy_estimate"
+ )
+ )
+ diagnostics.legacyRecords += 1
+ if !isCodexBreakdownConsistent(usage, total: usage.totalTokens) {
+ diagnostics.unknownBreakdownRecords += 1
+ }
+ }
+ }
+
+ cursor.currentModel = tail.currentModel
+ cursor.relevantLineNumber = tail.relevantLineNumber
+ return CodexCachedSession(
+ path: cached.path,
+ size: tail.processedSize,
+ modificationTime: tail.modificationTime,
+ fingerprint: tail.fingerprint,
+ validationFingerprint: nil,
+ sessionID: cached.sessionID,
+ createdAtEpoch: cached.createdAtEpoch,
+ parentSessionID: cached.parentSessionID,
+ anchors: (cached.anchors + codexAnchors(for: scan))
+ .sorted { $0.timestamp < $1.timestamp },
+ records: records,
+ summaryRecords: summarizeCodexRecords(records),
+ cursor: cursor,
+ diagnostics: diagnostics
+ )
+ }
+
+ static func scanCodexSessionTail(
+ at path: URL,
+ fromOffset offset: UInt64,
+ cursor: CodexSessionCursor
+ ) -> CodexSessionTail? {
+ guard let metadata = fileMetadata(for: path), metadata.size > offset else { return nil }
+
+ do {
+ if offset > 0 {
+ let handle = try FileHandle(forReadingFrom: path)
+ defer { try? handle.close() }
+ try handle.seek(toOffset: offset - 1)
+ guard try handle.read(upToCount: 1)?.first == 0x0A else { return nil }
+ }
+
+ var currentModel = cursor.currentModel
+ var relevantLineNumber = cursor.relevantLineNumber
+ var events = [CodexTokenEvent]()
+ var encounteredSessionMetadata = false
+ let processedSize = try forEachCompleteLine(
+ in: path,
+ fromOffset: offset,
+ matchingAny: ["session_meta", "turn_context", "token_count"]
+ ) { line in
+ autoreleasepool {
+ relevantLineNumber += 1
+ guard line.utf8.count <= maxRelevantLineBytes,
+ let obj = jsonObject(line)
+ else { return }
+ let type = obj["type"] as? String
+ let payload = obj["payload"] as? [String: Any]
+ if type == "session_meta" {
+ encounteredSessionMetadata = true
+ return
+ }
+ if type == "turn_context" {
+ currentModel = modelKey(payload?["model"] as? String ?? currentModel)
+ }
+ guard type == "event_msg",
+ payload?["type"] as? String == "token_count",
+ let info = payload?["info"] as? [String: Any]
+ else { return }
+ let timestamp = nonEmptyString(obj["timestamp"] as? String)
+ events.append(
+ CodexTokenEvent(
+ timestamp: timestamp,
+ timestampEpoch: timestamp.flatMap(parseISO)?.timeIntervalSince1970,
+ model: currentModel,
+ cumulativePresent: info.keys.contains("total_token_usage"),
+ cumulative: (info["total_token_usage"] as? [String: Any]).map(normalizeCodexUsage),
+ last: (info["last_token_usage"] as? [String: Any]).map(normalizeCodexUsage),
+ modelContextWindow: integerValue(info["model_context_window"] as Any),
+ lineNumber: relevantLineNumber
+ )
+ )
+ }
+ }
+
+ guard processedSize > offset, !encounteredSessionMetadata else { return nil }
+ guard let finalMetadata = fileMetadata(for: path),
+ let fingerprint = contentFingerprint(for: path, size: processedSize)
+ else { return nil }
+ return CodexSessionTail(
+ events: events,
+ currentModel: currentModel,
+ relevantLineNumber: relevantLineNumber,
+ processedSize: processedSize,
+ modificationTime: finalMetadata.modificationTime,
+ fingerprint: fingerprint
+ )
+ } catch {
+ return nil
+ }
+ }
+
+ static func scanCodexSessionFile(at path: URL) -> CodexSessionScan? {
+ guard FileManager.default.isReadableFile(atPath: path.path) else { return nil }
+ var canonicalSessionID: String?
+ var createdAt: String?
+ var parentSessionID: String?
+ var currentModel = "unknown"
+ var events: [CodexTokenEvent] = []
+ var relevantLineNumber = 0
+
+ do {
+ try forEachLine(in: path, matchingAny: ["session_meta", "turn_context", "token_count"]) { line in
+ autoreleasepool {
+ relevantLineNumber += 1
+ guard let obj = jsonObject(line) else { return }
+ let type = obj["type"] as? String
+ let payload = obj["payload"] as? [String: Any]
+
+ if type == "session_meta", canonicalSessionID == nil,
+ let id = nonEmptyString(payload?["id"] as? String) {
+ canonicalSessionID = id
+ createdAt = nonEmptyString(obj["timestamp"] as? String)
+ ?? nonEmptyString(payload?["timestamp"] as? String)
+ parentSessionID = codexParentSessionID(from: payload)
+ }
+ if type == "turn_context" {
+ currentModel = modelKey(payload?["model"] as? String ?? currentModel)
+ }
+ guard type == "event_msg",
+ payload?["type"] as? String == "token_count",
+ let info = payload?["info"] as? [String: Any]
+ else {
+ return
+ }
+
+ let timestamp = nonEmptyString(obj["timestamp"] as? String)
+ let cumulativePresent = info.keys.contains("total_token_usage")
+ let cumulative = (info["total_token_usage"] as? [String: Any]).map(normalizeCodexUsage)
+ let last = (info["last_token_usage"] as? [String: Any]).map(normalizeCodexUsage)
+ events.append(
+ CodexTokenEvent(
+ timestamp: timestamp,
+ timestampEpoch: timestamp.flatMap(parseISO)?.timeIntervalSince1970,
+ model: currentModel,
+ cumulativePresent: cumulativePresent,
+ cumulative: cumulative,
+ last: last,
+ modelContextWindow: integerValue(info["model_context_window"] as Any),
+ lineNumber: relevantLineNumber
+ )
+ )
+ }
+ }
+ } catch {
+ return nil
+ }
+
+ return CodexSessionScan(
+ canonicalSessionID: canonicalSessionID ?? path.deletingPathExtension().lastPathComponent,
+ createdAt: createdAt,
+ parentSessionID: parentSessionID,
+ sourcePath: path.path,
+ events: events,
+ finalModel: currentModel,
+ relevantLineCount: relevantLineNumber
+ )
+ }
+
+ static func codexParentSessionID(from payload: [String: Any]?) -> String? {
+ if let source = payload?["source"] as? [String: Any],
+ let subagent = source["subagent"] as? [String: Any],
+ let threadSpawn = subagent["thread_spawn"] as? [String: Any],
+ let parent = nonEmptyString(threadSpawn["parent_thread_id"] as? String) {
+ return parent
+ }
+ return [
+ payload?["parent_thread_id"] as? String,
+ payload?["forked_from_id"] as? String
+ ].compactMap(nonEmptyString).first
+ }
+
+ static func codexForkAnchor(
+ for scan: CodexSessionScan,
+ anchorsBySessionID: [String: [CodexAnchor]]
+ ) -> TokenUsageCounts? {
+ guard let parentID = scan.parentSessionID,
+ let anchors = anchorsBySessionID[parentID],
+ let childCreatedAt = scan.createdAt.flatMap(parseISO)?.timeIntervalSince1970
+ else {
+ return nil
+ }
+ return codexAnchor(atOrBefore: childCreatedAt, anchors: anchors)
+ }
+
+ static func codexAnchors(for scan: CodexSessionScan) -> [CodexAnchor] {
+ scan.events.compactMap { event in
+ guard event.cumulativePresent,
+ let usage = event.cumulative,
+ usage.totalTokens > 0,
+ let timestamp = event.timestampEpoch
+ ?? event.timestamp.flatMap(parseISO)?.timeIntervalSince1970
+ else {
+ return nil
+ }
+ return CodexAnchor(timestamp: timestamp, usage: usage)
+ }.sorted { $0.timestamp < $1.timestamp }
+ }
+
+ static func codexAnchor(
+ atOrBefore timestamp: TimeInterval,
+ anchors: [CodexAnchor]
+ ) -> TokenUsageCounts? {
+ var lower = 0
+ var upper = anchors.count
+ while lower < upper {
+ let middle = lower + (upper - lower) / 2
+ if anchors[middle].timestamp <= timestamp {
+ lower = middle + 1
+ } else {
+ upper = middle
+ }
+ }
+ guard lower > 0 else { return nil }
+ return anchors[lower - 1].usage
+ }
+
+ static func codexDeltaRecords(
+ from scan: CodexSessionScan,
+ parentAnchor: TokenUsageCounts?,
+ seenRequestIDs: inout Set
+ ) -> (
+ records: [UsageRecord],
+ diagnostics: CodexCollectionDiagnostics,
+ cursor: CodexDeltaCursor
+ ) {
+ var diagnostics = CodexCollectionDiagnostics(rawRecords: scan.events.count)
+ var records: [UsageRecord] = []
+ let hasCumulativeSchema = scan.events.contains { $0.cumulativePresent }
+
+ if !hasCumulativeSchema {
+ for event in scan.events {
+ guard let usage = event.last,
+ usage.totalTokens > 0,
+ let timestamp = event.timestamp,
+ let day = dayString(for: event)
+ else {
+ diagnostics.skippedRecords += 1
+ continue
+ }
+ let requestID = "codex:legacy:\(scan.canonicalSessionID):\(timestamp):\(usage.fingerprint)"
+ guard seenRequestIDs.insert(requestID).inserted else {
+ diagnostics.duplicateRecords += 1
+ continue
+ }
+ records.append(
+ codexUsageRecord(
+ scan: scan,
+ event: event,
+ day: day,
+ usage: usage,
+ requestID: requestID,
+ dataSource: "codex_last_usage_legacy_estimate"
+ )
+ )
+ diagnostics.legacyRecords += 1
+ if !isCodexBreakdownConsistent(usage, total: usage.totalTokens) {
+ diagnostics.unknownBreakdownRecords += 1
+ }
+ }
+ return (
+ records,
+ diagnostics,
+ CodexDeltaCursor(
+ hasCumulativeSchema: false,
+ previousCumulative: nil,
+ epoch: 0
+ )
+ )
+ }
+
+ var startIndex = 0
+ var previous: TokenUsageCounts?
+ if let parentAnchor,
+ parentAnchor.totalTokens > 0,
+ let anchorIndex = scan.events.firstIndex(where: {
+ $0.cumulativePresent && $0.cumulative == parentAnchor
+ }) {
+ previous = parentAnchor
+ startIndex = anchorIndex + 1
+ diagnostics.inheritedRecords = scan.events[...anchorIndex].filter(\.cumulativePresent).count
+ diagnostics.inheritedTokens = parentAnchor.totalTokens
+ }
+
+ var epoch = 0
+ for index in startIndex.. 0,
+ let day = dayString(for: event)
+ else {
+ diagnostics.skippedRecords += 1
+ continue
+ }
+
+ let deltaTotal: Int
+ let isReset: Bool
+ if let previous {
+ if current.totalTokens == previous.totalTokens {
+ diagnostics.duplicateRecords += 1
+ continue
+ }
+ if current.totalTokens > previous.totalTokens {
+ deltaTotal = current.totalTokens - previous.totalTokens
+ isReset = false
+ } else if isCodexContextWindowSentinel(event) {
+ diagnostics.skippedRecords += 1
+ continue
+ } else if isCredibleCodexReset(
+ at: index,
+ events: scan.events,
+ current: current,
+ previous: previous
+ ) {
+ epoch += 1
+ diagnostics.counterResets += 1
+ deltaTotal = current.totalTokens
+ isReset = true
+ } else {
+ diagnostics.skippedRecords += 1
+ continue
+ }
+ } else {
+ deltaTotal = current.totalTokens
+ isReset = false
+ }
+
+ guard deltaTotal > 0 else { continue }
+ let componentResult = codexIncrementUsage(
+ current: current,
+ previous: isReset ? nil : previous,
+ last: event.last,
+ total: deltaTotal
+ )
+ let requestID = "codex:cumulative:\(scan.canonicalSessionID):\(epoch):\(current.totalTokens)"
+ guard seenRequestIDs.insert(requestID).inserted else {
+ diagnostics.duplicateRecords += 1
+ previous = current
+ continue
+ }
+ records.append(
+ codexUsageRecord(
+ scan: scan,
+ event: event,
+ day: day,
+ usage: componentResult.usage,
+ requestID: requestID,
+ dataSource: componentResult.hasKnownBreakdown
+ ? "codex_total_usage_delta"
+ : "codex_total_usage_delta_unknown_breakdown"
+ )
+ )
+ diagnostics.exactRecords += 1
+ if !componentResult.hasKnownBreakdown {
+ diagnostics.unknownBreakdownRecords += 1
+ }
+ previous = current
+ }
+ return (
+ records,
+ diagnostics,
+ CodexDeltaCursor(
+ hasCumulativeSchema: true,
+ previousCumulative: previous,
+ epoch: epoch
+ )
+ )
+ }
+
+ static func codexUsageRecord(
+ scan: CodexSessionScan,
+ event: CodexTokenEvent,
+ day: String,
+ usage: TokenUsageCounts,
+ requestID: String,
+ dataSource: String
+ ) -> UsageRecord {
+ UsageRecord(
+ date: day,
+ timestamp: event.timestamp,
+ timestampEpoch: event.timestampEpoch,
+ tool: "Codex",
+ model: event.model,
+ usage: usage,
+ source: .nativeCodex,
+ requestID: requestID,
+ sessionID: scan.canonicalSessionID,
+ sourcePath: scan.sourcePath,
+ lineNumber: event.lineNumber,
+ dataSource: dataSource
+ )
+ }
+
+ static func codexIncrementUsage(
+ current: TokenUsageCounts,
+ previous: TokenUsageCounts?,
+ last: TokenUsageCounts?,
+ total: Int
+ ) -> (usage: TokenUsageCounts, hasKnownBreakdown: Bool) {
+ if let last,
+ last.totalTokens == total,
+ isCodexBreakdownConsistent(last, total: total) {
+ var result = last
+ result.totalTokens = total
+ return (result, true)
+ }
+
+ let previous = previous ?? TokenUsageCounts()
+ guard current.inputTokens >= previous.inputTokens,
+ current.outputTokens >= previous.outputTokens,
+ current.cacheCreationInputTokens >= previous.cacheCreationInputTokens,
+ current.cacheReadInputTokens >= previous.cacheReadInputTokens,
+ current.reasoningOutputTokens >= previous.reasoningOutputTokens
+ else {
+ return (TokenUsageCounts(totalTokens: total), false)
+ }
+ var result = TokenUsageCounts(
+ inputTokens: current.inputTokens - previous.inputTokens,
+ outputTokens: current.outputTokens - previous.outputTokens,
+ cacheCreationInputTokens: current.cacheCreationInputTokens - previous.cacheCreationInputTokens,
+ cacheReadInputTokens: current.cacheReadInputTokens - previous.cacheReadInputTokens,
+ reasoningOutputTokens: current.reasoningOutputTokens - previous.reasoningOutputTokens,
+ totalTokens: total
+ )
+ guard isCodexBreakdownConsistent(result, total: total) else {
+ result = TokenUsageCounts(totalTokens: total)
+ return (result, false)
+ }
+ return (result, true)
+ }
+
+ static func isCodexBreakdownConsistent(_ usage: TokenUsageCounts, total: Int) -> Bool {
+ usage.inputTokens >= 0
+ && usage.outputTokens >= 0
+ && usage.cacheCreationInputTokens >= 0
+ && usage.cacheReadInputTokens >= 0
+ && usage.reasoningOutputTokens >= 0
+ && usage.inputTokens + usage.outputTokens == total
+ && usage.cacheCreationInputTokens + usage.cacheReadInputTokens <= usage.inputTokens
+ && usage.reasoningOutputTokens <= usage.outputTokens
+ }
+
+ static func isCodexContextWindowSentinel(_ event: CodexTokenEvent) -> Bool {
+ guard let current = event.cumulative else { return false }
+ return current.inputTokens == 0
+ && current.outputTokens == 0
+ && current.cacheCreationInputTokens == 0
+ && current.cacheReadInputTokens == 0
+ && current.reasoningOutputTokens == 0
+ && (event.last?.totalTokens ?? 0) == 0
+ && event.modelContextWindow > 0
+ && current.totalTokens == event.modelContextWindow
+ }
+
+ static func isCredibleCodexReset(
+ at index: Int,
+ events: [CodexTokenEvent],
+ current: TokenUsageCounts,
+ previous: TokenUsageCounts
+ ) -> Bool {
+ if let last = events[index].last,
+ last.totalTokens == current.totalTokens,
+ isCodexBreakdownConsistent(last, total: current.totalTokens) {
+ return true
+ }
+ for candidate in events.dropFirst(index + 1) where candidate.cumulativePresent {
+ guard let next = candidate.cumulative, next.totalTokens > 0 else { continue }
+ if next.totalTokens == current.totalTokens { continue }
+ return next.totalTokens > current.totalTokens && next.totalTokens < previous.totalTokens
+ }
+ return false
+ }
+
+ static func defaultCodexSessionRoots(homeURL: URL) -> [URL] {
+ // archived_sessions may contain restored historical logs with rewritten timestamps.
+ // Only live Codex sessions should count as current usage.
+ [
+ homeURL.appendingPathComponent(".codex/sessions", isDirectory: true)
+ ]
+ }
+}
diff --git a/TokenStepSwift/Sources/TokenStepSwift/Services/Collector/UsageCollector+Dedupe.swift b/TokenStepSwift/Sources/TokenStepSwift/Services/Collector/UsageCollector+Dedupe.swift
new file mode 100644
index 0000000..1d27877
--- /dev/null
+++ b/TokenStepSwift/Sources/TokenStepSwift/Services/Collector/UsageCollector+Dedupe.swift
@@ -0,0 +1,278 @@
+import Foundation
+
+extension UsageCollector {
+ static func deduplicateCrossSource(
+ nativeRecords: [UsageRecord],
+ proxyRecords: [UsageRecord]
+ ) -> CrossSourceDedupeResult {
+ var enrichedNativeRecords = nativeRecords
+ let deduplicableProxyIndices = proxyRecords.indices.filter {
+ isDeduplicableProxyRecord(proxyRecords[$0])
+ }
+ var matchedProxyIndices = Set()
+ var matchedNativeIndices = Set()
+ let skippedProxyRecords = 0
+
+ let exactPairs = uniqueDedupePairs(
+ proxyIndices: deduplicableProxyIndices,
+ nativeIndices: Array(nativeRecords.indices)
+ ) { proxyIndex, nativeIndex in
+ isSameDedupeDomain(
+ proxyRecord: proxyRecords[proxyIndex],
+ nativeRecord: nativeRecords[nativeIndex]
+ ) && hasExactIdentifierMatch(
+ proxyRecord: proxyRecords[proxyIndex],
+ nativeRecord: nativeRecords[nativeIndex]
+ )
+ }
+ applyDedupePairs(
+ exactPairs,
+ proxyRecords: proxyRecords,
+ enrichedNativeRecords: &enrichedNativeRecords,
+ matchedProxyIndices: &matchedProxyIndices,
+ matchedNativeIndices: &matchedNativeIndices
+ )
+
+ // Similar timing/model/token vectors alone are not proof of identity: concurrent
+ // requests can legitimately look the same. A shared session is the minimum
+ // fallback correlation when request/response IDs are unavailable.
+ let remainingProxyIndices = deduplicableProxyIndices.filter { !matchedProxyIndices.contains($0) }
+ let remainingNativeIndices = nativeRecords.indices.filter { !matchedNativeIndices.contains($0) }
+ let sessionPairs = uniqueDedupePairs(
+ proxyIndices: remainingProxyIndices,
+ nativeIndices: Array(remainingNativeIndices)
+ ) { proxyIndex, nativeIndex in
+ isSameDedupeDomain(
+ proxyRecord: proxyRecords[proxyIndex],
+ nativeRecord: nativeRecords[nativeIndex]
+ ) && hasSessionIdentityMatch(
+ proxyRecord: proxyRecords[proxyIndex],
+ nativeRecord: nativeRecords[nativeIndex]
+ )
+ }
+ applyDedupePairs(
+ sessionPairs,
+ proxyRecords: proxyRecords,
+ enrichedNativeRecords: &enrichedNativeRecords,
+ matchedProxyIndices: &matchedProxyIndices,
+ matchedNativeIndices: &matchedNativeIndices
+ )
+
+ let keptProxyRecords = proxyRecords.indices
+ .filter { !matchedProxyIndices.contains($0) }
+ .map { proxyRecords[$0] }
+ return CrossSourceDedupeResult(
+ records: enrichedNativeRecords + keptProxyRecords,
+ rawProxyRecords: proxyRecords.count,
+ keptProxyRecords: keptProxyRecords.count,
+ dedupedProxyRecords: matchedProxyIndices.count,
+ skippedProxyRecords: skippedProxyRecords
+ )
+ }
+
+ static func uniqueDedupePairs(
+ proxyIndices: [Int],
+ nativeIndices: [Int],
+ matches: (Int, Int) -> Bool
+ ) -> [(proxy: Int, native: Int)] {
+ var nativeCandidatesByProxy: [Int: [Int]] = [:]
+ var proxyCandidateCountByNative: [Int: Int] = [:]
+ for proxyIndex in proxyIndices {
+ let candidates = nativeIndices.filter { matches(proxyIndex, $0) }
+ nativeCandidatesByProxy[proxyIndex] = candidates
+ for nativeIndex in candidates {
+ proxyCandidateCountByNative[nativeIndex, default: 0] += 1
+ }
+ }
+ return proxyIndices.compactMap { proxyIndex in
+ guard let candidates = nativeCandidatesByProxy[proxyIndex],
+ candidates.count == 1,
+ let nativeIndex = candidates.first,
+ proxyCandidateCountByNative[nativeIndex] == 1
+ else {
+ return nil
+ }
+ return (proxy: proxyIndex, native: nativeIndex)
+ }
+ }
+
+ static func applyDedupePairs(
+ _ pairs: [(proxy: Int, native: Int)],
+ proxyRecords: [UsageRecord],
+ enrichedNativeRecords: inout [UsageRecord],
+ matchedProxyIndices: inout Set,
+ matchedNativeIndices: inout Set
+ ) {
+ for pair in pairs {
+ enrichedNativeRecords[pair.native] = enrichedRecord(
+ enrichedNativeRecords[pair.native],
+ withProxyCostFrom: proxyRecords[pair.proxy]
+ )
+ matchedProxyIndices.insert(pair.proxy)
+ matchedNativeIndices.insert(pair.native)
+ }
+ }
+
+ static func sourceInfo(
+ _ source: SourceInfo,
+ annotatedWith result: CrossSourceDedupeResult
+ ) -> SourceInfo {
+ var annotated = source
+ annotated.rawRecords = result.rawProxyRecords
+ annotated.dedupedRecords = result.dedupedProxyRecords
+ annotated.skippedRecords = result.skippedProxyRecords
+ annotated.strategy = "request_level_dedupe"
+ annotated.records = result.keptProxyRecords
+ if source.status == "ok",
+ result.rawProxyRecords > 0,
+ result.keptProxyRecords == 0,
+ result.dedupedProxyRecords > 0 {
+ annotated.status = "all_deduped"
+ }
+ return annotated
+ }
+
+ static func isDeduplicableProxyRecord(_ record: UsageRecord) -> Bool {
+ guard record.source == .ccSwitchProxy else { return false }
+ guard let family = toolFamily(for: record.tool) else { return false }
+ return family == "claude" || family == "codex"
+ }
+
+ static func isSameDedupeDomain(proxyRecord: UsageRecord, nativeRecord: UsageRecord) -> Bool {
+ guard proxyRecord.date == nativeRecord.date,
+ let proxyFamily = toolFamily(for: proxyRecord.tool),
+ let nativeFamily = toolFamily(for: nativeRecord.tool),
+ proxyFamily == nativeFamily,
+ nativeRecord.source != .ccSwitchProxy
+ else {
+ return false
+ }
+ return true
+ }
+
+ static func hasExactIdentifierMatch(proxyRecord: UsageRecord, nativeRecord: UsageRecord) -> Bool {
+ let proxyIDs = Set([proxyRecord.requestID, proxyRecord.responseID].compactMap(nonEmptyString))
+ let nativeIDs = Set([nativeRecord.requestID, nativeRecord.responseID].compactMap(nonEmptyString))
+ return !proxyIDs.isDisjoint(with: nativeIDs)
+ }
+
+ static func hasSessionIdentityMatch(proxyRecord: UsageRecord, nativeRecord: UsageRecord) -> Bool {
+ guard let proxySessionID = nonEmptyString(proxyRecord.sessionID),
+ let nativeSessionID = nonEmptyString(nativeRecord.sessionID),
+ proxySessionID == nativeSessionID,
+ areTimestampsClose(proxyRecord.timestamp, nativeRecord.timestamp, seconds: 10),
+ modelsCompatible(proxyRecord.model, nativeRecord.model),
+ usageVectorsClose(proxyRecord: proxyRecord, nativeRecord: nativeRecord)
+ else {
+ return false
+ }
+ return true
+ }
+
+ static func enrichedRecord(
+ _ nativeRecord: UsageRecord,
+ withProxyCostFrom proxyRecord: UsageRecord
+ ) -> UsageRecord {
+ var record = nativeRecord
+ if record.costUSD == nil,
+ let proxyCost = proxyRecord.costUSD,
+ proxyCost > 0 {
+ record.costUSD = proxyCost
+ }
+ return record
+ }
+
+ static func toolFamily(for tool: String) -> String? {
+ let value = tool.lowercased()
+ if value.contains("claude") { return "claude" }
+ if value.contains("codex") { return "codex" }
+ if value.contains("gemini") { return "gemini" }
+ return nil
+ }
+
+ static func areTimestampsClose(_ lhs: String?, _ rhs: String?, seconds: TimeInterval) -> Bool {
+ guard let lhs,
+ let rhs,
+ let lhsDate = parseISO(lhs),
+ let rhsDate = parseISO(rhs)
+ else {
+ return false
+ }
+ return abs(lhsDate.timeIntervalSince(rhsDate)) <= seconds
+ }
+
+ static func modelsCompatible(_ lhs: String, _ rhs: String) -> Bool {
+ let left = canonicalModel(lhs)
+ let right = canonicalModel(rhs)
+ if left == right { return true }
+ guard left != "unknown",
+ right != "unknown",
+ min(left.count, right.count) >= 8
+ else {
+ return false
+ }
+ return left.contains(right) || right.contains(left)
+ }
+
+ static func canonicalModel(_ value: String) -> String {
+ value
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ .lowercased()
+ .replacingOccurrences(of: "_", with: "-")
+ }
+
+ static func usageVectorsClose(_ lhs: TokenUsageCounts, _ rhs: TokenUsageCounts) -> Bool {
+ guard tokenValuesClose(lhs.totalTokens, rhs.totalTokens) else { return false }
+ let pairs = [
+ (lhs.inputTokens, rhs.inputTokens),
+ (lhs.outputTokens, rhs.outputTokens),
+ (lhs.cacheCreationInputTokens, rhs.cacheCreationInputTokens),
+ (lhs.cacheReadInputTokens, rhs.cacheReadInputTokens),
+ (lhs.reasoningOutputTokens, rhs.reasoningOutputTokens)
+ ]
+ return pairs.allSatisfy { pair in
+ let left = pair.0
+ let right = pair.1
+ return left == 0 && right == 0 || tokenValuesClose(left, right)
+ }
+ }
+
+ static func usageVectorsClose(proxyRecord: UsageRecord, nativeRecord: UsageRecord) -> Bool {
+ guard toolFamily(for: proxyRecord.tool) == "codex",
+ toolFamily(for: nativeRecord.tool) == "codex"
+ else {
+ return usageVectorsClose(proxyRecord.usage, nativeRecord.usage)
+ }
+
+ let proxy = proxyRecord.usage
+ let native = nativeRecord.usage
+ guard tokenValuesClose(proxy.outputTokens, native.outputTokens),
+ tokenValuesClose(proxy.cacheReadInputTokens, native.cacheReadInputTokens),
+ tokenValuesClose(proxy.cacheCreationInputTokens, native.cacheCreationInputTokens)
+ else {
+ return false
+ }
+
+ // Native Codex reports cached input as a subset of input. CC Switch versions
+ // have emitted input both inclusive and exclusive of cached input, so compare
+ // both canonical interpretations without changing either source's stored data.
+ let nativeUncachedInput = max(0, native.inputTokens - native.cacheReadInputTokens)
+ let inputMatches = tokenValuesClose(proxy.inputTokens, native.inputTokens)
+ || tokenValuesClose(proxy.inputTokens, nativeUncachedInput)
+ guard inputMatches else { return false }
+
+ let proxyProcessedCandidates = [
+ proxy.inputTokens + proxy.outputTokens,
+ proxy.inputTokens + proxy.cacheReadInputTokens + proxy.cacheCreationInputTokens + proxy.outputTokens
+ ]
+ return proxyProcessedCandidates.contains { tokenValuesClose($0, native.totalTokens) }
+ }
+
+ static func tokenValuesClose(_ lhs: Int, _ rhs: Int) -> Bool {
+ if lhs == rhs { return true }
+ let baseline = max(lhs, rhs)
+ guard baseline > 0 else { return true }
+ let tolerance = max(4, Int((Double(baseline) * 0.01).rounded(.up)))
+ return abs(lhs - rhs) <= tolerance
+ }
+}
diff --git a/TokenStepSwift/Sources/TokenStepSwift/Services/Collector/UsageCollector+Parsing.swift b/TokenStepSwift/Sources/TokenStepSwift/Services/Collector/UsageCollector+Parsing.swift
new file mode 100644
index 0000000..ed7bf0b
--- /dev/null
+++ b/TokenStepSwift/Sources/TokenStepSwift/Services/Collector/UsageCollector+Parsing.swift
@@ -0,0 +1,335 @@
+import Foundation
+
+extension UsageCollector {
+ static func forEachLine(in url: URL, matchingAny markers: [String] = [], _ body: (String) -> Void) throws {
+ let handle = try FileHandle(forReadingFrom: url)
+ defer { try? handle.close() }
+
+ let newline = Data([0x0A])
+ let markerData = markers.map { Data($0.utf8) }
+ var buffer = Data()
+ buffer.reserveCapacity(128 * 1024)
+ var discardingOversizedLine = false
+
+ func processLine(_ lineData: Data) {
+ guard lineMatches(lineData, markers: markerData),
+ let line = String(data: lineData, encoding: .utf8),
+ !line.isEmpty
+ else {
+ return
+ }
+ body(line)
+ }
+
+ while try autoreleasepool(invoking: { () throws -> Bool in
+ guard let chunk = try handle.read(upToCount: 64 * 1024), !chunk.isEmpty else {
+ return false
+ }
+ buffer.append(chunk)
+
+ var consumedEnd = buffer.startIndex
+ var lineStart = buffer.startIndex
+ var searchRange = buffer.startIndex.. lineStart {
+ let lineData = buffer.subdata(in: lineStart.. buffer.startIndex {
+ buffer.removeSubrange(buffer.startIndex.. maxRelevantLineBytes {
+ discardingOversizedLine = true
+ buffer.removeAll(keepingCapacity: true)
+ }
+ return true
+ }) {}
+
+ if !discardingOversizedLine,
+ !buffer.isEmpty,
+ buffer.count <= maxRelevantLineBytes {
+ processLine(buffer)
+ }
+ }
+
+ @discardableResult
+ static func forEachCompleteLine(
+ in url: URL,
+ fromOffset offset: UInt64,
+ matchingAny markers: [String] = [],
+ _ body: (String) -> Void
+ ) throws -> UInt64 {
+ let handle = try FileHandle(forReadingFrom: url)
+ defer { try? handle.close() }
+ try handle.seek(toOffset: offset)
+
+ let newline = Data([0x0A])
+ let markerData = markers.map { Data($0.utf8) }
+ var buffer = Data()
+ buffer.reserveCapacity(128 * 1024)
+ var discardingOversizedLine = false
+ var discardedIncompleteBytes = 0
+ var processedSize = offset
+
+ func processLine(_ lineData: Data) {
+ guard lineMatches(lineData, markers: markerData),
+ let line = String(data: lineData, encoding: .utf8),
+ !line.isEmpty
+ else {
+ return
+ }
+ body(line)
+ }
+
+ while try autoreleasepool(invoking: { () throws -> Bool in
+ guard let chunk = try handle.read(upToCount: 64 * 1024), !chunk.isEmpty else {
+ return false
+ }
+ buffer.append(chunk)
+
+ var consumedEnd = buffer.startIndex
+ var lineStart = buffer.startIndex
+ var searchRange = buffer.startIndex.. lineStart {
+ processLine(buffer.subdata(in: lineStart.. buffer.startIndex {
+ let consumedBytes = buffer.distance(from: buffer.startIndex, to: consumedEnd)
+ processedSize += UInt64(discardedIncompleteBytes + consumedBytes)
+ discardedIncompleteBytes = 0
+ buffer.removeSubrange(buffer.startIndex.. maxRelevantLineBytes {
+ discardingOversizedLine = true
+ discardedIncompleteBytes += buffer.count
+ buffer.removeAll(keepingCapacity: true)
+ }
+ return true
+ }) {}
+
+ return processedSize
+ }
+
+ static func lineMatches(_ data: Data, markers: [Data]) -> Bool {
+ markers.isEmpty || markers.contains { data.range(of: $0) != nil }
+ }
+
+ static func jsonObject(_ line: String) -> [String: Any]? {
+ guard let data = line.data(using: .utf8),
+ let object = try? JSONSerialization.jsonObject(with: data),
+ let dictionary = object as? [String: Any]
+ else {
+ return nil
+ }
+ return dictionary
+ }
+
+ static func normalizeUsage(_ raw: [String: Any]?) -> TokenUsageCounts {
+ guard let raw else { return TokenUsageCounts() }
+ func value(_ keys: [String]) -> Int {
+ for key in keys where raw.keys.contains(key) {
+ return max(0, integerValue(raw[key] as Any))
+ }
+ return 0
+ }
+
+ let explicitTotal = ["total_tokens", "total"].first(where: { raw.keys.contains($0) })
+ .map { max(0, integerValue(raw[$0] as Any)) }
+ return canonicalUsageCounts(
+ rawInputTokens: value(["input_tokens", "input"]),
+ outputTokens: value(["output_tokens", "output"]),
+ cacheCreationInputTokens: value(["cache_creation_input_tokens"]),
+ cacheReadInputTokens: value(["cache_read_input_tokens", "cached_input_tokens", "cached"]),
+ reasoningOutputTokens: value(["reasoning_output_tokens", "reasoning_tokens", "thoughts"]),
+ inputIncludesCachedTokens: false,
+ explicitTotalTokens: explicitTotal
+ )
+ }
+
+ static func normalizeCodexUsage(_ raw: [String: Any]) -> TokenUsageCounts {
+ func value(_ keys: [String]) -> Int {
+ for key in keys where raw.keys.contains(key) {
+ return max(0, integerValue(raw[key] as Any))
+ }
+ return 0
+ }
+
+ let input = value(["input_tokens", "input"])
+ let output = value(["output_tokens", "output"])
+ let cached = value(["cached_input_tokens", "cache_read_input_tokens", "cached"])
+ let reasoning = value(["reasoning_output_tokens", "reasoning_tokens", "thoughts"])
+ let explicitTotal = ["total_tokens", "total"].first(where: { raw.keys.contains($0) })
+ .map { max(0, integerValue(raw[$0] as Any)) }
+ return canonicalUsageCounts(
+ rawInputTokens: input,
+ outputTokens: output,
+ cacheCreationInputTokens: value(["cache_creation_input_tokens", "cache_write_input_tokens"]),
+ cacheReadInputTokens: cached,
+ reasoningOutputTokens: reasoning,
+ inputIncludesCachedTokens: true,
+ explicitTotalTokens: explicitTotal,
+ explicitTotalIsAuthoritative: true
+ )
+ }
+
+ static func canonicalUsageCounts(
+ rawInputTokens: Int,
+ outputTokens: Int,
+ cacheCreationInputTokens: Int = 0,
+ cacheReadInputTokens: Int = 0,
+ reasoningOutputTokens: Int = 0,
+ inputIncludesCachedTokens: Bool,
+ explicitTotalTokens: Int? = nil,
+ explicitTotalIsAuthoritative: Bool = false
+ ) -> TokenUsageCounts {
+ let rawInput = max(0, rawInputTokens)
+ let output = max(0, outputTokens)
+ let cacheCreation = max(0, cacheCreationInputTokens)
+ let cacheRead = max(0, cacheReadInputTokens)
+ let reasoning = max(0, reasoningOutputTokens)
+ let input = rawInput + (inputIncludesCachedTokens ? 0 : cacheCreation + cacheRead)
+ let derivedTotal = input + output
+ let explicitTotal = max(0, explicitTotalTokens ?? 0)
+ let total = explicitTotalIsAuthoritative && explicitTotal > 0
+ ? explicitTotal
+ : (derivedTotal > 0 ? derivedTotal : explicitTotal)
+ return TokenUsageCounts(
+ inputTokens: input,
+ outputTokens: output,
+ cacheCreationInputTokens: cacheCreation,
+ cacheReadInputTokens: cacheRead,
+ reasoningOutputTokens: reasoning,
+ totalTokens: total
+ )
+ }
+
+ static func integerValue(_ 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
+ }
+
+ static func doubleValue(_ value: Any) -> Double {
+ if let double = value as? Double { return double }
+ if let int = value as? Int { return Double(int) }
+ if let string = value as? String { return Double(string) ?? 0 }
+ return 0
+ }
+
+ static func nonEmptyString(_ value: String?) -> String? {
+ guard let value else { return nil }
+ let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
+ return trimmed.isEmpty ? nil : trimmed
+ }
+
+ static func dayString(fromISO value: String) -> String? {
+ guard let date = parseISO(value) else { return nil }
+ return dayFormatter.string(from: date)
+ }
+
+ static func dayString(for event: CodexTokenEvent) -> String? {
+ if let timestamp = event.timestampEpoch {
+ return dayFormatter.string(from: Date(timeIntervalSince1970: timestamp))
+ }
+ return event.timestamp.flatMap(dayString(fromISO:))
+ }
+
+ static func hour(fromEpoch value: TimeInterval) -> Int {
+ calendar.component(.hour, from: Date(timeIntervalSince1970: value))
+ }
+
+ static func hour(fromISO value: String?) -> Int? {
+ guard let value, let date = parseISO(value) else { return nil }
+ return calendar.component(.hour, from: date)
+ }
+
+ static func dayString(fromEpoch value: Any?) -> String? {
+ guard let seconds = epochSeconds(value) else { return nil }
+ return dayFormatter.string(from: Date(timeIntervalSince1970: seconds))
+ }
+
+ static func isoString(fromEpoch value: Any?) -> String? {
+ guard let seconds = epochSeconds(value) else { return nil }
+ return isoFormatter.string(from: Date(timeIntervalSince1970: seconds))
+ }
+
+ static func epochSeconds(_ value: Any?) -> Double? {
+ var seconds: Double
+ if let int = value as? Int {
+ seconds = Double(int)
+ } else if let double = value as? Double {
+ seconds = double
+ } else if let string = value as? String, let parsed = Double(string) {
+ seconds = parsed
+ } else {
+ return nil
+ }
+ if seconds > 10_000_000_000 {
+ seconds /= 1_000
+ }
+ return seconds
+ }
+
+ static func parseISO(_ value: String) -> Date? {
+ if let date = isoFormatterWithFractional.date(from: value) {
+ return date
+ }
+ return isoFormatter.date(from: value)
+ }
+
+ static func modelKey(_ model: String?) -> String {
+ let value = (model ?? "unknown").trimmingCharacters(in: .whitespacesAndNewlines)
+ return value.isEmpty ? "unknown" : value
+ }
+
+ static func sqliteJSONRows(database: URL, query: String) -> [[String: Any]]? {
+ SQLiteReadonly.jsonRows(database: database, query: query)
+ }
+
+ static let dayFormatter: DateFormatter = {
+ let formatter = DateFormatter()
+ formatter.calendar = Calendar(identifier: .gregorian)
+ formatter.locale = Locale(identifier: "en_US_POSIX")
+ formatter.timeZone = timezone
+ formatter.dateFormat = "yyyy-MM-dd"
+ return formatter
+ }()
+
+ static let calendar: Calendar = {
+ var calendar = Calendar(identifier: .gregorian)
+ calendar.timeZone = timezone
+ return calendar
+ }()
+
+ static let isoFormatterWithFractional: ISO8601DateFormatter = {
+ let formatter = ISO8601DateFormatter()
+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+ return formatter
+ }()
+
+ static let isoFormatter: ISO8601DateFormatter = {
+ let formatter = ISO8601DateFormatter()
+ formatter.formatOptions = [.withInternetDateTime]
+ return formatter
+ }()
+}
diff --git a/TokenStepSwift/Sources/TokenStepSwift/Services/Collector/UsageCollector+Pricing.swift b/TokenStepSwift/Sources/TokenStepSwift/Services/Collector/UsageCollector+Pricing.swift
new file mode 100644
index 0000000..1f65f21
--- /dev/null
+++ b/TokenStepSwift/Sources/TokenStepSwift/Services/Collector/UsageCollector+Pricing.swift
@@ -0,0 +1,87 @@
+import Foundation
+
+extension UsageCollector {
+ /// How one family of models is billed, in USD per million tokens.
+ enum PriceScheme {
+ /// OpenAI style: cached input is a discounted subset of input.
+ case openAI(input: Double, cachedInput: Double, output: Double)
+ /// Anthropic style: cache writes and reads are priced separately from input.
+ case anthropic(input: Double, output: Double, cacheCreation: Double, cacheRead: Double)
+ /// One rate for every token when no breakdown is priced.
+ case flat(Double)
+ }
+
+ struct PriceRule {
+ /// Restricts the rule to one client; nil matches any client.
+ var tool: String?
+ /// Case-insensitive substring of the model name; nil matches any model.
+ var modelFragment: String?
+ var scheme: PriceScheme
+ }
+
+ /// Rough list prices for the local spend estimate. It is not a bill.
+ /// Rules are checked in order and the first match wins, so keep specific
+ /// models above broader families and fallbacks last.
+ static let priceRules: [PriceRule] = [
+ PriceRule(tool: "Codex", modelFragment: "gpt-5.5", scheme: .openAI(input: 5, cachedInput: 0.5, output: 30)),
+ PriceRule(tool: "Codex", modelFragment: "gpt-5.4", scheme: .openAI(input: 2.5, cachedInput: 0.25, output: 15)),
+ PriceRule(tool: nil, modelFragment: "opus", scheme: .anthropic(input: 5, output: 25, cacheCreation: 6.25, cacheRead: 0.5)),
+ PriceRule(tool: nil, modelFragment: "sonnet", scheme: .anthropic(input: 3, output: 15, cacheCreation: 3.75, cacheRead: 0.3)),
+ PriceRule(tool: "Claude Code", modelFragment: nil, scheme: .flat(3)),
+ PriceRule(tool: nil, modelFragment: nil, scheme: .flat(1))
+ ]
+
+ static func estimateCost(usage: TokenUsageCounts, tool: String, model: String) -> Double {
+ let lower = model.lowercased()
+ let rule = priceRules.first { rule in
+ (rule.tool == nil || rule.tool == tool)
+ && (rule.modelFragment.map { lower.contains($0) } ?? true)
+ }
+ switch rule?.scheme ?? .flat(1) {
+ case let .openAI(input, cachedInput, output):
+ return openAICostByParts(usage: usage, input: input, cachedInput: cachedInput, output: output)
+ case let .anthropic(input, output, cacheCreation, cacheRead):
+ return costByParts(usage: usage, input: input, output: output, cacheCreation: cacheCreation, cacheRead: cacheRead)
+ case let .flat(perMillion):
+ return Double(usage.totalTokens) / 1_000_000 * perMillion
+ }
+ }
+
+ static func openAICostByParts(
+ usage: TokenUsageCounts,
+ input: Double,
+ cachedInput: Double,
+ output: Double
+ ) -> Double {
+ let cached = max(0, usage.cacheReadInputTokens)
+ let cacheCreation = max(0, usage.cacheCreationInputTokens)
+ let uncachedInput = max(0, usage.inputTokens - cached - cacheCreation)
+ if uncachedInput == 0,
+ cached == 0,
+ cacheCreation == 0,
+ usage.outputTokens == 0,
+ usage.totalTokens > 0 {
+ return Double(usage.totalTokens) / 1_000_000 * input
+ }
+ return Double(uncachedInput + cacheCreation) / 1_000_000 * input
+ + Double(cached) / 1_000_000 * cachedInput
+ + Double(usage.outputTokens) / 1_000_000 * output
+ }
+
+ static func costByParts(
+ usage: TokenUsageCounts,
+ input: Double,
+ output: Double,
+ cacheCreation: Double,
+ cacheRead: Double
+ ) -> Double {
+ let uncachedInput = max(
+ 0,
+ usage.inputTokens - usage.cacheCreationInputTokens - usage.cacheReadInputTokens
+ )
+ return Double(uncachedInput) / 1_000_000 * input
+ + Double(usage.outputTokens) / 1_000_000 * output
+ + Double(usage.cacheCreationInputTokens) / 1_000_000 * cacheCreation
+ + Double(usage.cacheReadInputTokens) / 1_000_000 * cacheRead
+ }
+}
diff --git a/TokenStepSwift/Sources/TokenStepSwift/Services/Collector/UsageCollector.swift b/TokenStepSwift/Sources/TokenStepSwift/Services/Collector/UsageCollector.swift
new file mode 100644
index 0000000..2cf3cc4
--- /dev/null
+++ b/TokenStepSwift/Sources/TokenStepSwift/Services/Collector/UsageCollector.swift
@@ -0,0 +1,481 @@
+import CryptoKit
+import Foundation
+import SQLite3
+
+struct UsageCollectionFileState: Codable, Equatable {
+ var path: String
+ var size: UInt64
+ var modificationTime: TimeInterval
+}
+
+struct UsageCollectionState: Codable, Equatable {
+ var schemaVersion = 2
+ var historyDays: Int
+ var includesExperimentalAgentSources: Bool
+ var windowDay: String
+ // Optional so checkpoints written before the zone was recorded still decode;
+ // they compare unequal and trigger one fresh collection.
+ var timeZone: String?
+ var files: [UsageCollectionFileState]
+}
+
+struct CodexIncrementalCacheStats: Equatable {
+ var generation: Int
+ var sessions: Int
+ var records: Int
+ var lastLogicalWriteBytes: Int
+}
+
+struct CodexAccountingComparisonDiagnostics {
+ var incrementalSnapshot: UsageSnapshot
+ var referenceSnapshot: UsageSnapshot
+ var mismatchedPathHashes: [String]
+ var incrementalRecordCount: Int
+ var referenceRecordCount: Int
+}
+
+enum UsageCollector {
+ static let codexAccountingRevision = 8
+
+ // Fixed for the life of the process: the helper is relaunched for every collection.
+ static let timezone = TokenStepClock.timeZone
+ static let maxRelevantLineBytes = 1_048_576
+ static let ccSwitchSourceName = "CC Switch Proxy"
+
+ static func collect(
+ historyDays: Int = TokenStepSettings.defaults.historyDays,
+ includeCCSwitchProxyUsage: Bool = true,
+ ccSwitchDatabaseURL: URL? = nil,
+ includeExperimentalAgentSources: Bool = false,
+ zCodeDatabaseURL: URL? = nil,
+ hermesDatabaseURL: URL? = nil,
+ workBuddyRootURLs: [URL]? = nil,
+ forceFullValidation: Bool = false
+ ) -> UsageSnapshot {
+ let cacheLoad = loadCache()
+ var cache = cacheLoad.cache
+ var livePaths = Set()
+ let sourceCutoff = sourceFileCutoffDate(historyDays: historyDays)
+ var ccSwitch = includeCCSwitchProxyUsage
+ ? collectCCSwitchProxyUsage(databaseURL: ccSwitchDatabaseURL)
+ : CollectorResult(records: [], source: SourceInfo(status: "disabled", files: nil, records: 0))
+ let codexOutcome = collectCodex(
+ cache: &cache,
+ livePaths: &livePaths,
+ modifiedSince: sourceCutoff,
+ databaseURL: AppPaths.codexIncrementalCacheSQLite,
+ forceFullValidation: forceFullValidation,
+ requiresDetailedRecords: !ccSwitch.records.isEmpty
+ )
+ var codex = codexOutcome.result
+ codex.source.recalibratedFromRevision = cacheLoad.recalibratedFromRevision
+ let claude = collectClaudeCode(
+ cache: &cache,
+ livePaths: &livePaths,
+ modifiedSince: sourceCutoff,
+ forceFullValidation: forceFullValidation
+ )
+ let zCode = includeExperimentalAgentSources
+ ? collectZCodeUsage(databaseURL: zCodeDatabaseURL)
+ : CollectorResult(records: [], source: SourceInfo(status: "disabled", files: nil, records: 0))
+ let hermes = includeExperimentalAgentSources
+ ? collectHermesUsage(databaseURL: hermesDatabaseURL)
+ : CollectorResult(records: [], source: SourceInfo(status: "disabled", files: nil, records: 0))
+ let workBuddy = includeExperimentalAgentSources
+ ? collectWorkBuddyUsage(rootURLs: workBuddyRootURLs, modifiedSince: sourceCutoff)
+ : CollectorResult(records: [], source: SourceInfo(status: "disabled", files: nil, records: 0))
+ if codexOutcome.usedIncrementalStore {
+ cache.files = cache.files.filter { $0.value.tool != "Codex" && livePaths.contains($0.key) }
+ } else {
+ cache.files = cache.files.filter { livePaths.contains($0.key) }
+ }
+ saveCache(cache)
+
+ let nativeRecords = codex.records + claude.records
+ let deduped = deduplicateCrossSource(
+ nativeRecords: nativeRecords,
+ proxyRecords: ccSwitch.records
+ )
+ if includeCCSwitchProxyUsage {
+ ccSwitch.source = sourceInfo(ccSwitch.source, annotatedWith: deduped)
+ }
+ let records = recordsInHistoryWindow(
+ deduped.records + zCode.records + hermes.records + workBuddy.records,
+ historyDays: historyDays,
+ now: Date()
+ )
+ return aggregate(
+ records: records,
+ sources: [
+ "Codex": codex.source,
+ "Claude Code": claude.source,
+ ccSwitchSourceName: ccSwitch.source,
+ "ZCode": zCode.source,
+ "Hermes Agent": hermes.source,
+ "WorkBuddy": workBuddy.source
+ ]
+ )
+ }
+
+ static func collectionState(
+ historyDays: Int,
+ includeExperimentalAgentSources: Bool,
+ homeURL: URL = FileManager.default.homeDirectoryForCurrentUser,
+ now: Date = Date()
+ ) -> UsageCollectionState {
+ let cutoff = sourceFileCutoffDate(historyDays: historyDays)
+ var urls = defaultCodexSessionRoots(homeURL: homeURL)
+ .flatMap { jsonlFiles(under: $0, modifiedSince: cutoff) }
+ urls.append(contentsOf: jsonlFiles(
+ under: homeURL.appendingPathComponent(".claude/projects", isDirectory: true),
+ modifiedSince: cutoff
+ ))
+
+ let databases = [
+ homeURL.appendingPathComponent(".codex/state_5.sqlite"),
+ homeURL.appendingPathComponent(".codex/sqlite/state_5.sqlite"),
+ homeURL.appendingPathComponent(".cc-switch/cc-switch.db")
+ ]
+ urls.append(contentsOf: existingDatabaseFiles(databases))
+
+ if includeExperimentalAgentSources {
+ urls.append(contentsOf: existingDatabaseFiles([
+ homeURL.appendingPathComponent(".zcode/cli/db/db.sqlite"),
+ homeURL.appendingPathComponent(".hermes/state.db")
+ ]))
+ urls.append(contentsOf: [
+ homeURL.appendingPathComponent(".workbuddy/projects", isDirectory: true),
+ homeURL.appendingPathComponent("Library/Application Support/WorkBuddyExtension", isDirectory: true)
+ ].flatMap { jsonlFiles(under: $0, modifiedSince: cutoff) })
+ }
+
+ let files = Dictionary(grouping: urls, by: \.path)
+ .compactMap { _, duplicates in duplicates.first.flatMap(collectionFileState) }
+ .sorted { $0.path < $1.path }
+ return UsageCollectionState(
+ historyDays: historyDays,
+ includesExperimentalAgentSources: includeExperimentalAgentSources,
+ windowDay: dayFormatter.string(from: now),
+ timeZone: timezone.identifier,
+ files: files
+ )
+ }
+
+ static func codexIncrementalCacheStatsForTests(databaseURL: URL) -> CodexIncrementalCacheStats? {
+ try? CodexIncrementalStore(url: databaseURL).stats()
+ }
+
+ static func codexCollectionStateForTests(
+ homeURL: URL
+ ) -> [UsageCollectionFileState] {
+ defaultCodexSessionRoots(homeURL: homeURL)
+ .flatMap { jsonlFiles(under: $0, modifiedSince: nil) }
+ .compactMap(collectionFileState)
+ .sorted { $0.path < $1.path }
+ }
+
+ static func compareIncrementalCodexAccountingForTests(
+ homeURL: URL,
+ databaseURL: URL
+ ) throws -> CodexAccountingComparisonDiagnostics {
+ let incremental = try collectCodexIncrementally(
+ modifiedSince: nil,
+ databaseURL: databaseURL,
+ forceFullValidation: false,
+ homeURL: homeURL,
+ requiresDetailedRecords: true
+ )
+ var cache = CollectorCache()
+ var livePaths = Set()
+ let reference = collectCodexFromJSONL(
+ cache: &cache,
+ livePaths: &livePaths,
+ modifiedSince: nil,
+ homeURL: homeURL
+ )
+
+ return try accountingComparisonDiagnostics(
+ incremental: incremental,
+ reference: reference
+ )
+ }
+
+ static func compareLegacyMigrationCodexAccountingForTests(
+ homeURL: URL,
+ databaseURL: URL
+ ) throws -> CodexAccountingComparisonDiagnostics {
+ var legacyCache = CollectorCache()
+ var livePaths = Set()
+ let reference = collectCodexFromJSONL(
+ cache: &legacyCache,
+ livePaths: &livePaths,
+ modifiedSince: nil,
+ homeURL: homeURL
+ )
+ let incremental = try collectCodexIncrementally(
+ modifiedSince: nil,
+ databaseURL: databaseURL,
+ forceFullValidation: false,
+ homeURL: homeURL,
+ requiresDetailedRecords: true,
+ legacyCache: legacyCache
+ )
+ return try accountingComparisonDiagnostics(
+ incremental: incremental,
+ reference: reference
+ )
+ }
+
+ static func accountingComparisonDiagnostics(
+ incremental: CollectorResult,
+ reference: CollectorResult
+ ) throws -> CodexAccountingComparisonDiagnostics {
+ let incrementalByPath = Dictionary(grouping: incremental.records) {
+ $0.sourcePath ?? ""
+ }
+ let referenceByPath = Dictionary(grouping: reference.records) {
+ $0.sourcePath ?? ""
+ }
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.sortedKeys]
+ let paths = Set(incrementalByPath.keys).union(referenceByPath.keys)
+ let mismatches = try paths.compactMap { path -> String? in
+ let incrementalData = try encoder.encode(incrementalByPath[path] ?? [])
+ let referenceData = try encoder.encode(referenceByPath[path] ?? [])
+ return incrementalData == referenceData ? nil : anonymousPathHash(path)
+ }.sorted()
+
+ return CodexAccountingComparisonDiagnostics(
+ incrementalSnapshot: aggregate(
+ records: incremental.records,
+ sources: ["Codex": incremental.source]
+ ),
+ referenceSnapshot: aggregate(
+ records: reference.records,
+ sources: ["Codex": reference.source]
+ ),
+ mismatchedPathHashes: mismatches,
+ incrementalRecordCount: incremental.records.count,
+ referenceRecordCount: reference.records.count
+ )
+ }
+
+ static func anonymousPathHash(_ path: String) -> String {
+ var hash: UInt64 = 14_695_981_039_346_656_037
+ for byte in path.utf8 {
+ hash ^= UInt64(byte)
+ hash &*= 1_099_511_628_211
+ }
+ return String(format: "%016llx", hash)
+ }
+
+ static func existingDatabaseFiles(_ databases: [URL]) -> [URL] {
+ databases.flatMap { database in
+ [
+ database,
+ URL(fileURLWithPath: database.path + "-wal")
+ ].filter { FileManager.default.fileExists(atPath: $0.path) }
+ }
+ }
+
+ static func collectionFileState(_ url: URL) -> UsageCollectionFileState? {
+ guard let metadata = fileMetadata(for: url) else { return nil }
+ return UsageCollectionFileState(
+ path: url.standardizedFileURL.path,
+ size: metadata.size,
+ modificationTime: metadata.modificationTime
+ )
+ }
+
+ static func collectCCSwitchProxyUsageSnapshot(databaseURL: URL) -> UsageSnapshot {
+ let result = collectCCSwitchProxyUsage(databaseURL: databaseURL)
+ return aggregate(
+ records: result.records,
+ sources: [ccSwitchSourceName: result.source]
+ )
+ }
+
+ static func collectClaudeCodeUsageSnapshot(rootURL: URL) -> UsageSnapshot {
+ var cache = CollectorCache()
+ var livePaths = Set()
+ let result = collectClaudeCode(cache: &cache, livePaths: &livePaths, rootURL: rootURL, modifiedSince: nil)
+ return aggregate(records: result.records, sources: ["Claude Code": result.source])
+ }
+
+ static func collectCodexUsageSnapshotForTests(
+ homeURL: URL,
+ cacheURL: URL? = nil,
+ forceFullValidation: Bool = false,
+ requiresDetailedRecords: Bool = false
+ ) -> UsageSnapshot {
+ if let cacheURL {
+ do {
+ let result = try collectCodexIncrementally(
+ modifiedSince: nil,
+ databaseURL: cacheURL,
+ forceFullValidation: forceFullValidation,
+ homeURL: homeURL,
+ requiresDetailedRecords: requiresDetailedRecords
+ )
+ return aggregate(records: result.records, sources: ["Codex": result.source])
+ } catch {
+ return aggregate(
+ records: [],
+ sources: ["Codex": SourceInfo(status: "incremental_cache_error", files: 0, records: 0)]
+ )
+ }
+ }
+ var cache = CollectorCache()
+ var livePaths = Set()
+ let result = collectCodexFromJSONL(
+ cache: &cache,
+ livePaths: &livePaths,
+ modifiedSince: nil,
+ homeURL: homeURL
+ )
+ return aggregate(records: result.records, sources: ["Codex": result.source])
+ }
+
+ static func collectIncrementalCodexAndProxySnapshotForTests(
+ codexRoots: [URL],
+ cacheURL: URL,
+ ccSwitchDatabaseURL: URL
+ ) -> UsageSnapshot {
+ let codex: CollectorResult
+ do {
+ codex = try collectCodexIncrementally(
+ modifiedSince: nil,
+ databaseURL: cacheURL,
+ forceFullValidation: false,
+ requiresDetailedRecords: true,
+ roots: codexRoots
+ )
+ } catch {
+ codex = CollectorResult(
+ records: [],
+ source: SourceInfo(status: "incremental_cache_error", files: 0, records: 0)
+ )
+ }
+ var proxy = collectCCSwitchProxyUsage(databaseURL: ccSwitchDatabaseURL)
+ let deduped = deduplicateCrossSource(
+ nativeRecords: codex.records,
+ proxyRecords: proxy.records
+ )
+ proxy.source = sourceInfo(proxy.source, annotatedWith: deduped)
+ return aggregate(
+ records: deduped.records,
+ sources: [
+ "Codex": codex.source,
+ ccSwitchSourceName: proxy.source
+ ]
+ )
+ }
+
+ static func collectCodexWithIncrementalFallbackForTests(
+ homeURL: URL,
+ cacheURL: URL
+ ) -> UsageSnapshot {
+ var cache = CollectorCache()
+ var livePaths = Set()
+ let outcome = collectCodex(
+ cache: &cache,
+ livePaths: &livePaths,
+ modifiedSince: nil,
+ databaseURL: cacheURL,
+ forceFullValidation: false,
+ requiresDetailedRecords: false,
+ homeURL: homeURL
+ )
+ return aggregate(
+ records: outcome.result.records,
+ sources: ["Codex": outcome.result.source]
+ )
+ }
+
+ static func collectorCacheRecalibrationRevisionForTests(cacheURL: URL) -> Int? {
+ loadCache(at: cacheURL).recalibratedFromRevision
+ }
+
+ /// Collects Claude Code usage through a persistent collector cache, the way
+ /// repeated background collections do.
+ static func collectClaudeCodeWithCacheForTests(
+ rootURL: URL,
+ cacheURL: URL,
+ forceFullValidation: Bool = false
+ ) -> UsageSnapshot {
+ var cache = loadCurrentCache(at: cacheURL)
+ var livePaths = Set()
+ let result = collectClaudeCode(
+ cache: &cache,
+ livePaths: &livePaths,
+ rootURL: rootURL,
+ modifiedSince: nil,
+ forceFullValidation: forceFullValidation
+ )
+ cache.files = cache.files.filter { livePaths.contains($0.key) }
+ saveCache(cache, to: cacheURL)
+ return aggregate(records: result.records, sources: ["Claude Code": result.source])
+ }
+
+ static func collectorCacheIsReusableForTests(cacheURL: URL) -> Bool {
+ !loadCache(at: cacheURL).cache.files.isEmpty
+ }
+
+ static func collectUsageSnapshotForTests(
+ codexRoots: [URL] = [],
+ claudeRootURL: URL? = nil,
+ ccSwitchDatabaseURL: URL? = nil,
+ zCodeDatabaseURL: URL? = nil,
+ hermesDatabaseURL: URL? = nil,
+ workBuddyRootURLs: [URL]? = nil,
+ includeExperimentalAgentSources: Bool = false,
+ historyDays: Int? = nil,
+ now: Date = Date()
+ ) -> UsageSnapshot {
+ var cache = CollectorCache()
+ var livePaths = Set()
+ let codex = codexRoots.isEmpty
+ ? CollectorResult(records: [], source: SourceInfo(status: "disabled", files: nil, records: 0))
+ : collectCodexFromJSONL(
+ cache: &cache,
+ livePaths: &livePaths,
+ modifiedSince: nil,
+ roots: codexRoots
+ )
+ let claude = claudeRootURL.map {
+ collectClaudeCode(cache: &cache, livePaths: &livePaths, rootURL: $0, modifiedSince: nil)
+ } ?? CollectorResult(records: [], source: SourceInfo(status: "disabled", files: nil, records: 0))
+ var ccSwitch = ccSwitchDatabaseURL.map {
+ collectCCSwitchProxyUsage(databaseURL: $0)
+ } ?? CollectorResult(records: [], source: SourceInfo(status: "disabled", files: nil, records: 0))
+ let zCode = includeExperimentalAgentSources
+ ? zCodeDatabaseURL.map { collectZCodeUsage(databaseURL: $0) } ?? CollectorResult(records: [], source: SourceInfo(status: "missing_db", files: 0, records: 0))
+ : CollectorResult(records: [], source: SourceInfo(status: "disabled", files: nil, records: 0))
+ let hermes = includeExperimentalAgentSources
+ ? hermesDatabaseURL.map { collectHermesUsage(databaseURL: $0) } ?? CollectorResult(records: [], source: SourceInfo(status: "missing_db", files: 0, records: 0))
+ : CollectorResult(records: [], source: SourceInfo(status: "disabled", files: nil, records: 0))
+ let workBuddy = includeExperimentalAgentSources
+ ? collectWorkBuddyUsage(rootURLs: workBuddyRootURLs ?? [], modifiedSince: nil)
+ : CollectorResult(records: [], source: SourceInfo(status: "disabled", files: nil, records: 0))
+ let deduped = deduplicateCrossSource(
+ nativeRecords: codex.records + claude.records,
+ proxyRecords: ccSwitch.records
+ )
+ ccSwitch.source = sourceInfo(ccSwitch.source, annotatedWith: deduped)
+ let allRecords = deduped.records + zCode.records + hermes.records + workBuddy.records
+ let records = historyDays.map {
+ recordsInHistoryWindow(allRecords, historyDays: $0, now: now)
+ } ?? allRecords
+ return aggregate(
+ records: records,
+ sources: [
+ "Codex": codex.source,
+ "Claude Code": claude.source,
+ ccSwitchSourceName: ccSwitch.source,
+ "ZCode": zCode.source,
+ "Hermes Agent": hermes.source,
+ "WorkBuddy": workBuddy.source
+ ]
+ )
+ }
+}
diff --git a/TokenStepSwift/Sources/TokenStepSwift/Services/Collector/UsageCollectorModels.swift b/TokenStepSwift/Sources/TokenStepSwift/Services/Collector/UsageCollectorModels.swift
new file mode 100644
index 0000000..036a576
--- /dev/null
+++ b/TokenStepSwift/Sources/TokenStepSwift/Services/Collector/UsageCollectorModels.swift
@@ -0,0 +1,722 @@
+import Foundation
+
+struct CollectorResult {
+ var records: [UsageRecord]
+ var source: SourceInfo
+}
+
+struct CodexCollectionOutcome {
+ var result: CollectorResult
+ var usedIncrementalStore: Bool
+}
+
+struct PendingCodexSession {
+ var path: URL
+ var metadata: (size: UInt64, modificationTime: TimeInterval)
+ var fingerprint: String
+ var validationFingerprint: String? = nil
+ var scan: CodexSessionScan
+}
+
+struct StoredCodexSessionMetadata {
+ var size: UInt64
+ var modificationTime: TimeInterval
+ var fingerprint: String
+ var validationFingerprint: String?
+ var sessionID: String
+}
+
+struct CodexCachedSession {
+ var path: String
+ var size: UInt64
+ var modificationTime: TimeInterval
+ var fingerprint: String
+ var validationFingerprint: String? = nil
+ var sessionID: String
+ var createdAtEpoch: TimeInterval?
+ var parentSessionID: String?
+ var anchors: [CodexAnchor]
+ var records: [UsageRecord]
+ var summaryRecords: [UsageRecord]
+ var cursor: CodexSessionCursor
+ var diagnostics: CodexCollectionDiagnostics
+
+ func hasSameStoredAccounting(as other: CodexCachedSession) -> Bool {
+ path == other.path
+ && size == other.size
+ && abs(modificationTime - other.modificationTime) < 0.001
+ && fingerprint == other.fingerprint
+ && sessionID == other.sessionID
+ && createdAtEpoch == other.createdAtEpoch
+ && parentSessionID == other.parentSessionID
+ && anchors == other.anchors
+ && records == other.records
+ && summaryRecords == other.summaryRecords
+ && cursor == other.cursor
+ && diagnostics == other.diagnostics
+ }
+}
+
+struct CodexCachedContribution {
+ var records: [UsageRecord]
+ var recordCount: Int
+ var diagnostics: CodexCollectionDiagnostics
+}
+
+struct CodexSummaryKey: Hashable {
+ var date: String
+ var model: String
+ var hour: Int?
+}
+
+struct CodexSummaryAccumulator {
+ var timestamp: String?
+ var timestampEpoch: TimeInterval?
+ var usage = TokenUsageCounts()
+ var modelRequestCount = 0
+ var toolCallCount = 0
+
+ mutating func add(_ record: UsageRecord) {
+ timestamp = timestamp ?? record.timestamp
+ timestampEpoch = timestampEpoch ?? record.timestampEpoch
+ usage.add(record.usage)
+ modelRequestCount += max(0, record.modelRequestCount)
+ toolCallCount += max(0, record.toolCallCount)
+ }
+}
+
+struct CollectorCache: Codable {
+ static let currentVersion = UsageCollector.codexAccountingRevision
+
+ var version = currentVersion
+ // Cached records carry day keys, so they are only reusable in the zone that
+ // produced them. Nil means the cache predates this field (legacy zone).
+ var timeZone: String? = UsageCollector.timezone.identifier
+ var files: [String: CachedUsageFile] = [:]
+}
+
+struct CollectorCacheLoad {
+ var cache: CollectorCache
+ var recalibratedFromRevision: Int?
+}
+
+struct CachedUsageFile: Codable {
+ var tool: String
+ var size: UInt64
+ var modificationTime: TimeInterval
+ var records: [UsageRecord]
+ var codexScan: CodexSessionScan? = nil
+ var contentFingerprint: String? = nil
+ var claudeState: ClaudeFileState? = nil
+}
+
+/// Resume point for appending Claude Code transcripts.
+struct ClaudeFileState: Codable {
+ /// Byte offset just past the last complete line that was read.
+ var processedBytes: UInt64 = 0
+ /// Number of lines containing "usage" read so far; line-number identities depend on it.
+ var usageLineCount = 0
+ var candidates: [String: ClaudeUsageCandidate] = [:]
+ /// `contentFingerprint` of the first `processedBytes` bytes, to detect rewrites.
+ var prefixFingerprint: String?
+}
+
+struct CodexSessionScan: Codable {
+ var canonicalSessionID: String
+ var createdAt: String?
+ var parentSessionID: String?
+ var sourcePath: String
+ var events: [CodexTokenEvent]
+ var finalModel: String? = nil
+ var relevantLineCount: Int? = nil
+}
+
+struct CodexTokenEvent: Codable {
+ var timestamp: String?
+ var timestampEpoch: TimeInterval? = nil
+ var model: String
+ var cumulativePresent: Bool
+ var cumulative: TokenUsageCounts?
+ var last: TokenUsageCounts?
+ var modelContextWindow: Int
+ var lineNumber: Int
+}
+
+struct CodexAnchor: Codable, Equatable {
+ var timestamp: TimeInterval
+ var usage: TokenUsageCounts
+}
+
+struct CodexDeltaCursor {
+ var hasCumulativeSchema: Bool
+ var previousCumulative: TokenUsageCounts?
+ var epoch: Int
+}
+
+struct CodexSessionCursor: Codable, Equatable {
+ var currentModel: String
+ var relevantLineNumber: Int
+ var hasCumulativeSchema: Bool
+ var previousCumulative: TokenUsageCounts?
+ var epoch: Int
+}
+
+struct CodexSessionTail {
+ var events: [CodexTokenEvent]
+ var currentModel: String
+ var relevantLineNumber: Int
+ var processedSize: UInt64
+ var modificationTime: TimeInterval
+ var fingerprint: String
+}
+
+struct CodexCollectionDiagnostics: Codable, Equatable {
+ var rawRecords = 0
+ var exactRecords = 0
+ var legacyRecords = 0
+ var duplicateRecords = 0
+ var counterResets = 0
+ var inheritedRecords = 0
+ var inheritedTokens = 0
+ var skippedRecords = 0
+ var unknownBreakdownRecords = 0
+
+ mutating func add(_ other: CodexCollectionDiagnostics) {
+ rawRecords += other.rawRecords
+ exactRecords += other.exactRecords
+ legacyRecords += other.legacyRecords
+ duplicateRecords += other.duplicateRecords
+ counterResets += other.counterResets
+ inheritedRecords += other.inheritedRecords
+ inheritedTokens += other.inheritedTokens
+ skippedRecords += other.skippedRecords
+ unknownBreakdownRecords += other.unknownBreakdownRecords
+ }
+}
+
+struct UsageRecord: Codable, Equatable {
+ var date: String
+ var timestamp: String?
+ var timestampEpoch: TimeInterval? = nil
+ var tool: String
+ var model: String
+ var usage: TokenUsageCounts
+ var costUSD: Double? = nil
+ var source: UsageRecordSource = .unknown
+ var requestID: String? = nil
+ var sessionID: String? = nil
+ var responseID: String? = nil
+ var sourcePath: String? = nil
+ var lineNumber: Int? = nil
+ var dataSource: String? = nil
+ var modelRequestCount = 1
+ var toolCallCount = 0
+
+ enum CodingKeys: String, CodingKey {
+ case date
+ case timestamp
+ case timestampEpoch
+ case tool
+ case model
+ case usage
+ case costUSD
+ case source
+ case requestID
+ case sessionID
+ case responseID
+ case sourcePath
+ case lineNumber
+ case dataSource
+ case modelRequestCount
+ case toolCallCount
+ }
+
+ init(
+ date: String,
+ timestamp: String?,
+ timestampEpoch: TimeInterval? = nil,
+ tool: String,
+ model: String,
+ usage: TokenUsageCounts,
+ costUSD: Double? = nil,
+ source: UsageRecordSource = .unknown,
+ requestID: String? = nil,
+ sessionID: String? = nil,
+ responseID: String? = nil,
+ sourcePath: String? = nil,
+ lineNumber: Int? = nil,
+ dataSource: String? = nil,
+ modelRequestCount: Int = 1,
+ toolCallCount: Int = 0
+ ) {
+ self.date = date
+ self.timestamp = timestamp
+ self.timestampEpoch = timestampEpoch
+ self.tool = tool
+ self.model = model
+ self.usage = usage
+ self.costUSD = costUSD
+ self.source = source
+ self.requestID = requestID
+ self.sessionID = sessionID
+ self.responseID = responseID
+ self.sourcePath = sourcePath
+ self.lineNumber = lineNumber
+ self.dataSource = dataSource
+ self.modelRequestCount = modelRequestCount
+ self.toolCallCount = toolCallCount
+ }
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ date = try container.decode(String.self, forKey: .date)
+ timestamp = try container.decodeIfPresent(String.self, forKey: .timestamp)
+ timestampEpoch = try container.decodeIfPresent(TimeInterval.self, forKey: .timestampEpoch)
+ tool = try container.decode(String.self, forKey: .tool)
+ model = try container.decode(String.self, forKey: .model)
+ usage = try container.decode(TokenUsageCounts.self, forKey: .usage)
+ costUSD = try container.decodeIfPresent(Double.self, forKey: .costUSD)
+ source = try container.decodeIfPresent(UsageRecordSource.self, forKey: .source) ?? .unknown
+ requestID = try container.decodeIfPresent(String.self, forKey: .requestID)
+ sessionID = try container.decodeIfPresent(String.self, forKey: .sessionID)
+ responseID = try container.decodeIfPresent(String.self, forKey: .responseID)
+ sourcePath = try container.decodeIfPresent(String.self, forKey: .sourcePath)
+ lineNumber = try container.decodeIfPresent(Int.self, forKey: .lineNumber)
+ dataSource = try container.decodeIfPresent(String.self, forKey: .dataSource)
+ modelRequestCount = try container.decodeIfPresent(Int.self, forKey: .modelRequestCount) ?? 1
+ toolCallCount = try container.decodeIfPresent(Int.self, forKey: .toolCallCount) ?? 0
+ }
+}
+
+enum UsageRecordSource: String, Codable, Equatable {
+ case nativeCodex
+ case nativeCodexSQLite
+ case nativeClaudeCode
+ case ccSwitchProxy
+ case zcode
+ case hermes
+ case workbuddy
+ case unknown
+}
+
+struct CrossSourceDedupeResult {
+ var records: [UsageRecord]
+ var rawProxyRecords: Int
+ var keptProxyRecords: Int
+ var dedupedProxyRecords: Int
+ var skippedProxyRecords: Int
+}
+
+struct ClaudeIdentity {
+ var deduplicationKey: String
+ var requestID: String?
+ var responseID: String?
+ var sessionID: String?
+}
+
+struct ClaudeUsageCandidate: Codable {
+ var date: String
+ var timestamp: String
+ var model: String
+ var usage: TokenUsageCounts
+ var hasStopReason: Bool
+ var lineNumber: Int
+ var requestID: String?
+ var responseID: String?
+ var sessionID: String?
+ var sourcePath: String
+
+ var record: UsageRecord {
+ UsageRecord(
+ date: date,
+ timestamp: timestamp,
+ tool: "Claude Code",
+ model: model,
+ usage: usage,
+ source: .nativeClaudeCode,
+ requestID: requestID,
+ sessionID: sessionID,
+ responseID: responseID,
+ sourcePath: sourcePath,
+ lineNumber: lineNumber
+ )
+ }
+
+ func isPreferred(over other: ClaudeUsageCandidate) -> Bool {
+ if hasStopReason != other.hasStopReason {
+ return hasStopReason
+ }
+ if timestamp != other.timestamp {
+ return timestamp > other.timestamp
+ }
+ return lineNumber > other.lineNumber
+ }
+}
+
+struct TokenUsageCounts: Codable, Equatable {
+ var inputTokens = 0
+ var outputTokens = 0
+ var cacheCreationInputTokens = 0
+ var cacheReadInputTokens = 0
+ var reasoningOutputTokens = 0
+ var totalTokens = 0
+
+ mutating func add(_ other: TokenUsageCounts) {
+ inputTokens += other.inputTokens
+ outputTokens += other.outputTokens
+ cacheCreationInputTokens += other.cacheCreationInputTokens
+ cacheReadInputTokens += other.cacheReadInputTokens
+ reasoningOutputTokens += other.reasoningOutputTokens
+ totalTokens += other.totalTokens
+ }
+
+ var fingerprint: String {
+ [
+ totalTokens,
+ inputTokens,
+ cacheReadInputTokens,
+ outputTokens,
+ reasoningOutputTokens,
+ cacheCreationInputTokens
+ ].map(String.init).joined(separator: ":")
+ }
+
+ var cacheCoverageComplete: Bool {
+ inputTokens >= 0
+ && outputTokens >= 0
+ && cacheCreationInputTokens >= 0
+ && cacheReadInputTokens >= 0
+ && reasoningOutputTokens >= 0
+ && totalTokens == inputTokens + outputTokens
+ && cacheCreationInputTokens + cacheReadInputTokens <= inputTokens
+ && reasoningOutputTokens <= outputTokens
+ }
+}
+
+struct UsageAccumulator {
+ var usage = TokenUsageCounts()
+ var cost = 0.0
+
+ mutating func add(_ counts: TokenUsageCounts, cost: Double) {
+ usage.inputTokens += counts.inputTokens
+ usage.outputTokens += counts.outputTokens
+ usage.cacheCreationInputTokens += counts.cacheCreationInputTokens
+ usage.cacheReadInputTokens += counts.cacheReadInputTokens
+ usage.reasoningOutputTokens += counts.reasoningOutputTokens
+ usage.totalTokens += counts.totalTokens
+ self.cost += cost
+ }
+}
+
+struct DailyAccumulator {
+ var date: String
+ var tools: [String: Int] = [:]
+ var models: [String: Int] = [:]
+ var modelCosts: [String: Double] = [:]
+ var totalTokens = 0
+ var cost = 0.0
+
+ mutating func add(record: UsageRecord, cost: Double) {
+ tools[record.tool, default: 0] += record.usage.totalTokens
+ models[record.model, default: 0] += record.usage.totalTokens
+ modelCosts[record.model, default: 0] += cost
+ totalTokens += record.usage.totalTokens
+ self.cost += cost
+ }
+}
+
+struct AgentWorkAccumulator {
+ var date: String
+ var totalTokens = 0
+ var inputTokens = 0
+ var cachedInputTokens = 0
+ var outputTokens = 0
+ var cacheCoverageComplete = true
+ var unbucketedTokens = 0
+ var activeHours = Set()
+ var modelRequestCount = 0
+ var toolCallCount = 0
+ var sources: [String: AgentWorkSourceAccumulator] = [:]
+ var hourlySources: [Int: [String: AgentWorkHourlySourceAccumulator]] = [:]
+
+ mutating func add(record: UsageRecord, hour: Int?) {
+ totalTokens += record.usage.totalTokens
+ inputTokens += record.usage.inputTokens
+ cachedInputTokens += record.usage.cacheReadInputTokens
+ outputTokens += record.usage.outputTokens
+ cacheCoverageComplete = cacheCoverageComplete && record.usage.cacheCoverageComplete
+ if let hour {
+ activeHours.insert(hour)
+ var sourceRows = hourlySources[hour] ?? [:]
+ sourceRows[record.tool, default: AgentWorkHourlySourceAccumulator(source: record.tool)]
+ .add(record: record)
+ hourlySources[hour] = sourceRows
+ } else {
+ unbucketedTokens += record.usage.totalTokens
+ }
+ modelRequestCount += max(0, record.modelRequestCount)
+ toolCallCount += max(0, record.toolCallCount)
+ sources[record.tool, default: AgentWorkSourceAccumulator(source: record.tool)]
+ .add(record: record)
+ }
+
+ var dailyAgentWork: DailyAgentWork {
+ DailyAgentWork(
+ date: date,
+ totalTokens: totalTokens,
+ activeHours: activeHours.count,
+ modelRequestCount: modelRequestCount,
+ toolCallCount: toolCallCount,
+ sources: sources.values
+ .filter { $0.tokens > 0 }
+ .sorted { $0.tokens > $1.tokens }
+ .map(\.agentWorkSource),
+ inputTokens: inputTokens,
+ cachedInputTokens: cachedInputTokens,
+ outputTokens: outputTokens,
+ cacheCoverageComplete: cacheCoverageComplete,
+ hourlyBuckets: (0..<24).map { hour in
+ AgentWorkHourBucket(
+ hour: hour,
+ sources: (hourlySources[hour] ?? [:]).values
+ .filter { $0.tokens > 0 }
+ .sorted {
+ if $0.tokens == $1.tokens {
+ return $0.source < $1.source
+ }
+ return $0.tokens > $1.tokens
+ }
+ .map(\.hourlySource)
+ )
+ },
+ unbucketedTokens: unbucketedTokens
+ )
+ }
+}
+
+struct AgentWorkSourceAccumulator {
+ var source: String
+ var tokens = 0
+ var modelRequestCount = 0
+ var toolCallCount = 0
+
+ mutating func add(record: UsageRecord) {
+ tokens += record.usage.totalTokens
+ modelRequestCount += max(0, record.modelRequestCount)
+ toolCallCount += max(0, record.toolCallCount)
+ }
+
+ var agentWorkSource: AgentWorkSource {
+ AgentWorkSource(
+ source: source,
+ tokens: tokens,
+ modelRequestCount: modelRequestCount,
+ toolCallCount: toolCallCount
+ )
+ }
+}
+
+struct AgentWorkHourlySourceAccumulator {
+ var source: String
+ var tokens = 0
+ var inputTokens = 0
+ var cachedInputTokens = 0
+ var outputTokens = 0
+ var cacheCoverageComplete = true
+
+ mutating func add(record: UsageRecord) {
+ tokens += record.usage.totalTokens
+ inputTokens += record.usage.inputTokens
+ cachedInputTokens += record.usage.cacheReadInputTokens
+ outputTokens += record.usage.outputTokens
+ cacheCoverageComplete = cacheCoverageComplete && record.usage.cacheCoverageComplete
+ }
+
+ var hourlySource: AgentWorkHourlySource {
+ AgentWorkHourlySource(
+ source: source,
+ tokens: tokens,
+ inputTokens: inputTokens,
+ cachedInputTokens: cachedInputTokens,
+ outputTokens: outputTokens,
+ cacheCoverageComplete: cacheCoverageComplete
+ )
+ }
+}
+
+struct RhythmAccumulator {
+ var date: String
+ var hourlyTokens = Array(repeating: 0, count: 24)
+
+ mutating func add(tokens: Int, hour: Int) {
+ guard tokens > 0, (0.. right.offset
+ }
+ return left.element < right.element
+ }
+ let peakHour = (peak?.element ?? 0) > 0 ? peak?.offset : nil
+ let peakTokens = peak?.element ?? 0
+ let activeThreshold = Self.significantTokenThreshold(totalTokens: totalTokens, peakTokens: peakTokens)
+ let significantHourlyTokens = hourlyTokens.map { $0 >= activeThreshold ? $0 : 0 }
+ let activeHours = significantHourlyTokens.filter { $0 > 0 }.count
+ let firstActiveHour = significantHourlyTokens.firstIndex { $0 > 0 }
+ let lastActiveHour = significantHourlyTokens.lastIndex { $0 > 0 }
+ let primaryTag = Self.classify(
+ hourlyTokens: hourlyTokens,
+ significantHourlyTokens: significantHourlyTokens,
+ totalTokens: totalTokens,
+ peakHour: peakHour,
+ peakTokens: peakTokens,
+ activeHours: activeHours,
+ firstActiveHour: firstActiveHour
+ )
+
+ return DailyRhythm(
+ date: date,
+ buckets: buckets,
+ totalTokens: totalTokens,
+ peakHour: peakHour,
+ peakTokens: peakTokens,
+ activeHours: activeHours,
+ firstActiveHour: firstActiveHour,
+ lastActiveHour: lastActiveHour,
+ primaryTag: primaryTag,
+ companionTag: Self.companionTag(for: primaryTag)
+ )
+ }
+
+ static func classify(
+ hourlyTokens: [Int],
+ significantHourlyTokens: [Int],
+ totalTokens: Int,
+ peakHour: Int?,
+ peakTokens: Int,
+ activeHours: Int,
+ firstActiveHour: Int?
+ ) -> RhythmTag {
+ guard totalTokens > 0 else { return .quietDay }
+ let peakShare = share(peakTokens, of: totalTokens)
+ if isDoublePeak(hourlyTokens: significantHourlyTokens, peakTokens: peakTokens) {
+ return .doublePeak
+ }
+ if peakShare >= 0.50 {
+ return .oneShot
+ }
+
+ let nightShare = share(tokens(in: [21, 22, 23, 0, 1, 2], hourlyTokens: significantHourlyTokens), of: totalTokens)
+ if nightShare >= 0.35 || (peakHour.map { $0 >= 21 || $0 <= 2 } == true && nightShare >= 0.25) {
+ return .nightAgent
+ }
+
+ let eveningShare = share(tokens(in: [19, 20], hourlyTokens: significantHourlyTokens), of: totalTokens)
+ if peakHour.map({ (19...20).contains($0) }) == true || eveningShare >= 0.30 {
+ return .eveningSprint
+ }
+
+ let afternoonShare = share(tokens(in: Array(14...18), hourlyTokens: significantHourlyTokens), of: totalTokens)
+ if afternoonShare >= 0.35 || peakHour.map({ (14...18).contains($0) }) == true && afternoonShare >= 0.25 {
+ return .afternoonBurst
+ }
+
+ let earlyShare = share(tokens(in: Array(5...9), hourlyTokens: significantHourlyTokens), of: totalTokens)
+ if firstActiveHour.map({ $0 <= 8 }) == true && earlyShare >= 0.25 {
+ return .earlyStarter
+ }
+
+ let morningShare = share(tokens(in: Array(8...12), hourlyTokens: significantHourlyTokens), of: totalTokens)
+ if morningShare >= 0.35 || peakHour.map({ (8...12).contains($0) }) == true && morningShare >= 0.25 {
+ return .morningPlanner
+ }
+
+ if activeHours >= 6 && peakShare < 0.35 {
+ return .fragmented
+ }
+ if activeHours >= 4 {
+ return .steadyCruise
+ }
+ return .quietDay
+ }
+
+ static func companionTag(for tag: RhythmTag) -> RhythmTag {
+ switch tag {
+ case .earlyStarter:
+ return .nightAgent
+ case .morningPlanner:
+ return .afternoonBurst
+ case .afternoonBurst:
+ return .morningPlanner
+ case .eveningSprint:
+ return .steadyCruise
+ case .nightAgent:
+ return .earlyStarter
+ case .doublePeak:
+ return .steadyCruise
+ case .fragmented:
+ return .oneShot
+ case .oneShot:
+ return .fragmented
+ case .steadyCruise:
+ return .doublePeak
+ case .quietDay:
+ return .morningPlanner
+ }
+ }
+
+ static func isDoublePeak(hourlyTokens: [Int], peakTokens: Int) -> Bool {
+ guard peakTokens > 0 else { return false }
+ let peaks = localPeakCandidates(hourlyTokens: hourlyTokens)
+ .filter { Double($0.tokens) >= Double(peakTokens) * 0.45 }
+ .sorted { $0.tokens > $1.tokens }
+ .prefix(5)
+ for left in peaks {
+ for right in peaks where abs(left.hour - right.hour) >= 4 {
+ return true
+ }
+ }
+ return false
+ }
+
+ static func localPeakCandidates(hourlyTokens: [Int]) -> [(hour: Int, tokens: Int)] {
+ hourlyTokens.enumerated().compactMap { hour, tokens in
+ guard tokens > 0 else { return nil }
+ let previous = hour > 0 ? hourlyTokens[hour - 1] : 0
+ let next = hour < hourlyTokens.count - 1 ? hourlyTokens[hour + 1] : 0
+ guard tokens >= previous && tokens >= next else { return nil }
+ return (hour, tokens)
+ }
+ }
+
+ static func tokens(in hours: [Int], hourlyTokens: [Int]) -> Int {
+ hours.reduce(0) { total, hour in
+ guard hourlyTokens.indices.contains(hour) else { return total }
+ return total + hourlyTokens[hour]
+ }
+ }
+
+ static func share(_ value: Int, of total: Int) -> Double {
+ guard total > 0 else { return 0 }
+ return Double(value) / Double(total)
+ }
+
+ static func significantTokenThreshold(totalTokens: Int, peakTokens: Int) -> Int {
+ guard totalTokens > 0 else { return 1 }
+ let totalBased = Double(totalTokens) * 0.03
+ let peakBased = Double(peakTokens) * 0.30
+ return max(1, Int(max(totalBased, peakBased).rounded()))
+ }
+}
+
+struct ModelKey: Hashable {
+ var tool: String
+ var model: String
+}
diff --git a/TokenStepSwift/Sources/TokenStepSwift/Services/CursorUsageService.swift b/TokenStepSwift/Sources/TokenStepSwift/Services/CursorUsageService.swift
index 8c84d10..6db6005 100644
--- a/TokenStepSwift/Sources/TokenStepSwift/Services/CursorUsageService.swift
+++ b/TokenStepSwift/Sources/TokenStepSwift/Services/CursorUsageService.swift
@@ -206,7 +206,7 @@ enum CursorUsageService {
return UsageSnapshot(
generatedAt: snapshot.generatedAt,
- timezone: snapshot.timezone ?? "Asia/Shanghai",
+ timezone: snapshot.timezone ?? TokenStepClock.identifier,
totals: UsageTotals(
tokens: totalTokens,
cost: rounded(totalCost, digits: 2),
@@ -364,8 +364,7 @@ enum CursorUsageService {
}
private static func dateWindow(lookbackDays: Int, now: Date) -> (start: Date, end: Date, startDate: String, endDate: String) {
- var calendar = Calendar(identifier: .gregorian)
- calendar.timeZone = TimeZone(identifier: "Asia/Shanghai") ?? .current
+ let calendar = TokenStepClock.calendar
let start = calendar.date(
byAdding: .day,
value: -(lookbackDays - 1),
@@ -610,8 +609,7 @@ private struct DayAccumulator {
chargedCents += event.chargedCents
eventCount += 1
models[event.model, default: 0] += event.totalTokens
- var calendar = Calendar(identifier: .gregorian)
- calendar.timeZone = TimeZone(identifier: "Asia/Shanghai") ?? .current
+ let calendar = TokenStepClock.calendar
let hour = calendar.component(.hour, from: event.timestamp)
hourly[hour, default: HourAccumulator(hour: hour)].add(event)
}
diff --git a/TokenStepSwift/Sources/TokenStepSwift/Services/DataService.swift b/TokenStepSwift/Sources/TokenStepSwift/Services/DataService.swift
index cd168f4..c8e82de 100644
--- a/TokenStepSwift/Sources/TokenStepSwift/Services/DataService.swift
+++ b/TokenStepSwift/Sources/TokenStepSwift/Services/DataService.swift
@@ -256,6 +256,27 @@ enum DataService {
}
}
+ /// Foundation's atomic writes stage data in `.dat.nosync*` files beside the
+ /// target. A crash or forced quit mid-write leaves them behind. Only files a day
+ /// old are removed so an in-flight write is never touched.
+ static func removeStaleAtomicWriteLeftovers(
+ root: URL = AppPaths.appSupportRoot,
+ now: Date = Date()
+ ) {
+ let fileManager = FileManager.default
+ for folder in ["data", "config", "cache"] {
+ let directory = root.appendingPathComponent(folder, isDirectory: true)
+ guard let names = try? fileManager.contentsOfDirectory(atPath: directory.path) else { continue }
+ for name in names where name.hasPrefix(".dat.nosync") {
+ let url = directory.appendingPathComponent(name)
+ guard let modified = (try? fileManager.attributesOfItem(atPath: url.path))?[.modificationDate] as? Date,
+ now.timeIntervalSince(modified) > 24 * 60 * 60
+ else { continue }
+ try? fileManager.removeItem(at: url)
+ }
+ }
+ }
+
static func acknowledgeUsageRecalibrationNotice() {
try? FileManager.default.removeItem(at: AppPaths.usageRecalibrationNoticeMarker)
}
diff --git a/TokenStepSwift/Sources/TokenStepSwift/Services/QuotaRefreshCoordinator.swift b/TokenStepSwift/Sources/TokenStepSwift/Services/QuotaRefreshCoordinator.swift
index 14e479a..9aabe0b 100644
--- a/TokenStepSwift/Sources/TokenStepSwift/Services/QuotaRefreshCoordinator.swift
+++ b/TokenStepSwift/Sources/TokenStepSwift/Services/QuotaRefreshCoordinator.swift
@@ -16,6 +16,10 @@ enum QuotaRefreshCoordinator {
}
}
_ = group.wait(timeout: .now() + 12)
+ // Providers that missed the deadline keep running and may still write;
+ // snapshot under the same lock so the read never races those writes.
+ lock.lock()
+ defer { lock.unlock() }
return result
}
diff --git a/TokenStepSwift/Sources/TokenStepSwift/Services/UsageCollector.swift b/TokenStepSwift/Sources/TokenStepSwift/Services/UsageCollector.swift
deleted file mode 100644
index 51a2c00..0000000
--- a/TokenStepSwift/Sources/TokenStepSwift/Services/UsageCollector.swift
+++ /dev/null
@@ -1,4719 +0,0 @@
-import CryptoKit
-import Foundation
-import SQLite3
-
-struct UsageCollectionFileState: Codable, Equatable {
- var path: String
- var size: UInt64
- var modificationTime: TimeInterval
-}
-
-struct UsageCollectionState: Codable, Equatable {
- var schemaVersion = 2
- var historyDays: Int
- var includesExperimentalAgentSources: Bool
- var windowDay: String
- var files: [UsageCollectionFileState]
-}
-
-struct CodexIncrementalCacheStats: Equatable {
- var generation: Int
- var sessions: Int
- var records: Int
- var lastLogicalWriteBytes: Int
-}
-
-struct CodexAccountingComparisonDiagnostics {
- var incrementalSnapshot: UsageSnapshot
- var referenceSnapshot: UsageSnapshot
- var mismatchedPathHashes: [String]
- var incrementalRecordCount: Int
- var referenceRecordCount: Int
-}
-
-enum UsageCollector {
- static let codexAccountingRevision = 8
-
- private static let timezone = TimeZone(identifier: "Asia/Shanghai") ?? .current
- private static let maxRelevantLineBytes = 1_048_576
- private static let ccSwitchSourceName = "CC Switch Proxy"
-
- static func collect(
- historyDays: Int = TokenStepSettings.defaults.historyDays,
- includeCCSwitchProxyUsage: Bool = true,
- ccSwitchDatabaseURL: URL? = nil,
- includeExperimentalAgentSources: Bool = false,
- zCodeDatabaseURL: URL? = nil,
- hermesDatabaseURL: URL? = nil,
- workBuddyRootURLs: [URL]? = nil,
- forceFullValidation: Bool = false
- ) -> UsageSnapshot {
- let cacheLoad = loadCache()
- var cache = cacheLoad.cache
- var livePaths = Set()
- let sourceCutoff = sourceFileCutoffDate(historyDays: historyDays)
- var ccSwitch = includeCCSwitchProxyUsage
- ? collectCCSwitchProxyUsage(databaseURL: ccSwitchDatabaseURL)
- : CollectorResult(records: [], source: SourceInfo(status: "disabled", files: nil, records: 0))
- let codexOutcome = collectCodex(
- cache: &cache,
- livePaths: &livePaths,
- modifiedSince: sourceCutoff,
- databaseURL: AppPaths.codexIncrementalCacheSQLite,
- forceFullValidation: forceFullValidation,
- requiresDetailedRecords: !ccSwitch.records.isEmpty
- )
- var codex = codexOutcome.result
- codex.source.recalibratedFromRevision = cacheLoad.recalibratedFromRevision
- let claude = collectClaudeCode(cache: &cache, livePaths: &livePaths, modifiedSince: sourceCutoff)
- let zCode = includeExperimentalAgentSources
- ? collectZCodeUsage(databaseURL: zCodeDatabaseURL)
- : CollectorResult(records: [], source: SourceInfo(status: "disabled", files: nil, records: 0))
- let hermes = includeExperimentalAgentSources
- ? collectHermesUsage(databaseURL: hermesDatabaseURL)
- : CollectorResult(records: [], source: SourceInfo(status: "disabled", files: nil, records: 0))
- let workBuddy = includeExperimentalAgentSources
- ? collectWorkBuddyUsage(rootURLs: workBuddyRootURLs, modifiedSince: sourceCutoff)
- : CollectorResult(records: [], source: SourceInfo(status: "disabled", files: nil, records: 0))
- if codexOutcome.usedIncrementalStore {
- cache.files = cache.files.filter { $0.value.tool != "Codex" && livePaths.contains($0.key) }
- } else {
- cache.files = cache.files.filter { livePaths.contains($0.key) }
- }
- saveCache(cache)
-
- let nativeRecords = codex.records + claude.records
- let deduped = deduplicateCrossSource(
- nativeRecords: nativeRecords,
- proxyRecords: ccSwitch.records
- )
- if includeCCSwitchProxyUsage {
- ccSwitch.source = sourceInfo(ccSwitch.source, annotatedWith: deduped)
- }
- let records = recordsInHistoryWindow(
- deduped.records + zCode.records + hermes.records + workBuddy.records,
- historyDays: historyDays,
- now: Date()
- )
- return aggregate(
- records: records,
- sources: [
- "Codex": codex.source,
- "Claude Code": claude.source,
- ccSwitchSourceName: ccSwitch.source,
- "ZCode": zCode.source,
- "Hermes Agent": hermes.source,
- "WorkBuddy": workBuddy.source
- ]
- )
- }
-
- static func collectionState(
- historyDays: Int,
- includeExperimentalAgentSources: Bool,
- homeURL: URL = FileManager.default.homeDirectoryForCurrentUser,
- now: Date = Date()
- ) -> UsageCollectionState {
- let cutoff = sourceFileCutoffDate(historyDays: historyDays)
- var urls = defaultCodexSessionRoots(homeURL: homeURL)
- .flatMap { jsonlFiles(under: $0, modifiedSince: cutoff) }
- urls.append(contentsOf: jsonlFiles(
- under: homeURL.appendingPathComponent(".claude/projects", isDirectory: true),
- modifiedSince: cutoff
- ))
-
- let databases = [
- homeURL.appendingPathComponent(".codex/state_5.sqlite"),
- homeURL.appendingPathComponent(".codex/sqlite/state_5.sqlite"),
- homeURL.appendingPathComponent(".cc-switch/cc-switch.db")
- ]
- urls.append(contentsOf: existingDatabaseFiles(databases))
-
- if includeExperimentalAgentSources {
- urls.append(contentsOf: existingDatabaseFiles([
- homeURL.appendingPathComponent(".zcode/cli/db/db.sqlite"),
- homeURL.appendingPathComponent(".hermes/state.db")
- ]))
- urls.append(contentsOf: [
- homeURL.appendingPathComponent(".workbuddy/projects", isDirectory: true),
- homeURL.appendingPathComponent("Library/Application Support/WorkBuddyExtension", isDirectory: true)
- ].flatMap { jsonlFiles(under: $0, modifiedSince: cutoff) })
- }
-
- let files = Dictionary(grouping: urls, by: \.path)
- .compactMap { _, duplicates in duplicates.first.flatMap(collectionFileState) }
- .sorted { $0.path < $1.path }
- return UsageCollectionState(
- historyDays: historyDays,
- includesExperimentalAgentSources: includeExperimentalAgentSources,
- windowDay: dayFormatter.string(from: now),
- files: files
- )
- }
-
- static func codexIncrementalCacheStatsForTests(databaseURL: URL) -> CodexIncrementalCacheStats? {
- try? CodexIncrementalStore(url: databaseURL).stats()
- }
-
- static func codexCollectionStateForTests(
- homeURL: URL
- ) -> [UsageCollectionFileState] {
- defaultCodexSessionRoots(homeURL: homeURL)
- .flatMap { jsonlFiles(under: $0, modifiedSince: nil) }
- .compactMap(collectionFileState)
- .sorted { $0.path < $1.path }
- }
-
- static func compareIncrementalCodexAccountingForTests(
- homeURL: URL,
- databaseURL: URL
- ) throws -> CodexAccountingComparisonDiagnostics {
- let incremental = try collectCodexIncrementally(
- modifiedSince: nil,
- databaseURL: databaseURL,
- forceFullValidation: false,
- homeURL: homeURL,
- requiresDetailedRecords: true
- )
- var cache = CollectorCache()
- var livePaths = Set()
- let reference = collectCodexFromJSONL(
- cache: &cache,
- livePaths: &livePaths,
- modifiedSince: nil,
- homeURL: homeURL
- )
-
- return try accountingComparisonDiagnostics(
- incremental: incremental,
- reference: reference
- )
- }
-
- static func compareLegacyMigrationCodexAccountingForTests(
- homeURL: URL,
- databaseURL: URL
- ) throws -> CodexAccountingComparisonDiagnostics {
- var legacyCache = CollectorCache()
- var livePaths = Set()
- let reference = collectCodexFromJSONL(
- cache: &legacyCache,
- livePaths: &livePaths,
- modifiedSince: nil,
- homeURL: homeURL
- )
- let incremental = try collectCodexIncrementally(
- modifiedSince: nil,
- databaseURL: databaseURL,
- forceFullValidation: false,
- homeURL: homeURL,
- requiresDetailedRecords: true,
- legacyCache: legacyCache
- )
- return try accountingComparisonDiagnostics(
- incremental: incremental,
- reference: reference
- )
- }
-
- private static func accountingComparisonDiagnostics(
- incremental: CollectorResult,
- reference: CollectorResult
- ) throws -> CodexAccountingComparisonDiagnostics {
- let incrementalByPath = Dictionary(grouping: incremental.records) {
- $0.sourcePath ?? ""
- }
- let referenceByPath = Dictionary(grouping: reference.records) {
- $0.sourcePath ?? ""
- }
- let encoder = JSONEncoder()
- encoder.outputFormatting = [.sortedKeys]
- let paths = Set(incrementalByPath.keys).union(referenceByPath.keys)
- let mismatches = try paths.compactMap { path -> String? in
- let incrementalData = try encoder.encode(incrementalByPath[path] ?? [])
- let referenceData = try encoder.encode(referenceByPath[path] ?? [])
- return incrementalData == referenceData ? nil : anonymousPathHash(path)
- }.sorted()
-
- return CodexAccountingComparisonDiagnostics(
- incrementalSnapshot: aggregate(
- records: incremental.records,
- sources: ["Codex": incremental.source]
- ),
- referenceSnapshot: aggregate(
- records: reference.records,
- sources: ["Codex": reference.source]
- ),
- mismatchedPathHashes: mismatches,
- incrementalRecordCount: incremental.records.count,
- referenceRecordCount: reference.records.count
- )
- }
-
- private static func anonymousPathHash(_ path: String) -> String {
- var hash: UInt64 = 14_695_981_039_346_656_037
- for byte in path.utf8 {
- hash ^= UInt64(byte)
- hash &*= 1_099_511_628_211
- }
- return String(format: "%016llx", hash)
- }
-
- private static func existingDatabaseFiles(_ databases: [URL]) -> [URL] {
- databases.flatMap { database in
- [
- database,
- URL(fileURLWithPath: database.path + "-wal")
- ].filter { FileManager.default.fileExists(atPath: $0.path) }
- }
- }
-
- private static func collectionFileState(_ url: URL) -> UsageCollectionFileState? {
- guard let metadata = fileMetadata(for: url) else { return nil }
- return UsageCollectionFileState(
- path: url.standardizedFileURL.path,
- size: metadata.size,
- modificationTime: metadata.modificationTime
- )
- }
-
- static func collectCCSwitchProxyUsageSnapshot(databaseURL: URL) -> UsageSnapshot {
- let result = collectCCSwitchProxyUsage(databaseURL: databaseURL)
- return aggregate(
- records: result.records,
- sources: [ccSwitchSourceName: result.source]
- )
- }
-
- static func collectClaudeCodeUsageSnapshot(rootURL: URL) -> UsageSnapshot {
- var cache = CollectorCache()
- var livePaths = Set()
- let result = collectClaudeCode(cache: &cache, livePaths: &livePaths, rootURL: rootURL, modifiedSince: nil)
- return aggregate(records: result.records, sources: ["Claude Code": result.source])
- }
-
- static func collectCodexUsageSnapshotForTests(
- homeURL: URL,
- cacheURL: URL? = nil,
- forceFullValidation: Bool = false,
- requiresDetailedRecords: Bool = false
- ) -> UsageSnapshot {
- if let cacheURL {
- do {
- let result = try collectCodexIncrementally(
- modifiedSince: nil,
- databaseURL: cacheURL,
- forceFullValidation: forceFullValidation,
- homeURL: homeURL,
- requiresDetailedRecords: requiresDetailedRecords
- )
- return aggregate(records: result.records, sources: ["Codex": result.source])
- } catch {
- return aggregate(
- records: [],
- sources: ["Codex": SourceInfo(status: "incremental_cache_error", files: 0, records: 0)]
- )
- }
- }
- var cache = CollectorCache()
- var livePaths = Set()
- let result = collectCodexFromJSONL(
- cache: &cache,
- livePaths: &livePaths,
- modifiedSince: nil,
- homeURL: homeURL
- )
- return aggregate(records: result.records, sources: ["Codex": result.source])
- }
-
- static func collectIncrementalCodexAndProxySnapshotForTests(
- codexRoots: [URL],
- cacheURL: URL,
- ccSwitchDatabaseURL: URL
- ) -> UsageSnapshot {
- let codex: CollectorResult
- do {
- codex = try collectCodexIncrementally(
- modifiedSince: nil,
- databaseURL: cacheURL,
- forceFullValidation: false,
- requiresDetailedRecords: true,
- roots: codexRoots
- )
- } catch {
- codex = CollectorResult(
- records: [],
- source: SourceInfo(status: "incremental_cache_error", files: 0, records: 0)
- )
- }
- var proxy = collectCCSwitchProxyUsage(databaseURL: ccSwitchDatabaseURL)
- let deduped = deduplicateCrossSource(
- nativeRecords: codex.records,
- proxyRecords: proxy.records
- )
- proxy.source = sourceInfo(proxy.source, annotatedWith: deduped)
- return aggregate(
- records: deduped.records,
- sources: [
- "Codex": codex.source,
- ccSwitchSourceName: proxy.source
- ]
- )
- }
-
- static func collectCodexWithIncrementalFallbackForTests(
- homeURL: URL,
- cacheURL: URL
- ) -> UsageSnapshot {
- var cache = CollectorCache()
- var livePaths = Set()
- let outcome = collectCodex(
- cache: &cache,
- livePaths: &livePaths,
- modifiedSince: nil,
- databaseURL: cacheURL,
- forceFullValidation: false,
- requiresDetailedRecords: false,
- homeURL: homeURL
- )
- return aggregate(
- records: outcome.result.records,
- sources: ["Codex": outcome.result.source]
- )
- }
-
- static func collectorCacheRecalibrationRevisionForTests(cacheURL: URL) -> Int? {
- loadCache(at: cacheURL).recalibratedFromRevision
- }
-
- static func collectUsageSnapshotForTests(
- codexRoots: [URL] = [],
- claudeRootURL: URL? = nil,
- ccSwitchDatabaseURL: URL? = nil,
- zCodeDatabaseURL: URL? = nil,
- hermesDatabaseURL: URL? = nil,
- workBuddyRootURLs: [URL]? = nil,
- includeExperimentalAgentSources: Bool = false,
- historyDays: Int? = nil,
- now: Date = Date()
- ) -> UsageSnapshot {
- var cache = CollectorCache()
- var livePaths = Set()
- let codex = codexRoots.isEmpty
- ? CollectorResult(records: [], source: SourceInfo(status: "disabled", files: nil, records: 0))
- : collectCodexFromJSONL(
- cache: &cache,
- livePaths: &livePaths,
- modifiedSince: nil,
- roots: codexRoots
- )
- let claude = claudeRootURL.map {
- collectClaudeCode(cache: &cache, livePaths: &livePaths, rootURL: $0, modifiedSince: nil)
- } ?? CollectorResult(records: [], source: SourceInfo(status: "disabled", files: nil, records: 0))
- var ccSwitch = ccSwitchDatabaseURL.map {
- collectCCSwitchProxyUsage(databaseURL: $0)
- } ?? CollectorResult(records: [], source: SourceInfo(status: "disabled", files: nil, records: 0))
- let zCode = includeExperimentalAgentSources
- ? zCodeDatabaseURL.map { collectZCodeUsage(databaseURL: $0) } ?? CollectorResult(records: [], source: SourceInfo(status: "missing_db", files: 0, records: 0))
- : CollectorResult(records: [], source: SourceInfo(status: "disabled", files: nil, records: 0))
- let hermes = includeExperimentalAgentSources
- ? hermesDatabaseURL.map { collectHermesUsage(databaseURL: $0) } ?? CollectorResult(records: [], source: SourceInfo(status: "missing_db", files: 0, records: 0))
- : CollectorResult(records: [], source: SourceInfo(status: "disabled", files: nil, records: 0))
- let workBuddy = includeExperimentalAgentSources
- ? collectWorkBuddyUsage(rootURLs: workBuddyRootURLs ?? [], modifiedSince: nil)
- : CollectorResult(records: [], source: SourceInfo(status: "disabled", files: nil, records: 0))
- let deduped = deduplicateCrossSource(
- nativeRecords: codex.records + claude.records,
- proxyRecords: ccSwitch.records
- )
- ccSwitch.source = sourceInfo(ccSwitch.source, annotatedWith: deduped)
- let allRecords = deduped.records + zCode.records + hermes.records + workBuddy.records
- let records = historyDays.map {
- recordsInHistoryWindow(allRecords, historyDays: $0, now: now)
- } ?? allRecords
- return aggregate(
- records: records,
- sources: [
- "Codex": codex.source,
- "Claude Code": claude.source,
- ccSwitchSourceName: ccSwitch.source,
- "ZCode": zCode.source,
- "Hermes Agent": hermes.source,
- "WorkBuddy": workBuddy.source
- ]
- )
- }
-
- private static func collectCodex(
- cache: inout CollectorCache,
- livePaths: inout Set,
- modifiedSince cutoffDate: Date?,
- databaseURL: URL,
- forceFullValidation: Bool,
- requiresDetailedRecords: Bool,
- homeURL: URL = FileManager.default.homeDirectoryForCurrentUser
- ) -> CodexCollectionOutcome {
- func runIncremental() throws -> CollectorResult {
- try collectCodexIncrementally(
- modifiedSince: cutoffDate,
- databaseURL: databaseURL,
- forceFullValidation: forceFullValidation,
- homeURL: homeURL,
- requiresDetailedRecords: requiresDetailedRecords,
- legacyCache: cache
- )
- }
-
- do {
- let incremental = try runIncremental()
- if incremental.source.status == "ok" {
- return CodexCollectionOutcome(result: incremental, usedIncrementalStore: true)
- }
- } catch {
- if let cacheError = error as? CodexIncrementalStoreError,
- cacheError.shouldRebuildCache {
- CodexIncrementalStore.discardDatabase(at: databaseURL)
- if let rebuilt = try? runIncremental(), rebuilt.source.status == "ok" {
- return CodexCollectionOutcome(result: rebuilt, usedIncrementalStore: true)
- }
- }
- }
-
- let jsonlResult = collectCodexFromJSONL(
- cache: &cache,
- livePaths: &livePaths,
- modifiedSince: cutoffDate,
- homeURL: homeURL
- )
- if jsonlResult.source.status == "ok" {
- return CodexCollectionOutcome(result: jsonlResult, usedIncrementalStore: false)
- }
- return CodexCollectionOutcome(
- result: collectCodexFromSQLite() ?? jsonlResult,
- usedIncrementalStore: false
- )
- }
-
- private static func collectCodexIncrementally(
- modifiedSince cutoffDate: Date?,
- databaseURL: URL,
- forceFullValidation: Bool,
- homeURL: URL = FileManager.default.homeDirectoryForCurrentUser,
- requiresDetailedRecords: Bool = false,
- legacyCache: CollectorCache? = nil,
- roots: [URL]? = nil
- ) throws -> CollectorResult {
- let paths = (roots ?? defaultCodexSessionRoots(homeURL: homeURL))
- .flatMap { jsonlFiles(under: $0, modifiedSince: cutoffDate) }
- .sorted { $0.path < $1.path }
- guard !paths.isEmpty else {
- return CollectorResult(
- records: [],
- source: SourceInfo(status: "missing", files: 0, records: 0)
- )
- }
-
- let store = try CodexIncrementalStore(url: databaseURL)
- let storedMetadata = try store.metadataByPath()
- let currentPaths = Set(paths.map(\.path))
- let deletedPaths = Set(storedMetadata.keys).subtracting(currentPaths)
- var fullyAffectedParentIDs = Set(
- deletedPaths.compactMap { storedMetadata[$0]?.sessionID }
- )
- var appendedParentAnchorThresholds = [String: TimeInterval]()
- var stagedPaths = Set()
- try store.beginStaging()
- var committed = false
- defer {
- if !committed {
- store.abortStaging()
- }
- }
-
- func validatedScan(
- at path: URL,
- metadata: (size: UInt64, modificationTime: TimeInterval)
- ) throws -> PendingCodexSession {
- if !forceFullValidation,
- let legacyCache,
- let scan = cachedCodexScan(for: path, cache: legacyCache),
- let fingerprint = contentFingerprint(for: path, size: metadata.size) {
- return PendingCodexSession(
- path: path,
- metadata: metadata,
- fingerprint: fingerprint,
- scan: scan
- )
- }
-
- guard var stable = stableCodexScan(at: path) else {
- throw CodexIncrementalStoreError.unstableSource(path.path)
- }
- if !stable.isStable, let retry = stableCodexScan(at: path) {
- stable = retry
- }
- guard stable.isStable,
- let fingerprint = contentFingerprint(for: path, size: stable.metadata.size)
- else {
- throw CodexIncrementalStoreError.unstableSource(path.path)
- }
- return PendingCodexSession(
- path: path,
- metadata: stable.metadata,
- fingerprint: fingerprint,
- scan: stable.scan
- )
- }
-
- for path in paths {
- guard let metadata = fileMetadata(for: path) else { continue }
- let stored = storedMetadata[path.path]
- var validatedFullFingerprint: String?
- let metadataMatches = stored?.size == metadata.size
- && abs((stored?.modificationTime ?? -1) - metadata.modificationTime) < 0.001
- if metadataMatches {
- if !forceFullValidation {
- continue
- }
- let fingerprint = contentFingerprint(for: path, size: metadata.size)
- if fingerprint == stored?.fingerprint {
- guard let fullFingerprint = fullContentFingerprint(
- for: path,
- size: metadata.size
- ),
- let afterValidation = fileMetadata(for: path),
- UsageCollector.metadata(metadata, matches: afterValidation)
- else {
- throw CodexIncrementalStoreError.unstableSource(path.path)
- }
- if fullFingerprint == stored?.validationFingerprint {
- continue
- }
- validatedFullFingerprint = fullFingerprint
- }
- }
-
- if !forceFullValidation,
- let stored,
- metadata.size > stored.size,
- contentFingerprint(for: path, size: stored.size) == stored.fingerprint,
- let cachedSession = try store.session(path: path.path),
- let appended = incrementalCodexAppend(at: path, cached: cachedSession) {
- try store.stage(session: appended)
- if let earliestNewAnchor = appended.anchors
- .dropFirst(cachedSession.anchors.count)
- .first?.timestamp {
- appendedParentAnchorThresholds[appended.sessionID] = min(
- appendedParentAnchorThresholds[appended.sessionID] ?? earliestNewAnchor,
- earliestNewAnchor
- )
- }
- continue
- }
-
- var pending = try validatedScan(at: path, metadata: metadata)
- if validatedFullFingerprint != nil {
- pending.validationFingerprint = validatedFullFingerprint
- } else if forceFullValidation, stored != nil, metadataMatches {
- guard let fullFingerprint = fullContentFingerprint(
- for: path,
- size: pending.metadata.size
- ),
- let afterValidation = fileMetadata(for: path),
- UsageCollector.metadata(pending.metadata, matches: afterValidation)
- else {
- throw CodexIncrementalStoreError.unstableSource(path.path)
- }
- pending.validationFingerprint = fullFingerprint
- }
- try store.stage(
- scan: pending,
- anchors: codexAnchors(for: pending.scan),
- createdAtEpoch: pending.scan.createdAt.flatMap(parseISO)?.timeIntervalSince1970
- )
- stagedPaths.insert(path.path)
- fullyAffectedParentIDs.insert(pending.scan.canonicalSessionID)
- if let previousID = stored?.sessionID {
- fullyAffectedParentIDs.insert(previousID)
- }
- }
-
- func stageChild(at childPath: String) throws {
- guard currentPaths.contains(childPath), !stagedPaths.contains(childPath) else { return }
- let url = URL(fileURLWithPath: childPath)
- guard let metadata = fileMetadata(for: url) else {
- throw CodexIncrementalStoreError.unstableSource(childPath)
- }
- var pending = try validatedScan(at: url, metadata: metadata)
- if let stored = storedMetadata[childPath],
- stored.size == metadata.size,
- abs(stored.modificationTime - metadata.modificationTime) < 0.001,
- contentFingerprint(for: url, size: metadata.size) == stored.fingerprint {
- pending.validationFingerprint = stored.validationFingerprint
- }
- try store.stage(
- scan: pending,
- anchors: codexAnchors(for: pending.scan),
- createdAtEpoch: pending.scan.createdAt.flatMap(parseISO)?.timeIntervalSince1970
- )
- stagedPaths.insert(childPath)
- }
-
- for parentID in fullyAffectedParentIDs {
- for childPath in try store.childPaths(parentSessionID: parentID) {
- try stageChild(at: childPath)
- }
- }
- for (parentID, earliestNewAnchor) in appendedParentAnchorThresholds
- where !fullyAffectedParentIDs.contains(parentID) {
- for childPath in try store.childPaths(
- parentSessionID: parentID,
- createdAtOnOrAfter: earliestNewAnchor
- ) {
- try stageChild(at: childPath)
- }
- }
-
- for stagedPath in try store.stagedScanPaths() {
- guard let item = try store.stagedScan(path: stagedPath) else {
- throw CodexIncrementalStoreError.sqlite("missing staged scan for \(stagedPath)")
- }
- let parentAnchors: [CodexAnchor]?
- if let parentID = item.scan.parentSessionID {
- if let pendingParent = try store.stagedAnchors(sessionID: parentID) {
- parentAnchors = pendingParent
- } else {
- parentAnchors = try store.anchors(sessionID: parentID)
- }
- } else {
- parentAnchors = nil
- }
- let childCreatedAt = item.scan.createdAt.flatMap(parseISO)?.timeIntervalSince1970
- let parentAnchor = childCreatedAt.flatMap { timestamp in
- parentAnchors.flatMap { codexAnchor(atOrBefore: timestamp, anchors: $0) }
- }
- var seenRequestIDs = Set()
- let result = codexDeltaRecords(
- from: item.scan,
- parentAnchor: parentAnchor,
- seenRequestIDs: &seenRequestIDs
- )
- let candidate = CodexCachedSession(
- path: item.path.path,
- size: item.metadata.size,
- modificationTime: item.metadata.modificationTime,
- fingerprint: item.fingerprint,
- validationFingerprint: item.validationFingerprint,
- sessionID: item.scan.canonicalSessionID,
- createdAtEpoch: childCreatedAt,
- parentSessionID: item.scan.parentSessionID,
- anchors: try store.stagedAnchors(
- sessionID: item.scan.canonicalSessionID
- ) ?? [],
- records: result.records,
- summaryRecords: summarizeCodexRecords(result.records),
- cursor: CodexSessionCursor(
- currentModel: item.scan.finalModel ?? item.scan.events.last?.model ?? "unknown",
- relevantLineNumber: item.scan.relevantLineCount ?? item.scan.events.count,
- hasCumulativeSchema: result.cursor.hasCumulativeSchema,
- previousCumulative: result.cursor.previousCumulative,
- epoch: result.cursor.epoch
- ),
- diagnostics: result.diagnostics
- )
- if let existing = try store.session(path: item.path.path),
- candidate.hasSameStoredAccounting(as: existing) {
- if let validationFingerprint = candidate.validationFingerprint,
- validationFingerprint != existing.validationFingerprint {
- try store.updateValidationFingerprint(
- validationFingerprint,
- path: item.path.path
- )
- }
- } else {
- try store.stage(session: candidate)
- }
- }
-
- try store.commitStaged(deletedPaths: deletedPaths)
- committed = true
- let cachedSessionCount = try store.sessionCount()
- guard cachedSessionCount == paths.count else {
- throw CodexIncrementalStoreError.incompleteCache(
- expected: paths.count,
- actual: cachedSessionCount
- )
- }
-
- var seenRequestIDs = Set()
- var records = [UsageRecord]()
- var summaries = [CodexSummaryKey: CodexSummaryAccumulator]()
- var diagnostics = CodexCollectionDiagnostics()
- var sourceRecordCount = 0
- try store.forEachContribution(detailed: requiresDetailedRecords) { contribution in
- sourceRecordCount += contribution.recordCount
- diagnostics.add(contribution.diagnostics)
- for record in contribution.records {
- if let requestID = record.requestID,
- !seenRequestIDs.insert(requestID).inserted {
- diagnostics.duplicateRecords += 1
- continue
- }
- if requiresDetailedRecords {
- records.append(record)
- } else {
- addCodexSummary(record, to: &summaries)
- }
- }
- }
- if !requiresDetailedRecords {
- records = codexSummaryRecords(summaries)
- }
- return codexCollectorResult(
- records: records,
- diagnostics: diagnostics,
- fileCount: paths.count,
- sourceRecordCount: sourceRecordCount
- )
- }
-
- private static func collectCodexFromSQLite() -> CollectorResult? {
- let home = FileManager.default.homeDirectoryForCurrentUser
- let candidates = [
- home.appendingPathComponent(".codex/state_5.sqlite"),
- home.appendingPathComponent(".codex/sqlite/state_5.sqlite")
- ]
- guard let database = candidates.first(where: { FileManager.default.fileExists(atPath: $0.path) }) else {
- return nil
- }
-
- let query = "select created_at, model, tokens_used from threads where tokens_used > 0"
- let process = Process()
- process.executableURL = URL(fileURLWithPath: "/usr/bin/sqlite3")
- process.arguments = ["-readonly", "-json", database.path, query]
-
- let output = Pipe()
- process.standardOutput = output
- process.standardError = Pipe()
-
- do {
- try process.run()
- process.waitUntilExit()
- } catch {
- return nil
- }
- guard process.terminationStatus == 0 else { return nil }
-
- let data = output.fileHandleForReading.readDataToEndOfFile()
- guard let rows = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else {
- return nil
- }
-
- let records = rows.compactMap { row -> UsageRecord? in
- let tokens = integerValue(row["tokens_used"] as Any)
- guard tokens > 0,
- let day = dayString(fromEpoch: row["created_at"] as Any)
- else {
- return nil
- }
- var usage = TokenUsageCounts()
- usage.totalTokens = tokens
- return UsageRecord(
- date: day,
- timestamp: nil,
- tool: "Codex",
- model: modelKey(row["model"] as? String),
- usage: usage,
- source: .nativeCodexSQLite
- )
- }
-
- guard !records.isEmpty else { return nil }
- return CollectorResult(
- records: records,
- source: SourceInfo(
- status: "ok_sqlite",
- files: 1,
- records: records.count
- )
- )
- }
-
- private static func collectCodexFromJSONL(
- cache: inout CollectorCache,
- livePaths: inout Set,
- modifiedSince cutoffDate: Date?,
- homeURL: URL = FileManager.default.homeDirectoryForCurrentUser,
- roots: [URL]? = nil
- ) -> CollectorResult {
- let roots = roots ?? defaultCodexSessionRoots(homeURL: homeURL)
- let paths = roots
- .flatMap { jsonlFiles(under: $0, modifiedSince: cutoffDate) }
- .sorted { $0.path < $1.path }
- var scans: [CodexSessionScan] = []
-
- for path in paths {
- livePaths.insert(path.path)
- if let cached = cachedCodexScan(for: path, cache: cache) {
- scans.append(cached)
- continue
- }
-
- guard var result = stableCodexScan(at: path) else { continue }
- if !result.isStable, let retry = stableCodexScan(at: path) {
- result = retry
- }
- scans.append(result.scan)
- if result.isStable {
- updateCodexCache(path: path, scan: result.scan, metadata: result.metadata, cache: &cache)
- }
- }
-
- let scansBySessionID = Dictionary(
- scans.map { ($0.canonicalSessionID, $0) },
- uniquingKeysWith: { first, _ in first }
- )
- let anchorsBySessionID = scansBySessionID.mapValues(codexAnchors)
- var records: [UsageRecord] = []
- var diagnostics = CodexCollectionDiagnostics()
- var seenRequestIDs = Set()
- for scan in scans.sorted(by: { $0.sourcePath < $1.sourcePath }) {
- let parentAnchor = codexForkAnchor(for: scan, anchorsBySessionID: anchorsBySessionID)
- let result = codexDeltaRecords(
- from: scan,
- parentAnchor: parentAnchor,
- seenRequestIDs: &seenRequestIDs
- )
- records.append(contentsOf: result.records)
- diagnostics.add(result.diagnostics)
- }
-
- return codexCollectorResult(
- records: records,
- diagnostics: diagnostics,
- fileCount: paths.count
- )
- }
-
- private static func codexCollectorResult(
- records: [UsageRecord],
- diagnostics: CodexCollectionDiagnostics,
- fileCount: Int,
- sourceRecordCount: Int? = nil
- ) -> CollectorResult {
- let breakdown = records.reduce(into: TokenUsageCounts()) { partial, record in
- partial.add(record.usage)
- }
- return CollectorResult(
- records: records,
- source: SourceInfo(
- status: records.isEmpty ? "missing" : "ok",
- files: fileCount,
- records: sourceRecordCount ?? records.count,
- rawRecords: diagnostics.rawRecords,
- dedupedRecords: diagnostics.duplicateRecords + diagnostics.inheritedRecords,
- skippedRecords: diagnostics.skippedRecords,
- strategy: "total_token_usage_delta_v6_with_incremental_cache",
- exactRecords: diagnostics.exactRecords,
- legacyRecords: diagnostics.legacyRecords,
- duplicateRecords: diagnostics.duplicateRecords,
- counterResets: diagnostics.counterResets,
- inheritedRecords: diagnostics.inheritedRecords,
- inheritedTokens: diagnostics.inheritedTokens,
- unknownBreakdownRecords: diagnostics.unknownBreakdownRecords,
- accountingRevision: codexAccountingRevision,
- tokenBreakdown: SourceTokenBreakdown(
- processedTokens: breakdown.totalTokens,
- inputTokens: breakdown.inputTokens,
- cachedInputTokens: breakdown.cacheReadInputTokens,
- uncachedInputTokens: max(
- 0,
- breakdown.inputTokens
- - breakdown.cacheReadInputTokens
- - breakdown.cacheCreationInputTokens
- ),
- outputTokens: breakdown.outputTokens,
- reasoningTokens: breakdown.reasoningOutputTokens
- )
- )
- )
- }
-
- private static func summarizeCodexRecords(_ records: [UsageRecord]) -> [UsageRecord] {
- var summaries = [CodexSummaryKey: CodexSummaryAccumulator]()
- for record in records {
- addCodexSummary(record, to: &summaries)
- }
- return codexSummaryRecords(summaries)
- }
-
- private static func addCodexSummary(
- _ record: UsageRecord,
- to summaries: inout [CodexSummaryKey: CodexSummaryAccumulator]
- ) {
- let hour = record.timestampEpoch.map(hour(fromEpoch:))
- ?? hour(fromISO: record.timestamp)
- let key = CodexSummaryKey(date: record.date, model: record.model, hour: hour)
- summaries[key, default: CodexSummaryAccumulator()].add(record)
- }
-
- private static func codexSummaryRecords(
- _ summaries: [CodexSummaryKey: CodexSummaryAccumulator]
- ) -> [UsageRecord] {
- summaries.map { key, value in
- UsageRecord(
- date: key.date,
- timestamp: value.timestamp,
- timestampEpoch: value.timestampEpoch,
- tool: "Codex",
- model: key.model,
- usage: value.usage,
- source: .nativeCodex,
- dataSource: "codex_incremental_summary",
- modelRequestCount: value.modelRequestCount,
- toolCallCount: value.toolCallCount
- )
- }.sorted {
- if $0.date != $1.date { return $0.date < $1.date }
- if $0.model != $1.model { return $0.model < $1.model }
- return ($0.timestampEpoch ?? -1) < ($1.timestampEpoch ?? -1)
- }
- }
-
- private static func stableCodexScan(
- at path: URL
- ) -> (scan: CodexSessionScan, isStable: Bool, metadata: (size: UInt64, modificationTime: TimeInterval))? {
- guard let before = fileMetadata(for: path),
- let scan = scanCodexSessionFile(at: path),
- let after = fileMetadata(for: path)
- else {
- return nil
- }
- return (scan, metadata(before, matches: after), after)
- }
-
- private static func incrementalCodexAppend(
- at path: URL,
- cached: CodexCachedSession
- ) -> CodexCachedSession? {
- guard let tail = scanCodexSessionTail(
- at: path,
- fromOffset: cached.size,
- cursor: cached.cursor
- ) else {
- return nil
- }
-
- var records = cached.records
- var diagnostics = cached.diagnostics
- var cursor = cached.cursor
- diagnostics.rawRecords += tail.events.count
- var seenRequestIDs = Set(records.compactMap(\.requestID))
- let scan = CodexSessionScan(
- canonicalSessionID: cached.sessionID,
- createdAt: cached.createdAtEpoch.map { isoFormatter.string(from: Date(timeIntervalSince1970: $0)) },
- parentSessionID: cached.parentSessionID,
- sourcePath: cached.path,
- events: tail.events,
- finalModel: tail.currentModel,
- relevantLineCount: tail.relevantLineNumber
- )
-
- if cursor.hasCumulativeSchema {
- var previous = cursor.previousCumulative
- var epoch = cursor.epoch
- for index in tail.events.indices {
- let event = tail.events[index]
- guard event.cumulativePresent else {
- diagnostics.skippedRecords += 1
- continue
- }
- guard let current = event.cumulative,
- current.totalTokens > 0,
- let day = dayString(for: event)
- else {
- diagnostics.skippedRecords += 1
- continue
- }
-
- let deltaTotal: Int
- let isReset: Bool
- if let previous {
- if current.totalTokens == previous.totalTokens {
- diagnostics.duplicateRecords += 1
- continue
- }
- if current.totalTokens > previous.totalTokens {
- deltaTotal = current.totalTokens - previous.totalTokens
- isReset = false
- } else if isCodexContextWindowSentinel(event) {
- diagnostics.skippedRecords += 1
- continue
- } else if isCredibleCodexReset(
- at: index,
- events: tail.events,
- current: current,
- previous: previous
- ) {
- epoch += 1
- diagnostics.counterResets += 1
- deltaTotal = current.totalTokens
- isReset = true
- } else {
- // Re-read the complete session so an ambiguous reset can be
- // reconsidered when a following cumulative event arrives.
- return nil
- }
- } else {
- deltaTotal = current.totalTokens
- isReset = false
- }
-
- guard deltaTotal > 0 else { continue }
- let componentResult = codexIncrementUsage(
- current: current,
- previous: isReset ? nil : previous,
- last: event.last,
- total: deltaTotal
- )
- let requestID = "codex:cumulative:\(cached.sessionID):\(epoch):\(current.totalTokens)"
- guard seenRequestIDs.insert(requestID).inserted else {
- diagnostics.duplicateRecords += 1
- previous = current
- continue
- }
- records.append(
- codexUsageRecord(
- scan: scan,
- event: event,
- day: day,
- usage: componentResult.usage,
- requestID: requestID,
- dataSource: componentResult.hasKnownBreakdown
- ? "codex_total_usage_delta"
- : "codex_total_usage_delta_unknown_breakdown"
- )
- )
- diagnostics.exactRecords += 1
- if !componentResult.hasKnownBreakdown {
- diagnostics.unknownBreakdownRecords += 1
- }
- previous = current
- }
- cursor.previousCumulative = previous
- cursor.epoch = epoch
- } else {
- guard !tail.events.contains(where: \.cumulativePresent) else {
- return nil
- }
- for event in tail.events {
- guard let usage = event.last,
- usage.totalTokens > 0,
- let timestamp = event.timestamp,
- let day = dayString(for: event)
- else {
- diagnostics.skippedRecords += 1
- continue
- }
- let requestID = "codex:legacy:\(cached.sessionID):\(timestamp):\(usage.fingerprint)"
- guard seenRequestIDs.insert(requestID).inserted else {
- diagnostics.duplicateRecords += 1
- continue
- }
- records.append(
- codexUsageRecord(
- scan: scan,
- event: event,
- day: day,
- usage: usage,
- requestID: requestID,
- dataSource: "codex_last_usage_legacy_estimate"
- )
- )
- diagnostics.legacyRecords += 1
- if !isCodexBreakdownConsistent(usage, total: usage.totalTokens) {
- diagnostics.unknownBreakdownRecords += 1
- }
- }
- }
-
- cursor.currentModel = tail.currentModel
- cursor.relevantLineNumber = tail.relevantLineNumber
- return CodexCachedSession(
- path: cached.path,
- size: tail.processedSize,
- modificationTime: tail.modificationTime,
- fingerprint: tail.fingerprint,
- validationFingerprint: nil,
- sessionID: cached.sessionID,
- createdAtEpoch: cached.createdAtEpoch,
- parentSessionID: cached.parentSessionID,
- anchors: (cached.anchors + codexAnchors(for: scan))
- .sorted { $0.timestamp < $1.timestamp },
- records: records,
- summaryRecords: summarizeCodexRecords(records),
- cursor: cursor,
- diagnostics: diagnostics
- )
- }
-
- private static func scanCodexSessionTail(
- at path: URL,
- fromOffset offset: UInt64,
- cursor: CodexSessionCursor
- ) -> CodexSessionTail? {
- guard let metadata = fileMetadata(for: path), metadata.size > offset else { return nil }
-
- do {
- if offset > 0 {
- let handle = try FileHandle(forReadingFrom: path)
- defer { try? handle.close() }
- try handle.seek(toOffset: offset - 1)
- guard try handle.read(upToCount: 1)?.first == 0x0A else { return nil }
- }
-
- var currentModel = cursor.currentModel
- var relevantLineNumber = cursor.relevantLineNumber
- var events = [CodexTokenEvent]()
- var encounteredSessionMetadata = false
- let processedSize = try forEachCompleteLine(
- in: path,
- fromOffset: offset,
- matchingAny: ["session_meta", "turn_context", "token_count"]
- ) { line in
- autoreleasepool {
- relevantLineNumber += 1
- guard line.utf8.count <= maxRelevantLineBytes,
- let obj = jsonObject(line)
- else { return }
- let type = obj["type"] as? String
- let payload = obj["payload"] as? [String: Any]
- if type == "session_meta" {
- encounteredSessionMetadata = true
- return
- }
- if type == "turn_context" {
- currentModel = modelKey(payload?["model"] as? String ?? currentModel)
- }
- guard type == "event_msg",
- payload?["type"] as? String == "token_count",
- let info = payload?["info"] as? [String: Any]
- else { return }
- let timestamp = nonEmptyString(obj["timestamp"] as? String)
- events.append(
- CodexTokenEvent(
- timestamp: timestamp,
- timestampEpoch: timestamp.flatMap(parseISO)?.timeIntervalSince1970,
- model: currentModel,
- cumulativePresent: info.keys.contains("total_token_usage"),
- cumulative: (info["total_token_usage"] as? [String: Any]).map(normalizeCodexUsage),
- last: (info["last_token_usage"] as? [String: Any]).map(normalizeCodexUsage),
- modelContextWindow: integerValue(info["model_context_window"] as Any),
- lineNumber: relevantLineNumber
- )
- )
- }
- }
-
- guard processedSize > offset, !encounteredSessionMetadata else { return nil }
- guard let finalMetadata = fileMetadata(for: path),
- let fingerprint = contentFingerprint(for: path, size: processedSize)
- else { return nil }
- return CodexSessionTail(
- events: events,
- currentModel: currentModel,
- relevantLineNumber: relevantLineNumber,
- processedSize: processedSize,
- modificationTime: finalMetadata.modificationTime,
- fingerprint: fingerprint
- )
- } catch {
- return nil
- }
- }
-
- private static func scanCodexSessionFile(at path: URL) -> CodexSessionScan? {
- guard FileManager.default.isReadableFile(atPath: path.path) else { return nil }
- var canonicalSessionID: String?
- var createdAt: String?
- var parentSessionID: String?
- var currentModel = "unknown"
- var events: [CodexTokenEvent] = []
- var relevantLineNumber = 0
-
- do {
- try forEachLine(in: path, matchingAny: ["session_meta", "turn_context", "token_count"]) { line in
- autoreleasepool {
- relevantLineNumber += 1
- guard let obj = jsonObject(line) else { return }
- let type = obj["type"] as? String
- let payload = obj["payload"] as? [String: Any]
-
- if type == "session_meta", canonicalSessionID == nil,
- let id = nonEmptyString(payload?["id"] as? String) {
- canonicalSessionID = id
- createdAt = nonEmptyString(obj["timestamp"] as? String)
- ?? nonEmptyString(payload?["timestamp"] as? String)
- parentSessionID = codexParentSessionID(from: payload)
- }
- if type == "turn_context" {
- currentModel = modelKey(payload?["model"] as? String ?? currentModel)
- }
- guard type == "event_msg",
- payload?["type"] as? String == "token_count",
- let info = payload?["info"] as? [String: Any]
- else {
- return
- }
-
- let timestamp = nonEmptyString(obj["timestamp"] as? String)
- let cumulativePresent = info.keys.contains("total_token_usage")
- let cumulative = (info["total_token_usage"] as? [String: Any]).map(normalizeCodexUsage)
- let last = (info["last_token_usage"] as? [String: Any]).map(normalizeCodexUsage)
- events.append(
- CodexTokenEvent(
- timestamp: timestamp,
- timestampEpoch: timestamp.flatMap(parseISO)?.timeIntervalSince1970,
- model: currentModel,
- cumulativePresent: cumulativePresent,
- cumulative: cumulative,
- last: last,
- modelContextWindow: integerValue(info["model_context_window"] as Any),
- lineNumber: relevantLineNumber
- )
- )
- }
- }
- } catch {
- return nil
- }
-
- return CodexSessionScan(
- canonicalSessionID: canonicalSessionID ?? path.deletingPathExtension().lastPathComponent,
- createdAt: createdAt,
- parentSessionID: parentSessionID,
- sourcePath: path.path,
- events: events,
- finalModel: currentModel,
- relevantLineCount: relevantLineNumber
- )
- }
-
- private static func codexParentSessionID(from payload: [String: Any]?) -> String? {
- if let source = payload?["source"] as? [String: Any],
- let subagent = source["subagent"] as? [String: Any],
- let threadSpawn = subagent["thread_spawn"] as? [String: Any],
- let parent = nonEmptyString(threadSpawn["parent_thread_id"] as? String) {
- return parent
- }
- return [
- payload?["parent_thread_id"] as? String,
- payload?["forked_from_id"] as? String
- ].compactMap(nonEmptyString).first
- }
-
- private static func codexForkAnchor(
- for scan: CodexSessionScan,
- anchorsBySessionID: [String: [CodexAnchor]]
- ) -> TokenUsageCounts? {
- guard let parentID = scan.parentSessionID,
- let anchors = anchorsBySessionID[parentID],
- let childCreatedAt = scan.createdAt.flatMap(parseISO)?.timeIntervalSince1970
- else {
- return nil
- }
- return codexAnchor(atOrBefore: childCreatedAt, anchors: anchors)
- }
-
- private static func codexAnchors(for scan: CodexSessionScan) -> [CodexAnchor] {
- scan.events.compactMap { event in
- guard event.cumulativePresent,
- let usage = event.cumulative,
- usage.totalTokens > 0,
- let timestamp = event.timestampEpoch
- ?? event.timestamp.flatMap(parseISO)?.timeIntervalSince1970
- else {
- return nil
- }
- return CodexAnchor(timestamp: timestamp, usage: usage)
- }.sorted { $0.timestamp < $1.timestamp }
- }
-
- private static func codexAnchor(
- atOrBefore timestamp: TimeInterval,
- anchors: [CodexAnchor]
- ) -> TokenUsageCounts? {
- var lower = 0
- var upper = anchors.count
- while lower < upper {
- let middle = lower + (upper - lower) / 2
- if anchors[middle].timestamp <= timestamp {
- lower = middle + 1
- } else {
- upper = middle
- }
- }
- guard lower > 0 else { return nil }
- return anchors[lower - 1].usage
- }
-
- private static func codexDeltaRecords(
- from scan: CodexSessionScan,
- parentAnchor: TokenUsageCounts?,
- seenRequestIDs: inout Set
- ) -> (
- records: [UsageRecord],
- diagnostics: CodexCollectionDiagnostics,
- cursor: CodexDeltaCursor
- ) {
- var diagnostics = CodexCollectionDiagnostics(rawRecords: scan.events.count)
- var records: [UsageRecord] = []
- let hasCumulativeSchema = scan.events.contains { $0.cumulativePresent }
-
- if !hasCumulativeSchema {
- for event in scan.events {
- guard let usage = event.last,
- usage.totalTokens > 0,
- let timestamp = event.timestamp,
- let day = dayString(for: event)
- else {
- diagnostics.skippedRecords += 1
- continue
- }
- let requestID = "codex:legacy:\(scan.canonicalSessionID):\(timestamp):\(usage.fingerprint)"
- guard seenRequestIDs.insert(requestID).inserted else {
- diagnostics.duplicateRecords += 1
- continue
- }
- records.append(
- codexUsageRecord(
- scan: scan,
- event: event,
- day: day,
- usage: usage,
- requestID: requestID,
- dataSource: "codex_last_usage_legacy_estimate"
- )
- )
- diagnostics.legacyRecords += 1
- if !isCodexBreakdownConsistent(usage, total: usage.totalTokens) {
- diagnostics.unknownBreakdownRecords += 1
- }
- }
- return (
- records,
- diagnostics,
- CodexDeltaCursor(
- hasCumulativeSchema: false,
- previousCumulative: nil,
- epoch: 0
- )
- )
- }
-
- var startIndex = 0
- var previous: TokenUsageCounts?
- if let parentAnchor,
- parentAnchor.totalTokens > 0,
- let anchorIndex = scan.events.firstIndex(where: {
- $0.cumulativePresent && $0.cumulative == parentAnchor
- }) {
- previous = parentAnchor
- startIndex = anchorIndex + 1
- diagnostics.inheritedRecords = scan.events[...anchorIndex].filter(\.cumulativePresent).count
- diagnostics.inheritedTokens = parentAnchor.totalTokens
- }
-
- var epoch = 0
- for index in startIndex.. 0,
- let day = dayString(for: event)
- else {
- diagnostics.skippedRecords += 1
- continue
- }
-
- let deltaTotal: Int
- let isReset: Bool
- if let previous {
- if current.totalTokens == previous.totalTokens {
- diagnostics.duplicateRecords += 1
- continue
- }
- if current.totalTokens > previous.totalTokens {
- deltaTotal = current.totalTokens - previous.totalTokens
- isReset = false
- } else if isCodexContextWindowSentinel(event) {
- diagnostics.skippedRecords += 1
- continue
- } else if isCredibleCodexReset(
- at: index,
- events: scan.events,
- current: current,
- previous: previous
- ) {
- epoch += 1
- diagnostics.counterResets += 1
- deltaTotal = current.totalTokens
- isReset = true
- } else {
- diagnostics.skippedRecords += 1
- continue
- }
- } else {
- deltaTotal = current.totalTokens
- isReset = false
- }
-
- guard deltaTotal > 0 else { continue }
- let componentResult = codexIncrementUsage(
- current: current,
- previous: isReset ? nil : previous,
- last: event.last,
- total: deltaTotal
- )
- let requestID = "codex:cumulative:\(scan.canonicalSessionID):\(epoch):\(current.totalTokens)"
- guard seenRequestIDs.insert(requestID).inserted else {
- diagnostics.duplicateRecords += 1
- previous = current
- continue
- }
- records.append(
- codexUsageRecord(
- scan: scan,
- event: event,
- day: day,
- usage: componentResult.usage,
- requestID: requestID,
- dataSource: componentResult.hasKnownBreakdown
- ? "codex_total_usage_delta"
- : "codex_total_usage_delta_unknown_breakdown"
- )
- )
- diagnostics.exactRecords += 1
- if !componentResult.hasKnownBreakdown {
- diagnostics.unknownBreakdownRecords += 1
- }
- previous = current
- }
- return (
- records,
- diagnostics,
- CodexDeltaCursor(
- hasCumulativeSchema: true,
- previousCumulative: previous,
- epoch: epoch
- )
- )
- }
-
- private static func codexUsageRecord(
- scan: CodexSessionScan,
- event: CodexTokenEvent,
- day: String,
- usage: TokenUsageCounts,
- requestID: String,
- dataSource: String
- ) -> UsageRecord {
- UsageRecord(
- date: day,
- timestamp: event.timestamp,
- timestampEpoch: event.timestampEpoch,
- tool: "Codex",
- model: event.model,
- usage: usage,
- source: .nativeCodex,
- requestID: requestID,
- sessionID: scan.canonicalSessionID,
- sourcePath: scan.sourcePath,
- lineNumber: event.lineNumber,
- dataSource: dataSource
- )
- }
-
- private static func codexIncrementUsage(
- current: TokenUsageCounts,
- previous: TokenUsageCounts?,
- last: TokenUsageCounts?,
- total: Int
- ) -> (usage: TokenUsageCounts, hasKnownBreakdown: Bool) {
- if let last,
- last.totalTokens == total,
- isCodexBreakdownConsistent(last, total: total) {
- var result = last
- result.totalTokens = total
- return (result, true)
- }
-
- let previous = previous ?? TokenUsageCounts()
- guard current.inputTokens >= previous.inputTokens,
- current.outputTokens >= previous.outputTokens,
- current.cacheCreationInputTokens >= previous.cacheCreationInputTokens,
- current.cacheReadInputTokens >= previous.cacheReadInputTokens,
- current.reasoningOutputTokens >= previous.reasoningOutputTokens
- else {
- return (TokenUsageCounts(totalTokens: total), false)
- }
- var result = TokenUsageCounts(
- inputTokens: current.inputTokens - previous.inputTokens,
- outputTokens: current.outputTokens - previous.outputTokens,
- cacheCreationInputTokens: current.cacheCreationInputTokens - previous.cacheCreationInputTokens,
- cacheReadInputTokens: current.cacheReadInputTokens - previous.cacheReadInputTokens,
- reasoningOutputTokens: current.reasoningOutputTokens - previous.reasoningOutputTokens,
- totalTokens: total
- )
- guard isCodexBreakdownConsistent(result, total: total) else {
- result = TokenUsageCounts(totalTokens: total)
- return (result, false)
- }
- return (result, true)
- }
-
- private static func isCodexBreakdownConsistent(_ usage: TokenUsageCounts, total: Int) -> Bool {
- usage.inputTokens >= 0
- && usage.outputTokens >= 0
- && usage.cacheCreationInputTokens >= 0
- && usage.cacheReadInputTokens >= 0
- && usage.reasoningOutputTokens >= 0
- && usage.inputTokens + usage.outputTokens == total
- && usage.cacheCreationInputTokens + usage.cacheReadInputTokens <= usage.inputTokens
- && usage.reasoningOutputTokens <= usage.outputTokens
- }
-
- private static func isCodexContextWindowSentinel(_ event: CodexTokenEvent) -> Bool {
- guard let current = event.cumulative else { return false }
- return current.inputTokens == 0
- && current.outputTokens == 0
- && current.cacheCreationInputTokens == 0
- && current.cacheReadInputTokens == 0
- && current.reasoningOutputTokens == 0
- && (event.last?.totalTokens ?? 0) == 0
- && event.modelContextWindow > 0
- && current.totalTokens == event.modelContextWindow
- }
-
- private static func isCredibleCodexReset(
- at index: Int,
- events: [CodexTokenEvent],
- current: TokenUsageCounts,
- previous: TokenUsageCounts
- ) -> Bool {
- if let last = events[index].last,
- last.totalTokens == current.totalTokens,
- isCodexBreakdownConsistent(last, total: current.totalTokens) {
- return true
- }
- for candidate in events.dropFirst(index + 1) where candidate.cumulativePresent {
- guard let next = candidate.cumulative, next.totalTokens > 0 else { continue }
- if next.totalTokens == current.totalTokens { continue }
- return next.totalTokens > current.totalTokens && next.totalTokens < previous.totalTokens
- }
- return false
- }
-
- private static func defaultCodexSessionRoots(homeURL: URL) -> [URL] {
- // archived_sessions may contain restored historical logs with rewritten timestamps.
- // Only live Codex sessions should count as current usage.
- [
- homeURL.appendingPathComponent(".codex/sessions", isDirectory: true)
- ]
- }
-
- private static func collectClaudeCode(
- cache: inout CollectorCache,
- livePaths: inout Set,
- rootURL: URL = FileManager.default.homeDirectoryForCurrentUser
- .appendingPathComponent(".claude/projects", isDirectory: true),
- modifiedSince cutoffDate: Date?
- ) -> CollectorResult {
- let root = rootURL
- let paths = jsonlFiles(under: root, modifiedSince: cutoffDate)
- var records: [UsageRecord] = []
-
- for path in paths.sorted(by: { $0.path < $1.path }) {
- livePaths.insert(path.path)
- if let cached = cachedRecords(for: path, tool: "Claude Code", cache: cache) {
- records.append(contentsOf: cached)
- continue
- }
-
- var fileRecords: [UsageRecord] = []
- var responses = [String: ClaudeUsageCandidate]()
- guard FileManager.default.isReadableFile(atPath: path.path) else { continue }
-
- var lineNumber = 0
-
- try? forEachLine(in: path, matchingAny: ["usage"]) { line in
- autoreleasepool {
- lineNumber += 1
- guard let obj = jsonObject(line),
- obj["type"] as? String == "assistant",
- let message = obj["message"] as? [String: Any]
- else {
- return
- }
-
- let usage = normalizeUsage(message["usage"] as? [String: Any])
- guard usage.totalTokens > 0,
- let timestamp = obj["timestamp"] as? String,
- let day = dayString(fromISO: timestamp)
- else {
- return
- }
-
- let identity = claudeIdentity(obj: obj, message: message, path: path, lineNumber: lineNumber)
- let candidate = ClaudeUsageCandidate(
- date: day,
- timestamp: timestamp,
- model: modelKey(message["model"] as? String),
- usage: usage,
- hasStopReason: hasStopReason(message["stop_reason"]),
- lineNumber: lineNumber,
- requestID: identity.requestID,
- responseID: identity.responseID,
- sessionID: identity.sessionID,
- sourcePath: path.path
- )
- if let existing = responses[identity.deduplicationKey],
- !candidate.isPreferred(over: existing) {
- return
- }
- responses[identity.deduplicationKey] = candidate
- }
- }
- fileRecords = responses.values.map(\.record)
- records.append(contentsOf: fileRecords)
- updateCache(path: path, tool: "Claude Code", records: fileRecords, cache: &cache)
- }
-
- return CollectorResult(
- records: records,
- source: SourceInfo(
- status: records.isEmpty ? "missing" : "ok",
- files: paths.count,
- records: records.count
- )
- )
- }
-
- private static func collectCCSwitchProxyUsage(databaseURL: URL? = nil) -> CollectorResult {
- let database = databaseURL ?? FileManager.default.homeDirectoryForCurrentUser
- .appendingPathComponent(".cc-switch/cc-switch.db")
-
- guard FileManager.default.fileExists(atPath: database.path) else {
- return CollectorResult(
- records: [],
- source: SourceInfo(status: "missing_db", files: 0, records: 0)
- )
- }
-
- guard FileManager.default.isReadableFile(atPath: database.path) else {
- return CollectorResult(
- records: [],
- source: SourceInfo(status: "unreadable_db", files: 1, records: 0)
- )
- }
-
- guard let columns = sqliteJSONRows(
- database: database,
- query: "pragma table_info(proxy_request_logs)"
- ) else {
- return CollectorResult(
- records: [],
- source: SourceInfo(status: "schema_unreadable", files: 1, records: 0)
- )
- }
-
- guard !columns.isEmpty else {
- return CollectorResult(
- records: [],
- source: SourceInfo(status: "missing_table", files: 1, records: 0)
- )
- }
-
- let availableColumns = Set(columns.compactMap { $0["name"] as? String })
- let requiredColumns: Set = [
- "request_id",
- "app_type",
- "provider_id",
- "model",
- "request_model",
- "pricing_model",
- "input_tokens",
- "output_tokens",
- "cache_read_tokens",
- "cache_creation_tokens",
- "total_cost_usd",
- "status_code",
- "created_at"
- ]
- guard requiredColumns.isSubset(of: availableColumns) else {
- return CollectorResult(
- records: [],
- source: SourceInfo(status: "schema_mismatch", files: 1, records: 0)
- )
- }
- guard availableColumns.contains("data_source") else {
- return CollectorResult(
- records: [],
- source: SourceInfo(status: "schema_missing_data_source", files: 1, records: 0)
- )
- }
-
- let sessionColumn = availableColumns.contains("session_id") ? "session_id" : "null"
- let inputSemanticsColumn = availableColumns.contains("input_token_semantics")
- ? "coalesce(input_token_semantics, 0)"
- : "0"
- let query = """
- select
- request_id,
- \(sessionColumn) as session_id,
- data_source,
- created_at,
- app_type,
- coalesce(nullif(pricing_model, ''), nullif(model, ''), nullif(request_model, ''), 'unknown') as display_model,
- coalesce(input_tokens, 0) as input_tokens,
- coalesce(output_tokens, 0) as output_tokens,
- coalesce(cache_read_tokens, 0) as cache_read_tokens,
- coalesce(cache_creation_tokens, 0) as cache_creation_tokens,
- \(inputSemanticsColumn) as input_token_semantics,
- cast(coalesce(nullif(total_cost_usd, ''), '0') as real) as total_cost_usd
- from proxy_request_logs
- where status_code >= 200
- and status_code < 300
- and lower(data_source) = 'proxy'
- and (
- coalesce(input_tokens, 0)
- + coalesce(output_tokens, 0)
- + coalesce(cache_read_tokens, 0)
- + coalesce(cache_creation_tokens, 0)
- ) > 0
- order by created_at, request_id
- """
-
- guard let rows = sqliteJSONRows(database: database, query: query) else {
- return CollectorResult(
- records: [],
- source: SourceInfo(status: "query_failed", files: 1, records: 0)
- )
- }
-
- let records = rows.compactMap { row -> UsageRecord? in
- guard let day = dayString(fromEpoch: row["created_at"] as Any) else {
- return nil
- }
-
- let appType = row["app_type"] as? String
- let rawInputTokens = integerValue(row["input_tokens"] as Any)
- let cacheReadTokens = integerValue(row["cache_read_tokens"] as Any)
- let cacheCreationTokens = integerValue(row["cache_creation_tokens"] as Any)
- let freshInputTokens = ccSwitchFreshInputTokens(
- rawInputTokens: rawInputTokens,
- cacheReadTokens: cacheReadTokens,
- cacheCreationTokens: cacheCreationTokens,
- appType: appType,
- inputTokenSemantics: integerValue(row["input_token_semantics"] as Any)
- )
- let usage = canonicalUsageCounts(
- rawInputTokens: freshInputTokens,
- outputTokens: integerValue(row["output_tokens"] as Any),
- cacheCreationInputTokens: cacheCreationTokens,
- cacheReadInputTokens: cacheReadTokens,
- inputIncludesCachedTokens: false
- )
- guard usage.totalTokens > 0 else { return nil }
-
- return UsageRecord(
- date: day,
- timestamp: isoString(fromEpoch: row["created_at"] as Any),
- tool: ccSwitchToolName(appType: appType),
- model: modelKey(row["display_model"] as? String),
- usage: usage,
- costUSD: doubleValue(row["total_cost_usd"] as Any),
- source: .ccSwitchProxy,
- requestID: nonEmptyString(row["request_id"] as? String),
- sessionID: nonEmptyString(row["session_id"] as? String),
- dataSource: nonEmptyString(row["data_source"] as? String)
- )
- }
-
- return CollectorResult(
- records: records,
- source: SourceInfo(
- status: records.isEmpty ? "missing_valid_rows" : "ok",
- files: 1,
- records: records.count
- )
- )
- }
-
- private static func collectZCodeUsage(databaseURL: URL? = nil) -> CollectorResult {
- let database = databaseURL ?? FileManager.default.homeDirectoryForCurrentUser
- .appendingPathComponent(".zcode/cli/db/db.sqlite")
-
- guard FileManager.default.fileExists(atPath: database.path) else {
- return CollectorResult(records: [], source: SourceInfo(status: "missing_db", files: 0, records: 0))
- }
- guard FileManager.default.isReadableFile(atPath: database.path) else {
- return CollectorResult(records: [], source: SourceInfo(status: "unreadable_db", files: 1, records: 0))
- }
- guard let columns = sqliteJSONRows(database: database, query: "pragma table_info(model_usage)") else {
- return CollectorResult(records: [], source: SourceInfo(status: "schema_unreadable", files: 1, records: 0))
- }
- guard !columns.isEmpty else {
- return CollectorResult(records: [], source: SourceInfo(status: "missing_table", files: 1, records: 0))
- }
-
- let availableColumns = Set(columns.compactMap { $0["name"] as? String })
- let requiredColumns: Set = [
- "id",
- "session_id",
- "status",
- "started_at",
- "model_id",
- "input_tokens",
- "output_tokens",
- "reasoning_tokens",
- "cache_creation_input_tokens",
- "cache_read_input_tokens",
- "computed_total_tokens",
- "tool_call_count"
- ]
- guard requiredColumns.isSubset(of: availableColumns) else {
- return CollectorResult(records: [], source: SourceInfo(status: "schema_mismatch", files: 1, records: 0))
- }
-
- let providerTotalExpression = availableColumns.contains("provider_total_tokens")
- ? "coalesce(provider_total_tokens, 0)"
- : "0"
- let query = """
- select
- id,
- session_id,
- started_at,
- coalesce(nullif(model_id, ''), 'unknown') as display_model,
- coalesce(input_tokens, 0) as input_tokens,
- coalesce(output_tokens, 0) as output_tokens,
- coalesce(reasoning_tokens, 0) as reasoning_tokens,
- coalesce(cache_creation_input_tokens, 0) as cache_creation_input_tokens,
- coalesce(cache_read_input_tokens, 0) as cache_read_input_tokens,
- coalesce(computed_total_tokens, 0) as computed_total_tokens,
- \(providerTotalExpression) as provider_total_tokens,
- coalesce(tool_call_count, 0) as tool_call_count
- from model_usage
- where status = 'completed'
- and (
- coalesce(computed_total_tokens, 0) > 0
- or \(providerTotalExpression) > 0
- or (
- coalesce(input_tokens, 0)
- + coalesce(output_tokens, 0)
- + coalesce(reasoning_tokens, 0)
- + coalesce(cache_creation_input_tokens, 0)
- + coalesce(cache_read_input_tokens, 0)
- ) > 0
- )
- order by started_at, id
- """
-
- guard let rows = sqliteJSONRows(database: database, query: query) else {
- return CollectorResult(records: [], source: SourceInfo(status: "query_failed", files: 1, records: 0))
- }
-
- let records = rows.compactMap { row -> UsageRecord? in
- guard let day = dayString(fromEpoch: row["started_at"] as Any) else { return nil }
- let computedTotal = integerValue(row["computed_total_tokens"] as Any)
- let providerTotal = integerValue(row["provider_total_tokens"] as Any)
- let usage = canonicalUsageCounts(
- rawInputTokens: integerValue(row["input_tokens"] as Any),
- outputTokens: integerValue(row["output_tokens"] as Any),
- cacheCreationInputTokens: integerValue(row["cache_creation_input_tokens"] as Any),
- cacheReadInputTokens: integerValue(row["cache_read_input_tokens"] as Any),
- reasoningOutputTokens: integerValue(row["reasoning_tokens"] as Any),
- inputIncludesCachedTokens: true,
- explicitTotalTokens: computedTotal > 0 ? computedTotal : providerTotal
- )
- guard usage.totalTokens > 0 else { return nil }
-
- return UsageRecord(
- date: day,
- timestamp: isoString(fromEpoch: row["started_at"] as Any),
- tool: "ZCode",
- model: modelKey(row["display_model"] as? String),
- usage: usage,
- source: .zcode,
- requestID: nonEmptyString(row["id"] as? String),
- sessionID: nonEmptyString(row["session_id"] as? String),
- modelRequestCount: 1,
- toolCallCount: integerValue(row["tool_call_count"] as Any)
- )
- }
-
- return CollectorResult(
- records: records,
- source: SourceInfo(status: records.isEmpty ? "missing_valid_rows" : "ok", files: 1, records: records.count)
- )
- }
-
- private static func collectHermesUsage(databaseURL: URL? = nil) -> CollectorResult {
- let database = databaseURL ?? FileManager.default.homeDirectoryForCurrentUser
- .appendingPathComponent(".hermes/state.db")
-
- guard FileManager.default.fileExists(atPath: database.path) else {
- return CollectorResult(records: [], source: SourceInfo(status: "missing_db", files: 0, records: 0))
- }
- guard FileManager.default.isReadableFile(atPath: database.path) else {
- return CollectorResult(records: [], source: SourceInfo(status: "unreadable_db", files: 1, records: 0))
- }
- guard let columns = sqliteJSONRows(database: database, query: "pragma table_info(sessions)") else {
- return CollectorResult(records: [], source: SourceInfo(status: "schema_unreadable", files: 1, records: 0))
- }
- guard !columns.isEmpty else {
- return CollectorResult(records: [], source: SourceInfo(status: "missing_table", files: 1, records: 0))
- }
-
- let availableColumns = Set(columns.compactMap { $0["name"] as? String })
- let requiredColumns: Set = [
- "id",
- "source",
- "model",
- "started_at",
- "input_tokens",
- "output_tokens",
- "cache_read_tokens",
- "cache_write_tokens",
- "reasoning_tokens",
- "tool_call_count",
- "api_call_count",
- "actual_cost_usd",
- "estimated_cost_usd",
- "cost_status"
- ]
- guard requiredColumns.isSubset(of: availableColumns) else {
- return CollectorResult(records: [], source: SourceInfo(status: "schema_mismatch", files: 1, records: 0))
- }
-
- let query = """
- select
- id,
- source,
- model,
- started_at,
- coalesce(input_tokens, 0) as input_tokens,
- coalesce(output_tokens, 0) as output_tokens,
- coalesce(cache_read_tokens, 0) as cache_read_tokens,
- coalesce(cache_write_tokens, 0) as cache_write_tokens,
- coalesce(reasoning_tokens, 0) as reasoning_tokens,
- coalesce(tool_call_count, 0) as tool_call_count,
- coalesce(api_call_count, 0) as api_call_count,
- coalesce(actual_cost_usd, 0) as actual_cost_usd,
- coalesce(estimated_cost_usd, 0) as estimated_cost_usd,
- coalesce(cost_status, '') as cost_status
- from sessions
- where (
- coalesce(input_tokens, 0)
- + coalesce(output_tokens, 0)
- + coalesce(cache_read_tokens, 0)
- + coalesce(cache_write_tokens, 0)
- + coalesce(reasoning_tokens, 0)
- ) > 0
- order by started_at, id
- """
-
- guard let rows = sqliteJSONRows(database: database, query: query) else {
- return CollectorResult(records: [], source: SourceInfo(status: "query_failed", files: 1, records: 0))
- }
-
- let records = rows.compactMap { row -> UsageRecord? in
- guard let day = dayString(fromEpoch: row["started_at"] as Any) else { return nil }
- let usage = canonicalUsageCounts(
- rawInputTokens: integerValue(row["input_tokens"] as Any),
- outputTokens: integerValue(row["output_tokens"] as Any),
- cacheCreationInputTokens: integerValue(row["cache_write_tokens"] as Any),
- cacheReadInputTokens: integerValue(row["cache_read_tokens"] as Any),
- reasoningOutputTokens: integerValue(row["reasoning_tokens"] as Any),
- inputIncludesCachedTokens: false
- )
- guard usage.totalTokens > 0 else { return nil }
-
- let actualCost = doubleValue(row["actual_cost_usd"] as Any)
- let estimatedCost = doubleValue(row["estimated_cost_usd"] as Any)
- let cost: Double?
- if actualCost > 0 {
- cost = actualCost
- } else if estimatedCost > 0 {
- cost = estimatedCost
- } else {
- cost = nil
- }
- let requestCount = integerValue(row["api_call_count"] as Any)
-
- return UsageRecord(
- date: day,
- timestamp: isoString(fromEpoch: row["started_at"] as Any),
- tool: "Hermes Agent",
- model: modelKey(row["model"] as? String),
- usage: usage,
- costUSD: cost,
- source: .hermes,
- requestID: nonEmptyString(row["id"] as? String),
- sessionID: nonEmptyString(row["id"] as? String),
- dataSource: nonEmptyString(row["source"] as? String),
- modelRequestCount: requestCount,
- toolCallCount: integerValue(row["tool_call_count"] as Any)
- )
- }
-
- return CollectorResult(
- records: records,
- source: SourceInfo(status: records.isEmpty ? "missing_valid_rows" : "ok", files: 1, records: records.count)
- )
- }
-
- private static func collectWorkBuddyUsage(
- rootURLs: [URL]? = nil,
- modifiedSince cutoffDate: Date?
- ) -> CollectorResult {
- let home = FileManager.default.homeDirectoryForCurrentUser
- let roots = rootURLs ?? [
- home.appendingPathComponent(".workbuddy/projects", isDirectory: true),
- home.appendingPathComponent("Library/Application Support/WorkBuddyExtension", isDirectory: true)
- ]
- let discoveredRoots = roots.filter { FileManager.default.fileExists(atPath: $0.path) }
- let files = discoveredRoots.flatMap { jsonlFiles(under: $0, modifiedSince: cutoffDate) }
- var records: [UsageRecord] = []
-
- for file in files {
- var lineNumber = 0
- try? forEachLine(in: file, matchingAny: ["\"usage\"", "\"rawUsage\""]) { line in
- lineNumber += 1
- guard let data = line.data(using: .utf8),
- let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
- let timestamp = object["timestamp"],
- let day = dayString(fromEpoch: timestamp),
- let usage = workBuddyUsage(from: object),
- usage.totalTokens > 0
- else {
- return
- }
-
- let providerData = object["providerData"] as? [String: Any]
- let recordType = object["type"] as? String
- records.append(UsageRecord(
- date: day,
- timestamp: isoString(fromEpoch: timestamp),
- tool: "WorkBuddy",
- model: modelKey(
- providerData?["requestModelId"] as? String
- ?? providerData?["requestModelName"] as? String
- ?? providerData?["model"] as? String
- ),
- usage: usage,
- source: .workbuddy,
- requestID: nonEmptyString(providerData?["conversationRequestId"] as? String),
- sessionID: nonEmptyString(object["sessionId"] as? String),
- sourcePath: file.path,
- lineNumber: lineNumber,
- modelRequestCount: 1,
- toolCallCount: recordType == "function_call" ? 1 : 0
- ))
- }
- }
-
- let status: String
- if discoveredRoots.isEmpty {
- status = "missing"
- } else if files.isEmpty {
- status = "discovered_no_usage"
- } else if records.isEmpty {
- status = "missing_valid_rows"
- } else {
- status = "ok"
- }
- return CollectorResult(
- records: records,
- source: SourceInfo(
- status: status,
- files: files.count,
- records: records.count
- )
- )
- }
-
- private static func workBuddyUsage(from object: [String: Any]) -> TokenUsageCounts? {
- let message = object["message"] as? [String: Any]
- let providerData = object["providerData"] as? [String: Any]
- let usage = message?["usage"] as? [String: Any]
- ?? providerData?["rawUsage"] as? [String: Any]
- ?? providerData?["usage"] as? [String: Any]
- guard let usage else { return nil }
-
- let rawInput = firstIntegerValue(
- in: usage,
- keys: ["input_tokens", "inputTokens", "prompt_tokens"]
- )
- let output = firstIntegerValue(
- in: usage,
- keys: ["output_tokens", "outputTokens", "completion_tokens"]
- )
- let cacheRead = firstIntegerValue(
- in: usage,
- keys: ["cache_read_input_tokens", "cached_tokens", "prompt_cache_hit_tokens"]
- )
- let reasoning = firstIntegerValue(
- in: usage,
- keys: ["reasoning_tokens", "completion_thinking_tokens"]
- )
- let explicitTotal = firstIntegerValue(
- in: usage,
- keys: ["total_tokens", "totalTokens"]
- )
- return canonicalUsageCounts(
- rawInputTokens: rawInput,
- outputTokens: output,
- cacheReadInputTokens: cacheRead,
- reasoningOutputTokens: reasoning,
- inputIncludesCachedTokens: true,
- explicitTotalTokens: explicitTotal,
- explicitTotalIsAuthoritative: true
- )
- }
-
- private static func firstIntegerValue(in object: [String: Any], keys: [String]) -> Int {
- for key in keys where object.keys.contains(key) {
- return max(0, integerValue(object[key] as Any))
- }
- return 0
- }
-
- private static func deduplicateCrossSource(
- nativeRecords: [UsageRecord],
- proxyRecords: [UsageRecord]
- ) -> CrossSourceDedupeResult {
- var enrichedNativeRecords = nativeRecords
- let deduplicableProxyIndices = proxyRecords.indices.filter {
- isDeduplicableProxyRecord(proxyRecords[$0])
- }
- var matchedProxyIndices = Set()
- var matchedNativeIndices = Set()
- let skippedProxyRecords = 0
-
- let exactPairs = uniqueDedupePairs(
- proxyIndices: deduplicableProxyIndices,
- nativeIndices: Array(nativeRecords.indices)
- ) { proxyIndex, nativeIndex in
- isSameDedupeDomain(
- proxyRecord: proxyRecords[proxyIndex],
- nativeRecord: nativeRecords[nativeIndex]
- ) && hasExactIdentifierMatch(
- proxyRecord: proxyRecords[proxyIndex],
- nativeRecord: nativeRecords[nativeIndex]
- )
- }
- applyDedupePairs(
- exactPairs,
- proxyRecords: proxyRecords,
- enrichedNativeRecords: &enrichedNativeRecords,
- matchedProxyIndices: &matchedProxyIndices,
- matchedNativeIndices: &matchedNativeIndices
- )
-
- // Similar timing/model/token vectors alone are not proof of identity: concurrent
- // requests can legitimately look the same. A shared session is the minimum
- // fallback correlation when request/response IDs are unavailable.
- let remainingProxyIndices = deduplicableProxyIndices.filter { !matchedProxyIndices.contains($0) }
- let remainingNativeIndices = nativeRecords.indices.filter { !matchedNativeIndices.contains($0) }
- let sessionPairs = uniqueDedupePairs(
- proxyIndices: remainingProxyIndices,
- nativeIndices: Array(remainingNativeIndices)
- ) { proxyIndex, nativeIndex in
- isSameDedupeDomain(
- proxyRecord: proxyRecords[proxyIndex],
- nativeRecord: nativeRecords[nativeIndex]
- ) && hasSessionIdentityMatch(
- proxyRecord: proxyRecords[proxyIndex],
- nativeRecord: nativeRecords[nativeIndex]
- )
- }
- applyDedupePairs(
- sessionPairs,
- proxyRecords: proxyRecords,
- enrichedNativeRecords: &enrichedNativeRecords,
- matchedProxyIndices: &matchedProxyIndices,
- matchedNativeIndices: &matchedNativeIndices
- )
-
- let keptProxyRecords = proxyRecords.indices
- .filter { !matchedProxyIndices.contains($0) }
- .map { proxyRecords[$0] }
- return CrossSourceDedupeResult(
- records: enrichedNativeRecords + keptProxyRecords,
- rawProxyRecords: proxyRecords.count,
- keptProxyRecords: keptProxyRecords.count,
- dedupedProxyRecords: matchedProxyIndices.count,
- skippedProxyRecords: skippedProxyRecords
- )
- }
-
- private static func uniqueDedupePairs(
- proxyIndices: [Int],
- nativeIndices: [Int],
- matches: (Int, Int) -> Bool
- ) -> [(proxy: Int, native: Int)] {
- var nativeCandidatesByProxy: [Int: [Int]] = [:]
- var proxyCandidateCountByNative: [Int: Int] = [:]
- for proxyIndex in proxyIndices {
- let candidates = nativeIndices.filter { matches(proxyIndex, $0) }
- nativeCandidatesByProxy[proxyIndex] = candidates
- for nativeIndex in candidates {
- proxyCandidateCountByNative[nativeIndex, default: 0] += 1
- }
- }
- return proxyIndices.compactMap { proxyIndex in
- guard let candidates = nativeCandidatesByProxy[proxyIndex],
- candidates.count == 1,
- let nativeIndex = candidates.first,
- proxyCandidateCountByNative[nativeIndex] == 1
- else {
- return nil
- }
- return (proxy: proxyIndex, native: nativeIndex)
- }
- }
-
- private static func applyDedupePairs(
- _ pairs: [(proxy: Int, native: Int)],
- proxyRecords: [UsageRecord],
- enrichedNativeRecords: inout [UsageRecord],
- matchedProxyIndices: inout Set,
- matchedNativeIndices: inout Set
- ) {
- for pair in pairs {
- enrichedNativeRecords[pair.native] = enrichedRecord(
- enrichedNativeRecords[pair.native],
- withProxyCostFrom: proxyRecords[pair.proxy]
- )
- matchedProxyIndices.insert(pair.proxy)
- matchedNativeIndices.insert(pair.native)
- }
- }
-
- private static func sourceInfo(
- _ source: SourceInfo,
- annotatedWith result: CrossSourceDedupeResult
- ) -> SourceInfo {
- var annotated = source
- annotated.rawRecords = result.rawProxyRecords
- annotated.dedupedRecords = result.dedupedProxyRecords
- annotated.skippedRecords = result.skippedProxyRecords
- annotated.strategy = "request_level_dedupe"
- annotated.records = result.keptProxyRecords
- if source.status == "ok",
- result.rawProxyRecords > 0,
- result.keptProxyRecords == 0,
- result.dedupedProxyRecords > 0 {
- annotated.status = "all_deduped"
- }
- return annotated
- }
-
- private static func isDeduplicableProxyRecord(_ record: UsageRecord) -> Bool {
- guard record.source == .ccSwitchProxy else { return false }
- guard let family = toolFamily(for: record.tool) else { return false }
- return family == "claude" || family == "codex"
- }
-
- private static func isSameDedupeDomain(proxyRecord: UsageRecord, nativeRecord: UsageRecord) -> Bool {
- guard proxyRecord.date == nativeRecord.date,
- let proxyFamily = toolFamily(for: proxyRecord.tool),
- let nativeFamily = toolFamily(for: nativeRecord.tool),
- proxyFamily == nativeFamily,
- nativeRecord.source != .ccSwitchProxy
- else {
- return false
- }
- return true
- }
-
- private static func hasExactIdentifierMatch(proxyRecord: UsageRecord, nativeRecord: UsageRecord) -> Bool {
- let proxyIDs = Set([proxyRecord.requestID, proxyRecord.responseID].compactMap(nonEmptyString))
- let nativeIDs = Set([nativeRecord.requestID, nativeRecord.responseID].compactMap(nonEmptyString))
- return !proxyIDs.isDisjoint(with: nativeIDs)
- }
-
- private static func hasSessionIdentityMatch(proxyRecord: UsageRecord, nativeRecord: UsageRecord) -> Bool {
- guard let proxySessionID = nonEmptyString(proxyRecord.sessionID),
- let nativeSessionID = nonEmptyString(nativeRecord.sessionID),
- proxySessionID == nativeSessionID,
- areTimestampsClose(proxyRecord.timestamp, nativeRecord.timestamp, seconds: 10),
- modelsCompatible(proxyRecord.model, nativeRecord.model),
- usageVectorsClose(proxyRecord: proxyRecord, nativeRecord: nativeRecord)
- else {
- return false
- }
- return true
- }
-
- private static func enrichedRecord(
- _ nativeRecord: UsageRecord,
- withProxyCostFrom proxyRecord: UsageRecord
- ) -> UsageRecord {
- var record = nativeRecord
- if record.costUSD == nil,
- let proxyCost = proxyRecord.costUSD,
- proxyCost > 0 {
- record.costUSD = proxyCost
- }
- return record
- }
-
- private static func toolFamily(for tool: String) -> String? {
- let value = tool.lowercased()
- if value.contains("claude") { return "claude" }
- if value.contains("codex") { return "codex" }
- if value.contains("gemini") { return "gemini" }
- return nil
- }
-
- private static func areTimestampsClose(_ lhs: String?, _ rhs: String?, seconds: TimeInterval) -> Bool {
- guard let lhs,
- let rhs,
- let lhsDate = parseISO(lhs),
- let rhsDate = parseISO(rhs)
- else {
- return false
- }
- return abs(lhsDate.timeIntervalSince(rhsDate)) <= seconds
- }
-
- private static func modelsCompatible(_ lhs: String, _ rhs: String) -> Bool {
- let left = canonicalModel(lhs)
- let right = canonicalModel(rhs)
- if left == right { return true }
- guard left != "unknown",
- right != "unknown",
- min(left.count, right.count) >= 8
- else {
- return false
- }
- return left.contains(right) || right.contains(left)
- }
-
- private static func canonicalModel(_ value: String) -> String {
- value
- .trimmingCharacters(in: .whitespacesAndNewlines)
- .lowercased()
- .replacingOccurrences(of: "_", with: "-")
- }
-
- private static func usageVectorsClose(_ lhs: TokenUsageCounts, _ rhs: TokenUsageCounts) -> Bool {
- guard tokenValuesClose(lhs.totalTokens, rhs.totalTokens) else { return false }
- let pairs = [
- (lhs.inputTokens, rhs.inputTokens),
- (lhs.outputTokens, rhs.outputTokens),
- (lhs.cacheCreationInputTokens, rhs.cacheCreationInputTokens),
- (lhs.cacheReadInputTokens, rhs.cacheReadInputTokens),
- (lhs.reasoningOutputTokens, rhs.reasoningOutputTokens)
- ]
- return pairs.allSatisfy { pair in
- let left = pair.0
- let right = pair.1
- return left == 0 && right == 0 || tokenValuesClose(left, right)
- }
- }
-
- private static func usageVectorsClose(proxyRecord: UsageRecord, nativeRecord: UsageRecord) -> Bool {
- guard toolFamily(for: proxyRecord.tool) == "codex",
- toolFamily(for: nativeRecord.tool) == "codex"
- else {
- return usageVectorsClose(proxyRecord.usage, nativeRecord.usage)
- }
-
- let proxy = proxyRecord.usage
- let native = nativeRecord.usage
- guard tokenValuesClose(proxy.outputTokens, native.outputTokens),
- tokenValuesClose(proxy.cacheReadInputTokens, native.cacheReadInputTokens),
- tokenValuesClose(proxy.cacheCreationInputTokens, native.cacheCreationInputTokens)
- else {
- return false
- }
-
- // Native Codex reports cached input as a subset of input. CC Switch versions
- // have emitted input both inclusive and exclusive of cached input, so compare
- // both canonical interpretations without changing either source's stored data.
- let nativeUncachedInput = max(0, native.inputTokens - native.cacheReadInputTokens)
- let inputMatches = tokenValuesClose(proxy.inputTokens, native.inputTokens)
- || tokenValuesClose(proxy.inputTokens, nativeUncachedInput)
- guard inputMatches else { return false }
-
- let proxyProcessedCandidates = [
- proxy.inputTokens + proxy.outputTokens,
- proxy.inputTokens + proxy.cacheReadInputTokens + proxy.cacheCreationInputTokens + proxy.outputTokens
- ]
- return proxyProcessedCandidates.contains { tokenValuesClose($0, native.totalTokens) }
- }
-
- private static func tokenValuesClose(_ lhs: Int, _ rhs: Int) -> Bool {
- if lhs == rhs { return true }
- let baseline = max(lhs, rhs)
- guard baseline > 0 else { return true }
- let tolerance = max(4, Int((Double(baseline) * 0.01).rounded(.up)))
- return abs(lhs - rhs) <= tolerance
- }
-
- private static func aggregate(records: [UsageRecord], sources: [String: SourceInfo]) -> UsageSnapshot {
- var daily = [String: DailyAccumulator]()
- var rhythms = [String: RhythmAccumulator]()
- var agentWork = [String: AgentWorkAccumulator]()
- var tools = [String: UsageAccumulator]()
- var models = [ModelKey: UsageAccumulator]()
-
- for record in records {
- let cost = record.costUSD ?? estimateCost(usage: record.usage, tool: record.tool, model: record.model)
- daily[record.date, default: DailyAccumulator(date: record.date)].add(record: record, cost: cost)
- let recordHour = record.timestampEpoch.map(hour(fromEpoch:))
- ?? hour(fromISO: record.timestamp)
- if let hour = recordHour {
- rhythms[record.date, default: RhythmAccumulator(date: record.date)]
- .add(tokens: record.usage.totalTokens, hour: hour)
- }
- if isAgentWorkRecord(record) {
- agentWork[record.date, default: AgentWorkAccumulator(date: record.date)]
- .add(record: record, hour: recordHour)
- }
- tools[record.tool, default: UsageAccumulator()].add(record.usage, cost: cost)
- models[ModelKey(tool: record.tool, model: record.model), default: UsageAccumulator()].add(record.usage, cost: cost)
- }
-
- let totalTokens = tools.values.map(\.usage.totalTokens).reduce(0, +)
- let totalCost = tools.values.map(\.cost).reduce(0, +)
-
- let dailyRows = daily.values
- .sorted { $0.date < $1.date }
- .map { item in
- DailyUsage(
- date: item.date,
- tools: item.tools,
- models: item.models,
- modelCosts: item.modelCosts.mapValues { rounded($0, digits: 4) },
- totalTokens: item.totalTokens,
- cost: rounded(item.cost, digits: 4)
- )
- }
-
- let rhythmRows = rhythms.values
- .map(\.dailyRhythm)
- .filter { $0.totalTokens > 0 }
- .sorted { $0.date < $1.date }
-
- let agentWorkRows = agentWork.values
- .map(\.dailyAgentWork)
- .filter { $0.totalTokens > 0 }
- .sorted { $0.date < $1.date }
-
- let toolRows = tools
- .sorted { $0.value.usage.totalTokens > $1.value.usage.totalTokens }
- .map { tool, item in
- ToolUsage(
- tool: tool,
- tokens: item.usage.totalTokens,
- percent: percent(item.usage.totalTokens, of: totalTokens)
- )
- }
-
- let modelRows = models
- .sorted { $0.value.usage.totalTokens > $1.value.usage.totalTokens }
- .map { key, item in
- ModelUsage(
- model: key.model,
- tool: key.tool,
- tokens: item.usage.totalTokens,
- percent: percent(item.usage.totalTokens, of: totalTokens)
- )
- }
-
- return UsageSnapshot(
- generatedAt: isoFormatter.string(from: Date()),
- timezone: "Asia/Shanghai",
- totals: UsageTotals(
- tokens: totalTokens,
- cost: rounded(totalCost, digits: 2),
- activeDays: dailyRows.filter { $0.totalTokens > 0 }.count
- ),
- daily: dailyRows,
- rhythms: rhythmRows,
- agentWork: agentWorkRows,
- tools: toolRows,
- models: modelRows,
- sources: sources
- )
- }
-
- private static func isAgentWorkRecord(_ record: UsageRecord) -> Bool {
- switch record.source {
- case .nativeCodex, .nativeCodexSQLite, .nativeClaudeCode, .ccSwitchProxy, .zcode, .hermes, .workbuddy:
- return true
- case .unknown:
- return false
- }
- }
-
- private static func jsonlFiles(under root: URL, modifiedSince cutoffDate: Date? = nil) -> [URL] {
- guard FileManager.default.fileExists(atPath: root.path),
- let enumerator = FileManager.default.enumerator(
- at: root,
- includingPropertiesForKeys: [.isRegularFileKey, .contentModificationDateKey],
- options: [.skipsHiddenFiles]
- )
- else {
- return []
- }
-
- return enumerator.compactMap { item in
- guard let url = item as? URL,
- url.pathExtension == "jsonl",
- let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .contentModificationDateKey]),
- values.isRegularFile == true
- else {
- return nil
- }
- if let cutoffDate,
- let modificationDate = values.contentModificationDate,
- modificationDate < cutoffDate {
- return nil
- }
- return url
- }
- }
-
- private static func cachedRecords(for url: URL, tool: String, cache: CollectorCache) -> [UsageRecord]? {
- guard let metadata = fileMetadata(for: url),
- let fingerprint = contentFingerprint(for: url, size: metadata.size),
- let cached = cache.files[url.path],
- cached.tool == tool,
- cached.size == metadata.size,
- abs(cached.modificationTime - metadata.modificationTime) < 0.001,
- cached.contentFingerprint == fingerprint
- else {
- return nil
- }
- return cached.records
- }
-
- private static func cachedCodexScan(for url: URL, cache: CollectorCache) -> CodexSessionScan? {
- guard let metadata = fileMetadata(for: url),
- let fingerprint = contentFingerprint(for: url, size: metadata.size),
- let cached = cache.files[url.path],
- cached.tool == "Codex",
- cached.size == metadata.size,
- abs(cached.modificationTime - metadata.modificationTime) < 0.001,
- cached.contentFingerprint == fingerprint
- else {
- return nil
- }
- return cached.codexScan
- }
-
- private static func updateCache(path: URL, tool: String, records: [UsageRecord], cache: inout CollectorCache) {
- guard let metadata = fileMetadata(for: path),
- let fingerprint = contentFingerprint(for: path, size: metadata.size)
- else {
- return
- }
- cache.files[path.path] = CachedUsageFile(
- tool: tool,
- size: metadata.size,
- modificationTime: metadata.modificationTime,
- records: records,
- contentFingerprint: fingerprint
- )
- }
-
- private static func updateCodexCache(
- path: URL,
- scan: CodexSessionScan,
- metadata: (size: UInt64, modificationTime: TimeInterval),
- cache: inout CollectorCache
- ) {
- guard let currentMetadata = fileMetadata(for: path),
- UsageCollector.metadata(metadata, matches: currentMetadata),
- let fingerprint = contentFingerprint(for: path, size: currentMetadata.size),
- let finalMetadata = fileMetadata(for: path),
- UsageCollector.metadata(currentMetadata, matches: finalMetadata)
- else {
- return
- }
- cache.files[path.path] = CachedUsageFile(
- tool: "Codex",
- size: finalMetadata.size,
- modificationTime: finalMetadata.modificationTime,
- records: [],
- codexScan: scan,
- contentFingerprint: fingerprint
- )
- }
-
- private static func fileMetadata(for url: URL) -> (size: UInt64, modificationTime: TimeInterval)? {
- guard let values = try? url.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey]),
- let size = values.fileSize,
- let modificationDate = values.contentModificationDate
- else {
- return nil
- }
- return (UInt64(max(0, size)), modificationDate.timeIntervalSince1970)
- }
-
- private static func metadata(
- _ lhs: (size: UInt64, modificationTime: TimeInterval),
- matches rhs: (size: UInt64, modificationTime: TimeInterval)
- ) -> Bool {
- lhs.size == rhs.size && abs(lhs.modificationTime - rhs.modificationTime) < 0.001
- }
-
- private static func contentFingerprint(for url: URL, size: UInt64) -> String? {
- guard let handle = try? FileHandle(forReadingFrom: url) else { return nil }
- defer { try? handle.close() }
-
- let chunkSize = 4_096
- var hash: UInt64 = 14_695_981_039_346_656_037
- func include(_ data: Data) {
- for byte in data {
- hash ^= UInt64(byte)
- hash &*= 1_099_511_628_211
- }
- }
-
- do {
- include(withUnsafeBytes(of: size.littleEndian) { Data($0) })
- let leadingCount = min(chunkSize, Int(clamping: size))
- include(try handle.read(upToCount: leadingCount) ?? Data())
- if size > UInt64(leadingCount) {
- let trailingCount = min(chunkSize, Int(clamping: size))
- try handle.seek(toOffset: size - UInt64(trailingCount))
- include(try handle.read(upToCount: trailingCount) ?? Data())
- }
- return String(format: "%016llx", hash)
- } catch {
- return nil
- }
- }
-
- private static func fullContentFingerprint(for url: URL, size: UInt64) -> String? {
- guard let handle = try? FileHandle(forReadingFrom: url) else { return nil }
- defer { try? handle.close() }
-
- var hasher = SHA256()
- hasher.update(data: withUnsafeBytes(of: size.littleEndian) { Data($0) })
- var remaining = size
- do {
- while remaining > 0 {
- let requested = min(1_048_576, Int(clamping: remaining))
- guard let chunk = try autoreleasepool(invoking: {
- try handle.read(upToCount: requested)
- }), !chunk.isEmpty else {
- return nil
- }
- hasher.update(data: chunk)
- remaining -= UInt64(chunk.count)
- }
- return hasher.finalize().map { String(format: "%02x", $0) }.joined()
- } catch {
- return nil
- }
- }
-
- private static func loadCache() -> CollectorCacheLoad {
- loadCache(at: AppPaths.collectorCacheJSON)
- }
-
- private static func loadCache(at url: URL) -> CollectorCacheLoad {
- guard let data = try? Data(contentsOf: url),
- let decoded = try? JSONDecoder().decode(CollectorCache.self, from: data)
- else {
- return CollectorCacheLoad(cache: CollectorCache(), recalibratedFromRevision: nil)
- }
- guard decoded.version == CollectorCache.currentVersion else {
- return CollectorCacheLoad(
- cache: CollectorCache(),
- recalibratedFromRevision: decoded.version < CollectorCache.currentVersion ? decoded.version : nil
- )
- }
- return CollectorCacheLoad(cache: decoded, recalibratedFromRevision: nil)
- }
-
- private static func saveCache(_ cache: CollectorCache) {
- saveCache(cache, to: AppPaths.collectorCacheJSON)
- }
-
- private static func loadCurrentCache(at url: URL) -> CollectorCache {
- guard let data = try? Data(contentsOf: url),
- let cache = try? JSONDecoder().decode(CollectorCache.self, from: data),
- cache.version == CollectorCache.currentVersion
- else {
- return CollectorCache()
- }
- return cache
- }
-
- private static func saveCache(_ cache: CollectorCache, to url: URL) {
- do {
- let encoder = JSONEncoder()
- encoder.outputFormatting = [.sortedKeys]
- let data = try encoder.encode(cache)
- try FileManager.default.createDirectory(
- at: url.deletingLastPathComponent(),
- withIntermediateDirectories: true
- )
- if let attributes = try? FileManager.default.attributesOfItem(atPath: url.path),
- let existingSize = (attributes[.size] as? NSNumber)?.intValue,
- existingSize == data.count,
- let existing = try? Data(contentsOf: url),
- existing == data {
- return
- }
- try data.write(to: url, options: .atomic)
- } catch {
- // Cache misses should never prevent the app from showing fresh usage.
- }
- }
-
- private static func sourceFileCutoffDate(historyDays: Int) -> Date? {
- calendar.date(byAdding: .day, value: -max(7, historyDays + 1), to: Date())
- }
-
- private static func recordsInHistoryWindow(
- _ records: [UsageRecord],
- historyDays: Int,
- now: Date
- ) -> [UsageRecord] {
- let inclusiveDays = max(1, historyDays)
- let today = calendar.startOfDay(for: now)
- guard let firstDay = calendar.date(
- byAdding: .day,
- value: -(inclusiveDays - 1),
- to: today
- ) else {
- return records
- }
- let firstDayString = dayFormatter.string(from: firstDay)
- let todayString = dayFormatter.string(from: today)
- return records.filter {
- $0.date >= firstDayString && $0.date <= todayString
- }
- }
-
- private static func forEachLine(in url: URL, matchingAny markers: [String] = [], _ body: (String) -> Void) throws {
- let handle = try FileHandle(forReadingFrom: url)
- defer { try? handle.close() }
-
- let newline = Data([0x0A])
- let markerData = markers.map { Data($0.utf8) }
- var buffer = Data()
- buffer.reserveCapacity(128 * 1024)
- var discardingOversizedLine = false
-
- func processLine(_ lineData: Data) {
- guard lineMatches(lineData, markers: markerData),
- let line = String(data: lineData, encoding: .utf8),
- !line.isEmpty
- else {
- return
- }
- body(line)
- }
-
- while try autoreleasepool(invoking: { () throws -> Bool in
- guard let chunk = try handle.read(upToCount: 64 * 1024), !chunk.isEmpty else {
- return false
- }
- buffer.append(chunk)
-
- var consumedEnd = buffer.startIndex
- var lineStart = buffer.startIndex
- var searchRange = buffer.startIndex.. lineStart {
- let lineData = buffer.subdata(in: lineStart.. buffer.startIndex {
- buffer.removeSubrange(buffer.startIndex.. maxRelevantLineBytes {
- discardingOversizedLine = true
- buffer.removeAll(keepingCapacity: true)
- }
- return true
- }) {}
-
- if !discardingOversizedLine,
- !buffer.isEmpty,
- buffer.count <= maxRelevantLineBytes {
- processLine(buffer)
- }
- }
-
- @discardableResult
- private static func forEachCompleteLine(
- in url: URL,
- fromOffset offset: UInt64,
- matchingAny markers: [String] = [],
- _ body: (String) -> Void
- ) throws -> UInt64 {
- let handle = try FileHandle(forReadingFrom: url)
- defer { try? handle.close() }
- try handle.seek(toOffset: offset)
-
- let newline = Data([0x0A])
- let markerData = markers.map { Data($0.utf8) }
- var buffer = Data()
- buffer.reserveCapacity(128 * 1024)
- var discardingOversizedLine = false
- var discardedIncompleteBytes = 0
- var processedSize = offset
-
- func processLine(_ lineData: Data) {
- guard lineMatches(lineData, markers: markerData),
- let line = String(data: lineData, encoding: .utf8),
- !line.isEmpty
- else {
- return
- }
- body(line)
- }
-
- while try autoreleasepool(invoking: { () throws -> Bool in
- guard let chunk = try handle.read(upToCount: 64 * 1024), !chunk.isEmpty else {
- return false
- }
- buffer.append(chunk)
-
- var consumedEnd = buffer.startIndex
- var lineStart = buffer.startIndex
- var searchRange = buffer.startIndex.. lineStart {
- processLine(buffer.subdata(in: lineStart.. buffer.startIndex {
- let consumedBytes = buffer.distance(from: buffer.startIndex, to: consumedEnd)
- processedSize += UInt64(discardedIncompleteBytes + consumedBytes)
- discardedIncompleteBytes = 0
- buffer.removeSubrange(buffer.startIndex.. maxRelevantLineBytes {
- discardingOversizedLine = true
- discardedIncompleteBytes += buffer.count
- buffer.removeAll(keepingCapacity: true)
- }
- return true
- }) {}
-
- return processedSize
- }
-
- private static func lineMatches(_ data: Data, markers: [Data]) -> Bool {
- markers.isEmpty || markers.contains { data.range(of: $0) != nil }
- }
-
- private static func jsonObject(_ line: String) -> [String: Any]? {
- guard let data = line.data(using: .utf8),
- let object = try? JSONSerialization.jsonObject(with: data),
- let dictionary = object as? [String: Any]
- else {
- return nil
- }
- return dictionary
- }
-
- private static func normalizeUsage(_ raw: [String: Any]?) -> TokenUsageCounts {
- guard let raw else { return TokenUsageCounts() }
- func value(_ keys: [String]) -> Int {
- for key in keys where raw.keys.contains(key) {
- return max(0, integerValue(raw[key] as Any))
- }
- return 0
- }
-
- let explicitTotal = ["total_tokens", "total"].first(where: { raw.keys.contains($0) })
- .map { max(0, integerValue(raw[$0] as Any)) }
- return canonicalUsageCounts(
- rawInputTokens: value(["input_tokens", "input"]),
- outputTokens: value(["output_tokens", "output"]),
- cacheCreationInputTokens: value(["cache_creation_input_tokens"]),
- cacheReadInputTokens: value(["cache_read_input_tokens", "cached_input_tokens", "cached"]),
- reasoningOutputTokens: value(["reasoning_output_tokens", "reasoning_tokens", "thoughts"]),
- inputIncludesCachedTokens: false,
- explicitTotalTokens: explicitTotal
- )
- }
-
- private static func normalizeCodexUsage(_ raw: [String: Any]) -> TokenUsageCounts {
- func value(_ keys: [String]) -> Int {
- for key in keys where raw.keys.contains(key) {
- return max(0, integerValue(raw[key] as Any))
- }
- return 0
- }
-
- let input = value(["input_tokens", "input"])
- let output = value(["output_tokens", "output"])
- let cached = value(["cached_input_tokens", "cache_read_input_tokens", "cached"])
- let reasoning = value(["reasoning_output_tokens", "reasoning_tokens", "thoughts"])
- let explicitTotal = ["total_tokens", "total"].first(where: { raw.keys.contains($0) })
- .map { max(0, integerValue(raw[$0] as Any)) }
- return canonicalUsageCounts(
- rawInputTokens: input,
- outputTokens: output,
- cacheCreationInputTokens: value(["cache_creation_input_tokens", "cache_write_input_tokens"]),
- cacheReadInputTokens: cached,
- reasoningOutputTokens: reasoning,
- inputIncludesCachedTokens: true,
- explicitTotalTokens: explicitTotal,
- explicitTotalIsAuthoritative: true
- )
- }
-
- private static func canonicalUsageCounts(
- rawInputTokens: Int,
- outputTokens: Int,
- cacheCreationInputTokens: Int = 0,
- cacheReadInputTokens: Int = 0,
- reasoningOutputTokens: Int = 0,
- inputIncludesCachedTokens: Bool,
- explicitTotalTokens: Int? = nil,
- explicitTotalIsAuthoritative: Bool = false
- ) -> TokenUsageCounts {
- let rawInput = max(0, rawInputTokens)
- let output = max(0, outputTokens)
- let cacheCreation = max(0, cacheCreationInputTokens)
- let cacheRead = max(0, cacheReadInputTokens)
- let reasoning = max(0, reasoningOutputTokens)
- let input = rawInput + (inputIncludesCachedTokens ? 0 : cacheCreation + cacheRead)
- let derivedTotal = input + output
- let explicitTotal = max(0, explicitTotalTokens ?? 0)
- let total = explicitTotalIsAuthoritative && explicitTotal > 0
- ? explicitTotal
- : (derivedTotal > 0 ? derivedTotal : explicitTotal)
- return TokenUsageCounts(
- inputTokens: input,
- outputTokens: output,
- cacheCreationInputTokens: cacheCreation,
- cacheReadInputTokens: cacheRead,
- reasoningOutputTokens: reasoning,
- totalTokens: total
- )
- }
-
- private static func integerValue(_ 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 doubleValue(_ value: Any) -> Double {
- if let double = value as? Double { return double }
- if let int = value as? Int { return Double(int) }
- if let string = value as? String { return Double(string) ?? 0 }
- return 0
- }
-
- private static func nonEmptyString(_ value: String?) -> String? {
- guard let value else { return nil }
- let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
- return trimmed.isEmpty ? nil : trimmed
- }
-
- private static func dayString(fromISO value: String) -> String? {
- guard let date = parseISO(value) else { return nil }
- return dayFormatter.string(from: date)
- }
-
- private static func dayString(for event: CodexTokenEvent) -> String? {
- if let timestamp = event.timestampEpoch {
- return dayFormatter.string(from: Date(timeIntervalSince1970: timestamp))
- }
- return event.timestamp.flatMap(dayString(fromISO:))
- }
-
- private static func hour(fromEpoch value: TimeInterval) -> Int {
- calendar.component(.hour, from: Date(timeIntervalSince1970: value))
- }
-
- private static func hour(fromISO value: String?) -> Int? {
- guard let value, let date = parseISO(value) else { return nil }
- return calendar.component(.hour, from: date)
- }
-
- private static func dayString(fromEpoch value: Any?) -> String? {
- guard let seconds = epochSeconds(value) else { return nil }
- return dayFormatter.string(from: Date(timeIntervalSince1970: seconds))
- }
-
- private static func isoString(fromEpoch value: Any?) -> String? {
- guard let seconds = epochSeconds(value) else { return nil }
- return isoFormatter.string(from: Date(timeIntervalSince1970: seconds))
- }
-
- private static func epochSeconds(_ value: Any?) -> Double? {
- var seconds: Double
- if let int = value as? Int {
- seconds = Double(int)
- } else if let double = value as? Double {
- seconds = double
- } else if let string = value as? String, let parsed = Double(string) {
- seconds = parsed
- } else {
- return nil
- }
- if seconds > 10_000_000_000 {
- seconds /= 1_000
- }
- return seconds
- }
-
- private static func parseISO(_ value: String) -> Date? {
- if let date = isoFormatterWithFractional.date(from: value) {
- return date
- }
- return isoFormatter.date(from: value)
- }
-
- private static func modelKey(_ model: String?) -> String {
- let value = (model ?? "unknown").trimmingCharacters(in: .whitespacesAndNewlines)
- return value.isEmpty ? "unknown" : value
- }
-
- private static func claudeIdentity(
- obj: [String: Any],
- message: [String: Any],
- path: URL,
- lineNumber: Int
- ) -> ClaudeIdentity {
- let responseID = nonEmptyString(message["id"] as? String)
- let requestID = [
- obj["requestId"] as? String,
- obj["request_id"] as? String,
- message["requestId"] as? String,
- message["request_id"] as? String
- ].compactMap(nonEmptyString).first
- let sessionID = [
- obj["sessionId"] as? String,
- obj["session_id"] as? String,
- obj["sessionID"] as? String
- ].compactMap(nonEmptyString).first
- let uuid = nonEmptyString(obj["uuid"] as? String)
-
- let deduplicationKey: String
- if let responseID {
- deduplicationKey = "response:\(responseID)"
- } else if let requestID {
- deduplicationKey = "request:\(requestID)"
- } else if let uuid {
- deduplicationKey = "uuid:\(uuid)"
- } else {
- deduplicationKey = "line:\(path.path):\(lineNumber)"
- }
- return ClaudeIdentity(
- deduplicationKey: deduplicationKey,
- requestID: requestID,
- responseID: responseID,
- sessionID: sessionID
- )
- }
-
- private static func hasStopReason(_ value: Any?) -> Bool {
- guard let text = value as? String else { return false }
- return !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
- }
-
- private static func ccSwitchToolName(appType: String?) -> String {
- let value = (appType ?? "unknown").trimmingCharacters(in: .whitespacesAndNewlines)
- let normalized = value.lowercased()
- switch normalized {
- case "claude":
- return "Claude Code via CC Switch"
- case "codex":
- return "Codex via CC Switch"
- case "gemini":
- return "Gemini via CC Switch"
- default:
- return "\(value.isEmpty ? "unknown" : value) via CC Switch (experimental)"
- }
- }
-
- private static func ccSwitchFreshInputTokens(
- rawInputTokens: Int,
- cacheReadTokens: Int,
- cacheCreationTokens: Int,
- appType: String?,
- inputTokenSemantics: Int
- ) -> Int {
- let rawInput = max(0, rawInputTokens)
- let cacheRead = max(0, cacheReadTokens)
- let cacheCreation = max(0, cacheCreationTokens)
- let normalizedAppType = (appType ?? "")
- .trimmingCharacters(in: .whitespacesAndNewlines)
- .lowercased()
- let cacheInclusiveAppTypes: Set = ["codex", "gemini", "grokbuild"]
- guard cacheInclusiveAppTypes.contains(normalizedAppType) else {
- return rawInput
- }
-
- switch inputTokenSemantics {
- case 2:
- // FRESH: input excludes both cache-read and cache-write buckets.
- return rawInput
- case 1 where rawInput >= cacheRead + cacheCreation:
- // TOTAL: input already includes both cache buckets.
- return rawInput - cacheRead - cacheCreation
- case 0 where rawInput >= cacheRead:
- // LEGACY: cache reads were included, cache writes were separate.
- return rawInput - cacheRead
- default:
- // Malformed or future semantics stay conservative instead of going negative.
- return rawInput
- }
- }
-
- private static func sqliteJSONRows(database: URL, query: String) -> [[String: Any]]? {
- SQLiteReadonly.jsonRows(database: database, query: query)
- }
-
- private static func estimateCost(usage: TokenUsageCounts, tool: String, model: String) -> Double {
- let lower = model.lowercased()
- if tool == "Codex", lower.contains("gpt-5.5") {
- return openAICostByParts(usage: usage, input: 5, cachedInput: 0.5, output: 30)
- }
- if tool == "Codex", lower.contains("gpt-5.4") {
- return openAICostByParts(usage: usage, input: 2.5, cachedInput: 0.25, output: 15)
- }
- if lower.contains("opus") {
- return costByParts(usage: usage, input: 5, output: 25, cacheCreation: 6.25, cacheRead: 0.5)
- }
- if lower.contains("sonnet") {
- return costByParts(usage: usage, input: 3, output: 15, cacheCreation: 3.75, cacheRead: 0.3)
- }
- if tool == "Claude Code" {
- return Double(usage.totalTokens) / 1_000_000 * 3
- }
- return Double(usage.totalTokens) / 1_000_000
- }
-
- private static func openAICostByParts(
- usage: TokenUsageCounts,
- input: Double,
- cachedInput: Double,
- output: Double
- ) -> Double {
- let cached = max(0, usage.cacheReadInputTokens)
- let cacheCreation = max(0, usage.cacheCreationInputTokens)
- let uncachedInput = max(0, usage.inputTokens - cached - cacheCreation)
- if uncachedInput == 0,
- cached == 0,
- cacheCreation == 0,
- usage.outputTokens == 0,
- usage.totalTokens > 0 {
- return Double(usage.totalTokens) / 1_000_000 * input
- }
- return Double(uncachedInput + cacheCreation) / 1_000_000 * input
- + Double(cached) / 1_000_000 * cachedInput
- + Double(usage.outputTokens) / 1_000_000 * output
- }
-
- private static func costByParts(
- usage: TokenUsageCounts,
- input: Double,
- output: Double,
- cacheCreation: Double,
- cacheRead: Double
- ) -> Double {
- let uncachedInput = max(
- 0,
- usage.inputTokens - usage.cacheCreationInputTokens - usage.cacheReadInputTokens
- )
- return Double(uncachedInput) / 1_000_000 * input
- + Double(usage.outputTokens) / 1_000_000 * output
- + Double(usage.cacheCreationInputTokens) / 1_000_000 * cacheCreation
- + Double(usage.cacheReadInputTokens) / 1_000_000 * cacheRead
- }
-
- private static func percent(_ value: Int, of total: Int) -> Double {
- guard total > 0 else { return 0 }
- return rounded(Double(value) / Double(total) * 100, digits: 2)
- }
-
- private static func rounded(_ value: Double, digits: Int) -> Double {
- let multiplier = pow(10.0, Double(digits))
- return (value * multiplier).rounded() / multiplier
- }
-
- private static let dayFormatter: DateFormatter = {
- let formatter = DateFormatter()
- formatter.calendar = Calendar(identifier: .gregorian)
- formatter.locale = Locale(identifier: "en_US_POSIX")
- formatter.timeZone = timezone
- formatter.dateFormat = "yyyy-MM-dd"
- return formatter
- }()
-
- private static let calendar: Calendar = {
- var calendar = Calendar(identifier: .gregorian)
- calendar.timeZone = timezone
- return calendar
- }()
-
- private static let isoFormatterWithFractional: ISO8601DateFormatter = {
- let formatter = ISO8601DateFormatter()
- formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
- return formatter
- }()
-
- private static let isoFormatter: ISO8601DateFormatter = {
- let formatter = ISO8601DateFormatter()
- formatter.formatOptions = [.withInternetDateTime]
- return formatter
- }()
-}
-
-private struct CollectorResult {
- var records: [UsageRecord]
- var source: SourceInfo
-}
-
-private struct CodexCollectionOutcome {
- var result: CollectorResult
- var usedIncrementalStore: Bool
-}
-
-private struct PendingCodexSession {
- var path: URL
- var metadata: (size: UInt64, modificationTime: TimeInterval)
- var fingerprint: String
- var validationFingerprint: String? = nil
- var scan: CodexSessionScan
-}
-
-private struct StoredCodexSessionMetadata {
- var size: UInt64
- var modificationTime: TimeInterval
- var fingerprint: String
- var validationFingerprint: String?
- var sessionID: String
-}
-
-private struct CodexCachedSession {
- var path: String
- var size: UInt64
- var modificationTime: TimeInterval
- var fingerprint: String
- var validationFingerprint: String? = nil
- var sessionID: String
- var createdAtEpoch: TimeInterval?
- var parentSessionID: String?
- var anchors: [CodexAnchor]
- var records: [UsageRecord]
- var summaryRecords: [UsageRecord]
- var cursor: CodexSessionCursor
- var diagnostics: CodexCollectionDiagnostics
-
- func hasSameStoredAccounting(as other: CodexCachedSession) -> Bool {
- path == other.path
- && size == other.size
- && abs(modificationTime - other.modificationTime) < 0.001
- && fingerprint == other.fingerprint
- && sessionID == other.sessionID
- && createdAtEpoch == other.createdAtEpoch
- && parentSessionID == other.parentSessionID
- && anchors == other.anchors
- && records == other.records
- && summaryRecords == other.summaryRecords
- && cursor == other.cursor
- && diagnostics == other.diagnostics
- }
-}
-
-private struct CodexCachedContribution {
- var records: [UsageRecord]
- var recordCount: Int
- var diagnostics: CodexCollectionDiagnostics
-}
-
-private struct CodexSummaryKey: Hashable {
- var date: String
- var model: String
- var hour: Int?
-}
-
-private struct CodexSummaryAccumulator {
- var timestamp: String?
- var timestampEpoch: TimeInterval?
- var usage = TokenUsageCounts()
- var modelRequestCount = 0
- var toolCallCount = 0
-
- mutating func add(_ record: UsageRecord) {
- timestamp = timestamp ?? record.timestamp
- timestampEpoch = timestampEpoch ?? record.timestampEpoch
- usage.add(record.usage)
- modelRequestCount += max(0, record.modelRequestCount)
- toolCallCount += max(0, record.toolCallCount)
- }
-}
-
-private enum CodexIncrementalStoreError: LocalizedError {
- case sqlite(String)
- case corruptPayload(String)
- case unstableSource(String)
- case incompleteCache(expected: Int, actual: Int)
-
- var errorDescription: String? {
- switch self {
- case let .sqlite(message):
- return "Incremental cache error: \(message)"
- case let .corruptPayload(context):
- return "Incremental cache payload is corrupt: \(context)"
- case .unstableSource:
- return "A Codex session changed while it was being collected."
- case let .incompleteCache(expected, actual):
- return "Incremental cache is incomplete (expected \(expected), got \(actual))."
- }
- }
-
- var shouldRebuildCache: Bool {
- switch self {
- case .corruptPayload:
- return true
- case let .sqlite(message):
- let normalized = message.lowercased()
- return normalized.contains("not a database")
- || normalized.contains("database disk image is malformed")
- || normalized.contains("database malformed")
- case .incompleteCache:
- return true
- case .unstableSource:
- return false
- }
- }
-}
-
-private final class CodexIncrementalStore {
- private static let schemaVersion: Int32 = 6
- private static let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self)
-
- private var database: OpaquePointer?
- private var stagingTransactionActive = false
-
- static func discardDatabase(at url: URL) {
- let fileManager = FileManager.default
- for path in [url.path, url.path + "-wal", url.path + "-shm"] {
- guard fileManager.fileExists(atPath: path) else { continue }
- try? fileManager.removeItem(atPath: path)
- }
- }
-
- init(url: URL) throws {
- try FileManager.default.createDirectory(
- at: url.deletingLastPathComponent(),
- withIntermediateDirectories: true
- )
- let flags = SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_FULLMUTEX
- guard sqlite3_open_v2(url.path, &database, flags, nil) == SQLITE_OK else {
- let message = database.map { String(cString: sqlite3_errmsg($0)) } ?? "open failed"
- if let database { sqlite3_close(database) }
- database = nil
- throw CodexIncrementalStoreError.sqlite(message)
- }
- do {
- sqlite3_busy_timeout(database, 2_000)
- try execute("PRAGMA journal_mode=WAL")
- try execute("PRAGMA synchronous=NORMAL")
- try migrateIfNeeded()
- } catch {
- if let database {
- sqlite3_close(database)
- }
- database = nil
- throw error
- }
- }
-
- deinit {
- if let database {
- sqlite3_close(database)
- }
- }
-
- func metadataByPath() throws -> [String: StoredCodexSessionMetadata] {
- let statement = try prepare(
- """
- SELECT path, size, modification_time, fingerprint,
- validation_fingerprint, session_id
- FROM codex_sessions
- """
- )
- defer { sqlite3_finalize(statement) }
- var result = [String: StoredCodexSessionMetadata]()
- while sqlite3_step(statement) == SQLITE_ROW {
- guard let path = columnText(statement, index: 0),
- let fingerprint = columnText(statement, index: 3),
- let sessionID = columnText(statement, index: 5)
- else { continue }
- result[path] = StoredCodexSessionMetadata(
- size: UInt64(max(0, sqlite3_column_int64(statement, 1))),
- modificationTime: sqlite3_column_double(statement, 2),
- fingerprint: fingerprint,
- validationFingerprint: columnText(statement, index: 4),
- sessionID: sessionID
- )
- }
- try checkFinalStep(statement)
- return result
- }
-
- func childPaths(parentSessionID: String) throws -> [String] {
- let statement = try prepare(
- "SELECT path FROM codex_sessions WHERE parent_session_id = ? ORDER BY path"
- )
- defer { sqlite3_finalize(statement) }
- bind(parentSessionID, to: statement, index: 1)
- var result = [String]()
- while sqlite3_step(statement) == SQLITE_ROW {
- if let path = columnText(statement, index: 0) {
- result.append(path)
- }
- }
- try checkFinalStep(statement)
- return result
- }
-
- func childPaths(
- parentSessionID: String,
- createdAtOnOrAfter timestamp: TimeInterval
- ) throws -> [String] {
- let statement = try prepare(
- """
- SELECT path FROM codex_sessions
- WHERE parent_session_id = ?
- AND (created_at_epoch IS NULL OR created_at_epoch >= ?)
- ORDER BY path
- """
- )
- defer { sqlite3_finalize(statement) }
- bind(parentSessionID, to: statement, index: 1)
- sqlite3_bind_double(statement, 2, timestamp)
- var result = [String]()
- while sqlite3_step(statement) == SQLITE_ROW {
- if let path = columnText(statement, index: 0) {
- result.append(path)
- }
- }
- try checkFinalStep(statement)
- return result
- }
-
- func anchors(sessionID: String) throws -> [CodexAnchor]? {
- let statement = try prepare(
- "SELECT anchors FROM codex_sessions WHERE session_id = ? ORDER BY path LIMIT 1"
- )
- defer { sqlite3_finalize(statement) }
- bind(sessionID, to: statement, index: 1)
- let status = sqlite3_step(statement)
- if status == SQLITE_DONE { return nil }
- guard status == SQLITE_ROW,
- let data = columnData(statement, index: 0)
- else {
- throw currentError()
- }
- return try decode([CodexAnchor].self, from: data, context: "anchors")
- }
-
- func session(path: String) throws -> CodexCachedSession? {
- let statement = try prepare(
- """
- SELECT size, modification_time, fingerprint, validation_fingerprint,
- session_id, created_at_epoch, parent_session_id, anchors,
- records, COALESCE(summary_records, records), cursor, diagnostics
- FROM codex_sessions WHERE path = ? LIMIT 1
- """
- )
- defer { sqlite3_finalize(statement) }
- bind(path, to: statement, index: 1)
- let status = sqlite3_step(statement)
- if status == SQLITE_DONE { return nil }
- guard status == SQLITE_ROW,
- let fingerprint = columnText(statement, index: 2),
- let sessionID = columnText(statement, index: 4),
- let anchorsData = columnData(statement, index: 7),
- let recordsData = columnData(statement, index: 8),
- let summaryData = columnData(statement, index: 9),
- let cursorData = columnData(statement, index: 10),
- let diagnosticsData = columnData(statement, index: 11)
- else { return nil }
- return CodexCachedSession(
- path: path,
- size: UInt64(max(0, sqlite3_column_int64(statement, 0))),
- modificationTime: sqlite3_column_double(statement, 1),
- fingerprint: fingerprint,
- validationFingerprint: columnText(statement, index: 3),
- sessionID: sessionID,
- createdAtEpoch: sqlite3_column_type(statement, 5) == SQLITE_NULL
- ? nil : sqlite3_column_double(statement, 5),
- parentSessionID: columnText(statement, index: 6),
- anchors: try decode([CodexAnchor].self, from: anchorsData, context: "session anchors"),
- records: try decode([UsageRecord].self, from: recordsData, context: "session records"),
- summaryRecords: try decode([UsageRecord].self, from: summaryData, context: "session summaries"),
- cursor: try decode(CodexSessionCursor.self, from: cursorData, context: "session cursor"),
- diagnostics: try decode(
- CodexCollectionDiagnostics.self,
- from: diagnosticsData,
- context: "session diagnostics"
- )
- )
- }
-
- func beginStaging() throws {
- guard !stagingTransactionActive else {
- throw CodexIncrementalStoreError.sqlite("staging transaction already active")
- }
- try execute("BEGIN IMMEDIATE TRANSACTION")
- do {
- try execute("DELETE FROM codex_staged_scans")
- try execute("DELETE FROM codex_staged_sessions")
- stagingTransactionActive = true
- } catch {
- try? execute("ROLLBACK")
- throw error
- }
- }
-
- func abortStaging() {
- guard stagingTransactionActive else { return }
- try? execute("ROLLBACK")
- stagingTransactionActive = false
- }
-
- func updateValidationFingerprint(_ fingerprint: String, path: String) throws {
- guard stagingTransactionActive else {
- throw CodexIncrementalStoreError.sqlite("staging transaction is not active")
- }
- let statement = try prepare(
- "UPDATE codex_sessions SET validation_fingerprint = ? WHERE path = ?"
- )
- defer { sqlite3_finalize(statement) }
- bind(fingerprint, to: statement, index: 1)
- bind(path, to: statement, index: 2)
- try requireDone(statement)
- }
-
- func stage(
- scan item: PendingCodexSession,
- anchors: [CodexAnchor],
- createdAtEpoch: TimeInterval?
- ) throws {
- guard stagingTransactionActive else {
- throw CodexIncrementalStoreError.sqlite("staging transaction is not active")
- }
- let encoder = PropertyListEncoder()
- encoder.outputFormat = .binary
- let anchors = try encoder.encode(anchors)
- let scan = try encoder.encode(item.scan)
- let statement = try prepare(
- """
- INSERT OR REPLACE INTO codex_staged_scans (
- path, size, modification_time, fingerprint, validation_fingerprint,
- session_id, created_at_epoch, parent_session_id, anchors, scan
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
- """
- )
- defer { sqlite3_finalize(statement) }
- bind(item.path.path, to: statement, index: 1)
- sqlite3_bind_int64(statement, 2, sqlite3_int64(item.metadata.size))
- sqlite3_bind_double(statement, 3, item.metadata.modificationTime)
- bind(item.fingerprint, to: statement, index: 4)
- bind(item.validationFingerprint, to: statement, index: 5)
- bind(item.scan.canonicalSessionID, to: statement, index: 6)
- bind(createdAtEpoch, to: statement, index: 7)
- bind(item.scan.parentSessionID, to: statement, index: 8)
- bind(anchors, to: statement, index: 9)
- bind(scan, to: statement, index: 10)
- try requireDone(statement)
- }
-
- func stagedScanPaths() throws -> [String] {
- let statement = try prepare("SELECT path FROM codex_staged_scans ORDER BY path")
- defer { sqlite3_finalize(statement) }
- var paths = [String]()
- while sqlite3_step(statement) == SQLITE_ROW {
- if let path = columnText(statement, index: 0) {
- paths.append(path)
- }
- }
- try checkFinalStep(statement)
- return paths
- }
-
- func stagedScan(path: String) throws -> PendingCodexSession? {
- let statement = try prepare(
- """
- SELECT size, modification_time, fingerprint, validation_fingerprint, scan
- FROM codex_staged_scans WHERE path = ? LIMIT 1
- """
- )
- defer { sqlite3_finalize(statement) }
- bind(path, to: statement, index: 1)
- let status = sqlite3_step(statement)
- if status == SQLITE_DONE { return nil }
- guard status == SQLITE_ROW,
- let fingerprint = columnText(statement, index: 2),
- let scanData = columnData(statement, index: 4)
- else { throw currentError() }
- return PendingCodexSession(
- path: URL(fileURLWithPath: path),
- metadata: (
- size: UInt64(max(0, sqlite3_column_int64(statement, 0))),
- modificationTime: sqlite3_column_double(statement, 1)
- ),
- fingerprint: fingerprint,
- validationFingerprint: columnText(statement, index: 3),
- scan: try decode(CodexSessionScan.self, from: scanData, context: "staged scan")
- )
- }
-
- func stagedAnchors(sessionID: String) throws -> [CodexAnchor]? {
- for table in ["codex_staged_scans", "codex_staged_sessions"] {
- let statement = try prepare(
- "SELECT anchors FROM \(table) WHERE session_id = ? ORDER BY path LIMIT 1"
- )
- defer { sqlite3_finalize(statement) }
- bind(sessionID, to: statement, index: 1)
- let status = sqlite3_step(statement)
- if status == SQLITE_DONE { continue }
- guard status == SQLITE_ROW,
- let data = columnData(statement, index: 0)
- else { throw currentError() }
- return try decode([CodexAnchor].self, from: data, context: "staged anchors")
- }
- return nil
- }
-
- func stage(session: CodexCachedSession) throws {
- guard stagingTransactionActive else {
- throw CodexIncrementalStoreError.sqlite("staging transaction is not active")
- }
- let encoder = PropertyListEncoder()
- encoder.outputFormat = .binary
- let anchors = try encoder.encode(session.anchors)
- let records = try encoder.encode(session.records)
- let summaryRecords = try encoder.encode(session.summaryRecords)
- let cursor = try encoder.encode(session.cursor)
- let diagnostics = try encoder.encode(session.diagnostics)
- let statement = try prepare(
- """
- INSERT OR REPLACE INTO codex_staged_sessions (
- path, size, modification_time, fingerprint, validation_fingerprint,
- session_id, created_at_epoch, parent_session_id, anchors, records,
- summary_records, record_count, cursor, diagnostics
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
- """
- )
- defer { sqlite3_finalize(statement) }
- bind(session.path, to: statement, index: 1)
- sqlite3_bind_int64(statement, 2, sqlite3_int64(session.size))
- sqlite3_bind_double(statement, 3, session.modificationTime)
- bind(session.fingerprint, to: statement, index: 4)
- bind(session.validationFingerprint, to: statement, index: 5)
- bind(session.sessionID, to: statement, index: 6)
- bind(session.createdAtEpoch, to: statement, index: 7)
- bind(session.parentSessionID, to: statement, index: 8)
- bind(anchors, to: statement, index: 9)
- bind(records, to: statement, index: 10)
- bind(summaryRecords, to: statement, index: 11)
- sqlite3_bind_int64(statement, 12, sqlite3_int64(session.records.count))
- bind(cursor, to: statement, index: 13)
- bind(diagnostics, to: statement, index: 14)
- try requireDone(statement)
- }
-
- func commitStaged(deletedPaths: Set) throws {
- guard stagingTransactionActive else {
- throw CodexIncrementalStoreError.sqlite("staging transaction is not active")
- }
- do {
- let stagedCount = try stagedSessionCount()
- let stagedPayloadBytes = try stagedSessionPayloadBytes()
- if !deletedPaths.isEmpty {
- let statement = try prepare("DELETE FROM codex_sessions WHERE path = ?")
- defer { sqlite3_finalize(statement) }
- for path in deletedPaths {
- sqlite3_reset(statement)
- sqlite3_clear_bindings(statement)
- bind(path, to: statement, index: 1)
- try requireDone(statement)
- }
- }
- if stagedCount > 0 {
- try execute(
- """
- INSERT OR REPLACE INTO codex_sessions (
- path, size, modification_time, fingerprint, validation_fingerprint,
- session_id, created_at_epoch, parent_session_id, anchors, records,
- summary_records, record_count, cursor, diagnostics
- )
- SELECT path, size, modification_time, fingerprint,
- validation_fingerprint, session_id, created_at_epoch,
- parent_session_id, anchors, records, summary_records,
- record_count, cursor, diagnostics
- FROM codex_staged_sessions
- """
- )
- }
- if stagedCount > 0 || !deletedPaths.isEmpty {
- try execute(
- """
- INSERT INTO cache_meta(key, value) VALUES ('generation', '1')
- ON CONFLICT(key) DO UPDATE SET value = CAST(value AS INTEGER) + 1
- """
- )
- let logicalWriteBytes = stagedPayloadBytes * 2
- try execute(
- """
- INSERT INTO cache_meta(key, value)
- VALUES ('last_logical_write_bytes', '\(logicalWriteBytes)')
- ON CONFLICT(key) DO UPDATE SET value = excluded.value
- """
- )
- }
- try execute("DELETE FROM codex_staged_scans")
- try execute("DELETE FROM codex_staged_sessions")
- try execute("COMMIT")
- stagingTransactionActive = false
- } catch {
- try? execute("ROLLBACK")
- stagingTransactionActive = false
- throw error
- }
- }
-
- private func stagedSessionCount() throws -> Int {
- let statement = try prepare("SELECT COUNT(*) FROM codex_staged_sessions")
- defer { sqlite3_finalize(statement) }
- guard sqlite3_step(statement) == SQLITE_ROW else { throw currentError() }
- return Int(sqlite3_column_int64(statement, 0))
- }
-
- private func stagedSessionPayloadBytes() throws -> Int {
- let statement = try prepare(
- """
- SELECT COALESCE(SUM(
- LENGTH(anchors) + LENGTH(records) + LENGTH(summary_records)
- + LENGTH(cursor) + LENGTH(diagnostics)
- ), 0)
- FROM codex_staged_sessions
- """
- )
- defer { sqlite3_finalize(statement) }
- guard sqlite3_step(statement) == SQLITE_ROW else { throw currentError() }
- return Int(sqlite3_column_int64(statement, 0))
- }
-
- func sessionCount() throws -> Int {
- let statement = try prepare("SELECT COUNT(*) FROM codex_sessions")
- defer { sqlite3_finalize(statement) }
- guard sqlite3_step(statement) == SQLITE_ROW else { throw currentError() }
- return Int(sqlite3_column_int64(statement, 0))
- }
-
- func forEachContribution(
- detailed: Bool,
- _ body: (CodexCachedContribution) throws -> Void
- ) throws {
- let statement = try prepare(
- detailed
- ? "SELECT records, diagnostics, record_count FROM codex_sessions ORDER BY path"
- : """
- SELECT CASE
- WHEN session_id IN (
- SELECT session_id FROM codex_sessions
- GROUP BY session_id HAVING COUNT(*) > 1
- ) THEN records
- ELSE COALESCE(summary_records, records)
- END,
- diagnostics,
- record_count
- FROM codex_sessions
- ORDER BY path
- """
- )
- defer { sqlite3_finalize(statement) }
- while sqlite3_step(statement) == SQLITE_ROW {
- guard let recordsData = columnData(statement, index: 0),
- let diagnosticsData = columnData(statement, index: 1)
- else { throw currentError() }
- try body(
- CodexCachedContribution(
- records: try decode(
- [UsageRecord].self,
- from: recordsData,
- context: "contribution records"
- ),
- recordCount: Int(sqlite3_column_int64(statement, 2)),
- diagnostics: try decode(
- CodexCollectionDiagnostics.self,
- from: diagnosticsData,
- context: "contribution diagnostics"
- )
- )
- )
- }
- try checkFinalStep(statement)
- }
-
- func stats() throws -> CodexIncrementalCacheStats {
- let statement = try prepare(
- """
- SELECT
- COALESCE((SELECT CAST(value AS INTEGER) FROM cache_meta WHERE key = 'generation'), 0),
- COUNT(*),
- COALESCE(SUM(record_count), 0),
- COALESCE((
- SELECT CAST(value AS INTEGER) FROM cache_meta
- WHERE key = 'last_logical_write_bytes'
- ), 0)
- FROM codex_sessions
- """
- )
- defer { sqlite3_finalize(statement) }
- guard sqlite3_step(statement) == SQLITE_ROW else { throw currentError() }
- return CodexIncrementalCacheStats(
- generation: Int(sqlite3_column_int64(statement, 0)),
- sessions: Int(sqlite3_column_int64(statement, 1)),
- records: Int(sqlite3_column_int64(statement, 2)),
- lastLogicalWriteBytes: Int(sqlite3_column_int64(statement, 3))
- )
- }
-
- private func migrateIfNeeded() throws {
- guard database != nil else { throw CodexIncrementalStoreError.sqlite("database closed") }
- let current = userVersion()
- guard current >= 0, current <= Self.schemaVersion else {
- throw CodexIncrementalStoreError.sqlite("unsupported schema version \(current)")
- }
- try execute(
- """
- CREATE TABLE IF NOT EXISTS cache_meta (
- key TEXT PRIMARY KEY NOT NULL,
- value TEXT NOT NULL
- )
- """
- )
- if current > 0, current < Self.schemaVersion {
- // v0.1.48 is the first public incremental-cache release. Recreate
- // older development schemas so interim payloads cannot survive.
- try execute("DROP TABLE IF EXISTS codex_sessions")
- try execute("DROP TABLE IF EXISTS codex_staged_scans")
- try execute("DROP TABLE IF EXISTS codex_staged_sessions")
- try execute("DELETE FROM cache_meta")
- }
- try execute(
- """
- CREATE TABLE IF NOT EXISTS codex_sessions (
- path TEXT PRIMARY KEY NOT NULL,
- size INTEGER NOT NULL,
- modification_time REAL NOT NULL,
- fingerprint TEXT NOT NULL,
- validation_fingerprint TEXT,
- session_id TEXT NOT NULL,
- created_at_epoch REAL,
- parent_session_id TEXT,
- anchors BLOB NOT NULL,
- records BLOB NOT NULL,
- summary_records BLOB,
- record_count INTEGER NOT NULL,
- cursor BLOB,
- diagnostics BLOB NOT NULL
- )
- """
- )
- try execute(
- "CREATE INDEX IF NOT EXISTS codex_sessions_session_id ON codex_sessions(session_id)"
- )
- try execute(
- "CREATE INDEX IF NOT EXISTS codex_sessions_parent_id ON codex_sessions(parent_session_id)"
- )
- try execute(
- """
- CREATE TABLE IF NOT EXISTS codex_staged_scans (
- path TEXT PRIMARY KEY NOT NULL,
- size INTEGER NOT NULL,
- modification_time REAL NOT NULL,
- fingerprint TEXT NOT NULL,
- validation_fingerprint TEXT,
- session_id TEXT NOT NULL,
- created_at_epoch REAL,
- parent_session_id TEXT,
- anchors BLOB NOT NULL,
- scan BLOB NOT NULL
- )
- """
- )
- try execute(
- "CREATE INDEX IF NOT EXISTS codex_staged_scans_session_id ON codex_staged_scans(session_id)"
- )
- try execute(
- """
- CREATE TABLE IF NOT EXISTS codex_staged_sessions (
- path TEXT PRIMARY KEY NOT NULL,
- size INTEGER NOT NULL,
- modification_time REAL NOT NULL,
- fingerprint TEXT NOT NULL,
- validation_fingerprint TEXT,
- session_id TEXT NOT NULL,
- created_at_epoch REAL,
- parent_session_id TEXT,
- anchors BLOB NOT NULL,
- records BLOB NOT NULL,
- summary_records BLOB NOT NULL,
- record_count INTEGER NOT NULL,
- cursor BLOB NOT NULL,
- diagnostics BLOB NOT NULL
- )
- """
- )
- try execute("PRAGMA user_version = \(Self.schemaVersion)")
- }
-
- private func userVersion() -> Int32 {
- guard let statement = try? prepare("PRAGMA user_version") else { return -1 }
- defer { sqlite3_finalize(statement) }
- guard sqlite3_step(statement) == SQLITE_ROW else { return -1 }
- return sqlite3_column_int(statement, 0)
- }
-
- private func execute(_ sql: String) throws {
- guard let database else { throw CodexIncrementalStoreError.sqlite("database closed") }
- var error: UnsafeMutablePointer?
- guard sqlite3_exec(database, sql, nil, nil, &error) == SQLITE_OK else {
- let message = error.map { String(cString: $0) }
- ?? String(cString: sqlite3_errmsg(database))
- sqlite3_free(error)
- throw CodexIncrementalStoreError.sqlite(message)
- }
- }
-
- private func prepare(_ sql: String) throws -> OpaquePointer {
- guard let database else { throw CodexIncrementalStoreError.sqlite("database closed") }
- var statement: OpaquePointer?
- guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK,
- let statement
- else { throw currentError() }
- return statement
- }
-
- private func bind(_ value: String?, to statement: OpaquePointer, index: Int32) {
- guard let value else {
- sqlite3_bind_null(statement, index)
- return
- }
- sqlite3_bind_text(statement, index, value, -1, Self.transient)
- }
-
- private func bind(_ value: TimeInterval?, to statement: OpaquePointer, index: Int32) {
- guard let value else {
- sqlite3_bind_null(statement, index)
- return
- }
- sqlite3_bind_double(statement, index, value)
- }
-
- private func bind(_ data: Data, to statement: OpaquePointer, index: Int32) {
- _ = data.withUnsafeBytes { bytes in
- sqlite3_bind_blob(statement, index, bytes.baseAddress, Int32(bytes.count), Self.transient)
- }
- }
-
- private func decode(
- _ type: T.Type,
- from data: Data,
- context: String
- ) throws -> T {
- do {
- return try PropertyListDecoder().decode(type, from: data)
- } catch {
- throw CodexIncrementalStoreError.corruptPayload(context)
- }
- }
-
- private func columnText(_ statement: OpaquePointer, index: Int32) -> String? {
- guard let value = sqlite3_column_text(statement, index) else { return nil }
- return String(cString: value)
- }
-
- private func columnData(_ statement: OpaquePointer, index: Int32) -> Data? {
- let count = Int(sqlite3_column_bytes(statement, index))
- guard count >= 0 else { return nil }
- if count == 0 { return Data() }
- guard let bytes = sqlite3_column_blob(statement, index) else { return nil }
- return Data(bytes: bytes, count: count)
- }
-
- private func requireDone(_ statement: OpaquePointer) throws {
- guard sqlite3_step(statement) == SQLITE_DONE else { throw currentError() }
- }
-
- private func checkFinalStep(_ statement: OpaquePointer) throws {
- let status = sqlite3_errcode(database)
- guard status == SQLITE_OK || status == SQLITE_DONE else { throw currentError() }
- }
-
- private func currentError() -> CodexIncrementalStoreError {
- guard let database else { return .sqlite("database closed") }
- return .sqlite(String(cString: sqlite3_errmsg(database)))
- }
-}
-
-private struct CollectorCache: Codable {
- static let currentVersion = UsageCollector.codexAccountingRevision
-
- var version = currentVersion
- var files: [String: CachedUsageFile] = [:]
-}
-
-private struct CollectorCacheLoad {
- var cache: CollectorCache
- var recalibratedFromRevision: Int?
-}
-
-private struct CachedUsageFile: Codable {
- var tool: String
- var size: UInt64
- var modificationTime: TimeInterval
- var records: [UsageRecord]
- var codexScan: CodexSessionScan? = nil
- var contentFingerprint: String? = nil
-}
-
-private struct CodexSessionScan: Codable {
- var canonicalSessionID: String
- var createdAt: String?
- var parentSessionID: String?
- var sourcePath: String
- var events: [CodexTokenEvent]
- var finalModel: String? = nil
- var relevantLineCount: Int? = nil
-}
-
-private struct CodexTokenEvent: Codable {
- var timestamp: String?
- var timestampEpoch: TimeInterval? = nil
- var model: String
- var cumulativePresent: Bool
- var cumulative: TokenUsageCounts?
- var last: TokenUsageCounts?
- var modelContextWindow: Int
- var lineNumber: Int
-}
-
-private struct CodexAnchor: Codable, Equatable {
- var timestamp: TimeInterval
- var usage: TokenUsageCounts
-}
-
-private struct CodexDeltaCursor {
- var hasCumulativeSchema: Bool
- var previousCumulative: TokenUsageCounts?
- var epoch: Int
-}
-
-private struct CodexSessionCursor: Codable, Equatable {
- var currentModel: String
- var relevantLineNumber: Int
- var hasCumulativeSchema: Bool
- var previousCumulative: TokenUsageCounts?
- var epoch: Int
-}
-
-private struct CodexSessionTail {
- var events: [CodexTokenEvent]
- var currentModel: String
- var relevantLineNumber: Int
- var processedSize: UInt64
- var modificationTime: TimeInterval
- var fingerprint: String
-}
-
-private struct CodexCollectionDiagnostics: Codable, Equatable {
- var rawRecords = 0
- var exactRecords = 0
- var legacyRecords = 0
- var duplicateRecords = 0
- var counterResets = 0
- var inheritedRecords = 0
- var inheritedTokens = 0
- var skippedRecords = 0
- var unknownBreakdownRecords = 0
-
- mutating func add(_ other: CodexCollectionDiagnostics) {
- rawRecords += other.rawRecords
- exactRecords += other.exactRecords
- legacyRecords += other.legacyRecords
- duplicateRecords += other.duplicateRecords
- counterResets += other.counterResets
- inheritedRecords += other.inheritedRecords
- inheritedTokens += other.inheritedTokens
- skippedRecords += other.skippedRecords
- unknownBreakdownRecords += other.unknownBreakdownRecords
- }
-}
-
-private struct UsageRecord: Codable, Equatable {
- var date: String
- var timestamp: String?
- var timestampEpoch: TimeInterval? = nil
- var tool: String
- var model: String
- var usage: TokenUsageCounts
- var costUSD: Double? = nil
- var source: UsageRecordSource = .unknown
- var requestID: String? = nil
- var sessionID: String? = nil
- var responseID: String? = nil
- var sourcePath: String? = nil
- var lineNumber: Int? = nil
- var dataSource: String? = nil
- var modelRequestCount = 1
- var toolCallCount = 0
-
- enum CodingKeys: String, CodingKey {
- case date
- case timestamp
- case timestampEpoch
- case tool
- case model
- case usage
- case costUSD
- case source
- case requestID
- case sessionID
- case responseID
- case sourcePath
- case lineNumber
- case dataSource
- case modelRequestCount
- case toolCallCount
- }
-
- init(
- date: String,
- timestamp: String?,
- timestampEpoch: TimeInterval? = nil,
- tool: String,
- model: String,
- usage: TokenUsageCounts,
- costUSD: Double? = nil,
- source: UsageRecordSource = .unknown,
- requestID: String? = nil,
- sessionID: String? = nil,
- responseID: String? = nil,
- sourcePath: String? = nil,
- lineNumber: Int? = nil,
- dataSource: String? = nil,
- modelRequestCount: Int = 1,
- toolCallCount: Int = 0
- ) {
- self.date = date
- self.timestamp = timestamp
- self.timestampEpoch = timestampEpoch
- self.tool = tool
- self.model = model
- self.usage = usage
- self.costUSD = costUSD
- self.source = source
- self.requestID = requestID
- self.sessionID = sessionID
- self.responseID = responseID
- self.sourcePath = sourcePath
- self.lineNumber = lineNumber
- self.dataSource = dataSource
- self.modelRequestCount = modelRequestCount
- self.toolCallCount = toolCallCount
- }
-
- init(from decoder: Decoder) throws {
- let container = try decoder.container(keyedBy: CodingKeys.self)
- date = try container.decode(String.self, forKey: .date)
- timestamp = try container.decodeIfPresent(String.self, forKey: .timestamp)
- timestampEpoch = try container.decodeIfPresent(TimeInterval.self, forKey: .timestampEpoch)
- tool = try container.decode(String.self, forKey: .tool)
- model = try container.decode(String.self, forKey: .model)
- usage = try container.decode(TokenUsageCounts.self, forKey: .usage)
- costUSD = try container.decodeIfPresent(Double.self, forKey: .costUSD)
- source = try container.decodeIfPresent(UsageRecordSource.self, forKey: .source) ?? .unknown
- requestID = try container.decodeIfPresent(String.self, forKey: .requestID)
- sessionID = try container.decodeIfPresent(String.self, forKey: .sessionID)
- responseID = try container.decodeIfPresent(String.self, forKey: .responseID)
- sourcePath = try container.decodeIfPresent(String.self, forKey: .sourcePath)
- lineNumber = try container.decodeIfPresent(Int.self, forKey: .lineNumber)
- dataSource = try container.decodeIfPresent(String.self, forKey: .dataSource)
- modelRequestCount = try container.decodeIfPresent(Int.self, forKey: .modelRequestCount) ?? 1
- toolCallCount = try container.decodeIfPresent(Int.self, forKey: .toolCallCount) ?? 0
- }
-}
-
-private enum UsageRecordSource: String, Codable, Equatable {
- case nativeCodex
- case nativeCodexSQLite
- case nativeClaudeCode
- case ccSwitchProxy
- case zcode
- case hermes
- case workbuddy
- case unknown
-}
-
-private struct CrossSourceDedupeResult {
- var records: [UsageRecord]
- var rawProxyRecords: Int
- var keptProxyRecords: Int
- var dedupedProxyRecords: Int
- var skippedProxyRecords: Int
-}
-
-private struct ClaudeIdentity {
- var deduplicationKey: String
- var requestID: String?
- var responseID: String?
- var sessionID: String?
-}
-
-private struct ClaudeUsageCandidate {
- var date: String
- var timestamp: String
- var model: String
- var usage: TokenUsageCounts
- var hasStopReason: Bool
- var lineNumber: Int
- var requestID: String?
- var responseID: String?
- var sessionID: String?
- var sourcePath: String
-
- var record: UsageRecord {
- UsageRecord(
- date: date,
- timestamp: timestamp,
- tool: "Claude Code",
- model: model,
- usage: usage,
- source: .nativeClaudeCode,
- requestID: requestID,
- sessionID: sessionID,
- responseID: responseID,
- sourcePath: sourcePath,
- lineNumber: lineNumber
- )
- }
-
- func isPreferred(over other: ClaudeUsageCandidate) -> Bool {
- if hasStopReason != other.hasStopReason {
- return hasStopReason
- }
- if timestamp != other.timestamp {
- return timestamp > other.timestamp
- }
- return lineNumber > other.lineNumber
- }
-}
-
-private struct TokenUsageCounts: Codable, Equatable {
- var inputTokens = 0
- var outputTokens = 0
- var cacheCreationInputTokens = 0
- var cacheReadInputTokens = 0
- var reasoningOutputTokens = 0
- var totalTokens = 0
-
- mutating func add(_ other: TokenUsageCounts) {
- inputTokens += other.inputTokens
- outputTokens += other.outputTokens
- cacheCreationInputTokens += other.cacheCreationInputTokens
- cacheReadInputTokens += other.cacheReadInputTokens
- reasoningOutputTokens += other.reasoningOutputTokens
- totalTokens += other.totalTokens
- }
-
- var fingerprint: String {
- [
- totalTokens,
- inputTokens,
- cacheReadInputTokens,
- outputTokens,
- reasoningOutputTokens,
- cacheCreationInputTokens
- ].map(String.init).joined(separator: ":")
- }
-
- var cacheCoverageComplete: Bool {
- inputTokens >= 0
- && outputTokens >= 0
- && cacheCreationInputTokens >= 0
- && cacheReadInputTokens >= 0
- && reasoningOutputTokens >= 0
- && totalTokens == inputTokens + outputTokens
- && cacheCreationInputTokens + cacheReadInputTokens <= inputTokens
- && reasoningOutputTokens <= outputTokens
- }
-}
-
-private struct UsageAccumulator {
- var usage = TokenUsageCounts()
- var cost = 0.0
-
- mutating func add(_ counts: TokenUsageCounts, cost: Double) {
- usage.inputTokens += counts.inputTokens
- usage.outputTokens += counts.outputTokens
- usage.cacheCreationInputTokens += counts.cacheCreationInputTokens
- usage.cacheReadInputTokens += counts.cacheReadInputTokens
- usage.reasoningOutputTokens += counts.reasoningOutputTokens
- usage.totalTokens += counts.totalTokens
- self.cost += cost
- }
-}
-
-private struct DailyAccumulator {
- var date: String
- var tools: [String: Int] = [:]
- var models: [String: Int] = [:]
- var modelCosts: [String: Double] = [:]
- var totalTokens = 0
- var cost = 0.0
-
- mutating func add(record: UsageRecord, cost: Double) {
- tools[record.tool, default: 0] += record.usage.totalTokens
- models[record.model, default: 0] += record.usage.totalTokens
- modelCosts[record.model, default: 0] += cost
- totalTokens += record.usage.totalTokens
- self.cost += cost
- }
-}
-
-private struct AgentWorkAccumulator {
- var date: String
- var totalTokens = 0
- var inputTokens = 0
- var cachedInputTokens = 0
- var outputTokens = 0
- var cacheCoverageComplete = true
- var unbucketedTokens = 0
- var activeHours = Set()
- var modelRequestCount = 0
- var toolCallCount = 0
- var sources: [String: AgentWorkSourceAccumulator] = [:]
- var hourlySources: [Int: [String: AgentWorkHourlySourceAccumulator]] = [:]
-
- mutating func add(record: UsageRecord, hour: Int?) {
- totalTokens += record.usage.totalTokens
- inputTokens += record.usage.inputTokens
- cachedInputTokens += record.usage.cacheReadInputTokens
- outputTokens += record.usage.outputTokens
- cacheCoverageComplete = cacheCoverageComplete && record.usage.cacheCoverageComplete
- if let hour {
- activeHours.insert(hour)
- var sourceRows = hourlySources[hour] ?? [:]
- sourceRows[record.tool, default: AgentWorkHourlySourceAccumulator(source: record.tool)]
- .add(record: record)
- hourlySources[hour] = sourceRows
- } else {
- unbucketedTokens += record.usage.totalTokens
- }
- modelRequestCount += max(0, record.modelRequestCount)
- toolCallCount += max(0, record.toolCallCount)
- sources[record.tool, default: AgentWorkSourceAccumulator(source: record.tool)]
- .add(record: record)
- }
-
- var dailyAgentWork: DailyAgentWork {
- DailyAgentWork(
- date: date,
- totalTokens: totalTokens,
- activeHours: activeHours.count,
- modelRequestCount: modelRequestCount,
- toolCallCount: toolCallCount,
- sources: sources.values
- .filter { $0.tokens > 0 }
- .sorted { $0.tokens > $1.tokens }
- .map(\.agentWorkSource),
- inputTokens: inputTokens,
- cachedInputTokens: cachedInputTokens,
- outputTokens: outputTokens,
- cacheCoverageComplete: cacheCoverageComplete,
- hourlyBuckets: (0..<24).map { hour in
- AgentWorkHourBucket(
- hour: hour,
- sources: (hourlySources[hour] ?? [:]).values
- .filter { $0.tokens > 0 }
- .sorted {
- if $0.tokens == $1.tokens {
- return $0.source < $1.source
- }
- return $0.tokens > $1.tokens
- }
- .map(\.hourlySource)
- )
- },
- unbucketedTokens: unbucketedTokens
- )
- }
-}
-
-private struct AgentWorkSourceAccumulator {
- var source: String
- var tokens = 0
- var modelRequestCount = 0
- var toolCallCount = 0
-
- mutating func add(record: UsageRecord) {
- tokens += record.usage.totalTokens
- modelRequestCount += max(0, record.modelRequestCount)
- toolCallCount += max(0, record.toolCallCount)
- }
-
- var agentWorkSource: AgentWorkSource {
- AgentWorkSource(
- source: source,
- tokens: tokens,
- modelRequestCount: modelRequestCount,
- toolCallCount: toolCallCount
- )
- }
-}
-
-private struct AgentWorkHourlySourceAccumulator {
- var source: String
- var tokens = 0
- var inputTokens = 0
- var cachedInputTokens = 0
- var outputTokens = 0
- var cacheCoverageComplete = true
-
- mutating func add(record: UsageRecord) {
- tokens += record.usage.totalTokens
- inputTokens += record.usage.inputTokens
- cachedInputTokens += record.usage.cacheReadInputTokens
- outputTokens += record.usage.outputTokens
- cacheCoverageComplete = cacheCoverageComplete && record.usage.cacheCoverageComplete
- }
-
- var hourlySource: AgentWorkHourlySource {
- AgentWorkHourlySource(
- source: source,
- tokens: tokens,
- inputTokens: inputTokens,
- cachedInputTokens: cachedInputTokens,
- outputTokens: outputTokens,
- cacheCoverageComplete: cacheCoverageComplete
- )
- }
-}
-
-private struct RhythmAccumulator {
- var date: String
- var hourlyTokens = Array(repeating: 0, count: 24)
-
- mutating func add(tokens: Int, hour: Int) {
- guard tokens > 0, (0.. right.offset
- }
- return left.element < right.element
- }
- let peakHour = (peak?.element ?? 0) > 0 ? peak?.offset : nil
- let peakTokens = peak?.element ?? 0
- let activeThreshold = Self.significantTokenThreshold(totalTokens: totalTokens, peakTokens: peakTokens)
- let significantHourlyTokens = hourlyTokens.map { $0 >= activeThreshold ? $0 : 0 }
- let activeHours = significantHourlyTokens.filter { $0 > 0 }.count
- let firstActiveHour = significantHourlyTokens.firstIndex { $0 > 0 }
- let lastActiveHour = significantHourlyTokens.lastIndex { $0 > 0 }
- let primaryTag = Self.classify(
- hourlyTokens: hourlyTokens,
- significantHourlyTokens: significantHourlyTokens,
- totalTokens: totalTokens,
- peakHour: peakHour,
- peakTokens: peakTokens,
- activeHours: activeHours,
- firstActiveHour: firstActiveHour
- )
-
- return DailyRhythm(
- date: date,
- buckets: buckets,
- totalTokens: totalTokens,
- peakHour: peakHour,
- peakTokens: peakTokens,
- activeHours: activeHours,
- firstActiveHour: firstActiveHour,
- lastActiveHour: lastActiveHour,
- primaryTag: primaryTag,
- companionTag: Self.companionTag(for: primaryTag)
- )
- }
-
- private static func classify(
- hourlyTokens: [Int],
- significantHourlyTokens: [Int],
- totalTokens: Int,
- peakHour: Int?,
- peakTokens: Int,
- activeHours: Int,
- firstActiveHour: Int?
- ) -> RhythmTag {
- guard totalTokens > 0 else { return .quietDay }
- let peakShare = share(peakTokens, of: totalTokens)
- if isDoublePeak(hourlyTokens: significantHourlyTokens, peakTokens: peakTokens) {
- return .doublePeak
- }
- if peakShare >= 0.50 {
- return .oneShot
- }
-
- let nightShare = share(tokens(in: [21, 22, 23, 0, 1, 2], hourlyTokens: significantHourlyTokens), of: totalTokens)
- if nightShare >= 0.35 || (peakHour.map { $0 >= 21 || $0 <= 2 } == true && nightShare >= 0.25) {
- return .nightAgent
- }
-
- let eveningShare = share(tokens(in: [19, 20], hourlyTokens: significantHourlyTokens), of: totalTokens)
- if peakHour.map({ (19...20).contains($0) }) == true || eveningShare >= 0.30 {
- return .eveningSprint
- }
-
- let afternoonShare = share(tokens(in: Array(14...18), hourlyTokens: significantHourlyTokens), of: totalTokens)
- if afternoonShare >= 0.35 || peakHour.map({ (14...18).contains($0) }) == true && afternoonShare >= 0.25 {
- return .afternoonBurst
- }
-
- let earlyShare = share(tokens(in: Array(5...9), hourlyTokens: significantHourlyTokens), of: totalTokens)
- if firstActiveHour.map({ $0 <= 8 }) == true && earlyShare >= 0.25 {
- return .earlyStarter
- }
-
- let morningShare = share(tokens(in: Array(8...12), hourlyTokens: significantHourlyTokens), of: totalTokens)
- if morningShare >= 0.35 || peakHour.map({ (8...12).contains($0) }) == true && morningShare >= 0.25 {
- return .morningPlanner
- }
-
- if activeHours >= 6 && peakShare < 0.35 {
- return .fragmented
- }
- if activeHours >= 4 {
- return .steadyCruise
- }
- return .quietDay
- }
-
- private static func companionTag(for tag: RhythmTag) -> RhythmTag {
- switch tag {
- case .earlyStarter:
- return .nightAgent
- case .morningPlanner:
- return .afternoonBurst
- case .afternoonBurst:
- return .morningPlanner
- case .eveningSprint:
- return .steadyCruise
- case .nightAgent:
- return .earlyStarter
- case .doublePeak:
- return .steadyCruise
- case .fragmented:
- return .oneShot
- case .oneShot:
- return .fragmented
- case .steadyCruise:
- return .doublePeak
- case .quietDay:
- return .morningPlanner
- }
- }
-
- private static func isDoublePeak(hourlyTokens: [Int], peakTokens: Int) -> Bool {
- guard peakTokens > 0 else { return false }
- let peaks = localPeakCandidates(hourlyTokens: hourlyTokens)
- .filter { Double($0.tokens) >= Double(peakTokens) * 0.45 }
- .sorted { $0.tokens > $1.tokens }
- .prefix(5)
- for left in peaks {
- for right in peaks where abs(left.hour - right.hour) >= 4 {
- return true
- }
- }
- return false
- }
-
- private static func localPeakCandidates(hourlyTokens: [Int]) -> [(hour: Int, tokens: Int)] {
- hourlyTokens.enumerated().compactMap { hour, tokens in
- guard tokens > 0 else { return nil }
- let previous = hour > 0 ? hourlyTokens[hour - 1] : 0
- let next = hour < hourlyTokens.count - 1 ? hourlyTokens[hour + 1] : 0
- guard tokens >= previous && tokens >= next else { return nil }
- return (hour, tokens)
- }
- }
-
- private static func tokens(in hours: [Int], hourlyTokens: [Int]) -> Int {
- hours.reduce(0) { total, hour in
- guard hourlyTokens.indices.contains(hour) else { return total }
- return total + hourlyTokens[hour]
- }
- }
-
- private static func share(_ value: Int, of total: Int) -> Double {
- guard total > 0 else { return 0 }
- return Double(value) / Double(total)
- }
-
- private static func significantTokenThreshold(totalTokens: Int, peakTokens: Int) -> Int {
- guard totalTokens > 0 else { return 1 }
- let totalBased = Double(totalTokens) * 0.03
- let peakBased = Double(peakTokens) * 0.30
- return max(1, Int(max(totalBased, peakBased).rounded()))
- }
-}
-
-private struct ModelKey: Hashable {
- var tool: String
- var model: String
-}
diff --git a/TokenStepSwift/Sources/TokenStepSwift/Stores/AppState.swift b/TokenStepSwift/Sources/TokenStepSwift/Stores/AppState.swift
index fae8eb2..958ac26 100644
--- a/TokenStepSwift/Sources/TokenStepSwift/Stores/AppState.swift
+++ b/TokenStepSwift/Sources/TokenStepSwift/Stores/AppState.swift
@@ -44,6 +44,7 @@ final class AppState: ObservableObject {
private var lastUsageObservedAt: Date?
private var ledgerSnapshot: UsageSnapshot = .empty
private var isRefreshingCursorUsage = false
+ private var timeZoneObserver: NSObjectProtocol?
init() {
load()
@@ -54,9 +55,22 @@ final class AppState: ObservableObject {
refreshTokenRank()
scheduleDeferredUpdateCheck()
configureUpdateCheckTimer()
+ timeZoneObserver = NotificationCenter.default.addObserver(
+ forName: .NSSystemTimeZoneDidChange,
+ object: nil,
+ queue: .main
+ ) { [weak self] _ in
+ Task { @MainActor in
+ LifecycleLogger.log("System time zone changed to \(TokenStepClock.identifier); recollecting usage.")
+ self?.refresh(forceCollection: true)
+ }
+ }
}
deinit {
+ if let timeZoneObserver {
+ NotificationCenter.default.removeObserver(timeZoneObserver)
+ }
timer?.invalidate()
foregroundTimer?.invalidate()
updateCheckTimer?.invalidate()
@@ -93,8 +107,7 @@ final class AppState: ObservableObject {
}
var monthAverage: Int {
- var calendar = Calendar(identifier: .gregorian)
- calendar.timeZone = TimeZone(identifier: "Asia/Shanghai") ?? .current
+ let calendar = TokenStepClock.calendar
let endDate = calendar.startOfDay(for: Date())
let values = (0..<30).map { offset -> Int in
guard let date = calendar.date(byAdding: .day, value: -offset, to: endDate) else {
@@ -385,8 +398,7 @@ final class AppState: ObservableObject {
func sevenDayAgentAverage(endingAt dateKey: String) -> Int {
guard let endDate = DateFormatter.tokenStepDay.date(from: dateKey) else { return 0 }
- var calendar = Calendar(identifier: .gregorian)
- calendar.timeZone = TimeZone(identifier: "Asia/Shanghai") ?? .current
+ let calendar = TokenStepClock.calendar
let total = (0..<7).reduce(0) { partial, offset in
guard let date = calendar.date(byAdding: .day, value: -offset, to: endDate) else {
return partial
@@ -1182,6 +1194,7 @@ final class AppState: ObservableObject {
enum UsageSnapshotRefreshReason: Equatable {
case accountingRevision
+ case timeZoneChanged
case missingModelBreakdown
case missingSnapshotTimestamp
case stale
@@ -1196,6 +1209,9 @@ enum UsageSnapshotRefreshPolicy {
if DataService.requiresImmediateCodexRecalibration(snapshot) {
return .accountingRevision
}
+ if !snapshot.daily.isEmpty, !TokenStepClock.matchesCurrent(snapshot.timezone) {
+ return .timeZoneChanged
+ }
if snapshot.daily.contains(where: { $0.totalTokens > 0 && $0.models.isEmpty }) {
return .missingModelBreakdown
}
diff --git a/TokenStepSwift/Sources/TokenStepSwift/Support/Formatters.swift b/TokenStepSwift/Sources/TokenStepSwift/Support/Formatters.swift
index 2997a2d..687c1f8 100644
--- a/TokenStepSwift/Sources/TokenStepSwift/Support/Formatters.swift
+++ b/TokenStepSwift/Sources/TokenStepSwift/Support/Formatters.swift
@@ -53,7 +53,7 @@ enum TokenStepFormat {
let formatter = DateFormatter()
formatter.calendar = Calendar(identifier: .gregorian)
formatter.locale = Locale(identifier: "en_US_POSIX")
- formatter.timeZone = TimeZone(identifier: "Asia/Shanghai") ?? .current
+ formatter.timeZone = TokenStepClock.timeZone
formatter.dateFormat = "yyyy-MM-dd HH:mm"
return formatter.string(from: date)
}
@@ -96,7 +96,7 @@ extension DateFormatter {
let formatter = DateFormatter()
formatter.calendar = Calendar(identifier: .gregorian)
formatter.locale = Locale(identifier: "en_US_POSIX")
- formatter.timeZone = TimeZone(identifier: "Asia/Shanghai") ?? .current
+ formatter.timeZone = TokenStepClock.timeZone
formatter.dateFormat = "yyyy-MM-dd"
return formatter
}()
diff --git a/TokenStepSwift/Sources/TokenStepSwift/Support/Localization.swift b/TokenStepSwift/Sources/TokenStepSwift/Support/Localization.swift
index 242f4d0..907c1c5 100644
--- a/TokenStepSwift/Sources/TokenStepSwift/Support/Localization.swift
+++ b/TokenStepSwift/Sources/TokenStepSwift/Support/Localization.swift
@@ -489,7 +489,12 @@ enum TokenStepLocalization {
"%d 小时前成功": "Succeeded %d h ago",
"历史范围": "History range",
"%d 天": "%d days",
- "联网项(全部默认关闭)": "Network items (all off by default)",
+ "联网项(除检查更新外默认关闭)": "Network items (off by default except update checks)",
+ "近似 · %d 会话": "Approx. · %d sessions",
+ "近似值(按会话开始日汇总)": "Approximate (totals by session start day)",
+ "版本": "Ver",
+ "GitHub Release · 默认开启,可在设置关闭": "GitHub Release · on by default, can be turned off in Settings",
+ "检测到本机 Token Rank 账号时读取公开榜单,不上传": "Reads the public board when a local Token Rank account exists; uploads nothing",
"所有新增源默认关闭": "New sources stay off by default",
"Cursor、GLM、Kimi、Grok 都需要你手动打开。": "Cursor, GLM, Kimi, and Grok must be turned on manually.",
"其他额度探针": "Other quota probes",
@@ -612,7 +617,7 @@ enum TokenStepLocalization {
"日期 · 模型名 · 客户端名 · token 计数": "Dates, model names, client names, token counts",
"prompt · 代码正文 · 对话内容": "Prompts, source code, conversation text",
"不开代理 · 不按字数估算 token": "No proxy, and no token estimates from word count",
- "默认不联网、不上传。Token 统计全部来自本机日志。": "No network and no upload by default. Token totals come from local logs.",
+ "不上传用量与内容。Token 统计全部来自本机日志。": "Usage and content are never uploaded. Token totals come from local logs.",
"开启后才会发起请求,逐项独立": "Requests start only after you turn each item on",
"Codex 额度": "Codex quota",
"Claude 额度": "Claude quota",
@@ -620,7 +625,6 @@ enum TokenStepLocalization {
"钥匙串 OAuth → Anthropic": "Keychain OAuth → Anthropic",
"state.vscdb token → cursor.com": "state.vscdb token → cursor.com",
"各自本机凭证": "Each provider's local credentials",
- "仅在开启后上报": "Uploaded only after you opt in",
"Cursor 明细": "Cursor details",
"L3 本地": "L3 local",
"额度走网络,代码产出纯本地,两者独立开关": "Quota uses the network; code output stays local. Separate toggles.",
@@ -1144,7 +1148,13 @@ enum TokenStepLocalization {
"日期 · 模型名 · 客户端名 · token 计数": "日期 · 模型名 · 用戶端名 · token 計數",
"prompt · 代码正文 · 对话内容": "prompt · 程式碼正文 · 對話內容",
"不开代理 · 不按字数估算 token": "不開代理 · 不按字數估算 token",
- "默认不联网、不上传。Token 统计全部来自本机日志。": "預設不連網、不上傳。Token 統計全部來自本機日誌。",
+ "不上传用量与内容。Token 统计全部来自本机日志。": "不上傳用量與內容。Token 統計全部來自本機日誌。",
+ "联网项(除检查更新外默认关闭)": "連網項(除檢查更新外預設關閉)",
+ "近似 · %d 会话": "近似 · %d 會話",
+ "近似值(按会话开始日汇总)": "近似值(按會話開始日彙總)",
+ "版本": "版本",
+ "GitHub Release · 默认开启,可在设置关闭": "GitHub Release · 預設開啟,可在設定關閉",
+ "检测到本机 Token Rank 账号时读取公开榜单,不上传": "偵測到本機 Token Rank 帳號時讀取公開榜單,不上傳",
"开启后才会发起请求,逐项独立": "開啟後才會發起請求,逐項獨立",
"Codex 额度": "Codex 額度",
"Claude 额度": "Claude 額度",
@@ -1152,7 +1162,6 @@ enum TokenStepLocalization {
"钥匙串 OAuth → Anthropic": "鑰匙圈 OAuth → Anthropic",
"state.vscdb token → cursor.com": "state.vscdb token → cursor.com",
"各自本机凭证": "各自本機憑證",
- "仅在开启后上报": "僅在開啟後上報",
"Cursor 明细": "Cursor 明細",
"L3 本地": "L3 本地",
"额度走网络,代码产出纯本地,两者独立开关": "額度走網路,程式產出純本地,兩者獨立開關",
diff --git a/TokenStepSwift/Sources/TokenStepSwift/Support/SQLiteReadonly.swift b/TokenStepSwift/Sources/TokenStepSwift/Support/SQLiteReadonly.swift
index dcc680a..fb61cac 100644
--- a/TokenStepSwift/Sources/TokenStepSwift/Support/SQLiteReadonly.swift
+++ b/TokenStepSwift/Sources/TokenStepSwift/Support/SQLiteReadonly.swift
@@ -1,35 +1,69 @@
import Foundation
+import SQLite3
enum SQLiteReadonly {
+ /// Runs one read-only query in process and returns rows shaped like
+ /// `sqlite3 -json` output after `JSONSerialization`: numbers as `NSNumber`,
+ /// text and blobs as `String`, NULL as `NSNull`. Returns nil if the database
+ /// cannot be opened or the query fails.
static func jsonRows(database: URL, query: String) -> [[String: Any]]? {
- let outputURL = FileManager.default.temporaryDirectory
- .appendingPathComponent("tokenstep-sqlite-\(UUID().uuidString).json")
- _ = FileManager.default.createFile(atPath: outputURL.path, contents: nil)
- guard let outputHandle = try? FileHandle(forWritingTo: outputURL) else {
+ var connection: OpaquePointer?
+ let flags = SQLITE_OPEN_READONLY | SQLITE_OPEN_NOMUTEX
+ guard sqlite3_open_v2(database.path, &connection, flags, nil) == SQLITE_OK,
+ let connection
+ else {
+ sqlite3_close(connection)
return nil
}
- defer {
- try? outputHandle.close()
- try? FileManager.default.removeItem(at: outputURL)
- }
-
- let process = Process()
- process.executableURL = URL(fileURLWithPath: "/usr/bin/sqlite3")
- process.arguments = ["-readonly", "-json", database.path, query]
- process.standardOutput = outputHandle
- process.standardError = Pipe()
+ defer { sqlite3_close(connection) }
+ // Other apps keep these databases open and may be mid-write.
+ sqlite3_busy_timeout(connection, 2_000)
- do {
- try process.run()
- process.waitUntilExit()
- } catch {
+ var statement: OpaquePointer?
+ guard sqlite3_prepare_v2(connection, query, -1, &statement, nil) == SQLITE_OK,
+ let statement
+ else {
+ sqlite3_finalize(statement)
return nil
}
- guard process.terminationStatus == 0 else { return nil }
+ defer { sqlite3_finalize(statement) }
+
+ let columnCount = sqlite3_column_count(statement)
+ let names = (0.. Any {
+ switch sqlite3_column_type(statement, column) {
+ case SQLITE_INTEGER:
+ return NSNumber(value: sqlite3_column_int64(statement, column))
+ case SQLITE_FLOAT:
+ return NSNumber(value: sqlite3_column_double(statement, column))
+ case SQLITE_TEXT, SQLITE_BLOB:
+ // Fetch the pointer before the length, as SQLite requires.
+ let bytes = sqlite3_column_blob(statement, column)
+ let count = Int(sqlite3_column_bytes(statement, column))
+ guard count > 0, let bytes else { return "" }
+ return String(decoding: UnsafeRawBufferPointer(start: bytes, count: count), as: UTF8.self)
+ default:
+ return NSNull()
+ }
}
static func scalar(_ value: Any?) -> Int {
diff --git a/TokenStepSwift/Sources/TokenStepSwift/Support/TokenStepClock.swift b/TokenStepSwift/Sources/TokenStepSwift/Support/TokenStepClock.swift
new file mode 100644
index 0000000..ac616b1
--- /dev/null
+++ b/TokenStepSwift/Sources/TokenStepSwift/Support/TokenStepClock.swift
@@ -0,0 +1,32 @@
+import Foundation
+
+/// The single source of truth for the time zone TokenStep uses to split usage into days.
+enum TokenStepClock {
+ /// Snapshots and caches written before the zone was recorded were always
+ /// computed in this zone, so a missing marker is read as this value.
+ static let legacyTimeZoneIdentifier = "Asia/Shanghai"
+
+ /// Follows the system zone, including changes while the app is running.
+ /// `TOKENSTEP_TIMEZONE` pins a zone for fixture checks and debugging.
+ static var timeZone: TimeZone {
+ if let override = ProcessInfo.processInfo.environment["TOKENSTEP_TIMEZONE"],
+ let zone = TimeZone(identifier: override) {
+ return zone
+ }
+ return .autoupdatingCurrent
+ }
+
+ static var identifier: String {
+ timeZone.identifier
+ }
+
+ static var calendar: Calendar {
+ var calendar = Calendar(identifier: .gregorian)
+ calendar.timeZone = timeZone
+ return calendar
+ }
+
+ static func matchesCurrent(_ storedIdentifier: String?) -> Bool {
+ (storedIdentifier ?? legacyTimeZoneIdentifier) == identifier
+ }
+}
diff --git a/TokenStepSwift/Sources/TokenStepSwift/Support/TokenStepSecrets.swift b/TokenStepSwift/Sources/TokenStepSwift/Support/TokenStepSecrets.swift
index a042bb7..38d6b50 100644
--- a/TokenStepSwift/Sources/TokenStepSwift/Support/TokenStepSecrets.swift
+++ b/TokenStepSwift/Sources/TokenStepSwift/Support/TokenStepSecrets.swift
@@ -38,21 +38,44 @@ enum TokenStepSecrets {
delete(account)
return
}
+ // Control characters would end the interactive command line early.
+ guard !trimmed.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) }) else {
+ return
+ }
+ // Feed the command through `security -i` on stdin so the secret never
+ // appears in argv, where any local process could read it via `ps`.
+ // Staying on the security CLI keeps existing keychain items readable
+ // without a new access prompt, since their ACL trusts /usr/bin/security.
+ let command = [
+ "add-generic-password", "-U",
+ "-s", interactiveQuoted(service),
+ "-a", interactiveQuoted(account.rawValue),
+ "-w", interactiveQuoted(trimmed)
+ ].joined(separator: " ") + "\n"
let process = Process()
+ let input = Pipe()
process.executableURL = URL(fileURLWithPath: "/usr/bin/security")
- process.arguments = [
- "add-generic-password",
- "-U",
- "-s", service,
- "-a", account.rawValue,
- "-w", trimmed
- ]
+ process.arguments = ["-i"]
+ process.standardInput = input
process.standardOutput = Pipe()
process.standardError = Pipe()
- try? process.run()
+ do {
+ try process.run()
+ } catch {
+ return
+ }
+ input.fileHandleForWriting.write(Data(command.utf8))
+ try? input.fileHandleForWriting.close()
process.waitUntilExit()
}
+ private static func interactiveQuoted(_ value: String) -> String {
+ let escaped = value
+ .replacingOccurrences(of: "\\", with: "\\\\")
+ .replacingOccurrences(of: "\"", with: "\\\"")
+ return "\"\(escaped)\""
+ }
+
static func delete(_ account: Account) {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/security")
diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/AgentWorkViews.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/AgentWorkViews.swift
index c0f2163..d132aa9 100644
--- a/TokenStepSwift/Sources/TokenStepSwift/Views/AgentWorkViews.swift
+++ b/TokenStepSwift/Sources/TokenStepSwift/Views/AgentWorkViews.swift
@@ -114,8 +114,7 @@ struct TodayAgentWorkCard: View {
}
private var trailingSevenDayWorks: [DailyAgentWork] {
- var calendar = Calendar(identifier: .gregorian)
- calendar.timeZone = TimeZone(identifier: "Asia/Shanghai") ?? .current
+ let calendar = TokenStepClock.calendar
let todayKey = DateFormatter.tokenStepDay.string(from: Date())
guard let today = DateFormatter.tokenStepDay.date(from: todayKey) else {
return [work]
diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/PrivacyView.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/PrivacyView.swift
index 00b9de7..95c8f6b 100644
--- a/TokenStepSwift/Sources/TokenStepSwift/Views/PrivacyView.swift
+++ b/TokenStepSwift/Sources/TokenStepSwift/Views/PrivacyView.swift
@@ -10,7 +10,7 @@ struct PrivacyView: View {
Text(L("本地优先"))
.font(.title3.weight(.heavy))
.foregroundStyle(Color.tokenInk)
- Text(L("默认不联网、不上传。Token 统计全部来自本机日志。"))
+ Text(L("不上传用量与内容。Token 统计全部来自本机日志。"))
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
PrivacyFactRow(title: L("读取"), value: L("日期 · 模型名 · 客户端名 · token 计数"))
@@ -35,17 +35,18 @@ struct PrivacyView: View {
HStack(alignment: .top, spacing: 13) {
TokenCard {
VStack(alignment: .leading, spacing: 10) {
- Text(L("联网项(全部默认关闭)"))
+ Text(L("联网项(除检查更新外默认关闭)"))
.font(.title3.weight(.heavy))
.foregroundStyle(Color.tokenInk)
Text(L("开启后才会发起请求,逐项独立"))
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
+ PrivacyNetworkRow(badge: L("版本"), style: .ok, title: L("检查更新"), detail: L("GitHub Release · 默认开启,可在设置关闭"))
PrivacyNetworkRow(badge: "L2", style: .l2, title: L("Codex 额度"), detail: L("本机 codex 登录态"))
PrivacyNetworkRow(badge: "L2", style: .l2, title: L("Claude 额度"), detail: L("钥匙串 OAuth → Anthropic"))
PrivacyNetworkRow(badge: "L2", style: .l2, title: L("Cursor 额度与官方用量"), detail: L("state.vscdb → cursor.com 事件计入圆环"))
PrivacyNetworkRow(badge: "L2", style: .l2, title: L("GLM / Kimi / Grok"), detail: L("各自本机凭证"))
- PrivacyNetworkRow(badge: L("榜"), style: .ok, title: L("消耗榜"), detail: L("仅在开启后上报"))
+ PrivacyNetworkRow(badge: L("榜"), style: .ok, title: L("消耗榜"), detail: L("检测到本机 Token Rank 账号时读取公开榜单,不上传"))
}
}
diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/Settings/SettingsDataSourcesCard.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/Settings/SettingsDataSourcesCard.swift
index 571a865..dbc1d35 100644
--- a/TokenStepSwift/Sources/TokenStepSwift/Views/Settings/SettingsDataSourcesCard.swift
+++ b/TokenStepSwift/Sources/TokenStepSwift/Views/Settings/SettingsDataSourcesCard.swift
@@ -130,15 +130,20 @@ struct SettingsDataSourcesPane: View {
if let info = appState.snapshot.sources[source.displayName] ?? appState.snapshot.sources[source.id],
(raw == "ok" || raw == "ok_sqlite"),
let records = info.records, records > 0 {
- return LFormat("正常 · %d 请求", records)
+ // The SQLite fallback only has per-session totals, dated by session start.
+ return raw == "ok_sqlite"
+ ? LFormat("近似 · %d 会话", records)
+ : LFormat("正常 · %d 请求", records)
}
return SourceStatusCopy.text(raw)
}
private func badgeStyle(for source: AgentSourceDescriptor) -> SettingsBadgeStyle {
switch rawStatus(for: source) {
- case "ok", "ok_sqlite", "available":
+ case "ok", "available":
return .ok
+ case "ok_sqlite":
+ return .warn
case "disabled":
return .off
case "missing", "missing_db", "empty":
@@ -224,7 +229,8 @@ enum AgentSourceCopy {
enum SourceStatusCopy {
static func text(_ status: String?) -> String {
switch status {
- case "ok", "ok_sqlite": return L("已读取")
+ case "ok": return L("已读取")
+ case "ok_sqlite": return L("近似值(按会话开始日汇总)")
case "available": return L("额度可用")
case "missing", "missing_db": return L("数据库未找到")
case "unreadable_db": return L("无法读取")
diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/Share/ShareRhythmCardView.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/Share/ShareRhythmCardView.swift
index b763e2b..6ee93b8 100644
--- a/TokenStepSwift/Sources/TokenStepSwift/Views/Share/ShareRhythmCardView.swift
+++ b/TokenStepSwift/Sources/TokenStepSwift/Views/Share/ShareRhythmCardView.swift
@@ -200,7 +200,7 @@ struct ShareRhythmCardView: View {
let formatter = DateFormatter()
formatter.calendar = Calendar(identifier: .gregorian)
formatter.locale = Locale(identifier: "en_US_POSIX")
- formatter.timeZone = TimeZone(identifier: "Asia/Shanghai") ?? .current
+ formatter.timeZone = TokenStepClock.timeZone
formatter.dateFormat = "yyyy.MM.dd"
guard let date = DateFormatter.tokenStepDay.date(from: day.date) else { return day.date }
return formatter.string(from: date)
@@ -211,7 +211,7 @@ struct ShareRhythmCardView: View {
let formatter = DateFormatter()
formatter.calendar = Calendar(identifier: .gregorian)
formatter.locale = TokenStepLocalization.locale
- formatter.timeZone = TimeZone(identifier: "Asia/Shanghai") ?? .current
+ formatter.timeZone = TokenStepClock.timeZone
formatter.dateFormat = TokenStepLocalization.language == .en ? "EEE" : "EEEE"
return formatter.string(from: date)
}
diff --git a/TokenStepSwift/Tests/Fixtures/ClaudeIncrementalFixtureCheck.swift b/TokenStepSwift/Tests/Fixtures/ClaudeIncrementalFixtureCheck.swift
new file mode 100644
index 0000000..1278a40
--- /dev/null
+++ b/TokenStepSwift/Tests/Fixtures/ClaudeIncrementalFixtureCheck.swift
@@ -0,0 +1,227 @@
+import Darwin
+import Foundation
+
+// Checks that resuming Claude Code transcripts after the last complete line gives
+// exactly the same accounting as re-reading the whole file.
+@main
+struct ClaudeIncrementalFixtureCheck {
+ static func main() {
+ do {
+ try checkGrowthWithPartialLines()
+ try checkStreamingDuplicateAcrossAppends()
+ try checkShorterRewriteIsRescanned()
+ try checkPrefixEditIsRescanned()
+ try checkMiddleEditNeedsFullValidation()
+ print("Claude incremental collector fixture checks passed")
+ } catch {
+ fputs("Claude incremental collector fixture failed: \(error)\n", stderr)
+ exit(1)
+ }
+ }
+
+ /// Appends a transcript in uneven chunks, many ending mid-line, and compares
+ /// the cached collection with a full re-read after every append.
+ private static func checkGrowthWithPartialLines() throws {
+ try withFixture("growth") { fixture in
+ var transcript = ""
+ for index in 0..<120 {
+ transcript += assistantLine(id: "msg_\(index)", minute: index, input: 100 + index, output: 10)
+ transcript += userLine(minute: index)
+ }
+ let bytes = Array(transcript.utf8)
+ var written = 0
+ var chunk = 7
+ while written < bytes.count {
+ let end = min(bytes.count, written + chunk)
+ try fixture.append(Data(bytes[written.. String {
+ var message: [String: Any] = [
+ "id": id,
+ "model": "claude-sonnet-4",
+ "usage": ["input_tokens": input, "output_tokens": output]
+ ]
+ if let stopReason {
+ message["stop_reason"] = stopReason
+ }
+ return jsonLine([
+ "type": "assistant",
+ "timestamp": String(format: "2026-07-13T%02d:%02d:00Z", 1 + minute / 60, minute % 60),
+ "sessionId": "fixture",
+ "message": message
+ ])
+ }
+
+ /// A non-assistant line that mentions usage, so it passes the byte pre-filter.
+ private static func userLine(minute: Int) -> String {
+ jsonLine([
+ "type": "user",
+ "timestamp": String(format: "2026-07-13T%02d:%02d:30Z", 1 + minute / 60, minute % 60),
+ "message": ["content": "please check token usage \(minute)"]
+ ])
+ }
+
+ private static func jsonLine(_ object: [String: Any]) -> String {
+ let data = try! JSONSerialization.data(withJSONObject: object, options: [.sortedKeys])
+ return String(decoding: data, as: UTF8.self) + "\n"
+ }
+
+ private static func withFixture(_ label: String, body: (Fixture) throws -> Void) throws {
+ let dir = URL(fileURLWithPath: "/tmp", isDirectory: true)
+ .appendingPathComponent("TokenStepClaude-\(label)-\(UUID().uuidString)", isDirectory: true)
+ defer { try? FileManager.default.removeItem(at: dir) }
+ let fixture = try Fixture(dir: dir)
+ do {
+ try body(fixture)
+ } catch {
+ throw FixtureError("[\(label)] \(error)")
+ }
+ }
+
+ static func expect(_ condition: Bool, _ message: String) throws {
+ if !condition { throw FixtureError(message) }
+ }
+}
+
+private struct Fixture {
+ let root: URL
+ let file: URL
+ let cache: URL
+
+ init(dir: URL) throws {
+ root = dir.appendingPathComponent("projects", isDirectory: true)
+ let project = root.appendingPathComponent("p", isDirectory: true)
+ try FileManager.default.createDirectory(at: project, withIntermediateDirectories: true)
+ file = project.appendingPathComponent("session.jsonl")
+ cache = dir.appendingPathComponent("collector-cache.json")
+ FileManager.default.createFile(atPath: file.path, contents: nil)
+ }
+
+ func append(_ text: String) throws {
+ try append(Data(text.utf8))
+ }
+
+ func append(_ data: Data) throws {
+ let handle = try FileHandle(forWritingTo: file)
+ defer { try? handle.close() }
+ try handle.seekToEnd()
+ try handle.write(contentsOf: data)
+ try bumpModificationTime()
+ }
+
+ func replace(_ text: String) throws {
+ try Data(text.utf8).write(to: file)
+ try bumpModificationTime()
+ }
+
+ /// Guarantees a distinct mtime so the unchanged-file shortcut never masks an edit.
+ private func bumpModificationTime() throws {
+ let stamp = Date().addingTimeInterval(Double.random(in: 1...1_000_000))
+ try FileManager.default.setAttributes([.modificationDate: stamp], ofItemAtPath: file.path)
+ }
+
+ func cachedSnapshot(forceFullValidation: Bool = false) throws -> UsageSnapshot {
+ UsageCollector.collectClaudeCodeWithCacheForTests(
+ rootURL: root,
+ cacheURL: cache,
+ forceFullValidation: forceFullValidation
+ )
+ }
+
+ func expectCachedMatchesFull(_ context: String) throws {
+ let cached = try signature(cachedSnapshot())
+ let full = try signature(UsageCollector.collectClaudeCodeUsageSnapshot(rootURL: root))
+ if cached != full {
+ let dump = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("tokenstep-claude-mismatch")
+ try? FileManager.default.createDirectory(at: dump, withIntermediateDirectories: true)
+ try? cached.write(to: dump.appendingPathComponent("cached.json"))
+ try? full.write(to: dump.appendingPathComponent("full.json"))
+ throw FixtureError("\(context): cached collection differs from full re-read (see \(dump.path))")
+ }
+ }
+
+ private func signature(_ snapshot: UsageSnapshot) throws -> Data {
+ var snapshot = snapshot
+ snapshot.generatedAt = nil
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.sortedKeys]
+ return try encoder.encode(snapshot)
+ }
+}
+
+private struct FixtureError: Error, CustomStringConvertible {
+ var description: String
+ init(_ description: String) { self.description = description }
+}
diff --git a/TokenStepSwift/Tests/Fixtures/SettingsCodableFixtureCheck.swift b/TokenStepSwift/Tests/Fixtures/SettingsCodableFixtureCheck.swift
new file mode 100644
index 0000000..612c086
--- /dev/null
+++ b/TokenStepSwift/Tests/Fixtures/SettingsCodableFixtureCheck.swift
@@ -0,0 +1,113 @@
+import Darwin
+import Foundation
+
+// `TokenStepSettings` has a hand-written Codable implementation because it carries
+// migrations from older settings files. Adding a field means updating the
+// property list, CodingKeys, defaults, init, init(from:) and encode(to:); a missed
+// step still compiles but silently drops the setting. These checks catch that.
+@main
+struct SettingsCodableFixtureCheck {
+ /// Every stored property differs from `TokenStepSettings.defaults`. When a new
+ /// setting is added, set it to a non-default value here.
+ static let sample = TokenStepSettings(
+ dailyGoalTokens: 250_000_000,
+ refreshIntervalSeconds: 900,
+ historyDays: 90,
+ theme: .eventHorizon,
+ autoUpdateEnabled: false,
+ askBeforeDownloadingUpdates: false,
+ requireVerifiedUpdates: false,
+ tokenIslandEnabled: true,
+ tokenIslandPlacement: .notchLeft,
+ enabledQuotaProviders: [.codex, .glm, .cursor],
+ cursorQuotaEnabled: true,
+ cursorCodeSignalEnabled: true,
+ agentWorkRankVisibility: .hidden,
+ showExperimentalAgentSources: true,
+ language: .en,
+ skippedUpdateVersion: "9.9.9",
+ classicTheme: .ocean,
+ odysseyChapter: .trojanInferno
+ )
+
+ static func main() {
+ do {
+ try checkSampleCoversEveryProperty()
+ try checkEveryPropertyIsEncoded()
+ try checkRoundTripKeepsEveryProperty()
+ try checkMissingKeysFallBackToDefaults()
+ print("Settings Codable fixture checks passed")
+ } catch {
+ fputs("Settings Codable fixture failed: \(error)\n", stderr)
+ exit(1)
+ }
+ }
+
+ private static func checkSampleCoversEveryProperty() throws {
+ let defaults = properties(TokenStepSettings.defaults)
+ let values = properties(sample)
+ let unchanged = defaults.keys.filter { defaults[$0] == values[$0] }.sorted()
+ try expect(
+ unchanged.isEmpty,
+ "sample keeps the default for \(unchanged); give each new setting a non-default sample value"
+ )
+ }
+
+ private static func checkEveryPropertyIsEncoded() throws {
+ let object = try JSONSerialization.jsonObject(with: JSONEncoder().encode(sample)) as? [String: Any] ?? [:]
+ let encodedKeys = Set(object.keys)
+ let missing = properties(sample).keys
+ .filter { !encodedKeys.contains(snakeCase($0)) }
+ .sorted()
+ try expect(missing.isEmpty, "not written by encode(to:): \(missing)")
+ }
+
+ private static func checkRoundTripKeepsEveryProperty() throws {
+ let decoded = try JSONDecoder().decode(TokenStepSettings.self, from: JSONEncoder().encode(sample))
+ let before = properties(sample)
+ let after = properties(decoded)
+ let lost = before.keys.filter { before[$0] != after[$0] }.sorted()
+ try expect(lost.isEmpty, "changed by encode then decode: \(lost.map { "\($0): \(before[$0]!) -> \(after[$0]!)" })")
+ }
+
+ private static func checkMissingKeysFallBackToDefaults() throws {
+ let decoded = try JSONDecoder().decode(TokenStepSettings.self, from: Data("{}".utf8))
+ let defaults = properties(TokenStepSettings.defaults)
+ let values = properties(decoded)
+ let differing = defaults.keys.filter { defaults[$0] != values[$0] }.sorted()
+ try expect(differing.isEmpty, "an empty settings file does not decode to defaults: \(differing)")
+ }
+
+ /// Stored properties by name, compared through a stable description.
+ private static func properties(_ settings: TokenStepSettings) -> [String: String] {
+ var result: [String: String] = [:]
+ for child in Mirror(reflecting: settings).children {
+ guard let label = child.label else { continue }
+ if let set = child.value as? Set {
+ result[label] = set.map(\.rawValue).sorted().description
+ } else {
+ result[label] = String(describing: child.value)
+ }
+ }
+ return result
+ }
+
+ private static func snakeCase(_ name: String) -> String {
+ name.reduce(into: "") { result, character in
+ if character.isUppercase {
+ result += "_" + character.lowercased()
+ } else {
+ result.append(character)
+ }
+ }
+ }
+
+ private static func expect(_ condition: Bool, _ message: String) throws {
+ if !condition { throw FixtureError(message) }
+ }
+}
+
+private struct FixtureError: Error, CustomStringConvertible {
+ var description: String
+ init(_ description: String) { self.description = description }
+}
diff --git a/TokenStepSwift/Tests/Fixtures/TimeZoneFixtureCheck.swift b/TokenStepSwift/Tests/Fixtures/TimeZoneFixtureCheck.swift
new file mode 100644
index 0000000..0a13f80
--- /dev/null
+++ b/TokenStepSwift/Tests/Fixtures/TimeZoneFixtureCheck.swift
@@ -0,0 +1,128 @@
+import Darwin
+import Foundation
+
+// Driven by script/test_time_zone_collector.sh, which runs one phase per process
+// because the collector fixes its zone for the life of the process.
+@main
+struct TimeZoneFixtureCheck {
+ // 2026-07-13T02:30:00Z is 10:30 on 07-13 in Shanghai and 19:30 on 07-12 in Los Angeles.
+ static let eventTimestamp = "2026-07-13T02:30:00Z"
+
+ static func main() {
+ let arguments = Array(CommandLine.arguments.dropFirst())
+ do {
+ guard let phase = arguments.first, arguments.count >= 2 else {
+ throw FixtureError("usage: [args]")
+ }
+ let dir = URL(fileURLWithPath: arguments[1], isDirectory: true)
+ switch phase {
+ case "write":
+ try writeSources(in: dir)
+ case "check":
+ guard arguments.count == 4, let hour = Int(arguments[3]) else {
+ throw FixtureError("check needs ")
+ }
+ try check(dir: dir, expectedDay: arguments[2], expectedHour: hour)
+ case "cache-generation":
+ let stats = UsageCollector.codexIncrementalCacheStatsForTests(
+ databaseURL: dir.appendingPathComponent("codex-incremental.sqlite3")
+ )
+ print(stats?.generation ?? -1)
+ case "json-cache":
+ let reusable = UsageCollector.collectorCacheIsReusableForTests(
+ cacheURL: dir.appendingPathComponent(arguments.count > 2 ? arguments[2] : "collector-cache.json")
+ )
+ print(reusable ? "reusable" : "discarded")
+ default:
+ throw FixtureError("unknown phase \(phase)")
+ }
+ } catch {
+ fputs("Time zone fixture failed: \(error)\n", stderr)
+ exit(1)
+ }
+ }
+
+ private static func check(dir: URL, expectedDay: String, expectedHour: Int) throws {
+ let zone = ProcessInfo.processInfo.environment["TOKENSTEP_TIMEZONE"] ?? "?"
+ let codex = UsageCollector.collectCodexUsageSnapshotForTests(
+ homeURL: dir.appendingPathComponent("home", isDirectory: true),
+ cacheURL: dir.appendingPathComponent("codex-incremental.sqlite3")
+ )
+ try expect(codex.sources["Codex"]?.status == "ok", "[\(zone)] codex status ok")
+ try expect(codex.timezone == zone, "[\(zone)] snapshot records zone, got \(codex.timezone ?? "nil")")
+ try expect(
+ codex.daily.map(\.date) == [expectedDay],
+ "[\(zone)] codex day \(expectedDay), got \(codex.daily.map(\.date))"
+ )
+ try expect(
+ codex.rhythms.first?.peakHour == expectedHour,
+ "[\(zone)] codex peak hour \(expectedHour), got \(String(describing: codex.rhythms.first?.peakHour))"
+ )
+
+ let claude = UsageCollector.collectClaudeCodeUsageSnapshot(
+ rootURL: dir.appendingPathComponent("home/.claude/projects", isDirectory: true)
+ )
+ try expect(
+ claude.daily.map(\.date) == [expectedDay],
+ "[\(zone)] claude day \(expectedDay), got \(claude.daily.map(\.date))"
+ )
+ print("PASS [\(zone)] day=\(expectedDay) hour=\(expectedHour)")
+ }
+
+ private static func writeSources(in dir: URL) throws {
+ let sessions = dir.appendingPathComponent("home/.codex/sessions/2026/07/13", isDirectory: true)
+ try FileManager.default.createDirectory(at: sessions, withIntermediateDirectories: true)
+ let usage: [String: Any] = [
+ "input_tokens": 800,
+ "output_tokens": 200,
+ "cached_input_tokens": 500,
+ "reasoning_output_tokens": 50,
+ "total_tokens": 1_000
+ ]
+ let codexLines: [[String: Any]] = [
+ ["type": "session_meta", "timestamp": "2026-07-13T02:29:00Z", "payload": ["id": "tz-session"]],
+ ["type": "turn_context", "timestamp": "2026-07-13T02:29:01Z", "payload": ["model": "gpt-5"]],
+ [
+ "type": "event_msg",
+ "timestamp": eventTimestamp,
+ "payload": [
+ "type": "token_count",
+ "info": ["total_token_usage": usage, "last_token_usage": usage]
+ ]
+ ]
+ ]
+ try write(codexLines, to: sessions.appendingPathComponent("tz.jsonl"))
+
+ let project = dir.appendingPathComponent("home/.claude/projects/tz", isDirectory: true)
+ try FileManager.default.createDirectory(at: project, withIntermediateDirectories: true)
+ let claudeLines: [[String: Any]] = [[
+ "type": "assistant",
+ "timestamp": eventTimestamp,
+ "sessionId": "tz-claude",
+ "message": [
+ "id": "msg_tz",
+ "model": "claude-sonnet-4",
+ "stop_reason": "end_turn",
+ "usage": ["input_tokens": 100, "output_tokens": 20]
+ ]
+ ]]
+ try write(claudeLines, to: project.appendingPathComponent("tz.jsonl"))
+ }
+
+ private static func write(_ objects: [[String: Any]], to url: URL) throws {
+ let lines = try objects.map { object -> String in
+ let data = try JSONSerialization.data(withJSONObject: object, options: [.sortedKeys])
+ return String(decoding: data, as: UTF8.self)
+ }
+ try (lines.joined(separator: "\n") + "\n").write(to: url, atomically: true, encoding: .utf8)
+ }
+
+ private static func expect(_ condition: Bool, _ message: String) throws {
+ if !condition { throw FixtureError(message) }
+ }
+}
+
+private struct FixtureError: Error, CustomStringConvertible {
+ var description: String
+ init(_ description: String) { self.description = description }
+}
diff --git a/TokenStepSwift/Tests/TokenStepSwiftTests/UsageSnapshotRefreshPolicyTests.swift b/TokenStepSwift/Tests/TokenStepSwiftTests/UsageSnapshotRefreshPolicyTests.swift
index 218794a..82d680e 100644
--- a/TokenStepSwift/Tests/TokenStepSwiftTests/UsageSnapshotRefreshPolicyTests.swift
+++ b/TokenStepSwift/Tests/TokenStepSwiftTests/UsageSnapshotRefreshPolicyTests.swift
@@ -61,10 +61,32 @@ final class UsageSnapshotRefreshPolicyTests: XCTestCase {
)
}
- private func makeSnapshot(accountingRevision: Int?, records: Int) -> UsageSnapshot {
+ func testSnapshotFromAnotherTimeZoneRecollects() {
+ let otherZone = TokenStepClock.identifier == "Pacific/Chatham" ? "Pacific/Marquesas" : "Pacific/Chatham"
+ let snapshot = makeSnapshot(
+ accountingRevision: UsageCollector.codexAccountingRevision,
+ records: 1,
+ timezone: otherZone
+ )
+
+ XCTAssertEqual(
+ UsageSnapshotRefreshPolicy.reason(
+ snapshot: snapshot,
+ refreshIntervalSeconds: 0,
+ now: now
+ ),
+ .timeZoneChanged
+ )
+ }
+
+ private func makeSnapshot(
+ accountingRevision: Int?,
+ records: Int,
+ timezone: String = TokenStepClock.identifier
+ ) -> UsageSnapshot {
UsageSnapshot(
generatedAt: ISO8601DateFormatter().string(from: now),
- timezone: "Asia/Shanghai",
+ timezone: timezone,
totals: UsageTotals(tokens: 100, cost: 0, activeDays: 1),
daily: [
DailyUsage(
diff --git a/docs/AGENT_SUPPORT.md b/docs/AGENT_SUPPORT.md
index 767796d..640f67a 100644
--- a/docs/AGENT_SUPPORT.md
+++ b/docs/AGENT_SUPPORT.md
@@ -6,7 +6,7 @@ TokenStep 的原则是:能从本地日志中稳定读到 token 数,才进入
| Agent | 状态 | 数据来源 | 说明 |
| --- | --- | --- | --- |
-| Codex | 已支持 | `~/.codex/sessions` / `~/.codex/archived_sessions`,必要时回退 SQLite | 读取本地 token_count 事件,只统计数量;可选读取 5h / 7d 额度。 |
+| Codex | 已支持 | `~/.codex/sessions`,必要时回退 `~/.codex/state_5.sqlite`(回退时只有按会话汇总的近似值) | 读取本地 token_count 事件,只统计数量;可选读取 5h / 7d 额度。`archived_sessions` 可能是时间戳被改写的恢复日志,不计入。 |
| Claude Code | 已支持 | `~/.claude/projects` | 读取 assistant message 的 usage 字段,按 `message.id` 去重,避免 thinking / text / tool_use 多行重复累计;可选通过 Claude Code 本机钥匙串凭证读取 usage 额度。 |
## 实验支持:CC Switch Proxy
diff --git a/docs/PRD-0.2.0.md b/docs/PRD-0.2.0.md
index b7cb339..b9df416 100644
--- a/docs/PRD-0.2.0.md
+++ b/docs/PRD-0.2.0.md
@@ -45,6 +45,12 @@ UsageSnapshotRefreshPolicyTests.swift UsageCollectorExperimentalAgentTests.sw
### 0.3 已核实的代码事实(引用这些,不要凭记忆)
+> **当前代码位置**:下表是 0.1.48 时的快照,文件和行号已过时。
+> - `UsageCollector.swift` 已按职责拆到 `Services/Collector/`:`UsageCollector.swift`(入口与测试入口)、`+Codex`、`+Claude`、`+AgentSources`(CC Switch / ZCode / Hermes / WorkBuddy)、`+Dedupe`、`+Aggregate`、`+Cache`、`+Parsing`、`+Pricing`(单价规则表 `priceRules`)、`CodexIncrementalStore.swift`、`UsageCollectorModels.swift`。
+> - `sqliteJSONRows` 现在通过 `SQLiteReadonly` 在进程内以只读方式打开数据库(SQLite C API),不再启动 `sqlite3` 子进程。
+> - 日期切分使用 `TokenStepClock`(系统时区),不再固定为 `Asia/Shanghai`。
+> - 0.4 节的 6 处同步由 `script/test_settings_codable.sh` 自动检查。
+
动手前这些都在 0.1.48 真机核对过。**行号是 0.1.48 的,改完代码会漂移,认符号名不认行号。**
| 事实 | 位置 |
diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md
index 5f8a631..9ae9190 100644
--- a/docs/PRIVACY.md
+++ b/docs/PRIVACY.md
@@ -11,22 +11,25 @@ TokenStep reads local metadata from supported agent logs:
- client name
- token usage counts
-## What TokenStep Does Not Do
+It does not read prompts, code, conversation text, or project files, and it never uploads usage data or content.
-TokenStep does not upload anything by default.
+## Network Requests
-TokenStep does not need to send your code, prompts, or conversation text to any server.
+Token totals are always computed locally. The app makes only the requests listed below. Each one says when it happens and what it sends.
-## Optional Quota Display
+| Request | Destination | On by default | When | What is sent |
+| --- | --- | --- | --- | --- |
+| Update check | `api.github.com/repos/Backtthefuture/TokenStep/releases/latest`, falling back to `github.com/.../releases/latest` | Yes (Settings → Check for updates automatically) | At launch, periodically, when a window comes to the front, and when you click Check Updates | A `User-Agent` header with the app version. No usage data. |
+| Update download | `github.com/Backtthefuture/TokenStep/releases/download/...` | Only after you confirm an update | When you choose Install | Nothing beyond a standard download request. |
+| Token Rank board | `www.zhenganhuo.com/api/token-rank/leaderboard.php` | Automatic: only if a local Token Rank account exists in `~/.token-rank/client-state.json`; can be hidden in Settings | At launch, with background refreshes, and when a window comes to the front; at most every 30 minutes | A read-only request with the board filters (`client`, `range`, `usage_mode`). TokenStep uploads nothing. Uploading to the board is done by the separate Token Rank client, not by TokenStep. |
+| Codex quota | Runs the local `codex app-server`, which talks to OpenAI with your Codex login | No | When quota display is on, at most every 15 minutes | Handled by the Codex CLI. TokenStep only reads the rate-limit response. |
+| Claude Code quota | `api.anthropic.com/api/oauth/usage` | No | When quota display is on, at most every 15 minutes | Your Claude Code OAuth access token, read from the macOS Keychain item Claude Code created. |
+| Cursor quota and usage events | `cursor.com/api/usage-summary`, `cursor.com/api/usage`, `cursor.com/api/dashboard/get-filtered-usage-events` (fallback `api2.cursor.sh`) | No | When Cursor is turned on, at most every 15 minutes | Your Cursor session token, read from Cursor's local `state.vscdb`. It is kept in memory only. |
+| GLM quota | `open.bigmodel.cn` or `api.z.ai` usage endpoints | No | When GLM is turned on | The API key you entered. It is stored in the macOS Keychain. |
+| Kimi quota | `api.kimi.com` or `www.kimi.com` usage endpoints | No | When Kimi is turned on | The token from the local Kimi CLI login (`~/.kimi`), or the token you entered, which is stored in the macOS Keychain. |
+| Grok quota | `cli-chat-proxy.grok.com/v1/billing` | No | When Grok is turned on | The session from the local Grok CLI login (`~/.grok/auth.json`), or the token you entered, which is stored in the macOS Keychain. |
-The Agent quota display is off by default.
-
-When enabled, TokenStep may read local account metadata needed by supported tools:
-
-- Codex quota is read from the local Codex account/rate limit interface.
-- Claude Code quota is read by using the local macOS Keychain item for Claude Code and requesting Anthropic's OAuth usage endpoint.
-
-TokenStep uses this only to show remaining quota. The account token is not stored by TokenStep and is not uploaded to a TokenStep server.
+Credentials are used only for the provider they belong to. They are never sent to a TokenStep server, because there is no TokenStep server.
## Local Files
@@ -36,7 +39,11 @@ Generated app data is stored at:
~/Library/Application Support/TokenStep
```
-This folder contains settings, token summaries, and login item logs.
+This folder contains settings, token summaries, collector caches, and lifecycle logs. You can reveal or clear it from Settings.
+
+## Time Zone
+
+Usage is split into days using your Mac's current time zone. When the time zone changes, TokenStep recollects local logs so the day boundaries stay correct.
## Cost Estimates
@@ -44,4 +51,4 @@ The "spend" value is a rough local estimate based on bundled pricing assumptions
## Future Sync or Ranking Features
-If TokenStep later adds cloud sync or public ranking, it should be opt-in and should require a separate confirmation before uploading any data.
+If TokenStep later adds cloud sync or uploads to a public ranking, it will be opt-in and will require a separate confirmation before any data is uploaded.
diff --git a/docs/RELEASE.md b/docs/RELEASE.md
index 995ecab..5051da2 100644
--- a/docs/RELEASE.md
+++ b/docs/RELEASE.md
@@ -76,6 +76,12 @@ The packaging command already runs all of these gates and fails before producing
## Publish to GitHub
+Before the release commit, update the release documents (the Release workflow checks the first and last items):
+
+- Add `docs/RELEASE_NOTES_.md` starting with `# TokenStep `.
+- Add the version's summary at the top of `CHANGELOG.md`.
+- Update the "最新版本" section and the DMG download links in `README.md` to `TokenStep-.dmg`.
+
1. Merge the release commit to `main` and wait for CI.
2. Run the repository's `Release` workflow from `main` with the exact version.
3. The workflow creates a draft and uploads the notarized DMG, ZIP, and checksum file.
diff --git a/docs/RELEASE_NOTES_0.2.15.md b/docs/RELEASE_NOTES_0.2.15.md
new file mode 100644
index 0000000..848c20c
--- /dev/null
+++ b/docs/RELEASE_NOTES_0.2.15.md
@@ -0,0 +1,11 @@
+# TokenStep 0.2.15
+
+按你所在地的时间统计每天的用量,同时修复一个可能让采集卡住的问题,并让后台采集更省电。
+
+- **按本地时间分日**:以前固定按北京时间切分每天,身处其他时区时,「今天」会在当地非午夜的时刻换日。现在跟随 Mac 的系统时区;更换时区后会自动重新统计。时区不在北京时间的用户,升级后各天的数字会按本地午夜重新划分。
+- **修复采集卡住**:Codex 在极少数情况下需要从本地数据库回退统计,数据量较大时采集会卡住直到超时。现已修复,此时的数值在设置里标注为「近似值」。
+- **更省电**:Claude Code 长会话只读取新增内容,不再每次整份重读;读取 CC Switch 等本地数据库不再启动额外进程。
+- **更安全**:在设置中填写的 GLM / Kimi / Grok 密钥,保存时不再出现在系统进程列表里。
+- **隐私说明更准确**:隐私页和隐私文档列出了 TokenStep 发起的每一个联网请求。检查更新默认开启;消耗榜只读取公开榜单,TokenStep 不上传任何用量。
+
+同一时区下,Token、金额与额度的统计口径不变。
diff --git a/legacy/README.md b/legacy/README.md
new file mode 100644
index 0000000..9aa8d20
--- /dev/null
+++ b/legacy/README.md
@@ -0,0 +1,10 @@
+# Legacy Python implementation
+
+This folder holds the original Python prototype of TokenStep: a collector script
+(`token_usage_monitor.py`) run by launchd, and a PyObjC menu bar app
+(`TokenUsageMenuApp/`). It has been replaced by the native Swift app in
+`TokenStepSwift/` and is no longer built, tested, or released.
+
+It is kept for reference only. Paths inside it are relative to this folder, and
+the icon assets it uses stay in `../TokenUsageMenuApp/assets`, which the Swift
+app also bundles.
diff --git a/TokenUsageMenuApp/README.md b/legacy/TokenUsageMenuApp/README.md
similarity index 100%
rename from TokenUsageMenuApp/README.md
rename to legacy/TokenUsageMenuApp/README.md
diff --git a/TokenUsageMenuApp/TokenUsageMenu.py b/legacy/TokenUsageMenuApp/TokenUsageMenu.py
similarity index 100%
rename from TokenUsageMenuApp/TokenUsageMenu.py
rename to legacy/TokenUsageMenuApp/TokenUsageMenu.py
diff --git a/TokenUsageMenuApp/build_app.sh b/legacy/TokenUsageMenuApp/build_app.sh
similarity index 92%
rename from TokenUsageMenuApp/build_app.sh
rename to legacy/TokenUsageMenuApp/build_app.sh
index f5b9206..b4e2d8a 100755
--- a/TokenUsageMenuApp/build_app.sh
+++ b/legacy/TokenUsageMenuApp/build_app.sh
@@ -10,7 +10,8 @@ APP_BUNDLE="$DIST_DIR/$APP_NAME.app"
CONTENTS="$APP_BUNDLE/Contents"
MACOS="$CONTENTS/MacOS"
RESOURCES="$CONTENTS/Resources"
-ICON_FILE="$APP_DIR/assets/TokenStepIcon.icns"
+# Icon assets are shared with the Swift app and stay in the repository root.
+ICON_FILE="$ROOT_DIR/../TokenUsageMenuApp/assets/TokenStepIcon.icns"
PYTHON="/opt/homebrew/opt/python@3.14/bin/python3.14"
if [ ! -x "$PYTHON" ]; then
diff --git a/TokenUsageMenuApp/render_icon.py b/legacy/TokenUsageMenuApp/render_icon.py
similarity index 100%
rename from TokenUsageMenuApp/render_icon.py
rename to legacy/TokenUsageMenuApp/render_icon.py
diff --git a/script/build_pyobjc_and_run.sh b/legacy/build_pyobjc_and_run.sh
similarity index 89%
rename from script/build_pyobjc_and_run.sh
rename to legacy/build_pyobjc_and_run.sh
index c7bb122..78e7eb9 100755
--- a/script/build_pyobjc_and_run.sh
+++ b/legacy/build_pyobjc_and_run.sh
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
-ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
APP_BUILDER="$ROOT_DIR/TokenUsageMenuApp/build_app.sh"
APP_BUNDLE="$ROOT_DIR/TokenUsageMenuApp/dist/TokenStep.app"
diff --git a/config/pricing.json b/legacy/config/pricing.json
similarity index 100%
rename from config/pricing.json
rename to legacy/config/pricing.json
diff --git a/install-launchd.sh b/legacy/install-launchd.sh
similarity index 100%
rename from install-launchd.sh
rename to legacy/install-launchd.sh
diff --git a/token_usage_monitor.py b/legacy/token_usage_monitor.py
similarity index 100%
rename from token_usage_monitor.py
rename to legacy/token_usage_monitor.py
diff --git a/uninstall-launchd.sh b/legacy/uninstall-launchd.sh
similarity index 100%
rename from uninstall-launchd.sh
rename to legacy/uninstall-launchd.sh
diff --git a/script/audit_codex_accounting.py b/script/audit_codex_accounting.py
index ca46431..0202bcb 100755
--- a/script/audit_codex_accounting.py
+++ b/script/audit_codex_accounting.py
@@ -7,7 +7,7 @@
- The frozen copy preserves relative session paths and verbatim relevant JSONL lines.
- TokenStep's production cache/App Support and installed/running app are never touched.
-The Python implementation intentionally mirrors the current UsageCollector.swift
+The Python implementation intentionally mirrors the current Swift collector (Services/Collector/)
accounting revision. By default the script also compiles a temporary Swift harness from
the current working tree, runs the real collector twice against the frozen copy, and
checks the two implementations agree.
@@ -1034,10 +1034,13 @@ def compile_and_run_swift(repo_root: Path, output_root: Path, frozen_home: Path)
swift_dir = repo_root / "TokenStepSwift"
source_paths = [
swift_dir / "Sources/TokenStepSwift/Support/AppPaths.swift",
+ swift_dir / "Sources/TokenStepSwift/Support/TokenStepClock.swift",
swift_dir / "Sources/TokenStepSwift/Support/Localization.swift",
swift_dir / "Sources/TokenStepSwift/Support/Theme.swift",
+ swift_dir / "Sources/TokenStepSwift/Support/SQLiteReadonly.swift",
+ swift_dir / "Sources/TokenStepSwift/Models/QuotaModels.swift",
swift_dir / "Sources/TokenStepSwift/Models/UsageModels.swift",
- swift_dir / "Sources/TokenStepSwift/Services/UsageCollector.swift",
+ *sorted((swift_dir / "Sources/TokenStepSwift/Services/Collector").glob("*.swift")),
]
for source in source_paths:
if not source.is_file():
diff --git a/script/benchmark_energy_efficiency.sh b/script/benchmark_energy_efficiency.sh
index bfe0407..7c7dabd 100755
--- a/script/benchmark_energy_efficiency.sh
+++ b/script/benchmark_energy_efficiency.sh
@@ -1,6 +1,10 @@
#!/usr/bin/env bash
set -euo pipefail
+# Fixture expectations are written in this zone; pin it so results do not
+# depend on the machine running the check.
+export TOKENSTEP_TIMEZONE="Asia/Shanghai"
+
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SWIFT_DIR="$ROOT_DIR/TokenStepSwift"
BUILD_DIR="${TMPDIR:-/tmp}/tokenstep-energy-benchmark-$UID"
@@ -40,12 +44,13 @@ swiftc \
-Xcc "$OVERLAY_FILE" \
-parse-as-library \
"$SWIFT_DIR/Sources/TokenStepSwift/Support/AppPaths.swift" \
+ "$SWIFT_DIR/Sources/TokenStepSwift/Support/TokenStepClock.swift" \
"$SWIFT_DIR/Sources/TokenStepSwift/Support/Localization.swift" \
"$SWIFT_DIR/Sources/TokenStepSwift/Support/Theme.swift" \
"$SWIFT_DIR/Sources/TokenStepSwift/Support/SQLiteReadonly.swift" \
"$SWIFT_DIR/Sources/TokenStepSwift/Models/QuotaModels.swift" \
"$SWIFT_DIR/Sources/TokenStepSwift/Models/UsageModels.swift" \
- "$SWIFT_DIR/Sources/TokenStepSwift/Services/UsageCollector.swift" \
+ "$SWIFT_DIR/Sources/TokenStepSwift/Services/Collector/"*.swift \
"$SWIFT_DIR/Tests/Fixtures/EnergyEfficiencyBenchmark.swift" \
-o "$EXECUTABLE"
diff --git a/script/build_swiftui_and_run.sh b/script/build_swiftui_and_run.sh
index f020ca0..d9ad892 100755
--- a/script/build_swiftui_and_run.sh
+++ b/script/build_swiftui_and_run.sh
@@ -23,7 +23,7 @@ HELPER_EXECUTABLE="$BUILD_DIR/$HELPER_NAME"
ICON_FILE="$ROOT_DIR/TokenUsageMenuApp/assets/TokenStepIcon.icns"
ODYSSEY_ASSET_DIR="$ROOT_DIR/TokenUsageMenuApp/assets/odyssey"
INTERSTELLAR_ASSET_DIR="$ROOT_DIR/TokenUsageMenuApp/assets/interstellar"
-VERSION="${TOKENSTEP_VERSION:-0.2.14}"
+VERSION="${TOKENSTEP_VERSION:-0.2.15}"
LAUNCH=true
VERIFY=false
@@ -95,13 +95,14 @@ fi
HELPER_SOURCES=(
"$SWIFT_DIR/Sources/TokenStepSwift/Support/AppPaths.swift"
+ "$SWIFT_DIR/Sources/TokenStepSwift/Support/TokenStepClock.swift"
"$SWIFT_DIR/Sources/TokenStepSwift/Support/Localization.swift"
"$SWIFT_DIR/Sources/TokenStepSwift/Support/MemoryPressure.swift"
"$SWIFT_DIR/Sources/TokenStepSwift/Support/Theme.swift"
"$SWIFT_DIR/Sources/TokenStepSwift/Support/SQLiteReadonly.swift"
"$SWIFT_DIR/Sources/TokenStepSwift/Models/QuotaModels.swift"
"$SWIFT_DIR/Sources/TokenStepSwift/Models/UsageModels.swift"
- "$SWIFT_DIR/Sources/TokenStepSwift/Services/UsageCollector.swift"
+ "$SWIFT_DIR/Sources/TokenStepSwift/Services/Collector/"*.swift
"$SWIFT_DIR/Sources/TokenStepSwift/Services/DataService.swift"
"$SWIFT_DIR/Sources/TokenStepHelper/main.swift"
)
diff --git a/script/test_all.sh b/script/test_all.sh
new file mode 100755
index 0000000..e6c4de1
--- /dev/null
+++ b/script/test_all.sh
@@ -0,0 +1,45 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# One entry point for every local check. Fixture checks only need swiftc, so
+# they run with Command Line Tools alone. The XCTest suite needs a full Xcode,
+# because Command Line Tools do not ship XCTest.
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+
+# Test expectations encode day boundaries in this zone.
+export TOKENSTEP_TIMEZONE="Asia/Shanghai"
+
+FIXTURE_CHECKS=(
+ test_release_contract
+ test_ccswitch_proxy_collector
+ test_codex_cumulative_collector
+ test_usage_recalibration_migration
+ test_time_zone_collector
+ test_claude_incremental_collector
+ test_settings_codable
+)
+
+failed=()
+for check in "${FIXTURE_CHECKS[@]}"; do
+ echo "==> $check"
+ if ! "$ROOT_DIR/script/$check.sh"; then
+ failed+=("$check")
+ fi
+done
+
+if xcrun --find xctest >/dev/null 2>&1; then
+ echo "==> swift test"
+ if ! swift test --package-path "$ROOT_DIR/TokenStepSwift"; then
+ failed+=("swift test")
+ fi
+else
+ echo "==> swift test skipped: XCTest needs a full Xcode (Command Line Tools do not include it)."
+ echo " Install Xcode and run: sudo xcode-select -s /Applications/Xcode.app"
+fi
+
+if ((${#failed[@]} > 0)); then
+ echo "Failed: ${failed[*]}" >&2
+ exit 1
+fi
+echo "All available checks passed."
diff --git a/script/test_ccswitch_proxy_collector.sh b/script/test_ccswitch_proxy_collector.sh
index 77ecb9c..8411751 100755
--- a/script/test_ccswitch_proxy_collector.sh
+++ b/script/test_ccswitch_proxy_collector.sh
@@ -1,6 +1,10 @@
#!/usr/bin/env bash
set -euo pipefail
+# Fixture expectations are written in this zone; pin it so results do not
+# depend on the machine running the check.
+export TOKENSTEP_TIMEZONE="Asia/Shanghai"
+
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SWIFT_DIR="$ROOT_DIR/TokenStepSwift"
BUILD_DIR="$SWIFT_DIR/.build/ccswitch-fixture"
@@ -39,13 +43,14 @@ swiftc \
-Xcc "$OVERLAY_FILE" \
-parse-as-library \
"$SWIFT_DIR/Sources/TokenStepSwift/Support/AppPaths.swift" \
+ "$SWIFT_DIR/Sources/TokenStepSwift/Support/TokenStepClock.swift" \
"$SWIFT_DIR/Sources/TokenStepSwift/Support/Localization.swift" \
"$SWIFT_DIR/Sources/TokenStepSwift/Support/Theme.swift" \
"$SWIFT_DIR/Sources/TokenStepSwift/Support/SQLiteReadonly.swift" \
"$SWIFT_DIR/Sources/TokenStepSwift/Models/QuotaModels.swift" \
"$SWIFT_DIR/Sources/TokenStepSwift/Models/UsageModels.swift" \
"$SWIFT_DIR/Sources/TokenStepSwift/Services/TokenRankService.swift" \
- "$SWIFT_DIR/Sources/TokenStepSwift/Services/UsageCollector.swift" \
+ "$SWIFT_DIR/Sources/TokenStepSwift/Services/Collector/"*.swift \
"$SWIFT_DIR/Tests/Fixtures/CCSwitchProxyFixtureCheck.swift" \
-o "$EXECUTABLE"
diff --git a/script/test_claude_incremental_collector.sh b/script/test_claude_incremental_collector.sh
new file mode 100755
index 0000000..10502fa
--- /dev/null
+++ b/script/test_claude_incremental_collector.sh
@@ -0,0 +1,63 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# Verifies that resuming Claude Code transcripts after the last complete line
+# matches a full re-read, including partial lines and rewritten files.
+
+# Fixture expectations are written in this zone.
+export TOKENSTEP_TIMEZONE="Asia/Shanghai"
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+SWIFT_DIR="$ROOT_DIR/TokenStepSwift"
+BUILD_DIR="/tmp/tokenstep-claude-incremental-fixture-$UID-$$"
+OVERLAY_DIR="$BUILD_DIR/vfs-overlay"
+OVERLAY_FILE="$OVERLAY_DIR/overlay.yaml"
+EMPTY_MODULEMAP="$OVERLAY_DIR/empty.modulemap"
+EXECUTABLE="$BUILD_DIR/claude-incremental-fixture-check"
+
+cleanup() {
+ rm -rf "$BUILD_DIR"
+}
+trap cleanup EXIT
+
+mkdir -p "$BUILD_DIR" "$OVERLAY_DIR"
+cat > "$EMPTY_MODULEMAP" <<'EOF'
+// Intentionally empty.
+EOF
+cat > "$OVERLAY_FILE" </dev/null 2>&1; then
-emit-module \
-module-name TokenStepSwift \
"$SWIFT_DIR/Sources/TokenStepSwift/Support/AppPaths.swift" \
+ "$SWIFT_DIR/Sources/TokenStepSwift/Support/TokenStepClock.swift" \
"$SWIFT_DIR/Sources/TokenStepSwift/Support/Localization.swift" \
"$SWIFT_DIR/Sources/TokenStepSwift/Support/Theme.swift" \
"$SWIFT_DIR/Sources/TokenStepSwift/Support/SQLiteReadonly.swift" \
"$SWIFT_DIR/Sources/TokenStepSwift/Models/QuotaModels.swift" \
"$SWIFT_DIR/Sources/TokenStepSwift/Models/UsageModels.swift" \
- "$SWIFT_DIR/Sources/TokenStepSwift/Services/UsageCollector.swift" \
+ "$SWIFT_DIR/Sources/TokenStepSwift/Services/Collector/"*.swift \
-emit-module-path "$MODULE_DIR/TokenStepSwift.swiftmodule"
swiftc \
diff --git a/script/test_settings_codable.sh b/script/test_settings_codable.sh
new file mode 100755
index 0000000..806fc5a
--- /dev/null
+++ b/script/test_settings_codable.sh
@@ -0,0 +1,63 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# Guards the hand-written TokenStepSettings Codable implementation against
+# settings that are declared but not encoded or decoded.
+
+# Fixture expectations are written in this zone.
+export TOKENSTEP_TIMEZONE="Asia/Shanghai"
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+SWIFT_DIR="$ROOT_DIR/TokenStepSwift"
+BUILD_DIR="/tmp/tokenstep-settings-codable-fixture-$UID-$$"
+OVERLAY_DIR="$BUILD_DIR/vfs-overlay"
+OVERLAY_FILE="$OVERLAY_DIR/overlay.yaml"
+EMPTY_MODULEMAP="$OVERLAY_DIR/empty.modulemap"
+EXECUTABLE="$BUILD_DIR/settings-codable-fixture-check"
+
+cleanup() {
+ rm -rf "$BUILD_DIR"
+}
+trap cleanup EXIT
+
+mkdir -p "$BUILD_DIR" "$OVERLAY_DIR"
+cat > "$EMPTY_MODULEMAP" <<'EOF'
+// Intentionally empty.
+EOF
+cat > "$OVERLAY_FILE" < "$EMPTY_MODULEMAP" <<'EOF'
+// Intentionally empty.
+EOF
+cat > "$OVERLAY_FILE" <&2
+ exit 1
+}
+
+SH="Asia/Shanghai"
+LA="America/Los_Angeles"
+IN="Asia/Kolkata"
+DB="$DATA_DIR/codex-incremental.sqlite3"
+
+in_zone "$SH" write "$DATA_DIR"
+
+# Day and hour follow the zone, and the Codex incremental cache is rebuilt on
+# every switch (a stale cache would keep the previous zone's day key).
+in_zone "$SH" check "$DATA_DIR" 2026-07-13 10
+in_zone "$LA" check "$DATA_DIR" 2026-07-12 19
+in_zone "$IN" check "$DATA_DIR" 2026-07-13 8
+in_zone "$SH" check "$DATA_DIR" 2026-07-13 10
+
+# A cache from before the zone marker existed was built in Shanghai: it stays
+# valid there and gains a marker, but is rebuilt anywhere else.
+/usr/bin/sqlite3 "$DB" "DELETE FROM cache_meta WHERE key = 'time_zone'"
+before="$(in_zone "$SH" cache-generation "$DATA_DIR")"
+in_zone "$SH" check "$DATA_DIR" 2026-07-13 10
+after="$(in_zone "$SH" cache-generation "$DATA_DIR")"
+[[ "$before" == "$after" ]] || fail "legacy Shanghai cache was rebuilt (generation $before -> $after)"
+marker="$(/usr/bin/sqlite3 "$DB" "SELECT value FROM cache_meta WHERE key = 'time_zone'")"
+[[ "$marker" == "$SH" ]] || fail "legacy cache did not gain a zone marker (got '$marker')"
+echo "PASS legacy cache kept in Shanghai and marked"
+
+/usr/bin/sqlite3 "$DB" "DELETE FROM cache_meta WHERE key = 'time_zone'"
+in_zone "$LA" check "$DATA_DIR" 2026-07-12 19
+echo "PASS legacy cache rebuilt outside Shanghai"
+
+# The JSON collector cache follows the same rule.
+REVISION="$(grep -Eo 'static let codexAccountingRevision = [0-9]+' \
+ "$SWIFT_DIR/Sources/TokenStepSwift/Services/Collector/"*.swift | grep -Eo '[0-9]+$')"
+json_cache() {
+ local file="$1" zone_field="$2"
+ printf '{"version":%s,%s"files":{"/tmp/tz.jsonl":{"tool":"Claude Code","size":1,"modificationTime":0,"records":[]}}}' \
+ "$REVISION" "$zone_field" > "$DATA_DIR/$file"
+}
+json_cache legacy.json ''
+json_cache la.json "\"timeZone\":\"$LA\","
+[[ "$(in_zone "$SH" json-cache "$DATA_DIR" legacy.json)" == reusable ]] || fail "legacy JSON cache discarded in Shanghai"
+[[ "$(in_zone "$LA" json-cache "$DATA_DIR" legacy.json)" == discarded ]] || fail "legacy JSON cache reused in Los Angeles"
+[[ "$(in_zone "$LA" json-cache "$DATA_DIR" la.json)" == reusable ]] || fail "Los Angeles JSON cache discarded in Los Angeles"
+[[ "$(in_zone "$SH" json-cache "$DATA_DIR" la.json)" == discarded ]] || fail "Los Angeles JSON cache reused in Shanghai"
+echo "PASS JSON collector cache is scoped to its zone"
+
+echo "Time zone collector fixture checks passed"
diff --git a/script/test_usage_recalibration_migration.sh b/script/test_usage_recalibration_migration.sh
index f0a08c5..ef6f63b 100755
--- a/script/test_usage_recalibration_migration.sh
+++ b/script/test_usage_recalibration_migration.sh
@@ -1,6 +1,10 @@
#!/usr/bin/env bash
set -euo pipefail
+# Fixture expectations are written in this zone; pin it so results do not
+# depend on the machine running the check.
+export TOKENSTEP_TIMEZONE="Asia/Shanghai"
+
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SWIFT_DIR="$ROOT_DIR/TokenStepSwift"
BUILD_DIR="$SWIFT_DIR/.build/usage-recalibration-fixture"
@@ -42,6 +46,7 @@ swiftc \
-Xcc "$OVERLAY_FILE" \
-parse-as-library \
"$SWIFT_DIR/Sources/TokenStepSwift/Support/AppPaths.swift" \
+ "$SWIFT_DIR/Sources/TokenStepSwift/Support/TokenStepClock.swift" \
"$SWIFT_DIR/Sources/TokenStepSwift/Support/Localization.swift" \
"$SWIFT_DIR/Sources/TokenStepSwift/Support/MemoryPressure.swift" \
"$SWIFT_DIR/Sources/TokenStepSwift/Support/Theme.swift" \
@@ -49,7 +54,7 @@ swiftc \
"$SWIFT_DIR/Sources/TokenStepSwift/Support/EnergyRefreshPolicy.swift" \
"$SWIFT_DIR/Sources/TokenStepSwift/Models/QuotaModels.swift" \
"$SWIFT_DIR/Sources/TokenStepSwift/Models/UsageModels.swift" \
- "$SWIFT_DIR/Sources/TokenStepSwift/Services/UsageCollector.swift" \
+ "$SWIFT_DIR/Sources/TokenStepSwift/Services/Collector/"*.swift \
"$SWIFT_DIR/Sources/TokenStepSwift/Services/DataService.swift" \
"$SWIFT_DIR/Tests/Fixtures/UsageRecalibrationMigrationFixtureCheck.swift" \
-o "$EXECUTABLE"