@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.
Files changed (51) hide show
  1. package/package.json +2 -2
  2. package/src/audio/alaw.ts +56 -0
  3. package/src/audio/g722.ts +329 -0
  4. package/src/audio/index.ts +12 -0
  5. package/src/audio/loudness.ts +87 -0
  6. package/src/idle-timeout.ts +1 -0
  7. package/src/index.ts +65 -3
  8. package/src/interaction-coordinator.ts +17 -2
  9. package/src/interaction-policy.ts +12 -0
  10. package/src/observability-observer.ts +8 -4
  11. package/src/observability.ts +30 -1
  12. package/src/packet-factories.ts +124 -3
  13. package/src/packets.ts +139 -1
  14. package/src/plugin-contract.ts +41 -0
  15. package/src/policies/rule-based.ts +17 -2
  16. package/src/pricing.ts +137 -0
  17. package/src/primary-speaker-gate.ts +23 -3
  18. package/src/reasoner.ts +28 -1
  19. package/src/spend-cap.ts +52 -0
  20. package/src/turn-arbiter.ts +34 -2
  21. package/src/voice-agent-session-util.ts +18 -0
  22. package/src/voice-agent-session.ts +469 -36
  23. package/src/voice-text.ts +69 -0
  24. package/src/audio/audio.test.ts +0 -285
  25. package/src/audio-envelope.test.ts +0 -167
  26. package/src/confidence-to-wait.test.ts +0 -21
  27. package/src/error-handler.test.ts +0 -56
  28. package/src/hedge-throwing-backend.test.ts +0 -46
  29. package/src/interaction-coordinator.test.ts +0 -425
  30. package/src/iu-ledger.test.ts +0 -276
  31. package/src/latency-filler.test.ts +0 -62
  32. package/src/observability-observer.test.ts +0 -245
  33. package/src/observability.test.ts +0 -85
  34. package/src/packet-factories.test.ts +0 -53
  35. package/src/pipeline-bus.g10.test.ts +0 -145
  36. package/src/pipeline-bus.test.ts +0 -211
  37. package/src/policies/defer.test.ts +0 -86
  38. package/src/policies/rule-based.test.ts +0 -269
  39. package/src/primary-speaker-gate.test.ts +0 -150
  40. package/src/provider-fallback.test.ts +0 -87
  41. package/src/reasoner-hedge.test.ts +0 -361
  42. package/src/reasoner-route.test.ts +0 -248
  43. package/src/reasoner.test.ts +0 -69
  44. package/src/retry.test.ts +0 -83
  45. package/src/route-throwing-spec.test.ts +0 -44
  46. package/src/tts-playout-clock.test.ts +0 -125
  47. package/src/turn-arbiter.characterization.test.ts +0 -477
  48. package/src/turn-arbiter.test.ts +0 -567
  49. package/src/voice-agent-session.test.ts +0 -3316
  50. package/src/voice-text.test.ts +0 -94
  51. package/tsconfig.json +0 -21
@@ -1,211 +0,0 @@
1
- // SPDX-License-Identifier: MIT
2
-
3
- import { describe, it, expect, vi } from "vitest";
4
- import { PipelineBusImpl, Route, type PipelineBusConfig } from "../src/pipeline-bus.js";
5
- import type { VoicePacket } from "../src/packets.js";
6
-
7
- // =============================================================================
8
- // Helpers
9
- // =============================================================================
10
-
11
- function pkt(kind: string, contextId = "ctx-1"): VoicePacket {
12
- return { kind, contextId, timestampMs: Date.now() };
13
- }
14
-
15
- function createBus(config?: PipelineBusConfig): PipelineBusImpl {
16
- return new PipelineBusImpl(config);
17
- }
18
-
19
- /** Start bus, run fn, stop bus, await drain completion. */
20
- async function withBus(
21
- config: PipelineBusConfig | undefined,
22
- fn: (bus: PipelineBusImpl) => void | Promise<void>,
23
- ): Promise<void> {
24
- const bus = createBus(config);
25
- const startP = bus.start();
26
- // Give the start loop a tick to begin
27
- await new Promise((r) => setTimeout(r, 5));
28
- await fn(bus);
29
- // Allow pending dispatches to complete
30
- await new Promise((r) => setTimeout(r, 20));
31
- bus.stop();
32
- await startP;
33
- }
34
-
35
- // =============================================================================
36
- // Tests
37
- // =============================================================================
38
-
39
- describe("PipelineBusImpl", () => {
40
- describe("push and drain order", () => {
41
- it("drains Critical before Main", async () => {
42
- const processed: string[] = [];
43
- await withBus(undefined, (bus) => {
44
- bus.on("critical.event", () => { processed.push("critical"); });
45
- bus.on("main.event", () => { processed.push("main"); });
46
- bus.push(Route.Main, pkt("main.event"));
47
- bus.push(Route.Critical, pkt("critical.event"));
48
- });
49
- expect(processed).toEqual(["critical", "main"]);
50
- });
51
-
52
- it("drains Main before Background", async () => {
53
- const processed: string[] = [];
54
- await withBus(undefined, (bus) => {
55
- bus.on("main.event", () => { processed.push("main"); });
56
- bus.on("bg.event", () => { processed.push("bg"); });
57
- bus.push(Route.Background, pkt("bg.event"));
58
- bus.push(Route.Main, pkt("main.event"));
59
- });
60
- expect(processed).toEqual(["main", "bg"]);
61
- });
62
-
63
- it("batches Critical up to criticalBatchSize before yielding", async () => {
64
- const processed: string[] = [];
65
- await withBus({ criticalBatchSize: 3 }, (bus) => {
66
- bus.on("critical.event", () => { processed.push("c"); });
67
- for (let i = 0; i < 5; i++) {
68
- bus.push(Route.Critical, pkt("critical.event"));
69
- }
70
- });
71
- expect(processed.length).toBeGreaterThanOrEqual(5);
72
- });
73
- });
74
-
75
- describe("capacity and overflow", () => {
76
- it("drops oldest Background on overflow", async () => {
77
- const dropped: VoicePacket[] = [];
78
- const metrics: string[] = [];
79
- await withBus(
80
- { bgCapacity: 2, onBackgroundDrop: (d: VoicePacket) => { dropped.push(d); } },
81
- (bus) => {
82
- bus.on("metric.conversation", (pkt: any) => {
83
- metrics.push(pkt.name);
84
- });
85
- bus.push(Route.Background, pkt("bg.1", "id-1"));
86
- bus.push(Route.Background, pkt("bg.2", "id-2"));
87
- bus.push(Route.Background, pkt("bg.3", "id-3"));
88
- },
89
- );
90
- expect(dropped.length).toBeGreaterThanOrEqual(1);
91
- if (dropped.length > 0) {
92
- expect(dropped[0]!.contextId).toBe("id-1");
93
- }
94
- expect(metrics).toContain("pipeline.bus.background.dropped");
95
- });
96
-
97
- it("throws on Main overflow", () => {
98
- const bus = createBus({ mainCapacity: 1 });
99
- bus.push(Route.Main, pkt("main.1"));
100
- expect(() => bus.push(Route.Main, pkt("main.2"))).toThrow("Main queue full");
101
- });
102
-
103
- it("Critical never overflows", () => {
104
- const bus = createBus();
105
- for (let i = 0; i < 10000; i++) {
106
- bus.push(Route.Critical, pkt("critical.event"));
107
- }
108
- expect(true).toBe(true);
109
- });
110
- });
111
-
112
- describe("handler registration", () => {
113
- it("calls matching handler for packet kind", async () => {
114
- const fn = vi.fn();
115
- await withBus(undefined, (bus) => {
116
- bus.on("test.event", fn);
117
- bus.push(Route.Main, pkt("test.event"));
118
- });
119
- expect(fn).toHaveBeenCalledTimes(1);
120
- });
121
-
122
- it("does not call handler for different kind", async () => {
123
- const fn = vi.fn();
124
- await withBus(undefined, (bus) => {
125
- bus.on("test.event", fn);
126
- bus.push(Route.Main, pkt("other.event"));
127
- });
128
- expect(fn).not.toHaveBeenCalled();
129
- });
130
-
131
- it("unsubscribe removes handler", async () => {
132
- const fn = vi.fn();
133
- await withBus(undefined, (bus) => {
134
- const unsub = bus.on("test.event", fn);
135
- unsub();
136
- bus.push(Route.Main, pkt("test.event"));
137
- });
138
- expect(fn).not.toHaveBeenCalled();
139
- });
140
-
141
- it("multiple handlers for same kind all fire", async () => {
142
- const fn1 = vi.fn();
143
- const fn2 = vi.fn();
144
- await withBus(undefined, (bus) => {
145
- bus.on("test.event", fn1);
146
- bus.on("test.event", fn2);
147
- bus.push(Route.Main, pkt("test.event"));
148
- });
149
- expect(fn1).toHaveBeenCalledTimes(1);
150
- expect(fn2).toHaveBeenCalledTimes(1);
151
- });
152
- });
153
-
154
- describe("allPackets", () => {
155
- it("publishes every pushed packet with its route", async () => {
156
- const bus = createBus();
157
- const reader = bus.allPackets.getReader();
158
- bus.push(Route.Main, pkt("main.event", "main-1"));
159
- bus.push(Route.Critical, pkt("critical.event", "critical-1"));
160
-
161
- const first = await reader.read();
162
- const second = await reader.read();
163
- reader.releaseLock();
164
-
165
- expect(first.value).toMatchObject({
166
- route: Route.Main,
167
- packet: { kind: "main.event", contextId: "main-1" },
168
- });
169
- expect(second.value).toMatchObject({
170
- route: Route.Critical,
171
- packet: { kind: "critical.event", contextId: "critical-1" },
172
- });
173
- });
174
-
175
- it("does not retain packets when no reader is attached (drop-on-unread)", async () => {
176
- const bus = createBus();
177
- // No getReader() → stream unlocked → nothing retained (else a call with no
178
- // recorder would buffer every audio packet and OOM).
179
- for (let i = 0; i < 1000; i++) bus.push(Route.Main, pkt("main.event", `n-${i}`));
180
-
181
- // A reader that attaches later sees only packets pushed AFTER it attaches.
182
- const reader = bus.allPackets.getReader();
183
- bus.push(Route.Main, pkt("main.event", "after-reader"));
184
- const next = await reader.read();
185
- reader.releaseLock();
186
- expect(next.value).toMatchObject({ packet: { contextId: "after-reader" } });
187
- });
188
- });
189
-
190
- describe("error handling", () => {
191
- it("handler error pushes VoiceErrorPacket to Critical", async () => {
192
- const errorHandler = vi.fn();
193
- await withBus(undefined, (bus) => {
194
- bus.on("test.event", () => { throw new Error("boom"); });
195
- bus.on("pipeline.error", errorHandler);
196
- bus.push(Route.Main, pkt("test.event"));
197
- });
198
- expect(errorHandler).toHaveBeenCalled();
199
- });
200
-
201
- it("handler error does not stop other handlers", async () => {
202
- const fn2 = vi.fn();
203
- await withBus(undefined, (bus) => {
204
- bus.on("test.event", () => { throw new Error("boom"); });
205
- bus.on("test.event", fn2);
206
- bus.push(Route.Main, pkt("test.event"));
207
- });
208
- expect(fn2).toHaveBeenCalledTimes(1);
209
- });
210
- });
211
- });
@@ -1,86 +0,0 @@
1
- // SPDX-License-Identifier: MIT
2
-
3
- import { describe, it, expect } from "vitest";
4
- import type { InteractionObservation, InteractionPolicy } from "../interaction-policy.js";
5
- import { DeferInteractionPolicy } from "./defer.js";
6
-
7
- const OBSERVATIONS: InteractionObservation[] = [
8
- {
9
- kind: "vad_speech_started",
10
- contextId: "user",
11
- timestampMs: 1000,
12
- confidence: 0.99,
13
- interruptedContextId: "assistant-turn",
14
- },
15
- {
16
- kind: "vad_speech_activity",
17
- contextId: "user",
18
- timestampMs: 1300,
19
- },
20
- {
21
- kind: "vad_speech_ended",
22
- contextId: "user",
23
- timestampMs: 2000,
24
- hasActiveTts: true,
25
- },
26
- {
27
- kind: "vad_barge_in_audio",
28
- contextId: "user",
29
- timestampMs: 1500,
30
- audio: new Uint8Array([1, 2, 3]),
31
- },
32
- {
33
- kind: "stt_partial",
34
- contextId: "user",
35
- timestampMs: 1600,
36
- text: "hello",
37
- confidence: 0.9,
38
- interruptedContextId: "assistant-turn",
39
- },
40
- {
41
- kind: "stt_final",
42
- contextId: "user",
43
- timestampMs: 2000,
44
- text: "hello there",
45
- confidence: 0.95,
46
- interruptedContextId: "assistant-turn",
47
- },
48
- {
49
- kind: "audio_frame",
50
- contextId: "user",
51
- timestampMs: 1700,
52
- audio: new Int16Array([1, 2, 3]),
53
- },
54
- {
55
- kind: "playout_tick",
56
- contextId: "assistant-turn",
57
- timestampMs: 1800,
58
- playedOutMs: 500,
59
- ttsActive: true,
60
- },
61
- {
62
- kind: "delegate_state",
63
- contextId: "turn-1",
64
- timestampMs: 1900,
65
- delegateInFlight: true,
66
- },
67
- ];
68
-
69
- describe("DeferInteractionPolicy", () => {
70
- it("implements InteractionPolicy", () => {
71
- const policy: InteractionPolicy = new DeferInteractionPolicy();
72
- expect(policy).toBeInstanceOf(DeferInteractionPolicy);
73
- });
74
-
75
- it("observe returns [] for every observation kind", () => {
76
- const policy = new DeferInteractionPolicy();
77
- for (const obs of OBSERVATIONS) {
78
- expect(policy.observe(obs)).toEqual([]);
79
- }
80
- });
81
-
82
- it("reset is a no-op", () => {
83
- const policy = new DeferInteractionPolicy();
84
- expect(() => policy.reset("any-context")).not.toThrow();
85
- });
86
- });
@@ -1,269 +0,0 @@
1
- // SPDX-License-Identifier: MIT
2
-
3
- import { describe, it, expect } from "vitest";
4
- import { PipelineBusImpl } from "../pipeline-bus.js";
5
- import { RuleBasedInteractionPolicy } from "./rule-based.js";
6
- import { PrimarySpeakerGate } from "../primary-speaker-gate.js";
7
- import { TtsPlayoutClock } from "../tts-playout-clock.js";
8
-
9
- async function createPolicy(minInterruptionMs = 280) {
10
- const bus = new PipelineBusImpl();
11
- void bus.start();
12
- const ttsPlayout = new TtsPlayoutClock();
13
- const policy = new RuleBasedInteractionPolicy({
14
- bus,
15
- primarySpeakerGate: new PrimarySpeakerGate(),
16
- ttsPlayout,
17
- minInterruptionMs,
18
- });
19
- return { bus, ttsPlayout, policy };
20
- }
21
-
22
- function metricNames(bus: PipelineBusImpl): string[] {
23
- const names: string[] = [];
24
- bus.on("metric.conversation", (pkt) => {
25
- names.push((pkt as unknown as { name: string }).name);
26
- });
27
- return names;
28
- }
29
-
30
- async function drainBus(): Promise<void> {
31
- await new Promise((resolve) => setTimeout(resolve, 0));
32
- }
33
-
34
- describe("RuleBasedInteractionPolicy", () => {
35
- it("parity vector: sustained speech commits interrupt at activity frame", async () => {
36
- const { ttsPlayout, policy } = await createPolicy(280);
37
- ttsPlayout.noteAudio("assistant-turn", 100, 1000);
38
-
39
- const t0 = 2000;
40
- expect(
41
- policy.observe({
42
- kind: "vad_speech_started",
43
- contextId: "user",
44
- timestampMs: t0,
45
- confidence: 0.99,
46
- interruptedContextId: "assistant-turn",
47
- }),
48
- ).toEqual([]);
49
-
50
- expect(
51
- policy.observe({
52
- kind: "vad_speech_activity",
53
- contextId: "user",
54
- timestampMs: t0 + 300,
55
- }),
56
- ).toEqual([{ kind: "interrupt", interruptedContextId: "assistant-turn" }]);
57
- });
58
-
59
- it("suppresses short speech blip without interrupt decision", async () => {
60
- const { bus, ttsPlayout, policy } = await createPolicy(280);
61
- const metrics = metricNames(bus);
62
- ttsPlayout.noteAudio("assistant-turn", 100, 1000);
63
-
64
- const t0 = 3000;
65
- policy.observe({
66
- kind: "vad_speech_started",
67
- contextId: "user",
68
- timestampMs: t0,
69
- confidence: 0.99,
70
- interruptedContextId: "assistant-turn",
71
- });
72
- policy.observe({
73
- kind: "vad_speech_activity",
74
- contextId: "user",
75
- timestampMs: t0 + 90,
76
- });
77
- const decisions = policy.observe({
78
- kind: "vad_speech_ended",
79
- contextId: "user",
80
- timestampMs: t0 + 130,
81
- hasActiveTts: true,
82
- });
83
- await drainBus();
84
-
85
- expect(decisions).toEqual([]);
86
- expect(metrics).toContain("interrupt.suppressed_short_speech");
87
- });
88
-
89
- it("suppresses backchannel interim without interrupt decision", async () => {
90
- const { bus, ttsPlayout, policy } = await createPolicy(280);
91
- const metrics = metricNames(bus);
92
- ttsPlayout.noteAudio("assistant-turn", 100, 1000);
93
-
94
- policy.observe({
95
- kind: "vad_speech_started",
96
- contextId: "user",
97
- timestampMs: 2000,
98
- confidence: 0.99,
99
- interruptedContextId: "assistant-turn",
100
- });
101
- policy.observe({
102
- kind: "stt_partial",
103
- contextId: "user",
104
- timestampMs: 2050,
105
- text: "uh huh",
106
- });
107
- const decisions = policy.observe({
108
- kind: "vad_speech_activity",
109
- contextId: "user",
110
- timestampMs: 2300,
111
- });
112
- await drainBus();
113
-
114
- expect(decisions).toEqual([]);
115
- expect(metrics).toContain("interrupt.suppressed_backchannel");
116
- });
117
-
118
- it("IP-C3: emits no backchannel on VAD/user-pause observations", async () => {
119
- const { policy } = await createPolicy();
120
-
121
- expect(
122
- policy.observe({
123
- kind: "vad_speech_ended",
124
- contextId: "user",
125
- timestampMs: 5000,
126
- hasActiveTts: false,
127
- }),
128
- ).toEqual([]);
129
- expect(
130
- policy.observe({
131
- kind: "vad_speech_activity",
132
- contextId: "user",
133
- timestampMs: 5100,
134
- }),
135
- ).toEqual([]);
136
- });
137
-
138
- it("IP-C3: emits exactly one cue on started → delayed and clears on complete", async () => {
139
- const { policy } = await createPolicy();
140
-
141
- policy.observe({
142
- kind: "delegate_state",
143
- contextId: "turn-1",
144
- timestampMs: 1000,
145
- toolCallPhase: "started",
146
- });
147
- expect(
148
- policy.observe({
149
- kind: "delegate_state",
150
- contextId: "turn-1",
151
- timestampMs: 3000,
152
- toolCallPhase: "delayed",
153
- }),
154
- ).toEqual([{ kind: "backchannel", cue: "mm_hmm" }]);
155
- expect(
156
- policy.observe({
157
- kind: "delegate_state",
158
- contextId: "turn-1",
159
- timestampMs: 3200,
160
- toolCallPhase: "delayed",
161
- }),
162
- ).toEqual([]);
163
- policy.observe({
164
- kind: "delegate_state",
165
- contextId: "turn-1",
166
- timestampMs: 4000,
167
- toolCallPhase: "complete",
168
- });
169
- policy.observe({
170
- kind: "delegate_state",
171
- contextId: "turn-1",
172
- timestampMs: 5000,
173
- toolCallPhase: "started",
174
- });
175
- expect(
176
- policy.observe({
177
- kind: "delegate_state",
178
- contextId: "turn-1",
179
- timestampMs: 7000,
180
- toolCallPhase: "delayed",
181
- }),
182
- ).toEqual([{ kind: "backchannel", cue: "mm_hmm" }]);
183
- });
184
-
185
- it("IP-C3: suppresses backchannel when TTS is active or the user is speaking", async () => {
186
- const { ttsPlayout, policy } = await createPolicy();
187
- ttsPlayout.noteAudio("assistant-turn", 500, 1000);
188
-
189
- policy.observe({
190
- kind: "delegate_state",
191
- contextId: "turn-1",
192
- timestampMs: 1000,
193
- toolCallPhase: "started",
194
- });
195
- expect(
196
- policy.observe({
197
- kind: "delegate_state",
198
- contextId: "turn-1",
199
- timestampMs: 3000,
200
- toolCallPhase: "delayed",
201
- }),
202
- ).toEqual([]);
203
-
204
- ttsPlayout.release("assistant-turn");
205
- policy.observe({
206
- kind: "delegate_state",
207
- contextId: "turn-2",
208
- timestampMs: 4000,
209
- toolCallPhase: "started",
210
- });
211
- policy.observe({
212
- kind: "vad_speech_started",
213
- contextId: "user",
214
- timestampMs: 4100,
215
- confidence: 0.9,
216
- interruptedContextId: "",
217
- });
218
- expect(
219
- policy.observe({
220
- kind: "delegate_state",
221
- contextId: "turn-2",
222
- timestampMs: 6000,
223
- toolCallPhase: "delayed",
224
- }),
225
- ).toEqual([]);
226
- });
227
-
228
- it("IP-C3: reset clears delegate-gap backchannel state", async () => {
229
- const { policy } = await createPolicy();
230
-
231
- policy.observe({
232
- kind: "delegate_state",
233
- contextId: "turn-1",
234
- timestampMs: 1000,
235
- toolCallPhase: "started",
236
- });
237
- policy.reset("turn-1");
238
- expect(
239
- policy.observe({
240
- kind: "delegate_state",
241
- contextId: "turn-1",
242
- timestampMs: 3000,
243
- toolCallPhase: "delayed",
244
- }),
245
- ).toEqual([]);
246
- });
247
-
248
- it("reset clears pending state", async () => {
249
- const { ttsPlayout, policy } = await createPolicy(280);
250
- ttsPlayout.noteAudio("assistant-turn", 100, 1000);
251
-
252
- const t0 = 4000;
253
- policy.observe({
254
- kind: "vad_speech_started",
255
- contextId: "user",
256
- timestampMs: t0,
257
- confidence: 0.99,
258
- interruptedContextId: "assistant-turn",
259
- });
260
- policy.reset("user");
261
-
262
- const decisions = policy.observe({
263
- kind: "vad_speech_activity",
264
- contextId: "user",
265
- timestampMs: t0 + 300,
266
- });
267
- expect(decisions).toEqual([]);
268
- });
269
- });