pi2web 0.2.0 → 0.2.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 (55) hide show
  1. package/README.md +209 -18
  2. package/dist/activity.js +178 -0
  3. package/dist/agent-feed.js +133 -0
  4. package/dist/agent-templates.js +144 -0
  5. package/dist/agents.js +575 -97
  6. package/dist/auth.js +85 -20
  7. package/dist/collab/baseline.js +84 -0
  8. package/dist/collab/dispatcher.js +221 -0
  9. package/dist/collab/hub.js +2888 -0
  10. package/dist/collab/pi-extension.js +494 -0
  11. package/dist/collab/review-flow.js +459 -0
  12. package/dist/collab/routes.js +285 -0
  13. package/dist/collab/schemas.js +281 -0
  14. package/dist/collab/scoring-flow.js +336 -0
  15. package/dist/collab/similarity.js +15 -0
  16. package/dist/collab/store.js +918 -0
  17. package/dist/collab/types.js +137 -0
  18. package/dist/collab/validate.js +185 -0
  19. package/dist/drafts.js +50 -0
  20. package/dist/http/agent-routes.js +196 -0
  21. package/dist/http/login-limiter.js +53 -0
  22. package/dist/http/mail-settings-routes.js +53 -0
  23. package/dist/http/system-prompt-routes.js +52 -0
  24. package/dist/http/utils.js +43 -0
  25. package/dist/http/web-assets.js +63 -0
  26. package/dist/http/workspace-routes.js +58 -0
  27. package/dist/mail-notifier.js +95 -21
  28. package/dist/main.js +141 -17
  29. package/dist/mcp.js +132 -0
  30. package/dist/metadata-store.js +199 -56
  31. package/dist/paths.js +10 -0
  32. package/dist/sdk-backend.js +288 -49
  33. package/dist/server.js +558 -358
  34. package/dist/session-namer.js +163 -0
  35. package/dist/terminals.js +94 -27
  36. package/dist/timeline.js +185 -0
  37. package/dist/voice.js +196 -40
  38. package/dist/workspaces.js +91 -26
  39. package/dist/ws/agent-socket.js +256 -0
  40. package/dist/ws/terminal-socket.js +46 -0
  41. package/npm-shrinkwrap.json +3685 -0
  42. package/package.json +18 -6
  43. package/packages/edge-tts-server/src/remote_pi_edge_tts/__pycache__/__init__.cpython-312.pyc +0 -0
  44. package/packages/edge-tts-server/src/remote_pi_edge_tts/__pycache__/__main__.cpython-312.pyc +0 -0
  45. package/packages/protocol/openapi.yaml +8 -2
  46. package/packages/protocol/websocket.schema.json +94 -10
  47. package/web/activity-view.js +121 -0
  48. package/web/app.js +3770 -404
  49. package/web/backend-state.js +269 -0
  50. package/web/collab.html +95 -0
  51. package/web/collab.js +1080 -0
  52. package/web/hid-keyboard.js +453 -0
  53. package/web/index.html +14 -9
  54. package/web/styles.css +73 -0
  55. package/ANDROID_APP_FEATURES.md +0 -256
@@ -0,0 +1,163 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import { assistantText, authHeaders, clipSummaryField, deadline, endpoint, prepareSummaryInput } from './voice.js';
3
+ // No `temperature`: current reasoning models (gpt-5.x, gpt-6-luna, mai-code-*) reject the request with
4
+ // "Unsupported parameter: 'temperature'", and naming does not need it.
5
+ export const DEFAULT_SESSION_NAMER = {
6
+ baseUrl: 'http://localhost:8313/',
7
+ model: 'gpt-5.4-nano',
8
+ language: 'zh-CN',
9
+ // Reasoning models (gpt-5.x and friends) burn this budget on hidden reasoning tokens before they
10
+ // emit the name, so a tight cap yields an empty message with finish_reason "length".
11
+ maxInputChars: 8000,
12
+ maxOutputTokens: 2000,
13
+ requestTimeoutMs: 60000
14
+ };
15
+ export function parseSessionName(text) {
16
+ const trimmed = text
17
+ .trim()
18
+ .replace(/^```(?:json)?\s*/i, '')
19
+ .replace(/\s*```$/, '');
20
+ let candidate = trimmed;
21
+ const start = trimmed.indexOf('{'), end = trimmed.lastIndexOf('}');
22
+ if (start >= 0 && end > start) {
23
+ try {
24
+ const value = JSON.parse(trimmed.slice(start, end + 1));
25
+ if (typeof value?.sessionName === 'string')
26
+ candidate = value.sessionName;
27
+ }
28
+ catch {
29
+ /* fall back to the raw text below */
30
+ }
31
+ }
32
+ const name = candidate
33
+ .replace(/[\r\n\t]+/g, ' ')
34
+ .replace(/\s{2,}/g, ' ')
35
+ .replace(/^["'“”‘’]|["'“”‘’]$/g, '')
36
+ .trim()
37
+ .slice(0, 60)
38
+ .trim();
39
+ return name || undefined;
40
+ }
41
+ export class SessionNamer {
42
+ fetcher;
43
+ emitter = new EventEmitter();
44
+ lastAssistant = new Map();
45
+ lastPrompt = new Map();
46
+ pending = new Set();
47
+ closed = false;
48
+ config;
49
+ constructor(config = {}, fetcher = fetch) {
50
+ this.fetcher = fetcher;
51
+ this.config = { apiKey: '', ...DEFAULT_SESSION_NAMER, ...strip(config) };
52
+ }
53
+ subscribe(listener) {
54
+ this.emitter.on('event', listener);
55
+ return () => this.emitter.off('event', listener);
56
+ }
57
+ close() {
58
+ this.closed = true;
59
+ this.emitter.removeAllListeners();
60
+ this.lastAssistant.clear();
61
+ this.lastPrompt.clear();
62
+ }
63
+ recordUserPrompt(agentId, prompt) {
64
+ const value = prompt.trim();
65
+ if (value)
66
+ this.lastPrompt.set(agentId, value);
67
+ }
68
+ emit(agentId, event) {
69
+ this.emitter.emit('event', agentId, event);
70
+ }
71
+ handleAgentEvent(agentId, event, context = {}) {
72
+ if (this.closed)
73
+ return;
74
+ if (event.type === 'before_agent_start' && typeof event.prompt === 'string')
75
+ return void this.recordUserPrompt(agentId, event.prompt);
76
+ if (event.type === 'agent_start') {
77
+ if (typeof event.message === 'string')
78
+ this.recordUserPrompt(agentId, event.message);
79
+ return;
80
+ }
81
+ if (event.type === 'message_end') {
82
+ const text = assistantText(event.message);
83
+ if (text)
84
+ this.lastAssistant.set(agentId, text);
85
+ return;
86
+ }
87
+ if (event.type !== 'agent_settled')
88
+ return;
89
+ const finalOutput = this.lastAssistant.get(agentId) ?? '', userPrompt = this.lastPrompt.get(agentId) ?? '';
90
+ this.lastAssistant.delete(agentId);
91
+ this.lastPrompt.delete(agentId);
92
+ if (context.sessionName?.trim() || !context.setSessionName)
93
+ return;
94
+ if (!userPrompt && !finalOutput)
95
+ return;
96
+ void this.nameSession(agentId, userPrompt, finalOutput, context.setSessionName);
97
+ }
98
+ /** Generates and applies a name. Exposed so callers can trigger naming outside the event stream. */
99
+ async nameSession(agentId, userPrompt, finalOutput, apply) {
100
+ if (this.pending.has(agentId))
101
+ return;
102
+ this.pending.add(agentId);
103
+ try {
104
+ const name = await this.requestName(userPrompt, finalOutput);
105
+ if (!name)
106
+ throw new Error('Naming endpoint returned no session name');
107
+ await apply(name);
108
+ this.emit(agentId, { type: 'session_name_generated', name });
109
+ }
110
+ catch (error) {
111
+ this.emit(agentId, { type: 'session_name_error', message: error.message });
112
+ }
113
+ finally {
114
+ this.pending.delete(agentId);
115
+ }
116
+ }
117
+ async requestName(userPrompt, finalOutput) {
118
+ const promptBudget = Math.max(1, Math.floor(this.config.maxInputChars * 0.6));
119
+ const body = JSON.stringify({
120
+ userPrompt: clipSummaryField(userPrompt, promptBudget),
121
+ piFinalOutput: prepareSummaryInput(finalOutput, Math.max(1, this.config.maxInputChars - promptBudget))
122
+ });
123
+ const response = await this.fetcher(endpoint(this.config.baseUrl, '/chat/completions'), {
124
+ method: 'POST',
125
+ signal: deadline(this.config.requestTimeoutMs),
126
+ headers: { 'content-type': 'application/json', ...authHeaders(this.config.apiKey || undefined) },
127
+ body: JSON.stringify({
128
+ model: this.config.model,
129
+ stream: false,
130
+ max_tokens: this.config.maxOutputTokens,
131
+ messages: [
132
+ { role: 'system', content: this.prompt() },
133
+ { role: 'user', content: body }
134
+ ]
135
+ })
136
+ });
137
+ if (!response.ok)
138
+ throw new Error(`Session naming request failed (${response.status}): ${(await response.text()).slice(0, 300)}`);
139
+ const value = await response.json();
140
+ const choice = value?.choices?.[0];
141
+ const content = choice?.message?.content;
142
+ const text = typeof content === 'string'
143
+ ? content
144
+ : Array.isArray(content)
145
+ ? content
146
+ .filter((part) => part?.type === 'text' && typeof part.text === 'string')
147
+ .map((part) => part.text)
148
+ .join('')
149
+ : '';
150
+ if (!text.trim())
151
+ throw new Error(choice?.finish_reason === 'length'
152
+ ? `Naming endpoint returned no content: the model hit the ${this.config.maxOutputTokens}-token output budget (raise --session-name-max-output-tokens)`
153
+ : 'Naming endpoint returned no content');
154
+ return parseSessionName(text);
155
+ }
156
+ prompt() {
157
+ const language = this.config.language.toLowerCase().startsWith('zh')
158
+ ? '简体中文'
159
+ : '与用户输入相同的主要语言';
160
+ return `你负责为一次 Pi 编程会话生成标题。\n\n输入是一个 JSON 对象:\n- userPrompt:用户本轮真正想完成的任务。\n- piFinalOutput:Pi 最后一次回复。\n\n主要依据 userPrompt 概括任务主题,必要时参考 piFinalOutput。标题使用${language}:中文 6 到 18 个字,英文 3 到 8 个词;具体、便于检索,不写“新会话”“任务总结”等空泛名称,不带句号、引号、Emoji、代码或路径。\n\n只输出一个合法 JSON 对象:{"sessionName":"简短标题"},不要输出 Markdown 代码块、解释或任何额外文字。`;
161
+ }
162
+ }
163
+ const strip = (value) => Object.fromEntries(Object.entries(value).filter(([, v]) => v !== undefined && v !== ''));
package/dist/terminals.js CHANGED
@@ -5,7 +5,7 @@ import { randomUUID } from 'node:crypto';
5
5
  import * as pty from 'node-pty';
6
6
  const defaultFactory = (cwd, cols, rows) => {
7
7
  const windows = process.platform === 'win32';
8
- const shell = windows ? (process.env.COMSPEC || 'cmd.exe') : (process.env.SHELL || '/bin/sh');
8
+ const shell = windows ? process.env.COMSPEC || 'cmd.exe' : process.env.SHELL || '/bin/sh';
9
9
  const env = Object.fromEntries(Object.entries(process.env).filter((entry) => entry[1] !== undefined));
10
10
  return pty.spawn(shell, [], { name: 'xterm-256color', cwd, cols, rows, env, useConpty: windows });
11
11
  };
@@ -23,13 +23,23 @@ export class TerminalManager {
23
23
  this.maxBuffer = maxBuffer;
24
24
  this.maxRetained = maxRetained;
25
25
  }
26
- list() { return [...this.terminals.values()].map(x => ({ ...x.record })).sort((a, b) => b.lastActiveAt.localeCompare(a.lastActiveAt)); }
27
- get(id) { const entry = this.terminals.get(id); if (!entry)
28
- throw Object.assign(new Error('Terminal not found'), { code: 'TERMINAL_NOT_FOUND' }); return entry; }
26
+ list() {
27
+ return [...this.terminals.values()]
28
+ .map(x => ({ ...x.record }))
29
+ .sort((a, b) => b.lastActiveAt.localeCompare(a.lastActiveAt));
30
+ }
31
+ get(id) {
32
+ const entry = this.terminals.get(id);
33
+ if (!entry)
34
+ throw Object.assign(new Error('Terminal not found'), { code: 'TERMINAL_NOT_FOUND' });
35
+ return entry;
36
+ }
29
37
  async create(workspaceId, relativeCwd = '.', cols = 80, rows = 24) {
30
38
  this.pruneExited();
31
39
  if (this.list().filter(x => x.status === 'running').length >= this.maxRunning)
32
- throw Object.assign(new Error(`At most ${this.maxRunning} terminals may run at once`), { code: 'TERMINAL_LIMIT' });
40
+ throw Object.assign(new Error(`At most ${this.maxRunning} terminals may run at once`), {
41
+ code: 'TERMINAL_LIMIT'
42
+ });
33
43
  const workspace = this.workspaces.get(workspaceId);
34
44
  if (!workspace)
35
45
  throw Object.assign(new Error('Workspace not found'), { code: 'WORKSPACE_NOT_FOUND' });
@@ -37,34 +47,91 @@ export class TerminalManager {
37
47
  if (!(await stat(cwd)).isDirectory())
38
48
  throw Object.assign(new Error('cwd is not a directory'), { code: 'NOT_A_DIRECTORY' });
39
49
  const terminalId = `terminal-${randomUUID()}`, now = new Date().toISOString(), process = this.factory(cwd, this.dimension(cols, 80), this.dimension(rows, 24));
40
- const record = { terminalId, workspaceId, cwd, title: path.basename(cwd) || cwd, status: 'running', createdAt: now, lastActiveAt: now };
50
+ const record = {
51
+ terminalId,
52
+ workspaceId,
53
+ cwd,
54
+ title: path.basename(cwd) || cwd,
55
+ status: 'running',
56
+ createdAt: now,
57
+ lastActiveAt: now
58
+ };
41
59
  const entry = { record, process, emitter: new EventEmitter(), buffer: '' };
42
60
  this.terminals.set(terminalId, entry);
43
- entry.dataSubscription = process.onData(data => { entry.buffer = (entry.buffer + data).slice(-this.maxBuffer); entry.record.lastActiveAt = new Date().toISOString(); entry.emitter.emit('event', { type: 'output', data }); });
44
- const exitSubscription = process.onExit(event => { entry.record.status = 'exited'; entry.record.exitCode = event.exitCode; entry.record.lastActiveAt = new Date().toISOString(); entry.emitter.emit('event', { type: 'exit', exitCode: event.exitCode }); this.disposeProcessListeners(entry); this.pruneExited(); });
61
+ entry.dataSubscription = process.onData(data => {
62
+ entry.buffer = (entry.buffer + data).slice(-this.maxBuffer);
63
+ entry.record.lastActiveAt = new Date().toISOString();
64
+ entry.emitter.emit('event', { type: 'output', data });
65
+ });
66
+ const exitSubscription = process.onExit(event => {
67
+ entry.record.status = 'exited';
68
+ entry.record.exitCode = event.exitCode;
69
+ entry.record.lastActiveAt = new Date().toISOString();
70
+ entry.emitter.emit('event', { type: 'exit', exitCode: event.exitCode });
71
+ this.disposeProcessListeners(entry);
72
+ this.pruneExited();
73
+ });
45
74
  if (entry.record.status === 'exited')
46
75
  exitSubscription.dispose();
47
76
  else
48
77
  entry.exitSubscription = exitSubscription;
49
78
  return { ...record };
50
79
  }
51
- snapshot(id) { const entry = this.get(id); return { record: { ...entry.record }, data: entry.buffer }; }
52
- write(id, data) { const entry = this.get(id); if (entry.record.status !== 'running')
53
- throw Object.assign(new Error('Terminal has exited'), { code: 'TERMINAL_EXITED' }); if (typeof data !== 'string' || data.length > 64 * 1024)
54
- throw Object.assign(new Error('Invalid terminal input'), { code: 'INVALID_REQUEST' }); entry.process.write(data); entry.record.lastActiveAt = new Date().toISOString(); }
55
- resize(id, cols, rows) { const entry = this.get(id); if (entry.record.status === 'running')
56
- entry.process.resize(this.dimension(cols, 80), this.dimension(rows, 24)); }
57
- subscribe(id, listener) { const entry = this.get(id); entry.emitter.on('event', listener); return () => entry.emitter.off('event', listener); }
58
- close(id) { const entry = this.get(id); this.terminals.delete(id); if (entry.record.status === 'running')
59
- entry.emitter.emit('event', { type: 'exit', exitCode: -1 }); this.disposeProcessListeners(entry); if (entry.record.status === 'running')
60
- try {
61
- entry.process.kill();
62
- }
63
- catch { } entry.emitter.removeAllListeners(); }
64
- closeAll() { for (const id of [...this.terminals.keys()])
65
- this.close(id); }
66
- disposeProcessListeners(entry) { entry.dataSubscription?.dispose(); entry.exitSubscription?.dispose(); entry.dataSubscription = undefined; entry.exitSubscription = undefined; }
67
- pruneExited() { const exited = [...this.terminals.entries()].reverse().filter(([, entry]) => entry.record.status === 'exited').sort((a, b) => b[1].record.lastActiveAt.localeCompare(a[1].record.lastActiveAt)); for (const [id] of exited.slice(this.maxRetained))
68
- this.close(id); }
69
- dimension(value, fallback) { return Number.isInteger(value) && value > 0 && value <= 1000 ? value : fallback; }
80
+ snapshot(id) {
81
+ const entry = this.get(id);
82
+ return { record: { ...entry.record }, data: entry.buffer };
83
+ }
84
+ write(id, data) {
85
+ const entry = this.get(id);
86
+ if (entry.record.status !== 'running')
87
+ throw Object.assign(new Error('Terminal has exited'), { code: 'TERMINAL_EXITED' });
88
+ if (typeof data !== 'string' || data.length > 64 * 1024)
89
+ throw Object.assign(new Error('Invalid terminal input'), { code: 'INVALID_REQUEST' });
90
+ entry.process.write(data);
91
+ entry.record.lastActiveAt = new Date().toISOString();
92
+ }
93
+ resize(id, cols, rows) {
94
+ const entry = this.get(id);
95
+ if (entry.record.status === 'running')
96
+ entry.process.resize(this.dimension(cols, 80), this.dimension(rows, 24));
97
+ }
98
+ subscribe(id, listener) {
99
+ const entry = this.get(id);
100
+ entry.emitter.on('event', listener);
101
+ return () => entry.emitter.off('event', listener);
102
+ }
103
+ close(id) {
104
+ const entry = this.get(id);
105
+ this.terminals.delete(id);
106
+ if (entry.record.status === 'running')
107
+ entry.emitter.emit('event', { type: 'exit', exitCode: -1 });
108
+ this.disposeProcessListeners(entry);
109
+ if (entry.record.status === 'running')
110
+ try {
111
+ entry.process.kill();
112
+ }
113
+ catch { }
114
+ entry.emitter.removeAllListeners();
115
+ }
116
+ closeAll() {
117
+ for (const id of [...this.terminals.keys()])
118
+ this.close(id);
119
+ }
120
+ disposeProcessListeners(entry) {
121
+ entry.dataSubscription?.dispose();
122
+ entry.exitSubscription?.dispose();
123
+ entry.dataSubscription = undefined;
124
+ entry.exitSubscription = undefined;
125
+ }
126
+ pruneExited() {
127
+ const exited = [...this.terminals.entries()]
128
+ .reverse()
129
+ .filter(([, entry]) => entry.record.status === 'exited')
130
+ .sort((a, b) => b[1].record.lastActiveAt.localeCompare(a[1].record.lastActiveAt));
131
+ for (const [id] of exited.slice(this.maxRetained))
132
+ this.close(id);
133
+ }
134
+ dimension(value, fallback) {
135
+ return Number.isInteger(value) && value > 0 && value <= 1000 ? value : fallback;
136
+ }
70
137
  }
@@ -0,0 +1,185 @@
1
+ import { toolSummary } from './activity.js';
2
+ /**
3
+ * Bodies of collapsed kinds (tools, thinking...) up to this many characters travel with the
4
+ * timeline; larger ones load when expanded. Tool calls and results are 90% of a session's bytes
5
+ * and rarely opened, so this is kept small.
6
+ */
7
+ export const INLINE_LIMIT = 256;
8
+ const ALWAYS_INLINE = new Set(['user', 'assistant', 'error']);
9
+ const PREVIEW_LIMIT = 160;
10
+ const memos = new WeakMap();
11
+ /** Entries never change, so anything derived from one entry object can be computed once. */
12
+ function memo(key, compute) {
13
+ if (!memos.has(key))
14
+ memos.set(key, compute());
15
+ return memos.get(key);
16
+ }
17
+ /** Text of message content: strings, text parts, and a marker per image. */
18
+ export function contentText(content) {
19
+ if (typeof content === 'string')
20
+ return content;
21
+ if (!Array.isArray(content))
22
+ return '';
23
+ return content
24
+ .map((part) => part?.type === 'text' ? String(part.text ?? '') : part?.type === 'image' ? '[image]' : '')
25
+ .filter(Boolean)
26
+ .join('\n');
27
+ }
28
+ /** A failed provider response: Pi persists it as an assistant message with stopReason "error". */
29
+ function assistantError(message) {
30
+ if (message?.stopReason !== 'error' && !message?.errorMessage)
31
+ return '';
32
+ const detail = String(message.errorMessage || 'LLM request failed').trim(), source = [message.provider, message.model].filter(Boolean).join(' · ');
33
+ return source ? `${detail}\n\n${source}` : detail;
34
+ }
35
+ const preview = (text) => {
36
+ const line = text.trim().split('\n')[0]?.trim() ?? '';
37
+ return line.length > PREVIEW_LIMIT ? `${line.slice(0, PREVIEW_LIMIT - 1)}…` : line;
38
+ };
39
+ const timeOf = (message, entry) => {
40
+ const value = Number(message?.timestamp);
41
+ if (Number.isFinite(value) && value > 0)
42
+ return value;
43
+ const parsed = Date.parse(entry.timestamp ?? '');
44
+ return Number.isFinite(parsed) ? parsed : undefined;
45
+ };
46
+ function record(item, size, body) {
47
+ const full = { ...item, size };
48
+ if (ALWAYS_INLINE.has(item.kind) || size <= INLINE_LIMIT)
49
+ full.body = body();
50
+ else if (!full.summary)
51
+ full.summary = preview(body());
52
+ for (const key of Object.keys(full))
53
+ if (full[key] === undefined)
54
+ delete full[key];
55
+ return { item: full, body };
56
+ }
57
+ const NESTED_MARK = { ok: '✓', error: '✗', unfinished: '⊘' };
58
+ /** Calls the tool made to other tools (codemode scripts, MCP batches), one line each. */
59
+ function nestedText(nested) {
60
+ const calls = Array.isArray(nested?.calls) ? nested.calls : [];
61
+ if (!calls.length)
62
+ return '';
63
+ const lines = calls.map((call) => {
64
+ const target = toolSummary(call.arguments), duration = Number.isFinite(call.durationMs) ? ` · ${call.durationMs} ms` : '';
65
+ return `${NESTED_MARK[call.status] ?? '•'} ${call.name}${target ? `: ${target}` : ''}${duration}${call.error ? ` · ${call.error}` : ''}`;
66
+ });
67
+ return `\n\n— 嵌套调用(${calls.length}${nested.complete === false ? ',不完整' : ''})—\n${lines.join('\n')}`;
68
+ }
69
+ function text(id, kind, value, item = {}) {
70
+ return record({ id, kind, ...item }, value.length, () => value);
71
+ }
72
+ /** The items of one entry, computed once per entry object. */
73
+ function entryItems(entry) {
74
+ return memo(entry, () => {
75
+ const e = entry, at = timeOf(undefined, entry);
76
+ if (entry.type === 'compaction')
77
+ return [
78
+ text(entry.id, 'compaction', String(e.summary ?? ''), {
79
+ timestamp: at,
80
+ title: '上下文已压缩',
81
+ summary: Number.isFinite(e.tokensBefore) ? `压缩前 ${e.tokensBefore} tokens` : undefined
82
+ })
83
+ ];
84
+ if (entry.type === 'branch_summary')
85
+ return [text(entry.id, 'branch_summary', String(e.summary ?? ''), { timestamp: at, title: '分支摘要' })];
86
+ if (entry.type === 'custom_message')
87
+ return e.display
88
+ ? [
89
+ text(entry.id, 'custom', contentText(e.content), {
90
+ timestamp: at,
91
+ title: String(e.customType ?? 'custom')
92
+ })
93
+ ]
94
+ : [];
95
+ if (entry.type !== 'message')
96
+ return [];
97
+ const message = e.message, role = message?.role, timestamp = timeOf(message, entry);
98
+ if (role === 'user' || role === 'system') {
99
+ const value = contentText(message.content);
100
+ return value ? [text(entry.id, role, value, { timestamp })] : [];
101
+ }
102
+ if (role === 'bashExecution')
103
+ return [
104
+ text(entry.id, 'bash', String(message.output ?? ''), {
105
+ timestamp,
106
+ title: String(message.command ?? ''),
107
+ summary: message.cancelled
108
+ ? '已取消'
109
+ : message.exitCode === undefined
110
+ ? undefined
111
+ : `exit ${message.exitCode}${message.truncated ? ' · 输出已截断' : ''}`,
112
+ status: message.cancelled ? 'cancelled' : Number(message.exitCode) > 0 ? 'error' : 'ok'
113
+ })
114
+ ];
115
+ if (role === 'toolResult')
116
+ return [
117
+ text(entry.id, 'tool_result', (contentText(message.content) || (message.isError ? '执行失败' : '执行完成')) +
118
+ nestedText(message.nestedCalls), {
119
+ timestamp,
120
+ title: String(message.toolName ?? 'tool'),
121
+ status: message.isError ? 'error' : 'ok',
122
+ durationMs: Number.isFinite(message.durationMs) ? message.durationMs : undefined,
123
+ toolCallId: String(message.toolCallId ?? '')
124
+ })
125
+ ];
126
+ if (role !== 'assistant')
127
+ return [];
128
+ const items = [];
129
+ (Array.isArray(message.content) ? message.content : []).forEach((part, index) => {
130
+ const id = `${entry.id}.${index}`;
131
+ if (part?.type === 'text' && part.text)
132
+ items.push(text(id, 'assistant', String(part.text), { timestamp }));
133
+ else if (part?.type === 'thinking' && (part.thinking || part.text))
134
+ items.push(text(id, 'thinking', String(part.thinking || part.text), { timestamp }));
135
+ else if (part?.type === 'toolCall') {
136
+ const args = part.arguments;
137
+ items.push(text(id, 'tool_call', args && typeof args === 'object' && Object.keys(args).length ? JSON.stringify(args, null, 2) : '', {
138
+ timestamp,
139
+ title: String(part.name ?? 'tool'),
140
+ summary: toolSummary(args),
141
+ toolCallId: String(part.id ?? '')
142
+ }));
143
+ }
144
+ });
145
+ const error = assistantError(message);
146
+ if (error)
147
+ items.push(text(`${entry.id}.error`, 'error', error, { timestamp }));
148
+ return items;
149
+ });
150
+ }
151
+ /** Project a branch (root to leaf) into display items. */
152
+ export function buildTimeline(entries) {
153
+ return entries.flatMap(entryItems);
154
+ }
155
+ /**
156
+ * About `limit` items before `before` (or the newest ones). A page never splits a tool call from its
157
+ * result: it starts early enough to include the calls of the results it holds. Undefined when
158
+ * `before` is not on the timeline.
159
+ */
160
+ export function pageTimeline(records, limit = 50, before) {
161
+ const size = Math.min(500, Math.max(1, Math.trunc(Number(limit)) || 50)), end = before === undefined ? records.length : records.findIndex(r => r.item.id === before);
162
+ if (end < 0)
163
+ return undefined;
164
+ let start = Math.max(0, end - size);
165
+ const callIndex = new Map();
166
+ records.slice(0, end).forEach((r, index) => {
167
+ if (r.item.kind === 'tool_call')
168
+ callIndex.set(r.item.toolCallId, index);
169
+ });
170
+ for (let i = end - 1; i >= start; i--) {
171
+ const call = records[i].item.kind === 'tool_result' ? callIndex.get(records[i].item.toolCallId) : undefined;
172
+ if (call !== undefined && call < start)
173
+ start = call; // The loop then also checks what this brought in.
174
+ }
175
+ return {
176
+ items: records.slice(start, end).map(r => r.item),
177
+ total: records.length,
178
+ start,
179
+ hasMore: start > 0
180
+ };
181
+ }
182
+ /** The full body of one item. */
183
+ export function itemBody(records, id) {
184
+ return records.find(r => r.item.id === id)?.body();
185
+ }