coze_lab 0.1.3 → 0.1.5
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 +130 -40
- package/package.json +1 -1
- package/scripts/claude-code/cozeloop_hook.py +75 -6
- package/scripts/codex/cozeloop_hook.py +78 -7
- package/scripts/openclaw/dist/cozeloop-exporter.js +3 -1
- package/scripts/openclaw/dist/index.js +48 -5
- package/scripts/openclaw/dist/index.js.bak-20260601222111 +1315 -0
package/index.js
CHANGED
|
@@ -3,7 +3,10 @@
|
|
|
3
3
|
|
|
4
4
|
// ─── 0. Constants ─────────────────────────────────────────────────────────────
|
|
5
5
|
const CLIENT_ID = '46371084383473718052118955183420.app.coze';
|
|
6
|
-
const WORKSPACE_ID = '
|
|
6
|
+
const WORKSPACE_ID = '7644910356078837760';
|
|
7
|
+
// PPE 泳道:所有 cozeloop 上报 / OAuth 请求都带这两个 header(SDK 读 x_tt_env/x_use_ppe 环境变量自动注入)。
|
|
8
|
+
const PPE_TT_ENV = 'ppe_cozelab';
|
|
9
|
+
const PPE_USE_PPE = '1';
|
|
7
10
|
const COZE_API = 'https://api.coze.cn';
|
|
8
11
|
const CREDS_PATH = require('path').join(require('os').homedir(), '.cozeloop', 'credentials.json');
|
|
9
12
|
// Refresh when less than 10 minutes remain
|
|
@@ -3455,7 +3458,7 @@ async function _refreshToken(refreshTok) {
|
|
|
3455
3458
|
const body = JSON.stringify({ grant_type: "refresh_token", client_id: _CLIENT_ID, refresh_token: refreshTok });
|
|
3456
3459
|
const req = https.request(\`\${_COZE_API}/api/permission/oauth2/token\`, {
|
|
3457
3460
|
method: "POST",
|
|
3458
|
-
headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) },
|
|
3461
|
+
headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body), "x-tt-env": "ppe_cozelab", "x-use-ppe": "1" },
|
|
3459
3462
|
}, (res) => {
|
|
3460
3463
|
let buf = "";
|
|
3461
3464
|
res.on("data", c => buf += c);
|
|
@@ -3589,6 +3592,8 @@ export class CozeloopExporter {
|
|
|
3589
3592
|
headers: {
|
|
3590
3593
|
"Authorization": authorization,
|
|
3591
3594
|
"cozeloop-workspace-id": workspaceId,
|
|
3595
|
+
"x-tt-env": "ppe_cozelab",
|
|
3596
|
+
"x-use-ppe": "1",
|
|
3592
3597
|
},
|
|
3593
3598
|
});
|
|
3594
3599
|
this.provider = new BasicTracerProvider({ resource });
|
|
@@ -4117,20 +4122,61 @@ function parseArgs() {
|
|
|
4117
4122
|
|
|
4118
4123
|
const VALID_AGENTS = ['claude-code', 'codex', 'openclaw'];
|
|
4119
4124
|
|
|
4125
|
+
// resolveAgent 读 ~/.coze/agents/<agentId>/config.json,返回 { framework, workspace, agentId, root }。
|
|
4126
|
+
// 目录或 config 不存在、framework 非法时直接报错退出。
|
|
4127
|
+
function resolveAgent(agentId) {
|
|
4128
|
+
const root = path.join(os.homedir(), '.coze', 'agents', agentId);
|
|
4129
|
+
const configPath = path.join(root, 'config.json');
|
|
4130
|
+
if (!fs.existsSync(configPath)) {
|
|
4131
|
+
errorBox([
|
|
4132
|
+
`ERROR: agent "${agentId}" 不存在`,
|
|
4133
|
+
'',
|
|
4134
|
+
`未找到 ${configPath}`,
|
|
4135
|
+
'请确认 agentId 正确,或先用 coze-bridge 创建该 agent。',
|
|
4136
|
+
]);
|
|
4137
|
+
}
|
|
4138
|
+
let cfg;
|
|
4139
|
+
try {
|
|
4140
|
+
cfg = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
4141
|
+
} catch (e) {
|
|
4142
|
+
errorBox([`ERROR: 解析 ${configPath} 失败`, '', e.message]);
|
|
4143
|
+
}
|
|
4144
|
+
const framework = cfg.framework;
|
|
4145
|
+
if (!VALID_AGENTS.includes(framework)) {
|
|
4146
|
+
errorBox([
|
|
4147
|
+
`ERROR: agent "${agentId}" 的 framework="${framework}" 不受支持`,
|
|
4148
|
+
'',
|
|
4149
|
+
`支持的类型: ${VALID_AGENTS.join(', ')}`,
|
|
4150
|
+
]);
|
|
4151
|
+
}
|
|
4152
|
+
return { framework, workspace: cfg.workspace || '', agentId, root };
|
|
4153
|
+
}
|
|
4154
|
+
|
|
4120
4155
|
function validateArgs(args) {
|
|
4121
4156
|
if (args['logout']) return { logout: true };
|
|
4122
4157
|
if (args['login']) return { login: true };
|
|
4123
4158
|
if (args['status']) return { status: true };
|
|
4124
4159
|
if (args['refresh']) return { refresh: true };
|
|
4125
4160
|
|
|
4161
|
+
// 优先 --agent-id:读 ~/.coze/agents/<id>/config.json 的 framework 自动路由。
|
|
4162
|
+
if (args['agent-id']) {
|
|
4163
|
+
const resolved = resolveAgent(args['agent-id']);
|
|
4164
|
+
return {
|
|
4165
|
+
agent: resolved.framework,
|
|
4166
|
+
agentId: resolved.agentId,
|
|
4167
|
+
workspace: resolved.workspace,
|
|
4168
|
+
agentRoot: resolved.root,
|
|
4169
|
+
'codex-home': args['codex-home'],
|
|
4170
|
+
};
|
|
4171
|
+
}
|
|
4172
|
+
|
|
4126
4173
|
if (!args['agent']) {
|
|
4127
4174
|
errorBox([
|
|
4128
|
-
'ERROR: --agent
|
|
4175
|
+
'ERROR: --agent 或 --agent-id 至少提供一个',
|
|
4129
4176
|
'',
|
|
4130
4177
|
'Usage:',
|
|
4131
|
-
' --agent=claude-code',
|
|
4132
|
-
' --agent
|
|
4133
|
-
' --agent=openclaw',
|
|
4178
|
+
' --agent=claude-code | codex | openclaw (全局配置)',
|
|
4179
|
+
' --agent-id=<id> (按 ~/.coze/agents/<id> 的 framework 自动路由)',
|
|
4134
4180
|
'',
|
|
4135
4181
|
'Other commands:',
|
|
4136
4182
|
' --status Show authorization status',
|
|
@@ -4149,7 +4195,7 @@ function validateArgs(args) {
|
|
|
4149
4195
|
' --agent=openclaw',
|
|
4150
4196
|
]);
|
|
4151
4197
|
}
|
|
4152
|
-
return { agent: args['agent'] };
|
|
4198
|
+
return { agent: args['agent'], 'codex-home': args['codex-home'] };
|
|
4153
4199
|
}
|
|
4154
4200
|
|
|
4155
4201
|
// ─── 4. Agent detection ──────────────────────────────────────────────────────
|
|
@@ -4337,22 +4383,31 @@ function mergeJson(filepath, mergeFn) {
|
|
|
4337
4383
|
return mergeFn(existing);
|
|
4338
4384
|
}
|
|
4339
4385
|
|
|
4340
|
-
|
|
4386
|
+
// writeClaudeCodeHook 配置 Claude Code 的 hook。
|
|
4387
|
+
// configBaseDir 缺省 process.cwd()(全局/项目级);传入 agent 的 workspace 则 per-agent:
|
|
4388
|
+
// settings.json + 凭证写进 <configBaseDir>/.claude,仅该 agent(以此为 cwd 启动)生效。
|
|
4389
|
+
function writeClaudeCodeHook(patToken, workspaceId, pythonCmd, configBaseDir) {
|
|
4341
4390
|
const hooksDir = path.join(os.homedir(), '.claude', 'hooks');
|
|
4342
4391
|
const hookScript = path.join(hooksDir, 'cozeloop_hook.py');
|
|
4343
|
-
const
|
|
4344
|
-
|
|
4345
|
-
|
|
4346
|
-
|
|
4392
|
+
const baseDir = configBaseDir || process.cwd();
|
|
4393
|
+
// per-agent 时 settings.json 写进 agent 的 .claude;全局时仍写 ~/.claude/settings.json。
|
|
4394
|
+
const claudeDir = configBaseDir
|
|
4395
|
+
? path.join(baseDir, '.claude')
|
|
4396
|
+
: path.join(os.homedir(), '.claude');
|
|
4397
|
+
const settingsPath = path.join(claudeDir, 'settings.json');
|
|
4398
|
+
const localSettingsPath = path.join(baseDir, '.claude', 'settings.local.json');
|
|
4399
|
+
|
|
4400
|
+
// 1. Write Python hook scripts (trace + refresh) — 脚本放全局 ~/.claude/hooks,可共享
|
|
4347
4401
|
ensureDir(hooksDir);
|
|
4348
4402
|
writeHookScript(hookScript, readScript('claude-code/cozeloop_hook.py'));
|
|
4349
4403
|
const refreshScript = path.join(hooksDir, 'cozeloop_refresh.py');
|
|
4350
4404
|
writeHookScript(refreshScript, readScript('shared/cozeloop_refresh.py'));
|
|
4351
4405
|
|
|
4352
|
-
// 2. Merge
|
|
4353
|
-
const hookCmd = `${pythonCmd}
|
|
4354
|
-
const refreshCmd = `${pythonCmd}
|
|
4406
|
+
// 2. Merge settings.json — Stop (trace) + UserPromptSubmit (refresh)。用绝对路径。
|
|
4407
|
+
const hookCmd = `${pythonCmd} ${hookScript}`;
|
|
4408
|
+
const refreshCmd = `${pythonCmd} ${refreshScript}`;
|
|
4355
4409
|
|
|
4410
|
+
ensureDir(claudeDir);
|
|
4356
4411
|
const settings = mergeJson(settingsPath, (existing) => {
|
|
4357
4412
|
if (!existing.hooks) existing.hooks = {};
|
|
4358
4413
|
|
|
@@ -4377,14 +4432,17 @@ function writeClaudeCodeHook(patToken, workspaceId, pythonCmd) {
|
|
|
4377
4432
|
} catch (e) {
|
|
4378
4433
|
errorBox([`ERROR: Cannot write ${settingsPath}`, '', e.message]);
|
|
4379
4434
|
}
|
|
4380
|
-
ok(`
|
|
4435
|
+
ok(`Hook registered in ${settingsPath}`);
|
|
4381
4436
|
|
|
4382
|
-
// 3. Write
|
|
4383
|
-
ensureDir(path.join(
|
|
4437
|
+
// 3. Write credentials into <baseDir>/.claude/settings.local.json
|
|
4438
|
+
ensureDir(path.join(baseDir, '.claude'));
|
|
4384
4439
|
const localSettings = mergeJson(localSettingsPath, (existing) => {
|
|
4385
4440
|
if (!existing.env) existing.env = {};
|
|
4386
4441
|
existing.env.COZELOOP_WORKSPACE_ID = workspaceId;
|
|
4387
4442
|
existing.env.COZELOOP_API_TOKEN = patToken;
|
|
4443
|
+
// PPE 泳道:cozeloop SDK 读这两个环境变量,自动注入 x-tt-env / x-use-ppe header
|
|
4444
|
+
existing.env.x_tt_env = PPE_TT_ENV;
|
|
4445
|
+
existing.env.x_use_ppe = PPE_USE_PPE;
|
|
4388
4446
|
return existing;
|
|
4389
4447
|
});
|
|
4390
4448
|
try {
|
|
@@ -4398,11 +4456,14 @@ function writeClaudeCodeHook(patToken, workspaceId, pythonCmd) {
|
|
|
4398
4456
|
return { hookScript, settingsPath, localSettingsPath };
|
|
4399
4457
|
}
|
|
4400
4458
|
|
|
4401
|
-
|
|
4402
|
-
|
|
4459
|
+
// writeCodexHook 把 hook 写进指定的 CODEX_HOME。codexHome 缺省 ~/.codex;
|
|
4460
|
+
// 传入动态目录(如 coze-bridge 的 /tmp/coze-bridge-codex-home-xxx)即可 per-agent 生效。
|
|
4461
|
+
function writeCodexHook(token, workspaceId, pythonCmd, codexHome) {
|
|
4462
|
+
const home = codexHome || path.join(os.homedir(), '.codex');
|
|
4463
|
+
const hooksDir = path.join(home, 'hooks');
|
|
4403
4464
|
const hookScript = path.join(hooksDir, 'cozeloop_hook.py');
|
|
4404
4465
|
const envFile = path.join(hooksDir, 'cozeloop.env');
|
|
4405
|
-
const hooksJson = path.join(
|
|
4466
|
+
const hooksJson = path.join(home, 'hooks.json');
|
|
4406
4467
|
|
|
4407
4468
|
// 1. Write Python hook scripts (trace + refresh)
|
|
4408
4469
|
// Token is read from ~/.cozeloop/credentials.json at runtime
|
|
@@ -4416,6 +4477,9 @@ function writeCodexHook(token, workspaceId, pythonCmd) {
|
|
|
4416
4477
|
`COZELOOP_WORKSPACE_ID=${workspaceId}`,
|
|
4417
4478
|
`COZELOOP_API_TOKEN=${token}`,
|
|
4418
4479
|
'TRACE_TO_COZELOOP=true',
|
|
4480
|
+
// PPE 泳道:cozeloop SDK 读这两个环境变量,自动注入 x-tt-env / x-use-ppe header
|
|
4481
|
+
`x_tt_env=${PPE_TT_ENV}`,
|
|
4482
|
+
`x_use_ppe=${PPE_USE_PPE}`,
|
|
4419
4483
|
].join('\n') + '\n';
|
|
4420
4484
|
try {
|
|
4421
4485
|
fs.writeFileSync(envFile, envContent, { mode: 0o600 });
|
|
@@ -4425,8 +4489,9 @@ function writeCodexHook(token, workspaceId, pythonCmd) {
|
|
|
4425
4489
|
ok(`Credentials written to ${envFile} (chmod 600)`);
|
|
4426
4490
|
|
|
4427
4491
|
// 3. Merge hooks.json — Stop (trace) + SessionStart (refresh)
|
|
4428
|
-
|
|
4429
|
-
const
|
|
4492
|
+
// 命令用绝对路径(CODEX_HOME 不一定是 ~/.codex)。
|
|
4493
|
+
const hookCmd = `set -a && . ${envFile} && set +a && ${pythonCmd} ${hookScript}`;
|
|
4494
|
+
const refreshCmd = `${pythonCmd} ${refreshScript}`;
|
|
4430
4495
|
|
|
4431
4496
|
const hooks = mergeJson(hooksJson, (existing) => {
|
|
4432
4497
|
if (!existing.hooks) existing.hooks = {};
|
|
@@ -4551,6 +4616,17 @@ function writeOpenClawHook(token, workspaceId) {
|
|
|
4551
4616
|
errorBox([`ERROR: Cannot write ${configPath}`, '', e.message]);
|
|
4552
4617
|
}
|
|
4553
4618
|
ok(`OpenClaw plugin configured in ${configPath}`);
|
|
4619
|
+
|
|
4620
|
+
// 自动重启 gateway,让新插件配置立即生效(不依赖 plugins install 的隐式重启)。
|
|
4621
|
+
try {
|
|
4622
|
+
const { execSync } = require('child_process');
|
|
4623
|
+
info('Restarting OpenClaw gateway to apply hook changes...');
|
|
4624
|
+
execSync('openclaw gateway restart', { stdio: 'pipe' });
|
|
4625
|
+
ok('OpenClaw gateway restarted');
|
|
4626
|
+
} catch (e) {
|
|
4627
|
+
warn(`gateway restart 失败,请手动执行: openclaw gateway restart(${e.message})`);
|
|
4628
|
+
}
|
|
4629
|
+
|
|
4554
4630
|
return { configPath, pluginDir };
|
|
4555
4631
|
}
|
|
4556
4632
|
|
|
@@ -4563,7 +4639,8 @@ function httpsPost(url, body) {
|
|
|
4563
4639
|
const u = new URL(url);
|
|
4564
4640
|
const req = https.request(
|
|
4565
4641
|
{ hostname: u.hostname, path: u.pathname + u.search, method: 'POST',
|
|
4566
|
-
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data)
|
|
4642
|
+
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data),
|
|
4643
|
+
'x-tt-env': PPE_TT_ENV, 'x-use-ppe': PPE_USE_PPE } },
|
|
4567
4644
|
(res) => {
|
|
4568
4645
|
let buf = '';
|
|
4569
4646
|
res.on('data', c => buf += c);
|
|
@@ -4579,7 +4656,9 @@ function httpsPost(url, body) {
|
|
|
4579
4656
|
|
|
4580
4657
|
function httpsGet(url, headers) {
|
|
4581
4658
|
return new Promise((resolve, reject) => {
|
|
4582
|
-
|
|
4659
|
+
// 合并 PPE 泳道 header
|
|
4660
|
+
const h = { ...(headers || {}), 'x-tt-env': PPE_TT_ENV, 'x-use-ppe': PPE_USE_PPE };
|
|
4661
|
+
const req = https.get(url, { headers: h }, (res) => {
|
|
4583
4662
|
let buf = '';
|
|
4584
4663
|
res.on('data', c => buf += c);
|
|
4585
4664
|
res.on('end', () => resolve({ status: res.statusCode, body: buf }));
|
|
@@ -4845,9 +4924,9 @@ async function verifyWorkspace(token) {
|
|
|
4845
4924
|
|
|
4846
4925
|
// ─── 9. Main ─────────────────────────────────────────────────────────────────
|
|
4847
4926
|
const NEXT_STEP = {
|
|
4848
|
-
'claude-code': '
|
|
4849
|
-
'codex': '
|
|
4850
|
-
'openclaw': '
|
|
4927
|
+
'claude-code': 'Hook 已写入。请在当前 Claude Code 会话运行 /new(或重开会话)使 hook 生效——外部进程无法替你 /new。',
|
|
4928
|
+
'codex': 'Hook 已写入。请重开 Codex 会话使 hook 生效——外部进程无法替你重启当前会话。',
|
|
4929
|
+
'openclaw': 'OpenClaw gateway 已自动重启,trace 即刻生效。',
|
|
4851
4930
|
};
|
|
4852
4931
|
|
|
4853
4932
|
async function main() {
|
|
@@ -4913,17 +4992,22 @@ async function main() {
|
|
|
4913
4992
|
}
|
|
4914
4993
|
|
|
4915
4994
|
const { agent } = args;
|
|
4995
|
+
// per-agent 路由时 agent 的 workspace(claude-code 用它做配置根目录)。
|
|
4996
|
+
const agentWorkspace = args.workspace || '';
|
|
4997
|
+
if (args.agentId) {
|
|
4998
|
+
info(`目标 agent: ${args.agentId} (framework=${agent}, workspace=${agentWorkspace || 'N/A'})`);
|
|
4999
|
+
console.log('');
|
|
5000
|
+
}
|
|
4916
5001
|
|
|
4917
5002
|
// Step 1: Authorize (load cached → refresh → device code)
|
|
4918
|
-
// Step 2: Verify token works — if 401, refresh/re-auth and retry once
|
|
4919
5003
|
info('Step 1/5: 检查授权状态...');
|
|
4920
5004
|
let token = await getValidToken();
|
|
4921
5005
|
console.log('');
|
|
4922
5006
|
|
|
5007
|
+
// Step 2: Verify token works
|
|
4923
5008
|
info('Step 2/5: 验证工作空间权限...');
|
|
4924
5009
|
let verified = await verifyWorkspace(token);
|
|
4925
5010
|
if (verified === null) {
|
|
4926
|
-
// Token rejected by API — clear cached creds and re-authorize
|
|
4927
5011
|
warn('Token 已失效,正在重新授权...');
|
|
4928
5012
|
deleteCredentials();
|
|
4929
5013
|
console.log('');
|
|
@@ -4952,27 +5036,33 @@ async function main() {
|
|
|
4952
5036
|
|
|
4953
5037
|
// Step 5: Write hook configuration
|
|
4954
5038
|
info('Step 5/5: Writing hook configuration...');
|
|
5039
|
+
let written = {};
|
|
4955
5040
|
if (agent === 'claude-code') {
|
|
4956
|
-
|
|
5041
|
+
// per-agent 路由时把配置写进 agent 的 workspace(仅该 agent 生效)。
|
|
5042
|
+
written = writeClaudeCodeHook(token, WORKSPACE_ID, pythonCmd, args.agentId ? agentWorkspace : undefined);
|
|
4957
5043
|
} else if (agent === 'codex') {
|
|
4958
|
-
|
|
5044
|
+
// CODEX_HOME 来源优先级:--codex-home= > 环境变量 CODEX_HOME > ~/.codex(缺省)。
|
|
5045
|
+
const codexHome = args['codex-home'] || process.env.CODEX_HOME || undefined;
|
|
5046
|
+
if (codexHome) info(`Codex 配置目录: ${codexHome}`);
|
|
5047
|
+
written = writeCodexHook(token, WORKSPACE_ID, pythonCmd, codexHome);
|
|
4959
5048
|
} else {
|
|
4960
|
-
writeOpenClawHook(token, WORKSPACE_ID);
|
|
5049
|
+
written = writeOpenClawHook(token, WORKSPACE_ID) || {};
|
|
4961
5050
|
}
|
|
4962
5051
|
console.log('');
|
|
4963
5052
|
|
|
4964
|
-
// Success summary
|
|
5053
|
+
// Success summary(用实际写入路径,per-agent / 自定义 CODEX_HOME 时也准确)
|
|
4965
5054
|
const summaryLines = [
|
|
4966
5055
|
`Agent: ${agent} v${version}`,
|
|
4967
5056
|
];
|
|
5057
|
+
if (args.agentId) summaryLines.push(`Agent ID: ${args.agentId}`);
|
|
4968
5058
|
if (agent === 'claude-code') {
|
|
4969
|
-
summaryLines.push(`Hook script: ~/.claude/hooks/cozeloop_hook.py`);
|
|
4970
|
-
summaryLines.push(`Config: ~/.claude/settings.json`);
|
|
4971
|
-
summaryLines.push(`Credentials: .claude/settings.local.json`);
|
|
5059
|
+
summaryLines.push(`Hook script: ${written.hookScript || '~/.claude/hooks/cozeloop_hook.py'}`);
|
|
5060
|
+
summaryLines.push(`Config: ${written.settingsPath || '~/.claude/settings.json'}`);
|
|
5061
|
+
summaryLines.push(`Credentials: ${written.localSettingsPath || '.claude/settings.local.json'}`);
|
|
4972
5062
|
} else if (agent === 'codex') {
|
|
4973
|
-
summaryLines.push(`Hook script: ~/.codex/hooks/cozeloop_hook.py`);
|
|
4974
|
-
summaryLines.push(`Config: ~/.codex/hooks.json`);
|
|
4975
|
-
summaryLines.push(`Credentials: ~/.codex/hooks/cozeloop.env`);
|
|
5063
|
+
summaryLines.push(`Hook script: ${written.hookScript || '~/.codex/hooks/cozeloop_hook.py'}`);
|
|
5064
|
+
summaryLines.push(`Config: ${written.hooksJson || '~/.codex/hooks.json'}`);
|
|
5065
|
+
summaryLines.push(`Credentials: ${written.envFile || '~/.codex/hooks/cozeloop.env'}`);
|
|
4976
5066
|
} else {
|
|
4977
5067
|
summaryLines.push(`Config: ~/.openclaw/openclaw.json`);
|
|
4978
5068
|
}
|
package/package.json
CHANGED
|
@@ -431,6 +431,29 @@ def _group_subagent_steps(progress_msgs: List[Dict[str, Any]]) -> List[Dict[str,
|
|
|
431
431
|
return steps
|
|
432
432
|
|
|
433
433
|
|
|
434
|
+
def _parse_ts(msg):
|
|
435
|
+
"""从 transcript msg 的 timestamp 字段解析出 datetime(带时区)。失败返回 None。
|
|
436
|
+
|
|
437
|
+
Claude Code transcript 每条 user/assistant 消息都带 ISO8601 timestamp
|
|
438
|
+
(如 "2026-06-01T13:51:55.450Z")。建 span 时用它做 start_time,
|
|
439
|
+
避免回放时所有 span 挤在几毫秒内、duration 全是几毫秒。
|
|
440
|
+
"""
|
|
441
|
+
if not isinstance(msg, dict):
|
|
442
|
+
return None
|
|
443
|
+
ts = msg.get("timestamp")
|
|
444
|
+
if not ts or not isinstance(ts, str):
|
|
445
|
+
return None
|
|
446
|
+
try:
|
|
447
|
+
return datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
|
448
|
+
except Exception:
|
|
449
|
+
return None
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _ts_ms(dt):
|
|
453
|
+
"""datetime → 毫秒时间戳(int);None → None。"""
|
|
454
|
+
return int(dt.timestamp() * 1000) if dt is not None else None
|
|
455
|
+
|
|
456
|
+
|
|
434
457
|
def group_messages_into_turns(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
435
458
|
"""Group messages into conversation turns (user -> assistant -> tool_results).
|
|
436
459
|
|
|
@@ -793,13 +816,31 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, history
|
|
|
793
816
|
client = cozeloop.new_client(**client_kwargs)
|
|
794
817
|
|
|
795
818
|
try:
|
|
796
|
-
|
|
819
|
+
# 整体时间范围:第一条 user 消息 → 最后一条 assistant 消息(真实 transcript 时间)
|
|
820
|
+
_all_ts = []
|
|
821
|
+
for _t in turns:
|
|
822
|
+
_u = _parse_ts(_t.get("user_message"))
|
|
823
|
+
if _u:
|
|
824
|
+
_all_ts.append(_u)
|
|
825
|
+
for _s in _t.get("steps", []):
|
|
826
|
+
_a = _parse_ts(_s.get("assistant_message"))
|
|
827
|
+
if _a:
|
|
828
|
+
_all_ts.append(_a)
|
|
829
|
+
root_start_dt = min(_all_ts) if _all_ts else None
|
|
830
|
+
root_end_dt = max(_all_ts) if _all_ts else None
|
|
831
|
+
|
|
832
|
+
with client.start_span(name="claude_code_request", span_type="main", start_time=root_start_dt) as root_span:
|
|
797
833
|
root_span.set_runtime(Runtime(library="claude-code"))
|
|
798
834
|
root_tags = {
|
|
799
835
|
"thread_id": session_id,
|
|
800
836
|
"total_turns": len(turns),
|
|
801
837
|
"source": "claude_code"
|
|
802
838
|
}
|
|
839
|
+
# 真实耗时写 tag(SDK finish 不支持显式结束时间,duration 用 tag 兜底)
|
|
840
|
+
if root_start_dt is not None and root_end_dt is not None:
|
|
841
|
+
root_tags["real_start_ms"] = _ts_ms(root_start_dt)
|
|
842
|
+
root_tags["real_end_ms"] = _ts_ms(root_end_dt)
|
|
843
|
+
root_tags["latency_ms"] = _ts_ms(root_end_dt) - _ts_ms(root_start_dt)
|
|
803
844
|
root_baggage = {
|
|
804
845
|
"thread_id": session_id,
|
|
805
846
|
}
|
|
@@ -836,14 +877,29 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, history
|
|
|
836
877
|
steps = turn.get("steps", [])
|
|
837
878
|
total_steps = len(steps)
|
|
838
879
|
|
|
839
|
-
|
|
880
|
+
# turn 真实时间:start=user 消息时间,end=最后一个 step 的 assistant 时间
|
|
881
|
+
turn_start_dt = _parse_ts(turn.get("user_message"))
|
|
882
|
+
turn_end_dt = None
|
|
883
|
+
for _s in steps:
|
|
884
|
+
_a = _parse_ts(_s.get("assistant_message"))
|
|
885
|
+
if _a:
|
|
886
|
+
turn_end_dt = _a
|
|
887
|
+
if turn_start_dt is None:
|
|
888
|
+
turn_start_dt = turn_end_dt
|
|
889
|
+
|
|
890
|
+
with client.start_span(name=f"turn_{i}", span_type="main", start_time=turn_start_dt) as turn_span:
|
|
840
891
|
turn_span.set_runtime(Runtime(library="claude-code"))
|
|
841
|
-
|
|
892
|
+
_turn_tags = {
|
|
842
893
|
"thread_id": session_id,
|
|
843
894
|
"turn_index": i,
|
|
844
895
|
"total_steps": total_steps,
|
|
845
896
|
"source": "claude_code",
|
|
846
|
-
}
|
|
897
|
+
}
|
|
898
|
+
if turn_start_dt is not None and turn_end_dt is not None:
|
|
899
|
+
_turn_tags["real_start_ms"] = _ts_ms(turn_start_dt)
|
|
900
|
+
_turn_tags["real_end_ms"] = _ts_ms(turn_end_dt)
|
|
901
|
+
_turn_tags["latency_ms"] = _ts_ms(turn_end_dt) - _ts_ms(turn_start_dt)
|
|
902
|
+
turn_span.set_tags(_turn_tags)
|
|
847
903
|
|
|
848
904
|
# Extract user input for this turn
|
|
849
905
|
user_message = turn.get("user_message", {}).get("message", {})
|
|
@@ -861,10 +917,23 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, history
|
|
|
861
917
|
raw_content = assistant_message_obj.get("content", [])
|
|
862
918
|
model_name = assistant_message_obj.get("model", "claude-code")
|
|
863
919
|
|
|
920
|
+
# step 真实时间:start=本 step assistant 时间;end=下一 step assistant 时间(即本次模型调用+工具的耗时),无下一 step 则用 turn_end
|
|
921
|
+
step_start_dt = _parse_ts(assistant_msg)
|
|
922
|
+
if j + 1 < len(steps):
|
|
923
|
+
step_end_dt = _parse_ts(steps[j + 1].get("assistant_message")) or turn_end_dt
|
|
924
|
+
else:
|
|
925
|
+
step_end_dt = turn_end_dt
|
|
926
|
+
|
|
864
927
|
# --- Create model span for this step ---
|
|
865
|
-
with client.start_span(name=f"model_call_{j}", span_type="model") as model_span:
|
|
928
|
+
with client.start_span(name=f"model_call_{j}", span_type="model", start_time=step_start_dt) as model_span:
|
|
866
929
|
model_span.set_runtime(Runtime(library="claude-code"))
|
|
867
930
|
model_span.set_model_name(model_name)
|
|
931
|
+
if step_start_dt is not None and step_end_dt is not None:
|
|
932
|
+
model_span.set_tags({
|
|
933
|
+
"real_start_ms": _ts_ms(step_start_dt),
|
|
934
|
+
"real_end_ms": _ts_ms(step_end_dt),
|
|
935
|
+
"latency_ms": _ts_ms(step_end_dt) - _ts_ms(step_start_dt),
|
|
936
|
+
})
|
|
868
937
|
|
|
869
938
|
# Set input: accumulated context up to this point
|
|
870
939
|
model_span.set_input(ModelInput(
|
|
@@ -958,7 +1027,7 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, history
|
|
|
958
1027
|
span_type = "agent" if is_agent else "tool"
|
|
959
1028
|
span_name = f"agent_{tool_name}" if is_agent else f"tool_{tool_name}"
|
|
960
1029
|
|
|
961
|
-
with client.start_span(name=span_name, span_type=span_type) as tool_span:
|
|
1030
|
+
with client.start_span(name=span_name, span_type=span_type, start_time=step_start_dt) as tool_span:
|
|
962
1031
|
tool_span.set_runtime(Runtime(library="claude-code"))
|
|
963
1032
|
tags = {
|
|
964
1033
|
"tool_name": tool_name,
|
|
@@ -415,6 +415,28 @@ def truncate_text(text: str, limit: int = 12000) -> str:
|
|
|
415
415
|
|
|
416
416
|
# --- Message Grouping ---
|
|
417
417
|
|
|
418
|
+
def _parse_ts(obj):
|
|
419
|
+
"""从 codex entry/payload 的 timestamp 解析 datetime(带时区)。失败返回 None。
|
|
420
|
+
|
|
421
|
+
Codex rollout JSONL 每条 entry 顶层带 ISO8601 timestamp。建 span 时用它做
|
|
422
|
+
start_time,避免回放时所有 span 挤在几毫秒内。
|
|
423
|
+
"""
|
|
424
|
+
if not isinstance(obj, dict):
|
|
425
|
+
return None
|
|
426
|
+
ts = obj.get("timestamp") or obj.get("_ts")
|
|
427
|
+
if not ts or not isinstance(ts, str):
|
|
428
|
+
return None
|
|
429
|
+
try:
|
|
430
|
+
return datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
|
431
|
+
except Exception:
|
|
432
|
+
return None
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _ts_ms(dt):
|
|
436
|
+
"""datetime → 毫秒时间戳(int);None → None。"""
|
|
437
|
+
return int(dt.timestamp() * 1000) if dt is not None else None
|
|
438
|
+
|
|
439
|
+
|
|
418
440
|
def group_messages_into_turns(entries: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
419
441
|
"""Group raw JSONL entries into conversation turns.
|
|
420
442
|
|
|
@@ -437,6 +459,9 @@ def group_messages_into_turns(entries: List[Dict[str, Any]]) -> List[Dict[str, A
|
|
|
437
459
|
for entry in entries:
|
|
438
460
|
entry_type = entry.get("type")
|
|
439
461
|
payload = entry.get("payload", {})
|
|
462
|
+
# 把 entry 顶层 timestamp 注入 payload,供后续建 span 取真实时间
|
|
463
|
+
if isinstance(payload, dict) and entry.get("timestamp") and "_ts" not in payload:
|
|
464
|
+
payload["_ts"] = entry.get("timestamp")
|
|
440
465
|
|
|
441
466
|
# --- Turn lifecycle events ---
|
|
442
467
|
if entry_type == "event_msg":
|
|
@@ -645,13 +670,30 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
|
|
|
645
670
|
ctx: List[Dict[str, Any]] = list(history_context) if history_context else []
|
|
646
671
|
|
|
647
672
|
try:
|
|
648
|
-
|
|
673
|
+
# 整体时间范围:所有 user/assistant payload 的真实 timestamp 极值
|
|
674
|
+
_all_ts = []
|
|
675
|
+
for _t in turns:
|
|
676
|
+
_u = _parse_ts(_t.get("user_message"))
|
|
677
|
+
if _u:
|
|
678
|
+
_all_ts.append(_u)
|
|
679
|
+
for _a in _t.get("assistant_messages", []):
|
|
680
|
+
_ad = _parse_ts(_a)
|
|
681
|
+
if _ad:
|
|
682
|
+
_all_ts.append(_ad)
|
|
683
|
+
root_start_dt = min(_all_ts) if _all_ts else None
|
|
684
|
+
root_end_dt = max(_all_ts) if _all_ts else None
|
|
685
|
+
|
|
686
|
+
with client.start_span(name="codex_request", span_type="main", start_time=root_start_dt) as root_span:
|
|
649
687
|
root_span.set_runtime(Runtime(library="codex-cli"))
|
|
650
688
|
root_tags = {
|
|
651
689
|
"thread_id": session_id,
|
|
652
690
|
"total_turns": len(turns),
|
|
653
691
|
"source": "codex_cli",
|
|
654
692
|
}
|
|
693
|
+
if root_start_dt is not None and root_end_dt is not None:
|
|
694
|
+
root_tags["real_start_ms"] = _ts_ms(root_start_dt)
|
|
695
|
+
root_tags["real_end_ms"] = _ts_ms(root_end_dt)
|
|
696
|
+
root_tags["latency_ms"] = _ts_ms(root_end_dt) - _ts_ms(root_start_dt)
|
|
655
697
|
root_baggage = {
|
|
656
698
|
"thread_id": session_id,
|
|
657
699
|
}
|
|
@@ -689,20 +731,49 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
|
|
|
689
731
|
# Process each turn
|
|
690
732
|
for i, turn in enumerate(turns):
|
|
691
733
|
try:
|
|
692
|
-
|
|
734
|
+
# turn 真实时间:start=user payload 时间;end=最后一条 assistant payload 时间
|
|
735
|
+
turn_start_dt = _parse_ts(turn.get("user_message"))
|
|
736
|
+
turn_end_dt = None
|
|
737
|
+
for _a in turn.get("assistant_messages", []):
|
|
738
|
+
_ad = _parse_ts(_a)
|
|
739
|
+
if _ad:
|
|
740
|
+
turn_end_dt = _ad
|
|
741
|
+
if turn_start_dt is None:
|
|
742
|
+
turn_start_dt = turn_end_dt
|
|
743
|
+
|
|
744
|
+
with client.start_span(name=f"turn_{i}", span_type="main", start_time=turn_start_dt) as turn_span:
|
|
693
745
|
turn_span.set_runtime(Runtime(library="codex-cli"))
|
|
694
|
-
|
|
746
|
+
_turn_tags = {
|
|
695
747
|
"thread_id": session_id,
|
|
696
748
|
"turn_index": i,
|
|
697
749
|
"turn_id": turn.get("turn_id", ""),
|
|
698
750
|
"source": "codex_cli",
|
|
699
|
-
}
|
|
751
|
+
}
|
|
752
|
+
if turn_start_dt is not None and turn_end_dt is not None:
|
|
753
|
+
_turn_tags["real_start_ms"] = _ts_ms(turn_start_dt)
|
|
754
|
+
_turn_tags["real_end_ms"] = _ts_ms(turn_end_dt)
|
|
755
|
+
_turn_tags["latency_ms"] = _ts_ms(turn_end_dt) - _ts_ms(turn_start_dt)
|
|
756
|
+
turn_span.set_tags(_turn_tags)
|
|
700
757
|
|
|
701
758
|
# --- Model span for assistant response ---
|
|
702
759
|
if turn.get("assistant_messages"):
|
|
703
|
-
|
|
760
|
+
# model span start:第一条 assistant payload 时间,回退到 turn 起点
|
|
761
|
+
_model_start_dt = None
|
|
762
|
+
for _a in turn.get("assistant_messages", []):
|
|
763
|
+
_model_start_dt = _parse_ts(_a)
|
|
764
|
+
if _model_start_dt:
|
|
765
|
+
break
|
|
766
|
+
if _model_start_dt is None:
|
|
767
|
+
_model_start_dt = turn_start_dt
|
|
768
|
+
with client.start_span(name="assistant_response", span_type="model", start_time=_model_start_dt) as model_span:
|
|
704
769
|
model_span.set_runtime(Runtime(library="codex-cli"))
|
|
705
770
|
model_span.set_model_name(model_name)
|
|
771
|
+
if _model_start_dt is not None and turn_end_dt is not None:
|
|
772
|
+
model_span.set_tags({
|
|
773
|
+
"real_start_ms": _ts_ms(_model_start_dt),
|
|
774
|
+
"real_end_ms": _ts_ms(turn_end_dt),
|
|
775
|
+
"latency_ms": _ts_ms(turn_end_dt) - _ts_ms(_model_start_dt),
|
|
776
|
+
})
|
|
706
777
|
|
|
707
778
|
# Build input messages: history + current turn input
|
|
708
779
|
turn_input = turn.get("input_messages", [])
|
|
@@ -770,7 +841,7 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
|
|
|
770
841
|
# --- Tool call spans ---
|
|
771
842
|
for tool_call in turn.get("tool_calls", []):
|
|
772
843
|
tool_name = tool_call.get("name", "unknown")
|
|
773
|
-
with client.start_span(name=f"tool_{tool_name}", span_type="tool") as tool_span:
|
|
844
|
+
with client.start_span(name=f"tool_{tool_name}", span_type="tool", start_time=turn_start_dt) as tool_span:
|
|
774
845
|
tool_span.set_runtime(Runtime(library="codex-cli"))
|
|
775
846
|
tool_span.set_tags({
|
|
776
847
|
"tool_name": tool_name,
|
|
@@ -794,7 +865,7 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
|
|
|
794
865
|
agent_id = sc.get("agent_id") or "unknown"
|
|
795
866
|
nickname = sc.get("nickname") or agent_id
|
|
796
867
|
|
|
797
|
-
with client.start_span(name=f"subagent_{nickname}", span_type="agent") as subagent_span:
|
|
868
|
+
with client.start_span(name=f"subagent_{nickname}", span_type="agent", start_time=turn_start_dt) as subagent_span:
|
|
798
869
|
subagent_span.set_runtime(Runtime(library="codex-cli"))
|
|
799
870
|
subagent_span.set_tags({
|
|
800
871
|
"agent_id": agent_id,
|
|
@@ -36,7 +36,7 @@ async function _refreshToken(refreshTok) {
|
|
|
36
36
|
const body = JSON.stringify({ grant_type: "refresh_token", client_id: _CLIENT_ID, refresh_token: refreshTok });
|
|
37
37
|
const req = https.request(`${_COZE_API}/api/permission/oauth2/token`, {
|
|
38
38
|
method: "POST",
|
|
39
|
-
headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) },
|
|
39
|
+
headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body), "x-tt-env": "ppe_cozelab", "x-use-ppe": "1" },
|
|
40
40
|
}, (res) => {
|
|
41
41
|
let buf = "";
|
|
42
42
|
res.on("data", c => buf += c);
|
|
@@ -170,6 +170,8 @@ export class CozeloopExporter {
|
|
|
170
170
|
headers: {
|
|
171
171
|
"Authorization": authorization,
|
|
172
172
|
"cozeloop-workspace-id": workspaceId,
|
|
173
|
+
"x-tt-env": "ppe_cozelab",
|
|
174
|
+
"x-use-ppe": "1",
|
|
173
175
|
},
|
|
174
176
|
});
|
|
175
177
|
this.provider = new BasicTracerProvider({ resource });
|