coze_lab 0.1.16 → 0.1.18

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/index.js CHANGED
@@ -4572,8 +4572,14 @@ function writeCodexHook(token, workspaceId, pythonCmd, codexHome, cloud) {
4572
4572
  if (!cloud) {
4573
4573
  envLines.push(`COZELOOP_API_TOKEN=${token}`);
4574
4574
  }
4575
+ envLines.push(`CODEX_HOME=${home}`);
4575
4576
  envLines.push(`COZELOOP_HOOK_LOG=${logFile}`);
4576
4577
  envLines.push('TRACE_TO_COZELOOP=true');
4578
+ if (process.env.COZELOOP_API_BASE_URL) {
4579
+ envLines.push(`COZELOOP_API_BASE_URL=${process.env.COZELOOP_API_BASE_URL}`);
4580
+ } else if (process.env.OTEL_ENDPOINT) {
4581
+ envLines.push(`OTEL_ENDPOINT=${process.env.OTEL_ENDPOINT}`);
4582
+ }
4577
4583
  // PPE 泳道:cozeloop SDK 读这两个环境变量,自动注入 x-tt-env / x-use-ppe header
4578
4584
  envLines.push(`x_tt_env=${PPE_TT_ENV}`);
4579
4585
  envLines.push(`x_use_ppe=${PPE_USE_PPE}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coze_lab",
3
- "version": "0.1.16",
3
+ "version": "0.1.18",
4
4
  "description": "Configure local AI agents (Claude Code, Codex, OpenClaw) to report traces to CozeLoop",
5
5
  "keywords": [
6
6
  "cozeloop",
@@ -46,6 +46,7 @@ _COZELOOP_CLIENT_ID = "46371084383473718052118955183420.app.coze"
46
46
  _COZE_API = "https://api.coze.cn"
47
47
  _REFRESH_THRESHOLD = 10 * 60
48
48
  _DEFAULT_WORKSPACE_ID = "7644910356078837760" # hardcoded spaceID fallback
49
+ _OTEL_SUFFIX = "/v1/loop/opentelemetry"
49
50
 
50
51
 
51
52
  # --- coze-context parsing -------------------------------------------------
@@ -98,6 +99,22 @@ def coze_context_tags(text: str) -> Dict[str, str]:
98
99
  return {f"coze_{k}": v for k, v in parse_coze_context(text).items()}
99
100
 
100
101
 
102
+ def turn_coze_context(turn: Dict[str, Any]) -> Dict[str, str]:
103
+ """Extract coze-context from a grouped turn with fallbacks for Codex rollout shapes."""
104
+ texts = [turn.get("user_message_text", "")]
105
+ for msg in turn.get("input_messages", []):
106
+ if isinstance(msg, dict):
107
+ texts.append(str(msg.get("content", "")))
108
+ user_payload = turn.get("user_message")
109
+ if isinstance(user_payload, dict):
110
+ texts.append(extract_message_content_text(user_payload))
111
+ for text in texts:
112
+ ctx = parse_coze_context(text)
113
+ if ctx:
114
+ return ctx
115
+ return {}
116
+
117
+
101
118
  # --- trace upload failure / logid capture ---------------------------------
102
119
  def _extract_logid(msg: str) -> str:
103
120
  """Pull the server logid out of an SDK error message, if present.
@@ -190,6 +207,20 @@ def _refresh_token(refresh_tok: str):
190
207
  pass
191
208
  return None
192
209
 
210
+
211
+ def _normalize_api_base_url(url: str) -> str:
212
+ base = (url or "").strip().rstrip("/")
213
+ if base.endswith(_OTEL_SUFFIX):
214
+ return base[:-len(_OTEL_SUFFIX)].rstrip("/")
215
+ return base
216
+
217
+
218
+ def get_api_base_url() -> str:
219
+ return _normalize_api_base_url(
220
+ os.environ.get("COZELOOP_API_BASE_URL", "") or os.environ.get("OTEL_ENDPOINT", "")
221
+ )
222
+
223
+
193
224
  def get_fresh_token():
194
225
  creds = _load_credentials()
195
226
  if creds:
@@ -200,8 +231,14 @@ def get_fresh_token():
200
231
  new_token = _refresh_token(creds["refresh_token"])
201
232
  if new_token:
202
233
  return new_token
203
- # Cloud sandbox: token lives in COZE_API_TOKEN (no credentials.json / refresh).
204
- return os.environ.get("COZELOOP_API_TOKEN") or os.environ.get("COZE_API_TOKEN")
234
+ token = os.environ.get("COZELOOP_API_TOKEN")
235
+ if token:
236
+ return token
237
+ if get_api_base_url():
238
+ return os.environ.get("COZE_API_TOKEN")
239
+ if os.environ.get("COZE_API_TOKEN"):
240
+ hook_log("COZE_API_TOKEN present but ignored without COZELOOP_API_BASE_URL or OTEL_ENDPOINT")
241
+ return None
205
242
  # -------------------------------------------------------------------------
206
243
 
207
244
  # --- SDK Import ---
@@ -366,6 +403,101 @@ def read_rollout_messages(transcript_path: str, start_line: int = 0) -> List[Dic
366
403
  return entries
367
404
 
368
405
 
406
+ def _add_unique_path(paths: List[Path], p: Optional[Path]):
407
+ if not p:
408
+ return
409
+ try:
410
+ resolved = p.expanduser().resolve()
411
+ except Exception:
412
+ return
413
+ if resolved not in paths:
414
+ paths.append(resolved)
415
+
416
+
417
+ def _candidate_codex_homes() -> List[Path]:
418
+ homes: List[Path] = []
419
+ _add_unique_path(homes, Path(os.environ["CODEX_HOME"]) if os.environ.get("CODEX_HOME") else None)
420
+
421
+ log_path = _log_file_path()
422
+ if log_path:
423
+ try:
424
+ log_parent = Path(log_path).expanduser().resolve().parent
425
+ if log_parent.name == "hooks":
426
+ _add_unique_path(homes, log_parent.parent)
427
+ except Exception:
428
+ pass
429
+
430
+ _add_unique_path(homes, Path.home() / ".codex")
431
+
432
+ agents_root = Path.home() / ".coze" / "agents"
433
+ try:
434
+ if agents_root.exists():
435
+ for child in agents_root.iterdir():
436
+ _add_unique_path(homes, child / "codex-home")
437
+ except Exception:
438
+ pass
439
+
440
+ return homes
441
+
442
+
443
+ def _latest_file(paths: List[Path]) -> Optional[Path]:
444
+ existing = []
445
+ for p in paths:
446
+ try:
447
+ if p.is_file():
448
+ existing.append(p)
449
+ except Exception:
450
+ pass
451
+ if not existing:
452
+ return None
453
+ return max(existing, key=lambda p: p.stat().st_mtime)
454
+
455
+
456
+ def find_latest_transcript() -> Optional[str]:
457
+ """Best-effort fallback when Codex does not pass hook stdin."""
458
+ candidates: List[Path] = []
459
+ for codex_home in _candidate_codex_homes():
460
+ sessions_dir = codex_home / "sessions"
461
+ if not sessions_dir.exists():
462
+ continue
463
+ try:
464
+ candidates.extend(sessions_dir.rglob("rollout-*.jsonl"))
465
+ except Exception as e:
466
+ hook_log(f"fallback scan failed dir={sessions_dir} error={repr(e)}")
467
+
468
+ latest = _latest_file(candidates)
469
+ if latest:
470
+ return str(latest)
471
+
472
+ for codex_home in _candidate_codex_homes():
473
+ sessions_dir = codex_home / "sessions"
474
+ if not sessions_dir.exists():
475
+ continue
476
+ try:
477
+ candidates.extend(sessions_dir.rglob("*.jsonl"))
478
+ except Exception:
479
+ pass
480
+
481
+ latest = _latest_file(candidates)
482
+ return str(latest) if latest else None
483
+
484
+
485
+ def recover_hook_input(reason: str) -> Optional[Dict[str, Any]]:
486
+ transcript_path = find_latest_transcript()
487
+ if not transcript_path:
488
+ hook_log(f"fallback failed reason={reason} no transcript found")
489
+ print(f"[CozeLoop] Hook input missing ({reason}); no Codex transcript found.", file=sys.stderr)
490
+ return None
491
+ hook_log(f"fallback transcript reason={reason} path={transcript_path}")
492
+ print(f"[CozeLoop] Hook input missing ({reason}); fallback transcript: {transcript_path}", file=sys.stderr)
493
+ return {
494
+ "hook_event_name": "Stop",
495
+ "session_id": "",
496
+ "transcript_path": transcript_path,
497
+ "input_fallback": reason,
498
+ }
499
+
500
+
369
501
  def parse_session_meta(entries: List[Dict[str, Any]]) -> Dict[str, Any]:
370
502
  """Extract session identity from session_meta entry."""
371
503
  result = {
@@ -421,6 +553,8 @@ def is_real_user_message(payload: Dict[str, Any]) -> bool:
421
553
  if item.get("type") != "input_text":
422
554
  continue
423
555
  text = item.get("text", "")
556
+ if parse_coze_context(text):
557
+ return True
424
558
  if text.startswith("<environment_context>"):
425
559
  continue
426
560
  if text.startswith("<permissions instructions>"):
@@ -439,9 +573,10 @@ def extract_user_text(payload: Dict[str, Any]) -> str:
439
573
  for item in payload.get("content", []):
440
574
  if isinstance(item, dict) and item.get("type") == "input_text":
441
575
  text = item.get("text", "")
442
- if (not text.startswith("<environment_context>") and
576
+ if (parse_coze_context(text) or
577
+ (not text.startswith("<environment_context>") and
443
578
  not text.startswith("<permissions instructions>") and
444
- not text.startswith("<turn_aborted>")):
579
+ not text.startswith("<turn_aborted>"))):
445
580
  parts.append(text)
446
581
  return "\n".join(parts)
447
582
 
@@ -726,6 +861,10 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
726
861
  "upload_timeout": 120,
727
862
  "trace_finish_event_processor": _make_finish_event_processor(),
728
863
  }
864
+ api_base_url = get_api_base_url()
865
+ if api_base_url:
866
+ client_kwargs["api_base_url"] = api_base_url
867
+ hook_log(f"api_base_url={api_base_url}")
729
868
  if workspace_id:
730
869
  client_kwargs["workspace_id"] = workspace_id
731
870
  if token:
@@ -764,7 +903,7 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
764
903
  # Inject coze-context kv (last occurrence across turns wins).
765
904
  coze_tags = {}
766
905
  for turn in turns:
767
- t = coze_context_tags(turn.get("user_message_text", ""))
906
+ t = {f"coze_{k}": v for k, v in turn_coze_context(turn).items()}
768
907
  if t:
769
908
  coze_tags = t
770
909
  if coze_tags:
@@ -1076,28 +1215,41 @@ def main():
1076
1215
  try:
1077
1216
  raw_input = sys.stdin.read().strip()
1078
1217
  if not raw_input:
1079
- hook_log("skip no stdin")
1218
+ hook_log("stdin empty, trying fallback")
1080
1219
  debug_log("No input received from stdin")
1081
- return
1082
- hook_input = json.loads(raw_input)
1220
+ hook_input = recover_hook_input("empty_stdin")
1221
+ if not hook_input:
1222
+ return
1223
+ else:
1224
+ hook_input = json.loads(raw_input)
1083
1225
  except Exception as e:
1084
- hook_log(f"skip stdin parse error={repr(e)}")
1226
+ hook_log(f"stdin parse error={repr(e)}, trying fallback")
1085
1227
  debug_log(f"Error reading hook input from stdin: {e}")
1086
- return
1228
+ hook_input = recover_hook_input("stdin_parse_error")
1229
+ if not hook_input:
1230
+ return
1087
1231
 
1088
1232
  debug_log(f"Hook input: {json.dumps(hook_input, ensure_ascii=False)}")
1089
1233
 
1090
1234
  # Get transcript path
1091
1235
  transcript_path = hook_input.get("transcript_path")
1092
1236
  if not transcript_path:
1093
- hook_log("skip missing transcript_path")
1237
+ hook_log("missing transcript_path, trying fallback")
1094
1238
  debug_log("No transcript_path in hook input")
1095
- return
1239
+ recovered = recover_hook_input("missing_transcript_path")
1240
+ if not recovered:
1241
+ return
1242
+ hook_input = recovered
1243
+ transcript_path = hook_input.get("transcript_path")
1096
1244
 
1097
1245
  if not os.path.exists(transcript_path):
1098
- hook_log(f"skip transcript not found path={transcript_path}")
1246
+ hook_log(f"transcript not found path={transcript_path}, trying fallback")
1099
1247
  debug_log(f"Transcript file not found: {transcript_path}")
1100
- return
1248
+ recovered = recover_hook_input("transcript_not_found")
1249
+ if not recovered:
1250
+ return
1251
+ hook_input = recovered
1252
+ transcript_path = hook_input.get("transcript_path")
1101
1253
 
1102
1254
  # Load state
1103
1255
  state_file = get_state_file_path(transcript_path)
@@ -1168,7 +1320,7 @@ def main():
1168
1320
  # Send turns to CozeLoop — only if at least one turn carries coze-context.
1169
1321
  if turns:
1170
1322
  has_coze_ctx = any(
1171
- parse_coze_context(t.get("user_message_text", ""))
1323
+ turn_coze_context(t)
1172
1324
  for t in turns
1173
1325
  )
1174
1326
  if not has_coze_ctx:
@@ -22,7 +22,11 @@ function safeClone(value) {
22
22
  // --- coze-context parsing ---------------------------------------------------
23
23
  // User input may embed a <coze-context>...</coze-context> block with key:value
24
24
  // lines (agent_id, session_id, message_id, account_id, ...). Parse the LAST
25
- // block and inject the pairs (prefixed "coze.") into the root span attributes.
25
+ // block and inject the pairs (prefixed "coze_") into the root span attributes.
26
+ // NOTE: prefix MUST be "coze_" (underscore) to match the cozelab backend trace
27
+ // query field names (coze_message_id / coze_agent_id) and stay consistent with
28
+ // the claude-code / codex hooks. Do NOT use "coze." (dot) — backend Eq filter
29
+ // matches tag keys exactly and would never hit dotted keys.
26
30
  const COZE_CTX_OPEN = "<coze-context>";
27
31
  const COZE_CTX_CLOSE = "</coze-context>";
28
32
  function cozeInputToText(input) {
@@ -67,7 +71,7 @@ function parseCozeContext(input) {
67
71
  const key = line.slice(0, sep).trim();
68
72
  const value = line.slice(sep + 1).trim();
69
73
  if (key)
70
- out["coze." + key] = value;
74
+ out["coze_" + key] = value;
71
75
  }
72
76
  return out;
73
77
  }