@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,608 @@
1
+ // The imported meeting library: meetings this Mac did not record.
2
+ //
3
+ // Root is dataPath('imports'), 0700, with 0600 files in YYYY-MM folders, the
4
+ // same shape as the recordings store. Three record kinds live here:
5
+ //
6
+ // fireflies one imported vendor meeting <date>_fireflies_<h16>.md
7
+ // merged one meeting plus its G2 recordings <date>_merged_<h16>.md
8
+ // piece one span of a long recording <date>_piece_<h16>.md
9
+ //
10
+ // WHY A SEPARATE ROOT. The recordings store holds what the glasses captured,
11
+ // and `.g2-chunks.json` beside a recording is the key the whole speaker system
12
+ // looks meetings up by (findBySessionId, sidecarSessionId, sidecarListHints and
13
+ // two sidecar scans in cos-operations-meetings). An imported record has no
14
+ // capture and no session, so it carries `.import.json` / `.derived.json`
15
+ // instead. Nothing here ever writes a `.g2-chunks.json`: a derived record that
16
+ // looked like a capture would be offered for speaker review, relabelled, and
17
+ // then re-derived from its own output.
18
+ //
19
+ // ADDITIVE, NEVER REDUCTIVE. A record written here is a NEW file. No source is
20
+ // edited, moved or deleted, so every action in this release is reversible by
21
+ // removing what it added.
22
+ //
23
+ // FILENAMES ARE A CONTRACT, not a convenience. The hash in the name is derived
24
+ // from the vendor id (or the merge inputs), so writing the same meeting twice
25
+ // lands on the same file rather than making a second row, and a strict pattern
26
+ // on the way back in is what keeps an iCloud conflict copy ("... 2.md"), a
27
+ // hand-dropped file, or a crafted name out of the list.
28
+
29
+ import { createHash } from 'node:crypto'
30
+ import { chmodSync, lstatSync, mkdirSync, readdirSync, unlinkSync } from 'node:fs'
31
+ import { join } from 'node:path'
32
+ import { durableAtomicWriteFileSync } from './atomic-fs.js'
33
+ import { extractMeetingDateTime } from './cos-operations-meetings.js'
34
+ import { dataPath } from './data-dir.js'
35
+ import { domainAbbreviation, isSafeDomainName } from './domains.js'
36
+ import {
37
+ MAX_MEETING_BYTES,
38
+ existingRootRealpath,
39
+ safeDirectoryRealpath,
40
+ safeReadFile,
41
+ safeReadFileHead,
42
+ } from './meeting-file-guards.js'
43
+ import { parseMeeting, toMeta } from './meeting-parse.js'
44
+ import type { MeetingDetail, MeetingMeta } from './meeting-store.js'
45
+
46
+ export const IMPORTS_DIR_NAME = 'imports'
47
+ /** The routing domain of every record here. Not the meeting's own domain. */
48
+ export const IMPORTED_DOMAIN = 'imported'
49
+ /** Where an import came from when nothing says otherwise. */
50
+ export const DEFAULT_ORIGIN_DOMAIN = 'personal'
51
+
52
+ export const IMPORTED_RECORD_KINDS = ['fireflies', 'merged', 'piece'] as const
53
+ export type ImportedRecordKind = (typeof IMPORTED_RECORD_KINDS)[number]
54
+
55
+ /** Sidecars are JSON and legitimately large; markdown is not. Separate ceilings. */
56
+ export const IMPORT_SIDECAR_MAX_BYTES = 32 * 1024 * 1024
57
+ export const IMPORT_MARKDOWN_MAX_BYTES = 9 * 1024 * 1024
58
+ /** Enough to clear a sidecar's leading metadata keys whatever their order. */
59
+ export const IMPORT_SIDECAR_HEAD_BYTES = 4096
60
+
61
+ export const IMPORTED_MONTH_PATTERN = /^\d{4}-(0[1-9]|1[0-2])$/
62
+ export const IMPORTED_FILENAME_PATTERN =
63
+ /^(\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01]))_(fireflies|merged|piece)_([0-9a-f]{16})\.md$/
64
+ export const IMPORTED_SIDECAR_PATTERN =
65
+ /^\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])_(?:fireflies|merged|piece)_[0-9a-f]{16}\.(?:import|derived)\.json$/
66
+ /** durableAtomicWriteFileSync's temp shape: .<name>.<pid>.<hex>.tmp */
67
+ export const IMPORTED_TEMP_PATTERN = /^\..+\.\d+\.[0-9a-f]{6,}\.tmp$/
68
+
69
+ export class ImportedLibraryError extends Error {
70
+ constructor(message: string, readonly status: number, readonly code: string) {
71
+ super(message)
72
+ this.name = 'ImportedLibraryError'
73
+ }
74
+ }
75
+
76
+ /** A path under the imports root. Resolved per call so tests can move the data home. */
77
+ export function importsRoot(...parts: string[]): string {
78
+ return dataPath(IMPORTS_DIR_NAME, ...parts)
79
+ }
80
+
81
+ function hash16(input: string): string {
82
+ return createHash('sha256').update(input).digest('hex').slice(0, 16)
83
+ }
84
+
85
+ export function importHash(firefliesId: string): string {
86
+ return hash16(`fireflies:${firefliesId}`)
87
+ }
88
+
89
+ /**
90
+ * Identity of a merged record: its Fireflies primary plus every G2 session it
91
+ * holds, sorted so input order cannot make two names for one merge.
92
+ *
93
+ * The separator is a comma because a session id may itself contain a colon
94
+ * (normalizeSessionId allows it), and a separator that can appear inside a
95
+ * part is a separator that can collide.
96
+ */
97
+ export function mergedHash(firefliesPrimaryId: string, g2SessionIds: string[]): string {
98
+ return hash16(`merge:${firefliesPrimaryId}:${[...g2SessionIds].sort().join(',')}`)
99
+ }
100
+
101
+ export function pieceHash(sourceRecordId: string, pieceIndex: number): string {
102
+ return hash16(`split:${sourceRecordId}:${pieceIndex}`)
103
+ }
104
+
105
+ /** The id every surface uses for one of these rows. */
106
+ export function importRecordId(kind: ImportedRecordKind, hash: string): string {
107
+ return kind === 'fireflies' ? `imported:fireflies:${hash}` : `blended:${hash}`
108
+ }
109
+
110
+ export function importedFilename(kind: ImportedRecordKind, date: string, hash: string): string {
111
+ return `${date}_${kind}_${hash}.md`
112
+ }
113
+
114
+ export function sidecarFilenameFor(filename: string): string {
115
+ const match = filename.match(IMPORTED_FILENAME_PATTERN)
116
+ if (!match) throw new ImportedLibraryError('Not an imported record filename', 400, 'invalid_filename')
117
+ const suffix = match[2] === 'fireflies' ? '.import.json' : '.derived.json'
118
+ return filename.replace(/\.md$/, suffix)
119
+ }
120
+
121
+ export function localDateParts(timestamp: number): { date: string; month: string; time: string } {
122
+ const value = new Date(timestamp)
123
+ if (!Number.isFinite(timestamp) || Number.isNaN(value.getTime())) {
124
+ throw new ImportedLibraryError('Invalid meeting time', 400, 'invalid_date')
125
+ }
126
+ const year = String(value.getFullYear())
127
+ const month = String(value.getMonth() + 1).padStart(2, '0')
128
+ const day = String(value.getDate()).padStart(2, '0')
129
+ const hour = String(value.getHours()).padStart(2, '0')
130
+ const minute = String(value.getMinutes()).padStart(2, '0')
131
+ return { date: `${year}-${month}-${day}`, month: `${year}-${month}`, time: `${hour}:${minute}` }
132
+ }
133
+
134
+ /**
135
+ * Flatten a value into one safe markdown line.
136
+ *
137
+ * Two collapses are load-bearing rather than cosmetic. `##` becomes `#` so a
138
+ * title or a spoken sentence cannot open a section the parser then reads as the
139
+ * summary, and `**` becomes `*` so it cannot forge a `| **Date** |` field. Both
140
+ * are matched by the parser ANYWHERE in the file, and the title line sits above
141
+ * the real table, so without this a meeting called "**Date** | 1999-01-01"
142
+ * would file itself under 1999. The unedited text is kept in the sidecar.
143
+ */
144
+ export function singleLine(value: string): string {
145
+ return value
146
+ .normalize('NFKC')
147
+ .replace(/[\u0000-\u001f\u007f]+/g, ' ')
148
+ .replace(/#{2,}/g, '#')
149
+ .replace(/\*{2,}/g, '*')
150
+ .replace(/\s+/g, ' ')
151
+ .trim()
152
+ }
153
+
154
+ function tableValue(value: string): string {
155
+ return singleLine(value).replace(/\|/g, '\\|')
156
+ }
157
+
158
+ export function timestampLabel(seconds: number): string {
159
+ const total = Number.isFinite(seconds) && seconds > 0 ? Math.floor(seconds) : 0
160
+ const hours = String(Math.floor(total / 3600)).padStart(2, '0')
161
+ const minutes = String(Math.floor((total % 3600) / 60)).padStart(2, '0')
162
+ const secs = String(total % 60).padStart(2, '0')
163
+ return `[${hours}:${minutes}:${secs}]`
164
+ }
165
+
166
+ export interface ImportedTranscriptLine {
167
+ speakerName: string
168
+ text: string
169
+ startTime: number
170
+ }
171
+
172
+ /**
173
+ * The vendor hands back action items as ONE string with newlines in it, not as
174
+ * a list, so the split belongs here rather than in the parser: `## Action
175
+ * Items` is a bulleted section, and `parseActions` reads one item per line.
176
+ *
177
+ * A line that already starts with a bullet loses it, or the render would show
178
+ * two. Blank lines are dropped rather than becoming empty bullets.
179
+ */
180
+ export function actionItemLines(value: string | null | undefined): string[] {
181
+ if (!value) return []
182
+ return value
183
+ .split(/\r?\n/)
184
+ .map(line => line.replace(/^\s*(?:[-*\u2022]|\d+[.)])\s+/, '').trim())
185
+ .filter(line => line.length > 0)
186
+ }
187
+
188
+ export interface FirefliesImportRender {
189
+ title: string
190
+ dateMs: number
191
+ durationSeconds: number | null
192
+ attendees: string[]
193
+ overview?: string
194
+ actionItems?: string[]
195
+ sentences: ImportedTranscriptLine[]
196
+ }
197
+
198
+ /** Shown in place of the lines that did not fit. Plain words, no arrows. */
199
+ export const TRANSCRIPT_TRUNCATED_NOTE = 'Transcript truncated here. The full transcript is kept in this record sidecar.'
200
+
201
+ /**
202
+ * The import template.
203
+ *
204
+ * It has to satisfy three readers at once: `parseMeeting` here, the COS
205
+ * pipeline's own duration regex (`**Duration** | N min`) and date regex
206
+ * (`**Date** | YYYY-MM-DD HH:MM`) in sync_meetings.py, and a person reading the
207
+ * file. `## Transcript` is last because the parser reads that section to the
208
+ * end of the file.
209
+ */
210
+ export function renderImportMarkdown(input: FirefliesImportRender): string {
211
+ const parts = localDateParts(input.dateMs)
212
+ const durationMinutes = input.durationSeconds == null ? null : Math.round(input.durationSeconds / 60)
213
+ const title = singleLine(input.title) || `Fireflies meeting ${parts.date}`
214
+
215
+ const lines: string[] = [
216
+ `# ${title}`,
217
+ '',
218
+ '| Field | Value |',
219
+ '|-------|-------|',
220
+ `| **Date** | ${parts.date} ${parts.time} |`,
221
+ ...(durationMinutes == null ? [] : [`| **Duration** | ${durationMinutes} minutes |`]),
222
+ '| **Source** | Fireflies |',
223
+ `| **Domain** | ${IMPORTED_DOMAIN} |`,
224
+ '',
225
+ ]
226
+
227
+ const overview = input.overview ? singleLine(input.overview) : ''
228
+ if (overview) lines.push('## Summary', '', overview, '')
229
+
230
+ const actionItems = (input.actionItems ?? []).map(singleLine).filter(Boolean)
231
+ if (actionItems.length > 0) {
232
+ lines.push('## Action Items', '', ...actionItems.map(item => `- ${item}`), '')
233
+ }
234
+
235
+ const attendees = input.attendees.map(singleLine).filter(Boolean)
236
+ if (attendees.length > 0) {
237
+ lines.push('## Attendees', '', ...attendees.map(name => `- ${name}`), '')
238
+ }
239
+
240
+ lines.push('## Transcript', '')
241
+ const header = `${lines.join('\n')}\n`
242
+
243
+ const body: string[] = []
244
+ let bytes = Buffer.byteLength(header)
245
+ let truncated = false
246
+ for (const sentence of input.sentences) {
247
+ const speaker = singleLine(sentence.speakerName) || 'Unknown'
248
+ const text = singleLine(sentence.text)
249
+ if (!text) continue
250
+ const line = `${timestampLabel(sentence.startTime)} ${speaker}: ${text}`
251
+ const lineBytes = Buffer.byteLength(`${line}\n`)
252
+ // Leave room for the note itself, so the ceiling holds even at the boundary.
253
+ if (bytes + lineBytes + Buffer.byteLength(TRANSCRIPT_TRUNCATED_NOTE) + 2 > IMPORT_MARKDOWN_MAX_BYTES) {
254
+ truncated = true
255
+ break
256
+ }
257
+ body.push(line)
258
+ bytes += lineBytes
259
+ }
260
+ if (truncated) body.push('', TRANSCRIPT_TRUNCATED_NOTE)
261
+
262
+ return `${header}${body.join('\n')}\n`
263
+ }
264
+
265
+ export interface ImportedRecordWrite {
266
+ kind: ImportedRecordKind
267
+ hash: string
268
+ /** Decides the filename date and the month folder. */
269
+ dateMs: number
270
+ markdown: string
271
+ sidecar: unknown
272
+ }
273
+
274
+ export interface ImportedRecordWriteResult {
275
+ written: boolean
276
+ month: string
277
+ filename: string
278
+ filepath: string
279
+ sidecarPath: string
280
+ recordId: string
281
+ }
282
+
283
+ /** A list row for an imported record. WS5 widens MeetingMeta to carry these. */
284
+ export type ImportedMeetingMeta = Omit<MeetingMeta, 'librarySource'> & {
285
+ librarySource: 'imported' | 'blended'
286
+ recordId: string
287
+ mutable: false
288
+ originDomain: string
289
+ derivedKind?: 'merge' | 'split'
290
+ }
291
+
292
+ /** Omit-and-restate rather than extend: MeetingMeta's `librarySource` union
293
+ * does not carry the imported values yet, and widening a type this many
294
+ * surfaces decode from belongs with the list and detail integration, not here. */
295
+ export type ImportedMeetingDetail = Omit<MeetingDetail, 'librarySource'> & {
296
+ librarySource: 'imported' | 'blended'
297
+ recordId: string
298
+ mutable: false
299
+ originDomain: string
300
+ derivedKind?: 'merge' | 'split'
301
+ }
302
+
303
+ function ensurePrivateDirectory(path: string): void {
304
+ mkdirSync(path, { recursive: true, mode: 0o700 })
305
+ const stat = lstatSync(path)
306
+ if (stat.isSymbolicLink() || !stat.isDirectory()) {
307
+ throw new ImportedLibraryError('Unsafe imports directory', 500, 'unsafe_imports_store')
308
+ }
309
+ chmodSync(path, 0o700)
310
+ }
311
+
312
+ function originDomainFromSidecarHead(head: string | null): string {
313
+ if (!head) return DEFAULT_ORIGIN_DOMAIN
314
+ const match = head.match(/"originDomain"\s*:\s*"([^"]{1,64})"/)
315
+ if (!match) return DEFAULT_ORIGIN_DOMAIN
316
+ return isSafeDomainName(match[1]) ? match[1] : DEFAULT_ORIGIN_DOMAIN
317
+ }
318
+
319
+ /**
320
+ * The vendor's own id for an imported meeting.
321
+ *
322
+ * ON THE ROW SO NO CLIENT RE-DERIVES IT. `recordId` is `imported:fireflies:<h16>`, where the
323
+ * h16 is `sha256("fireflies:" + firefliesId)` — one way. A surface that wanted to say "this
324
+ * suggestion is about THAT row" therefore had to re-implement the server's hash in its own
325
+ * language, from a rule written in a comment, and be kept in step with it forever. The id it
326
+ * hashes is already in the sidecar; carrying it is cheaper than the agreement.
327
+ */
328
+ function vendorIdFromSidecarHead(head: string | null): string | undefined {
329
+ const match = head?.match(/"firefliesId"\s*:\s*"([^"]{1,128})"/)
330
+ return match ? match[1] : undefined
331
+ }
332
+
333
+ export class ImportedMeetingLibrary {
334
+ readonly root: string
335
+
336
+ constructor(options: { root?: string } = {}) {
337
+ this.root = options.root ?? importsRoot()
338
+ }
339
+
340
+ private rootReal(): string | null {
341
+ return existingRootRealpath(this.root, () => new ImportedLibraryError('Unsafe imports directory', 500, 'unsafe_imports_store'))
342
+ }
343
+
344
+ private months(rootReal: string): string[] {
345
+ return readdirSync(this.root)
346
+ .filter(name => IMPORTED_MONTH_PATTERN.test(name) && safeDirectoryRealpath(join(this.root, name), rootReal))
347
+ .sort()
348
+ .reverse()
349
+ }
350
+
351
+ /**
352
+ * Write one record. Sidecar first, markdown second.
353
+ *
354
+ * The markdown is the commit marker: a crash between the two leaves a sidecar
355
+ * with no record, which lists as nothing and is swept at the next startup. The
356
+ * reverse order would publish a listed record whose evidence never landed.
357
+ */
358
+ writeRecord(input: ImportedRecordWrite): ImportedRecordWriteResult {
359
+ if (!/^[0-9a-f]{16}$/.test(input.hash)) {
360
+ throw new ImportedLibraryError('Invalid record hash', 400, 'invalid_hash')
361
+ }
362
+ const parts = localDateParts(input.dateMs)
363
+ const filename = importedFilename(input.kind, parts.date, input.hash)
364
+ if (!IMPORTED_FILENAME_PATTERN.test(filename)) {
365
+ throw new ImportedLibraryError('Invalid record filename', 400, 'invalid_filename')
366
+ }
367
+ const sidecarText = JSON.stringify(input.sidecar)
368
+ if (Buffer.byteLength(sidecarText) > IMPORT_SIDECAR_MAX_BYTES) {
369
+ throw new ImportedLibraryError('Record evidence is too large to store', 507, 'derived_too_large')
370
+ }
371
+ if (Buffer.byteLength(input.markdown) > IMPORT_MARKDOWN_MAX_BYTES) {
372
+ throw new ImportedLibraryError('Record markdown is too large to store', 507, 'derived_too_large')
373
+ }
374
+
375
+ ensurePrivateDirectory(this.root)
376
+ const monthDir = join(this.root, parts.month)
377
+ ensurePrivateDirectory(monthDir)
378
+ const monthReal = safeDirectoryRealpath(monthDir, this.rootReal() ?? '')
379
+ if (!monthReal) throw new ImportedLibraryError('Unsafe imports month folder', 500, 'unsafe_imports_store')
380
+
381
+ const sidecarName = sidecarFilenameFor(filename)
382
+ const filepath = join(monthDir, filename)
383
+ const sidecarPath = join(monthDir, sidecarName)
384
+ const recordId = importRecordId(input.kind, input.hash)
385
+
386
+ // Re-deriving the same bytes is not a write. This is what lets the importer
387
+ // re-see a meeting on every poll without rewriting the library each time.
388
+ const existingMarkdown = safeReadFile(monthDir, monthReal, filename, IMPORT_MARKDOWN_MAX_BYTES)
389
+ if (existingMarkdown === input.markdown) {
390
+ const existingSidecar = safeReadFile(monthDir, monthReal, sidecarName, IMPORT_SIDECAR_MAX_BYTES)
391
+ if (existingSidecar === sidecarText) {
392
+ return { written: false, month: parts.month, filename, filepath, sidecarPath, recordId }
393
+ }
394
+ }
395
+
396
+ durableAtomicWriteFileSync(sidecarPath, sidecarText, { mode: 0o600 })
397
+ try {
398
+ durableAtomicWriteFileSync(filepath, input.markdown, { mode: 0o600 })
399
+ } catch (error) {
400
+ try { unlinkSync(sidecarPath) } catch { /* an orphan sidecar lists as nothing */ }
401
+ throw error
402
+ }
403
+ return { written: true, month: parts.month, filename, filepath, sidecarPath, recordId }
404
+ }
405
+
406
+ /** Hashes actually on disk. The importer proves a write by re-listing.
407
+ *
408
+ * `kind` matters once merged and split records live here too: a count of
409
+ * "imported meetings" that silently included every derived record would
410
+ * climb every time the engine ran. */
411
+ listHashes(options: { month?: string; kind?: ImportedRecordKind } = {}): Set<string> {
412
+ const hashes = new Set<string>()
413
+ const rootReal = this.rootReal()
414
+ if (!rootReal) return hashes
415
+ for (const month of this.months(rootReal)) {
416
+ if (options.month && month !== options.month) continue
417
+ const monthDir = join(this.root, month)
418
+ if (!safeDirectoryRealpath(monthDir, rootReal)) continue
419
+ for (const name of readdirSync(monthDir)) {
420
+ const match = name.match(IMPORTED_FILENAME_PATTERN)
421
+ if (!match) continue
422
+ if (options.kind && match[2] !== options.kind) continue
423
+ hashes.add(match[3])
424
+ }
425
+ }
426
+ return hashes
427
+ }
428
+
429
+ has(hash: string): boolean {
430
+ return this.listHashes().has(hash)
431
+ }
432
+
433
+ list(options: { limit?: number; domain?: string; month?: string; day?: string } = {}): ImportedMeetingMeta[] {
434
+ const rootReal = this.rootReal()
435
+ if (!rootReal) return []
436
+ const domain = options.domain ?? 'all'
437
+ const rows: ImportedMeetingMeta[] = []
438
+
439
+ for (const month of this.months(rootReal)) {
440
+ if (options.month && month !== options.month) continue
441
+ const monthDir = join(this.root, month)
442
+ const monthReal = safeDirectoryRealpath(monthDir, rootReal)
443
+ if (!monthReal) continue
444
+ for (const filename of readdirSync(monthDir).filter(name => IMPORTED_FILENAME_PATTERN.test(name)).sort().reverse()) {
445
+ try {
446
+ const row = this.metaFor(monthDir, monthReal, month, filename)
447
+ if (!row) continue
448
+ // `imported` is the routing domain every record shares; a domain
449
+ // filter otherwise matches where the meeting actually came from.
450
+ if (domain !== 'all' && domain !== IMPORTED_DOMAIN && row.originDomain !== domain) continue
451
+ if (options.day && row.date !== options.day) continue
452
+ rows.push(row)
453
+ } catch {
454
+ // One unreadable record must not hide the rest of the library.
455
+ }
456
+ }
457
+ }
458
+
459
+ rows.sort((left, right) => (
460
+ right.date.localeCompare(left.date)
461
+ || (right.time ?? '').localeCompare(left.time ?? '')
462
+ || right.filename.localeCompare(left.filename)
463
+ ))
464
+ return typeof options.limit === 'number' ? rows.slice(0, Math.max(0, options.limit)) : rows
465
+ }
466
+
467
+ private metaFor(monthDir: string, monthReal: string, month: string, filename: string): ImportedMeetingMeta | null {
468
+ const content = safeReadFile(monthDir, monthReal, filename, MAX_MEETING_BYTES)
469
+ if (content === null) return null
470
+ const match = filename.match(IMPORTED_FILENAME_PATTERN)
471
+ if (!match) return null
472
+ const kind = match[2] as ImportedRecordKind
473
+ const hash = match[3]
474
+ const detail = parseMeeting(content, filename, month)
475
+ const stamp = extractMeetingDateTime(content, filename)
476
+ const sidecarHead = safeReadFileHead(monthDir, monthReal, sidecarFilenameFor(filename), IMPORT_SIDECAR_HEAD_BYTES)
477
+ const originDomain = originDomainFromSidecarHead(sidecarHead)
478
+ const vendorId = kind === 'fireflies' ? vendorIdFromSidecarHead(sidecarHead) : undefined
479
+ const meta = toMeta({ ...detail, date: stamp.date })
480
+ return {
481
+ ...meta,
482
+ ...(stamp.time ? { time: stamp.time } : {}),
483
+ domain: IMPORTED_DOMAIN,
484
+ domainAbbr: domainAbbreviation(originDomain),
485
+ originDomain,
486
+ ...(vendorId ? { vendorId } : {}),
487
+ librarySource: kind === 'fireflies' ? 'imported' : 'blended',
488
+ recordId: importRecordId(kind, hash),
489
+ mutable: false,
490
+ ...(kind === 'merged' ? { derivedKind: 'merge' as const } : {}),
491
+ ...(kind === 'piece' ? { derivedKind: 'split' as const } : {}),
492
+ }
493
+ }
494
+
495
+ detail(month: string, filename: string): ImportedMeetingDetail | null {
496
+ if (!IMPORTED_MONTH_PATTERN.test(month)) return null
497
+ const match = filename.match(IMPORTED_FILENAME_PATTERN)
498
+ if (!match) return null
499
+ const rootReal = this.rootReal()
500
+ if (!rootReal) return null
501
+ const monthDir = join(this.root, month)
502
+ const monthReal = safeDirectoryRealpath(monthDir, rootReal)
503
+ if (!monthReal) return null
504
+ const content = safeReadFile(monthDir, monthReal, filename, MAX_MEETING_BYTES)
505
+ if (content === null) return null
506
+
507
+ const kind = match[2] as ImportedRecordKind
508
+ const detail = parseMeeting(content, filename, month)
509
+ // The table carries `YYYY-MM-DD HH:MM`, so the raw field is a date AND a
510
+ // time. list() splits them; detail() has to split them the same way or the
511
+ // row and the record it opens disagree about when the meeting happened.
512
+ const stamp = extractMeetingDateTime(content, filename)
513
+ const sidecarHead = safeReadFileHead(monthDir, monthReal, sidecarFilenameFor(filename), IMPORT_SIDECAR_HEAD_BYTES)
514
+ const originDomain = originDomainFromSidecarHead(sidecarHead)
515
+ const vendorId = kind === 'fireflies' ? vendorIdFromSidecarHead(sidecarHead) : undefined
516
+ return {
517
+ ...detail,
518
+ date: stamp.date,
519
+ ...(stamp.time ? { time: stamp.time } : {}),
520
+ domain: IMPORTED_DOMAIN,
521
+ domainAbbr: domainAbbreviation(originDomain),
522
+ originDomain,
523
+ ...(vendorId ? { vendorId } : {}),
524
+ librarySource: kind === 'fireflies' ? 'imported' : 'blended',
525
+ recordId: importRecordId(kind, match[3]),
526
+ mutable: false,
527
+ ...(kind === 'merged' ? { derivedKind: 'merge' as const } : {}),
528
+ ...(kind === 'piece' ? { derivedKind: 'split' as const } : {}),
529
+ }
530
+ }
531
+
532
+ /** The record's evidence. Bounded well above markdown: this is where the
533
+ * unedited sentences, fingerprints and per-sentence labels live. */
534
+ readSidecar(month: string, filename: string): unknown | null {
535
+ if (!IMPORTED_MONTH_PATTERN.test(month) || !IMPORTED_FILENAME_PATTERN.test(filename)) return null
536
+ const rootReal = this.rootReal()
537
+ if (!rootReal) return null
538
+ const monthDir = join(this.root, month)
539
+ const monthReal = safeDirectoryRealpath(monthDir, rootReal)
540
+ if (!monthReal) return null
541
+ const text = safeReadFile(monthDir, monthReal, sidecarFilenameFor(filename), IMPORT_SIDECAR_MAX_BYTES)
542
+ if (text === null) return null
543
+ try {
544
+ return JSON.parse(text)
545
+ } catch {
546
+ return null
547
+ }
548
+ }
549
+
550
+ /**
551
+ * Startup sweep: temp files from a killed write, and sidecars whose markdown
552
+ * never landed. Both are invisible to every reader, so they are debris rather
553
+ * than data, and leaving them means a crashed backfill silently fills the
554
+ * data home.
555
+ *
556
+ * SKIPPED WHILE A WRITER HOLDS THE LEASE. Mid-write, a sidecar with no
557
+ * markdown is the correct intermediate state, and deleting it would destroy
558
+ * the evidence of a record that is about to exist.
559
+ */
560
+ sweep(options: { isBusy?: () => boolean } = {}): { removedTemp: number; removedSidecars: number; skipped: boolean } {
561
+ if (options.isBusy?.()) return { removedTemp: 0, removedSidecars: 0, skipped: true }
562
+ const rootReal = this.rootReal()
563
+ if (!rootReal) return { removedTemp: 0, removedSidecars: 0, skipped: false }
564
+ let removedTemp = 0
565
+ let removedSidecars = 0
566
+
567
+ const removeFile = (directory: string, name: string): boolean => {
568
+ const path = join(directory, name)
569
+ try {
570
+ const stat = lstatSync(path)
571
+ if (stat.isSymbolicLink() || !stat.isFile()) return false
572
+ unlinkSync(path)
573
+ return true
574
+ } catch {
575
+ return false
576
+ }
577
+ }
578
+
579
+ for (const name of readdirSync(this.root)) {
580
+ if (IMPORTED_TEMP_PATTERN.test(name) && removeFile(this.root, name)) removedTemp += 1
581
+ }
582
+
583
+ for (const month of this.months(rootReal)) {
584
+ const monthDir = join(this.root, month)
585
+ if (!safeDirectoryRealpath(monthDir, rootReal)) continue
586
+ const names = readdirSync(monthDir)
587
+ const present = new Set(names)
588
+ for (const name of names) {
589
+ if (IMPORTED_TEMP_PATTERN.test(name)) {
590
+ if (removeFile(monthDir, name)) removedTemp += 1
591
+ continue
592
+ }
593
+ if (!IMPORTED_SIDECAR_PATTERN.test(name)) continue
594
+ const markdown = name.replace(/\.(?:import|derived)\.json$/, '.md')
595
+ if (!present.has(markdown) && removeFile(monthDir, name)) removedSidecars += 1
596
+ }
597
+ }
598
+
599
+ return { removedTemp, removedSidecars, skipped: false }
600
+ }
601
+ }
602
+
603
+ let library: ImportedMeetingLibrary | null = null
604
+
605
+ export function getImportedMeetingLibrary(): ImportedMeetingLibrary {
606
+ library ??= new ImportedMeetingLibrary()
607
+ return library
608
+ }
@@ -68,6 +68,20 @@ export type MaintenanceWorkKind =
68
68
  // committing a drain into a decode that outlives its 90s timeout
69
69
  // (main.swift:1963 waitForRestartProof).
70
70
  | 'orphan_recovery'
71
+ // One page of a Fireflies import (6.47.0). Held over the page's WRITES, not
72
+ // its network fetch: a page fetch can run to 30s, and with vendor 5xx retries
73
+ // far longer, which would push a drain past COS Control's 90s timeout. The
74
+ // importer checks admissions before each fetch and treats a drain error here
75
+ // as "defer this page", so an Update Server waits only for file writes.
76
+ | 'meeting_import'
77
+ // One merge action (6.47.0): the derived-record write in imports mode, or the
78
+ // pipeline spawn in apply mode. Held PER ACTION, never across a whole run, and
79
+ // the spawn's own wall is 75s (pipeline-runner.ts), under COS Control's 90s
80
+ // drain timeout (main.swift waitForRestartProof). The runner catches
81
+ // `maintenance_drain_active` and defers the action rather than fighting the
82
+ // drain: nothing is written at that point, so the next run repeats the same
83
+ // decision at no cost.
84
+ | 'meeting_merge'
71
85
 
72
86
  export type MaintenanceWorkPhase = 'queued' | 'active'
73
87
  export type MaintenanceOperationScope = 'same_boot' | 'cross_boot'