myccusage 1.2.2__tar.gz → 1.3.0__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 (28) hide show
  1. {myccusage-1.2.2 → myccusage-1.3.0}/PKG-INFO +6 -14
  2. {myccusage-1.2.2 → myccusage-1.3.0}/README.md +5 -13
  3. {myccusage-1.2.2 → myccusage-1.3.0}/myccusage.egg-info/PKG-INFO +6 -14
  4. {myccusage-1.2.2 → myccusage-1.3.0}/myccusage.egg-info/SOURCES.txt +10 -0
  5. {myccusage-1.2.2 → myccusage-1.3.0}/myccusage_lib/__init__.py +1 -1
  6. myccusage-1.3.0/myccusage_lib/adapters/__init__.py +48 -0
  7. myccusage-1.3.0/myccusage_lib/adapters/agy.py +199 -0
  8. myccusage-1.3.0/myccusage_lib/adapters/base.py +74 -0
  9. myccusage-1.3.0/myccusage_lib/adapters/claude.py +189 -0
  10. myccusage-1.3.0/myccusage_lib/adapters/codex.py +230 -0
  11. myccusage-1.3.0/myccusage_lib/adapters/grok.py +169 -0
  12. myccusage-1.3.0/myccusage_lib/adapters/hermes.py +108 -0
  13. myccusage-1.3.0/myccusage_lib/adapters/opencode.py +107 -0
  14. myccusage-1.3.0/myccusage_lib/adapters/pi.py +159 -0
  15. myccusage-1.3.0/myccusage_lib/adapters/workbuddy.py +196 -0
  16. {myccusage-1.2.2 → myccusage-1.3.0}/myccusage_lib/cli.py +5 -1
  17. {myccusage-1.2.2 → myccusage-1.3.0}/myccusage_lib/core.py +274 -65
  18. {myccusage-1.2.2 → myccusage-1.3.0}/myccusage_lib/web/static/app.js +107 -1
  19. {myccusage-1.2.2 → myccusage-1.3.0}/myccusage_lib/web/static/index.html +25 -0
  20. {myccusage-1.2.2 → myccusage-1.3.0}/myccusage_lib/web/static/style.css +13 -0
  21. {myccusage-1.2.2 → myccusage-1.3.0}/pyproject.toml +1 -1
  22. {myccusage-1.2.2 → myccusage-1.3.0}/LICENSE +0 -0
  23. {myccusage-1.2.2 → myccusage-1.3.0}/myccusage.egg-info/dependency_links.txt +0 -0
  24. {myccusage-1.2.2 → myccusage-1.3.0}/myccusage.egg-info/entry_points.txt +0 -0
  25. {myccusage-1.2.2 → myccusage-1.3.0}/myccusage.egg-info/top_level.txt +0 -0
  26. {myccusage-1.2.2 → myccusage-1.3.0}/myccusage_lib/web/__init__.py +0 -0
  27. {myccusage-1.2.2 → myccusage-1.3.0}/myccusage_lib/web/server.py +0 -0
  28. {myccusage-1.2.2 → myccusage-1.3.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: myccusage
3
- Version: 1.2.2
3
+ Version: 1.3.0
4
4
  Summary: Multi-Agent session usage analytics and DeepSeek-V4.1-Flash cost estimator
5
5
  Author-email: Richard Huang <RichardHuang0001@users.noreply.github.com>
6
6
  License: MIT License
@@ -67,7 +67,7 @@ Dynamic: license-file
67
67
 
68
68
  ## 主要功能
69
69
 
70
- - **支持多种编程 Agent**:统一查看 Google Antigravity、Claude Code、Hermes Agent、OpenAI Codex、Grok、Pi Agent、OpenCode 的使用记录。
70
+ - **支持多种编程 Agent**:统一查看 Google Antigravity、Claude Code、Hermes Agent、OpenAI Codex、Grok、Pi Agent、OpenCode、WorkBuddy 的使用记录。
71
71
  - **Token 消耗与费用参考**:统计 Input、Cache 与 Output 构成,支持按 DeepSeek-V4.1-Flash、Gemini 3.8 Flash 等模型单价换算参考费用。
72
72
  - **缓存命中率统计**:直观查看每次会话与整体的 KV Cache 命中比例,了解缓存节省情况。
73
73
  - **自动读取会话标题**:解析本地 SQLite / JSONL / Protobuf 文件还原会话标题,避免面对难以辨识的 UUID。
@@ -83,18 +83,9 @@ Dynamic: license-file
83
83
 
84
84
  ## 安装说明
85
85
 
86
- ### 1. 安装底层依赖 `ccusage`
86
+ ### 直接使用 pip 安装(无需 Node.js / ccusage)
87
87
 
88
- `myccusage` 依赖开源工具 [ccusage](https://github.com/ryoppippi/ccusage) 收集底层数据,需先全局安装:
89
-
90
- ```bash
91
- npm install -g ccusage
92
- # 或使用 bun / pnpm:
93
- # bun add -g ccusage
94
- # pnpm add -g ccusage
95
- ```
96
-
97
- ### 2. 安装 `myccusage`
88
+ `myccusage` 现已内置各大 Agent 的原生 Python 高性能解析内核,**完全不依赖 Node.js 或外部 ccusage 工具**,直接 pip 即可安装并使用:
98
89
 
99
90
  ```bash
100
91
  pip install myccusage
@@ -131,10 +122,11 @@ myccusage --agy
131
122
  # 查看 Claude Code 每日账本
132
123
  myccusage --claude
133
124
 
134
- # 查看 Codex / Hermes / OpenCode
125
+ # 查看 Codex / Hermes / OpenCode / WorkBuddy
135
126
  myccusage --codex
136
127
  myccusage --hermes
137
128
  myccusage --opencode
129
+ myccusage --workbuddy
138
130
 
139
131
  # 查看某 Agent 各项目生命周期总消耗
140
132
  myccusage --agy -s
@@ -30,7 +30,7 @@
30
30
 
31
31
  ## 主要功能
32
32
 
33
- - **支持多种编程 Agent**:统一查看 Google Antigravity、Claude Code、Hermes Agent、OpenAI Codex、Grok、Pi Agent、OpenCode 的使用记录。
33
+ - **支持多种编程 Agent**:统一查看 Google Antigravity、Claude Code、Hermes Agent、OpenAI Codex、Grok、Pi Agent、OpenCode、WorkBuddy 的使用记录。
34
34
  - **Token 消耗与费用参考**:统计 Input、Cache 与 Output 构成,支持按 DeepSeek-V4.1-Flash、Gemini 3.8 Flash 等模型单价换算参考费用。
35
35
  - **缓存命中率统计**:直观查看每次会话与整体的 KV Cache 命中比例,了解缓存节省情况。
36
36
  - **自动读取会话标题**:解析本地 SQLite / JSONL / Protobuf 文件还原会话标题,避免面对难以辨识的 UUID。
@@ -46,18 +46,9 @@
46
46
 
47
47
  ## 安装说明
48
48
 
49
- ### 1. 安装底层依赖 `ccusage`
49
+ ### 直接使用 pip 安装(无需 Node.js / ccusage)
50
50
 
51
- `myccusage` 依赖开源工具 [ccusage](https://github.com/ryoppippi/ccusage) 收集底层数据,需先全局安装:
52
-
53
- ```bash
54
- npm install -g ccusage
55
- # 或使用 bun / pnpm:
56
- # bun add -g ccusage
57
- # pnpm add -g ccusage
58
- ```
59
-
60
- ### 2. 安装 `myccusage`
51
+ `myccusage` 现已内置各大 Agent 的原生 Python 高性能解析内核,**完全不依赖 Node.js 或外部 ccusage 工具**,直接 pip 即可安装并使用:
61
52
 
62
53
  ```bash
63
54
  pip install myccusage
@@ -94,10 +85,11 @@ myccusage --agy
94
85
  # 查看 Claude Code 每日账本
95
86
  myccusage --claude
96
87
 
97
- # 查看 Codex / Hermes / OpenCode
88
+ # 查看 Codex / Hermes / OpenCode / WorkBuddy
98
89
  myccusage --codex
99
90
  myccusage --hermes
100
91
  myccusage --opencode
92
+ myccusage --workbuddy
101
93
 
102
94
  # 查看某 Agent 各项目生命周期总消耗
103
95
  myccusage --agy -s
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: myccusage
3
- Version: 1.2.2
3
+ Version: 1.3.0
4
4
  Summary: Multi-Agent session usage analytics and DeepSeek-V4.1-Flash cost estimator
5
5
  Author-email: Richard Huang <RichardHuang0001@users.noreply.github.com>
6
6
  License: MIT License
@@ -67,7 +67,7 @@ Dynamic: license-file
67
67
 
68
68
  ## 主要功能
69
69
 
70
- - **支持多种编程 Agent**:统一查看 Google Antigravity、Claude Code、Hermes Agent、OpenAI Codex、Grok、Pi Agent、OpenCode 的使用记录。
70
+ - **支持多种编程 Agent**:统一查看 Google Antigravity、Claude Code、Hermes Agent、OpenAI Codex、Grok、Pi Agent、OpenCode、WorkBuddy 的使用记录。
71
71
  - **Token 消耗与费用参考**:统计 Input、Cache 与 Output 构成,支持按 DeepSeek-V4.1-Flash、Gemini 3.8 Flash 等模型单价换算参考费用。
72
72
  - **缓存命中率统计**:直观查看每次会话与整体的 KV Cache 命中比例,了解缓存节省情况。
73
73
  - **自动读取会话标题**:解析本地 SQLite / JSONL / Protobuf 文件还原会话标题,避免面对难以辨识的 UUID。
@@ -83,18 +83,9 @@ Dynamic: license-file
83
83
 
84
84
  ## 安装说明
85
85
 
86
- ### 1. 安装底层依赖 `ccusage`
86
+ ### 直接使用 pip 安装(无需 Node.js / ccusage)
87
87
 
88
- `myccusage` 依赖开源工具 [ccusage](https://github.com/ryoppippi/ccusage) 收集底层数据,需先全局安装:
89
-
90
- ```bash
91
- npm install -g ccusage
92
- # 或使用 bun / pnpm:
93
- # bun add -g ccusage
94
- # pnpm add -g ccusage
95
- ```
96
-
97
- ### 2. 安装 `myccusage`
88
+ `myccusage` 现已内置各大 Agent 的原生 Python 高性能解析内核,**完全不依赖 Node.js 或外部 ccusage 工具**,直接 pip 即可安装并使用:
98
89
 
99
90
  ```bash
100
91
  pip install myccusage
@@ -131,10 +122,11 @@ myccusage --agy
131
122
  # 查看 Claude Code 每日账本
132
123
  myccusage --claude
133
124
 
134
- # 查看 Codex / Hermes / OpenCode
125
+ # 查看 Codex / Hermes / OpenCode / WorkBuddy
135
126
  myccusage --codex
136
127
  myccusage --hermes
137
128
  myccusage --opencode
129
+ myccusage --workbuddy
138
130
 
139
131
  # 查看某 Agent 各项目生命周期总消耗
140
132
  myccusage --agy -s
@@ -9,6 +9,16 @@ myccusage.egg-info/top_level.txt
9
9
  myccusage_lib/__init__.py
10
10
  myccusage_lib/cli.py
11
11
  myccusage_lib/core.py
12
+ myccusage_lib/adapters/__init__.py
13
+ myccusage_lib/adapters/agy.py
14
+ myccusage_lib/adapters/base.py
15
+ myccusage_lib/adapters/claude.py
16
+ myccusage_lib/adapters/codex.py
17
+ myccusage_lib/adapters/grok.py
18
+ myccusage_lib/adapters/hermes.py
19
+ myccusage_lib/adapters/opencode.py
20
+ myccusage_lib/adapters/pi.py
21
+ myccusage_lib/adapters/workbuddy.py
12
22
  myccusage_lib/web/__init__.py
13
23
  myccusage_lib/web/server.py
14
24
  myccusage_lib/web/static/app.js
@@ -2,4 +2,4 @@
2
2
  myccusage: 多 AI 编程 Agent 本地会话用量分析与 DeepSeek-V4.1-Flash 等效计费工具。
3
3
  """
4
4
 
5
- __version__ = "1.2.2"
5
+ __version__ = "1.3.0"
@@ -0,0 +1,48 @@
1
+ """
2
+ myccusage_lib.adapters:
3
+ Agent 原生适配器统一导出注册表:
4
+ 包含 8 大 Agent 的高性能适配器实现
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from .base import BaseAgentAdapter
10
+ from .workbuddy import WorkBuddyAdapter
11
+ from .claude import ClaudeAdapter
12
+ from .hermes import HermesAdapter
13
+ from .opencode import OpenCodeAdapter
14
+ from .codex import CodexAdapter
15
+ from .pi import PiAdapter
16
+ from .grok import GrokAdapter
17
+ from .agy import AntigravityAdapter
18
+
19
+ ADAPTERS = {
20
+ "workbuddy": WorkBuddyAdapter(),
21
+ "claude": ClaudeAdapter(),
22
+ "hermes": HermesAdapter(),
23
+ "opencode": OpenCodeAdapter(),
24
+ "codex": CodexAdapter(),
25
+ "pi": PiAdapter(),
26
+ "grok": GrokAdapter(),
27
+ "agy": AntigravityAdapter(),
28
+ }
29
+
30
+ def get_adapter(agent_type: str) -> BaseAgentAdapter:
31
+ """获取指定 Agent 适配器实例"""
32
+ if agent_type not in ADAPTERS:
33
+ raise ValueError(f"未知 Agent 类型: {agent_type},支持列表: {list(ADAPTERS.keys())}")
34
+ return ADAPTERS[agent_type]
35
+
36
+ __all__ = [
37
+ "BaseAgentAdapter",
38
+ "ADAPTERS",
39
+ "get_adapter",
40
+ "WorkBuddyAdapter",
41
+ "ClaudeAdapter",
42
+ "HermesAdapter",
43
+ "OpenCodeAdapter",
44
+ "CodexAdapter",
45
+ "PiAdapter",
46
+ "GrokAdapter",
47
+ "AntigravityAdapter",
48
+ ]
@@ -0,0 +1,199 @@
1
+ """
2
+ myccusage_lib.adapters.agy:
3
+ Google Antigravity 适配器:
4
+ - 双轨制设计 (Dual-track architecture):
5
+ 1. 若本地存在 ~/.cache/myccusage/agy_daily.json 则优先极速命中
6
+ 2. 若系统已安装 ccusage CLI,则安全调用底层 ccusage antigravity 抓取最新切片并落盘缓存
7
+ 3. 若系统未安装 ccusage CLI,则降级为本地直接解析 ~/.gemini/antigravity 目录
8
+ - 支持基于 agyhub_summaries_proto.pb 与 transcript.jsonl 的原生标题与 Prompt 提取
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import re
15
+ import json
16
+ import glob
17
+ import shutil
18
+ import subprocess
19
+ from datetime import datetime, timezone
20
+ from .base import BaseAgentAdapter, ts_to_iso, ts_to_date_str
21
+
22
+ class AntigravityAdapter(BaseAgentAdapter):
23
+ agent_id = "agy"
24
+ display_name = "Google Antigravity"
25
+ has_times = False
26
+
27
+ def __init__(self):
28
+ super().__init__()
29
+ self.base_dir = os.path.expanduser("~/.gemini/antigravity")
30
+ self.cache_dir = os.path.expanduser("~/.cache/myccusage")
31
+
32
+ def is_available(self) -> bool:
33
+ return os.path.exists(self.base_dir) or os.path.exists(os.path.join(self.cache_dir, "agy_daily.json"))
34
+
35
+ def get_titles_and_times(self) -> tuple[dict[str, str], dict[str, str]]:
36
+ titles = {}
37
+ times = {}
38
+ if not self.is_available():
39
+ return titles, times
40
+
41
+ proto_path = os.path.join(self.base_dir, "agyhub_summaries_proto.pb")
42
+ if os.path.exists(proto_path):
43
+ try:
44
+ with open(proto_path, "rb") as f:
45
+ data = f.read()
46
+ pattern = re.compile(rb"([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})")
47
+ matches = list(pattern.finditer(data))
48
+ for i, m in enumerate(matches):
49
+ uid = m.group(1).decode("ascii")
50
+ start = m.end()
51
+ end = matches[i+1].start() if i + 1 < len(matches) else len(data)
52
+ slice_data = data[start:min(start+300, end)]
53
+ text_matches = re.findall(rb"[\x20-\x7e\x80-\xff]{4,}", slice_data)
54
+ for tm in text_matches:
55
+ try:
56
+ t = tm.decode("utf-8").strip().strip("\"'%,!\t ")
57
+ if (len(t) > 3 and not re.match(r"^[0-9a-f-]{36}$", t)
58
+ and not t.startswith("outside")
59
+ and not t.startswith("file://")
60
+ and not t.startswith("git@")
61
+ and not t.startswith("main")
62
+ and not t.endswith("R")
63
+ and "/" not in t):
64
+ if uid not in titles:
65
+ titles[uid] = t
66
+ break
67
+ except Exception:
68
+ pass
69
+ except Exception:
70
+ pass
71
+
72
+ logs_glob = os.path.join(self.base_dir, "brain/*/.system_generated/logs/transcript.jsonl")
73
+ for log_path in glob.glob(logs_glob):
74
+ parts = log_path.split(os.sep)
75
+ uid = ""
76
+ for idx, p in enumerate(parts):
77
+ if p == "brain" and idx + 1 < len(parts):
78
+ uid = parts[idx + 1]
79
+ break
80
+ if not uid:
81
+ uid = parts[-4]
82
+
83
+ if uid not in titles or titles[uid].endswith(":") or len(titles[uid]) < 4:
84
+ try:
85
+ with open(log_path, "r", encoding="utf-8") as f:
86
+ first_line = f.readline()
87
+ if first_line:
88
+ obj = json.loads(first_line)
89
+ content = obj.get("content", "")
90
+ if "<USER_REQUEST>" in content:
91
+ req = content.split("<USER_REQUEST>")[1].split("</USER_REQUEST>")[0].strip()
92
+ else:
93
+ req = content.strip()
94
+ first_line_clean = req.split("\n")[0][:60].strip()
95
+ if first_line_clean:
96
+ titles[uid] = first_line_clean
97
+ except Exception:
98
+ pass
99
+
100
+ return titles, times
101
+
102
+ def fetch_data(self) -> tuple[dict[str, list[dict]], list[dict]]:
103
+ if not self.is_available():
104
+ return {}, []
105
+
106
+ cache_file = os.path.join(self.cache_dir, "agy_daily.json")
107
+ has_ccusage = shutil.which("ccusage") is not None
108
+
109
+ # 内存快速命中 (若 60 秒内已读取过,直接返回)
110
+ with self._lock:
111
+ if hasattr(self, "_cached_res") and self._cached_res:
112
+ c_time, c_val = self._cached_res
113
+ import time
114
+ if time.time() - c_time < 60.0:
115
+ return c_val
116
+
117
+ # 方案 A: 若有 ccusage 命令,调用 ccusage antigravity 抓取并同步缓存
118
+ if has_ccusage:
119
+ try:
120
+ res = subprocess.run(["ccusage", "antigravity", "session", "--json", "--offline"],
121
+ capture_output=True, text=True, timeout=10)
122
+ if res.returncode == 0:
123
+ data = json.loads(res.stdout)
124
+ raw_sessions = data.get("sessions", [])
125
+ daily_map = {}
126
+ session_list = []
127
+ for s in raw_sessions:
128
+ tot = s.get("totalTokens", 0)
129
+ if tot <= 0:
130
+ continue
131
+ sid = s.get("sessionId", "")
132
+ inp = s.get("inputTokens", 0)
133
+ cr = s.get("cacheReadTokens", 0)
134
+ out = s.get("outputTokens", 0)
135
+ last_act = s.get("lastActivity") or s.get("firstActivity") or ""
136
+ date_str = last_act[:10] if last_act else "1970-01-01"
137
+
138
+ rec = {
139
+ "sessionId": sid,
140
+ "date": date_str,
141
+ "inputTokens": inp,
142
+ "cacheReadTokens": cr,
143
+ "outputTokens": out,
144
+ "totalTokens": tot,
145
+ "lastActivity": last_act
146
+ }
147
+ if date_str not in daily_map:
148
+ daily_map[date_str] = []
149
+ daily_map[date_str].append(rec)
150
+
151
+ session_list.append({
152
+ "sessionId": sid,
153
+ "inputTokens": inp,
154
+ "cacheReadTokens": cr,
155
+ "outputTokens": out,
156
+ "totalTokens": tot,
157
+ "lastActivity": last_act
158
+ })
159
+
160
+ try:
161
+ os.makedirs(self.cache_dir, exist_ok=True)
162
+ with open(cache_file, "w", encoding="utf-8") as f:
163
+ json.dump(daily_map, f)
164
+ except Exception:
165
+ pass
166
+
167
+ res_pair = (daily_map, session_list)
168
+ with self._lock:
169
+ import time
170
+ self._cached_res = (time.time(), res_pair)
171
+ return res_pair
172
+ except Exception:
173
+ pass
174
+
175
+ # 方案 B: 若 ccusage 不可用或调用失败,读取本地 agy_daily.json 缓存
176
+ if os.path.exists(cache_file):
177
+ try:
178
+ with open(cache_file, "r", encoding="utf-8") as f:
179
+ daily_map = json.load(f)
180
+ session_list = []
181
+ for d_str, records in daily_map.items():
182
+ for r in records:
183
+ session_list.append({
184
+ "sessionId": r.get("sessionId"),
185
+ "inputTokens": r.get("inputTokens", 0),
186
+ "cacheReadTokens": r.get("cacheReadTokens", 0),
187
+ "outputTokens": r.get("outputTokens", 0),
188
+ "totalTokens": r.get("totalTokens", 0),
189
+ "lastActivity": r.get("lastActivity", "")
190
+ })
191
+ res_pair = (daily_map, session_list)
192
+ with self._lock:
193
+ import time
194
+ self._cached_res = (time.time(), res_pair)
195
+ return res_pair
196
+ except Exception:
197
+ pass
198
+
199
+ return {}, []
@@ -0,0 +1,74 @@
1
+ """
2
+ myccusage_lib.adapters.base:
3
+ Agent 适配器基类与公共工具函数:
4
+ - 统一定义 (daily_map, session_list) 数据返回契约
5
+ - 文件级 mtime/size 防抖缓存通用工具
6
+ - 统一毫秒/秒级时间戳转换与 ISO 格式化
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import threading
13
+ from datetime import datetime, timezone
14
+
15
+ class BaseAgentAdapter:
16
+ agent_id = ""
17
+ display_name = ""
18
+ has_times = False
19
+
20
+ def __init__(self):
21
+ self._file_cache = {}
22
+ self._lock = threading.Lock()
23
+
24
+ def is_available(self) -> bool:
25
+ """检测当前 Agent 本地数据源是否存在"""
26
+ raise NotImplementedError
27
+
28
+ def get_titles_and_times(self) -> tuple[dict[str, str], dict[str, str]]:
29
+ """
30
+ 提取会话原生标题映射与时间覆盖映射:
31
+ - titles: { sessionId: title }
32
+ - times: { sessionId: iso_timestamp }
33
+ """
34
+ return {}, {}
35
+
36
+ def fetch_data(self) -> tuple[dict[str, list[dict]], list[dict]]:
37
+ """
38
+ 高性能提取原始数据:
39
+ - daily_map: { "YYYY-MM-DD": [ {sessionId, date, inputTokens, cacheReadTokens, outputTokens, totalTokens, lastActivity}, ... ] }
40
+ - session_list: [ {sessionId, inputTokens, cacheReadTokens, outputTokens, totalTokens, lastActivity}, ... ]
41
+ """
42
+ raise NotImplementedError
43
+
44
+ def ts_to_iso(ts_seconds: float) -> str:
45
+ """浮点/整数秒转 ISO 8601 字符串"""
46
+ if not ts_seconds:
47
+ return ""
48
+ try:
49
+ return datetime.fromtimestamp(ts_seconds, timezone.utc).isoformat()
50
+ except Exception:
51
+ return ""
52
+
53
+ def ms_to_iso(ts_ms: int) -> str:
54
+ """毫秒时间戳转 ISO 8601 字符串"""
55
+ if not ts_ms:
56
+ return ""
57
+ try:
58
+ return datetime.fromtimestamp(ts_ms / 1000.0, timezone.utc).isoformat()
59
+ except Exception:
60
+ return ""
61
+
62
+ def ts_to_date_str(ts_seconds: float) -> str:
63
+ """浮点/整数秒转 YYYY-MM-DD 本地日期"""
64
+ try:
65
+ return datetime.fromtimestamp(ts_seconds).strftime("%Y-%m-%d")
66
+ except Exception:
67
+ return "1970-01-01"
68
+
69
+ def ms_to_date_str(ts_ms: int) -> str:
70
+ """毫秒时间戳转 YYYY-MM-DD 本地日期"""
71
+ try:
72
+ return datetime.fromtimestamp(ts_ms / 1000.0).strftime("%Y-%m-%d")
73
+ except Exception:
74
+ return "1970-01-01"
@@ -0,0 +1,189 @@
1
+ """
2
+ myccusage_lib.adapters.claude:
3
+ Claude Code 原生适配器:
4
+ - 读取 ~/.claude/history.jsonl 与 ~/.claude/projects/*/*.jsonl
5
+ - 流式提取 Token,并根据 message.id 进行精准幂等去重 (解决 thinking 与 text 重复上报)
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import glob
12
+ import json
13
+ from datetime import datetime, timezone
14
+ from .base import BaseAgentAdapter
15
+
16
+ class ClaudeAdapter(BaseAgentAdapter):
17
+ agent_id = "claude"
18
+ display_name = "Claude Code"
19
+ has_times = False
20
+
21
+ def __init__(self):
22
+ super().__init__()
23
+ self.base_dir = os.path.expanduser("~/.claude")
24
+ self.projects_dir = os.path.join(self.base_dir, "projects")
25
+
26
+ def is_available(self) -> bool:
27
+ return os.path.exists(self.base_dir)
28
+
29
+ def get_titles_and_times(self) -> tuple[dict[str, str], dict[str, str]]:
30
+ titles = {}
31
+ times = {}
32
+ if not self.is_available():
33
+ return titles, times
34
+
35
+ history_file = os.path.join(self.base_dir, "history.jsonl")
36
+ if os.path.exists(history_file):
37
+ try:
38
+ with open(history_file, "r", encoding="utf-8") as f:
39
+ for line in f:
40
+ try:
41
+ obj = json.loads(line)
42
+ sid = obj.get("sessionId")
43
+ disp = obj.get("display", "").strip()
44
+ if sid and disp and sid not in titles:
45
+ titles[sid] = disp[:60]
46
+ except Exception:
47
+ pass
48
+ except Exception:
49
+ pass
50
+
51
+ for p in glob.glob(os.path.join(self.projects_dir, "*/*.jsonl")):
52
+ sid = os.path.basename(p).replace(".jsonl", "")
53
+ if sid not in titles or titles[sid].startswith("/"):
54
+ try:
55
+ with open(p, "r", encoding="utf-8") as f:
56
+ for line in f:
57
+ try:
58
+ obj = json.loads(line)
59
+ except Exception:
60
+ continue
61
+ if obj.get("type") == "user":
62
+ msg = obj.get("message", {})
63
+ cnt = msg.get("content")
64
+ if isinstance(cnt, str) and cnt.strip():
65
+ titles[sid] = cnt.split("\n")[0][:60].strip()
66
+ break
67
+ elif isinstance(cnt, list):
68
+ for item in cnt:
69
+ if isinstance(item, dict) and item.get("text"):
70
+ titles[sid] = item["text"].split("\n")[0][:60].strip()
71
+ break
72
+ if sid in titles:
73
+ break
74
+ except Exception:
75
+ pass
76
+ return titles, times
77
+
78
+ def fetch_data(self) -> tuple[dict[str, list[dict]], list[dict]]:
79
+ if not self.is_available():
80
+ return {}, []
81
+
82
+ daily_map = {}
83
+ session_map = {}
84
+
85
+ files = glob.glob(os.path.join(self.projects_dir, "*/*.jsonl"))
86
+
87
+ with self._lock:
88
+ for fpath in files:
89
+ try:
90
+ stat = os.stat(fpath)
91
+ except OSError:
92
+ continue
93
+
94
+ mtime, size = stat.st_mtime, stat.st_size
95
+ cached = self._file_cache.get(fpath)
96
+ if cached and cached[0] == mtime and cached[1] == size:
97
+ records = cached[2]
98
+ else:
99
+ records = []
100
+ sid = os.path.basename(fpath).replace(".jsonl", "")
101
+ seen_msg_ids = {} # msg_id -> (date_str, iso_str, inp, cr, cw, out, tot)
102
+ try:
103
+ with open(fpath, "r", encoding="utf-8") as f:
104
+ for idx, line in enumerate(f):
105
+ if "usage" not in line:
106
+ continue
107
+ try:
108
+ obj = json.loads(line)
109
+ except Exception:
110
+ continue
111
+ msg = obj.get("message") or {}
112
+ mid = msg.get("id")
113
+ u = msg.get("usage")
114
+ if not u:
115
+ continue
116
+
117
+ ts_str = obj.get("timestamp") or ""
118
+ if ts_str:
119
+ try:
120
+ dt = datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
121
+ date_str = dt.astimezone().strftime("%Y-%m-%d")
122
+ iso_str = dt.astimezone(timezone.utc).isoformat()
123
+ except Exception:
124
+ date_str = "1970-01-01"
125
+ iso_str = ts_str
126
+ else:
127
+ date_str = "1970-01-01"
128
+ iso_str = ""
129
+
130
+ inp = u.get("input_tokens", 0)
131
+ cr = u.get("cache_read_input_tokens", 0)
132
+ cw = u.get("cache_creation_input_tokens", 0)
133
+ out = u.get("output_tokens", 0)
134
+ tot = inp + cr + cw + out
135
+ if tot == 0:
136
+ continue
137
+
138
+ key = mid if mid else f"line_{idx}"
139
+ # 保留最新的那一次上报(通常包含完整的 thinking + output_tokens)
140
+ seen_msg_ids[key] = (sid, date_str, iso_str, inp + cw, cr, out, tot)
141
+
142
+ records = list(seen_msg_ids.values())
143
+ except Exception:
144
+ pass
145
+ self._file_cache[fpath] = (mtime, size, records)
146
+
147
+ for sid, date_str, iso_str, inp, cr, out, tot in records:
148
+ # 每日切片
149
+ if date_str not in daily_map:
150
+ daily_map[date_str] = {}
151
+ if sid not in daily_map[date_str]:
152
+ daily_map[date_str][sid] = {
153
+ "sessionId": sid,
154
+ "date": date_str,
155
+ "inputTokens": 0,
156
+ "cacheReadTokens": 0,
157
+ "outputTokens": 0,
158
+ "totalTokens": 0,
159
+ "lastActivity": iso_str
160
+ }
161
+ ds = daily_map[date_str][sid]
162
+ ds["inputTokens"] += inp
163
+ ds["cacheReadTokens"] += cr
164
+ ds["outputTokens"] += out
165
+ ds["totalTokens"] += tot
166
+ if iso_str > ds["lastActivity"]:
167
+ ds["lastActivity"] = iso_str
168
+
169
+ # 项目生命周期汇总
170
+ if sid not in session_map:
171
+ session_map[sid] = {
172
+ "sessionId": sid,
173
+ "inputTokens": 0,
174
+ "cacheReadTokens": 0,
175
+ "outputTokens": 0,
176
+ "totalTokens": 0,
177
+ "lastActivity": iso_str
178
+ }
179
+ ss = session_map[sid]
180
+ ss["inputTokens"] += inp
181
+ ss["cacheReadTokens"] += cr
182
+ ss["outputTokens"] += out
183
+ ss["totalTokens"] += tot
184
+ if iso_str > ss["lastActivity"]:
185
+ ss["lastActivity"] = iso_str
186
+
187
+ daily_res = {d: list(s_dict.values()) for d, s_dict in daily_map.items()}
188
+ session_res = list(session_map.values())
189
+ return daily_res, session_res