pythonasyncioapiasyncfastapiframeworkjsonjson-schemaopenapiopenapi3pydanticpython-typespython3redocreststarletteswaggerswagger-uiuvicornweb
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
720 lines
31 KiB
720 lines
31 KiB
#!/usr/bin/env python3
|
|
"""
|
|
Process raw transcript to extract all messages and generate summaries.
|
|
Handles both incremental (Stop event) and final (SessionEnd) processing.
|
|
Cross-platform support for Windows, macOS, and Linux.
|
|
"""
|
|
import json
|
|
import sys
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from collections import defaultdict
|
|
from claude_code_capture_utils import get_log_file_path, add_ab_metadata, detect_model_lane, get_experiment_root
|
|
|
|
def read_and_process_raw_transcript(transcript_path):
|
|
"""
|
|
Read raw transcript and extract all unique messages.
|
|
Returns deduplicated messages with last occurrence (final state).
|
|
Also extracts thinking blocks as separate entries.
|
|
"""
|
|
if not os.path.exists(transcript_path):
|
|
return []
|
|
|
|
# Track assistant messages by message.id (they have IDs, can have duplicates)
|
|
assistant_messages = {}
|
|
# Track thinking blocks separately (won't be counted in token usage)
|
|
thinking_blocks = {}
|
|
# Track user messages by uuid (they don't have message.id, use uuid)
|
|
user_messages = {}
|
|
|
|
try:
|
|
with open(transcript_path, 'r', encoding='utf-8') as f:
|
|
for line in f:
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
|
|
try:
|
|
event = json.loads(line)
|
|
event_type = event.get('type')
|
|
message = event.get('message', {})
|
|
|
|
# Process assistant messages (have message.id)
|
|
if event_type == 'assistant':
|
|
msg_id = message.get('id')
|
|
if msg_id:
|
|
# Check for thinking blocks in content
|
|
content = message.get('content', [])
|
|
if isinstance(content, list):
|
|
for item in content:
|
|
if isinstance(item, dict) and item.get('type') == 'thinking':
|
|
# Extract thinking block as separate entry
|
|
thinking_entry = {
|
|
'type': 'assistant_thinking',
|
|
'timestamp': event.get('timestamp'),
|
|
'message_id': msg_id,
|
|
'thinking_content': item.get('thinking', ''),
|
|
'session_id': event.get('sessionId'),
|
|
'cwd': event.get('cwd')
|
|
}
|
|
# Use message_id as key (one thinking block per message)
|
|
thinking_blocks[msg_id] = thinking_entry
|
|
|
|
# Store/overwrite with last occurrence (streaming)
|
|
assistant_msg = {
|
|
'type': event_type,
|
|
'timestamp': event.get('timestamp'),
|
|
'message': message,
|
|
'session_id': event.get('sessionId'),
|
|
'cwd': event.get('cwd')
|
|
}
|
|
|
|
# Preserve stop_reason from message if present
|
|
if message.get('stop_reason'):
|
|
assistant_msg['stop_reason'] = message['stop_reason']
|
|
|
|
assistant_messages[msg_id] = assistant_msg
|
|
|
|
# Process user messages (use uuid as key, no message.id)
|
|
elif event_type == 'user':
|
|
uuid = event.get('uuid')
|
|
if uuid:
|
|
user_msg = {
|
|
'type': event_type,
|
|
'timestamp': event.get('timestamp'),
|
|
'message': message,
|
|
'session_id': event.get('sessionId'),
|
|
'cwd': event.get('cwd'),
|
|
'uuid': uuid
|
|
}
|
|
|
|
# Preserve thinking metadata if present
|
|
if 'thinkingMetadata' in event:
|
|
user_msg['thinkingMetadata'] = event['thinkingMetadata']
|
|
|
|
# Preserve isMeta flag if present
|
|
if event.get('isMeta'):
|
|
user_msg['isMeta'] = event['isMeta']
|
|
|
|
user_messages[uuid] = user_msg
|
|
|
|
except json.JSONDecodeError:
|
|
continue
|
|
|
|
except Exception as e:
|
|
print(f"[ERROR] Reading raw transcript: {e}", file=sys.stderr)
|
|
return []
|
|
|
|
# Combine all: assistant messages + thinking blocks + user messages
|
|
# Thinking blocks inserted right before their corresponding assistant message
|
|
all_messages = []
|
|
|
|
# First, add all messages with their thinking blocks properly ordered
|
|
assistant_list = list(assistant_messages.values())
|
|
user_list = list(user_messages.values())
|
|
|
|
# Combine and sort all by timestamp
|
|
combined = assistant_list + user_list
|
|
combined.sort(key=lambda m: m.get('timestamp', ''))
|
|
|
|
# Insert thinking blocks right before their parent assistant message
|
|
for msg in combined:
|
|
if msg['type'] == 'assistant':
|
|
msg_id = msg['message'].get('id')
|
|
# If there's a thinking block for this message, insert it first
|
|
if msg_id in thinking_blocks:
|
|
all_messages.append(thinking_blocks[msg_id])
|
|
all_messages.append(msg)
|
|
|
|
return all_messages
|
|
|
|
def aggregate_token_usage(messages):
|
|
"""Aggregate token usage from all assistant messages.
|
|
Note: assistant_thinking entries are explicitly excluded to avoid double-counting.
|
|
"""
|
|
total_usage = {
|
|
'total_input_tokens': 0,
|
|
'total_output_tokens': 0,
|
|
'total_cache_creation_tokens': 0,
|
|
'total_cache_read_tokens': 0,
|
|
'total_ephemeral_5m_tokens': 0,
|
|
'total_ephemeral_1h_tokens': 0,
|
|
'service_tier': None
|
|
}
|
|
|
|
for msg_data in messages:
|
|
# Only count tokens from 'assistant' type, NOT 'assistant_thinking'
|
|
# Thinking tokens are already included in the parent assistant message's output_tokens
|
|
if msg_data['type'] == 'assistant':
|
|
message = msg_data['message']
|
|
usage = message.get('usage', {})
|
|
|
|
if usage:
|
|
total_usage['total_input_tokens'] += usage.get('input_tokens', 0)
|
|
total_usage['total_output_tokens'] += usage.get('output_tokens', 0)
|
|
total_usage['total_cache_creation_tokens'] += usage.get('cache_creation_input_tokens', 0)
|
|
total_usage['total_cache_read_tokens'] += usage.get('cache_read_input_tokens', 0)
|
|
|
|
cache_creation = usage.get('cache_creation', {})
|
|
total_usage['total_ephemeral_5m_tokens'] += cache_creation.get('ephemeral_5m_input_tokens', 0)
|
|
total_usage['total_ephemeral_1h_tokens'] += cache_creation.get('ephemeral_1h_input_tokens', 0)
|
|
|
|
if usage.get('service_tier'):
|
|
total_usage['service_tier'] = usage.get('service_tier')
|
|
|
|
# Add calculated total
|
|
total_usage['total_actual_input_tokens'] = (
|
|
total_usage['total_input_tokens'] +
|
|
total_usage['total_cache_creation_tokens'] +
|
|
total_usage['total_cache_read_tokens']
|
|
)
|
|
|
|
return total_usage
|
|
|
|
def analyze_tool_calls(messages):
|
|
"""Extract tool call metrics from messages."""
|
|
tool_calls = defaultdict(int)
|
|
tool_results = defaultdict(int)
|
|
|
|
for msg_data in messages:
|
|
# Skip entries without 'message' key (e.g., assistant_thinking)
|
|
if 'message' not in msg_data:
|
|
continue
|
|
message = msg_data['message']
|
|
content = message.get('content', [])
|
|
|
|
if not isinstance(content, list):
|
|
continue
|
|
|
|
for item in content:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
|
|
if item.get('type') == 'tool_use':
|
|
tool_name = item.get('name', 'unknown')
|
|
tool_calls[tool_name] += 1
|
|
|
|
elif item.get('type') == 'tool_result':
|
|
# Try to infer tool name from context (simplified)
|
|
tool_results['total'] += 1
|
|
|
|
return {
|
|
'tool_calls_by_type': dict(tool_calls),
|
|
'total_tool_calls': sum(tool_calls.values()),
|
|
'total_tool_results': tool_results.get('total', 0)
|
|
}
|
|
|
|
def analyze_thinking_usage(messages, transcript_path):
|
|
"""Analyze thinking mode usage in messages."""
|
|
thinking_stats = {
|
|
'thinking_enabled_turns': 0,
|
|
'thinking_disabled_turns': 0,
|
|
'assistant_with_thinking_blocks': 0,
|
|
'thinking_levels': defaultdict(int)
|
|
}
|
|
|
|
# Track which turns had thinking enabled (from user thinkingMetadata)
|
|
for msg_data in messages:
|
|
if msg_data['type'] == 'user' and 'thinkingMetadata' in msg_data:
|
|
metadata = msg_data['thinkingMetadata']
|
|
if not metadata.get('disabled', True):
|
|
thinking_stats['thinking_enabled_turns'] += 1
|
|
level = metadata.get('level', 'none')
|
|
thinking_stats['thinking_levels'][level] += 1
|
|
else:
|
|
thinking_stats['thinking_disabled_turns'] += 1
|
|
|
|
# Count assistant messages with thinking blocks
|
|
# Check ALL occurrences in raw transcript (not just final deduplicated state)
|
|
assistant_msg_ids_with_thinking = set()
|
|
|
|
try:
|
|
if os.path.exists(transcript_path):
|
|
with open(transcript_path, 'r', encoding='utf-8') as f:
|
|
for line in f:
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
try:
|
|
event = json.loads(line)
|
|
if event.get('type') == 'assistant':
|
|
message = event.get('message', {})
|
|
msg_id = message.get('id')
|
|
content = message.get('content', [])
|
|
|
|
if msg_id and isinstance(content, list):
|
|
# Check if this occurrence has thinking
|
|
has_thinking = any(
|
|
isinstance(item, dict) and item.get('type') == 'thinking'
|
|
for item in content
|
|
)
|
|
if has_thinking:
|
|
assistant_msg_ids_with_thinking.add(msg_id)
|
|
except:
|
|
continue
|
|
except Exception:
|
|
pass
|
|
|
|
thinking_stats['assistant_with_thinking_blocks'] = len(assistant_msg_ids_with_thinking)
|
|
|
|
return {
|
|
'thinking_enabled_turns': thinking_stats['thinking_enabled_turns'],
|
|
'thinking_disabled_turns': thinking_stats['thinking_disabled_turns'],
|
|
'assistant_with_thinking_blocks': thinking_stats['assistant_with_thinking_blocks'],
|
|
'thinking_levels': dict(thinking_stats['thinking_levels'])
|
|
}
|
|
|
|
def calculate_git_metrics(cwd, base_commit):
|
|
"""Calculate git metrics from diff."""
|
|
try:
|
|
original_cwd = os.getcwd()
|
|
os.chdir(cwd)
|
|
|
|
if not base_commit:
|
|
os.chdir(original_cwd)
|
|
return {}
|
|
|
|
# Add untracked files
|
|
excluded_patterns = ['.claude/', '__pycache__/', 'node_modules/', '.mypy_cache/',
|
|
'.pytest_cache/', '.DS_Store', '.vscode/', '.idea/']
|
|
|
|
untracked_result = subprocess.run(
|
|
['git', 'ls-files', '--others', '--exclude-standard'],
|
|
capture_output=True, text=True, timeout=30
|
|
)
|
|
|
|
if untracked_result.returncode == 0 and untracked_result.stdout.strip():
|
|
untracked_files = [
|
|
f.strip() for f in untracked_result.stdout.strip().split('\n')
|
|
if f.strip() and not any(pattern in f for pattern in excluded_patterns)
|
|
]
|
|
|
|
for file in untracked_files:
|
|
subprocess.run(['git', 'add', '-N', file], capture_output=True, timeout=5)
|
|
|
|
# Calculate numstat
|
|
result = subprocess.run(
|
|
['git', 'diff', '--numstat', base_commit, '--', '.',
|
|
':!.claude', ':!**/.mypy_cache', ':!**/__pycache__', ':!**/.pytest_cache',
|
|
':!**/.DS_Store', ':!**/node_modules', ':!**/.vscode', ':!**/.idea'],
|
|
capture_output=True, text=True, timeout=30
|
|
)
|
|
|
|
os.chdir(original_cwd)
|
|
|
|
if result.returncode != 0:
|
|
return {}
|
|
|
|
lines = result.stdout.strip().split('\n') if result.stdout.strip() else []
|
|
files_changed = 0
|
|
total_lines_changed = 0
|
|
|
|
for line in lines:
|
|
if line.strip():
|
|
parts = line.split('\t')
|
|
if len(parts) >= 3:
|
|
try:
|
|
added = int(parts[0]) if parts[0] != '-' else 0
|
|
removed = int(parts[1]) if parts[1] != '-' else 0
|
|
files_changed += 1
|
|
total_lines_changed += added + removed
|
|
except ValueError:
|
|
continue
|
|
|
|
return {
|
|
"files_changed_count": files_changed,
|
|
"lines_of_code_changed_count": total_lines_changed
|
|
}
|
|
|
|
except Exception as e:
|
|
print(f"Warning: Could not calculate git metrics: {e}", file=sys.stderr)
|
|
if 'original_cwd' in locals():
|
|
os.chdir(original_cwd)
|
|
return {}
|
|
|
|
def copy_raw_transcript(transcript_path, session_id, cwd):
|
|
"""Copy raw transcript to logs folder."""
|
|
try:
|
|
source_path = Path(transcript_path)
|
|
if not source_path.exists():
|
|
print(f"Warning: Raw transcript not found at {source_path}", file=sys.stderr)
|
|
return False
|
|
|
|
model_lane = detect_model_lane(cwd)
|
|
experiment_root = get_experiment_root(cwd)
|
|
|
|
if model_lane and experiment_root:
|
|
logs_dir = Path(experiment_root) / "logs" / model_lane
|
|
logs_dir.mkdir(parents=True, exist_ok=True)
|
|
dest_path = logs_dir / f"session_{session_id}_raw.jsonl"
|
|
else:
|
|
project_dir = os.environ.get('CLAUDE_PROJECT_DIR', os.getcwd())
|
|
logs_dir = Path(project_dir) / "logs"
|
|
logs_dir.mkdir(exist_ok=True)
|
|
dest_path = logs_dir / f"session_{session_id}_raw.jsonl"
|
|
|
|
shutil.copy2(source_path, dest_path)
|
|
print(f"[OK] Copied raw transcript to {dest_path}")
|
|
return True
|
|
|
|
except Exception as e:
|
|
print(f"[ERROR] Copying raw transcript: {e}", file=sys.stderr)
|
|
return False
|
|
|
|
def get_base_commit_from_log(log_file):
|
|
"""Extract base commit from session_start event."""
|
|
try:
|
|
if not os.path.exists(log_file):
|
|
return None
|
|
|
|
with open(log_file, 'r', encoding='utf-8') as f:
|
|
for line in f:
|
|
line = line.strip()
|
|
if line:
|
|
try:
|
|
event = json.loads(line)
|
|
if event.get('type') == 'session_start':
|
|
git_metadata = event.get('git_metadata', {})
|
|
return git_metadata.get('base_commit')
|
|
except json.JSONDecodeError:
|
|
continue
|
|
return None
|
|
except Exception:
|
|
return None
|
|
|
|
def main():
|
|
try:
|
|
if len(sys.argv) < 2:
|
|
print("Usage: process_transcript.py [incremental|final]", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
mode = sys.argv[1].lower()
|
|
if mode not in ["incremental", "final"]:
|
|
print("Mode must be 'incremental' or 'final'", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
input_data = json.load(sys.stdin)
|
|
|
|
session_id = input_data.get("session_id", "unknown")
|
|
transcript_path = input_data.get("transcript_path", "")
|
|
cwd = input_data.get("cwd", "")
|
|
|
|
log_file = get_log_file_path(session_id, cwd)
|
|
|
|
if mode == "incremental":
|
|
# Stop event: incremental processing (fault tolerance)
|
|
messages = read_and_process_raw_transcript(transcript_path)
|
|
|
|
if not messages:
|
|
return
|
|
|
|
# Append all new unique messages to log
|
|
# Track what we've already logged (assistant by msg_id, thinking by msg_id, user by uuid)
|
|
existing_assistant_ids = set()
|
|
existing_thinking_ids = set()
|
|
existing_user_uuids = set()
|
|
|
|
if os.path.exists(log_file):
|
|
with open(log_file, 'r', encoding='utf-8') as f:
|
|
for line in f:
|
|
try:
|
|
event = json.loads(line)
|
|
event_type = event.get('type')
|
|
if event_type == 'assistant':
|
|
msg = event.get('message', {})
|
|
if msg.get('id'):
|
|
existing_assistant_ids.add(msg['id'])
|
|
elif event_type == 'assistant_thinking':
|
|
msg_id = event.get('message_id')
|
|
if msg_id:
|
|
existing_thinking_ids.add(msg_id)
|
|
elif event_type == 'user':
|
|
uuid = event.get('uuid')
|
|
if uuid:
|
|
existing_user_uuids.add(uuid)
|
|
except:
|
|
continue
|
|
|
|
# Append new messages
|
|
new_count = 0
|
|
with open(log_file, "a", encoding="utf-8") as f:
|
|
for msg_data in messages:
|
|
# Check if this is a new message
|
|
is_new = False
|
|
if msg_data['type'] == 'assistant':
|
|
msg_id = msg_data['message'].get('id')
|
|
if msg_id and msg_id not in existing_assistant_ids:
|
|
is_new = True
|
|
existing_assistant_ids.add(msg_id)
|
|
elif msg_data['type'] == 'assistant_thinking':
|
|
msg_id = msg_data.get('message_id')
|
|
if msg_id and msg_id not in existing_thinking_ids:
|
|
is_new = True
|
|
existing_thinking_ids.add(msg_id)
|
|
elif msg_data['type'] == 'user':
|
|
uuid = msg_data.get('uuid')
|
|
if uuid and uuid not in existing_user_uuids:
|
|
is_new = True
|
|
existing_user_uuids.add(uuid)
|
|
|
|
if is_new:
|
|
# Add A/B metadata
|
|
log_entry = add_ab_metadata(msg_data.copy(), cwd)
|
|
f.write(json.dumps(log_entry) + "\n")
|
|
new_count += 1
|
|
|
|
if new_count > 0:
|
|
print(f"[OK] Processed {new_count} new messages (total: {len(messages)} unique)")
|
|
|
|
elif mode == "final":
|
|
# SessionEnd: complete processing + summary
|
|
|
|
# Step 1: Copy raw transcript
|
|
copy_raw_transcript(transcript_path, session_id, cwd)
|
|
|
|
# Step 2: Process complete raw transcript
|
|
messages = read_and_process_raw_transcript(transcript_path)
|
|
|
|
if not messages:
|
|
print("Warning: No messages found in raw transcript", file=sys.stderr)
|
|
return
|
|
|
|
# Step 3: REBUILD processed log in perfect chronological order
|
|
# Read existing non-message events (session_start, etc.)
|
|
non_message_events = []
|
|
|
|
if os.path.exists(log_file):
|
|
with open(log_file, 'r', encoding='utf-8') as f:
|
|
for line in f:
|
|
try:
|
|
event = json.loads(line)
|
|
# Keep session_start and other non-message events
|
|
# Exclude assistant, assistant_thinking, and user messages (they come from raw transcript)
|
|
if event.get('type') not in ['assistant', 'assistant_thinking', 'user']:
|
|
non_message_events.append(event)
|
|
except:
|
|
continue
|
|
|
|
# Combine all events and sort by timestamp
|
|
session_start = [e for e in non_message_events if e.get('type') == 'session_start']
|
|
other_events = [e for e in non_message_events if e.get('type') != 'session_start']
|
|
|
|
# Build chronological list: session_start first, then messages sorted by time
|
|
all_events = []
|
|
|
|
# Add session_start first (if exists)
|
|
if session_start:
|
|
all_events.extend(session_start)
|
|
|
|
# Add all messages (already sorted by timestamp from read_and_process_raw_transcript)
|
|
# Messages already include thinking blocks inserted before their parent assistant message
|
|
for msg_data in messages:
|
|
all_events.append(add_ab_metadata(msg_data.copy(), cwd))
|
|
|
|
print(f"[OK] Rebuilding log with {len(all_events)} events in chronological order")
|
|
|
|
# Step 4: Generate session summary
|
|
usage_totals = aggregate_token_usage(messages)
|
|
tool_metrics = analyze_tool_calls(messages)
|
|
thinking_metrics = analyze_thinking_usage(messages, transcript_path)
|
|
|
|
# Calculate duration
|
|
timestamps = [
|
|
datetime.fromisoformat(msg['timestamp'].replace('Z', '+00:00'))
|
|
for msg in messages if msg.get('timestamp')
|
|
]
|
|
|
|
total_duration = 0
|
|
if len(timestamps) >= 2:
|
|
duration = max(timestamps) - min(timestamps)
|
|
total_duration = duration.total_seconds()
|
|
|
|
# Count messages with proper categorization
|
|
# Note: assistant_thinking is NOT counted as a separate message (it's part of assistant message)
|
|
assistant_count = sum(1 for m in messages if m['type'] == 'assistant')
|
|
thinking_count = sum(1 for m in messages if m['type'] == 'assistant_thinking')
|
|
|
|
# Categorize user messages
|
|
user_prompts = 0
|
|
tool_results = 0
|
|
system_messages = 0
|
|
|
|
for m in messages:
|
|
if m['type'] == 'user':
|
|
message_content = m['message'].get('content', '')
|
|
|
|
# Check if it's a system/meta message
|
|
if m.get('isMeta'):
|
|
system_messages += 1
|
|
# Check if it's a tool result
|
|
elif isinstance(message_content, list):
|
|
has_tool_result = any(
|
|
isinstance(item, dict) and item.get('type') == 'tool_result'
|
|
for item in message_content
|
|
)
|
|
if has_tool_result:
|
|
tool_results += 1
|
|
else:
|
|
user_prompts += 1
|
|
# Check if it's an exit/system command
|
|
elif isinstance(message_content, str) and (
|
|
'<command-name>' in message_content or
|
|
'<local-command-stdout>' in message_content
|
|
):
|
|
system_messages += 1
|
|
# Real user prompt (string content, not system)
|
|
elif isinstance(message_content, str):
|
|
user_prompts += 1
|
|
else:
|
|
user_prompts += 1 # Default to user prompt
|
|
|
|
total_user_events = user_prompts + tool_results + system_messages
|
|
|
|
# Calculate actual total messages (excluding thinking blocks as they're not separate messages)
|
|
actual_total_messages = assistant_count + total_user_events
|
|
|
|
# Get git metrics
|
|
base_commit = get_base_commit_from_log(log_file)
|
|
git_metrics = calculate_git_metrics(cwd, base_commit) if base_commit else {}
|
|
|
|
# Create session summary
|
|
model_lane = detect_model_lane(cwd)
|
|
|
|
summary = {
|
|
"type": "session_summary",
|
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
"session_id": session_id,
|
|
"transcript_path": transcript_path,
|
|
"cwd": cwd,
|
|
"summary_data": {
|
|
"total_duration_seconds": round(total_duration, 2),
|
|
"total_messages": actual_total_messages,
|
|
"assistant_messages": assistant_count,
|
|
"user_prompts": user_prompts,
|
|
"user_metrics": {
|
|
"user_prompts": user_prompts,
|
|
"tool_results": tool_results,
|
|
"system_messages": system_messages,
|
|
"total_user_events": total_user_events
|
|
},
|
|
"usage_totals": usage_totals,
|
|
"tool_metrics": tool_metrics,
|
|
"thinking_metrics": {
|
|
**thinking_metrics,
|
|
"assistant_thinking_blocks_captured": thinking_count
|
|
},
|
|
"git_metrics": git_metrics,
|
|
"files": {
|
|
"processed_log": f"session_{session_id}.jsonl",
|
|
"raw_transcript": f"session_{session_id}_raw.jsonl",
|
|
"git_diff": f"{model_lane}_diff.patch" if model_lane else None
|
|
},
|
|
"validation": {
|
|
"complete": True,
|
|
"unique_messages_processed": actual_total_messages,
|
|
"thinking_blocks_extracted": thinking_count
|
|
}
|
|
}
|
|
}
|
|
|
|
summary = add_ab_metadata(summary, cwd)
|
|
|
|
# Add session summary to events
|
|
all_events.append(summary)
|
|
|
|
# Step 5: Rewrite log file with all events in perfect chronological order
|
|
# Write to temp file first, then rename (atomic)
|
|
temp_log_file = log_file + ".tmp"
|
|
|
|
with open(temp_log_file, "w", encoding="utf-8") as f:
|
|
for event in all_events:
|
|
f.write(json.dumps(event) + "\n")
|
|
|
|
# Atomic rename
|
|
os.replace(temp_log_file, log_file)
|
|
|
|
print(f"[OK] Rebuilt log with {len(all_events)} events in chronological order")
|
|
print(f"[OK] Generated session summary: {actual_total_messages} messages, {assistant_count} assistant, {user_prompts} user prompts")
|
|
if thinking_count > 0:
|
|
print(f"[OK] Captured {thinking_count} thinking blocks (tokens already included in assistant output)")
|
|
print(f"[OK] User breakdown: {user_prompts} prompts, {tool_results} tool results, {system_messages} system")
|
|
print(f"[OK] Tokens: {usage_totals['total_actual_input_tokens']:,} input, {usage_totals['total_output_tokens']:,} output")
|
|
|
|
except Exception as e:
|
|
print(f"[ERROR] Processing transcript: {e}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
def calculate_git_metrics(cwd, base_commit):
|
|
"""Calculate git metrics from diff."""
|
|
try:
|
|
original_cwd = os.getcwd()
|
|
os.chdir(cwd)
|
|
|
|
if not base_commit:
|
|
os.chdir(original_cwd)
|
|
return {}
|
|
|
|
# Add untracked files
|
|
excluded_patterns = ['.claude/', '__pycache__/', 'node_modules/', '.mypy_cache/',
|
|
'.pytest_cache/', '.DS_Store', '.vscode/', '.idea/']
|
|
|
|
untracked_result = subprocess.run(
|
|
['git', 'ls-files', '--others', '--exclude-standard'],
|
|
capture_output=True, text=True, timeout=30
|
|
)
|
|
|
|
if untracked_result.returncode == 0 and untracked_result.stdout.strip():
|
|
untracked_files = [
|
|
f.strip() for f in untracked_result.stdout.strip().split('\n')
|
|
if f.strip() and not any(pattern in f for pattern in excluded_patterns)
|
|
]
|
|
|
|
for file in untracked_files:
|
|
subprocess.run(['git', 'add', '-N', file], capture_output=True, timeout=5)
|
|
|
|
# Calculate numstat
|
|
result = subprocess.run(
|
|
['git', 'diff', '--numstat', base_commit, '--', '.',
|
|
':!.claude', ':!**/.mypy_cache', ':!**/__pycache__', ':!**/.pytest_cache',
|
|
':!**/.DS_Store', ':!**/node_modules', ':!**/.vscode', ':!**/.idea'],
|
|
capture_output=True, text=True, timeout=30
|
|
)
|
|
|
|
os.chdir(original_cwd)
|
|
|
|
if result.returncode != 0:
|
|
return {}
|
|
|
|
lines = result.stdout.strip().split('\n') if result.stdout.strip() else []
|
|
files_changed = 0
|
|
total_lines_changed = 0
|
|
|
|
for line in lines:
|
|
if line.strip():
|
|
parts = line.split('\t')
|
|
if len(parts) >= 3:
|
|
try:
|
|
added = int(parts[0]) if parts[0] != '-' else 0
|
|
removed = int(parts[1]) if parts[1] != '-' else 0
|
|
files_changed += 1
|
|
total_lines_changed += added + removed
|
|
except ValueError:
|
|
continue
|
|
|
|
return {
|
|
"files_changed_count": files_changed,
|
|
"lines_of_code_changed_count": total_lines_changed
|
|
}
|
|
|
|
except Exception as e:
|
|
print(f"Warning: Could not calculate git metrics: {e}", file=sys.stderr)
|
|
if 'original_cwd' in locals():
|
|
os.chdir(original_cwd)
|
|
return {}
|
|
|
|
if __name__ == "__main__":
|
|
main()
|
|
|
|
|