@olares/lares-core 0.15.22 → 0.15.23

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/brand/identity.js CHANGED
@@ -10,6 +10,11 @@ export const UPSTREAM_TITLE = "DeepSeek Harness";
10
10
 
11
11
  export const THEME_COLOR = "#6366F1";
12
12
 
13
+ /** Letter-by-letter, so a model cannot drop the "a" and emit "olores". */
14
+ function platformSpelling() {
15
+ return `Spell the platform name exactly ${PLATFORM_NAME} (${[...PLATFORM_NAME].join(", ")}).`;
16
+ }
17
+
13
18
  export function replaceProductTitle(title) {
14
19
  return String(title ?? "").split(UPSTREAM_TITLE).join(PRODUCT_NAME);
15
20
  }
@@ -18,10 +23,11 @@ export function replaceProductTitle(title) {
18
23
  export function identityPrompt() {
19
24
  return [
20
25
  `You are ${PRODUCT_NAME}, a helpful assistant running on ${PLATFORM_NAME}.`,
26
+ platformSpelling(),
21
27
  `Prefer olares-cli for ${PLATFORM_NAME} platform tasks when skills apply.`,
22
28
  `olares-cli is on PATH and already signed in as the user: ${PLATFORM_NAME} mounts this application's credential into the container. Never run profile login or profile import — a platform-issued profile refuses both.`,
23
29
  "For image, video, audio, or 3D generation, creation, or image editing — including FlowStudio scenes and workflows (T2V, I2V, R2V) — follow the lares-media-create skill: list that family on Router, then call media_generate, which waits for the result and publishes it. Do not curl FlowStudio. Do not diagnose Router or sync catalogs unless that list is empty.",
24
- "Use read/write/edit for files; use background jobs for long shell work.",
30
+ "To answer a question or look something up, only read: use read, search, and read-only commands such as olares-cli. Do not use write or edit to look something up, take notes, or try a change the user did not ask for. Use write or edit only when the user asked you to change or produce that file, and prefer them over shell for that file work. Use background jobs for long shell work.",
25
31
  "User-visible files belong in the session workspace (cwd) as relative paths. Do not write them under /app: that is the application overlay, and the chat preview cannot open it.",
26
32
  "For the current date or time, run `date`; the process TZ is the user's configured Olares time zone.",
27
33
  "Images the user attaches are already in context; read_image is only for image files that exist on disk, never for an attachment.",
@@ -40,6 +46,7 @@ export function surfacePrompt(webUrl) {
40
46
  `You are interacting with the user through ${PRODUCT_NAME} at ${webUrl}.`,
41
47
  `When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean ${PRODUCT_NAME}.`,
42
48
  "The browser provides no implicit DOM, route, or screenshot context.",
49
+ platformSpelling(),
43
50
  `If asked who you are, answer as ${PRODUCT_NAME} on ${PLATFORM_NAME}.`,
44
51
  ].join(" ");
45
52
  }
@@ -54,7 +61,9 @@ You are helping inside an ${PLATFORM_NAME} workspace via ${PRODUCT_NAME}.
54
61
  - For image / video / audio / 3D generation, including FlowStudio scenes and workflows, follow the lares-media-create skill: list that family, POST the matching Router shim route, land the file. Do not curl FlowStudio.
55
62
  - Stay inside the workspace for file edits unless the user explicitly asks otherwise.
56
63
  - Write files the user should open as workspace-relative paths (session cwd). \`/app\` is the running application and is not previewable.
57
- - Prefer structured fs tools (read / write / edit) over shell for file work.
64
+ - To look something up, only read. Do not use write or edit to look something up, take notes, or try a change the user did not ask for.
65
+ - Use write or edit only when the user asked you to change or produce that file. Prefer those tools over shell for that file work.
66
+ - ${platformSpelling()}
58
67
  - Long-running shell work can use background jobs; check results with job_output.
59
68
  - \`@path\` in a user message is workspace-relative, not absolute; \`/id\` names a skill.
60
69
  - If asked who you are, you are ${PRODUCT_NAME} on ${PLATFORM_NAME}.
@@ -67,6 +76,20 @@ export const LEGACY_AGENTS_SEEDS = [
67
76
 
68
77
  You are helping inside an Olares workspace via Lares.
69
78
 
79
+ - Prefer olares-cli skills for platform tasks (market, cluster, files, router, …).
80
+ - olares-cli is already signed in as the user from the credential Olares mounts into this container. Never run \`profile login\` or \`profile import\`; a platform-issued profile refuses both.
81
+ - For image / video / audio / 3D generation, including FlowStudio scenes and workflows, follow the lares-media-create skill: list that family, POST the matching Router shim route, land the file. Do not curl FlowStudio.
82
+ - Stay inside the workspace for file edits unless the user explicitly asks otherwise.
83
+ - Write files the user should open as workspace-relative paths (session cwd). \`/app\` is the running application and is not previewable.
84
+ - Prefer structured fs tools (read / write / edit) over shell for file work.
85
+ - Long-running shell work can use background jobs; check results with job_output.
86
+ - \`@path\` in a user message is workspace-relative, not absolute; \`/id\` names a skill.
87
+ - If asked who you are, you are Lares on Olares.
88
+ `,
89
+ `# AGENTS.md
90
+
91
+ You are helping inside an Olares workspace via Lares.
92
+
70
93
  - Prefer olares-cli skills for platform tasks (market, cluster, files, router, …).
71
94
  - For image / video / audio / 3D generation, including FlowStudio scenes and workflows, follow the lares-media-create skill: list that family, POST the matching Router shim route, land the file. Do not curl FlowStudio.
72
95
  - Stay inside the workspace for file edits unless the user explicitly asks otherwise.
package/i18n/voice.js CHANGED
@@ -105,6 +105,7 @@ export const ZH = {
105
105
  "call.start": "语音对话",
106
106
  "call.end": "结束语音",
107
107
  "call.resume": "恢复语音",
108
+ "call.restart": "重新开始",
108
109
  "call.stopPlayback": "停止朗读",
109
110
  "call.cancelTask": "取消任务",
110
111
  "call.listening": "正在收听",
@@ -387,6 +388,7 @@ export const EN = {
387
388
  "call.start": "Voice conversation",
388
389
  "call.end": "End voice",
389
390
  "call.resume": "Resume voice",
391
+ "call.restart": "Start again",
390
392
  "call.stopPlayback": "Stop speaking",
391
393
  "call.cancelTask": "Cancel task",
392
394
  "call.listening": "Listening",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olares/lares-core",
3
- "version": "0.15.22",
3
+ "version": "0.15.23",
4
4
  "description": "Lares 共享业务核心:Router、workspace、文件/媒体、Olares 身份(无 UI、无平台壳)",
5
5
  "type": "module",
6
6
  "repository": {
@@ -14,7 +14,7 @@ export interface RouterCatalogRow {
14
14
  reasoningDefault?: string;
15
15
  /** Tokens one request may span, or null when Router states none. */
16
16
  contextWindow: number | null;
17
- /** Output tokens a turn may reserve: Router's limit capped at a quarter of the window. */
17
+ /** Output tokens a turn may reserve: Router's limit capped at a tenth of the window. */
18
18
  maxTokens: number | null;
19
19
  }
20
20
 
package/router/catalog.js CHANGED
@@ -42,13 +42,14 @@ function tokenCount(value) {
42
42
  /**
43
43
  * Output tokens a turn may ask for. A local engine reports its whole context
44
44
  * as max_output_tokens, and a request that reserves all of it cannot fit
45
- * beside its own prompt: the engine's KV budget refuses it or, worse, admits
46
- * it and evicts every other request. A quarter of the window leaves room for
47
- * the prompt and still exceeds any reply a chat turn writes.
45
+ * beside its own prompt. Compaction subtracts this cap from the model's
46
+ * context window before the 80% pressure line, so the cap has to fit in the
47
+ * remaining fifth or that line never fires. A tenth of the window does, once
48
+ * the summary headroom (8192) is counted, for any window of at least 80k.
48
49
  */
49
50
  export function outputTokenBudget(declared, contextWindow) {
50
51
  if (contextWindow === null) return declared;
51
- const cap = Math.max(1, Math.floor(contextWindow / 4));
52
+ const cap = Math.max(1, Math.floor(contextWindow / 10));
52
53
  return declared === null ? cap : Math.min(declared, cap);
53
54
  }
54
55
 
@@ -107,6 +107,7 @@ export function createVoiceController({ input, agent, output, voiceId = mintRpcI
107
107
  bindSession(sessionId) { if (sessionId) change({ sessionId }); },
108
108
  subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); },
109
109
  async start({ sessionId = null, continuous = false, running = false, manual = false, shouldStart = () => true } = {}) {
110
+ output.arm?.();
110
111
  manualTurns = manual;
111
112
  const startedEpoch = ++epoch;
112
113
  creating = null;
@@ -1,3 +1,4 @@
1
+ import { primePlaybackContext } from "./pcm-player.js";
1
2
  import { createVoiceController } from "./controller.js";
2
3
  import { createDeviceInput } from "./device-input.js";
3
4
  import { createHostInput } from "./host-input.js";
@@ -46,7 +47,7 @@ export function createVoiceConversation({ request, transcribe, playback, onReply
46
47
  const controller = createVoiceController({ voiceId, input, idleTimeoutMs, recoveryTimeoutMs, onExpired: () => stop(), onTranscript: onTranscript ? async (value) => { await onTranscript(value); if (!continuous) await stop(); } : null, metric: (event) => {
47
48
  metric(event);
48
49
  void call("/session/metric", { stage: event.stage, durationMs: event.durationMs }).catch(() => {});
49
- }, output: { get canPause() { return playback.canPause === true; }, key: () => playback.key, pause: () => playback.pause?.(), resume: () => playback.resume?.(), async stop() {
50
+ }, output: { arm() { primePlaybackContext(); }, get canPause() { return playback.canPause === true; }, key: () => playback.key, pause: () => playback.pause?.(), resume: () => playback.resume?.(), async stop() {
50
51
  const reply = lastUtterance;
51
52
  playback.stop();
52
53
  if (!reply || !Number.isFinite(reply.turn) || typeof lastSessionId !== "string") return;
package/voice/panel.js CHANGED
@@ -2,7 +2,7 @@ import { setVoiceActivity } from "./activity.js";
2
2
  import { DICTATION_PRESETS } from "./dictation.js";
3
3
 
4
4
  /** The panel owns drafts and turn policy; adapters only supply settings, audio and insertion ports. */
5
- export function createVoicePanel({ create, complete, loadConfig = async () => ({}), process = async ({ text }) => text, onSession = () => {}, manualTurns = true, inlineDictation = false, prepareInput = () => null }) {
5
+ export function createVoicePanel({ create, complete, loadConfig = async () => ({}), process = async ({ text }) => text, onSession = () => {}, manualTurns = true, inlineDictation = false, prepareInput = () => null, armOutput = () => {} }) {
6
6
  let state = { visible: false, inputActive: false, previewVisible: false, mode: "single", sessionId: null, raw: "", draft: "", edited: false, preset: "raw", stage: "idle", snapshot: null, busy: false, error: null, readAloudError: null, device: "", levels: [], startedAt: null, elapsedMs: 0, holding: false, cancelArmed: false };
7
7
  let call = null, config = null, generation = 0, intent = 0, processing = null;
8
8
  let inputPreparation = null;
@@ -136,8 +136,10 @@ export function createVoicePanel({ create, complete, loadConfig = async () => ({
136
136
  readAloudPending = pending;
137
137
  return pending.promise;
138
138
  },
139
- retryReadAloud() { return panel.setReadAloud(readAloudPreference); },
139
+ retryReadAloud() { armOutput(); return panel.setReadAloud(readAloudPreference); },
140
+ arm() { armOutput(); },
140
141
  open({ mode = state.mode, sessionId = state.sessionId, preset, configSnapshot } = {}) {
142
+ armOutput();
141
143
  if (state.busy) return Promise.resolve();
142
144
  cancelPreparation();
143
145
  const snapshot = configSnapshot ? structuredClone(configSnapshot) : null;
@@ -162,7 +164,7 @@ export function createVoicePanel({ create, complete, loadConfig = async () => ({
162
164
  },
163
165
  close() { intent++; cancelPreparation(); cancelProcessing(); emit({ visible: false, holding: false, cancelArmed: false }); if (state.mode === "continuous") void call?.controller.cancelRecording(); return enqueue(pause); },
164
166
  pause() { intent++; cancelPreparation(); cancelProcessing(); emit({ holding: false, cancelArmed: false }); if (state.mode === "continuous") void call?.controller.cancelRecording(); return enqueue(pause); },
165
- start() { const token = ++intent; return enqueue(() => prepare(token)); },
167
+ start() { armOutput(); const token = ++intent; return enqueue(() => prepare(token)); },
166
168
  finish() {
167
169
  if (state.busy) return Promise.resolve();
168
170
  const token = intent;
@@ -182,6 +184,7 @@ export function createVoicePanel({ create, complete, loadConfig = async () => ({
182
184
  return enqueue(async () => { await transform(preset); return true; });
183
185
  },
184
186
  holdStart() {
187
+ armOutput();
185
188
  if (state.busy || state.holding || state.snapshot?.capture === "transcribing") return Promise.resolve();
186
189
  const token = ++intent; emit({ holding: true, cancelArmed: false });
187
190
  return enqueue(async () => {
@@ -1,21 +1,61 @@
1
1
  import { PCM_PLAYER_WORKLET } from "./pcm-worklet.js";
2
2
  import { pcmSamples } from "./realtime-audio.js";
3
3
 
4
+ let primed = null;
5
+ const worklets = new WeakMap();
6
+
7
+ function ensureWorklet(ctx) {
8
+ const pending = worklets.get(ctx);
9
+ if (pending) return pending;
10
+ const url = URL.createObjectURL(new Blob([PCM_PLAYER_WORKLET], { type: "text/javascript" }));
11
+ let tracked;
12
+ tracked = Promise.resolve(ctx.audioWorklet.addModule(url)).finally(() => URL.revokeObjectURL(url)).catch((error) => {
13
+ if (worklets.get(ctx) === tracked) worklets.delete(ctx);
14
+ throw error;
15
+ });
16
+ worklets.set(ctx, tracked);
17
+ return tracked;
18
+ }
19
+
20
+ /** Create and resume the playback context inside the gesture that asked for sound. */
21
+ export function primePlaybackContext(Context = globalThis.AudioContext || globalThis.webkitAudioContext) {
22
+ if (!Context) return null;
23
+ if (!primed || primed.state === "closed") primed = new Context();
24
+ const resume = primed.resume?.();
25
+ if (resume && typeof resume.catch === "function") resume.catch(() => {});
26
+ ensureWorklet(primed).catch(() => {});
27
+ return primed;
28
+ }
29
+
4
30
  export function createPcmStreamPlayer({ onProgress = () => {}, onMetric = () => {}, context: Context = globalThis.AudioContext || globalThis.webkitAudioContext } = {}) {
5
- let context, node, resolve, reject, played = 0, generation = 0, pendingByte = null, failed = null, paused = false;
31
+ let context, node, resolve, reject, played = 0, generation = 0, pendingByte = null, failed = null, paused = false, owned = false;
6
32
  const stop = () => {
7
33
  generation++; paused = false; node?.disconnect(); if (node) node.port.onmessage = null; node = null;
8
- if (context && context.state !== "closed") void context.close().catch(() => {}); context = null; resolve?.(); resolve = reject = null; pendingByte = null;
34
+ const closing = owned ? context : null;
35
+ owned = false;
36
+ if (closing && closing.state !== "closed") void closing.close().catch(() => {});
37
+ context = null; resolve?.(); resolve = reject = null; pendingByte = null;
9
38
  };
10
39
  return {
11
40
  async start({ sampleRate, channels, encoding }) {
12
41
  stop(); failed = null; played = 0;
13
42
  if (!Context || channels !== 1 || encoding !== "pcm_s16le" || !Number.isInteger(sampleRate) || sampleRate < 8000 || sampleRate > 192000) throw new Error("voice_audio_format");
14
43
  const epoch = generation;
15
- context = new Context(); const opened = context;
16
- const url = URL.createObjectURL(new Blob([PCM_PLAYER_WORKLET], { type: "text/javascript" }));
17
- try { await opened.audioWorklet.addModule(url); await opened.resume(); } finally { URL.revokeObjectURL(url); }
18
- if (epoch !== generation) { await opened.close().catch(() => {}); throw new Error("voice_cancelled"); }
44
+ const shared = primed && primed.state !== "closed" ? primed : null;
45
+ owned = !shared;
46
+ context = shared || new Context(); const opened = context;
47
+ try {
48
+ await ensureWorklet(opened);
49
+ await opened.resume?.();
50
+ if (epoch !== generation) throw new Error("voice_cancelled");
51
+ if (opened.state && opened.state !== "running") throw new Error("voice_playback_failed");
52
+ onMetric({ stage: "playback_context", state: opened.state || "unknown", shared: Boolean(shared) });
53
+ } catch (error) {
54
+ onMetric({ stage: "playback_context", state: opened.state || "missing", shared: Boolean(shared), code: error?.message || "voice_playback_failed" });
55
+ if (owned && opened.state !== "closed") void opened.close?.().catch(() => {});
56
+ owned = false; context = null;
57
+ throw error;
58
+ }
19
59
  node = new AudioWorkletNode(opened, "lares-pcm-player", { processorOptions: { inputRate: sampleRate }, numberOfInputs: 0, numberOfOutputs: 1, outputChannelCount: [1] });
20
60
  node.port.onmessage = ({ data }) => {
21
61
  if (epoch !== generation) return;
@@ -14,6 +14,21 @@ export function voicePanelPresentation(view) {
14
14
  return { phase, status, preview, recording, pending: processing || transcribing || preparing, error };
15
15
  }
16
16
 
17
+ const RESTART_ERRORS = new Set(["voice_recovery_expired", "voice_idle_timeout"]);
18
+
19
+ /**
20
+ * A continuous call that has failed is one composer banner.
21
+ * The session can still be resumed while it is paused; once the recovery
22
+ * window has closed, the only way back is a new call.
23
+ */
24
+ export function voiceCallFailure(view) {
25
+ if (!view?.visible || view.mode !== "continuous") return null;
26
+ const ui = voicePanelPresentation(view);
27
+ if (!ui.error) return null;
28
+ const recoverable = view.snapshot?.connection === "paused" && !RESTART_ERRORS.has(ui.error);
29
+ return { code: ui.error, action: recoverable ? "resume" : "restart" };
30
+ }
31
+
17
32
  /** Shared inline labels; clients own focus, positioning and the timer tick. */
18
33
  export function voiceInputPresentation(view, now = Date.now()) {
19
34
  const ui = voicePanelPresentation(view);
@@ -35,7 +35,19 @@ export async function playAudioStream(options) {
35
35
  const connection = await openAudioStream({ ...options, operation: "tts" });
36
36
  if (!connection) throw new Error("voice_stream_failed");
37
37
  const file = createBrowserPlayer();
38
- const player = createPcmStreamPlayer({ onProgress: (frames) => { if (connection.readyState === 1) connection.send(JSON.stringify({ type: "progress", frames })); },
38
+ let progressNoted = false, progressBlocked = false;
39
+ const player = createPcmStreamPlayer({ onProgress: (frames) => {
40
+ if (connection.readyState !== 1) {
41
+ if (!progressBlocked) { progressBlocked = true; streamMetric(options, { stage: "playback_progress_blocked", readyState: connection.readyState, frames }); }
42
+ return;
43
+ }
44
+ try { connection.send(JSON.stringify({ type: "progress", frames })); }
45
+ catch (error) {
46
+ if (!progressBlocked) { progressBlocked = true; streamMetric(options, { stage: "playback_progress_blocked", code: error?.message || "send_failed", frames }); }
47
+ return;
48
+ }
49
+ if (!progressNoted) { progressNoted = true; streamMetric(options, { stage: "playback_progress_sent", frames }); }
50
+ },
39
51
  onMetric: (event) => { if (event.stage === "playback_started") options.onStatus?.("playing"); streamMetric(options, { ...event, durationMs: Date.now() - started }); } });
40
52
  let chain = Promise.resolve(), complete = false, firstReceived = false, ready = false, paused = false, segmentIndex = -1;
41
53
  options.controls?.({
@@ -45,7 +57,14 @@ export async function playAudioStream(options) {
45
57
  });
46
58
  try {
47
59
  await new Promise((resolve, reject) => {
48
- const fail = (error) => { reject(error); connection.close(); };
60
+ let failureLogged = false;
61
+ const noteFailure = (error) => {
62
+ const code = error?.message || "voice_playback_failed";
63
+ if (failureLogged || code === "voice_cancelled") return;
64
+ failureLogged = true;
65
+ streamMetric(options, { stage: "playback_failed", code, durationMs: Date.now() - started });
66
+ };
67
+ const fail = (error) => { noteFailure(error); reject(error); connection.close(); };
49
68
  const cancelled = () => { player.stop(); file.stop(); fail(new Error("voice_cancelled")); };
50
69
  options.signal?.addEventListener("abort", cancelled, { once: true });
51
70
  connection.addEventListener("message", ({ data }) => {
@@ -67,7 +86,10 @@ export async function playAudioStream(options) {
67
86
  });
68
87
  const dropped = () => new Error(options.signal?.aborted ? "voice_cancelled" : "voice_disconnected");
69
88
  connection.addEventListener("error", () => fail(dropped()), { once: true });
70
- connection.addEventListener("close", () => { options.signal?.removeEventListener("abort", cancelled); if (!complete) reject(dropped()); }, { once: true });
89
+ connection.addEventListener("close", () => {
90
+ options.signal?.removeEventListener("abort", cancelled);
91
+ if (!complete) { const error = dropped(); noteFailure(error); reject(error); }
92
+ }, { once: true });
71
93
  if (options.signal?.aborted) cancelled();
72
94
  });
73
95
  return true;
@@ -54,7 +54,11 @@ export function createAudioStreamGateway({ config, models, replies, synthesize =
54
54
  const started = Date.now();
55
55
  const send = (value) => { if (client.readyState === 1) client.send(JSON.stringify(value)); };
56
56
  const close = () => { if (closing) return; closing = true; abort.abort(); waiter?.(); fileDone?.(); clearTimeout(lifetime); upstream?.terminate(); clients.delete(client); client.close(); };
57
- const error = (cause) => { send({ type: "error", code: cause.code || "voice_stream_failed" }); close(); };
57
+ const error = (cause) => {
58
+ const code = cause?.code || "voice_stream_failed";
59
+ if (grant.operation === "tts" && code !== "voice_cancelled") metric({ stage: "playback_stop", voiceId: grant.scope.id, code, sentFrames, playedFrames });
60
+ send({ type: "error", code }); close();
61
+ };
58
62
  const lifetime = setTimeout(() => error(fault("voice_timeout")), 180000);
59
63
  client.on("close", close); client.on("error", close);
60
64
  client.on("message", (raw, binary) => {
@@ -77,7 +81,9 @@ export function createAudioStreamGateway({ config, models, replies, synthesize =
77
81
  if (frame.type === "resume") { pausedAt = 0; waiter?.(); waiter = null; return; }
78
82
  if (frame.type === "file.done" && fileDone) { fileDone(); fileDone = null; return; }
79
83
  if (frame.type !== "progress" || !Number.isSafeInteger(frame.frames) || frame.frames < playedFrames || frame.frames > sentFrames) throw fault("voice_stream_state");
84
+ const firstProgress = playedFrames === 0 && frame.frames > 0;
80
85
  playedFrames = frame.frames; waiter?.(); waiter = null;
86
+ if (firstProgress) metric({ stage: "playback_progress", voiceId: grant.scope.id, frames: playedFrames });
81
87
  }
82
88
  } catch (cause) { error(cause); }
83
89
  });
@@ -110,6 +116,7 @@ export function createAudioStreamGateway({ config, models, replies, synthesize =
110
116
  waiter = wake;
111
117
  abort.signal.addEventListener("abort", wake, { once: true });
112
118
  });
119
+ let reportedWait = false;
113
120
  const pump = async () => {
114
121
  while (!abort.signal.aborted) {
115
122
  if (cursor >= run.events.length) {
@@ -129,6 +136,7 @@ export function createAudioStreamGateway({ config, models, replies, synthesize =
129
136
  if (!rate) throw fault("voice_audio_format", 502);
130
137
  for (let offset = 0; offset < event.chunk.length; offset += 16384) {
131
138
  while (pausedAt || sentFrames - playedFrames > rate * 2 || client.bufferedAmount > 128 * 1024) {
139
+ if (!reportedWait) { reportedWait = true; metric({ stage: "playback_wait", voiceId: grant.scope.id, sentFrames, playedFrames, paused: Boolean(pausedAt) }); }
132
140
  await new Promise((resolve, reject) => { const timer = setTimeout(() => { waiter = null; reject(fault(pausedAt ? "voice_interruption_timeout" : "voice_audio_backpressure")); }, pausedAt ? Math.max(1, 10000 - (Date.now() - pausedAt)) : 10000); waiter = () => { clearTimeout(timer); resolve(); }; });
133
141
  if (abort.signal.aborted || client.readyState !== 1) throw fault("voice_cancelled");
134
142
  }