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.
- package/README.md +15 -5
- package/dist/activity.js +83 -9
- package/dist/agents.js +244 -43
- package/dist/http/agent-routes.js +37 -3
- package/dist/http/utils.js +5 -1
- package/dist/http/web-assets.js +1 -0
- package/dist/mail-notifier.js +7 -2
- package/dist/metadata-store.js +14 -1
- package/dist/safe-pause.js +100 -0
- package/dist/sdk-backend.js +48 -3
- package/dist/server.js +52 -2
- package/dist/session-performance.js +139 -0
- package/dist/sotto-stt.js +217 -0
- package/dist/voice.js +2 -2
- package/dist/ws/agent-socket.js +2 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/packages/protocol/openapi.yaml +22 -1
- package/packages/protocol/websocket.schema.json +4 -3
- package/web/activity-view.js +49 -4
- package/web/app.js +764 -94
- package/web/collab.js +18 -4
- package/web/index.html +5 -2
- package/web/styles.css +8 -2
- package/web/voice-pcm.js +36 -0
|
@@ -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.
|
|
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.
|
|
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')
|
package/dist/http/utils.js
CHANGED
|
@@ -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' ||
|
|
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;
|
package/dist/http/web-assets.js
CHANGED
|
@@ -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' },
|
package/dist/mail-notifier.js
CHANGED
|
@@ -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({
|
|
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
|
}
|
package/dist/metadata-store.js
CHANGED
|
@@ -176,7 +176,8 @@ export class MetadataStore {
|
|
|
176
176
|
writeAgent(record, archivedAt) {
|
|
177
177
|
this.database
|
|
178
178
|
.prepare(
|
|
179
|
-
//
|
|
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
|
+
}
|
package/dist/sdk-backend.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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:
|
|
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
|
+
}
|