@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.
Files changed (48) hide show
  1. package/README.md +4 -0
  2. package/package.json +22 -3
  3. package/src/audio/alaw.ts +56 -0
  4. package/src/audio/g722.ts +329 -0
  5. package/src/audio/index.ts +12 -0
  6. package/src/audio/loudness.ts +87 -0
  7. package/src/confidence-to-wait.ts +30 -0
  8. package/src/idle-timeout.ts +1 -0
  9. package/src/incremental-unit.ts +19 -0
  10. package/src/index.ts +101 -1
  11. package/src/interaction-coordinator.ts +240 -0
  12. package/src/interaction-policy.ts +116 -0
  13. package/src/iu-ledger.ts +110 -0
  14. package/src/observability-observer.ts +8 -4
  15. package/src/observability.ts +30 -1
  16. package/src/packet-factories.ts +137 -2
  17. package/src/packets.ts +140 -4
  18. package/src/plugin-contract.ts +41 -0
  19. package/src/policies/defer.ts +15 -0
  20. package/src/policies/rule-based.ts +155 -0
  21. package/src/pricing.ts +137 -0
  22. package/src/primary-speaker-gate.ts +23 -3
  23. package/src/reasoner-hedge.ts +216 -0
  24. package/src/reasoner-route.ts +113 -0
  25. package/src/reasoner.ts +28 -1
  26. package/src/spend-cap.ts +52 -0
  27. package/src/turn-arbiter.ts +45 -4
  28. package/src/voice-agent-session.ts +682 -53
  29. package/src/voice-text.ts +69 -0
  30. package/src/audio/audio.test.ts +0 -285
  31. package/src/audio-envelope.test.ts +0 -167
  32. package/src/error-handler.test.ts +0 -56
  33. package/src/latency-filler.test.ts +0 -62
  34. package/src/observability-observer.test.ts +0 -245
  35. package/src/observability.test.ts +0 -85
  36. package/src/packet-factories.test.ts +0 -34
  37. package/src/pipeline-bus.g10.test.ts +0 -145
  38. package/src/pipeline-bus.test.ts +0 -211
  39. package/src/primary-speaker-gate.test.ts +0 -150
  40. package/src/provider-fallback.test.ts +0 -87
  41. package/src/reasoner.test.ts +0 -69
  42. package/src/retry.test.ts +0 -83
  43. package/src/tts-playout-clock.test.ts +0 -125
  44. package/src/turn-arbiter.characterization.test.ts +0 -477
  45. package/src/turn-arbiter.test.ts +0 -567
  46. package/src/voice-agent-session.test.ts +0 -2879
  47. package/src/voice-text.test.ts +0 -94
  48. 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
- | { readonly type: "finish"; readonly reason: "stop" | "tool" | "length"; readonly text: string };
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
+ }
@@ -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
+ }
@@ -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.emitInterruptDetected(interruptedContextId);
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.emitInterruptDetected(pending.interruptedContextId);
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,