@chatpanel/events 0.21.0 → 0.22.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/index.js CHANGED
@@ -17,6 +17,7 @@ export {
17
17
 
18
18
  export { REF_KINDS, RESOLUTION, makeRef, isRef, resolveRef } from './ref.js';
19
19
  export { linearize, compareEvents, causesAreWellFormed } from './order.js';
20
+ export { pendingQueue, isQueued, dequeue, moveQueued, promoteQueued } from './queue.js';
20
21
  export { UPCASTERS, upcast, upcastAll } from './upcast.js';
21
22
  export {
22
23
  validateCapability, validateInvocation, canSatisfy,
@@ -59,6 +60,7 @@ export {
59
60
  timerTrigger, meetingStartedTrigger, meetingEndedTrigger, personJoinedTrigger,
60
61
  phraseTrigger, topicTrigger, questionTrigger, voiceCommandTrigger,
61
62
  defineJob, dueJobs, jobsForEvent, occurrenceKey,
63
+ clipText, matchSummary,
62
64
  } from './schedule.js';
63
65
  export { defineMeetingAnalyzer, createAnalyzerRegistry, CADENCES, AnalyzerError } from './meeting-analyzers.js';
64
66
  export {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chatpanel/events",
3
- "version": "0.21.0",
3
+ "version": "0.22.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",
@@ -20,6 +20,7 @@
20
20
  "./meeting-analyzers.js": "./meeting-analyzers.js",
21
21
  "./memory.js": "./memory.js",
22
22
  "./order.js": "./order.js",
23
+ "./queue.js": "./queue.js",
23
24
  "./ref.js": "./ref.js",
24
25
  "./registry.js": "./registry.js",
25
26
  "./route-graph.js": "./route-graph.js",
@@ -68,6 +69,7 @@
68
69
  "memory.js",
69
70
  "observability.js",
70
71
  "order.js",
72
+ "queue.js",
71
73
  "ref.js",
72
74
  "registry.js",
73
75
  "route-graph.js",
package/queue.js ADDED
@@ -0,0 +1,98 @@
1
+ // THE SEND QUEUE — what someone typed while a reply was still streaming, and what they are
2
+ // allowed to do about it before it is answered.
3
+ //
4
+ // Typing over a running answer is not a mistake to be prevented; it is how people think. But
5
+ // a queue you can only ADD to is a trap, and in three specific ways:
6
+ //
7
+ // • the thought that arrives mid-answer is often the URGENT one — it should not have to
8
+ // wait out a reply it was meant to redirect (steer);
9
+ // • the reply frequently answers a queued question before it is ever sent, and sending it
10
+ // anyway spends a turn on something nobody wants any more (dequeue);
11
+ // • the order things occur to you is not the order they should be asked in (reorder).
12
+ //
13
+ // The queue is deliberately NOT a second list living beside the transcript. It IS the
14
+ // trailing run of user messages that has no reply after it — exactly the set a client
15
+ // answers when the current stream ends. One definition means what is drawn and what is sent
16
+ // can never disagree, and a message cannot be "in the queue" but missing from the turn.
17
+ //
18
+ // Every function here is a pure transform: it returns a NEW array, or the array it was given
19
+ // (by identity) when the operation was a no-op, so a caller can tell "nothing changed" from
20
+ // "changed" without diffing. No clock, no ids minted, no DOM, no storage — the panel, a
21
+ // mobile client, the gateway and the bridge can all reason about the same queue.
22
+ //
23
+ // STEERING, honestly: no engine we speak to accepts new input into a run already in flight
24
+ // (the bridge closes a CLI's stdin with the prompt; HTTP streaming has no upstream channel).
25
+ // So "send now" is promote-then-interrupt: the partial answer stays in the transcript as
26
+ // context, and the next turn opens with the message the user wanted read first. This module
27
+ // owns the ORDER half of that — the client owns the abort.
28
+
29
+ const isUser = (m) => !!m && m.role === 'user';
30
+
31
+ /**
32
+ * The pending queue: the trailing run of user messages with no reply after it.
33
+ *
34
+ * Returned oldest-first — the order they will be sent in — as
35
+ * `{ message, id, index, position }`, where `index` is the position in the whole
36
+ * transcript and `position` the position within the queue.
37
+ */
38
+ export function pendingQueue(messages) {
39
+ const list = Array.isArray(messages) ? messages : [];
40
+ let start = list.length;
41
+ while (start > 0 && isUser(list[start - 1])) start--;
42
+ const out = [];
43
+ for (let i = start; i < list.length; i++) {
44
+ out.push({ message: list[i], id: list[i].id, index: i, position: i - start });
45
+ }
46
+ return out;
47
+ }
48
+
49
+ /** Is this message currently in the queue (rather than already answered)? */
50
+ export function isQueued(messages, id) {
51
+ return pendingQueue(messages).some((e) => e.id === id);
52
+ }
53
+
54
+ /**
55
+ * Drop a queued message — the question the running reply already answered.
56
+ *
57
+ * Only ever removes from the pending run: an id from further up the transcript is a
58
+ * no-op, so a stale click can never delete a turn that has already been answered.
59
+ */
60
+ export function dequeue(messages, id) {
61
+ const queue = pendingQueue(messages);
62
+ const hit = queue.find((e) => e.id === id);
63
+ if (!hit) return messages;
64
+ const next = messages.slice();
65
+ next.splice(hit.index, 1);
66
+ return next;
67
+ }
68
+
69
+ /** Move a queued message by `delta` places (-1 up, +1 down). Clamped: past either end is a no-op. */
70
+ export function moveQueued(messages, id, delta) {
71
+ const queue = pendingQueue(messages);
72
+ const from = queue.findIndex((e) => e.id === id);
73
+ if (from < 0) return messages;
74
+ const step = Math.trunc(Number(delta) || 0);
75
+ const to = from + step;
76
+ if (!step || to < 0 || to >= queue.length) return messages;
77
+ return reordered(messages, queue, from, to);
78
+ }
79
+
80
+ /**
81
+ * Put a queued message at the FRONT — the ordering half of "send now".
82
+ *
83
+ * The rest of the queue keeps its relative order behind it: steering is a statement about
84
+ * what to read first, not an instruction to throw away everything else that was typed.
85
+ */
86
+ export function promoteQueued(messages, id) {
87
+ const queue = pendingQueue(messages);
88
+ const from = queue.findIndex((e) => e.id === id);
89
+ if (from <= 0) return messages; // absent, or already first
90
+ return reordered(messages, queue, from, 0);
91
+ }
92
+
93
+ function reordered(messages, queue, from, to) {
94
+ const block = queue.map((e) => e.message);
95
+ const [moved] = block.splice(from, 1);
96
+ block.splice(to, 0, moved);
97
+ return messages.slice(0, queue[0].index).concat(block);
98
+ }
package/schedule.js CHANGED
@@ -162,6 +162,25 @@ export const MIN_PHRASE_CHARS = 3;
162
162
 
163
163
  const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
164
164
 
165
+ /**
166
+ * Shorten text a person is meant to READ.
167
+ *
168
+ * A hard `slice` lands mid-word — "…with the informati" — and reads as a bug in whatever
169
+ * wrote it rather than as an abbreviation. Backing up to the last word boundary costs
170
+ * nothing and is the whole difference. Exported because three places shorten the same kinds
171
+ * of strings (a trigger's reason, a batch summary, a job named after its own instruction)
172
+ * and three hard slices is how they drift apart.
173
+ */
174
+ export function clipText(text, max = 120) {
175
+ const t = String(text || '').replace(/\s+/g, ' ').trim();
176
+ if (t.length <= max) return t;
177
+ const cut = t.slice(0, max);
178
+ const at = cut.lastIndexOf(' ');
179
+ // Only back up when there is a boundary worth backing up TO. A single long token — a URL,
180
+ // an id — has none, and shrinking it to nothing helps nobody.
181
+ return `${(at > max * 0.6 ? cut.slice(0, at) : cut).replace(/[\s,;:.!?—-]+$/, '')}…`;
182
+ }
183
+
165
184
  /**
166
185
  * Whole-word containment, not `includes`.
167
186
  *
@@ -386,7 +405,13 @@ export const questionTrigger = defineTrigger({
386
405
  if (!speakerAllowed(params.speaker || 'anyone', seg.speaker, ctx)) continue;
387
406
  const text = String(seg.text || '').trim();
388
407
  if (text.length < 8) continue; // "what?" is not a question worth waking a model for
389
- if (text.includes('?') || QUESTION.test(text)) return { why: `question from ${seg.speaker || 'someone'}`, segment: seg };
408
+ // The QUESTION itself, not only who asked it. Every sibling trigger names what it
409
+ // fired on — the phrase, the spoken command, the people who joined — and "question
410
+ // from Alex" leaves the answer under it looking like an answer to nothing. This
411
+ // string is the row in the thread, the toast, and the line in the job's run log.
412
+ if (text.includes('?') || QUESTION.test(text)) {
413
+ return { why: `question from ${seg.speaker || 'someone'}: “${clipText(text, 100)}”`, segment: seg };
414
+ }
390
415
  }
391
416
  return null;
392
417
  },
@@ -545,6 +570,31 @@ export function matchTexts(matches = []) {
545
570
  .filter(Boolean);
546
571
  }
547
572
 
573
+ /**
574
+ * One line saying what a batch of matches actually fired on.
575
+ *
576
+ * "3 questions" is a count, and a count is exactly the part the reader already knows — three
577
+ * answers are about to appear underneath it. What they cannot recover is WHICH three, so the
578
+ * questions themselves are the summary and the number is the prefix.
579
+ *
580
+ * `noun` is the plural word for what these are ('questions', 'matches'), since the trigger
581
+ * that produced them is not carried on a match.
582
+ */
583
+ export function matchSummary(matches = [], { noun = 'matches', max = 3, chars = 180 } = {}) {
584
+ const list = (matches || []).filter(Boolean);
585
+ if (!list.length) return '';
586
+ // One match already has a human reason written by its own trigger — that reason is better
587
+ // than anything a generic formatter can say about it.
588
+ if (list.length === 1) return String(list[0].why || '') || clipText(list[0]?.segment?.text, chars);
589
+ const texts = matchTexts(list);
590
+ if (!texts.length) return `${list.length} ${noun}`;
591
+ const shown = texts.slice(0, max);
592
+ const per = Math.max(40, Math.floor(chars / shown.length));
593
+ const rest = texts.length - shown.length;
594
+ return `${texts.length} ${noun}: ${shown.map((t) => `“${clipText(t, per)}”`).join(' · ')}`
595
+ + (rest > 0 ? ` +${rest} more` : '');
596
+ }
597
+
548
598
  export function jobsForEvent(jobs, event, { registry, ctx = {}, admit = null } = {}) {
549
599
  const out = [];
550
600
  for (const job of jobs || []) {