@olares/lares-core 0.15.17 → 0.15.18

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.
Files changed (60) hide show
  1. package/brand/identity.js +25 -2
  2. package/i18n/chat-model-switch.js +8 -2
  3. package/i18n/voice.js +78 -2
  4. package/i18n/webhook.js +39 -0
  5. package/larepass/mux.js +1 -0
  6. package/larepass/rpc.js +4 -3
  7. package/larepass/runtime.js +4 -1
  8. package/larepass/settings.js +11 -8
  9. package/package.json +29 -2
  10. package/router/catalog-cache.js +1 -1
  11. package/router/catalog-coordinator.js +60 -0
  12. package/router/catalog-sync.js +17 -62
  13. package/router/catalog.d.ts +3 -1
  14. package/router/catalog.js +6 -13
  15. package/router/default-selection.d.ts +3 -0
  16. package/router/default-selection.js +22 -0
  17. package/router/model-authorization.js +53 -0
  18. package/router/model-state.js +35 -0
  19. package/router/models.js +2 -6
  20. package/router/session-model.js +5 -18
  21. package/router/stt.js +2 -1
  22. package/router/tts.js +13 -20
  23. package/voice/activity.js +10 -0
  24. package/voice/agent-session.js +4 -0
  25. package/voice/browser-capture.js +72 -6
  26. package/voice/browser-playback.js +16 -20
  27. package/voice/client.js +28 -14
  28. package/voice/composite-gateway.js +7 -3
  29. package/voice/config.js +6 -0
  30. package/voice/controller.js +19 -8
  31. package/voice/conversation.js +48 -15
  32. package/voice/device-input.js +14 -9
  33. package/voice/dialogue.js +4 -3
  34. package/voice/host-service.js +7 -2
  35. package/voice/interruption.js +44 -0
  36. package/voice/lares-policy.js +12 -0
  37. package/voice/options.js +2 -1
  38. package/voice/panel.js +61 -20
  39. package/voice/pcm-player.js +51 -0
  40. package/voice/pcm-worklet.js +71 -0
  41. package/voice/preferences.js +89 -0
  42. package/voice/presentation.js +13 -1
  43. package/voice/reply-stream.js +97 -0
  44. package/voice/router-audio.js +23 -6
  45. package/voice/script.js +27 -36
  46. package/voice/service.js +46 -33
  47. package/voice/shortcuts.js +54 -5
  48. package/voice/speech-segmenter.js +119 -0
  49. package/voice/speech.js +60 -13
  50. package/voice/spoken-text.js +196 -0
  51. package/voice/startup.js +9 -0
  52. package/voice/stream-client.js +165 -0
  53. package/voice/stream-gateway.js +146 -0
  54. package/voice/tts-preview.js +27 -0
  55. package/voice/tts-settings.js +79 -0
  56. package/voice/tts-voice.js +20 -0
  57. package/webhook/client.js +34 -0
  58. package/webhook/config.js +55 -0
  59. package/webhook/public.js +11 -0
  60. package/webhook/reply.js +12 -0
@@ -5,17 +5,66 @@ export function shortcutFromEvent(event) {
5
5
  if (!key || ["Control", "Meta", "Alt", "Shift"].includes(key)) return "";
6
6
  return [event.ctrlKey && "Ctrl", event.metaKey && "Meta", event.altKey && "Alt", event.shiftKey && "Shift", key].filter(Boolean).join("+");
7
7
  }
8
+ export function voiceShortcutProblem(key, action, value) {
9
+ if (typeof key !== "string" || key && (!/^(?:Ctrl\+)?(?:Meta\+)?(?:Alt\+)?(?:Shift\+)?(?:[a-z0-9]|F(?:[1-9]|1[0-2]))$/.test(key) || !/^(Ctrl|Meta|Alt)\+/.test(key))) return "invalid";
10
+ if (RESERVED.has(key)) return "reserved";
11
+ if (key && ACTIONS.some(id => id !== action && value[id] === key)) return "duplicate";
12
+ return null;
13
+ }
8
14
  export function validateVoiceShortcuts(value) {
9
15
  if (!value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).sort().join() !== [...ACTIONS].sort().join()) throw new Error("voice_shortcut_invalid");
10
- const seen = new Set(); const result = {};
16
+ const result = {};
11
17
  for (const action of ACTIONS) {
12
- const key = value[action];
13
- if (typeof key !== "string" || (key && ((!/^(?:Ctrl\+)?(?:Meta\+)?(?:Alt\+)?(?:Shift\+)?(?:[a-z0-9]|F(?:[1-9]|1[0-2]))$/.test(key) || !/^(Ctrl|Meta|Alt)\+/.test(key)) || RESERVED.has(key) || seen.has(key)))) throw new Error("voice_shortcut_invalid");
14
- if (key) seen.add(key);
15
- result[action] = key;
18
+ const reason = voiceShortcutProblem(value[action], action, value);
19
+ if (reason) throw Object.assign(new Error("voice_shortcut_invalid"), { reason });
20
+ result[action] = value[action];
16
21
  }
17
22
  return result;
18
23
  }
24
+ export function formatVoiceShortcut(value, mac = false) {
25
+ if (!value) return "";
26
+ return value.split("+").map(key => mac ? ({ Meta: "⌘", Alt: "⌥", Ctrl: "⌃", Shift: "⇧" })[key] || key.toUpperCase() : key.length === 1 ? key.toUpperCase() : key).join(mac ? "" : "+");
27
+ }
28
+
29
+ /** One recorder per settings page; only release of the chosen main key can commit it. */
30
+ export function createVoiceShortcutRecorder({ current, save }) {
31
+ let state = { action: null, candidate: "", errors: {}, pending: {}, failed: {} }, code = null;
32
+ const listeners = new Set();
33
+ const emit = patch => { state = { ...state, ...patch }; for (const listener of listeners) listener(); };
34
+ const commit = async (action, key) => {
35
+ if (state.pending[action]) return;
36
+ emit({ action: null, candidate: "", pending: { ...state.pending, [action]: true }, errors: { ...state.errors, [action]: null } });
37
+ try { await save(action, key); emit({ failed: { ...state.failed, [action]: null } }); }
38
+ catch (error) { emit({ errors: { ...state.errors, [action]: error.reason || "save" }, failed: { ...state.failed, [action]: key } }); }
39
+ finally { emit({ pending: { ...state.pending, [action]: false } }); }
40
+ };
41
+ return {
42
+ getSnapshot: () => state,
43
+ subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); },
44
+ begin(action) { if (!state.pending[action]) { code = null; emit({ action, candidate: "", errors: { ...state.errors, [action]: null } }); } },
45
+ cancel() { code = null; emit({ action: null, candidate: "" }); },
46
+ keydown(event) {
47
+ if (!state.action) return false;
48
+ if (event.key === "Tab") { this.cancel(); return false; }
49
+ if (event.key === "Escape") { this.cancel(); return true; }
50
+ if (event.repeat || event.isComposing) return true;
51
+ const key = shortcutFromEvent(event);
52
+ if (!key) return true;
53
+ const error = voiceShortcutProblem(key, state.action, current());
54
+ code = error ? null : event.code || event.key;
55
+ emit({ candidate: error ? "" : key, errors: { ...state.errors, [state.action]: error } });
56
+ return true;
57
+ },
58
+ keyup(event) {
59
+ if (!state.action) return false;
60
+ if (event.isComposing || event.repeat) return true;
61
+ if (code && (event.code || event.key) === code && state.candidate) { const action = state.action, key = state.candidate; code = null; void commit(action, key); }
62
+ return true;
63
+ },
64
+ clear: action => commit(action, ""),
65
+ retry: action => state.failed[action] != null ? commit(action, state.failed[action]) : Promise.resolve(),
66
+ };
67
+ }
19
68
  export function matchVoiceShortcut(event, shortcuts) {
20
69
  if (event.repeat || event.isComposing || event.defaultPrevented) return null;
21
70
  const key = shortcutFromEvent(event);
@@ -0,0 +1,119 @@
1
+ import { segment } from "sentencex";
2
+ import { spokenBlocks } from "./spoken-text.js";
3
+
4
+ const LIMIT = 80, TARGET = 40;
5
+ export const LIST_WAIT_MS = 250;
6
+ const pairs = new Map([["(", ")"], ["(", ")"], ["[", "]"], ["【", "】"], ["{", "}"], ["“", "”"], ["‘", "’"], ["「", "」"], ["『", "』"]]);
7
+ const weight = text => [...text].reduce((n, c) => n + (/\p{Script=Han}|[\u3000-\u303f\uff00-\uffef]/u.test(c) ? 1 : 1 / 3), 0);
8
+
9
+ function boundaries(text) {
10
+ const stack = [], positions = new Set();
11
+ for (let i = 0; i < text.length; i++) {
12
+ const c = text[i];
13
+ if (c === "'" && !(/[\p{L}\p{N}]/u.test(text[i - 1] || "") && /[\p{L}\p{N}]/u.test(text[i + 1] || ""))) {
14
+ if (stack.at(-1) === "'") stack.pop(); else stack.push("'");
15
+ } else if (c === '"') { if (stack.at(-1) === '"') stack.pop(); else stack.push('"'); }
16
+ else if (pairs.has(c)) stack.push(pairs.get(c));
17
+ else if (stack.at(-1) === c) stack.pop();
18
+ if (!stack.length) positions.add(i + 1);
19
+ }
20
+ return { positions, balanced: !stack.length };
21
+ }
22
+
23
+ function sentences(text, closed) {
24
+ const { positions, balanced } = boundaries(text);
25
+ const ends = new Set();
26
+ let from = 0;
27
+ const parts = segment("en", text);
28
+ for (let i = 0; i < parts.length; i++) {
29
+ const at = text.indexOf(parts[i], from); if (at < 0) continue;
30
+ from = at + parts[i].length;
31
+ if (i < parts.length - 1 || closed) ends.add(from);
32
+ }
33
+ for (let i = 0; i < text.length; i++) {
34
+ if (/[。!?]/.test(text[i])) {
35
+ let end = i + 1;
36
+ while (/[”’」』))\]]/.test(text[end] || "") && end < text.length) end++;
37
+ ends.add(end);
38
+ }
39
+ }
40
+ if (closed) ends.add(text.length);
41
+ const out = []; let consumed = 0;
42
+ for (const end of [...ends].sort((a, b) => a - b)) {
43
+ if (!positions.has(end) && !(closed && end === text.length)) continue;
44
+ const sentence = text.slice(consumed, end).trim();
45
+ if (sentence.includes("\uFFFC")) break;
46
+ if (sentence) out.push(sentence);
47
+ consumed = end;
48
+ }
49
+ return { out, consumed, balanced };
50
+ }
51
+
52
+ function clauses(text) {
53
+ if (weight(text) <= LIMIT) return [text];
54
+ const { positions } = boundaries(text);
55
+ const out = []; let start = 0, lastSafe = 0;
56
+ for (let i = 0; i < text.length; i++) {
57
+ if (positions.has(i + 1) && /^(?:[,;:]|[,;:](?=\s))/.test(text.slice(i, i + 2))) lastSafe = i + 1;
58
+ if (lastSafe > start && weight(text.slice(start, i + 1)) >= LIMIT) {
59
+ out.push(text.slice(start, lastSafe).trim()); start = lastSafe;
60
+ } else if (lastSafe === i + 1 && weight(text.slice(start, i + 1)) >= TARGET) {
61
+ out.push(text.slice(start, i + 1).trim()); start = i + 1;
62
+ }
63
+ }
64
+ if (text.slice(start).trim()) out.push(text.slice(start).trim());
65
+ return out;
66
+ }
67
+
68
+ /** A turn owns this state. Deadlines are serviced by Host orchestration, not UI timers. */
69
+ export function createNaturalSegmenter({ now = Date.now } = {}) {
70
+ let source = "", seen = new Map(), pending = [], group = null, deadline = null, ended = false;
71
+ const listText = items => items.map(item => /[。!?!?;;]$/.test(item) ? item : `${item};`).join("");
72
+ const emitList = out => {
73
+ if (pending.length) out.push(listText(pending));
74
+ pending = []; group = null; deadline = null;
75
+ };
76
+ const update = (text, { done = false } = {}) => {
77
+ if (ended) { if (text !== source) throw new Error("voice_reply_rewritten"); return []; }
78
+ source = text;
79
+ const out = [];
80
+ if (deadline !== null && now() >= deadline) emitList(out);
81
+ const blocks = spokenBlocks(source, { done });
82
+ const present = new Set(blocks.map(block => block.id));
83
+ for (const id of seen.keys()) if (!present.has(id)) throw new Error("voice_reply_rewritten");
84
+ for (const block of blocks) {
85
+ const previous = seen.get(block.id) || "";
86
+ if (!block.text.startsWith(previous)) throw new Error("voice_reply_rewritten");
87
+ const text = block.text.slice(previous.length);
88
+ if (!text) continue;
89
+ if (block.kind === "list") {
90
+ if (pending.length && group !== block.group) emitList(out);
91
+ if (!block.closed || !done && !boundaries(text).balanced || text.includes("\uFFFC")) break;
92
+ if (weight(listText([text])) > LIMIT) {
93
+ emitList(out); out.push(...sentences(text, true).out.flatMap(clauses));
94
+ seen.set(block.id, block.text); continue;
95
+ }
96
+ if (pending.length && weight(listText([...pending, text])) > LIMIT) emitList(out);
97
+ if (!pending.length) { group = block.group; deadline = now() + LIST_WAIT_MS; }
98
+ pending.push(text); seen.set(block.id, block.text);
99
+ if (pending.length >= 3 || weight(listText(pending)) >= LIMIT) emitList(out);
100
+ } else {
101
+ emitList(out);
102
+ const ready = sentences(text, block.closed && (done || boundaries(text).balanced));
103
+ for (const sentence of ready.out) out.push(...clauses(sentence));
104
+ if (ready.consumed) seen.set(block.id, (previous + text.slice(0, ready.consumed)).trimEnd());
105
+ if (ready.consumed < text.length) break;
106
+ }
107
+ }
108
+ if (done) { emitList(out); ended = true; }
109
+ return out;
110
+ };
111
+ return {
112
+ update,
113
+ push(chunk, options) { return update(source + chunk, options); },
114
+ finish() { return update(source, { done: true }); },
115
+ flush() { return update(source); },
116
+ get deadline() { return deadline; },
117
+ reset() { source = ""; seen = new Map(); pending = []; group = null; deadline = null; ended = false; },
118
+ };
119
+ }
package/voice/speech.js CHANGED
@@ -1,3 +1,14 @@
1
+ let readout = null;
2
+ const readoutListeners = new Set();
3
+ export const readoutState = {
4
+ getSnapshot: () => readout,
5
+ subscribe(listener) { readoutListeners.add(listener); return () => readoutListeners.delete(listener); },
6
+ publish(value) {
7
+ readout = value ? { ...(readout?.key === value.key ? readout : {}), ...value } : null;
8
+ for (const listener of readoutListeners) listener();
9
+ },
10
+ };
11
+
1
12
  /**
2
13
  * The utterance a turn may speak: the text of the step being written, read
3
14
  * while it streams. Only a step that ends without a tool call answers the
@@ -67,15 +78,22 @@ function withBlock(state, index, text) {
67
78
  export function reduceUtterance(state, event) {
68
79
  const type = event?.type;
69
80
  if (type === "turn/start") return null;
70
- if (type === "turn/end") return state && !state.done ? { ...state, done: true } : state;
81
+ if (type === "turn/end") {
82
+ if (state && state.turn !== turnOf(event)) return state;
83
+ const failed = ["error", "cancelled"].includes(event.data?.reason?.kind);
84
+ return state ? { ...state, done: true, silent: state.silent || failed } : state;
85
+ }
71
86
  const turn = turnOf(event);
72
87
  if (turn === null) return state;
73
88
  const step = stepOf(event);
74
- const same = state?.turn === turn && state.step === step;
89
+ if (state && (turn < state.turn || turn === state.turn && step < state.step)) return state;
90
+ const same = state?.turn === turn && state.step === step && (!event.data?.attemptId || state.attemptId === event.data.attemptId);
75
91
  if (isChunkEvent(event)) {
76
92
  if (same && state.done) return state;
77
93
  const chunk = event.data?.chunk;
78
- const current = same ? state : stepUtterance(turn, step);
94
+ let current = same ? state : { ...stepUtterance(turn, step), attemptId: event.data?.attemptId };
95
+ if (Number.isInteger(event.data?.chunkIndex) && event.data.chunkIndex <= (current.chunkIndex ?? -1)) return current;
96
+ if (Number.isInteger(event.data?.chunkIndex)) current = { ...current, chunkIndex: event.data.chunkIndex };
79
97
  if (opensToolCall(chunk)) return current.silent ? current : { ...current, silent: true };
80
98
  const index = Number.isInteger(chunk?.index) ? chunk.index : 0;
81
99
  if (chunk?.type === "text-delta" && typeof chunk.text === "string") {
@@ -91,6 +109,8 @@ export function reduceUtterance(state, event) {
91
109
  return {
92
110
  ...stepUtterance(turn, step),
93
111
  text: assistantText(event),
112
+ messageId: event.data?.message?.id ?? event.data?.messageId,
113
+ attemptId: same ? state.attemptId : undefined,
94
114
  done: true,
95
115
  silent: Boolean(event.data?.interrupted) || content.some((block) => block?.type === "tool-call"),
96
116
  };
@@ -165,24 +185,33 @@ const BOUNDARY = /[.!?;。!?;…\n]/;
165
185
  * fail?: (err: unknown) => void,
166
186
  * }} io
167
187
  */
168
- export function createSentencePlayer({ send, play, halt, silence, fail = () => {}, onState = () => {} }) {
188
+ export function createSentencePlayer({ send, play, halt, silence, fail = () => {}, onState = () => {}, onOutput = readoutState.publish, stream }) {
169
189
  let gen = 0;
170
190
  let current = null;
171
191
  // An utterance is read once: finishing it, failing, or being stopped all end it for good.
172
192
  let lastKey = null;
173
193
  let silencedKey = null;
194
+ const consumed = new Set();
195
+ const mutedTurns = new Set();
196
+ let lastTurn = null;
197
+ const turnKey = (value) => Number.isFinite(value.turn) ? `${value.sessionId ?? ""}:${value.turn}` : null;
198
+ const mark = (set, key) => { if (key != null) set.add(key); };
174
199
 
200
+ const emitOutput = value => {
201
+ if (value || readoutState.getSnapshot()?.key === lastKey || onOutput !== readoutState.publish) onOutput(value);
202
+ };
175
203
  const failed = (run, err) => {
176
204
  if (run.gen !== gen) return;
205
+ run.abort?.abort();
177
206
  gen += 1;
178
207
  run.wake?.();
179
208
  current = null;
180
- onState(false);
181
- if (err?.code !== "voice_cancelled") fail(err);
209
+ onState(false); emitOutput(null);
210
+ if (!["voice_cancelled", "voice_output_owned"].includes(err?.code || err?.message)) { emitOutput({ ...run.identity, status: "error", error: err?.code || err?.message || "voice_playback_failed", stop: () => emitOutput(null) }); fail(err); }
182
211
  };
183
212
 
184
213
  const handOver = (run) => {
185
- if (run.sending || run.gen !== gen) return;
214
+ if (stream || run.sending || run.gen !== gen) return;
186
215
  const due = (run.done && !run.sentDone) || BOUNDARY.test(run.text.slice(run.sentLength));
187
216
  if (!due) return;
188
217
  const { text, done } = run;
@@ -207,6 +236,11 @@ export function createSentencePlayer({ send, play, halt, silence, fail = () => {
207
236
  };
208
237
 
209
238
  const read = async (run) => {
239
+ if (stream) {
240
+ await stream({ key: run.key, text: run.text, source: run.source, signal: run.abort.signal, controls: (value) => { if (run.gen === gen) run.controls = value; }, onStatus: (status) => { if (run.gen === gen) { if (status === "playing") onState(true); emitOutput({ key: run.key, status }); } } });
241
+ if (run.gen === gen) { current = null; onState(false); emitOutput(null); }
242
+ return;
243
+ }
210
244
  let next = null;
211
245
  for (let part = 0; run.gen === gen;) {
212
246
  if (part >= run.sent) {
@@ -220,15 +254,16 @@ export function createSentencePlayer({ send, play, halt, silence, fail = () => {
220
254
  await play(sentence);
221
255
  part += 1;
222
256
  }
223
- if (run.gen === gen) { current = null; onState(false); }
257
+ if (run.gen === gen) { current = null; onState(false); emitOutput(null); }
224
258
  };
225
259
 
226
260
  const stop = () => {
227
261
  gen += 1;
262
+ current?.abort?.abort();
228
263
  current?.wake?.();
229
264
  if (current) halt();
230
265
  current = null;
231
- onState(false);
266
+ onState(false); emitOutput(null);
232
267
  };
233
268
 
234
269
  return {
@@ -243,14 +278,20 @@ export function createSentencePlayer({ send, play, halt, silence, fail = () => {
243
278
  return;
244
279
  }
245
280
  if (!utterance.text) return;
281
+ const identity = turnKey(utterance);
282
+ if (identity && mutedTurns.has(identity)) return;
283
+ // A later tool call can invalidate uncommitted assistant text.
284
+ if (stream && !utterance.done && !utterance.source) return;
246
285
  if (current?.key !== utterance.key) {
247
- if (utterance.key === lastKey) return;
248
- if (current) stop();
286
+ if (consumed.has(utterance.key)) return;
287
+ if (current) { mark(mutedTurns, lastTurn); stop(); }
249
288
  gen += 1;
250
- lastKey = utterance.key;
289
+ lastKey = utterance.key; lastTurn = identity; mark(consumed, lastKey);
251
290
  current = {
252
291
  gen,
292
+ abort: new AbortController(),
253
293
  key: utterance.key,
294
+ source: utterance.source,
254
295
  text: utterance.text,
255
296
  done: utterance.done,
256
297
  sent: 0,
@@ -259,8 +300,9 @@ export function createSentencePlayer({ send, play, halt, silence, fail = () => {
259
300
  sending: null,
260
301
  wake: null,
261
302
  };
303
+ current.identity = utterance;
262
304
  const run = current;
263
- onState(true);
305
+ onState(true); emitOutput({ ...utterance, status: "preparing", stop: () => { mark(mutedTurns, lastTurn); stop(); silence(); } });
264
306
  handOver(run);
265
307
  read(run).catch((err) => failed(run, err));
266
308
  return;
@@ -269,7 +311,12 @@ export function createSentencePlayer({ send, play, halt, silence, fail = () => {
269
311
  current.done = utterance.done;
270
312
  handOver(current);
271
313
  },
314
+ get key() { return current?.key; },
315
+ get canPause() { return current?.controls?.canPause === true; },
316
+ pause() { if (!current?.controls?.canPause) return false; current.controls.pause(); emitOutput({ key: current.key, status: "confirming" }); return true; },
317
+ resume() { if (!current?.controls?.canPause) return false; current.controls.resume(); emitOutput({ key: current.key, status: "playing" }); return true; },
272
318
  stop() {
319
+ mark(mutedTurns, lastTurn);
273
320
  stop();
274
321
  silence();
275
322
  },
@@ -0,0 +1,196 @@
1
+ import emojiRegex from "emoji-regex";
2
+ import MarkdownIt from "markdown-it";
3
+
4
+ const markdown = new MarkdownIt({ html: false, linkify: false, typographer: false });
5
+ const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
6
+
7
+ markdown.inline.ruler.before("backticks", "speech_open_code", (state, silent) => {
8
+ if (!silent && state.env.streaming && state.src[state.pos] === "`") {
9
+ const marks = state.src.slice(state.pos).match(/^`+/)[0];
10
+ const rest = state.src.slice(state.pos + marks.length);
11
+ if (![...rest.matchAll(/`+/g)].some((match) => match[0].length === marks.length)) {
12
+ state.env.pending = Math.min(state.env.pending, state.pos);
13
+ }
14
+ }
15
+ return false;
16
+ });
17
+
18
+ // Retain parser positions only for this invocation; no state survives a turn.
19
+ markdown.inline.ruler.before("strikethrough", "speech_delimiters", (state, silent) => {
20
+ if (silent || !state.env.streaming || !/[*_~]/.test(state.src[state.pos])) return false;
21
+ const positions = state.env.states.get(state) ?? new Map();
22
+ state.env.states.set(state, positions);
23
+ const scanned = state.scanDelims(state.pos, state.src[state.pos] !== "_");
24
+ const first = state.tokens.length + (state.pending ? 1 : 0);
25
+ for (let index = 0; index < scanned.length; index++) positions.set(first + index, state.pos + index);
26
+ return false;
27
+ });
28
+ markdown.inline.ruler2.before("fragments_join", "speech_unclosed_pairs", (state) => {
29
+ if (!state.env.streaming) return;
30
+ const positions = state.env.states.get(state);
31
+ for (const delimiter of state.delimiters) {
32
+ if (delimiter.open && delimiter.end < 0 && state.tokens[delimiter.token]?.type === "text") {
33
+ state.env.pending = Math.min(state.env.pending, positions?.get(delimiter.token) ?? Infinity);
34
+ }
35
+ }
36
+ });
37
+ markdown.inline.ruler.push("speech_incomplete", (state, silent) => {
38
+ if (!silent && state.env.streaming && /[\[!`<&\\]/.test(state.src[state.pos])) {
39
+ state.env.pending = Math.min(state.env.pending, state.pos);
40
+ }
41
+ return false;
42
+ });
43
+
44
+ function tokensText(tokens) {
45
+ return tokens.map((token) => {
46
+ if (token.type === "image") return tokensText(token.children ?? []);
47
+ if (token.type === "softbreak" || token.type === "hardbreak") return "\n";
48
+ return token.type === "text" || token.type === "code_inline" ? token.content : "";
49
+ }).join("");
50
+ }
51
+
52
+ function inlineText(source, streaming, references) {
53
+ const env = { streaming, references, pending: Infinity, states: new Map() };
54
+ let tokens = markdown.parseInline(source, env)[0].children;
55
+ if (Number.isFinite(env.pending)) tokens = markdown.parseInline(source.slice(0, env.pending), { references })[0].children;
56
+ const last = tokens.findLast((token) => token.type === "image" || token.content?.trim());
57
+ return { text: tokensText(tokens), pending: Number.isFinite(env.pending) || last?.type === "image" && !tokensText(last.children ?? []).trim() };
58
+ }
59
+
60
+ const RULE_LINE = /^\s*(?:\|?\s*:?-{3,}:?\s*)+\|?\s*$|^\s*([-*_])(?:\s*\1){2,}\s*$/;
61
+ const BLOCK_MARK = /^\s*(?:>(?:[ \t]+|$))*(?:#{1,6}\s+|[-*+]\s+(?:\[[ xX]\]\s+)?|\d{1,9}[.)]\s+)?/;
62
+
63
+ function blockLine(line, table = false) {
64
+ if (RULE_LINE.test(line)) return "";
65
+ let content = line.replace(BLOCK_MARK, "");
66
+ if (/^\s*(?:>\s*)*#{1,6}\s+/.test(line)) content = content.replace(/[ \t]+#+[ \t]*$/, "");
67
+ if (!table && !/^\s*\|.*\|\s*$/.test(content)) return content;
68
+ // Only table framing is removed; inline code and escaped pipes are content.
69
+ return content.trim().replace(/^\||(?<!\\)\|$/g, "").replace(/\\.|(`+)[\s\S]*?\1|\|/g, (match) => match === "|" ? " " : match);
70
+ }
71
+
72
+ function pipeAt(line) {
73
+ return [...line.matchAll(/\\.|(`+)[\s\S]*?\1|\|/g)].find((match) => match[0] === "|")?.index;
74
+ }
75
+
76
+ function blockText(source, done) {
77
+ const rows = new Set(), visible = new Set(), env = {};
78
+ for (const token of markdown.parse(source, env)) {
79
+ if (token.type === "tr_open" && token.map) rows.add(token.map[0]);
80
+ if (["inline", "code_block", "tr_open"].includes(token.type) && token.map) {
81
+ for (let line = token.map[0]; line < token.map[1]; line++) visible.add(line);
82
+ }
83
+ }
84
+ if (!done) {
85
+ const complete = {};
86
+ markdown.parse(source.slice(0, source.lastIndexOf("\n") + 1), complete);
87
+ env.references = complete.references;
88
+ }
89
+ return {
90
+ text: source.split("\n").map((line, index) => visible.has(index) ? blockLine(line, rows.has(index)) : "").join("\n"),
91
+ references: env.references,
92
+ };
93
+ }
94
+
95
+ function withoutEmoji(text) {
96
+ return text.replace(emojiRegex(), (match) => {
97
+ if (/^[0-9#*]\uFE0F?\u20E3$/.test(match)) return match[0];
98
+ if (/^[©®™]$/.test(match)) return match;
99
+ return " ";
100
+ });
101
+ }
102
+
103
+ /** The assistant's spoken copy; never apply this to stored messages or raw TTS API input. */
104
+ function prepareSource(text, done, structural = false) {
105
+ let pending = false;
106
+ const skip = (match, offset, source) => {
107
+ if (offset + match.length >= source.trimEnd().length) pending = true;
108
+ return structural ? "\n\n---\n\n" : " ";
109
+ };
110
+ let source = String(text ?? "")
111
+ .replace(/<think\b[^>]*>[\s\S]*?<\/think>/gi, skip)
112
+ .replace(/^[\s\S]*?<\/think>/i, skip)
113
+ .replace(/<think\b[\s\S]*$/i, () => { pending = true; return ""; })
114
+ .replace(/(`{3,}|~{3,})[\s\S]*?\1/g, (match, _marks, offset, source) => skip(match, offset, source));
115
+ const openFence = /`{3,}|~{3,}/.exec(source);
116
+ if (openFence) { pending = true; source = source.slice(0, openFence.index); }
117
+ if (!done) {
118
+ const lines = source.split("\n");
119
+ let candidate = lines.length - 1;
120
+ // An unfinished separator can still turn the preceding line into a table.
121
+ if (candidate > 0 && /^[\s|:-]*$/.test(lines[candidate]) && !RULE_LINE.test(lines[candidate - 1]) && pipeAt(lines[candidate - 1]) !== undefined) candidate--;
122
+ const pipe = pipeAt(lines[candidate]);
123
+ if (pipe !== undefined) {
124
+ source = [...lines.slice(0, candidate), lines[candidate].slice(0, pipe)].join("\n");
125
+ pending = true;
126
+ }
127
+ }
128
+ return { source, pending };
129
+ }
130
+
131
+ export function spokenLines(text, { done = true } = {}) {
132
+ let { source, pending } = prepareSource(text, done);
133
+ const blocks = blockText(source, done);
134
+ const inline = inlineText(blocks.text, !done, blocks.references);
135
+ // An opaque unfinished tail keeps earlier completed sentences eligible. It
136
+ // is always the final, withheld sentence and never reaches synthesis.
137
+ let plain = inline.text;
138
+ if (!done) {
139
+ const lineStart = plain.lastIndexOf("\n") + 1;
140
+ const end = plain.slice(lineStart).trimEnd();
141
+ const tail = [...graphemes.segment(end)].at(-1);
142
+ if (tail && (emojiRegex().test(tail.segment) || /[\uD800-\uDBFF]$/.test(tail.segment))) {
143
+ plain = plain.slice(0, lineStart) + end.slice(0, tail.index);
144
+ pending = true;
145
+ }
146
+ if (pending || inline.pending) plain += "\uFFFC";
147
+ }
148
+ return withoutEmoji(plain).split("\n").map((line) => line.replace(/\s+/g, " ").trim());
149
+ }
150
+
151
+
152
+ /** Structural units for speech aggregation; rendered/stored Markdown is untouched. */
153
+ export function spokenBlocks(text, { done = true } = {}) {
154
+ const { source, pending } = prepareSource(text, done, true);
155
+ const env = {}, tokens = markdown.parse(source, env);
156
+ const references = blockText(source, done).references;
157
+ const last = tokens.findLastIndex(token => ["inline", "code_block"].includes(token.type));
158
+ const lists = [], items = [], result = [];
159
+ let heading = false, barrier = 0, rowLine = 0;
160
+ const paragraphs = new Set(), tableRows = new Set();
161
+ for (const [index, token] of tokens.entries()) {
162
+ if (token.type === "tr_open") { rowLine = token.map[0]; }
163
+ if (token.type === "hr" || token.type === "fence") barrier++;
164
+ if (token.type.endsWith("list_open")) lists.push(`${token.map[0]}:${token.level}`);
165
+ else if (token.type.endsWith("list_close")) lists.pop();
166
+ else if (token.type === "list_item_open") items.push(token.map[0]);
167
+ else if (token.type === "list_item_close") items.pop();
168
+ else if (token.type === "heading_open") heading = true;
169
+ else if (token.type === "heading_close") heading = false;
170
+ else if (["inline", "code_block"].includes(token.type)) {
171
+ if (!token.map && tableRows.has(rowLine)) continue;
172
+ if (!token.map) tableRows.add(rowLine);
173
+ const map = token.map || [rowLine, rowLine + 1];
174
+ const id = token.map ? `${map[0]}:${token.type}` : `${map[0]}:table`;
175
+ const nextBlock = tokens.slice(index + 1).some(next => next.map && next.map[0] >= map[1]);
176
+ const closed = done || nextBlock || (token.map ? index < last || map[1] < source.split("\n").length - 1 : map[1] <= source.split("\n").length - 1);
177
+ const content = token.type === "inline" && token.map
178
+ ? source.split("\n").slice(map[0], map[1]).map(line => blockLine(line)).join("\n")
179
+ : token.map ? token.content : blockLine(source.split("\n")[rowLine], true);
180
+ const inline = inlineText(content, !done, references);
181
+ let plain = inline.text, opaque = inline.pending || index === last && pending && map[1] >= source.split("\n").length;
182
+ if (!done && !closed) {
183
+ const tail = [...graphemes.segment(plain.trimEnd())].at(-1);
184
+ if (tail && (emojiRegex().test(tail.segment) || /[\uD800-\uDBFF]$/.test(tail.segment))) { plain = plain.slice(0, tail.index); opaque = true; }
185
+ }
186
+ if (!done && opaque) plain += "\uFFFC";
187
+ plain = withoutEmoji(plain).replace(/\s+/g, " ").trim();
188
+ const continuation = lists.length && paragraphs.has(items.at(-1));
189
+ if (lists.length) paragraphs.add(items.at(-1));
190
+ if (plain) result.push({ id, text: plain, closed,
191
+ kind: lists.length && !continuation ? "list" : heading ? "heading" : "paragraph",
192
+ group: `${lists.at(-1)}:${barrier}`, item: items.at(-1), depth: lists.length });
193
+ }
194
+ }
195
+ return result;
196
+ }
@@ -0,0 +1,9 @@
1
+ /** Startup timing contains only a stage and duration, never content or credentials. */
2
+ export function recordVoiceStartup(stage, durationMs) {
3
+ const fields = { stage, durationMs: Math.max(0, durationMs) };
4
+ try {
5
+ const clock = globalThis.performance;
6
+ clock?.measure?.(`lares.voice.startup.${stage}`, { start: Math.max(0, clock.now() - fields.durationMs), duration: fields.durationMs, detail: fields });
7
+ globalThis.console?.debug?.("[lares.voice.startup]", fields);
8
+ } catch { /* Observability cannot block recording. */ }
9
+ }