@chatpanel/events 0.101.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.
Files changed (138) hide show
  1. package/adapters.js +1 -83
  2. package/adaptive-tool-policy.js +1 -45
  3. package/agent.js +2 -255
  4. package/attribution.js +1 -132
  5. package/backup-envelope.js +1 -221
  6. package/board-tool.js +2 -165
  7. package/budget.js +1 -116
  8. package/capability.js +1 -126
  9. package/citations.js +5 -127
  10. package/client-prefs.js +1 -148
  11. package/context-attachments.js +8 -144
  12. package/cowriter-router.js +1 -83
  13. package/cowriter-writer.js +19 -193
  14. package/cowriter.js +1 -190
  15. package/curate.js +3 -509
  16. package/distance.js +1 -124
  17. package/engine.js +1 -131
  18. package/entitlement.js +1 -332
  19. package/entity.js +1 -354
  20. package/event.js +1 -175
  21. package/extraction.js +4 -293
  22. package/failover.js +1 -93
  23. package/find-tool.js +1 -66
  24. package/flowchart.js +2 -644
  25. package/gate.js +1 -74
  26. package/harness.js +4 -101
  27. package/index.js +1 -345
  28. package/invariants.js +1 -174
  29. package/job.js +3 -147
  30. package/kernel.js +1 -255
  31. package/knowledge-derive.js +1 -267
  32. package/knowledge.js +3 -230
  33. package/library.js +11 -280
  34. package/loop.js +1 -132
  35. package/manifest.js +1 -107
  36. package/markdown-authoring.js +11 -288
  37. package/markdown-render.js +0 -0
  38. package/mcp-client.js +8 -358
  39. package/mcp-dispatch.js +1 -52
  40. package/mcp-errors.js +1 -106
  41. package/mcp-manager.js +1 -127
  42. package/media-transcript.js +8 -531
  43. package/meeting-analyzers.js +1 -83
  44. package/meeting-insights.js +7 -164
  45. package/meeting-shape.js +1 -282
  46. package/meeting-text.js +5 -94
  47. package/memory.js +2 -638
  48. package/model-candidates.js +1 -358
  49. package/model-health.js +1 -182
  50. package/model-ledger.js +1 -228
  51. package/model-picker.js +1 -188
  52. package/note-actions.js +15 -246
  53. package/note-graph.js +1 -158
  54. package/note-links.js +1 -68
  55. package/note-mentions.js +8 -114
  56. package/note-plan.js +9 -107
  57. package/note-research.js +1 -135
  58. package/observability.js +1 -119
  59. package/omni.js +1 -125
  60. package/order.js +1 -78
  61. package/package.json +1 -4
  62. package/pdf-layout.js +7 -253
  63. package/project-run.js +20 -444
  64. package/project.js +1 -170
  65. package/promotion.js +1 -171
  66. package/queue.js +1 -98
  67. package/reach.js +1 -23
  68. package/recipe-tool.js +2 -161
  69. package/recipe.js +1 -264
  70. package/record-list.js +2 -110
  71. package/recruit.js +3 -456
  72. package/redaction-tokens.js +1 -61
  73. package/ref.js +1 -55
  74. package/registry.js +1 -240
  75. package/route-graph.js +1 -115
  76. package/route-strategies.js +2 -232
  77. package/router.js +2 -880
  78. package/rrf.js +1 -89
  79. package/rules.js +1 -142
  80. package/schedule.js +1 -663
  81. package/scm-connection.js +1 -180
  82. package/scopes.js +1 -12
  83. package/scorecard.js +1 -335
  84. package/search-engines.js +1 -81
  85. package/skill-manifest.js +1 -330
  86. package/skill-scan.js +3 -209
  87. package/skill-sources.js +1 -162
  88. package/skill-vars.js +2 -252
  89. package/slash-commands.js +1 -158
  90. package/source-gate.js +1 -92
  91. package/sources-retrieval.js +5 -186
  92. package/sources.js +1 -273
  93. package/store.js +1 -171
  94. package/structured.js +4 -901
  95. package/subject-kinds.js +1 -5
  96. package/subject-name.js +1 -96
  97. package/sync-plan.js +1 -213
  98. package/synthesis.js +3 -123
  99. package/tags.js +1 -203
  100. package/team-board.js +6 -337
  101. package/team-cache.js +2 -63
  102. package/team-observe.js +1 -118
  103. package/team-org.js +1 -348
  104. package/team-plan.js +4 -131
  105. package/team-record.js +1 -225
  106. package/team-run.js +9 -652
  107. package/team-subtask.js +2 -191
  108. package/team-task.js +2 -124
  109. package/team-tool.js +2 -196
  110. package/team-trail.js +2 -73
  111. package/team-worklog.js +2 -149
  112. package/team.js +1 -302
  113. package/text-search.js +1 -155
  114. package/theme.js +17 -154
  115. package/titles.js +4 -285
  116. package/tool-discovery.js +1 -87
  117. package/tool-dispatch.js +5 -230
  118. package/tool-groups.js +1 -81
  119. package/tool-hints.js +10 -185
  120. package/tool-loop-guard.js +4 -182
  121. package/tool-need.js +2 -124
  122. package/tool-result.js +2 -438
  123. package/tool-round.js +0 -0
  124. package/tool-schema.js +1 -155
  125. package/tool-traits.js +1 -182
  126. package/toolset.js +2 -88
  127. package/trajectory.js +1 -530
  128. package/turn-loop.js +4 -440
  129. package/upcast.js +1 -37
  130. package/vault.js +2 -250
  131. package/view.js +1 -88
  132. package/voice-intents.js +2 -1488
  133. package/voice-speaker.js +1 -98
  134. package/weather-tool.js +1 -65
  135. package/weather.js +2 -211
  136. package/web-search-tool.js +14 -102
  137. package/web-search.js +1 -160
  138. 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};