@chatpanel/events 0.102.0 → 0.103.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/adapters.js +1 -83
- package/adaptive-tool-policy.js +1 -45
- package/agent.js +2 -255
- package/attribution.js +1 -132
- package/backup-envelope.js +1 -221
- package/board-tool.js +2 -165
- package/budget.js +1 -116
- package/capability.js +1 -126
- package/citations.js +5 -127
- package/client-prefs.js +1 -148
- package/context-attachments.js +8 -144
- package/cowriter-router.js +1 -83
- package/cowriter-writer.js +19 -193
- package/cowriter.js +1 -190
- package/curate.js +3 -509
- package/distance.js +1 -124
- package/engine.js +1 -131
- package/entitlement.js +1 -332
- package/entity.js +1 -354
- package/event.js +1 -175
- package/extraction.js +4 -293
- package/failover.js +1 -93
- package/find-tool.js +1 -66
- package/flowchart.js +2 -644
- package/gate.js +1 -74
- package/harness.js +4 -101
- package/index.js +1 -345
- package/invariants.js +1 -174
- package/job.js +3 -147
- package/kernel.js +1 -255
- package/knowledge-derive.js +1 -267
- package/knowledge.js +3 -230
- package/library.js +11 -280
- package/loop.js +1 -132
- package/manifest.js +1 -107
- package/markdown-authoring.js +11 -288
- package/markdown-render.js +0 -0
- package/mcp-client.js +8 -358
- package/mcp-dispatch.js +1 -52
- package/mcp-errors.js +1 -106
- package/mcp-manager.js +1 -127
- package/media-transcript.js +8 -531
- package/meeting-analyzers.js +1 -83
- package/meeting-insights.js +7 -164
- package/meeting-shape.js +1 -282
- package/meeting-text.js +5 -94
- package/memory.js +2 -638
- package/model-candidates.js +1 -358
- package/model-health.js +1 -182
- package/model-ledger.js +1 -228
- package/model-picker.js +1 -188
- package/note-actions.js +15 -246
- package/note-graph.js +1 -158
- package/note-links.js +1 -68
- package/note-mentions.js +8 -114
- package/note-plan.js +9 -107
- package/note-research.js +1 -135
- package/observability.js +1 -119
- package/omni.js +1 -125
- package/order.js +1 -78
- package/package.json +1 -4
- package/pdf-layout.js +7 -253
- package/project-run.js +20 -444
- package/project.js +1 -170
- package/promotion.js +1 -171
- package/queue.js +1 -98
- package/reach.js +1 -23
- package/recipe-tool.js +2 -161
- package/recipe.js +1 -264
- package/record-list.js +2 -110
- package/recruit.js +3 -456
- package/redaction-tokens.js +1 -61
- package/ref.js +1 -55
- package/registry.js +1 -240
- package/route-graph.js +1 -115
- package/route-strategies.js +2 -232
- package/router.js +2 -880
- package/rrf.js +1 -89
- package/rules.js +1 -142
- package/schedule.js +1 -663
- package/scm-connection.js +1 -180
- package/scopes.js +1 -12
- package/scorecard.js +1 -335
- package/search-engines.js +1 -81
- package/skill-manifest.js +1 -330
- package/skill-scan.js +3 -209
- package/skill-sources.js +1 -162
- package/skill-vars.js +2 -252
- package/slash-commands.js +1 -158
- package/source-gate.js +1 -92
- package/sources-retrieval.js +5 -186
- package/sources.js +1 -273
- package/store.js +1 -171
- package/structured.js +4 -901
- package/subject-kinds.js +1 -5
- package/subject-name.js +1 -96
- package/sync-plan.js +1 -213
- package/synthesis.js +3 -123
- package/tags.js +1 -203
- package/team-board.js +6 -337
- package/team-cache.js +2 -63
- package/team-observe.js +1 -118
- package/team-org.js +1 -442
- package/team-plan.js +4 -131
- package/team-record.js +1 -225
- package/team-run.js +9 -652
- package/team-subtask.js +2 -191
- package/team-task.js +2 -124
- package/team-tool.js +2 -196
- package/team-trail.js +2 -73
- package/team-worklog.js +2 -149
- package/team.js +1 -302
- package/text-search.js +1 -155
- package/theme.js +17 -154
- package/titles.js +4 -285
- package/tool-discovery.js +1 -87
- package/tool-dispatch.js +5 -230
- package/tool-groups.js +1 -81
- package/tool-hints.js +10 -185
- package/tool-loop-guard.js +4 -182
- package/tool-need.js +2 -124
- package/tool-result.js +2 -438
- package/tool-round.js +0 -0
- package/tool-schema.js +1 -155
- package/tool-traits.js +1 -182
- package/toolset.js +2 -88
- package/trajectory.js +1 -530
- package/turn-loop.js +4 -440
- package/upcast.js +1 -37
- package/vault.js +2 -250
- package/view.js +1 -88
- package/voice-intents.js +2 -1488
- package/voice-speaker.js +1 -98
- package/weather-tool.js +1 -65
- package/weather.js +2 -211
- package/web-search-tool.js +14 -102
- package/web-search.js +1 -160
- package/widget.js +1 -153
package/voice-intents.js
CHANGED
|
@@ -1,1488 +1,2 @@
|
|
|
1
|
-
|
|
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};
|