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.
- package/CODEX_LANGFUSE_PLAN.md +62 -0
- package/README.md +114 -0
- package/bin/cli.js +166 -0
- package/bin/langfuse-cli.js +718 -0
- package/codex_langfuse_notify.py +591 -0
- package/langfuse_hook.py +603 -0
- package/opencode-ohai-report/.claude/commands/report-ai-issue.md +60 -0
- package/opencode-ohai-report/.opencode/commands/report-ai-issue.md +30 -0
- package/opencode-ohai-report/.opencode/plugins/oh-ai-report.ts +569 -0
- package/opencode-ohai-report/README.md +45 -0
- package/opencode-ohai-report/bin/cli.js +421 -0
- package/opencode-ohai-report/docs/opencode-ai-issue-collection-architecture.md +313 -0
- package/opencode-ohai-report/docs/opencode-ai-issue-collection-best-practices.md +476 -0
- package/opencode-ohai-report/docs/opencode-ai-issue-collection-phase1-summary.md +405 -0
- package/opencode-ohai-report/examples/issue_output.json +4 -0
- package/opencode-ohai-report/package.json +40 -0
- package/opencode-ohai-report/scripts/claude_report_hook.py +257 -0
- package/opencode-ohai-report/scripts/create_issue.py +34 -0
- package/opencode-ohai-report/scripts/install-claude-plugin.ps1 +254 -0
- package/opencode-ohai-report/scripts/install-opencode-plugin.ps1 +264 -0
- package/opencode-ohai-report/scripts/install-opencode-plugin.sh +218 -0
- package/opencode-ohai-report/scripts/merge-claude-settings.py +99 -0
- package/opencode-ohai-report/tools/ohai-report/README.md +151 -0
- package/opencode-ohai-report/tools/ohai-report/examples/issue-input.json +26 -0
- package/opencode-ohai-report/tools/ohai-report/ohai_report/__init__.py +5 -0
- package/opencode-ohai-report/tools/ohai-report/ohai_report/__main__.py +9 -0
- package/opencode-ohai-report/tools/ohai-report/ohai_report/cli.py +319 -0
- package/opencode-ohai-report/tools/ohai-report/ohai_report/git_context.py +32 -0
- package/opencode-ohai-report/tools/ohai-report/ohai_report/gitcode_defaults.py +14 -0
- package/opencode-ohai-report/tools/ohai-report/ohai_report/issue_markdown.py +313 -0
- package/opencode-ohai-report/tools/ohai-report/ohai_report/metadata.py +360 -0
- package/opencode-ohai-report/tools/ohai-report/ohai_report/observability/__init__.py +1 -0
- package/opencode-ohai-report/tools/ohai-report/ohai_report/observability/langfuse.py +38 -0
- package/opencode-ohai-report/tools/ohai-report/ohai_report/payload.py +64 -0
- package/opencode-ohai-report/tools/ohai-report/ohai_report/schema.py +80 -0
- package/opencode-ohai-report/tools/ohai-report/ohai_report/sinks/__init__.py +1 -0
- package/opencode-ohai-report/tools/ohai-report/ohai_report/sinks/base.py +15 -0
- package/opencode-ohai-report/tools/ohai-report/ohai_report/sinks/gitcode.py +405 -0
- package/opencode-ohai-report/tools/ohai-report/ohai_report/sinks/local.py +21 -0
- package/opencode-ohai-report/tools/ohai-report/ohai_report/sinks/webhook.py +354 -0
- package/opencode-ohai-report/tools/ohai-report/ohai_report/webhook_defaults.py +9 -0
- package/opencode-ohai-report/tools/ohai-report/ohai_report/workspace.py +61 -0
- package/opencode-ohai-report/tools/ohai-report/ohai_report.py +10 -0
- package/opencode-ohai-report/tools/ohai-report/schemas/report_issue.schema.json +166 -0
- package/package.json +59 -0
- package/scripts/codex-langfuse-check.mjs +101 -0
- package/scripts/codex-langfuse-setup.mjs +181 -0
- package/scripts/langfuse-check.mjs +90 -0
- package/scripts/langfuse-setup.mjs +278 -0
- package/scripts/opencode-langfuse-check.mjs +94 -0
- package/scripts/opencode-langfuse-run.mjs +96 -0
- package/scripts/opencode-langfuse-setup.mjs +478 -0
- package/scripts/resolve-opencode-cli.mjs +58 -0
- package/setup-langfuse.bat +163 -0
- 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())
|