@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.
Files changed (138) hide show
  1. package/adapters.js +1 -83
  2. package/adaptive-tool-policy.js +1 -45
  3. package/agent.js +2 -255
  4. package/attribution.js +1 -132
  5. package/backup-envelope.js +1 -221
  6. package/board-tool.js +2 -165
  7. package/budget.js +1 -116
  8. package/capability.js +1 -126
  9. package/citations.js +5 -127
  10. package/client-prefs.js +1 -148
  11. package/context-attachments.js +8 -144
  12. package/cowriter-router.js +1 -83
  13. package/cowriter-writer.js +19 -193
  14. package/cowriter.js +1 -190
  15. package/curate.js +3 -509
  16. package/distance.js +1 -124
  17. package/engine.js +1 -131
  18. package/entitlement.js +1 -332
  19. package/entity.js +1 -354
  20. package/event.js +1 -175
  21. package/extraction.js +4 -293
  22. package/failover.js +1 -93
  23. package/find-tool.js +1 -66
  24. package/flowchart.js +2 -644
  25. package/gate.js +1 -74
  26. package/harness.js +4 -101
  27. package/index.js +1 -345
  28. package/invariants.js +1 -174
  29. package/job.js +3 -147
  30. package/kernel.js +1 -255
  31. package/knowledge-derive.js +1 -267
  32. package/knowledge.js +3 -230
  33. package/library.js +11 -280
  34. package/loop.js +1 -132
  35. package/manifest.js +1 -107
  36. package/markdown-authoring.js +11 -288
  37. package/markdown-render.js +0 -0
  38. package/mcp-client.js +8 -358
  39. package/mcp-dispatch.js +1 -52
  40. package/mcp-errors.js +1 -106
  41. package/mcp-manager.js +1 -127
  42. package/media-transcript.js +8 -531
  43. package/meeting-analyzers.js +1 -83
  44. package/meeting-insights.js +7 -164
  45. package/meeting-shape.js +1 -282
  46. package/meeting-text.js +5 -94
  47. package/memory.js +2 -638
  48. package/model-candidates.js +1 -358
  49. package/model-health.js +1 -182
  50. package/model-ledger.js +1 -228
  51. package/model-picker.js +1 -188
  52. package/note-actions.js +15 -246
  53. package/note-graph.js +1 -158
  54. package/note-links.js +1 -68
  55. package/note-mentions.js +8 -114
  56. package/note-plan.js +9 -107
  57. package/note-research.js +1 -135
  58. package/observability.js +1 -119
  59. package/omni.js +1 -125
  60. package/order.js +1 -78
  61. package/package.json +1 -4
  62. package/pdf-layout.js +7 -253
  63. package/project-run.js +20 -444
  64. package/project.js +1 -170
  65. package/promotion.js +1 -171
  66. package/queue.js +1 -98
  67. package/reach.js +1 -23
  68. package/recipe-tool.js +2 -161
  69. package/recipe.js +1 -264
  70. package/record-list.js +2 -110
  71. package/recruit.js +3 -456
  72. package/redaction-tokens.js +1 -61
  73. package/ref.js +1 -55
  74. package/registry.js +1 -240
  75. package/route-graph.js +1 -115
  76. package/route-strategies.js +2 -232
  77. package/router.js +2 -880
  78. package/rrf.js +1 -89
  79. package/rules.js +1 -142
  80. package/schedule.js +1 -663
  81. package/scm-connection.js +1 -180
  82. package/scopes.js +1 -12
  83. package/scorecard.js +1 -335
  84. package/search-engines.js +1 -81
  85. package/skill-manifest.js +1 -330
  86. package/skill-scan.js +3 -209
  87. package/skill-sources.js +1 -162
  88. package/skill-vars.js +2 -252
  89. package/slash-commands.js +1 -158
  90. package/source-gate.js +1 -92
  91. package/sources-retrieval.js +5 -186
  92. package/sources.js +1 -273
  93. package/store.js +1 -171
  94. package/structured.js +4 -901
  95. package/subject-kinds.js +1 -5
  96. package/subject-name.js +1 -96
  97. package/sync-plan.js +1 -213
  98. package/synthesis.js +3 -123
  99. package/tags.js +1 -203
  100. package/team-board.js +6 -337
  101. package/team-cache.js +2 -63
  102. package/team-observe.js +1 -118
  103. package/team-org.js +1 -442
  104. package/team-plan.js +4 -131
  105. package/team-record.js +1 -225
  106. package/team-run.js +9 -652
  107. package/team-subtask.js +2 -191
  108. package/team-task.js +2 -124
  109. package/team-tool.js +2 -196
  110. package/team-trail.js +2 -73
  111. package/team-worklog.js +2 -149
  112. package/team.js +1 -302
  113. package/text-search.js +1 -155
  114. package/theme.js +17 -154
  115. package/titles.js +4 -285
  116. package/tool-discovery.js +1 -87
  117. package/tool-dispatch.js +5 -230
  118. package/tool-groups.js +1 -81
  119. package/tool-hints.js +10 -185
  120. package/tool-loop-guard.js +4 -182
  121. package/tool-need.js +2 -124
  122. package/tool-result.js +2 -438
  123. package/tool-round.js +0 -0
  124. package/tool-schema.js +1 -155
  125. package/tool-traits.js +1 -182
  126. package/toolset.js +2 -88
  127. package/trajectory.js +1 -530
  128. package/turn-loop.js +4 -440
  129. package/upcast.js +1 -37
  130. package/vault.js +2 -250
  131. package/view.js +1 -88
  132. package/voice-intents.js +2 -1488
  133. package/voice-speaker.js +1 -98
  134. package/weather-tool.js +1 -65
  135. package/weather.js +2 -211
  136. package/web-search-tool.js +14 -102
  137. package/web-search.js +1 -160
  138. package/widget.js +1 -153
@@ -1,164 +1,7 @@
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
- }
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
- // THE SHAPE OF A MEETING who spoke, when, and for how long.
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
- // 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
- // 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};