@chatpanel/events 0.18.0 → 0.19.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 +2 -2
- package/schedule.js +91 -5
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/events",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "The canonical ChatPanel event-log and capability contracts
|
|
3
|
+
"version": "0.19.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
|
@@ -152,6 +152,32 @@ export function createTriggerRegistry(triggers = []) {
|
|
|
152
152
|
const words = (s) => String(s || '').toLowerCase().match(/[\p{L}\p{N}']+/gu) || [];
|
|
153
153
|
const norm = (s) => String(s || '').trim().toLowerCase();
|
|
154
154
|
|
|
155
|
+
/** Shorter than this and ordinary speech contains it constantly. */
|
|
156
|
+
export const MIN_PHRASE_CHARS = 3;
|
|
157
|
+
|
|
158
|
+
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Whole-word containment, not `includes`.
|
|
162
|
+
*
|
|
163
|
+
* A substring match turns "in" into a trigger that fires on interview, thing, going and
|
|
164
|
+
* finding — which is indistinguishable, from the user's chair, from a trigger that ignores
|
|
165
|
+
* its phrase entirely. That is exactly how it was reported: "for every utterance I say, it
|
|
166
|
+
* runs". Boundaries are only required at ends that are word characters, so "action item"
|
|
167
|
+
* still matches "an action item," and ":shipped" still matches ":shipped".
|
|
168
|
+
*/
|
|
169
|
+
export function saidIn(text, phrase) {
|
|
170
|
+
const p = norm(phrase);
|
|
171
|
+
if (p.length < MIN_PHRASE_CHARS) return false;
|
|
172
|
+
const left = /[\p{L}\p{N}]/u.test(p[0]) ? '\\b' : '';
|
|
173
|
+
const right = /[\p{L}\p{N}]/u.test(p[p.length - 1]) ? '\\b' : '';
|
|
174
|
+
try {
|
|
175
|
+
return new RegExp(`${left}${escapeRe(p)}${right}`, 'iu').test(String(text || ''));
|
|
176
|
+
} catch {
|
|
177
|
+
return norm(text).includes(p); // a phrase that will not compile still gets a plain match
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
155
181
|
// Whose speech a meeting trigger cares about. The default is 'anyone' because a phrase
|
|
156
182
|
// trigger is usually watching for what OTHERS say — unlike a spoken command, which is only
|
|
157
183
|
// ever the owner's (see voice-intents.js).
|
|
@@ -161,6 +187,60 @@ function speakerAllowed(want, speaker, ctx) {
|
|
|
161
187
|
return true;
|
|
162
188
|
}
|
|
163
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
|
+
|
|
164
244
|
export const timerTrigger = defineTrigger({
|
|
165
245
|
id: 'timer:schedule',
|
|
166
246
|
label: 'On a schedule',
|
|
@@ -208,12 +288,14 @@ export const phraseTrigger = defineTrigger({
|
|
|
208
288
|
kind: 'meeting',
|
|
209
289
|
watches: ['meeting.transcript.delta'],
|
|
210
290
|
matches: (event, params = {}, ctx = {}) => {
|
|
211
|
-
|
|
212
|
-
|
|
291
|
+
// Both guards exist because their absence looks the same to a user: a trigger that fires
|
|
292
|
+
// on everything. An empty list would match every line; a one- or two-letter phrase
|
|
293
|
+
// effectively does too.
|
|
294
|
+
const any = (params.any || []).map(norm).filter((p) => p.length >= MIN_PHRASE_CHARS);
|
|
295
|
+
if (!any.length) return null;
|
|
213
296
|
for (const seg of event.segments || []) {
|
|
214
297
|
if (!speakerAllowed(params.speaker, seg.speaker, ctx)) continue;
|
|
215
|
-
const
|
|
216
|
-
const hit = any.find((p) => text.includes(p));
|
|
298
|
+
const hit = any.find((p) => saidIn(seg.text, p));
|
|
217
299
|
if (hit) return { why: `“${hit}” said by ${seg.speaker || 'someone'}`, segment: seg, phrase: hit };
|
|
218
300
|
}
|
|
219
301
|
return null;
|
|
@@ -253,7 +335,11 @@ export const questionTrigger = defineTrigger({
|
|
|
253
335
|
// Other people by default: this exists for reacting to what someone ELSE asks (an
|
|
254
336
|
// interview, a customer call). The job form offers the choice, so 'anyone' and 'me' are
|
|
255
337
|
// one dropdown away rather than an invisible default nobody can reach.
|
|
256
|
-
|
|
338
|
+
// ANYONE by default, including you. 'others' read well — this exists for reacting to
|
|
339
|
+
// what someone ELSE asks — but it made the first thing anybody does (test it alone in a
|
|
340
|
+
// call, ask a question, wait) match nothing, so the feature looked dead. "Only what
|
|
341
|
+
// other people ask" is still one dropdown away in the job form.
|
|
342
|
+
if (!speakerAllowed(params.speaker || 'anyone', seg.speaker, ctx)) continue;
|
|
257
343
|
const text = String(seg.text || '').trim();
|
|
258
344
|
if (text.length < 8) continue; // "what?" is not a question worth waking a model for
|
|
259
345
|
if (text.includes('?') || QUESTION.test(text)) return { why: `question from ${seg.speaker || 'someone'}`, segment: seg };
|