@chatpanel/events 0.48.0 → 0.50.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
@@ -208,3 +208,10 @@ export {
208
208
  checkoutUrl, planOf, planLabel, isPro, isTeam, can, tierFor, withinFreeLimit,
209
209
  verifyEntitlement, licenseFromPayload, needsRecheck,
210
210
  } from './entitlement.js';
211
+
212
+ // One markdown renderer for every client — escaped first, link policy injected.
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';
Binary file
@@ -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,6 +1,6 @@
1
1
  {
2
2
  "name": "@chatpanel/events",
3
- "version": "0.48.0",
3
+ "version": "0.50.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",
@@ -26,9 +26,11 @@
26
26
  "./loop.js": "./loop.js",
27
27
  "./manifest.js": "./manifest.js",
28
28
  "./markdown-authoring.js": "./markdown-authoring.js",
29
+ "./markdown-render.js": "./markdown-render.js",
29
30
  "./mcp-errors.js": "./mcp-errors.js",
30
31
  "./media-transcript.js": "./media-transcript.js",
31
32
  "./meeting-analyzers.js": "./meeting-analyzers.js",
33
+ "./meeting-text.js": "./meeting-text.js",
32
34
  "./memory.js": "./memory.js",
33
35
  "./observability.js": "./observability.js",
34
36
  "./omni.js": "./omni.js",
@@ -97,9 +99,11 @@
97
99
  "loop.js",
98
100
  "manifest.js",
99
101
  "markdown-authoring.js",
102
+ "markdown-render.js",
100
103
  "mcp-errors.js",
101
104
  "media-transcript.js",
102
105
  "meeting-analyzers.js",
106
+ "meeting-text.js",
103
107
  "memory.js",
104
108
  "observability.js",
105
109
  "omni.js",