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.
- {myccusage-1.2.2 → myccusage-1.3.0}/PKG-INFO +6 -14
- {myccusage-1.2.2 → myccusage-1.3.0}/README.md +5 -13
- {myccusage-1.2.2 → myccusage-1.3.0}/myccusage.egg-info/PKG-INFO +6 -14
- {myccusage-1.2.2 → myccusage-1.3.0}/myccusage.egg-info/SOURCES.txt +10 -0
- {myccusage-1.2.2 → myccusage-1.3.0}/myccusage_lib/__init__.py +1 -1
- myccusage-1.3.0/myccusage_lib/adapters/__init__.py +48 -0
- myccusage-1.3.0/myccusage_lib/adapters/agy.py +199 -0
- myccusage-1.3.0/myccusage_lib/adapters/base.py +74 -0
- myccusage-1.3.0/myccusage_lib/adapters/claude.py +189 -0
- myccusage-1.3.0/myccusage_lib/adapters/codex.py +230 -0
- myccusage-1.3.0/myccusage_lib/adapters/grok.py +169 -0
- myccusage-1.3.0/myccusage_lib/adapters/hermes.py +108 -0
- myccusage-1.3.0/myccusage_lib/adapters/opencode.py +107 -0
- myccusage-1.3.0/myccusage_lib/adapters/pi.py +159 -0
- myccusage-1.3.0/myccusage_lib/adapters/workbuddy.py +196 -0
- {myccusage-1.2.2 → myccusage-1.3.0}/myccusage_lib/cli.py +5 -1
- {myccusage-1.2.2 → myccusage-1.3.0}/myccusage_lib/core.py +274 -65
- {myccusage-1.2.2 → myccusage-1.3.0}/myccusage_lib/web/static/app.js +107 -1
- {myccusage-1.2.2 → myccusage-1.3.0}/myccusage_lib/web/static/index.html +25 -0
- {myccusage-1.2.2 → myccusage-1.3.0}/myccusage_lib/web/static/style.css +13 -0
- {myccusage-1.2.2 → myccusage-1.3.0}/pyproject.toml +1 -1
- {myccusage-1.2.2 → myccusage-1.3.0}/LICENSE +0 -0
- {myccusage-1.2.2 → myccusage-1.3.0}/myccusage.egg-info/dependency_links.txt +0 -0
- {myccusage-1.2.2 → myccusage-1.3.0}/myccusage.egg-info/entry_points.txt +0 -0
- {myccusage-1.2.2 → myccusage-1.3.0}/myccusage.egg-info/top_level.txt +0 -0
- {myccusage-1.2.2 → myccusage-1.3.0}/myccusage_lib/web/__init__.py +0 -0
- {myccusage-1.2.2 → myccusage-1.3.0}/myccusage_lib/web/server.py +0 -0
- {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.
|
|
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
|
-
###
|
|
86
|
+
### 直接使用 pip 安装(无需 Node.js / ccusage)
|
|
87
87
|
|
|
88
|
-
`myccusage`
|
|
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
|
-
###
|
|
49
|
+
### 直接使用 pip 安装(无需 Node.js / ccusage)
|
|
50
50
|
|
|
51
|
-
`myccusage`
|
|
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.
|
|
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
|
-
###
|
|
86
|
+
### 直接使用 pip 安装(无需 Node.js / ccusage)
|
|
87
87
|
|
|
88
|
-
`myccusage`
|
|
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
|
|
@@ -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
|