oh-langfuse 0.1.64 → 0.1.66
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/README.md +15 -0
- package/codex_langfuse_notify.py +159 -1
- package/langfuse_hook.py +216 -7
- package/package.json +1 -1
- package/scripts/opencode-langfuse-setup.mjs +218 -58
- package/scripts/real-self-verify.mjs +94 -74
package/README.md
CHANGED
|
@@ -142,6 +142,21 @@ metadata.skill_invocation_modes
|
|
|
142
142
|
metadata.skill_agent_paths
|
|
143
143
|
```
|
|
144
144
|
|
|
145
|
+
Repository context is also attached to each Agent Turn when the runtime can identify a current working directory. The hook performs only lightweight read-only git probes and stays silent on timeout or failure.
|
|
146
|
+
|
|
147
|
+
Common repository fields:
|
|
148
|
+
|
|
149
|
+
```text
|
|
150
|
+
metadata.cwd
|
|
151
|
+
metadata.cwd_source
|
|
152
|
+
metadata.git_context_available
|
|
153
|
+
metadata.is_git_repo
|
|
154
|
+
metadata.git_remote
|
|
155
|
+
metadata.git_repo_slug
|
|
156
|
+
metadata.git_branch
|
|
157
|
+
metadata.git_commit
|
|
158
|
+
```
|
|
159
|
+
|
|
145
160
|
Dashboard 统计建议:
|
|
146
161
|
|
|
147
162
|
```text
|
package/codex_langfuse_notify.py
CHANGED
|
@@ -10,6 +10,7 @@ emit the new assistant/user/tool events to Langfuse.
|
|
|
10
10
|
import json
|
|
11
11
|
import os
|
|
12
12
|
import re
|
|
13
|
+
import subprocess
|
|
13
14
|
import sys
|
|
14
15
|
import time
|
|
15
16
|
import hashlib
|
|
@@ -87,6 +88,9 @@ MAX_CHARS = int(os.environ.get("CODEX_LANGFUSE_MAX_CHARS", "20000"))
|
|
|
87
88
|
METRICS_SCHEMA_VERSION = "1.1"
|
|
88
89
|
AGENT_NAME = "codex"
|
|
89
90
|
AGENT_TURN_NAME = "Codex Agent Turn"
|
|
91
|
+
REPO_CONTEXT_TTL_S = 30.0
|
|
92
|
+
REPO_CONTEXT_GIT_TIMEOUT_S = 0.5
|
|
93
|
+
_REPO_CONTEXT_CACHE: Dict[str, Tuple[float, Dict[str, Any]]] = {}
|
|
90
94
|
|
|
91
95
|
|
|
92
96
|
def log(level: str, message: str) -> None:
|
|
@@ -104,6 +108,158 @@ def debug(message: str) -> None:
|
|
|
104
108
|
log("DEBUG", message)
|
|
105
109
|
|
|
106
110
|
|
|
111
|
+
def empty_repo_context(
|
|
112
|
+
cwd: Optional[str] = None,
|
|
113
|
+
cwd_source: str = "unavailable",
|
|
114
|
+
available: bool = False,
|
|
115
|
+
error: str = "",
|
|
116
|
+
is_git_repo: bool = False,
|
|
117
|
+
) -> Dict[str, Any]:
|
|
118
|
+
return {
|
|
119
|
+
"cwd": str(cwd or ""),
|
|
120
|
+
"cwd_source": cwd_source or "unavailable",
|
|
121
|
+
"git_context_available": bool(available),
|
|
122
|
+
"git_context_error": error or "",
|
|
123
|
+
"is_git_repo": bool(is_git_repo),
|
|
124
|
+
"git_root": "",
|
|
125
|
+
"git_remote": "",
|
|
126
|
+
"git_remote_host": "",
|
|
127
|
+
"git_repo_owner": "",
|
|
128
|
+
"git_repo_name": "",
|
|
129
|
+
"git_repo_slug": "",
|
|
130
|
+
"git_branch": "",
|
|
131
|
+
"git_commit": "",
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def sanitize_git_remote(remote: Optional[str]) -> Dict[str, str]:
|
|
136
|
+
empty = {
|
|
137
|
+
"git_remote": "",
|
|
138
|
+
"git_remote_host": "",
|
|
139
|
+
"git_repo_owner": "",
|
|
140
|
+
"git_repo_name": "",
|
|
141
|
+
"git_repo_slug": "",
|
|
142
|
+
}
|
|
143
|
+
raw = str(remote or "").strip()
|
|
144
|
+
if not raw:
|
|
145
|
+
return empty
|
|
146
|
+
|
|
147
|
+
try:
|
|
148
|
+
if "://" in raw:
|
|
149
|
+
parsed = urlparse(raw)
|
|
150
|
+
host = parsed.hostname or ""
|
|
151
|
+
parts = [part for part in parsed.path.strip("/").split("/") if part]
|
|
152
|
+
else:
|
|
153
|
+
scp_like = re.match(r"^[^@]+@([^:]+):(.+)$", raw)
|
|
154
|
+
if scp_like:
|
|
155
|
+
host = scp_like.group(1)
|
|
156
|
+
parts = [part for part in scp_like.group(2).strip("/").split("/") if part]
|
|
157
|
+
else:
|
|
158
|
+
parsed = urlparse(f"ssh://{raw}")
|
|
159
|
+
host = parsed.hostname or ""
|
|
160
|
+
parts = [part for part in parsed.path.strip("/").split("/") if part]
|
|
161
|
+
|
|
162
|
+
if len(parts) < 2 or not host:
|
|
163
|
+
return empty
|
|
164
|
+
owner = parts[-2]
|
|
165
|
+
repo = parts[-1]
|
|
166
|
+
if repo.endswith(".git"):
|
|
167
|
+
repo = repo[:-4]
|
|
168
|
+
if not owner or not repo:
|
|
169
|
+
return empty
|
|
170
|
+
slug = f"{owner}/{repo}"
|
|
171
|
+
return {
|
|
172
|
+
"git_remote": f"{host}/{slug}",
|
|
173
|
+
"git_remote_host": host,
|
|
174
|
+
"git_repo_owner": owner,
|
|
175
|
+
"git_repo_name": repo,
|
|
176
|
+
"git_repo_slug": slug,
|
|
177
|
+
}
|
|
178
|
+
except Exception:
|
|
179
|
+
return empty
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def run_git_command(cwd: str, args: List[str], timeout_s: float = REPO_CONTEXT_GIT_TIMEOUT_S) -> Tuple[bool, str, str]:
|
|
183
|
+
try:
|
|
184
|
+
result = subprocess.run(
|
|
185
|
+
["git", "-C", cwd, *args],
|
|
186
|
+
text=True,
|
|
187
|
+
capture_output=True,
|
|
188
|
+
timeout=timeout_s,
|
|
189
|
+
check=False,
|
|
190
|
+
)
|
|
191
|
+
return result.returncode == 0, (result.stdout or "").strip(), (result.stderr or "").strip()
|
|
192
|
+
except subprocess.TimeoutExpired:
|
|
193
|
+
return False, "", "timeout"
|
|
194
|
+
except FileNotFoundError:
|
|
195
|
+
return False, "", "git_unavailable"
|
|
196
|
+
except Exception:
|
|
197
|
+
return False, "", "git_error"
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def collect_repo_context(cwd: Optional[str], cwd_source: str = "unavailable") -> Dict[str, Any]:
|
|
201
|
+
cwd_text = str(cwd or "").strip()
|
|
202
|
+
if not cwd_text:
|
|
203
|
+
return empty_repo_context(error="missing_cwd")
|
|
204
|
+
|
|
205
|
+
path = Path(cwd_text)
|
|
206
|
+
if not path.exists():
|
|
207
|
+
return empty_repo_context(cwd_text, cwd_source, available=False, error="missing_cwd")
|
|
208
|
+
|
|
209
|
+
cache_key = str(path.resolve())
|
|
210
|
+
cached = _REPO_CONTEXT_CACHE.get(cache_key)
|
|
211
|
+
now = time.time()
|
|
212
|
+
if cached and now - cached[0] <= REPO_CONTEXT_TTL_S:
|
|
213
|
+
context = dict(cached[1])
|
|
214
|
+
context["cwd_source"] = cwd_source or context.get("cwd_source") or "unavailable"
|
|
215
|
+
return context
|
|
216
|
+
|
|
217
|
+
ok, inside, error = run_git_command(cwd_text, ["rev-parse", "--is-inside-work-tree"])
|
|
218
|
+
if not ok:
|
|
219
|
+
reason = error if error in ("timeout", "git_unavailable") else ""
|
|
220
|
+
if reason:
|
|
221
|
+
context = empty_repo_context(cwd_text, cwd_source, available=False, error=reason)
|
|
222
|
+
else:
|
|
223
|
+
context = empty_repo_context(cwd_text, cwd_source, available=True, is_git_repo=False)
|
|
224
|
+
_REPO_CONTEXT_CACHE[cache_key] = (now, dict(context))
|
|
225
|
+
return context
|
|
226
|
+
if inside.lower() != "true":
|
|
227
|
+
context = empty_repo_context(cwd_text, cwd_source, available=True, is_git_repo=False)
|
|
228
|
+
_REPO_CONTEXT_CACHE[cache_key] = (now, dict(context))
|
|
229
|
+
return context
|
|
230
|
+
|
|
231
|
+
context = empty_repo_context(cwd_text, cwd_source, available=True, is_git_repo=True)
|
|
232
|
+
|
|
233
|
+
ok_root, root, root_error = run_git_command(cwd_text, ["rev-parse", "--show-toplevel"])
|
|
234
|
+
if not ok_root and root_error in ("timeout", "git_unavailable"):
|
|
235
|
+
context["git_context_available"] = False
|
|
236
|
+
context["git_context_error"] = root_error
|
|
237
|
+
_REPO_CONTEXT_CACHE[cache_key] = (now, dict(context))
|
|
238
|
+
return context
|
|
239
|
+
context["git_root"] = root
|
|
240
|
+
|
|
241
|
+
ok_remote, remote, remote_error = run_git_command(cwd_text, ["config", "--get", "remote.origin.url"])
|
|
242
|
+
if ok_remote and remote:
|
|
243
|
+
remote_context = sanitize_git_remote(remote)
|
|
244
|
+
context.update(remote_context)
|
|
245
|
+
if not remote_context["git_remote"]:
|
|
246
|
+
context["git_context_error"] = "remote_parse_failed"
|
|
247
|
+
elif remote_error in ("timeout", "git_unavailable"):
|
|
248
|
+
context["git_context_available"] = False
|
|
249
|
+
context["git_context_error"] = remote_error
|
|
250
|
+
|
|
251
|
+
ok_branch, branch, _branch_error = run_git_command(cwd_text, ["branch", "--show-current"])
|
|
252
|
+
if ok_branch:
|
|
253
|
+
context["git_branch"] = branch
|
|
254
|
+
|
|
255
|
+
ok_commit, commit, _commit_error = run_git_command(cwd_text, ["rev-parse", "--short", "HEAD"])
|
|
256
|
+
if ok_commit:
|
|
257
|
+
context["git_commit"] = commit
|
|
258
|
+
|
|
259
|
+
_REPO_CONTEXT_CACHE[cache_key] = (now, dict(context))
|
|
260
|
+
return context
|
|
261
|
+
|
|
262
|
+
|
|
107
263
|
class FileLock:
|
|
108
264
|
def __init__(self, path: Path, timeout_s: float = 2.0):
|
|
109
265
|
self.path = path
|
|
@@ -1198,6 +1354,7 @@ def emit_codex_turn(
|
|
|
1198
1354
|
skill_use_events=skill_use_events,
|
|
1199
1355
|
)
|
|
1200
1356
|
skill_summary = summarize_skill_usages(skill_usages)
|
|
1357
|
+
repo_context = dict(meta.get("repo_context") or collect_repo_context(first_string(meta.get("cwd")), "session_meta"))
|
|
1201
1358
|
|
|
1202
1359
|
with propagate_attributes(
|
|
1203
1360
|
user_id=user_id,
|
|
@@ -1216,7 +1373,7 @@ def emit_codex_turn(
|
|
|
1216
1373
|
"session_id": session_id,
|
|
1217
1374
|
"turn_number": turn_num,
|
|
1218
1375
|
"session_path": str(session_path),
|
|
1219
|
-
|
|
1376
|
+
**repo_context,
|
|
1220
1377
|
"originator": meta.get("originator"),
|
|
1221
1378
|
"cli_version": meta.get("cli_version"),
|
|
1222
1379
|
"user_text": user_meta,
|
|
@@ -1374,6 +1531,7 @@ def main() -> int:
|
|
|
1374
1531
|
meta = get_session_meta(rows, session_path)
|
|
1375
1532
|
meta.update({k: v for k, v in parsed.session_meta.items() if v not in (None, "")})
|
|
1376
1533
|
meta.setdefault("session_path", str(session_path))
|
|
1534
|
+
meta["repo_context"] = collect_repo_context(first_string(meta.get("cwd")), "session_meta")
|
|
1377
1535
|
session_id = first_string(str(meta.get("id")) if meta.get("id") else "", session_path.stem) or session_path.stem
|
|
1378
1536
|
uploaded = 0
|
|
1379
1537
|
|
package/langfuse_hook.py
CHANGED
|
@@ -7,6 +7,7 @@ Claude Code -> Langfuse hook
|
|
|
7
7
|
import json
|
|
8
8
|
import os
|
|
9
9
|
import re
|
|
10
|
+
import subprocess
|
|
10
11
|
import sys
|
|
11
12
|
import time
|
|
12
13
|
import hashlib
|
|
@@ -66,8 +67,11 @@ LOCK_FILE = STATE_DIR / "langfuse_state.lock"
|
|
|
66
67
|
DEBUG = os.environ.get("CC_LANGFUSE_DEBUG", "").lower() == "true"
|
|
67
68
|
MAX_CHARS = int(os.environ.get("CC_LANGFUSE_MAX_CHARS", "20000"))
|
|
68
69
|
METRICS_SCHEMA_VERSION = "1.1"
|
|
69
|
-
AGENT_NAME = "claude"
|
|
70
|
-
AGENT_TURN_NAME = "Claude Agent Turn"
|
|
70
|
+
AGENT_NAME = "claude"
|
|
71
|
+
AGENT_TURN_NAME = "Claude Agent Turn"
|
|
72
|
+
REPO_CONTEXT_TTL_S = 30.0
|
|
73
|
+
REPO_CONTEXT_GIT_TIMEOUT_S = 0.5
|
|
74
|
+
_REPO_CONTEXT_CACHE: Dict[str, Tuple[float, Dict[str, Any]]] = {}
|
|
71
75
|
|
|
72
76
|
# ----------------- Logging -----------------
|
|
73
77
|
def _log(level: str, message: str) -> None:
|
|
@@ -93,6 +97,158 @@ def warn(msg: str) -> None:
|
|
|
93
97
|
def error(msg: str) -> None:
|
|
94
98
|
_log("ERROR", msg)
|
|
95
99
|
|
|
100
|
+
|
|
101
|
+
def empty_repo_context(
|
|
102
|
+
cwd: Optional[str] = None,
|
|
103
|
+
cwd_source: str = "unavailable",
|
|
104
|
+
available: bool = False,
|
|
105
|
+
error: str = "",
|
|
106
|
+
is_git_repo: bool = False,
|
|
107
|
+
) -> Dict[str, Any]:
|
|
108
|
+
return {
|
|
109
|
+
"cwd": str(cwd or ""),
|
|
110
|
+
"cwd_source": cwd_source or "unavailable",
|
|
111
|
+
"git_context_available": bool(available),
|
|
112
|
+
"git_context_error": error or "",
|
|
113
|
+
"is_git_repo": bool(is_git_repo),
|
|
114
|
+
"git_root": "",
|
|
115
|
+
"git_remote": "",
|
|
116
|
+
"git_remote_host": "",
|
|
117
|
+
"git_repo_owner": "",
|
|
118
|
+
"git_repo_name": "",
|
|
119
|
+
"git_repo_slug": "",
|
|
120
|
+
"git_branch": "",
|
|
121
|
+
"git_commit": "",
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def sanitize_git_remote(remote: Optional[str]) -> Dict[str, str]:
|
|
126
|
+
empty = {
|
|
127
|
+
"git_remote": "",
|
|
128
|
+
"git_remote_host": "",
|
|
129
|
+
"git_repo_owner": "",
|
|
130
|
+
"git_repo_name": "",
|
|
131
|
+
"git_repo_slug": "",
|
|
132
|
+
}
|
|
133
|
+
raw = str(remote or "").strip()
|
|
134
|
+
if not raw:
|
|
135
|
+
return empty
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
if "://" in raw:
|
|
139
|
+
parsed = urlparse(raw)
|
|
140
|
+
host = parsed.hostname or ""
|
|
141
|
+
parts = [part for part in parsed.path.strip("/").split("/") if part]
|
|
142
|
+
else:
|
|
143
|
+
scp_like = re.match(r"^[^@]+@([^:]+):(.+)$", raw)
|
|
144
|
+
if scp_like:
|
|
145
|
+
host = scp_like.group(1)
|
|
146
|
+
parts = [part for part in scp_like.group(2).strip("/").split("/") if part]
|
|
147
|
+
else:
|
|
148
|
+
parsed = urlparse(f"ssh://{raw}")
|
|
149
|
+
host = parsed.hostname or ""
|
|
150
|
+
parts = [part for part in parsed.path.strip("/").split("/") if part]
|
|
151
|
+
|
|
152
|
+
if len(parts) < 2 or not host:
|
|
153
|
+
return empty
|
|
154
|
+
owner = parts[-2]
|
|
155
|
+
repo = parts[-1]
|
|
156
|
+
if repo.endswith(".git"):
|
|
157
|
+
repo = repo[:-4]
|
|
158
|
+
if not owner or not repo:
|
|
159
|
+
return empty
|
|
160
|
+
slug = f"{owner}/{repo}"
|
|
161
|
+
return {
|
|
162
|
+
"git_remote": f"{host}/{slug}",
|
|
163
|
+
"git_remote_host": host,
|
|
164
|
+
"git_repo_owner": owner,
|
|
165
|
+
"git_repo_name": repo,
|
|
166
|
+
"git_repo_slug": slug,
|
|
167
|
+
}
|
|
168
|
+
except Exception:
|
|
169
|
+
return empty
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def run_git_command(cwd: str, args: List[str], timeout_s: float = REPO_CONTEXT_GIT_TIMEOUT_S) -> Tuple[bool, str, str]:
|
|
173
|
+
try:
|
|
174
|
+
result = subprocess.run(
|
|
175
|
+
["git", "-C", cwd, *args],
|
|
176
|
+
text=True,
|
|
177
|
+
capture_output=True,
|
|
178
|
+
timeout=timeout_s,
|
|
179
|
+
check=False,
|
|
180
|
+
)
|
|
181
|
+
return result.returncode == 0, (result.stdout or "").strip(), (result.stderr or "").strip()
|
|
182
|
+
except subprocess.TimeoutExpired:
|
|
183
|
+
return False, "", "timeout"
|
|
184
|
+
except FileNotFoundError:
|
|
185
|
+
return False, "", "git_unavailable"
|
|
186
|
+
except Exception:
|
|
187
|
+
return False, "", "git_error"
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def collect_repo_context(cwd: Optional[str], cwd_source: str = "unavailable") -> Dict[str, Any]:
|
|
191
|
+
cwd_text = str(cwd or "").strip()
|
|
192
|
+
if not cwd_text:
|
|
193
|
+
return empty_repo_context(error="missing_cwd")
|
|
194
|
+
|
|
195
|
+
path = Path(cwd_text)
|
|
196
|
+
if not path.exists():
|
|
197
|
+
return empty_repo_context(cwd_text, cwd_source, available=False, error="missing_cwd")
|
|
198
|
+
|
|
199
|
+
cache_key = str(path.resolve())
|
|
200
|
+
cached = _REPO_CONTEXT_CACHE.get(cache_key)
|
|
201
|
+
now = time.time()
|
|
202
|
+
if cached and now - cached[0] <= REPO_CONTEXT_TTL_S:
|
|
203
|
+
context = dict(cached[1])
|
|
204
|
+
context["cwd_source"] = cwd_source or context.get("cwd_source") or "unavailable"
|
|
205
|
+
return context
|
|
206
|
+
|
|
207
|
+
ok, inside, error = run_git_command(cwd_text, ["rev-parse", "--is-inside-work-tree"])
|
|
208
|
+
if not ok:
|
|
209
|
+
reason = error if error in ("timeout", "git_unavailable") else ""
|
|
210
|
+
if reason:
|
|
211
|
+
context = empty_repo_context(cwd_text, cwd_source, available=False, error=reason)
|
|
212
|
+
else:
|
|
213
|
+
context = empty_repo_context(cwd_text, cwd_source, available=True, is_git_repo=False)
|
|
214
|
+
_REPO_CONTEXT_CACHE[cache_key] = (now, dict(context))
|
|
215
|
+
return context
|
|
216
|
+
if inside.lower() != "true":
|
|
217
|
+
context = empty_repo_context(cwd_text, cwd_source, available=True, is_git_repo=False)
|
|
218
|
+
_REPO_CONTEXT_CACHE[cache_key] = (now, dict(context))
|
|
219
|
+
return context
|
|
220
|
+
|
|
221
|
+
context = empty_repo_context(cwd_text, cwd_source, available=True, is_git_repo=True)
|
|
222
|
+
|
|
223
|
+
ok_root, root, root_error = run_git_command(cwd_text, ["rev-parse", "--show-toplevel"])
|
|
224
|
+
if not ok_root and root_error in ("timeout", "git_unavailable"):
|
|
225
|
+
context["git_context_available"] = False
|
|
226
|
+
context["git_context_error"] = root_error
|
|
227
|
+
_REPO_CONTEXT_CACHE[cache_key] = (now, dict(context))
|
|
228
|
+
return context
|
|
229
|
+
context["git_root"] = root
|
|
230
|
+
|
|
231
|
+
ok_remote, remote, remote_error = run_git_command(cwd_text, ["config", "--get", "remote.origin.url"])
|
|
232
|
+
if ok_remote and remote:
|
|
233
|
+
remote_context = sanitize_git_remote(remote)
|
|
234
|
+
context.update(remote_context)
|
|
235
|
+
if not remote_context["git_remote"]:
|
|
236
|
+
context["git_context_error"] = "remote_parse_failed"
|
|
237
|
+
elif remote_error in ("timeout", "git_unavailable"):
|
|
238
|
+
context["git_context_available"] = False
|
|
239
|
+
context["git_context_error"] = remote_error
|
|
240
|
+
|
|
241
|
+
ok_branch, branch, _branch_error = run_git_command(cwd_text, ["branch", "--show-current"])
|
|
242
|
+
if ok_branch:
|
|
243
|
+
context["git_branch"] = branch
|
|
244
|
+
|
|
245
|
+
ok_commit, commit, _commit_error = run_git_command(cwd_text, ["rev-parse", "--short", "HEAD"])
|
|
246
|
+
if ok_commit:
|
|
247
|
+
context["git_commit"] = commit
|
|
248
|
+
|
|
249
|
+
_REPO_CONTEXT_CACHE[cache_key] = (now, dict(context))
|
|
250
|
+
return context
|
|
251
|
+
|
|
96
252
|
# ----------------- State locking (best-effort) -----------------
|
|
97
253
|
class FileLock:
|
|
98
254
|
def __init__(self, path: Path, timeout_s: float = 2.0):
|
|
@@ -166,6 +322,53 @@ def read_hook_payload() -> Dict[str, Any]:
|
|
|
166
322
|
except Exception:
|
|
167
323
|
return {}
|
|
168
324
|
|
|
325
|
+
|
|
326
|
+
def first_string(*values: Any) -> Optional[str]:
|
|
327
|
+
for value in values:
|
|
328
|
+
if isinstance(value, str) and value.strip():
|
|
329
|
+
return value.strip()
|
|
330
|
+
return None
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def find_value(obj: Any, keys: Tuple[str, ...]) -> Optional[str]:
|
|
334
|
+
if isinstance(obj, dict):
|
|
335
|
+
for key, value in obj.items():
|
|
336
|
+
if key in keys and isinstance(value, str) and value.strip():
|
|
337
|
+
return value.strip()
|
|
338
|
+
found = find_value(value, keys)
|
|
339
|
+
if found:
|
|
340
|
+
return found
|
|
341
|
+
elif isinstance(obj, list):
|
|
342
|
+
for item in obj:
|
|
343
|
+
found = find_value(item, keys)
|
|
344
|
+
if found:
|
|
345
|
+
return found
|
|
346
|
+
return None
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def resolve_payload_cwd(payload: Dict[str, Any]) -> Tuple[Optional[str], str]:
|
|
350
|
+
candidate = find_value(
|
|
351
|
+
payload,
|
|
352
|
+
(
|
|
353
|
+
"cwd",
|
|
354
|
+
"currentWorkingDirectory",
|
|
355
|
+
"current_working_directory",
|
|
356
|
+
"workspace",
|
|
357
|
+
"workspace_path",
|
|
358
|
+
"workspacePath",
|
|
359
|
+
"project_dir",
|
|
360
|
+
"projectDir",
|
|
361
|
+
),
|
|
362
|
+
)
|
|
363
|
+
text = first_string(candidate)
|
|
364
|
+
if text:
|
|
365
|
+
return text, "payload"
|
|
366
|
+
try:
|
|
367
|
+
return os.getcwd(), "process"
|
|
368
|
+
except Exception:
|
|
369
|
+
return None, "unavailable"
|
|
370
|
+
|
|
371
|
+
|
|
169
372
|
def extract_session_transcript_and_user(payload: Dict[str, Any]) -> Tuple[Optional[str], Optional[Path], Optional[str]]:
|
|
170
373
|
"""
|
|
171
374
|
Tries a few plausible field names; exact keys can vary across hook types/versions.
|
|
@@ -734,6 +937,7 @@ class Turn:
|
|
|
734
937
|
assistant_msgs: List[Dict[str, Any]]
|
|
735
938
|
tool_results_by_id: Dict[str, Any]
|
|
736
939
|
context_msgs: List[Dict[str, Any]] = field(default_factory=list)
|
|
940
|
+
repo_context: Dict[str, Any] = field(default_factory=dict)
|
|
737
941
|
|
|
738
942
|
def is_skill_context_user_msg(msg: Dict[str, Any]) -> bool:
|
|
739
943
|
if get_role(msg) != "user" or is_tool_result(msg):
|
|
@@ -868,6 +1072,7 @@ def emit_turn(
|
|
|
868
1072
|
skill_use_events=skill_use_events,
|
|
869
1073
|
)
|
|
870
1074
|
skill_summary = summarize_skill_usages(skill_usages)
|
|
1075
|
+
repo_context = dict(getattr(turn, "repo_context", {}) or collect_repo_context(None))
|
|
871
1076
|
|
|
872
1077
|
# attach tool outputs
|
|
873
1078
|
for c in tool_calls:
|
|
@@ -883,11 +1088,11 @@ def emit_turn(
|
|
|
883
1088
|
with propagate_attributes(
|
|
884
1089
|
user_id=user_id,
|
|
885
1090
|
session_id=session_id,
|
|
886
|
-
trace_name=AGENT_TURN_NAME,
|
|
887
|
-
tags=[AGENT_NAME],
|
|
888
|
-
):
|
|
889
|
-
with langfuse.start_as_current_observation(
|
|
890
|
-
name=AGENT_TURN_NAME,
|
|
1091
|
+
trace_name=AGENT_TURN_NAME,
|
|
1092
|
+
tags=[AGENT_NAME],
|
|
1093
|
+
):
|
|
1094
|
+
with langfuse.start_as_current_observation(
|
|
1095
|
+
name=AGENT_TURN_NAME,
|
|
891
1096
|
input={"role": "user", "content": user_text},
|
|
892
1097
|
output={"role": "assistant", "content": assistant_text},
|
|
893
1098
|
metadata={
|
|
@@ -897,6 +1102,7 @@ def emit_turn(
|
|
|
897
1102
|
"session_id": session_id,
|
|
898
1103
|
"turn_number": turn_num,
|
|
899
1104
|
"transcript_path": str(transcript_path),
|
|
1105
|
+
**repo_context,
|
|
900
1106
|
"user_text": user_text_meta,
|
|
901
1107
|
"skills": skill_summary,
|
|
902
1108
|
},
|
|
@@ -1001,6 +1207,8 @@ def main() -> int:
|
|
|
1001
1207
|
return 0
|
|
1002
1208
|
|
|
1003
1209
|
turns = build_turns(msgs)
|
|
1210
|
+
cwd, cwd_source = resolve_payload_cwd(payload)
|
|
1211
|
+
repo_context = collect_repo_context(cwd, cwd_source)
|
|
1004
1212
|
if not turns:
|
|
1005
1213
|
write_session_state(state, key, ss)
|
|
1006
1214
|
save_state(state)
|
|
@@ -1011,6 +1219,7 @@ def main() -> int:
|
|
|
1011
1219
|
for t in turns:
|
|
1012
1220
|
emitted += 1
|
|
1013
1221
|
turn_num = ss.turn_count + emitted
|
|
1222
|
+
t.repo_context = repo_context
|
|
1014
1223
|
try:
|
|
1015
1224
|
emit_turn(langfuse, session_id, user_id, turn_num, t, transcript_path)
|
|
1016
1225
|
except Exception as e:
|
package/package.json
CHANGED
|
@@ -106,10 +106,14 @@ function psQuote(s) {
|
|
|
106
106
|
return `'${String(s).replace(/'/g, "''")}'`;
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
-
function cmdQuote(s) {
|
|
110
|
-
return `"${String(s).replace(/"/g, '""')}"`;
|
|
111
|
-
}
|
|
112
|
-
|
|
109
|
+
function cmdQuote(s) {
|
|
110
|
+
return `"${String(s).replace(/"/g, '""')}"`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function cmdSetValue(s) {
|
|
114
|
+
return String(s).replace(/"/g, '""');
|
|
115
|
+
}
|
|
116
|
+
|
|
113
117
|
function writeLangfusePluginUserConfig({ userId }) {
|
|
114
118
|
const home = os.homedir();
|
|
115
119
|
const dir = path.join(home, ".config", "opencode-plugin-langfuse");
|
|
@@ -129,6 +133,7 @@ function getPatchedLangfuseDistIndexJs() {
|
|
|
129
133
|
'import { promises as fs } from "node:fs";',
|
|
130
134
|
'import os from "node:os";',
|
|
131
135
|
'import path from "node:path";',
|
|
136
|
+
'import { execFileSync } from "node:child_process";',
|
|
132
137
|
'import { NodeSDK } from "@opentelemetry/sdk-node";',
|
|
133
138
|
'import { trace } from "@opentelemetry/api";',
|
|
134
139
|
"",
|
|
@@ -226,6 +231,129 @@ function getPatchedLangfuseDistIndexJs() {
|
|
|
226
231
|
"",
|
|
227
232
|
"const writeMetric = (span, key, value) => writeSpanAttribute(span, `langfuse.observation.metadata.${key}`, value);",
|
|
228
233
|
"",
|
|
234
|
+
"const emptyRepoContext = (cwd = '', cwdSource = 'unavailable', available = false, error = '', isGitRepo = false) => ({",
|
|
235
|
+
" cwd: String(cwd || ''),",
|
|
236
|
+
" cwd_source: cwdSource || 'unavailable',",
|
|
237
|
+
" git_context_available: Boolean(available),",
|
|
238
|
+
" git_context_error: error || '',",
|
|
239
|
+
" is_git_repo: Boolean(isGitRepo),",
|
|
240
|
+
" git_root: '',",
|
|
241
|
+
" git_remote: '',",
|
|
242
|
+
" git_remote_host: '',",
|
|
243
|
+
" git_repo_owner: '',",
|
|
244
|
+
" git_repo_name: '',",
|
|
245
|
+
" git_repo_slug: '',",
|
|
246
|
+
" git_branch: '',",
|
|
247
|
+
" git_commit: '',",
|
|
248
|
+
"});",
|
|
249
|
+
"",
|
|
250
|
+
"const sanitizeGitRemote = (remote) => {",
|
|
251
|
+
" const empty = { git_remote: '', git_remote_host: '', git_repo_owner: '', git_repo_name: '', git_repo_slug: '' };",
|
|
252
|
+
" const raw = String(remote || '').trim();",
|
|
253
|
+
" if (!raw) return empty;",
|
|
254
|
+
" try {",
|
|
255
|
+
" let host = '';",
|
|
256
|
+
" let parts = [];",
|
|
257
|
+
" if (raw.includes('://')) {",
|
|
258
|
+
" const parsed = new URL(raw);",
|
|
259
|
+
" host = parsed.hostname;",
|
|
260
|
+
" parts = parsed.pathname.replace(/^\\/+|\\/+$/g, '').split('/').filter(Boolean);",
|
|
261
|
+
" } else {",
|
|
262
|
+
" const scp = raw.match(/^[^@]+@([^:]+):(.+)$/);",
|
|
263
|
+
" if (scp) {",
|
|
264
|
+
" host = scp[1];",
|
|
265
|
+
" parts = scp[2].replace(/^\\/+|\\/+$/g, '').split('/').filter(Boolean);",
|
|
266
|
+
" } else {",
|
|
267
|
+
" const parsed = new URL(`ssh://${raw}`);",
|
|
268
|
+
" host = parsed.hostname;",
|
|
269
|
+
" parts = parsed.pathname.replace(/^\\/+|\\/+$/g, '').split('/').filter(Boolean);",
|
|
270
|
+
" }",
|
|
271
|
+
" }",
|
|
272
|
+
" if (!host || parts.length < 2) return empty;",
|
|
273
|
+
" const owner = parts[parts.length - 2];",
|
|
274
|
+
" const repo = parts[parts.length - 1].replace(/\\.git$/i, '');",
|
|
275
|
+
" if (!owner || !repo) return empty;",
|
|
276
|
+
" const slug = `${owner}/${repo}`;",
|
|
277
|
+
" return { git_remote: `${host}/${slug}`, git_remote_host: host, git_repo_owner: owner, git_repo_name: repo, git_repo_slug: slug };",
|
|
278
|
+
" } catch {",
|
|
279
|
+
" return empty;",
|
|
280
|
+
" }",
|
|
281
|
+
"};",
|
|
282
|
+
"",
|
|
283
|
+
"const runGit = (cwd, args) => {",
|
|
284
|
+
" try {",
|
|
285
|
+
" const stdout = execFileSync('git', ['-C', cwd, ...args], { encoding: 'utf8', timeout: 500, stdio: ['ignore', 'pipe', 'ignore'] });",
|
|
286
|
+
" return { ok: true, stdout: String(stdout || '').trim(), error: '' };",
|
|
287
|
+
" } catch (error) {",
|
|
288
|
+
" if (error && error.code === 'ETIMEDOUT') return { ok: false, stdout: '', error: 'timeout' };",
|
|
289
|
+
" if (error && error.code === 'ENOENT') return { ok: false, stdout: '', error: 'git_unavailable' };",
|
|
290
|
+
" return { ok: false, stdout: '', error: 'git_error' };",
|
|
291
|
+
" }",
|
|
292
|
+
"};",
|
|
293
|
+
"",
|
|
294
|
+
"const repoContextCache = new Map();",
|
|
295
|
+
'const collectRepoContext = (cwd, cwdSource = "process") => {',
|
|
296
|
+
" const cwdText = String(cwd || '').trim();",
|
|
297
|
+
" if (!cwdText) return emptyRepoContext('', 'unavailable', false, 'missing_cwd');",
|
|
298
|
+
" const now = Date.now();",
|
|
299
|
+
" const cached = repoContextCache.get(cwdText);",
|
|
300
|
+
" if (cached && now - cached.time <= 30000) return { ...cached.context, cwd_source: cwdSource || cached.context.cwd_source };",
|
|
301
|
+
" let context = emptyRepoContext(cwdText, cwdSource, false, '', false);",
|
|
302
|
+
" const inside = runGit(cwdText, ['rev-parse', '--is-inside-work-tree']);",
|
|
303
|
+
" if (!inside.ok) {",
|
|
304
|
+
" const reason = ['timeout', 'git_unavailable'].includes(inside.error) ? inside.error : '';",
|
|
305
|
+
" context = reason ? emptyRepoContext(cwdText, cwdSource, false, reason, false) : emptyRepoContext(cwdText, cwdSource, true, '', false);",
|
|
306
|
+
" repoContextCache.set(cwdText, { time: now, context });",
|
|
307
|
+
" return context;",
|
|
308
|
+
" }",
|
|
309
|
+
" if (inside.stdout.toLowerCase() !== 'true') {",
|
|
310
|
+
" context = emptyRepoContext(cwdText, cwdSource, true, '', false);",
|
|
311
|
+
" repoContextCache.set(cwdText, { time: now, context });",
|
|
312
|
+
" return context;",
|
|
313
|
+
" }",
|
|
314
|
+
" context = emptyRepoContext(cwdText, cwdSource, true, '', true);",
|
|
315
|
+
" const root = runGit(cwdText, ['rev-parse', '--show-toplevel']);",
|
|
316
|
+
" if (!root.ok && ['timeout', 'git_unavailable'].includes(root.error)) {",
|
|
317
|
+
" context.git_context_available = false;",
|
|
318
|
+
" context.git_context_error = root.error;",
|
|
319
|
+
" repoContextCache.set(cwdText, { time: now, context });",
|
|
320
|
+
" return context;",
|
|
321
|
+
" }",
|
|
322
|
+
" context.git_root = root.stdout || '';",
|
|
323
|
+
" const remote = runGit(cwdText, ['config', '--get', 'remote.origin.url']);",
|
|
324
|
+
" if (remote.ok && remote.stdout) {",
|
|
325
|
+
" const sanitized = sanitizeGitRemote(remote.stdout);",
|
|
326
|
+
" context = { ...context, ...sanitized };",
|
|
327
|
+
" if (!sanitized.git_remote) context.git_context_error = 'remote_parse_failed';",
|
|
328
|
+
" } else if (['timeout', 'git_unavailable'].includes(remote.error)) {",
|
|
329
|
+
" context.git_context_available = false;",
|
|
330
|
+
" context.git_context_error = remote.error;",
|
|
331
|
+
" }",
|
|
332
|
+
" const branch = runGit(cwdText, ['branch', '--show-current']);",
|
|
333
|
+
" if (branch.ok) context.git_branch = branch.stdout || '';",
|
|
334
|
+
" const commit = runGit(cwdText, ['rev-parse', '--short', 'HEAD']);",
|
|
335
|
+
" if (commit.ok) context.git_commit = commit.stdout || '';",
|
|
336
|
+
" repoContextCache.set(cwdText, { time: now, context });",
|
|
337
|
+
" return context;",
|
|
338
|
+
"};",
|
|
339
|
+
"",
|
|
340
|
+
"const writeRepoContextMetrics = (span, context) => {",
|
|
341
|
+
" const repoContext = context || {};",
|
|
342
|
+
" writeSpanAttribute(span, 'langfuse.observation.metadata.cwd', repoContext.cwd);",
|
|
343
|
+
" writeSpanAttribute(span, 'langfuse.observation.metadata.cwd_source', repoContext.cwd_source);",
|
|
344
|
+
" writeSpanAttribute(span, 'langfuse.observation.metadata.git_context_available', repoContext.git_context_available);",
|
|
345
|
+
" writeSpanAttribute(span, 'langfuse.observation.metadata.git_context_error', repoContext.git_context_error);",
|
|
346
|
+
" writeSpanAttribute(span, 'langfuse.observation.metadata.is_git_repo', repoContext.is_git_repo);",
|
|
347
|
+
" writeSpanAttribute(span, 'langfuse.observation.metadata.git_root', repoContext.git_root);",
|
|
348
|
+
" writeSpanAttribute(span, 'langfuse.observation.metadata.git_remote', repoContext.git_remote);",
|
|
349
|
+
" writeSpanAttribute(span, 'langfuse.observation.metadata.git_remote_host', repoContext.git_remote_host);",
|
|
350
|
+
" writeSpanAttribute(span, 'langfuse.observation.metadata.git_repo_owner', repoContext.git_repo_owner);",
|
|
351
|
+
" writeSpanAttribute(span, 'langfuse.observation.metadata.git_repo_name', repoContext.git_repo_name);",
|
|
352
|
+
" writeSpanAttribute(span, 'langfuse.observation.metadata.git_repo_slug', repoContext.git_repo_slug);",
|
|
353
|
+
" writeSpanAttribute(span, 'langfuse.observation.metadata.git_branch', repoContext.git_branch);",
|
|
354
|
+
" writeSpanAttribute(span, 'langfuse.observation.metadata.git_commit', repoContext.git_commit);",
|
|
355
|
+
"};",
|
|
356
|
+
"",
|
|
229
357
|
"const writeOpencodeMetricAttributes = (span, userId) => {",
|
|
230
358
|
" const attrs = span.attributes ?? {};",
|
|
231
359
|
' writeSpanAttribute(span, "oh.langfuse.source", "opencode");',
|
|
@@ -658,6 +786,7 @@ function getPatchedLangfuseDistIndexJs() {
|
|
|
658
786
|
' if (tokenMetrics.cacheRead !== undefined) span.setAttribute("langfuse.observation.metadata.cache_read_tokens", tokenMetrics.cacheRead);',
|
|
659
787
|
' if (tokenMetrics.reasoning !== undefined) span.setAttribute("langfuse.observation.metadata.reasoning_tokens", tokenMetrics.reasoning);',
|
|
660
788
|
' if (text) span.setAttribute("langfuse.observation.metadata.output_text_preview", text.slice(0, 512));',
|
|
789
|
+
' writeRepoContextMetrics(span, collectRepoContext(process.cwd(), "process"));',
|
|
661
790
|
" span.end();",
|
|
662
791
|
" messageTextById.delete(messageId);",
|
|
663
792
|
" skillUsagesByMessageId.delete(messageId);",
|
|
@@ -719,7 +848,7 @@ function writeWindowsLauncherCmd(opencodeDir, { publicKey, secretKey, baseUrl, u
|
|
|
719
848
|
cmd.push(" exit /b %ERRORLEVEL%");
|
|
720
849
|
cmd.push(")");
|
|
721
850
|
cmd.push("opencode %*");
|
|
722
|
-
fs.writeFileSync(p, cmd.join("\r\n") + "\r\n", "utf8");
|
|
851
|
+
fs.writeFileSync(p, cmd.join("\r\n") + "\r\n", "utf8");
|
|
723
852
|
return p;
|
|
724
853
|
}
|
|
725
854
|
|
|
@@ -754,7 +883,7 @@ function writeAutoUpdateHelper(target) {
|
|
|
754
883
|
"exit /b 0",
|
|
755
884
|
""
|
|
756
885
|
];
|
|
757
|
-
fs.writeFileSync(helper, lines.join(os.EOL), "utf8");
|
|
886
|
+
fs.writeFileSync(helper, lines.join(os.EOL), "utf8");
|
|
758
887
|
return helper;
|
|
759
888
|
}
|
|
760
889
|
|
|
@@ -776,30 +905,30 @@ function writeAutoUpdateHelper(target) {
|
|
|
776
905
|
return helper;
|
|
777
906
|
}
|
|
778
907
|
|
|
779
|
-
function windowsAutoUpdateCommand(target) {
|
|
780
|
-
return `call ${cmdQuote(writeAutoUpdateHelper(target))}`;
|
|
781
|
-
}
|
|
782
|
-
|
|
783
|
-
function unixAutoUpdateCommand(target) {
|
|
784
|
-
return `${shQuote(writeAutoUpdateHelper(target))} || true`;
|
|
785
|
-
}
|
|
786
|
-
|
|
787
|
-
function windowsGitPathBootstrap() {
|
|
788
|
-
return [
|
|
789
|
-
"for /f \"delims=\" %%G in ('where.exe git.exe 2^>nul') do (",
|
|
790
|
-
" set \"OH_LANGFUSE_GIT_EXE=%%G\"",
|
|
791
|
-
" set \"npm_config_git=%%G\"",
|
|
792
|
-
" set \"PATH=%%~dpG;%PATH%\"",
|
|
793
|
-
" goto :oh_langfuse_git_ready",
|
|
794
|
-
")",
|
|
795
|
-
":oh_langfuse_git_ready"
|
|
796
|
-
];
|
|
797
|
-
}
|
|
798
|
-
|
|
799
|
-
function writeWindowsUpdateCheckScript(dir) {
|
|
800
|
-
if (process.platform !== "win32") return null;
|
|
801
|
-
ensureDir(dir);
|
|
802
|
-
const p = path.join(dir, "oh-langfuse-opencode-update-check.ps1");
|
|
908
|
+
function windowsAutoUpdateCommand(target) {
|
|
909
|
+
return `call ${cmdQuote(writeAutoUpdateHelper(target))}`;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
function unixAutoUpdateCommand(target) {
|
|
913
|
+
return `${shQuote(writeAutoUpdateHelper(target))} || true`;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
function windowsGitPathBootstrap() {
|
|
917
|
+
return [
|
|
918
|
+
"for /f \"delims=\" %%G in ('where.exe git.exe 2^>nul') do (",
|
|
919
|
+
" set \"OH_LANGFUSE_GIT_EXE=%%G\"",
|
|
920
|
+
" set \"npm_config_git=%%G\"",
|
|
921
|
+
" set \"PATH=%%~dpG;%PATH%\"",
|
|
922
|
+
" goto :oh_langfuse_git_ready",
|
|
923
|
+
")",
|
|
924
|
+
":oh_langfuse_git_ready"
|
|
925
|
+
];
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
function writeWindowsUpdateCheckScript(dir) {
|
|
929
|
+
if (process.platform !== "win32") return null;
|
|
930
|
+
ensureDir(dir);
|
|
931
|
+
const p = path.join(dir, "oh-langfuse-opencode-update-check.ps1");
|
|
803
932
|
const ps = [
|
|
804
933
|
"$ErrorActionPreference = 'SilentlyContinue'",
|
|
805
934
|
"$statePath = Join-Path $env:USERPROFILE '.config\\oh-langfuse\\runtime.json'",
|
|
@@ -818,11 +947,11 @@ function writeWindowsUpdateCheckScript(dir) {
|
|
|
818
947
|
return p;
|
|
819
948
|
}
|
|
820
949
|
|
|
821
|
-
function writeOpencodeCommandShim(opencodeDir, { publicKey, secretKey, baseUrl, userId, realOpencodeCli }) {
|
|
822
|
-
const shimDir = path.join(opencodeDir, "bin");
|
|
823
|
-
ensureDir(shimDir);
|
|
824
|
-
if (process.platform === "win32") {
|
|
825
|
-
const shim = path.join(shimDir, "opencode.cmd");
|
|
950
|
+
function writeOpencodeCommandShim(opencodeDir, { publicKey, secretKey, baseUrl, userId, realOpencodeCli }) {
|
|
951
|
+
const shimDir = path.join(opencodeDir, "bin");
|
|
952
|
+
ensureDir(shimDir);
|
|
953
|
+
if (process.platform === "win32") {
|
|
954
|
+
const shim = path.join(shimDir, "opencode.cmd");
|
|
826
955
|
const cmd = ["@echo off", "REM Auto-generated by scripts/opencode-langfuse-setup.mjs"];
|
|
827
956
|
cmd.push("set OH_LANGFUSE_OPENCODE_SHIM=1");
|
|
828
957
|
cmd.push(`set LANGFUSE_PUBLIC_KEY=${publicKey}`);
|
|
@@ -831,26 +960,57 @@ function writeOpencodeCommandShim(opencodeDir, { publicKey, secretKey, baseUrl,
|
|
|
831
960
|
if (userId) cmd.push(`set LANGFUSE_USER_ID=${userId}`);
|
|
832
961
|
cmd.push(...windowsGitPathBootstrap());
|
|
833
962
|
cmd.push(windowsAutoUpdateCommand("opencode"));
|
|
834
|
-
cmd.push(`
|
|
835
|
-
cmd.push(
|
|
963
|
+
cmd.push(`set "OH_LANGFUSE_REAL_OPENCODE=${cmdSetValue(realOpencodeCli)}"`);
|
|
964
|
+
cmd.push('if exist "%OH_LANGFUSE_REAL_OPENCODE%" (');
|
|
965
|
+
cmd.push(' call "%OH_LANGFUSE_REAL_OPENCODE%" %*');
|
|
966
|
+
cmd.push(" exit /b %ERRORLEVEL%");
|
|
967
|
+
cmd.push(")");
|
|
968
|
+
cmd.push('if exist "%USERPROFILE%\\.opencode\\bin\\opencode.exe" (');
|
|
969
|
+
cmd.push(' call "%USERPROFILE%\\.opencode\\bin\\opencode.exe" %*');
|
|
970
|
+
cmd.push(" exit /b %ERRORLEVEL%");
|
|
971
|
+
cmd.push(")");
|
|
972
|
+
cmd.push('if exist "%USERPROFILE%\\.opencode\\bin\\opencode.cmd" (');
|
|
973
|
+
cmd.push(' call "%USERPROFILE%\\.opencode\\bin\\opencode.cmd" %*');
|
|
974
|
+
cmd.push(" exit /b %ERRORLEVEL%");
|
|
975
|
+
cmd.push(")");
|
|
976
|
+
cmd.push('if exist "%APPDATA%\\npm\\opencode.cmd" (');
|
|
977
|
+
cmd.push(' call "%APPDATA%\\npm\\opencode.cmd" %*');
|
|
978
|
+
cmd.push(" exit /b %ERRORLEVEL%");
|
|
979
|
+
cmd.push(")");
|
|
980
|
+
cmd.push('for /f "delims=" %%O in (\'where.exe opencode 2^>nul\') do (');
|
|
981
|
+
cmd.push(' if /I not "%%~fO"=="%~f0" (');
|
|
982
|
+
cmd.push(' call "%%O" %*');
|
|
983
|
+
cmd.push(" exit /b %ERRORLEVEL%");
|
|
984
|
+
cmd.push(" )");
|
|
985
|
+
cmd.push(")");
|
|
986
|
+
cmd.push("call npx.cmd -y opencode@latest %*");
|
|
987
|
+
cmd.push("exit /b %ERRORLEVEL%");
|
|
836
988
|
fs.writeFileSync(shim, cmd.join("\r\n") + "\r\n", "utf8");
|
|
837
|
-
return { shim, shimDir };
|
|
838
|
-
}
|
|
839
|
-
|
|
840
|
-
const shim = path.join(shimDir, "opencode");
|
|
841
|
-
const
|
|
842
|
-
|
|
843
|
-
"
|
|
844
|
-
"
|
|
845
|
-
"
|
|
989
|
+
return { shim, shimDir };
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
const shim = path.join(shimDir, "opencode");
|
|
993
|
+
const quotedRealOpencodeCli = shQuote(realOpencodeCli);
|
|
994
|
+
const lines = [
|
|
995
|
+
"#!/usr/bin/env sh",
|
|
996
|
+
"# Auto-generated by scripts/opencode-langfuse-setup.mjs",
|
|
997
|
+
"set -eu",
|
|
998
|
+
"export OH_LANGFUSE_OPENCODE_SHIM=1",
|
|
846
999
|
`export LANGFUSE_PUBLIC_KEY=${shQuote(publicKey)}`,
|
|
847
|
-
`export LANGFUSE_SECRET_KEY=${shQuote(secretKey)}`,
|
|
848
|
-
`export LANGFUSE_BASEURL=${shQuote(baseUrl)}`,
|
|
849
|
-
userId ? `export LANGFUSE_USER_ID=${shQuote(userId)}` : null,
|
|
850
|
-
unixAutoUpdateCommand("opencode"),
|
|
851
|
-
`
|
|
852
|
-
""
|
|
853
|
-
|
|
1000
|
+
`export LANGFUSE_SECRET_KEY=${shQuote(secretKey)}`,
|
|
1001
|
+
`export LANGFUSE_BASEURL=${shQuote(baseUrl)}`,
|
|
1002
|
+
userId ? `export LANGFUSE_USER_ID=${shQuote(userId)}` : null,
|
|
1003
|
+
unixAutoUpdateCommand("opencode"),
|
|
1004
|
+
`OH_LANGFUSE_REAL_OPENCODE=${quotedRealOpencodeCli}`,
|
|
1005
|
+
'if [ -x "$OH_LANGFUSE_REAL_OPENCODE" ]; then exec "$OH_LANGFUSE_REAL_OPENCODE" "$@"; fi',
|
|
1006
|
+
'if [ -x "$HOME/.opencode/bin/opencode" ]; then exec "$HOME/.opencode/bin/opencode" "$@"; fi',
|
|
1007
|
+
'if command -v opencode >/dev/null 2>&1; then',
|
|
1008
|
+
' resolved="$(command -v opencode)"',
|
|
1009
|
+
' if [ "$resolved" != "$0" ]; then exec "$resolved" "$@"; fi',
|
|
1010
|
+
"fi",
|
|
1011
|
+
'exec npx -y opencode@latest "$@"',
|
|
1012
|
+
""
|
|
1013
|
+
].filter(Boolean);
|
|
854
1014
|
fs.writeFileSync(shim, lines.join("\n"), "utf8");
|
|
855
1015
|
fs.chmodSync(shim, 0o755);
|
|
856
1016
|
return { shim, shimDir };
|
|
@@ -1093,7 +1253,7 @@ async function runNpmInstallOrThrow({ opencodeDir, pkgName = "opencode-plugin-la
|
|
|
1093
1253
|
);
|
|
1094
1254
|
}
|
|
1095
1255
|
|
|
1096
|
-
function setWindowsUserEnv({ publicKey, secretKey, baseUrl }) {
|
|
1256
|
+
function setWindowsUserEnv({ publicKey, secretKey, baseUrl }) {
|
|
1097
1257
|
const cmd = [
|
|
1098
1258
|
"$ErrorActionPreference = 'Stop';",
|
|
1099
1259
|
`[Environment]::SetEnvironmentVariable('LANGFUSE_PUBLIC_KEY', ${psQuote(publicKey)}, 'User');`,
|
|
@@ -1103,10 +1263,10 @@ function setWindowsUserEnv({ publicKey, secretKey, baseUrl }) {
|
|
|
1103
1263
|
const r = spawnSync("powershell", ["-NoProfile", "-Command", cmd], { stdio: "inherit", encoding: "utf8" });
|
|
1104
1264
|
if (r.status !== 0) {
|
|
1105
1265
|
throw new Error("写入用户级环境变量失败。");
|
|
1106
|
-
}
|
|
1107
|
-
}
|
|
1108
|
-
|
|
1109
|
-
function prependWindowsUserPath(dir) {
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
function prependWindowsUserPath(dir) {
|
|
1110
1270
|
if (process.platform !== "win32") return false;
|
|
1111
1271
|
const cmd = [
|
|
1112
1272
|
"$ErrorActionPreference = 'Stop';",
|
|
@@ -9,10 +9,10 @@ import { resolveOpencodeCli } from "./resolve-opencode-cli.mjs";
|
|
|
9
9
|
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
10
10
|
const packageJson = JSON.parse(fs.readFileSync(path.join(rootDir, "package.json"), "utf8"));
|
|
11
11
|
|
|
12
|
-
const DEFAULT_LANGFUSE_BASE_URL = "http://120.46.221.227:3000";
|
|
13
|
-
const DEFAULT_LANGFUSE_PUBLIC_KEY = "pk-lf-da0c90a7-6e93-4eb7-bb86-c1047c8d187d";
|
|
14
|
-
const DEFAULT_LANGFUSE_SECRET_KEY = "sk-lf-0269b85d-bfdc-442c-bfa3-e737954e3315";
|
|
15
|
-
const OPENCODE_NATIVE_AGENT_TURN_NAMES = new Set(["session.llm:ai.streamText.doStream"]);
|
|
12
|
+
const DEFAULT_LANGFUSE_BASE_URL = "http://120.46.221.227:3000";
|
|
13
|
+
const DEFAULT_LANGFUSE_PUBLIC_KEY = "pk-lf-da0c90a7-6e93-4eb7-bb86-c1047c8d187d";
|
|
14
|
+
const DEFAULT_LANGFUSE_SECRET_KEY = "sk-lf-0269b85d-bfdc-442c-bfa3-e737954e3315";
|
|
15
|
+
const OPENCODE_NATIVE_AGENT_TURN_NAMES = new Set(["session.llm:ai.streamText.doStream"]);
|
|
16
16
|
|
|
17
17
|
function parseArgs(argv) {
|
|
18
18
|
const args = { _: [] };
|
|
@@ -323,34 +323,34 @@ function metadataValue(item, key) {
|
|
|
323
323
|
const attrs = metadata.attributes || {};
|
|
324
324
|
const prefixed = `langfuse.observation.metadata.${key}`;
|
|
325
325
|
if (attrs[prefixed] !== undefined) return attrs[prefixed];
|
|
326
|
-
const ohKey = `oh.langfuse.${key}`;
|
|
327
|
-
if (attrs[ohKey] !== undefined) return attrs[ohKey];
|
|
328
|
-
if (metadata.source === "opencode" || attrs["oh.langfuse.source"] === "opencode") {
|
|
329
|
-
const usage = item?.usage || {};
|
|
330
|
-
if (key === "interaction_id") return `opencode:${metadata.user_id || attrs["oh.langfuse.user_id"] || "unknown"}:${item?.id || item?.traceId || item?.trace_id || "unknown"}`;
|
|
331
|
-
if (key === "interaction_count") return 1;
|
|
332
|
-
if (key === "token_metrics_available") {
|
|
333
|
-
return usage.input !== undefined || usage.output !== undefined || usage.total !== undefined || [
|
|
334
|
-
"ai.usage.inputTokens",
|
|
335
|
-
"ai.usage.outputTokens",
|
|
336
|
-
"ai.usage.totalTokens",
|
|
326
|
+
const ohKey = `oh.langfuse.${key}`;
|
|
327
|
+
if (attrs[ohKey] !== undefined) return attrs[ohKey];
|
|
328
|
+
if (metadata.source === "opencode" || attrs["oh.langfuse.source"] === "opencode") {
|
|
329
|
+
const usage = item?.usage || {};
|
|
330
|
+
if (key === "interaction_id") return `opencode:${metadata.user_id || attrs["oh.langfuse.user_id"] || "unknown"}:${item?.id || item?.traceId || item?.trace_id || "unknown"}`;
|
|
331
|
+
if (key === "interaction_count") return 1;
|
|
332
|
+
if (key === "token_metrics_available") {
|
|
333
|
+
return usage.input !== undefined || usage.output !== undefined || usage.total !== undefined || [
|
|
334
|
+
"ai.usage.inputTokens",
|
|
335
|
+
"ai.usage.outputTokens",
|
|
336
|
+
"ai.usage.totalTokens",
|
|
337
337
|
"ai.usage.promptTokens",
|
|
338
338
|
"ai.usage.completionTokens",
|
|
339
339
|
].some((attrKey) => attrs[attrKey] !== undefined);
|
|
340
340
|
}
|
|
341
|
-
if (key === "tool_call_count" || key === "skill_use_count") return 0;
|
|
342
|
-
const tokenAttrMap = {
|
|
343
|
-
input_tokens: ["input", "ai.usage.inputTokens", "ai.usage.promptTokens"],
|
|
344
|
-
output_tokens: ["output", "ai.usage.outputTokens", "ai.usage.completionTokens"],
|
|
345
|
-
total_tokens: ["total", "ai.usage.totalTokens"],
|
|
346
|
-
cache_read_tokens: ["ai.usage.cachedInputTokens", "ai.usage.inputTokenDetails.cacheReadTokens"],
|
|
347
|
-
reasoning_tokens: ["ai.usage.reasoningTokens", "ai.usage.outputTokenDetails.reasoningTokens"],
|
|
348
|
-
};
|
|
349
|
-
for (const attrKey of tokenAttrMap[key] || []) {
|
|
350
|
-
if (usage[attrKey] !== undefined) return usage[attrKey];
|
|
351
|
-
if (attrs[attrKey] !== undefined) return attrs[attrKey];
|
|
352
|
-
}
|
|
353
|
-
}
|
|
341
|
+
if (key === "tool_call_count" || key === "skill_use_count") return 0;
|
|
342
|
+
const tokenAttrMap = {
|
|
343
|
+
input_tokens: ["input", "ai.usage.inputTokens", "ai.usage.promptTokens"],
|
|
344
|
+
output_tokens: ["output", "ai.usage.outputTokens", "ai.usage.completionTokens"],
|
|
345
|
+
total_tokens: ["total", "ai.usage.totalTokens"],
|
|
346
|
+
cache_read_tokens: ["ai.usage.cachedInputTokens", "ai.usage.inputTokenDetails.cacheReadTokens"],
|
|
347
|
+
reasoning_tokens: ["ai.usage.reasoningTokens", "ai.usage.outputTokenDetails.reasoningTokens"],
|
|
348
|
+
};
|
|
349
|
+
for (const attrKey of tokenAttrMap[key] || []) {
|
|
350
|
+
if (usage[attrKey] !== undefined) return usage[attrKey];
|
|
351
|
+
if (attrs[attrKey] !== undefined) return attrs[attrKey];
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
354
|
return undefined;
|
|
355
355
|
}
|
|
356
356
|
|
|
@@ -369,18 +369,18 @@ function metricInteractionId(item, target) {
|
|
|
369
369
|
: metadataValue(item, "interaction_id");
|
|
370
370
|
}
|
|
371
371
|
|
|
372
|
-
function isAgentTurnObservation(item) {
|
|
373
|
-
const metadata = item?.metadata || {};
|
|
374
|
-
const attrs = metadata.attributes || {};
|
|
375
|
-
const isOpencode = metadata.source === "opencode" || attrs["oh.langfuse.source"] === "opencode";
|
|
376
|
-
if (/^(Claude|OpenCode|Codex) Agent Turn$/.test(String(item?.name || "")) || item?.name === "Agent Turn" || (!isOpencode && (metadataValue(item, "interaction_count") === 1 || metadataValue(item, "interaction_count") === "1"))) {
|
|
377
|
-
return true;
|
|
378
|
-
}
|
|
379
|
-
if (isOpencode && OPENCODE_NATIVE_AGENT_TURN_NAMES.has(String(item?.name || ""))) return true;
|
|
380
|
-
const hasModel = attrs["ai.model.id"] !== undefined || attrs["ai.model.provider"] !== undefined;
|
|
381
|
-
const isTool = attrs["ai.toolCall.name"] !== undefined || attrs["ai.toolCall.id"] !== undefined;
|
|
382
|
-
return isOpencode && hasModel && !isTool && item?.type === "GENERATION";
|
|
383
|
-
}
|
|
372
|
+
function isAgentTurnObservation(item) {
|
|
373
|
+
const metadata = item?.metadata || {};
|
|
374
|
+
const attrs = metadata.attributes || {};
|
|
375
|
+
const isOpencode = metadata.source === "opencode" || attrs["oh.langfuse.source"] === "opencode";
|
|
376
|
+
if (/^(Claude|OpenCode|Codex) Agent Turn$/.test(String(item?.name || "")) || item?.name === "Agent Turn" || (!isOpencode && (metadataValue(item, "interaction_count") === 1 || metadataValue(item, "interaction_count") === "1"))) {
|
|
377
|
+
return true;
|
|
378
|
+
}
|
|
379
|
+
if (isOpencode && OPENCODE_NATIVE_AGENT_TURN_NAMES.has(String(item?.name || ""))) return true;
|
|
380
|
+
const hasModel = attrs["ai.model.id"] !== undefined || attrs["ai.model.provider"] !== undefined;
|
|
381
|
+
const isTool = attrs["ai.toolCall.name"] !== undefined || attrs["ai.toolCall.id"] !== undefined;
|
|
382
|
+
return isOpencode && hasModel && !isTool && item?.type === "GENERATION";
|
|
383
|
+
}
|
|
384
384
|
|
|
385
385
|
function expectedAgentTurnName(target) {
|
|
386
386
|
if (target === "claude") return "Claude Agent Turn";
|
|
@@ -389,32 +389,32 @@ function expectedAgentTurnName(target) {
|
|
|
389
389
|
return "Agent Turn";
|
|
390
390
|
}
|
|
391
391
|
|
|
392
|
-
async function observationsForTrace(config, traceId, since) {
|
|
393
|
-
if (!traceId) return [];
|
|
394
|
-
const params = { limit: 100, fields: "core,basic,usage", traceId };
|
|
395
|
-
try {
|
|
396
|
-
return dataArray(await langfuseGet(config, "/v2/observations", params));
|
|
397
|
-
} catch (error) {
|
|
398
|
-
if (error.status !== 400 && error.status !== 404) throw error;
|
|
399
|
-
}
|
|
400
|
-
try {
|
|
401
|
-
return dataArray(await langfuseGet(config, "/observations", { limit: 100, traceId }));
|
|
402
|
-
} catch (error) {
|
|
403
|
-
if (error.status !== 400) throw error;
|
|
404
|
-
}
|
|
392
|
+
async function observationsForTrace(config, traceId, since) {
|
|
393
|
+
if (!traceId) return [];
|
|
394
|
+
const params = { limit: 100, fields: "core,basic,usage", traceId };
|
|
395
|
+
try {
|
|
396
|
+
return dataArray(await langfuseGet(config, "/v2/observations", params));
|
|
397
|
+
} catch (error) {
|
|
398
|
+
if (error.status !== 400 && error.status !== 404) throw error;
|
|
399
|
+
}
|
|
400
|
+
try {
|
|
401
|
+
return dataArray(await langfuseGet(config, "/observations", { limit: 100, traceId }));
|
|
402
|
+
} catch (error) {
|
|
403
|
+
if (error.status !== 400) throw error;
|
|
404
|
+
}
|
|
405
405
|
const fallback = dataArray(await langfuseGetLenient(config, "/observations", { limit: 100, fromTimestamp: since.toISOString() }));
|
|
406
406
|
return fallback.filter((item) => item.traceId === traceId || item.trace_id === traceId);
|
|
407
407
|
}
|
|
408
408
|
|
|
409
409
|
function mergeMetricCandidates(items) {
|
|
410
410
|
const out = [];
|
|
411
|
-
const seen = new Set();
|
|
412
|
-
for (const item of items || []) {
|
|
413
|
-
if (!item || typeof item !== "object") continue;
|
|
414
|
-
const key = idOf(item) || [item.name || "", item.traceId || item.trace_id || ""].join(":");
|
|
415
|
-
if (seen.has(key)) continue;
|
|
416
|
-
seen.add(key);
|
|
417
|
-
out.push(item);
|
|
411
|
+
const seen = new Set();
|
|
412
|
+
for (const item of items || []) {
|
|
413
|
+
if (!item || typeof item !== "object") continue;
|
|
414
|
+
const key = idOf(item) || [item.name || "", item.traceId || item.trace_id || ""].join(":");
|
|
415
|
+
if (seen.has(key)) continue;
|
|
416
|
+
seen.add(key);
|
|
417
|
+
out.push(item);
|
|
418
418
|
}
|
|
419
419
|
return out;
|
|
420
420
|
}
|
|
@@ -464,13 +464,13 @@ async function verifyMetricObservations(config, found, { since, target, marker =
|
|
|
464
464
|
|
|
465
465
|
if (!interactions.length) {
|
|
466
466
|
throw new Error(`Metric verification failed for ${target}: Agent Turn observation was not found for trace ${traceId || found.id}.`);
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
const expectedName = expectedAgentTurnName(target);
|
|
470
|
-
if (target !== "opencode" && !interactions.some((item) => item?.name === expectedName)) {
|
|
471
|
-
const names = interactions.map((item) => item?.name || "<unnamed>").join(", ");
|
|
472
|
-
throw new Error(`Metric verification failed for ${target}: expected observation name ${expectedName}, found ${names}.`);
|
|
473
|
-
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const expectedName = expectedAgentTurnName(target);
|
|
470
|
+
if (target !== "opencode" && !interactions.some((item) => item?.name === expectedName)) {
|
|
471
|
+
const names = interactions.map((item) => item?.name || "<unnamed>").join(", ");
|
|
472
|
+
throw new Error(`Metric verification failed for ${target}: expected observation name ${expectedName}, found ${names}.`);
|
|
473
|
+
}
|
|
474
474
|
|
|
475
475
|
const byInteractionId = new Map();
|
|
476
476
|
const seenInteractionIds = new Set();
|
|
@@ -487,13 +487,33 @@ async function verifyMetricObservations(config, found, { since, target, marker =
|
|
|
487
487
|
throw new Error(`Metric verification failed for ${target}: Agent Turn is missing ${key}.`);
|
|
488
488
|
}
|
|
489
489
|
}
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
490
|
+
const repoContextKeys = [
|
|
491
|
+
"cwd",
|
|
492
|
+
"cwd_source",
|
|
493
|
+
"git_context_available",
|
|
494
|
+
"git_context_error",
|
|
495
|
+
"is_git_repo",
|
|
496
|
+
"git_root",
|
|
497
|
+
"git_remote",
|
|
498
|
+
"git_remote_host",
|
|
499
|
+
"git_repo_owner",
|
|
500
|
+
"git_repo_name",
|
|
501
|
+
"git_repo_slug",
|
|
502
|
+
"git_branch",
|
|
503
|
+
"git_commit",
|
|
504
|
+
];
|
|
505
|
+
for (const key of repoContextKeys) {
|
|
506
|
+
if (!hasMetadataKey(item, key)) {
|
|
507
|
+
throw new Error(`Metric verification failed for ${target}: Agent Turn is missing repo context ${key}.`);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
if (target === "opencode") {
|
|
511
|
+
for (const key of ["interaction_id", "interaction_count", "token_metrics_available", "tool_call_count", "skill_use_count", "input_tokens", "output_tokens", "total_tokens"]) {
|
|
512
|
+
if (metadataValue(item, key) === undefined) {
|
|
513
|
+
throw new Error(`Metric verification failed for ${target}: effective metadata is missing ${key}.`);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
}
|
|
497
517
|
const tokenAvailable = metadataValue(item, "token_metrics_available");
|
|
498
518
|
for (const tokenKey of ["input_tokens", "output_tokens", "total_tokens"]) {
|
|
499
519
|
const value = metadataValue(item, tokenKey);
|