@olares/lares-core 0.15.10 → 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.
Files changed (45) hide show
  1. package/i18n/voice.js +246 -13
  2. package/larepass/host.js +6 -0
  3. package/larepass/mux.js +4 -3
  4. package/larepass/runtime.js +5 -3
  5. package/larepass/settings.js +3 -0
  6. package/larepass/transcript.js +4 -6
  7. package/olares/trusted-host.js +11 -1
  8. package/package.json +27 -2
  9. package/router/tts.js +25 -1
  10. package/tools/http.js +27 -1
  11. package/voice/agent-session.js +133 -0
  12. package/voice/agent.js +9 -1
  13. package/voice/browser-capture.js +274 -0
  14. package/voice/browser-playback.js +30 -0
  15. package/voice/capture.js +12 -2
  16. package/voice/client.js +71 -9
  17. package/voice/composite-gateway.js +156 -0
  18. package/voice/config.js +30 -17
  19. package/voice/controller.js +147 -0
  20. package/voice/conversation.js +131 -0
  21. package/voice/device-input.js +79 -0
  22. package/voice/dialogue.js +71 -0
  23. package/voice/duplex.js +1 -1
  24. package/voice/external.js +26 -0
  25. package/voice/gpu-mode.js +460 -0
  26. package/voice/gpu-problem.js +47 -0
  27. package/voice/host/bluetooth-ready.py +2 -0
  28. package/voice/host-input.js +38 -0
  29. package/voice/host-service.js +171 -0
  30. package/voice/input.js +2 -1
  31. package/voice/install.js +33 -12
  32. package/voice/languages.js +2 -6
  33. package/voice/lease.js +16 -0
  34. package/voice/options.js +23 -0
  35. package/voice/protocol-client.js +24 -0
  36. package/voice/realtime-audio.js +107 -0
  37. package/voice/realtime-conversation.js +122 -0
  38. package/voice/realtime-gateway.js +162 -0
  39. package/voice/realtime.js +63 -0
  40. package/voice/records.js +72 -0
  41. package/voice/router-audio.js +26 -0
  42. package/voice/script.js +85 -15
  43. package/voice/service.js +340 -57
  44. package/voice/session.js +11 -1
  45. package/voice/speech.js +228 -44
package/voice/speech.js CHANGED
@@ -1,9 +1,9 @@
1
1
  /**
2
- * The utterance a turn may speak: the text of the assistant message that
3
- * concludes it. The agent loop stops stepping once a settled message carries
4
- * no tool call, so that message is the answer even while turn-stopping work
5
- * still holds the turn open. Reasoning and text said alongside a tool call
6
- * are intermediate and stay silent.
2
+ * The utterance a turn may speak: the text of the step being written, read
3
+ * while it streams. Only a step that ends without a tool call answers the
4
+ * turn, and that is not known until the step settles, so a step turns silent
5
+ * the moment a tool call shows up in it, and an interrupted step is silent
6
+ * too. Reasoning is never read.
7
7
  */
8
8
 
9
9
  function turnOf(event) {
@@ -13,6 +13,12 @@ function turnOf(event) {
13
13
  return Number.isFinite(number) ? number : null;
14
14
  }
15
15
 
16
+ function stepOf(event) {
17
+ const data = event?.data ?? {};
18
+ const number = Number(data.step ?? data.message?.step);
19
+ return Number.isFinite(number) ? number : 0;
20
+ }
21
+
16
22
  function messageContent(event) {
17
23
  const data = event?.data ?? {};
18
24
  const content = data.message?.content ?? data.content;
@@ -28,66 +34,244 @@ export function assistantText(event) {
28
34
  .trim();
29
35
  }
30
36
 
31
- /** The answer text when this event concludes its turn, otherwise "". */
32
- export function concludingReply(event) {
33
- if (event?.type !== "assistant/message" || event.data?.interrupted) return "";
34
- if (messageContent(event).some((block) => block?.type === "tool-call")) return "";
35
- return assistantText(event);
37
+ function isChunkEvent(event) {
38
+ return event?.type === "assistant/chunk" || event?.type === "assistant/live-chunk";
39
+ }
40
+
41
+ function opensToolCall(chunk) {
42
+ return chunk?.type === "tool-call-delta"
43
+ || (chunk?.type === "block-start" && chunk.blockType === "tool-call")
44
+ || (chunk?.type === "block-end" && chunk.block?.type === "tool-call");
45
+ }
46
+
47
+ function stepUtterance(turn, step) {
48
+ return { key: `${turn}:${step}`, turn, step, blocks: {}, text: "", done: false, silent: false };
49
+ }
50
+
51
+ function withBlock(state, index, text) {
52
+ const blocks = { ...state.blocks, [index]: text };
53
+ const joined = Object.keys(blocks)
54
+ .map(Number)
55
+ .sort((a, b) => a - b)
56
+ .map((at) => blocks[at].trim())
57
+ .filter(Boolean)
58
+ .join("\n");
59
+ return { ...state, blocks, text: joined };
36
60
  }
37
61
 
38
62
  /**
39
- * Which concluded answers a live view reads aloud: only those it watched
40
- * arrive while their turn was running. Opening a session, or history that is
41
- * already finished, stays silent.
63
+ * @typedef {{ key: string, turn: number, step: number, text: string, done: boolean, silent: boolean }} Utterance
64
+ * @param {Utterance | null} state
65
+ * @returns {Utterance | null}
66
+ */
67
+ export function reduceUtterance(state, event) {
68
+ const type = event?.type;
69
+ if (type === "turn/start") return null;
70
+ if (type === "turn/end") return state && !state.done ? { ...state, done: true } : state;
71
+ const turn = turnOf(event);
72
+ if (turn === null) return state;
73
+ const step = stepOf(event);
74
+ const same = state?.turn === turn && state.step === step;
75
+ if (isChunkEvent(event)) {
76
+ if (same && state.done) return state;
77
+ const chunk = event.data?.chunk;
78
+ const current = same ? state : stepUtterance(turn, step);
79
+ if (opensToolCall(chunk)) return current.silent ? current : { ...current, silent: true };
80
+ const index = Number.isInteger(chunk?.index) ? chunk.index : 0;
81
+ if (chunk?.type === "text-delta" && typeof chunk.text === "string") {
82
+ return withBlock(current, index, `${current.blocks[index] ?? ""}${chunk.text}`);
83
+ }
84
+ if (chunk?.type === "block-end" && chunk.block?.type === "text" && typeof chunk.block.text === "string") {
85
+ return withBlock(current, index, chunk.block.text);
86
+ }
87
+ return state;
88
+ }
89
+ if (type === "assistant/message") {
90
+ const content = messageContent(event);
91
+ return {
92
+ ...stepUtterance(turn, step),
93
+ text: assistantText(event),
94
+ done: true,
95
+ silent: Boolean(event.data?.interrupted) || content.some((block) => block?.type === "tool-call"),
96
+ };
97
+ }
98
+ return state;
99
+ }
100
+
101
+ export function matchSpeechEvent(event) {
102
+ const type = event?.type;
103
+ if (type !== "turn/start" && type !== "turn/end" && type !== "assistant/message" && !isChunkEvent(event)) return null;
104
+ const turn = turnOf(event);
105
+ if (turn === null) return null;
106
+ return { id: String(turn), role: type === "turn/start" ? "start" : "update" };
107
+ }
108
+
109
+ /**
110
+ * Which utterances a live view reads aloud: only those it watched being
111
+ * written while their turn was running. Opening a session mid-answer, or
112
+ * history that is already finished, stays silent.
42
113
  */
43
114
  export function createReplySpeaker() {
44
115
  let armed = false;
45
- let spokenSeq = -1;
116
+ let skipKey = null;
46
117
  return {
47
- /** @returns {string} text to speak now, or "" */
48
- observe({ sessionChanged, wasRunning, running, reply }) {
118
+ /** @returns {Utterance | null} the utterance to follow now, or null */
119
+ observe({ sessionChanged, wasRunning, running, utterance }) {
49
120
  if (sessionChanged) {
50
121
  armed = Boolean(running);
51
- spokenSeq = reply?.seq ?? -1;
52
- return "";
122
+ skipKey = utterance?.key ?? null;
123
+ return null;
53
124
  }
54
125
  if (!wasRunning && running) {
55
126
  armed = true;
56
- // A turn start resets the reply, so one already here was loaded with
57
- // history, not watched.
58
- if (reply) spokenSeq = Math.max(spokenSeq, reply.seq);
59
- }
60
- let text = "";
61
- if (armed && reply && reply.seq > spokenSeq) {
62
- spokenSeq = reply.seq;
63
- text = reply.text;
127
+ skipKey = utterance?.key ?? null;
64
128
  }
129
+ const follow = armed && utterance && utterance.key !== skipKey ? utterance : null;
65
130
  if (!running) armed = false;
66
- return text;
131
+ return follow;
67
132
  },
68
133
  };
69
134
  }
70
135
 
71
- export function emptySpeechState() {
72
- return { text: "", seq: -1 };
136
+ /**
137
+ * Which state of an utterance the reader gets. Streaming reads while the step
138
+ * is written; otherwise the step is read once it has settled.
139
+ * @param {Utterance | null} utterance
140
+ */
141
+ export function speakable(utterance, streaming) {
142
+ if (!utterance) return null;
143
+ return streaming || utterance.done || utterance.silent ? utterance : null;
73
144
  }
74
145
 
146
+ /** Text past this may have closed a sentence; the host decides whether it did. */
147
+ const BOUNDARY = /[.!?;。!?;…\n]/;
148
+
75
149
  /**
76
- * A steer that lands after the answer runs another step in the same turn, so
77
- * a turn may conclude more than once; `seq` tells the answers apart.
78
- * @param {{ text: string, seq: number }} state
150
+ * Reads one utterance at a time, sentence by sentence, while it is still being
151
+ * written. The host owns sentence boundaries (sentencex does not run in a
152
+ * page): the text is handed over whenever it may have finished a sentence, and
153
+ * the host answers how many sentences are complete. That keeps a headset
154
+ * reading on without gaps; the page fetches a sentence's audio just before it
155
+ * needs it and plays it through `play`.
156
+ *
157
+ * `halt` stops the page's own audio. `silence` also stops the host, for a step
158
+ * that turned out to call a tool while the headset was reading it.
159
+ *
160
+ * @param {{
161
+ * send: (request: { key: string, text: string, done: boolean, part?: number }) => Promise<any>,
162
+ * play: (sentence: { audio: unknown, bluetooth?: string }) => Promise<unknown>,
163
+ * halt: () => void,
164
+ * silence: () => void,
165
+ * fail?: (err: unknown) => void,
166
+ * }} io
79
167
  */
80
- export function reduceSpeechEvent(state, event) {
81
- const current = state ?? emptySpeechState();
82
- if (event?.type === "turn/start") return emptySpeechState();
83
- const text = concludingReply(event);
84
- return text ? { text, seq: Number(event.seq) } : current;
85
- }
168
+ export function createSentencePlayer({ send, play, halt, silence, fail = () => {}, onState = () => {} }) {
169
+ let gen = 0;
170
+ let current = null;
171
+ // An utterance is read once: finishing it, failing, or being stopped all end it for good.
172
+ let lastKey = null;
173
+ let silencedKey = null;
86
174
 
87
- export function matchSpeechEvent(event) {
88
- const type = event?.type;
89
- if (type !== "turn/start" && type !== "assistant/message") return null;
90
- const turn = turnOf(event);
91
- if (turn === null) return null;
92
- return { id: String(turn), role: type === "turn/start" ? "start" : "update" };
175
+ const failed = (run, err) => {
176
+ if (run.gen !== gen) return;
177
+ gen += 1;
178
+ run.wake?.();
179
+ current = null;
180
+ onState(false);
181
+ if (err?.code !== "voice_cancelled") fail(err);
182
+ };
183
+
184
+ const handOver = (run) => {
185
+ if (run.sending || run.gen !== gen) return;
186
+ const due = (run.done && !run.sentDone) || BOUNDARY.test(run.text.slice(run.sentLength));
187
+ if (!due) return;
188
+ const { text, done } = run;
189
+ run.sending = send({ key: run.key, text, done })
190
+ .then((result) => {
191
+ run.sending = null;
192
+ run.sent = Math.max(run.sent, Number(result?.sentences) || 0);
193
+ run.sentLength = text.length;
194
+ run.sentDone = done;
195
+ const wake = run.wake;
196
+ run.wake = null;
197
+ wake?.();
198
+ handOver(run);
199
+ })
200
+ .catch((err) => failed(run, err));
201
+ };
202
+
203
+ const fetchPart = (run, part) => {
204
+ const pending = send({ key: run.key, text: run.text, done: run.done, part });
205
+ pending.catch(() => {});
206
+ return pending;
207
+ };
208
+
209
+ const read = async (run) => {
210
+ let next = null;
211
+ for (let part = 0; run.gen === gen;) {
212
+ if (part >= run.sent) {
213
+ if (run.sentDone) break;
214
+ await new Promise((resolve) => { run.wake = resolve; });
215
+ continue;
216
+ }
217
+ const sentence = await (next ?? fetchPart(run, part));
218
+ next = part + 1 < run.sent ? fetchPart(run, part + 1) : null;
219
+ if (run.gen !== gen) return;
220
+ await play(sentence);
221
+ part += 1;
222
+ }
223
+ if (run.gen === gen) { current = null; onState(false); }
224
+ };
225
+
226
+ const stop = () => {
227
+ gen += 1;
228
+ current?.wake?.();
229
+ if (current) halt();
230
+ current = null;
231
+ onState(false);
232
+ };
233
+
234
+ return {
235
+ /** Feed the latest state of the utterance; null leaves the reading as it is. */
236
+ follow(utterance) {
237
+ if (!utterance) return;
238
+ if (utterance.silent) {
239
+ if (utterance.key !== lastKey || silencedKey === lastKey) return;
240
+ silencedKey = lastKey;
241
+ stop();
242
+ silence();
243
+ return;
244
+ }
245
+ if (!utterance.text) return;
246
+ if (current?.key !== utterance.key) {
247
+ if (utterance.key === lastKey) return;
248
+ if (current) stop();
249
+ gen += 1;
250
+ lastKey = utterance.key;
251
+ current = {
252
+ gen,
253
+ key: utterance.key,
254
+ text: utterance.text,
255
+ done: utterance.done,
256
+ sent: 0,
257
+ sentLength: 0,
258
+ sentDone: false,
259
+ sending: null,
260
+ wake: null,
261
+ };
262
+ const run = current;
263
+ onState(true);
264
+ handOver(run);
265
+ read(run).catch((err) => failed(run, err));
266
+ return;
267
+ }
268
+ current.text = utterance.text;
269
+ current.done = utterance.done;
270
+ handOver(current);
271
+ },
272
+ stop() {
273
+ stop();
274
+ silence();
275
+ },
276
+ };
93
277
  }