Skip to content

Detail levels and compact rendering of conversations#96

Merged
cboos merged 25 commits intomainfrom
dev/shallow
Apr 18, 2026
Merged

Detail levels and compact rendering of conversations#96
cboos merged 25 commits intomainfrom
dev/shallow

Conversation

@cboos
Copy link
Copy Markdown
Collaborator

@cboos cboos commented Mar 1, 2026

We add a --detail <level> CLI flag that controls how much of a conversation gets rendered, and a --compact flag that merges consecutive same-type headings in Markdown output.


Summary

--detail <level> — controls message-type filtering

  • full (default): everything
  • high: detailed but cleaned (drops SystemMessage, HookSummaryMessage, SlashCommandMessage, CommandOutputMessage, CompactedSummaryMessage, UserMemoryMessage, UnknownMessage). Keeps tools, bash, thinking.
  • low: interaction-focused + key signals (drops Read/Edit/Bash/etc. tool_use/result, thinking, sidechains). Keeps WebSearch, WebFetch, and Task (agent delegation) tool_use/result pairs.
  • minimal: user + assistant text only (equivalent to the original --shallow proposal).

Implemented via a cumulative per-level exclude-class system (_HIGH_EXCLUDE_CLASSES, _LOW_EXCLUDE_CLASSES, _MINIMAL_EXCLUDE_CLASSES). low uses an additional keep-set for tool_use by name (_LOW_KEEP_TOOLS = {"WebSearch", "WebFetch", "Task"}).

--compact — merges consecutive same-type headings (Markdown only)

When --detail low or --detail minimal strips tools, you often get runs of consecutive User or Assistant sections, each with its own ### heading. --compact merges them: the first heading stays, subsequent same-type headings are suppressed. Flow-of-reading is preserved.

Markdown-only (HTML output has its own fold/expand UI). Works at any --detail level, most useful with low / minimal.

Test plan

  • uv run pytest -n auto test/test_detail_levels.py -v — 40+ tests pass (4 HIGH + 5 LOW + 6 compact-markdown + existing minimal coverage)
  • uv run pytest -n auto — full suite passes (897 unit tests)
  • uv run pyright — 0 errors
  • uv run ty check — clean
  • Manual: claude-code-log --detail minimal path/to/file.jsonl produces clean user/assistant-only output
  • Manual: claude-code-log --detail low --compact --format md path/to/file.jsonl merges consecutive assistants into a single heading

Release Notes

  • New Features

    • Added --detail <level> CLI flag (full / high / low / minimal) replacing the older binary --shallow idea with a four-level filtering system.
    • Added --compact CLI flag that merges consecutive same-type headings in Markdown output, improving readability of stripped transcripts.
    • --detail low preserves WebSearch/WebFetch/Task tool calls as key conversation signals.
  • Tests

    • Comprehensive coverage across all detail levels and compact-Markdown behavior.

Summary by CodeRabbit

  • New Features

    • Added --detail (full|high|low|minimal) and --compact flags; compaction-point navigation items added.
  • Improvements

    • Output verbosity filters tool/thinking/sidechain content by detail level; compact mode consolidates consecutive headings in Markdown.
    • Improved session/branch stitching and navigation; anchors auto-unfold ancestors for reliable scrolling.
    • Paginated/regeneration respects detail/compact to ensure up-to-date outputs.
  • Documentation

    • Dev docs updated for multi-root session/DAG behavior.
  • Tests

    • New tests for detail levels, compact mode, DAG collapse, and integration.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Mar 1, 2026

📝 Walkthrough

Walkthrough

Adds a DetailLevel enum and compact mode, exposes --detail/--compact CLI options, threads these through converter entrypoints into renderer creation, implements pre/post-render detail filtering and compaction navigation/markup, updates DAG handling for structural subtrees, and extends templates, tests, and snapshots accordingly.

Changes

Cohort / File(s) Summary
CLI Parameter Plumbing
claude_code_log/cli.py
Add --detail (full
Core Models
claude_code_log/models.py
Add DetailLevel enum; add compactMetadata to SystemTranscriptEntry; add compact_pre_tokens and compact_trigger to SystemMessage.
Converter Pipeline
claude_code_log/converter.py
Add detail/compact params to conversion/generation functions; pass to get_renderer(); enforce regeneration when non-default detail or compact requested.
Renderer Core Filtering & Nav
claude_code_log/renderer.py
Add detail and compact fields to Renderer and get_renderer(); implement _filter_by_detail() and _filter_template_by_detail(); add compaction-point nav entries, fractional branch ancestry levels, and _compact_nav_label().
HTML/Markdown Renderer Integration
claude_code_log/html/renderer.py, claude_code_log/markdown/renderer.py
HTML renderer passes detail into generate_template_messages(); Markdown renderer implements compact heading suppression and initializes _last_heading_category.
System Factory
claude_code_log/factories/system_factory.py
create_system_message() extracts compactMetadata.preTokens and compactMetadata.trigger for subtype=="compact_boundary" into new SystemMessage fields.
DAG & Session Stitching
claude_code_log/dag.py
Add _is_structural_subtree() and _is_expected_root_type(); revise tool-result stitching and fork collapse logic to collapse/ stitch structural/passthrough subtrees; adjust multi-root logging.
Templates & Client JS
claude_code_log/html/templates/...
Add session-compaction-point nav item and .compaction-link CSS; .session-compaction-point styling; transcript.html emits d-<ancestor_index> classes and adds unfoldAncestorsOf() hash-anchor unfolding logic.
Tests & Snapshots
test/..., test/__snapshots__/...
Add comprehensive test/test_detail_levels.py; update/add DAG unit and integration tests for structural collapse and compaction points; update snapshot reflecting CSS/JS changes.
Docs
dev-docs/dag.md
Document multi-root-aware DAG extraction, three-pattern tool-result stitching, structural-side-branch collapse, compaction-point nav behavior, and DAG coverage invariants.

Sequence Diagram

sequenceDiagram
    participant User as User/CLI
    participant CLI as CLI Module
    participant Converter as Converter
    participant RendererFactory as Renderer Factory
    participant FilterPre as Pre-Render Filter
    participant TemplateGen as Template Generator
    participant FilterPost as Post-Render Filter
    participant Output as Output (HTML/MD)

    User->>CLI: invoke with --detail=low --compact
    CLI->>Converter: convert_jsonl_to(detail=LOW, compact=True)
    Converter->>RendererFactory: get_renderer(detail=LOW, compact=True)
    RendererFactory->>RendererFactory: set renderer.detail & renderer.compact
    Converter->>FilterPre: _filter_by_detail(messages, LOW)
    FilterPre-->>Converter: filtered messages
    Converter->>TemplateGen: generate_template_messages(filtered, detail=LOW)
    TemplateGen->>FilterPost: _filter_template_by_detail(templates, LOW)
    FilterPost-->>TemplateGen: filtered templates
    TemplateGen-->>RendererFactory: template messages
    RendererFactory->>Output: render templates (apply compact behavior if MD)
    Output-->>User: final rendered files
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested reviewers

  • daaain

Poem

🐰📜 I trim the verbose, hop through each line,
Compact the headings so outputs align.
I stitch branches tidy, mark compaction with flair,
Detail levels set — from full down to bare.
A rabbit’s soft nibble makes navigation fair. 🥕

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the two main features added: detail levels (with full/high/low/minimal options) and compact rendering for conversations. It is concise, specific, and clearly reflects the primary changes in the changeset.
Docstring Coverage ✅ Passed Docstring coverage is 86.67% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch dev/shallow

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (2)
test/test_shallow_mode.py (1)

561-639: Add a cache mode-toggle regression test (full → shallow and shallow → full).

Current tests mostly generate from fresh outputs, so they won’t catch stale reuse when the same cached target is rendered with different modes.

Also applies to: 751-774

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/test_shallow_mode.py` around lines 561 - 639, Add a regression test that
exercises cache-mode toggling by rendering the same output target twice with
different modes: first generate a cached HTML/MD with --shallow and assert tool
messages are omitted, then re-run the CLI for the same output path without
--shallow (full) and assert tool messages are present (and vice versa: start
with full then run --shallow) to ensure cached artifacts are not incorrectly
reused across modes; implement this using the existing test helpers and
CliRunner.invoke(main, ...) and reuse the same output_file path to force cache
reuse, checking presence/absence of "class='message tool_use" / "class='message
tool_result" (or tool name like "Bash") and exit_code == 0 for each invocation.
claude_code_log/renderer.py (1)

527-544: Missing docstring for shallow parameter.

The shallow parameter was added to the function signature but not documented in the Args section of the docstring.

📝 Add documentation for the shallow parameter
     """Generate root messages and session navigation from transcript messages.

     This is the format-neutral rendering step that produces data structures
     ready for template rendering by any format-specific renderer.

     Args:
         messages: List of transcript entries to process.
+        shallow: If True, filter to only user and assistant text messages,
+            excluding tools, thinking, system messages, and sidechains.

     Returns:
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@claude_code_log/renderer.py` around lines 527 - 544, The docstring for
generate_template_messages is missing documentation for the shallow parameter;
update the Args section to include a one-line description of shallow (its type
bool, default False) and what it controls (e.g., whether to perform a shallow
rendering that skips deep parsing/child population or only generates top-level
session headers), so readers understand its effect when calling
generate_template_messages(messages, shallow=True/False).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@claude_code_log/converter.py`:
- Around line 701-702: Staleness checks and early exits are ignoring the new
shallow render mode, so add the shallow flag into the cache/staleness identity
used wherever staleness is computed for combined, paginated, and session
outputs: include shallow in the metadata written to and read from cache
files/marker files and incorporate it into whatever key/hash/compute_staleness
function is used by the early-exit logic (the code paths that currently accept
shallow: bool and return Path). Update the marker read/write and the
is_stale/check_staleness logic to compare the shallow value as well as
timestamps/inputs so switching shallow vs full on the same output path won't
return the wrong artifact.

In `@test/test_shallow_mode.py`:
- Around line 126-130: Rename the outdated references to _filter_compact to the
current function name _filter_shallow in the test header and docstring: update
the section comment and the TestFilterShallow class docstring so they mention
_filter_shallow (and any other lingering _filter_compact mentions) to accurately
reflect the tested symbol and intent.

---

Nitpick comments:
In `@claude_code_log/renderer.py`:
- Around line 527-544: The docstring for generate_template_messages is missing
documentation for the shallow parameter; update the Args section to include a
one-line description of shallow (its type bool, default False) and what it
controls (e.g., whether to perform a shallow rendering that skips deep
parsing/child population or only generates top-level session headers), so
readers understand its effect when calling generate_template_messages(messages,
shallow=True/False).

In `@test/test_shallow_mode.py`:
- Around line 561-639: Add a regression test that exercises cache-mode toggling
by rendering the same output target twice with different modes: first generate a
cached HTML/MD with --shallow and assert tool messages are omitted, then re-run
the CLI for the same output path without --shallow (full) and assert tool
messages are present (and vice versa: start with full then run --shallow) to
ensure cached artifacts are not incorrectly reused across modes; implement this
using the existing test helpers and CliRunner.invoke(main, ...) and reuse the
same output_file path to force cache reuse, checking presence/absence of
"class='message tool_use" / "class='message tool_result" (or tool name like
"Bash") and exit_code == 0 for each invocation.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 70bb2e0 and ed70361.

📒 Files selected for processing (6)
  • claude_code_log/cli.py
  • claude_code_log/converter.py
  • claude_code_log/html/renderer.py
  • claude_code_log/markdown/renderer.py
  • claude_code_log/renderer.py
  • test/test_shallow_mode.py

Comment thread claude_code_log/converter.py Outdated
Comment on lines 701 to 702
shallow: bool = False,
) -> Path:
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Cache invalidation does not account for render mode (shallow vs full).

shallow is now threaded through generation, but staleness checks/early exits are still mode-agnostic. That can serve the wrong artifact when users switch modes on the same output path.

🛠️ Minimum mitigation direction
@@ def convert_jsonl_to(...):
-        if (
+        if (
             cache_manager is not None
             and not cache_was_updated
             and from_date is None
             and to_date is None
+            and not shallow
         ):
             ...
@@
-            should_regenerate = (
+            should_regenerate = shallow or (
                 is_stale
                 or renderer.is_outdated(output_path)
                 or from_date is not None
                 or to_date is not None
                 or not output_path.exists()
             )
@@
-            if format == "html" and cache_manager is not None:
+            if format == "html" and cache_manager is not None and not shallow:
                 cache_manager.update_html_cache(...)

A complete fix should include render mode in staleness identity (cache metadata and/or file marker checks) for combined, paginated, and session outputs.

Also applies to: 921-922, 1026-1027, 1073-1074, 1126-1127, 1489-1490, 1525-1526, 1673-1674, 1830-1831

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@claude_code_log/converter.py` around lines 701 - 702, Staleness checks and
early exits are ignoring the new shallow render mode, so add the shallow flag
into the cache/staleness identity used wherever staleness is computed for
combined, paginated, and session outputs: include shallow in the metadata
written to and read from cache files/marker files and incorporate it into
whatever key/hash/compute_staleness function is used by the early-exit logic
(the code paths that currently accept shallow: bool and return Path). Update the
marker read/write and the is_stale/check_staleness logic to compare the shallow
value as well as timestamps/inputs so switching shallow vs full on the same
output path won't return the wrong artifact.

@cboos cboos self-assigned this Mar 2, 2026
@cboos cboos added the enhancement New feature or request label Mar 2, 2026
@cboos cboos marked this pull request as draft March 2, 2026 08:58
@cboos
Copy link
Copy Markdown
Collaborator Author

cboos commented Apr 12, 2026

Neither "compact" nor "shallow" feels right. Maybe "coarse", as opposed to "detailed"?
I'll redo this one on top of the DAG PRs, #97 and #99.

@cboos cboos changed the base branch from main to dev/dag-agents April 13, 2026 14:03
@cboos
Copy link
Copy Markdown
Collaborator Author

cboos commented Apr 14, 2026

After a little brainstorming session with ChatGPT:

✅ Final polished version

--detail full      # everything
--detail high      # detailed but cleaned (no system/hook noise)
--detail low       # interaction-focused + key signals
--detail minimal   # user + agent messages only

Does that sound right?

@daaain
Copy link
Copy Markdown
Owner

daaain commented Apr 14, 2026

After a little brainstorming session with ChatGPT:

✅ Final polished version

--detail full      # everything
--detail high      # detailed but cleaned (no system/hook noise)
--detail low       # interaction-focused + key signals
--detail minimal   # user + agent messages only

Does that sound right?

Yes, I think these sound great! Please put those comments as details in the CLI help too!

@cboos cboos force-pushed the dev/dag-agents branch 2 times, most recently from 57abed6 to 0418998 Compare April 17, 2026 16:28
cboos and others added 15 commits April 18, 2026 13:04
Filters out tool use/result, thinking, and system messages early in
the rendering pipeline via _filter_compact() in renderer.py. Threaded
through CLI, converter, and both HTML/Markdown renderers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
27 tests covering unit (_filter_compact), integration
(generate_template_messages), HTML/Markdown rendering, CLI flag,
real project data, and bundled test data files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…-derived types

Use isinstance checks on content classes instead of message_type strings,
since SlashCommandMessage, CommandOutputMessage, and CompactedSummaryMessage
all return "user" as their message_type.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add explicit ContentItem annotation and cast for narrowing the
UserTranscriptEntry/AssistantTranscriptEntry union on .message assignment.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add isinstance narrowing for TranscriptEntry union types before
accessing .message and .isSidechain attributes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Rename CLI flag, parameters, functions, constants, test file, and all
references from "compact" to "shallow" to avoid confusion with the
existing CompactedSummary message type and future compact styling mode.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Two issues from rebasing dev/shallow onto main:

1. generate_single_session_export() called load_directory_transcripts()
   which now returns (messages, session_tree), but only assigned to
   messages — session ID lookup iterated a tuple instead of entries.

2. Shallow mode skipped regeneration when pre-existing HTML matched
   the current version. Added shallow flag to should_regenerate
   conditions so --shallow always produces fresh output.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Implement four detail levels: full, high, low, minimal.

- full: everything (default, no change from current behavior)
- high: detailed but cleaned (no system/hook/slash-command noise)
- low: interaction-focused + key signals (keeps WebSearch, WebFetch,
  Task tool_use/result; drops other tools, bash, thinking, sidechains)
- minimal: user + assistant text only (equivalent to old --shallow)

Infrastructure:
- Add DetailLevel enum to models.py
- Replace shallow: bool with detail: DetailLevel throughout pipeline
  (cli.py, converter.py, renderer.py, html/renderer.py, markdown/renderer.py)
- Level-specific filtering via _filter_by_detail (pre-render) and
  _filter_template_by_detail (post-render) with per-level exclude sets
- CLI: --detail full|high|low|minimal with help text per daaain's request

Rename test/test_shallow_mode.py -> test/test_detail_levels.py.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Per monk review feedback: existing tests only covered MINIMAL level.

HIGH level tests (4):
- Keeps tool_use and tool_result messages
- Keeps ThinkingMessage (not filtered at HIGH)
- Drops SystemMessage
- Drops slash command messages

LOW level tests (5):
- Keeps WebSearch tool_use/result (key signal)
- Drops Read tool_use/result (not a key signal)
- Drops ThinkingMessage
- Keeps user and assistant text
- Drops system messages

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When --compact is set, consecutive messages of the same type share
a single heading instead of repeating it. Most useful with --detail
low/minimal where tool stripping creates runs of Assistant sections.

Implementation: MarkdownRenderer._render_message tracks
_last_message_type and suppresses the heading when the current
message matches. Resets on session header boundaries.

Markdown-only — HTML ignores the flag (no heading-per-message there).

6 tests covering: consecutive merge, type boundary, session reset,
off-by-default, no effect on HTML.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Per monk review: without this, cached non-compact output would be
served when --compact is requested. Same pattern as detail level.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Two related fixes for DAG-rendered transcripts with many branches:

1. Branch nav items whose first message was filtered out (e.g. a
   passthrough attachment entry) rendered as href='#msg-d-None' links
   pointing nowhere. Drop such branches, and drop the surrounding fork
   point if all its branches are dead.

2. Clicking a #msg-d-X anchor from the session index landed on an
   element that was display:none because its ancestor fold-bars were
   initially folded. Add a hashchange listener (also runs on initial
   load with a hash) that walks the target's d-X ancestor classes,
   unfolds each ancestor's fold-all-levels control, then re-scrolls
   the now-visible target into view.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two related corrections to DAG fork detection:

1. Variant 1 of `_stitch_tool_results` used to treat any user child
   with a same-session descendant as "live", which missed the common
   parallel-tool_use pattern where the tool_result has only a
   hook_success attachment leaf. Replace the "no immediate children"
   check with `_is_structural_subtree()`, which walks descendants
   looking for user/assistant content and returns True for subtrees
   that contain only passthrough/system entries.

2. A parent whose multiple children are all passthrough
   (e.g. hook_success + SessionStart:resume at far-apart timestamps)
   used to produce a fake fork with two dead branches. Handle this
   directly in `_walk_session_with_forks` by collapsing all children
   into the chain and terminating — no fork, no branches.

Variant 2 of `_stitch_tool_results` is unchanged. Existing tests for
both variants still pass; three new tests cover the
parallel-tool_use+attachment, attachment-only, and real-rewind cases.

On the 1594-entry BCT Teamcenter test file this eliminates 22 fake
fork points and 42 dead branch links with no content loss.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Within-session branch-headers used to restart the fold hierarchy at
level 0, making them siblings of their parent session. Clicking the
parent session's 'fold all levels' button therefore did not cascade
into branch content: descendants of branches had no 'd-<parent>'
ancestor class and were skipped by the `.message.d-X` selector.

Assign branch-headers a fractional level of 0.5 so they nest under
their parent session while staying above user messages. Descendants
now carry both the parent session's ancestor class and the branch-
header's, so the existing anchor-unfold JS and the fold-bar cascade
work uniformly across branches and non-branch content. Only
within-session forks (`is_branch_header`) move — resumed-session
headers stay at level 0.

Three companion changes:
- Template: emit ancestor `d-X` classes on session-header <div> too,
  so branch-headers carry their parent session's ancestor class.
- Test: walk the full flat/tree of template messages instead of
  expecting branch-headers to live at the top of `root_messages`.
- dag.py: tighten the all-passthrough fork early-exit to also check
  `_is_structural_subtree` per monk's review — defense-in-depth for
  any future passthrough type that might carry conversational
  descendants.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`/compact` writes a parentless `system/compact_boundary` entry followed
by a user entry carrying the summary — the existing
`CompactedSummaryMessage` content type. The transition is a real
content discontinuity (115k+ tokens of context replaced by a summary),
so it deserves a navigational landmark rather than being silently
merged into the main trunk during DAG walking.

Three related changes:

1. `build_session_nav` emits a `is_compaction_point` nav item for each
   `CompactedSummaryMessage` in a session, indented under its parent
   session (depth + 1). The anchor points at the summary's own
   `#msg-d-X` so clicking jumps to the rendered content. Ordering is
   chronological by message timestamp.

2. `session_nav.html` + `session_nav_styles.css` render the new item
   with a 📦 glyph and a solid (vs dashed for forks/branches) border
   on the nav card.

3. `extract_session_dag_lines` now classifies the roots of multi-root
   sessions. `system/compact_boundary` and `system/local_command`
   roots are the two known expected cases — when all roots are
   expected, demote the warning to debug. When anything else shows up
   (an orphan user/assistant hinting at a missing parent), keep the
   warning at its original level. The underlying multi-root walking
   behavior is unchanged.

Tests:
- `TestRootClassification`: two compact_boundary + one local_command
  root produces no warning; an orphan user entry does.
- `TestCompactBoundaryNav`: single/multiple/no-boundary cases for
  `is_compaction_point` nav emission.

On the 1594-entry BCT Teamcenter file: previously noisy "3 roots
found" warning is gone, and the two manual `/compact` points are
surfaced in the session index as navigable landmarks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
cboos and others added 4 commits April 18, 2026 13:04
Align dag.md with the current dag.py/renderer.py behavior after the
alice-{fix-nav-anchors,real-fork-fix,branch-hierarchy,compact-boundaries}
series.

Changes:

- Phase 3 ("Extract Session DAG-lines") now reflects multi-root handling,
  `_walk_session_with_forks`, trunk merging, and the coverage-fallback.
  The "verify linearity" claim is gone — within-session forks are
  supported intentionally.

- "Tool-Result Side-Branches" section now uses Mermaid diagrams for
  Variant 1, Variant 2, and the structural-only fork case, each with a
  detection/action summary. Variant 1 diagrams show the pre-fix fake
  fork and the post-fix stitched chain side-by-side. Added a summary
  table covering all five multi-child dispositions (variant 1/2,
  structural-only, real rewind, compaction replay).

- New section "Compact Boundaries and Multi-Root Sessions": diagrams a
  3-root session produced by two `/compact` runs; documents the
  `_EXPECTED_ROOT_SYSTEM_SUBTYPES` classifier (`compact_boundary`,
  `local_command`) that demotes the multi-root warning to debug; notes
  the `is_compaction_point` nav landmarks built from
  `CompactedSummaryMessage` entries.

- Assertions/Invariants rewritten to state "trunk is linear after
  stitching" and enumerate the three legitimate sources of intra-session
  branching. Added multi-root tolerance and DAG walk coverage as
  separate invariants.

No code changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The alice-real-fork-fix branch handled two structural-collapse cases:
the passthrough-with-attachment tool_result variant (via updated
`_is_structural_subtree` check in `_stitch_tool_results`), and the
all-passthrough early-exit (e.g. hook_success + SessionStart:resume
attachments on the same parent).

It missed the general mixed case: a conversational child alongside a
passthrough-only sibling (e.g. a `<user>` next to a bare `<progress>`
entry, or `<progress>`→`<progress>` chain). `_stitch_tool_results`
requires both a user and an assistant child, so this fell through to
the different-timestamps real-fork path, producing spurious
"Fork point (1 branches)" entries in the nav.

Replace the all-passthrough early-exit with a slightly more general
test: if at most one non-structural child remains after isolating the
PassthroughTranscriptEntry subtrees whose descendants are
`_is_structural_subtree`, the chain continues through that single
child (or terminates if none remain) and the structural children are
stitched chronologically as dead-end side entries.

Three new tests cover:
- `<user>` + `<progress>` leaf sibling → linear chain, 0 branches
- `<user>` + `<progress>`→`<progress>` chain → linear, skipped subtree
- two conversational siblings → real fork still preserved

On the -experiments-ideas fixture the two previously-spurious
1-branch forks are gone; the single real 2-branch fork is kept.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The compact_boundary system entry carries `compactMetadata`
(preTokens, trigger, postTokens, durationMs) that the nav landmark
label previously ignored, producing bare "Conversation compacted"
entries with no signal about how much context was actually dropped.

Surface preTokens and trigger through the factory:

- `SystemTranscriptEntry` gains `compactMetadata: Optional[dict]` so
  the raw JSONL field lands on the parsed entry.
- `SystemMessage` gains two optional typed fields — `compact_pre_tokens`
  and `compact_trigger` — populated only for subtype="compact_boundary"
  by `create_system_message`. Other system entries leave them None.
- `_compact_nav_label` looks up the landmark's parent TemplateMessage
  by parent_uuid and reads these fields off the SystemMessage,
  producing labels like "Conversation compacted (115k tokens) •
  2026-04-14 09:09:28". Falls back to the bare label when metadata
  is missing (e.g. older transcripts).

Tested against the BCT Teamcenter session: the two existing landmarks
now show "(115k tokens)" and "(126k tokens)" alongside their
timestamps.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Follow-up to monk-dag-docs (867d81f), picking up the two alice branches
that have since landed on dev/shallow:

- alice-fix-structural-collapse (2676a56) generalized the passthrough
  early-exit from the all-children-passthrough case to any parent with
  at most one non-structural child. Rename the subsection to
  "Structural-side-branch collapse", describe the
  structural_kids / non_structural partition, and add a second Mermaid
  diagram for the mixed user + `<progress>` sibling shape. Note
  explicitly that Variant 1 and the collapse path don't overlap
  (UserTranscriptEntry with a passthrough-only subtree stays in
  non_structural and goes through `_stitch_tool_results`). Summary
  table row updated accordingly.

- alice-compact-landmark-enrichment (86264d4) surfaced
  `compactMetadata.preTokens` and `trigger` through
  SystemTranscriptEntry → SystemMessage. Extend the "Nav landmarks"
  subsection with an example label ("Conversation compacted (115k
  tokens) • 2026-04-14 09:09:28"), document the plumbing and the
  graceful-degradation fallbacks (missing parent_uuid, filtered parent
  at HIGH detail, non-SystemMessage parent, None/zero tokens), and note
  that `compact_trigger` is plumbed but not rendered yet.

No code changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@cboos cboos changed the title Shallow rendering of conversations Detail levels and compact rendering of conversations Apr 18, 2026
@cboos cboos changed the base branch from dev/dag-agents to main April 18, 2026 11:57
@cboos cboos marked this pull request as ready for review April 18, 2026 11:57
@cboos
Copy link
Copy Markdown
Collaborator Author

cboos commented Apr 18, 2026

@coderabbitai full review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 18, 2026

✅ Actions performed

Full review triggered.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
claude_code_log/converter.py (1)

1129-1145: ⚠️ Potential issue | 🟡 Minor

Docstring missing compact parameter.

The convert_jsonl_to docstring documents detail but not the new compact parameter added alongside it. Same applies to _generate_individual_session_files and process_projects_hierarchy docstrings.

📝 Proposed addition
         detail: Output detail level (full, high, low, minimal).
+        compact: If True, merge consecutive same-type headings (Markdown only).
     """
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@claude_code_log/converter.py` around lines 1129 - 1145, Update the docstrings
to document the new boolean compact parameter: add a line describing "compact:
Whether to produce compact output (e.g., omit verbose metadata) and how it
affects output" inside the convert_jsonl_to(...) docstring and the
_generate_individual_session_files(...) and process_projects_hierarchy(...)
docstrings so each lists the parameter alongside existing ones like detail,
include its type (bool) and default behavior/impact on output generation; ensure
the parameter name exactly matches "compact" to align with the function
signatures (_generate_individual_session_files, process_projects_hierarchy, and
convert_jsonl_to) and keep wording consistent across all three docstrings.
♻️ Duplicate comments (1)
claude_code_log/converter.py (1)

1295-1297: ⚠️ Potential issue | 🟠 Major

Detail/compact not incorporated into paginated or per-session staleness checks.

detail/compact are threaded through, and the single-file branch forces regenerate when they are non-default (Lines 1309-1310, 1320-1321). But the paginated path (_generate_paginated_html uses cache_manager.is_page_stale(...) at Line 972) and _generate_individual_session_files staleness checks (Lines 1781-1787, 1790-1796) don't know about these modes. Two resulting bugs:

  1. Running with --detail low --compact then again with defaults (or vice versa) on the same output path returns the cached prior-mode artifact in both the paginated and session-file cases.
  2. Even in the non-paginated branch, detail != FULL or compact only forces regeneration going into those modes — cached low/compact outputs are treated as up-to-date for subsequent --detail full runs.

A complete fix needs to include detail and compact in the cache identity (marker/metadata) so page and session staleness flip when modes change. At minimum, mirror the force-regen guard into the paginated and session-file branches.

Also applies to: 1345-1358

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@claude_code_log/converter.py` around lines 1295 - 1297, The paginated and
per-session staleness logic doesn't consider the detail and compact modes, so
update the cache identity used by cache_manager.is_page_stale and the staleness
checks in _generate_paginated_html and _generate_individual_session_files to
include the detail and compact values (or add them to the metadata/marker passed
into the cache manager), or at minimum mirror the existing force-regen guard
(the detail != FULL || compact condition used in the single-file branch) into
those branches so changes to detail/compact flip page/session staleness; refer
to the functions _generate_paginated_html, _generate_individual_session_files
and the cache_manager.is_page_stale calls and ensure the mode tuple (detail,
compact) is included in the cache key/metadata used for staleness decisions.
🧹 Nitpick comments (4)
claude_code_log/markdown/renderer.py (1)

841-856: Compact mode: heading-suppression logic is sound; note one subtle state scope.

The ordering is correct: reset on session headers → check suppression against previous type → update self._last_message_type. Because the update lives inside if title:, messages that produce an empty title (other than pair_last, which already returns early at line 826) won't update the tracker and thus don't "break" a run of same-type headings. That should be fine in practice given current title methods, but it's a behavior worth keeping in mind if future title methods start returning empty strings for non-paired messages.

Also, tracking is global across the recursive tree walk rather than per-parent/per-sibling. For the current message shape (tool results as children, not repeated top-level message types) this is a non-issue, but a future nested same-type scenario could see a deeper heading accidentally suppressed.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@claude_code_log/markdown/renderer.py` around lines 841 - 856, The suppression
logic can miss updating the tracker when a message yields an empty title
(because _last_message_type is currently set only after building the heading),
so always assign self._last_message_type = msg_type unconditionally (i.e., move
the update out of the title/heading conditional) so runs of same-type messages
are tracked even when a specific message produces no title; keep the existing
session-reset (is_session_header) and suppress_heading calculation using compact
and msg_type intact, and add a brief comment near the _last_message_type update
to note global-tracking behavior across the recursive walk.
test/test_detail_levels.py (2)

656-684: Fragile substring assertion for tool-name absence.

"Write" not in md.split("File created!")[0].split("Creating the file.")[1] depends on exact formatting and substring layout in the Markdown output; any future heading/prefix change (e.g., a rendered "Write" anywhere in the slice) silently breaks or masks regressions. Prefer the _collect_types pattern used elsewhere, or check for concrete tool markers.

♻️ Proposed alternative
-        assert (
-            "Write" not in md.split("File created!")[0].split("Creating the file.")[1]
-        )
+        # No tool_use block markers should be rendered in minimal Markdown.
+        _, _, ctx = generate_template_messages(messages, detail=DetailLevel.MINIMAL)
+        types = {m.content.message_type for m in ctx.messages}
+        assert "tool_use" not in types
+        assert "tool_result" not in types
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/test_detail_levels.py` around lines 656 - 684, The assertion in
test_minimal_markdown_no_tool_content is fragile because it slices md by literal
strings; update the test to assert absence of tool-specific content using the
same robust helper/pattern used elsewhere: call the _collect_types(md) utility
(or parse md for concrete tool markers generated from
_tool_use_item/_tool_result_item) and assert that tool names like "Write" or
tool markers (e.g., entries created by _tool_use_item/_tool_result_item) are not
present in the collected types/output; replace the substring-slice assertion
with checks against _collect_types(md) or explicit absence of tool markers on
the rendered Markdown.

721-740: Non-deterministic fixture selection.

jsonl_files[0] depends on filesystem iteration order since real_projects.iterdir() and project_dir.glob("*.jsonl") are unordered. On different filesystems/CI runners the chosen file can differ, producing flaky behaviour on edge-case transcripts. Sort the collection before picking an element.

♻️ Proposed fix
-        jsonl_files = []
-        for project_dir in real_projects.iterdir():
-            if project_dir.is_dir():
-                jsonl_files.extend(project_dir.glob("*.jsonl"))
+        jsonl_files = []
+        for project_dir in sorted(real_projects.iterdir()):
+            if project_dir.is_dir():
+                jsonl_files.extend(sorted(project_dir.glob("*.jsonl")))
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/test_detail_levels.py` around lines 721 - 740, The test
test_minimal_markdown_on_real_projects is picking jsonl_files[0] from an
unsorted jsonl_files list built via real_projects.iterdir() and
project_dir.glob("*.jsonl"), which yields non-deterministic order; fix by
sorting the collected jsonl_files (e.g., sort by path name) before selecting the
first element so the test always uses a deterministic file—update the code that
builds jsonl_files (the loop using real_projects.iterdir() / project_dir.glob)
to append then call jsonl_files.sort() (or use a sorted() wrapper) prior to
load_transcript(...) and generating with MarkdownRenderer/DetailLevel.MINIMAL.
claude_code_log/converter.py (1)

920-921: Bypass of get_renderer(...) creates a second renderer construction path.

_generate_paginated_html imports HtmlRenderer directly and assigns detail/compact as attributes post-init, while the rest of the file consistently uses get_renderer(format, image_export_mode, detail=detail, compact=compact) (Lines 1243, 1750). If get_renderer ever gains additional wiring (e.g., default image_export_mode, validation, instrumentation), this path will silently diverge.

♻️ Proposed alignment
-    from .html.renderer import HtmlRenderer
     from .utils import format_timestamp
@@
-        page_renderer = HtmlRenderer()
-        page_renderer.detail = detail
-        page_renderer.compact = compact
+        page_renderer = get_renderer(
+            "html", image_export_mode=None, detail=detail, compact=compact
+        )

(Adjust signature if image_export_mode should be threaded through from the caller.)

Also applies to: 1059-1062

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@claude_code_log/converter.py` around lines 920 - 921, The helper
_generate_paginated_html currently imports and constructs HtmlRenderer directly
(assigning detail/compact attributes afterwards), creating a second construction
path that diverges from the rest of the module which uses get_renderer(format,
image_export_mode, detail=detail, compact=compact); change
_generate_paginated_html to call get_renderer(...) instead of importing
HtmlRenderer directly, passing through the caller's image_export_mode (or add an
image_export_mode parameter to _generate_paginated_html if needed) so all
renderer wiring/validation/instrumentation stays centralized in get_renderer and
remove the direct HtmlRenderer usage.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@claude_code_log/dag.py`:
- Around line 539-563: The current logic treats any non-expected root as
"unexpected" and logs a warning even when there's a single ordinary primary root
plus expected compact_boundary/local_command roots; update the check around
roots/unexpected in the block that uses _is_expected_root_type so that one
non-expected root is allowed as the session primary and only additional
non-expected roots trigger the warning—e.g., compute unexpected as you do, then
treat the first non-expected as the primary (if any) and only call
logger.warning when len(unexpected) > 1 (or when len(unexpected[1:]) > 0),
adjusting the logged counts/messages to reflect how many unexpected extras were
found and keeping the existing debug path when there are no extra non-expected
roots.

In `@claude_code_log/html/templates/transcript.html`:
- Around line 862-867: The code only tries to un-fold ancestor messages by
clicking the '.fold-all-levels' button, so single-level fold bars
('.fold-one-level.full-width') are left folded; update the ancestor handling in
the block that queries foldBar/ foldAll (using the ancestorMsg variable) to also
look for a fallback button: query
foldBar.querySelector('.fold-one-level.full-width') and if it exists and has the
'folded' class, call click() on it (fall back to this when foldAll is null) so
single-level folded ancestors are expanded too.

In `@dev-docs/dag.md`:
- Around line 460-462: The fenced code block containing the label "📦
Conversation compacted (115k tokens) • 2026-04-14 09:09:28" is missing a
language identifier; update that markdown block to include the `text` language
specifier (i.e., change the opening backticks to ```text) so markdownlint MD040
is satisfied and the content is treated as plain text.

In `@test/test_dag_integration.py`:
- Around line 1378-1398: The compact-boundary fixture returned by
_make_compact_boundary is missing the system message "level" field required by
SystemMessage construction, which breaks uuid_to_msg and the "100k tokens" label
path; update the dict returned by _make_compact_boundary to include a suitable
"level" value (e.g., "system" or the expected enum/string used elsewhere)
alongside the existing keys like "type", "subtype", "uuid", and
"compactMetadata" so the fixture produces a valid SystemMessage.

In `@test/test_detail_levels.py`:
- Around line 130-134: The test file's section header and class docstring
incorrectly reference _filter_compact while the tests actually import and
exercise _filter_by_detail; update the section comment text and the
TestFilterMinimal class docstring to reference _filter_by_detail (or otherwise
clarify that the tests target _filter_by_detail) so the documented intent
matches the imported symbol _filter_by_detail used in the tests.

---

Outside diff comments:
In `@claude_code_log/converter.py`:
- Around line 1129-1145: Update the docstrings to document the new boolean
compact parameter: add a line describing "compact: Whether to produce compact
output (e.g., omit verbose metadata) and how it affects output" inside the
convert_jsonl_to(...) docstring and the _generate_individual_session_files(...)
and process_projects_hierarchy(...) docstrings so each lists the parameter
alongside existing ones like detail, include its type (bool) and default
behavior/impact on output generation; ensure the parameter name exactly matches
"compact" to align with the function signatures
(_generate_individual_session_files, process_projects_hierarchy, and
convert_jsonl_to) and keep wording consistent across all three docstrings.

---

Duplicate comments:
In `@claude_code_log/converter.py`:
- Around line 1295-1297: The paginated and per-session staleness logic doesn't
consider the detail and compact modes, so update the cache identity used by
cache_manager.is_page_stale and the staleness checks in _generate_paginated_html
and _generate_individual_session_files to include the detail and compact values
(or add them to the metadata/marker passed into the cache manager), or at
minimum mirror the existing force-regen guard (the detail != FULL || compact
condition used in the single-file branch) into those branches so changes to
detail/compact flip page/session staleness; refer to the functions
_generate_paginated_html, _generate_individual_session_files and the
cache_manager.is_page_stale calls and ensure the mode tuple (detail, compact) is
included in the cache key/metadata used for staleness decisions.

---

Nitpick comments:
In `@claude_code_log/converter.py`:
- Around line 920-921: The helper _generate_paginated_html currently imports and
constructs HtmlRenderer directly (assigning detail/compact attributes
afterwards), creating a second construction path that diverges from the rest of
the module which uses get_renderer(format, image_export_mode, detail=detail,
compact=compact); change _generate_paginated_html to call get_renderer(...)
instead of importing HtmlRenderer directly, passing through the caller's
image_export_mode (or add an image_export_mode parameter to
_generate_paginated_html if needed) so all renderer
wiring/validation/instrumentation stays centralized in get_renderer and remove
the direct HtmlRenderer usage.

In `@claude_code_log/markdown/renderer.py`:
- Around line 841-856: The suppression logic can miss updating the tracker when
a message yields an empty title (because _last_message_type is currently set
only after building the heading), so always assign self._last_message_type =
msg_type unconditionally (i.e., move the update out of the title/heading
conditional) so runs of same-type messages are tracked even when a specific
message produces no title; keep the existing session-reset (is_session_header)
and suppress_heading calculation using compact and msg_type intact, and add a
brief comment near the _last_message_type update to note global-tracking
behavior across the recursive walk.

In `@test/test_detail_levels.py`:
- Around line 656-684: The assertion in test_minimal_markdown_no_tool_content is
fragile because it slices md by literal strings; update the test to assert
absence of tool-specific content using the same robust helper/pattern used
elsewhere: call the _collect_types(md) utility (or parse md for concrete tool
markers generated from _tool_use_item/_tool_result_item) and assert that tool
names like "Write" or tool markers (e.g., entries created by
_tool_use_item/_tool_result_item) are not present in the collected types/output;
replace the substring-slice assertion with checks against _collect_types(md) or
explicit absence of tool markers on the rendered Markdown.
- Around line 721-740: The test test_minimal_markdown_on_real_projects is
picking jsonl_files[0] from an unsorted jsonl_files list built via
real_projects.iterdir() and project_dir.glob("*.jsonl"), which yields
non-deterministic order; fix by sorting the collected jsonl_files (e.g., sort by
path name) before selecting the first element so the test always uses a
deterministic file—update the code that builds jsonl_files (the loop using
real_projects.iterdir() / project_dir.glob) to append then call
jsonl_files.sort() (or use a sorted() wrapper) prior to load_transcript(...) and
generating with MarkdownRenderer/DetailLevel.MINIMAL.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f4f349a2-b5f5-4478-b1c6-a1ce8a44a64b

📥 Commits

Reviewing files that changed from the base of the PR and between ed70361 and 176a6ed.

📒 Files selected for processing (16)
  • claude_code_log/cli.py
  • claude_code_log/converter.py
  • claude_code_log/dag.py
  • claude_code_log/factories/system_factory.py
  • claude_code_log/html/renderer.py
  • claude_code_log/html/templates/components/session_nav.html
  • claude_code_log/html/templates/components/session_nav_styles.css
  • claude_code_log/html/templates/transcript.html
  • claude_code_log/markdown/renderer.py
  • claude_code_log/models.py
  • claude_code_log/renderer.py
  • dev-docs/dag.md
  • test/__snapshots__/test_snapshot_html.ambr
  • test/test_dag.py
  • test/test_dag_integration.py
  • test/test_detail_levels.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • claude_code_log/renderer.py

Comment thread claude_code_log/dag.py
Comment on lines 539 to +563
if len(roots) > 1:
logger.warning(
"Session %s: %d roots found, walking all from earliest (%s)",
session_id,
len(roots),
roots[0].uuid,
)
# Roots that arise from expected Claude Code mechanisms —
# `/compact` writes a compact_boundary system entry with no
# parentUuid; early `local_command` entries like `/memory`
# sometimes land as orphans too. Warn only when an unexpected
# root shows up (e.g. an orphan user/assistant that hints at a
# missing parent); otherwise log at debug level.
unexpected = [n for n in roots if not _is_expected_root_type(n.entry)]
if unexpected:
logger.warning(
"Session %s: %d roots found (%d unexpected), "
"walking all from earliest (%s)",
session_id,
len(roots),
len(unexpected),
roots[0].uuid,
)
else:
logger.debug(
"Session %s: %d expected roots (compact_boundary / "
"local_command), walking all from earliest (%s)",
session_id,
len(roots),
roots[0].uuid,
)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Allow one ordinary primary root before warning on extra roots.

With a normal initial user root plus later compact_boundary roots, unexpected still includes that primary user root and emits the warning this change is trying to suppress. Treat one non-expected root as the session’s primary root, then warn only on additional non-expected roots.

🐛 Proposed root classification fix
-            unexpected = [n for n in roots if not _is_expected_root_type(n.entry)]
+            ordinary_roots = [n for n in roots if not _is_expected_root_type(n.entry)]
+            unexpected = ordinary_roots[1:]
             if unexpected:
                 logger.warning(
                     "Session %s: %d roots found (%d unexpected), "
@@
             else:
                 logger.debug(
-                    "Session %s: %d expected roots (compact_boundary / "
-                    "local_command), walking all from earliest (%s)",
+                    "Session %s: %d roots found with only expected extra "
+                    "roots, walking all from earliest (%s)",
                     session_id,
                     len(roots),
                     roots[0].uuid,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@claude_code_log/dag.py` around lines 539 - 563, The current logic treats any
non-expected root as "unexpected" and logs a warning even when there's a single
ordinary primary root plus expected compact_boundary/local_command roots; update
the check around roots/unexpected in the block that uses _is_expected_root_type
so that one non-expected root is allowed as the session primary and only
additional non-expected roots trigger the warning—e.g., compute unexpected as
you do, then treat the first non-expected as the primary (if any) and only call
logger.warning when len(unexpected) > 1 (or when len(unexpected[1:]) > 0),
adjusting the logged counts/messages to reflect how many unexpected extras were
found and keeping the existing debug path when there are no extra non-expected
roots.

Comment thread claude_code_log/html/templates/transcript.html
Comment thread dev-docs/dag.md
Comment on lines +460 to +462
```
📦 Conversation compacted (115k tokens) • 2026-04-14 09:09:28
```
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Add a language identifier to the fenced code block (MD040).

markdownlint flagged this block as missing a language specifier. Since the content is a formatted label example (not code), text is appropriate.

📝 Proposed fix
-```
+```text
 📦 Conversation compacted (115k tokens) • 2026-04-14 09:09:28
</details>

<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **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.

```suggestion

🧰 Tools
🪛 markdownlint-cli2 (0.22.0)

[warning] 460-460: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@dev-docs/dag.md` around lines 460 - 462, The fenced code block containing the
label "📦 Conversation compacted (115k tokens) • 2026-04-14 09:09:28" is missing
a language identifier; update that markdown block to include the `text` language
specifier (i.e., change the opening backticks to ```text) so markdownlint MD040
is satisfied and the content is treated as plain text.

Comment on lines +1378 to +1398
def _make_compact_boundary(
uuid: str,
session_id: str,
timestamp: str,
pre_tokens: int = 100_000,
) -> dict[str, Any]:
"""Synthesize a system/compact_boundary entry (always parent-null)."""
return {
"type": "system",
"subtype": "compact_boundary",
"timestamp": timestamp,
"parentUuid": None,
"isSidechain": False,
"userType": "human",
"cwd": "/tmp",
"sessionId": session_id,
"version": "1.0.0",
"uuid": uuid,
"content": "Conversation compacted",
"compactMetadata": {"trigger": "manual", "preTokens": pre_tokens},
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Add level to compact-boundary fixtures.

SystemMessage creation requires a system level, so this fixture can leave the compact-boundary parent out of uuid_to_msg and break the "100k tokens" label path.

🧪 Proposed fixture fix
         "uuid": uuid,
+        "level": "info",
         "content": "Conversation compacted",
         "compactMetadata": {"trigger": "manual", "preTokens": pre_tokens},
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/test_dag_integration.py` around lines 1378 - 1398, The compact-boundary
fixture returned by _make_compact_boundary is missing the system message "level"
field required by SystemMessage construction, which breaks uuid_to_msg and the
"100k tokens" label path; update the dict returned by _make_compact_boundary to
include a suitable "level" value (e.g., "system" or the expected enum/string
used elsewhere) alongside the existing keys like "type", "subtype", "uuid", and
"compactMetadata" so the fixture produces a valid SystemMessage.

Comment on lines +130 to +134
# -- Unit tests for _filter_compact ------------------------------------------


class TestFilterMinimal:
"""Test the _filter_compact function directly on parsed TranscriptEntry lists."""
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Stale _filter_compact references — tested symbol is _filter_by_detail.

The section header and class docstring both reference _filter_compact, but the tests call _filter_by_detail (imported at Line 32). This mismatches the tested symbol and makes the test intent harder to follow.

📝 Proposed fix
-# -- Unit tests for _filter_compact ------------------------------------------
+# -- Unit tests for _filter_by_detail ----------------------------------------


 class TestFilterMinimal:
-    """Test the _filter_compact function directly on parsed TranscriptEntry lists."""
+    """Test the _filter_by_detail function directly on parsed TranscriptEntry lists."""
📝 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
# -- Unit tests for _filter_compact ------------------------------------------
class TestFilterMinimal:
"""Test the _filter_compact function directly on parsed TranscriptEntry lists."""
# -- Unit tests for _filter_by_detail ----------------------------------------
class TestFilterMinimal:
"""Test the _filter_by_detail function directly on parsed TranscriptEntry lists."""
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/test_detail_levels.py` around lines 130 - 134, The test file's section
header and class docstring incorrectly reference _filter_compact while the tests
actually import and exercise _filter_by_detail; update the section comment text
and the TestFilterMinimal class docstring to reference _filter_by_detail (or
otherwise clarify that the tests target _filter_by_detail) so the documented
intent matches the imported symbol _filter_by_detail used in the tests.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 5

♻️ Duplicate comments (5)
claude_code_log/html/templates/transcript.html (1)

862-867: ⚠️ Potential issue | 🟠 Major

Fallback to the single-level fold control when unfolding ancestors.

Line 864 only looks for .fold-all-levels, but one-level fold bars render only .fold-one-level.full-width, so anchors under those folded ancestors can remain hidden.

🐛 Proposed fix
-                    const foldAll = foldBar.querySelector('.fold-all-levels');
-                    if (foldAll && foldAll.classList.contains('folded')) {
-                        foldAll.click();
+                    const foldAll = foldBar.querySelector('.fold-all-levels');
+                    const unfoldButton = foldAll || foldBar.querySelector('.fold-one-level.full-width');
+                    if (unfoldButton && unfoldButton.classList.contains('folded')) {
+                        unfoldButton.click();
                     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@claude_code_log/html/templates/transcript.html` around lines 862 - 867, When
expanding ancestor messages, the code only checks for a `.fold-all-levels`
control and misses single-level fold bars (`.fold-one-level.full-width`),
leaving anchors hidden; update the ancestor un-folding logic in the block that
queries `foldBar` and `foldAll` so it first looks for `.fold-all-levels` and if
not found falls back to selecting `.fold-one-level.full-width` (e.g., a
`foldOne` variable) and triggers a click on whichever control exists and is in a
folded state, using the existing `ancestorMsg.querySelector` approach and
reusing the `foldBar` element/logic.
claude_code_log/dag.py (1)

539-563: ⚠️ Potential issue | 🟡 Minor

Allow one ordinary primary root before warning on expected extra roots.

Line 546 still counts the normal initial user/assistant root as unexpected, so a common session shape like user root + compact_boundary root will warn even though only the extra compact root is expected noise.

🐛 Proposed root-classification fix
-            unexpected = [n for n in roots if not _is_expected_root_type(n.entry)]
+            ordinary_roots = [n for n in roots if not _is_expected_root_type(n.entry)]
+            # Allow one ordinary root as the session's primary chain; warn only
+            # when additional ordinary roots indicate disconnected conversation.
+            unexpected = ordinary_roots[1:]
             if unexpected:
                 logger.warning(
                     "Session %s: %d roots found (%d unexpected), "
@@
             else:
                 logger.debug(
-                    "Session %s: %d expected roots (compact_boundary / "
-                    "local_command), walking all from earliest (%s)",
+                    "Session %s: %d roots found with only expected extra "
+                    "roots, walking all from earliest (%s)",
                     session_id,
                     len(roots),
                     roots[0].uuid,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@claude_code_log/dag.py` around lines 539 - 563, The current unexpected-root
check treats the normal initial user/assistant root as unexpected; change the
logic in the roots-handling block so one ordinary primary root is allowed before
flagging others: compute unexpected by skipping the first root when present
(e.g. unexpected = [n for n in roots[1:] if not
_is_expected_root_type(n.entry)]) or more explicitly allow roots[0] even if
_is_expected_root_type(root[0].entry) is false, then use that unexpected list in
the existing logger.warning/debug branches (symbols to update: roots,
unexpected, _is_expected_root_type, session_id, logger).
test/test_detail_levels.py (1)

130-134: ⚠️ Potential issue | 🟡 Minor

Rename the stale _filter_compact references.

These tests exercise _filter_by_detail, so the section header and class docstring should match the actual symbol under test.

📝 Proposed fix
-# -- Unit tests for _filter_compact ------------------------------------------
+# -- Unit tests for _filter_by_detail ----------------------------------------


 class TestFilterMinimal:
-    """Test the _filter_compact function directly on parsed TranscriptEntry lists."""
+    """Test the _filter_by_detail function directly on parsed TranscriptEntry lists."""
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/test_detail_levels.py` around lines 130 - 134, The test section and
class docstring incorrectly refer to _filter_compact; update the section comment
header and the TestFilterMinimal class docstring to reference the actual symbol
under test, _filter_by_detail (replace any occurrences of "_filter_compact" with
"_filter_by_detail" in this block), and ensure the class name TestFilterMinimal
still maps to tests exercising _filter_by_detail.
claude_code_log/converter.py (1)

971-977: ⚠️ Potential issue | 🟠 Major

Include detail/compact in cache identity before reusing cached artifacts.

Render-mode invalidation is only partial: directory early exit, paginated page reuse, per-session reuse, and hierarchy needs_work can still skip generation for non-default output. Also, non-default renders still update the same HTML/page cache keys, so a later default render can be treated as current while the file contains filtered output.

Until cache metadata stores detail/compact, avoid cache reuse and cache updates for non-default render modes.

🛠️ Minimum mitigation direction
+    non_default_render = detail != DetailLevel.FULL or (
+        format in ("md", "markdown") and compact
+    )
+
         if (
             cache_manager is not None
             and not cache_was_updated
             and from_date is None
             and to_date is None
+            and not non_default_render
         ):
@@
-            if format == "html" and cache_manager is not None:
+            if (
+                format == "html"
+                and cache_manager is not None
+                and detail == DetailLevel.FULL
+            ):
                 cache_manager.update_html_cache(
                     output_path.name, None, total_message_count
                 )

For _generate_paginated_html() and _generate_individual_session_files(), apply the same rule: do not skip or update cached page/session entries when detail != DetailLevel.FULL until those cache tables compare render-mode metadata.

Also applies to: 1071-1084, 1192-1210, 1303-1337, 1776-1829, 2128-2134

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@claude_code_log/converter.py` around lines 971 - 977, Cached-page reuse and
cache updates are incorrectly applied for non-default render modes; change the
early-return/skip logic that relies on cache_manager.is_page_stale (and
analogous session/hierarchy checks) to also consider render mode by refusing to
reuse or update cached artifacts when detail != DetailLevel.FULL (i.e., when
using compact/detail modes) in _generate_paginated_html() and
_generate_individual_session_files(); until cache metadata is extended to store
render-mode, ensure any branch that currently skips generation or updates the
same HTML/page cache keys also bypasses reuse/update for non-default modes so
cached keys aren’t incorrectly reused for different render outputs.
dev-docs/dag.md (1)

460-462: ⚠️ Potential issue | 🟡 Minor

Add a language identifier to this fenced block.

This is plain text, so text keeps markdownlint MD040 satisfied.

📝 Proposed fix
-```
+```text
 📦 Conversation compacted (115k tokens) • 2026-04-14 09:09:28

</details>

<details>
<summary>🤖 Prompt for AI Agents</summary>

Verify each finding against the current code and only fix it if needed.

In @dev-docs/dag.md around lines 460 - 462, The fenced code block containing "📦
Conversation compacted (115k tokens) • 2026-04-14 09:09:28" lacks a language
identifier; update that fenced block to include the text language identifier
(i.e., change the opening backticks to ```text) so markdownlint MD040 is
satisfied and the block is explicitly marked as plain text.


</details>

</blockquote></details>

</blockquote></details>

<details>
<summary>🤖 Prompt for all review comments with AI agents</summary>

Verify each finding against the current code and only fix it if needed.

Inline comments:
In @claude_code_log/cli.py:

  • Around line 516-532: The --detail and --compact CLI options are never
    forwarded when exporting a single session because generate_single_session_file()
    doesn't accept them and the CLI returns before calling convert_jsonl_to; update
    generate_single_session_file() in converter.py to add detail and compact
    parameters, pass those through to get_renderer() where it's invoked (around the
    get_renderer(...) call), and then update the CLI call site that invokes
    generate_single_session_file() (the --session-id branch in cli.py) to call it
    with detail=detail_level and compact=compact so the renderer receives and
    applies those options.

In @claude_code_log/factories/system_factory.py:

  • Around line 93-99: The code accepts boolean values for preTokens because
    isinstance(raw_pre, int) returns True for booleans; update the check in the
    block handling transcript.compactMetadata (inspect variables raw_pre and
    pre_tokens in system_factory.py) to use a strict type comparison (type(raw_pre)
    is int) before assigning pre_tokens, so True/False are rejected, leaving the
    raw_trigger handling unchanged for strings.

In @claude_code_log/markdown/renderer.py:

  • Around line 841-856: The compact-heading suppression currently compares
    msg_type but should compare the actual rendered heading category; update the
    logic around msg_type/title/suppress_heading so suppression and the stored
    tracker use a normalized rendered-heading key (e.g., derive heading_key =
    title.lower().strip() or another stable normalization of title) instead of
    msg_type, then set self._last_message_type = heading_key and compare msg_type ==
    self._last_message_type to use heading_key in the suppress_heading condition
    (reference symbols: msg_type, title, suppress_heading, self._last_message_type,
    compact). Ensure session-header reset still clears the tracker the same way.

In @claude_code_log/renderer.py:

  • Around line 693-697: The current detail-level filtering replaces ctx.messages
    with a filtered list via _filter_template_by_detail but leaves
    RenderingContext.message_index values unchanged, causing RenderingContext.get()
    and nav/parent indices to point to wrong messages; after filtering (or instead
    of assigning directly) rebuild the RenderingContext indices by remapping
    message_index values to the new positions (or re-register messages) so every
    stored index references the correct message. Locate the block that calls
    _filter_template_by_detail and update it to either perform the filter before
    messages are registered in RenderingContext or, immediately after ctx.messages
    is replaced, iterate the context's registration (or message_index map) and
    recompute indices based on the filtered ctx.messages so RenderingContext.get()
    returns consistent results.

In @test/__snapshots__/test_snapshot_html.ambr:

  • Around line 6454-6471: unfoldAncestorsOf currently only opens the ancestor's
    .fold-all-levels control so messages still stay hidden if an ancestor is
    collapsed via the .fold-one-level control; update unfoldAncestorsOf to also
    find the .fold-one-level element on each ancestor's foldBar (the same place
    you look up .fold-all-levels) and, if it exists and has the folded class,
    call its click() as well so both fold controls are unfolded when navigating to
    an anchor (references: function unfoldAncestorsOf, variables ancestorMsg and
    foldBar, selectors '.fold-all-levels' and '.fold-one-level').

Duplicate comments:
In @claude_code_log/converter.py:

  • Around line 971-977: Cached-page reuse and cache updates are incorrectly
    applied for non-default render modes; change the early-return/skip logic that
    relies on cache_manager.is_page_stale (and analogous session/hierarchy checks)
    to also consider render mode by refusing to reuse or update cached artifacts
    when detail != DetailLevel.FULL (i.e., when using compact/detail modes) in
    _generate_paginated_html() and _generate_individual_session_files(); until cache
    metadata is extended to store render-mode, ensure any branch that currently
    skips generation or updates the same HTML/page cache keys also bypasses
    reuse/update for non-default modes so cached keys aren’t incorrectly reused for
    different render outputs.

In @claude_code_log/dag.py:

  • Around line 539-563: The current unexpected-root check treats the normal
    initial user/assistant root as unexpected; change the logic in the
    roots-handling block so one ordinary primary root is allowed before flagging
    others: compute unexpected by skipping the first root when present (e.g.
    unexpected = [n for n in roots[1:] if not _is_expected_root_type(n.entry)]) or
    more explicitly allow roots[0] even if _is_expected_root_type(root[0].entry) is
    false, then use that unexpected list in the existing logger.warning/debug
    branches (symbols to update: roots, unexpected, _is_expected_root_type,
    session_id, logger).

In @claude_code_log/html/templates/transcript.html:

  • Around line 862-867: When expanding ancestor messages, the code only checks
    for a .fold-all-levels control and misses single-level fold bars
    (.fold-one-level.full-width), leaving anchors hidden; update the ancestor
    un-folding logic in the block that queries foldBar and foldAll so it first
    looks for .fold-all-levels and if not found falls back to selecting
    .fold-one-level.full-width (e.g., a foldOne variable) and triggers a click
    on whichever control exists and is in a folded state, using the existing
    ancestorMsg.querySelector approach and reusing the foldBar element/logic.

In @dev-docs/dag.md:

  • Around line 460-462: The fenced code block containing "📦 Conversation
    compacted (115k tokens) • 2026-04-14 09:09:28" lacks a language identifier;
    update that fenced block to include the text language identifier (i.e., change
    the opening backticks to ```text) so markdownlint MD040 is satisfied and the
    block is explicitly marked as plain text.

In @test/test_detail_levels.py:

  • Around line 130-134: The test section and class docstring incorrectly refer to
    _filter_compact; update the section comment header and the TestFilterMinimal
    class docstring to reference the actual symbol under test, _filter_by_detail
    (replace any occurrences of "_filter_compact" with "_filter_by_detail" in this
    block), and ensure the class name TestFilterMinimal still maps to tests
    exercising _filter_by_detail.

</details>

<details>
<summary>🪄 Autofix (Beta)</summary>

Fix all unresolved CodeRabbit comments on this PR:

- [ ] <!-- {"checkboxId": "4b0d0e0a-96d7-4f10-b296-3a18ea78f0b9"} --> Push a commit to this branch (recommended)
- [ ] <!-- {"checkboxId": "ff5b1114-7d8c-49e6-8ac1-43f82af23a33"} --> Create a new PR with the fixes

</details>

---

<details>
<summary>ℹ️ Review info</summary>

<details>
<summary>⚙️ Run configuration</summary>

**Configuration used**: defaults

**Review profile**: CHILL

**Plan**: Pro

**Run ID**: `a4ea6165-9eaf-40a2-b486-779e762dfe92`

</details>

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 269b984e4b1daf5ca64c0992d1f9b07d10973e33 and 176a6ed35ba252dbf834a07cb44861adad112e29.

</details>

<details>
<summary>📒 Files selected for processing (16)</summary>

* `claude_code_log/cli.py`
* `claude_code_log/converter.py`
* `claude_code_log/dag.py`
* `claude_code_log/factories/system_factory.py`
* `claude_code_log/html/renderer.py`
* `claude_code_log/html/templates/components/session_nav.html`
* `claude_code_log/html/templates/components/session_nav_styles.css`
* `claude_code_log/html/templates/transcript.html`
* `claude_code_log/markdown/renderer.py`
* `claude_code_log/models.py`
* `claude_code_log/renderer.py`
* `dev-docs/dag.md`
* `test/__snapshots__/test_snapshot_html.ambr`
* `test/test_dag.py`
* `test/test_dag_integration.py`
* `test/test_detail_levels.py`

</details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->

Comment thread claude_code_log/cli.py
Comment on lines +516 to +532
@click.option(
"--detail",
type=click.Choice(["full", "high", "low", "minimal"], case_sensitive=False),
default="full",
help=(
"Detail level for output. "
"full: everything; "
"high: detailed but cleaned (no system/hook noise); "
"low: interaction-focused + key signals; "
"minimal: user + assistant messages only."
),
)
@click.option(
"--compact",
is_flag=True,
help="Merge consecutive same-type headings in Markdown output.",
)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Inspect generate_single_session_file signature and all call sites.
# Expectation: either generate_single_session_file accepts detail/compact, or it needs to be extended before cli.py passes them.

rg -nP -C5 '\bdef\s+generate_single_session_file\s*\('
rg -nP -C5 '\bgenerate_single_session_file\s*\('

Repository: daaain/claude-code-log

Length of output: 10029


🏁 Script executed:

# Get the full signature of generate_single_session_file
sed -n '1838,1850p' claude_code_log/converter.py

Repository: daaain/claude-code-log

Length of output: 478


🏁 Script executed:

# Check the --session-id code path around line 732
sed -n '700,750p' claude_code_log/cli.py | cat -n

Repository: daaain/claude-code-log

Length of output: 2552


🏁 Script executed:

# Search for convert_jsonl_to calls within generate_single_session_file
sed -n '1838,2000p' claude_code_log/converter.py | rg -C3 'convert_jsonl_to'

Repository: daaain/claude-code-log

Length of output: 48


🏁 Script executed:

# Get the signature of convert_jsonl_to
rg -nP -C2 'def\s+convert_jsonl_to\s*\(' claude_code_log/converter.py | head -30

Repository: daaain/claude-code-log

Length of output: 153


🏁 Script executed:

# Get the full signature of convert_jsonl_to
sed -n '1116,1130p' claude_code_log/converter.py

Repository: daaain/claude-code-log

Length of output: 553


🏁 Script executed:

# Get the full body of generate_single_session_file
sed -n '1838,1920p' claude_code_log/converter.py

Repository: daaain/claude-code-log

Length of output: 3031


🏁 Script executed:

# Continue reading generate_single_session_file to see what it calls
sed -n '1920,2000p' claude_code_log/converter.py

Repository: daaain/claude-code-log

Length of output: 2795


🏁 Script executed:

# Search for where the actual conversion happens in generate_single_session_file
rg -n 'def generate_single_session_file' -A 150 claude_code_log/converter.py | tail -100

Repository: daaain/claude-code-log

Length of output: 3913


🏁 Script executed:

# Check if renderer.generate_session has any reference to detail/compact
rg -n 'def generate_session' claude_code_log/converter.py

Repository: daaain/claude-code-log

Length of output: 48


🏁 Script executed:

# Check the CLI to understand the detail and compact options better
sed -n '516,550p' claude_code_log/cli.py | cat -n

Repository: daaain/claude-code-log

Length of output: 1216


🏁 Script executed:

# See the full main function signature including detail and compact parameters
sed -n '516,580p' claude_code_log/cli.py | cat -n

Repository: daaain/claude-code-log

Length of output: 2492


🏁 Script executed:

# Search for where detail_level is used in cli.py
rg -n 'detail_level\|detail:' claude_code_log/cli.py | head -20

Repository: daaain/claude-code-log

Length of output: 48


🏁 Script executed:

# Search for convert_jsonl_to calls in cli.py to see if detail_level is passed
rg -n 'convert_jsonl_to' claude_code_log/cli.py -A 10

Repository: daaain/claude-code-log

Length of output: 1352


🏁 Script executed:

# Check line 732 and surrounding context to find the early return for --session-id
sed -n '690,740p' claude_code_log/cli.py | cat -n

Repository: daaain/claude-code-log

Length of output: 2608


🏁 Script executed:

# Find the renderer classes and generate_session method
rg -n 'class.*Renderer' claude_code_log/ -A 5

Repository: daaain/claude-code-log

Length of output: 1297


🏁 Script executed:

# Search for generate_session implementations
rg -n 'def generate_session' claude_code_log/ -B 2 -A 10 | head -50

Repository: daaain/claude-code-log

Length of output: 3350


🏁 Script executed:

# Check how convert_jsonl_to calls the renderer methods
rg -n 'def convert_jsonl_to' -A 100 claude_code_log/converter.py | grep -A 20 'renderer\.' | head -40

Repository: daaain/claude-code-log

Length of output: 48


🏁 Script executed:

# Check the full generate_session signature in MarkdownRenderer to see if it accepts detail/compact
sed -n '920,960p' claude_code_log/markdown/renderer.py

Repository: daaain/claude-code-log

Length of output: 1643


🏁 Script executed:

# Check if generate_session calls generate() and if generate() accepts/uses detail or compact
sed -n '920,945p' claude_code_log/markdown/renderer.py

Repository: daaain/claude-code-log

Length of output: 1027


🏁 Script executed:

# Look at the generate method signature
rg -n 'def generate\(' claude_code_log/markdown/renderer.py -A 8 | head -30

Repository: daaain/claude-code-log

Length of output: 436


🏁 Script executed:

# Search for where detail or compact is actually used in the rendering
rg -n 'detail|compact' claude_code_log/renderer.py | head -30

Repository: daaain/claude-code-log

Length of output: 1999


🏁 Script executed:

# Check how convert_jsonl_to uses detail_level and compact
sed -n '1116,1200p' claude_code_log/converter.py

Repository: daaain/claude-code-log

Length of output: 3701


🏁 Script executed:

# Check if renderer.generate_session actually uses detail/compact by searching for where these would be used
rg -n 'generate_individual_sessions' claude_code_log/converter.py -B 5 -A 15 | head -50

Repository: daaain/claude-code-log

Length of output: 1805


🏁 Script executed:

# Check the full flow in convert_jsonl_to to see how it uses detail/compact with individual sessions
sed -n '1170,1230p' claude_code_log/converter.py

Repository: daaain/claude-code-log

Length of output: 2796


🏁 Script executed:

# Check if detail is used in renderer.generate() method
sed -n '879,950p' claude_code_log/markdown/renderer.py | grep -i 'detail\|compact'

Repository: daaain/claude-code-log

Length of output: 133


🏁 Script executed:

# Check how convert_jsonl_to passes detail to the rendering process
sed -n '1220,1280p' claude_code_log/converter.py

Repository: daaain/claude-code-log

Length of output: 2612


🏁 Script executed:

# Check how get_renderer is called in the session path vs main path
rg -n 'get_renderer' claude_code_log/

Repository: daaain/claude-code-log

Length of output: 757


🏁 Script executed:

# Check how the Renderer is initialized with detail/compact
sed -n '2633,2700p' claude_code_log/renderer.py | head -60

Repository: daaain/claude-code-log

Length of output: 2413


🏁 Script executed:

# Get the full get_renderer signature
sed -n '2923,2960p' claude_code_log/renderer.py

Repository: daaain/claude-code-log

Length of output: 1484


🏁 Script executed:

# Confirm the issue: Line 1951 in converter.py (generate_single_session_file)
sed -n '1948,1958p' claude_code_log/converter.py

Repository: daaain/claude-code-log

Length of output: 478


Wire --detail and --compact into --session-id exports.

The --session-id branch returns at line 733 before reaching the convert_jsonl_to(...) calls that receive these options, so claude-code-log --session-id ... --detail minimal and --compact are silently ignored. The generate_single_session_file() function needs to accept these parameters and pass them to get_renderer().

🔎 Required changes
  1. Extend generate_single_session_file() signature in converter.py to accept detail and compact parameters
  2. Pass these to get_renderer() at line 1951
  3. Update the call site in cli.py (lines 721–729) to pass detail=detail_level and compact=compact
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@claude_code_log/cli.py` around lines 516 - 532, The --detail and --compact
CLI options are never forwarded when exporting a single session because
generate_single_session_file() doesn't accept them and the CLI returns before
calling convert_jsonl_to; update generate_single_session_file() in converter.py
to add detail and compact parameters, pass those through to get_renderer() where
it's invoked (around the get_renderer(...) call), and then update the CLI call
site that invokes generate_single_session_file() (the --session-id branch in
cli.py) to call it with detail=detail_level and compact=compact so the renderer
receives and applies those options.

Comment thread claude_code_log/factories/system_factory.py
Comment thread claude_code_log/markdown/renderer.py Outdated
Comment thread claude_code_log/renderer.py
Comment thread test/__snapshots__/test_snapshot_html.ambr
cboos and others added 6 commits April 18, 2026 15:31
CodeRabbit flagged that `_filter_template_by_detail` returns a shorter
list that was assigned directly to `ctx.messages`, while every surviving
TemplateMessage kept its original `message_index`. `RenderingContext.get()`
indexes into the list by position, so post-filter:

- `ctx.get(idx)` returns the wrong message when idx sits past the new
  length, or refers to a neighbour that happened to slide into that slot.
- `session_first_message[sid]` can point at an index that now resolves
  to a different session's first survivor, breaking nav anchors.
- `pair_first` / `pair_last` hold stale references that make the
  Markdown renderer's `ctx.get(message.pair_first)` dereference wrong
  messages (the pair-identification pass below re-runs, but only after
  nav has already been built).

Add `_reindex_filtered_context` to rewrite `ctx.messages` to the filtered
list, remap every surviving message's `message_index` (and mirror it on
`content.message_index`) to its new position, drop stale
`session_first_message` entries whose target was filtered out, and clear
the pair pointers so `_identify_message_pairs` can rebuild them cleanly.
Ancestry is already rebuilt from scratch by `_build_message_hierarchy`
after this point, so it doesn't need explicit remapping.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`generate_single_session_file` called `get_renderer(format, image_export_mode)`
with defaults, so `--session-id --detail low` or `--compact` silently produced
a full-detail session file. CodeRabbit flagged this as a parameter threading
gap on the single-session code path.

Accept `detail` and `compact` on `generate_single_session_file` (defaulting
to FULL / False to preserve the public signature) and pass both through to
`get_renderer`. Update the CLI's `--session-id` branch to forward the CLI's
already-parsed `detail_level` and `compact` flags.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Python's `bool` is a subclass of `int`, so `isinstance(True, int)` is
True — meaning a JSON `true` or `false` in `compactMetadata.preTokens`
would be copied onto `SystemMessage.compact_pre_tokens` as 1 or 0 and
surface in the nav label as "1 tokens". CodeRabbit noted the pattern.

Narrow the guard to `isinstance(raw_pre, int) and not isinstance(raw_pre, bool)`
so only genuine integer token counts survive.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Markdown compact mode suppressed headings for consecutive same-type
messages using `msg.content.message_type`, but that raw type doesn't
always match the rendered title category. A paired `ThinkingMessage`
(is_first_in_pair, paired with an AssistantTextMessage) renders an
"🤖 Assistant: ..." heading via `title_ThinkingMessage`, yet the
tracking key stored "thinking". A following standalone AssistantText
then failed to compact even though the visible headings were both
Assistant.

Track the rendered category derived from the title itself — everything
before the first ":" (e.g. "🤖 Assistant", "🤷 User", "💭 Thinking").
Rename the tracking field from `_last_message_type` to
`_last_heading_category` so the intent is clear.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A parent message with `immediate_children_count == total_descendants_count`
only renders a single full-width `.fold-one-level` button (per
transcript.html lines 106-111) — `.fold-all-levels` isn't emitted at
all. The anchor-unfold JS queried `.fold-all-levels` unconditionally, so
anchors sitting under one of those single-level ancestors stayed folded
even when we'd navigated directly to them. CodeRabbit caught this on
the merged alice-fix-nav-anchors flow.

Fall back to `.fold-one-level.full-width` when `.fold-all-levels` is
absent on a given fold-bar. Both controls cover the same
descendant-hiding behaviour at their respective levels, so the same
.folded guard + .click() pattern applies uniformly.

Snapshots regenerate mechanically (pure JS comment + logic tweak, no
structural change to output).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pyright flagged the `isinstance(detail, str) and not isinstance(detail,
DetailLevel)` guard at renderer.py:595 as unnecessary because DetailLevel
inherits from str. The guard was actually load-bearing for accepting raw
strings from the CLI, so the fix is to reflect that in the annotation
rather than removing the check.

- Widen `detail` param to `DetailLevel | str` in generate_template_messages
- Simplify normalization to `if not isinstance(detail, DetailLevel)`
- Docstring clarified to mention both accepted forms

Resolves 2 pre-existing pyright warnings monk flagged on the
alice-coderabbit-fixes review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
claude_code_log/converter.py (2)

1781-1787: ⚠️ Potential issue | 🟠 Major

Per-session regeneration check does not consider detail/compact.

_generate_individual_session_files now accepts detail/compact and passes them to get_renderer, but should_regenerate_session here only checks cache staleness, version, date filters, and file existence. If a user previously generated sessions with --detail full and now requests --detail low (or vice-versa), existing session files will be served as-is.

Consider adding or detail != DetailLevel.FULL or compact to the HTML branch (mirroring Lines 1309-1310) as an interim mitigation, and ideally factoring detail/compact into the html_cache staleness key for correctness in both directions.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@claude_code_log/converter.py` around lines 1781 - 1787, The per-session
regeneration check in _generate_individual_session_files fails to account for
detail/compact, so update the should_regenerate_session logic (the variable
computed where renderer.is_outdated(session_file_path) is checked) to also
consider the current rendering mode — e.g., add a condition that compares
detail/compact with the previous/full state (mirroring the earlier check: or
detail != DetailLevel.FULL or compact) so files generated under a different
detail level are regenerated; also, for a robust fix, include the detail/compact
values in the html_cache staleness key used by get_renderer/html_cache so
different detail levels produce distinct cache entries and stale detection works
correctly.

1059-1068: ⚠️ Potential issue | 🟠 Major

Paginated generation bypasses is_page_stale invalidation for non-default detail/compact.

Inside _generate_paginated_html, is_page_stale (Line 972) is the sole gate for regeneration and does not account for detail/compact. Pages previously rendered with --detail full will be reused when the user requests --detail low (or vice-versa), returning the wrong content. The single-file path was hardened at Lines 1309–1310 and 1320–1321 but the paginated path has no equivalent guard.

At minimum, short-circuit is_stale when detail != DetailLevel.FULL or compact so pages always regenerate under non-default flags; longer-term, include these flags in the page_cache staleness identity.

🛠️ Interim mitigation
-        is_stale, reason = cache_manager.is_page_stale(page_num, page_size)
-
-        if not is_stale and page_file.exists():
+        is_stale, reason = cache_manager.is_page_stale(page_num, page_size)
+        force_regen = detail != DetailLevel.FULL or compact
+
+        if not is_stale and not force_regen and page_file.exists():
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@claude_code_log/converter.py` around lines 1059 - 1068, The paginated path in
_generate_paginated_html reuses cached pages via is_page_stale but doesn't
consider the detail or compact flags, causing mismatched renders; update
_generate_paginated_html to force regeneration when detail != DetailLevel.FULL
or compact is True (i.e., short-circuit/override is_page_stale to treat page as
stale) before calling HtmlRenderer.generate, and as a longer-term fix include
detail/compact in the page cache/staleness identity so page_cache keys consider
those flags.
♻️ Duplicate comments (1)
claude_code_log/converter.py (1)

1192-1210: ⚠️ Potential issue | 🟠 Major

Early-exit ignores detail/compact — can serve stale artifact when flags change.

The Phase 1b early-exit at Lines 1192–1210 returns output_path based only on cache freshness and is_html_outdated, without considering detail or compact. If a user first runs with --detail low (which forces regeneration via Lines 1309-1310/1320-1321) and then re-runs with the default --detail full, is_html_stale will report not-stale and the filtered LOW artifact will be returned for a FULL request.

The should_regenerate guard added for the single-file path (Lines 1303–1311, 1314–1322) only forces regeneration when detail != FULL or compact; it cannot detect the inverse direction (switching back to FULL after a non-FULL run). A complete fix requires including detail/compact in the cache/staleness identity (e.g., stored in html_cache metadata, page cache, and session html_cache), and also checking them in this early-exit and in the paginated path (is_page_stale) and individual-session path (Lines 1781–1787).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@claude_code_log/converter.py` around lines 1192 - 1210, The early-exit in
converter.py (the block using cache_manager, is_html_stale(output_path.name,
None), is_html_outdated, get_stale_sessions, generate_individual_sessions and
returning output_path) ignores the detail and compact flags so it can return a
filtered LOW/compact artifact for a FULL request; fix by including detail and
compact in the cache identity and staleness checks: when calling is_html_stale
(and is_page_stale) pass/derive a cache key that incorporates detail and compact
(and update html_cache/page/session cache metadata to store these flags), and
amend the early-exit condition to require regeneration when the cached
artifact's detail/compact differ from the current request (also ensure
session-level cache checks in get_stale_sessions and individual-session logic
account for detail/compact).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@claude_code_log/converter.py`:
- Around line 1781-1787: The per-session regeneration check in
_generate_individual_session_files fails to account for detail/compact, so
update the should_regenerate_session logic (the variable computed where
renderer.is_outdated(session_file_path) is checked) to also consider the current
rendering mode — e.g., add a condition that compares detail/compact with the
previous/full state (mirroring the earlier check: or detail != DetailLevel.FULL
or compact) so files generated under a different detail level are regenerated;
also, for a robust fix, include the detail/compact values in the html_cache
staleness key used by get_renderer/html_cache so different detail levels produce
distinct cache entries and stale detection works correctly.
- Around line 1059-1068: The paginated path in _generate_paginated_html reuses
cached pages via is_page_stale but doesn't consider the detail or compact flags,
causing mismatched renders; update _generate_paginated_html to force
regeneration when detail != DetailLevel.FULL or compact is True (i.e.,
short-circuit/override is_page_stale to treat page as stale) before calling
HtmlRenderer.generate, and as a longer-term fix include detail/compact in the
page cache/staleness identity so page_cache keys consider those flags.

---

Duplicate comments:
In `@claude_code_log/converter.py`:
- Around line 1192-1210: The early-exit in converter.py (the block using
cache_manager, is_html_stale(output_path.name, None), is_html_outdated,
get_stale_sessions, generate_individual_sessions and returning output_path)
ignores the detail and compact flags so it can return a filtered LOW/compact
artifact for a FULL request; fix by including detail and compact in the cache
identity and staleness checks: when calling is_html_stale (and is_page_stale)
pass/derive a cache key that incorporates detail and compact (and update
html_cache/page/session cache metadata to store these flags), and amend the
early-exit condition to require regeneration when the cached artifact's
detail/compact differ from the current request (also ensure session-level cache
checks in get_stale_sessions and individual-session logic account for
detail/compact).

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f1b69a7b-2fe6-4e31-a651-25d00ac69a01

📥 Commits

Reviewing files that changed from the base of the PR and between 176a6ed and 356b708.

📒 Files selected for processing (7)
  • claude_code_log/cli.py
  • claude_code_log/converter.py
  • claude_code_log/factories/system_factory.py
  • claude_code_log/html/templates/transcript.html
  • claude_code_log/markdown/renderer.py
  • claude_code_log/renderer.py
  • test/__snapshots__/test_snapshot_html.ambr
🚧 Files skipped from review as they are similar to previous changes (3)
  • claude_code_log/cli.py
  • claude_code_log/factories/system_factory.py
  • claude_code_log/renderer.py

@cboos
Copy link
Copy Markdown
Collaborator Author

cboos commented Apr 18, 2026

Per-session regeneration check does not consider detail/compact.
Paginated generation bypasses is_page_stale invalidation for non-default detail/compact.

OK, noted. Will be done in a follow-up PR.

@cboos cboos merged commit 53d892b into main Apr 18, 2026
12 checks passed
cboos added a commit that referenced this pull request Apr 19, 2026
The detail-level filter and compact-Markdown mode landed in #96 and
#114 but weren't surfaced in the README. Add:

- Key Features bullet calling out the pairing with --format md for
  feeding past conversations to an LLM
- New Usage subsection "Feeding Past Conversations to an LLM" with
  the --detail low --format md --compact combo and a short level
  reference
- Problem-statement entry for LLM-based analysis/experience building
- Markdown Output Features bullet for --compact

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
cboos added a commit that referenced this pull request Apr 19, 2026
…115)

* Preserve agentId anchors in parallel-Task stitch

Parallel `Task` tool_uses emit a chain of assistant tool_use entries
whose sibling tool_result anchors each reference a different subagent
via agentId. Variant 2 of `_stitch_tool_results` classified the
intermediate assistant subtrees as "dead-end" and let the walker mark
their descendants — including agentId-bearing tool_results — as
skipped, so only the outermost subagent session had an attachment
point the traversal could find.

Fix: add `_collect_agent_anchors` to walk an assistant subtree for
UserTranscriptEntry descendants with agentId, and surface those anchors
into the stitch result alongside the assistant dead-ends. The anchors
still end up in `skipped` via `_collect_descendants` (since they're
descendants of the assistant), but now they also live in the DAG-line's
chain — which is what `build_session_tree` reads when choosing
attachment points for subagent sessions.

Regression tests cover the 2-teammate case, 3-teammate case (the
experiments/worktrees shape), and an end-to-end splice verifying a
subagent entry is actually reached through the anchor.

Fixes issue #91 symptom: only 2 of 6 subagents rendering in a
teammates session.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Restore collapse affordance on expanded tool-param details

The generic tool renderer (render_params_table) wraps long parameter
values in <details class='tool-param-collapsible'>. The CSS hid the
entire <summary> on open, which also removed the browser's default
disclosure arrow — leaving no visible way to collapse the expanded
value.

Wrap the preview text in <span class='tool-param-preview'> so the
[open] rule can hide just the preview, not the whole summary. The
summary (with its list-item marker / arrow) stays visible, and a
"collapse" hint via ::after makes the click target obvious.

Covers both call sites in tool_formatters.py: structured (JSON) and
simple (string) long values.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Advertise --detail / --compact in README

The detail-level filter and compact-Markdown mode landed in #96 and
#114 but weren't surfaced in the README. Add:

- Key Features bullet calling out the pairing with --format md for
  feeding past conversations to an LLM
- New Usage subsection "Feeding Past Conversations to an LLM" with
  the --detail low --format md --compact combo and a short level
  reference
- Problem-statement entry for LLM-based analysis/experience building
- Markdown Output Features bullet for --compact

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@cboos cboos mentioned this pull request Apr 19, 2026
@coderabbitai coderabbitai Bot mentioned this pull request Apr 26, 2026
6 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants