pi2web 0.2.6 → 0.2.12

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.
@@ -81,6 +81,8 @@ export async function handleAgentRoutes(ctx, req, res, u, parts) {
81
81
  return send(200, {
82
82
  data: await ctx.agents.timeline(id, Number(u.searchParams.get('limit') ?? 50), u.searchParams.get('before') ?? undefined)
83
83
  });
84
+ if (req.method === 'GET' && parts[4] === 'performance' && parts.length === 5)
85
+ return send(200, { data: await ctx.agents.performance(id) });
84
86
  // /agents/:id/sessions/:sessionId/items/:itemId — items never change, and the session is part of the
85
87
  // URL so a cached body is never served for another one: let the browser keep it for good.
86
88
  if (req.method === 'GET' && parts[4] === 'sessions' && parts[6] === 'items' && parts.length === 8) {
@@ -90,6 +92,14 @@ export async function handleAgentRoutes(ctx, req, res, u, parts) {
90
92
  }
91
93
  if (req.method === 'GET' && parts[4] === 'system-prompt')
92
94
  return send(200, { data: await ctx.agents.systemPrompt(id) });
95
+ if (req.method === 'POST' && parts[4] === 'system-prompt') {
96
+ const b = await ctx.body();
97
+ if (b.templateId !== undefined && typeof b.templateId !== 'string')
98
+ throw Object.assign(new Error('templateId must be a string'), { code: 'INVALID_REQUEST' });
99
+ const template = b.templateId ? ctx.templates.snapshot(b.templateId) : undefined;
100
+ const record = await ctx.agents.setTemplate(id, template, candidate => ctx.metadata.commitAgentTemplate(candidate));
101
+ return send(200, { data: { template: record.template ?? null } });
102
+ }
93
103
  if (req.method === 'GET' && parts[4] === 'capabilities')
94
104
  return send(200, { data: await ctx.agents.capabilities(id) });
95
105
  if (req.method === 'GET' && parts[4] === 'session')
@@ -145,6 +155,14 @@ export async function handleAgentRoutes(ctx, req, res, u, parts) {
145
155
  ctx.metadata.scheduleAgent(ctx.agents.get(id).record);
146
156
  return send(200, { data: result });
147
157
  }
158
+ // Safe pause: POST asks the running turn to stop before its next model request; DELETE withdraws that.
159
+ if (parts[4] === 'pause' && (req.method === 'POST' || req.method === 'DELETE')) {
160
+ if (req.method === 'POST')
161
+ await ctx.agents.requestPause(id);
162
+ else
163
+ await ctx.agents.cancelPause(id);
164
+ return send(200, { data: { success: true } });
165
+ }
148
166
  if (req.method === 'POST' && parts[4] === 'bash-abort') {
149
167
  await ctx.agents.abortBash(id);
150
168
  return send(200, { data: { aborted: true } });
@@ -154,6 +172,15 @@ export async function handleAgentRoutes(ctx, req, res, u, parts) {
154
172
  return send(200, { data: await ctx.agents.queue(id) });
155
173
  if (req.method === 'DELETE')
156
174
  return send(200, { data: await ctx.agents.clearQueue(id) });
175
+ if (req.method === 'POST' && parts[5] === 'move') {
176
+ const { from, index, expected } = await ctx.body();
177
+ if ((from !== 'steering' && from !== 'followUp') ||
178
+ !Number.isSafeInteger(index) ||
179
+ index < 0 ||
180
+ typeof expected !== 'string')
181
+ throw Object.assign(new Error('from, index and expected are required'), { code: 'INVALID_REQUEST' });
182
+ return send(200, { data: await ctx.agents.moveQueue(id, from, index, expected) });
183
+ }
157
184
  }
158
185
  if (parts[4] === 'mcp') {
159
186
  const cwd = ctx.agents.get(id).record.cwd;
@@ -172,10 +199,17 @@ export async function handleAgentRoutes(ctx, req, res, u, parts) {
172
199
  return send(200, { data: { success: true } });
173
200
  }
174
201
  if (req.method === 'POST' && parts[4] === 'transcribe') {
202
+ ctx.agents.get(id);
203
+ const mime = String(req.headers['content-type'] ?? 'audio/webm').split(';')[0];
204
+ if (ctx.sottoStt.getSettings().enabled) {
205
+ if (mime !== 'application/octet-stream')
206
+ throw Object.assign(new Error('Sotto requires 16 kHz float32 PCM'), { code: 'INVALID_REQUEST' });
207
+ const audio = await ctx.binaryBody();
208
+ return send(200, { data: await ctx.sottoStt.transcribe(audio) });
209
+ }
175
210
  if (!ctx.voice)
176
211
  throw Object.assign(new Error('Voice service is disabled'), { code: 'VOICE_DISABLED' });
177
- ctx.agents.get(id);
178
- const audio = await ctx.binaryBody(), mime = String(req.headers['content-type'] ?? 'audio/webm').split(';')[0], filename = String(req.headers['x-audio-filename'] ?? 'recording.webm');
212
+ const audio = await ctx.binaryBody(), filename = String(req.headers['x-audio-filename'] ?? 'recording.webm');
179
213
  return send(200, { data: await ctx.voice.transcribe(audio, mime, filename) });
180
214
  }
181
215
  if (req.method === 'POST' && ['prompt', 'steer', 'follow-up', 'abort'].includes(parts[4])) {
@@ -184,7 +218,7 @@ export async function handleAgentRoutes(ctx, req, res, u, parts) {
184
218
  ctx.voice?.recordUserPrompt(id, String(b.message ?? ''));
185
219
  ctx.sessionNamer?.recordUserPrompt(id, String(b.message ?? ''));
186
220
  }
187
- const disposition = await ctx.agents.command(id, parts[4], b.message ?? '');
221
+ const disposition = await ctx.agents.submitCommand(id, parts[4], b.message ?? '');
188
222
  const record = ctx.agents.get(id).record;
189
223
  ctx.metadata.scheduleAgent(record);
190
224
  if (parts[4] === 'prompt')
@@ -35,7 +35,11 @@ export function errorStatus(code) {
35
35
  return 403;
36
36
  if (code === 'FILE_TOO_LARGE' || code === 'REQUEST_TOO_LARGE')
37
37
  return 413;
38
- if (code === 'VOICE_DISABLED' || code === 'VOICE_STT_DISABLED' || code === 'MAIL_NOTIFICATIONS_DISABLED')
38
+ if (code === 'VOICE_DISABLED' ||
39
+ code === 'VOICE_STT_DISABLED' ||
40
+ code === 'SOTTO_DISABLED' ||
41
+ code === 'SOTTO_UNAVAILABLE' ||
42
+ code === 'MAIL_NOTIFICATIONS_DISABLED')
39
43
  return 503;
40
44
  if (code?.endsWith('NOT_FOUND'))
41
45
  return 404;
@@ -10,6 +10,7 @@ const webFiles = {
10
10
  '/': { file: 'index.html', type: 'text/html; charset=utf-8' },
11
11
  '/index.html': { file: 'index.html', type: 'text/html; charset=utf-8' },
12
12
  '/app.js': { file: 'app.js', type: 'text/javascript; charset=utf-8' },
13
+ '/voice-pcm.js': { file: 'voice-pcm.js', type: 'text/javascript; charset=utf-8' },
13
14
  '/backend-state.js': { file: 'backend-state.js', type: 'text/javascript; charset=utf-8' },
14
15
  '/hid-keyboard.js': { file: 'hid-keyboard.js', type: 'text/javascript; charset=utf-8' },
15
16
  '/activity-view.js': { file: 'activity-view.js', type: 'text/javascript; charset=utf-8' },
@@ -76,8 +76,13 @@ export class MailNotifier {
76
76
  this.lastAssistant.delete(agentId);
77
77
  if (!this.settings.enabled)
78
78
  return;
79
- // Pi 0.99+ reports whether the run was cancelled; a cancelled run is not a completed task.
80
- this.queue.push({ agentId, finalOutput, context, aborted: event.aborted === true });
79
+ // Pi 0.99+ reports whether the run was cancelled; a cancelled or safely paused run is not a completed task.
80
+ this.queue.push({
81
+ agentId,
82
+ finalOutput,
83
+ context,
84
+ aborted: event.aborted === true || event.paused === true
85
+ });
81
86
  if (!this.timer)
82
87
  this.scheduleFlush();
83
88
  }
@@ -176,7 +176,8 @@ export class MetadataStore {
176
176
  writeAgent(record, archivedAt) {
177
177
  this.database
178
178
  .prepare(
179
- // `template` is written on insert only: an Agent's system prompt template never changes.
179
+ // Ordinary state/index writes must not overwrite the conversation's template snapshot.
180
+ // Only commitAgentTemplate may change it after creation.
180
181
  `INSERT INTO agents(id,workspace_id,session_id,session_file,session_name,cwd,status,profile,template,created_at,last_active_at,archived_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET workspace_id=excluded.workspace_id,session_id=excluded.session_id,session_file=excluded.session_file,session_name=excluded.session_name,cwd=excluded.cwd,status=excluded.status,profile=excluded.profile,last_active_at=excluded.last_active_at,archived_at=excluded.archived_at`)
181
182
  .run(record.agentId, record.workspaceId, record.sessionId, record.sessionFile ?? null, record.sessionName ?? null, record.cwd, record.status, record.profile ?? 'default', record.template ? JSON.stringify(record.template) : null, asTime(record.createdAt), asTime(record.lastActiveAt), archivedAt);
182
183
  }
@@ -186,6 +187,18 @@ export class MetadataStore {
186
187
  this.pendingAgents.delete(record.agentId);
187
188
  this.writeAgent(record, null);
188
189
  }
190
+ /** Commit the validated pre-conversation choice and its session identity together. */
191
+ commitAgentTemplate(record) {
192
+ this.database.transaction(() => {
193
+ const updated = this.database
194
+ .prepare('UPDATE agents SET template=? WHERE id=? AND archived_at IS NULL')
195
+ .run(record.template ? JSON.stringify(record.template) : null, record.agentId);
196
+ if (!updated.changes)
197
+ throw Object.assign(new Error('Agent not found'), { code: 'AGENT_NOT_FOUND' });
198
+ this.writeAgent(record, null);
199
+ })();
200
+ this.pendingAgents.delete(record.agentId);
201
+ }
189
202
  scheduleAgent(record) {
190
203
  if (this.archivedAgents.has(record.agentId))
191
204
  return;
@@ -0,0 +1,100 @@
1
+ const error = (message, code) => Object.assign(new Error(message), { code });
2
+ /**
3
+ * Stops a Pi session before the conversation advances: no further model response or tool call
4
+ * once the pause is reached. Tools that are running finish and their results are recorded first,
5
+ * so the transcript ends with tool results (or the user's message) and nothing is lost.
6
+ *
7
+ * A Pi run asks the model again through exactly two entries, and each one is gated:
8
+ * - Gate A, the agent loop's `finishTurn`: the next turn inside the loop.
9
+ * - Gate B, `agent.continue()`: every continuation the session's run controller starts after the
10
+ * loop ends, i.e. auto-retry, overflow-compaction retry, queued messages and extension
11
+ * `agent_before_settle` continuations.
12
+ * Neither gate depends on event timing. Housekeeping model calls that do not advance the
13
+ * conversation (compaction summaries, prompt-cache warming) are not gated.
14
+ */
15
+ export class SafePause {
16
+ session;
17
+ emit;
18
+ state = 'none';
19
+ /** Set for the agent_settled of a run that ended at the safe point. */
20
+ settledPaused = false;
21
+ constructor(session, emit) {
22
+ this.session = session;
23
+ this.emit = emit;
24
+ const agent = session.agent;
25
+ // Gate A. AgentSession installed its own finishTurn (extension turn_end boundaries); run it first.
26
+ const finishTurn = agent.finishTurn;
27
+ agent.finishTurn = async (turn, signal) => {
28
+ const decision = (await finishTurn?.(turn, signal)) ?? undefined;
29
+ if (this.state === 'none' || decision?.action === 'end')
30
+ return decision;
31
+ // An error or aborted turn ends the loop anyway; a retry has to pass gate B.
32
+ if (turn.message.stopReason === 'error' || turn.message.stopReason === 'aborted')
33
+ return decision;
34
+ // A final answer with nothing queued and no continuation request ends the loop by itself.
35
+ const wouldContinue = turn.toolResults.length > 0 || decision?.action === 'continue' || agent.hasQueuedMessages();
36
+ if (!wouldContinue)
37
+ return decision;
38
+ this.reach();
39
+ return { action: 'end' };
40
+ };
41
+ // Gate B. The run controller calls agent.continue() only after the previous loop has fully
42
+ // ended, so no request is in flight and no tool is running here. Instead of continuing, ask
43
+ // the session to stop its run: abort() first sets the controller's stop flag synchronously,
44
+ // which ends the run before another request. Queued messages stay queued for the next prompt.
45
+ const resume = agent.continue.bind(agent);
46
+ agent.continue = async () => {
47
+ if (this.state === 'none')
48
+ return resume();
49
+ this.reach();
50
+ // abort() waits for the run to settle, which happens only after this call returns.
51
+ void session.abort().catch(() => { });
52
+ };
53
+ session.subscribe(event => {
54
+ if (event.type !== 'agent_settled') {
55
+ this.settledPaused = false;
56
+ return;
57
+ }
58
+ const state = this.state;
59
+ this.state = 'none';
60
+ this.settledPaused = state === 'reached';
61
+ if (state === 'reached')
62
+ this.emit('paused');
63
+ else if (state === 'requested')
64
+ this.emit('finished');
65
+ });
66
+ }
67
+ /** Whether the agent_settled being delivered ends a run that stopped at the safe point. */
68
+ get pausedRunSettled() {
69
+ return this.settledPaused;
70
+ }
71
+ async request() {
72
+ if (!this.session.isStreaming)
73
+ throw error('Agent is not running', 'AGENT_NOT_RUNNING');
74
+ if (this.state !== 'none')
75
+ return;
76
+ this.state = 'requested';
77
+ this.emit('requested');
78
+ // During a retry backoff the next step is the retried model request. isRetrying means Pi has
79
+ // created the retry's abort controller, so cancelling it now takes effect; the run then winds
80
+ // down through the controller, and any continuation it still attempts meets gate B.
81
+ if (this.session.isRetrying) {
82
+ this.reach();
83
+ this.session.abortRetry();
84
+ }
85
+ }
86
+ async cancel() {
87
+ if (this.state === 'reached')
88
+ throw error('The run has already stopped at the safe point', 'PAUSE_ALREADY_REACHED');
89
+ if (this.state !== 'requested')
90
+ return;
91
+ this.state = 'none';
92
+ this.emit('cancelled');
93
+ }
94
+ reach() {
95
+ if (this.state === 'reached')
96
+ return;
97
+ this.state = 'reached';
98
+ this.emit('reached');
99
+ }
100
+ }
@@ -1,6 +1,8 @@
1
1
  import { createAgentSession, DefaultResourceLoader, getAgentDir, SessionManager } from '@earendil-works/pi-coding-agent';
2
2
  import { EventEmitter } from 'node:events';
3
3
  import { randomUUID } from 'node:crypto';
4
+ import { PERFORMANCE_ENTRY, SessionPerformanceTracker } from './session-performance.js';
5
+ import { SafePause } from './safe-pause.js';
4
6
  import { createCollabExtension } from './collab/pi-extension.js';
5
7
  /** Older Pi builds resolved steer()/followUp() with undefined; treat anything but an explicit `handled` as queued. */
6
8
  const normalizeDisposition = (value) => value === 'handled' ? 'handled' : 'queued';
@@ -11,11 +13,22 @@ export class SdkBackend {
11
13
  cwd;
12
14
  disposed = false;
13
15
  events = new EventEmitter();
16
+ safePause;
14
17
  pendingUi = new Map();
15
18
  constructor(session, agentId, cwd) {
16
19
  this.session = session;
17
20
  this.agentId = agentId;
18
21
  this.cwd = cwd;
22
+ const performance = new SessionPerformanceTracker(run => this.session.sessionManager.appendCustomEntry(PERFORMANCE_ENTRY, run));
23
+ this.session.subscribe(event => performance.apply(event));
24
+ this.safePause = new SafePause(session, state => this.events.emit('event', { type: 'safe_pause', state }));
25
+ }
26
+ /** Ask the running turn to stop before its next model request (see SafePause). */
27
+ requestPause() {
28
+ return this.safePause.request();
29
+ }
30
+ cancelPause() {
31
+ return this.safePause.cancel();
19
32
  }
20
33
  static async create(agentId, cwd, sessionFile, profile = 'default', collabBridge, options = {}) {
21
34
  const sessionManager = sessionFile ? SessionManager.open(sessionFile) : SessionManager.create(cwd);
@@ -53,14 +66,18 @@ export class SdkBackend {
53
66
  session.dispose();
54
67
  throw Object.assign(new Error(modelFallbackMessage), { code: 'PI_MODEL_UNAVAILABLE' });
55
68
  }
69
+ return SdkBackend.fromSession(session, agentId, cwd);
70
+ }
71
+ /** Wrap an existing Pi session; tests use it to drive a real session with a scripted model. */
72
+ static async fromSession(session, agentId, cwd) {
56
73
  const backend = new SdkBackend(session, agentId, cwd);
57
74
  await session.bindExtensions({ uiContext: backend.extensionUi() });
58
75
  return backend;
59
76
  }
60
77
  // Slash commands, skills and prompt templates are expanded by prompt() itself (expandPromptTemplates defaults to true),
61
78
  // so `/name args` typed in the web composer behaves exactly like the Pi TUI.
62
- prompt(message) {
63
- return this.session.prompt(message);
79
+ prompt(message, onAccepted) {
80
+ return this.session.prompt(message, { preflightResult: onAccepted });
64
81
  }
65
82
  // Since pi 0.99 steer()/followUp() report whether the message reached the running turn (`handled`)
66
83
  // or waits for it (`queued`); the web composer shows that difference instead of guessing.
@@ -81,6 +98,31 @@ export class SdkBackend {
81
98
  const cleared = this.session.clearQueue();
82
99
  return { steering: [...cleared.steering], followUp: [...cleared.followUp] };
83
100
  }
101
+ async moveQueue(from, index, expected) {
102
+ const before = {
103
+ steering: [...this.session.getSteeringMessages()],
104
+ followUp: [...this.session.getFollowUpMessages()]
105
+ };
106
+ if (!this.session.isStreaming || before[from][index] !== expected)
107
+ throw Object.assign(new Error('Queued message has changed or already been consumed'), {
108
+ code: 'QUEUE_CHANGED'
109
+ });
110
+ const to = from === 'steering' ? 'followUp' : 'steering';
111
+ before[from].splice(index, 1);
112
+ before[to].push(expected);
113
+ if ([...before.steering, ...before.followUp].some(text => text.startsWith('/')))
114
+ throw Object.assign(new Error('Slash commands cannot be moved safely'), { code: 'QUEUE_CHANGED' });
115
+ // Pi exposes clearQueue(), but no per-message move. Clear and requeue in the same JS turn:
116
+ // steer()/followUp() enqueue synchronously before their promises settle. No model callback can
117
+ // drain the half-updated queue between these calls. Never replace a stale queue by index alone.
118
+ this.session.clearQueue();
119
+ const pending = [
120
+ ...before.steering.map(text => this.session.steer(text)),
121
+ ...before.followUp.map(text => this.session.followUp(text))
122
+ ];
123
+ await Promise.all(pending);
124
+ return this.getQueue();
125
+ }
84
126
  abort() {
85
127
  return this.session.abort();
86
128
  }
@@ -239,7 +281,10 @@ export class SdkBackend {
239
281
  throw Object.assign(new Error('Extension UI request not found'), { code: 'EXTENSION_REQUEST_NOT_FOUND' });
240
282
  }
241
283
  subscribe(listener) {
242
- const fromSession = (event) => listener(event);
284
+ // A paused run settles like a finished one; notifications must not report it as completed.
285
+ const fromSession = (event) => listener(event.type === 'agent_settled' && this.safePause.pausedRunSettled
286
+ ? { ...event, paused: true }
287
+ : event);
243
288
  const unsubscribe = this.session.subscribe(fromSession);
244
289
  this.events.on('event', listener);
245
290
  return () => {
package/dist/server.js CHANGED
@@ -8,6 +8,7 @@ import { AgentManager } from './agents.js';
8
8
  import { createSdkBackend } from './sdk-backend.js';
9
9
  import { TerminalManager } from './terminals.js';
10
10
  import { MetadataStore } from './metadata-store.js';
11
+ import { SottoSttService } from './sotto-stt.js';
11
12
  import { CollabHub } from './collab/hub.js';
12
13
  import { CollabStore } from './collab/store.js';
13
14
  import { CollabRouter } from './collab/routes.js';
@@ -54,6 +55,7 @@ export class RemotePiServer {
54
55
  sessionIndexJobs = new Map();
55
56
  agentIndexJobs = new Map();
56
57
  voice;
58
+ sottoStt;
57
59
  sessionNamer;
58
60
  mailNotifier;
59
61
  loginLimiter = new LoginLimiter();
@@ -64,6 +66,7 @@ export class RemotePiServer {
64
66
  this.dataDir = options.dataDir ?? defaultDataDir();
65
67
  this.auth = new AuthStore(this.dataDir);
66
68
  this.metadata = new MetadataStore(this.dataDir);
69
+ this.sottoStt = new SottoSttService(this.metadata);
67
70
  this.templates = new AgentTemplateStore(path.join(this.dataDir, AGENT_TEMPLATES_DIR));
68
71
  this.workspaces = options.workspaces ?? new WorkspaceStore();
69
72
  this.voice = options.voice;
@@ -115,6 +118,7 @@ export class RemotePiServer {
115
118
  console.warn('WARNING: --allow-insecure-no-auth disables loopback bind and Host/Origin restrictions. Anyone who can reach this server, including through a web page, can access workspaces and control agents and terminals without a pairing code. Use only on a trusted, access-controlled network.');
116
119
  const first = this.noAuth ? {} : await this.auth.init();
117
120
  this.metadata.init();
121
+ this.sottoStt.load();
118
122
  this.collab.init();
119
123
  this.unsubscribeCollab = this.collab.subscribe(event => this.collabNotification(event));
120
124
  this.collabStallTimer = setInterval(() => {
@@ -343,7 +347,10 @@ export class RemotePiServer {
343
347
  .map(x => x.trim())
344
348
  .find(x => x.startsWith('access-token.'))
345
349
  ?.slice(13);
346
- const pathname = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`).pathname, isSocket = pathname === '/api/v1/ws' || /^\/api\/v1\/terminals\/[^/]+\/ws$/.test(pathname);
350
+ const pathname = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`).pathname, voiceMatch = pathname.match(/^\/api\/v1\/agents\/([^/]+)\/transcribe\/ws$/), isTranscription = pathname === '/api/v1/transcribe/ws', isSocket = pathname === '/api/v1/ws' ||
351
+ /^\/api\/v1\/terminals\/[^/]+\/ws$/.test(pathname) ||
352
+ !!voiceMatch ||
353
+ isTranscription;
347
354
  if (!isSocket) {
348
355
  socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
349
356
  socket.destroy();
@@ -374,6 +381,24 @@ export class RemotePiServer {
374
381
  return;
375
382
  }
376
383
  this.loginLimiter.clear(key);
384
+ if (voiceMatch || isTranscription) {
385
+ try {
386
+ if (voiceMatch)
387
+ this.agents.get(decodeURIComponent(voiceMatch[1]));
388
+ this.sottoStt.streamUrl();
389
+ }
390
+ catch {
391
+ socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
392
+ socket.destroy();
393
+ return;
394
+ }
395
+ this.wss.handleUpgrade(req, socket, head, ws => {
396
+ this.clients.add(ws);
397
+ ws.on('close', () => this.clients.delete(ws));
398
+ this.sottoStt.stream(ws);
399
+ });
400
+ return;
401
+ }
377
402
  this.wss.handleUpgrade(req, socket, head, ws => this.wss.emit('connection', ws, req));
378
403
  }
379
404
  clientKey(req) {
@@ -575,12 +600,36 @@ export class RemotePiServer {
575
600
  terminalCount: this.terminals.list().length,
576
601
  webSocketConnections: this.clients.size,
577
602
  voiceEnabled: !!this.voice,
578
- voiceCapabilities: this.voice?.capabilities() ?? { tts: false, stt: false },
603
+ voiceCapabilities: {
604
+ tts: this.voice?.capabilities().tts ?? false,
605
+ stt: (this.voice?.capabilities().stt ?? false) || this.sottoStt.getSettings().enabled
606
+ },
607
+ sottoStt: this.sottoStt.getSettings(),
579
608
  sessionNamingEnabled: !!this.sessionNamer,
580
609
  mailNotificationsConfigured: !!this.mailNotifier,
581
610
  mailNotificationsEnabled: this.mailNotifier?.getSettings().enabled ?? false
582
611
  }
583
612
  });
613
+ if (u.pathname === '/api/v1/sotto-stt') {
614
+ if (req.method === 'GET')
615
+ return json(res, 200, { data: this.sottoStt.getSettings() });
616
+ if (req.method === 'PUT')
617
+ return json(res, 200, { data: await this.sottoStt.update(await readJsonBody(req)) });
618
+ }
619
+ if (req.method === 'POST' && u.pathname === '/api/v1/transcribe') {
620
+ const mime = String(req.headers['content-type'] ?? 'audio/webm').split(';')[0];
621
+ if (this.sottoStt.getSettings().enabled) {
622
+ if (mime !== 'application/octet-stream')
623
+ throw Object.assign(new Error('Sotto requires 16 kHz float32 PCM'), { code: 'INVALID_REQUEST' });
624
+ return json(res, 200, { data: await this.sottoStt.transcribe(await readBinaryBody(req)) });
625
+ }
626
+ if (!this.voice)
627
+ throw Object.assign(new Error('Voice service is disabled'), { code: 'VOICE_DISABLED' });
628
+ const filename = String(req.headers['x-audio-filename'] ?? 'recording.webm');
629
+ return json(res, 200, {
630
+ data: await this.voice.transcribe(await readBinaryBody(req), mime, filename)
631
+ });
632
+ }
584
633
  if (await handleMailSettingsRoutes({ mailNotifier: this.mailNotifier, metadata: this.metadata }, req, res, u))
585
634
  return;
586
635
  const workspaceContext = {
@@ -599,6 +648,7 @@ export class RemotePiServer {
599
648
  metadata: this.metadata,
600
649
  workspaces: this.workspaces,
601
650
  voice: this.voice,
651
+ sottoStt: this.sottoStt,
602
652
  sessionNamer: this.sessionNamer,
603
653
  ensureSessionIndex: (workspace, cwd, force) => this.ensureSessionIndex(workspace, cwd, force),
604
654
  indexAgentSession: (agentId) => this.indexAgentSession(agentId),
@@ -0,0 +1,139 @@
1
+ export const PERFORMANCE_ENTRY = 'pi2web.performance.v1';
2
+ /** Wall-clock spans, collected on the host running Pi. Parallel tool spans deliberately overlap. */
3
+ export class SessionPerformanceTracker {
4
+ save;
5
+ now;
6
+ run = null;
7
+ model = null;
8
+ tools = new Map();
9
+ other = null;
10
+ constructor(save, now = Date.now) {
11
+ this.save = save;
12
+ this.now = now;
13
+ }
14
+ finish(span, at) {
15
+ if (span)
16
+ span.end = at;
17
+ }
18
+ span(kind, at, name) {
19
+ const span = { kind, start: at, end: at };
20
+ if (name)
21
+ span.name = name;
22
+ this.run.spans.push(span);
23
+ return span;
24
+ }
25
+ apply(event) {
26
+ const at = this.now(), e = event;
27
+ if (event.type === 'agent_start' && !this.run)
28
+ this.run = { start: at, spans: [] };
29
+ if (!this.run)
30
+ return;
31
+ switch (event.type) {
32
+ case 'turn_start':
33
+ if (this.other?.kind === 'retry') {
34
+ this.finish(this.other, at);
35
+ this.other = null;
36
+ }
37
+ this.finish(this.model, at);
38
+ this.model = this.span('model', at);
39
+ break;
40
+ case 'message_update':
41
+ if (this.model && this.model.firstResponse === undefined)
42
+ this.model.firstResponse = at;
43
+ break;
44
+ case 'message_end':
45
+ if (e.message?.role === 'assistant') {
46
+ if (this.model && Number.isFinite(e.message.usage?.output) && e.message.usage.output >= 0)
47
+ this.model.outputTokens = e.message.usage.output;
48
+ this.finish(this.model, at);
49
+ this.model = null;
50
+ }
51
+ break;
52
+ case 'tool_execution_start':
53
+ if (e.parentToolCallId)
54
+ break; // nested tools are included in their parent's wall-clock span
55
+ if (this.model) {
56
+ this.finish(this.model, at);
57
+ this.model = null;
58
+ }
59
+ if (e.toolCallId) {
60
+ const span = this.span('tool', at, String(e.toolName ?? 'tool'));
61
+ span.toolCallId = String(e.toolCallId);
62
+ this.tools.set(span.toolCallId, span);
63
+ }
64
+ break;
65
+ case 'tool_execution_end': {
66
+ const span = this.tools.get(String(e.toolCallId));
67
+ if (span) {
68
+ this.finish(span, at);
69
+ this.tools.delete(String(e.toolCallId));
70
+ }
71
+ break;
72
+ }
73
+ case 'compaction_start':
74
+ case 'auto_retry_start':
75
+ this.finish(this.other, at);
76
+ this.other = this.span(event.type === 'compaction_start' ? 'compaction' : 'retry', at);
77
+ break;
78
+ case 'compaction_end':
79
+ case 'auto_retry_end':
80
+ this.finish(this.other, at);
81
+ this.other = null;
82
+ if (event.type === 'auto_retry_end' && e.finalError === 'Retry cancelled')
83
+ this.run.incomplete = true;
84
+ break;
85
+ case 'agent_end':
86
+ this.finish(this.model, at);
87
+ this.model = null;
88
+ if (e.aborted)
89
+ this.run.incomplete = true;
90
+ break;
91
+ case 'agent_settled':
92
+ // Unlike agent_end, this also follows post-run compaction or a cancelled retry delay.
93
+ this.finish(this.model, at);
94
+ this.finish(this.other, at);
95
+ for (const span of this.tools.values()) {
96
+ this.finish(span, at);
97
+ span.incomplete = true;
98
+ }
99
+ this.save({
100
+ start: this.run.start,
101
+ end: at,
102
+ spans: this.run.spans,
103
+ ...(this.run.incomplete ? { incomplete: true } : {})
104
+ });
105
+ this.run = null;
106
+ this.model = this.other = null;
107
+ this.tools.clear();
108
+ }
109
+ }
110
+ }
111
+ /** Current branch only. Earlier sessions have tool timestamps but no model-call spans. */
112
+ export function sessionPerformance(entries) {
113
+ const runs = [];
114
+ const legacyTools = [];
115
+ for (const entry of entries) {
116
+ const e = entry;
117
+ if (e.type === 'custom' &&
118
+ e.customType === PERFORMANCE_ENTRY &&
119
+ e.data?.spans &&
120
+ Number.isFinite(e.data.start) &&
121
+ Number.isFinite(e.data.end)) {
122
+ runs.push(e.data);
123
+ }
124
+ }
125
+ for (const entry of entries) {
126
+ const e = entry;
127
+ if (e.type !== 'message' || e.message?.role !== 'toolResult')
128
+ continue;
129
+ const duration = Number(e.message.durationMs);
130
+ const end = Number(e.message.timestamp) || Date.parse(e.timestamp ?? '');
131
+ if (!Number.isFinite(duration) ||
132
+ duration < 0 ||
133
+ !Number.isFinite(end) ||
134
+ runs.some(run => end >= run.start && end <= run.end))
135
+ continue;
136
+ legacyTools.push({ kind: 'tool', start: end - duration, end, name: String(e.message.toolName ?? 'tool') });
137
+ }
138
+ return { runs, legacyTools };
139
+ }