@runtypelabs/persona 4.27.0 → 4.28.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -0
- package/dist/{approval-ui-QAKWNFNM.js → approval-ui-OBBI62ES.js} +1 -1
- package/dist/approval-ui.cjs +2 -2
- package/dist/approval-ui.d.cts +20 -5
- package/dist/approval-ui.d.ts +20 -5
- package/dist/approval-ui.js +2 -2
- package/dist/artifacts-ui.d.cts +20 -5
- package/dist/artifacts-ui.d.ts +20 -5
- package/dist/{chunk-NQLDCJAP.js → chunk-D4TDKCT5.js} +2 -2
- package/dist/chunk-FNXK3VTC.js +1 -0
- package/dist/{client-history-HTRKHBAF.js → client-history-QGAFMCBE.js} +1 -1
- package/dist/client-history.js +1 -1
- package/dist/{client-stream-N22JHTLE.js → client-stream-WXD2FL23.js} +1 -1
- package/dist/client-stream.js +3 -3
- package/dist/codegen.cjs +4 -4
- package/dist/codegen.js +5 -5
- package/dist/context-mentions.d.cts +20 -5
- package/dist/context-mentions.d.ts +20 -5
- package/dist/event-stream-view.d.cts +20 -5
- package/dist/event-stream-view.d.ts +20 -5
- package/dist/forms-ui.d.cts +26 -5
- package/dist/forms-ui.d.ts +26 -5
- package/dist/index.cjs +41 -41
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +59 -30
- package/dist/index.d.ts +59 -30
- package/dist/index.global.js +1 -1
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +41 -41
- package/dist/index.js.map +1 -1
- package/dist/session-reconnect.d.cts +20 -5
- package/dist/session-reconnect.d.ts +20 -5
- package/dist/smart-dom-reader.d.cts +20 -5
- package/dist/smart-dom-reader.d.ts +20 -5
- package/dist/theme-editor-preview.cjs +63 -63
- package/dist/theme-editor-preview.d.cts +20 -5
- package/dist/theme-editor-preview.d.ts +20 -5
- package/dist/theme-editor-preview.js +6 -6
- package/dist/theme-editor.d.cts +21 -6
- package/dist/theme-editor.d.ts +21 -6
- package/dist/voice-runtime-TWFJ4UUS.js +17 -0
- package/dist/voice-runtime.cjs +8 -8
- package/dist/voice-runtime.d.cts +31 -5
- package/dist/voice-runtime.d.ts +31 -5
- package/dist/voice-runtime.js +8 -8
- package/dist/webmcp-runtime-5Q3IHAHP.js +6 -0
- package/dist/webmcp-runtime.cjs +2 -2
- package/dist/webmcp-runtime.d.cts +22 -26
- package/dist/webmcp-runtime.d.ts +22 -26
- package/dist/webmcp-runtime.js +2 -2
- package/package.json +5 -2
- package/src/client.interrupt.test.ts +18 -0
- package/src/client.ts +7 -0
- package/src/generated/runtype-openapi-contract.ts +3 -0
- package/src/session.ts +11 -3
- package/src/session.voice.test.ts +140 -0
- package/src/theme-editor/webmcp/types.ts +1 -1
- package/src/types.ts +26 -5
- package/src/voice/runtype-voice-provider.ts +93 -11
- package/src/voice/voice-clause-buffer.test.ts +143 -0
- package/src/voice/voice-clause-buffer.ts +187 -0
- package/src/voice/voice-delegation.ts +85 -3
- package/src/voice/voice.test.ts +257 -2
- package/src/webmcp-bridge.test.ts +87 -21
- package/src/webmcp-bridge.ts +21 -26
- package/src/webmcp-polyfill.integration.test.ts +339 -0
- package/src/webmcp-polyfill.ts +7 -4
- package/src/webmcp-runtime-bundle.test.ts +1 -1
- package/src/webmcp-runtime-entry.ts +34 -19
- package/dist/chunk-3255TNY7.js +0 -1
- package/dist/voice-runtime-RHRX4F3T.js +0 -17
- package/dist/webmcp-runtime-V6ZWW3PI.js +0 -6
|
@@ -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 = {
|
|
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
|
-
|
|
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
|
}
|