@chatpanel/events 0.24.0 → 0.28.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/voice-intents.js CHANGED
@@ -27,6 +27,14 @@
27
27
  // timestamps come from the host's own timezone via Date. That is the only environmental
28
28
  // input, and it is the one users would be astonished to see normalised away.
29
29
 
30
+ import {
31
+ defineSchema, describeSchema, responseFormat, coerce, createStructuredStream,
32
+ } from './structured.js';
33
+ // The same list meeting triggers read. "Has this person finished the thought?" must have ONE
34
+ // answer in this package — a second copy here would drift, and two features would disagree
35
+ // about the same caption on the same screen.
36
+ import { DANGLING_TAILS } from './schedule.js';
37
+
30
38
  export class VoiceIntentError extends Error {
31
39
  constructor(code, message) { super(message); this.name = 'VoiceIntentError'; this.code = code; }
32
40
  }
@@ -41,8 +49,12 @@ export const DEFAULT_WAKE = Object.freeze(['chatpanel']);
41
49
  // the same attempt.
42
50
  function slack(len) { return len <= 4 ? 0 : len <= 6 ? 1 : 2; }
43
51
 
44
- // The widest span of spoken tokens that may add up to one wake phrase ("chat" "pan" "ell").
52
+ // The widest span of spoken tokens that may add up to a ONE-WORD wake phrase ("chat" "pan"
53
+ // "ell"). A longer phrase widens its own window — see compileWake.
45
54
  const MAX_WAKE_TOKENS = 3;
55
+ // …but never without limit: the scan is O(tokens x window x phrases) over every utterance,
56
+ // and a wake phrase longer than this is a sentence, not a wake phrase.
57
+ const WAKE_TOKEN_CEILING = 8;
46
58
 
47
59
  // Bounded Levenshtein — returns early once the distance cannot come in under `max`, so a
48
60
  // wake scan over a long transcript stays linear in practice.
@@ -99,39 +111,475 @@ export function tokenize(text) {
99
111
  * squashed to letters so "chat panel", "ChatPanel" and "chat-panel" are one phrase.
100
112
  */
101
113
  export function compileWake(words = DEFAULT_WAKE) {
102
- const list = (Array.isArray(words) ? words : [words])
103
- .map((w) => String(w || '').toLowerCase().replace(/[^\p{L}\p{N}]/gu, ''))
104
- .filter((w) => w.length >= 3); // shorter than this and ordinary speech trips it constantly
114
+ // SEVERAL PHRASES, however they arrive. People do not say one fixed thing: "ok chatpanel",
115
+ // "okay chat panel" and "hey chatpanel" are one intent with three spellings, and asking
116
+ // someone to pick exactly one is asking them to remember which one they picked.
117
+ //
118
+ // A COMMA SEPARATES THEM — "chatpanel, siri, google" is what anyone would write, and any
119
+ // other separator is a rule to learn. The apparent conflict ("okay, chat panel" is also how
120
+ // you would write ONE phrase) is not a real one: matching strips punctuation from the
121
+ // TRANSCRIPT, so a comma is never needed inside a configured phrase to hear one spoken.
122
+ // Type "okay chat panel" and "okay, chat panel" is heard. `|`, `;` and newlines work too.
123
+ const raw = (Array.isArray(words) ? words : String(words ?? '').split(/[,|;\n]/))
124
+ .map((w) => String(w ?? '').trim())
125
+ .filter(Boolean);
126
+ const list = [];
127
+ // The phrases AS TYPED, kept alongside the squashed forms and in the same order. The
128
+ // squashed form is an implementation detail — "okchatpanel" is neither what the user wrote
129
+ // nor what they would say — so any UI that echoes the setting back must have the original
130
+ // to show. Deduped on the squashed form, keeping the first spelling of each.
131
+ const labels = [];
132
+ let widest = 1;
133
+ for (const phrase of raw) {
134
+ // Punctuation and spacing are stripped, so "ok chat panel", "ok, chat panel" and
135
+ // "okchatpanel" compile to one and the same thing — which is what makes the setting
136
+ // forgiving of how it was typed AND of how the transcriber spaced it.
137
+ const squashed = phrase.toLowerCase().replace(/[^\p{L}\p{N}]/gu, '');
138
+ if (squashed.length < 3) continue; // shorter than this and ordinary speech trips it constantly
139
+ if (list.includes(squashed)) continue;
140
+ list.push(squashed);
141
+ labels.push(phrase);
142
+ // The scan joins adjacent spoken tokens looking for the phrase, so its window has to be
143
+ // at least as wide as the longest phrase is in WORDS — otherwise a three-word wake phrase
144
+ // could never be found, however it was typed. +1 for a transcriber that splits one of
145
+ // them ("chat" "pan" "ell").
146
+ widest = Math.max(widest, phrase.split(/\s+/).filter(Boolean).length + 1);
147
+ }
105
148
  if (!list.length) throw new VoiceIntentError('BAD_WAKE', 'wake word must have at least 3 letters');
106
- return Object.freeze({ phrases: Object.freeze(list) });
149
+ return Object.freeze({
150
+ phrases: Object.freeze(list),
151
+ labels: Object.freeze(labels),
152
+ maxTokens: Math.min(Math.max(widest, MAX_WAKE_TOKENS), WAKE_TOKEN_CEILING),
153
+ });
107
154
  }
108
155
 
109
156
  /**
110
- * Find "<wake>, <command>" in one utterance.
157
+ * How much of what follows the wake word is the command.
111
158
  *
112
- * Returns the command with its ORIGINAL casing, plus which wake phrase matched and where —
113
- * the host logs the span so a user can see why something fired.
159
+ * THE 720-HOUR TIMER. One caption held six wake words and 420 characters, and the command was
160
+ * "everything after the first one to the end of the line". So "Okay, chat panel. Set a timer
161
+ * for 1 minute" swallowed four later sentences including "…research on the weather for the
162
+ * next 30 days" — and the duration parser, scanning the whole span, found 30 days. The user
163
+ * got a 720-hour timer from a request for one minute.
164
+ *
165
+ * A spoken command is a sentence, occasionally two ("Set a timer for 30 seconds. Make it
166
+ * two."). Never a paragraph. Bounded here, and bounded again by the next wake word: a second
167
+ * address is by definition the end of the first command.
114
168
  */
115
- export function findWakeCommand(text, wake = compileWake()) {
116
- const raw = String(text || '');
117
- const tokens = tokenize(raw);
118
- if (!tokens.length) return null;
169
+ export const MAX_COMMAND_SENTENCES = 2;
170
+
171
+ /** Where every wake phrase sits in this text, in order. Shared by the singular and plural. */
172
+ function wakeHits(raw, tokens, wake) {
173
+ const hits = [];
174
+ const window = wake.maxTokens || MAX_WAKE_TOKENS; // an older compiled wake has no maxTokens
119
175
  for (let i = 0; i < tokens.length; i++) {
120
176
  let squashed = '';
121
- for (let n = 0; n < MAX_WAKE_TOKENS && i + n < tokens.length; n++) {
177
+ let matched = false;
178
+ for (let n = 0; n < window && i + n < tokens.length && !matched; n++) {
122
179
  squashed += tokens[i + n].w.replace(/[^\p{L}\p{N}]/gu, '');
123
180
  for (const phrase of wake.phrases) {
124
181
  // A window far from the phrase's length cannot match; skip the distance work.
125
182
  if (Math.abs(squashed.length - phrase.length) > slack(phrase.length)) continue;
126
183
  if (editDistance(squashed, phrase, slack(phrase.length)) <= slack(phrase.length)) {
127
- const end = tokens[i + n].end;
128
- const command = stripLeadIn(raw.slice(end));
129
- return { command, wake: phrase, heard: raw.slice(tokens[i].start, end), at: tokens[i].start };
184
+ hits.push({
185
+ phrase,
186
+ start: tokens[i].start,
187
+ end: tokens[i + n].end,
188
+ addressed: isAddressed(raw, tokens, i, n),
189
+ });
190
+ // Skip past the phrase so "chat panel" is one hit, not two overlapping ones.
191
+ i += n;
192
+ matched = true;
193
+ break;
130
194
  }
131
195
  }
132
196
  }
133
197
  }
134
- return null;
198
+ return hits;
199
+ }
200
+
201
+ /**
202
+ * The first `max` sentences of `text`, or all of it when it has fewer.
203
+ *
204
+ * A boundary is terminal punctuation followed by WHITESPACE or the end — not any full stop.
205
+ * "Go to google.com and search" is one sentence; splitting on the dot in a domain cut a
206
+ * command down to "Go to google." and sent that. Decimals ("2.5 minutes") and initials break
207
+ * the same way.
208
+ */
209
+ function firstSentences(text, max) {
210
+ const t = String(text || '');
211
+ if (!t) return t;
212
+ const re = /[.!?…]+["'\u2019\u201d)\]]*(?=\s|$)/g;
213
+ let taken = 0;
214
+ let m;
215
+ while (taken < max && (m = re.exec(t))) {
216
+ taken += 1;
217
+ if (taken === max) return t.slice(0, m.index + m[0].length);
218
+ }
219
+ // Fewer sentences than asked for — all of it. A trailing fragment with no terminal
220
+ // punctuation is still what they said, and must not be silently emptied.
221
+ return t;
222
+ }
223
+
224
+ /**
225
+ * EVERY "<wake>, <command>" in one utterance, in order.
226
+ *
227
+ * A live caption often carries a whole minute of speech, and a person addressing an assistant
228
+ * addresses it more than once in a minute. Returning only the first match meant the other
229
+ * five requests in the same caption were invisible — and made the first command swallow them.
230
+ */
231
+ export function findWakeCommands(text, wake = compileWake(), { maxSentences = MAX_COMMAND_SENTENCES } = {}) {
232
+ const raw = String(text || '');
233
+ const tokens = tokenize(raw);
234
+ if (!tokens.length) return [];
235
+ const hits = wakeHits(raw, tokens, wake);
236
+ return hits.map((hit, idx) => {
237
+ // Bounded by the NEXT address, then by sentence count. A second wake word is the end of
238
+ // the first command however the sentences fall.
239
+ const stop = idx + 1 < hits.length ? hits[idx + 1].start : raw.length;
240
+ const span = raw.slice(hit.end, stop);
241
+ const command = trimTrailingLeadIn(stripLeadIn(firstSentences(span, maxSentences)).trim());
242
+ return {
243
+ command,
244
+ wake: hit.phrase,
245
+ heard: raw.slice(hit.start, hit.end),
246
+ at: hit.start,
247
+ addressed: hit.addressed,
248
+ // What was said after the command's own sentences, up to the next address. Not part of
249
+ // the command — kept so a caller refining with a model has the surrounding words.
250
+ rest: raw.slice(hit.end + span.indexOf(command) + command.length, stop).trim(),
251
+ };
252
+ });
253
+ }
254
+
255
+ /**
256
+ * Find "<wake>, <command>" in one utterance — the first one.
257
+ *
258
+ * Returns the command with its ORIGINAL casing, plus which wake phrase matched and where —
259
+ * the host logs the span so a user can see why something fired.
260
+ */
261
+ export function findWakeCommand(text, wake = compileWake(), opts = {}) {
262
+ return findWakeCommands(text, wake, opts)[0] || null;
263
+ }
264
+
265
+ /**
266
+ * The REQUEST inside a spoken utterance — not everything that followed the wake word.
267
+ *
268
+ * People do not stop talking when they finish asking. A real capture:
269
+ *
270
+ * "Okay, chat panel. Whenever I do anything or ask any question just to do a research for
271
+ * me and get me the answer, okay? All right, so. I want to know how is the weather in
272
+ * Fairview today? All right, so we will see. It does anything. Does it get added to?"
273
+ *
274
+ * Everything after the wake word became the job's name AND its prompt, so the job was a
275
+ * paragraph of thinking-aloud with a weather question buried in the middle — which is what
276
+ * "it didn't separate" means, and why the answer was useless even on the runs that happened.
277
+ *
278
+ * What this does, and deliberately no more: split into sentences, drop the ones that are pure
279
+ * filler, and prefer a QUESTION when one was asked (the LAST one — people circle back, and
280
+ * the restatement is the version they meant). Everything here is free, deterministic and
281
+ * reversible. Turning rambling into a good PROMPT is a model's job, and the parser already
282
+ * says so by returning `needsModel`; this is the FLOOR under that, for when no model is
283
+ * configured and for the instant before one answers. It is a heuristic over speech and it
284
+ * will sometimes pick the wrong sentence — that is precisely why the contract asks the host
285
+ * to pay for a model rather than pretending this is the answer.
286
+ */
287
+
288
+ // Sentences carrying no request — verbal punctuation, thinking aloud, or narrating the very
289
+ // experiment being run. Matched WHOLE, so "so we will see" goes and "see if the build passed"
290
+ // stays.
291
+ const FILLER_SENTENCE = new RegExp('^(?:'
292
+ + "ok(?:ay)?|all ?right|right|so|well|um+|uh+|hmm+|yeah|yep|hey|and|but|then|now"
293
+ + "|let(?:'s| us) see|we(?:'ll| will) see|so we(?:'ll| will) see"
294
+ + "|i think it is doing something|it does anything|does it (?:do )?anything"
295
+ + "|hold on(?: a second)?|one second|let me see|i think|i guess|here we go|there we go"
296
+ + "|test(?:ing)?"
297
+ + ')[\\s,.!?]*$', 'i');
298
+
299
+ const SENTENCE_SPLIT = /(?<=[.!?])\s+/;
300
+
301
+ /**
302
+ * True when a sentence is only filler — verbal punctuation rather than a request.
303
+ *
304
+ * Checked CLAUSE BY CLAUSE, because people string filler together with commas: "All right,
305
+ * so we will see." is two fillers in one sentence and matches neither whole. Every clause
306
+ * must be filler for the sentence to be, so "right after the demo, remind me" survives on the
307
+ * strength of its first clause even though the second would pass alone.
308
+ */
309
+ export function isFillerSentence(text) {
310
+ const t = String(text || '').trim().replace(/^[\s,.:;!?-]+/, '');
311
+ if (!t) return true;
312
+ if (FILLER_SENTENCE.test(t)) return true;
313
+ const clauses = t.split(',').map((c) => c.trim()).filter(Boolean);
314
+ return clauses.length > 1 && clauses.every((c) => FILLER_SENTENCE.test(c));
315
+ }
316
+
317
+ /**
318
+ * @returns { request, name, ambiguous } — the text to act on, a short label for it, and
319
+ * whether more than one question was asked (in which case `request` is everything
320
+ * meaningful and a model should be asked to pick). Both strings fall back to the
321
+ * cleaned original rather than to nothing: a command we could not parse is still a
322
+ * command the user gave, and dropping it silently is the worse failure.
323
+ */
324
+ export function refineSpokenCommand(text, { maxName = 48 } = {}) {
325
+ const raw = stripLeadIn(String(text || '')).trim();
326
+ if (!raw) return { request: '', name: '' };
327
+ const sentences = raw.split(SENTENCE_SPLIT).map((t) => t.trim()).filter(Boolean);
328
+ const meaningful = sentences.filter((t) => !isFillerSentence(t));
329
+ // ONE question is the request. SEVERAL is a guess, and this function refuses to make it.
330
+ //
331
+ // A real capture contained three: a standing preamble ("whenever I ask anything, research
332
+ // it for me, okay?"), the actual request ("how is the weather in Fairview today?") and a
333
+ // meta-question about the tool ("does it get added to?"). Last-wins picks the third,
334
+ // longest-wins picks the first, and every other rule that fits this sample is a rule fitted
335
+ // to this sample. Choosing between them needs to understand them — which is a model's job,
336
+ // and exactly what `needsModel` exists to ask for. So: an unambiguous question is used, and
337
+ // an ambiguous one is handed on WHOLE with `ambiguous` set, for the caller to refine.
338
+ const questions = meaningful.filter((t) => /\?\s*$/.test(t));
339
+ const ambiguous = questions.length > 1;
340
+ const picked = questions.length === 1
341
+ ? questions[0]
342
+ : (meaningful.length ? meaningful.join(' ') : raw);
343
+ const request = stripLeadIn(picked).trim() || raw;
344
+ // The name is a label in a list, not the instruction. One line, clipped on a word boundary.
345
+ const flat = request.replace(/\s+/g, ' ').trim();
346
+ const name = flat.length > maxName
347
+ ? `${flat.slice(0, maxName - 1).replace(/\s+\S*$/, '')}…`
348
+ : flat;
349
+ return { request, name: name || flat, ambiguous };
350
+ }
351
+
352
+ /**
353
+ * Ask a model what was actually being asked — the other half of `needsModel`.
354
+ *
355
+ * The parser has always returned `needsModel: true` for a command it did not recognise, with
356
+ * a comment saying the host "may pay for a small model to read it, and MUST NOT guess". No
357
+ * host ever did, so an unrecognised spoken request became a job whose name and prompt were
358
+ * both the entire utterance. refineSpokenCommand() is the free floor under this; when several
359
+ * questions were asked it declines to choose, and THIS is what chooses.
360
+ *
361
+ * Deliberately a tiny, single-shot classification with a strict output shape: it runs on a
362
+ * fast model while a meeting is happening, so it must cost about as much as one sentence.
363
+ */
364
+ /**
365
+ * The shape of the answer, declared ONCE.
366
+ *
367
+ * It used to be typed twice — as prose inside the prompt string and again as a list of enum
368
+ * values thirty lines below in the parser — with nothing making the two agree. Adding a kind
369
+ * to one and not the other is a silent, permanent bug: the model answers correctly and the
370
+ * parser maps it to "question" forever. Now the prompt is rendered from this and the parser
371
+ * coerces onto it, so there is only one place a field exists.
372
+ */
373
+ export const REFINEMENT_SCHEMA = defineSchema({
374
+ name: 'voice_refinement',
375
+ fields: {
376
+ request: {
377
+ type: 'string', required: true, max: 400,
378
+ describe: 'the one thing they actually want done, in their own words, one sentence',
379
+ },
380
+ name: { type: 'string', max: 48, describe: 'a label of at most 6 words' },
381
+ kind: {
382
+ type: 'enum',
383
+ values: ['question', 'monitor', 'note', 'skill', 'none'],
384
+ // An unknown kind becomes a QUESTION — the least surprising thing to do with something
385
+ // someone asked for, and the only kind that is undone by ignoring the answer. Guessing
386
+ // "monitor" instead would leave a card watching the meeting that nobody asked for.
387
+ default: 'question',
388
+ describe: 'the SMALLEST kind that does what they asked',
389
+ },
390
+ skill: { type: 'string', max: 80, describe: 'the skill name, only when kind is skill' },
391
+ },
392
+ // "none" is a real answer and the most important one to honour: it is how the model says
393
+ // "they were just talking", which is the case that produced junk jobs. It arrives two ways —
394
+ // as the whole reply, and as the value of `request` — and both are this.
395
+ nothing: { request: '', name: '', kind: 'none', skill: '' },
396
+ });
397
+
398
+ export function refinementPrompt(utterance) {
399
+ return [
400
+ 'A person spoke to their assistant during a meeting. Below is everything they said after',
401
+ 'the wake word, transcribed live — so it contains false starts, thinking aloud, and',
402
+ 'sometimes several questions where only one is the request.',
403
+ '',
404
+ describeSchema(REFINEMENT_SCHEMA),
405
+ '',
406
+ 'Pick the SMALLEST kind that does what they asked:',
407
+ ' question — answer it once, now. The DEFAULT for anything they want to know.',
408
+ ' monitor — only if they asked to be told as the meeting CONTINUES ("let me know if",',
409
+ ' "keep an eye on"). A one-off question is NOT a monitor.',
410
+ ' note — they asked for notes written down ("take notes on", "write that up").',
411
+ ' skill — they named a saved skill ("use the summarize skill"); put its name in `skill`.',
412
+ ' none — not asking for anything: thinking aloud, or talking ABOUT the assistant.',
413
+ 'Never invent a request that is not there — return "none". Keep `request` close to their',
414
+ 'words; do not answer it.',
415
+ '',
416
+ 'WHAT THEY SAID:',
417
+ String(utterance || ''),
418
+ ].join('\n');
419
+ }
420
+
421
+ /**
422
+ * The body fragment that makes a capable endpoint enforce the shape server-side.
423
+ * Null for an agent CLI, which has no such control — the prompt and the repair pass carry it.
424
+ */
425
+ export function refinementFormat(mode = 'schema') { return responseFormat(REFINEMENT_SCHEMA, { mode }); }
426
+
427
+ /**
428
+ * Read the model's answer back, defensively.
429
+ *
430
+ * Everything generic — code fences, a prose preamble, single quotes, a trailing comma, the
431
+ * word "none" in place of an object, a key spelled `Request` — is handled by the shared
432
+ * coercer, which means every OTHER structured call in the product gets those repairs too.
433
+ * What stays here is only what is true of THIS answer and no other.
434
+ *
435
+ * Returns null for anything unusable, so the caller falls back to the deterministic pass
436
+ * rather than acting on a hallucinated request.
437
+ */
438
+ export function parseRefinement(text) {
439
+ const got = coerce(text, REFINEMENT_SCHEMA);
440
+ if (!got) return null;
441
+ return settleRefinement(got.value);
442
+ }
443
+
444
+ /**
445
+ * The two rules that are about voice commands rather than about JSON.
446
+ *
447
+ * Shared with the streaming reader below, because a rule applied on the final answer and not
448
+ * on the partial one is a rule the user watches the UI break. Exported because a caller that
449
+ * fetches the answer through the generic structured-call capability gets the raw coerced
450
+ * object and still needs these — the rules must not live only inside one of two paths.
451
+ */
452
+ export function settleRefinement(v) {
453
+ if (!v) return null;
454
+ const request = String(v.request || '').trim();
455
+ if (v.kind === 'none' || !request) return { request: '', name: '', kind: 'none', skill: '' };
456
+ // A "skill" with no name is a question — there is nothing to run.
457
+ const kind = v.kind === 'skill' && !v.skill ? 'question' : v.kind;
458
+ return { request, name: String(v.name || '').trim() || request, kind, skill: v.skill || '' };
459
+ }
460
+
461
+ /**
462
+ * The same answer, AS IT ARRIVES.
463
+ *
464
+ * A refinement is asked for mid-meeting while someone is waiting to see whether they were
465
+ * heard, and the standing rule is that every model output streams with visible progress. The
466
+ * `settled` set is what makes that safe: `request` can be shown growing, and `kind` — which
467
+ * decides whether a monitor gets created — is only acted on once the model has closed it.
468
+ *
469
+ * const s = refinementStream({ onChange: (v, settled) => paint(v, settled) });
470
+ * await stream({ …, onDelta: (d) => s.push(d) });
471
+ * const final = s.end().value; // already settled, or null
472
+ */
473
+ export function refinementStream({ onChange = null } = {}) {
474
+ const inner = createStructuredStream(REFINEMENT_SCHEMA, {
475
+ onChange: onChange ? (v, settled) => onChange(settleRefinement(v), settled) : null,
476
+ });
477
+ const wrap = (snap) => ({ ...snap, value: settleRefinement(snap.value) });
478
+ return {
479
+ push: (chunk) => wrap(inner.push(chunk)),
480
+ end: () => wrap(inner.end()),
481
+ snapshot: () => wrap(inner.snapshot()),
482
+ reset: () => inner.reset(),
483
+ get text() { return inner.text; },
484
+ };
485
+ }
486
+
487
+ /**
488
+ * Was the assistant SPOKEN TO, or merely spoken about?
489
+ *
490
+ * "we should talk about the chat panel roadmap next week" contains the wake word and is not a
491
+ * command — acting on it is how ordinary conversation quietly set timers. But "Okay, chat
492
+ * panel. Anytime I ask a question…" is unmistakably addressed, and dropping it is why a
493
+ * clearly-spoken request did nothing at all.
494
+ *
495
+ * The signal that separates them is grammatical and cheap: a wake word used as a NOUN is
496
+ * introduced by a determiner or preposition ("the chat panel", "about ChatPanel", "our chat
497
+ * panel"). A wake word used as a VOCATIVE is at the start of what is being said, or follows
498
+ * an address word ("okay", "hey", "hi"), or follows the end of the previous sentence.
499
+ *
500
+ * Wrong sometimes, in both directions — which is exactly why it decides whether to ASK
501
+ * (needsModel, a visible and reversible monitor) rather than whether to act.
502
+ */
503
+ const NOUN_MARKERS = /^(?:the|a|an|our|your|their|my|this|that|these|those|about|on|in|of|with|via|using|called|named|to)$/i;
504
+
505
+ // Adverbs that sit between a subject and its verb — "chat panel ACTUALLY helps us".
506
+ const SUBJECT_ADVERBS = /^(?:actually|really|also|always|never|just|only|still|often|usually|basically|literally|probably|certainly|definitely|now|then|even|apparently|obviously)$/i;
507
+ // Verb forms that make whatever comes before them the SUBJECT of a claim rather than the
508
+ // person being spoken to. A closed list on purpose: the general rule ("any third-person verb")
509
+ // cannot be told from an imperative without a parser, and guessing wrong here costs a command.
510
+ const SUBJECT_VERBS = /^(?:is|isn't|was|wasn't|are|aren't|were|weren't|has|hasn't|have|had|does|doesn't|did|didn't|can|can't|cannot|could|couldn't|will|won't|would|wouldn't|should|shouldn't|shall|may|might|must|seems|helps|lets|gives|allows|works|looks|means|needs|wants|keeps|makes|shows|tells|comes|goes|takes|runs|becomes|provides|supports)$/i;
511
+ // …unless a pronoun follows, which turns the same auxiliary into a question aimed at us:
512
+ // "chatpanel, can YOU set a timer" against "chat panel can help us".
513
+ const QUESTION_PRONOUNS = /^(?:you|we|i|they|it|there|he|she)$/i;
514
+
515
+ const bare = (t) => String(t?.w || '').replace(/[^\p{L}\p{N}']/gu, '');
516
+
517
+ /**
518
+ * Is the wake phrase the SUBJECT of the sentence rather than the person being addressed?
519
+ *
520
+ * "…what chat panel actually helps us to monitor" is a sentence ABOUT the product, and it
521
+ * fired a request. Nothing in the words before the name says so — the giveaway is what comes
522
+ * after it: a vocative is followed by a comma, an imperative or a question word, while a
523
+ * subject is followed by its verb.
524
+ */
525
+ function readsAsSubject(raw, tokens, endIdx) {
526
+ const next = tokens[endIdx + 1];
527
+ if (!next) return false; // nothing after the name at all — not a claim about it
528
+ // Punctuation between the name and what follows is the vocative comma (or a sentence
529
+ // break). Either way the name stands alone, which subjects do not do.
530
+ if (/[.!?,;:–—-]/.test(raw.slice(tokens[endIdx].end, next.start))) return false;
531
+ let j = endIdx + 1;
532
+ if (SUBJECT_ADVERBS.test(bare(tokens[j])) && tokens[j + 1]) j += 1;
533
+ if (!SUBJECT_VERBS.test(bare(tokens[j]))) return false;
534
+ return !QUESTION_PRONOUNS.test(bare(tokens[j + 1]));
535
+ }
536
+
537
+ function isAddressed(raw, tokens, i, n = 0) {
538
+ // The fuzzy match is generous enough to SWALLOW a leading article: "a chat panel" squashes
539
+ // to "achatpanel", one edit from "chatpanel", so the determiner ends up inside the matched
540
+ // span instead of before it. Check the first matched token too, or "a chat panel would be
541
+ // useful here" reads as an address purely because the "a" was absorbed.
542
+ if (n > 0 && NOUN_MARKERS.test(String(tokens[i].w || '').replace(/[^\p{L}\p{N}]/gu, ''))) return false;
543
+ // Whatever came before it, a name followed by its own verb is being TALKED ABOUT.
544
+ if (readsAsSubject(raw, tokens, i + n)) return false;
545
+ if (i === 0) return true; // nothing before it — it opens the utterance
546
+ const prev = tokens[i - 1];
547
+ const word = String(prev.w || '').replace(/[^\p{L}\p{N}]/gu, '');
548
+ if (NOUN_MARKERS.test(word)) return false; // "the chat panel" — a thing, not a listener
549
+ // "hey chatpanel", "ok chatpanel" — an address word is how people open one.
550
+ if (/^(?:ok|okay|hey|hi|yo|hello|so|um|uh)$/i.test(word)) return true;
551
+ // Punctuation before it is the vocative comma or a sentence break — "…here. Okay, chat
552
+ // panel", "so I was thinking. ChatPanel, what did we decide?" — and both mean a fresh
553
+ // address rather than a continuing noun phrase. Measured from the previous token's START,
554
+ // because the tokenizer keeps trailing punctuation ON the token ("thinking."), so the gap
555
+ // between tokens is only the space and the full stop would be missed.
556
+ const gap = raw.slice(prev.start, tokens[i].start);
557
+ if (!/[.!?,;:]["'’”)\]]*\s*$/.test(gap)) return false;
558
+ // …but ONLY a break the speaker actually made. The transcriber invents full stops, and it
559
+ // invents them mid-clause: "…another round of testing to see what. Chat panel actually
560
+ // helps us to monitor" is one sentence about the product, cut in half by a machine, and the
561
+ // half-stop made the second half read as a fresh address. A break is only a break when the
562
+ // words before it are a finished thought — the same test that decides when a command has
563
+ // stopped growing, for exactly the same reason.
564
+ return !/[.!?…]/.test(gap) || commandLooksFinished(raw.slice(0, prev.end));
565
+ }
566
+
567
+ /**
568
+ * Drop the NEXT address's run-up from the end of this command.
569
+ *
570
+ * A command is bounded by where the next wake phrase starts — but people open an address with
571
+ * a word or two before the name ("…Okay, chat panel."), and those land on the end of the
572
+ * previous command. Harmless to read, ruinous to identity: as the caption grows, "start
573
+ * monitoring the pricing question." becomes "…pricing question. Okay", which is different
574
+ * words, a different key, and therefore the same request acted on twice.
575
+ *
576
+ * Only the address words are trimmed, and only from the end — the same short list that marks
577
+ * an opening in isAddressed().
578
+ */
579
+ function trimTrailingLeadIn(text) {
580
+ return String(text)
581
+ .replace(/(?:[\s,.:;!?-]*\b(?:ok|okay|hey|hi|yo|hello|so|um+|uh+|and|then|now)\b)+[\s,.:;!?-]*$/i, '')
582
+ .trim();
135
583
  }
136
584
 
137
585
  // "chatpanel, could you please set a timer" — politeness is not part of the command, and
@@ -590,13 +1038,94 @@ export function parseCommand(text, { wake = compileWake(), intents = defaultVoic
590
1038
  if (!found) return null;
591
1039
  const parsed = intents.parse(found.command, { now });
592
1040
  if (!parsed) return null;
593
- return { ...parsed, wake: found.wake, heard: found.heard, at: found.at };
1041
+ return { ...parsed, wake: found.wake, heard: found.heard, at: found.at, addressed: found.addressed !== false };
594
1042
  }
595
1043
 
596
1044
  // ---------------------------------------------------------------------------
597
1045
  // Transcript → commands
598
1046
  // ---------------------------------------------------------------------------
599
1047
 
1048
+ /**
1049
+ * Has the speaker finished the thought? — and PUNCTUATION IS NOT THE EVIDENCE.
1050
+ *
1051
+ * This used to be `endsSentence`: a full stop at the end of the caption meant the speaker had
1052
+ * stopped. Live caption engines punctuate as they go, and they punctuate FRAGMENTS. One
1053
+ * capture of this feature in use produced, in order: "Take.", "Take the question and ask
1054
+ * the.", "set a timer for.", "let's summar." — four full stops nobody uttered, and four
1055
+ * half-sentences sent to a model as requests while the speaker was still saying the rest.
1056
+ *
1057
+ * So the full stop is thrown away and the LAST WORD is read instead. A command ending on a
1058
+ * preposition, an article, a conjunction or an auxiliary ("…ask the", "…a timer for") is
1059
+ * someone mid-thought, however the transcriber punctuated it.
1060
+ *
1061
+ * A HINT, NOT A VERDICT. "Tell me what that is" is a real request that ends on 'is', so a
1062
+ * dangling tail must never DISCARD a command — it only makes the gate below wait longer for
1063
+ * the rest to arrive. A command that is never acted on is the worse failure of the two.
1064
+ */
1065
+ // Words that can end a sentence grammatically but in speech mean the qualifier is still being
1066
+ // chosen — "summarize the last 30 seconds, like, maybe…".
1067
+ const TRAILING_HEDGES = new Set(['maybe', 'perhaps', 'probably', 'basically', 'roughly', 'kinda', 'sorta']);
1068
+
1069
+ export function commandLooksFinished(text) {
1070
+ const t = String(text || '').trim().replace(/[\s.,;:!?…'"’”)\]-]+$/u, '');
1071
+ if (!t) return false;
1072
+ const tokens = t.toLowerCase().match(/[\p{L}\p{N}']+/gu) || [];
1073
+ const last = tokens[tokens.length - 1];
1074
+ if (!last) return false;
1075
+ return !DANGLING_TAILS.has(last) && !TRAILING_HEDGES.has(last);
1076
+ }
1077
+
1078
+ // Terminal punctuation. Worth almost nothing on its own — see above — but it is the only
1079
+ // signal a caller with no gate has, so the ungated path keeps asking for it ON TOP of the
1080
+ // tail test rather than getting looser than it was.
1081
+ const endsSentence = (text) => /[.!?…]["'’”)\]]*\s*$/.test(String(text || '').trim());
1082
+
1083
+ /**
1084
+ * The SHORTEST span that parses wins.
1085
+ *
1086
+ * "Set a timer for 30 seconds. And then that should actually set a timer for 30 seconds."
1087
+ * is one request said twice, and the duration parser sums what it finds across the span —
1088
+ * so a two-sentence window turned 30 seconds into 60 and produced a one-minute timer nobody
1089
+ * asked for. Worse, it moved: as the caption grew, the same words re-parsed to a different
1090
+ * duration, which is a different dedupe key, which is another timer. That is the "why is it
1091
+ * creating timers again and again" report.
1092
+ *
1093
+ * So the first sentence is tried alone, and the wider span only when it yields nothing. A
1094
+ * request that genuinely needs two ("Set a timer. Make it five minutes.") still gets them.
1095
+ */
1096
+ function parseShortest(command, intents, now) {
1097
+ const first = firstSentences(command, 1).trim();
1098
+ if (first && first !== command) {
1099
+ const narrow = intents.parse(first, { now });
1100
+ if (narrow?.intent) return narrow;
1101
+ }
1102
+ return intents.parse(command, { now });
1103
+ }
1104
+
1105
+ /**
1106
+ * A command's words, reduced to what survives re-transcription.
1107
+ *
1108
+ * Case and spacing vary between flushes of the same sentence, and punctuation appears and
1109
+ * disappears as the engine revises — so none of them may be part of an identity that is
1110
+ * supposed to say "you have already done this".
1111
+ */
1112
+ export const gistText = (text) => String(text || '')
1113
+ .toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim().slice(0, 160);
1114
+
1115
+ /**
1116
+ * The OPENING of a request — a looser identity than its full words.
1117
+ *
1118
+ * A transcriber does not only append; it REVISES. "How is the weather in Seattle, Washington
1119
+ * now?" became "How is the weather in Seattle, Washington?" on a later flush — different
1120
+ * words, a different key, and the same question asked twice. The first few words are what a
1121
+ * revision leaves alone.
1122
+ *
1123
+ * Six is chosen against the failure it exists to stop: long enough that two genuinely
1124
+ * different requests rarely share an opening, short enough to survive the tail being rewritten.
1125
+ */
1126
+ export const OPENING_WORDS = 6;
1127
+ export const gistOpening = (text) => gistText(text).split(' ').slice(0, OPENING_WORDS).join(' ');
1128
+
600
1129
  /** How many commands one transcript delta may produce. */
601
1130
  export const MAX_COMMANDS_PER_DELTA = 3;
602
1131
 
@@ -618,27 +1147,66 @@ export const MAX_COMMANDS_PER_DELTA = 3;
618
1147
  */
619
1148
  export function commandsFromSegments(segments, {
620
1149
  wake = compileWake(), intents = defaultVoiceIntents(), isSelf = null,
621
- sinceTs = 0, now = Date.now(), meetingId = '', max = MAX_COMMANDS_PER_DELTA,
1150
+ sinceTs = 0, now = Date.now(), meetingId = '', max = MAX_COMMANDS_PER_DELTA, gate = null,
622
1151
  } = {}) {
623
1152
  const out = [];
624
1153
  for (const seg of segments || []) {
625
1154
  if (!seg || !seg.text) continue;
626
1155
  if (seg.t && seg.t <= sinceTs) continue;
627
- const parsed = parseCommand(seg.text, { wake, intents, now });
1156
+ // EVERY address in this caption, not just the first. A live caption carries a minute of
1157
+ // speech, and a person addressing an assistant addresses it more than once in a minute —
1158
+ // "…set a timer for 1 minute. Okay chat panel, go and search…" is two commands. Taking
1159
+ // only the first also made that first command swallow the rest, which is how a one-minute
1160
+ // timer became 720 hours: the duration parser found "30 days" four sentences later.
1161
+ for (const found of findWakeCommands(seg.text, wake)) {
1162
+ if (!found.command) continue; // a bare mention with nothing after it
1163
+ const intent = parseShortest(found.command, intents, now);
1164
+ // `rest` rides along: the deterministic intents parse only the tight span (that is what
1165
+ // stops a duration being found four sentences away), but a MODEL asked to read an
1166
+ // unrecognised request should see the words around it — the question often follows a
1167
+ // sentence of preamble. Parse narrow, refine wide.
1168
+ const parsed = intent && {
1169
+ ...intent, wake: found.wake, heard: found.heard, at: found.at,
1170
+ addressed: found.addressed, rest: found.rest,
1171
+ };
628
1172
  if (!parsed) continue;
629
- // NO INTENT, NO ACTION. parseCommand returns a shape for anything that carries the wake
630
- // word and a time-ish phrase, intent included or not — so "we should talk about the chat
631
- // panel roadmap next week" came back as a command with intent:null and the caller acted
632
- // on it anyway. In a live meeting that means ordinary conversation quietly sets timers,
633
- // which is what happened: a caption grows, keeps matching, and fires again.
1173
+ // MENTIONED vs ADDRESSED — and this guard used to conflate them.
1174
+ //
1175
+ // parseCommand returns a shape for anything carrying the wake word, intent or not, so
1176
+ // "we should talk about the chat panel roadmap next week" came back as a command and the
1177
+ // caller acted on it: ordinary conversation quietly setting timers. Dropping every
1178
+ // intentless utterance fixed that, and broke the opposite case just as badly — "Okay,
1179
+ // chat panel. How is the weather in Lakeside?" is unmistakably a request, matches no
1180
+ // built-in intent (there is no weather intent, and there should not be), and was
1181
+ // discarded here. Nothing downstream ever saw it, which is why `needsModel` had no
1182
+ // handler: it could not reach one.
634
1183
  //
635
- // An automation that runs when it did not understand the request is worse than one that
636
- // does nothing, so an unrecognised utterance stops here.
637
- if (!parsed.intent) continue;
1184
+ // So the test is whether the assistant was SPOKEN TO. A passing mention still stops here.
1185
+ // An address with no matching intent goes on with needsModel set, for a model to read —
1186
+ // which is what the parser has always said should happen.
1187
+ //
1188
+ if (!parsed.intent && !parsed.addressed) continue;
1189
+ // Does this read as a whole thought? Computed once, where the command's own words are:
1190
+ // the guard below and the gate must never be able to answer that differently.
1191
+ const finished = commandLooksFinished(found.command);
1192
+ // WHETHER THE SENTENCE HAS ENDED IS NOT DECIDED HERE — when there is a gate.
1193
+ //
1194
+ // It used to be decided here, on the caption's terminal punctuation, and that is exactly
1195
+ // what sent "Take the question and ask the." to a model as a request. This function sees
1196
+ // ONE delivery of a caption; only something watching the same utterance across
1197
+ // deliveries can tell a finished sentence from a punctuated fragment, and that is the
1198
+ // gate below (`finished` is the hint it reads).
1199
+ //
1200
+ // A caller with no gate has no such thing, so it keeps the old conservative rule and
1201
+ // gains the tail test on top of it: both, or the request waits for the next delivery.
1202
+ // Getting LOOSER than the code being fixed would be a strange way to fix it.
1203
+ if (!parsed.intent && !gate && !(finished && endsSentence(seg.text))) continue;
638
1204
  const allowed = isSelf ? !!isSelf(seg.speaker) : false;
639
1205
  out.push({
640
1206
  ...parsed,
641
1207
  allowed,
1208
+ // Carried, so the gate reads the same answer this scan did.
1209
+ finished,
642
1210
  speaker: seg.speaker || '',
643
1211
  t: seg.t || now,
644
1212
  meetingId,
@@ -656,9 +1224,180 @@ export function commandsFromSegments(segments, {
656
1224
  // one spoken request look like a new request on every update, and a single "set a timer
657
1225
  // for 30 seconds" became a screenful of timers. `sid` is assigned once per utterance and
658
1226
  // never moves, so the same sentence keeps one key however many times it is rescanned.
659
- key: `voice:${meetingId}:${seg.sid || seg.t || 0}:${parsed.intent || 'unknown'}:${parsed.ms ?? parsed.when ?? ''}`,
1227
+ // WHAT WAS ASKED, not which delivery of it carried the words.
1228
+ //
1229
+ // This used to key on the caption's identity (`sid`) and the wake word's offset. Both
1230
+ // move: `sid` is re-minted whenever the caption engine loses the overlap between a
1231
+ // growing line and the one before it, and a monologue keeps ONE entry alive for
1232
+ // minutes — re-scanned on every flush, by design, so a half-heard command gets a
1233
+ // second chance. So one spoken "set a timer for 30 seconds" kept arriving as a
1234
+ // brand-new command and kept creating timers.
1235
+ //
1236
+ // The words are what does not move. Two different commands in one breath still differ;
1237
+ // the same command through fifty flushes is one request.
1238
+ // The OPENING, not the whole sentence: a live caption keeps growing ("…10 seconds",
1239
+ // then "…10 seconds and then", then "…and then we moved on"), and gistText over the
1240
+ // full text moves with every one of those — which is the very bug this key exists to
1241
+ // stop, just later in the sentence. The intent and its resolved duration are already
1242
+ // in the key, so two genuinely different commands still differ.
1243
+ key: `voice:${meetingId}:${parsed.intent || 'ask'}:${parsed.ms ?? parsed.when ?? ''}:${gistOpening(found.command)}`,
660
1244
  });
661
- if (out.length >= max) break; // a pathological transcript cannot fire fifty actions
1245
+ }
662
1246
  }
663
- return out;
1247
+ // THE NEWEST, not the first.
1248
+ //
1249
+ // The cap exists so a pathological transcript cannot fire fifty actions, and it used to
1250
+ // stop scanning once it had `max` — counting from the START of the caption. A caption entry
1251
+ // in a monologue lives for minutes and is re-scanned on every flush, accumulating every
1252
+ // address spoken into it, so the first three (long since acted on) consumed the whole
1253
+ // budget and everything said AFTER them was never returned at all. The request you just
1254
+ // made was the one thrown away.
1255
+ //
1256
+ // The newest are both the most likely to be fresh and the ones a person is waiting on, so
1257
+ // the cap keeps those. The already-acted ones are dropped downstream by the dedupe anyway.
1258
+ const found = out.length > max ? out.slice(-max) : out;
1259
+ // WITH A GATE, nothing is returned until the words stop moving — see createUtteranceGate.
1260
+ // Without one the old behaviour stands, so a host that has not adopted it (or a test asking
1261
+ // what the grammar sees) is unchanged.
1262
+ return gate ? gate.offer(found, now).due(now) : found;
1263
+ }
1264
+
1265
+ // ---------------------------------------------------------------------------
1266
+ // One utterance, one action
1267
+ // ---------------------------------------------------------------------------
1268
+
1269
+ /**
1270
+ * How long a command's words must stop changing before it is acted on.
1271
+ *
1272
+ * LONGER THAN THE CAPTURE'S FLUSH INTERVAL, and that is the whole calculation. Captions reach
1273
+ * a client in batches — the extension debounces its flush by 4s — so "these words have not
1274
+ * changed for 1s" says nothing except that no batch arrived in the last second. Only a wait
1275
+ * that outlasts a flush can distinguish "they stopped talking" from "we have not been told
1276
+ * what they said next".
1277
+ */
1278
+ export const UTTERANCE_SETTLE_MS = 5_000;
1279
+
1280
+ /**
1281
+ * How long a command that ends MID-THOUGHT waits instead.
1282
+ *
1283
+ * "Set a timer for" is not a request yet; the duration is in the breath after it. Waiting the
1284
+ * ordinary window and acting on it is exactly the bug this file exists to stop. But the tail
1285
+ * test is a word list, not grammar, and "tell me what that is" ends on 'is' — so a dangling
1286
+ * command is DELAYED, never dropped. If the speaker really did stop there, it still runs.
1287
+ */
1288
+ export const UTTERANCE_DANGLING_MS = 12_000;
1289
+
1290
+ // How long an utterance is remembered after it was last heard. A caption entry in a monologue
1291
+ // is re-delivered for minutes, and every one of those redeliveries has to find the record
1292
+ // saying "this one is done" — that record IS the one-utterance-one-action guarantee.
1293
+ const UTTERANCE_FORGET_MS = 3 * 60_000;
1294
+ // A cap, because a long meeting must not grow this without bound. Small: only utterances
1295
+ // still in flight or recently acted on matter, and the caller has its own longer-lived record
1296
+ // of what has been done.
1297
+ const UTTERANCE_TRACKED_MAX = 24;
1298
+
1299
+ /**
1300
+ * Is this the same spoken request as that one, a moment later?
1301
+ *
1302
+ * Two relations, because a transcriber does both. It APPENDS — "let's summar" becomes "let's
1303
+ * summarize the notes" — which is a prefix. And it REVISES — "…in Seattle, Washington now?"
1304
+ * came back as "…in Seattle, Washington?" — which is not, but keeps the opening.
1305
+ */
1306
+ export function sameUtterance(a, b) {
1307
+ if (!a || !b) return false;
1308
+ if (a === b || a.startsWith(b) || b.startsWith(a)) return true;
1309
+ const oa = a.split(' ').slice(0, OPENING_WORDS);
1310
+ const ob = b.split(' ').slice(0, OPENING_WORDS);
1311
+ // A short opening is not enough evidence: "set a timer" opens half the commands ever
1312
+ // spoken, and collapsing two of them into one utterance loses the second silently.
1313
+ return oa.length >= OPENING_WORDS && oa.join(' ') === ob.join(' ');
1314
+ }
1315
+
1316
+ /**
1317
+ * The thing that makes a growing caption ONE request.
1318
+ *
1319
+ * THE BUG THIS IS. A live caption is re-delivered as it grows, and every delivery was acted
1320
+ * on the moment it parsed. So a single spoken "Okay ChatPanel, set a timer for 30 seconds"
1321
+ * arrived first as "set a timer for." — no duration, no intent, addressed, punctuated by the
1322
+ * transcriber — and went to the model as a QUESTION; then, seconds later, arrived whole and
1323
+ * became a TIMER. One thing said, two things done, and the user reasonably reported it as
1324
+ * "it set the timer but it also sent a message". Nothing downstream could relate the two:
1325
+ * they had different words, different intents, and therefore different dedupe keys.
1326
+ *
1327
+ * They can only be related by watching them, so this is the one stateful thing in the file —
1328
+ * and it is still clock-free (`now` is passed in, like everywhere else) and platform-free.
1329
+ * The host owns exactly two things: keeping the object, and calling back when the wait is up.
1330
+ *
1331
+ * const gate = createUtteranceGate();
1332
+ * const ready = commandsFromSegments(segments, { …, gate }); // offers and drains
1333
+ * const later = gate.nextDueIn(); // ms until something becomes actionable, or null
1334
+ * …setTimeout(() => act(gate.due()), later) // silence needs a nudge
1335
+ *
1336
+ * Note what does NOT come out of `due()`: an utterance that already fired. It stays in the
1337
+ * gate, matching its own redeliveries, so the completed version of a request that was already
1338
+ * acted on cannot act again as something else.
1339
+ */
1340
+ export function createUtteranceGate({
1341
+ settleMs = UTTERANCE_SETTLE_MS,
1342
+ danglingMs = UTTERANCE_DANGLING_MS,
1343
+ forgetMs = UTTERANCE_FORGET_MS,
1344
+ max = UTTERANCE_TRACKED_MAX,
1345
+ } = {}) {
1346
+ let live = [];
1347
+ const waitFor = (e) => (e.command.finished === false ? danglingMs : settleMs);
1348
+ const find = (command, gist) => live.find(
1349
+ (e) => e.meetingId === (command.meetingId || '') && sameUtterance(e.gist, gist),
1350
+ );
1351
+ return {
1352
+ /** Offer this delta's commands. Chainable, so a scan reads as one expression. */
1353
+ offer(commands, now = Date.now()) {
1354
+ for (const command of commands || []) {
1355
+ const gist = gistText(command.command);
1356
+ if (!gist) continue;
1357
+ const entry = find(command, gist);
1358
+ if (!entry) {
1359
+ live.push({ meetingId: command.meetingId || '', gist, command, changedAt: now, seenAt: now, done: false });
1360
+ continue;
1361
+ }
1362
+ entry.seenAt = now;
1363
+ if (entry.done) continue; // said once, done once — however many more words arrive
1364
+ if (gist === entry.gist) continue; // unchanged: the clock keeps running, untouched
1365
+ // Still growing (or being revised). The newest wording is the one to act on, and the
1366
+ // wait starts again from here — which is what makes a pause, not a full stop, the
1367
+ // signal that someone has finished.
1368
+ entry.gist = gist;
1369
+ entry.command = command;
1370
+ entry.changedAt = now;
1371
+ }
1372
+ live = live.filter((e) => now - e.seenAt <= forgetMs);
1373
+ if (live.length > max) live = live.slice(-max);
1374
+ return this;
1375
+ },
1376
+ /** The commands whose words have stopped moving. Each is returned exactly once. */
1377
+ due(now = Date.now()) {
1378
+ const out = [];
1379
+ for (const e of live) {
1380
+ if (e.done || now - e.changedAt < waitFor(e)) continue;
1381
+ e.done = true;
1382
+ out.push(e.command);
1383
+ }
1384
+ return out;
1385
+ },
1386
+ /**
1387
+ * ms until the earliest waiting command becomes actionable, or null when none is waiting.
1388
+ * The host needs this because silence produces no deltas to re-scan on: the last thing
1389
+ * said before someone stops talking is exactly the thing they are waiting to see happen.
1390
+ */
1391
+ nextDueIn(now = Date.now()) {
1392
+ let soonest = null;
1393
+ for (const e of live) {
1394
+ if (e.done) continue;
1395
+ const left = Math.max(0, waitFor(e) - (now - e.changedAt));
1396
+ if (soonest === null || left < soonest) soonest = left;
1397
+ }
1398
+ return soonest;
1399
+ },
1400
+ /** How many utterances are still waiting — for tests and for a diagnostics line. */
1401
+ get waiting() { return live.filter((e) => !e.done).length; },
1402
+ };
664
1403
  }