@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,528 @@
1
+ // ---------------- traedb 解析器(Trae CN / Trae Work 的 SQLCipher 会话库) ----------------
2
+ // 数据源:<产品根>/ModularData/ai-agent/database.db —— SQLCipher 4.x 加密的 SQLite 库
3
+ // (产品根 = %APPDATA%/Trae CN 或 %APPDATA%/Trae Work;用户把数据挪走时以实际路径为准)
4
+ //
5
+ // 为什么值得为它写一个解析器(而不再是「读不了,只能看 renderer.log」):
6
+ // renderer.log 只有事件元数据(谁提问、调了什么工具、结束状态),没有 token、没有逐次调用、
7
+ // 没有 AI 输出正文;而这张库里什么都有 —— 逐轮的 prompt/completion/cache 精确用量(
8
+ // server_history_info.extra_info 的 exact_* 字段)、每次工具调用的入参与返回(chat_message_task
9
+ // 的 plan_item JSON)、每轮的用户输入与最终回答、项目路径、会话标题。差异不是「多一点少一点」,
10
+ // 是「整张卡片从空的变成满的」。密钥说明见 REFERENCE.md「Trae 的 SQLCipher 库」一节。
11
+ //
12
+ // SQLCipher 解密(纯 JS,零依赖;参数取自 sqlcipher.c 与实测爆破):
13
+ // · 页面 4096 字节;page1 头 16 字节是 salt(明文,不参与加密),其余页从 0 起
14
+ // · 尾 80 字节 = IV(16) + HMAC-SHA512(64);HMAC 输入 = 密文+IV + 页号(LE u32)
15
+ // · AES 密钥 = 32 字节原始密钥(raw key 模式,x'...');HMAC 密钥 = PBKDF2(kdf, key, salt^0x3a, 2, 32)
16
+ // · WAL 的帧头是**大端**(salt 对不上的是上一代残留帧,忽略;只重放到最后一个提交帧)
17
+ // 参数组合有个小矩阵(kdf sha512/256/1 × fast_iter 2/1 × HMAC sha512/256/1 × 页号 LE/BE ×
18
+ // 页大小 4096/8192/2048/1024/16384),用 page1+page2 双页 HMAC 确认真命中的那一组 ——
19
+ // 本机实测 4.5.7 落在 (sha512, 2, sha512, LE, 4096),但别的 Trae 版本/未来版本不保证,
20
+ // 探测一次的成本是几十毫秒,好过写死一组参数后在新版本上静默读不出来。
21
+ //
22
+ // 解密纪律:**只读**(fs.readFileSync 一份副本,原件一个字节不碰);解密结果写进 os.tmpdir 的
23
+ // 临时文件(node:sqlite 只认文件路径),查询完在 finally 里删除 —— 明文不在磁盘上留驻。
24
+ //
25
+ // 切轮与字段口径(对照真实库逐一验证过):
26
+ // chat_turn 一行 = 一轮;reply_to_message_id = 用户消息、response_message_id = 助手消息
27
+ // · 用户输入 chat_message_general.content = [{type:'text', text_content}] 按 message_id 取
28
+ // · 助手正文 response 消息按 message_type 分三种落点:
29
+ // task → chat_message_task.content JSON 里最后一个 plan_item 的 tool_call_info.name='finish',
30
+ // 正文在它的 params(JSON 字符串)的 summary 字段;没有 finish(未跑完/被取消)取最后一条 thought
31
+ // chat → chat_message_chat.content JSON 的 content 字段(2025 老会话)
32
+ // general → 同上 general 格式
33
+ // · 工具 task JSON 的 plan_item(tool_call_info.name ≠ finish):入参 params、返回 result、
34
+ // 耗时取 timing.{tool_call_started_at_ms, tool_call_finished_at_ms}(老库没有 timing,dur=0)
35
+ // · token server_history_info 里 session_id == 该轮 reply_to_message_id 的行:
36
+ // source='llm_default' 的行 = 一次 LLM 调用(extra_info.exact_* 是精确用量),
37
+ // 其余 source(工具名/user_input/…)是历史条目不是调用。轮级 = 逐次求和:
38
+ // tin = Σ(prompt − cache_read)(cache_read 单列进 tcache,与 claude 同口径)、
39
+ // tout = Σ output、ctxUsed = 最后一次调用的 prompt(含 cache,与 claude/kimi 同口径)
40
+ // —— 2025 老会话没有这些行(源头就没记),token 恒 0,详情页用 callsNote 说明
41
+ // · 项目 chat_session.project_id → project.absolute_path;查不到时回退 turn.context 里的
42
+ // references/workspace_folders
43
+ // · 会话名 fts_session_title(增量同步的全文索引表,标题跟着 Trae UI 走);没有就退回 chat_session.session_title
44
+ //
45
+ // 增量:SQLite 没有字节偏移语义,且 WAL 模式下新数据先落在 database.db-wal(库文件 mtime 不动),
46
+ // 所以签名 = db 与 wal 的 {mtime,size} 四元组,变了才整份重解密 —— 与 zcode / gemini 同一路数。
47
+ // 库体量比 zcode 大(本机 12MB / 3000 页),但纯 JS 解密的成本在几十毫秒量级,3s 一轮的节奏下
48
+ // 只有 Trae 正在写库的那几秒会付这份成本。traedb 因此**不进 OFF_KINDS**(内存重建,无需 PARSER_REV)。
49
+ //
50
+ // 与 renderer.log(tracecode/tracework)的关系:两者覆盖同一批会话(新版 Trae 两个都写),
51
+ // 同时上线就是每轮两张重复卡片 —— 二选一由主文件的数据源切换逻辑保证(db 能用就用 db,
52
+ // 读不出来才回退日志,并清掉另一边的条目)。本文件只负责「db 这条路」。
53
+ import fs from 'node:fs';
54
+ import os from 'node:os';
55
+ import path from 'node:path';
56
+ import crypto from 'node:crypto';
57
+ import { files, entries, srcs, removedIds, sorted, addEntry, toolNameCounts, modelName, trunc, sqliteMod } from './shared.mjs';
58
+
59
+ // 每个 agent 最近一次 traedb 扫描的失败原因(诊断页「已接入 · 0 条」或回退 renderer.log 时展示)
60
+ export const traeDbErr = new Map();
61
+
62
+ function traeDbJson(raw) { try { return JSON.parse(raw); } catch { return null; } }
63
+
64
+ // ---------------- SQLCipher 解密 ----------------
65
+ const PAGE_SIZES = [4096, 8192, 2048, 1024, 16384]; // 4096 是 SQLCipher 默认,探测顺序按命中概率
66
+ const KEY_RE = /^[0-9a-f]{64}$/i; // raw key 模式:32 字节 = 64 位十六进制
67
+
68
+ function u32le(n) { const b = Buffer.alloc(4); b.writeUInt32LE(n >>> 0); return b; }
69
+ function u32be(n) { const b = Buffer.alloc(4); b.writeUInt32BE(n >>> 0); return b; }
70
+
71
+ // 密码参数矩阵:与 rawdec.cjs(对着真实库验证过的那份)保持同一套组合
72
+ const KDFS = ['sha512', 'sha256', 'sha1'];
73
+ const FAST_ITERS = [2, 1];
74
+ const HMAC_ALGOS = [
75
+ { algo: 'sha512', hs: 64, reserve: 80 },
76
+ { algo: 'sha256', hs: 32, reserve: 48 },
77
+ { algo: 'sha1', hs: 20, reserve: 48 },
78
+ ];
79
+ const PGNOS = [{ f: u32le }, { f: u32be }];
80
+
81
+ // HMAC 覆盖范围:密文 + IV(IV 就是尾段 reserve 的头 16 字节)。page1 的前 16 字节是 salt,不属于密文。
82
+ // 页号编码跟着探测结果走(LE 是 4.5.x 默认;PGNOS 矩阵里两种都试)。
83
+ function hmacInput(page, pgno, ps, reserve) {
84
+ const base = pgno === 1 ? 16 : 0;
85
+ return page.subarray(base, ps - reserve + 16);
86
+ }
87
+ function hmacCheck(page, pgno, ps, reserve, hs, algo, hk, pgnoF) {
88
+ const got = crypto.createHmac(algo, hk).update(Buffer.concat([hmacInput(page, pgno, ps, reserve), pgnoF(pgno)])).digest();
89
+ const stored = page.subarray(ps - reserve + 16, ps - reserve + 16 + hs);
90
+ return got.length === stored.length && got.equals(stored);
91
+ }
92
+
93
+ // 用 page1+page2 的 HMAC 从矩阵里确诊实际参数(两页都验上才算赢家;单页库只验 page1)
94
+ function detectParams(raw, key) {
95
+ const salt = raw.subarray(0, 16);
96
+ const saltXor = Buffer.from(salt.map(b => b ^ 0x3a));
97
+ for (const ps of PAGE_SIZES) {
98
+ if (raw.length < ps) continue;
99
+ const p1 = raw.subarray(0, ps);
100
+ const hasP2 = raw.length >= ps * 2;
101
+ for (const kdf of KDFS) for (const fit of FAST_ITERS) {
102
+ let hk;
103
+ try { hk = crypto.pbkdf2Sync(key, saltXor, fit, 32, kdf); } catch { continue; }
104
+ for (const hc of HMAC_ALGOS) for (const pg of PGNOS) {
105
+ if (!hmacCheck(p1, 1, ps, hc.reserve, hc.hs, hc.algo, hk, pg.f)) continue;
106
+ if (hasP2 && !hmacCheck(raw.subarray(ps, ps * 2), 2, ps, hc.reserve, hc.hs, hc.algo, hk, pg.f)) continue;
107
+ return { ps, kdf, fit, hc, pgnoF: pg.f };
108
+ }
109
+ }
110
+ }
111
+ return null;
112
+ }
113
+
114
+ function decryptPage(page, pgno, ps, reserve, key) {
115
+ const base = pgno === 1 ? 16 : 0;
116
+ const iv = page.subarray(ps - reserve, ps - reserve + 16);
117
+ const ct = page.subarray(base, ps - reserve);
118
+ const d = crypto.createDecipheriv('aes-256-cbc', key, iv);
119
+ d.setAutoPadding(false);
120
+ const pt = Buffer.concat([d.update(ct), d.final()]);
121
+ const out = Buffer.alloc(ps);
122
+ if (pgno === 1) Buffer.from('SQLite format 3\0', 'latin1').copy(out, 0);
123
+ pt.copy(out, base);
124
+ return out;
125
+ }
126
+
127
+ // 解整库(含 WAL 重放)。返回 { ok:true, buf } 或 { ok:false, why }。
128
+ // why 的措辞会直接进诊断页,必须能区分「密钥不对」与「库在写/结构异常」——两种情况的下一步完全不同。
129
+ function decryptTraeDb(raw, keyHex, walPath) {
130
+ let key; try { key = Buffer.from(keyHex, 'hex'); } catch { key = null; }
131
+ if (!key || key.length !== 32) return { ok: false, why: '密钥格式不对(需要 64 位十六进制字符)' };
132
+ if (raw.length < 1024) return { ok: false, why: '库文件太小,不像 SQLCipher 库' };
133
+ const params = detectParams(raw, key);
134
+ if (!params) return { ok: false, why: '密钥不对(也可能是新版换了加密参数):页面 HMAC 全部未命中' };
135
+ const { ps, kdf, fit, hc, pgnoF } = params;
136
+ const hk = crypto.pbkdf2Sync(key, Buffer.from(raw.subarray(0, 16).map(b => b ^ 0x3a)), fit, 32, kdf);
137
+
138
+ const totalPages = Math.floor(raw.length / ps);
139
+ const pages = new Array(totalPages + 1); // 1-based
140
+ for (let i = 1; i <= totalPages; i++) {
141
+ const page = raw.subarray((i - 1) * ps, i * ps);
142
+ if (!hmacCheck(page, i, ps, hc.reserve, hc.hs, hc.algo, hk, pgnoF)) continue;
143
+ pages[i] = decryptPage(page, i, ps, hc.reserve, key);
144
+ }
145
+ if (!pages[1]) return { ok: false, why: '第 1 页校验失败:库可能正在被 Trae 重写(下一轮扫描会自动重试)' };
146
+ // 逻辑页数在解出来的 page1 头(offset 28, BE)。物理可能比它大(末尾残留空间),
147
+ // 校验只要求 1..logical 全部完好;logical 之后的页不必读。
148
+ const logical = pages[1].readUInt32BE(28) || totalPages;
149
+ // usable 范围内的坏页 = 读到了写一半的库(文件正在被替换/truncate)。宁可这一轮不更新,
150
+ // 也不能拿一份缺页的库去查表 —— 缺的可能是索引页,查询会给出错误结果。
151
+ let usableBad = 0;
152
+ for (let i = 1; i <= Math.min(totalPages, logical); i++) if (!pages[i]) usableBad++;
153
+ if (usableBad > 0) return { ok: false, why: '库中有 ' + usableBad + ' 页校验失败(Trae 正在写入),下一轮会自动重试' };
154
+
155
+ let finalPages = logical;
156
+ // ---- WAL 重放(帧头大端;与主库同一套页加密,pgno=1 同样跳过 16 字节 salt) ----
157
+ if (walPath && fs.existsSync(walPath)) {
158
+ try {
159
+ const wal = fs.readFileSync(walPath);
160
+ if (wal.length > 32) {
161
+ const frameSz = 24 + ps;
162
+ const nFrames = Math.floor((wal.length - 32) / frameSz);
163
+ const s1 = wal.readUInt32BE(16), s2 = wal.readUInt32BE(20);
164
+ const frames = [];
165
+ for (let i = 0; i < nFrames; i++) {
166
+ const off = 32 + i * frameSz;
167
+ const pgno = wal.readUInt32BE(off);
168
+ const dbsize = wal.readUInt32BE(off + 4);
169
+ if (wal.readUInt32BE(off + 8) !== s1 || wal.readUInt32BE(off + 12) !== s2) continue; // 上一代残留帧
170
+ const data = wal.subarray(off + 24, off + 24 + ps);
171
+ if (pgno < 1 || pgno > 1e7) continue;
172
+ if (!hmacCheck(data, pgno, ps, hc.reserve, hc.hs, hc.algo, hk, pgnoF)) continue;
173
+ frames.push({ pgno, dbsize, data });
174
+ }
175
+ let lastCommit = -1;
176
+ for (let k = 0; k < frames.length; k++) if (frames[k].dbsize > 0) lastCommit = k;
177
+ if (lastCommit >= 0) {
178
+ for (let k = 0; k <= lastCommit; k++) {
179
+ const f = frames[k];
180
+ pages[f.pgno] = decryptPage(f.data, f.pgno, ps, hc.reserve, key);
181
+ }
182
+ finalPages = frames[lastCommit].dbsize || finalPages;
183
+ }
184
+ }
185
+ } catch { /* WAL 读不了就当没有:主库本身仍是自洽的一份 */ }
186
+ }
187
+
188
+ const out = Buffer.alloc(finalPages * ps);
189
+ for (let i = 1; i <= finalPages; i++) if (pages[i]) pages[i].copy(out, (i - 1) * ps);
190
+ out.writeUInt32BE(finalPages, 28);
191
+ return { ok: true, buf: out, note: 'ps=' + ps + ' kdf=' + kdf + '/' + fit + ' hmac=' + hc.algo + ' pages=' + finalPages };
192
+ }
193
+
194
+ // ---------------- 查询与装配 ----------------
195
+ // 内容表 → message_id 映射(三张表按 message_type 拆分,键不重叠)
196
+ function contentMap(qAll, table) {
197
+ const m = new Map();
198
+ for (const r of qAll('SELECT message_id, content FROM ' + table) || []) {
199
+ if (r && typeof r.message_id === 'string') m.set(r.message_id, r.content);
200
+ }
201
+ return m;
202
+ }
203
+
204
+ // general 格式:[{type:'text', text_content}] —— 用户输入与(老会话的)助手正文共用
205
+ function generalText(content) {
206
+ const j = traeDbJson(content);
207
+ if (!Array.isArray(j)) return '';
208
+ const out = [];
209
+ for (const b of j) {
210
+ if (b && typeof b.text_content === 'string' && b.text_content.trim()) out.push(b.text_content);
211
+ else if (b && b.type === 'image') out.push('[图片]');
212
+ }
213
+ return out.join('\n');
214
+ }
215
+
216
+ const TRAE_TOOL_SKIP = new Set(['finish']); // finish 不是真工具,它的正文是助手回答
217
+ const TRAE_OTHER_SOURCES = new Set(['user_input', 'llm_default', 'new_context', 'task_notification', 'summary', 'system']);
218
+
219
+ // task JSON(chat_message_task.content)→ 助手正文 + 工具列表 + others 锚点
220
+ function parseTaskContent(content, out) {
221
+ const j = traeDbJson(content);
222
+ if (!j || !Array.isArray(j.messages)) return false;
223
+ let finishText = '', lastThought = '';
224
+ for (const m of j.messages) {
225
+ if (!m) continue;
226
+ if (m.type === 'finish' && typeof m.content === 'string') { finishText = finishText || m.content; continue; } // 老格式
227
+ if (m.type === 'proposal' && m.proposal) {
228
+ const p = traeDbJson(m.proposal);
229
+ out.others.push({ type: 'proposal', t: out.lastTs, json: JSON.stringify(p ? (p.content?.thought || p.content || p) : m.proposal).slice(0, 2000) });
230
+ continue;
231
+ }
232
+ const pi = m.plan_item || m; // 新版是 {type:'plan_item', plan_item:{...}},patience 容错:直接就是 plan_item 形状时也认
233
+ if (!pi || typeof pi !== 'object') continue;
234
+ const tci = pi.tool_call_info;
235
+ const timing = (pi.timing && typeof pi.timing === 'object') ? pi.timing : null;
236
+ if (tci && typeof tci.name === 'string') {
237
+ if (TRAE_TOOL_SKIP.has(tci.name)) {
238
+ // finish:正文优先取 params.summary(上游就是这么存的),退回 result.data.summary、再退 thought
239
+ let summary = '';
240
+ const pj = typeof tci.params === 'string' ? traeDbJson(tci.params) : tci.params;
241
+ if (pj && typeof pj.summary === 'string') summary = pj.summary;
242
+ if (!summary && tci.result && typeof tci.result === 'object' && tci.result.data && typeof tci.result.data.summary === 'string') summary = tci.result.data.summary;
243
+ if (!summary) summary = typeof pi.thought === 'string' ? pi.thought : '';
244
+ if (summary && !finishText) finishText = summary;
245
+ continue;
246
+ }
247
+ const st = tci.result && typeof tci.result === 'object' ? tci.result.status : null;
248
+ const started = timing && timing.tool_call_started_at_ms, finished = timing && timing.tool_call_finished_at_ms;
249
+ const tool = {
250
+ name: tci.name,
251
+ tid: (tci.meta && tci.meta.llm_toolcall_id) || tci.id || null,
252
+ ct: started || (timing && timing.generated_at_ms) || 0,
253
+ input: typeof tci.params === 'string' ? tci.params : JSON.stringify(tci.params ?? ''),
254
+ output: tci.result != null ? (typeof tci.result === 'string' ? tci.result : JSON.stringify(tci.result)) : '',
255
+ error: (st && st !== 'success') ? String((tci.result && tci.result.error_message) || st) : null,
256
+ dur: (started && finished && finished > started) ? finished - started : 0,
257
+ };
258
+ out.tools.push(tool);
259
+ }
260
+ if (typeof pi.thought === 'string' && pi.thought.trim()) lastThought = pi.thought;
261
+ if (typeof pi.reasoning_content === 'string' && pi.reasoning_content) {
262
+ out.others.push({ type: 'reasoning', t: (timing && timing.generated_at_ms) || out.lastTs, json: JSON.stringify({ len: pi.reasoning_content.length }) });
263
+ }
264
+ }
265
+ out.assistant = finishText || lastThought;
266
+ return true;
267
+ }
268
+
269
+ // models[] 去重:大小写不敏感(同一模型在 user_message_context 与 server_history_info 里可能
270
+ // 一个写配置名、一个写产品展示名),先到者胜出(用户消息的 model_info 先处理,名字更友好)
271
+ function modelOf(t, name) {
272
+ const n = modelName(name);
273
+ if (!n) return '';
274
+ const k = n.toLowerCase();
275
+ const hit = t.models.find(m => m.toLowerCase() === k);
276
+ if (hit) return hit;
277
+ t.models.push(n);
278
+ return n;
279
+ }
280
+
281
+ // 单轮装配(把两个来源 —— 任务 JSON 与用量行 —— 合进 out)
282
+ function buildTurn(row, ctx) {
283
+ const t = {
284
+ sid: row.session_id, key: row.turn_id, time: 0, lastTs: 0, endTs: 0,
285
+ user: '', assistant: '', preview: '', name: ctx.name, project: ctx.project,
286
+ tools: [], calls: [], events: [], others: [],
287
+ models: [], ctxWin: ctx.ctxWin || 0, ctxUsed: 0,
288
+ tin: 0, tout: 0, tcache: 0, err: false, errMsg: '', aborted: false, live: false, finish: false,
289
+ };
290
+ const c = traeDbJson(row.context) || {};
291
+ const startMs = c.chat_start_time || (row.created_at || 0) * 1000;
292
+ const endMs = c.chat_end_time || (row.updated_at || 0) * 1000;
293
+ t.time = startMs; t.lastTs = endMs; t.endTs = endMs;
294
+ const st = String(row.turn_status || '');
295
+ t.live = st === 'in_progress';
296
+ t.aborted = st === 'canceled';
297
+ if (row.error_message) { t.err = true; t.errMsg = String(row.error_message); }
298
+ else if (st && !['completed', 'canceled', 'in_progress'].includes(st)) { t.err = true; t.errMsg = 'turn_status=' + st; }
299
+ return t;
300
+ }
301
+
302
+ export function scanTraeDb(agent, dbPath, keyHex) {
303
+ const key = dbPath;
304
+ let dst; try { dst = fs.statSync(dbPath); } catch { traeDbErr.set(agent, '数据库不存在:' + dbPath); return 'fail'; }
305
+ // 密钥检查放在最前:用户把 traeKey 删掉/改错后必须立刻走「回退 renderer.log」这条路,
306
+ // 不能被下面的「库没变就不重解析」短路留住 —— 那会让「我已经关了它」的意图看不出来。
307
+ const k = String(keyHex || '').trim();
308
+ if (!KEY_RE.test(k)) { traeDbErr.set(agent, '未配置 traeKey(或格式不对),已回退日志解析'); return 'nokey'; }
309
+ const kh = crypto.createHash('sha256').update(k).digest('hex');
310
+ let wst = null; try { wst = fs.statSync(dbPath + '-wal'); } catch {} // WAL 可能已被 checkpoint 掉,没有是正常的
311
+ const sig = dst.mtimeMs + '|' + dst.size + '|' + (wst ? wst.mtimeMs + '|' + wst.size : '-');
312
+ const prev = files.get(key);
313
+ if (prev && prev.data.sig === sig && prev.data.keyHash === kh) {
314
+ // 库没变(密钥也没换)就不重解析 —— 但条目可能被 LRU 淘汰过(同 zcode:按最后一轮补回)
315
+ const last = prev.data.turns[prev.data.turns.length - 1];
316
+ if (last && !entries.has(traeDbIdBase(key) + last.sid.replace(/#/g, '~') + '#' + last.key)) emitTraeDbTurns(agent, key, prev.data, null);
317
+ traeDbErr.delete(agent);
318
+ return prev.data.turns.length ? 'ok' : 'empty';
319
+ }
320
+
321
+ const mod = sqliteMod();
322
+ if (!mod) { traeDbErr.set(agent, '当前 Node 没有 node:sqlite(需要 22.5+)'); return 'fail'; }
323
+
324
+ let tmp = null;
325
+ try {
326
+ const raw = fs.readFileSync(dbPath);
327
+ const dec = decryptTraeDb(raw, k, dbPath + '-wal');
328
+ if (!dec.ok) { traeDbErr.set(agent, dec.why); return 'fail'; }
329
+
330
+ tmp = path.join(os.tmpdir(), 'agent-log-trae-' + process.pid + '-' + Math.random().toString(36).slice(2, 8) + '.db');
331
+ fs.writeFileSync(tmp, dec.buf, { mode: 0o600 });
332
+ const data = { turns: [], sig, keyHash: kh, rev: (prev?.data?.rev || 0) + 1 };
333
+ const db = new mod.DatabaseSync(tmp, { readOnly: true });
334
+ try { data.turns = collectTurns(db); } finally { try { db.close(); } catch {} }
335
+ // 全空的轮不灌条目(打开了没说话的会话):user/assistant/tools 全无的轮就是噪音
336
+ data.turns = data.turns.filter(t => t.user || t.assistant || t.tools.length || t.errMsg);
337
+ files.set(key, { agent, kind: 'traedb', m: dst.mtimeMs, s: dst.size, off: 0, data });
338
+ traeDbErr.delete(agent);
339
+ emitTraeDbTurns(agent, key, data, prev?.data);
340
+ return data.turns.length ? 'ok' : 'empty';
341
+ } catch (e) {
342
+ // 解密/查询失败:保留上一轮数据,原因写进诊断页,绝不让一张库拖垮整轮扫描
343
+ console.error('[traedb]', e.message);
344
+ traeDbErr.set(agent, e.message);
345
+ return 'fail';
346
+ } finally {
347
+ // node:sqlite 打开 WAL 库时会在同目录建 -shm/-wal 副产物,只删主文件会在 temp 里越积越多
348
+ if (tmp) for (const p of [tmp, tmp + '-shm', tmp + '-wal']) { try { fs.unlinkSync(p); } catch {} }
349
+ }
350
+ }
351
+
352
+ // 一条 SQL 单独容错:库在、但某张表缺失/损坏时,其余表照常解析,不让整库变白
353
+ function queryTurns(qAll) {
354
+ // 见文件头「切轮与字段口径」。所有 deleted_at 过滤都用 COALESCE,兼容 NULL 与 0 两种历史写法。
355
+ return {
356
+ sessions: qAll("SELECT session_id, project_id, session_type, session_title, created_at FROM chat_session WHERE COALESCE(deleted_at,0)=0"),
357
+ projects: qAll('SELECT project_id, absolute_path FROM project') || [],
358
+ titles: qAll('SELECT session_id, title FROM fts_session_title') || [],
359
+ turns: qAll("SELECT session_id, turn_id, reply_to_message_id, response_message_id, turn_status, error_message, context, created_at, updated_at, agent_type, agent_name FROM chat_turn WHERE COALESCE(deleted_at,0)=0 ORDER BY created_at"),
360
+ userCtx: qAll("SELECT message_id, user_message_context FROM chat_message WHERE message_role='user' AND COALESCE(deleted_at,0)=0") || [],
361
+ history: qAll("SELECT conversation_id, session_id, source, extra_info, created_at FROM server_history_info WHERE COALESCE(is_deleted,0)=0") || [],
362
+ };
363
+ }
364
+
365
+ function collectTurns(db) {
366
+ const qAll = (sql) => { try { return db.prepare(sql).all(); } catch { return null; } };
367
+ const tb = queryTurns(qAll);
368
+ if (!tb.sessions) throw new Error('读不到 chat_session 表(schema 不兼容或库损坏)');
369
+ const general = contentMap(qAll, 'chat_message_general');
370
+ const chat = contentMap(qAll, 'chat_message_chat');
371
+ const task = contentMap(qAll, 'chat_message_task');
372
+
373
+ const projPath = new Map(tb.projects.map(p => [p.project_id, p.absolute_path]));
374
+ const titleOf = new Map(tb.titles.map(r => [r.session_id, (r.title || '').trim()]));
375
+ // 用量行按「会话|用户消息 id」归组 —— server_history_info.session_id 存的就是那一轮的用户消息 id
376
+ const histByTurn = new Map();
377
+ for (const h of tb.history) {
378
+ const k = h.conversation_id + '\u0000' + h.session_id;
379
+ if (!histByTurn.has(k)) histByTurn.set(k, []);
380
+ histByTurn.get(k).push(h);
381
+ }
382
+ const userCtxOf = new Map(tb.userCtx.map(r => [r.message_id, r.user_message_context]));
383
+
384
+ const turns = [];
385
+ for (const s of tb.sessions) {
386
+ const title = titleOf.get(s.session_id) || (s.session_title || '').trim();
387
+ // 项目:project 表优先;表里没有(老会话)就等装配时从 turn.context 里找
388
+ const ctxBase = { name: title || null, project: projPath.get(s.project_id) || '' };
389
+ for (const row of tb.turns) {
390
+ if (row.session_id !== s.session_id) continue;
391
+ const t = buildTurn(row, ctxBase);
392
+ // 用户输入
393
+ const uctx = userCtxOf.get(row.reply_to_message_id);
394
+ t.user = generalText(general.get(row.reply_to_message_id));
395
+ if (uctx) {
396
+ const mi = (traeDbJson(uctx) || {}).model_info;
397
+ if (mi) {
398
+ // 名字取 config_name(与 server_history_info 同源,跨来源去重才干净);没有才退回展示名
399
+ const cn = typeof mi.config_name === 'string' ? mi.config_name : '';
400
+ const dn = typeof mi.display_model_name === 'string' ? mi.display_model_name : '';
401
+ if (cn || dn) modelOf(t, cn || dn);
402
+ // prompt_max_tokens = 源头自己的「最大 prompt」上限,正是这张进度条的分母
403
+ if (typeof mi.prompt_max_tokens === 'number' && mi.prompt_max_tokens > 0) t.ctxWin = mi.prompt_max_tokens;
404
+ }
405
+ }
406
+ // 助手正文与工具
407
+ const am = row.response_message_id;
408
+ if (am && task.has(am)) parseTaskContent(task.get(am), t);
409
+ else if (am && chat.has(am)) {
410
+ const cj = traeDbJson(chat.get(am));
411
+ if (cj && typeof cj.content === 'string') t.assistant = cj.content;
412
+ } else if (am && general.has(am)) t.assistant = generalText(general.get(am));
413
+
414
+ // 用量与逐次调用
415
+ const rows = histByTurn.get(s.session_id + '\u0000' + row.reply_to_message_id) || [];
416
+ for (const h of rows) {
417
+ if (h.source === 'llm_default') {
418
+ const ei = traeDbJson(h.extra_info) || {};
419
+ const prompt = Number(ei.exact_prompt_tokens_v1) || 0;
420
+ const out = Number(ei.exact_output_tokens_v1) || 0;
421
+ const cache = Number(ei.exact_cache_read_input_tokens_v1) || 0;
422
+ // 源头的 exact_token_semantics_v1 自己写明 completion_includes_reasoning ——
423
+ // 所以 output 直接就是 tout,不再加 reasoning(加了就是双计)
424
+ const cfg = typeof ei.config_name === 'string' ? ei.config_name : '';
425
+ const call = { model: modelOf(t, cfg) || t.models[0] || '(未知模型)', tin: Math.max(0, prompt - cache), tout: out, tcache: cache, dur: 0, t: (h.created_at || 0) * 1000 };
426
+ t.calls.push(call);
427
+ if (prompt > 0) t.ctxUsed = prompt; // 最后一次调用胜出(与 claude/kimi 同口径)
428
+ t.events.push({ k: 'llm', t: call.t || t.time, model: call.model, tin: call.tin, tout: call.tout, tcache: cache, dur: 0, err: false, blocks: [] });
429
+ } else if (!TRAE_OTHER_SOURCES.has(String(h.source))) {
430
+ // 工具历史行:source 就是工具名。task JSON 已有工具时只做补充(补名字计数),
431
+ // 没有 task JSON 的老会话才用它兜底成一条纯指标工具。
432
+ if (!t.tools.length) {
433
+ t.tools.push({ name: String(h.source), tid: null, ct: (h.created_at || 0) * 1000, input: '', output: '', error: null, dur: 0 });
434
+ }
435
+ }
436
+ }
437
+ if (!t.calls.length) {
438
+ // 老会话没有 server_history_info:turn.context.token_usage 可能还有最后一笔(有就用,没有就是 0)
439
+ const cj = traeDbJson(row.context) || {};
440
+ const tu = cj.token_usage || {};
441
+ const prompt = Number(tu.prompt_tokens) || 0, out = Number(tu.completion_tokens) || 0, cache = Number(tu.cache_read_input_tokens) || 0;
442
+ if (prompt || out) { t.tin = Math.max(0, prompt - cache); t.tout = out; t.tcache = cache; t.ctxUsed = prompt || 0; }
443
+ } else {
444
+ for (const c of t.calls) { t.tin += c.tin; t.tout += c.tout; t.tcache += c.tcache; }
445
+ }
446
+ // 项目兜底:turn.context 的 workspace_folders / references
447
+ if (!t.project) {
448
+ const cj = traeDbJson(row.context) || {};
449
+ const wf = Array.isArray(cj.workspace_folders) ? cj.workspace_folders : [];
450
+ const refs = Array.isArray(cj.references) ? cj.references : [];
451
+ const p = (wf[0] && (wf[0].path || wf[0].uri)) || (refs[0] && refs[0].uri) || '';
452
+ t.project = p ? (path.dirname(String(p)) || String(p)) : '(Trae)';
453
+ }
454
+ // 事件流:user → 逐次 llm → 工具 call/result → end(与 dsh/zcode 同一 schema)
455
+ t.events.unshift({ k: 'user', t: t.time });
456
+ t.tools.forEach((tool, i) => {
457
+ const ct = tool.ct || t.time;
458
+ t.events.push({ k: 'call', t: ct, name: tool.name, tid: tool.tid, i });
459
+ t.events.push({ k: 'result', t: ct + (tool.dur || 0), tid: tool.tid, i, dur: tool.dur || 0, error: !!tool.error });
460
+ });
461
+ t.events.push({ k: 'end', t: t.endTs || t.lastTs, reason: t.err ? 'error' : (t.aborted ? 'canceled' : (t.live ? 'running' : 'completed')) });
462
+ t.events.sort((a, b) => (a.t - b.t) || 0);
463
+ t.finish = !!t.assistant;
464
+ t.user = t.user.slice(0, 3000);
465
+ t.assistant = t.assistant.slice(0, 20000);
466
+ t.preview = (t.user || t.assistant || t.errMsg || '').replace(/\s+/g, ' ').slice(0, 300);
467
+ turns.push(t);
468
+ }
469
+ }
470
+ return turns;
471
+ }
472
+
473
+ export function traeDbIdBase(key) { return 'td#' + key.replace(/[\\/:]/g, '~') + '#'; }
474
+
475
+ function emitTraeDbTurns(agent, key, data, prevData) {
476
+ const idBase = traeDbIdBase(key);
477
+ const ids = new Set();
478
+ data.turns.forEach((t, i) => {
479
+ const id = idBase + t.sid.replace(/#/g, '~') + '#' + t.key;
480
+ ids.add(id);
481
+ addEntry(id, {
482
+ agent, project: t.project, session: t.sid,
483
+ time: t.time, dur: Math.max(0, (t.endTs || t.lastTs) - t.time),
484
+ status: t.err ? 'error' : (t.aborted ? 'canceled' : 'ok'),
485
+ tin: t.tin, tout: t.tout, tcache: t.tcache, total: t.tin + t.tout + t.tcache,
486
+ ctx: t.ctxWin || 0, ctxUsed: t.ctxUsed || null,
487
+ rounds: t.calls.length, tools: t.tools.length, calls: t.calls.length,
488
+ models: t.models, preview: t.preview, name: t.name || null,
489
+ finished: !t.live, aborted: t.aborted,
490
+ toolNames: toolNameCounts(t.tools),
491
+ }, { file: key, turn: i, kind: 'traedb' });
492
+ });
493
+ // 会话在 Trae 里被删 / 库重写时旧条目要撤掉(同 dsh / zcode)
494
+ for (const old of prevData?.ids || []) {
495
+ if (ids.has(old)) continue;
496
+ entries.delete(old); srcs.delete(old); removedIds.push(old);
497
+ }
498
+ data.ids = [...ids];
499
+ sorted.cache = null;
500
+ }
501
+
502
+ // 详情页:扫描时已把整轮(用户输入 / 正文 / 工具 / 逐次调用 / 事件流)解析进内存,直接读
503
+ export function traeDbEntryContent(src, full) {
504
+ const st = files.get(src.file);
505
+ const t = st?.data?.turns?.[src.turn];
506
+ if (!t) return null;
507
+ const tools = t.tools.map(x => {
508
+ const fi = {}, fo = {};
509
+ return {
510
+ name: x.name, tid: x.tid, error: x.error, dur: x.dur || 0,
511
+ input: trunc(x.input, full, fi), inputTrunc: !!fi.t,
512
+ output: trunc(x.output, full, fo), outputTrunc: !!fo.t,
513
+ };
514
+ });
515
+ const out = {
516
+ user: t.user, assistant: t.assistant,
517
+ tools, calls: t.calls, v: 'td' + (st.data.rev || 0),
518
+ events: t.events || [],
519
+ others: (t.others || []).map(o => ({ type: o.type, t: o.t, json: full ? o.json : o.json.slice(0, 800) })),
520
+ };
521
+ // 「为什么这张卡片上 token 全是 0」在 2025 老会话里是常态(源头就没记),必须说出来。
522
+ // 判据是「**一个数字都没有**」而不是「没有逐次调用」:有些轮只有 turn.context.token_usage 这一笔
523
+ // 汇总账(没有逐次行),token 明明有值,再挂「不存 token 用量」就是自相矛盾;进行中的轮则是
524
+ // 还没到记账的时候,也不该按旧版会话提示。
525
+ if (!t.calls.length && !t.tin && !t.tout && !t.tcache && !t.live) out.callsNote = '这是 Trae 的旧版会话记录(2025-08 之前):那一版的库不存 token 用量与逐次调用,只留了对话正文。';
526
+ if (t.err && t.errMsg) out.callsNote = '这一轮没有成功拿到模型回复:' + t.errMsg;
527
+ return out;
528
+ }