@runtypelabs/persona 4.27.0 → 4.28.0

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 (51) hide show
  1. package/dist/approval-ui.d.cts +13 -0
  2. package/dist/approval-ui.d.ts +13 -0
  3. package/dist/artifacts-ui.d.cts +13 -0
  4. package/dist/artifacts-ui.d.ts +13 -0
  5. package/dist/chunk-AALCT54S.js +1 -0
  6. package/dist/{client-history-HTRKHBAF.js → client-history-LV3KHEFA.js} +1 -1
  7. package/dist/client-history.js +1 -1
  8. package/dist/codegen.cjs +1 -1
  9. package/dist/codegen.js +1 -1
  10. package/dist/context-mentions.d.cts +13 -0
  11. package/dist/context-mentions.d.ts +13 -0
  12. package/dist/event-stream-view.d.cts +13 -0
  13. package/dist/event-stream-view.d.ts +13 -0
  14. package/dist/forms-ui.d.cts +19 -0
  15. package/dist/forms-ui.d.ts +19 -0
  16. package/dist/index.cjs +33 -33
  17. package/dist/index.cjs.map +1 -1
  18. package/dist/index.d.cts +27 -0
  19. package/dist/index.d.ts +27 -0
  20. package/dist/index.global.js +1 -1
  21. package/dist/index.global.js.map +1 -1
  22. package/dist/index.js +27 -27
  23. package/dist/index.js.map +1 -1
  24. package/dist/session-reconnect.d.cts +13 -0
  25. package/dist/session-reconnect.d.ts +13 -0
  26. package/dist/smart-dom-reader.d.cts +13 -0
  27. package/dist/smart-dom-reader.d.ts +13 -0
  28. package/dist/theme-editor-preview.cjs +63 -63
  29. package/dist/theme-editor-preview.d.cts +13 -0
  30. package/dist/theme-editor-preview.d.ts +13 -0
  31. package/dist/theme-editor-preview.js +5 -5
  32. package/dist/theme-editor.d.cts +13 -0
  33. package/dist/theme-editor.d.ts +13 -0
  34. package/dist/voice-runtime-LVUMPUDS.js +17 -0
  35. package/dist/voice-runtime.cjs +8 -8
  36. package/dist/voice-runtime.d.cts +24 -0
  37. package/dist/voice-runtime.d.ts +24 -0
  38. package/dist/voice-runtime.js +8 -8
  39. package/package.json +1 -1
  40. package/src/client.interrupt.test.ts +18 -0
  41. package/src/client.ts +7 -0
  42. package/src/session.ts +11 -3
  43. package/src/session.voice.test.ts +140 -0
  44. package/src/types.ts +19 -0
  45. package/src/voice/runtype-voice-provider.ts +93 -11
  46. package/src/voice/voice-clause-buffer.test.ts +143 -0
  47. package/src/voice/voice-clause-buffer.ts +187 -0
  48. package/src/voice/voice-delegation.ts +85 -3
  49. package/src/voice/voice.test.ts +257 -2
  50. package/dist/chunk-3255TNY7.js +0 -1
  51. package/dist/voice-runtime-RHRX4F3T.js +0 -17
@@ -64,7 +64,25 @@
64
64
  // agent as conversation; a user bubble becomes conversation only when it is
65
65
  // submitted. With `context` negotiated, one `context{text}` frame (chat history
66
66
  // as of call start plus the host's `callContext`) is held until the visitor's
67
- // first final transcript or first delegation.
67
+ // first final transcript or first delegation; a delegation's frames (deltas
68
+ // and result) wait for it (up to 2 s, then it is dropped).
69
+ //
70
+ // Streaming (`delegation_stream` and `delegation_read_back` both negotiated,
71
+ // with client delegation; the read-back tags are what keep a streamed answer
72
+ // spoken as several utterances folded): while a
73
+ // delegated turn renders, its assistant answer goes out as ordered
74
+ // `delegation_delta{delegationId, text}` pieces (clause-sized, see
75
+ // voice-clause-buffer.ts; approval cards, tool bubbles and captions never
76
+ // stream), all before the delegation's first `delegation_update` or
77
+ // `delegation_result`. That frame then carries `streamedChars`: how many
78
+ // leading characters of its `text` the deltas already sent (an approval
79
+ // script appended after the answer is the unstreamed rest). Absent or 0: none.
80
+ // The server reports each spoken chunk with `delegation_progress{delegationId,
81
+ // text}`; the first one of a spoken phase folds the read-back as
82
+ // `delegation_completed{speak:true}` does (status stays "processing" until
83
+ // completed), and that phase's completion doesn't fold another utterance.
84
+ // Delegated sends carry `voice: { spoken: true }` on the chat request, so the
85
+ // agent answers in speakable sentences.
68
86
 
69
87
  import type {
70
88
  VoiceProvider,
@@ -299,8 +317,13 @@ export class RuntypeVoiceProvider implements VoiceProvider {
299
317
  // The released context's send; settles once it went out (or never will).
300
318
  #contextSend: { done: Promise<void>; drop: () => void } | null = null;
301
319
  #delegations: Promise<void> = Promise.resolve();
302
- // Delegations answered ok: their spoken read-back is folded.
320
+ // Delegations answered ok (or that streamed part of their answer): their
321
+ // spoken read-back is folded.
303
322
  #answered = new Set<string>();
323
+ // Delegations whose current spoken phase already folded on `delegation_progress`.
324
+ #progressed = new Set<string>();
325
+ // The delegation whose read-back `#foldReadback` is waiting for.
326
+ #foldArmedBy: string | null = null;
304
327
  #foldReadback = false;
305
328
  #assistantTurns = new Set<string>();
306
329
  #foldedTurns = new Set<string>();
@@ -473,7 +496,7 @@ export class RuntypeVoiceProvider implements VoiceProvider {
473
496
  #voiceSocketUrl(host: string, agentId: string, attach?: URLSearchParams): string {
474
497
  const capabilities = ["partial_transcript", "context"];
475
498
  if (this.bridge && this.config?.clientDelegation !== false)
476
- capabilities.push("client_delegation", "delegation_update", "delegation_read_back");
499
+ capabilities.push("client_delegation", "delegation_update", "delegation_read_back", "delegation_stream");
477
500
  if (attach) capabilities.unshift("attach");
478
501
  const params = new URLSearchParams({
479
502
  voiceProtocol: VOICE_PROTOCOL,
@@ -849,6 +872,13 @@ export class RuntypeVoiceProvider implements VoiceProvider {
849
872
  const tag = msg.delegationId;
850
873
  if (typeof tag === "string" && this.#answered.has(tag) && !this.#assistantTurns.has(turnId)) {
851
874
  this.#foldedTurns.add(turnId);
875
+ this.#foldReadback = false;
876
+ } else if (this.#foldReadback && !this.#assistantTurns.has(turnId)) {
877
+ // Armed by `delegation_progress`: the server may leave the
878
+ // read-back untagged (it continued a filler it cut), so the
879
+ // first new utterance after it is the read-back.
880
+ this.#foldedTurns.add(turnId);
881
+ this.#foldReadback = false;
852
882
  }
853
883
  } else if (this.#foldReadback && !this.#assistantTurns.has(turnId)) {
854
884
  this.#foldedTurns.add(turnId);
@@ -891,8 +921,13 @@ export class RuntypeVoiceProvider implements VoiceProvider {
891
921
  } else if (msg.speak !== false && this.#answered.has(delegationId)) {
892
922
  // Every spoken phase (the approval ask, the late result) of an answer
893
923
  // the chat shows is read back. A refusal of one that never started
894
- // here has no chat answer: it renders. `final` may never come.
895
- this.#foldReadback = true;
924
+ // here has no chat answer: it renders. `final` may never come. A
925
+ // phase whose first `delegation_progress` already armed the fold
926
+ // ends here without arming it again. With `delegation_read_back`
927
+ // the tag marks the read-back, so completion arms nothing (only
928
+ // progress does, for a read-back the server left untagged).
929
+ if (!this.#progressed.delete(delegationId) && !this.#capabilities.has("delegation_read_back"))
930
+ this.#armFold(delegationId);
896
931
  }
897
932
  this.#delegating = msg.type === "delegation_started";
898
933
  if (!this.#isSpeaking && !this.#isCancelling()) {
@@ -901,6 +936,23 @@ export class RuntypeVoiceProvider implements VoiceProvider {
901
936
  break;
902
937
  }
903
938
 
939
+ case "delegation_progress": {
940
+ // The server handed a spoken chunk of this delegation to the voice
941
+ // model: its read-back is starting. The first one of a phase folds it,
942
+ // as `delegation_completed{speak:true}` does; later ones (and that
943
+ // phase's completion) don't fold another utterance. Still delegating.
944
+ const delegationId = String(msg.delegationId);
945
+ if (
946
+ this.#answered.has(delegationId) &&
947
+ !this.#progressed.has(delegationId) &&
948
+ !this.#cancelledDelegations.has(delegationId)
949
+ ) {
950
+ this.#progressed.add(delegationId);
951
+ this.#armFold(delegationId);
952
+ }
953
+ break;
954
+ }
955
+
904
956
  case "delegation_cancelled":
905
957
  // The server gave up on it: no more frames for it, and its approval
906
958
  // timers stop. The chat turn still renders. Past its deadline (or for
@@ -1021,9 +1073,19 @@ export class RuntypeVoiceProvider implements VoiceProvider {
1021
1073
  if (timedOut) send.drop();
1022
1074
  }
1023
1075
 
1076
+ /** The next new assistant utterance is this delegation's read-back: fold it. */
1077
+ #armFold(delegationId: string): void {
1078
+ this.#foldReadback = true;
1079
+ this.#foldArmedBy = delegationId;
1080
+ }
1081
+
1024
1082
  /** No more frames for this delegation; its chat answer goes back to browser TTS. */
1025
1083
  #dropDelegation(delegationId: string, expired = false): void {
1026
1084
  this.#cancelledDelegations.add(delegationId);
1085
+ // A fold it armed whose read-back never started would hide the next
1086
+ // unrelated utterance: disarm it.
1087
+ if (this.#foldArmedBy === delegationId) this.#foldReadback = false;
1088
+ this.#progressed.delete(delegationId);
1027
1089
  this.bridge?.dropDelegation?.(delegationId, expired);
1028
1090
  this.#followUps.get(delegationId)?.abort();
1029
1091
  }
@@ -1032,28 +1094,46 @@ export class RuntypeVoiceProvider implements VoiceProvider {
1032
1094
  #runDelegation(delegationId: string, request: VoiceDelegationRequest, generation: number): void {
1033
1095
  const bridge = this.bridge!;
1034
1096
  /** Send a frame for this delegation, unless the call or the delegation ended. */
1035
- const send = (type: "delegation_update" | "delegation_result", status: string, text: string) => {
1097
+ const send = ({ type, ...fields }: Record<string, unknown> & { type: string }) => {
1036
1098
  const ws = this.#ws;
1037
1099
  if (generation !== this.#callGeneration || ws?.readyState !== WebSocket.OPEN) return false;
1038
1100
  if (this.#cancelledDelegations.has(delegationId)) return false;
1039
- ws.send(JSON.stringify({ type, delegationId, status, text }));
1101
+ ws.send(JSON.stringify({ type, delegationId, ...fields }));
1040
1102
  return true;
1041
1103
  };
1104
+ const answer = (type: "delegation_update" | "delegation_result", status: string, text: string, streamedChars = 0) =>
1105
+ send({ type, status, text, ...(streamedChars > 0 && { streamedChars }) });
1042
1106
  this.#delegations = this.#delegations.then(async () => {
1043
1107
  // Cancelled while queued behind another turn: never start it.
1044
1108
  if (generation !== this.#callGeneration || this.#cancelledDelegations.has(delegationId)) return;
1109
+ // Streaming (`delegation_stream`): the answer goes out as
1110
+ // `delegation_delta` pieces while the chat renders it, after the call
1111
+ // context, and all of them before the result.
1112
+ let deltas: Promise<void> | null = null;
1113
+ // Only with read-back tags: a streamed answer can be spoken as several
1114
+ // utterances, and the tags are what keep all of them folded.
1115
+ const streaming = this.#capabilities.has("delegation_stream") && this.#capabilities.has("delegation_read_back");
1116
+ const stream = (text: string) => {
1117
+ deltas = (deltas ?? this.#awaitContextSend()).then(() => {
1118
+ // The chat shows what was streamed: its read-back is folded.
1119
+ if (send({ type: "delegation_delta", text })) this.#answered.add(delegationId);
1120
+ });
1121
+ };
1045
1122
  const result: VoiceDelegationResult = await bridge
1046
- .runDelegatedTurn(request)
1123
+ .runDelegatedTurn(streaming ? { ...request, stream } : request)
1047
1124
  .catch(() => ({ status: "failed", text: "" }));
1048
1125
  await this.#awaitContextSend();
1126
+ await deltas;
1127
+ const streamedChars = streaming ? (result.streamedChars ?? 0) : 0;
1049
1128
  // Parked on an approval: ask now (non-terminal), answer once the visitor
1050
1129
  // decides. A server that can't take an update gets the ask as the result.
1051
1130
  const parked = !!result.followUp;
1052
1131
  const update = parked && this.#capabilities.has("delegation_update");
1053
- const sent = send(
1132
+ const sent = answer(
1054
1133
  update ? "delegation_update" : "delegation_result",
1055
1134
  parked && !update ? "completed" : result.status,
1056
1135
  result.text,
1136
+ streamedChars,
1057
1137
  );
1058
1138
  if (!sent) return;
1059
1139
  if (result.status !== "failed" && result.inChat !== false) this.#answered.add(delegationId);
@@ -1068,12 +1148,12 @@ export class RuntypeVoiceProvider implements VoiceProvider {
1068
1148
  approvalTimeoutMs: this.config?.approvalTimeoutMs && Math.min(this.config.approvalTimeoutMs, APPROVAL_TIMEOUT_MAX_MS),
1069
1149
  readBack: update,
1070
1150
  // Another gated tool in the same turn: another (non-terminal) update.
1071
- onUpdate: (text) => send("delegation_update", "pending_approval", text),
1151
+ onUpdate: (text) => answer("delegation_update", "pending_approval", text),
1072
1152
  })
1073
1153
  .then((followUp) => {
1074
1154
  this.#followUps.delete(delegationId);
1075
1155
  // Hang-up and cancellation send nothing.
1076
- if (update && followUp && !abort.signal.aborted) send("delegation_result", followUp.status, followUp.text);
1156
+ if (update && followUp && !abort.signal.aborted) answer("delegation_result", followUp.status, followUp.text);
1077
1157
  });
1078
1158
  });
1079
1159
  }
@@ -1170,6 +1250,8 @@ export class RuntypeVoiceProvider implements VoiceProvider {
1170
1250
  this.#contextSend = null;
1171
1251
  this.#delegations = Promise.resolve();
1172
1252
  this.#answered.clear();
1253
+ this.#progressed.clear();
1254
+ this.#foldArmedBy = null;
1173
1255
  this.#foldReadback = false;
1174
1256
  this.#assistantTurns.clear();
1175
1257
  this.#foldedTurns.clear();
@@ -0,0 +1,143 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { CLAUSE_MAX_CHARS, ClauseBuffer } from "./voice-clause-buffer";
3
+
4
+ /** Push `chunks` one by one, then flush; returns every piece in order. */
5
+ function run(chunks: string[]): string[] {
6
+ const buffer = new ClauseBuffer();
7
+ const pieces = chunks.flatMap((chunk) => buffer.push(chunk));
8
+ return [...pieces, ...buffer.flush()];
9
+ }
10
+
11
+ /** Push character by character (the worst case for boundary detection). */
12
+ const byChar = (text: string) => run([...text]);
13
+
14
+ describe("ClauseBuffer", () => {
15
+ it("emits the first piece at a clause mark, then whole sentences", () => {
16
+ expect(byChar("Sure, we open at eight. On Sundays we are closed. Anything else?")).toEqual([
17
+ "Sure, ",
18
+ "we open at eight. ",
19
+ "On Sundays we are closed. ",
20
+ "Anything else?",
21
+ ]);
22
+ });
23
+
24
+ it("does not split later pieces at clause marks", () => {
25
+ expect(byChar("Yes. Monday, Tuesday, and Friday work; Sunday doesn't. Done")).toEqual([
26
+ "Yes. ",
27
+ "Monday, Tuesday, and Friday work; Sunday doesn't. ",
28
+ "Done",
29
+ ]);
30
+ });
31
+
32
+ it("emits the first piece after ~30 characters or 6 words without punctuation", () => {
33
+ const buffer = new ClauseBuffer();
34
+ expect(buffer.push("We bake sourdough rye ")).toEqual([]);
35
+ expect(buffer.push("and a seeded loaf every")).toEqual(["We bake sourdough rye and a seeded loaf "]);
36
+ expect(buffer.push(" morning")).toEqual([]);
37
+ expect(buffer.flush()).toEqual(["every morning"]);
38
+
39
+ const words = new ClauseBuffer();
40
+ expect(words.push("a b c d e f ")).toEqual(["a b c d e f "]);
41
+ });
42
+
43
+ it("keeps a whole first sentence together when it arrives before its trailing space", () => {
44
+ const buffer = new ClauseBuffer();
45
+ expect(buffer.push("I can place that order for you.")).toEqual([]);
46
+ expect(buffer.flush()).toEqual(["I can place that order for you."]);
47
+ expect(run(["I can place that order for you.", " Shall I?"])).toEqual(["I can place that order for you. ", "Shall I?"]);
48
+ });
49
+
50
+ it("keeps abbreviations, initials, decimals and list numbers inside a sentence", () => {
51
+ expect(
52
+ byChar("Hi there. Dr. Smith and J. R. Lee arrive at 9 a.m. today, e.g. for 3.5 hours. Then we close."),
53
+ ).toEqual(["Hi there. ", "Dr. Smith and J. R. Lee arrive at 9 a.m. today, e.g. for 3.5 hours. ", "Then we close."]);
54
+ expect(byChar("Steps:\n1. Mix the flour.\n2. Bake it.")).toEqual([
55
+ "Steps:\n",
56
+ "1. Mix the flour.\n",
57
+ "2. Bake it.",
58
+ ]);
59
+ expect(byChar("Please call 911. It is urgent.")).toEqual(["Please call 911. ", "It is urgent."]);
60
+ });
61
+
62
+ it("treats line breaks (Markdown blocks and list items) as boundaries", () => {
63
+ expect(byChar("**Opening hours**\n\n- Monday to Friday: 8am to 6pm\n- Sunday: closed")).toEqual([
64
+ "**Opening hours**\n\n",
65
+ "- Monday to Friday: 8am to 6pm\n",
66
+ "- Sunday: closed",
67
+ ]);
68
+ });
69
+
70
+ it("keeps closing quotes and emphasis with their sentence", () => {
71
+ expect(byChar('He said "go." Then **we left.** Fine')).toEqual(['He said "go." ', "Then **we left.** ", "Fine"]);
72
+ expect(byChar("Really?! Yes")).toEqual(["Really?! ", "Yes"]);
73
+ });
74
+
75
+ it("waits for whitespace after a period before cutting", () => {
76
+ const buffer = new ClauseBuffer();
77
+ expect(buffer.push("It costs 3.")).toEqual([]);
78
+ expect(buffer.push("50 dollars. And")).toEqual(["It costs 3.50 dollars. "]);
79
+ });
80
+
81
+ it("caps pieces at the max length, cutting at a clause mark, then a word, then hard", () => {
82
+ const clauses = `${"word ".repeat(30)}and then, ${"more ".repeat(30)}end.`;
83
+ const buffer = new ClauseBuffer();
84
+ expect(buffer.push("Hi. ")).toEqual([]); // the whitespace may still grow
85
+ const [hi, ...pieces] = [...buffer.push(clauses), ...buffer.flush()];
86
+ expect(hi).toBe("Hi. ");
87
+ expect(pieces.every((p) => p.length <= CLAUSE_MAX_CHARS)).toBe(true);
88
+ expect(pieces[0]).toMatch(/and then, $/);
89
+ expect(pieces.join("")).toBe(clauses);
90
+
91
+ const words = run(["Ok. ", "abcd ".repeat(100)]);
92
+ expect(words.slice(1).every((p) => p.length <= CLAUSE_MAX_CHARS && /\s$/.test(p))).toBe(true);
93
+
94
+ const solid = run(["x".repeat(500)]);
95
+ expect(solid.map((p) => p.length)).toEqual([CLAUSE_MAX_CHARS, CLAUSE_MAX_CHARS, 60]);
96
+ });
97
+
98
+ it("never cuts inside a surrogate pair", () => {
99
+ const text = `${"x".repeat(CLAUSE_MAX_CHARS - 1)}😀${"y".repeat(10)}`;
100
+ const pieces = run([text]);
101
+ expect(pieces.join("")).toBe(text);
102
+ expect(pieces[0]).toBe("x".repeat(CLAUSE_MAX_CHARS - 1));
103
+ });
104
+
105
+ it("pieces always concatenate to the input, however it is chunked", () => {
106
+ const text =
107
+ "**Opening hours**\n\n- Monday to Friday: 8am to 6pm\n- Saturday: 9am to 4pm\n\nWe're on Main St. near the U.S. post office, e.g. by the park. Call us! ";
108
+ for (const size of [1, 2, 3, 7, 13, 40, text.length]) {
109
+ const chunks: string[] = [];
110
+ for (let i = 0; i < text.length; i += size) chunks.push(text.slice(i, i + size));
111
+ const pieces = run(chunks);
112
+ const joined = pieces.join("");
113
+ expect(text.startsWith(joined)).toBe(true);
114
+ expect(joined.trimEnd()).toBe(text.trimEnd());
115
+ expect(pieces.every((p) => p.trim().length > 0 && p.length <= CLAUSE_MAX_CHARS)).toBe(true);
116
+ }
117
+ });
118
+
119
+ it("idle() releases held text that ends in a terminator, and nothing else", () => {
120
+ const buffer = new ClauseBuffer();
121
+ expect(buffer.push("We're open Monday to Friday from 8am to 6pm.")).toEqual([]);
122
+ expect(buffer.idle()).toEqual(["We're open Monday to Friday from 8am to 6pm."]);
123
+ expect(buffer.idle()).toEqual([]);
124
+ expect(buffer.push(" On Saturdays we open")).toEqual([]);
125
+ expect(buffer.idle()).toEqual([]); // no terminator: keep waiting
126
+ expect(buffer.push(" at 9 a.m.")).toEqual([]);
127
+ expect(buffer.idle()).toEqual([" On Saturdays we open at 9 a.m."]); // no guards on a pause
128
+ for (const ending of ["Really?!", 'He said "go."', "**Done.**", "Wait…", "Line\n"]) {
129
+ const b = new ClauseBuffer();
130
+ b.push(ending);
131
+ expect(b.idle()).toEqual([ending]);
132
+ }
133
+ });
134
+
135
+ it("emits nothing for whitespace only, and flush empties the buffer", () => {
136
+ const buffer = new ClauseBuffer();
137
+ expect(buffer.push(" ")).toEqual([]);
138
+ expect(buffer.flush()).toEqual([]);
139
+ expect(buffer.push("Hello")).toEqual([]);
140
+ expect(buffer.flush()).toEqual(["Hello"]);
141
+ expect(buffer.flush()).toEqual([]);
142
+ });
143
+ });
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Groups a growing answer into speakable pieces for `delegation_delta`
3
+ * (streaming client delegation): the first piece goes out early (at a clause
4
+ * mark, or once ~30 characters / 6 words are in), later pieces at sentence ends
5
+ * or line breaks, and no piece is longer than {@link CLAUSE_MAX_CHARS}.
6
+ *
7
+ * Pieces are exact slices of the input: concatenated, they equal everything
8
+ * pushed so far (whitespace included), so a receiver can count them as a
9
+ * prefix of the final text.
10
+ */
11
+
12
+ /** The longest piece. */
13
+ export const CLAUSE_MAX_CHARS = 220;
14
+ const FIRST_MIN_CHARS = 30;
15
+ const FIRST_MIN_WORDS = 6;
16
+
17
+ /** Words ending in "." that rarely end a sentence (lowercased, inner dots kept). */
18
+ const ABBREVIATIONS = new Set([
19
+ "mr",
20
+ "mrs",
21
+ "ms",
22
+ "dr",
23
+ "prof",
24
+ "sr",
25
+ "jr",
26
+ "st",
27
+ "mt",
28
+ "vs",
29
+ "etc",
30
+ "approx",
31
+ "inc",
32
+ "ltd",
33
+ "co",
34
+ "corp",
35
+ "no",
36
+ "nos",
37
+ "fig",
38
+ "ft",
39
+ "dept",
40
+ "est",
41
+ "min",
42
+ "max",
43
+ "e.g",
44
+ "i.e",
45
+ "a.m",
46
+ "p.m",
47
+ "u.s",
48
+ "u.k",
49
+ ]);
50
+
51
+ // Punctuation (then closers like quotes, brackets or Markdown emphasis) and the
52
+ // whitespace after it, or a line break.
53
+ const BOUNDARY = /([.!?]+|[,;:])(["'”’)\]*_]*)(\s+)|\n\s*/g;
54
+
55
+ /** Whether the "." ending `before` belongs to an abbreviation, an initial, or a list number. */
56
+ function dotIsNotSentenceEnd(before: string): boolean {
57
+ const token = /(\S+)$/.exec(before)?.[1];
58
+ if (!token) return false;
59
+ const word = token.replace(/^[("'“‘[*_]+/, "").toLowerCase();
60
+ if (ABBREVIATIONS.has(word)) return true;
61
+ // An initial ("J.") or a dotted abbreviation ("U.S", "e.g").
62
+ if (/^[a-z](?:\.[a-z])*$/.test(word)) return true;
63
+ // "1. " opening a line is a list marker, not a sentence.
64
+ if (/^\d+$/.test(token)) {
65
+ const start = before.length - token.length;
66
+ return start === 0 || /\n\s*$/.test(before.slice(0, start));
67
+ }
68
+ return false;
69
+ }
70
+
71
+ /**
72
+ * Ends (after the whitespace) of each boundary in `text` with content before
73
+ * it. A boundary whose whitespace reaches the end of `text` may still grow
74
+ * ("\n" then "\n- item"), so it counts only once more text follows.
75
+ */
76
+ function* boundaryEnds(text: string, clauses: boolean): Generator<number> {
77
+ const re = new RegExp(BOUNDARY.source, "g");
78
+ for (let m = re.exec(text); m; m = re.exec(text)) {
79
+ const mark = m[1];
80
+ if (!text.slice(0, m.index + (mark?.length ?? 0)).trim()) continue;
81
+ if (mark !== undefined) {
82
+ if (/^[,;:]$/.test(mark) && !clauses) continue;
83
+ if (mark === "." && dotIsNotSentenceEnd(text.slice(0, m.index))) continue;
84
+ }
85
+ const end = m.index + m[0].length;
86
+ if (end < text.length) yield end;
87
+ }
88
+ }
89
+
90
+ /** End of the last whitespace run within `limit` that has content before it, or 0. */
91
+ function lastWordEnd(text: string, limit: number): number {
92
+ const window = text.slice(0, limit);
93
+ let best = 0;
94
+ for (const m of window.matchAll(/\s+/g)) {
95
+ if (window.slice(0, m.index).trim()) best = m.index! + m[0].length;
96
+ }
97
+ return best;
98
+ }
99
+
100
+ /** Where to cut a piece that reached {@link CLAUSE_MAX_CHARS} with no sentence end. */
101
+ function overflowCut(text: string): number {
102
+ let clause = 0;
103
+ for (const end of boundaryEnds(text.slice(0, CLAUSE_MAX_CHARS), true)) clause = end;
104
+ if (clause) return clause;
105
+ const word = lastWordEnd(text, CLAUSE_MAX_CHARS);
106
+ if (word) return word;
107
+ // One unbroken run: cut hard, never inside a surrogate pair.
108
+ const code = text.charCodeAt(CLAUSE_MAX_CHARS - 1);
109
+ return code >= 0xd800 && code <= 0xdbff ? CLAUSE_MAX_CHARS - 1 : CLAUSE_MAX_CHARS;
110
+ }
111
+
112
+ export class ClauseBuffer {
113
+ #pending = "";
114
+ #first = true;
115
+
116
+ /** Add text; returns the pieces now complete (possibly none). */
117
+ push(text: string): string[] {
118
+ this.#pending += text;
119
+ return this.#drain();
120
+ }
121
+
122
+ /** Everything still held, as the last pieces (whitespace-only remainder is dropped). */
123
+ flush(): string[] {
124
+ const pieces = this.#drain();
125
+ const rest = this.#pending;
126
+ this.#pending = "";
127
+ if (rest.trim()) {
128
+ for (let at = 0; at < rest.length; ) {
129
+ const tail = rest.slice(at);
130
+ const cut = tail.length > CLAUSE_MAX_CHARS ? overflowCut(tail) : tail.length;
131
+ pieces.push(tail.slice(0, cut));
132
+ at += cut;
133
+ }
134
+ this.#first = false;
135
+ }
136
+ return pieces;
137
+ }
138
+
139
+ /**
140
+ * The stream went quiet: if the held text ends in a sentence terminator
141
+ * (`. ! ? …`, closers allowed) or a line break, release it as if the
142
+ * boundary were confirmed. No abbreviation guards: a pause is enough.
143
+ */
144
+ idle(): string[] {
145
+ const text = this.#pending;
146
+ if (!text.trim() || !/(?:[.!?…]["'”’)\]*_]*|\n)\s*$/.test(text)) return [];
147
+ this.#pending = "";
148
+ this.#first = false;
149
+ const pieces: string[] = [];
150
+ for (let at = 0; at < text.length; ) {
151
+ const tail = text.slice(at);
152
+ const cut = tail.length > CLAUSE_MAX_CHARS ? overflowCut(tail) : tail.length;
153
+ pieces.push(tail.slice(0, cut));
154
+ at += cut;
155
+ }
156
+ return pieces;
157
+ }
158
+
159
+ #drain(): string[] {
160
+ const pieces: string[] = [];
161
+ for (let cut = this.#cut(); cut > 0; cut = this.#cut()) {
162
+ pieces.push(this.#pending.slice(0, cut));
163
+ this.#pending = this.#pending.slice(cut);
164
+ this.#first = false;
165
+ }
166
+ return pieces;
167
+ }
168
+
169
+ /** Length of the next complete piece, or 0 to keep waiting. */
170
+ #cut(): number {
171
+ const text = this.#pending;
172
+ if (!text.trim()) return 0;
173
+ const end = boundaryEnds(text, this.#first).next().value ?? 0;
174
+ if (end && end <= CLAUSE_MAX_CHARS) return end;
175
+ // Ending on sentence punctuation, the text is most likely a whole sentence
176
+ // waiting for the space that confirms it: don't split it early.
177
+ const atSentenceEnd = /[.!?]["'”’)\]*_]*$/.test(text);
178
+ if (this.#first && !atSentenceEnd) {
179
+ const words = text.match(/\S+\s+/g)?.length ?? 0;
180
+ if (text.trim().length >= FIRST_MIN_CHARS || words >= FIRST_MIN_WORDS) {
181
+ const word = lastWordEnd(text, CLAUSE_MAX_CHARS);
182
+ if (word) return word;
183
+ }
184
+ }
185
+ return text.length > CLAUSE_MAX_CHARS ? overflowCut(text) : 0;
186
+ }
187
+ }
@@ -7,11 +7,32 @@ import type {
7
7
  VoiceSessionBridge,
8
8
  } from "../types";
9
9
 
10
+ import { ClauseBuffer } from "./voice-clause-buffer";
11
+
10
12
  /**
11
13
  * What a delegated turn streamed: its assistant messages, whether it failed,
12
14
  * and whether the server dropped its result (then browser TTS may read it).
15
+ * `onUpdate` runs after each of its assistant messages is stored (the answer
16
+ * grew), so a streaming bridge can read the new text.
13
17
  */
14
- export type VoiceDelegationCapture = { ids: string[]; failed: boolean; dropped?: boolean };
18
+ export type VoiceDelegationCapture = {
19
+ ids: string[];
20
+ failed: boolean;
21
+ dropped?: boolean;
22
+ onUpdate?: () => void;
23
+ /** Stops a streaming answer (its pending idle flush included); set while it streams. */
24
+ stopStream?: () => void;
25
+ };
26
+
27
+ /** How long a held sentence end waits for more text before it streams anyway. */
28
+ const STREAM_IDLE_MS = 200;
29
+
30
+ /** Length of the common prefix of `a` and `b`. */
31
+ const commonPrefix = (a: string, b: string) => {
32
+ let i = 0;
33
+ while (i < a.length && i < b.length && a[i] === b[i]) i += 1;
34
+ return i;
35
+ };
15
36
 
16
37
  /** The session internals the delegation bridge drives. */
17
38
  export interface VoiceDelegationHost {
@@ -199,6 +220,7 @@ export function createVoiceSessionBridge(host: VoiceDelegationHost): VoiceSessio
199
220
  const park = expired && parks.find((p) => p.delegationId === delegationId);
200
221
  if (park && !park.outcome) settle(park, "timeout", "");
201
222
  for (const capture of captures.get(delegationId) ?? []) {
223
+ capture.stopStream?.();
202
224
  capture.dropped = true;
203
225
  if (capture.ids.length) host.unspoken(capture.ids);
204
226
  }
@@ -208,6 +230,7 @@ export function createVoiceSessionBridge(host: VoiceDelegationHost): VoiceSessio
208
230
  delegationId,
209
231
  userText,
210
232
  userUtteranceIds,
233
+ stream,
211
234
  }: VoiceDelegationRequest): Promise<VoiceDelegationResult> {
212
235
  const live = parks.filter((park) => pendingOf(park).length);
213
236
  const pendingIds = live.flatMap(pendingOf);
@@ -228,6 +251,48 @@ export function createVoiceSessionBridge(host: VoiceDelegationHost): VoiceSessio
228
251
  const before = new Set(host.messages().map((m) => m.id));
229
252
  const raised = () => host.messages().filter((m) => !before.has(m.id) && m.variant === "approval");
230
253
  const capture: VoiceDelegationCapture = { ids: [], failed: false };
254
+ // Streaming (`delegation_stream`): the answer goes out in speakable
255
+ // pieces while it renders. `fed` is the answer text the buffer has seen,
256
+ // `sent` what went out. An answer rewritten mid-stream (not an extension
257
+ // of what was fed) stops the stream; the result then says how much of
258
+ // its text was already sent.
259
+ const buffer = stream ? new ClauseBuffer() : null;
260
+ let fed = "";
261
+ let sent = "";
262
+ let rewritten = false;
263
+ const emit = (pieces: string[]) => {
264
+ for (const piece of pieces) {
265
+ sent += piece;
266
+ stream!(piece);
267
+ }
268
+ };
269
+ // A sentence end is confirmed only by the text after it; when the
270
+ // stream pauses on one, it goes out after STREAM_IDLE_MS anyway.
271
+ let idleTimer: ReturnType<typeof setTimeout> | undefined;
272
+ if (buffer) {
273
+ capture.onUpdate = () => {
274
+ if (rewritten) return;
275
+ const now = answerOf(capture.ids);
276
+ if (!now.startsWith(fed)) {
277
+ rewritten = true;
278
+ clearTimeout(idleTimer);
279
+ return;
280
+ }
281
+ if (now.length === fed.length) return;
282
+ const delta = now.slice(fed.length);
283
+ fed = now;
284
+ emit(buffer.push(delta));
285
+ clearTimeout(idleTimer);
286
+ idleTimer = setTimeout(() => {
287
+ if (!rewritten) emit(buffer.idle());
288
+ }, STREAM_IDLE_MS);
289
+ };
290
+ capture.stopStream = () => {
291
+ clearTimeout(idleTimer);
292
+ rewritten = true; // nothing more streams
293
+ capture.onUpdate = undefined;
294
+ };
295
+ }
231
296
  remember(delegationId, capture);
232
297
  active = capture;
233
298
  retrack();
@@ -241,6 +306,19 @@ export function createVoiceSessionBridge(host: VoiceDelegationHost): VoiceSessio
241
306
  active = null;
242
307
  retrack();
243
308
  }
309
+ if (buffer) {
310
+ // The last of the answer goes out before the result.
311
+ capture.onUpdate?.();
312
+ clearTimeout(idleTimer);
313
+ if (!rewritten) emit(buffer.flush());
314
+ capture.onUpdate = undefined;
315
+ capture.stopStream = undefined;
316
+ }
317
+ /** `streamedChars` for a result whose `text` starts with the answer. */
318
+ const streamed = (text: string) => {
319
+ const chars = commonPrefix(sent, text);
320
+ return chars > 0 ? { streamedChars: chars } : {};
321
+ };
244
322
  const replies = capture.ids.flatMap((id) => host.messages().find((m) => m.id === id) ?? []);
245
323
  let text = answerOf(capture.ids);
246
324
  const pending = capture.failed ? [] : raised().filter(isPendingApproval);
@@ -272,10 +350,13 @@ export function createVoiceSessionBridge(host: VoiceDelegationHost): VoiceSessio
272
350
  if (cancelled.get(delegationId)) settle(park, "timeout", "");
273
351
  // Parked on approvals: answer now, so the voice model asks for the
274
352
  // decision, then read the outcome back once the visitor decides.
353
+ // The streamed answer stays the prefix; the approval script is the
354
+ // unstreamed suffix.
275
355
  text = `${text}\n\n${buildApprovalScript(pending.map((m) => m.approval!))}`.trim();
276
356
  return {
277
357
  status: "pending_approval",
278
358
  text,
359
+ ...streamed(text),
279
360
  followUp: async ({ signal, approvalTimeoutMs = APPROVAL_TTL_MS, readBack = true, onUpdate }) => {
280
361
  const follow: VoiceDelegationCapture = { ids: [], failed: false };
281
362
  // Called once the ask went out: it carried this turn's reply to the
@@ -372,9 +453,10 @@ export function createVoiceSessionBridge(host: VoiceDelegationHost): VoiceSessio
372
453
  !m.toolCall?.name?.startsWith("webmcp:"),
373
454
  )
374
455
  ) {
375
- return { status: capture.failed ? "failed" : "completed", text: `${text}\n\n${WAITING_FOR_INPUT}`.trim() };
456
+ text = `${text}\n\n${WAITING_FOR_INPUT}`.trim();
457
+ return { status: capture.failed ? "failed" : "completed", text, ...streamed(text) };
376
458
  }
377
- return { status: !capture.failed && text ? "completed" : "failed", text };
459
+ return { status: !capture.failed && text ? "completed" : "failed", text, ...streamed(text) };
378
460
  },
379
461
  };
380
462
  }