@pimote/pimote 0.3.1 → 0.4.1
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/{CklMSqcv.js → B-E0ZvqP.js} +1 -1
- package/client/build/_app/immutable/chunks/B8_nBK84.js +1 -0
- package/client/build/_app/immutable/chunks/BXTn4iTX.js +5 -0
- package/client/build/_app/immutable/chunks/_NcRSVb1.js +1 -0
- package/client/build/_app/immutable/chunks/wFL3btjl.js +1 -0
- package/client/build/_app/immutable/entry/{app.B-HFVtpC.js → app.B5rNDlzR.js} +2 -2
- package/client/build/_app/immutable/entry/start.Ge2L0aip.js +1 -0
- package/client/build/_app/immutable/nodes/0.DncQszfo.js +10 -0
- package/client/build/_app/immutable/nodes/{1.CmxFYjRm.js → 1.Bm4mW4cF.js} +1 -1
- package/client/build/_app/immutable/nodes/2.CIMIcssN.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 -23
- package/server/dist/message-mapper.js +1 -0
- package/server/dist/paths.js +2 -0
- package/server/dist/server.js +9 -1
- package/server/dist/session-manager.js +21 -7
- 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 +121 -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 +120 -0
- package/server/dist/voice/index.js +31 -6
- package/server/dist/voice/speechmux-client.js +20 -3
- package/server/dist/voice-orchestrator-boot.js +14 -50
- package/server/dist/voice-orchestrator.js +10 -22
- package/shared/dist/protocol.d.ts +24 -1
- package/client/build/_app/immutable/assets/0.C7loWTOC.css +0 -2
- package/client/build/_app/immutable/assets/2.DwPXxSa-.css +0 -1
- package/client/build/_app/immutable/chunks/-Lc-U-GJ.js +0 -1
- package/client/build/_app/immutable/chunks/CO_BwWGt.js +0 -1
- package/client/build/_app/immutable/chunks/D1INvMB9.js +0 -1
- package/client/build/_app/immutable/chunks/D1vhgXpq.js +0 -5
- package/client/build/_app/immutable/entry/start.DJTQ8-sD.js +0 -1
- package/client/build/_app/immutable/nodes/0.CepAO4xf.js +0 -10
- package/client/build/_app/immutable/nodes/2.DAtqfmki.js +0 -54
- package/patches/@mariozechner+pi-coding-agent+0.67.6.patch +0 -24
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default in-memory implementation backed by a `Map<slug, StaticHostRegistration>`
|
|
3
|
+
* with a secondary `Map<sessionId, Set<slug>>` for fast `unregisterAllForSession`.
|
|
4
|
+
*/
|
|
5
|
+
export class InMemoryStaticHostRegistry {
|
|
6
|
+
bySlug = new Map();
|
|
7
|
+
bySession = new Map();
|
|
8
|
+
register(reg) {
|
|
9
|
+
if (this.bySlug.has(reg.slug)) {
|
|
10
|
+
throw new Error(`static-host slug already registered: ${reg.slug}`);
|
|
11
|
+
}
|
|
12
|
+
this.bySlug.set(reg.slug, reg);
|
|
13
|
+
let set = this.bySession.get(reg.sessionId);
|
|
14
|
+
if (!set) {
|
|
15
|
+
set = new Set();
|
|
16
|
+
this.bySession.set(reg.sessionId, set);
|
|
17
|
+
}
|
|
18
|
+
set.add(reg.slug);
|
|
19
|
+
}
|
|
20
|
+
unregister(slug) {
|
|
21
|
+
const entry = this.bySlug.get(slug);
|
|
22
|
+
if (!entry)
|
|
23
|
+
return;
|
|
24
|
+
this.bySlug.delete(slug);
|
|
25
|
+
const set = this.bySession.get(entry.sessionId);
|
|
26
|
+
if (set) {
|
|
27
|
+
set.delete(slug);
|
|
28
|
+
if (set.size === 0)
|
|
29
|
+
this.bySession.delete(entry.sessionId);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
unregisterAllForSession(sessionId) {
|
|
33
|
+
const set = this.bySession.get(sessionId);
|
|
34
|
+
if (!set)
|
|
35
|
+
return;
|
|
36
|
+
for (const slug of set)
|
|
37
|
+
this.bySlug.delete(slug);
|
|
38
|
+
this.bySession.delete(sessionId);
|
|
39
|
+
}
|
|
40
|
+
lookup(slug) {
|
|
41
|
+
return this.bySlug.get(slug);
|
|
42
|
+
}
|
|
43
|
+
has(slug) {
|
|
44
|
+
return this.bySlug.has(slug);
|
|
45
|
+
}
|
|
46
|
+
listForSession(sessionId) {
|
|
47
|
+
const set = this.bySession.get(sessionId);
|
|
48
|
+
if (!set)
|
|
49
|
+
return [];
|
|
50
|
+
const out = [];
|
|
51
|
+
for (const slug of set) {
|
|
52
|
+
const entry = this.bySlug.get(slug);
|
|
53
|
+
if (entry)
|
|
54
|
+
out.push(entry);
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir, rename, unlink } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
/**
|
|
4
|
+
* Filesystem-backed `StaticHostStore`. One file per sessionId under `storeDir`.
|
|
5
|
+
*/
|
|
6
|
+
export class FileStaticHostStore {
|
|
7
|
+
storeDir;
|
|
8
|
+
constructor(storeDir) {
|
|
9
|
+
this.storeDir = storeDir;
|
|
10
|
+
}
|
|
11
|
+
pathFor(sessionId) {
|
|
12
|
+
return join(this.storeDir, `${sessionId}.json`);
|
|
13
|
+
}
|
|
14
|
+
async read(sessionId) {
|
|
15
|
+
const path = this.pathFor(sessionId);
|
|
16
|
+
let raw;
|
|
17
|
+
try {
|
|
18
|
+
raw = await readFile(path, 'utf-8');
|
|
19
|
+
}
|
|
20
|
+
catch (err) {
|
|
21
|
+
if (err.code === 'ENOENT')
|
|
22
|
+
return undefined;
|
|
23
|
+
throw err;
|
|
24
|
+
}
|
|
25
|
+
return JSON.parse(raw);
|
|
26
|
+
}
|
|
27
|
+
async write(sessionId, file) {
|
|
28
|
+
await mkdir(this.storeDir, { recursive: true });
|
|
29
|
+
const finalPath = this.pathFor(sessionId);
|
|
30
|
+
const tmpPath = finalPath + '.tmp';
|
|
31
|
+
await writeFile(tmpPath, JSON.stringify(file, null, 2) + '\n', 'utf-8');
|
|
32
|
+
await rename(tmpPath, finalPath);
|
|
33
|
+
}
|
|
34
|
+
async remove(sessionId) {
|
|
35
|
+
try {
|
|
36
|
+
await unlink(this.pathFor(sessionId));
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
if (err.code === 'ENOENT')
|
|
40
|
+
return;
|
|
41
|
+
throw err;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
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
|
+
const url = `/s/${resolved}/`;
|
|
98
|
+
deps.emitNavigate(url);
|
|
99
|
+
return { slug: resolved, url };
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Execute the `pimote_static_host_remove` tool body. Returns `{ removed: false }`
|
|
103
|
+
* when the slug is not owned by this session.
|
|
104
|
+
*
|
|
105
|
+
* Concurrency assumption: same as `executeRegisterTool` — relies on pi's
|
|
106
|
+
* per-session serialization of tool calls. If that changes, this body must
|
|
107
|
+
* be revisited.
|
|
108
|
+
*/
|
|
109
|
+
export async function executeRemoveTool(input, deps) {
|
|
110
|
+
const existing = deps.registry.lookup(input.slug);
|
|
111
|
+
if (!existing || existing.sessionId !== deps.sessionId) {
|
|
112
|
+
return { removed: false };
|
|
113
|
+
}
|
|
114
|
+
const file = (await deps.store.read(deps.sessionId)) ?? { version: 1, entries: [] };
|
|
115
|
+
const entries = file.entries.filter((e) => e.slug !== input.slug);
|
|
116
|
+
await deps.store.write(deps.sessionId, { version: 1, entries });
|
|
117
|
+
deps.registry.unregister(input.slug);
|
|
118
|
+
deps.emitPanelCards();
|
|
119
|
+
return { removed: true };
|
|
120
|
+
}
|
|
@@ -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';
|
|
@@ -168,6 +168,9 @@ export function createVoiceExtension(opts) {
|
|
|
168
168
|
client.onFrame((frame) => {
|
|
169
169
|
void dispatch({ type: 'ws:incoming', frame });
|
|
170
170
|
});
|
|
171
|
+
client.onDisconnect(() => {
|
|
172
|
+
void dispatch({ type: 'ws:disconnected' });
|
|
173
|
+
});
|
|
171
174
|
await dispatch({ type: 'ws:opened' });
|
|
172
175
|
}
|
|
173
176
|
catch (err) {
|
|
@@ -191,7 +194,7 @@ export function createVoiceExtension(opts) {
|
|
|
191
194
|
console.warn('[voice] send_frame with no client — dropping', action.frame.type);
|
|
192
195
|
return;
|
|
193
196
|
}
|
|
194
|
-
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;
|
|
195
198
|
console.log('[voice_trace] send_frame', JSON.stringify({ type: action.frame.type, preview }));
|
|
196
199
|
try {
|
|
197
200
|
speechmuxClient.send(action.frame);
|
|
@@ -271,10 +274,6 @@ export function createVoiceExtension(opts) {
|
|
|
271
274
|
// The `tool_call` hook is intentionally NOT registered. The streaming
|
|
272
275
|
// reducer is the sole emitter of speak frames; bulk-emission via
|
|
273
276
|
// tool_call was the source of the double-emit class of bugs.
|
|
274
|
-
//
|
|
275
|
-
// The `turn_end` safety net is also intentionally NOT registered.
|
|
276
|
-
// With per-speak `end` framing driven by `toolcall_end`, it was
|
|
277
|
-
// redundant and contributed to double-end emissions.
|
|
278
277
|
pi.on('message_start', (event) => {
|
|
279
278
|
// Only assistant messages reset the streaming state. User and
|
|
280
279
|
// tool-result messages don't have content blocks we care about.
|
|
@@ -319,6 +318,32 @@ export function createVoiceExtension(opts) {
|
|
|
319
318
|
return;
|
|
320
319
|
}
|
|
321
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
|
+
});
|
|
322
347
|
pi.on('context', (event, ctx) => {
|
|
323
348
|
lastCtx = ctx;
|
|
324
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:
|
|
@@ -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) {
|
|
@@ -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 {
|
|
@@ -550,6 +558,21 @@ export interface PanelUpdateEvent {
|
|
|
550
558
|
sessionId: string;
|
|
551
559
|
cards: Card[];
|
|
552
560
|
}
|
|
561
|
+
/**
|
|
562
|
+
* Server-initiated client navigation request. Emitted by extensions that
|
|
563
|
+
* register a new same-origin surface (e.g. `pimote_static_host`) and want
|
|
564
|
+
* the client to jump to it on creation. The client decides whether to act —
|
|
565
|
+
* the static-host extension emits this only when the session is the one
|
|
566
|
+
* that issued the tool call, but clients should still ignore the event when
|
|
567
|
+
* the targeted `sessionId` is not the currently viewed session to avoid
|
|
568
|
+
* yanking users browsing elsewhere.
|
|
569
|
+
*/
|
|
570
|
+
export interface NavigateEvent {
|
|
571
|
+
type: 'pimote_navigate';
|
|
572
|
+
sessionId: string;
|
|
573
|
+
/** Same-origin URL to navigate to. */
|
|
574
|
+
url: string;
|
|
575
|
+
}
|
|
553
576
|
/**
|
|
554
577
|
* Success response to a CallBindCommand. Carries the per-call WebRTC
|
|
555
578
|
* signalling endpoint the client should connect to. The PWA obtains
|
|
@@ -601,7 +624,7 @@ export interface VersionMismatchEvent {
|
|
|
601
624
|
type: 'version_mismatch';
|
|
602
625
|
serverVersion: string;
|
|
603
626
|
}
|
|
604
|
-
export type PimoteEvent = PimoteSessionEvent | ExtensionUiRequestEvent | SessionConflictEvent | SessionOpenedEvent | SessionClosedEvent | SessionDeletedEvent | SessionRenamedEvent | SessionArchivedEvent | SessionReplacedEvent | SessionStateChangedEvent | ConnectionRestoredEvent | SessionRestoreEvent | BufferedEventsEvent | FullResyncEvent | PanelUpdateEvent | CallBindResponse | CallReadyEvent | CallEndedEvent | CallStatusEvent | VersionMismatchEvent;
|
|
627
|
+
export type PimoteEvent = PimoteSessionEvent | ExtensionUiRequestEvent | SessionConflictEvent | SessionOpenedEvent | SessionClosedEvent | SessionDeletedEvent | SessionRenamedEvent | SessionArchivedEvent | SessionReplacedEvent | SessionStateChangedEvent | ConnectionRestoredEvent | SessionRestoreEvent | BufferedEventsEvent | FullResyncEvent | PanelUpdateEvent | NavigateEvent | CallBindResponse | CallReadyEvent | CallEndedEvent | CallStatusEvent | VersionMismatchEvent;
|
|
605
628
|
export interface PimoteResponse<T = unknown> {
|
|
606
629
|
/** Matches the `id` from the originating PimoteCommand */
|
|
607
630
|
id: string;
|