Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## 0.5.74-dev (unreleased)

### Features

- **Token + 模型 section in `board daily` report** (#153) — Auto-generated per-tongxue daily reports now include a `## Token + 模型` block: current model (with first-model trail if it changed during the shift), message count, token totals (input / output / cache-read / cache-write), and an estimated cost. Scoped to the last 24h by default; missing JSONL data is silent so the daily flow never fails on this. Stacks on #221.

## 0.5.70-dev (unreleased)

### Features
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.5.70-dev
0.5.74-dev
22 changes: 22 additions & 0 deletions lib/shift_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,28 @@ def generate_agent_report(
lines = [f"- {c}" for c in commits[:20]]
sections.append(f"## Git Commits ({len(commits)})\n" + "\n".join(lines))

# ── Token + Model usage (best-effort; missing data is silent) ──
try:
from lib.token_usage import estimate_cost, tongxue_token_summary

usage = tongxue_token_summary(project_root, session_name)
if usage and usage.get("messages", 0) > 0:
model = usage.get("latest_model") or usage.get("model", "?")
models = usage.get("models") or []
cost = estimate_cost(usage)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Price mixed-model reports by the right model

When a retained session has switched models, the report displays latest_model but this call prices the aggregated tokens with estimate_cost, which looks only at usage["model"] (the first model seen). For example, an Opus→Sonnet session will show Sonnet as current while charging all later Sonnet tokens at Opus rates, so the new daily cost estimate can be materially wrong whenever model changes occur during the reporting window.

Useful? React with 👍 / 👎.

trail = f" (从 {models[0]})" if len(models) >= 2 and models[0] != model else ""
section_lines = [
f"- 模型: `{model}`{trail}",
f"- 消息: {usage['messages']}",
f"- Tokens: in {usage['input']:,} / out {usage['output']:,} / "
f"cache-read {usage['cache_read']:,} / cache-write {usage['cache_create']:,}",
f"- 估算费用: ${cost:.2f}",
]
sections.append("## Token + 模型\n" + "\n".join(section_lines))
except Exception:
# Daily report must never fail because of usage parsing.
pass

header = f"# 日报 — {session_name} — {date_str}"
if not sections:
return header + "\n\n(本轮无活动)"
Expand Down
21 changes: 21 additions & 0 deletions lib/token_usage.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,27 @@ def model_state_alerts(sessions: list[dict]) -> list[str]:
return alerts


def tongxue_token_summary(
project_root: Path,
name: str,
*,
recent_hours: float | None = 24.0,
) -> dict | None:
"""Return aggregated usage for a single tongxue, or None if no data.

The default 24h window matches a typical shift; pass `None` for full history.
"""
sessions = _load_project_sessions(project_root, recent_hours=recent_hours)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Scope daily token totals to the report window

Because this new daily summary relies on _load_project_sessions(..., recent_hours=24), it only excludes whole JSONL files by file mtime while parse_session_usage still sums every assistant record in any retained file. If a Claude session JSONL was started days ago and appended today, the new board daily Token + 模型 section reports lifetime tokens and cost for that file rather than the advertised last-24h/shift usage, which can substantially overstate daily totals for long-lived sessions.

Useful? React with 👍 / 👎.

if not sessions:
return None
needle = name.lower()
sessions = [s for s in sessions if (s.get("name") or "").lower() == needle]
if not sessions:
return None
agg = aggregate_by_name(sessions)
return agg[0] if agg else None
Comment on lines +189 to +190

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Merge matching names case-insensitively

This filters JSONLs with a case-insensitive name match, but then hands them to aggregate_by_name, which groups by the original case-sensitive s["name"]; if one log says Alice and another says alice, the daily report returns only whichever group sorts first by output and silently drops the other matching session's tokens. Since board daily lowercases the identity while the parser preserves customTitle/agentName, casing drift across Claude logs can make the new Token + 模型 block undercount usage for that tongxue.

Useful? React with 👍 / 👎.



def _parse_usage_args(args: list[str]) -> dict[str, Any]:
parsed: dict[str, Any] = {"detail": False, "budget": 0.0, "warn_pct": DEFAULT_BUDGET_WARN_PCT}
i = 0
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "claude-nb",
"version": "0.5.70-dev",
"version": "0.5.74-dev",
"description": "Multi-agent coordination framework for Claude Code sessions",
"engines": {
"node": ">=18"
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "claude-nb"
version = "0.5.70.dev0"
version = "0.5.74.dev0"
description = "Multi-agent coordination framework for Claude Code sessions"
requires-python = ">=3.11"
license = "MIT"
Expand Down
73 changes: 73 additions & 0 deletions tests/test_token_usage.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
load_budget_defaults,
model_state_alerts,
parse_session_usage,
tongxue_token_summary,
)


Expand Down Expand Up @@ -563,3 +564,75 @@ def test_budget_and_model_state_output(self, tmp_path, capsys):
assert "WARNING: alice: model downgraded claude-opus-4-7 -> claude-sonnet-4-7" in out
assert "预算: $100.00" in out
assert "WARNING: token budget usage" in out


class TestTongxueTokenSummary:
def test_returns_none_when_no_jsonls(self, tmp_path):
project_dir = tmp_path / "empty"
project_dir.mkdir()
with patch("lib.token_usage._find_project_dir", return_value=project_dir):
assert tongxue_token_summary(tmp_path / "project", "alice") is None

def test_returns_none_when_name_not_found(self, tmp_path):
project_dir = tmp_path / "jsonls"
project_dir.mkdir()
_write_jsonl(
project_dir / "s1.jsonl",
[{"type": "custom-title", "customTitle": "bob"}, _make_assistant_msg()],
)
with patch("lib.token_usage._find_project_dir", return_value=project_dir):
assert tongxue_token_summary(tmp_path / "project", "alice") is None

def test_returns_aggregated_data(self, tmp_path):
project_dir = tmp_path / "jsonls"
project_dir.mkdir()
_write_jsonl(
project_dir / "s1.jsonl",
[
{"type": "custom-title", "customTitle": "alice"},
_make_assistant_msg(input_tokens=100, output_tokens=200, model="claude-opus-4-7"),
],
)
with patch("lib.token_usage._find_project_dir", return_value=project_dir):
result = tongxue_token_summary(tmp_path / "project", "alice")
assert result is not None
assert result["name"] == "alice"
assert result["input"] == 100
assert result["output"] == 200
assert result["latest_model"] == "claude-opus-4-7"
assert result["messages"] == 1

def test_name_match_is_case_insensitive(self, tmp_path):
project_dir = tmp_path / "jsonls"
project_dir.mkdir()
_write_jsonl(
project_dir / "s1.jsonl",
[{"type": "custom-title", "customTitle": "Alice"}, _make_assistant_msg()],
)
with patch("lib.token_usage._find_project_dir", return_value=project_dir):
result = tongxue_token_summary(tmp_path / "project", "ALICE")
assert result is not None
assert result["name"] == "Alice"

def test_merges_multiple_jsonls(self, tmp_path):
project_dir = tmp_path / "jsonls"
project_dir.mkdir()
_write_jsonl(
project_dir / "s1.jsonl",
[
{"type": "custom-title", "customTitle": "alice"},
_make_assistant_msg(input_tokens=10, output_tokens=50),
],
)
_write_jsonl(
project_dir / "s2.jsonl",
[
{"type": "custom-title", "customTitle": "alice"},
_make_assistant_msg(input_tokens=20, output_tokens=100),
],
)
with patch("lib.token_usage._find_project_dir", return_value=project_dir):
result = tongxue_token_summary(tmp_path / "project", "alice")
assert result["input"] == 30
assert result["output"] == 150
assert result["messages"] == 2