@gotcos/glasses-server 6.21.27 → 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,3 +1,83 @@
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
+
69
+ ## 6.21.28
70
+
71
+ **Published. Contains the route only — see 6.21.29 for what it is missing.**
72
+
73
+ - `GET /meeting/:sessionId/content` — the readable meeting plus two ready-made
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.
80
+
1
81
  ## 6.21.27
2
82
 
3
83
  - Per-speaker speaking time on the review. `speakingMs` per voice, plus
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.21.27",
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": {
@@ -0,0 +1,501 @@
1
+ // The readable meeting, and the two forms of it that leave the machine.
2
+ //
3
+ // WHAT v1 GOT WRONG, because the shape of the fix follows from it.
4
+ //
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.
11
+ //
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.
37
+
38
+ /** One `## Heading` section of a scribe file, in document order. */
39
+ export interface ScribeSection {
40
+ heading: string
41
+ /** Body with the heading line removed, scaffolding stripped, edges trimmed. */
42
+ body: string
43
+ }
44
+
45
+ export interface ParsedScribe {
46
+ /** The `# Title` line, or '' when the file has none. */
47
+ title: string
48
+ sections: ScribeSection[]
49
+ summary: string
50
+ topics: string
51
+ decisions: string
52
+ actions: string
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[]
77
+ }
78
+
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
+ }
122
+
123
+ /**
124
+ * Split a scribe into its `##` sections.
125
+ *
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.
133
+ */
134
+ export function parseScribe(markdown: string): ParsedScribe {
135
+ const lines = markdown.replace(/\r\n?/g, '\n').split('\n')
136
+ let title = ''
137
+ const sections: ScribeSection[] = []
138
+ let current: ScribeSection | null = null
139
+ let inFence = false
140
+
141
+ for (const line of lines) {
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
+ }
151
+ }
152
+ if (current) current.body += line + '\n'
153
+ }
154
+ if (current) sections.push({ ...current, body: cleanBody(current.body) })
155
+
156
+ const used = new Set<ScribeSection>()
157
+ /** Exact heading match, first name wins. */
158
+ const exact = (...names: string[]): string => {
159
+ for (const n of names) {
160
+ const s = sections.find(x => x.heading.toLowerCase() === n.toLowerCase() && !used.has(x))
161
+ if (s) { used.add(s); return s.body }
162
+ }
163
+ return ''
164
+ }
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
+
212
+ return {
213
+ title,
214
+ sections,
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),
225
+ }
226
+ }
227
+
228
+ /**
229
+ * The meeting's calendar date, as `YYYY-MM-DD`.
230
+ *
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.
235
+ */
236
+ export function meetingDate(startTime: unknown): string {
237
+ let ms: number | null = null
238
+ if (typeof startTime === 'number' && Number.isFinite(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
242
+ } else if (typeof startTime === 'string' && startTime.trim()) {
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))
251
+ if (Number.isFinite(t)) ms = t
252
+ }
253
+ // 0 and negatives are not real capture times; they rendered as "1969-12-31".
254
+ if (ms === null || ms <= 0) return ''
255
+ const d = new Date(ms)
256
+ if (Number.isNaN(d.getTime())) return ''
257
+ const pad = (n: number) => String(n).padStart(2, '0')
258
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
259
+ }
260
+
261
+ /** A voice as the clipboard should describe it. */
262
+ export interface AttendeeLine {
263
+ label: string
264
+ /** False when the review refuses to assert this name. */
265
+ asserted: boolean
266
+ speakingMs: number
267
+ /** Share of identified speech, 0..1, or null when unknown/unnamed. */
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
279
+ }
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
+
290
+ function mmss(ms: number): string {
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))
294
+ const m = Math.floor(total / 60)
295
+ const s = total % 60
296
+ if (m === 0) return `${s}s`
297
+ return s === 0 ? `${m}m` : `${m}m ${s}s`
298
+ }
299
+
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 {
325
+ const named = voices.filter(v => v.asserted).sort((a, b) => b.speakingMs - a.speakingMs)
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
333
+ const out: string[] = []
334
+ for (const v of named) {
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}`)
339
+ }
340
+ if (rest.length > 0) {
341
+ out.push(`- Unidentified: ${mmss(o.unattributedMs)} across ${rest.length} ` +
342
+ `voice${rest.length === 1 ? '' : 's'} the review could not name`)
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
+ }
355
+ return out.length > 0 ? out.join('\n') : '- (no voices identified)'
356
+ }
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
+
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
414
+ title: string
415
+ date: string
416
+ durationMin: number
417
+ attendees: AttendeeLine[]
418
+ scribe: ParsedScribe
419
+ /** Both required, so neither can be omitted. See AttendeeRenderOptions. */
420
+ coverage: number | null
421
+ unattributedMs: number
422
+ }
423
+
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
+ )
452
+ for (const [heading, body] of [
453
+ ['Summary', i.scribe.summary],
454
+ ['Topics', i.scribe.topics],
455
+ ['Decisions', i.scribe.decisions],
456
+ ['Action items', i.scribe.actions],
457
+ ] as const) {
458
+ if (body.trim()) parts.push('', `## ${heading}`, body.trim())
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._`)
478
+ return parts.join('\n') + '\n'
479
+ }
480
+
481
+ /** Full form: everything including the transcript, for pasting into a model. */
482
+ export function clipboardFull(i: ClipboardInput): string {
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())
499
+ }
500
+ return parts.join('\n') + '\n'
501
+ }
@@ -118,9 +118,16 @@ import {
118
118
  attachRawChunkIndices,
119
119
  isUnattributed,
120
120
  reviewMeetingSpeakers,
121
- type SpeakerWordSegment,
122
121
  type ReviewChunk,
122
+ type SpeakerWordSegment,
123
123
  } from '../lib/meeting-speaker-review.js'
124
+ import {
125
+ parseScribe,
126
+ clipboardSummary,
127
+ clipboardFull,
128
+ meetingDate,
129
+ SHARE_COVERAGE_FLOOR,
130
+ } from '../lib/meeting-scribe-content.js'
124
131
  import {
125
132
  acquireMaintenanceWork,
126
133
  maintenanceAdmissionsOpen,
@@ -769,6 +776,162 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
769
776
  // touched, and a failed intent write aborts without mutating anything. A
770
777
  // process that dies mid-rewrite therefore leaves a visible pending correction
771
778
  // rather than a silently half-relabelled meeting.
779
+ /**
780
+ * The readable meeting, plus two clipboard forms.
781
+ *
782
+ * Resolution is operations-first, identical to GET /speakers — the same session
783
+ * lives in both trees under different names and the list reads operations, so
784
+ * anything keyed on a session has to resolve there too or the row and this
785
+ * view disagree about the same meeting.
786
+ *
787
+ * The attendee list is REBUILT from the speaker review rather than taken from
788
+ * the scribe's own `## Attendees`, which applies no confidence floor: the
789
+ * 2026-08-06 IJO scribe lists 15 attendees for a 26-minute call including a
790
+ * name already confirmed absent. Copying that verbatim would launder a guess
791
+ * into a fact in whatever the reviewer pastes it into.
792
+ */
793
+ router.get('/meeting/:sessionId/content', (req, res) => {
794
+ const sessionId = String(req.params.sessionId ?? '')
795
+ if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) {
796
+ res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
797
+ return
798
+ }
799
+ const operations = cosOperationsMeetingsConfigured()
800
+ ? findCosOperationsMeetingBySessionId(sessionId)
801
+ : null
802
+ const saved = operations ? null : store.findBySessionId(sessionId)
803
+ if (!operations && !saved) {
804
+ res.status(404).json({ error: 'No saved meeting for this session', reason: 'meeting_not_found' })
805
+ return
806
+ }
807
+ const sidecarPath = operations?.sidecarPath ?? saved!.sidecarPath
808
+ const title = operations?.title ?? saved!.title
809
+ const mdPath = operations?.meetingPath ?? sidecarPath.replace(/\.g2-chunks\.json$/, '.md')
810
+
811
+ let sidecar: Record<string, unknown>
812
+ try {
813
+ sidecar = (JSON.parse(readFileSync(sidecarPath, 'utf-8')) ?? {}) as Record<string, unknown>
814
+ } catch {
815
+ res.status(422).json({ error: 'Chunk sidecar is missing or unreadable', reason: 'sidecar_unreadable' })
816
+ return
817
+ }
818
+ const rawChunks = Array.isArray(sidecar) ? sidecar : sidecar.chunks
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[]
828
+
829
+ const review = reviewMeetingSpeakers(attachRawChunkIndices(chunks, sidecar.chunkEntries), {
830
+ owner: getOwnerSpeakerLabel(),
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,
835
+ durationMs: typeof sidecar.durationMs === 'number' ? sidecar.durationMs : undefined,
836
+ batchSegments: Array.isArray(sidecar.batchSegments)
837
+ ? (sidecar.batchSegments as SpeakerWordSegment[])
838
+ : undefined,
839
+ })
840
+
841
+ // Share is over NAMED speech and totals 100%, matching the panel. The union
842
+ // in attributedSpeakingMs counts crosstalk once, so dividing by it would let
843
+ // the shares exceed 100%.
844
+ const namedTotal = review.voices.reduce((n, v) => (v.nameAsserted ? n + v.speakingMs : n), 0)
845
+ const attendees = review.voices.map(v => ({
846
+ label: v.label,
847
+ asserted: v.nameAsserted,
848
+ speakingMs: v.speakingMs,
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,
855
+ }))
856
+
857
+ // A missing .md is not fatal — the review and the sidecar still describe the
858
+ // meeting, and a reviewer would rather have who-spoke than a 404.
859
+ let markdown = ''
860
+ try { markdown = readFileSync(mdPath, 'utf-8') } catch { markdown = '' }
861
+ const scribe = parseScribe(markdown)
862
+
863
+ const date = meetingDate(sidecar.startTime)
864
+ const durationMin = Math.round((review.durationMs || 0) / 60_000)
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)
902
+
903
+ res.set('Cache-Control', 'private, no-store')
904
+ res.json({
905
+ sessionId,
906
+ title: scribe.title || title,
907
+ date,
908
+ durationMin,
909
+ scribeAvailable: markdown.length > 0,
910
+ attendees,
911
+ speakingTimeSource: review.speakingTimeSource,
912
+ voicedMs: review.voicedMs,
913
+ summary: scribe.summary,
914
+ topics: scribe.topics,
915
+ decisions: scribe.decisions,
916
+ actions: scribe.actions,
917
+ extras: scribe.extras.map(x => ({ heading: x.heading, body: x.body })),
918
+ transcriptChars: scribe.transcript.length,
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,
932
+ })
933
+ })
934
+
772
935
  router.post('/meeting/:sessionId/relabel', (req, res) => {
773
936
  res.set('Cache-Control', 'private, no-store')
774
937
  const sessionId = String(req.params.sessionId ?? '')