@kuralle-syrinx/core 2.1.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/README.md +41 -0
- package/package.json +22 -0
- package/src/audio/audio.test.ts +285 -0
- package/src/audio/index.ts +5 -0
- package/src/audio/mulaw.ts +42 -0
- package/src/audio/pcm.ts +43 -0
- package/src/audio/resample.ts +177 -0
- package/src/audio-envelope.test.ts +167 -0
- package/src/audio-envelope.ts +143 -0
- package/src/conversation-event.ts +62 -0
- package/src/error-handler.test.ts +56 -0
- package/src/error-handler.ts +149 -0
- package/src/idle-timeout.ts +210 -0
- package/src/index.ts +215 -0
- package/src/init-chain.ts +137 -0
- package/src/init-stage-order.ts +79 -0
- package/src/latency-filler-fixtures.ts +16 -0
- package/src/latency-filler.test.ts +62 -0
- package/src/latency-filler.ts +125 -0
- package/src/mode-switcher.ts +110 -0
- package/src/observability-observer.test.ts +245 -0
- package/src/observability-observer.ts +195 -0
- package/src/observability.test.ts +85 -0
- package/src/observability.ts +93 -0
- package/src/packet-factories.test.ts +34 -0
- package/src/packet-factories.ts +243 -0
- package/src/packets.ts +553 -0
- package/src/pipeline-bus.g10.test.ts +145 -0
- package/src/pipeline-bus.test.ts +197 -0
- package/src/pipeline-bus.ts +369 -0
- package/src/plugin-contract.ts +79 -0
- package/src/primary-speaker-fixtures.ts +45 -0
- package/src/primary-speaker-gate.test.ts +150 -0
- package/src/primary-speaker-gate.ts +186 -0
- package/src/provider-fallback.test.ts +87 -0
- package/src/provider-fallback.ts +88 -0
- package/src/reasoner.test.ts +69 -0
- package/src/reasoner.ts +54 -0
- package/src/retry.test.ts +83 -0
- package/src/retry.ts +106 -0
- package/src/scheduler.ts +28 -0
- package/src/tts-playout-clock.test.ts +125 -0
- package/src/tts-playout-clock.ts +116 -0
- package/src/turn-arbiter.characterization.test.ts +477 -0
- package/src/turn-arbiter.test.ts +567 -0
- package/src/turn-arbiter.ts +283 -0
- package/src/voice-agent-session-util.ts +240 -0
- package/src/voice-agent-session.test.ts +2487 -0
- package/src/voice-agent-session.ts +1175 -0
- package/src/voice-text.test.ts +81 -0
- package/src/voice-text.ts +102 -0
- package/tsconfig.json +21 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
import { Route, type PipelineBus } from "./pipeline-bus.js";
|
|
4
|
+
import { monotonicNowMs, type MetricsExporter } from "./observability.js";
|
|
5
|
+
import type {
|
|
6
|
+
TurnBoundaryKind,
|
|
7
|
+
VadSpeechStartedPacket,
|
|
8
|
+
VadSpeechEndedPacket,
|
|
9
|
+
EndOfSpeechPacket,
|
|
10
|
+
TextToSpeechAudioPacket,
|
|
11
|
+
TextToSpeechEndPacket,
|
|
12
|
+
InterruptionDetectedPacket,
|
|
13
|
+
} from "./packets.js";
|
|
14
|
+
import * as make from "./packet-factories.js";
|
|
15
|
+
|
|
16
|
+
export interface ObservabilityDims {
|
|
17
|
+
readonly provider: string;
|
|
18
|
+
readonly model: string;
|
|
19
|
+
readonly region: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ObservabilityObserverDeps {
|
|
23
|
+
readonly bus: PipelineBus;
|
|
24
|
+
readonly exporter: MetricsExporter;
|
|
25
|
+
readonly sessionId: string;
|
|
26
|
+
readonly dims: ObservabilityDims;
|
|
27
|
+
readonly getContextId: () => string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type BoundaryTimes = Partial<Record<TurnBoundaryKind, number>>;
|
|
31
|
+
type StageDims = Partial<ObservabilityDims> & { cancelled?: boolean };
|
|
32
|
+
|
|
33
|
+
export class ObservabilityObserver {
|
|
34
|
+
private readonly boundaryTimes = new Map<string, BoundaryTimes>();
|
|
35
|
+
private readonly stageDims = new Map<string, StageDims>();
|
|
36
|
+
private readonly agentStartedEmitted = new Set<string>();
|
|
37
|
+
private readonly unsubscribes: Array<() => void> = [];
|
|
38
|
+
|
|
39
|
+
constructor(private readonly deps: ObservabilityObserverDeps) {}
|
|
40
|
+
|
|
41
|
+
wire(disposers?: Array<() => void>): void {
|
|
42
|
+
const reg = (unsub: () => void): void => {
|
|
43
|
+
this.unsubscribes.push(unsub);
|
|
44
|
+
disposers?.push(unsub);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
reg(
|
|
48
|
+
this.deps.bus.on("vad.speech_started", (pkt) =>
|
|
49
|
+
this.onVadSpeechStarted(pkt as VadSpeechStartedPacket),
|
|
50
|
+
),
|
|
51
|
+
);
|
|
52
|
+
reg(
|
|
53
|
+
this.deps.bus.on("vad.speech_ended", (pkt) =>
|
|
54
|
+
this.onVadSpeechEnded(pkt as VadSpeechEndedPacket),
|
|
55
|
+
),
|
|
56
|
+
);
|
|
57
|
+
reg(
|
|
58
|
+
this.deps.bus.on("eos.turn_complete", (pkt) =>
|
|
59
|
+
this.onTurnComplete(pkt as EndOfSpeechPacket),
|
|
60
|
+
),
|
|
61
|
+
);
|
|
62
|
+
reg(this.deps.bus.on("tts.audio", (pkt) => this.onTtsAudio(pkt as TextToSpeechAudioPacket)));
|
|
63
|
+
reg(this.deps.bus.on("tts.end", (pkt) => this.onTtsEnd(pkt as TextToSpeechEndPacket)));
|
|
64
|
+
reg(
|
|
65
|
+
this.deps.bus.on("interrupt.detected", (pkt) =>
|
|
66
|
+
this.onInterruptDetected(pkt as InterruptionDetectedPacket),
|
|
67
|
+
),
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
dispose(): void {
|
|
72
|
+
for (const unsub of this.unsubscribes) unsub();
|
|
73
|
+
this.unsubscribes.length = 0;
|
|
74
|
+
this.boundaryTimes.clear();
|
|
75
|
+
this.stageDims.clear();
|
|
76
|
+
this.agentStartedEmitted.clear();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private speechId(contextId: string): string {
|
|
80
|
+
return contextId || this.deps.getContextId();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private onVadSpeechStarted(pkt: VadSpeechStartedPacket): void {
|
|
84
|
+
this.emitBoundary(this.speechId(pkt.contextId), "user_started_speaking");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private onVadSpeechEnded(pkt: VadSpeechEndedPacket): void {
|
|
88
|
+
this.emitBoundary(this.speechId(pkt.contextId), "user_stopped_speaking");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private onTurnComplete(pkt: EndOfSpeechPacket): void {
|
|
92
|
+
const provider = pkt.transcripts[0]?.provider;
|
|
93
|
+
if (provider) this.mergeStageDims(this.speechId(pkt.contextId), provider);
|
|
94
|
+
this.emitBoundary(this.speechId(pkt.contextId), "agent_thinking");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private onTtsAudio(pkt: TextToSpeechAudioPacket): void {
|
|
98
|
+
const id = pkt.contextId;
|
|
99
|
+
if (pkt.provider) this.mergeStageDims(id, pkt.provider);
|
|
100
|
+
if (this.agentStartedEmitted.has(id)) return;
|
|
101
|
+
this.agentStartedEmitted.add(id);
|
|
102
|
+
this.emitBoundary(id, "agent_started_speaking");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private onTtsEnd(pkt: TextToSpeechEndPacket): void {
|
|
106
|
+
this.emitBoundary(this.speechId(pkt.contextId), "agent_audio_done");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private onInterruptDetected(pkt: InterruptionDetectedPacket): void {
|
|
110
|
+
this.stageDims.set(this.speechId(pkt.contextId), {
|
|
111
|
+
...this.stageDims.get(this.speechId(pkt.contextId)),
|
|
112
|
+
cancelled: true,
|
|
113
|
+
});
|
|
114
|
+
this.emitBoundary(this.speechId(pkt.contextId), "interruption", true);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private emitBoundary(speechId: string, boundary: TurnBoundaryKind, cancelled = false): void {
|
|
118
|
+
const now = monotonicNowMs();
|
|
119
|
+
let times = this.boundaryTimes.get(speechId);
|
|
120
|
+
if (!times) {
|
|
121
|
+
times = {};
|
|
122
|
+
this.boundaryTimes.set(speechId, times);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const dims = this.dimsFor(speechId, cancelled);
|
|
126
|
+
const tags = {
|
|
127
|
+
sessionId: this.deps.sessionId,
|
|
128
|
+
speechId,
|
|
129
|
+
provider: dims.provider,
|
|
130
|
+
model: dims.model,
|
|
131
|
+
region: dims.region,
|
|
132
|
+
cancelled: dims.cancelled ? "true" : "false",
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
if (boundary === "agent_started_speaking") {
|
|
136
|
+
const stopped = times.user_stopped_speaking;
|
|
137
|
+
if (stopped !== undefined) {
|
|
138
|
+
const delta = now - stopped;
|
|
139
|
+
if (delta >= 0) this.deps.exporter.observeHistogram("v2v_ms", delta, tags);
|
|
140
|
+
}
|
|
141
|
+
const thinking = times.agent_thinking;
|
|
142
|
+
if (thinking !== undefined) {
|
|
143
|
+
const delta = now - thinking;
|
|
144
|
+
if (delta >= 0) this.deps.exporter.observeHistogram("thinking_ms", delta, tags);
|
|
145
|
+
}
|
|
146
|
+
} else if (boundary === "agent_audio_done") {
|
|
147
|
+
const started = times.agent_started_speaking;
|
|
148
|
+
if (started !== undefined) {
|
|
149
|
+
const delta = now - started;
|
|
150
|
+
if (delta >= 0) this.deps.exporter.observeHistogram("agent_speech_ms", delta, tags);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
times[boundary] = now;
|
|
155
|
+
|
|
156
|
+
this.deps.bus.push(
|
|
157
|
+
Route.Background,
|
|
158
|
+
make.turnBoundary(speechId, Date.now(), {
|
|
159
|
+
boundary,
|
|
160
|
+
sessionId: this.deps.sessionId,
|
|
161
|
+
speechId,
|
|
162
|
+
monotonicMs: now,
|
|
163
|
+
provider: dims.provider,
|
|
164
|
+
model: dims.model,
|
|
165
|
+
region: dims.region,
|
|
166
|
+
cancelled: dims.cancelled || undefined,
|
|
167
|
+
}),
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private mergeStageDims(speechId: string, provider: Record<string, unknown>): void {
|
|
172
|
+
const current = this.stageDims.get(speechId) ?? {};
|
|
173
|
+
this.stageDims.set(speechId, {
|
|
174
|
+
...current,
|
|
175
|
+
provider: readString(provider["name"], readString(provider["provider"], current.provider)),
|
|
176
|
+
model: readString(provider["model"], current.model),
|
|
177
|
+
region: readString(provider["region"], current.region),
|
|
178
|
+
cancelled: typeof provider["cancelled"] === "boolean" ? provider["cancelled"] : current.cancelled,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
private dimsFor(speechId: string, cancelled: boolean): Required<ObservabilityDims> & { cancelled: boolean } {
|
|
183
|
+
const stage = this.stageDims.get(speechId) ?? {};
|
|
184
|
+
return {
|
|
185
|
+
provider: stage.provider ?? this.deps.dims.provider,
|
|
186
|
+
model: stage.model ?? this.deps.dims.model,
|
|
187
|
+
region: stage.region ?? this.deps.dims.region,
|
|
188
|
+
cancelled: cancelled || stage.cancelled === true,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function readString(value: unknown, fallback: string | undefined): string | undefined {
|
|
194
|
+
return typeof value === "string" && value.length > 0 ? value : fallback;
|
|
195
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
InMemoryMetricsExporter,
|
|
7
|
+
monotonicNowMs,
|
|
8
|
+
noopMetricsExporter,
|
|
9
|
+
reconstructTurnTimeline,
|
|
10
|
+
} from "./observability.js";
|
|
11
|
+
import type { TurnBoundaryEventPacket } from "./packets.js";
|
|
12
|
+
|
|
13
|
+
describe("monotonicNowMs", () => {
|
|
14
|
+
it("returns increasing positive numbers", () => {
|
|
15
|
+
const first = monotonicNowMs();
|
|
16
|
+
const second = monotonicNowMs();
|
|
17
|
+
expect(first).toBeGreaterThan(0);
|
|
18
|
+
expect(second).toBeGreaterThanOrEqual(first);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe("InMemoryMetricsExporter", () => {
|
|
23
|
+
it("records histogram name, value, and tags", () => {
|
|
24
|
+
const exporter = new InMemoryMetricsExporter();
|
|
25
|
+
exporter.observeHistogram("turn.latency_ms", 42, { sessionId: "s1", boundary: "agent_audio_done" });
|
|
26
|
+
expect(exporter.histograms).toEqual([
|
|
27
|
+
{
|
|
28
|
+
name: "turn.latency_ms",
|
|
29
|
+
valueMs: 42,
|
|
30
|
+
tags: { sessionId: "s1", boundary: "agent_audio_done" },
|
|
31
|
+
},
|
|
32
|
+
]);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("records non-negative span duration on end", () => {
|
|
36
|
+
vi.useFakeTimers();
|
|
37
|
+
const exporter = new InMemoryMetricsExporter();
|
|
38
|
+
const handle = exporter.startSpan("turn.process", { sessionId: "s1" });
|
|
39
|
+
vi.advanceTimersByTime(10);
|
|
40
|
+
handle.end();
|
|
41
|
+
expect(exporter.spans).toHaveLength(1);
|
|
42
|
+
expect(exporter.spans[0]?.durationMs).toBeGreaterThanOrEqual(0);
|
|
43
|
+
vi.useRealTimers();
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("noopMetricsExporter", () => {
|
|
48
|
+
it("accepts histogram and span calls without throwing", () => {
|
|
49
|
+
noopMetricsExporter.observeHistogram("ignored", 0, {});
|
|
50
|
+
noopMetricsExporter.startSpan("ignored", {}).end();
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("reconstructTurnTimeline", () => {
|
|
55
|
+
function ev(sessionId: string, speechId: string, boundary: TurnBoundaryEventPacket["boundary"], monotonicMs: number, cancelled = false): TurnBoundaryEventPacket {
|
|
56
|
+
return { kind: "obs.turn_boundary", contextId: speechId, timestampMs: 0, boundary, sessionId, speechId, monotonicMs, cancelled };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
it("orders one session's boundaries by monotonic time with inter-boundary deltas", () => {
|
|
60
|
+
const events = [
|
|
61
|
+
ev("sess-A", "turn-1", "agent_started_speaking", 300),
|
|
62
|
+
ev("sess-A", "turn-1", "user_started_speaking", 100),
|
|
63
|
+
ev("sess-B", "turn-9", "user_started_speaking", 150), // other session — excluded
|
|
64
|
+
ev("sess-A", "turn-1", "user_stopped_speaking", 200),
|
|
65
|
+
];
|
|
66
|
+
const timeline = reconstructTurnTimeline(events, "sess-A");
|
|
67
|
+
expect(timeline.map((s) => s.boundary)).toEqual([
|
|
68
|
+
"user_started_speaking",
|
|
69
|
+
"user_stopped_speaking",
|
|
70
|
+
"agent_started_speaking",
|
|
71
|
+
]);
|
|
72
|
+
expect(timeline.map((s) => s.sincePrevMs)).toEqual([0, 100, 100]);
|
|
73
|
+
expect(timeline.every((s) => s.speechId === "turn-1")).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("filters by speechId when provided and surfaces the cancelled flag", () => {
|
|
77
|
+
const events = [
|
|
78
|
+
ev("s", "t1", "user_started_speaking", 10),
|
|
79
|
+
ev("s", "t2", "interruption", 20, true),
|
|
80
|
+
];
|
|
81
|
+
const t2 = reconstructTurnTimeline(events, "s", "t2");
|
|
82
|
+
expect(t2).toHaveLength(1);
|
|
83
|
+
expect(t2[0]!.cancelled).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
import type { TurnBoundaryEventPacket } from "./packets.js";
|
|
4
|
+
|
|
5
|
+
/** Monotonic clock immune to system-clock adjustments; ms since an arbitrary origin. */
|
|
6
|
+
export function monotonicNowMs(): number {
|
|
7
|
+
return performance.timeOrigin + performance.now();
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** One step of a reconstructed turn timeline, with elapsed ms since the prior boundary. */
|
|
11
|
+
export interface TurnTimelineStep {
|
|
12
|
+
readonly boundary: TurnBoundaryEventPacket["boundary"];
|
|
13
|
+
readonly speechId: string;
|
|
14
|
+
readonly monotonicMs: number;
|
|
15
|
+
/** ms since the previous boundary in this reconstruction (0 for the first). */
|
|
16
|
+
readonly sincePrevMs: number;
|
|
17
|
+
readonly cancelled: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Incident reconstruction (VE-07.5): given the `obs.turn_boundary` events for one
|
|
22
|
+
* session id, return the ordered turn timeline with inter-boundary deltas so a
|
|
23
|
+
* developer can replay what happened from a single session id.
|
|
24
|
+
*/
|
|
25
|
+
export function reconstructTurnTimeline(
|
|
26
|
+
events: readonly TurnBoundaryEventPacket[],
|
|
27
|
+
sessionId: string,
|
|
28
|
+
speechId?: string,
|
|
29
|
+
): TurnTimelineStep[] {
|
|
30
|
+
const ordered = events
|
|
31
|
+
.filter((e) => e.sessionId === sessionId && (speechId === undefined || e.speechId === speechId))
|
|
32
|
+
.slice()
|
|
33
|
+
.sort((a, b) => a.monotonicMs - b.monotonicMs);
|
|
34
|
+
let prevMs: number | null = null;
|
|
35
|
+
return ordered.map((e) => {
|
|
36
|
+
const sincePrevMs = prevMs === null ? 0 : Math.max(0, e.monotonicMs - prevMs);
|
|
37
|
+
prevMs = e.monotonicMs;
|
|
38
|
+
return {
|
|
39
|
+
boundary: e.boundary,
|
|
40
|
+
speechId: e.speechId,
|
|
41
|
+
monotonicMs: e.monotonicMs,
|
|
42
|
+
sincePrevMs,
|
|
43
|
+
cancelled: e.cancelled === true,
|
|
44
|
+
};
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface MetricTags {
|
|
49
|
+
readonly [key: string]: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface SpanHandle {
|
|
53
|
+
end(tags?: MetricTags): void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Export seam — implementations (Prometheus/OTel) live in optional packages, NOT here. */
|
|
57
|
+
export interface MetricsExporter {
|
|
58
|
+
observeHistogram(name: string, valueMs: number, tags: MetricTags): void;
|
|
59
|
+
startSpan(name: string, tags: MetricTags): SpanHandle;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Default no-op exporter (core never depends on a backend). */
|
|
63
|
+
export const noopMetricsExporter: MetricsExporter = {
|
|
64
|
+
observeHistogram() {},
|
|
65
|
+
startSpan() {
|
|
66
|
+
return { end() {} };
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/** In-memory exporter for tests + incident reconstruction. */
|
|
71
|
+
export class InMemoryMetricsExporter implements MetricsExporter {
|
|
72
|
+
readonly histograms: Array<{ name: string; valueMs: number; tags: MetricTags }> = [];
|
|
73
|
+
readonly spans: Array<{ name: string; tags: MetricTags; durationMs?: number }> = [];
|
|
74
|
+
|
|
75
|
+
observeHistogram(name: string, valueMs: number, tags: MetricTags): void {
|
|
76
|
+
this.histograms.push({ name, valueMs, tags });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
startSpan(name: string, tags: MetricTags): SpanHandle {
|
|
80
|
+
const startMs = monotonicNowMs();
|
|
81
|
+
const spanIndex = this.spans.length;
|
|
82
|
+
this.spans.push({ name, tags });
|
|
83
|
+
return {
|
|
84
|
+
end: (endTags?: MetricTags) => {
|
|
85
|
+
this.spans[spanIndex] = {
|
|
86
|
+
name,
|
|
87
|
+
tags: endTags ?? tags,
|
|
88
|
+
durationMs: monotonicNowMs() - startMs,
|
|
89
|
+
};
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { reasoningResume, reasoningSuspended } from "./packet-factories.js";
|
|
6
|
+
|
|
7
|
+
describe("reasoningSuspended", () => {
|
|
8
|
+
it("returns a reasoning.suspended packet with the expected shape", () => {
|
|
9
|
+
const pkt = reasoningSuspended("ctx-1", 1234, "run-1", { step: 3 }, "Pause for input.");
|
|
10
|
+
|
|
11
|
+
expect(pkt).toEqual({
|
|
12
|
+
kind: "reasoning.suspended",
|
|
13
|
+
contextId: "ctx-1",
|
|
14
|
+
timestampMs: 1234,
|
|
15
|
+
runId: "run-1",
|
|
16
|
+
prompt: "Pause for input.",
|
|
17
|
+
payload: { step: 3 },
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe("reasoningResume", () => {
|
|
23
|
+
it("returns a reasoning.resume packet with the expected shape", () => {
|
|
24
|
+
const pkt = reasoningResume("ctx-1", 5678, "run-1", "user answer");
|
|
25
|
+
|
|
26
|
+
expect(pkt).toEqual({
|
|
27
|
+
kind: "reasoning.resume",
|
|
28
|
+
contextId: "ctx-1",
|
|
29
|
+
timestampMs: 5678,
|
|
30
|
+
runId: "run-1",
|
|
31
|
+
data: "user answer",
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
//
|
|
3
|
+
// Syrinx Kernel v2 — Packet Factories
|
|
4
|
+
//
|
|
5
|
+
// Typed constructors for the packets the session orchestrator pushes onto the
|
|
6
|
+
// bus. Each returns a fully-typed packet, so the shape is checked at the factory
|
|
7
|
+
// definition and call sites need no `as` assertion — illegal packet shapes are
|
|
8
|
+
// unrepresentable at construction instead of asserted-valid by a cast (CR-05).
|
|
9
|
+
|
|
10
|
+
import { ErrorCategory } from "./packets.js";
|
|
11
|
+
import type {
|
|
12
|
+
ConversationMetricPacket,
|
|
13
|
+
TurnBoundaryKind,
|
|
14
|
+
TurnBoundaryEventPacket,
|
|
15
|
+
DtmfDigit,
|
|
16
|
+
DtmfReceivedPacket,
|
|
17
|
+
RecordUserAudioPacket,
|
|
18
|
+
RecordAssistantAudioDataPacket,
|
|
19
|
+
RecordAssistantAudioTruncatePacket,
|
|
20
|
+
VadAudioPacket,
|
|
21
|
+
SpeechToTextAudioPacket,
|
|
22
|
+
EndOfSpeechAudioPacket,
|
|
23
|
+
EndOfSpeechPacket,
|
|
24
|
+
SttResultPacket,
|
|
25
|
+
UserInputPacket,
|
|
26
|
+
TextToSpeechTextPacket,
|
|
27
|
+
TextToSpeechDonePacket,
|
|
28
|
+
TtsErrorPacket,
|
|
29
|
+
InterruptionDetectedPacket,
|
|
30
|
+
InterruptionSource,
|
|
31
|
+
InterruptTtsPacket,
|
|
32
|
+
InterruptLlmPacket,
|
|
33
|
+
InterruptSttPacket,
|
|
34
|
+
InjectMessagePacket,
|
|
35
|
+
LlmDeltaPacket,
|
|
36
|
+
LlmResponseDonePacket,
|
|
37
|
+
ReasoningSuspendedPacket,
|
|
38
|
+
ReasoningResumePacket,
|
|
39
|
+
StartIdleTimeoutPacket,
|
|
40
|
+
StopIdleTimeoutPacket,
|
|
41
|
+
ModeSwitchRequestedPacket,
|
|
42
|
+
} from "./packets.js";
|
|
43
|
+
|
|
44
|
+
const DTMF_DIGITS = new Set<DtmfDigit>(["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "*", "#"]);
|
|
45
|
+
|
|
46
|
+
export function parseDtmfDigit(raw: string): DtmfDigit | null {
|
|
47
|
+
const trimmed = raw.trim();
|
|
48
|
+
if (trimmed.length !== 1 || !DTMF_DIGITS.has(trimmed as DtmfDigit)) return null;
|
|
49
|
+
return trimmed as DtmfDigit;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function dtmfReceived(
|
|
53
|
+
contextId: string,
|
|
54
|
+
timestampMs: number,
|
|
55
|
+
digit: DtmfDigit,
|
|
56
|
+
provider: DtmfReceivedPacket["provider"],
|
|
57
|
+
rawDigit: string,
|
|
58
|
+
): DtmfReceivedPacket {
|
|
59
|
+
return { kind: "dtmf.received", contextId, timestampMs, digit, provider, rawDigit };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function metric(
|
|
63
|
+
contextId: string,
|
|
64
|
+
name: string,
|
|
65
|
+
value: string,
|
|
66
|
+
timestampMs: number = Date.now(),
|
|
67
|
+
): ConversationMetricPacket {
|
|
68
|
+
return { kind: "metric.conversation", contextId, timestampMs, name, value };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function turnBoundary(
|
|
72
|
+
contextId: string,
|
|
73
|
+
timestampMs: number,
|
|
74
|
+
fields: {
|
|
75
|
+
boundary: TurnBoundaryKind;
|
|
76
|
+
sessionId: string;
|
|
77
|
+
speechId: string;
|
|
78
|
+
monotonicMs: number;
|
|
79
|
+
provider?: string;
|
|
80
|
+
model?: string;
|
|
81
|
+
region?: string;
|
|
82
|
+
cancelled?: boolean;
|
|
83
|
+
requestId?: string;
|
|
84
|
+
},
|
|
85
|
+
): TurnBoundaryEventPacket {
|
|
86
|
+
return {
|
|
87
|
+
kind: "obs.turn_boundary",
|
|
88
|
+
contextId,
|
|
89
|
+
timestampMs,
|
|
90
|
+
boundary: fields.boundary,
|
|
91
|
+
sessionId: fields.sessionId,
|
|
92
|
+
speechId: fields.speechId,
|
|
93
|
+
monotonicMs: fields.monotonicMs,
|
|
94
|
+
provider: fields.provider,
|
|
95
|
+
model: fields.model,
|
|
96
|
+
region: fields.region,
|
|
97
|
+
cancelled: fields.cancelled,
|
|
98
|
+
requestId: fields.requestId,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function recordUserAudio(
|
|
103
|
+
contextId: string,
|
|
104
|
+
timestampMs: number,
|
|
105
|
+
audio: Uint8Array,
|
|
106
|
+
): RecordUserAudioPacket {
|
|
107
|
+
return { kind: "record.user_audio", contextId, timestampMs, audio };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function vadAudio(contextId: string, timestampMs: number, audio: Uint8Array): VadAudioPacket {
|
|
111
|
+
return { kind: "vad.audio", contextId, timestampMs, audio };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function sttAudio(contextId: string, timestampMs: number, audio: Uint8Array): SpeechToTextAudioPacket {
|
|
115
|
+
return { kind: "stt.audio", contextId, timestampMs, audio };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function eosAudio(contextId: string, timestampMs: number, audio: Uint8Array): EndOfSpeechAudioPacket {
|
|
119
|
+
return { kind: "eos.audio", contextId, timestampMs, audio };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function eosTurnComplete(
|
|
123
|
+
contextId: string,
|
|
124
|
+
timestampMs: number,
|
|
125
|
+
text: string,
|
|
126
|
+
transcripts: readonly SttResultPacket[],
|
|
127
|
+
): EndOfSpeechPacket {
|
|
128
|
+
return { kind: "eos.turn_complete", contextId, timestampMs, text, transcripts };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function userInput(
|
|
132
|
+
contextId: string,
|
|
133
|
+
timestampMs: number,
|
|
134
|
+
text: string,
|
|
135
|
+
language: string,
|
|
136
|
+
): UserInputPacket {
|
|
137
|
+
return { kind: "user.input", contextId, timestampMs, text, language };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function ttsText(contextId: string, timestampMs: number, text: string): TextToSpeechTextPacket {
|
|
141
|
+
return { kind: "tts.text", contextId, timestampMs, text };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function ttsDone(contextId: string, timestampMs: number, text: string): TextToSpeechDonePacket {
|
|
145
|
+
return { kind: "tts.done", contextId, timestampMs, text };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function recordAssistantAudio(
|
|
149
|
+
contextId: string,
|
|
150
|
+
timestampMs: number,
|
|
151
|
+
audio: Uint8Array,
|
|
152
|
+
sampleRateHz: number,
|
|
153
|
+
): RecordAssistantAudioDataPacket {
|
|
154
|
+
return { kind: "record.assistant_audio", contextId, timestampMs, audio, sampleRateHz, truncate: false };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function recordAssistantTruncate(
|
|
158
|
+
contextId: string,
|
|
159
|
+
timestampMs: number,
|
|
160
|
+
): RecordAssistantAudioTruncatePacket {
|
|
161
|
+
return { kind: "record.assistant_audio", contextId, timestampMs, audio: new Uint8Array(0), truncate: true };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function interruptDetected(
|
|
165
|
+
contextId: string,
|
|
166
|
+
timestampMs: number,
|
|
167
|
+
source: InterruptionSource,
|
|
168
|
+
): InterruptionDetectedPacket {
|
|
169
|
+
return { kind: "interrupt.detected", contextId, timestampMs, source };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function interruptTts(contextId: string, timestampMs: number): InterruptTtsPacket {
|
|
173
|
+
return { kind: "interrupt.tts", contextId, timestampMs };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function interruptLlm(contextId: string, timestampMs: number): InterruptLlmPacket {
|
|
177
|
+
return { kind: "interrupt.llm", contextId, timestampMs };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function interruptStt(contextId: string, timestampMs: number): InterruptSttPacket {
|
|
181
|
+
return { kind: "interrupt.stt", contextId, timestampMs };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function ttsError(
|
|
185
|
+
contextId: string,
|
|
186
|
+
timestampMs: number,
|
|
187
|
+
cause: Error,
|
|
188
|
+
category: ErrorCategory,
|
|
189
|
+
isRecoverable: boolean,
|
|
190
|
+
): TtsErrorPacket {
|
|
191
|
+
return { kind: "tts.error", contextId, timestampMs, component: "tts", category, cause, isRecoverable };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function injectMessage(contextId: string, timestampMs: number, text: string): InjectMessagePacket {
|
|
195
|
+
return { kind: "inject.message", contextId, timestampMs, text };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function llmDelta(contextId: string, timestampMs: number, text: string): LlmDeltaPacket {
|
|
199
|
+
return { kind: "llm.delta", contextId, timestampMs, text };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function llmDone(contextId: string, timestampMs: number, text: string): LlmResponseDonePacket {
|
|
203
|
+
return { kind: "llm.done", contextId, timestampMs, text };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function reasoningSuspended(
|
|
207
|
+
contextId: string,
|
|
208
|
+
timestampMs: number,
|
|
209
|
+
runId: string,
|
|
210
|
+
payload: unknown,
|
|
211
|
+
prompt?: string,
|
|
212
|
+
): ReasoningSuspendedPacket {
|
|
213
|
+
return { kind: "reasoning.suspended", contextId, timestampMs, runId, prompt, payload };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function reasoningResume(
|
|
217
|
+
contextId: string,
|
|
218
|
+
timestampMs: number,
|
|
219
|
+
runId: string,
|
|
220
|
+
data: unknown,
|
|
221
|
+
): ReasoningResumePacket {
|
|
222
|
+
return { kind: "reasoning.resume", contextId, timestampMs, runId, data };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function startIdleTimeout(contextId: string, timestampMs: number): StartIdleTimeoutPacket {
|
|
226
|
+
return { kind: "behavior.idle_timeout_start", contextId, timestampMs };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function stopIdleTimeout(
|
|
230
|
+
contextId: string,
|
|
231
|
+
timestampMs: number,
|
|
232
|
+
resetCount: boolean,
|
|
233
|
+
): StopIdleTimeoutPacket {
|
|
234
|
+
return { kind: "behavior.idle_timeout_stop", contextId, timestampMs, resetCount };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function modeSwitchRequested(
|
|
238
|
+
contextId: string,
|
|
239
|
+
timestampMs: number,
|
|
240
|
+
mode: "text" | "audio",
|
|
241
|
+
): ModeSwitchRequestedPacket {
|
|
242
|
+
return { kind: "mode.switch_requested", contextId, timestampMs, mode };
|
|
243
|
+
}
|