coze_lab 0.1.38 → 0.1.41
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 +150 -84
- package/package.json +1 -1
- package/scripts/claude-code/cozeloop_hook.py +96 -13
- package/scripts/codex/cozeloop_hook.py +101 -7
- package/scripts/openclaw/dist/cozeloop-exporter.js +19 -2
- package/scripts/openclaw/dist/index.js +43 -10
- package/scripts/shared/cozeloop_refresh.py +2 -1
package/index.js
CHANGED
|
@@ -495,14 +495,27 @@ function writeHookScript(dest, content) {
|
|
|
495
495
|
}
|
|
496
496
|
}
|
|
497
497
|
|
|
498
|
+
// atomicWriteFileSync 原子写文件:先写到同目录下的临时文件(带 pid),fsync 后 rename
|
|
499
|
+
// 覆盖目标。rename 在同一文件系统上是原子操作,避免读-改-写过程中崩溃留下半截/空文件。
|
|
500
|
+
function atomicWriteFileSync(filepath, data, options) {
|
|
501
|
+
const dir = path.dirname(filepath);
|
|
502
|
+
const tmp = path.join(dir, `.tmp.${process.pid}.${Date.now()}.${path.basename(filepath)}`);
|
|
503
|
+
try {
|
|
504
|
+
fs.writeFileSync(tmp, data, options);
|
|
505
|
+
fs.renameSync(tmp, filepath);
|
|
506
|
+
} catch (e) {
|
|
507
|
+
// 失败时清掉残留临时文件,保持目标文件原样(要么旧内容,要么不存在)。
|
|
508
|
+
try { fs.unlinkSync(tmp); } catch { /* ignore */ }
|
|
509
|
+
throw e;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
498
513
|
function mergeJson(filepath, mergeFn) {
|
|
499
514
|
let existing = {};
|
|
500
515
|
if (fs.existsSync(filepath)) {
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
warnBox([`Warning: Could not parse existing ${filepath}`, '', 'Starting with a fresh configuration.']);
|
|
505
|
-
}
|
|
516
|
+
// 解析失败时绝不退回空对象继续合并——那会丢掉文件里已有的 hook/凭证配置。
|
|
517
|
+
// 直接抛错,让调用方保留原文件内容、让用户感知到坏文件,而不是静默覆盖。
|
|
518
|
+
existing = JSON.parse(fs.readFileSync(filepath, 'utf8'));
|
|
506
519
|
}
|
|
507
520
|
return mergeFn(existing);
|
|
508
521
|
}
|
|
@@ -532,32 +545,46 @@ function writeClaudeCodeHook(patToken, workspaceId, pythonCmd, configBaseDir, cl
|
|
|
532
545
|
const refreshScript = path.join(hooksDir, 'cozeloop_refresh.py');
|
|
533
546
|
writeHookScript(refreshScript, readScript('shared/cozeloop_refresh.py'));
|
|
534
547
|
|
|
535
|
-
// 2. Merge settings.json — Stop (trace) + UserPromptSubmit (refresh)。用绝对路径。
|
|
548
|
+
// 2. Merge settings.json — Stop (trace 收尾) + PostToolUse (trace 增量) + UserPromptSubmit (refresh)。用绝对路径。
|
|
536
549
|
const hookCmd = `${pythonCmd} ${hookScript}`;
|
|
537
550
|
const refreshCmd = `${pythonCmd} ${refreshScript}`;
|
|
538
551
|
|
|
539
552
|
ensureDir(claudeDir);
|
|
540
|
-
|
|
541
|
-
if (!existing.hooks) existing.hooks = {};
|
|
542
|
-
|
|
543
|
-
// Stop hook — trace upload
|
|
544
|
-
if (!existing.hooks.Stop) existing.hooks.Stop = [];
|
|
545
|
-
existing.hooks.Stop = existing.hooks.Stop.filter(
|
|
546
|
-
entry => !entry.hooks?.some(h => h.command?.includes('cozeloop_hook.py'))
|
|
547
|
-
);
|
|
548
|
-
existing.hooks.Stop.push({ matcher: '', hooks: [{ type: 'command', command: hookCmd }] });
|
|
549
|
-
|
|
550
|
-
// UserPromptSubmit hook — token refresh before each user message
|
|
551
|
-
if (!existing.hooks.UserPromptSubmit) existing.hooks.UserPromptSubmit = [];
|
|
552
|
-
existing.hooks.UserPromptSubmit = existing.hooks.UserPromptSubmit.filter(
|
|
553
|
-
entry => !entry.hooks?.some(h => h.command?.includes('cozeloop_refresh.py'))
|
|
554
|
-
);
|
|
555
|
-
existing.hooks.UserPromptSubmit.push({ matcher: '', hooks: [{ type: 'command', command: refreshCmd }] });
|
|
556
|
-
|
|
557
|
-
return existing;
|
|
558
|
-
});
|
|
553
|
+
let settings;
|
|
559
554
|
try {
|
|
560
|
-
|
|
555
|
+
settings = mergeJson(settingsPath, (existing) => {
|
|
556
|
+
if (!existing.hooks) existing.hooks = {};
|
|
557
|
+
|
|
558
|
+
// Stop hook — trace upload(收尾,全量上报最后一批)
|
|
559
|
+
if (!existing.hooks.Stop) existing.hooks.Stop = [];
|
|
560
|
+
existing.hooks.Stop = existing.hooks.Stop.filter(
|
|
561
|
+
entry => !entry.hooks?.some(h => h.command?.includes('cozeloop_hook.py'))
|
|
562
|
+
);
|
|
563
|
+
existing.hooks.Stop.push({ matcher: '', hooks: [{ type: 'command', command: hookCmd }] });
|
|
564
|
+
|
|
565
|
+
// PostToolUse hook — trace 增量上报(任务进行中即上报已完成 turn,脚本内置节流+turn边界控制)。
|
|
566
|
+
// 没有它,长任务(单 turn 跑十几分钟)期间 Stop 不触发,trace 全程查不到。
|
|
567
|
+
if (!existing.hooks.PostToolUse) existing.hooks.PostToolUse = [];
|
|
568
|
+
existing.hooks.PostToolUse = existing.hooks.PostToolUse.filter(
|
|
569
|
+
entry => !entry.hooks?.some(h => h.command?.includes('cozeloop_hook.py'))
|
|
570
|
+
);
|
|
571
|
+
existing.hooks.PostToolUse.push({ matcher: '', hooks: [{ type: 'command', command: hookCmd }] });
|
|
572
|
+
|
|
573
|
+
// UserPromptSubmit hook — token refresh before each user message
|
|
574
|
+
if (!existing.hooks.UserPromptSubmit) existing.hooks.UserPromptSubmit = [];
|
|
575
|
+
existing.hooks.UserPromptSubmit = existing.hooks.UserPromptSubmit.filter(
|
|
576
|
+
entry => !entry.hooks?.some(h => h.command?.includes('cozeloop_refresh.py'))
|
|
577
|
+
);
|
|
578
|
+
existing.hooks.UserPromptSubmit.push({ matcher: '', hooks: [{ type: 'command', command: refreshCmd }] });
|
|
579
|
+
|
|
580
|
+
return existing;
|
|
581
|
+
});
|
|
582
|
+
} catch (e) {
|
|
583
|
+
// 现存 settings.json 解析失败:保留原文件,提示用户手动修复,绝不用空配置覆盖。
|
|
584
|
+
errorBox([`ERROR: Existing ${settingsPath} is not valid JSON`, '', e.message, '', '请修复或删除该文件后重试,避免覆盖丢失现有 hook 配置。']);
|
|
585
|
+
}
|
|
586
|
+
try {
|
|
587
|
+
atomicWriteFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
|
561
588
|
} catch (e) {
|
|
562
589
|
errorBox([`ERROR: Cannot write ${settingsPath}`, '', e.message]);
|
|
563
590
|
}
|
|
@@ -566,42 +593,47 @@ function writeClaudeCodeHook(patToken, workspaceId, pythonCmd, configBaseDir, cl
|
|
|
566
593
|
// 3. Write credentials into <baseDir>/.claude/settings.local.json
|
|
567
594
|
ensureDir(path.join(baseDir, '.claude'));
|
|
568
595
|
ensureDir(path.dirname(logFile));
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
existing.env.
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
596
|
+
let localSettings;
|
|
597
|
+
try {
|
|
598
|
+
localSettings = mergeJson(localSettingsPath, (existing) => {
|
|
599
|
+
if (!existing.env) existing.env = {};
|
|
600
|
+
existing.env.COZELOOP_WORKSPACE_ID = workspaceId;
|
|
601
|
+
existing.env.COZELOOP_HOOK_LOG = logFile;
|
|
602
|
+
if (cloud) {
|
|
603
|
+
const loopToken = readEnv('COZELOOP_API_TOKEN');
|
|
604
|
+
const cozeToken = readEnv('COZE_API_TOKEN');
|
|
605
|
+
existing.env.COZELAB_ONBOARD_CLOUD = '1';
|
|
606
|
+
if (loopToken) {
|
|
607
|
+
existing.env.COZELOOP_API_TOKEN = loopToken;
|
|
608
|
+
delete existing.env.COZE_API_TOKEN;
|
|
609
|
+
} else if (cozeToken) {
|
|
610
|
+
existing.env.COZE_API_TOKEN = cozeToken;
|
|
611
|
+
delete existing.env.COZELOOP_API_TOKEN;
|
|
612
|
+
}
|
|
613
|
+
} else {
|
|
582
614
|
delete existing.env.COZELOOP_API_TOKEN;
|
|
615
|
+
delete existing.env.COZE_API_TOKEN;
|
|
616
|
+
delete existing.env.COZELAB_ONBOARD_CLOUD;
|
|
583
617
|
}
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
existing.env.
|
|
596
|
-
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
return existing;
|
|
602
|
-
});
|
|
618
|
+
const loopBaseUrl = readEnv('COZELOOP_API_BASE_URL');
|
|
619
|
+
const otelEndpoint = readEnv('OTEL_ENDPOINT');
|
|
620
|
+
if (loopBaseUrl) {
|
|
621
|
+
existing.env.COZELOOP_API_BASE_URL = loopBaseUrl;
|
|
622
|
+
delete existing.env.OTEL_ENDPOINT;
|
|
623
|
+
} else if (otelEndpoint) {
|
|
624
|
+
existing.env.OTEL_ENDPOINT = otelEndpoint;
|
|
625
|
+
delete existing.env.COZELOOP_API_BASE_URL;
|
|
626
|
+
}
|
|
627
|
+
// 切正式环境:移除历史注入的 PPE 泳道 env(旧版本曾写入 x_tt_env/x_use_ppe)
|
|
628
|
+
delete existing.env.x_tt_env;
|
|
629
|
+
delete existing.env.x_use_ppe;
|
|
630
|
+
return existing;
|
|
631
|
+
});
|
|
632
|
+
} catch (e) {
|
|
633
|
+
errorBox([`ERROR: Existing ${localSettingsPath} is not valid JSON`, '', e.message, '', '请修复或删除该文件后重试,避免覆盖丢失现有凭证配置。']);
|
|
634
|
+
}
|
|
603
635
|
try {
|
|
604
|
-
|
|
636
|
+
atomicWriteFileSync(localSettingsPath, JSON.stringify(localSettings, null, 2));
|
|
605
637
|
} catch (e) {
|
|
606
638
|
errorBox([`ERROR: Cannot write credentials to ${localSettingsPath}`, '', e.message]);
|
|
607
639
|
}
|
|
@@ -662,7 +694,7 @@ function writeCodexHook(token, workspaceId, pythonCmd, codexHome, cloud) {
|
|
|
662
694
|
}
|
|
663
695
|
ok(`Credentials written to ${envFile} (chmod 600)`);
|
|
664
696
|
|
|
665
|
-
// 3. Merge hooks.json — Stop (trace) + SessionStart (refresh)
|
|
697
|
+
// 3. Merge hooks.json — Stop (trace 收尾) + PostToolUse (trace 增量) + SessionStart (refresh)
|
|
666
698
|
// 命令用绝对路径(CODEX_HOME 不一定是 ~/.codex)。
|
|
667
699
|
const hookCmd = `set -a && . ${envFile} && set +a && ${pythonCmd} ${hookScript}`;
|
|
668
700
|
const refreshCmd = `${pythonCmd} ${refreshScript}`;
|
|
@@ -670,13 +702,21 @@ function writeCodexHook(token, workspaceId, pythonCmd, codexHome, cloud) {
|
|
|
670
702
|
const hooks = mergeJson(hooksJson, (existing) => {
|
|
671
703
|
if (!existing.hooks) existing.hooks = {};
|
|
672
704
|
|
|
673
|
-
// Stop hook — trace upload
|
|
705
|
+
// Stop hook — trace upload(turn 结束,收尾全量上报)
|
|
674
706
|
if (!existing.hooks.Stop) existing.hooks.Stop = [];
|
|
675
707
|
existing.hooks.Stop = existing.hooks.Stop.filter(
|
|
676
708
|
entry => !entry.hooks?.some(h => h.command?.includes('cozeloop_hook.py'))
|
|
677
709
|
);
|
|
678
710
|
existing.hooks.Stop.push({ matcher: null, hooks: [{ type: 'command', command: hookCmd, timeout: 60 }] });
|
|
679
711
|
|
|
712
|
+
// PostToolUse hook — trace 增量上报(turn 进行中即上报已完成 turn,脚本内置节流+turn边界控制)。
|
|
713
|
+
// 没有它,单个长 turn 内(多轮工具调用持续数分钟)期间 Stop 不触发,trace 全程查不到。
|
|
714
|
+
if (!existing.hooks.PostToolUse) existing.hooks.PostToolUse = [];
|
|
715
|
+
existing.hooks.PostToolUse = existing.hooks.PostToolUse.filter(
|
|
716
|
+
entry => !entry.hooks?.some(h => h.command?.includes('cozeloop_hook.py'))
|
|
717
|
+
);
|
|
718
|
+
existing.hooks.PostToolUse.push({ matcher: null, hooks: [{ type: 'command', command: hookCmd, timeout: 60 }] });
|
|
719
|
+
|
|
680
720
|
// SessionStart hook — token refresh
|
|
681
721
|
if (!existing.hooks.SessionStart) existing.hooks.SessionStart = [];
|
|
682
722
|
existing.hooks.SessionStart = existing.hooks.SessionStart.filter(
|
|
@@ -713,16 +753,31 @@ function resolveCodexHome(args) {
|
|
|
713
753
|
function resolveHomeDir(cloud) {
|
|
714
754
|
const h = os.homedir();
|
|
715
755
|
if (!cloud) return h;
|
|
716
|
-
//
|
|
717
|
-
|
|
756
|
+
// 优先级:$HOME > os.homedir()(非空才用)> 显式候选目录。
|
|
757
|
+
// 不再无脑回退 /root —— 云端真实 home 可能是 /home/appuser,回退 /root 会把
|
|
758
|
+
// openclaw.json/凭证写错位置。仅在 HOME 与 os.homedir() 都拿不到时才探测候选。
|
|
759
|
+
const envHome = (process.env.HOME || '').trim();
|
|
760
|
+
if (envHome) {
|
|
761
|
+
info(`resolveHomeDir: using $HOME=${envHome}`);
|
|
762
|
+
return envHome;
|
|
763
|
+
}
|
|
764
|
+
if (h) {
|
|
765
|
+
info(`resolveHomeDir: using os.homedir()=${h}`);
|
|
766
|
+
return h;
|
|
767
|
+
}
|
|
768
|
+
// HOME 与 os.homedir() 都为空:探测云端常见 home 候选,挑第一个已有 coze/openclaw 配置的。
|
|
769
|
+
const candidates = ['/home/appuser', '/root'];
|
|
718
770
|
for (const c of candidates) {
|
|
719
771
|
try {
|
|
720
772
|
if (fs.existsSync(path.join(c, '.coze')) || fs.existsSync(path.join(c, '.openclaw'))) {
|
|
773
|
+
info(`resolveHomeDir: HOME/os.homedir() empty, detected home=${c}`);
|
|
721
774
|
return c;
|
|
722
775
|
}
|
|
723
776
|
} catch { /* ignore */ }
|
|
724
777
|
}
|
|
725
|
-
|
|
778
|
+
const fallback = candidates[candidates.length - 1];
|
|
779
|
+
warn(`resolveHomeDir: cannot determine home (HOME/os.homedir() empty, no candidate has coze config), falling back to ${fallback}`);
|
|
780
|
+
return fallback;
|
|
726
781
|
}
|
|
727
782
|
|
|
728
783
|
function normalizeTraceAgentIds(ids) {
|
|
@@ -1479,8 +1534,9 @@ function loadCredentials() {
|
|
|
1479
1534
|
}
|
|
1480
1535
|
|
|
1481
1536
|
function saveCredentials(creds) {
|
|
1482
|
-
|
|
1483
|
-
fs.
|
|
1537
|
+
// 0o700:凭证目录仅 owner 可读/进入,其他用户无法枚举 ~/.cozeloop 内容。
|
|
1538
|
+
fs.mkdirSync(path.dirname(CREDS_PATH), { recursive: true, mode: 0o700 });
|
|
1539
|
+
atomicWriteFileSync(CREDS_PATH, JSON.stringify(creds, null, 2), { mode: 0o600 });
|
|
1484
1540
|
}
|
|
1485
1541
|
|
|
1486
1542
|
function deleteCredentials() {
|
|
@@ -1519,7 +1575,7 @@ async function refreshToken(creds) {
|
|
|
1519
1575
|
const updated = {
|
|
1520
1576
|
access_token: data.access_token,
|
|
1521
1577
|
refresh_token: data.refresh_token ?? creds.refresh_token,
|
|
1522
|
-
expires_at: (data.expires_in ?? 0) * 1000, //
|
|
1578
|
+
expires_at: (data.expires_in ?? 0) * 1000, // expires_at stored in milliseconds (Python 端按毫秒读)
|
|
1523
1579
|
workspace_id: creds.workspace_id ?? WORKSPACE_ID, // preserve workspace_id
|
|
1524
1580
|
};
|
|
1525
1581
|
saveCredentials(updated);
|
|
@@ -1602,7 +1658,7 @@ async function deviceCodeAuth() {
|
|
|
1602
1658
|
const creds = {
|
|
1603
1659
|
access_token: pollData.access_token,
|
|
1604
1660
|
refresh_token: pollData.refresh_token,
|
|
1605
|
-
expires_at: (pollData.expires_in ?? 0) * 1000, //
|
|
1661
|
+
expires_at: (pollData.expires_in ?? 0) * 1000, // expires_at stored in milliseconds (Python 端按毫秒读)
|
|
1606
1662
|
workspace_id: WORKSPACE_ID,
|
|
1607
1663
|
};
|
|
1608
1664
|
saveCredentials(creds);
|
|
@@ -1977,20 +2033,30 @@ async function main() {
|
|
|
1977
2033
|
successBox(summaryLines);
|
|
1978
2034
|
}
|
|
1979
2035
|
|
|
1980
|
-
main()
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
cloudResult.inject
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
cloudResult.verify
|
|
2036
|
+
// 仅在作为 CLI 直接运行时执行 main();被 require(单元测试)时只导出内部函数。
|
|
2037
|
+
if (require.main === module) {
|
|
2038
|
+
main().catch(e => {
|
|
2039
|
+
// 云端模式:失败时输出结构化结果(含 logid),exit 非 0 但带 COZE_LAB_RESULT 行。
|
|
2040
|
+
if (CLOUD_MODE) {
|
|
2041
|
+
if (cloudResult.inject !== 'ok') {
|
|
2042
|
+
// 写 hook 配置前的任何失败 → 注入失败。
|
|
2043
|
+
cloudResult.inject = 'fail';
|
|
2044
|
+
} else if (cloudResult.verify === 'skip') {
|
|
2045
|
+
// 注入已成功,但验证阶段异常崩溃(未走到正常的 ok/fail 判定)→ 记为验证失败。
|
|
2046
|
+
cloudResult.verify = 'fail';
|
|
2047
|
+
}
|
|
2048
|
+
if (!cloudResult.message) cloudResult.message = e && e.message ? e.message : 'unexpected failure';
|
|
2049
|
+
if (!cloudResult.logid) cloudResult.logid = extractLogid(cloudResult.message);
|
|
2050
|
+
emitCloudResult();
|
|
2051
|
+
process.exit(1);
|
|
1989
2052
|
}
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
2053
|
+
errorBox(['ERROR: Unexpected failure', '', e.message]);
|
|
2054
|
+
});
|
|
2055
|
+
}
|
|
2056
|
+
|
|
2057
|
+
module.exports = {
|
|
2058
|
+
resolveHomeDir,
|
|
2059
|
+
mergeJson,
|
|
2060
|
+
atomicWriteFileSync,
|
|
2061
|
+
isExpired,
|
|
2062
|
+
};
|
package/package.json
CHANGED
|
@@ -127,6 +127,10 @@ _DEFAULT_WORKSPACE_ID = "7649231955045072915" # hardcoded spaceID fallback
|
|
|
127
127
|
_COZE_CTX_OPEN = "<coze-context>"
|
|
128
128
|
_COZE_CTX_CLOSE = "</coze-context>"
|
|
129
129
|
|
|
130
|
+
# 中途事件(PostToolUse)增量上报的最小间隔(秒)。密集工具调用下用它节流,避免每秒多次 flush。
|
|
131
|
+
# 终态事件(Stop)不受此限制。
|
|
132
|
+
INCREMENTAL_UPLOAD_MIN_INTERVAL = float(os.environ.get("COZELOOP_INCREMENTAL_MIN_INTERVAL", "10"))
|
|
133
|
+
|
|
130
134
|
|
|
131
135
|
def _content_to_text(content: Any) -> str:
|
|
132
136
|
"""Flatten Claude message content (str | list[dict] | dict) to plain text."""
|
|
@@ -438,8 +442,8 @@ def _token_from_credentials() -> Optional[str]:
|
|
|
438
442
|
creds = _load_credentials()
|
|
439
443
|
if not creds:
|
|
440
444
|
return None
|
|
441
|
-
|
|
442
|
-
remaining =
|
|
445
|
+
# expires_at 由 index.js 以毫秒存储;先减去当前毫秒时间再换算成秒,与 _REFRESH_THRESHOLD(秒) 比较。
|
|
446
|
+
remaining = (creds.get("expires_at", 0) - time.time() * 1000) / 1000
|
|
443
447
|
if remaining > _REFRESH_THRESHOLD:
|
|
444
448
|
debug_log(f"Cached token valid, expires in {int(remaining)}s.")
|
|
445
449
|
return creds.get("access_token")
|
|
@@ -1067,7 +1071,7 @@ def _build_history_messages(history_turns: List[Dict[str, Any]]) -> list:
|
|
|
1067
1071
|
|
|
1068
1072
|
# --- CozeLoop Trace Reporting ---
|
|
1069
1073
|
|
|
1070
|
-
def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, history_turns: Optional[List[Dict[str, Any]]] = None, retry_on_auth_failure: bool = True):
|
|
1074
|
+
def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, history_turns: Optional[List[Dict[str, Any]]] = None, retry_on_auth_failure: bool = True, coze_tags_override: Optional[Dict[str, str]] = None):
|
|
1071
1075
|
"""Send conversation turns to CozeLoop.
|
|
1072
1076
|
|
|
1073
1077
|
Span hierarchy:
|
|
@@ -1091,7 +1095,13 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, history
|
|
|
1091
1095
|
else:
|
|
1092
1096
|
print("[CozeLoop] 警告: 未找到有效 Token,上报可能失败", file=sys.stderr)
|
|
1093
1097
|
creds = _load_credentials()
|
|
1094
|
-
|
|
1098
|
+
# 云端模式:sandbox 注入的 COZELOOP_WORKSPACE_ID 必须优先于 credentials.json。
|
|
1099
|
+
# 否则残留的本地 credentials.json workspace_id 会覆盖云端注入,导致 trace 上报错位。
|
|
1100
|
+
is_cloud = os.environ.get("COZELAB_ONBOARD_CLOUD", "").lower() in ("1", "true", "yes")
|
|
1101
|
+
if is_cloud:
|
|
1102
|
+
workspace_id = os.environ.get("COZELOOP_WORKSPACE_ID", "") or (creds or {}).get("workspace_id") or _DEFAULT_WORKSPACE_ID
|
|
1103
|
+
else:
|
|
1104
|
+
workspace_id = (creds or {}).get("workspace_id") or os.environ.get("COZELOOP_WORKSPACE_ID", "") or _DEFAULT_WORKSPACE_ID
|
|
1095
1105
|
os.environ["COZELOOP_WORKSPACE_ID"] = workspace_id
|
|
1096
1106
|
upload_events: List[str] = []
|
|
1097
1107
|
client_kwargs = {
|
|
@@ -1150,6 +1160,21 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, history
|
|
|
1150
1160
|
t = coze_context_tags(um.get("content") if um else None)
|
|
1151
1161
|
if t:
|
|
1152
1162
|
coze_tags = t
|
|
1163
|
+
# 增量上报兜底:本批 turns 不含 <coze-context>(它只在首 turn)时,用 caller
|
|
1164
|
+
# 从 state 传入的 override,保证每个 turn 的 root span 都带 coze_message_id,
|
|
1165
|
+
# 否则后续增量 turn 的 root span 无法按 message_id 查到。
|
|
1166
|
+
if not coze_tags and coze_tags_override:
|
|
1167
|
+
coze_tags = dict(coze_tags_override)
|
|
1168
|
+
# Drop empty-valued coze_* tags: the backend pairs traces by exact tag
|
|
1169
|
+
# match (coze_message_id / coze_agent_id), where an empty string is
|
|
1170
|
+
# indistinguishable from "absent" yet still bloats the span — never
|
|
1171
|
+
# emit one. Log when a pairing key is missing so a silent pairing miss
|
|
1172
|
+
# is debuggable.
|
|
1173
|
+
coze_tags = {k: v for k, v in coze_tags.items() if isinstance(v, str) and v.strip()}
|
|
1174
|
+
if not coze_tags.get("coze_message_id"):
|
|
1175
|
+
hook_log(f"coze-context missing coze_message_id session={session_id}")
|
|
1176
|
+
if not coze_tags.get("coze_session_id"):
|
|
1177
|
+
hook_log(f"coze-context missing coze_session_id session={session_id}")
|
|
1153
1178
|
if coze_tags:
|
|
1154
1179
|
root_tags.update(coze_tags)
|
|
1155
1180
|
root_baggage.update(coze_tags)
|
|
@@ -1561,7 +1586,19 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, history
|
|
|
1561
1586
|
debug_log(f"An error occurred while sending traces to CozeLoop: {e}")
|
|
1562
1587
|
return None
|
|
1563
1588
|
finally:
|
|
1564
|
-
#
|
|
1589
|
+
# Flush buffered spans BEFORE closing so a slow/failed close() can't drop
|
|
1590
|
+
# them. close() also flushes, but flushing first surfaces the upload
|
|
1591
|
+
# failure (via trace_finish_event_processor -> upload_events) while the
|
|
1592
|
+
# client is still usable.
|
|
1593
|
+
# KNOWN RISK: when flush/close reports a 401, the block below re-sends the
|
|
1594
|
+
# ENTIRE turn batch with a refreshed token. The SDK has no per-span ack, so
|
|
1595
|
+
# any batch the first attempt already delivered before the auth failure is
|
|
1596
|
+
# uploaded a second time -> duplicate trace. Acceptable for now (a missing
|
|
1597
|
+
# trace is worse than a dup); revisit if the SDK gains partial-ack/resume.
|
|
1598
|
+
try:
|
|
1599
|
+
client.flush()
|
|
1600
|
+
except Exception as _flush_err:
|
|
1601
|
+
debug_log(f"client.flush() before close failed: {_flush_err}")
|
|
1565
1602
|
client.close()
|
|
1566
1603
|
debug_log("CozeLoop client closed.")
|
|
1567
1604
|
|
|
@@ -1572,7 +1609,7 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, history
|
|
|
1572
1609
|
if new_token:
|
|
1573
1610
|
os.environ["COZELOOP_API_TOKEN"] = new_token
|
|
1574
1611
|
hook_log("retry upload once after forced token refresh")
|
|
1575
|
-
return send_turns_to_cozeloop(turns, session_id, history_turns, retry_on_auth_failure=False)
|
|
1612
|
+
return send_turns_to_cozeloop(turns, session_id, history_turns, retry_on_auth_failure=False, coze_tags_override=coze_tags_override)
|
|
1576
1613
|
return None
|
|
1577
1614
|
|
|
1578
1615
|
return True
|
|
@@ -1614,6 +1651,11 @@ def main():
|
|
|
1614
1651
|
# Read hook input from stdin (Claude Code provides transcript_path, session_id, etc.)
|
|
1615
1652
|
hook_input = read_hook_stdin()
|
|
1616
1653
|
|
|
1654
|
+
# 触发事件类型:Stop=会话结束(收尾,全量上报);PostToolUse=任务进行中(增量上报)。
|
|
1655
|
+
# 缺省按 Stop 处理,兼容旧注册与手动调用。
|
|
1656
|
+
hook_event = (hook_input.get("hook_event_name") or "Stop").strip()
|
|
1657
|
+
is_terminal_event = hook_event not in ("PostToolUse", "PreToolUse")
|
|
1658
|
+
|
|
1617
1659
|
# Determine conversation file: prefer stdin, fallback to file scan
|
|
1618
1660
|
conversation_file = hook_input.get("transcript_path")
|
|
1619
1661
|
if conversation_file:
|
|
@@ -1637,6 +1679,16 @@ def main():
|
|
|
1637
1679
|
state = load_state(state_file)
|
|
1638
1680
|
last_processed_line = state.get("last_processed_line", 0)
|
|
1639
1681
|
|
|
1682
|
+
# 节流:PostToolUse 在密集工具调用下会高频触发。距上次上报不足 INCREMENTAL_UPLOAD_MIN_INTERVAL
|
|
1683
|
+
# 秒则跳过本次增量上报,避免每秒多次 flush 抬高上报量/成本。终态事件(Stop)永不被节流,
|
|
1684
|
+
# 保证任务结束时一定收尾上报最后一批。
|
|
1685
|
+
if not is_terminal_event:
|
|
1686
|
+
now_ts = time.time()
|
|
1687
|
+
last_upload_ts = state.get("last_upload_ts", 0)
|
|
1688
|
+
if now_ts - last_upload_ts < INCREMENTAL_UPLOAD_MIN_INTERVAL:
|
|
1689
|
+
debug_log(f"throttled: event={hook_event} since_last={now_ts - last_upload_ts:.1f}s < {INCREMENTAL_UPLOAD_MIN_INTERVAL}s, skip")
|
|
1690
|
+
return
|
|
1691
|
+
|
|
1640
1692
|
# Read new messages from the file
|
|
1641
1693
|
new_messages = read_new_messages(conversation_file, last_processed_line)
|
|
1642
1694
|
|
|
@@ -1685,18 +1737,49 @@ def main():
|
|
|
1685
1737
|
if not has_coze_ctx:
|
|
1686
1738
|
debug_log("No coze-context found in any turn (incl. history), skipping upload.")
|
|
1687
1739
|
return
|
|
1688
|
-
|
|
1689
|
-
|
|
1740
|
+
|
|
1741
|
+
# 持久化 coze_tags 到 state:<coze-context> 只在首 turn,后续增量批次的 turns 不含它,
|
|
1742
|
+
# 其 root span 会缺 coze_message_id 而无法按 message_id 查到。首次解析到就存 state,
|
|
1743
|
+
# 后续批次作为 override 传给 send_turns,保证每个 turn 的 root span 都带 coze_* tag。
|
|
1744
|
+
for _t in list(turns) + list(history_turns):
|
|
1745
|
+
_um = (_t.get("user_message", {}).get("message", {}) or {})
|
|
1746
|
+
_tags = {k: v for k, v in coze_context_tags(_um.get("content")).items() if isinstance(v, str) and v.strip()}
|
|
1747
|
+
if _tags:
|
|
1748
|
+
state["coze_tags"] = _tags
|
|
1749
|
+
break
|
|
1750
|
+
coze_tags_override = state.get("coze_tags") or None
|
|
1751
|
+
|
|
1752
|
+
# turn 边界控制:中途事件(PostToolUse)触发时,最后一个 turn 往往仍在进行中
|
|
1753
|
+
# (后续还会追加 step)。若此刻就上报并推进其行号,同一逻辑 turn 会在下次触发时
|
|
1754
|
+
# 因缺了起始 user 消息而被拆成新的 root span。故中途事件只上报“已完成”的 turn
|
|
1755
|
+
# (= 除最后一个之外的所有 turn),把最后一个留到下次/收尾。终态事件(Stop)上报全部。
|
|
1756
|
+
if is_terminal_event:
|
|
1757
|
+
turns_to_send = turns
|
|
1758
|
+
else:
|
|
1759
|
+
turns_to_send = turns[:-1]
|
|
1760
|
+
if not turns_to_send:
|
|
1761
|
+
debug_log(f"event={hook_event}: no completed turn to send yet (turns={len(turns)}), defer")
|
|
1762
|
+
return
|
|
1763
|
+
|
|
1764
|
+
print(f"[CozeLoop] 开始上报: session={session_id}, event={hook_event}, turns={len(turns_to_send)}/{len(turns)}", file=sys.stderr)
|
|
1765
|
+
uploaded = send_turns_to_cozeloop(turns_to_send, session_id, history_turns, coze_tags_override=coze_tags_override)
|
|
1690
1766
|
if uploaded is None:
|
|
1691
1767
|
debug_log("Send failed, state not advanced.")
|
|
1692
1768
|
return
|
|
1693
1769
|
|
|
1694
|
-
#
|
|
1695
|
-
|
|
1696
|
-
|
|
1770
|
+
# 推进 last_processed_line:只推进到已上报 turn 覆盖的最后一行。中途事件保留了最后一个
|
|
1771
|
+
# 未完成 turn,故推进到“倒数第二个 turn 的末行”,让未完成 turn 的所有行下次重新读取。
|
|
1772
|
+
if is_terminal_event:
|
|
1773
|
+
last_line_in_batch = max(msg.get("_line_number", 0) for msg in new_messages)
|
|
1774
|
+
state["last_processed_line"] = last_line_in_batch + 1
|
|
1775
|
+
else:
|
|
1776
|
+
# turns_to_send 是 turns[:-1],下一个未发送 turn 的起始行即新的水位线。
|
|
1777
|
+
next_turn_start = turns[-1].get("start_line", 0)
|
|
1778
|
+
state["last_processed_line"] = next_turn_start
|
|
1779
|
+
state["last_upload_ts"] = time.time()
|
|
1697
1780
|
save_state(state_file, state)
|
|
1698
|
-
print(f"[CozeLoop] 上报完成 ✓ session={session_id}, turns={len(
|
|
1699
|
-
debug_log(f"State updated.
|
|
1781
|
+
print(f"[CozeLoop] 上报完成 ✓ session={session_id}, turns={len(turns_to_send)}", file=sys.stderr)
|
|
1782
|
+
debug_log(f"State updated. event={hook_event} last_processed_line={state['last_processed_line']}")
|
|
1700
1783
|
|
|
1701
1784
|
debug_log("Hook finished.")
|
|
1702
1785
|
|
|
@@ -50,6 +50,10 @@ _REFRESH_LOCK_STALE = 30
|
|
|
50
50
|
_DEFAULT_WORKSPACE_ID = "7649231955045072915" # hardcoded spaceID fallback
|
|
51
51
|
_OTEL_SUFFIX = "/v1/loop/opentelemetry"
|
|
52
52
|
|
|
53
|
+
# 中途事件(PostToolUse)增量上报的最小间隔(秒)。密集工具调用下用它节流,避免每次工具调用都 flush。
|
|
54
|
+
# 终态事件(Stop/SubagentStop)不受此限制。
|
|
55
|
+
INCREMENTAL_UPLOAD_MIN_INTERVAL = float(os.environ.get("COZELOOP_INCREMENTAL_MIN_INTERVAL", "10"))
|
|
56
|
+
|
|
53
57
|
|
|
54
58
|
# --- coze-context parsing -------------------------------------------------
|
|
55
59
|
# User messages may embed a block like:
|
|
@@ -351,7 +355,8 @@ def _token_from_credentials():
|
|
|
351
355
|
creds = _load_credentials()
|
|
352
356
|
if not creds:
|
|
353
357
|
return None
|
|
354
|
-
|
|
358
|
+
# expires_at 由 index.js 以毫秒存储;先减去当前毫秒时间再换算成秒,与 _REFRESH_THRESHOLD(秒) 比较。
|
|
359
|
+
remaining = (creds.get("expires_at", 0) - time.time() * 1000) / 1000
|
|
355
360
|
if remaining > _REFRESH_THRESHOLD:
|
|
356
361
|
return creds.get("access_token")
|
|
357
362
|
if creds.get("refresh_token"):
|
|
@@ -1072,7 +1077,8 @@ def _make_model_message(role: str, content: str = "", tool_calls: list = None,
|
|
|
1072
1077
|
|
|
1073
1078
|
def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_name: str = "codex",
|
|
1074
1079
|
history_context: Optional[List[Dict[str, Any]]] = None,
|
|
1075
|
-
retry_on_auth_failure: bool = True
|
|
1080
|
+
retry_on_auth_failure: bool = True,
|
|
1081
|
+
coze_tags_override: Optional[Dict[str, str]] = None) -> Optional[List[Dict[str, Any]]]:
|
|
1076
1082
|
"""Send conversation turns to CozeLoop for tracing.
|
|
1077
1083
|
|
|
1078
1084
|
Span hierarchy:
|
|
@@ -1102,7 +1108,13 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
|
|
|
1102
1108
|
)
|
|
1103
1109
|
print("[CozeLoop] 警告: 未找到有效 Token,上报可能失败", file=sys.stderr)
|
|
1104
1110
|
creds = _load_credentials()
|
|
1105
|
-
|
|
1111
|
+
# 云端模式:sandbox 注入的 COZELOOP_WORKSPACE_ID 必须优先于 credentials.json。
|
|
1112
|
+
# 否则残留的本地 credentials.json workspace_id 会覆盖云端注入,导致 trace 上报错位。
|
|
1113
|
+
is_cloud = os.environ.get("COZELAB_ONBOARD_CLOUD", "").lower() in ("1", "true", "yes")
|
|
1114
|
+
if is_cloud:
|
|
1115
|
+
workspace_id = os.environ.get("COZELOOP_WORKSPACE_ID", "") or (creds or {}).get("workspace_id") or _DEFAULT_WORKSPACE_ID
|
|
1116
|
+
else:
|
|
1117
|
+
workspace_id = (creds or {}).get("workspace_id") or os.environ.get("COZELOOP_WORKSPACE_ID", "") or _DEFAULT_WORKSPACE_ID
|
|
1106
1118
|
os.environ["COZELOOP_WORKSPACE_ID"] = workspace_id
|
|
1107
1119
|
hook_log(f"workspace_id={workspace_id}")
|
|
1108
1120
|
upload_events: List[str] = []
|
|
@@ -1151,6 +1163,21 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
|
|
|
1151
1163
|
t = {f"coze_{k}": v for k, v in turn_coze_context(turn).items()}
|
|
1152
1164
|
if t:
|
|
1153
1165
|
coze_tags = t
|
|
1166
|
+
# 增量上报兜底:本批 turns 不含 <coze-context>(它只在首 turn)时,用 caller 从
|
|
1167
|
+
# state 传入的 override,保证每个 turn 的 root span 都带 coze_message_id,否则后续
|
|
1168
|
+
# 增量 turn 的 root span 无法按 message_id 查到。
|
|
1169
|
+
if not coze_tags and coze_tags_override:
|
|
1170
|
+
coze_tags = dict(coze_tags_override)
|
|
1171
|
+
# Drop empty-valued coze_* tags: the backend pairs traces by exact tag
|
|
1172
|
+
# match (coze_message_id / coze_agent_id), where an empty string is
|
|
1173
|
+
# indistinguishable from "absent" yet still bloats the span — never
|
|
1174
|
+
# emit one. Log when a pairing key is missing so a silent pairing miss
|
|
1175
|
+
# is debuggable.
|
|
1176
|
+
coze_tags = {k: v for k, v in coze_tags.items() if isinstance(v, str) and v.strip()}
|
|
1177
|
+
if not coze_tags.get("coze_message_id"):
|
|
1178
|
+
hook_log(f"coze-context missing coze_message_id session_id={session_id}")
|
|
1179
|
+
if not coze_tags.get("coze_session_id"):
|
|
1180
|
+
hook_log(f"coze-context missing coze_session_id session_id={session_id}")
|
|
1154
1181
|
if coze_tags:
|
|
1155
1182
|
root_tags.update(coze_tags)
|
|
1156
1183
|
root_baggage.update(coze_tags)
|
|
@@ -1488,6 +1515,19 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
|
|
|
1488
1515
|
debug_log(f"An error occurred while sending traces to CozeLoop: {e}")
|
|
1489
1516
|
return None
|
|
1490
1517
|
finally:
|
|
1518
|
+
# Flush buffered spans BEFORE closing so a slow/failed close() can't drop
|
|
1519
|
+
# them. close() also flushes, but flushing first surfaces the upload
|
|
1520
|
+
# failure (via trace_finish_event_processor -> upload_events) while the
|
|
1521
|
+
# client is still usable.
|
|
1522
|
+
# KNOWN RISK: when flush/close reports a 401, the block below re-sends the
|
|
1523
|
+
# ENTIRE turn batch with a refreshed token. The SDK has no per-span ack, so
|
|
1524
|
+
# any batch the first attempt already delivered before the auth failure is
|
|
1525
|
+
# uploaded a second time -> duplicate trace. Acceptable for now (a missing
|
|
1526
|
+
# trace is worse than a dup); revisit if the SDK gains partial-ack/resume.
|
|
1527
|
+
try:
|
|
1528
|
+
client.flush()
|
|
1529
|
+
except Exception as _flush_err:
|
|
1530
|
+
debug_log(f"client.flush() before close failed: {_flush_err}")
|
|
1491
1531
|
client.close()
|
|
1492
1532
|
hook_log("client closed")
|
|
1493
1533
|
debug_log("CozeLoop client closed.")
|
|
@@ -1505,6 +1545,7 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
|
|
|
1505
1545
|
model_name,
|
|
1506
1546
|
history_context,
|
|
1507
1547
|
retry_on_auth_failure=False,
|
|
1548
|
+
coze_tags_override=coze_tags_override,
|
|
1508
1549
|
)
|
|
1509
1550
|
return None
|
|
1510
1551
|
|
|
@@ -1545,6 +1586,11 @@ def main():
|
|
|
1545
1586
|
|
|
1546
1587
|
debug_log(f"Hook input: {json.dumps(hook_input, ensure_ascii=False)}")
|
|
1547
1588
|
|
|
1589
|
+
# 触发事件类型:Stop/SubagentStop=turn 结束(收尾,全量上报);PostToolUse=turn 进行中
|
|
1590
|
+
# (增量上报,仅上报已完成 turn + 节流)。缺省按终态处理,兼容旧注册与手动调用。
|
|
1591
|
+
hook_event = (hook_input.get("hook_event_name") or "Stop").strip()
|
|
1592
|
+
is_terminal_event = hook_event not in ("PostToolUse", "PreToolUse")
|
|
1593
|
+
|
|
1548
1594
|
# Get transcript path
|
|
1549
1595
|
transcript_path = hook_input.get("transcript_path")
|
|
1550
1596
|
if not transcript_path:
|
|
@@ -1633,7 +1679,12 @@ def main():
|
|
|
1633
1679
|
|
|
1634
1680
|
# Send turns to CozeLoop — only if at least one turn carries coze-context.
|
|
1635
1681
|
if turns:
|
|
1636
|
-
|
|
1682
|
+
# coze-context 只出现在首个 turn 的 user 消息里。增量上报推进后,后续批次的 turns
|
|
1683
|
+
# 已不含首 turn,直接判断会误判为"无 coze-context"而跳过。故用 state 持久化"本会话
|
|
1684
|
+
# 曾见过 coze-context"标记:一旦见过,后续增量批次都视为应上报(与 claude-code 用
|
|
1685
|
+
# history_turns 判断等效)。
|
|
1686
|
+
seen_ctx = bool(state.get("seen_coze_context"))
|
|
1687
|
+
has_coze_ctx = seen_ctx or any(
|
|
1637
1688
|
turn_coze_context(t)
|
|
1638
1689
|
for t in turns
|
|
1639
1690
|
)
|
|
@@ -1641,17 +1692,60 @@ def main():
|
|
|
1641
1692
|
hook_log(f"skip no coze-context turns={len(turns)} session_id={session_id}")
|
|
1642
1693
|
debug_log("No coze-context found in any turn, skipping upload.")
|
|
1643
1694
|
return
|
|
1695
|
+
if not seen_ctx:
|
|
1696
|
+
state["seen_coze_context"] = True
|
|
1697
|
+
|
|
1698
|
+
# 持久化 coze_tags 到 state:<coze-context> 只在首 turn,后续增量批次不含它,其 root span
|
|
1699
|
+
# 会缺 coze_message_id 无法按 message_id 查到。首次解析到就存 state,后续批次作为 override
|
|
1700
|
+
# 传给 send_turns,保证每个 turn 的 root span 都带 coze_* tag。
|
|
1701
|
+
if not state.get("coze_tags"):
|
|
1702
|
+
for _t in turns:
|
|
1703
|
+
_tags = {f"coze_{k}": v for k, v in turn_coze_context(_t).items() if isinstance(v, str) and v.strip()}
|
|
1704
|
+
if _tags:
|
|
1705
|
+
state["coze_tags"] = _tags
|
|
1706
|
+
break
|
|
1707
|
+
coze_tags_override = state.get("coze_tags") or None
|
|
1708
|
+
|
|
1709
|
+
# 节流:PostToolUse 在密集工具调用下高频触发。距上次上报不足间隔则跳过本次增量上报。
|
|
1710
|
+
# 终态事件(Stop/SubagentStop)永不被节流,保证 turn 结束时一定收尾。
|
|
1711
|
+
if not is_terminal_event:
|
|
1712
|
+
now_ts = time.time()
|
|
1713
|
+
last_upload_ts = state.get("last_upload_ts", 0)
|
|
1714
|
+
if now_ts - last_upload_ts < INCREMENTAL_UPLOAD_MIN_INTERVAL:
|
|
1715
|
+
hook_log(f"throttled event={hook_event} since_last={now_ts - last_upload_ts:.1f}s session_id={session_id}")
|
|
1716
|
+
debug_log(f"throttled: event={hook_event}, skip incremental upload")
|
|
1717
|
+
return
|
|
1718
|
+
|
|
1719
|
+
# turn 边界控制:中途事件(PostToolUse)触发时,最后一个 turn 往往仍在进行中
|
|
1720
|
+
# (task_complete 尚未到达,后续还会追加内容)。只上报已完成 turn(turns[:-1]),
|
|
1721
|
+
# 把最后一个留到下次/收尾,避免同一 turn 被拆成多个 trace。终态事件上报全部。
|
|
1722
|
+
if is_terminal_event:
|
|
1723
|
+
turns_to_send = turns
|
|
1724
|
+
else:
|
|
1725
|
+
turns_to_send = turns[:-1]
|
|
1726
|
+
if not turns_to_send:
|
|
1727
|
+
hook_log(f"defer no completed turn event={hook_event} turns={len(turns)} session_id={session_id}")
|
|
1728
|
+
debug_log(f"event={hook_event}: no completed turn to send yet, defer")
|
|
1729
|
+
return
|
|
1730
|
+
|
|
1644
1731
|
history_context = state.get("conversation_history", [])
|
|
1645
1732
|
updated_history = send_turns_to_cozeloop(
|
|
1646
|
-
|
|
1733
|
+
turns_to_send, session_id, model_name,
|
|
1647
1734
|
history_context=history_context,
|
|
1735
|
+
coze_tags_override=coze_tags_override,
|
|
1648
1736
|
)
|
|
1649
1737
|
if updated_history is not None:
|
|
1650
|
-
|
|
1738
|
+
# 推进 last_processed_line:终态推进到所有 entry 末行;中途保留最后一个未完成 turn,
|
|
1739
|
+
# 推进到该 turn 的起始行,让它的所有 entry 下次重新读取。
|
|
1740
|
+
if is_terminal_event:
|
|
1741
|
+
last_line = max(e.get("_line_number", 0) for e in entries) + 1
|
|
1742
|
+
else:
|
|
1743
|
+
last_line = turns[-1].get("start_line", max(e.get("_line_number", 0) for e in entries) + 1)
|
|
1651
1744
|
state["last_processed_line"] = last_line
|
|
1652
1745
|
state["conversation_history"] = updated_history
|
|
1746
|
+
state["last_upload_ts"] = time.time()
|
|
1653
1747
|
save_state(state_file, state)
|
|
1654
|
-
hook_log(f"state advanced last_line={last_line} session_id={session_id}")
|
|
1748
|
+
hook_log(f"state advanced event={hook_event} last_line={last_line} sent={len(turns_to_send)}/{len(turns)} session_id={session_id}")
|
|
1655
1749
|
debug_log(f"State updated, last processed line: {last_line}")
|
|
1656
1750
|
else:
|
|
1657
1751
|
hook_log(f"send failed state not advanced session_id={session_id}")
|
|
@@ -404,6 +404,11 @@ class CozeloopIngestExporter {
|
|
|
404
404
|
this.serviceName = config.serviceName;
|
|
405
405
|
this.onAuthFailure = config.onAuthFailure;
|
|
406
406
|
this.shutdownRequested = false;
|
|
407
|
+
// In-flight postSpans() promises. export() returns void (OTel contract),
|
|
408
|
+
// so without tracking these the process can exit while an HTTP POST is
|
|
409
|
+
// still pending and silently drop the span batch. forceFlush() awaits
|
|
410
|
+
// this set so dispose()/shutdown() can guarantee delivery.
|
|
411
|
+
this.pendingPromises = new Set();
|
|
407
412
|
fileLog(this.logFile, `[ingest] exporter ready url=${this.url} workspaceId=${this.workspaceId}`);
|
|
408
413
|
}
|
|
409
414
|
export(spans, resultCallback) {
|
|
@@ -411,13 +416,17 @@ class CozeloopIngestExporter {
|
|
|
411
416
|
resultCallback({ code: EXPORT_SUCCESS });
|
|
412
417
|
return;
|
|
413
418
|
}
|
|
414
|
-
this.postSpans(spans)
|
|
419
|
+
const pending = this.postSpans(spans)
|
|
415
420
|
.then(() => resultCallback({ code: EXPORT_SUCCESS }))
|
|
416
421
|
.catch((err) => {
|
|
417
422
|
this.logger?.error?.(`[CozeloopTrace] CozeLoop ingest export failed: ${err?.message || err}`);
|
|
418
423
|
fileLog(this.logFile, `[ingest] export FAILED url=${this.url} spans=${spans.length} err=${err?.message || err}`);
|
|
419
424
|
resultCallback({ code: EXPORT_FAILED, error: err });
|
|
425
|
+
})
|
|
426
|
+
.finally(() => {
|
|
427
|
+
this.pendingPromises.delete(pending);
|
|
420
428
|
});
|
|
429
|
+
this.pendingPromises.add(pending);
|
|
421
430
|
}
|
|
422
431
|
async postSpans(spans) {
|
|
423
432
|
const body = {
|
|
@@ -486,10 +495,15 @@ class CozeloopIngestExporter {
|
|
|
486
495
|
fileLog(this.logFile, `[ingest] OK HTTP ${res.status} spans=${body.spans.length}${retry ? " retry=1" : ""}`);
|
|
487
496
|
}
|
|
488
497
|
async forceFlush() {
|
|
489
|
-
|
|
498
|
+
// Wait for every in-flight POST to settle. allSettled (not all) so one
|
|
499
|
+
// failed batch doesn't abort the wait on the others.
|
|
500
|
+
if (this.pendingPromises.size > 0) {
|
|
501
|
+
await Promise.allSettled([...this.pendingPromises]);
|
|
502
|
+
}
|
|
490
503
|
}
|
|
491
504
|
async shutdown() {
|
|
492
505
|
this.shutdownRequested = true;
|
|
506
|
+
await this.forceFlush();
|
|
493
507
|
}
|
|
494
508
|
}
|
|
495
509
|
|
|
@@ -888,6 +902,9 @@ export class CozeloopExporter {
|
|
|
888
902
|
async dispose() {
|
|
889
903
|
if (this.provider) {
|
|
890
904
|
try {
|
|
905
|
+
// Flush in-flight batches before tearing the provider down so a
|
|
906
|
+
// pending HTTP POST is never killed by process exit.
|
|
907
|
+
await this.flush();
|
|
891
908
|
await this.provider.shutdown();
|
|
892
909
|
}
|
|
893
910
|
catch (err) {
|
|
@@ -70,7 +70,11 @@ function parseCozeContext(input) {
|
|
|
70
70
|
continue;
|
|
71
71
|
const key = line.slice(0, sep).trim();
|
|
72
72
|
const value = line.slice(sep + 1).trim();
|
|
73
|
-
|
|
73
|
+
// Tag keys map directly onto backend trace-query field names, so only
|
|
74
|
+
// accept the same charset the keys are written in (letters/digits/_).
|
|
75
|
+
// Lines whose key carries any other char are malformed — skip them
|
|
76
|
+
// rather than emit a tag the backend Eq filter could never hit.
|
|
77
|
+
if (key && /^[A-Za-z0-9_]+$/.test(key))
|
|
74
78
|
out["coze_" + key] = value;
|
|
75
79
|
}
|
|
76
80
|
return out;
|
|
@@ -1409,14 +1413,17 @@ const cozeloopTracePlugin = {
|
|
|
1409
1413
|
};
|
|
1410
1414
|
// OpenClaw runtime 周期性发送的心跳轮询消息,不是真实对话,整条 trace 丢弃。
|
|
1411
1415
|
const HEARTBEAT_INPUT = "[OpenClaw heartbeat poll]";
|
|
1416
|
+
// 宽松匹配:trim 后只要包含心跳标记即算心跳,避免 runtime 加前后缀/包裹时漏判。
|
|
1417
|
+
const isHeartbeatText = (text) => typeof text === "string"
|
|
1418
|
+
&& text.trim().includes(HEARTBEAT_INPUT);
|
|
1412
1419
|
const isHeartbeatInput = (input) => {
|
|
1413
1420
|
if (typeof input === "string") {
|
|
1414
|
-
return input
|
|
1421
|
+
return isHeartbeatText(input);
|
|
1415
1422
|
}
|
|
1416
1423
|
// content 可能是 [{type:'text', text:'...'}] 形式
|
|
1417
1424
|
if (Array.isArray(input)) {
|
|
1418
1425
|
return input.some((p) => p && typeof p === "object"
|
|
1419
|
-
&&
|
|
1426
|
+
&& isHeartbeatText(p.text));
|
|
1420
1427
|
}
|
|
1421
1428
|
return false;
|
|
1422
1429
|
};
|
|
@@ -1428,6 +1435,7 @@ const cozeloopTracePlugin = {
|
|
|
1428
1435
|
const lastUserFallback = lastUserFallbackFor(ctx, channelId);
|
|
1429
1436
|
const heartbeatInput = ctx.userInput
|
|
1430
1437
|
|| lastUserFallback?.userInput || (lastUserFallback ? lastUserInput : undefined);
|
|
1438
|
+
const currentIsHeartbeat = isHeartbeatInput(ctx.userInput);
|
|
1431
1439
|
if (isHeartbeatInput(heartbeatInput)) {
|
|
1432
1440
|
if (config.debug) {
|
|
1433
1441
|
api.logger.info(`[CozeloopTrace] skip heartbeat poll trace, traceId=${ctx.traceId}`);
|
|
@@ -1439,13 +1447,24 @@ const cozeloopTracePlugin = {
|
|
|
1439
1447
|
// /loop follow-ups and later turns have none. Fall back to the per-
|
|
1440
1448
|
// session cache (and global last-seen) instead of dropping the trace.
|
|
1441
1449
|
// Only skip when NO context has ever been seen for this session.
|
|
1450
|
+
//
|
|
1451
|
+
// EXCEPTION: never apply the cache fallback to a heartbeat poll — the
|
|
1452
|
+
// cache could resurrect a previous real turn's coze-context and make
|
|
1453
|
+
// the heartbeat masquerade as that conversation (a phantom trace).
|
|
1454
|
+
// Claude-code / codex hooks only look at the current turn; match that.
|
|
1442
1455
|
const ocSessionId = ctx.openclawSessionId || lastOpenclawSessionId;
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1456
|
+
let cozeCtx;
|
|
1457
|
+
if (currentIsHeartbeat) {
|
|
1458
|
+
cozeCtx = parseCozeContext(ctx.userInput);
|
|
1459
|
+
}
|
|
1460
|
+
else {
|
|
1461
|
+
rememberCozeContext(ctx.userInput, ocSessionId);
|
|
1462
|
+
cozeCtx = resolveCozeContext(ctx.userInput, ocSessionId);
|
|
1463
|
+
if (Object.keys(cozeCtx).length === 0) {
|
|
1464
|
+
// Try the fallback user inputs too before giving up.
|
|
1465
|
+
const fallbackInput = lastUserFallback?.userInput || (lastUserFallback ? lastUserInput : undefined);
|
|
1466
|
+
cozeCtx = resolveCozeContext(fallbackInput, ocSessionId);
|
|
1467
|
+
}
|
|
1449
1468
|
}
|
|
1450
1469
|
if (Object.keys(cozeCtx).length === 0) {
|
|
1451
1470
|
return;
|
|
@@ -1477,6 +1496,20 @@ const cozeloopTracePlugin = {
|
|
|
1477
1496
|
if (!ctx.userInput) {
|
|
1478
1497
|
ctx.userInput = lastUserFallback?.userInput || (lastUserFallback ? lastUserInput : undefined);
|
|
1479
1498
|
}
|
|
1499
|
+
// Drop empty-valued coze_* tags: the backend pairs traces by exact
|
|
1500
|
+
// tag match (coze_message_id / coze_session_id), and an empty-string
|
|
1501
|
+
// tag is indistinguishable from "absent" there while still bloating
|
|
1502
|
+
// the span — so never emit one. Log when the pairing keys are missing
|
|
1503
|
+
// so a silent pairing miss is debuggable.
|
|
1504
|
+
const cozeTags = {};
|
|
1505
|
+
for (const [k, v] of Object.entries(cozeCtx)) {
|
|
1506
|
+
if (typeof v === "string" && v.trim() !== "") {
|
|
1507
|
+
cozeTags[k] = v;
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
if (config.debug && (!cozeTags.coze_message_id || !cozeTags.coze_session_id)) {
|
|
1511
|
+
api.logger.info(`[CozeloopTrace] coze-context missing pairing key(s): message_id=${cozeTags.coze_message_id ? "ok" : "MISSING"} session_id=${cozeTags.coze_session_id ? "ok" : "MISSING"}, traceId=${ctx.traceId}`);
|
|
1512
|
+
}
|
|
1480
1513
|
const rootSpanData = {
|
|
1481
1514
|
name: "openclaw_request",
|
|
1482
1515
|
type: "entry",
|
|
@@ -1486,7 +1519,7 @@ const cozeloopTracePlugin = {
|
|
|
1486
1519
|
"run.id": ctx.runId,
|
|
1487
1520
|
"turn.id": ctx.turnId,
|
|
1488
1521
|
"openclaw.channel_id": channelId,
|
|
1489
|
-
...
|
|
1522
|
+
...cozeTags,
|
|
1490
1523
|
},
|
|
1491
1524
|
input: ctx.userInput,
|
|
1492
1525
|
traceId: ctx.traceId,
|
|
@@ -44,7 +44,8 @@ def refresh(rt):
|
|
|
44
44
|
def main():
|
|
45
45
|
creds = load()
|
|
46
46
|
if not creds: return
|
|
47
|
-
|
|
47
|
+
# expires_at 由 index.js 以毫秒存储;先减去当前毫秒时间再换算成秒,与 THRESHOLD(秒) 比较。
|
|
48
|
+
remaining = (creds.get("expires_at",0) - time.time()*1000)/1000
|
|
48
49
|
if remaining > THRESHOLD: return # still fresh
|
|
49
50
|
print(f"[cozeloop_refresh] token expiring in {int(remaining)}s, refreshing...", file=sys.stderr)
|
|
50
51
|
if creds.get("refresh_token"):
|