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.
- lightcone/cli/__init__.py +16 -0
- lightcone/cli/claude/lightcone/agents/lc-extractor.md +114 -0
- lightcone/cli/claude/lightcone/guides/astra-reference.md +290 -0
- lightcone/cli/claude/lightcone/guides/lightcone-cli-reference.md +75 -0
- lightcone/cli/claude/lightcone/guides/ui-brand.md +86 -0
- lightcone/cli/claude/lightcone/hooks/langfuse_git_commit_hook.py +303 -0
- lightcone/cli/claude/lightcone/hooks/langfuse_hook.py +894 -0
- lightcone/cli/claude/lightcone/hooks/langfuse_prepare_commit_msg.py +142 -0
- lightcone/cli/claude/lightcone/hooks/langfuse_session_init_hook.py +83 -0
- lightcone/cli/claude/lightcone/hooks/langfuse_utils.py +457 -0
- lightcone/cli/claude/lightcone/scripts/activate-venv.sh +44 -0
- lightcone/cli/claude/lightcone/scripts/check-lc-run.sh +140 -0
- lightcone/cli/claude/lightcone/scripts/session-start.sh +140 -0
- lightcone/cli/claude/lightcone/scripts/validate-on-save.sh +77 -0
- lightcone/cli/claude/lightcone/skills/lc-build/SKILL.md +92 -0
- lightcone/cli/claude/lightcone/skills/lc-build/assets/loop-prompt.md +92 -0
- lightcone/cli/claude/lightcone/skills/lc-build/scripts/setup-lc-build.sh +240 -0
- lightcone/cli/claude/lightcone/skills/lc-feedback/SKILL.md +94 -0
- lightcone/cli/claude/lightcone/skills/lc-migrate/SKILL.md +98 -0
- lightcone/cli/claude/lightcone/skills/lc-new/SKILL.md +183 -0
- lightcone/cli/claude/lightcone/skills/lc-verify/SKILL.md +53 -0
- lightcone/cli/claude/lightcone/templates/CLAUDE.md +32 -0
- lightcone/cli/commands.py +2327 -0
- lightcone/cli/plugin.py +34 -0
- lightcone/engine/__init__.py +42 -0
- lightcone/engine/assets.py +418 -0
- lightcone/engine/container.py +370 -0
- lightcone/engine/io_manager.py +27 -0
- lightcone/engine/runner.py +1017 -0
- lightcone/engine/site_registry.py +142 -0
- lightcone/engine/status.py +135 -0
- lightcone/engine/targets.py +68 -0
- lightcone/engine/tree.py +245 -0
- lightcone/eval/__init__.py +25 -0
- lightcone/eval/build.py +148 -0
- lightcone/eval/cli.py +176 -0
- lightcone/eval/graders.py +192 -0
- lightcone/eval/harness.py +265 -0
- lightcone/eval/models.py +117 -0
- lightcone/eval/report.py +214 -0
- lightcone/eval/sandbox.py +394 -0
- lightcone_cli-0.2.0.dist-info/METADATA +16 -0
- lightcone_cli-0.2.0.dist-info/RECORD +46 -0
- lightcone_cli-0.2.0.dist-info/WHEEL +4 -0
- lightcone_cli-0.2.0.dist-info/entry_points.txt +2 -0
- 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())
|