@kuralle-syrinx/core 4.1.0 → 4.3.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 +4 -0
- package/package.json +22 -3
- 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/confidence-to-wait.ts +30 -0
- package/src/idle-timeout.ts +1 -0
- package/src/incremental-unit.ts +19 -0
- package/src/index.ts +101 -1
- package/src/interaction-coordinator.ts +240 -0
- package/src/interaction-policy.ts +116 -0
- package/src/iu-ledger.ts +110 -0
- package/src/observability-observer.ts +8 -4
- package/src/observability.ts +30 -1
- package/src/packet-factories.ts +137 -2
- package/src/packets.ts +140 -4
- package/src/plugin-contract.ts +41 -0
- package/src/policies/defer.ts +15 -0
- package/src/policies/rule-based.ts +155 -0
- package/src/pricing.ts +137 -0
- package/src/primary-speaker-gate.ts +23 -3
- package/src/reasoner-hedge.ts +216 -0
- package/src/reasoner-route.ts +113 -0
- package/src/reasoner.ts +28 -1
- package/src/spend-cap.ts +52 -0
- package/src/turn-arbiter.ts +45 -4
- package/src/voice-agent-session.ts +682 -53
- 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/error-handler.test.ts +0 -56
- 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 -34
- package/src/pipeline-bus.g10.test.ts +0 -145
- package/src/pipeline-bus.test.ts +0 -211
- package/src/primary-speaker-gate.test.ts +0 -150
- package/src/provider-fallback.test.ts +0 -87
- package/src/reasoner.test.ts +0 -69
- package/src/retry.test.ts +0 -83
- 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 -2879
- package/src/voice-text.test.ts +0 -94
- package/tsconfig.json +0 -21
package/src/reasoner.ts
CHANGED
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
|
|
9
9
|
/** A reasoning backend reduced to one normalized pull-stream per turn. */
|
|
10
10
|
export interface Reasoner {
|
|
11
|
+
/** Add transient steering context consumed by the next turn, when supported. */
|
|
12
|
+
injectContext?(text: string): void;
|
|
13
|
+
|
|
11
14
|
/**
|
|
12
15
|
* Drive one reasoning turn. The returned async-iterable IS the response.
|
|
13
16
|
* Cancellation (barge-in) is via `turn.signal` (abort) — the adapter forwards
|
|
@@ -50,11 +53,35 @@ export type ReasoningPart =
|
|
|
50
53
|
| { readonly type: "text-delta"; readonly text: string }
|
|
51
54
|
| { readonly type: "tool-call"; readonly toolId: string; readonly toolName: string; readonly args: Record<string, unknown> }
|
|
52
55
|
| { readonly type: "tool-result"; readonly toolId: string; readonly toolName: string; readonly result: string }
|
|
56
|
+
| { readonly type: "control"; readonly name: string; readonly payload: unknown }
|
|
57
|
+
| { readonly type: "blocked"; readonly userFacingMessage: string; readonly payload?: unknown }
|
|
53
58
|
// Human-in-the-loop pause (step 3). ALWAYS the terminal part for the turn.
|
|
54
59
|
| { readonly type: "suspended"; readonly runId: string; readonly toolId?: string; readonly prompt?: string; readonly payload: unknown }
|
|
55
60
|
// (B1) Error/abort the backend surfaced. The bridge treats `error` like today's
|
|
56
61
|
// thrown TextStreamPart `error`/`tool-error`/`finish-step(error)`: it drives the
|
|
57
62
|
// retry/`llm.error` path. `recoverable` mirrors `categorizeLlmError`. ALWAYS terminal.
|
|
58
63
|
| { readonly type: "error"; readonly cause: Error; readonly recoverable: boolean }
|
|
59
|
-
| {
|
|
64
|
+
| {
|
|
65
|
+
readonly type: "finish";
|
|
66
|
+
readonly reason: "stop" | "tool" | "length";
|
|
67
|
+
readonly text: string;
|
|
68
|
+
/**
|
|
69
|
+
* Billable token usage for this turn, when the backend reports it. Optional —
|
|
70
|
+
* a reasoner that cannot report usage omits it, and the metering path treats a
|
|
71
|
+
* missing field as "unknown", never zero. Consumed into `usage.recorded`.
|
|
72
|
+
*/
|
|
73
|
+
readonly usage?: ReasonerUsage;
|
|
74
|
+
};
|
|
60
75
|
|
|
76
|
+
/** Token usage a reasoner may attach to its terminal `finish` part. */
|
|
77
|
+
export interface ReasonerUsage {
|
|
78
|
+
/** Provider slug (e.g. "openai"), for low-cardinality cost attribution. */
|
|
79
|
+
readonly provider?: string;
|
|
80
|
+
/** Model id (e.g. "gpt-4.1-mini"). Without this, spend cannot be attributed to a model. */
|
|
81
|
+
readonly model?: string;
|
|
82
|
+
readonly inputTokens?: number;
|
|
83
|
+
readonly outputTokens?: number;
|
|
84
|
+
readonly totalTokens?: number;
|
|
85
|
+
readonly cachedInputTokens?: number;
|
|
86
|
+
readonly reasoningTokens?: number;
|
|
87
|
+
}
|
package/src/spend-cap.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
//
|
|
3
|
+
// Pure spend accumulator + policy. Observe (record) and control (check) are
|
|
4
|
+
// separated so the same usage packet is never double-counted by the guard.
|
|
5
|
+
|
|
6
|
+
import type { UsageRecordedPacket } from "./packets.js";
|
|
7
|
+
import { costOf, DEFAULT_PRICE_CATALOG, type PriceCatalog } from "./pricing.js";
|
|
8
|
+
|
|
9
|
+
export interface SpendCapConfig {
|
|
10
|
+
readonly maxUsd: number;
|
|
11
|
+
readonly catalog?: PriceCatalog;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface SpendCapCheck {
|
|
15
|
+
readonly exceeded: boolean;
|
|
16
|
+
readonly spentUsd: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Accumulates priced `usage.recorded` into session spend and reports whether a
|
|
21
|
+
* configured USD cap has been breached. Does not call providers; the session
|
|
22
|
+
* layer decides refuse/fallback from `check()`.
|
|
23
|
+
*/
|
|
24
|
+
export class SpendCapGuard {
|
|
25
|
+
private readonly maxUsd: number;
|
|
26
|
+
private readonly catalog: PriceCatalog;
|
|
27
|
+
private spentUsd = 0;
|
|
28
|
+
private exceeded = false;
|
|
29
|
+
|
|
30
|
+
constructor(cfg: SpendCapConfig) {
|
|
31
|
+
if (!Number.isFinite(cfg.maxUsd) || cfg.maxUsd < 0) {
|
|
32
|
+
throw new Error(`SpendCapGuard maxUsd must be a non-negative finite number, got ${String(cfg.maxUsd)}`);
|
|
33
|
+
}
|
|
34
|
+
this.maxUsd = cfg.maxUsd;
|
|
35
|
+
this.catalog = cfg.catalog ?? DEFAULT_PRICE_CATALOG;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Observe one usage packet. Unpriced usage does not change spentUsd. */
|
|
39
|
+
record(usage: UsageRecordedPacket): void {
|
|
40
|
+
const result = costOf(usage, this.catalog);
|
|
41
|
+
if (result.usd === null) return;
|
|
42
|
+
this.spentUsd += result.usd;
|
|
43
|
+
if (this.spentUsd > this.maxUsd) {
|
|
44
|
+
this.exceeded = true;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Read-only policy view — never mutates spend. */
|
|
49
|
+
check(): SpendCapCheck {
|
|
50
|
+
return { exceeded: this.exceeded, spentUsd: this.spentUsd };
|
|
51
|
+
}
|
|
52
|
+
}
|
package/src/turn-arbiter.ts
CHANGED
|
@@ -59,17 +59,39 @@ export function isBackchannel(text: string): boolean {
|
|
|
59
59
|
return BACKCHANNELS.has(norm);
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
export function isAcknowledgement(text: string, phrases: readonly string[]): boolean {
|
|
63
|
+
const norm = text
|
|
64
|
+
.toLowerCase()
|
|
65
|
+
.replace(/[^a-z\s'-]/g, "")
|
|
66
|
+
.trim()
|
|
67
|
+
.replace(/\s+/g, " ");
|
|
68
|
+
if (!norm) return false;
|
|
69
|
+
return phrases.some((p) => {
|
|
70
|
+
const pNorm = p
|
|
71
|
+
.toLowerCase()
|
|
72
|
+
.replace(/[^a-z\s'-]/g, "")
|
|
73
|
+
.trim()
|
|
74
|
+
.replace(/\s+/g, " ");
|
|
75
|
+
return pNorm === norm;
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
62
79
|
export interface TurnArbiterDeps {
|
|
63
80
|
readonly bus: PipelineBus;
|
|
64
81
|
readonly primarySpeakerGate: PrimarySpeakerGate;
|
|
65
82
|
readonly ttsPlayout: TtsPlayoutClock;
|
|
66
83
|
readonly minInterruptionMs: number;
|
|
84
|
+
readonly onInterrupt?: (interruptedContextId: string, source: "vad") => void;
|
|
85
|
+
/** When true, emit `interaction.duck` while evaluating a barge-in during active TTS,
|
|
86
|
+
* and `interaction.resume` if the pending window resolves without an interrupt. */
|
|
87
|
+
readonly pauseThenResolveBargeIn?: boolean;
|
|
67
88
|
}
|
|
68
89
|
|
|
69
90
|
export class TurnArbiter {
|
|
70
91
|
private turnInterruption: TurnInterruptionState = { kind: "idle" };
|
|
71
92
|
private latestInterimText = "";
|
|
72
93
|
private latestInterimConfidence: number | null = null;
|
|
94
|
+
private ducked = false;
|
|
73
95
|
|
|
74
96
|
constructor(private readonly deps: TurnArbiterDeps) {}
|
|
75
97
|
|
|
@@ -92,7 +114,7 @@ export class TurnArbiter {
|
|
|
92
114
|
return;
|
|
93
115
|
}
|
|
94
116
|
bus.push(Route.Background, make.metric(interruptedContextId, "vaqi.interruption", "1"));
|
|
95
|
-
this.
|
|
117
|
+
this.decideInterrupt(interruptedContextId);
|
|
96
118
|
return;
|
|
97
119
|
}
|
|
98
120
|
|
|
@@ -129,7 +151,7 @@ export class TurnArbiter {
|
|
|
129
151
|
if (
|
|
130
152
|
this.deps.primarySpeakerGate.isEnabled() &&
|
|
131
153
|
this.deps.primarySpeakerGate.hasProfile() &&
|
|
132
|
-
!this.deps.primarySpeakerGate.shouldCommitBargeIn()
|
|
154
|
+
!this.deps.primarySpeakerGate.shouldCommitBargeIn(pending.userContextId)
|
|
133
155
|
) {
|
|
134
156
|
this.suppress(pending, "interrupt.suppressed_non_primary", durationMs);
|
|
135
157
|
} else {
|
|
@@ -165,6 +187,7 @@ export class TurnArbiter {
|
|
|
165
187
|
commitClientInterrupt(interruptedContextId: string): void {
|
|
166
188
|
if (!this.deps.ttsPlayout.isActive(interruptedContextId)) return;
|
|
167
189
|
this.turnInterruption = { kind: "idle" };
|
|
190
|
+
this.ducked = false;
|
|
168
191
|
this.deps.primarySpeakerGate.resetBargeInWindow();
|
|
169
192
|
this.deps.bus.push(Route.Background, make.metric(interruptedContextId, "interrupt.committed_after_ms", "0"));
|
|
170
193
|
this.deps.bus.push(Route.Background, make.metric(interruptedContextId, "vaqi.interruption", "1"));
|
|
@@ -176,6 +199,7 @@ export class TurnArbiter {
|
|
|
176
199
|
this.turnInterruption = { kind: "idle" };
|
|
177
200
|
this.latestInterimText = "";
|
|
178
201
|
this.latestInterimConfidence = null;
|
|
202
|
+
this.ducked = false;
|
|
179
203
|
}
|
|
180
204
|
|
|
181
205
|
private pendingFor(userContextId: string): PendingTurnInterruption | null {
|
|
@@ -202,6 +226,10 @@ export class TurnArbiter {
|
|
|
202
226
|
firstSpeechMs: pkt.timestampMs,
|
|
203
227
|
awaitingAudio,
|
|
204
228
|
};
|
|
229
|
+
if (this.deps.pauseThenResolveBargeIn && this.deps.ttsPlayout.isActive(interruptedContextId) && !this.ducked) {
|
|
230
|
+
this.deps.bus.push(Route.Main, make.interactionDuck(interruptedContextId, pkt.timestampMs));
|
|
231
|
+
this.ducked = true;
|
|
232
|
+
}
|
|
205
233
|
}
|
|
206
234
|
|
|
207
235
|
private setAwaitingAudio(awaitingAudio: boolean): void {
|
|
@@ -221,7 +249,7 @@ export class TurnArbiter {
|
|
|
221
249
|
if (nowMs - pending.firstSpeechMs < this.deps.minInterruptionMs) return;
|
|
222
250
|
|
|
223
251
|
const gate = this.deps.primarySpeakerGate;
|
|
224
|
-
if (gate.isEnabled() && gate.hasProfile() && !gate.shouldCommitBargeIn()) {
|
|
252
|
+
if (gate.isEnabled() && gate.hasProfile() && !gate.shouldCommitBargeIn(pending.userContextId)) {
|
|
225
253
|
this.suppress(pending, "interrupt.suppressed_non_primary", nowMs - pending.firstSpeechMs);
|
|
226
254
|
return;
|
|
227
255
|
}
|
|
@@ -237,6 +265,7 @@ export class TurnArbiter {
|
|
|
237
265
|
}
|
|
238
266
|
this.turnInterruption = { kind: "idle" };
|
|
239
267
|
gate.resetBargeInWindow();
|
|
268
|
+
this.ducked = false;
|
|
240
269
|
|
|
241
270
|
const { bus, ttsPlayout } = this.deps;
|
|
242
271
|
if (!ttsPlayout.isActive(pending.interruptedContextId)) {
|
|
@@ -256,11 +285,19 @@ export class TurnArbiter {
|
|
|
256
285
|
Route.Background,
|
|
257
286
|
make.metric(pending.interruptedContextId, "interrupt.latency_ms", String(sustainedMs)),
|
|
258
287
|
);
|
|
259
|
-
this.
|
|
288
|
+
this.decideInterrupt(pending.interruptedContextId);
|
|
260
289
|
this.latestInterimText = "";
|
|
261
290
|
this.latestInterimConfidence = null;
|
|
262
291
|
}
|
|
263
292
|
|
|
293
|
+
private decideInterrupt(interruptedContextId: string): void {
|
|
294
|
+
if (this.deps.onInterrupt) {
|
|
295
|
+
this.deps.onInterrupt(interruptedContextId, "vad");
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
this.emitInterruptDetected(interruptedContextId);
|
|
299
|
+
}
|
|
300
|
+
|
|
264
301
|
private suppress(
|
|
265
302
|
pending: PendingTurnInterruption,
|
|
266
303
|
metricName: string,
|
|
@@ -272,6 +309,10 @@ export class TurnArbiter {
|
|
|
272
309
|
} else {
|
|
273
310
|
this.turnInterruption = { kind: "idle" };
|
|
274
311
|
this.deps.primarySpeakerGate.resetBargeInWindow();
|
|
312
|
+
if (this.ducked) {
|
|
313
|
+
this.deps.bus.push(Route.Main, make.interactionResume(pending.interruptedContextId, Date.now()));
|
|
314
|
+
this.ducked = false;
|
|
315
|
+
}
|
|
275
316
|
}
|
|
276
317
|
this.deps.bus.push(
|
|
277
318
|
Route.Background,
|