refactor: Extract shared JSONL parsing into parse-session.sh
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
index 21281fda..3a728f21 100644
--- a/ai/claude/skills/dump-session/SKILL.md
+++ b/ai/claude/skills/dump-session/SKILL.md
@@ -53,14 +53,11 @@ Use the bundled script to convert the JSONL to Markdown:
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:
+The script produces a Markdown document designed for GitHub PR descriptions:
-- 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
+- A summary placeholder at the top (visible without expanding)
+- The full conversation in a collapsible `<details>` block
+- Session metadata (IDs, timestamps, tools) in a second collapsible block
## Writing the Summary
@@ -129,7 +126,11 @@ 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`.
+- `scripts/parse-session.sh` — Parses a session JSONL file into structured
+ JSON (metadata, tool counts, filtered messages). Both formatters depend on
+ this script. Requires `jq` 1.6+.
+- `scripts/dump-session.sh` — Converts the intermediate JSON from
+ `parse-session.sh` into Markdown. Requires `jq` 1.6+.
+- `scripts/dump-session-html.sh` — Converts the intermediate JSON from
+ `parse-session.sh` into 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
index a2d200a6..8c9e8e70 100755
--- a/ai/claude/skills/dump-session/scripts/dump-session-html.sh
+++ b/ai/claude/skills/dump-session/scripts/dump-session-html.sh
@@ -18,114 +18,11 @@ if [[ $# -lt 1 ]]; then
exit 1
fi
-INPUT="$1"
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
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")
+# Parse session JSONL into structured JSON.
+JSON=$("$SCRIPT_DIR/parse-session.sh" "$1")
# Convert markdown content to HTML fragments via pandoc.
# Iterates over each message, converts content, and reassembles.
diff --git a/ai/claude/skills/dump-session/scripts/dump-session.sh b/ai/claude/skills/dump-session/scripts/dump-session.sh
index e517e95c..2af63518 100755
--- a/ai/claude/skills/dump-session/scripts/dump-session.sh
+++ b/ai/claude/skills/dump-session/scripts/dump-session.sh
@@ -16,134 +16,56 @@ if [[ $# -lt 1 ]]; then
exit 1
fi
-INPUT="$1"
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
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 |
-
+# Parse session JSONL into structured JSON, then format as Markdown.
+"$SCRIPT_DIR/parse-session.sh" "$1" | jq -r '
# 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") |
+ (.messages | map(
+ if .role == "user" then
+ "### User <sub>\(.timestamp)</sub>\n\n\(.content)\n"
+ elif .role == "assistant" then
+ ([.tools[] |
"- **\(.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)`"
+ if .detail then " `\(.detail)`"
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
+ "### Assistant <sub>\(.timestamp)</sub>\n\n" +
+ (if (.content | length) > 0 then .content + "\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)
+ else ""
end
- ) | map(select(. != null)) | join("\n")) as $conversation |
+ ) | map(select(. != "")) | join("\n")) as $conversation |
- # Assemble document
- "# Session Export\n\n" +
- "## Metadata\n\n" +
+ # Format tool list
+ (.tools | map("\(.name) (\(.count))") | join(" · ")) as $tool_list |
+
+ # Assemble document — designed for GitHub PR descriptions.
+ # Summary first (visible without expanding), conversation and metadata collapsed.
+ "<!-- Replace this with a summary of the session -->\n\n" +
+ "<details><summary>Session transcript</summary>\n\n" +
+ $conversation + "\n\n" +
+ "</details>\n\n" +
+ "<details><summary>Session metadata</summary>\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"
+ "| Session ID | `\(.meta.session_id)` |\n" +
+ "| Claude Code | v\(.meta.version) |\n" +
+ "| Directory | `\(.meta.cwd)` |\n" +
+ "| Branch | `\(.meta.branch)` |\n" +
+ "| Started | \(.meta.started) |\n" +
+ "| Ended | \(.meta.ended) |\n" +
+ "| Messages | \(.meta.user_count) user · \(.meta.assistant_count) assistant |\n" +
+ "| Models | \(.meta.models) |\n" +
+ "| Tools | \($tool_list) |\n\n" +
+ "</details>\n"
+' > "$OUTPUT"
if [[ "$OUTPUT" != "/dev/stdout" ]]; then
echo "Exported to $OUTPUT" >&2
diff --git a/ai/claude/skills/dump-session/scripts/parse-session.sh b/ai/claude/skills/dump-session/scripts/parse-session.sh
new file mode 100755
index 00000000..10ae5edd
--- /dev/null
+++ b/ai/claude/skills/dump-session/scripts/parse-session.sh
@@ -0,0 +1,126 @@
+#!/usr/bin/env bash
+#
+# parse-session.sh — Parse a Claude Code session JSONL into structured JSON.
+#
+# Usage:
+# parse-session.sh <session.jsonl|session-id> [output.json]
+#
+# 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.json is omitted, writes to stdout.
+#
+# Requires: jq 1.6+
+
+set -euo pipefail
+
+if [[ $# -lt 1 ]]; then
+ echo "Usage: parse-session.sh <session.jsonl|session-id> [output.json]" >&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
+
+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 |
+
+ ($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,
+ kind: "message"}
+ 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" > "$OUTPUT"