@gotcos/glasses-server 6.21.27 → 6.21.28
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 +23 -0
- package/package.json +1 -1
- package/server/lib/meeting-scribe-content.ts +192 -0
- package/server/routes/meeting.ts +99 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,26 @@
|
|
|
1
|
+
## 6.21.28
|
|
2
|
+
|
|
3
|
+
- `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.
|
|
23
|
+
|
|
1
24
|
## 6.21.27
|
|
2
25
|
|
|
3
26
|
- Per-speaker speaking time on the review. `speakingMs` per voice, plus
|
package/package.json
CHANGED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// The readable meeting: what the scribe markdown says, and two clipboard forms.
|
|
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.
|
|
9
|
+
//
|
|
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.
|
|
13
|
+
//
|
|
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.
|
|
17
|
+
|
|
18
|
+
/** One `## Heading` section of a scribe file, in document order. */
|
|
19
|
+
export interface ScribeSection {
|
|
20
|
+
heading: string
|
|
21
|
+
/** Body text with the heading line removed and edges trimmed. */
|
|
22
|
+
body: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ParsedScribe {
|
|
26
|
+
/** The `# Title` line, or '' when the file has none. */
|
|
27
|
+
title: string
|
|
28
|
+
sections: ScribeSection[]
|
|
29
|
+
/** Convenience lookups for the sections a UI renders directly. */
|
|
30
|
+
summary: string
|
|
31
|
+
topics: string
|
|
32
|
+
decisions: string
|
|
33
|
+
actions: string
|
|
34
|
+
transcript: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const SECTION = /^##\s+(.+?)\s*$/
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Split a scribe file into its `##` sections.
|
|
41
|
+
*
|
|
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.
|
|
47
|
+
*/
|
|
48
|
+
export function parseScribe(markdown: string): ParsedScribe {
|
|
49
|
+
const lines = markdown.split('\n')
|
|
50
|
+
let title = ''
|
|
51
|
+
const sections: ScribeSection[] = []
|
|
52
|
+
let current: ScribeSection | null = null
|
|
53
|
+
const preamble: string[] = []
|
|
54
|
+
|
|
55
|
+
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
|
|
62
|
+
}
|
|
63
|
+
if (current) current.body += line + '\n'
|
|
64
|
+
else preamble.push(line)
|
|
65
|
+
}
|
|
66
|
+
if (current) sections.push({ ...current, body: current.body.trim() })
|
|
67
|
+
|
|
68
|
+
const find = (...names: string[]): string => {
|
|
69
|
+
for (const n of names) {
|
|
70
|
+
const s = sections.find(x => x.heading.toLowerCase() === n.toLowerCase())
|
|
71
|
+
if (s) return s.body
|
|
72
|
+
}
|
|
73
|
+
return ''
|
|
74
|
+
}
|
|
75
|
+
const summary = find('Summary')
|
|
76
|
+
return {
|
|
77
|
+
title,
|
|
78
|
+
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'),
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The meeting's calendar date, as `YYYY-MM-DD`.
|
|
91
|
+
*
|
|
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.
|
|
97
|
+
*/
|
|
98
|
+
export function meetingDate(startTime: unknown): string {
|
|
99
|
+
let ms: number | null = null
|
|
100
|
+
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
|
|
103
|
+
} else if (typeof startTime === 'string' && startTime.trim()) {
|
|
104
|
+
const t = Date.parse(startTime)
|
|
105
|
+
if (Number.isFinite(t)) ms = t
|
|
106
|
+
}
|
|
107
|
+
if (ms === null) return ''
|
|
108
|
+
const d = new Date(ms)
|
|
109
|
+
if (Number.isNaN(d.getTime())) return ''
|
|
110
|
+
const pad = (n: number) => String(n).padStart(2, '0')
|
|
111
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** A voice as the clipboard should describe it. */
|
|
115
|
+
export interface AttendeeLine {
|
|
116
|
+
label: string
|
|
117
|
+
/** False when the review refuses to assert this name. */
|
|
118
|
+
asserted: boolean
|
|
119
|
+
speakingMs: number
|
|
120
|
+
/** Share of NAMED speaking time, 0..1, or null when unknown/unnamed. */
|
|
121
|
+
share: number | null
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function mmss(ms: number): string {
|
|
125
|
+
const total = Math.round(ms / 1000)
|
|
126
|
+
const m = Math.floor(total / 60)
|
|
127
|
+
const s = total % 60
|
|
128
|
+
return m > 0 ? `${m}m ${s}s` : `${s}s`
|
|
129
|
+
}
|
|
130
|
+
|
|
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 {
|
|
140
|
+
const named = voices.filter(v => v.asserted).sort((a, b) => b.speakingMs - a.speakingMs)
|
|
141
|
+
const rest = voices.filter(v => !v.asserted)
|
|
142
|
+
const out: string[] = []
|
|
143
|
+
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}`)
|
|
146
|
+
}
|
|
147
|
+
const restMs = rest.reduce((n, v) => n + v.speakingMs, 0)
|
|
148
|
+
if (rest.length > 0) {
|
|
149
|
+
out.push(`- Unidentified — ${mmss(restMs)} across ${rest.length} ` +
|
|
150
|
+
`voice${rest.length === 1 ? '' : 's'} the review could not name`)
|
|
151
|
+
}
|
|
152
|
+
return out.length > 0 ? out.join('\n') : '- (no voices identified)'
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export interface ClipboardInput {
|
|
156
|
+
title: string
|
|
157
|
+
date: string
|
|
158
|
+
durationMin: number
|
|
159
|
+
attendees: AttendeeLine[]
|
|
160
|
+
scribe: ParsedScribe
|
|
161
|
+
}
|
|
162
|
+
|
|
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
|
+
]
|
|
172
|
+
for (const [heading, body] of [
|
|
173
|
+
['Summary', i.scribe.summary],
|
|
174
|
+
['Topics', i.scribe.topics],
|
|
175
|
+
['Decisions', i.scribe.decisions],
|
|
176
|
+
['Action items', i.scribe.actions],
|
|
177
|
+
] as const) {
|
|
178
|
+
if (body.trim()) parts.push('', `## ${heading}`, body.trim())
|
|
179
|
+
}
|
|
180
|
+
return parts.join('\n') + '\n'
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Full form: everything including the transcript, for pasting into an LLM. */
|
|
184
|
+
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'
|
|
190
|
+
}
|
|
191
|
+
return head + '\n\n## Transcript\n' + i.scribe.transcript.trim() + '\n'
|
|
192
|
+
}
|
package/server/routes/meeting.ts
CHANGED
|
@@ -119,8 +119,14 @@ import {
|
|
|
119
119
|
isUnattributed,
|
|
120
120
|
reviewMeetingSpeakers,
|
|
121
121
|
type SpeakerWordSegment,
|
|
122
|
-
type ReviewChunk,
|
|
123
122
|
} from '../lib/meeting-speaker-review.js'
|
|
123
|
+
import { type ReviewChunk } from '../lib/meeting-speaker-review.js'
|
|
124
|
+
import {
|
|
125
|
+
parseScribe,
|
|
126
|
+
clipboardSummary,
|
|
127
|
+
clipboardFull,
|
|
128
|
+
meetingDate,
|
|
129
|
+
} from '../lib/meeting-scribe-content.js'
|
|
124
130
|
import {
|
|
125
131
|
acquireMaintenanceWork,
|
|
126
132
|
maintenanceAdmissionsOpen,
|
|
@@ -769,6 +775,98 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
769
775
|
// touched, and a failed intent write aborts without mutating anything. A
|
|
770
776
|
// process that dies mid-rewrite therefore leaves a visible pending correction
|
|
771
777
|
// rather than a silently half-relabelled meeting.
|
|
778
|
+
/**
|
|
779
|
+
* The readable meeting, plus two clipboard forms.
|
|
780
|
+
*
|
|
781
|
+
* Resolution is operations-first, identical to GET /speakers — the same session
|
|
782
|
+
* lives in both trees under different names and the list reads operations, so
|
|
783
|
+
* anything keyed on a session has to resolve there too or the row and this
|
|
784
|
+
* view disagree about the same meeting.
|
|
785
|
+
*
|
|
786
|
+
* The attendee list is REBUILT from the speaker review rather than taken from
|
|
787
|
+
* the scribe's own `## Attendees`, which applies no confidence floor: the
|
|
788
|
+
* 2026-08-06 IJO scribe lists 15 attendees for a 26-minute call including a
|
|
789
|
+
* name already confirmed absent. Copying that verbatim would launder a guess
|
|
790
|
+
* into a fact in whatever the reviewer pastes it into.
|
|
791
|
+
*/
|
|
792
|
+
router.get('/meeting/:sessionId/content', (req, res) => {
|
|
793
|
+
const sessionId = String(req.params.sessionId ?? '')
|
|
794
|
+
if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) {
|
|
795
|
+
res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
|
|
796
|
+
return
|
|
797
|
+
}
|
|
798
|
+
const operations = cosOperationsMeetingsConfigured()
|
|
799
|
+
? findCosOperationsMeetingBySessionId(sessionId)
|
|
800
|
+
: null
|
|
801
|
+
const saved = operations ? null : store.findBySessionId(sessionId)
|
|
802
|
+
if (!operations && !saved) {
|
|
803
|
+
res.status(404).json({ error: 'No saved meeting for this session', reason: 'meeting_not_found' })
|
|
804
|
+
return
|
|
805
|
+
}
|
|
806
|
+
const sidecarPath = operations?.sidecarPath ?? saved!.sidecarPath
|
|
807
|
+
const title = operations?.title ?? saved!.title
|
|
808
|
+
const mdPath = operations?.meetingPath ?? sidecarPath.replace(/\.g2-chunks\.json$/, '.md')
|
|
809
|
+
|
|
810
|
+
let sidecar: Record<string, unknown>
|
|
811
|
+
try {
|
|
812
|
+
sidecar = (JSON.parse(readFileSync(sidecarPath, 'utf-8')) ?? {}) as Record<string, unknown>
|
|
813
|
+
} catch {
|
|
814
|
+
res.status(422).json({ error: 'Chunk sidecar is missing or unreadable', reason: 'sidecar_unreadable' })
|
|
815
|
+
return
|
|
816
|
+
}
|
|
817
|
+
const rawChunks = Array.isArray(sidecar) ? sidecar : sidecar.chunks
|
|
818
|
+
const chunks = (Array.isArray(rawChunks) ? rawChunks : []) as ReviewChunk[]
|
|
819
|
+
|
|
820
|
+
const review = reviewMeetingSpeakers(attachRawChunkIndices(chunks, sidecar.chunkEntries), {
|
|
821
|
+
owner: getOwnerSpeakerLabel(),
|
|
822
|
+
confirmed: confirmedLabels(sessionId),
|
|
823
|
+
durationMs: typeof sidecar.durationMs === 'number' ? sidecar.durationMs : undefined,
|
|
824
|
+
batchSegments: Array.isArray(sidecar.batchSegments)
|
|
825
|
+
? (sidecar.batchSegments as SpeakerWordSegment[])
|
|
826
|
+
: undefined,
|
|
827
|
+
})
|
|
828
|
+
|
|
829
|
+
// Share is over NAMED speech and totals 100%, matching the panel. The union
|
|
830
|
+
// in attributedSpeakingMs counts crosstalk once, so dividing by it would let
|
|
831
|
+
// the shares exceed 100%.
|
|
832
|
+
const namedTotal = review.voices.reduce((n, v) => (v.nameAsserted ? n + v.speakingMs : n), 0)
|
|
833
|
+
const attendees = review.voices.map(v => ({
|
|
834
|
+
label: v.label,
|
|
835
|
+
asserted: v.nameAsserted,
|
|
836
|
+
speakingMs: v.speakingMs,
|
|
837
|
+
share: v.nameAsserted && namedTotal > 0 ? v.speakingMs / namedTotal : null,
|
|
838
|
+
}))
|
|
839
|
+
|
|
840
|
+
// A missing .md is not fatal — the review and the sidecar still describe the
|
|
841
|
+
// meeting, and a reviewer would rather have who-spoke than a 404.
|
|
842
|
+
let markdown = ''
|
|
843
|
+
try { markdown = readFileSync(mdPath, 'utf-8') } catch { markdown = '' }
|
|
844
|
+
const scribe = parseScribe(markdown)
|
|
845
|
+
|
|
846
|
+
const date = meetingDate(sidecar.startTime)
|
|
847
|
+
const durationMin = Math.round((review.durationMs || 0) / 60_000)
|
|
848
|
+
const clip = { title: scribe.title || title, date, durationMin, attendees, scribe }
|
|
849
|
+
|
|
850
|
+
res.set('Cache-Control', 'private, no-store')
|
|
851
|
+
res.json({
|
|
852
|
+
sessionId,
|
|
853
|
+
title: scribe.title || title,
|
|
854
|
+
date,
|
|
855
|
+
durationMin,
|
|
856
|
+
scribeAvailable: markdown.length > 0,
|
|
857
|
+
attendees,
|
|
858
|
+
speakingTimeSource: review.speakingTimeSource,
|
|
859
|
+
voicedMs: review.voicedMs,
|
|
860
|
+
summary: scribe.summary,
|
|
861
|
+
topics: scribe.topics,
|
|
862
|
+
decisions: scribe.decisions,
|
|
863
|
+
actions: scribe.actions,
|
|
864
|
+
transcriptChars: scribe.transcript.length,
|
|
865
|
+
clipboardSummary: clipboardSummary(clip),
|
|
866
|
+
clipboardFull: clipboardFull(clip),
|
|
867
|
+
})
|
|
868
|
+
})
|
|
869
|
+
|
|
772
870
|
router.post('/meeting/:sessionId/relabel', (req, res) => {
|
|
773
871
|
res.set('Cache-Control', 'private, no-store')
|
|
774
872
|
const sessionId = String(req.params.sessionId ?? '')
|