@chatpanel/events 0.27.0 → 0.29.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chatpanel/events",
3
- "version": "0.27.0",
3
+ "version": "0.29.0",
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
@@ -222,6 +222,11 @@ export const DANGLING_TAILS = Object.freeze(new Set([
222
222
  'and', 'or', 'but', 'so', 'because', 'cause', 'cos', 'since', 'although', 'though', 'while',
223
223
  'whereas', 'unless', 'until', 'if', 'when', 'whenever', 'that', 'which', 'who', 'whom',
224
224
  'whose', 'than', 'as', 'like',
225
+ // 'what' ends a clause only in a handful of set phrases ("guess what") and otherwise means
226
+ // the object of the thought is still coming — "…testing to see what". Live captions cut
227
+ // exactly there and punctuate it, which is how a sentence ABOUT the product became an
228
+ // address to it (see isAddressed in voice-intents.js).
229
+ 'what',
225
230
  // prepositions and particles
226
231
  'to', 'of', 'in', 'on', 'at', 'by', 'for', 'from', 'with', 'without', 'about', 'into',
227
232
  'onto', 'over', 'under', 'between', 'through', 'during', 'against', 'per',
package/voice-intents.js CHANGED
@@ -380,7 +380,7 @@ export const REFINEMENT_SCHEMA = defineSchema({
380
380
  name: { type: 'string', max: 48, describe: 'a label of at most 6 words' },
381
381
  kind: {
382
382
  type: 'enum',
383
- values: ['question', 'monitor', 'note', 'skill', 'none'],
383
+ values: ['question', 'monitor', 'note', 'skill', 'timer', 'none'],
384
384
  // An unknown kind becomes a QUESTION — the least surprising thing to do with something
385
385
  // someone asked for, and the only kind that is undone by ignoring the answer. Guessing
386
386
  // "monitor" instead would leave a card watching the meeting that nobody asked for.
@@ -409,6 +409,7 @@ export function refinementPrompt(utterance) {
409
409
  ' "keep an eye on"). A one-off question is NOT a monitor.',
410
410
  ' note — they asked for notes written down ("take notes on", "write that up").',
411
411
  ' skill — they named a saved skill ("use the summarize skill"); put its name in `skill`.',
412
+ ' timer — alerted after an AMOUNT OF TIME ("set a one minute"); keep it in `request`.',
412
413
  ' none — not asking for anything: thinking aloud, or talking ABOUT the assistant.',
413
414
  'Never invent a request that is not there — return "none". Keep `request` close to their',
414
415
  'words; do not answer it.',
@@ -454,8 +455,23 @@ export function settleRefinement(v) {
454
455
  const request = String(v.request || '').trim();
455
456
  if (v.kind === 'none' || !request) return { request: '', name: '', kind: 'none', skill: '' };
456
457
  // A "skill" with no name is a question — there is nothing to run.
457
- const kind = v.kind === 'skill' && !v.skill ? 'question' : v.kind;
458
- return { request, name: String(v.name || '').trim() || request, kind, skill: v.skill || '' };
458
+ let kind = v.kind === 'skill' && !v.skill ? 'question' : v.kind;
459
+ const name = String(v.name || '').trim() || request;
460
+ // A TIMER IS RESOLVED HERE, not by whatever runs the request.
461
+ //
462
+ // A spoken timer the grammar missed used to arrive as a plain question, so it went to the
463
+ // chat — where an agent answered it by running `sleep 60` in its own sandbox and saying it
464
+ // would notify. It cannot: nothing connects that process back to the user. Reading the
465
+ // duration here turns it back into a job the product itself owns and can fire.
466
+ //
467
+ // No duration means the model called it a timer without one, and a timer with no duration
468
+ // is a question about time. Downgraded rather than dropped.
469
+ if (kind === 'timer') {
470
+ const d = parseDuration(request);
471
+ if (!d) return { request, name, kind: 'question', skill: '' };
472
+ return { request, name, kind: 'timer', skill: '', ms: d.ms };
473
+ }
474
+ return { request, name, kind, skill: v.skill || '' };
459
475
  }
460
476
 
461
477
  /**
@@ -502,24 +518,66 @@ export function refinementStream({ onChange = null } = {}) {
502
518
  */
503
519
  const NOUN_MARKERS = /^(?:the|a|an|our|your|their|my|this|that|these|those|about|on|in|of|with|via|using|called|named|to)$/i;
504
520
 
521
+ // Adverbs that sit between a subject and its verb — "chat panel ACTUALLY helps us".
522
+ const SUBJECT_ADVERBS = /^(?:actually|really|also|always|never|just|only|still|often|usually|basically|literally|probably|certainly|definitely|now|then|even|apparently|obviously)$/i;
523
+ // Verb forms that make whatever comes before them the SUBJECT of a claim rather than the
524
+ // person being spoken to. A closed list on purpose: the general rule ("any third-person verb")
525
+ // cannot be told from an imperative without a parser, and guessing wrong here costs a command.
526
+ const SUBJECT_VERBS = /^(?:is|isn't|was|wasn't|are|aren't|were|weren't|has|hasn't|have|had|does|doesn't|did|didn't|can|can't|cannot|could|couldn't|will|won't|would|wouldn't|should|shouldn't|shall|may|might|must|seems|helps|lets|gives|allows|works|looks|means|needs|wants|keeps|makes|shows|tells|comes|goes|takes|runs|becomes|provides|supports)$/i;
527
+ // …unless a pronoun follows, which turns the same auxiliary into a question aimed at us:
528
+ // "chatpanel, can YOU set a timer" against "chat panel can help us".
529
+ const QUESTION_PRONOUNS = /^(?:you|we|i|they|it|there|he|she)$/i;
530
+
531
+ const bare = (t) => String(t?.w || '').replace(/[^\p{L}\p{N}']/gu, '');
532
+
533
+ /**
534
+ * Is the wake phrase the SUBJECT of the sentence rather than the person being addressed?
535
+ *
536
+ * "…what chat panel actually helps us to monitor" is a sentence ABOUT the product, and it
537
+ * fired a request. Nothing in the words before the name says so — the giveaway is what comes
538
+ * after it: a vocative is followed by a comma, an imperative or a question word, while a
539
+ * subject is followed by its verb.
540
+ */
541
+ function readsAsSubject(raw, tokens, endIdx) {
542
+ const next = tokens[endIdx + 1];
543
+ if (!next) return false; // nothing after the name at all — not a claim about it
544
+ // Punctuation between the name and what follows is the vocative comma (or a sentence
545
+ // break). Either way the name stands alone, which subjects do not do.
546
+ if (/[.!?,;:–—-]/.test(raw.slice(tokens[endIdx].end, next.start))) return false;
547
+ let j = endIdx + 1;
548
+ if (SUBJECT_ADVERBS.test(bare(tokens[j])) && tokens[j + 1]) j += 1;
549
+ if (!SUBJECT_VERBS.test(bare(tokens[j]))) return false;
550
+ return !QUESTION_PRONOUNS.test(bare(tokens[j + 1]));
551
+ }
552
+
505
553
  function isAddressed(raw, tokens, i, n = 0) {
506
554
  // The fuzzy match is generous enough to SWALLOW a leading article: "a chat panel" squashes
507
555
  // to "achatpanel", one edit from "chatpanel", so the determiner ends up inside the matched
508
556
  // span instead of before it. Check the first matched token too, or "a chat panel would be
509
557
  // useful here" reads as an address purely because the "a" was absorbed.
510
558
  if (n > 0 && NOUN_MARKERS.test(String(tokens[i].w || '').replace(/[^\p{L}\p{N}]/gu, ''))) return false;
559
+ // Whatever came before it, a name followed by its own verb is being TALKED ABOUT.
560
+ if (readsAsSubject(raw, tokens, i + n)) return false;
511
561
  if (i === 0) return true; // nothing before it — it opens the utterance
512
562
  const prev = tokens[i - 1];
513
563
  const word = String(prev.w || '').replace(/[^\p{L}\p{N}]/gu, '');
514
564
  if (NOUN_MARKERS.test(word)) return false; // "the chat panel" — a thing, not a listener
565
+ // "hey chatpanel", "ok chatpanel" — an address word is how people open one.
566
+ if (/^(?:ok|okay|hey|hi|yo|hello|so|um|uh)$/i.test(word)) return true;
515
567
  // Punctuation before it is the vocative comma or a sentence break — "…here. Okay, chat
516
568
  // panel", "so I was thinking. ChatPanel, what did we decide?" — and both mean a fresh
517
569
  // address rather than a continuing noun phrase. Measured from the previous token's START,
518
570
  // because the tokenizer keeps trailing punctuation ON the token ("thinking."), so the gap
519
571
  // between tokens is only the space and the full stop would be missed.
520
- if (/[.!?,;:]["')\]]?\s*$/.test(raw.slice(prev.start, tokens[i].start))) return true;
521
- // "hey chatpanel", "ok chatpanel" — an address word is the other way people open one.
522
- return /^(?:ok|okay|hey|hi|yo|hello|so|um|uh)$/i.test(word);
572
+ const gap = raw.slice(prev.start, tokens[i].start);
573
+ if (!/[.!?,;:]["'’”)\]]*\s*$/.test(gap)) return false;
574
+ // …but ONLY a break the speaker actually made. The transcriber invents full stops, and it
575
+ // invents them mid-clause: "…another round of testing to see what. Chat panel actually
576
+ // helps us to monitor" is one sentence about the product, cut in half by a machine, and the
577
+ // half-stop made the second half read as a fresh address. A break is only a break when the
578
+ // words before it are a finished thought — the same test that decides when a command has
579
+ // stopped growing, for exactly the same reason.
580
+ return !/[.!?…]/.test(gap) || commandLooksFinished(raw.slice(0, prev.end));
523
581
  }
524
582
 
525
583
  /**
@@ -566,13 +624,21 @@ const FRACTION = { half: 0.5, quarter: 0.25 };
566
624
  export function parseNumberWords(words) {
567
625
  if (!words.length) return null;
568
626
  let total = null;
627
+ // THE TWO-MINUTE ONE-MINUTE TIMER. The article used to set the count to 1 outright, and
628
+ // everything after it ADDS — so "set a one minute timer", which is how most people say it,
629
+ // came out as 1 + 1 = two minutes. Reported as "I asked for a 1-minute timer, it didn't
630
+ // work": it worked, twice as long, which looks exactly like not working.
631
+ //
632
+ // The article is now only a count when nothing else supplies one. "A minute" is still a
633
+ // minute; "a one minute" is one minute, not two.
634
+ let article = false;
569
635
  for (let i = 0; i < words.length; i++) {
570
636
  const w = words[i];
571
637
  if (w === 'and' || w === 'of') continue; // "two AND a half", "a quarter OF an hour"
572
638
  // "an hour" is one hour. "A quarter of an hour" is a quarter, and "two and A half" is
573
639
  // 2.5 — in both of those the article belongs to the fraction, not to the count.
574
640
  if (w === 'a' || w === 'an') {
575
- if (total === null && !(words[i + 1] in FRACTION)) total = 1;
641
+ if (total === null && !(words[i + 1] in FRACTION)) article = true;
576
642
  continue;
577
643
  }
578
644
  if (w in FRACTION) { total = (total ?? 0) + FRACTION[w]; continue; }
@@ -581,7 +647,7 @@ export function parseNumberWords(words) {
581
647
  if (/^\d+(?:\.\d+)?$/.test(w)) { total = (total ?? 0) + Number(w); continue; }
582
648
  return null;
583
649
  }
584
- return total;
650
+ return total ?? (article ? 1 : null);
585
651
  }
586
652
 
587
653
  const UNIT_MS = {
@@ -885,9 +951,23 @@ export const timerIntent = defineVoiceIntent({
885
951
  description: 'Starts a countdown and alerts when it finishes.',
886
952
  examples: ['set a timer for 10 minutes', 'start a 90 second timer', 'timer for an hour and a half'],
887
953
  match: (command, { now = Date.now() } = {}) => {
888
- if (!/\btimers?\b/i.test(command)) return null;
889
954
  const d = parseDuration(command);
890
955
  if (!d) return null;
956
+ // THE MISSING HEAD NOUN. "Okay ChatPanel, set a 1-minute timer" reached the scanner as
957
+ // "set a one minute." — the word this pattern was keyed on was still being said. With no
958
+ // intent it went to the model, which answered a spoken timer by running `sleep 60` in a
959
+ // sandbox and promising a notification it had no way to deliver.
960
+ //
961
+ // So a SET verb whose only content is a duration is a timer: nothing else is ever said
962
+ // that way. Anything left over after the duration and the plumbing words means it is
963
+ // something else — "set a 5 minute meeting" is a meeting — and still needs the noun.
964
+ if (!/\btimers?\b/i.test(command)) {
965
+ if (!/^(?:set|start|make|create|put)\b/i.test(command.trim())) return null;
966
+ const rest = (command.slice(0, d.start) + ' ' + command.slice(d.end))
967
+ .replace(/\b(set|start|make|create|put|a|an|the|for|please|to|of|and|half|quarter|this|that|time|up|on|me)\b/gi, ' ')
968
+ .replace(/[^\p{L}\p{N}]+/gu, '');
969
+ if (rest) return null;
970
+ }
891
971
  // "timer for the standup" — whatever is left once the duration and the plumbing words
892
972
  // are removed is what the timer is FOR, and a labelled timer is the difference between
893
973
  // three anonymous countdowns and three useful ones.
@@ -925,7 +1005,10 @@ export const noteIntent = defineVoiceIntent({
925
1005
  description: 'Appends a line to the meeting notes.',
926
1006
  examples: ['note that we agreed to ship on Friday', 'take a note: budget is approved'],
927
1007
  match: (command) => {
928
- const m = /^(?:take\s+a\s+note|make\s+a\s+note|note)\b[\s:,-]*(?:that\s+)?(.+)$/i.exec(command.trim());
1008
+ // "Take THE NOTES of whatever we spoke so far" — the plural, the definite article and
1009
+ // "of" instead of "that" were all misses, so the request went to the model, which spent
1010
+ // four tool calls hunting for a transcript before writing anything.
1011
+ const m = /^(?:(?:take|make|write|jot|add)\s+(?:down\s+)?(?:a\s+|the\s+|some\s+)?notes?|(?:write|jot)\s+down|notes?)\b[\s:,-]*(?:down\s+)?(?:that\s+|of\s+|on\s+|about\s+|from\s+)?(.+)$/i.exec(command.trim());
929
1012
  const text = m && tidy(m[1]);
930
1013
  return text ? { text } : null;
931
1014
  },
@@ -938,7 +1021,10 @@ export const monitorIntent = defineVoiceIntent({
938
1021
  examples: ['watch for whether we agree a date', 'keep an eye on the pricing question', 'track who owns the migration'],
939
1022
  classUsed: 'C', // it starts model turns for the rest of the meeting — say so
940
1023
  match: (command) => {
941
- const m = /^(?:watch\s+(?:out\s+)?for|watch|keep\s+an\s+eye\s+on|track|monitor)\b[\s:,-]*(?:whether\s+|if\s+|for\s+)?(.+)$/i.exec(command.trim());
1024
+ // "Start a live monitor about…" is how it was asked for in the very demo of the feature,
1025
+ // and it matched nothing: every pattern here began at the verb, so the noun form —
1026
+ // start/set up a monitor — fell through to the model and no card was ever created.
1027
+ const m = /^(?:(?:start|set\s+up|create|begin|add|open|run)\s+(?:a\s+|the\s+|an\s+)?(?:live\s+|new\s+)?(?:monitor|monitoring|watch|tracker)|watch\s+(?:out\s+)?for|watch|keep\s+an\s+eye\s+on|track|monitor|monitoring)\b[\s:,-]*(?:whether\s+|if\s+|for\s+|on\s+|about\s+|that\s+)?(.+)$/i.exec(command.trim());
942
1028
  const prompt = m && tidy(m[1]);
943
1029
  return prompt && prompt.length > 2 ? { prompt } : null;
944
1030
  },