refactor: Extract shared JSONL parsing into parse-session.sh
Assisted-by: Claude Opus 4.6 via Claude Code
change tkqptqtvynsnuqwlxkkwzswvxnwxxwrp
commit cd3c1e2fbccabdead954a400b631fb21c781a023
author Alpha Chen <alpha@kejadlen.dev>
date
parent lttpkopl
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"