@runtypelabs/persona 4.26.1 → 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 (61) hide show
  1. package/dist/approval-ui.d.cts +18 -0
  2. package/dist/approval-ui.d.ts +18 -0
  3. package/dist/artifacts-ui.d.cts +18 -0
  4. package/dist/artifacts-ui.d.ts +18 -0
  5. package/dist/chunk-AALCT54S.js +1 -0
  6. package/dist/{client-history-GOZWBDA5.js → client-history-LV3KHEFA.js} +1 -1
  7. package/dist/client-history.js +1 -1
  8. package/dist/client-stream-N22JHTLE.js +4 -0
  9. package/dist/client-stream.js +3 -3
  10. package/dist/codegen.cjs +1 -1
  11. package/dist/codegen.js +1 -1
  12. package/dist/context-mentions.d.cts +18 -0
  13. package/dist/context-mentions.d.ts +18 -0
  14. package/dist/event-stream-view.d.cts +18 -0
  15. package/dist/event-stream-view.d.ts +18 -0
  16. package/dist/forms-ui.d.cts +26 -0
  17. package/dist/forms-ui.d.ts +26 -0
  18. package/dist/index.cjs +57 -57
  19. package/dist/index.cjs.map +1 -1
  20. package/dist/index.d.cts +34 -0
  21. package/dist/index.d.ts +34 -0
  22. package/dist/index.global.js +1 -1
  23. package/dist/index.global.js.map +1 -1
  24. package/dist/index.js +58 -58
  25. package/dist/index.js.map +1 -1
  26. package/dist/session-reconnect.d.cts +18 -0
  27. package/dist/session-reconnect.d.ts +18 -0
  28. package/dist/smart-dom-reader.d.cts +18 -0
  29. package/dist/smart-dom-reader.d.ts +18 -0
  30. package/dist/theme-editor-preview.cjs +66 -66
  31. package/dist/theme-editor-preview.d.cts +18 -0
  32. package/dist/theme-editor-preview.d.ts +18 -0
  33. package/dist/theme-editor-preview.js +31 -31
  34. package/dist/theme-editor.d.cts +18 -0
  35. package/dist/theme-editor.d.ts +18 -0
  36. package/dist/voice-runtime-LVUMPUDS.js +17 -0
  37. package/dist/voice-runtime.cjs +7 -7
  38. package/dist/voice-runtime.d.cts +33 -0
  39. package/dist/voice-runtime.d.ts +33 -0
  40. package/dist/voice-runtime.js +7 -7
  41. package/package.json +1 -1
  42. package/src/client-stream.ts +10 -3
  43. package/src/client.interrupt.test.ts +18 -0
  44. package/src/client.test.ts +20 -0
  45. package/src/client.ts +7 -0
  46. package/src/session.ts +16 -3
  47. package/src/session.voice.test.ts +199 -3
  48. package/src/types.ts +24 -0
  49. package/src/ui.composer-defer-one.test.ts +17 -0
  50. package/src/ui.ts +49 -12
  51. package/src/voice/__fixtures__/voice-wire/server/session_end-provider-error.json +6 -0
  52. package/src/voice/__fixtures__/voice-wire/server/session_end-unknown-reason.json +4 -0
  53. package/src/voice/__fixtures__/voice-wire/server/session_end.json +5 -0
  54. package/src/voice/runtype-voice-provider.ts +205 -21
  55. package/src/voice/voice-clause-buffer.test.ts +143 -0
  56. package/src/voice/voice-clause-buffer.ts +187 -0
  57. package/src/voice/voice-delegation.ts +111 -13
  58. package/src/voice/voice.test.ts +433 -2
  59. package/dist/chunk-GKPCIQPB.js +0 -1
  60. package/dist/client-stream-42FOI3EC.js +0 -4
  61. package/dist/voice-runtime-XLU6YK2N.js +0 -17
@@ -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 {
@@ -164,16 +185,32 @@ export function createVoiceSessionBridge(host: VoiceDelegationHost): VoiceSessio
164
185
  };
165
186
 
166
187
  return {
167
- getHistory: () =>
168
- host.messages().flatMap((m) =>
169
- (m.role === "user" || m.role === "assistant") &&
170
- !m.variant &&
171
- !m.voiceProcessing &&
172
- !m.voiceCaption &&
173
- m.content
174
- ? [{ role: m.role, content: m.content }]
175
- : [],
176
- ),
188
+ // Read once at call start, before this call has any captions, so the
189
+ // captions here are earlier calls' (e.g. on the previous page of a
190
+ // multi-page site): spoken conversation the new voice session never heard.
191
+ // An assistant caption followed by a chat answer before the next user turn
192
+ // is a delegated turn's spoken filler ("Let me check."): it would only use
193
+ // up a slot of the context's recent-message window.
194
+ getHistory: () => {
195
+ const settled = host.messages().filter(
196
+ (m) =>
197
+ (m.role === "user" || m.role === "assistant") &&
198
+ !m.variant &&
199
+ !m.voiceProcessing &&
200
+ !m.streaming &&
201
+ m.content,
202
+ );
203
+ let answered = false;
204
+ const kept: Array<{ role: "user" | "assistant"; content: string }> = [];
205
+ for (let i = settled.length - 1; i >= 0; i -= 1) {
206
+ const m = settled[i];
207
+ if (m.role === "user") answered = false;
208
+ else if (!m.voiceCaption) answered = true;
209
+ else if (answered) continue;
210
+ kept.unshift({ role: m.role as "user" | "assistant", content: m.content });
211
+ }
212
+ return kept;
213
+ },
177
214
 
178
215
  dropDelegation(delegationId, expired) {
179
216
  // Remembered for a turn still running: when it parks, the same rule applies.
@@ -183,6 +220,7 @@ export function createVoiceSessionBridge(host: VoiceDelegationHost): VoiceSessio
183
220
  const park = expired && parks.find((p) => p.delegationId === delegationId);
184
221
  if (park && !park.outcome) settle(park, "timeout", "");
185
222
  for (const capture of captures.get(delegationId) ?? []) {
223
+ capture.stopStream?.();
186
224
  capture.dropped = true;
187
225
  if (capture.ids.length) host.unspoken(capture.ids);
188
226
  }
@@ -192,6 +230,7 @@ export function createVoiceSessionBridge(host: VoiceDelegationHost): VoiceSessio
192
230
  delegationId,
193
231
  userText,
194
232
  userUtteranceIds,
233
+ stream,
195
234
  }: VoiceDelegationRequest): Promise<VoiceDelegationResult> {
196
235
  const live = parks.filter((park) => pendingOf(park).length);
197
236
  const pendingIds = live.flatMap(pendingOf);
@@ -212,6 +251,48 @@ export function createVoiceSessionBridge(host: VoiceDelegationHost): VoiceSessio
212
251
  const before = new Set(host.messages().map((m) => m.id));
213
252
  const raised = () => host.messages().filter((m) => !before.has(m.id) && m.variant === "approval");
214
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
+ }
215
296
  remember(delegationId, capture);
216
297
  active = capture;
217
298
  retrack();
@@ -225,6 +306,19 @@ export function createVoiceSessionBridge(host: VoiceDelegationHost): VoiceSessio
225
306
  active = null;
226
307
  retrack();
227
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
+ };
228
322
  const replies = capture.ids.flatMap((id) => host.messages().find((m) => m.id === id) ?? []);
229
323
  let text = answerOf(capture.ids);
230
324
  const pending = capture.failed ? [] : raised().filter(isPendingApproval);
@@ -256,10 +350,13 @@ export function createVoiceSessionBridge(host: VoiceDelegationHost): VoiceSessio
256
350
  if (cancelled.get(delegationId)) settle(park, "timeout", "");
257
351
  // Parked on approvals: answer now, so the voice model asks for the
258
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.
259
355
  text = `${text}\n\n${buildApprovalScript(pending.map((m) => m.approval!))}`.trim();
260
356
  return {
261
357
  status: "pending_approval",
262
358
  text,
359
+ ...streamed(text),
263
360
  followUp: async ({ signal, approvalTimeoutMs = APPROVAL_TTL_MS, readBack = true, onUpdate }) => {
264
361
  const follow: VoiceDelegationCapture = { ids: [], failed: false };
265
362
  // Called once the ask went out: it carried this turn's reply to the
@@ -356,9 +453,10 @@ export function createVoiceSessionBridge(host: VoiceDelegationHost): VoiceSessio
356
453
  !m.toolCall?.name?.startsWith("webmcp:"),
357
454
  )
358
455
  ) {
359
- 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) };
360
458
  }
361
- return { status: !capture.failed && text ? "completed" : "failed", text };
459
+ return { status: !capture.failed && text ? "completed" : "failed", text, ...streamed(text) };
362
460
  },
363
461
  };
364
462
  }