@chatpanel/events 0.67.0 → 0.68.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 +3 -0
- package/meeting-insights.js +62 -0
- package/package.json +6 -2
- package/record-list.js +110 -0
package/index.js
CHANGED
|
@@ -273,3 +273,6 @@ export { renderMarkdown, defaultLinkPolicy } from './markdown-render.js';
|
|
|
273
273
|
// extension writes and MCP reads, so every client shows one transcript, not three.
|
|
274
274
|
export { parseMeetingText, speakerStats, densityRibbon } from './meeting-text.js';
|
|
275
275
|
export { speakerBreakdown, speakerTimeline, formatTalkTime, SPEAKER_SLOTS } from './meeting-shape.js';
|
|
276
|
+
// A list of records as a person reads it, and what a meeting settled — both read, never derived.
|
|
277
|
+
export { SORT_MODES, SORT_LABELS, sortStamp, sortRecords, filterRecords, dayBucket, rowTime, groupRecords } from './record-list.js';
|
|
278
|
+
export { INSIGHT_KINDS, summarySections, insightKindOf, meetingInsights, hasInsights } from './meeting-insights.js';
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// What a meeting settled, asked for and left open — read out of the summary it already has.
|
|
2
|
+
//
|
|
3
|
+
// The extension writes a meeting's summary as markdown with headings: "## Decisions",
|
|
4
|
+
// "## Action items", "## Open questions", whatever the model chose to call them. A client that
|
|
5
|
+
// wants an Insights view should not re-derive that with a second model call; the sections
|
|
6
|
+
// are there, and one parser that recognises the headings is the same on every client.
|
|
7
|
+
//
|
|
8
|
+
// Pure, forgiving of wording: a heading is matched by what it MEANS ("Decisions", "Agreed",
|
|
9
|
+
// "Outcomes" are one thing), bullets in any marker, numbered or not.
|
|
10
|
+
|
|
11
|
+
const KINDS = Object.freeze([
|
|
12
|
+
{ id: 'decisions', label: 'Decisions', re: /\b(decision|decided|agreed|agreement|outcome|resolution)s?\b/i },
|
|
13
|
+
{ id: 'actions', label: 'Action items', re: /\b(action|todo|to-do|next step|follow[- ]?up|task|owner)s?\b/i },
|
|
14
|
+
{ id: 'questions', label: 'Open questions', re: /\b(question|open item|unresolved|blocker|risk)s?\b/i },
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
export const INSIGHT_KINDS = Object.freeze(KINDS.map((k) => ({ id: k.id, label: k.label })));
|
|
18
|
+
|
|
19
|
+
/** Split markdown into `[{ heading, level, items, text }]` on its headings. */
|
|
20
|
+
export function summarySections(markdown) {
|
|
21
|
+
const lines = String(markdown || '').replace(/\r\n?/g, '\n').split('\n');
|
|
22
|
+
const out = [];
|
|
23
|
+
let cur = { heading: '', level: 0, items: [], text: '' };
|
|
24
|
+
const push = () => { if (cur.heading || cur.items.length || cur.text.trim()) out.push({ ...cur, text: cur.text.trim() }); };
|
|
25
|
+
for (const raw of lines) {
|
|
26
|
+
const h = /^\s{0,3}(#{1,6})\s+(.+?)\s*#*\s*$/.exec(raw);
|
|
27
|
+
if (h) { push(); cur = { heading: h[2].trim(), level: h[1].length, items: [], text: '' }; continue; }
|
|
28
|
+
const b = /^\s*(?:[-*•]|\d+[.)])\s+(.+?)\s*$/.exec(raw);
|
|
29
|
+
if (b) { cur.items.push(b[1]); continue; }
|
|
30
|
+
if (raw.trim()) cur.text += (cur.text ? '\n' : '') + raw.trim();
|
|
31
|
+
}
|
|
32
|
+
push();
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Which insight a heading names, or null for a section that is neither. */
|
|
37
|
+
export function insightKindOf(heading) {
|
|
38
|
+
const h = String(heading || '');
|
|
39
|
+
for (const k of KINDS) if (k.re.test(h)) return k.id;
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* `{ decisions, actions, questions, other }` — each a list of `{ text, section }`; `other`
|
|
45
|
+
* keeps the sections that were none of the three, so a view can still show them.
|
|
46
|
+
*/
|
|
47
|
+
export function meetingInsights(markdown) {
|
|
48
|
+
const out = { decisions: [], actions: [], questions: [], other: [] };
|
|
49
|
+
for (const s of summarySections(markdown)) {
|
|
50
|
+
const kind = insightKindOf(s.heading);
|
|
51
|
+
const items = s.items.length ? s.items : (s.text ? s.text.split('\n') : []);
|
|
52
|
+
if (!kind) { if (s.heading || items.length) out.other.push(s); continue; }
|
|
53
|
+
for (const text of items) out[kind].push({ text, section: s.heading });
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** True when the summary has at least one insight worth a tab. */
|
|
59
|
+
export function hasInsights(markdown) {
|
|
60
|
+
const i = meetingInsights(markdown);
|
|
61
|
+
return i.decisions.length + i.actions.length + i.questions.length > 0;
|
|
62
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/events",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.68.0",
|
|
4
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",
|
|
@@ -98,7 +98,9 @@
|
|
|
98
98
|
"./toolset.js": "./toolset.js",
|
|
99
99
|
"./tool-dispatch.js": "./tool-dispatch.js",
|
|
100
100
|
"./find-tool.js": "./find-tool.js",
|
|
101
|
-
"./web-search-tool.js": "./web-search-tool.js"
|
|
101
|
+
"./web-search-tool.js": "./web-search-tool.js",
|
|
102
|
+
"./record-list.js": "./record-list.js",
|
|
103
|
+
"./meeting-insights.js": "./meeting-insights.js"
|
|
102
104
|
},
|
|
103
105
|
"files": [
|
|
104
106
|
"LICENSE",
|
|
@@ -131,6 +133,7 @@
|
|
|
131
133
|
"mcp-errors.js",
|
|
132
134
|
"media-transcript.js",
|
|
133
135
|
"meeting-analyzers.js",
|
|
136
|
+
"meeting-insights.js",
|
|
134
137
|
"meeting-shape.js",
|
|
135
138
|
"meeting-text.js",
|
|
136
139
|
"memory.js",
|
|
@@ -149,6 +152,7 @@
|
|
|
149
152
|
"queue.js",
|
|
150
153
|
"reach.js",
|
|
151
154
|
"recipe.js",
|
|
155
|
+
"record-list.js",
|
|
152
156
|
"redaction-tokens.js",
|
|
153
157
|
"ref.js",
|
|
154
158
|
"registry.js",
|
package/record-list.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// A list of records as a person reads it: which order, which ones, and under which heading.
|
|
2
|
+
//
|
|
3
|
+
// The desktop showed 300 rows newest-modified-first with a date and nothing else, and it read
|
|
4
|
+
// as unsorted — twelve rows saying "Sep 12" are indistinguishable, "modified" is not the
|
|
5
|
+
// date a person remembers a chat by, and nothing separated today from last month. The query
|
|
6
|
+
// was right; the reading was impossible. These are the rules that make the same rows
|
|
7
|
+
// legible, kept out of the client because every list of records — the extension's history,
|
|
8
|
+
// a phone's — has to answer the same three questions the same way.
|
|
9
|
+
//
|
|
10
|
+
// Pure: `now` is injected, and a record needs only `{ title, snippet?, updatedAt, createdAt }`.
|
|
11
|
+
|
|
12
|
+
export const SORT_MODES = Object.freeze(['recent', 'started', 'title']);
|
|
13
|
+
|
|
14
|
+
export const SORT_LABELS = Object.freeze({
|
|
15
|
+
recent: 'Recently active',
|
|
16
|
+
started: 'Date started',
|
|
17
|
+
title: 'Title A–Z',
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0);
|
|
21
|
+
|
|
22
|
+
/** The timestamp a sort mode reads — modified for `recent`, created for `started`. */
|
|
23
|
+
export function sortStamp(rec, mode = 'recent') {
|
|
24
|
+
if (mode === 'started') return num(rec?.createdAt) || num(rec?.updatedAt);
|
|
25
|
+
return num(rec?.updatedAt) || num(rec?.createdAt);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function sortRecords(items, mode = 'recent') {
|
|
29
|
+
const list = Array.isArray(items) ? [...items] : [];
|
|
30
|
+
if (mode === 'title') {
|
|
31
|
+
return list.sort((a, b) => String(a?.title || '').localeCompare(String(b?.title || ''), undefined, { sensitivity: 'base' }) || sortStamp(b) - sortStamp(a));
|
|
32
|
+
}
|
|
33
|
+
const m = SORT_MODES.includes(mode) ? mode : 'recent';
|
|
34
|
+
return list.sort((a, b) => sortStamp(b, m) - sortStamp(a, m));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Keep the rows every word of the query appears in — title or snippet, any order, any case.
|
|
39
|
+
* A query of nothing keeps everything, so a filter box can be bound to it directly.
|
|
40
|
+
*/
|
|
41
|
+
export function filterRecords(items, query) {
|
|
42
|
+
const words = String(query || '').toLowerCase().split(/\s+/).filter(Boolean);
|
|
43
|
+
const list = Array.isArray(items) ? items : [];
|
|
44
|
+
if (!words.length) return list;
|
|
45
|
+
return list.filter((r) => {
|
|
46
|
+
const hay = `${r?.title || ''}\n${r?.snippet || ''}`.toLowerCase();
|
|
47
|
+
return words.every((w) => hay.includes(w));
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const DAY = 86_400_000;
|
|
52
|
+
|
|
53
|
+
function startOfDay(ts) {
|
|
54
|
+
const d = new Date(ts);
|
|
55
|
+
d.setHours(0, 0, 0, 0);
|
|
56
|
+
return d.getTime();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
|
|
60
|
+
const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
|
61
|
+
|
|
62
|
+
/** Which heading a timestamp files under, relative to `now`. */
|
|
63
|
+
export function dayBucket(ts, now = Date.now()) {
|
|
64
|
+
const t = num(ts);
|
|
65
|
+
if (!t) return { key: 'undated', label: 'Undated', order: 9e15 };
|
|
66
|
+
const today = startOfDay(now);
|
|
67
|
+
const day = startOfDay(t);
|
|
68
|
+
const daysAgo = Math.round((today - day) / DAY);
|
|
69
|
+
if (daysAgo <= 0) return { key: 'today', label: 'Today', order: 0 };
|
|
70
|
+
if (daysAgo === 1) return { key: 'yesterday', label: 'Yesterday', order: 1 };
|
|
71
|
+
if (daysAgo < 7) return { key: 'week', label: 'Earlier this week', order: 2 };
|
|
72
|
+
const d = new Date(t);
|
|
73
|
+
const n = new Date(now);
|
|
74
|
+
if (d.getFullYear() === n.getFullYear() && d.getMonth() === n.getMonth()) return { key: 'month', label: 'Earlier this month', order: 3 };
|
|
75
|
+
const label = d.getFullYear() === n.getFullYear() ? MONTHS[d.getMonth()] : `${MONTHS[d.getMonth()]} ${d.getFullYear()}`;
|
|
76
|
+
return { key: `m-${d.getFullYear()}-${d.getMonth()}`, label, order: 4 + (n.getFullYear() * 12 + n.getMonth()) - (d.getFullYear() * 12 + d.getMonth()) };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The time a row shows, sized to how far away it is: a clock today and yesterday, a weekday
|
|
81
|
+
* this week, a short date after that. Inside a day group the clock is the only thing that
|
|
82
|
+
* tells two rows apart — which is the whole reason the desktop's list looked unsorted.
|
|
83
|
+
*/
|
|
84
|
+
export function rowTime(ts, now = Date.now(), { locale = undefined } = {}) {
|
|
85
|
+
const t = num(ts);
|
|
86
|
+
if (!t) return '';
|
|
87
|
+
const b = dayBucket(t, now);
|
|
88
|
+
const d = new Date(t);
|
|
89
|
+
if (b.key === 'today' || b.key === 'yesterday') return d.toLocaleTimeString(locale, { hour: 'numeric', minute: '2-digit' });
|
|
90
|
+
if (b.key === 'week') return DAYS[d.getDay()];
|
|
91
|
+
const sameYear = d.getFullYear() === new Date(now).getFullYear();
|
|
92
|
+
return d.toLocaleDateString(locale, { month: 'short', day: 'numeric', ...(sameYear ? {} : { year: 'numeric' }) });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Sort, filter and group in one pass: `[{ key, label, items }]`, groups in reading order.
|
|
97
|
+
* With `mode: 'title'` there is one group and no heading, because an alphabetical list has
|
|
98
|
+
* nothing to do with days.
|
|
99
|
+
*/
|
|
100
|
+
export function groupRecords(items, { mode = 'recent', query = '', now = Date.now() } = {}) {
|
|
101
|
+
const sorted = sortRecords(filterRecords(items, query), mode);
|
|
102
|
+
if (mode === 'title') return sorted.length ? [{ key: 'all', label: '', items: sorted }] : [];
|
|
103
|
+
const groups = new Map();
|
|
104
|
+
for (const r of sorted) {
|
|
105
|
+
const b = dayBucket(sortStamp(r, mode), now);
|
|
106
|
+
if (!groups.has(b.key)) groups.set(b.key, { key: b.key, label: b.label, order: b.order, items: [] });
|
|
107
|
+
groups.get(b.key).items.push(r);
|
|
108
|
+
}
|
|
109
|
+
return [...groups.values()].sort((a, b) => a.order - b.order).map(({ order, ...g }) => g);
|
|
110
|
+
}
|