@chatpanel/events 0.18.1 → 0.20.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 (2) hide show
  1. package/package.json +2 -2
  2. package/schedule.js +152 -3
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@chatpanel/events",
3
- "version": "0.18.1",
4
- "description": "The canonical ChatPanel event-log and capability contracts \u2014 typed durable facts, clock-free deterministic linearization, schema upcasting, and the invariants the replay harness asserts. Pure, dependency-free ESM shared by the ChatPanel extension, gateway and bridge.",
3
+ "version": "0.20.0",
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",
7
7
  "exports": {
package/schedule.js CHANGED
@@ -187,6 +187,96 @@ function speakerAllowed(want, speaker, ctx) {
187
187
  return true;
188
188
  }
189
189
 
190
+ /**
191
+ * Words a sentence does not end on.
192
+ *
193
+ * Not a grammar: a short list of the tails that mean "the speaker is mid-thought". A caption
194
+ * ending on one of these is a line that has not finished arriving.
195
+ */
196
+ export const DANGLING_TAILS = Object.freeze(new Set([
197
+ // conjunctions and subordinators — the reason, the contrast, the condition all come AFTER
198
+ 'and', 'or', 'but', 'so', 'because', 'cause', 'cos', 'since', 'although', 'though', 'while',
199
+ 'whereas', 'unless', 'until', 'if', 'when', 'whenever', 'that', 'which', 'who', 'whom',
200
+ 'whose', 'than', 'as', 'like',
201
+ // prepositions and particles
202
+ 'to', 'of', 'in', 'on', 'at', 'by', 'for', 'from', 'with', 'without', 'about', 'into',
203
+ 'onto', 'over', 'under', 'between', 'through', 'during', 'against', 'per',
204
+ // determiners that must be followed by something. Only the ones that genuinely cannot end
205
+ // a sentence — 'this', 'some' and 'both' all can ("take both"), so they are not here.
206
+ 'a', 'an', 'the', 'my', 'our', 'your', 'their', 'its',
207
+ // auxiliaries and copulas left hanging
208
+ 'is', 'are', 'was', 'were', 'be', 'been', 'being', 'am', 'do', 'does', 'did', 'have', 'has',
209
+ 'had', 'will', 'would', 'can', 'could', 'should', 'shall', 'may', 'might', 'must',
210
+ // fillers a speaker trails off on. Pronouns are NOT here: "we should ship it" is a whole
211
+ // thought, and treating every object pronoun as a trailing word made the common case wait.
212
+ 'um', 'uh', 'er',
213
+ ]));
214
+
215
+ /**
216
+ * Does this line read as a FINISHED thought?
217
+ *
218
+ * A live caption is delivered while it is still being spoken and GROWS across flushes, so a
219
+ * trigger that matched can be holding half a sentence: "the product is just amazing because"
220
+ * — with the reason, the only part worth acting on, still unsaid. The caller uses this to
221
+ * decide whether to read the transcript now or wait for the rest of it.
222
+ *
223
+ * DEFAULT TRUE, deliberately. Speech-to-text frequently emits no punctuation at all, so
224
+ * requiring a full stop would make every job wait every time — and the complaint this exists
225
+ * to fix is about missing context, not about speed. Only a line that ends the way an
226
+ * unfinished one ends is treated as unfinished.
227
+ *
228
+ * Pure and synchronous, like every other predicate in this file: the waiting is the caller's
229
+ * problem, and it is the only part that needs a platform.
230
+ */
231
+ export function utteranceLooksComplete(text) {
232
+ const t = String(text || '').trim();
233
+ if (!t) return false;
234
+ // Sentence-final punctuation, optionally inside a closing quote or bracket.
235
+ if (/[.!?…]["'\u2019\u201d)\]]*$/.test(t)) return true;
236
+ // A comma, dash or colon at the end is a speaker who is explicitly not done.
237
+ if (/[,;:\-\u2013\u2014]$/.test(t)) return false;
238
+ const tokens = t.toLowerCase().match(/[\p{L}\p{N}']+/gu) || [];
239
+ const last = tokens[tokens.length - 1];
240
+ if (!last) return true;
241
+ return !DANGLING_TAILS.has(last);
242
+ }
243
+
244
+ // ---------------------------------------------------------------------------
245
+ // WHERE TEXT COMES FROM.
246
+ //
247
+ // A phrase worth acting on is a phrase worth acting on wherever it is written. The triggers
248
+ // below were built for live captions, but nothing in them is about a meeting: they take
249
+ // {speaker, text} arriving over time, which is equally a note being typed and a chat being
250
+ // sent. So the SOURCE became a parameter instead of three copies of the matcher.
251
+ //
252
+ // `text.delta` is the source-agnostic event. `meeting.transcript.delta` stays a first-class
253
+ // type — it is what the meeting pipeline already emits and what stored jobs were matched
254
+ // against — and is read as source 'meeting'.
255
+ //
256
+ // DEFAULT IS MEETING-ONLY, and that is a compatibility guarantee, not a preference: every
257
+ // job stored before this existed was created against a form that said "when a phrase is
258
+ // said" in a call. Widening those silently would start running models over notes their
259
+ // author never pointed them at.
260
+ // ---------------------------------------------------------------------------
261
+ export const TEXT_DELTA = 'text.delta';
262
+ export const TRIGGER_SOURCES = Object.freeze(['meeting', 'note', 'chat']);
263
+ export const TEXT_WATCHES = Object.freeze(['meeting.transcript.delta', TEXT_DELTA]);
264
+
265
+ /** Which surface an event came from. A meeting delta says so by its type alone. */
266
+ export function eventSource(event) {
267
+ if (event?.type === 'meeting.transcript.delta') return 'meeting';
268
+ const s = norm(event?.source);
269
+ return TRIGGER_SOURCES.includes(s) ? s : '';
270
+ }
271
+
272
+ /** May this job act on this surface? Absent `sources` means meetings only — see above. */
273
+ export function sourceAllowed(sources, event) {
274
+ const from = eventSource(event);
275
+ if (!from) return false;
276
+ const want = (Array.isArray(sources) ? sources : []).map(norm).filter((x) => TRIGGER_SOURCES.includes(x));
277
+ return want.length ? want.includes(from) : from === 'meeting';
278
+ }
279
+
190
280
  export const timerTrigger = defineTrigger({
191
281
  id: 'timer:schedule',
192
282
  label: 'On a schedule',
@@ -232,11 +322,12 @@ export const phraseTrigger = defineTrigger({
232
322
  id: 'meeting:phrase',
233
323
  label: 'When a phrase is said',
234
324
  kind: 'meeting',
235
- watches: ['meeting.transcript.delta'],
325
+ watches: TEXT_WATCHES,
236
326
  matches: (event, params = {}, ctx = {}) => {
237
327
  // Both guards exist because their absence looks the same to a user: a trigger that fires
238
328
  // on everything. An empty list would match every line; a one- or two-letter phrase
239
329
  // effectively does too.
330
+ if (!sourceAllowed(params.sources, event)) return null;
240
331
  const any = (params.any || []).map(norm).filter((p) => p.length >= MIN_PHRASE_CHARS);
241
332
  if (!any.length) return null;
242
333
  for (const seg of event.segments || []) {
@@ -252,11 +343,12 @@ export const topicTrigger = defineTrigger({
252
343
  id: 'meeting:topic',
253
344
  label: 'When someone talks about something',
254
345
  kind: 'meeting',
255
- watches: ['meeting.transcript.delta'],
346
+ watches: TEXT_WATCHES,
256
347
  // Looser than a phrase on purpose: "says something about pricing" should not require the
257
348
  // word "pricing" in the exact shape the job author typed. Term overlap over the window is
258
349
  // deterministic, explainable, and free — a model would be all three of the opposite.
259
350
  matches: (event, params = {}, ctx = {}) => {
351
+ if (!sourceAllowed(params.sources, event)) return null;
260
352
  const terms = (params.terms || []).map(norm).filter(Boolean);
261
353
  if (!terms.length) return null;
262
354
  const need = Math.max(1, Math.min(params.minHits || 1, terms.length));
@@ -275,8 +367,9 @@ export const questionTrigger = defineTrigger({
275
367
  id: 'meeting:question',
276
368
  label: 'When a question is asked',
277
369
  kind: 'meeting',
278
- watches: ['meeting.transcript.delta'],
370
+ watches: TEXT_WATCHES,
279
371
  matches: (event, params = {}, ctx = {}) => {
372
+ if (!sourceAllowed(params.sources, event)) return null;
280
373
  for (const seg of event.segments || []) {
281
374
  // Other people by default: this exists for reacting to what someone ELSE asks (an
282
375
  // interview, a customer call). The job form offers the choice, so 'anyone' and 'me' are
@@ -391,6 +484,62 @@ export function nextWakeAt(jobs, { now, lastRun = {} } = {}) {
391
484
  * Which jobs an event fires. Returns matches; running them is the host's business, because
392
485
  * only the host knows what a skill costs and whether the user approved it.
393
486
  */
487
+ /** More than this in one batch and the answer stops being an answer. */
488
+ export const DEFAULT_COALESCE_MAX = 8;
489
+
490
+ /**
491
+ * Fold several triggers of the SAME job into ONE run.
492
+ *
493
+ * A cooldown exists because a topic a meeting keeps returning to is one thing happening, not
494
+ * six — but for a job that ANSWERS something, dropping the second trigger drops a question
495
+ * nobody ever answers. Reported exactly that way: thirteen questions asked in a burst, one
496
+ * answered, the rest skipped with "fired moments ago (cooldown)" and never seen again.
497
+ *
498
+ * So the cooldown stops meaning "discard" and starts meaning "wait, and bring the rest with
499
+ * you". This is the pure half: which of the queued triggers survive into the batch.
500
+ *
501
+ * Identity is the SPOKEN LINE, not the match. A live caption is re-emitted as it grows, so
502
+ * the same sentence arrives several times at different lengths; keying on a normalized
503
+ * prefix collapses those without the segment ids having to agree, and the LONGEST version
504
+ * wins because it is the finished one.
505
+ *
506
+ * Over the cap the OLDEST go: a question the meeting has already moved past is worth less
507
+ * than the one just asked, and an unbounded batch is a prompt nobody can afford.
508
+ */
509
+ export function coalesceMatches(matches = [], { max = DEFAULT_COALESCE_MAX } = {}) {
510
+ const rows = [];
511
+ for (const m of matches) {
512
+ const text = String(m?.segment?.text || m?.why || '').trim();
513
+ if (!text) continue;
514
+ const who = norm(m?.segment?.speaker);
515
+ const t = m?.segment?.t || 0;
516
+ const flat = text.toLowerCase().replace(/\s+/g, ' ');
517
+ // Same utterance if it starts at the same moment, or — when the caption carries no
518
+ // timestamp — if one text is a PREFIX of the other, which is precisely how a caption
519
+ // grows. A fixed-length key cannot do this: "why is the sky" and "why is the sky blue"
520
+ // differ inside any prefix long enough to tell two real questions apart.
521
+ // When BOTH carry a start time that is identity on its own, and prefix matching must not
522
+ // get a say: "question number 1" is a prefix of "question number 10", so an OR here
523
+ // silently merges two different asks.
524
+ const i = rows.findIndex((r) => r.who === who && (
525
+ (t && r.t) ? r.t === t : (r.flat.startsWith(flat) || flat.startsWith(r.flat))
526
+ ));
527
+ if (i >= 0) {
528
+ if (text.length > rows[i].text.length) rows[i] = { ...rows[i], match: m, text, flat };
529
+ continue;
530
+ }
531
+ rows.push({ who, t, match: m, text, flat });
532
+ }
533
+ return rows.slice(-Math.max(1, max)).map((r) => r.match);
534
+ }
535
+
536
+ /** The lines a batch is asking about, in the order they were said. For a prompt, and for a log. */
537
+ export function matchTexts(matches = []) {
538
+ return coalesceMatches(matches, { max: Number.MAX_SAFE_INTEGER })
539
+ .map((m) => String(m?.segment?.text || m?.why || '').trim())
540
+ .filter(Boolean);
541
+ }
542
+
394
543
  export function jobsForEvent(jobs, event, { registry, ctx = {}, admit = null } = {}) {
395
544
  const out = [];
396
545
  for (const job of jobs || []) {