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 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
@@ -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
- "cwd": meta.get("cwd"),
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oh-langfuse",
3
- "version": "0.1.64",
3
+ "version": "0.1.66",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Use npm scripts to configure Claude Code / OpenCode / Codex with Langfuse tracing.",
@@ -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(`call ${cmdQuote(realOpencodeCli)} %*`);
835
- cmd.push("exit /b %ERRORLEVEL%");
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 lines = [
842
- "#!/usr/bin/env sh",
843
- "# Auto-generated by scripts/opencode-langfuse-setup.mjs",
844
- "set -eu",
845
- "export OH_LANGFUSE_OPENCODE_SHIM=1",
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
- `exec ${shQuote(realOpencodeCli)} "$@"`,
852
- ""
853
- ].filter(Boolean);
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
- if (target === "opencode") {
491
- for (const key of ["interaction_id", "interaction_count", "token_metrics_available", "tool_call_count", "skill_use_count", "input_tokens", "output_tokens", "total_tokens"]) {
492
- if (metadataValue(item, key) === undefined) {
493
- throw new Error(`Metric verification failed for ${target}: effective metadata is missing ${key}.`);
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);