@pimote/pimote 0.1.1 → 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 +46 -17
- 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/{BTSGQ0LP.js → B8lQCytv.js} +1 -1
- 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/chunks/{L5t1qIFa.js → uZO1iyJZ.js} +2 -2
- package/client/build/_app/immutable/entry/app.DO-zgzyy.js +2 -0
- 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.B2l9JGRO.js +1 -0
- package/client/build/_app/immutable/nodes/2.ph9M0S1U.js +54 -0
- package/client/build/_app/version.json +1 -1
- package/client/build/index.html +8 -8
- package/package.json +9 -5
- package/patches/{@mariozechner+pi-coding-agent+0.65.0.patch → @mariozechner+pi-coding-agent+0.67.6.patch} +4 -4
- 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/folder-index.js +8 -4
- package/server/dist/git-branch.js +32 -0
- package/server/dist/index.js +31 -1
- package/server/dist/message-mapper.js +99 -4
- package/server/dist/server.js +5 -2
- package/server/dist/session-manager.js +99 -6
- 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 +340 -36
- 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.Cj7UL9cq.css +0 -2
- package/client/build/_app/immutable/assets/2.CIRqqeIr.css +0 -1
- package/client/build/_app/immutable/chunks/BEKHoMUP.js +0 -1
- package/client/build/_app/immutable/chunks/CfQ6Egqh.js +0 -1
- package/client/build/_app/immutable/chunks/DQ-KfPq0.js +0 -1
- package/client/build/_app/immutable/chunks/DfA0ecbz.js +0 -1
- package/client/build/_app/immutable/chunks/Dnh9Emns.js +0 -5
- package/client/build/_app/immutable/entry/app.j0V4R67V.js +0 -2
- package/client/build/_app/immutable/entry/start.wkfo4Ebw.js +0 -1
- package/client/build/_app/immutable/nodes/0.CUipL_P7.js +0 -5
- package/client/build/_app/immutable/nodes/1.ex7ejMby.js +0 -1
- package/client/build/_app/immutable/nodes/2.165oQG9Z.js +0 -49
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { createAgentSessionRuntime, createAgentSessionServices, createAgentSessionFromServices, createEventBus, AuthStorage, ModelRegistry, getAgentDir, SessionManager as PiSessionManager, } from '@mariozechner/pi-coding-agent';
|
|
2
2
|
import { EventBuffer } from './event-buffer.js';
|
|
3
3
|
import { applyPanelMessage, getMergedPanelCards } from './panel-state.js';
|
|
4
|
+
import { getGitBranch } from './git-branch.js';
|
|
5
|
+
import { createVoiceExtension } from './voice/index.js';
|
|
6
|
+
import { autoDrainOnAbort } from './auto-drain-on-abort.js';
|
|
4
7
|
// ---- Slot-based helpers (operate on ManagedSlot) ----
|
|
5
8
|
/** Send an event to the client connected to this slot. No-op if disconnected. */
|
|
6
9
|
export function sendSlotEvent(slot, event) {
|
|
@@ -59,6 +62,7 @@ function createSessionState(session, eventBus, config, callbacks, slotRef, folde
|
|
|
59
62
|
panelState: new Map(),
|
|
60
63
|
panelListenerUnsubs: [],
|
|
61
64
|
panelThrottleTimer: null,
|
|
65
|
+
treeNavigationInProgress: false,
|
|
62
66
|
};
|
|
63
67
|
// Subscribe to session events
|
|
64
68
|
const unsubscribe = session.subscribe((event) => {
|
|
@@ -72,6 +76,13 @@ function createSessionState(session, eventBus, config, callbacks, slotRef, folde
|
|
|
72
76
|
if (slotRef.slot)
|
|
73
77
|
callbacks.onAgentEnd?.(sessionId, slotRef.slot);
|
|
74
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]);
|
|
75
86
|
}
|
|
76
87
|
eventBuffer.onEvent(event, sessionId, (e) => callbacks.sendEvent(e), () => session.messages[session.messages.length - 1]);
|
|
77
88
|
});
|
|
@@ -125,18 +136,48 @@ export class PimoteSessionManager {
|
|
|
125
136
|
modelRegistry;
|
|
126
137
|
sessions = new Map();
|
|
127
138
|
idleCheckHandle = null;
|
|
139
|
+
gitBranchCheckHandle = null;
|
|
140
|
+
lastKnownGitBranchBySession = new Map();
|
|
128
141
|
onStatusChange;
|
|
129
142
|
onSessionClosed;
|
|
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;
|
|
130
148
|
constructor(config, pushNotificationService) {
|
|
131
149
|
this.config = config;
|
|
132
150
|
this.pushNotificationService = pushNotificationService;
|
|
133
151
|
this.authStorage = AuthStorage.create();
|
|
134
152
|
this.modelRegistry = ModelRegistry.create(this.authStorage);
|
|
135
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
|
+
}
|
|
136
174
|
async openSession(folderPath, sessionFilePath) {
|
|
137
175
|
const eventBusRef = { current: null };
|
|
138
176
|
const sharedAuthStorage = this.authStorage;
|
|
139
177
|
const sharedModelRegistry = this.modelRegistry;
|
|
178
|
+
const sessionManager = sessionFilePath ? PiSessionManager.open(sessionFilePath) : PiSessionManager.create(folderPath);
|
|
179
|
+
const effectiveFolderPath = sessionFilePath ? sessionManager.getCwd() : folderPath;
|
|
180
|
+
const voiceExtensionFactory = this.buildVoiceExtensionFactory();
|
|
140
181
|
const factory = async ({ cwd, agentDir, sessionManager, sessionStartEvent }) => {
|
|
141
182
|
const eventBus = createEventBus();
|
|
142
183
|
eventBusRef.current = eventBus;
|
|
@@ -145,7 +186,10 @@ export class PimoteSessionManager {
|
|
|
145
186
|
agentDir,
|
|
146
187
|
authStorage: sharedAuthStorage,
|
|
147
188
|
modelRegistry: sharedModelRegistry,
|
|
148
|
-
resourceLoaderOptions: {
|
|
189
|
+
resourceLoaderOptions: {
|
|
190
|
+
eventBus,
|
|
191
|
+
...(voiceExtensionFactory ? { extensionFactories: [voiceExtensionFactory] } : {}),
|
|
192
|
+
},
|
|
149
193
|
});
|
|
150
194
|
return {
|
|
151
195
|
...(await createAgentSessionFromServices({ services, sessionManager, sessionStartEvent })),
|
|
@@ -154,9 +198,9 @@ export class PimoteSessionManager {
|
|
|
154
198
|
};
|
|
155
199
|
};
|
|
156
200
|
const runtime = await createAgentSessionRuntime(factory, {
|
|
157
|
-
cwd:
|
|
201
|
+
cwd: effectiveFolderPath,
|
|
158
202
|
agentDir: getAgentDir(),
|
|
159
|
-
sessionManager
|
|
203
|
+
sessionManager,
|
|
160
204
|
});
|
|
161
205
|
const session = runtime.session;
|
|
162
206
|
const sessionId = session.sessionId;
|
|
@@ -180,16 +224,22 @@ export class PimoteSessionManager {
|
|
|
180
224
|
session.setThinkingLevel(this.config.defaultThinkingLevel);
|
|
181
225
|
console.log(`[pimote] Set default thinking level: ${this.config.defaultThinkingLevel}`);
|
|
182
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');
|
|
183
233
|
// Create the slot object. Use a slotRef so createSessionState callbacks can reference the slot.
|
|
184
234
|
const slotRef = { slot: null };
|
|
185
235
|
const sessionState = createSessionState(session, eventBusRef.current, this.config, {
|
|
186
236
|
onStatusChange: (sid, fp) => this.onStatusChange?.(sid, fp),
|
|
187
237
|
onAgentEnd: (sid, s) => this.handleAgentEnd(sid, s),
|
|
188
238
|
sendEvent: (e) => sendSlotEvent(slot, e),
|
|
189
|
-
}, slotRef,
|
|
239
|
+
}, slotRef, effectiveFolderPath);
|
|
190
240
|
const slot = {
|
|
191
241
|
runtime,
|
|
192
|
-
folderPath,
|
|
242
|
+
folderPath: effectiveFolderPath,
|
|
193
243
|
eventBusRef,
|
|
194
244
|
connection: null,
|
|
195
245
|
sessionState,
|
|
@@ -199,6 +249,7 @@ export class PimoteSessionManager {
|
|
|
199
249
|
};
|
|
200
250
|
slotRef.slot = slot;
|
|
201
251
|
this.sessions.set(sessionId, slot);
|
|
252
|
+
this.lastKnownGitBranchBySession.set(sessionId, getGitBranch(effectiveFolderPath));
|
|
202
253
|
return sessionId;
|
|
203
254
|
}
|
|
204
255
|
handleAgentEnd(sessionId, slot) {
|
|
@@ -256,22 +307,40 @@ export class PimoteSessionManager {
|
|
|
256
307
|
const slot = this.sessions.get(sessionId);
|
|
257
308
|
if (!slot)
|
|
258
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
|
+
}
|
|
259
318
|
teardownSessionState(slot.sessionState);
|
|
260
319
|
slot.eventBusRef.current?.clear();
|
|
261
320
|
const folderPath = slot.folderPath;
|
|
262
321
|
await slot.runtime.dispose();
|
|
263
322
|
this.sessions.delete(sessionId);
|
|
323
|
+
this.lastKnownGitBranchBySession.delete(sessionId);
|
|
264
324
|
this.onSessionClosed?.(sessionId, folderPath);
|
|
265
325
|
}
|
|
266
326
|
/** Re-key a slot in the session map after session replacement. */
|
|
267
327
|
reKeySession(slot, oldId, newId) {
|
|
268
328
|
this.sessions.delete(oldId);
|
|
269
329
|
this.sessions.set(newId, slot);
|
|
330
|
+
const lastKnown = this.lastKnownGitBranchBySession.get(oldId) ?? null;
|
|
331
|
+
this.lastKnownGitBranchBySession.delete(oldId);
|
|
332
|
+
this.lastKnownGitBranchBySession.set(newId, lastKnown);
|
|
270
333
|
}
|
|
271
334
|
/** Rebuild a slot's SessionState after session replacement.
|
|
272
|
-
* 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. */
|
|
273
339
|
rebuildSessionState(slot) {
|
|
274
340
|
teardownSessionState(slot.sessionState);
|
|
341
|
+
const newCwd = slot.runtime.session.sessionManager.getCwd();
|
|
342
|
+
if (newCwd)
|
|
343
|
+
slot.folderPath = newCwd;
|
|
275
344
|
const slotRef = { slot: slot };
|
|
276
345
|
slot.sessionState = createSessionState(slot.runtime.session, slot.eventBusRef.current, this.config, {
|
|
277
346
|
onStatusChange: (sid, fp) => this.onStatusChange?.(sid, fp),
|
|
@@ -279,6 +348,11 @@ export class PimoteSessionManager {
|
|
|
279
348
|
sendEvent: (e) => sendSlotEvent(slot, e),
|
|
280
349
|
}, slotRef, slot.folderPath);
|
|
281
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
|
+
}
|
|
282
356
|
getSession(sessionId) {
|
|
283
357
|
return this.sessions.get(sessionId);
|
|
284
358
|
}
|
|
@@ -289,6 +363,9 @@ export class PimoteSessionManager {
|
|
|
289
363
|
this.stopIdleCheck();
|
|
290
364
|
this.idleCheckHandle = setInterval(() => {
|
|
291
365
|
for (const [sessionId, slot] of this.sessions) {
|
|
366
|
+
if (slot.sessionState.treeNavigationInProgress) {
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
292
369
|
const clientId = slot.connection?.connectedClientId ?? null;
|
|
293
370
|
const hasConnectedClient = clientId !== null && (isClientConnected?.(clientId) ?? false);
|
|
294
371
|
if (!hasConnectedClient && Date.now() - slot.sessionState.lastActivity > idleTimeout) {
|
|
@@ -298,12 +375,28 @@ export class PimoteSessionManager {
|
|
|
298
375
|
}
|
|
299
376
|
}
|
|
300
377
|
}, 60_000);
|
|
378
|
+
this.gitBranchCheckHandle = setInterval(() => {
|
|
379
|
+
for (const [sessionId, slot] of this.sessions) {
|
|
380
|
+
if (!slot.connection?.connectedClientId)
|
|
381
|
+
continue;
|
|
382
|
+
const next = getGitBranch(slot.folderPath);
|
|
383
|
+
const prev = this.lastKnownGitBranchBySession.get(sessionId) ?? null;
|
|
384
|
+
if (next !== prev) {
|
|
385
|
+
this.lastKnownGitBranchBySession.set(sessionId, next);
|
|
386
|
+
this.onGitBranchChange?.(sessionId, slot.folderPath);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}, 3000);
|
|
301
390
|
}
|
|
302
391
|
stopIdleCheck() {
|
|
303
392
|
if (this.idleCheckHandle !== null) {
|
|
304
393
|
clearInterval(this.idleCheckHandle);
|
|
305
394
|
this.idleCheckHandle = null;
|
|
306
395
|
}
|
|
396
|
+
if (this.gitBranchCheckHandle !== null) {
|
|
397
|
+
clearInterval(this.gitBranchCheckHandle);
|
|
398
|
+
this.gitBranchCheckHandle = null;
|
|
399
|
+
}
|
|
307
400
|
}
|
|
308
401
|
async dispose() {
|
|
309
402
|
this.stopIdleCheck();
|
|
@@ -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
|
+
}
|