@chatpanel/events 0.15.0 → 0.17.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/index.js CHANGED
@@ -30,7 +30,7 @@ export { defineToolGroup, createToolGroupRegistry, ToolGroupError } from './tool
30
30
  export { toolNeedFor } from './tool-need.js';
31
31
  export { parseFlowchart, layoutFlowchart, renderFlowchartSvg } from './flowchart.js';
32
32
  export { validateView, validateViewInvocation, viewResult } from './view.js';
33
- export { validateWidget, validateWidgetMessage, effectiveGrants, WIDGET_SURFACES } from './widget.js';
33
+ export { validateWidget, validateWidgetMessage, effectiveGrants, widgetIcon, WIDGET_SURFACES } from './widget.js';
34
34
  export { fuseRRF, planQueries, multiSearch } from './rrf.js';
35
35
  export {
36
36
  ACCESS_LOG_VERSION, ACCESS_LOG_MAX, redactAccessArgs, makeAccessEvent,
@@ -45,7 +45,22 @@ export { defineModel, defineMiddleware, defineRouteStrategy, createModelRouter,
45
45
  export { makeSourceStore, manifestText, shortUrl, readSource, sourceId } from './sources-retrieval.js';
46
46
  export { classifySource, extractUrls, hostMatches, meetReach, sourcePolicyFor, DEFAULT_INTERNAL_PATTERNS, INTERNAL_PATTERN_CATALOG } from './sources.js';
47
47
  export { defineRule, createRuleEngine, SUPPRESSED, RuleError } from './rules.js';
48
+ export {
49
+ SCHEDULE_KINDS, TRIGGER_KINDS, JOB_ACTIONS, MISSED_POLICIES, ScheduleError,
50
+ validateSchedule, nextFireAt, occurrencesBetween, nextWakeAt,
51
+ defineTrigger, createTriggerRegistry, BUILTIN_TRIGGERS,
52
+ timerTrigger, meetingStartedTrigger, meetingEndedTrigger, personJoinedTrigger,
53
+ phraseTrigger, topicTrigger, questionTrigger, voiceCommandTrigger,
54
+ defineJob, dueJobs, jobsForEvent, occurrenceKey,
55
+ } from './schedule.js';
48
56
  export { defineMeetingAnalyzer, createAnalyzerRegistry, CADENCES, AnalyzerError } from './meeting-analyzers.js';
57
+ export {
58
+ DEFAULT_WAKE, MAX_COMMANDS_PER_DELTA, DAYPART_HOUR, VoiceIntentError,
59
+ compileWake, findWakeCommand, parseCommand, commandsFromSegments,
60
+ parseDuration, parseClock, parseWhen, parseNumberWords, normalizeSpeech, tokenize, editDistance,
61
+ defineVoiceIntent, createVoiceIntentRegistry, defaultVoiceIntents, BUILTIN_VOICE_INTENTS,
62
+ timerIntent, reminderIntent, scheduleIntent, noteIntent, monitorIntent,
63
+ } from './voice-intents.js';
49
64
  export { explainMcpError, packageFromArgs } from './mcp-errors.js';
50
65
  export { createManifest, ManifestError, SOURCES } from './manifest.js';
51
66
  export { createKernel, meetDecisions, KernelError, REQUIRED_PLUGINS, ALLOW_ALL } from './kernel.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chatpanel/events",
3
- "version": "0.15.0",
3
+ "version": "0.17.0",
4
4
  "description": "The canonical ChatPanel event-log and capability contracts \u2014 typed durable facts, clock-free deterministic linearization, schema upcasting, and the invariants the replay harness asserts. Pure, dependency-free ESM shared by the ChatPanel extension, gateway and bridge.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -25,6 +25,7 @@
25
25
  "./route-graph.js": "./route-graph.js",
26
26
  "./router.js": "./router.js",
27
27
  "./rules.js": "./rules.js",
28
+ "./schedule.js": "./schedule.js",
28
29
  "./scopes.js": "./scopes.js",
29
30
  "./search-engines.js": "./search-engines.js",
30
31
  "./skill-manifest.js": "./skill-manifest.js",
@@ -39,6 +40,7 @@
39
40
  "./tool-need.js": "./tool-need.js",
40
41
  "./trajectory.js": "./trajectory.js",
41
42
  "./upcast.js": "./upcast.js",
43
+ "./voice-intents.js": "./voice-intents.js",
42
44
  "./observability.js": "./observability.js",
43
45
  "./flowchart.js": "./flowchart.js",
44
46
  "./rrf.js": "./rrf.js",
@@ -71,6 +73,7 @@
71
73
  "router.js",
72
74
  "rrf.js",
73
75
  "rules.js",
76
+ "schedule.js",
74
77
  "scopes.js",
75
78
  "search-engines.js",
76
79
  "skill-manifest.js",
@@ -85,6 +88,7 @@
85
88
  "tool-need.js",
86
89
  "trajectory.js",
87
90
  "upcast.js",
91
+ "voice-intents.js",
88
92
  "view.js",
89
93
  "widget.js"
90
94
  ],
package/schedule.js ADDED
@@ -0,0 +1,374 @@
1
+ // Jobs — the thing that runs when nobody asked it to.
2
+ //
3
+ // Every turn ChatPanel has ever run began with a person pressing send. A job does not, and
4
+ // that single difference is what this contract is mostly about: consent, cost, dedup and a
5
+ // findable result all have to be settled BEFORE the run, because there is nobody sitting
6
+ // there to judge the outcome.
7
+ //
8
+ // Three layers, and the split is the whole point (see docs/feature-f5-scheduler.md):
9
+ //
10
+ // • schedule maths — "when does this next fire", "what did we miss while the laptop was
11
+ // asleep". Pure input → output, and wrong in a subtly different way in every
12
+ // reimplementation, so it is written once, here.
13
+ // • the job model, admission and dedup — policy, also here.
14
+ // • WAKING UP — `chrome.alarms`, `WorkManager`, `BGTaskScheduler`. The only platform-bound
15
+ // part, injected by the client, never imported.
16
+ //
17
+ // A trigger says WHEN. It never says what, and it cannot widen what a job may do: a phrase
18
+ // spoken in a meeting can start a job the user already created and approved, and can do
19
+ // nothing else. That is deliberate — the transcript is written by everyone in the room.
20
+ //
21
+ // WALL CLOCK, NOT INTERVALS. "Every day at 8am" is stored as an hour and a minute, not as
22
+ // 86_400_000 milliseconds, because the second one drifts by an hour twice a year and nobody
23
+ // can explain why the brief started arriving at 7. `now` is injected for the same reason it
24
+ // is everywhere else in this package: so a Wednesday can be tested on a Tuesday.
25
+
26
+ export class ScheduleError extends Error {
27
+ constructor(code, message) { super(message); this.name = 'ScheduleError'; this.code = code; }
28
+ }
29
+
30
+ export const SCHEDULE_KINDS = Object.freeze(['once', 'interval', 'daily', 'weekly']);
31
+ export const TRIGGER_KINDS = Object.freeze(['timer', 'meeting', 'voice', 'data']);
32
+ /** What a job does when it fires. `skill` is the headline: the instruction IS a skill. */
33
+ export const JOB_ACTIONS = Object.freeze(['skill', 'prompt', 'monitor', 'notify']);
34
+ /** What to do about occurrences that passed while nothing was running. */
35
+ export const MISSED_POLICIES = Object.freeze(['skip', 'runOnce', 'runAll']);
36
+
37
+ const MAX_CATCH_UP = 20; // a fortnight asleep must not queue a hundred model calls
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Schedule maths
41
+ // ---------------------------------------------------------------------------
42
+
43
+ function atLocal(base, { days = 0, hour, minute = 0 }) {
44
+ const d = new Date(base);
45
+ d.setDate(d.getDate() + days);
46
+ d.setHours(hour, minute, 0, 0);
47
+ return d.getTime();
48
+ }
49
+
50
+ const isWeekend = (ts) => { const d = new Date(ts).getDay(); return d === 0 || d === 6; };
51
+
52
+ export function validateSchedule(s) {
53
+ if (!s || typeof s !== 'object') throw new ScheduleError('BAD_SCHEDULE', 'schedule must be an object');
54
+ if (!SCHEDULE_KINDS.includes(s.kind)) throw new ScheduleError('BAD_SCHEDULE', `unknown schedule kind '${s.kind}'`);
55
+ if (s.kind === 'once' && !(s.at > 0)) throw new ScheduleError('BAD_SCHEDULE', 'once needs `at`');
56
+ if (s.kind === 'interval' && !(s.everyMs >= 60_000)) {
57
+ // A minute is the floor every platform's scheduler shares (chrome.alarms refuses less).
58
+ // Accepting 5s here would produce a job that silently fires on somebody else's cadence.
59
+ throw new ScheduleError('BAD_SCHEDULE', 'interval needs everyMs >= 60000');
60
+ }
61
+ if (s.kind === 'daily' || s.kind === 'weekly') {
62
+ if (!Number.isInteger(s.hour) || s.hour < 0 || s.hour > 23) throw new ScheduleError('BAD_SCHEDULE', 'hour must be 0-23');
63
+ const m = s.minute ?? 0;
64
+ if (!Number.isInteger(m) || m < 0 || m > 59) throw new ScheduleError('BAD_SCHEDULE', 'minute must be 0-59');
65
+ }
66
+ if (s.kind === 'weekly' && (!Number.isInteger(s.weekday) || s.weekday < 0 || s.weekday > 6)) {
67
+ throw new ScheduleError('BAD_SCHEDULE', 'weekly needs weekday 0-6 (Sunday = 0)');
68
+ }
69
+ return s;
70
+ }
71
+
72
+ /**
73
+ * The first firing STRICTLY after `from`. Null when a one-shot is already spent.
74
+ *
75
+ * Strictly after, so feeding a fire time back in advances instead of returning the same
76
+ * occurrence forever — the loop that ran a job and then asked "what's next?" would otherwise
77
+ * never stop.
78
+ */
79
+ export function nextFireAt(schedule, from) {
80
+ const s = validateSchedule(schedule);
81
+ const minute = s.minute ?? 0;
82
+ switch (s.kind) {
83
+ case 'once':
84
+ return s.at > from ? s.at : null;
85
+ case 'interval': {
86
+ // Anchored so a job restored from storage keeps its original phase instead of drifting
87
+ // a little later every time the extension restarts.
88
+ const anchor = s.anchor ?? from;
89
+ if (anchor > from) return anchor;
90
+ const steps = Math.floor((from - anchor) / s.everyMs) + 1;
91
+ return anchor + steps * s.everyMs;
92
+ }
93
+ case 'daily': {
94
+ let at = atLocal(from, { hour: s.hour, minute });
95
+ if (at <= from) at = atLocal(from, { days: 1, hour: s.hour, minute });
96
+ if (s.weekdaysOnly) for (let i = 0; i < 7 && isWeekend(at); i++) at = atLocal(at, { days: 1, hour: s.hour, minute });
97
+ return at;
98
+ }
99
+ case 'weekly': {
100
+ const day = new Date(from).getDay();
101
+ let delta = (s.weekday - day + 7) % 7;
102
+ if (delta === 0 && atLocal(from, { hour: s.hour, minute }) <= from) delta = 7;
103
+ return atLocal(from, { days: delta, hour: s.hour, minute });
104
+ }
105
+ default:
106
+ return null;
107
+ }
108
+ }
109
+
110
+ /** Every firing in (from, to], oldest first. Capped: a long sleep is not a queue of work. */
111
+ export function occurrencesBetween(schedule, from, to, max = MAX_CATCH_UP) {
112
+ const out = [];
113
+ let cursor = from;
114
+ while (out.length < max) {
115
+ const at = nextFireAt(schedule, cursor);
116
+ if (at === null || at > to) break;
117
+ out.push(at);
118
+ cursor = at;
119
+ }
120
+ return out;
121
+ }
122
+
123
+ // ---------------------------------------------------------------------------
124
+ // Triggers — declarations. A trigger says WHEN, never what.
125
+ // ---------------------------------------------------------------------------
126
+
127
+ /**
128
+ * @param matches (event, params, ctx) => match | null. Pure and synchronous, like rules.js:
129
+ * "did this match" must be answerable without side effects or a network.
130
+ * @param watches the event types it can possibly match, so a busy bus is cheap.
131
+ */
132
+ export function defineTrigger({
133
+ id, label, kind, watches = [], description = '', classUsed = 'R', matches = null, params = {},
134
+ }) {
135
+ if (!id) throw new ScheduleError('BAD_TRIGGER', 'trigger.id required');
136
+ if (!TRIGGER_KINDS.includes(kind)) throw new ScheduleError('BAD_TRIGGER', `trigger '${id}': unknown kind '${kind}'`);
137
+ if (kind !== 'timer' && typeof matches !== 'function') {
138
+ throw new ScheduleError('BAD_TRIGGER', `trigger '${id}': an event trigger needs matches()`);
139
+ }
140
+ return Object.freeze({ id, label: label || id, kind, watches: Object.freeze([...watches]), description, classUsed, matches, params: Object.freeze({ ...params }) });
141
+ }
142
+
143
+ export function createTriggerRegistry(triggers = []) {
144
+ const list = [...triggers];
145
+ return {
146
+ add(t) { list.push(t); return () => { const i = list.indexOf(t); if (i >= 0) list.splice(i, 1); }; },
147
+ list: () => [...list],
148
+ get: (id) => list.find((t) => t.id === id) || null,
149
+ };
150
+ }
151
+
152
+ const words = (s) => String(s || '').toLowerCase().match(/[\p{L}\p{N}']+/gu) || [];
153
+ const norm = (s) => String(s || '').trim().toLowerCase();
154
+
155
+ // Whose speech a meeting trigger cares about. The default is 'anyone' because a phrase
156
+ // trigger is usually watching for what OTHERS say — unlike a spoken command, which is only
157
+ // ever the owner's (see voice-intents.js).
158
+ function speakerAllowed(want, speaker, ctx) {
159
+ if (want === 'me') return !!ctx?.isSelf?.(speaker);
160
+ if (want === 'others') return !ctx?.isSelf?.(speaker);
161
+ return true;
162
+ }
163
+
164
+ export const timerTrigger = defineTrigger({
165
+ id: 'timer:schedule',
166
+ label: 'On a schedule',
167
+ kind: 'timer',
168
+ description: 'Once, on an interval, daily, or on a weekday — "every weekday at 8am".',
169
+ });
170
+
171
+ export const meetingStartedTrigger = defineTrigger({
172
+ id: 'meeting:started',
173
+ label: 'When a meeting starts',
174
+ kind: 'meeting',
175
+ watches: ['meeting.started'],
176
+ matches: (event, params = {}) => {
177
+ if (params.platform && norm(event.platform) !== norm(params.platform)) return null;
178
+ if (params.titleIncludes && !norm(event.title).includes(norm(params.titleIncludes))) return null;
179
+ return { why: `meeting started: ${event.title || event.meetingId}` };
180
+ },
181
+ });
182
+
183
+ export const meetingEndedTrigger = defineTrigger({
184
+ id: 'meeting:ended',
185
+ label: 'When a meeting ends',
186
+ kind: 'meeting',
187
+ watches: ['meeting.ended'],
188
+ matches: (event) => ({ why: `meeting ended: ${event.title || event.meetingId}` }),
189
+ });
190
+
191
+ export const personJoinedTrigger = defineTrigger({
192
+ id: 'meeting:person-joined',
193
+ label: 'When someone joins',
194
+ kind: 'meeting',
195
+ watches: ['meeting.person-joined'],
196
+ // No names means anyone — "tell me when the call fills up" is as valid as "tell me when
197
+ // Alex joins", and an empty list that matched nothing would look like a broken job.
198
+ matches: (event, params = {}) => {
199
+ const want = (params.names || []).map(norm).filter(Boolean);
200
+ const joined = (event.people || []).filter((p) => !want.length || want.some((n) => norm(p) === n || norm(p).startsWith(`${n} `)));
201
+ return joined.length ? { why: `joined: ${joined.join(', ')}`, people: joined } : null;
202
+ },
203
+ });
204
+
205
+ export const phraseTrigger = defineTrigger({
206
+ id: 'meeting:phrase',
207
+ label: 'When a phrase is said',
208
+ kind: 'meeting',
209
+ watches: ['meeting.transcript.delta'],
210
+ matches: (event, params = {}, ctx = {}) => {
211
+ const any = (params.any || []).map(norm).filter(Boolean);
212
+ if (!any.length) return null; // a phrase trigger with no phrase would fire on every word
213
+ for (const seg of event.segments || []) {
214
+ if (!speakerAllowed(params.speaker, seg.speaker, ctx)) continue;
215
+ const text = norm(seg.text);
216
+ const hit = any.find((p) => text.includes(p));
217
+ if (hit) return { why: `“${hit}” said by ${seg.speaker || 'someone'}`, segment: seg, phrase: hit };
218
+ }
219
+ return null;
220
+ },
221
+ });
222
+
223
+ export const topicTrigger = defineTrigger({
224
+ id: 'meeting:topic',
225
+ label: 'When someone talks about something',
226
+ kind: 'meeting',
227
+ watches: ['meeting.transcript.delta'],
228
+ // Looser than a phrase on purpose: "says something about pricing" should not require the
229
+ // word "pricing" in the exact shape the job author typed. Term overlap over the window is
230
+ // deterministic, explainable, and free — a model would be all three of the opposite.
231
+ matches: (event, params = {}, ctx = {}) => {
232
+ const terms = (params.terms || []).map(norm).filter(Boolean);
233
+ if (!terms.length) return null;
234
+ const need = Math.max(1, Math.min(params.minHits || 1, terms.length));
235
+ const window = (event.segments || []).filter((s) => speakerAllowed(params.speaker, s.speaker, ctx));
236
+ const bag = new Set(window.flatMap((s) => words(s.text)));
237
+ const hits = terms.filter((t) => t.split(/\s+/).every((w) => bag.has(w)));
238
+ return hits.length >= need ? { why: `talking about ${hits.join(', ')}`, terms: hits } : null;
239
+ },
240
+ });
241
+
242
+ // A question mark is the cheap half; the interrogative openers are what catch speech-to-text
243
+ // output, which frequently drops the punctuation entirely.
244
+ const QUESTION = /^(who|what|when|where|why|how|which|whose|can|could|would|should|shall|do|does|did|is|are|was|were|will|have|has|any(one|body)|is there|are there)\b/i;
245
+
246
+ export const questionTrigger = defineTrigger({
247
+ id: 'meeting:question',
248
+ label: 'When a question is asked',
249
+ kind: 'meeting',
250
+ watches: ['meeting.transcript.delta'],
251
+ matches: (event, params = {}, ctx = {}) => {
252
+ for (const seg of event.segments || []) {
253
+ if (!speakerAllowed(params.speaker || 'others', seg.speaker, ctx)) continue;
254
+ const text = String(seg.text || '').trim();
255
+ if (text.length < 8) continue; // "what?" is not a question worth waking a model for
256
+ if (text.includes('?') || QUESTION.test(text)) return { why: `question from ${seg.speaker || 'someone'}`, segment: seg };
257
+ }
258
+ return null;
259
+ },
260
+ });
261
+
262
+ export const voiceCommandTrigger = defineTrigger({
263
+ id: 'voice:command',
264
+ label: 'When you say the wake word',
265
+ kind: 'voice',
266
+ watches: ['voice.command'],
267
+ matches: (event, params = {}) => {
268
+ const want = params.intents || [];
269
+ if (!event.command?.allowed) return null; // authority is settled upstream; never widened here
270
+ if (want.length && !want.includes(event.command.intent)) return null;
271
+ return { why: `you said “${event.command.command}”`, command: event.command };
272
+ },
273
+ });
274
+
275
+ export const BUILTIN_TRIGGERS = Object.freeze([
276
+ timerTrigger, meetingStartedTrigger, meetingEndedTrigger, personJoinedTrigger,
277
+ phraseTrigger, topicTrigger, questionTrigger, voiceCommandTrigger,
278
+ ]);
279
+
280
+ // ---------------------------------------------------------------------------
281
+ // Jobs
282
+ // ---------------------------------------------------------------------------
283
+
284
+ /**
285
+ * @param action { kind: 'skill', skillId } — the headline case: the instruction IS a skill,
286
+ * so "every morning, do my daily brief" is a job whose action names the skill
287
+ * the user already wrote. Also 'prompt' (raw text), 'monitor', 'notify'.
288
+ * @param limits { maxPerDay } — a job that fails must back off, not retry in a tight loop.
289
+ * @param onMissed what to do about occurrences that passed while nothing was awake. Stated,
290
+ * because silently running eleven catch-up briefs is as wrong as silently
291
+ * running none, and the difference is money.
292
+ */
293
+ export function defineJob({
294
+ id, name, trigger, schedule = null, params = {}, action,
295
+ enabled = true, onMissed = 'runOnce', limits = {}, approval = null, createdAt = 0,
296
+ }) {
297
+ if (!id) throw new ScheduleError('BAD_JOB', 'job.id required');
298
+ if (!name) throw new ScheduleError('BAD_JOB', `job '${id}': name required`);
299
+ if (!trigger) throw new ScheduleError('BAD_JOB', `job '${id}': trigger required`);
300
+ if (!action || !JOB_ACTIONS.includes(action.kind)) {
301
+ throw new ScheduleError('BAD_JOB', `job '${id}': action.kind must be one of ${JOB_ACTIONS}`);
302
+ }
303
+ if (action.kind === 'skill' && !action.skillId) throw new ScheduleError('BAD_JOB', `job '${id}': skill action needs skillId`);
304
+ if (!MISSED_POLICIES.includes(onMissed)) throw new ScheduleError('BAD_JOB', `job '${id}': unknown onMissed '${onMissed}'`);
305
+ if (trigger === timerTrigger.id || schedule) validateSchedule(schedule);
306
+ return {
307
+ id, name, trigger, schedule, params: { ...params }, action: { ...action },
308
+ enabled: !!enabled, onMissed, limits: { ...limits }, approval, createdAt: createdAt || 0,
309
+ };
310
+ }
311
+
312
+ /**
313
+ * Dedup is on the SCHEDULED time, never the fired time — a wake-up at 09:04 for the 09:00
314
+ * slot is the 09:00 run, and a second wake-up for that slot is a no-op. Alarms are
315
+ * approximate and devices sleep; without this, "approximately 9" means "twice".
316
+ */
317
+ export function occurrenceKey(jobId, at) { return `${jobId}@${at}`; }
318
+
319
+ /**
320
+ * Which timer jobs are due, and which of those are catch-up rather than on-time.
321
+ *
322
+ * @param lastRun { [jobId]: ts } — the last OCCURRENCE run, not the last wake-up.
323
+ */
324
+ export function dueJobs(jobs, { now, lastRun = {}, admit = null, max = MAX_CATCH_UP } = {}) {
325
+ const out = [];
326
+ for (const job of jobs || []) {
327
+ if (!job.enabled) continue;
328
+ if (job.trigger !== timerTrigger.id || !job.schedule) continue;
329
+ if (admit && !admit(job)) continue;
330
+ const since = lastRun[job.id] || job.createdAt || 0;
331
+ if (!since) continue; // a job with no anchor cannot know what it missed
332
+ const missed = occurrencesBetween(job.schedule, since, now, max);
333
+ if (!missed.length) continue;
334
+ const runs = job.onMissed === 'runAll' ? missed
335
+ : job.onMissed === 'runOnce' ? [missed[missed.length - 1]]
336
+ : [];
337
+ for (const at of runs) out.push({ job, at, key: occurrenceKey(job.id, at), late: now - at > 60_000, missedCount: missed.length });
338
+ // 'skip' still reports so the caller can advance its watermark without running anything.
339
+ if (!runs.length) out.push({ job, at: missed[missed.length - 1], key: occurrenceKey(job.id, missed[missed.length - 1]), skipped: true, missedCount: missed.length });
340
+ }
341
+ return out;
342
+ }
343
+
344
+ /** The soonest any timer job wants to be woken, so a client arms ONE platform alarm. */
345
+ export function nextWakeAt(jobs, { now, lastRun = {} } = {}) {
346
+ let soonest = null;
347
+ for (const job of jobs || []) {
348
+ if (!job.enabled || job.trigger !== timerTrigger.id || !job.schedule) continue;
349
+ const at = nextFireAt(job.schedule, Math.max(now, lastRun[job.id] || 0));
350
+ if (at !== null && (soonest === null || at < soonest)) soonest = at;
351
+ }
352
+ return soonest;
353
+ }
354
+
355
+ /**
356
+ * Which jobs an event fires. Returns matches; running them is the host's business, because
357
+ * only the host knows what a skill costs and whether the user approved it.
358
+ */
359
+ export function jobsForEvent(jobs, event, { registry, ctx = {}, admit = null } = {}) {
360
+ const out = [];
361
+ for (const job of jobs || []) {
362
+ if (!job.enabled) continue;
363
+ if (admit && !admit(job)) continue;
364
+ const trigger = registry?.get(job.trigger);
365
+ if (!trigger || trigger.kind === 'timer') continue;
366
+ if (trigger.watches.length && !trigger.watches.includes(event?.type)) continue;
367
+ let match = null;
368
+ try { match = trigger.matches(event, job.params, ctx); } catch { match = null; }
369
+ // A condition that threw did not match. Firing on an unanswered question is how
370
+ // automation does something nobody asked for.
371
+ if (match) out.push({ job, trigger, match, key: occurrenceKey(job.id, event.at || event.t || 0) });
372
+ }
373
+ return out;
374
+ }
@@ -0,0 +1,664 @@
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
+ // "Every weekday at 8am run my daily brief." The recurrence parser already existed for
542
+ // reminders; what makes this different is that the thing being scheduled is WORK — a skill
543
+ // the user already wrote — so the job says only when, and the skill stays the single
544
+ // definition of what. Declared class C because it will start a model turn every time.
545
+ export const scheduleIntent = defineVoiceIntent({
546
+ id: 'voice:schedule',
547
+ label: 'Schedule something',
548
+ description: 'Runs one of your skills (or a plain instruction) on a schedule.',
549
+ examples: ['every weekday at 8am run my daily brief', 'run the standup summary every morning', 'tomorrow at 9 do the release checklist'],
550
+ classUsed: 'C',
551
+ match: (command, { now = Date.now() } = {}) => {
552
+ const verb = /\b(run|do|start|kick\s+off|execute)\b/i.exec(command);
553
+ if (!verb) return null;
554
+ const when = parseWhen(command, { now });
555
+ // No time is not a schedule — it is a request to do something now, which is a chat
556
+ // message, not a job. Refusing here is what keeps "run the checklist" out of the
557
+ // scheduler.
558
+ if (!when) return null;
559
+ let target = when.end > when.start
560
+ ? command.slice(0, when.start) + ' ' + command.slice(when.end)
561
+ : command;
562
+ const v = /\b(run|do|start|kick\s+off|execute)\b/i.exec(target);
563
+ target = tidy((v ? target.slice(v.index + v[0].length) : target)
564
+ .replace(/^\s*(?:my|the|our)\b/i, '')
565
+ .replace(/\b(skill|job|task)\b\s*$/i, ''));
566
+ if (!target) return null;
567
+ return { target, at: when.at, recurrence: when.recurrence, when: when.kind };
568
+ },
569
+ });
570
+
571
+ export const BUILTIN_VOICE_INTENTS = Object.freeze([timerIntent, reminderIntent, scheduleIntent, noteIntent, monitorIntent]);
572
+
573
+ function tidy(s) {
574
+ return String(s || '').replace(/\s+/g, ' ').replace(/^[\s,.:;-]+|[\s,.:;-]+$/g, '').trim();
575
+ }
576
+
577
+ /** A registry preloaded with the built-ins — the common case, one call. */
578
+ export function defaultVoiceIntents() {
579
+ return createVoiceIntentRegistry(BUILTIN_VOICE_INTENTS);
580
+ }
581
+
582
+ /**
583
+ * Parse one utterance end to end: wake word, then intent.
584
+ *
585
+ * Returns null when the utterance was not addressed to us — which is almost every utterance
586
+ * in a meeting, and must therefore be the cheapest path through this module.
587
+ */
588
+ export function parseCommand(text, { wake = compileWake(), intents = defaultVoiceIntents(), now = Date.now() } = {}) {
589
+ const found = findWakeCommand(text, wake);
590
+ if (!found) return null;
591
+ const parsed = intents.parse(found.command, { now });
592
+ if (!parsed) return null;
593
+ return { ...parsed, wake: found.wake, heard: found.heard, at: found.at };
594
+ }
595
+
596
+ // ---------------------------------------------------------------------------
597
+ // Transcript → commands
598
+ // ---------------------------------------------------------------------------
599
+
600
+ /** How many commands one transcript delta may produce. */
601
+ export const MAX_COMMANDS_PER_DELTA = 3;
602
+
603
+ /**
604
+ * Scan new transcript segments for commands addressed to us.
605
+ *
606
+ * WHO IS ALLOWED TO SPEAK TO IT is the whole security question here. A meeting transcript
607
+ * carries everyone in the room, so an ungated version of this lets any participant put
608
+ * reminders on someone else's device by saying the wake word — and lets a compromised page
609
+ * do it by writing captions. `self` is therefore matched by the HOST, which is the only
610
+ * layer that knows which label is the device owner; segments from anyone else come back
611
+ * with `allowed: false` rather than being dropped silently, so "why didn't it fire" has an
612
+ * answer.
613
+ *
614
+ * @param segments [{ t, speaker, text }] — the delta, not the whole meeting.
615
+ * @param isSelf (speaker) => boolean. Omit ONLY when the host has decided anyone may
616
+ * command this install; the default refuses, because failing closed on a
617
+ * question about authority is the only safe default.
618
+ */
619
+ export function commandsFromSegments(segments, {
620
+ wake = compileWake(), intents = defaultVoiceIntents(), isSelf = null,
621
+ sinceTs = 0, now = Date.now(), meetingId = '', max = MAX_COMMANDS_PER_DELTA,
622
+ } = {}) {
623
+ const out = [];
624
+ for (const seg of segments || []) {
625
+ if (!seg || !seg.text) continue;
626
+ if (seg.t && seg.t <= sinceTs) continue;
627
+ const parsed = parseCommand(seg.text, { wake, intents, now });
628
+ if (!parsed) continue;
629
+ // NO INTENT, NO ACTION. parseCommand returns a shape for anything that carries the wake
630
+ // word and a time-ish phrase, intent included or not — so "we should talk about the chat
631
+ // panel roadmap next week" came back as a command with intent:null and the caller acted
632
+ // on it anyway. In a live meeting that means ordinary conversation quietly sets timers,
633
+ // which is what happened: a caption grows, keeps matching, and fires again.
634
+ //
635
+ // An automation that runs when it did not understand the request is worse than one that
636
+ // does nothing, so an unrecognised utterance stops here.
637
+ if (!parsed.intent) continue;
638
+ const allowed = isSelf ? !!isSelf(seg.speaker) : false;
639
+ out.push({
640
+ ...parsed,
641
+ allowed,
642
+ speaker: seg.speaker || '',
643
+ t: seg.t || now,
644
+ meetingId,
645
+ // Stable across redeliveries of the same segment, so the dedupe actually dedupes.
646
+ //
647
+ // `parsed.at` used to be in this key, and it is an ABSOLUTE time computed as now + the
648
+ // spoken duration — so it changed on every scan. A live caption is rescanned as the
649
+ // sentence grows (deliberately: a half-heard command must get a second chance), which
650
+ // meant one "set a timer for 10 seconds" produced a brand-new key, and a brand-new
651
+ // timer, on every caption update — indefinitely, and faster than the user could delete
652
+ // them. The key now carries only what the same utterance keeps: where it was said, and
653
+ // what it asked for.
654
+ // IDENTITY, NOT FRESHNESS. `seg.t` is bumped every time a live caption's text grows —
655
+ // that is what keeps the line flowing through the delta filter — so keying on it made
656
+ // one spoken request look like a new request on every update, and a single "set a timer
657
+ // for 30 seconds" became a screenful of timers. `sid` is assigned once per utterance and
658
+ // never moves, so the same sentence keeps one key however many times it is rescanned.
659
+ key: `voice:${meetingId}:${seg.sid || seg.t || 0}:${parsed.intent || 'unknown'}:${parsed.ms ?? parsed.when ?? ''}`,
660
+ });
661
+ if (out.length >= max) break; // a pathological transcript cannot fire fifty actions
662
+ }
663
+ return out;
664
+ }
package/widget.js CHANGED
@@ -42,6 +42,11 @@ export function validateWidget(w) {
42
42
  if (!str(w.name)) throw new EventError('SHAPE', 'widget.name required');
43
43
  if (!str(w.html)) throw new EventError('SHAPE', 'widget.html required');
44
44
  if (w.html.length > MAX_HTML) throw new EventError('SHAPE', `widget.html exceeds ${MAX_HTML} bytes`);
45
+ // An icon NAME (the client maps it to its own icon set), so a pinned widget is
46
+ // recognisable at a glance rather than being one of five identical marks.
47
+ if (w.icon != null && !(str(w.icon) && /^[a-z][a-z0-9-]{0,31}$/.test(w.icon))) {
48
+ throw new EventError('SHAPE', 'widget.icon must be an icon name like "timer"');
49
+ }
45
50
  if (w.surface != null && !WIDGET_SURFACES.includes(w.surface)) {
46
51
  throw new EventError('SHAPE', `widget.surface must be one of ${WIDGET_SURFACES}`);
47
52
  }
@@ -108,3 +113,41 @@ export function effectiveGrants(widget, approved = []) {
108
113
  const asked = new Set(widget?.requests || []);
109
114
  return (approved || []).filter((id) => asked.has(id));
110
115
  }
116
+
117
+ // Pick an icon for a widget from what it is called. A pinned widget sits in a narrow strip
118
+ // next to the others, so five identical marks are worse than no icon at all — the point of
119
+ // pinning is to find the thing without reading.
120
+ //
121
+ // Returns an icon NAME, not a glyph: the client owns how it is drawn (the extension resolves
122
+ // these through its vendored set, a mobile client through its own), and a name survives that
123
+ // mapping in a way an emoji does not. Every name here exists in the extension's icon set.
124
+ const ICON_WORDS = [
125
+ [/\b(timers?|pomodoro|stopwatch|countdowns?|intervals?)\b/i, 'timer'],
126
+ [/\b(calc|calculators?|math|arithmetic|tip)\b/i, 'hash'],
127
+ [/\b(notes?|sticky|scratch|memos?|journals?)\b/i, 'notebook-pen'],
128
+ [/\b(todos?|tasks?|checklists?|habits?|trackers?)\b/i, 'list-checks'],
129
+ [/\b(charts?|graphs?|stats?|metrics?|dashboards?)\b/i, 'bar-chart-3'],
130
+ [/\b(convert|converters?|units?|currency|exchange|weights?)\b/i, 'scale'],
131
+ [/\b(calendars?|schedules?|agendas?|dates?)\b/i, 'calendar'],
132
+ [/\b(clocks?|time|timezones?|zones?)\b/i, 'clock'],
133
+ [/\b(goals?|targets?|focus|okrs?)\b/i, 'target'],
134
+ [/\b(ideas?|brainstorm|prompts?)\b/i, 'lightbulb'],
135
+ [/\b(dice|random|rolls?|roller|coins?|shuffle)\b/i, 'zap'],
136
+ [/\b(search|find|lookup)\b/i, 'search'],
137
+ [/\b(web|urls?|links?|browser)\b/i, 'globe'],
138
+ [/\b(mood|mind|memory|brain)\b/i, 'brain'],
139
+ [/\b(password|secrets?|vault|lock)\b/i, 'lock'],
140
+ [/\b(quotes?|sayings?)\b/i, 'quote'],
141
+ [/\b(meetings?|standups?|people|team)\b/i, 'users'],
142
+ [/\b(music|player|sound|audio)\b/i, 'play'],
143
+ [/\b(photos?|images?|gallery)\b/i, 'image'],
144
+ [/\b(files?|documents?|docs?)\b/i, 'file-text'],
145
+ ];
146
+
147
+ /** The widget's own icon if it declared one, else one derived from its name. */
148
+ export function widgetIcon(widget) {
149
+ if (widget?.icon) return widget.icon;
150
+ const name = String(widget?.name || '');
151
+ for (const [re, iconName] of ICON_WORDS) if (re.test(name)) return iconName;
152
+ return 'app-window'; // generic, but never the mark the shelf itself uses
153
+ }