@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/voice/gpu-mode.js CHANGED
@@ -2,8 +2,24 @@ import { readJsonFile, writeJsonFile } from "../tools/json-file.js";
2
2
  import { dshPluginConfigPath } from "../workspace/home.js";
3
3
  import { VoiceError } from "../router/stt.js";
4
4
  import { readConfig, writeConfig } from "./config.js";
5
+ import { cliProblem, gpuProblem } from "./gpu-problem.js";
5
6
  import { chartPhase, currentChartRow, runOlaresCli, VOICE_CHARTS } from "./install.js";
6
7
 
8
+ /** A GPU failure the settings panel explains; `params` reach the browser beside the code. */
9
+ export class GpuError extends VoiceError {
10
+ /** @param {import('./gpu-problem.js').GpuProblem} problem */
11
+ constructor(problem, status = 502) {
12
+ super(problem.code, status, problem.detail || problem.code);
13
+ this.params = problem.params;
14
+ this.problem = problem;
15
+ }
16
+ }
17
+
18
+ function problemOf(err) {
19
+ if (err instanceof GpuError) return err.problem;
20
+ return gpuProblem("voice_gpu_failed", {}, err instanceof Error ? err.message : String(err));
21
+ }
22
+
7
23
  /**
8
24
  * Reading answers while they are written needs the LLM and speech models on
9
25
  * the GPU at once, so the card is cut into memory slices. Reading finished
@@ -71,8 +87,8 @@ export function parseQuantity(text) {
71
87
  * @returns {Promise<{ app: string, title: string, source: string, phase: string, required: number }[]>}
72
88
  */
73
89
  export async function gpuApps(options = {}) {
74
- const mine = await runOlaresCli(["market", "list", "--mine", "-o", "json"], options.spawnFn);
75
- if (mine.code !== 0 || !Array.isArray(mine.json)) throw new VoiceError("voice_gpu_failed", 502, mine.message);
90
+ const mine = await runOlaresCli(["market", "list", "--mine", "-o", "json"], options.spawnFn, MANIFEST_LIMIT);
91
+ if (mine.code !== 0 || !Array.isArray(mine.json)) throw new GpuError(cliProblem(mine, "voice_gpu_failed"));
76
92
  const apps = await Promise.all(mine.json.map(async (row) => {
77
93
  const app = typeof row?.name === "string" ? row.name : "";
78
94
  if (!app) return null;
@@ -110,7 +126,7 @@ function speechDevice(nodes) {
110
126
  export async function speechGpu(options = {}) {
111
127
  const result = await runOlaresCli(["settings", "compute", "list", "-o", "json"], options.spawnFn);
112
128
  if (result.code !== 0 || !Array.isArray(result.json)) {
113
- throw new VoiceError("voice_gpu_failed", 502, result.message);
129
+ throw new GpuError(cliProblem(result, "voice_gpu_failed"));
114
130
  }
115
131
  const device = speechDevice(result.json);
116
132
  if (!device) return null;
@@ -155,11 +171,48 @@ function sliceFlag(gpu, app, plan) {
155
171
  return `${gpu.node}:${gpu.device}:${Math.round(Number(plan.slices[app]) / MIB)}Mi`;
156
172
  }
157
173
 
174
+ /** The binding an app had before this switch touched it, to put it back after a failure. */
175
+ function bindingFlag(gpu, binding) {
176
+ return gpu.type === "MemorySlice" && binding.memory > 0
177
+ ? `${gpu.node}:${gpu.device}:${Math.round(binding.memory / MIB)}Mi`
178
+ : `${gpu.node}:${gpu.device}`;
179
+ }
180
+
181
+ function gib(bytes) {
182
+ return Math.round((bytes / 2 ** 30) * 10) / 10;
183
+ }
184
+
185
+ /**
186
+ * Entering MemorySlice with no slice planned would stop every app and start
187
+ * none. The first time, each running app on the card gets its manifest
188
+ * minimum; when that cannot fit, or nothing qualifies, the user plans it.
189
+ */
190
+ async function ensureSlices(gpu, plan, options) {
191
+ if (slicePlan(null, plan).some((entry) => entry.enabled)) return plan;
192
+ const onCard = new Set(union(plan.apps, gpu.bindings.map((binding) => binding.app)));
193
+ const seeded = (await gpuApps(options)).filter((entry) => (
194
+ onCard.has(entry.app) && entry.phase === "running" && entry.required > 0 && !plan.off.includes(entry.app)
195
+ ));
196
+ if (!seeded.length) throw new GpuError(gpuProblem("voice_gpu_plan_empty"), 409);
197
+ const need = seeded.reduce((sum, entry) => sum + entry.required, 0);
198
+ if (gpu.memory > 0 && need > gpu.memory) {
199
+ throw new GpuError(gpuProblem("voice_gpu_plan_over", { need: gib(need), total: gib(gpu.memory) }), 409);
200
+ }
201
+ const slices = { ...plan.slices };
202
+ for (const entry of seeded) slices[entry.app] = Math.ceil(entry.required / MIB) * MIB;
203
+ const next = { ...plan, slices };
204
+ writePlan(next);
205
+ return next;
206
+ }
207
+
158
208
  let job = null;
159
209
 
210
+ const BUSY = new Set(["switching", "restoring", "resuming"]);
211
+
160
212
  /** The switch in progress or last finished, for the settings panel to poll. */
161
213
  export function gpuSwitchJob() {
162
- return job ? { ...job, apps: job.apps.map((app) => ({ ...app })) } : null;
214
+ if (!job) return null;
215
+ return { ...job, apps: job.apps.map((app) => ({ ...app })), restored: job.restored.map((app) => ({ ...app })) };
163
216
  }
164
217
 
165
218
  /**
@@ -167,89 +220,121 @@ export function gpuSwitchJob() {
167
220
  * the plan. Switching the type stops every app bound to the card; within
168
221
  * MemorySlice only apps whose slice changed are stopped and rebound. Apps
169
222
  * resume smallest first, so the LLM is not starved of a slice.
223
+ * The reading mode is saved only once the card is where it needs to be; a
224
+ * failure before that puts the stopped apps back on their old bindings.
170
225
  * Returns at once; the job runs on and reports through `gpuSwitchJob`.
171
226
  */
172
227
  export function startGpuSwitch(streaming, options = {}) {
173
- if (job?.phase === "switching" || job?.phase === "resuming") {
174
- throw new VoiceError("voice_gpu_busy", 409, "the GPU is already switching");
228
+ if (BUSY.has(job?.phase)) {
229
+ throw new GpuError(gpuProblem("voice_gpu_busy"), 409);
175
230
  }
176
231
  const target = gpuTypeFor(streaming);
177
- job = { target, phase: "switching", startedAt: Date.now(), apps: [], error: "" };
232
+ job = { target, phase: "switching", startedAt: Date.now(), apps: [], restored: [], problem: null };
178
233
  const current = job;
179
- void runSwitch(current, options).catch((err) => {
234
+ void runSwitch(current, streaming, options).catch((err) => {
180
235
  current.phase = "failed";
181
- current.error = err instanceof Error ? err.message : String(err);
236
+ current.problem = problemOf(err);
182
237
  });
183
238
  return gpuSwitchJob();
184
239
  }
185
240
 
186
- async function runSwitch(current, options) {
241
+ async function runSwitch(current, streaming, options) {
187
242
  const run = (args) => runOlaresCli(args, options.spawnFn);
188
- // set-type and unbind return before their stops settle, and a resume against a stopping app is refused.
189
- const stop = (app) => run(["market", "stop", app, "--watch", "--watch-timeout", "10m", "-o", "json"]);
190
243
  const gpu = await speechGpu(options);
191
- if (!gpu) throw new VoiceError("voice_gpu_failed", 404, "no NVIDIA GPU found");
192
244
  const sliced = current.target === "MemorySlice";
245
+ if (!gpu) {
246
+ if (sliced) throw new GpuError(gpuProblem("voice_gpu_no_gpu"), 404);
247
+ writeConfig({ streaming });
248
+ current.phase = "done";
249
+ return;
250
+ }
193
251
  const switching = gpu.type !== current.target;
252
+ if (switching && !gpu.available.includes(current.target)) {
253
+ throw new GpuError(gpuProblem("voice_gpu_unsupported", { model: gpu.model || gpu.device }), 400);
254
+ }
194
255
  if (gpu.bindings.length) rememberBound(gpu, switching);
195
- const plan = readPlan();
256
+ const plan = sliced ? await ensureSlices(gpu, readPlan(), options) : readPlan();
196
257
  const kept = new Set();
197
- if (switching) {
198
- if (!gpu.available.includes(current.target)) {
199
- throw new VoiceError("voice_gpu_failed", 400, `this GPU does not support ${current.target}`);
258
+ const stopped = [];
259
+ // set-type and unbind return before their stops settle, and a resume against a stopping app is refused.
260
+ const stop = async (binding) => {
261
+ const result = await run(["market", "stop", binding.app, "--watch", "--watch-timeout", "10m", "-o", "json"]);
262
+ if (result.code !== 0 && !(await settledAs(binding.app, "stopped", options))) {
263
+ throw new GpuError(cliProblem(result, "voice_gpu_stop_failed", { app: binding.app }));
200
264
  }
201
- for (const binding of gpu.bindings) await stop(binding.app);
202
- const switched = await run(["settings", "compute", "set-type", gpu.node, gpu.device, "--type", current.target, "--yes", "-o", "json"]);
203
- if (switched.code !== 0) throw new VoiceError("voice_gpu_failed", 502, switched.message);
204
- } else if (sliced) {
205
- for (const binding of gpu.bindings) {
206
- const wanted = plan.off.includes(binding.app) ? 0 : Number(plan.slices[binding.app]) || 0;
207
- if (wanted === binding.memory) {
208
- kept.add(binding.app);
209
- continue;
265
+ stopped.push(binding);
266
+ };
267
+ try {
268
+ if (switching) {
269
+ for (const binding of gpu.bindings) await stop(binding);
270
+ const switched = await run(["settings", "compute", "set-type", gpu.node, gpu.device, "--type", current.target, "--yes", "-o", "json"]);
271
+ if (switched.code !== 0) {
272
+ throw new GpuError(cliProblem(switched, "voice_gpu_failed"));
273
+ }
274
+ } else if (sliced) {
275
+ for (const binding of gpu.bindings) {
276
+ const wanted = plan.off.includes(binding.app) ? 0 : Number(plan.slices[binding.app]) || 0;
277
+ if (wanted === binding.memory) {
278
+ kept.add(binding.app);
279
+ continue;
280
+ }
281
+ await stop(binding);
282
+ const unbound = await run(["settings", "compute", "unbind", binding.app, "--yes", "-o", "json"]);
283
+ if (unbound.code !== 0) throw new GpuError(cliProblem(unbound, "voice_gpu_failed", { app: binding.app }));
210
284
  }
211
- await stop(binding.app);
212
- const unbound = await run(["settings", "compute", "unbind", binding.app, "--yes", "-o", "json"]);
213
- if (unbound.code !== 0) throw new VoiceError("voice_gpu_failed", 502, unbound.message);
214
285
  }
286
+ } catch (err) {
287
+ current.phase = "restoring";
288
+ for (const binding of stopped) {
289
+ const resumed = await resumeApp(binding.app, bindingFlag(gpu, binding), options);
290
+ current.restored.push({ app: binding.app, ok: !resumed });
291
+ }
292
+ throw err;
215
293
  }
294
+ writeConfig({ streaming });
216
295
  current.phase = "resuming";
217
296
  const apps = (sliced
218
297
  ? slicePlan(null, plan).filter((entry) => entry.enabled).map((entry) => entry.app)
219
298
  : plan.apps
220
299
  ).sort((a, b) => (Number(plan.slices[a]) || Infinity) - (Number(plan.slices[b]) || Infinity));
221
300
  for (const app of apps) {
222
- const entry = { app, ok: false, message: "" };
301
+ const entry = { app, ok: false, problem: null };
223
302
  current.apps.push(entry);
224
303
  if (kept.has(app)) {
225
304
  entry.ok = true;
226
305
  continue;
227
306
  }
228
- const resumed = await run([
229
- "market", "resume", app,
230
- "--compute-binding", sliced ? sliceFlag(gpu, app, plan) : `${gpu.node}:${gpu.device}`,
231
- "--watch", "--watch-timeout", "15m", "-o", "json",
232
- ]);
233
- entry.ok = resumed.code === 0;
234
- if (!entry.ok) entry.message = resumed.message;
307
+ entry.problem = await resumeApp(app, sliced ? sliceFlag(gpu, app, plan) : `${gpu.node}:${gpu.device}`, options);
308
+ entry.ok = !entry.problem;
235
309
  }
236
310
  current.phase = current.apps.every((app) => app.ok) ? "done" : "partial";
237
311
  }
238
312
 
313
+ /** Resumes one app onto the card; null once it runs, else why it did not. */
314
+ async function resumeApp(app, binding, options) {
315
+ const resumed = await runOlaresCli([
316
+ "market", "resume", app,
317
+ "--compute-binding", binding,
318
+ "--watch", "--watch-timeout", "15m", "-o", "json",
319
+ ], options.spawnFn);
320
+ if (resumed.code === 0 || await settledAs(app, "running", options)) return null;
321
+ return cliProblem(resumed, "voice_gpu_resume_failed", { app });
322
+ }
323
+
239
324
  /** What the settings panel shows: the reading mode, the card, its slice plan, and any switch in progress. */
240
325
  export async function gpuModePayload(options = {}) {
241
326
  let gpu = null;
242
- let error = "";
327
+ let problem = null;
243
328
  try {
244
329
  gpu = await speechGpu(options);
245
330
  } catch (err) {
246
- error = err instanceof Error ? err.message : String(err);
331
+ problem = problemOf(err);
247
332
  }
248
333
  let apps = [];
249
334
  try {
250
335
  apps = await gpuApps(options);
251
336
  } catch (err) {
252
- error ||= err instanceof Error ? err.message : String(err);
337
+ problem ||= problemOf(err);
253
338
  }
254
339
  const streaming = readConfig().streaming;
255
340
  const bound = new Set(gpu?.bindings.map((binding) => binding.app) ?? []);
@@ -262,18 +347,17 @@ export async function gpuModePayload(options = {}) {
262
347
  ...entry,
263
348
  bound: bound.has(entry.app),
264
349
  pending: pendingApps.get(entry.app) ?? "",
265
- error: appErrors.get(entry.app) ?? "",
350
+ problem: appErrors.get(entry.app) ?? null,
266
351
  })),
267
352
  job: gpuSwitchJob(),
268
- error,
353
+ problem,
269
354
  };
270
355
  }
271
356
 
272
- /** Saves the reading mode and starts moving the card to the type it needs. */
357
+ /** Starts moving the card to the type the reading mode needs; the mode is saved once it gets there. */
273
358
  export async function switchReadingMode(streaming, options = {}) {
274
359
  if (typeof streaming !== "boolean") throw new VoiceError("voice_config_invalid", 400, "streaming must be a boolean");
275
360
  startGpuSwitch(streaming, options);
276
- writeConfig({ streaming });
277
361
  return gpuModePayload(options);
278
362
  }
279
363
 
@@ -283,12 +367,12 @@ export async function switchReadingMode(streaming, options = {}) {
283
367
  * @param {unknown} entries `[{ app, enabled, memory }]`, memory in bytes
284
368
  */
285
369
  export async function saveSlicePlan(entries, options = {}) {
286
- const invalid = (message) => {
287
- throw new VoiceError("voice_config_invalid", 400, message);
370
+ const invalid = (code, params, detail) => {
371
+ throw new GpuError(gpuProblem(code, params, detail), 400);
288
372
  };
289
- if (!Array.isArray(entries)) invalid("plan must be a list");
373
+ if (!Array.isArray(entries)) throw new VoiceError("voice_config_invalid", 400, "plan must be a list");
290
374
  const gpu = await speechGpu(options);
291
- if (!gpu) throw new VoiceError("voice_gpu_failed", 404, "no NVIDIA GPU found");
375
+ if (!gpu) throw new GpuError(gpuProblem("voice_gpu_no_gpu"), 404);
292
376
  const plan = readPlan();
293
377
  const candidates = slicePlan(gpu, plan, await gpuApps(options));
294
378
  const known = new Set(candidates.map((entry) => entry.app));
@@ -298,12 +382,12 @@ export async function saveSlicePlan(entries, options = {}) {
298
382
  let total = 0;
299
383
  for (const entry of entries) {
300
384
  const app = entry?.app;
301
- if (typeof app !== "string" || !known.has(app)) invalid(`unknown app ${String(app)}`);
302
- if (typeof entry.enabled !== "boolean") invalid(`${app}: enabled must be a boolean`);
385
+ if (typeof app !== "string" || !known.has(app)) invalid("voice_gpu_unknown_app", { app: String(app) }, `unknown app ${String(app)}`);
386
+ if (typeof entry.enabled !== "boolean") throw new VoiceError("voice_config_invalid", 400, `${app}: enabled must be a boolean`);
303
387
  const memory = Number(entry.memory);
304
388
  const floor = Math.max(256 * MIB, required.get(app) || 0);
305
389
  if (entry.enabled && !(Number.isFinite(memory) && memory >= floor)) {
306
- invalid(`${app}: memory must be at least ${Math.round(floor / MIB)}Mi`);
390
+ invalid("voice_gpu_plan_floor", { app, size: gib(floor) }, `${app}: memory must be at least ${Math.round(floor / MIB)}Mi`);
307
391
  }
308
392
  if (Number.isFinite(memory) && memory > 0) slices[app] = Math.round(memory / MIB) * MIB;
309
393
  if (entry.enabled) {
@@ -313,7 +397,9 @@ export async function saveSlicePlan(entries, options = {}) {
313
397
  off.add(app);
314
398
  }
315
399
  }
316
- if (gpu.memory > 0 && total > gpu.memory) invalid("slices exceed the GPU memory");
400
+ if (gpu.memory > 0 && total > gpu.memory) {
401
+ invalid("voice_gpu_plan_over", { need: gib(total), total: gib(gpu.memory) }, "slices exceed the GPU memory");
402
+ }
317
403
  writePlan({ apps: union(plan.apps, gpu.bindings.map((binding) => binding.app)), slices, off: [...off] });
318
404
  if (gpu.type === "MemorySlice") startGpuSwitch(true, options);
319
405
  return gpuModePayload(options);
@@ -337,17 +423,19 @@ const appErrors = new Map();
337
423
  */
338
424
  export async function gpuAppAction(app, op, options = {}) {
339
425
  if (op !== "resume" && op !== "stop") throw new VoiceError("voice_config_invalid", 400, "op must be resume or stop");
340
- if (job?.phase === "switching" || job?.phase === "resuming" || pendingApps.has(app)) {
341
- throw new VoiceError("voice_gpu_busy", 409, "the GPU is busy");
426
+ if (BUSY.has(job?.phase) || pendingApps.has(app)) {
427
+ throw new GpuError(gpuProblem("voice_gpu_busy"), 409);
342
428
  }
343
429
  const [gpu, apps] = await Promise.all([speechGpu(options), gpuApps(options)]);
344
- if (!gpu) throw new VoiceError("voice_gpu_failed", 404, "no NVIDIA GPU found");
345
- if (!apps.some((entry) => entry.app === app)) throw new VoiceError("voice_config_invalid", 400, `${app} is not a GPU app`);
430
+ if (!gpu) throw new GpuError(gpuProblem("voice_gpu_no_gpu"), 404);
431
+ if (!apps.some((entry) => entry.app === app)) {
432
+ throw new GpuError(gpuProblem("voice_gpu_unknown_app", { app }, `${app} is not a GPU app`), 400);
433
+ }
346
434
  const plan = readPlan();
347
435
  let binding = `${gpu.node}:${gpu.device}`;
348
436
  if (op === "resume" && gpu.type === "MemorySlice") {
349
437
  if (plan.off.includes(app) || !(Number(plan.slices[app]) > 0)) {
350
- throw new VoiceError("voice_config_invalid", 400, `${app} has no memory slice`);
438
+ throw new GpuError(gpuProblem("voice_gpu_no_slice", { app }, `${app} has no memory slice`), 400);
351
439
  }
352
440
  binding = sliceFlag(gpu, app, plan);
353
441
  }
@@ -359,14 +447,14 @@ export async function gpuAppAction(app, op, options = {}) {
359
447
  void runOlaresCli(args, options.spawnFn)
360
448
  .then(async (result) => {
361
449
  if (result.code !== 0 && !(await settledAs(app, op === "stop" ? "stopped" : "running", options))) {
362
- appErrors.set(app, result.message);
450
+ appErrors.set(app, cliProblem(result, op === "stop" ? "voice_gpu_stop_failed" : "voice_gpu_resume_failed", { app }));
363
451
  return;
364
452
  }
365
453
  const latest = readPlan();
366
454
  const kept = latest.apps.filter((name) => name !== app);
367
455
  writePlan({ ...latest, apps: op === "resume" ? [...kept, app] : kept });
368
456
  })
369
- .catch((err) => appErrors.set(app, err instanceof Error ? err.message : String(err)))
457
+ .catch((err) => appErrors.set(app, problemOf(err)))
370
458
  .finally(() => pendingApps.delete(app));
371
459
  return gpuModePayload(options);
372
460
  }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Why a GPU step failed, as a code the settings panel turns into what to do
3
+ * next. `detail` keeps olares-cli's own words for what a code cannot explain.
4
+ * Shared by the Host and the browser, so it imports nothing.
5
+ *
6
+ * @typedef {{ code: string, params: Record<string, unknown>, detail: string }} GpuProblem
7
+ */
8
+
9
+ /** @returns {GpuProblem} */
10
+ export function gpuProblem(code, params = {}, detail = "") {
11
+ return { code, params, detail: String(detail ?? "") };
12
+ }
13
+
14
+ const FORBIDDEN = /\b403\b|forbidden|permission denied|not (allowed|permitted)|admin(istrator)? (role )?(is )?required/i;
15
+ const LOGIN = /\b(401|459)\b|unauthori[sz]ed|invalidated|not logged in|profile login|credential (is )?(missing|absent|invalid)/i;
16
+ const BLOCKED = /bound-apps-stop-blocked/i;
17
+ const MEMORY = /out of memory|(insufficient|not enough)[^.\n]*(gpu|memory|vram)|gpu memory[^.\n]*(exceed|insufficient)/i;
18
+
19
+ /**
20
+ * An olares-cli result → the problem the user acts on. Account and login
21
+ * failures are fixed outside Lares, so they win over the step's own code.
22
+ * @param {{ message?: string, text?: string }} result
23
+ * @param {string} fallback code for this step
24
+ */
25
+ export function cliProblem(result, fallback, params = {}) {
26
+ const detail = String(result?.message ?? "");
27
+ const text = `${detail}\n${result?.text ?? ""}`;
28
+ if (FORBIDDEN.test(text)) return gpuProblem("voice_gpu_forbidden", params, detail);
29
+ if (LOGIN.test(text)) return gpuProblem("voice_gpu_login", params, detail);
30
+ if (BLOCKED.test(text)) return gpuProblem("voice_gpu_blocked", params, detail);
31
+ if (MEMORY.test(text)) return gpuProblem("voice_gpu_memory", params, detail);
32
+ return gpuProblem(fallback, params, detail);
33
+ }
34
+
35
+ /**
36
+ * Problem → sentence in the product language. Unknown codes fall back to the
37
+ * generic failure, which carries the CLI detail.
38
+ * @param {(key: string, params?: Record<string, unknown>) => string} translate
39
+ * @param {GpuProblem | null | undefined} problem
40
+ */
41
+ export function gpuProblemText(translate, problem) {
42
+ if (!problem?.code) return "";
43
+ const params = { ...problem.params, detail: problem.detail || problem.code };
44
+ const key = `error.${problem.code}`;
45
+ const text = translate(key, params);
46
+ return text === key ? translate("error.voice_gpu_failed", params) : text;
47
+ }
@@ -1131,6 +1131,8 @@ def dispatch(payload, directory):
1131
1131
  return play_file(directory, payload.get("file"))
1132
1132
  if op == "play-stream":
1133
1133
  return open_stream(directory, payload.get("file"), payload.get("rate"), payload.get("channels"))
1134
+ if op == "playback-status":
1135
+ return {"op": op, "playing": playback_proc is not None and playback_proc.poll() is None}
1134
1136
  if op == "playback-stop":
1135
1137
  return stop_playback_request()
1136
1138
  if op == "capture-start":
@@ -0,0 +1,38 @@
1
+ export function createHostInput(port) {
2
+ let abort = null;
3
+ return {
4
+ capabilities: { duplex: false, bargeIn: false, audio: "pcm16-16000" },
5
+ async capture({ signal, onTranscribing, continuous = false } = {}) {
6
+ abort = new AbortController();
7
+ const current = abort;
8
+ const cancel = () => current.abort();
9
+ signal?.addEventListener("abort", cancel, { once: true });
10
+ try {
11
+ if (signal?.aborted) return "";
12
+ const opened = await port.start(current.signal);
13
+ if (!opened.recording) throw new Error("voice_bluetooth_not_ready");
14
+ while (!current.signal.aborted) {
15
+ const state = await port.status(current.signal);
16
+ if (state.phase === "transcribing") onTranscribing?.();
17
+ if (state.phase === "done") return state.text;
18
+ if (state.phase === "error") {
19
+ if (continuous && ["voice_no_input", "voice_too_short"].includes(state.error)) return "";
20
+ throw new Error(state.error);
21
+ }
22
+ if (state.phase === "idle") return "";
23
+ await new Promise((resolve) => {
24
+ const finish = () => { clearTimeout(timer); current.signal.removeEventListener("abort", finish); resolve(); };
25
+ const timer = setTimeout(finish, 200);
26
+ current.signal.addEventListener("abort", finish, { once: true });
27
+ });
28
+ }
29
+ return "";
30
+ } catch (error) {
31
+ if (current.signal.aborted) return "";
32
+ throw error;
33
+ } finally { signal?.removeEventListener("abort", cancel); }
34
+ },
35
+ stop: () => port.stop(),
36
+ async cancel() { if (!abort) return; abort.abort(); abort = null; await port.cancel(); },
37
+ };
38
+ }
@@ -0,0 +1,171 @@
1
+ import { createVoiceAgentSession } from "./agent-session.js";
2
+ import { createVoiceRecords } from "./records.js";
3
+ import { createVoiceDialogue, cleanDictation } from "./dialogue.js";
4
+ import { readConfig } from "./config.js";
5
+ import { VOICE_PRESETS } from "./options.js";
6
+
7
+ const failure = (code, status) => Object.assign(new Error(code), { code, status });
8
+
9
+ /** Presets and task policy stay in core; the Host supplies an authenticated scope. */
10
+ export function createVoiceHostService(api, { env = process.env, metric = () => {}, records = createVoiceRecords(), config = readConfig, chat } = {}) {
11
+ const ownerOf = (scope) => scope.owner || "@local";
12
+ const keyOf = (scope) => scope.session?.snapshot().sessionId || `voice:${scope.contextId || scope.id}`;
13
+ const append = (scope, role, text, turnId, taskId = null) => records.append(ownerOf(scope), keyOf(scope), {
14
+ id: `${scope.id}:${turnId}:${role}`, voiceId: scope.id, clientId: scope.id, turnId, role, text: text.slice(0, 16000), taskId,
15
+ });
16
+ const scopes = new Set();
17
+ const service = {
18
+ async clean(scope, text) {
19
+ if (typeof text !== "string" || text.length > 16000) throw failure("voice_input_invalid", 400);
20
+ return cleanDictation(text, { config: config(), owner: scope.owner, chat });
21
+ },
22
+ async open(scope, body) {
23
+ if (scope.active || scope.opening || scope.closing) throw failure("voice_session_active", 409);
24
+ const epoch = scope.epoch = (scope.epoch || 0) + 1;
25
+ scope.opening = true;
26
+ try {
27
+ if (body.sessionId != null && (typeof body.sessionId !== "string" || !body.sessionId || body.sessionId.length > 256)) throw failure("voice_session_invalid", 400);
28
+ if (body.contextId != null && (typeof body.contextId !== "string" || !/^[a-zA-Z0-9_-]{8,128}$/.test(body.contextId))) throw failure("voice_context_invalid", 400);
29
+ scope.contextId = body.contextId || scope.id;
30
+ const binding = records.read(ownerOf(scope), `voice:${scope.contextId}`).binding;
31
+ const boundSessionId = body.sessionId || binding || null;
32
+ if (boundSessionId) await api.inspect(boundSessionId);
33
+ if (scope.epoch !== epoch) throw failure("voice_session_closed", 409);
34
+ await scope.session?.close();
35
+ const selected = config();
36
+ const preset = body.preset || selected.preset;
37
+ if (!VOICE_PRESETS.includes(preset)) throw failure("voice_preset_invalid", 400);
38
+ scopes.add(scope);
39
+ scope.voiceAbort = new AbortController();
40
+ scope.voiceConfig = { ...selected, preset };
41
+ const history = records.read(ownerOf(scope), boundSessionId || `voice:${scope.contextId || scope.id}`);
42
+ scope.recoveryContext = history.summary || null;
43
+ const restoredRequests = [...new Set(history.records.map((row) => row.taskId).filter(Boolean))].map((requestId) => ({ requestId }));
44
+ scope.session = createVoiceAgentSession(api, { sessionId: boundSessionId,
45
+ preset: scope.authorizedBinding?.agentPreset || env.LARES_VOICE_AGENT_PRESET || undefined, workspaceId: scope.authorizedBinding?.workspaceId || env.LARES_VOICE_WORKSPACE_ID || undefined, restoredRequests,
46
+ metric: (fields) => metric({ voiceId: scope.id, ...fields }),
47
+ });
48
+ await scope.session.open();
49
+ if (scope.epoch !== epoch) throw failure("voice_session_closed", 409);
50
+ scope.recordKey = keyOf(scope);
51
+ scope.voiceResults = []; scope.summaryError = null;
52
+ scope.active = true; scope.readAloud = body.readAloud === true;
53
+ scope.voiceState = { version: 0, running: false, utterance: null, error: null };
54
+ scope.voiceRequests = new Map(); scope.deliveredTasks = new Set(restoredRequests.map((item) => item.requestId));
55
+ scope.dialogue = createVoiceDialogue({ config: scope.voiceConfig, owner: scope.owner, context: history.summary?.recent || [], summary: history.summary?.text || "", chat,
56
+ submit: async (text, requestId, operation) => {
57
+ if (operation === "status") return scope.session.snapshot();
58
+ const id = `${requestId}:delegate`;
59
+ // The source transcript is persisted before any side effect, even for lazy business sessions.
60
+ const before = keyOf(scope);
61
+ append(scope, "task", text, requestId, id);
62
+ let state;
63
+ try { state = await scope.session.submit(text, id, scope.submitMode || "queue"); }
64
+ finally { if (before !== keyOf(scope)) {
65
+ for (const row of records.read(ownerOf(scope), before).records) records.append(ownerOf(scope), keyOf(scope), row);
66
+ records.clear(ownerOf(scope), before);
67
+ records.bind(ownerOf(scope), scope.contextId, keyOf(scope));
68
+ } }
69
+ return { status: "pending", sessionId: state.sessionId, requestId: id };
70
+ },
71
+ });
72
+ return service.state(scope);
73
+ } finally { if (scope.epoch === epoch) scope.opening = false; }
74
+ },
75
+ async prompt(scope, body) {
76
+ if (!scope.active || !scope.session) throw failure("voice_session_closed", 409);
77
+ if (typeof body.text !== "string" || !body.text.trim() || body.text.length > 16000 || typeof body.requestId !== "string" || !body.requestId || body.requestId.length > 128) throw failure("voice_session_invalid", 400);
78
+ if (scope.voiceConfig.preset === "dictation") return { ...service.state(scope), text: body.text };
79
+ if (scope.voiceConfig.preset === "direct") {
80
+ const before = keyOf(scope);
81
+ append(scope, "user", body.text, body.requestId, body.requestId);
82
+ try { return await scope.session.submit(body.text, body.requestId, body.mode); }
83
+ finally {
84
+ if (before !== keyOf(scope)) {
85
+ for (const row of records.read(ownerOf(scope), before).records) records.append(ownerOf(scope), keyOf(scope), row);
86
+ records.clear(ownerOf(scope), before);
87
+ records.bind(ownerOf(scope), scope.contextId, keyOf(scope));
88
+ }
89
+ }
90
+ }
91
+ if (scope.voiceRequests.has(body.requestId)) return scope.voiceRequests.get(body.requestId);
92
+ if (scope.voiceRequests.size >= 256) throw failure("voice_session_limit", 429);
93
+ const operation = (scope.voiceSerial || Promise.resolve()).catch(() => {}).then(async () => {
94
+ if (!scope.active || scope.voiceAbort.signal.aborted) throw failure("voice_session_closed", 409);
95
+ scope.submitMode = body.mode === "steer" ? "steer" : "queue";
96
+ scope.voiceState.running = true; scope.voiceState.error = null; scope.voiceState.version++;
97
+ append(scope, "user", body.text, body.requestId);
98
+ try {
99
+ const text = await scope.dialogue.reply(body.text, body.requestId, scope.voiceAbort.signal);
100
+ append(scope, "assistant", text, body.requestId);
101
+ const key = keyOf(scope);
102
+ const checkpointVersion = records.read(ownerOf(scope), key).version;
103
+ scope.summaryTask = scope.dialogue.summarize().then((summary) => records.checkpoint(ownerOf(scope), key, summary, checkpointVersion)).catch((cause) => {
104
+ scope.summaryError = cause.code || cause.message || "voice_summary_failed";
105
+ });
106
+ scope.voiceState.utterance = { key: body.requestId, turn: scope.voiceState.version, step: 0, text, done: true, silent: false };
107
+ } catch (cause) { scope.voiceState.error = cause.code || cause.message || "voice_failed"; throw cause; }
108
+ finally { scope.voiceState.running = false; scope.voiceState.version++; }
109
+ return service.state(scope);
110
+ });
111
+ scope.voiceSerial = operation;
112
+ scope.voiceRequests.set(body.requestId, operation);
113
+ return operation;
114
+ },
115
+ state(scope) {
116
+ const state = scope.session?.snapshot() || {};
117
+ if (!scope.voiceState) return { ...state, contextId: scope.contextId, active: scope.active === true };
118
+ // Completion is keyed by durable request/turn, never by the session's aggregate idle flag.
119
+ for (const task of state.tasks || []) {
120
+ if (!["completed", "failed", "cancelled"].includes(task.status) || scope.deliveredTasks.has(task.requestId)) continue;
121
+ append(scope, "task", JSON.stringify(task), `${task.requestId}:result`, task.requestId);
122
+ scope.deliveredTasks.add(task.requestId);
123
+ scope.voiceResults ??= [];
124
+ scope.voiceResults.push(task);
125
+ }
126
+ if (scope.voiceConfig.preset === "direct") return { ...state, contextId: scope.contextId, active: scope.active === true };
127
+ // Pending business work does not block the next speech turn.
128
+ if (scope.active && !scope.voiceState.running && scope.voiceResults?.length && !scope.voiceInputActive) {
129
+ const task = scope.voiceResults.shift();
130
+ scope.dialogue.observeTask(task);
131
+ const text = task.status === "completed" ? task.text : `Task ${task.status}: ${task.error || ""}`;
132
+ scope.voiceState.utterance = { key: `task:${task.requestId}`, turn: ++scope.voiceState.version, step: 0, text, done: true, silent: false };
133
+ append(scope, "assistant", text, `result:${task.requestId}`, task.requestId);
134
+ }
135
+ return { ...state, ...scope.voiceState, contextId: scope.contextId, active: scope.active === true, backgroundRunning: state.running, summaryError: scope.summaryError || null, preset: scope.voiceConfig.preset };
136
+ },
137
+ async remove(sessionId) {
138
+ for (const scope of scopes) if (scope.session?.snapshot().sessionId === sessionId) { await service.close(scope); scopes.delete(scope); }
139
+ records.clearConversation(sessionId);
140
+ },
141
+ record(scope, role, text, turnId) {
142
+ const key = keyOf(scope);
143
+ if (scope.recordKey && scope.recordKey !== key) {
144
+ for (const row of records.read(ownerOf(scope), scope.recordKey).records) records.append(ownerOf(scope), key, row);
145
+ records.clear(ownerOf(scope), scope.recordKey);
146
+ records.bind(ownerOf(scope), scope.contextId, key);
147
+ }
148
+ scope.recordKey = key;
149
+ return append(scope, role, text.slice(0, 16000), String(turnId || scope.voiceState.version), role === "task" ? String(turnId) : null);
150
+ },
151
+ records(scope) { return records.read(ownerOf(scope), keyOf(scope)); },
152
+ async clearRecords(scope) { const key = keyOf(scope); await service.close(scope); records.clear(ownerOf(scope), key); },
153
+ output(scope, value) { if (typeof value !== "boolean") throw failure("voice_output_invalid", 400); scope.readAloud = value; },
154
+ resume: (scope) => scope.session?.resume(),
155
+ cancel: (scope) => scope.session?.cancel(),
156
+ async close(scope) {
157
+ scope.epoch = (scope.epoch || 0) + 1; scope.opening = false; scope.closing = true;
158
+ scope.active = false; scope.voiceAbort?.abort(); scope.realtimeSocket?.close();
159
+ try {
160
+ scope.capture?.stop(true);
161
+ const cleanup = await Promise.allSettled([scope.duplex?.interrupt(), scope.realtimeAudio?.stop(), scope.session?.close()]);
162
+ await scope.voiceSerial?.catch(() => {});
163
+ await scope.summaryTask;
164
+ scopes.delete(scope);
165
+ const failed = cleanup.find((result) => result.status === "rejected");
166
+ if (failed) throw failed.reason;
167
+ } finally { scope.closing = false; }
168
+ },
169
+ };
170
+ return service;
171
+ }
package/voice/input.js CHANGED
@@ -1,4 +1,5 @@
1
1
  /** Where a take is recorded. The desktop page uses the headset; the phone page uses its own mic. */
2
- export function captureSource(surface) {
2
+ export function captureSource(surface, selected = "auto") {
3
+ if (selected === "device" || selected === "headset") return selected;
3
4
  return surface === "mobile" ? "device" : "headset";
4
5
  }