@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.
- package/dist/assets/hook.py +322 -197
- package/dist/commands/integrate/claude-code.d.ts +3 -0
- package/dist/commands/integrate/claude-code.js +46 -14
- package/dist/commands/integrate/codex-cli.d.ts +3 -0
- package/dist/commands/integrate/gemini-cli.d.ts +3 -0
- package/dist/commands/integrate/opencode.d.ts +3 -0
- package/dist/lib/integrate.d.ts +3 -0
- package/dist/lib/integrate.js +10 -0
- package/oclif.manifest.json +89 -1
- package/package.json +2 -2
- package/dist/assets/assets/hook.py +0 -909
|
@@ -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()
|