@gotcos/glasses-server 6.46.0 → 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 (39) hide show
  1. package/CHANGELOG.md +21 -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/g2-ops-handoff.ts +15 -1
  8. package/server/lib/imported-library-rows.ts +616 -0
  9. package/server/lib/imported-meeting-library.ts +608 -0
  10. package/server/lib/maintenance-lifecycle.ts +14 -0
  11. package/server/lib/meeting-actions-store.ts +478 -0
  12. package/server/lib/meeting-actions.ts +2583 -0
  13. package/server/lib/meeting-corrections.ts +32 -1
  14. package/server/lib/meeting-decisions.ts +223 -0
  15. package/server/lib/meeting-engine/align.ts +167 -0
  16. package/server/lib/meeting-engine/attribute.ts +265 -0
  17. package/server/lib/meeting-engine/evidence.ts +428 -0
  18. package/server/lib/meeting-engine/pairing.ts +327 -0
  19. package/server/lib/meeting-engine/render.ts +694 -0
  20. package/server/lib/meeting-engine/split.ts +242 -0
  21. package/server/lib/meeting-engine/worker.ts +238 -0
  22. package/server/lib/meeting-engine-mode.ts +197 -0
  23. package/server/lib/meeting-file-guards.ts +141 -0
  24. package/server/lib/meeting-import.ts +763 -0
  25. package/server/lib/meeting-library-search.ts +146 -11
  26. package/server/lib/meeting-parse.ts +184 -0
  27. package/server/lib/meeting-store.ts +108 -275
  28. package/server/lib/meeting-suggestion-sides.ts +242 -0
  29. package/server/lib/morning-brief-runtime.ts +20 -8
  30. package/server/lib/pipeline-runner.ts +227 -0
  31. package/server/lib/voice-evidence-guard.ts +87 -0
  32. package/server/routes/fireflies-key.ts +102 -0
  33. package/server/routes/meeting-actions.ts +82 -0
  34. package/server/routes/meeting-engine.ts +52 -0
  35. package/server/routes/meeting-import.ts +67 -0
  36. package/server/routes/meeting-suggestions.ts +66 -0
  37. package/server/routes/meeting.ts +117 -10
  38. package/server/routes/meetings.ts +205 -37
  39. package/server/routes/voice.ts +18 -0
@@ -7,20 +7,47 @@ import {
7
7
  getDirectLibraryMeetingDetail,
8
8
  getCosOperationsMeetingDetail,
9
9
  listDirectLibraryMeetings,
10
- listDirectLibraryMeetingDays,
11
10
  listDirectLibraryMeetingMonths,
12
11
  listCosOperationsMeetings,
13
- listCosOperationsMeetingDays,
14
12
  listCosOperationsMeetingMonths,
15
13
  resolveMeetingLibrary,
16
14
  } from '../lib/cos-operations-meetings.js'
17
15
  import type { MeetingMeta } from '../lib/meeting-store.js'
18
16
  import { meetingListLimit } from '../lib/meeting-store.js'
19
17
  import { searchMeetingLibrary } from '../lib/meeting-library-search.js'
18
+ import { g2RecordingsReachOperations } from '../lib/g2-ops-handoff.js'
19
+ import {
20
+ type ImportedMeetingLibrary,
21
+ IMPORTED_DOMAIN,
22
+ getImportedMeetingLibrary,
23
+ } from '../lib/imported-meeting-library.js'
24
+ import {
25
+ type DerivedRecordSummary,
26
+ derivedSourcesFor,
27
+ dropSupersededRows,
28
+ importedLibraryMonths,
29
+ listImportedLibraryRows,
30
+ readDerivedRecords,
31
+ supersededDayCounts,
32
+ supersededFromRows,
33
+ supersededInputsOf,
34
+ withDerivedIdentity,
35
+ } from '../lib/imported-library-rows.js'
20
36
 
21
37
  const MONTH_QUERY = /^\d{4}-(0[1-9]|1[0-2])$/
22
38
  const DAY_QUERY = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/
23
39
 
40
+ /**
41
+ * The only filenames the imported branch of detail will answer for.
42
+ *
43
+ * Deliberately stricter than "is it in the imports folder": a G2 recording whose
44
+ * TITLE contains the word "merged" produces a store filename that must keep
45
+ * resolving through the store, and a hand-dropped or iCloud-conflicted file in
46
+ * the imports root must not be served as a record. The branch falls through on a
47
+ * miss rather than 404ing, so a near-miss is answered by the next source.
48
+ */
49
+ export const IMPORTED_DETAIL_FILENAME = /^\d{4}-\d{2}-\d{2}_(?:fireflies|merged|piece)_[0-9a-f]{16}\.md$/
50
+
24
51
  function withStandaloneIdentity(meeting: MeetingMeta): MeetingMeta {
25
52
  return {
26
53
  ...meeting,
@@ -73,25 +100,78 @@ function parseListFilters(query: { month?: unknown; day?: unknown }): {
73
100
  return { month, day }
74
101
  }
75
102
 
76
- function mergeDayCounts(
77
- groups: Array<Array<{ date: string; count: number }>>,
78
- ): Array<{ date: string; count: number }> {
79
- const counts = new Map<string, number>()
80
- for (const group of groups) {
81
- for (const { date, count } of group) {
82
- counts.set(date, (counts.get(date) ?? 0) + count)
83
- }
103
+ function uniqueSortedMonths(groups: string[][]): string[] {
104
+ return [...new Set(groups.flat())].sort().reverse()
105
+ }
106
+
107
+ /**
108
+ * Detail for one record of the imported library, or null to fall through.
109
+ *
110
+ * NULL, NEVER 404. The three detail sources share one URL shape, and a record
111
+ * that is not here may still be an operations meeting or a store recording, so a
112
+ * miss has to hand the request on. The two gates — the routing domain and the
113
+ * strict filename — are what keep an ordinary meeting out of this branch.
114
+ *
115
+ * A merged record answers with `sources[]`, each input naming the record that
116
+ * still holds it. Nothing is read from the client: the record's own sidecar is
117
+ * the only thing consulted.
118
+ */
119
+ function importedMeetingDetail(
120
+ domain: string,
121
+ month: string,
122
+ filename: string,
123
+ store: MeetingStore,
124
+ library: ImportedMeetingLibrary,
125
+ ): MeetingMeta | null {
126
+ if (domain !== IMPORTED_DOMAIN) return null
127
+ if (!IMPORTED_DETAIL_FILENAME.test(filename)) return null
128
+ const detail = library.detail(month, filename)
129
+ if (!detail) return null
130
+ if (detail.librarySource !== 'blended') return detail as MeetingMeta
131
+ const record = readDerivedRecords(library).find(entry => entry.recordId === detail.recordId)
132
+ if (!record) return detail as MeetingMeta
133
+ return {
134
+ ...withDerivedIdentity(detail as MeetingMeta, record),
135
+ sources: derivedSourcesFor(record, store),
84
136
  }
85
- return [...counts.entries()]
86
- .sort((a, b) => a[0].localeCompare(b[0]))
87
- .map(([date, count]) => ({ date, count }))
88
137
  }
89
138
 
90
- function uniqueSortedMonths(groups: string[][]): string[] {
91
- return [...new Set(groups.flat())].sort().reverse()
139
+ /**
140
+ * The imported library's contribution to one list request.
141
+ *
142
+ * Read ONCE per request: the derived sidecars decide both what the derived rows
143
+ * say about themselves and which G2, import and long-original rows they have
144
+ * taken over, and reading them twice would let a write between the two reads
145
+ * show a meeting and its merged replacement side by side.
146
+ */
147
+ function importedContribution(
148
+ options: { limit: number; domain: string; month?: string; day?: string },
149
+ library: ImportedMeetingLibrary,
150
+ ): {
151
+ imports: MeetingMeta[]
152
+ derived: MeetingMeta[]
153
+ records: DerivedRecordSummary[]
154
+ drop: (rows: MeetingMeta[]) => MeetingMeta[]
155
+ present: boolean
156
+ } {
157
+ const records = readDerivedRecords(library)
158
+ const { imports, derived } = listImportedLibraryRows(options, library, records)
159
+ const superseded = supersededInputsOf(records)
160
+ return {
161
+ imports,
162
+ derived,
163
+ records,
164
+ drop: rows => dropSupersededRows(rows, superseded),
165
+ present: imports.length > 0 || derived.length > 0,
166
+ }
92
167
  }
93
168
 
94
- export function createMeetingsRouter(store: MeetingStore = getMeetingStore()): Router {
169
+ export function createMeetingsRouter(
170
+ store: MeetingStore = getMeetingStore(),
171
+ // Injectable so a test can drive a whole imports library without reaching the
172
+ // process-wide data home. Production passes nothing and gets the singleton.
173
+ importsLibrary: ImportedMeetingLibrary = getImportedMeetingLibrary(),
174
+ ): Router {
95
175
  const router = Router()
96
176
 
97
177
  // GET /api/meetings?limit=20&domain=all
@@ -126,6 +206,7 @@ export function createMeetingsRouter(store: MeetingStore = getMeetingStore()): R
126
206
  const listOptions = { limit: sourceLimit, domain, month: filters.month, day: filters.day }
127
207
 
128
208
  if (library.layout === 'direct') {
209
+ const imported = importedContribution(listOptions, importsLibrary)
129
210
  const operations = cosOperationsMeetingsConfigured()
130
211
  ? listCosOperationsMeetings(listOptions)
131
212
  : []
@@ -133,24 +214,30 @@ export function createMeetingsRouter(store: MeetingStore = getMeetingStore()): R
133
214
  ? listDirectLibraryMeetings({ limit: sourceLimit, month: filters.month, day: filters.day })
134
215
  : []
135
216
  const standalone = store.list(listOptions).map(withStandaloneIdentity)
136
- const meetings = mergeMeetingSources([operations, direct, standalone], limit, sourceLimit)
217
+ // Derived rows lead. They carry the sessionId of the earliest capture they
218
+ // hold, so leading makes a merged record win that session outright even if
219
+ // its sidecar became unreadable and the supersession filter went quiet.
220
+ const meetings = mergeMeetingSources([
221
+ imported.derived,
222
+ imported.drop(operations),
223
+ imported.drop(direct),
224
+ imported.drop(standalone),
225
+ imported.drop(imported.imports),
226
+ ], limit, sourceLimit)
137
227
  const months = uniqueSortedMonths([
138
228
  ...(cosOperationsMeetingsConfigured() ? [listCosOperationsMeetingMonths(domain)] : []),
139
229
  ...(domain === 'all' || domain === 'library' ? [listDirectLibraryMeetingMonths()] : []),
140
230
  store.listMonths(),
231
+ importedLibraryMonths(importsLibrary),
141
232
  ])
142
233
  const days = filters.month
143
- ? mergeDayCounts([
144
- ...(cosOperationsMeetingsConfigured() ? [listCosOperationsMeetingDays(filters.month, domain)] : []),
145
- ...(domain === 'all' || domain === 'library' ? [listDirectLibraryMeetingDays(filters.month)] : []),
146
- store.listDayCounts(filters.month),
147
- ])
234
+ ? supersededDayCounts(filters.month, 'direct', { domain, store, library: importsLibrary })
148
235
  : []
149
236
  res.json({
150
237
  meetings,
151
238
  months,
152
239
  days,
153
- source: operations.length > 0 ? 'mixed_library' : 'direct_library',
240
+ source: operations.length > 0 || imported.present ? 'mixed_library' : 'direct_library',
154
241
  layout: 'direct',
155
242
  root: library.root,
156
243
  rootFingerprint: library.rootFingerprint,
@@ -161,17 +248,74 @@ export function createMeetingsRouter(store: MeetingStore = getMeetingStore()): R
161
248
  }
162
249
 
163
250
  if (library.layout === 'multi_domain') {
164
- const meetings = listCosOperationsMeetings({
165
- limit,
166
- domain,
167
- month: filters.month,
168
- day: filters.day,
169
- })
251
+ if (g2RecordingsReachOperations()) {
252
+ // The pipeline owns this tree, so the tree is the whole library: no
253
+ // imports, no derived records from `data/imports`. A merge here was
254
+ // spliced into the Fireflies scribe, which declares the captures it
255
+ // holds, so the capture's own row is dropped from its own tree.
256
+ const rows = listCosOperationsMeetings({
257
+ limit,
258
+ domain,
259
+ month: filters.month,
260
+ day: filters.day,
261
+ })
262
+ const declaredSessions = supersededFromRows(rows).g2Sessions
263
+ const meetings = dropSupersededRows(rows, { g2Sessions: declaredSessions, importRecordIds: new Set<string>(), isEmpty: declaredSessions.size === 0 })
264
+ // ONLY WHEN THE LIST SAW THE WHOLE MONTH. The row list is capped, and a capped
265
+ // list knows about fewer merged scribes than the month holds, so handing its
266
+ // sessions to an UNCAPPED day count would subtract too little and put the dot
267
+ // back above the row. A capped page pays for the scan instead.
268
+ const listSawWholeMonth = rows.length < limit
269
+ res.json({
270
+ meetings,
271
+ months: listCosOperationsMeetingMonths(domain),
272
+ // The rows above already read every scribe in this month and told us which
273
+ // sessions the merged ones hold. Handing that over is the difference between
274
+ // one read per file and two on every month request.
275
+ days: filters.month
276
+ ? supersededDayCounts(filters.month, 'multi_domain', {
277
+ domain,
278
+ store,
279
+ library: importsLibrary,
280
+ pipeline: true,
281
+ ...(listSawWholeMonth ? { declaredSessions } : {}),
282
+ })
283
+ : [],
284
+ source: 'cos_operations',
285
+ layout: 'multi_domain',
286
+ root: library.root,
287
+ rootFingerprint: library.rootFingerprint,
288
+ meetingCount: meetings.length,
289
+ warnings: library.warnings,
290
+ })
291
+ return
292
+ }
293
+
294
+ // No COS pipeline can file this Mac's G2 recordings into the operations folder (a Starter Kit library
295
+ // without COS_SCRIPTS_DIR, 2026-09-14), so they live only in this server's store. List them beside the
296
+ // operations rows. Operations rows go first, so a copy that did reach operations wins by sessionId, and
297
+ // the day counts skip the store row it covers. Day counts always describe the whole month.
298
+ const imported = importedContribution(listOptions, importsLibrary)
299
+ const operations = listCosOperationsMeetings(listOptions)
300
+ const standalone = store.list(listOptions).map(withStandaloneIdentity)
301
+ const meetings = mergeMeetingSources([
302
+ imported.derived,
303
+ imported.drop(operations),
304
+ imported.drop(standalone),
305
+ imported.drop(imported.imports),
306
+ ], limit, sourceLimit)
307
+ const days = filters.month
308
+ ? supersededDayCounts(filters.month, 'multi_domain', { domain, store, library: importsLibrary, pipeline: false })
309
+ : []
170
310
  res.json({
171
311
  meetings,
172
- months: listCosOperationsMeetingMonths(domain),
173
- days: filters.month ? listCosOperationsMeetingDays(filters.month, domain) : [],
174
- source: 'cos_operations',
312
+ months: uniqueSortedMonths([
313
+ listCosOperationsMeetingMonths(domain),
314
+ store.listMonths(),
315
+ importedLibraryMonths(importsLibrary),
316
+ ]),
317
+ days,
318
+ source: standalone.length > 0 || imported.present ? 'mixed_library' : 'cos_operations',
175
319
  layout: 'multi_domain',
176
320
  root: library.root,
177
321
  rootFingerprint: library.rootFingerprint,
@@ -181,17 +325,23 @@ export function createMeetingsRouter(store: MeetingStore = getMeetingStore()): R
181
325
  return
182
326
  }
183
327
 
184
- const meetings = store.list({
185
- limit,
328
+ const imported = importedContribution(listOptions, importsLibrary)
329
+ const standalone = store.list({
330
+ limit: sourceLimit,
186
331
  domain,
187
332
  month: filters.month,
188
333
  day: filters.day,
189
334
  }).map(withStandaloneIdentity)
335
+ const meetings = mergeMeetingSources([
336
+ imported.derived,
337
+ imported.drop(standalone),
338
+ imported.drop(imported.imports),
339
+ ], limit, sourceLimit)
190
340
  res.json({
191
341
  meetings,
192
- months: store.listMonths(),
193
- days: filters.month ? store.listDayCounts(filters.month) : [],
194
- source: 'standalone_recordings',
342
+ months: uniqueSortedMonths([store.listMonths(), importedLibraryMonths(importsLibrary)]),
343
+ days: filters.month ? supersededDayCounts(filters.month, 'standalone', { domain, store, library: importsLibrary }) : [],
344
+ source: imported.present ? 'mixed_library' : 'standalone_recordings',
195
345
  layout: 'standalone',
196
346
  meetingCount: meetings.length,
197
347
  })
@@ -243,6 +393,12 @@ export function createMeetingsRouter(store: MeetingStore = getMeetingStore()): R
243
393
  }
244
394
  }
245
395
 
396
+ const imported = importedMeetingDetail(domain, month, filename, store, importsLibrary)
397
+ if (imported) {
398
+ res.json(imported)
399
+ return
400
+ }
401
+
246
402
  if (cosOperationsMeetingsConfigured()) {
247
403
  const detail = getCosOperationsMeetingDetail(domain, month, filename)
248
404
  if (detail) {
@@ -273,6 +429,18 @@ export function createMeetingsRouter(store: MeetingStore = getMeetingStore()): R
273
429
  }
274
430
  }
275
431
 
432
+ const imported = importedMeetingDetail(
433
+ req.params.domain,
434
+ req.params.month,
435
+ req.params.filename,
436
+ store,
437
+ importsLibrary,
438
+ )
439
+ if (imported) {
440
+ res.json(imported)
441
+ return
442
+ }
443
+
276
444
  if (cosOperationsMeetingsConfigured()) {
277
445
  const detail = getCosOperationsMeetingDetail(
278
446
  req.params.domain,
@@ -20,6 +20,7 @@ import { fanOutSpeakerRename, type SpeakerRenameFanOut } from '../lib/speaker-re
20
20
  import { HeldGroupError, discardHeldSamples, enrollHeldGroup, heldVoiceGroups, parseHeldMembers, previewDiscard } from '../lib/held-voice-groups.js'
21
21
  import { previewHeldNaming, applyHeldNaming, undoHeldNaming, resumeHeldNaming, namingBatchList, resolveStoredName, NAMING_CAPABILITIES } from '../lib/held-naming-batches.js'
22
22
  import { resolveCosOperationsDir } from '../lib/cos-operations-meetings.js'
23
+ import { assertVoiceEvidenceSource } from '../lib/voice-evidence-guard.js'
23
24
 
24
25
  // These MUST match the writer in transcribe-stream.ts, which saves under
25
26
  // dataPath(). They previously resolved relative to __dirname — i.e. inside the
@@ -364,6 +365,10 @@ voiceRouter.post('/voice/enroll-ext', async (req, res) => {
364
365
  if (!name || typeof name !== 'string' || name.length < 2) {
365
366
  return res.status(400).json({ error: 'name is required (min 2 chars)' })
366
367
  }
368
+ // An imported or derived record never has audio to enrol from, and its
369
+ // speaker names came from a vendor rather than a voiceprint.
370
+ const evidenceRefusal = assertVoiceEvidenceSource([sessionId])
371
+ if (evidenceRefusal) return res.status(evidenceRefusal.status).json(evidenceRefusal.body)
367
372
 
368
373
  if (!existsSync(EXT_AUDIO_DIR)) {
369
374
  return res.json({ enrolled: 0, message: 'No ext-audio available' })
@@ -583,6 +588,19 @@ voiceRouter.post('/voice/held-groups/enroll', async (req, res) => {
583
588
  const nameCheck = checkSpeakerName(req.body?.name, { ownerLabel: getOwnerSpeakerLabel() })
584
589
  if (!nameCheck.ok) return res.status(400).json({ success:false,error:nameCheck.message,reason:nameCheck.reason })
585
590
  const name = resolveStoredName(String(req.body.name).trim())
591
+ // Held samples are audio this Mac captured. A member pointing at an imported
592
+ // or derived record is not held audio, so it never reaches the preview.
593
+ //
594
+ // ON THE RAW BODY, BEFORE parseHeldMembers. That parser calls
595
+ // `normalizeSessionId`, which rewrites every colon to an underscore for the
596
+ // on-disk directory name, so `blended:<h16>` arrives here as
597
+ // `blended_<h16>` and an id-kind guard reading the parsed members never
598
+ // fires. Caught by the execution test, which is the only place it could be.
599
+ const rawMembers = Array.isArray(req.body?.members) ? req.body.members : []
600
+ const evidenceRefusal = assertVoiceEvidenceSource(
601
+ rawMembers.map((member: unknown) => (member as { sessionId?: unknown })?.sessionId),
602
+ )
603
+ if (evidenceRefusal) return res.status(evidenceRefusal.status).json(evidenceRefusal.body)
586
604
  const members = parseHeldMembers(req.body?.members)
587
605
  if (!req.body?.previewHash || req.body?.dryRun === true) {
588
606
  if (name.owner && req.body?.confirm === true && req.body?.dryRun !== true) return res.status(400).json({success:false,error:'Owner confirmation requires a preview and ownerAck',reason:'owner_confirmation_required'})