@chatpanel/events 0.102.0 → 0.103.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/adapters.js +1 -83
- package/adaptive-tool-policy.js +1 -45
- package/agent.js +2 -255
- package/attribution.js +1 -132
- package/backup-envelope.js +1 -221
- package/board-tool.js +2 -165
- package/budget.js +1 -116
- package/capability.js +1 -126
- package/citations.js +5 -127
- package/client-prefs.js +1 -148
- package/context-attachments.js +8 -144
- package/cowriter-router.js +1 -83
- package/cowriter-writer.js +19 -193
- package/cowriter.js +1 -190
- package/curate.js +3 -509
- package/distance.js +1 -124
- package/engine.js +1 -131
- package/entitlement.js +1 -332
- package/entity.js +1 -354
- package/event.js +1 -175
- package/extraction.js +4 -293
- package/failover.js +1 -93
- package/find-tool.js +1 -66
- package/flowchart.js +2 -644
- package/gate.js +1 -74
- package/harness.js +4 -101
- package/index.js +1 -345
- package/invariants.js +1 -174
- package/job.js +3 -147
- package/kernel.js +1 -255
- package/knowledge-derive.js +1 -267
- package/knowledge.js +3 -230
- package/library.js +11 -280
- package/loop.js +1 -132
- package/manifest.js +1 -107
- package/markdown-authoring.js +11 -288
- package/markdown-render.js +0 -0
- package/mcp-client.js +8 -358
- package/mcp-dispatch.js +1 -52
- package/mcp-errors.js +1 -106
- package/mcp-manager.js +1 -127
- package/media-transcript.js +8 -531
- package/meeting-analyzers.js +1 -83
- package/meeting-insights.js +7 -164
- package/meeting-shape.js +1 -282
- package/meeting-text.js +5 -94
- package/memory.js +2 -638
- package/model-candidates.js +1 -358
- package/model-health.js +1 -182
- package/model-ledger.js +1 -228
- package/model-picker.js +1 -188
- package/note-actions.js +15 -246
- package/note-graph.js +1 -158
- package/note-links.js +1 -68
- package/note-mentions.js +8 -114
- package/note-plan.js +9 -107
- package/note-research.js +1 -135
- package/observability.js +1 -119
- package/omni.js +1 -125
- package/order.js +1 -78
- package/package.json +1 -4
- package/pdf-layout.js +7 -253
- package/project-run.js +20 -444
- package/project.js +1 -170
- package/promotion.js +1 -171
- package/queue.js +1 -98
- package/reach.js +1 -23
- package/recipe-tool.js +2 -161
- package/recipe.js +1 -264
- package/record-list.js +2 -110
- package/recruit.js +3 -456
- package/redaction-tokens.js +1 -61
- package/ref.js +1 -55
- package/registry.js +1 -240
- package/route-graph.js +1 -115
- package/route-strategies.js +2 -232
- package/router.js +2 -880
- package/rrf.js +1 -89
- package/rules.js +1 -142
- package/schedule.js +1 -663
- package/scm-connection.js +1 -180
- package/scopes.js +1 -12
- package/scorecard.js +1 -335
- package/search-engines.js +1 -81
- package/skill-manifest.js +1 -330
- package/skill-scan.js +3 -209
- package/skill-sources.js +1 -162
- package/skill-vars.js +2 -252
- package/slash-commands.js +1 -158
- package/source-gate.js +1 -92
- package/sources-retrieval.js +5 -186
- package/sources.js +1 -273
- package/store.js +1 -171
- package/structured.js +4 -901
- package/subject-kinds.js +1 -5
- package/subject-name.js +1 -96
- package/sync-plan.js +1 -213
- package/synthesis.js +3 -123
- package/tags.js +1 -203
- package/team-board.js +6 -337
- package/team-cache.js +2 -63
- package/team-observe.js +1 -118
- package/team-org.js +1 -442
- package/team-plan.js +4 -131
- package/team-record.js +1 -225
- package/team-run.js +9 -652
- package/team-subtask.js +2 -191
- package/team-task.js +2 -124
- package/team-tool.js +2 -196
- package/team-trail.js +2 -73
- package/team-worklog.js +2 -149
- package/team.js +1 -302
- package/text-search.js +1 -155
- package/theme.js +17 -154
- package/titles.js +4 -285
- package/tool-discovery.js +1 -87
- package/tool-dispatch.js +5 -230
- package/tool-groups.js +1 -81
- package/tool-hints.js +10 -185
- package/tool-loop-guard.js +4 -182
- package/tool-need.js +2 -124
- package/tool-result.js +2 -438
- package/tool-round.js +0 -0
- package/tool-schema.js +1 -155
- package/tool-traits.js +1 -182
- package/toolset.js +2 -88
- package/trajectory.js +1 -530
- package/turn-loop.js +4 -440
- package/upcast.js +1 -37
- package/vault.js +2 -250
- package/view.js +1 -88
- package/voice-intents.js +2 -1488
- package/voice-speaker.js +1 -98
- package/weather-tool.js +1 -65
- package/weather.js +2 -211
- package/web-search-tool.js +14 -102
- package/web-search.js +1 -160
- package/widget.js +1 -153
package/schedule.js
CHANGED
|
@@ -1,663 +1 @@
|
|
|
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
|
-
// 'channel' is the plug-in point for external messaging surfaces (Telegram/WhatsApp). A
|
|
32
|
-
// message arriving in a paired chat is an event like any other, so it can START a job the user
|
|
33
|
-
// already created and approved — "when I text the bot, run my daily brief" — and can do nothing
|
|
34
|
-
// else, exactly like a phrase spoken in a meeting. The trigger definition itself lives in the
|
|
35
|
-
// chatpanel-channels package; this enum is what lets it declare kind:'channel'.
|
|
36
|
-
export const TRIGGER_KINDS = Object.freeze(['timer', 'meeting', 'voice', 'data', 'channel']);
|
|
37
|
-
/** What a job does when it fires. `skill` is the headline: the instruction IS a skill. */
|
|
38
|
-
export const JOB_ACTIONS = Object.freeze(['skill', 'prompt', 'monitor', 'notify']);
|
|
39
|
-
/** What to do about occurrences that passed while nothing was running. */
|
|
40
|
-
export const MISSED_POLICIES = Object.freeze(['skip', 'runOnce', 'runAll']);
|
|
41
|
-
|
|
42
|
-
const MAX_CATCH_UP = 20; // a fortnight asleep must not queue a hundred model calls
|
|
43
|
-
|
|
44
|
-
// ---------------------------------------------------------------------------
|
|
45
|
-
// Schedule maths
|
|
46
|
-
// ---------------------------------------------------------------------------
|
|
47
|
-
|
|
48
|
-
function atLocal(base, { days = 0, hour, minute = 0 }) {
|
|
49
|
-
const d = new Date(base);
|
|
50
|
-
d.setDate(d.getDate() + days);
|
|
51
|
-
d.setHours(hour, minute, 0, 0);
|
|
52
|
-
return d.getTime();
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
const isWeekend = (ts) => { const d = new Date(ts).getDay(); return d === 0 || d === 6; };
|
|
56
|
-
|
|
57
|
-
export function validateSchedule(s) {
|
|
58
|
-
if (!s || typeof s !== 'object') throw new ScheduleError('BAD_SCHEDULE', 'schedule must be an object');
|
|
59
|
-
if (!SCHEDULE_KINDS.includes(s.kind)) throw new ScheduleError('BAD_SCHEDULE', `unknown schedule kind '${s.kind}'`);
|
|
60
|
-
if (s.kind === 'once' && !(s.at > 0)) throw new ScheduleError('BAD_SCHEDULE', 'once needs `at`');
|
|
61
|
-
if (s.kind === 'interval' && !(s.everyMs >= 60_000)) {
|
|
62
|
-
// A minute is the floor every platform's scheduler shares (chrome.alarms refuses less).
|
|
63
|
-
// Accepting 5s here would produce a job that silently fires on somebody else's cadence.
|
|
64
|
-
throw new ScheduleError('BAD_SCHEDULE', 'interval needs everyMs >= 60000');
|
|
65
|
-
}
|
|
66
|
-
if (s.kind === 'daily' || s.kind === 'weekly') {
|
|
67
|
-
if (!Number.isInteger(s.hour) || s.hour < 0 || s.hour > 23) throw new ScheduleError('BAD_SCHEDULE', 'hour must be 0-23');
|
|
68
|
-
const m = s.minute ?? 0;
|
|
69
|
-
if (!Number.isInteger(m) || m < 0 || m > 59) throw new ScheduleError('BAD_SCHEDULE', 'minute must be 0-59');
|
|
70
|
-
}
|
|
71
|
-
if (s.kind === 'weekly' && (!Number.isInteger(s.weekday) || s.weekday < 0 || s.weekday > 6)) {
|
|
72
|
-
throw new ScheduleError('BAD_SCHEDULE', 'weekly needs weekday 0-6 (Sunday = 0)');
|
|
73
|
-
}
|
|
74
|
-
return s;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* The first firing STRICTLY after `from`. Null when a one-shot is already spent.
|
|
79
|
-
*
|
|
80
|
-
* Strictly after, so feeding a fire time back in advances instead of returning the same
|
|
81
|
-
* occurrence forever — the loop that ran a job and then asked "what's next?" would otherwise
|
|
82
|
-
* never stop.
|
|
83
|
-
*/
|
|
84
|
-
export function nextFireAt(schedule, from) {
|
|
85
|
-
const s = validateSchedule(schedule);
|
|
86
|
-
const minute = s.minute ?? 0;
|
|
87
|
-
switch (s.kind) {
|
|
88
|
-
case 'once':
|
|
89
|
-
return s.at > from ? s.at : null;
|
|
90
|
-
case 'interval': {
|
|
91
|
-
// Anchored so a job restored from storage keeps its original phase instead of drifting
|
|
92
|
-
// a little later every time the extension restarts.
|
|
93
|
-
const anchor = s.anchor ?? from;
|
|
94
|
-
if (anchor > from) return anchor;
|
|
95
|
-
const steps = Math.floor((from - anchor) / s.everyMs) + 1;
|
|
96
|
-
return anchor + steps * s.everyMs;
|
|
97
|
-
}
|
|
98
|
-
case 'daily': {
|
|
99
|
-
let at = atLocal(from, { hour: s.hour, minute });
|
|
100
|
-
if (at <= from) at = atLocal(from, { days: 1, hour: s.hour, minute });
|
|
101
|
-
if (s.weekdaysOnly) for (let i = 0; i < 7 && isWeekend(at); i++) at = atLocal(at, { days: 1, hour: s.hour, minute });
|
|
102
|
-
return at;
|
|
103
|
-
}
|
|
104
|
-
case 'weekly': {
|
|
105
|
-
const day = new Date(from).getDay();
|
|
106
|
-
let delta = (s.weekday - day + 7) % 7;
|
|
107
|
-
if (delta === 0 && atLocal(from, { hour: s.hour, minute }) <= from) delta = 7;
|
|
108
|
-
return atLocal(from, { days: delta, hour: s.hour, minute });
|
|
109
|
-
}
|
|
110
|
-
default:
|
|
111
|
-
return null;
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/** Sunday-first, matching `Date#getDay()` and the `weekday` field. */
|
|
116
|
-
export const WEEKDAY_NAMES = Object.freeze([
|
|
117
|
-
'Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday',
|
|
118
|
-
]);
|
|
119
|
-
|
|
120
|
-
const hhmm = (s) => `${String(s?.hour ?? 0).padStart(2, '0')}:${String(s?.minute ?? 0).padStart(2, '0')}`;
|
|
121
|
-
|
|
122
|
-
/**
|
|
123
|
-
* A schedule as a sentence: "every weekday at 08:00", not `{kind:'daily',hour:8,...}`.
|
|
124
|
-
*
|
|
125
|
-
* It lives beside the model rather than in a client because a schedule the user cannot read
|
|
126
|
-
* back is a schedule they cannot trust, and every client has to solve that. The desktop and
|
|
127
|
-
* the extension had already written this twice, and the copies disagreed: one rendered
|
|
128
|
-
* `weekdaysOnly` and the other silently dropped it, so a job that skipped weekends still
|
|
129
|
-
* read as "every day".
|
|
130
|
-
*
|
|
131
|
-
* Returns '' for a schedule it cannot describe rather than inventing one — an unreadable
|
|
132
|
-
* label is better than a confident wrong one.
|
|
133
|
-
*/
|
|
134
|
-
export function describeSchedule(s) {
|
|
135
|
-
if (!s || typeof s !== 'object') return '';
|
|
136
|
-
switch (s.kind) {
|
|
137
|
-
case 'once': {
|
|
138
|
-
if (!(s.at > 0)) return '';
|
|
139
|
-
return `once, at ${new Date(s.at).toLocaleString()}`;
|
|
140
|
-
}
|
|
141
|
-
case 'interval': {
|
|
142
|
-
const ms = Number(s.everyMs) || 0;
|
|
143
|
-
if (ms < 60_000) return '';
|
|
144
|
-
const mins = Math.round(ms / 60_000);
|
|
145
|
-
if (mins % 1440 === 0) { const d = mins / 1440; return `every ${d === 1 ? 'day' : `${d} days`}`; }
|
|
146
|
-
if (mins % 60 === 0) { const h = mins / 60; return `every ${h === 1 ? 'hour' : `${h} hours`}`; }
|
|
147
|
-
return `every ${mins} minutes`;
|
|
148
|
-
}
|
|
149
|
-
case 'daily':
|
|
150
|
-
return s.weekdaysOnly ? `every weekday at ${hhmm(s)}` : `every day at ${hhmm(s)}`;
|
|
151
|
-
case 'weekly': {
|
|
152
|
-
const name = WEEKDAY_NAMES[s.weekday];
|
|
153
|
-
return name ? `every ${name} at ${hhmm(s)}` : '';
|
|
154
|
-
}
|
|
155
|
-
default:
|
|
156
|
-
return '';
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
/** Every firing in (from, to], oldest first. Capped: a long sleep is not a queue of work. */
|
|
161
|
-
export function occurrencesBetween(schedule, from, to, max = MAX_CATCH_UP) {
|
|
162
|
-
const out = [];
|
|
163
|
-
let cursor = from;
|
|
164
|
-
while (out.length < max) {
|
|
165
|
-
const at = nextFireAt(schedule, cursor);
|
|
166
|
-
if (at === null || at > to) break;
|
|
167
|
-
out.push(at);
|
|
168
|
-
cursor = at;
|
|
169
|
-
}
|
|
170
|
-
return out;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
// ---------------------------------------------------------------------------
|
|
174
|
-
// Triggers — declarations. A trigger says WHEN, never what.
|
|
175
|
-
// ---------------------------------------------------------------------------
|
|
176
|
-
|
|
177
|
-
/**
|
|
178
|
-
* @param matches (event, params, ctx) => match | null. Pure and synchronous, like rules.js:
|
|
179
|
-
* "did this match" must be answerable without side effects or a network.
|
|
180
|
-
* @param watches the event types it can possibly match, so a busy bus is cheap.
|
|
181
|
-
*/
|
|
182
|
-
export function defineTrigger({
|
|
183
|
-
id, label, kind, watches = [], description = '', classUsed = 'R', matches = null, params = {},
|
|
184
|
-
}) {
|
|
185
|
-
if (!id) throw new ScheduleError('BAD_TRIGGER', 'trigger.id required');
|
|
186
|
-
if (!TRIGGER_KINDS.includes(kind)) throw new ScheduleError('BAD_TRIGGER', `trigger '${id}': unknown kind '${kind}'`);
|
|
187
|
-
if (kind !== 'timer' && typeof matches !== 'function') {
|
|
188
|
-
throw new ScheduleError('BAD_TRIGGER', `trigger '${id}': an event trigger needs matches()`);
|
|
189
|
-
}
|
|
190
|
-
return Object.freeze({ id, label: label || id, kind, watches: Object.freeze([...watches]), description, classUsed, matches, params: Object.freeze({ ...params }) });
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
export function createTriggerRegistry(triggers = []) {
|
|
194
|
-
const list = [...triggers];
|
|
195
|
-
return {
|
|
196
|
-
add(t) { list.push(t); return () => { const i = list.indexOf(t); if (i >= 0) list.splice(i, 1); }; },
|
|
197
|
-
list: () => [...list],
|
|
198
|
-
get: (id) => list.find((t) => t.id === id) || null,
|
|
199
|
-
};
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
const words = (s) => String(s || '').toLowerCase().match(/[\p{L}\p{N}']+/gu) || [];
|
|
203
|
-
const norm = (s) => String(s || '').trim().toLowerCase();
|
|
204
|
-
|
|
205
|
-
/** Shorter than this and ordinary speech contains it constantly. */
|
|
206
|
-
export const MIN_PHRASE_CHARS = 3;
|
|
207
|
-
|
|
208
|
-
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
209
|
-
|
|
210
|
-
/**
|
|
211
|
-
* Shorten text a person is meant to READ.
|
|
212
|
-
*
|
|
213
|
-
* A hard `slice` lands mid-word — "…with the informati" — and reads as a bug in whatever
|
|
214
|
-
* wrote it rather than as an abbreviation. Backing up to the last word boundary costs
|
|
215
|
-
* nothing and is the whole difference. Exported because three places shorten the same kinds
|
|
216
|
-
* of strings (a trigger's reason, a batch summary, a job named after its own instruction)
|
|
217
|
-
* and three hard slices is how they drift apart.
|
|
218
|
-
*/
|
|
219
|
-
export function clipText(text, max = 120) {
|
|
220
|
-
const t = String(text || '').replace(/\s+/g, ' ').trim();
|
|
221
|
-
if (t.length <= max) return t;
|
|
222
|
-
const cut = t.slice(0, max);
|
|
223
|
-
const at = cut.lastIndexOf(' ');
|
|
224
|
-
// Only back up when there is a boundary worth backing up TO. A single long token — a URL,
|
|
225
|
-
// an id — has none, and shrinking it to nothing helps nobody.
|
|
226
|
-
return `${(at > max * 0.6 ? cut.slice(0, at) : cut).replace(/[\s,;:.!?—-]+$/, '')}…`;
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
/**
|
|
230
|
-
* Whole-word containment, not `includes`.
|
|
231
|
-
*
|
|
232
|
-
* A substring match turns "in" into a trigger that fires on interview, thing, going and
|
|
233
|
-
* finding — which is indistinguishable, from the user's chair, from a trigger that ignores
|
|
234
|
-
* its phrase entirely. That is exactly how it was reported: "for every utterance I say, it
|
|
235
|
-
* runs". Boundaries are only required at ends that are word characters, so "action item"
|
|
236
|
-
* still matches "an action item," and ":shipped" still matches ":shipped".
|
|
237
|
-
*/
|
|
238
|
-
export function saidIn(text, phrase) {
|
|
239
|
-
const p = norm(phrase);
|
|
240
|
-
if (p.length < MIN_PHRASE_CHARS) return false;
|
|
241
|
-
const left = /[\p{L}\p{N}]/u.test(p[0]) ? '\\b' : '';
|
|
242
|
-
const right = /[\p{L}\p{N}]/u.test(p[p.length - 1]) ? '\\b' : '';
|
|
243
|
-
try {
|
|
244
|
-
return new RegExp(`${left}${escapeRe(p)}${right}`, 'iu').test(String(text || ''));
|
|
245
|
-
} catch {
|
|
246
|
-
return norm(text).includes(p); // a phrase that will not compile still gets a plain match
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
// Whose speech a meeting trigger cares about. The default is 'anyone' because a phrase
|
|
251
|
-
// trigger is usually watching for what OTHERS say — unlike a spoken command, which is only
|
|
252
|
-
// ever the owner's (see voice-intents.js).
|
|
253
|
-
function speakerAllowed(want, speaker, ctx) {
|
|
254
|
-
if (want === 'me') return !!ctx?.isSelf?.(speaker);
|
|
255
|
-
if (want === 'others') return !ctx?.isSelf?.(speaker);
|
|
256
|
-
return true;
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
/**
|
|
260
|
-
* Words a sentence does not end on.
|
|
261
|
-
*
|
|
262
|
-
* Not a grammar: a short list of the tails that mean "the speaker is mid-thought". A caption
|
|
263
|
-
* ending on one of these is a line that has not finished arriving.
|
|
264
|
-
*/
|
|
265
|
-
export const DANGLING_TAILS = Object.freeze(new Set([
|
|
266
|
-
// conjunctions and subordinators — the reason, the contrast, the condition all come AFTER
|
|
267
|
-
'and', 'or', 'but', 'so', 'because', 'cause', 'cos', 'since', 'although', 'though', 'while',
|
|
268
|
-
'whereas', 'unless', 'until', 'if', 'when', 'whenever', 'that', 'which', 'who', 'whom',
|
|
269
|
-
'whose', 'than', 'as', 'like',
|
|
270
|
-
// 'what' ends a clause only in a handful of set phrases ("guess what") and otherwise means
|
|
271
|
-
// the object of the thought is still coming — "…testing to see what". Live captions cut
|
|
272
|
-
// exactly there and punctuate it, which is how a sentence ABOUT the product became an
|
|
273
|
-
// address to it (see isAddressed in voice-intents.js).
|
|
274
|
-
'what',
|
|
275
|
-
// prepositions and particles
|
|
276
|
-
'to', 'of', 'in', 'on', 'at', 'by', 'for', 'from', 'with', 'without', 'about', 'into',
|
|
277
|
-
'onto', 'over', 'under', 'between', 'through', 'during', 'against', 'per',
|
|
278
|
-
// determiners that must be followed by something. Only the ones that genuinely cannot end
|
|
279
|
-
// a sentence — 'this', 'some' and 'both' all can ("take both"), so they are not here.
|
|
280
|
-
'a', 'an', 'the', 'my', 'our', 'your', 'their', 'its',
|
|
281
|
-
// auxiliaries and copulas left hanging
|
|
282
|
-
'is', 'are', 'was', 'were', 'be', 'been', 'being', 'am', 'do', 'does', 'did', 'have', 'has',
|
|
283
|
-
'had', 'will', 'would', 'can', 'could', 'should', 'shall', 'may', 'might', 'must',
|
|
284
|
-
// fillers a speaker trails off on. Pronouns are NOT here: "we should ship it" is a whole
|
|
285
|
-
// thought, and treating every object pronoun as a trailing word made the common case wait.
|
|
286
|
-
'um', 'uh', 'er',
|
|
287
|
-
]));
|
|
288
|
-
|
|
289
|
-
/**
|
|
290
|
-
* Does this line read as a FINISHED thought?
|
|
291
|
-
*
|
|
292
|
-
* A live caption is delivered while it is still being spoken and GROWS across flushes, so a
|
|
293
|
-
* trigger that matched can be holding half a sentence: "the product is just amazing because"
|
|
294
|
-
* — with the reason, the only part worth acting on, still unsaid. The caller uses this to
|
|
295
|
-
* decide whether to read the transcript now or wait for the rest of it.
|
|
296
|
-
*
|
|
297
|
-
* DEFAULT TRUE, deliberately. Speech-to-text frequently emits no punctuation at all, so
|
|
298
|
-
* requiring a full stop would make every job wait every time — and the complaint this exists
|
|
299
|
-
* to fix is about missing context, not about speed. Only a line that ends the way an
|
|
300
|
-
* unfinished one ends is treated as unfinished.
|
|
301
|
-
*
|
|
302
|
-
* Pure and synchronous, like every other predicate in this file: the waiting is the caller's
|
|
303
|
-
* problem, and it is the only part that needs a platform.
|
|
304
|
-
*/
|
|
305
|
-
export function utteranceLooksComplete(text) {
|
|
306
|
-
const t = String(text || '').trim();
|
|
307
|
-
if (!t) return false;
|
|
308
|
-
// Sentence-final punctuation, optionally inside a closing quote or bracket.
|
|
309
|
-
if (/[.!?…]["'\u2019\u201d)\]]*$/.test(t)) return true;
|
|
310
|
-
// A comma, dash or colon at the end is a speaker who is explicitly not done.
|
|
311
|
-
if (/[,;:\-\u2013\u2014]$/.test(t)) return false;
|
|
312
|
-
const tokens = t.toLowerCase().match(/[\p{L}\p{N}']+/gu) || [];
|
|
313
|
-
const last = tokens[tokens.length - 1];
|
|
314
|
-
if (!last) return true;
|
|
315
|
-
return !DANGLING_TAILS.has(last);
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
// ---------------------------------------------------------------------------
|
|
319
|
-
// WHERE TEXT COMES FROM.
|
|
320
|
-
//
|
|
321
|
-
// A phrase worth acting on is a phrase worth acting on wherever it is written. The triggers
|
|
322
|
-
// below were built for live captions, but nothing in them is about a meeting: they take
|
|
323
|
-
// {speaker, text} arriving over time, which is equally a note being typed and a chat being
|
|
324
|
-
// sent. So the SOURCE became a parameter instead of three copies of the matcher.
|
|
325
|
-
//
|
|
326
|
-
// `text.delta` is the source-agnostic event. `meeting.transcript.delta` stays a first-class
|
|
327
|
-
// type — it is what the meeting pipeline already emits and what stored jobs were matched
|
|
328
|
-
// against — and is read as source 'meeting'.
|
|
329
|
-
//
|
|
330
|
-
// DEFAULT IS MEETING-ONLY, and that is a compatibility guarantee, not a preference: every
|
|
331
|
-
// job stored before this existed was created against a form that said "when a phrase is
|
|
332
|
-
// said" in a call. Widening those silently would start running models over notes their
|
|
333
|
-
// author never pointed them at.
|
|
334
|
-
// ---------------------------------------------------------------------------
|
|
335
|
-
export const TEXT_DELTA = 'text.delta';
|
|
336
|
-
export const TRIGGER_SOURCES = Object.freeze(['meeting', 'note', 'chat']);
|
|
337
|
-
export const TEXT_WATCHES = Object.freeze(['meeting.transcript.delta', TEXT_DELTA]);
|
|
338
|
-
|
|
339
|
-
/** Which surface an event came from. A meeting delta says so by its type alone. */
|
|
340
|
-
export function eventSource(event) {
|
|
341
|
-
if (event?.type === 'meeting.transcript.delta') return 'meeting';
|
|
342
|
-
const s = norm(event?.source);
|
|
343
|
-
return TRIGGER_SOURCES.includes(s) ? s : '';
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
/** May this job act on this surface? Absent `sources` means meetings only — see above. */
|
|
347
|
-
export function sourceAllowed(sources, event) {
|
|
348
|
-
const from = eventSource(event);
|
|
349
|
-
if (!from) return false;
|
|
350
|
-
const want = (Array.isArray(sources) ? sources : []).map(norm).filter((x) => TRIGGER_SOURCES.includes(x));
|
|
351
|
-
return want.length ? want.includes(from) : from === 'meeting';
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
export const timerTrigger = defineTrigger({
|
|
355
|
-
id: 'timer:schedule',
|
|
356
|
-
label: 'On a schedule',
|
|
357
|
-
kind: 'timer',
|
|
358
|
-
description: 'Once, on an interval, daily, or on a weekday — "every weekday at 8am".',
|
|
359
|
-
});
|
|
360
|
-
|
|
361
|
-
export const meetingStartedTrigger = defineTrigger({
|
|
362
|
-
id: 'meeting:started',
|
|
363
|
-
label: 'When a meeting starts',
|
|
364
|
-
kind: 'meeting',
|
|
365
|
-
watches: ['meeting.started'],
|
|
366
|
-
matches: (event, params = {}) => {
|
|
367
|
-
if (params.platform && norm(event.platform) !== norm(params.platform)) return null;
|
|
368
|
-
if (params.titleIncludes && !norm(event.title).includes(norm(params.titleIncludes))) return null;
|
|
369
|
-
return { why: `meeting started: ${event.title || event.meetingId}` };
|
|
370
|
-
},
|
|
371
|
-
});
|
|
372
|
-
|
|
373
|
-
export const meetingEndedTrigger = defineTrigger({
|
|
374
|
-
id: 'meeting:ended',
|
|
375
|
-
label: 'When a meeting ends',
|
|
376
|
-
kind: 'meeting',
|
|
377
|
-
watches: ['meeting.ended'],
|
|
378
|
-
matches: (event) => ({ why: `meeting ended: ${event.title || event.meetingId}` }),
|
|
379
|
-
});
|
|
380
|
-
|
|
381
|
-
export const personJoinedTrigger = defineTrigger({
|
|
382
|
-
id: 'meeting:person-joined',
|
|
383
|
-
label: 'When someone joins',
|
|
384
|
-
kind: 'meeting',
|
|
385
|
-
watches: ['meeting.person-joined'],
|
|
386
|
-
// No names means anyone — "tell me when the call fills up" is as valid as "tell me when
|
|
387
|
-
// Alex joins", and an empty list that matched nothing would look like a broken job.
|
|
388
|
-
matches: (event, params = {}) => {
|
|
389
|
-
const want = (params.names || []).map(norm).filter(Boolean);
|
|
390
|
-
const joined = (event.people || []).filter((p) => !want.length || want.some((n) => norm(p) === n || norm(p).startsWith(`${n} `)));
|
|
391
|
-
return joined.length ? { why: `joined: ${joined.join(', ')}`, people: joined } : null;
|
|
392
|
-
},
|
|
393
|
-
});
|
|
394
|
-
|
|
395
|
-
export const phraseTrigger = defineTrigger({
|
|
396
|
-
id: 'meeting:phrase',
|
|
397
|
-
label: 'When a phrase is said',
|
|
398
|
-
kind: 'meeting',
|
|
399
|
-
watches: TEXT_WATCHES,
|
|
400
|
-
matches: (event, params = {}, ctx = {}) => {
|
|
401
|
-
// Both guards exist because their absence looks the same to a user: a trigger that fires
|
|
402
|
-
// on everything. An empty list would match every line; a one- or two-letter phrase
|
|
403
|
-
// effectively does too.
|
|
404
|
-
if (!sourceAllowed(params.sources, event)) return null;
|
|
405
|
-
const any = (params.any || []).map(norm).filter((p) => p.length >= MIN_PHRASE_CHARS);
|
|
406
|
-
if (!any.length) return null;
|
|
407
|
-
for (const seg of event.segments || []) {
|
|
408
|
-
if (!speakerAllowed(params.speaker, seg.speaker, ctx)) continue;
|
|
409
|
-
const hit = any.find((p) => saidIn(seg.text, p));
|
|
410
|
-
if (hit) return { why: `“${hit}” said by ${seg.speaker || 'someone'}`, segment: seg, phrase: hit };
|
|
411
|
-
}
|
|
412
|
-
return null;
|
|
413
|
-
},
|
|
414
|
-
});
|
|
415
|
-
|
|
416
|
-
export const topicTrigger = defineTrigger({
|
|
417
|
-
id: 'meeting:topic',
|
|
418
|
-
label: 'When someone talks about something',
|
|
419
|
-
kind: 'meeting',
|
|
420
|
-
watches: TEXT_WATCHES,
|
|
421
|
-
// Looser than a phrase on purpose: "says something about pricing" should not require the
|
|
422
|
-
// word "pricing" in the exact shape the job author typed. Term overlap over the window is
|
|
423
|
-
// deterministic, explainable, and free — a model would be all three of the opposite.
|
|
424
|
-
matches: (event, params = {}, ctx = {}) => {
|
|
425
|
-
if (!sourceAllowed(params.sources, event)) return null;
|
|
426
|
-
const terms = (params.terms || []).map(norm).filter(Boolean);
|
|
427
|
-
if (!terms.length) return null;
|
|
428
|
-
const need = Math.max(1, Math.min(params.minHits || 1, terms.length));
|
|
429
|
-
const window = (event.segments || []).filter((s) => speakerAllowed(params.speaker, s.speaker, ctx));
|
|
430
|
-
const bag = new Set(window.flatMap((s) => words(s.text)));
|
|
431
|
-
const hits = terms.filter((t) => t.split(/\s+/).every((w) => bag.has(w)));
|
|
432
|
-
return hits.length >= need ? { why: `talking about ${hits.join(', ')}`, terms: hits } : null;
|
|
433
|
-
},
|
|
434
|
-
});
|
|
435
|
-
|
|
436
|
-
// A question mark is the cheap half; the interrogative openers are what catch speech-to-text
|
|
437
|
-
// output, which frequently drops the punctuation entirely.
|
|
438
|
-
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;
|
|
439
|
-
|
|
440
|
-
export const questionTrigger = defineTrigger({
|
|
441
|
-
id: 'meeting:question',
|
|
442
|
-
label: 'When a question is asked',
|
|
443
|
-
kind: 'meeting',
|
|
444
|
-
watches: TEXT_WATCHES,
|
|
445
|
-
matches: (event, params = {}, ctx = {}) => {
|
|
446
|
-
if (!sourceAllowed(params.sources, event)) return null;
|
|
447
|
-
for (const seg of event.segments || []) {
|
|
448
|
-
// Other people by default: this exists for reacting to what someone ELSE asks (an
|
|
449
|
-
// interview, a customer call). The job form offers the choice, so 'anyone' and 'me' are
|
|
450
|
-
// one dropdown away rather than an invisible default nobody can reach.
|
|
451
|
-
// ANYONE by default, including you. 'others' read well — this exists for reacting to
|
|
452
|
-
// what someone ELSE asks — but it made the first thing anybody does (test it alone in a
|
|
453
|
-
// call, ask a question, wait) match nothing, so the feature looked dead. "Only what
|
|
454
|
-
// other people ask" is still one dropdown away in the job form.
|
|
455
|
-
if (!speakerAllowed(params.speaker || 'anyone', seg.speaker, ctx)) continue;
|
|
456
|
-
const text = String(seg.text || '').trim();
|
|
457
|
-
if (text.length < 8) continue; // "what?" is not a question worth waking a model for
|
|
458
|
-
// The QUESTION itself, not only who asked it. Every sibling trigger names what it
|
|
459
|
-
// fired on — the phrase, the spoken command, the people who joined — and "question
|
|
460
|
-
// from Alex" leaves the answer under it looking like an answer to nothing. This
|
|
461
|
-
// string is the row in the thread, the toast, and the line in the job's run log.
|
|
462
|
-
if (text.includes('?') || QUESTION.test(text)) {
|
|
463
|
-
return { why: `question from ${seg.speaker || 'someone'}: “${clipText(text, 100)}”`, segment: seg };
|
|
464
|
-
}
|
|
465
|
-
}
|
|
466
|
-
return null;
|
|
467
|
-
},
|
|
468
|
-
});
|
|
469
|
-
|
|
470
|
-
export const voiceCommandTrigger = defineTrigger({
|
|
471
|
-
id: 'voice:command',
|
|
472
|
-
label: 'When you say the wake word',
|
|
473
|
-
kind: 'voice',
|
|
474
|
-
watches: ['voice.command'],
|
|
475
|
-
matches: (event, params = {}) => {
|
|
476
|
-
const want = params.intents || [];
|
|
477
|
-
if (!event.command?.allowed) return null; // authority is settled upstream; never widened here
|
|
478
|
-
if (want.length && !want.includes(event.command.intent)) return null;
|
|
479
|
-
return { why: `you said “${event.command.command}”`, command: event.command };
|
|
480
|
-
},
|
|
481
|
-
});
|
|
482
|
-
|
|
483
|
-
export const BUILTIN_TRIGGERS = Object.freeze([
|
|
484
|
-
timerTrigger, meetingStartedTrigger, meetingEndedTrigger, personJoinedTrigger,
|
|
485
|
-
phraseTrigger, topicTrigger, questionTrigger, voiceCommandTrigger,
|
|
486
|
-
]);
|
|
487
|
-
|
|
488
|
-
// ---------------------------------------------------------------------------
|
|
489
|
-
// Jobs
|
|
490
|
-
// ---------------------------------------------------------------------------
|
|
491
|
-
|
|
492
|
-
/**
|
|
493
|
-
* @param action { kind: 'skill', skillId } — the headline case: the instruction IS a skill,
|
|
494
|
-
* so "every morning, do my daily brief" is a job whose action names the skill
|
|
495
|
-
* the user already wrote. Also 'prompt' (raw text), 'monitor', 'notify'.
|
|
496
|
-
* @param limits { maxPerDay } — a job that fails must back off, not retry in a tight loop.
|
|
497
|
-
* @param onMissed what to do about occurrences that passed while nothing was awake. Stated,
|
|
498
|
-
* because silently running eleven catch-up briefs is as wrong as silently
|
|
499
|
-
* running none, and the difference is money.
|
|
500
|
-
*/
|
|
501
|
-
export function defineJob({
|
|
502
|
-
id, name, trigger, schedule = null, params = {}, action,
|
|
503
|
-
enabled = true, onMissed = 'runOnce', limits = {}, approval = null, createdAt = 0,
|
|
504
|
-
}) {
|
|
505
|
-
if (!id) throw new ScheduleError('BAD_JOB', 'job.id required');
|
|
506
|
-
if (!name) throw new ScheduleError('BAD_JOB', `job '${id}': name required`);
|
|
507
|
-
if (!trigger) throw new ScheduleError('BAD_JOB', `job '${id}': trigger required`);
|
|
508
|
-
if (!action || !JOB_ACTIONS.includes(action.kind)) {
|
|
509
|
-
throw new ScheduleError('BAD_JOB', `job '${id}': action.kind must be one of ${JOB_ACTIONS}`);
|
|
510
|
-
}
|
|
511
|
-
if (action.kind === 'skill' && !action.skillId) throw new ScheduleError('BAD_JOB', `job '${id}': skill action needs skillId`);
|
|
512
|
-
if (!MISSED_POLICIES.includes(onMissed)) throw new ScheduleError('BAD_JOB', `job '${id}': unknown onMissed '${onMissed}'`);
|
|
513
|
-
if (trigger === timerTrigger.id || schedule) validateSchedule(schedule);
|
|
514
|
-
return {
|
|
515
|
-
id, name, trigger, schedule, params: { ...params }, action: { ...action },
|
|
516
|
-
enabled: !!enabled, onMissed, limits: { ...limits }, approval, createdAt: createdAt || 0,
|
|
517
|
-
};
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
/**
|
|
521
|
-
* Dedup is on the SCHEDULED time, never the fired time — a wake-up at 09:04 for the 09:00
|
|
522
|
-
* slot is the 09:00 run, and a second wake-up for that slot is a no-op. Alarms are
|
|
523
|
-
* approximate and devices sleep; without this, "approximately 9" means "twice".
|
|
524
|
-
*/
|
|
525
|
-
export function occurrenceKey(jobId, at) { return `${jobId}@${at}`; }
|
|
526
|
-
|
|
527
|
-
/**
|
|
528
|
-
* Which timer jobs are due, and which of those are catch-up rather than on-time.
|
|
529
|
-
*
|
|
530
|
-
* @param lastRun { [jobId]: ts } — the last OCCURRENCE run, not the last wake-up.
|
|
531
|
-
*/
|
|
532
|
-
export function dueJobs(jobs, { now, lastRun = {}, admit = null, max = MAX_CATCH_UP } = {}) {
|
|
533
|
-
const out = [];
|
|
534
|
-
for (const job of jobs || []) {
|
|
535
|
-
if (!job.enabled) continue;
|
|
536
|
-
if (job.trigger !== timerTrigger.id || !job.schedule) continue;
|
|
537
|
-
if (admit && !admit(job)) continue;
|
|
538
|
-
const since = lastRun[job.id] || job.createdAt || 0;
|
|
539
|
-
if (!since) continue; // a job with no anchor cannot know what it missed
|
|
540
|
-
const missed = occurrencesBetween(job.schedule, since, now, max);
|
|
541
|
-
if (!missed.length) continue;
|
|
542
|
-
const runs = job.onMissed === 'runAll' ? missed
|
|
543
|
-
: job.onMissed === 'runOnce' ? [missed[missed.length - 1]]
|
|
544
|
-
: [];
|
|
545
|
-
for (const at of runs) out.push({ job, at, key: occurrenceKey(job.id, at), late: now - at > 60_000, missedCount: missed.length });
|
|
546
|
-
// 'skip' still reports so the caller can advance its watermark without running anything.
|
|
547
|
-
if (!runs.length) out.push({ job, at: missed[missed.length - 1], key: occurrenceKey(job.id, missed[missed.length - 1]), skipped: true, missedCount: missed.length });
|
|
548
|
-
}
|
|
549
|
-
return out;
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
/** The soonest any timer job wants to be woken, so a client arms ONE platform alarm. */
|
|
553
|
-
export function nextWakeAt(jobs, { now, lastRun = {} } = {}) {
|
|
554
|
-
let soonest = null;
|
|
555
|
-
for (const job of jobs || []) {
|
|
556
|
-
if (!job.enabled || job.trigger !== timerTrigger.id || !job.schedule) continue;
|
|
557
|
-
const at = nextFireAt(job.schedule, Math.max(now, lastRun[job.id] || 0));
|
|
558
|
-
if (at !== null && (soonest === null || at < soonest)) soonest = at;
|
|
559
|
-
}
|
|
560
|
-
return soonest;
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
/**
|
|
564
|
-
* Which jobs an event fires. Returns matches; running them is the host's business, because
|
|
565
|
-
* only the host knows what a skill costs and whether the user approved it.
|
|
566
|
-
*/
|
|
567
|
-
/** More than this in one batch and the answer stops being an answer. */
|
|
568
|
-
export const DEFAULT_COALESCE_MAX = 8;
|
|
569
|
-
|
|
570
|
-
/**
|
|
571
|
-
* Fold several triggers of the SAME job into ONE run.
|
|
572
|
-
*
|
|
573
|
-
* A cooldown exists because a topic a meeting keeps returning to is one thing happening, not
|
|
574
|
-
* six — but for a job that ANSWERS something, dropping the second trigger drops a question
|
|
575
|
-
* nobody ever answers. Reported exactly that way: thirteen questions asked in a burst, one
|
|
576
|
-
* answered, the rest skipped with "fired moments ago (cooldown)" and never seen again.
|
|
577
|
-
*
|
|
578
|
-
* So the cooldown stops meaning "discard" and starts meaning "wait, and bring the rest with
|
|
579
|
-
* you". This is the pure half: which of the queued triggers survive into the batch.
|
|
580
|
-
*
|
|
581
|
-
* Identity is the SPOKEN LINE, not the match. A live caption is re-emitted as it grows, so
|
|
582
|
-
* the same sentence arrives several times at different lengths; keying on a normalized
|
|
583
|
-
* prefix collapses those without the segment ids having to agree, and the LONGEST version
|
|
584
|
-
* wins because it is the finished one.
|
|
585
|
-
*
|
|
586
|
-
* Over the cap the OLDEST go: a question the meeting has already moved past is worth less
|
|
587
|
-
* than the one just asked, and an unbounded batch is a prompt nobody can afford.
|
|
588
|
-
*/
|
|
589
|
-
export function coalesceMatches(matches = [], { max = DEFAULT_COALESCE_MAX } = {}) {
|
|
590
|
-
const rows = [];
|
|
591
|
-
for (const m of matches) {
|
|
592
|
-
const text = String(m?.segment?.text || m?.why || '').trim();
|
|
593
|
-
if (!text) continue;
|
|
594
|
-
const who = norm(m?.segment?.speaker);
|
|
595
|
-
const t = m?.segment?.t || 0;
|
|
596
|
-
const flat = text.toLowerCase().replace(/\s+/g, ' ');
|
|
597
|
-
// Same utterance if it starts at the same moment, or — when the caption carries no
|
|
598
|
-
// timestamp — if one text is a PREFIX of the other, which is precisely how a caption
|
|
599
|
-
// grows. A fixed-length key cannot do this: "why is the sky" and "why is the sky blue"
|
|
600
|
-
// differ inside any prefix long enough to tell two real questions apart.
|
|
601
|
-
// When BOTH carry a start time that is identity on its own, and prefix matching must not
|
|
602
|
-
// get a say: "question number 1" is a prefix of "question number 10", so an OR here
|
|
603
|
-
// silently merges two different asks.
|
|
604
|
-
const i = rows.findIndex((r) => r.who === who && (
|
|
605
|
-
(t && r.t) ? r.t === t : (r.flat.startsWith(flat) || flat.startsWith(r.flat))
|
|
606
|
-
));
|
|
607
|
-
if (i >= 0) {
|
|
608
|
-
if (text.length > rows[i].text.length) rows[i] = { ...rows[i], match: m, text, flat };
|
|
609
|
-
continue;
|
|
610
|
-
}
|
|
611
|
-
rows.push({ who, t, match: m, text, flat });
|
|
612
|
-
}
|
|
613
|
-
return rows.slice(-Math.max(1, max)).map((r) => r.match);
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
/** The lines a batch is asking about, in the order they were said. For a prompt, and for a log. */
|
|
617
|
-
export function matchTexts(matches = []) {
|
|
618
|
-
return coalesceMatches(matches, { max: Number.MAX_SAFE_INTEGER })
|
|
619
|
-
.map((m) => String(m?.segment?.text || m?.why || '').trim())
|
|
620
|
-
.filter(Boolean);
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
/**
|
|
624
|
-
* One line saying what a batch of matches actually fired on.
|
|
625
|
-
*
|
|
626
|
-
* "3 questions" is a count, and a count is exactly the part the reader already knows — three
|
|
627
|
-
* answers are about to appear underneath it. What they cannot recover is WHICH three, so the
|
|
628
|
-
* questions themselves are the summary and the number is the prefix.
|
|
629
|
-
*
|
|
630
|
-
* `noun` is the plural word for what these are ('questions', 'matches'), since the trigger
|
|
631
|
-
* that produced them is not carried on a match.
|
|
632
|
-
*/
|
|
633
|
-
export function matchSummary(matches = [], { noun = 'matches', max = 3, chars = 180 } = {}) {
|
|
634
|
-
const list = (matches || []).filter(Boolean);
|
|
635
|
-
if (!list.length) return '';
|
|
636
|
-
// One match already has a human reason written by its own trigger — that reason is better
|
|
637
|
-
// than anything a generic formatter can say about it.
|
|
638
|
-
if (list.length === 1) return String(list[0].why || '') || clipText(list[0]?.segment?.text, chars);
|
|
639
|
-
const texts = matchTexts(list);
|
|
640
|
-
if (!texts.length) return `${list.length} ${noun}`;
|
|
641
|
-
const shown = texts.slice(0, max);
|
|
642
|
-
const per = Math.max(40, Math.floor(chars / shown.length));
|
|
643
|
-
const rest = texts.length - shown.length;
|
|
644
|
-
return `${texts.length} ${noun}: ${shown.map((t) => `“${clipText(t, per)}”`).join(' · ')}`
|
|
645
|
-
+ (rest > 0 ? ` +${rest} more` : '');
|
|
646
|
-
}
|
|
647
|
-
|
|
648
|
-
export function jobsForEvent(jobs, event, { registry, ctx = {}, admit = null } = {}) {
|
|
649
|
-
const out = [];
|
|
650
|
-
for (const job of jobs || []) {
|
|
651
|
-
if (!job.enabled) continue;
|
|
652
|
-
if (admit && !admit(job)) continue;
|
|
653
|
-
const trigger = registry?.get(job.trigger);
|
|
654
|
-
if (!trigger || trigger.kind === 'timer') continue;
|
|
655
|
-
if (trigger.watches.length && !trigger.watches.includes(event?.type)) continue;
|
|
656
|
-
let match = null;
|
|
657
|
-
try { match = trigger.matches(event, job.params, ctx); } catch { match = null; }
|
|
658
|
-
// A condition that threw did not match. Firing on an unanswered question is how
|
|
659
|
-
// automation does something nobody asked for.
|
|
660
|
-
if (match) out.push({ job, trigger, match, key: occurrenceKey(job.id, event.at || event.t || 0) });
|
|
661
|
-
}
|
|
662
|
-
return out;
|
|
663
|
-
}
|
|
1
|
+
class a extends Error{constructor(t,n){super(n),this.name="ScheduleError",this.code=t}}const T=Object.freeze(["once","interval","daily","weekly"]),j=Object.freeze(["timer","meeting","voice","data","channel"]),S=Object.freeze(["skill","prompt","monitor","notify"]),O=Object.freeze(["skip","runOnce","runAll"]),$=20;function g(e,{days:t=0,hour:n,minute:r=0}){const o=new Date(e);return o.setDate(o.getDate()+t),o.setHours(n,r,0,0),o.getTime()}const I=e=>{const t=new Date(e).getDay();return t===0||t===6};function E(e){if(!e||typeof e!="object")throw new a("BAD_SCHEDULE","schedule must be an object");if(!T.includes(e.kind))throw new a("BAD_SCHEDULE",`unknown schedule kind '${e.kind}'`);if(e.kind==="once"&&!(e.at>0))throw new a("BAD_SCHEDULE","once needs `at`");if(e.kind==="interval"&&!(e.everyMs>=6e4))throw new a("BAD_SCHEDULE","interval needs everyMs >= 60000");if(e.kind==="daily"||e.kind==="weekly"){if(!Number.isInteger(e.hour)||e.hour<0||e.hour>23)throw new a("BAD_SCHEDULE","hour must be 0-23");const t=e.minute??0;if(!Number.isInteger(t)||t<0||t>59)throw new a("BAD_SCHEDULE","minute must be 0-59")}if(e.kind==="weekly"&&(!Number.isInteger(e.weekday)||e.weekday<0||e.weekday>6))throw new a("BAD_SCHEDULE","weekly needs weekday 0-6 (Sunday = 0)");return e}function A(e,t){const n=E(e),r=n.minute??0;switch(n.kind){case"once":return n.at>t?n.at:null;case"interval":{const o=n.anchor??t;if(o>t)return o;const i=Math.floor((t-o)/n.everyMs)+1;return o+i*n.everyMs}case"daily":{let o=g(t,{hour:n.hour,minute:r});if(o<=t&&(o=g(t,{days:1,hour:n.hour,minute:r})),n.weekdaysOnly)for(let i=0;i<7&&I(o);i++)o=g(o,{days:1,hour:n.hour,minute:r});return o}case"weekly":{const o=new Date(t).getDay();let i=(n.weekday-o+7)%7;return i===0&&g(t,{hour:n.hour,minute:r})<=t&&(i=7),g(t,{days:i,hour:n.hour,minute:r})}default:return null}}const B=Object.freeze(["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"]),w=e=>`${String(e?.hour??0).padStart(2,"0")}:${String(e?.minute??0).padStart(2,"0")}`;function Y(e){if(!e||typeof e!="object")return"";switch(e.kind){case"once":return e.at>0?`once, at ${new Date(e.at).toLocaleString()}`:"";case"interval":{const t=Number(e.everyMs)||0;if(t<6e4)return"";const n=Math.round(t/6e4);if(n%1440===0){const r=n/1440;return`every ${r===1?"day":`${r} days`}`}if(n%60===0){const r=n/60;return`every ${r===1?"hour":`${r} hours`}`}return`every ${n} minutes`}case"daily":return e.weekdaysOnly?`every weekday at ${w(e)}`:`every day at ${w(e)}`;case"weekly":{const t=B[e.weekday];return t?`every ${t} at ${w(e)}`:""}default:return""}}function L(e,t,n,r=$){const o=[];let i=t;for(;o.length<r;){const s=A(e,i);if(s===null||s>n)break;o.push(s),i=s}return o}function d({id:e,label:t,kind:n,watches:r=[],description:o="",classUsed:i="R",matches:s=null,params:u={}}){if(!e)throw new a("BAD_TRIGGER","trigger.id required");if(!j.includes(n))throw new a("BAD_TRIGGER",`trigger '${e}': unknown kind '${n}'`);if(n!=="timer"&&typeof s!="function")throw new a("BAD_TRIGGER",`trigger '${e}': an event trigger needs matches()`);return Object.freeze({id:e,label:t||e,kind:n,watches:Object.freeze([...r]),description:o,classUsed:i,matches:s,params:Object.freeze({...u})})}function V(e=[]){const t=[...e];return{add(n){return t.push(n),()=>{const r=t.indexOf(n);r>=0&&t.splice(r,1)}},list:()=>[...t],get:n=>t.find(r=>r.id===n)||null}}const C=e=>String(e||"").toLowerCase().match(/[\p{L}\p{N}']+/gu)||[],h=e=>String(e||"").trim().toLowerCase(),D=3,M=e=>e.replace(/[.*+?^${}()|[\]\\]/g,"\\$&");function p(e,t=120){const n=String(e||"").replace(/\s+/g," ").trim();if(n.length<=t)return n;const r=n.slice(0,t),o=r.lastIndexOf(" ");return`${(o>t*.6?r.slice(0,o):r).replace(/[\s,;:.!?—-]+$/,"")}…`}function R(e,t){const n=h(t);if(n.length<D)return!1;const r=/[\p{L}\p{N}]/u.test(n[0])?"\\b":"",o=/[\p{L}\p{N}]/u.test(n[n.length-1])?"\\b":"";try{return new RegExp(`${r}${M(n)}${o}`,"iu").test(String(e||""))}catch{return h(e).includes(n)}}function y(e,t,n){return e==="me"?!!n?.isSelf?.(t):e==="others"?!n?.isSelf?.(t):!0}const N=Object.freeze(new Set(["and","or","but","so","because","cause","cos","since","although","though","while","whereas","unless","until","if","when","whenever","that","which","who","whom","whose","than","as","like","what","to","of","in","on","at","by","for","from","with","without","about","into","onto","over","under","between","through","during","against","per","a","an","the","my","our","your","their","its","is","are","was","were","be","been","being","am","do","does","did","have","has","had","will","would","can","could","should","shall","may","might","must","um","uh","er"]));function Z(e){const t=String(e||"").trim();if(!t)return!1;if(/[.!?…]["'\u2019\u201d)\]]*$/.test(t))return!0;if(/[,;:\-\u2013\u2014]$/.test(t))return!1;const n=t.toLowerCase().match(/[\p{L}\p{N}']+/gu)||[],r=n[n.length-1];return r?!N.has(r):!0}const v="text.delta",_=Object.freeze(["meeting","note","chat"]),k=Object.freeze(["meeting.transcript.delta",v]);function G(e){if(e?.type==="meeting.transcript.delta")return"meeting";const t=h(e?.source);return _.includes(t)?t:""}function b(e,t){const n=G(t);if(!n)return!1;const r=(Array.isArray(e)?e:[]).map(h).filter(o=>_.includes(o));return r.length?r.includes(n):n==="meeting"}const m=d({id:"timer:schedule",label:"On a schedule",kind:"timer",description:'Once, on an interval, daily, or on a weekday — "every weekday at 8am".'}),W=d({id:"meeting:started",label:"When a meeting starts",kind:"meeting",watches:["meeting.started"],matches:(e,t={})=>t.platform&&h(e.platform)!==h(t.platform)||t.titleIncludes&&!h(e.title).includes(h(t.titleIncludes))?null:{why:`meeting started: ${e.title||e.meetingId}`}}),H=d({id:"meeting:ended",label:"When a meeting ends",kind:"meeting",watches:["meeting.ended"],matches:e=>({why:`meeting ended: ${e.title||e.meetingId}`})}),U=d({id:"meeting:person-joined",label:"When someone joins",kind:"meeting",watches:["meeting.person-joined"],matches:(e,t={})=>{const n=(t.names||[]).map(h).filter(Boolean),r=(e.people||[]).filter(o=>!n.length||n.some(i=>h(o)===i||h(o).startsWith(`${i} `)));return r.length?{why:`joined: ${r.join(", ")}`,people:r}:null}}),z=d({id:"meeting:phrase",label:"When a phrase is said",kind:"meeting",watches:k,matches:(e,t={},n={})=>{if(!b(t.sources,e))return null;const r=(t.any||[]).map(h).filter(o=>o.length>=D);if(!r.length)return null;for(const o of e.segments||[]){if(!y(t.speaker,o.speaker,n))continue;const i=r.find(s=>R(o.text,s));if(i)return{why:`“${i}” said by ${o.speaker||"someone"}`,segment:o,phrase:i}}return null}}),J=d({id:"meeting:topic",label:"When someone talks about something",kind:"meeting",watches:k,matches:(e,t={},n={})=>{if(!b(t.sources,e))return null;const r=(t.terms||[]).map(h).filter(Boolean);if(!r.length)return null;const o=Math.max(1,Math.min(t.minHits||1,r.length)),i=(e.segments||[]).filter(c=>y(t.speaker,c.speaker,n)),s=new Set(i.flatMap(c=>C(c.text))),u=r.filter(c=>c.split(/\s+/).every(l=>s.has(l)));return u.length>=o?{why:`talking about ${u.join(", ")}`,terms:u}:null}}),q=/^(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,F=d({id:"meeting:question",label:"When a question is asked",kind:"meeting",watches:k,matches:(e,t={},n={})=>{if(!b(t.sources,e))return null;for(const r of e.segments||[]){if(!y(t.speaker||"anyone",r.speaker,n))continue;const o=String(r.text||"").trim();if(!(o.length<8)&&(o.includes("?")||q.test(o)))return{why:`question from ${r.speaker||"someone"}: “${p(o,100)}”`,segment:r}}return null}}),X=d({id:"voice:command",label:"When you say the wake word",kind:"voice",watches:["voice.command"],matches:(e,t={})=>{const n=t.intents||[];return!e.command?.allowed||n.length&&!n.includes(e.command.intent)?null:{why:`you said “${e.command.command}”`,command:e.command}}}),ee=Object.freeze([m,W,H,U,z,J,F,X]);function te({id:e,name:t,trigger:n,schedule:r=null,params:o={},action:i,enabled:s=!0,onMissed:u="runOnce",limits:c={},approval:l=null,createdAt:f=0}){if(!e)throw new a("BAD_JOB","job.id required");if(!t)throw new a("BAD_JOB",`job '${e}': name required`);if(!n)throw new a("BAD_JOB",`job '${e}': trigger required`);if(!i||!S.includes(i.kind))throw new a("BAD_JOB",`job '${e}': action.kind must be one of ${S}`);if(i.kind==="skill"&&!i.skillId)throw new a("BAD_JOB",`job '${e}': skill action needs skillId`);if(!O.includes(u))throw new a("BAD_JOB",`job '${e}': unknown onMissed '${u}'`);return(n===m.id||r)&&E(r),{id:e,name:t,trigger:n,schedule:r,params:{...o},action:{...i},enabled:!!s,onMissed:u,limits:{...c},approval:l,createdAt:f||0}}function x(e,t){return`${e}@${t}`}function ne(e,{now:t,lastRun:n={},admit:r=null,max:o=$}={}){const i=[];for(const s of e||[]){if(!s.enabled||s.trigger!==m.id||!s.schedule||r&&!r(s))continue;const u=n[s.id]||s.createdAt||0;if(!u)continue;const c=L(s.schedule,u,t,o);if(!c.length)continue;const l=s.onMissed==="runAll"?c:s.onMissed==="runOnce"?[c[c.length-1]]:[];for(const f of l)i.push({job:s,at:f,key:x(s.id,f),late:t-f>6e4,missedCount:c.length});l.length||i.push({job:s,at:c[c.length-1],key:x(s.id,c[c.length-1]),skipped:!0,missedCount:c.length})}return i}function re(e,{now:t,lastRun:n={}}={}){let r=null;for(const o of e||[]){if(!o.enabled||o.trigger!==m.id||!o.schedule)continue;const i=A(o.schedule,Math.max(t,n[o.id]||0));i!==null&&(r===null||i<r)&&(r=i)}return r}const K=8;function P(e=[],{max:t=K}={}){const n=[];for(const r of e){const o=String(r?.segment?.text||r?.why||"").trim();if(!o)continue;const i=h(r?.segment?.speaker),s=r?.segment?.t||0,u=o.toLowerCase().replace(/\s+/g," "),c=n.findIndex(l=>l.who===i&&(s&&l.t?l.t===s:l.flat.startsWith(u)||u.startsWith(l.flat)));if(c>=0){o.length>n[c].text.length&&(n[c]={...n[c],match:r,text:o,flat:u});continue}n.push({who:i,t:s,match:r,text:o,flat:u})}return n.slice(-Math.max(1,t)).map(r=>r.match)}function Q(e=[]){return P(e,{max:Number.MAX_SAFE_INTEGER}).map(t=>String(t?.segment?.text||t?.why||"").trim()).filter(Boolean)}function oe(e=[],{noun:t="matches",max:n=3,chars:r=180}={}){const o=(e||[]).filter(Boolean);if(!o.length)return"";if(o.length===1)return String(o[0].why||"")||p(o[0]?.segment?.text,r);const i=Q(o);if(!i.length)return`${o.length} ${t}`;const s=i.slice(0,n),u=Math.max(40,Math.floor(r/s.length)),c=i.length-s.length;return`${i.length} ${t}: ${s.map(l=>`“${p(l,u)}”`).join(" · ")}`+(c>0?` +${c} more`:"")}function ie(e,t,{registry:n,ctx:r={},admit:o=null}={}){const i=[];for(const s of e||[]){if(!s.enabled||o&&!o(s))continue;const u=n?.get(s.trigger);if(!u||u.kind==="timer"||u.watches.length&&!u.watches.includes(t?.type))continue;let c=null;try{c=u.matches(t,s.params,r)}catch{c=null}c&&i.push({job:s,trigger:u,match:c,key:x(s.id,t.at||t.t||0)})}return i}export{ee as BUILTIN_TRIGGERS,N as DANGLING_TAILS,K as DEFAULT_COALESCE_MAX,S as JOB_ACTIONS,D as MIN_PHRASE_CHARS,O as MISSED_POLICIES,T as SCHEDULE_KINDS,a as ScheduleError,v as TEXT_DELTA,k as TEXT_WATCHES,j as TRIGGER_KINDS,_ as TRIGGER_SOURCES,B as WEEKDAY_NAMES,p as clipText,P as coalesceMatches,V as createTriggerRegistry,te as defineJob,d as defineTrigger,Y as describeSchedule,ne as dueJobs,G as eventSource,ie as jobsForEvent,oe as matchSummary,Q as matchTexts,H as meetingEndedTrigger,W as meetingStartedTrigger,A as nextFireAt,re as nextWakeAt,x as occurrenceKey,L as occurrencesBetween,U as personJoinedTrigger,z as phraseTrigger,F as questionTrigger,R as saidIn,b as sourceAllowed,m as timerTrigger,J as topicTrigger,Z as utteranceLooksComplete,E as validateSchedule,X as voiceCommandTrigger};
|