@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 +1 -1
- package/schedule.js +5 -0
- package/voice-intents.js +97 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/events",
|
|
3
|
-
"version": "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
|
-
|
|
458
|
-
|
|
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
|
-
|
|
521
|
-
|
|
522
|
-
|
|
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))
|
|
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
|
-
|
|
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
|
-
|
|
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
|
},
|