@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.
Files changed (38) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/package.json +6 -2
  3. package/server/index.ts +76 -0
  4. package/server/lib/cos-operations-meetings.ts +99 -8
  5. package/server/lib/fireflies-client.ts +862 -0
  6. package/server/lib/fireflies-key.ts +182 -0
  7. package/server/lib/imported-library-rows.ts +616 -0
  8. package/server/lib/imported-meeting-library.ts +608 -0
  9. package/server/lib/maintenance-lifecycle.ts +14 -0
  10. package/server/lib/meeting-actions-store.ts +478 -0
  11. package/server/lib/meeting-actions.ts +2583 -0
  12. package/server/lib/meeting-corrections.ts +32 -1
  13. package/server/lib/meeting-decisions.ts +223 -0
  14. package/server/lib/meeting-engine/align.ts +167 -0
  15. package/server/lib/meeting-engine/attribute.ts +265 -0
  16. package/server/lib/meeting-engine/evidence.ts +428 -0
  17. package/server/lib/meeting-engine/pairing.ts +327 -0
  18. package/server/lib/meeting-engine/render.ts +694 -0
  19. package/server/lib/meeting-engine/split.ts +242 -0
  20. package/server/lib/meeting-engine/worker.ts +238 -0
  21. package/server/lib/meeting-engine-mode.ts +197 -0
  22. package/server/lib/meeting-file-guards.ts +141 -0
  23. package/server/lib/meeting-import.ts +763 -0
  24. package/server/lib/meeting-library-search.ts +146 -11
  25. package/server/lib/meeting-parse.ts +184 -0
  26. package/server/lib/meeting-store.ts +108 -275
  27. package/server/lib/meeting-suggestion-sides.ts +242 -0
  28. package/server/lib/morning-brief-runtime.ts +20 -8
  29. package/server/lib/pipeline-runner.ts +227 -0
  30. package/server/lib/voice-evidence-guard.ts +87 -0
  31. package/server/routes/fireflies-key.ts +102 -0
  32. package/server/routes/meeting-actions.ts +82 -0
  33. package/server/routes/meeting-engine.ts +52 -0
  34. package/server/routes/meeting-import.ts +67 -0
  35. package/server/routes/meeting-suggestions.ts +66 -0
  36. package/server/routes/meeting.ts +117 -10
  37. package/server/routes/meetings.ts +177 -50
  38. 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
- : `ops:${row.domain}:${row.month}:${row.filename}`
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
- interface SemanticRawHit {
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
- function semanticHitToLibrary(raw: SemanticRawHit): MeetingSearchHit | null {
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 identity = hitIdentity({
325
- domain: domain || 'quilt',
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' : 'cos_operations',
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
+ }