@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,582 @@
|
|
|
1
|
+
// ---------------- buddyext 解析器(CodeBuddy **扩展版**:%LOCALAPPDATA%\CodeBuddyExtension\Data) ----------------
|
|
2
|
+
//
|
|
3
|
+
// 为什么要单独一个 kind:CodeBuddy 家有三种落盘,彼此**不共享目录**(2026-09-28 本机逐目录核实):
|
|
4
|
+
// · CLI `@tencent-ai/codebuddy-code` → `~/.codebuddy/projects/**/*.jsonl` = 已有的 kind `buddyjsonl`
|
|
5
|
+
// · CodeBuddy CN 桌面应用(VSCode 壳)→ `%APPDATA%\CodeBuddy CN`:**只有 Electron 缓存 + state.vscdb**,
|
|
6
|
+
// 没有会话正文 —— 所以「装了应用却一条都看不到」不是解析器坏了,是正文真的不在那儿
|
|
7
|
+
// · 共用的 genie 扩展(VSCode / CodeBuddyIDE / JetBrains 三种宿主都走它)
|
|
8
|
+
// → `%LOCALAPPDATA%\CodeBuddyExtension\Data`:**这才是本 kind 的真源**
|
|
9
|
+
// 三者互不相干,故 `codebuddy`(buddyjsonl)与 `codebuddy-ext`(本 kind)是侧栏上两个独立 agent。
|
|
10
|
+
//
|
|
11
|
+
// 盘上摆法(<Data> = %LOCALAPPDATA%\CodeBuddyExtension\Data):
|
|
12
|
+
// <Data>/<acct>/<Host>/history/<wsKey>/index.json ← 工作区级:conversations[]{id,name,…} + current
|
|
13
|
+
// <Data>/<acct>/<Host>/history/<wsKey>/<会话 id>/index.json ← **会话级主源**:messages[](索引)+ requests[](轮)
|
|
14
|
+
// <Data>/<acct>/<Host>/history/<wsKey>/<会话 id>/messages/<消息 id>.json ← 逐条正文(一条一个文件)
|
|
15
|
+
// <Host> ∈ {VSCode, CodeBuddyIDE, JetBrains}。<acct> ∈ {`default`} ∪ {36 位 uuid},两种形状:
|
|
16
|
+
// acct==='default' → `<acct>/<Host>/history/…`(本机实测 5 段)
|
|
17
|
+
// acct 为 uuid → `<acct>/<Host>/<acct>/history/…`(**同一串再重复一层**,本机实测 6 段)
|
|
18
|
+
// 重复那一层是刻意的还是历史包袱无从考证,所以 `historyDirs` 两种都探(不写死层数)。
|
|
19
|
+
//
|
|
20
|
+
// ⚠️ **不读** 同一棵 tree 里的这些(与解析无关,读了只会平白多出几万个 stat/read):
|
|
21
|
+
// `check-point/` `file-tree/` `plan-task/` `genie-cache/` `.index_bak.json` `index.json.lock`
|
|
22
|
+
// 也**不读** `<Host>` 那一层旁边的 IDE 状态文件(`globalState.json` 等同族里有明文密钥的先例,见 cline.mjs 头)。
|
|
23
|
+
// 本 kind 全程只打开三类文件:工作区 index.json(取 name)、会话 index.json(取轮)、messages/*.json(取正文)。
|
|
24
|
+
//
|
|
25
|
+
// ---------------- 为什么是「整份重写 + 逐条签名」而不是 off 增量 ----------------
|
|
26
|
+
// 会话级 index.json 在会话进行中**每次都被整体重写**(实测:一条 assistant 消息从 isComplete:false 翻成
|
|
27
|
+
// true 时文件 mtime/size 都变),所以没有可续读的字节偏移 ⇒ **不进 OFF_KINDS**,files 状态不落 index,
|
|
28
|
+
// 重启即整份重扫。`PARSER_REV.buddyext` 仍然登记 —— 它在这里管的是 `~/.agent-acta/search/` 全文分片的作废。
|
|
29
|
+
// 但一个会话的消息是**散成上千个小文件**的(本机最大一份 3331 条 / 8.6MB),逐轮重读整棵 messages/ 太贵,
|
|
30
|
+
// 于是做了第二层缓存:每条消息按自己的 `{mtime,size}` 存一份紧凑投影(`data.mc`)。
|
|
31
|
+
// 每轮扫描的代价 = 会话 index 变了才重算聚合,重算时对每条消息只 stat;正文文件仅在签名变了才 read。
|
|
32
|
+
//
|
|
33
|
+
// ---------------- 轮口径:轮 = requests[] 里的一项 ----------------
|
|
34
|
+
// 与 cline / claude 靠「role=user 且有正文」猜轮边界不同,这里产品**自己记了轮**:
|
|
35
|
+
// requests[i] = {id, type:'craft', messages:[消息 id 按序], state:'complete'|'running', startedAt?, usage?}
|
|
36
|
+
// 所以开轮判据就一条:`messages[]` 非空(本机 311/311 条请求都满足,且请求引用的消息 id 全部存在于
|
|
37
|
+
// 会话级 messages[] 索引里)。扫描侧与详情侧共用 `buddyExtTurns()` 挑这一份数组,
|
|
38
|
+
// 「扫到第 N 轮」与「详情第 N 轮」不可能错位。
|
|
39
|
+
//
|
|
40
|
+
// ---------------- token 口径 ----------------
|
|
41
|
+
// `usage = {inputTokens, outputTokens, totalTokens, lastTokens, cacheTokens?, cachedWriteTokens?, cachedMissTokens?, credit?}`
|
|
42
|
+
// ⚠️ **inputTokens 含缓存**(与 cline 同、与 openclaw/minimax 相反),算术自证(本机 2026-09-28 活会话):
|
|
43
|
+
// cachedMissTokens 22449 + cacheTokens 512 + cachedWriteTokens 0 === inputTokens 22961
|
|
44
|
+
// ⇒ tin = inputTokens − cacheTokens − cachedWriteTokens(净输入,实测恒等于 cachedMissTokens)、
|
|
45
|
+
// tcache = 两个 cache 桶之和、tout = outputTokens、total = totalTokens(= Σ 各次调用的 in+out)。
|
|
46
|
+
// 多步请求的 inputTokens 是**各步之和**(本机实测 76 条消息的那一轮 = 2512800),不是末次值 ——
|
|
47
|
+
// 所以它可以直接和逐工具的聚合量并排显示,不用换算。
|
|
48
|
+
// ctxUsed = `lastTokens`(产品自己记的「末次调用的上下文占用」;老样本没有时退回末条 assistant 的
|
|
49
|
+
// statsSnapshot.inputTokens)。ctx(窗口**容量**分母)**一律 0**:模型目录嵌在扩展二进制里,盘上没有任何
|
|
50
|
+
// JSON 版本 ⇒ 不编分母,页面退化成只显示占用(与 cline / minimax 同款处理)。
|
|
51
|
+
// 逐次明细:新版每条 assistant 的 `extra.statsSnapshot` = {inputTokens, outputTokens, cachedInputTokens,
|
|
52
|
+
// cacheWriteTokens, cacheMissTokens, thinkingTokens, elapsedMs, credit} —— 是**那一步自己的**记账
|
|
53
|
+
// (与同一条上的 lastStep* 三兄弟一致),故 calls[].tin/tout/tcache/dur 能逐次给。
|
|
54
|
+
// ⚠️ 老方言(本机 2026 年 1–3 月那几份会话)没有 statsSnapshot:逐次 token **留 null**(页面据此不印 0),
|
|
55
|
+
// 逐次 dur 退化成「相邻消息时间戳之差」。
|
|
56
|
+
//
|
|
57
|
+
// ---------------- 时间口径 ----------------
|
|
58
|
+
// 轮时间优先 `request.startedAt`(epoch 毫秒;本机 282/311 有),否则退到该轮消息里最早的时间戳。
|
|
59
|
+
// 消息时间戳:新版每条消息带 `createdAt`(ISO);老版只有 `extra.responseId`,两种书写都要认 ——
|
|
60
|
+
// `^[0-9]{14}` 前缀 = <YYYYMMDDHHmmss><随机>,**是本地时间**(按本地时区解释才是对的 epoch);
|
|
61
|
+
// `^gen-<10 位 epoch 秒>-` 。两者都取不到 → 该轮 time=0 + timeUnknown=true(页面据此标「时间未知」)。
|
|
62
|
+
// dur = 该轮最晚消息时间戳 − time。**不要用文件 mtime 算**:整份重写会把一批消息文件的 mtime 拍平到
|
|
63
|
+
// 同一瞬间(本机 218e107b / 33ae5ab3 两份会话实测:mtime 差 ~2ms 而内容时间戳差 22s/84s),
|
|
64
|
+
// 拿它算出来的「耗时」是假的。
|
|
65
|
+
// finished = 非末轮恒 true;末轮看 `state`('running' → false)。这里比 cline 多一个真信号,
|
|
66
|
+
// 所以按真信号写,不用「末轮一律 false + 页面 5 分钟兜底」那套。
|
|
67
|
+
//
|
|
68
|
+
// ---------------- 失败 / 中断口径 ----------------
|
|
69
|
+
// 逐工具成败只在 `tool-result` 块里:`result.status ∈ {success, error, cancelled, skipped}`。
|
|
70
|
+
// status==='error' → 该工具红 + 整轮 status=error(errorMessage 进 callsNote);
|
|
71
|
+
// status==='cancelled'(errorMessage 是 "User cancel tool execution")或消息级 extra.isCancelled
|
|
72
|
+
// → 整轮 aborted。
|
|
73
|
+
// `skipped` 既不算失败也不算中断(本机 5 条,是「批量调用里被跳过的一项」,拿它标红就是误报)。
|
|
74
|
+
// 模型侧失败**不落盘**(没有 stopReason/errorMessage 这类字段)⇒ 认不出来的失败宁可不标。
|
|
75
|
+
import fs from 'node:fs';
|
|
76
|
+
import path from 'node:path';
|
|
77
|
+
import {
|
|
78
|
+
files, entries, srcs, removedIds, sorted, addEntry,
|
|
79
|
+
modelName, toText, trunc, listDirCached, isDir, isFile,
|
|
80
|
+
} from './shared.mjs';
|
|
81
|
+
|
|
82
|
+
export const buddyExtScanErr = new Map();
|
|
83
|
+
|
|
84
|
+
// 三种宿主目录名(大小写不敏感)。别的名字一律不认 —— 这一层认错了会把邻居应用的数据当成本 kind。
|
|
85
|
+
const HOSTS = new Set(['vscode', 'codebuddyide', 'jetbrains']);
|
|
86
|
+
|
|
87
|
+
// id 前缀 `bx#`:与 claude 的、codex 的 `x#`、cline 的 `cl#`、openclaw 的 `oc#` 都不撞
|
|
88
|
+
export function buddyExtIdBase(p) { return 'bx#' + String(p).replace(/[\\/:]/g, '~') + '#'; }
|
|
89
|
+
function turnSuffix(key) { return String(key == null ? '' : key).replace(/#/g, '~'); }
|
|
90
|
+
|
|
91
|
+
function num(v) { const n = Number(v); return Number.isFinite(n) ? n : 0; }
|
|
92
|
+
// extra / message 两个字段在盘上都是**JSON 字符串套字符串**(不是对象),但别的版本直接给对象
|
|
93
|
+
function safeJson(raw) {
|
|
94
|
+
if (raw && typeof raw === 'object') return raw;
|
|
95
|
+
if (typeof raw !== 'string' || !raw) return null;
|
|
96
|
+
try { return JSON.parse(raw); } catch { return null; }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ---------------- 时间戳 ----------------
|
|
100
|
+
const RID_HEX = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/;
|
|
101
|
+
const RID_GEN = /^gen-(\d{10})-/;
|
|
102
|
+
function tsOf(ex, o) {
|
|
103
|
+
if (o && typeof o.createdAt === 'string') { const t = Date.parse(o.createdAt); if (Number.isFinite(t)) return t; }
|
|
104
|
+
const rid = ex && ex.responseId;
|
|
105
|
+
if (typeof rid === 'string') {
|
|
106
|
+
const m1 = rid.match(RID_HEX);
|
|
107
|
+
// ⚠️ 那 14 位是**本地时间**书写:用 new Date(y,m,d,h,mi,s) 而不是 ISO 串,否则整体偏一个时区
|
|
108
|
+
if (m1) return new Date(+m1[1], +m1[2] - 1, +m1[3], +m1[4], +m1[5], +m1[6]).getTime();
|
|
109
|
+
const m2 = rid.match(RID_GEN);
|
|
110
|
+
if (m2) return +m2[1] * 1000;
|
|
111
|
+
}
|
|
112
|
+
return 0;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ---------------- 用户正文 ----------------
|
|
116
|
+
// 一条 user 消息的正文里裹着好几层产品自己注入的壳(<user_info> 带 Workspace Folder、<git_status>、
|
|
117
|
+
// <additional_data> 带当前时间/打开过的文件……),真问句在 <user_query> 里。三层取法,优先级从高到低:
|
|
118
|
+
// ① extra.inputPhrase —— 产品自己记的「用户实际输入了什么」,干净短句,不用剥壳;
|
|
119
|
+
// ② <user_query> 块;
|
|
120
|
+
// ③ 剥掉已知壳标签后的正文(老方言 / 手工改过的文件)。
|
|
121
|
+
function wrapRe(name) { return new RegExp('<' + name + '\\b[^>]*>([\\s\\S]*?)<\\/' + name + '>', 'i'); }
|
|
122
|
+
const WRAP_TAGS = ['user_info', 'additional_data', 'git_status', 'open_and_recently_viewed_files',
|
|
123
|
+
'mode_notice', 'system_reminder', 'project_context', 'environment_details', 'selection'];
|
|
124
|
+
function inputPhraseText(ip) {
|
|
125
|
+
if (!Array.isArray(ip)) return '';
|
|
126
|
+
const buf = [];
|
|
127
|
+
for (const x of ip) {
|
|
128
|
+
if (!x || typeof x !== 'object') continue;
|
|
129
|
+
const c = typeof x.content === 'string' ? x.content
|
|
130
|
+
: (typeof x.expandContent === 'string' ? x.expandContent : '');
|
|
131
|
+
if (c.trim()) buf.push(c.trim());
|
|
132
|
+
}
|
|
133
|
+
return buf.join(';');
|
|
134
|
+
}
|
|
135
|
+
function contentText(content) {
|
|
136
|
+
if (typeof content === 'string') return content; // ⚠️ user 正文在本机 294/311 里就是**裸字符串**
|
|
137
|
+
if (!Array.isArray(content)) return '';
|
|
138
|
+
const buf = [];
|
|
139
|
+
for (const b of content) if (b && b.type === 'text' && typeof b.text === 'string') buf.push(b.text);
|
|
140
|
+
return buf.join('\n\n');
|
|
141
|
+
}
|
|
142
|
+
export function buddyExtUserText(raw) {
|
|
143
|
+
const m = wrapRe('user_query').exec(raw || '');
|
|
144
|
+
if (m && m[1].trim()) return m[1].trim();
|
|
145
|
+
let s = String(raw || '');
|
|
146
|
+
for (const n of WRAP_TAGS) s = s.replace(wrapRe(n), ' ');
|
|
147
|
+
return s.split('\n').map(x => x.trim()).filter(Boolean).join('\n').trim();
|
|
148
|
+
}
|
|
149
|
+
// 项目名:<Data> 里的 <wsKey> 是 md5(反推不出路径),唯一可读的工作区路径是 <user_info> 里那一行
|
|
150
|
+
function workspaceFolder(raw) {
|
|
151
|
+
const m = /Workspace Folder:\s*([^\r\n<]+)/i.exec(raw || '');
|
|
152
|
+
return m ? m[1].trim() : '';
|
|
153
|
+
}
|
|
154
|
+
// assistant 正文:text 之外还有 reasoning(本机 13 条),一并拼出来 —— 不拼的话「它为什么这么改」就没了
|
|
155
|
+
function assistantText(content) {
|
|
156
|
+
if (typeof content === 'string') return toText(content);
|
|
157
|
+
if (!Array.isArray(content)) return '';
|
|
158
|
+
const buf = [];
|
|
159
|
+
for (const b of content) {
|
|
160
|
+
if (!b || typeof b !== 'object') continue;
|
|
161
|
+
if ((b.type === 'text' || b.type === 'reasoning') && typeof b.text === 'string') buf.push(b.text);
|
|
162
|
+
}
|
|
163
|
+
return buf.join('\n\n');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ---------------- 工具块 ----------------
|
|
167
|
+
// tool-call 的 args 是**对象**({filePath, ...})。不能丢给 toText —— 它见 content/text 字段就只回那段
|
|
168
|
+
// 正文,路径这类键会被吞掉(与 cline 的 toolArgsText 同一个坑)。
|
|
169
|
+
function toolArgsText(args) {
|
|
170
|
+
if (args == null) return '';
|
|
171
|
+
if (typeof args === 'string') return args;
|
|
172
|
+
try { return JSON.stringify(args, null, 2); } catch { return toText(args); }
|
|
173
|
+
}
|
|
174
|
+
// tool-result 的 result = {status, success, result?, errorMessage?};内层 result 各家工具自己的结构
|
|
175
|
+
// (read_file_result 带 content、execute_command_result 带 stdout/stderr/exitCode)
|
|
176
|
+
// ⚠️ status 四态里**只有 error 算失败**。`cancelled`("User cancel tool execution")记成中断、
|
|
177
|
+
// `skipped`("may take a long time and the user does not want to wait")两头都不记 —— 那是用户的
|
|
178
|
+
// 显式选择,标红就是把正常结果读成故障(与 claude 侧不把 rg「No matches found」算进失败率同一条约定)。
|
|
179
|
+
// 三者的 errorMessage 都照常进 output 文本,详情里不会是一片空白。
|
|
180
|
+
function toolResultInfo(block) {
|
|
181
|
+
const r = (block && block.result) || {};
|
|
182
|
+
const st = typeof r.status === 'string' ? r.status : '';
|
|
183
|
+
const cancelled = st === 'cancelled';
|
|
184
|
+
const error = st === 'error' ? String(r.errorMessage || '工具执行失败') : '';
|
|
185
|
+
const inner = r.result;
|
|
186
|
+
let text = '';
|
|
187
|
+
if (typeof inner === 'string') text = inner;
|
|
188
|
+
else if (inner && typeof inner === 'object') {
|
|
189
|
+
const t = inner.content != null ? inner.content : (inner.text != null ? inner.text : inner.stdout);
|
|
190
|
+
if (typeof t === 'string' && t) text = t;
|
|
191
|
+
else { try { text = JSON.stringify(inner, null, 2); } catch { text = toText(inner); } }
|
|
192
|
+
const se = typeof inner.stderr === 'string' ? inner.stderr.trim() : '';
|
|
193
|
+
if (se && !text.includes(se)) text += (text ? '\n' : '') + se;
|
|
194
|
+
} else if (inner != null) text = String(inner);
|
|
195
|
+
// 没有内层 result 时(cancelled / skipped 就只有个 errorMessage)把那句话当**出参**带出去 ——
|
|
196
|
+
// 不带就是详情里一行空白,用户看不到「这个工具为什么什么都没返回」。它不进 error,所以不会被算成失败。
|
|
197
|
+
if (!text && r.errorMessage) text = String(r.errorMessage);
|
|
198
|
+
return { text, error, cancelled };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ---------------- 逐条消息的紧凑投影 ----------------
|
|
202
|
+
// 卡片聚合只要这几个量;正文与出入参**不进内存**(一份大会话 3331 条 / 8.6MB,全存下来就是几十 MB),
|
|
203
|
+
// 详情侧按 src.turn 只重读那一轮的消息文件(几十条封顶)。
|
|
204
|
+
function project(o) {
|
|
205
|
+
const ex = safeJson(o.extra) || {};
|
|
206
|
+
const msg = safeJson(o.message) || {};
|
|
207
|
+
const content = msg.content;
|
|
208
|
+
const p = {
|
|
209
|
+
role: typeof o.role === 'string' ? o.role : '',
|
|
210
|
+
ts: tsOf(ex, o),
|
|
211
|
+
model: (typeof ex.modelName === 'string' && ex.modelName.trim()) || (typeof ex.modelId === 'string' ? ex.modelId : ''),
|
|
212
|
+
cancelled: ex.isCancelled === true,
|
|
213
|
+
ntools: 0, names: null, fails: 0, cancels: 0, errMsg: '',
|
|
214
|
+
ask: '', proj: '',
|
|
215
|
+
snap: null,
|
|
216
|
+
};
|
|
217
|
+
if (Array.isArray(content)) {
|
|
218
|
+
for (const b of content) {
|
|
219
|
+
if (!b || typeof b !== 'object') continue;
|
|
220
|
+
if (b.type === 'tool-call') {
|
|
221
|
+
p.ntools++;
|
|
222
|
+
const nm = (typeof b.toolName === 'string' && b.toolName) ? b.toolName : '?';
|
|
223
|
+
(p.names || (p.names = [])).push(nm);
|
|
224
|
+
} else if (b.type === 'tool-result') {
|
|
225
|
+
const info = toolResultInfo(b);
|
|
226
|
+
if (info.cancelled) p.cancels++;
|
|
227
|
+
else if (info.error) { p.fails++; if (!p.errMsg) p.errMsg = info.error; }
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
if (p.role === 'user') {
|
|
232
|
+
const raw = contentText(content);
|
|
233
|
+
p.ask = inputPhraseText(ex.inputPhrase) || buddyExtUserText(raw);
|
|
234
|
+
p.proj = workspaceFolder(raw);
|
|
235
|
+
}
|
|
236
|
+
const s = ex.statsSnapshot;
|
|
237
|
+
if (s && typeof s === 'object') {
|
|
238
|
+
p.snap = {
|
|
239
|
+
i: num(s.inputTokens), o: num(s.outputTokens),
|
|
240
|
+
cr: num(s.cachedInputTokens) + num(s.cacheWriteTokens),
|
|
241
|
+
ms: num(s.elapsedMs),
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
return p;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ---------------- 轮:requests[] 的一项 = 一轮 ----------------
|
|
248
|
+
// 扫描侧与详情侧**必须共用**这个筛选,否则「第 N 轮」两边数不齐。
|
|
249
|
+
export function buddyExtTurns(payload) {
|
|
250
|
+
const out = [];
|
|
251
|
+
const reqs = payload && Array.isArray(payload.requests) ? payload.requests : [];
|
|
252
|
+
for (const r of reqs) {
|
|
253
|
+
if (!r || typeof r !== 'object') continue;
|
|
254
|
+
if (!Array.isArray(r.messages) || !r.messages.length) continue;
|
|
255
|
+
out.push(r);
|
|
256
|
+
}
|
|
257
|
+
return out;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// mc: Map<消息 id, {m, s, p}>(p 就是上面那个投影)
|
|
261
|
+
function buildTurn(r, mc, idx) {
|
|
262
|
+
const t = {
|
|
263
|
+
key: typeof r.id === 'string' && r.id ? r.id : 'i' + idx,
|
|
264
|
+
time: 0, dur: 0, running: r.state === 'running',
|
|
265
|
+
tin: 0, tout: 0, tcache: 0, total: 0, ctxUsed: 0,
|
|
266
|
+
calls: 0, tools: 0, models: [], toolNames: {},
|
|
267
|
+
err: false, aborted: false, errMsg: '',
|
|
268
|
+
preview: '', proj: '',
|
|
269
|
+
};
|
|
270
|
+
let minTs = 0, maxTs = 0;
|
|
271
|
+
for (const mid of r.messages) {
|
|
272
|
+
const e = mc.get(mid);
|
|
273
|
+
if (!e) continue;
|
|
274
|
+
const p = e.p;
|
|
275
|
+
const ts = p.ts;
|
|
276
|
+
if (ts) { if (!minTs || ts < minTs) minTs = ts; if (ts > maxTs) maxTs = ts; }
|
|
277
|
+
if (p.role === 'assistant') {
|
|
278
|
+
t.calls++;
|
|
279
|
+
const mdl = modelName(p.model);
|
|
280
|
+
if (mdl && !t.models.includes(mdl)) t.models.push(mdl);
|
|
281
|
+
t.tools += p.ntools;
|
|
282
|
+
for (const nm of p.names || []) t.toolNames[nm] = (t.toolNames[nm] || 0) + 1;
|
|
283
|
+
if (p.snap && p.snap.i) t.ctxUsed = p.snap.i + p.snap.o; // 老方言兜底:末条 assistant 的那步占用
|
|
284
|
+
}
|
|
285
|
+
if (p.fails) { t.err = true; if (!t.errMsg) t.errMsg = p.errMsg; }
|
|
286
|
+
if (p.cancels || p.cancelled) t.aborted = true;
|
|
287
|
+
if (!t.preview && p.ask) t.preview = p.ask.replace(/\s+/g, ' ').slice(0, 300);
|
|
288
|
+
if (!t.proj && p.proj) t.proj = p.proj;
|
|
289
|
+
}
|
|
290
|
+
const u = (r.usage && typeof r.usage === 'object') ? r.usage : {};
|
|
291
|
+
const inp = num(u.inputTokens), outp = num(u.outputTokens);
|
|
292
|
+
const cache = num(u.cacheTokens) + num(u.cachedWriteTokens);
|
|
293
|
+
t.tin = Math.max(0, inp - cache); // ⚠️ inputTokens **含**缓存(见文件头)
|
|
294
|
+
t.tcache = cache;
|
|
295
|
+
t.tout = outp;
|
|
296
|
+
t.total = num(u.totalTokens) || (t.tin + t.tout + t.tcache);
|
|
297
|
+
const lt = num(u.lastTokens);
|
|
298
|
+
if (lt) t.ctxUsed = lt;
|
|
299
|
+
t.time = num(r.startedAt) || minTs;
|
|
300
|
+
if (maxTs > t.time) t.dur = maxTs - t.time;
|
|
301
|
+
return t;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ---------------- emit:一轮一条 entry ----------------
|
|
305
|
+
function emit(agent, key, data, prevData) {
|
|
306
|
+
const base = buddyExtIdBase(key);
|
|
307
|
+
const ids = new Set();
|
|
308
|
+
const n = data.turns.length;
|
|
309
|
+
data.turns.forEach((t, i) => {
|
|
310
|
+
const id = base + turnSuffix(t.key);
|
|
311
|
+
ids.add(id);
|
|
312
|
+
addEntry(id, {
|
|
313
|
+
agent,
|
|
314
|
+
project: data.project,
|
|
315
|
+
session: data.sid,
|
|
316
|
+
name: data.name || undefined,
|
|
317
|
+
time: t.time,
|
|
318
|
+
timeUnknown: t.time ? undefined : true,
|
|
319
|
+
dur: t.dur,
|
|
320
|
+
status: t.err ? 'error' : 'ok',
|
|
321
|
+
tin: t.tin, tout: t.tout, tcache: t.tcache, total: t.total,
|
|
322
|
+
ctx: 0, // 窗口容量只嵌在扩展二进制里,盘上没有 —— 不编分母
|
|
323
|
+
ctxUsed: t.ctxUsed || 0,
|
|
324
|
+
rounds: t.calls,
|
|
325
|
+
calls: t.calls,
|
|
326
|
+
tools: t.tools,
|
|
327
|
+
models: t.models,
|
|
328
|
+
preview: t.preview,
|
|
329
|
+
// 末轮才看 state:'running' 说明会话还在写(页面据此判「进行中」,见 recomputeLive)
|
|
330
|
+
finished: i < n - 1 ? true : !t.running,
|
|
331
|
+
aborted: t.aborted,
|
|
332
|
+
toolNames: t.toolNames,
|
|
333
|
+
}, { file: key, turn: i, kind: 'buddyext' });
|
|
334
|
+
});
|
|
335
|
+
// 会话被删/轮被回收时按 id 集合回收,否则老条目悬在页面上
|
|
336
|
+
for (const old of (prevData && prevData.ids) || []) {
|
|
337
|
+
if (ids.has(old)) continue;
|
|
338
|
+
entries.delete(old); srcs.delete(old); removedIds.push(old);
|
|
339
|
+
}
|
|
340
|
+
data.ids = [...ids];
|
|
341
|
+
sorted.cache = null;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// ---------------- 目录枚举 ----------------
|
|
345
|
+
// <Data>/<acct>/<Host>/(<acct>/)?history —— 两种账号形状都探,不写死层数。
|
|
346
|
+
// ⚠️ 这是每 3s 一轮的 sniff 路径:只用 listDirCached(按目录 mtime 缓存),且第三层只在
|
|
347
|
+
// <Host> 下没有直挂 history 时才多读一次目录名。
|
|
348
|
+
function historyDirs(dataRoot) {
|
|
349
|
+
const out = [];
|
|
350
|
+
for (const acct of listDirCached(dataRoot) || []) {
|
|
351
|
+
const acctDir = path.join(dataRoot, acct);
|
|
352
|
+
if (!isDir(acctDir)) continue;
|
|
353
|
+
for (const host of listDirCached(acctDir) || []) {
|
|
354
|
+
if (!HOSTS.has(host.toLowerCase())) continue;
|
|
355
|
+
const hostDir = path.join(acctDir, host);
|
|
356
|
+
if (!isDir(hostDir)) continue;
|
|
357
|
+
const direct = path.join(hostDir, 'history');
|
|
358
|
+
if (isDir(direct)) { out.push(direct); continue; }
|
|
359
|
+
for (const mid of listDirCached(hostDir) || []) {
|
|
360
|
+
if (mid === 'history') continue;
|
|
361
|
+
const deep = path.join(hostDir, mid, 'history');
|
|
362
|
+
if (isDir(deep)) out.push(deep);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
return out;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// 会话清单:…/history/<wsKey>/<会话 id>/index.json(与同级那份工作区 index.json 配对,只取 name)
|
|
370
|
+
function convIndexes(root, limit) {
|
|
371
|
+
const out = [];
|
|
372
|
+
for (const hist of historyDirs(root)) {
|
|
373
|
+
for (const wsKey of listDirCached(hist) || []) {
|
|
374
|
+
const wsDir = path.join(hist, wsKey);
|
|
375
|
+
if (!isDir(wsDir)) continue;
|
|
376
|
+
const wsIndex = path.join(wsDir, 'index.json');
|
|
377
|
+
for (const conv of listDirCached(wsDir) || []) {
|
|
378
|
+
if (conv === 'index.json' || conv === '.index_bak.json' || conv.endsWith('.lock')) continue;
|
|
379
|
+
const convDir = path.join(wsDir, conv);
|
|
380
|
+
if (!isDir(convDir)) continue;
|
|
381
|
+
const index = path.join(convDir, 'index.json');
|
|
382
|
+
if (!isFile(index)) continue;
|
|
383
|
+
out.push({ convId: conv, convDir, index, wsIndex });
|
|
384
|
+
if (limit && out.length >= limit) return out;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
return out;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// 会话名来自**工作区级** index.json 的 conversations[](会话自己那份没有 name 字段)。
|
|
392
|
+
// 空串就当没有 —— 页面会退回裸会话 id,比拿预览顶包诚实。
|
|
393
|
+
function readConvName(wsIndex, convId) {
|
|
394
|
+
let j; try { j = JSON.parse(fs.readFileSync(wsIndex, 'utf8')); } catch { return ''; }
|
|
395
|
+
const list = j && Array.isArray(j.conversations) ? j.conversations : [];
|
|
396
|
+
for (const c of list) {
|
|
397
|
+
if (c && typeof c === 'object' && c.id === convId) {
|
|
398
|
+
return typeof c.name === 'string' && c.name.trim() ? c.name.trim() : '';
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
return '';
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// ---------------- 主源根(discovery 的 sniffBase 与手工添加共用)----------------
|
|
405
|
+
// 手工添加时用户可能填 `%LOCALAPPDATA%\CodeBuddyExtension`、`…\CodeBuddyExtension\Data`,
|
|
406
|
+
// 也可能顺着资源管理器填到 `<Data>/<acct>` 甚至 `<Data>/<acct>/<Host>` 那一层。
|
|
407
|
+
// ⚠️ sniffBase 是**每 3s 对每个候选目录**都要跑一遍的路径,所以这里绝不对任意目录盲下探:
|
|
408
|
+
// 先一次 stat 探 `<base>/Data` 在不在(绝大多数机器只有这一条命中),
|
|
409
|
+
// 往上走则只认**目录名真叫 data** 的那一层才付 historyDirs 的 readdir 代价。
|
|
410
|
+
// 判据本身是**结构**(acct/Host/history 三层齐不齐)而不是目录名,所以名字猜错不会误接邻居应用的数据。
|
|
411
|
+
export function buddyExtRoot(base) {
|
|
412
|
+
if (!isDir(base)) return null;
|
|
413
|
+
const bn = p => path.basename(p).toLowerCase();
|
|
414
|
+
const sub = path.join(base, 'Data');
|
|
415
|
+
if (isDir(sub) && historyDirs(sub).length) return sub;
|
|
416
|
+
for (let d = base, i = 0; i < 4 && isDir(d); i++, d = path.dirname(d)) {
|
|
417
|
+
if (bn(d) === 'data' && historyDirs(d).length) return d;
|
|
418
|
+
}
|
|
419
|
+
return null;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// ---------------- 扫描主入口 ----------------
|
|
423
|
+
// root = <Data> 目录(由 sniffBase 解析出来)
|
|
424
|
+
export function scanBuddyExt(agent, root, onlyFile) {
|
|
425
|
+
if (!isDir(root)) return;
|
|
426
|
+
for (const c of convIndexes(root, 400)) {
|
|
427
|
+
if (onlyFile && c.index !== onlyFile) continue;
|
|
428
|
+
let ist; try { ist = fs.statSync(c.index); } catch { continue; }
|
|
429
|
+
let wst = null; try { wst = fs.statSync(c.wsIndex); } catch {}
|
|
430
|
+
// 签名带上工作区那份 index:会话名是**产品后来才补上的**(首条回复之后才自动起标题),
|
|
431
|
+
// 不把它算进签名,name 会长期停在旧值上。
|
|
432
|
+
const sig = ist.mtimeMs + '|' + ist.size + '#' + (wst ? wst.mtimeMs + '|' + wst.size : '-');
|
|
433
|
+
const key = c.index;
|
|
434
|
+
const prev = files.get(key);
|
|
435
|
+
if (prev && prev.data && prev.data.sig === sig) {
|
|
436
|
+
// 本 kind 不落 index:files 可能从本轮更早那次扫描带来,条目却已被清掉 → 按内存里那份重建
|
|
437
|
+
const last = prev.data.turns && prev.data.turns[prev.data.turns.length - 1];
|
|
438
|
+
if (last && !entries.has(buddyExtIdBase(key) + turnSuffix(last.key))) emit(agent, key, prev.data, null);
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
let payload;
|
|
442
|
+
try { payload = JSON.parse(fs.readFileSync(c.index, 'utf8')); } catch (e) {
|
|
443
|
+
buddyExtScanErr.set(agent, c.index + ' 读不动(整份 JSON 解析失败):' + e.message);
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
if (!payload || !Array.isArray(payload.requests)) {
|
|
447
|
+
buddyExtScanErr.set(agent, c.index + ' 里没有 requests[] —— 不是认得的 CodeBuddy 扩展会话文件');
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
buddyExtScanErr.delete(agent);
|
|
451
|
+
const requests = buddyExtTurns(payload);
|
|
452
|
+
const mdir = path.join(c.convDir, 'messages');
|
|
453
|
+
const prevMc = prev && prev.data && prev.data.mc;
|
|
454
|
+
const mc = new Map();
|
|
455
|
+
for (const r of requests) {
|
|
456
|
+
for (const mid of r.messages) {
|
|
457
|
+
if (mc.has(mid)) continue;
|
|
458
|
+
const fp = path.join(mdir, mid + '.json');
|
|
459
|
+
let mst; try { mst = fs.statSync(fp); } catch { continue; }
|
|
460
|
+
const cached = prevMc && prevMc.get(mid);
|
|
461
|
+
// ⚠️ 只 stat 不 read 是这里的**全部增量语义**:正文文件写完就不动(消息级 isComplete 翻在 index 里),
|
|
462
|
+
// 签名没变的直接复用上一轮的投影;index 每轮重写不会让它们重读。
|
|
463
|
+
if (cached && cached.m === mst.mtimeMs && cached.s === mst.size) { mc.set(mid, cached); continue; }
|
|
464
|
+
let o; try { o = JSON.parse(fs.readFileSync(fp, 'utf8')); } catch { continue; }
|
|
465
|
+
mc.set(mid, { m: mst.mtimeMs, s: mst.size, p: project(o) });
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
const turns = requests.map((r, idx) => buildTurn(r, mc, idx));
|
|
469
|
+
let wsPath = '';
|
|
470
|
+
for (const t of turns) if (t.proj) { wsPath = t.proj; break; }
|
|
471
|
+
const data = {
|
|
472
|
+
sig, sid: c.convId, turns, mc,
|
|
473
|
+
project: wsPath || '(CodeBuddy 扩展)',
|
|
474
|
+
name: readConvName(c.wsIndex, c.convId),
|
|
475
|
+
rev: (prev && prev.data ? (prev.data.rev || 0) : 0) + 1,
|
|
476
|
+
};
|
|
477
|
+
files.set(key, { agent, kind: 'buddyext', m: ist.mtimeMs, s: ist.size, off: 0, data });
|
|
478
|
+
emit(agent, key, data, prev && prev.data);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// ---------------- 详情:重读会话 index + 那一轮的消息文件 ----------------
|
|
483
|
+
// 轮的选择与扫描侧共用 buddyExtTurns(),所以 src.turn 一定落在同一份数组上。
|
|
484
|
+
export function buddyExtEntryContent(src, full) {
|
|
485
|
+
const st = files.get(src.file);
|
|
486
|
+
const version = st ? st.m + ':' + st.s : null;
|
|
487
|
+
let payload;
|
|
488
|
+
try { payload = JSON.parse(fs.readFileSync(src.file, 'utf8')); } catch { return null; }
|
|
489
|
+
const r = buddyExtTurns(payload)[src.turn];
|
|
490
|
+
if (!r) return null;
|
|
491
|
+
const NOTE = 'CodeBuddy 扩展(VSCode / CodeBuddyIDE / JetBrains 共用的 genie 扩展)的源是**每个会话一份 ' +
|
|
492
|
+
'index.json(会话进行中整体重写)+ messages/<消息 id>.json 逐条正文**,不是 JSONL。' +
|
|
493
|
+
'usage.inputTokens **含**缓存(cacheTokens + cachedWriteTokens 是它的子集,与 cline 同、与 openclaw 相反),' +
|
|
494
|
+
'故 tin = input − 两个 cache 桶、tcache = 两桶之和、total = usage.totalTokens = Σ 各次调用、' +
|
|
495
|
+
'ctxUsed = usage.lastTokens(末次调用的上下文占用)。逐次明细挂在每条 assistant 的 ' +
|
|
496
|
+
'extra.statsSnapshot 上(含 elapsedMs);⚠️ 老方言没有这一层,此时逐次 token **留空**(不印假 0)、' +
|
|
497
|
+
'逐次耗时退化成相邻消息时间戳之差。上下文窗口容量只嵌在扩展二进制里、盘上没有 ⇒ ctx 恒 0(不编分母)。';
|
|
498
|
+
const out = { user: '', assistant: '', tools: [], calls: [], v: version, callsNote: NOTE };
|
|
499
|
+
const mdir = path.join(path.dirname(src.file), 'messages');
|
|
500
|
+
const texts = [];
|
|
501
|
+
const notes = new Set();
|
|
502
|
+
const callStart = new Map(); // toolCallId -> 发起它那条 assistant 的时间戳
|
|
503
|
+
let prevTs = 0;
|
|
504
|
+
for (const mid of r.messages) {
|
|
505
|
+
const o = (() => { try { return JSON.parse(fs.readFileSync(path.join(mdir, mid + '.json'), 'utf8')); } catch { return null; } })();
|
|
506
|
+
if (!o) continue;
|
|
507
|
+
const ex = safeJson(o.extra) || {};
|
|
508
|
+
const msg = safeJson(o.message) || {};
|
|
509
|
+
const content = msg.content;
|
|
510
|
+
const ts = tsOf(ex, o);
|
|
511
|
+
const role = typeof o.role === 'string' ? o.role : '';
|
|
512
|
+
if (role === 'user') {
|
|
513
|
+
const raw = contentText(content);
|
|
514
|
+
const ask = inputPhraseText(ex.inputPhrase) || buddyExtUserText(raw);
|
|
515
|
+
if (ask && !out.user) out.user = ask;
|
|
516
|
+
if (ts) prevTs = ts;
|
|
517
|
+
continue;
|
|
518
|
+
}
|
|
519
|
+
if (role === 'assistant') {
|
|
520
|
+
const text = assistantText(content);
|
|
521
|
+
if (text) texts.push(text);
|
|
522
|
+
const call = { model: modelName(ex.modelName || ex.modelId) || '', provider: '', tin: null, tout: null, tcache: null, dur: 0 };
|
|
523
|
+
const s = ex.statsSnapshot && typeof ex.statsSnapshot === 'object' ? ex.statsSnapshot : null;
|
|
524
|
+
if (s) {
|
|
525
|
+
const cache = num(s.cachedInputTokens) + num(s.cacheWriteTokens);
|
|
526
|
+
call.tin = Math.max(0, num(s.inputTokens) - cache);
|
|
527
|
+
call.tcache = cache;
|
|
528
|
+
call.tout = num(s.outputTokens);
|
|
529
|
+
call.dur = num(s.elapsedMs);
|
|
530
|
+
} else if (ts && prevTs) call.dur = Math.max(0, ts - prevTs);
|
|
531
|
+
if (text) { const ft = {}; call.text = trunc(text, full, ft); call.textTrunc = !!ft.t; }
|
|
532
|
+
const names = [];
|
|
533
|
+
for (const b of (Array.isArray(content) ? content : [])) {
|
|
534
|
+
if (!b || b.type !== 'tool-call') continue;
|
|
535
|
+
const nm = (typeof b.toolName === 'string' && b.toolName) ? b.toolName : '?';
|
|
536
|
+
names.push(nm);
|
|
537
|
+
const tid = typeof b.toolCallId === 'string' && b.toolCallId ? b.toolCallId : null;
|
|
538
|
+
if (tid) callStart.set(tid, ts);
|
|
539
|
+
const fi = {};
|
|
540
|
+
out.tools.push({
|
|
541
|
+
name: nm, tid, dur: 0, error: null, filled: false,
|
|
542
|
+
input: trunc(toolArgsText(b.args), full, fi), inputTrunc: !!fi.t,
|
|
543
|
+
output: '', outputTrunc: false,
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
if (names.length) call.tools = names;
|
|
547
|
+
out.calls.push(call);
|
|
548
|
+
if (ts) prevTs = ts;
|
|
549
|
+
continue;
|
|
550
|
+
}
|
|
551
|
+
// 其余角色(本机实测是 'tool'):只用来按 toolCallId 回填出参与成败
|
|
552
|
+
for (const b of (Array.isArray(content) ? content : [])) {
|
|
553
|
+
if (!b || b.type !== 'tool-result') continue;
|
|
554
|
+
const info = toolResultInfo(b);
|
|
555
|
+
const tid = typeof b.toolCallId === 'string' ? b.toolCallId : null;
|
|
556
|
+
const nm = (typeof b.toolName === 'string' && b.toolName) ? b.toolName : '?';
|
|
557
|
+
const t = (tid && out.tools.find(x => x.tid === tid)) || out.tools.find(x => !x.filled && x.name === nm);
|
|
558
|
+
if (!t) { notes.add('有工具返回找不到对应的调用(多半落在被摘要掉的那一段里)'); continue; }
|
|
559
|
+
t.filled = true;
|
|
560
|
+
const fo = {};
|
|
561
|
+
t.output = trunc(info.text, full, fo); t.outputTrunc = !!fo.t;
|
|
562
|
+
if (info.error) t.error = info.error;
|
|
563
|
+
const ct = t.tid ? callStart.get(t.tid) : 0;
|
|
564
|
+
if (ts && ct) t.dur = Math.max(0, ts - ct);
|
|
565
|
+
}
|
|
566
|
+
if (ts) prevTs = ts;
|
|
567
|
+
}
|
|
568
|
+
out.assistant = texts.join('\n\n---\n\n');
|
|
569
|
+
// filled 只是本轮内配对用的临时字段,不进 API 出参(与 cline / openclaw 的 tools[] 形状对齐)
|
|
570
|
+
for (const t of out.tools) delete t.filled;
|
|
571
|
+
const errs = out.tools.filter(t => t.error);
|
|
572
|
+
if (errs.length) {
|
|
573
|
+
// 一行 note 装不下两条口径,挑可行动的那条(与 cline / openclaw 同款取舍)
|
|
574
|
+
out.callsNote = '本轮有 ' + errs.length + ' 个工具执行失败(' +
|
|
575
|
+
errs.slice(0, 3).map(t => t.name + ':' + t.error).join(';') +
|
|
576
|
+
(errs.length > 3 ? ' 等' : '') + ')。CodeBuddy 扩展只在 tool-result 的 result.status 里记成败' +
|
|
577
|
+
'(error / cancelled / skipped),模型侧失败不落盘。' + (notes.size ? ' ' + [...notes].join(';') : '');
|
|
578
|
+
} else if (notes.size) {
|
|
579
|
+
out.callsNote = NOTE + ' ' + [...notes].join(';');
|
|
580
|
+
}
|
|
581
|
+
return out;
|
|
582
|
+
}
|