@respan/cli 0.4.1 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,909 +0,0 @@
1
- #!/usr/bin/env python3
2
- """
3
- Respan Hook for Claude Code
4
-
5
- Sends Claude Code conversation traces to Respan after each response.
6
- Uses Claude Code's Stop hook to capture transcripts and convert them to Respan spans.
7
-
8
- Usage:
9
- Copy this file to ~/.claude/hooks/respan_hook.py
10
- Configure in ~/.claude/settings.json (see .claude/settings.json.example)
11
- Enable per-project in .claude/settings.local.json (see .claude/settings.local.json.example)
12
- """
13
-
14
- import contextlib
15
- import json
16
- import os
17
- import sys
18
- import tempfile
19
- import time
20
- import requests
21
- from datetime import datetime, timezone
22
- from pathlib import Path
23
- from typing import Any, Dict, List, Optional, Tuple
24
-
25
- try:
26
- import fcntl
27
- except ImportError:
28
- fcntl = None # Not available on Windows
29
-
30
- # Configuration
31
- LOG_FILE = Path.home() / ".claude" / "state" / "respan_hook.log"
32
- STATE_FILE = Path.home() / ".claude" / "state" / "respan_state.json"
33
- LOCK_FILE = Path.home() / ".claude" / "state" / "respan_hook.lock"
34
- DEBUG = os.environ.get("CC_RESPAN_DEBUG", "").lower() == "true"
35
-
36
- try:
37
- MAX_CHARS = int(os.environ.get("CC_RESPAN_MAX_CHARS", "4000"))
38
- except (ValueError, TypeError):
39
- MAX_CHARS = 4000
40
-
41
-
42
- def log(level: str, message: str) -> None:
43
- """Log a message to the log file."""
44
- LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
45
- timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
46
- with open(LOG_FILE, "a", encoding="utf-8") as f:
47
- f.write(f"{timestamp} [{level}] {message}\n")
48
-
49
-
50
- def debug(message: str) -> None:
51
- """Log a debug message (only if DEBUG is enabled)."""
52
- if DEBUG:
53
- log("DEBUG", message)
54
-
55
-
56
- def load_state() -> Dict[str, Any]:
57
- """Load the state file containing session tracking info."""
58
- if not STATE_FILE.exists():
59
- return {}
60
- try:
61
- return json.loads(STATE_FILE.read_text(encoding="utf-8"))
62
- except (json.JSONDecodeError, IOError):
63
- return {}
64
-
65
-
66
- def save_state(state: Dict[str, Any]) -> None:
67
- """Save the state file atomically via write-to-temp + rename."""
68
- STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
69
- try:
70
- fd, tmp_path = tempfile.mkstemp(dir=STATE_FILE.parent, suffix=".tmp")
71
- try:
72
- with os.fdopen(fd, "w", encoding="utf-8") as f:
73
- json.dump(state, f, indent=2)
74
- os.rename(tmp_path, STATE_FILE)
75
- except BaseException:
76
- with contextlib.suppress(OSError):
77
- os.unlink(tmp_path)
78
- raise
79
- except OSError as e:
80
- log("ERROR", f"Failed to save state atomically, falling back: {e}")
81
- STATE_FILE.write_text(json.dumps(state, indent=2), encoding="utf-8")
82
-
83
-
84
- def get_content(msg: Dict[str, Any]) -> Any:
85
- """Extract content from a message."""
86
- if isinstance(msg, dict):
87
- if "message" in msg:
88
- return msg["message"].get("content")
89
- return msg.get("content")
90
- return None
91
-
92
-
93
- def is_tool_result(msg: Dict[str, Any]) -> bool:
94
- """Check if a message contains tool results."""
95
- content = get_content(msg)
96
- if isinstance(content, list):
97
- return any(
98
- isinstance(item, dict) and item.get("type") == "tool_result"
99
- for item in content
100
- )
101
- return False
102
-
103
-
104
- def get_tool_calls(msg: Dict[str, Any]) -> List[Dict[str, Any]]:
105
- """Extract tool use blocks from a message."""
106
- content = get_content(msg)
107
- if isinstance(content, list):
108
- return [
109
- item for item in content
110
- if isinstance(item, dict) and item.get("type") == "tool_use"
111
- ]
112
- return []
113
-
114
-
115
- def get_text_content(msg: Dict[str, Any]) -> str:
116
- """Extract text content from a message."""
117
- content = get_content(msg)
118
- if isinstance(content, str):
119
- return content
120
- if isinstance(content, list):
121
- text_parts = []
122
- for item in content:
123
- if isinstance(item, dict) and item.get("type") == "text":
124
- text_parts.append(item.get("text", ""))
125
- elif isinstance(item, str):
126
- text_parts.append(item)
127
- return "\n".join(text_parts)
128
- return ""
129
-
130
-
131
- def format_tool_input(tool_name: str, tool_input: Any, max_length: int = MAX_CHARS) -> str:
132
- """Format tool input for better readability."""
133
- if not tool_input:
134
- return ""
135
-
136
- # Handle Write/Edit tool - show file path and content preview
137
- if tool_name in ("Write", "Edit", "MultiEdit"):
138
- if isinstance(tool_input, dict):
139
- file_path = tool_input.get("file_path", tool_input.get("path", ""))
140
- content = tool_input.get("content", "")
141
-
142
- result = f"File: {file_path}\n"
143
- if content:
144
- content_preview = content[:2000] + "..." if len(content) > 2000 else content
145
- result += f"Content:\n{content_preview}"
146
- return result[:max_length]
147
-
148
- # Handle Read tool
149
- if tool_name == "Read":
150
- if isinstance(tool_input, dict):
151
- file_path = tool_input.get("file_path", tool_input.get("path", ""))
152
- return f"File: {file_path}"
153
-
154
- # Handle Bash/Shell tool
155
- if tool_name in ("Bash", "Shell"):
156
- if isinstance(tool_input, dict):
157
- command = tool_input.get("command", "")
158
- return f"Command: {command}"
159
-
160
- # Default: JSON dump with truncation
161
- try:
162
- result = json.dumps(tool_input, indent=2)
163
- if len(result) > max_length:
164
- result = result[:max_length] + "\n... (truncated)"
165
- return result
166
- except (TypeError, ValueError):
167
- return str(tool_input)[:max_length]
168
-
169
-
170
- def format_tool_output(tool_name: str, tool_output: Any, max_length: int = MAX_CHARS) -> str:
171
- """Format tool output for better readability."""
172
- if not tool_output:
173
- return ""
174
-
175
- # Handle string output directly
176
- if isinstance(tool_output, str):
177
- if len(tool_output) > max_length:
178
- return tool_output[:max_length] + "\n... (truncated)"
179
- return tool_output
180
-
181
- # Handle list of content blocks (common in Claude Code tool results)
182
- if isinstance(tool_output, list):
183
- parts = []
184
- total_length = 0
185
-
186
- for item in tool_output:
187
- if isinstance(item, dict):
188
- # Text content block
189
- if item.get("type") == "text":
190
- text = item.get("text", "")
191
- if total_length + len(text) > max_length:
192
- remaining = max_length - total_length
193
- if remaining > 100:
194
- parts.append(text[:remaining] + "... (truncated)")
195
- break
196
- parts.append(text)
197
- total_length += len(text)
198
- # Image or other type
199
- elif item.get("type") == "image":
200
- parts.append("[Image output]")
201
- else:
202
- # Try to extract any text-like content
203
- text = str(item)[:500]
204
- parts.append(text)
205
- total_length += len(text)
206
- elif isinstance(item, str):
207
- if total_length + len(item) > max_length:
208
- remaining = max_length - total_length
209
- if remaining > 100:
210
- parts.append(item[:remaining] + "... (truncated)")
211
- break
212
- parts.append(item)
213
- total_length += len(item)
214
-
215
- return "\n".join(parts)
216
-
217
- # Handle dict output
218
- if isinstance(tool_output, dict):
219
- # Special handling for Write tool success/error
220
- if "success" in tool_output:
221
- return f"Success: {tool_output.get('success')}\n{tool_output.get('message', '')}"
222
-
223
- # Default JSON formatting
224
- try:
225
- result = json.dumps(tool_output, indent=2)
226
- if len(result) > max_length:
227
- result = result[:max_length] + "\n... (truncated)"
228
- return result
229
- except (TypeError, ValueError):
230
- return str(tool_output)[:max_length]
231
-
232
- return str(tool_output)[:max_length]
233
-
234
-
235
- def merge_assistant_parts(parts: List[Dict[str, Any]]) -> Dict[str, Any]:
236
- """Merge multiple assistant message parts into one."""
237
- if not parts:
238
- return {}
239
-
240
- merged_content = []
241
- for part in parts:
242
- content = get_content(part)
243
- if isinstance(content, list):
244
- merged_content.extend(content)
245
- elif content:
246
- merged_content.append({"type": "text", "text": str(content)})
247
-
248
- # Use the structure from the first part
249
- result = parts[0].copy()
250
- if "message" in result:
251
- result["message"] = result["message"].copy()
252
- result["message"]["content"] = merged_content
253
- else:
254
- result["content"] = merged_content
255
-
256
- return result
257
-
258
-
259
- def find_latest_transcript() -> Optional[Tuple[str, Path]]:
260
- """Find the most recently modified transcript file.
261
-
262
- Claude Code stores transcripts as *.jsonl files directly in the project directory.
263
- Main conversation files have UUID names, agent files have agent-*.jsonl names.
264
- The session ID is stored inside each JSON line.
265
- """
266
- projects_dir = Path.home() / ".claude" / "projects"
267
-
268
- if not projects_dir.exists():
269
- debug(f"Projects directory not found: {projects_dir}")
270
- return None
271
-
272
- latest_file = None
273
- latest_mtime = 0
274
-
275
- for project_dir in projects_dir.iterdir():
276
- if not project_dir.is_dir():
277
- continue
278
-
279
- # Look for all .jsonl files directly in the project directory
280
- for transcript_file in project_dir.glob("*.jsonl"):
281
- mtime = transcript_file.stat().st_mtime
282
- if mtime > latest_mtime:
283
- latest_mtime = mtime
284
- latest_file = transcript_file
285
-
286
- if latest_file:
287
- # Extract session ID from the first line of the file
288
- try:
289
- first_line = latest_file.read_text(encoding="utf-8").split("\n")[0]
290
- if first_line:
291
- first_msg = json.loads(first_line)
292
- session_id = first_msg.get("sessionId", latest_file.stem)
293
- debug(f"Found transcript: {latest_file}, session: {session_id}")
294
- return (session_id, latest_file)
295
- except (json.JSONDecodeError, IOError, IndexError, UnicodeDecodeError) as e:
296
- debug(f"Error reading transcript {latest_file}: {e}")
297
- return None
298
-
299
- debug("No transcript files found")
300
- return None
301
-
302
-
303
- def parse_timestamp(ts_str: str) -> Optional[datetime]:
304
- """Parse ISO timestamp string to datetime."""
305
- try:
306
- # Handle both with and without timezone
307
- if ts_str.endswith("Z"):
308
- ts_str = ts_str[:-1] + "+00:00"
309
- return datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
310
- except (ValueError, AttributeError):
311
- return None
312
-
313
-
314
- def create_respan_spans(
315
- session_id: str,
316
- turn_num: int,
317
- user_msg: Dict[str, Any],
318
- assistant_msgs: List[Dict[str, Any]],
319
- tool_results: List[Dict[str, Any]],
320
- ) -> List[Dict[str, Any]]:
321
- """Create Respan span logs for a single turn with all available metadata.
322
-
323
- Produces a proper span tree so that the Respan UI renders nested children:
324
- Root (agent container)
325
- ├── claude.chat (generation – carries model, tokens, messages)
326
- ├── Thinking 1 (generation, if extended thinking is present)
327
- ├── Tool: Read (tool, if tool use occurred)
328
- └── Tool: Write (tool, if tool use occurred)
329
- """
330
- spans = []
331
-
332
- # ------------------------------------------------------------------
333
- # 1. Extract data from the transcript messages
334
- # ------------------------------------------------------------------
335
- user_text = get_text_content(user_msg)
336
- user_timestamp = user_msg.get("timestamp")
337
- user_time = parse_timestamp(user_timestamp) if user_timestamp else None
338
-
339
- # Collect assistant text across all messages in the turn
340
- final_output = ""
341
- if assistant_msgs:
342
- text_parts = [get_text_content(m) for m in assistant_msgs]
343
- final_output = "\n".join(p for p in text_parts if p)
344
-
345
- # Aggregate model, usage, timing from (possibly multiple) API calls
346
- model = "claude"
347
- usage = None
348
- request_id = None
349
- stop_reason = None
350
- first_assistant_timestamp = None
351
- last_assistant_timestamp = None
352
- last_assistant_time = None
353
-
354
- for a_msg in assistant_msgs:
355
- if not (isinstance(a_msg, dict) and "message" in a_msg):
356
- continue
357
- msg_obj = a_msg["message"]
358
- model = msg_obj.get("model", model)
359
- request_id = a_msg.get("requestId", request_id)
360
- stop_reason = msg_obj.get("stop_reason") or stop_reason
361
- ts = a_msg.get("timestamp")
362
- if ts:
363
- if first_assistant_timestamp is None:
364
- first_assistant_timestamp = ts
365
- last_assistant_timestamp = ts
366
- last_assistant_time = parse_timestamp(ts)
367
-
368
- msg_usage = msg_obj.get("usage")
369
- if msg_usage:
370
- if usage is None:
371
- usage = dict(msg_usage)
372
- else:
373
- for key in ("input_tokens", "output_tokens",
374
- "cache_creation_input_tokens",
375
- "cache_read_input_tokens"):
376
- if key in msg_usage:
377
- usage[key] = usage.get(key, 0) + msg_usage[key]
378
- if "service_tier" in msg_usage:
379
- usage["service_tier"] = msg_usage["service_tier"]
380
-
381
- # Timing
382
- now_str = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
383
- start_time_str = user_timestamp or first_assistant_timestamp or now_str
384
- timestamp_str = last_assistant_timestamp or first_assistant_timestamp or now_str
385
-
386
- latency = None
387
- if user_time and last_assistant_time:
388
- latency = (last_assistant_time - user_time).total_seconds()
389
-
390
- # Messages
391
- prompt_messages: List[Dict[str, Any]] = []
392
- if user_text:
393
- prompt_messages.append({"role": "user", "content": user_text})
394
- completion_message: Optional[Dict[str, Any]] = None
395
- if final_output:
396
- completion_message = {"role": "assistant", "content": final_output}
397
-
398
- # IDs
399
- trace_unique_id = f"{session_id}_turn_{turn_num}"
400
- workflow_name = "claude-code"
401
- user_preview = (user_text[:60] + "...") if user_text and len(user_text) > 60 else (user_text or f"turn_{turn_num}")
402
- root_span_name = f"Turn {turn_num}: {user_preview}"
403
- thread_id = f"claudecode_{session_id}"
404
- customer_id = os.environ.get("RESPAN_CUSTOMER_ID", "claude-code")
405
-
406
- # Metadata
407
- metadata: Dict[str, Any] = {"claude_code_turn": turn_num}
408
- if request_id:
409
- metadata["request_id"] = request_id
410
- if stop_reason:
411
- metadata["stop_reason"] = stop_reason
412
- env_metadata = os.environ.get("RESPAN_METADATA")
413
- if env_metadata:
414
- try:
415
- extra = json.loads(env_metadata)
416
- if isinstance(extra, dict):
417
- metadata.update(extra)
418
- except json.JSONDecodeError:
419
- pass
420
-
421
- # Usage
422
- usage_fields: Dict[str, Any] = {}
423
- if usage:
424
- prompt_tokens = usage.get("input_tokens", 0)
425
- completion_tokens = usage.get("output_tokens", 0)
426
- usage_fields["prompt_tokens"] = prompt_tokens
427
- usage_fields["completion_tokens"] = completion_tokens
428
- total = prompt_tokens + completion_tokens
429
- if total > 0:
430
- usage_fields["total_tokens"] = total
431
- cache_creation = usage.get("cache_creation_input_tokens", 0)
432
- cache_read = usage.get("cache_read_input_tokens", 0)
433
- if cache_creation > 0:
434
- usage_fields["cache_creation_prompt_tokens"] = cache_creation
435
- prompt_tokens_details: Dict[str, int] = {}
436
- if cache_creation > 0:
437
- prompt_tokens_details["cache_creation_tokens"] = cache_creation
438
- if cache_read > 0:
439
- prompt_tokens_details["cached_tokens"] = cache_read
440
- if prompt_tokens_details:
441
- usage_fields["prompt_tokens_details"] = prompt_tokens_details
442
- service_tier = usage.get("service_tier")
443
- if service_tier:
444
- metadata["service_tier"] = service_tier
445
-
446
- # ------------------------------------------------------------------
447
- # 2. Root span – pure agent container (no model / token info)
448
- # ------------------------------------------------------------------
449
- root_span_id = f"claudecode_{trace_unique_id}_root"
450
- root_span: Dict[str, Any] = {
451
- "trace_unique_id": trace_unique_id,
452
- "thread_identifier": thread_id,
453
- "customer_identifier": customer_id,
454
- "span_unique_id": root_span_id,
455
- "span_name": root_span_name,
456
- "span_workflow_name": workflow_name,
457
- "span_path": "",
458
- "log_type": "agent",
459
- "input": json.dumps(prompt_messages) if prompt_messages else "",
460
- "output": json.dumps(completion_message) if completion_message else "",
461
- "timestamp": timestamp_str,
462
- "start_time": start_time_str,
463
- "metadata": metadata,
464
- }
465
- if latency is not None:
466
- root_span["latency"] = latency
467
- spans.append(root_span)
468
-
469
- # ------------------------------------------------------------------
470
- # 3. LLM generation child span (always created → every turn has ≥1 child)
471
- # ------------------------------------------------------------------
472
- gen_span_id = f"claudecode_{trace_unique_id}_gen"
473
- gen_start = first_assistant_timestamp or start_time_str
474
- gen_end = last_assistant_timestamp or timestamp_str
475
- gen_latency = None
476
- gen_start_dt = parse_timestamp(gen_start) if gen_start else None
477
- gen_end_dt = parse_timestamp(gen_end) if gen_end else None
478
- if gen_start_dt and gen_end_dt:
479
- gen_latency = (gen_end_dt - gen_start_dt).total_seconds()
480
-
481
- gen_span: Dict[str, Any] = {
482
- "trace_unique_id": trace_unique_id,
483
- "span_unique_id": gen_span_id,
484
- "span_parent_id": root_span_id,
485
- "span_name": "claude.chat",
486
- "span_workflow_name": workflow_name,
487
- "span_path": "claude_chat",
488
- "log_type": "generation",
489
- "model": model,
490
- "provider_id": "anthropic",
491
- "input": json.dumps(prompt_messages) if prompt_messages else "",
492
- "output": json.dumps(completion_message) if completion_message else "",
493
- "prompt_messages": prompt_messages,
494
- "completion_message": completion_message,
495
- "timestamp": gen_end,
496
- "start_time": gen_start,
497
- }
498
- if gen_latency is not None:
499
- gen_span["latency"] = gen_latency
500
- gen_span.update(usage_fields)
501
- spans.append(gen_span)
502
-
503
- # ------------------------------------------------------------------
504
- # 4. Thinking child spans
505
- # ------------------------------------------------------------------
506
- thinking_num = 0
507
- for assistant_msg in assistant_msgs:
508
- if not (isinstance(assistant_msg, dict) and "message" in assistant_msg):
509
- continue
510
- content = assistant_msg["message"].get("content", [])
511
- if not isinstance(content, list):
512
- continue
513
- for item in content:
514
- if isinstance(item, dict) and item.get("type") == "thinking":
515
- thinking_text = item.get("thinking", "")
516
- if not thinking_text:
517
- continue
518
- thinking_num += 1
519
- thinking_ts = assistant_msg.get("timestamp", timestamp_str)
520
- spans.append({
521
- "trace_unique_id": trace_unique_id,
522
- "span_unique_id": f"claudecode_{trace_unique_id}_thinking_{thinking_num}",
523
- "span_parent_id": root_span_id,
524
- "span_name": f"Thinking {thinking_num}",
525
- "span_workflow_name": workflow_name,
526
- "span_path": "thinking",
527
- "log_type": "generation",
528
- "input": "",
529
- "output": thinking_text,
530
- "timestamp": thinking_ts,
531
- "start_time": thinking_ts,
532
- })
533
-
534
- # ------------------------------------------------------------------
535
- # 5. Tool child spans
536
- # ------------------------------------------------------------------
537
- tool_call_map: Dict[str, Dict[str, Any]] = {}
538
- for assistant_msg in assistant_msgs:
539
- for tool_call in get_tool_calls(assistant_msg):
540
- tool_id = tool_call.get("id", "")
541
- tool_call_map[tool_id] = {
542
- "name": tool_call.get("name", "unknown"),
543
- "input": tool_call.get("input", {}),
544
- "id": tool_id,
545
- "timestamp": assistant_msg.get("timestamp") if isinstance(assistant_msg, dict) else None,
546
- }
547
-
548
- for tr in tool_results:
549
- tr_content = get_content(tr)
550
- tool_result_metadata: Dict[str, Any] = {}
551
- if isinstance(tr, dict):
552
- tur = tr.get("toolUseResult") or {}
553
- for src, dst in [("durationMs", "duration_ms"), ("numFiles", "num_files"),
554
- ("filenames", "filenames"), ("truncated", "truncated")]:
555
- if src in tur:
556
- tool_result_metadata[dst] = tur[src]
557
- if isinstance(tr_content, list):
558
- for item in tr_content:
559
- if isinstance(item, dict) and item.get("type") == "tool_result":
560
- tool_use_id = item.get("tool_use_id")
561
- if tool_use_id in tool_call_map:
562
- tool_call_map[tool_use_id]["output"] = item.get("content")
563
- tool_call_map[tool_use_id]["result_metadata"] = tool_result_metadata
564
- tool_call_map[tool_use_id]["result_timestamp"] = tr.get("timestamp")
565
-
566
- tool_num = 0
567
- for tool_id, td in tool_call_map.items():
568
- tool_num += 1
569
- tool_ts = td.get("result_timestamp") or td.get("timestamp") or timestamp_str
570
- tool_start = td.get("timestamp") or start_time_str
571
- tool_span: Dict[str, Any] = {
572
- "trace_unique_id": trace_unique_id,
573
- "span_unique_id": f"claudecode_{trace_unique_id}_tool_{tool_num}",
574
- "span_parent_id": root_span_id,
575
- "span_name": f"Tool: {td['name']}",
576
- "span_workflow_name": workflow_name,
577
- "span_path": f"tool_{td['name'].lower()}",
578
- "log_type": "tool",
579
- "input": format_tool_input(td["name"], td["input"]),
580
- "output": format_tool_output(td["name"], td.get("output")),
581
- "timestamp": tool_ts,
582
- "start_time": tool_start,
583
- }
584
- if td.get("result_metadata"):
585
- tool_span["metadata"] = td["result_metadata"]
586
- duration_ms = td["result_metadata"].get("duration_ms")
587
- if duration_ms:
588
- tool_span["latency"] = duration_ms / 1000.0
589
- spans.append(tool_span)
590
-
591
- return spans
592
-
593
-
594
- def send_spans(
595
- spans: List[Dict[str, Any]],
596
- api_key: str,
597
- base_url: str,
598
- turn_num: int,
599
- ) -> None:
600
- """Send spans to Respan with timeout and one retry on transient errors."""
601
- url = f"{base_url}/v1/traces/ingest"
602
- headers = {"Authorization": f"Bearer {api_key}"}
603
-
604
- for attempt in range(2):
605
- try:
606
- response = requests.post(url, json=spans, headers=headers, timeout=30)
607
- if response.status_code < 400:
608
- debug(f"Sent {len(spans)} spans for turn {turn_num}")
609
- return
610
- if response.status_code < 500:
611
- # 4xx — not retryable
612
- log("ERROR", f"Failed to send spans for turn {turn_num}: HTTP {response.status_code}")
613
- return
614
- # 5xx — retryable
615
- if attempt == 0:
616
- debug(f"Server error {response.status_code} for turn {turn_num}, retrying...")
617
- time.sleep(1)
618
- continue
619
- log("ERROR", f"Failed to send spans for turn {turn_num} after retry: HTTP {response.status_code}")
620
- except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as e:
621
- if attempt == 0:
622
- debug(f"Transient error for turn {turn_num}: {e}, retrying...")
623
- time.sleep(1)
624
- continue
625
- log("ERROR", f"Failed to send spans for turn {turn_num} after retry: {e}")
626
- except Exception as e:
627
- log("ERROR", f"Failed to send spans for turn {turn_num}: {e}")
628
- return
629
-
630
-
631
- def process_transcript(
632
- session_id: str,
633
- transcript_file: Path,
634
- state: Dict[str, Any],
635
- api_key: str,
636
- base_url: str,
637
- ) -> int:
638
- """Process a transcript file and create traces for new turns."""
639
- # Get previous state for this session
640
- session_state = state.get(session_id, {})
641
- last_line = session_state.get("last_line", 0)
642
- turn_count = session_state.get("turn_count", 0)
643
-
644
- # Read transcript - need ALL messages to build conversation history
645
- lines = transcript_file.read_text(encoding="utf-8").strip().split("\n")
646
- total_lines = len(lines)
647
-
648
- if last_line >= total_lines:
649
- debug(f"No new lines to process (last: {last_line}, total: {total_lines})")
650
- return 0
651
-
652
- # Parse new messages, tracking their line indices
653
- new_messages = []
654
- for i in range(last_line, total_lines):
655
- try:
656
- if lines[i].strip():
657
- msg = json.loads(lines[i])
658
- msg["_line_idx"] = i
659
- new_messages.append(msg)
660
- except json.JSONDecodeError:
661
- continue
662
-
663
- if not new_messages:
664
- return 0
665
-
666
- debug(f"Processing {len(new_messages)} new messages")
667
-
668
- # Group messages into turns (user -> assistant(s) -> tool_results)
669
- turns_processed = 0
670
- # Track the line after the last fully-processed turn so we can
671
- # re-read incomplete turns on the next invocation.
672
- last_committed_line = last_line
673
- current_user = None
674
- current_user_line = last_line
675
- current_assistants = []
676
- current_assistant_parts = []
677
- current_msg_id = None
678
- current_tool_results = []
679
-
680
- def _commit_turn():
681
- """Send the current turn and update last_committed_line."""
682
- nonlocal turns_processed, last_committed_line
683
- turns_processed += 1
684
- turn_num = turn_count + turns_processed
685
- spans = create_respan_spans(
686
- session_id, turn_num, current_user, current_assistants, current_tool_results
687
- )
688
- send_spans(spans, api_key, base_url, turn_num)
689
- last_committed_line = total_lines # safe default, refined below
690
-
691
- for msg in new_messages:
692
- line_idx = msg.pop("_line_idx", last_line)
693
- role = msg.get("type") or (msg.get("message", {}).get("role"))
694
-
695
- if role == "user":
696
- # Check if this is a tool result
697
- if is_tool_result(msg):
698
- current_tool_results.append(msg)
699
- continue
700
-
701
- # New user message - finalize previous turn
702
- if current_msg_id and current_assistant_parts:
703
- merged = merge_assistant_parts(current_assistant_parts)
704
- current_assistants.append(merged)
705
- current_assistant_parts = []
706
- current_msg_id = None
707
-
708
- if current_user and current_assistants:
709
- _commit_turn()
710
- # Advance committed line to just before this new user msg
711
- last_committed_line = line_idx
712
-
713
- # Start new turn
714
- current_user = msg
715
- current_user_line = line_idx
716
- current_assistants = []
717
- current_assistant_parts = []
718
- current_msg_id = None
719
- current_tool_results = []
720
-
721
- elif role == "assistant":
722
- msg_id = None
723
- if isinstance(msg, dict) and "message" in msg:
724
- msg_id = msg["message"].get("id")
725
-
726
- if not msg_id:
727
- # No message ID, treat as continuation
728
- current_assistant_parts.append(msg)
729
- elif msg_id == current_msg_id:
730
- # Same message ID, add to current parts
731
- current_assistant_parts.append(msg)
732
- else:
733
- # New message ID - finalize previous message
734
- if current_msg_id and current_assistant_parts:
735
- merged = merge_assistant_parts(current_assistant_parts)
736
- current_assistants.append(merged)
737
-
738
- # Start new assistant message
739
- current_msg_id = msg_id
740
- current_assistant_parts = [msg]
741
-
742
- # Process final turn
743
- if current_msg_id and current_assistant_parts:
744
- merged = merge_assistant_parts(current_assistant_parts)
745
- current_assistants.append(merged)
746
-
747
- if current_user and current_assistants:
748
- _commit_turn()
749
- last_committed_line = total_lines
750
- else:
751
- # Incomplete turn — rewind so the next run re-reads from the
752
- # unmatched user message (or from where we left off if no user).
753
- if current_user:
754
- last_committed_line = current_user_line
755
- debug(f"Incomplete turn at line {current_user_line}, will retry next run")
756
- # else: no pending user, advance past non-turn lines
757
- elif last_committed_line == last_line:
758
- last_committed_line = total_lines
759
-
760
- # Update state
761
- state[session_id] = {
762
- "last_line": last_committed_line,
763
- "turn_count": turn_count + turns_processed,
764
- "updated": datetime.now(timezone.utc).isoformat(),
765
- }
766
- save_state(state)
767
-
768
- return turns_processed
769
-
770
-
771
- def read_stdin_payload() -> Optional[Tuple[str, Path]]:
772
- """Read session_id and transcript_path from stdin JSON payload.
773
-
774
- Claude Code hooks pipe a JSON object on stdin with at least
775
- ``session_id`` and ``transcript_path``. Returns ``None`` when
776
- stdin is a TTY, empty, or contains invalid data.
777
- """
778
- if sys.stdin.isatty():
779
- debug("stdin is a TTY, skipping stdin payload")
780
- return None
781
-
782
- try:
783
- raw = sys.stdin.read()
784
- except Exception as e:
785
- debug(f"Failed to read stdin: {e}")
786
- return None
787
-
788
- if not raw or not raw.strip():
789
- debug("stdin is empty")
790
- return None
791
-
792
- try:
793
- payload = json.loads(raw)
794
- except json.JSONDecodeError as e:
795
- debug(f"Invalid JSON on stdin: {e}")
796
- return None
797
-
798
- session_id = payload.get("session_id")
799
- transcript_path_str = payload.get("transcript_path")
800
- if not session_id or not transcript_path_str:
801
- debug("stdin payload missing session_id or transcript_path")
802
- return None
803
-
804
- transcript_path = Path(transcript_path_str)
805
- if not transcript_path.exists():
806
- debug(f"transcript_path from stdin does not exist: {transcript_path}")
807
- return None
808
-
809
- debug(f"Got transcript from stdin: session={session_id}, path={transcript_path}")
810
- return (session_id, transcript_path)
811
-
812
-
813
- @contextlib.contextmanager
814
- def state_lock(timeout: float = 5.0):
815
- """Acquire an advisory file lock around state operations.
816
-
817
- Falls back to no-lock when fcntl is unavailable (Windows) or on errors.
818
- """
819
- if fcntl is None:
820
- yield
821
- return
822
-
823
- LOCK_FILE.parent.mkdir(parents=True, exist_ok=True)
824
- lock_fd = None
825
- try:
826
- lock_fd = open(LOCK_FILE, "w")
827
- deadline = time.monotonic() + timeout
828
- while True:
829
- try:
830
- fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
831
- break
832
- except (IOError, OSError):
833
- if time.monotonic() >= deadline:
834
- debug("Could not acquire state lock within timeout, proceeding without lock")
835
- lock_fd.close()
836
- lock_fd = None
837
- yield
838
- return
839
- time.sleep(0.1)
840
- try:
841
- yield
842
- finally:
843
- fcntl.flock(lock_fd, fcntl.LOCK_UN)
844
- lock_fd.close()
845
- except Exception as e:
846
- debug(f"Lock error, proceeding without lock: {e}")
847
- if lock_fd is not None:
848
- with contextlib.suppress(Exception):
849
- lock_fd.close()
850
- yield
851
-
852
-
853
- def main():
854
- script_start = datetime.now()
855
- debug("Hook started")
856
-
857
- # Check if tracing is enabled
858
- if os.environ.get("TRACE_TO_RESPAN", "").lower() != "true":
859
- debug("Tracing disabled (TRACE_TO_RESPAN != true)")
860
- sys.exit(0)
861
-
862
- # Check for required environment variables
863
- api_key = os.getenv("RESPAN_API_KEY")
864
- # Default: api.respan.ai | Enterprise: endpoint.respan.ai (set RESPAN_BASE_URL)
865
- base_url = os.getenv("RESPAN_BASE_URL", "https://api.respan.ai/api")
866
-
867
- if not api_key:
868
- log("ERROR", "Respan API key not set (RESPAN_API_KEY)")
869
- sys.exit(0)
870
-
871
- # Try stdin payload first, fall back to filesystem scan
872
- result = read_stdin_payload()
873
- if not result:
874
- result = find_latest_transcript()
875
- if not result:
876
- debug("No transcript file found")
877
- sys.exit(0)
878
-
879
- session_id, transcript_file = result
880
-
881
- if not transcript_file:
882
- debug("No transcript file found")
883
- sys.exit(0)
884
-
885
- debug(f"Processing session: {session_id}")
886
-
887
- # Process the transcript under file lock
888
- try:
889
- with state_lock():
890
- state = load_state()
891
- turns = process_transcript(session_id, transcript_file, state, api_key, base_url)
892
-
893
- # Log execution time
894
- duration = (datetime.now() - script_start).total_seconds()
895
- log("INFO", f"Processed {turns} turns in {duration:.1f}s")
896
-
897
- if duration > 180:
898
- log("WARN", f"Hook took {duration:.1f}s (>3min), consider optimizing")
899
-
900
- except Exception as e:
901
- log("ERROR", f"Failed to process transcript: {e}")
902
- import traceback
903
- debug(traceback.format_exc())
904
-
905
- sys.exit(0)
906
-
907
-
908
- if __name__ == "__main__":
909
- main()