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
7 changes: 3 additions & 4 deletions src/copilot_usage/docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ Monorepo containing Python CLI utilities that share tooling, CI, and common depe
| Module | Responsibility |
|--------|---------------|
| `cli.py` | Click command group — routes commands to parser/report functions, handles CLI options, error display. Also contains the interactive loop (invoked when no subcommand is given) with watchdog-based auto-refresh (2-second debounce). |
| `parser.py` | Discovers sessions, reads events.jsonl line by line, builds SessionSummary per session via focused helpers: `_first_pass()` (extract identity/shutdowns/counters), `_detect_resume()` (post-shutdown scan), `_build_completed_summary()`, `_build_active_summary()`. |
| `parser.py` | Discovers sessions, reads events.jsonl line by line, builds SessionSummary per session via focused helpers: `_first_pass()` (extract identity/shutdowns/counters/post-shutdown resume data in a single pass), `_build_completed_summary()`, `_build_active_summary()`. |
| `models.py` | Pydantic v2 models for all event types + SessionSummary aggregate (includes model_calls and user_messages fields). Runtime validation at parse boundary. |
| `report.py` | Rich-formatted terminal output — summary tables (with Model Calls and User Msgs columns), live view, premium request breakdown. Shows raw counts and `~`-prefixed premium cost estimates for live/active sessions; historical post-shutdown views display exact API-provided numbers. |
| `render_detail.py` | Session detail rendering — extracted from report.py. Displays event timeline, per-event metadata, and session-level aggregates. |
Expand All @@ -50,9 +50,8 @@ Monorepo containing Python CLI utilities that share tooling, CI, and common depe
1. **Discovery** — `discover_sessions()` scans `~/.copilot/session-state/*/events.jsonl`, returns paths sorted by modification time
2. **Parsing** — `_parse_events_from_offset()` reads each line as JSON in binary mode, creates `SessionEvent` objects via Pydantic validation. The production pipeline accesses this through `get_cached_events()`, which caches results and supports incremental byte-offset parsing for append-only file growth. The public `parse_events()` delegates to the same implementation with `include_partial_tail=True` for one-shot full-file reads. Malformed lines are skipped with a warning.
3. **Typed dispatch** — callers use the narrowly-typed `as_*()` accessors (`as_session_start()`, `as_assistant_message()`, etc.) on `SessionEvent` to get a validated payload for each known event type. Unknown event types still validate as `SessionEvent`, but normal processing ignores them unless a caller explicitly validates `data` with `GenericEventData`.
4. **Summarization** — `build_session_summary()` orchestrates four focused helpers:
- `_first_pass()`: single pass over events — extracts session metadata from `session.start`, counts raw events (model calls, user messages, output tokens), collects all shutdown data
- `_detect_resume()`: scans events after the last shutdown for resume indicators (`session.resume`, `user.message`, `assistant.message`) and separately tracks post-shutdown `assistant.turn_start` activity
4. **Summarization** — `build_session_summary()` orchestrates focused helpers:
- `_first_pass()`: single pass over events — extracts session metadata from `session.start`, counts raw events (model calls, user messages, output tokens), collects all shutdown data, and tracks rolling post-shutdown accumulators (reset on each shutdown) for resume detection
- `_build_completed_summary()`: merges all shutdown cycles (metrics, premium requests, code changes) into a SessionSummary. Sets `is_active=True` if resumed.
- `_build_active_summary()`: for sessions with no shutdowns — infers model from `tool.execution_complete` events or `~/.copilot/config.json`, builds synthetic metrics from output tokens
- Two frozen dataclasses (`_FirstPassResult`, `_ResumeInfo`) carry state between helpers
Expand Down
2 changes: 1 addition & 1 deletion src/copilot_usage/docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ Manual work only — autonomous agent pipeline PRs are not tracked here.
**Plan**: Break the ~200-line `build_session_summary` monolith into focused private helpers while preserving identical behavior.

**Done**:
- Extracted 4 helpers: `_first_pass()`, `_detect_resume()`, `_build_completed_summary()`, `_build_active_summary()`
- Extracted helpers: `_first_pass()`, `_build_completed_summary()`, `_build_active_summary()`
- Two frozen dataclasses (`_FirstPassResult`, `_ResumeInfo`) carry state between phases
- `build_session_summary` is now a 6-line coordinator
- Public API unchanged — all ~90 existing tests pass without modification
Expand Down
39 changes: 20 additions & 19 deletions src/copilot_usage/docs/implementation.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,30 +148,31 @@ The model name for a shutdown is resolved in priority order (in `parser.py`):

### Detection logic

After the first pass, `build_session_summary()` calls `_detect_resume(events, fp.all_shutdowns)` (in `parser.py`) which scans events after the last shutdown index:
`_first_pass()` tracks rolling post-shutdown accumulators that reset on each `session.shutdown` event. After the single-pass loop completes, these accumulators hold the post-*last*-shutdown values, making resume detection O(0) additional work:

```python
def _detect_resume(events, all_shutdowns):
# Inside _first_pass():
for idx, ev in enumerate(events):
etype = ev.type
# ...
last_shutdown_idx = all_shutdowns[-1][0]

for i in range(last_shutdown_idx + 1, len(events)):
ev = events[i]
etype = ev.type
if etype == EventType.ASSISTANT_MESSAGE:
session_resumed = True
# ... accumulate output tokens ...
elif etype == EventType.USER_MESSAGE:
session_resumed = True
# ... count user messages ...
elif etype == EventType.ASSISTANT_TURN_START:
# ... count turn starts ...
elif etype == EventType.SESSION_RESUME:
session_resumed = True
# ... capture resume timestamp ...
if etype == EventType.SESSION_SHUTDOWN:
# ... parse shutdown data ...
# Reset rolling accumulators for new post-shutdown window
_ps_resumed = False
_ps_output_tokens = 0
_ps_turn_starts = 0
_ps_user_messages = 0
_ps_last_resume_time = None

elif etype == EventType.USER_MESSAGE:
user_message_count += 1
if _shutdowns:
_ps_resumed = True
_ps_user_messages += 1
# ... similar for ASSISTANT_MESSAGE, ASSISTANT_TURN_START, SESSION_RESUME
```

The helper includes a defensive guard for empty `all_shutdowns` (returns empty `_ResumeInfo`), making it safe to call independently. The `if/elif` chain short-circuits after the first match, reducing comparisons from 5 per event to 1 for the most common `ASSISTANT_MESSAGE` case.
The result fields (`post_shutdown_resumed`, `post_shutdown_output_tokens`, etc.) are carried in `_FirstPassResult` and converted to a `_ResumeInfo` in `_build_session_summary_with_meta`.

The presence of **any** `session.resume`, `user.message`, or `assistant.message` event after the last shutdown triggers `session_resumed = True`.

Expand Down
115 changes: 54 additions & 61 deletions src/copilot_usage/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -727,6 +727,12 @@ class _FirstPassResult:
total_output_tokens: int
total_turn_starts: int
tool_model: str | None
# Post-last-shutdown rolling accumulators (reset on each shutdown)
post_shutdown_resumed: bool
post_shutdown_output_tokens: int
post_shutdown_turn_starts: int
post_shutdown_user_messages: int
last_resume_time: datetime | None


@dataclasses.dataclass(frozen=True, slots=True)
Expand Down Expand Up @@ -755,12 +761,19 @@ class _ResumeInfo:
EventType.USER_MESSAGE,
EventType.ASSISTANT_TURN_START,
EventType.ASSISTANT_MESSAGE,
EventType.SESSION_RESUME,
}
)


def _first_pass(events: list[SessionEvent]) -> _FirstPassResult:
"""Iterate *events* once, extracting identity, shutdown data, and counters."""
"""Iterate *events* once, extracting identity, shutdown data, and counters.

Also tracks rolling post-shutdown accumulators that reset on each
``session.shutdown``. After the loop they hold the post-*last*-shutdown
values, eliminating the need for a separate second pass to detect
resume activity.
"""
session_id = ""
start_time = None
end_time = None
Expand All @@ -773,6 +786,13 @@ def _first_pass(events: list[SessionEvent]) -> _FirstPassResult:
total_turn_starts = 0
tool_model: str | None = None

# Rolling post-shutdown accumulators — reset on each SESSION_SHUTDOWN
_ps_resumed = False
_ps_output_tokens = 0
_ps_turn_starts = 0
_ps_user_messages = 0
_ps_last_resume_time: datetime | None = None

for idx, ev in enumerate(events):
etype = ev.type
# Fast path: once tool_model is found, skip all tool-complete events
Expand Down Expand Up @@ -817,16 +837,37 @@ def _first_pass(events: list[SessionEvent]) -> _FirstPassResult:
_shutdowns.append((idx, data))
end_time = ev.timestamp
model = current_model
# Reset rolling accumulators for new post-shutdown window
_ps_resumed = False
_ps_output_tokens = 0
_ps_turn_starts = 0
_ps_user_messages = 0
_ps_last_resume_time = None

elif etype == EventType.USER_MESSAGE:
user_message_count += 1
if _shutdowns:
_ps_resumed = True
_ps_user_messages += 1

elif etype == EventType.ASSISTANT_TURN_START:
total_turn_starts += 1
if _shutdowns:
_ps_turn_starts += 1

elif etype == EventType.ASSISTANT_MESSAGE:
if (tokens := _extract_output_tokens(ev)) is not None:
total_output_tokens += tokens
if _shutdowns:
_ps_output_tokens += tokens
if _shutdowns:
_ps_resumed = True

elif etype == EventType.SESSION_RESUME:
if _shutdowns:
_ps_resumed = True
if ev.timestamp is not None:
_ps_last_resume_time = ev.timestamp

return _FirstPassResult(
session_id=session_id,
Expand All @@ -839,65 +880,11 @@ def _first_pass(events: list[SessionEvent]) -> _FirstPassResult:
total_output_tokens=total_output_tokens,
total_turn_starts=total_turn_starts,
tool_model=tool_model,
)


_DETECT_RESUME_EVENT_TYPES: Final[frozenset[str]] = frozenset(
{
EventType.ASSISTANT_MESSAGE,
EventType.USER_MESSAGE,
EventType.ASSISTANT_TURN_START,
EventType.SESSION_RESUME,
}
)


def _detect_resume(
events: list[SessionEvent],
all_shutdowns: tuple[tuple[int, SessionShutdownData], ...],
) -> _ResumeInfo:
"""Scan events after the last shutdown for resume indicators."""
if not all_shutdowns:
return _ResumeInfo(
session_resumed=False,
post_shutdown_output_tokens=0,
post_shutdown_turn_starts=0,
post_shutdown_user_messages=0,
last_resume_time=None,
)

last_shutdown_idx = all_shutdowns[-1][0]
session_resumed = False
post_shutdown_output_tokens = 0
post_shutdown_turn_starts = 0
post_shutdown_user_messages = 0
last_resume_time = None

for i in range(last_shutdown_idx + 1, len(events)):
ev = events[i]
etype = ev.type
if etype not in _DETECT_RESUME_EVENT_TYPES:
continue
if etype == EventType.ASSISTANT_MESSAGE:
session_resumed = True
if (tokens := _extract_output_tokens(ev)) is not None:
post_shutdown_output_tokens += tokens
elif etype == EventType.USER_MESSAGE:
session_resumed = True
post_shutdown_user_messages += 1
elif etype == EventType.ASSISTANT_TURN_START:
post_shutdown_turn_starts += 1
elif etype == EventType.SESSION_RESUME:
session_resumed = True
if ev.timestamp is not None:
last_resume_time = ev.timestamp

return _ResumeInfo(
session_resumed=session_resumed,
post_shutdown_output_tokens=post_shutdown_output_tokens,
post_shutdown_turn_starts=post_shutdown_turn_starts,
post_shutdown_user_messages=post_shutdown_user_messages,
last_resume_time=last_resume_time,
post_shutdown_resumed=_ps_resumed,
post_shutdown_output_tokens=_ps_output_tokens,
post_shutdown_turn_starts=_ps_turn_starts,
post_shutdown_user_messages=_ps_user_messages,
last_resume_time=_ps_last_resume_time,
)


Expand Down Expand Up @@ -1052,7 +1039,13 @@ def _build_session_summary_with_meta(
)

if fp.all_shutdowns:
resume = _detect_resume(events, fp.all_shutdowns)
resume = _ResumeInfo(
session_resumed=fp.post_shutdown_resumed,
post_shutdown_output_tokens=fp.post_shutdown_output_tokens,
post_shutdown_turn_starts=fp.post_shutdown_turn_starts,
post_shutdown_user_messages=fp.post_shutdown_user_messages,
last_resume_time=fp.last_resume_time,
)
return _BuildMeta(
_build_completed_summary(fp, name, resume, events, events_path=events_path),
used_config_fallback=False,
Expand Down
Loading
Loading