Skip to content

Commit a18f7d9

Browse files
committed
Add --minimal export mode (dialogue-only mini-log)
New flag '--minimal' on 'export' and 'export-all' produces a streamlined mini-log containing only user prompts and assistant text replies. Drops: - tool_use blocks (assistant calling Bash/Edit/etc.) - tool_result messages (the user-role wrappers Claude gets back) - thinking blocks (visible reasoning text) - image / unknown blocks Output filename gets a '.minimal.md' suffix so both modes can coexist for the same session. Frontmatter records 'mode: dialogue-only' and an adjusted 'messages' count reflecting only the kept turns. On a real 296-message session the minimal export is roughly half the size. parser.py adds 'dialogue_text(msg)' and 'is_dialogue_message(msg)' helpers that the exporter uses when minimal=True. 9 new tests; 64/64 pass.
1 parent a827da8 commit a18f7d9

4 files changed

Lines changed: 204 additions & 12 deletions

File tree

claude_backup/cli.py

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,17 @@ def list_cmd(ctx: click.Context) -> None:
5959
default=Path("./backups"),
6060
help="Output directory (default: ./backups/).",
6161
)
62+
@click.option(
63+
"--minimal",
64+
is_flag=True,
65+
default=False,
66+
help="Mini-log: only user/assistant dialogue, no tool calls or reasoning. "
67+
"Filename gets a .minimal.md suffix so it doesn't overwrite the full export.",
68+
)
6269
@click.pass_context
63-
def export_cmd(ctx: click.Context, session_id: str, output: Path) -> None:
70+
def export_cmd(
71+
ctx: click.Context, session_id: str, output: Path, minimal: bool
72+
) -> None:
6473
projects = _safe_scan(ctx.obj.get("claude_home"))
6574
matches = []
6675
for project in projects:
@@ -87,7 +96,7 @@ def export_cmd(ctx: click.Context, session_id: str, output: Path) -> None:
8796
)
8897
sys.exit(2)
8998

90-
out = export_session(target, output)
99+
out = export_session(target, output, minimal=minimal)
91100
click.echo(f"Exported: {out}")
92101

93102

@@ -99,8 +108,14 @@ def export_cmd(ctx: click.Context, session_id: str, output: Path) -> None:
99108
default=Path("./backups"),
100109
help="Output directory (default: ./backups/).",
101110
)
111+
@click.option(
112+
"--minimal",
113+
is_flag=True,
114+
default=False,
115+
help="Mini-log mode: only user/assistant dialogue, no tools or reasoning.",
116+
)
102117
@click.pass_context
103-
def export_all_cmd(ctx: click.Context, output: Path) -> None:
118+
def export_all_cmd(ctx: click.Context, output: Path, minimal: bool) -> None:
104119
projects = _safe_scan(ctx.obj.get("claude_home"))
105120
exported = 0
106121
skipped = 0
@@ -116,7 +131,7 @@ def export_all_cmd(ctx: click.Context, output: Path) -> None:
116131
skipped += 1
117132
continue
118133
try:
119-
path = export_session(session, project_out)
134+
path = export_session(session, project_out, minimal=minimal)
120135
click.echo(f"Exported: {path}")
121136
exported += 1
122137
except Exception as e: # pragma: no cover - defensive

claude_backup/exporter.py

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from datetime import datetime, timezone
66
from pathlib import Path
77

8-
from .parser import Message, parse_session
8+
from .parser import Message, dialogue_text, is_dialogue_message, parse_session
99
from .scanner import SessionInfo
1010

1111

@@ -21,8 +21,13 @@ def export_session(
2121
session: SessionInfo,
2222
output_dir: Path,
2323
now: datetime | None = None,
24+
minimal: bool = False,
2425
) -> Path:
25-
"""Export a single session to Markdown. Returns path to the written file."""
26+
"""Export a single session to Markdown. Returns path to the written file.
27+
28+
When `minimal=True`, drops tool calls, tool results, and reasoning blocks —
29+
keeps only the user/assistant dialogue. Filename gets a `.minimal.md` suffix.
30+
"""
2631
if session.jsonl_path is None or not session.jsonl_path.exists():
2732
raise FileNotFoundError(
2833
f"Session JSONL not found for {session.session_id}"
@@ -32,10 +37,11 @@ def export_session(
3237
messages = parse_session(session.jsonl_path)
3338

3439
date_prefix = _date_prefix(session, messages)
35-
filename = f"{date_prefix}--{session.session_id}.md"
40+
suffix = ".minimal.md" if minimal else ".md"
41+
filename = f"{date_prefix}--{session.session_id}{suffix}"
3642
out_path = output_dir / filename
3743

38-
md = render_markdown(session, messages, now=now)
44+
md = render_markdown(session, messages, now=now, minimal=minimal)
3945
out_path.write_text(md, encoding="utf-8")
4046
return out_path
4147

@@ -44,13 +50,16 @@ def render_markdown(
4450
session: SessionInfo,
4551
messages: list[Message],
4652
now: datetime | None = None,
53+
minimal: bool = False,
4754
) -> str:
4855
"""Build the full Markdown document for a session."""
4956
now = now or datetime.now(timezone.utc)
5057

58+
visible = [m for m in messages if is_dialogue_message(m)] if minimal else messages
59+
5160
branch = session.git_branch or _first_non_empty(m.git_branch for m in messages)
5261
model = _first_non_empty(m.model for m in messages)
53-
msg_count = session.message_count or len(messages)
62+
msg_count = len(visible) if minimal else (session.message_count or len(messages))
5463

5564
fm_fields = {
5665
"project": session.project,
@@ -60,6 +69,8 @@ def render_markdown(
6069
"messages": msg_count,
6170
"exported_at": _format_iso(now),
6271
}
72+
if minimal:
73+
fm_fields["mode"] = "dialogue-only"
6374
if session.title:
6475
fm_fields["title"] = session.title
6576
frontmatter = _render_frontmatter(**fm_fields)
@@ -70,7 +81,7 @@ def render_markdown(
7081
else:
7182
header = f"# {session.project} / {title_branch} / {session.session_id}\n"
7283

73-
body = _render_body(messages)
84+
body = _render_body(visible, minimal=minimal)
7485

7586
parts = [frontmatter, "", header, body]
7687
return "\n".join(parts).rstrip() + "\n"
@@ -108,7 +119,7 @@ def _yaml_scalar(value) -> str:
108119
return s
109120

110121

111-
def _render_body(messages: list[Message]) -> str:
122+
def _render_body(messages: list[Message], minimal: bool = False) -> str:
112123
if not messages:
113124
return "_No messages._\n"
114125

@@ -117,7 +128,11 @@ def _render_body(messages: list[Message]) -> str:
117128
label = ROLE_LABELS.get(msg.role, msg.role.capitalize())
118129
time_str = _format_short_time(msg.timestamp)
119130
heading = f"## {label} ({time_str})" if time_str else f"## {label}"
120-
body = msg.content.rstrip() if msg.content else "_(empty)_"
131+
if minimal:
132+
text = dialogue_text(msg).rstrip()
133+
else:
134+
text = msg.content.rstrip() if msg.content else ""
135+
body = text or "_(empty)_"
121136
sections.append(f"{heading}\n{body}\n")
122137
return "\n".join(sections)
123138

claude_backup/parser.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,37 @@ def _normalize_content(content) -> str:
181181
return str(content)
182182

183183

184+
def dialogue_text(msg: "Message") -> str:
185+
"""Extract only human-facing text from a message: no tool_use, no tool_result,
186+
no thinking, no images. Used by `--minimal` export mode."""
187+
nested = msg.raw.get("message") if isinstance(msg.raw, dict) else None
188+
raw = nested if isinstance(nested, dict) else msg.raw
189+
content = raw.get("content") if isinstance(raw, dict) else None
190+
191+
if isinstance(content, str):
192+
return content
193+
if not isinstance(content, list):
194+
return ""
195+
196+
parts: list[str] = []
197+
for block in content:
198+
if isinstance(block, str):
199+
parts.append(block)
200+
continue
201+
if not isinstance(block, dict):
202+
continue
203+
if block.get("type") == "text":
204+
text = block.get("text", "")
205+
if isinstance(text, str) and text:
206+
parts.append(text)
207+
return "\n".join(parts)
208+
209+
210+
def is_dialogue_message(msg: "Message") -> bool:
211+
"""True if the message has any visible human-facing dialogue text."""
212+
return bool(dialogue_text(msg).strip())
213+
214+
184215
def session_summary(jsonl_path: Path) -> dict:
185216
"""Quick stats about a session: message count, first prompt, model."""
186217
messages = parse_session(jsonl_path)

tests/test_minimal.py

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
"""Tests for --minimal export mode (dialogue-only output)."""
2+
3+
from __future__ import annotations
4+
5+
from pathlib import Path
6+
7+
from click.testing import CliRunner
8+
9+
from claude_backup.cli import main
10+
from claude_backup.exporter import export_session, render_markdown
11+
from claude_backup.parser import dialogue_text, is_dialogue_message, parse_session
12+
from claude_backup.scanner import scan_projects
13+
14+
15+
def _real_session(claude_home: Path):
16+
projects = scan_projects(claude_home)
17+
real = next(p for p in projects if p.name == "real-format-project")
18+
return real.sessions[0]
19+
20+
21+
def test_dialogue_text_strips_tool_use(claude_home: Path) -> None:
22+
"""In `real-001.jsonl` the assistant says 'Installing now.' then makes a tool_use call.
23+
`dialogue_text` should keep the text and drop the tool_use marker."""
24+
f = claude_home / "real-format-project" / "real-001.jsonl"
25+
messages = parse_session(f)
26+
assistant_first = next(m for m in messages if m.role == "assistant")
27+
assert dialogue_text(assistant_first) == "Installing now."
28+
assert "[tool_use:" not in dialogue_text(assistant_first)
29+
30+
31+
def test_dialogue_text_strips_tool_result(claude_home: Path) -> None:
32+
"""The user 'tool_result' message should produce empty dialogue text."""
33+
f = claude_home / "real-format-project" / "real-001.jsonl"
34+
messages = parse_session(f)
35+
tool_result_user = [
36+
m for m in messages if m.role == "user" and "installed" in m.content
37+
][0]
38+
assert dialogue_text(tool_result_user) == ""
39+
assert is_dialogue_message(tool_result_user) is False
40+
41+
42+
def test_dialogue_text_strips_thinking(tmp_path: Path) -> None:
43+
f = tmp_path / "x.jsonl"
44+
f.write_text(
45+
'{"role":"assistant","content":['
46+
'{"type":"thinking","thinking":"weighing options","signature":"sig"},'
47+
'{"type":"text","text":"answer"}'
48+
']}\n'
49+
)
50+
messages = parse_session(f)
51+
assert dialogue_text(messages[0]) == "answer"
52+
53+
54+
def test_is_dialogue_message_true_for_user_prompt(claude_home: Path) -> None:
55+
f = claude_home / "real-format-project" / "real-001.jsonl"
56+
messages = parse_session(f)
57+
real_user = next(m for m in messages if m.role == "user")
58+
assert is_dialogue_message(real_user) is True
59+
60+
61+
def test_render_markdown_minimal_drops_tool_messages(claude_home: Path) -> None:
62+
session = _real_session(claude_home)
63+
messages = parse_session(session.jsonl_path)
64+
md = render_markdown(session, messages, minimal=True)
65+
66+
assert "[tool_use:" not in md
67+
assert "tool_result" not in md
68+
assert "installed" not in md # tool_result content
69+
assert "Installing now." in md
70+
assert "Done." in md
71+
assert "install superpowers skill" in md
72+
assert "mode: dialogue-only" in md
73+
74+
75+
def test_render_markdown_full_keeps_tool_messages(claude_home: Path) -> None:
76+
"""Sanity: without minimal flag, tool messages are still there."""
77+
session = _real_session(claude_home)
78+
messages = parse_session(session.jsonl_path)
79+
md = render_markdown(session, messages, minimal=False)
80+
81+
assert "[tool_use: Bash]" in md
82+
assert "installed" in md
83+
assert "mode: dialogue-only" not in md
84+
85+
86+
def test_export_session_minimal_writes_separate_file(
87+
claude_home: Path, tmp_path: Path
88+
) -> None:
89+
session = _real_session(claude_home)
90+
full_path = export_session(session, tmp_path, minimal=False)
91+
minimal_path = export_session(session, tmp_path, minimal=True)
92+
93+
assert full_path != minimal_path
94+
assert full_path.name.endswith(".md")
95+
assert not full_path.name.endswith(".minimal.md")
96+
assert minimal_path.name.endswith(".minimal.md")
97+
98+
assert "[tool_use:" in full_path.read_text(encoding="utf-8")
99+
assert "[tool_use:" not in minimal_path.read_text(encoding="utf-8")
100+
101+
102+
def test_cli_export_with_minimal_flag(claude_home: Path, tmp_path: Path) -> None:
103+
runner = CliRunner()
104+
out = tmp_path / "backups"
105+
result = runner.invoke(
106+
main,
107+
[
108+
"--claude-home",
109+
str(claude_home),
110+
"export",
111+
"real-001",
112+
"--output",
113+
str(out),
114+
"--minimal",
115+
],
116+
)
117+
assert result.exit_code == 0, result.output
118+
files = list(out.glob("*.minimal.md"))
119+
assert len(files) == 1
120+
121+
122+
def test_cli_export_all_with_minimal_flag(claude_home: Path, tmp_path: Path) -> None:
123+
runner = CliRunner()
124+
out = tmp_path / "all"
125+
result = runner.invoke(
126+
main,
127+
["--claude-home", str(claude_home), "export-all", "--output", str(out), "--minimal"],
128+
)
129+
assert result.exit_code == 0, result.output
130+
minimal_files = list(out.rglob("*.minimal.md"))
131+
assert len(minimal_files) >= 1

0 commit comments

Comments
 (0)