diff --git a/.claude/hooks/capture_session_event.py b/.claude/hooks/capture_session_event.py new file mode 100755 index 0000000..46f7dfe --- /dev/null +++ b/.claude/hooks/capture_session_event.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +""" +Hook to capture session start/end events. +Cross-platform support for Windows, macOS, and Linux. +""" +import json +import sys +import os +import subprocess +from datetime import datetime, timezone +from claude_code_capture_utils import get_log_file_path, add_ab_metadata + +def get_git_metadata(repo_dir): + """Get current git commit and branch.""" + try: + # Get current commit hash + commit_result = subprocess.run( + ['git', 'rev-parse', 'HEAD'], + cwd=repo_dir, + capture_output=True, + text=True, + timeout=10 + ) + + # Get current branch + branch_result = subprocess.run( + ['git', 'branch', '--show-current'], + cwd=repo_dir, + capture_output=True, + text=True, + timeout=10 + ) + + git_metadata = { + "base_commit": commit_result.stdout.strip() if commit_result.returncode == 0 else None, + "branch": branch_result.stdout.strip() if branch_result.returncode == 0 else None, + "timestamp": datetime.now(timezone.utc).isoformat() + } + + if git_metadata["base_commit"]: + return git_metadata + else: + return None + + except Exception as e: + print(f"Warning: Could not capture git metadata: {e}", file=sys.stderr) + return None + +def main(): + try: + if len(sys.argv) < 2: + print("Usage: capture_session_event.py [start|end]", file=sys.stderr) + sys.exit(1) + + event_type = sys.argv[1].lower() + if event_type not in ["start", "end"]: + print("Event type must be 'start' or 'end'", 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", "") + + if event_type == "start": + # Session start: capture git metadata + git_metadata = get_git_metadata(cwd) + + log_entry = { + "type": "session_start", + "timestamp": datetime.now(timezone.utc).isoformat(), + "session_id": session_id, + "transcript_path": transcript_path, + "cwd": cwd, + "git_metadata": git_metadata + } + + log_entry = add_ab_metadata(log_entry, cwd) + + if git_metadata: + print(f"[OK] Captured git metadata: {git_metadata['base_commit'][:8]} on {git_metadata['branch']}") + + # Write session_start event + log_file = get_log_file_path(session_id, cwd) + with open(log_file, "a", encoding="utf-8") as f: + f.write(json.dumps(log_entry) + "\n") + + elif event_type == "end": + # Session end: log the event + log_entry = { + "type": "session_end", + "timestamp": datetime.now(timezone.utc).isoformat(), + "session_id": session_id, + "transcript_path": transcript_path, + "cwd": cwd, + "reason": input_data.get("reason", "") + } + + log_entry = add_ab_metadata(log_entry, cwd) + + # Write session_end event + log_file = get_log_file_path(session_id, cwd) + with open(log_file, "a", encoding="utf-8") as f: + f.write(json.dumps(log_entry) + "\n") + + except Exception as e: + print(f"[ERROR] Session {event_type}: {e}", file=sys.stderr) + sys.exit(1) + +if __name__ == "__main__": + main() diff --git a/.claude/hooks/claude_code_capture_utils.py b/.claude/hooks/claude_code_capture_utils.py new file mode 100755 index 0000000..fe2d754 --- /dev/null +++ b/.claude/hooks/claude_code_capture_utils.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +""" +Utility functions for A/B testing hooks. +Cross-platform support for Windows, macOS, and Linux. +""" +import json +import os +from pathlib import Path + +def detect_model_lane(cwd): + """Detect if we're in model_a or model_b directory.""" + path_parts = Path(cwd).parts + if 'model_a' in path_parts: + return 'model_a' + elif 'model_b' in path_parts: + return 'model_b' + return None + +def get_experiment_root(cwd): + """Get the experiment root directory (parent of model_a/model_b).""" + current_path = Path(cwd) + + # Check if we're inside a model_a or model_b directory + # Look for the parent that contains both model_a and model_b + for parent in [current_path] + list(current_path.parents): + if (parent / 'model_a').exists() and (parent / 'model_b').exists(): + return str(parent) + # Also check one level up (in case we're inside the cloned repo) + parent_up = parent.parent + if (parent_up / 'model_a').exists() and (parent_up / 'model_b').exists(): + return str(parent_up) + return None + +def read_manifest(experiment_root): + """Read the manifest.json file to get task_id and model assignments.""" + try: + manifest_path = os.path.join(experiment_root, 'manifest.json') + if os.path.exists(manifest_path): + with open(manifest_path, 'r', encoding='utf-8') as f: + return json.load(f) + except Exception: + pass + return {} + +def get_ab_metadata(cwd): + """Get A/B testing metadata (task_id, model_lane, model_name) from current directory.""" + model_lane = detect_model_lane(cwd) + experiment_root = get_experiment_root(cwd) + + if not model_lane or not experiment_root: + return {} + + manifest = read_manifest(experiment_root) + + metadata = { + "task_id": manifest.get("task_id"), + "model_lane": model_lane, + "experiment_root": experiment_root + } + + # Get model name from assignments + assignments = manifest.get("assignments", {}) + if model_lane in assignments: + metadata["model_name"] = assignments[model_lane] + + return metadata + +def get_log_file_path(session_id, cwd): + """Get the correct log file path for A/B testing (routes to model-specific directory).""" + model_lane = detect_model_lane(cwd) + experiment_root = get_experiment_root(cwd) + + if model_lane and experiment_root: + # Route to model-specific logs directory + logs_dir = os.path.join(experiment_root, "logs", model_lane) + os.makedirs(logs_dir, exist_ok=True) + return os.path.join(logs_dir, f"session_{session_id}.jsonl") + else: + # Fallback to current behavior + project_dir = os.environ.get("CLAUDE_PROJECT_DIR", os.getcwd()) + logs_dir = os.path.join(project_dir, "logs") + os.makedirs(logs_dir, exist_ok=True) + return os.path.join(logs_dir, f"session_{session_id}.jsonl") + +def add_ab_metadata(event, cwd): + """Add A/B testing metadata to an event.""" + ab_metadata = get_ab_metadata(cwd) + if ab_metadata: + for key, value in ab_metadata.items(): + if value is not None: + event[key] = value + return event + diff --git a/.claude/hooks/process_transcript.py b/.claude/hooks/process_transcript.py new file mode 100755 index 0000000..955560d --- /dev/null +++ b/.claude/hooks/process_transcript.py @@ -0,0 +1,720 @@ +#!/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 ( + '' in message_content or + '' 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() + diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..09edeb9 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,45 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "python .claude/hooks/capture_session_event.py start" + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "python .claude/hooks/process_transcript.py incremental" + } + ] + } + ], + "SessionEnd": [ + { + "hooks": [ + { + "type": "command", + "command": "python .claude/hooks/process_transcript.py final" + }, + { + "type": "command", + "command": "python .claude/hooks/capture_session_event.py end" + } + ] + } + ] + }, + "model": "Panther", + "env": { + "ANTHROPIC_AUTH_TOKEN": "sk-1234-tap-tap-go-new", + "ANTHROPIC_BASE_URL": "https://mercor-rl--litellm-proxy-serve.modal.run", + "ANTHROPIC_CUSTOM_HEADERS": "X-Session-Meta: task_id=TASK_13244;expert_name=Flavio Espinoza" + }, + "alwaysThinkingEnabled": true +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..8a84feb --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,22 @@ +{ + "workbench.colorCustomizations": { + "activityBar.activeBackground": "#ff9cab", + "activityBar.background": "#ff9cab", + "activityBar.foreground": "#15202b", + "activityBar.inactiveForeground": "#15202b99", + "activityBarBadge.background": "#138000", + "activityBarBadge.foreground": "#e7e7e7", + "commandCenter.border": "#15202b99", + "sash.hoverBorder": "#ff9cab", + "statusBar.background": "#ff6980", + "statusBar.foreground": "#15202b", + "statusBarItem.hoverBackground": "#ff3655", + "statusBarItem.remoteBackground": "#ff6980", + "statusBarItem.remoteForeground": "#15202b", + "titleBar.activeBackground": "#ff6980", + "titleBar.activeForeground": "#15202b", + "titleBar.inactiveBackground": "#ff698099", + "titleBar.inactiveForeground": "#15202b99" + }, + "peacock.color": "#ff6980" +} \ No newline at end of file diff --git a/CMakeLists.txt b/CMakeLists.txt index b60106d..5a958ac 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -91,6 +91,7 @@ set(Aquila_HEADERS aquila/transform/Dct.h aquila/transform/Mfcc.h aquila/transform/Spectrogram.h + aquila/functions/TransientDetector.h aquila/tools/TextPlot.h ) @@ -124,6 +125,7 @@ set(Aquila_SOURCES aquila/transform/Dct.cpp aquila/transform/Mfcc.cpp aquila/transform/Spectrogram.cpp + aquila/functions/TransientDetector.cpp aquila/tools/TextPlot.cpp ) diff --git a/aquila/functions/TransientDetector.cpp b/aquila/functions/TransientDetector.cpp new file mode 100644 index 0000000..1159690 --- /dev/null +++ b/aquila/functions/TransientDetector.cpp @@ -0,0 +1,175 @@ +/** + * @file TransientDetector.cpp + * + * Implementation of the STFT-based glass-break transient detector. + * + * This file is part of the Aquila DSP library. + * Aquila is free software, licensed under the MIT/X11 License. A copy of + * the license is provided with the library in the LICENSE file. + * + * @package Aquila + * @version 3.0.0-dev + * @author Aquila contributors + * @date 2007-2014 + * @license http://www.opensource.org/licenses/mit-license.php MIT + * @since 3.0.0 + */ + +#include "TransientDetector.h" +#include "../transform/FftFactory.h" +#include "../source/SignalSource.h" +#include +#include +#include + +#ifndef M_PI +#define M_PI 3.14159265358979323846 +#endif + +namespace Aquila +{ + /** + * Constructs the detector and pre-allocates every internal buffer. + * + * The Hann window, windowed-sample scratch buffer, and circular + * background-history buffer are all sized here so that processFrame() + * never touches the heap. + */ + TransientDetector::TransientDetector(std::size_t fftSize, + FrequencyType sampleRate, + std::size_t backgroundFrames, + double threshold): + m_fftSize(fftSize), + m_sampleRate(sampleRate), + m_backgroundFrames(backgroundFrames), + m_threshold(threshold), + m_fft(FftFactory::getFft(fftSize)), + m_window(fftSize), + m_windowedBuffer(fftSize), + m_bandStart(0), + m_bandEnd(0), + m_backgroundHistory(backgroundFrames, 0.0), + m_historyIndex(0), + m_historyCount(0), + m_backgroundSum(0.0), + m_currentRatio(0.0) + { + // Pre-compute Hann window coefficients. + for (std::size_t i = 0; i < fftSize; ++i) + { + m_window[i] = 0.5 * (1.0 - std::cos(2.0 * M_PI * i / + static_cast(fftSize - 1))); + } + + // Map the 10-15 kHz band to FFT bin indices. + // Bin k corresponds to frequency k * sampleRate / fftSize. + double binWidth = sampleRate / static_cast(fftSize); + m_bandStart = static_cast(std::ceil(10000.0 / binWidth)); + m_bandEnd = static_cast(std::floor(15000.0 / binWidth)) + 1; + + // Clamp to the valid range (bins above N/2 are mirrored). + std::size_t maxBin = fftSize / 2; + if (m_bandStart > maxBin) + { + m_bandStart = maxBin; + } + if (m_bandEnd > maxBin) + { + m_bandEnd = maxBin; + } + } + + /** + * Processes a single audio frame through the STFT detection pipeline. + * + * 1. Apply the pre-computed Hann window (writes into m_windowedBuffer). + * 2. Forward-FFT via the factory-provided engine. + * 3. Sum |X[k]|^2 across the 10-15 kHz bins. + * 4. Update the rolling background average (circular buffer). + * 5. Compare current energy to background; flag if >= threshold. + * + * @param frame SignalSource containing at least m_fftSize samples + * @return true when band energy >= threshold * background average + */ + bool TransientDetector::processFrame(const SignalSource& frame) + { + // --- 1. Windowing (zero-allocation: writes into pre-sized buffer) --- + const SampleType* samples = frame.toArray(); + for (std::size_t i = 0; i < m_fftSize; ++i) + { + m_windowedBuffer[i] = samples[i] * m_window[i]; + } + + // --- 2. Forward FFT via the shared engine --- + SpectrumType spectrum = m_fft->fft(m_windowedBuffer.data()); + + // --- 3. Band energy in 10-15 kHz --- + double energy = computeBandEnergy(spectrum); + + // --- 4. Rolling background average (circular buffer) --- + if (m_historyCount < m_backgroundFrames) + { + // Still filling the history buffer. + m_backgroundHistory[m_historyIndex] = energy; + m_backgroundSum += energy; + m_historyCount++; + } + else + { + // Overwrite the oldest entry. + m_backgroundSum -= m_backgroundHistory[m_historyIndex]; + m_backgroundHistory[m_historyIndex] = energy; + m_backgroundSum += energy; + } + m_historyIndex = (m_historyIndex + 1) % m_backgroundFrames; + + // --- 5. Detection decision --- + double avg = getBackgroundAverage(); + m_currentRatio = (avg > 0.0) ? (energy / avg) : 0.0; + + return m_currentRatio >= m_threshold; + } + + /** + * Returns the mean band energy over the filled portion of the + * circular history buffer. + */ + double TransientDetector::getBackgroundAverage() const + { + if (m_historyCount == 0) + { + return 0.0; + } + return m_backgroundSum / static_cast(m_historyCount); + } + + /** + * Clears background history and resets the detector to its + * initial state (as if no frames had been processed). + */ + void TransientDetector::reset() + { + std::fill(m_backgroundHistory.begin(), + m_backgroundHistory.end(), 0.0); + m_historyIndex = 0; + m_historyCount = 0; + m_backgroundSum = 0.0; + m_currentRatio = 0.0; + } + + /** + * Accumulates squared magnitudes of the spectrum bins that fall + * inside the target frequency band [m_bandStart, m_bandEnd). + */ + double TransientDetector::computeBandEnergy( + const SpectrumType& spectrum) const + { + double energy = 0.0; + for (std::size_t k = m_bandStart; k < m_bandEnd; ++k) + { + double mag = std::abs(spectrum[k]); + energy += mag * mag; + } + return energy; + } +} diff --git a/aquila/functions/TransientDetector.h b/aquila/functions/TransientDetector.h new file mode 100644 index 0000000..5bbd7ee --- /dev/null +++ b/aquila/functions/TransientDetector.h @@ -0,0 +1,128 @@ +/** + * @file TransientDetector.h + * + * STFT-based transient detector targeting the 10-15 kHz band + * (glass-breaking signature). Compares per-frame band energy against + * a rolling background average; anything past 300 % is a detection. + * + * Designed for embedded hardware: every buffer is pre-allocated at + * construction time. The processing path performs zero heap allocations + * (the FFT engine's own internal allocations are outside our control). + * + * This file is part of the Aquila DSP library. + * Aquila is free software, licensed under the MIT/X11 License. A copy of + * the license is provided with the library in the LICENSE file. + * + * @package Aquila + * @version 3.0.0-dev + * @author Aquila contributors + * @date 2007-2014 + * @license http://www.opensource.org/licenses/mit-license.php MIT + * @since 3.0.0 + */ + +#ifndef TRANSIENTDETECTOR_H +#define TRANSIENTDETECTOR_H + +#include "../global.h" +#include +#include +#include + +namespace Aquila +{ + class Fft; + class SignalSource; + + /** + * Detects glass-breaking transients by monitoring energy spikes + * in the 10-15 kHz frequency band via short-time Fourier transform. + * + * Usage: + * @code + * TransientDetector detector(1024, 44100.0); + * // feed frames continuously + * if (detector.processFrame(audioFrame)) { + * // glass break detected + * } + * @endcode + */ + class AQUILA_EXPORT TransientDetector + { + public: + /** + * Creates a detector with the given FFT parameters. + * + * @param fftSize FFT length in samples (must be power of 2) + * @param sampleRate audio sample rate in Hz + * @param backgroundFrames rolling-average window length (default 20) + * @param threshold detection threshold as a ratio (3.0 = 300 %) + */ + TransientDetector(std::size_t fftSize, + FrequencyType sampleRate, + std::size_t backgroundFrames = 20, + double threshold = 3.0); + + /** + * Processes one frame of audio data. + * + * @param frame audio frame wrapped as a SignalSource + * (must contain at least fftSize samples) + * @return true if a glass-breaking transient was detected + */ + bool processFrame(const SignalSource& frame); + + /** + * Returns the energy-ratio from the most recent processFrame() call + * (current band energy / rolling background average). + */ + double getCurrentRatio() const { return m_currentRatio; } + + /** + * Returns the current rolling background average of band energy. + */ + double getBackgroundAverage() const; + + /** + * Resets all detector state (background history, counters, ratio). + */ + void reset(); + + private: + std::size_t m_fftSize; + FrequencyType m_sampleRate; + std::size_t m_backgroundFrames; + double m_threshold; + + /// FFT engine obtained through FftFactory. + std::shared_ptr m_fft; + + /// Pre-computed Hann window coefficients (length = fftSize). + std::vector m_window; + + /// Windowed sample buffer written into each processFrame() call. + std::vector m_windowedBuffer; + + /// First FFT bin inside the 10 kHz lower edge (inclusive). + std::size_t m_bandStart; + + /// One past the last FFT bin inside the 15 kHz upper edge (exclusive). + std::size_t m_bandEnd; + + /// Circular buffer of past band energies (length = backgroundFrames). + std::vector m_backgroundHistory; + std::size_t m_historyIndex; + std::size_t m_historyCount; + double m_backgroundSum; + + /// Energy-to-background ratio from the latest frame. + double m_currentRatio; + + /** + * Sums |X[k]|^2 over the target frequency band. + */ + double computeBandEnergy(const SpectrumType& spectrum) const; + }; +} + +#endif // TRANSIENTDETECTOR_H diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 2b8571a..e142a59 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -48,6 +48,7 @@ set(Aquila_Test_SOURCES transform/OouraFft.cpp transform/Dct.cpp transform/Spectrogram.cpp + functions/TransientDetector.cpp ) if(SFML_FOUND) diff --git a/tests/functions/TransientDetector.cpp b/tests/functions/TransientDetector.cpp new file mode 100644 index 0000000..97ad77c --- /dev/null +++ b/tests/functions/TransientDetector.cpp @@ -0,0 +1,169 @@ +/** + * @file tests/functions/TransientDetector.cpp + * + * Unit tests for the STFT-based glass-break transient detector. + */ + +#include "aquila/global.h" +#include "aquila/source/SignalSource.h" +#include "aquila/functions/TransientDetector.h" +#include "UnitTest++/UnitTest++.h" +#include +#include +#include + +#ifndef M_PI +#define M_PI 3.14159265358979323846 +#endif + +/// Helper: build a single-frame SignalSource containing a pure sine tone. +static Aquila::SignalSource makeSineFrame( + std::size_t fftSize, + Aquila::FrequencyType sampleRate, + Aquila::FrequencyType freq, + double amplitude) +{ + std::vector samples(fftSize); + for (std::size_t i = 0; i < fftSize; ++i) + { + samples[i] = amplitude * + std::sin(2.0 * M_PI * freq * i / sampleRate); + } + return Aquila::SignalSource(std::move(samples), sampleRate); +} + +/// Helper: build a silent frame (all zeros). +static Aquila::SignalSource makeSilentFrame( + std::size_t fftSize, + Aquila::FrequencyType sampleRate) +{ + std::vector samples(fftSize, 0.0); + return Aquila::SignalSource(std::move(samples), sampleRate); +} + +SUITE(TransientDetector) +{ + const std::size_t FFT_SIZE = 1024; + const Aquila::FrequencyType SAMPLE_RATE = 44100.0; + const std::size_t BG_FRAMES = 10; + + // Silence should never trigger a detection. + TEST(NoDetectionOnSilence) + { + Aquila::TransientDetector detector(FFT_SIZE, SAMPLE_RATE, BG_FRAMES); + + for (int i = 0; i < 20; ++i) + { + bool detected = detector.processFrame( + makeSilentFrame(FFT_SIZE, SAMPLE_RATE)); + CHECK(!detected); + } + } + + // A steady low-frequency tone has no energy in the 10-15 kHz band. + TEST(NoDetectionOnLowFrequency) + { + Aquila::TransientDetector detector(FFT_SIZE, SAMPLE_RATE, BG_FRAMES); + + Aquila::SignalSource frame = + makeSineFrame(FFT_SIZE, SAMPLE_RATE, 200.0, 1.0); + + for (int i = 0; i < 20; ++i) + { + bool detected = detector.processFrame(frame); + CHECK(!detected); + } + } + + // After a quiet 12 kHz background, a loud 12 kHz burst must trigger. + TEST(DetectsHighFrequencySpike) + { + Aquila::TransientDetector detector(FFT_SIZE, SAMPLE_RATE, BG_FRAMES); + + // Build background with very quiet energy in the target band. + Aquila::SignalSource quietFrame = + makeSineFrame(FFT_SIZE, SAMPLE_RATE, 12000.0, 0.01); + for (std::size_t i = 0; i < BG_FRAMES; ++i) + { + detector.processFrame(quietFrame); + } + + // Loud burst in the same band. + Aquila::SignalSource loudFrame = + makeSineFrame(FFT_SIZE, SAMPLE_RATE, 12000.0, 1.0); + bool detected = detector.processFrame(loudFrame); + CHECK(detected); + CHECK(detector.getCurrentRatio() >= 3.0); + } + + // 10x amplitude jump → 100x energy jump → ratio well above 3. + TEST(RatioReflectsEnergyDifference) + { + Aquila::TransientDetector detector(FFT_SIZE, SAMPLE_RATE, BG_FRAMES); + + Aquila::SignalSource quietFrame = + makeSineFrame(FFT_SIZE, SAMPLE_RATE, 12000.0, 0.1); + for (std::size_t i = 0; i < BG_FRAMES; ++i) + { + detector.processFrame(quietFrame); + } + + Aquila::SignalSource loudFrame = + makeSineFrame(FFT_SIZE, SAMPLE_RATE, 12000.0, 1.0); + detector.processFrame(loudFrame); + CHECK(detector.getCurrentRatio() > 3.0); + } + + // A constant-amplitude signal at 12 kHz should settle into a + // steady background with no spurious detections once filled. + TEST(SteadySignalNoFalsePositive) + { + Aquila::TransientDetector detector(FFT_SIZE, SAMPLE_RATE, BG_FRAMES); + + Aquila::SignalSource frame = + makeSineFrame(FFT_SIZE, SAMPLE_RATE, 12000.0, 0.5); + + for (int i = 0; i < 30; ++i) + { + bool detected = detector.processFrame(frame); + // After the background fills, every frame looks like + // the average, so the ratio should be ~1.0. + if (i >= static_cast(BG_FRAMES)) + { + CHECK(!detected); + } + } + } + + // reset() must clear all state. + TEST(ResetClearsState) + { + Aquila::TransientDetector detector(FFT_SIZE, SAMPLE_RATE, BG_FRAMES); + + Aquila::SignalSource frame = + makeSineFrame(FFT_SIZE, SAMPLE_RATE, 12000.0, 1.0); + detector.processFrame(frame); + + detector.reset(); + CHECK_CLOSE(0.0, detector.getCurrentRatio(), 0.000001); + CHECK_CLOSE(0.0, detector.getBackgroundAverage(), 0.000001); + } + + // When the sample rate is so low that 10-15 kHz is above Nyquist, + // band energy is always zero and no detection should fire. + TEST(NoDetectionWhenBandAboveNyquist) + { + Aquila::FrequencyType lowRate = 16000.0; + Aquila::TransientDetector detector(FFT_SIZE, lowRate, BG_FRAMES); + + // 7 kHz tone (below Nyquist for 16 kHz rate). + Aquila::SignalSource frame = + makeSineFrame(FFT_SIZE, lowRate, 7000.0, 1.0); + + for (int i = 0; i < 20; ++i) + { + bool detected = detector.processFrame(frame); + CHECK(!detected); + } + } +}