@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 +16 -1
- package/package.json +5 -1
- package/schedule.js +374 -0
- package/voice-intents.js +664 -0
- package/widget.js +43 -0
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.
|
|
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
|
+
}
|
package/voice-intents.js
ADDED
|
@@ -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
|
+
}
|