@chatpanel/events 0.102.0 → 0.103.1
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/adapters.js +1 -83
- package/adaptive-tool-policy.js +1 -45
- package/agent.js +2 -255
- package/attribution.js +1 -132
- package/backup-envelope.js +1 -221
- package/board-tool.js +2 -165
- package/budget.js +1 -116
- package/capability.js +1 -126
- package/citations.js +5 -127
- package/client-prefs.js +1 -148
- package/context-attachments.js +8 -144
- package/cowriter-router.js +1 -83
- package/cowriter-writer.js +19 -193
- package/cowriter.js +1 -190
- package/curate.js +3 -509
- package/distance.js +1 -124
- package/engine.js +1 -131
- package/entitlement.js +1 -332
- package/entity.js +1 -354
- package/event.js +1 -175
- package/extraction.js +4 -293
- package/failover.js +1 -93
- package/find-tool.js +1 -66
- package/flowchart.js +2 -644
- package/gate.js +1 -74
- package/harness.js +4 -101
- package/index.js +1 -345
- package/invariants.js +1 -174
- package/job.js +3 -147
- package/kernel.js +1 -255
- package/knowledge-derive.js +1 -267
- package/knowledge.js +3 -230
- package/library.js +11 -280
- package/loop.js +1 -132
- package/manifest.js +1 -107
- package/markdown-authoring.js +11 -288
- package/markdown-render.js +0 -0
- package/mcp-client.js +8 -358
- package/mcp-dispatch.js +1 -52
- package/mcp-errors.js +1 -106
- package/mcp-manager.js +1 -127
- package/media-transcript.js +8 -531
- package/meeting-analyzers.js +1 -83
- package/meeting-insights.js +7 -164
- package/meeting-shape.js +1 -282
- package/meeting-text.js +5 -94
- package/memory.js +2 -638
- package/model-candidates.js +1 -358
- package/model-health.js +1 -182
- package/model-ledger.js +1 -228
- package/model-picker.js +1 -188
- package/note-actions.js +15 -246
- package/note-graph.js +1 -158
- package/note-links.js +1 -68
- package/note-mentions.js +8 -114
- package/note-plan.js +9 -107
- package/note-research.js +1 -135
- package/observability.js +1 -119
- package/omni.js +1 -125
- package/order.js +1 -78
- package/package.json +1 -4
- package/pdf-layout.js +7 -253
- package/project-run.js +20 -444
- package/project.js +1 -170
- package/promotion.js +1 -171
- package/queue.js +1 -98
- package/reach.js +1 -23
- package/recipe-tool.js +2 -161
- package/recipe.js +1 -264
- package/record-list.js +2 -110
- package/recruit.js +3 -456
- package/redaction-tokens.js +1 -61
- package/ref.js +1 -55
- package/registry.js +1 -240
- package/route-graph.js +1 -115
- package/route-strategies.js +2 -232
- package/router.js +2 -880
- package/rrf.js +1 -89
- package/rules.js +1 -142
- package/schedule.js +1 -663
- package/scm-connection.js +1 -180
- package/scopes.js +1 -12
- package/scorecard.js +1 -335
- package/search-engines.js +1 -81
- package/skill-manifest.js +1 -330
- package/skill-scan.js +3 -209
- package/skill-sources.js +1 -162
- package/skill-vars.js +2 -252
- package/slash-commands.js +1 -158
- package/source-gate.js +1 -92
- package/sources-retrieval.js +5 -186
- package/sources.js +1 -273
- package/store.js +1 -171
- package/structured.js +4 -901
- package/subject-kinds.js +1 -5
- package/subject-name.js +1 -96
- package/sync-plan.js +1 -213
- package/synthesis.js +3 -123
- package/tags.js +1 -203
- package/team-board.js +6 -337
- package/team-cache.js +2 -63
- package/team-observe.js +1 -118
- package/team-org.js +1 -442
- package/team-plan.js +4 -131
- package/team-record.js +1 -225
- package/team-run.js +9 -652
- package/team-subtask.js +2 -191
- package/team-task.js +2 -124
- package/team-tool.js +2 -196
- package/team-trail.js +2 -73
- package/team-worklog.js +2 -149
- package/team.js +1 -302
- package/text-search.js +1 -155
- package/theme.js +17 -154
- package/titles.js +4 -285
- package/tool-discovery.js +1 -87
- package/tool-dispatch.js +5 -230
- package/tool-groups.js +1 -81
- package/tool-hints.js +10 -185
- package/tool-loop-guard.js +4 -182
- package/tool-need.js +2 -124
- package/tool-result.js +2 -438
- package/tool-round.js +0 -0
- package/tool-schema.js +1 -155
- package/tool-traits.js +1 -182
- package/toolset.js +2 -88
- package/trajectory.js +1 -530
- package/turn-loop.js +4 -440
- package/upcast.js +1 -37
- package/vault.js +2 -250
- package/view.js +1 -88
- package/voice-intents.js +2 -1488
- package/voice-speaker.js +1 -98
- package/weather-tool.js +1 -65
- package/weather.js +2 -211
- package/web-search-tool.js +14 -102
- package/web-search.js +1 -160
- package/widget.js +1 -153
package/meeting-insights.js
CHANGED
|
@@ -1,164 +1,7 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
}
|
|
63
|
-
|
|
64
|
-
// ── The meeting page's own read of the notes ──────────────────────────────────────
|
|
65
|
-
//
|
|
66
|
-
// The Insights tab on a meeting is five tiles — Summary, Topics, Key Moments, Shared Links,
|
|
67
|
-
// Action Items — and the extension's meetings page parsed them out of the notes with its own
|
|
68
|
-
// section matcher, badge reader and owner/due grammar, inline. The desktop's meeting page
|
|
69
|
-
// draws the same five tiles, so the parse is here: one answer to "which line is a risk".
|
|
70
|
-
//
|
|
71
|
-
// Different from `meetingInsights` above on purpose: that groups by what a line MEANS
|
|
72
|
-
// (decisions / actions / questions) for a dashboard count; this keeps the notes' own
|
|
73
|
-
// sections and the marks inside them (a [Risk] badge, an _(owner)_, a due date).
|
|
74
|
-
|
|
75
|
-
const isBullet = (l) => /^\s*([-*+]|\d+\.)\s+/.test(l);
|
|
76
|
-
const stripBullet = (l) => l.replace(/^\s*([-*+]|\d+\.)\s+/, '').trim();
|
|
77
|
-
|
|
78
|
-
/** Plain text of a markdown-ish line: bold, italic, code and underscores unwrapped. */
|
|
79
|
-
export const demd = (s) => String(s ?? '').replace(/\*\*(.+?)\*\*/g, '$1').replace(/(^|[^*])\*(?!\s)(.+?)\*/g, '$1$2').replace(/`(.+?)`/g, '$1').replace(/_(.+?)_/g, '$1').trim();
|
|
80
|
-
|
|
81
|
-
export const NOTE_SECTIONS = Object.freeze(['summary', 'topics', 'moments', 'links', 'actions']);
|
|
82
|
-
|
|
83
|
-
/** Which of the five tiles a heading belongs to, or null. */
|
|
84
|
-
export function noteSectionKind(heading) {
|
|
85
|
-
const s = String(heading || '').toLowerCase();
|
|
86
|
-
if (/tl;?dr|summary|overview|recap/.test(s)) return 'summary';
|
|
87
|
-
if (/topic|agenda/.test(s)) return 'topics';
|
|
88
|
-
if (/key moment|moments|highlight|decision/.test(s)) return 'moments';
|
|
89
|
-
if (/shared link|link|url|resource|reference/.test(s)) return 'links';
|
|
90
|
-
if (/action|task|next step|to-?do|follow-?up/.test(s)) return 'actions';
|
|
91
|
-
return null;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
export const MOMENT_BADGES = Object.freeze(['decision', 'risk', 'question', 'highlight']);
|
|
95
|
-
|
|
96
|
-
/** A key moment's badge — `[Risk] the replica…` → risk — defaulting to highlight. */
|
|
97
|
-
export function momentBadge(text) {
|
|
98
|
-
const t = String(text ?? '');
|
|
99
|
-
// `**Risk:**` puts the colon before the closing stars; `**Risk**:` after. Both are a badge.
|
|
100
|
-
// Anchored: a badge is how the line STARTS — "a plain highlight" is not a highlight badge.
|
|
101
|
-
const m = t.match(/^\s*\*{0,2}\[?\s*(decision|risk|question|highlight)\s*\]?\s*:?\*{0,2}\s*:?/i);
|
|
102
|
-
if (m) return { badge: m[1].toLowerCase(), text: t.slice(m.index + m[0].length).trim() };
|
|
103
|
-
return { badge: 'highlight', text: t };
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* The five tiles, read out of a meeting's notes.
|
|
108
|
-
* -> { summary, topics: [], moments: [{ badge, text }], links: [], actions: [{ text, done, owner, due, lineIndex }], hasAny }
|
|
109
|
-
*
|
|
110
|
-
* An action item is `- [ ] text _(owner)_ — due` in any of its looser forms; a plain bullet
|
|
111
|
-
* under the actions heading is an item with nothing known about it.
|
|
112
|
-
*/
|
|
113
|
-
export function parseMeetingNotes(md) {
|
|
114
|
-
const out = { summary: '', topics: [], moments: [], links: [], actions: [], hasAny: false };
|
|
115
|
-
const src = String(md ?? '');
|
|
116
|
-
if (!src.trim()) return out;
|
|
117
|
-
let cur = 'summary';
|
|
118
|
-
const summaryParts = [];
|
|
119
|
-
src.replace(/\r\n?/g, '\n').split('\n').forEach((raw, idx) => {
|
|
120
|
-
const line = raw.replace(/\s+$/, '');
|
|
121
|
-
const h = line.match(/^#{1,6}\s+(.*)$/);
|
|
122
|
-
if (h) { cur = noteSectionKind(h[1]); return; }
|
|
123
|
-
if (!line.trim()) return;
|
|
124
|
-
if (cur === 'summary') summaryParts.push(isBullet(line) ? stripBullet(line) : line.trim());
|
|
125
|
-
else if (cur === 'topics') { if (isBullet(line)) out.topics.push(demd(stripBullet(line))); }
|
|
126
|
-
else if (cur === 'moments') { if (isBullet(line)) { const b = momentBadge(stripBullet(line)); out.moments.push({ badge: b.badge, text: demd(b.text) }); } }
|
|
127
|
-
else if (cur === 'links') {
|
|
128
|
-
if (isBullet(line)) {
|
|
129
|
-
const value = demd(stripBullet(line));
|
|
130
|
-
if (value && !/^no shared links\.?$/i.test(value)) out.links.push(value);
|
|
131
|
-
}
|
|
132
|
-
} else if (cur === 'actions') {
|
|
133
|
-
const m = line.match(/^\s*[-*+]\s*\[([ xX])\]\s*(.*)$/);
|
|
134
|
-
if (m) {
|
|
135
|
-
let text = m[2].trim(); let owner = ''; let due = '';
|
|
136
|
-
const ow = text.match(/_\(([^)]+)\)_|\(([^)]+)\)/);
|
|
137
|
-
if (ow) { owner = (ow[1] || ow[2] || '').trim(); text = text.replace(ow[0], '').trim(); }
|
|
138
|
-
const du = text.match(/[—-]\s*_?([^_]+?)_?\s*$/);
|
|
139
|
-
if (du && /due|\d|mon|tue|wed|thu|fri|sat|sun|jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec|today|tomorrow|eod|eow|next/i.test(du[1])) {
|
|
140
|
-
due = du[1].replace(/^due\s*/i, '').trim(); text = text.slice(0, du.index).trim();
|
|
141
|
-
}
|
|
142
|
-
out.actions.push({ text: demd(text), done: m[1].toLowerCase() === 'x', owner: demd(owner), due, lineIndex: idx });
|
|
143
|
-
} else if (isBullet(line)) out.actions.push({ text: demd(stripBullet(line)), done: false, owner: '', due: '', lineIndex: idx });
|
|
144
|
-
}
|
|
145
|
-
});
|
|
146
|
-
out.summary = demd(summaryParts.join(' ').trim());
|
|
147
|
-
out.hasAny = !!(out.summary || out.topics.length || out.moments.length || out.links.length || out.actions.length);
|
|
148
|
-
return out;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/** Action items grouped by owner, named owners first, "Unassigned" last. */
|
|
152
|
-
export function groupActionsByOwner(actions) {
|
|
153
|
-
const groups = new Map();
|
|
154
|
-
(actions || []).forEach((action, index) => {
|
|
155
|
-
const owner = (action.owner || '').trim() || 'Unassigned';
|
|
156
|
-
if (!groups.has(owner)) groups.set(owner, { owner, items: [] });
|
|
157
|
-
groups.get(owner).items.push({ action, index });
|
|
158
|
-
});
|
|
159
|
-
return [...groups.values()].sort((a, b) => {
|
|
160
|
-
if (a.owner === 'Unassigned') return 1;
|
|
161
|
-
if (b.owner === 'Unassigned') return -1;
|
|
162
|
-
return a.owner.localeCompare(b.owner);
|
|
163
|
-
});
|
|
164
|
-
}
|
|
1
|
+
const w=Object.freeze([{id:"decisions",label:"Decisions",re:/\b(decision|decided|agreed|agreement|outcome|resolution)s?\b/i},{id:"actions",label:"Action items",re:/\b(action|todo|to-do|next step|follow[- ]?up|task|owner)s?\b/i},{id:"questions",label:"Open questions",re:/\b(question|open item|unresolved|blocker|risk)s?\b/i}]),O=Object.freeze(w.map(s=>({id:s.id,label:s.label})));function b(s){const e=String(s||"").replace(/\r\n?/g,`
|
|
2
|
+
`).split(`
|
|
3
|
+
`),t=[];let n={heading:"",level:0,items:[],text:""};const o=()=>{(n.heading||n.items.length||n.text.trim())&&t.push({...n,text:n.text.trim()})};for(const c of e){const a=/^\s{0,3}(#{1,6})\s+(.+?)\s*#*\s*$/.exec(c);if(a){o(),n={heading:a[2].trim(),level:a[1].length,items:[],text:""};continue}const i=/^\s*(?:[-*•]|\d+[.)])\s+(.+?)\s*$/.exec(c);if(i){n.items.push(i[1]);continue}c.trim()&&(n.text+=(n.text?`
|
|
4
|
+
`:"")+c.trim())}return o(),t}function k(s){const e=String(s||"");for(const t of w)if(t.re.test(e))return t.id;return null}function y(s){const e={decisions:[],actions:[],questions:[],other:[]};for(const t of b(s)){const n=k(t.heading),o=t.items.length?t.items:t.text?t.text.split(`
|
|
5
|
+
`):[];if(!n){(t.heading||o.length)&&e.other.push(t);continue}for(const c of o)e[n].push({text:c,section:t.heading})}return e}function _(s){const e=y(s);return e.decisions.length+e.actions.length+e.questions.length>0}const m=s=>/^\s*([-*+]|\d+\.)\s+/.test(s),h=s=>s.replace(/^\s*([-*+]|\d+\.)\s+/,"").trim(),u=s=>String(s??"").replace(/\*\*(.+?)\*\*/g,"$1").replace(/(^|[^*])\*(?!\s)(.+?)\*/g,"$1$2").replace(/`(.+?)`/g,"$1").replace(/_(.+?)_/g,"$1").trim(),I=Object.freeze(["summary","topics","moments","links","actions"]);function S(s){const e=String(s||"").toLowerCase();return/tl;?dr|summary|overview|recap/.test(e)?"summary":/topic|agenda/.test(e)?"topics":/key moment|moments|highlight|decision/.test(e)?"moments":/shared link|link|url|resource|reference/.test(e)?"links":/action|task|next step|to-?do|follow-?up/.test(e)?"actions":null}const j=Object.freeze(["decision","risk","question","highlight"]);function $(s){const e=String(s??""),t=e.match(/^\s*\*{0,2}\[?\s*(decision|risk|question|highlight)\s*\]?\s*:?\*{0,2}\s*:?/i);return t?{badge:t[1].toLowerCase(),text:e.slice(t.index+t[0].length).trim()}:{badge:"highlight",text:e}}function v(s){const e={summary:"",topics:[],moments:[],links:[],actions:[],hasAny:!1},t=String(s??"");if(!t.trim())return e;let n="summary";const o=[];return t.replace(/\r\n?/g,`
|
|
6
|
+
`).split(`
|
|
7
|
+
`).forEach((c,a)=>{const i=c.replace(/\s+$/,""),p=i.match(/^#{1,6}\s+(.*)$/);if(p){n=S(p[1]);return}if(i.trim()){if(n==="summary")o.push(m(i)?h(i):i.trim());else if(n==="topics")m(i)&&e.topics.push(u(h(i)));else if(n==="moments"){if(m(i)){const r=$(h(i));e.moments.push({badge:r.badge,text:u(r.text)})}}else if(n==="links"){if(m(i)){const r=u(h(i));r&&!/^no shared links\.?$/i.test(r)&&e.links.push(r)}}else if(n==="actions"){const r=i.match(/^\s*[-*+]\s*\[([ xX])\]\s*(.*)$/);if(r){let l=r[2].trim(),g="",x="";const d=l.match(/_\(([^)]+)\)_|\(([^)]+)\)/);d&&(g=(d[1]||d[2]||"").trim(),l=l.replace(d[0],"").trim());const f=l.match(/[—-]\s*_?([^_]+?)_?\s*$/);f&&/due|\d|mon|tue|wed|thu|fri|sat|sun|jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec|today|tomorrow|eod|eow|next/i.test(f[1])&&(x=f[1].replace(/^due\s*/i,"").trim(),l=l.slice(0,f.index).trim()),e.actions.push({text:u(l),done:r[1].toLowerCase()==="x",owner:u(g),due:x,lineIndex:a})}else m(i)&&e.actions.push({text:u(h(i)),done:!1,owner:"",due:"",lineIndex:a})}}}),e.summary=u(o.join(" ").trim()),e.hasAny=!!(e.summary||e.topics.length||e.moments.length||e.links.length||e.actions.length),e}function q(s){const e=new Map;return(s||[]).forEach((t,n)=>{const o=(t.owner||"").trim()||"Unassigned";e.has(o)||e.set(o,{owner:o,items:[]}),e.get(o).items.push({action:t,index:n})}),[...e.values()].sort((t,n)=>t.owner==="Unassigned"?1:n.owner==="Unassigned"?-1:t.owner.localeCompare(n.owner))}export{O as INSIGHT_KINDS,j as MOMENT_BADGES,I as NOTE_SECTIONS,u as demd,q as groupActionsByOwner,_ as hasInsights,k as insightKindOf,y as meetingInsights,$ as momentBadge,S as noteSectionKind,v as parseMeetingNotes,b as summarySections};
|
package/meeting-shape.js
CHANGED
|
@@ -1,282 +1 @@
|
|
|
1
|
-
|
|
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
|
-
}
|
|
263
|
-
|
|
264
|
-
/**
|
|
265
|
-
* A "speaker" that is really an avatar URL. Some captures (Zoom among them) put the picture
|
|
266
|
-
* where the name goes, for a participant whose name was never captured. The URL is a fine
|
|
267
|
-
* identity — distinct people get distinct URLs, so the arithmetic above is right — but it
|
|
268
|
-
* is not a label, and printing it puts a tracking-shaped link in a legend. A chart calls
|
|
269
|
-
* such a speaker "Participant", numbered when there is more than one.
|
|
270
|
-
*/
|
|
271
|
-
export const isSpeakerImageValue = (value) => typeof value === 'string'
|
|
272
|
-
&& /^https?:\/\/\S+$/i.test(value.trim())
|
|
273
|
-
&& /\.(png|jpe?g|gif|webp|svg)(\?|#|$)|images\.zoom\.us|\/p\/v2\/|gravatar|avatar|googleusercontent|wbxcdn|teams\.(microsoft|live)/i.test(value);
|
|
274
|
-
|
|
275
|
-
/** What to call each speaker on screen — names as they are, avatar URLs as "Participant n". */
|
|
276
|
-
export function speakerLabeller(speakers) {
|
|
277
|
-
const names = (speakers || []).map((s) => (typeof s === 'string' ? s : s?.speaker));
|
|
278
|
-
const anon = names.filter(isSpeakerImageValue);
|
|
279
|
-
const numbered = anon.length > 1;
|
|
280
|
-
const index = new Map(anon.map((n, i) => [n, `Participant ${i + 1}`]));
|
|
281
|
-
return (name) => (isSpeakerImageValue(name) ? (numbered ? index.get(name) : 'Participant') : name);
|
|
282
|
-
}
|
|
1
|
+
const v=15,R=800,A=3e4,$=5,y=r=>String(r??"").replace(/\s+/g," ").trim();function T(r){const o=r?.t??r?.ts??null;if(typeof o=="number"&&Number.isFinite(o)&&o>0)return o;if(typeof o=="string"&&/^\d{4}-\d{2}-\d{2}T/.test(o)){const e=Date.parse(o);if(Number.isFinite(e))return e}return null}function d(r){return(Array.isArray(r)?r:r?.segments||[]).map(e=>({speaker:y(e?.speaker)||"Speaker",text:String(e?.text??""),t:T(e)})).filter(e=>e.text.trim()||e.speaker)}const N=r=>Math.min(3e4,Math.max(800,r.length/15*1e3));function S(r){const o=d(r).filter(e=>e.t!==null);return o.length<2||(o.sort((e,a)=>e.t-a.t),o.at(-1).t-o[0].t<=0)?null:o.map((e,a)=>{const s=o[a+1],c=s?s.t-e.t:0,l=c>0?Math.min(c,3e4):N(e.text);return{...e,ms:Math.max(800,l)}})}function P(r,{slots:o=5}={}){const e=S(r),a=e||d(r),s=e?"clock":"text",c=new Map;for(const t of a){const n=c.get(t.speaker)||{speaker:t.speaker,lines:0,chars:0,ms:0};n.lines+=1,n.chars+=t.text.length,n.ms+=t.ms||0,c.set(t.speaker,n)}const l=t=>s==="clock"?t.ms:t.chars,i=[...c.values()].reduce((t,n)=>t+l(n),0),f=[...c.values()].sort((t,n)=>l(n)-l(t)||t.speaker.localeCompare(n.speaker)).map((t,n)=>({speaker:t.speaker,slot:n<o?n:-1,lines:t.lines,chars:t.chars,ms:s==="clock"?Math.round(t.ms):null,seconds:s==="clock"?Math.round(t.ms/1e3):null,share:i?l(t)/i:0})),u=f.filter(t=>t.slot===-1);return{basis:s,totalMs:s==="clock"?Math.round(i):null,lines:a.length,speakers:f,shown:f.filter(t=>t.slot>=0),other:u.length?{speakers:u.map(t=>t.speaker),lines:u.reduce((t,n)=>t+n.lines,0),ms:s==="clock"?u.reduce((t,n)=>t+n.ms,0):null,seconds:s==="clock"?Math.round(u.reduce((t,n)=>t+n.ms,0)/1e3):null,share:u.reduce((t,n)=>t+n.share,0)}:null}}function U(r,{buckets:o=48}={}){const e=S(r),a=e||d(r);if(!a.length)return{basis:e?"clock":"text",from:null,to:null,buckets:[]};const s=Math.max(1,Math.floor(o)),c=Array.from({length:s},()=>new Map),l=(t,n,p)=>t.set(n,(t.get(n)||0)+p);let i=null,m=null;if(e){i=e[0].t,m=Math.max(e.at(-1).t+e.at(-1).ms,i+1);const t=m-i;for(const n of e){const p=n.t,g=Math.min(m,n.t+n.ms),k=Math.min(s-1,Math.floor((p-i)/t*s)),M=Math.min(s-1,Math.floor((g-i)/t*s));for(let h=k;h<=M;h+=1){const b=i+t*h/s,_=i+t*(h+1)/s,x=Math.min(g,_)-Math.max(p,b);x>0&&l(c[h],n.speaker,x)}}}else{const t=a.length/s;a.forEach((n,p)=>l(c[Math.min(s-1,Math.floor(p/t))],n.speaker,Math.max(1,n.text.length)))}const f=c.map((t,n)=>{const p=[...t.entries()].sort((M,h)=>h[1]-M[1]||M[0].localeCompare(h[0])),g=p.reduce((M,[,h])=>M+h,0),k=m===null?null:(m-i)/s;return{speaker:p[0]?.[0]||"",weight:g,by:p.map(([M,h])=>({speaker:M,weight:h,share:g?h/g:0})),from:k===null?null:Math.round(i+k*n),to:k===null?null:Math.round(i+k*(n+1))}}),u=Math.max(...f.map(t=>t.weight),1);return{basis:e?"clock":"text",from:i,to:m,buckets:f.map(t=>({...t,weight:t.weight/u}))}}function E(r){const o=new Map;for(const a of r?.segments||[]){const s=o.get(a.speaker)||{speaker:a.speaker,lines:0,chars:0};s.lines+=1,s.chars+=a.text.length,o.set(a.speaker,s)}const e=[...o.values()].reduce((a,s)=>a+s.chars,0)||1;return[...o.values()].map(a=>({...a,share:a.chars/e})).sort((a,s)=>s.chars-a.chars)}function O(r,o=40){const e=r?.segments||[];if(!e.length)return[];const a=[],s=e.length/o;for(let l=0;l<o;l+=1){const i=Math.floor(l*s),m=Math.max(i+1,Math.floor((l+1)*s)),f=e.slice(i,m);if(!f.length){a.push({speaker:"",weight:0});continue}const u=new Map;for(const p of f)u.set(p.speaker,(u.get(p.speaker)||0)+p.text.length);const[t,n]=[...u.entries()].sort((p,g)=>g[1]-p[1])[0];a.push({speaker:t,weight:n})}const c=Math.max(...a.map(l=>l.weight),1);return a.map(l=>({...l,weight:l.weight/c}))}function C(r){if(!Number.isFinite(r)||r<=0)return"";const o=Math.round(r/1e3);if(o<60)return`${o}s`;const e=Math.round(o/60);return e<60?`${e} min`:`${Math.floor(e/60)}h ${String(e%60).padStart(2,"0")}m`}const w=r=>typeof r=="string"&&/^https?:\/\/\S+$/i.test(r.trim())&&/\.(png|jpe?g|gif|webp|svg)(\?|#|$)|images\.zoom\.us|\/p\/v2\/|gravatar|avatar|googleusercontent|wbxcdn|teams\.(microsoft|live)/i.test(r);function F(r){const e=(r||[]).map(c=>typeof c=="string"?c:c?.speaker).filter(w),a=e.length>1,s=new Map(e.map((c,l)=>[c,`Participant ${l+1}`]));return c=>w(c)?a?s.get(c):"Participant":c}export{$ as SPEAKER_SLOTS,O as densityRibbon,C as formatTalkTime,w as isSpeakerImageValue,P as speakerBreakdown,F as speakerLabeller,E as speakerStats,U as speakerTimeline};
|
package/meeting-text.js
CHANGED
|
@@ -1,94 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
// 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';
|
|
1
|
+
const f=/^MEETING:\s*(.*)$/,u=/^\[([^\]]{1,20})\]\s*([^:]{1,80}?):\s*([\s\S]*)$/;function g(c){const r=String(c??"").split(`
|
|
2
|
+
`),a=f.exec(r[0]||"");if(!a)return null;const e={title:a[1].trim(),date:"",platform:"",tags:[],summary:"",preamble:"",segments:[],speakers:[]};let n="head";const o=[],l=[];for(let s=1;s<r.length;s+=1){const t=r[s],m=t.trim();if(m==="SUMMARY:"){n="summary";continue}if(m==="TRANSCRIPT:"){n="transcript";continue}if(n==="head"){t.startsWith("Date: ")?e.date=t.slice(6).trim():t.startsWith("Platform: ")?e.platform=t.slice(10).trim():t.startsWith("Tags: ")&&(e.tags=t.slice(6).split(",").map(p=>p.trim()).filter(Boolean));continue}if(n==="summary"){o.push(t);continue}const i=u.exec(t);i?e.segments.push({at:i[1].trim(),speaker:i[2].trim(),text:i[3].trim()}):e.segments.length?m&&e.segments.length&&(e.segments[e.segments.length-1].text+=`
|
|
3
|
+
${t}`):l.push(t)}return e.summary=o.join(`
|
|
4
|
+
`).trim(),e.preamble=l.join(`
|
|
5
|
+
`).trim(),e.speakers=[...new Set(e.segments.map(s=>s.speaker))],e}import{speakerStats as x,densityRibbon as y}from"./meeting-shape.js";export{y as densityRibbon,g as parseMeetingText,x as speakerStats};
|