@chatpanel/events 0.16.0 → 0.18.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
@@ -45,6 +45,13 @@ export { defineModel, defineMiddleware, defineRouteStrategy, createModelRouter,
45
45
  export { makeSourceStore, manifestText, shortUrl, readSource, sourceId } from './sources-retrieval.js';
46
46
  export { classifySource, extractUrls, hostMatches, meetReach, sourcePolicyFor, DEFAULT_INTERNAL_PATTERNS, INTERNAL_PATTERN_CATALOG } from './sources.js';
47
47
  export { defineRule, createRuleEngine, SUPPRESSED, RuleError } from './rules.js';
48
+ export {
49
+ VAULT_VERSION, KDF_ITERATIONS, KDF_HASH, DEFAULT_LOCK_MS, VaultError,
50
+ MAX_TITLE_CHARS, MAX_NOTE_CHARS, MAX_SECRET_CHARS, MAX_ENTRIES,
51
+ createVault, unlockVault, deriveKey, sealEntry, openEntry,
52
+ validateEntry, entryMeta, searchEntries, isLocked, lockedSummary, canAddEntry,
53
+ toB64, fromB64,
54
+ } from './vault.js';
48
55
  export {
49
56
  SCHEDULE_KINDS, TRIGGER_KINDS, JOB_ACTIONS, MISSED_POLICIES, ScheduleError,
50
57
  validateSchedule, nextFireAt, occurrencesBetween, nextWakeAt,
@@ -59,7 +66,7 @@ export {
59
66
  compileWake, findWakeCommand, parseCommand, commandsFromSegments,
60
67
  parseDuration, parseClock, parseWhen, parseNumberWords, normalizeSpeech, tokenize, editDistance,
61
68
  defineVoiceIntent, createVoiceIntentRegistry, defaultVoiceIntents, BUILTIN_VOICE_INTENTS,
62
- timerIntent, reminderIntent, noteIntent, monitorIntent,
69
+ timerIntent, reminderIntent, scheduleIntent, noteIntent, monitorIntent,
63
70
  } from './voice-intents.js';
64
71
  export { explainMcpError, packageFromArgs } from './mcp-errors.js';
65
72
  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.18.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",
@@ -40,6 +40,7 @@
40
40
  "./tool-need.js": "./tool-need.js",
41
41
  "./trajectory.js": "./trajectory.js",
42
42
  "./upcast.js": "./upcast.js",
43
+ "./vault.js": "./vault.js",
43
44
  "./voice-intents.js": "./voice-intents.js",
44
45
  "./observability.js": "./observability.js",
45
46
  "./flowchart.js": "./flowchart.js",
@@ -88,6 +89,7 @@
88
89
  "tool-need.js",
89
90
  "trajectory.js",
90
91
  "upcast.js",
92
+ "vault.js",
91
93
  "voice-intents.js",
92
94
  "view.js",
93
95
  "widget.js"
package/schedule.js CHANGED
@@ -250,6 +250,9 @@ export const questionTrigger = defineTrigger({
250
250
  watches: ['meeting.transcript.delta'],
251
251
  matches: (event, params = {}, ctx = {}) => {
252
252
  for (const seg of event.segments || []) {
253
+ // Other people by default: this exists for reacting to what someone ELSE asks (an
254
+ // interview, a customer call). The job form offers the choice, so 'anyone' and 'me' are
255
+ // one dropdown away rather than an invisible default nobody can reach.
253
256
  if (!speakerAllowed(params.speaker || 'others', seg.speaker, ctx)) continue;
254
257
  const text = String(seg.text || '').trim();
255
258
  if (text.length < 8) continue; // "what?" is not a question worth waking a model for
package/vault.js ADDED
@@ -0,0 +1,250 @@
1
+ // The vault — secrets that survive a copied disk.
2
+ //
3
+ // ChatPanel already encrypts two things at rest (meeting transcripts, stored API keys), and
4
+ // both of those modules say the same thing in their own comments: the key sits in storage
5
+ // beside the data, so it is obfuscation, not protection, and the real answer is "a key
6
+ // derived from a user passphrase and never written to disk". This is that answer.
7
+ //
8
+ // WHAT IS DIFFERENT HERE, and why it justifies a third crypto path rather than a fourth
9
+ // caller of the second one:
10
+ //
11
+ // • The key is derived from a passphrase and lives ONLY in session memory. Disk holds
12
+ // ciphertext, the KDF parameters, and a verifier — nothing that yields the key.
13
+ // • It FAILS CLOSED. The other two deliberately fail open: a crypto hiccup must never lose
14
+ // someone's API key, so they fall back to plaintext. A vault that quietly writes
15
+ // plaintext when something goes wrong is not a vault, so every failure here is an error
16
+ // the caller has to handle.
17
+ // • EVERYTHING is encrypted, titles included. A list of entry names is a list of the
18
+ // accounts someone has — "Chatpanel", "bank", "ex-employer" — which is most of what an
19
+ // attacker wanted. So a locked vault can say how many entries it holds and nothing else,
20
+ // and search runs over decrypted entries in memory, after unlocking.
21
+ //
22
+ // Pure and portable: the crypto is WebCrypto, which the browser, Node and a mobile runtime
23
+ // all have, and `subtle`/`random`/`now` are injected so this is testable and replayable. The
24
+ // envelope is versioned and self-describing — a vault written by the extension today must
25
+ // open in a phone app in two years, and the iteration count must be raisable without
26
+ // stranding anyone's data.
27
+
28
+ export class VaultError extends Error {
29
+ constructor(code, message) { super(message); this.name = 'VaultError'; this.code = code; }
30
+ }
31
+
32
+ export const VAULT_VERSION = 1;
33
+
34
+ // PBKDF2-SHA256. Higher than the backup envelope's 250k because a vault is a standing
35
+ // target rather than a file someone chose to export, and unlocking is a once-per-session
36
+ // cost a person is already waiting through. The parameters travel WITH the vault, so this
37
+ // number can rise later without breaking anything already written.
38
+ export const KDF_ITERATIONS = 310_000;
39
+ export const KDF_HASH = 'SHA-256';
40
+
41
+ export const MAX_TITLE_CHARS = 200;
42
+ export const MAX_NOTE_CHARS = 20_000;
43
+ export const MAX_SECRET_CHARS = 8_000;
44
+ export const MAX_ENTRIES = 2_000;
45
+
46
+ // What the verifier proves: that the passphrase derives the same key as last time. It is a
47
+ // constant sealed under the key, so a wrong passphrase fails the AES-GCM tag check and is
48
+ // TOLD APART from an empty vault — "wrong passphrase" and "nothing here" must never look
49
+ // alike to a user staring at an empty list.
50
+ const VERIFIER_PLAINTEXT = 'chatpanel-vault-v1';
51
+
52
+ const enc = new TextEncoder();
53
+ const dec = new TextDecoder();
54
+
55
+ const B64_CHUNK = 0x8000;
56
+ export function toB64(bytes) {
57
+ const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
58
+ let binary = '';
59
+ for (let i = 0; i < view.length; i += B64_CHUNK) binary += String.fromCharCode(...view.subarray(i, i + B64_CHUNK));
60
+ return btoa(binary);
61
+ }
62
+ export function fromB64(s) {
63
+ const binary = atob(String(s || ''));
64
+ const out = new Uint8Array(binary.length);
65
+ for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i);
66
+ return out;
67
+ }
68
+
69
+ function subtleOf(subtle) {
70
+ const s = subtle || globalThis.crypto?.subtle;
71
+ if (!s) throw new VaultError('NO_CRYPTO', 'WebCrypto is unavailable in this runtime');
72
+ return s;
73
+ }
74
+ function randomOf(random) {
75
+ const r = random || ((n) => globalThis.crypto.getRandomValues(new Uint8Array(n)));
76
+ return r;
77
+ }
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // Entries
81
+ // ---------------------------------------------------------------------------
82
+
83
+ const str = (v) => typeof v === 'string';
84
+
85
+ /**
86
+ * An entry is a title, an optional note, and an optional secret. The secret is not special
87
+ * to the crypto — everything is sealed together — it is special to the UI, which must not
88
+ * put it on screen without being asked.
89
+ */
90
+ export function validateEntry(entry) {
91
+ if (!entry || typeof entry !== 'object') throw new VaultError('SHAPE', 'entry must be an object');
92
+ if (!str(entry.title) || !entry.title.trim()) throw new VaultError('SHAPE', 'entry.title required');
93
+ if (entry.title.length > MAX_TITLE_CHARS) throw new VaultError('SHAPE', `entry.title exceeds ${MAX_TITLE_CHARS} chars`);
94
+ if (entry.note != null && (!str(entry.note) || entry.note.length > MAX_NOTE_CHARS)) {
95
+ throw new VaultError('SHAPE', `entry.note must be a string under ${MAX_NOTE_CHARS} chars`);
96
+ }
97
+ if (entry.secret != null && (!str(entry.secret) || entry.secret.length > MAX_SECRET_CHARS)) {
98
+ throw new VaultError('SHAPE', `entry.secret must be a string under ${MAX_SECRET_CHARS} chars`);
99
+ }
100
+ return entry;
101
+ }
102
+
103
+ /**
104
+ * What may be shown, logged or handed to a widget WITHOUT revealing anything.
105
+ *
106
+ * `hasSecret` rather than the secret, and never the note: a note is where people put the
107
+ * answers to their security questions. This is the shape every list, search result and log
108
+ * line uses, so there is one definition of "safe to show" instead of one per caller.
109
+ */
110
+ export function entryMeta(entry) {
111
+ return {
112
+ id: entry.id,
113
+ title: entry.title,
114
+ hasSecret: !!entry.secret,
115
+ updatedAt: entry.updatedAt || 0,
116
+ createdAt: entry.createdAt || 0,
117
+ };
118
+ }
119
+
120
+ /** Search decrypted entries. Titles and notes match; secrets are never searched. */
121
+ export function searchEntries(entries, query) {
122
+ const q = String(query || '').trim().toLowerCase();
123
+ if (!q) return entries;
124
+ // Searching secret material would leak it through timing and through "1 result" on a page
125
+ // that never shows the secret — and nobody searches for a password they already have.
126
+ return entries.filter((e) => `${e.title || ''}\n${e.note || ''}`.toLowerCase().includes(q));
127
+ }
128
+
129
+ // ---------------------------------------------------------------------------
130
+ // Keys, sealing, opening
131
+ // ---------------------------------------------------------------------------
132
+
133
+ export async function deriveKey(passphrase, salt, iterations = KDF_ITERATIONS, { subtle } = {}) {
134
+ const s = subtleOf(subtle);
135
+ if (!str(passphrase) || !passphrase) throw new VaultError('NO_PASSPHRASE', 'a passphrase is required');
136
+ const base = await s.importKey('raw', enc.encode(passphrase), 'PBKDF2', false, ['deriveKey']);
137
+ return s.deriveKey(
138
+ { name: 'PBKDF2', salt, iterations, hash: KDF_HASH },
139
+ base,
140
+ { name: 'AES-GCM', length: 256 },
141
+ // Extractable, because the host has to hand this key to session storage to survive the
142
+ // panel being closed and reopened. It never touches disk — see the client's session-only
143
+ // storage — and a non-extractable key would force a re-prompt on every panel open, which
144
+ // is the kind of friction that makes people choose a weaker passphrase.
145
+ true,
146
+ ['encrypt', 'decrypt'],
147
+ );
148
+ }
149
+
150
+ async function seal(key, plaintextString, { subtle, random } = {}) {
151
+ const s = subtleOf(subtle);
152
+ const iv = randomOf(random)(12); // fresh per seal — an IV reused under one key breaks GCM
153
+ const ct = await s.encrypt({ name: 'AES-GCM', iv }, key, enc.encode(plaintextString));
154
+ return { iv: toB64(iv), ct: toB64(ct) };
155
+ }
156
+
157
+ async function open(key, sealed, { subtle } = {}) {
158
+ const s = subtleOf(subtle);
159
+ if (!sealed?.iv || !sealed?.ct) throw new VaultError('CORRUPT', 'sealed value is missing its iv or ciphertext');
160
+ try {
161
+ const out = await s.decrypt({ name: 'AES-GCM', iv: fromB64(sealed.iv) }, key, fromB64(sealed.ct));
162
+ return dec.decode(out);
163
+ } catch {
164
+ // AES-GCM authenticates: this is either the wrong key or tampered bytes, and the caller
165
+ // cannot tell which. Both mean "do not trust what came back", which is the only thing
166
+ // the caller needs to act on.
167
+ throw new VaultError('BAD_KEY', 'wrong passphrase, or the data has been altered');
168
+ }
169
+ }
170
+
171
+ /** A brand-new, empty vault. Returns what is safe to write to disk. */
172
+ export async function createVault(passphrase, { subtle, random, now = () => Date.now() } = {}) {
173
+ const salt = randomOf(random)(16);
174
+ const key = await deriveKey(passphrase, salt, KDF_ITERATIONS, { subtle });
175
+ const verifier = await seal(key, VERIFIER_PLAINTEXT, { subtle, random });
176
+ return {
177
+ vault: {
178
+ version: VAULT_VERSION,
179
+ kdf: { name: 'PBKDF2', hash: KDF_HASH, iterations: KDF_ITERATIONS, salt: toB64(salt) },
180
+ verifier,
181
+ entries: {},
182
+ createdAt: now(),
183
+ },
184
+ key,
185
+ };
186
+ }
187
+
188
+ /**
189
+ * Derive the key and PROVE it is the right one before the caller stores it.
190
+ *
191
+ * Without the proof, a typo would be discovered later, one entry at a time, as
192
+ * "corrupt data" — and a user would reasonably conclude their vault was destroyed.
193
+ */
194
+ export async function unlockVault(vault, passphrase, { subtle } = {}) {
195
+ if (!vault?.kdf?.salt) throw new VaultError('NO_VAULT', 'no vault has been created yet');
196
+ if (vault.version > VAULT_VERSION) {
197
+ // Forward-compat is a promise in one direction only. Opening a newer vault with older
198
+ // rules risks writing back something the newer client cannot read.
199
+ throw new VaultError('TOO_NEW', 'this vault was written by a newer version of ChatPanel');
200
+ }
201
+ const key = await deriveKey(passphrase, fromB64(vault.kdf.salt), vault.kdf.iterations || KDF_ITERATIONS, { subtle });
202
+ await open(key, vault.verifier, { subtle }); // throws BAD_KEY on a wrong passphrase
203
+ return key;
204
+ }
205
+
206
+ /** Seal one entry. The whole entry, titles included — see the note at the top. */
207
+ export async function sealEntry(key, entry, { subtle, random, now = () => Date.now() } = {}) {
208
+ validateEntry(entry);
209
+ const at = now();
210
+ const full = { ...entry, updatedAt: at, createdAt: entry.createdAt || at };
211
+ const sealed = await seal(key, JSON.stringify(full), { subtle, random });
212
+ // updatedAt is deliberately OUTSIDE the ciphertext as well: a list has to sort without
213
+ // unlocking, and "something changed at 14:02" is not a secret worth the cost of hiding.
214
+ return { id: entry.id, updatedAt: at, ...sealed };
215
+ }
216
+
217
+ export async function openEntry(key, record, { subtle } = {}) {
218
+ const json = await open(key, record, { subtle });
219
+ try {
220
+ const entry = JSON.parse(json);
221
+ return { ...entry, id: record.id ?? entry.id };
222
+ } catch {
223
+ throw new VaultError('CORRUPT', 'entry could not be read');
224
+ }
225
+ }
226
+
227
+ // ---------------------------------------------------------------------------
228
+ // Locking
229
+ // ---------------------------------------------------------------------------
230
+
231
+ export const DEFAULT_LOCK_MS = 15 * 60_000;
232
+
233
+ /**
234
+ * Auto-lock is measured from the last USE, not from the unlock: a vault someone is actively
235
+ * working in should not lock under their hands, and one they walked away from should.
236
+ */
237
+ export function isLocked({ unlockedAt = 0, lastUsedAt = 0, now = Date.now(), timeoutMs = DEFAULT_LOCK_MS } = {}) {
238
+ if (!unlockedAt) return true;
239
+ if (!timeoutMs) return false; // 0 = stay unlocked until the session ends or the user locks
240
+ return now - Math.max(unlockedAt, lastUsedAt) >= timeoutMs;
241
+ }
242
+
243
+ /** How many entries a locked vault may admit to holding. A count is not a secret; names are. */
244
+ export function lockedSummary(vault) {
245
+ return { exists: !!vault?.kdf, entries: Object.keys(vault?.entries || {}).length, locked: true };
246
+ }
247
+
248
+ export function canAddEntry(vault) {
249
+ return Object.keys(vault?.entries || {}).length < MAX_ENTRIES;
250
+ }
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
  }