@chatpanel/events 0.19.0 → 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.
- package/package.json +1 -1
- package/schedule.js +98 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/events",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.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
|
@@ -241,6 +241,42 @@ export function utteranceLooksComplete(text) {
|
|
|
241
241
|
return !DANGLING_TAILS.has(last);
|
|
242
242
|
}
|
|
243
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
|
+
|
|
244
280
|
export const timerTrigger = defineTrigger({
|
|
245
281
|
id: 'timer:schedule',
|
|
246
282
|
label: 'On a schedule',
|
|
@@ -286,11 +322,12 @@ export const phraseTrigger = defineTrigger({
|
|
|
286
322
|
id: 'meeting:phrase',
|
|
287
323
|
label: 'When a phrase is said',
|
|
288
324
|
kind: 'meeting',
|
|
289
|
-
watches:
|
|
325
|
+
watches: TEXT_WATCHES,
|
|
290
326
|
matches: (event, params = {}, ctx = {}) => {
|
|
291
327
|
// Both guards exist because their absence looks the same to a user: a trigger that fires
|
|
292
328
|
// on everything. An empty list would match every line; a one- or two-letter phrase
|
|
293
329
|
// effectively does too.
|
|
330
|
+
if (!sourceAllowed(params.sources, event)) return null;
|
|
294
331
|
const any = (params.any || []).map(norm).filter((p) => p.length >= MIN_PHRASE_CHARS);
|
|
295
332
|
if (!any.length) return null;
|
|
296
333
|
for (const seg of event.segments || []) {
|
|
@@ -306,11 +343,12 @@ export const topicTrigger = defineTrigger({
|
|
|
306
343
|
id: 'meeting:topic',
|
|
307
344
|
label: 'When someone talks about something',
|
|
308
345
|
kind: 'meeting',
|
|
309
|
-
watches:
|
|
346
|
+
watches: TEXT_WATCHES,
|
|
310
347
|
// Looser than a phrase on purpose: "says something about pricing" should not require the
|
|
311
348
|
// word "pricing" in the exact shape the job author typed. Term overlap over the window is
|
|
312
349
|
// deterministic, explainable, and free — a model would be all three of the opposite.
|
|
313
350
|
matches: (event, params = {}, ctx = {}) => {
|
|
351
|
+
if (!sourceAllowed(params.sources, event)) return null;
|
|
314
352
|
const terms = (params.terms || []).map(norm).filter(Boolean);
|
|
315
353
|
if (!terms.length) return null;
|
|
316
354
|
const need = Math.max(1, Math.min(params.minHits || 1, terms.length));
|
|
@@ -329,8 +367,9 @@ export const questionTrigger = defineTrigger({
|
|
|
329
367
|
id: 'meeting:question',
|
|
330
368
|
label: 'When a question is asked',
|
|
331
369
|
kind: 'meeting',
|
|
332
|
-
watches:
|
|
370
|
+
watches: TEXT_WATCHES,
|
|
333
371
|
matches: (event, params = {}, ctx = {}) => {
|
|
372
|
+
if (!sourceAllowed(params.sources, event)) return null;
|
|
334
373
|
for (const seg of event.segments || []) {
|
|
335
374
|
// Other people by default: this exists for reacting to what someone ELSE asks (an
|
|
336
375
|
// interview, a customer call). The job form offers the choice, so 'anyone' and 'me' are
|
|
@@ -445,6 +484,62 @@ export function nextWakeAt(jobs, { now, lastRun = {} } = {}) {
|
|
|
445
484
|
* Which jobs an event fires. Returns matches; running them is the host's business, because
|
|
446
485
|
* only the host knows what a skill costs and whether the user approved it.
|
|
447
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
|
+
|
|
448
543
|
export function jobsForEvent(jobs, event, { registry, ctx = {}, admit = null } = {}) {
|
|
449
544
|
const out = [];
|
|
450
545
|
for (const job of jobs || []) {
|