Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions claude_code_log/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -705,6 +705,17 @@ def _validate_git_link_template(template: str) -> None:
"error) if combined with --format html / --format json."
),
)
@click.option(
"--no-recaps",
is_flag=True,
help=(
"Suppress '※ recap' (away-summary) messages. Recaps are otherwise "
"shown at every detail level — they are themselves a high-level "
"summary of activity (#179). Use this to get a 'really user-only' "
"view (--detail user-only --no-recaps) or to drop the recap/agent "
"redundancy at --detail minimal."
),
)
@click.option(
"--debug",
is_flag=True,
Expand Down Expand Up @@ -735,6 +746,7 @@ def main(
compact: bool,
git_link: Optional[str],
no_timestamps: bool,
no_recaps: bool,
debug: bool,
) -> None:
"""Convert Claude transcript JSONL files to HTML or Markdown.
Expand Down Expand Up @@ -1004,6 +1016,7 @@ def main(
detail=detail_level,
compact=compact,
no_timestamps=no_timestamps,
no_recaps=no_recaps,
)
click.echo(f"Successfully exported session to {output_path}")
if open_browser:
Expand Down Expand Up @@ -1064,6 +1077,7 @@ def main(
filter_path=filter_path,
write_combined=write_combined,
no_timestamps=no_timestamps,
no_recaps=no_recaps,
)

# Count processed projects
Expand Down Expand Up @@ -1123,6 +1137,7 @@ def main(
update_cache=output is None,
write_combined=write_combined,
no_timestamps=no_timestamps,
no_recaps=no_recaps,
)
if input_path.is_file():
click.echo(f"Successfully converted {input_path} to {output_path}")
Expand Down
22 changes: 17 additions & 5 deletions claude_code_log/converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -1286,6 +1286,7 @@ def _generate_paginated_html(
session_tree: Optional[SessionTree] = None,
detail: DetailLevel = DetailLevel.FULL,
compact: bool = False,
no_recaps: bool = False,
) -> Path:
"""Generate paginated HTML files for combined transcript.

Expand All @@ -1305,7 +1306,7 @@ def _generate_paginated_html(
from .html.renderer import HtmlRenderer
from .utils import format_timestamp, variant_suffix as _variant_suffix

suffix = _variant_suffix(detail, compact, "html")
suffix = _variant_suffix(detail, compact, "html", no_recaps=no_recaps)

# Check if page size changed - if so, invalidate all pages
cached_page_size = cache_manager.get_page_size_config()
Expand Down Expand Up @@ -1449,6 +1450,7 @@ def _generate_paginated_html(
page_renderer = HtmlRenderer()
page_renderer.detail = detail
page_renderer.compact = compact
page_renderer.no_recaps = no_recaps
html_content = page_renderer.generate(
page_messages,
page_title,
Expand Down Expand Up @@ -1525,6 +1527,7 @@ def convert_jsonl_to(
output_root: Optional[Path] = None,
write_combined: bool = True,
no_timestamps: bool = False,
no_recaps: bool = False,
) -> Path:
"""Convert JSONL transcript(s) to the specified format.

Expand Down Expand Up @@ -1565,7 +1568,7 @@ def convert_jsonl_to(

from .utils import variant_suffix as _variant_suffix

suffix = _variant_suffix(detail, compact, format, no_timestamps)
suffix = _variant_suffix(detail, compact, format, no_timestamps, no_recaps)

# Output destination decoupled from `input_path` (#151). Both
# branches below assign to `effective_output_dir`; declare it
Expand Down Expand Up @@ -1659,6 +1662,7 @@ def convert_jsonl_to(
detail=detail,
compact=compact,
no_timestamps=no_timestamps,
no_recaps=no_recaps,
)

# Decide whether to use pagination (HTML only, directory mode, no date filter)
Expand Down Expand Up @@ -1720,6 +1724,7 @@ def convert_jsonl_to(
session_tree=session_tree,
detail=detail,
compact=compact,
no_recaps=no_recaps,
)
else:
# Use single-file generation for small projects or filtered views
Expand Down Expand Up @@ -1784,6 +1789,7 @@ def convert_jsonl_to(
compact=compact,
write_combined=write_combined,
no_timestamps=no_timestamps,
no_recaps=no_recaps,
)

return output_path
Expand Down Expand Up @@ -1966,6 +1972,7 @@ def _generate_individual_session_files(
compact: bool = False,
write_combined: bool = True,
no_timestamps: bool = False,
no_recaps: bool = False,
) -> int:
"""Generate individual files for each session in the specified format.

Expand All @@ -1975,7 +1982,7 @@ def _generate_individual_session_files(
from .utils import variant_suffix as _variant_suffix

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

Expand Down Expand Up @@ -2014,6 +2021,7 @@ def _generate_individual_session_files(
detail=detail,
compact=compact,
no_timestamps=no_timestamps,
no_recaps=no_recaps,
)
regenerated_count = 0

Expand Down Expand Up @@ -2118,6 +2126,7 @@ def generate_single_session_file(
detail: DetailLevel = DetailLevel.FULL,
compact: bool = False,
no_timestamps: bool = False,
no_recaps: bool = False,
) -> Path:
"""Generate a single session output file for the given session ID.

Expand Down Expand Up @@ -2220,7 +2229,7 @@ def generate_single_session_file(
from .utils import variant_suffix as _variant_suffix

ext = get_file_extension(format)
suffix = _variant_suffix(detail, compact, format, no_timestamps)
suffix = _variant_suffix(detail, compact, format, no_timestamps, no_recaps)
output_dir = input_path
if output is not None:
# User's explicit path wins; no suffix appended.
Expand All @@ -2236,6 +2245,7 @@ def generate_single_session_file(
detail=detail,
compact=compact,
no_timestamps=no_timestamps,
no_recaps=no_recaps,
)
session_content = renderer.generate_session(
session_messages, matched_id, session_title, cache_manager, output_dir
Expand Down Expand Up @@ -2307,6 +2317,7 @@ def process_projects_hierarchy(
filter_path: Optional[str] = None,
write_combined: bool = True,
no_timestamps: bool = False,
no_recaps: bool = False,
) -> Path:
"""Process the entire ~/.claude/projects/ hierarchy and create linked output files.

Expand Down Expand Up @@ -2384,7 +2395,7 @@ def process_projects_hierarchy(
# all need to use the same name. Hard-coding "combined_transcripts.html"
# would make non-default --format / --detail / --compact
# combinations cache-miss forever and link to the wrong file.
variant = _variant_suffix(detail, compact, output_format, no_timestamps)
variant = _variant_suffix(detail, compact, output_format, no_timestamps, no_recaps)
combined_ext = get_file_extension(output_format)
combined_name = f"combined_transcripts{variant}.{combined_ext}"

Expand Down Expand Up @@ -2555,6 +2566,7 @@ def _rel_to_index(p: Path) -> str:
output_root=(dest_dir if dest_dir != project_dir else None),
write_combined=write_combined,
no_timestamps=no_timestamps,
no_recaps=no_recaps,
)

# Track timing
Expand Down
9 changes: 7 additions & 2 deletions claude_code_log/html/renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -1440,7 +1440,10 @@ def _generate_inner(

# Get root messages (tree) and session navigation from format-neutral renderer
root_messages, session_nav, ctx = generate_template_messages(
messages, session_tree=session_tree, detail=self.detail
messages,
session_tree=session_tree,
detail=self.detail,
no_recaps=self.no_recaps,
)
# Snapshot the teammate-color map onto the renderer so per-message
# format methods can consult it without threading ctx through every
Expand Down Expand Up @@ -1526,7 +1529,9 @@ def generate_session(
if project_cache and project_cache.sessions:
from ..utils import variant_suffix as _variant_suffix

suffix = _variant_suffix(self.detail, self.compact, "html")
suffix = _variant_suffix(
self.detail, self.compact, "html", no_recaps=self.no_recaps
)
combined_link = f"combined_transcripts{suffix}.html"
except Exception:
pass
Expand Down
9 changes: 7 additions & 2 deletions claude_code_log/json/renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,10 @@ def generate(
) -> str:
"""Serialize the processed transcript tree to JSON."""
root_messages, session_nav, _ = generate_template_messages(
messages, session_tree=session_tree, detail=self.detail
messages,
session_tree=session_tree,
detail=self.detail,
no_recaps=self.no_recaps,
)

payload: dict[str, Any] = {
Expand Down Expand Up @@ -145,7 +148,9 @@ def generate_session(
if cache_manager is not None and not suppress_combined_link:
from ..utils import variant_suffix as _variant_suffix

suffix = _variant_suffix(self.detail, self.compact, "json")
suffix = _variant_suffix(
self.detail, self.compact, "json", no_recaps=self.no_recaps
)
combined_link = f"combined_transcripts{suffix}.json"

return self.generate(
Expand Down
7 changes: 5 additions & 2 deletions claude_code_log/markdown/renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -2063,7 +2063,10 @@ def _generate_inner(

# Get root messages (tree), session navigation, and rendering context
root_messages, session_nav, ctx = generate_template_messages(
messages, session_tree=session_tree, detail=self.detail
messages,
session_tree=session_tree,
detail=self.detail,
no_recaps=self.no_recaps,
)
self._ctx = ctx
self._teammate_colors_by_session = {
Expand Down Expand Up @@ -2118,7 +2121,7 @@ def generate_session(
from ..utils import variant_suffix as _variant_suffix

suffix = _variant_suffix(
self.detail, self.compact, "md", self.no_timestamps
self.detail, self.compact, "md", self.no_timestamps, self.no_recaps
)
combined_link = f"combined_transcripts{suffix}.md"
else:
Expand Down
12 changes: 7 additions & 5 deletions claude_code_log/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -534,11 +534,13 @@ class AwaySummaryMessage(MessageContent):
and the level-bearing SystemMessage (info/warning/error).
"""

# Recaps are narrative content, not noise: visible at FULL and HIGH,
# dropped at LOW and below (alongside bash/thinking). Declared via the
# class-attribute detail-visibility mechanism so the rule lives with the
# content type rather than in renderer.py's exclude registries.
detail_visibility: ClassVar[DetailLevel] = DetailLevel.HIGH
# Recaps are a high-level summary of activity, so they stay visible at
# EVERY detail level — including user-only, where "user said this, agent
# did that (just the recap)" is exactly the wanted view (#179). Declared at
# the least-verbose threshold (USER_ONLY) via the class-attribute
# detail-visibility mechanism. Suppress them explicitly with ``--no-recaps``
# (handled in ``_ghost_template_by_detail``), not by lowering this.
detail_visibility: ClassVar[DetailLevel] = DetailLevel.USER_ONLY

text: str # Recap prose; may contain light markdown.

Expand Down
23 changes: 21 additions & 2 deletions claude_code_log/renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -666,6 +666,7 @@ def generate_template_messages(
messages: list[TranscriptEntry],
session_tree: Optional["SessionTree"] = None,
detail: DetailLevel | str = DetailLevel.FULL,
no_recaps: bool = False,
) -> Tuple[list[TemplateMessage], list[dict[str, Any]], RenderingContext]:
"""Generate root messages and session navigation from transcript messages.

Expand Down Expand Up @@ -770,9 +771,11 @@ def generate_template_messages(
# (``session_first_message``, ``parent_message_index``,
# ``junction_forward_links``) so dropped fork-points don't leave
# dead ``#msg-d-{N}`` links.
if detail != DetailLevel.FULL:
# ``--no-recaps`` suppresses recaps even at FULL, so run the ghost pass
# whenever filtering OR recap suppression is requested (#179).
if detail != DetailLevel.FULL or no_recaps:
with log_timing(f"Detail post-render filter ({detail.value})", t_start):
_ghost_template_by_detail(ctx, detail)
_ghost_template_by_detail(ctx, detail, no_recaps=no_recaps)

# Prepare session navigation data (uses ctx for session header indices)
session_nav: list[dict[str, Any]] = []
Expand Down Expand Up @@ -3395,6 +3398,7 @@ def _drop_anchor_refs_into_ghosts(ctx: RenderingContext) -> None:
def _ghost_template_by_detail(
ctx: RenderingContext,
detail: DetailLevel,
no_recaps: bool = False,
) -> None:
"""Ghost (set to None) ctx.messages slots that aren't visible at ``detail``.

Expand Down Expand Up @@ -3457,6 +3461,12 @@ def _ghost_template_by_detail(
):
visible = False

# ``--no-recaps`` (#179): recaps are otherwise visible at every level
# (AwaySummaryMessage.detail_visibility == USER_ONLY); this is the
# explicit opt-out, applied regardless of detail level.
if visible and no_recaps and isinstance(msg.content, AwaySummaryMessage):
visible = False

if not visible:
ctx.messages[idx] = None

Expand Down Expand Up @@ -4270,6 +4280,10 @@ class Renderer:

detail: DetailLevel = DetailLevel.FULL
compact: bool = False
# When True, suppress ``※ recap`` (away_summary) messages at every detail
# level (#179). Recaps are otherwise always visible (see
# ``AwaySummaryMessage.detail_visibility``).
no_recaps: bool = False

# Output format identifier consulted by the class-side dispatch path
# below. Subclasses override to ``"html"`` etc.; the default
Expand Down Expand Up @@ -4619,6 +4633,7 @@ def get_renderer(
detail: DetailLevel = DetailLevel.FULL,
compact: bool = False,
no_timestamps: bool = False,
no_recaps: bool = False,
) -> Renderer:
"""Get a renderer instance for the specified format.

Expand All @@ -4631,6 +4646,9 @@ def get_renderer(
no_timestamps: If True, suppress per-message timestamp lines
in Markdown output (issue #160). Ignored for HTML/JSON
since they don't emit those lines.
no_recaps: If True, suppress ``※ recap`` (away_summary) messages at
every detail level (issue #179). Recaps are otherwise always
visible.

Returns:
A Renderer instance for the specified format.
Expand Down Expand Up @@ -4658,6 +4676,7 @@ def get_renderer(
raise ValueError(f"Unsupported format: {format}")
renderer.detail = detail
renderer.compact = compact
renderer.no_recaps = no_recaps
return renderer


Expand Down
9 changes: 9 additions & 0 deletions claude_code_log/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ def variant_suffix(
compact: bool = False,
format: str = "html",
no_timestamps: bool = False,
no_recaps: bool = False,
) -> str:
"""Compute the filename infix for a given render variant.

Expand All @@ -65,6 +66,14 @@ def variant_suffix(
parts: list[str] = []
if detail != DetailLevel.FULL:
parts.append(detail.value)
# `--no-recaps` filters *messages* out of the rendered tree, so it
# affects EVERY format (html/md/json) — unlike compact/no-timestamps
# below. It must earn a suffix slot regardless of format, else a
# `--no-recaps` export collides with the plain one on filename + cache
# key and the path-existence/cache check serves the stale variant
# (same class as the #165 no-timestamps finding; #179).
if no_recaps:
parts.append("no-recaps")
# `--compact` and `--no-timestamps` are Markdown-only (merges of
# same-category headings / suppression of per-message timestamp
# lines). They are silent no-ops for HTML, so they don't earn a
Expand Down
7 changes: 7 additions & 0 deletions dev-docs/application_model.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,13 @@ how much of the transcript renders:
(designed for feeding to downstream agents, e.g. building a
requirements doc).

Recaps (`AwaySummaryMessage`) are a cross-cutting exception: they are a
high-level summary of activity, so they stay visible at *every* level
(`detail_visibility = USER_ONLY`), including `user-only`. The `--no-recaps`
flag suppresses them at all levels — giving `--detail user-only --no-recaps`
for a truly user-only view, or `--detail minimal --no-recaps` to drop the
recap/agent redundancy (#179).

Filtering happens in a single *post-render* pass on `TemplateMessage`:
`_ghost_template_by_detail` sets each non-visible slot in
`RenderingContext.messages` to `None` ("ghosting"), keyed by the content
Expand Down
Loading
Loading