@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
@@ -0,0 +1,406 @@
1
+ // ---------------- doubao 解析器(豆包 Work 智能体:trajectory.jsonl) ----------------
2
+ // 落盘:<workspace>/.sessions/<会话 id>/agents/<agent id>/system/trajectory.jsonl
3
+ // OpenAI 风格逐行 jsonl,一行 = 一条消息:
4
+ // {"role":"user","content":"…"}
5
+ // {"role":"assistant","content":"…","tool_calls":[{"id":"…","type":"function","function":{"name":"…","arguments":{…}}}]}
6
+ // {"role":"tool","content":"…","tool_call_id":"…"}
7
+ // user 消息开新轮,其后的 assistant / tool 都归这一轮,下一条 user 收轮。
8
+ //
9
+ // 源头**没有**这些字段(实测本机两份 trajectory.jsonl):
10
+ // · 时间戳 —— 每轮的真实时间在同目录 assignment.md 的「## [ISO时间] 需求」记录里(与 trajectory
11
+ // 同一事件流追加,按规范化文本匹配对齐;匹配不到 / 没有该文件 → time=0,页面显示「—」);
12
+ // · 模型 / token / usage —— 转录不落盘:逐次 token 恒 0;模型名从 IndexedDB 补
13
+ // (chat 库的 model_item_key 数字 key + launcher 库 model_list 目录的 key→UTF-16LE 名字)。
14
+ // 工具调用是真实可数的:assistant 行的 tool_calls[] 计数,tool 行按 tool_call_id 回填出入参。
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+ import { files, entries, addEntry, markKind, listDirCached, isDir, isFile, toText, trunc, readCompleteLines } from './shared.mjs';
18
+
19
+ // ---------------- 全局 IndexedDB context 提取(context_window_usage / model_item_key / 模型名目录) ----------------
20
+ // doubao 客户端的 chat IndexedDB 存了每轮会话的上下文占用(system_prompt + messages + skills + tools 的 token 数),
21
+ // 还有 model_item_key(数字 key,如 "9")和 reasoning_effort(数字 key,如 "5")。
22
+ // 但 IndexedDB 里嵌在 protobuf 里,JSON key 部分被 tag bytes 破坏,只能用正则 + 数值推断来提。
23
+ // 模型名的对照表在**另一个库**——launcher 的 model_list 目录(key → UTF-16LE name,如 9→自动、4→豆包 2.1 Turbo)。
24
+ // 这些数据是全局的(整个客户端共享 chat / launcher 两个库),缓存键含两个目录,任一目录变化即刷新。
25
+
26
+ import { homedir } from 'node:os';
27
+
28
+ // 硬编码路径:DoubaoWork 在 Windows 上的默认位置
29
+ function doubaoIdbDir() {
30
+ const p = path.join(homedir(), 'AppData', 'Local', 'DoubaoWork', 'User Data', 'Default', 'IndexedDB', 'chrome_doubaowork-chat_0.indexeddb.leveldb');
31
+ return fs.existsSync(p) ? p : null;
32
+ }
33
+
34
+ function doubaoLauncherDir() {
35
+ const p = path.join(homedir(), 'AppData', 'Local', 'DoubaoWork', 'User Data', 'Default', 'IndexedDB', 'chrome_doubaowork-launcher_0.indexeddb.leveldb');
36
+ return fs.existsSync(p) ? p : null;
37
+ }
38
+
39
+ // 按 mtime 缓存
40
+ let _contextCache = { dir: null, mtime: 0, result: null };
41
+
42
+ function latestMtime(dir) {
43
+ if (!dir) return 0;
44
+ let latest = 0;
45
+ for (const f of fs.readdirSync(dir)) {
46
+ if (!f.endsWith('.ldb') && !f.endsWith('.log')) continue;
47
+ try { latest = Math.max(latest, fs.statSync(path.join(dir, f)).mtimeMs); } catch {}
48
+ }
49
+ return latest;
50
+ }
51
+
52
+ // launcher 库的 model_list 目录:`model_item_key"\x01<数字>` 附近跟着 `name` + 0x63 tag + 长度 + UTF-16LE 名字
53
+ // (9→自动、4→豆包 2.1 Turbo、5→豆包 2.1 Pro)。菜单目录(modeSelectConfig)里同名字段是混合编码坏样本,
54
+ // 用「每个码点都得是常用汉字或可打印 ASCII」筛掉;effort 档位名(低/中/高,2 字节)由 len>=4 排除。
55
+ function extractDoubaoModelNames() {
56
+ const dir = doubaoLauncherDir();
57
+ const map = new Map();
58
+ if (!dir) return map;
59
+ for (const fname of fs.readdirSync(dir)) {
60
+ if (!fname.endsWith('.ldb') && !fname.endsWith('.log')) continue;
61
+ let buf; try { buf = fs.readFileSync(path.join(dir, fname)); } catch { continue; }
62
+ const s = buf.toString('latin1'); // 1 字符 = 1 字节,charCodeAt 即原始字节值
63
+ let i = 0;
64
+ while ((i = s.indexOf('model_item_key', i)) !== -1) {
65
+ i += 14;
66
+ const kM = /^"\x01(.)$/.exec(s.slice(i, i + 3));
67
+ if (!kM || !/\d/.test(kM[1]) || map.has(kM[1])) continue;
68
+ const key = kM[1];
69
+ const win = s.slice(i, i + 120);
70
+ let j = 0;
71
+ while ((j = win.indexOf('name', j)) !== -1) {
72
+ let p = j + 4;
73
+ if (win.charCodeAt(p) !== 0x63) p++; // 部分记录 name 与 tag 之间夹一个分隔字节
74
+ const len = win.charCodeAt(p) === 0x63 ? win.charCodeAt(p + 1) : 0;
75
+ if (len >= 4 && len <= 48 && len % 2 === 0 && p + 2 + len <= win.length) {
76
+ let name = '', good = true;
77
+ for (let k = 0; k < len; k += 2) {
78
+ const cp = win.charCodeAt(p + 2 + k) | (win.charCodeAt(p + 3 + k) << 8);
79
+ if (!((cp >= 0x4e00 && cp <= 0x9fff) || (cp >= 0x20 && cp <= 0x7e))) { good = false; break; }
80
+ name += String.fromCharCode(cp);
81
+ }
82
+ if (good && name.trim()) { map.set(key, name.trim()); break; }
83
+ }
84
+ j += 4;
85
+ }
86
+ }
87
+ }
88
+ return map;
89
+ }
90
+
91
+ function extractDoubaoContext() {
92
+ const dir = doubaoIdbDir();
93
+ const ldir = doubaoLauncherDir();
94
+ if (!dir) return null;
95
+ // 用两个库(chat + launcher)LDB/LOG 的最新 mtime 做 dirty 标记
96
+ const latest = Math.max(latestMtime(dir), latestMtime(ldir));
97
+ const ck = dir + '|' + (ldir || '');
98
+ if (_contextCache.dir === ck && _contextCache.mtime === latest) return _contextCache.result;
99
+
100
+ const t0 = Date.now();
101
+ const out = { ctxUsed: 0, ctx: 0, modelKey: '', modelName: '', reasoningEffort: '' };
102
+ const windows = new Map();
103
+ const modelKeys = new Set();
104
+ const efforts = new Set();
105
+
106
+ for (const fname of fs.readdirSync(dir)) {
107
+ if (Date.now() - t0 > 3000) break; // 总超时保护:不要因为 IndexedDB 扫描拖慢服务启动
108
+ if (!fname.endsWith('.ldb') && !fname.endsWith('.log')) continue;
109
+ let buf; try { buf = fs.readFileSync(path.join(dir, fname)); } catch { continue; }
110
+ const text = buf.toString('utf8');
111
+
112
+ // 正则必须提到循环外。写在 while 条件里是**死循环**:正则字面量每次求值都是新对象,
113
+ // lastIndex 恒为 0,于是只要文本里命中过一次,exec 每次都返回同一个匹配、永远不返回 null。
114
+ // 解析器/页面都靠外层线程,这里转死 = 服务起不来,且是在 import 阶段(见文件末尾 note 的说明)。
115
+ let m;
116
+ const reModelKey = /"model_item_key"\s*:\s*"([^"]+)"/g;
117
+ const reEffort = /"reasoning_effort"\s*:\s*"([^"]+)"/g;
118
+ while ((m = reModelKey.exec(text)) !== null) modelKeys.add(m[1]);
119
+ while ((m = reEffort.exec(text)) !== null) efforts.add(m[1]);
120
+
121
+ let idx = 0, safeCount = 0;
122
+ while ((idx = text.indexOf('"system_prompt"', idx)) !== -1) {
123
+ safeCount++;
124
+ if (safeCount > 5000) break; // 异常保护
125
+ const braceStart = text.lastIndexOf('{', idx);
126
+ if (braceStart === -1 || braceStart < idx - 200) { idx++; continue; }
127
+ const twsIdx = text.indexOf('256000', idx);
128
+ if (twsIdx === -1 || twsIdx > idx + 50000) { idx++; continue; }
129
+ const braceEnd = text.indexOf('}', twsIdx);
130
+ if (braceEnd === -1 || braceEnd > idx + 60000) { idx++; continue; }
131
+ const frag = text.slice(braceStart, braceEnd + 1);
132
+
133
+ if (!frag.includes('8282')) { idx = braceEnd; continue; }
134
+ if (!frag.includes('256000')) { idx = braceEnd; continue; }
135
+
136
+ const nums = [...frag.matchAll(/(\d+)/g)].map(x => parseInt(x[1]));
137
+ const tws = nums.find(n => n === 256000) || 0;
138
+ const sp = nums.find(n => n === 8282 || n === 8280 || n === 8300) || 0;
139
+ if (!tws || !sp) { idx = braceEnd; continue; }
140
+
141
+ const rest = [...new Set(nums.filter(n => n !== sp && n !== tws && n > 0))];
142
+ let messages = 0, skills = 0, tools = 0;
143
+ if (rest.length >= 3) {
144
+ skills = rest.find(n => n >= 0 && n <= 5000) ?? Math.min(...rest);
145
+ tools = rest.find(n => n >= 50000) ?? Math.max(...rest);
146
+ messages = rest.find(n => n !== skills && n !== tools && n >= 1000) ?? 0;
147
+ } else if (rest.length === 2) {
148
+ skills = Math.min(...rest); tools = Math.max(...rest);
149
+ } else if (rest.length === 1) {
150
+ tools = rest[0];
151
+ }
152
+
153
+ const sig = `${sp}|${messages}|${skills}|${tools}|${tws}`;
154
+ if (!windows.has(sig)) windows.set(sig, { system_prompt: sp, messages, skills, tools, total_window_size: tws });
155
+ idx = braceEnd;
156
+ }
157
+ }
158
+
159
+ if (modelKeys.size) {
160
+ out.modelKey = [...modelKeys][0];
161
+ out.modelName = extractDoubaoModelNames().get(out.modelKey) || '';
162
+ }
163
+ if (efforts.size) out.reasoningEffort = [...efforts][0];
164
+
165
+ // context 窗口:取平均值(所有会话共享同一个 IndexedDB,可能有多个会话各自的窗口大小)
166
+ if (windows.size) {
167
+ let sumUsed = 0, sumTws = 0;
168
+ for (const [, w] of windows) {
169
+ sumUsed += w.system_prompt + w.messages + w.skills + w.tools;
170
+ sumTws += w.total_window_size;
171
+ }
172
+ out.ctxUsed = Math.round(sumUsed / windows.size);
173
+ out.ctx = Math.round(sumTws / windows.size);
174
+ }
175
+
176
+ _contextCache = { dir, mtime: latest, result: out };
177
+ return out;
178
+ }
179
+
180
+ // 注意:这里**必须**是函数,不能是 `export const ... = ...extractDoubaoContext()...`。
181
+ // 以前它是 const + 顶层 IIFE,于是「import 这个模块」= 「同步扫一遍豆包客户端的 IndexedDB」。
182
+ // 而 server 是静态 import 全部 parser 的,所以任何参数(连 --version / --help / --stop 这些
183
+ // 明令零副作用的)都得先等这次扫描;扫描里出任何毛病(2026-09-20 那次是正则死循环,
184
+ // 见上方 reModelKey 处的说明)就是整个 CLI 零输出挂死,而不是「豆包那块不好使」。
185
+ // 谁要这条 note 谁调;每轮详情那条活的 note 在 doubaoEntryContent 里,本来也带这些字段。
186
+ // 模型显示名:launcher 库目录里有 key→名字(9→自动)就用名字;目录没命中才退回数字 key
187
+ function doubaoModelLabel(ctx) {
188
+ return ctx.modelName || (ctx.modelKey ? 'item_key=' + ctx.modelKey : '');
189
+ }
190
+
191
+ export function doubaoNoUsageNote() {
192
+ const c = extractDoubaoContext();
193
+ return 'doubao 转录(trajectory.jsonl)只有 role / content / tool_calls,源头不落盘 token 用量;' +
194
+ '模型名 = launcher IndexedDB model_list 目录按 chat 库的 model_item_key=' + (c?.modelKey || '(未提取到)') +
195
+ ' 映射(' + (c?.modelName || '目录未命中,退回数字 key') + ');' +
196
+ '故本卡逐次 token 恒为 0;' +
197
+ '上下文占用 = IndexedDB context_window_usage 的平均值(system_prompt + messages + skills + tools / total_window_size)。' +
198
+ '每轮时间取自同目录 assignment.md 的「## [时间] 需求」记录(按文本匹配对齐),匹配不到的轮为 0。';
199
+ }
200
+
201
+ // ---------------- assignment.md 时间戳源 ----------------
202
+ // 每轮用户需求以「## [ISO时间] 需求」开行、正文跟在其后,直到下一条标题。两文件同源追加,
203
+ // 轮数与顺序天然一致,但正文写法可能不同(skill 链接在 assignment 里是 skill://、在 trajectory
204
+ // 里是 <本地路径>),所以匹配用**规范化文本**(剥 markdown 链接 → 折叠空白)而不是原样比对。
205
+ const ASSIGN_TS_RE = /^##\s*\[([^\]]+)\]\s*(.*)$/; // 形如「## [ISO时间] 需求」——标题行带一个标签后缀,别只认到 ] 为止
206
+
207
+ function parseAssignment(fp) {
208
+ const out = []; // [{ts, text, used}]
209
+ let cur = null;
210
+ try {
211
+ for (const line of fs.readFileSync(fp, 'utf8').split(/\r?\n/)) {
212
+ const m = ASSIGN_TS_RE.exec(line.trim());
213
+ if (m) { cur = { ts: Date.parse(m[1]) || 0, text: '', used: false }; out.push(cur); continue; }
214
+ if (!cur) continue; // 标题之前的内容(头部元信息)不归任何轮
215
+ const t = line.trim();
216
+ if (t) cur.text += (cur.text ? '\n' : '') + t;
217
+ }
218
+ } catch { /* 读不到 = 没有时间源,全部轮 time=0 */ }
219
+ return out;
220
+ }
221
+
222
+ function normText(s) {
223
+ return String(s || '')
224
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') // markdown 链接 → 链接文本(两处写法的差异剥掉)
225
+ .replace(/\s+/g, ' ')
226
+ .trim();
227
+ }
228
+
229
+ // 给一条 user 消息找一个时间戳:先按规范化文本精确匹配(第一优先,处理链接写法差异),
230
+ // 匹配不到再按顺序取下一个未用条目兜底(两文件同源追加,顺序对齐在绝大多数情况成立)。
231
+ // 都落空 → 0(页面显示「—」,不编造时间)。
232
+ function matchAssignTs(assign, text0) {
233
+ const n = normText(text0);
234
+ for (const a of assign) {
235
+ if (a.used || !a.text) continue;
236
+ if (normText(a.text) === n) { a.used = true; return a.ts; }
237
+ }
238
+ for (const a of assign) {
239
+ if (!a.used) { a.used = true; return a.ts; }
240
+ }
241
+ return 0;
242
+ }
243
+
244
+ // ---------------- 扫描(OFF_KINDS:逐行 off 增量续读,语义与 claude 完全一致) ----------------
245
+ export function doubaoIdBase(fp) { return 'db#' + fp.replace(/[\\/:]/g, '~') + '#'; }
246
+
247
+ function emitDoubaoTurns(agent, fp, data, fromIdx) {
248
+ const idBase = doubaoIdBase(fp);
249
+ const ctx = data._context || {}; // { ctx, ctxUsed, modelKey }
250
+ for (let i = fromIdx; i < data.turns.length; i++) {
251
+ const t = data.turns[i];
252
+ const next = data.turns[i + 1];
253
+ // dur = 下一轮时间 − 本轮时间(同 claude 的 lastTs 推算;最后一轮没有下一轮 → 0)
254
+ addEntry(idBase + t.idx, {
255
+ agent, project: data.project, session: data.session,
256
+ time: t.time, dur: next ? Math.max(0, next.time - t.time) : 0, status: t.err ? 'error' : 'ok',
257
+ tin: 0, tout: 0, tcache: 0, total: 0,
258
+ ctx: ctx.ctx || 0, ctxUsed: ctx.ctxUsed || 0,
259
+ rounds: t.calls, tools: t.tools, calls: t.calls, // 一轮 = 一条 user 消息;calls = 其后的 assistant 行数
260
+ // R21:模型字段 = launcher 目录映射出的模型名(9→自动),目录没命中退回 item_key=数字
261
+ models: [doubaoModelLabel(ctx)].filter(Boolean),
262
+ preview: t.preview,
263
+ // R19:转录没有「轮结束」标志,收不了口的最后一轮由页面按最后活动时间兜底(同 claude)
264
+ finished: i < data.turns.length - 1,
265
+ aborted: false,
266
+ toolNames: t.toolNames, // R20:工具名去重计数,逐行扫到就累计
267
+ }, { file: fp, turn: t.idx, kind: 'doubao' });
268
+ }
269
+ }
270
+
271
+ // onlyFile:分片首扫用(doubao 单文件很小,暂不进 SLICE_BY_FILE,但签名保持一致)
272
+ export function scanDoubao(agent, root, onlyFile) {
273
+ // 每个 workspace 扫一次 IndexedDB(全局共享一个 chat IndexedDB,缓存自动处理重复调用)
274
+ const ctx = extractDoubaoContext() || {};
275
+
276
+ for (const sid of listDirCached(root) || []) {
277
+ const agentsDir = path.join(root, sid, 'agents');
278
+ if (!isDir(agentsDir)) continue;
279
+ for (const aid of listDirCached(agentsDir) || []) {
280
+ const sysDir = path.join(agentsDir, aid, 'system');
281
+ const fp = path.join(sysDir, 'trajectory.jsonl');
282
+ if (!isFile(fp)) continue;
283
+ if (onlyFile && fp !== onlyFile) continue;
284
+ let fst; try { fst = fs.statSync(fp); } catch { continue; }
285
+ const key = fp, m = fst.mtimeMs, s = fst.size;
286
+ const prev = files.get(key);
287
+ if (prev && prev.m === m && prev.s === s) {
288
+ // 重启后 entries 为空但 files 状态被恢复:从状态直接重建条目,不重扫文件
289
+ if (prev.data) prev.data._context = ctx; // 确保 context 最新
290
+ if (prev.data?.turns?.length && !entries.has(doubaoIdBase(fp) + (prev.data.turns.length - 1))) emitDoubaoTurns(agent, fp, prev.data, 0);
291
+ continue;
292
+ }
293
+ const off = prev?.off || 0;
294
+ const data = prev?.data || { turns: [], emitted: 0, session: sid + '/' + aid, project: '' };
295
+ data._context = ctx;
296
+ // project:doubao 没有项目概念,用会话根(.sessions)的上级 workspace 目录作为真实容器
297
+ //(同一 workspace 的所有 doubao 会话归到一个项目,避免空字符串进项目筛选下拉)
298
+ if (!data.project) data.project = (root ? path.dirname(root) : '') || '(doubao)';
299
+ if (s <= off) { files.set(key, { agent, kind: 'doubao', m, s, off, data }); markKind('doubao'); continue; }
300
+ try {
301
+ // 文件有新增内容(新轮/新消息)时重读 assignment.md:它与 trajectory 同源追加,此时多半也长了
302
+ data.assign = parseAssignment(path.join(sysDir, 'assignment.md'));
303
+ const rd = readCompleteLines(fp, off, s);
304
+ if (!rd) { files.set(key, { agent, kind: 'doubao', m, s, off, data }); markKind('doubao'); continue; } // 尚无完整行
305
+ const newOff = rd.newOff;
306
+ for (const line of rd.lines) {
307
+ if (!line.trim()) continue;
308
+ let j; try { j = JSON.parse(line); } catch { continue; }
309
+ if (j.role === 'user') {
310
+ const text0 = toText(j.content);
311
+ if (!text0) continue; // 空用户消息不开轮
312
+ data.turns.push({
313
+ idx: data.turns.length, time: matchAssignTs(data.assign, text0), lastTs: 0,
314
+ tin: 0, tout: 0, tcache: 0, tools: 0, calls: 0, ctxUsed: 0,
315
+ models: [], err: false, aborted: false,
316
+ preview: text0.replace(/\s+/g, ' ').slice(0, 300),
317
+ toolNames: {}, // R20:工具名去重计数(逐行扫到就累计)
318
+ });
319
+ } else if (j.role === 'assistant' && data.turns.length) {
320
+ const cur = data.turns[data.turns.length - 1];
321
+ cur.calls++; // 每条 assistant 行 = 一次 LLM 响应
322
+ const tcs = Array.isArray(j.tool_calls) ? j.tool_calls : [];
323
+ for (const tc of tcs) {
324
+ const nm = typeof tc?.function?.name === 'string' && tc.function.name ? tc.function.name : '?';
325
+ cur.tools++;
326
+ cur.toolNames[nm] = (cur.toolNames[nm] || 0) + 1;
327
+ }
328
+ }
329
+ // role === 'tool':详情侧按 tool_call_id 回填,扫描侧不参与轮统计
330
+ }
331
+ files.set(key, { agent, kind: 'doubao', m, s, off: newOff, data });
332
+ markKind('doubao');
333
+ // 从最后一个已发轮起重发(进行中的轮会被原地更新)
334
+ emitDoubaoTurns(agent, fp, data, Math.max(0, data.emitted - 1));
335
+ data.emitted = data.turns.length;
336
+ } catch {}
337
+ }
338
+ }
339
+ }
340
+
341
+ // ---------------- 详情:重读转录按 src.turn 过滤 ----------------
342
+ // 开轮判定与扫描侧同一套(role=user 即开轮),不会出现「扫描算了一轮、详情找不到」。
343
+ export function doubaoEntryContent(src, full) {
344
+ const fstate = files.get(src.file);
345
+ const version = fstate ? fstate.m + ':' + (fstate.off || 0) : null;
346
+ const lines = fs.readFileSync(src.file, 'utf8').split('\n');
347
+ // 从 IndexedDB 拿 model_item_key(可能被 fstate 缓存了)
348
+ const ctx = fstate?.data?._context || extractDoubaoContext() || {};
349
+ let turnIdx = -1;
350
+ const noteParts = [
351
+ 'doubao 转录(trajectory.jsonl)只有 role / content / tool_calls,源头不落盘 token 用量;',
352
+ '模型名 = launcher IndexedDB model_list 目录按 model_item_key=' + (ctx.modelKey || '?') +
353
+ ' 映射出的「' + (ctx.modelName || '(目录未命中,退回数字 key)') + '」(reasoning_effort=' + (ctx.reasoningEffort || '?') + ');',
354
+ ];
355
+ if (ctx.ctx) noteParts.push('上下文占用 ≈ ' + (ctx.ctxUsed || 0).toLocaleString() + ' / ' + ctx.ctx.toLocaleString() + ' tokens(来自 IndexedDB context_window_usage 平均值);');
356
+ noteParts.push('每轮时间取自同目录 assignment.md 的「## [时间] 需求」记录,匹配不到的轮为 0。');
357
+ const out = { user: '', assistant: '', tools: [], calls: [], v: version, callsNote: noteParts.join(' ') };
358
+ const texts = [];
359
+ for (const line of lines) {
360
+ if (!line.trim()) continue;
361
+ let j; try { j = JSON.parse(line); } catch { continue; }
362
+ if (j.role === 'user') {
363
+ const text0 = toText(j.content);
364
+ if (!text0) continue; // 与扫描侧同判据:空用户消息不开轮,否则两侧轮序号错开
365
+ turnIdx++;
366
+ if (turnIdx === src.turn) out.user = text0;
367
+ continue;
368
+ }
369
+ if (turnIdx !== src.turn) continue;
370
+ if (j.role === 'assistant') {
371
+ const text = toText(j.content);
372
+ if (text) texts.push(text);
373
+ const tcs = Array.isArray(j.tool_calls) ? j.tool_calls : [];
374
+ const names = [];
375
+ for (const tc of tcs) {
376
+ const fn = (tc && tc.function) || {};
377
+ const nm = typeof fn.name === 'string' && fn.name ? fn.name : '?';
378
+ names.push(nm);
379
+ const fi = {};
380
+ // arguments 可能是字符串(JSON 文本)或对象,统一落成可读文本
381
+ const args = typeof fn.arguments === 'string' ? fn.arguments : JSON.stringify(fn.arguments ?? '');
382
+ out.tools.push({
383
+ name: nm, tid: tc.id || null, error: null,
384
+ input: trunc(args, full, fi), inputTrunc: !!fi.t, output: '', outputTrunc: false,
385
+ });
386
+ }
387
+ // 一次 assistant 行 = 一次 LLM 调用;源里没有 token/usage/逐次模型,只有正文与工具名
388
+ // R21:模型字段 = launcher 目录映射的模型名(9→自动),没命中退回 item_key=数字
389
+ const call = { model: doubaoModelLabel(ctx), tin: 0, tout: 0, tcache: 0, dur: 0 };
390
+ if (text) { const ft = {}; call.text = trunc(text, full, ft); call.textTrunc = !!ft.t; }
391
+ if (names.length) call.tools = names;
392
+ out.calls.push(call);
393
+ } else if (j.role === 'tool') {
394
+ const t = out.tools.find(x => x.tid === j.tool_call_id);
395
+ if (t) {
396
+ const fo = {};
397
+ t.output = trunc(j.content, full, fo);
398
+ t.outputTrunc = !!fo.t;
399
+ if (j.is_error) t.error = 'error';
400
+ }
401
+ }
402
+ if (turnIdx > src.turn) break;
403
+ }
404
+ out.assistant = texts.join('\n\n---\n\n');
405
+ return out;
406
+ }