Add dump-session skill for exporting Claude Code sessions
Assisted-by: Claude Opus 4.6 via Claude Code
change lttpkoplrlklnpwsnmwvvtkmqnlvslvz
commit a8e7483317dd4650470fd76ca6db6c22767ed3d5
author Alpha Chen <alpha@kejadlen.dev>
date
parent wkpptwrl
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("<"; "&lt;") | gsub(">"; "&gt;")) + "</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