Fact-check (README positioning): - Template count: 120/123 → 108 (ground truth recount) - Ratio: 14× → 24× (19,000 ÷ 784 = 24.2×) - everything-cc stars: 31.9k → 45k+ (verified Feb 15) - Commands count: 20 → 23, hooks: 30 → 31 Added: - Grepai MCP documentation (semantic search, call graphs) - 3 hook templates (rtk-baseline, session-summary, session-summary-config) - 2 resource evaluations (system-prompts update, qmd token savings) Changed: - RTK documentation overhaul (v0.7.0 → v0.16.0, rtk-ai org) - Exports deprecated (kimi.pdf, notebooklm.pdf → deprecated/) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1335 lines
51 KiB
Bash
Executable file
1335 lines
51 KiB
Bash
Executable file
#!/bin/bash
|
|
# .claude/hooks/session-summary.sh
|
|
# Event: SessionEnd
|
|
# Auto-display comprehensive session summary when Claude Code session ends
|
|
# Inspired by Gemini CLI session summary feature
|
|
#
|
|
# v3 - Full Analytics + CLI Config
|
|
#
|
|
# Displays (configurable sections):
|
|
# - Session ID, name, git branch
|
|
# - Duration (wall time, active time, turns count, exit reason)
|
|
# - Tool calls breakdown with success/error rates
|
|
# - Error details (tool name + truncated message)
|
|
# - Files touched (read/edited/created with top edited files)
|
|
# - Features used (MCP servers, agents, skills, teams, plan mode)
|
|
# - Git diff summary (+/- lines, files changed)
|
|
# - Lines of code written (via Edit/Write)
|
|
# - Model usage (requests, tokens, cache hit rate)
|
|
# - Estimated cost (via ccusage or pricing table fallback)
|
|
# - RTK token savings (if RTK installed, delta from session start)
|
|
# - Conversation ratio (interactive vs auto turns, avg time/turn)
|
|
# - Thinking blocks count (off by default)
|
|
# - Context window estimate (off by default)
|
|
#
|
|
# Requirements:
|
|
# - jq (required for JSON parsing)
|
|
# - ccusage (optional, for accurate cost calculation)
|
|
# - rtk (optional, for token savings tracking)
|
|
#
|
|
# Configuration priority: env vars > config file > defaults
|
|
# Config file: ~/.config/session-summary/config.sh
|
|
# CLI tool: session-summary-config.sh (show, set, reset, sections, preview, install, log)
|
|
#
|
|
# Environment variables (SESSION_SUMMARY_*):
|
|
# SKIP=1 - Disable summary entirely
|
|
# LOG=<path> - Override log directory (default: ~/.claude/logs)
|
|
# FILES=0|1 - Files section (default: 1)
|
|
# RTK=auto|1|0 - RTK savings (default: auto-detect)
|
|
# GIT=0|1 - Git diff summary (default: 1)
|
|
# ERRORS=0|1 - Error details (default: 1)
|
|
# LOC=0|1 - Lines of code (default: 1)
|
|
# RATIO=0|1 - Conversation ratio (default: 1)
|
|
# FEATURES=0|1 - Features used (default: 1)
|
|
# THINKING=0|1 - Thinking blocks (default: 0)
|
|
# CONTEXT=0|1 - Context estimate (default: 0)
|
|
# SECTIONS=<csv> - Section order (comma-separated)
|
|
#
|
|
# Place in: .claude/hooks/session-summary.sh
|
|
# Register in: .claude/settings.json under SessionEnd event
|
|
# NOTE: Do NOT use Stop event - it fires after every assistant turn, not just at exit
|
|
|
|
set -euo pipefail
|
|
export LC_NUMERIC=C # Ensure consistent decimal separator (bc outputs '.' not ',')
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
# Configuration (priority: env vars > config file > defaults)
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
CONFIG_FILE="${HOME}/.config/session-summary/config.sh"
|
|
|
|
# Defaults
|
|
_DEFAULT_LOG_DIR="$HOME/.claude/logs"
|
|
_DEFAULT_SKIP=0
|
|
_DEFAULT_FILES=1
|
|
_DEFAULT_RTK=auto
|
|
_DEFAULT_GIT=1
|
|
_DEFAULT_ERRORS=1
|
|
_DEFAULT_LOC=1
|
|
_DEFAULT_RATIO=1
|
|
_DEFAULT_FEATURES=1
|
|
_DEFAULT_THINKING=0
|
|
_DEFAULT_CONTEXT=0
|
|
_DEFAULT_SECTIONS="meta,duration,tools,errors,files,features,git,loc,models,cache,cost,rtk,ratio,thinking,context"
|
|
|
|
# Load config file (if exists), then overlay env vars
|
|
load_config() {
|
|
# Start with defaults
|
|
LOG_DIR="$_DEFAULT_LOG_DIR"
|
|
SKIP="$_DEFAULT_SKIP"
|
|
FILES_ENABLED="$_DEFAULT_FILES"
|
|
RTK_ENABLED="$_DEFAULT_RTK"
|
|
GIT_ENABLED="$_DEFAULT_GIT"
|
|
ERRORS_ENABLED="$_DEFAULT_ERRORS"
|
|
LOC_ENABLED="$_DEFAULT_LOC"
|
|
RATIO_ENABLED="$_DEFAULT_RATIO"
|
|
FEATURES_ENABLED="$_DEFAULT_FEATURES"
|
|
THINKING_ENABLED="$_DEFAULT_THINKING"
|
|
CONTEXT_ENABLED="$_DEFAULT_CONTEXT"
|
|
SECTION_ORDER="$_DEFAULT_SECTIONS"
|
|
|
|
# Layer 2: config file
|
|
if [[ -f "$CONFIG_FILE" ]]; then
|
|
local val
|
|
val=$(bash -c "source '$CONFIG_FILE' 2>/dev/null && echo \"\${LOG_DIR:-}|\${SKIP:-}|\${FILES:-}|\${RTK:-}|\${GIT:-}|\${ERRORS:-}|\${LOC:-}|\${RATIO:-}|\${FEATURES:-}|\${THINKING:-}|\${CONTEXT:-}|\${SECTIONS:-}\"")
|
|
IFS='|' read -r _cf_log _cf_skip _cf_files _cf_rtk _cf_git _cf_errors _cf_loc _cf_ratio _cf_features _cf_thinking _cf_context _cf_sections <<< "$val"
|
|
[[ -n "$_cf_log" ]] && LOG_DIR="$_cf_log"
|
|
[[ -n "$_cf_skip" ]] && SKIP="$_cf_skip"
|
|
[[ -n "$_cf_files" ]] && FILES_ENABLED="$_cf_files"
|
|
[[ -n "$_cf_rtk" ]] && RTK_ENABLED="$_cf_rtk"
|
|
[[ -n "$_cf_git" ]] && GIT_ENABLED="$_cf_git"
|
|
[[ -n "$_cf_errors" ]] && ERRORS_ENABLED="$_cf_errors"
|
|
[[ -n "$_cf_loc" ]] && LOC_ENABLED="$_cf_loc"
|
|
[[ -n "$_cf_ratio" ]] && RATIO_ENABLED="$_cf_ratio"
|
|
[[ -n "$_cf_features" ]] && FEATURES_ENABLED="$_cf_features"
|
|
[[ -n "$_cf_thinking" ]] && THINKING_ENABLED="$_cf_thinking"
|
|
[[ -n "$_cf_context" ]] && CONTEXT_ENABLED="$_cf_context"
|
|
[[ -n "$_cf_sections" ]] && SECTION_ORDER="$_cf_sections"
|
|
fi
|
|
|
|
# Layer 3: env vars (highest priority)
|
|
[[ -n "${SESSION_SUMMARY_LOG:-}" ]] && LOG_DIR="$SESSION_SUMMARY_LOG"
|
|
[[ -n "${SESSION_SUMMARY_SKIP:-}" ]] && SKIP="$SESSION_SUMMARY_SKIP"
|
|
[[ -n "${SESSION_SUMMARY_FILES:-}" ]] && FILES_ENABLED="$SESSION_SUMMARY_FILES"
|
|
[[ -n "${SESSION_SUMMARY_RTK:-}" ]] && RTK_ENABLED="$SESSION_SUMMARY_RTK"
|
|
[[ -n "${SESSION_SUMMARY_GIT:-}" ]] && GIT_ENABLED="$SESSION_SUMMARY_GIT"
|
|
[[ -n "${SESSION_SUMMARY_ERRORS:-}" ]] && ERRORS_ENABLED="$SESSION_SUMMARY_ERRORS"
|
|
[[ -n "${SESSION_SUMMARY_LOC:-}" ]] && LOC_ENABLED="$SESSION_SUMMARY_LOC"
|
|
[[ -n "${SESSION_SUMMARY_RATIO:-}" ]] && RATIO_ENABLED="$SESSION_SUMMARY_RATIO"
|
|
[[ -n "${SESSION_SUMMARY_FEATURES:-}" ]] && FEATURES_ENABLED="$SESSION_SUMMARY_FEATURES"
|
|
[[ -n "${SESSION_SUMMARY_THINKING:-}" ]] && THINKING_ENABLED="$SESSION_SUMMARY_THINKING"
|
|
[[ -n "${SESSION_SUMMARY_CONTEXT:-}" ]] && CONTEXT_ENABLED="$SESSION_SUMMARY_CONTEXT"
|
|
[[ -n "${SESSION_SUMMARY_SECTIONS:-}" ]] && SECTION_ORDER="$SESSION_SUMMARY_SECTIONS"
|
|
|
|
# Auto-detect RTK availability
|
|
if [[ "$RTK_ENABLED" == "auto" ]]; then
|
|
command -v rtk &>/dev/null && RTK_ENABLED=1 || RTK_ENABLED=0
|
|
fi
|
|
}
|
|
|
|
load_config
|
|
|
|
# Section enablement check
|
|
# Always-on: meta, duration, tools, models, cache, cost
|
|
# Configurable: files, git, errors, loc, rtk, ratio, features (default ON)
|
|
# Configurable: thinking, context (default OFF)
|
|
is_section_enabled() {
|
|
local section="$1"
|
|
case "$section" in
|
|
meta|duration|tools|models|cache|cost) return 0 ;; # always on
|
|
files) [[ "$FILES_ENABLED" == "1" ]] ;;
|
|
git) [[ "$GIT_ENABLED" == "1" ]] ;;
|
|
errors) [[ "$ERRORS_ENABLED" == "1" ]] ;;
|
|
loc) [[ "$LOC_ENABLED" == "1" ]] ;;
|
|
rtk) [[ "$RTK_ENABLED" == "1" ]] ;;
|
|
ratio) [[ "$RATIO_ENABLED" == "1" ]] ;;
|
|
features) [[ "$FEATURES_ENABLED" == "1" ]] ;;
|
|
thinking) [[ "$THINKING_ENABLED" == "1" ]] ;;
|
|
context) [[ "$CONTEXT_ENABLED" == "1" ]] ;;
|
|
*) return 1 ;;
|
|
esac
|
|
}
|
|
|
|
# Output target: /dev/tty bypasses Claude Code's stderr capture for lifecycle hooks
|
|
# Falls back to stderr when /dev/tty is unavailable (CI, cron, dry-run in pipes)
|
|
if (echo -n "" > /dev/tty) 2>/dev/null; then
|
|
OUTPUT_TARGET="/dev/tty"
|
|
else
|
|
OUTPUT_TARGET="/dev/stderr"
|
|
fi
|
|
|
|
# ANSI colors (respect NO_COLOR)
|
|
if [[ -z "${NO_COLOR:-}" ]]; then
|
|
BOLD=$'\033[1m'
|
|
DIM=$'\033[2m'
|
|
CYAN=$'\033[36m'
|
|
GREEN=$'\033[32m'
|
|
YELLOW=$'\033[33m'
|
|
RED=$'\033[31m'
|
|
RESET=$'\033[0m'
|
|
else
|
|
BOLD='' DIM='' CYAN='' GREEN='' YELLOW='' RED='' RESET=''
|
|
fi
|
|
|
|
# Pricing table (per million tokens, as of 2026-02)
|
|
# Used as fallback if ccusage is unavailable
|
|
get_pricing() {
|
|
local model="$1"
|
|
local type="$2" # input or output
|
|
|
|
case "$model" in
|
|
claude-opus-4-6)
|
|
[[ "$type" == "input" ]] && echo "15.00" || echo "75.00"
|
|
;;
|
|
claude-sonnet-4-5)
|
|
[[ "$type" == "input" ]] && echo "3.00" || echo "15.00"
|
|
;;
|
|
claude-haiku-4-5)
|
|
[[ "$type" == "input" ]] && echo "0.80" || echo "4.00"
|
|
;;
|
|
*)
|
|
echo "0"
|
|
;;
|
|
esac
|
|
}
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
# Helper Functions
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
check_dependencies() {
|
|
if ! command -v jq &> /dev/null; then
|
|
echo "Error: jq is required but not installed" >&2
|
|
echo "Install: brew install jq (macOS) or apt-get install jq (Linux)" >&2
|
|
exit 0 # Don't block session end
|
|
fi
|
|
}
|
|
|
|
locate_jsonl() {
|
|
local session_id="$1"
|
|
local cwd="$2"
|
|
|
|
# Encode project path: /Users/foo/bar -> -Users-foo-bar
|
|
local encoded_path
|
|
encoded_path=$(echo "$cwd" | tr '/' '-')
|
|
|
|
local jsonl_path="$HOME/.claude/projects/${encoded_path}/${session_id}.jsonl"
|
|
|
|
# Fallback: find if encoding wrong
|
|
if [[ ! -f "$jsonl_path" ]]; then
|
|
jsonl_path=$(find "$HOME/.claude/projects" -name "${session_id}.jsonl" -maxdepth 2 2>/dev/null | head -1)
|
|
fi
|
|
|
|
echo "$jsonl_path"
|
|
}
|
|
|
|
format_duration() {
|
|
local ms="$1"
|
|
local seconds=$((ms / 1000))
|
|
local minutes=$((seconds / 60))
|
|
local hours=$((minutes / 60))
|
|
|
|
if [[ $hours -gt 0 ]]; then
|
|
printf "%dh %dm" "$hours" "$((minutes % 60))"
|
|
elif [[ $minutes -gt 0 ]]; then
|
|
printf "%dm %ds" "$minutes" "$((seconds % 60))"
|
|
else
|
|
printf "%ds" "$seconds"
|
|
fi
|
|
}
|
|
|
|
format_number() {
|
|
local num="$1"
|
|
|
|
if [[ $num -ge 1000000 ]]; then
|
|
printf "%.1fM" "$(bc <<< "scale=1; $num / 1000000")"
|
|
elif [[ $num -ge 1000 ]]; then
|
|
printf "%.1fK" "$(bc <<< "scale=1; $num / 1000")"
|
|
else
|
|
printf "%d" "$num"
|
|
fi
|
|
}
|
|
|
|
shorten_model_name() {
|
|
local model="$1"
|
|
# claude-sonnet-4-5-20250929 -> claude-sonnet-4-5
|
|
echo "$model" | sed -E 's/-(20[0-9]{6})$//'
|
|
}
|
|
|
|
# Parse a numeric value with K/M/B suffix from rtk gain text output
|
|
parse_rtk_number() {
|
|
local text="$1"
|
|
local label="$2"
|
|
|
|
echo "$text" | awk -v label="$label" '
|
|
$0 ~ label {
|
|
for (i=1; i<=NF; i++) {
|
|
if ($i ~ /^[0-9]/) {
|
|
val = $i
|
|
gsub(/,/, "", val)
|
|
if (val ~ /[Bb]$/) { sub(/[Bb]$/, "", val); printf "%.0f", val * 1000000000 }
|
|
else if (val ~ /[Mm]$/) { sub(/[Mm]$/, "", val); printf "%.0f", val * 1000000 }
|
|
else if (val ~ /[Kk]$/) { sub(/[Kk]$/, "", val); printf "%.0f", val * 1000 }
|
|
else { printf "%.0f", val }
|
|
exit
|
|
}
|
|
}
|
|
}
|
|
'
|
|
}
|
|
|
|
# Diff "By Command" tables from two rtk gain outputs, return commands with positive delta
|
|
# Output: "git status(2), ls(3), git diff(1)"
|
|
diff_rtk_commands() {
|
|
local baseline="$1"
|
|
local current="$2"
|
|
|
|
{ echo "___BASELINE___"; echo "$baseline"; echo "___CURRENT___"; echo "$current"; echo "___END___"; } | awk '
|
|
/^___BASELINE___$/ { section="baseline"; next }
|
|
/^___CURRENT___$/ { section="current"; in_table=0; next }
|
|
/^___END___$/ {
|
|
for (cmd in cur) {
|
|
delta = cur[cmd] - (base[cmd] + 0)
|
|
if (delta > 0) {
|
|
short = cmd
|
|
sub(/^rtk /, "", short)
|
|
# Truncate long commands
|
|
if (length(short) > 20) short = substr(short, 1, 17) "..."
|
|
if (length(out) > 0) out = out ", "
|
|
out = out short "(" delta ")"
|
|
}
|
|
}
|
|
print out
|
|
exit
|
|
}
|
|
/^By Command:/ { in_table=1; next }
|
|
/^─/ { next }
|
|
/^Command/ { next }
|
|
in_table && /^$/ { in_table=0; next }
|
|
in_table && NF >= 5 {
|
|
count = $(NF-3)
|
|
if (count ~ /^[0-9,]+$/) {
|
|
gsub(/,/, "", count)
|
|
cmd = ""
|
|
for (i=1; i<=NF-4; i++) cmd = cmd (i>1?" ":"") $i
|
|
if (section == "baseline") base[cmd] = count + 0
|
|
else cur[cmd] = count + 0
|
|
}
|
|
}
|
|
'
|
|
}
|
|
|
|
# Calculate RTK token savings for this session (delta between start and end)
|
|
calculate_rtk_savings() {
|
|
# Build baseline file path (must match rtk-baseline.sh)
|
|
local baseline_key
|
|
baseline_key=$(echo "${CLAUDE_PROJECT_DIR:-$(pwd)}" | tr '/' '-')
|
|
local baseline_file="/tmp/rtk-baseline${baseline_key}.txt"
|
|
|
|
# No baseline = no delta possible
|
|
if [[ ! -f "$baseline_file" ]]; then
|
|
echo ""
|
|
return
|
|
fi
|
|
|
|
local baseline_text
|
|
baseline_text=$(<"$baseline_file")
|
|
|
|
local current_text
|
|
current_text=$(rtk gain 2>/dev/null) || { echo ""; return; }
|
|
|
|
# Parse total commands from both snapshots
|
|
local start_cmds end_cmds
|
|
start_cmds=$(parse_rtk_number "$baseline_text" "Total commands")
|
|
end_cmds=$(parse_rtk_number "$current_text" "Total commands")
|
|
|
|
local delta_cmds=$(( ${end_cmds:-0} - ${start_cmds:-0} ))
|
|
|
|
# No commands rewritten this session
|
|
if [[ $delta_cmds -le 0 ]]; then
|
|
rm -f "$baseline_file"
|
|
echo ""
|
|
return
|
|
fi
|
|
|
|
# Parse all token lines upfront (needed by all 3 approaches + pct calculation)
|
|
local start_saved end_saved start_input end_input start_output end_output
|
|
start_saved=$(parse_rtk_number "$baseline_text" "Tokens saved")
|
|
end_saved=$(parse_rtk_number "$current_text" "Tokens saved")
|
|
start_input=$(parse_rtk_number "$baseline_text" "Input tokens")
|
|
end_input=$(parse_rtk_number "$current_text" "Input tokens")
|
|
start_output=$(parse_rtk_number "$baseline_text" "Output tokens")
|
|
end_output=$(parse_rtk_number "$current_text" "Output tokens")
|
|
|
|
# Token savings: 3 approaches (M/K rounding loses precision for small sessions)
|
|
local delta_saved=0 estimated=0
|
|
|
|
# Approach 1: Direct delta from "Tokens saved" line
|
|
delta_saved=$(( ${end_saved:-0} - ${start_saved:-0} ))
|
|
|
|
# Approach 2: If 0 due to rounding, try (Input - Output) delta
|
|
if [[ $delta_saved -le 0 ]]; then
|
|
delta_saved=$(( (${end_input:-0} - ${start_input:-0}) - (${end_output:-0} - ${start_output:-0}) ))
|
|
fi
|
|
|
|
# Approach 3: Estimate from global average per command
|
|
if [[ $delta_saved -le 0 && ${end_cmds:-0} -gt 0 ]]; then
|
|
estimated=1
|
|
delta_saved=$(bc <<< "scale=0; ${end_saved:-0} / ${end_cmds:-1} * $delta_cmds")
|
|
fi
|
|
|
|
# Calculate percentage
|
|
local pct="0"
|
|
local delta_input=$(( ${end_input:-0} - ${start_input:-0} ))
|
|
if [[ $estimated -eq 1 ]]; then
|
|
# Use global average percentage
|
|
if [[ ${end_input:-0} -gt 0 ]]; then
|
|
pct=$(bc <<< "scale=0; ${end_saved:-0} * 100 / ${end_input:-1}")
|
|
fi
|
|
elif [[ ${delta_input:-0} -gt 0 ]]; then
|
|
pct=$(bc <<< "scale=0; $delta_saved * 100 / $delta_input")
|
|
fi
|
|
|
|
# Parse per-command deltas from "By Command" table
|
|
local cmds_detail
|
|
cmds_detail=$(diff_rtk_commands "$baseline_text" "$current_text")
|
|
|
|
# Clean up baseline file
|
|
rm -f "$baseline_file"
|
|
|
|
# Return JSON
|
|
jq -cn \
|
|
--argjson cmds "$delta_cmds" \
|
|
--argjson tokens_saved "${delta_saved:-0}" \
|
|
--arg pct "$pct" \
|
|
--argjson estimated "$estimated" \
|
|
--arg commands "${cmds_detail:-}" \
|
|
'{cmds: $cmds, tokens_saved: $tokens_saved, pct: $pct, estimated: ($estimated == 1), commands: $commands}'
|
|
}
|
|
|
|
# Collect git diff stats (files changed, insertions, deletions)
|
|
collect_git_diff() {
|
|
local cwd="$1"
|
|
if ! command -v git &>/dev/null; then
|
|
echo "{}"
|
|
return
|
|
fi
|
|
|
|
local stat_line
|
|
stat_line=$(git -C "$cwd" diff --stat HEAD 2>/dev/null | tail -1) || { echo "{}"; return; }
|
|
|
|
if [[ -z "$stat_line" ]]; then
|
|
echo "{}"
|
|
return
|
|
fi
|
|
|
|
# Parse: " 4 files changed, 142 insertions(+), 37 deletions(-)"
|
|
local files_changed=0 insertions=0 deletions=0
|
|
files_changed=$(echo "$stat_line" | grep -oE '[0-9]+ files? changed' | grep -oE '[0-9]+' || echo "0")
|
|
insertions=$(echo "$stat_line" | grep -oE '[0-9]+ insertion' | grep -oE '[0-9]+' || echo "0")
|
|
deletions=$(echo "$stat_line" | grep -oE '[0-9]+ deletion' | grep -oE '[0-9]+' || echo "0")
|
|
|
|
jq -cn \
|
|
--argjson files "${files_changed:-0}" \
|
|
--argjson ins "${insertions:-0}" \
|
|
--argjson del "${deletions:-0}" \
|
|
'{files_changed: $files, insertions: $ins, deletions: $del}'
|
|
}
|
|
|
|
extract_session_data() {
|
|
local jsonl_path="$1"
|
|
local loc_enabled="${2:-1}"
|
|
local features_enabled="${3:-1}"
|
|
local errors_enabled="${4:-1}"
|
|
local thinking_enabled="${5:-0}"
|
|
local context_enabled="${6:-0}"
|
|
|
|
if [[ ! -f "$jsonl_path" ]]; then
|
|
echo "{}"
|
|
return
|
|
fi
|
|
|
|
# Single-pass jq extraction using reduce inputs (streaming, memory-efficient)
|
|
# Pass feature flags to skip expensive operations when disabled
|
|
jq -n --arg jsonl_path "$jsonl_path" \
|
|
--argjson loc_on "$loc_enabled" \
|
|
--argjson feat_on "$features_enabled" \
|
|
--argjson err_on "$errors_enabled" \
|
|
--argjson think_on "$thinking_enabled" \
|
|
--argjson ctx_on "$context_enabled" '
|
|
reduce (inputs | select(. != null and . != "")) as $line (
|
|
{
|
|
models: {},
|
|
tools: {},
|
|
tool_errors: 0,
|
|
turns: 0,
|
|
turn_ms: 0,
|
|
first_ts: null,
|
|
last_ts: null,
|
|
api_requests: 0,
|
|
git_branch: null,
|
|
files_read: {},
|
|
files_edited: {},
|
|
files_created: {},
|
|
# v3 new fields
|
|
pending_tools: {},
|
|
error_details: [],
|
|
loc_added: 0,
|
|
loc_removed: 0,
|
|
user_prompts: 0,
|
|
thinking_blocks: 0,
|
|
peak_input: 0,
|
|
mcp_servers: {},
|
|
agents: {},
|
|
skills: [],
|
|
has_teams: false,
|
|
has_plan_mode: false
|
|
};
|
|
|
|
if $line.type == "assistant" and $line.message.usage != null then
|
|
.api_requests += 1 |
|
|
.models[$line.message.model] = {
|
|
requests: ((.models[$line.message.model].requests // 0) + 1),
|
|
input: ((.models[$line.message.model].input // 0) + $line.message.usage.input_tokens),
|
|
output: ((.models[$line.message.model].output // 0) + $line.message.usage.output_tokens),
|
|
cache_read: ((.models[$line.message.model].cache_read // 0) + ($line.message.usage.cache_read_input_tokens // 0)),
|
|
cache_create: ((.models[$line.message.model].cache_create // 0) + ($line.message.usage.cache_creation_input_tokens // 0))
|
|
} |
|
|
# Context window tracking (peak input tokens)
|
|
if $ctx_on == 1 then
|
|
(($line.message.usage.input_tokens + ($line.message.usage.cache_read_input_tokens // 0)) as $ctx |
|
|
if $ctx > .peak_input then .peak_input = $ctx else . end)
|
|
else . end |
|
|
# Thinking blocks count
|
|
if $think_on == 1 then
|
|
.thinking_blocks += ([($line.message.content[]? | select(.type == "thinking"))] | length)
|
|
else . end |
|
|
# Extract tool_use blocks: count tools, track files, features, errors, LOC
|
|
(reduce ($line.message.content[]? | select(.type == "tool_use")) as $block (.;
|
|
.tools[$block.name] = ((.tools[$block.name] // 0) + 1) |
|
|
# Track pending tools for error mapping
|
|
(if $err_on == 1 and ($block.id // null) != null then
|
|
.pending_tools[$block.id] = $block.name
|
|
else . end) |
|
|
# File tracking
|
|
if ($block.name == "Read") and (($block.input.file_path // null) != null) then
|
|
.files_read[$block.input.file_path] = true
|
|
elif ($block.name == "Edit" or $block.name == "MultiEdit") and (($block.input.file_path // null) != null) then
|
|
.files_edited[$block.input.file_path] = ((.files_edited[$block.input.file_path] // 0) + 1) |
|
|
# LOC tracking for Edit
|
|
if $loc_on == 1 and $block.name == "Edit" then
|
|
.loc_removed += (($block.input.old_string // "") | split("\n") | length) |
|
|
.loc_added += (($block.input.new_string // "") | split("\n") | length)
|
|
else . end
|
|
elif ($block.name == "Write") and (($block.input.file_path // null) != null) then
|
|
.files_created[$block.input.file_path] = true |
|
|
# LOC tracking for Write
|
|
if $loc_on == 1 then
|
|
.loc_added += (($block.input.content // "") | split("\n") | length)
|
|
else . end
|
|
else . end |
|
|
# Features detection
|
|
if $feat_on == 1 then
|
|
if ($block.name | startswith("mcp__")) then
|
|
(($block.name | split("__") | .[1]) // "unknown") as $server |
|
|
.mcp_servers[$server] = ((.mcp_servers[$server] // 0) + 1)
|
|
elif $block.name == "Task" then
|
|
(($block.input.subagent_type // "unknown") | tostring) as $atype |
|
|
.agents[$atype] = ((.agents[$atype] // 0) + 1)
|
|
elif $block.name == "Skill" then
|
|
.skills += [(($block.input.skill // "unknown") | tostring)]
|
|
elif $block.name == "TeamCreate" then
|
|
.has_teams = true
|
|
elif $block.name == "EnterPlanMode" then
|
|
.has_plan_mode = true
|
|
else . end
|
|
else . end
|
|
)) |
|
|
# Extract git branch from JSONL entries (fallback for session meta)
|
|
(if .git_branch == null and $line.gitBranch != null then .git_branch = $line.gitBranch else . end)
|
|
elif $line.type == "user" then
|
|
# Count tool errors + map to tool names
|
|
(reduce ($line.message.content[]? | select(.type == "tool_result" and .is_error == true)) as $err (.;
|
|
.tool_errors += 1 |
|
|
if $err_on == 1 then
|
|
((.pending_tools[$err.tool_use_id] // "unknown") as $tool_name |
|
|
(($err.content // ($err.output // "")) | tostring | if length > 80 then .[:77] + "..." else . end) as $msg |
|
|
.error_details += [{tool: $tool_name, message: $msg}])
|
|
else . end
|
|
)) |
|
|
# Count user interactive prompts (messages with text content, not just tool results)
|
|
if ([($line.message.content[]? | select(.type == "text"))] | length) > 0 then
|
|
.user_prompts += 1
|
|
else . end
|
|
elif $line.type == "system" and $line.subtype == "turn_duration" then
|
|
.turns += 1 | .turn_ms += $line.durationMs
|
|
else . end |
|
|
|
|
if $line.timestamp != null then
|
|
(if .first_ts == null then .first_ts = $line.timestamp else . end) |
|
|
.last_ts = $line.timestamp
|
|
else . end
|
|
) |
|
|
# Clean up pending_tools (internal tracking only)
|
|
del(.pending_tools)
|
|
' "$jsonl_path"
|
|
}
|
|
|
|
get_session_meta() {
|
|
local session_id="$1"
|
|
local cwd="$2"
|
|
|
|
local encoded_path
|
|
encoded_path=$(echo "$cwd" | tr '/' '-')
|
|
local index_path="$HOME/.claude/projects/${encoded_path}/sessions-index.json"
|
|
|
|
if [[ ! -f "$index_path" ]]; then
|
|
echo "{}"
|
|
return
|
|
fi
|
|
|
|
jq --arg sid "$session_id" '
|
|
.entries[]? | select(.sessionId == $sid) | {
|
|
summary: .summary,
|
|
gitBranch: .gitBranch,
|
|
messageCount: .messageCount
|
|
}
|
|
' "$index_path" 2>/dev/null || echo "{}"
|
|
}
|
|
|
|
calculate_cost() {
|
|
local session_id="$1"
|
|
local session_data="$2"
|
|
|
|
# Try ccusage first (with timeout)
|
|
if command -v ccusage &> /dev/null; then
|
|
local cost
|
|
cost=$(timeout 5s ccusage session --id "$session_id" --json --offline 2>/dev/null | jq -r '.totalCost // empty' || echo "")
|
|
|
|
if [[ -n "$cost" ]]; then
|
|
echo "$cost"
|
|
return
|
|
fi
|
|
fi
|
|
|
|
# Fallback: calculate from pricing table
|
|
local total_cost=0
|
|
|
|
while IFS= read -r model_entry; do
|
|
local model
|
|
model=$(echo "$model_entry" | jq -r '.model')
|
|
local input
|
|
input=$(echo "$model_entry" | jq -r '.input')
|
|
local output
|
|
output=$(echo "$model_entry" | jq -r '.output')
|
|
|
|
# Shorten model name to match pricing keys
|
|
local model_short
|
|
model_short=$(shorten_model_name "$model")
|
|
|
|
local input_price
|
|
input_price=$(get_pricing "$model_short" "input")
|
|
local output_price
|
|
output_price=$(get_pricing "$model_short" "output")
|
|
|
|
# Cost = (tokens / 1M) * price_per_M
|
|
local model_cost
|
|
model_cost=$(bc <<< "scale=4; ($input / 1000000) * $input_price + ($output / 1000000) * $output_price")
|
|
total_cost=$(bc <<< "scale=4; $total_cost + $model_cost")
|
|
done < <(echo "$session_data" | jq -c '.models | to_entries[] | {model: .key, input: .value.input, output: .value.output}')
|
|
|
|
echo "$total_cost"
|
|
}
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
# Section Renderers (each returns a string, empty if no data)
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
# Shared state populated by format_output() before calling renderers
|
|
_SD="" # session_data JSON
|
|
_SM="" # session_meta JSON
|
|
_SID="" # session_id
|
|
_BRANCH="" # git branch
|
|
_COST="" # cost string
|
|
_RTK="" # rtk_data JSON
|
|
_GIT_DIFF="" # git diff JSON
|
|
_EXIT_REASON="" # exit reason
|
|
|
|
# Pre-computed values shared across renderers
|
|
_TOOL_CALLS_TOTAL=0
|
|
_TOOL_OK=0
|
|
_TOOL_ERRORS=0
|
|
_TOTAL_INPUT=0
|
|
_TOTAL_OUTPUT=0
|
|
_TOTAL_CACHE_READ=0
|
|
_TOTAL_CACHE_CREATE=0
|
|
_TURNS=0
|
|
_TURN_MS=0
|
|
_WALL_MS=0
|
|
|
|
render_meta() {
|
|
local session_name
|
|
session_name=$(echo "$_SM" | jq -r '.summary // "Unnamed session"')
|
|
local out=""
|
|
out+="${DIM}ID:${RESET} ${_SID:0:16}..."$'\n'
|
|
out+="${DIM}Name:${RESET} $session_name"$'\n'
|
|
out+="${DIM}Branch:${RESET} $_BRANCH"
|
|
echo "$out"
|
|
}
|
|
|
|
render_duration() {
|
|
local wall_str active_str
|
|
wall_str=$(format_duration "$_WALL_MS")
|
|
active_str=$(format_duration "$_TURN_MS")
|
|
local turn_label="turns"
|
|
[[ $_TURNS -eq 1 ]] && turn_label="turn"
|
|
|
|
local line="${DIM}Duration:${RESET} Wall ${wall_str} | Active ${active_str} | ${_TURNS} ${turn_label}"
|
|
[[ -n "$_EXIT_REASON" ]] && line+=" | Exit: ${_EXIT_REASON}"
|
|
echo "$line"
|
|
}
|
|
|
|
render_tools() {
|
|
local tools_line=""
|
|
while IFS= read -r tool_entry; do
|
|
local tool count
|
|
tool=$(echo "$tool_entry" | jq -r '.tool')
|
|
count=$(echo "$tool_entry" | jq -r '.count')
|
|
tools_line+=" ${CYAN}$tool:${RESET} $count"
|
|
done < <(echo "$_SD" | jq -c '.tools | to_entries[] | {tool: .key, count: .value}' | sort -t':' -k2 -rn | head -8)
|
|
|
|
local out=""
|
|
out+="${DIM}Tool Calls:${RESET} $_TOOL_CALLS_TOTAL ${GREEN}(OK $_TOOL_OK / ERR $_TOOL_ERRORS)${RESET}"$'\n'
|
|
out+="$tools_line"
|
|
echo "$out"
|
|
}
|
|
|
|
render_errors() {
|
|
local error_count
|
|
error_count=$(echo "$_SD" | jq '.error_details | length')
|
|
[[ "$error_count" == "0" || "$error_count" == "null" ]] && return
|
|
|
|
local out=""
|
|
out+="${DIM}Errors:${RESET} ${RED}${error_count}${RESET}"
|
|
|
|
# Group errors by tool, show count and first message
|
|
local grouped
|
|
grouped=$(echo "$_SD" | jq -r '
|
|
.error_details | group_by(.tool) | .[] |
|
|
(.[0].tool) as $tool |
|
|
(length) as $count |
|
|
(.[0].message | gsub("\n"; " ")) as $msg |
|
|
" \($tool): \"\($msg)\" (x\($count))"
|
|
')
|
|
[[ -n "$grouped" ]] && out+=$'\n'"$grouped"
|
|
echo "$out"
|
|
}
|
|
|
|
render_files() {
|
|
local file_stats
|
|
file_stats=$(echo "$_SD" | jq '
|
|
. as $data |
|
|
{
|
|
read_only: [.files_read | keys[] | . as $p | select(($data.files_edited | has($p)) | not) | select(($data.files_created | has($p)) | not)] | length,
|
|
edited: (.files_edited | length),
|
|
created_only: [.files_created | keys[] | . as $p | select(($data.files_edited | has($p)) | not)] | length,
|
|
top_edited: [.files_edited | to_entries | sort_by(-.value) | .[:5][] | {name: (.key | split("/") | last), count: .value}]
|
|
}
|
|
')
|
|
|
|
local read_only edited created_only
|
|
read_only=$(echo "$file_stats" | jq -r '.read_only')
|
|
edited=$(echo "$file_stats" | jq -r '.edited')
|
|
created_only=$(echo "$file_stats" | jq -r '.created_only')
|
|
|
|
local total_files=$((read_only + edited + created_only))
|
|
[[ $total_files -eq 0 ]] && return
|
|
|
|
local parts=""
|
|
[[ $read_only -gt 0 ]] && parts+="$read_only read"
|
|
[[ $edited -gt 0 ]] && { [[ -n "$parts" ]] && parts+=" · "; parts+="$edited edited"; }
|
|
[[ $created_only -gt 0 ]] && { [[ -n "$parts" ]] && parts+=" · "; parts+="$created_only created"; }
|
|
|
|
local out="${DIM}Files:${RESET} $parts"
|
|
|
|
local top_edited
|
|
top_edited=$(echo "$file_stats" | jq -r '.top_edited | map("\(.name) (\(.count) edits)") | join(", ")')
|
|
[[ -n "$top_edited" ]] && out+=$'\n'" $top_edited"
|
|
echo "$out"
|
|
}
|
|
|
|
render_features() {
|
|
local parts=""
|
|
|
|
# MCP servers
|
|
local mcp_count
|
|
mcp_count=$(echo "$_SD" | jq '.mcp_servers | length')
|
|
if [[ "$mcp_count" != "0" && "$mcp_count" != "null" ]]; then
|
|
local mcp_detail
|
|
mcp_detail=$(echo "$_SD" | jq -r '.mcp_servers | to_entries | sort_by(-.value) | map("\(.key) x\(.value)") | join(", ")')
|
|
parts+="MCP ($mcp_detail)"
|
|
fi
|
|
|
|
# Agents
|
|
local agent_count
|
|
agent_count=$(echo "$_SD" | jq '.agents | length')
|
|
if [[ "$agent_count" != "0" && "$agent_count" != "null" ]]; then
|
|
local agent_detail
|
|
agent_detail=$(echo "$_SD" | jq -r '.agents | to_entries | sort_by(-.value) | map("\(.key) x\(.value)") | join(", ")')
|
|
[[ -n "$parts" ]] && parts+=" · "
|
|
parts+="Agents ($agent_detail)"
|
|
fi
|
|
|
|
# Skills
|
|
local skill_count
|
|
skill_count=$(echo "$_SD" | jq '.skills | unique | length')
|
|
if [[ "$skill_count" != "0" && "$skill_count" != "null" ]]; then
|
|
local skill_list
|
|
skill_list=$(echo "$_SD" | jq -r '.skills | unique | join(", ")')
|
|
[[ -n "$parts" ]] && parts+=" · "
|
|
parts+="Skills ($skill_list)"
|
|
fi
|
|
|
|
# Teams
|
|
local has_teams
|
|
has_teams=$(echo "$_SD" | jq -r '.has_teams')
|
|
if [[ "$has_teams" == "true" ]]; then
|
|
[[ -n "$parts" ]] && parts+=" · "
|
|
parts+="Teams"
|
|
fi
|
|
|
|
# Plan mode
|
|
local has_plan
|
|
has_plan=$(echo "$_SD" | jq -r '.has_plan_mode')
|
|
if [[ "$has_plan" == "true" ]]; then
|
|
[[ -n "$parts" ]] && parts+=" · "
|
|
parts+="Plan mode"
|
|
fi
|
|
|
|
[[ -z "$parts" ]] && return
|
|
echo "${DIM}Features:${RESET} $parts"
|
|
}
|
|
|
|
render_git() {
|
|
[[ -z "$_GIT_DIFF" || "$_GIT_DIFF" == "{}" ]] && return
|
|
|
|
local files ins del
|
|
files=$(echo "$_GIT_DIFF" | jq -r '.files_changed')
|
|
ins=$(echo "$_GIT_DIFF" | jq -r '.insertions')
|
|
del=$(echo "$_GIT_DIFF" | jq -r '.deletions')
|
|
|
|
[[ "$files" == "0" ]] && return
|
|
|
|
local file_label="files"
|
|
[[ "$files" == "1" ]] && file_label="file"
|
|
echo "${DIM}Git:${RESET} ${GREEN}+${ins}${RESET} ${RED}-${del}${RESET} lines · ${files} ${file_label} changed"
|
|
}
|
|
|
|
render_loc() {
|
|
local loc_added loc_removed
|
|
loc_added=$(echo "$_SD" | jq -r '.loc_added // 0')
|
|
loc_removed=$(echo "$_SD" | jq -r '.loc_removed // 0')
|
|
|
|
[[ "$loc_added" == "0" && "$loc_removed" == "0" ]] && return
|
|
echo "${DIM}Code:${RESET} ${GREEN}+${loc_added}${RESET} ${RED}-${loc_removed}${RESET} net (via Edit/Write)"
|
|
}
|
|
|
|
render_models() {
|
|
local models_section=""
|
|
while IFS= read -r model_entry; do
|
|
local model requests input output cache_read cache_create
|
|
model=$(echo "$model_entry" | jq -r '.model')
|
|
requests=$(echo "$model_entry" | jq -r '.requests')
|
|
input=$(echo "$model_entry" | jq -r '.input')
|
|
output=$(echo "$model_entry" | jq -r '.output')
|
|
|
|
local model_short input_fmt output_fmt
|
|
model_short=$(shorten_model_name "$model")
|
|
input_fmt=$(format_number "$input")
|
|
output_fmt=$(format_number "$output")
|
|
|
|
models_section+="$(printf "${CYAN}%-20s${RESET} %4d %7s %6s\n" "$model_short" "$requests" "$input_fmt" "$output_fmt")"
|
|
done < <(echo "$_SD" | jq -c '.models | to_entries[] | {model: .key, requests: .value.requests, input: .value.input, output: .value.output}')
|
|
|
|
local out=""
|
|
out+="${DIM}Model Usage${RESET} Reqs Input Output"$'\n'
|
|
out+="$models_section"
|
|
echo -n "$out"
|
|
}
|
|
|
|
render_cache() {
|
|
[[ $_TOTAL_CACHE_READ -eq 0 && $_TOTAL_CACHE_CREATE -eq 0 ]] && return
|
|
|
|
local cache_hit_rate="0"
|
|
local cache_denominator=$((_TOTAL_CACHE_READ + _TOTAL_INPUT))
|
|
if [[ $cache_denominator -gt 0 ]]; then
|
|
cache_hit_rate=$(bc <<< "scale=0; $_TOTAL_CACHE_READ * 100 / $cache_denominator")
|
|
fi
|
|
echo "Cache: ${cache_hit_rate}% hit rate ($(format_number $_TOTAL_CACHE_READ) read / $(format_number $_TOTAL_CACHE_CREATE) created)"
|
|
}
|
|
|
|
render_cost() {
|
|
[[ -z "$_COST" || "$_COST" == "0" ]] && return
|
|
echo "Est. Cost: ${GREEN}\$$(printf "%.3f" "$_COST")${RESET}"
|
|
}
|
|
|
|
render_rtk() {
|
|
[[ -z "$_RTK" ]] && return
|
|
|
|
local rtk_cmds rtk_tokens rtk_pct rtk_estimated rtk_commands
|
|
rtk_cmds=$(echo "$_RTK" | jq -r '.cmds')
|
|
rtk_tokens=$(echo "$_RTK" | jq -r '.tokens_saved')
|
|
rtk_pct=$(echo "$_RTK" | jq -r '.pct')
|
|
rtk_estimated=$(echo "$_RTK" | jq -r '.estimated')
|
|
rtk_commands=$(echo "$_RTK" | jq -r '.commands')
|
|
|
|
local est_prefix=""
|
|
[[ "$rtk_estimated" == "true" ]] && est_prefix="est. "
|
|
|
|
local out=""
|
|
if [[ $rtk_tokens -gt 0 ]]; then
|
|
out="RTK Savings: $rtk_cmds cmds · ~$(format_number "$rtk_tokens") tokens saved (${est_prefix}${rtk_pct}%)"
|
|
else
|
|
out="RTK Savings: $rtk_cmds cmds rewritten"
|
|
fi
|
|
[[ -n "$rtk_commands" ]] && out+=$'\n'" $rtk_commands"
|
|
echo "$out"
|
|
}
|
|
|
|
render_ratio() {
|
|
local user_prompts turns
|
|
user_prompts=$(echo "$_SD" | jq -r '.user_prompts // 0')
|
|
turns=$_TURNS
|
|
|
|
[[ $turns -eq 0 ]] && return
|
|
|
|
local auto_turns=$((turns - user_prompts))
|
|
[[ $auto_turns -lt 0 ]] && auto_turns=0
|
|
|
|
local avg_sec=""
|
|
if [[ $_TURN_MS -gt 0 && $turns -gt 0 ]]; then
|
|
avg_sec=$(bc <<< "scale=1; $_TURN_MS / 1000 / $turns")
|
|
avg_sec="${avg_sec}s/turn"
|
|
fi
|
|
|
|
local turn_label="turns"
|
|
[[ $turns -eq 1 ]] && turn_label="turn"
|
|
|
|
local out="${DIM}Turns:${RESET} ${turns} (${user_prompts} interactive · ${auto_turns} auto)"
|
|
[[ -n "$avg_sec" ]] && out+=" · Avg ${avg_sec}"
|
|
echo "$out"
|
|
}
|
|
|
|
render_thinking() {
|
|
local thinking_blocks
|
|
thinking_blocks=$(echo "$_SD" | jq -r '.thinking_blocks // 0')
|
|
[[ "$thinking_blocks" == "0" ]] && return
|
|
echo "${DIM}Thinking:${RESET} ${thinking_blocks} blocks"
|
|
}
|
|
|
|
render_context() {
|
|
local peak_input
|
|
peak_input=$(echo "$_SD" | jq -r '.peak_input // 0')
|
|
[[ "$peak_input" == "0" ]] && return
|
|
|
|
# Estimate context limit based on model (200K default)
|
|
local ctx_limit=200000
|
|
local pct
|
|
pct=$(bc <<< "scale=0; $peak_input * 100 / $ctx_limit")
|
|
|
|
echo "${DIM}Context:${RESET} ~${pct}% peak (est.) · Model limit: $(format_number $ctx_limit)"
|
|
}
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
# Output Orchestrator
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
format_output() {
|
|
local session_id="$1"
|
|
local session_meta="$2"
|
|
local session_data="$3"
|
|
local cost="$4"
|
|
local git_branch="${5:-unknown}"
|
|
local rtk_data="${6:-}"
|
|
local exit_reason="${7:-}"
|
|
local git_diff_data="${8:-}"
|
|
|
|
# Set shared state for renderers
|
|
_SD="$session_data"
|
|
_SM="$session_meta"
|
|
_SID="$session_id"
|
|
_BRANCH="$git_branch"
|
|
_COST="$cost"
|
|
_RTK="$rtk_data"
|
|
_GIT_DIFF="$git_diff_data"
|
|
_EXIT_REASON="$exit_reason"
|
|
|
|
# Extract session data
|
|
local api_requests
|
|
api_requests=$(echo "$session_data" | jq -r '.api_requests // 0')
|
|
|
|
# Handle empty session
|
|
if [[ $api_requests -eq 0 ]]; then
|
|
local session_name
|
|
session_name=$(echo "$session_meta" | jq -r '.summary // "Unnamed session"')
|
|
local buf=""
|
|
buf+=$'\n'
|
|
buf+="${BOLD}═══ Session Summary ═══════════════════${RESET}"$'\n'
|
|
buf+="${DIM}ID:${RESET} ${session_id:0:16}..."$'\n'
|
|
buf+="${DIM}Name:${RESET} $session_name"$'\n'
|
|
buf+="${DIM}Branch:${RESET} $git_branch"$'\n'
|
|
buf+="${DIM}Status:${RESET} ${YELLOW}Empty session (no API requests)${RESET}"$'\n'
|
|
buf+="${BOLD}═══════════════════════════════════════${RESET}"$'\n'
|
|
printf '%s\n' "$buf" > "$OUTPUT_TARGET"
|
|
return
|
|
fi
|
|
|
|
# Pre-compute shared values for renderers
|
|
local first_ts last_ts
|
|
first_ts=$(echo "$session_data" | jq -r '.first_ts // 0')
|
|
last_ts=$(echo "$session_data" | jq -r '.last_ts // 0')
|
|
_TURN_MS=$(echo "$session_data" | jq -r '.turn_ms // 0')
|
|
_TURNS=$(echo "$session_data" | jq -r '.turns // 0')
|
|
|
|
_WALL_MS=0
|
|
if [[ $first_ts != "null" && $last_ts != "null" && $first_ts != "0" && $last_ts != "0" ]]; then
|
|
_WALL_MS=$(jq -n --arg first "$first_ts" --arg last "$last_ts" '
|
|
(($last | split(".")[0] + "Z" | fromdate) - ($first | split(".")[0] + "Z" | fromdate)) * 1000
|
|
' 2>/dev/null || echo "0")
|
|
fi
|
|
|
|
_TOOL_ERRORS=$(echo "$session_data" | jq -r '.tool_errors // 0')
|
|
_TOOL_CALLS_TOTAL=0
|
|
while IFS= read -r tool_entry; do
|
|
local count
|
|
count=$(echo "$tool_entry" | jq -r '.count')
|
|
_TOOL_CALLS_TOTAL=$((_TOOL_CALLS_TOTAL + count))
|
|
done < <(echo "$session_data" | jq -c '.tools | to_entries[] | {count: .value}')
|
|
_TOOL_OK=$((_TOOL_CALLS_TOTAL > _TOOL_ERRORS ? _TOOL_CALLS_TOTAL - _TOOL_ERRORS : _TOOL_CALLS_TOTAL))
|
|
|
|
# Pre-compute token totals for cache/cost renderers
|
|
_TOTAL_INPUT=0 _TOTAL_OUTPUT=0 _TOTAL_CACHE_READ=0 _TOTAL_CACHE_CREATE=0
|
|
while IFS= read -r model_entry; do
|
|
local input output cache_read cache_create
|
|
input=$(echo "$model_entry" | jq -r '.input')
|
|
output=$(echo "$model_entry" | jq -r '.output')
|
|
cache_read=$(echo "$model_entry" | jq -r '.cache_read')
|
|
cache_create=$(echo "$model_entry" | jq -r '.cache_create')
|
|
_TOTAL_INPUT=$((_TOTAL_INPUT + input))
|
|
_TOTAL_OUTPUT=$((_TOTAL_OUTPUT + output))
|
|
_TOTAL_CACHE_READ=$((_TOTAL_CACHE_READ + cache_read))
|
|
_TOTAL_CACHE_CREATE=$((_TOTAL_CACHE_CREATE + cache_create))
|
|
done < <(echo "$session_data" | jq -c '.models | to_entries[] | {input: .value.input, output: .value.output, cache_read: .value.cache_read, cache_create: .value.cache_create}')
|
|
|
|
# Render sections in configured order
|
|
local buf=""
|
|
buf+=$'\n'
|
|
buf+="${BOLD}═══ Session Summary ═══════════════════${RESET}"$'\n'
|
|
|
|
local section_output
|
|
IFS=',' read -ra sections <<< "$SECTION_ORDER"
|
|
for section in "${sections[@]}"; do
|
|
section=$(echo "$section" | tr -d ' ') # trim whitespace
|
|
if is_section_enabled "$section" && type "render_${section}" &>/dev/null; then
|
|
section_output=$(render_${section})
|
|
if [[ -n "$section_output" ]]; then
|
|
buf+="$section_output"$'\n'
|
|
fi
|
|
fi
|
|
done
|
|
|
|
buf+="${BOLD}═══════════════════════════════════════${RESET}"$'\n'
|
|
|
|
printf '%s\n' "$buf" > "$OUTPUT_TARGET"
|
|
}
|
|
|
|
log_summary() {
|
|
local session_id="$1"
|
|
local session_meta="$2"
|
|
local session_data="$3"
|
|
local cost="$4"
|
|
local cwd="$5"
|
|
local git_branch="${6:-unknown}"
|
|
local rtk_data="${7:-}"
|
|
local exit_reason="${8:-}"
|
|
local git_diff_data="${9:-}"
|
|
|
|
# Ensure log directory exists
|
|
mkdir -p "$LOG_DIR"
|
|
|
|
local log_file="$LOG_DIR/session-summaries.jsonl"
|
|
|
|
# Calculate total tokens
|
|
local total_input=0 total_output=0 total_cache_read=0 total_cache_create=0
|
|
|
|
while IFS= read -r model_entry; do
|
|
local input output cache_read cache_create
|
|
input=$(echo "$model_entry" | jq -r '.input')
|
|
output=$(echo "$model_entry" | jq -r '.output')
|
|
cache_read=$(echo "$model_entry" | jq -r '.cache_read')
|
|
cache_create=$(echo "$model_entry" | jq -r '.cache_create')
|
|
|
|
total_input=$((total_input + input))
|
|
total_output=$((total_output + output))
|
|
total_cache_read=$((total_cache_read + cache_read))
|
|
total_cache_create=$((total_cache_create + cache_create))
|
|
done < <(echo "$session_data" | jq -c '.models | to_entries[] | {input: .value.input, output: .value.output, cache_read: .value.cache_read, cache_create: .value.cache_create}')
|
|
|
|
# Calculate wall time for log
|
|
local first_ts last_ts
|
|
first_ts=$(echo "$session_data" | jq -r '.first_ts // "0"')
|
|
last_ts=$(echo "$session_data" | jq -r '.last_ts // "0"')
|
|
|
|
local duration_wall_ms=0
|
|
if [[ $first_ts != "null" && $last_ts != "null" && $first_ts != "0" && $last_ts != "0" ]]; then
|
|
duration_wall_ms=$(jq -n --arg first "$first_ts" --arg last "$last_ts" '
|
|
(($last | split(".")[0] + "Z" | fromdate) - ($first | split(".")[0] + "Z" | fromdate)) * 1000
|
|
' 2>/dev/null || echo "0")
|
|
fi
|
|
|
|
# Cache hit rate
|
|
local cache_hit_rate="0"
|
|
local cache_denominator=$((total_cache_read + total_input))
|
|
if [[ $cache_denominator -gt 0 ]]; then
|
|
cache_hit_rate=$(bc <<< "scale=1; $total_cache_read * 100 / $cache_denominator")
|
|
[[ "$cache_hit_rate" == .* ]] && cache_hit_rate="0$cache_hit_rate"
|
|
fi
|
|
|
|
# File stats
|
|
local file_stats
|
|
file_stats=$(echo "$session_data" | jq '
|
|
. as $data |
|
|
{
|
|
read_only: [.files_read | keys[] | . as $p | select(($data.files_edited | has($p)) | not) | select(($data.files_created | has($p)) | not)] | length,
|
|
edited: (.files_edited | length),
|
|
created: [.files_created | keys[] | . as $p | select(($data.files_edited | has($p)) | not)] | length,
|
|
top_edited: [.files_edited | to_entries | sort_by(-.value) | .[:5][] | {name: (.key | split("/") | last), count: .value}]
|
|
}
|
|
')
|
|
|
|
# RTK savings for log (JSON or null)
|
|
local rtk_json="${rtk_data:-null}"
|
|
[[ -z "$rtk_json" ]] && rtk_json="null"
|
|
|
|
# Git diff for log (JSON or null)
|
|
local git_json="${git_diff_data:-null}"
|
|
[[ -z "$git_json" || "$git_json" == "{}" ]] && git_json="null"
|
|
|
|
# v3 new fields from session_data
|
|
local error_details loc_added loc_removed user_prompts thinking_blocks peak_input
|
|
error_details=$(echo "$session_data" | jq -c '.error_details // []')
|
|
loc_added=$(echo "$session_data" | jq -r '.loc_added // 0')
|
|
loc_removed=$(echo "$session_data" | jq -r '.loc_removed // 0')
|
|
user_prompts=$(echo "$session_data" | jq -r '.user_prompts // 0')
|
|
thinking_blocks=$(echo "$session_data" | jq -r '.thinking_blocks // 0')
|
|
peak_input=$(echo "$session_data" | jq -r '.peak_input // 0')
|
|
|
|
# Features for log
|
|
local mcp_servers agents skills has_teams has_plan_mode
|
|
mcp_servers=$(echo "$session_data" | jq -c '.mcp_servers // {}')
|
|
agents=$(echo "$session_data" | jq -c '.agents // {}')
|
|
skills=$(echo "$session_data" | jq -c '.skills | unique // []')
|
|
has_teams=$(echo "$session_data" | jq -r '.has_teams // false')
|
|
has_plan_mode=$(echo "$session_data" | jq -r '.has_plan_mode // false')
|
|
|
|
# Build log entry
|
|
local log_entry
|
|
log_entry=$(jq -cn \
|
|
--arg timestamp "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" \
|
|
--arg session_id "$session_id" \
|
|
--arg session_name "$(echo "$session_meta" | jq -r '.summary // "Unnamed"')" \
|
|
--arg git_branch "$git_branch" \
|
|
--arg project "$cwd" \
|
|
--arg exit_reason "${exit_reason:-unknown}" \
|
|
--argjson duration_wall_ms "$duration_wall_ms" \
|
|
--argjson duration_active_ms "$(echo "$session_data" | jq -r '.turn_ms // 0')" \
|
|
--argjson turns "$(echo "$session_data" | jq -r '.turns // 0')" \
|
|
--argjson user_prompts "$user_prompts" \
|
|
--argjson api_requests "$(echo "$session_data" | jq -r '.api_requests // 0')" \
|
|
--argjson tool_calls "$(echo "$session_data" | jq -c '.tools // {}')" \
|
|
--argjson tool_errors "$(echo "$session_data" | jq -r '.tool_errors // 0')" \
|
|
--argjson error_details "$error_details" \
|
|
--argjson models "$(echo "$session_data" | jq -c '.models // {}')" \
|
|
--argjson total_input "$total_input" \
|
|
--argjson total_output "$total_output" \
|
|
--argjson total_cache_read "$total_cache_read" \
|
|
--argjson total_cache_create "$total_cache_create" \
|
|
--argjson cache_hit_rate "$cache_hit_rate" \
|
|
--argjson files "$file_stats" \
|
|
--argjson loc_added "$loc_added" \
|
|
--argjson loc_removed "$loc_removed" \
|
|
--argjson git_diff "$git_json" \
|
|
--argjson thinking_blocks "$thinking_blocks" \
|
|
--argjson peak_input "$peak_input" \
|
|
--argjson mcp_servers "$mcp_servers" \
|
|
--argjson agents "$agents" \
|
|
--argjson skills "$skills" \
|
|
--argjson has_teams "$has_teams" \
|
|
--argjson has_plan_mode "$has_plan_mode" \
|
|
--argjson rtk_savings "$rtk_json" \
|
|
--arg cost_usd "${cost:-0}" \
|
|
'{
|
|
timestamp: $timestamp,
|
|
session_id: $session_id,
|
|
session_name: $session_name,
|
|
git_branch: $git_branch,
|
|
project: $project,
|
|
exit_reason: $exit_reason,
|
|
duration_wall_ms: $duration_wall_ms,
|
|
duration_active_ms: $duration_active_ms,
|
|
turns: $turns,
|
|
user_prompts: $user_prompts,
|
|
api_requests: $api_requests,
|
|
tool_calls: $tool_calls,
|
|
tool_errors: $tool_errors,
|
|
error_details: $error_details,
|
|
models: $models,
|
|
total_tokens: {
|
|
input: $total_input,
|
|
output: $total_output,
|
|
cache_read: $total_cache_read,
|
|
cache_create: $total_cache_create
|
|
},
|
|
cache_hit_rate: $cache_hit_rate,
|
|
files: $files,
|
|
loc: { added: $loc_added, removed: $loc_removed },
|
|
git_diff: $git_diff,
|
|
thinking_blocks: $thinking_blocks,
|
|
peak_input: $peak_input,
|
|
features: {
|
|
mcp_servers: $mcp_servers,
|
|
agents: $agents,
|
|
skills: $skills,
|
|
has_teams: $has_teams,
|
|
has_plan_mode: $has_plan_mode
|
|
},
|
|
rtk_savings: $rtk_savings,
|
|
cost_usd: ($cost_usd | tonumber)
|
|
}'
|
|
)
|
|
|
|
# Append to log file
|
|
echo "$log_entry" >> "$log_file"
|
|
}
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
# Main
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
main() {
|
|
# Skip if disabled
|
|
if [[ "$SKIP" == "1" ]]; then
|
|
exit 0
|
|
fi
|
|
|
|
# Check dependencies
|
|
check_dependencies
|
|
|
|
# Read hook input (SessionEnd receives JSON on stdin with session_id, transcript_path, cwd, reason)
|
|
local input=""
|
|
input=$(cat 2>/dev/null) || true
|
|
|
|
# Extract session metadata from stdin JSON
|
|
local session_id=""
|
|
local cwd="${CLAUDE_PROJECT_DIR:-$(pwd)}"
|
|
local exit_reason=""
|
|
local transcript_path=""
|
|
|
|
if [[ -n "$input" ]]; then
|
|
session_id=$(echo "$input" | jq -r '.session_id // ""')
|
|
cwd=$(echo "$input" | jq -r '.cwd // "'"$cwd"'"')
|
|
transcript_path=$(echo "$input" | jq -r '.transcript_path // ""')
|
|
|
|
# Parse exit reason from stdin
|
|
local raw_reason
|
|
raw_reason=$(echo "$input" | jq -r '.reason // ""')
|
|
case "$raw_reason" in
|
|
prompt_input_exit|user_exit) exit_reason="user" ;;
|
|
clear) exit_reason="clear" ;;
|
|
context_limit) exit_reason="context" ;;
|
|
api_error) exit_reason="error" ;;
|
|
"") exit_reason="" ;;
|
|
*) exit_reason="$raw_reason" ;;
|
|
esac
|
|
fi
|
|
|
|
# Locate JSONL file: prefer transcript_path from stdin, then locate by session_id
|
|
local jsonl_path=""
|
|
|
|
if [[ -n "$transcript_path" && -f "$transcript_path" ]]; then
|
|
jsonl_path="$transcript_path"
|
|
# Extract session_id from transcript_path if not set
|
|
[[ -z "$session_id" ]] && session_id=$(basename "$jsonl_path" .jsonl)
|
|
fi
|
|
|
|
if [[ -z "$jsonl_path" && -n "$session_id" ]]; then
|
|
jsonl_path=$(locate_jsonl "$session_id" "$cwd")
|
|
fi
|
|
|
|
# Fallback: find most recently modified JSONL in project dir
|
|
if [[ -z "$jsonl_path" || ! -f "$jsonl_path" ]]; then
|
|
local encoded_path
|
|
encoded_path=$(echo "$cwd" | tr '/' '-')
|
|
local project_dir="$HOME/.claude/projects/${encoded_path}"
|
|
|
|
if [[ -d "$project_dir" ]]; then
|
|
# Use subshell to avoid pipefail+SIGPIPE on ls|head with many files
|
|
jsonl_path=$(set +o pipefail; ls -t "$project_dir"/*.jsonl 2>/dev/null | head -1)
|
|
fi
|
|
|
|
# Extract session_id from filename
|
|
if [[ -n "$jsonl_path" ]]; then
|
|
session_id=$(basename "$jsonl_path" .jsonl)
|
|
fi
|
|
fi
|
|
|
|
# No session data found at all
|
|
if [[ -z "$jsonl_path" || ! -f "$jsonl_path" ]]; then
|
|
exit 0
|
|
fi
|
|
|
|
# Extract session data (pass feature flags to skip expensive ops when disabled)
|
|
local session_data
|
|
session_data=$(extract_session_data "$jsonl_path" \
|
|
"$LOC_ENABLED" "$FEATURES_ENABLED" "$ERRORS_ENABLED" \
|
|
"$THINKING_ENABLED" "$CONTEXT_ENABLED")
|
|
|
|
# Get session metadata
|
|
local session_meta
|
|
session_meta=$(get_session_meta "$session_id" "$cwd")
|
|
|
|
# Resolve git branch (session meta first, then JSONL fallback)
|
|
local git_branch
|
|
git_branch=$(echo "$session_meta" | jq -r '.gitBranch // empty' 2>/dev/null)
|
|
if [[ -z "$git_branch" ]]; then
|
|
git_branch=$(echo "$session_data" | jq -r '.git_branch // "unknown"')
|
|
fi
|
|
|
|
# Calculate cost
|
|
local cost
|
|
cost=$(calculate_cost "$session_id" "$session_data")
|
|
|
|
# Calculate RTK savings (if enabled)
|
|
local rtk_data=""
|
|
if [[ "$RTK_ENABLED" == "1" ]]; then
|
|
rtk_data=$(calculate_rtk_savings)
|
|
fi
|
|
|
|
# Collect git diff (if enabled)
|
|
local git_diff_data=""
|
|
if is_section_enabled "git"; then
|
|
git_diff_data=$(collect_git_diff "$cwd")
|
|
fi
|
|
|
|
# Format and display output
|
|
format_output "$session_id" "$session_meta" "$session_data" "$cost" "$git_branch" \
|
|
"$rtk_data" "$exit_reason" "$git_diff_data"
|
|
|
|
# Log summary
|
|
log_summary "$session_id" "$session_meta" "$session_data" "$cost" "$cwd" "$git_branch" \
|
|
"$rtk_data" "$exit_reason" "$git_diff_data"
|
|
|
|
exit 0
|
|
}
|
|
|
|
main "$@"
|