@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.
Files changed (127) hide show
  1. package/LICENSE +21 -0
  2. package/LOGFORMATS.md +960 -0
  3. package/README.md +238 -0
  4. package/REFERENCE.md +1428 -0
  5. package/agent-acta-page.html +1998 -0
  6. package/agent-acta-server.mjs +1066 -0
  7. package/agent-acta-widget.html +724 -0
  8. package/archive.mjs +335 -0
  9. package/core/log.mjs +20 -0
  10. package/core/service.mjs +4285 -0
  11. package/hooks/cursor-usage-hook.mjs +89 -0
  12. package/mcp-server.mjs +372 -0
  13. package/package.json +68 -0
  14. package/page/dialog-add-agent.js +65 -0
  15. package/page/dialog-alert.js +159 -0
  16. package/page/dialog-archive.js +342 -0
  17. package/page/dialog-compare.js +238 -0
  18. package/page/dialog-ctxw.js +101 -0
  19. package/page/dialog-diag.js +72 -0
  20. package/page/dialog-heatmap.js +215 -0
  21. package/page/dialog-selftest.js +202 -0
  22. package/page/dialog-sess-timeline.js +108 -0
  23. package/page/dialog-stats.js +694 -0
  24. package/page/dialog-usage.js +158 -0
  25. package/page/log-card.js +277 -0
  26. package/page/log-view.js +273 -0
  27. package/page/sess-view.js +382 -0
  28. package/page/shared.js +408 -0
  29. package/page/sidebar.js +129 -0
  30. package/page/style.css +973 -0
  31. package/page/topbar.js +61 -0
  32. package/parsers/atomcode.mjs +321 -0
  33. package/parsers/buddy.mjs +272 -0
  34. package/parsers/buddyext.mjs +582 -0
  35. package/parsers/claude.mjs +496 -0
  36. package/parsers/cline.mjs +468 -0
  37. package/parsers/codearts.mjs +96 -0
  38. package/parsers/codex.mjs +188 -0
  39. package/parsers/comate.mjs +195 -0
  40. package/parsers/copilot.mjs +308 -0
  41. package/parsers/cursor.mjs +493 -0
  42. package/parsers/devindb.mjs +358 -0
  43. package/parsers/discovery.mjs +1230 -0
  44. package/parsers/doubao.mjs +406 -0
  45. package/parsers/dsh.mjs +377 -0
  46. package/parsers/gemini.mjs +267 -0
  47. package/parsers/generic.mjs +494 -0
  48. package/parsers/hermes.mjs +398 -0
  49. package/parsers/kilo.mjs +42 -0
  50. package/parsers/kimi.mjs +249 -0
  51. package/parsers/mimocode.mjs +531 -0
  52. package/parsers/minimax.mjs +503 -0
  53. package/parsers/openclaw.mjs +490 -0
  54. package/parsers/opencode.mjs +465 -0
  55. package/parsers/shared.mjs +418 -0
  56. package/parsers/trace.mjs +66 -0
  57. package/parsers/tracecode.mjs +348 -0
  58. package/parsers/traedb.mjs +528 -0
  59. package/parsers/zcode.mjs +356 -0
  60. package/plugin/client.js +51 -0
  61. package/plugin/cordis.patch.yml +5 -0
  62. package/plugin/plugin.mjs +220 -0
  63. package/search.mjs +554 -0
  64. package/tools/grab-trae-key.ps1 +265 -0
  65. package/vendor/element-plus-icons.js +3 -0
  66. package/vendor/element-plus.css +1 -0
  67. package/vendor/element-plus.dark.css +1 -0
  68. package/vendor/element-plus.full.min.js +65 -0
  69. package/vendor/element-plus.zh-cn.js +2 -0
  70. package/vendor/svg/antgroup-color.svg +1 -0
  71. package/vendor/svg/bytedance-color.svg +1 -0
  72. package/vendor/svg/chatglm-color.svg +1 -0
  73. package/vendor/svg/claude-color.svg +1 -0
  74. package/vendor/svg/claudecode-color.svg +1 -0
  75. package/vendor/svg/cline.svg +1 -0
  76. package/vendor/svg/codebuddy-color.svg +1 -0
  77. package/vendor/svg/codebuddy.svg +1 -0
  78. package/vendor/svg/codex-color.svg +1 -0
  79. package/vendor/svg/copilot.svg +1 -0
  80. package/vendor/svg/cursor.svg +1 -0
  81. package/vendor/svg/deepseek-color.svg +1 -0
  82. package/vendor/svg/deepseek-harness-whale.svg +8 -0
  83. package/vendor/svg/doubao-color.svg +1 -0
  84. package/vendor/svg/gemini-color.svg +1 -0
  85. package/vendor/svg/glmv-color.svg +1 -0
  86. package/vendor/svg/grok.svg +1 -0
  87. package/vendor/svg/hermesagent.svg +1 -0
  88. package/vendor/svg/huawei-color.svg +1 -0
  89. package/vendor/svg/hunyuan-color.svg +1 -0
  90. package/vendor/svg/kilocode-avatar.svg +1 -0
  91. package/vendor/svg/kilocode.svg +1 -0
  92. package/vendor/svg/kimi-color.svg +1 -0
  93. package/vendor/svg/kimi.svg +1 -0
  94. package/vendor/svg/logo-AtomGit-G-red.svg +3 -0
  95. package/vendor/svg/logo-AtomGit-dark.svg +4 -0
  96. package/vendor/svg/logo-AtomGit-light.svg +4 -0
  97. package/vendor/svg/meta-color.svg +1 -0
  98. package/vendor/svg/mimo.svg +66 -0
  99. package/vendor/svg/minimax-code-logo.svg +8 -0
  100. package/vendor/svg/minimax-color.svg +1 -0
  101. package/vendor/svg/moonshot.svg +1 -0
  102. package/vendor/svg/nex-color.svg +1 -0
  103. package/vendor/svg/nex.svg +1 -0
  104. package/vendor/svg/nvidia-color.svg +1 -0
  105. package/vendor/svg/openai.svg +1 -0
  106. package/vendor/svg/openclaw-color.svg +1 -0
  107. package/vendor/svg/openclaw.svg +1 -0
  108. package/vendor/svg/opencode.svg +1 -0
  109. package/vendor/svg/openrouter-color.svg +1 -0
  110. package/vendor/svg/qoder-color.svg +1 -0
  111. package/vendor/svg/qwen-color.svg +1 -0
  112. package/vendor/svg/sensenova-color.svg +1 -0
  113. package/vendor/svg/stepfun.svg +1 -0
  114. package/vendor/svg/trae-color.svg +1 -0
  115. package/vendor/svg/traework-v2.svg +1 -0
  116. package/vendor/svg/wenxin-color.svg +1 -0
  117. package/vendor/svg/windsurf.svg +1 -0
  118. package/vendor/svg/workbuddy-color.svg +29 -0
  119. package/vendor/svg/xiaomimimo.svg +1 -0
  120. package/vendor/svg/yuanbao-color.svg +1 -0
  121. package/vendor/svg/yuanbao.svg +1 -0
  122. package/vendor/svg/zai.svg +1 -0
  123. package/vendor/svg/zhipu-color.svg +1 -0
  124. package/vendor/vue.global.prod.js +9 -0
  125. package/widget/main.js +202 -0
  126. package/widget/package.json +7 -0
  127. package/widget/preload.js +11 -0
package/page/topbar.js ADDED
@@ -0,0 +1,61 @@
1
+ // 顶栏(R25 批 2 从 agent-acta-page.html 搬出的第一个组件)。
2
+ // 契约见 page/shared.js 头部的「R25 组件文件契约」;Vue 的 API 一律写全名,不解构。
3
+ //
4
+ // 数据进出(搬的时候逐个对过):
5
+ // · 这里只是**显示 + 发命令**:liveText / idleText / scanning / scanMs / idleExitMs 全是根的 SSE
6
+ // 与 /api/settings 回写出来的状态(改前就在根里,改后还在根里)—— 组件不持有其中任何一个,
7
+ // 否则一份真相变两份,多标签页对齐(settings 事件)就会写不到真正驱动连接的那份上。
8
+ // · 三个命令走 emit:set-scan / set-idle 都是 POST /api/settings + ElMessage,launch-client 是 POST /api/client,
9
+ // 这些都要用根的 getJSON(404 点亮版本红条那段逻辑只有一份),所以处理函数留在根。
10
+ // · 品牌区(logo + AgentActa)纯静态,跟着搬家。
11
+ window.AACTA = window.AACTA || {};
12
+ AACTA.Topbar = {
13
+ name: 'aa-topbar',
14
+ template: `
15
+ <header class="topbar">
16
+ <div class="brand"><span class="logo">A</span>AgentActa</div>
17
+ <div class="top-right">
18
+ <!-- 唤出桌面悬浮卡片:卡片被 ✕ 关掉后壳可能还活着(被面板窗吊着),命令行之外给页面一个入口。
19
+ 服务端 POST /api/client → runClient()(幂等):壳在就聚焦/重建卡片,不在就拉起新壳。 -->
20
+ <el-button size="small" plain :loading="clientLaunching" title="打开桌面悬浮卡片(右上角常驻的小窗/水球)" @click="$emit('launch-client')">悬浮卡片</el-button>
21
+ <el-dropdown trigger="click" placement="bottom-end" @command="v => $emit('set-idle', v)">
22
+ <span class="live-tag idle" :class="{idleOn: idleExitMs > 0}" :title="'页面全关且没有新日志持续这么久后,服务自己退出(下次开会话会被 hook 自动拉起)。当前:' + idleText">
23
+ <span class="dot"></span>{{ idleText }}<el-icon class="caret"><arrow-down></arrow-down></el-icon>
24
+ </span>
25
+ <template #dropdown>
26
+ <el-dropdown-menu>
27
+ <el-dropdown-item v-for="o in idleOptions" :key="o.v" :command="o.v" :class="{picked: idleExitMs === o.v}">{{ o.label }}</el-dropdown-item>
28
+ </el-dropdown-menu>
29
+ </template>
30
+ </el-dropdown>
31
+ <el-dropdown trigger="click" placement="bottom-end" @command="v => $emit('set-scan', v)">
32
+ <span class="live-tag" :class="{on: live && !paused && !scanning, scan: live && scanning, paused: live && paused && !scanning}"
33
+ :title="scanning ? '正在扫描各 agent 的日志目录,扫完自动进入实时更新' : (paused ? '已暂停定时扫描,点此选择刷新频率' : '点此调整刷新频率')">
34
+ <span class="dot"></span>{{ liveText }}<el-icon class="caret"><arrow-down></arrow-down></el-icon>
35
+ </span>
36
+ <template #dropdown>
37
+ <el-dropdown-menu>
38
+ <el-dropdown-item v-for="o in scanOptions" :key="o.v" :command="o.v" :class="{picked: scanMs === o.v}">{{ o.label }}</el-dropdown-item>
39
+ </el-dropdown-menu>
40
+ </template>
41
+ </el-dropdown>
42
+ </div>
43
+ </header>`,
44
+ props: {
45
+ clientLaunching: Boolean,
46
+ live: Boolean,
47
+ paused: Boolean,
48
+ // null = 没在扫;{ done, total, agent } = 正在扫。模板只读它做 class 与 title,不写
49
+ scanning: { type: Object, default: null },
50
+ scanMs: { type: Number, default: 0 },
51
+ scanOptions: { type: Array, default: () => [] },
52
+ liveText: { type: String, default: '' },
53
+ idleExitMs: { type: Number, default: 0 },
54
+ idleOptions: { type: Array, default: () => [] },
55
+ idleText: { type: String, default: '' },
56
+ },
57
+ emits: ['launch-client', 'set-idle', 'set-scan'],
58
+ setup() {
59
+ return {};
60
+ }
61
+ };
@@ -0,0 +1,321 @@
1
+ // ---------------- atomcode 解析器(sessions/<hash>/*.meta + *.jsonl) ----------------
2
+ // 从 agent-acta-server.mjs 拆出(纯搬家,逻辑零改动)。导出:
3
+ // expandMeta —— .meta 轮次 → 条目(scanAtomcodeDir 与分片首扫共用)
4
+ // scanAtomcodeDir —— 一个 hash 会话目录:*.jsonl 逐行增量(轮时间戳/预览)+ *.meta 全量
5
+ // atomcodeDirs —— hash 会话目录列表
6
+ // atomcodeEntryContent —— 单轮详情(重读 .jsonl 取正文/工具;逐次调用来自 datalog)
7
+ // datalogCalls / datalogDir —— 逐次 LLM 调用明细(数据源,atomcodeEntryContent 内部调用)
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import { HOME, files, addEntry, modelList, markKind, listDirCached, isDir, trunc, readCompleteLines } from './shared.mjs';
11
+
12
+ export function sumModel(ts, key) { return (ts.model_usage || []).reduce((a, m) => a + (m.tokens?.[key] || 0), 0); }
13
+
14
+ // ---------------- atomcode datalog(config.toml 的 [datalog]:逐次 LLM 调用明细) ----------------
15
+ // atomcode 的「逐次请求」明细**不在** sessions/ 里:`.meta` 的 `turn_stats[].model_usage` 是
16
+ // **轮 × 模型**的聚合(一轮里跑 80 次调用也只留一行),拆不开。真正的逐次记录在 `config.toml`
17
+ // 的 `[datalog]` 段指向的独立目录里:每个 turn 一对
18
+ // `<时间戳>-<会话uuid>-t<轮号>-p<pid>-i<实例>.{md,jsonl}`
19
+ // `.md` 里每个 round 一段 `### Turn N`,带**真实**的 `_[tokens: prompt=…+completion=…, cache=…tok]_`。
20
+ //
21
+ // 三个必须守住的约束:
22
+ // 1. **只读 .md**。本机实测那目录 16GB,其中 11GB 是 .jsonl(完整请求体),.md 只有 85MB。
23
+ // 2. **只取数字**。.md 里有原始 prompt、推理全文、工具出入参——一个字节都不往这里带。
24
+ // 3. **拿不到不算错**。datalog 默认关闭(`enabled=false` 时目录根本不存在),用户没开就返回空数组。
25
+ // 旧命名(`2026-05-26_15-57-41.md`,名字里没有 uuid)挂不到具体某一轮上,只能放弃——
26
+ // 本机 3233 轮里能精确对上的 1013 轮,所以 UI 必须容忍「有的轮有明细、有的没有」。
27
+
28
+ // config.toml 里 [datalog] 的 dir,30s 缓存。**必须读配置**:本机就落在 E: 盘(不在 ~/.atomcode 下),
29
+ // 硬猜默认路径在这台机器上会一条都读不到。没有 config.toml / 解析不了 → 退回默认位置试一下。
30
+ let dlCfg = { at: 0, dir: null };
31
+ export function datalogDir() {
32
+ if (dlCfg.dir !== null && Date.now() - dlCfg.at < 30000) return dlCfg.dir || null;
33
+ const home = path.join(HOME, '.atomcode');
34
+ const fallback = path.join(home, 'datalog');
35
+ let dir = fallback;
36
+ try {
37
+ const text = fs.readFileSync(path.join(home, 'config.toml'), 'utf8');
38
+ let inSec = false, enabled = null, raw = '';
39
+ for (const line0 of text.split('\n')) {
40
+ const line = line0.trim();
41
+ if (line.startsWith('[')) { inSec = line.replace(/\s/g, '') === '[datalog]'; continue; }
42
+ if (!inSec || line.startsWith('#')) continue;
43
+ const m = line.match(/^([A-Za-z_][\w.-]*)\s*=\s*(.*)$/);
44
+ if (!m) continue;
45
+ let v = m[2];
46
+ if (v[0] === '"' || v[0] === "'") { const q = v[0], e = v.indexOf(q, 1); v = e > 0 ? v.slice(1, e) : v.slice(1); }
47
+ else { const h = v.indexOf('#'); if (h >= 0) v = v.slice(0, h); v = v.trim(); }
48
+ if (m[1] === 'enabled') enabled = v !== 'false';
49
+ if (m[1] === 'dir') raw = v;
50
+ }
51
+ if (enabled === false) { dlCfg = { at: Date.now(), dir: '' }; return null; } // 显式关掉 → 不读
52
+ if (raw) {
53
+ // 支持 ~ 与绝对路径;相对路径按 cwd 解析(这里没有可靠的 cwd)→ 不猜,退回默认位置
54
+ if (raw === '~') dir = HOME;
55
+ else if (/^[~][\\/]/.test(raw)) dir = path.join(HOME, raw.slice(2));
56
+ else if (path.isAbsolute(raw)) dir = raw;
57
+ }
58
+ } catch { /* 没有 config.toml:按默认位置试 */ }
59
+ try { if (!fs.statSync(dir).isDirectory()) dir = ''; } catch { dir = ''; }
60
+ dlCfg = { at: Date.now(), dir };
61
+ return dir || null;
62
+ }
63
+
64
+ // uuid#轮号 → .md 路径。uuid 全局唯一,不用拿项目名/hash 去拼(省掉一套命名清洗的坑)。
65
+ // 目录里 2600+ 文件、6 个子目录,readdir 一遍很便宜,30s 重建一次足够。
66
+ let dlIdx = { at: 0, dir: '', map: null };
67
+ function datalogIndex(dir) {
68
+ if (dlIdx.map && dlIdx.dir === dir && Date.now() - dlIdx.at < 30000) return dlIdx.map;
69
+ const map = new Map();
70
+ try {
71
+ for (const proj of fs.readdirSync(dir)) {
72
+ const dp = path.join(dir, proj);
73
+ let st; try { st = fs.statSync(dp); } catch { continue; }
74
+ if (!st.isDirectory()) continue;
75
+ for (const f of fs.readdirSync(dp)) {
76
+ const m = f.match(/-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})-t(\d+)-[^\\/]*\.md$/i);
77
+ if (m) map.set(m[1].toLowerCase() + '#' + m[2], path.join(dp, f));
78
+ }
79
+ }
80
+ } catch { /* 目录半路消失就当没有 */ }
81
+ dlIdx = { at: Date.now(), dir, map };
82
+ return map;
83
+ }
84
+
85
+ // 把一份 .md 解析成逐轮调用。两个坑都踩过,记下来:
86
+ //
87
+ // 1. **别拿 ``` 配对来跳过代码块**。真这么写了,结果某些 md 里围栏行数是**奇数**(实测一份
88
+ // 139 行,全都在列 0),状态机一旦错位就把后半份文件整个吞掉——那次的症状是「54 轮只解析出
89
+ // 44 轮」。源文件的围栏本来就不保证平衡,靠它做区间划分就是用不可靠的输入去当边界。
90
+ // 2. 工具结果里**可能逐字回显** `### Turn N` 和整行 tokens(这个面板自己就在读别的日志,这不是
91
+ // 假想)。所以表头必须序号连续才认;账行要求行首恰好缩进两格、整行严丝合缝;而且一轮里若
92
+ // 出现多行,**以最后一行为准**——真账行总是打在这一轮全部工具调用之后,回声在它前面,会被盖掉。
93
+ function parseDatalogMd(text) {
94
+ let model = '';
95
+ const rounds = [];
96
+ let cur = null, last = 0;
97
+ for (const line of text.split('\n')) {
98
+ if (!model && line.startsWith('**env:**')) { const m = line.match(/model=([^,\s]+)/); if (m) model = m[1]; continue; }
99
+ const h = line.match(/^### Turn (\d+)\s*$/);
100
+ if (h) {
101
+ const n = Number(h[1]);
102
+ if (n === last + 1) { cur = { n, prompt: null, completion: 0, cache: 0, dur: null }; rounds.push(cur); last = n; }
103
+ continue; // 不连续的表头 = 正文里的回声,当没看见(保留 cur,别把这一轮的账丢了)
104
+ }
105
+ if (!cur) continue;
106
+ const t = line.match(/^ {2}_\[tokens: prompt=(\d+)\+completion=(\d+), cache=(\d+)tok([^\]]*)\]_\s*$/);
107
+ if (t) {
108
+ cur.prompt = +t[1]; cur.completion = +t[2]; cur.cache = +t[3];
109
+ // v5.1.0 起这一行会多带本轮的请求耗时(on_request→on_model_response 计时)。字段是本机
110
+ // 5.0.9 还产不出来的东西,所以**尾段留成通配**再捞 `dur=Nms`:格式换了位置也认,
111
+ // 没有就是没有(对 5.1.0 之前的全部旧文件实测零影响)。
112
+ const d = t[4].match(/dur=(\d+)ms/);
113
+ if (d) cur.dur = +d[1];
114
+ }
115
+ }
116
+ return { model, rounds: rounds.filter(r => r.prompt != null) };
117
+ }
118
+
119
+ // 同一份 .md 会随会话进行不断变长,而 live 的条目每个扫描周期都会被重新取一次详情——
120
+ // 用 {mtime,size} 当版本号挡住重复解析(statSync 比重读+解析一个几 MB 的 md 便宜得多)。
121
+ const dlCache = new Map();
122
+ export function datalogCalls(src, fallbackModel) {
123
+ const dir = datalogDir();
124
+ if (!dir) return [];
125
+ const base = path.basename(src.file);
126
+ const uuid = (base.endsWith('.jsonl') ? base.slice(0, -6) : base).toLowerCase();
127
+ const f = datalogIndex(dir).get(uuid + '#' + src.turn);
128
+ if (!f) return [];
129
+ let calls, key;
130
+ try {
131
+ const st = fs.statSync(f);
132
+ key = st.mtimeMs + ':' + st.size;
133
+ const hit = dlCache.get(f);
134
+ if (hit && hit.key === key) return hit.calls;
135
+ const { model, rounds } = parseDatalogMd(fs.readFileSync(f, 'utf8'));
136
+ const mdl = model || fallbackModel || '';
137
+ calls = rounds.map(r => ({
138
+ model: mdl,
139
+ // 口径对齐卡片:datalog 的 prompt **含**缓存,面板的 tin 是**不含**缓存的输入。
140
+ // 对过账:Σ(prompt − cache) == .meta 的 model_usage.input,896/896 精确相等。
141
+ tin: Math.max(0, r.prompt - r.cache), tout: r.completion, tcache: r.cache,
142
+ // 本轮请求耗时。5.1.0 之前 datalog 不记(旧命名格式那个 _(3.9s)_ 是**工具**耗时,别混进来),
143
+ // 拿不到就是 0 → 页面 fmtDur 渲染成 —,宁可留空也不编一个数出来
144
+ dur: r.dur != null ? r.dur : 0,
145
+ }));
146
+ } catch { return []; }
147
+ if (dlCache.size > 64) dlCache.clear();
148
+ dlCache.set(f, { key, calls });
149
+ return calls;
150
+ }
151
+
152
+ // ---------------- 条目生成 ----------------
153
+ export function expandMeta(agent, metaPath, base, jsonlRef, data) {
154
+ const jsonlPath = jsonlRef ? jsonlRef[0] : metaPath.slice(0, -5) + '.jsonl';
155
+ const jdata = jsonlRef ? files.get(jsonlRef[0])?.data : null;
156
+ const turnTs = jdata?.turnTs || {};
157
+ const preview = jdata?.preview || {};
158
+ const md5ish = metaPath.replace(/[\\/:]/g, '~');
159
+ for (const t of data.turns) {
160
+ // 确定性 id:meta 每轮都会变、会重扫,自增 id 会导致同一轮重复灌入(曾放大到 67 份)
161
+ const id = 'a#' + md5ish + '#' + t.turn_id;
162
+ // I16 / D1:`time` 一律取**轮起点**,与全项目口径一致(claude / dsh / zcode / codex 全取起点)。
163
+ // 以前写的是 `turnTs[t.turn_id]`,而 jsonl 的 `ts` 是**轮结束**时刻(对账 ts − started_at
164
+ // ≈ duration_ms,本机 773 条样本平均偏差 15ms 证实)⇒ atomcode 是唯一一家取终点的,
165
+ // 卡片/会话页显示的时间戳整体偏一个轮长、按天分桶也跟着偏。
166
+ // jsonl 里**同时**有 started_at(起点)与 ts(终点):优先取 started_at,拿不到才退 ts − dur
167
+ // (dur = meta 的 duration_ms,本身就是「终点−起点」),两者都拿不到才算时间未知。
168
+ // I16 / D2:拿不到时间时**不再沉默回落 updated_at**。那会让同一会话几十轮全塌成同一时刻
169
+ // (本机 2391/3492 轮 = 68.5% 是这么来的),页面看上去「这些轮同时发生」,而真相是「不知道」。
170
+ // 改成落 `timeUnknown: true` + time=0 —— 页面按「时间未知」如实标注并把它排到最后,
171
+ // 不拿会话更新时刻冒充轮时间。这是前置闸那条「排除时间未知的轮」的落点。
172
+ const end = Number(turnTs[t.turn_id]);
173
+ const start = Number(jdata?.turnStart?.[t.turn_id]);
174
+ const dur = Number(t.dur) || 0;
175
+ let time = 0, unknown = false;
176
+ if (start > 0) time = start;
177
+ else if (end > 0) time = Math.max(0, end - dur);
178
+ else unknown = true;
179
+ addEntry(id, {
180
+ agent, project: data.project || '(未知项目)', session: data.name || base,
181
+ time, dur: t.dur, status: t.errored ? 'error' : 'ok',
182
+ // 时间未知的轮如实标注:页面据此不拿它做时间轴/拓扑排序,也不并进按天分桶的假位置。
183
+ timeUnknown: unknown ? true : undefined,
184
+ // total 与别家口径统一:轮内各次调用的累加(meta 的 model_usage 本来就是按轮累加的)。
185
+ // ⚠️ 不能再用 meta.total_tokens 当 total —— 实测它 = **末轮 prompt+completion**(986 个样本 985 个精确相等),
186
+ // 语义是「这一轮结束时的上下文占用」,跟 tin/tout/tcache(各轮累加)不是一个量纲。
187
+ // 拿它当合计的话卡片上会出现「合计 269160 < 输入 431208」这种算不通的数。
188
+ tin: t.tin, tout: t.tout, tcache: t.tcache, total: t.tin + t.tout + t.tcache,
189
+ // ctx 是窗口**容量**,ctxUsed 是结束时**实际占用**,页面那根进度条要的是后者(见页面 ctxUsed)
190
+ ctx: t.ctx, ctxUsed: t.ctxUsed,
191
+ // round_count 就是轮内 LLM 调用次数(拿 datalog 的逐轮块数对过:1015 轮里 896 轮精确相等,
192
+ // 少数差 1 是被打断没跑完的那轮)——以前写死 null 让「N 次 LLM」徽章一直不显示,数字其实一直在手上
193
+ rounds: t.rounds, tools: t.tools, calls: t.rounds,
194
+ toolNames: (jdata?.toolNames || {})[t.turn_id] || {},
195
+ models: t.models, preview: preview[t.turn_id] || '',
196
+ }, { file: jsonlPath, turn: Number(t.turn_id), kind: 'atomcode' });
197
+ }
198
+ }
199
+
200
+ // ---------------- 扫描 ----------------
201
+ export function scanAtomcodeDir(agent, names, dir) {
202
+ const metas = [], jsonls = new Map();
203
+ for (const n of names) {
204
+ const fp = path.join(dir, n);
205
+ let fst; try { fst = fs.statSync(fp); } catch { continue; }
206
+ if (!fst.isFile()) continue;
207
+ if (n.endsWith('.meta')) metas.push([fp, fst]);
208
+ else if (n.endsWith('.jsonl')) jsonls.set(n.slice(0, -6), [fp, fst]);
209
+ }
210
+ for (const [base, [fp, fst]] of jsonls) {
211
+ const key = fp, m = fst.mtimeMs, s = fst.size;
212
+ const prev = files.get(key);
213
+ if (prev && prev.m === m && prev.s === s) continue;
214
+ const off = prev?.off || 0;
215
+ let data = prev?.data || { turnTs: {}, turnStart: {}, preview: {}, toolNames: {} };
216
+ if (s <= off) continue;
217
+ try {
218
+ // 只推进到最后一个完整行,避免半截行被吞(旧版 off=size+shift 每次追加丢一条)
219
+ const rd = readCompleteLines(fp, off, s);
220
+ if (!rd) { files.set(key, { agent, kind: 'jsonl', m, s, off, data }); continue; }
221
+ const newOff = rd.newOff;
222
+ for (const line of rd.lines) {
223
+ // I16 / D1:`ts` 是**轮结束**时刻、`started_at` 才是**轮起点**(全项目口径取起点)。
224
+ // 两个都收下来,emit 时优先用 started_at(见 expandMeta),拿不到才退 `ts − duration_ms`。
225
+ // 老 state 没有 turnStart ⇒ 缺这一位时回落仍成立,不会把已有的轮时间弄丢。
226
+ const tm = line.match(/"turn_id":(\d+)/), tsm = line.match(/"ts":(\d+)/), sam = line.match(/"started_at":(\d+)/);
227
+ if (!tm || (!tsm && !sam)) continue;
228
+ const tid = tm[1];
229
+ if (tsm && !data.turnTs[tid]) data.turnTs[tid] = Number(tsm[1]);
230
+ if (!data.turnStart) data.turnStart = {};
231
+ if (sam && !data.turnStart[tid]) data.turnStart[tid] = Number(sam[1]);
232
+ if (!data.preview[tid]) {
233
+ const um = line.match(/"user":"((?:[^"\\]|\\.)*)"/);
234
+ if (um) data.preview[tid] = um[1].replace(/\\n/g, ' ').replace(/\\"/g, '"').replace(/\\u[\dA-Fa-f]{4}/g, '·').slice(0, 200);
235
+ }
236
+ // 工具名去重计数(卡片「调用」旁的问号悬浮层数据源):jsonl 每行的 tools[] 带逐个 name。
237
+ // 用 JSON.parse 而不是正则:tools 的 args 字符串里可能含 ] 等字符,正则会断在半路
238
+ // (实测一轮 7 个 edit_file 只数出 1 个)。解析失败的行宁可丢掉也不数错。
239
+ try {
240
+ const j = JSON.parse(line);
241
+ if (Array.isArray(j.tools) && j.tools.length) {
242
+ if (!data.toolNames[tid]) data.toolNames[tid] = {};
243
+ const tn = data.toolNames[tid];
244
+ for (const tl of j.tools) { const n = tl && tl.name; if (n) tn[n] = (tn[n] || 0) + 1; }
245
+ }
246
+ } catch {}
247
+ }
248
+ files.set(key, { agent, kind: 'jsonl', m, s, off: newOff, data });
249
+ markKind('jsonl');
250
+ } catch {}
251
+ }
252
+ for (const [fp, fst] of metas) {
253
+ const base = path.basename(fp).slice(0, -5);
254
+ const key = fp, m = fst.mtimeMs, s = fst.size;
255
+ const prev = files.get(key);
256
+ if (prev && prev.m === m && prev.s === s) continue;
257
+ let data = null;
258
+ try {
259
+ const j = JSON.parse(fs.readFileSync(fp, 'utf8'));
260
+ data = {
261
+ project: j.working_dir || '', name: j.name || '', created: j.created_at || 0, updated: j.updated_at || 0,
262
+ turns: (j.turn_stats || []).map(t => ({
263
+ // total_tokens 不是「本轮用了多少 token」,是**末轮 prompt+completion**=结束时的上下文占用,
264
+ // 所以挂在 ctxUsed 上(页面用它画上下文进度条),别当 token 合计用
265
+ turn_id: t.turn_id, dur: t.duration_ms || 0, ctxUsed: t.total_tokens || 0,
266
+ tin: sumModel(t, 'input'), tout: sumModel(t, 'output'), tcache: sumModel(t, 'cached_input'),
267
+ ctx: t.ctx_window || 0, rounds: t.round_count || 0, tools: t.tool_call_count || 0,
268
+ errored: !!t.errored, models: modelList((t.model_usage || []).map(x => x.model_id)),
269
+ })),
270
+ };
271
+ } catch { continue; }
272
+ files.set(key, { agent, kind: 'meta', m, s, off: 0, data });
273
+ expandMeta(agent, fp, base, jsonls.get(base), data);
274
+ }
275
+ }
276
+
277
+ // hash 会话目录列表(原逻辑:列不出目录、或子项不是目录都跳过)
278
+ export function atomcodeDirs(root) {
279
+ const hashes = listDirCached(root);
280
+ if (!hashes) return [];
281
+ const out = [];
282
+ for (const hash of hashes) { const d = path.join(root, hash); if (isDir(d)) out.push(d); }
283
+ return out;
284
+ }
285
+
286
+ // 详情:重读 sessions 的 .jsonl 按 turn_id 过滤取正文/工具,逐次调用明细来自 datalog(见上)。
287
+ // entry 是扫描侧产出的条目(models / calls)——只作兜底用,从参数传入,避免这里反向去查全局表。
288
+ export function atomcodeEntryContent(src, full, entry) {
289
+ const fstate = files.get(src.file);
290
+ const version = fstate ? fstate.m + ':' + (fstate.off || 0) : null;
291
+ let out = null;
292
+ const lines = fs.readFileSync(src.file, 'utf8').split('\n');
293
+ for (const line of lines) {
294
+ if (!line.trim()) continue;
295
+ let j; try { j = JSON.parse(line); } catch { continue; }
296
+ if (j.turn_id !== src.turn) continue;
297
+ if (!out) out = { user: '', assistant: '', tools: [] };
298
+ if (j.user && !out.user) out.user = String(j.user);
299
+ // 一轮多 round:assistant 逐行累加(原为覆盖,中间输出会丢)
300
+ if (j.assistant) out.assistant = out.assistant ? out.assistant + '\n\n---\n\n' + j.assistant : String(j.assistant);
301
+ for (const tl of j.tools || []) {
302
+ const fi = {}, fo = {};
303
+ out.tools.push({ name: tl.name, input: trunc(tl.args, full, fi), inputTrunc: !!fi.t, output: trunc(tl.result, full, fo), outputTrunc: !!fo.t, error: !!tl.is_error });
304
+ }
305
+ }
306
+ if (!out) out = { user: '', assistant: '', tools: [] };
307
+ // 逐次 LLM 调用明细来自 datalog(sessions 里没有这个粒度),没有就空数组 → 页面那块不渲染。
308
+ // 注意:sessions 的 .jsonl 可能是 0 字节(实测有这种会话),此时上面循环啥也没读到,
309
+ // 但 datalog 照旧能给明细,所以 calls 必须挂在「读不到正文」这个分支之外。
310
+ out.calls = datalogCalls(src, (entry?.models || [])[0]);
311
+ // 卡片上写着「N 次 LLM」却一条明细都拿不出来时,得说清为什么——不然看着像坏了。
312
+ // 两种情况分开写:开关/目录压根没开(用户能去开),和这一轮恰好没落盘(只能等)。
313
+ if (!out.calls.length) {
314
+ const rc = entry?.calls || 0;
315
+ if (rc > 0) out.callsNote = datalogDir()
316
+ ? `这一轮没有 datalog 记录(${rc} 次调用只剩轮级汇总)`
317
+ : 'datalog 未开启(默认关闭),拿不到逐次明细';
318
+ }
319
+ out.v = version;
320
+ return out;
321
+ }
@@ -0,0 +1,272 @@
1
+ // ---------------- buddy jsonl(codebuddy / workbuddy:<base>/projects/<slug>/<sessionId>.jsonl) ----------------
2
+ // 从 agent-acta-server.mjs 拆出(纯搬家,逻辑零改动)。导出 scanBuddy / buddyEntryContent
3
+ // 与口径函数(buddyUserText / buddyCall / buddyPreview —— 详情与扫描共用同一套)。
4
+ //
5
+ // 与 claude 同族的逐行 jsonl,但记录类型不同:
6
+ // {"type":"message","role":"user"|"assistant"} 开轮/正文
7
+ // {"type":"function_call"} / {"type":"function_call_result"}(callId 配对)/ {"type":"reasoning"}
8
+ // {"type":"turn-metrics","durationMs","tokenDelta"} 一轮收尾 → 精确耗时
9
+ // {"type":"ai-title","aiTitle"} 会话标题
10
+ // 两个必须知道的格式坑:
11
+ // 1) 用户真实输入常被包在 <system-reminder>(IDE 注入)里,真正的 prompt 在 <user_query> 中
12
+ // (workbuddy 几乎全是这种),codebuddy 多数是裸文本 —— 先取 user_query,再剥 system-reminder 段。
13
+ // 2) token 用量挂在「每次 LLM 调用」的记录上,既可能在 message:assistant 也可能在 function_call,
14
+ // 按 providerData.messageId 去重累加(实测累加值 == turn-metrics.tokenDelta,可互相校验);
15
+ // 且 usage.input_tokens 含缓存命中,要减去 cache_read_input_tokens 才与 claude 口径一致。
16
+ // 3) 同一条用户消息会被源重写一次(jsonl 只追加 → 两行、`id` 相同,第二行把同一段话包进
17
+ // <user_query>),两边时间戳与抽取正文一致 —— 开轮按 `id` 去重(扫描/详情同一判据),
18
+ // 否则一张卡变两张(本机 149 份会话实测 7 对)。
19
+ import fs from 'node:fs';
20
+ import path from 'node:path';
21
+ import { files, entries, addEntry, markKind, listDirCached, isDir, trunc, readCompleteLines } from './shared.mjs';
22
+
23
+ export const BUDDY_SKIP_RE = /^(<command-name>|<local-command|<system-reminder|<cb_summary>)/;
24
+
25
+ export function buddyUserText(content) {
26
+ const blocks = Array.isArray(content) ? content : [];
27
+ const raw = blocks.map(b => (typeof b === 'string' ? b : String(b?.text || ''))).join('\n');
28
+ if (!raw.trim()) return '';
29
+ const q = /<user_query>([\s\S]*?)<\/user_query>/.exec(raw);
30
+ if (q && q[1].trim()) return q[1].replace(/\s+/g, ' ').trim();
31
+ const rest = raw.replace(/<system-reminder[\s\S]*?<\/system-reminder>/g, '').trim();
32
+ if (!rest || BUDDY_SKIP_RE.test(rest)) return ''; // 纯注入/命令消息不开新轮(与详情同口径)
33
+ return rest;
34
+ }
35
+
36
+ export function buddyUsage(j) {
37
+ const u = j.message?.usage || j.providerData?.usage;
38
+ const raw = j.providerData?.rawUsage;
39
+ if (!u && !raw) return null;
40
+ const inAll = u?.input_tokens ?? raw?.prompt_tokens ?? 0;
41
+ // 两处都有可能带缓存命中数,取大者(口径:input_tokens 含缓存)
42
+ const cache = Math.max(u?.cache_read_input_tokens || 0, raw?.prompt_tokens_details?.cached_tokens || 0);
43
+ const tout = u?.output_tokens ?? raw?.completion_tokens ?? 0;
44
+ return { tin: Math.max(0, inAll - cache), tout, tcache: cache };
45
+ }
46
+
47
+ // 一条 LLM 调用的用量(调用可能是 assistant 文本,也可能是 function_call,共用一个 messageId)
48
+ export function buddyCall(j) {
49
+ const pd = j.providerData || {};
50
+ return { mid: pd.messageId || j.id || null, model: pd.requestModelName || pd.model || '', usage: buddyUsage(j) };
51
+ }
52
+
53
+ // 预览文案:后台任务通知 / 队友消息 / 压缩摘要同样会开轮(它们确实在烧 token),但预览要把 XML 标签换成看得懂的字
54
+ export function buddyPreview(t) {
55
+ const tn = /<task-notification>([\s\S]*?)<\/task-notification>/.exec(t);
56
+ if (tn) {
57
+ const s = /<summary>([\s\S]*?)<\/summary>/.exec(tn[1]);
58
+ return ('[后台任务] ' + (s ? s[1] : tn[1])).replace(/\s+/g, ' ').trim();
59
+ }
60
+ const tm = /<teammate-message[^>]*>([\s\S]*?)<\/teammate-message>/.exec(t);
61
+ if (tm) return ('[队友消息] ' + tm[1].replace(/<[^>]*>/g, ' ')).replace(/\s+/g, ' ').trim();
62
+ if (/^<(cb_summary|conversation_history_summary)>/.test(t)) return ('[历史摘要] ' + t.replace(/<\/?[a-z_]+>/g, ' ')).replace(/\s+/g, ' ').trim();
63
+ return t.replace(/\s+/g, ' ').trim();
64
+ }
65
+
66
+ export function buddyIdBase(fp) { return 'b#' + fp.replace(/[\\/:]/g, '~') + '#'; }
67
+
68
+ function emitBuddyTurns(agent, fp, slug, data, fromIdx) {
69
+ const idBase = buddyIdBase(fp);
70
+ for (let i = fromIdx; i < data.turns.length; i++) {
71
+ const t = data.turns[i];
72
+ addEntry(idBase + t.idx, {
73
+ agent, project: data.cwd || slug, session: data.sessionId || path.basename(fp, '.jsonl'),
74
+ time: t.time, dur: t.dur || Math.max(0, t.lastTs - t.time), status: t.err ? 'error' : 'ok',
75
+ tin: t.tin, tout: t.tout, tcache: t.tcache, total: t.tin + t.tout + t.tcache,
76
+ ctx: 0, rounds: t.calls, tools: t.tools, calls: t.calls, // 同 claude:rounds=calls=轮内 LLM 调用数
77
+ models: t.models, preview: t.preview, name: data.title || null, // name=会话标题(无模型信息时前端用它兜底)
78
+ finished: !!t.finished, aborted: !!t.aborted, toolNames: t.toolNames || {},
79
+ }, { file: fp, turn: t.idx, kind: 'buddyjsonl' });
80
+ }
81
+ }
82
+
83
+ export function scanBuddy(agent, root, onlyFile) { // onlyFile 见 scanClaude 的说明
84
+ const slugs = listDirCached(root);
85
+ if (!slugs) return;
86
+ const onlyDir = onlyFile ? path.dirname(onlyFile) : null; // 同 scanClaude:排在 isDir 之前,省掉 slug 数 × 文件数次 stat
87
+ for (const slug of slugs) {
88
+ const dir = path.join(root, slug);
89
+ if (onlyDir && dir !== onlyDir) continue;
90
+ if (!isDir(dir)) continue;
91
+ const names = listDirCached(dir);
92
+ if (!names) continue;
93
+ for (const n of names) {
94
+ if (!n.endsWith('.jsonl')) continue;
95
+ const fp = path.join(dir, n);
96
+ if (onlyFile && fp !== onlyFile) continue;
97
+ let fst; try { fst = fs.statSync(fp); } catch { continue; }
98
+ const key = fp, m = fst.mtimeMs, s = fst.size;
99
+ const prev = files.get(key);
100
+ if (prev && prev.m === m && prev.s === s) {
101
+ // 重启后 entries 为空但 files 状态被恢复:从状态直接重建条目,不重扫文件
102
+ if (prev.data?.turns?.length && !entries.has(buddyIdBase(fp) + (prev.data.turns.length - 1))) emitBuddyTurns(agent, fp, slug, prev.data, 0);
103
+ continue;
104
+ }
105
+ const off = prev?.off || 0;
106
+ const data = prev?.data || { cwd: '', title: '', sessionId: '', turns: [], emitted: 0, uids: [] };
107
+ if (s <= off) { files.set(key, { agent, kind: 'buddyjsonl', m, s, off, data }); markKind('buddyjsonl'); continue; }
108
+ try {
109
+ const rd = readCompleteLines(fp, off, s);
110
+ if (!rd) { files.set(key, { agent, kind: 'buddyjsonl', m, s, off, data }); markKind('buddyjsonl'); continue; } // 尚无完整行
111
+ const newOff = rd.newOff;
112
+ for (const line of rd.lines) {
113
+ if (!line.trim()) continue;
114
+ let j; try { j = JSON.parse(line); } catch { continue; }
115
+ if (j.cwd && !data.cwd) data.cwd = j.cwd;
116
+ if (j.sessionId) data.sessionId = j.sessionId;
117
+ if (j.type === 'ai-title' && j.aiTitle) data.title = String(j.aiTitle).slice(0, 60);
118
+ if (j.type === 'message' && j.role === 'user') {
119
+ const t0 = buddyUserText(j.content);
120
+ if (!t0) continue;
121
+ // 同一条用户消息会被源**原地重写**一次(jsonl 只追加,故落盘两行、`id` 相同):
122
+ // 先一行裸文本、紧跟一行把同一段话包进 <user_query>(实测 149 份会话共 7 对,
123
+ // 两边的时间戳与抽取后的正文完全一致)。不去重就一张卡变两张。
124
+ const umsg = j.id || null;
125
+ if (umsg) {
126
+ if (data.uids.includes(umsg)) continue;
127
+ data.uids.push(umsg);
128
+ }
129
+ const ts = j.timestamp || 0;
130
+ if (data.turns.length) data.turns[data.turns.length - 1].finished = true;
131
+ data.turns.push({
132
+ idx: data.turns.length, time: ts, lastTs: ts, dur: 0,
133
+ tin: 0, tout: 0, tcache: 0, tools: 0, calls: 0, mids: [], models: [], err: false,
134
+ preview: buddyPreview(t0).slice(0, 300), toolNames: {}, finished: false, aborted: false,
135
+ });
136
+ continue;
137
+ }
138
+ const cur = data.turns[data.turns.length - 1];
139
+ if (!cur) continue;
140
+ if (j.type === 'turn-metrics') {
141
+ // 一轮收尾:durationMs 是从本轮开始到此刻的真实耗时(比相邻时间戳推算准)
142
+ cur.dur = j.durationMs || 0;
143
+ cur.finished = true;
144
+ } else if (j.type === 'function_call') {
145
+ cur.tools++;
146
+ const nm = typeof j.name === 'string' && j.name ? j.name : (typeof j.toolName === 'string' && j.toolName ? j.toolName : '?');
147
+ cur.toolNames[nm] = (cur.toolNames[nm] || 0) + 1;
148
+ } else if (j.type !== 'message' && j.type !== 'function_call_result') {
149
+ continue;
150
+ }
151
+ const c = buddyCall(j);
152
+ if (c.mid && cur.mids.includes(c.mid)) {
153
+ // 同一次 LLM 调用的多条记录(正文 + 工具调用)已计过用量
154
+ } else if (c.usage) {
155
+ cur.mids.push(c.mid);
156
+ cur.calls++;
157
+ cur.tin += c.usage.tin; cur.tout += c.usage.tout; cur.tcache += c.usage.tcache;
158
+ }
159
+ if (c.model && !cur.models.includes(c.model)) cur.models.push(c.model);
160
+ if (j.status === 'error' || j.status === 'failed') cur.err = true;
161
+ if (j.timestamp) cur.lastTs = Math.max(cur.lastTs, j.timestamp);
162
+ }
163
+ files.set(key, { agent, kind: 'buddyjsonl', m, s, off: newOff, data });
164
+ markKind('buddyjsonl');
165
+ // 从最后一个已发轮起重发(进行中的轮会被原地更新)
166
+ emitBuddyTurns(agent, fp, slug, data, Math.max(0, data.emitted - 1));
167
+ data.emitted = data.turns.length;
168
+ } catch {}
169
+ }
170
+ }
171
+ }
172
+
173
+ // 详情:重读 jsonl 按 src.turn 过滤。开轮判定与用量去重都走扫描侧同一套函数
174
+ // (buddyUserText / buddyCall,同一条 messageId 只算一次),两边口径对齐。
175
+ export function buddyEntryContent(src, full) {
176
+ const fstate = files.get(src.file);
177
+ const version = fstate ? fstate.m + ':' + (fstate.off || 0) : null;
178
+ const lines = fs.readFileSync(src.file, 'utf8').split('\n');
179
+ let turnIdx = -1, prevTs = 0;
180
+ const out = { user: '', assistant: '', tools: [], calls: [], v: version };
181
+ const texts = [];
182
+ const byMid = new Set(); // 同一次 LLM 调用的多条记录只算一次用量
183
+ const byCallId = new Map(); // function_call_result 按 callId 精确回填(并行调用不错位)
184
+ // 正文和用量常常**不在同一条记录上**:纯工具调用那几次的用量挂在 function_call 那条,
185
+ // 正文挂在 message/assistant 那条,两边靠 providerData.messageId 关联。
186
+ // 实测 20 份会话:313 个带正文的 mid,只有 102 个的正文与用量同条。
187
+ // 所以整篇读完再按 mid 回填,别在读到正文的那一刻就往上挂(那时还不知道它属于哪次调用)。
188
+ const callByMid = new Map(); // mid -> 这一行明细
189
+ const textByMid = new Map(); // mid -> 这次调用自己产出的正文
190
+ const toolsByMid = new Map(); // mid -> 这次调用发起了哪些工具(同一次响应并行调几个就有几条记录)
191
+ const seenUser = new Set(); // 同 id 的用户消息是源自己重写的第二条,扫描侧不开轮,这里也不能数
192
+ for (const line of lines) {
193
+ if (!line.trim()) continue;
194
+ let j; try { j = JSON.parse(line); } catch { continue; }
195
+ if (j.type === 'message' && j.role === 'user') {
196
+ const t0 = buddyUserText(j.content);
197
+ if (!t0) continue; // 与 scanBuddy 同口径跳过注入/命令消息
198
+ if (j.id) {
199
+ if (seenUser.has(j.id)) continue;
200
+ seenUser.add(j.id);
201
+ }
202
+ turnIdx++;
203
+ if (turnIdx > src.turn) break;
204
+ if (turnIdx === src.turn) { out.user = t0; prevTs = j.timestamp || 0; }
205
+ continue;
206
+ }
207
+ if (turnIdx !== src.turn) continue;
208
+ const ts = j.timestamp || 0;
209
+ if (j.type === 'message' && j.role === 'assistant') {
210
+ // 与 scanBuddy 同一口径:只在「带用量的记录」上按 messageId 去重计一次
211
+ const c = buddyCall(j);
212
+ if (c.usage && !(c.mid && byMid.has(c.mid))) {
213
+ if (c.mid) byMid.add(c.mid);
214
+ const call = { model: c.model, tin: c.usage.tin, tout: c.usage.tout, tcache: c.usage.tcache, dur: ts && prevTs ? Math.max(0, ts - prevTs) : 0 };
215
+ out.calls.push(call);
216
+ if (c.mid) callByMid.set(c.mid, call);
217
+ }
218
+ for (const b of (Array.isArray(j.content) ? j.content : [])) {
219
+ if (!b || b.type !== 'output_text' || !b.text) continue;
220
+ const seg = String(b.text);
221
+ texts.push(seg);
222
+ // 正文记在当前记录自己的 mid 上(与上面记调用用的是同一个 messageId)
223
+ if (c.mid) textByMid.set(c.mid, textByMid.has(c.mid) ? textByMid.get(c.mid) + '\n' + seg : seg);
224
+ }
225
+ } else if (j.type === 'function_call') {
226
+ const c = buddyCall(j);
227
+ if (c.usage && !(c.mid && byMid.has(c.mid))) {
228
+ if (c.mid) byMid.add(c.mid);
229
+ const call = { model: c.model, tin: c.usage.tin, tout: c.usage.tout, tcache: c.usage.tcache, dur: ts && prevTs ? Math.max(0, ts - prevTs) : 0 };
230
+ out.calls.push(call);
231
+ if (c.mid) callByMid.set(c.mid, call);
232
+ }
233
+ const fi = {};
234
+ const tool = {
235
+ name: j.name || '?', tid: j.callId || null, ts,
236
+ input: trunc(j.arguments ?? '', full, fi), inputTrunc: !!fi.t, // 过 toText(trunc 内),别在这里 String() 掉对象
237
+ output: '', error: null,
238
+ };
239
+ out.tools.push(tool);
240
+ if (tool.tid) byCallId.set(tool.tid, tool);
241
+ if (c.mid) {
242
+ const arr = toolsByMid.get(c.mid);
243
+ if (arr) arr.push(tool.name); else toolsByMid.set(c.mid, [tool.name]);
244
+ }
245
+ } else if (j.type === 'function_call_result') {
246
+ // callId 精确配对;无 id 时退回第一个空 output(旧格式)
247
+ const t = (j.callId && byCallId.get(j.callId)) || out.tools.find(x => !x.output);
248
+ if (t) {
249
+ const o = j.output;
250
+ const fo = {};
251
+ t.output = trunc(o, full, fo); // 同 claude:内容块/对象的拆解统一交给 toText()
252
+ t.outputTrunc = !!fo.t;
253
+ if (j.status && j.status !== 'completed') t.error = j.status;
254
+ if (ts && t.ts) t.dur = Math.max(0, ts - t.ts); // 工具自身耗时:调用 → 结果
255
+ }
256
+ } else {
257
+ continue;
258
+ }
259
+ if (ts) prevTs = ts;
260
+ }
261
+ for (const [mid, call] of callByMid) {
262
+ const seg = textByMid.get(mid);
263
+ if (seg) {
264
+ const ft = {};
265
+ call.text = trunc(seg, full, ft); call.textTrunc = !!ft.t;
266
+ }
267
+ const tl = toolsByMid.get(mid);
268
+ if (tl && tl.length) call.tools = tl;
269
+ }
270
+ out.assistant = texts.join('\n');
271
+ return out;
272
+ }