cc-session 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694
  1. #!/usr/bin/env bash
  2. # cc-session: Analyze Claude Code session JSONL logs
  3. # Zero dependencies beyond jq (required) and standard coreutils.
  4. #
  5. # Usage:
  6. # cc-session <command> [session.jsonl] [options]
  7. # cc-session <command> --project <project-name> [options]
  8. # cc-session <command> --dir <directory-pattern> [options]
  9. #
  10. # If no file given, uses the most recent session in the current project.
  11. set -euo pipefail
  12. CLAUDE_DIR="${CLAUDE_DIR:-$HOME/.claude}"
  13. PROJECTS_DIR="$CLAUDE_DIR/projects"
  14. # --- Formatting ---
  15. # Colors (disabled if not a terminal or NO_COLOR is set)
  16. if [[ -t 1 && -z "${NO_COLOR:-}" ]]; then
  17. C_RESET='\033[0m'
  18. C_BOLD='\033[1m'
  19. C_DIM='\033[2m'
  20. C_CYAN='\033[36m'
  21. C_GREEN='\033[32m'
  22. C_YELLOW='\033[33m'
  23. C_BLUE='\033[34m'
  24. C_MAGENTA='\033[35m'
  25. C_RED='\033[31m'
  26. C_WHITE='\033[37m'
  27. else
  28. C_RESET='' C_BOLD='' C_DIM='' C_CYAN='' C_GREEN=''
  29. C_YELLOW='' C_BLUE='' C_MAGENTA='' C_RED='' C_WHITE=''
  30. fi
  31. header() { printf "${C_BOLD}${C_CYAN}%s${C_RESET}\n" "$1"; }
  32. subhead() { printf "\n${C_BOLD}%s${C_RESET}\n" "$1"; }
  33. label() { printf " ${C_DIM}%-16s${C_RESET} %s\n" "$1" "$2"; }
  34. divider() { printf "${C_DIM}%s${C_RESET}\n" "$(printf '%.0s-' {1..50})"; }
  35. bar() {
  36. # Usage: bar <value> <max> <width>
  37. local val=${1:-0} max=${2:-1} width=${3:-20}
  38. [[ $max -le 0 ]] && max=1
  39. local filled=$(( val * width / max ))
  40. [[ $filled -gt $width ]] && filled=$width
  41. local empty=$(( width - filled ))
  42. local filled_str="" empty_str=""
  43. local i
  44. for (( i=0; i<filled; i++ )); do filled_str+="█"; done
  45. for (( i=0; i<empty; i++ )); do empty_str+="░"; done
  46. printf "${C_GREEN}%s${C_DIM}%s${C_RESET}" "$filled_str" "$empty_str"
  47. }
  48. # --- Helpers ---
  49. die() { printf "${C_RED}error:${C_RESET} %s\n" "$1" >&2; exit 1; }
  50. usage() {
  51. cat <<'USAGE'
  52. cc-session - Claude Code session log analyzer
  53. COMMANDS:
  54. sessions List recent sessions with size and age
  55. overview Entry type counts, duration, model info
  56. tools Tool usage frequency (sorted)
  57. tool-chain Sequential tool call trace with timing
  58. thinking Extract thinking/reasoning blocks
  59. thinking-summary First 200 chars of each thinking block
  60. errors Tool results containing errors
  61. conversation Reconstructed user/assistant turns
  62. files Files read, edited, or written
  63. turns Per-turn breakdown (duration, tools, tokens)
  64. agents Subagent spawns and their tool usage
  65. search <pattern> Search across sessions (user + assistant text)
  66. cost Rough token/cost estimation
  67. timeline Event timeline with gaps highlighted
  68. summary Session summaries (compaction boundaries)
  69. OPTIONS:
  70. --project, -p <name> Filter by project name (partial match)
  71. --dir, -d <pattern> Filter by directory pattern in project path
  72. --all Search all projects (with search command)
  73. --recent <n> Use nth most recent session (default: 1)
  74. --json Output as JSON instead of text
  75. EXAMPLES:
  76. cc-session overview # Current project, latest session
  77. cc-session tools # Tool frequency
  78. cc-session tools --recent 2 # Second most recent session
  79. cc-session search "auth" --all # Search all projects
  80. cc-session errors -p claude-mods # Errors in claude-mods project
  81. cc-session tool-chain # Full tool call sequence
  82. cc-session thinking | grep -i "decision" # Search reasoning
  83. cc-session turns --json | jq '.[] | select(.tools > 5)'
  84. USAGE
  85. exit 0
  86. }
  87. # Resolve project directory from current working directory
  88. resolve_project() {
  89. local project_filter="${1:-}"
  90. local candidates=()
  91. if [[ -n "$project_filter" ]]; then
  92. # Find by partial match - collect all matches
  93. while IFS= read -r d; do
  94. candidates+=("$d")
  95. done < <(ls "$PROJECTS_DIR" 2>/dev/null | grep -i "$project_filter")
  96. else
  97. # Derive from cwd
  98. local encoded
  99. encoded=$(pwd | sed 's/[:\\\/]/-/g' | sed 's/--*/-/g')
  100. while IFS= read -r d; do
  101. candidates+=("$d")
  102. done < <(ls "$PROJECTS_DIR" 2>/dev/null | grep -i "${encoded##*-}")
  103. fi
  104. [[ ${#candidates[@]} -gt 0 ]] || die "No project matching '${project_filter:-$(pwd)}'"
  105. # Pick the candidate that actually has JSONL files, preferring most recent
  106. for candidate in "${candidates[@]}"; do
  107. local dir="$PROJECTS_DIR/$candidate"
  108. # Check if directory contains any .jsonl files (not just subdirs)
  109. if ls "$dir"/*.jsonl &>/dev/null; then
  110. echo "$dir"
  111. return
  112. fi
  113. done
  114. # Fallback: return first match even without JSONL files
  115. echo "$PROJECTS_DIR/${candidates[0]}"
  116. }
  117. # Resolve session file
  118. resolve_session() {
  119. local project_dir="$1"
  120. local recent="${2:-1}"
  121. # Use ls on directory then filter - portable across Git Bash / macOS / Linux
  122. ls -t "$project_dir/" 2>/dev/null | grep '\.jsonl$' | grep -v '^agent-' | sed -n "${recent}p" |
  123. while read -r f; do echo "$project_dir/$f"; done
  124. }
  125. # --- Commands ---
  126. cmd_sessions() {
  127. local project_dir="$1" json="${2:-}"
  128. if [[ "$json" == "json" ]]; then
  129. ls -t "$project_dir/" 2>/dev/null | grep '\.jsonl$' | grep -v '^agent-' | while read -r fname; do
  130. local fpath="$project_dir/$fname"
  131. local session_id="${fname%.jsonl}"
  132. local size
  133. size=$(wc -c < "$fpath" 2>/dev/null | tr -d ' ')
  134. local mod_date
  135. mod_date=$(date -r "$fpath" '+%Y-%m-%d %H:%M' 2>/dev/null || stat -c '%y' "$fpath" 2>/dev/null | cut -d. -f1)
  136. printf '{"session":"%s","size_bytes":%s,"modified":"%s"}\n' "$session_id" "$size" "$mod_date"
  137. done
  138. else
  139. header "Sessions"
  140. echo ""
  141. printf " ${C_DIM}%-4s %-38s %6s %s${C_RESET}\n" "#" "Session ID" "Size" "Last Modified"
  142. divider
  143. local n=0
  144. ls -t "$project_dir/" 2>/dev/null | grep '\.jsonl$' | grep -v '^agent-' | while read -r fname; do
  145. n=$((n + 1))
  146. local fpath="$project_dir/$fname"
  147. local session_id="${fname%.jsonl}"
  148. # Human-readable size
  149. local bytes
  150. bytes=$(wc -c < "$fpath" 2>/dev/null | tr -d ' ')
  151. local size_h
  152. if [[ $bytes -ge 1048576 ]]; then
  153. size_h="$(( bytes / 1048576 ))M"
  154. elif [[ $bytes -ge 1024 ]]; then
  155. size_h="$(( bytes / 1024 ))K"
  156. else
  157. size_h="${bytes}B"
  158. fi
  159. local mod_date
  160. mod_date=$(date -r "$fpath" '+%Y-%m-%d %H:%M' 2>/dev/null || stat -c '%y' "$fpath" 2>/dev/null | cut -d. -f1)
  161. if [[ $n -eq 1 ]]; then
  162. printf " ${C_GREEN}%-4s${C_RESET} ${C_BOLD}%-38s${C_RESET} %6s %s ${C_GREEN}(latest)${C_RESET}\n" "$n" "$session_id" "$size_h" "$mod_date"
  163. else
  164. printf " ${C_DIM}%-4s${C_RESET} %-38s %6s %s\n" "$n" "$session_id" "$size_h" "$mod_date"
  165. fi
  166. done
  167. echo ""
  168. printf " ${C_DIM}Use --recent <n> to select a session, e.g.: cc-session overview --recent 3${C_RESET}\n"
  169. printf " ${C_DIM}Resume with: claude --resume <full-session-id>${C_RESET}\n"
  170. fi
  171. }
  172. cmd_overview() {
  173. local f="$1" json="${2:-}"
  174. if [[ "$json" == "json" ]]; then
  175. cat "$f" | jq -sc '{
  176. file: input_filename,
  177. entries: length,
  178. types: (group_by(.type) | map({type: .[0].type, count: length})),
  179. first_ts: (map(.timestamp | select(.) | strings) | sort | first),
  180. last_ts: (map(.timestamp | select(.) | strings) | sort | last),
  181. duration_ms: (
  182. [.[] | select(.type == "system" and .subtype == "turn_duration") | .durationMs] | add
  183. ),
  184. turns: ([.[] | select(.type == "system" and .subtype == "turn_duration")] | length),
  185. tool_calls: ([.[] | select(.type == "assistant") | .message.content[]? | select(.type == "tool_use")] | length),
  186. thinking_blocks: ([.[] | select(.type == "assistant") | .message.content[]? | select(.type == "thinking")] | length),
  187. user_messages: ([.[] | select(.type == "user") | .message.content[]? | select(.type == "text")] | length)
  188. }' 2>/dev/null
  189. else
  190. # Gather all stats in one jq pass
  191. local stats
  192. stats=$(cat "$f" | jq -rsc '
  193. ([.[] | select(.type == "system" and .subtype == "turn_duration") | .durationMs] | add // 0) as $total_ms |
  194. ([.[] | select(.type == "system" and .subtype == "turn_duration")] | length) as $turns |
  195. ([.[] | select(.type == "user") | .message.content[]? | select(.type == "text")] | length) as $user_msgs |
  196. ([.[] | select(.type == "assistant") | .message.content[]? | select(.type == "tool_use")] | length) as $tools |
  197. ([.[] | select(.type == "assistant") | .message.content[]? | select(.type == "thinking")] | length) as $thinking |
  198. ([.[] | select(.type == "assistant") | .message.content[]? | select(.type == "tool_use") | .name] |
  199. group_by(.) | map({t: .[0], n: length}) | sort_by(-.n) | .[:5]) as $top_tools |
  200. (map(.timestamp | select(.) | strings) | sort) as $ts |
  201. ([.[] | select(.type == "user") | .message.content[]? | select(.type == "text") | .text | length] | add // 0) as $user_chars |
  202. ([.[] | select(.type == "assistant") | .message.content[]? | select(.type == "text") | .text | length] | add // 0) as $asst_chars |
  203. ([.[] | select(.type == "assistant") | .message.content[]? | select(.type == "thinking") | .thinking | length] | add // 0) as $think_chars |
  204. (($user_chars + $asst_chars + $think_chars) / 4 | floor) as $est_tokens |
  205. {
  206. total_ms: $total_ms,
  207. total_s: ($total_ms / 1000 | floor),
  208. mins: ($total_ms / 60000 | floor),
  209. secs: (($total_ms / 1000 | floor) % 60),
  210. turns: $turns,
  211. avg_s: (if $turns > 0 then ($total_ms / $turns / 1000 | floor) else 0 end),
  212. user_msgs: $user_msgs,
  213. tools: $tools,
  214. thinking: $thinking,
  215. top_tools: $top_tools,
  216. entries: length,
  217. first_ts: ($ts | first // "unknown"),
  218. last_ts: ($ts | last // "unknown"),
  219. est_tokens_k: (($est_tokens / 1000 * 10 | floor) / 10)
  220. } | @json
  221. ' 2>/dev/null)
  222. local session_id
  223. session_id=$(basename "$f" .jsonl)
  224. header "Session Overview"
  225. echo ""
  226. label "Session" "$session_id"
  227. label "Resume" "claude --resume $session_id"
  228. label "Started" "$(echo "$stats" | jq -r '.first_ts | .[0:19] | gsub("T"; " ")')"
  229. label "Ended" "$(echo "$stats" | jq -r '.last_ts | .[0:19] | gsub("T"; " ")')"
  230. label "Entries" "$(echo "$stats" | jq -r '.entries')"
  231. subhead "Timing"
  232. divider
  233. local mins secs turns avg
  234. mins=$(echo "$stats" | jq -r '.mins')
  235. secs=$(echo "$stats" | jq -r '.secs')
  236. turns=$(echo "$stats" | jq -r '.turns')
  237. avg=$(echo "$stats" | jq -r '.avg_s')
  238. label "Duration" "${mins}m ${secs}s"
  239. label "Turns" "$turns"
  240. label "Avg turn" "${avg}s"
  241. subhead "Activity"
  242. divider
  243. local user_msgs tools thinking
  244. user_msgs=$(echo "$stats" | jq -r '.user_msgs')
  245. tools=$(echo "$stats" | jq -r '.tools')
  246. thinking=$(echo "$stats" | jq -r '.thinking')
  247. local est_tokens
  248. est_tokens=$(echo "$stats" | jq -r '.est_tokens_k')
  249. label "User messages" "$user_msgs"
  250. label "Tool calls" "$tools"
  251. label "Thinking" "$thinking blocks"
  252. label "Est. tokens" "${est_tokens}k"
  253. subhead "Top Tools"
  254. divider
  255. echo "$stats" | jq -r '.top_tools[] | "\(.t)\t\(.n)"' | tr -d '\r' | while IFS=$'\t' read -r tool count; do
  256. printf " ${C_YELLOW}%-16s${C_RESET} %3s " "$tool" "$count"
  257. bar "$count" "$tools" 20
  258. echo ""
  259. done
  260. fi
  261. }
  262. cmd_tools() {
  263. local f="$1" json="${2:-}"
  264. if [[ "$json" == "json" ]]; then
  265. cat "$f" | jq -sc '[
  266. .[] | select(.type == "assistant") | .message.content[]? |
  267. select(.type == "tool_use") | .name
  268. ] | group_by(.) | map({tool: .[0], count: length}) | sort_by(-.count)' 2>/dev/null
  269. else
  270. header "Tool Usage"
  271. echo ""
  272. local total
  273. total=$(cat "$f" | jq -r 'select(.type == "assistant") | .message.content[]? | select(.type == "tool_use") | .name' | wc -l)
  274. cat "$f" | jq -r 'select(.type == "assistant") | .message.content[]? | select(.type == "tool_use") | .name' |
  275. sort | uniq -c | sort -rn | tr -d '\r' | while read -r count tool; do
  276. printf " ${C_YELLOW}%-16s${C_RESET} %3s " "$tool" "$count"
  277. bar "$count" "$total" 25
  278. echo ""
  279. done
  280. divider
  281. label "Total" "$total calls"
  282. fi
  283. }
  284. cmd_tool_chain() {
  285. local f="$1" json="${2:-}"
  286. if [[ "$json" == "json" ]]; then
  287. cat "$f" | jq -c '
  288. select(.type == "assistant") | .message.content[]? |
  289. select(.type == "tool_use") |
  290. {name, id, input_summary: (
  291. if .name == "Bash" then (.input.command | .[0:120])
  292. elif .name == "Read" then .input.file_path
  293. elif .name == "Write" then .input.file_path
  294. elif .name == "Edit" then .input.file_path
  295. elif .name == "Grep" then "\(.input.pattern) in \(.input.path // ".")"
  296. elif .name == "Glob" then .input.pattern
  297. elif .name == "Agent" then "\(.input.subagent_type // "general"): \(.input.description // "")"
  298. elif .name == "Skill" then .input.skill
  299. elif .name == "WebSearch" then .input.query
  300. elif .name == "WebFetch" then .input.url
  301. else (.input | tostring | .[0:100])
  302. end
  303. )}
  304. ' 2>/dev/null
  305. else
  306. cat "$f" | jq -r '
  307. select(.type == "assistant") | .message.content[]? |
  308. select(.type == "tool_use") |
  309. "\(.name | . + " " * (15 - length)) \(
  310. if .name == "Bash" then (.input.command | .[0:100])
  311. elif .name == "Read" then .input.file_path
  312. elif .name == "Write" then .input.file_path
  313. elif .name == "Edit" then .input.file_path
  314. elif .name == "Grep" then "pattern=\(.input.pattern) path=\(.input.path // ".")"
  315. elif .name == "Glob" then .input.pattern
  316. elif .name == "Agent" then "\(.input.subagent_type // "general"): \(.input.description // "")"
  317. elif .name == "Skill" then .input.skill
  318. elif .name == "WebSearch" then .input.query
  319. elif .name == "WebFetch" then .input.url
  320. else (.input | tostring | .[0:80])
  321. end
  322. )"
  323. ' 2>/dev/null
  324. fi
  325. }
  326. cmd_thinking() {
  327. local f="$1"
  328. cat "$f" | jq -r '
  329. select(.type == "assistant") | .message.content[]? |
  330. select(.type == "thinking") | .thinking
  331. ' 2>/dev/null
  332. }
  333. cmd_thinking_summary() {
  334. local f="$1" json="${2:-}"
  335. if [[ "$json" == "json" ]]; then
  336. cat "$f" | jq -sc '[
  337. .[] | select(.type == "assistant") | .message.content[]? |
  338. select(.type == "thinking") | {preview: (.thinking | .[0:200])}
  339. ]' 2>/dev/null
  340. else
  341. local n=0
  342. cat "$f" | jq -r '
  343. select(.type == "assistant") | .message.content[]? |
  344. select(.type == "thinking") | .thinking | .[0:200] | gsub("\n"; " ")
  345. ' 2>/dev/null | while read -r line; do
  346. n=$((n + 1))
  347. printf "[%d] %s...\n\n" "$n" "$line"
  348. done
  349. fi
  350. }
  351. cmd_errors() {
  352. local f="$1" json="${2:-}"
  353. if [[ "$json" == "json" ]]; then
  354. cat "$f" | jq -c '
  355. select(.type == "user") | .message.content[]? |
  356. select(.type == "tool_result") |
  357. select(.content | type == "string" and test("error|Error|ERROR|failed|Failed|FAILED")) |
  358. {tool_use_id, error: (.content | .[0:300])}
  359. ' 2>/dev/null
  360. else
  361. cat "$f" | jq -r '
  362. select(.type == "user") | .message.content[]? |
  363. select(.type == "tool_result") |
  364. select(.content | type == "string" and test("error|Error|ERROR|failed|Failed|FAILED")) |
  365. "--- tool_use_id: \(.tool_use_id) ---\n\(.content | .[0:300])\n"
  366. ' 2>/dev/null
  367. fi
  368. }
  369. cmd_conversation() {
  370. local f="$1"
  371. cat "$f" | jq -r '
  372. if .type == "user" then
  373. .message.content[]? | select(.type == "text") | "USER: \(.text)"
  374. elif .type == "assistant" then
  375. .message.content[]? | select(.type == "text") | "CLAUDE: \(.text | .[0:500])"
  376. else empty end
  377. ' 2>/dev/null
  378. }
  379. cmd_files() {
  380. local f="$1" json="${2:-}"
  381. if [[ "$json" == "json" ]]; then
  382. cat "$f" | jq -sc '{
  383. read: [.[] | select(.type == "assistant") | .message.content[]? | select(.type == "tool_use" and .name == "Read") | .input.file_path] | group_by(.) | map({file: .[0], count: length}) | sort_by(-.count),
  384. edited: [.[] | select(.type == "assistant") | .message.content[]? | select(.type == "tool_use" and .name == "Edit") | .input.file_path] | group_by(.) | map({file: .[0], count: length}) | sort_by(-.count),
  385. written: [.[] | select(.type == "assistant") | .message.content[]? | select(.type == "tool_use" and .name == "Write") | .input.file_path] | unique
  386. }' 2>/dev/null
  387. else
  388. header "Files Touched"
  389. subhead "Read"
  390. divider
  391. cat "$f" | jq -r 'select(.type == "assistant") | .message.content[]? | select(.type == "tool_use" and .name == "Read") | .input.file_path' |
  392. sort | uniq -c | sort -rn | while read -r count path; do
  393. local short="${path##*/}"
  394. printf " ${C_DIM}%3s${C_RESET} ${C_BLUE}%s${C_RESET}\n" "$count" "$short"
  395. done
  396. subhead "Edited"
  397. divider
  398. cat "$f" | jq -r 'select(.type == "assistant") | .message.content[]? | select(.type == "tool_use" and .name == "Edit") | .input.file_path' |
  399. sort | uniq -c | sort -rn | while read -r count path; do
  400. local short="${path##*/}"
  401. printf " ${C_DIM}%3s${C_RESET} ${C_YELLOW}%s${C_RESET}\n" "$count" "$short"
  402. done
  403. subhead "Written (new files)"
  404. divider
  405. cat "$f" | jq -r 'select(.type == "assistant") | .message.content[]? | select(.type == "tool_use" and .name == "Write") | .input.file_path' |
  406. sort -u | while read -r path; do
  407. local short="${path##*/}"
  408. printf " ${C_GREEN}+ %s${C_RESET}\n" "$short"
  409. done
  410. fi
  411. }
  412. cmd_turns() {
  413. local f="$1" json="${2:-}"
  414. cat "$f" | jq -sc '
  415. [.[] | select(.type == "system" and .subtype == "turn_duration")] as $durations |
  416. [.[] | select(.type == "assistant")] as $assistants |
  417. [range(0; [$durations | length, $assistants | length] | min) |
  418. {
  419. turn: (. + 1),
  420. duration_s: ($durations[.].durationMs / 1000 | floor),
  421. tools: ([$assistants[.].message.content[]? | select(.type == "tool_use") | .name] | length),
  422. tool_names: ([$assistants[.].message.content[]? | select(.type == "tool_use") | .name]),
  423. has_thinking: ([$assistants[.].message.content[]? | select(.type == "thinking")] | length > 0),
  424. text_length: ([$assistants[.].message.content[]? | select(.type == "text") | .text | length] | add // 0)
  425. }
  426. ]
  427. ' 2>/dev/null | if [[ "$json" == "json" ]]; then cat; else
  428. local max_dur
  429. max_dur=$(jq '[.[].duration_s] | max // 1' <<< "$(cat)")
  430. # Re-read from the pipe above (need to re-run since we consumed stdin)
  431. cat "$f" | jq -sc '
  432. [.[] | select(.type == "system" and .subtype == "turn_duration")] as $durations |
  433. [.[] | select(.type == "assistant")] as $assistants |
  434. ([.[] | select(.type == "system" and .subtype == "turn_duration") | .durationMs] | max // 1000) as $max_ms |
  435. [range(0; [$durations | length, $assistants | length] | min) |
  436. {
  437. turn: (. + 1),
  438. duration_s: ($durations[.].durationMs / 1000 | floor),
  439. max_s: ($max_ms / 1000 | floor),
  440. tools: ([$assistants[.].message.content[]? | select(.type == "tool_use") | .name] | length),
  441. tool_names: ([$assistants[.].message.content[]? | select(.type == "tool_use") | .name]),
  442. has_thinking: ([$assistants[.].message.content[]? | select(.type == "thinking")] | length > 0)
  443. }
  444. ]
  445. ' 2>/dev/null | jq -r '.[] | "\(.turn)\t\(.duration_s)\t\(.max_s)\t\(.tools)\t\(.tool_names | join(", ") | if . == "" then "-" else . end)\t\(.has_thinking)"' | tr -d '\r' |
  446. {
  447. header "Turns"
  448. echo ""
  449. printf " ${C_DIM}%-6s %-8s %-6s %-8s %s${C_RESET}\n" "Turn" "Time" "Tools" "" "Details"
  450. divider
  451. while IFS=$'\t' read -r turn dur max_s tools tool_names thinking; do
  452. thinking=$(echo "$thinking" | tr -d '\r ')
  453. local think_flag=""
  454. [[ "$thinking" == "true" ]] && think_flag=" ${C_MAGENTA}[T]${C_RESET}"
  455. printf " ${C_BOLD}%-6s${C_RESET} %5ss " "#$turn" "$dur"
  456. bar "$dur" "$max_s" 12
  457. printf " %2s ${C_DIM}%s${C_RESET}%b\n" "$tools" "$tool_names" "$think_flag"
  458. done
  459. }
  460. fi
  461. }
  462. cmd_agents() {
  463. local f="$1" json="${2:-}"
  464. if [[ "$json" == "json" ]]; then
  465. cat "$f" | jq -c '
  466. select(.type == "assistant") | .message.content[]? |
  467. select(.type == "tool_use" and .name == "Agent") |
  468. {
  469. id,
  470. subagent_type: (.input.subagent_type // "general-purpose"),
  471. description: .input.description,
  472. prompt_preview: (.input.prompt | .[0:200]),
  473. background: (.input.run_in_background // false),
  474. isolation: (.input.isolation // null)
  475. }
  476. ' 2>/dev/null
  477. else
  478. cat "$f" | jq -r '
  479. select(.type == "assistant") | .message.content[]? |
  480. select(.type == "tool_use" and .name == "Agent") |
  481. "[\(.input.subagent_type // "general")] \(.input.description // "no description")\n prompt: \(.input.prompt | .[0:150] | gsub("\n"; " "))...\n"
  482. ' 2>/dev/null
  483. fi
  484. }
  485. cmd_search() {
  486. local pattern="$1"
  487. shift
  488. local search_dir="$1"
  489. rg -l "$pattern" "$search_dir"/*.jsonl 2>/dev/null | while read -r f; do
  490. local session
  491. session=$(basename "$f" .jsonl)
  492. echo "=== $session ==="
  493. cat "$f" | jq -r "
  494. if .type == \"user\" then
  495. .message.content[]? | select(.type == \"text\") | .text
  496. elif .type == \"assistant\" then
  497. .message.content[]? | select(.type == \"text\") | .text
  498. else empty end
  499. " 2>/dev/null | grep -i --color=auto -C2 "$pattern" || true
  500. echo ""
  501. done
  502. }
  503. cmd_cost() {
  504. local f="$1" json="${2:-}"
  505. cat "$f" | jq -sc '
  506. ([.[] | select(.type == "user") | .message.content[]? | select(.type == "text") | .text | length] | add // 0) as $user_chars |
  507. ([.[] | select(.type == "assistant") | .message.content[]? | select(.type == "text") | .text | length] | add // 0) as $asst_chars |
  508. ([.[] | select(.type == "assistant") | .message.content[]? | select(.type == "thinking") | .thinking | length] | add // 0) as $think_chars |
  509. ($user_chars + $asst_chars + $think_chars) as $total_chars |
  510. ($total_chars / 4 | floor) as $est_tokens |
  511. {
  512. user_chars: $user_chars,
  513. assistant_chars: $asst_chars,
  514. thinking_chars: $think_chars,
  515. total_chars: $total_chars,
  516. est_tokens: $est_tokens,
  517. est_tokens_k: (($est_tokens / 1000 * 10 | floor) / 10)
  518. }
  519. ' 2>/dev/null | if [[ "$json" == "json" ]]; then cat; else
  520. jq -r '.' | {
  521. header "Cost Estimate"
  522. echo ""
  523. local data
  524. data=$(cat)
  525. local est_k user asst think total
  526. est_k=$(echo "$data" | jq -r '.est_tokens_k')
  527. user=$(echo "$data" | jq -r '.user_chars')
  528. asst=$(echo "$data" | jq -r '.assistant_chars')
  529. think=$(echo "$data" | jq -r '.thinking_chars')
  530. total=$(echo "$data" | jq -r '.total_chars')
  531. label "Est. tokens" "${C_BOLD}${est_k}k${C_RESET}"
  532. label "User" "${user} chars"
  533. label "Assistant" "${asst} chars"
  534. label "Thinking" "${think} chars"
  535. divider
  536. label "Total chars" "$total"
  537. }
  538. fi
  539. }
  540. cmd_timeline() {
  541. local f="$1"
  542. cat "$f" | jq -r '
  543. select(.type == "user" or .type == "assistant") |
  544. select(.timestamp) |
  545. "\(.timestamp) \(.type | . + " " * (12 - length)) \(
  546. if .type == "user" then
  547. (.message.content[]? | select(.type == "text") | .text | .[0:80] | gsub("\n"; " ")) // "[tool_result]"
  548. else
  549. (.message.content[]? |
  550. if .type == "tool_use" then "[\(.name)] \(
  551. if .name == "Bash" then (.input.command | .[0:60])
  552. elif .name == "Read" then .input.file_path
  553. elif .name == "Edit" then .input.file_path
  554. elif .name == "Write" then .input.file_path
  555. else (.input | tostring | .[0:60])
  556. end
  557. )"
  558. elif .type == "text" then .text | .[0:80] | gsub("\n"; " ")
  559. elif .type == "thinking" then "[thinking...]"
  560. else empty
  561. end
  562. ) // ""
  563. end
  564. )"
  565. ' 2>/dev/null
  566. }
  567. cmd_summary() {
  568. local f="$1"
  569. cat "$f" | jq -r '
  570. select(.type == "summary" or (.type == "system" and .subtype == "compact_boundary")) |
  571. if .type == "summary" then
  572. "=== Summary ===\n\(.summary)\n"
  573. else
  574. "--- Compaction Boundary ---"
  575. end
  576. ' 2>/dev/null
  577. }
  578. # --- Main ---
  579. [[ $# -eq 0 ]] && usage
  580. cmd="$1"
  581. shift
  582. # Parse options
  583. session_file=""
  584. project=""
  585. recent=1
  586. output="text"
  587. search_all=false
  588. dir_pattern=""
  589. while [[ $# -gt 0 ]]; do
  590. case "$1" in
  591. --project|-p) project="$2"; shift 2 ;;
  592. --dir|-d) dir_pattern="$2"; shift 2 ;;
  593. --recent) recent="$2"; shift 2 ;;
  594. --json) output="json"; shift ;;
  595. --all) search_all=true; shift ;;
  596. --help|-h) usage ;;
  597. *)
  598. if [[ -f "$1" ]]; then
  599. session_file="$1"
  600. elif [[ "$cmd" == "search" && -z "${search_pattern:-}" ]]; then
  601. search_pattern="$1"
  602. fi
  603. shift
  604. ;;
  605. esac
  606. done
  607. # Resolve session file if not given directly
  608. if [[ -z "$session_file" ]]; then
  609. project_dir=$(resolve_project "${project}${dir_pattern:+$dir_pattern}")
  610. session_file=$(resolve_session "$project_dir" "$recent")
  611. [[ -n "$session_file" ]] || die "No session files found in $project_dir"
  612. fi
  613. # Execute command
  614. case "$cmd" in
  615. sessions)
  616. project_dir=$(resolve_project "${project}${dir_pattern:+$dir_pattern}")
  617. cmd_sessions "$project_dir" "$output"
  618. exit 0
  619. ;;
  620. overview) cmd_overview "$session_file" "$output" ;;
  621. tools) cmd_tools "$session_file" "$output" ;;
  622. tool-chain) cmd_tool_chain "$session_file" "$output" ;;
  623. thinking) cmd_thinking "$session_file" ;;
  624. thinking-summary) cmd_thinking_summary "$session_file" "$output" ;;
  625. errors) cmd_errors "$session_file" "$output" ;;
  626. conversation) cmd_conversation "$session_file" ;;
  627. files) cmd_files "$session_file" "$output" ;;
  628. turns) cmd_turns "$session_file" "$output" ;;
  629. agents) cmd_agents "$session_file" "$output" ;;
  630. cost) cmd_cost "$session_file" "$output" ;;
  631. timeline) cmd_timeline "$session_file" ;;
  632. summary) cmd_summary "$session_file" ;;
  633. search)
  634. [[ -n "${search_pattern:-}" ]] || die "search requires a pattern"
  635. if [[ "$search_all" == true ]]; then
  636. for d in "$PROJECTS_DIR"/*/; do
  637. echo ">>> $(basename "$d")"
  638. cmd_search "$search_pattern" "$d"
  639. done
  640. else
  641. project_dir=$(resolve_project "${project}${dir_pattern:+$dir_pattern}")
  642. cmd_search "$search_pattern" "$project_dir"
  643. fi
  644. ;;
  645. *)
  646. die "Unknown command: $cmd. Run cc-session --help for usage."
  647. ;;
  648. esac