Add dump-session skill for exporting Claude Code sessions
Assisted-by: Claude Opus 4.6 via Claude Code
diff --git a/ai/claude/skills/dump-session/SKILL.md b/ai/claude/skills/dump-session/SKILL.md
new file mode 100644
index 00000000..21281fda
--- /dev/null
+++ b/ai/claude/skills/dump-session/SKILL.md
@@ -0,0 +1,135 @@
+---
+name: dump-session
+description: This skill should be used when the user asks to "dump session", "export session", "save session", "/dump-session", "export conversation", "save this conversation", or "write session to markdown". Exports the current Claude Code session as Markdown and HTML files with a summary.
+---
+
+# Dump Session
+
+Export the current Claude Code session as Markdown and HTML files in the
+repository, with a generated summary at the top and the full conversation below.
+
+## Workflow
+
+1. Identify the current session JSONL file
+2. Run the Markdown export script
+3. Run the HTML export script
+4. Read the exports and write summaries to replace the placeholders
+5. Review the exports for sensitive content and propose redactions
+6. Save both files to the repository
+
+## Identifying the Session
+
+The user may specify which session to export by passing a session UUID or a file
+path as an argument. When provided, use that value directly and skip
+auto-detection.
+
+When no argument is given, find the current session automatically. Claude Code
+stores sessions as JSONL files under `~/.claude/projects/`. The project
+directory name is the working directory path with slashes and dots replaced by
+dashes, prefixed with a dash:
+
+```
+~/.claude/projects/-Users-alpha-chen-src-project-name/
+```
+
+To find the current session file, identify the most recently modified `.jsonl`
+in the project directory. Run:
+
+```bash
+ls -t ~/.claude/projects/<project-dir>/*.jsonl | head -1
+```
+
+Derive `<project-dir>` from the current working directory by replacing `/` and
+`.` with `-`.
+
+## Running the Export
+
+Use the bundled script to convert the JSONL to Markdown:
+
+```bash
+~/.claude/skills/dump-session/scripts/dump-session.sh <session.jsonl|session-id> <output.md>
+```
+
+The first argument accepts either a path to a JSONL file or a session UUID. When
+given a UUID, the script searches `~/.claude/projects/` for a matching file.
+
+The script produces a complete Markdown document with:
+
+- A metadata table (session ID, Claude Code version, working directory, branch,
+ timestamps, message counts, models)
+- A tool usage summary
+- A `## Summary` section containing a placeholder comment
+- A `## Conversation` section with all user and assistant messages, tool calls
+ in collapsible `<details>` blocks
+
+## Writing the Summary
+
+After the script runs, read the output file and replace the summary placeholder
+with a concise summary of the session. The summary should cover what was
+discussed, what was built or changed, and key decisions made. Write it in first
+person to match the user's voice.
+
+Replace:
+
+```markdown
+<!-- Replace this with a summary of the session -->
+```
+
+With the actual summary text.
+
+## Redaction Review
+
+After writing the summary, scan the full export for sensitive content that
+should not be shared. Present each finding to the user with `AskUserQuestion`
+and let them decide what to redact.
+
+Common categories to flag:
+
+- API keys, tokens, passwords, and credentials
+- `<system-reminder>` content that leaked into visible messages (instructions,
+ skill listings, CLAUDE.md contents)
+- Absolute filesystem paths that reveal machine or user directory structure
+- Internal hostnames, IP addresses, or URLs not meant to be public
+- Personal information (emails, phone numbers, employee IDs) that appeared in
+ tool output or conversation
+
+For each finding, show the surrounding context and propose a replacement such as
+`[REDACTED]` or a descriptive placeholder like `[API_KEY]`. Apply only the
+redactions the user approves.
+
+If nothing sensitive is found, tell the user and move on.
+
+## HTML Generation
+
+After the Markdown file is finalized (summary written, redactions applied),
+generate a self-contained HTML thread view from the same session:
+
+```bash
+~/.claude/skills/dump-session/scripts/dump-session-html.sh <session.jsonl|session-id> <output.html>
+```
+
+The HTML script produces a chat-style page with user and assistant messages,
+collapsed tool calls, collapsed skill content, metadata header, and a summary
+placeholder. It uses pandoc to render markdown within messages. The output
+includes inline CSS with dark/light mode support.
+
+After generating, replace the HTML summary placeholder the same way as the
+Markdown file. The summary `<div>` contains an HTML comment to replace.
+
+## Output Location
+
+Save both files to `docs/sessions/` in the repository root, creating the
+directory if needed. Name them with the date and a slug derived from the session
+topic:
+
+```
+docs/sessions/YYYY-MM-DD-topic-slug.md
+docs/sessions/YYYY-MM-DD-topic-slug.html
+```
+
+## Scripts
+
+- `scripts/dump-session.sh` — Converts a session JSONL file to Markdown in a
+ single `jq` pass. Requires `jq` 1.6+.
+- `scripts/dump-session-html.sh` — Converts a session JSONL file to a
+ self-contained HTML thread view. Requires `jq` 1.6+ and `pandoc`.
diff --git a/ai/claude/skills/dump-session/scripts/dump-session-html.sh b/ai/claude/skills/dump-session/scripts/dump-session-html.sh
new file mode 100755
index 00000000..a2d200a6
--- /dev/null
+++ b/ai/claude/skills/dump-session/scripts/dump-session-html.sh
@@ -0,0 +1,473 @@
+#!/usr/bin/env bash
+#
+# dump-session-html.sh — Export a Claude Code session JSONL to a self-contained HTML file.
+#
+# Usage:
+# dump-session-html.sh <session.jsonl|session-id> [output.html]
+#
+# The first argument can be a path to a JSONL file or a session UUID.
+# When given a UUID, the script searches ~/.claude/projects/ for a matching file.
+# If output.html is omitted, writes to stdout.
+#
+# Requires: jq 1.6+, pandoc
+
+set -euo pipefail
+
+if [[ $# -lt 1 ]]; then
+ echo "Usage: dump-session-html.sh <session.jsonl|session-id> [output.html]" >&2
+ exit 1
+fi
+
+INPUT="$1"
+OUTPUT="${2:-/dev/stdout}"
+
+# Resolve input: file path or session UUID
+if [[ -f "$INPUT" ]]; then
+ SESSION_FILE="$INPUT"
+elif [[ "$INPUT" =~ ^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$ ]]; then
+ SESSION_FILE=$(find ~/.claude/projects -name "${INPUT}.jsonl" -print -quit 2>/dev/null)
+ if [[ -z "$SESSION_FILE" ]]; then
+ echo "Error: No session file found for ID: $INPUT" >&2
+ exit 1
+ fi
+else
+ echo "Error: Not a file path or valid session UUID: $INPUT" >&2
+ exit 1
+fi
+
+# Extract structured JSON from the session JSONL.
+# Produces: { meta: {...}, tools: [...], messages: [...] }
+JSON=$(jq -rs '
+ (map(select(.type == "user"))) as $users |
+ (map(select(.type == "assistant"))) as $assistants |
+ (map(select(.type == "user" or .type == "assistant"))) as $messages |
+
+ ($users[0] // {}) as $first |
+
+ # Tool usage counts
+ ([
+ $assistants[].message.content[]? |
+ select(type == "object" and .type == "tool_use") | .name
+ ] | group_by(.) | map({name: .[0], count: length}) | sort_by(-.count)) as $tools |
+
+ # Build messages array
+ ($messages | map(
+ if .type == "user" then
+ (.message.content |
+ if type == "string" then .
+ elif type == "array" then
+ [.[] |
+ if type == "string" then .
+ elif .type == "text" then .text
+ elif .type == "tool_result" then null
+ else null
+ end
+ ] | map(select(. != null)) | join("\n\n")
+ else ""
+ end
+ ) as $content |
+
+ (.message.content |
+ if type == "array" then all(type == "object" and .type == "tool_result")
+ else false
+ end
+ ) as $is_tool_result_only |
+
+ ($content | test("^<(command-name|local-command|system-reminder)")) as $is_system |
+
+ # Detect skill content (injected by Skill tool)
+ ($content | test("^Base directory for this skill:")) as $is_skill |
+
+ if $is_tool_result_only or $is_system or ($content | length) == 0 then
+ null
+ else
+ {role: "user", timestamp: (.timestamp // ""), content: $content, tools: [],
+ kind: (if $is_skill then "skill" else "message" end)}
+ end
+
+ elif .type == "assistant" then
+ ([.message.content[]? | select(type == "object" and .type == "text") | .text] | join("\n\n")) as $text |
+
+ ([.message.content[]? | select(type == "object" and .type == "tool_use") |
+ {
+ name: .name,
+ detail: (
+ if .input.command then (.input.command | tostring | split("\n") | .[0])
+ elif .input.pattern then .input.pattern
+ elif .input.file_path then .input.file_path
+ else null
+ end
+ )
+ }
+ ]) as $tool_list |
+
+ if ($text | length) == 0 and ($tool_list | length) == 0 then
+ null
+ else
+ {role: "assistant", timestamp: (.timestamp // ""), content: $text, tools: $tool_list}
+ end
+ else null
+ end
+ ) | map(select(. != null))) as $msg_list |
+
+ {
+ meta: {
+ session_id: ($first.sessionId // "unknown"),
+ version: ($first.version // "unknown"),
+ cwd: ($first.cwd // "unknown"),
+ branch: ($first.gitBranch // "unknown"),
+ started: ($messages | first | .timestamp // ""),
+ ended: ($messages | last | .timestamp // ""),
+ user_count: ($users | length),
+ assistant_count: ($assistants | length),
+ models: ([$assistants[].message.model // empty] | unique | join(", "))
+ },
+ tools: $tools,
+ messages: $msg_list
+ }
+' "$SESSION_FILE")
+
+# Convert markdown content to HTML fragments via pandoc.
+# Iterates over each message, converts content, and reassembles.
+MESSAGES_HTML=$(echo "$JSON" | jq -r '.messages[] | @base64' | while read -r encoded; do
+ msg=$(echo "$encoded" | base64 --decode)
+ role=$(echo "$msg" | jq -r '.role')
+ timestamp=$(echo "$msg" | jq -r '.timestamp')
+ content=$(echo "$msg" | jq -r '.content')
+ tools=$(echo "$msg" | jq -c '.tools')
+ kind=$(echo "$msg" | jq -r '.kind // "message"')
+
+ # Convert markdown content to HTML
+ if [[ -n "$content" ]]; then
+ content_html=$(echo "$content" | pandoc -f markdown+raw_html -t html)
+ else
+ content_html=""
+ fi
+
+ # Build tool call HTML
+ tools_html=""
+ tool_count=$(echo "$tools" | jq 'length')
+ if [[ "$tool_count" -gt 0 ]]; then
+ tools_html=$(echo "$tools" | jq -r '.[] |
+ "<div class=\"tool-call\">" +
+ "<span class=\"tool-name\">" + .name + "</span>" +
+ (if .detail then " <code>" + (.detail | gsub("<"; "<") | gsub(">"; ">")) + "</code>" else "" end) +
+ "</div>"
+ ')
+ fi
+
+ # Skip empty assistant messages
+ if [[ "$role" == "assistant" && -z "$content_html" && "$tool_count" -eq 0 ]]; then
+ continue
+ fi
+
+ # Emit the message HTML
+ if [[ "$role" == "user" && "$kind" == "skill" ]]; then
+ # Extract skill name from the first H1 in the content
+ skill_name=$(echo "$content" | grep -m1 '^# ' | sed 's/^# //')
+ : "${skill_name:=Skill Content}"
+ cat <<MSGEOF
+<div class="message message-skill">
+ <details class="skill-content">
+ <summary>Skill loaded: ${skill_name}</summary>
+ <div class="skill-body">${content_html}</div>
+ </details>
+</div>
+MSGEOF
+ elif [[ "$role" == "user" ]]; then
+ cat <<MSGEOF
+<div class="message message-user">
+ <div class="avatar">U</div>
+ <div class="message-content">
+ ${content_html}
+ </div>
+</div>
+MSGEOF
+ else
+ cat <<MSGEOF
+<div class="message message-assistant">
+MSGEOF
+ if [[ -n "$content_html" ]]; then
+ cat <<MSGEOF
+ <div class="assistant-text">${content_html}</div>
+MSGEOF
+ fi
+ if [[ "$tool_count" -gt 0 ]]; then
+ cat <<MSGEOF
+ <details class="tool-calls">
+ <summary>Tool calls (${tool_count})</summary>
+ ${tools_html}
+ </details>
+MSGEOF
+ fi
+ cat <<MSGEOF
+</div>
+MSGEOF
+ fi
+done)
+
+# Extract metadata for the header
+META_SESSION_ID=$(echo "$JSON" | jq -r '.meta.session_id')
+META_VERSION=$(echo "$JSON" | jq -r '.meta.version')
+META_CWD=$(echo "$JSON" | jq -r '.meta.cwd')
+META_BRANCH=$(echo "$JSON" | jq -r '.meta.branch')
+META_STARTED=$(echo "$JSON" | jq -r '.meta.started')
+META_ENDED=$(echo "$JSON" | jq -r '.meta.ended')
+META_USER_COUNT=$(echo "$JSON" | jq -r '.meta.user_count')
+META_ASSISTANT_COUNT=$(echo "$JSON" | jq -r '.meta.assistant_count')
+META_MODELS=$(echo "$JSON" | jq -r '.meta.models')
+TOOLS_HTML=$(echo "$JSON" | jq -r '[.tools[] | "\(.name) (\(.count))"] | join(" · ")')
+
+# Assemble the full HTML document
+cat <<'HTMLEOF' > "$OUTPUT"
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Session Export</title>
+<style>
+:root {
+ color-scheme: dark light;
+ --bg: #0c0c0c;
+ --fg: #d4d4d4;
+ --fg-muted: #737373;
+ --border: #262626;
+ --surface: #171717;
+ --code-bg: #1a1a1a;
+ --user-border: #363636;
+ --radius: 8px;
+ --font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
+ --mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+}
+@media (prefers-color-scheme: light) {
+ :root {
+ --bg: #fff;
+ --fg: #1a1a1a;
+ --fg-muted: #737373;
+ --border: #e5e5e5;
+ --surface: #f7f7f7;
+ --code-bg: #f3f3f3;
+ --user-border: #d4d4d4;
+ }
+}
+*, *::before, *::after { box-sizing: border-box; }
+body {
+ margin: 0;
+ padding: 0;
+ background: var(--bg);
+ color: var(--fg);
+ font-family: var(--font);
+ font-size: 15px;
+ line-height: 1.6;
+ -webkit-font-smoothing: antialiased;
+}
+
+/* --- Layout --- */
+.page {
+ display: grid;
+ grid-template-columns: 260px 1fr;
+ min-height: 100vh;
+}
+@media (max-width: 860px) {
+ .page { grid-template-columns: 1fr; }
+ .sidebar {
+ position: static !important;
+ height: auto !important;
+ border-right: none !important;
+ border-bottom: 1px solid var(--border);
+ }
+}
+.sidebar {
+ position: sticky;
+ top: 0;
+ height: 100vh;
+ overflow-y: auto;
+ border-right: 1px solid var(--border);
+ padding: 1.5rem 1.25rem;
+ font-size: 0.8rem;
+ color: var(--fg-muted);
+}
+.conversation { max-width: 720px; padding: 2rem 1.5rem; }
+
+/* --- Sidebar metadata --- */
+.session-meta { margin: 0; padding: 0; }
+.session-meta dt { font-weight: 600; margin-top: 0.6rem; }
+.session-meta dt:first-child { margin-top: 0; }
+.session-meta dd { margin: 0; word-break: break-all; }
+.session-tools { margin-top: 0.75rem; }
+.session-summary {
+ margin-top: 1.25rem;
+ padding-top: 1.25rem;
+ border-top: 1px solid var(--border);
+}
+.session-summary h2 { margin: 0 0 0.5rem; font-size: 0.85rem; font-weight: 600; color: var(--fg); }
+
+/* --- Messages --- */
+.message { margin: 1.25rem 0; }
+.message-user {
+ display: grid;
+ grid-template-columns: 28px 1fr;
+ gap: 0.5rem;
+ align-items: start;
+}
+.avatar {
+ width: 28px;
+ height: 28px;
+ border-radius: 50%;
+ background: var(--border);
+ color: var(--fg-muted);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 0.7rem;
+ font-weight: 600;
+ flex-shrink: 0;
+}
+.message-content {
+ border: 1px solid var(--user-border);
+ border-radius: var(--radius);
+ padding: 0.5rem 0.75rem;
+ min-width: 0;
+}
+.message-content p:first-child { margin-top: 0; }
+.message-content p:last-child { margin-bottom: 0; }
+.message-assistant { padding-left: 36px; }
+.assistant-text p:first-child { margin-top: 0; }
+.assistant-text p:last-child { margin-bottom: 0; }
+
+/* --- Tool calls --- */
+.tool-calls {
+ margin: 0.75rem 0;
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ font-size: 0.85rem;
+}
+.tool-calls summary {
+ cursor: pointer;
+ padding: 0.375rem 0.75rem;
+ color: var(--fg-muted);
+ user-select: none;
+}
+.tool-calls summary:hover { color: var(--fg); }
+.tool-calls[open] summary { border-bottom: 1px solid var(--border); }
+.tool-call {
+ padding: 0.25rem 0.75rem;
+ border-bottom: 1px solid var(--border);
+}
+.tool-call:last-child { border-bottom: none; }
+.tool-name {
+ font-weight: 600;
+ font-size: 0.8rem;
+}
+.tool-call code {
+ font-family: var(--mono);
+ font-size: 0.8rem;
+ color: var(--fg-muted);
+}
+
+/* --- Skill content (collapsed) --- */
+.message-skill { padding-left: 36px; }
+.skill-content {
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ font-size: 0.85rem;
+}
+.skill-content summary {
+ cursor: pointer;
+ padding: 0.375rem 0.75rem;
+ color: var(--fg-muted);
+ font-style: italic;
+ user-select: none;
+}
+.skill-content summary:hover { color: var(--fg); }
+.skill-content[open] summary { border-bottom: 1px solid var(--border); }
+.skill-body {
+ padding: 0.5rem 0.75rem;
+ max-height: 300px;
+ overflow-y: auto;
+ color: var(--fg-muted);
+}
+
+/* --- Markdown content --- */
+pre {
+ background: var(--code-bg);
+ border-radius: 6px;
+ padding: 0.75rem 1rem;
+ overflow-x: auto;
+ font-size: 0.85rem;
+ line-height: 1.5;
+}
+code {
+ font-family: var(--mono);
+ font-size: 0.9em;
+}
+p code {
+ background: var(--code-bg);
+ padding: 0.15em 0.35em;
+ border-radius: 4px;
+}
+pre code { background: none; padding: 0; font-size: inherit; }
+a { color: #60a5fa; text-decoration: underline; text-underline-offset: 2px; }
+a:hover { text-decoration: none; }
+@media (prefers-color-scheme: light) {
+ a { color: #2563eb; }
+}
+ul, ol { padding-left: 1.5rem; margin: 0.5em 0; }
+li { margin: 0.25em 0; }
+table { border-collapse: collapse; width: 100%; margin: 0.75em 0; font-size: 0.9em; }
+th, td { border: 1px solid var(--border); padding: 0.375rem 0.5rem; text-align: left; }
+th { background: var(--surface); font-weight: 600; }
+blockquote {
+ border-left: 3px solid var(--border);
+ margin: 0.75em 0;
+ padding: 0.25em 1em;
+ color: var(--fg-muted);
+}
+h1, h2, h3, h4, h5, h6 { line-height: 1.3; }
+h1 { font-size: 1.5rem; }
+h2 { font-size: 1.25rem; }
+h3 { font-size: 1.1rem; }
+strong { font-weight: 600; }
+</style>
+</head>
+<body>
+<div class="page">
+HTMLEOF
+
+# Write sidebar
+cat <<HEADEREOF >> "$OUTPUT"
+<aside class="sidebar">
+ <dl class="session-meta">
+ <dt>Session</dt><dd><code>${META_SESSION_ID}</code></dd>
+ <dt>Claude Code</dt><dd>v${META_VERSION}</dd>
+ <dt>Directory</dt><dd><code>${META_CWD}</code></dd>
+ <dt>Branch</dt><dd>${META_BRANCH}</dd>
+ <dt>Started</dt><dd>${META_STARTED}</dd>
+ <dt>Ended</dt><dd>${META_ENDED}</dd>
+ <dt>Messages</dt><dd>${META_USER_COUNT} user · ${META_ASSISTANT_COUNT} assistant</dd>
+ <dt>Models</dt><dd>${META_MODELS}</dd>
+ </dl>
+ <div class="session-tools">Tools: ${TOOLS_HTML}</div>
+ <div class="session-summary">
+ <h2>Summary</h2>
+ <!-- Replace this with a summary of the session -->
+ </div>
+</aside>
+<main class="conversation">
+HEADEREOF
+
+# Write messages
+echo "$MESSAGES_HTML" >> "$OUTPUT"
+
+# Close HTML
+cat <<'HTMLEOF' >> "$OUTPUT"
+</main>
+</div>
+</body>
+</html>
+HTMLEOF
+
+if [[ "$OUTPUT" != "/dev/stdout" ]]; then
+ echo "Exported to $OUTPUT" >&2
+fi
diff --git a/ai/claude/skills/dump-session/scripts/dump-session.sh b/ai/claude/skills/dump-session/scripts/dump-session.sh
new file mode 100755
index 00000000..e517e95c
--- /dev/null
+++ b/ai/claude/skills/dump-session/scripts/dump-session.sh
@@ -0,0 +1,150 @@
+#!/usr/bin/env bash
+#
+# dump-session.sh — Export a Claude Code session JSONL to readable Markdown.
+#
+# Usage:
+# dump-session.sh <session.jsonl|session-id> [output.md]
+#
+# The first argument can be a path to a JSONL file or a session UUID.
+# When given a UUID, the script searches ~/.claude/projects/ for a matching file.
+# If output.md is omitted, writes to stdout.
+
+set -euo pipefail
+
+if [[ $# -lt 1 ]]; then
+ echo "Usage: dump-session.sh <session.jsonl|session-id> [output.md]" >&2
+ exit 1
+fi
+
+INPUT="$1"
+OUTPUT="${2:-/dev/stdout}"
+
+# Resolve input: file path or session UUID
+if [[ -f "$INPUT" ]]; then
+ SESSION_FILE="$INPUT"
+elif [[ "$INPUT" =~ ^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$ ]]; then
+ # Search all project dirs for a matching JSONL
+ SESSION_FILE=$(find ~/.claude/projects -name "${INPUT}.jsonl" -print -quit 2>/dev/null)
+ if [[ -z "$SESSION_FILE" ]]; then
+ echo "Error: No session file found for ID: $INPUT" >&2
+ exit 1
+ fi
+else
+ echo "Error: Not a file path or valid session UUID: $INPUT" >&2
+ exit 1
+fi
+
+# Single jq invocation: slurp all lines and produce the full markdown.
+jq -rs '
+ # Partition by type
+ (map(select(.type == "user"))) as $users |
+ (map(select(.type == "assistant"))) as $assistants |
+ (map(select(.type == "user" or .type == "assistant"))) as $messages |
+
+ # Metadata from first user message
+ ($users[0] // {}) as $first |
+ ($first.sessionId // "unknown") as $session_id |
+ ($first.version // "unknown") as $version |
+ ($first.cwd // "unknown") as $cwd |
+ ($first.gitBranch // "unknown") as $branch |
+
+ # Time range
+ ($messages | first | .timestamp // "") as $first_ts |
+ ($messages | last | .timestamp // "") as $last_ts |
+
+ # Models
+ ([$assistants[].message.model // empty] | unique | join(", ")) as $models |
+
+ # Tool usage counts
+ ([
+ $assistants[].message.content[]? |
+ select(type == "object" and .type == "tool_use") | .name
+ ] | group_by(.) | map({name: .[0], count: length}) | sort_by(-.count)) as $tools |
+
+ # Format tool list
+ ($tools | map("- \(.name) (\(.count))") | join("\n")) as $tool_list |
+
+ # Format conversation
+ ($messages | map(
+ if .type == "user" then
+ # Extract content
+ (.message.content |
+ if type == "string" then .
+ elif type == "array" then
+ [.[] |
+ if type == "string" then .
+ elif .type == "text" then .text
+ elif .type == "tool_result" then null
+ else null
+ end
+ ] | map(select(. != null)) | join("\n\n")
+ else ""
+ end
+ ) as $content |
+
+ # Check if tool_result-only
+ (.message.content |
+ if type == "array" then all(type == "object" and .type == "tool_result")
+ else false
+ end
+ ) as $is_tool_result_only |
+
+ # Check if system/command message
+ ($content | test("^<(command-name|local-command|system-reminder)")) as $is_system |
+
+ if $is_tool_result_only or $is_system or ($content | length) == 0 then
+ null
+ else
+ "### User <sub>\(.timestamp // "")</sub>\n\n\($content)\n"
+ end
+
+ elif .type == "assistant" then
+ # Text blocks
+ ([.message.content[]? | select(type == "object" and .type == "text") | .text] | join("\n\n")) as $text |
+
+ # Tool use summary
+ ([.message.content[]? | select(type == "object" and .type == "tool_use") |
+ "- **\(.name)**" +
+ if .input.command then " `\(.input.command | tostring | split("\n") | .[0])`"
+ elif .input.pattern then " `\(.input.pattern)`"
+ elif .input.file_path then " `\(.input.file_path)`"
+ else ""
+ end
+ ]) as $tool_lines |
+
+ if ($text | length) == 0 and ($tool_lines | length) == 0 then
+ null
+ else
+ "### Assistant <sub>\(.timestamp // "")</sub>\n\n" +
+ (if ($text | length) > 0 then $text + "\n\n" else "" end) +
+ (if ($tool_lines | length) > 0 then
+ "<details><summary>Tool calls</summary>\n\n" +
+ ($tool_lines | join("\n")) +
+ "\n\n</details>\n"
+ else "" end)
+ end
+ else null
+ end
+ ) | map(select(. != null)) | join("\n")) as $conversation |
+
+ # Assemble document
+ "# Session Export\n\n" +
+ "## Metadata\n\n" +
+ "| Field | Value |\n|-------|-------|\n" +
+ "| Session ID | `\($session_id)` |\n" +
+ "| Claude Code | v\($version) |\n" +
+ "| Working directory | `\($cwd)` |\n" +
+ "| Branch | `\($branch)` |\n" +
+ "| Started | \($first_ts) |\n" +
+ "| Ended | \($last_ts) |\n" +
+ "| User messages | \($users | length) |\n" +
+ "| Assistant messages | \($assistants | length) |\n" +
+ "| Models | \($models) |\n\n" +
+ "## Tools Used\n\n\($tool_list)\n\n---\n\n" +
+ "## Summary\n\n<!-- Replace this with a summary of the session -->\n\n---\n\n" +
+ "## Conversation\n\n\($conversation)\n"
+' "$SESSION_FILE" > "$OUTPUT"
+
+if [[ "$OUTPUT" != "/dev/stdout" ]]; then
+ echo "Exported to $OUTPUT" >&2
+fi