coze_lab 0.1.44 → 0.1.46
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 +55 -59
- package/index.js +130 -414
- package/package.json +1 -2
- package/scripts/claude-code/cozeloop_hook.py +6 -244
- package/scripts/codex/cozeloop_hook.py +7 -371
- package/scripts/openclaw/dist/cozeloop-exporter.js +11 -183
- package/scripts/openclaw/dist/index.js +1 -1
- package/scripts/openclaw/openclaw.plugin.json +2 -2
- package/scripts/openclaw/package.json +1 -1
- package/scripts/shared/cozeloop_refresh.py +0 -57
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "coze_lab",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.46",
|
|
4
4
|
"description": "Configure local AI agents (Claude Code, Codex, OpenClaw) to report traces to CozeLoop",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cozeloop",
|
|
@@ -21,7 +21,6 @@
|
|
|
21
21
|
"index.js",
|
|
22
22
|
"scripts/claude-code/cozeloop_hook.py",
|
|
23
23
|
"scripts/codex/cozeloop_hook.py",
|
|
24
|
-
"scripts/shared/cozeloop_refresh.py",
|
|
25
24
|
"scripts/openclaw/dist/",
|
|
26
25
|
"scripts/openclaw/openclaw.plugin.json",
|
|
27
26
|
"scripts/openclaw/package.json"
|
|
@@ -22,8 +22,6 @@ import sys
|
|
|
22
22
|
import glob
|
|
23
23
|
import hashlib
|
|
24
24
|
import time
|
|
25
|
-
import urllib.request
|
|
26
|
-
import urllib.error
|
|
27
25
|
from datetime import datetime
|
|
28
26
|
from pathlib import Path
|
|
29
27
|
from typing import Optional, List, Dict, Any
|
|
@@ -105,12 +103,7 @@ else:
|
|
|
105
103
|
|
|
106
104
|
# --- Configuration ---
|
|
107
105
|
DEBUG = os.environ.get("CC_COZELOOP_DEBUG", "").lower() == "true"
|
|
108
|
-
_COZELOOP_CLIENT_ID = "08972682140163281554629748278108.app.coze"
|
|
109
|
-
_COZE_API = "https://api.coze.cn"
|
|
110
106
|
_OTEL_SUFFIX = "/v1/loop/opentelemetry"
|
|
111
|
-
_REFRESH_THRESHOLD = 10 * 60 # refresh when < 10 minutes remain
|
|
112
|
-
_FORCE_REFRESH_COOLDOWN_MS = 60 * 1000
|
|
113
|
-
_REFRESH_LOCK_STALE = 30
|
|
114
107
|
_DEFAULT_WORKSPACE_ID = "7649231955045072915" # hardcoded spaceID fallback
|
|
115
108
|
|
|
116
109
|
|
|
@@ -256,235 +249,17 @@ def debug_log(message: str):
|
|
|
256
249
|
if DEBUG:
|
|
257
250
|
print(f"[COZELOOP_HOOK_DEBUG] {datetime.now().isoformat()} - {message}", file=sys.stderr)
|
|
258
251
|
|
|
259
|
-
# --- Token
|
|
260
|
-
|
|
261
|
-
def _get_credentials_path() -> Path:
|
|
262
|
-
return Path.home() / ".cozeloop" / "credentials.json"
|
|
263
|
-
|
|
264
|
-
def _get_refresh_state_path() -> Path:
|
|
265
|
-
return Path.home() / ".cozeloop" / "refresh-state.json"
|
|
266
|
-
|
|
267
|
-
def _get_refresh_lock_path() -> Path:
|
|
268
|
-
return Path.home() / ".cozeloop" / "refresh-state.lock"
|
|
269
|
-
|
|
270
|
-
def _load_credentials() -> Optional[Dict]:
|
|
271
|
-
path = _get_credentials_path()
|
|
272
|
-
if not path.exists():
|
|
273
|
-
return None
|
|
274
|
-
try:
|
|
275
|
-
return json.loads(path.read_text())
|
|
276
|
-
except Exception:
|
|
277
|
-
return None
|
|
278
|
-
|
|
279
|
-
def _save_credentials(creds: Dict):
|
|
280
|
-
path = _get_credentials_path()
|
|
281
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
|
282
|
-
path.write_text(json.dumps(creds, indent=2))
|
|
283
|
-
os.chmod(path, 0o600)
|
|
284
|
-
|
|
285
|
-
def _load_refresh_state() -> Dict[str, Any]:
|
|
286
|
-
path = _get_refresh_state_path()
|
|
287
|
-
if not path.exists():
|
|
288
|
-
return {}
|
|
289
|
-
try:
|
|
290
|
-
return json.loads(path.read_text())
|
|
291
|
-
except Exception:
|
|
292
|
-
return {}
|
|
293
|
-
|
|
294
|
-
def _save_refresh_state(patch: Dict[str, Any]):
|
|
295
|
-
path = _get_refresh_state_path()
|
|
296
|
-
try:
|
|
297
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
|
298
|
-
state = _load_refresh_state()
|
|
299
|
-
state.update(patch)
|
|
300
|
-
path.write_text(json.dumps(state, indent=2))
|
|
301
|
-
os.chmod(path, 0o600)
|
|
302
|
-
except Exception:
|
|
303
|
-
pass
|
|
304
|
-
|
|
305
|
-
def _with_refresh_lock(fn):
|
|
306
|
-
lock_path = _get_refresh_lock_path()
|
|
307
|
-
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
308
|
-
for _ in range(24):
|
|
309
|
-
fd = None
|
|
310
|
-
try:
|
|
311
|
-
fd = os.open(str(lock_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
|
312
|
-
try:
|
|
313
|
-
return fn()
|
|
314
|
-
finally:
|
|
315
|
-
try:
|
|
316
|
-
os.close(fd)
|
|
317
|
-
except Exception:
|
|
318
|
-
pass
|
|
319
|
-
try:
|
|
320
|
-
lock_path.unlink()
|
|
321
|
-
except Exception:
|
|
322
|
-
pass
|
|
323
|
-
except FileExistsError:
|
|
324
|
-
try:
|
|
325
|
-
if time.time() - lock_path.stat().st_mtime > _REFRESH_LOCK_STALE:
|
|
326
|
-
lock_path.unlink()
|
|
327
|
-
continue
|
|
328
|
-
except Exception:
|
|
329
|
-
pass
|
|
330
|
-
time.sleep(0.25)
|
|
331
|
-
hook_log("forced refresh skipped because refresh lock is busy")
|
|
332
|
-
return None
|
|
333
|
-
|
|
334
|
-
def _refresh_token(refresh_token: str) -> Optional[str]:
|
|
335
|
-
"""Call Coze refresh token API. Returns new access_token or None on failure."""
|
|
336
|
-
try:
|
|
337
|
-
payload = json.dumps({
|
|
338
|
-
"grant_type": "refresh_token",
|
|
339
|
-
"client_id": _COZELOOP_CLIENT_ID,
|
|
340
|
-
"refresh_token": refresh_token,
|
|
341
|
-
}).encode()
|
|
342
|
-
req = urllib.request.Request(
|
|
343
|
-
f"{_COZE_API}/api/permission/oauth2/token",
|
|
344
|
-
data=payload,
|
|
345
|
-
headers={
|
|
346
|
-
"Content-Type": "application/json",
|
|
347
|
-
},
|
|
348
|
-
)
|
|
349
|
-
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
350
|
-
data = json.loads(resp.read())
|
|
351
|
-
if data.get("access_token"):
|
|
352
|
-
existing = _load_credentials() or {}
|
|
353
|
-
creds = {
|
|
354
|
-
"access_token": data["access_token"],
|
|
355
|
-
"refresh_token": data.get("refresh_token", refresh_token),
|
|
356
|
-
"expires_at": data.get("expires_in", 0) * 1000, # unix timestamp in seconds
|
|
357
|
-
"workspace_id": existing.get("workspace_id", ""),
|
|
358
|
-
}
|
|
359
|
-
_save_credentials(creds)
|
|
360
|
-
debug_log("Token refreshed successfully.")
|
|
361
|
-
return creds["access_token"]
|
|
362
|
-
except Exception as e:
|
|
363
|
-
debug_log(f"Token refresh failed: {e}")
|
|
364
|
-
return None
|
|
365
|
-
|
|
366
|
-
def force_refresh_token_after_upload_failure(reason: str = "upload_failure", current_token: Optional[str] = None) -> Optional[str]:
|
|
367
|
-
"""Force-refresh local credentials after an upload failure, with cross-process throttling."""
|
|
368
|
-
is_cloud = os.environ.get("COZELAB_ONBOARD_CLOUD", "").lower() in ("1", "true", "yes")
|
|
369
|
-
if is_cloud:
|
|
370
|
-
hook_log("upload failure token refresh skipped in cloud mode")
|
|
371
|
-
return None
|
|
372
|
-
if os.environ.get("COZELOOP_TOKEN_SOURCE") == "agent_config.patToken":
|
|
373
|
-
hook_log("upload failure token refresh skipped for agent config patToken")
|
|
374
|
-
return None
|
|
375
|
-
|
|
376
|
-
creds = _load_credentials()
|
|
377
|
-
if not creds or not creds.get("refresh_token"):
|
|
378
|
-
hook_log("upload failure token refresh skipped; no local refresh_token")
|
|
379
|
-
return None
|
|
380
|
-
|
|
381
|
-
cached = creds.get("access_token")
|
|
382
|
-
now_ms = int(time.time() * 1000)
|
|
383
|
-
last_ms = int(_load_refresh_state().get("last_forced_refresh_ms", 0) or 0)
|
|
384
|
-
if last_ms and now_ms - last_ms < _FORCE_REFRESH_COOLDOWN_MS:
|
|
385
|
-
if cached and cached != current_token:
|
|
386
|
-
hook_log("forced refresh throttled; reuse newer cached token")
|
|
387
|
-
return cached
|
|
388
|
-
hook_log(f"forced refresh throttled ageMs={now_ms - last_ms}")
|
|
389
|
-
return None
|
|
390
|
-
|
|
391
|
-
def _locked_refresh():
|
|
392
|
-
locked_creds = _load_credentials()
|
|
393
|
-
if not locked_creds or not locked_creds.get("refresh_token"):
|
|
394
|
-
hook_log("forced refresh skipped in lock; credentials missing")
|
|
395
|
-
return None
|
|
396
|
-
locked_cached = locked_creds.get("access_token")
|
|
397
|
-
locked_now_ms = int(time.time() * 1000)
|
|
398
|
-
locked_last_ms = int(_load_refresh_state().get("last_forced_refresh_ms", 0) or 0)
|
|
399
|
-
if locked_last_ms and locked_now_ms - locked_last_ms < _FORCE_REFRESH_COOLDOWN_MS:
|
|
400
|
-
if locked_cached and locked_cached != current_token:
|
|
401
|
-
hook_log("forced refresh already done by another process; reuse token")
|
|
402
|
-
return locked_cached
|
|
403
|
-
hook_log(f"forced refresh skipped in lock ageMs={locked_now_ms - locked_last_ms}")
|
|
404
|
-
return None
|
|
405
|
-
_save_refresh_state({
|
|
406
|
-
"last_forced_refresh_ms": locked_now_ms,
|
|
407
|
-
"last_reason": (reason or "upload_failure")[:120],
|
|
408
|
-
})
|
|
409
|
-
hook_log(f"forced token refresh after upload failure reason={(reason or 'upload_failure')[:120]}")
|
|
410
|
-
refreshed = _refresh_token(locked_creds["refresh_token"])
|
|
411
|
-
if refreshed:
|
|
412
|
-
hook_log("forced token refresh OK")
|
|
413
|
-
else:
|
|
414
|
-
hook_log("forced token refresh FAILED")
|
|
415
|
-
return refreshed
|
|
416
|
-
|
|
417
|
-
return _with_refresh_lock(_locked_refresh)
|
|
418
|
-
|
|
419
|
-
def _normalize_api_base_url(url: str) -> str:
|
|
420
|
-
base = (url or "").strip().rstrip("/")
|
|
421
|
-
if not base:
|
|
422
|
-
return base
|
|
423
|
-
# 剥除已知 loop 路径后缀,还原纯 API base。
|
|
424
|
-
# 关键:含 /api 的变体必须连 /api 整体剥掉,否则残留 /api 会拼出 /api/v1/loop/... → 后端 400。
|
|
425
|
-
# 顺序:更长 / 带 /api 的在前,裸 /v1 最后,避免误匹配短后缀。
|
|
426
|
-
suffixes = (
|
|
427
|
-
"/api/v1/loop/opentelemetry/v1/traces",
|
|
428
|
-
_OTEL_SUFFIX + "/v1/traces",
|
|
429
|
-
"/api/v1/loop/opentelemetry",
|
|
430
|
-
_OTEL_SUFFIX,
|
|
431
|
-
"/api/v1",
|
|
432
|
-
"/v1",
|
|
433
|
-
)
|
|
434
|
-
for suffix in suffixes:
|
|
435
|
-
if base.endswith(suffix):
|
|
436
|
-
return base[:-len(suffix)].rstrip("/")
|
|
437
|
-
return base
|
|
438
|
-
|
|
439
|
-
def get_api_base_url() -> str:
|
|
440
|
-
return _normalize_api_base_url(
|
|
441
|
-
os.environ.get("COZELOOP_API_BASE_URL", "")
|
|
442
|
-
)
|
|
443
|
-
|
|
444
|
-
def _token_from_credentials() -> Optional[str]:
|
|
445
|
-
creds = _load_credentials()
|
|
446
|
-
if not creds:
|
|
447
|
-
return None
|
|
448
|
-
# expires_at 由 index.js 以毫秒存储;先减去当前毫秒时间再换算成秒,与 _REFRESH_THRESHOLD(秒) 比较。
|
|
449
|
-
remaining = (creds.get("expires_at", 0) - time.time() * 1000) / 1000
|
|
450
|
-
if remaining > _REFRESH_THRESHOLD:
|
|
451
|
-
debug_log(f"Cached token valid, expires in {int(remaining)}s.")
|
|
452
|
-
return creds.get("access_token")
|
|
453
|
-
if creds.get("refresh_token"):
|
|
454
|
-
debug_log(f"Token expiring in {int(remaining)}s, refreshing...")
|
|
455
|
-
new_token = _refresh_token(creds["refresh_token"])
|
|
456
|
-
if new_token:
|
|
457
|
-
return new_token
|
|
458
|
-
debug_log("Refresh failed.")
|
|
459
|
-
return None
|
|
460
|
-
|
|
252
|
+
# --- Token resolution -----------------------------------------------------
|
|
461
253
|
|
|
462
254
|
def get_fresh_token() -> Optional[str]:
|
|
463
|
-
"""Return
|
|
255
|
+
"""Return the token injected by onboard. Do not read local credentials."""
|
|
464
256
|
env_token = os.environ.get("COZELOOP_API_TOKEN")
|
|
465
257
|
env_coze_token = os.environ.get("COZE_API_TOKEN")
|
|
466
|
-
is_cloud = os.environ.get("COZELAB_ONBOARD_CLOUD", "").lower() in ("1", "true", "yes")
|
|
467
|
-
if os.environ.get("COZELOOP_TOKEN_SOURCE") == "agent_config.patToken" and env_token:
|
|
468
|
-
return env_token
|
|
469
|
-
if is_cloud:
|
|
470
|
-
return env_token or env_coze_token or _token_from_credentials()
|
|
471
|
-
|
|
472
|
-
# Local onboard used to write a short-lived access token into settings.local.json.
|
|
473
|
-
# If credentials exist but cannot be refreshed, do not fall back to that stale
|
|
474
|
-
# env token; failing closed keeps state from advancing on a known-bad token.
|
|
475
|
-
creds = _load_credentials()
|
|
476
|
-
if creds:
|
|
477
|
-
return _token_from_credentials()
|
|
478
|
-
if env_token and not env_coze_token:
|
|
479
|
-
token = _token_from_credentials()
|
|
480
|
-
if token:
|
|
481
|
-
return token
|
|
482
|
-
return env_token
|
|
483
258
|
if env_token:
|
|
484
259
|
return env_token
|
|
485
|
-
if
|
|
260
|
+
if os.environ.get("COZELAB_ONBOARD_CLOUD", "").lower() in ("1", "true", "yes"):
|
|
486
261
|
return env_coze_token
|
|
487
|
-
return
|
|
262
|
+
return None
|
|
488
263
|
|
|
489
264
|
# -------------------------------------------------------------------------
|
|
490
265
|
|
|
@@ -1089,7 +864,7 @@ def _build_history_messages(history_turns: List[Dict[str, Any]]) -> list:
|
|
|
1089
864
|
|
|
1090
865
|
# --- CozeLoop Trace Reporting ---
|
|
1091
866
|
|
|
1092
|
-
def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, history_turns: Optional[List[Dict[str, Any]]] = None,
|
|
867
|
+
def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, history_turns: Optional[List[Dict[str, Any]]] = None, coze_tags_override: Optional[Dict[str, str]] = None):
|
|
1093
868
|
"""Send conversation turns to CozeLoop.
|
|
1094
869
|
|
|
1095
870
|
Span hierarchy:
|
|
@@ -1112,14 +887,7 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, history
|
|
|
1112
887
|
print(f"[CozeLoop] Token 获取成功 ({token[:12]}...)", file=sys.stderr)
|
|
1113
888
|
else:
|
|
1114
889
|
print("[CozeLoop] 警告: 未找到有效 Token,上报可能失败", file=sys.stderr)
|
|
1115
|
-
|
|
1116
|
-
# 云端模式:sandbox 注入的 COZELOOP_WORKSPACE_ID 必须优先于 credentials.json。
|
|
1117
|
-
# 否则残留的本地 credentials.json workspace_id 会覆盖云端注入,导致 trace 上报错位。
|
|
1118
|
-
is_cloud = os.environ.get("COZELAB_ONBOARD_CLOUD", "").lower() in ("1", "true", "yes")
|
|
1119
|
-
if is_cloud:
|
|
1120
|
-
workspace_id = os.environ.get("COZELOOP_WORKSPACE_ID", "") or (creds or {}).get("workspace_id") or _DEFAULT_WORKSPACE_ID
|
|
1121
|
-
else:
|
|
1122
|
-
workspace_id = (creds or {}).get("workspace_id") or os.environ.get("COZELOOP_WORKSPACE_ID", "") or _DEFAULT_WORKSPACE_ID
|
|
890
|
+
workspace_id = os.environ.get("COZELOOP_WORKSPACE_ID", "") or _DEFAULT_WORKSPACE_ID
|
|
1123
891
|
os.environ["COZELOOP_WORKSPACE_ID"] = workspace_id
|
|
1124
892
|
upload_events: List[str] = []
|
|
1125
893
|
client_kwargs = {
|
|
@@ -1622,12 +1390,6 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, history
|
|
|
1622
1390
|
|
|
1623
1391
|
if upload_events:
|
|
1624
1392
|
debug_log(f"Upload failed, state not advanced. Last failure: {upload_events[-1][:500]}")
|
|
1625
|
-
if retry_on_auth_failure:
|
|
1626
|
-
new_token = force_refresh_token_after_upload_failure(upload_events[-1], token)
|
|
1627
|
-
if new_token:
|
|
1628
|
-
os.environ["COZELOOP_API_TOKEN"] = new_token
|
|
1629
|
-
hook_log("retry upload once after forced token refresh")
|
|
1630
|
-
return send_turns_to_cozeloop(turns, session_id, history_turns, retry_on_auth_failure=False, coze_tags_override=coze_tags_override)
|
|
1631
1393
|
return None
|
|
1632
1394
|
|
|
1633
1395
|
return True
|