@chatpanel/events 0.14.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/capability.js +4 -0
- package/index.js +23 -0
- package/memory.js +638 -0
- package/package.json +13 -3
- package/schedule.js +374 -0
- package/scopes.js +1 -1
- package/view.js +88 -0
- package/voice-intents.js +613 -0
- package/widget.js +153 -0
package/voice-intents.js
ADDED
|
@@ -0,0 +1,613 @@
|
|
|
1
|
+
// Spoken commands — "<wake word>, set a timer for ten minutes".
|
|
2
|
+
//
|
|
3
|
+
// A meeting transcript already streams into ChatPanel every few seconds, speaker-attributed.
|
|
4
|
+
// That makes it an INPUT DEVICE, not just a record: a person can address the product in the
|
|
5
|
+
// middle of a call and expect something to happen. The obvious way to build that is to ask a
|
|
6
|
+
// model, every tick, whether anything was said to us. That is the wrong shape — it pays a
|
|
7
|
+
// model call per tick to answer "no" almost every time, and the answer arrives a minute
|
|
8
|
+
// after the sentence ended.
|
|
9
|
+
//
|
|
10
|
+
// So the model is used ONCE, to parse a command that a free matcher already found, and
|
|
11
|
+
// never to watch. Everything here is class R: pure string work, microseconds, no network,
|
|
12
|
+
// no tokens. The parse either recognises the command or reports `needsModel`, which is the
|
|
13
|
+
// seam a small model fills for the phrasings a grammar will never cover.
|
|
14
|
+
//
|
|
15
|
+
// THREE THINGS THIS MODULE REFUSES TO DO, each because it would break a rule that matters:
|
|
16
|
+
//
|
|
17
|
+
// 1. It does not act. `parseCommand` returns a description of what was asked for; the
|
|
18
|
+
// host decides whether that is allowed and carries it out. A parser that could start a
|
|
19
|
+
// timer could also be talked into starting anything, by anyone in the room.
|
|
20
|
+
// 2. It does not know who is allowed to speak to it. The host passes `self`, because only
|
|
21
|
+
// the host knows which speaker label is the device's owner — and gating on that is the
|
|
22
|
+
// whole security story (see commandsFromSegments).
|
|
23
|
+
// 3. It does not read a clock. `now` is injected, exactly like loop.js and event.js, so a
|
|
24
|
+
// command parses identically on replay and a test does not have to wait for Wednesday.
|
|
25
|
+
//
|
|
26
|
+
// Local time is deliberate: "9am" means 9am where the person is standing, so the resolved
|
|
27
|
+
// timestamps come from the host's own timezone via Date. That is the only environmental
|
|
28
|
+
// input, and it is the one users would be astonished to see normalised away.
|
|
29
|
+
|
|
30
|
+
export class VoiceIntentError extends Error {
|
|
31
|
+
constructor(code, message) { super(message); this.name = 'VoiceIntentError'; this.code = code; }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** What a wake word defaults to when the user has not chosen one. Configurable per install. */
|
|
35
|
+
export const DEFAULT_WAKE = Object.freeze(['chatpanel']);
|
|
36
|
+
|
|
37
|
+
// Speech-to-text mangles a brand name it has never seen: "chatpanel" comes back as "chat
|
|
38
|
+
// panel", "chat pal", "chad panel". A gate that only accepts the exact spelling is a gate
|
|
39
|
+
// that never opens on a real transcript. Tolerance scales with length because one edit in a
|
|
40
|
+
// four-letter word is a different word, and two edits in a nine-letter one is still clearly
|
|
41
|
+
// the same attempt.
|
|
42
|
+
function slack(len) { return len <= 4 ? 0 : len <= 6 ? 1 : 2; }
|
|
43
|
+
|
|
44
|
+
// The widest span of spoken tokens that may add up to one wake phrase ("chat" "pan" "ell").
|
|
45
|
+
const MAX_WAKE_TOKENS = 3;
|
|
46
|
+
|
|
47
|
+
// Bounded Levenshtein — returns early once the distance cannot come in under `max`, so a
|
|
48
|
+
// wake scan over a long transcript stays linear in practice.
|
|
49
|
+
export function editDistance(a, b, max = Infinity) {
|
|
50
|
+
if (a === b) return 0;
|
|
51
|
+
if (Math.abs(a.length - b.length) > max) return max + 1;
|
|
52
|
+
let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
53
|
+
for (let i = 1; i <= a.length; i++) {
|
|
54
|
+
const cur = [i];
|
|
55
|
+
let best = i;
|
|
56
|
+
for (let j = 1; j <= b.length; j++) {
|
|
57
|
+
cur[j] = Math.min(
|
|
58
|
+
prev[j] + 1,
|
|
59
|
+
cur[j - 1] + 1,
|
|
60
|
+
prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1),
|
|
61
|
+
);
|
|
62
|
+
if (cur[j] < best) best = cur[j];
|
|
63
|
+
}
|
|
64
|
+
if (best > max) return max + 1;
|
|
65
|
+
prev = cur;
|
|
66
|
+
}
|
|
67
|
+
return prev[b.length];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Lowercase and blank out punctuation WITHOUT changing length, so every offset computed
|
|
71
|
+
// against the normalised copy still points at the same character of the original. The
|
|
72
|
+
// command text handed back to the user keeps its capitals and its apostrophes; matching
|
|
73
|
+
// never has to care about either.
|
|
74
|
+
export function normalizeSpeech(text) {
|
|
75
|
+
return String(text || '')
|
|
76
|
+
.toLowerCase()
|
|
77
|
+
.replace(/[‘’]/g, "'")
|
|
78
|
+
.replace(/[^\p{L}\p{N}':.\s]/gu, ' ');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Tokens with offsets into the ORIGINAL string. */
|
|
82
|
+
export function tokenize(text) {
|
|
83
|
+
const norm = normalizeSpeech(text);
|
|
84
|
+
const out = [];
|
|
85
|
+
const re = /[\p{L}\p{N}'.:]+/gu;
|
|
86
|
+
let m;
|
|
87
|
+
while ((m = re.exec(norm))) {
|
|
88
|
+
// Keep dots INSIDE a token ("a.m.", "9:30") and drop them at the edges — a trailing
|
|
89
|
+
// full stop turned "ten minutes." into an unknown unit, the kind of bug that only
|
|
90
|
+
// shows up on the one transcript that punctuates.
|
|
91
|
+
const w = m[0].replace(/^[.:]+|[.:]+$/g, '');
|
|
92
|
+
if (w) out.push({ w, start: m.index, end: m.index + m[0].length });
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Compile the user's chosen wake word(s) into a matcher. Accepts a string or a list; each is
|
|
99
|
+
* squashed to letters so "chat panel", "ChatPanel" and "chat-panel" are one phrase.
|
|
100
|
+
*/
|
|
101
|
+
export function compileWake(words = DEFAULT_WAKE) {
|
|
102
|
+
const list = (Array.isArray(words) ? words : [words])
|
|
103
|
+
.map((w) => String(w || '').toLowerCase().replace(/[^\p{L}\p{N}]/gu, ''))
|
|
104
|
+
.filter((w) => w.length >= 3); // shorter than this and ordinary speech trips it constantly
|
|
105
|
+
if (!list.length) throw new VoiceIntentError('BAD_WAKE', 'wake word must have at least 3 letters');
|
|
106
|
+
return Object.freeze({ phrases: Object.freeze(list) });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Find "<wake>, <command>" in one utterance.
|
|
111
|
+
*
|
|
112
|
+
* Returns the command with its ORIGINAL casing, plus which wake phrase matched and where —
|
|
113
|
+
* the host logs the span so a user can see why something fired.
|
|
114
|
+
*/
|
|
115
|
+
export function findWakeCommand(text, wake = compileWake()) {
|
|
116
|
+
const raw = String(text || '');
|
|
117
|
+
const tokens = tokenize(raw);
|
|
118
|
+
if (!tokens.length) return null;
|
|
119
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
120
|
+
let squashed = '';
|
|
121
|
+
for (let n = 0; n < MAX_WAKE_TOKENS && i + n < tokens.length; n++) {
|
|
122
|
+
squashed += tokens[i + n].w.replace(/[^\p{L}\p{N}]/gu, '');
|
|
123
|
+
for (const phrase of wake.phrases) {
|
|
124
|
+
// A window far from the phrase's length cannot match; skip the distance work.
|
|
125
|
+
if (Math.abs(squashed.length - phrase.length) > slack(phrase.length)) continue;
|
|
126
|
+
if (editDistance(squashed, phrase, slack(phrase.length)) <= slack(phrase.length)) {
|
|
127
|
+
const end = tokens[i + n].end;
|
|
128
|
+
const command = stripLeadIn(raw.slice(end));
|
|
129
|
+
return { command, wake: phrase, heard: raw.slice(tokens[i].start, end), at: tokens[i].start };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// "chatpanel, could you please set a timer" — politeness is not part of the command, and
|
|
138
|
+
// leaving it in makes every intent pattern carry an optional-courtesy prefix.
|
|
139
|
+
function stripLeadIn(text) {
|
|
140
|
+
return String(text)
|
|
141
|
+
.replace(/^[\s,.:;!?-]+/, '')
|
|
142
|
+
.replace(/^(?:(?:hey|hi|ok|okay|yo)\b[\s,]*)+/i, '')
|
|
143
|
+
.replace(/^(?:(?:can|could|would|will)\s+you\s+(?:please\s+)?|please\s+)/i, '')
|
|
144
|
+
.trim();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
// Numbers, durations and clock times as people actually say them
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
const SMALL = {
|
|
152
|
+
zero: 0, one: 1, two: 2, three: 3, four: 4, five: 5, six: 6, seven: 7, eight: 8, nine: 9,
|
|
153
|
+
ten: 10, eleven: 11, twelve: 12, thirteen: 13, fourteen: 14, fifteen: 15, sixteen: 16,
|
|
154
|
+
seventeen: 17, eighteen: 18, nineteen: 19,
|
|
155
|
+
};
|
|
156
|
+
const TENS = { twenty: 20, thirty: 30, forty: 40, fourty: 40, fifty: 50, sixty: 60, seventy: 70, eighty: 80, ninety: 90 };
|
|
157
|
+
const FRACTION = { half: 0.5, quarter: 0.25 };
|
|
158
|
+
|
|
159
|
+
/** "twenty five" → 25, "a" → 1, "half" → 0.5. Returns null when the words are not a number. */
|
|
160
|
+
export function parseNumberWords(words) {
|
|
161
|
+
if (!words.length) return null;
|
|
162
|
+
let total = null;
|
|
163
|
+
for (let i = 0; i < words.length; i++) {
|
|
164
|
+
const w = words[i];
|
|
165
|
+
if (w === 'and' || w === 'of') continue; // "two AND a half", "a quarter OF an hour"
|
|
166
|
+
// "an hour" is one hour. "A quarter of an hour" is a quarter, and "two and A half" is
|
|
167
|
+
// 2.5 — in both of those the article belongs to the fraction, not to the count.
|
|
168
|
+
if (w === 'a' || w === 'an') {
|
|
169
|
+
if (total === null && !(words[i + 1] in FRACTION)) total = 1;
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (w in FRACTION) { total = (total ?? 0) + FRACTION[w]; continue; }
|
|
173
|
+
if (w in SMALL) { total = (total ?? 0) + SMALL[w]; continue; }
|
|
174
|
+
if (w in TENS) { total = (total ?? 0) + TENS[w]; continue; }
|
|
175
|
+
if (/^\d+(?:\.\d+)?$/.test(w)) { total = (total ?? 0) + Number(w); continue; }
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
return total;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const UNIT_MS = {
|
|
182
|
+
second: 1000, seconds: 1000, sec: 1000, secs: 1000, s: 1000,
|
|
183
|
+
minute: 60_000, minutes: 60_000, min: 60_000, mins: 60_000, m: 60_000,
|
|
184
|
+
hour: 3_600_000, hours: 3_600_000, hr: 3_600_000, hrs: 3_600_000, h: 3_600_000,
|
|
185
|
+
day: 86_400_000, days: 86_400_000,
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
// "10", "10m", "90s" — a number welded to its unit, which is how people type and how STT
|
|
189
|
+
// sometimes renders speech.
|
|
190
|
+
const GLUED = /^(\d+(?:\.\d+)?)(s|m|h|secs?|mins?|hrs?|seconds?|minutes?|hours?|days?)$/;
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Total duration named anywhere in `text`: "10 minutes", "an hour and a half",
|
|
194
|
+
* "1 hour 30 minutes", "90s", "half an hour", "two and a half hours".
|
|
195
|
+
*
|
|
196
|
+
* Summing every (quantity, unit) pair rather than taking the first is what makes
|
|
197
|
+
* "1 hour 30 minutes" 90 minutes instead of an hour.
|
|
198
|
+
*/
|
|
199
|
+
export function parseDuration(text) {
|
|
200
|
+
const tokens = tokenize(text);
|
|
201
|
+
let ms = 0;
|
|
202
|
+
let start = -1;
|
|
203
|
+
let end = -1;
|
|
204
|
+
let matched = false;
|
|
205
|
+
let qty = []; // words that could still add up to a quantity
|
|
206
|
+
let qtyStart = -1;
|
|
207
|
+
|
|
208
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
209
|
+
const t = tokens[i];
|
|
210
|
+
const take = (n, unit, from, to) => {
|
|
211
|
+
ms += n * unit;
|
|
212
|
+
if (start < 0) start = from;
|
|
213
|
+
end = to;
|
|
214
|
+
matched = true;
|
|
215
|
+
};
|
|
216
|
+
const glued = GLUED.exec(t.w);
|
|
217
|
+
const unit = glued ? UNIT_MS[glued[2]] : UNIT_MS[t.w];
|
|
218
|
+
if (!unit) {
|
|
219
|
+
// Not a unit: extend the pending quantity while it still parses as a number, else
|
|
220
|
+
// start over from this word. An unrelated clause before the number cannot poison it.
|
|
221
|
+
const next = [...qty, t.w];
|
|
222
|
+
if (parseNumberWords(next) !== null) { if (qtyStart < 0) qtyStart = t.start; qty = next; }
|
|
223
|
+
else if (parseNumberWords([t.w]) !== null) { qty = [t.w]; qtyStart = t.start; }
|
|
224
|
+
else { qty = []; qtyStart = -1; }
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
if (glued) take(Number(glued[1]), unit, t.start, t.end);
|
|
228
|
+
else {
|
|
229
|
+
const n = qty.length ? parseNumberWords(qty) : null;
|
|
230
|
+
if (n !== null) take(n, unit, qtyStart >= 0 ? qtyStart : t.start, t.end);
|
|
231
|
+
}
|
|
232
|
+
qty = []; qtyStart = -1;
|
|
233
|
+
// "an hour and a half" — the fraction trails its unit, so here is the only place it
|
|
234
|
+
// can be attributed to the right one.
|
|
235
|
+
const j = consumeTrailingFraction(tokens, i);
|
|
236
|
+
if (j > i) { ms += FRACTION[tokens[j].w] * unit; end = tokens[j].end; i = j; }
|
|
237
|
+
}
|
|
238
|
+
if (!matched || ms <= 0) return null;
|
|
239
|
+
return { ms: Math.round(ms), start, end };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Index of the last token of a trailing "…and a half" / "…and a quarter", or `i` when what
|
|
243
|
+
// follows is something else.
|
|
244
|
+
function consumeTrailingFraction(tokens, i) {
|
|
245
|
+
let j = i + 1;
|
|
246
|
+
if (tokens[j]?.w !== 'and') return i;
|
|
247
|
+
j++;
|
|
248
|
+
if (tokens[j]?.w === 'a' || tokens[j]?.w === 'an') j++;
|
|
249
|
+
return FRACTION[tokens[j]?.w] === undefined ? i : j;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const WEEKDAYS = {
|
|
253
|
+
sunday: 0, sun: 0, monday: 1, mon: 1, tuesday: 2, tue: 2, tues: 2, wednesday: 3, wed: 3,
|
|
254
|
+
weds: 3, thursday: 4, thu: 4, thur: 4, thurs: 4, friday: 5, fri: 5, saturday: 6, sat: 6,
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
// When someone names a part of the day instead of a time. Chosen to be unsurprising rather
|
|
258
|
+
// than clever: a reminder that fires at a time nobody expected is worse than one that fires
|
|
259
|
+
// at a boring one.
|
|
260
|
+
export const DAYPART_HOUR = Object.freeze({ morning: 9, afternoon: 14, evening: 19, night: 20, tonight: 19, noon: 12, midnight: 0 });
|
|
261
|
+
|
|
262
|
+
function atLocal(base, { days = 0, hour, minute = 0 }) {
|
|
263
|
+
const d = new Date(base);
|
|
264
|
+
d.setDate(d.getDate() + days);
|
|
265
|
+
d.setHours(hour, minute, 0, 0);
|
|
266
|
+
return d.getTime();
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* When a command says to do something: "in 20 minutes", "at 9am", "tomorrow at 3",
|
|
271
|
+
* "on Wednesday at 9am", "every weekday morning".
|
|
272
|
+
*
|
|
273
|
+
* Returns `{ at, recurrence }`. `recurrence` is null for one-shots and otherwise the shape
|
|
274
|
+
* the scheduler consumes — daily/weekly plus a local wall-clock time, NOT an interval in
|
|
275
|
+
* milliseconds, because "every day at 8am" survives a daylight-saving change and
|
|
276
|
+
* "every 86400000ms" does not.
|
|
277
|
+
*/
|
|
278
|
+
export function parseWhen(text, { now = Date.now() } = {}) {
|
|
279
|
+
const raw = String(text || '');
|
|
280
|
+
const norm = normalizeSpeech(raw);
|
|
281
|
+
|
|
282
|
+
// WHERE the time was said matters as much as what it was: a reminder's body is the
|
|
283
|
+
// command minus the time phrase, and a phrase at the START of the sentence ("remind me
|
|
284
|
+
// every weekday morning to check the queue") used to take the whole reminder with it.
|
|
285
|
+
let from = Infinity;
|
|
286
|
+
let to = -1;
|
|
287
|
+
const span = (a, b) => { if (a < from) from = a; if (b > to) to = b; };
|
|
288
|
+
|
|
289
|
+
// "in 20 minutes" — relative, and unambiguous enough to answer before anything else.
|
|
290
|
+
const rel = /\bin\s+(.+)$/i.exec(norm);
|
|
291
|
+
if (rel) {
|
|
292
|
+
const d = parseDuration(rel[1]);
|
|
293
|
+
if (d) {
|
|
294
|
+
const base = rel.index + rel[0].length - rel[1].length;
|
|
295
|
+
return { at: now + d.ms, recurrence: null, kind: 'relative', ...widen(norm, rel.index, base + d.end) };
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
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)
|
|
300
|
+
|| /\b(daily|nightly|weekly)\b/i.exec(norm);
|
|
301
|
+
const clock = parseClock(norm);
|
|
302
|
+
const dayWord = /\b(today|tonight|tomorrow)\b/i.exec(norm);
|
|
303
|
+
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);
|
|
304
|
+
const daypart = /\b(morning|afternoon|evening|tonight|night|noon|midnight)\b/i.exec(norm);
|
|
305
|
+
|
|
306
|
+
if (every) span(every.index, every.index + every[0].length);
|
|
307
|
+
if (clock) span(clock.start, clock.end);
|
|
308
|
+
if (dayWord) span(dayWord.index, dayWord.index + dayWord[0].length);
|
|
309
|
+
if (weekdayMatch) span(weekdayMatch.index, weekdayMatch.index + weekdayMatch[0].length);
|
|
310
|
+
if (daypart) span(daypart.index, daypart.index + daypart[0].length);
|
|
311
|
+
|
|
312
|
+
let hour = clock ? clock.hour : daypart ? DAYPART_HOUR[daypart[1]] : null;
|
|
313
|
+
const minute = clock ? clock.minute : 0;
|
|
314
|
+
// "tonight at 8" is 8 in the EVENING. A bare hour with no meridiem, said alongside a part
|
|
315
|
+
// of the day that is plainly not the morning, means the afternoon reading.
|
|
316
|
+
if (clock && !clock.meridiem && hour < 12 && daypart && DAYPART_HOUR[daypart[1]] >= 12) hour += 12;
|
|
317
|
+
|
|
318
|
+
if (every) {
|
|
319
|
+
const word = (every[1] || '').toLowerCase();
|
|
320
|
+
const h = hour ?? DAYPART_HOUR[word] ?? DAYPART_HOUR[word.replace(/ly$/, '')] ?? 9; // "nightly" is night
|
|
321
|
+
const at = word in WEEKDAYS
|
|
322
|
+
? nextWeekday(now, WEEKDAYS[word], h, minute)
|
|
323
|
+
: word === 'week' || word === 'weekly'
|
|
324
|
+
? atLocal(now, { days: 7, hour: h, minute })
|
|
325
|
+
: nextDailyAt(now, h, minute, word === 'weekday');
|
|
326
|
+
const recurrence = word in WEEKDAYS
|
|
327
|
+
? { kind: 'weekly', weekday: WEEKDAYS[word], hour: h, minute }
|
|
328
|
+
: word === 'week' || word === 'weekly'
|
|
329
|
+
? { kind: 'weekly', weekday: new Date(now).getDay(), hour: h, minute }
|
|
330
|
+
: { kind: 'daily', hour: h, minute, weekdaysOnly: word === 'weekday' };
|
|
331
|
+
return { at, recurrence, kind: 'recurring', ...widen(norm, from, to) };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (hour === null && !dayWord && !weekdayMatch) return null;
|
|
335
|
+
|
|
336
|
+
if (weekdayMatch) {
|
|
337
|
+
const wd = WEEKDAYS[weekdayMatch[2]];
|
|
338
|
+
const h = hour ?? 9;
|
|
339
|
+
// "next Wednesday" is never today, even when today is Wednesday and the hour is ahead.
|
|
340
|
+
const at = nextWeekday(now, wd, h, minute, !!weekdayMatch[1]);
|
|
341
|
+
return { at, recurrence: null, kind: 'weekday', ...widen(norm, from, to) };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const h = hour ?? DAYPART_HOUR[daypart?.[1] || 'morning'];
|
|
345
|
+
if (dayWord) {
|
|
346
|
+
const w = dayWord[1].toLowerCase();
|
|
347
|
+
return { at: atLocal(now, { days: w === 'tomorrow' ? 1 : 0, hour: h, minute }), recurrence: null, kind: w, ...widen(norm, from, to) };
|
|
348
|
+
}
|
|
349
|
+
// A bare clock time: today if it is still ahead, otherwise the same time tomorrow. Firing
|
|
350
|
+
// immediately for a time that has already passed is never what was meant.
|
|
351
|
+
let at = atLocal(now, { hour: h, minute });
|
|
352
|
+
if (at <= now) at = atLocal(now, { days: 1, hour: h, minute });
|
|
353
|
+
return { at, recurrence: null, kind: 'clock', ...widen(norm, from, to) };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Grow a time span backwards over the preposition that introduced it, so cutting it out of
|
|
357
|
+
// "take the kids to school AT 9am" does not leave a dangling "at".
|
|
358
|
+
function widen(norm, from, to) {
|
|
359
|
+
if (!(from >= 0) || !(to > from)) return { start: -1, end: -1 };
|
|
360
|
+
const lead = /\b(?:at|on|by|in|this|starting|from)\s+$/i.exec(norm.slice(0, from));
|
|
361
|
+
return { start: lead ? from - lead[0].length : from, end: to };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function nextDailyAt(now, hour, minute, weekdaysOnly = false) {
|
|
365
|
+
let at = atLocal(now, { hour, minute });
|
|
366
|
+
if (at <= now) at = atLocal(now, { days: 1, hour, minute });
|
|
367
|
+
if (weekdaysOnly) {
|
|
368
|
+
for (let i = 0; i < 7; i++) {
|
|
369
|
+
const day = new Date(at).getDay();
|
|
370
|
+
if (day !== 0 && day !== 6) break;
|
|
371
|
+
at = atLocal(at, { days: 1, hour, minute });
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
return at;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function nextWeekday(now, weekday, hour, minute, skipToday = false) {
|
|
378
|
+
const d = new Date(now);
|
|
379
|
+
let delta = (weekday - d.getDay() + 7) % 7;
|
|
380
|
+
if (delta === 0 && (skipToday || atLocal(now, { hour, minute }) <= now)) delta = 7;
|
|
381
|
+
return atLocal(now, { days: delta, hour, minute });
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** "9am", "9:30 pm", "at nine", "21:15", "9 o'clock". Returns 24h {hour, minute}. */
|
|
385
|
+
export function parseClock(text) {
|
|
386
|
+
const norm = normalizeSpeech(text);
|
|
387
|
+
// A NUMBER IS ONLY A TIME WHEN SOMETHING SAYS SO. "set a timer for 10 minutes" contains
|
|
388
|
+
// the digits of a perfectly good clock time, and reading it as 10 o'clock is how a timer
|
|
389
|
+
// becomes tomorrow morning. The cue must be attached to THIS number — a meridiem, a
|
|
390
|
+
// minutes part, "o'clock", or an immediately preceding "at" — not merely present
|
|
391
|
+
// somewhere in the sentence.
|
|
392
|
+
const re = /(\d{1,2})(?:[:.](\d{2}))?\s*(a\.?m\.?|p\.?m\.?|o'?clock)?/gi;
|
|
393
|
+
let m;
|
|
394
|
+
while ((m = re.exec(norm))) {
|
|
395
|
+
const attachedAt = /\bat\s+$/.test(norm.slice(0, m.index));
|
|
396
|
+
if (!m[2] && !m[3] && !attachedAt) continue;
|
|
397
|
+
let hour = Number(m[1]);
|
|
398
|
+
const minute = m[2] ? Number(m[2]) : 0;
|
|
399
|
+
if (hour > 23 || minute > 59) continue;
|
|
400
|
+
const mer = (m[3] || '').replace(/[.\s]/g, '').toLowerCase();
|
|
401
|
+
if (mer === 'pm' && hour < 12) hour += 12;
|
|
402
|
+
if (mer === 'am' && hour === 12) hour = 0;
|
|
403
|
+
return { hour, minute, meridiem: mer === 'am' || mer === 'pm', start: m.index, end: m.index + m[0].length };
|
|
404
|
+
}
|
|
405
|
+
// Spelled out: "at nine am". "at half past" is deliberately unsupported — rare in STT
|
|
406
|
+
// output and ambiguous enough to deserve a model rather than a guess.
|
|
407
|
+
const words = /\bat\s+([a-z]+)(?:\s+(a\.?m\.?|p\.?m\.?))?/i.exec(norm);
|
|
408
|
+
if (words) {
|
|
409
|
+
const n = parseNumberWords([words[1]]);
|
|
410
|
+
if (n !== null && Number.isInteger(n) && n >= 0 && n <= 23) {
|
|
411
|
+
let hour = n;
|
|
412
|
+
const mer = (words[2] || '').replace(/[.\s]/g, '').toLowerCase();
|
|
413
|
+
if (mer === 'pm' && hour < 12) hour += 12;
|
|
414
|
+
if (mer === 'am' && hour === 12) hour = 0;
|
|
415
|
+
return { hour, minute: 0, meridiem: mer === 'am' || mer === 'pm', start: words.index, end: words.index + words[0].length };
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
return null;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// ---------------------------------------------------------------------------
|
|
422
|
+
// Intents — declarations, so a client adds one without touching the parser
|
|
423
|
+
// ---------------------------------------------------------------------------
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* @param match (command, ctx) => args | null. Pure and synchronous, for the same reason
|
|
427
|
+
* rules.js insists on it: "did this match" must be answerable without side effects.
|
|
428
|
+
* @param classUsed what carrying it out costs — R for a local timer, C when it will start a
|
|
429
|
+
* model turn. Declared, never inferred, so the honest answer to "did that spend
|
|
430
|
+
* anything" is readable in the declaration.
|
|
431
|
+
* @param effects 'idempotent' | 'non-replayable' — the host uses it to decide whether a
|
|
432
|
+
* redelivered command may be re-run.
|
|
433
|
+
*/
|
|
434
|
+
export function defineVoiceIntent({
|
|
435
|
+
id, label, description = '', examples = [], classUsed = 'R',
|
|
436
|
+
effects = 'idempotent', requiresApproval = false, match,
|
|
437
|
+
}) {
|
|
438
|
+
if (!id) throw new VoiceIntentError('BAD_INTENT', 'intent.id required');
|
|
439
|
+
if (typeof match !== 'function') throw new VoiceIntentError('BAD_INTENT', `intent '${id}': match required`);
|
|
440
|
+
return Object.freeze({ id, label: label || id, description, examples: Object.freeze([...examples]), classUsed, effects, requiresApproval, match });
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
export function createVoiceIntentRegistry(intents = []) {
|
|
444
|
+
const list = [...intents];
|
|
445
|
+
return {
|
|
446
|
+
add(intent) {
|
|
447
|
+
list.push(intent);
|
|
448
|
+
return () => { const i = list.indexOf(intent); if (i >= 0) list.splice(i, 1); };
|
|
449
|
+
},
|
|
450
|
+
list: () => [...list],
|
|
451
|
+
get: (id) => list.find((i) => i.id === id) || null,
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* First intent whose pattern matches wins; declaration order is precedence. A command
|
|
455
|
+
* nothing recognises comes back with `needsModel`, which is a different answer from "not
|
|
456
|
+
* a command" — the host may pay for a small model to read it, and MUST NOT guess.
|
|
457
|
+
*/
|
|
458
|
+
parse(command, ctx = {}) {
|
|
459
|
+
const text = String(command || '').trim();
|
|
460
|
+
if (!text) return null;
|
|
461
|
+
for (const intent of list) {
|
|
462
|
+
let args = null;
|
|
463
|
+
try { args = intent.match(text, ctx); } catch { args = null; }
|
|
464
|
+
if (args) return { intent: intent.id, label: intent.label, classUsed: intent.classUsed, effects: intent.effects, requiresApproval: intent.requiresApproval, args, command: text, needsModel: false };
|
|
465
|
+
}
|
|
466
|
+
return { intent: null, args: null, command: text, needsModel: true };
|
|
467
|
+
},
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// ── the built-ins ──────────────────────────────────────────────────────────
|
|
472
|
+
// Deliberately the four that are local, revertible and need no new permission. Anything
|
|
473
|
+
// that sends, spends or clicks is not a good first thing to trigger by talking near a
|
|
474
|
+
// laptop, and belongs behind the per-action confirm gate the host already has.
|
|
475
|
+
|
|
476
|
+
export const timerIntent = defineVoiceIntent({
|
|
477
|
+
id: 'voice:timer',
|
|
478
|
+
label: 'Set a timer',
|
|
479
|
+
description: 'Starts a countdown and alerts when it finishes.',
|
|
480
|
+
examples: ['set a timer for 10 minutes', 'start a 90 second timer', 'timer for an hour and a half'],
|
|
481
|
+
match: (command, { now = Date.now() } = {}) => {
|
|
482
|
+
if (!/\btimers?\b/i.test(command)) return null;
|
|
483
|
+
const d = parseDuration(command);
|
|
484
|
+
if (!d) return null;
|
|
485
|
+
// "timer for the standup" — whatever is left once the duration and the plumbing words
|
|
486
|
+
// are removed is what the timer is FOR, and a labelled timer is the difference between
|
|
487
|
+
// three anonymous countdowns and three useful ones.
|
|
488
|
+
const label = (command.slice(0, d.start) + ' ' + command.slice(d.end))
|
|
489
|
+
.replace(/\b(set|start|make|create|a|an|the|for|please|timer|timers|to|of|and|half|quarter)\b/gi, ' ');
|
|
490
|
+
return { ms: d.ms, at: now + d.ms, label: tidy(label) };
|
|
491
|
+
},
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
export const reminderIntent = defineVoiceIntent({
|
|
495
|
+
id: 'voice:reminder',
|
|
496
|
+
label: 'Set a reminder',
|
|
497
|
+
description: 'Remembers something and raises it at the time you said.',
|
|
498
|
+
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'],
|
|
499
|
+
match: (command, { now = Date.now() } = {}) => {
|
|
500
|
+
const m = /\b(?:remind\s+(?:me|us)|reminder|remember)\b/i.exec(command);
|
|
501
|
+
if (!m) return null;
|
|
502
|
+
const when = parseWhen(command, { now });
|
|
503
|
+
// Cut out exactly the span parseWhen matched — a notification that already says when it
|
|
504
|
+
// is should not also read "…at 9am on wednesday" in its title, and the phrase can sit at
|
|
505
|
+
// either end of the sentence ("remind me every weekday morning to check the queue").
|
|
506
|
+
let text = when && when.end > when.start
|
|
507
|
+
? command.slice(0, when.start) + ' ' + command.slice(when.end)
|
|
508
|
+
: command;
|
|
509
|
+
text = text.slice(text.toLowerCase().indexOf(m[0].toLowerCase()) + m[0].length);
|
|
510
|
+
text = tidy(text.replace(/^(?:\s*(?:to|that|about|i\s+need\s+to|we\s+need\s+to))\b/i, ''));
|
|
511
|
+
if (!text) return null; // "remind me" with nothing to remember is not a reminder
|
|
512
|
+
return { text, at: when?.at ?? null, recurrence: when?.recurrence ?? null, when: when?.kind ?? null };
|
|
513
|
+
},
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
export const noteIntent = defineVoiceIntent({
|
|
517
|
+
id: 'voice:note',
|
|
518
|
+
label: 'Take a note',
|
|
519
|
+
description: 'Appends a line to the meeting notes.',
|
|
520
|
+
examples: ['note that we agreed to ship on Friday', 'take a note: budget is approved'],
|
|
521
|
+
match: (command) => {
|
|
522
|
+
const m = /^(?:take\s+a\s+note|make\s+a\s+note|note)\b[\s:,-]*(?:that\s+)?(.+)$/i.exec(command.trim());
|
|
523
|
+
const text = m && tidy(m[1]);
|
|
524
|
+
return text ? { text } : null;
|
|
525
|
+
},
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
export const monitorIntent = defineVoiceIntent({
|
|
529
|
+
id: 'voice:monitor',
|
|
530
|
+
label: 'Watch for something',
|
|
531
|
+
description: 'Starts a live monitor that answers as the meeting continues.',
|
|
532
|
+
examples: ['watch for whether we agree a date', 'keep an eye on the pricing question', 'track who owns the migration'],
|
|
533
|
+
classUsed: 'C', // it starts model turns for the rest of the meeting — say so
|
|
534
|
+
match: (command) => {
|
|
535
|
+
const m = /^(?:watch\s+(?:out\s+)?for|watch|keep\s+an\s+eye\s+on|track|monitor)\b[\s:,-]*(?:whether\s+|if\s+|for\s+)?(.+)$/i.exec(command.trim());
|
|
536
|
+
const prompt = m && tidy(m[1]);
|
|
537
|
+
return prompt && prompt.length > 2 ? { prompt } : null;
|
|
538
|
+
},
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
export const BUILTIN_VOICE_INTENTS = Object.freeze([timerIntent, reminderIntent, noteIntent, monitorIntent]);
|
|
542
|
+
|
|
543
|
+
function tidy(s) {
|
|
544
|
+
return String(s || '').replace(/\s+/g, ' ').replace(/^[\s,.:;-]+|[\s,.:;-]+$/g, '').trim();
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/** A registry preloaded with the built-ins — the common case, one call. */
|
|
548
|
+
export function defaultVoiceIntents() {
|
|
549
|
+
return createVoiceIntentRegistry(BUILTIN_VOICE_INTENTS);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Parse one utterance end to end: wake word, then intent.
|
|
554
|
+
*
|
|
555
|
+
* Returns null when the utterance was not addressed to us — which is almost every utterance
|
|
556
|
+
* in a meeting, and must therefore be the cheapest path through this module.
|
|
557
|
+
*/
|
|
558
|
+
export function parseCommand(text, { wake = compileWake(), intents = defaultVoiceIntents(), now = Date.now() } = {}) {
|
|
559
|
+
const found = findWakeCommand(text, wake);
|
|
560
|
+
if (!found) return null;
|
|
561
|
+
const parsed = intents.parse(found.command, { now });
|
|
562
|
+
if (!parsed) return null;
|
|
563
|
+
return { ...parsed, wake: found.wake, heard: found.heard, at: found.at };
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// ---------------------------------------------------------------------------
|
|
567
|
+
// Transcript → commands
|
|
568
|
+
// ---------------------------------------------------------------------------
|
|
569
|
+
|
|
570
|
+
/** How many commands one transcript delta may produce. */
|
|
571
|
+
export const MAX_COMMANDS_PER_DELTA = 3;
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Scan new transcript segments for commands addressed to us.
|
|
575
|
+
*
|
|
576
|
+
* WHO IS ALLOWED TO SPEAK TO IT is the whole security question here. A meeting transcript
|
|
577
|
+
* carries everyone in the room, so an ungated version of this lets any participant put
|
|
578
|
+
* reminders on someone else's device by saying the wake word — and lets a compromised page
|
|
579
|
+
* do it by writing captions. `self` is therefore matched by the HOST, which is the only
|
|
580
|
+
* layer that knows which label is the device owner; segments from anyone else come back
|
|
581
|
+
* with `allowed: false` rather than being dropped silently, so "why didn't it fire" has an
|
|
582
|
+
* answer.
|
|
583
|
+
*
|
|
584
|
+
* @param segments [{ t, speaker, text }] — the delta, not the whole meeting.
|
|
585
|
+
* @param isSelf (speaker) => boolean. Omit ONLY when the host has decided anyone may
|
|
586
|
+
* command this install; the default refuses, because failing closed on a
|
|
587
|
+
* question about authority is the only safe default.
|
|
588
|
+
*/
|
|
589
|
+
export function commandsFromSegments(segments, {
|
|
590
|
+
wake = compileWake(), intents = defaultVoiceIntents(), isSelf = null,
|
|
591
|
+
sinceTs = 0, now = Date.now(), meetingId = '', max = MAX_COMMANDS_PER_DELTA,
|
|
592
|
+
} = {}) {
|
|
593
|
+
const out = [];
|
|
594
|
+
for (const seg of segments || []) {
|
|
595
|
+
if (!seg || !seg.text) continue;
|
|
596
|
+
if (seg.t && seg.t <= sinceTs) continue;
|
|
597
|
+
const parsed = parseCommand(seg.text, { wake, intents, now });
|
|
598
|
+
if (!parsed) continue;
|
|
599
|
+
const allowed = isSelf ? !!isSelf(seg.speaker) : false;
|
|
600
|
+
out.push({
|
|
601
|
+
...parsed,
|
|
602
|
+
allowed,
|
|
603
|
+
speaker: seg.speaker || '',
|
|
604
|
+
t: seg.t || now,
|
|
605
|
+
meetingId,
|
|
606
|
+
// Stable across redeliveries of the same segment, so the rule engine's dedup does its
|
|
607
|
+
// job: a flush that resends the last ten seconds must not set two timers.
|
|
608
|
+
key: `voice:${meetingId}:${seg.t || 0}:${parsed.at}:${parsed.intent || 'unknown'}`,
|
|
609
|
+
});
|
|
610
|
+
if (out.length >= max) break; // a pathological transcript cannot fire fifty actions
|
|
611
|
+
}
|
|
612
|
+
return out;
|
|
613
|
+
}
|