@chatpanel/events 0.19.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/event.js +6 -1
  2. package/package.json +1 -1
  3. package/schedule.js +104 -4
package/event.js CHANGED
@@ -37,7 +37,12 @@ export const ALL_TYPES = Object.freeze(
37
37
  Object.entries(EVENT_TYPES).flatMap(([fam, kinds]) => kinds.map((k) => `${fam}.${k}`)),
38
38
  );
39
39
 
40
- export const ACTOR_KINDS = Object.freeze(['user', 'rule', 'schedule', 'model', 'agent']);
40
+ // 'channel' is a message arriving from a paired external surface — Telegram, WhatsApp — that
41
+ // drives a turn the same way a person pressing send does. It is turn-independent for the same
42
+ // reason 'schedule' and 'agent' are: nobody is sitting in the panel when it fires, so consent
43
+ // and reach have to be settled at pairing time, not at the keystroke. The actor.id carries the
44
+ // surface and the sender, e.g. 'telegram:8412…'. See chatpanel-channels for the invoker.
45
+ export const ACTOR_KINDS = Object.freeze(['user', 'rule', 'schedule', 'model', 'agent', 'channel']);
41
46
  export const SCOPE_KINDS = Object.freeze(['global', 'site', 'tab', 'session', 'agent']);
42
47
  export const CLASSES = Object.freeze(['R', 'M', 'L', 'C', 'A', 'X', 'H']);
43
48
  export const EFFECTS = Object.freeze(['pure', 'idempotent', 'replay-safe', 'non-replayable']);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chatpanel/events",
3
- "version": "0.19.0",
3
+ "version": "0.21.0",
4
4
  "description": "The canonical ChatPanel event-log and capability contracts — typed durable facts, clock-free deterministic linearization, schema upcasting, and the invariants the replay harness asserts. Pure, dependency-free ESM shared by the ChatPanel extension, gateway and bridge.",
5
5
  "type": "module",
6
6
  "main": "index.js",
package/schedule.js CHANGED
@@ -28,7 +28,12 @@ export class ScheduleError extends Error {
28
28
  }
29
29
 
30
30
  export const SCHEDULE_KINDS = Object.freeze(['once', 'interval', 'daily', 'weekly']);
31
- export const TRIGGER_KINDS = Object.freeze(['timer', 'meeting', 'voice', 'data']);
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']);
32
37
  /** What a job does when it fires. `skill` is the headline: the instruction IS a skill. */
33
38
  export const JOB_ACTIONS = Object.freeze(['skill', 'prompt', 'monitor', 'notify']);
34
39
  /** What to do about occurrences that passed while nothing was running. */
@@ -241,6 +246,42 @@ export function utteranceLooksComplete(text) {
241
246
  return !DANGLING_TAILS.has(last);
242
247
  }
243
248
 
249
+ // ---------------------------------------------------------------------------
250
+ // WHERE TEXT COMES FROM.
251
+ //
252
+ // A phrase worth acting on is a phrase worth acting on wherever it is written. The triggers
253
+ // below were built for live captions, but nothing in them is about a meeting: they take
254
+ // {speaker, text} arriving over time, which is equally a note being typed and a chat being
255
+ // sent. So the SOURCE became a parameter instead of three copies of the matcher.
256
+ //
257
+ // `text.delta` is the source-agnostic event. `meeting.transcript.delta` stays a first-class
258
+ // type — it is what the meeting pipeline already emits and what stored jobs were matched
259
+ // against — and is read as source 'meeting'.
260
+ //
261
+ // DEFAULT IS MEETING-ONLY, and that is a compatibility guarantee, not a preference: every
262
+ // job stored before this existed was created against a form that said "when a phrase is
263
+ // said" in a call. Widening those silently would start running models over notes their
264
+ // author never pointed them at.
265
+ // ---------------------------------------------------------------------------
266
+ export const TEXT_DELTA = 'text.delta';
267
+ export const TRIGGER_SOURCES = Object.freeze(['meeting', 'note', 'chat']);
268
+ export const TEXT_WATCHES = Object.freeze(['meeting.transcript.delta', TEXT_DELTA]);
269
+
270
+ /** Which surface an event came from. A meeting delta says so by its type alone. */
271
+ export function eventSource(event) {
272
+ if (event?.type === 'meeting.transcript.delta') return 'meeting';
273
+ const s = norm(event?.source);
274
+ return TRIGGER_SOURCES.includes(s) ? s : '';
275
+ }
276
+
277
+ /** May this job act on this surface? Absent `sources` means meetings only — see above. */
278
+ export function sourceAllowed(sources, event) {
279
+ const from = eventSource(event);
280
+ if (!from) return false;
281
+ const want = (Array.isArray(sources) ? sources : []).map(norm).filter((x) => TRIGGER_SOURCES.includes(x));
282
+ return want.length ? want.includes(from) : from === 'meeting';
283
+ }
284
+
244
285
  export const timerTrigger = defineTrigger({
245
286
  id: 'timer:schedule',
246
287
  label: 'On a schedule',
@@ -286,11 +327,12 @@ export const phraseTrigger = defineTrigger({
286
327
  id: 'meeting:phrase',
287
328
  label: 'When a phrase is said',
288
329
  kind: 'meeting',
289
- watches: ['meeting.transcript.delta'],
330
+ watches: TEXT_WATCHES,
290
331
  matches: (event, params = {}, ctx = {}) => {
291
332
  // Both guards exist because their absence looks the same to a user: a trigger that fires
292
333
  // on everything. An empty list would match every line; a one- or two-letter phrase
293
334
  // effectively does too.
335
+ if (!sourceAllowed(params.sources, event)) return null;
294
336
  const any = (params.any || []).map(norm).filter((p) => p.length >= MIN_PHRASE_CHARS);
295
337
  if (!any.length) return null;
296
338
  for (const seg of event.segments || []) {
@@ -306,11 +348,12 @@ export const topicTrigger = defineTrigger({
306
348
  id: 'meeting:topic',
307
349
  label: 'When someone talks about something',
308
350
  kind: 'meeting',
309
- watches: ['meeting.transcript.delta'],
351
+ watches: TEXT_WATCHES,
310
352
  // Looser than a phrase on purpose: "says something about pricing" should not require the
311
353
  // word "pricing" in the exact shape the job author typed. Term overlap over the window is
312
354
  // deterministic, explainable, and free — a model would be all three of the opposite.
313
355
  matches: (event, params = {}, ctx = {}) => {
356
+ if (!sourceAllowed(params.sources, event)) return null;
314
357
  const terms = (params.terms || []).map(norm).filter(Boolean);
315
358
  if (!terms.length) return null;
316
359
  const need = Math.max(1, Math.min(params.minHits || 1, terms.length));
@@ -329,8 +372,9 @@ export const questionTrigger = defineTrigger({
329
372
  id: 'meeting:question',
330
373
  label: 'When a question is asked',
331
374
  kind: 'meeting',
332
- watches: ['meeting.transcript.delta'],
375
+ watches: TEXT_WATCHES,
333
376
  matches: (event, params = {}, ctx = {}) => {
377
+ if (!sourceAllowed(params.sources, event)) return null;
334
378
  for (const seg of event.segments || []) {
335
379
  // Other people by default: this exists for reacting to what someone ELSE asks (an
336
380
  // interview, a customer call). The job form offers the choice, so 'anyone' and 'me' are
@@ -445,6 +489,62 @@ export function nextWakeAt(jobs, { now, lastRun = {} } = {}) {
445
489
  * Which jobs an event fires. Returns matches; running them is the host's business, because
446
490
  * only the host knows what a skill costs and whether the user approved it.
447
491
  */
492
+ /** More than this in one batch and the answer stops being an answer. */
493
+ export const DEFAULT_COALESCE_MAX = 8;
494
+
495
+ /**
496
+ * Fold several triggers of the SAME job into ONE run.
497
+ *
498
+ * A cooldown exists because a topic a meeting keeps returning to is one thing happening, not
499
+ * six — but for a job that ANSWERS something, dropping the second trigger drops a question
500
+ * nobody ever answers. Reported exactly that way: thirteen questions asked in a burst, one
501
+ * answered, the rest skipped with "fired moments ago (cooldown)" and never seen again.
502
+ *
503
+ * So the cooldown stops meaning "discard" and starts meaning "wait, and bring the rest with
504
+ * you". This is the pure half: which of the queued triggers survive into the batch.
505
+ *
506
+ * Identity is the SPOKEN LINE, not the match. A live caption is re-emitted as it grows, so
507
+ * the same sentence arrives several times at different lengths; keying on a normalized
508
+ * prefix collapses those without the segment ids having to agree, and the LONGEST version
509
+ * wins because it is the finished one.
510
+ *
511
+ * Over the cap the OLDEST go: a question the meeting has already moved past is worth less
512
+ * than the one just asked, and an unbounded batch is a prompt nobody can afford.
513
+ */
514
+ export function coalesceMatches(matches = [], { max = DEFAULT_COALESCE_MAX } = {}) {
515
+ const rows = [];
516
+ for (const m of matches) {
517
+ const text = String(m?.segment?.text || m?.why || '').trim();
518
+ if (!text) continue;
519
+ const who = norm(m?.segment?.speaker);
520
+ const t = m?.segment?.t || 0;
521
+ const flat = text.toLowerCase().replace(/\s+/g, ' ');
522
+ // Same utterance if it starts at the same moment, or — when the caption carries no
523
+ // timestamp — if one text is a PREFIX of the other, which is precisely how a caption
524
+ // grows. A fixed-length key cannot do this: "why is the sky" and "why is the sky blue"
525
+ // differ inside any prefix long enough to tell two real questions apart.
526
+ // When BOTH carry a start time that is identity on its own, and prefix matching must not
527
+ // get a say: "question number 1" is a prefix of "question number 10", so an OR here
528
+ // silently merges two different asks.
529
+ const i = rows.findIndex((r) => r.who === who && (
530
+ (t && r.t) ? r.t === t : (r.flat.startsWith(flat) || flat.startsWith(r.flat))
531
+ ));
532
+ if (i >= 0) {
533
+ if (text.length > rows[i].text.length) rows[i] = { ...rows[i], match: m, text, flat };
534
+ continue;
535
+ }
536
+ rows.push({ who, t, match: m, text, flat });
537
+ }
538
+ return rows.slice(-Math.max(1, max)).map((r) => r.match);
539
+ }
540
+
541
+ /** The lines a batch is asking about, in the order they were said. For a prompt, and for a log. */
542
+ export function matchTexts(matches = []) {
543
+ return coalesceMatches(matches, { max: Number.MAX_SAFE_INTEGER })
544
+ .map((m) => String(m?.segment?.text || m?.why || '').trim())
545
+ .filter(Boolean);
546
+ }
547
+
448
548
  export function jobsForEvent(jobs, event, { registry, ctx = {}, admit = null } = {}) {
449
549
  const out = [];
450
550
  for (const job of jobs || []) {