oh-langfuse 0.1.7

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.
@@ -0,0 +1,603 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Claude Code -> Langfuse hook
4
+
5
+ """
6
+
7
+ import json
8
+ import os
9
+ import sys
10
+ import time
11
+ import hashlib
12
+ from dataclasses import dataclass
13
+ from datetime import datetime, timezone
14
+ from pathlib import Path
15
+ from typing import Any, Dict, List, Optional, Tuple
16
+
17
+ # --- Langfuse import (fail-open) ---
18
+ try:
19
+ from langfuse import Langfuse, propagate_attributes
20
+ except Exception:
21
+ sys.exit(0)
22
+
23
+ # --- Paths ---
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
+
29
+ DEBUG = os.environ.get("CC_LANGFUSE_DEBUG", "").lower() == "true"
30
+ MAX_CHARS = int(os.environ.get("CC_LANGFUSE_MAX_CHARS", "20000"))
31
+
32
+ # ----------------- Logging -----------------
33
+ def _log(level: str, message: str) -> None:
34
+ try:
35
+ STATE_DIR.mkdir(parents=True, exist_ok=True)
36
+ ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
37
+ with open(LOG_FILE, "a", encoding="utf-8") as f:
38
+ f.write(f"{ts} [{level}] {message}\n")
39
+ except Exception:
40
+ # Never block
41
+ pass
42
+
43
+ def debug(msg: str) -> None:
44
+ if DEBUG:
45
+ _log("DEBUG", msg)
46
+
47
+ def info(msg: str) -> None:
48
+ _log("INFO", msg)
49
+
50
+ def warn(msg: str) -> None:
51
+ _log("WARN", msg)
52
+
53
+ def error(msg: str) -> None:
54
+ _log("ERROR", msg)
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 # Unix only
68
+ deadline = time.time() + self.timeout_s
69
+ while True:
70
+ try:
71
+ fcntl.flock(self._fh.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
72
+ break
73
+ except BlockingIOError:
74
+ if time.time() > deadline:
75
+ break
76
+ time.sleep(0.05)
77
+ except Exception:
78
+ # If locking isn't available, proceed without it.
79
+ pass
80
+ return self
81
+
82
+ def __exit__(self, exc_type, exc, tb):
83
+ try:
84
+ import fcntl
85
+ fcntl.flock(self._fh.fileno(), fcntl.LOCK_UN)
86
+ except Exception:
87
+ pass
88
+ try:
89
+ self._fh.close()
90
+ except Exception:
91
+ pass
92
+
93
+ def load_state() -> Dict[str, Any]:
94
+ try:
95
+ if not STATE_FILE.exists():
96
+ return {}
97
+ return json.loads(STATE_FILE.read_text(encoding="utf-8"))
98
+ except Exception:
99
+ return {}
100
+
101
+ def save_state(state: Dict[str, Any]) -> None:
102
+ try:
103
+ STATE_DIR.mkdir(parents=True, exist_ok=True)
104
+ tmp = STATE_FILE.with_suffix(".tmp")
105
+ tmp.write_text(json.dumps(state, indent=2, sort_keys=True), encoding="utf-8")
106
+ os.replace(tmp, STATE_FILE)
107
+ except Exception as e:
108
+ debug(f"save_state failed: {e}")
109
+
110
+ def state_key(session_id: str, transcript_path: str) -> str:
111
+ # stable key even if session_id collides
112
+ raw = f"{session_id}::{transcript_path}"
113
+ return hashlib.sha256(raw.encode("utf-8")).hexdigest()
114
+
115
+ # ----------------- Hook payload -----------------
116
+ def read_hook_payload() -> Dict[str, Any]:
117
+ """
118
+ Claude Code hooks pass a JSON payload on stdin.
119
+ This script tolerates missing/empty stdin by returning {}.
120
+ """
121
+ try:
122
+ data = sys.stdin.read()
123
+ if not data.strip():
124
+ return {}
125
+ return json.loads(data)
126
+ except Exception:
127
+ return {}
128
+
129
+ def extract_session_transcript_and_user(payload: Dict[str, Any]) -> Tuple[Optional[str], Optional[Path], Optional[str]]:
130
+ """
131
+ Tries a few plausible field names; exact keys can vary across hook types/versions.
132
+ Prefer structured values from stdin over heuristics.
133
+ """
134
+ session_id = (
135
+ payload.get("sessionId")
136
+ or payload.get("session_id")
137
+ or payload.get("session", {}).get("id")
138
+ )
139
+
140
+ transcript = (
141
+ payload.get("transcriptPath")
142
+ or payload.get("transcript_path")
143
+ or payload.get("transcript", {}).get("path")
144
+ )
145
+
146
+ if transcript:
147
+ try:
148
+ transcript_path = Path(transcript).expanduser().resolve()
149
+ except Exception:
150
+ transcript_path = None
151
+ else:
152
+ transcript_path = None
153
+
154
+ user_id = (
155
+ payload.get("userId")
156
+ or payload.get("user_id")
157
+ or payload.get("user", {}).get("id")
158
+ or payload.get("username")
159
+ or payload.get("userName")
160
+ )
161
+
162
+ if not user_id:
163
+ # Best-effort fallback on local username for CLI sessions.
164
+ user_id = (
165
+ os.environ.get("CC_LANGFUSE_USER_ID")
166
+ or os.environ.get("CLAUDE_USER_ID")
167
+ or os.environ.get("CC_USER_ID")
168
+ or os.environ.get("LANGFUSE_USER_ID")
169
+ or os.environ.get("USERNAME")
170
+ )
171
+
172
+ return session_id, transcript_path, user_id
173
+
174
+ # ----------------- Transcript parsing helpers -----------------
175
+ def get_content(msg: Dict[str, Any]) -> Any:
176
+ if not isinstance(msg, dict):
177
+ return None
178
+ if "message" in msg and isinstance(msg.get("message"), dict):
179
+ return msg["message"].get("content")
180
+ return msg.get("content")
181
+
182
+ def get_role(msg: Dict[str, Any]) -> Optional[str]:
183
+ # Claude Code transcript lines commonly have type=user/assistant OR message.role
184
+ t = msg.get("type")
185
+ if t in ("user", "assistant"):
186
+ return t
187
+ m = msg.get("message")
188
+ if isinstance(m, dict):
189
+ r = m.get("role")
190
+ if r in ("user", "assistant"):
191
+ return r
192
+ return None
193
+
194
+ def is_tool_result(msg: Dict[str, Any]) -> bool:
195
+ role = get_role(msg)
196
+ if role != "user":
197
+ return False
198
+ content = get_content(msg)
199
+ if isinstance(content, list):
200
+ return any(isinstance(x, dict) and x.get("type") == "tool_result" for x in content)
201
+ return False
202
+
203
+ def iter_tool_results(content: Any) -> List[Dict[str, Any]]:
204
+ out: List[Dict[str, Any]] = []
205
+ if isinstance(content, list):
206
+ for x in content:
207
+ if isinstance(x, dict) and x.get("type") == "tool_result":
208
+ out.append(x)
209
+ return out
210
+
211
+ def iter_tool_uses(content: Any) -> List[Dict[str, Any]]:
212
+ out: List[Dict[str, Any]] = []
213
+ if isinstance(content, list):
214
+ for x in content:
215
+ if isinstance(x, dict) and x.get("type") == "tool_use":
216
+ out.append(x)
217
+ return out
218
+
219
+ def extract_text(content: Any) -> str:
220
+ if isinstance(content, str):
221
+ return content
222
+ if isinstance(content, list):
223
+ parts: List[str] = []
224
+ for x in content:
225
+ if isinstance(x, dict) and x.get("type") == "text":
226
+ parts.append(x.get("text", ""))
227
+ elif isinstance(x, str):
228
+ parts.append(x)
229
+ return "\n".join([p for p in parts if p])
230
+ return ""
231
+
232
+ def truncate_text(s: str, max_chars: int = MAX_CHARS) -> Tuple[str, Dict[str, Any]]:
233
+ if s is None:
234
+ return "", {"truncated": False, "orig_len": 0}
235
+ orig_len = len(s)
236
+ if orig_len <= max_chars:
237
+ return s, {"truncated": False, "orig_len": orig_len}
238
+ head = s[:max_chars]
239
+ return head, {"truncated": True, "orig_len": orig_len, "kept_len": len(head), "sha256": hashlib.sha256(s.encode("utf-8")).hexdigest()}
240
+
241
+ def get_model(msg: Dict[str, Any]) -> str:
242
+ m = msg.get("message")
243
+ if isinstance(m, dict):
244
+ return m.get("model") or "claude"
245
+ return "claude"
246
+
247
+ def get_usage(msg: Dict[str, Any]) -> Dict[str, int]:
248
+ """
249
+ Extract token usage from Claude transcript message shape:
250
+ msg.message.usage.{input_tokens, output_tokens, cache_read_input_tokens, cache_creation_input_tokens}
251
+ """
252
+ m = msg.get("message")
253
+ usage = m.get("usage") if isinstance(m, dict) else None
254
+ if not isinstance(usage, dict):
255
+ return {}
256
+
257
+ out: Dict[str, int] = {}
258
+ field_map = {
259
+ "input_tokens": "input",
260
+ "output_tokens": "output",
261
+ "cache_read_input_tokens": "cache_read_input_tokens",
262
+ "cache_creation_input_tokens": "cache_creation_input_tokens",
263
+ }
264
+
265
+ for src_key, dst_key in field_map.items():
266
+ val = usage.get(src_key)
267
+ if isinstance(val, int) and val >= 0:
268
+ out[dst_key] = val
269
+ return out
270
+
271
+ def get_message_id(msg: Dict[str, Any]) -> Optional[str]:
272
+ m = msg.get("message")
273
+ if isinstance(m, dict):
274
+ mid = m.get("id")
275
+ if isinstance(mid, str) and mid:
276
+ return mid
277
+ return None
278
+
279
+ # ----------------- Incremental reader -----------------
280
+ @dataclass
281
+ class SessionState:
282
+ offset: int = 0
283
+ buffer: str = ""
284
+ turn_count: int = 0
285
+
286
+ def load_session_state(global_state: Dict[str, Any], key: str) -> SessionState:
287
+ s = global_state.get(key, {})
288
+ return SessionState(
289
+ offset=int(s.get("offset", 0)),
290
+ buffer=str(s.get("buffer", "")),
291
+ turn_count=int(s.get("turn_count", 0)),
292
+ )
293
+
294
+ def write_session_state(global_state: Dict[str, Any], key: str, ss: SessionState) -> None:
295
+ global_state[key] = {
296
+ "offset": ss.offset,
297
+ "buffer": ss.buffer,
298
+ "turn_count": ss.turn_count,
299
+ "updated": datetime.now(timezone.utc).isoformat(),
300
+ }
301
+
302
+ def read_new_jsonl(transcript_path: Path, ss: SessionState) -> Tuple[List[Dict[str, Any]], SessionState]:
303
+ """
304
+ Reads only new bytes since ss.offset. Keeps ss.buffer for partial last line.
305
+ Returns parsed JSON lines (best-effort) and updated state.
306
+ """
307
+ if not transcript_path.exists():
308
+ return [], ss
309
+
310
+ try:
311
+ with open(transcript_path, "rb") as f:
312
+ f.seek(ss.offset)
313
+ chunk = f.read()
314
+ new_offset = f.tell()
315
+ except Exception as e:
316
+ debug(f"read_new_jsonl failed: {e}")
317
+ return [], ss
318
+
319
+ if not chunk:
320
+ return [], ss
321
+
322
+ try:
323
+ text = chunk.decode("utf-8", errors="replace")
324
+ except Exception:
325
+ text = chunk.decode(errors="replace")
326
+
327
+ combined = ss.buffer + text
328
+ lines = combined.split("\n")
329
+ # last element may be incomplete
330
+ ss.buffer = lines[-1]
331
+ ss.offset = new_offset
332
+
333
+ msgs: List[Dict[str, Any]] = []
334
+ for line in lines[:-1]:
335
+ line = line.strip()
336
+ if not line:
337
+ continue
338
+ try:
339
+ msgs.append(json.loads(line))
340
+ except Exception:
341
+ continue
342
+
343
+ return msgs, ss
344
+
345
+ # ----------------- Turn assembly -----------------
346
+ @dataclass
347
+ class Turn:
348
+ user_msg: Dict[str, Any]
349
+ assistant_msgs: List[Dict[str, Any]]
350
+ tool_results_by_id: Dict[str, Any]
351
+
352
+ def build_turns(messages: List[Dict[str, Any]]) -> List[Turn]:
353
+ """
354
+ Groups incremental transcript rows into turns:
355
+ user (non-tool-result) -> assistant messages -> (tool_result rows, possibly interleaved)
356
+ Uses:
357
+ - assistant message dedupe by message.id (latest row wins)
358
+ - tool results dedupe by tool_use_id (latest wins)
359
+ """
360
+ turns: List[Turn] = []
361
+ current_user: Optional[Dict[str, Any]] = None
362
+
363
+ # assistant messages for current turn:
364
+ assistant_order: List[str] = [] # message ids in order of first appearance (or synthetic)
365
+ assistant_latest: Dict[str, Dict[str, Any]] = {} # id -> latest msg
366
+
367
+ tool_results_by_id: Dict[str, Any] = {} # tool_use_id -> content
368
+
369
+ def flush_turn():
370
+ nonlocal current_user, assistant_order, assistant_latest, tool_results_by_id, turns
371
+ if current_user is None:
372
+ return
373
+ if not assistant_latest:
374
+ return
375
+ assistants = [assistant_latest[mid] for mid in assistant_order if mid in assistant_latest]
376
+ turns.append(Turn(user_msg=current_user, assistant_msgs=assistants, tool_results_by_id=dict(tool_results_by_id)))
377
+
378
+ for msg in messages:
379
+ role = get_role(msg)
380
+
381
+ # tool_result rows show up as role=user with content blocks of type tool_result
382
+ if is_tool_result(msg):
383
+ for tr in iter_tool_results(get_content(msg)):
384
+ tid = tr.get("tool_use_id")
385
+ if tid:
386
+ tool_results_by_id[str(tid)] = tr.get("content")
387
+ continue
388
+
389
+ if role == "user":
390
+ # new user message -> finalize previous turn
391
+ flush_turn()
392
+
393
+ # start a new turn
394
+ current_user = msg
395
+ assistant_order = []
396
+ assistant_latest = {}
397
+ tool_results_by_id = {}
398
+ continue
399
+
400
+ if role == "assistant":
401
+ if current_user is None:
402
+ # ignore assistant rows until we see a user message
403
+ continue
404
+
405
+ mid = get_message_id(msg) or f"noid:{len(assistant_order)}"
406
+ if mid not in assistant_latest:
407
+ assistant_order.append(mid)
408
+ assistant_latest[mid] = msg
409
+ continue
410
+
411
+ # ignore unknown rows
412
+
413
+ # flush last
414
+ flush_turn()
415
+ return turns
416
+
417
+ # ----------------- Langfuse emit -----------------
418
+ def _tool_calls_from_assistants(assistant_msgs: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
419
+ calls: List[Dict[str, Any]] = []
420
+ for am in assistant_msgs:
421
+ for tu in iter_tool_uses(get_content(am)):
422
+ tid = tu.get("id") or ""
423
+ calls.append({
424
+ "id": str(tid),
425
+ "name": tu.get("name") or "unknown",
426
+ "input": tu.get("input") if isinstance(tu.get("input"), (dict, list, str, int, float, bool)) else {},
427
+ })
428
+ return calls
429
+
430
+ def emit_turn(
431
+ langfuse: Langfuse,
432
+ session_id: str,
433
+ user_id: Optional[str],
434
+ turn_num: int,
435
+ turn: Turn,
436
+ transcript_path: Path,
437
+ ) -> None:
438
+ user_text_raw = extract_text(get_content(turn.user_msg))
439
+ user_text, user_text_meta = truncate_text(user_text_raw)
440
+
441
+ last_assistant = turn.assistant_msgs[-1]
442
+ assistant_text_raw = extract_text(get_content(last_assistant))
443
+ assistant_text, assistant_text_meta = truncate_text(assistant_text_raw)
444
+
445
+ model = get_model(turn.assistant_msgs[0])
446
+ usage_details = get_usage(last_assistant)
447
+
448
+ tool_calls = _tool_calls_from_assistants(turn.assistant_msgs)
449
+
450
+ # attach tool outputs
451
+ for c in tool_calls:
452
+ if c["id"] and c["id"] in turn.tool_results_by_id:
453
+ out_raw = turn.tool_results_by_id[c["id"]]
454
+ out_str = out_raw if isinstance(out_raw, str) else json.dumps(out_raw, ensure_ascii=False)
455
+ out_trunc, out_meta = truncate_text(out_str)
456
+ c["output"] = out_trunc
457
+ c["output_meta"] = out_meta
458
+ else:
459
+ c["output"] = None
460
+
461
+ with propagate_attributes(
462
+ user_id=user_id,
463
+ session_id=session_id,
464
+ trace_name=f"Claude Code - Turn {turn_num}",
465
+ tags=["claude-code"],
466
+ ):
467
+ with langfuse.start_as_current_observation(
468
+ name=f"Claude Code - Turn {turn_num}",
469
+ input={"role": "user", "content": user_text},
470
+ metadata={
471
+ "source": "claude-code",
472
+ "session_id": session_id,
473
+ "turn_number": turn_num,
474
+ "transcript_path": str(transcript_path),
475
+ "user_text": user_text_meta,
476
+ },
477
+ ) as trace_span:
478
+ # LLM generation
479
+ with langfuse.start_as_current_observation(
480
+ name="Claude Response",
481
+ as_type="generation",
482
+ model=model,
483
+ input={"role": "user", "content": user_text},
484
+ output={"role": "assistant", "content": assistant_text},
485
+ usage_details=usage_details or None,
486
+ metadata={
487
+ "assistant_text": assistant_text_meta,
488
+ "tool_count": len(tool_calls),
489
+ "usage_details": usage_details,
490
+ },
491
+ ):
492
+ pass
493
+
494
+ # Tool observations
495
+ for tc in tool_calls:
496
+ in_obj = tc["input"]
497
+ # truncate tool input if it's a large string payload
498
+ if isinstance(in_obj, str):
499
+ in_obj, in_meta = truncate_text(in_obj)
500
+ else:
501
+ in_meta = None
502
+
503
+ with langfuse.start_as_current_observation(
504
+ name=f"Tool: {tc['name']}",
505
+ as_type="tool",
506
+ input=in_obj,
507
+ metadata={
508
+ "tool_name": tc["name"],
509
+ "tool_id": tc["id"],
510
+ "input_meta": in_meta,
511
+ "output_meta": tc.get("output_meta"),
512
+ },
513
+ ) as tool_obs:
514
+ tool_obs.update(output=tc.get("output"))
515
+
516
+ trace_span.update(output={"role": "assistant", "content": assistant_text})
517
+
518
+ # ----------------- Main -----------------
519
+ def main() -> int:
520
+ start = time.time()
521
+ debug("Hook started")
522
+
523
+ if os.environ.get("TRACE_TO_LANGFUSE", "").lower() != "true":
524
+ return 0
525
+
526
+ public_key = os.environ.get("CC_LANGFUSE_PUBLIC_KEY") or os.environ.get("LANGFUSE_PUBLIC_KEY")
527
+ secret_key = os.environ.get("CC_LANGFUSE_SECRET_KEY") or os.environ.get("LANGFUSE_SECRET_KEY")
528
+ host = os.environ.get("CC_LANGFUSE_BASE_URL") or os.environ.get("LANGFUSE_BASEURL") or "https://cloud.langfuse.com"
529
+
530
+ if not public_key or not secret_key:
531
+ return 0
532
+
533
+ payload = read_hook_payload()
534
+ session_id, transcript_path, user_id = extract_session_transcript_and_user(payload)
535
+
536
+ if not session_id or not transcript_path:
537
+ # No structured payload; fail open (do not guess)
538
+ debug("Missing session_id or transcript_path from hook payload; exiting.")
539
+ return 0
540
+
541
+ if not transcript_path.exists():
542
+ debug(f"Transcript path does not exist: {transcript_path}")
543
+ return 0
544
+
545
+ try:
546
+ langfuse = Langfuse(public_key=public_key, secret_key=secret_key, host=host)
547
+ except Exception:
548
+ return 0
549
+
550
+ try:
551
+ with FileLock(LOCK_FILE):
552
+ state = load_state()
553
+ key = state_key(session_id, str(transcript_path))
554
+ ss = load_session_state(state, key)
555
+
556
+ msgs, ss = read_new_jsonl(transcript_path, ss)
557
+ if not msgs:
558
+ write_session_state(state, key, ss)
559
+ save_state(state)
560
+ return 0
561
+
562
+ turns = build_turns(msgs)
563
+ if not turns:
564
+ write_session_state(state, key, ss)
565
+ save_state(state)
566
+ return 0
567
+
568
+ # emit turns
569
+ emitted = 0
570
+ for t in turns:
571
+ emitted += 1
572
+ turn_num = ss.turn_count + emitted
573
+ try:
574
+ emit_turn(langfuse, session_id, user_id, turn_num, t, transcript_path)
575
+ except Exception as e:
576
+ debug(f"emit_turn failed: {e}")
577
+ # continue emitting other turns
578
+
579
+ ss.turn_count += emitted
580
+ write_session_state(state, key, ss)
581
+ save_state(state)
582
+
583
+ try:
584
+ langfuse.flush()
585
+ except Exception:
586
+ pass
587
+
588
+ dur = time.time() - start
589
+ info(f"Processed {emitted} turns in {dur:.2f}s (session={session_id})")
590
+ return 0
591
+
592
+ except Exception as e:
593
+ debug(f"Unexpected failure: {e}")
594
+ return 0
595
+
596
+ finally:
597
+ try:
598
+ langfuse.shutdown()
599
+ except Exception:
600
+ pass
601
+
602
+ if __name__ == "__main__":
603
+ sys.exit(main())
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "oh-langfuse",
3
+ "version": "0.1.7",
4
+ "private": false,
5
+ "type": "module",
6
+ "description": "Use npm scripts to configure Claude Code / OpenCode / Codex with Langfuse tracing.",
7
+ "bin": {
8
+ "oh-langfuse": "bin/cli.js",
9
+ "code-tool-langfuse": "bin/cli.js"
10
+ },
11
+ "files": [
12
+ "bin",
13
+ "scripts",
14
+ "langfuse_hook.py",
15
+ "codex_langfuse_notify.py",
16
+ "README.md",
17
+ "CODEX_LANGFUSE_PLAN.md",
18
+ "setup-langfuse.bat",
19
+ "setup-langfuse.sh"
20
+ ],
21
+ "scripts": {
22
+ "start": "node bin/cli.js",
23
+ "check": "node --check bin/cli.js",
24
+ "pack:check": "npm pack --dry-run",
25
+ "claude:setup": "node scripts/langfuse-setup.mjs",
26
+ "claude:check": "node scripts/langfuse-check.mjs",
27
+ "langfuse:setup": "node scripts/langfuse-setup.mjs",
28
+ "langfuse:check": "node scripts/langfuse-check.mjs",
29
+ "opencode:run": "node scripts/opencode-langfuse-run.mjs",
30
+ "opencode:setup": "node scripts/opencode-langfuse-setup.mjs",
31
+ "opencode:check": "node scripts/opencode-langfuse-check.mjs",
32
+ "opencode:langfuse:setup": "node scripts/opencode-langfuse-setup.mjs",
33
+ "opencode:langfuse:check": "node scripts/opencode-langfuse-check.mjs",
34
+ "opencode:langfuse:run": "node scripts/opencode-langfuse-run.mjs",
35
+ "codex:setup": "node scripts/codex-langfuse-setup.mjs",
36
+ "codex:check": "node scripts/codex-langfuse-check.mjs",
37
+ "codex:langfuse:setup": "node scripts/codex-langfuse-setup.mjs",
38
+ "codex:langfuse:check": "node scripts/codex-langfuse-check.mjs"
39
+ },
40
+ "dependencies": {}
41
+ }