@chatpanel/events 0.102.0 → 0.104.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.
Files changed (139) 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 +4 -5
  62. package/pdf-layout.js +7 -253
  63. package/policy.js +1 -0
  64. package/project-run.js +20 -444
  65. package/project.js +1 -170
  66. package/promotion.js +1 -171
  67. package/queue.js +1 -98
  68. package/reach.js +1 -23
  69. package/recipe-tool.js +2 -161
  70. package/recipe.js +1 -264
  71. package/record-list.js +2 -110
  72. package/recruit.js +3 -456
  73. package/redaction-tokens.js +1 -61
  74. package/ref.js +1 -55
  75. package/registry.js +1 -240
  76. package/route-graph.js +1 -115
  77. package/route-strategies.js +2 -232
  78. package/router.js +2 -880
  79. package/rrf.js +1 -89
  80. package/rules.js +1 -142
  81. package/schedule.js +1 -663
  82. package/scm-connection.js +1 -180
  83. package/scopes.js +1 -12
  84. package/scorecard.js +1 -335
  85. package/search-engines.js +1 -81
  86. package/skill-manifest.js +1 -330
  87. package/skill-scan.js +3 -209
  88. package/skill-sources.js +1 -162
  89. package/skill-vars.js +2 -252
  90. package/slash-commands.js +1 -158
  91. package/source-gate.js +1 -92
  92. package/sources-retrieval.js +5 -186
  93. package/sources.js +1 -273
  94. package/store.js +1 -171
  95. package/structured.js +4 -901
  96. package/subject-kinds.js +1 -5
  97. package/subject-name.js +1 -96
  98. package/sync-plan.js +1 -213
  99. package/synthesis.js +3 -123
  100. package/tags.js +1 -203
  101. package/team-board.js +6 -337
  102. package/team-cache.js +2 -63
  103. package/team-observe.js +1 -118
  104. package/team-org.js +1 -442
  105. package/team-plan.js +4 -131
  106. package/team-record.js +1 -225
  107. package/team-run.js +9 -652
  108. package/team-subtask.js +2 -191
  109. package/team-task.js +2 -124
  110. package/team-tool.js +2 -196
  111. package/team-trail.js +2 -73
  112. package/team-worklog.js +2 -149
  113. package/team.js +1 -302
  114. package/text-search.js +1 -155
  115. package/theme.js +17 -154
  116. package/titles.js +4 -285
  117. package/tool-discovery.js +1 -87
  118. package/tool-dispatch.js +5 -230
  119. package/tool-groups.js +1 -81
  120. package/tool-hints.js +10 -185
  121. package/tool-loop-guard.js +4 -182
  122. package/tool-need.js +2 -124
  123. package/tool-result.js +2 -438
  124. package/tool-round.js +0 -0
  125. package/tool-schema.js +1 -155
  126. package/tool-traits.js +1 -182
  127. package/toolset.js +2 -88
  128. package/trajectory.js +1 -530
  129. package/turn-loop.js +4 -440
  130. package/upcast.js +1 -37
  131. package/vault.js +2 -250
  132. package/view.js +1 -88
  133. package/voice-intents.js +2 -1488
  134. package/voice-speaker.js +1 -98
  135. package/weather-tool.js +1 -65
  136. package/weather.js +2 -211
  137. package/web-search-tool.js +14 -102
  138. package/web-search.js +1 -160
  139. 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};