@gotcos/glasses-server 6.21.28 → 6.21.29

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/CHANGELOG.md CHANGED
@@ -1,25 +1,82 @@
1
+ ## 6.21.29
2
+
3
+ Everything below was found by two rounds of adversarial review of 6.21.28 against
4
+ the real corpus. **Published 6.21.28 contains the route and none of these fixes**
5
+ — its `meeting-scribe-content.ts` is 192 lines with no `renderProvenance`, no
6
+ `cleanBody`, and no coverage floor (verified by extracting the tarball, not by
7
+ reading the changelog). If you are on 6.21.28, per-voice shares are printed
8
+ without the floor.
9
+
10
+ - **The compact form no longer ships a transcript.** `contains('transcript')`
11
+ kept only the longest match and the loser fell through to `extras`, which went
12
+ into BOTH forms — so the "no transcript" summary carried a second full
13
+ transcript on 112 of 2,090 real scribes, worst case 78,652 characters, printed
14
+ directly above its own "Transcript omitted" line. All transcript-shaped
15
+ sections are now consumed; extras beyond 2,000 characters are omitted from the
16
+ compact form only. The size ceiling matters because a heading cannot be
17
+ trusted: one real scribe hides a full transcript under `G2 Glasses Enrichment`.
18
+
19
+ - **"Voice matching confirmed" was false for two of the three ways a name is
20
+ asserted.** `nameAsserted` is one boolean over three different warrants — the
21
+ cosine floor, a human typing the name, and the wearer exemption (identity comes
22
+ from holding the device, not from a score). The route carried `isOwner` and
23
+ `confirmedByHuman` and discarded both. The output now names the warrant per
24
+ person: `"MU" by wearing the device; "Gina Obert" by voice match; "Luke Henry"
25
+ because a human named that voice`. Labels are quoted, so a typed "Smith, John"
26
+ cannot read as two people, and the caveat now covers different SPELLINGS of a
27
+ confirmed name (labels are `MU` while the prose says "Miles").
28
+
29
+ - **A lone named voice no longer gets a share.** "100% of identified speech" is
30
+ always true with one name and reads as "he did all the talking" — on 23 real
31
+ meetings, including one where 6m 29s was unidentified.
32
+
33
+ - **Rows that overlap say so.** Each per-voice figure is that voice's own union,
34
+ but two people talking over each other is counted once EACH, so the rows add to
35
+ more than the meeting on 34 of 323 real meetings (71m of rows inside 66
36
+ minutes). The figures are right; adding them is what misleads, so the block now
37
+ explains the overlap instead of shrinking anyone.
38
+
39
+ - **"(no transcript in this scribe)" was a lie on 140 of 399 sidecars.** Those
40
+ meetings have no write-up yet while the recording holds the speech — one case
41
+ today had 27,442 characters in the sidecar this route had just parsed. It now
42
+ distinguishes "not written up yet" from "nobody spoke".
43
+
44
+ - **The payload says which business it is.** 98 of 251 real meetings are
45
+ `personal` and 25 of 251 summaries carry compensation, termination, or legal
46
+ content, while the buttons are framed for Slack and email. `/speakers` has
47
+ always carried `domain`; this route dropped it. Personal meetings also get an
48
+ explicit line before the content.
49
+
50
+ - **A derived note is never relabelled as the transcript.** With no real
51
+ transcript, a `Transcript Enrichment (from raw recording)` note won by default
52
+ and was printed under `## Transcript` with its real heading destroyed. The
53
+ section's own heading is now used, and an `## Attendees (from transcript)`
54
+ variant can no longer win the slot at all — nor slip into `extras`, where it
55
+ printed the unfloored name list this module exists to replace.
56
+
57
+ - **The generator stamp is stripped by POSITION, not wording.** Measured across
58
+ 1,227 real stamps, four generator names appear ("Meeting Intelligence System",
59
+ "COS Split Pipeline", "COS Meeting Intelligence", "Manual Granola Paste"), so a
60
+ name-anchored regex leaks some — I shipped one that leaked 7. Being the last
61
+ line is the invariant, which also means a mid-body italic line a human wrote
62
+ now survives. `<!-- g2-needs-domain-review -->` and `<details open>` are
63
+ stripped too; the internal marker reached 129 of 362 real clipboards.
64
+
65
+ - Also: `mmss` no longer renders `NaNm NaNs`; `meetingDate(1)` no longer returns
66
+ 1969-12-31; a date-only ISO string no longer reports the previous day; each
67
+ clipboard form is rendered once instead of twice per request.
68
+
1
69
  ## 6.21.28
2
70
 
71
+ **Published. Contains the route only — see 6.21.29 for what it is missing.**
72
+
3
73
  - `GET /meeting/:sessionId/content` — the readable meeting plus two ready-made
4
- clipboard forms. Resolution is operations-first, identical to `/speakers`, so
5
- the list row and this view can never describe the same meeting differently.
6
-
7
- The attendee block is REBUILT from the speaker review, not taken from the
8
- scribe's own `## Attendees`, which applies no confidence floor: the 2026-08-06
9
- IJO scribe lists 15 attendees for a 26-minute call including a name already
10
- confirmed absent. Copying that verbatim into Slack, an email or an LLM prompt
11
- launders a guess into a fact. Only asserted voices are named; the rest collapse
12
- into one line with their combined talk time.
13
-
14
- Two forms because they serve different jobs — measured on a real 26-minute
15
- meeting, 3.6 KB summary for pasting into a message versus 28 KB with the
16
- transcript for pasting into a model. Formatting lives server-side so it is
17
- mutation-testable; Swift has no execution-test harness here.
18
-
19
- - `meetingDate()` — `startTime` in the sidecar is epoch MILLISECONDS, not ISO.
20
- Slicing the stringified number produced "1786123940", which renders as a
21
- plausible-looking date field containing a timestamp. Caught only by running the
22
- route against a real meeting; the unit tests would have stayed green.
74
+ clipboard forms. Operations-first resolution, identical to `/speakers`, so the
75
+ list row and this view can never describe the same meeting differently.
76
+
77
+ - The attendee block is rebuilt from the review rather than reusing the scribe's
78
+ own `## Attendees`, which applies no confidence floor: 2026-08 alone carries
79
+ scribes listing 55, 21, 20, 19, 18, 18, 17 and 15 attendees.
23
80
 
24
81
  ## 6.21.27
25
82
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.21.28",
3
+ "version": "6.21.29",
4
4
  "description": "COS Glasses — self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, or Cursor Agent CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,24 +1,44 @@
1
- // The readable meeting: what the scribe markdown says, and two clipboard forms.
1
+ // The readable meeting, and the two forms of it that leave the machine.
2
2
  //
3
- // WHY THIS IS NOT JUST "SEND THE FILE". The scribe's `## Attendees` list applies
4
- // NO confidence floor — it is the raw label set from capture. Measured on the
5
- // 2026-08-06 IJO Post-Mortem: 15 attendees listed for a 26-minute call, one of
6
- // them a name Miles had already confirmed was never in the room. Copying that
7
- // verbatim into Slack, an email, or an LLM prompt launders a guess into a fact,
8
- // which is the exact defect the display floor exists to prevent one layer up.
3
+ // WHAT v1 GOT WRONG, because the shape of the fix follows from it.
9
4
  //
10
- // So the attendee list served here is rebuilt from the speaker review: only
11
- // voices the review ASSERTS get named, and everything else is reported as
12
- // unidentified with its share of the talking.
5
+ // v1 floored the attendee block and shipped every other section verbatim. On the
6
+ // 2026-08-06 IJO Post-Mortem where the review asserts exactly ONE voice — all
7
+ // 14 unasserted names still reached the clipboard, including one Miles had
8
+ // already confirmed was never in the room, as `[Name]:` transcript labels. The
9
+ // stated contract, "only asserted voices are named", was false for the largest
10
+ // output.
13
11
  //
14
- // Formatting lives here rather than in Swift on purpose. Swift has no execution
15
- // test harness in this project, and these strings are the product — they need to
16
- // be mutation-testable.
12
+ // The fix is NOT redaction. Two reasons:
13
+ // 1. Transcript labels are EVIDENCE, and they came from the same identifier
14
+ // whose verdict we would be applying — rewriting them is circular and
15
+ // destroys the raw record the reviewer needs to judge for themselves.
16
+ // 2. A name in the prose is frequently a person MENTIONED, not one who SPOKE.
17
+ // An action item naming Jessica Thompson does not claim she was in the room.
18
+ // Stripping it would replace one wrong claim with a different one.
19
+ //
20
+ // So the output carries an ALLOWLIST: who voice matching confirmed, plus a plain
21
+ // statement that every other name below is unverified capture output. Honest,
22
+ // lossless, and short — a blocklist ran to 14 names on that meeting and 55 on
23
+ // another.
24
+ //
25
+ // v1's second error: it printed "100% of named speech" on a meeting that was
26
+ // 10.5% identified, and 49% of real meetings sit under that floor. A share is a
27
+ // fraction of the IDENTIFIED speech, so at 40% coverage "53%" may be 21% of the
28
+ // room — precise-looking and wrong. Hence SHARE_COVERAGE_FLOOR.
29
+ //
30
+ // And a correction to v1's own comment, which claimed the Control panel already
31
+ // suppressed those shares: it did not. `Views.swift` drew a percentage for every
32
+ // asserted voice with no coverage condition at all — the only `0.6` comparison in
33
+ // the app swapped a caption's colour. Measured: the panel showed shares the
34
+ // clipboard refused on 170 of 355 real meetings, while this file asserted twice
35
+ // that the two "can never disagree". The gate is now IN the panel row. Never
36
+ // describe another surface's behaviour from a comment; read its code.
17
37
 
18
38
  /** One `## Heading` section of a scribe file, in document order. */
19
39
  export interface ScribeSection {
20
40
  heading: string
21
- /** Body text with the heading line removed and edges trimmed. */
41
+ /** Body with the heading line removed, scaffolding stripped, edges trimmed. */
22
42
  body: string
23
43
  }
24
44
 
@@ -26,85 +46,212 @@ export interface ParsedScribe {
26
46
  /** The `# Title` line, or '' when the file has none. */
27
47
  title: string
28
48
  sections: ScribeSection[]
29
- /** Convenience lookups for the sections a UI renders directly. */
30
49
  summary: string
31
50
  topics: string
32
51
  decisions: string
33
52
  actions: string
34
53
  transcript: string
54
+ /**
55
+ * The heading the transcript body actually came FROM.
56
+ *
57
+ * When a file has no real transcript, a `Transcript Enrichment (from raw
58
+ * recording)` note wins by default and v1 relabelled derived analysis as
59
+ * `## Transcript` while destroying its real heading — the reader could not
60
+ * tell. Carry the heading and print it.
61
+ */
62
+ transcriptHeading: string
63
+ /**
64
+ * Further transcript-ish sections beyond the longest. Carried so nothing is
65
+ * lost, but they belong ONLY in the full form — 112 real scribes have two.
66
+ */
67
+ otherTranscripts: ScribeSection[]
68
+ /**
69
+ * Sections that are none of the above, in document order.
70
+ *
71
+ * v1 discarded these, losing 116,820 characters across the corpus including
72
+ * `Granola Structured Notes (canonical)` — Miles's own write-up — and
73
+ * `Fathom Action Items (with exact timestamps)`. "Copy the meeting" has to
74
+ * copy the meeting.
75
+ */
76
+ extras: ScribeSection[]
35
77
  }
36
78
 
37
- const SECTION = /^##\s+(.+?)\s*$/
79
+ const HEADING = /^(#{1,6})\s+(.+?)\s*$/
80
+ const FENCE = /^\s*(```|~~~)/
81
+
82
+ /**
83
+ * Strip markdown scaffolding that is structure rather than content.
84
+ *
85
+ * 160 of 227 real scribes wrap the transcript in a `<details>` disclosure and
86
+ * 161 end with a generator stamp. Both land in whatever the final section is, and
87
+ * both are noise in every destination — the stamp especially, because it is
88
+ * generation time and reads like meeting content.
89
+ */
90
+ export function cleanBody(body: string): string {
91
+ const kept: string[] = []
92
+ for (const line of body.split('\n')) {
93
+ const t = line.trim()
94
+ // `<details open>` and `<details markdown="1">` both occur in LLM-authored
95
+ // markdown and neither matched the bare-tag test.
96
+ if (/^<\/?details(\s[^>]*)?>$/i.test(t)) continue
97
+ if (/^<summary>.*<\/summary>$/i.test(t)) continue
98
+ // Internal pipeline triage markers. `g2-needs-domain-review` reached 129 of
99
+ // 362 reachable clipboards — more frequent than the generator stamp, and it
100
+ // is not meeting content.
101
+ if (/^<!--[\s\S]*-->$/.test(t)) continue
102
+ kept.push(line)
103
+ }
104
+ // Drop trailing rules AND the blank lines between them, left orphaned by the
105
+ // stamp that sat underneath. Popping rules alone stops at the first blank line
106
+ // the stamp left behind, so the rule survived — caught by test, not by reading.
107
+ // The generator stamp is stripped HERE, as a footer, by POSITION not wording.
108
+ // Measured across 1,227 real stamps, four different generator names appear
109
+ // ("Meeting Intelligence System", "COS Split Pipeline", "COS Meeting
110
+ // Intelligence", "Manual Granola Paste"), so any name-anchored regex leaks
111
+ // some — I tried one and it leaked 7. Being the last line IS the invariant,
112
+ // and it also means a mid-body italic line a human wrote ("the deck was
113
+ // *Generated by the team* last week") now survives, where the old
114
+ // line-by-line filter deleted it wherever it appeared.
115
+ const trailingNoise = (line: string) =>
116
+ line.trim() === '' ||
117
+ /^\s*(-{3,}|\*{3,}|_{3,})\s*$/.test(line) ||
118
+ /^\*Generated by .*\*$/i.test(line.trim())
119
+ while (kept.length > 0 && trailingNoise(kept[kept.length - 1])) kept.pop()
120
+ return kept.join('\n').trim()
121
+ }
38
122
 
39
123
  /**
40
- * Split a scribe file into its `##` sections.
124
+ * Split a scribe into its `##` sections.
41
125
  *
42
- * Deliberately tolerant: a scribe with no headings returns everything as the
43
- * summary rather than an empty object, because a partially-written file is more
44
- * useful to a reviewer than nothing. `###` subheadings (the Action Items split
45
- * into High Confidence / Needs Review) stay INSIDE their parent section — they
46
- * are part of that section's body, not sections of their own.
126
+ * FENCE-AWARE. A `#` inside a fenced code block is code, not a heading. Without
127
+ * this, a fence truncates its section at the first `##` inside it and can lift a
128
+ * shell comment into the meeting title. No real scribe carries a fence today, but
129
+ * the Granola sections are LLM-authored markdown, so one will arrive.
130
+ *
131
+ * `###` subheadings stay INSIDE their parent — Action Items splitting into High
132
+ * Confidence / Needs Review is one section's body, not two sections.
47
133
  */
48
134
  export function parseScribe(markdown: string): ParsedScribe {
49
- const lines = markdown.split('\n')
135
+ const lines = markdown.replace(/\r\n?/g, '\n').split('\n')
50
136
  let title = ''
51
137
  const sections: ScribeSection[] = []
52
138
  let current: ScribeSection | null = null
53
- const preamble: string[] = []
139
+ let inFence = false
54
140
 
55
141
  for (const line of lines) {
56
- if (!title && /^#\s+/.test(line)) { title = line.replace(/^#\s+/, '').trim(); continue }
57
- const m = SECTION.exec(line)
58
- if (m) {
59
- if (current) sections.push({ ...current, body: current.body.trim() })
60
- current = { heading: m[1], body: '' }
61
- continue
142
+ if (FENCE.test(line)) inFence = !inFence
143
+ if (!inFence) {
144
+ const m = HEADING.exec(line)
145
+ if (m && m[1].length === 1 && !title) { title = m[2].trim(); continue }
146
+ if (m && m[1].length === 2) {
147
+ if (current) sections.push({ ...current, body: cleanBody(current.body) })
148
+ current = { heading: m[2], body: '' }
149
+ continue
150
+ }
62
151
  }
63
152
  if (current) current.body += line + '\n'
64
- else preamble.push(line)
65
153
  }
66
- if (current) sections.push({ ...current, body: current.body.trim() })
154
+ if (current) sections.push({ ...current, body: cleanBody(current.body) })
67
155
 
68
- const find = (...names: string[]): string => {
156
+ const used = new Set<ScribeSection>()
157
+ /** Exact heading match, first name wins. */
158
+ const exact = (...names: string[]): string => {
69
159
  for (const n of names) {
70
- const s = sections.find(x => x.heading.toLowerCase() === n.toLowerCase())
71
- if (s) return s.body
160
+ const s = sections.find(x => x.heading.toLowerCase() === n.toLowerCase() && !used.has(x))
161
+ if (s) { used.add(s); return s.body }
72
162
  }
73
163
  return ''
74
164
  }
75
- const summary = find('Summary')
165
+ /**
166
+ * CONTAINS match, longest body wins.
167
+ *
168
+ * Prefix matching is the obvious rule and it is WRONG: 113 scribes head the
169
+ * transcript `G2 Speaker-Separated Transcript` — the speaker-attributed one,
170
+ * the most useful thing to hand a model — which does not start with the word.
171
+ * Measured across 1,930 transcript headings, prefix catches 94%, contains 100%.
172
+ * Longest-body separates a real transcript from a short
173
+ * `Transcript Enrichment (from raw recording)` note in the same file.
174
+ */
175
+ const otherTranscripts: ScribeSection[] = []
176
+ let transcriptHeading = ''
177
+ const contains = (needle: string): string => {
178
+ const hits = sections
179
+ .filter(x => {
180
+ const h = x.heading.toLowerCase()
181
+ // `## Attendees (from transcript)` is eligible on a naive match and would
182
+ // promote the unfloored attendee list — the thing this module drops — into
183
+ // the transcript slot. Never let an attendee heading win.
184
+ return h.includes(needle) && !h.includes('attendee') && !used.has(x)
185
+ })
186
+ .sort((a, b) => b.body.length - a.body.length)
187
+ if (hits.length === 0) return ''
188
+ transcriptHeading = hits[0].heading
189
+ // Consume EVERY transcript-ish section. Keeping only the longest let the
190
+ // losers fall through to `extras`, and `extras` goes into BOTH clipboard
191
+ // forms — so the "compact, no transcript" summary shipped a second full
192
+ // transcript on 112 of 2,090 real scribes, worst case 78,652 characters,
193
+ // directly above its own "Transcript omitted" line.
194
+ for (const h of hits) used.add(h)
195
+ otherTranscripts.push(...hits.slice(1))
196
+ return hits[0].body
197
+ }
198
+
199
+ const summary = exact('Summary')
200
+ const topics = exact('Topics Discussed', 'Topics')
201
+ const decisions = exact('Decisions Made', 'Decisions')
202
+ const actions = exact('Action Items', 'Actions')
203
+ const transcript = contains('transcript')
204
+ // Attendees is consumed and DROPPED on purpose: it is the unfloored label list
205
+ // this module exists to replace. CONTAINS, not exact — `## Attendees (from
206
+ // transcript)` slipped past the exact match, fell through to `extras`, and
207
+ // printed the unfloored names verbatim in both clipboard forms.
208
+ for (const x of sections) {
209
+ if (x.heading.toLowerCase().includes('attendee')) used.add(x)
210
+ }
211
+
76
212
  return {
77
213
  title,
78
214
  sections,
79
- // No headings at all: treat the whole file as the summary rather than
80
- // returning a shell that renders as an empty meeting.
81
- summary: summary || (sections.length === 0 ? markdown.trim() : ''),
82
- topics: find('Topics Discussed', 'Topics'),
83
- decisions: find('Decisions Made', 'Decisions'),
84
- actions: find('Action Items', 'Actions'),
85
- transcript: find('Transcript'),
215
+ // Heading-less fallback drops the `# Title` line the parser already consumed;
216
+ // 18 real files otherwise printed their title twice in the clipboard.
217
+ summary: summary || (sections.length === 0 ? cleanBody(markdown.split('\n').filter(l => !/^#\s+/.test(l)).join('\n')) : ''),
218
+ topics,
219
+ decisions,
220
+ actions,
221
+ transcript,
222
+ transcriptHeading,
223
+ otherTranscripts,
224
+ extras: sections.filter(x => !used.has(x) && x.body.trim().length > 0),
86
225
  }
87
226
  }
88
227
 
89
228
  /**
90
229
  * The meeting's calendar date, as `YYYY-MM-DD`.
91
230
  *
92
- * `startTime` in the sidecar is epoch MILLISECONDS (measured: 1786123940914),
93
- * not an ISO string. Slicing the stringified number gives "1786123940", which
94
- * renders in the clipboard as a plausible-looking date field containing a
95
- * timestamp. LOCAL date on purpose the scribe filename uses the local day, so
96
- * a UTC conversion would disagree with the file for anything late in the evening.
231
+ * `startTime` is epoch MILLISECONDS (measured: 1786123940914), not ISO. Slicing
232
+ * the stringified number gave "1786123940" — a plausible-looking date field
233
+ * holding a timestamp. LOCAL date, because the scribe filename uses the local day
234
+ * and a UTC conversion would disagree with the file late in the evening.
97
235
  */
98
236
  export function meetingDate(startTime: unknown): string {
99
237
  let ms: number | null = null
100
238
  if (typeof startTime === 'number' && Number.isFinite(startTime)) {
101
- // Tolerate seconds as well as milliseconds: a 10-digit value is seconds.
102
- ms = startTime < 1e12 ? startTime * 1000 : startTime
239
+ // Seconds-vs-ms by magnitude, but only within a plausible capture era:
240
+ // startTime = 1 was read as 1 second and rendered "1969-12-31".
241
+ ms = startTime < 1e12 ? (startTime > 1e8 ? startTime * 1000 : 0) : startTime
103
242
  } else if (typeof startTime === 'string' && startTime.trim()) {
104
- const t = Date.parse(startTime)
243
+ const numeric = Number(startTime)
244
+ const t = Number.isFinite(numeric)
245
+ ? (numeric < 1e12 ? numeric * 1000 : numeric)
246
+ // A date-only ISO string parses as UTC midnight, so a local getDate()
247
+ // then reports the PREVIOUS day. Pin it to local noon.
248
+ : (/^\d{4}-\d{2}-\d{2}$/.test(startTime.trim())
249
+ ? Date.parse(`${startTime.trim()}T12:00:00`)
250
+ : Date.parse(startTime))
105
251
  if (Number.isFinite(t)) ms = t
106
252
  }
107
- if (ms === null) return ''
253
+ // 0 and negatives are not real capture times; they rendered as "1969-12-31".
254
+ if (ms === null || ms <= 0) return ''
108
255
  const d = new Date(ms)
109
256
  if (Number.isNaN(d.getTime())) return ''
110
257
  const pad = (n: number) => String(n).padStart(2, '0')
@@ -117,58 +264,191 @@ export interface AttendeeLine {
117
264
  /** False when the review refuses to assert this name. */
118
265
  asserted: boolean
119
266
  speakingMs: number
120
- /** Share of NAMED speaking time, 0..1, or null when unknown/unnamed. */
267
+ /** Share of identified speech, 0..1, or null when unknown/unnamed. */
121
268
  share: number | null
269
+ /**
270
+ * WHY this name is asserted. `nameAsserted` is one boolean over THREE
271
+ * different warrants — passed the cosine floor, a human typed it, or the
272
+ * wearer is exempt because identity comes from holding the device. v1 printed
273
+ * all three as "Voice matching confirmed", which is false for two of them and
274
+ * is exactly the laundering this module exists to prevent. The review already
275
+ * carries both flags; the route discarded them.
276
+ */
277
+ isOwner: boolean
278
+ confirmedByHuman: boolean
122
279
  }
123
280
 
281
+ /**
282
+ * Coverage below which per-voice shares are NOT reported.
283
+ *
284
+ * Mirrors Control's `speakingCoverage < 0.6` gate exactly, so the clipboard and
285
+ * the panel can never disagree about whether a share is trustworthy. 49% of real
286
+ * meetings sit below it.
287
+ */
288
+ export const SHARE_COVERAGE_FLOOR = 0.6
289
+
124
290
  function mmss(ms: number): string {
125
- const total = Math.round(ms / 1000)
291
+ // NaN survives Math.max and renders "NaNm NaNs".
292
+ if (!Number.isFinite(ms)) return '0s'
293
+ const total = Math.max(0, Math.round(ms / 1000))
126
294
  const m = Math.floor(total / 60)
127
295
  const s = total % 60
128
- return m > 0 ? `${m}m ${s}s` : `${s}s`
296
+ if (m === 0) return `${s}s`
297
+ return s === 0 ? `${m}m` : `${m}m ${s}s`
129
298
  }
130
299
 
131
- /**
132
- * The attendee block, floor-applied.
133
- *
134
- * Asserted voices are named with their talk time. Everything else is collapsed
135
- * into a single honest line rather than listed as people: a large unmatched
136
- * cluster is frequently several different speakers, and naming them
137
- * individually would be the 15-attendee defect again in a new place.
138
- */
139
- export function renderAttendees(voices: AttendeeLine[]): string {
300
+ export interface AttendeeRenderOptions {
301
+ /**
302
+ * Identified share of voiced time, 0..1, or null when unknown. Null suppresses
303
+ * shares failing closed, because unknown coverage cannot justify a
304
+ * precise-looking percentage.
305
+ */
306
+ coverage: number | null
307
+ /**
308
+ * UNION of unattributed speaking time, from the review.
309
+ *
310
+ * Required rather than optional so it cannot be forgotten. Summing per-voice
311
+ * figures double-counts crosstalk: v1 printed "30m 21s across 15 voices" inside
312
+ * a 26-minute meeting while the union on the same review said 22m 17s. The
313
+ * server CHANGELOG states the invariant that sum breaks.
314
+ */
315
+ unattributedMs: number
316
+ /**
317
+ * UNION of all voiced time. Used only to detect that the rows overlap — see
318
+ * the crosstalk note in `renderAttendees`. 0 disables the check.
319
+ */
320
+ voicedMs: number
321
+ }
322
+
323
+ /** The attendee block, floor-applied. */
324
+ export function renderAttendees(voices: AttendeeLine[], o: AttendeeRenderOptions): string {
140
325
  const named = voices.filter(v => v.asserted).sort((a, b) => b.speakingMs - a.speakingMs)
141
326
  const rest = voices.filter(v => !v.asserted)
327
+ // A share needs someone to be a share OF. With one named voice it is always
328
+ // "100% of identified speech" — 23 real meetings — and on screen that reads as
329
+ // "he did all the talking" when the same meeting had 6m 29s unidentified.
330
+ const showShares = o.coverage !== null &&
331
+ o.coverage >= SHARE_COVERAGE_FLOOR &&
332
+ named.length >= 2
142
333
  const out: string[] = []
143
334
  for (const v of named) {
144
- const pct = v.share === null ? '' : ` · ${Math.round(v.share * 100)}% of named speech`
145
- out.push(`- ${v.label} ${mmss(v.speakingMs)}${pct}`)
335
+ const pct = showShares && v.share !== null
336
+ ? ` · ${Math.round(v.share * 100)}% of identified speech`
337
+ : ''
338
+ out.push(`- ${v.label}: ${mmss(v.speakingMs)}${pct}`)
146
339
  }
147
- const restMs = rest.reduce((n, v) => n + v.speakingMs, 0)
148
340
  if (rest.length > 0) {
149
- out.push(`- Unidentified ${mmss(restMs)} across ${rest.length} ` +
341
+ out.push(`- Unidentified: ${mmss(o.unattributedMs)} across ${rest.length} ` +
150
342
  `voice${rest.length === 1 ? '' : 's'} the review could not name`)
151
343
  }
344
+ // Each figure above is that voice's OWN union of speaking time, so crosstalk is
345
+ // counted once per person — but two people talking over each other is counted
346
+ // once EACH, and the rows then add to more than the meeting. 34 of 323 real
347
+ // meetings overflow, worst case 71m of rows inside 66 minutes. The numbers are
348
+ // right; adding them is what misleads, so say so rather than shrink anyone.
349
+ const rowTotal = named.reduce((t, v) => t + (Number.isFinite(v.speakingMs) ? v.speakingMs : 0), 0) +
350
+ (rest.length > 0 && Number.isFinite(o.unattributedMs) ? o.unattributedMs : 0)
351
+ if (o.voicedMs > 0 && rowTotal > o.voicedMs * 1.02) {
352
+ out.push(`- (these overlap: people talked over each other, so the rows add to ` +
353
+ `more than the ${mmss(o.voicedMs)} of talking)`)
354
+ }
152
355
  return out.length > 0 ? out.join('\n') : '- (no voices identified)'
153
356
  }
154
357
 
358
+ /**
359
+ * The provenance block: who was confirmed, and a warning covering everyone else.
360
+ *
361
+ * An ALLOWLIST rather than a blocklist. The prose and transcript below carry raw
362
+ * capture labels, some of which the review rejected, and a reader pasting this
363
+ * into a model has no other way to tell which. Naming the confirmed set is short
364
+ * and complete.
365
+ */
366
+ export function renderProvenance(voices: AttendeeLine[], coverage: number | null): string {
367
+ const named = voices.filter(v => v.asserted)
368
+ // Quoted because a label may legitimately contain a comma — `invalidLabelReason`
369
+ // rejects brackets and newlines but not commas, and a typed "Smith, John" would
370
+ // otherwise read as two confirmed people.
371
+ const q = (v: AttendeeLine) => `"${v.label}"`
372
+ const clauses: string[] = []
373
+ const owner = named.filter(v => v.isOwner).map(q)
374
+ const human = named.filter(v => !v.isOwner && v.confirmedByHuman).map(q)
375
+ const matched = named.filter(v => !v.isOwner && !v.confirmedByHuman).map(q)
376
+ if (owner.length) clauses.push(`${owner.join(', ')} by wearing the device`)
377
+ if (matched.length) clauses.push(`${matched.join(', ')} by voice match`)
378
+ if (human.length) clauses.push(`${human.join(', ')} because a human named that voice`)
379
+ const lines: string[] = [
380
+ clauses.length > 0
381
+ ? `Names established here: ${clauses.join('; ')}.`
382
+ : 'No name in this meeting was established by any means.',
383
+ 'Every other name below — and any different spelling of the ones just listed — ' +
384
+ 'comes from the raw capture or the write-up and was NOT confirmed. Some may be ' +
385
+ 'people mentioned rather than people present.',
386
+ ]
387
+ if (coverage !== null && coverage < SHARE_COVERAGE_FLOOR) {
388
+ lines.push(
389
+ `Only ${Math.round(coverage * 100)}% of the voice in this meeting was ` +
390
+ 'identified, so speaking shares are not reported.',
391
+ )
392
+ }
393
+ return lines.join(' ')
394
+ }
395
+
155
396
  export interface ClipboardInput {
397
+ /**
398
+ * Which business this meeting belongs to, when known.
399
+ *
400
+ * 98 of 251 real meetings are `personal`, and 25 of 251 summaries carry
401
+ * compensation, termination, or legal content — while the buttons are framed
402
+ * for Slack and email. Nothing in the payload distinguished a 1:1 about
403
+ * someone's bonus from a marketing sync. The sibling /speakers route already
404
+ * carries this; this one dropped it.
405
+ */
406
+ domain: string
407
+ /**
408
+ * Characters of transcript present in the RECORDING, whether or not a write-up
409
+ * exists. Distinguishes "not written up yet" from "nobody spoke".
410
+ */
411
+ capturedChars: number
412
+ /** UNION of voiced time, for the crosstalk-overflow note. */
413
+ voicedMs: number
156
414
  title: string
157
415
  date: string
158
416
  durationMin: number
159
417
  attendees: AttendeeLine[]
160
418
  scribe: ParsedScribe
419
+ /** Both required, so neither can be omitted. See AttendeeRenderOptions. */
420
+ coverage: number | null
421
+ unattributedMs: number
161
422
  }
162
423
 
163
- /** Compact form: for pasting into Slack, email, or a note. No transcript. */
164
- export function clipboardSummary(i: ClipboardInput): string {
165
- const parts = [
166
- `# ${i.title || 'Untitled meeting'}`,
167
- `${i.date} · ${i.durationMin} minutes`,
168
- '',
169
- '## Who spoke',
170
- renderAttendees(i.attendees),
171
- ]
424
+ /**
425
+ * Per-extra size ceiling for the COMPACT form.
426
+ *
427
+ * A heading cannot be trusted to reveal a transcript: one real scribe carries a
428
+ * full one under `## G2 Glasses Enrichment ... (Combined)`. Gating on SIZE
429
+ * catches that generically, where a keyword list never would. The full form has
430
+ * no ceiling.
431
+ */
432
+ export const SUMMARY_EXTRA_MAX_CHARS = 2000
433
+
434
+ function header(i: ClipboardInput, compact: boolean): string[] {
435
+ const when = [i.date, i.durationMin > 0 ? `${i.durationMin} minutes` : '']
436
+ .filter(Boolean).join(' · ')
437
+ const parts = [`# ${i.title || 'Untitled meeting'}`]
438
+ // Domain on the same line as the date. A reader about to paste this into a
439
+ // channel should see "personal" before they see the content, not after.
440
+ const line = [when, i.domain ? `${i.domain.replace(/_/g, ' ')} meeting` : ''].filter(Boolean).join(' · ')
441
+ if (line) parts.push(line)
442
+ if (i.domain === 'personal') {
443
+ parts.push('', '> Personal meeting. Check before pasting this anywhere shared.')
444
+ }
445
+ parts.push(
446
+ '', '## Who spoke',
447
+ renderAttendees(i.attendees, {
448
+ coverage: i.coverage, unattributedMs: i.unattributedMs, voicedMs: i.voicedMs,
449
+ }),
450
+ '', renderProvenance(i.attendees, i.coverage),
451
+ )
172
452
  for (const [heading, body] of [
173
453
  ['Summary', i.scribe.summary],
174
454
  ['Topics', i.scribe.topics],
@@ -177,16 +457,45 @@ export function clipboardSummary(i: ClipboardInput): string {
177
457
  ] as const) {
178
458
  if (body.trim()) parts.push('', `## ${heading}`, body.trim())
179
459
  }
460
+ for (const s of i.scribe.extras) {
461
+ const body = s.body.trim()
462
+ if (compact && body.length > SUMMARY_EXTRA_MAX_CHARS) {
463
+ parts.push('', `## ${s.heading}`, `_Omitted from the summary (${body.length} characters). Use Copy full._`)
464
+ continue
465
+ }
466
+ parts.push('', `## ${s.heading}`, body)
467
+ }
468
+ return parts
469
+ }
470
+
471
+ /** Compact form: for pasting into a message. No transcript. */
472
+ export function clipboardSummary(i: ClipboardInput): string {
473
+ const parts = header(i, true)
474
+ // State that a transcript exists but was omitted. Without this, a reader
475
+ // holding only the summary cannot tell whether one exists at all.
476
+ const tx = i.scribe.transcript.trim()
477
+ if (tx) parts.push('', `_Transcript omitted (${tx.length} characters). Use Copy full for it._`)
180
478
  return parts.join('\n') + '\n'
181
479
  }
182
480
 
183
- /** Full form: everything including the transcript, for pasting into an LLM. */
481
+ /** Full form: everything including the transcript, for pasting into a model. */
184
482
  export function clipboardFull(i: ClipboardInput): string {
185
- const head = clipboardSummary(i).trimEnd()
186
- if (!i.scribe.transcript.trim()) {
187
- // Say so rather than silently returning the summary otherwise "Copy full"
188
- // and "Copy summary" produce identical text with no explanation.
189
- return head + '\n\n## Transcript\n(no transcript in this scribe)\n'
483
+ const parts = header(i, false)
484
+ const tx = i.scribe.transcript.trim()
485
+ // The section's OWN heading, so a `Transcript Enrichment` note is never
486
+ // presented as the transcript.
487
+ // "(no transcript in this scribe)" was a lie on 140 of 399 real sidecars: the
488
+ // write-up has not been generated yet, while the recording itself holds the
489
+ // speech — one case today had 27,442 characters sitting in the sidecar this
490
+ // route had just parsed. Say which of the two is actually true.
491
+ const fallback = i.capturedChars > 0
492
+ ? `(no write-up saved for this meeting yet — ${i.capturedChars} characters of ` +
493
+ `transcript are in the recording, and will appear here once it is written up)`
494
+ : '(no transcript in this scribe)'
495
+ parts.push('', `## ${i.scribe.transcriptHeading || 'Transcript'}`, tx || fallback)
496
+ // Additional transcript sections, full form only.
497
+ for (const o of i.scribe.otherTranscripts) {
498
+ if (o.body.trim()) parts.push('', `## ${o.heading}`, o.body.trim())
190
499
  }
191
- return head + '\n\n## Transcript\n' + i.scribe.transcript.trim() + '\n'
500
+ return parts.join('\n') + '\n'
192
501
  }
@@ -118,14 +118,15 @@ import {
118
118
  attachRawChunkIndices,
119
119
  isUnattributed,
120
120
  reviewMeetingSpeakers,
121
+ type ReviewChunk,
121
122
  type SpeakerWordSegment,
122
123
  } from '../lib/meeting-speaker-review.js'
123
- import { type ReviewChunk } from '../lib/meeting-speaker-review.js'
124
124
  import {
125
125
  parseScribe,
126
126
  clipboardSummary,
127
127
  clipboardFull,
128
128
  meetingDate,
129
+ SHARE_COVERAGE_FLOOR,
129
130
  } from '../lib/meeting-scribe-content.js'
130
131
  import {
131
132
  acquireMaintenanceWork,
@@ -815,11 +816,22 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
815
816
  return
816
817
  }
817
818
  const rawChunks = Array.isArray(sidecar) ? sidecar : sidecar.chunks
818
- const chunks = (Array.isArray(rawChunks) ? rawChunks : []) as ReviewChunk[]
819
+ if (!Array.isArray(rawChunks)) {
820
+ // Mirrors GET /speakers exactly. Coercing to [] returned 200 with an empty
821
+ // floor while still shipping the full verbatim prose and transcript — the
822
+ // worst pairing, and the two routes would have disagreed about the same
823
+ // session (panel 422, copy buttons fine).
824
+ res.status(422).json({ error: 'Chunk sidecar holds no chunk array', reason: 'sidecar_empty' })
825
+ return
826
+ }
827
+ const chunks = rawChunks as ReviewChunk[]
819
828
 
820
829
  const review = reviewMeetingSpeakers(attachRawChunkIndices(chunks, sidecar.chunkEntries), {
821
830
  owner: getOwnerSpeakerLabel(),
822
831
  confirmed: confirmedLabels(sessionId),
832
+ // This route renders no phrases; the default of 3 per voice computed and
833
+ // discarded 48 transcript excerpts on a 16-voice meeting.
834
+ phrasesPerVoice: 1,
823
835
  durationMs: typeof sidecar.durationMs === 'number' ? sidecar.durationMs : undefined,
824
836
  batchSegments: Array.isArray(sidecar.batchSegments)
825
837
  ? (sidecar.batchSegments as SpeakerWordSegment[])
@@ -835,6 +847,11 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
835
847
  asserted: v.nameAsserted,
836
848
  speakingMs: v.speakingMs,
837
849
  share: v.nameAsserted && namedTotal > 0 ? v.speakingMs / namedTotal : null,
850
+ // WHY the name is asserted, not just that it is. The review has always
851
+ // carried these and this route dropped them, so the clipboard credited
852
+ // "voice matching" for the wearer exemption and for names a human typed.
853
+ isOwner: v.isOwner,
854
+ confirmedByHuman: v.confirmedByHuman,
838
855
  }))
839
856
 
840
857
  // A missing .md is not fatal — the review and the sidecar still describe the
@@ -845,7 +862,43 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
845
862
 
846
863
  const date = meetingDate(sidecar.startTime)
847
864
  const durationMin = Math.round((review.durationMs || 0) / 60_000)
848
- const clip = { title: scribe.title || title, date, durationMin, attendees, scribe }
865
+ // EXACTLY Control's speakingCoverage: attributed / (attributed + unattributed).
866
+ // Not attributed/voicedMs — voicedMs is the union of everyone, which would
867
+ // give a different number and let the clipboard and the panel disagree about
868
+ // whether a share is trustworthy. Null when there is no voice at all, which
869
+ // suppresses shares rather than dividing by zero.
870
+ const coverageDenominator = review.attributedSpeakingMs + review.unattributedSpeakingMs
871
+ const coverage = coverageDenominator > 0
872
+ ? review.attributedSpeakingMs / coverageDenominator
873
+ : null
874
+ const clip = {
875
+ title: scribe.title || title,
876
+ date,
877
+ durationMin,
878
+ attendees,
879
+ scribe,
880
+ coverage,
881
+ // The UNION, never the sum of per-voice figures — crosstalk is counted once.
882
+ unattributedMs: review.unattributedSpeakingMs,
883
+ // Union of ALL voiced time. Only used to notice that the per-voice rows add
884
+ // to more than the meeting, which happens whenever people talk over
885
+ // each other — 34 of 323 real meetings.
886
+ voicedMs: review.voicedMs,
887
+ // Which business this is. The sibling /speakers route has always carried
888
+ // this and this one dropped it, so a personal 1:1 about someone's
889
+ // compensation was byte-identical to a marketing sync.
890
+ domain: operations?.domain ?? saved?.domain ?? '',
891
+ // Transcript actually captured, whether or not a write-up exists yet. 140
892
+ // of 399 real sidecars have no .md, and the fallback text claimed there was
893
+ // no transcript while holding one.
894
+ capturedChars: chunks.reduce(
895
+ (t, c) => t + (typeof c?.text === 'string' ? c.text.length : 0), 0),
896
+ }
897
+
898
+ // Rendered ONCE. Calling each builder twice to measure its own length meant
899
+ // two extra full renders per request, transcript included.
900
+ const summaryText = clipboardSummary(clip)
901
+ const fullText = clipboardFull(clip)
849
902
 
850
903
  res.set('Cache-Control', 'private, no-store')
851
904
  res.json({
@@ -861,9 +914,21 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
861
914
  topics: scribe.topics,
862
915
  decisions: scribe.decisions,
863
916
  actions: scribe.actions,
917
+ extras: scribe.extras.map(x => ({ heading: x.heading, body: x.body })),
864
918
  transcriptChars: scribe.transcript.length,
865
- clipboardSummary: clipboardSummary(clip),
866
- clipboardFull: clipboardFull(clip),
919
+ // Captured vs written-up are different facts and the panel needs both:
920
+ // transcriptChars is 0 for a meeting with 27,442 characters of speech and
921
+ // no write-up yet.
922
+ capturedChars: clip.capturedChars,
923
+ domain: clip.domain,
924
+ coverage,
925
+ sharesReported: coverage !== null && coverage >= SHARE_COVERAGE_FLOOR,
926
+ clipboardSummary: summaryText,
927
+ clipboardFull: fullText,
928
+ // Sizes of the ACTUAL strings, so the button label and the copy
929
+ // confirmation can never quote two different numbers for one click.
930
+ summaryChars: summaryText.length,
931
+ fullChars: fullText.length,
867
932
  })
868
933
  })
869
934