lightcone-cli 0.2.0__py3-none-any.whl

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.
Files changed (46) hide show
  1. lightcone/cli/__init__.py +16 -0
  2. lightcone/cli/claude/lightcone/agents/lc-extractor.md +114 -0
  3. lightcone/cli/claude/lightcone/guides/astra-reference.md +290 -0
  4. lightcone/cli/claude/lightcone/guides/lightcone-cli-reference.md +75 -0
  5. lightcone/cli/claude/lightcone/guides/ui-brand.md +86 -0
  6. lightcone/cli/claude/lightcone/hooks/langfuse_git_commit_hook.py +303 -0
  7. lightcone/cli/claude/lightcone/hooks/langfuse_hook.py +894 -0
  8. lightcone/cli/claude/lightcone/hooks/langfuse_prepare_commit_msg.py +142 -0
  9. lightcone/cli/claude/lightcone/hooks/langfuse_session_init_hook.py +83 -0
  10. lightcone/cli/claude/lightcone/hooks/langfuse_utils.py +457 -0
  11. lightcone/cli/claude/lightcone/scripts/activate-venv.sh +44 -0
  12. lightcone/cli/claude/lightcone/scripts/check-lc-run.sh +140 -0
  13. lightcone/cli/claude/lightcone/scripts/session-start.sh +140 -0
  14. lightcone/cli/claude/lightcone/scripts/validate-on-save.sh +77 -0
  15. lightcone/cli/claude/lightcone/skills/lc-build/SKILL.md +92 -0
  16. lightcone/cli/claude/lightcone/skills/lc-build/assets/loop-prompt.md +92 -0
  17. lightcone/cli/claude/lightcone/skills/lc-build/scripts/setup-lc-build.sh +240 -0
  18. lightcone/cli/claude/lightcone/skills/lc-feedback/SKILL.md +94 -0
  19. lightcone/cli/claude/lightcone/skills/lc-migrate/SKILL.md +98 -0
  20. lightcone/cli/claude/lightcone/skills/lc-new/SKILL.md +183 -0
  21. lightcone/cli/claude/lightcone/skills/lc-verify/SKILL.md +53 -0
  22. lightcone/cli/claude/lightcone/templates/CLAUDE.md +32 -0
  23. lightcone/cli/commands.py +2327 -0
  24. lightcone/cli/plugin.py +34 -0
  25. lightcone/engine/__init__.py +42 -0
  26. lightcone/engine/assets.py +418 -0
  27. lightcone/engine/container.py +370 -0
  28. lightcone/engine/io_manager.py +27 -0
  29. lightcone/engine/runner.py +1017 -0
  30. lightcone/engine/site_registry.py +142 -0
  31. lightcone/engine/status.py +135 -0
  32. lightcone/engine/targets.py +68 -0
  33. lightcone/engine/tree.py +245 -0
  34. lightcone/eval/__init__.py +25 -0
  35. lightcone/eval/build.py +148 -0
  36. lightcone/eval/cli.py +176 -0
  37. lightcone/eval/graders.py +192 -0
  38. lightcone/eval/harness.py +265 -0
  39. lightcone/eval/models.py +117 -0
  40. lightcone/eval/report.py +214 -0
  41. lightcone/eval/sandbox.py +394 -0
  42. lightcone_cli-0.2.0.dist-info/METADATA +16 -0
  43. lightcone_cli-0.2.0.dist-info/RECORD +46 -0
  44. lightcone_cli-0.2.0.dist-info/WHEEL +4 -0
  45. lightcone_cli-0.2.0.dist-info/entry_points.txt +2 -0
  46. lightcone_cli-0.2.0.dist-info/licenses/LICENSE +29 -0
@@ -0,0 +1,894 @@
1
+ #!/usr/bin/env python3
2
+ # Copied from langfuse-cli (https://github.com/langfuse/langfuse-cli)
3
+ # Copyright (c) 2023-2026 Langfuse GmbH — MIT License
4
+ # See NOTICE file in the project root for full license text.
5
+ """
6
+ Claude Code Stop hook -> Langfuse tracing.
7
+
8
+ Reads the conversation transcript incrementally and emits turns to Langfuse.
9
+ Installed by langfuse-cli.
10
+ """
11
+
12
+ import hashlib
13
+ import json
14
+ import os
15
+ import re
16
+ import sys
17
+ import time
18
+ import time as _time_mod
19
+ from dataclasses import dataclass, field
20
+ from datetime import datetime, timezone
21
+ from pathlib import Path
22
+ from typing import Any, Dict, List, Optional, Tuple
23
+
24
+ try:
25
+ from langfuse_utils import (
26
+ DEBUG,
27
+ LAST_TRACE_FILE,
28
+ LOCK_FILE,
29
+ MAX_CHARS,
30
+ STATE_DIR,
31
+ STATE_FILE,
32
+ debug,
33
+ error,
34
+ extract_session_id,
35
+ extract_transcript_path,
36
+ get_claude_user_email,
37
+ get_git_metadata,
38
+ get_langfuse_credentials,
39
+ info,
40
+ read_hook_payload,
41
+ read_last_trace,
42
+ resolve_repo_root_with_fallback,
43
+ save_last_trace,
44
+ tracing_enabled,
45
+ write_trace_manifest,
46
+ )
47
+ except ImportError:
48
+ sys.exit(0)
49
+
50
+ try:
51
+ from langfuse import Langfuse, propagate_attributes
52
+ except Exception:
53
+ sys.exit(0)
54
+
55
+
56
+ # --------------- State locking (best-effort) ---------------
57
+ class FileLock:
58
+ def __init__(self, path: Path, timeout_s: float = 2.0):
59
+ self.path = path
60
+ self.timeout_s = timeout_s
61
+ self._fh = None
62
+
63
+ def __enter__(self):
64
+ STATE_DIR.mkdir(parents=True, exist_ok=True)
65
+ self._fh = open(self.path, "a+", encoding="utf-8")
66
+ try:
67
+ import fcntl
68
+
69
+ deadline = time.time() + self.timeout_s
70
+ while True:
71
+ try:
72
+ fcntl.flock(self._fh.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
73
+ break
74
+ except BlockingIOError:
75
+ if time.time() > deadline:
76
+ break
77
+ time.sleep(0.05)
78
+ except Exception:
79
+ pass
80
+ return self
81
+
82
+ def __exit__(self, exc_type, exc, tb):
83
+ try:
84
+ import fcntl
85
+
86
+ fcntl.flock(self._fh.fileno(), fcntl.LOCK_UN)
87
+ except Exception:
88
+ pass
89
+ try:
90
+ self._fh.close()
91
+ except Exception:
92
+ pass
93
+
94
+
95
+ def load_state() -> Dict[str, Any]:
96
+ try:
97
+ if not STATE_FILE.exists():
98
+ return {}
99
+ return json.loads(STATE_FILE.read_text(encoding="utf-8"))
100
+ except Exception:
101
+ return {}
102
+
103
+
104
+ def save_state(state: Dict[str, Any]) -> None:
105
+ try:
106
+ STATE_DIR.mkdir(parents=True, exist_ok=True)
107
+ tmp = STATE_FILE.with_suffix(".tmp")
108
+ tmp.write_text(json.dumps(state, indent=2, sort_keys=True), encoding="utf-8")
109
+ os.replace(tmp, STATE_FILE)
110
+ except Exception as e:
111
+ debug(f"save_state failed: {e}")
112
+
113
+
114
+ def state_key(session_id: str, transcript_path: str) -> str:
115
+ raw = f"{session_id}::{transcript_path}"
116
+ return hashlib.sha256(raw.encode("utf-8")).hexdigest()
117
+
118
+
119
+ # --------------- Transcript parsing helpers ---------------
120
+ def get_content(msg: Dict[str, Any]) -> Any:
121
+ if not isinstance(msg, dict):
122
+ return None
123
+ if "message" in msg and isinstance(msg.get("message"), dict):
124
+ return msg["message"].get("content")
125
+ return msg.get("content")
126
+
127
+
128
+ def get_role(msg: Dict[str, Any]) -> Optional[str]:
129
+ t = msg.get("type")
130
+ if t in ("user", "assistant"):
131
+ return t
132
+ m = msg.get("message")
133
+ if isinstance(m, dict):
134
+ r = m.get("role")
135
+ if r in ("user", "assistant"):
136
+ return r
137
+ return None
138
+
139
+
140
+ def is_tool_result(msg: Dict[str, Any]) -> bool:
141
+ role = get_role(msg)
142
+ if role != "user":
143
+ return False
144
+ content = get_content(msg)
145
+ if isinstance(content, list):
146
+ return any(isinstance(x, dict) and x.get("type") == "tool_result" for x in content)
147
+ return False
148
+
149
+
150
+ def iter_tool_results(content: Any) -> List[Dict[str, Any]]:
151
+ out: List[Dict[str, Any]] = []
152
+ if isinstance(content, list):
153
+ for x in content:
154
+ if isinstance(x, dict) and x.get("type") == "tool_result":
155
+ out.append(x)
156
+ return out
157
+
158
+
159
+ def iter_tool_uses(content: Any) -> List[Dict[str, Any]]:
160
+ out: List[Dict[str, Any]] = []
161
+ if isinstance(content, list):
162
+ for x in content:
163
+ if isinstance(x, dict) and x.get("type") == "tool_use":
164
+ out.append(x)
165
+ return out
166
+
167
+
168
+ def extract_text(content: Any) -> str:
169
+ if isinstance(content, str):
170
+ return content
171
+ if isinstance(content, list):
172
+ parts: List[str] = []
173
+ for x in content:
174
+ if isinstance(x, dict) and x.get("type") == "text":
175
+ parts.append(x.get("text", ""))
176
+ elif isinstance(x, str):
177
+ parts.append(x)
178
+ return "\n".join([p for p in parts if p])
179
+ return ""
180
+
181
+
182
+ def truncate_text(s: str, max_chars: int = MAX_CHARS) -> Tuple[str, Dict[str, Any]]:
183
+ if s is None:
184
+ return "", {"truncated": False, "orig_len": 0}
185
+ orig_len = len(s)
186
+ if orig_len <= max_chars:
187
+ return s, {"truncated": False, "orig_len": orig_len}
188
+ head = s[:max_chars]
189
+ return head, {
190
+ "truncated": True,
191
+ "orig_len": orig_len,
192
+ "kept_len": len(head),
193
+ "sha256": hashlib.sha256(s.encode("utf-8")).hexdigest(),
194
+ }
195
+
196
+
197
+ def get_model(msg: Dict[str, Any]) -> str:
198
+ m = msg.get("message")
199
+ if isinstance(m, dict):
200
+ return m.get("model") or "claude"
201
+ return "claude"
202
+
203
+
204
+ def get_message_id(msg: Dict[str, Any]) -> Optional[str]:
205
+ m = msg.get("message")
206
+ if isinstance(m, dict):
207
+ mid = m.get("id")
208
+ if isinstance(mid, str) and mid:
209
+ return mid
210
+ return None
211
+
212
+
213
+ def parse_timestamp(msg: Dict[str, Any]) -> Optional[datetime]:
214
+ ts = msg.get("timestamp")
215
+ if isinstance(ts, str):
216
+ try:
217
+ return datetime.fromisoformat(ts.replace("Z", "+00:00"))
218
+ except Exception:
219
+ return None
220
+ return None
221
+
222
+
223
+ def get_version(msg: Dict[str, Any]) -> Optional[str]:
224
+ v = msg.get("version")
225
+ return v if isinstance(v, str) and v else None
226
+
227
+
228
+ def _duration_ns(start: Optional[datetime], end: Optional[datetime]) -> Optional[int]:
229
+ """Compute duration in nanoseconds between two transcript timestamps."""
230
+ if not start or not end:
231
+ return None
232
+ delta = (end - start).total_seconds()
233
+ if delta < 0:
234
+ return None
235
+ return int(delta * 1_000_000_000)
236
+
237
+
238
+ def extract_bash_command_prefix(tool_input: Any) -> Optional[str]:
239
+ """Extract the first command word from a Bash tool input."""
240
+ if isinstance(tool_input, dict):
241
+ cmd = tool_input.get("command", "")
242
+ elif isinstance(tool_input, str):
243
+ cmd = tool_input
244
+ else:
245
+ return None
246
+ if not isinstance(cmd, str) or not cmd.strip():
247
+ return None
248
+ tokens = re.split(r"[\s|;&]", cmd.strip())
249
+ first_word = tokens[0] if tokens else None
250
+ return first_word if first_word else None
251
+
252
+
253
+ # --------------- Incremental reader ---------------
254
+ @dataclass
255
+ class SessionState:
256
+ offset: int = 0
257
+ buffer: str = ""
258
+ turn_count: int = 0
259
+
260
+
261
+ def load_session_state(global_state: Dict[str, Any], key: str) -> SessionState:
262
+ s = global_state.get(key, {})
263
+ return SessionState(
264
+ offset=int(s.get("offset", 0)),
265
+ buffer=str(s.get("buffer", "")),
266
+ turn_count=int(s.get("turn_count", 0)),
267
+ )
268
+
269
+
270
+ def write_session_state(global_state: Dict[str, Any], key: str, ss: SessionState) -> None:
271
+ global_state[key] = {
272
+ "offset": ss.offset,
273
+ "buffer": ss.buffer,
274
+ "turn_count": ss.turn_count,
275
+ "updated": datetime.now(timezone.utc).isoformat(),
276
+ }
277
+
278
+
279
+ def read_new_jsonl(transcript_path: Path, ss: SessionState) -> Tuple[List[Dict[str, Any]], SessionState]:
280
+ if not transcript_path.exists():
281
+ return [], ss
282
+
283
+ try:
284
+ file_size = transcript_path.stat().st_size
285
+ if ss.offset > file_size:
286
+ # Transcript may have been rotated/truncated; restart incremental read.
287
+ ss.offset = 0
288
+ ss.buffer = ""
289
+ with open(transcript_path, "rb") as f:
290
+ f.seek(ss.offset)
291
+ chunk = f.read()
292
+ new_offset = f.tell()
293
+ except Exception as e:
294
+ debug(f"read_new_jsonl failed: {e}")
295
+ return [], ss
296
+
297
+ if not chunk:
298
+ return [], ss
299
+
300
+ try:
301
+ text = chunk.decode("utf-8", errors="replace")
302
+ except Exception:
303
+ text = chunk.decode(errors="replace")
304
+
305
+ combined = ss.buffer + text
306
+ lines = combined.split("\n")
307
+ ss.buffer = lines[-1]
308
+ ss.offset = new_offset
309
+
310
+ msgs: List[Dict[str, Any]] = []
311
+ for line in lines[:-1]:
312
+ line = line.strip()
313
+ if not line:
314
+ continue
315
+ try:
316
+ msgs.append(json.loads(line))
317
+ except Exception:
318
+ continue
319
+
320
+ return msgs, ss
321
+
322
+
323
+ # --------------- Turn assembly ---------------
324
+ @dataclass
325
+ class ToolResult:
326
+ content: Any
327
+ is_error: bool = False
328
+ timestamp: Optional[datetime] = None
329
+
330
+
331
+ @dataclass
332
+ class Turn:
333
+ user_msg: Dict[str, Any]
334
+ assistant_msgs: List[Dict[str, Any]]
335
+ tool_results_by_id: Dict[str, ToolResult]
336
+ user_timestamp: Optional[datetime] = None
337
+ first_assistant_timestamp: Optional[datetime] = None
338
+ last_assistant_timestamp: Optional[datetime] = None
339
+ tool_use_timestamps: Dict[str, Optional[datetime]] = field(default_factory=dict)
340
+ claude_code_version: Optional[str] = None
341
+
342
+
343
+ def build_turns(messages: List[Dict[str, Any]]) -> List[Turn]:
344
+ """Assemble a flat list of JSONL transcript messages into conversation turns.
345
+
346
+ A *turn* groups a single user message with all the assistant messages that
347
+ follow it (including intermediate tool-use / tool-result exchanges) until
348
+ the next user message arrives.
349
+
350
+ The function handles the multi-step structure produced by Claude Code:
351
+
352
+ * One or more ``"user"`` messages may carry ``tool_result`` content blocks
353
+ that belong to the *preceding* assistant turn (i.e. the results of tool
354
+ calls the assistant requested). These are attached to the current turn,
355
+ not treated as a new user turn.
356
+ * Multiple assistant messages are issued during a single turn (one per
357
+ tool-use / response cycle). All of them are collected in
358
+ ``Turn.assistant_msgs``; only the *latest* message with a given
359
+ ``message_id`` is kept to avoid duplicates from streaming.
360
+ * When a new user message that is *not* purely tool-result content arrives,
361
+ the accumulated state is flushed into a ``Turn`` object and a new turn
362
+ begins.
363
+
364
+ Args:
365
+ messages: Raw JSONL objects from a Claude Code transcript file. Each
366
+ entry must have at least a ``"role"`` key (``"user"`` or
367
+ ``"assistant"``). Entries without a recognized role are silently
368
+ skipped.
369
+
370
+ Returns:
371
+ Ordered list of :class:`Turn` objects, one per user-initiated exchange.
372
+ """
373
+ turns: List[Turn] = []
374
+ current_user: Optional[Dict[str, Any]] = None
375
+ user_ts: Optional[datetime] = None
376
+ assistant_order: List[str] = []
377
+ assistant_latest: Dict[str, Dict[str, Any]] = {}
378
+ assistant_timestamps: Dict[str, Optional[datetime]] = {}
379
+ tool_results_by_id: Dict[str, ToolResult] = {}
380
+ tool_use_timestamps: Dict[str, Optional[datetime]] = {}
381
+ version: Optional[str] = None
382
+
383
+ def flush_turn():
384
+ nonlocal current_user, user_ts, assistant_order, assistant_latest
385
+ nonlocal assistant_timestamps, tool_results_by_id, tool_use_timestamps
386
+ nonlocal turns, version
387
+ if current_user is None:
388
+ return
389
+ if not assistant_latest:
390
+ # No assistant response yet. If there are tool_results (from
391
+ # a denial recorded as a user-side tool_result), still skip
392
+ # because we have no assistant content to show.
393
+ return
394
+ ordered_mids = [mid for mid in assistant_order if mid in assistant_latest]
395
+ assistants = [assistant_latest[mid] for mid in ordered_mids]
396
+ first_ts = assistant_timestamps.get(ordered_mids[0]) if ordered_mids else None
397
+ last_ts = assistant_timestamps.get(ordered_mids[-1]) if ordered_mids else None
398
+ turns.append(Turn(
399
+ user_msg=current_user,
400
+ assistant_msgs=assistants,
401
+ tool_results_by_id=dict(tool_results_by_id),
402
+ user_timestamp=user_ts,
403
+ first_assistant_timestamp=first_ts,
404
+ last_assistant_timestamp=last_ts,
405
+ tool_use_timestamps=dict(tool_use_timestamps),
406
+ claude_code_version=version,
407
+ ))
408
+
409
+ for msg_idx, msg in enumerate(messages):
410
+ msg_version = get_version(msg)
411
+ if msg_version:
412
+ version = msg_version
413
+
414
+ role = get_role(msg)
415
+ msg_type = msg.get("type", "?")
416
+ debug(f"build_turns[{msg_idx}]: type={msg_type} role={role} is_tool_result={is_tool_result(msg)}")
417
+
418
+ if is_tool_result(msg):
419
+ tr_ts = parse_timestamp(msg)
420
+ for tr in iter_tool_results(get_content(msg)):
421
+ tid = tr.get("tool_use_id")
422
+ if tid:
423
+ tool_results_by_id[str(tid)] = ToolResult(
424
+ content=tr.get("content"),
425
+ is_error=bool(tr.get("is_error", False)),
426
+ timestamp=tr_ts,
427
+ )
428
+ continue
429
+
430
+ if role == "user":
431
+ flush_turn()
432
+ current_user = msg
433
+ user_ts = parse_timestamp(msg)
434
+ assistant_order = []
435
+ assistant_latest = {}
436
+ assistant_timestamps = {}
437
+ tool_results_by_id = {}
438
+ tool_use_timestamps = {}
439
+ continue
440
+
441
+ if role == "assistant":
442
+ if current_user is None:
443
+ continue
444
+ mid = get_message_id(msg) or f"noid:{len(assistant_order)}"
445
+ if mid not in assistant_latest:
446
+ assistant_order.append(mid)
447
+ assistant_latest[mid] = msg
448
+ assistant_timestamps[mid] = parse_timestamp(msg)
449
+ for tu in iter_tool_uses(get_content(msg)):
450
+ tid = tu.get("id")
451
+ if tid:
452
+ tool_use_timestamps[str(tid)] = parse_timestamp(msg)
453
+ continue
454
+
455
+ flush_turn()
456
+ return turns
457
+
458
+
459
+ # --------------- Langfuse emit ---------------
460
+ def _tool_calls_from_assistants(assistant_msgs: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
461
+ calls: List[Dict[str, Any]] = []
462
+ for am in assistant_msgs:
463
+ for tu in iter_tool_uses(get_content(am)):
464
+ tid = tu.get("id") or ""
465
+ raw_input = tu.get("input") if isinstance(tu.get("input"), (dict, list, str, int, float, bool)) else {}
466
+ calls.append({
467
+ "id": str(tid),
468
+ "name": tu.get("name") or "unknown",
469
+ "input": raw_input,
470
+ })
471
+ return calls
472
+
473
+
474
+ def _tool_calls_to_chatml(tool_calls: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
475
+ """Convert internal tool call list to OpenAI ChatML tool_calls format."""
476
+ out: List[Dict[str, Any]] = []
477
+ for tc in tool_calls:
478
+ args = tc["input"]
479
+ args_str = args if isinstance(args, str) else json.dumps(args, ensure_ascii=False)
480
+ out.append({
481
+ "id": tc["id"],
482
+ "type": "function",
483
+ "function": {
484
+ "name": tc["name"],
485
+ "arguments": args_str,
486
+ },
487
+ })
488
+ return out
489
+
490
+
491
+ def _merge_metadata(base: Dict[str, Any], extra: Dict[str, Any]) -> Dict[str, Any]:
492
+ merged = dict(base)
493
+ for key, value in extra.items():
494
+ if value is not None and value != "":
495
+ merged[key] = value
496
+ return merged
497
+
498
+
499
+ def _build_propagated_metadata(git_metadata: Dict[str, Any]) -> Dict[str, str]:
500
+ out: Dict[str, str] = {}
501
+ commit_url = git_metadata.get("git_commit_url")
502
+ if isinstance(commit_url, str) and commit_url and len(commit_url) <= 200:
503
+ out["github_commit_url"] = commit_url
504
+ commit_sha = git_metadata.get("git_commit_sha")
505
+ if isinstance(commit_sha, str) and commit_sha:
506
+ out["commit_sha"] = commit_sha
507
+ return out
508
+
509
+
510
+ def emit_turn(
511
+ langfuse: Langfuse,
512
+ session_id: str,
513
+ turn_num: int,
514
+ turn: Turn,
515
+ transcript_path: Path,
516
+ pre_trace_id: Optional[str] = None,
517
+ git_metadata: Optional[Dict[str, Any]] = None,
518
+ propagated_metadata: Optional[Dict[str, str]] = None,
519
+ user_id: Optional[str] = None,
520
+ ) -> Optional[str]:
521
+ """Emit a single conversation turn to Langfuse as a trace + generation span.
522
+
523
+ Each call creates:
524
+
525
+ * A **trace** (``langfuse.trace``) scoped to *session_id*. If
526
+ *pre_trace_id* is supplied (from the session-init hook) and this is
527
+ the first turn (``turn_num == 1``), the trace reuses that ID so the
528
+ full session appears as one trace in the Langfuse UI.
529
+ * A **generation span** (``trace.generation``) that carries the full
530
+ ChatML-formatted input/output, model name, tool calls with their
531
+ outputs, and timing metadata.
532
+
533
+ Tool calls present in the assistant messages are extracted and added to
534
+ the generation span in OpenAI ChatML format (``tool_calls`` key).
535
+ Tool results (from subsequent ``"user"`` role messages) are looked up
536
+ by tool-call ID in ``turn.tool_results_by_id`` and attached as
537
+ ``output`` fields on each tool call.
538
+
539
+ Args:
540
+ langfuse: An authenticated :class:`langfuse.Langfuse` client instance.
541
+ session_id: The Claude Code session identifier used to group traces.
542
+ turn_num: 1-based index of this turn within the session.
543
+ turn: The assembled :class:`Turn` object to emit.
544
+ transcript_path: Path to the transcript file (stored as metadata).
545
+ pre_trace_id: Deterministic trace ID from the session-init hook.
546
+ When provided and ``turn_num == 1``, the trace is created with
547
+ this ID so it links to the pre-session trace entry.
548
+ git_metadata: Dict of git context (commit SHA, GitHub URL, branch).
549
+ Merged into span metadata when present.
550
+ propagated_metadata: Extra string metadata to propagate from a prior
551
+ turn's trace (e.g. ``github_commit_url``).
552
+ user_id: The Claude user's email address (from ``~/.claude.json``).
553
+ Attached to the trace for per-user analytics in Langfuse.
554
+
555
+ Returns:
556
+ The trace ID string if the emit succeeded, ``None`` on any error.
557
+ """
558
+ user_text_raw = extract_text(get_content(turn.user_msg))
559
+ user_text, user_text_meta = truncate_text(user_text_raw)
560
+
561
+ last_assistant = turn.assistant_msgs[-1]
562
+ assistant_text_raw = extract_text(get_content(last_assistant))
563
+ assistant_text, assistant_text_meta = truncate_text(assistant_text_raw)
564
+
565
+ model = get_model(turn.assistant_msgs[0])
566
+ tool_calls = _tool_calls_from_assistants(turn.assistant_msgs)
567
+
568
+ for c in tool_calls:
569
+ tid = c["id"]
570
+ if tid and tid in turn.tool_results_by_id:
571
+ tr = turn.tool_results_by_id[tid]
572
+ out_raw = tr.content
573
+ out_str = out_raw if isinstance(out_raw, str) else json.dumps(out_raw, ensure_ascii=False)
574
+ out_trunc, out_meta = truncate_text(out_str)
575
+ c["output"] = out_trunc
576
+ c["output_meta"] = out_meta
577
+ c["is_error"] = tr.is_error
578
+ else:
579
+ c["output"] = None
580
+ c["is_error"] = True
581
+
582
+ chatml_tool_calls = _tool_calls_to_chatml(tool_calls)
583
+
584
+ # ChatML-formatted input (OpenAI-style request body)
585
+ generation_input: Dict[str, Any] = {
586
+ "model": model,
587
+ "messages": [{"role": "user", "content": user_text}],
588
+ }
589
+
590
+ # ChatML-formatted output (assistant message with optional tool_calls)
591
+ generation_output: Dict[str, Any] = {
592
+ "role": "assistant",
593
+ "content": assistant_text,
594
+ }
595
+ if chatml_tool_calls:
596
+ generation_output["tool_calls"] = chatml_tool_calls
597
+
598
+ span_input: Dict[str, Any] = {
599
+ "model": model,
600
+ "messages": [{"role": "user", "content": user_text}],
601
+ }
602
+ span_output: Dict[str, Any] = dict(generation_output)
603
+
604
+ span_metadata: Dict[str, Any] = {
605
+ "source": "claude-code",
606
+ "session_id": session_id,
607
+ "turn_number": turn_num,
608
+ "transcript_path": str(transcript_path),
609
+ "user_text": user_text_meta,
610
+ }
611
+ if turn.claude_code_version:
612
+ span_metadata["claude_code_version"] = turn.claude_code_version
613
+ if git_metadata:
614
+ span_metadata = _merge_metadata(span_metadata, git_metadata)
615
+
616
+ propagate_kwargs: Dict[str, Any] = {
617
+ "session_id": session_id,
618
+ "trace_name": f"Claude Code - Turn {turn_num}",
619
+ "tags": ["claude-code"],
620
+ }
621
+ if user_id:
622
+ propagate_kwargs["user_id"] = user_id
623
+ if propagated_metadata:
624
+ propagate_kwargs["metadata"] = propagated_metadata
625
+
626
+ # Compute durations in nanoseconds from transcript timestamps
627
+ span_dur_ns = _duration_ns(turn.user_timestamp, turn.last_assistant_timestamp)
628
+ gen_dur_ns = _duration_ns(turn.first_assistant_timestamp, turn.last_assistant_timestamp)
629
+
630
+ with propagate_attributes(**propagate_kwargs):
631
+ if pre_trace_id:
632
+ obs_kwargs: Dict[str, Any] = {
633
+ "as_type": "span",
634
+ "name": f"Claude Code - Turn {turn_num}",
635
+ "input": span_input,
636
+ "metadata": span_metadata,
637
+ "trace_context": {"trace_id": pre_trace_id},
638
+ }
639
+ try:
640
+ span_ctx = langfuse.start_as_current_observation(**obs_kwargs)
641
+ except TypeError as exc:
642
+ if "trace_context" in str(exc):
643
+ obs_kwargs.pop("trace_context", None)
644
+ span_ctx = langfuse.start_as_current_observation(**obs_kwargs)
645
+ else:
646
+ raise
647
+ else:
648
+ span_ctx = langfuse.start_as_current_span(
649
+ name=f"Claude Code - Turn {turn_num}",
650
+ input=span_input,
651
+ metadata=span_metadata,
652
+ )
653
+
654
+ span_start_ns = _time_mod.time_ns()
655
+
656
+ with span_ctx as trace_span:
657
+ gen_metadata = _merge_metadata({
658
+ "assistant_text": assistant_text_meta,
659
+ "tool_count": len(tool_calls),
660
+ }, git_metadata or {})
661
+ if turn.claude_code_version:
662
+ gen_metadata["claude_code_version"] = turn.claude_code_version
663
+
664
+ gen_start_ns = _time_mod.time_ns()
665
+ gen_obs = langfuse.start_observation(
666
+ name="Claude Response",
667
+ as_type="generation",
668
+ model=model,
669
+ input=generation_input,
670
+ output=generation_output,
671
+ metadata=gen_metadata,
672
+ )
673
+ if gen_dur_ns is not None:
674
+ gen_obs.end(end_time=gen_start_ns + gen_dur_ns)
675
+ else:
676
+ gen_obs.end()
677
+
678
+ for tc in tool_calls:
679
+ # ChatML-formatted tool input (assistant's tool call)
680
+ chatml_tc = _tool_calls_to_chatml([tc])[0]
681
+ tool_chatml_input: Dict[str, Any] = {
682
+ "role": "assistant",
683
+ "tool_calls": [chatml_tc],
684
+ }
685
+
686
+ # ChatML-formatted tool output (tool result message)
687
+ tool_chatml_output: Optional[Dict[str, Any]] = None
688
+ if tc.get("output") is not None:
689
+ tool_chatml_output = {
690
+ "role": "tool",
691
+ "tool_call_id": tc["id"],
692
+ "content": tc["output"],
693
+ }
694
+
695
+ # Observation name: include bash command prefix for Bash tools
696
+ obs_name = f"Tool: {tc['name']}"
697
+ if tc["name"] == "Bash":
698
+ prefix = extract_bash_command_prefix(tc["input"])
699
+ if prefix:
700
+ obs_name = f"Tool: Bash ({prefix})"
701
+
702
+ tool_metadata = _merge_metadata({
703
+ "tool_name": tc["name"],
704
+ "tool_id": tc["id"],
705
+ "output_meta": tc.get("output_meta"),
706
+ }, git_metadata or {})
707
+ if turn.claude_code_version:
708
+ tool_metadata["claude_code_version"] = turn.claude_code_version
709
+
710
+ # Level for denied/failed tools
711
+ level_kwargs: Dict[str, Any] = {}
712
+ if tc.get("is_error") or tc.get("output") is None:
713
+ level_kwargs["level"] = "ERROR"
714
+ level_kwargs["status_message"] = "Tool execution denied or failed"
715
+
716
+ tool_start_ns = _time_mod.time_ns()
717
+ tool_obs = langfuse.start_observation(
718
+ name=obs_name,
719
+ as_type="tool",
720
+ input=tool_chatml_input,
721
+ output=tool_chatml_output,
722
+ metadata=tool_metadata,
723
+ **level_kwargs,
724
+ )
725
+
726
+ tu_ts = turn.tool_use_timestamps.get(tc["id"])
727
+ tr_obj = turn.tool_results_by_id.get(tc["id"])
728
+ tool_dur_ns = _duration_ns(tu_ts, tr_obj.timestamp if tr_obj else None)
729
+ if tool_dur_ns is not None:
730
+ tool_obs.end(end_time=tool_start_ns + tool_dur_ns)
731
+ else:
732
+ tool_obs.end()
733
+
734
+ trace_span.update(output=span_output)
735
+
736
+ # Set span end_time based on transcript duration
737
+ if span_dur_ns is not None:
738
+ try:
739
+ trace_span.end(end_time=span_start_ns + span_dur_ns)
740
+ except Exception:
741
+ pass
742
+
743
+ return getattr(trace_span, "trace_id", None)
744
+
745
+
746
+ # --------------- Main ---------------
747
+ def main() -> int:
748
+ start = time.time()
749
+ debug("Hook started")
750
+
751
+ if not tracing_enabled():
752
+ return 0
753
+
754
+ creds = get_langfuse_credentials()
755
+ if not creds:
756
+ return 0
757
+
758
+ payload = read_hook_payload()
759
+ session_id = extract_session_id(payload)
760
+ transcript_path = extract_transcript_path(payload)
761
+
762
+ if not session_id or not transcript_path:
763
+ debug("Missing session_id or transcript_path from hook payload; exiting.")
764
+ return 0
765
+
766
+ if not transcript_path.exists():
767
+ debug(f"Transcript path does not exist: {transcript_path}")
768
+ return 0
769
+
770
+ cwd = Path(os.getcwd())
771
+ git_metadata = get_git_metadata(transcript_path, cwd)
772
+ propagated_metadata = _build_propagated_metadata(git_metadata)
773
+
774
+ user_email = get_claude_user_email()
775
+ if user_email:
776
+ debug(f"Resolved Claude Code user email: {user_email}")
777
+
778
+ try:
779
+ langfuse = Langfuse(
780
+ public_key=creds["public_key"],
781
+ secret_key=creds["secret_key"],
782
+ host=creds["host"],
783
+ )
784
+ except Exception:
785
+ return 0
786
+
787
+ pre_trace_id = None
788
+ last_trace = read_last_trace(expected_session_id=session_id)
789
+ if last_trace:
790
+ pre_trace_id = last_trace.get("trace_id")
791
+ debug(f"Using pre-generated trace_id: {pre_trace_id}")
792
+
793
+ try:
794
+ with FileLock(LOCK_FILE):
795
+ state = load_state()
796
+ key = state_key(session_id, str(transcript_path))
797
+ ss = load_session_state(state, key)
798
+
799
+ msgs, ss = read_new_jsonl(transcript_path, ss)
800
+ if not msgs:
801
+ debug(f"No new messages in transcript (offset={ss.offset})")
802
+ write_session_state(state, key, ss)
803
+ save_state(state)
804
+ return 0
805
+
806
+ debug(f"Read {len(msgs)} new messages from transcript")
807
+ turns = build_turns(msgs)
808
+ if not turns:
809
+ # Log at INFO level to help diagnose missing traces
810
+ msg_types = [m.get("type", "?") for m in msgs]
811
+ info(f"No turns built from {len(msgs)} messages (types: {msg_types}, session={session_id})")
812
+ write_session_state(state, key, ss)
813
+ save_state(state)
814
+ return 0
815
+ debug(f"Built {len(turns)} turns from messages")
816
+
817
+ emitted = 0
818
+ last_trace_id = None
819
+ for t in turns:
820
+ emitted += 1
821
+ turn_num = ss.turn_count + emitted
822
+ # Only bind to the pre-generated trace_id for the very
823
+ # first turn of a session (so the commit-message URL
824
+ # matches). All subsequent turns get their own traces,
825
+ # grouped under the same session_id.
826
+ use_trace_id = pre_trace_id if (ss.turn_count == 0 and emitted == 1) else None
827
+ try:
828
+ tid = emit_turn(
829
+ langfuse,
830
+ session_id,
831
+ turn_num,
832
+ t,
833
+ transcript_path,
834
+ pre_trace_id=use_trace_id,
835
+ git_metadata=git_metadata,
836
+ propagated_metadata=propagated_metadata,
837
+ user_id=user_email,
838
+ )
839
+ if tid:
840
+ last_trace_id = tid
841
+ except Exception as e:
842
+ debug(f"emit_turn failed: {e}")
843
+
844
+ ss.turn_count += emitted
845
+ write_session_state(state, key, ss)
846
+ save_state(state)
847
+
848
+ effective_trace_id = last_trace_id or pre_trace_id
849
+
850
+ # Explicitly stamp git metadata onto the trace so it appears
851
+ # in the Langfuse trace-level metadata (propagate_attributes
852
+ # only applies to NEW traces; the trace may already exist).
853
+ if effective_trace_id and git_metadata:
854
+ trace_meta: Dict[str, Any] = {"source": "claude-code"}
855
+ commit_url = git_metadata.get("git_commit_url")
856
+ if commit_url:
857
+ trace_meta["github_commit_url"] = commit_url
858
+ commit_sha = git_metadata.get("git_commit_sha")
859
+ if commit_sha:
860
+ trace_meta["commit_sha"] = commit_sha
861
+ try:
862
+ langfuse.trace(id=effective_trace_id, metadata=trace_meta)
863
+ except Exception as e:
864
+ debug(f"trace metadata update failed: {e}")
865
+
866
+ try:
867
+ langfuse.flush()
868
+ except Exception:
869
+ pass
870
+
871
+ if effective_trace_id:
872
+ save_last_trace(session_id, effective_trace_id, creds["host"])
873
+
874
+ repo_root = resolve_repo_root_with_fallback(transcript_path, cwd)
875
+ if repo_root and effective_trace_id:
876
+ write_trace_manifest(repo_root, session_id, effective_trace_id, creds["host"], git_metadata)
877
+
878
+ dur = time.time() - start
879
+ info(f"Processed {emitted} turns in {dur:.2f}s (session={session_id})")
880
+ return 0
881
+
882
+ except Exception as e:
883
+ debug(f"Unexpected failure: {e}")
884
+ return 0
885
+
886
+ finally:
887
+ try:
888
+ langfuse.shutdown()
889
+ except Exception:
890
+ pass
891
+
892
+
893
+ if __name__ == "__main__":
894
+ sys.exit(main())