pi2web 0.2.1 → 0.2.8

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 (54) hide show
  1. package/README.md +53 -6
  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 +671 -152
  6. package/dist/auth.js +3 -1
  7. package/dist/collab/baseline.js +84 -0
  8. package/dist/collab/dispatcher.js +45 -10
  9. package/dist/collab/hub.js +1392 -384
  10. package/dist/collab/pi-extension.js +322 -65
  11. package/dist/collab/review-flow.js +197 -48
  12. package/dist/collab/routes.js +94 -23
  13. package/dist/collab/schemas.js +69 -13
  14. package/dist/collab/scoring-flow.js +145 -39
  15. package/dist/collab/similarity.js +15 -0
  16. package/dist/collab/store.js +467 -112
  17. package/dist/collab/types.js +94 -10
  18. package/dist/collab/validate.js +62 -25
  19. package/dist/drafts.js +26 -6
  20. package/dist/http/agent-routes.js +222 -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 +47 -0
  25. package/dist/http/web-assets.js +64 -0
  26. package/dist/http/workspace-routes.js +58 -0
  27. package/dist/mail-notifier.js +63 -17
  28. package/dist/main.js +61 -16
  29. package/dist/mcp.js +24 -5
  30. package/dist/metadata-store.js +203 -56
  31. package/dist/paths.js +10 -0
  32. package/dist/sdk-backend.js +249 -45
  33. package/dist/server.js +488 -614
  34. package/dist/session-namer.js +66 -17
  35. package/dist/session-performance.js +137 -0
  36. package/dist/sotto-stt.js +217 -0
  37. package/dist/terminals.js +94 -27
  38. package/dist/timeline.js +185 -0
  39. package/dist/voice.js +153 -35
  40. package/dist/workspaces.js +91 -26
  41. package/dist/ws/agent-socket.js +256 -0
  42. package/dist/ws/terminal-socket.js +46 -0
  43. package/npm-shrinkwrap.json +3685 -0
  44. package/package.json +11 -1
  45. package/packages/protocol/openapi.yaml +24 -2
  46. package/packages/protocol/websocket.schema.json +58 -15
  47. package/web/activity-view.js +121 -0
  48. package/web/app.js +3647 -756
  49. package/web/backend-state.js +59 -27
  50. package/web/collab.js +684 -257
  51. package/web/hid-keyboard.js +173 -140
  52. package/web/index.html +7 -2
  53. package/web/styles.css +21 -2
  54. package/web/voice-pcm.js +36 -0
@@ -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
+ }
package/dist/voice.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { EventEmitter } from 'node:events';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  const punctuation = new Set(['。', '!', '?', '!', '?', '.']);
4
- const closing = new Set(['”', '’', '"', '\'', ')', ')', '】', ']']);
4
+ const closing = new Set(['”', '’', '"', "'", ')', ')', '】', ']']);
5
5
  export const endpoint = (base, path) => `${base.replace(/\/$/, '')}${base.replace(/\/$/, '').endsWith('/v1') ? '' : '/v1'}${path}`;
6
6
  export const authHeaders = (key) => key ? { authorization: `Bearer ${key}` } : {};
7
7
  const abortError = (error) => error instanceof Error && error.name === 'AbortError';
@@ -19,7 +19,11 @@ export function assistantText(message) {
19
19
  if (typeof value.content === 'string')
20
20
  return value.content.trim() || undefined;
21
21
  if (Array.isArray(value.content)) {
22
- const text = value.content.filter((part) => part?.type === 'text' && typeof part.text === 'string').map((part) => part.text).join('\n').trim();
22
+ const text = value.content
23
+ .filter((part) => part?.type === 'text' && typeof part.text === 'string')
24
+ .map((part) => part.text)
25
+ .join('\n')
26
+ .trim();
23
27
  return text || undefined;
24
28
  }
25
29
  }
@@ -43,7 +47,10 @@ export function prepareSummaryInput(text, maxChars = 32000) {
43
47
  return cleaned;
44
48
  }
45
49
  export function clipSummaryField(text, maxChars) {
46
- const normalized = text.replace(/[ \t]+/g, ' ').replace(/\n{3,}/g, '\n\n').trim();
50
+ const normalized = text
51
+ .replace(/[ \t]+/g, ' ')
52
+ .replace(/\n{3,}/g, '\n\n')
53
+ .trim();
47
54
  if (normalized.length <= maxChars)
48
55
  return normalized;
49
56
  const head = Math.floor(maxChars * 0.375), tail = maxChars - head;
@@ -78,14 +85,24 @@ export function parseSummaryResult(text) {
78
85
  if (!summary)
79
86
  throw new Error('Summary endpoint returned no summary');
80
87
  const rawName = typeof value?.sessionName === 'string' ? value.sessionName.trim() : '';
81
- const sessionName = rawName.replace(/[\r\n\t]+/g, ' ').replace(/\s{2,}/g, ' ').slice(0, 60).trim();
88
+ const sessionName = rawName
89
+ .replace(/[\r\n\t]+/g, ' ')
90
+ .replace(/\s{2,}/g, ' ')
91
+ .slice(0, 60)
92
+ .trim();
82
93
  return { summary, ...(sessionName ? { sessionName } : {}) };
83
94
  }
84
95
  export function formatSpokenSummary(summary, sessionName, language = 'zh-CN') {
85
- const name = sessionName?.replace(/[\r\n\t]+/g, ' ').replace(/\s{2,}/g, ' ').slice(0, 60).trim();
96
+ const name = sessionName
97
+ ?.replace(/[\r\n\t]+/g, ' ')
98
+ .replace(/\s{2,}/g, ' ')
99
+ .slice(0, 60)
100
+ .trim();
86
101
  if (!name)
87
102
  return summary;
88
- return language.toLowerCase().startsWith('zh') ? `会话${name}已完成:${summary}` : `Session ${name} completed: ${summary}`;
103
+ return language.toLowerCase().startsWith('zh')
104
+ ? `会话${name}已完成:${summary}`
105
+ : `Session ${name} completed: ${summary}`;
89
106
  }
90
107
  export class SentenceChunker {
91
108
  maxChars;
@@ -93,8 +110,13 @@ export class SentenceChunker {
93
110
  constructor(maxChars = 100) {
94
111
  this.maxChars = maxChars;
95
112
  }
96
- add(delta) { this.buffer += delta; return this.consume(false); }
97
- close() { return this.consume(true); }
113
+ add(delta) {
114
+ this.buffer += delta;
115
+ return this.consume(false);
116
+ }
117
+ close() {
118
+ return this.consume(true);
119
+ }
98
120
  consume(final) {
99
121
  const chunks = [];
100
122
  while (this.buffer) {
@@ -174,13 +196,30 @@ export class VoiceManager {
174
196
  config;
175
197
  constructor(config, fetcher = fetch) {
176
198
  this.fetcher = fetcher;
177
- this.config = { language: 'zh-CN', maxInputChars: 32000, sampleRate: 24000, ttsFormat: 'pcm', requestTimeoutMs: 120000, ...config };
199
+ this.config = {
200
+ language: 'zh-CN',
201
+ maxInputChars: 32000,
202
+ sampleRate: 24000,
203
+ ttsFormat: 'pcm',
204
+ requestTimeoutMs: 120000,
205
+ ...config
206
+ };
207
+ }
208
+ subscribe(listener) {
209
+ this.emitter.on('event', listener);
210
+ return () => this.emitter.off('event', listener);
211
+ }
212
+ capabilities() {
213
+ return { tts: true, stt: !!this.config.sttModel };
214
+ }
215
+ recordUserPrompt(agentId, prompt) {
216
+ const value = prompt.trim();
217
+ if (value)
218
+ this.lastPrompt.set(agentId, value);
219
+ }
220
+ emit(agentId, event) {
221
+ this.emitter.emit('event', agentId, event);
178
222
  }
179
- subscribe(listener) { this.emitter.on('event', listener); return () => this.emitter.off('event', listener); }
180
- capabilities() { return { tts: true, stt: !!this.config.sttModel }; }
181
- recordUserPrompt(agentId, prompt) { const value = prompt.trim(); if (value)
182
- this.lastPrompt.set(agentId, value); }
183
- emit(agentId, event) { this.emitter.emit('event', agentId, event); }
184
223
  handleAgentEvent(agentId, event, context = {}) {
185
224
  if (event.type === 'before_agent_start' && typeof event.prompt === 'string') {
186
225
  this.lastPrompt.set(agentId, event.prompt);
@@ -207,13 +246,21 @@ export class VoiceManager {
207
246
  void this.announce(agentId, { userPrompt, finalOutput, ...context });
208
247
  }
209
248
  }
210
- cancel(agentId) { this.generations.set(agentId, (this.generations.get(agentId) ?? 0) + 1); const controller = this.active.get(agentId); if (controller) {
211
- controller.abort();
212
- this.active.delete(agentId);
213
- this.emit(agentId, { type: 'voice_cancelled' });
214
- } }
215
- close() { this.closed = true; for (const id of [...new Set([...this.active.keys(), ...this.generations.keys()])])
216
- this.cancel(id); this.emitter.removeAllListeners(); }
249
+ cancel(agentId) {
250
+ this.generations.set(agentId, (this.generations.get(agentId) ?? 0) + 1);
251
+ const controller = this.active.get(agentId);
252
+ if (controller) {
253
+ controller.abort();
254
+ this.active.delete(agentId);
255
+ this.emit(agentId, { type: 'voice_cancelled' });
256
+ }
257
+ }
258
+ close() {
259
+ this.closed = true;
260
+ for (const id of [...new Set([...this.active.keys(), ...this.generations.keys()])])
261
+ this.cancel(id);
262
+ this.emitter.removeAllListeners();
263
+ }
217
264
  async transcribe(audio, mimeType = 'audio/webm', filename = 'recording.webm') {
218
265
  if (!this.config.sttModel)
219
266
  throw Object.assign(new Error('Voice STT model is not configured'), { code: 'VOICE_STT_DISABLED' });
@@ -223,7 +270,16 @@ export class VoiceManager {
223
270
  form.append('file', new Blob([copy.buffer], { type: mimeType }), filename);
224
271
  form.append('model', this.config.sttModel);
225
272
  form.append('response_format', 'json');
226
- const response = await this.fetcher(endpoint(this.config.speechBaseUrl, '/audio/transcriptions'), { method: 'POST', signal: deadline(this.config.requestTimeoutMs), headers: authHeaders(this.config.speechApiKey), body: form }).catch(error => { throw timeoutError(error) ? Object.assign(new Error(`Speech transcription timed out after ${this.config.requestTimeoutMs} ms`), { code: 'VOICE_STT_FAILED' }) : error; });
273
+ const response = await this.fetcher(endpoint(this.config.speechBaseUrl, '/audio/transcriptions'), {
274
+ method: 'POST',
275
+ signal: deadline(this.config.requestTimeoutMs),
276
+ headers: authHeaders(this.config.speechApiKey),
277
+ body: form
278
+ }).catch(error => {
279
+ throw timeoutError(error)
280
+ ? Object.assign(new Error(`Speech transcription timed out after ${this.config.requestTimeoutMs} ms`), { code: 'VOICE_STT_FAILED' })
281
+ : error;
282
+ });
227
283
  if (!response.ok)
228
284
  throw Object.assign(new Error(`Speech transcription failed (${response.status}): ${(await response.text()).slice(0, 500)}`), { code: 'VOICE_STT_FAILED' });
229
285
  const value = await response.json();
@@ -236,14 +292,22 @@ export class VoiceManager {
236
292
  const input = typeof value === 'string' ? { userPrompt: '', finalOutput: value } : value;
237
293
  this.cancel(agentId);
238
294
  const generation = this.generations.get(agentId);
239
- const job = this.announcementQueue.then(() => this.closed || this.generations.get(agentId) !== generation ? undefined : this.runAnnouncement(agentId, input, generation));
295
+ const job = this.announcementQueue.then(() => this.closed || this.generations.get(agentId) !== generation
296
+ ? undefined
297
+ : this.runAnnouncement(agentId, input, generation));
240
298
  this.announcementQueue = job.then(() => undefined, () => undefined);
241
299
  return job;
242
300
  }
243
301
  async runAnnouncement(agentId, input, generation) {
244
302
  const controller = new AbortController(), playbackId = randomUUID();
245
303
  this.active.set(agentId, controller);
246
- this.emit(agentId, { type: 'voice_start', playbackId, encoding: this.config.ttsFormat === 'mp3' ? 'mp3' : 'pcm_s16le', sampleRate: this.config.sampleRate, channels: 1 });
304
+ this.emit(agentId, {
305
+ type: 'voice_start',
306
+ playbackId,
307
+ encoding: this.config.ttsFormat === 'mp3' ? 'mp3' : 'pcm_s16le',
308
+ sampleRate: this.config.sampleRate,
309
+ channels: 1
310
+ });
247
311
  let rawResult = '', spokenSummary = '';
248
312
  try {
249
313
  const summaryInput = prepareSummaryContext(input, this.config.maxInputChars);
@@ -257,7 +321,11 @@ export class VoiceManager {
257
321
  await input.setSessionName(result.sessionName);
258
322
  }
259
323
  catch (error) {
260
- this.emit(agentId, { type: 'voice_session_name_error', playbackId, message: error.message });
324
+ this.emit(agentId, {
325
+ type: 'voice_session_name_error',
326
+ playbackId,
327
+ message: error.message
328
+ });
261
329
  }
262
330
  }
263
331
  this.emit(agentId, { type: 'voice_summary_delta', playbackId, text: spokenSummary });
@@ -265,19 +333,28 @@ export class VoiceManager {
265
333
  for (const sentence of [...chunker.add(spokenSummary), ...chunker.close()])
266
334
  await this.speak(agentId, playbackId, sentence, controller.signal);
267
335
  if (this.active.get(agentId) === controller)
268
- this.emit(agentId, { type: 'voice_end', playbackId, summary: spokenSummary, sessionName: result.sessionName });
336
+ this.emit(agentId, {
337
+ type: 'voice_end',
338
+ playbackId,
339
+ summary: spokenSummary,
340
+ sessionName: result.sessionName
341
+ });
269
342
  }
270
343
  catch (error) {
271
344
  if (!abortError(error) && this.active.get(agentId) === controller) {
272
345
  this.emit(agentId, { type: 'voice_error', playbackId, message: error.message });
273
- const fallback = this.config.language.toLowerCase().startsWith('zh') ? 'Agent 已完成,等待输入。' : 'The agent has finished and is ready for input.';
346
+ const fallback = this.config.language.toLowerCase().startsWith('zh')
347
+ ? 'Agent 已完成,等待输入。'
348
+ : 'The agent has finished and is ready for input.';
274
349
  if (!spokenSummary)
275
350
  try {
276
351
  this.emit(agentId, { type: 'voice_summary_delta', playbackId, text: fallback });
277
352
  await this.speak(agentId, playbackId, fallback, controller.signal);
278
353
  this.emit(agentId, { type: 'voice_end', playbackId, summary: fallback });
279
354
  }
280
- catch { /* The text notification remains available when TTS also fails. */ }
355
+ catch {
356
+ /* The text notification remains available when TTS also fails. */
357
+ }
281
358
  else
282
359
  this.emit(agentId, { type: 'voice_end', playbackId, summary: spokenSummary, partial: true });
283
360
  }
@@ -292,12 +369,24 @@ export class VoiceManager {
292
369
  /** Retries once per unsupported parameter so a strict model still produces a summary instead of failing the announcement. */
293
370
  async requestSummary(summaryInput, signal) {
294
371
  for (let attempt = 0;; attempt++) {
295
- const body = { model: this.config.summaryModel, stream: true, messages: [{ role: 'system', content: this.summaryPrompt() }, { role: 'user', content: summaryInput }] };
372
+ const body = {
373
+ model: this.config.summaryModel,
374
+ stream: true,
375
+ messages: [
376
+ { role: 'system', content: this.summaryPrompt() },
377
+ { role: 'user', content: summaryInput }
378
+ ]
379
+ };
296
380
  if (this.summaryParams.temperature && typeof this.config.summaryTemperature === 'number')
297
381
  body.temperature = this.config.summaryTemperature;
298
382
  if (this.summaryParams.tokenLimitField && this.config.maxOutputTokens)
299
383
  body[this.summaryParams.tokenLimitField] = this.config.maxOutputTokens;
300
- const response = await this.fetcher(endpoint(this.config.summaryBaseUrl, '/chat/completions'), { method: 'POST', signal: deadline(this.config.requestTimeoutMs, signal), headers: { 'content-type': 'application/json', ...authHeaders(this.config.summaryApiKey) }, body: JSON.stringify(body) });
384
+ const response = await this.fetcher(endpoint(this.config.summaryBaseUrl, '/chat/completions'), {
385
+ method: 'POST',
386
+ signal: deadline(this.config.requestTimeoutMs, signal),
387
+ headers: { 'content-type': 'application/json', ...authHeaders(this.config.summaryApiKey) },
388
+ body: JSON.stringify(body)
389
+ });
301
390
  if (response.ok)
302
391
  return response;
303
392
  const detail = (await response.text()).slice(0, 500);
@@ -317,7 +406,9 @@ export class VoiceManager {
317
406
  }
318
407
  const field = this.summaryParams.tokenLimitField;
319
408
  if (field === 'max_tokens' && text.includes('max_tokens')) {
320
- this.summaryParams.tokenLimitField = text.includes('max_completion_tokens') ? 'max_completion_tokens' : null;
409
+ this.summaryParams.tokenLimitField = text.includes('max_completion_tokens')
410
+ ? 'max_completion_tokens'
411
+ : null;
321
412
  changed = true;
322
413
  }
323
414
  else if (field === 'max_completion_tokens' && text.includes('max_completion_tokens')) {
@@ -326,16 +417,38 @@ export class VoiceManager {
326
417
  }
327
418
  return changed;
328
419
  }
329
- summaryPrompt() { const language = this.config.language.toLowerCase().startsWith('zh') ? '简体中文' : '与用户输入相同的主要语言'; return `你负责把一次 Pi 编程任务压缩成准确、自然、可直接朗读的完成通知,并在需要时为 Session 命名。\n\n输入是一个 JSON 对象:\n- userPrompt:用户本轮真正想完成的任务。\n- piFinalOutput:Pi 最后一次回复,包含完成情况、改动、验证结果和后续事项。\n- sessionNeedsName:只有为 true 时才需要生成 Session 名称。\n\n先结合 userPrompt 判断目标,再以 piFinalOutput 为事实依据总结。不要把用户的要求误说成已经完成;没有明确证据时不要声称测试通过或任务成功。摘要使用${language},写 2 到 4 个短句,最多 120 个中文字符或 70 个英文单词。优先交代:是否完成、最重要的结果或修改、测试/验证结果、失败原因,以及用户必须采取的下一步。省略代码、命令、URL、完整路径、哈希、冗长文件清单、日志和 Markdown;不要使用标题、列表、“总结如下”等套话。\n\n如果 sessionNeedsName 为 true,请根据 userPrompt 生成一个具体、简短、便于检索的名称:中文建议 6 到 18 个字,英文建议 3 到 8 个词;使用任务主题或目标,不写“新会话”“任务总结”等空泛名称,不带句号、引号、Emoji 或路径。如果 sessionNeedsName 为 false,sessionName 必须为 null。\n\n只输出一个合法 JSON 对象,不要输出 Markdown 代码块、解释或任何额外文字。有名称时使用 {"summary":"适合直接朗读的摘要","sessionName":"简短名称"};无需命名时使用 {"summary":"适合直接朗读的摘要","sessionName":null}。summary 必须是 JSON 字符串,sessionName 必须是 JSON 字符串或 null。`; }
420
+ summaryPrompt() {
421
+ const language = this.config.language.toLowerCase().startsWith('zh')
422
+ ? '简体中文'
423
+ : '与用户输入相同的主要语言';
424
+ return `你负责把一次 Pi 编程任务压缩成准确、自然、可直接朗读的完成通知,并在需要时为 Session 命名。\n\n输入是一个 JSON 对象:\n- userPrompt:用户本轮真正想完成的任务。\n- piFinalOutput:Pi 最后一次回复,包含完成情况、改动、验证结果和后续事项。\n- sessionNeedsName:只有为 true 时才需要生成 Session 名称。\n\n先结合 userPrompt 判断目标,再以 piFinalOutput 为事实依据总结。不要把用户的要求误说成已经完成;没有明确证据时不要声称测试通过或任务成功。摘要使用${language},写 2 到 4 个短句,最多 120 个中文字符或 70 个英文单词。优先交代:是否完成、最重要的结果或修改、测试/验证结果、失败原因,以及用户必须采取的下一步。省略代码、命令、URL、完整路径、哈希、冗长文件清单、日志和 Markdown;不要使用标题、列表、“总结如下”等套话。\n\n如果 sessionNeedsName 为 true,请根据 userPrompt 生成一个具体、简短、便于检索的名称:中文建议 6 到 18 个字,英文建议 3 到 8 个词;使用任务主题或目标,不写“新会话”“任务总结”等空泛名称,不带句号、引号、Emoji 或路径。如果 sessionNeedsName 为 false,sessionName 必须为 null。\n\n只输出一个合法 JSON 对象,不要输出 Markdown 代码块、解释或任何额外文字。有名称时使用 {"summary":"适合直接朗读的摘要","sessionName":"简短名称"};无需命名时使用 {"summary":"适合直接朗读的摘要","sessionName":null}。summary 必须是 JSON 字符串,sessionName 必须是 JSON 字符串或 null。`;
425
+ }
330
426
  async speak(agentId, playbackId, text, signal) {
331
- const response = await this.fetcher(endpoint(this.config.speechBaseUrl, '/audio/speech'), { method: 'POST', signal: deadline(this.config.requestTimeoutMs, signal), headers: { 'content-type': 'application/json', ...authHeaders(this.config.speechApiKey) }, body: JSON.stringify({ model: this.config.ttsModel, voice: this.config.ttsVoice, input: text, response_format: this.config.ttsFormat, sample_rate: this.config.sampleRate, stream_format: 'audio' }) });
427
+ const response = await this.fetcher(endpoint(this.config.speechBaseUrl, '/audio/speech'), {
428
+ method: 'POST',
429
+ signal: deadline(this.config.requestTimeoutMs, signal),
430
+ headers: { 'content-type': 'application/json', ...authHeaders(this.config.speechApiKey) },
431
+ body: JSON.stringify({
432
+ model: this.config.ttsModel,
433
+ voice: this.config.ttsVoice,
434
+ input: text,
435
+ response_format: this.config.ttsFormat,
436
+ sample_rate: this.config.sampleRate,
437
+ stream_format: 'audio'
438
+ })
439
+ });
332
440
  if (!response.ok)
333
441
  throw new Error(`Speech synthesis failed (${response.status}): ${(await response.text()).slice(0, 500)}`);
334
442
  if (this.config.ttsFormat === 'mp3') {
335
443
  const audio = Buffer.from(await response.arrayBuffer());
336
444
  if (!audio.length)
337
445
  throw new Error('Speech synthesis returned no audio');
338
- this.emit(agentId, { type: 'voice_audio_chunk', playbackId, encoding: 'mp3', audio: audio.toString('base64') });
446
+ this.emit(agentId, {
447
+ type: 'voice_audio_chunk',
448
+ playbackId,
449
+ encoding: 'mp3',
450
+ audio: audio.toString('base64')
451
+ });
339
452
  return;
340
453
  }
341
454
  if (!response.body)
@@ -351,7 +464,12 @@ export class VoiceManager {
351
464
  const bytes = pending ? Buffer.concat([pending, Buffer.from(value)]) : Buffer.from(value);
352
465
  const evenLength = bytes.length & ~1;
353
466
  if (evenLength)
354
- this.emit(agentId, { type: 'voice_audio_chunk', playbackId, sampleRate: this.config.sampleRate, audio: bytes.subarray(0, evenLength).toString('base64') });
467
+ this.emit(agentId, {
468
+ type: 'voice_audio_chunk',
469
+ playbackId,
470
+ sampleRate: this.config.sampleRate,
471
+ audio: bytes.subarray(0, evenLength).toString('base64')
472
+ });
355
473
  pending = evenLength < bytes.length ? Buffer.from(bytes.subarray(evenLength)) : undefined;
356
474
  }
357
475
  if (pending)
@@ -7,31 +7,88 @@ export class WorkspaceStore {
7
7
  constructor(items = []) {
8
8
  this.items = items;
9
9
  }
10
- list() { return this.items.map(x => ({ ...x })); }
11
- replace(items) { this.items = items.map(item => ({ ...item, rootPath: path.resolve(item.rootPath) })); }
12
- add(label, rootPath) { const root = path.resolve(rootPath); let rootStat; try {
13
- rootStat = statSync(root);
14
- }
15
- catch (error) {
16
- if (error.code === 'ENOENT' || error.code === 'ENOTDIR')
17
- throw Object.assign(new Error('Workspace root does not exist'), { code: 'WORKSPACE_ROOT_NOT_FOUND' });
18
- throw error;
19
- } if (!rootStat.isDirectory())
20
- throw Object.assign(new Error('Workspace root is not a directory'), { code: 'WORKSPACE_ROOT_NOT_DIRECTORY' }); const key = process.platform === 'win32' ? root.toLowerCase() : root; if (this.items.some(item => (process.platform === 'win32' ? item.rootPath.toLowerCase() : item.rootPath) === key))
21
- throw Object.assign(new Error('Workspace root already exists'), { code: 'WORKSPACE_ALREADY_EXISTS' }); const item = { id: randomUUID(), label, rootPath: root, createdAt: new Date().toISOString() }; this.items.push(item); return item; }
22
- get(id) { return this.items.find(x => x.id === id); }
23
- async resolve(ws, relative = '.') { const candidate = path.resolve(ws.rootPath, relative); const root = await realpath(ws.rootPath); let actual; try {
24
- actual = await realpath(candidate);
25
- }
26
- catch {
27
- throw Object.assign(new Error('Path does not exist'), { code: 'PATH_NOT_FOUND' });
28
- } if (actual !== root && !actual.startsWith(root + path.sep))
29
- throw Object.assign(new Error('Path is outside the workspace root'), { code: 'WORKSPACE_PATH_OUTSIDE_ROOT' }); return actual; }
30
- async tree(id, relative = '.') { const ws = this.get(id); if (!ws)
31
- throw Object.assign(new Error('Workspace not found'), { code: 'WORKSPACE_NOT_FOUND' }); const dir = await this.resolve(ws, relative); if (!(await stat(dir)).isDirectory())
32
- throw new Error('Not a directory'); const entries = await readdir(dir, { withFileTypes: true }); return Promise.all(entries.sort((a, b) => Number(b.isDirectory()) - Number(a.isDirectory()) || a.name.localeCompare(b.name)).map(async (e) => { const p = path.join(dir, e.name); const s = await lstat(p); return { name: e.name, type: e.isDirectory() ? 'directory' : 'file', size: s.size, modifiedAt: s.mtime.toISOString() }; })); }
33
- async stat(id, relative = '.') { const ws = this.get(id); if (!ws)
34
- throw Object.assign(new Error('Workspace not found'), { code: 'WORKSPACE_NOT_FOUND' }); const p = await this.resolve(ws, relative); const s = await lstat(p); return { path: relative, type: s.isDirectory() ? 'directory' : s.isFile() ? 'file' : 'other', size: s.size, modifiedAt: s.mtime.toISOString() }; }
10
+ list() {
11
+ return this.items.map(x => ({ ...x }));
12
+ }
13
+ replace(items) {
14
+ this.items = items.map(item => ({ ...item, rootPath: path.resolve(item.rootPath) }));
15
+ }
16
+ add(label, rootPath) {
17
+ const root = path.resolve(rootPath);
18
+ let rootStat;
19
+ try {
20
+ rootStat = statSync(root);
21
+ }
22
+ catch (error) {
23
+ if (error.code === 'ENOENT' ||
24
+ error.code === 'ENOTDIR')
25
+ throw Object.assign(new Error('Workspace root does not exist'), { code: 'WORKSPACE_ROOT_NOT_FOUND' });
26
+ throw error;
27
+ }
28
+ if (!rootStat.isDirectory())
29
+ throw Object.assign(new Error('Workspace root is not a directory'), {
30
+ code: 'WORKSPACE_ROOT_NOT_DIRECTORY'
31
+ });
32
+ const key = process.platform === 'win32' ? root.toLowerCase() : root;
33
+ if (this.items.some(item => (process.platform === 'win32' ? item.rootPath.toLowerCase() : item.rootPath) === key))
34
+ throw Object.assign(new Error('Workspace root already exists'), { code: 'WORKSPACE_ALREADY_EXISTS' });
35
+ const item = { id: randomUUID(), label, rootPath: root, createdAt: new Date().toISOString() };
36
+ this.items.push(item);
37
+ return item;
38
+ }
39
+ get(id) {
40
+ return this.items.find(x => x.id === id);
41
+ }
42
+ async resolve(ws, relative = '.') {
43
+ const candidate = path.resolve(ws.rootPath, relative);
44
+ const root = await realpath(ws.rootPath);
45
+ let actual;
46
+ try {
47
+ actual = await realpath(candidate);
48
+ }
49
+ catch {
50
+ throw Object.assign(new Error('Path does not exist'), { code: 'PATH_NOT_FOUND' });
51
+ }
52
+ if (actual !== root && !actual.startsWith(root + path.sep))
53
+ throw Object.assign(new Error('Path is outside the workspace root'), {
54
+ code: 'WORKSPACE_PATH_OUTSIDE_ROOT'
55
+ });
56
+ return actual;
57
+ }
58
+ async tree(id, relative = '.') {
59
+ const ws = this.get(id);
60
+ if (!ws)
61
+ throw Object.assign(new Error('Workspace not found'), { code: 'WORKSPACE_NOT_FOUND' });
62
+ const dir = await this.resolve(ws, relative);
63
+ if (!(await stat(dir)).isDirectory())
64
+ throw new Error('Not a directory');
65
+ const entries = await readdir(dir, { withFileTypes: true });
66
+ return Promise.all(entries
67
+ .sort((a, b) => Number(b.isDirectory()) - Number(a.isDirectory()) || a.name.localeCompare(b.name))
68
+ .map(async (e) => {
69
+ const p = path.join(dir, e.name);
70
+ const s = await lstat(p);
71
+ return {
72
+ name: e.name,
73
+ type: e.isDirectory() ? 'directory' : 'file',
74
+ size: s.size,
75
+ modifiedAt: s.mtime.toISOString()
76
+ };
77
+ }));
78
+ }
79
+ async stat(id, relative = '.') {
80
+ const ws = this.get(id);
81
+ if (!ws)
82
+ throw Object.assign(new Error('Workspace not found'), { code: 'WORKSPACE_NOT_FOUND' });
83
+ const p = await this.resolve(ws, relative);
84
+ const s = await lstat(p);
85
+ return {
86
+ path: relative,
87
+ type: s.isDirectory() ? 'directory' : s.isFile() ? 'file' : 'other',
88
+ size: s.size,
89
+ modifiedAt: s.mtime.toISOString()
90
+ };
91
+ }
35
92
  async file(id, relative, offset = 0, limit = 1024 * 1024) {
36
93
  const ws = this.get(id);
37
94
  if (!ws)
@@ -55,6 +112,14 @@ export class WorkspaceStore {
55
112
  await handle.close();
56
113
  }
57
114
  const binary = chunk.includes(0);
58
- return { path: relative, size: s.size, modifiedAt: s.mtime.toISOString(), binary, content: binary ? undefined : chunk.toString('utf8'), offset, limit: chunk.length };
115
+ return {
116
+ path: relative,
117
+ size: s.size,
118
+ modifiedAt: s.mtime.toISOString(),
119
+ binary,
120
+ content: binary ? undefined : chunk.toString('utf8'),
121
+ offset,
122
+ limit: chunk.length
123
+ };
59
124
  }
60
125
  }