@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 +4 -0
- package/meeting-text.js +135 -0
- package/package.json +4 -1
- package/schedule.js +45 -0
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';
|
package/meeting-text.js
ADDED
|
@@ -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.
|
|
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 = [];
|