@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 +2 -0
- package/package.json +3 -1
- package/queue.js +98 -0
- package/schedule.js +51 -1
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.
|
|
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
|
-
|
|
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 || []) {
|