@olares/lares-core 0.15.11 → 0.15.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/i18n/voice.js CHANGED
@@ -1,4 +1,55 @@
1
1
  export const ZH = {
2
+ "settings.backend": "语音后端",
3
+ "settings.backend.pipeline": "STT → Lares → TTS",
4
+ "settings.realtime.model": "Realtime 模型",
5
+ "settings.realtime.hint": "Realtime 模型完成实际验收后才可启用。普通语音模式始终可用。",
6
+ "error.voice_host_upgrade_required": "需要更新 Lares 主机音频助手",
7
+ "error.voice_device_lost": "音频设备已断开,请重新连接后恢复语音",
8
+
9
+ "call.mute": "关闭本次朗读",
10
+ "call.unmute": "开启本次朗读",
11
+ "call.history": "语音交流记录",
12
+ "call.clearHistory": "清除交流记录",
13
+ "settings.dictationCleanup": "整理听写文字(保留原意)",
14
+ "settings.preset": "语音工作方式",
15
+ "settings.chatModel": "交流模型",
16
+ "settings.preset.dictation": "只做听写",
17
+ "settings.preset.chat": "纯语音聊天",
18
+ "settings.preset.assistant": "语音助手委托 Lares",
19
+ "settings.preset.direct": "每轮直接提交 Lares",
20
+ "error.voice_idle_timeout": "语音因闲置已结束,点击重新开始",
21
+ "error.voice_recovery_expired": "语音恢复窗口已结束,点击重新开始",
22
+ "mic.cancel": "取消录音",
23
+ "mic.finish": "完成录音",
24
+ "call.pauseInput": "暂停收听",
25
+ "call.resumeInput": "恢复收听",
26
+ "call.inputPaused": "麦克风已暂停",
27
+ "call.halfDuplex": "播放中,麦克风已暂停",
28
+ "error.voice_draft_changed": "输入框暂时无法插入,请保留转写文字后重试",
29
+ "error.voice_background": "语音已暂停,点击恢复",
30
+ "settings.mobileMode": "移动端默认模式",
31
+ "settings.webMode": "网页默认模式",
32
+ "call.start": "语音对话",
33
+ "call.end": "结束语音",
34
+ "call.resume": "恢复语音",
35
+ "call.stopPlayback": "停止朗读",
36
+ "call.cancelTask": "取消任务",
37
+ "call.listening": "正在收听",
38
+ "call.working": "正在执行任务",
39
+ "call.speaking": "正在播放",
40
+ "call.waiting": "等待下一轮",
41
+ "settings.conversation": "语音对话",
42
+ "settings.mode": "对话方式",
43
+ "settings.mode.single": "语音输入",
44
+ "settings.mode.continuous": "连续对话",
45
+ "settings.device": "音频设备",
46
+ "settings.device.auto": "默认设备",
47
+ "settings.device.device": "浏览器麦克风与扬声器",
48
+ "settings.device.headset": "主机/蓝牙设备",
49
+ "error.voice_device_busy": "音频设备正在被其他语音连接使用",
50
+ "error.voice_disconnected": "连接已断开,点击恢复后继续收听",
51
+ "error.voice_realtime_unavailable": "Realtime 尚未验收,请选择普通语音模式",
52
+
2
53
  "error.voice_permission_denied": "麦克风权限被拒绝",
3
54
  "error.voice_audio_too_large": "录音过长",
4
55
  "error.voice_audio_unreadable": "音频无法识别,请重录",
@@ -9,7 +60,7 @@ export const ZH = {
9
60
  "error.voice_failed": "转写失败,请重试",
10
61
  "error.voice_unsupported": "当前浏览器不支持录音",
11
62
  "error.voice_bluetooth_not_ready": "蓝牙麦克风还没连上,请重试",
12
- "error.voice_bluetooth_unavailable": "这台机器的蓝牙还没就绪",
63
+ "error.voice_bluetooth_unavailable": "这台机器的蓝牙还没就绪。请确认机器有蓝牙并已打开,然后在 Olares 市场重启 Lares,重启时会重新安装蓝牙组件",
13
64
  "error.voice_bluetooth_scan_failed": "扫描附近设备失败",
14
65
  "error.voice_bluetooth_connect_failed": "连接失败,请确认设备仍在等待连接",
15
66
  "error.voice_bluetooth_invalid": "蓝牙地址无效",
@@ -18,7 +69,22 @@ export const ZH = {
18
69
  "error.voice_speak_failed": "语音播报失败,请重试",
19
70
  "error.voice_speak_timeout": "语音播报超时,请重试",
20
71
  "error.voice_speak_unready": "当前不能保存语音播报开关",
21
- "mic.preparing": "正在连接蓝牙麦克风…",
72
+ "error.voice_gpu_failed": "切换显卡模式失败({detail})。可以点重试;仍然失败,请到 Olares「设置 → GPU」查看显卡状态",
73
+ "error.voice_gpu_busy": "显卡正在切换,请等它完成后再操作",
74
+ "error.voice_gpu_no_gpu": "这台机器没有可用的 NVIDIA 显卡,不能边写边读,请使用「写完再读」",
75
+ "error.voice_gpu_unsupported": "{model} 不支持显存分片,不能边写边读,请使用「写完再读」",
76
+ "error.voice_gpu_plan_empty": "还没有应用分到显存,显卡模式没有改变。请先在下方「显存分片」里开启要用的应用并填好显存,保存后点重试",
77
+ "error.voice_gpu_plan_over": "这些应用至少需要 {need} GiB 显存,超过了显卡的 {total} GiB,显卡模式没有改变。请在下方「显存分片」里关掉一些应用或调小显存,保存后点重试",
78
+ "error.voice_gpu_plan_floor": "{app}的显存不能低于 {size} GiB",
79
+ "error.voice_gpu_unknown_app": "{app}不在这张显卡上,请刷新后重试",
80
+ "error.voice_gpu_no_slice": "{app}还没有分到显存。请先在「显存分片」里开启它并保存",
81
+ "error.voice_gpu_forbidden": "当前 Olares 账号没有管理显卡的权限,显卡模式没有改变。请用管理员账号安装 Lares,或请管理员到 Olares「设置 → GPU」里切换",
82
+ "error.voice_gpu_login": "Lares 访问 Olares 的登录凭据失效了。请在 Olares 市场重启 Lares 后再试",
83
+ "error.voice_gpu_blocked": "有应用停不下来,显卡模式没有改变({detail})。请先在 Olares 市场手动停止它,再点重试",
84
+ "error.voice_gpu_stop_failed": "{app}停不下来,显卡模式没有改变({detail})。请在 Olares 市场查看它的状态,再点重试",
85
+ "error.voice_gpu_memory": "{app}显存不够,没能启动。请在「显存分片」里调大它的显存,或关掉其他应用后重试",
86
+ "error.voice_gpu_resume_failed": "{app}没能启动({detail})。请在 Olares 市场查看它的状态,再点重试",
87
+ "mic.preparing": "正在准备麦克风…",
22
88
  "mic.stop": "点击结束录音",
23
89
  "mic.transcribing": "转写中…",
24
90
  "mic.idle": "语音输入",
@@ -54,11 +120,13 @@ export const ZH = {
54
120
  "settings.mode.streaming": "边写边读",
55
121
  "settings.mode.switching": "正在切换显卡模式…",
56
122
  "settings.mode.resumingApp": "正在恢复 {app}…",
57
- "settings.mode.partial": "显卡已切换,但这些应用没能恢复:{apps}",
58
- "settings.mode.failed": "切换显卡模式失败:{error}",
123
+ "settings.mode.restoring": "切换没成功,正在恢复原来的应用…",
124
+ "settings.mode.restored": "原来运行的应用已恢复。",
125
+ "settings.mode.notRestored": "这些应用没能恢复,请在 Olares 市场手动启动:{apps}",
126
+ "settings.mode.partial": "显卡已切换,但有应用没能启动。",
59
127
  "settings.mode.mismatch": "显卡当前是{type},和播报方式不一致",
60
- "settings.mode.noGpu": "没有找到 NVIDIA 显卡",
61
128
  "settings.mode.retry": "重试",
129
+ "settings.list.separator": "、",
62
130
  "settings.gpu.MemorySlice": "显存分片",
63
131
  "settings.gpu.TimeSlice": "时间分片",
64
132
  "settings.gpu.Exclusive": "独占",
@@ -113,6 +181,57 @@ export const ZH = {
113
181
  };
114
182
 
115
183
  export const EN = {
184
+ "settings.backend": "Voice backend",
185
+ "settings.backend.pipeline": "STT → Lares → TTS",
186
+ "settings.realtime.model": "Realtime model",
187
+ "settings.realtime.hint": "Realtime is available only after model validation. Pipeline mode remains available.",
188
+ "error.voice_host_upgrade_required": "Update the Lares host audio helper",
189
+ "error.voice_device_lost": "Audio device disconnected. Reconnect it and resume voice.",
190
+
191
+ "call.mute": "Mute replies",
192
+ "call.unmute": "Enable spoken replies",
193
+ "call.history": "Voice history",
194
+ "call.clearHistory": "Clear voice history",
195
+ "settings.dictationCleanup": "Clean up dictation without rewriting",
196
+ "settings.preset": "Voice behavior",
197
+ "settings.chatModel": "Conversation model",
198
+ "settings.preset.dictation": "Dictation only",
199
+ "settings.preset.chat": "Voice chat",
200
+ "settings.preset.assistant": "Voice assistant delegates to Lares",
201
+ "settings.preset.direct": "Send every turn to Lares",
202
+ "error.voice_idle_timeout": "Voice ended after inactivity. Start again to continue.",
203
+ "error.voice_recovery_expired": "Voice recovery expired. Start again to continue.",
204
+ "mic.cancel": "Cancel recording",
205
+ "mic.finish": "Finish recording",
206
+ "call.pauseInput": "Pause microphone",
207
+ "call.resumeInput": "Resume microphone",
208
+ "call.inputPaused": "Microphone paused",
209
+ "call.halfDuplex": "Microphone paused during playback",
210
+ "error.voice_draft_changed": "The composer cannot accept text yet. Keep the transcript and retry.",
211
+ "error.voice_background": "Voice paused. Resume explicitly.",
212
+ "settings.mobileMode": "Mobile default mode",
213
+ "settings.webMode": "Web default mode",
214
+ "call.start": "Voice conversation",
215
+ "call.end": "End voice",
216
+ "call.resume": "Resume voice",
217
+ "call.stopPlayback": "Stop speaking",
218
+ "call.cancelTask": "Cancel task",
219
+ "call.listening": "Listening",
220
+ "call.working": "Working",
221
+ "call.speaking": "Speaking",
222
+ "call.waiting": "Waiting",
223
+ "settings.conversation": "Voice conversation",
224
+ "settings.mode": "Conversation mode",
225
+ "settings.mode.single": "Dictation",
226
+ "settings.mode.continuous": "Continuous conversation",
227
+ "settings.device": "Audio device",
228
+ "settings.device.auto": "Default device",
229
+ "settings.device.device": "Browser microphone and speaker",
230
+ "settings.device.headset": "Host / Bluetooth device",
231
+ "error.voice_device_busy": "Audio device is in use by another connection",
232
+ "error.voice_disconnected": "Connection lost. Resume to listen again",
233
+ "error.voice_realtime_unavailable": "Realtime has not been validated. Select pipeline mode",
234
+
116
235
  "error.voice_permission_denied": "Microphone access denied",
117
236
  "error.voice_audio_too_large": "Recording too long",
118
237
  "error.voice_audio_unreadable": "Audio unreadable, please re-record",
@@ -124,7 +243,8 @@ export const EN = {
124
243
  "error.voice_failed": "Transcription failed, please retry",
125
244
  "error.voice_unsupported": "This browser does not support recording",
126
245
  "error.voice_bluetooth_not_ready": "Bluetooth microphone is not ready, please retry",
127
- "error.voice_bluetooth_unavailable": "Bluetooth is not ready on this machine",
246
+ "error.voice_bluetooth_unavailable":
247
+ "Bluetooth is not ready on this machine. Make sure it has Bluetooth turned on, then restart Lares in the Olares Market; the restart reinstalls the Bluetooth pieces",
128
248
  "error.voice_bluetooth_scan_failed": "Failed to scan for nearby devices",
129
249
  "error.voice_bluetooth_connect_failed": "Connection failed. Confirm the device is still waiting to connect",
130
250
  "error.voice_bluetooth_invalid": "Invalid Bluetooth address",
@@ -133,7 +253,29 @@ export const EN = {
133
253
  "error.voice_speak_failed": "Couldn't speak the answer, please retry",
134
254
  "error.voice_speak_timeout": "Speech timed out, please retry",
135
255
  "error.voice_speak_unready": "Speech playback can't be saved right now",
136
- "mic.preparing": "Connecting the Bluetooth microphone…",
256
+ "error.voice_gpu_failed":
257
+ "Couldn't switch the GPU mode ({detail}). Retry; if it keeps failing, check the GPU in Olares Settings → GPU",
258
+ "error.voice_gpu_busy": "The GPU is switching. Wait for it to finish",
259
+ "error.voice_gpu_no_gpu": "This machine has no usable NVIDIA GPU, so reading while writing is unavailable. Use \"When done\"",
260
+ "error.voice_gpu_unsupported": "{model} does not support memory slicing, so reading while writing is unavailable. Use \"When done\"",
261
+ "error.voice_gpu_plan_empty":
262
+ "No app has a memory slice yet, so the GPU mode was left unchanged. Turn on the apps you need under Memory slices below, set their memory, save, then retry",
263
+ "error.voice_gpu_plan_over":
264
+ "These apps need at least {need} GiB, more than the GPU's {total} GiB, so the GPU mode was left unchanged. Turn some off or lower their memory under Memory slices below, save, then retry",
265
+ "error.voice_gpu_plan_floor": "{app} needs at least {size} GiB",
266
+ "error.voice_gpu_unknown_app": "{app} is not on this GPU. Refresh and try again",
267
+ "error.voice_gpu_no_slice": "{app} has no memory slice yet. Turn it on under Memory slices and save",
268
+ "error.voice_gpu_forbidden":
269
+ "This Olares account cannot manage the GPU, so the GPU mode was left unchanged. Install Lares from an admin account, or ask an admin to switch it in Olares Settings → GPU",
270
+ "error.voice_gpu_login": "Lares lost its Olares sign-in. Restart Lares in the Olares Market and try again",
271
+ "error.voice_gpu_blocked":
272
+ "An app would not stop, so the GPU mode was left unchanged ({detail}). Stop it in the Olares Market, then retry",
273
+ "error.voice_gpu_stop_failed":
274
+ "{app} would not stop, so the GPU mode was left unchanged ({detail}). Check it in the Olares Market, then retry",
275
+ "error.voice_gpu_memory":
276
+ "{app} ran out of GPU memory and did not start. Give it more memory under Memory slices, or turn other apps off, then retry",
277
+ "error.voice_gpu_resume_failed": "{app} did not start ({detail}). Check it in the Olares Market, then retry",
278
+ "mic.preparing": "Preparing microphone…",
137
279
  "mic.stop": "Click to stop recording",
138
280
  "mic.transcribing": "Transcribing…",
139
281
  "mic.idle": "Voice input",
@@ -169,11 +311,13 @@ export const EN = {
169
311
  "settings.mode.streaming": "While writing",
170
312
  "settings.mode.switching": "Switching GPU mode…",
171
313
  "settings.mode.resumingApp": "Resuming {app}…",
172
- "settings.mode.partial": "GPU switched, but these apps did not resume: {apps}",
173
- "settings.mode.failed": "GPU switch failed: {error}",
314
+ "settings.mode.restoring": "The switch failed. Restoring the apps that were running…",
315
+ "settings.mode.restored": "The apps that were running are back.",
316
+ "settings.mode.notRestored": "These apps did not come back; start them in the Olares Market: {apps}",
317
+ "settings.mode.partial": "The GPU switched, but some apps did not start.",
174
318
  "settings.mode.mismatch": "The GPU is in {type}, which does not match the reading mode",
175
- "settings.mode.noGpu": "No NVIDIA GPU found",
176
319
  "settings.mode.retry": "Retry",
320
+ "settings.list.separator": ", ",
177
321
  "settings.gpu.MemorySlice": "memory slicing",
178
322
  "settings.gpu.TimeSlice": "time slicing",
179
323
  "settings.gpu.Exclusive": "exclusive",
@@ -862,6 +862,8 @@ export function createChatRuntime(client) {
862
862
  }
863
863
  return client.upload(id, file, options);
864
864
  },
865
+ voiceSocket(ticket) { return client.voiceSocket(ticket); },
866
+ voiceRequest(path, options) { return client.voiceRequest(path, options); },
865
867
  transcribe(blob, language, signal) {
866
868
  if (typeof client.transcribe !== "function") {
867
869
  return Promise.reject(new Error("voice_failed"));
@@ -101,6 +101,9 @@ export function createHostSettings(request) {
101
101
  status: payload(status, "/api/lares/voice/status"),
102
102
  sttModels: modelIds(listed, "stt"),
103
103
  ttsModels: modelIds(listed, "tts"),
104
+ chatModels: modelIds(listed, "chat"),
105
+ realtimeModels: modelIds(listed, "realtime"),
106
+ realtimeReady: modelIds(listed, "realtimeReady"),
104
107
  };
105
108
  }, { force: Boolean(force) });
106
109
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olares/lares-core",
3
- "version": "0.15.11",
3
+ "version": "0.15.12",
4
4
  "description": "Lares 共享业务核心:Router、workspace、文件/媒体、Olares 身份(无 UI、无平台壳)",
5
5
  "type": "module",
6
6
  "repository": {
@@ -124,6 +124,7 @@
124
124
  "./voice/capture": "./voice/capture.js",
125
125
  "./voice/install": "./voice/install.js",
126
126
  "./voice/gpu-mode": "./voice/gpu-mode.js",
127
+ "./voice/gpu-problem": "./voice/gpu-problem.js",
127
128
  "./search/config": "./search/config.js",
128
129
  "./search/menu": "./search/menu.js",
129
130
  "./search/client": "./search/client.js",
@@ -168,6 +169,29 @@
168
169
  "types": "./skills/state.d.ts",
169
170
  "default": "./skills/state.js"
170
171
  },
171
- "./skills/client": "./skills/client.js"
172
+ "./skills/client": "./skills/client.js",
173
+ "./voice/controller": "./voice/controller.js",
174
+ "./voice/lease": "./voice/lease.js",
175
+ "./voice/browser-capture": "./voice/browser-capture.js",
176
+ "./voice/agent-session": "./voice/agent-session.js",
177
+ "./voice/device-input": "./voice/device-input.js",
178
+ "./voice/host-input": "./voice/host-input.js",
179
+ "./voice/conversation": "./voice/conversation.js",
180
+ "./voice/browser-playback": "./voice/browser-playback.js",
181
+ "./voice/realtime": "./voice/realtime.js",
182
+ "./voice/realtime-gateway": "./voice/realtime-gateway.js",
183
+ "./voice/realtime-audio": "./voice/realtime-audio.js",
184
+ "./voice/realtime-conversation": "./voice/realtime-conversation.js",
185
+ "./voice/host-service": "./voice/host-service.js",
186
+ "./voice/options": "./voice/options.js",
187
+ "./voice/records": "./voice/records.js",
188
+ "./voice/dialogue": "./voice/dialogue.js",
189
+ "./voice/external": "./voice/external.js",
190
+ "./voice/composite-gateway": "./voice/composite-gateway.js",
191
+ "./voice/router-audio": "./voice/router-audio.js",
192
+ "./voice/protocol-client": "./voice/protocol-client.js"
193
+ },
194
+ "dependencies": {
195
+ "ws": "^8.22.0"
172
196
  }
173
197
  }
package/router/tts.js CHANGED
@@ -71,7 +71,7 @@ export async function synthesize(request) {
71
71
  response_format: "wav",
72
72
  ...(request.voice ? { voice: request.voice } : {}),
73
73
  }),
74
- signal: AbortSignal.timeout(request.timeoutMs ?? REQUEST_TIMEOUT_MS),
74
+ signal: request.signal ? AbortSignal.any([request.signal, AbortSignal.timeout(request.timeoutMs ?? REQUEST_TIMEOUT_MS)]) : AbortSignal.timeout(request.timeoutMs ?? REQUEST_TIMEOUT_MS),
75
75
  });
76
76
  } catch (err) {
77
77
  const timedOut = err?.name === "TimeoutError" || err?.name === "AbortError";
package/tools/http.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { LAREPASS_PAGE_ORIGINS } from "../larepass/host.js";
2
2
  import { pageOrigin } from "../olares/trusted-host.js";
3
3
 
4
- const CORS_HEADERS = "content-type, x-authorization, x-unauth-error, x-lares-session-id, x-lares-file-name, x-lares-upload-request-id";
4
+ const CORS_HEADERS = "content-type, x-authorization, x-unauth-error, x-lares-session-id, x-lares-file-name, x-lares-upload-request-id, x-lares-voice-id, x-lares-voice-device";
5
5
 
6
6
  /** @returns {boolean} true when the request came from a LarePass page. */
7
7
  function allowLarepassOrigin(req, res) {
@@ -85,6 +85,7 @@ function errorResponse(err, fallbackCode) {
85
85
  status: err.status >= 400 ? err.status : 500,
86
86
  code: err.code,
87
87
  message: err instanceof Error ? err.message : String(err),
88
+ params: err.params && typeof err.params === "object" ? err.params : undefined,
88
89
  };
89
90
  }
90
91
  return {
@@ -97,7 +98,7 @@ function errorResponse(err, fallbackCode) {
97
98
  export function sendError(res, err, fallbackCode) {
98
99
  const failure = errorResponse(err, fallbackCode);
99
100
  sendJson(res, failure.status, {
100
- error: { code: failure.code, message: failure.message },
101
+ error: { code: failure.code, message: failure.message, params: failure.params },
101
102
  });
102
103
  }
103
104
 
@@ -0,0 +1,133 @@
1
+ import { mintRpcId } from "../larepass/rpc.js";
2
+ import { createSessionFollow } from "../larepass/mux.js";
3
+ import { reduceUtterance } from "./speech.js";
4
+
5
+ export const SPOKEN_PROMPT = "This is a voice conversation. Keep user-facing answers concise and natural for speech. Do not read reasoning or tool payloads aloud. Preserve the task's factual results and use the user's language.";
6
+
7
+ /** dsh opens a turn before claiming input; rpcId associates input with that open turn. */
8
+ export function createVoiceAgentSession(api, { sessionId = null, preset, workspaceId, restoredRequests = [], metric = () => {} } = {}) {
9
+ let creating;
10
+ let following;
11
+ let closed = false;
12
+ let utterance = null;
13
+ let error = null;
14
+ let version = 0;
15
+ let cursor = -1;
16
+ let openTurn = null;
17
+ let latestRequest = null;
18
+ let followTask = null;
19
+ let ready = null;
20
+ let readyTimer;
21
+ let observedRunning = false;
22
+ const requests = new Map();
23
+ for (const restored of restoredRequests.slice(-256)) {
24
+ requests.set(restored.requestId, { requestId: restored.requestId, turn: null, status: "unknown", utterance: null, error: null, at: Date.now(), firstTextAt: null });
25
+ }
26
+ const decoder = createSessionFollow();
27
+ const unfinished = (task) => ["accepted", "running", "waiting", "unknown"].includes(task.status);
28
+ const snapshot = () => ({ sessionId, running: observedRunning || [...requests.values()].some(unfinished), utterance, error, version,
29
+ requestId: latestRequest,
30
+ tasks: [...requests.values()].map(({ requestId, turn, status, utterance: reply, error: taskError }) => ({ requestId, turn, status, text: reply?.silent ? "" : reply?.text || "", error: taskError })),
31
+ });
32
+ const ensure = async () => {
33
+ if (sessionId) return sessionId;
34
+ creating ??= Promise.resolve(api.create({ agentPreset: preset, workspaceId })).then((value) => {
35
+ if (!value?.sessionId) throw new Error("voice_session_failed");
36
+ sessionId = value.sessionId;
37
+ return sessionId;
38
+ });
39
+ return creating;
40
+ };
41
+ const publish = (event, replay = false) => {
42
+ if (!event || typeof event !== "object") return;
43
+ if (typeof event.seq === "number") {
44
+ if (event.seq <= cursor) return;
45
+ cursor = event.seq;
46
+ }
47
+ if (event.type === "turn/start") { openTurn = event.data.turn; observedRunning = true; }
48
+ if (event.type === "user/message") {
49
+ const id = event.data?.requestId ?? event.data?.source?.rpcId;
50
+ const task = requests.get(id);
51
+ if (task && openTurn !== null) { task.turn = openTurn; task.status = "running"; }
52
+ }
53
+ const turn = event.data?.turn ?? openTurn;
54
+ const owned = [...requests.values()].filter((task) => task.turn != null && task.turn === turn && unfinished(task));
55
+ for (const task of owned) {
56
+ task.utterance = reduceUtterance(task.utterance, event);
57
+ if (event.type === "approval/asked") task.status = "waiting";
58
+ if (event.type === "approval/decided") task.status = "running";
59
+ if (event.type === "turn/end") {
60
+ const reason = event.data?.reason?.kind;
61
+ task.status = reason === "error" ? "failed" : reason === "cancelled" ? "cancelled" : "completed";
62
+ task.error = reason === "error" ? "voice_agent_failed" : reason === "cancelled" ? "voice_cancelled" : null;
63
+ }
64
+ if (!replay && task.utterance?.text && !task.firstTextAt) {
65
+ task.firstTextAt = Date.now();
66
+ metric({ stage: "agent_first_text", requestId: task.requestId, durationMs: task.firstTextAt - task.at });
67
+ }
68
+ if (!replay && task.requestId === latestRequest) { utterance = task.utterance; error = task.error; }
69
+ }
70
+ if (event.type === "turn/end" && openTurn === event.data?.turn) { openTurn = null; observedRunning = false; }
71
+ version++;
72
+ };
73
+ const follow = () => {
74
+ if (following && !following.signal.aborted) return ready;
75
+ const abort = new AbortController();
76
+ following = abort;
77
+ let resolveReady;
78
+ let rejectReady;
79
+ ready = new Promise((resolve, reject) => { resolveReady = resolve; rejectReady = reject; });
80
+ readyTimer = setTimeout(() => { rejectReady(new Error("voice_agent_timeout")); abort.abort(); }, 15_000);
81
+ ready.catch(() => {});
82
+ abort.signal.addEventListener("abort", () => rejectReady(new Error("voice_session_closed")), { once: true });
83
+ followTask = (async () => {
84
+ try {
85
+ for await (const frame of api.follow({ address: { kind: "session", sessionId }, assistantStream: true }, abort.signal)) {
86
+ const decoded = decoder.push(frame);
87
+ if (frame.type === "snapshot") {
88
+ for (const event of decoded?.events ?? []) publish(event, true);
89
+ clearTimeout(readyTimer); resolveReady();
90
+ } else for (const event of decoded?.events ?? []) publish(event);
91
+ }
92
+ if (!abort.signal.aborted) throw new Error("voice_agent_disconnected");
93
+ } catch (cause) {
94
+ clearTimeout(readyTimer); rejectReady(cause);
95
+ if (!abort.signal.aborted) { error = "voice_agent_disconnected"; version++; abort.abort(); }
96
+ }
97
+ })();
98
+ followTask.catch(() => {});
99
+ return ready;
100
+ };
101
+ return {
102
+ snapshot,
103
+ async open() { if (sessionId) await follow(); },
104
+ async resume() {
105
+ following?.abort(); await followTask;
106
+ closed = false; error = null; utterance = null; cursor = -1; openTurn = null; observedRunning = false; decoder.reset();
107
+ if (sessionId) await follow();
108
+ },
109
+ async submit(text, requestId = mintRpcId(), mode = "queue") {
110
+ if (closed) throw new Error("voice_session_closed");
111
+ if (!String(text).trim()) return snapshot();
112
+ if (requests.has(requestId)) return requests.get(requestId).operation || snapshot();
113
+ if (requests.size >= 256) throw new Error("voice_session_limit");
114
+ const task = { requestId, turn: null, status: "accepted", utterance: null, error: null, at: Date.now(), firstTextAt: null };
115
+ requests.set(requestId, task);
116
+ latestRequest = requestId; utterance = null; error = null; version++;
117
+ task.operation = (async () => {
118
+ try {
119
+ await ensure();
120
+ await follow();
121
+ await api.prompt({ sessionId, requestId, mode: mode === "steer" ? "steer" : "queue", content: [{ type: "text", text }] }, following.signal);
122
+ return snapshot();
123
+ } catch (cause) {
124
+ task.status = "unknown"; task.error = "submission_outcome_unknown"; version++;
125
+ throw cause;
126
+ }
127
+ })();
128
+ return task.operation;
129
+ },
130
+ async cancel() { if (sessionId) await api.cancel({ sessionId }); },
131
+ async close() { closed = true; clearTimeout(readyTimer); following?.abort(); await followTask; },
132
+ };
133
+ }
package/voice/agent.js CHANGED
@@ -29,7 +29,15 @@ export async function liveAgent(options = {}) {
29
29
  return agentIsLive(info, now()) ? info : null;
30
30
  }
31
31
 
32
- export async function askAgent(op, fields, options) {
32
+ const requests = new Map();
33
+ export function askAgent(op, fields, options = {}) {
34
+ const key = voiceDir(options);
35
+ const result = (requests.get(key) ?? Promise.resolve()).catch(() => {}).then(() => performAgentRequest(op, fields, options));
36
+ requests.set(key, result);
37
+ void result.finally(() => { if (requests.get(key) === result) requests.delete(key); }).catch(() => {});
38
+ return result;
39
+ }
40
+ async function performAgentRequest(op, fields, options) {
33
41
  const dir = voiceDir(options);
34
42
  const now = options.now ?? Date.now;
35
43
  const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));