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 修正了坠落模式的逼近方向:膨胀锚点从画面中部移到**黑洞本体**(左上视界阴影处),让黑洞成为整个画面的放射原点——所有元素从它向外流走,右侧弧线不断放大、冲出画面右缘,弧顶顺流右移,读起来就是"视角正在被吸进去",而不是画面整体向上顶。 + +

+ TokenStep 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 给「引力边界」主题装上了真正的引力动效。黑洞不再是一张静态壁纸:打开浮层或仪表盘的瞬间,你就在朝它坠落。 + +

+ TokenStep 0.2.9 坠落模式:黑洞向你压来 +

+ +- **三档签名动效,浮层右上角随时切换**:静谧(只剩环境光呼吸)、轨道(白热等离子光斑拖着彗尾沿吸积盘弧线绕行,星尘坠落)、坠落(黑洞以自身为中心原地膨胀、越来越亮,9 秒内把你吞进去后悬置)。 +- **Token 坠落脉冲**:坠落模式下,今日 Token 增长或点击浮层波形按钮,会从视界方向涌出一圈进食波。 +- **每次打开都是一次新的坠落**:浮层关闭即复位,重新打开从头开始逼近;窗口失焦、被遮挡时动效自动暂停省电。 +- **低成本有底线**:最高 24 fps;低电量模式降到 12 fps 并冻结逼近;截图和 macOS「减少动态效果」下保持完全静态;所有动效由纯函数采样驱动,可用固定时间离线渲染逐帧校验。 +- **可感但克制**:静谧档保持接近静态的呼吸;轨道档持续有可察觉的流动;只有坠落档全力逼近。 + +

+ TokenStep 0.2.9 轨道模式:等离子光斑绕行 +

+ +本次不改变 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 的整套界面。 + +

+ TokenStep 0.2.7 引力边界黑洞主题包浮层 +

+ +- **第三套主题皮肤包**:在 `设置 → 通用 → 主题皮肤包` 中,可在经典、奥德赛和引力边界之间随时切换。 +- **黑洞是第一视觉层**:巨大事件视界占据左上,象牙白吸积盘横贯界面,并保留上方透镜弧和更暗的下方反转弧。 +- **全界面统一换肤**:菜单栏浮层、今日、历史、隐私、设置、更新窗口、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 0.2.4 奥德赛主题包四个视觉篇章 +

+ +### 两套皮肤包,随时切换 + +- **经典**:保留青绿、海蓝、紫藤、琥珀和石墨五种原版配色。 +- **奥德赛**:新增导演剪辑、爱琴海冷雾、特洛伊火海和灰烬神像四个视觉篇章。 +- 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 修正了坠落模式的逼近方向:膨胀锚点从画面中部移到**黑洞本体**(左上视界阴影处),让黑洞成为整个画面的放射原点——所有元素从它向外流走,右侧弧线不断放大、冲出画面右缘,弧顶顺流右移,读起来就是"视角正在被吸进去",而不是画面整体向上顶。 - -

- TokenStep 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 给「引力边界」主题装上了真正的引力动效。黑洞不再是一张静态壁纸:打开浮层或仪表盘的瞬间,你就在朝它坠落。 - -

- TokenStep 0.2.9 坠落模式:黑洞向你压来 -

- -- **三档签名动效,浮层右上角随时切换**:静谧(只剩环境光呼吸)、轨道(白热等离子光斑拖着彗尾沿吸积盘弧线绕行,星尘坠落)、坠落(黑洞以自身为中心原地膨胀、越来越亮,9 秒内把你吞进去后悬置)。 -- **Token 坠落脉冲**:坠落模式下,今日 Token 增长或点击浮层波形按钮,会从视界方向涌出一圈进食波。 -- **每次打开都是一次新的坠落**:浮层关闭即复位,重新打开从头开始逼近;窗口失焦、被遮挡时动效自动暂停省电。 -- **低成本有底线**:最高 24 fps;低电量模式降到 12 fps 并冻结逼近;截图和 macOS「减少动态效果」下保持完全静态;所有动效由纯函数采样驱动,可用固定时间离线渲染逐帧校验。 -- **可感但克制**:静谧档保持接近静态的呼吸;轨道档持续有可察觉的流动;只有坠落档全力逼近。 - -

- TokenStep 0.2.9 轨道模式:等离子光斑绕行 -

- -本次不改变 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 的整套界面。 - -

- TokenStep 0.2.7 引力边界黑洞主题包浮层 -

- -- **第三套主题皮肤包**:在 `设置 → 通用 → 主题皮肤包` 中,可在经典、奥德赛和引力边界之间随时切换。 -- **黑洞是第一视觉层**:巨大事件视界占据左上,象牙白吸积盘横贯界面,并保留上方透镜弧和更暗的下方反转弧。 -- **全界面统一换肤**:菜单栏浮层、今日、历史、隐私、设置、更新窗口、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 0.2.4 奥德赛主题包四个视觉篇章 -

- -### 两套皮肤包,随时切换 - -- **经典**:保留青绿、海蓝、紫藤、琥珀和石墨五种原版配色。 -- **奥德赛**:新增导演剪辑、爱琴海冷雾、特洛伊火海和灰烬神像四个视觉篇章。 -- 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"