@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.
- package/CHANGELOG.md +21 -0
- package/package.json +6 -2
- package/server/index.ts +76 -0
- package/server/lib/cos-operations-meetings.ts +99 -8
- package/server/lib/fireflies-client.ts +862 -0
- package/server/lib/fireflies-key.ts +182 -0
- package/server/lib/g2-ops-handoff.ts +15 -1
- package/server/lib/imported-library-rows.ts +616 -0
- package/server/lib/imported-meeting-library.ts +608 -0
- package/server/lib/maintenance-lifecycle.ts +14 -0
- package/server/lib/meeting-actions-store.ts +478 -0
- package/server/lib/meeting-actions.ts +2583 -0
- package/server/lib/meeting-corrections.ts +32 -1
- package/server/lib/meeting-decisions.ts +223 -0
- package/server/lib/meeting-engine/align.ts +167 -0
- package/server/lib/meeting-engine/attribute.ts +265 -0
- package/server/lib/meeting-engine/evidence.ts +428 -0
- package/server/lib/meeting-engine/pairing.ts +327 -0
- package/server/lib/meeting-engine/render.ts +694 -0
- package/server/lib/meeting-engine/split.ts +242 -0
- package/server/lib/meeting-engine/worker.ts +238 -0
- package/server/lib/meeting-engine-mode.ts +197 -0
- package/server/lib/meeting-file-guards.ts +141 -0
- package/server/lib/meeting-import.ts +763 -0
- package/server/lib/meeting-library-search.ts +146 -11
- package/server/lib/meeting-parse.ts +184 -0
- package/server/lib/meeting-store.ts +108 -275
- package/server/lib/meeting-suggestion-sides.ts +242 -0
- package/server/lib/morning-brief-runtime.ts +20 -8
- package/server/lib/pipeline-runner.ts +227 -0
- package/server/lib/voice-evidence-guard.ts +87 -0
- package/server/routes/fireflies-key.ts +102 -0
- package/server/routes/meeting-actions.ts +82 -0
- package/server/routes/meeting-engine.ts +52 -0
- package/server/routes/meeting-import.ts +67 -0
- package/server/routes/meeting-suggestions.ts +66 -0
- package/server/routes/meeting.ts +117 -10
- package/server/routes/meetings.ts +205 -37
- package/server/routes/voice.ts +18 -0
|
@@ -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'
|