@framers/agentos-ext-endpoint-semantic 0.2.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.
@@ -0,0 +1,440 @@
1
+ /**
2
+ * @file SemanticEndpointDetector.ts
3
+ * @description IEndpointDetector implementation that combines the heuristic
4
+ * detector's punctuation and backchannel rules with LLM-based turn-completeness
5
+ * classification for ambiguous cases.
6
+ *
7
+ * Detection strategy (in priority order):
8
+ * 1. **Backchannel** — recognised phrases emitted as `backchannel_detected`
9
+ * without advancing the turn.
10
+ * 2. **Terminal punctuation** — immediate `turn_complete` with reason
11
+ * `'punctuation'`.
12
+ * 3. **LLM classification** — invoked after `minSilenceBeforeCheckMs` of
13
+ * silence; `COMPLETE` fires `turn_complete` with reason `'semantic_model'`.
14
+ * `INCOMPLETE` defers to the hard silence timeout.
15
+ * 4. **Silence timeout** — hard fallback after `silenceTimeoutMs`, reason
16
+ * `'silence_timeout'`.
17
+ *
18
+ * @module endpoint-semantic/SemanticEndpointDetector
19
+ */
20
+
21
+ import { EventEmitter } from 'node:events';
22
+ import { TurnCompletenessClassifier } from './TurnCompletenessClassifier.js';
23
+ import type { SemanticEndpointConfig } from './types.js';
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Local type mirror — avoids a hard runtime dep on @framers/agentos
27
+ // These match the shapes in packages/agentos/src/voice-pipeline/types.ts
28
+ // ---------------------------------------------------------------------------
29
+
30
+ /**
31
+ * Minimal VAD event shape required by this detector.
32
+ * Mirrors `VadEvent` from `@framers/agentos` voice-pipeline types.
33
+ */
34
+ interface VadEvent {
35
+ /** Type of the VAD transition. */
36
+ type: 'speech_start' | 'speech_end' | 'silence';
37
+ /** Unix epoch millisecond timestamp of the transition. */
38
+ timestamp: number;
39
+ /** Optional raw energy level (implementation-defined). */
40
+ energyLevel?: number;
41
+ /** Origin of the VAD event. */
42
+ source?: 'vad' | 'stt';
43
+ }
44
+
45
+ /**
46
+ * Minimal transcript event shape required by this detector.
47
+ * Mirrors `TranscriptEvent` from `@framers/agentos` voice-pipeline types.
48
+ */
49
+ interface TranscriptEvent {
50
+ /** Full transcript text for this event. */
51
+ text: string;
52
+ /** Aggregate confidence score in the range [0, 1]. */
53
+ confidence: number;
54
+ /** Word-level tokens (may be empty for interim events). */
55
+ words: unknown[];
56
+ /** `true` when this hypothesis is stable and will not be revised. */
57
+ isFinal: boolean;
58
+ /** Duration of the recognised speech segment, ms (final events only). */
59
+ durationMs?: number;
60
+ }
61
+
62
+ /**
63
+ * Semantic reason for turn completion.
64
+ * Mirrors `EndpointReason` from `@framers/agentos` voice-pipeline types.
65
+ */
66
+ type EndpointReason =
67
+ | 'silence_timeout'
68
+ | 'punctuation'
69
+ | 'syntax_complete'
70
+ | 'semantic_model'
71
+ | 'manual'
72
+ | 'timeout';
73
+
74
+ /**
75
+ * Payload emitted on `turn_complete`.
76
+ * Mirrors `TurnCompleteEvent` from `@framers/agentos` voice-pipeline types.
77
+ */
78
+ interface TurnCompleteEvent {
79
+ /** Final consolidated transcript for this turn. */
80
+ transcript: string;
81
+ /** Aggregate STT confidence in the range [0, 1]. */
82
+ confidence: number;
83
+ /** Total detected speech duration in milliseconds. */
84
+ durationMs: number;
85
+ /** The semantic reason that triggered turn completion. */
86
+ reason: EndpointReason;
87
+ }
88
+
89
+ /**
90
+ * Minimal IEndpointDetector interface required by this pack.
91
+ * Mirrors `IEndpointDetector` from `@framers/agentos` voice-pipeline types.
92
+ */
93
+ interface IEndpointDetector extends EventEmitter {
94
+ readonly mode: 'acoustic' | 'heuristic' | 'semantic';
95
+ pushVadEvent(event: VadEvent): void;
96
+ pushTranscript(event: TranscriptEvent): void;
97
+ reset(): void;
98
+ }
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // Constants
102
+ // ---------------------------------------------------------------------------
103
+
104
+ /** Default silence timeout (ms) — hard fallback if the LLM returns INCOMPLETE. */
105
+ const DEFAULT_SILENCE_TIMEOUT_MS = 1_500;
106
+
107
+ /** Default delay (ms) between speech_end and LLM invocation. */
108
+ const DEFAULT_MIN_SILENCE_BEFORE_CHECK_MS = 500;
109
+
110
+ /** Terminal punctuation that indicates sentence completion. */
111
+ const TERMINAL_PUNCTUATION = /[.?!]$/;
112
+
113
+ /**
114
+ * Normalised backchannel phrases that indicate acknowledgement without a full
115
+ * conversational turn. Compared after `.trim().toLowerCase()`.
116
+ */
117
+ const BACKCHANNEL_PHRASES = new Set([
118
+ 'uh huh',
119
+ 'yeah',
120
+ 'okay',
121
+ 'ok',
122
+ 'mm hmm',
123
+ 'mmhmm',
124
+ 'mhm',
125
+ 'mm-hmm',
126
+ 'right',
127
+ 'sure',
128
+ 'yep',
129
+ 'yup',
130
+ 'gotcha',
131
+ ]);
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // Implementation
135
+ // ---------------------------------------------------------------------------
136
+
137
+ /**
138
+ * Semantic endpoint detector that augments heuristic rules with LLM-based
139
+ * turn-completeness classification.
140
+ *
141
+ * ### Events emitted
142
+ * - `'turn_complete'` ({@link TurnCompleteEvent}) — user turn has ended.
143
+ * - `'backchannel_detected'` (`{ text: string }`) — backchannel phrase
144
+ * recognised; accumulation suppressed.
145
+ *
146
+ * @example
147
+ * ```ts
148
+ * const detector = new SemanticEndpointDetector(
149
+ * async (prompt) => callMyLLM(prompt),
150
+ * { minSilenceBeforeCheckMs: 600, silenceTimeoutMs: 2000 }
151
+ * );
152
+ * detector.on('turn_complete', (e) => console.log(e.reason));
153
+ * ```
154
+ */
155
+ export class SemanticEndpointDetector extends EventEmitter implements IEndpointDetector {
156
+ /** Active detection strategy label. */
157
+ readonly mode: IEndpointDetector['mode'] = 'semantic';
158
+
159
+ /** LLM-backed completeness classifier. */
160
+ private readonly classifier: TurnCompletenessClassifier;
161
+
162
+ /** Resolved silence timeout (ms) — hard fallback. */
163
+ private readonly silenceTimeoutMs: number;
164
+
165
+ /** Silence duration (ms) before invoking the LLM classifier. */
166
+ private readonly minSilenceBeforeCheckMs: number;
167
+
168
+ // ---------------------------------------------------------------------------
169
+ // Mutable state (reset on each turn)
170
+ // ---------------------------------------------------------------------------
171
+
172
+ /** Accumulated final transcript text for the current turn. */
173
+ private accumulatedText = '';
174
+
175
+ /** Whether VAD reports active speech. */
176
+ private speechActive = false;
177
+
178
+ /**
179
+ * Handle to the "check" timer — fires after `minSilenceBeforeCheckMs` and
180
+ * invokes the LLM classifier.
181
+ */
182
+ private checkTimer: ReturnType<typeof setTimeout> | null = null;
183
+
184
+ /**
185
+ * Handle to the hard silence-timeout timer — fires after `silenceTimeoutMs`.
186
+ */
187
+ private silenceTimer: ReturnType<typeof setTimeout> | null = null;
188
+
189
+ /** Wall-clock timestamp (ms) when speech began for the current turn. */
190
+ private turnStartMs: number | null = null;
191
+
192
+ /** Confidence score from the most recent final STT transcript. */
193
+ private lastConfidence = 1;
194
+
195
+ // ---------------------------------------------------------------------------
196
+ // Constructor
197
+ // ---------------------------------------------------------------------------
198
+
199
+ /**
200
+ * @param llmCall — Async function that sends a prompt to an LLM and resolves
201
+ * with the raw response text.
202
+ * @param config — Optional configuration overrides.
203
+ */
204
+ constructor(
205
+ llmCall: (prompt: string) => Promise<string>,
206
+ config: SemanticEndpointConfig = {},
207
+ ) {
208
+ super();
209
+
210
+ this.silenceTimeoutMs = config.silenceTimeoutMs ?? DEFAULT_SILENCE_TIMEOUT_MS;
211
+ this.minSilenceBeforeCheckMs =
212
+ config.minSilenceBeforeCheckMs ?? DEFAULT_MIN_SILENCE_BEFORE_CHECK_MS;
213
+
214
+ this.classifier = new TurnCompletenessClassifier(llmCall, config.timeoutMs);
215
+ }
216
+
217
+ // ---------------------------------------------------------------------------
218
+ // IEndpointDetector — pushTranscript
219
+ // ---------------------------------------------------------------------------
220
+
221
+ /**
222
+ * Ingest a transcript event from the upstream STT session.
223
+ *
224
+ * Only final events (`isFinal: true`) affect state. Interim results are
225
+ * silently ignored as they are unstable and arrive at high frequency.
226
+ *
227
+ * Recognised backchannel phrases are emitted as `'backchannel_detected'`
228
+ * without being accumulated, preventing a subsequent `speech_end` from
229
+ * triggering `turn_complete` for an acknowledgement.
230
+ *
231
+ * @param transcript — Transcript event from the upstream STT session.
232
+ */
233
+ pushTranscript(transcript: TranscriptEvent): void {
234
+ if (!transcript.isFinal) {
235
+ return;
236
+ }
237
+
238
+ const text = transcript.text;
239
+ const normalised = text.trim().toLowerCase();
240
+
241
+ if (BACKCHANNEL_PHRASES.has(normalised)) {
242
+ this.emit('backchannel_detected', { text });
243
+ return;
244
+ }
245
+
246
+ this.accumulatedText = text;
247
+ this.lastConfidence = transcript.confidence;
248
+ }
249
+
250
+ // ---------------------------------------------------------------------------
251
+ // IEndpointDetector — pushVadEvent
252
+ // ---------------------------------------------------------------------------
253
+
254
+ /**
255
+ * Ingest a VAD (voice activity detection) event.
256
+ *
257
+ * - `speech_start` — marks the turn as active, cancels pending timers.
258
+ * - `speech_end` — if text has been accumulated, either fires immediately
259
+ * on terminal punctuation, or schedules the LLM check
260
+ * timer + hard silence-timeout timer.
261
+ * - `silence` — periodic heartbeats; no action required.
262
+ *
263
+ * @param event — VAD transition event.
264
+ */
265
+ pushVadEvent(event: VadEvent): void {
266
+ switch (event.type) {
267
+ case 'speech_start': {
268
+ this.speechActive = true;
269
+ this._clearAllTimers();
270
+ if (this.turnStartMs === null) {
271
+ this.turnStartMs = event.timestamp;
272
+ }
273
+ break;
274
+ }
275
+
276
+ case 'speech_end': {
277
+ this.speechActive = false;
278
+
279
+ if (!this.accumulatedText) {
280
+ // No transcript available yet — nothing to evaluate.
281
+ break;
282
+ }
283
+
284
+ if (TERMINAL_PUNCTUATION.test(this.accumulatedText)) {
285
+ // Sentence-terminal punctuation: fire immediately, identical to the
286
+ // heuristic detector.
287
+ this._emitTurnComplete('punctuation', event.timestamp);
288
+ break;
289
+ }
290
+
291
+ // Ambiguous case: schedule the LLM check and the hard silence fallback.
292
+ this._startCheckTimer(event.timestamp);
293
+ this._startSilenceTimer(event.timestamp);
294
+ break;
295
+ }
296
+
297
+ case 'silence': {
298
+ // Periodic heartbeat — no action; timers already handle delayed firing.
299
+ break;
300
+ }
301
+ }
302
+ }
303
+
304
+ // ---------------------------------------------------------------------------
305
+ // IEndpointDetector — reset
306
+ // ---------------------------------------------------------------------------
307
+
308
+ /**
309
+ * Cancel all pending timers and clear accumulated state so the detector is
310
+ * ready for the next turn. Should be called by the pipeline after each
311
+ * `turn_complete` event.
312
+ */
313
+ reset(): void {
314
+ this._clearAllTimers();
315
+ this.accumulatedText = '';
316
+ this.speechActive = false;
317
+ this.turnStartMs = null;
318
+ this.lastConfidence = 1;
319
+ }
320
+
321
+ // ---------------------------------------------------------------------------
322
+ // Private helpers
323
+ // ---------------------------------------------------------------------------
324
+
325
+ /**
326
+ * Schedule the LLM completeness check after `minSilenceBeforeCheckMs`.
327
+ *
328
+ * If the LLM returns `COMPLETE`, `turn_complete` is emitted with reason
329
+ * `'semantic_model'` and both timers are cancelled. If the LLM returns
330
+ * `INCOMPLETE` or `TIMEOUT`, the check timer is done and the hard silence
331
+ * timer (already running) continues as the fallback.
332
+ *
333
+ * @param speechEndTimestamp — Timestamp of the triggering `speech_end` event.
334
+ */
335
+ private _startCheckTimer(speechEndTimestamp: number): void {
336
+ this._clearCheckTimer();
337
+
338
+ // Capture state at scheduling time so async re-entrancy cannot corrupt it.
339
+ const transcriptSnapshot = this.accumulatedText;
340
+
341
+ this.checkTimer = setTimeout(async () => {
342
+ this.checkTimer = null;
343
+
344
+ // Guard: if the user resumed speaking before this timer fired, bail out.
345
+ if (this.speechActive || this.accumulatedText !== transcriptSnapshot) {
346
+ return;
347
+ }
348
+
349
+ let result: Awaited<ReturnType<TurnCompletenessClassifier['classify']>>;
350
+
351
+ try {
352
+ result = await this.classifier.classify(transcriptSnapshot);
353
+ } catch {
354
+ // Unexpected classifier error — let the silence timer handle it.
355
+ return;
356
+ }
357
+
358
+ if (result === 'COMPLETE') {
359
+ // Cancel the hard silence timer — LLM beat it.
360
+ this._clearSilenceTimer();
361
+
362
+ // Another guard: state may have changed while we awaited the LLM.
363
+ if (this.accumulatedText === transcriptSnapshot && !this.speechActive) {
364
+ this._emitTurnComplete('semantic_model', speechEndTimestamp);
365
+ }
366
+ }
367
+ // INCOMPLETE or TIMEOUT → do nothing; the hard silence timer continues.
368
+ }, this.minSilenceBeforeCheckMs);
369
+ }
370
+
371
+ /**
372
+ * Start the hard silence-timeout timer. When it fires, `turn_complete` is
373
+ * emitted with reason `'silence_timeout'` regardless of the LLM result.
374
+ *
375
+ * @param speechEndTimestamp — Timestamp of the triggering `speech_end` event.
376
+ */
377
+ private _startSilenceTimer(speechEndTimestamp: number): void {
378
+ this._clearSilenceTimer();
379
+
380
+ this.silenceTimer = setTimeout(() => {
381
+ this.silenceTimer = null;
382
+ this._clearCheckTimer();
383
+ this._emitTurnComplete('silence_timeout', speechEndTimestamp);
384
+ }, this.silenceTimeoutMs);
385
+ }
386
+
387
+ /**
388
+ * Emit `turn_complete` with the currently accumulated transcript and reset
389
+ * internal state so the detector is clean for the next turn.
390
+ *
391
+ * State is reset **before** emitting so that re-entrant listeners see a
392
+ * clean detector.
393
+ *
394
+ * @param reason — Semantic reason driving turn completion.
395
+ * @param speechEndTimestamp — Timestamp used to compute `durationMs`.
396
+ */
397
+ private _emitTurnComplete(reason: EndpointReason, speechEndTimestamp: number): void {
398
+ const durationMs =
399
+ this.turnStartMs !== null ? speechEndTimestamp - this.turnStartMs : 0;
400
+
401
+ const event: TurnCompleteEvent = {
402
+ transcript: this.accumulatedText,
403
+ confidence: this.lastConfidence,
404
+ durationMs,
405
+ reason,
406
+ };
407
+
408
+ this.reset();
409
+
410
+ this.emit('turn_complete', event);
411
+ }
412
+
413
+ /**
414
+ * Cancel the LLM check timer without any other side effects.
415
+ */
416
+ private _clearCheckTimer(): void {
417
+ if (this.checkTimer !== null) {
418
+ clearTimeout(this.checkTimer);
419
+ this.checkTimer = null;
420
+ }
421
+ }
422
+
423
+ /**
424
+ * Cancel the hard silence timer without any other side effects.
425
+ */
426
+ private _clearSilenceTimer(): void {
427
+ if (this.silenceTimer !== null) {
428
+ clearTimeout(this.silenceTimer);
429
+ this.silenceTimer = null;
430
+ }
431
+ }
432
+
433
+ /**
434
+ * Cancel both the check timer and the silence timer.
435
+ */
436
+ private _clearAllTimers(): void {
437
+ this._clearCheckTimer();
438
+ this._clearSilenceTimer();
439
+ }
440
+ }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * @file TurnCompletenessClassifier.ts
3
+ * @description LLM-backed binary classifier that decides whether an utterance
4
+ * is a complete conversational turn (COMPLETE) or likely to continue
5
+ * (INCOMPLETE). Results are LRU-cached to avoid redundant LLM round-trips for
6
+ * repeated short phrases.
7
+ *
8
+ * @module endpoint-semantic/TurnCompletenessClassifier
9
+ */
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Public types
13
+ // ---------------------------------------------------------------------------
14
+
15
+ /**
16
+ * The three possible results from {@link TurnCompletenessClassifier.classify}.
17
+ *
18
+ * - `COMPLETE` — The utterance is a self-contained thought expecting a reply.
19
+ * - `INCOMPLETE` — The speaker is likely to continue before yielding the floor.
20
+ * - `TIMEOUT` — The LLM did not respond within the configured time budget;
21
+ * the caller should fall back to silence-timeout behaviour.
22
+ */
23
+ export type ClassifyResult = 'COMPLETE' | 'INCOMPLETE' | 'TIMEOUT';
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Implementation
27
+ // ---------------------------------------------------------------------------
28
+
29
+ /**
30
+ * Classifies a transcript as {@link ClassifyResult.COMPLETE} or
31
+ * {@link ClassifyResult.INCOMPLETE} using an LLM prompt.
32
+ *
33
+ * ### Caching
34
+ * Results are stored in a fixed-capacity LRU cache keyed on the first 100
35
+ * characters of the transcript. When the cache is full, the oldest entry is
36
+ * evicted (insertion-order eviction via `Map` iteration order, which is
37
+ * guaranteed by the ES2015 spec).
38
+ *
39
+ * ### Prompt format
40
+ * The LLM is asked to respond with exactly `COMPLETE` or `INCOMPLETE` as the
41
+ * first word, followed by one sentence of reasoning. Only the first word is
42
+ * examined — anything after the first space is treated as explanatory text and
43
+ * discarded.
44
+ *
45
+ * @example
46
+ * ```ts
47
+ * const classifier = new TurnCompletenessClassifier(
48
+ * async (prompt) => openai.chat.completions.create({ ... }).then(r => r.choices[0].message.content ?? ''),
49
+ * 400,
50
+ * );
51
+ * const result = await classifier.classify('Tell me about your', 'User: Hello\nAgent: Hi there!');
52
+ * // → 'INCOMPLETE' (sentence is unfinished)
53
+ * ```
54
+ */
55
+ export class TurnCompletenessClassifier {
56
+ /**
57
+ * LRU result cache. Keys are the first 100 chars of the transcript; values
58
+ * are the cached classification. `Map` preserves insertion order, so
59
+ * eviction iterates from the oldest entry (`.keys().next().value`).
60
+ */
61
+ private readonly cache = new Map<string, 'COMPLETE' | 'INCOMPLETE'>();
62
+
63
+ /**
64
+ * Maximum number of entries to keep in {@link cache} before evicting the
65
+ * oldest one.
66
+ */
67
+ private readonly maxCacheSize = 100;
68
+
69
+ /**
70
+ * @param llmCall — Async function that sends a prompt string to an LLM and
71
+ * resolves with the raw response text.
72
+ * @param timeoutMs — Maximum milliseconds to wait for the LLM before
73
+ * returning `TIMEOUT`. Defaults to 500 ms.
74
+ */
75
+ constructor(
76
+ private readonly llmCall: (prompt: string) => Promise<string>,
77
+ private readonly timeoutMs: number = 500,
78
+ ) {}
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // Public API
82
+ // ---------------------------------------------------------------------------
83
+
84
+ /**
85
+ * Classify `transcript` as `COMPLETE`, `INCOMPLETE`, or `TIMEOUT`.
86
+ *
87
+ * The result is cached on the first 100 characters of `transcript` so that
88
+ * repeated short phrases (common in voice interaction) never hit the LLM
89
+ * twice.
90
+ *
91
+ * @param transcript — The utterance text to evaluate.
92
+ * @param context — Optional string containing the last 2 conversation turns,
93
+ * formatted as `"Speaker: text\n..."`. When omitted, the
94
+ * prompt uses `'N/A'`.
95
+ * @returns A {@link ClassifyResult} resolving to `COMPLETE`, `INCOMPLETE`,
96
+ * or `TIMEOUT`.
97
+ */
98
+ async classify(transcript: string, context?: string): Promise<ClassifyResult> {
99
+ // -----------------------------------------------------------------------
100
+ // Cache lookup
101
+ // -----------------------------------------------------------------------
102
+ const cacheKey = transcript.slice(0, 100);
103
+
104
+ if (this.cache.has(cacheKey)) {
105
+ return this.cache.get(cacheKey)!;
106
+ }
107
+
108
+ // -----------------------------------------------------------------------
109
+ // Build prompt
110
+ // -----------------------------------------------------------------------
111
+ const prompt =
112
+ `Is the following utterance a complete thought that expects a response, ` +
113
+ `or is the speaker likely to continue?\n\n` +
114
+ `Context (last 2 turns):\n${context ?? 'N/A'}\n\n` +
115
+ `Current utterance: "${transcript}"\n\n` +
116
+ `Respond with exactly COMPLETE or INCOMPLETE, then one sentence of reasoning.`;
117
+
118
+ // -----------------------------------------------------------------------
119
+ // LLM call with timeout race
120
+ // -----------------------------------------------------------------------
121
+ let rawResult: string;
122
+
123
+ try {
124
+ rawResult = await Promise.race([
125
+ this.llmCall(prompt),
126
+ new Promise<never>((_, reject) =>
127
+ setTimeout(() => reject(new Error('timeout')), this.timeoutMs),
128
+ ),
129
+ ]);
130
+ } catch {
131
+ // Either the LLM threw or the timeout won — return TIMEOUT without
132
+ // caching so the next call re-attempts the LLM.
133
+ return 'TIMEOUT';
134
+ }
135
+
136
+ // -----------------------------------------------------------------------
137
+ // Parse response — only the first word matters
138
+ // -----------------------------------------------------------------------
139
+ const classification: 'COMPLETE' | 'INCOMPLETE' = rawResult.trim().startsWith('COMPLETE')
140
+ ? 'COMPLETE'
141
+ : 'INCOMPLETE';
142
+
143
+ // -----------------------------------------------------------------------
144
+ // LRU eviction + cache write
145
+ // -----------------------------------------------------------------------
146
+ if (this.cache.size >= this.maxCacheSize) {
147
+ // Map.keys() returns an iterator in insertion order; the first key is the
148
+ // oldest entry.
149
+ const firstKey = this.cache.keys().next().value as string;
150
+ this.cache.delete(firstKey);
151
+ }
152
+
153
+ this.cache.set(cacheKey, classification);
154
+
155
+ return classification;
156
+ }
157
+ }