@yxzpro/agent-acta 2.14.4
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/LICENSE +21 -0
- package/LOGFORMATS.md +960 -0
- package/README.md +238 -0
- package/REFERENCE.md +1428 -0
- package/agent-acta-page.html +1998 -0
- package/agent-acta-server.mjs +1066 -0
- package/agent-acta-widget.html +724 -0
- package/archive.mjs +335 -0
- package/core/log.mjs +20 -0
- package/core/service.mjs +4285 -0
- package/hooks/cursor-usage-hook.mjs +89 -0
- package/mcp-server.mjs +372 -0
- package/package.json +68 -0
- package/page/dialog-add-agent.js +65 -0
- package/page/dialog-alert.js +159 -0
- package/page/dialog-archive.js +342 -0
- package/page/dialog-compare.js +238 -0
- package/page/dialog-ctxw.js +101 -0
- package/page/dialog-diag.js +72 -0
- package/page/dialog-heatmap.js +215 -0
- package/page/dialog-selftest.js +202 -0
- package/page/dialog-sess-timeline.js +108 -0
- package/page/dialog-stats.js +694 -0
- package/page/dialog-usage.js +158 -0
- package/page/log-card.js +277 -0
- package/page/log-view.js +273 -0
- package/page/sess-view.js +382 -0
- package/page/shared.js +408 -0
- package/page/sidebar.js +129 -0
- package/page/style.css +973 -0
- package/page/topbar.js +61 -0
- package/parsers/atomcode.mjs +321 -0
- package/parsers/buddy.mjs +272 -0
- package/parsers/buddyext.mjs +582 -0
- package/parsers/claude.mjs +496 -0
- package/parsers/cline.mjs +468 -0
- package/parsers/codearts.mjs +96 -0
- package/parsers/codex.mjs +188 -0
- package/parsers/comate.mjs +195 -0
- package/parsers/copilot.mjs +308 -0
- package/parsers/cursor.mjs +493 -0
- package/parsers/devindb.mjs +358 -0
- package/parsers/discovery.mjs +1230 -0
- package/parsers/doubao.mjs +406 -0
- package/parsers/dsh.mjs +377 -0
- package/parsers/gemini.mjs +267 -0
- package/parsers/generic.mjs +494 -0
- package/parsers/hermes.mjs +398 -0
- package/parsers/kilo.mjs +42 -0
- package/parsers/kimi.mjs +249 -0
- package/parsers/mimocode.mjs +531 -0
- package/parsers/minimax.mjs +503 -0
- package/parsers/openclaw.mjs +490 -0
- package/parsers/opencode.mjs +465 -0
- package/parsers/shared.mjs +418 -0
- package/parsers/trace.mjs +66 -0
- package/parsers/tracecode.mjs +348 -0
- package/parsers/traedb.mjs +528 -0
- package/parsers/zcode.mjs +356 -0
- package/plugin/client.js +51 -0
- package/plugin/cordis.patch.yml +5 -0
- package/plugin/plugin.mjs +220 -0
- package/search.mjs +554 -0
- package/tools/grab-trae-key.ps1 +265 -0
- package/vendor/element-plus-icons.js +3 -0
- package/vendor/element-plus.css +1 -0
- package/vendor/element-plus.dark.css +1 -0
- package/vendor/element-plus.full.min.js +65 -0
- package/vendor/element-plus.zh-cn.js +2 -0
- package/vendor/svg/antgroup-color.svg +1 -0
- package/vendor/svg/bytedance-color.svg +1 -0
- package/vendor/svg/chatglm-color.svg +1 -0
- package/vendor/svg/claude-color.svg +1 -0
- package/vendor/svg/claudecode-color.svg +1 -0
- package/vendor/svg/cline.svg +1 -0
- package/vendor/svg/codebuddy-color.svg +1 -0
- package/vendor/svg/codebuddy.svg +1 -0
- package/vendor/svg/codex-color.svg +1 -0
- package/vendor/svg/copilot.svg +1 -0
- package/vendor/svg/cursor.svg +1 -0
- package/vendor/svg/deepseek-color.svg +1 -0
- package/vendor/svg/deepseek-harness-whale.svg +8 -0
- package/vendor/svg/doubao-color.svg +1 -0
- package/vendor/svg/gemini-color.svg +1 -0
- package/vendor/svg/glmv-color.svg +1 -0
- package/vendor/svg/grok.svg +1 -0
- package/vendor/svg/hermesagent.svg +1 -0
- package/vendor/svg/huawei-color.svg +1 -0
- package/vendor/svg/hunyuan-color.svg +1 -0
- package/vendor/svg/kilocode-avatar.svg +1 -0
- package/vendor/svg/kilocode.svg +1 -0
- package/vendor/svg/kimi-color.svg +1 -0
- package/vendor/svg/kimi.svg +1 -0
- package/vendor/svg/logo-AtomGit-G-red.svg +3 -0
- package/vendor/svg/logo-AtomGit-dark.svg +4 -0
- package/vendor/svg/logo-AtomGit-light.svg +4 -0
- package/vendor/svg/meta-color.svg +1 -0
- package/vendor/svg/mimo.svg +66 -0
- package/vendor/svg/minimax-code-logo.svg +8 -0
- package/vendor/svg/minimax-color.svg +1 -0
- package/vendor/svg/moonshot.svg +1 -0
- package/vendor/svg/nex-color.svg +1 -0
- package/vendor/svg/nex.svg +1 -0
- package/vendor/svg/nvidia-color.svg +1 -0
- package/vendor/svg/openai.svg +1 -0
- package/vendor/svg/openclaw-color.svg +1 -0
- package/vendor/svg/openclaw.svg +1 -0
- package/vendor/svg/opencode.svg +1 -0
- package/vendor/svg/openrouter-color.svg +1 -0
- package/vendor/svg/qoder-color.svg +1 -0
- package/vendor/svg/qwen-color.svg +1 -0
- package/vendor/svg/sensenova-color.svg +1 -0
- package/vendor/svg/stepfun.svg +1 -0
- package/vendor/svg/trae-color.svg +1 -0
- package/vendor/svg/traework-v2.svg +1 -0
- package/vendor/svg/wenxin-color.svg +1 -0
- package/vendor/svg/windsurf.svg +1 -0
- package/vendor/svg/workbuddy-color.svg +29 -0
- package/vendor/svg/xiaomimimo.svg +1 -0
- package/vendor/svg/yuanbao-color.svg +1 -0
- package/vendor/svg/yuanbao.svg +1 -0
- package/vendor/svg/zai.svg +1 -0
- package/vendor/svg/zhipu-color.svg +1 -0
- package/vendor/vue.global.prod.js +9 -0
- package/widget/main.js +202 -0
- package/widget/package.json +7 -0
- package/widget/preload.js +11 -0
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
// ---------------- claude 解析器(~/.claude/projects/<slug>/*.jsonl) ----------------
|
|
2
|
+
// 从 agent-acta-server.mjs 拆出(纯搬家,逻辑零改动)。导出 scanClaude / claudeEntryContent +
|
|
3
|
+
// claudeUserText / claudeIsToolResultOnly / claudeIsCommandText(详情与扫描共用同一套口径函数)。
|
|
4
|
+
// 逐行增量解析 transcript:user 文本消息开新 turn,assistant 累加 usage/tools
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { files, entries, addEntry, modelName, modelList, windowOf, markKind, listDirCached, isDir, trunc, readCompleteLines } from './shared.mjs';
|
|
8
|
+
|
|
9
|
+
export function claudeUserText(content) {
|
|
10
|
+
if (typeof content === 'string') return content;
|
|
11
|
+
if (Array.isArray(content)) {
|
|
12
|
+
const texts = content.filter(b => b && b.type === 'text').map(b => b.text || '');
|
|
13
|
+
return texts.join('\n');
|
|
14
|
+
}
|
|
15
|
+
return '';
|
|
16
|
+
}
|
|
17
|
+
export function claudeIsToolResultOnly(content) {
|
|
18
|
+
return Array.isArray(content) && content.length > 0 && content.every(b => b && (b.type === 'tool_result'));
|
|
19
|
+
}
|
|
20
|
+
// /clear、!命令 等命令消息不是 LLM 请求,不产生 turn(扫描与详情两处必须同一口径,否则轮次对不上)
|
|
21
|
+
export function claudeIsCommandText(text0) {
|
|
22
|
+
const t = String(text0).trimStart();
|
|
23
|
+
return t.startsWith('<command-name>') || t.startsWith('<local-command');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function claudeIdBase(fp) { return 'c#' + fp.replace(/[\\/:]/g, '~') + '#'; }
|
|
27
|
+
// qoder-cn 专属字段(claude 转录里没有,别家解析出的条目也不该带):
|
|
28
|
+
// credits / ctxRatio —— 来自 message.usage(qoder 的 token 全 0,真实用量只有积分和上下文占比)。
|
|
29
|
+
// 压缩画像(compacts / compPre / compPost / compDrop / compMs / compAuto / compManual)**不是**
|
|
30
|
+
// qoder 专属:源头是 compact_boundary 系统行,claude 与 qoder 都有(I12,见下方 scanClaude)。
|
|
31
|
+
function emitClaudeTurns(agent, fp, slug, data, fromIdx) {
|
|
32
|
+
const isQoder = agent === 'qoder' || agent === 'qoder-cn';
|
|
33
|
+
const idBase = claudeIdBase(fp);
|
|
34
|
+
// I16 子 agent 拓扑:`data.link` 是**这一份转录自己的谱系**(主转录为 null = 根会话;
|
|
35
|
+
// `subagents/agent-*.jsonl` 那些带 parent/depth/label,见 scanClaudeSub)。
|
|
36
|
+
// 源头给的是显式字段(目录层级 + .meta.json 的 spawnDepth / description),不是按时间区间猜的。
|
|
37
|
+
const link = data.link || null;
|
|
38
|
+
for (let i = fromIdx; i < data.turns.length; i++) {
|
|
39
|
+
const t = data.turns[i];
|
|
40
|
+
const total = t.tin + t.tout + t.tcache;
|
|
41
|
+
addEntry(idBase + t.idx, {
|
|
42
|
+
// 子转录的项目**跟父走**(link.project,见 claudeParentProject):它自己的 cwd 常是父项目的
|
|
43
|
+
// 子目录,照抄会让父子落到两个 projectKey、认父失败。拿不到父项目时才退回自己的 cwd。
|
|
44
|
+
agent, project: (link && link.project) || data.cwd || slug, session: path.basename(fp, '.jsonl'),
|
|
45
|
+
// 谱系三件套。根会话为 undefined ⇒ JSON 丢键,页面按「根」处理,不编层级。
|
|
46
|
+
parent: link ? link.parent : undefined,
|
|
47
|
+
depth: link ? link.depth : undefined,
|
|
48
|
+
sub: link ? link.label : undefined,
|
|
49
|
+
// I14 会话命名:会话列表里这些会话以前全是裸 UUID,而名字**源头一直有** —— 就是产品自己
|
|
50
|
+
// 写的 ai-title(见下 scanClaude 里的落点)。name 落在每一轮上,会话列表(sessionsAgg)
|
|
51
|
+
// 取「任一轮有值即可」,所以 ai-title 出现在文件哪儿都不影响。
|
|
52
|
+
// 次级兜底是 last-prompt(用户最后一句原话):qoder-cn 实测**只有它、没有 ai-title**。
|
|
53
|
+
name: data.title || data.lastPrompt || null,
|
|
54
|
+
time: t.time, dur: Math.max(0, t.lastTs - t.time), status: t.err ? 'error' : 'ok',
|
|
55
|
+
tin: t.tin, tout: t.tout, tcache: t.tcache, total,
|
|
56
|
+
// ctx = 窗口容量:转录里没有这个字段,按模型名查 CTX_WINDOWS 表,认不出就是 0
|
|
57
|
+
// (页面此时只显示占用、不画进度条 —— 见页面对 ctxUsed 的兜底)。
|
|
58
|
+
// ctxUsed = 这一轮结束时的实际占用(轮内**最后一次**调用的 prompt 全量,非累加)。
|
|
59
|
+
ctx: windowOf(t.models), ctxUsed: t.ctxUsed || null,
|
|
60
|
+
rounds: t.calls, tools: t.tools, calls: t.calls, // claude: rounds=calls=轮内 assistant 消息数
|
|
61
|
+
// models 在这里再过一遍 modelList:持久化状态里可能存的是首次扫描时映射表尚未建立
|
|
62
|
+
// 而烤进去的原始 key(如 qoder 的 q37fmodel),恢复路径直接重放会显示假模型名。
|
|
63
|
+
// 存原始 key 反而更好(保留源信息),这里用当前映射表纠正即可。
|
|
64
|
+
models: modelList(t.models), preview: t.preview,
|
|
65
|
+
// I15 工作指纹(2026-09-23):branch(git 分支)/ permMode(权限模式)/ cliVer(产品版本)。
|
|
66
|
+
// 只有 claude 源有这三样(见 scanClaude 里 curBranch / curVer / curPerm 三个游标);
|
|
67
|
+
// qoder / qoder-cn 走同一个解析器但转录里没有这些字段 → undefined → JSON 序列化自动丢掉,
|
|
68
|
+
// 页面按「无此维度」如实为空、不印 0(与 qoder「无 token 就不印 0」同一条约定)。
|
|
69
|
+
branch: t.branch, permMode: t.permMode, cliVer: t.cliVer,
|
|
70
|
+
// R19:claude 的转录没有「轮结束」标志(被打断就是戛然而止),服务端只能推算 ——
|
|
71
|
+
// 后面还有轮 = 这一轮确定结束了;最后一轮收不了口,由页面按最后活动时间兜底。
|
|
72
|
+
finished: i < data.turns.length - 1,
|
|
73
|
+
aborted: !!t.aborted, // 用户中断("[Request interrupted by user]"),与 status:'error' 独立
|
|
74
|
+
// I17 非 LLM 时间分解(2026-09-24):rdur = 产品在轮末自报的 turn_duration.durationMs,
|
|
75
|
+
// 是这一轮的**真实工作时间**(剔除用户离开/隔夜的 idle gap)。与墙钟 dur(首条→末条
|
|
76
|
+
// 时间差)并存,页面给「墙钟 / 实际」两列并说明差额来源 —— 不直接用 rdur 替换 dur,
|
|
77
|
+
// 那会改变 status/排序/告警的既有语义。只有 claude 转录有 turn_duration:
|
|
78
|
+
// qoder / qoder-cn 走同一解析器但没有该事件 → rdur 保持 undefined → JSON 序列化自动丢掉
|
|
79
|
+
//(与 branch 同一条约定)。轮进行中(文件尾还没发 turn_duration)同样为 undefined。
|
|
80
|
+
rdur: t.rdur || undefined, rdurMsg: t.rdurMsg || undefined,
|
|
81
|
+
toolNames: t.toolNames || {},
|
|
82
|
+
// 仅 qoder 落这两个字段:页面据此把「积分 / 上下文占比」替进卡片
|
|
83
|
+
//(qoder 的 token 恒为 0,按原样渲染会是一排假 0)。
|
|
84
|
+
...(isQoder ? { credits: t.credits || 0, ctxRatio: t.ctxRatio || 0 } : {}),
|
|
85
|
+
// I12 上下文压缩画像(2026-09-28):**不再只在 qoder 分支里** —— 源头(compact_boundary)
|
|
86
|
+
// claude 与 qoder 都有,scanClaude 一直无条件解析,只是以前被上面的 isQoder 守卫丢掉了。
|
|
87
|
+
// compDrop(**本轮**丢弃 token,= 本轮各次压缩的会话累计增量之和)只有 claude 源有 ⇒
|
|
88
|
+
// 缺席时落 undefined、JSON 自动丢,
|
|
89
|
+
// 页面据「有没有这一位」决定说不说,绝不印一个假 0(qoder 会被读成「压了却没丢 token」);
|
|
90
|
+
// compMs(压缩等待)/ compAuto / compManual(自动 / 手动计数)两家都有。
|
|
91
|
+
compacts: t.compacts || 0, compPre: t.compPre || 0, compPost: t.compPost || 0,
|
|
92
|
+
compDrop: t.compDrop ?? undefined, compMs: t.compMs ?? undefined,
|
|
93
|
+
compAuto: t.compAuto || 0, compManual: t.compManual || 0,
|
|
94
|
+
}, { file: fp, turn: t.idx, kind: 'claude' });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// onlyFile:分片首扫用 —— 本次只处理这一个文件,让首扫能按「一个会话文件」为粒度让出事件循环。
|
|
99
|
+
// 不传(同步扫描)时行为和以前完全一样;listDirCached 有缓存,反复调用不额外读盘。
|
|
100
|
+
export function scanClaude(agent, root, onlyFile) {
|
|
101
|
+
const slugs = listDirCached(root);
|
|
102
|
+
if (!slugs) return;
|
|
103
|
+
// onlySlug 提前算好并**排在 isDir 之前**:分片时每次只处理一个文件,而这个循环是「所有 slug × 每次调用」,
|
|
104
|
+
// 把 isDir(一次 statSync)留在前面会让 stat 次数变成 slug 数 × 文件数(本机 workbuddy 34×59 ≈ 2000 次,
|
|
105
|
+
// 白白多花 ~0.2s)。path.relative 是纯字符串运算,放在最前面就能把这一坨整个省掉。
|
|
106
|
+
// ⚠️ I16 改的是**判据粒度**(原为 dir 全等,现在只比 slug):子转录在
|
|
107
|
+
// <root>/<slug>/<会话>/subagents/ 下,它的 dirname 不是 <root>/<slug>,用 dir 全等会把这一整批
|
|
108
|
+
// 从分片首扫里整段跳过(同步扫描看不到这个问题,只有首扫分片路径会漏)。
|
|
109
|
+
const onlySlug = onlyFile ? path.relative(root, onlyFile).split(path.sep)[0] : null;
|
|
110
|
+
for (const slug of slugs) {
|
|
111
|
+
const dir = path.join(root, slug);
|
|
112
|
+
if (onlySlug && slug !== onlySlug) continue; // 不是这个项目目录,整段跳过
|
|
113
|
+
if (!isDir(dir)) continue;
|
|
114
|
+
const names = listDirCached(dir);
|
|
115
|
+
if (!names) continue;
|
|
116
|
+
for (const n of names) {
|
|
117
|
+
if (!n.endsWith('.jsonl')) continue;
|
|
118
|
+
const fp = path.join(dir, n);
|
|
119
|
+
if (onlyFile && fp !== onlyFile) continue;
|
|
120
|
+
scanClaudeFile(agent, fp, slug, null);
|
|
121
|
+
}
|
|
122
|
+
// I16 子 agent 拓扑:claude 把子 agent 的转录写在**会话目录下面的 subagents/ 子目录**里
|
|
123
|
+
// ~/.claude/projects/<slug>/<父会话 uuid>/subagents/agent-<id>.jsonl(本机实测 27 份)。
|
|
124
|
+
// 以前这一层**整个没扫**(只读 <slug>/*.jsonl),所以子 agent 的轮次在面板上完全不存在。
|
|
125
|
+
// 谱系是源头写死的三层:① 目录层级(subagents/ 的父目录名 = 父会话 uuid);
|
|
126
|
+
// ② 转录自己的 sessionId 字段(= 父会话 uuid,与父转录的 session 值同形,能直接对上);
|
|
127
|
+
// ③ 同目录的 <同名>.meta.json(agentType / description / spawnDepth)—— 子 agent 是干嘛的。
|
|
128
|
+
// 不靠时间区间推、不猜:三条都是显式字段。
|
|
129
|
+
for (const sess of listDirCached(dir) || []) {
|
|
130
|
+
const subDir = path.join(dir, sess, 'subagents');
|
|
131
|
+
const subNames = listDirCached(subDir);
|
|
132
|
+
if (!subNames) continue;
|
|
133
|
+
for (const sn of subNames) {
|
|
134
|
+
if (!sn.endsWith('.jsonl')) continue;
|
|
135
|
+
const fp = path.join(subDir, sn);
|
|
136
|
+
if (onlyFile && fp !== onlyFile) continue;
|
|
137
|
+
// parentFp 是给「子转录继承父会话的项目」用的,见 claudeParentProject
|
|
138
|
+
scanClaudeFile(agent, fp, slug, { dir: subDir, parentSess: sess, parentFp: path.join(dir, sess + '.jsonl') });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// I16:子 agent 转录旁边的那份 `<同名>.meta.json`。它是**唯一**能回答「这个子 agent 是干嘛的」的地方:
|
|
145
|
+
// {"agentType":"Explore","description":"Find docs referencing data dir","toolUseId":"…","spawnDepth":1}
|
|
146
|
+
// 读不出来(没有 / 坏 JSON)就如实返回空 —— 谱系本身仍由目录层级给出,不因为缺这份文件而丢掉父子关系。
|
|
147
|
+
// 名字有上限:description 可能是一整句派活原文,截到 80 字随条目下发(与 preview 同一条「够看就行」约定)。
|
|
148
|
+
const claudeSubMetaCache = new Map(); // fp -> {m, v}
|
|
149
|
+
function claudeSubMeta(dir, base) {
|
|
150
|
+
const fp = path.join(dir, base + '.meta.json');
|
|
151
|
+
let st; try { st = fs.statSync(fp); } catch { return {}; }
|
|
152
|
+
const hit = claudeSubMetaCache.get(fp);
|
|
153
|
+
if (hit && hit.m === st.mtimeMs && hit.s === st.size) return hit.v;
|
|
154
|
+
let v = {};
|
|
155
|
+
try {
|
|
156
|
+
const j = JSON.parse(fs.readFileSync(fp, 'utf8'));
|
|
157
|
+
const desc = String(j.description || '').replace(/\s+/g, ' ').trim();
|
|
158
|
+
v = {
|
|
159
|
+
agentType: j.agentType ? String(j.agentType) : '',
|
|
160
|
+
label: desc ? desc.slice(0, 80) : '',
|
|
161
|
+
spawnDepth: Number(j.spawnDepth) > 0 ? Number(j.spawnDepth) : 0,
|
|
162
|
+
};
|
|
163
|
+
} catch { /* 坏 JSON:只丢描述,父子关系照旧由目录给出 */ }
|
|
164
|
+
if (claudeSubMetaCache.size > 256) claudeSubMetaCache.clear();
|
|
165
|
+
claudeSubMetaCache.set(fp, { m: st.mtimeMs, s: st.size, v });
|
|
166
|
+
return v;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// I16:子转录要继承**父会话的项目**,不能照抄自己的 cwd。
|
|
170
|
+
// 为什么:claude 子 agent 的 cwd 常是父项目的**子目录**(本机实测 `F:\centos\agentLog\agent-acta`、
|
|
171
|
+
// `…\agent-log-plugin\agent-log-hook`),而 project 会被归一化成 projectKey;两边一旦不同,服务端
|
|
172
|
+
// 按 (agent, projectKey, key) 认父就查不到(agent-acta-server.mjs 的 sessionsAgg),这批子会话
|
|
173
|
+
// 就如实降级成根 —— 树断掉、父行少一截合计(2026-09-28 实测 27 份里丢 10 份)。
|
|
174
|
+
// 父的 cwd 取两条路,**都不依赖扫描顺序**:① 本进程已扫过这个父(内存态,热启动走这条);
|
|
175
|
+
// ② 直接读父转录开头找第一条带 cwd 的行 —— 首扫分片下 `subagents/` 排在 `<会话>.jsonl` 前面
|
|
176
|
+
// (sliceFiles 先推目录项、再推它下面的子文件),内存态这时往往还没有。
|
|
177
|
+
// 两条都拿不到(父转录不在盘上)→ 返回空,调用方如实退回自己的 cwd,不编一个项目出来。
|
|
178
|
+
const claudeCwdCache = new Map(); // parentFp -> cwd
|
|
179
|
+
function claudeParentProject(fp) {
|
|
180
|
+
const st = files.get(fp);
|
|
181
|
+
if (st?.data?.cwd) return st.data.cwd;
|
|
182
|
+
if (claudeCwdCache.has(fp)) return claudeCwdCache.get(fp);
|
|
183
|
+
let v = '';
|
|
184
|
+
try {
|
|
185
|
+
const fst = fs.statSync(fp);
|
|
186
|
+
const rd = readCompleteLines(fp, 0, Math.min(fst.size, 262144));
|
|
187
|
+
for (const line of (rd ? rd.lines : [])) {
|
|
188
|
+
if (!line.includes('"cwd"')) continue;
|
|
189
|
+
let j; try { j = JSON.parse(line); } catch { continue; }
|
|
190
|
+
if (j.cwd) { v = String(j.cwd); break; }
|
|
191
|
+
}
|
|
192
|
+
} catch { /* 父转录读不动:如实留空 */ }
|
|
193
|
+
if (claudeCwdCache.size > 256) claudeCwdCache.clear();
|
|
194
|
+
claudeCwdCache.set(fp, v);
|
|
195
|
+
return v;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// 读一份 claude 转录(主转录或 subagents/*.jsonl)。sub 非空 = 这是子 agent 转录,带谱系上下文。
|
|
199
|
+
export function scanClaudeFile(agent, fp, slug, sub) {
|
|
200
|
+
let fst; try { fst = fs.statSync(fp); } catch { return; }
|
|
201
|
+
const key = fp, m = fst.mtimeMs, s = fst.size;
|
|
202
|
+
const prev = files.get(key);
|
|
203
|
+
if (prev && prev.m === m && prev.s === s) {
|
|
204
|
+
// 重启后 entries 为空但 files 状态被恢复:从状态直接重建条目,不重扫文件
|
|
205
|
+
if (prev.data?.turns?.length && !entries.has(claudeIdBase(fp) + (prev.data.turns.length - 1))) emitClaudeTurns(agent, fp, slug, prev.data, 0);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
const off = prev?.off || 0;
|
|
209
|
+
// title / lastPrompt 是 I14 加的会话名(老 state 没有这两个键,undefined 即「还没读到」)
|
|
210
|
+
// curBranch / curVer / curPerm 是 I15 加的工作指纹游标(git 分支 / 产品版本 / 权限模式):
|
|
211
|
+
// 行是顺序读的,游标 = 「到当前行为止最后一次声明」,开轮时快照进轮、行尾回写当前轮 ——
|
|
212
|
+
// 这样轮内取到的永远是**最后一次声明**(会话中途切分支/切权限,后面的才是当前值)。
|
|
213
|
+
const data = prev?.data || { cwd: '', title: '', lastPrompt: '', turns: [], emitted: 0, lastCum: 0 };
|
|
214
|
+
// I16:谱系只认**子转录**这一层(主转录 link = null = 根会话)。link 随 index 一起落,
|
|
215
|
+
// 老 index 没有 link ⇒ 恢复出来就是根会话 —— 与「老会话本来也没扫过子转录」自洽。
|
|
216
|
+
if (sub) {
|
|
217
|
+
const meta = claudeSubMeta(sub.dir, path.basename(fp, '.jsonl'));
|
|
218
|
+
data.link = {
|
|
219
|
+
parent: sub.parentSess,
|
|
220
|
+
depth: meta.spawnDepth > 0 ? meta.spawnDepth : 1,
|
|
221
|
+
label: meta.label,
|
|
222
|
+
// 继承父项目的 cwd(拿不到则空串,emitClaudeTurns 退回自己的 cwd)
|
|
223
|
+
project: sub.parentFp ? claudeParentProject(sub.parentFp) : '',
|
|
224
|
+
agentType: meta.agentType,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
if (s <= off) { files.set(key, { agent, kind: 'claude', m, s, off, data }); markKind('claude'); return; }
|
|
228
|
+
try {
|
|
229
|
+
const rd = readCompleteLines(fp, off, s);
|
|
230
|
+
if (!rd) { files.set(key, { agent, kind: 'claude', m, s, off, data }); markKind('claude'); return; } // 尚无完整行
|
|
231
|
+
const newOff = rd.newOff;
|
|
232
|
+
for (const line of rd.lines) {
|
|
233
|
+
if (!line.trim()) continue;
|
|
234
|
+
let j; try { j = JSON.parse(line); } catch { continue; }
|
|
235
|
+
if (j.cwd && !data.cwd) data.cwd = j.cwd;
|
|
236
|
+
// I15 工作指纹游标(2026-09-23):git 分支 / 产品版本 / 权限模式。
|
|
237
|
+
// gitBranch / version 在**几乎所有顶层行**上(user / assistant / attachment / system…);
|
|
238
|
+
// permissionMode 在 permission-mode 行(**无时间戳**,现有分支都不处理它,正好在此层收下)
|
|
239
|
+
// 与部分 user 行上。统一在这层更新游标:行是顺序读的,先声明、后续行覆盖,
|
|
240
|
+
// 最后一行 = 到当前为止的**最后一次声明**。轮内取最后一条(会话中途切分支/切权限,
|
|
241
|
+
// 后面的才是当前值)。qoder 转录没有这些字段 → 游标保持 undefined → 如实无此维度。
|
|
242
|
+
if (j.gitBranch) data.curBranch = String(j.gitBranch);
|
|
243
|
+
if (j.version) data.curVer = String(j.version);
|
|
244
|
+
if (j.permissionMode) data.curPerm = String(j.permissionMode);
|
|
245
|
+
// I14 会话命名:**产品自己起的标题**,源头一直有、只是以前没读 —— 会话列表里这 100 多个
|
|
246
|
+
// claude 会话以前全是裸 UUID(见 agent-log-ideas.md 的 I14)。
|
|
247
|
+
// ai-title(本机 3718 条,{"aiTitle":"ZCode 日志数据源适配"})是首选,质量就是给人看的会话名;
|
|
248
|
+
// last-prompt(3737 条,用户最后一句原话)是次级兜底 —— qoder-cn 实测**只有它、没有 ai-title**。
|
|
249
|
+
// 两者都取**最后一条**:会话进行中产品会重写标题,后面的才是当前的。
|
|
250
|
+
// 这两行没有 timestamp,必须在下面取 ts 之前处理(它们不开轮、不进 lastTs)。
|
|
251
|
+
if (j.type === 'ai-title' && j.aiTitle) {
|
|
252
|
+
const s = String(j.aiTitle).replace(/\s+/g, ' ').trim();
|
|
253
|
+
if (s) data.title = s.slice(0, 60);
|
|
254
|
+
} else if (j.type === 'last-prompt' && j.lastPrompt) {
|
|
255
|
+
const s = String(j.lastPrompt).replace(/\s+/g, ' ').trim();
|
|
256
|
+
if (s) data.lastPrompt = s.slice(0, 60);
|
|
257
|
+
}
|
|
258
|
+
const ts = j.timestamp ? Date.parse(j.timestamp) : 0;
|
|
259
|
+
if (j.type === 'user') {
|
|
260
|
+
const content = j.message?.content;
|
|
261
|
+
if (claudeIsToolResultOnly(content)) continue; // 工具结果不开新轮
|
|
262
|
+
const text0 = claudeUserText(content);
|
|
263
|
+
if (!text0) continue;
|
|
264
|
+
if (claudeIsCommandText(text0)) continue; // 命令消息不算请求
|
|
265
|
+
// 用户中断(Esc):转录里是一条 user 角色的 "[Request interrupted by user…]",
|
|
266
|
+
// 它不是真实请求,但按「开卡标特殊状态」口径保留记录 —— aborted=true,页面灰显「已打断」。
|
|
267
|
+
const interrupted = /^\[Request interrupted by user( for tool use)?\]$/.test(text0.trim());
|
|
268
|
+
data.turns.push({
|
|
269
|
+
idx: data.turns.length, time: ts, lastTs: ts,
|
|
270
|
+
tin: 0, tout: 0, tcache: 0, tools: 0, calls: 0, ctxUsed: 0,
|
|
271
|
+
credits: 0, ctxRatio: 0, // qoder 专属,见 emitClaudeTurns
|
|
272
|
+
// I12 压缩画像:compacts / compPre / compPost 三家通用;compDrop / compMs 缺席保持
|
|
273
|
+
// null(源里没有这一位 ⇒ 不冒充 0),compAuto / compManual 是 trigger 的逐条计数。
|
|
274
|
+
compacts: 0, compPre: 0, compPost: 0, compDrop: null, compMs: null, compAuto: 0, compManual: 0,
|
|
275
|
+
models: [], mids: [], err: false, aborted: interrupted,
|
|
276
|
+
// I15:开轮时快照工作指纹游标(当前行已在上面更新过游标,快照即「本轮第一行的声明」;
|
|
277
|
+
// 之后每行行尾回写覆盖,最终 = 轮内最后一次声明)。
|
|
278
|
+
branch: data.curBranch, cliVer: data.curVer, permMode: data.curPerm,
|
|
279
|
+
preview: text0.replace(/\s+/g, ' ').slice(0, 300),
|
|
280
|
+
toolNames: {}, // R20:工具名去重计数(toolNameCounts 说的第二形态:逐行扫到就累计)
|
|
281
|
+
});
|
|
282
|
+
} else if (j.type === 'system' && j.subtype === 'compact_boundary' && data.turns.length) {
|
|
283
|
+
// 上下文压缩事件。这里**无条件**解析并累计到轮状态(claude 与 qoder 都会走到)。
|
|
284
|
+
// ⚠️ 2026-09-23 更正:本行原注释写「claude 转录没有这个 subtype」——**错的**。
|
|
285
|
+
// 本机实测 ~/.claude/projects 下有 39 条 compact_boundary(trigger auto/manual、
|
|
286
|
+
// preTokens/postTokens、durationMs、cumulativeDroppedTokens 齐全)。
|
|
287
|
+
// I12(2026-09-28)已把 emit 侧的 isQoder 守卫松开,落盘 turns 口径随之 bump 到 11。
|
|
288
|
+
const cur = data.turns[data.turns.length - 1];
|
|
289
|
+
const cm = j.compactMetadata || {};
|
|
290
|
+
cur.compacts++;
|
|
291
|
+
cur.compPre = cm.preTokens || 0;
|
|
292
|
+
cur.compPost = cm.postTokens || 0;
|
|
293
|
+
// I12 上下文压缩画像:把「压掉多少 / 等了多久 / 自动还是手动」也落下来。
|
|
294
|
+
// compMs = durationMs(这次压缩的等待时长,claude 与 qoder 都有),按轮累加 —— 它本就是
|
|
295
|
+
// 逐事件的量,累加=「这轮一共等了多久」;
|
|
296
|
+
// compDrop **只有 claude 源有**:源里的 `cumulativeDroppedTokens` 是**会话累计**(每压一次
|
|
297
|
+
// 往上叠,本机实测增量恰等于该次 pre − post),**不是**这次丢的量。所以按轮累加的是
|
|
298
|
+
// 「相邻事件累计值的增量」= 本轮真正丢掉的 token 数(一轮可能压不止一次才累加)。
|
|
299
|
+
// ⚠️ 2026-09-28 修正:原写法直接把累计值逐条相加,于是每轮显示的是「到该轮为止的会话
|
|
300
|
+
// 运行总量」,会话小结再把逐轮值求和 ⇒ 约虚高 (k+1)/2 倍(本机 5 次压缩的会话
|
|
301
|
+
// 777,858 被读成 2,332,127,约 3×)。已在同一次未发版的 I12 内修正并 bump 到 12。
|
|
302
|
+
// ⚠️ 缺席 ≠ 0:源里没有这一位就保持 null(emit 时落 undefined),
|
|
303
|
+
// 否则 qoder 会被读成「压过、却一个 token 都没丢」。
|
|
304
|
+
if (typeof cm.cumulativeDroppedTokens === 'number') {
|
|
305
|
+
cur.compDrop = (cur.compDrop || 0) + (cm.cumulativeDroppedTokens - (data.lastCum || 0));
|
|
306
|
+
data.lastCum = cm.cumulativeDroppedTokens;
|
|
307
|
+
}
|
|
308
|
+
if (typeof cm.durationMs === 'number') cur.compMs = (cur.compMs || 0) + cm.durationMs;
|
|
309
|
+
// trigger 逐条计数:页面要说清「自动压缩」还是「用户手动压的」;
|
|
310
|
+
// 认不出的取值两边都不计 —— 宁可少说,也不替源编一个。
|
|
311
|
+
if (cm.trigger === 'manual') cur.compManual++;
|
|
312
|
+
else if (cm.trigger === 'auto') cur.compAuto++;
|
|
313
|
+
} else if (j.type === 'system' && j.subtype === 'turn_duration' && data.turns.length) {
|
|
314
|
+
// I17 非 LLM 时间分解(2026-09-24):产品在**每轮结束**时发 system/turn_duration,
|
|
315
|
+
// 自报 durationMs = 这一轮的**真实工作时间**(产品侧主动测量,剔除用户离开/隔夜的
|
|
316
|
+
// idle gap),messageCount = 本轮消息数。事件落点 = 轮末 ⇒ 记到当前打开的那一轮,
|
|
317
|
+
// 与 compact_boundary 同一条挂载逻辑(轮进行中读到的是上一轮的 turn_duration,
|
|
318
|
+
// 下一轮 user 消息还没发,所以挂在当前轮正好对上)。墙钟 dur 不动,rdur 并存。
|
|
319
|
+
const cur = data.turns[data.turns.length - 1];
|
|
320
|
+
cur.rdur = j.durationMs || 0;
|
|
321
|
+
cur.rdurMsg = j.messageCount || 0;
|
|
322
|
+
} else if (j.type === 'assistant' && data.turns.length) {
|
|
323
|
+
const cur = data.turns[data.turns.length - 1];
|
|
324
|
+
// 同一 message.id 可能因续写出现多行,去重避免 token 翻倍。
|
|
325
|
+
// 但 tool_use 是独立调用,不能因为 mid 重复就跳过整条消息:qoder-cn 在同一轮内所有 assistant 消息共享同一个 id,
|
|
326
|
+
// 若直接 continue 会把后续含 tool_use 的消息全部丢弃 → tools=0。
|
|
327
|
+
// 处理:mid 命中去重时只跳过 usage/calls/models(token 口径需要去重),tool_use 与时间戳照常累计。
|
|
328
|
+
const mid = j.message?.id;
|
|
329
|
+
let seenMid = false;
|
|
330
|
+
if (mid) {
|
|
331
|
+
if ((seenMid = cur.mids.includes(mid))) { /* duplicate – skip token fields */ }
|
|
332
|
+
else cur.mids.push(mid);
|
|
333
|
+
}
|
|
334
|
+
if (!seenMid) {
|
|
335
|
+
const u = j.message?.usage || {};
|
|
336
|
+
cur.tin += u.input_tokens || 0;
|
|
337
|
+
cur.tout += u.output_tokens || 0;
|
|
338
|
+
cur.tcache += (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0);
|
|
339
|
+
const ctxNow = (u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0);
|
|
340
|
+
if (ctxNow > 0) cur.ctxUsed = ctxNow;
|
|
341
|
+
cur.calls++;
|
|
342
|
+
const model = j.message?.model;
|
|
343
|
+
const mn = modelName(model);
|
|
344
|
+
if (mn && !cur.models.includes(mn)) cur.models.push(mn);
|
|
345
|
+
}
|
|
346
|
+
// qoder:token 全 0,真实用量在 credits(每次调用计费额)与 context_usage_ratio。
|
|
347
|
+
// 必须放在 mid 去重**之外**:qoder 同轮所有 assistant 消息共享同一个 id(见上方去重注释),
|
|
348
|
+
// 放进 seenMid 守卫里的话一轮只累计到第一次调用。claude 没有这两个字段(|| 0 无副作用),
|
|
349
|
+
// 其续写重复行即使带 usage 也不含 credits,不会重复计费。
|
|
350
|
+
{
|
|
351
|
+
const u = j.message?.usage || {};
|
|
352
|
+
cur.credits += u.credits || 0;
|
|
353
|
+
if (u.context_usage_ratio > 0) cur.ctxRatio = u.context_usage_ratio;
|
|
354
|
+
}
|
|
355
|
+
const blocks = j.message?.content;
|
|
356
|
+
if (Array.isArray(blocks)) {
|
|
357
|
+
for (const b of blocks) {
|
|
358
|
+
if (!b || b.type !== 'tool_use') continue;
|
|
359
|
+
cur.tools++;
|
|
360
|
+
const nm = typeof b.name === 'string' && b.name ? b.name : '?';
|
|
361
|
+
cur.toolNames[nm] = (cur.toolNames[nm] || 0) + 1;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
if (j.isApiErrorMessage) cur.err = true;
|
|
365
|
+
if (ts) cur.lastTs = ts;
|
|
366
|
+
}
|
|
367
|
+
// I15:行尾把工作指纹游标回写到**当前轮**(若已开轮)—— 声明在开轮之后的行
|
|
368
|
+
// (permission-mode 无时间戳、可能落在 user 行之后;assistant 行也带 gitBranch/version)
|
|
369
|
+
// 也要让当前轮看到,保证「轮内最后一次声明」成立。快照与回写都做,游标本身也持久化。
|
|
370
|
+
if (data.turns.length) {
|
|
371
|
+
const curT = data.turns[data.turns.length - 1];
|
|
372
|
+
curT.branch = data.curBranch; curT.cliVer = data.curVer; curT.permMode = data.curPerm;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
files.set(key, { agent, kind: 'claude', m, s, off: newOff, data });
|
|
376
|
+
markKind('claude');
|
|
377
|
+
// 从最后一个已发轮起重发(进行中的轮会被原地更新)
|
|
378
|
+
emitClaudeTurns(agent, fp, slug, data, Math.max(0, data.emitted - 1));
|
|
379
|
+
data.emitted = data.turns.length;
|
|
380
|
+
} catch {}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// 详情:重读转录按 src.turn 过滤。开轮判定与扫描侧走同一套谓词(claudeUserText /
|
|
384
|
+
// claudeIsToolResultOnly / claudeIsCommandText),不会一边跳过命令消息、另一边把它算成一轮。
|
|
385
|
+
export function claudeEntryContent(src, full) {
|
|
386
|
+
const fstate = files.get(src.file);
|
|
387
|
+
const version = fstate ? fstate.m + ':' + (fstate.off || 0) : null;
|
|
388
|
+
const lines = fs.readFileSync(src.file, 'utf8').split('\n');
|
|
389
|
+
let turnIdx = -1, prevTs = 0;
|
|
390
|
+
const out = { user: '', assistant: '', tools: [], calls: [], v: version };
|
|
391
|
+
// 一次 API 响应(一个 message.id)在转录里**不是一个记录**:更常见的是一种区块一条记录,
|
|
392
|
+
// 每条都带同一份 usage。实测一份本机会话:33 个 mid 里,[thinking] 一条、每个 [tool_use]
|
|
393
|
+
// 各一条,于是「正文/工具块不在首条上」是常态而不是例外(15 份样本里 694 个带正文的 mid,
|
|
394
|
+
// 687 个的正文不在首条上)。
|
|
395
|
+
// → 所以按 mid **归并**:调用只计一次(取首条,它带 usage 与耗时基准),区块全部收下。
|
|
396
|
+
// 旧写法是「见过这个 mid 就 continue」,等于只认首条:症状是详情里「AI 输出」与
|
|
397
|
+
// 「工具调用」整块空掉(calls 却照常显示,因为调用正是在首条上记的),看着像解析器没接上。
|
|
398
|
+
const callByMid = new Map(); // mid -> 这一行明细(同时用于把正文回填到对应调用)
|
|
399
|
+
const textByMid = new Map(); // mid -> 这次调用自己产出的正文
|
|
400
|
+
const toolIds = new Set(); // tool_use 的 id:同一块被写进多条记录时不去重就是重复的工具行
|
|
401
|
+
const texts = [];
|
|
402
|
+
let anon = 0; // 没有 message.id 的记录:各算各的,别互相吞
|
|
403
|
+
for (const line of lines) {
|
|
404
|
+
if (!line.trim()) continue;
|
|
405
|
+
let j; try { j = JSON.parse(line); } catch { continue; }
|
|
406
|
+
if (j.type === 'user') {
|
|
407
|
+
const content = j.message?.content;
|
|
408
|
+
// 工具结果按 tool_use_id 精确回填到对应工具(并行调用时不再错位;无 id 时退回第一个空 output)
|
|
409
|
+
if (claudeIsToolResultOnly(content)) {
|
|
410
|
+
if (turnIdx === src.turn) {
|
|
411
|
+
if (j.timestamp) prevTs = Date.parse(j.timestamp) || prevTs;
|
|
412
|
+
// I13:超时 / 软失败的结构化标志在**记录级**的 `toolUseResult` 上,不在 tool_result 块里。
|
|
413
|
+
// 要害是超时那一批 `is_error` 是**显式 false**(本机实测 30 条全是,不是缺键)⇒ 只看
|
|
414
|
+
// `is_error` 会把它读成"这一跑没问题"。不读这一层,超时在失败画像里就是不可见的。
|
|
415
|
+
const tur = j.toolUseResult && typeof j.toolUseResult === 'object' ? j.toolUseResult : null;
|
|
416
|
+
for (const b of content) {
|
|
417
|
+
const t = (b.tool_use_id && out.tools.find(x => x.tid === b.tool_use_id)) || out.tools.find(x => !x.output);
|
|
418
|
+
if (t) {
|
|
419
|
+
const c = b.content;
|
|
420
|
+
const fo = {};
|
|
421
|
+
t.output = trunc(c, full, fo); // 内容块的拆解统一交给 toText(),这里不再各写一份
|
|
422
|
+
t.outputTrunc = !!fo.t;
|
|
423
|
+
if (b.is_error) t.error = 'error';
|
|
424
|
+
// 只搬**结构化**的那几位,一个字正文都不扫:超时是产品给的档位数值,软失败是产品给的
|
|
425
|
+
// 判定串。这两者都**不并进** `error` —— 尤其软失败(`No matches found` = rg 没搜到)是
|
|
426
|
+
// 正常结果,并进去就是把最常见的正常结果读成故障。
|
|
427
|
+
if (tur) {
|
|
428
|
+
if (tur.timedOutAfterMs != null) t.timeout = tur.timedOutAfterMs;
|
|
429
|
+
if (tur.returnCodeInterpretation != null) t.soft = tur.returnCodeInterpretation;
|
|
430
|
+
else if (tur.staleRecovered != null) t.soft = 'staleRecovered';
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
const text0 = claudeUserText(content);
|
|
438
|
+
if (!text0) continue;
|
|
439
|
+
if (claudeIsCommandText(text0)) continue; // 与 scanClaude 同口径跳过命令消息
|
|
440
|
+
turnIdx++;
|
|
441
|
+
if (turnIdx === src.turn) { out.user = text0; prevTs = j.timestamp ? Date.parse(j.timestamp) : 0; }
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
if (j.type === 'assistant' && turnIdx === src.turn) {
|
|
445
|
+
const ts = j.timestamp ? Date.parse(j.timestamp) : 0;
|
|
446
|
+
const mid = j.message?.id || ('#' + (anon++));
|
|
447
|
+
// 每次 LLM 调用只记一次:usage 与耗时基准取首条(较新的记录里 usage 各条相同,
|
|
448
|
+
// 耗时仍是「首条时间 − 上一事件」,不能随第 N 条记录往后挪)
|
|
449
|
+
let call = callByMid.get(mid);
|
|
450
|
+
if (!call) {
|
|
451
|
+
const u = j.message?.usage || {};
|
|
452
|
+
call = {
|
|
453
|
+
model: j.message?.model || '',
|
|
454
|
+
tin: u.input_tokens || 0, tout: u.output_tokens || 0,
|
|
455
|
+
tcache: (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0),
|
|
456
|
+
dur: ts && prevTs ? Math.max(0, ts - prevTs) : 0,
|
|
457
|
+
};
|
|
458
|
+
callByMid.set(mid, call);
|
|
459
|
+
out.calls.push(call);
|
|
460
|
+
if (ts) prevTs = ts;
|
|
461
|
+
}
|
|
462
|
+
const blocks = j.message?.content;
|
|
463
|
+
if (!Array.isArray(blocks)) continue;
|
|
464
|
+
for (const b of blocks) {
|
|
465
|
+
if (!b) continue;
|
|
466
|
+
if (b.type === 'text' && b.text) {
|
|
467
|
+
texts.push(b.text);
|
|
468
|
+
// 这次调用自己说了什么(而不是整轮拼起来的那坨)——「LLM 调用明细」展开就看这段
|
|
469
|
+
const seg = String(b.text);
|
|
470
|
+
textByMid.set(mid, textByMid.has(mid) ? textByMid.get(mid) + '\n' + seg : seg);
|
|
471
|
+
} else if (b.type === 'tool_use') {
|
|
472
|
+
if (b.id && toolIds.has(b.id)) continue; // 同一块被重复写进多条记录
|
|
473
|
+
if (b.id) toolIds.add(b.id);
|
|
474
|
+
// 这次调用发起了哪些工具。没有工具的那几次不建空数组 ——
|
|
475
|
+
// 否则每条明细都多一个 "tools":[] 白占带宽。
|
|
476
|
+
const tname = b.name || '?';
|
|
477
|
+
if (call.tools) call.tools.push(tname);
|
|
478
|
+
else call.tools = [tname];
|
|
479
|
+
const fi = {};
|
|
480
|
+
out.tools.push({ name: b.name || '?', tid: b.id || null, input: trunc(JSON.stringify(b.input ?? ''), full, fi), inputTrunc: !!fi.t, output: '', error: null });
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
if (turnIdx > src.turn) break;
|
|
485
|
+
}
|
|
486
|
+
// 正文回填放在循环外:同一次响应的正文可能落在**任意一条**记录上(687/694 不在首条),
|
|
487
|
+
// 边读边挂的话,只有「正文恰好与 usage 同条」的那 1% 能挂上。
|
|
488
|
+
for (const [mid, call] of callByMid) {
|
|
489
|
+
const seg = textByMid.get(mid);
|
|
490
|
+
if (!seg) continue;
|
|
491
|
+
const ft = {};
|
|
492
|
+
call.text = trunc(seg, full, ft); call.textTrunc = !!ft.t;
|
|
493
|
+
}
|
|
494
|
+
out.assistant = texts.join('\n');
|
|
495
|
+
return out;
|
|
496
|
+
}
|