@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,377 @@
1
+ // ---------------- dsh 型解析器(DeepSeek Harness:sessions/<slug>/<会话目录>/session*.jsonl.zstd) ----------------
2
+ // 从 agent-acta-server.mjs 拆出(纯搬家,逻辑零改动)。导出 scanDsh。
3
+ //
4
+ // 事件流是从 dsh 自己的源码里核出来的(@deepseek-ai/dsh-agent-loop 里逐条 session.append,
5
+ // 本机又拿 2 个真实会话逐行验过)。一行一个 {type, seq, time, data}:
6
+ // session 会话头(cwd / createdAt / version / agentPreset)
7
+ // turn/start 一轮开始,data.turn 从 1 起
8
+ // step/start|end 一步。**一轮可以有多步**(模型调工具 → 拿结果 → 再调模型),
9
+ // 所以「轮内调用了几次模型」和「走了几步」是两个数,别混
10
+ // user/message 正文在 data.content[].text。⚠️ **只有 source.kind === 'user' 才是人打的**:
11
+ // 同一条通道还写 agent-instructions(工作区指令)/ skill-catalog(技能目录)/
12
+ // plugin(审批策略变更通知)以及 tool(工具结果,见下)。
13
+ // 不过滤就会把一整段系统提示当成用户提问,且一轮里挤进来好几条
14
+ // assistant/message 一次**成功提交**的模型调用:正文在 data.message.content[](text / reasoning /
15
+ // tool-call 三种块),用量在 data.usage(字段口径见下)
16
+ // assistant/attempt 同一次调用**没提交出消息**时的落法(报错 / 被中断且无可提交内容),
17
+ // 只有 stream 没有 usage —— 与 assistant/message 互斥(源码里是同一个 settle)
18
+ // tool/call|result 工具调用与结果,靠 data.callId / message.content[].toolCallId 配对
19
+ // session/title 会话标题(先落 fallback 的「首条输入」,随后被标题模型的结果覆盖)
20
+ // request/context **模型 + contextWindow**。dsh 这一路不用查表就有上下文分母(见下)
21
+ //
22
+ // 用量口径(@deepseek-ai/dsh-llm 的 LlmUsage 注释写明):inputTokens 是**不含缓存命中**的那部分,
23
+ // 缓存命中/写入另算 cacheReadTokens / cacheWriteTokens —— 与 claude / kimi 的口径一致,
24
+ // 所以 tin=inputTokens、tcache=cacheRead+Write、上下文占用=三者之和(轮内最后一次胜出,非累加)。
25
+ //
26
+ // zstd 这一层是**接入 dsh 真正卡住的地方**,单独说清楚:
27
+ // 磁盘上是 .zstd,但**不是「一个 zstd 文件」**——dsh 每落一次盘就追加一帧,一个会话几十帧
28
+ // (本机实测 11~19 帧)。而 Node 的 zlib.zstdDecompressSync / createZstdDecompress
29
+ // **只解第一帧就停**:不报错、不继续、也不告诉你后面还有。偏偏第一帧就是那条会话头
30
+ // (解出来 202 字节、1 行)—— 于是「把文件解压开」得到的是 1 行、0 轮,
31
+ // 看上去跟完全没有数据一模一样。必须逐帧解(见 dshDecode)。
32
+ import fs from 'node:fs';
33
+ import path from 'node:path';
34
+ import zlib from 'node:zlib';
35
+ import { files, entries, srcs, removedIds, sorted, addEntry, toolNameCounts, modelName, toText, markKind, listDirCached, isDir } from './shared.mjs';
36
+ import { dshSessionFile, hasDshSessions } from './discovery.mjs';
37
+
38
+ const DSH_MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd]); // zstd 帧头 magic
39
+ // Node 22.15+ 才有 zstd 解压;更老的 Node 上仍然要**认得出** dsh(见 hasDshSessions),
40
+ // 但扫不出条目 —— 那种机器由诊断直说原因,不静默变成 0 条(见 diagnose)
41
+ export const DSH_ZSTD_OK = typeof zlib.zstdDecompressSync === 'function';
42
+
43
+ // 逐帧解压并拼成完整 JSONL 文本。
44
+ // · 按 magic 找每一帧的起点,逐帧解 —— zstd 帧头里**不写本帧的压缩长度**(只有解压后的长度),
45
+ // 所以没法「从上一帧末尾接着读」,只能靠下一个 magic 定位,这也正是逐帧解可行、按偏移续读不可行的原因;
46
+ // · 假命中(magic 恰好出现在压缩数据里)几乎必然解不成 JSON,用「每一行都能 JSON.parse」筛掉;
47
+ // · 半帧(正在写入的那一帧)会直接抛异常,跳过即可 —— 下一轮扫描它写完了自然会被解出来。
48
+ function dshDecode(buf) {
49
+ const parts = [];
50
+ for (let i = buf.indexOf(DSH_MAGIC); i !== -1; i = buf.indexOf(DSH_MAGIC, i + 1)) {
51
+ try {
52
+ const text = zlib.zstdDecompressSync(buf.subarray(i)).toString('utf8');
53
+ const lines = text.split('\n').filter(Boolean);
54
+ if (lines.every(l => { try { JSON.parse(l); return true; } catch { return false; } })) parts.push(text);
55
+ } catch {}
56
+ }
57
+ return parts.join('');
58
+ }
59
+
60
+ export function dshIdBase(fp) { return 'd#' + fp.replace(/[\\/:]/g, '~') + '#'; }
61
+
62
+ function dshTurn(n, ts) {
63
+ return {
64
+ n, time: ts || 0, lastTs: ts || 0, endTs: 0, steps: 0,
65
+ tin: 0, tout: 0, tcache: 0, calls: 0, ctxUsed: 0, ctx: 0,
66
+ models: [], callList: [], tools: [], byCall: new Map(),
67
+ user: '', texts: [], err: false, errMsg: '', finished: false, aborted: false,
68
+ toolNames: {},
69
+ // 轨迹(R6):本轮**按真实时间顺序**的事件流,供 /api/entry 输出、页面画时间线。
70
+ //
71
+ // 为什么能这么便宜:下面那个逐行循环本来就按顺序拿得到每个事件的 j.time,
72
+ // 以及 stream[] 的起止(见 assistant/message 分支)。以前只留了轮级的 time/lastTs,
73
+ // 逐事件时间戳、step 边界、工具耗时全被丢掉,而「工具归属哪次 LLM 调用」根本无法还原。
74
+ //
75
+ // 为什么**不用**动 PARSER_REV:dsh 不在 OFF_KINDS 里 —— 本轮的 turns 从不落盘,
76
+ // 每次启动整份重解析,所以加大 events 不会让 index/*.json 膨胀,也不存在
77
+ // 「增量偏移已推到文件尾、改了口径却永不自愈」那个非要 rev 升版才能解的问题。
78
+ events: [],
79
+ };
80
+ }
81
+
82
+ // 会话目录名与文件属主都可能为空,项目名按「会话头里的 cwd(权威)> 目录 slug」取,
83
+ // 与 kimi 的 cwdIdx > slug 同思路;cwd 是绝对路径,页面的 projNorm 会把它和别的 agent 的记录并成一项。
84
+ function emitDshTurns(agent, fp, data, prevData) {
85
+ const idBase = dshIdBase(fp);
86
+ const ids = new Set();
87
+ data.turns.forEach((t, i) => {
88
+ const id = idBase + t.n;
89
+ ids.add(id);
90
+ addEntry(id, {
91
+ agent, project: data.cwd || data.slug || '(未知项目)', session: data.sessionId || data.sessName || '',
92
+ // I16 子 agent 拓扑:dsh **在会话头里直接写死谱系**(本机 34 个会话实测:30 个 depth=0 无 parent,
93
+ // 4 个 depth=1、parentSession 精确指到父会话 id)。这是**显式字段**,
94
+ // 不是靠时间区间猜的 —— 与前置闸推翻的那条路([time, time+dur] 嵌套)毫无关系。
95
+ // · parentSession 是父会话的 `session-<uuid>` 全名,与本解析器落的 session 值同形,能直接对上;
96
+ // · delegationDepth 是深度(0=人开的,1=子 agent),页面据此决定缩进口径;
97
+ // · 缺席(老会话/别家)→ undefined → JSON 丢键,页面按「根会话」处理、不编层级。
98
+ parent: data.parentSess || undefined, depth: data.depth || undefined,
99
+ time: t.time, dur: Math.max(0, (t.endTs || t.lastTs) - t.time), status: t.err ? 'error' : 'ok',
100
+ tin: t.tin, tout: t.tout, tcache: t.tcache, total: t.tin + t.tout + t.tcache,
101
+ rounds: t.calls, tools: t.tools.length, calls: t.calls,
102
+ models: t.models, preview: t.preview,
103
+ finished: !!t.finished, aborted: !!t.aborted, toolNames: toolNameCounts(t.tools),
104
+ // ctx 是**源头给的**窗口容量(request/context.contextWindow),不是查表猜的;
105
+ // ctxUsed 是本轮最后一次调用的 prompt 全量。两者都有,页面才画得出真的进度条。
106
+ ctx: t.ctx || data.ctxWin || 0, ctxUsed: t.ctxUsed || null,
107
+ name: data.title || null,
108
+ // 子 agent 的「派活描述」。只有子会话有(descriptor 事件),根会话为 undefined。
109
+ sub: data.subLabel ? data.subLabel : undefined,
110
+ subMode: data.subMode || undefined, subProvider: data.subProvider || undefined,
111
+ }, { file: fp, turn: i, kind: 'dsh' });
112
+ });
113
+ // 会话被重写/截断(比如调用了 compact)时,旧的轮要撤掉,否则新旧并存
114
+ for (const old of prevData?.ids || []) {
115
+ if (ids.has(old)) continue;
116
+ entries.delete(old); srcs.delete(old); removedIds.push(old);
117
+ }
118
+ data.ids = [...ids];
119
+ sorted.cache = null;
120
+ }
121
+
122
+ export function scanDshFile(agent, fp, slug, sessName) {
123
+ let fst; try { fst = fs.statSync(fp); } catch { return false; }
124
+ const key = fp, m = fst.mtimeMs, s = fst.size;
125
+ const prev = files.get(key);
126
+ // 整份重解析 + {mtime,size} 签名跳过没变的文件(与 gemini / cursor 同一路数)。
127
+ // 为什么不走 off 增量:多帧 zstd 没法从任意字节偏移续读(帧长不写在帧头里,见 dshDecode);
128
+ // 而单份会话解出来也就几十~几百 KB,整份重读比维护帧边界可靠得多(同 §6 的教训)。
129
+ // 顺带这也是「进行中的轮」能自己更新的原因:文件每 flush 一次 size 就变,下一轮扫描重解析。
130
+ if (prev && prev.m === m && prev.s === s) {
131
+ // 文件没变就不重解析 —— 但**条目可能被 LRU 淘汰过**(evictIfNeeded 只删 entries,不动 files)。
132
+ // 不补这一下,被淘汰的轮会一直空着,直到这个会话再写一行才自愈。判据取最后一轮(同 kimi)。
133
+ const last = prev.data?.turns?.[prev.data.turns.length - 1];
134
+ if (last && !entries.has(dshIdBase(fp) + last.n)) emitDshTurns(agent, fp, prev.data, null);
135
+ return true;
136
+ }
137
+ if (!DSH_ZSTD_OK) return true; // 老 Node:认得出、解不开,原因由诊断说
138
+ let text;
139
+ try { text = dshDecode(fs.readFileSync(fp)); } catch (e) { console.error('[dsh] read', path.basename(fp), e.message); return true; }
140
+ if (!text) return true; // 空文件 / 半个首帧还没写完
141
+
142
+ const byNum = new Map();
143
+ const order = [];
144
+ const ensure = (n, ts) => {
145
+ let t = byNum.get(n);
146
+ if (!t) { t = dshTurn(n, ts); byNum.set(n, t); order.push(t); }
147
+ if (ts && (!t.time || ts < t.time)) t.time = ts;
148
+ return t;
149
+ };
150
+ // routeModel 是**路由上**的模型名(request/context),只用来给「没提交出消息的调用」兜底:
151
+ // 失败的那次没有 message.source.model,不留名字的话整轮 models 空着、卡片标题就没模型可显示。
152
+ let cwd = '', sessionId = '', title = '', ctxWin = 0, routeModel = '', cur = null;
153
+ // I16 子 agent 拓扑:这三样全部来自会话头 / subagent 事件,是**源头写死的谱系**(见 emitDshTurns)。
154
+ let parentSess = '', depth = 0, subLabel = '', subMode = '', subProvider = '';
155
+ for (const line of text.split('\n')) {
156
+ if (!line.trim()) continue;
157
+ let j; try { j = JSON.parse(line); } catch { continue; }
158
+ const d = j.data || {};
159
+ const ts = typeof j.time === 'number' ? j.time : 0;
160
+ // ⚠️ 会话头是**扁平**的({type,version,id,createdAt,cwd,…}),不像后面的事件那样包在 data 里——
161
+ // 照抄事件那套 `d.cwd` 会永远取到空值,项目名就悄悄退回目录 slug(`--F-centos-next-admin--`),
162
+ // 页面按 projNorm 跟别的 agent 合并项目时也就对不上了。
163
+ // I16:谱系两字段(delegationDepth / parentSession)同样在**扁平**的会话头上,别写成 d.*。
164
+ //(会话头里还有个 origin='subagent',那只是探针核对该样本时的旁证,解析器不读它 —— 谱系一律以 parentSession 为准。)
165
+ if (j.type === 'session') {
166
+ cwd = j.cwd || ''; sessionId = j.id || '';
167
+ if (j.parentSession) parentSess = String(j.parentSession);
168
+ if (typeof j.delegationDepth === 'number' && j.delegationDepth > 0) depth = j.delegationDepth;
169
+ else if (parentSess) depth = 1; // 有父没写深度:至少是 1,不编更深的
170
+ continue;
171
+ }
172
+ if (j.type === 'session/title') { if (d.title) title = String(d.title); continue; }
173
+ // 子 agent 的描述符:这是**唯一**能回答「这个子会话是干嘛的 / 怎么起来的」的地方
174
+ //(label 是派活时给的任务名,mode 是 continuable / one-shot,provider 是 spawn / fork)。
175
+ // 只取字符串、截断后随条目下发;子会话本来就有自己的 title,两者互补。
176
+ if (j.type === 'subagent/descriptor') {
177
+ if (d.label) subLabel = String(d.label).replace(/\s+/g, ' ').trim().slice(0, 80);
178
+ if (d.mode) subMode = String(d.mode);
179
+ if (d.provider) subProvider = String(d.provider);
180
+ continue;
181
+ }
182
+ if (j.type === 'subagent/catalog') continue; // 父侧的子会话清单:父子关系已由子会话头的 parentSession 给出,不重复收
183
+ if (j.type === 'request/context') {
184
+ if (d.contextWindow) { ctxWin = d.contextWindow; if (cur) cur.ctx = ctxWin; }
185
+ const rm = modelName(d.model);
186
+ if (rm) routeModel = rm;
187
+ continue;
188
+ }
189
+ if (j.type === 'turn/start') { cur = ensure(d.turn, ts); cur.ctx = ctxWin; continue; }
190
+ // 带轮号的事件一律按轮号落(不依赖事件顺序);不带轮号的(user/message 也没有)落到当前轮
191
+ const n = typeof d.turn === 'number' ? d.turn : (cur ? cur.n : 0);
192
+ if (!n) continue;
193
+ const t = ensure(n, ts);
194
+ if (ts) t.lastTs = Math.max(t.lastTs, ts);
195
+
196
+ if (j.type === 'step/start') {
197
+ t.steps++;
198
+ t.events.push({ k: 'step', t: ts || t.time, n: typeof d.step === 'number' ? d.step : t.steps, ph: 'start' });
199
+ continue;
200
+ }
201
+ // step/end 以前**没有任何分支接**(直接落空),轨迹要靠它定「这一步到哪结束」
202
+ if (j.type === 'step/end') {
203
+ t.events.push({ k: 'step', t: ts || t.time, n: typeof d.step === 'number' ? d.step : null, ph: 'end' });
204
+ continue;
205
+ }
206
+ if (j.type === 'turn/end') {
207
+ t.endTs = ts || t.lastTs;
208
+ t.finished = true;
209
+ // 状态只看**这一轮的结局**(turn/end 的 reason)。不看中途的 assistant/attempt:
210
+ // 一次失败的调用会被 dsh-llm-retry 重试,重试成功后这一轮是 completed —— 按 attempt 判错会把好轮标红。
211
+ const kind = d.reason?.kind || '';
212
+ if (kind && kind !== 'completed' && kind !== 'max-tokens') {
213
+ if (/abort|cancel|interrupt/i.test(kind)) t.aborted = true;
214
+ t.err = true;
215
+ t.errMsg = d.reason?.error?.message || d.reason?.reason?.message || d.reason?.message || '';
216
+ }
217
+ t.events.push({ k: 'end', t: ts || t.lastTs, reason: kind });
218
+ if (cur === t) cur = null;
219
+ continue;
220
+ }
221
+ if (j.type === 'system/message') {
222
+ // 轨迹里只留一个「这一步注入了多长的系统提示」的锚点,**正文不存**:
223
+ // 它是整段提示词,而 t.user / t.texts / t.tools 已经占着内存,再存一份不划算。
224
+ t.events.push({ k: 'sys', t: ts || t.time, len: toText(d.message?.content).length });
225
+ continue;
226
+ }
227
+ if (j.type === 'user/message') {
228
+ // 只认人打的(见文件头那段):agent-instructions / skill-catalog / plugin / tool 全跳过
229
+ if (d.source?.kind !== 'user') continue;
230
+ const s0 = toText(d.content).trim();
231
+ if (!s0 || t.user) continue; // 一轮只取第一条(后续同轮的多半是注入物)
232
+ t.user = s0;
233
+ t.events.push({ k: 'user', t: ts || t.time });
234
+ continue;
235
+ }
236
+ if (j.type === 'assistant/message' || j.type === 'assistant/attempt') {
237
+ // 一次**已结算**的模型调用(提交出消息的算 message,报错/中断没消息的算 attempt)。两者互斥。
238
+ // attempt 也计入:不然一轮全是失败(本机那个会话 4 轮全 405)会显示「0 次调用」,
239
+ // 看着像解析器没接上,而实际上确实调了 4 次、只是都失败了。
240
+ t.calls++;
241
+ const u = d.usage;
242
+ if (u) {
243
+ const inNow = u.inputTokens || 0;
244
+ const cache = (u.cacheReadTokens || 0) + (u.cacheWriteTokens || 0);
245
+ t.tin += inNow;
246
+ t.tout += u.outputTokens || 0;
247
+ t.tcache += cache;
248
+ // 上下文占用 = **这一次**请求的 prompt 全量(与 claude / kimi 同口径:轮内最后一次胜出,不是累加)
249
+ const ctxNow = inNow + cache;
250
+ if (ctxNow > 0) t.ctxUsed = ctxNow;
251
+ }
252
+ const mdl = modelName(d.message?.source?.model) || routeModel;
253
+ if (mdl && !t.models.includes(mdl)) t.models.push(mdl);
254
+ if (Array.isArray(d.message?.content)) {
255
+ for (const b of d.message.content) if (b && b.type === 'text' && b.text) t.texts.push(String(b.text));
256
+ }
257
+ // 每次调用单独一行明细(卡片上「N 次 LLM」点开的那个表):耗时取这次流式本身的跨度
258
+ let lo = 0, hi = 0;
259
+ for (const st of d.stream || []) {
260
+ for (const v of [st?.time, st?.time0]) {
261
+ if (typeof v !== 'number') continue;
262
+ if (!lo || v < lo) lo = v;
263
+ if (v > hi) hi = v;
264
+ }
265
+ }
266
+ t.callList.push({
267
+ model: mdl || '(未知模型)',
268
+ tin: u?.inputTokens || 0, tout: u?.outputTokens || 0,
269
+ tcache: (u?.cacheReadTokens || 0) + (u?.cacheWriteTokens || 0),
270
+ dur: lo && hi > lo ? hi - lo : 0,
271
+ });
272
+ // 轨迹里把这次调用拆成两段:
273
+ // ttft = 首个流事件 − 该次调用之前**最近一个事件**的时刻(正常就是同一步的 step/start),
274
+ // 也就是队列 + prefill 的等待;
275
+ // dur = 流式本身的跨度,也就是 decode。
276
+ // 两者相加 ≈ 这一步的墙钟,零头是收尾/落盘。失败调用(assistant/attempt)常常没有 stream,
277
+ // 两个都是 0 —— 页面必须画占位,不能假装有耗时(对照 callsNote 的处理思路)。
278
+ const prevEv = t.events.length ? t.events[t.events.length - 1] : null;
279
+ t.events.push({
280
+ k: 'llm', t: ts || t.time,
281
+ ttft: lo && prevEv && prevEv.t ? Math.max(0, lo - prevEv.t) : 0,
282
+ dur: lo && hi > lo ? hi - lo : 0,
283
+ model: mdl || '',
284
+ tin: u?.inputTokens || 0, tout: u?.outputTokens || 0,
285
+ tcache: (u?.cacheReadTokens || 0) + (u?.cacheWriteTokens || 0),
286
+ err: j.type === 'assistant/attempt',
287
+ blocks: Array.isArray(d.message?.content) ? d.message.content.map(b => (b && b.type) || '').filter(Boolean) : [],
288
+ });
289
+ if (j.type === 'assistant/attempt') {
290
+ // 把源头给的失败原因原样记下来 —— 否则失败轮在详情里是「0 次调用 + 全 0」,像坏了
291
+ for (const st of d.stream || []) {
292
+ const c = st?.chunk;
293
+ if (c?.type === 'finish' && c.reason?.kind === 'error' && !t.errMsg) {
294
+ t.errMsg = c.reason.failure?.message || '';
295
+ }
296
+ }
297
+ }
298
+ continue;
299
+ }
300
+ if (j.type === 'tool/call') {
301
+ // ct 是这次调用的发起时刻,只给轨迹算工具耗时用(O(1),不必回头扫 events)。
302
+ // /api/entry 的 tools[] 是**显式白名单**映射,所以 ct 不会漏进接口。
303
+ const tool = { name: d.name || '?', tid: d.callId || null, input: d.arguments, output: '', error: null, ct: ts || 0 };
304
+ t.tools.push(tool);
305
+ if (tool.tid) t.byCall.set(tool.tid, tool);
306
+ // i 是**指向 t.tools 的下标**,不是正文副本:入参/返回正文已经在 t.tools[i] 里,
307
+ // 复制一份会让内存翻倍,还会让 full=1 时出现「事件里是截断的、tools 里是全的」两份真相。
308
+ t.events.push({ k: 'call', t: ts || t.time, name: tool.name, tid: tool.tid, i: t.tools.length - 1 });
309
+ continue;
310
+ }
311
+ if (j.type === 'tool/result') {
312
+ // 结果的正文包在 data.message 里(dsh 内部就是一条 role:'user'、source.kind:'tool' 的消息),
313
+ // 真正的用例挂在 content[].toolCallId 上 —— 与 tool/call 的 callId 配对
314
+ const blocks = Array.isArray(d.message?.content) ? d.message.content : [];
315
+ const tr = blocks.find(b => b && b.type === 'tool-result') || null;
316
+ const tid = tr?.toolCallId || d.message?.source?.callId || null;
317
+ const tool = (tid && t.byCall.get(tid)) || t.tools.find(x => !x.output) || null;
318
+ if (tool) {
319
+ tool.output = tr ? tr.content : d.message;
320
+ tool.error = !!(d.error || tr?.isError);
321
+ }
322
+ // 工具真实耗时 = 结果时刻 − 这次调用的发起时刻(tool.ct)。配不到 → 0,页面画占位。
323
+ //
324
+ // ⚠️ 事件下标**只认 tid 精确配对**,不走上面 tool.output 那个 `find(x => !x.output)` 的兜底:
325
+ // 兜底是为了「有输出的轮别漏掉输出」,用在**归属**上却是错的 —— 它会把这个孤儿结果挂到
326
+ // 第一个「还没输出」的工具上(空字符串输出也满足 `!x.output`,于是能被反复命中)。
327
+ // 本机实测一轮 177 次调用却有 181 条 result,多出来的 4 条就是这么被硬塞进某个工具的。
328
+ // 轨迹宁可如实写「未配对」(i = -1),也不要指错工具。
329
+ const evTool = (tid && t.byCall.get(tid)) || null;
330
+ t.events.push({
331
+ k: 'result', t: ts || t.time, tid,
332
+ i: evTool ? t.tools.indexOf(evTool) : -1,
333
+ dur: evTool && evTool.ct && ts ? Math.max(0, ts - evTool.ct) : 0,
334
+ error: !!(d.error || tr?.isError),
335
+ });
336
+ continue;
337
+ }
338
+ }
339
+
340
+ // 轮按号排序后再编号:ord 只用来定位(详情按 src.turn 取 data.turns[i]),序稳、id 稳
341
+ const turns = order.sort((a, b) => a.n - b.n);
342
+ for (const t of turns) {
343
+ t.user = t.user.slice(0, 3000);
344
+ const body = t.texts.join('\n\n---\n\n');
345
+ t.preview = (t.user || body || t.errMsg || '').replace(/\s+/g, ' ').slice(0, 300);
346
+ }
347
+ const data = {
348
+ slug, sessName, cwd, sessionId, title, ctxWin, turns,
349
+ // I16:谱系随 data 一起落(dsh 不进 OFF_KINDS,本就不落 index,这里是给 emit 用的)。
350
+ parentSess, depth, subLabel, subMode, subProvider,
351
+ rev: (prev?.data?.rev || 0) + 1,
352
+ };
353
+ files.set(key, { agent, kind: 'dsh', m, s, off: 0, data });
354
+ emitDshTurns(agent, fp, data, prev?.data);
355
+ return true;
356
+ }
357
+
358
+ export function scanDsh(agent, root) {
359
+ // 配置里的 sessions 可能是**基目录**(~/.dsh)而不是会话目录本身(~/.dsh/sessions)——
360
+ // 手工添加时很容易这么填,而两种填法都得能扫出东西来。所以这里认一次:
361
+ // 根目录下没有会话文件就再往下一层试 <root>/sessions。自动发现给的一律是后者(sniffBase
362
+ // 把 <base>/sessions 填进 sessions 字段),所以这一支只对手工配置生效。
363
+ const dirs = hasDshSessions(root) ? [root] : [path.join(root, 'sessions')];
364
+ for (const dir of dirs) {
365
+ for (const slug of listDirCached(dir) || []) {
366
+ const slugDir = path.join(dir, slug);
367
+ if (!isDir(slugDir)) continue;
368
+ for (const sess of listDirCached(slugDir) || []) {
369
+ const sessDir = path.join(slugDir, sess);
370
+ if (!isDir(sessDir)) continue;
371
+ const fp = dshSessionFile(sessDir);
372
+ if (!fp) continue;
373
+ try { scanDshFile(agent, fp, slug, sess); } catch (e) { console.error('[dsh]', sess, e.message); }
374
+ }
375
+ }
376
+ }
377
+ }
@@ -0,0 +1,267 @@
1
+ // ---------------- gemini 解析器(~/.gemini/tmp/<项目slug>/) ----------------
2
+ // 从 agent-acta-server.mjs 拆出(纯搬家,逻辑零改动)。导出 scanGemini。
3
+ // 两个数据源,同一个会话只取一处(有会话文件的以它为准,其余回落到提示词流水):
4
+ //
5
+ // 1) chats/session-<时间>-<短id>.jsonl —— **新版 CLI(2026-09 起)的会话正文**,一行一条记录。
6
+ // 逐行解析见 geminiSessionFile;消息里:
7
+ // type=user → {id,timestamp,content:[{text}]} 开一轮
8
+ // type=gemini → 一次 LLM 调用:{content(字符串正文), tokens{input,output,cached,thoughts,tool,total},
9
+ // model, toolCalls[{id,name,args,result,status}]}(工具结果内联在调用上)
10
+ // type=info / error → 非 LLM("Request cancelled." 之类)跳过;error 表示该轮失败
11
+ // 2) logs.json —— 整目录一份的提示词流水 [{sessionId,messageId,type:'user',message,timestamp}]。
12
+ // 它**只有提示词**,没有 token / 模型 / 回复正文,只用来兜住「会话文件已经不在」的那些老会话
13
+ // (有会话文件的会话由上面的 covered 集合让位)。这类条目给 name='(无用量记录)'
14
+ // (沿用前端既有的「小括号=无信息」标记习惯),不当成没数据而不收。
15
+ //
16
+ // ⚠ 历史教训:这里原先写的是「新版 CLI 不再落 chats/」,那是 2026-04 前后观察到的**临时状态** ——
17
+ // 2026-09 的新版 CLI 其实会落,只是把格式从单份 `.json` 换成了 `.jsonl`,而扫描只 glob `session-*.json`,
18
+ // 于是一整个新版会话都读不到、全部回落到 logs.json,表现就是「全是无用量记录、也没有模型」。
19
+ // **老的单份 `.json` 支持已按需求删除**,现在只认 `.jsonl`。
20
+ //
21
+ // 不收 chats/<父会话短id>/<子会话id>.json:那是子代理(codebase_investigator 等)的完整轨迹,
22
+ // 单个 6MB 且 prompt 是内部指令而非用户请求,与「用户请求日志」的目标不符。
23
+ //
24
+ // token 口径:total = input + output + thoughts(+tool),且 input 含缓存 → tin = input - cached,
25
+ // tout = output + thoughts + tool(thoughts 与 output 同价,别漏)。与 claude/buddy 的 tin 口径一致。
26
+ import fs from 'node:fs';
27
+ import path from 'node:path';
28
+ import { files, entries, srcs, removedIds, sorted, addEntry, toolNameCounts, listDirCached, isDir } from './shared.mjs';
29
+
30
+ const GEMINI_CMD_RE = /^[/!]/; // '/model' '/quit' '/exit' 这类 CLI 命令不算一次请求
31
+ // CLI 注入的 <session_context>(工作区目录树那一大坨)**不是用户说的话**,不能开轮:
32
+ // 实测新版每条会话的第 1 条 user 消息就是它,而**下一条才是用户真正敲的**,
33
+ // 不滤掉就会多出一个假条目 —— 时间是会话开始时刻、没有模型也没有 token、
34
+ // preview 是几百行目录树(比真正的提问还长,排在列表里特别显眼)。
35
+ // 只认开头的标签:正文里出现这四个字(比如用户就在讨论它)不算。
36
+ const GEMINI_CTX_RE = /^<session_context>/;
37
+ const GEMINI_CLI_TITLE = '(无用量记录)'; // logs.json 只有提示词,没有用量可报
38
+
39
+ export function geminiText(content) {
40
+ if (typeof content === 'string') return content;
41
+ if (!Array.isArray(content)) return '';
42
+ return content.map(b => (typeof b === 'string' ? b : String(b?.text || ''))).join('\n');
43
+ }
44
+
45
+ export function geminiUsage(tokens) {
46
+ const t = tokens || {};
47
+ const cached = t.cached || 0;
48
+ return { tin: Math.max(0, (t.input || 0) - cached), tout: (t.output || 0) + (t.thoughts || 0) + (t.tool || 0), tcache: cached };
49
+ }
50
+
51
+ // 工具结果内联在 toolCalls[i].result 里,形如 [{functionResponse:{response:{output}}}](也可能是纯字符串)
52
+ function geminiToolOutput(tc) {
53
+ const parts = [];
54
+ const push = v => { if (v == null) return; const s = typeof v === 'string' ? v : JSON.stringify(v); if (s) parts.push(s); };
55
+ if (Array.isArray(tc.result)) {
56
+ for (const x of tc.result) {
57
+ if (x == null) continue;
58
+ if (typeof x === 'string') { push(x); continue; }
59
+ const fr = x.functionResponse?.response;
60
+ if (fr && fr.output != null) push(fr.output);
61
+ else if (fr) push(fr);
62
+ else if (x.text) push(x.text);
63
+ else push(x);
64
+ }
65
+ } else push(tc.result);
66
+ const s = parts.join('\n');
67
+ if (s) return s;
68
+ return typeof tc.resultDisplay === 'string' ? tc.resultDisplay : ''; // 对象型 resultDisplay(子代理进度)不当正文
69
+ }
70
+
71
+ // 项目名:<slug>/.project_root 最准(CLI 自己写的),退回 <base>/projects.json 反查,再退回 slug 本身
72
+ let geminiProjMap = null;
73
+ function geminiProject(slugDir, slug, root) {
74
+ try {
75
+ const s = fs.readFileSync(path.join(slugDir, '.project_root'), 'utf8').trim();
76
+ if (s) return s;
77
+ } catch {}
78
+ try {
79
+ const mf = path.join(path.dirname(root), 'projects.json');
80
+ const st = fs.statSync(mf);
81
+ if (!geminiProjMap || geminiProjMap.m !== st.mtimeMs) {
82
+ const j = JSON.parse(fs.readFileSync(mf, 'utf8'));
83
+ const map = {};
84
+ for (const [p, s] of Object.entries(j.projects || {})) map[s] = p;
85
+ geminiProjMap = { m: st.mtimeMs, map };
86
+ }
87
+ if (geminiProjMap.map[slug]) return geminiProjMap.map[slug];
88
+ } catch {}
89
+ return slug;
90
+ }
91
+
92
+ function geminiTurn(text0, sid, time, title) {
93
+ return {
94
+ user: text0, assistant: '', tools: [], callList: [],
95
+ time, lastTs: time, dur: 0, tin: 0, tout: 0, tcache: 0, nCalls: 0, nTools: 0,
96
+ models: [], err: false, session: sid, title,
97
+ preview: text0.replace(/\s+/g, ' ').slice(0, 300),
98
+ };
99
+ }
100
+
101
+ // 会话文件(`session-*.jsonl`,一行一条记录,三种行):
102
+ // 1) 第 1 行 = 会话头 {sessionId, projectHash, startTime, lastUpdated, kind}
103
+ // 2) 消息行 = 完整消息对象 {id, timestamp, type, content, tokens, model, toolCalls}(与老格式同形)
104
+ // 3) 补丁行 = {"$set":{…}},字段整体替换。实测出现过的键:messages / lastUpdated / summary
105
+ // (lastUpdated 每来一条消息就跟着来一行,与本解析无关,直接跳过)
106
+ //
107
+ // 两个**实测出来**的要点,都不直观:
108
+ // · **同一个 id 会重复出现**:同一轮的消息先落一条简版、随后再落一条更全的
109
+ // (实测第 5/7 行同 id,后一条多了 toolCalls)。所以按 id 收进 Map —— 后到的覆盖先到的;
110
+ // 而 Map.set 对已存在的键**不改变插入位置**,天然保住「首次出现」的顺序。
111
+ // · `$set.messages` 只在开头出现一次、长度为 1(初始化用)。真出现就按整体替换处理,
112
+ // 与「消息逐行追加」并不冲突。
113
+ //
114
+ // 老格式(单份 JSON、messages 挂在顶层)已按需求**停止支持**:新版 CLI 只落 .jsonl。
115
+ function geminiSessionFile(fp) {
116
+ const msgs = new Map();
117
+ let head = null, title = '';
118
+ for (const line of fs.readFileSync(fp, 'utf8').split('\n')) {
119
+ if (!line.trim()) continue;
120
+ let j; try { j = JSON.parse(line); } catch { continue; } // 追加写可能留半行
121
+ if (!j) continue;
122
+ if (j.$set) {
123
+ const sm = j.$set.messages;
124
+ if (Array.isArray(sm)) { msgs.clear(); for (const x of sm) if (x && x.id) msgs.set(x.id, x); }
125
+ if (typeof j.$set.summary === 'string') title = j.$set.summary.slice(0, 60);
126
+ continue;
127
+ }
128
+ if (j.id) { msgs.set(j.id, j); continue; } // 消息行:同 id 后到者更全 → 覆盖,顺序不变
129
+ if (j.sessionId) head = j; // 第 1 行的会话头
130
+ }
131
+ const sessionId = String((head && head.sessionId) || path.basename(fp, '.jsonl'));
132
+ const turns = [];
133
+ for (const m of msgs.values()) {
134
+ const ts = m.timestamp ? Date.parse(m.timestamp) || 0 : 0;
135
+ if (m.type === 'user') {
136
+ const text0 = geminiText(m.content).trim();
137
+ if (!text0 || GEMINI_CMD_RE.test(text0) || GEMINI_CTX_RE.test(text0)) continue; // 空/命令/上下文注入不开轮(扫描与详情同口径)
138
+ turns.push(geminiTurn(text0, sessionId, ts, title));
139
+ continue;
140
+ }
141
+ const cur = turns[turns.length - 1];
142
+ if (!cur) continue;
143
+ if (m.type === 'info') continue;
144
+ if (m.type === 'error') { cur.err = true; if (ts) cur.lastTs = Math.max(cur.lastTs, ts); continue; }
145
+ if (m.type !== 'gemini') continue;
146
+ const u = geminiUsage(m.tokens);
147
+ cur.tin += u.tin; cur.tout += u.tout; cur.tcache += u.tcache;
148
+ cur.nCalls++;
149
+ const model = m.model || '';
150
+ if (model && !cur.models.includes(model)) cur.models.push(model);
151
+ const txt = geminiText(m.content).trim();
152
+ // 一轮里多次 LLM 调用各自可能带正文,逐段保留(同 atomcode 的多 round 处理)
153
+ if (txt) cur.assistant = cur.assistant ? cur.assistant + '\n\n---\n\n' + txt : txt;
154
+ // text = **这一次**调用自己产出的正文。gemini 的正文与用量同在一条记录上,
155
+ // 是各家里唯一一上手就能按调用切开的(claude/codebuddy 要按 messageId 回填)。
156
+ const call = {
157
+ model, tin: u.tin, tout: u.tout, tcache: u.tcache,
158
+ dur: ts && cur.lastTs ? Math.max(0, ts - cur.lastTs) : 0,
159
+ text: txt || '',
160
+ };
161
+ // 同一条记录里的工具调用就属于这一次调用,不用像 claude/codebuddy 那样回填。
162
+ // 没有工具的那几次不建空数组(这份 callList 是**常驻内存**的,空数组会白占)。
163
+ if (m.toolCalls && m.toolCalls.length) {
164
+ call.tools = m.toolCalls.map(tc => tc.name || tc.displayName || '?');
165
+ }
166
+ cur.callList.push(call);
167
+ for (const tc of m.toolCalls || []) {
168
+ cur.tools.push({
169
+ name: tc.name || tc.displayName || '?', tid: tc.id || null,
170
+ input: JSON.stringify(tc.args ?? ''), output: geminiToolOutput(tc),
171
+ // 状态缺失不算错(旧记录没有 status 字段);只有明确非成功才标红
172
+ error: tc.status && tc.status !== 'success' && tc.status !== 'completed' ? String(tc.status) : null,
173
+ });
174
+ cur.nTools++;
175
+ }
176
+ if (ts) cur.lastTs = Math.max(cur.lastTs, ts);
177
+ }
178
+ for (const t of turns) t.dur = Math.max(0, t.lastTs - t.time);
179
+ return { sessionId, turns };
180
+ }
181
+
182
+ // 一个项目 slug = 一份状态(key 是 slug 目录)。目录里任何文件变了就整份重解析:会话文件是一次性重写的
183
+ // 整份 JSON,没有 jsonl 那种增量偏移;logs.json 也就几百 KB,全量重解析比维护偏移可靠。
184
+ // 条目 id 按 sessionId 派生(g#<sessionId>#<轮序>),所以重解析是原地覆盖而不是灌重复条目。
185
+ export function scanGemini(agent, root) {
186
+ for (const slug of listDirCached(root) || []) {
187
+ const dir = path.join(root, slug);
188
+ if (!isDir(dir)) continue;
189
+ try { scanGeminiSlug(agent, dir, slug, root); } catch (e) { console.error('[gemini]', slug, e.message); }
190
+ }
191
+ }
192
+
193
+ function scanGeminiSlug(agent, dir, slug, root) {
194
+ const chatsDir = path.join(dir, 'chats');
195
+ const chatFiles = [];
196
+ for (const n of listDirCached(chatsDir) || []) {
197
+ // 只认新版 CLI 的 .jsonl(老的单份 .json 已停用);子目录里的子代理轨迹不收
198
+ if (!n.startsWith('session-') || !n.endsWith('.jsonl')) continue;
199
+ const fp = path.join(chatsDir, n);
200
+ let st; try { st = fs.statSync(fp); } catch { continue; }
201
+ if (st.isFile()) chatFiles.push([fp, st]);
202
+ }
203
+ const logsPath = path.join(dir, 'logs.json');
204
+ let lst = null;
205
+ try { const st = fs.statSync(logsPath); if (st.isFile()) lst = st; } catch {}
206
+ const key = 'gemini:' + dir;
207
+ const sig = chatFiles.map(([fp, st]) => fp + '|' + st.mtimeMs + '|' + st.size).join(';') +
208
+ '||' + (lst ? lst.mtimeMs + '|' + lst.size : '-');
209
+ const prev = files.get(key);
210
+ if (prev && prev.data.sig === sig) return; // 内容没变:本轮不重解析(stat 量级,3s 一轮无压力)
211
+ const project = geminiProject(dir, slug, root);
212
+ const turns = [];
213
+ const covered = new Set(); // 有会话文件的会话:logs.json 里同一句提示词不再重复计
214
+ for (const [fp] of chatFiles) {
215
+ try {
216
+ const r = geminiSessionFile(fp);
217
+ // 轮序按会话内从 0 数(不是 slug 内累计):新增一个会话文件不会让别人的 id 漂移
218
+ r.turns.forEach((t, k) => { t.ord = k; turns.push(t); });
219
+ if (r.sessionId) covered.add(r.sessionId);
220
+ } catch (e) { console.error('[gemini] parse', path.basename(fp), e.message); }
221
+ }
222
+ if (lst) {
223
+ try {
224
+ const arr = JSON.parse(fs.readFileSync(logsPath, 'utf8'));
225
+ for (let i = 0; Array.isArray(arr) && i < arr.length; i++) {
226
+ const e = arr[i];
227
+ if (!e || e.type !== 'user') continue;
228
+ const sid = String(e.sessionId || '');
229
+ if (covered.has(sid)) continue;
230
+ const text0 = geminiText(e.message).trim();
231
+ if (!text0 || GEMINI_CMD_RE.test(text0)) continue;
232
+ const ts = e.timestamp ? Date.parse(e.timestamp) || 0 : 0;
233
+ const t = geminiTurn(text0, sid, ts, GEMINI_CLI_TITLE);
234
+ t.ord = Number.isFinite(e.messageId) ? e.messageId : i; // 直接用文件里的轮序,最稳
235
+ turns.push(t);
236
+ }
237
+ } catch (e) { console.error('[gemini] logs', slug, e.message); }
238
+ }
239
+ const data = { sig, project, rev: (prev?.data?.rev || 0) + 1, turns };
240
+ files.set(key, { agent, kind: 'gemini', m: 0, s: 0, off: 0, data });
241
+ emitGeminiTurns(agent, key, project, data, prev?.data);
242
+ }
243
+
244
+ function emitGeminiTurns(agent, key, project, data, prevData) {
245
+ const idBase = 'g#' + key.slice(7).replace(/[\\/:]/g, '~') + '#';
246
+ const ids = new Set();
247
+ data.turns.forEach((t, i) => {
248
+ const id = idBase + t.session + '#' + t.ord;
249
+ ids.add(id);
250
+ addEntry(id, {
251
+ agent, project, session: t.session,
252
+ time: t.time, dur: t.dur || Math.max(0, t.lastTs - t.time), status: t.err ? 'error' : 'ok',
253
+ tin: t.tin, tout: t.tout, tcache: t.tcache, total: t.tin + t.tout + t.tcache,
254
+ ctx: 0, rounds: t.nCalls, tools: t.nTools, calls: t.nCalls, // 同 claude:rounds=calls=轮内 LLM 调用数
255
+ models: t.models, preview: t.preview, name: t.title || null,
256
+ finished: !!t.finished || i < data.turns.length - 1, aborted: !!t.aborted,
257
+ toolNames: toolNameCounts(t.tools),
258
+ }, { file: key, turn: i, kind: 'gemini' });
259
+ });
260
+ // 会话文件后到 / 被覆盖掉的 logs.json 条目要撤掉,否则会和新条目并存(同一句提示词出现两次)
261
+ for (const old of prevData?.ids || []) {
262
+ if (ids.has(old)) continue;
263
+ entries.delete(old); srcs.delete(old); removedIds.push(old);
264
+ }
265
+ data.ids = [...ids];
266
+ sorted.cache = null;
267
+ }