@chatpanel/events 0.18.0 → 0.18.1

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 +1 -1
  2. package/schedule.js +37 -5
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chatpanel/events",
3
- "version": "0.18.0",
3
+ "version": "0.18.1",
4
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.",
5
5
  "type": "module",
6
6
  "main": "index.js",
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).
@@ -208,12 +234,14 @@ export const phraseTrigger = defineTrigger({
208
234
  kind: 'meeting',
209
235
  watches: ['meeting.transcript.delta'],
210
236
  matches: (event, params = {}, ctx = {}) => {
211
- const any = (params.any || []).map(norm).filter(Boolean);
212
- if (!any.length) return null; // a phrase trigger with no phrase would fire on every word
237
+ // Both guards exist because their absence looks the same to a user: a trigger that fires
238
+ // on everything. An empty list would match every line; a one- or two-letter phrase
239
+ // effectively does too.
240
+ const any = (params.any || []).map(norm).filter((p) => p.length >= MIN_PHRASE_CHARS);
241
+ if (!any.length) return null;
213
242
  for (const seg of event.segments || []) {
214
243
  if (!speakerAllowed(params.speaker, seg.speaker, ctx)) continue;
215
- const text = norm(seg.text);
216
- const hit = any.find((p) => text.includes(p));
244
+ const hit = any.find((p) => saidIn(seg.text, p));
217
245
  if (hit) return { why: `“${hit}” said by ${seg.speaker || 'someone'}`, segment: seg, phrase: hit };
218
246
  }
219
247
  return null;
@@ -253,7 +281,11 @@ export const questionTrigger = defineTrigger({
253
281
  // Other people by default: this exists for reacting to what someone ELSE asks (an
254
282
  // interview, a customer call). The job form offers the choice, so 'anyone' and 'me' are
255
283
  // one dropdown away rather than an invisible default nobody can reach.
256
- if (!speakerAllowed(params.speaker || 'others', seg.speaker, ctx)) continue;
284
+ // ANYONE by default, including you. 'others' read well — this exists for reacting to
285
+ // what someone ELSE asks — but it made the first thing anybody does (test it alone in a
286
+ // call, ask a question, wait) match nothing, so the feature looked dead. "Only what
287
+ // other people ask" is still one dropdown away in the job form.
288
+ if (!speakerAllowed(params.speaker || 'anyone', seg.speaker, ctx)) continue;
257
289
  const text = String(seg.text || '').trim();
258
290
  if (text.length < 8) continue; // "what?" is not a question worth waking a model for
259
291
  if (text.includes('?') || QUESTION.test(text)) return { why: `question from ${seg.speaker || 'someone'}`, segment: seg };