@chatpanel/events 0.63.0 → 0.64.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
@@ -252,3 +252,4 @@ export { renderMarkdown, defaultLinkPolicy } from './markdown-render.js';
252
252
  // A meeting read back out of the flat text the warm store holds — the same grammar the
253
253
  // extension writes and MCP reads, so every client shows one transcript, not three.
254
254
  export { parseMeetingText, speakerStats, densityRibbon } from './meeting-text.js';
255
+ export { speakerBreakdown, speakerTimeline, formatTalkTime, SPEAKER_SLOTS } from './meeting-shape.js';
@@ -0,0 +1,262 @@
1
+ // THE SHAPE OF A MEETING — who spoke, when, and for how long.
2
+ //
3
+ // A transcript you did not attend is a wall. The shape of it — one band showing who held
4
+ // the floor across the hour, and a breakdown of the minutes — is the fastest way in, and
5
+ // it is derivable from the captions alone. So it lives HERE, not in a client: the
6
+ // extension, the desktop app and anything that reads the warm index all want the same
7
+ // answer, and three implementations of "who talked most" drift into three numbers.
8
+ //
9
+ // TWO BASES, and the module says which one it used.
10
+ //
11
+ // 'clock' — the segments carry real timestamps (the extension's capture stamps every
12
+ // caption with `t`, epoch ms). Buckets are equal slices of WALL TIME, so a
13
+ // five-minute silence is visibly five minutes of nothing, and talk time is
14
+ // real minutes.
15
+ // 'text' — the segments carry no usable clock (a meeting read back out of its flat
16
+ // text form, where the timestamps are display strings like "6:28:00 PM" that
17
+ // cannot be turned into durations without guessing at a date and a timezone).
18
+ // Buckets are equal slices of POSITION and shares are shares of characters.
19
+ //
20
+ // A caller that draws minutes on a 'text' meeting would be inventing them, which is why
21
+ // `basis` is part of the return rather than something to infer from a null.
22
+ //
23
+ // THE TRANSCRIPT IS UNTRUSTED CONTENT — participant-chosen display names, live captions,
24
+ // meeting chat. Everything here treats a speaker as an opaque label and returns it as its
25
+ // own field; nothing is concatenated into a sentence a prompt could read as an instruction.
26
+
27
+ /** Ordinary speech, in characters per second — ~150 words per minute. */
28
+ const CPS = 15;
29
+ /** No caption is shorter than this. Below it, rounding noise becomes "talk time". */
30
+ const MIN_TURN_MS = 800;
31
+ /** Past this, the speaker has stopped and the meeting is simply quiet. A gap longer than
32
+ * half a minute is silence, a screen share or a break — not somebody still holding forth. */
33
+ const MAX_TURN_MS = 30_000;
34
+
35
+ /** How many speakers get their own colour before the rest fold into one bucket. Five is
36
+ * where a categorical palette stops being separable for colour-blind readers; past it,
37
+ * more hues buy nothing and cost legibility. */
38
+ export const SPEAKER_SLOTS = 5;
39
+
40
+ const clean = (v) => String(v ?? '').replace(/\s+/g, ' ').trim();
41
+
42
+ /** Epoch ms from whatever the segment carries, or `null` when it carries nothing usable. */
43
+ function clockOf(seg) {
44
+ const t = seg?.t ?? seg?.ts ?? null;
45
+ if (typeof t === 'number' && Number.isFinite(t) && t > 0) return t;
46
+ // An ISO string round-trips through JSON as a string; a display clock ("6:28:00 PM")
47
+ // does not parse and must not be coerced into today's date.
48
+ if (typeof t === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(t)) {
49
+ const ms = Date.parse(t);
50
+ if (Number.isFinite(ms)) return ms;
51
+ }
52
+ return null;
53
+ }
54
+
55
+ /** The segments as this module needs them: a label, some text, and a clock or `null`. */
56
+ function segmentsOf(meeting) {
57
+ const raw = Array.isArray(meeting) ? meeting : (meeting?.segments || []);
58
+ return raw
59
+ .map((s) => ({ speaker: clean(s?.speaker) || 'Speaker', text: String(s?.text ?? ''), t: clockOf(s) }))
60
+ .filter((s) => s.text.trim() || s.speaker);
61
+ }
62
+
63
+ /** How long a caption plausibly took to say, from its own length. */
64
+ const spoken = (text) => Math.min(MAX_TURN_MS, Math.max(MIN_TURN_MS, (text.length / CPS) * 1000));
65
+
66
+ /**
67
+ * Each segment with a start and a duration, sorted, when the meeting has a usable clock.
68
+ * Returns `null` otherwise — the caller falls back to counting characters.
69
+ *
70
+ * A segment runs until the NEXT one starts, capped at `MAX_TURN_MS` so a break does not
71
+ * get billed to whoever spoke last, and floored by its own text so back-to-back interim
72
+ * captions (which can share a millisecond) still register.
73
+ */
74
+ function timedSegments(meeting) {
75
+ const segs = segmentsOf(meeting).filter((s) => s.t !== null);
76
+ if (segs.length < 2) return null;
77
+ segs.sort((a, b) => a.t - b.t);
78
+ if (segs.at(-1).t - segs[0].t <= 0) return null; // every caption on one millisecond
79
+ return segs.map((s, i) => {
80
+ const next = segs[i + 1];
81
+ const gap = next ? next.t - s.t : 0;
82
+ const ms = gap > 0 ? Math.min(gap, MAX_TURN_MS) : spoken(s.text);
83
+ return { ...s, ms: Math.max(MIN_TURN_MS, ms) };
84
+ });
85
+ }
86
+
87
+ /**
88
+ * Who spoke, for how long, in one shape — the input to both the talk-time chart and the
89
+ * colouring of everything else.
90
+ *
91
+ * Speakers are ranked by talk time and given a SLOT in that order, and the slot is what a
92
+ * client turns into a colour. Ranking rather than hashing the name is deliberate: a hash
93
+ * is stable across meetings but collides, so two people in the same meeting can come out
94
+ * the same colour, which is the one thing this chart must never do. The ranking is stable
95
+ * for a given meeting because the speaker set is fixed — no filter removes a series here.
96
+ *
97
+ * Past `slots` speakers, the tail folds into `other` rather than taking a generated hue.
98
+ */
99
+ export function speakerBreakdown(meeting, { slots = SPEAKER_SLOTS } = {}) {
100
+ const timed = timedSegments(meeting);
101
+ const segs = timed || segmentsOf(meeting);
102
+ const basis = timed ? 'clock' : 'text';
103
+
104
+ const by = new Map();
105
+ for (const s of segs) {
106
+ const prev = by.get(s.speaker) || { speaker: s.speaker, lines: 0, chars: 0, ms: 0 };
107
+ prev.lines += 1;
108
+ prev.chars += s.text.length;
109
+ prev.ms += s.ms || 0;
110
+ by.set(s.speaker, prev);
111
+ }
112
+
113
+ const weightOf = (s) => (basis === 'clock' ? s.ms : s.chars);
114
+ const total = [...by.values()].reduce((n, s) => n + weightOf(s), 0);
115
+ const ranked = [...by.values()].sort((a, b) => weightOf(b) - weightOf(a) || a.speaker.localeCompare(b.speaker));
116
+
117
+ const speakers = ranked.map((s, i) => ({
118
+ speaker: s.speaker,
119
+ slot: i < slots ? i : -1,
120
+ lines: s.lines,
121
+ chars: s.chars,
122
+ ms: basis === 'clock' ? Math.round(s.ms) : null,
123
+ seconds: basis === 'clock' ? Math.round(s.ms / 1000) : null,
124
+ share: total ? weightOf(s) / total : 0,
125
+ }));
126
+
127
+ const tail = speakers.filter((s) => s.slot === -1);
128
+ return {
129
+ basis,
130
+ totalMs: basis === 'clock' ? Math.round(total) : null,
131
+ lines: segs.length,
132
+ speakers,
133
+ shown: speakers.filter((s) => s.slot >= 0),
134
+ other: tail.length
135
+ ? {
136
+ speakers: tail.map((s) => s.speaker),
137
+ lines: tail.reduce((n, s) => n + s.lines, 0),
138
+ ms: basis === 'clock' ? tail.reduce((n, s) => n + s.ms, 0) : null,
139
+ seconds: basis === 'clock' ? Math.round(tail.reduce((n, s) => n + s.ms, 0) / 1000) : null,
140
+ share: tail.reduce((n, s) => n + s.share, 0),
141
+ }
142
+ : null,
143
+ };
144
+ }
145
+
146
+ /**
147
+ * The meeting as a row of buckets — who held the floor, across the meeting.
148
+ *
149
+ * On a clock basis the buckets are equal slices of WALL TIME between the first and last
150
+ * caption, so silence reads as silence and a bucket can be genuinely empty. Position
151
+ * buckets (the fallback) cannot show that: they space the captions evenly whatever the
152
+ * pace, which makes a quiet hour look like a busy one.
153
+ *
154
+ * Each bucket names its DOMINANT speaker and the full split, so a client can paint one
155
+ * bar per bucket and still say in a tooltip who else was in it.
156
+ */
157
+ export function speakerTimeline(meeting, { buckets = 48 } = {}) {
158
+ const timed = timedSegments(meeting);
159
+ const segs = timed || segmentsOf(meeting);
160
+ if (!segs.length) return { basis: timed ? 'clock' : 'text', from: null, to: null, buckets: [] };
161
+
162
+ const n = Math.max(1, Math.floor(buckets));
163
+ const bins = Array.from({ length: n }, () => new Map());
164
+ const add = (bin, speaker, weight) => bin.set(speaker, (bin.get(speaker) || 0) + weight);
165
+
166
+ let from = null;
167
+ let to = null;
168
+
169
+ if (timed) {
170
+ from = timed[0].t;
171
+ to = Math.max(timed.at(-1).t + timed.at(-1).ms, from + 1);
172
+ const span = to - from;
173
+ for (const s of timed) {
174
+ // A long turn spans buckets; bill each one for the part of the turn inside it, or a
175
+ // ten-minute monologue counts once and the band under-reports a whole stretch.
176
+ const start = s.t;
177
+ const end = Math.min(to, s.t + s.ms);
178
+ const first = Math.min(n - 1, Math.floor(((start - from) / span) * n));
179
+ const last = Math.min(n - 1, Math.floor(((end - from) / span) * n));
180
+ for (let b = first; b <= last; b += 1) {
181
+ const bFrom = from + (span * b) / n;
182
+ const bTo = from + (span * (b + 1)) / n;
183
+ const overlap = Math.min(end, bTo) - Math.max(start, bFrom);
184
+ if (overlap > 0) add(bins[b], s.speaker, overlap);
185
+ }
186
+ }
187
+ } else {
188
+ const per = segs.length / n;
189
+ segs.forEach((s, i) => add(bins[Math.min(n - 1, Math.floor(i / per))], s.speaker, Math.max(1, s.text.length)));
190
+ }
191
+
192
+ const rows = bins.map((bin, i) => {
193
+ const by = [...bin.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
194
+ const weight = by.reduce((sum, [, w]) => sum + w, 0);
195
+ const span = to === null ? null : (to - from) / n;
196
+ return {
197
+ speaker: by[0]?.[0] || '',
198
+ weight,
199
+ by: by.map(([speaker, w]) => ({ speaker, weight: w, share: weight ? w / weight : 0 })),
200
+ from: span === null ? null : Math.round(from + span * i),
201
+ to: span === null ? null : Math.round(from + span * (i + 1)),
202
+ };
203
+ });
204
+
205
+ const max = Math.max(...rows.map((r) => r.weight), 1);
206
+ return { basis: timed ? 'clock' : 'text', from, to, buckets: rows.map((r) => ({ ...r, weight: r.weight / max })) };
207
+ }
208
+
209
+ /**
210
+ * Who spoke, and how much — the character-share form kept for callers that only ever had
211
+ * text. `speakerBreakdown` is the fuller answer; this stays because the desktop's meeting
212
+ * pane and `meeting-text.js` are built on it.
213
+ */
214
+ export function speakerStats(meeting) {
215
+ const by = new Map();
216
+ for (const s of meeting?.segments || []) {
217
+ const prev = by.get(s.speaker) || { speaker: s.speaker, lines: 0, chars: 0 };
218
+ prev.lines += 1;
219
+ prev.chars += s.text.length;
220
+ by.set(s.speaker, prev);
221
+ }
222
+ const total = [...by.values()].reduce((n, s) => n + s.chars, 0) || 1;
223
+ return [...by.values()]
224
+ .map((s) => ({ ...s, share: s.chars / total }))
225
+ .sort((a, b) => b.chars - a.chars);
226
+ }
227
+
228
+ /**
229
+ * The meeting as one ribbon of who-spoke-when, bucketed by POSITION.
230
+ *
231
+ * Kept as-is for the desktop pane, which reads meetings back out of their flat text form
232
+ * and so has no clock to bucket by. New callers want `speakerTimeline`, which uses the
233
+ * clock when there is one and falls back to exactly this when there is not.
234
+ */
235
+ export function densityRibbon(meeting, buckets = 40) {
236
+ const segs = meeting?.segments || [];
237
+ if (!segs.length) return [];
238
+ const out = [];
239
+ const per = segs.length / buckets;
240
+ for (let b = 0; b < buckets; b += 1) {
241
+ const from = Math.floor(b * per);
242
+ const to = Math.max(from + 1, Math.floor((b + 1) * per));
243
+ const slice = segs.slice(from, to);
244
+ if (!slice.length) { out.push({ speaker: '', weight: 0 }); continue; }
245
+ const by = new Map();
246
+ for (const s of slice) by.set(s.speaker, (by.get(s.speaker) || 0) + s.text.length);
247
+ const [speaker, chars] = [...by.entries()].sort((a, b2) => b2[1] - a[1])[0];
248
+ out.push({ speaker, weight: chars });
249
+ }
250
+ const max = Math.max(...out.map((o) => o.weight), 1);
251
+ return out.map((o) => ({ ...o, weight: o.weight / max }));
252
+ }
253
+
254
+ /** `41 min`, `1h 12m`, `48s` — a duration a person reads without converting it. */
255
+ export function formatTalkTime(ms) {
256
+ if (!Number.isFinite(ms) || ms <= 0) return '';
257
+ const total = Math.round(ms / 1000);
258
+ if (total < 60) return `${total}s`;
259
+ const mins = Math.round(total / 60);
260
+ if (mins < 60) return `${mins} min`;
261
+ return `${Math.floor(mins / 60)}h ${String(mins % 60).padStart(2, '0')}m`;
262
+ }
package/meeting-text.js CHANGED
@@ -87,49 +87,8 @@ export function parseMeetingText(text) {
87
87
  return out;
88
88
  }
89
89
 
90
- /**
91
- * Who spoke, and how much the shape of the meeting rather than its content.
92
- *
93
- * Ordered by line count, because "who ran this meeting" is usually the first thing someone
94
- * wants from a transcript they did not attend.
95
- */
96
- export function speakerStats(meeting) {
97
- const by = new Map();
98
- for (const s of meeting?.segments || []) {
99
- const prev = by.get(s.speaker) || { speaker: s.speaker, lines: 0, chars: 0 };
100
- prev.lines += 1;
101
- prev.chars += s.text.length;
102
- by.set(s.speaker, prev);
103
- }
104
- const total = [...by.values()].reduce((n, s) => n + s.chars, 0) || 1;
105
- return [...by.values()]
106
- .map((s) => ({ ...s, share: s.chars / total }))
107
- .sort((a, b) => b.chars - a.chars);
108
- }
109
-
110
- /**
111
- * The meeting as one ribbon of who-spoke-when.
112
- *
113
- * `buckets` slices the transcript by POSITION rather than by clock: the timestamps are
114
- * display strings from the capture ("6:28:00 PM"), not durations, and parsing them into a
115
- * timeline would be guessing at a date, a timezone and whether the meeting crossed midnight.
116
- * Position is honest about being an approximation of pace.
117
- */
118
- export function densityRibbon(meeting, buckets = 40) {
119
- const segs = meeting?.segments || [];
120
- if (!segs.length) return [];
121
- const out = [];
122
- const per = segs.length / buckets;
123
- for (let b = 0; b < buckets; b += 1) {
124
- const from = Math.floor(b * per);
125
- const to = Math.max(from + 1, Math.floor((b + 1) * per));
126
- const slice = segs.slice(from, to);
127
- if (!slice.length) { out.push({ speaker: '', weight: 0 }); continue; }
128
- const by = new Map();
129
- for (const s of slice) by.set(s.speaker, (by.get(s.speaker) || 0) + s.text.length);
130
- const [speaker, chars] = [...by.entries()].sort((a, b2) => b2[1] - a[1])[0];
131
- out.push({ speaker, weight: chars });
132
- }
133
- const max = Math.max(...out.map((o) => o.weight), 1);
134
- return out.map((o) => ({ ...o, weight: o.weight / max }));
135
- }
90
+ // The shape analysis — who spoke, when, for how long — lives in `meeting-shape.js` so the
91
+ // clients that never read the text form can have it without the parser. Re-exported here
92
+ // because these two names were part of this module before that split, and the desktop
93
+ // meeting pane imports them from this path.
94
+ export { speakerStats, densityRibbon } from './meeting-shape.js';
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@chatpanel/events",
3
- "version": "0.63.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.",
3
+ "version": "0.64.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.",
5
5
  "type": "module",
6
6
  "main": "index.js",
7
7
  "exports": {
@@ -33,8 +33,10 @@
33
33
  "./mcp-errors.js": "./mcp-errors.js",
34
34
  "./media-transcript.js": "./media-transcript.js",
35
35
  "./meeting-analyzers.js": "./meeting-analyzers.js",
36
+ "./meeting-shape.js": "./meeting-shape.js",
36
37
  "./meeting-text.js": "./meeting-text.js",
37
38
  "./memory.js": "./memory.js",
39
+ "./model-candidates.js": "./model-candidates.js",
38
40
  "./model-picker.js": "./model-picker.js",
39
41
  "./note-actions.js": "./note-actions.js",
40
42
  "./note-graph.js": "./note-graph.js",
@@ -53,6 +55,7 @@
53
55
  "./ref.js": "./ref.js",
54
56
  "./registry.js": "./registry.js",
55
57
  "./route-graph.js": "./route-graph.js",
58
+ "./route-strategies.js": "./route-strategies.js",
56
59
  "./router.js": "./router.js",
57
60
  "./rrf.js": "./rrf.js",
58
61
  "./rules.js": "./rules.js",
@@ -82,12 +85,10 @@
82
85
  "./vault.js": "./vault.js",
83
86
  "./view.js": "./view.js",
84
87
  "./voice-intents.js": "./voice-intents.js",
88
+ "./voice-speaker.js": "./voice-speaker.js",
85
89
  "./weather.js": "./weather.js",
86
90
  "./web-search.js": "./web-search.js",
87
- "./widget.js": "./widget.js",
88
- "./model-candidates.js": "./model-candidates.js",
89
- "./route-strategies.js": "./route-strategies.js",
90
- "./voice-speaker.js": "./voice-speaker.js"
91
+ "./widget.js": "./widget.js"
91
92
  },
92
93
  "files": [
93
94
  "LICENSE",
@@ -119,6 +120,7 @@
119
120
  "mcp-errors.js",
120
121
  "media-transcript.js",
121
122
  "meeting-analyzers.js",
123
+ "meeting-shape.js",
122
124
  "meeting-text.js",
123
125
  "memory.js",
124
126
  "model-picker.js",