oh-aicoding-tool 0.1.0

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 (55) hide show
  1. package/CODEX_LANGFUSE_PLAN.md +62 -0
  2. package/README.md +114 -0
  3. package/bin/cli.js +166 -0
  4. package/bin/langfuse-cli.js +718 -0
  5. package/codex_langfuse_notify.py +591 -0
  6. package/langfuse_hook.py +603 -0
  7. package/opencode-ohai-report/.claude/commands/report-ai-issue.md +60 -0
  8. package/opencode-ohai-report/.opencode/commands/report-ai-issue.md +30 -0
  9. package/opencode-ohai-report/.opencode/plugins/oh-ai-report.ts +569 -0
  10. package/opencode-ohai-report/README.md +45 -0
  11. package/opencode-ohai-report/bin/cli.js +421 -0
  12. package/opencode-ohai-report/docs/opencode-ai-issue-collection-architecture.md +313 -0
  13. package/opencode-ohai-report/docs/opencode-ai-issue-collection-best-practices.md +476 -0
  14. package/opencode-ohai-report/docs/opencode-ai-issue-collection-phase1-summary.md +405 -0
  15. package/opencode-ohai-report/examples/issue_output.json +4 -0
  16. package/opencode-ohai-report/package.json +40 -0
  17. package/opencode-ohai-report/scripts/claude_report_hook.py +257 -0
  18. package/opencode-ohai-report/scripts/create_issue.py +34 -0
  19. package/opencode-ohai-report/scripts/install-claude-plugin.ps1 +254 -0
  20. package/opencode-ohai-report/scripts/install-opencode-plugin.ps1 +264 -0
  21. package/opencode-ohai-report/scripts/install-opencode-plugin.sh +218 -0
  22. package/opencode-ohai-report/scripts/merge-claude-settings.py +99 -0
  23. package/opencode-ohai-report/tools/ohai-report/README.md +151 -0
  24. package/opencode-ohai-report/tools/ohai-report/examples/issue-input.json +26 -0
  25. package/opencode-ohai-report/tools/ohai-report/ohai_report/__init__.py +5 -0
  26. package/opencode-ohai-report/tools/ohai-report/ohai_report/__main__.py +9 -0
  27. package/opencode-ohai-report/tools/ohai-report/ohai_report/cli.py +319 -0
  28. package/opencode-ohai-report/tools/ohai-report/ohai_report/git_context.py +32 -0
  29. package/opencode-ohai-report/tools/ohai-report/ohai_report/gitcode_defaults.py +14 -0
  30. package/opencode-ohai-report/tools/ohai-report/ohai_report/issue_markdown.py +313 -0
  31. package/opencode-ohai-report/tools/ohai-report/ohai_report/metadata.py +360 -0
  32. package/opencode-ohai-report/tools/ohai-report/ohai_report/observability/__init__.py +1 -0
  33. package/opencode-ohai-report/tools/ohai-report/ohai_report/observability/langfuse.py +38 -0
  34. package/opencode-ohai-report/tools/ohai-report/ohai_report/payload.py +64 -0
  35. package/opencode-ohai-report/tools/ohai-report/ohai_report/schema.py +80 -0
  36. package/opencode-ohai-report/tools/ohai-report/ohai_report/sinks/__init__.py +1 -0
  37. package/opencode-ohai-report/tools/ohai-report/ohai_report/sinks/base.py +15 -0
  38. package/opencode-ohai-report/tools/ohai-report/ohai_report/sinks/gitcode.py +405 -0
  39. package/opencode-ohai-report/tools/ohai-report/ohai_report/sinks/local.py +21 -0
  40. package/opencode-ohai-report/tools/ohai-report/ohai_report/sinks/webhook.py +354 -0
  41. package/opencode-ohai-report/tools/ohai-report/ohai_report/webhook_defaults.py +9 -0
  42. package/opencode-ohai-report/tools/ohai-report/ohai_report/workspace.py +61 -0
  43. package/opencode-ohai-report/tools/ohai-report/ohai_report.py +10 -0
  44. package/opencode-ohai-report/tools/ohai-report/schemas/report_issue.schema.json +166 -0
  45. package/package.json +59 -0
  46. package/scripts/codex-langfuse-check.mjs +101 -0
  47. package/scripts/codex-langfuse-setup.mjs +181 -0
  48. package/scripts/langfuse-check.mjs +90 -0
  49. package/scripts/langfuse-setup.mjs +278 -0
  50. package/scripts/opencode-langfuse-check.mjs +94 -0
  51. package/scripts/opencode-langfuse-run.mjs +96 -0
  52. package/scripts/opencode-langfuse-setup.mjs +478 -0
  53. package/scripts/resolve-opencode-cli.mjs +58 -0
  54. package/setup-langfuse.bat +163 -0
  55. package/setup-langfuse.sh +130 -0
@@ -0,0 +1,591 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Codex -> Langfuse notify hook.
4
+
5
+ Codex calls the configured notify command near the end of a turn. This script
6
+ uses that signal to incrementally read the matching Codex session JSONL file and
7
+ emit the new assistant/user/tool events to Langfuse.
8
+ """
9
+
10
+ import json
11
+ import os
12
+ import sys
13
+ import time
14
+ import hashlib
15
+ from dataclasses import dataclass
16
+ from datetime import datetime, timezone
17
+ from pathlib import Path
18
+ from typing import Any, Dict, List, Optional, Tuple
19
+
20
+ try:
21
+ from langfuse import Langfuse, propagate_attributes
22
+ except Exception:
23
+ sys.exit(0)
24
+
25
+
26
+ CODEX_DIR = Path(os.environ.get("CODEX_HOME") or (Path.home() / ".codex"))
27
+ STATE_DIR = CODEX_DIR / "langfuse"
28
+ CONFIG_FILE = STATE_DIR / "config.json"
29
+ STATE_FILE = STATE_DIR / "state.json"
30
+ LOCK_FILE = STATE_DIR / "state.lock"
31
+ LOG_FILE = STATE_DIR / "codex_langfuse_notify.log"
32
+
33
+ DEBUG = os.environ.get("CODEX_LANGFUSE_DEBUG", "").lower() == "true"
34
+ MAX_CHARS = int(os.environ.get("CODEX_LANGFUSE_MAX_CHARS", "20000"))
35
+
36
+
37
+ def log(level: str, message: str) -> None:
38
+ try:
39
+ STATE_DIR.mkdir(parents=True, exist_ok=True)
40
+ ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
41
+ with open(LOG_FILE, "a", encoding="utf-8") as f:
42
+ f.write(f"{ts} [{level}] {message}\n")
43
+ except Exception:
44
+ pass
45
+
46
+
47
+ def debug(message: str) -> None:
48
+ if DEBUG:
49
+ log("DEBUG", message)
50
+
51
+
52
+ class FileLock:
53
+ def __init__(self, path: Path, timeout_s: float = 2.0):
54
+ self.path = path
55
+ self.timeout_s = timeout_s
56
+ self._fh = None
57
+
58
+ def __enter__(self):
59
+ STATE_DIR.mkdir(parents=True, exist_ok=True)
60
+ self._fh = open(self.path, "a+", encoding="utf-8")
61
+ try:
62
+ import fcntl
63
+
64
+ deadline = time.time() + self.timeout_s
65
+ while True:
66
+ try:
67
+ fcntl.flock(self._fh.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
68
+ break
69
+ except BlockingIOError:
70
+ if time.time() > deadline:
71
+ break
72
+ time.sleep(0.05)
73
+ except Exception:
74
+ pass
75
+ return self
76
+
77
+ def __exit__(self, exc_type, exc, tb):
78
+ try:
79
+ import fcntl
80
+
81
+ fcntl.flock(self._fh.fileno(), fcntl.LOCK_UN)
82
+ except Exception:
83
+ pass
84
+ try:
85
+ self._fh.close()
86
+ except Exception:
87
+ pass
88
+
89
+
90
+ @dataclass
91
+ class SessionState:
92
+ offset: int = 0
93
+ buffer: str = ""
94
+ turn_count: int = 0
95
+
96
+
97
+ def read_json_if_exists(path: Path) -> Dict[str, Any]:
98
+ try:
99
+ if not path.exists():
100
+ return {}
101
+ text = path.read_text(encoding="utf-8-sig")
102
+ if not text.strip():
103
+ return {}
104
+ value = json.loads(text)
105
+ return value if isinstance(value, dict) else {}
106
+ except Exception as e:
107
+ debug(f"read_json_if_exists failed for {path}: {e}")
108
+ return {}
109
+
110
+
111
+ def write_json_atomic(path: Path, obj: Dict[str, Any]) -> None:
112
+ try:
113
+ path.parent.mkdir(parents=True, exist_ok=True)
114
+ tmp = path.with_suffix(".tmp")
115
+ tmp.write_text(json.dumps(obj, indent=2, sort_keys=True), encoding="utf-8")
116
+ os.replace(tmp, path)
117
+ except Exception as e:
118
+ debug(f"write_json_atomic failed for {path}: {e}")
119
+
120
+
121
+ def read_stdin_payload() -> Dict[str, Any]:
122
+ try:
123
+ text = sys.stdin.read()
124
+ if not text.strip():
125
+ return {}
126
+ data = json.loads(text)
127
+ return data if isinstance(data, dict) else {}
128
+ except Exception as e:
129
+ debug(f"stdin payload parse failed: {e}")
130
+ return {}
131
+
132
+
133
+ def first_string(*values: Any) -> Optional[str]:
134
+ for value in values:
135
+ if isinstance(value, str) and value.strip():
136
+ return value.strip()
137
+ return None
138
+
139
+
140
+ def find_value(obj: Any, keys: Tuple[str, ...]) -> Optional[str]:
141
+ if isinstance(obj, dict):
142
+ for key, value in obj.items():
143
+ if key in keys and isinstance(value, str) and value.strip():
144
+ return value.strip()
145
+ found = find_value(value, keys)
146
+ if found:
147
+ return found
148
+ elif isinstance(obj, list):
149
+ for item in obj:
150
+ found = find_value(item, keys)
151
+ if found:
152
+ return found
153
+ return None
154
+
155
+
156
+ def normalize_session_path(raw: Optional[str]) -> Optional[Path]:
157
+ if not raw:
158
+ return None
159
+ try:
160
+ path = Path(raw).expanduser()
161
+ if path.exists() and path.is_file():
162
+ return path.resolve()
163
+ except Exception:
164
+ return None
165
+ return None
166
+
167
+
168
+ def latest_session_file() -> Optional[Path]:
169
+ sessions_dir = CODEX_DIR / "sessions"
170
+ if not sessions_dir.exists():
171
+ return None
172
+ newest: Optional[Path] = None
173
+ newest_mtime = -1.0
174
+ try:
175
+ for path in sessions_dir.rglob("*.jsonl"):
176
+ try:
177
+ mtime = path.stat().st_mtime
178
+ except Exception:
179
+ continue
180
+ if mtime > newest_mtime:
181
+ newest = path
182
+ newest_mtime = mtime
183
+ except Exception as e:
184
+ debug(f"latest_session_file failed: {e}")
185
+ return newest
186
+
187
+
188
+ def resolve_session_file(payload: Dict[str, Any]) -> Optional[Path]:
189
+ raw_path = first_string(
190
+ find_value(payload, ("session_path", "sessionPath", "transcript_path", "transcriptPath", "rollout_path", "rolloutPath")),
191
+ os.environ.get("CODEX_SESSION_PATH"),
192
+ )
193
+ session_path = normalize_session_path(raw_path)
194
+ if session_path:
195
+ return session_path
196
+
197
+ session_id = first_string(
198
+ find_value(payload, ("session_id", "sessionId", "conversation_id", "conversationId", "id")),
199
+ os.environ.get("CODEX_SESSION_ID"),
200
+ )
201
+ if session_id:
202
+ sessions_dir = CODEX_DIR / "sessions"
203
+ try:
204
+ matches = list(sessions_dir.rglob(f"*{session_id}*.jsonl"))
205
+ if matches:
206
+ return max(matches, key=lambda p: p.stat().st_mtime).resolve()
207
+ except Exception as e:
208
+ debug(f"session_id lookup failed: {e}")
209
+
210
+ return latest_session_file()
211
+
212
+
213
+ def session_key(path: Path) -> str:
214
+ return hashlib.sha256(str(path).encode("utf-8")).hexdigest()
215
+
216
+
217
+ def load_session_state(state: Dict[str, Any], key: str) -> SessionState:
218
+ raw = state.get(key, {})
219
+ if not isinstance(raw, dict):
220
+ raw = {}
221
+ return SessionState(
222
+ offset=int(raw.get("offset", 0) or 0),
223
+ buffer=str(raw.get("buffer", "") or ""),
224
+ turn_count=int(raw.get("turn_count", 0) or 0),
225
+ )
226
+
227
+
228
+ def save_session_state(state: Dict[str, Any], key: str, ss: SessionState) -> None:
229
+ state[key] = {
230
+ "offset": ss.offset,
231
+ "buffer": ss.buffer,
232
+ "turn_count": ss.turn_count,
233
+ "updated": datetime.now(timezone.utc).isoformat(),
234
+ }
235
+
236
+
237
+ def read_new_jsonl(path: Path, ss: SessionState) -> Tuple[List[Dict[str, Any]], SessionState]:
238
+ out: List[Dict[str, Any]] = []
239
+ try:
240
+ size = path.stat().st_size
241
+ if ss.offset > size:
242
+ ss = SessionState()
243
+ with open(path, "rb") as f:
244
+ f.seek(ss.offset)
245
+ chunk = f.read().decode("utf-8", errors="replace")
246
+ ss.offset = f.tell()
247
+ except Exception as e:
248
+ debug(f"read_new_jsonl failed: {e}")
249
+ return out, ss
250
+
251
+ text = ss.buffer + chunk
252
+ if not text:
253
+ return out, ss
254
+
255
+ lines = text.splitlines(keepends=True)
256
+ ss.buffer = ""
257
+ if lines and not lines[-1].endswith(("\n", "\r")):
258
+ ss.buffer = lines.pop()
259
+
260
+ for line in lines:
261
+ raw = line.strip()
262
+ if not raw:
263
+ continue
264
+ try:
265
+ item = json.loads(raw)
266
+ if isinstance(item, dict):
267
+ out.append(item)
268
+ except Exception as e:
269
+ debug(f"jsonl row parse failed: {e}")
270
+
271
+ return out, ss
272
+
273
+
274
+ def extract_text(content: Any) -> str:
275
+ if isinstance(content, str):
276
+ return content
277
+ if isinstance(content, list):
278
+ parts: List[str] = []
279
+ for item in content:
280
+ if isinstance(item, str):
281
+ parts.append(item)
282
+ elif isinstance(item, dict):
283
+ text = item.get("text") or item.get("output_text") or item.get("input_text")
284
+ if isinstance(text, str):
285
+ parts.append(text)
286
+ return "\n".join(parts)
287
+ if isinstance(content, dict):
288
+ text = content.get("text") or content.get("message")
289
+ return text if isinstance(text, str) else ""
290
+ return ""
291
+
292
+
293
+ def truncate(value: Any, max_chars: int = MAX_CHARS) -> Tuple[Any, Dict[str, Any]]:
294
+ if not isinstance(value, str):
295
+ try:
296
+ text = json.dumps(value, ensure_ascii=False)
297
+ except Exception:
298
+ text = str(value)
299
+ else:
300
+ text = value
301
+
302
+ orig_len = len(text)
303
+ if orig_len <= max_chars:
304
+ return value if isinstance(value, str) else value, {"truncated": False, "orig_len": orig_len}
305
+ kept = text[:max_chars]
306
+ return kept, {
307
+ "truncated": True,
308
+ "orig_len": orig_len,
309
+ "kept_len": len(kept),
310
+ "sha256": hashlib.sha256(text.encode("utf-8")).hexdigest(),
311
+ }
312
+
313
+
314
+ def get_payload(row: Dict[str, Any]) -> Dict[str, Any]:
315
+ payload = row.get("payload")
316
+ return payload if isinstance(payload, dict) else {}
317
+
318
+
319
+ def get_session_meta(rows: List[Dict[str, Any]], session_path: Path) -> Dict[str, Any]:
320
+ meta: Dict[str, Any] = {"session_path": str(session_path)}
321
+ for row in rows:
322
+ if row.get("type") == "session_meta":
323
+ payload = get_payload(row)
324
+ if payload:
325
+ meta.update(payload)
326
+ return meta
327
+
328
+
329
+ def extract_usage(rows: List[Dict[str, Any]]) -> Dict[str, Any]:
330
+ usage: Dict[str, Any] = {}
331
+ for row in rows:
332
+ payload = get_payload(row)
333
+ if row.get("type") == "event_msg" and payload.get("type") == "token_count":
334
+ info = payload.get("info")
335
+ if isinstance(info, dict):
336
+ last = info.get("last_token_usage")
337
+ total = info.get("total_token_usage")
338
+ if isinstance(last, dict):
339
+ usage["last_token_usage"] = last
340
+ if isinstance(total, dict):
341
+ usage["total_token_usage"] = total
342
+ return usage
343
+
344
+
345
+ def usage_details_from_codex(usage: Dict[str, Any]) -> Dict[str, int]:
346
+ raw = usage.get("last_token_usage")
347
+ if not isinstance(raw, dict):
348
+ return {}
349
+ out: Dict[str, int] = {}
350
+ mapping = {
351
+ "input_tokens": "input",
352
+ "output_tokens": "output",
353
+ "cached_input_tokens": "cache_read_input_tokens",
354
+ "reasoning_output_tokens": "reasoning_output_tokens",
355
+ }
356
+ for src, dst in mapping.items():
357
+ value = raw.get(src)
358
+ if isinstance(value, int) and value >= 0:
359
+ out[dst] = value
360
+ return out
361
+
362
+
363
+ def collect_turn_material(rows: List[Dict[str, Any]]) -> Dict[str, Any]:
364
+ user_texts: List[str] = []
365
+ assistant_texts: List[str] = []
366
+ tool_calls: List[Dict[str, Any]] = []
367
+ tool_results: List[Dict[str, Any]] = []
368
+
369
+ for row in rows:
370
+ row_type = row.get("type")
371
+ payload = get_payload(row)
372
+
373
+ if row_type == "response_item":
374
+ item_type = payload.get("type")
375
+ if item_type == "message":
376
+ role = payload.get("role")
377
+ text = extract_text(payload.get("content"))
378
+ if text:
379
+ if role == "user":
380
+ user_texts.append(text)
381
+ elif role == "assistant":
382
+ assistant_texts.append(text)
383
+ elif item_type == "function_call":
384
+ tool_calls.append(
385
+ {
386
+ "id": payload.get("call_id") or payload.get("id") or "",
387
+ "name": payload.get("name") or "tool",
388
+ "input": payload.get("arguments") or payload.get("input") or {},
389
+ }
390
+ )
391
+
392
+ if row_type == "event_msg":
393
+ event_type = payload.get("type")
394
+ if event_type == "agent_message" and isinstance(payload.get("message"), str):
395
+ assistant_texts.append(payload["message"])
396
+ elif isinstance(event_type, str) and event_type.endswith("_end"):
397
+ tool_results.append(
398
+ {
399
+ "id": payload.get("call_id") or "",
400
+ "name": event_type,
401
+ "output": {
402
+ "stdout": payload.get("stdout"),
403
+ "stderr": payload.get("stderr"),
404
+ "success": payload.get("success"),
405
+ "status": payload.get("status"),
406
+ "aggregated_output": payload.get("aggregated_output"),
407
+ },
408
+ }
409
+ )
410
+
411
+ return {
412
+ "user_text": "\n\n".join(user_texts[-3:]),
413
+ "assistant_text": "\n\n".join(assistant_texts),
414
+ "tool_calls": tool_calls,
415
+ "tool_results": tool_results,
416
+ }
417
+
418
+
419
+ def emit_codex_turn(
420
+ langfuse: Langfuse,
421
+ session_id: str,
422
+ user_id: Optional[str],
423
+ turn_num: int,
424
+ session_path: Path,
425
+ meta: Dict[str, Any],
426
+ material: Dict[str, Any],
427
+ usage: Dict[str, Any],
428
+ ) -> None:
429
+ user_text, user_meta = truncate(material.get("user_text") or "")
430
+ assistant_text, assistant_meta = truncate(material.get("assistant_text") or "")
431
+ usage_details = usage_details_from_codex(usage)
432
+ model = first_string(meta.get("model"), meta.get("model_provider")) or "codex"
433
+
434
+ with propagate_attributes(
435
+ user_id=user_id,
436
+ session_id=session_id,
437
+ trace_name=f"Codex - Turn {turn_num}",
438
+ tags=["codex"],
439
+ ):
440
+ with langfuse.start_as_current_observation(
441
+ name=f"Codex - Turn {turn_num}",
442
+ input={"role": "user", "content": user_text},
443
+ metadata={
444
+ "source": "codex",
445
+ "session_id": session_id,
446
+ "turn_number": turn_num,
447
+ "session_path": str(session_path),
448
+ "cwd": meta.get("cwd"),
449
+ "originator": meta.get("originator"),
450
+ "cli_version": meta.get("cli_version"),
451
+ "user_text": user_meta,
452
+ "usage": usage,
453
+ },
454
+ ) as trace_span:
455
+ with langfuse.start_as_current_observation(
456
+ name="Codex Response",
457
+ as_type="generation",
458
+ model=model,
459
+ input={"role": "user", "content": user_text},
460
+ output={"role": "assistant", "content": assistant_text},
461
+ usage_details=usage_details or None,
462
+ metadata={"assistant_text": assistant_meta},
463
+ ):
464
+ pass
465
+
466
+ for call in material.get("tool_calls") or []:
467
+ tool_input, input_meta = truncate(call.get("input"))
468
+ with langfuse.start_as_current_observation(
469
+ name=f"Tool: {call.get('name') or 'tool'}",
470
+ as_type="tool",
471
+ input=tool_input,
472
+ metadata={
473
+ "tool_id": call.get("id"),
474
+ "tool_name": call.get("name"),
475
+ "input_meta": input_meta,
476
+ },
477
+ ):
478
+ pass
479
+
480
+ for result in material.get("tool_results") or []:
481
+ output, output_meta = truncate(result.get("output"))
482
+ with langfuse.start_as_current_observation(
483
+ name=f"Tool Result: {result.get('name') or 'tool'}",
484
+ as_type="tool",
485
+ metadata={
486
+ "tool_id": result.get("id"),
487
+ "tool_name": result.get("name"),
488
+ "output_meta": output_meta,
489
+ },
490
+ ) as tool_obs:
491
+ tool_obs.update(output=output)
492
+
493
+ trace_span.update(output={"role": "assistant", "content": assistant_text})
494
+
495
+
496
+ def main() -> int:
497
+ if os.environ.get("TRACE_TO_LANGFUSE", "").lower() == "false":
498
+ return 0
499
+
500
+ payload = read_stdin_payload()
501
+ config = read_json_if_exists(CONFIG_FILE)
502
+
503
+ public_key = (
504
+ os.environ.get("CODEX_LANGFUSE_PUBLIC_KEY")
505
+ or os.environ.get("LANGFUSE_PUBLIC_KEY")
506
+ or config.get("publicKey")
507
+ )
508
+ secret_key = (
509
+ os.environ.get("CODEX_LANGFUSE_SECRET_KEY")
510
+ or os.environ.get("LANGFUSE_SECRET_KEY")
511
+ or config.get("secretKey")
512
+ )
513
+ host = (
514
+ os.environ.get("CODEX_LANGFUSE_BASE_URL")
515
+ or os.environ.get("LANGFUSE_BASEURL")
516
+ or os.environ.get("LANGFUSE_HOST")
517
+ or config.get("baseUrl")
518
+ or "https://cloud.langfuse.com"
519
+ )
520
+ user_id = (
521
+ find_value(payload, ("user_id", "userId", "username", "userName"))
522
+ or os.environ.get("CODEX_LANGFUSE_USER_ID")
523
+ or os.environ.get("LANGFUSE_USER_ID")
524
+ or config.get("userId")
525
+ or os.environ.get("USERNAME")
526
+ )
527
+
528
+ if not public_key or not secret_key:
529
+ debug("missing Langfuse credentials")
530
+ return 0
531
+
532
+ session_path = resolve_session_file(payload)
533
+ if not session_path or not session_path.exists():
534
+ debug("missing Codex session file")
535
+ return 0
536
+
537
+ try:
538
+ langfuse = Langfuse(public_key=public_key, secret_key=secret_key, host=host)
539
+ except Exception as e:
540
+ debug(f"Langfuse init failed: {e}")
541
+ return 0
542
+
543
+ try:
544
+ with FileLock(LOCK_FILE):
545
+ state = read_json_if_exists(STATE_FILE)
546
+ key = session_key(session_path)
547
+ ss = load_session_state(state, key)
548
+ rows, ss = read_new_jsonl(session_path, ss)
549
+ if not rows:
550
+ save_session_state(state, key, ss)
551
+ write_json_atomic(STATE_FILE, state)
552
+ return 0
553
+
554
+ material = collect_turn_material(rows)
555
+ if not material.get("assistant_text") and not material.get("tool_calls") and not material.get("tool_results"):
556
+ save_session_state(state, key, ss)
557
+ write_json_atomic(STATE_FILE, state)
558
+ return 0
559
+
560
+ meta = get_session_meta(rows, session_path)
561
+ session_id = first_string(str(meta.get("id")) if meta.get("id") else "", session_path.stem) or session_path.stem
562
+ usage = extract_usage(rows)
563
+ turn_num = ss.turn_count + 1
564
+
565
+ try:
566
+ emit_codex_turn(langfuse, session_id, user_id, turn_num, session_path, meta, material, usage)
567
+ ss.turn_count = turn_num
568
+ except Exception as e:
569
+ debug(f"emit_codex_turn failed: {e}")
570
+
571
+ save_session_state(state, key, ss)
572
+ write_json_atomic(STATE_FILE, state)
573
+
574
+ try:
575
+ langfuse.flush()
576
+ except Exception:
577
+ pass
578
+ log("INFO", f"Processed Codex turn {ss.turn_count} for {session_path}")
579
+ return 0
580
+ except Exception as e:
581
+ debug(f"unexpected failure: {e}")
582
+ return 0
583
+ finally:
584
+ try:
585
+ langfuse.shutdown()
586
+ except Exception:
587
+ pass
588
+
589
+
590
+ if __name__ == "__main__":
591
+ sys.exit(main())