@kuralle-syrinx/core 4.2.0 → 4.4.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/package.json +2 -2
- package/src/audio/alaw.ts +56 -0
- package/src/audio/g722.ts +329 -0
- package/src/audio/index.ts +12 -0
- package/src/audio/loudness.ts +87 -0
- package/src/idle-timeout.ts +1 -0
- package/src/index.ts +65 -3
- package/src/interaction-coordinator.ts +17 -2
- package/src/interaction-policy.ts +12 -0
- package/src/observability-observer.ts +8 -4
- package/src/observability.ts +30 -1
- package/src/packet-factories.ts +124 -3
- package/src/packets.ts +139 -1
- package/src/plugin-contract.ts +41 -0
- package/src/policies/rule-based.ts +17 -2
- package/src/pricing.ts +137 -0
- package/src/primary-speaker-gate.ts +23 -3
- package/src/reasoner.ts +28 -1
- package/src/spend-cap.ts +52 -0
- package/src/turn-arbiter.ts +34 -2
- package/src/voice-agent-session-util.ts +18 -0
- package/src/voice-agent-session.ts +469 -36
- package/src/voice-text.ts +69 -0
- package/src/audio/audio.test.ts +0 -285
- package/src/audio-envelope.test.ts +0 -167
- package/src/confidence-to-wait.test.ts +0 -21
- package/src/error-handler.test.ts +0 -56
- package/src/hedge-throwing-backend.test.ts +0 -46
- package/src/interaction-coordinator.test.ts +0 -425
- package/src/iu-ledger.test.ts +0 -276
- package/src/latency-filler.test.ts +0 -62
- package/src/observability-observer.test.ts +0 -245
- package/src/observability.test.ts +0 -85
- package/src/packet-factories.test.ts +0 -53
- package/src/pipeline-bus.g10.test.ts +0 -145
- package/src/pipeline-bus.test.ts +0 -211
- package/src/policies/defer.test.ts +0 -86
- package/src/policies/rule-based.test.ts +0 -269
- package/src/primary-speaker-gate.test.ts +0 -150
- package/src/provider-fallback.test.ts +0 -87
- package/src/reasoner-hedge.test.ts +0 -361
- package/src/reasoner-route.test.ts +0 -248
- package/src/reasoner.test.ts +0 -69
- package/src/retry.test.ts +0 -83
- package/src/route-throwing-spec.test.ts +0 -44
- package/src/tts-playout-clock.test.ts +0 -125
- package/src/turn-arbiter.characterization.test.ts +0 -477
- package/src/turn-arbiter.test.ts +0 -567
- package/src/voice-agent-session.test.ts +0 -3316
- package/src/voice-text.test.ts +0 -94
- package/tsconfig.json +0 -21
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MIT
|
|
2
2
|
|
|
3
3
|
import type { VadAudioPacket } from "./packets.js";
|
|
4
|
-
import type { SttInterimPacket, SttResultPacket } from "./packets.js";
|
|
4
|
+
import type { SttInterimPacket, SttResultPacket, TurnEndOwner, TurnEndReason } from "./packets.js";
|
|
5
5
|
import { Route } from "./pipeline-bus.js";
|
|
6
6
|
import type { PipelineBus } from "./pipeline-bus.js";
|
|
7
7
|
import { confidenceToWaitMs } from "./confidence-to-wait.js";
|
|
@@ -41,6 +41,10 @@ export class InteractionCoordinator {
|
|
|
41
41
|
isTtsActive?: () => boolean;
|
|
42
42
|
hasCueAsset?: (cueId: string) => boolean;
|
|
43
43
|
onBackchannelEmitted?: (contextId: string) => void;
|
|
44
|
+
/** Session's configured endpointing owner — labels every eos this coordinator emits. */
|
|
45
|
+
endpointingOwner?: TurnEndOwner;
|
|
46
|
+
/** True when the STT force-finalize watchdog already fired for this context. */
|
|
47
|
+
wasForceFinalized?: (contextId: string) => boolean;
|
|
44
48
|
},
|
|
45
49
|
) {
|
|
46
50
|
this.scheduler = deps.scheduler ?? new TimerScheduler();
|
|
@@ -180,7 +184,18 @@ export class InteractionCoordinator {
|
|
|
180
184
|
live.transcripts.length > 0
|
|
181
185
|
? live.transcripts
|
|
182
186
|
: [{ kind: "stt.result", contextId, timestampMs: Date.now(), text: finalText, confidence: 0 }];
|
|
183
|
-
this
|
|
187
|
+
// The policy committed to this turn, so the endpointing decision is genuinely
|
|
188
|
+
// known here — label who decided and why. A force-finalize watchdog firing
|
|
189
|
+
// underneath us is a different diagnosis from a natural endpoint.
|
|
190
|
+
const configuredOwner = this.deps.endpointingOwner;
|
|
191
|
+
const forced = this.deps.wasForceFinalized?.(contextId) === true;
|
|
192
|
+
const reason: TurnEndReason = forced ? "force_finalized" : "end_of_speech";
|
|
193
|
+
const endpointing =
|
|
194
|
+
configuredOwner === undefined ? undefined : { owner: configuredOwner, reason };
|
|
195
|
+
this.deps.bus.push(
|
|
196
|
+
Route.Main,
|
|
197
|
+
make.eosTurnComplete(contextId, Date.now(), finalText, transcripts, endpointing),
|
|
198
|
+
);
|
|
184
199
|
});
|
|
185
200
|
}
|
|
186
201
|
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MIT
|
|
2
2
|
|
|
3
|
+
import type { AcousticSignal } from "./packets.js";
|
|
4
|
+
|
|
3
5
|
export interface WordTiming {
|
|
4
6
|
readonly word: string;
|
|
5
7
|
readonly startMs: number;
|
|
@@ -85,9 +87,19 @@ export type InteractionDecision =
|
|
|
85
87
|
| { readonly kind: "hold" }
|
|
86
88
|
| { readonly kind: "interrupt"; readonly interruptedContextId: string };
|
|
87
89
|
|
|
90
|
+
export interface AcousticSignalObservation {
|
|
91
|
+
readonly contextId: string;
|
|
92
|
+
readonly timestampMs: number;
|
|
93
|
+
readonly signal: AcousticSignal;
|
|
94
|
+
readonly payload?: Readonly<Record<string, unknown>>;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export type AcousticSignalSink = (signal: AcousticSignalObservation) => void;
|
|
98
|
+
|
|
88
99
|
export interface InteractionPolicy {
|
|
89
100
|
observe(obs: InteractionObservation): readonly InteractionDecision[];
|
|
90
101
|
reset(contextId: string): void;
|
|
102
|
+
setAcousticSignalSink?(sink: AcousticSignalSink): void;
|
|
91
103
|
}
|
|
92
104
|
|
|
93
105
|
/** Optional lifecycle for externally supplied model policies (session-owned). */
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MIT
|
|
2
2
|
|
|
3
3
|
import { Route, type PipelineBus } from "./pipeline-bus.js";
|
|
4
|
-
import { monotonicNowMs, type MetricsExporter } from "./observability.js";
|
|
4
|
+
import { monotonicNowMs, type MetricsExporter, type ObservabilityLayer } from "./observability.js";
|
|
5
5
|
import type {
|
|
6
6
|
TurnBoundaryKind,
|
|
7
7
|
VadSpeechStartedPacket,
|
|
@@ -17,6 +17,7 @@ export interface ObservabilityDims {
|
|
|
17
17
|
readonly provider: string;
|
|
18
18
|
readonly model: string;
|
|
19
19
|
readonly region: string;
|
|
20
|
+
readonly layer?: ObservabilityLayer;
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
export interface ObservabilityObserverDeps {
|
|
@@ -124,12 +125,11 @@ export class ObservabilityObserver {
|
|
|
124
125
|
|
|
125
126
|
const dims = this.dimsFor(speechId, cancelled);
|
|
126
127
|
const tags = {
|
|
127
|
-
sessionId: this.deps.sessionId,
|
|
128
|
-
speechId,
|
|
129
128
|
provider: dims.provider,
|
|
130
129
|
model: dims.model,
|
|
131
130
|
region: dims.region,
|
|
132
131
|
cancelled: dims.cancelled ? "true" : "false",
|
|
132
|
+
layer: dims.layer ?? "conversation",
|
|
133
133
|
};
|
|
134
134
|
|
|
135
135
|
if (boundary === "agent_started_speaking") {
|
|
@@ -179,12 +179,16 @@ export class ObservabilityObserver {
|
|
|
179
179
|
});
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
-
private dimsFor(
|
|
182
|
+
private dimsFor(
|
|
183
|
+
speechId: string,
|
|
184
|
+
cancelled: boolean,
|
|
185
|
+
): Required<ObservabilityDims> & { cancelled: boolean } {
|
|
183
186
|
const stage = this.stageDims.get(speechId) ?? {};
|
|
184
187
|
return {
|
|
185
188
|
provider: stage.provider ?? this.deps.dims.provider,
|
|
186
189
|
model: stage.model ?? this.deps.dims.model,
|
|
187
190
|
region: stage.region ?? this.deps.dims.region,
|
|
191
|
+
layer: stage.layer ?? this.deps.dims.layer ?? "conversation",
|
|
188
192
|
cancelled: cancelled || stage.cancelled === true,
|
|
189
193
|
};
|
|
190
194
|
}
|
package/src/observability.ts
CHANGED
|
@@ -7,6 +7,21 @@ export function monotonicNowMs(): number {
|
|
|
7
7
|
return performance.timeOrigin + performance.now();
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
export type ObservabilityLayer = "infrastructure" | "conversation";
|
|
11
|
+
|
|
12
|
+
export type TurnLocalizationVerdict = ObservabilityLayer | "none";
|
|
13
|
+
|
|
14
|
+
export interface TurnLocalizationSignals {
|
|
15
|
+
readonly infrastructureBreached: boolean;
|
|
16
|
+
readonly conversationFlagged: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function localizeTurn(signals: TurnLocalizationSignals): TurnLocalizationVerdict {
|
|
20
|
+
if (signals.infrastructureBreached) return "infrastructure";
|
|
21
|
+
if (signals.conversationFlagged) return "conversation";
|
|
22
|
+
return "none";
|
|
23
|
+
}
|
|
24
|
+
|
|
10
25
|
/** One step of a reconstructed turn timeline, with elapsed ms since the prior boundary. */
|
|
11
26
|
export interface TurnTimelineStep {
|
|
12
27
|
readonly boundary: TurnBoundaryEventPacket["boundary"];
|
|
@@ -46,7 +61,8 @@ export function reconstructTurnTimeline(
|
|
|
46
61
|
}
|
|
47
62
|
|
|
48
63
|
export interface MetricTags {
|
|
49
|
-
readonly [key: string]: string;
|
|
64
|
+
readonly [key: string]: string | undefined;
|
|
65
|
+
readonly layer?: ObservabilityLayer;
|
|
50
66
|
}
|
|
51
67
|
|
|
52
68
|
export interface SpanHandle {
|
|
@@ -56,12 +72,20 @@ export interface SpanHandle {
|
|
|
56
72
|
/** Export seam — implementations (Prometheus/OTel) live in optional packages, NOT here. */
|
|
57
73
|
export interface MetricsExporter {
|
|
58
74
|
observeHistogram(name: string, valueMs: number, tags: MetricTags): void;
|
|
75
|
+
/**
|
|
76
|
+
* A monotonic count (tokens, characters, audio-seconds). Counters, not histograms:
|
|
77
|
+
* usage sums to a bill, latency distributes to a percentile — different instruments.
|
|
78
|
+
* Optional so an existing exporter that predates it still satisfies the interface;
|
|
79
|
+
* producers call it as `exporter.observeCounter?.(...)`.
|
|
80
|
+
*/
|
|
81
|
+
observeCounter?(name: string, value: number, tags: MetricTags): void;
|
|
59
82
|
startSpan(name: string, tags: MetricTags): SpanHandle;
|
|
60
83
|
}
|
|
61
84
|
|
|
62
85
|
/** Default no-op exporter (core never depends on a backend). */
|
|
63
86
|
export const noopMetricsExporter: MetricsExporter = {
|
|
64
87
|
observeHistogram() {},
|
|
88
|
+
observeCounter() {},
|
|
65
89
|
startSpan() {
|
|
66
90
|
return { end() {} };
|
|
67
91
|
},
|
|
@@ -70,12 +94,17 @@ export const noopMetricsExporter: MetricsExporter = {
|
|
|
70
94
|
/** In-memory exporter for tests + incident reconstruction. */
|
|
71
95
|
export class InMemoryMetricsExporter implements MetricsExporter {
|
|
72
96
|
readonly histograms: Array<{ name: string; valueMs: number; tags: MetricTags }> = [];
|
|
97
|
+
readonly counters: Array<{ name: string; value: number; tags: MetricTags }> = [];
|
|
73
98
|
readonly spans: Array<{ name: string; tags: MetricTags; durationMs?: number }> = [];
|
|
74
99
|
|
|
75
100
|
observeHistogram(name: string, valueMs: number, tags: MetricTags): void {
|
|
76
101
|
this.histograms.push({ name, valueMs, tags });
|
|
77
102
|
}
|
|
78
103
|
|
|
104
|
+
observeCounter(name: string, value: number, tags: MetricTags): void {
|
|
105
|
+
this.counters.push({ name, value, tags });
|
|
106
|
+
}
|
|
107
|
+
|
|
79
108
|
startSpan(name: string, tags: MetricTags): SpanHandle {
|
|
80
109
|
const startMs = monotonicNowMs();
|
|
81
110
|
const spanIndex = this.spans.length;
|
package/src/packet-factories.ts
CHANGED
|
@@ -10,11 +10,18 @@
|
|
|
10
10
|
import type { WordTiming } from "./interaction-policy.js";
|
|
11
11
|
import { ErrorCategory } from "./packets.js";
|
|
12
12
|
import type {
|
|
13
|
+
UsageRecordedPacket,
|
|
13
14
|
ConversationMetricPacket,
|
|
15
|
+
AcousticSignalPacket,
|
|
16
|
+
AcousticSignal,
|
|
17
|
+
TurnLocalizationPacket,
|
|
14
18
|
TurnBoundaryKind,
|
|
15
19
|
TurnBoundaryEventPacket,
|
|
16
20
|
DtmfDigit,
|
|
17
21
|
DtmfReceivedPacket,
|
|
22
|
+
DtmfSendPacket,
|
|
23
|
+
CallTransferPacket,
|
|
24
|
+
CallTransferMode,
|
|
18
25
|
RecordUserAudioPacket,
|
|
19
26
|
RecordAssistantAudioDataPacket,
|
|
20
27
|
RecordAssistantAudioTruncatePacket,
|
|
@@ -22,6 +29,8 @@ import type {
|
|
|
22
29
|
SpeechToTextAudioPacket,
|
|
23
30
|
EndOfSpeechAudioPacket,
|
|
24
31
|
EndOfSpeechPacket,
|
|
32
|
+
TurnEndOwner,
|
|
33
|
+
TurnEndReason,
|
|
25
34
|
SttPartialPacket,
|
|
26
35
|
SttResultPacket,
|
|
27
36
|
FinalizeSttPacket,
|
|
@@ -35,6 +44,7 @@ import type {
|
|
|
35
44
|
InterruptLlmPacket,
|
|
36
45
|
InterruptSttPacket,
|
|
37
46
|
InjectMessagePacket,
|
|
47
|
+
SttReconfigurePacket,
|
|
38
48
|
LlmDeltaPacket,
|
|
39
49
|
LlmResponseDonePacket,
|
|
40
50
|
ReasoningSuspendedPacket,
|
|
@@ -43,7 +53,10 @@ import type {
|
|
|
43
53
|
StopIdleTimeoutPacket,
|
|
44
54
|
ModeSwitchRequestedPacket,
|
|
45
55
|
InteractionBackchannelPacket,
|
|
56
|
+
InteractionDuckPacket,
|
|
57
|
+
InteractionResumePacket,
|
|
46
58
|
} from "./packets.js";
|
|
59
|
+
import type { SttReconfigurePartial } from "./plugin-contract.js";
|
|
47
60
|
|
|
48
61
|
const DTMF_DIGITS = new Set<DtmfDigit>(["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "*", "#"]);
|
|
49
62
|
|
|
@@ -63,6 +76,36 @@ export function dtmfReceived(
|
|
|
63
76
|
return { kind: "dtmf.received", contextId, timestampMs, digit, provider, rawDigit };
|
|
64
77
|
}
|
|
65
78
|
|
|
79
|
+
const DTMF_SEND_CHARS = /^[0-9*#wW]+$/;
|
|
80
|
+
|
|
81
|
+
/** Validate + build a `dtmf.send` packet. Throws on empty/invalid digit strings. */
|
|
82
|
+
export function dtmfSend(contextId: string, timestampMs: number, digits: string): DtmfSendPacket {
|
|
83
|
+
if (!digits || !DTMF_SEND_CHARS.test(digits)) {
|
|
84
|
+
throw new Error(`Invalid dtmf.send digits: ${JSON.stringify(digits)} (expected [0-9*#wW]+)`);
|
|
85
|
+
}
|
|
86
|
+
return { kind: "dtmf.send", contextId, timestampMs, digits };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Build a `call.transfer` packet. */
|
|
90
|
+
export function callTransfer(
|
|
91
|
+
contextId: string,
|
|
92
|
+
timestampMs: number,
|
|
93
|
+
mode: CallTransferMode,
|
|
94
|
+
target: string,
|
|
95
|
+
summary?: string,
|
|
96
|
+
): CallTransferPacket {
|
|
97
|
+
const trimmed = target.trim();
|
|
98
|
+
if (!trimmed) throw new Error("call.transfer target must be a non-empty E.164 / SIP URI");
|
|
99
|
+
return {
|
|
100
|
+
kind: "call.transfer",
|
|
101
|
+
contextId,
|
|
102
|
+
timestampMs,
|
|
103
|
+
mode,
|
|
104
|
+
target: trimmed,
|
|
105
|
+
...(summary !== undefined ? { summary } : {}),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
66
109
|
export function metric(
|
|
67
110
|
contextId: string,
|
|
68
111
|
name: string,
|
|
@@ -72,6 +115,46 @@ export function metric(
|
|
|
72
115
|
return { kind: "metric.conversation", contextId, timestampMs, name, value };
|
|
73
116
|
}
|
|
74
117
|
|
|
118
|
+
export function acousticSignal(
|
|
119
|
+
contextId: string,
|
|
120
|
+
timestampMs: number,
|
|
121
|
+
signal: AcousticSignal,
|
|
122
|
+
payload?: Readonly<Record<string, unknown>>,
|
|
123
|
+
): AcousticSignalPacket {
|
|
124
|
+
return {
|
|
125
|
+
kind: "acoustic.signal",
|
|
126
|
+
contextId,
|
|
127
|
+
timestampMs,
|
|
128
|
+
signal,
|
|
129
|
+
...(payload ? { payload } : {}),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function turnLocalization(
|
|
134
|
+
contextId: string,
|
|
135
|
+
timestampMs: number,
|
|
136
|
+
value: TurnLocalizationPacket["value"],
|
|
137
|
+
infrastructureBreached: boolean,
|
|
138
|
+
conversationFlagged: boolean,
|
|
139
|
+
): TurnLocalizationPacket {
|
|
140
|
+
return {
|
|
141
|
+
kind: "turn.localization",
|
|
142
|
+
contextId,
|
|
143
|
+
timestampMs,
|
|
144
|
+
value,
|
|
145
|
+
infrastructureBreached,
|
|
146
|
+
conversationFlagged,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function usageRecorded(
|
|
151
|
+
contextId: string,
|
|
152
|
+
fields: Omit<UsageRecordedPacket, "kind" | "contextId" | "timestampMs">,
|
|
153
|
+
timestampMs: number = Date.now(),
|
|
154
|
+
): UsageRecordedPacket {
|
|
155
|
+
return { kind: "usage.recorded", contextId, timestampMs, ...fields };
|
|
156
|
+
}
|
|
157
|
+
|
|
75
158
|
export function turnBoundary(
|
|
76
159
|
contextId: string,
|
|
77
160
|
timestampMs: number,
|
|
@@ -139,8 +222,19 @@ export function eosTurnComplete(
|
|
|
139
222
|
timestampMs: number,
|
|
140
223
|
text: string,
|
|
141
224
|
transcripts: readonly SttResultPacket[],
|
|
225
|
+
endpointing?: { readonly owner: TurnEndOwner; readonly reason: TurnEndReason },
|
|
142
226
|
): EndOfSpeechPacket {
|
|
143
|
-
return
|
|
227
|
+
return endpointing === undefined
|
|
228
|
+
? { kind: "eos.turn_complete", contextId, timestampMs, text, transcripts }
|
|
229
|
+
: {
|
|
230
|
+
kind: "eos.turn_complete",
|
|
231
|
+
contextId,
|
|
232
|
+
timestampMs,
|
|
233
|
+
text,
|
|
234
|
+
transcripts,
|
|
235
|
+
endpointingOwner: endpointing.owner,
|
|
236
|
+
endpointingReason: endpointing.reason,
|
|
237
|
+
};
|
|
144
238
|
}
|
|
145
239
|
|
|
146
240
|
export function finalizeStt(contextId: string, timestampMs: number): FinalizeSttPacket {
|
|
@@ -210,8 +304,21 @@ export function ttsError(
|
|
|
210
304
|
return { kind: "tts.error", contextId, timestampMs, component: "tts", category, cause, isRecoverable };
|
|
211
305
|
}
|
|
212
306
|
|
|
213
|
-
export function injectMessage(
|
|
214
|
-
|
|
307
|
+
export function injectMessage(
|
|
308
|
+
contextId: string,
|
|
309
|
+
timestampMs: number,
|
|
310
|
+
text: string,
|
|
311
|
+
mode?: InjectMessagePacket["mode"],
|
|
312
|
+
): InjectMessagePacket {
|
|
313
|
+
return { kind: "inject.message", contextId, timestampMs, text, ...(mode ? { mode } : {}) };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export function sttReconfigure(
|
|
317
|
+
contextId: string,
|
|
318
|
+
timestampMs: number,
|
|
319
|
+
partial: SttReconfigurePartial,
|
|
320
|
+
): SttReconfigurePacket {
|
|
321
|
+
return { kind: "stt.reconfigure", contextId, timestampMs, partial };
|
|
215
322
|
}
|
|
216
323
|
|
|
217
324
|
export function llmDelta(contextId: string, timestampMs: number, text: string): LlmDeltaPacket {
|
|
@@ -268,3 +375,17 @@ export function interactionBackchannel(
|
|
|
268
375
|
): InteractionBackchannelPacket {
|
|
269
376
|
return { kind: "interaction.backchannel", contextId, timestampMs, cue };
|
|
270
377
|
}
|
|
378
|
+
|
|
379
|
+
export function interactionDuck(
|
|
380
|
+
contextId: string,
|
|
381
|
+
timestampMs: number,
|
|
382
|
+
): InteractionDuckPacket {
|
|
383
|
+
return { kind: "interaction.duck", contextId, timestampMs };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
export function interactionResume(
|
|
387
|
+
contextId: string,
|
|
388
|
+
timestampMs: number,
|
|
389
|
+
): InteractionResumePacket {
|
|
390
|
+
return { kind: "interaction.resume", contextId, timestampMs };
|
|
391
|
+
}
|
package/src/packets.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
// Lifecycle: InitStepCompleted, InitFailed, InitCompleted
|
|
11
11
|
|
|
12
12
|
import type { WordTiming } from "./interaction-policy.js";
|
|
13
|
+
import type { EndpointingOwner, SttReconfigurePartial } from "./plugin-contract.js";
|
|
13
14
|
|
|
14
15
|
// =============================================================================
|
|
15
16
|
// Base Types
|
|
@@ -199,6 +200,16 @@ export interface FinalizeSttPacket extends VoicePacket {
|
|
|
199
200
|
readonly kind: "stt.finalize";
|
|
200
201
|
}
|
|
201
202
|
|
|
203
|
+
/**
|
|
204
|
+
* Per-turn STT reconfigure actuation. Session routes to the stt plugin's
|
|
205
|
+
* `sttReconfigure` seam when present (warn-and-no-op otherwise).
|
|
206
|
+
* Call at a turn boundary so reconnect-based STTs do not drop mid-utterance audio.
|
|
207
|
+
*/
|
|
208
|
+
export interface SttReconfigurePacket extends VoicePacket {
|
|
209
|
+
readonly kind: "stt.reconfigure";
|
|
210
|
+
readonly partial: SttReconfigurePartial;
|
|
211
|
+
}
|
|
212
|
+
|
|
202
213
|
export interface SttErrorPacket extends VoicePacket, VoiceErrorPacket {
|
|
203
214
|
readonly kind: "stt.error";
|
|
204
215
|
readonly component: "stt";
|
|
@@ -209,11 +220,34 @@ export interface EndOfSpeechAudioPacket extends VoicePacket {
|
|
|
209
220
|
readonly audio: Uint8Array;
|
|
210
221
|
}
|
|
211
222
|
|
|
223
|
+
/**
|
|
224
|
+
* Who decided a turn ended. Extends {@link EndpointingOwner} with the two
|
|
225
|
+
* non-plugin regimes: `"timer"` (a realtime front owns its own turn
|
|
226
|
+
* detection) and `"text"` (the user typed — no endpointer fired at all).
|
|
227
|
+
*
|
|
228
|
+
* Absent means genuinely unknown: never fabricate an owner the backend did
|
|
229
|
+
* not measure. A debugging surface that guesses is worse than one that says so.
|
|
230
|
+
*/
|
|
231
|
+
export type TurnEndOwner = EndpointingOwner | "timer" | "text";
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Why a turn ended, paired with {@link TurnEndOwner}.
|
|
235
|
+
* - `end_of_speech` — an endpointer marked natural end of speech.
|
|
236
|
+
* - `force_finalized` — the STT was force-finalized by the
|
|
237
|
+
* `sttForceFinalizeTimeoutMs` watchdog (a timeout), not a natural endpoint.
|
|
238
|
+
* - `typed` — the turn came from typed input; no speech was endpointed.
|
|
239
|
+
*/
|
|
240
|
+
export type TurnEndReason = "end_of_speech" | "force_finalized" | "typed";
|
|
241
|
+
|
|
212
242
|
export interface EndOfSpeechPacket extends VoicePacket {
|
|
213
243
|
readonly kind: "eos.turn_complete";
|
|
214
244
|
readonly text: string;
|
|
215
245
|
/** All accumulated STT transcripts for this turn. */
|
|
216
246
|
readonly transcripts: readonly SttResultPacket[];
|
|
247
|
+
/** Which owner decided the turn ended. Omitted when genuinely unknown. */
|
|
248
|
+
readonly endpointingOwner?: TurnEndOwner;
|
|
249
|
+
/** Why the turn ended. Omitted when genuinely unknown. */
|
|
250
|
+
readonly endpointingReason?: TurnEndReason;
|
|
217
251
|
}
|
|
218
252
|
|
|
219
253
|
export interface InterimEndOfSpeechPacket extends VoicePacket {
|
|
@@ -280,6 +314,32 @@ export interface DtmfReceivedPacket extends VoicePacket {
|
|
|
280
314
|
readonly rawDigit: string;
|
|
281
315
|
}
|
|
282
316
|
|
|
317
|
+
/**
|
|
318
|
+
* Outbound DTMF request (IVR navigation). Digits may include pause syntax
|
|
319
|
+
* `w` (0.5s) / `W` (1s). Mechanism unit-tested; live carrier decode unverified.
|
|
320
|
+
*/
|
|
321
|
+
export interface DtmfSendPacket extends VoicePacket {
|
|
322
|
+
readonly kind: "dtmf.send";
|
|
323
|
+
/** Digits in `[0-9*#wW]+`. */
|
|
324
|
+
readonly digits: string;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export type CallTransferMode = "warm" | "cold" | "sip_refer";
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Outbound call transfer. Prefer Call-Control transfer over SIP REFER where
|
|
331
|
+
* answer-rate matters (REFER drops STIR/SHAKEN attestation). Mechanism
|
|
332
|
+
* unit-tested; live transfer bridge unverified against a carrier.
|
|
333
|
+
*/
|
|
334
|
+
export interface CallTransferPacket extends VoicePacket {
|
|
335
|
+
readonly kind: "call.transfer";
|
|
336
|
+
readonly mode: CallTransferMode;
|
|
337
|
+
/** E.164 number or SIP URI. */
|
|
338
|
+
readonly target: string;
|
|
339
|
+
/** Warm-handoff context for the receiving agent/human (mode `"warm"`). */
|
|
340
|
+
readonly summary?: string;
|
|
341
|
+
}
|
|
342
|
+
|
|
283
343
|
// =============================================================================
|
|
284
344
|
// LLM Pipeline Packets
|
|
285
345
|
// =============================================================================
|
|
@@ -354,6 +414,14 @@ export interface DelegateResultPacket extends VoicePacket {
|
|
|
354
414
|
readonly grounded: boolean;
|
|
355
415
|
readonly toolId?: string;
|
|
356
416
|
readonly toolName?: string;
|
|
417
|
+
readonly control?: {
|
|
418
|
+
readonly name: string;
|
|
419
|
+
readonly payload: unknown;
|
|
420
|
+
};
|
|
421
|
+
readonly blocked?: {
|
|
422
|
+
readonly userFacingMessage: string;
|
|
423
|
+
readonly payload?: unknown;
|
|
424
|
+
};
|
|
357
425
|
}
|
|
358
426
|
|
|
359
427
|
export interface ReasoningResumePacket extends VoicePacket {
|
|
@@ -480,6 +548,16 @@ export interface InteractionBackchannelPacket extends VoicePacket {
|
|
|
480
548
|
readonly cue: string;
|
|
481
549
|
}
|
|
482
550
|
|
|
551
|
+
/** Signal to attenuate active TTS while a barge-in is being evaluated (pending window). */
|
|
552
|
+
export interface InteractionDuckPacket extends VoicePacket {
|
|
553
|
+
readonly kind: "interaction.duck";
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** Signal to restore attenuated TTS when the pending window resolved without an interrupt. */
|
|
557
|
+
export interface InteractionResumePacket extends VoicePacket {
|
|
558
|
+
readonly kind: "interaction.resume";
|
|
559
|
+
}
|
|
560
|
+
|
|
483
561
|
// =============================================================================
|
|
484
562
|
// Behavior Packets
|
|
485
563
|
// =============================================================================
|
|
@@ -496,6 +574,8 @@ export interface StopIdleTimeoutPacket extends VoicePacket {
|
|
|
496
574
|
export interface InjectMessagePacket extends VoicePacket {
|
|
497
575
|
readonly kind: "inject.message";
|
|
498
576
|
readonly text: string;
|
|
577
|
+
/** Defaults to speak for compatibility with existing inject.message producers. */
|
|
578
|
+
readonly mode?: "speak" | "context";
|
|
499
579
|
}
|
|
500
580
|
|
|
501
581
|
export interface DisconnectRequestedPacket extends VoicePacket {
|
|
@@ -537,6 +617,58 @@ export interface ConversationMetricPacket extends VoicePacket {
|
|
|
537
617
|
readonly value: string;
|
|
538
618
|
}
|
|
539
619
|
|
|
620
|
+
export type AcousticSignal =
|
|
621
|
+
| "prosody"
|
|
622
|
+
| "backchannel"
|
|
623
|
+
| "interruption"
|
|
624
|
+
| "primary_speaker"
|
|
625
|
+
| "echo_rejected"
|
|
626
|
+
| "cadence";
|
|
627
|
+
|
|
628
|
+
export interface AcousticSignalPacket extends VoicePacket {
|
|
629
|
+
readonly kind: "acoustic.signal";
|
|
630
|
+
readonly signal: AcousticSignal;
|
|
631
|
+
readonly payload?: Readonly<Record<string, unknown>>;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
export type TurnLocalizationVerdict = "infrastructure" | "conversation" | "none";
|
|
635
|
+
|
|
636
|
+
export interface TurnLocalizationPacket extends VoicePacket {
|
|
637
|
+
readonly kind: "turn.localization";
|
|
638
|
+
readonly value: TurnLocalizationVerdict;
|
|
639
|
+
readonly infrastructureBreached: boolean;
|
|
640
|
+
readonly conversationFlagged: boolean;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/** The pipeline stage that consumed resources. Billing planes group cost by this. */
|
|
644
|
+
export type UsageStage = "llm" | "stt" | "tts";
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* One unit of billable resource consumption, recorded where it happens.
|
|
648
|
+
*
|
|
649
|
+
* The full shape is defined up front — LLM tokens, STT audio-seconds, TTS characters —
|
|
650
|
+
* so a producer added later needs no schema change; only the LLM producer is wired today
|
|
651
|
+
* (the AI SDK finish part carries usage the bridge had been dropping). `VoiceAgentSession`
|
|
652
|
+
* accumulates these into an end-of-session `session.usage` manifest and exports them as
|
|
653
|
+
* counters — the load-bearing seam for metering, spend caps, and eventual per-tenant billing.
|
|
654
|
+
* Fields not applicable to a stage are simply absent (tokens on STT/TTS, seconds on LLM).
|
|
655
|
+
*/
|
|
656
|
+
export interface UsageRecordedPacket extends VoicePacket {
|
|
657
|
+
readonly kind: "usage.recorded";
|
|
658
|
+
readonly stage: UsageStage;
|
|
659
|
+
readonly provider?: string;
|
|
660
|
+
readonly model?: string;
|
|
661
|
+
// LLM
|
|
662
|
+
readonly inputTokens?: number;
|
|
663
|
+
readonly outputTokens?: number;
|
|
664
|
+
readonly totalTokens?: number;
|
|
665
|
+
readonly cachedInputTokens?: number;
|
|
666
|
+
readonly reasoningTokens?: number;
|
|
667
|
+
// STT / TTS
|
|
668
|
+
readonly audioSeconds?: number;
|
|
669
|
+
readonly characters?: number;
|
|
670
|
+
}
|
|
671
|
+
|
|
540
672
|
export type TurnBoundaryKind =
|
|
541
673
|
| "user_started_speaking"
|
|
542
674
|
| "user_stopped_speaking"
|
|
@@ -583,6 +715,7 @@ export type InputPacket =
|
|
|
583
715
|
| SttPartialPacket
|
|
584
716
|
| SttResultPacket
|
|
585
717
|
| FinalizeSttPacket
|
|
718
|
+
| SttReconfigurePacket
|
|
586
719
|
| SttErrorPacket
|
|
587
720
|
| EndOfSpeechAudioPacket
|
|
588
721
|
| EndOfSpeechPacket
|
|
@@ -628,7 +761,12 @@ export type AnyErrorPacket =
|
|
|
628
761
|
| InitializationFailedPacket;
|
|
629
762
|
|
|
630
763
|
/** Observability packets (Background route). */
|
|
631
|
-
export type ObservabilityPacket =
|
|
764
|
+
export type ObservabilityPacket =
|
|
765
|
+
| ConversationMetricPacket
|
|
766
|
+
| TurnBoundaryEventPacket
|
|
767
|
+
| UsageRecordedPacket
|
|
768
|
+
| AcousticSignalPacket
|
|
769
|
+
| TurnLocalizationPacket;
|
|
632
770
|
|
|
633
771
|
/** Delegate (Responder-Thinker) lifecycle packets (Background route). */
|
|
634
772
|
export type DelegatePacket = DelegateQueryPacket | DelegateResultPacket;
|
package/src/plugin-contract.ts
CHANGED
|
@@ -22,9 +22,43 @@ export interface EndpointingCapability {
|
|
|
22
22
|
readonly disableConfig?: PluginConfig;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Vendor-agnostic mid-stream STT reconfiguration. Per-turn reconfigure is a COMMODITY STT capability
|
|
27
|
+
* (Deepgram Flux `Configure`, AssemblyAI `UpdateConfiguration`, Speechmatics `SetRecognitionConfig`) —
|
|
28
|
+
* the value of THIS seam is normalizing those differing wire shapes behind one interface so an
|
|
29
|
+
* InteractionPolicy can actuate any STT without vendor-specific plumbing. All fields optional; a plugin
|
|
30
|
+
* applies what it supports and ignores the rest (best-effort — e.g. Deepgram ignores `contextText`).
|
|
31
|
+
*/
|
|
32
|
+
export interface SttReconfigurePartial {
|
|
33
|
+
readonly keyterms?: readonly string[];
|
|
34
|
+
readonly eotThreshold?: number;
|
|
35
|
+
readonly eagerEotThreshold?: number;
|
|
36
|
+
readonly eotTimeoutMs?: number;
|
|
37
|
+
/** Silence-based endpointing (ms). Nova-style; Flux may ignore (it uses eotTimeoutMs). */
|
|
38
|
+
readonly endpointingMs?: number;
|
|
39
|
+
readonly vadThreshold?: number;
|
|
40
|
+
readonly languageHints?: readonly string[];
|
|
41
|
+
/**
|
|
42
|
+
* Hard recognition-language switch (e.g. "en-US" → "es-ES", or Nova-3 "multi" for code-switch).
|
|
43
|
+
* Distinct from `languageHints` (soft bias): this changes the recognizer's language. Providers
|
|
44
|
+
* that carry language in the connection URL (Nova) reconnect to apply it; model-fixed providers
|
|
45
|
+
* (Flux, `flux-general-en`) ignore it and rely on `languageHints`.
|
|
46
|
+
*/
|
|
47
|
+
readonly language?: string;
|
|
48
|
+
/** AssemblyAI-style agent-context biasing (the agent's own prior reply). Ignored where unsupported. */
|
|
49
|
+
readonly contextText?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface SttReconfigure {
|
|
53
|
+
reconfigure(partial: SttReconfigurePartial): void;
|
|
54
|
+
}
|
|
55
|
+
|
|
25
56
|
export interface VoicePlugin {
|
|
26
57
|
readonly endpointingCapability?: EndpointingCapability;
|
|
27
58
|
|
|
59
|
+
/** Present when the STT supports mid-stream reconfiguration (see {@link SttReconfigure}). */
|
|
60
|
+
readonly sttReconfigure?: SttReconfigure;
|
|
61
|
+
|
|
28
62
|
/**
|
|
29
63
|
* Initialize the plugin. Called during the init chain.
|
|
30
64
|
* Connect to provider, start streams, register bus handlers if needed.
|
|
@@ -34,6 +68,13 @@ export interface VoicePlugin {
|
|
|
34
68
|
*/
|
|
35
69
|
initialize(bus: PipelineBus, config: PluginConfig): Promise<void>;
|
|
36
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Best-effort warm of remote/expensive resources (open connections, wake a scaled-to-zero
|
|
73
|
+
* endpoint). Called AFTER initialize, before the first turn. Must not throw fatally — swallow
|
|
74
|
+
* errors internally. Optional; plugins with nothing to warm omit it.
|
|
75
|
+
*/
|
|
76
|
+
prewarm?(): Promise<void>;
|
|
77
|
+
|
|
37
78
|
/**
|
|
38
79
|
* Tear down the plugin. Called during the finalize chain (reverse order).
|
|
39
80
|
* Close connections, flush buffers, release resources.
|
|
@@ -11,11 +11,19 @@ export interface RuleBasedInteractionPolicyDeps {
|
|
|
11
11
|
readonly primarySpeakerGate: PrimarySpeakerGate;
|
|
12
12
|
readonly ttsPlayout: TtsPlayoutClock;
|
|
13
13
|
readonly minInterruptionMs: number;
|
|
14
|
+
/** When true, the underlying TurnArbiter emits duck/resume signals during barge-in evaluation. */
|
|
15
|
+
readonly pauseThenResolveBargeIn?: boolean;
|
|
16
|
+
/** Tenant-level backchannel knobs. Default (undefined) = current behavior (`mm_hmm`). */
|
|
17
|
+
readonly backchannel?: {
|
|
18
|
+
readonly enabled?: boolean;
|
|
19
|
+
readonly cues?: readonly string[];
|
|
20
|
+
};
|
|
14
21
|
}
|
|
15
22
|
|
|
16
23
|
export class RuleBasedInteractionPolicy implements InteractionPolicy {
|
|
17
24
|
readonly arbiter: TurnArbiter;
|
|
18
25
|
private readonly ttsPlayout: TtsPlayoutClock;
|
|
26
|
+
private readonly backchannelConfig: RuleBasedInteractionPolicyDeps["backchannel"];
|
|
19
27
|
private pending: InteractionDecision[] = [];
|
|
20
28
|
private bargeInAudioConsumed = false;
|
|
21
29
|
private delegateGapOpen = false;
|
|
@@ -24,8 +32,13 @@ export class RuleBasedInteractionPolicy implements InteractionPolicy {
|
|
|
24
32
|
|
|
25
33
|
constructor(deps: RuleBasedInteractionPolicyDeps) {
|
|
26
34
|
this.ttsPlayout = deps.ttsPlayout;
|
|
35
|
+
this.backchannelConfig = deps.backchannel;
|
|
27
36
|
this.arbiter = new TurnArbiter({
|
|
28
|
-
|
|
37
|
+
bus: deps.bus,
|
|
38
|
+
primarySpeakerGate: deps.primarySpeakerGate,
|
|
39
|
+
ttsPlayout: deps.ttsPlayout,
|
|
40
|
+
minInterruptionMs: deps.minInterruptionMs,
|
|
41
|
+
pauseThenResolveBargeIn: deps.pauseThenResolveBargeIn,
|
|
29
42
|
onInterrupt: (id) => this.pending.push({ kind: "interrupt", interruptedContextId: id }),
|
|
30
43
|
});
|
|
31
44
|
}
|
|
@@ -109,7 +122,9 @@ export class RuleBasedInteractionPolicy implements InteractionPolicy {
|
|
|
109
122
|
if (this.depsTtsActive()) return [];
|
|
110
123
|
if (this.userSpeaking) return [];
|
|
111
124
|
this.delegateCuePlayed = true;
|
|
112
|
-
|
|
125
|
+
if (this.backchannelConfig?.enabled === false) return [];
|
|
126
|
+
const cue = this.backchannelConfig?.cues?.[0] ?? "mm_hmm";
|
|
127
|
+
return [{ kind: "backchannel", cue }];
|
|
113
128
|
}
|
|
114
129
|
case "complete":
|
|
115
130
|
case "failed":
|