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,142 @@
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
+ prepare-commit-msg hook: appends a Langfuse-Trace trailer to commit messages.
7
+ Installed by langfuse-cli.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ try:
17
+ from langfuse_utils import (
18
+ LAST_TRACE_FILE,
19
+ read_recent_trace,
20
+ resolve_repo_root,
21
+ tracing_enabled,
22
+ )
23
+ except ImportError:
24
+ LAST_TRACE_FILE = Path.home() / ".claude" / "state" / "langfuse_last_trace.json"
25
+ import json
26
+ from datetime import datetime, timezone
27
+
28
+ def tracing_enabled() -> bool:
29
+ return os.environ.get("TRACE_TO_LANGFUSE", "").lower() == "true"
30
+
31
+ def read_recent_trace(
32
+ path: Path, max_age_hours: float, expected_session_id: str | None = None
33
+ ) -> dict | None:
34
+ if not path.exists():
35
+ return None
36
+ try:
37
+ data = json.loads(path.read_text(encoding="utf-8"))
38
+ except Exception:
39
+ return None
40
+ if not isinstance(data, dict):
41
+ return None
42
+ trace_url = data.get("trace_url")
43
+ trace_id = data.get("trace_id")
44
+ if not isinstance(trace_url, str) or not trace_url:
45
+ return None
46
+ if not isinstance(trace_id, str) or not trace_id:
47
+ return None
48
+ if expected_session_id and data.get("session_id") != expected_session_id:
49
+ return None
50
+ updated_at = data.get("updated_at")
51
+ if isinstance(updated_at, str):
52
+ try:
53
+ ts = datetime.fromisoformat(updated_at)
54
+ if ts.tzinfo is None:
55
+ ts = ts.replace(tzinfo=timezone.utc)
56
+ age_hours = (datetime.now(timezone.utc) - ts).total_seconds() / 3600
57
+ if age_hours > max_age_hours:
58
+ return None
59
+ except Exception:
60
+ return None
61
+ else:
62
+ return None
63
+ return data
64
+
65
+ def resolve_repo_root(search_path: Path) -> Path | None:
66
+ _ = search_path
67
+ return None
68
+
69
+
70
+ MAX_AGE_HOURS = 4
71
+ SESSION_TRAILER_KEY = "Langfuse-Session"
72
+
73
+
74
+ def _append_trailers(content: str, trailers: list[str]) -> str:
75
+ """Append one or more trailers to a commit message, preserving existing trailer blocks."""
76
+ lines = content.rstrip("\n").split("\n")
77
+
78
+ has_existing_trailers = False
79
+ for line in reversed(lines):
80
+ stripped = line.strip()
81
+ if not stripped:
82
+ break
83
+ if ": " in stripped and not stripped.startswith("#"):
84
+ has_existing_trailers = True
85
+ break
86
+ else:
87
+ break
88
+
89
+ joined = "\n".join(trailers)
90
+ if has_existing_trailers:
91
+ return "\n".join(lines) + "\n" + joined + "\n"
92
+ return "\n".join(lines) + "\n\n" + joined + "\n"
93
+
94
+
95
+ def main() -> int:
96
+ try:
97
+ if not tracing_enabled():
98
+ return 0
99
+
100
+ if len(sys.argv) < 2:
101
+ return 0
102
+
103
+ msg_file = sys.argv[1]
104
+ commit_source = sys.argv[2] if len(sys.argv) > 2 else ""
105
+
106
+ if commit_source in ("merge", "squash"):
107
+ return 0
108
+
109
+ # Prefer the global last-trace file (updated eagerly by the PreToolUse
110
+ # session-init hook for the *current* session) over the per-repo
111
+ # current-session file (only updated by the Stop hook, which runs
112
+ # *after* the commit finishes).
113
+ data = read_recent_trace(LAST_TRACE_FILE, MAX_AGE_HOURS)
114
+ if not data:
115
+ repo_root = resolve_repo_root(Path.cwd()) or Path.cwd()
116
+ local_trace_path = repo_root / ".langfuse" / "current-session.json"
117
+ data = read_recent_trace(local_trace_path, MAX_AGE_HOURS)
118
+ if not data:
119
+ return 0
120
+
121
+ session_url = data.get("session_url")
122
+ if not isinstance(session_url, str) or not session_url:
123
+ return 0
124
+
125
+ try:
126
+ content = Path(msg_file).read_text(encoding="utf-8")
127
+ except Exception:
128
+ return 0
129
+
130
+ if f"{SESSION_TRAILER_KEY}:" in content:
131
+ return 0
132
+
133
+ result = _append_trailers(content, [f"{SESSION_TRAILER_KEY}: {session_url}"])
134
+ Path(msg_file).write_text(result, encoding="utf-8")
135
+ return 0
136
+
137
+ except Exception:
138
+ return 0
139
+
140
+
141
+ if __name__ == "__main__":
142
+ sys.exit(main())
@@ -0,0 +1,83 @@
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
+ PreToolUse hook: eagerly initializes the Langfuse trace ID for the current
7
+ Claude Code session so that prepare-commit-msg can reference it immediately.
8
+
9
+ On the first tool use of a session, this hook:
10
+ 1. Generates a deterministic trace_id from the session_id
11
+ 2. Writes it to ~/.claude/state/langfuse_last_trace.json
12
+
13
+ Subsequent invocations detect the matching session_id and exit immediately.
14
+ Installed by langfuse-cli.
15
+ """
16
+
17
+ import hashlib
18
+ import os
19
+ import sys
20
+
21
+ try:
22
+ from langfuse_utils import (
23
+ debug,
24
+ extract_session_id,
25
+ get_langfuse_credentials,
26
+ read_hook_payload,
27
+ read_last_trace,
28
+ save_last_trace,
29
+ tracing_enabled,
30
+ )
31
+ except ImportError:
32
+ sys.exit(0)
33
+
34
+
35
+ def main() -> int:
36
+ try:
37
+ if not tracing_enabled():
38
+ return 0
39
+
40
+ payload = read_hook_payload()
41
+ session_id = extract_session_id(payload)
42
+ if not session_id:
43
+ return 0
44
+
45
+ existing = read_last_trace(expected_session_id=session_id)
46
+ if existing:
47
+ return 0
48
+
49
+ creds = get_langfuse_credentials()
50
+ if not creds:
51
+ return 0
52
+
53
+ # Generate a deterministic trace_id from the session_id.
54
+ # Prefer the Langfuse SDK's create_trace_id (W3C-compatible 32-char hex)
55
+ # with a fallback to SHA-256 for environments without the SDK or older versions.
56
+ trace_id = None
57
+ try:
58
+ from langfuse import Langfuse
59
+
60
+ lf = Langfuse(
61
+ public_key=creds["public_key"],
62
+ secret_key=creds["secret_key"],
63
+ host=creds["host"],
64
+ )
65
+ trace_id = lf.create_trace_id(seed=session_id)
66
+ lf.shutdown()
67
+ except Exception:
68
+ pass
69
+
70
+ if not trace_id:
71
+ trace_id = hashlib.sha256(session_id.encode("utf-8")).hexdigest()[:32]
72
+
73
+ save_last_trace(session_id, trace_id, creds["host"])
74
+ debug(f"Initialized trace_id {trace_id} for session {session_id}")
75
+
76
+ return 0
77
+
78
+ except Exception:
79
+ return 0
80
+
81
+
82
+ if __name__ == "__main__":
83
+ sys.exit(main())
@@ -0,0 +1,457 @@
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
+ """Shared utilities for Langfuse Claude Code hooks.
6
+
7
+ Installed by langfuse-cli. All hook scripts import from this module.
8
+ """
9
+
10
+ import json
11
+ import os
12
+ import re
13
+ import subprocess
14
+ import sys
15
+ import tempfile
16
+ from datetime import datetime, timezone
17
+ from pathlib import Path
18
+ from typing import Any, Dict, List, Optional
19
+
20
+ # --------------- Configuration ---------------
21
+ HOOK_DEBUG_ENV = "LANGFUSE_HOOK_DEBUG"
22
+ DEBUG = os.environ.get(HOOK_DEBUG_ENV, "").lower() == "true"
23
+
24
+ STATE_DIR = Path.home() / ".claude" / "state"
25
+ LOG_FILE = STATE_DIR / "langfuse_hook.log"
26
+ STATE_FILE = STATE_DIR / "langfuse_state.json"
27
+ LOCK_FILE = STATE_DIR / "langfuse_state.lock"
28
+ LAST_TRACE_FILE = STATE_DIR / "langfuse_last_trace.json"
29
+
30
+ MAX_CHARS = int(os.environ.get("LANGFUSE_HOOK_MAX_CHARS", "20000"))
31
+
32
+
33
+ # --------------- Logging ---------------
34
+ def _log(level: str, message: str) -> None:
35
+ try:
36
+ STATE_DIR.mkdir(parents=True, exist_ok=True)
37
+ ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
38
+ with open(LOG_FILE, "a", encoding="utf-8") as f:
39
+ f.write(f"{ts} [{level}] {message}\n")
40
+ except Exception:
41
+ pass
42
+
43
+
44
+ def debug(msg: str) -> None:
45
+ if DEBUG:
46
+ _log("DEBUG", msg)
47
+
48
+
49
+ def info(msg: str) -> None:
50
+ _log("INFO", msg)
51
+
52
+
53
+ def warn(msg: str) -> None:
54
+ _log("WARN", msg)
55
+
56
+
57
+ def error(msg: str) -> None:
58
+ _log("ERROR", msg)
59
+
60
+
61
+ # --------------- Environment ---------------
62
+ def tracing_enabled() -> bool:
63
+ return os.environ.get("TRACE_TO_LANGFUSE", "").lower() == "true"
64
+
65
+
66
+ def get_langfuse_credentials() -> Optional[Dict[str, str]]:
67
+ public_key = os.environ.get("LANGFUSE_PUBLIC_KEY")
68
+ secret_key = os.environ.get("LANGFUSE_SECRET_KEY")
69
+ if not public_key or not secret_key:
70
+ return None
71
+ host = (
72
+ os.environ.get("LANGFUSE_BASE_URL")
73
+ or os.environ.get("LANGFUSE_HOST")
74
+ or "https://cloud.langfuse.com"
75
+ ).rstrip("/")
76
+ return {"public_key": public_key, "secret_key": secret_key, "host": host}
77
+
78
+
79
+ # --------------- Hook payload ---------------
80
+ def read_hook_payload() -> Dict[str, Any]:
81
+ try:
82
+ data = sys.stdin.read()
83
+ if not data.strip():
84
+ return {}
85
+ return json.loads(data)
86
+ except Exception:
87
+ return {}
88
+
89
+
90
+ def extract_session_id(payload: Dict[str, Any]) -> Optional[str]:
91
+ return (
92
+ payload.get("sessionId")
93
+ or payload.get("session_id")
94
+ or (payload.get("session") or {}).get("id")
95
+ )
96
+
97
+
98
+ def extract_transcript_path(payload: Dict[str, Any]) -> Optional[Path]:
99
+ transcript = (
100
+ payload.get("transcriptPath")
101
+ or payload.get("transcript_path")
102
+ or (payload.get("transcript") or {}).get("path")
103
+ )
104
+ if not transcript:
105
+ return None
106
+ try:
107
+ return Path(transcript).expanduser().resolve()
108
+ except Exception:
109
+ return None
110
+
111
+
112
+ # --------------- Git helpers ---------------
113
+ def run_git(cwd: Path, args: List[str]) -> Optional[str]:
114
+ try:
115
+ output = subprocess.check_output(
116
+ ["git", *args],
117
+ cwd=str(cwd),
118
+ stderr=subprocess.DEVNULL,
119
+ text=True,
120
+ )
121
+ value = output.strip()
122
+ return value or None
123
+ except Exception:
124
+ return None
125
+
126
+
127
+ def resolve_repo_root(search_path: Path) -> Optional[Path]:
128
+ cwd = search_path.parent if search_path.is_file() else search_path
129
+ root = run_git(cwd, ["rev-parse", "--show-toplevel"])
130
+ if not root:
131
+ return None
132
+ try:
133
+ return Path(root).expanduser().resolve()
134
+ except Exception:
135
+ return None
136
+
137
+
138
+ def first_remote(repo_root: Path) -> Optional[str]:
139
+ remotes = run_git(repo_root, ["remote"])
140
+ if not remotes:
141
+ return None
142
+ for line in remotes.splitlines():
143
+ remote = line.strip()
144
+ if remote:
145
+ return remote
146
+ return None
147
+
148
+
149
+ def build_github_commit_url(remote_url: Optional[str], commit_sha: str) -> Optional[str]:
150
+ if not remote_url:
151
+ return None
152
+ remote = remote_url.strip()
153
+ if not remote:
154
+ return None
155
+ patterns = [
156
+ r"^https?://github\.com/(.+?)(?:\.git)?/?$",
157
+ r"^git@github\.com:(.+?)(?:\.git)?$",
158
+ r"^ssh://git@github\.com/(.+?)(?:\.git)?/?$",
159
+ ]
160
+ for pattern in patterns:
161
+ match = re.match(pattern, remote, re.IGNORECASE)
162
+ if match and match.group(1):
163
+ return f"https://github.com/{match.group(1)}/commit/{commit_sha}"
164
+ return None
165
+
166
+
167
+ def get_remote_url(repo_root: Path) -> Optional[str]:
168
+ remote_url = run_git(repo_root, ["remote", "get-url", "origin"])
169
+ if not remote_url:
170
+ remote_name = first_remote(repo_root)
171
+ if remote_name:
172
+ remote_url = run_git(repo_root, ["remote", "get-url", remote_name])
173
+ return remote_url
174
+
175
+
176
+ def resolve_repo_root_with_fallback(*paths: Path) -> Optional[Path]:
177
+ """Try each path in order, returning the first that resolves to a git repo root."""
178
+ for p in paths:
179
+ root = resolve_repo_root(p)
180
+ if root:
181
+ return root
182
+ return None
183
+
184
+
185
+ def get_git_metadata(*search_paths: Path) -> Dict[str, Any]:
186
+ """Build git metadata from the first search path that resolves to a repo.
187
+
188
+ Pass multiple candidates (e.g. transcript path, then cwd) so we find
189
+ the repo even when the transcript lives outside the working tree.
190
+ """
191
+ repo_root = resolve_repo_root_with_fallback(*search_paths)
192
+ if not repo_root:
193
+ return {}
194
+ commit_sha = run_git(repo_root, ["rev-parse", "HEAD"])
195
+ if not commit_sha:
196
+ return {}
197
+ remote_url = get_remote_url(repo_root)
198
+ commit_url = build_github_commit_url(remote_url, commit_sha)
199
+ metadata: Dict[str, Any] = {
200
+ "git_commit_sha": commit_sha,
201
+ "git_remote_url": remote_url,
202
+ }
203
+ if commit_url:
204
+ metadata["git_commit_url"] = commit_url
205
+ return metadata
206
+
207
+
208
+ # --------------- Claude Code identity ---------------
209
+ _cached_user_email: Optional[str] = None
210
+
211
+
212
+ def get_claude_user_email() -> Optional[str]:
213
+ """Resolve the Claude Code user's email address.
214
+
215
+ Checks ~/.claude.json for stored auth data (oauthAccount.emailAddress),
216
+ then falls back to running ``claude auth status`` and parsing the output.
217
+ """
218
+ global _cached_user_email
219
+ if _cached_user_email is not None:
220
+ return _cached_user_email or None
221
+
222
+ email_keys = ("emailAddress", "email", "userEmail", "user_email")
223
+
224
+ # 1) Try ~/.claude.json (Claude Code stores oauthAccount here)
225
+ try:
226
+ claude_json_path = Path.home() / ".claude.json"
227
+ if claude_json_path.exists():
228
+ data = json.loads(claude_json_path.read_text(encoding="utf-8"))
229
+ if isinstance(data, dict):
230
+ # Check top-level keys
231
+ for key in email_keys:
232
+ val = data.get(key)
233
+ if isinstance(val, str) and "@" in val:
234
+ _cached_user_email = val
235
+ return val
236
+ # Check nested objects (oauthAccount, auth, user, etc.)
237
+ for outer in ("oauthAccount", "auth", "oauth", "user", "account"):
238
+ nested = data.get(outer)
239
+ if isinstance(nested, dict):
240
+ for key in email_keys:
241
+ val = nested.get(key)
242
+ if isinstance(val, str) and "@" in val:
243
+ _cached_user_email = val
244
+ return val
245
+ except Exception:
246
+ pass
247
+
248
+ # 2) Fallback: ``claude auth status``
249
+ try:
250
+ out = subprocess.check_output(
251
+ ["claude", "auth", "status"],
252
+ stderr=subprocess.DEVNULL,
253
+ text=True,
254
+ timeout=5,
255
+ )
256
+ # Try JSON output first
257
+ try:
258
+ status = json.loads(out.strip())
259
+ if isinstance(status, dict):
260
+ for key in email_keys:
261
+ val = status.get(key)
262
+ if isinstance(val, str) and "@" in val:
263
+ _cached_user_email = val
264
+ return val
265
+ except (json.JSONDecodeError, ValueError):
266
+ pass
267
+ # Try plain text: "Logged in as user@example.com"
268
+ import re as _re
269
+ match = _re.search(r"[\w.+-]+@[\w-]+\.[\w.-]+", out)
270
+ if match:
271
+ _cached_user_email = match.group(0)
272
+ return _cached_user_email
273
+ except Exception:
274
+ pass
275
+
276
+ _cached_user_email = ""
277
+ return None
278
+
279
+
280
+ # --------------- File I/O ---------------
281
+ def atomic_write_json(path: Path, data: dict) -> None:
282
+ path.parent.mkdir(parents=True, exist_ok=True)
283
+ fd, tmp_path = tempfile.mkstemp(prefix=f"{path.name}.", dir=str(path.parent))
284
+ try:
285
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
286
+ json.dump(data, f, indent=2)
287
+ f.write("\n")
288
+ os.replace(tmp_path, path)
289
+ finally:
290
+ if os.path.exists(tmp_path):
291
+ os.unlink(tmp_path)
292
+
293
+
294
+ def save_last_trace(session_id: str, trace_id: str, host: str) -> None:
295
+ try:
296
+ project_id = os.environ.get("LANGFUSE_PROJECT_ID", "")
297
+
298
+ data: Dict[str, Any] = {
299
+ "session_id": session_id,
300
+ "trace_id": trace_id,
301
+ "trace_url": f"{host}/trace/{trace_id}",
302
+ "host": host,
303
+ "updated_at": datetime.now(timezone.utc).isoformat(),
304
+ }
305
+
306
+ if project_id:
307
+ data["project_id"] = project_id
308
+ data["session_url"] = f"{host}/project/{project_id}/sessions/{session_id}"
309
+
310
+ STATE_DIR.mkdir(parents=True, exist_ok=True)
311
+ tmp = LAST_TRACE_FILE.with_suffix(".tmp")
312
+ tmp.write_text(json.dumps(data, indent=2), encoding="utf-8")
313
+ os.replace(tmp, LAST_TRACE_FILE)
314
+ except Exception as e:
315
+ debug(f"save_last_trace failed: {e}")
316
+
317
+
318
+ def read_last_trace(expected_session_id: Optional[str] = None) -> Optional[Dict[str, Any]]:
319
+ """Read the last trace file, optionally validating the session_id matches."""
320
+ if not LAST_TRACE_FILE.exists():
321
+ return None
322
+ try:
323
+ data = json.loads(LAST_TRACE_FILE.read_text(encoding="utf-8"))
324
+ if not isinstance(data, dict) or not data.get("trace_id"):
325
+ return None
326
+ if expected_session_id and data.get("session_id") != expected_session_id:
327
+ return None
328
+ return data
329
+ except Exception:
330
+ return None
331
+
332
+
333
+ def _parse_iso_utc(value: Any) -> Optional[datetime]:
334
+ if not isinstance(value, str) or not value:
335
+ return None
336
+ try:
337
+ ts = datetime.fromisoformat(value)
338
+ if ts.tzinfo is None:
339
+ ts = ts.replace(tzinfo=timezone.utc)
340
+ return ts.astimezone(timezone.utc)
341
+ except Exception:
342
+ return None
343
+
344
+
345
+ def read_recent_trace(
346
+ path: Path,
347
+ max_age_hours: float,
348
+ expected_session_id: Optional[str] = None,
349
+ ) -> Optional[Dict[str, Any]]:
350
+ """Read a trace file if present and recent enough."""
351
+ if not path.exists():
352
+ return None
353
+ try:
354
+ data = json.loads(path.read_text(encoding="utf-8"))
355
+ except Exception:
356
+ return None
357
+
358
+ if not isinstance(data, dict):
359
+ return None
360
+
361
+ trace_id = data.get("trace_id")
362
+ trace_url = data.get("trace_url")
363
+ if not isinstance(trace_id, str) or not trace_id:
364
+ return None
365
+ if not isinstance(trace_url, str) or not trace_url:
366
+ return None
367
+
368
+ if expected_session_id and data.get("session_id") != expected_session_id:
369
+ return None
370
+
371
+ updated_at = _parse_iso_utc(data.get("updated_at"))
372
+ if updated_at is None:
373
+ return None
374
+
375
+ age_hours = (datetime.now(timezone.utc) - updated_at).total_seconds() / 3600
376
+ if age_hours > max_age_hours:
377
+ return None
378
+
379
+ return data
380
+
381
+
382
+ # --------------- Trace manifest ---------------
383
+ def write_trace_manifest(
384
+ repo_root: Path,
385
+ session_id: str,
386
+ trace_id: str,
387
+ host: str,
388
+ git_metadata: Optional[Dict[str, Any]] = None,
389
+ ) -> None:
390
+ try:
391
+ safe_sid = re.sub(r"[^A-Za-z0-9._-]", "_", session_id)
392
+ manifest_dir = repo_root / ".langfuse" / "traces"
393
+ manifest_path = manifest_dir / f"{safe_sid}.json"
394
+
395
+ existing: Dict[str, Any] = {}
396
+ if manifest_path.exists():
397
+ try:
398
+ existing = json.loads(manifest_path.read_text(encoding="utf-8"))
399
+ except Exception:
400
+ pass
401
+
402
+ trace_url = f"{host}/trace/{trace_id}"
403
+ project_id = os.environ.get("LANGFUSE_PROJECT_ID", "")
404
+ session_url = f"{host}/project/{project_id}/sessions/{session_id}" if project_id else ""
405
+
406
+ commit_sha = (git_metadata or {}).get("git_commit_sha", "")
407
+ remote_url = (git_metadata or {}).get("git_remote_url")
408
+ commit_url = (git_metadata or {}).get("git_commit_url")
409
+
410
+ git_block = existing.get("git", {}) if isinstance(existing.get("git"), dict) else {}
411
+ if commit_sha:
412
+ git_block["commit_sha"] = commit_sha
413
+ if commit_url:
414
+ git_block["commit_url"] = commit_url
415
+ if remote_url:
416
+ git_block["remote_url"] = remote_url
417
+ branch = run_git(repo_root, ["rev-parse", "--abbrev-ref", "HEAD"])
418
+ if branch:
419
+ git_block["branch"] = branch
420
+ msg = run_git(repo_root, ["log", "-1", "--pretty=%s"])
421
+ if msg:
422
+ git_block["commit_message"] = msg
423
+
424
+ langfuse_block: Dict[str, Any] = {
425
+ "trace_id": trace_id,
426
+ "trace_url": trace_url,
427
+ "session_id": session_id,
428
+ "host": host.rstrip("/"),
429
+ }
430
+ if session_url:
431
+ langfuse_block["session_url"] = session_url
432
+
433
+ manifest = {
434
+ "schema_version": 1,
435
+ "langfuse": langfuse_block,
436
+ "git": git_block,
437
+ "created_at": existing.get("created_at", datetime.now(timezone.utc).isoformat()),
438
+ "updated_at": datetime.now(timezone.utc).isoformat(),
439
+ }
440
+
441
+ atomic_write_json(manifest_path, manifest)
442
+
443
+ current_session_data: Dict[str, Any] = {
444
+ "session_id": session_id,
445
+ "trace_id": trace_id,
446
+ "trace_url": trace_url,
447
+ "host": host.rstrip("/"),
448
+ "updated_at": datetime.now(timezone.utc).isoformat(),
449
+ }
450
+ if session_url:
451
+ current_session_data["session_url"] = session_url
452
+
453
+ current_session_path = repo_root / ".langfuse" / "current-session.json"
454
+ atomic_write_json(current_session_path, current_session_data)
455
+ debug(f"Wrote trace manifest to {manifest_path}")
456
+ except Exception as exc:
457
+ debug(f"write_trace_manifest failed: {exc}")