tokenmetre 0.2.0__py3-none-any.whl

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 (37) hide show
  1. tokenmetre/__init__.py +1 -0
  2. tokenmetre/adapters/__init__.py +1 -0
  3. tokenmetre/adapters/claude-code/__init__.py +1 -0
  4. tokenmetre/adapters/claude-code/adapter.py +498 -0
  5. tokenmetre/adapters/claude-code/commands/tokenmetre-report.md +69 -0
  6. tokenmetre/adapters/claude-code/commands/tokenmetre-scan.md +92 -0
  7. tokenmetre/adapters/claude-code/install.py +248 -0
  8. tokenmetre/adapters/claude-code/skills/tokenmetre/SKILL.md +158 -0
  9. tokenmetre/adapters/claude-code/skills/tokenmetre-pricing/SKILL.md +131 -0
  10. tokenmetre/adapters/trae/__init__.py +8 -0
  11. tokenmetre/adapters/trae/adapter.py +263 -0
  12. tokenmetre/adapters/trae/decrypt.py +394 -0
  13. tokenmetre/adapters/trae/extractor.py +244 -0
  14. tokenmetre/cli/__init__.py +0 -0
  15. tokenmetre/cli/main.py +586 -0
  16. tokenmetre/core/__init__.py +1 -0
  17. tokenmetre/core/bind.py +328 -0
  18. tokenmetre/core/core.py +24 -0
  19. tokenmetre/core/db.py +534 -0
  20. tokenmetre/core/domain.py +497 -0
  21. tokenmetre/core/emit.py +302 -0
  22. tokenmetre/core/gconfig.py +103 -0
  23. tokenmetre/core/loader.py +86 -0
  24. tokenmetre/core/registry.py +622 -0
  25. tokenmetre/data/weights.yaml +128 -0
  26. tokenmetre/web/__init__.py +0 -0
  27. tokenmetre/web/fonts/ibmplexmono-400.woff2 +0 -0
  28. tokenmetre/web/fonts/ibmplexmono-500.woff2 +0 -0
  29. tokenmetre/web/frontend/dist/assets/index-DpkkjUZ8.css +1 -0
  30. tokenmetre/web/frontend/dist/assets/index-yIGPRcR5.js +18 -0
  31. tokenmetre/web/frontend/dist/index.html +14 -0
  32. tokenmetre/web/server.py +732 -0
  33. tokenmetre-0.2.0.dist-info/METADATA +189 -0
  34. tokenmetre-0.2.0.dist-info/RECORD +37 -0
  35. tokenmetre-0.2.0.dist-info/WHEEL +5 -0
  36. tokenmetre-0.2.0.dist-info/entry_points.txt +2 -0
  37. tokenmetre-0.2.0.dist-info/top_level.txt +1 -0
tokenmetre/__init__.py ADDED
@@ -0,0 +1 @@
1
+ 
@@ -0,0 +1 @@
1
+ 
@@ -0,0 +1 @@
1
+ 
@@ -0,0 +1,498 @@
1
+ """
2
+ tokenMetre CC adapter: transcript 解析 + scan 入库。
3
+
4
+ v2 架构(无 hook, codeburn 风格 per-message):
5
+ - 直接读 ~/.claude/projects/<sanitized_cwd>/*.jsonl transcript
6
+ - 每条 assistant message → 一条 Record(UNIQUE session_id + ts 天然去重)
7
+ - scan 游标用 SQLite processed_sessions 表
8
+ - 零 hook 依赖、零 mark 文件、零实时注入
9
+
10
+ 用法:
11
+ python adapters/claude-code/adapter.py scan --project <cwd>
12
+ python adapters/claude-code/adapter.py scan --all
13
+ python adapters/claude-code/adapter.py scan --status
14
+ python adapters/claude-code/adapter.py scan --dry-run
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import json
20
+ import os
21
+ import re
22
+ import sys
23
+ from datetime import datetime, timezone
24
+ from pathlib import Path
25
+
26
+ # pip 安装后直接用包命名空间(loader 动态加载本文件, tokenmetre 已在 site-packages)
27
+ from tokenmetre.core.core import (
28
+ Record, derive_host, derive_user, derive_category, derive_provider,
29
+ )
30
+ from tokenmetre.core import db, registry, bind
31
+
32
+
33
+ # ============================================================
34
+ # CC transcript 路径处理
35
+ # ============================================================
36
+
37
+ def _sanitize_cwd_for_cc(cwd: str) -> str:
38
+ r"""把 Windows 绝对路径转 CC projects 目录名格式。
39
+ 例: E:\work\ai\tokenMetre → E--work-ai-tokenMetre"""
40
+ s = str(cwd).rstrip("/\\")
41
+ # 盘符: E: → E--
42
+ s = re.sub(r'^([A-Za-z]):\\', r'\1--', s)
43
+ # 把 \ 换成 -
44
+ s = s.replace("\\", "-")
45
+ s = s.replace("/", "-")
46
+ # 去掉末尾的 -
47
+ s = s.rstrip("-")
48
+ return s
49
+
50
+
51
+ def _find_cc_project_dir(cwd: str) -> Path | None:
52
+ """找 CC projects 下对应 cwd 的目录。精确匹配失败时大小写不敏感兜底。"""
53
+ cc_dir = Path.home() / ".claude" / "projects"
54
+ if not cc_dir.is_dir():
55
+ return None
56
+ exact = _sanitize_cwd_for_cc(cwd)
57
+ exact_path = cc_dir / exact
58
+ if exact_path.is_dir():
59
+ return exact_path
60
+ # 大小写不敏感兜底(CC 可能对同项目用不同盘符大小写)
61
+ exact_lower = exact.lower()
62
+ for child in cc_dir.iterdir():
63
+ if child.is_dir() and child.name.lower() == exact_lower:
64
+ return child
65
+ return None
66
+
67
+
68
+ # ============================================================
69
+ # transcript 解析(per-turn, 纯函数)
70
+ # ============================================================
71
+
72
+ def parse_transcript(transcript_path: str) -> list[dict]:
73
+ """解析 CC transcript JSONL, 返回 LLM 调用组列表(同 token = 同一次 LLM 调用 = 一条 Record)。
74
+
75
+ 每条结果:
76
+ {
77
+ "ts": "2026-09-10T15:30:00+08:00",
78
+ "model": "deepseek-flash[1M]",
79
+ "token_in": 1500, "token_cache": 800, "token_out": 300,
80
+ "tool_names": ["Read", "Read", "Bash"],
81
+ "file_exts": [".md"],
82
+ "has_text": True,
83
+ }
84
+
85
+ 处理逻辑:
86
+ 1. 遍历 entry, 只关心 type="assistant" (跳过 user/attachment/queue-operation 等所有非 assistant)
87
+ 2. 提取 tool_names / file_exts / has_text / usage tokens
88
+ 3. 过滤纯 thinking 碎片(has_thinking=True 且 无 text 无 tools)和 token=0 空消息
89
+ 4. 按 token_total 连续分组: token 相同 → 同一次 LLM 调用 → 合并成一条
90
+ 5. 同 token 组内合并: token 取第一条(相同), tools/exts 取并集
91
+ """
92
+ p = Path(transcript_path).expanduser()
93
+ if not p.exists():
94
+ return []
95
+
96
+ # Step 1+2: 收集所有 assistant entry, 提取字段
97
+ raw: list[dict] = []
98
+ for line in p.read_text(encoding="utf-8").splitlines():
99
+ line = line.strip()
100
+ if not line:
101
+ continue
102
+ try:
103
+ entry = json.loads(line)
104
+ except json.JSONDecodeError:
105
+ continue
106
+
107
+ # 只关心 assistant
108
+ if entry.get("type") != "assistant":
109
+ continue
110
+
111
+ msg = entry.get("message") or {}
112
+ content = msg.get("content") or []
113
+ if not isinstance(content, list):
114
+ continue
115
+
116
+ tool_names: list[str] = []
117
+ file_exts: list[str] = []
118
+ bash_cmds: list[str] = []
119
+ has_thinking = False
120
+ has_text = False
121
+
122
+ for c in content:
123
+ if not isinstance(c, dict):
124
+ continue
125
+ ctype = c.get("type", "")
126
+ if ctype == "tool_use":
127
+ tname = c.get("name", "")
128
+ tool_names.append(tname)
129
+ inp = c.get("input") or {}
130
+ if tname in ("Write", "Edit", "MultiEdit", "Patch", "Replace") and isinstance(inp, dict):
131
+ fp = inp.get("file_path") or inp.get("path") or ""
132
+ if fp:
133
+ ext = os.path.splitext(fp)[1].lower()
134
+ if ext:
135
+ file_exts.append(ext)
136
+ elif tname in ("Bash", "RunCommand", "Terminal", "Execute") and isinstance(inp, dict):
137
+ cmd = inp.get("command", "")
138
+ if cmd:
139
+ bash_cmds.append(cmd)
140
+ elif ctype == "thinking":
141
+ has_thinking = True
142
+ elif ctype == "text":
143
+ has_text = True
144
+
145
+ usage = msg.get("usage") or {}
146
+ token_in = int(usage.get("input_tokens") or usage.get("prompt_tokens") or 0)
147
+ token_cache = int(usage.get("cache_read_input_tokens") or usage.get("cache_read_tokens") or 0)
148
+ token_out = int(usage.get("output_tokens") or usage.get("completion_tokens") or 0)
149
+ token_total = token_in + token_cache + token_out
150
+
151
+ raw.append({
152
+ "ts": msg.get("timestamp") or entry.get("timestamp") or "",
153
+ "model": msg.get("model", "unknown"),
154
+ "token_in": token_in,
155
+ "token_cache": token_cache,
156
+ "token_out": token_out,
157
+ "token_total": token_total,
158
+ "tool_names": tool_names,
159
+ "file_exts": file_exts,
160
+ "bash_cmds": bash_cmds,
161
+ "has_thinking": has_thinking,
162
+ "has_text": has_text,
163
+ # 归属真相: 每条 entry 自带的工作目录(实测 100% 有值)。
164
+ # 一个 session 内可能漂移多个项目, 所以必须逐条记, 不能取会话目录。
165
+ "cwd": entry.get("cwd") or "",
166
+ })
167
+
168
+ # Step 3: 过滤纯 thinking 碎片 + token_total=0 空消息
169
+ valid = [m for m in raw
170
+ if not (m["has_thinking"] and not m["has_text"] and not m["tool_names"])
171
+ and m["token_total"] > 0]
172
+
173
+ if not valid:
174
+ return []
175
+
176
+ # Step 4+5: 按 token_total 连续分组(同 token = 同一次 LLM 调用)
177
+ result: list[dict] = []
178
+ cur_group: list[dict] = [valid[0]]
179
+
180
+ for m in valid[1:]:
181
+ if m["token_total"] == cur_group[0]["token_total"]:
182
+ cur_group.append(m)
183
+ else:
184
+ merged = _merge_group(cur_group)
185
+ if merged:
186
+ result.append(merged)
187
+ cur_group = [m]
188
+
189
+ merged = _merge_group(cur_group)
190
+ if merged:
191
+ result.append(merged)
192
+
193
+ return result
194
+
195
+
196
+ def _merge_group(group: list[dict]) -> dict | None:
197
+ """把同 token 的一组 assistant messages 合并成一条 Record 输入。"""
198
+ if not group:
199
+ return None
200
+
201
+ first = group[0]
202
+ all_tools: list[str] = []
203
+ all_exts: list[str] = []
204
+ all_bash_cmds: list[str] = []
205
+ any_text = False
206
+
207
+ for m in group:
208
+ all_tools.extend(m["tool_names"])
209
+ all_exts.extend(m["file_exts"])
210
+ all_bash_cmds.extend(m.get("bash_cmds", []))
211
+ if m["has_text"]:
212
+ any_text = True
213
+
214
+ if not all_tools and not any_text:
215
+ return None
216
+
217
+ return {
218
+ "ts": first["ts"],
219
+ "model": first["model"],
220
+ "token_in": first["token_in"], # 同组 token 全相同, 取第一条
221
+ "token_cache": first["token_cache"],
222
+ "token_out": first["token_out"],
223
+ "tool_names": all_tools,
224
+ "file_exts": list(dict.fromkeys(all_exts)),
225
+ "bash_cmds": all_bash_cmds,
226
+ "has_text": any_text,
227
+ # 同一次 LLM 调用的所有 entry 共享 cwd; 取组内首个非空值兜底
228
+ "cwd": next((m.get("cwd") for m in group if m.get("cwd")), ""),
229
+ }
230
+
231
+
232
+ # ============================================================
233
+ # 进度条(零依赖 ASCII, 单行覆盖刷新)
234
+ # ============================================================
235
+
236
+ def _progress(current: int, total: int, width: int = 30, label: str = "") -> None:
237
+ """单行 ASCII 进度条: 每步覆盖写同一行, 完成后换行。"""
238
+ if total <= 0:
239
+ return
240
+ pct = current / total
241
+ filled = int(width * pct)
242
+ bar = "█" * filled + "░" * (width - filled)
243
+ tail = f" {current}/{total}"
244
+ if label:
245
+ tail = f" {label}{tail}"
246
+ sys.stdout.write(f"\r [{bar}]{tail}")
247
+ sys.stdout.flush()
248
+ if current >= total:
249
+ sys.stdout.write("\n")
250
+ sys.stdout.flush()
251
+
252
+
253
+ # ============================================================
254
+ # scan 核心: transcript → Record → SQLite
255
+ # ============================================================
256
+
257
+ def _transcript_to_record(
258
+ msg: dict,
259
+ session_id: str,
260
+ source: str = "claude-code",
261
+ ) -> Record | None:
262
+ """把 parse_transcript 的输出 → Record。
263
+
264
+ 只做确定性映射, 不调 LLM, **不读 registry** —— 采集层是纯函数,
265
+ 同输入永远同输出。归属由消息自带的 cwd 承载, 读时/上报时才解析。
266
+ """
267
+ ts = msg.get("ts") or ""
268
+ if not ts:
269
+ return None
270
+
271
+ return Record(
272
+ ts=ts,
273
+ session_id=session_id,
274
+ source=source,
275
+ user=derive_user(),
276
+ host=derive_host(),
277
+ provider=derive_provider(msg["model"]), # 由模型名确定性反推(transcript 无厂商字段)
278
+ model=msg["model"],
279
+ token_in=msg["token_in"],
280
+ token_cache=msg["token_cache"],
281
+ token_out=msg["token_out"],
282
+ cwd=msg.get("cwd") or "",
283
+ category=derive_category(
284
+ tool_names=msg.get("tool_names", []),
285
+ file_exts=msg.get("file_exts"),
286
+ has_text=msg.get("has_text", False),
287
+ bash_cmds=msg.get("bash_cmds"),
288
+ ),
289
+ )
290
+
291
+
292
+ def _all_transcript_dirs() -> list[Path]:
293
+ """~/.claude/projects/ 下所有会话目录。"""
294
+ cc_dir = Path.home() / ".claude" / "projects"
295
+ if not cc_dir.is_dir():
296
+ return []
297
+ return sorted(p for p in cc_dir.iterdir() if p.is_dir())
298
+
299
+
300
+ def scan_transcripts(
301
+ dirs: list[Path] | None = None,
302
+ force_rescan: bool = False,
303
+ dry_run: bool = False,
304
+ ) -> dict:
305
+ """扫描 transcript → Record → SQLite。
306
+
307
+ **扫描源与归属解耦**: 一条会话住在哪个目录不再决定它属于哪个项目 ——
308
+ 每条 record 自带 cwd, 归属在「读时统计」和「上报云端」时才解析。
309
+ 所以这里不做任何 registry 过滤, 命中什么扫什么; 未登记项目的 record
310
+ 留在库里, 等 `register add` / `register claim` 认领后自动出现。
311
+
312
+ 返回 {"transcripts_found","sessions_scanned","sessions_skipped",
313
+ "messages_parsed","records_inserted","records_updated"}
314
+ """
315
+ dirs = _all_transcript_dirs() if dirs is None else dirs
316
+ transcripts: list[Path] = []
317
+ for d in dirs:
318
+ transcripts.extend(sorted(d.glob("*.jsonl")))
319
+
320
+ total_transcripts = len(transcripts)
321
+
322
+ # 先按游标筛出真正要处理的 session, 进度条只跑这一批 ——
323
+ # 否则增量扫描时也会为每个游标命中的 session 刷一帧, 白刷屏。
324
+ todo: list[Path] = []
325
+ skipped_sessions = 0
326
+ for t in transcripts:
327
+ if not force_rescan and db.is_session_processed(t.stem, t.stat().st_mtime):
328
+ skipped_sessions += 1
329
+ else:
330
+ todo.append(t)
331
+
332
+ sessions_scanned = 0
333
+ total_msgs = 0
334
+ total_inserted = 0
335
+ total_updated = 0
336
+
337
+ for done, transcript in enumerate(todo, 1):
338
+ session_id = transcript.stem
339
+ mtime = transcript.stat().st_mtime
340
+ if len(todo) > 1:
341
+ _progress(done, len(todo))
342
+
343
+ records_batch: list[Record] = []
344
+ for m in parse_transcript(str(transcript)):
345
+ total_msgs += 1
346
+ rec = _transcript_to_record(m, session_id)
347
+ if rec is not None:
348
+ records_batch.append(rec)
349
+
350
+ sessions_scanned += 1
351
+ if dry_run:
352
+ total_inserted += len(records_batch) # dry-run 假装全是新增
353
+ continue
354
+
355
+ # v3.2: 按每条 record 的归属项目的 cloud 配置, 分组写 DB
356
+ # 有 cloud → sync_status=pending, cloud_key=api_base
357
+ # 无 cloud → sync_status=synced, cloud_key=None
358
+ groups: dict[tuple[str, str | None], list] = {}
359
+ for rec in records_batch:
360
+ hit = registry.resolve_attribution(rec.cwd)
361
+ cloud_key: str | None = None
362
+ sync_status: str = "synced"
363
+ if hit:
364
+ pid, _slug = hit
365
+ cloud = registry.get_project_cloud(pid)
366
+ if cloud and cloud.get("api_base"):
367
+ sync_status = "pending"
368
+ cloud_key = cloud["api_base"]
369
+ groups.setdefault((sync_status, cloud_key), []).append(rec)
370
+
371
+ for (sync_status, cloud_key), group_records in groups.items():
372
+ n_ins, n_upd = db.insert_records_batch(
373
+ group_records, sync_status=sync_status, cloud_key=cloud_key,
374
+ )
375
+ total_inserted += n_ins
376
+ total_updated += n_upd
377
+
378
+ db.mark_session_processed(session_id, str(transcript), mtime)
379
+
380
+ return {
381
+ "transcripts_found": total_transcripts,
382
+ "sessions_scanned": sessions_scanned,
383
+ "sessions_skipped": skipped_sessions,
384
+ "messages_parsed": total_msgs,
385
+ "records_inserted": total_inserted,
386
+ "records_updated": total_updated,
387
+ }
388
+
389
+
390
+ def scan_project(cwd: str, force_rescan: bool = False, dry_run: bool = False) -> dict:
391
+ """只扫某个 cwd 对应的 transcript 目录。
392
+
393
+ 调试入口 —— 注意它**不再决定归属**, 只是缩小扫描范围。
394
+ """
395
+ proj_dir = _find_cc_project_dir(cwd)
396
+ if proj_dir is None:
397
+ return {
398
+ "transcripts_found": 0, "sessions_scanned": 0, "sessions_skipped": 0,
399
+ "messages_parsed": 0, "records_inserted": 0, "records_updated": 0,
400
+ "error": "CC projects 目录未找到(该项目可能还没有 transcript)",
401
+ }
402
+ return scan_transcripts([proj_dir], force_rescan=force_rescan, dry_run=dry_run)
403
+
404
+
405
+ def scan_all_active(force_rescan: bool = False, dry_run: bool = False) -> dict:
406
+ """兼容旧入口 —— 现在等同于全量扫所有 transcript(不再按 active 过滤)。"""
407
+ return scan_transcripts(force_rescan=force_rescan, dry_run=dry_run)
408
+
409
+
410
+ def scan_status() -> dict:
411
+ """transcript 处理状态 + 按归属聚合的 record 数。
412
+
413
+ 归属是读时解析的(按 record.cwd), 所以这里用 db.distinct_cwds() 拿到
414
+ 所有出现过的 cwd, 逐个解析归组 —— 未登记的会被 `resolve_attribution` 挡掉。
415
+ """
416
+ active_dirs = registry.load_registry().get("active", [])
417
+ processed = db.get_processed_session_ids()
418
+
419
+ transcripts: list[Path] = []
420
+ for d in _all_transcript_dirs():
421
+ transcripts.extend(d.glob("*.jsonl"))
422
+ unprocessed = sum(
423
+ 1 for t in transcripts
424
+ if not db.is_session_processed(t.stem, t.stat().st_mtime)
425
+ )
426
+
427
+ grouped: dict[str, dict] = {}
428
+ for cwd, n in db.distinct_cwds():
429
+ hit = registry.resolve_attribution(cwd)
430
+ if hit is None:
431
+ continue
432
+ pid, slug = hit
433
+ g = grouped.setdefault(pid, {"slug": slug, "cwds": 0, "records": 0})
434
+ g["cwds"] += 1
435
+ g["records"] += n
436
+
437
+ return {
438
+ "active_projects": len(active_dirs),
439
+ "total_transcripts": len(transcripts),
440
+ "processed_sessions": len(processed),
441
+ "unprocessed_sessions": unprocessed,
442
+ "total_records": db.count_records(),
443
+ "distinct_cwds": len(db.distinct_cwds()),
444
+ "pending_sync": bind.pending_pushable(),
445
+ "projects": [
446
+ {"project_id": pid, **v} for pid, v in sorted(grouped.items())
447
+ ],
448
+ }
449
+
450
+
451
+ # ============================================================
452
+ # CLI 入口(直接跑 adapter.py 时用; 实际扫描入口主要通过 cli/main.py scan)
453
+ # ============================================================
454
+
455
+ def main():
456
+ if hasattr(sys.stdout, "reconfigure"):
457
+ sys.stdout.reconfigure(encoding="utf-8")
458
+ ap = argparse.ArgumentParser(prog="tokenmetre-adapter-scan")
459
+ sub = ap.add_subparsers(dest="command", required=True)
460
+
461
+ p_scan = sub.add_parser("scan")
462
+ p_scan.add_argument("--project", help="只扫该 cwd 的 transcript 目录(默认全扫)")
463
+ p_scan.add_argument("--all", action="store_true", help="(默认行为, 保留兼容)")
464
+ p_scan.add_argument("--force", action="store_true", help="忽略游标, 全量重扫")
465
+ p_scan.add_argument("--dry-run", action="store_true", help="只看会产出什么, 不实际写")
466
+
467
+ sub.add_parser("status")
468
+
469
+ args = ap.parse_args()
470
+
471
+ if args.command == "scan":
472
+ if args.project:
473
+ result = scan_project(args.project, force_rescan=args.force, dry_run=args.dry_run)
474
+ else:
475
+ result = scan_transcripts(force_rescan=args.force, dry_run=args.dry_run)
476
+ if "error" in result:
477
+ print(f"[FAIL] {result['error']}")
478
+ sys.exit(1)
479
+ tag = "(dry-run) " if args.dry_run else ""
480
+ print(f"{tag}sessions: {result.get('sessions_scanned', 0)} "
481
+ f"msgs: {result.get('messages_parsed', 0)} "
482
+ f"inserted: {result['records_inserted']} "
483
+ f"updated: {result.get('records_updated', 0)}")
484
+
485
+ elif args.command == "status":
486
+ s = scan_status()
487
+ print(f"active projects: {s['active_projects']}")
488
+ print(f"transcripts: {s['total_transcripts']}")
489
+ print(f"processed sessions: {s['processed_sessions']}")
490
+ print(f"unprocessed sessions: {s['unprocessed_sessions']}")
491
+ print(f"total records: {s['total_records']} (distinct cwd: {s['distinct_cwds']})")
492
+ print(f"pending sync: {s['pending_sync']}")
493
+ for p in s["projects"]:
494
+ print(f" {p['slug']}: {p['cwds']} cwds, {p['records']} records")
495
+
496
+
497
+ if __name__ == "__main__":
498
+ main()
@@ -0,0 +1,69 @@
1
+ ---
2
+ name: tokenmetre-report
3
+ description: 汇总 tokenMetre 采集的 token 数据,按 source/model 聚合 unified_token,输出表格报告。当用户问"token 用了多少""开发成本多少""unified_token"时使用。
4
+ ---
5
+
6
+ # tokenmetre-report
7
+
8
+ 产出 tokenMetre 数据汇总报告。**先 scan 再 report**——scan 是唯一的入库入口。
9
+
10
+ ## 执行步骤
11
+
12
+ 1. 先 scan 最新 transcript,确保 SQLite 有新数据:
13
+
14
+ ```bash
15
+ python "$HOME/.tokenmetre/bin/tokenmetre" scan
16
+ ```
17
+
18
+ `tokenmetre` **不在 PATH 上,必须走绝对路径**(直接执行 `~/.tokenmetre/bin/tokenmetre` 会因 shebang 是 `python3` 而静默失败)。
19
+
20
+ 想跨项目汇总就先 `scan --all`。
21
+
22
+ 2. 生成报告(已内置聚合 + unified_token):
23
+
24
+ ```bash
25
+ python "$HOME/.tokenmetre/bin/tokenmetre" report
26
+ ```
27
+
28
+ 常用选项:
29
+
30
+ ```bash
31
+ python "$T" report --all # 跨所有项目
32
+ python "$T" report --project <slug> # 只看某项目
33
+ python "$T" report --from <ISO8601> --to <ISO8601>
34
+ ```
35
+
36
+ ⚠️ **没有** `--category` 参数。要按 category 拆分,直接查库:
37
+
38
+ ```bash
39
+ python - <<'PY'
40
+ import sqlite3, pathlib
41
+ c = sqlite3.connect(str(pathlib.Path.home()/'.tokenmetre'/'tokenmetre.db'))
42
+ for r in c.execute('select category, count(*), sum(token_in), sum(token_cache), sum(token_out) '
43
+ 'from records group by category order by 2 desc'):
44
+ print(r)
45
+ PY
46
+ ```
47
+
48
+ 数据源:LocalEmit 模式读本地 SQLite;HttpEmit 模式拉云端。云端模式有 pending sync 时会标注提示。
49
+
50
+ 3. unified_token 用 `~/.tokenmetre/data/weights.yaml` 的权重算:
51
+
52
+ ```
53
+ unified = (token_in*price_in + token_cache*price_cache + token_out*price_out)
54
+ / (price_in + price_cache + price_out)
55
+ ```
56
+
57
+ 某模型算不出 unified_token(显示为 0 / None)说明 `weights.yaml` 未收录它,
58
+ 用 `/tokenmetre-pricing` skill 补价格。
59
+
60
+ 4. 按 source + model 聚合,category 作为辅助维度展示。
61
+
62
+ 5. 用 markdown 表格输出,字段顺序:`source | model | 记录数 | token_in | token_cache | token_out | unified_token`
63
+
64
+ ## 如果没有 records
65
+
66
+ 提示 "no records yet. run `tokenmetre scan` to import transcript data."。
67
+
68
+ 排查顺序:① 项目是否 `register add` 登记过 → ② 该目录下 CC 是否真的产生过 transcript
69
+ (`~/.claude/projects/<sanitized-path>/*.jsonl`)→ ③ 跑 `scan --status` 看游标状态。
@@ -0,0 +1,92 @@
1
+ ---
2
+ name: tokenmetre-scan
3
+ description: 扫描 Claude Code transcript 把 token 数据入库并生成统计报告。当用户说"scan token"、"提交工作量"、"我今天写了多少 token"时使用。
4
+ ---
5
+
6
+ # tokenmetre-scan
7
+
8
+ 扫描 Claude Code transcript → SQLite 入库 → 生成统计报告。
9
+
10
+ v3 架构:零 hook、零 plugin,完全只读 CC 自己写的 transcript;per-LLM-call 粒度。
11
+ **扫描与归属解耦**:扫描只负责把 transcript 里的内容连同每条消息的 `cwd` 落库,
12
+ 归属在读时按 cwd 解析(见下)。
13
+
14
+ ## 执行步骤
15
+
16
+ 1. 扫描 transcript 入库:
17
+
18
+ ```bash
19
+ python "$HOME/.tokenmetre/bin/tokenmetre" scan
20
+ ```
21
+
22
+ `tokenmetre` **不在 PATH 上,必须走绝对路径**(直接执行 `~/.tokenmetre/bin/tokenmetre` 会因 shebang 是 `python3` 而静默失败)。
23
+
24
+ 默认**全量扫 `~/.claude/projects/**`**,不再按 active 过滤 —— 因为会话住在哪个
25
+ 目录不再决定它属于哪个项目。已处理过的 session 按 mtime 游标自动跳过。
26
+
27
+ 常用选项:
28
+
29
+ ```bash
30
+ python "$T" scan --project <cwd> # 只扫该目录的 transcript(调试用)
31
+ python "$T" scan --force # 忽略游标全量重扫(改了 category 规则后用)
32
+ python "$T" scan --dry-run # 只看会产出什么,不实际写
33
+ python "$T" scan --status # 处理状态 + 按归属聚合
34
+ ```
35
+
36
+ ⚠️ `--force` 现在真正生效 —— 写入改成 upsert 了,重扫会覆盖旧行。
37
+ (v3.0 用的是 `INSERT OR IGNORE`,重扫写不进去,这个 bug 已修)
38
+
39
+ 2. 生成报告:
40
+
41
+ ```bash
42
+ python "$HOME/.tokenmetre/bin/tokenmetre" report
43
+ ```
44
+
45
+ 常用选项:
46
+
47
+ ```bash
48
+ python "$T" report --all # 跨所有项目
49
+ python "$T" report --project <slug>
50
+ python "$T" report --from <ISO8601> --to <ISO8601>
51
+ ```
52
+
53
+ ⚠️ `report` **没有** `--category` 参数。
54
+
55
+ 3. 用 markdown 表格输出,字段顺序:`source | model | 记录数 | token_in | token_cache | token_out | unified_token`
56
+
57
+ ## 如果没有 records
58
+
59
+ 分两种情况,别混:
60
+
61
+ - **库里根本没记录** → 报告 "no records yet. run `tokenmetre scan` ..."。先 `scan` 入库。
62
+ - **库里有记录、但报表是空的/偏少** → 大概率是**未归属**:这些记录的 cwd 不在任何
63
+ active 链上,也没有被 claim。`report` 会打一行
64
+ 「N 条记录的工作目录未登记/未认领, 不计入」提示。用 `register add <cwd>`
65
+ (新项目) 或 `register claim <cwd> --id <tm_xxx>`(搬家/子目录) 认领后**立即出现** ——
66
+ 不需要重扫, 归属是读时解析的。
67
+
68
+ 排查:`scan --status` 看 `total records` 与 `distinct cwd`;`register list` 看 active / ignored / claims。
69
+
70
+ ## 分类(category)说明
71
+
72
+ scan 按工具名确定性映射 category(**不调 LLM**),共 5 类:
73
+
74
+ | category | 中文 | 包含工具 / 判定 |
75
+ |---|---|---|
76
+ | `documentation` | 文档 | Write 目标文件后缀 `.md` / `.markdown` |
77
+ | `coding` | 编码 | Write / Edit / MultiEdit / Patch / Replace(非 .md) |
78
+ | `execution` | 实施 | Skill / Agent / Task / TodoWrite + Bash「写/装/跑」 |
79
+ | `exploration` | 调研 | Read / Glob / Grep / LS / Search / WebFetch / WebSearch + Bash「读/查/搜」 |
80
+ | `discussion` | 讨论 | AskUserQuestion,或无工具但有 text |
81
+
82
+ **优先级**:`documentation > coding > execution > exploration > discussion`
83
+
84
+ 一次 LLM 调用触发多个工具时,按优先级取最高的 category。
85
+
86
+ ## 粒度说明
87
+
88
+ **一条 record = 一次 LLM 调用**(不是一条 assistant message)。
89
+ CC 会把一次调用拆成 thinking / text / tool_use 等多条 jsonl entry,它们共享同一组 usage 数字;
90
+ scan 按相同 usage 归组,所以记录数会明显少于 assistant entry 数——这是**正确的**,不是丢数据。
91
+
92
+ 例:`29fe6af6` 会话里 14 条 assistant entry → 6 条 record(`#6+#7` 一组、`#9+#10+#11` 一组……)。