token-tracker 0.3.6__tar.gz → 0.3.8__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. token_tracker-0.3.8/LICENSE +21 -0
  2. token_tracker-0.3.8/PKG-INFO +179 -0
  3. {token_tracker-0.3.6 → token_tracker-0.3.8}/README.md +10 -3
  4. token_tracker-0.3.8/pyproject.toml +68 -0
  5. {token_tracker-0.3.6 → token_tracker-0.3.8}/src/adapters/claude.py +17 -44
  6. token_tracker-0.3.8/src/adapters/codex.py +200 -0
  7. {token_tracker-0.3.6 → token_tracker-0.3.8}/src/adapters/rate_limits.py +6 -11
  8. {token_tracker-0.3.6 → token_tracker-0.3.8}/src/adapters/types.py +15 -4
  9. token_tracker-0.3.8/src/adapters/util.py +39 -0
  10. token_tracker-0.3.8/src/analyzer/aggregator.py +113 -0
  11. {token_tracker-0.3.6 → token_tracker-0.3.8}/src/analyzer/blocks.py +4 -9
  12. token_tracker-0.3.8/src/analyzer/cost.py +245 -0
  13. token_tracker-0.3.8/src/cli.py +426 -0
  14. {token_tracker-0.3.6 → token_tracker-0.3.8}/src/hooks.py +36 -29
  15. {token_tracker-0.3.6 → token_tracker-0.3.8}/src/i18n.py +3 -2
  16. token_tracker-0.3.8/src/ui/console.py +31 -0
  17. token_tracker-0.3.8/src/ui/format.py +86 -0
  18. token_tracker-0.3.8/src/ui/panels.py +257 -0
  19. token_tracker-0.3.8/src/ui/tables.py +469 -0
  20. token_tracker-0.3.8/src/ui/theme.py +48 -0
  21. token_tracker-0.3.8/src/ui/widgets.py +55 -0
  22. token_tracker-0.3.8/tests/test_aggregator.py +85 -0
  23. token_tracker-0.3.8/tests/test_blocks.py +79 -0
  24. token_tracker-0.3.8/tests/test_cli.py +95 -0
  25. token_tracker-0.3.8/tests/test_codex.py +87 -0
  26. token_tracker-0.3.8/tests/test_cost.py +210 -0
  27. token_tracker-0.3.8/tests/test_hooks.py +18 -0
  28. token_tracker-0.3.8/token_tracker.egg-info/PKG-INFO +179 -0
  29. {token_tracker-0.3.6 → token_tracker-0.3.8}/token_tracker.egg-info/SOURCES.txt +13 -0
  30. token_tracker-0.3.8/token_tracker.egg-info/requires.txt +9 -0
  31. token_tracker-0.3.6/PKG-INFO +0 -6
  32. token_tracker-0.3.6/pyproject.toml +0 -18
  33. token_tracker-0.3.6/src/adapters/codex.py +0 -230
  34. token_tracker-0.3.6/src/analyzer/aggregator.py +0 -133
  35. token_tracker-0.3.6/src/analyzer/cost.py +0 -124
  36. token_tracker-0.3.6/src/cli.py +0 -434
  37. token_tracker-0.3.6/src/ui/tables.py +0 -852
  38. token_tracker-0.3.6/token_tracker.egg-info/PKG-INFO +0 -6
  39. token_tracker-0.3.6/token_tracker.egg-info/requires.txt +0 -1
  40. {token_tracker-0.3.6 → token_tracker-0.3.8}/setup.cfg +0 -0
  41. {token_tracker-0.3.6 → token_tracker-0.3.8}/src/__init__.py +0 -0
  42. {token_tracker-0.3.6 → token_tracker-0.3.8}/src/adapters/__init__.py +0 -0
  43. {token_tracker-0.3.6 → token_tracker-0.3.8}/src/adapters/registry.py +1 -1
  44. {token_tracker-0.3.6 → token_tracker-0.3.8}/src/analyzer/__init__.py +0 -0
  45. {token_tracker-0.3.6 → token_tracker-0.3.8}/src/ui/__init__.py +0 -0
  46. {token_tracker-0.3.6 → token_tracker-0.3.8}/token_tracker.egg-info/dependency_links.txt +0 -0
  47. {token_tracker-0.3.6 → token_tracker-0.3.8}/token_tracker.egg-info/entry_points.txt +0 -0
  48. {token_tracker-0.3.6 → token_tracker-0.3.8}/token_tracker.egg-info/top_level.txt +0 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 stormzhang
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,179 @@
1
+ Metadata-Version: 2.4
2
+ Name: token-tracker
3
+ Version: 0.3.8
4
+ Summary: Track token usage across local AI agents (Claude Code, Codex)
5
+ Author-email: stormzhang <stormzhang.dev@gmail.com>
6
+ License: MIT License
7
+
8
+ Copyright (c) 2026 stormzhang
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+
28
+ Project-URL: Homepage, https://github.com/stormzhang/token-tracker
29
+ Project-URL: Repository, https://github.com/stormzhang/token-tracker
30
+ Project-URL: Issues, https://github.com/stormzhang/token-tracker/issues
31
+ Keywords: claude-code,codex,token,usage,cost,ai-agent,statusline,cli
32
+ Classifier: Development Status :: 4 - Beta
33
+ Classifier: Environment :: Console
34
+ Classifier: Intended Audience :: Developers
35
+ Classifier: License :: OSI Approved :: MIT License
36
+ Classifier: Operating System :: OS Independent
37
+ Classifier: Programming Language :: Python :: 3.11
38
+ Classifier: Programming Language :: Python :: 3.12
39
+ Classifier: Topic :: Software Development
40
+ Classifier: Topic :: Utilities
41
+ Requires-Python: >=3.11
42
+ Description-Content-Type: text/markdown
43
+ License-File: LICENSE
44
+ Requires-Dist: rich>=13.7
45
+ Provides-Extra: test
46
+ Requires-Dist: pytest>=8.0; extra == "test"
47
+ Provides-Extra: dev
48
+ Requires-Dist: pytest>=8.0; extra == "dev"
49
+ Requires-Dist: ruff>=0.6; extra == "dev"
50
+ Requires-Dist: mypy>=1.8; extra == "dev"
51
+ Dynamic: license-file
52
+
53
+ # Token Tracker (tt)
54
+
55
+ 本地 AI Agent Token 消耗追踪/分析工具,支持 **Claude Code** 和 **Codex** 。
56
+
57
+ 自定义 StatusLine 状态栏 + CLI Dashboard,实时查看 token 用量、等效成本、限额状态。
58
+
59
+ ![Python](https://img.shields.io/badge/python-3.11+-blue) ![License](https://img.shields.io/badge/license-MIT-green)
60
+
61
+ [English](README_EN.md)
62
+
63
+ ## StatusLine 状态栏
64
+
65
+ 自动为 Claude Code 和 Codex 配置状态栏,`tt setup` 一键配置,脚本更新时自动升级。
66
+
67
+ **Claude Code**:基于官方自定义 StatusLine 接口,数据完全来自本地 Claude,准确无任何推测
68
+
69
+ ![Claude Code StatusLine](assets/screenshot-statusline-cc.png)
70
+
71
+ 状态栏共三行,从左到右:
72
+
73
+ | 行 | 字段 | 说明 |
74
+ |----|------|------|
75
+ | 1 | `项目名(分支)` | 当前项目目录 + Git 分支,未提交的修改会标 `*` |
76
+ | 1 | `5h: ██░ 31% (1h19m)` | 5 小时滑动窗口配额用量,括号内为重置倒计时 |
77
+ | 1 | `7d: ██░ 11% (5d8h)` | 7 天滑动窗口配额用量 |
78
+ | 1 | `1.0M Context: ██░ 20%` | 上下文窗口总大小及已用占比 |
79
+ | 2 | `Tokens: in 155k, out 128k` | 本次会话累计输入/输出 Token |
80
+ | 2 | `(本轮: in 1, out 15)` | 当前对话轮次的 Token 用量 |
81
+ | 2 | `Cached: 204k` | 当前轮次命中的 Prompt Cache Token 数 |
82
+ | 2 | `Cost: $35.51` | 本次会话等效成本(按官方定价计算) |
83
+ | 3 | `Model: Opus 4.6/high/nofast` | 模型名 / thinking 级别 / 是否 fast 模式 |
84
+ | 3 | `Duration: 1h33m` | 当前会话已持续时间 |
85
+
86
+ > 终端宽度不足时会自动降级:先隐藏重置倒计时,再将进度条简化为百分比数字。
87
+
88
+ **Codex**:官方暂不支持自定义 StatusLine 渲染,沿用官方默认样式,`tt setup` 仅写入字段配置
89
+
90
+ ![Codex StatusLine](assets/screenshot-statusline-codex.png)
91
+
92
+ | 字段 | 说明 |
93
+ |------|------|
94
+ | `project` | 当前项目目录名 |
95
+ | `five-hour-limit` | 5 小时滑动窗口配额用量 |
96
+ | `weekly-limit` | 7 天滑动窗口配额用量 |
97
+ | `context-remaining` | 上下文窗口剩余占比 |
98
+ | `model-with-reasoning` | 模型名 + 推理强度(如 `gpt-5-codex/high`) |
99
+
100
+ ## Dashboard 数据面板和 日/周/月 数据报表分析
101
+
102
+ ![Token Tracker Dashboard](assets/screenshot.png)
103
+
104
+ ![Token Tracker Daily](assets/screenshot-daily.png)
105
+
106
+ ![Token Tracker Weekly](assets/screenshot-weekly.png)
107
+
108
+ ![Token Tracker Monthly](assets/screenshot-monthly.png)
109
+
110
+ ## 功能
111
+
112
+ - **多 Agent 追踪** — Claude Code + Codex 统一面板,左右键切换
113
+ - **状态栏集成** — Claude Code statusLine + Codex status_line,首次运行自动配置,脚本更新自动升级
114
+ - **限额监控** — 实时 5h / 7d 配额百分比 + 重置倒计时
115
+ - **成本分析** — 按会话、日、周、月维度的等效成本统计,多 Agent 按来源分组展示
116
+ - **会话洞察** — 项目、模型、时长、消息数一览
117
+ - **零配置** — 自动检测已安装的 Agent,直接读取本地数据
118
+ - **隐私安全** — 数据纯本地存储,不采集、不上传任何用户信息,极轻量无后顾之忧
119
+
120
+ ## 安装
121
+
122
+ ```bash
123
+ curl -sSL https://raw.githubusercontent.com/stormzhang/token-tracker/main/install.sh | bash
124
+ ```
125
+
126
+ 或者通过 pip:
127
+
128
+ ```bash
129
+ pip install --force-reinstall token-tracker && tt setup
130
+ ```
131
+
132
+ ## 使用
133
+
134
+ ```bash
135
+ tt setup # 初始化配置 Claude Code + Codex status_line
136
+ tt # 交互式 Dashboard(方向键切换 Agent)
137
+ tt claude # 仅展示 Claude Code
138
+ tt codex # 仅展示 Codex
139
+ tt daily # 按日汇总(按 token 消耗排序)
140
+ tt weekly # 按周汇总(多 Agent 分组展示)
141
+ tt monthly # 按月汇总(多 Agent 分组展示)
142
+ tt sessions # 最近 20 条会话明细数据
143
+ tt unsetup # 卸载并恢复安装前的配置
144
+ ```
145
+
146
+ ### 报告排序
147
+
148
+ 所有报告命令支持 `--sort` 和 `--asc/--desc` 参数:
149
+
150
+ ```bash
151
+ tt daily --sort cost --desc # 按成本降序
152
+ tt sessions --sort tokens --asc # 按 token 升序
153
+ ```
154
+
155
+ 可选排序字段:`tokens` / `cost` / `messages` / `time` / `input` / `output`
156
+
157
+ ### Dashboard 快捷键
158
+
159
+ | 按键 | 功能 |
160
+ |------|------|
161
+ | `←` `→` | 切换 Agent |
162
+ | `↑` `↓` | 滚动内容 |
163
+ | `s` | 切换排序字段(时间 → Token → 等效成本 → 消息数) |
164
+ | `r` | 反转排序方向 |
165
+ | `+` / `-` | 调整会话显示条数(±10,最少 10 条) |
166
+ | `q` | 退出 |
167
+
168
+ ## 环境要求
169
+
170
+ - Python 3.11+
171
+ - [Rich](https://github.com/Textualize/rich)(自动安装)
172
+
173
+ ## TODO
174
+
175
+ 未来持续增加更多数据报表,多维度分析。
176
+
177
+ ## License
178
+
179
+ Copyright (c) 2026 stormzhang. MIT License.
@@ -33,10 +33,18 @@
33
33
 
34
34
  > 终端宽度不足时会自动降级:先隐藏重置倒计时,再将进度条简化为百分比数字。
35
35
 
36
- **Codex**:官方暂不支持自定义 StatusLine,使用官方默认样式,展示项目名、5h/7d 配额、上下文剩余、模型名
36
+ **Codex**:官方暂不支持自定义 StatusLine 渲染,沿用官方默认样式,`tt setup` 仅写入字段配置
37
37
 
38
38
  ![Codex StatusLine](assets/screenshot-statusline-codex.png)
39
39
 
40
+ | 字段 | 说明 |
41
+ |------|------|
42
+ | `project` | 当前项目目录名 |
43
+ | `five-hour-limit` | 5 小时滑动窗口配额用量 |
44
+ | `weekly-limit` | 7 天滑动窗口配额用量 |
45
+ | `context-remaining` | 上下文窗口剩余占比 |
46
+ | `model-with-reasoning` | 模型名 + 推理强度(如 `gpt-5-codex/high`) |
47
+
40
48
  ## Dashboard 数据面板和 日/周/月 数据报表分析
41
49
 
42
50
  ![Token Tracker Dashboard](assets/screenshot.png)
@@ -66,8 +74,7 @@ curl -sSL https://raw.githubusercontent.com/stormzhang/token-tracker/main/instal
66
74
  或者通过 pip:
67
75
 
68
76
  ```bash
69
- pip install --force-reinstall token-tracker
70
- tt setup
77
+ pip install --force-reinstall token-tracker && tt setup
71
78
  ```
72
79
 
73
80
  ## 使用
@@ -0,0 +1,68 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "token-tracker"
7
+ version = "0.3.8"
8
+ description = "Track token usage across local AI agents (Claude Code, Codex)"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = { file = "LICENSE" }
12
+ authors = [
13
+ { name = "stormzhang", email = "stormzhang.dev@gmail.com" },
14
+ ]
15
+ keywords = ["claude-code", "codex", "token", "usage", "cost", "ai-agent", "statusline", "cli"]
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Environment :: Console",
19
+ "Intended Audience :: Developers",
20
+ "License :: OSI Approved :: MIT License",
21
+ "Operating System :: OS Independent",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Topic :: Software Development",
25
+ "Topic :: Utilities",
26
+ ]
27
+ dependencies = [
28
+ "rich>=13.7",
29
+ ]
30
+
31
+ [project.optional-dependencies]
32
+ test = [
33
+ "pytest>=8.0",
34
+ ]
35
+ dev = [
36
+ "pytest>=8.0",
37
+ "ruff>=0.6",
38
+ "mypy>=1.8",
39
+ ]
40
+
41
+ [project.urls]
42
+ Homepage = "https://github.com/stormzhang/token-tracker"
43
+ Repository = "https://github.com/stormzhang/token-tracker"
44
+ Issues = "https://github.com/stormzhang/token-tracker/issues"
45
+
46
+ [project.scripts]
47
+ tt = "src.cli:main"
48
+
49
+ [tool.setuptools.packages.find]
50
+ include = ["src*"]
51
+
52
+ [tool.ruff]
53
+ line-length = 120
54
+ target-version = "py311"
55
+
56
+ [tool.ruff.lint]
57
+ # E501(行长)故意不开:状态栏 ANSI 色码串本就长,启用只产生噪音
58
+ select = ["E4", "E7", "E9", "F", "W", "I", "UP", "B"]
59
+
60
+ [tool.pytest.ini_options]
61
+ testpaths = ["tests"]
62
+ addopts = "-q"
63
+
64
+ [tool.mypy]
65
+ python_version = "3.11"
66
+ ignore_missing_imports = true
67
+ # 宽松起步,后续逐步收紧
68
+ check_untyped_defs = true
@@ -1,9 +1,9 @@
1
- import json
2
1
  import os
3
- from datetime import datetime, timezone
2
+ from datetime import UTC, datetime
4
3
  from pathlib import Path
5
4
 
6
5
  from .types import AgentInfo, UsageEntry
6
+ from .util import iter_jsonl_dicts, project_from_cwd
7
7
 
8
8
  CLAUDE_DIRS = [
9
9
  os.path.expanduser("~/.claude/projects"),
@@ -14,12 +14,7 @@ CLAUDE_DIRS = [
14
14
  def detect() -> AgentInfo | None:
15
15
  for d in _get_claude_dirs():
16
16
  if Path(d).is_dir():
17
- return AgentInfo(
18
- id="claude-code",
19
- name="Claude Code",
20
- data_dir=d,
21
- installed=True,
22
- )
17
+ return AgentInfo(id="claude-code", name="Claude Code")
23
18
  return None
24
19
 
25
20
 
@@ -29,7 +24,7 @@ def load_entries(hours_back: int = 0) -> list[UsageEntry]:
29
24
  cutoff = None
30
25
  if hours_back > 0:
31
26
  from datetime import timedelta
32
- cutoff = datetime.now(timezone.utc) - timedelta(hours=hours_back)
27
+ cutoff = datetime.now(UTC) - timedelta(hours=hours_back)
33
28
 
34
29
  for base_dir in _get_claude_dirs():
35
30
  base = Path(base_dir)
@@ -54,16 +49,6 @@ def _get_claude_dirs() -> list[str]:
54
49
  return dirs
55
50
 
56
51
 
57
- def _project_from_cwd(cwd: str) -> str:
58
- home = os.path.expanduser("~")
59
- if cwd.startswith(home):
60
- rel = cwd[len(home):].strip(os.sep)
61
- else:
62
- rel = cwd.strip(os.sep)
63
- parts = rel.split(os.sep)
64
- return parts[-1] if parts and parts[-1] else rel or "unknown"
65
-
66
-
67
52
  def _extract_project_from_dir(jsonl_path: Path, base: Path) -> str:
68
53
  rel = jsonl_path.relative_to(base)
69
54
  project_dir = str(rel.parts[0]) if rel.parts else "unknown"
@@ -82,34 +67,22 @@ def _parse_jsonl(
82
67
  seen: set[str],
83
68
  cutoff: datetime | None,
84
69
  ) -> None:
85
- try:
86
- with open(path, "r", encoding="utf-8") as f:
87
- for line in f:
88
- line = line.strip()
89
- if not line:
90
- continue
91
- try:
92
- data = json.loads(line)
93
- except json.JSONDecodeError:
94
- continue
95
-
96
- if data.get("type") != "assistant":
97
- continue
70
+ for data in iter_jsonl_dicts(path):
71
+ if data.get("type") != "assistant":
72
+ continue
98
73
 
99
- entry = _parse_assistant_entry(data, project)
100
- if entry is None:
101
- continue
74
+ entry = _parse_assistant_entry(data, project)
75
+ if entry is None:
76
+ continue
102
77
 
103
- if cutoff and entry.timestamp < cutoff:
104
- continue
78
+ if cutoff and entry.timestamp < cutoff:
79
+ continue
105
80
 
106
- if entry.dedup_key in seen:
107
- continue
108
- seen.add(entry.dedup_key)
81
+ if entry.dedup_key in seen:
82
+ continue
83
+ seen.add(entry.dedup_key)
109
84
 
110
- entries.append(entry)
111
- except (OSError, PermissionError):
112
- pass
85
+ entries.append(entry)
113
86
 
114
87
 
115
88
  def _parse_assistant_entry(data: dict, project: str) -> UsageEntry | None:
@@ -143,7 +116,7 @@ def _parse_assistant_entry(data: dict, project: str) -> UsageEntry | None:
143
116
 
144
117
  cwd = data.get("cwd", "")
145
118
  if cwd:
146
- project = _project_from_cwd(cwd)
119
+ project = project_from_cwd(cwd)
147
120
 
148
121
  return UsageEntry(
149
122
  timestamp=ts,
@@ -0,0 +1,200 @@
1
+ import os
2
+ import sqlite3
3
+ from datetime import UTC, datetime, timedelta
4
+ from pathlib import Path
5
+
6
+ from .types import AgentInfo, RateLimits, UsageEntry, normalize_pct
7
+ from .util import iter_jsonl_dicts, project_from_cwd
8
+
9
+ CODEX_DIR = os.path.expanduser("~/.codex")
10
+ SESSIONS_DIR = os.path.join(CODEX_DIR, "sessions")
11
+ STATE_DB = os.path.join(CODEX_DIR, "state_5.sqlite")
12
+ _RATE_LIMIT_SCAN_FILES = 5 # 只扫最近改动的 N 个 session 文件找限额信息
13
+
14
+
15
+ def detect() -> AgentInfo | None:
16
+ if Path(SESSIONS_DIR).is_dir():
17
+ return AgentInfo(id="codex", name="Codex")
18
+ return None
19
+
20
+
21
+ def load_entries(hours_back: int = 0) -> list[UsageEntry]:
22
+ entries: list[UsageEntry] = []
23
+ seen: set[str] = set()
24
+ cutoff = None
25
+ if hours_back > 0:
26
+ cutoff = datetime.now(UTC) - timedelta(hours=hours_back)
27
+
28
+ models = _load_thread_models()
29
+
30
+ sessions_path = Path(SESSIONS_DIR)
31
+ if not sessions_path.is_dir():
32
+ return entries
33
+
34
+ for jsonl_path in sessions_path.rglob("*.jsonl"):
35
+ _parse_jsonl(jsonl_path, models, entries, seen, cutoff)
36
+
37
+ entries.sort(key=lambda e: e.timestamp)
38
+ return entries
39
+
40
+
41
+ def _load_thread_models() -> dict[str, str]:
42
+ if not os.path.exists(STATE_DB):
43
+ return {}
44
+ try:
45
+ conn = sqlite3.connect(f"file:{STATE_DB}?mode=ro", uri=True)
46
+ rows = conn.execute("SELECT id, model FROM threads WHERE model IS NOT NULL").fetchall()
47
+ conn.close()
48
+ return {row[0]: row[1] for row in rows}
49
+ except (sqlite3.Error, OSError):
50
+ return {}
51
+
52
+
53
+ def load_rate_limits() -> RateLimits | None:
54
+ sessions_path = Path(SESSIONS_DIR)
55
+ if not sessions_path.is_dir():
56
+ return None
57
+
58
+ # session 文件在轮转,rglob 与 stat 之间文件可能消失:mtime 取不到时退化为 0,避免整体崩溃
59
+ jsonl_files = sorted(sessions_path.rglob("*.jsonl"), key=_safe_mtime, reverse=True)
60
+ models = _load_thread_models()
61
+
62
+ for path in jsonl_files[:_RATE_LIMIT_SCAN_FILES]:
63
+ rl = _extract_rate_limits(path, models)
64
+ if rl:
65
+ return rl
66
+ return None
67
+
68
+
69
+ def _safe_mtime(path: Path) -> float:
70
+ try:
71
+ return path.stat().st_mtime
72
+ except OSError:
73
+ return 0.0
74
+
75
+
76
+ def _extract_rate_limits(path: Path, models: dict[str, str]) -> RateLimits | None:
77
+ session_id = ""
78
+ last_payload = None
79
+ for data in iter_jsonl_dicts(path):
80
+ if data.get("type") == "session_meta":
81
+ session_id = data.get("payload", {}).get("id", "")
82
+ if data.get("type") != "event_msg":
83
+ continue
84
+ payload = data.get("payload", {})
85
+ if payload.get("type") != "token_count":
86
+ continue
87
+ rl = payload.get("rate_limits")
88
+ if rl:
89
+ last_payload = (rl, payload.get("info") or {}, session_id)
90
+
91
+ if not last_payload:
92
+ return None
93
+
94
+ rl, info, sid = last_payload
95
+
96
+ now_ts = datetime.now(UTC).timestamp()
97
+ five_pct = five_reset = None
98
+ seven_pct = seven_reset = None
99
+
100
+ # 按 window_minutes 字段分配 5h / 7d 桶,
101
+ # 而不是固定 primary→5h、secondary→7d(free plan 实测 primary 为 7 天窗口)
102
+ for bucket in (rl.get("primary"), rl.get("secondary")):
103
+ if not bucket:
104
+ continue
105
+ resets = bucket.get("resets_at")
106
+ window = bucket.get("window_minutes") or 0
107
+ pct = normalize_pct(bucket.get("used_percent"), resets, now_ts)
108
+ if window < 1440:
109
+ five_pct, five_reset = pct, resets
110
+ else:
111
+ seven_pct, seven_reset = pct, resets
112
+
113
+ if five_pct is None and seven_pct is None:
114
+ return None
115
+
116
+ return RateLimits(
117
+ five_hour_pct=five_pct,
118
+ five_hour_resets_at=five_reset,
119
+ seven_day_pct=seven_pct,
120
+ seven_day_resets_at=seven_reset,
121
+ model=models.get(sid, ""),
122
+ plan_type=rl.get("plan_type") or "",
123
+ context_window=info.get("model_context_window"),
124
+ )
125
+
126
+
127
+ def _parse_jsonl(
128
+ path: Path,
129
+ models: dict[str, str],
130
+ entries: list[UsageEntry],
131
+ seen: set[str],
132
+ cutoff: datetime | None,
133
+ ) -> None:
134
+ session_id = ""
135
+ session_ts = ""
136
+ project = "unknown"
137
+ model = "unknown"
138
+ last_usage = None
139
+ msg_count = 0
140
+
141
+ for data in iter_jsonl_dicts(path):
142
+ row_type = data.get("type")
143
+
144
+ if row_type == "session_meta":
145
+ payload = data.get("payload", {})
146
+ session_id = payload.get("id", "")
147
+ session_ts = payload.get("timestamp", "")
148
+ cwd = payload.get("cwd", "")
149
+ if cwd:
150
+ project = project_from_cwd(cwd)
151
+ model = models.get(session_id, "unknown")
152
+ continue
153
+
154
+ if row_type != "event_msg":
155
+ continue
156
+
157
+ payload = data.get("payload", {})
158
+ if payload.get("type") == "token_count":
159
+ info = payload.get("info")
160
+ if info and info.get("total_token_usage"):
161
+ last_usage = info["total_token_usage"]
162
+ msg_count += 1
163
+
164
+ if not last_usage or not session_id:
165
+ return
166
+
167
+ cached = last_usage.get("cached_input_tokens", 0)
168
+ input_tokens = last_usage.get("input_tokens", 0) - cached
169
+ output_tokens = last_usage.get("output_tokens", 0) + last_usage.get("reasoning_output_tokens", 0)
170
+
171
+ if input_tokens == 0 and output_tokens == 0:
172
+ return
173
+
174
+ try:
175
+ ts = datetime.fromisoformat(session_ts.replace("Z", "+00:00"))
176
+ except (ValueError, AttributeError):
177
+ return
178
+
179
+ if cutoff and ts < cutoff:
180
+ return
181
+
182
+ if session_id in seen:
183
+ return
184
+ seen.add(session_id)
185
+
186
+ entries.append(UsageEntry(
187
+ timestamp=ts,
188
+ session_id=session_id,
189
+ message_id=session_id,
190
+ request_id="",
191
+ model=model,
192
+ input_tokens=input_tokens,
193
+ output_tokens=output_tokens,
194
+ cache_creation_tokens=0,
195
+ cache_read_tokens=cached,
196
+ cost_usd=None,
197
+ project=project,
198
+ agent_id="codex",
199
+ message_count=msg_count,
200
+ ))
@@ -1,8 +1,8 @@
1
1
  import json
2
2
  import os
3
- from datetime import datetime, timezone
3
+ from datetime import UTC, datetime
4
4
 
5
- from .types import RateLimits
5
+ from .types import RateLimits, normalize_pct
6
6
 
7
7
  STATUS_FILE = os.path.expanduser("~/.claude/tt-status.json")
8
8
 
@@ -12,7 +12,7 @@ def load_rate_limits() -> RateLimits | None:
12
12
  return None
13
13
 
14
14
  try:
15
- with open(STATUS_FILE, "r", encoding="utf-8") as f:
15
+ with open(STATUS_FILE, encoding="utf-8") as f:
16
16
  data = json.load(f)
17
17
  except (json.JSONDecodeError, OSError):
18
18
  return None
@@ -21,16 +21,12 @@ def load_rate_limits() -> RateLimits | None:
21
21
  five = rl.get("five_hour") or {}
22
22
  seven = rl.get("seven_day") or {}
23
23
 
24
- now_ts = datetime.now(timezone.utc).timestamp()
25
- five_pct = five.get("used_percentage")
24
+ now_ts = datetime.now(UTC).timestamp()
26
25
  five_reset = five.get("resets_at")
27
- if five_reset and five_reset < now_ts:
28
- five_pct = 0.0
26
+ five_pct = normalize_pct(five.get("used_percentage"), five_reset, now_ts)
29
27
 
30
- seven_pct = seven.get("used_percentage")
31
28
  seven_reset = seven.get("resets_at")
32
- if seven_reset and seven_reset < now_ts:
33
- seven_pct = 0.0
29
+ seven_pct = normalize_pct(seven.get("used_percentage"), seven_reset, now_ts)
34
30
 
35
31
  model_info = data.get("model") or {}
36
32
  model_name = model_info.get("display_name") or model_info.get("id") or ""
@@ -44,5 +40,4 @@ def load_rate_limits() -> RateLimits | None:
44
40
  seven_day_pct=seven_pct,
45
41
  seven_day_resets_at=seven_reset,
46
42
  model=model_name,
47
- updated_at=data.get("_received_at", ""),
48
43
  )
@@ -1,5 +1,17 @@
1
1
  from dataclasses import dataclass, field
2
- from datetime import datetime
2
+ from datetime import UTC, datetime
3
+
4
+
5
+ def normalize_pct(pct: float | None, resets_at: int | float | None, now_ts: float | None = None) -> float | None:
6
+ """配额百分比:若已过重置时间则归零(窗口已滚动,旧用量不再有效)。"""
7
+ if pct is None:
8
+ return None
9
+ if resets_at:
10
+ if now_ts is None:
11
+ now_ts = datetime.now(UTC).timestamp()
12
+ if resets_at < now_ts:
13
+ return 0.0
14
+ return pct
3
15
 
4
16
 
5
17
  @dataclass
@@ -31,8 +43,6 @@ class UsageEntry:
31
43
  class AgentInfo:
32
44
  id: str
33
45
  name: str
34
- data_dir: str
35
- installed: bool
36
46
 
37
47
 
38
48
  @dataclass
@@ -107,7 +117,8 @@ class RateLimits:
107
117
  seven_day_pct: float | None = None
108
118
  seven_day_resets_at: int | None = None
109
119
  model: str = ""
110
- updated_at: str = ""
120
+ plan_type: str = ""
121
+ context_window: int | None = None
111
122
 
112
123
 
113
124
  @dataclass