pi-skill-evolve 0.1.4 → 0.1.6

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 (47) hide show
  1. package/extensions/skill-evolve/lib/aggregate.ts +153 -0
  2. package/extensions/skill-evolve/lib/dispatch-types.ts +65 -0
  3. package/extensions/skill-evolve/lib/dispatch.ts +32 -52
  4. package/extensions/skill-evolve/lib/evolution.ts +1 -0
  5. package/extensions/skill-evolve/lib/report.ts +4 -1
  6. package/extensions/skill-evolve/lib/runs.ts +35 -2
  7. package/extensions/skill-evolve/lib/shutdown-dispatch.ts +24 -1
  8. package/extensions/skill-evolve/lib/telemetry.ts +2 -0
  9. package/extensions/skill-evolve/lib/web/applied.ts +4 -0
  10. package/extensions/skill-evolve/lib/web/approvals.ts +4 -0
  11. package/extensions/skill-evolve/lib/web/data.ts +13 -226
  12. package/extensions/skill-evolve/lib/web/done.ts +65 -0
  13. package/extensions/skill-evolve/lib/web/filter.ts +4 -4
  14. package/extensions/skill-evolve/lib/web/list-query.ts +43 -0
  15. package/extensions/skill-evolve/lib/web/markdown-lite.ts +95 -0
  16. package/extensions/skill-evolve/lib/web/pending.ts +191 -0
  17. package/extensions/skill-evolve/lib/web/render-controls.ts +58 -32
  18. package/extensions/skill-evolve/lib/web/render-rows.ts +178 -205
  19. package/extensions/skill-evolve/lib/web/render-run.ts +14 -0
  20. package/extensions/skill-evolve/lib/web/render-session.ts +138 -0
  21. package/extensions/skill-evolve/lib/web/render-skills.ts +0 -1
  22. package/extensions/skill-evolve/lib/web/render-table.ts +126 -0
  23. package/extensions/skill-evolve/lib/web/render.ts +116 -54
  24. package/extensions/skill-evolve/lib/web/reports.ts +11 -5
  25. package/extensions/skill-evolve/lib/web/run-detail.ts +6 -0
  26. package/extensions/skill-evolve/lib/web/run-session.ts +62 -0
  27. package/extensions/skill-evolve/lib/web/server.ts +21 -1
  28. package/extensions/skill-evolve/lib/web/session-file.ts +136 -0
  29. package/extensions/skill-evolve/lib/web/session-render.ts +189 -0
  30. package/extensions/skill-evolve/lib/web/session-view.ts +276 -0
  31. package/extensions/skill-evolve/lib/web/shell.ts +42 -11
  32. package/extensions/skill-evolve/lib/web/style.ts +171 -89
  33. package/extensions/skill-evolve/lib/web/text.ts +57 -0
  34. package/extensions/skill-evolve/test/aggregate-dispatch.test.ts +165 -0
  35. package/extensions/skill-evolve/test/aggregate-harness.ts +114 -0
  36. package/extensions/skill-evolve/test/aggregate.test.ts +80 -0
  37. package/extensions/skill-evolve/test/session-nav.test.ts +162 -0
  38. package/extensions/skill-evolve/test/session-page.test.ts +200 -0
  39. package/extensions/skill-evolve/test/session-render.test.ts +203 -0
  40. package/extensions/skill-evolve/test/session-view.test.ts +180 -0
  41. package/extensions/skill-evolve/test/web-applied.test.ts +30 -22
  42. package/extensions/skill-evolve/test/web-redesign.test.ts +264 -0
  43. package/extensions/skill-evolve/test/web-render.test.ts +57 -41
  44. package/extensions/skill-evolve/test/web-server.test.ts +4 -1
  45. package/extensions/skill-evolve/test/web-session-column.test.ts +139 -0
  46. package/extensions/skill-evolve/test/web-shell.test.ts +7 -5
  47. package/package.json +1 -1
@@ -0,0 +1,136 @@
1
+ /**
2
+ * 会话详情页的**读盘层**:从 sessionId 找到会话 .jsonl 绝对路径,读出来交给解析器。
3
+ *
4
+ * 两条来源(顺序固定):
5
+ * 1. run.json 里已经写着 `sessionFile`(pi 派发时写下的绝对路径);`?file=` 参数同理,但要**过安全闸**;
6
+ * 2. 该路径已不在本机(换了机器 / 清理过 sessions),就按 sessionId 在
7
+ * `~/.pi/agent/sessions/*​/*.jsonl` 里按文件名反查 —— 文件名形如
8
+ * `2026-11-08T02-48-52-675Z_<sessionId>.jsonl`,所以「文件名含 sessionId」就是精确命中。
9
+ *
10
+ * 安全闸(面板虽然是 127.0.0.1,但 query 参数来自浏览器,不能当可信路径用):
11
+ * 只接受 **realpath 落在 sessions 根目录之下**、且**文件名含 sessionId**、且以 `.jsonl` 结尾的文件。
12
+ * 三条同时成立才读 —— 少了任何一条,`?file=/etc/passwd` 就能读到任意文件。
13
+ */
14
+ import * as fs from "node:fs";
15
+ import * as os from "node:os";
16
+ import * as path from "node:path";
17
+ import { listRuns } from "../runs.ts";
18
+ import { parseSessionText, type SessionTranscript } from "./session-view.ts";
19
+
20
+ /** sessionId 只允许这些字符:它会被当文件名匹配词,绝不允许 `/` 之类进到路径里 */
21
+ const SESSION_ID_PATTERN = /^[A-Za-z0-9._-]{4,128}$/;
22
+
23
+ /** 反查 runs 时要扫多少条(与 data.ts 的 SCAN_LIMIT 同量级,够覆盖最近这批 run) */
24
+ const RUN_SCAN_LIMIT = 100;
25
+
26
+ export interface SessionSource {
27
+ sessionId: string;
28
+ /** 解析到的会话原文绝对路径;找不到 = null */
29
+ file: string | null;
30
+ /** 文件字节数(找不到 = 0) */
31
+ sizeBytes: number;
32
+ /** 解析结果;找不到文件 = null */
33
+ transcript: SessionTranscript | null;
34
+ }
35
+
36
+ /** pi 会话根目录:`~/.pi/agent/sessions`(测试用 home 覆盖,绝不读真实目录) */
37
+ export function sessionsRoot(home?: string): string {
38
+ return path.join(home ?? os.homedir(), ".pi", "agent", "sessions");
39
+ }
40
+
41
+ /** 规范化 sessionId:不合法返回 null(调用方据此直接给「会话文件已不在本机」) */
42
+ export function safeSessionId(raw: string): string | null {
43
+ const trimmed = raw.trim();
44
+ return SESSION_ID_PATTERN.test(trimmed) ? trimmed : null;
45
+ }
46
+
47
+ /** 路径是否真在根目录之下(用 realpath 比,符号链接绕不出去) */
48
+ function insideRoot(root: string, file: string): boolean {
49
+ try {
50
+ const realRoot = fs.realpathSync(root);
51
+ const realFile = fs.realpathSync(file);
52
+ return realFile.startsWith(`${realRoot}${path.sep}`);
53
+ } catch {
54
+ return false;
55
+ }
56
+ }
57
+
58
+ /** 文件本身是不是这个会话的原文:basename 含 sessionId、后缀 .jsonl、且在根目录内 */
59
+ function isSessionFile(root: string, sessionId: string, file: string): boolean {
60
+ if (!file.endsWith(".jsonl")) return false;
61
+ if (!path.basename(file).includes(sessionId)) return false;
62
+ return insideRoot(root, file);
63
+ }
64
+
65
+ /**
66
+ * 按 sessionId 在 sessions 根目录下反查(一层子目录,pi 按项目路径分目录)。
67
+ * 命中多条时取文件名最大的那条:名字以 ISO 时间开头,字典序最大 = 最近一次会话。
68
+ */
69
+ export function findSessionFile(sessionId: string, root: string): string | null {
70
+ let dirs: fs.Dirent[];
71
+ try {
72
+ dirs = fs.readdirSync(root, { withFileTypes: true });
73
+ } catch {
74
+ return null;
75
+ }
76
+ let found: string | null = null;
77
+ for (const dir of dirs) {
78
+ if (!dir.isDirectory()) continue;
79
+ const sub = path.join(root, dir.name);
80
+ let names: string[];
81
+ try {
82
+ names = fs.readdirSync(sub);
83
+ } catch {
84
+ continue;
85
+ }
86
+ for (const name of names) {
87
+ if (!name.endsWith(".jsonl") || !name.includes(sessionId)) continue;
88
+ if (found === null || name > path.basename(found)) found = path.join(sub, name);
89
+ }
90
+ }
91
+ return found !== null && isSessionFile(root, sessionId, found) ? found : null;
92
+ }
93
+
94
+ /** 解析会话文件路径:先试给定路径(过安全闸),不行再按 sessionId 反查 */
95
+ export function resolveSessionFile(input: { sessionId: string; file?: string | null; root: string }): string | null {
96
+ const { sessionId, file, root } = input;
97
+ if (file !== null && file !== undefined && file.length > 0) {
98
+ if (isSessionFile(root, sessionId, file)) return file;
99
+ }
100
+ return findSessionFile(sessionId, root);
101
+ }
102
+
103
+ /** run.json 里记着的会话路径:同 sessionId 的 run 里挑最近一条(队列条目没有这个字段,只能反查) */
104
+ export function declaredSessionFile(evolutionDir: string, sessionId: string): string | null {
105
+ const runs = listRuns(evolutionDir, RUN_SCAN_LIMIT);
106
+ for (const run of runs) {
107
+ if (run.sessionId === sessionId && run.sessionFile !== null && run.sessionFile.length > 0) return run.sessionFile;
108
+ }
109
+ return null;
110
+ }
111
+
112
+ /**
113
+ * 读出会话原文并解析。找不到文件不抛错:页面要显示「会话文件已不在本机」这句人话,
114
+ * 而不是一个 500 或空白页。
115
+ */
116
+ export function readSessionSource(input: {
117
+ evolutionDir: string;
118
+ sessionId: string;
119
+ file?: string | null;
120
+ home?: string;
121
+ }): SessionSource {
122
+ const sessionId = safeSessionId(input.sessionId);
123
+ const root = sessionsRoot(input.home);
124
+ if (sessionId === null) return { sessionId: input.sessionId.trim(), file: null, sizeBytes: 0, transcript: null };
125
+ const declared = input.file ?? declaredSessionFile(input.evolutionDir, sessionId);
126
+ const file = resolveSessionFile({ sessionId, file: declared, root });
127
+ if (file === null) return { sessionId, file: null, sizeBytes: 0, transcript: null };
128
+ let raw: string;
129
+ try {
130
+ raw = fs.readFileSync(file, "utf8");
131
+ } catch {
132
+ // 目录在但文件被删 / 没权限:与「不在本机」同一句话,不暴露系统错误
133
+ return { sessionId, file: null, sizeBytes: 0, transcript: null };
134
+ }
135
+ return { sessionId, file, sizeBytes: Buffer.byteLength(raw, "utf8"), transcript: parseSessionText(raw) };
136
+ }
@@ -0,0 +1,189 @@
1
+ /**
2
+ * 会话正文与目录的**渲染**:把 session-view.ts 解析出来的块拼成 HTML 与目录条目。
3
+ *
4
+ * 与解析拆开是因为两件事的行数各自都不小:解析要处理 pi 的 JSONL 细节,
5
+ * 渲染要处理 Markdown、折叠、目录锚点与总输出上限 —— 合在一个文件里必然破 300 行。
6
+ *
7
+ * 三条口径:
8
+ * 1. **正文走 Markdown-lite**:pi 的对话里满是 `**加粗**`、`` `代码` ``、列表,
9
+ * 原样输出等于让人读一堆星号(用户 2026-11-08:「Markdown 原样输出」);
10
+ * 2. **工具块默认收起**,抬头给「次数 + 首个参数摘要」——`bash ×9` 看不出调了什么;
11
+ * 3. **正文与目录一次遍历产出**:锚点 id 与目录 href 因此不会对不上。
12
+ */
13
+ import { renderMarkdownLite } from "./markdown-lite.ts";
14
+ import {
15
+ MAX_MESSAGES,
16
+ MAX_OUTPUT,
17
+ MAX_TEXT,
18
+ MAX_USER_TEXT,
19
+ type SessionEntry,
20
+ type SessionToolCall,
21
+ type SessionTranscript,
22
+ } from "./session-view.ts";
23
+ import { escapeHtml, formatTime, formatTimeSeconds } from "./text.ts";
24
+
25
+ /** 折叠抬头里的首个参数摘要上限 */
26
+ export const TOOL_PREVIEW = 60;
27
+ /** 目录条目里的用户消息摘要上限 */
28
+ export const TOC_TEXT = 40;
29
+
30
+ /** 截断并加省略号:中文一行话也可能超长,所以在字符数上截,不在行上截 */
31
+ function clip(text: string, max: number): string {
32
+ const trimmed = text.trim();
33
+ return trimmed.length > max ? `${trimmed.slice(0, max)}…` : trimmed;
34
+ }
35
+
36
+ /** 工具调用的抬头:`read ×3、bash ×2`(同名的合成一个计数,别写三遍 read) */
37
+ function toolSummary(calls: readonly SessionToolCall[]): string {
38
+ const counts = new Map<string, number>();
39
+ for (const call of calls) counts.set(call.name, (counts.get(call.name) ?? 0) + 1);
40
+ return [...counts.entries()].map(([name, count]) => `${name} ×${count}`).join("、");
41
+ }
42
+
43
+ /** 一个工具调用块:名字 + 参数摘要 + 结果前 200 字(全转义,original 里可能带尖括号) */
44
+ function toolBlock(call: SessionToolCall): string {
45
+ return `<div class="tool">
46
+ <p class="tool-name">${escapeHtml(call.name)}</p>
47
+ <pre class="tool-args">${escapeHtml(call.args.length > 0 ? call.args : "(无参数)")}</pre>
48
+ <pre class="tool-result">${escapeHtml(call.result)}</pre>
49
+ </div>`;
50
+ }
51
+
52
+ /**
53
+ * 一组工具调用:`<details>` **默认收起**(不加 `open`)——
54
+ * 一段会话动辄几百次调用,默认展开等于把对话正文顶到十屏之外。
55
+ * 抬头里给「首个参数摘要」:`bash ×9` 看不出调了什么,`rg -n "x" lib` 一眼就知道。
56
+ */
57
+ function toolGroup(calls: readonly SessionToolCall[]): string {
58
+ const first = calls[0];
59
+ // 摘要按工具取(bash 取 command 第一行、read 取文件名…),取不到就只留 `▸ bash ×9`
60
+ const preview = first === undefined ? "" : clip(first.summary, TOOL_PREVIEW);
61
+ const tail = preview.length === 0 ? "" : ` · <span class="muted">${escapeHtml(preview)}</span>`;
62
+ return `<details class="tools"><summary>▸ ${escapeHtml(toolSummary(calls))}${tail}</summary>
63
+ ${calls.map(toolBlock).join("\n")}
64
+ </details>`;
65
+ }
66
+
67
+ /** `only=user` 时工具调用的替身:一行说清省略了多少次,并给「看全部」的链接 */
68
+ function omittedLine(calls: readonly SessionToolCall[], expandHref: string | null): string {
69
+ const more = expandHref === null ? "" : `(<a href="${escapeHtml(expandHref)}">看全部</a>)`;
70
+ return `<p class="hint">… 本轮省略 ${calls.length} 次工具调用${more}</p>`;
71
+ }
72
+
73
+ /** 目录条目:`19:35 · 前 40 字`(换行压成空格,否则目录会被一段代码撑成几行) */
74
+ export interface TocEntry {
75
+ id: string;
76
+ at: number | null;
77
+ text: string;
78
+ }
79
+
80
+ export interface TranscriptView {
81
+ /** 消息流 HTML */
82
+ body: string;
83
+ /** 目录条目(只收 user 消息,顺序与正文一致) */
84
+ toc: TocEntry[];
85
+ /** 实际渲染的块数 */
86
+ shown: number;
87
+ /** 是否撞到总输出上限 */
88
+ truncated: boolean;
89
+ }
90
+
91
+ export interface RenderSessionOptions {
92
+ /** 因「只渲染最后 200 条」丢掉的条数(>0 时在顶部说明一句) */
93
+ dropped?: number;
94
+ /** 只看对话:工具调用压成一行 */
95
+ onlyUser?: boolean;
96
+ /** 「看全部」链接(去掉 only=user 的同一会话地址) */
97
+ expandHref?: string | null;
98
+ }
99
+
100
+ /** 一条对话消息:徽章 + 时刻 + Markdown-lite 正文(用户底色更亮,扫读时能一眼分段) */
101
+ function messageBlock(
102
+ entry: Extract<SessionEntry, { kind: "user" | "assistant" }>,
103
+ id: string,
104
+ label: { text: string; tone: string },
105
+ ): { html: string; text: string } {
106
+ const max = entry.kind === "user" ? MAX_USER_TEXT : MAX_TEXT;
107
+ const body = clip(entry.text, max);
108
+ if (body.length === 0) return { html: "", text: "" };
109
+ const at = entry.at === null ? "" : `<span class="at">${formatTimeSeconds(entry.at)}</span>`;
110
+ const html = `<div class="msg ${entry.kind} m" id="${id}">
111
+ <div class="mhead"><span class="badge ${label.tone}">${label.text}</span>${at}</div>
112
+ <div class="msg-body">${renderMarkdownLite(body)}</div>
113
+ </div>`;
114
+ return { html, text: body.replace(/\s+/g, " ").trim() };
115
+ }
116
+
117
+ /**
118
+ * 会话正文 + 目录:**一次遍历同时产出两者**,锚点 id 与目录 href 因此不会对不上
119
+ * (分两处各自编号迟早出现「点了目录跳错块」)。
120
+ */
121
+ export function renderTranscript(
122
+ transcript: SessionTranscript,
123
+ options: RenderSessionOptions = {},
124
+ ): TranscriptView {
125
+ const parts: string[] = [];
126
+ const toc: TocEntry[] = [];
127
+ let size = 0;
128
+ let shown = 0;
129
+ let truncated = false;
130
+ if (options.dropped !== undefined && options.dropped > 0) {
131
+ parts.push(
132
+ `<p class="hint">只渲染最后 ${MAX_MESSAGES} 条消息,前面还有 ${options.dropped} 条未显示(看全文请用 rg/read 打开原文件)。</p>`,
133
+ );
134
+ }
135
+ for (const entry of transcript.entries) {
136
+ const id = `m-${shown + 1}`;
137
+ const rendered =
138
+ entry.kind === "tools"
139
+ ? {
140
+ html:
141
+ options.onlyUser === true ? omittedLine(entry.calls, options.expandHref ?? null) : toolGroup(entry.calls),
142
+ text: "",
143
+ }
144
+ : messageBlock(
145
+ entry,
146
+ id,
147
+ entry.kind === "user" ? { text: "用户", tone: "blue" } : { text: "助手", tone: "green" },
148
+ );
149
+ if (rendered.html.length === 0) continue;
150
+ if (size + rendered.html.length > MAX_OUTPUT) {
151
+ truncated = true;
152
+ break;
153
+ }
154
+ shown += 1;
155
+ parts.push(rendered.html);
156
+ size += rendered.html.length;
157
+ if (entry.kind === "user") {
158
+ toc.push({ id, at: entry.at, text: clip(rendered.text, TOC_TEXT) });
159
+ }
160
+ }
161
+ if (shown === 0) {
162
+ return {
163
+ body: '<p class="empty">这段会话里没有可渲染的对话(可能全是工具调用与系统提示)。</p>',
164
+ toc,
165
+ shown,
166
+ truncated,
167
+ };
168
+ }
169
+ if (truncated) {
170
+ parts.push(
171
+ `<p class="hint">已达页面输出上限(${Math.round(MAX_OUTPUT / 1024)}KB),后面还有内容未渲染:继续看请用 rg/read 打开原文件。</p>`,
172
+ );
173
+ }
174
+ return { body: parts.join("\n"), toc, shown, truncated };
175
+ }
176
+
177
+ /**
178
+ * 目录内容(左侧粘性栏与窄屏折叠目录共用同一份):
179
+ * 只列 user 消息 —— 一段会话里用户消息就是天然的「章节」。
180
+ */
181
+ export function renderToc(toc: readonly TocEntry[]): string {
182
+ if (toc.length === 0) return `<p class="hint">这段会话里没有用户消息。</p>`;
183
+ return toc
184
+ .map((item) => {
185
+ const when = item.at === null ? "—" : formatTime(item.at);
186
+ return `<a href="#${item.id}">${escapeHtml(when)} · ${escapeHtml(item.text)}</a>`;
187
+ })
188
+ .join("\n");
189
+ }
@@ -0,0 +1,276 @@
1
+ /**
2
+ * 会话详情页的**纯函数**:把 pi 的会话 JSONL 解析成「可渲染的块」,再拼成 HTML 片段与目录。
3
+ *
4
+ * 为什么要这一页(用户 2026-11-08:「支持打开会话的详情, 工具调用可收起展开, 默认收起」):
5
+ * 面板上每条 run 都关联一段 pi 会话,出问题时要看的是「那段对话里到底发生了什么」——
6
+ * 而 sessions/*.jsonl 有几 MB、几千行,在编辑器里翻根本对不上哪句是哪轮。
7
+ *
8
+ * 结构口径(与 pi 的真实写入一致):
9
+ * - 只取顶层 `type === "message"` 的条目,其余(session / model_change / thinking_level_change / custom)全跳过;
10
+ * - `message.role` = user / assistant / toolResult / system;system 不渲染(它是系统提示词,不是对话);
11
+ * - assistant 的内容里 `text` 是给人看的、`thinking` 不渲染、`toolCall` 聚成折叠块;
12
+ * - toolResult 消息按 `toolCallId` 回连到那次调用(结果贴在该调用的参数之后);
13
+ * - 时间取该条 JSONL 记录顶层的 `timestamp`(ISO 字符串),拿不到就是 null。
14
+ *
15
+ * 三条上限(会话可能很大,页面不能被一段对话撑爆浏览器):
16
+ * 只渲染最后 200 条消息 · 单条 4000 字 · 用户原文 2000 字 · 总输出超 2MB 就停手并指路原文件。
17
+ *
18
+ * 这个文件里**没有一行 IO**:入参是字符串,出参是数据结构,所以能直接拿临时 JSONL 片段做单测。
19
+ * HTML 与目录在 ./session-render.ts —— 解析与渲染各自都够一屏,混在一起必然破 300 行。
20
+ */
21
+
22
+ /** 只渲染最后多少条 message(更早的对话对排查当前问题几乎无用) */
23
+ export const MAX_MESSAGES = 200;
24
+ /** 单条消息正文上限 */
25
+ export const MAX_TEXT = 4000;
26
+ /** 用户消息正文上限:用户要的是「原文」,所以给得比 assistant 宽,但仍要防一次粘贴几万字 */
27
+ export const MAX_USER_TEXT = 2000;
28
+ /** 一次工具调用的参数摘要上限(目录与折叠抬头只用 60 字,见 `TOOL_PREVIEW`) */
29
+ export const MAX_ARGS = 400;
30
+ /** 折叠抬头里那句「调了什么」的上限:一行看得完,不抢工具名的位置 */
31
+ export const TOOL_SUMMARY = 60;
32
+ /** 一次工具调用的结果上限 */
33
+ export const MAX_RESULT = 200;
34
+ /** 整页会话正文的输出上限(字符数,UTF-8 下中文 3 字节,这里按保守值算) */
35
+ export const MAX_OUTPUT = 2 * 1024 * 1024;
36
+
37
+ export interface SessionToolCall {
38
+ name: string;
39
+ /** 参数原文(JSON 压成一行后截断):展开后给「完整参数」看 */
40
+ args: string;
41
+ /** 抬头摘要:按工具挑一个可读参数(见 `toolArgSummary`),取不到就是空串 */
42
+ summary: string;
43
+ /** 结果前 200 字(没有结果就是「(没有结果)」) */
44
+ result: string;
45
+ }
46
+
47
+ export type SessionEntry =
48
+ | { kind: "user"; text: string; at: number | null }
49
+ | { kind: "assistant"; text: string; at: number | null }
50
+ | { kind: "tools"; calls: SessionToolCall[]; at: number | null };
51
+
52
+ /**
53
+ * 解析中途的调用记录:结果(toolResult)总是排在携带 toolCall 的 assistant 消息**之后**,
54
+ * 所以先带 id 收进来,全文扫完再回填结果 —— 边扫边取会永远取不到(那时结果还没出现)。
55
+ */
56
+ interface RawCall {
57
+ id: string;
58
+ name: string;
59
+ args: string;
60
+ summary: string;
61
+ }
62
+
63
+ export interface SessionTranscript {
64
+ entries: SessionEntry[];
65
+ /** 解析到的 message 总条数(不限 200) */
66
+ messages: number;
67
+ /** 工具调用总次数 */
68
+ toolCalls: number;
69
+ /** 因为只留最后 200 条而丢掉的消息条数 */
70
+ dropped: number;
71
+ /** 第一条 / 最后一条消息的时间:概览卡片的「10:32 → 11:52」用它 */
72
+ firstAt: number | null;
73
+ lastAt: number | null;
74
+ }
75
+
76
+ /** 截断并加省略号:中文一行话也可能超长,所以在字符数上截,不在行上截 */
77
+ function clip(text: string, max: number): string {
78
+ const trimmed = text.trim();
79
+ return trimmed.length > max ? `${trimmed.slice(0, max)}…` : trimmed;
80
+ }
81
+
82
+ /** 顶层时间戳:ISO 字符串或已是数字;解析不了就当没有(不拿当前时间冒充) */
83
+ function stamp(record: Record<string, unknown>): number | null {
84
+ const raw = record.timestamp;
85
+ if (typeof raw === "number" && Number.isFinite(raw)) return raw;
86
+ if (typeof raw !== "string") return null;
87
+ const parsed = Date.parse(raw);
88
+ return Number.isFinite(parsed) ? parsed : null;
89
+ }
90
+
91
+ /** 参数压成一行 JSON:多行缩进在面板里只会占满屏 */
92
+ function argumentsText(value: unknown): string {
93
+ let text: string;
94
+ if (typeof value === "string") {
95
+ text = value;
96
+ } else {
97
+ try {
98
+ text = JSON.stringify(value) ?? "";
99
+ } catch {
100
+ text = String(value);
101
+ }
102
+ }
103
+ return clip(text.replace(/\s+/g, " "), MAX_ARGS);
104
+ }
105
+
106
+ /** bash 的 command:只要第一行(多行脚本会把抬头撑成一屏),`\n` 还原成空格,首尾引号去掉 */
107
+ function commandHead(command: string | null): string | null {
108
+ if (command === null) return null;
109
+ const firstLine = command.split("\n")[0] ?? "";
110
+ return firstLine.replace(/\\n/g, " ").replace(/^[\s"'`]+|[\s"'`]+$/g, "");
111
+ }
112
+
113
+ /** 路径的文件名:`read /home/x/a.ts` 的抬头只值 `a.ts`,整条路径会把抬头挤满 */
114
+ function basenameOf(path: string): string {
115
+ const parts = path.split("/").filter((part) => part.length > 0);
116
+ return parts.length > 0 ? (parts[parts.length - 1] as string) : path;
117
+ }
118
+
119
+ /** 参数对象里第一个非空字符串值:没有统一约定的工具就取它,总比一串 JSON 强 */
120
+ function firstString(record: Record<string, unknown> | null): string | null {
121
+ if (record === null) return null;
122
+ for (const value of Object.values(record)) {
123
+ if (typeof value === "string" && value.length > 0) return value;
124
+ }
125
+ return null;
126
+ }
127
+
128
+ /**
129
+ * 折叠抬头的参数摘要:**按工具取那一个可读参数**,绝不把整段 JSON 泄出来。
130
+ * `bash` → command 第一行;`read`/`edit`/`write` → path 的文件名;`rg`/`grep` → pattern;
131
+ * `compress` → 没有单一可读参数(整段对话都在入参里),留空只显示 `×N`;
132
+ * 其它工具取第一个字符串参数。取不到一律返回空串(抬头就只剩 `×N`)。
133
+ * 参数本身是裸字符串时(少数工具直接传字符串)就把它当那一个参数。
134
+ */
135
+ export function toolArgSummary(name: string, args: unknown): string {
136
+ if (name === "compress") return "";
137
+ const record = typeof args === "object" && args !== null && !Array.isArray(args)
138
+ ? (args as Record<string, unknown>)
139
+ : null;
140
+ const single = typeof args === "string" ? args : null;
141
+ const field = (key: string): string | null => {
142
+ const value = record === null ? null : record[key];
143
+ return typeof value === "string" ? value : null;
144
+ };
145
+ let picked: string | null;
146
+ if (name === "bash") picked = commandHead(field("command") ?? single);
147
+ else if (name === "read" || name === "edit" || name === "write") {
148
+ const path = field("path") ?? single;
149
+ picked = path === null ? null : basenameOf(path);
150
+ } else if (name === "rg" || name === "grep") picked = field("pattern") ?? single;
151
+ else picked = firstString(record) ?? single;
152
+ return picked === null ? "" : clip(picked, TOOL_SUMMARY);
153
+ }
154
+
155
+ /** toolResult / user 的 content:可能是字符串,也可能是 [{type:"text",text}],图片只留一句占位 */
156
+ function contentText(content: unknown): string {
157
+ if (typeof content === "string") return content;
158
+ if (!Array.isArray(content)) return "";
159
+ const parts: string[] = [];
160
+ for (const part of content) {
161
+ if (typeof part !== "object" || part === null) continue;
162
+ const record = part as Record<string, unknown>;
163
+ if (record.type === "text" && typeof record.text === "string") parts.push(record.text);
164
+ else if (record.type === "image") parts.push("(图片输出)");
165
+ }
166
+ return parts.join("\n");
167
+ }
168
+
169
+ /** 工具结果:只留前 200 字(完整输出在原理行数里,面板里看首尾就够判断成败) */
170
+ function resultText(content: unknown): string {
171
+ return clip(contentText(content), MAX_RESULT);
172
+ }
173
+
174
+ /** assistant 内容块:文本拼接、toolCall 收集;thinking 不渲染(它是推理过程,不是回答) */
175
+ function readAssistantParts(content: unknown): { text: string; calls: RawCall[] } {
176
+ if (typeof content === "string") return { text: content, calls: [] };
177
+ if (!Array.isArray(content)) return { text: "", calls: [] };
178
+ const texts: string[] = [];
179
+ const calls: RawCall[] = [];
180
+ for (const part of content) {
181
+ if (typeof part !== "object" || part === null) continue;
182
+ const record = part as Record<string, unknown>;
183
+ if (record.type === "text" && typeof record.text === "string") texts.push(record.text);
184
+ else if (record.type === "toolCall") {
185
+ const name = typeof record.name === "string" && record.name.length > 0 ? record.name : "(未命名工具)";
186
+ calls.push({
187
+ id: typeof record.id === "string" ? record.id : "",
188
+ name,
189
+ args: argumentsText(record.arguments),
190
+ summary: toolArgSummary(name, record.arguments),
191
+ });
192
+ }
193
+ }
194
+ return { text: texts.join("\n"), calls };
195
+ }
196
+
197
+ /** 解析整份 JSONL:坏行跳过(可能是进程被 kill 时写了一半的最后一行) */
198
+ export function parseSessionText(raw: string): SessionTranscript {
199
+ type Pending = { entry: SessionEntry | { kind: "tools"; calls: RawCall[]; at: number | null }; roles: string[] };
200
+ const kept: Pending[] = [];
201
+ const results = new Map<string, string>();
202
+ const stamps: number[] = [];
203
+ let messages = 0;
204
+ for (const line of raw.split(/\r?\n/)) {
205
+ if (line.trim().length === 0) continue;
206
+ let record: Record<string, unknown>;
207
+ try {
208
+ record = JSON.parse(line) as Record<string, unknown>;
209
+ } catch {
210
+ continue;
211
+ }
212
+ if (record.type !== "message") continue;
213
+ const message = record.message;
214
+ if (typeof message !== "object" || message === null) continue;
215
+ const fields = message as Record<string, unknown>;
216
+ const role = typeof fields.role === "string" ? fields.role : "";
217
+ const at = stamp(record);
218
+ messages += 1;
219
+ if (at !== null) stamps.push(at);
220
+ if (role === "toolResult") {
221
+ // 结果先存起来:它总是排在携带 toolCall 的那条 assistant 消息之后
222
+ const id = typeof fields.toolCallId === "string" ? fields.toolCallId : "";
223
+ if (id.length > 0) results.set(id, resultText(fields.content));
224
+ continue;
225
+ }
226
+ if (role === "user") {
227
+ kept.push({ entry: { kind: "user", text: contentText(fields.content), at }, roles: [role] });
228
+ continue;
229
+ }
230
+ if (role !== "assistant") continue; // system 是系统提示词,不是对话
231
+ const parts = readAssistantParts(fields.content);
232
+ if (parts.text.trim().length > 0) {
233
+ const last = kept[kept.length - 1];
234
+ // 一条 assistant 消息里「文本 + toolCall」并存:文本先出,工具另起一段,顺序与真实发生一致
235
+ if (last && last.roles[last.roles.length - 1] === "assistant-text") {
236
+ last.entry = { kind: "assistant", text: `${(last.entry as { text: string }).text}\n${parts.text}`, at };
237
+ } else {
238
+ kept.push({ entry: { kind: "assistant", text: parts.text, at }, roles: ["assistant-text"] });
239
+ }
240
+ }
241
+ if (parts.calls.length > 0) {
242
+ const last = kept[kept.length - 1];
243
+ // 连续工具调用(中间没有文本)聚成一个折叠块:一次 read 一个块会堆出上百个折叠框
244
+ if (last && last.entry.kind === "tools" && last.roles[last.roles.length - 1] !== "assistant-text") {
245
+ (last.entry.calls as RawCall[]).push(...parts.calls);
246
+ } else {
247
+ kept.push({ entry: { kind: "tools", calls: [...parts.calls], at }, roles: ["tools"] });
248
+ }
249
+ }
250
+ }
251
+ const toolCalls = kept.reduce((sum, item) => (item.entry.kind === "tools" ? sum + item.entry.calls.length : sum), 0);
252
+ const dropped = Math.max(0, kept.length - MAX_MESSAGES);
253
+ // 全文扫完再回填结果:toolResult 一定在 toolCall 之后出现
254
+ const entries: SessionEntry[] = kept.slice(dropped).map((item) =>
255
+ item.entry.kind === "tools"
256
+ ? {
257
+ kind: "tools",
258
+ at: item.entry.at,
259
+ calls: (item.entry.calls as RawCall[]).map((call) => ({
260
+ name: call.name,
261
+ args: call.args,
262
+ summary: call.summary,
263
+ result: results.get(call.id) ?? "(没有结果)",
264
+ })),
265
+ }
266
+ : item.entry,
267
+ );
268
+ return {
269
+ entries,
270
+ messages,
271
+ toolCalls,
272
+ dropped,
273
+ firstAt: stamps.length === 0 ? null : Math.min(...stamps),
274
+ lastAt: stamps.length === 0 ? null : Math.max(...stamps),
275
+ };
276
+ }