Skip to content
Open
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
26 changes: 26 additions & 0 deletions claude_code_log/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ def _render_provider_input_file(
compact: bool,
no_timestamps: bool,
no_recaps: bool,
branches: str,
open_browser: bool,
) -> None:
"""Render a single provider session file handed in as an INPUT_PATH.
Expand Down Expand Up @@ -204,6 +205,7 @@ def _run_provider_wholesale(
compact: bool,
no_timestamps: bool,
no_recaps: bool,
branches: str,
write_combined: bool,
write_individual: bool,
from_date: "Optional[str]",
Expand Down Expand Up @@ -1033,6 +1035,20 @@ def _validate_git_link_template(template: str) -> None:
"redundancy at --depth assistant."
),
)
@click.option(
"--branches",
type=click.Choice(["all", "main"]),
default="all",
show_default=True,
help=(
"Which conversation branches to render. Rewinding (/rewind, or "
"editing an earlier prompt) forks the transcript instead of "
"deleting anything, so abandoned attempts stay in the JSONL and "
"are rendered as extra 'Branch' sections. 'main' keeps only the "
"longest root-to-leaf path per trunk, dropping the attempts that "
"were rewound away."
),
)
@click.option(
"--debug",
is_flag=True,
Expand Down Expand Up @@ -1069,6 +1085,7 @@ def main(
git_link: Optional[str],
no_timestamps: bool,
no_recaps: bool,
branches: str,
debug: bool,
) -> None:
"""Convert Claude transcript JSONL files to HTML or Markdown.
Expand Down Expand Up @@ -1384,6 +1401,7 @@ def main(
compact,
no_timestamps,
no_recaps,
branches,
write_combined,
write_individual,
from_date,
Expand Down Expand Up @@ -1411,6 +1429,7 @@ def main(
compact,
no_timestamps,
no_recaps,
branches,
open_browser,
)
return
Expand Down Expand Up @@ -1659,6 +1678,7 @@ def render_provider(destination: Path) -> Path:
compact=compact,
no_timestamps=no_timestamps,
no_recaps=no_recaps,
branches=branches,
),
)
return
Expand All @@ -1674,6 +1694,7 @@ def render_provider(destination: Path) -> Path:
compact=compact,
no_timestamps=no_timestamps,
no_recaps=no_recaps,
branches=branches,
)
click.echo(f"Successfully exported session to {output_path}")
if open_browser:
Expand Down Expand Up @@ -1735,6 +1756,7 @@ def render_provider(destination: Path) -> Path:
write_combined=write_combined,
no_timestamps=no_timestamps,
no_recaps=no_recaps,
branches=branches,
jobs=jobs,
)

Expand Down Expand Up @@ -1775,6 +1797,7 @@ def render_provider(destination: Path) -> Path:
compact,
no_timestamps,
no_recaps,
branches,
write_combined,
write_individual,
from_date,
Expand All @@ -1797,6 +1820,7 @@ def render_provider(destination: Path) -> Path:
compact,
no_timestamps,
no_recaps,
branches,
open_browser,
)
return
Expand Down Expand Up @@ -1854,6 +1878,7 @@ def render_provider(destination: Path) -> Path:
write_combined=True,
no_timestamps=no_timestamps,
no_recaps=no_recaps,
branches=branches,
force_regenerate=True,
),
)
Expand Down Expand Up @@ -1883,6 +1908,7 @@ def render_provider(destination: Path) -> Path:
write_combined=write_combined,
no_timestamps=no_timestamps,
no_recaps=no_recaps,
branches=branches,
# An explicit `-o` *file* always regenerates: the version-marker
# skip only knows the embedded version, not which source produced
# the file, so it would keep stale content at a user-chosen path
Expand Down
31 changes: 30 additions & 1 deletion claude_code_log/converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,12 @@
ToolResultContent,
ToolUseContent,
)
from .dag import SessionTree, build_dag_from_entries, traverse_session_tree
from .dag import (
SessionTree,
build_dag_from_entries,
filter_to_main_line,
traverse_session_tree,
)
from .renderer import (
get_renderer,
prepare_session_ai_titles,
Expand Down Expand Up @@ -1877,6 +1882,7 @@ def convert_jsonl_to(
write_combined: bool = True,
no_timestamps: bool = False,
no_recaps: bool = False,
branches: str = "all",
force_regenerate: bool = False,
report: Optional["RegenerationReport"] = None,
) -> Path:
Expand All @@ -1895,6 +1901,9 @@ def convert_jsonl_to(
page_size: Maximum messages per page for combined transcript pagination.
If None, uses format default (embedded for HTML, referenced for Markdown).
depth: Output depth level (full, high, low, minimal).
branches: Which conversation branches to render. "all" (default)
keeps every rewound fork; "main" keeps only the longest
root→leaf path per trunk, dropping abandoned rewind attempts.
force_regenerate: Always (re)generate, bypassing the version-marker
staleness skip. The CLI sets this for an explicit ``--output``
(issue #221): the staleness heuristic only knows the embedded
Expand Down Expand Up @@ -2044,6 +2053,18 @@ def convert_jsonl_to(
# Deduplicate messages (removes version stutters while preserving concurrent tool results)
messages = deduplicate_messages(messages)

# Drop rewound-and-abandoned fork branches (--branches main). Runs after
# dedup so the DAG sees the same entries the renderer will. A prebuilt
# tree describes the unfiltered graph, so rebuild it from the survivors
# while carrying the workflow links across.
if branches == "main":
messages = filter_to_main_line(messages)
if session_tree is not None:
_pruned_tree = build_dag_from_entries(messages)
_pruned_tree.workflow_runs = session_tree.workflow_runs
_pruned_tree.workflow_links = session_tree.workflow_links
session_tree = _pruned_tree

# Update title to include date range if specified
if from_date or to_date:
date_range_parts: list[str] = []
Expand Down Expand Up @@ -2609,6 +2630,7 @@ def generate_single_session_file(
compact: bool = False,
no_timestamps: bool = False,
no_recaps: bool = False,
branches: str = "all",
) -> Path:
"""Generate a single session output file for the given session ID.

Expand Down Expand Up @@ -2646,6 +2668,11 @@ def generate_single_session_file(
# Load messages from JSONL files
messages, _session_tree = load_directory_transcripts(input_path, cache_manager)

# Mirror ``convert_jsonl_to``: drop rewound-and-abandoned fork branches
# so a per-session file matches the combined transcript (--branches main).
if branches == "main":
messages = filter_to_main_line(messages)

Comment on lines +2671 to +2675

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Branch pruning runs before dedup here, unlike convert_jsonl_to.

The comment claims this mirrors convert_jsonl_to, but convert_jsonl_to explicitly dedups the full message list before calling filter_to_main_line ("Runs after dedup so the DAG sees the same entries the renderer will", Line 2057). Here, filter_to_main_line(messages) runs on the raw, un-deduplicated multi-session list; deduplicate_messages only runs later (Line 2715) on the already-narrowed session_messages subset. deduplicate_messages's own docstring documents real duplicate shapes (version-stutter, same-timestamp "branch switch artifacts") that the DAG builder could misread as competing fork branches before dedup collapses them — so a session containing such duplicates could get pruned differently here than in convert_jsonl_to, defeating the stated "matches the combined transcript" goal.

🐛 Proposed fix — dedup before pruning, mirroring convert_jsonl_to
     # Load messages from JSONL files
     messages, _session_tree = load_directory_transcripts(input_path, cache_manager)
 
+    messages = deduplicate_messages(messages)
+
     # Mirror ``convert_jsonl_to``: drop rewound-and-abandoned fork branches
     # so a per-session file matches the combined transcript (--branches main).
     if branches == "main":
         messages = filter_to_main_line(messages)

(and drop the now-redundant deduplicate_messages(session_messages) call below, or leave it as a cheap no-op safety net.)

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# Mirror ``convert_jsonl_to``: drop rewound-and-abandoned fork branches
# so a per-session file matches the combined transcript (--branches main).
if branches == "main":
messages = filter_to_main_line(messages)
# Load messages from JSONL files
messages, _session_tree = load_directory_transcripts(input_path, cache_manager)
messages = deduplicate_messages(messages)
# Mirror ``convert_jsonl_to``: drop rewound-and-abandoned fork branches
# so a per-session file matches the combined transcript (--branches main).
if branches == "main":
messages = filter_to_main_line(messages)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@claude_code_log/converter.py` around lines 2671 - 2675, Update the
per-session conversion flow around filter_to_main_line and deduplicate_messages
so messages are deduplicated before branch pruning, matching convert_jsonl_to’s
ordering. Apply deduplicate_messages to the full messages list before
filter_to_main_line when branches == "main", then remove the redundant later
deduplication of session_messages unless it is intentionally retained as a no-op
safety check.

# Collect all known session IDs: from loaded messages + cache metadata
all_session_ids: set[str] = {
getattr(msg, "sessionId")
Expand Down Expand Up @@ -3435,6 +3462,7 @@ def process_projects_hierarchy(
write_combined: bool = True,
no_timestamps: bool = False,
no_recaps: bool = False,
branches: str = "all",
jobs: Optional[int] = None,
) -> Path:
"""Process the entire ~/.claude/projects/ hierarchy and create linked output files.
Expand Down Expand Up @@ -3649,6 +3677,7 @@ def _convert_plan_inline(plan: _ProjectPlan) -> None:
write_combined=write_combined,
no_timestamps=no_timestamps,
no_recaps=no_recaps,
branches=branches,
)
except Exception:
_print_project_failed(plan, traceback.format_exc())
Expand Down
167 changes: 167 additions & 0 deletions claude_code_log/dag.py
Original file line number Diff line number Diff line change
Expand Up @@ -1144,3 +1144,170 @@ def build_dag_from_entries(
build_dag(nodes, sidechain_uuids=sidechain_uuids)
sessions = extract_session_dag_lines(nodes)
return build_session_tree(nodes, sessions)


# =============================================================================
# Step 6: Abandoned-branch pruning (--branches main)
# =============================================================================


def _line_group_key(dag_line: SessionDAGLine) -> str:
"""Group a DAG-line with the trunk it forked from.

Within-session forks get synthetic ids (``s1@abcd1234``) and carry the
trunk id in ``original_session_id``; the trunk itself carries none.
"""
return dag_line.original_session_id or dag_line.session_id


def _path_length(
line_id: str,
sessions: dict[str, SessionDAGLine],
memo: dict[str, int],
) -> int:
"""Messages from the conversation root through the end of ``line_id``.

A fork does not restate the history it grew from, so a branch's true
length is its parent's length *up to the fork point* plus its own.
"""
if line_id in memo:
return memo[line_id]
memo[line_id] = 0 # cycle guard
dag_line = sessions[line_id]
prefix = 0
parent_id = dag_line.parent_session_id
if dag_line.is_branch and parent_id is not None and parent_id in sessions:
parent = sessions[parent_id]
if dag_line.attachment_uuid in parent.uuids:
prefix = parent.uuids.index(dag_line.attachment_uuid) + 1
# The fork point's own history counts, but only once.
prefix += _path_length(parent_id, sessions, memo) - len(parent.uuids)
total = prefix + len(dag_line.uuids)
memo[line_id] = total
return total


def select_main_lines(
sessions: dict[str, SessionDAGLine],
) -> dict[str, SessionDAGLine]:
"""Drop within-session fork branches that were rewound and abandoned.

Claude Code's ``/rewind`` (and editing an earlier prompt) does not delete
anything: it appends a new message whose ``parentUuid`` points at an
*earlier* message, so the transcript becomes a tree and the abandoned
attempt stays in the file. Anthropic never marks those dead branches —
``isSidechain`` stays ``False`` on every node (anthropics/claude-code#24471)
— so they cannot be filtered by flag; the shape of the graph is the only
signal available.

This keeps, per trunk, the single root→leaf path carrying the most
messages, and truncates each ancestor at the fork point it was left at.
Sidechains (subagent transcripts) never compete: they are real work
branching off the trunk, and are kept whenever their anchor survives.

Longest path — not latest timestamp — is deliberate. The newest leaf in a
real transcript is routinely a stray ``attachment`` or a one-line prompt
typed after a rewind, so "most recent" selects a stub. Longest path picks
the thread the session actually spent its time on.
"""
groups: dict[str, list[str]] = {}
for line_id, dag_line in sessions.items():
if dag_line.is_sidechain:
continue
groups.setdefault(_line_group_key(dag_line), []).append(line_id)

memo: dict[str, int] = {}
kept: dict[str, SessionDAGLine] = {}

for group_lines in groups.values():
if len(group_lines) == 1:
kept[group_lines[0]] = sessions[group_lines[0]]
continue
winner = max(
group_lines,
key=lambda lid: (
_path_length(lid, sessions, memo),
sessions[lid].first_timestamp,
),
)
# Walk the winner back to the trunk, truncating each ancestor at
# the fork point its surviving child grew from.
chain: list[str] = []
cursor: Optional[str] = winner
guard: set[str] = set()
while cursor is not None and cursor not in guard:
guard.add(cursor)
chain.append(cursor)
current = sessions[cursor]
if not current.is_branch:
break
cursor = current.parent_session_id
if cursor is not None and cursor not in sessions:
cursor = None
chain.reverse()

for index, line_id in enumerate(chain):
dag_line = sessions[line_id]
child_id = chain[index + 1] if index + 1 < len(chain) else None
if child_id is None:
kept[line_id] = dag_line
continue
fork_uuid = sessions[child_id].attachment_uuid
if fork_uuid is None or fork_uuid not in dag_line.uuids:
kept[line_id] = dag_line
continue
cut = dag_line.uuids.index(fork_uuid) + 1
kept[line_id] = SessionDAGLine(
session_id=dag_line.session_id,
uuids=dag_line.uuids[:cut],
first_timestamp=dag_line.first_timestamp,
parent_session_id=dag_line.parent_session_id,
attachment_uuid=dag_line.attachment_uuid,
is_branch=dag_line.is_branch,
original_session_id=dag_line.original_session_id,
is_sidechain=dag_line.is_sidechain,
)

# Re-admit sidechains whose anchor message survived the prune.
surviving_uuids = {uuid for dl in kept.values() for uuid in dl.uuids}
for line_id, dag_line in sessions.items():
if not dag_line.is_sidechain:
continue
anchor = dag_line.attachment_uuid
if anchor is None or anchor in surviving_uuids:
kept[line_id] = dag_line

dropped = len(sessions) - len(kept)
if dropped:
logger.debug("Pruned %d abandoned fork branch(es)", dropped)
return kept


def main_line_uuids(
entries: list[TranscriptEntry],
sidechain_uuids: set[str] | None = None,
) -> set[str]:
"""UUIDs surviving :func:`select_main_lines` for ``entries``."""
nodes = build_message_index(entries)
build_dag(nodes, sidechain_uuids=sidechain_uuids)
sessions = extract_session_dag_lines(nodes)
kept = select_main_lines(sessions)
return {uuid for dag_line in kept.values() for uuid in dag_line.uuids}


def filter_to_main_line(
entries: list[TranscriptEntry],
sidechain_uuids: set[str] | None = None,
) -> list[TranscriptEntry]:
"""Return ``entries`` with abandoned rewind branches removed.

Entries without a ``uuid`` (summaries, ai-titles, queue operations) do
not participate in the DAG and are passed through untouched.
"""
keep = main_line_uuids(entries, sidechain_uuids=sidechain_uuids)
result: list[TranscriptEntry] = []
for entry in entries:
uuid = getattr(entry, "uuid", None)
if uuid is None or uuid in keep:
result.append(entry)
return result
Comment on lines +1298 to +1313

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find where SummaryTranscriptEntry.leafUuid is consumed downstream.
rg -n -C3 '\.leafUuid\b' claude_code_log --type=py

Repository: daaain/claude-code-log

Length of output: 2423


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== dag.py relevant sections =="
sed -n '1200,1330p' claude_code_log/dag.py
echo
echo "== converter.py relevant sections =="
sed -n '980,1125p' claude_code_log/converter.py
echo
echo "== renderer.py summary mapping =="
sed -n '1110,1165p' claude_code_log/renderer.py
echo
echo "== cache.py summary handling =="
sed -n '560,605p' claude_code_log/cache.py
echo
echo "== root-level classes =="
rg -n --type=py 'class SummaryTranscriptEntry|class TranscriptEntry|def deduplicate_messages|def filter_to_main_line|def main_line_uuids' claude_code_log

Repository: daaain/claude-code-log

Length of output: 16532


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== filter_to_main_line call sites =="
rg -n 'filter_to_main_line\(' claude_code_log -g '*.py'

echo
echo "== converter.py summary construction =="
rg -n -C4 'SummaryTranscriptEntry\(|leafUuid|summary' claude_code_log/converter.py

echo
echo "== cache usage of _leaf_uuid and cache entry order/build =="
rg -n -C3 '_leaf_uuid|_deserialize_entry|serialize_entry|prepare_session_summaries|deduplicate_messages|filter_to_main_line' claude_code_log -g '*.py'

echo
echo "== deterministic probe outline =="
python3 - <<'PY'
from pathlib import Path
for p in Path("claude_code_log").glob("*"):
    if p.suffix not in {".py",".pyi"}: continue
    text=p.read_text(errors="ignore")
    print(f"{p}: leafUuid occurrences={text.count('leafUuid')}, summaries occurrences={text.count('summary')}")
PY

Repository: daaain/claude-code-log

Length of output: 26366


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== converter.py around summary generation =="
sed -n '2008,2100p' claude_code_log/converter.py
echo
echo "== converter.py around per-session branch==main summaries =="
sed -n '2378,2430p' claude_code_log/converter.py
echo
echo "== renderer.py around summary collection and first text =="
sed -n '730,790p' claude_code_log/renderer.py
sed -n '1112,1150p' claude_code_log/renderer.py

echo
echo "== compute_session_data flow summary =="
python3 - <<'PY'
from pathlib import Path
p=Path("claude_code_log/converter.py")
s=p.read_text()
for name in ["def get_summary_messages","def _collect_project_sessions","def compute_session_data","def _collect_project_sessions"]:
    idx=s.find(f"def {name}")
    print(f"{name}: {idx}")
PY

Repository: daaain/claude-code-log

Length of output: 11043


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== build_session_data / summary messages area =="
sed -n '2028,2065p' claude_code_log/converter.py
echo
echo "== renderer.py summary messages first_user text collection area =="
sed -n '1025,1095p' claude_code_log/renderer.py
sed -n '1096,1112p' claude_code_log/renderer.py

echo
echo "== summary output summary messages area in model serialization/collector =="
rg -n -C4 'summary_messages|SessionSummary|session_summaries\(|first_user_message|title or first_user' claude_code_log/cache.py claude_code_log/renderer.py claude_code_log/converter.py -g '*.py'

echo
echo "== exact call order in renderer.py =="
sed -n '740,770p'claude_code_log/renderer.py

Repository: daaain/claude-code-log

Length of output: 32050


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== renderer.py summary messages construction =="
sed -n '1035,1090p' claude_code_log/renderer.py
echo
echo "== models.py SummaryTranscriptEntry =="
sed -n '265,285p' claude_code_log/models.py

echo
echo "== summary text consumers and first_user fallback only area =="
python3 - <<'PY'
from pathlib import Path
import re
for p in Path("claude_code_log").glob("*.py"):
    s=p.read_text(errors="ignore")
    hits=[]
    for n,line in enumerate(s.splitlines(),1):
        if "first_user_message" in line or "prepare_session_summaries" in line:
            hits.append((n,line.rstrip()))
    if hits:
        print(f"\n--- {p} ---")
        for n,line in hits[:80]:
            print(f"{n}: {line}")
PY

Repository: daaain/claude-code-log

Length of output: 6417


Update filter_to_main_line to keep summary sessions coherent.

After --branches main pruning, prepare_session_summaries() still includes the original SummaryTranscriptEntry, but its leafUuid may now point at a removed message. The current map then gets no sessionId for that target UUID, so the summary can disappear from session_nav/JSON while remaining in the cache as a dangling _leaf_uuid. Rewatch/filter summaries after pruning, or rebuild/clear leafUuid when the target message is not in the surviving entry set.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@claude_code_log/dag.py` around lines 1298 - 1313, Update filter_to_main_line
to keep SummaryTranscriptEntry leafUuid references consistent with the surviving
entries: after pruning, rewatch or filter summaries against the retained entry
set, and rebuild or clear leafUuid when its target UUID was removed. Ensure
summaries retained in the result have valid session mapping and do not leave
dangling _leaf_uuid references.

Loading