@gotcos/glasses-server 6.46.1 → 6.48.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 (56) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +24 -0
  3. package/bin/cli.cjs +22 -0
  4. package/bin/hooks/cos-session-hook +43 -0
  5. package/managed-runtime-contract.json +7 -1
  6. package/package.json +8 -2
  7. package/server/index.ts +85 -0
  8. package/server/lib/claude-hooks-installer.ts +403 -0
  9. package/server/lib/claude-session-registry.ts +25 -0
  10. package/server/lib/cos-operations-meetings.ts +99 -8
  11. package/server/lib/fireflies-client.ts +862 -0
  12. package/server/lib/fireflies-key.ts +182 -0
  13. package/server/lib/imported-library-rows.ts +616 -0
  14. package/server/lib/imported-meeting-library.ts +608 -0
  15. package/server/lib/maintenance-lifecycle.ts +14 -0
  16. package/server/lib/meeting-actions-store.ts +478 -0
  17. package/server/lib/meeting-actions.ts +2583 -0
  18. package/server/lib/meeting-corrections.ts +32 -1
  19. package/server/lib/meeting-decisions.ts +223 -0
  20. package/server/lib/meeting-engine/align.ts +167 -0
  21. package/server/lib/meeting-engine/attribute.ts +265 -0
  22. package/server/lib/meeting-engine/evidence.ts +428 -0
  23. package/server/lib/meeting-engine/pairing.ts +327 -0
  24. package/server/lib/meeting-engine/render.ts +694 -0
  25. package/server/lib/meeting-engine/split.ts +242 -0
  26. package/server/lib/meeting-engine/worker.ts +238 -0
  27. package/server/lib/meeting-engine-mode.ts +197 -0
  28. package/server/lib/meeting-file-guards.ts +141 -0
  29. package/server/lib/meeting-import.ts +763 -0
  30. package/server/lib/meeting-library-search.ts +146 -11
  31. package/server/lib/meeting-parse.ts +184 -0
  32. package/server/lib/meeting-store.ts +108 -275
  33. package/server/lib/meeting-suggestion-sides.ts +242 -0
  34. package/server/lib/morning-brief-runtime.ts +20 -8
  35. package/server/lib/pipeline-runner.ts +227 -0
  36. package/server/lib/session-hook-events.ts +200 -0
  37. package/server/lib/session-hook-ledger.ts +129 -0
  38. package/server/lib/session-hook-spool.ts +264 -0
  39. package/server/lib/session-hooks-runtime.ts +229 -0
  40. package/server/lib/session-signal-store.ts +361 -0
  41. package/server/lib/session-state-derive.ts +211 -0
  42. package/server/lib/voice-evidence-guard.ts +87 -0
  43. package/server/routes/agent-sessions.ts +56 -6
  44. package/server/routes/claude-sessions.ts +32 -5
  45. package/server/routes/fireflies-key.ts +102 -0
  46. package/server/routes/health.ts +2 -0
  47. package/server/routes/meeting-actions.ts +82 -0
  48. package/server/routes/meeting-engine.ts +52 -0
  49. package/server/routes/meeting-import.ts +67 -0
  50. package/server/routes/meeting-suggestions.ts +66 -0
  51. package/server/routes/meeting.ts +117 -10
  52. package/server/routes/meetings.ts +177 -50
  53. package/server/routes/session-hooks.ts +70 -0
  54. package/server/routes/voice.ts +18 -0
  55. package/server/scripts/hooks-cli.ts +48 -0
  56. package/server/lib/__fixtures__/query-jobs-6.43.3/2099-01-01.jsonl +0 -2
@@ -0,0 +1,616 @@
1
+ /**
2
+ * Rows for the imported library, and the supersession rule (6.47.0, WS5).
3
+ *
4
+ * WHAT SUPERSESSION IS. A merge is ADDITIVE: the Fireflies import, the G2
5
+ * recordings and the merged record all exist on disk afterwards, because that is
6
+ * what makes Undo possible (D12). But a person scrolling their meetings must see
7
+ * ONE row for one meeting, so the LIST drops the inputs a derived record already
8
+ * holds. Nothing is deleted; the sources stay reachable from the merged record's
9
+ * detail through its `sources[]`.
10
+ *
11
+ * WHY A FILTER RATHER THAN A MERGE RULE. `mergeMeetingSources` dedupes by
12
+ * sessionId, and 6.46.1 leans on its group ORDER so an operations copy beats the
13
+ * store copy of the same capture. Teaching it about derived records would have
14
+ * meant a second precedence rule inside the first. Worse, a split piece has NO
15
+ * sessionId (it is a span of a recording, not a capture), so two pieces of one
16
+ * recording would have collapsed onto each other. Supersession therefore runs
17
+ * BEFORE the merge, as its own pass, and derived rows dedupe on `recordId`.
18
+ *
19
+ * COST. Every helper here is free until a derived record exists: the scan reads
20
+ * the imports root's `.derived.json` sidecars, and when there are none, each
21
+ * function returns its input untouched without opening a meeting file. That
22
+ * matters because `probeMeetings` sums day counts across every month on every
23
+ * morning brief.
24
+ */
25
+
26
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
27
+ import { join } from 'node:path'
28
+ import { dataPath } from './data-dir.js'
29
+ import {
30
+ cosOperationsMeetingsConfigured,
31
+ discoverMeetingDomains,
32
+ findCosOperationsMeetingBySessionId,
33
+ listCosOperationsMeetingDays,
34
+ listDirectLibraryMeetingDays,
35
+ mergedScribeSessions,
36
+ resolveCosOperationsDir,
37
+ resolveMeetingLibrary,
38
+ sidecarSessionId,
39
+ } from './cos-operations-meetings.js'
40
+ import { g2RecordingsReachOperations } from './g2-ops-handoff.js'
41
+ import {
42
+ type ImportedMeetingLibrary,
43
+ type ImportedRecordKind,
44
+ IMPORTED_DOMAIN,
45
+ IMPORTED_FILENAME_PATTERN,
46
+ IMPORTED_MONTH_PATTERN,
47
+ getImportedMeetingLibrary,
48
+ importHash,
49
+ importRecordId,
50
+ } from './imported-meeting-library.js'
51
+ import { getMeetingStore, type MeetingMeta, type MeetingStore } from './meeting-store.js'
52
+
53
+ /** The merged-scribe markers live with the scribe reader; re-exported for callers. */
54
+ export {
55
+ G2_SESSION_MARKER,
56
+ MERGE_ACTION_MARKER,
57
+ mergedScribeActionId,
58
+ mergedScribeSessions,
59
+ } from './cos-operations-meetings.js'
60
+
61
+ const DAY_FILE_PREFIX = /^(\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01]))_/
62
+ /** The month folder a day-prefixed filename belongs to. */
63
+ const MONTH_FROM_DAY_FILE = /^(\d{4}-(?:0[1-9]|1[0-2]))-(?:0[1-9]|[12]\d|3[01])_/
64
+
65
+ /** One derived record's identity and inputs, read from its `.derived.json`. */
66
+ export interface DerivedRecordSummary {
67
+ recordId: string
68
+ month: string
69
+ filename: string
70
+ kind: 'merge' | 'split'
71
+ actionId?: string
72
+ /** G2 captures the record holds, in the sidecar's own order (start order). */
73
+ g2SessionIds: string[]
74
+ /** Fireflies meetings the record holds: the primary first, then alternates. */
75
+ firefliesIds: string[]
76
+ pieceIndex?: number
77
+ /** The capture whose content defined a split piece, when it names one. */
78
+ sourceSessionId?: string
79
+ }
80
+
81
+ /** Everything a derived record has taken over. Empty until one exists. */
82
+ export interface SupersededInputs {
83
+ g2Sessions: Set<string>
84
+ /** `imported:fireflies:<h16>` record ids of the imports a derived record holds. */
85
+ importRecordIds: Set<string>
86
+ isEmpty: boolean
87
+ }
88
+
89
+ function asRecord(value: unknown): Record<string, unknown> | null {
90
+ return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : null
91
+ }
92
+
93
+ function stringField(source: Record<string, unknown>, key: string): string | undefined {
94
+ const value = source[key]
95
+ return typeof value === 'string' && value.length > 0 ? value : undefined
96
+ }
97
+
98
+ /**
99
+ * Read one `.derived.json` into the fields the surfaces need.
100
+ *
101
+ * Tolerant on purpose: a sidecar from a newer version, or one that lost a field,
102
+ * must degrade to "this record exists" rather than take the whole list down with
103
+ * it. The one field it cannot do without is `inputs`, because that is what
104
+ * supersession is computed from.
105
+ */
106
+ export function summarizeDerivedSidecar(
107
+ sidecar: unknown,
108
+ ref: { recordId: string; month: string; filename: string; kind: ImportedRecordKind },
109
+ ): DerivedRecordSummary | null {
110
+ const doc = asRecord(sidecar)
111
+ if (!doc) return null
112
+ const inputs = Array.isArray(doc.inputs) ? doc.inputs : []
113
+ const g2SessionIds: string[] = []
114
+ const firefliesIds: string[] = []
115
+ for (const entry of inputs) {
116
+ const input = asRecord(entry)
117
+ if (!input) continue
118
+ const id = stringField(input, 'id')
119
+ if (!id) continue
120
+ if (input.kind === 'g2') g2SessionIds.push(id)
121
+ else if (input.kind === 'fireflies') firefliesIds.push(id)
122
+ }
123
+ const pieces = Array.isArray(doc.pieces) ? doc.pieces : []
124
+ const firstPiece = asRecord(pieces[0])
125
+ const pieceIndex = typeof firstPiece?.index === 'number' && Number.isInteger(firstPiece.index)
126
+ ? firstPiece.index
127
+ : undefined
128
+ const pieceSessions = Array.isArray(firstPiece?.sessionIds) ? firstPiece.sessionIds : []
129
+ const sourceSessionId = typeof pieceSessions[0] === 'string' ? pieceSessions[0] as string : undefined
130
+ const actionId = stringField(doc, 'actionId')
131
+
132
+ return {
133
+ recordId: ref.recordId,
134
+ month: ref.month,
135
+ filename: ref.filename,
136
+ kind: ref.kind === 'piece' ? 'split' : 'merge',
137
+ ...(actionId ? { actionId } : {}),
138
+ g2SessionIds,
139
+ firefliesIds,
140
+ ...(ref.kind === 'piece' && pieceIndex !== undefined ? { pieceIndex } : {}),
141
+ ...(ref.kind === 'piece' && sourceSessionId ? { sourceSessionId } : {}),
142
+ }
143
+ }
144
+
145
+ /** Derived record filenames on disk, without opening anything. */
146
+ function derivedFilenames(
147
+ library: ImportedMeetingLibrary,
148
+ ): Array<{ month: string; filename: string; kind: ImportedRecordKind; hash: string }> {
149
+ const found: Array<{ month: string; filename: string; kind: ImportedRecordKind; hash: string }> = []
150
+ let months: string[]
151
+ try {
152
+ months = readdirSync(library.root).filter(name => IMPORTED_MONTH_PATTERN.test(name))
153
+ } catch {
154
+ return found
155
+ }
156
+ for (const month of months) {
157
+ let names: string[]
158
+ try {
159
+ names = readdirSync(join(library.root, month))
160
+ } catch {
161
+ continue
162
+ }
163
+ for (const filename of names) {
164
+ const match = filename.match(IMPORTED_FILENAME_PATTERN)
165
+ if (!match) continue
166
+ const kind = match[2] as ImportedRecordKind
167
+ if (kind === 'fireflies') continue
168
+ found.push({ month, filename, kind, hash: match[3] })
169
+ }
170
+ }
171
+ return found
172
+ }
173
+
174
+ /**
175
+ * Every derived record in the imports library.
176
+ *
177
+ * Reads only the derived sidecars — never a markdown file — through the
178
+ * library's own bounded reader, so a 32 MiB sidecar is capped and an iCloud
179
+ * conflict copy is refused by the filename pattern before it is opened.
180
+ */
181
+ export function readDerivedRecords(
182
+ library: ImportedMeetingLibrary = getImportedMeetingLibrary(),
183
+ ): DerivedRecordSummary[] {
184
+ const records: DerivedRecordSummary[] = []
185
+ for (const { month, filename, kind, hash } of derivedFilenames(library)) {
186
+ const summary = summarizeDerivedSidecar(library.readSidecar(month, filename), {
187
+ recordId: importRecordId(kind, hash),
188
+ month,
189
+ filename,
190
+ kind,
191
+ })
192
+ if (summary) records.push(summary)
193
+ }
194
+ return records
195
+ }
196
+
197
+ export function supersededInputsOf(records: readonly DerivedRecordSummary[]): SupersededInputs {
198
+ const g2Sessions = new Set<string>()
199
+ const importRecordIds = new Set<string>()
200
+ for (const record of records) {
201
+ for (const sessionId of record.g2SessionIds) g2Sessions.add(sessionId)
202
+ for (const firefliesId of record.firefliesIds) {
203
+ importRecordIds.add(importRecordId('fireflies', importHash(firefliesId)))
204
+ }
205
+ }
206
+ return { g2Sessions, importRecordIds, isEmpty: g2Sessions.size === 0 && importRecordIds.size === 0 }
207
+ }
208
+
209
+ /**
210
+ * Drop the rows a derived record already holds.
211
+ *
212
+ * A row is superseded when its session is one a derived record holds, or when it
213
+ * IS the import a derived record holds. A derived row is never superseded: pieces
214
+ * of one long recording each stand alone, and grouping (WS3) guarantees one
215
+ * merged record per capture.
216
+ */
217
+ export function dropSupersededRows(rows: readonly MeetingMeta[], superseded: SupersededInputs): MeetingMeta[] {
218
+ if (superseded.isEmpty) return [...rows]
219
+ return rows.filter(row => {
220
+ if (row.derivedKind) return true
221
+ if (row.recordId && superseded.importRecordIds.has(row.recordId)) return false
222
+ if (row.sessionId && superseded.g2Sessions.has(row.sessionId)) return false
223
+ return true
224
+ })
225
+ }
226
+
227
+ /** Derived rows dedupe on `recordId`, because a split piece has no session. */
228
+ export function dedupeDerivedRows(rows: readonly MeetingMeta[]): MeetingMeta[] {
229
+ const seen = new Set<string>()
230
+ const kept: MeetingMeta[] = []
231
+ for (const row of rows) {
232
+ const key = row.recordId ?? `${row.month}:${row.filename}`
233
+ if (seen.has(key)) continue
234
+ seen.add(key)
235
+ kept.push(row)
236
+ }
237
+ return kept
238
+ }
239
+
240
+ export function withDerivedIdentity(row: MeetingMeta, record: DerivedRecordSummary | undefined): MeetingMeta {
241
+ if (!record) return row
242
+ const identified: MeetingMeta = { ...row }
243
+ if (record.actionId) identified.actionId = record.actionId
244
+ if (record.kind === 'merge') {
245
+ // The earliest capture, so the row that replaced it can still open the
246
+ // speaker panel. `g2SessionIds` keeps the rest reachable.
247
+ if (record.g2SessionIds.length > 0) {
248
+ identified.sessionId = record.g2SessionIds[0]
249
+ identified.g2SessionIds = [...record.g2SessionIds]
250
+ }
251
+ } else {
252
+ // A piece is a SPAN, not a capture. A sessionId here would make two pieces of
253
+ // one recording collapse onto each other in mergeMeetingSources.
254
+ delete identified.sessionId
255
+ if (record.pieceIndex !== undefined) identified.pieceIndex = record.pieceIndex
256
+ if (record.sourceSessionId) identified.sourceSessionId = record.sourceSessionId
257
+ }
258
+ return identified
259
+ }
260
+
261
+ /**
262
+ * The imported library's rows, split into imports and derived records, with the
263
+ * derived rows carrying their action and their inputs.
264
+ *
265
+ * The limit is doubled before it reaches the library because supersession runs
266
+ * after this: a superseded import and the merged record that replaced it are two
267
+ * rows on the same date, and asking for exactly `limit` would let the pair push
268
+ * an older meeting out of a page that ends up holding only one of them.
269
+ */
270
+ export function listImportedLibraryRows(
271
+ options: { limit?: number; domain?: string; month?: string; day?: string } = {},
272
+ library: ImportedMeetingLibrary = getImportedMeetingLibrary(),
273
+ records: readonly DerivedRecordSummary[] = readDerivedRecords(library),
274
+ ): { imports: MeetingMeta[]; derived: MeetingMeta[] } {
275
+ const byRecordId = new Map(records.map(record => [record.recordId, record]))
276
+ const imports: MeetingMeta[] = []
277
+ const derived: MeetingMeta[] = []
278
+ const rows = library.list({
279
+ ...options,
280
+ ...(typeof options.limit === 'number' ? { limit: options.limit * 2 } : {}),
281
+ })
282
+ for (const row of rows) {
283
+ if (row.librarySource === 'imported') {
284
+ imports.push(row as MeetingMeta)
285
+ continue
286
+ }
287
+ derived.push(withDerivedIdentity(row as MeetingMeta, byRecordId.get(row.recordId)))
288
+ }
289
+ return { imports, derived: dedupeDerivedRows(derived) }
290
+ }
291
+
292
+ /** Month folders of the imported library, newest first. */
293
+ export function importedLibraryMonths(
294
+ library: ImportedMeetingLibrary = getImportedMeetingLibrary(),
295
+ ): string[] {
296
+ try {
297
+ return readdirSync(library.root).filter(name => IMPORTED_MONTH_PATTERN.test(name)).sort().reverse()
298
+ } catch {
299
+ return []
300
+ }
301
+ }
302
+
303
+ /**
304
+ * A derived record's inputs, each with the record id that opens it.
305
+ *
306
+ * This is how "merged automatically" stays honest: the sources are not gone, and
307
+ * the detail names exactly where each one still lives. A G2 input's id is
308
+ * resolved against the trees rather than assumed, because the same capture is
309
+ * `ops:...` on one Mac and `standalone:...` on another, and a link built from the
310
+ * wrong assumption opens nothing.
311
+ */
312
+ export function derivedSourcesFor(
313
+ record: DerivedRecordSummary,
314
+ store: MeetingStore = getMeetingStore(),
315
+ ): NonNullable<MeetingMeta['sources']> {
316
+ const sources: NonNullable<MeetingMeta['sources']> = []
317
+ for (const id of record.firefliesIds) {
318
+ sources.push({ kind: 'fireflies', id, recordId: importRecordId('fireflies', importHash(id)) })
319
+ }
320
+ for (const id of record.g2SessionIds) {
321
+ const recordId = g2RecordIdFor(id, store)
322
+ sources.push({ kind: 'g2', id, ...(recordId ? { recordId } : {}) })
323
+ }
324
+ return sources
325
+ }
326
+
327
+ function g2RecordIdFor(sessionId: string, store: MeetingStore): string | undefined {
328
+ if (cosOperationsMeetingsConfigured()) {
329
+ const operations = findCosOperationsMeetingBySessionId(sessionId)
330
+ if (operations) return `ops:${operations.domain}:${operations.month}:${operations.filename}`
331
+ }
332
+ try {
333
+ return store.findBySessionId(sessionId) ? `standalone:${sessionId}` : undefined
334
+ } catch {
335
+ return undefined
336
+ }
337
+ }
338
+
339
+ /** The derived record behind a `blended:` row, or null when there is none. */
340
+ export function findDerivedRecord(
341
+ recordId: string,
342
+ library: ImportedMeetingLibrary = getImportedMeetingLibrary(),
343
+ ): DerivedRecordSummary | null {
344
+ if (!/^blended:[0-9a-f]{16}$/.test(recordId)) return null
345
+ return readDerivedRecords(library).find(record => record.recordId === recordId) ?? null
346
+ }
347
+
348
+ /**
349
+ * Sessions the rows in hand already declare they hold.
350
+ *
351
+ * Used for the operations tree, where a merged scribe carries its inputs as
352
+ * `<!-- g2-session -->` markers rather than a `.derived.json`: the list has the
353
+ * markers in hand, so the filter costs nothing beyond what it already read.
354
+ */
355
+ export function supersededFromRows(rows: readonly MeetingMeta[]): SupersededInputs {
356
+ const g2Sessions = new Set<string>()
357
+ for (const row of rows) {
358
+ if (!row.derivedKind) continue
359
+ for (const sessionId of row.g2SessionIds ?? []) g2Sessions.add(sessionId)
360
+ if (row.sessionId) g2Sessions.add(row.sessionId)
361
+ }
362
+ return { g2Sessions, importRecordIds: new Set<string>(), isEmpty: g2Sessions.size === 0 }
363
+ }
364
+
365
+ /** Day counts from filenames, summed across groups. Shared with the list. */
366
+ export function mergeDayCounts(
367
+ groups: Array<Array<{ date: string; count: number }>>,
368
+ ): Array<{ date: string; count: number }> {
369
+ const counts = new Map<string, number>()
370
+ for (const group of groups) {
371
+ for (const { date, count } of group) {
372
+ counts.set(date, (counts.get(date) ?? 0) + count)
373
+ }
374
+ }
375
+ return [...counts.entries()]
376
+ .filter(([, count]) => count > 0)
377
+ .sort((a, b) => a[0].localeCompare(b[0]))
378
+ .map(([date, count]) => ({ date, count }))
379
+ }
380
+
381
+ /**
382
+ * Day counts for the imported library in one month.
383
+ *
384
+ * An import a derived record has taken over is dropped, so the calendar dot and
385
+ * the list agree. For `all` and `imported` nothing is opened: the record id is a
386
+ * pure function of the filename's hash. A filter on the meeting's OWN domain has
387
+ * to read each record's sidecar head, which is what `list()` already does.
388
+ */
389
+ export function importedLibraryDayCounts(
390
+ month: string,
391
+ superseded: SupersededInputs,
392
+ library: ImportedMeetingLibrary = getImportedMeetingLibrary(),
393
+ domain = 'all',
394
+ ): Array<{ date: string; count: number }> {
395
+ const counts = new Map<string, number>()
396
+ if (domain !== 'all' && domain !== IMPORTED_DOMAIN) {
397
+ for (const row of library.list({ month, domain })) {
398
+ if (row.librarySource === 'imported' && superseded.importRecordIds.has(row.recordId)) continue
399
+ counts.set(row.date, (counts.get(row.date) ?? 0) + 1)
400
+ }
401
+ } else {
402
+ let names: string[]
403
+ try {
404
+ names = readdirSync(join(library.root, month))
405
+ } catch {
406
+ return []
407
+ }
408
+ for (const filename of names) {
409
+ const match = filename.match(IMPORTED_FILENAME_PATTERN)
410
+ if (!match) continue
411
+ const kind = match[2] as ImportedRecordKind
412
+ if (kind === 'fireflies' && superseded.importRecordIds.has(importRecordId('fireflies', match[3]))) continue
413
+ counts.set(match[1], (counts.get(match[1]) ?? 0) + 1)
414
+ }
415
+ }
416
+ return [...counts.entries()].sort((a, b) => a[0].localeCompare(b[0])).map(([date, count]) => ({ date, count }))
417
+ }
418
+
419
+ // ── Day counts with supersession ─────────────────────────────────────────────
420
+ //
421
+ // The calendar dot and the list must agree, and the list drops superseded rows,
422
+ // so the count has to drop them too. The existing day-count helpers are FILENAME
423
+ // scans and are uncapped, which is why they can describe a whole month the
424
+ // 50-row list cannot; that property is kept. What is added is a subtraction, and
425
+ // the subtraction is the only part that opens a file — a 4 KB sidecar head per
426
+ // candidate — so it is gated on there being anything to subtract at all.
427
+
428
+ export type SupersededLayout = 'direct' | 'multi_domain' | 'standalone'
429
+
430
+ /**
431
+ * The session a meeting file declares, with the same fallback the LIST uses.
432
+ *
433
+ * The operations tree gitignores `.g2-chunks.json`, so a recording whose markdown arrived
434
+ * through git or iCloud sits there with no sidecar beside it. `listCosOperationsMeetings`
435
+ * handles that by looking under the server's own recordings root by the same stem; this did
436
+ * not, so a row the list DROPPED was still counted, and the calendar dot said two where the
437
+ * list showed one. A count that can disagree with the list it counts is the bug being fixed.
438
+ */
439
+ function droppedSessionId(directory: string, filename: string, recordingsRoot: string): string | undefined {
440
+ const direct = sidecarSessionId(directory, filename)
441
+ if (direct) return direct
442
+ const month = filename.match(MONTH_FROM_DAY_FILE)?.[1]
443
+ if (!month) return undefined
444
+ const fallbackDir = join(recordingsRoot, month)
445
+ if (!existsSync(fallbackDir)) return undefined
446
+ return sidecarSessionId(fallbackDir, filename)
447
+ }
448
+
449
+ /** One `-1` per file in `directories` whose chunk sidecar names a dropped session. */
450
+ function sessionDropCounts(
451
+ directories: readonly string[],
452
+ sessions: ReadonlySet<string>,
453
+ recordingsRoot: string = dataPath('recordings'),
454
+ ): Array<{ date: string; count: number }> {
455
+ if (sessions.size === 0) return []
456
+ const drops: Array<{ date: string; count: number }> = []
457
+ for (const directory of directories) {
458
+ let names: string[]
459
+ try {
460
+ names = readdirSync(directory).filter(name => name.endsWith('.md'))
461
+ } catch {
462
+ continue
463
+ }
464
+ for (const filename of names) {
465
+ const date = filename.match(DAY_FILE_PREFIX)?.[1]
466
+ if (!date) continue
467
+ const sessionId = droppedSessionId(directory, filename, recordingsRoot)
468
+ if (!sessionId || !sessions.has(sessionId)) continue
469
+ drops.push({ date, count: -1 })
470
+ }
471
+ }
472
+ return drops
473
+ }
474
+
475
+ /** Month folders of the operations tree that the domain filter admits. */
476
+ function operationsMonthDirs(month: string, domainFilter: string): string[] {
477
+ const operationsDir = resolveCosOperationsDir()
478
+ if (!operationsDir) return []
479
+ const discovered = discoverMeetingDomains(operationsDir)
480
+ const domains = domainFilter === 'all'
481
+ ? discovered
482
+ : discovered.includes(domainFilter) ? [domainFilter] : []
483
+ return domains.map(domain => join(operationsDir, domain, 'meetings', month))
484
+ }
485
+
486
+
487
+ /**
488
+ * Sessions declared by merged scribes in the operations tree for one month.
489
+ *
490
+ * READS EVERY SCRIBE IN THE MONTH, deliberately. A cheaper gate was tried first
491
+ * — scan only where the server has written a pipeline decision file — and it was
492
+ * wrong: the ROW filter reads these markers unconditionally, so a tree holding a
493
+ * merged scribe with no decision file beside it showed one row while the
494
+ * calendar dot said two. A count that can disagree with the list it counts is
495
+ * worse than a count that costs a read, and in this layout the scribe is the
496
+ * only place the merge is recorded.
497
+ *
498
+ * NOT WHEN THE CALLER ALREADY KNOWS. `/api/meetings` has just read every scribe
499
+ * in this month to build its rows, and each merged one told it which sessions it
500
+ * holds. Reading the same files again to learn the same thing doubled the cost
501
+ * of every month request on a pipeline Mac. The caller passes what it declared;
502
+ * only a caller with no list of its own — `probeMeetings` — pays for the scan.
503
+ *
504
+ * The markers sit before `## Transcript`, past the summary, decisions, action
505
+ * items and attendees, so there is no head-sized prefix that reliably holds
506
+ * them: when this does read, it reads the file.
507
+ */
508
+ function operationsMergedSessions(
509
+ directories: readonly string[],
510
+ declared: ReadonlySet<string> | undefined,
511
+ ): Set<string> {
512
+ if (declared) return new Set(declared)
513
+ const sessions = new Set<string>()
514
+ for (const directory of directories) {
515
+ let names: string[]
516
+ try {
517
+ names = readdirSync(directory).filter(name => name.endsWith('.md'))
518
+ } catch {
519
+ continue
520
+ }
521
+ for (const filename of names) {
522
+ let content = ''
523
+ try {
524
+ content = readFileSync(join(directory, filename), 'utf8')
525
+ } catch {
526
+ continue
527
+ }
528
+ for (const sessionId of mergedScribeSessions(content)) sessions.add(sessionId)
529
+ }
530
+ }
531
+ return sessions
532
+ }
533
+
534
+ /**
535
+ * Day counts for one month across every source the list shows, with supersession
536
+ * applied. The list and `probeMeetings` share it so a dot and a row can never
537
+ * disagree about how many meetings a day holds.
538
+ */
539
+ export function supersededDayCounts(
540
+ month: string,
541
+ layout: SupersededLayout,
542
+ options: {
543
+ domain?: string
544
+ store?: MeetingStore
545
+ library?: ImportedMeetingLibrary
546
+ /** Whether a COS pipeline files this Mac's recordings. Read live by default. */
547
+ pipeline?: boolean
548
+ /**
549
+ * Sessions the CALLER's rows already declare they hold.
550
+ *
551
+ * `/api/meetings` has just read every scribe in this month; passing what it found here
552
+ * is the difference between one read per file and two. A caller with no list of its own
553
+ * omits it and the scan runs.
554
+ */
555
+ declaredSessions?: ReadonlySet<string>
556
+ /** The server's own recordings root, for the sidecar fallback. Tests point it at a temp dir. */
557
+ recordingsRoot?: string
558
+ } = {},
559
+ ): Array<{ date: string; count: number }> {
560
+ if (!IMPORTED_MONTH_PATTERN.test(month)) return []
561
+ const domain = options.domain ?? 'all'
562
+ const store = options.store ?? getMeetingStore()
563
+ const library = options.library ?? getImportedMeetingLibrary()
564
+ const superseded = supersededInputsOf(readDerivedRecords(library))
565
+
566
+ if (layout === 'multi_domain' && (options.pipeline ?? g2RecordingsReachOperations())) {
567
+ // Operations rows only, as the list shows them. The imports root is not read
568
+ // into this layout at all: on a pipeline Mac the operations tree is the one
569
+ // library, and a derived record here would be a second row for one meeting.
570
+ const directories = operationsMonthDirs(month, domain)
571
+ return mergeDayCounts([
572
+ listCosOperationsMeetingDays(month, domain),
573
+ sessionDropCounts(directories, operationsMergedSessions(directories, options.declaredSessions), options.recordingsRoot),
574
+ ])
575
+ }
576
+
577
+ const groups: Array<Array<{ date: string; count: number }>> = []
578
+ const sessionDirectories: string[] = []
579
+
580
+ if (layout !== 'standalone' && cosOperationsMeetingsConfigured()) {
581
+ groups.push(listCosOperationsMeetingDays(month, domain))
582
+ sessionDirectories.push(...operationsMonthDirs(month, domain))
583
+ }
584
+ if (layout === 'direct' && (domain === 'all' || domain === 'library')) {
585
+ groups.push(listDirectLibraryMeetingDays(month))
586
+ const root = resolveMeetingLibrary().root
587
+ if (root) sessionDirectories.push(join(root, month))
588
+ }
589
+ // DOMAIN-SCOPED. Every other group here honours the filter; this one did not, so a
590
+ // `domain=quilt` count added every personal recording in the store to it.
591
+ groups.push(store.listDayCounts(month, domain))
592
+ sessionDirectories.push(join(store.root, month))
593
+ groups.push(importedLibraryDayCounts(month, superseded, library, domain))
594
+
595
+ if (layout === 'multi_domain') {
596
+ // 6.46.1: an operations copy of a capture wins over the store copy, and the
597
+ // day count skips the store row it covers. Unchanged, and now uncapped.
598
+ const covered = new Set<string>()
599
+ for (const directory of operationsMonthDirs(month, domain)) {
600
+ let names: string[]
601
+ try {
602
+ names = readdirSync(directory).filter(name => name.endsWith('.md'))
603
+ } catch {
604
+ continue
605
+ }
606
+ for (const filename of names) {
607
+ const sessionId = sidecarSessionId(directory, filename)
608
+ if (sessionId) covered.add(sessionId)
609
+ }
610
+ }
611
+ groups.push(sessionDropCounts([join(store.root, month)], covered, options.recordingsRoot))
612
+ }
613
+
614
+ groups.push(sessionDropCounts(sessionDirectories, superseded.g2Sessions, options.recordingsRoot))
615
+ return mergeDayCounts(groups)
616
+ }