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
58 changes: 27 additions & 31 deletions claude_code_log/converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
from .cache import CacheManager

from .utils import (
coalesce_trunk_session_id,
collect_trunk_session_ids,
format_timestamp_range,
get_parent_session_id,
get_project_display_name,
Expand Down Expand Up @@ -1431,8 +1433,8 @@ def compute_session_data(
):
continue

session_id = get_parent_session_id(getattr(message, "sessionId", ""))
if not session_id or session_id in warmup:
session_id = coalesce_trunk_session_id(message, warmup)
if not session_id:
continue

if session_id not in result:
Expand Down Expand Up @@ -1689,10 +1691,19 @@ def _generate_paginated_html(
total_output_tokens = 0
total_cache_creation_tokens = 0
total_cache_read_tokens = 0
# The cached count must be the sessions-table sum, because
# is_page_stale() compares it against SUM(sessions.message_count)
# on the next run. len(page_messages) counts what's rendered
# (including Summary/AiTitle/Attachment entries that
# compute_session_data skips), so caching it left any page where
# the two rules diverge permanently "message_count_changed" —
# regenerating on every single run.
page_sessions_message_count = 0

for session_id in page_session_ids:
if session_id in session_data:
s = session_data[session_id]
page_sessions_message_count += s.message_count
if s.first_timestamp and (
first_timestamp is None or s.first_timestamp < first_timestamp
):
Expand Down Expand Up @@ -1778,7 +1789,7 @@ def _generate_paginated_html(
html_path=html_path,
page_size_config=page_size,
session_ids=page_session_ids,
message_count=page_message_count,
message_count=page_sessions_message_count,
first_timestamp=first_timestamp,
last_timestamp=last_timestamp,
total_input_tokens=total_input_tokens,
Expand Down Expand Up @@ -2046,16 +2057,9 @@ def convert_jsonl_to(
assert cache_manager is not None # Ensured by use_pagination condition
# Use cached session data if available, otherwise build from messages
if cached_data is not None:
warmup_session_ids = get_warmup_session_ids(messages)
current_session_ids: set[str] = set()
for message in messages:
session_id = getattr(message, "sessionId", "")
if (
session_id
and session_id not in warmup_session_ids
and not is_agent_session(session_id)
):
current_session_ids.add(session_id)
current_session_ids = collect_trunk_session_ids(
messages, get_warmup_session_ids(messages)
)
session_data = {
session_id: session_cache
for session_id, session_cache in cached_data.sessions.items()
Expand Down Expand Up @@ -2398,20 +2402,14 @@ def _generate_individual_session_files(

ext = get_file_extension(format)
suffix = _variant_suffix(detail, compact, format, no_timestamps, no_recaps)
# Pre-compute warmup sessions to exclude them
warmup_session_ids = get_warmup_session_ids(messages)

# Find all unique session IDs (excluding warmup and agent sessions)
session_ids: set[str] = set()
for message in messages:
if hasattr(message, "sessionId"):
session_id: str = getattr(message, "sessionId")
if (
session_id
and session_id not in warmup_session_ids
and not is_agent_session(session_id)
):
session_ids.add(session_id)
# Find all unique session IDs, excluding warmup sessions and
# coalescing agent sessionIds to their trunk — same rule as
# compute_session_data() when it writes the sessions table.
# Dropping (rather than coalescing) agent ids left agent-sidechain-
# only sessions in the sessions table but never rendered, so
# get_stale_sessions() flagged them "not_cached" on every run —
# regenerating the project forever without ever writing the file.
session_ids = collect_trunk_session_ids(messages, get_warmup_session_ids(messages))

# Get session data from cache for better titles
session_data: dict[str, Any] = {}
Expand Down Expand Up @@ -3154,10 +3152,8 @@ def _rel_to_index(p: Path) -> str:
),
):
continue
if not hasattr(_msg, "sessionId"):
continue
_sid = get_parent_session_id(getattr(_msg, "sessionId", ""))
if not _sid or _sid in warmup_for_teams:
_sid = coalesce_trunk_session_id(_msg, warmup_for_teams)
if not _sid:
continue
_tn = getattr(_msg, "teamName", None)
if _tn and _sid not in team_name_per_session:
Expand Down
39 changes: 39 additions & 0 deletions claude_code_log/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -692,6 +692,45 @@ def get_warmup_session_ids(messages: list[TranscriptEntry]) -> set[str]:
return warmup_sessions


def coalesce_trunk_session_id(
message: TranscriptEntry, warmup_session_ids: set[str]
) -> str:
"""Return ``message``'s sessionId coalesced to its trunk, or ``""``.

The shared normalization every session-collection site must agree
on: agent synthetic sessionIds (``{trunk}#agent-{id}``) coalesce to
their trunk via ``get_parent_session_id``, and warmup-only sessions
are excluded. Returns ``""`` for entries without a sessionId (e.g.
``SummaryTranscriptEntry``) or whose trunk is a warmup session.
"""
session_id = getattr(message, "sessionId", "") or ""
if session_id:
session_id = get_parent_session_id(session_id)
if session_id in warmup_session_ids:
return ""
return session_id


def collect_trunk_session_ids(
messages: list[TranscriptEntry], warmup_session_ids: set[str]
) -> set[str]:
"""Collect the distinct trunk sessionIds present in ``messages``.

Coalescing (rather than dropping) agent sessionIds matters: a fork
session whose own messages were all deduplicated into the original
session can survive ONLY through its agent sidechains, and it must
still claim its page/file slot or its messages are grouped under a
session no output owns and vanish (regenerating forever — see
``_generate_individual_session_files``).
"""
session_ids: set[str] = set()
for message in messages:
session_id = coalesce_trunk_session_id(message, warmup_session_ids)
if session_id:
session_ids.add(session_id)
return session_ids


# Artifact favicons are 1-2 emoji (ZWJ sequences can span ~a dozen code
# points); anything longer is malformed input not worth echoing inline.
# Shared by the HTML and Markdown renderers so the cap can't drift.
Expand Down
Loading