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 +6 -0
- package/package.json +1 -1
- package/scripts/codex/cozeloop_hook.py +167 -15
- package/scripts/openclaw/dist/index.js +6 -2
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
|
@@ -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
|
-
|
|
204
|
-
|
|
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 (
|
|
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 =
|
|
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("
|
|
1218
|
+
hook_log("stdin empty, trying fallback")
|
|
1080
1219
|
debug_log("No input received from stdin")
|
|
1081
|
-
|
|
1082
|
-
|
|
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"
|
|
1226
|
+
hook_log(f"stdin parse error={repr(e)}, trying fallback")
|
|
1085
1227
|
debug_log(f"Error reading hook input from stdin: {e}")
|
|
1086
|
-
|
|
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("
|
|
1237
|
+
hook_log("missing transcript_path, trying fallback")
|
|
1094
1238
|
debug_log("No transcript_path in hook input")
|
|
1095
|
-
|
|
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"
|
|
1246
|
+
hook_log(f"transcript not found path={transcript_path}, trying fallback")
|
|
1099
1247
|
debug_log(f"Transcript file not found: {transcript_path}")
|
|
1100
|
-
|
|
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
|
-
|
|
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 "
|
|
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["
|
|
74
|
+
out["coze_" + key] = value;
|
|
71
75
|
}
|
|
72
76
|
return out;
|
|
73
77
|
}
|