@chatpanel/events 0.23.1 → 0.27.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,433 @@ 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
+ function isAddressed(raw, tokens, i, n = 0) {
506
+ // The fuzzy match is generous enough to SWALLOW a leading article: "a chat panel" squashes
507
+ // to "achatpanel", one edit from "chatpanel", so the determiner ends up inside the matched
508
+ // span instead of before it. Check the first matched token too, or "a chat panel would be
509
+ // useful here" reads as an address purely because the "a" was absorbed.
510
+ if (n > 0 && NOUN_MARKERS.test(String(tokens[i].w || '').replace(/[^\p{L}\p{N}]/gu, ''))) return false;
511
+ if (i === 0) return true; // nothing before it — it opens the utterance
512
+ const prev = tokens[i - 1];
513
+ const word = String(prev.w || '').replace(/[^\p{L}\p{N}]/gu, '');
514
+ if (NOUN_MARKERS.test(word)) return false; // "the chat panel" — a thing, not a listener
515
+ // Punctuation before it is the vocative comma or a sentence break — "…here. Okay, chat
516
+ // panel", "so I was thinking. ChatPanel, what did we decide?" — and both mean a fresh
517
+ // address rather than a continuing noun phrase. Measured from the previous token's START,
518
+ // because the tokenizer keeps trailing punctuation ON the token ("thinking."), so the gap
519
+ // between tokens is only the space and the full stop would be missed.
520
+ if (/[.!?,;:]["')\]]?\s*$/.test(raw.slice(prev.start, tokens[i].start))) return true;
521
+ // "hey chatpanel", "ok chatpanel" — an address word is the other way people open one.
522
+ return /^(?:ok|okay|hey|hi|yo|hello|so|um|uh)$/i.test(word);
523
+ }
524
+
525
+ /**
526
+ * Drop the NEXT address's run-up from the end of this command.
527
+ *
528
+ * A command is bounded by where the next wake phrase starts — but people open an address with
529
+ * a word or two before the name ("…Okay, chat panel."), and those land on the end of the
530
+ * previous command. Harmless to read, ruinous to identity: as the caption grows, "start
531
+ * monitoring the pricing question." becomes "…pricing question. Okay", which is different
532
+ * words, a different key, and therefore the same request acted on twice.
533
+ *
534
+ * Only the address words are trimmed, and only from the end — the same short list that marks
535
+ * an opening in isAddressed().
536
+ */
537
+ function trimTrailingLeadIn(text) {
538
+ return String(text)
539
+ .replace(/(?:[\s,.:;!?-]*\b(?:ok|okay|hey|hi|yo|hello|so|um+|uh+|and|then|now)\b)+[\s,.:;!?-]*$/i, '')
540
+ .trim();
135
541
  }
136
542
 
137
543
  // "chatpanel, could you please set a timer" — politeness is not part of the command, and
@@ -590,13 +996,94 @@ export function parseCommand(text, { wake = compileWake(), intents = defaultVoic
590
996
  if (!found) return null;
591
997
  const parsed = intents.parse(found.command, { now });
592
998
  if (!parsed) return null;
593
- return { ...parsed, wake: found.wake, heard: found.heard, at: found.at };
999
+ return { ...parsed, wake: found.wake, heard: found.heard, at: found.at, addressed: found.addressed !== false };
594
1000
  }
595
1001
 
596
1002
  // ---------------------------------------------------------------------------
597
1003
  // Transcript → commands
598
1004
  // ---------------------------------------------------------------------------
599
1005
 
1006
+ /**
1007
+ * Has the speaker finished the thought? — and PUNCTUATION IS NOT THE EVIDENCE.
1008
+ *
1009
+ * This used to be `endsSentence`: a full stop at the end of the caption meant the speaker had
1010
+ * stopped. Live caption engines punctuate as they go, and they punctuate FRAGMENTS. One
1011
+ * capture of this feature in use produced, in order: "Take.", "Take the question and ask
1012
+ * the.", "set a timer for.", "let's summar." — four full stops nobody uttered, and four
1013
+ * half-sentences sent to a model as requests while the speaker was still saying the rest.
1014
+ *
1015
+ * So the full stop is thrown away and the LAST WORD is read instead. A command ending on a
1016
+ * preposition, an article, a conjunction or an auxiliary ("…ask the", "…a timer for") is
1017
+ * someone mid-thought, however the transcriber punctuated it.
1018
+ *
1019
+ * A HINT, NOT A VERDICT. "Tell me what that is" is a real request that ends on 'is', so a
1020
+ * dangling tail must never DISCARD a command — it only makes the gate below wait longer for
1021
+ * the rest to arrive. A command that is never acted on is the worse failure of the two.
1022
+ */
1023
+ // Words that can end a sentence grammatically but in speech mean the qualifier is still being
1024
+ // chosen — "summarize the last 30 seconds, like, maybe…".
1025
+ const TRAILING_HEDGES = new Set(['maybe', 'perhaps', 'probably', 'basically', 'roughly', 'kinda', 'sorta']);
1026
+
1027
+ export function commandLooksFinished(text) {
1028
+ const t = String(text || '').trim().replace(/[\s.,;:!?…'"’”)\]-]+$/u, '');
1029
+ if (!t) return false;
1030
+ const tokens = t.toLowerCase().match(/[\p{L}\p{N}']+/gu) || [];
1031
+ const last = tokens[tokens.length - 1];
1032
+ if (!last) return false;
1033
+ return !DANGLING_TAILS.has(last) && !TRAILING_HEDGES.has(last);
1034
+ }
1035
+
1036
+ // Terminal punctuation. Worth almost nothing on its own — see above — but it is the only
1037
+ // signal a caller with no gate has, so the ungated path keeps asking for it ON TOP of the
1038
+ // tail test rather than getting looser than it was.
1039
+ const endsSentence = (text) => /[.!?…]["'’”)\]]*\s*$/.test(String(text || '').trim());
1040
+
1041
+ /**
1042
+ * The SHORTEST span that parses wins.
1043
+ *
1044
+ * "Set a timer for 30 seconds. And then that should actually set a timer for 30 seconds."
1045
+ * is one request said twice, and the duration parser sums what it finds across the span —
1046
+ * so a two-sentence window turned 30 seconds into 60 and produced a one-minute timer nobody
1047
+ * asked for. Worse, it moved: as the caption grew, the same words re-parsed to a different
1048
+ * duration, which is a different dedupe key, which is another timer. That is the "why is it
1049
+ * creating timers again and again" report.
1050
+ *
1051
+ * So the first sentence is tried alone, and the wider span only when it yields nothing. A
1052
+ * request that genuinely needs two ("Set a timer. Make it five minutes.") still gets them.
1053
+ */
1054
+ function parseShortest(command, intents, now) {
1055
+ const first = firstSentences(command, 1).trim();
1056
+ if (first && first !== command) {
1057
+ const narrow = intents.parse(first, { now });
1058
+ if (narrow?.intent) return narrow;
1059
+ }
1060
+ return intents.parse(command, { now });
1061
+ }
1062
+
1063
+ /**
1064
+ * A command's words, reduced to what survives re-transcription.
1065
+ *
1066
+ * Case and spacing vary between flushes of the same sentence, and punctuation appears and
1067
+ * disappears as the engine revises — so none of them may be part of an identity that is
1068
+ * supposed to say "you have already done this".
1069
+ */
1070
+ export const gistText = (text) => String(text || '')
1071
+ .toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim().slice(0, 160);
1072
+
1073
+ /**
1074
+ * The OPENING of a request — a looser identity than its full words.
1075
+ *
1076
+ * A transcriber does not only append; it REVISES. "How is the weather in Seattle, Washington
1077
+ * now?" became "How is the weather in Seattle, Washington?" on a later flush — different
1078
+ * words, a different key, and the same question asked twice. The first few words are what a
1079
+ * revision leaves alone.
1080
+ *
1081
+ * Six is chosen against the failure it exists to stop: long enough that two genuinely
1082
+ * different requests rarely share an opening, short enough to survive the tail being rewritten.
1083
+ */
1084
+ export const OPENING_WORDS = 6;
1085
+ export const gistOpening = (text) => gistText(text).split(' ').slice(0, OPENING_WORDS).join(' ');
1086
+
600
1087
  /** How many commands one transcript delta may produce. */
601
1088
  export const MAX_COMMANDS_PER_DELTA = 3;
602
1089
 
@@ -618,27 +1105,66 @@ export const MAX_COMMANDS_PER_DELTA = 3;
618
1105
  */
619
1106
  export function commandsFromSegments(segments, {
620
1107
  wake = compileWake(), intents = defaultVoiceIntents(), isSelf = null,
621
- sinceTs = 0, now = Date.now(), meetingId = '', max = MAX_COMMANDS_PER_DELTA,
1108
+ sinceTs = 0, now = Date.now(), meetingId = '', max = MAX_COMMANDS_PER_DELTA, gate = null,
622
1109
  } = {}) {
623
1110
  const out = [];
624
1111
  for (const seg of segments || []) {
625
1112
  if (!seg || !seg.text) continue;
626
1113
  if (seg.t && seg.t <= sinceTs) continue;
627
- const parsed = parseCommand(seg.text, { wake, intents, now });
1114
+ // EVERY address in this caption, not just the first. A live caption carries a minute of
1115
+ // speech, and a person addressing an assistant addresses it more than once in a minute —
1116
+ // "…set a timer for 1 minute. Okay chat panel, go and search…" is two commands. Taking
1117
+ // only the first also made that first command swallow the rest, which is how a one-minute
1118
+ // timer became 720 hours: the duration parser found "30 days" four sentences later.
1119
+ for (const found of findWakeCommands(seg.text, wake)) {
1120
+ if (!found.command) continue; // a bare mention with nothing after it
1121
+ const intent = parseShortest(found.command, intents, now);
1122
+ // `rest` rides along: the deterministic intents parse only the tight span (that is what
1123
+ // stops a duration being found four sentences away), but a MODEL asked to read an
1124
+ // unrecognised request should see the words around it — the question often follows a
1125
+ // sentence of preamble. Parse narrow, refine wide.
1126
+ const parsed = intent && {
1127
+ ...intent, wake: found.wake, heard: found.heard, at: found.at,
1128
+ addressed: found.addressed, rest: found.rest,
1129
+ };
628
1130
  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.
1131
+ // MENTIONED vs ADDRESSED — and this guard used to conflate them.
1132
+ //
1133
+ // parseCommand returns a shape for anything carrying the wake word, intent or not, so
1134
+ // "we should talk about the chat panel roadmap next week" came back as a command and the
1135
+ // caller acted on it: ordinary conversation quietly setting timers. Dropping every
1136
+ // intentless utterance fixed that, and broke the opposite case just as badly — "Okay,
1137
+ // chat panel. How is the weather in Lakeside?" is unmistakably a request, matches no
1138
+ // built-in intent (there is no weather intent, and there should not be), and was
1139
+ // discarded here. Nothing downstream ever saw it, which is why `needsModel` had no
1140
+ // handler: it could not reach one.
1141
+ //
1142
+ // So the test is whether the assistant was SPOKEN TO. A passing mention still stops here.
1143
+ // An address with no matching intent goes on with needsModel set, for a model to read —
1144
+ // which is what the parser has always said should happen.
634
1145
  //
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;
1146
+ if (!parsed.intent && !parsed.addressed) continue;
1147
+ // Does this read as a whole thought? Computed once, where the command's own words are:
1148
+ // the guard below and the gate must never be able to answer that differently.
1149
+ const finished = commandLooksFinished(found.command);
1150
+ // WHETHER THE SENTENCE HAS ENDED IS NOT DECIDED HERE — when there is a gate.
1151
+ //
1152
+ // It used to be decided here, on the caption's terminal punctuation, and that is exactly
1153
+ // what sent "Take the question and ask the." to a model as a request. This function sees
1154
+ // ONE delivery of a caption; only something watching the same utterance across
1155
+ // deliveries can tell a finished sentence from a punctuated fragment, and that is the
1156
+ // gate below (`finished` is the hint it reads).
1157
+ //
1158
+ // A caller with no gate has no such thing, so it keeps the old conservative rule and
1159
+ // gains the tail test on top of it: both, or the request waits for the next delivery.
1160
+ // Getting LOOSER than the code being fixed would be a strange way to fix it.
1161
+ if (!parsed.intent && !gate && !(finished && endsSentence(seg.text))) continue;
638
1162
  const allowed = isSelf ? !!isSelf(seg.speaker) : false;
639
1163
  out.push({
640
1164
  ...parsed,
641
1165
  allowed,
1166
+ // Carried, so the gate reads the same answer this scan did.
1167
+ finished,
642
1168
  speaker: seg.speaker || '',
643
1169
  t: seg.t || now,
644
1170
  meetingId,
@@ -656,9 +1182,180 @@ export function commandsFromSegments(segments, {
656
1182
  // one spoken request look like a new request on every update, and a single "set a timer
657
1183
  // for 30 seconds" became a screenful of timers. `sid` is assigned once per utterance and
658
1184
  // 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 ?? ''}`,
1185
+ // WHAT WAS ASKED, not which delivery of it carried the words.
1186
+ //
1187
+ // This used to key on the caption's identity (`sid`) and the wake word's offset. Both
1188
+ // move: `sid` is re-minted whenever the caption engine loses the overlap between a
1189
+ // growing line and the one before it, and a monologue keeps ONE entry alive for
1190
+ // minutes — re-scanned on every flush, by design, so a half-heard command gets a
1191
+ // second chance. So one spoken "set a timer for 30 seconds" kept arriving as a
1192
+ // brand-new command and kept creating timers.
1193
+ //
1194
+ // The words are what does not move. Two different commands in one breath still differ;
1195
+ // the same command through fifty flushes is one request.
1196
+ // The OPENING, not the whole sentence: a live caption keeps growing ("…10 seconds",
1197
+ // then "…10 seconds and then", then "…and then we moved on"), and gistText over the
1198
+ // full text moves with every one of those — which is the very bug this key exists to
1199
+ // stop, just later in the sentence. The intent and its resolved duration are already
1200
+ // in the key, so two genuinely different commands still differ.
1201
+ key: `voice:${meetingId}:${parsed.intent || 'ask'}:${parsed.ms ?? parsed.when ?? ''}:${gistOpening(found.command)}`,
660
1202
  });
661
- if (out.length >= max) break; // a pathological transcript cannot fire fifty actions
1203
+ }
662
1204
  }
663
- return out;
1205
+ // THE NEWEST, not the first.
1206
+ //
1207
+ // The cap exists so a pathological transcript cannot fire fifty actions, and it used to
1208
+ // stop scanning once it had `max` — counting from the START of the caption. A caption entry
1209
+ // in a monologue lives for minutes and is re-scanned on every flush, accumulating every
1210
+ // address spoken into it, so the first three (long since acted on) consumed the whole
1211
+ // budget and everything said AFTER them was never returned at all. The request you just
1212
+ // made was the one thrown away.
1213
+ //
1214
+ // The newest are both the most likely to be fresh and the ones a person is waiting on, so
1215
+ // the cap keeps those. The already-acted ones are dropped downstream by the dedupe anyway.
1216
+ const found = out.length > max ? out.slice(-max) : out;
1217
+ // WITH A GATE, nothing is returned until the words stop moving — see createUtteranceGate.
1218
+ // Without one the old behaviour stands, so a host that has not adopted it (or a test asking
1219
+ // what the grammar sees) is unchanged.
1220
+ return gate ? gate.offer(found, now).due(now) : found;
1221
+ }
1222
+
1223
+ // ---------------------------------------------------------------------------
1224
+ // One utterance, one action
1225
+ // ---------------------------------------------------------------------------
1226
+
1227
+ /**
1228
+ * How long a command's words must stop changing before it is acted on.
1229
+ *
1230
+ * LONGER THAN THE CAPTURE'S FLUSH INTERVAL, and that is the whole calculation. Captions reach
1231
+ * a client in batches — the extension debounces its flush by 4s — so "these words have not
1232
+ * changed for 1s" says nothing except that no batch arrived in the last second. Only a wait
1233
+ * that outlasts a flush can distinguish "they stopped talking" from "we have not been told
1234
+ * what they said next".
1235
+ */
1236
+ export const UTTERANCE_SETTLE_MS = 5_000;
1237
+
1238
+ /**
1239
+ * How long a command that ends MID-THOUGHT waits instead.
1240
+ *
1241
+ * "Set a timer for" is not a request yet; the duration is in the breath after it. Waiting the
1242
+ * ordinary window and acting on it is exactly the bug this file exists to stop. But the tail
1243
+ * test is a word list, not grammar, and "tell me what that is" ends on 'is' — so a dangling
1244
+ * command is DELAYED, never dropped. If the speaker really did stop there, it still runs.
1245
+ */
1246
+ export const UTTERANCE_DANGLING_MS = 12_000;
1247
+
1248
+ // How long an utterance is remembered after it was last heard. A caption entry in a monologue
1249
+ // is re-delivered for minutes, and every one of those redeliveries has to find the record
1250
+ // saying "this one is done" — that record IS the one-utterance-one-action guarantee.
1251
+ const UTTERANCE_FORGET_MS = 3 * 60_000;
1252
+ // A cap, because a long meeting must not grow this without bound. Small: only utterances
1253
+ // still in flight or recently acted on matter, and the caller has its own longer-lived record
1254
+ // of what has been done.
1255
+ const UTTERANCE_TRACKED_MAX = 24;
1256
+
1257
+ /**
1258
+ * Is this the same spoken request as that one, a moment later?
1259
+ *
1260
+ * Two relations, because a transcriber does both. It APPENDS — "let's summar" becomes "let's
1261
+ * summarize the notes" — which is a prefix. And it REVISES — "…in Seattle, Washington now?"
1262
+ * came back as "…in Seattle, Washington?" — which is not, but keeps the opening.
1263
+ */
1264
+ export function sameUtterance(a, b) {
1265
+ if (!a || !b) return false;
1266
+ if (a === b || a.startsWith(b) || b.startsWith(a)) return true;
1267
+ const oa = a.split(' ').slice(0, OPENING_WORDS);
1268
+ const ob = b.split(' ').slice(0, OPENING_WORDS);
1269
+ // A short opening is not enough evidence: "set a timer" opens half the commands ever
1270
+ // spoken, and collapsing two of them into one utterance loses the second silently.
1271
+ return oa.length >= OPENING_WORDS && oa.join(' ') === ob.join(' ');
1272
+ }
1273
+
1274
+ /**
1275
+ * The thing that makes a growing caption ONE request.
1276
+ *
1277
+ * THE BUG THIS IS. A live caption is re-delivered as it grows, and every delivery was acted
1278
+ * on the moment it parsed. So a single spoken "Okay ChatPanel, set a timer for 30 seconds"
1279
+ * arrived first as "set a timer for." — no duration, no intent, addressed, punctuated by the
1280
+ * transcriber — and went to the model as a QUESTION; then, seconds later, arrived whole and
1281
+ * became a TIMER. One thing said, two things done, and the user reasonably reported it as
1282
+ * "it set the timer but it also sent a message". Nothing downstream could relate the two:
1283
+ * they had different words, different intents, and therefore different dedupe keys.
1284
+ *
1285
+ * They can only be related by watching them, so this is the one stateful thing in the file —
1286
+ * and it is still clock-free (`now` is passed in, like everywhere else) and platform-free.
1287
+ * The host owns exactly two things: keeping the object, and calling back when the wait is up.
1288
+ *
1289
+ * const gate = createUtteranceGate();
1290
+ * const ready = commandsFromSegments(segments, { …, gate }); // offers and drains
1291
+ * const later = gate.nextDueIn(); // ms until something becomes actionable, or null
1292
+ * …setTimeout(() => act(gate.due()), later) // silence needs a nudge
1293
+ *
1294
+ * Note what does NOT come out of `due()`: an utterance that already fired. It stays in the
1295
+ * gate, matching its own redeliveries, so the completed version of a request that was already
1296
+ * acted on cannot act again as something else.
1297
+ */
1298
+ export function createUtteranceGate({
1299
+ settleMs = UTTERANCE_SETTLE_MS,
1300
+ danglingMs = UTTERANCE_DANGLING_MS,
1301
+ forgetMs = UTTERANCE_FORGET_MS,
1302
+ max = UTTERANCE_TRACKED_MAX,
1303
+ } = {}) {
1304
+ let live = [];
1305
+ const waitFor = (e) => (e.command.finished === false ? danglingMs : settleMs);
1306
+ const find = (command, gist) => live.find(
1307
+ (e) => e.meetingId === (command.meetingId || '') && sameUtterance(e.gist, gist),
1308
+ );
1309
+ return {
1310
+ /** Offer this delta's commands. Chainable, so a scan reads as one expression. */
1311
+ offer(commands, now = Date.now()) {
1312
+ for (const command of commands || []) {
1313
+ const gist = gistText(command.command);
1314
+ if (!gist) continue;
1315
+ const entry = find(command, gist);
1316
+ if (!entry) {
1317
+ live.push({ meetingId: command.meetingId || '', gist, command, changedAt: now, seenAt: now, done: false });
1318
+ continue;
1319
+ }
1320
+ entry.seenAt = now;
1321
+ if (entry.done) continue; // said once, done once — however many more words arrive
1322
+ if (gist === entry.gist) continue; // unchanged: the clock keeps running, untouched
1323
+ // Still growing (or being revised). The newest wording is the one to act on, and the
1324
+ // wait starts again from here — which is what makes a pause, not a full stop, the
1325
+ // signal that someone has finished.
1326
+ entry.gist = gist;
1327
+ entry.command = command;
1328
+ entry.changedAt = now;
1329
+ }
1330
+ live = live.filter((e) => now - e.seenAt <= forgetMs);
1331
+ if (live.length > max) live = live.slice(-max);
1332
+ return this;
1333
+ },
1334
+ /** The commands whose words have stopped moving. Each is returned exactly once. */
1335
+ due(now = Date.now()) {
1336
+ const out = [];
1337
+ for (const e of live) {
1338
+ if (e.done || now - e.changedAt < waitFor(e)) continue;
1339
+ e.done = true;
1340
+ out.push(e.command);
1341
+ }
1342
+ return out;
1343
+ },
1344
+ /**
1345
+ * ms until the earliest waiting command becomes actionable, or null when none is waiting.
1346
+ * The host needs this because silence produces no deltas to re-scan on: the last thing
1347
+ * said before someone stops talking is exactly the thing they are waiting to see happen.
1348
+ */
1349
+ nextDueIn(now = Date.now()) {
1350
+ let soonest = null;
1351
+ for (const e of live) {
1352
+ if (e.done) continue;
1353
+ const left = Math.max(0, waitFor(e) - (now - e.changedAt));
1354
+ if (soonest === null || left < soonest) soonest = left;
1355
+ }
1356
+ return soonest;
1357
+ },
1358
+ /** How many utterances are still waiting — for tests and for a diagnostics line. */
1359
+ get waiting() { return live.filter((e) => !e.done).length; },
1360
+ };
664
1361
  }