@chatpanel/events 0.102.0 → 0.104.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. package/adapters.js +1 -83
  2. package/adaptive-tool-policy.js +1 -45
  3. package/agent.js +2 -255
  4. package/attribution.js +1 -132
  5. package/backup-envelope.js +1 -221
  6. package/board-tool.js +2 -165
  7. package/budget.js +1 -116
  8. package/capability.js +1 -126
  9. package/citations.js +5 -127
  10. package/client-prefs.js +1 -148
  11. package/context-attachments.js +8 -144
  12. package/cowriter-router.js +1 -83
  13. package/cowriter-writer.js +19 -193
  14. package/cowriter.js +1 -190
  15. package/curate.js +3 -509
  16. package/distance.js +1 -124
  17. package/engine.js +1 -131
  18. package/entitlement.js +1 -332
  19. package/entity.js +1 -354
  20. package/event.js +1 -175
  21. package/extraction.js +4 -293
  22. package/failover.js +1 -93
  23. package/find-tool.js +1 -66
  24. package/flowchart.js +2 -644
  25. package/gate.js +1 -74
  26. package/harness.js +4 -101
  27. package/index.js +1 -345
  28. package/invariants.js +1 -174
  29. package/job.js +3 -147
  30. package/kernel.js +1 -255
  31. package/knowledge-derive.js +1 -267
  32. package/knowledge.js +3 -230
  33. package/library.js +11 -280
  34. package/loop.js +1 -132
  35. package/manifest.js +1 -107
  36. package/markdown-authoring.js +11 -288
  37. package/markdown-render.js +0 -0
  38. package/mcp-client.js +8 -358
  39. package/mcp-dispatch.js +1 -52
  40. package/mcp-errors.js +1 -106
  41. package/mcp-manager.js +1 -127
  42. package/media-transcript.js +8 -531
  43. package/meeting-analyzers.js +1 -83
  44. package/meeting-insights.js +7 -164
  45. package/meeting-shape.js +1 -282
  46. package/meeting-text.js +5 -94
  47. package/memory.js +2 -638
  48. package/model-candidates.js +1 -358
  49. package/model-health.js +1 -182
  50. package/model-ledger.js +1 -228
  51. package/model-picker.js +1 -188
  52. package/note-actions.js +15 -246
  53. package/note-graph.js +1 -158
  54. package/note-links.js +1 -68
  55. package/note-mentions.js +8 -114
  56. package/note-plan.js +9 -107
  57. package/note-research.js +1 -135
  58. package/observability.js +1 -119
  59. package/omni.js +1 -125
  60. package/order.js +1 -78
  61. package/package.json +4 -5
  62. package/pdf-layout.js +7 -253
  63. package/policy.js +1 -0
  64. package/project-run.js +20 -444
  65. package/project.js +1 -170
  66. package/promotion.js +1 -171
  67. package/queue.js +1 -98
  68. package/reach.js +1 -23
  69. package/recipe-tool.js +2 -161
  70. package/recipe.js +1 -264
  71. package/record-list.js +2 -110
  72. package/recruit.js +3 -456
  73. package/redaction-tokens.js +1 -61
  74. package/ref.js +1 -55
  75. package/registry.js +1 -240
  76. package/route-graph.js +1 -115
  77. package/route-strategies.js +2 -232
  78. package/router.js +2 -880
  79. package/rrf.js +1 -89
  80. package/rules.js +1 -142
  81. package/schedule.js +1 -663
  82. package/scm-connection.js +1 -180
  83. package/scopes.js +1 -12
  84. package/scorecard.js +1 -335
  85. package/search-engines.js +1 -81
  86. package/skill-manifest.js +1 -330
  87. package/skill-scan.js +3 -209
  88. package/skill-sources.js +1 -162
  89. package/skill-vars.js +2 -252
  90. package/slash-commands.js +1 -158
  91. package/source-gate.js +1 -92
  92. package/sources-retrieval.js +5 -186
  93. package/sources.js +1 -273
  94. package/store.js +1 -171
  95. package/structured.js +4 -901
  96. package/subject-kinds.js +1 -5
  97. package/subject-name.js +1 -96
  98. package/sync-plan.js +1 -213
  99. package/synthesis.js +3 -123
  100. package/tags.js +1 -203
  101. package/team-board.js +6 -337
  102. package/team-cache.js +2 -63
  103. package/team-observe.js +1 -118
  104. package/team-org.js +1 -442
  105. package/team-plan.js +4 -131
  106. package/team-record.js +1 -225
  107. package/team-run.js +9 -652
  108. package/team-subtask.js +2 -191
  109. package/team-task.js +2 -124
  110. package/team-tool.js +2 -196
  111. package/team-trail.js +2 -73
  112. package/team-worklog.js +2 -149
  113. package/team.js +1 -302
  114. package/text-search.js +1 -155
  115. package/theme.js +17 -154
  116. package/titles.js +4 -285
  117. package/tool-discovery.js +1 -87
  118. package/tool-dispatch.js +5 -230
  119. package/tool-groups.js +1 -81
  120. package/tool-hints.js +10 -185
  121. package/tool-loop-guard.js +4 -182
  122. package/tool-need.js +2 -124
  123. package/tool-result.js +2 -438
  124. package/tool-round.js +0 -0
  125. package/tool-schema.js +1 -155
  126. package/tool-traits.js +1 -182
  127. package/toolset.js +2 -88
  128. package/trajectory.js +1 -530
  129. package/turn-loop.js +4 -440
  130. package/upcast.js +1 -37
  131. package/vault.js +2 -250
  132. package/view.js +1 -88
  133. package/voice-intents.js +2 -1488
  134. package/voice-speaker.js +1 -98
  135. package/weather-tool.js +1 -65
  136. package/weather.js +2 -211
  137. package/web-search-tool.js +14 -102
  138. package/web-search.js +1 -160
  139. package/widget.js +1 -153
package/voice-intents.js CHANGED
@@ -1,1488 +1,2 @@
1
- // Spoken commands "<wake word>, set a timer for ten minutes".
2
- //
3
- // A meeting transcript already streams into ChatPanel every few seconds, speaker-attributed.
4
- // That makes it an INPUT DEVICE, not just a record: a person can address the product in the
5
- // middle of a call and expect something to happen. The obvious way to build that is to ask a
6
- // model, every tick, whether anything was said to us. That is the wrong shape — it pays a
7
- // model call per tick to answer "no" almost every time, and the answer arrives a minute
8
- // after the sentence ended.
9
- //
10
- // So the model is used ONCE, to parse a command that a free matcher already found, and
11
- // never to watch. Everything here is class R: pure string work, microseconds, no network,
12
- // no tokens. The parse either recognises the command or reports `needsModel`, which is the
13
- // seam a small model fills for the phrasings a grammar will never cover.
14
- //
15
- // THREE THINGS THIS MODULE REFUSES TO DO, each because it would break a rule that matters:
16
- //
17
- // 1. It does not act. `parseCommand` returns a description of what was asked for; the
18
- // host decides whether that is allowed and carries it out. A parser that could start a
19
- // timer could also be talked into starting anything, by anyone in the room.
20
- // 2. It does not know who is allowed to speak to it. The host passes `self`, because only
21
- // the host knows which speaker label is the device's owner — and gating on that is the
22
- // whole security story (see commandsFromSegments).
23
- // 3. It does not read a clock. `now` is injected, exactly like loop.js and event.js, so a
24
- // command parses identically on replay and a test does not have to wait for Wednesday.
25
- //
26
- // Local time is deliberate: "9am" means 9am where the person is standing, so the resolved
27
- // timestamps come from the host's own timezone via Date. That is the only environmental
28
- // input, and it is the one users would be astonished to see normalised away.
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
- // The wake matcher's fuzzy compare. It lived here until the maintenance pass needed the
38
- // same question answered; re-exported so every existing caller (and index.js) is unchanged.
39
- import { editDistance } from './distance.js';
40
-
41
- export { editDistance };
42
-
43
- export class VoiceIntentError extends Error {
44
- constructor(code, message) { super(message); this.name = 'VoiceIntentError'; this.code = code; }
45
- }
46
-
47
- /** What a wake word defaults to when the user has not chosen one. Configurable per install. */
48
- export const DEFAULT_WAKE = Object.freeze(['chatpanel']);
49
-
50
- // Speech-to-text mangles a brand name it has never seen: "chatpanel" comes back as "chat
51
- // panel", "chat pal", "chad panel". A gate that only accepts the exact spelling is a gate
52
- // that never opens on a real transcript. Tolerance scales with length because one edit in a
53
- // four-letter word is a different word, and two edits in a nine-letter one is still clearly
54
- // the same attempt.
55
- function slack(len) { return len <= 4 ? 0 : len <= 6 ? 1 : 2; }
56
-
57
- // The widest span of spoken tokens that may add up to a ONE-WORD wake phrase ("chat" "pan"
58
- // "ell"). A longer phrase widens its own window — see compileWake.
59
- const MAX_WAKE_TOKENS = 3;
60
- // …but never without limit: the scan is O(tokens x window x phrases) over every utterance,
61
- // and a wake phrase longer than this is a sentence, not a wake phrase.
62
- const WAKE_TOKEN_CEILING = 8;
63
-
64
- // Lowercase and blank out punctuation WITHOUT changing length, so every offset computed
65
- // against the normalised copy still points at the same character of the original. The
66
- // command text handed back to the user keeps its capitals and its apostrophes; matching
67
- // never has to care about either.
68
- export function normalizeSpeech(text) {
69
- return String(text || '')
70
- .toLowerCase()
71
- .replace(/[‘’]/g, "'")
72
- .replace(/[^\p{L}\p{N}':.\s]/gu, ' ');
73
- }
74
-
75
- /** Tokens with offsets into the ORIGINAL string. */
76
- export function tokenize(text) {
77
- const norm = normalizeSpeech(text);
78
- const out = [];
79
- const re = /[\p{L}\p{N}'.:]+/gu;
80
- let m;
81
- while ((m = re.exec(norm))) {
82
- // Keep dots INSIDE a token ("a.m.", "9:30") and drop them at the edges — a trailing
83
- // full stop turned "ten minutes." into an unknown unit, the kind of bug that only
84
- // shows up on the one transcript that punctuates.
85
- const w = m[0].replace(/^[.:]+|[.:]+$/g, '');
86
- if (w) out.push({ w, start: m.index, end: m.index + m[0].length });
87
- }
88
- return out;
89
- }
90
-
91
- /**
92
- * Compile the user's chosen wake word(s) into a matcher. Accepts a string or a list; each is
93
- * squashed to letters so "chat panel", "ChatPanel" and "chat-panel" are one phrase.
94
- */
95
- export function compileWake(words = DEFAULT_WAKE) {
96
- // SEVERAL PHRASES, however they arrive. People do not say one fixed thing: "ok chatpanel",
97
- // "okay chat panel" and "hey chatpanel" are one intent with three spellings, and asking
98
- // someone to pick exactly one is asking them to remember which one they picked.
99
- //
100
- // A COMMA SEPARATES THEM — "chatpanel, siri, google" is what anyone would write, and any
101
- // other separator is a rule to learn. The apparent conflict ("okay, chat panel" is also how
102
- // you would write ONE phrase) is not a real one: matching strips punctuation from the
103
- // TRANSCRIPT, so a comma is never needed inside a configured phrase to hear one spoken.
104
- // Type "okay chat panel" and "okay, chat panel" is heard. `|`, `;` and newlines work too.
105
- const raw = (Array.isArray(words) ? words : String(words ?? '').split(/[,|;\n]/))
106
- .map((w) => String(w ?? '').trim())
107
- .filter(Boolean);
108
- const list = [];
109
- // The phrases AS TYPED, kept alongside the squashed forms and in the same order. The
110
- // squashed form is an implementation detail — "okchatpanel" is neither what the user wrote
111
- // nor what they would say — so any UI that echoes the setting back must have the original
112
- // to show. Deduped on the squashed form, keeping the first spelling of each.
113
- const labels = [];
114
- let widest = 1;
115
- for (const phrase of raw) {
116
- // Punctuation and spacing are stripped, so "ok chat panel", "ok, chat panel" and
117
- // "okchatpanel" compile to one and the same thing — which is what makes the setting
118
- // forgiving of how it was typed AND of how the transcriber spaced it.
119
- const squashed = phrase.toLowerCase().replace(/[^\p{L}\p{N}]/gu, '');
120
- if (squashed.length < 3) continue; // shorter than this and ordinary speech trips it constantly
121
- if (list.includes(squashed)) continue;
122
- list.push(squashed);
123
- labels.push(phrase);
124
- // The scan joins adjacent spoken tokens looking for the phrase, so its window has to be
125
- // at least as wide as the longest phrase is in WORDS — otherwise a three-word wake phrase
126
- // could never be found, however it was typed. +1 for a transcriber that splits one of
127
- // them ("chat" "pan" "ell").
128
- widest = Math.max(widest, phrase.split(/\s+/).filter(Boolean).length + 1);
129
- }
130
- if (!list.length) throw new VoiceIntentError('BAD_WAKE', 'wake word must have at least 3 letters');
131
- return Object.freeze({
132
- phrases: Object.freeze(list),
133
- labels: Object.freeze(labels),
134
- maxTokens: Math.min(Math.max(widest, MAX_WAKE_TOKENS), WAKE_TOKEN_CEILING),
135
- });
136
- }
137
-
138
- /**
139
- * How much of what follows the wake word is the command.
140
- *
141
- * THE 720-HOUR TIMER. One caption held six wake words and 420 characters, and the command was
142
- * "everything after the first one to the end of the line". So "Okay, chat panel. Set a timer
143
- * for 1 minute" swallowed four later sentences including "…research on the weather for the
144
- * next 30 days" — and the duration parser, scanning the whole span, found 30 days. The user
145
- * got a 720-hour timer from a request for one minute.
146
- *
147
- * A spoken command is a sentence, occasionally two ("Set a timer for 30 seconds. Make it
148
- * two."). Never a paragraph. Bounded here, and bounded again by the next wake word: a second
149
- * address is by definition the end of the first command.
150
- */
151
- export const MAX_COMMAND_SENTENCES = 2;
152
-
153
- /** Where every wake phrase sits in this text, in order. Shared by the singular and plural. */
154
- function wakeHits(raw, tokens, wake) {
155
- const hits = [];
156
- const window = wake.maxTokens || MAX_WAKE_TOKENS; // an older compiled wake has no maxTokens
157
- for (let i = 0; i < tokens.length; i++) {
158
- let squashed = '';
159
- let matched = false;
160
- for (let n = 0; n < window && i + n < tokens.length && !matched; n++) {
161
- squashed += tokens[i + n].w.replace(/[^\p{L}\p{N}]/gu, '');
162
- for (const phrase of wake.phrases) {
163
- // A window far from the phrase's length cannot match; skip the distance work.
164
- if (Math.abs(squashed.length - phrase.length) > slack(phrase.length)) continue;
165
- if (editDistance(squashed, phrase, slack(phrase.length)) <= slack(phrase.length)) {
166
- hits.push({
167
- phrase,
168
- start: tokens[i].start,
169
- end: tokens[i + n].end,
170
- addressed: isAddressed(raw, tokens, i, n),
171
- });
172
- // Skip past the phrase so "chat panel" is one hit, not two overlapping ones.
173
- i += n;
174
- matched = true;
175
- break;
176
- }
177
- }
178
- }
179
- }
180
- return hits;
181
- }
182
-
183
- /**
184
- * The first `max` sentences of `text`, or all of it when it has fewer.
185
- *
186
- * A boundary is terminal punctuation followed by WHITESPACE or the end — not any full stop.
187
- * "Go to google.com and search" is one sentence; splitting on the dot in a domain cut a
188
- * command down to "Go to google." and sent that. Decimals ("2.5 minutes") and initials break
189
- * the same way.
190
- */
191
- function firstSentences(text, max) {
192
- const t = String(text || '');
193
- if (!t) return t;
194
- const re = /[.!?…]+["'\u2019\u201d)\]]*(?=\s|$)/g;
195
- let taken = 0;
196
- let m;
197
- while (taken < max && (m = re.exec(t))) {
198
- taken += 1;
199
- if (taken === max) return t.slice(0, m.index + m[0].length);
200
- }
201
- // Fewer sentences than asked for — all of it. A trailing fragment with no terminal
202
- // punctuation is still what they said, and must not be silently emptied.
203
- return t;
204
- }
205
-
206
- /**
207
- * EVERY "<wake>, <command>" in one utterance, in order.
208
- *
209
- * A live caption often carries a whole minute of speech, and a person addressing an assistant
210
- * addresses it more than once in a minute. Returning only the first match meant the other
211
- * five requests in the same caption were invisible — and made the first command swallow them.
212
- */
213
- export function findWakeCommands(text, wake = compileWake(), { maxSentences = MAX_COMMAND_SENTENCES } = {}) {
214
- const raw = String(text || '');
215
- const tokens = tokenize(raw);
216
- if (!tokens.length) return [];
217
- const hits = wakeHits(raw, tokens, wake);
218
- const out = [];
219
- // How far the last command emitted reaches. A wake phrase the speaker used as a WORD, inside
220
- // a command already running, is part of that command and not a second one.
221
- let covered = 0;
222
- for (let idx = 0; idx < hits.length; idx++) {
223
- const hit = hits[idx];
224
- // THE PRODUCT'S OWN NAME IS A WORD PEOPLE SAY.
225
- //
226
- // "Okay chat panel, take the notes of whatever we spoke so far in chat panel notes" holds
227
- // the wake phrase twice: once as an address, once as the name of where to put them. The
228
- // second was treated as a fresh address, which did two things and both were wrong — it cut
229
- // the command down to "…so far", losing where they wanted the notes, and it emitted
230
- // "notes. So that is good." as a command of its own, which noteIntent matched. One spoken
231
- // request became two notes and a truncated one.
232
- //
233
- // A mention that is not an address and falls inside the command already being carried is
234
- // skipped. A mention that stands on its own still gets through — the intent match is the
235
- // safety net for an address this heuristic misjudged, and dropping those outright would
236
- // trade a stray action for a lost one.
237
- if (!hit.addressed && hit.start < covered) continue;
238
- // Bounded by the next ADDRESS, then by sentence count. Deliberately the next address and
239
- // not the next mention: a second wake word is the end of the first command only when the
240
- // speaker was turning to us again, and the sentence they are still saying is not that.
241
- let stop = raw.length;
242
- for (let j = idx + 1; j < hits.length; j++) if (hits[j].addressed) { stop = hits[j].start; break; }
243
- const span = raw.slice(hit.end, stop);
244
- const command = trimTrailingLeadIn(stripLeadIn(firstSentences(span, maxSentences)).trim());
245
- covered = Math.max(covered, hit.end + span.indexOf(command) + command.length);
246
- out.push({
247
- command,
248
- wake: hit.phrase,
249
- heard: raw.slice(hit.start, hit.end),
250
- at: hit.start,
251
- addressed: hit.addressed,
252
- // What was said after the command's own sentences, up to the next address. Not part of
253
- // the command — kept so a caller refining with a model has the surrounding words.
254
- rest: raw.slice(hit.end + span.indexOf(command) + command.length, stop).trim(),
255
- });
256
- }
257
- return out;
258
- }
259
-
260
- /**
261
- * Find "<wake>, <command>" in one utterance — the first one.
262
- *
263
- * Returns the command with its ORIGINAL casing, plus which wake phrase matched and where —
264
- * the host logs the span so a user can see why something fired.
265
- */
266
- export function findWakeCommand(text, wake = compileWake(), opts = {}) {
267
- return findWakeCommands(text, wake, opts)[0] || null;
268
- }
269
-
270
- /**
271
- * The REQUEST inside a spoken utterance — not everything that followed the wake word.
272
- *
273
- * People do not stop talking when they finish asking. A real capture:
274
- *
275
- * "Okay, chat panel. Whenever I do anything or ask any question just to do a research for
276
- * me and get me the answer, okay? All right, so. I want to know how is the weather in
277
- * Fairview today? All right, so we will see. It does anything. Does it get added to?"
278
- *
279
- * Everything after the wake word became the job's name AND its prompt, so the job was a
280
- * paragraph of thinking-aloud with a weather question buried in the middle — which is what
281
- * "it didn't separate" means, and why the answer was useless even on the runs that happened.
282
- *
283
- * What this does, and deliberately no more: split into sentences, drop the ones that are pure
284
- * filler, and prefer a QUESTION when one was asked (the LAST one — people circle back, and
285
- * the restatement is the version they meant). Everything here is free, deterministic and
286
- * reversible. Turning rambling into a good PROMPT is a model's job, and the parser already
287
- * says so by returning `needsModel`; this is the FLOOR under that, for when no model is
288
- * configured and for the instant before one answers. It is a heuristic over speech and it
289
- * will sometimes pick the wrong sentence — that is precisely why the contract asks the host
290
- * to pay for a model rather than pretending this is the answer.
291
- */
292
-
293
- // Sentences carrying no request — verbal punctuation, thinking aloud, or narrating the very
294
- // experiment being run. Matched WHOLE, so "so we will see" goes and "see if the build passed"
295
- // stays.
296
- const FILLER_SENTENCE = new RegExp('^(?:'
297
- + "ok(?:ay)?|all ?right|right|so|well|um+|uh+|hmm+|yeah|yep|hey|and|but|then|now"
298
- + "|let(?:'s| us) see|we(?:'ll| will) see|so we(?:'ll| will) see"
299
- + "|i think it is doing something|it does anything|does it (?:do )?anything"
300
- + "|hold on(?: a second)?|one second|let me see|i think|i guess|here we go|there we go"
301
- + "|test(?:ing)?"
302
- + ')[\\s,.!?]*$', 'i');
303
-
304
- const SENTENCE_SPLIT = /(?<=[.!?])\s+/;
305
-
306
- /**
307
- * True when a sentence is only filler — verbal punctuation rather than a request.
308
- *
309
- * Checked CLAUSE BY CLAUSE, because people string filler together with commas: "All right,
310
- * so we will see." is two fillers in one sentence and matches neither whole. Every clause
311
- * must be filler for the sentence to be, so "right after the demo, remind me" survives on the
312
- * strength of its first clause even though the second would pass alone.
313
- */
314
- export function isFillerSentence(text) {
315
- const t = String(text || '').trim().replace(/^[\s,.:;!?-]+/, '');
316
- if (!t) return true;
317
- if (FILLER_SENTENCE.test(t)) return true;
318
- const clauses = t.split(',').map((c) => c.trim()).filter(Boolean);
319
- return clauses.length > 1 && clauses.every((c) => FILLER_SENTENCE.test(c));
320
- }
321
-
322
- /**
323
- * @returns { request, name, ambiguous } — the text to act on, a short label for it, and
324
- * whether more than one question was asked (in which case `request` is everything
325
- * meaningful and a model should be asked to pick). Both strings fall back to the
326
- * cleaned original rather than to nothing: a command we could not parse is still a
327
- * command the user gave, and dropping it silently is the worse failure.
328
- */
329
- export function refineSpokenCommand(text, { maxName = 48 } = {}) {
330
- const raw = stripLeadIn(String(text || '')).trim();
331
- if (!raw) return { request: '', name: '' };
332
- const sentences = raw.split(SENTENCE_SPLIT).map((t) => t.trim()).filter(Boolean);
333
- const meaningful = sentences.filter((t) => !isFillerSentence(t));
334
- // ONE question is the request. SEVERAL is a guess, and this function refuses to make it.
335
- //
336
- // A real capture contained three: a standing preamble ("whenever I ask anything, research
337
- // it for me, okay?"), the actual request ("how is the weather in Fairview today?") and a
338
- // meta-question about the tool ("does it get added to?"). Last-wins picks the third,
339
- // longest-wins picks the first, and every other rule that fits this sample is a rule fitted
340
- // to this sample. Choosing between them needs to understand them — which is a model's job,
341
- // and exactly what `needsModel` exists to ask for. So: an unambiguous question is used, and
342
- // an ambiguous one is handed on WHOLE with `ambiguous` set, for the caller to refine.
343
- const questions = meaningful.filter((t) => /\?\s*$/.test(t));
344
- const ambiguous = questions.length > 1;
345
- const picked = questions.length === 1
346
- ? questions[0]
347
- : (meaningful.length ? meaningful.join(' ') : raw);
348
- const request = stripLeadIn(picked).trim() || raw;
349
- // The name is a label in a list, not the instruction. One line, clipped on a word boundary.
350
- const flat = request.replace(/\s+/g, ' ').trim();
351
- const name = flat.length > maxName
352
- ? `${flat.slice(0, maxName - 1).replace(/\s+\S*$/, '')}…`
353
- : flat;
354
- return { request, name: name || flat, ambiguous };
355
- }
356
-
357
- /**
358
- * Ask a model what was actually being asked — the other half of `needsModel`.
359
- *
360
- * The parser has always returned `needsModel: true` for a command it did not recognise, with
361
- * a comment saying the host "may pay for a small model to read it, and MUST NOT guess". No
362
- * host ever did, so an unrecognised spoken request became a job whose name and prompt were
363
- * both the entire utterance. refineSpokenCommand() is the free floor under this; when several
364
- * questions were asked it declines to choose, and THIS is what chooses.
365
- *
366
- * Deliberately a tiny, single-shot classification with a strict output shape: it runs on a
367
- * fast model while a meeting is happening, so it must cost about as much as one sentence.
368
- */
369
- /**
370
- * The shape of the answer, declared ONCE.
371
- *
372
- * It used to be typed twice — as prose inside the prompt string and again as a list of enum
373
- * values thirty lines below in the parser — with nothing making the two agree. Adding a kind
374
- * to one and not the other is a silent, permanent bug: the model answers correctly and the
375
- * parser maps it to "question" forever. Now the prompt is rendered from this and the parser
376
- * coerces onto it, so there is only one place a field exists.
377
- */
378
- export const REFINEMENT_SCHEMA = defineSchema({
379
- name: 'voice_refinement',
380
- fields: {
381
- request: {
382
- type: 'string', required: true, max: 400,
383
- describe: 'the one thing they actually want done, in their own words, one sentence',
384
- },
385
- name: { type: 'string', max: 48, describe: 'a label of at most 6 words' },
386
- kind: {
387
- type: 'enum',
388
- // 'action' is here because it was MISSING, and the gap was silent. A browser command
389
- // ("go to google.com and search for chat panel") is not something they want to KNOW, so
390
- // it does not read as a question — and the only other bucket that fitted a rambling,
391
- // narrated demo was "none", which is dropped without a word. Spoken four different ways
392
- // in one meeting, it did nothing every time while the timer beside it worked.
393
- values: ['question', 'action', 'monitor', 'note', 'skill', 'timer', 'none'],
394
- // An unknown kind becomes a QUESTION — the least surprising thing to do with something
395
- // someone asked for, and the only kind that is undone by ignoring the answer. Guessing
396
- // "monitor" instead would leave a card watching the meeting that nobody asked for.
397
- default: 'question',
398
- describe: 'the SMALLEST kind that does what they asked',
399
- },
400
- skill: { type: 'string', max: 80, describe: 'the skill name, only when kind is skill' },
401
- },
402
- // "none" is a real answer and the most important one to honour: it is how the model says
403
- // "they were just talking", which is the case that produced junk jobs. It arrives two ways —
404
- // as the whole reply, and as the value of `request` — and both are this.
405
- nothing: { request: '', name: '', kind: 'none', skill: '' },
406
- });
407
-
408
- export function refinementPrompt(utterance) {
409
- return [
410
- 'A person spoke to their assistant during a meeting. Below is everything they said after',
411
- 'the wake word, transcribed live — so it contains false starts, thinking aloud, and',
412
- 'sometimes several questions where only one is the request.',
413
- '',
414
- describeSchema(REFINEMENT_SCHEMA),
415
- '',
416
- 'Pick the SMALLEST kind that does what they asked:',
417
- ' question — answer it once, now. The DEFAULT for anything they want to know.',
418
- ' action — DO something in the browser or an app ("go to google.com and search for X").',
419
- ' monitor — only if they asked to be told as the meeting CONTINUES ("let me know if",',
420
- ' "keep an eye on"). A one-off question is NOT a monitor.',
421
- ' note — they asked for notes written down ("take notes on", "write that up").',
422
- ' skill — they named a saved skill ("use the summarize skill"); put its name in `skill`.',
423
- ' timer — alerted after an AMOUNT OF TIME ("set a one minute"); keep it in `request`.',
424
- ' none — not asking for anything: thinking aloud, or talking ABOUT the assistant.',
425
- 'Never invent a request that is not there — return "none". Keep `request` close to their',
426
- 'words; do not answer it.',
427
- '',
428
- 'WHAT THEY SAID:',
429
- String(utterance || ''),
430
- ].join('\n');
431
- }
432
-
433
- /**
434
- * The body fragment that makes a capable endpoint enforce the shape server-side.
435
- * Null for an agent CLI, which has no such control — the prompt and the repair pass carry it.
436
- */
437
- export function refinementFormat(mode = 'schema') { return responseFormat(REFINEMENT_SCHEMA, { mode }); }
438
-
439
- /**
440
- * Read the model's answer back, defensively.
441
- *
442
- * Everything generic — code fences, a prose preamble, single quotes, a trailing comma, the
443
- * word "none" in place of an object, a key spelled `Request` — is handled by the shared
444
- * coercer, which means every OTHER structured call in the product gets those repairs too.
445
- * What stays here is only what is true of THIS answer and no other.
446
- *
447
- * Returns null for anything unusable, so the caller falls back to the deterministic pass
448
- * rather than acting on a hallucinated request.
449
- */
450
- export function parseRefinement(text) {
451
- const got = coerce(text, REFINEMENT_SCHEMA);
452
- if (!got) return null;
453
- return settleRefinement(got.value);
454
- }
455
-
456
- /**
457
- * The two rules that are about voice commands rather than about JSON.
458
- *
459
- * Shared with the streaming reader below, because a rule applied on the final answer and not
460
- * on the partial one is a rule the user watches the UI break. Exported because a caller that
461
- * fetches the answer through the generic structured-call capability gets the raw coerced
462
- * object and still needs these — the rules must not live only inside one of two paths.
463
- */
464
- export function settleRefinement(v) {
465
- if (!v) return null;
466
- const request = String(v.request || '').trim();
467
- if (v.kind === 'none' || !request) return { request: '', name: '', kind: 'none', skill: '' };
468
- // A "skill" with no name is a question — there is nothing to run.
469
- let kind = v.kind === 'skill' && !v.skill ? 'question' : v.kind;
470
- const name = String(v.name || '').trim() || request;
471
- // A TIMER IS RESOLVED HERE, not by whatever runs the request.
472
- //
473
- // A spoken timer the grammar missed used to arrive as a plain question, so it went to the
474
- // chat — where an agent answered it by running `sleep 60` in its own sandbox and saying it
475
- // would notify. It cannot: nothing connects that process back to the user. Reading the
476
- // duration here turns it back into a job the product itself owns and can fire.
477
- //
478
- // No duration means the model called it a timer without one, and a timer with no duration
479
- // is a question about time. Downgraded rather than dropped.
480
- if (kind === 'timer') {
481
- const d = parseDuration(request);
482
- if (!d) return { request, name, kind: 'question', skill: '' };
483
- return { request, name, kind: 'timer', skill: '', ms: d.ms };
484
- }
485
- return { request, name, kind, skill: v.skill || '' };
486
- }
487
-
488
- /**
489
- * Did the user's own spoken words name this host?
490
- *
491
- * The authority test for a hands-free browser command. A URL a MODEL picked is
492
- * attacker-influenced by construction — it has been reading page text and meeting captions —
493
- * so it gets a confirmation dialog. A URL whose host the USER said out loud has already been
494
- * reviewed by the only person that dialog would have asked, and a modal in a side panel is
495
- * exactly what nobody in a meeting is looking at: "go to google.com and search for chat
496
- * panel" was spoken four ways in one call and did nothing every time.
497
- *
498
- * Deliberately strict. The full hostname always counts ("google.com"). The bare first label
499
- * counts ONLY for a two-label host, so saying "docs" can never authorise `docs.evil.test` —
500
- * anything deeper has to be said in full.
501
- *
502
- * Shared rather than written in the panel because it is a pure decision with no platform in
503
- * it: the bridge relays the same page tools, and a second copy of an authority rule is a
504
- * second answer to "may this happen".
505
- */
506
- export function spokenNamesHost(url, spoken) {
507
- const said = String(spoken || '').toLowerCase();
508
- if (!said) return false;
509
- let host;
510
- try { host = new URL(url).hostname.toLowerCase().replace(/^www\./, ''); } catch { return false; }
511
- if (!host) return false;
512
- if (said.includes(host)) return true;
513
- const labels = host.split('.');
514
- if (labels.length !== 2) return false;
515
- return new RegExp(`\\b${labels[0].replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`).test(said);
516
- }
517
-
518
- /**
519
- * The same answer, AS IT ARRIVES.
520
- *
521
- * A refinement is asked for mid-meeting while someone is waiting to see whether they were
522
- * heard, and the standing rule is that every model output streams with visible progress. The
523
- * `settled` set is what makes that safe: `request` can be shown growing, and `kind` — which
524
- * decides whether a monitor gets created — is only acted on once the model has closed it.
525
- *
526
- * const s = refinementStream({ onChange: (v, settled) => paint(v, settled) });
527
- * await stream({ …, onDelta: (d) => s.push(d) });
528
- * const final = s.end().value; // already settled, or null
529
- */
530
- export function refinementStream({ onChange = null } = {}) {
531
- const inner = createStructuredStream(REFINEMENT_SCHEMA, {
532
- onChange: onChange ? (v, settled) => onChange(settleRefinement(v), settled) : null,
533
- });
534
- const wrap = (snap) => ({ ...snap, value: settleRefinement(snap.value) });
535
- return {
536
- push: (chunk) => wrap(inner.push(chunk)),
537
- end: () => wrap(inner.end()),
538
- snapshot: () => wrap(inner.snapshot()),
539
- reset: () => inner.reset(),
540
- get text() { return inner.text; },
541
- };
542
- }
543
-
544
- /**
545
- * Was the assistant SPOKEN TO, or merely spoken about?
546
- *
547
- * "we should talk about the chat panel roadmap next week" contains the wake word and is not a
548
- * command — acting on it is how ordinary conversation quietly set timers. But "Okay, chat
549
- * panel. Anytime I ask a question…" is unmistakably addressed, and dropping it is why a
550
- * clearly-spoken request did nothing at all.
551
- *
552
- * The signal that separates them is grammatical and cheap: a wake word used as a NOUN is
553
- * introduced by a determiner or preposition ("the chat panel", "about ChatPanel", "our chat
554
- * panel"). A wake word used as a VOCATIVE is at the start of what is being said, or follows
555
- * an address word ("okay", "hey", "hi"), or follows the end of the previous sentence.
556
- *
557
- * Wrong sometimes, in both directions — which is exactly why it decides whether to ASK
558
- * (needsModel, a visible and reversible monitor) rather than whether to act.
559
- */
560
- 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;
561
-
562
- // Adverbs that sit between a subject and its verb — "chat panel ACTUALLY helps us".
563
- const SUBJECT_ADVERBS = /^(?:actually|really|also|always|never|just|only|still|often|usually|basically|literally|probably|certainly|definitely|now|then|even|apparently|obviously)$/i;
564
- // Verb forms that make whatever comes before them the SUBJECT of a claim rather than the
565
- // person being spoken to. A closed list on purpose: the general rule ("any third-person verb")
566
- // cannot be told from an imperative without a parser, and guessing wrong here costs a command.
567
- 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;
568
- // …unless a pronoun follows, which turns the same auxiliary into a question aimed at us:
569
- // "chatpanel, can YOU set a timer" against "chat panel can help us".
570
- const QUESTION_PRONOUNS = /^(?:you|we|i|they|it|there|he|she)$/i;
571
-
572
- const bare = (t) => String(t?.w || '').replace(/[^\p{L}\p{N}']/gu, '');
573
-
574
- /**
575
- * Is the wake phrase the SUBJECT of the sentence rather than the person being addressed?
576
- *
577
- * "…what chat panel actually helps us to monitor" is a sentence ABOUT the product, and it
578
- * fired a request. Nothing in the words before the name says so — the giveaway is what comes
579
- * after it: a vocative is followed by a comma, an imperative or a question word, while a
580
- * subject is followed by its verb.
581
- */
582
- function readsAsSubject(raw, tokens, endIdx) {
583
- const next = tokens[endIdx + 1];
584
- if (!next) return false; // nothing after the name at all — not a claim about it
585
- // Punctuation between the name and what follows is the vocative comma (or a sentence
586
- // break). Either way the name stands alone, which subjects do not do.
587
- if (/[.!?,;:–—-]/.test(raw.slice(tokens[endIdx].end, next.start))) return false;
588
- let j = endIdx + 1;
589
- if (SUBJECT_ADVERBS.test(bare(tokens[j])) && tokens[j + 1]) j += 1;
590
- if (!SUBJECT_VERBS.test(bare(tokens[j]))) return false;
591
- return !QUESTION_PRONOUNS.test(bare(tokens[j + 1]));
592
- }
593
-
594
- function isAddressed(raw, tokens, i, n = 0) {
595
- // The fuzzy match is generous enough to SWALLOW a leading article: "a chat panel" squashes
596
- // to "achatpanel", one edit from "chatpanel", so the determiner ends up inside the matched
597
- // span instead of before it. Check the first matched token too, or "a chat panel would be
598
- // useful here" reads as an address purely because the "a" was absorbed.
599
- if (n > 0 && NOUN_MARKERS.test(String(tokens[i].w || '').replace(/[^\p{L}\p{N}]/gu, ''))) return false;
600
- // Whatever came before it, a name followed by its own verb is being TALKED ABOUT.
601
- if (readsAsSubject(raw, tokens, i + n)) return false;
602
- if (i === 0) return true; // nothing before it — it opens the utterance
603
- const prev = tokens[i - 1];
604
- const word = String(prev.w || '').replace(/[^\p{L}\p{N}]/gu, '');
605
- if (NOUN_MARKERS.test(word)) return false; // "the chat panel" — a thing, not a listener
606
- // "hey chatpanel", "ok chatpanel" — an address word is how people open one.
607
- if (/^(?:ok|okay|hey|hi|yo|hello|so|um|uh)$/i.test(word)) return true;
608
- // Punctuation before it is the vocative comma or a sentence break — "…here. Okay, chat
609
- // panel", "so I was thinking. ChatPanel, what did we decide?" — and both mean a fresh
610
- // address rather than a continuing noun phrase. Measured from the previous token's START,
611
- // because the tokenizer keeps trailing punctuation ON the token ("thinking."), so the gap
612
- // between tokens is only the space and the full stop would be missed.
613
- const gap = raw.slice(prev.start, tokens[i].start);
614
- if (!/[.!?,;:]["'’”)\]]*\s*$/.test(gap)) return false;
615
- // …but ONLY a break the speaker actually made. The transcriber invents full stops, and it
616
- // invents them mid-clause: "…another round of testing to see what. Chat panel actually
617
- // helps us to monitor" is one sentence about the product, cut in half by a machine, and the
618
- // half-stop made the second half read as a fresh address. A break is only a break when the
619
- // words before it are a finished thought — the same test that decides when a command has
620
- // stopped growing, for exactly the same reason.
621
- return !/[.!?…]/.test(gap) || commandLooksFinished(raw.slice(0, prev.end));
622
- }
623
-
624
- /**
625
- * Drop the NEXT address's run-up from the end of this command.
626
- *
627
- * A command is bounded by where the next wake phrase starts — but people open an address with
628
- * a word or two before the name ("…Okay, chat panel."), and those land on the end of the
629
- * previous command. Harmless to read, ruinous to identity: as the caption grows, "start
630
- * monitoring the pricing question." becomes "…pricing question. Okay", which is different
631
- * words, a different key, and therefore the same request acted on twice.
632
- *
633
- * Only the address words are trimmed, and only from the end — the same short list that marks
634
- * an opening in isAddressed().
635
- */
636
- function trimTrailingLeadIn(text) {
637
- return String(text)
638
- .replace(/(?:[\s,.:;!?-]*\b(?:ok|okay|hey|hi|yo|hello|so|um+|uh+|and|then|now)\b)+[\s,.:;!?-]*$/i, '')
639
- .trim();
640
- }
641
-
642
- // "chatpanel, could you please set a timer" — politeness is not part of the command, and
643
- // leaving it in makes every intent pattern carry an optional-courtesy prefix.
644
- function stripLeadIn(text) {
645
- return String(text)
646
- .replace(/^[\s,.:;!?-]+/, '')
647
- .replace(/^(?:(?:hey|hi|ok|okay|yo)\b[\s,]*)+/i, '')
648
- .replace(/^(?:(?:can|could|would|will)\s+you\s+(?:please\s+)?|please\s+)/i, '')
649
- .trim();
650
- }
651
-
652
- // ---------------------------------------------------------------------------
653
- // Numbers, durations and clock times as people actually say them
654
- // ---------------------------------------------------------------------------
655
-
656
- const SMALL = {
657
- zero: 0, one: 1, two: 2, three: 3, four: 4, five: 5, six: 6, seven: 7, eight: 8, nine: 9,
658
- ten: 10, eleven: 11, twelve: 12, thirteen: 13, fourteen: 14, fifteen: 15, sixteen: 16,
659
- seventeen: 17, eighteen: 18, nineteen: 19,
660
- };
661
- const TENS = { twenty: 20, thirty: 30, forty: 40, fourty: 40, fifty: 50, sixty: 60, seventy: 70, eighty: 80, ninety: 90 };
662
- const FRACTION = { half: 0.5, quarter: 0.25 };
663
-
664
- /** "twenty five" → 25, "a" → 1, "half" → 0.5. Returns null when the words are not a number. */
665
- export function parseNumberWords(words) {
666
- if (!words.length) return null;
667
- let total = null;
668
- // THE TWO-MINUTE ONE-MINUTE TIMER. The article used to set the count to 1 outright, and
669
- // everything after it ADDS — so "set a one minute timer", which is how most people say it,
670
- // came out as 1 + 1 = two minutes. Reported as "I asked for a 1-minute timer, it didn't
671
- // work": it worked, twice as long, which looks exactly like not working.
672
- //
673
- // The article is now only a count when nothing else supplies one. "A minute" is still a
674
- // minute; "a one minute" is one minute, not two.
675
- let article = false;
676
- for (let i = 0; i < words.length; i++) {
677
- const w = words[i];
678
- if (w === 'and' || w === 'of') continue; // "two AND a half", "a quarter OF an hour"
679
- // "an hour" is one hour. "A quarter of an hour" is a quarter, and "two and A half" is
680
- // 2.5 — in both of those the article belongs to the fraction, not to the count.
681
- if (w === 'a' || w === 'an') {
682
- if (total === null && !(words[i + 1] in FRACTION)) article = true;
683
- continue;
684
- }
685
- if (w in FRACTION) { total = (total ?? 0) + FRACTION[w]; continue; }
686
- if (w in SMALL) { total = (total ?? 0) + SMALL[w]; continue; }
687
- if (w in TENS) { total = (total ?? 0) + TENS[w]; continue; }
688
- if (/^\d+(?:\.\d+)?$/.test(w)) { total = (total ?? 0) + Number(w); continue; }
689
- return null;
690
- }
691
- return total ?? (article ? 1 : null);
692
- }
693
-
694
- const UNIT_MS = {
695
- second: 1000, seconds: 1000, sec: 1000, secs: 1000, s: 1000,
696
- minute: 60_000, minutes: 60_000, min: 60_000, mins: 60_000, m: 60_000,
697
- hour: 3_600_000, hours: 3_600_000, hr: 3_600_000, hrs: 3_600_000, h: 3_600_000,
698
- day: 86_400_000, days: 86_400_000,
699
- };
700
-
701
- // "10", "10m", "90s" — a number welded to its unit, which is how people type and how STT
702
- // sometimes renders speech.
703
- const GLUED = /^(\d+(?:\.\d+)?)(s|m|h|secs?|mins?|hrs?|seconds?|minutes?|hours?|days?)$/;
704
-
705
- /**
706
- * Total duration named anywhere in `text`: "10 minutes", "an hour and a half",
707
- * "1 hour 30 minutes", "90s", "half an hour", "two and a half hours".
708
- *
709
- * Summing every (quantity, unit) pair rather than taking the first is what makes
710
- * "1 hour 30 minutes" 90 minutes instead of an hour.
711
- */
712
- export function parseDuration(text) {
713
- const tokens = tokenize(text);
714
- let ms = 0;
715
- let start = -1;
716
- let end = -1;
717
- let matched = false;
718
- let qty = []; // words that could still add up to a quantity
719
- let qtyStart = -1;
720
-
721
- for (let i = 0; i < tokens.length; i++) {
722
- const t = tokens[i];
723
- const take = (n, unit, from, to) => {
724
- ms += n * unit;
725
- if (start < 0) start = from;
726
- end = to;
727
- matched = true;
728
- };
729
- const glued = GLUED.exec(t.w);
730
- const unit = glued ? UNIT_MS[glued[2]] : UNIT_MS[t.w];
731
- if (!unit) {
732
- // Not a unit: extend the pending quantity while it still parses as a number, else
733
- // start over from this word. An unrelated clause before the number cannot poison it.
734
- const next = [...qty, t.w];
735
- if (parseNumberWords(next) !== null) { if (qtyStart < 0) qtyStart = t.start; qty = next; }
736
- else if (parseNumberWords([t.w]) !== null) { qty = [t.w]; qtyStart = t.start; }
737
- else { qty = []; qtyStart = -1; }
738
- continue;
739
- }
740
- if (glued) take(Number(glued[1]), unit, t.start, t.end);
741
- else {
742
- const n = qty.length ? parseNumberWords(qty) : null;
743
- if (n !== null) take(n, unit, qtyStart >= 0 ? qtyStart : t.start, t.end);
744
- }
745
- qty = []; qtyStart = -1;
746
- // "an hour and a half" — the fraction trails its unit, so here is the only place it
747
- // can be attributed to the right one.
748
- const j = consumeTrailingFraction(tokens, i);
749
- if (j > i) { ms += FRACTION[tokens[j].w] * unit; end = tokens[j].end; i = j; }
750
- }
751
- if (!matched || ms <= 0) return null;
752
- return { ms: Math.round(ms), start, end };
753
- }
754
-
755
- // Index of the last token of a trailing "…and a half" / "…and a quarter", or `i` when what
756
- // follows is something else.
757
- function consumeTrailingFraction(tokens, i) {
758
- let j = i + 1;
759
- if (tokens[j]?.w !== 'and') return i;
760
- j++;
761
- if (tokens[j]?.w === 'a' || tokens[j]?.w === 'an') j++;
762
- return FRACTION[tokens[j]?.w] === undefined ? i : j;
763
- }
764
-
765
- const WEEKDAYS = {
766
- sunday: 0, sun: 0, monday: 1, mon: 1, tuesday: 2, tue: 2, tues: 2, wednesday: 3, wed: 3,
767
- weds: 3, thursday: 4, thu: 4, thur: 4, thurs: 4, friday: 5, fri: 5, saturday: 6, sat: 6,
768
- };
769
-
770
- // When someone names a part of the day instead of a time. Chosen to be unsurprising rather
771
- // than clever: a reminder that fires at a time nobody expected is worse than one that fires
772
- // at a boring one.
773
- export const DAYPART_HOUR = Object.freeze({ morning: 9, afternoon: 14, evening: 19, night: 20, tonight: 19, noon: 12, midnight: 0 });
774
-
775
- function atLocal(base, { days = 0, hour, minute = 0 }) {
776
- const d = new Date(base);
777
- d.setDate(d.getDate() + days);
778
- d.setHours(hour, minute, 0, 0);
779
- return d.getTime();
780
- }
781
-
782
- /**
783
- * When a command says to do something: "in 20 minutes", "at 9am", "tomorrow at 3",
784
- * "on Wednesday at 9am", "every weekday morning".
785
- *
786
- * Returns `{ at, recurrence }`. `recurrence` is null for one-shots and otherwise the shape
787
- * the scheduler consumes — daily/weekly plus a local wall-clock time, NOT an interval in
788
- * milliseconds, because "every day at 8am" survives a daylight-saving change and
789
- * "every 86400000ms" does not.
790
- */
791
- export function parseWhen(text, { now = Date.now() } = {}) {
792
- const raw = String(text || '');
793
- const norm = normalizeSpeech(raw);
794
-
795
- // WHERE the time was said matters as much as what it was: a reminder's body is the
796
- // command minus the time phrase, and a phrase at the START of the sentence ("remind me
797
- // every weekday morning to check the queue") used to take the whole reminder with it.
798
- let from = Infinity;
799
- let to = -1;
800
- const span = (a, b) => { if (a < from) from = a; if (b > to) to = b; };
801
-
802
- // "in 20 minutes" — relative, and unambiguous enough to answer before anything else.
803
- const rel = /\bin\s+(.+)$/i.exec(norm);
804
- if (rel) {
805
- const d = parseDuration(rel[1]);
806
- if (d) {
807
- const base = rel.index + rel[0].length - rel[1].length;
808
- return { at: now + d.ms, recurrence: null, kind: 'relative', ...widen(norm, rel.index, base + d.end) };
809
- }
810
- }
811
-
812
- const every = /\bevery\s+(day|morning|afternoon|evening|night|week|weekday|[a-z]+day|mon|tue|tues|wed|weds|thu|thur|thurs|fri|sat|sun)\b/i.exec(norm)
813
- || /\b(daily|nightly|weekly)\b/i.exec(norm);
814
- const clock = parseClock(norm);
815
- const dayWord = /\b(today|tonight|tomorrow)\b/i.exec(norm);
816
- const weekdayMatch = /\b(next\s+)?(sunday|sun|monday|mon|tuesday|tues|tue|wednesday|weds|wed|thursday|thurs|thur|thu|friday|fri|saturday|sat)\b/i.exec(norm);
817
- const daypart = /\b(morning|afternoon|evening|tonight|night|noon|midnight)\b/i.exec(norm);
818
-
819
- if (every) span(every.index, every.index + every[0].length);
820
- if (clock) span(clock.start, clock.end);
821
- if (dayWord) span(dayWord.index, dayWord.index + dayWord[0].length);
822
- if (weekdayMatch) span(weekdayMatch.index, weekdayMatch.index + weekdayMatch[0].length);
823
- if (daypart) span(daypart.index, daypart.index + daypart[0].length);
824
-
825
- let hour = clock ? clock.hour : daypart ? DAYPART_HOUR[daypart[1]] : null;
826
- const minute = clock ? clock.minute : 0;
827
- // "tonight at 8" is 8 in the EVENING. A bare hour with no meridiem, said alongside a part
828
- // of the day that is plainly not the morning, means the afternoon reading.
829
- if (clock && !clock.meridiem && hour < 12 && daypart && DAYPART_HOUR[daypart[1]] >= 12) hour += 12;
830
-
831
- if (every) {
832
- const word = (every[1] || '').toLowerCase();
833
- const h = hour ?? DAYPART_HOUR[word] ?? DAYPART_HOUR[word.replace(/ly$/, '')] ?? 9; // "nightly" is night
834
- const at = word in WEEKDAYS
835
- ? nextWeekday(now, WEEKDAYS[word], h, minute)
836
- : word === 'week' || word === 'weekly'
837
- ? atLocal(now, { days: 7, hour: h, minute })
838
- : nextDailyAt(now, h, minute, word === 'weekday');
839
- const recurrence = word in WEEKDAYS
840
- ? { kind: 'weekly', weekday: WEEKDAYS[word], hour: h, minute }
841
- : word === 'week' || word === 'weekly'
842
- ? { kind: 'weekly', weekday: new Date(now).getDay(), hour: h, minute }
843
- : { kind: 'daily', hour: h, minute, weekdaysOnly: word === 'weekday' };
844
- return { at, recurrence, kind: 'recurring', ...widen(norm, from, to) };
845
- }
846
-
847
- if (hour === null && !dayWord && !weekdayMatch) return null;
848
-
849
- if (weekdayMatch) {
850
- const wd = WEEKDAYS[weekdayMatch[2]];
851
- const h = hour ?? 9;
852
- // "next Wednesday" is never today, even when today is Wednesday and the hour is ahead.
853
- const at = nextWeekday(now, wd, h, minute, !!weekdayMatch[1]);
854
- return { at, recurrence: null, kind: 'weekday', ...widen(norm, from, to) };
855
- }
856
-
857
- const h = hour ?? DAYPART_HOUR[daypart?.[1] || 'morning'];
858
- if (dayWord) {
859
- const w = dayWord[1].toLowerCase();
860
- return { at: atLocal(now, { days: w === 'tomorrow' ? 1 : 0, hour: h, minute }), recurrence: null, kind: w, ...widen(norm, from, to) };
861
- }
862
- // A bare clock time: today if it is still ahead, otherwise the same time tomorrow. Firing
863
- // immediately for a time that has already passed is never what was meant.
864
- let at = atLocal(now, { hour: h, minute });
865
- if (at <= now) at = atLocal(now, { days: 1, hour: h, minute });
866
- return { at, recurrence: null, kind: 'clock', ...widen(norm, from, to) };
867
- }
868
-
869
- // Grow a time span backwards over the preposition that introduced it, so cutting it out of
870
- // "take the kids to school AT 9am" does not leave a dangling "at".
871
- function widen(norm, from, to) {
872
- if (!(from >= 0) || !(to > from)) return { start: -1, end: -1 };
873
- const lead = /\b(?:at|on|by|in|this|starting|from)\s+$/i.exec(norm.slice(0, from));
874
- return { start: lead ? from - lead[0].length : from, end: to };
875
- }
876
-
877
- function nextDailyAt(now, hour, minute, weekdaysOnly = false) {
878
- let at = atLocal(now, { hour, minute });
879
- if (at <= now) at = atLocal(now, { days: 1, hour, minute });
880
- if (weekdaysOnly) {
881
- for (let i = 0; i < 7; i++) {
882
- const day = new Date(at).getDay();
883
- if (day !== 0 && day !== 6) break;
884
- at = atLocal(at, { days: 1, hour, minute });
885
- }
886
- }
887
- return at;
888
- }
889
-
890
- function nextWeekday(now, weekday, hour, minute, skipToday = false) {
891
- const d = new Date(now);
892
- let delta = (weekday - d.getDay() + 7) % 7;
893
- if (delta === 0 && (skipToday || atLocal(now, { hour, minute }) <= now)) delta = 7;
894
- return atLocal(now, { days: delta, hour, minute });
895
- }
896
-
897
- /** "9am", "9:30 pm", "at nine", "21:15", "9 o'clock". Returns 24h {hour, minute}. */
898
- export function parseClock(text) {
899
- const norm = normalizeSpeech(text);
900
- // A NUMBER IS ONLY A TIME WHEN SOMETHING SAYS SO. "set a timer for 10 minutes" contains
901
- // the digits of a perfectly good clock time, and reading it as 10 o'clock is how a timer
902
- // becomes tomorrow morning. The cue must be attached to THIS number — a meridiem, a
903
- // minutes part, "o'clock", or an immediately preceding "at" — not merely present
904
- // somewhere in the sentence.
905
- const re = /(\d{1,2})(?:[:.](\d{2}))?\s*(a\.?m\.?|p\.?m\.?|o'?clock)?/gi;
906
- let m;
907
- while ((m = re.exec(norm))) {
908
- const attachedAt = /\bat\s+$/.test(norm.slice(0, m.index));
909
- if (!m[2] && !m[3] && !attachedAt) continue;
910
- let hour = Number(m[1]);
911
- const minute = m[2] ? Number(m[2]) : 0;
912
- if (hour > 23 || minute > 59) continue;
913
- const mer = (m[3] || '').replace(/[.\s]/g, '').toLowerCase();
914
- if (mer === 'pm' && hour < 12) hour += 12;
915
- if (mer === 'am' && hour === 12) hour = 0;
916
- return { hour, minute, meridiem: mer === 'am' || mer === 'pm', start: m.index, end: m.index + m[0].length };
917
- }
918
- // Spelled out: "at nine am". "at half past" is deliberately unsupported — rare in STT
919
- // output and ambiguous enough to deserve a model rather than a guess.
920
- const words = /\bat\s+([a-z]+)(?:\s+(a\.?m\.?|p\.?m\.?))?/i.exec(norm);
921
- if (words) {
922
- const n = parseNumberWords([words[1]]);
923
- if (n !== null && Number.isInteger(n) && n >= 0 && n <= 23) {
924
- let hour = n;
925
- const mer = (words[2] || '').replace(/[.\s]/g, '').toLowerCase();
926
- if (mer === 'pm' && hour < 12) hour += 12;
927
- if (mer === 'am' && hour === 12) hour = 0;
928
- return { hour, minute: 0, meridiem: mer === 'am' || mer === 'pm', start: words.index, end: words.index + words[0].length };
929
- }
930
- }
931
- return null;
932
- }
933
-
934
- // ---------------------------------------------------------------------------
935
- // Intents — declarations, so a client adds one without touching the parser
936
- // ---------------------------------------------------------------------------
937
-
938
- /**
939
- * @param match (command, ctx) => args | null. Pure and synchronous, for the same reason
940
- * rules.js insists on it: "did this match" must be answerable without side effects.
941
- * @param classUsed what carrying it out costs — R for a local timer, C when it will start a
942
- * model turn. Declared, never inferred, so the honest answer to "did that spend
943
- * anything" is readable in the declaration.
944
- * @param effects 'idempotent' | 'non-replayable' — the host uses it to decide whether a
945
- * redelivered command may be re-run.
946
- */
947
- export function defineVoiceIntent({
948
- id, label, description = '', examples = [], classUsed = 'R',
949
- effects = 'idempotent', requiresApproval = false, match,
950
- }) {
951
- if (!id) throw new VoiceIntentError('BAD_INTENT', 'intent.id required');
952
- if (typeof match !== 'function') throw new VoiceIntentError('BAD_INTENT', `intent '${id}': match required`);
953
- return Object.freeze({ id, label: label || id, description, examples: Object.freeze([...examples]), classUsed, effects, requiresApproval, match });
954
- }
955
-
956
- export function createVoiceIntentRegistry(intents = []) {
957
- const list = [...intents];
958
- return {
959
- add(intent) {
960
- list.push(intent);
961
- return () => { const i = list.indexOf(intent); if (i >= 0) list.splice(i, 1); };
962
- },
963
- list: () => [...list],
964
- get: (id) => list.find((i) => i.id === id) || null,
965
-
966
- /**
967
- * First intent whose pattern matches wins; declaration order is precedence. A command
968
- * nothing recognises comes back with `needsModel`, which is a different answer from "not
969
- * a command" — the host may pay for a small model to read it, and MUST NOT guess.
970
- */
971
- parse(command, ctx = {}) {
972
- const text = String(command || '').trim();
973
- if (!text) return null;
974
- for (const intent of list) {
975
- let args = null;
976
- try { args = intent.match(text, ctx); } catch { args = null; }
977
- if (args) return { intent: intent.id, label: intent.label, classUsed: intent.classUsed, effects: intent.effects, requiresApproval: intent.requiresApproval, args, command: text, needsModel: false };
978
- }
979
- return { intent: null, args: null, command: text, needsModel: true };
980
- },
981
- };
982
- }
983
-
984
- // ── the built-ins ──────────────────────────────────────────────────────────
985
- // Deliberately the four that are local, revertible and need no new permission. Anything
986
- // that sends, spends or clicks is not a good first thing to trigger by talking near a
987
- // laptop, and belongs behind the per-action confirm gate the host already has.
988
-
989
- export const timerIntent = defineVoiceIntent({
990
- id: 'voice:timer',
991
- label: 'Set a timer',
992
- description: 'Starts a countdown and alerts when it finishes.',
993
- examples: ['set a timer for 10 minutes', 'start a 90 second timer', 'timer for an hour and a half'],
994
- match: (command, { now = Date.now() } = {}) => {
995
- const d = parseDuration(command);
996
- if (!d) return null;
997
- // THE MISSING HEAD NOUN. "Okay ChatPanel, set a 1-minute timer" reached the scanner as
998
- // "set a one minute." — the word this pattern was keyed on was still being said. With no
999
- // intent it went to the model, which answered a spoken timer by running `sleep 60` in a
1000
- // sandbox and promising a notification it had no way to deliver.
1001
- //
1002
- // So a SET verb whose only content is a duration is a timer: nothing else is ever said
1003
- // that way. Anything left over after the duration and the plumbing words means it is
1004
- // something else — "set a 5 minute meeting" is a meeting — and still needs the noun.
1005
- if (!/\btimers?\b/i.test(command)) {
1006
- if (!/^(?:set|start|make|create|put)\b/i.test(command.trim())) return null;
1007
- const rest = (command.slice(0, d.start) + ' ' + command.slice(d.end))
1008
- .replace(/\b(set|start|make|create|put|a|an|the|for|please|to|of|and|half|quarter|this|that|time|up|on|me)\b/gi, ' ')
1009
- .replace(/[^\p{L}\p{N}]+/gu, '');
1010
- if (rest) return null;
1011
- }
1012
- // "timer for the standup" — whatever is left once the duration and the plumbing words
1013
- // are removed is what the timer is FOR, and a labelled timer is the difference between
1014
- // three anonymous countdowns and three useful ones.
1015
- const label = (command.slice(0, d.start) + ' ' + command.slice(d.end))
1016
- .replace(/\b(set|start|make|create|a|an|the|for|please|timer|timers|to|of|and|half|quarter)\b/gi, ' ');
1017
- return { ms: d.ms, at: now + d.ms, label: tidy(label) };
1018
- },
1019
- });
1020
-
1021
- export const reminderIntent = defineVoiceIntent({
1022
- id: 'voice:reminder',
1023
- label: 'Set a reminder',
1024
- description: 'Remembers something and raises it at the time you said.',
1025
- examples: ['remind me to send the deck at 4pm', 'remember to take the kids to school at 9am on Wednesday', 'remind me every weekday morning to check the queue'],
1026
- match: (command, { now = Date.now() } = {}) => {
1027
- const m = /\b(?:remind\s+(?:me|us)|reminder|remember)\b/i.exec(command);
1028
- if (!m) return null;
1029
- const when = parseWhen(command, { now });
1030
- // Cut out exactly the span parseWhen matched — a notification that already says when it
1031
- // is should not also read "…at 9am on wednesday" in its title, and the phrase can sit at
1032
- // either end of the sentence ("remind me every weekday morning to check the queue").
1033
- let text = when && when.end > when.start
1034
- ? command.slice(0, when.start) + ' ' + command.slice(when.end)
1035
- : command;
1036
- text = text.slice(text.toLowerCase().indexOf(m[0].toLowerCase()) + m[0].length);
1037
- text = tidy(text.replace(/^(?:\s*(?:to|that|about|i\s+need\s+to|we\s+need\s+to))\b/i, ''));
1038
- if (!text) return null; // "remind me" with nothing to remember is not a reminder
1039
- return { text, at: when?.at ?? null, recurrence: when?.recurrence ?? null, when: when?.kind ?? null };
1040
- },
1041
- });
1042
-
1043
- export const noteIntent = defineVoiceIntent({
1044
- id: 'voice:note',
1045
- label: 'Take a note',
1046
- description: 'Appends a line to the meeting notes.',
1047
- examples: ['note that we agreed to ship on Friday', 'take a note: budget is approved'],
1048
- match: (command) => {
1049
- // "Take THE NOTES of whatever we spoke so far" — the plural, the definite article and
1050
- // "of" instead of "that" were all misses, so the request went to the model, which spent
1051
- // four tool calls hunting for a transcript before writing anything.
1052
- const m = /^(?:(?:take|make|write|jot|add)\s+(?:down\s+)?(?:a\s+|the\s+|some\s+)?notes?|(?:write|jot)\s+down|notes?)\b[\s:,-]*(?:down\s+)?(?:that\s+|of\s+|on\s+|about\s+|from\s+)?(.+)$/i.exec(command.trim());
1053
- const text = m && tidy(m[1]);
1054
- return text ? { text } : null;
1055
- },
1056
- });
1057
-
1058
- export const monitorIntent = defineVoiceIntent({
1059
- id: 'voice:monitor',
1060
- label: 'Watch for something',
1061
- description: 'Starts a live monitor that answers as the meeting continues.',
1062
- examples: ['watch for whether we agree a date', 'keep an eye on the pricing question', 'track who owns the migration'],
1063
- classUsed: 'C', // it starts model turns for the rest of the meeting — say so
1064
- match: (command) => {
1065
- // "Start a live monitor about…" is how it was asked for in the very demo of the feature,
1066
- // and it matched nothing: every pattern here began at the verb, so the noun form —
1067
- // start/set up a monitor — fell through to the model and no card was ever created.
1068
- const m = /^(?:(?:start|set\s+up|create|begin|add|open|run)\s+(?:a\s+|the\s+|an\s+)?(?:live\s+|new\s+)?(?:monitor|monitoring|watch|tracker)|watch\s+(?:out\s+)?for|watch|keep\s+an\s+eye\s+on|track|monitor|monitoring)\b[\s:,-]*(?:whether\s+|if\s+|for\s+|on\s+|about\s+|that\s+)?(.+)$/i.exec(command.trim());
1069
- const prompt = m && tidy(m[1]);
1070
- return prompt && prompt.length > 2 ? { prompt } : null;
1071
- },
1072
- });
1073
-
1074
- // "Every weekday at 8am run my daily brief." The recurrence parser already existed for
1075
- // reminders; what makes this different is that the thing being scheduled is WORK — a skill
1076
- // the user already wrote — so the job says only when, and the skill stays the single
1077
- // definition of what. Declared class C because it will start a model turn every time.
1078
- export const scheduleIntent = defineVoiceIntent({
1079
- id: 'voice:schedule',
1080
- label: 'Schedule something',
1081
- description: 'Runs one of your skills (or a plain instruction) on a schedule.',
1082
- examples: ['every weekday at 8am run my daily brief', 'run the standup summary every morning', 'tomorrow at 9 do the release checklist'],
1083
- classUsed: 'C',
1084
- match: (command, { now = Date.now() } = {}) => {
1085
- const verb = /\b(run|do|start|kick\s+off|execute)\b/i.exec(command);
1086
- if (!verb) return null;
1087
- const when = parseWhen(command, { now });
1088
- // No time is not a schedule — it is a request to do something now, which is a chat
1089
- // message, not a job. Refusing here is what keeps "run the checklist" out of the
1090
- // scheduler.
1091
- if (!when) return null;
1092
- let target = when.end > when.start
1093
- ? command.slice(0, when.start) + ' ' + command.slice(when.end)
1094
- : command;
1095
- const v = /\b(run|do|start|kick\s+off|execute)\b/i.exec(target);
1096
- target = tidy((v ? target.slice(v.index + v[0].length) : target)
1097
- .replace(/^\s*(?:my|the|our)\b/i, '')
1098
- .replace(/\b(skill|job|task)\b\s*$/i, ''));
1099
- if (!target) return null;
1100
- return { target, at: when.at, recurrence: when.recurrence, when: when.kind };
1101
- },
1102
- });
1103
-
1104
- export const BUILTIN_VOICE_INTENTS = Object.freeze([timerIntent, reminderIntent, scheduleIntent, noteIntent, monitorIntent]);
1105
-
1106
- function tidy(s) {
1107
- return String(s || '').replace(/\s+/g, ' ').replace(/^[\s,.:;-]+|[\s,.:;-]+$/g, '').trim();
1108
- }
1109
-
1110
- /** A registry preloaded with the built-ins — the common case, one call. */
1111
- export function defaultVoiceIntents() {
1112
- return createVoiceIntentRegistry(BUILTIN_VOICE_INTENTS);
1113
- }
1114
-
1115
- /**
1116
- * Parse one utterance end to end: wake word, then intent.
1117
- *
1118
- * Returns null when the utterance was not addressed to us — which is almost every utterance
1119
- * in a meeting, and must therefore be the cheapest path through this module.
1120
- */
1121
- export function parseCommand(text, { wake = compileWake(), intents = defaultVoiceIntents(), now = Date.now() } = {}) {
1122
- const found = findWakeCommand(text, wake);
1123
- if (!found) return null;
1124
- const parsed = intents.parse(found.command, { now });
1125
- if (!parsed) return null;
1126
- return { ...parsed, wake: found.wake, heard: found.heard, at: found.at, addressed: found.addressed !== false };
1127
- }
1128
-
1129
- // ---------------------------------------------------------------------------
1130
- // Transcript → commands
1131
- // ---------------------------------------------------------------------------
1132
-
1133
- /**
1134
- * Has the speaker finished the thought? — and PUNCTUATION IS NOT THE EVIDENCE.
1135
- *
1136
- * This used to be `endsSentence`: a full stop at the end of the caption meant the speaker had
1137
- * stopped. Live caption engines punctuate as they go, and they punctuate FRAGMENTS. One
1138
- * capture of this feature in use produced, in order: "Take.", "Take the question and ask
1139
- * the.", "set a timer for.", "let's summar." — four full stops nobody uttered, and four
1140
- * half-sentences sent to a model as requests while the speaker was still saying the rest.
1141
- *
1142
- * So the full stop is thrown away and the LAST WORD is read instead. A command ending on a
1143
- * preposition, an article, a conjunction or an auxiliary ("…ask the", "…a timer for") is
1144
- * someone mid-thought, however the transcriber punctuated it.
1145
- *
1146
- * A HINT, NOT A VERDICT. "Tell me what that is" is a real request that ends on 'is', so a
1147
- * dangling tail must never DISCARD a command — it only makes the gate below wait longer for
1148
- * the rest to arrive. A command that is never acted on is the worse failure of the two.
1149
- */
1150
- // Words that can end a sentence grammatically but in speech mean the qualifier is still being
1151
- // chosen — "summarize the last 30 seconds, like, maybe…".
1152
- const TRAILING_HEDGES = new Set(['maybe', 'perhaps', 'probably', 'basically', 'roughly', 'kinda', 'sorta']);
1153
-
1154
- export function commandLooksFinished(text) {
1155
- const t = String(text || '').trim().replace(/[\s.,;:!?…'"’”)\]-]+$/u, '');
1156
- if (!t) return false;
1157
- const tokens = t.toLowerCase().match(/[\p{L}\p{N}']+/gu) || [];
1158
- const last = tokens[tokens.length - 1];
1159
- if (!last) return false;
1160
- return !DANGLING_TAILS.has(last) && !TRAILING_HEDGES.has(last);
1161
- }
1162
-
1163
- // Terminal punctuation. Worth almost nothing on its own — see above — but it is the only
1164
- // signal a caller with no gate has, so the ungated path keeps asking for it ON TOP of the
1165
- // tail test rather than getting looser than it was.
1166
- const endsSentence = (text) => /[.!?…]["'’”)\]]*\s*$/.test(String(text || '').trim());
1167
-
1168
- /**
1169
- * The SHORTEST span that parses wins.
1170
- *
1171
- * "Set a timer for 30 seconds. And then that should actually set a timer for 30 seconds."
1172
- * is one request said twice, and the duration parser sums what it finds across the span —
1173
- * so a two-sentence window turned 30 seconds into 60 and produced a one-minute timer nobody
1174
- * asked for. Worse, it moved: as the caption grew, the same words re-parsed to a different
1175
- * duration, which is a different dedupe key, which is another timer. That is the "why is it
1176
- * creating timers again and again" report.
1177
- *
1178
- * So the first sentence is tried alone, and the wider span only when it yields nothing. A
1179
- * request that genuinely needs two ("Set a timer. Make it five minutes.") still gets them.
1180
- */
1181
- function parseShortest(command, intents, now) {
1182
- const first = firstSentences(command, 1).trim();
1183
- if (first && first !== command) {
1184
- const narrow = intents.parse(first, { now });
1185
- if (narrow?.intent) return narrow;
1186
- }
1187
- return intents.parse(command, { now });
1188
- }
1189
-
1190
- /**
1191
- * A command's words, reduced to what survives re-transcription.
1192
- *
1193
- * Case and spacing vary between flushes of the same sentence, and punctuation appears and
1194
- * disappears as the engine revises — so none of them may be part of an identity that is
1195
- * supposed to say "you have already done this".
1196
- */
1197
- export const gistText = (text) => String(text || '')
1198
- .toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim().slice(0, 160);
1199
-
1200
- /**
1201
- * The OPENING of a request — a looser identity than its full words.
1202
- *
1203
- * A transcriber does not only append; it REVISES. "How is the weather in Seattle, Washington
1204
- * now?" became "How is the weather in Seattle, Washington?" on a later flush — different
1205
- * words, a different key, and the same question asked twice. The first few words are what a
1206
- * revision leaves alone.
1207
- *
1208
- * Six is chosen against the failure it exists to stop: long enough that two genuinely
1209
- * different requests rarely share an opening, short enough to survive the tail being rewritten.
1210
- */
1211
- export const OPENING_WORDS = 6;
1212
- export const gistOpening = (text) => gistText(text).split(' ').slice(0, OPENING_WORDS).join(' ');
1213
-
1214
- /** How many commands one transcript delta may produce. */
1215
- export const MAX_COMMANDS_PER_DELTA = 3;
1216
-
1217
- /**
1218
- * Scan new transcript segments for commands addressed to us.
1219
- *
1220
- * WHO IS ALLOWED TO SPEAK TO IT is the whole security question here. A meeting transcript
1221
- * carries everyone in the room, so an ungated version of this lets any participant put
1222
- * reminders on someone else's device by saying the wake word — and lets a compromised page
1223
- * do it by writing captions. `self` is therefore matched by the HOST, which is the only
1224
- * layer that knows which label is the device owner; segments from anyone else come back
1225
- * with `allowed: false` rather than being dropped silently, so "why didn't it fire" has an
1226
- * answer.
1227
- *
1228
- * @param segments [{ t, speaker, text }] — the delta, not the whole meeting.
1229
- * @param isSelf (speaker) => boolean. Omit ONLY when the host has decided anyone may
1230
- * command this install; the default refuses, because failing closed on a
1231
- * question about authority is the only safe default.
1232
- */
1233
- export function commandsFromSegments(segments, {
1234
- wake = compileWake(), intents = defaultVoiceIntents(), isSelf = null,
1235
- sinceTs = 0, now = Date.now(), meetingId = '', max = MAX_COMMANDS_PER_DELTA, gate = null,
1236
- } = {}) {
1237
- const out = [];
1238
- for (const seg of segments || []) {
1239
- if (!seg || !seg.text) continue;
1240
- if (seg.t && seg.t <= sinceTs) continue;
1241
- // EVERY address in this caption, not just the first. A live caption carries a minute of
1242
- // speech, and a person addressing an assistant addresses it more than once in a minute —
1243
- // "…set a timer for 1 minute. Okay chat panel, go and search…" is two commands. Taking
1244
- // only the first also made that first command swallow the rest, which is how a one-minute
1245
- // timer became 720 hours: the duration parser found "30 days" four sentences later.
1246
- for (const found of findWakeCommands(seg.text, wake)) {
1247
- if (!found.command) continue; // a bare mention with nothing after it
1248
- const intent = parseShortest(found.command, intents, now);
1249
- // `rest` rides along: the deterministic intents parse only the tight span (that is what
1250
- // stops a duration being found four sentences away), but a MODEL asked to read an
1251
- // unrecognised request should see the words around it — the question often follows a
1252
- // sentence of preamble. Parse narrow, refine wide.
1253
- const parsed = intent && {
1254
- ...intent, wake: found.wake, heard: found.heard, at: found.at,
1255
- addressed: found.addressed, rest: found.rest,
1256
- };
1257
- if (!parsed) continue;
1258
- // MENTIONED vs ADDRESSED — and this guard used to conflate them.
1259
- //
1260
- // parseCommand returns a shape for anything carrying the wake word, intent or not, so
1261
- // "we should talk about the chat panel roadmap next week" came back as a command and the
1262
- // caller acted on it: ordinary conversation quietly setting timers. Dropping every
1263
- // intentless utterance fixed that, and broke the opposite case just as badly — "Okay,
1264
- // chat panel. How is the weather in Lakeside?" is unmistakably a request, matches no
1265
- // built-in intent (there is no weather intent, and there should not be), and was
1266
- // discarded here. Nothing downstream ever saw it, which is why `needsModel` had no
1267
- // handler: it could not reach one.
1268
- //
1269
- // So the test is whether the assistant was SPOKEN TO. A passing mention still stops here.
1270
- // An address with no matching intent goes on with needsModel set, for a model to read —
1271
- // which is what the parser has always said should happen.
1272
- //
1273
- if (!parsed.intent && !parsed.addressed) continue;
1274
- // Does this read as a whole thought? Computed once, where the command's own words are:
1275
- // the guard below and the gate must never be able to answer that differently.
1276
- const finished = commandLooksFinished(found.command);
1277
- // WHETHER THE SENTENCE HAS ENDED IS NOT DECIDED HERE — when there is a gate.
1278
- //
1279
- // It used to be decided here, on the caption's terminal punctuation, and that is exactly
1280
- // what sent "Take the question and ask the." to a model as a request. This function sees
1281
- // ONE delivery of a caption; only something watching the same utterance across
1282
- // deliveries can tell a finished sentence from a punctuated fragment, and that is the
1283
- // gate below (`finished` is the hint it reads).
1284
- //
1285
- // A caller with no gate has no such thing, so it keeps the old conservative rule and
1286
- // gains the tail test on top of it: both, or the request waits for the next delivery.
1287
- // Getting LOOSER than the code being fixed would be a strange way to fix it.
1288
- if (!parsed.intent && !gate && !(finished && endsSentence(seg.text))) continue;
1289
- const allowed = isSelf ? !!isSelf(seg.speaker) : false;
1290
- out.push({
1291
- ...parsed,
1292
- allowed,
1293
- // Carried, so the gate reads the same answer this scan did.
1294
- finished,
1295
- speaker: seg.speaker || '',
1296
- t: seg.t || now,
1297
- meetingId,
1298
- // Stable across redeliveries of the same segment, so the dedupe actually dedupes.
1299
- //
1300
- // `parsed.at` used to be in this key, and it is an ABSOLUTE time computed as now + the
1301
- // spoken duration — so it changed on every scan. A live caption is rescanned as the
1302
- // sentence grows (deliberately: a half-heard command must get a second chance), which
1303
- // meant one "set a timer for 10 seconds" produced a brand-new key, and a brand-new
1304
- // timer, on every caption update — indefinitely, and faster than the user could delete
1305
- // them. The key now carries only what the same utterance keeps: where it was said, and
1306
- // what it asked for.
1307
- // IDENTITY, NOT FRESHNESS. `seg.t` is bumped every time a live caption's text grows —
1308
- // that is what keeps the line flowing through the delta filter — so keying on it made
1309
- // one spoken request look like a new request on every update, and a single "set a timer
1310
- // for 30 seconds" became a screenful of timers. `sid` is assigned once per utterance and
1311
- // never moves, so the same sentence keeps one key however many times it is rescanned.
1312
- // WHAT WAS ASKED, not which delivery of it carried the words.
1313
- //
1314
- // This used to key on the caption's identity (`sid`) and the wake word's offset. Both
1315
- // move: `sid` is re-minted whenever the caption engine loses the overlap between a
1316
- // growing line and the one before it, and a monologue keeps ONE entry alive for
1317
- // minutes — re-scanned on every flush, by design, so a half-heard command gets a
1318
- // second chance. So one spoken "set a timer for 30 seconds" kept arriving as a
1319
- // brand-new command and kept creating timers.
1320
- //
1321
- // The words are what does not move. Two different commands in one breath still differ;
1322
- // the same command through fifty flushes is one request.
1323
- // The OPENING, not the whole sentence: a live caption keeps growing ("…10 seconds",
1324
- // then "…10 seconds and then", then "…and then we moved on"), and gistText over the
1325
- // full text moves with every one of those — which is the very bug this key exists to
1326
- // stop, just later in the sentence. The intent and its resolved duration are already
1327
- // in the key, so two genuinely different commands still differ.
1328
- key: `voice:${meetingId}:${parsed.intent || 'ask'}:${parsed.ms ?? parsed.when ?? ''}:${gistOpening(found.command)}`,
1329
- });
1330
- }
1331
- }
1332
- // THE NEWEST, not the first.
1333
- //
1334
- // The cap exists so a pathological transcript cannot fire fifty actions, and it used to
1335
- // stop scanning once it had `max` — counting from the START of the caption. A caption entry
1336
- // in a monologue lives for minutes and is re-scanned on every flush, accumulating every
1337
- // address spoken into it, so the first three (long since acted on) consumed the whole
1338
- // budget and everything said AFTER them was never returned at all. The request you just
1339
- // made was the one thrown away.
1340
- //
1341
- // The newest are both the most likely to be fresh and the ones a person is waiting on, so
1342
- // the cap keeps those. The already-acted ones are dropped downstream by the dedupe anyway.
1343
- const found = out.length > max ? out.slice(-max) : out;
1344
- // WITH A GATE, nothing is returned until the words stop moving — see createUtteranceGate.
1345
- // Without one the old behaviour stands, so a host that has not adopted it (or a test asking
1346
- // what the grammar sees) is unchanged.
1347
- return gate ? gate.offer(found, now).due(now) : found;
1348
- }
1349
-
1350
- // ---------------------------------------------------------------------------
1351
- // One utterance, one action
1352
- // ---------------------------------------------------------------------------
1353
-
1354
- /**
1355
- * How long a command's words must stop changing before it is acted on.
1356
- *
1357
- * LONGER THAN THE CAPTURE'S FLUSH INTERVAL, and that is the whole calculation. Captions reach
1358
- * a client in batches — the extension debounces its flush by 4s — so "these words have not
1359
- * changed for 1s" says nothing except that no batch arrived in the last second. Only a wait
1360
- * that outlasts a flush can distinguish "they stopped talking" from "we have not been told
1361
- * what they said next".
1362
- */
1363
- export const UTTERANCE_SETTLE_MS = 5_000;
1364
-
1365
- /**
1366
- * How long a command that ends MID-THOUGHT waits instead.
1367
- *
1368
- * "Set a timer for" is not a request yet; the duration is in the breath after it. Waiting the
1369
- * ordinary window and acting on it is exactly the bug this file exists to stop. But the tail
1370
- * test is a word list, not grammar, and "tell me what that is" ends on 'is' — so a dangling
1371
- * command is DELAYED, never dropped. If the speaker really did stop there, it still runs.
1372
- */
1373
- export const UTTERANCE_DANGLING_MS = 12_000;
1374
-
1375
- // How long an utterance is remembered after it was last heard. A caption entry in a monologue
1376
- // is re-delivered for minutes, and every one of those redeliveries has to find the record
1377
- // saying "this one is done" — that record IS the one-utterance-one-action guarantee.
1378
- const UTTERANCE_FORGET_MS = 3 * 60_000;
1379
- // A cap, because a long meeting must not grow this without bound. Small: only utterances
1380
- // still in flight or recently acted on matter, and the caller has its own longer-lived record
1381
- // of what has been done.
1382
- const UTTERANCE_TRACKED_MAX = 24;
1383
-
1384
- /**
1385
- * Is this the same spoken request as that one, a moment later?
1386
- *
1387
- * Two relations, because a transcriber does both. It APPENDS — "let's summar" becomes "let's
1388
- * summarize the notes" — which is a prefix. And it REVISES — "…in Seattle, Washington now?"
1389
- * came back as "…in Seattle, Washington?" — which is not, but keeps the opening.
1390
- */
1391
- export function sameUtterance(a, b) {
1392
- if (!a || !b) return false;
1393
- if (a === b || a.startsWith(b) || b.startsWith(a)) return true;
1394
- const oa = a.split(' ').slice(0, OPENING_WORDS);
1395
- const ob = b.split(' ').slice(0, OPENING_WORDS);
1396
- // A short opening is not enough evidence: "set a timer" opens half the commands ever
1397
- // spoken, and collapsing two of them into one utterance loses the second silently.
1398
- return oa.length >= OPENING_WORDS && oa.join(' ') === ob.join(' ');
1399
- }
1400
-
1401
- /**
1402
- * The thing that makes a growing caption ONE request.
1403
- *
1404
- * THE BUG THIS IS. A live caption is re-delivered as it grows, and every delivery was acted
1405
- * on the moment it parsed. So a single spoken "Okay ChatPanel, set a timer for 30 seconds"
1406
- * arrived first as "set a timer for." — no duration, no intent, addressed, punctuated by the
1407
- * transcriber — and went to the model as a QUESTION; then, seconds later, arrived whole and
1408
- * became a TIMER. One thing said, two things done, and the user reasonably reported it as
1409
- * "it set the timer but it also sent a message". Nothing downstream could relate the two:
1410
- * they had different words, different intents, and therefore different dedupe keys.
1411
- *
1412
- * They can only be related by watching them, so this is the one stateful thing in the file —
1413
- * and it is still clock-free (`now` is passed in, like everywhere else) and platform-free.
1414
- * The host owns exactly two things: keeping the object, and calling back when the wait is up.
1415
- *
1416
- * const gate = createUtteranceGate();
1417
- * const ready = commandsFromSegments(segments, { …, gate }); // offers and drains
1418
- * const later = gate.nextDueIn(); // ms until something becomes actionable, or null
1419
- * …setTimeout(() => act(gate.due()), later) // silence needs a nudge
1420
- *
1421
- * Note what does NOT come out of `due()`: an utterance that already fired. It stays in the
1422
- * gate, matching its own redeliveries, so the completed version of a request that was already
1423
- * acted on cannot act again as something else.
1424
- */
1425
- export function createUtteranceGate({
1426
- settleMs = UTTERANCE_SETTLE_MS,
1427
- danglingMs = UTTERANCE_DANGLING_MS,
1428
- forgetMs = UTTERANCE_FORGET_MS,
1429
- max = UTTERANCE_TRACKED_MAX,
1430
- } = {}) {
1431
- let live = [];
1432
- const waitFor = (e) => (e.command.finished === false ? danglingMs : settleMs);
1433
- const find = (command, gist) => live.find(
1434
- (e) => e.meetingId === (command.meetingId || '') && sameUtterance(e.gist, gist),
1435
- );
1436
- return {
1437
- /** Offer this delta's commands. Chainable, so a scan reads as one expression. */
1438
- offer(commands, now = Date.now()) {
1439
- for (const command of commands || []) {
1440
- const gist = gistText(command.command);
1441
- if (!gist) continue;
1442
- const entry = find(command, gist);
1443
- if (!entry) {
1444
- live.push({ meetingId: command.meetingId || '', gist, command, changedAt: now, seenAt: now, done: false });
1445
- continue;
1446
- }
1447
- entry.seenAt = now;
1448
- if (entry.done) continue; // said once, done once — however many more words arrive
1449
- if (gist === entry.gist) continue; // unchanged: the clock keeps running, untouched
1450
- // Still growing (or being revised). The newest wording is the one to act on, and the
1451
- // wait starts again from here — which is what makes a pause, not a full stop, the
1452
- // signal that someone has finished.
1453
- entry.gist = gist;
1454
- entry.command = command;
1455
- entry.changedAt = now;
1456
- }
1457
- live = live.filter((e) => now - e.seenAt <= forgetMs);
1458
- if (live.length > max) live = live.slice(-max);
1459
- return this;
1460
- },
1461
- /** The commands whose words have stopped moving. Each is returned exactly once. */
1462
- due(now = Date.now()) {
1463
- const out = [];
1464
- for (const e of live) {
1465
- if (e.done || now - e.changedAt < waitFor(e)) continue;
1466
- e.done = true;
1467
- out.push(e.command);
1468
- }
1469
- return out;
1470
- },
1471
- /**
1472
- * ms until the earliest waiting command becomes actionable, or null when none is waiting.
1473
- * The host needs this because silence produces no deltas to re-scan on: the last thing
1474
- * said before someone stops talking is exactly the thing they are waiting to see happen.
1475
- */
1476
- nextDueIn(now = Date.now()) {
1477
- let soonest = null;
1478
- for (const e of live) {
1479
- if (e.done) continue;
1480
- const left = Math.max(0, waitFor(e) - (now - e.changedAt));
1481
- if (soonest === null || left < soonest) soonest = left;
1482
- }
1483
- return soonest;
1484
- },
1485
- /** How many utterances are still waiting — for tests and for a diagnostics line. */
1486
- get waiting() { return live.filter((e) => !e.done).length; },
1487
- };
1488
- }
1
+ import{defineSchema as ee,describeSchema as te,responseFormat as ne,coerce as re,createStructuredStream as se}from"./structured.js";import{DANGLING_TAILS as ie}from"./schedule.js";import{editDistance as U}from"./distance.js";class D extends Error{constructor(e,s){super(s),this.name="VoiceIntentError",this.code=e}}const oe=Object.freeze(["chatpanel"]);function I(t){return t<=4?0:t<=6?1:2}const j=3,ae=8;function C(t){return String(t||"").toLowerCase().replace(/[‘’]/g,"'").replace(/[^\p{L}\p{N}':.\s]/gu," ")}function W(t){const e=C(t),s=[],n=/[\p{L}\p{N}'.:]+/gu;let r;for(;r=n.exec(e);){const i=r[0].replace(/^[.:]+|[.:]+$/g,"");i&&s.push({w:i,start:r.index,end:r.index+r[0].length})}return s}function A(t=oe){const e=(Array.isArray(t)?t:String(t??"").split(/[,|;\n]/)).map(i=>String(i??"").trim()).filter(Boolean),s=[],n=[];let r=1;for(const i of e){const a=i.toLowerCase().replace(/[^\p{L}\p{N}]/gu,"");a.length<3||s.includes(a)||(s.push(a),n.push(i),r=Math.max(r,i.split(/\s+/).filter(Boolean).length+1))}if(!s.length)throw new D("BAD_WAKE","wake word must have at least 3 letters");return Object.freeze({phrases:Object.freeze(s),labels:Object.freeze(n),maxTokens:Math.min(Math.max(r,j),ae)})}const le=2;function ce(t,e,s){const n=[],r=s.maxTokens||j;for(let i=0;i<e.length;i++){let a="",c=!1;for(let l=0;l<r&&i+l<e.length&&!c;l++){a+=e[i+l].w.replace(/[^\p{L}\p{N}]/gu,"");for(const o of s.phrases)if(!(Math.abs(a.length-o.length)>I(o.length))&&U(a,o,I(o.length))<=I(o.length)){n.push({phrase:o,start:e[i].start,end:e[i+l].end,addressed:we(t,e,i,l)}),i+=l,c=!0;break}}}return n}function z(t,e){const s=String(t||"");if(!s)return s;const n=/[.!?…]+["'\u2019\u201d)\]]*(?=\s|$)/g;let r=0,i;for(;r<e&&(i=n.exec(s));)if(r+=1,r===e)return s.slice(0,i.index+i[0].length);return s}function F(t,e=A(),{maxSentences:s=le}={}){const n=String(t||""),r=W(n);if(!r.length)return[];const i=ce(n,r,e),a=[];let c=0;for(let l=0;l<i.length;l++){const o=i[l];if(!o.addressed&&o.start<c)continue;let f=n.length;for(let p=l+1;p<i.length;p++)if(i[p].addressed){f=i[p].start;break}const u=n.slice(o.end,f),d=ye($(z(u,s)).trim());c=Math.max(c,o.end+u.indexOf(d)+d.length),a.push({command:d,wake:o.phrase,heard:n.slice(o.start,o.end),at:o.start,addressed:o.addressed,rest:n.slice(o.end+u.indexOf(d)+d.length,f).trim()})}return a}function ue(t,e=A(),s={}){return F(t,e,s)[0]||null}const B=new RegExp("^(?:ok(?:ay)?|all ?right|right|so|well|um+|uh+|hmm+|yeah|yep|hey|and|but|then|now|let(?:'s| us) see|we(?:'ll| will) see|so we(?:'ll| will) see|i think it is doing something|it does anything|does it (?:do )?anything|hold on(?: a second)?|one second|let me see|i think|i guess|here we go|there we go|test(?:ing)?)[\\s,.!?]*$","i"),de=/(?<=[.!?])\s+/;function fe(t){const e=String(t||"").trim().replace(/^[\s,.:;!?-]+/,"");if(!e||B.test(e))return!0;const s=e.split(",").map(n=>n.trim()).filter(Boolean);return s.length>1&&s.every(n=>B.test(n))}function Be(t,{maxName:e=48}={}){const s=$(String(t||"")).trim();if(!s)return{request:"",name:""};const r=s.split(de).map(u=>u.trim()).filter(Boolean).filter(u=>!fe(u)),i=r.filter(u=>/\?\s*$/.test(u)),a=i.length>1,c=i.length===1?i[0]:r.length?r.join(" "):s,l=$(c).trim()||s,o=l.replace(/\s+/g," ").trim(),f=o.length>e?`${o.slice(0,e-1).replace(/\s+\S*$/,"")}…`:o;return{request:l,name:f||o,ambiguous:a}}const T=ee({name:"voice_refinement",fields:{request:{type:"string",required:!0,max:400,describe:"the one thing they actually want done, in their own words, one sentence"},name:{type:"string",max:48,describe:"a label of at most 6 words"},kind:{type:"enum",values:["question","action","monitor","note","skill","timer","none"],default:"question",describe:"the SMALLEST kind that does what they asked"},skill:{type:"string",max:80,describe:"the skill name, only when kind is skill"}},nothing:{request:"",name:"",kind:"none",skill:""}});function Ge(t){return["A person spoke to their assistant during a meeting. Below is everything they said after","the wake word, transcribed live — so it contains false starts, thinking aloud, and","sometimes several questions where only one is the request.","",te(T),"","Pick the SMALLEST kind that does what they asked:"," question — answer it once, now. The DEFAULT for anything they want to know.",' action — DO something in the browser or an app ("go to google.com and search for X").',' monitor — only if they asked to be told as the meeting CONTINUES ("let me know if",',' "keep an eye on"). A one-off question is NOT a monitor.',' note — they asked for notes written down ("take notes on", "write that up").',' skill — they named a saved skill ("use the summarize skill"); put its name in `skill`.',' timer — alerted after an AMOUNT OF TIME ("set a one minute"); keep it in `request`.'," none — not asking for anything: thinking aloud, or talking ABOUT the assistant.",'Never invent a request that is not there — return "none". Keep `request` close to their',"words; do not answer it.","","WHAT THEY SAID:",String(t||"")].join(`
2
+ `)}function Ke(t="schema"){return ne(T,{mode:t})}function He(t){const e=re(t,T);return e?O(e.value):null}function O(t){if(!t)return null;const e=String(t.request||"").trim();if(t.kind==="none"||!e)return{request:"",name:"",kind:"none",skill:""};let s=t.kind==="skill"&&!t.skill?"question":t.kind;const n=String(t.name||"").trim()||e;if(s==="timer"){const r=M(e);return r?{request:e,name:n,kind:"timer",skill:"",ms:r.ms}:{request:e,name:n,kind:"question",skill:""}}return{request:e,name:n,kind:s,skill:t.skill||""}}function Ve(t,e){const s=String(e||"").toLowerCase();if(!s)return!1;let n;try{n=new URL(t).hostname.toLowerCase().replace(/^www\./,"")}catch{return!1}if(!n)return!1;if(s.includes(n))return!0;const r=n.split(".");return r.length!==2?!1:new RegExp(`\\b${r[0].replace(/[.*+?^${}()|[\]\\]/g,"\\$&")}\\b`).test(s)}function Pe({onChange:t=null}={}){const e=se(T,{onChange:t?(n,r)=>t(O(n),r):null}),s=n=>({...n,value:O(n.value)});return{push:n=>s(e.push(n)),end:()=>s(e.end()),snapshot:()=>s(e.snapshot()),reset:()=>e.reset(),get text(){return e.text}}}const G=/^(?:the|a|an|our|your|their|my|this|that|these|those|about|on|in|of|with|via|using|called|named|to)$/i,he=/^(?:actually|really|also|always|never|just|only|still|often|usually|basically|literally|probably|certainly|definitely|now|then|even|apparently|obviously)$/i,me=/^(?: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,pe=/^(?:you|we|i|they|it|there|he|she)$/i,q=t=>String(t?.w||"").replace(/[^\p{L}\p{N}']/gu,"");function ge(t,e,s){const n=e[s+1];if(!n||/[.!?,;:–—-]/.test(t.slice(e[s].end,n.start)))return!1;let r=s+1;return he.test(q(e[r]))&&e[r+1]&&(r+=1),me.test(q(e[r]))?!pe.test(q(e[r+1])):!1}function we(t,e,s,n=0){if(n>0&&G.test(String(e[s].w||"").replace(/[^\p{L}\p{N}]/gu,""))||ge(t,e,s+n))return!1;if(s===0)return!0;const r=e[s-1],i=String(r.w||"").replace(/[^\p{L}\p{N}]/gu,"");if(G.test(i))return!1;if(/^(?:ok|okay|hey|hi|yo|hello|so|um|uh)$/i.test(i))return!0;const a=t.slice(r.start,e[s].start);return/[.!?,;:]["'’”)\]]*\s*$/.test(a)?!/[.!?…]/.test(a)||J(t.slice(0,r.end)):!1}function ye(t){return String(t).replace(/(?:[\s,.:;!?-]*\b(?:ok|okay|hey|hi|yo|hello|so|um+|uh+|and|then|now)\b)+[\s,.:;!?-]*$/i,"").trim()}function $(t){return String(t).replace(/^[\s,.:;!?-]+/,"").replace(/^(?:(?:hey|hi|ok|okay|yo)\b[\s,]*)+/i,"").replace(/^(?:(?:can|could|would|will)\s+you\s+(?:please\s+)?|please\s+)/i,"").trim()}const K={zero:0,one:1,two:2,three:3,four:4,five:5,six:6,seven:7,eight:8,nine:9,ten:10,eleven:11,twelve:12,thirteen:13,fourteen:14,fifteen:15,sixteen:16,seventeen:17,eighteen:18,nineteen:19},H={twenty:20,thirty:30,forty:40,fourty:40,fifty:50,sixty:60,seventy:70,eighty:80,ninety:90},x={half:.5,quarter:.25};function L(t){if(!t.length)return null;let e=null,s=!1;for(let n=0;n<t.length;n++){const r=t[n];if(!(r==="and"||r==="of")){if(r==="a"||r==="an"){e===null&&!(t[n+1]in x)&&(s=!0);continue}if(r in x){e=(e??0)+x[r];continue}if(r in K){e=(e??0)+K[r];continue}if(r in H){e=(e??0)+H[r];continue}if(/^\d+(?:\.\d+)?$/.test(r)){e=(e??0)+Number(r);continue}return null}}return e??(s?1:null)}const V={second:1e3,seconds:1e3,sec:1e3,secs:1e3,s:1e3,minute:6e4,minutes:6e4,min:6e4,mins:6e4,m:6e4,hour:36e5,hours:36e5,hr:36e5,hrs:36e5,h:36e5,day:864e5,days:864e5},ke=/^(\d+(?:\.\d+)?)(s|m|h|secs?|mins?|hrs?|seconds?|minutes?|hours?|days?)$/;function M(t){const e=W(t);let s=0,n=-1,r=-1,i=!1,a=[],c=-1;for(let l=0;l<e.length;l++){const o=e[l],f=(h,y,k,m)=>{s+=h*y,n<0&&(n=k),r=m,i=!0},u=ke.exec(o.w),d=u?V[u[2]]:V[o.w];if(!d){const h=[...a,o.w];L(h)!==null?(c<0&&(c=o.start),a=h):L([o.w])!==null?(a=[o.w],c=o.start):(a=[],c=-1);continue}if(u)f(Number(u[1]),d,o.start,o.end);else{const h=a.length?L(a):null;h!==null&&f(h,d,c>=0?c:o.start,o.end)}a=[],c=-1;const p=xe(e,l);p>l&&(s+=x[e[p].w]*d,r=e[p].end,l=p)}return!i||s<=0?null:{ms:Math.round(s),start:n,end:r}}function xe(t,e){let s=e+1;return t[s]?.w!=="and"?e:(s++,(t[s]?.w==="a"||t[s]?.w==="an")&&s++,x[t[s]?.w]===void 0?e:s)}const b={sunday:0,sun:0,monday:1,mon:1,tuesday:2,tue:2,tues:2,wednesday:3,wed:3,weds:3,thursday:4,thu:4,thur:4,thurs:4,friday:5,fri:5,saturday:6,sat:6},S=Object.freeze({morning:9,afternoon:14,evening:19,night:20,tonight:19,noon:12,midnight:0});function w(t,{days:e=0,hour:s,minute:n=0}){const r=new Date(t);return r.setDate(r.getDate()+e),r.setHours(s,n,0,0),r.getTime()}function P(t,{now:e=Date.now()}={}){const s=String(t||""),n=C(s);let r=1/0,i=-1;const a=(m,g)=>{m<r&&(r=m),g>i&&(i=g)},c=/\bin\s+(.+)$/i.exec(n);if(c){const m=M(c[1]);if(m){const g=c.index+c[0].length-c[1].length;return{at:e+m.ms,recurrence:null,kind:"relative",...E(n,c.index,g+m.end)}}}const l=/\bevery\s+(day|morning|afternoon|evening|night|week|weekday|[a-z]+day|mon|tue|tues|wed|weds|thu|thur|thurs|fri|sat|sun)\b/i.exec(n)||/\b(daily|nightly|weekly)\b/i.exec(n),o=Se(n),f=/\b(today|tonight|tomorrow)\b/i.exec(n),u=/\b(next\s+)?(sunday|sun|monday|mon|tuesday|tues|tue|wednesday|weds|wed|thursday|thurs|thur|thu|friday|fri|saturday|sat)\b/i.exec(n),d=/\b(morning|afternoon|evening|tonight|night|noon|midnight)\b/i.exec(n);l&&a(l.index,l.index+l[0].length),o&&a(o.start,o.end),f&&a(f.index,f.index+f[0].length),u&&a(u.index,u.index+u[0].length),d&&a(d.index,d.index+d[0].length);let p=o?o.hour:d?S[d[1]]:null;const h=o?o.minute:0;if(o&&!o.meridiem&&p<12&&d&&S[d[1]]>=12&&(p+=12),l){const m=(l[1]||"").toLowerCase(),g=p??S[m]??S[m.replace(/ly$/,"")]??9,R=m in b?X(e,b[m],g,h):m==="week"||m==="weekly"?w(e,{days:7,hour:g,minute:h}):be(e,g,h,m==="weekday"),Z=m in b?{kind:"weekly",weekday:b[m],hour:g,minute:h}:m==="week"||m==="weekly"?{kind:"weekly",weekday:new Date(e).getDay(),hour:g,minute:h}:{kind:"daily",hour:g,minute:h,weekdaysOnly:m==="weekday"};return{at:R,recurrence:Z,kind:"recurring",...E(n,r,i)}}if(p===null&&!f&&!u)return null;if(u){const m=b[u[2]];return{at:X(e,m,p??9,h,!!u[1]),recurrence:null,kind:"weekday",...E(n,r,i)}}const y=p??S[d?.[1]||"morning"];if(f){const m=f[1].toLowerCase();return{at:w(e,{days:m==="tomorrow"?1:0,hour:y,minute:h}),recurrence:null,kind:m,...E(n,r,i)}}let k=w(e,{hour:y,minute:h});return k<=e&&(k=w(e,{days:1,hour:y,minute:h})),{at:k,recurrence:null,kind:"clock",...E(n,r,i)}}function E(t,e,s){if(!(e>=0)||!(s>e))return{start:-1,end:-1};const n=/\b(?:at|on|by|in|this|starting|from)\s+$/i.exec(t.slice(0,e));return{start:n?e-n[0].length:e,end:s}}function be(t,e,s,n=!1){let r=w(t,{hour:e,minute:s});if(r<=t&&(r=w(t,{days:1,hour:e,minute:s})),n)for(let i=0;i<7;i++){const a=new Date(r).getDay();if(a!==0&&a!==6)break;r=w(r,{days:1,hour:e,minute:s})}return r}function X(t,e,s,n,r=!1){const i=new Date(t);let a=(e-i.getDay()+7)%7;return a===0&&(r||w(t,{hour:s,minute:n})<=t)&&(a=7),w(t,{days:a,hour:s,minute:n})}function Se(t){const e=C(t),s=/(\d{1,2})(?:[:.](\d{2}))?\s*(a\.?m\.?|p\.?m\.?|o'?clock)?/gi;let n;for(;n=s.exec(e);){const i=/\bat\s+$/.test(e.slice(0,n.index));if(!n[2]&&!n[3]&&!i)continue;let a=Number(n[1]);const c=n[2]?Number(n[2]):0;if(a>23||c>59)continue;const l=(n[3]||"").replace(/[.\s]/g,"").toLowerCase();return l==="pm"&&a<12&&(a+=12),l==="am"&&a===12&&(a=0),{hour:a,minute:c,meridiem:l==="am"||l==="pm",start:n.index,end:n.index+n[0].length}}const r=/\bat\s+([a-z]+)(?:\s+(a\.?m\.?|p\.?m\.?))?/i.exec(e);if(r){const i=L([r[1]]);if(i!==null&&Number.isInteger(i)&&i>=0&&i<=23){let a=i;const c=(r[2]||"").replace(/[.\s]/g,"").toLowerCase();return c==="pm"&&a<12&&(a+=12),c==="am"&&a===12&&(a=0),{hour:a,minute:0,meridiem:c==="am"||c==="pm",start:r.index,end:r.index+r[0].length}}}return null}function N({id:t,label:e,description:s="",examples:n=[],classUsed:r="R",effects:i="idempotent",requiresApproval:a=!1,match:c}){if(!t)throw new D("BAD_INTENT","intent.id required");if(typeof c!="function")throw new D("BAD_INTENT",`intent '${t}': match required`);return Object.freeze({id:t,label:e||t,description:s,examples:Object.freeze([...n]),classUsed:r,effects:i,requiresApproval:a,match:c})}function Ee(t=[]){const e=[...t];return{add(s){return e.push(s),()=>{const n=e.indexOf(s);n>=0&&e.splice(n,1)}},list:()=>[...e],get:s=>e.find(n=>n.id===s)||null,parse(s,n={}){const r=String(s||"").trim();if(!r)return null;for(const i of e){let a=null;try{a=i.match(r,n)}catch{a=null}if(a)return{intent:i.id,label:i.label,classUsed:i.classUsed,effects:i.effects,requiresApproval:i.requiresApproval,args:a,command:r,needsModel:!1}}return{intent:null,args:null,command:r,needsModel:!0}}}}const Ne=N({id:"voice:timer",label:"Set a timer",description:"Starts a countdown and alerts when it finishes.",examples:["set a timer for 10 minutes","start a 90 second timer","timer for an hour and a half"],match:(t,{now:e=Date.now()}={})=>{const s=M(t);if(!s||!/\btimers?\b/i.test(t)&&(!/^(?:set|start|make|create|put)\b/i.test(t.trim())||(t.slice(0,s.start)+" "+t.slice(s.end)).replace(/\b(set|start|make|create|put|a|an|the|for|please|to|of|and|half|quarter|this|that|time|up|on|me)\b/gi," ").replace(/[^\p{L}\p{N}]+/gu,"")))return null;const n=(t.slice(0,s.start)+" "+t.slice(s.end)).replace(/\b(set|start|make|create|a|an|the|for|please|timer|timers|to|of|and|half|quarter)\b/gi," ");return{ms:s.ms,at:e+s.ms,label:_(n)}}}),_e=N({id:"voice:reminder",label:"Set a reminder",description:"Remembers something and raises it at the time you said.",examples:["remind me to send the deck at 4pm","remember to take the kids to school at 9am on Wednesday","remind me every weekday morning to check the queue"],match:(t,{now:e=Date.now()}={})=>{const s=/\b(?:remind\s+(?:me|us)|reminder|remember)\b/i.exec(t);if(!s)return null;const n=P(t,{now:e});let r=n&&n.end>n.start?t.slice(0,n.start)+" "+t.slice(n.end):t;return r=r.slice(r.toLowerCase().indexOf(s[0].toLowerCase())+s[0].length),r=_(r.replace(/^(?:\s*(?:to|that|about|i\s+need\s+to|we\s+need\s+to))\b/i,"")),r?{text:r,at:n?.at??null,recurrence:n?.recurrence??null,when:n?.kind??null}:null}}),Ae=N({id:"voice:note",label:"Take a note",description:"Appends a line to the meeting notes.",examples:["note that we agreed to ship on Friday","take a note: budget is approved"],match:t=>{const e=/^(?:(?:take|make|write|jot|add)\s+(?:down\s+)?(?:a\s+|the\s+|some\s+)?notes?|(?:write|jot)\s+down|notes?)\b[\s:,-]*(?:down\s+)?(?:that\s+|of\s+|on\s+|about\s+|from\s+)?(.+)$/i.exec(t.trim()),s=e&&_(e[1]);return s?{text:s}:null}}),Te=N({id:"voice:monitor",label:"Watch for something",description:"Starts a live monitor that answers as the meeting continues.",examples:["watch for whether we agree a date","keep an eye on the pricing question","track who owns the migration"],classUsed:"C",match:t=>{const e=/^(?:(?:start|set\s+up|create|begin|add|open|run)\s+(?:a\s+|the\s+|an\s+)?(?:live\s+|new\s+)?(?:monitor|monitoring|watch|tracker)|watch\s+(?:out\s+)?for|watch|keep\s+an\s+eye\s+on|track|monitor|monitoring)\b[\s:,-]*(?:whether\s+|if\s+|for\s+|on\s+|about\s+|that\s+)?(.+)$/i.exec(t.trim()),s=e&&_(e[1]);return s&&s.length>2?{prompt:s}:null}}),Le=N({id:"voice:schedule",label:"Schedule something",description:"Runs one of your skills (or a plain instruction) on a schedule.",examples:["every weekday at 8am run my daily brief","run the standup summary every morning","tomorrow at 9 do the release checklist"],classUsed:"C",match:(t,{now:e=Date.now()}={})=>{if(!/\b(run|do|start|kick\s+off|execute)\b/i.exec(t))return null;const n=P(t,{now:e});if(!n)return null;let r=n.end>n.start?t.slice(0,n.start)+" "+t.slice(n.end):t;const i=/\b(run|do|start|kick\s+off|execute)\b/i.exec(r);return r=_((i?r.slice(i.index+i[0].length):r).replace(/^\s*(?:my|the|our)\b/i,"").replace(/\b(skill|job|task)\b\s*$/i,"")),r?{target:r,at:n.at,recurrence:n.recurrence,when:n.kind}:null}}),ve=Object.freeze([Ne,_e,Le,Ae,Te]);function _(t){return String(t||"").replace(/\s+/g," ").replace(/^[\s,.:;-]+|[\s,.:;-]+$/g,"").trim()}function Y(){return Ee(ve)}function Xe(t,{wake:e=A(),intents:s=Y(),now:n=Date.now()}={}){const r=ue(t,e);if(!r)return null;const i=s.parse(r.command,{now:n});return i?{...i,wake:r.wake,heard:r.heard,at:r.at,addressed:r.addressed!==!1}:null}const De=new Set(["maybe","perhaps","probably","basically","roughly","kinda","sorta"]);function J(t){const e=String(t||"").trim().replace(/[\s.,;:!?…'"’”)\]-]+$/u,"");if(!e)return!1;const s=e.toLowerCase().match(/[\p{L}\p{N}']+/gu)||[],n=s[s.length-1];return n?!ie.has(n)&&!De.has(n):!1}const Ie=t=>/[.!?…]["'’”)\]]*\s*$/.test(String(t||"").trim());function Ce(t,e,s){const n=z(t,1).trim();if(n&&n!==t){const r=e.parse(n,{now:s});if(r?.intent)return r}return e.parse(t,{now:s})}const Q=t=>String(t||"").toLowerCase().replace(/[^a-z0-9]+/g," ").trim().slice(0,160),v=6,Oe=t=>Q(t).split(" ").slice(0,v).join(" "),qe=3;function Ye(t,{wake:e=A(),intents:s=Y(),isSelf:n=null,sinceTs:r=0,now:i=Date.now(),meetingId:a="",max:c=qe,gate:l=null}={}){const o=[];for(const u of t||[])if(!(!u||!u.text)&&!(u.t&&u.t<=r))for(const d of F(u.text,e)){if(!d.command)continue;const p=Ce(d.command,s,i),h=p&&{...p,wake:d.wake,heard:d.heard,at:d.at,addressed:d.addressed,rest:d.rest};if(!h||!h.intent&&!h.addressed)continue;const y=J(d.command);if(!h.intent&&!l&&!(y&&Ie(u.text)))continue;const k=n?!!n(u.speaker):!1;o.push({...h,allowed:k,finished:y,speaker:u.speaker||"",t:u.t||i,meetingId:a,key:`voice:${a}:${h.intent||"ask"}:${h.ms??h.when??""}:${Oe(d.command)}`})}const f=o.length>c?o.slice(-c):o;return l?l.offer(f,i).due(i):f}const $e=5e3,Me=12e3,Re=3*6e4,Ue=24;function je(t,e){if(!t||!e)return!1;if(t===e||t.startsWith(e)||e.startsWith(t))return!0;const s=t.split(" ").slice(0,v),n=e.split(" ").slice(0,v);return s.length>=v&&s.join(" ")===n.join(" ")}function Je({settleMs:t=$e,danglingMs:e=Me,forgetMs:s=Re,max:n=Ue}={}){let r=[];const i=c=>c.command.finished===!1?e:t,a=(c,l)=>r.find(o=>o.meetingId===(c.meetingId||"")&&je(o.gist,l));return{offer(c,l=Date.now()){for(const o of c||[]){const f=Q(o.command);if(!f)continue;const u=a(o,f);if(!u){r.push({meetingId:o.meetingId||"",gist:f,command:o,changedAt:l,seenAt:l,done:!1});continue}u.seenAt=l,!u.done&&f!==u.gist&&(u.gist=f,u.command=o,u.changedAt=l)}return r=r.filter(o=>l-o.seenAt<=s),r.length>n&&(r=r.slice(-n)),this},due(c=Date.now()){const l=[];for(const o of r)o.done||c-o.changedAt<i(o)||(o.done=!0,l.push(o.command));return l},nextDueIn(c=Date.now()){let l=null;for(const o of r){if(o.done)continue;const f=Math.max(0,i(o)-(c-o.changedAt));(l===null||f<l)&&(l=f)}return l},get waiting(){return r.filter(c=>!c.done).length}}}export{ve as BUILTIN_VOICE_INTENTS,S as DAYPART_HOUR,oe as DEFAULT_WAKE,qe as MAX_COMMANDS_PER_DELTA,le as MAX_COMMAND_SENTENCES,v as OPENING_WORDS,T as REFINEMENT_SCHEMA,Me as UTTERANCE_DANGLING_MS,$e as UTTERANCE_SETTLE_MS,D as VoiceIntentError,J as commandLooksFinished,Ye as commandsFromSegments,A as compileWake,Je as createUtteranceGate,Ee as createVoiceIntentRegistry,Y as defaultVoiceIntents,N as defineVoiceIntent,U as editDistance,ue as findWakeCommand,F as findWakeCommands,Oe as gistOpening,Q as gistText,fe as isFillerSentence,Te as monitorIntent,C as normalizeSpeech,Ae as noteIntent,Se as parseClock,Xe as parseCommand,M as parseDuration,L as parseNumberWords,He as parseRefinement,P as parseWhen,Be as refineSpokenCommand,Ke as refinementFormat,Ge as refinementPrompt,Pe as refinementStream,_e as reminderIntent,je as sameUtterance,Le as scheduleIntent,O as settleRefinement,Ve as spokenNamesHost,Ne as timerIntent,W as tokenize};