@gotcos/glasses-server 6.46.1 → 6.47.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/package.json +6 -2
- package/server/index.ts +76 -0
- package/server/lib/cos-operations-meetings.ts +99 -8
- package/server/lib/fireflies-client.ts +862 -0
- package/server/lib/fireflies-key.ts +182 -0
- package/server/lib/imported-library-rows.ts +616 -0
- package/server/lib/imported-meeting-library.ts +608 -0
- package/server/lib/maintenance-lifecycle.ts +14 -0
- package/server/lib/meeting-actions-store.ts +478 -0
- package/server/lib/meeting-actions.ts +2583 -0
- package/server/lib/meeting-corrections.ts +32 -1
- package/server/lib/meeting-decisions.ts +223 -0
- package/server/lib/meeting-engine/align.ts +167 -0
- package/server/lib/meeting-engine/attribute.ts +265 -0
- package/server/lib/meeting-engine/evidence.ts +428 -0
- package/server/lib/meeting-engine/pairing.ts +327 -0
- package/server/lib/meeting-engine/render.ts +694 -0
- package/server/lib/meeting-engine/split.ts +242 -0
- package/server/lib/meeting-engine/worker.ts +238 -0
- package/server/lib/meeting-engine-mode.ts +197 -0
- package/server/lib/meeting-file-guards.ts +141 -0
- package/server/lib/meeting-import.ts +763 -0
- package/server/lib/meeting-library-search.ts +146 -11
- package/server/lib/meeting-parse.ts +184 -0
- package/server/lib/meeting-store.ts +108 -275
- package/server/lib/meeting-suggestion-sides.ts +242 -0
- package/server/lib/morning-brief-runtime.ts +20 -8
- package/server/lib/pipeline-runner.ts +227 -0
- package/server/lib/voice-evidence-guard.ts +87 -0
- package/server/routes/fireflies-key.ts +102 -0
- package/server/routes/meeting-actions.ts +82 -0
- package/server/routes/meeting-engine.ts +52 -0
- package/server/routes/meeting-import.ts +67 -0
- package/server/routes/meeting-suggestions.ts +66 -0
- package/server/routes/meeting.ts +117 -10
- package/server/routes/meetings.ts +177 -50
- package/server/routes/voice.ts +18 -0
|
@@ -19,6 +19,20 @@ import {
|
|
|
19
19
|
resolveMeetingLibrary,
|
|
20
20
|
sidecarSessionId,
|
|
21
21
|
} from './cos-operations-meetings.js'
|
|
22
|
+
import {
|
|
23
|
+
type ImportedMeetingLibrary,
|
|
24
|
+
type ImportedRecordKind,
|
|
25
|
+
IMPORTED_DOMAIN,
|
|
26
|
+
IMPORTED_FILENAME_PATTERN,
|
|
27
|
+
getImportedMeetingLibrary,
|
|
28
|
+
importRecordId,
|
|
29
|
+
} from './imported-meeting-library.js'
|
|
30
|
+
import {
|
|
31
|
+
type SupersededInputs,
|
|
32
|
+
importedLibraryMonths,
|
|
33
|
+
readDerivedRecords,
|
|
34
|
+
supersededInputsOf,
|
|
35
|
+
} from './imported-library-rows.js'
|
|
22
36
|
import { getMeetingStore, MeetingStore } from './meeting-store.js'
|
|
23
37
|
import type { MeetingMeta } from './meeting-store.js'
|
|
24
38
|
|
|
@@ -67,6 +81,10 @@ export function libraryRefFromPath(filePath: string): { domain: string; month: s
|
|
|
67
81
|
if (ops) return { domain: ops[1], month: ops[2], filename: ops[3] }
|
|
68
82
|
const standalone = normalized.match(/\/recordings\/(\d{4}-\d{2})\/([^/]+\.md)$/i)
|
|
69
83
|
if (standalone) return { domain: 'personal', month: standalone[1], filename: standalone[2] }
|
|
84
|
+
// BEFORE the direct-library shape, which is the bare `<month>/<file>.md` tail
|
|
85
|
+
// every one of these paths ends with and would otherwise claim them all.
|
|
86
|
+
const imports = normalized.match(/\/imports\/(\d{4}-\d{2})\/([^/]+\.md)$/i)
|
|
87
|
+
if (imports) return { domain: IMPORTED_DOMAIN, month: imports[1], filename: imports[2] }
|
|
70
88
|
const direct = normalized.match(/\/(\d{4}-\d{2})\/([^/]+\.md)$/)
|
|
71
89
|
if (direct) return { domain: 'library', month: direct[1], filename: direct[2] }
|
|
72
90
|
return null
|
|
@@ -121,21 +139,89 @@ function sourceFrom(content: string): string {
|
|
|
121
139
|
return match ? match[1].replace(/\s*\|?\s*$/, '').trim() : ''
|
|
122
140
|
}
|
|
123
141
|
|
|
142
|
+
/**
|
|
143
|
+
* The record id a hit opens.
|
|
144
|
+
*
|
|
145
|
+
* The imported and derived branches derive it from the FILENAME's hash rather
|
|
146
|
+
* than from the row, because a semantic hit arrives as a file path and has no
|
|
147
|
+
* row behind it. `importRecordId` is the one definition of that shape, shared
|
|
148
|
+
* with the library and the list, so a hit and a row can never name one record
|
|
149
|
+
* two different ways.
|
|
150
|
+
*/
|
|
124
151
|
function hitIdentity(row: {
|
|
125
152
|
domain: string
|
|
126
153
|
month: string
|
|
127
154
|
filename: string
|
|
128
155
|
librarySource?: MeetingMeta['librarySource']
|
|
129
156
|
sessionId?: string
|
|
157
|
+
recordId?: string
|
|
130
158
|
}): Pick<MeetingSearchHit, 'recordId' | 'month' | 'filename' | 'domain'> {
|
|
131
159
|
const recordId = row.librarySource === 'direct_library'
|
|
132
160
|
? `direct:${row.month}:${row.filename}`
|
|
133
161
|
: row.librarySource === 'standalone_recordings'
|
|
134
162
|
? `standalone:${row.sessionId || `${row.domain}:${row.month}:${row.filename}`}`
|
|
135
|
-
:
|
|
163
|
+
: row.librarySource === 'imported' || row.librarySource === 'blended'
|
|
164
|
+
? row.recordId || importedRecordIdFromFilename(row.filename) || `ops:${row.domain}:${row.month}:${row.filename}`
|
|
165
|
+
: `ops:${row.domain}:${row.month}:${row.filename}`
|
|
136
166
|
return { recordId, month: row.month, filename: row.filename, domain: row.domain }
|
|
137
167
|
}
|
|
138
168
|
|
|
169
|
+
/** `2026-09-10_merged_<h16>.md` names its own record. Null when it does not. */
|
|
170
|
+
export function importedRecordIdFromFilename(filename: string): string | null {
|
|
171
|
+
const match = filename.match(IMPORTED_FILENAME_PATTERN)
|
|
172
|
+
if (!match) return null
|
|
173
|
+
return importRecordId(match[2] as ImportedRecordKind, match[3])
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Keyword hits from the imported library.
|
|
178
|
+
*
|
|
179
|
+
* Runs BEFORE standalone and shares the same file budget, so a Mac with a large
|
|
180
|
+
* imported library cannot starve its own recordings of scan budget or the other
|
|
181
|
+
* way round. Supersession is applied by the caller, so one meeting gives one hit
|
|
182
|
+
* whether it is the import, the capture, or the merged record that matched.
|
|
183
|
+
*/
|
|
184
|
+
function keywordHitsFromImports(
|
|
185
|
+
tokens: string[],
|
|
186
|
+
domainFilter: string,
|
|
187
|
+
budget: { remaining: number },
|
|
188
|
+
library: ImportedMeetingLibrary = getImportedMeetingLibrary(),
|
|
189
|
+
): MeetingSearchHit[] {
|
|
190
|
+
const hits: MeetingSearchHit[] = []
|
|
191
|
+
for (const month of importedLibraryMonths(library)) {
|
|
192
|
+
if (budget.remaining <= 0) break
|
|
193
|
+
for (const row of library.list({ month, domain: domainFilter })) {
|
|
194
|
+
if (budget.remaining <= 0) break
|
|
195
|
+
budget.remaining -= 1
|
|
196
|
+
let content = ''
|
|
197
|
+
try {
|
|
198
|
+
content = readFileSync(join(library.root, row.month, row.filename), 'utf8').slice(0, HEAD_BYTES)
|
|
199
|
+
} catch {
|
|
200
|
+
content = ''
|
|
201
|
+
}
|
|
202
|
+
const haystack = `${row.filename}\n${row.title}\n${row.originDomain}\n${content}`
|
|
203
|
+
const scored = scoreKeywordMatch(tokens, row.title, haystack)
|
|
204
|
+
if (scored.score <= 0) continue
|
|
205
|
+
hits.push({
|
|
206
|
+
recordId: row.recordId,
|
|
207
|
+
title: row.title,
|
|
208
|
+
date: row.date,
|
|
209
|
+
domain: row.domain,
|
|
210
|
+
duration: row.duration,
|
|
211
|
+
month: row.month,
|
|
212
|
+
filename: row.filename,
|
|
213
|
+
source: row.source,
|
|
214
|
+
librarySource: row.librarySource,
|
|
215
|
+
snippet: scored.snippet || row.title,
|
|
216
|
+
keywordScore: scored.score,
|
|
217
|
+
semanticScore: 0,
|
|
218
|
+
match: 'keyword',
|
|
219
|
+
})
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return hits
|
|
223
|
+
}
|
|
224
|
+
|
|
139
225
|
function keywordHitsFromOps(tokens: string[], domainFilter: string, budget: { remaining: number }): MeetingSearchHit[] {
|
|
140
226
|
const operationsDir = resolveCosOperationsDir()
|
|
141
227
|
if (!operationsDir) return []
|
|
@@ -296,11 +382,55 @@ export function keywordSearchMeetings(
|
|
|
296
382
|
if (library.layout === 'direct' && (domain === 'all' || domain === 'library')) {
|
|
297
383
|
for (const hit of keywordHitsFromDirect(tokens, budget)) push(hit)
|
|
298
384
|
}
|
|
385
|
+
// Imports before standalone, sharing one budget: a large imported library must
|
|
386
|
+
// not be scanned only after the recordings have spent the allowance, and the
|
|
387
|
+
// reverse must not happen either.
|
|
388
|
+
for (const hit of keywordHitsFromImports(tokens, domain, budget)) push(hit)
|
|
299
389
|
for (const hit of keywordHitsFromStandalone(tokens, domain, budget, store)) push(hit)
|
|
300
|
-
return [...grouped.values()].sort((a, b) => b.keywordScore - a.keywordScore)
|
|
390
|
+
return dropSupersededHits([...grouped.values()]).sort((a, b) => b.keywordScore - a.keywordScore)
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* One meeting, one hit.
|
|
395
|
+
*
|
|
396
|
+
* A merged record contains the transcript of both its import and its capture, so
|
|
397
|
+
* a query that matches the meeting matches all three files. The list already
|
|
398
|
+
* drops the inputs a derived record holds; the search has to drop them too or
|
|
399
|
+
* the same conversation comes back three times under three titles.
|
|
400
|
+
*
|
|
401
|
+
* Only the imports root is consulted. On a Mac where the PIPELINE applied the
|
|
402
|
+
* merge there is nothing to drop: the merge was spliced into the Fireflies
|
|
403
|
+
* scribe in place and the capture's standalone scribe was retired, so only one
|
|
404
|
+
* file holds the meeting.
|
|
405
|
+
*/
|
|
406
|
+
export function dropSupersededHits(
|
|
407
|
+
hits: MeetingSearchHit[],
|
|
408
|
+
superseded: SupersededInputs = supersededInputsOf(readDerivedRecords()),
|
|
409
|
+
): MeetingSearchHit[] {
|
|
410
|
+
if (superseded.isEmpty) return hits
|
|
411
|
+
return hits.filter(hit => {
|
|
412
|
+
if (hit.librarySource === 'blended') return true
|
|
413
|
+
if (superseded.importRecordIds.has(hit.recordId)) return false
|
|
414
|
+
if (hit.sessionId && superseded.g2Sessions.has(hit.sessionId)) return false
|
|
415
|
+
return true
|
|
416
|
+
})
|
|
301
417
|
}
|
|
302
418
|
|
|
303
|
-
|
|
419
|
+
/**
|
|
420
|
+
* Which library a path belongs to.
|
|
421
|
+
*
|
|
422
|
+
* An imports path decides between `imported` and `blended` from the FILENAME's
|
|
423
|
+
* own kind, so a merged record found by semantic search is labelled the same way
|
|
424
|
+
* the list labels it rather than as a plain import.
|
|
425
|
+
*/
|
|
426
|
+
function librarySourceForRef(refDomain: string | undefined, filename: string): MeetingMeta['librarySource'] {
|
|
427
|
+
if (refDomain === 'library') return 'direct_library'
|
|
428
|
+
if (refDomain !== IMPORTED_DOMAIN) return 'cos_operations'
|
|
429
|
+
const match = filename.match(IMPORTED_FILENAME_PATTERN)
|
|
430
|
+
return match && match[2] !== 'fireflies' ? 'blended' : 'imported'
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
export interface SemanticRawHit {
|
|
304
434
|
title?: string
|
|
305
435
|
date?: string
|
|
306
436
|
domain?: string
|
|
@@ -310,7 +440,16 @@ interface SemanticRawHit {
|
|
|
310
440
|
meeting_id?: string
|
|
311
441
|
}
|
|
312
442
|
|
|
313
|
-
|
|
443
|
+
/**
|
|
444
|
+
* One semantic result as a library hit.
|
|
445
|
+
*
|
|
446
|
+
* Exported for the identity tests: a semantic hit arrives as a FILE PATH with no
|
|
447
|
+
* row behind it, so this is the only place where an imported or derived record's
|
|
448
|
+
* id has to be reconstructed from the filename. The keyword scan never exercises
|
|
449
|
+
* that branch, and an untested reconstruction is how a hit and a row end up
|
|
450
|
+
* naming one record two different ways.
|
|
451
|
+
*/
|
|
452
|
+
export function semanticHitToLibrary(raw: SemanticRawHit): MeetingSearchHit | null {
|
|
314
453
|
const fromPath = raw.file_path ? libraryRefFromPath(raw.file_path) : null
|
|
315
454
|
const domain = fromPath?.domain || raw.domain || ''
|
|
316
455
|
const date = raw.date || ''
|
|
@@ -321,12 +460,8 @@ function semanticHitToLibrary(raw: SemanticRawHit): MeetingSearchHit | null {
|
|
|
321
460
|
}
|
|
322
461
|
if (!filename || !month) return null
|
|
323
462
|
const title = raw.title || titleFrom('', filename)
|
|
324
|
-
const
|
|
325
|
-
|
|
326
|
-
month,
|
|
327
|
-
filename,
|
|
328
|
-
librarySource: fromPath?.domain === 'library' ? 'direct_library' : 'cos_operations',
|
|
329
|
-
})
|
|
463
|
+
const librarySource = librarySourceForRef(fromPath?.domain, filename)
|
|
464
|
+
const identity = hitIdentity({ domain: domain || 'quilt', month, filename, librarySource })
|
|
330
465
|
const semanticScore = typeof raw.score === 'number' && Number.isFinite(raw.score) ? Math.max(0, Math.min(1, raw.score)) : 0
|
|
331
466
|
return {
|
|
332
467
|
...identity,
|
|
@@ -334,7 +469,7 @@ function semanticHitToLibrary(raw: SemanticRawHit): MeetingSearchHit | null {
|
|
|
334
469
|
date,
|
|
335
470
|
duration: '',
|
|
336
471
|
source: '',
|
|
337
|
-
librarySource: identity.recordId.startsWith('direct:') ? 'direct_library' :
|
|
472
|
+
librarySource: identity.recordId.startsWith('direct:') ? 'direct_library' : librarySource,
|
|
338
473
|
snippet: String(raw.summary || '').slice(0, 180),
|
|
339
474
|
keywordScore: 0,
|
|
340
475
|
semanticScore,
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// The meeting markdown parser, shared by MeetingStore and the imported library.
|
|
2
|
+
//
|
|
3
|
+
// Moved out of meeting-store.ts in 6.47.0, unchanged. Imported records are the
|
|
4
|
+
// same markdown shape as every other meeting record on this box, and the one
|
|
5
|
+
// thing worse than two libraries would be two parsers: a field the importer
|
|
6
|
+
// wrote and this parser read differently is a meeting that lists with the
|
|
7
|
+
// wrong date and opens with the wrong duration.
|
|
8
|
+
//
|
|
9
|
+
// meeting-store.ts re-exports what it used to export, so every existing caller
|
|
10
|
+
// and every existing test keeps its import path.
|
|
11
|
+
|
|
12
|
+
import { basename } from 'node:path'
|
|
13
|
+
import { FALLBACK_DOMAIN, domainAbbreviation } from './domains.js'
|
|
14
|
+
import type { MeetingActionItem, MeetingDetail, MeetingMeta } from './meeting-store.js'
|
|
15
|
+
|
|
16
|
+
export const MEETING_SOURCE_MAX_BYTES = 100_000
|
|
17
|
+
export const DETAIL_CHUNK_ESTIMATE_CHARS = 1_700
|
|
18
|
+
|
|
19
|
+
export function parseField(content: string, field: string): string {
|
|
20
|
+
const escaped = field.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
21
|
+
const table = content.match(new RegExp(`\\*\\*${escaped}\\*\\*\\s*\\|\\s*(.+)`, 'i'))
|
|
22
|
+
if (table) return table[1].replace(/\s*\|?\s*$/, '').trim()
|
|
23
|
+
const plain = content.match(new RegExp(`\\*\\*${escaped}:\\*\\*\\s*(.+)`, 'i'))
|
|
24
|
+
return plain ? plain[1].trim() : ''
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function extractSection(content: string, headings: string[], toEnd = false): string {
|
|
28
|
+
for (const heading of headings) {
|
|
29
|
+
const escaped = heading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
30
|
+
const pattern = toEnd
|
|
31
|
+
? new RegExp(`##\\s+${escaped}\\s*\\n([\\s\\S]*)$`, 'i')
|
|
32
|
+
: new RegExp(`##\\s+${escaped}\\s*\\n([\\s\\S]*?)(?=\\n##\\s|$)`, 'i')
|
|
33
|
+
const match = content.match(pattern)
|
|
34
|
+
if (match) return match[1].trim()
|
|
35
|
+
}
|
|
36
|
+
return ''
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function parseListSection(content: string, headings: string[], limit: number): string[] {
|
|
40
|
+
const section = extractSection(content, headings)
|
|
41
|
+
if (!section) return []
|
|
42
|
+
return section
|
|
43
|
+
.split('\n')
|
|
44
|
+
.filter(line => /^\s*[-*]\s+/.test(line))
|
|
45
|
+
.map(line => line.replace(/^\s*[-*]\s+/, '').trim())
|
|
46
|
+
.filter(Boolean)
|
|
47
|
+
.slice(0, limit)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function parseActions(content: string): MeetingActionItem[] {
|
|
51
|
+
const section = extractSection(content, ['Action Items', 'Tasks', 'Next Steps'])
|
|
52
|
+
if (!section) return []
|
|
53
|
+
return section
|
|
54
|
+
.split('\n')
|
|
55
|
+
.filter(line => /^\s*(?:[-*]|\[[ xX]\])\s+/.test(line))
|
|
56
|
+
.map(line => {
|
|
57
|
+
const cleaned = line
|
|
58
|
+
.replace(/^\s*[-*]\s+/, '')
|
|
59
|
+
.replace(/^\[[ xX]\]\s*/, '')
|
|
60
|
+
.replace(/`\[REVIEW\]`\s*/i, '')
|
|
61
|
+
.trim()
|
|
62
|
+
const ownerMatch = cleaned.match(/\(\*\*(.+?)\*\*\)\s*$/)
|
|
63
|
+
return {
|
|
64
|
+
task: ownerMatch ? cleaned.replace(ownerMatch[0], '').trim() : cleaned,
|
|
65
|
+
owner: ownerMatch ? ownerMatch[1] : '',
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
.filter(item => item.task.length > 0)
|
|
69
|
+
.slice(0, 15)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function parseAttendees(content: string): string[] {
|
|
73
|
+
return parseListSection(content, ['Attendees'], 20).map(line => {
|
|
74
|
+
const match = line.match(/^\*\*(.+?)\*\*/) || line.match(/^([^(]+)/)
|
|
75
|
+
return match ? match[1].trim() : line
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function parseDurationMinutes(duration: string): number | undefined {
|
|
80
|
+
if (!duration) return undefined
|
|
81
|
+
const value = duration.toLowerCase()
|
|
82
|
+
const colon = value.match(/\b(\d+):(\d{2})\b/)
|
|
83
|
+
if (colon) return Number(colon[1]) * 60 + Number(colon[2])
|
|
84
|
+
let total = 0
|
|
85
|
+
const hours = value.match(/(\d+(?:\.\d+)?)\s*(?:h|hr|hrs|hour|hours)\b/)
|
|
86
|
+
if (hours) total += Math.round(Number(hours[1]) * 60)
|
|
87
|
+
const minutes = value.match(/(\d+)\s*(?:m|min|mins|minute|minutes)\b/)
|
|
88
|
+
if (minutes) total += Number(minutes[1])
|
|
89
|
+
return total > 0 ? total : undefined
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Return enough canonical source for grounded meeting follow-ups without
|
|
93
|
+
* allowing an unexpectedly large archive record to inflate every response.
|
|
94
|
+
* The boundary backs up over UTF-8 continuation bytes so the prefix never
|
|
95
|
+
* ends with a replacement character. */
|
|
96
|
+
export function boundedMeetingSource(content: string): { sourceContent: string; sourceTruncated: boolean } {
|
|
97
|
+
const bytes = Buffer.from(content, 'utf8')
|
|
98
|
+
if (bytes.length <= MEETING_SOURCE_MAX_BYTES) return { sourceContent: content, sourceTruncated: false }
|
|
99
|
+
let end = MEETING_SOURCE_MAX_BYTES
|
|
100
|
+
while (end > 0 && (bytes[end] & 0xc0) === 0x80) end -= 1
|
|
101
|
+
return { sourceContent: bytes.subarray(0, end).toString('utf8'), sourceTruncated: true }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function parseMeeting(content: string, filename: string, month: string): MeetingDetail {
|
|
105
|
+
const heading = content.match(/^#\s+(.+)$/m)?.[1]?.trim()
|
|
106
|
+
const date = parseField(content, 'Date') || filename.match(/^(\d{4}-\d{2}-\d{2})/)?.[1] || 'unknown'
|
|
107
|
+
const domain = parseField(content, 'Domain') || FALLBACK_DOMAIN
|
|
108
|
+
const duration = parseField(content, 'Duration')
|
|
109
|
+
const transcript = extractSection(content, ['Transcript'], true)
|
|
110
|
+
const storedSummary = extractSection(content, ['Summary'])
|
|
111
|
+
// A placeholder is not a summary. Until 6.37 this returned the TRANSCRIPT as
|
|
112
|
+
// the summary, which put the transcript in the summary slot on every surface
|
|
113
|
+
// and read as "the summary is broken" — the bug this replaces. The transcript
|
|
114
|
+
// is returned in its own `transcript` field and each surface renders it in
|
|
115
|
+
// its own section. An empty summary is the honest state, and every consumer
|
|
116
|
+
// already handles it: display-pages.ts falls back to 'No summary available.',
|
|
117
|
+
// and COS Control's Copy-summary button correctly disables on empty.
|
|
118
|
+
const summary = !storedSummary || /standalone recording|summary unavailable/i.test(storedSummary)
|
|
119
|
+
? ''
|
|
120
|
+
: storedSummary
|
|
121
|
+
const topics = parseListSection(content, ['Topics Discussed'], 10)
|
|
122
|
+
const decisions = parseListSection(content, ['Decisions', 'Decisions Made'], 10)
|
|
123
|
+
const actionItems = parseActions(content)
|
|
124
|
+
const attendees = parseAttendees(content)
|
|
125
|
+
const source = boundedMeetingSource(content)
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
filename,
|
|
129
|
+
title: heading || basename(filename, '.md').split('_').slice(1).join(' ') || 'Untitled Meeting',
|
|
130
|
+
date,
|
|
131
|
+
domain,
|
|
132
|
+
// Shared derivation. This was slice(0,2), which rendered sprocket_rocket as
|
|
133
|
+
// "SP" while cos-operations-meetings rendered it "SR" — two schemes in one
|
|
134
|
+
// codebase, disagreeing with each other.
|
|
135
|
+
domainAbbr: domainAbbreviation(domain),
|
|
136
|
+
source: parseField(content, 'Source'),
|
|
137
|
+
duration,
|
|
138
|
+
...(parseDurationMinutes(duration) !== undefined ? { durationMinutes: parseDurationMinutes(duration) } : {}),
|
|
139
|
+
month,
|
|
140
|
+
summary,
|
|
141
|
+
topics,
|
|
142
|
+
decisions,
|
|
143
|
+
actionItems,
|
|
144
|
+
attendees,
|
|
145
|
+
transcript,
|
|
146
|
+
...source,
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function toMeta(detail: MeetingDetail, sessionId?: string): MeetingMeta {
|
|
151
|
+
// Must mirror what the reader actually renders (display-pages.ts
|
|
152
|
+
// formatMeetingDetailBody), or the list row advertises a page count the
|
|
153
|
+
// reader does not honour. The transcript is part of that body since 6.37;
|
|
154
|
+
// omitting it here reported every standalone meeting as ~1p.
|
|
155
|
+
const detailCharEstimate = [
|
|
156
|
+
detail.title,
|
|
157
|
+
detail.date,
|
|
158
|
+
detail.duration || detail.source,
|
|
159
|
+
detail.summary,
|
|
160
|
+
detail.topics.join('\n'),
|
|
161
|
+
detail.decisions.join('\n'),
|
|
162
|
+
detail.actionItems.map(item => `${item.owner ? `[${item.owner}] ` : ''}${item.task}`).join('\n'),
|
|
163
|
+
detail.attendees.join(', '),
|
|
164
|
+
detail.transcript,
|
|
165
|
+
].join('\n\n').trim().length
|
|
166
|
+
return {
|
|
167
|
+
filename: detail.filename,
|
|
168
|
+
...(sessionId ? { sessionId } : {}),
|
|
169
|
+
title: detail.title,
|
|
170
|
+
date: detail.date,
|
|
171
|
+
domain: detail.domain,
|
|
172
|
+
domainAbbr: detail.domainAbbr,
|
|
173
|
+
source: detail.source,
|
|
174
|
+
duration: detail.duration,
|
|
175
|
+
...(detail.durationMinutes !== undefined ? { durationMinutes: detail.durationMinutes } : {}),
|
|
176
|
+
month: detail.month,
|
|
177
|
+
detailCharEstimate,
|
|
178
|
+
estimatedDetailPages: Math.max(1, Math.ceil(detailCharEstimate / DETAIL_CHUNK_ESTIMATE_CHARS)),
|
|
179
|
+
topicCount: detail.topics.length,
|
|
180
|
+
decisionCount: detail.decisions.length,
|
|
181
|
+
actionCount: detail.actionItems.length,
|
|
182
|
+
attendeeCount: detail.attendees.length,
|
|
183
|
+
}
|
|
184
|
+
}
|