@pimote/pimote 0.2.0 → 0.3.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 +43 -16
- package/client/build/_app/immutable/assets/0.C7loWTOC.css +2 -0
- package/client/build/_app/immutable/assets/2.D9fiCd8W.css +1 -0
- package/client/build/_app/immutable/chunks/BNqgidwO.js +5 -0
- package/client/build/_app/immutable/chunks/D26i4pYm.js +1 -0
- package/client/build/_app/immutable/chunks/D_Fpgknp.js +1 -0
- package/client/build/_app/immutable/chunks/DoVhjU85.js +1 -0
- package/client/build/_app/immutable/chunks/DzqbY2XU.js +1 -0
- package/client/build/_app/immutable/entry/{app.CNzpBgAg.js → app.DO-zgzyy.js} +2 -2
- package/client/build/_app/immutable/entry/start.BZlrOH0-.js +1 -0
- package/client/build/_app/immutable/nodes/0.BEh4bPGQ.js +10 -0
- package/client/build/_app/immutable/nodes/{1.B8zmHMre.js → 1.B2l9JGRO.js} +1 -1
- package/client/build/_app/immutable/nodes/2.ph9M0S1U.js +54 -0
- package/client/build/_app/version.json +1 -1
- package/client/build/index.html +7 -7
- package/package.json +7 -3
- package/server/dist/auto-drain-on-abort.js +49 -0
- package/server/dist/config.js +21 -0
- package/server/dist/extension-ui-bridge.js +14 -1
- package/server/dist/index.js +31 -1
- package/server/dist/message-mapper.js +38 -6
- package/server/dist/server.js +2 -2
- package/server/dist/session-manager.js +64 -2
- package/server/dist/voice/fsm/actions.js +6 -0
- package/server/dist/voice/fsm/events.js +7 -0
- package/server/dist/voice/fsm/reducer.js +74 -0
- package/server/dist/voice/fsm/reducers/lifecycle.js +146 -0
- package/server/dist/voice/fsm/reducers/streaming.js +220 -0
- package/server/dist/voice/fsm/reducers/walkback.js +73 -0
- package/server/dist/voice/fsm/state.js +21 -0
- package/server/dist/voice/fsm/text-extractor.js +128 -0
- package/server/dist/voice/index.js +319 -0
- package/server/dist/voice/interpreter-prompt.js +115 -0
- package/server/dist/voice/speechmux-client.js +153 -0
- package/server/dist/voice/state-machine.js +7 -0
- package/server/dist/voice/wait-for-idle.js +67 -0
- package/server/dist/voice/walk-back.js +198 -0
- package/server/dist/voice-orchestrator-boot.js +90 -0
- package/server/dist/voice-orchestrator.js +91 -0
- package/server/dist/ws-handler.js +108 -5
- package/shared/dist/index.d.ts +1 -0
- package/shared/dist/index.js +2 -0
- package/shared/dist/protocol.d.ts +614 -0
- package/shared/dist/protocol.js +30 -0
- package/client/build/_app/immutable/assets/0.DBrr7n4n.css +0 -2
- package/client/build/_app/immutable/assets/2.DE6k3bQj.css +0 -1
- package/client/build/_app/immutable/chunks/5vSSf6qG.js +0 -5
- package/client/build/_app/immutable/chunks/CT6ckxpD.js +0 -1
- package/client/build/_app/immutable/chunks/DlJOVoUQ.js +0 -1
- package/client/build/_app/immutable/chunks/YxmLwfhj.js +0 -1
- package/client/build/_app/immutable/chunks/yWVx3W2o.js +0 -1
- package/client/build/_app/immutable/entry/start.DYkTAHh1.js +0 -1
- package/client/build/_app/immutable/nodes/0.DNlQhEb_.js +0 -10
- package/client/build/_app/immutable/nodes/2.W9yV4-x2.js +0 -54
|
@@ -55,13 +55,36 @@ export function extractMessageEntryIds(branch) {
|
|
|
55
55
|
}
|
|
56
56
|
return ids;
|
|
57
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* True when the message is pi-agent-core's synthetic aborted placeholder
|
|
60
|
+
* (pushed into agent.state.messages on session.abort() but never persisted
|
|
61
|
+
* via message_end). Identifying these lets entryId alignment skip over them.
|
|
62
|
+
*/
|
|
63
|
+
export function isAbortedPlaceholderMessage(msg) {
|
|
64
|
+
if (msg.role !== 'assistant')
|
|
65
|
+
return false;
|
|
66
|
+
if (msg.aborted !== true)
|
|
67
|
+
return false;
|
|
68
|
+
return msg.content.every((c) => c.type === 'text' && !c.text);
|
|
69
|
+
}
|
|
58
70
|
/**
|
|
59
71
|
* Apply entry IDs from the session manager onto mapped messages.
|
|
60
|
-
*
|
|
72
|
+
*
|
|
73
|
+
* Subtle alignment: `messages` comes from `agent.state.messages`, which
|
|
74
|
+
* includes pi-agent-core's synthetic aborted placeholders (abort pushes an
|
|
75
|
+
* empty assistant into state but never persists an entry for it).
|
|
76
|
+
* `entryIds` comes from persisted session entries, which do NOT include
|
|
77
|
+
* those placeholders. We walk the messages and skip aborted placeholders
|
|
78
|
+
* so the persisted IDs land on the correct real messages.
|
|
61
79
|
*/
|
|
62
80
|
export function applyEntryIds(messages, entryIds) {
|
|
63
|
-
|
|
64
|
-
|
|
81
|
+
let idIdx = 0;
|
|
82
|
+
for (let i = 0; i < messages.length; i++) {
|
|
83
|
+
if (isAbortedPlaceholderMessage(messages[i]))
|
|
84
|
+
continue;
|
|
85
|
+
if (idIdx >= entryIds.length)
|
|
86
|
+
break;
|
|
87
|
+
messages[i].entryId = entryIds[idIdx++];
|
|
65
88
|
}
|
|
66
89
|
}
|
|
67
90
|
export function mapAgentMessage(msg) {
|
|
@@ -104,8 +127,12 @@ export function mapAgentMessage(msg) {
|
|
|
104
127
|
console.warn('[message-mapper] Content value:', msg.content);
|
|
105
128
|
content.push({ type: 'text', text: `[Unexpected content type: ${typeof msg.content}]` });
|
|
106
129
|
}
|
|
107
|
-
//
|
|
108
|
-
|
|
130
|
+
// Aborted assistant turns are a real signal in voice mode (every barge-in
|
|
131
|
+
// produces one via pi-agent-core's handleRunFailure) and shouldn't be
|
|
132
|
+
// confused with malformed messages. Log the empty-content warning only
|
|
133
|
+
// when it's NOT an expected aborted turn.
|
|
134
|
+
const aborted = role === 'assistant' && msg.stopReason === 'aborted';
|
|
135
|
+
if (content.length === 0 && !aborted) {
|
|
109
136
|
console.warn('[message-mapper] Empty content array for message:', { role, content: msg.content });
|
|
110
137
|
}
|
|
111
138
|
// Handle custom messages — preserve customType and display flag for the client
|
|
@@ -139,5 +166,10 @@ export function mapAgentMessage(msg) {
|
|
|
139
166
|
// Note: msg.id is typically undefined for standard SDK messages (UserMessage,
|
|
140
167
|
// AssistantMessage, ToolResultMessage). Entry IDs are applied separately via
|
|
141
168
|
// applyEntryIds() using the session manager's branch entries.
|
|
142
|
-
return {
|
|
169
|
+
return {
|
|
170
|
+
role,
|
|
171
|
+
content,
|
|
172
|
+
...(msg.id ? { entryId: msg.id } : {}),
|
|
173
|
+
...(aborted ? { aborted: true } : {}),
|
|
174
|
+
};
|
|
143
175
|
}
|
package/server/dist/server.js
CHANGED
|
@@ -79,7 +79,7 @@ async function serveFallback(res) {
|
|
|
79
79
|
res.end(JSON.stringify({ error: 'not found' }));
|
|
80
80
|
}
|
|
81
81
|
}
|
|
82
|
-
export async function createServer(config, sessionManager, folderIndex, pushNotificationService, sessionMetadataStore) {
|
|
82
|
+
export async function createServer(config, sessionManager, folderIndex, pushNotificationService, sessionMetadataStore, voiceOrchestrator) {
|
|
83
83
|
const clientVersion = await loadClientVersion();
|
|
84
84
|
if (clientVersion) {
|
|
85
85
|
console.log(`[pimote] Client build version: ${clientVersion}`);
|
|
@@ -157,7 +157,7 @@ export async function createServer(config, sessionManager, folderIndex, pushNoti
|
|
|
157
157
|
// The close handler skips cleanup when the registry already points to a
|
|
158
158
|
// different handler, so this is the only place it runs.
|
|
159
159
|
const existing = clientRegistry.get(clientId);
|
|
160
|
-
const handler = new WsHandler(sessionManager, folderIndex, ws, pushNotificationService, sessionMetadataStore, clientId, clientRegistry);
|
|
160
|
+
const handler = new WsHandler(sessionManager, folderIndex, ws, pushNotificationService, sessionMetadataStore, clientId, clientRegistry, voiceOrchestrator);
|
|
161
161
|
clientRegistry.set(clientId, handler);
|
|
162
162
|
if (existing) {
|
|
163
163
|
existing.cleanup();
|
|
@@ -2,6 +2,8 @@ import { createAgentSessionRuntime, createAgentSessionServices, createAgentSessi
|
|
|
2
2
|
import { EventBuffer } from './event-buffer.js';
|
|
3
3
|
import { applyPanelMessage, getMergedPanelCards } from './panel-state.js';
|
|
4
4
|
import { getGitBranch } from './git-branch.js';
|
|
5
|
+
import { createVoiceExtension } from './voice/index.js';
|
|
6
|
+
import { autoDrainOnAbort } from './auto-drain-on-abort.js';
|
|
5
7
|
// ---- Slot-based helpers (operate on ManagedSlot) ----
|
|
6
8
|
/** Send an event to the client connected to this slot. No-op if disconnected. */
|
|
7
9
|
export function sendSlotEvent(slot, event) {
|
|
@@ -74,6 +76,13 @@ function createSessionState(session, eventBus, config, callbacks, slotRef, folde
|
|
|
74
76
|
if (slotRef.slot)
|
|
75
77
|
callbacks.onAgentEnd?.(sessionId, slotRef.slot);
|
|
76
78
|
callbacks.onStatusChange?.(sessionId, folderPath);
|
|
79
|
+
// If the run ended via abort and there are queued steering /
|
|
80
|
+
// follow-up messages, drain them — pi-agent-core's runLoop skips
|
|
81
|
+
// its trailing queue poll on the abort exit path, so without this
|
|
82
|
+
// queued messages would sit until the next prompt() call. Universal
|
|
83
|
+
// across pimote (not voice-specific) so typed-mode users also
|
|
84
|
+
// benefit. See `auto-drain-on-abort.ts` for rationale.
|
|
85
|
+
void autoDrainOnAbort(session, event.messages[event.messages.length - 1]);
|
|
77
86
|
}
|
|
78
87
|
eventBuffer.onEvent(event, sessionId, (e) => callbacks.sendEvent(e), () => session.messages[session.messages.length - 1]);
|
|
79
88
|
});
|
|
@@ -132,18 +141,43 @@ export class PimoteSessionManager {
|
|
|
132
141
|
onStatusChange;
|
|
133
142
|
onSessionClosed;
|
|
134
143
|
onGitBranchChange;
|
|
144
|
+
/** Fired synchronously before a session's state is torn down (e.g. idle
|
|
145
|
+
* reap, explicit close). Consumers use this to drop external bookkeeping
|
|
146
|
+
* (e.g. `VoiceOrchestrator.endCall`) while the session is still addressable. */
|
|
147
|
+
onBeforeSessionClose;
|
|
135
148
|
constructor(config, pushNotificationService) {
|
|
136
149
|
this.config = config;
|
|
137
150
|
this.pushNotificationService = pushNotificationService;
|
|
138
151
|
this.authStorage = AuthStorage.create();
|
|
139
152
|
this.modelRegistry = ModelRegistry.create(this.authStorage);
|
|
140
153
|
}
|
|
154
|
+
/**
|
|
155
|
+
* Build the voice extension factory for this server's config, if possible.
|
|
156
|
+
* Returns undefined (and logs a warning) when neither voice-specific model
|
|
157
|
+
* refs nor fallback defaultProvider/defaultModel are configured — existing
|
|
158
|
+
* non-voice deployments continue to work unchanged.
|
|
159
|
+
*/
|
|
160
|
+
buildVoiceExtensionFactory() {
|
|
161
|
+
const interpreter = this.config.defaultInterpreterModel ??
|
|
162
|
+
(this.config.defaultProvider && this.config.defaultModel ? { provider: this.config.defaultProvider, modelId: this.config.defaultModel } : undefined);
|
|
163
|
+
const worker = this.config.defaultWorkerModel ??
|
|
164
|
+
(this.config.defaultProvider && this.config.defaultModel ? { provider: this.config.defaultProvider, modelId: this.config.defaultModel } : undefined);
|
|
165
|
+
if (!interpreter || !worker) {
|
|
166
|
+
console.warn('[pimote] voice extension disabled: no defaultInterpreterModel/defaultWorkerModel or fallback defaultProvider/defaultModel in config');
|
|
167
|
+
return undefined;
|
|
168
|
+
}
|
|
169
|
+
return createVoiceExtension({
|
|
170
|
+
defaultInterpreterModel: interpreter,
|
|
171
|
+
defaultWorkerModel: worker,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
141
174
|
async openSession(folderPath, sessionFilePath) {
|
|
142
175
|
const eventBusRef = { current: null };
|
|
143
176
|
const sharedAuthStorage = this.authStorage;
|
|
144
177
|
const sharedModelRegistry = this.modelRegistry;
|
|
145
178
|
const sessionManager = sessionFilePath ? PiSessionManager.open(sessionFilePath) : PiSessionManager.create(folderPath);
|
|
146
179
|
const effectiveFolderPath = sessionFilePath ? sessionManager.getCwd() : folderPath;
|
|
180
|
+
const voiceExtensionFactory = this.buildVoiceExtensionFactory();
|
|
147
181
|
const factory = async ({ cwd, agentDir, sessionManager, sessionStartEvent }) => {
|
|
148
182
|
const eventBus = createEventBus();
|
|
149
183
|
eventBusRef.current = eventBus;
|
|
@@ -152,7 +186,10 @@ export class PimoteSessionManager {
|
|
|
152
186
|
agentDir,
|
|
153
187
|
authStorage: sharedAuthStorage,
|
|
154
188
|
modelRegistry: sharedModelRegistry,
|
|
155
|
-
resourceLoaderOptions: {
|
|
189
|
+
resourceLoaderOptions: {
|
|
190
|
+
eventBus,
|
|
191
|
+
...(voiceExtensionFactory ? { extensionFactories: [voiceExtensionFactory] } : {}),
|
|
192
|
+
},
|
|
156
193
|
});
|
|
157
194
|
return {
|
|
158
195
|
...(await createAgentSessionFromServices({ services, sessionManager, sessionStartEvent })),
|
|
@@ -187,6 +224,12 @@ export class PimoteSessionManager {
|
|
|
187
224
|
session.setThinkingLevel(this.config.defaultThinkingLevel);
|
|
188
225
|
console.log(`[pimote] Set default thinking level: ${this.config.defaultThinkingLevel}`);
|
|
189
226
|
}
|
|
227
|
+
// Pimote convention: drain the entire steering / follow-up queue into a
|
|
228
|
+
// single consolidated run rather than one message per run. Matches the
|
|
229
|
+
// UX expectation that queued messages are delivered together when the
|
|
230
|
+
// agent next processes, not metered out across multiple turns.
|
|
231
|
+
session.setSteeringMode('all');
|
|
232
|
+
session.setFollowUpMode('all');
|
|
190
233
|
// Create the slot object. Use a slotRef so createSessionState callbacks can reference the slot.
|
|
191
234
|
const slotRef = { slot: null };
|
|
192
235
|
const sessionState = createSessionState(session, eventBusRef.current, this.config, {
|
|
@@ -264,6 +307,14 @@ export class PimoteSessionManager {
|
|
|
264
307
|
const slot = this.sessions.get(sessionId);
|
|
265
308
|
if (!slot)
|
|
266
309
|
return;
|
|
310
|
+
if (this.onBeforeSessionClose) {
|
|
311
|
+
try {
|
|
312
|
+
await this.onBeforeSessionClose(sessionId, slot.folderPath);
|
|
313
|
+
}
|
|
314
|
+
catch (err) {
|
|
315
|
+
console.warn('[pimote] onBeforeSessionClose threw:', err);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
267
318
|
teardownSessionState(slot.sessionState);
|
|
268
319
|
slot.eventBusRef.current?.clear();
|
|
269
320
|
const folderPath = slot.folderPath;
|
|
@@ -281,9 +332,15 @@ export class PimoteSessionManager {
|
|
|
281
332
|
this.lastKnownGitBranchBySession.set(newId, lastKnown);
|
|
282
333
|
}
|
|
283
334
|
/** Rebuild a slot's SessionState after session replacement.
|
|
284
|
-
* Tears down the old state and creates a new one from the current runtime.session.
|
|
335
|
+
* Tears down the old state and creates a new one from the current runtime.session.
|
|
336
|
+
* Also refreshes slot.folderPath from the new session's header cwd, since fork-from
|
|
337
|
+
* (e.g. the worktree extension) can rebind the slot to a session whose cwd differs
|
|
338
|
+
* from the original. */
|
|
285
339
|
rebuildSessionState(slot) {
|
|
286
340
|
teardownSessionState(slot.sessionState);
|
|
341
|
+
const newCwd = slot.runtime.session.sessionManager.getCwd();
|
|
342
|
+
if (newCwd)
|
|
343
|
+
slot.folderPath = newCwd;
|
|
287
344
|
const slotRef = { slot: slot };
|
|
288
345
|
slot.sessionState = createSessionState(slot.runtime.session, slot.eventBusRef.current, this.config, {
|
|
289
346
|
onStatusChange: (sid, fp) => this.onStatusChange?.(sid, fp),
|
|
@@ -291,6 +348,11 @@ export class PimoteSessionManager {
|
|
|
291
348
|
sendEvent: (e) => sendSlotEvent(slot, e),
|
|
292
349
|
}, slotRef, slot.folderPath);
|
|
293
350
|
}
|
|
351
|
+
/** Alias for getSession that returns null (not undefined) for consumers
|
|
352
|
+
* that expect nullable pointers (e.g. VoiceSessionBusResolver). */
|
|
353
|
+
getSlot(sessionId) {
|
|
354
|
+
return this.sessions.get(sessionId) ?? null;
|
|
355
|
+
}
|
|
294
356
|
getSession(sessionId) {
|
|
295
357
|
return this.sessions.get(sessionId);
|
|
296
358
|
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Events the voice FSM consumes.
|
|
2
|
+
//
|
|
3
|
+
// Every external stimulus (EventBus, WS lifecycle, WS frames, SDK hooks)
|
|
4
|
+
// is normalized into one of these typed events before reaching the
|
|
5
|
+
// reducer. The shell in `index.ts` is the only place where ad-hoc input
|
|
6
|
+
// translation lives.
|
|
7
|
+
export {};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Top-level voice FSM reducer.
|
|
2
|
+
//
|
|
3
|
+
// Folds the three sub-reducers (lifecycle, streaming, walkback) into a
|
|
4
|
+
// single transition. Every event is fanned out to the sub-reducers
|
|
5
|
+
// that care about it; results are merged.
|
|
6
|
+
//
|
|
7
|
+
// The sub-reducers don't know about each other. Two cross-cutting bits
|
|
8
|
+
// are handled here:
|
|
9
|
+
//
|
|
10
|
+
// 1. Frame buffering. The streaming reducer emits raw `OutgoingFrame`
|
|
11
|
+
// values; we route each through `bufferOrPassFrame` against the
|
|
12
|
+
// current lifecycle state to either forward (Active) or buffer
|
|
13
|
+
// (Activating). The buffered case mutates the lifecycle slice; the
|
|
14
|
+
// forwarded case becomes a `send_frame` action.
|
|
15
|
+
//
|
|
16
|
+
// 2. The `ws:incoming` `user` frame turns into a `send_user_message`
|
|
17
|
+
// action — and only when lifecycle is `active`. (The walkback
|
|
18
|
+
// reducer intentionally ignores user frames; this is the right
|
|
19
|
+
// place to handle them because it lives at the boundary between
|
|
20
|
+
// "do we even have a connection" and "what should the agent do".)
|
|
21
|
+
import { reduceLifecycle, applyLifecycleResult, bufferOrPassFrame } from './reducers/lifecycle.js';
|
|
22
|
+
import { reduceStreaming } from './reducers/streaming.js';
|
|
23
|
+
import { reduceWalkback, applyWalkbackResult } from './reducers/walkback.js';
|
|
24
|
+
export function reduce(prev, event, reducers) {
|
|
25
|
+
let state = prev;
|
|
26
|
+
const actions = [];
|
|
27
|
+
// ---- Lifecycle ---------------------------------------------------------
|
|
28
|
+
const life = reduceLifecycle(state.lifecycle, event, {
|
|
29
|
+
interpreterApplied: state.interpreterApplied,
|
|
30
|
+
config: reducers.config,
|
|
31
|
+
});
|
|
32
|
+
state = applyLifecycleResult(state, life);
|
|
33
|
+
actions.push(...life.actions);
|
|
34
|
+
// ---- Streaming ---------------------------------------------------------
|
|
35
|
+
// Only meaningful when activating or active. Dormant means we can't
|
|
36
|
+
// emit frames anywhere, so skip the work and any accidental frames.
|
|
37
|
+
if (state.lifecycle.kind !== 'dormant') {
|
|
38
|
+
const stream = reduceStreaming(state.message, event);
|
|
39
|
+
state = { ...state, message: stream.next };
|
|
40
|
+
for (const frame of stream.frames) {
|
|
41
|
+
const routed = bufferOrPassFrame(state.lifecycle, frame);
|
|
42
|
+
state = { ...state, lifecycle: routed.next };
|
|
43
|
+
actions.push(...routed.actions);
|
|
44
|
+
}
|
|
45
|
+
// Fold the latest ended speak id into runtime so walkback has a
|
|
46
|
+
// fallback target if speechmux's rollback/abort doesn't echo a
|
|
47
|
+
// speak_id (e.g. older speechmux build).
|
|
48
|
+
if (stream.endedSpeakIds.length > 0) {
|
|
49
|
+
state = {
|
|
50
|
+
...state,
|
|
51
|
+
lastEmittedSpeakId: stream.endedSpeakIds[stream.endedSpeakIds.length - 1] ?? state.lastEmittedSpeakId,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
// ---- Walkback ----------------------------------------------------------
|
|
56
|
+
const wb = reduceWalkback(state.walkback, state.lastEmittedSpeakId, event);
|
|
57
|
+
state = applyWalkbackResult(state, wb);
|
|
58
|
+
actions.push(...wb.actions);
|
|
59
|
+
// Clear lastEmittedSpeakId on full deactivation so a subsequent call
|
|
60
|
+
// doesn't carry it over.
|
|
61
|
+
if (event.type === 'eb:deactivate') {
|
|
62
|
+
state = { ...state, lastEmittedSpeakId: null };
|
|
63
|
+
}
|
|
64
|
+
// ---- Cross-cutting: incoming `user` frame → sendUserMessage ------------
|
|
65
|
+
if (event.type === 'ws:incoming' && event.frame.type === 'user') {
|
|
66
|
+
if (state.lifecycle.kind === 'active') {
|
|
67
|
+
actions.push({ kind: 'send_user_message', text: event.frame.text });
|
|
68
|
+
}
|
|
69
|
+
// If lifecycle isn't active, drop. The shell will log this — it
|
|
70
|
+
// means a user frame arrived after we tore down (or before we
|
|
71
|
+
// wired the WS), both of which are bugs upstream.
|
|
72
|
+
}
|
|
73
|
+
return { next: state, actions };
|
|
74
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// Concern A: Lifecycle reducer.
|
|
2
|
+
//
|
|
3
|
+
// Responsible for:
|
|
4
|
+
// - Activate / deactivate of voice mode.
|
|
5
|
+
// - WS connection lifecycle (opened / failed / disconnected).
|
|
6
|
+
// - Buffering of outgoing speak frames during the `activating` window
|
|
7
|
+
// and flushing them on `ws:opened`.
|
|
8
|
+
//
|
|
9
|
+
// Holds NO knowledge of the streaming / walkback machines. Those plug in
|
|
10
|
+
// through the top-level dispatcher.
|
|
11
|
+
import { VOICE_CALL_STARTED_SENTINEL } from '../../state-machine.js';
|
|
12
|
+
/**
|
|
13
|
+
* Pure transition function for the lifecycle slice.
|
|
14
|
+
*
|
|
15
|
+
* Emits the bulk of the side-effect actions: model setup, sentinel user
|
|
16
|
+
* message, WS open/close, deactivate-request.
|
|
17
|
+
*
|
|
18
|
+
* Frame emission policy: whenever the streaming machine produces a
|
|
19
|
+
* `send_frame` action, it goes via the top-level dispatcher into
|
|
20
|
+
* `bufferOrPassFrame()` below — which either forwards it (Active) or
|
|
21
|
+
* appends to `pendingFrames` (Activating). On `ws:opened` we flush in
|
|
22
|
+
* order.
|
|
23
|
+
*/
|
|
24
|
+
export function reduceLifecycle(prev, event, ctx) {
|
|
25
|
+
switch (event.type) {
|
|
26
|
+
case 'eb:activate': {
|
|
27
|
+
if (prev.kind !== 'dormant') {
|
|
28
|
+
// Re-activation while already active or activating is a no-op
|
|
29
|
+
// — the orchestrator's bind path is supposed to be the single
|
|
30
|
+
// owner. We log loudly in the shell; here we just stay put.
|
|
31
|
+
return { next: prev, interpreterAppliedNow: false, actions: [] };
|
|
32
|
+
}
|
|
33
|
+
const actions = [];
|
|
34
|
+
if (!ctx.interpreterApplied) {
|
|
35
|
+
actions.push({
|
|
36
|
+
kind: 'set_interpreter_model',
|
|
37
|
+
provider: ctx.config.defaultInterpreterModel.provider,
|
|
38
|
+
modelId: ctx.config.defaultInterpreterModel.modelId,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
actions.push({ kind: 'send_user_message', text: VOICE_CALL_STARTED_SENTINEL });
|
|
42
|
+
actions.push({ kind: 'open_ws', url: event.msg.speechmuxWsUrl });
|
|
43
|
+
return {
|
|
44
|
+
next: {
|
|
45
|
+
kind: 'activating',
|
|
46
|
+
sessionId: event.msg.sessionId,
|
|
47
|
+
wsUrl: event.msg.speechmuxWsUrl,
|
|
48
|
+
pendingFrames: [],
|
|
49
|
+
},
|
|
50
|
+
interpreterAppliedNow: !ctx.interpreterApplied,
|
|
51
|
+
actions,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
case 'eb:deactivate': {
|
|
55
|
+
if (prev.kind === 'dormant') {
|
|
56
|
+
return { next: prev, interpreterAppliedNow: false, actions: [] };
|
|
57
|
+
}
|
|
58
|
+
// Idempotent: close_ws is a no-op if no client is open.
|
|
59
|
+
return {
|
|
60
|
+
next: { kind: 'dormant' },
|
|
61
|
+
interpreterAppliedNow: false,
|
|
62
|
+
actions: [{ kind: 'close_ws' }],
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
case 'ws:opened': {
|
|
66
|
+
if (prev.kind !== 'activating') {
|
|
67
|
+
// Stray opened event (e.g. after a deactivate-then-open race).
|
|
68
|
+
// Close the new connection if we somehow have one.
|
|
69
|
+
return { next: prev, interpreterAppliedNow: false, actions: [] };
|
|
70
|
+
}
|
|
71
|
+
const actions = [];
|
|
72
|
+
// Flush buffered speak frames in arrival order. Done here, not
|
|
73
|
+
// in the streaming reducer, so frame ordering is preserved across
|
|
74
|
+
// the activating→active boundary.
|
|
75
|
+
for (const frame of prev.pendingFrames) {
|
|
76
|
+
actions.push({ kind: 'send_frame', frame });
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
next: { kind: 'active', sessionId: prev.sessionId },
|
|
80
|
+
interpreterAppliedNow: false,
|
|
81
|
+
actions,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
case 'ws:open_failed': {
|
|
85
|
+
if (prev.kind !== 'activating') {
|
|
86
|
+
return { next: prev, interpreterAppliedNow: false, actions: [] };
|
|
87
|
+
}
|
|
88
|
+
// Drop any buffered frames; the shell will rebuild from scratch
|
|
89
|
+
// on the next activate.
|
|
90
|
+
return {
|
|
91
|
+
next: { kind: 'dormant' },
|
|
92
|
+
interpreterAppliedNow: false,
|
|
93
|
+
actions: [{ kind: 'emit_deactivate_request' }],
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
case 'ws:disconnected': {
|
|
97
|
+
if (prev.kind === 'dormant') {
|
|
98
|
+
return { next: prev, interpreterAppliedNow: false, actions: [] };
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
next: { kind: 'dormant' },
|
|
102
|
+
interpreterAppliedNow: false,
|
|
103
|
+
actions: [{ kind: 'emit_deactivate_request' }],
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
default:
|
|
107
|
+
return { next: prev, interpreterAppliedNow: false, actions: [] };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Apply a `send_frame` action against the lifecycle state. Returns
|
|
112
|
+
* either the same action (to be executed by the shell) or a state
|
|
113
|
+
* mutation that buffers the frame for later flush.
|
|
114
|
+
*
|
|
115
|
+
* Splitting this out keeps the streaming reducer agnostic of the
|
|
116
|
+
* lifecycle phase — it always emits `send_frame`; this function decides
|
|
117
|
+
* whether to forward or buffer.
|
|
118
|
+
*/
|
|
119
|
+
export function bufferOrPassFrame(prev, frame) {
|
|
120
|
+
switch (prev.kind) {
|
|
121
|
+
case 'dormant':
|
|
122
|
+
// Frame produced while no call is bound — drop. Streaming reducer
|
|
123
|
+
// is supposed to no-op while dormant; if we land here it's a
|
|
124
|
+
// diagnostic the shell will log.
|
|
125
|
+
return { next: prev, actions: [] };
|
|
126
|
+
case 'activating':
|
|
127
|
+
return {
|
|
128
|
+
next: { ...prev, pendingFrames: [...prev.pendingFrames, frame] },
|
|
129
|
+
actions: [],
|
|
130
|
+
};
|
|
131
|
+
case 'active':
|
|
132
|
+
return { next: prev, actions: [{ kind: 'send_frame', frame }] };
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Top-level merge helper used by the dispatcher to splice the lifecycle
|
|
137
|
+
* sub-state back into the runtime record. Kept here so the dispatcher
|
|
138
|
+
* stays mechanical.
|
|
139
|
+
*/
|
|
140
|
+
export function applyLifecycleResult(prev, r) {
|
|
141
|
+
return {
|
|
142
|
+
...prev,
|
|
143
|
+
lifecycle: r.next,
|
|
144
|
+
interpreterApplied: prev.interpreterApplied || r.interpreterAppliedNow,
|
|
145
|
+
};
|
|
146
|
+
}
|