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
140 changes: 113 additions & 27 deletions analyzer/dispatch_ledger.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from adapters.types import UsageEntry
from loaders import agy_loader, agy_quota_probe, codex_loader, muse_quota_probe
from loaders.claude_paths import claude_config_dirs
from usage_common.project_resolver import resolve_project_name

_DISPATCH = {
"codex": re.compile(r"(^|[;&|(\s])codex exec"),
Expand Down Expand Up @@ -61,14 +62,63 @@ class LedgerRow(TypedDict):
dispatch_count: int
claude_pct: float | None
codex_pct: float | None
agy_pct: float | None
agy_groups: dict[str, float]
claude_reset: bool
codex_reset: bool
agy_reset: bool
total_pct: float


class LedgerData(TypedDict):
rows: list[LedgerRow]
unowned_codex: int
unowned_agy: int
used_pct: dict[str, float]


class LedgerBlock(TypedDict):
quota: str
used_pct: float | None
conversations: list[tuple[LedgerRow, float]]
more_count: int
more_pct: float
other_pct: float | None
all_reset: bool


def ledger_blocks(rows: list[LedgerRow], used_pct: dict[str, float]) -> list[LedgerBlock]:
"""Select each quota's top five from every owned conversation."""
shares: dict[str, list[tuple[LedgerRow, float]]] = defaultdict(list)
for row in rows:
if row["codex_pct"] is not None and row["codex_pct"] > 0:
shares["codex"].append((row, row["codex_pct"]))
for group, value in row["agy_groups"].items():
if value > 0:
shares[f"antigravity:{group}"].append((row, value))
if any(row["codex_reset"] for row in rows):
shares.setdefault("codex", [])
blocks: list[LedgerBlock] = []
for quota, conversations in shares.items():
conversations.sort(key=lambda item: item[1], reverse=True)
total = sum(value for _, value in conversations)
used = used_pct.get(quota)
other = max(0.0, used - total) if used is not None else None
blocks.append(
{
"quota": quota,
"used_pct": used,
"conversations": conversations[:5],
"more_count": len(conversations[5:]),
"more_pct": sum(value for _, value in conversations[5:]),
"other_pct": other if other is not None and other >= 0.5 else None,
"all_reset": quota == "codex" and not conversations,
}
)
blocks.sort(
key=lambda block: sum(value for _, value in block["conversations"]) + block["more_pct"],
reverse=True,
)
return blocks


def _timestamp(value: object) -> datetime | None:
Expand Down Expand Up @@ -319,11 +369,15 @@ def estimate_pct(
return session_tokens / total_tokens * window.used_pct


SessionGroup = tuple[str, str, str]


def estimate_sessions(
entries: list[UsageEntry], windows: dict[str, QuotaWindow], now: datetime
) -> dict[tuple[str, str], float | None]:
) -> tuple[dict[SessionGroup, float], set[SessionGroup]]:
totals: dict[str, int] = defaultdict(int)
sessions: dict[tuple[str, str, str], int] = defaultdict(int)
before_window: set[SessionGroup] = set()
for entry in entries:
group = entry.agent_id
if entry.agent_id == "antigravity":
Expand Down Expand Up @@ -351,18 +405,21 @@ def estimate_sessions(
"",
)
window = windows.get(group)
if window is None or not window.resets_at - window.length <= entry.timestamp <= now:
continue
tokens = entry.input_tokens + entry.output_tokens + entry.cache_creation_tokens
if window is None or entry.timestamp > now or tokens <= 0:
continue
key = (entry.agent_id, entry.session_id, group)
if entry.timestamp < window.resets_at - window.length:
before_window.add(key)
continue
totals[group] += tokens
sessions[(entry.agent_id, entry.session_id, group)] += tokens
estimates: dict[tuple[str, str], float | None] = {}
sessions[key] += tokens
estimates: dict[SessionGroup, float] = {}
for (agent_id, session_id, group), tokens in sessions.items():
value = estimate_pct(tokens, totals[group], windows.get(group), now)
if value is not None:
key = (agent_id, session_id)
estimates[key] = (estimates.get(key) or 0.0) + value
return estimates
estimates[(agent_id, session_id, group)] = value
return estimates, before_window - sessions.keys()


def _session_title(path: Path) -> str:
Expand Down Expand Up @@ -392,13 +449,23 @@ def _session_title(path: Path) -> str:
return title or first_text


def build_ledger(estimates: dict[tuple[str, str], float | None], now: datetime) -> LedgerData:
def build_ledger(
estimates: dict[SessionGroup, float],
now: datetime,
reset_sessions: set[SessionGroup] | None = None,
used_pct: dict[str, float] | None = None,
) -> LedgerData:
cutoff = now - timedelta(days=7)
paths = _claude_paths(cutoff)
dispatches = [
item for path in paths for item in _read_dispatches(path) if cutoff <= item.timestamp <= now
]
empty: LedgerData = {"rows": [], "unowned_codex": 0, "unowned_agy": 0}
empty: LedgerData = {
"rows": [],
"unowned_codex": 0,
"unowned_agy": 0,
"used_pct": used_pct or {},
}
if not dispatches:
return empty
known_sessions = {path.stem for path in paths}
Expand Down Expand Up @@ -426,32 +493,51 @@ def build_ledger(estimates: dict[tuple[str, str], float | None], now: datetime)
origin = by_owner.get(owner)
if not origin:
continue
shares = {"claude-code": estimates.get(("claude-code", owner))}
for agent_id in ("codex", "antigravity"):
values = [estimates.get((agent_id, session_id)) for session_id in children[agent_id]]
shares[agent_id] = (
sum(value for value in values if value is not None)
if any(value is not None for value in values)
else None
)
claude_pct = estimates.get(("claude-code", owner, "claude-code"))
codex_values = [
estimates[("codex", child, "codex")]
for child in children["codex"]
if ("codex", child, "codex") in estimates
]
codex_pct = sum(codex_values) if codex_values else None
agy_groups: dict[str, float] = {}
for (agent, child, group), value in estimates.items():
if agent == "antigravity" and child in children[agent]:
name = group.removeprefix("antigravity:")
agy_groups[name] = agy_groups.get(name, 0.0) + value
reset_agents = {
agent
for agent, session, _ in reset_sessions or set()
if (agent == "claude-code" and session == owner)
or session in children.get(agent, set())
}
first = min(origin, key=lambda item: item.timestamp)
rows.append(
(
owner,
{
"date": first.timestamp.astimezone().strftime("%Y-%m-%d"),
"project": Path(first.cwd).name or "unknown",
"project": resolve_project_name(first.cwd),
"title": "",
"dispatch_count": len(origin),
"claude_pct": shares["claude-code"],
"codex_pct": shares["codex"],
"agy_pct": shares["antigravity"],
"total_pct": sum(value for value in shares.values() if value is not None),
"claude_pct": claude_pct,
"codex_pct": codex_pct,
"agy_groups": agy_groups,
"claude_reset": claude_pct is None and "claude-code" in reset_agents,
"codex_reset": codex_pct is None and "codex" in reset_agents,
"agy_reset": not agy_groups and "antigravity" in reset_agents,

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 Preserve reset state per Antigravity group

When a ledger row has current-window usage in one Antigravity group and pre-window-only usage in another, agy_groups is nonempty, so this condition sets agy_reset to false and the reset group disappears entirely. This is possible because the groups have independent reset times; retain reset status keyed by group and render it alongside groups with percentages rather than collapsing it into one agent-level flag.

Useful? React with 👍 / 👎.

"total_pct": (claude_pct or 0) + (codex_pct or 0) + sum(agy_groups.values()),
},
)
)
rows.sort(key=lambda item: item[1]["total_pct"], reverse=True)
for owner, row in rows[:15]:
row["title"] = _session_title(path_by_session[owner])
empty["rows"].append(row)
empty["rows"] = [row for _, row in rows]
displayed = {
id(row)
for block in ledger_blocks(empty["rows"], empty["used_pct"])
for row, _ in block["conversations"]
}
for owner, row in rows:
if id(row) in displayed:
row["title"] = _session_title(path_by_session[owner])
return empty
15 changes: 13 additions & 2 deletions analyzer/reporter.py
Original file line number Diff line number Diff line change
Expand Up @@ -1258,7 +1258,13 @@ def build_report_data(agents: list[AgentInfo], period: str = "month") -> ReportD
entry for entry in weekly_entries if not ledger_backfill.is_ledger_entry(entry)
]
windows = dispatch_ledger.load_windows() if weekly_entries else {}
estimates = dispatch_ledger.estimate_sessions(weekly_entries, windows, now)
group_estimates, reset_sessions = dispatch_ledger.estimate_sessions(
weekly_entries, windows, now
)
estimates: dict[tuple[str, str], float] = {}
for (agent_id, session_id, _), value in group_estimates.items():
key = (agent_id, session_id)
estimates[key] = estimates.get(key, 0.0) + value
sessions_by_cost = sorted(
aggregate_sessions(real_entries), key=lambda session: session.cost_usd, reverse=True
)
Expand Down Expand Up @@ -1322,7 +1328,12 @@ def build_report_data(agents: list[AgentInfo], period: str = "month") -> ReportD
"sessions": session_rows,
"daily_trend": daily_trend,
"top_sessions": top_sessions,
"dispatch_ledger": dispatch_ledger.build_ledger(estimates, now),
"dispatch_ledger": dispatch_ledger.build_ledger(
group_estimates,
now,
reset_sessions,
{key: window.used_pct for key, window in windows.items()},
),
"comparison": comparison,
"subscriptions": subscription.load_subscriptions(),
"persona": _load_persona_for_period(period),
Expand Down
Loading
Loading