@kuralle-syrinx/core 4.3.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kuralle-syrinx/core",
3
- "version": "4.3.0",
3
+ "version": "4.4.0",
4
4
  "private": false,
5
5
  "description": "Voice pipeline kernel for Syrinx — pipeline bus, turn arbitration, playout clock, packets, and the framework-agnostic Reasoner seam",
6
6
  "keywords": [
package/src/index.ts CHANGED
@@ -39,6 +39,8 @@ export {
39
39
  type EndOfSpeechPacket,
40
40
  type InterimEndOfSpeechPacket,
41
41
  type EndOfSpeechRetractedPacket,
42
+ type TurnEndOwner,
43
+ type TurnEndReason,
42
44
  type UserInputPacket,
43
45
  } from "./packets.js";
44
46
 
@@ -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.deps.bus.push(Route.Main, make.eosTurnComplete(contextId, Date.now(), finalText, transcripts));
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
 
@@ -29,6 +29,8 @@ import type {
29
29
  SpeechToTextAudioPacket,
30
30
  EndOfSpeechAudioPacket,
31
31
  EndOfSpeechPacket,
32
+ TurnEndOwner,
33
+ TurnEndReason,
32
34
  SttPartialPacket,
33
35
  SttResultPacket,
34
36
  FinalizeSttPacket,
@@ -220,8 +222,19 @@ export function eosTurnComplete(
220
222
  timestampMs: number,
221
223
  text: string,
222
224
  transcripts: readonly SttResultPacket[],
225
+ endpointing?: { readonly owner: TurnEndOwner; readonly reason: TurnEndReason },
223
226
  ): EndOfSpeechPacket {
224
- return { kind: "eos.turn_complete", contextId, timestampMs, text, transcripts };
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
+ };
225
238
  }
226
239
 
227
240
  export function finalizeStt(contextId: string, timestampMs: number): FinalizeSttPacket {
package/src/packets.ts CHANGED
@@ -10,7 +10,7 @@
10
10
  // Lifecycle: InitStepCompleted, InitFailed, InitCompleted
11
11
 
12
12
  import type { WordTiming } from "./interaction-policy.js";
13
- import type { SttReconfigurePartial } from "./plugin-contract.js";
13
+ import type { EndpointingOwner, SttReconfigurePartial } from "./plugin-contract.js";
14
14
 
15
15
  // =============================================================================
16
16
  // Base Types
@@ -220,11 +220,34 @@ export interface EndOfSpeechAudioPacket extends VoicePacket {
220
220
  readonly audio: Uint8Array;
221
221
  }
222
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
+
223
242
  export interface EndOfSpeechPacket extends VoicePacket {
224
243
  readonly kind: "eos.turn_complete";
225
244
  readonly text: string;
226
245
  /** All accumulated STT transcripts for this turn. */
227
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;
228
251
  }
229
252
 
230
253
  export interface InterimEndOfSpeechPacket extends VoicePacket {
@@ -81,6 +81,8 @@ export class VoiceSessionWatchdogs {
81
81
  private readonly scheduler: Scheduler;
82
82
  private sttForceFinalizeScheduled = false;
83
83
  private pendingSttContextId = "";
84
+ /** Contexts whose STT the force-finalize watchdog has already fired on this turn. */
85
+ private readonly forceFinalizedContexts = new Set<string>();
84
86
  private vaqiMissedResponseScheduled = false;
85
87
  private vaqiMissedResponseContextId = "";
86
88
  private vaqiMissedResponseStartMs = 0;
@@ -98,6 +100,7 @@ export class VoiceSessionWatchdogs {
98
100
  this.clearVaqiMissedResponseTimer();
99
101
  this.clearTtsStallTimer();
100
102
  this.clearInputCadenceWatchdog();
103
+ this.forceFinalizedContexts.clear();
101
104
  }
102
105
 
103
106
  scheduleSttForceFinalize(contextId: string): void {
@@ -109,11 +112,26 @@ export class VoiceSessionWatchdogs {
109
112
  this.sttForceFinalizeScheduled = true;
110
113
  this.scheduler.schedule("voice.watchdog.stt_force_finalize", this.deps.sttForceFinalizeTimeoutMs, () => {
111
114
  this.sttForceFinalizeScheduled = false;
115
+ this.forceFinalizedContexts.add(contextId);
116
+ // Broadcast the decision on the bus: the completing eos may come from the STT
117
+ // plugin (which cannot know the watchdog fired), so the metrics tracker reads
118
+ // this mark to label the turn rather than each emitter re-deriving it.
119
+ this.deps.bus.push(Route.Background, make.metric(contextId, "stt.force_finalized", "1"));
112
120
  const plugin = findForceFinalizableSttPlugin(this.deps.plugins);
113
121
  plugin?.forceFinalize(contextId);
114
122
  });
115
123
  }
116
124
 
125
+ /** True when the force-finalize watchdog has already fired for this context this turn. */
126
+ wasForceFinalized(contextId: string): boolean {
127
+ return this.forceFinalizedContexts.has(contextId);
128
+ }
129
+
130
+ /** Retire the flag once the turn has completed (read by the emitter + metrics tracker). */
131
+ clearForceFinalized(contextId: string): void {
132
+ this.forceFinalizedContexts.delete(contextId);
133
+ }
134
+
117
135
  clearSttForceFinalizeIfContext(contextId: string): void {
118
136
  if (this.pendingSttContextId === contextId) {
119
137
  this.clearSttForceFinalizeTimer();
@@ -511,6 +511,8 @@ export class VoiceAgentSession {
511
511
  onBackchannelEmitted: (contextId) => {
512
512
  this.timingFor(contextId).backchannelUsed = true;
513
513
  },
514
+ endpointingOwner: this.endpointingOwner,
515
+ wasForceFinalized: (contextId) => this.watchdogs.wasForceFinalized(contextId),
514
516
  });
515
517
  this.watchdogs = new VoiceSessionWatchdogs({
516
518
  bus: this.bus,
@@ -874,8 +876,16 @@ export class VoiceAgentSession {
874
876
  }
875
877
 
876
878
  private handleUserText(pkt: UserTextReceivedPacket): void {
877
- // Treat text input as an immediate EOS turn complete
878
- this.bus.push(Route.Main, make.eosTurnComplete(pkt.contextId, pkt.timestampMs, pkt.text, []));
879
+ // Typed input is an immediate turn end — but nothing endpointed it and no one
880
+ // decided the caller stopped talking, because they typed. Label it as `text` so
881
+ // the timeline never claims a speech endpointer fired on a typed turn.
882
+ this.bus.push(
883
+ Route.Main,
884
+ make.eosTurnComplete(pkt.contextId, pkt.timestampMs, pkt.text, [], {
885
+ owner: "text",
886
+ reason: "typed",
887
+ }),
888
+ );
879
889
  }
880
890
 
881
891
  private handleSttPartial(pkt: SttPartialPacket): void {
@@ -1179,6 +1189,10 @@ export class VoiceAgentSession {
1179
1189
  }
1180
1190
  this.lastFinalizedContextId = pkt.contextId;
1181
1191
  this.interaction.reset(pkt.contextId);
1192
+ // The force-finalize flag has now been read by whoever emitted this eos (and
1193
+ // captured by the metrics tracker via its metric) — retire it so the per-call
1194
+ // Set does not grow unbounded on a stable transport contextId.
1195
+ this.watchdogs.clearForceFinalized(pkt.contextId);
1182
1196
 
1183
1197
  // Re-arm per-turn guard state for the next turn. Transports with a stable
1184
1198
  // per-call contextId (telephony callSid) reuse one id across turns, so these