@chatpanel/events 0.16.0 → 0.17.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 CHANGED
@@ -59,7 +59,7 @@ export {
59
59
  compileWake, findWakeCommand, parseCommand, commandsFromSegments,
60
60
  parseDuration, parseClock, parseWhen, parseNumberWords, normalizeSpeech, tokenize, editDistance,
61
61
  defineVoiceIntent, createVoiceIntentRegistry, defaultVoiceIntents, BUILTIN_VOICE_INTENTS,
62
- timerIntent, reminderIntent, noteIntent, monitorIntent,
62
+ timerIntent, reminderIntent, scheduleIntent, noteIntent, monitorIntent,
63
63
  } from './voice-intents.js';
64
64
  export { explainMcpError, packageFromArgs } from './mcp-errors.js';
65
65
  export { createManifest, ManifestError, SOURCES } from './manifest.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chatpanel/events",
3
- "version": "0.16.0",
3
+ "version": "0.17.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
@@ -538,7 +538,37 @@ export const monitorIntent = defineVoiceIntent({
538
538
  },
539
539
  });
540
540
 
541
- export const BUILTIN_VOICE_INTENTS = Object.freeze([timerIntent, reminderIntent, noteIntent, monitorIntent]);
541
+ // "Every weekday at 8am run my daily brief." The recurrence parser already existed for
542
+ // reminders; what makes this different is that the thing being scheduled is WORK — a skill
543
+ // the user already wrote — so the job says only when, and the skill stays the single
544
+ // definition of what. Declared class C because it will start a model turn every time.
545
+ export const scheduleIntent = defineVoiceIntent({
546
+ id: 'voice:schedule',
547
+ label: 'Schedule something',
548
+ description: 'Runs one of your skills (or a plain instruction) on a schedule.',
549
+ examples: ['every weekday at 8am run my daily brief', 'run the standup summary every morning', 'tomorrow at 9 do the release checklist'],
550
+ classUsed: 'C',
551
+ match: (command, { now = Date.now() } = {}) => {
552
+ const verb = /\b(run|do|start|kick\s+off|execute)\b/i.exec(command);
553
+ if (!verb) return null;
554
+ const when = parseWhen(command, { now });
555
+ // No time is not a schedule — it is a request to do something now, which is a chat
556
+ // message, not a job. Refusing here is what keeps "run the checklist" out of the
557
+ // scheduler.
558
+ if (!when) return null;
559
+ let target = when.end > when.start
560
+ ? command.slice(0, when.start) + ' ' + command.slice(when.end)
561
+ : command;
562
+ const v = /\b(run|do|start|kick\s+off|execute)\b/i.exec(target);
563
+ target = tidy((v ? target.slice(v.index + v[0].length) : target)
564
+ .replace(/^\s*(?:my|the|our)\b/i, '')
565
+ .replace(/\b(skill|job|task)\b\s*$/i, ''));
566
+ if (!target) return null;
567
+ return { target, at: when.at, recurrence: when.recurrence, when: when.kind };
568
+ },
569
+ });
570
+
571
+ export const BUILTIN_VOICE_INTENTS = Object.freeze([timerIntent, reminderIntent, scheduleIntent, noteIntent, monitorIntent]);
542
572
 
543
573
  function tidy(s) {
544
574
  return String(s || '').replace(/\s+/g, ' ').replace(/^[\s,.:;-]+|[\s,.:;-]+$/g, '').trim();
@@ -596,6 +626,15 @@ export function commandsFromSegments(segments, {
596
626
  if (seg.t && seg.t <= sinceTs) continue;
597
627
  const parsed = parseCommand(seg.text, { wake, intents, now });
598
628
  if (!parsed) continue;
629
+ // NO INTENT, NO ACTION. parseCommand returns a shape for anything that carries the wake
630
+ // word and a time-ish phrase, intent included or not — so "we should talk about the chat
631
+ // panel roadmap next week" came back as a command with intent:null and the caller acted
632
+ // on it anyway. In a live meeting that means ordinary conversation quietly sets timers,
633
+ // which is what happened: a caption grows, keeps matching, and fires again.
634
+ //
635
+ // An automation that runs when it did not understand the request is worse than one that
636
+ // does nothing, so an unrecognised utterance stops here.
637
+ if (!parsed.intent) continue;
599
638
  const allowed = isSelf ? !!isSelf(seg.speaker) : false;
600
639
  out.push({
601
640
  ...parsed,
@@ -603,9 +642,21 @@ export function commandsFromSegments(segments, {
603
642
  speaker: seg.speaker || '',
604
643
  t: seg.t || now,
605
644
  meetingId,
606
- // Stable across redeliveries of the same segment, so the rule engine's dedup does its
607
- // job: a flush that resends the last ten seconds must not set two timers.
608
- key: `voice:${meetingId}:${seg.t || 0}:${parsed.at}:${parsed.intent || 'unknown'}`,
645
+ // Stable across redeliveries of the same segment, so the dedupe actually dedupes.
646
+ //
647
+ // `parsed.at` used to be in this key, and it is an ABSOLUTE time computed as now + the
648
+ // spoken duration — so it changed on every scan. A live caption is rescanned as the
649
+ // sentence grows (deliberately: a half-heard command must get a second chance), which
650
+ // meant one "set a timer for 10 seconds" produced a brand-new key, and a brand-new
651
+ // timer, on every caption update — indefinitely, and faster than the user could delete
652
+ // them. The key now carries only what the same utterance keeps: where it was said, and
653
+ // what it asked for.
654
+ // IDENTITY, NOT FRESHNESS. `seg.t` is bumped every time a live caption's text grows —
655
+ // that is what keeps the line flowing through the delta filter — so keying on it made
656
+ // one spoken request look like a new request on every update, and a single "set a timer
657
+ // for 30 seconds" became a screenful of timers. `sid` is assigned once per utterance and
658
+ // never moves, so the same sentence keeps one key however many times it is rescanned.
659
+ key: `voice:${meetingId}:${seg.sid || seg.t || 0}:${parsed.intent || 'unknown'}:${parsed.ms ?? parsed.when ?? ''}`,
609
660
  });
610
661
  if (out.length >= max) break; // a pathological transcript cannot fire fifty actions
611
662
  }