@chatpanel/events 0.49.0 → 0.52.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
@@ -211,3 +211,7 @@ export {
211
211
 
212
212
  // One markdown renderer for every client — escaped first, link policy injected.
213
213
  export { renderMarkdown, defaultLinkPolicy } from './markdown-render.js';
214
+
215
+ // A meeting read back out of the flat text the warm store holds — the same grammar the
216
+ // extension writes and MCP reads, so every client shows one transcript, not three.
217
+ export { parseMeetingText, speakerStats, densityRibbon } from './meeting-text.js';
@@ -0,0 +1,135 @@
1
+ // READING A MEETING BACK OUT OF ITS TEXT FORM.
2
+ //
3
+ // The same problem `parseBriefText` solves, for the same reason. The warm store holds
4
+ // records — `{ id, title, type, date, text }` and nothing else — so a meeting crosses to
5
+ // the gateway, to MCP, and to any client that reads the index as one flat string. A client
6
+ // that wants to show a transcript with speakers, or a summary separate from the captions,
7
+ // has to get that structure from somewhere.
8
+ //
9
+ // It is a GRAMMAR, not a rendering: the extension's meeting source writes
10
+ //
11
+ // MEETING: <title>
12
+ // Date: <date>
13
+ // Platform: <platform>
14
+ //
15
+ // SUMMARY:
16
+ // <markdown>
17
+ //
18
+ // TRANSCRIPT:
19
+ // <preamble>
20
+ // [<clock>] <speaker>: <line>
21
+ //
22
+ // and this reads it back. Round-trips in the tests.
23
+ //
24
+ // THE TRANSCRIPT IS UNTRUSTED CONTENT, and the source text says so in its own preamble:
25
+ // live captions, meeting chat and participant-CHOSEN display names. A speaker can name
26
+ // themselves anything, including something shaped like an instruction. This module returns
27
+ // it as DATA with the speaker as a separate field — never merged into a line that a prompt
28
+ // might read as a directive — and every consumer must render it escaped.
29
+
30
+ const HEAD = /^MEETING:\s*(.*)$/;
31
+ const LINE = /^\[([^\]]{1,20})\]\s*([^:]{1,80}?):\s*([\s\S]*)$/;
32
+
33
+ /** Returns `null` for text that is not a meeting, so "not a meeting" differs from "empty". */
34
+ export function parseMeetingText(text) {
35
+ const lines = String(text ?? '').split('\n');
36
+ const head = HEAD.exec(lines[0] || '');
37
+ if (!head) return null;
38
+
39
+ const out = {
40
+ title: head[1].trim(),
41
+ date: '',
42
+ platform: '',
43
+ tags: [],
44
+ summary: '',
45
+ preamble: '',
46
+ segments: [],
47
+ speakers: [],
48
+ };
49
+
50
+ let section = 'head';
51
+ const summary = [];
52
+ const preamble = [];
53
+
54
+ for (let i = 1; i < lines.length; i += 1) {
55
+ const line = lines[i];
56
+ const trimmed = line.trim();
57
+
58
+ if (trimmed === 'SUMMARY:') { section = 'summary'; continue; }
59
+ if (trimmed === 'TRANSCRIPT:') { section = 'transcript'; continue; }
60
+
61
+ if (section === 'head') {
62
+ if (line.startsWith('Date: ')) out.date = line.slice(6).trim();
63
+ else if (line.startsWith('Platform: ')) out.platform = line.slice(10).trim();
64
+ else if (line.startsWith('Tags: ')) out.tags = line.slice(6).split(',').map((t) => t.trim()).filter(Boolean);
65
+ continue;
66
+ }
67
+
68
+ if (section === 'summary') { summary.push(line); continue; }
69
+
70
+ // Transcript. A line that matches the grammar is a segment; anything before the first
71
+ // one is the preamble (the injection warning and the "--- Meeting Transcript ---" rule),
72
+ // which is framing rather than content and should not be rendered as somebody speaking.
73
+ const m = LINE.exec(line);
74
+ if (m) {
75
+ out.segments.push({ at: m[1].trim(), speaker: m[2].trim(), text: m[3].trim() });
76
+ } else if (!out.segments.length) {
77
+ preamble.push(line);
78
+ } else if (trimmed && out.segments.length) {
79
+ // A continuation of the previous speaker's line — a wrapped caption, not a new turn.
80
+ out.segments[out.segments.length - 1].text += `\n${line}`;
81
+ }
82
+ }
83
+
84
+ out.summary = summary.join('\n').trim();
85
+ out.preamble = preamble.join('\n').trim();
86
+ out.speakers = [...new Set(out.segments.map((s) => s.speaker))];
87
+ return out;
88
+ }
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
+ }
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@chatpanel/events",
3
- "version": "0.49.0",
3
+ "version": "0.52.0",
4
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": {
8
8
  ".": "./index.js",
9
9
  "./adapters.js": "./adapters.js",
10
+ "./attribution.js": "./attribution.js",
10
11
  "./backup-envelope.js": "./backup-envelope.js",
11
12
  "./capability.js": "./capability.js",
12
13
  "./citations.js": "./citations.js",
@@ -30,6 +31,7 @@
30
31
  "./mcp-errors.js": "./mcp-errors.js",
31
32
  "./media-transcript.js": "./media-transcript.js",
32
33
  "./meeting-analyzers.js": "./meeting-analyzers.js",
34
+ "./meeting-text.js": "./meeting-text.js",
33
35
  "./memory.js": "./memory.js",
34
36
  "./observability.js": "./observability.js",
35
37
  "./omni.js": "./omni.js",
@@ -102,6 +104,7 @@
102
104
  "mcp-errors.js",
103
105
  "media-transcript.js",
104
106
  "meeting-analyzers.js",
107
+ "meeting-text.js",
105
108
  "memory.js",
106
109
  "observability.js",
107
110
  "omni.js",
package/schedule.js CHANGED
@@ -112,6 +112,51 @@ export function nextFireAt(schedule, from) {
112
112
  }
113
113
  }
114
114
 
115
+ /** Sunday-first, matching `Date#getDay()` and the `weekday` field. */
116
+ export const WEEKDAY_NAMES = Object.freeze([
117
+ 'Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday',
118
+ ]);
119
+
120
+ const hhmm = (s) => `${String(s?.hour ?? 0).padStart(2, '0')}:${String(s?.minute ?? 0).padStart(2, '0')}`;
121
+
122
+ /**
123
+ * A schedule as a sentence: "every weekday at 08:00", not `{kind:'daily',hour:8,...}`.
124
+ *
125
+ * It lives beside the model rather than in a client because a schedule the user cannot read
126
+ * back is a schedule they cannot trust, and every client has to solve that. The desktop and
127
+ * the extension had already written this twice, and the copies disagreed: one rendered
128
+ * `weekdaysOnly` and the other silently dropped it, so a job that skipped weekends still
129
+ * read as "every day".
130
+ *
131
+ * Returns '' for a schedule it cannot describe rather than inventing one — an unreadable
132
+ * label is better than a confident wrong one.
133
+ */
134
+ export function describeSchedule(s) {
135
+ if (!s || typeof s !== 'object') return '';
136
+ switch (s.kind) {
137
+ case 'once': {
138
+ if (!(s.at > 0)) return '';
139
+ return `once, at ${new Date(s.at).toLocaleString()}`;
140
+ }
141
+ case 'interval': {
142
+ const ms = Number(s.everyMs) || 0;
143
+ if (ms < 60_000) return '';
144
+ const mins = Math.round(ms / 60_000);
145
+ if (mins % 1440 === 0) { const d = mins / 1440; return `every ${d === 1 ? 'day' : `${d} days`}`; }
146
+ if (mins % 60 === 0) { const h = mins / 60; return `every ${h === 1 ? 'hour' : `${h} hours`}`; }
147
+ return `every ${mins} minutes`;
148
+ }
149
+ case 'daily':
150
+ return s.weekdaysOnly ? `every weekday at ${hhmm(s)}` : `every day at ${hhmm(s)}`;
151
+ case 'weekly': {
152
+ const name = WEEKDAY_NAMES[s.weekday];
153
+ return name ? `every ${name} at ${hhmm(s)}` : '';
154
+ }
155
+ default:
156
+ return '';
157
+ }
158
+ }
159
+
115
160
  /** Every firing in (from, to], oldest first. Capped: a long sleep is not a queue of work. */
116
161
  export function occurrencesBetween(schedule, from, to, max = MAX_CATCH_UP) {
117
162
  const out = [];