@pimote/pimote 0.3.0 → 0.4.0
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 +35 -24
- package/client/build/_app/immutable/assets/0.DzEJOwCY.css +2 -0
- package/client/build/_app/immutable/assets/2.BaqEkCa-.css +1 -0
- package/client/build/_app/immutable/chunks/Bu9BFOcV.js +5 -0
- package/client/build/_app/immutable/chunks/{D_Fpgknp.js → CC5lD39t.js} +1 -1
- package/client/build/_app/immutable/chunks/Cofo8G1m.js +1 -0
- package/client/build/_app/immutable/chunks/IBhhn0wx.js +1 -0
- package/client/build/_app/immutable/chunks/RduuwJ24.js +1 -0
- package/client/build/_app/immutable/entry/{app.DO-zgzyy.js → app.D7ddwq0U.js} +2 -2
- package/client/build/_app/immutable/entry/start.8XREk-Eq.js +1 -0
- package/client/build/_app/immutable/nodes/0.nayop99c.js +10 -0
- package/client/build/_app/immutable/nodes/{1.B2l9JGRO.js → 1.BuW4kxL-.js} +1 -1
- package/client/build/_app/immutable/nodes/2.BParOJL4.js +54 -0
- package/client/build/_app/version.json +1 -1
- package/client/build/index.html +7 -7
- package/package.json +3 -4
- package/server/dist/config.js +0 -1
- package/server/dist/extension-ui-bridge.js +17 -0
- package/server/dist/folder-index.js +1 -1
- package/server/dist/index.js +55 -18
- package/server/dist/message-mapper.js +1 -0
- package/server/dist/paths.js +2 -0
- package/server/dist/push-notification.js +11 -0
- package/server/dist/server.js +9 -1
- package/server/dist/session-manager.js +22 -8
- package/server/dist/static-host/gc.js +42 -0
- package/server/dist/static-host/http-handler.js +170 -0
- package/server/dist/static-host/index.js +117 -0
- package/server/dist/static-host/prompt.js +19 -0
- package/server/dist/static-host/registry.js +58 -0
- package/server/dist/static-host/store.js +44 -0
- package/server/dist/static-host/tools.js +118 -0
- package/server/dist/voice/fsm/reducers/lifecycle.js +14 -2
- package/server/dist/voice/index.js +49 -7
- package/server/dist/voice/speechmux-client.js +20 -3
- package/server/dist/voice/state-machine.js +7 -0
- package/server/dist/voice-orchestrator-boot.js +14 -50
- package/server/dist/voice-orchestrator.js +10 -22
- package/server/dist/ws-handler.js +4 -2
- package/shared/dist/protocol.d.ts +8 -0
- package/client/build/_app/immutable/assets/0.C7loWTOC.css +0 -2
- package/client/build/_app/immutable/assets/2.D9fiCd8W.css +0 -1
- package/client/build/_app/immutable/chunks/BNqgidwO.js +0 -5
- package/client/build/_app/immutable/chunks/D26i4pYm.js +0 -1
- package/client/build/_app/immutable/chunks/DoVhjU85.js +0 -1
- package/client/build/_app/immutable/chunks/DzqbY2XU.js +0 -1
- package/client/build/_app/immutable/entry/start.BZlrOH0-.js +0 -1
- package/client/build/_app/immutable/nodes/0.BEh4bPGQ.js +0 -10
- package/client/build/_app/immutable/nodes/2.ph9M0S1U.js +0 -54
- package/patches/@mariozechner+pi-coding-agent+0.67.6.patch +0 -24
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { stat } from 'node:fs/promises';
|
|
2
|
+
import { isAbsolute, join } from 'node:path';
|
|
3
|
+
/**
|
|
4
|
+
* Validates and normalises a slug.
|
|
5
|
+
*
|
|
6
|
+
* Rules: lowercase alphanumerics and hyphens, no leading/trailing dash,
|
|
7
|
+
* non-empty, and a reasonable length cap (<= 64).
|
|
8
|
+
*
|
|
9
|
+
* Returns `null` if invalid.
|
|
10
|
+
*/
|
|
11
|
+
const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
12
|
+
export function validateSlug(slug) {
|
|
13
|
+
if (typeof slug !== 'string')
|
|
14
|
+
return null;
|
|
15
|
+
if (slug.length === 0 || slug.length > 64)
|
|
16
|
+
return null;
|
|
17
|
+
if (!SLUG_RE.test(slug))
|
|
18
|
+
return null;
|
|
19
|
+
return slug;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Resolve a slug against the registry. Returns the input slug if it is free;
|
|
23
|
+
* otherwise appends `-2`, `-3`, ... until a free slug is found.
|
|
24
|
+
*
|
|
25
|
+
* The caller must have already validated the input slug via `validateSlug`.
|
|
26
|
+
*/
|
|
27
|
+
export function resolveSlugCollision(slug, registry) {
|
|
28
|
+
if (!registry.has(slug))
|
|
29
|
+
return slug;
|
|
30
|
+
for (let i = 2;; i++) {
|
|
31
|
+
const candidate = `${slug}-${i}`;
|
|
32
|
+
if (!registry.has(candidate))
|
|
33
|
+
return candidate;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Execute the `pimote_static_host` tool body.
|
|
38
|
+
*
|
|
39
|
+
* Throws on validation failure (invalid slug, missing folder, no index.html).
|
|
40
|
+
* On success: updates the in-memory list for the session, atomically rewrites
|
|
41
|
+
* the persistence file, calls `registry.register(...)`, and emits the panel
|
|
42
|
+
* snapshot.
|
|
43
|
+
*
|
|
44
|
+
* Concurrency assumption: pi serializes tool calls within a single session,
|
|
45
|
+
* so the read-modify-write sequence on `store` here is safe. If that ever
|
|
46
|
+
* changes (parallel tool execution per session), this body must be revisited
|
|
47
|
+
* — the existing read/write pair would race and the registry/disk could
|
|
48
|
+
* desync. Re-derive entries from `registry.listForSession(sessionId)` after
|
|
49
|
+
* `registry.register`, or add a per-session lock.
|
|
50
|
+
*/
|
|
51
|
+
export async function executeRegisterTool(input, deps) {
|
|
52
|
+
const validSlug = validateSlug(input.slug);
|
|
53
|
+
if (validSlug === null) {
|
|
54
|
+
throw new Error(`invalid slug: ${JSON.stringify(input.slug)}`);
|
|
55
|
+
}
|
|
56
|
+
if (typeof input.folder !== 'string' || !isAbsolute(input.folder)) {
|
|
57
|
+
throw new Error(`folder must be an absolute path: ${JSON.stringify(input.folder)}`);
|
|
58
|
+
}
|
|
59
|
+
let folderStat;
|
|
60
|
+
try {
|
|
61
|
+
folderStat = await stat(input.folder);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
throw new Error(`folder does not exist: ${input.folder}`);
|
|
65
|
+
}
|
|
66
|
+
if (!folderStat.isDirectory()) {
|
|
67
|
+
throw new Error(`folder is not a directory: ${input.folder}`);
|
|
68
|
+
}
|
|
69
|
+
const indexPath = join(input.folder, 'index.html');
|
|
70
|
+
let indexStat;
|
|
71
|
+
try {
|
|
72
|
+
indexStat = await stat(indexPath);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
throw new Error(`folder has no index.html: ${input.folder}`);
|
|
76
|
+
}
|
|
77
|
+
if (!indexStat.isFile()) {
|
|
78
|
+
throw new Error(`index.html is not a file: ${indexPath}`);
|
|
79
|
+
}
|
|
80
|
+
const resolved = resolveSlugCollision(validSlug, deps.registry);
|
|
81
|
+
const cardMetadata = {
|
|
82
|
+
title: input.title,
|
|
83
|
+
...(input.tag !== undefined ? { tag: input.tag } : {}),
|
|
84
|
+
...(input.color !== undefined ? { color: input.color } : {}),
|
|
85
|
+
};
|
|
86
|
+
const existing = (await deps.store.read(deps.sessionId)) ?? { version: 1, entries: [] };
|
|
87
|
+
const entries = [...existing.entries, { slug: resolved, folderPath: input.folder, cardMetadata }];
|
|
88
|
+
const file = { version: 1, entries };
|
|
89
|
+
await deps.store.write(deps.sessionId, file);
|
|
90
|
+
deps.registry.register({
|
|
91
|
+
slug: resolved,
|
|
92
|
+
folderPath: input.folder,
|
|
93
|
+
sessionId: deps.sessionId,
|
|
94
|
+
cardMetadata,
|
|
95
|
+
});
|
|
96
|
+
deps.emitPanelCards();
|
|
97
|
+
return { slug: resolved, url: `/s/${resolved}/` };
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Execute the `pimote_static_host_remove` tool body. Returns `{ removed: false }`
|
|
101
|
+
* when the slug is not owned by this session.
|
|
102
|
+
*
|
|
103
|
+
* Concurrency assumption: same as `executeRegisterTool` — relies on pi's
|
|
104
|
+
* per-session serialization of tool calls. If that changes, this body must
|
|
105
|
+
* be revisited.
|
|
106
|
+
*/
|
|
107
|
+
export async function executeRemoveTool(input, deps) {
|
|
108
|
+
const existing = deps.registry.lookup(input.slug);
|
|
109
|
+
if (!existing || existing.sessionId !== deps.sessionId) {
|
|
110
|
+
return { removed: false };
|
|
111
|
+
}
|
|
112
|
+
const file = (await deps.store.read(deps.sessionId)) ?? { version: 1, entries: [] };
|
|
113
|
+
const entries = file.entries.filter((e) => e.slug !== input.slug);
|
|
114
|
+
await deps.store.write(deps.sessionId, { version: 1, entries });
|
|
115
|
+
deps.registry.unregister(input.slug);
|
|
116
|
+
deps.emitPanelCards();
|
|
117
|
+
return { removed: true };
|
|
118
|
+
}
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
//
|
|
9
9
|
// Holds NO knowledge of the streaming / walkback machines. Those plug in
|
|
10
10
|
// through the top-level dispatcher.
|
|
11
|
-
import { VOICE_CALL_STARTED_SENTINEL } from '../../state-machine.js';
|
|
11
|
+
import { VOICE_CALL_ENDED_SENTINEL, VOICE_CALL_STARTED_SENTINEL } from '../../state-machine.js';
|
|
12
12
|
/**
|
|
13
13
|
* Pure transition function for the lifecycle slice.
|
|
14
14
|
*
|
|
@@ -55,11 +55,23 @@ export function reduceLifecycle(prev, event, ctx) {
|
|
|
55
55
|
if (prev.kind === 'dormant') {
|
|
56
56
|
return { next: prev, interpreterAppliedNow: false, actions: [] };
|
|
57
57
|
}
|
|
58
|
+
// Inject an explicit end-of-call sentinel into the conversation so
|
|
59
|
+
// the agent has an in-history signal that voice mode is over —
|
|
60
|
+
// mirrors the `<voice_call_started/>` sentinel on activate. Without
|
|
61
|
+
// it, a session resumed in text mode after a call sees a wall of
|
|
62
|
+
// prior `speak()` calls and tends to mimic one more before the
|
|
63
|
+
// tool-side guard rejects it.
|
|
64
|
+
//
|
|
65
|
+
// Crucially this is a *silent* injection: unlike the start sentinel
|
|
66
|
+
// (which deliberately triggers the greeting turn), the end sentinel
|
|
67
|
+
// must NOT trigger a turn — the call is over, the LLM has nothing
|
|
68
|
+
// to do right now, and a triggered turn would just provoke one more
|
|
69
|
+
// speak() attempt that we then have to reject.
|
|
58
70
|
// Idempotent: close_ws is a no-op if no client is open.
|
|
59
71
|
return {
|
|
60
72
|
next: { kind: 'dormant' },
|
|
61
73
|
interpreterAppliedNow: false,
|
|
62
|
-
actions: [{ kind: 'close_ws' }],
|
|
74
|
+
actions: [{ kind: 'inject_silent_user_message', customType: 'voice_call_ended', text: VOICE_CALL_ENDED_SENTINEL }, { kind: 'close_ws' }],
|
|
63
75
|
};
|
|
64
76
|
}
|
|
65
77
|
case 'ws:opened': {
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
// messages because it was reset on the wrong event (a substring of
|
|
17
17
|
// `assistantMessageEvent` that never fires inside `message_update`). The
|
|
18
18
|
// FSM split + correct reset-on-message_start eliminates that bug class.
|
|
19
|
-
import { Type } from '
|
|
19
|
+
import { Type } from 'typebox';
|
|
20
20
|
import { renderInterpreterPrompt } from './interpreter-prompt.js';
|
|
21
21
|
import { createDefaultSpeechmuxClientFactory } from './speechmux-client.js';
|
|
22
22
|
import { ensureIdleWithImplicitAbort } from './wait-for-idle.js';
|
|
@@ -141,6 +141,18 @@ export function createVoiceExtension(opts) {
|
|
|
141
141
|
pi.sendUserMessage(action.text, action.deliverAs ? { deliverAs: action.deliverAs } : undefined);
|
|
142
142
|
return;
|
|
143
143
|
}
|
|
144
|
+
case 'inject_silent_user_message': {
|
|
145
|
+
// sendMessage() with a `custom` role + triggerTurn:false appends
|
|
146
|
+
// an entry that converts to a `role:"user"` message for the LLM
|
|
147
|
+
// (see core/messages.ts convertToLlm) but does not start a turn
|
|
148
|
+
// now. Exactly what we want for the end-of-call sentinel.
|
|
149
|
+
pi.sendMessage({
|
|
150
|
+
customType: action.customType,
|
|
151
|
+
content: action.text,
|
|
152
|
+
display: true,
|
|
153
|
+
}, { triggerTurn: false });
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
144
156
|
case 'open_ws': {
|
|
145
157
|
// Reentrancy guard: close any prior client first.
|
|
146
158
|
try {
|
|
@@ -156,6 +168,9 @@ export function createVoiceExtension(opts) {
|
|
|
156
168
|
client.onFrame((frame) => {
|
|
157
169
|
void dispatch({ type: 'ws:incoming', frame });
|
|
158
170
|
});
|
|
171
|
+
client.onDisconnect(() => {
|
|
172
|
+
void dispatch({ type: 'ws:disconnected' });
|
|
173
|
+
});
|
|
159
174
|
await dispatch({ type: 'ws:opened' });
|
|
160
175
|
}
|
|
161
176
|
catch (err) {
|
|
@@ -179,7 +194,7 @@ export function createVoiceExtension(opts) {
|
|
|
179
194
|
console.warn('[voice] send_frame with no client — dropping', action.frame.type);
|
|
180
195
|
return;
|
|
181
196
|
}
|
|
182
|
-
const preview = action.frame.type === 'token' ? action.frame.text.slice(0, 60) : null;
|
|
197
|
+
const preview = action.frame.type === 'token' ? action.frame.text.slice(0, 60) : action.frame.type === 'error' ? action.frame.message.slice(0, 60) : null;
|
|
183
198
|
console.log('[voice_trace] send_frame', JSON.stringify({ type: action.frame.type, preview }));
|
|
184
199
|
try {
|
|
185
200
|
speechmuxClient.send(action.frame);
|
|
@@ -238,7 +253,12 @@ export function createVoiceExtension(opts) {
|
|
|
238
253
|
return { content: [{ type: 'text', text: 'ok' }], details: {} };
|
|
239
254
|
}
|
|
240
255
|
return {
|
|
241
|
-
content: [
|
|
256
|
+
content: [
|
|
257
|
+
{
|
|
258
|
+
type: 'text',
|
|
259
|
+
text: 'Voice call has ended. The user is now in text mode — do NOT call speak() again. Reply with normal assistant text. Any further speak() calls in this session will be rejected.',
|
|
260
|
+
},
|
|
261
|
+
],
|
|
242
262
|
details: {},
|
|
243
263
|
isError: true,
|
|
244
264
|
};
|
|
@@ -254,10 +274,6 @@ export function createVoiceExtension(opts) {
|
|
|
254
274
|
// The `tool_call` hook is intentionally NOT registered. The streaming
|
|
255
275
|
// reducer is the sole emitter of speak frames; bulk-emission via
|
|
256
276
|
// tool_call was the source of the double-emit class of bugs.
|
|
257
|
-
//
|
|
258
|
-
// The `turn_end` safety net is also intentionally NOT registered.
|
|
259
|
-
// With per-speak `end` framing driven by `toolcall_end`, it was
|
|
260
|
-
// redundant and contributed to double-end emissions.
|
|
261
277
|
pi.on('message_start', (event) => {
|
|
262
278
|
// Only assistant messages reset the streaming state. User and
|
|
263
279
|
// tool-result messages don't have content blocks we care about.
|
|
@@ -302,6 +318,32 @@ export function createVoiceExtension(opts) {
|
|
|
302
318
|
return;
|
|
303
319
|
}
|
|
304
320
|
});
|
|
321
|
+
pi.on('turn_end', (event) => {
|
|
322
|
+
if (state.lifecycle.kind !== 'active' || !speechmuxClient)
|
|
323
|
+
return;
|
|
324
|
+
const lastSpeakResult = [...event.toolResults].reverse().find((result) => result.toolName === 'speak');
|
|
325
|
+
if (!lastSpeakResult)
|
|
326
|
+
return;
|
|
327
|
+
try {
|
|
328
|
+
speechmuxClient.send(typeof lastSpeakResult.toolCallId === 'string' ? { type: 'floor_released', speak_id: lastSpeakResult.toolCallId } : { type: 'floor_released' });
|
|
329
|
+
}
|
|
330
|
+
catch (err) {
|
|
331
|
+
console.warn('[voice] speechmux send failed', 'floor_released', err);
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
pi.on('agent_end', (event) => {
|
|
335
|
+
if (state.lifecycle.kind !== 'active' || !speechmuxClient)
|
|
336
|
+
return;
|
|
337
|
+
const error = event.error;
|
|
338
|
+
if (typeof error !== 'string' || error.length === 0)
|
|
339
|
+
return;
|
|
340
|
+
try {
|
|
341
|
+
speechmuxClient.send({ type: 'error', message: error });
|
|
342
|
+
}
|
|
343
|
+
catch (err) {
|
|
344
|
+
console.warn('[voice] speechmux send failed', 'error', err);
|
|
345
|
+
}
|
|
346
|
+
});
|
|
305
347
|
pi.on('context', (event, ctx) => {
|
|
306
348
|
lastCtx = ctx;
|
|
307
349
|
// The walkback reducer always runs walkBack (even when no rewrite
|
|
@@ -10,8 +10,9 @@
|
|
|
10
10
|
* Default `SpeechmuxClient` factory backed by the `ws` package. Opens a
|
|
11
11
|
* WebSocket to `wsUrl` and routes incoming JSON text frames to registered
|
|
12
12
|
* listeners. The LLM-WS protocol has no hello frame — the harness simply
|
|
13
|
-
* connects and exchanges `user` / `token` / `end` / `
|
|
14
|
-
* frames (see
|
|
13
|
+
* connects and exchanges `user` / `token` / `end` / `floor_released` /
|
|
14
|
+
* `error` / `abort` / `rollback` frames (see
|
|
15
|
+
* speechmux/docs/llm-ws-protocol.md).
|
|
15
16
|
*
|
|
16
17
|
* Resolves once the socket is open. Rejects if the socket errors or closes
|
|
17
18
|
* before opening.
|
|
@@ -28,10 +29,13 @@ export function createDefaultSpeechmuxClientFactory() {
|
|
|
28
29
|
}
|
|
29
30
|
const ws = new WsCtor(wsUrl);
|
|
30
31
|
const listeners = new Set();
|
|
32
|
+
const disconnectListeners = new Set();
|
|
31
33
|
// Buffer frames that arrive after `hello` but before the caller has had a
|
|
32
34
|
// chance to attach an `onFrame` listener. Drained on the first attach.
|
|
33
35
|
const pending = [];
|
|
34
36
|
let closed = false;
|
|
37
|
+
let opened = false;
|
|
38
|
+
let disconnectNotified = false;
|
|
35
39
|
// Install the message handler before resolving so frames sent between
|
|
36
40
|
// open and the caller's onFrame attach are buffered instead of dropped.
|
|
37
41
|
// See review finding 5 (speechmux-client race).
|
|
@@ -75,6 +79,7 @@ export function createDefaultSpeechmuxClientFactory() {
|
|
|
75
79
|
if (settled)
|
|
76
80
|
return;
|
|
77
81
|
settled = true;
|
|
82
|
+
opened = true;
|
|
78
83
|
cleanup();
|
|
79
84
|
resolve();
|
|
80
85
|
};
|
|
@@ -101,8 +106,16 @@ export function createDefaultSpeechmuxClientFactory() {
|
|
|
101
106
|
ws.once('open', onOpen);
|
|
102
107
|
ws.once('error', onError);
|
|
103
108
|
});
|
|
109
|
+
const notifyDisconnect = () => {
|
|
110
|
+
if (!opened || disconnectNotified)
|
|
111
|
+
return;
|
|
112
|
+
disconnectNotified = true;
|
|
113
|
+
for (const listener of disconnectListeners)
|
|
114
|
+
listener();
|
|
115
|
+
};
|
|
104
116
|
ws.on('close', () => {
|
|
105
117
|
closed = true;
|
|
118
|
+
notifyDisconnect();
|
|
106
119
|
});
|
|
107
120
|
return {
|
|
108
121
|
send(frame) {
|
|
@@ -122,6 +135,10 @@ export function createDefaultSpeechmuxClientFactory() {
|
|
|
122
135
|
}
|
|
123
136
|
return () => listeners.delete(listener);
|
|
124
137
|
},
|
|
138
|
+
onDisconnect(listener) {
|
|
139
|
+
disconnectListeners.add(listener);
|
|
140
|
+
return () => disconnectListeners.delete(listener);
|
|
141
|
+
},
|
|
125
142
|
close() {
|
|
126
143
|
if (closed)
|
|
127
144
|
return;
|
|
@@ -144,7 +161,7 @@ function isIncomingFrame(value) {
|
|
|
144
161
|
case 'user':
|
|
145
162
|
return typeof v.text === 'string';
|
|
146
163
|
case 'abort':
|
|
147
|
-
return
|
|
164
|
+
return v.reason === undefined || v.reason === 'user_speaking' || v.reason === 'barge_in' || v.reason === 'session_closed';
|
|
148
165
|
case 'rollback':
|
|
149
166
|
return typeof v.heard_text === 'string';
|
|
150
167
|
default:
|
|
@@ -5,3 +5,10 @@
|
|
|
5
5
|
// server-side VoiceOrchestrator.
|
|
6
6
|
/** Sentinel user message appended on entry to the `active` state. */
|
|
7
7
|
export const VOICE_CALL_STARTED_SENTINEL = '<voice_call_started/>';
|
|
8
|
+
/**
|
|
9
|
+
* Sentinel user message appended on exit from `active`/`activating` back to
|
|
10
|
+
* `dormant`. Gives the agent an explicit in-history signal that the voice
|
|
11
|
+
* call has ended, so subsequent turns (including future text-mode pickups
|
|
12
|
+
* of the same session) don't keep mimicking prior `speak()` calls.
|
|
13
|
+
*/
|
|
14
|
+
export const VOICE_CALL_ENDED_SENTINEL = '<voice_call_ended/>';
|
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
// Wire the VoiceOrchestrator together with its runtime dependencies at
|
|
2
2
|
// server boot time. Kept separate from `index.ts` so the wiring is
|
|
3
|
-
// testable
|
|
4
|
-
//
|
|
5
|
-
|
|
3
|
+
// testable and isolated from the plain HTTP/WS boot sequence.
|
|
4
|
+
//
|
|
5
|
+
// Returns `null` when voice is not configured. Speechmux is treated as an
|
|
6
|
+
// externally managed service (systemd, container, remote host, etc.); pimote
|
|
7
|
+
// no longer spawns it as a sidecar.
|
|
6
8
|
import { VoiceOrchestrator } from './voice-orchestrator.js';
|
|
9
|
+
/** True iff the config has the URLs needed to bind a voice call. */
|
|
10
|
+
export function isVoiceConfigured(config) {
|
|
11
|
+
return Boolean(config.voice?.speechmuxSignalUrl && config.voice?.speechmuxLlmWsUrl);
|
|
12
|
+
}
|
|
7
13
|
/**
|
|
8
14
|
* Construct a VoiceOrchestrator backed by real seams:
|
|
9
|
-
* - speechmux sidecar via `child_process.spawn`
|
|
10
15
|
* - displacement = looks up current owner via clientRegistry and calls its
|
|
11
16
|
* `sendDisplacedEvent(sessionId)`
|
|
12
17
|
*
|
|
@@ -14,10 +19,14 @@ import { VoiceOrchestrator } from './voice-orchestrator.js';
|
|
|
14
19
|
* per-session TURN credentials are minted by speechmux and returned to the
|
|
15
20
|
* PWA in its `/signal` `session` response. Pimote's orchestrator only
|
|
16
21
|
* hands out the signalling URL.
|
|
22
|
+
*
|
|
23
|
+
* Returns `null` if voice is not configured \u2014 callers should skip all voice
|
|
24
|
+
* wiring in that case.
|
|
17
25
|
*/
|
|
18
26
|
export function buildVoiceOrchestrator(args) {
|
|
19
27
|
const { config, sessionManager, clientRegistry } = args;
|
|
20
|
-
|
|
28
|
+
if (!isVoiceConfigured(config))
|
|
29
|
+
return null;
|
|
21
30
|
const busResolver = {
|
|
22
31
|
getSlot: (sessionId) => sessionManager.getSlot(sessionId),
|
|
23
32
|
getEventBus: (sessionId) => sessionManager.getSlot(sessionId)?.eventBusRef.current ?? null,
|
|
@@ -26,51 +35,6 @@ export function buildVoiceOrchestrator(args) {
|
|
|
26
35
|
config,
|
|
27
36
|
sessionManager,
|
|
28
37
|
busResolver,
|
|
29
|
-
startSpeechmux: async () => {
|
|
30
|
-
const bin = config.voice?.speechmuxBinary;
|
|
31
|
-
if (!bin) {
|
|
32
|
-
console.log('[voice] speechmuxBinary not configured; assuming speechmux is externally managed (systemd, container, remote host, etc.)');
|
|
33
|
-
return;
|
|
34
|
-
}
|
|
35
|
-
if (speechmuxProc)
|
|
36
|
-
return;
|
|
37
|
-
speechmuxProc = spawn(bin, [], { stdio: ['ignore', 'inherit', 'inherit'] });
|
|
38
|
-
speechmuxProc.on('exit', (code, signal) => {
|
|
39
|
-
console.warn(`[voice] speechmux exited (code=${code}, signal=${signal})`);
|
|
40
|
-
speechmuxProc = null;
|
|
41
|
-
});
|
|
42
|
-
// NB: we do not wait for a ready marker here — speechmux emits readiness
|
|
43
|
-
// to its own logs. Callers should ensure startup ordering or implement a
|
|
44
|
-
// readiness probe as part of the Step 14 smoke.
|
|
45
|
-
},
|
|
46
|
-
stopSpeechmux: async () => {
|
|
47
|
-
if (!speechmuxProc)
|
|
48
|
-
return;
|
|
49
|
-
const proc = speechmuxProc;
|
|
50
|
-
speechmuxProc = null;
|
|
51
|
-
await new Promise((resolve) => {
|
|
52
|
-
const timer = setTimeout(() => {
|
|
53
|
-
try {
|
|
54
|
-
proc.kill('SIGKILL');
|
|
55
|
-
}
|
|
56
|
-
catch {
|
|
57
|
-
/* ignore */
|
|
58
|
-
}
|
|
59
|
-
resolve();
|
|
60
|
-
}, 2000);
|
|
61
|
-
proc.once('exit', () => {
|
|
62
|
-
clearTimeout(timer);
|
|
63
|
-
resolve();
|
|
64
|
-
});
|
|
65
|
-
try {
|
|
66
|
-
proc.kill('SIGTERM');
|
|
67
|
-
}
|
|
68
|
-
catch {
|
|
69
|
-
clearTimeout(timer);
|
|
70
|
-
resolve();
|
|
71
|
-
}
|
|
72
|
-
});
|
|
73
|
-
},
|
|
74
38
|
displaceOwner: async (sessionId, _newOwner) => {
|
|
75
39
|
const slot = sessionManager.getSlot(sessionId);
|
|
76
40
|
const existingClientId = slot?.connection?.connectedClientId;
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
// Voice orchestrator — owns
|
|
2
|
-
//
|
|
1
|
+
// Voice orchestrator — owns per-call bind dispatch.
|
|
2
|
+
// See docs/plans/voice-mode.md → "Voice orchestrator".
|
|
3
3
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
4
|
+
// Speechmux is treated as an externally managed service (systemd, container,
|
|
5
|
+
// remote host, etc.). This orchestrator is only constructed when voice config
|
|
6
|
+
// is present (`voice.speechmuxSignalUrl` + `voice.speechmuxLlmWsUrl`); when
|
|
7
|
+
// it is absent, the server skips voice wiring entirely.
|
|
6
8
|
/** Typed error carrying the discriminable reason code used in PimoteResponse.error. */
|
|
7
9
|
export class CallBindError extends Error {
|
|
8
10
|
code;
|
|
@@ -14,24 +16,12 @@ export class CallBindError extends Error {
|
|
|
14
16
|
}
|
|
15
17
|
export class VoiceOrchestrator {
|
|
16
18
|
opts;
|
|
17
|
-
started = false;
|
|
18
19
|
activeCalls = new Set();
|
|
19
20
|
constructor(opts) {
|
|
20
21
|
this.opts = opts;
|
|
21
22
|
}
|
|
22
|
-
/**
|
|
23
|
-
async start() {
|
|
24
|
-
if (this.started)
|
|
25
|
-
return;
|
|
26
|
-
await this.opts.startSpeechmux();
|
|
27
|
-
this.started = true;
|
|
28
|
-
}
|
|
29
|
-
/** Kills speechmux. Idempotent. */
|
|
23
|
+
/** Drop all active-call bookkeeping. Idempotent. Called on server shutdown. */
|
|
30
24
|
async stop() {
|
|
31
|
-
if (!this.started)
|
|
32
|
-
return;
|
|
33
|
-
await this.opts.stopSpeechmux();
|
|
34
|
-
this.started = false;
|
|
35
25
|
this.activeCalls.clear();
|
|
36
26
|
}
|
|
37
27
|
/** Called by ws-handler for CallBindCommand. */
|
|
@@ -47,11 +37,9 @@ export class VoiceOrchestrator {
|
|
|
47
37
|
if (alreadyOwned && args.force) {
|
|
48
38
|
await this.opts.displaceOwner(args.sessionId, args.clientConnection);
|
|
49
39
|
}
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
// response) and what authenticates peers (via Cloudflare Access at the
|
|
54
|
-
// edge), so pimote no longer needs to mint anything.
|
|
40
|
+
// The orchestrator is only constructed when voice is configured, so
|
|
41
|
+
// these URLs are guaranteed present. Re-read them per call so live
|
|
42
|
+
// config edits (if/when supported) take effect on the next bind.
|
|
55
43
|
const signalUrl = this.opts.config.voice?.speechmuxSignalUrl;
|
|
56
44
|
const llmWsUrl = this.opts.config.voice?.speechmuxLlmWsUrl;
|
|
57
45
|
if (!signalUrl || !llmWsUrl) {
|
|
@@ -989,7 +989,8 @@ export class WsHandler {
|
|
|
989
989
|
onSessionReset: (s) => this.handleSessionReset(s),
|
|
990
990
|
};
|
|
991
991
|
slot.connection = connection;
|
|
992
|
-
|
|
992
|
+
// Note: do NOT touch `idleSince` here. Idleness is an agent-level concept driven by
|
|
993
|
+
// agent_start/agent_end — a client claiming a session does not extend its idle clock.
|
|
993
994
|
this.subscribedSessions.add(sessionId);
|
|
994
995
|
// Bind extensions when needed. The bridge holds a direct reference to this
|
|
995
996
|
// ManagedSlot — on reconnect we skip rebinding, but on session reset
|
|
@@ -1321,9 +1322,10 @@ export class WsHandler {
|
|
|
1321
1322
|
const slot = this.sessionManager.getSession(sid);
|
|
1322
1323
|
if (slot) {
|
|
1323
1324
|
slot.connection = null;
|
|
1324
|
-
slot.sessionState.lastActivity = Date.now();
|
|
1325
1325
|
// Note: pending UI responses are NOT resolved here — they survive
|
|
1326
1326
|
// for replay on reconnect. They are resolved on session close or abort.
|
|
1327
|
+
// Note: do NOT touch `idleSince`. Disconnecting does not reset idleness — if the
|
|
1328
|
+
// agent finished 10 minutes ago, a peeking client should not extend the session's life.
|
|
1327
1329
|
}
|
|
1328
1330
|
}
|
|
1329
1331
|
this.subscribedSessions.clear();
|
|
@@ -71,6 +71,8 @@ export interface PimoteAgentMessage {
|
|
|
71
71
|
* these with an "interrupted" indicator rather than as empty bubbles.
|
|
72
72
|
*/
|
|
73
73
|
aborted?: boolean;
|
|
74
|
+
/** Present on assistant messages whose turn failed before producing content. */
|
|
75
|
+
errorMessage?: string;
|
|
74
76
|
[key: string]: unknown;
|
|
75
77
|
}
|
|
76
78
|
export type CardColor = 'accent' | 'success' | 'warning' | 'error' | 'muted';
|
|
@@ -88,6 +90,12 @@ export interface Card {
|
|
|
88
90
|
};
|
|
89
91
|
body?: BodySection[];
|
|
90
92
|
footer?: string[];
|
|
93
|
+
/**
|
|
94
|
+
* Optional same-origin URL. When present, the client renders the entire
|
|
95
|
+
* card as a clickable link (same-tab navigation). No server-side validation
|
|
96
|
+
* — any string is allowed; the consumer is responsible for using a sane URL.
|
|
97
|
+
*/
|
|
98
|
+
href?: string;
|
|
91
99
|
}
|
|
92
100
|
/** Session tree node transferred over the wire (preview-only, no full message content). */
|
|
93
101
|
export interface PimoteTreeNode {
|