@chatpanel/events 0.30.0 → 0.32.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.
@@ -170,6 +170,54 @@ export function pickCaptionTrack(tracks, { language = '', languages = ['en'] } =
170
170
  .sort((a, b) => b.s - a.s)[0].t;
171
171
  }
172
172
 
173
+ // --------------------------------------------------------------------------
174
+ // The InnerTube player request — how a caption URL that WORKS is obtained
175
+ // --------------------------------------------------------------------------
176
+ //
177
+ // The caption URLs printed into the watch page's HTML answer HTTP 200 with an EMPTY BODY —
178
+ // measured on every video tried, with and without session cookies, Referer and Origin. The
179
+ // ones returned by the InnerTube player endpoint for the ANDROID client do not.
180
+ //
181
+ // AND THE CLIENT VERSION IS THE WHOLE DIFFERENCE, which is worth stating because it is
182
+ // invisible and it will go stale:
183
+ //
184
+ // clientVersion 20.10.38 -> 1 track, 60,441 bytes of captions
185
+ // clientVersion 19.09.37 -> no captionTracks at all
186
+ // clientVersion 17.31.35 -> no captionTracks at all
187
+ //
188
+ // A stale version does not error. It returns a well-formed player response with the
189
+ // `captions` block missing, which reads exactly like "this video has no subtitles" — so the
190
+ // failure mode of letting this rot is a feature that quietly claims videos have no captions.
191
+ // tests/media-transcript.test.js pins the shape; a live check is the client's job.
192
+
193
+ /** The InnerTube client whose player response carries usable caption URLs. */
194
+ export const INNERTUBE_ANDROID = Object.freeze({ clientName: 'ANDROID', clientVersion: '20.10.38' });
195
+
196
+ /** The public InnerTube key is printed into every watch page; it is not a secret. */
197
+ export function innertubeApiKeyFromHtml(html) {
198
+ const m = /"INNERTUBE_API_KEY":\s*"([^"]+)"/.exec(String(html || ''))
199
+ || /INNERTUBE_API_KEY\\":\\"([^\\"]+)/.exec(String(html || ''));
200
+ return m ? m[1] : '';
201
+ }
202
+
203
+ /**
204
+ * The request to make, as data — so the caller performs it wherever its network is.
205
+ *
206
+ * Returned rather than sent for the same reason nothing else here fetches: the extension, the
207
+ * bridge and a mobile client each have their own idea of what "fetch" means, and this file
208
+ * has to run in all three.
209
+ */
210
+ export function innertubePlayerRequest(videoId, { apiKey = '', client = INNERTUBE_ANDROID } = {}) {
211
+ if (!videoId) return null;
212
+ const query = apiKey ? `?key=${encodeURIComponent(apiKey)}` : '';
213
+ return {
214
+ url: `https://www.youtube.com/youtubei/v1/player${query}`,
215
+ method: 'POST',
216
+ headers: { 'content-type': 'application/json' },
217
+ body: JSON.stringify({ context: { client: { ...client } }, videoId }),
218
+ };
219
+ }
220
+
173
221
  /**
174
222
  * Ask a caption URL for a specific serialisation.
175
223
  *
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@chatpanel/events",
3
- "version": "0.30.0",
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.",
3
+ "version": "0.32.0",
4
+ "description": "The canonical ChatPanel event-log and capability contracts — 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",
7
7
  "exports": {
package/voice-intents.js CHANGED
@@ -233,13 +233,35 @@ export function findWakeCommands(text, wake = compileWake(), { maxSentences = MA
233
233
  const tokens = tokenize(raw);
234
234
  if (!tokens.length) return [];
235
235
  const hits = wakeHits(raw, tokens, wake);
236
- return hits.map((hit, idx) => {
237
- // Bounded by the NEXT address, then by sentence count. A second wake word is the end of
238
- // the first command however the sentences fall.
239
- const stop = idx + 1 < hits.length ? hits[idx + 1].start : raw.length;
236
+ const out = [];
237
+ // How far the last command emitted reaches. A wake phrase the speaker used as a WORD, inside
238
+ // a command already running, is part of that command and not a second one.
239
+ let covered = 0;
240
+ for (let idx = 0; idx < hits.length; idx++) {
241
+ const hit = hits[idx];
242
+ // THE PRODUCT'S OWN NAME IS A WORD PEOPLE SAY.
243
+ //
244
+ // "Okay chat panel, take the notes of whatever we spoke so far in chat panel notes" holds
245
+ // the wake phrase twice: once as an address, once as the name of where to put them. The
246
+ // second was treated as a fresh address, which did two things and both were wrong — it cut
247
+ // the command down to "…so far", losing where they wanted the notes, and it emitted
248
+ // "notes. So that is good." as a command of its own, which noteIntent matched. One spoken
249
+ // request became two notes and a truncated one.
250
+ //
251
+ // A mention that is not an address and falls inside the command already being carried is
252
+ // skipped. A mention that stands on its own still gets through — the intent match is the
253
+ // safety net for an address this heuristic misjudged, and dropping those outright would
254
+ // trade a stray action for a lost one.
255
+ if (!hit.addressed && hit.start < covered) continue;
256
+ // Bounded by the next ADDRESS, then by sentence count. Deliberately the next address and
257
+ // not the next mention: a second wake word is the end of the first command only when the
258
+ // speaker was turning to us again, and the sentence they are still saying is not that.
259
+ let stop = raw.length;
260
+ for (let j = idx + 1; j < hits.length; j++) if (hits[j].addressed) { stop = hits[j].start; break; }
240
261
  const span = raw.slice(hit.end, stop);
241
262
  const command = trimTrailingLeadIn(stripLeadIn(firstSentences(span, maxSentences)).trim());
242
- return {
263
+ covered = Math.max(covered, hit.end + span.indexOf(command) + command.length);
264
+ out.push({
243
265
  command,
244
266
  wake: hit.phrase,
245
267
  heard: raw.slice(hit.start, hit.end),
@@ -248,8 +270,9 @@ export function findWakeCommands(text, wake = compileWake(), { maxSentences = MA
248
270
  // What was said after the command's own sentences, up to the next address. Not part of
249
271
  // the command — kept so a caller refining with a model has the surrounding words.
250
272
  rest: raw.slice(hit.end + span.indexOf(command) + command.length, stop).trim(),
251
- };
252
- });
273
+ });
274
+ }
275
+ return out;
253
276
  }
254
277
 
255
278
  /**
@@ -380,7 +403,12 @@ export const REFINEMENT_SCHEMA = defineSchema({
380
403
  name: { type: 'string', max: 48, describe: 'a label of at most 6 words' },
381
404
  kind: {
382
405
  type: 'enum',
383
- values: ['question', 'monitor', 'note', 'skill', 'timer', 'none'],
406
+ // 'action' is here because it was MISSING, and the gap was silent. A browser command
407
+ // ("go to google.com and search for chat panel") is not something they want to KNOW, so
408
+ // it does not read as a question — and the only other bucket that fitted a rambling,
409
+ // narrated demo was "none", which is dropped without a word. Spoken four different ways
410
+ // in one meeting, it did nothing every time while the timer beside it worked.
411
+ values: ['question', 'action', 'monitor', 'note', 'skill', 'timer', 'none'],
384
412
  // An unknown kind becomes a QUESTION — the least surprising thing to do with something
385
413
  // someone asked for, and the only kind that is undone by ignoring the answer. Guessing
386
414
  // "monitor" instead would leave a card watching the meeting that nobody asked for.
@@ -405,6 +433,7 @@ export function refinementPrompt(utterance) {
405
433
  '',
406
434
  'Pick the SMALLEST kind that does what they asked:',
407
435
  ' question — answer it once, now. The DEFAULT for anything they want to know.',
436
+ ' action — DO something in the browser or an app ("go to google.com and search for X").',
408
437
  ' monitor — only if they asked to be told as the meeting CONTINUES ("let me know if",',
409
438
  ' "keep an eye on"). A one-off question is NOT a monitor.',
410
439
  ' note — they asked for notes written down ("take notes on", "write that up").',
@@ -474,6 +503,36 @@ export function settleRefinement(v) {
474
503
  return { request, name, kind, skill: v.skill || '' };
475
504
  }
476
505
 
506
+ /**
507
+ * Did the user's own spoken words name this host?
508
+ *
509
+ * The authority test for a hands-free browser command. A URL a MODEL picked is
510
+ * attacker-influenced by construction — it has been reading page text and meeting captions —
511
+ * so it gets a confirmation dialog. A URL whose host the USER said out loud has already been
512
+ * reviewed by the only person that dialog would have asked, and a modal in a side panel is
513
+ * exactly what nobody in a meeting is looking at: "go to google.com and search for chat
514
+ * panel" was spoken four ways in one call and did nothing every time.
515
+ *
516
+ * Deliberately strict. The full hostname always counts ("google.com"). The bare first label
517
+ * counts ONLY for a two-label host, so saying "docs" can never authorise `docs.evil.test` —
518
+ * anything deeper has to be said in full.
519
+ *
520
+ * Shared rather than written in the panel because it is a pure decision with no platform in
521
+ * it: the bridge relays the same page tools, and a second copy of an authority rule is a
522
+ * second answer to "may this happen".
523
+ */
524
+ export function spokenNamesHost(url, spoken) {
525
+ const said = String(spoken || '').toLowerCase();
526
+ if (!said) return false;
527
+ let host;
528
+ try { host = new URL(url).hostname.toLowerCase().replace(/^www\./, ''); } catch { return false; }
529
+ if (!host) return false;
530
+ if (said.includes(host)) return true;
531
+ const labels = host.split('.');
532
+ if (labels.length !== 2) return false;
533
+ return new RegExp(`\\b${labels[0].replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`).test(said);
534
+ }
535
+
477
536
  /**
478
537
  * The same answer, AS IT ARRIVES.
479
538
  *