@chatpanel/events 0.28.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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/voice-intents.js +52 -8
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chatpanel/events",
3
- "version": "0.28.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/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
  /**
@@ -608,13 +624,21 @@ const FRACTION = { half: 0.5, quarter: 0.25 };
608
624
  export function parseNumberWords(words) {
609
625
  if (!words.length) return null;
610
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;
611
635
  for (let i = 0; i < words.length; i++) {
612
636
  const w = words[i];
613
637
  if (w === 'and' || w === 'of') continue; // "two AND a half", "a quarter OF an hour"
614
638
  // "an hour" is one hour. "A quarter of an hour" is a quarter, and "two and A half" is
615
639
  // 2.5 — in both of those the article belongs to the fraction, not to the count.
616
640
  if (w === 'a' || w === 'an') {
617
- if (total === null && !(words[i + 1] in FRACTION)) total = 1;
641
+ if (total === null && !(words[i + 1] in FRACTION)) article = true;
618
642
  continue;
619
643
  }
620
644
  if (w in FRACTION) { total = (total ?? 0) + FRACTION[w]; continue; }
@@ -623,7 +647,7 @@ export function parseNumberWords(words) {
623
647
  if (/^\d+(?:\.\d+)?$/.test(w)) { total = (total ?? 0) + Number(w); continue; }
624
648
  return null;
625
649
  }
626
- return total;
650
+ return total ?? (article ? 1 : null);
627
651
  }
628
652
 
629
653
  const UNIT_MS = {
@@ -927,9 +951,23 @@ export const timerIntent = defineVoiceIntent({
927
951
  description: 'Starts a countdown and alerts when it finishes.',
928
952
  examples: ['set a timer for 10 minutes', 'start a 90 second timer', 'timer for an hour and a half'],
929
953
  match: (command, { now = Date.now() } = {}) => {
930
- if (!/\btimers?\b/i.test(command)) return null;
931
954
  const d = parseDuration(command);
932
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
+ }
933
971
  // "timer for the standup" — whatever is left once the duration and the plumbing words
934
972
  // are removed is what the timer is FOR, and a labelled timer is the difference between
935
973
  // three anonymous countdowns and three useful ones.
@@ -967,7 +1005,10 @@ export const noteIntent = defineVoiceIntent({
967
1005
  description: 'Appends a line to the meeting notes.',
968
1006
  examples: ['note that we agreed to ship on Friday', 'take a note: budget is approved'],
969
1007
  match: (command) => {
970
- 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());
971
1012
  const text = m && tidy(m[1]);
972
1013
  return text ? { text } : null;
973
1014
  },
@@ -980,7 +1021,10 @@ export const monitorIntent = defineVoiceIntent({
980
1021
  examples: ['watch for whether we agree a date', 'keep an eye on the pricing question', 'track who owns the migration'],
981
1022
  classUsed: 'C', // it starts model turns for the rest of the meeting — say so
982
1023
  match: (command) => {
983
- 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());
984
1028
  const prompt = m && tidy(m[1]);
985
1029
  return prompt && prompt.length > 2 ? { prompt } : null;
986
1030
  },