@chatpanel/events 0.29.0 → 0.31.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
@@ -34,6 +34,18 @@ export { parseFlowchart, layoutFlowchart, renderFlowchartSvg } from './flowchart
34
34
  export { validateView, validateViewInvocation, viewResult } from './view.js';
35
35
  export { validateWidget, validateWidgetMessage, effectiveGrants, widgetIcon, WIDGET_SURFACES } from './widget.js';
36
36
  export { fuseRRF, planQueries, multiSearch } from './rrf.js';
37
+ export {
38
+ PDF_MAX_CHARS, linesFromItems, orderLines, paragraphsFromLines,
39
+ pageTextFromItems, looksScanned, buildPdfDocument,
40
+ } from './pdf-layout.js';
41
+ export {
42
+ YOUTUBE_HOSTS, TRANSCRIPT_MAX_CHARS,
43
+ parseYouTubeUrl, isYouTubeUrl,
44
+ captionTracksFromPlayerResponse, videoMetaFromPlayerResponse,
45
+ pickCaptionTrack, timedTextUrl, parseTimedText,
46
+ groupSegments, formatTimestamp, formatTranscript,
47
+ buildTranscriptDocument, transcriptFromTracks,
48
+ } from './media-transcript.js';
37
49
  export {
38
50
  ACCESS_LOG_VERSION, ACCESS_LOG_MAX, redactAccessArgs, makeAccessEvent,
39
51
  createAccessLog, makeStorageTier, formatBytes,
@@ -0,0 +1,535 @@
1
+ // Video transcripts, as a contract rather than as a scraper.
2
+ //
3
+ // "Summarise this video" is the same question as "summarise this page" — the only
4
+ // difference is where the words live. On a video page the words are NOT in the DOM: the
5
+ // article body a reader would summarise is a caption track, fetched separately, published
6
+ // in four different serialisations, and duplicated once per language plus once more for
7
+ // the machine-generated version.
8
+ //
9
+ // So the CHOOSING and the PARSING live here, not in a client:
10
+ // • which of eleven caption tracks is the one the user meant (manual over ASR, their
11
+ // language over the uploader's, an explicit ask over both);
12
+ // • json3 / srv3 / srv1 XML / WebVTT / SRT → one segment list;
13
+ // • segments → readable paragraphs with timestamps you can cite and click.
14
+ //
15
+ // None of that needs a browser, and every client will need all of it: the extension reads
16
+ // the tab, the bridge may be handed a URL by a CLI agent, the gateway may be asked to
17
+ // summarise one server-side, and a mobile app has no DOM to scrape at all. Written inside
18
+ // the extension it would be copied three times and would disagree three ways about which
19
+ // track is "the" transcript.
20
+ //
21
+ // WHAT IS NOT HERE: fetching. Every platform gates caption URLs on the session that asked
22
+ // (cookies, origin, a consent cookie, a per-load token), so the FETCH has to happen where
23
+ // that session is — in the page for the extension, behind the user's own credentials for a
24
+ // CLI. Callers pass a `fetchText` in; this module never reaches the network, which is also
25
+ // what keeps it testable and dependency-free.
26
+
27
+ /** Hostnames that serve YouTube watch pages. */
28
+ export const YOUTUBE_HOSTS = Object.freeze([
29
+ 'youtube.com', 'www.youtube.com', 'm.youtube.com', 'music.youtube.com',
30
+ 'youtube-nocookie.com', 'www.youtube-nocookie.com', 'youtu.be',
31
+ ]);
32
+
33
+ const VIDEO_ID_RE = /^[A-Za-z0-9_-]{11}$/;
34
+
35
+ /** `1h2m3s`, `90s`, `90` → seconds. Returns 0 for anything unparseable. */
36
+ function parseTimeParam(raw) {
37
+ const s = String(raw || '').trim();
38
+ if (!s) return 0;
39
+ if (/^\d+$/.test(s)) return Number(s);
40
+ const m = /^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/.exec(s);
41
+ if (!m || !(m[1] || m[2] || m[3])) return 0;
42
+ return Number(m[1] || 0) * 3600 + Number(m[2] || 0) * 60 + Number(m[3] || 0);
43
+ }
44
+
45
+ /**
46
+ * Recognise a YouTube video URL in any of the shapes people actually paste.
47
+ *
48
+ * Every surface has its own: /watch?v=, youtu.be/, /shorts/, /embed/, /live/, /v/, and the
49
+ * mobile and music hosts on top. Matching only /watch?v= — the one everybody writes first —
50
+ * silently drops Shorts, which is most of what gets pasted into a chat.
51
+ *
52
+ * @returns {{videoId: string, start: number, url: string} | null}
53
+ */
54
+ export function parseYouTubeUrl(input) {
55
+ let u;
56
+ try {
57
+ u = new URL(String(input || '').trim());
58
+ } catch {
59
+ return null;
60
+ }
61
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') return null;
62
+ const host = u.hostname.toLowerCase().replace(/^www\./, '');
63
+ const known = YOUTUBE_HOSTS.some((h) => host === h.replace(/^www\./, ''));
64
+ if (!known) return null;
65
+
66
+ let id = '';
67
+ if (host === 'youtu.be') {
68
+ id = u.pathname.split('/').filter(Boolean)[0] || '';
69
+ } else {
70
+ const parts = u.pathname.split('/').filter(Boolean);
71
+ if (parts[0] === 'watch') id = u.searchParams.get('v') || '';
72
+ else if (['shorts', 'embed', 'live', 'v'].includes(parts[0])) id = parts[1] || '';
73
+ }
74
+ if (!VIDEO_ID_RE.test(id)) return null;
75
+ const start = parseTimeParam(u.searchParams.get('t') || u.searchParams.get('start'));
76
+ return { videoId: id, start, url: `https://www.youtube.com/watch?v=${id}` };
77
+ }
78
+
79
+ export function isYouTubeUrl(input) {
80
+ return parseYouTubeUrl(input) !== null;
81
+ }
82
+
83
+ // --------------------------------------------------------------------------
84
+ // Track selection
85
+ // --------------------------------------------------------------------------
86
+
87
+ /** Normalise one YouTube captionTracks entry into the shape the rest of this file uses. */
88
+ function normalizeTrack(t) {
89
+ if (!t || typeof t !== 'object') return null;
90
+ const baseUrl = String(t.baseUrl || t.url || '');
91
+ if (!baseUrl) return null;
92
+ const name = t.name?.simpleText
93
+ || t.name?.runs?.map((r) => r.text).join('')
94
+ || String(t.label || '');
95
+ return {
96
+ baseUrl,
97
+ lang: String(t.languageCode || t.lang || '').toLowerCase(),
98
+ name: name || '',
99
+ // `asr` is YouTube's marker for the machine-generated track. It is usually the ONLY
100
+ // track on a video, so it must never be filtered out — only ranked below a human one.
101
+ generated: String(t.kind || '') === 'asr' || /auto-generated/i.test(name),
102
+ translatable: t.isTranslatable !== false,
103
+ };
104
+ }
105
+
106
+ /**
107
+ * Pull the caption tracks (and the video's own metadata) out of a YouTube player response.
108
+ *
109
+ * The player response is the JSON blob the watch page hands its player. Reading captions
110
+ * from it is what every open-source transcript library does, because it is the only place
111
+ * the *signed* caption URLs exist — they carry an expiring signature, so a URL guessed
112
+ * from the video id is rejected, and one copied from a previous load has expired.
113
+ */
114
+ export function captionTracksFromPlayerResponse(pr) {
115
+ const list = pr?.captions?.playerCaptionsTracklistRenderer?.captionTracks;
116
+ if (!Array.isArray(list)) return [];
117
+ return list.map(normalizeTrack).filter(Boolean);
118
+ }
119
+
120
+ export function videoMetaFromPlayerResponse(pr) {
121
+ const d = pr?.videoDetails || {};
122
+ const videoId = String(d.videoId || '');
123
+ const seconds = Number(d.lengthSeconds || 0) || 0;
124
+ return {
125
+ videoId,
126
+ title: String(d.title || ''),
127
+ author: String(d.author || ''),
128
+ durationSec: seconds,
129
+ url: videoId ? `https://www.youtube.com/watch?v=${videoId}` : '',
130
+ // The uploader's own description is frequently where the links, chapters and
131
+ // corrections live — a summary that ignores it misses what the video points at.
132
+ description: String(d.shortDescription || ''),
133
+ live: !!d.isLiveContent,
134
+ };
135
+ }
136
+
137
+ /** `en-GB` and `en_gb` both mean the `en` family. */
138
+ function langFamily(code) {
139
+ return String(code || '').toLowerCase().replace('_', '-').split('-')[0];
140
+ }
141
+
142
+ /**
143
+ * Choose the track to read.
144
+ *
145
+ * Ranked, not filtered: a video with only an auto-generated Hindi track must still return
146
+ * that track for an English-preferring user, because the alternative is telling them there
147
+ * is no transcript when there plainly is one. Preference order:
148
+ *
149
+ * 1. an explicitly requested language (exact code beats family)
150
+ * 2. one of the user's preferred languages
151
+ * 3. a human-written track over the machine one
152
+ * 4. the order YouTube itself listed them (its own default is first)
153
+ */
154
+ export function pickCaptionTrack(tracks, { language = '', languages = ['en'] } = {}) {
155
+ const list = (Array.isArray(tracks) ? tracks : []).map(normalizeTrack).filter(Boolean);
156
+ if (!list.length) return null;
157
+ const wanted = String(language || '').toLowerCase();
158
+ const prefs = (wanted ? [wanted] : languages || []).map((l) => String(l).toLowerCase());
159
+ const score = (t, i) => {
160
+ let s = 0;
161
+ const exact = prefs.indexOf(t.lang);
162
+ const family = prefs.findIndex((p) => langFamily(p) === langFamily(t.lang));
163
+ if (exact >= 0) s += 1000 - exact * 10;
164
+ else if (family >= 0) s += 800 - family * 10;
165
+ if (!t.generated) s += 100;
166
+ return s - i; // stable: YouTube's own ordering breaks every remaining tie
167
+ };
168
+ return list
169
+ .map((t, i) => ({ t, s: score(t, i) }))
170
+ .sort((a, b) => b.s - a.s)[0].t;
171
+ }
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
+
221
+ /**
222
+ * Ask a caption URL for a specific serialisation.
223
+ *
224
+ * json3 is the one to want: it is the only format that reports per-segment durations
225
+ * reliably, and it needs no XML parser — which matters because a DOMParser does not exist
226
+ * in a service worker, in Node, or in a mobile JS runtime.
227
+ */
228
+ export function timedTextUrl(baseUrl, { fmt = 'json3', language = '' } = {}) {
229
+ let u;
230
+ try {
231
+ u = new URL(String(baseUrl || ''));
232
+ } catch {
233
+ return '';
234
+ }
235
+ if (fmt) u.searchParams.set('fmt', fmt);
236
+ // Ask YouTube to translate only when the track we found is not already the language
237
+ // asked for; `tlang` on a matching track returns a machine round-trip of itself.
238
+ if (language) u.searchParams.set('tlang', language);
239
+ return u.toString();
240
+ }
241
+
242
+ // --------------------------------------------------------------------------
243
+ // Parsing — four serialisations, one segment list
244
+ // --------------------------------------------------------------------------
245
+
246
+ const XML_ENTITIES = {
247
+ amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", '#39': "'", nbsp: ' ',
248
+ };
249
+
250
+ /** Caption text is double-escaped on the XML endpoints (`&amp;#39;` for an apostrophe). */
251
+ function decodeEntities(s) {
252
+ let out = String(s || '');
253
+ for (let pass = 0; pass < 2; pass++) {
254
+ out = out.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (m, name) => {
255
+ if (name[0] === '#') {
256
+ const code = name[1] === 'x' || name[1] === 'X'
257
+ ? parseInt(name.slice(2), 16)
258
+ : parseInt(name.slice(1), 10);
259
+ return Number.isFinite(code) && code > 0 ? String.fromCodePoint(code) : m;
260
+ }
261
+ const hit = XML_ENTITIES[name.toLowerCase()];
262
+ return hit === undefined ? m : hit;
263
+ });
264
+ if (!/&[a-zA-Z#]/.test(out)) break;
265
+ }
266
+ return out;
267
+ }
268
+
269
+ // Only the tags a caption track actually carries. A blanket `<[^>]*>` strip would also
270
+ // eat `<tag>` where the speaker said "angle bracket tag" and the track escaped it.
271
+ const CAPTION_MARKUP_RE = /<\/?(?:i|b|u|s|br|font|v|c|ruby|rt|rp)\b[^>]*>/gi;
272
+
273
+ /**
274
+ * Caption text, as text.
275
+ *
276
+ * ORDER MATTERS TWICE. Literal markup is stripped BEFORE decoding, because srv3 wraps every
277
+ * word in an `<s>` span for karaoke timing. Then entities are decoded — twice, since the XML
278
+ * endpoints double-escape (`&amp;#39;` for an apostrophe) — and markup is stripped once more,
279
+ * because `<i>` reaches us escaped rather than literal on some tracks. Decoding first would
280
+ * turn an escaped `&lt;tag&gt;` the speaker actually said into markup and delete it.
281
+ */
282
+ function cleanText(s) {
283
+ return decodeEntities(String(s || '').replace(CAPTION_MARKUP_RE, ''))
284
+ .replace(CAPTION_MARKUP_RE, '')
285
+ .replace(/\s+/g, ' ')
286
+ .trim();
287
+ }
288
+
289
+ /** `00:01:02.500` / `01:02,500` / `62.5` → milliseconds. */
290
+ function parseClock(raw) {
291
+ const s = String(raw || '').trim().replace(',', '.');
292
+ const parts = s.split(':').map(Number);
293
+ if (parts.some((n) => !Number.isFinite(n))) return NaN;
294
+ let sec = 0;
295
+ for (const p of parts) sec = sec * 60 + p;
296
+ return Math.round(sec * 1000);
297
+ }
298
+
299
+ function parseJson3(body) {
300
+ let doc;
301
+ try {
302
+ doc = typeof body === 'string' ? JSON.parse(body) : body;
303
+ } catch {
304
+ return null;
305
+ }
306
+ if (!Array.isArray(doc?.events)) return null;
307
+ const out = [];
308
+ for (const ev of doc.events) {
309
+ const text = cleanText((ev?.segs || []).map((s) => s?.utf8 || '').join(''));
310
+ if (!text) continue; // json3 emits empty timing-only events between cues
311
+ out.push({
312
+ start: Number(ev.tStartMs) || 0,
313
+ dur: Number(ev.dDurationMs) || 0,
314
+ text,
315
+ });
316
+ }
317
+ return out;
318
+ }
319
+
320
+ function parseTimedTextXml(body) {
321
+ const src = String(body || '');
322
+ if (!/<(transcript|timedtext|text|p)\b/i.test(src)) return null;
323
+ const out = [];
324
+ // srv1 uses <text start dur>, srv3 uses <p t d>. One regex over both beats requiring a
325
+ // DOMParser that half our runtimes do not have.
326
+ const re = /<(?:text|p)\b([^>]*)>([\s\S]*?)<\/(?:text|p)>/gi;
327
+ let m;
328
+ while ((m = re.exec(src))) {
329
+ const attrs = m[1];
330
+ const num = (name) => {
331
+ const a = new RegExp(`${name}="([^"]*)"`, 'i').exec(attrs);
332
+ return a ? Number(a[1]) : NaN;
333
+ };
334
+ const startSec = num('start');
335
+ const startMs = Number.isFinite(startSec) ? startSec * 1000 : num('t');
336
+ const durSec = num('dur');
337
+ const durMs = Number.isFinite(durSec) ? durSec * 1000 : num('d');
338
+ const text = cleanText(m[2]);
339
+ if (!text) continue;
340
+ out.push({
341
+ start: Math.round(Number.isFinite(startMs) ? startMs : 0),
342
+ dur: Math.round(Number.isFinite(durMs) ? durMs : 0),
343
+ text,
344
+ });
345
+ }
346
+ return out.length ? out : null;
347
+ }
348
+
349
+ function parseCueList(body, sepRe) {
350
+ const blocks = String(body || '').replace(/\r\n?/g, '\n').split(/\n{2,}/);
351
+ const out = [];
352
+ for (const block of blocks) {
353
+ const lines = block.split('\n').filter((l) => l.trim());
354
+ const timeIdx = lines.findIndex((l) => sepRe.test(l));
355
+ if (timeIdx < 0) continue;
356
+ const [rawStart, rawEnd] = lines[timeIdx].split(sepRe);
357
+ const start = parseClock(rawStart);
358
+ const end = parseClock(String(rawEnd || '').split(/\s+/)[0]);
359
+ const text = cleanText(lines.slice(timeIdx + 1).join(' '));
360
+ if (!text || !Number.isFinite(start)) continue;
361
+ out.push({ start, dur: Number.isFinite(end) ? Math.max(0, end - start) : 0, text });
362
+ }
363
+ return out.length ? out : null;
364
+ }
365
+
366
+ const parseVtt = (body) => parseCueList(body, /\s*-->\s*/);
367
+
368
+ /**
369
+ * Any caption serialisation → `[{ start, dur, text }]` (milliseconds), sniffed by content.
370
+ *
371
+ * Sniffing rather than trusting a declared format: the same `fmt=json3` URL answers with
372
+ * XML when the parameter is dropped by a proxy or the track predates json3, and a parser
373
+ * chosen from the request instead of the response fails on exactly those.
374
+ */
375
+ export function parseTimedText(body) {
376
+ const src = typeof body === 'string' ? body : JSON.stringify(body ?? '');
377
+ if (!src.trim()) return [];
378
+ return parseJson3(src) || parseTimedTextXml(src) || parseVtt(src) || [];
379
+ }
380
+
381
+ // --------------------------------------------------------------------------
382
+ // Formatting
383
+ // --------------------------------------------------------------------------
384
+
385
+ /** ms → `m:ss`, or `h:mm:ss` once it earns the hour. */
386
+ export function formatTimestamp(ms) {
387
+ const total = Math.max(0, Math.round(Number(ms) || 0) / 1000);
388
+ const h = Math.floor(total / 3600);
389
+ const m = Math.floor((total % 3600) / 60);
390
+ const s = Math.floor(total % 60);
391
+ const pad = (n) => String(n).padStart(2, '0');
392
+ return h ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
393
+ }
394
+
395
+ /**
396
+ * Caption cues → paragraphs.
397
+ *
398
+ * Cues are 2-6 words long and arrive several per second; handed over raw, a 40-minute talk
399
+ * is 8,000 lines of fragments in which nothing is a sentence. The model then spends its
400
+ * attention on reassembly rather than on the content, and every quote it produces is a
401
+ * fragment. Grouping by a time budget (and by a real pause) restores paragraphs and cuts
402
+ * the token count roughly in half by removing the line breaks alone.
403
+ */
404
+ export function groupSegments(segments, { windowMs = 30000, gapMs = 2500 } = {}) {
405
+ const list = (Array.isArray(segments) ? segments : []).filter((s) => s && s.text);
406
+ const out = [];
407
+ let cur = null;
408
+ let prevEnd = 0;
409
+ for (const seg of list) {
410
+ const start = Number(seg.start) || 0;
411
+ const gap = start - prevEnd;
412
+ const tooLong = cur && start - cur.start >= windowMs;
413
+ const paused = cur && prevEnd > 0 && gap >= gapMs;
414
+ if (!cur || tooLong || paused) {
415
+ cur = { start, text: seg.text };
416
+ out.push(cur);
417
+ } else {
418
+ cur.text += ' ' + seg.text;
419
+ }
420
+ prevEnd = start + (Number(seg.dur) || 0);
421
+ }
422
+ // Auto-generated tracks repeat the tail of each cue as the head of the next (a rolling
423
+ // two-line caption). Left in, roughly a third of the transcript is duplicated text.
424
+ for (const p of out) p.text = dedupeOverlap(p.text);
425
+ return out;
426
+ }
427
+
428
+ /**
429
+ * Collapse an immediately-repeated run of words: `a b c b c d` → `a b c d`.
430
+ *
431
+ * The rolling two-line caption an auto-generated track emits means each cue re-states the
432
+ * tail of the one before it, so a naive join duplicates roughly a third of the transcript.
433
+ * Longest run first, so `b c b c` collapses as one four-word repeat rather than twice.
434
+ */
435
+ function dedupeOverlap(text) {
436
+ const words = String(text || '').split(' ');
437
+ for (let k = Math.min(12, words.length >> 1); k >= 3; k--) {
438
+ for (let i = 0; i + 2 * k <= words.length; i++) {
439
+ let same = true;
440
+ for (let j = 0; j < k && same; j++) same = words[i + j] === words[i + k + j];
441
+ if (same) { words.splice(i + k, k); i--; }
442
+ }
443
+ }
444
+ return words.join(' ');
445
+ }
446
+
447
+ export const TRANSCRIPT_MAX_CHARS = 120_000;
448
+
449
+ /**
450
+ * Segments → the text a model reads.
451
+ *
452
+ * Timestamps are kept by default and are not decoration: they are what lets an answer say
453
+ * "at 12:04 they say…", and what lets the panel turn that into a link that seeks the video.
454
+ */
455
+ export function formatTranscript(segments, {
456
+ timestamps = true, windowMs = 30000, gapMs = 2500, maxChars = TRANSCRIPT_MAX_CHARS,
457
+ } = {}) {
458
+ const groups = groupSegments(segments, { windowMs, gapMs });
459
+ const lines = groups.map((g) => (timestamps ? `[${formatTimestamp(g.start)}] ${g.text}` : g.text));
460
+ const text = lines.join('\n\n');
461
+ if (text.length <= maxChars) return text;
462
+ return `${text.slice(0, maxChars)}\n\n…[transcript truncated at ${maxChars} characters]`;
463
+ }
464
+
465
+ /**
466
+ * The whole thing a caller attaches or hands a model: metadata header + transcript body.
467
+ *
468
+ * The header exists because a transcript alone is anonymous. "Summarise this" over bare
469
+ * caption text produces a summary that cannot say what it summarised, and a model that
470
+ * cannot see the duration guesses at the shape of what it is reading.
471
+ */
472
+ export function buildTranscriptDocument({
473
+ meta = {}, segments = [], language = '', generated = false, source = '', ...opts
474
+ } = {}) {
475
+ const head = [];
476
+ if (meta.title) head.push(`# ${meta.title}`);
477
+ const facts = [];
478
+ if (meta.author) facts.push(`Channel: ${meta.author}`);
479
+ if (meta.durationSec) facts.push(`Duration: ${formatTimestamp(meta.durationSec * 1000)}`);
480
+ if (language) facts.push(`Captions: ${language}${generated ? ' (auto-generated)' : ''}`);
481
+ if (meta.url) facts.push(`URL: ${meta.url}`);
482
+ if (facts.length) head.push(facts.join(' · '));
483
+ const desc = String(meta.description || '').trim();
484
+ if (desc) head.push(`## Description\n${desc.slice(0, 2000)}`);
485
+ head.push('## Transcript');
486
+ const body = formatTranscript(segments, opts);
487
+ const text = `${head.join('\n\n')}\n\n${body}`;
488
+ return {
489
+ title: meta.title || 'Video transcript',
490
+ url: meta.url || '',
491
+ language,
492
+ generated,
493
+ source,
494
+ segments: segments.length,
495
+ durationSec: meta.durationSec || 0,
496
+ text,
497
+ chars: text.length,
498
+ };
499
+ }
500
+
501
+ /**
502
+ * The one orchestration worth sharing: tracks → chosen track → fetched body → document.
503
+ *
504
+ * `fetchText(url)` is injected, because WHERE the fetch runs is the entire reliability
505
+ * story and it differs per client (see the header). Everything either side of it is
506
+ * identical everywhere, so it lives here and gets tested with a fake fetch.
507
+ */
508
+ export async function transcriptFromTracks({
509
+ tracks, meta = {}, fetchText, language = '', languages = ['en'], source = '', ...opts
510
+ } = {}) {
511
+ const track = pickCaptionTrack(tracks, { language, languages });
512
+ if (!track) return null;
513
+ // Only ask for a translation when the track genuinely is not the language wanted.
514
+ const wantTranslation = !!language && langFamily(track.lang) !== langFamily(language);
515
+ const attempts = [
516
+ timedTextUrl(track.baseUrl, { fmt: 'json3', language: wantTranslation ? language : '' }),
517
+ timedTextUrl(track.baseUrl, { fmt: 'srv1', language: wantTranslation ? language : '' }),
518
+ track.baseUrl,
519
+ ].filter(Boolean);
520
+ for (const url of attempts) {
521
+ let body;
522
+ try {
523
+ body = await fetchText(url);
524
+ } catch {
525
+ continue; // a format the endpoint refuses is a reason to try the next, not to fail
526
+ }
527
+ const segments = parseTimedText(body);
528
+ if (segments.length) {
529
+ return buildTranscriptDocument({
530
+ meta, segments, language: track.lang, generated: track.generated, source, ...opts,
531
+ });
532
+ }
533
+ }
534
+ return null;
535
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@chatpanel/events",
3
- "version": "0.29.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.31.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": {
@@ -16,10 +16,12 @@
16
16
  "./loop.js": "./loop.js",
17
17
  "./manifest.js": "./manifest.js",
18
18
  "./markdown-authoring.js": "./markdown-authoring.js",
19
+ "./media-transcript.js": "./media-transcript.js",
19
20
  "./mcp-errors.js": "./mcp-errors.js",
20
21
  "./meeting-analyzers.js": "./meeting-analyzers.js",
21
22
  "./memory.js": "./memory.js",
22
23
  "./order.js": "./order.js",
24
+ "./pdf-layout.js": "./pdf-layout.js",
23
25
  "./queue.js": "./queue.js",
24
26
  "./reach.js": "./reach.js",
25
27
  "./ref.js": "./ref.js",
@@ -71,10 +73,12 @@
71
73
  "manifest.js",
72
74
  "markdown-authoring.js",
73
75
  "mcp-errors.js",
76
+ "media-transcript.js",
74
77
  "meeting-analyzers.js",
75
78
  "memory.js",
76
79
  "observability.js",
77
80
  "order.js",
81
+ "pdf-layout.js",
78
82
  "queue.js",
79
83
  "reach.js",
80
84
  "ref.js",
package/pdf-layout.js ADDED
@@ -0,0 +1,258 @@
1
+ // A PDF's text items → something a person (or a model) can read.
2
+ //
3
+ // A PDF does not contain paragraphs. It contains positioned glyph runs: "Introduction" at
4
+ // (72, 690), "and this is" at (72, 674), "why" at (140, 674). Every PDF engine — pdf.js in
5
+ // a browser, a native renderer on mobile, a CLI extractor on the bridge — hands back that
6
+ // same list of runs with the same transform matrix, and every one of them leaves the job of
7
+ // turning runs into READING ORDER to the caller. Done naively (join the items with spaces)
8
+ // a two-column paper becomes an interleaved ransom note, and every hyphenated line break
9
+ // becomes a broken word the model then cannot match against a query.
10
+ //
11
+ // So the reconstruction lives here — pure, testable, engine-agnostic — and the client
12
+ // contributes only the engine that produced the items.
13
+ //
14
+ // THE ITEM SHAPE (pdf.js `getTextContent().items`, and what other engines are adapted to):
15
+ // { str, transform: [a, b, c, d, x, y], width, height, hasEOL }
16
+ // x/y are in PDF user space: y grows UPWARD from the bottom of the page.
17
+
18
+ /** One text item → the numbers this file reasons about. */
19
+ function place(item) {
20
+ const t = Array.isArray(item?.transform) ? item.transform : [1, 0, 0, 1, 0, 0];
21
+ return {
22
+ text: String(item?.str ?? ''),
23
+ x: Number(t[4]) || 0,
24
+ y: Number(t[5]) || 0,
25
+ width: Number(item?.width) || 0,
26
+ // The transform's vertical scale is the font size; `height` is often 0 on the items
27
+ // real documents produce, and a zero line height makes every line one paragraph.
28
+ size: Math.abs(Number(t[3])) || Number(item?.height) || 0,
29
+ eol: !!item?.hasEOL,
30
+ };
31
+ }
32
+
33
+ /**
34
+ * Items → lines, by shared baseline.
35
+ *
36
+ * Tolerance is a FRACTION OF THE FONT SIZE, not a fixed number of points: a footnote at 7pt
37
+ * and a heading at 24pt do not agree on what "the same line" means, and a constant that
38
+ * works for body text either splits headings or merges footnotes.
39
+ */
40
+ export function linesFromItems(items, { tolerance = 0.5 } = {}) {
41
+ const placed = (Array.isArray(items) ? items : []).map(place).filter((p) => p.text !== '');
42
+ if (!placed.length) return [];
43
+ const lines = [];
44
+ for (const p of placed) {
45
+ const tol = Math.max(1, (p.size || 10) * tolerance);
46
+ // Compare against the most recent line only: items arrive in content-stream order, so a
47
+ // matching baseline further back belongs to an earlier column, not to this line.
48
+ const last = lines[lines.length - 1];
49
+ if (last && Math.abs(last.y - p.y) <= tol) {
50
+ last.items.push(p);
51
+ if (p.size > last.size) last.size = p.size;
52
+ } else {
53
+ lines.push({ y: p.y, size: p.size || 10, items: [p] });
54
+ }
55
+ }
56
+ return lines.map((line) => {
57
+ const sorted = line.items.slice().sort((a, b) => a.x - b.x);
58
+ return {
59
+ y: line.y,
60
+ size: line.size,
61
+ x: sorted[0].x,
62
+ right: Math.max(...sorted.map((i) => i.x + i.width)),
63
+ text: joinRun(sorted, line.size),
64
+ };
65
+ });
66
+ }
67
+
68
+ /**
69
+ * Glyph runs on one line → a string.
70
+ *
71
+ * PDFs encode a space either as a space character or as a horizontal gap with nothing in
72
+ * it, and which one you get depends on the producer. Joining on the character alone loses
73
+ * every word break in documents from the second kind; joining every item with a space puts
74
+ * one inside every kerned pair in documents from the first.
75
+ */
76
+ function joinRun(sorted, size) {
77
+ let out = '';
78
+ let prevRight = null;
79
+ for (const item of sorted) {
80
+ if (prevRight !== null) {
81
+ const gap = item.x - prevRight;
82
+ const needsSpace = gap > (size || 10) * 0.2;
83
+ if (needsSpace && !/\s$/.test(out) && !/^\s/.test(item.text)) out += ' ';
84
+ }
85
+ out += item.text;
86
+ prevRight = item.x + item.width;
87
+ }
88
+ return out.replace(/\s+/g, ' ').trim();
89
+ }
90
+
91
+ /**
92
+ * Lines → columns, in reading order.
93
+ *
94
+ * Two-column layouts are the case that makes naive extraction useless, and they are most of
95
+ * academic and technical PDF reading. A column is detected as a vertical band that lines
96
+ * cluster into; if the page is one column (or the split is not clean) this returns the lines
97
+ * unchanged rather than inventing a split, because a wrong split is worse than none.
98
+ */
99
+ export function orderLines(lines, { pageWidth = 0 } = {}) {
100
+ const list = (Array.isArray(lines) ? lines : []).slice();
101
+ if (list.length < 6) return list.sort((a, b) => b.y - a.y);
102
+ const width = pageWidth || Math.max(...list.map((l) => l.right));
103
+ const mid = width / 2;
104
+ const left = list.filter((l) => l.right <= mid * 1.05);
105
+ const right = list.filter((l) => l.x >= mid * 0.95);
106
+ const spanning = list.filter((l) => !left.includes(l) && !right.includes(l));
107
+ // A real two-column page has substantial text on BOTH sides and few lines crossing the
108
+ // gutter. Anything else is a single column with a stray indent or a wide table.
109
+ const twoColumn = left.length >= 3 && right.length >= 3
110
+ && spanning.length <= list.length * 0.2;
111
+ if (!twoColumn) return list.sort((a, b) => b.y - a.y);
112
+ const byY = (a, b) => b.y - a.y;
113
+ // Headers and footers that span the gutter keep their vertical position relative to the
114
+ // column they sit above; sorting them into the left column first is the conventional and
115
+ // least-surprising reading order.
116
+ return [...spanning.filter((l) => l.y > Math.max(...left.map((x) => x.y), 0)).sort(byY),
117
+ ...left.sort(byY), ...right.sort(byY),
118
+ ...spanning.filter((l) => l.y <= Math.max(...left.map((x) => x.y), 0)).sort(byY)];
119
+ }
120
+
121
+ /**
122
+ * Lines → paragraphs.
123
+ *
124
+ * A new paragraph is a bigger-than-usual vertical gap, a first-line indent, or a line that
125
+ * ended well short of the right margin. A word broken across lines with a hyphen is put
126
+ * back together — otherwise "distri-\nbuted" never matches a search for "distributed", and
127
+ * the model reads a word that does not exist.
128
+ *
129
+ * MARGINS ARE MEASURED PER BLOCK, NOT PER PAGE. On a two-column page every line in the left
130
+ * column ends far short of the page's right edge, so a page-wide margin makes every single
131
+ * line "short" and therefore its own paragraph — which is what a first attempt at this did.
132
+ * Lines that share a left edge are one block; a change of block is itself a paragraph break,
133
+ * because it is a change of column or of indentation level.
134
+ */
135
+ export function paragraphsFromLines(lines) {
136
+ const list = (Array.isArray(lines) ? lines : []).filter((l) => l && l.text);
137
+ if (!list.length) return [];
138
+
139
+ // A block is a run of lines that share a left edge AND a font size. The size half is not
140
+ // cosmetic: a 14pt heading is wider than the 10pt lines under it, so a block containing
141
+ // both takes its right margin from the heading — and then every body line is "short" and
142
+ // every one of them becomes its own paragraph.
143
+ const blocks = [];
144
+ for (const line of list) {
145
+ const cur = blocks[blocks.length - 1];
146
+ const prev = cur?.lines[cur.lines.length - 1];
147
+ const sameEdge = cur && Math.abs(cur.lines[0].x - line.x) <= (line.size || 10) * 1.5;
148
+ const sameSize = prev && Math.abs((prev.size || 10) - (line.size || 10)) <= (prev.size || 10) * 0.15;
149
+ if (sameEdge && sameSize) cur.lines.push(line);
150
+ else blocks.push({ lines: [line] });
151
+ }
152
+
153
+ const paras = [];
154
+ for (const block of blocks) {
155
+ const rows = block.lines;
156
+ const gaps = [];
157
+ for (let i = 1; i < rows.length; i++) gaps.push(Math.abs(rows[i - 1].y - rows[i].y));
158
+ // A LOW percentile, not the median: in a three-line block whose second gap IS the
159
+ // paragraph break, the median sits between the line gap and the break and neither is
160
+ // then unusual. The common gap is a line gap, and it lives at the bottom of the range.
161
+ const typical = percentile(gaps, 0.3) || (rows[0].size || 10) * 1.2;
162
+ const rightEdge = Math.max(...rows.map((l) => l.right));
163
+ const leftEdge = Math.min(...rows.map((l) => l.x));
164
+
165
+ let cur = '';
166
+ for (let i = 0; i < rows.length; i++) {
167
+ const line = rows[i];
168
+ const prev = rows[i - 1];
169
+ const gap = prev ? Math.abs(prev.y - line.y) : 0;
170
+ const indented = line.x > leftEdge + (line.size || 10) * 0.8;
171
+ const prevShort = prev ? prev.right < rightEdge - (line.size || 10) * 3 : false;
172
+ if (cur && (gap > typical * 1.5 || indented || prevShort)) {
173
+ paras.push(cur);
174
+ cur = line.text;
175
+ } else if (!cur) {
176
+ cur = line.text;
177
+ } else if (/(\w)-$/.test(cur)) {
178
+ cur = cur.replace(/-$/, '') + line.text.replace(/^\s+/, '');
179
+ } else {
180
+ cur += ' ' + line.text;
181
+ }
182
+ }
183
+ if (cur) paras.push(cur);
184
+ }
185
+ return paras.map((p) => p.replace(/\s+/g, ' ').trim()).filter(Boolean);
186
+ }
187
+
188
+ /** Nearest-rank percentile of the positive values, 0 when there are none. */
189
+ function percentile(nums, q) {
190
+ const list = nums.filter((n) => Number.isFinite(n) && n > 0).sort((a, b) => a - b);
191
+ if (!list.length) return 0;
192
+ return list[Math.min(list.length - 1, Math.floor(q * (list.length - 1)))];
193
+ }
194
+
195
+ /** One page's items → its text. */
196
+ export function pageTextFromItems(items, { pageWidth = 0, ...opts } = {}) {
197
+ const lines = linesFromItems(items, opts);
198
+ return paragraphsFromLines(orderLines(lines, { pageWidth })).join('\n\n');
199
+ }
200
+
201
+ export const PDF_MAX_CHARS = 200_000;
202
+
203
+ /**
204
+ * Is this PDF a picture of a document rather than a document?
205
+ *
206
+ * A scanned page has a text layer of nothing, and every extractor answers with an empty
207
+ * string. Returning that silently is the worst outcome: the user sees a summary of nothing
208
+ * and no explanation. Detecting it lets the caller say "this needs OCR" — which is an
209
+ * answer, where an empty attachment is a mystery.
210
+ */
211
+ export function looksScanned(pages) {
212
+ const list = Array.isArray(pages) ? pages : [];
213
+ if (!list.length) return false;
214
+ const chars = list.reduce((n, p) => n + String(p?.text || '').length, 0);
215
+ return chars < list.length * 40;
216
+ }
217
+
218
+ /**
219
+ * Pages → the document a caller attaches.
220
+ *
221
+ * Page markers are kept because a citation into a 90-page PDF is worth nothing without one,
222
+ * and because they are the only thing that tells a model the document has an order at all.
223
+ */
224
+ export function buildPdfDocument({ meta = {}, pages = [], maxChars = PDF_MAX_CHARS } = {}) {
225
+ const list = Array.isArray(pages) ? pages : [];
226
+ const head = [];
227
+ if (meta.title) head.push(`# ${meta.title}`);
228
+ const facts = [];
229
+ if (meta.author) facts.push(`Author: ${meta.author}`);
230
+ if (list.length) facts.push(`Pages: ${meta.pageCount || list.length}`);
231
+ if (meta.url) facts.push(`URL: ${meta.url}`);
232
+ if (facts.length) head.push(facts.join(' · '));
233
+
234
+ const body = list
235
+ .map((p) => {
236
+ const text = String(p?.text || '').trim();
237
+ return text ? `[page ${p.page}]\n${text}` : '';
238
+ })
239
+ .filter(Boolean)
240
+ .join('\n\n');
241
+
242
+ let text = head.length ? `${head.join('\n\n')}\n\n${body}` : body;
243
+ let truncated = false;
244
+ if (text.length > maxChars) {
245
+ text = `${text.slice(0, maxChars)}\n\n…[PDF truncated at ${maxChars} characters]`;
246
+ truncated = true;
247
+ }
248
+ return {
249
+ title: meta.title || 'PDF',
250
+ url: meta.url || '',
251
+ pageCount: meta.pageCount || list.length,
252
+ pagesRead: list.length,
253
+ scanned: looksScanned(list),
254
+ truncated,
255
+ text,
256
+ chars: text.length,
257
+ };
258
+ }