@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.
- package/dist/approval-ui.d.cts +13 -0
- package/dist/approval-ui.d.ts +13 -0
- package/dist/artifacts-ui.d.cts +13 -0
- package/dist/artifacts-ui.d.ts +13 -0
- package/dist/chunk-AALCT54S.js +1 -0
- package/dist/{client-history-HTRKHBAF.js → client-history-LV3KHEFA.js} +1 -1
- package/dist/client-history.js +1 -1
- package/dist/codegen.cjs +1 -1
- package/dist/codegen.js +1 -1
- package/dist/context-mentions.d.cts +13 -0
- package/dist/context-mentions.d.ts +13 -0
- package/dist/event-stream-view.d.cts +13 -0
- package/dist/event-stream-view.d.ts +13 -0
- package/dist/forms-ui.d.cts +19 -0
- package/dist/forms-ui.d.ts +19 -0
- package/dist/index.cjs +33 -33
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +27 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.global.js +1 -1
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +27 -27
- package/dist/index.js.map +1 -1
- package/dist/session-reconnect.d.cts +13 -0
- package/dist/session-reconnect.d.ts +13 -0
- package/dist/smart-dom-reader.d.cts +13 -0
- package/dist/smart-dom-reader.d.ts +13 -0
- package/dist/theme-editor-preview.cjs +63 -63
- package/dist/theme-editor-preview.d.cts +13 -0
- package/dist/theme-editor-preview.d.ts +13 -0
- package/dist/theme-editor-preview.js +5 -5
- package/dist/theme-editor.d.cts +13 -0
- package/dist/theme-editor.d.ts +13 -0
- package/dist/voice-runtime-LVUMPUDS.js +17 -0
- package/dist/voice-runtime.cjs +8 -8
- package/dist/voice-runtime.d.cts +24 -0
- package/dist/voice-runtime.d.ts +24 -0
- package/dist/voice-runtime.js +8 -8
- package/package.json +1 -1
- package/src/client.interrupt.test.ts +18 -0
- package/src/client.ts +7 -0
- package/src/session.ts +11 -3
- package/src/session.voice.test.ts +140 -0
- package/src/types.ts +19 -0
- 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/dist/chunk-3255TNY7.js +0 -1
- 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
|
|
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
|
-
|
|
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
|
|
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,
|
|
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 =
|
|
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) =>
|
|
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)
|
|
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 = {
|
|
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
|
}
|