@gotcos/glasses-server 6.7.0 → 6.8.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 +30 -0
- package/package.json +1 -1
- package/server/index.ts +4 -0
- package/server/lib/atomic-fs.ts +77 -1
- package/server/lib/batch-transcript-quality.ts +243 -0
- package/server/lib/meeting-batch-persistence.ts +53 -0
- package/server/lib/meeting-batch-transcribe.ts +249 -0
- package/server/lib/meeting-store.ts +594 -0
- package/server/routes/health.ts +1 -0
- package/server/routes/meeting.ts +329 -0
- package/server/routes/meetings.ts +66 -0
- package/server/routes/transcribe-stream.ts +116 -23
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
import {
|
|
2
|
+
chmodSync,
|
|
3
|
+
closeSync,
|
|
4
|
+
constants,
|
|
5
|
+
existsSync,
|
|
6
|
+
fstatSync,
|
|
7
|
+
lstatSync,
|
|
8
|
+
mkdirSync,
|
|
9
|
+
openSync,
|
|
10
|
+
readFileSync,
|
|
11
|
+
readdirSync,
|
|
12
|
+
realpathSync,
|
|
13
|
+
unlinkSync,
|
|
14
|
+
} from 'node:fs'
|
|
15
|
+
import { createHash } from 'node:crypto'
|
|
16
|
+
import { basename, dirname, join, resolve, sep } from 'node:path'
|
|
17
|
+
import { durableAtomicWriteFileSync } from './atomic-fs.js'
|
|
18
|
+
import { dataPath } from './data-dir.js'
|
|
19
|
+
import type {
|
|
20
|
+
ProviderCandidateRecord,
|
|
21
|
+
IndexedTranscriptChunk,
|
|
22
|
+
TranscriptChunk,
|
|
23
|
+
TranscriptGapReport,
|
|
24
|
+
} from '../routes/transcribe-stream.js'
|
|
25
|
+
|
|
26
|
+
const MONTH_PATTERN = /^\d{4}-(0[1-9]|1[0-2])$/
|
|
27
|
+
const SAFE_FILENAME_PATTERN = /^\d{4}-\d{2}-\d{2}_[A-Za-z0-9][A-Za-z0-9_-]{0,95}\.md$/
|
|
28
|
+
const DOMAIN_PATTERN = /^[a-z][a-z0-9_]{0,31}$/
|
|
29
|
+
const MAX_MEETING_BYTES = 10 * 1024 * 1024
|
|
30
|
+
const DETAIL_CHUNK_ESTIMATE_CHARS = 1_700
|
|
31
|
+
|
|
32
|
+
export class MeetingStoreError extends Error {
|
|
33
|
+
constructor(
|
|
34
|
+
message: string,
|
|
35
|
+
readonly status: number,
|
|
36
|
+
readonly code: string,
|
|
37
|
+
) {
|
|
38
|
+
super(message)
|
|
39
|
+
this.name = 'MeetingStoreError'
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface MeetingMeta {
|
|
44
|
+
filename: string
|
|
45
|
+
title: string
|
|
46
|
+
date: string
|
|
47
|
+
domain: string
|
|
48
|
+
domainAbbr: string
|
|
49
|
+
source: string
|
|
50
|
+
duration: string
|
|
51
|
+
durationMinutes?: number
|
|
52
|
+
month: string
|
|
53
|
+
estimatedDetailPages?: number
|
|
54
|
+
detailCharEstimate?: number
|
|
55
|
+
topicCount?: number
|
|
56
|
+
decisionCount?: number
|
|
57
|
+
actionCount?: number
|
|
58
|
+
attendeeCount?: number
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface MeetingActionItem {
|
|
62
|
+
task: string
|
|
63
|
+
owner: string
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface MeetingDetail extends MeetingMeta {
|
|
67
|
+
summary: string
|
|
68
|
+
topics: string[]
|
|
69
|
+
decisions: string[]
|
|
70
|
+
actionItems: MeetingActionItem[]
|
|
71
|
+
attendees: string[]
|
|
72
|
+
/** Additive field for API consumers that want the exact canonical record. */
|
|
73
|
+
transcript: string
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface SaveMeetingInput {
|
|
77
|
+
sessionId: string
|
|
78
|
+
title?: string
|
|
79
|
+
domain?: string
|
|
80
|
+
transcript: string
|
|
81
|
+
startTime: number
|
|
82
|
+
durationMs: number
|
|
83
|
+
chunks: TranscriptChunk[]
|
|
84
|
+
chunkEntries?: IndexedTranscriptChunk[]
|
|
85
|
+
providerCandidates?: Record<string, ProviderCandidateRecord>
|
|
86
|
+
transferIntegrity?: TranscriptGapReport | null
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface SavedMeeting {
|
|
90
|
+
filepath: string
|
|
91
|
+
sidecarPath: string
|
|
92
|
+
filename: string
|
|
93
|
+
month: string
|
|
94
|
+
title: string
|
|
95
|
+
domain: string
|
|
96
|
+
durationMin: number
|
|
97
|
+
transferIntegrity?: TranscriptGapReport | null
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function ensurePrivateDirectory(path: string): void {
|
|
101
|
+
mkdirSync(path, { recursive: true, mode: 0o700 })
|
|
102
|
+
const stat = lstatSync(path)
|
|
103
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
104
|
+
throw new MeetingStoreError('Unsafe recordings directory', 500, 'unsafe_recordings_store')
|
|
105
|
+
}
|
|
106
|
+
chmodSync(path, 0o700)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function normalizeSessionId(value: string): string {
|
|
110
|
+
if (!/^[A-Za-z0-9:_-]{3,96}$/.test(value)) {
|
|
111
|
+
throw new MeetingStoreError('Invalid sessionId', 400, 'invalid_session_id')
|
|
112
|
+
}
|
|
113
|
+
return value
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function normalizeDomain(value?: string): string {
|
|
117
|
+
const domain = (value || 'personal').trim().toLowerCase()
|
|
118
|
+
if (!DOMAIN_PATTERN.test(domain)) {
|
|
119
|
+
throw new MeetingStoreError('Invalid domain', 400, 'invalid_domain')
|
|
120
|
+
}
|
|
121
|
+
return domain
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function normalizeTitle(value: string | undefined, fallback: string): string {
|
|
125
|
+
const normalized = (value || fallback)
|
|
126
|
+
.normalize('NFKC')
|
|
127
|
+
.replace(/[\u0000-\u001f\u007f]/g, ' ')
|
|
128
|
+
.replace(/[\\/]+/g, ' ')
|
|
129
|
+
.replace(/\s+/g, ' ')
|
|
130
|
+
.trim()
|
|
131
|
+
.slice(0, 160)
|
|
132
|
+
return normalized || fallback
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function filenameStem(title: string, fallback: string): string {
|
|
136
|
+
const safe = title
|
|
137
|
+
.normalize('NFKD')
|
|
138
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
139
|
+
.replace(/[^A-Za-z0-9_-]+/g, '_')
|
|
140
|
+
.replace(/^[_-]+|[_-]+$/g, '')
|
|
141
|
+
.replace(/_{2,}/g, '_')
|
|
142
|
+
.slice(0, 72)
|
|
143
|
+
return safe || fallback
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function localParts(timestamp: number): {
|
|
147
|
+
date: string
|
|
148
|
+
month: string
|
|
149
|
+
time: string
|
|
150
|
+
hourMinute: string
|
|
151
|
+
} {
|
|
152
|
+
const date = new Date(timestamp)
|
|
153
|
+
if (!Number.isFinite(timestamp) || Number.isNaN(date.getTime())) {
|
|
154
|
+
throw new MeetingStoreError('Invalid meeting start time', 400, 'invalid_start_time')
|
|
155
|
+
}
|
|
156
|
+
const yyyy = String(date.getFullYear())
|
|
157
|
+
const mm = String(date.getMonth() + 1).padStart(2, '0')
|
|
158
|
+
const dd = String(date.getDate()).padStart(2, '0')
|
|
159
|
+
const hh = String(date.getHours()).padStart(2, '0')
|
|
160
|
+
const minute = String(date.getMinutes()).padStart(2, '0')
|
|
161
|
+
return {
|
|
162
|
+
date: `${yyyy}-${mm}-${dd}`,
|
|
163
|
+
month: `${yyyy}-${mm}`,
|
|
164
|
+
hourMinute: `${hh}${minute}`,
|
|
165
|
+
time: date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }),
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function escapeTableValue(value: string): string {
|
|
170
|
+
return value.replace(/\|/g, '\\|').replace(/[\r\n]+/g, ' ')
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function wordCount(text: string): number {
|
|
174
|
+
return text.trim().split(/\s+/).filter(Boolean).length
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function canonicalProvider(chunks: TranscriptChunk[]): 'server-whisper' | 'iphone-whisperkit-beta' | 'mixed' {
|
|
178
|
+
const providers = new Set(chunks.map(chunk => chunk.asrProvider || 'server-whisper'))
|
|
179
|
+
if (providers.size === 0) return 'server-whisper'
|
|
180
|
+
if (providers.size === 1) return [...providers][0] as 'server-whisper' | 'iphone-whisperkit-beta'
|
|
181
|
+
return 'mixed'
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function parseField(content: string, field: string): string {
|
|
185
|
+
const escaped = field.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
186
|
+
const table = content.match(new RegExp(`\\*\\*${escaped}\\*\\*\\s*\\|\\s*(.+)`, 'i'))
|
|
187
|
+
if (table) return table[1].replace(/\s*\|?\s*$/, '').trim()
|
|
188
|
+
const plain = content.match(new RegExp(`\\*\\*${escaped}:\\*\\*\\s*(.+)`, 'i'))
|
|
189
|
+
return plain ? plain[1].trim() : ''
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function extractSection(content: string, headings: string[], toEnd = false): string {
|
|
193
|
+
for (const heading of headings) {
|
|
194
|
+
const escaped = heading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
195
|
+
const pattern = toEnd
|
|
196
|
+
? new RegExp(`##\\s+${escaped}\\s*\\n([\\s\\S]*)$`, 'i')
|
|
197
|
+
: new RegExp(`##\\s+${escaped}\\s*\\n([\\s\\S]*?)(?=\\n##\\s|$)`, 'i')
|
|
198
|
+
const match = content.match(pattern)
|
|
199
|
+
if (match) return match[1].trim()
|
|
200
|
+
}
|
|
201
|
+
return ''
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function parseListSection(content: string, headings: string[], limit: number): string[] {
|
|
205
|
+
const section = extractSection(content, headings)
|
|
206
|
+
if (!section) return []
|
|
207
|
+
return section
|
|
208
|
+
.split('\n')
|
|
209
|
+
.filter(line => /^\s*[-*]\s+/.test(line))
|
|
210
|
+
.map(line => line.replace(/^\s*[-*]\s+/, '').trim())
|
|
211
|
+
.filter(Boolean)
|
|
212
|
+
.slice(0, limit)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function parseActions(content: string): MeetingActionItem[] {
|
|
216
|
+
const section = extractSection(content, ['Action Items', 'Tasks', 'Next Steps'])
|
|
217
|
+
if (!section) return []
|
|
218
|
+
return section
|
|
219
|
+
.split('\n')
|
|
220
|
+
.filter(line => /^\s*(?:[-*]|\[[ xX]\])\s+/.test(line))
|
|
221
|
+
.map(line => {
|
|
222
|
+
const cleaned = line
|
|
223
|
+
.replace(/^\s*[-*]\s+/, '')
|
|
224
|
+
.replace(/^\[[ xX]\]\s*/, '')
|
|
225
|
+
.replace(/`\[REVIEW\]`\s*/i, '')
|
|
226
|
+
.trim()
|
|
227
|
+
const ownerMatch = cleaned.match(/\(\*\*(.+?)\*\*\)\s*$/)
|
|
228
|
+
return {
|
|
229
|
+
task: ownerMatch ? cleaned.replace(ownerMatch[0], '').trim() : cleaned,
|
|
230
|
+
owner: ownerMatch ? ownerMatch[1] : '',
|
|
231
|
+
}
|
|
232
|
+
})
|
|
233
|
+
.filter(item => item.task.length > 0)
|
|
234
|
+
.slice(0, 15)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function parseAttendees(content: string): string[] {
|
|
238
|
+
return parseListSection(content, ['Attendees'], 20).map(line => {
|
|
239
|
+
const match = line.match(/^\*\*(.+?)\*\*/) || line.match(/^([^(]+)/)
|
|
240
|
+
return match ? match[1].trim() : line
|
|
241
|
+
})
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function parseDurationMinutes(duration: string): number | undefined {
|
|
245
|
+
if (!duration) return undefined
|
|
246
|
+
const value = duration.toLowerCase()
|
|
247
|
+
const colon = value.match(/\b(\d+):(\d{2})\b/)
|
|
248
|
+
if (colon) return Number(colon[1]) * 60 + Number(colon[2])
|
|
249
|
+
let total = 0
|
|
250
|
+
const hours = value.match(/(\d+(?:\.\d+)?)\s*(?:h|hr|hrs|hour|hours)\b/)
|
|
251
|
+
if (hours) total += Math.round(Number(hours[1]) * 60)
|
|
252
|
+
const minutes = value.match(/(\d+)\s*(?:m|min|mins|minute|minutes)\b/)
|
|
253
|
+
if (minutes) total += Number(minutes[1])
|
|
254
|
+
return total > 0 ? total : undefined
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function parseMeeting(content: string, filename: string, month: string): MeetingDetail {
|
|
258
|
+
const heading = content.match(/^#\s+(.+)$/m)?.[1]?.trim()
|
|
259
|
+
const date = parseField(content, 'Date') || filename.match(/^(\d{4}-\d{2}-\d{2})/)?.[1] || 'unknown'
|
|
260
|
+
const domain = parseField(content, 'Domain') || 'personal'
|
|
261
|
+
const duration = parseField(content, 'Duration')
|
|
262
|
+
const transcript = extractSection(content, ['Transcript'], true)
|
|
263
|
+
const storedSummary = extractSection(content, ['Summary'])
|
|
264
|
+
// Standalone recordings have no private enrichment pipeline. Returning the
|
|
265
|
+
// canonical transcript as the detail summary lets the build199 reader review
|
|
266
|
+
// the saved meeting instead of displaying only a placeholder.
|
|
267
|
+
const summary = !storedSummary || /standalone recording|summary unavailable/i.test(storedSummary)
|
|
268
|
+
? transcript
|
|
269
|
+
: storedSummary
|
|
270
|
+
const topics = parseListSection(content, ['Topics Discussed'], 10)
|
|
271
|
+
const decisions = parseListSection(content, ['Decisions', 'Decisions Made'], 10)
|
|
272
|
+
const actionItems = parseActions(content)
|
|
273
|
+
const attendees = parseAttendees(content)
|
|
274
|
+
|
|
275
|
+
return {
|
|
276
|
+
filename,
|
|
277
|
+
title: heading || basename(filename, '.md').split('_').slice(1).join(' ') || 'Untitled Meeting',
|
|
278
|
+
date,
|
|
279
|
+
domain,
|
|
280
|
+
domainAbbr: domain === 'personal' ? 'P' : domain.slice(0, 2).toUpperCase() || '?',
|
|
281
|
+
source: parseField(content, 'Source'),
|
|
282
|
+
duration,
|
|
283
|
+
...(parseDurationMinutes(duration) !== undefined ? { durationMinutes: parseDurationMinutes(duration) } : {}),
|
|
284
|
+
month,
|
|
285
|
+
summary,
|
|
286
|
+
topics,
|
|
287
|
+
decisions,
|
|
288
|
+
actionItems,
|
|
289
|
+
attendees,
|
|
290
|
+
transcript,
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function toMeta(detail: MeetingDetail): MeetingMeta {
|
|
295
|
+
const detailCharEstimate = [
|
|
296
|
+
detail.title,
|
|
297
|
+
detail.date,
|
|
298
|
+
detail.duration || detail.source,
|
|
299
|
+
detail.summary,
|
|
300
|
+
detail.topics.join('\n'),
|
|
301
|
+
detail.decisions.join('\n'),
|
|
302
|
+
detail.actionItems.map(item => `${item.owner ? `[${item.owner}] ` : ''}${item.task}`).join('\n'),
|
|
303
|
+
detail.attendees.join(', '),
|
|
304
|
+
].join('\n\n').trim().length
|
|
305
|
+
return {
|
|
306
|
+
filename: detail.filename,
|
|
307
|
+
title: detail.title,
|
|
308
|
+
date: detail.date,
|
|
309
|
+
domain: detail.domain,
|
|
310
|
+
domainAbbr: detail.domainAbbr,
|
|
311
|
+
source: detail.source,
|
|
312
|
+
duration: detail.duration,
|
|
313
|
+
...(detail.durationMinutes !== undefined ? { durationMinutes: detail.durationMinutes } : {}),
|
|
314
|
+
month: detail.month,
|
|
315
|
+
detailCharEstimate,
|
|
316
|
+
estimatedDetailPages: Math.max(1, Math.ceil(detailCharEstimate / DETAIL_CHUNK_ESTIMATE_CHARS)),
|
|
317
|
+
topicCount: detail.topics.length,
|
|
318
|
+
decisionCount: detail.decisions.length,
|
|
319
|
+
actionCount: detail.actionItems.length,
|
|
320
|
+
attendeeCount: detail.attendees.length,
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function isContained(parent: string, child: string): boolean {
|
|
325
|
+
return child === parent || child.startsWith(`${parent}${sep}`)
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export class MeetingStore {
|
|
329
|
+
readonly root: string
|
|
330
|
+
|
|
331
|
+
constructor(root = dataPath('recordings')) {
|
|
332
|
+
this.root = resolve(root)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
save(input: SaveMeetingInput): SavedMeeting {
|
|
336
|
+
const sessionId = normalizeSessionId(input.sessionId)
|
|
337
|
+
const domain = normalizeDomain(input.domain)
|
|
338
|
+
const transcript = input.transcript
|
|
339
|
+
.replace(/\u0000/g, '')
|
|
340
|
+
.replace(/\r\n?/g, '\n')
|
|
341
|
+
.trim()
|
|
342
|
+
if (!transcript) throw new MeetingStoreError('Transcript is empty', 400, 'empty_transcript')
|
|
343
|
+
|
|
344
|
+
const parts = localParts(input.startTime)
|
|
345
|
+
const fallbackTitle = `G2 Recording ${parts.date} ${parts.hourMinute}`
|
|
346
|
+
const title = normalizeTitle(input.title, fallbackTitle)
|
|
347
|
+
const stem = filenameStem(title, `G2_Recording_${parts.hourMinute}`)
|
|
348
|
+
const durationMs = Number.isFinite(input.durationMs) ? Math.max(0, input.durationMs) : 0
|
|
349
|
+
const durationMin = Math.round(durationMs / 60_000)
|
|
350
|
+
|
|
351
|
+
ensurePrivateDirectory(this.root)
|
|
352
|
+
const meetingDir = join(this.root, parts.month)
|
|
353
|
+
ensurePrivateDirectory(meetingDir)
|
|
354
|
+
|
|
355
|
+
const suffix = createHash('sha256').update(sessionId).digest('hex').slice(0, 8)
|
|
356
|
+
// Include the session-derived suffix up front. The process-wide server lock
|
|
357
|
+
// gives us one writer, and distinct sessions cannot choose the same target
|
|
358
|
+
// merely because their title/date match.
|
|
359
|
+
let filename = `${parts.date}_${stem}_${suffix}.md`
|
|
360
|
+
let filepath = join(meetingDir, filename)
|
|
361
|
+
if (existsSync(filepath) || existsSync(filepath.replace(/\.md$/, '.g2-chunks.json'))) {
|
|
362
|
+
let collision = 2
|
|
363
|
+
while (existsSync(filepath) || existsSync(filepath.replace(/\.md$/, '.g2-chunks.json'))) {
|
|
364
|
+
filename = `${parts.date}_${stem}_${suffix}_${collision}.md`
|
|
365
|
+
filepath = join(meetingDir, filename)
|
|
366
|
+
collision++
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
const sidecarPath = filepath.replace(/\.md$/, '.g2-chunks.json')
|
|
370
|
+
|
|
371
|
+
const missing = input.transferIntegrity?.missingIndices.length ?? 0
|
|
372
|
+
const completenessPct = input.transferIntegrity
|
|
373
|
+
? Math.floor(input.transferIntegrity.completeness * 1_000) / 10
|
|
374
|
+
: 100
|
|
375
|
+
const integrityValue = missing > 0
|
|
376
|
+
? `${completenessPct}% — ${missing} chunk${missing === 1 ? '' : 's'} not received`
|
|
377
|
+
: '100%'
|
|
378
|
+
const markdown = [
|
|
379
|
+
`# ${title}`,
|
|
380
|
+
'',
|
|
381
|
+
'| Field | Value |',
|
|
382
|
+
'|-------|-------|',
|
|
383
|
+
`| **Date** | ${parts.date} |`,
|
|
384
|
+
`| **Time** | ${parts.time} |`,
|
|
385
|
+
`| **Duration** | ${durationMin} minutes |`,
|
|
386
|
+
'| **Source** | G2 Glasses |',
|
|
387
|
+
`| **Domain** | ${escapeTableValue(domain)} |`,
|
|
388
|
+
`| **Transfer integrity** | ${integrityValue} |`,
|
|
389
|
+
'| **Transcription quality** | streaming |',
|
|
390
|
+
'',
|
|
391
|
+
'## Summary',
|
|
392
|
+
'',
|
|
393
|
+
'*Standalone recording — canonical transcript shown in meeting detail.*',
|
|
394
|
+
'',
|
|
395
|
+
'## Transcript',
|
|
396
|
+
'',
|
|
397
|
+
transcript,
|
|
398
|
+
'',
|
|
399
|
+
].join('\n')
|
|
400
|
+
|
|
401
|
+
const providers = [...new Set(input.chunks.map(chunk => chunk.asrProvider || 'server-whisper'))]
|
|
402
|
+
const sidecar = {
|
|
403
|
+
schemaVersion: 2,
|
|
404
|
+
sessionId,
|
|
405
|
+
startTime: input.startTime,
|
|
406
|
+
durationMs,
|
|
407
|
+
domain,
|
|
408
|
+
title,
|
|
409
|
+
canonicalProvider: canonicalProvider(input.chunks),
|
|
410
|
+
providers,
|
|
411
|
+
providerCandidates: input.providerCandidates ?? {},
|
|
412
|
+
speakers: [...new Set(input.chunks.map(chunk => chunk.speaker).filter(speaker => speaker && speaker !== 'Ext'))],
|
|
413
|
+
chunks: input.chunks,
|
|
414
|
+
chunkEntries: input.chunkEntries ?? input.chunks.map((chunk, chunkIndex) => ({ chunkIndex, chunk })),
|
|
415
|
+
transferIntegrity: input.transferIntegrity ?? null,
|
|
416
|
+
transcriptionQuality: 'streaming',
|
|
417
|
+
batchApplied: false,
|
|
418
|
+
streamingWordCount: wordCount(transcript),
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Sidecar first, markdown second: the markdown is the visible commit marker.
|
|
422
|
+
// A crash can leave an orphan sidecar, but never a listed meeting whose
|
|
423
|
+
// canonical chunk metadata was not durably published.
|
|
424
|
+
durableAtomicWriteFileSync(sidecarPath, JSON.stringify(sidecar, null, 2), { mode: 0o600 })
|
|
425
|
+
try {
|
|
426
|
+
durableAtomicWriteFileSync(filepath, markdown, { mode: 0o600 })
|
|
427
|
+
} catch (error) {
|
|
428
|
+
try { unlinkSync(sidecarPath) } catch { /* orphan stays hidden without markdown */ }
|
|
429
|
+
throw error
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
return {
|
|
433
|
+
filepath,
|
|
434
|
+
sidecarPath,
|
|
435
|
+
filename,
|
|
436
|
+
month: parts.month,
|
|
437
|
+
title,
|
|
438
|
+
domain,
|
|
439
|
+
durationMin,
|
|
440
|
+
transferIntegrity: input.transferIntegrity ?? null,
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** Durable idempotency lookup for a client retry after its save response was lost. */
|
|
445
|
+
findBySessionId(rawSessionId: string): SavedMeeting | null {
|
|
446
|
+
const sessionId = normalizeSessionId(rawSessionId)
|
|
447
|
+
const rootReal = this.existingRootRealpath()
|
|
448
|
+
if (!rootReal) return null
|
|
449
|
+
for (const month of readdirSync(this.root).filter(name => MONTH_PATTERN.test(name)).sort().reverse()) {
|
|
450
|
+
const monthDir = join(this.root, month)
|
|
451
|
+
const monthReal = this.safeDirectoryRealpath(monthDir, rootReal)
|
|
452
|
+
if (!monthReal) continue
|
|
453
|
+
const sidecars = readdirSync(monthDir)
|
|
454
|
+
.filter(name => /^\d{4}-\d{2}-\d{2}_[A-Za-z0-9][A-Za-z0-9_-]{0,95}\.g2-chunks\.json$/.test(name))
|
|
455
|
+
.sort()
|
|
456
|
+
.reverse()
|
|
457
|
+
for (const sidecarName of sidecars) {
|
|
458
|
+
const sidecarText = this.safeReadFile(monthDir, monthReal, sidecarName)
|
|
459
|
+
if (sidecarText === null) continue
|
|
460
|
+
try {
|
|
461
|
+
const sidecar = JSON.parse(sidecarText) as Record<string, unknown>
|
|
462
|
+
if (sidecar.sessionId !== sessionId) continue
|
|
463
|
+
const filename = sidecarName.replace(/\.g2-chunks\.json$/, '.md')
|
|
464
|
+
const markdown = this.safeReadMeeting(monthDir, monthReal, filename)
|
|
465
|
+
if (markdown === null) continue
|
|
466
|
+
const detail = parseMeeting(markdown, filename, month)
|
|
467
|
+
const durationMs = typeof sidecar.durationMs === 'number' && Number.isFinite(sidecar.durationMs)
|
|
468
|
+
? Math.max(0, sidecar.durationMs)
|
|
469
|
+
: 0
|
|
470
|
+
return {
|
|
471
|
+
filepath: join(monthDir, filename),
|
|
472
|
+
sidecarPath: join(monthDir, sidecarName),
|
|
473
|
+
filename,
|
|
474
|
+
month,
|
|
475
|
+
title: detail.title,
|
|
476
|
+
domain: detail.domain,
|
|
477
|
+
durationMin: Math.round(durationMs / 60_000),
|
|
478
|
+
transferIntegrity: sidecar.transferIntegrity as TranscriptGapReport | null | undefined,
|
|
479
|
+
}
|
|
480
|
+
} catch {
|
|
481
|
+
// Malformed diagnostic metadata is not a valid idempotency record.
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
return null
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
list(options: { limit?: number; domain?: string } = {}): MeetingMeta[] {
|
|
489
|
+
const limit = Math.max(1, Math.min(50, Math.trunc(options.limit ?? 20)))
|
|
490
|
+
const domain = options.domain ?? 'all'
|
|
491
|
+
if (domain !== 'all' && !DOMAIN_PATTERN.test(domain)) {
|
|
492
|
+
throw new MeetingStoreError('Invalid domain filter', 400, 'invalid_domain')
|
|
493
|
+
}
|
|
494
|
+
const rootReal = this.existingRootRealpath()
|
|
495
|
+
if (!rootReal) return []
|
|
496
|
+
const meetings: MeetingMeta[] = []
|
|
497
|
+
|
|
498
|
+
for (const month of readdirSync(this.root).filter(name => MONTH_PATTERN.test(name)).sort().reverse()) {
|
|
499
|
+
const monthDir = join(this.root, month)
|
|
500
|
+
const monthReal = this.safeDirectoryRealpath(monthDir, rootReal)
|
|
501
|
+
if (!monthReal) continue
|
|
502
|
+
for (const filename of readdirSync(monthDir).filter(name => SAFE_FILENAME_PATTERN.test(name)).sort().reverse()) {
|
|
503
|
+
try {
|
|
504
|
+
const content = this.safeReadMeeting(monthDir, monthReal, filename)
|
|
505
|
+
if (content === null) continue
|
|
506
|
+
const detail = parseMeeting(content, filename, month)
|
|
507
|
+
if (domain !== 'all' && detail.domain !== domain) continue
|
|
508
|
+
meetings.push(toMeta(detail))
|
|
509
|
+
} catch {
|
|
510
|
+
// One unreadable/corrupt entry must not hide the rest of the store.
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
meetings.sort((left, right) => (
|
|
515
|
+
right.date.localeCompare(left.date) || right.filename.localeCompare(left.filename)
|
|
516
|
+
))
|
|
517
|
+
return meetings.slice(0, limit)
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
detail(domain: string, month: string, filename: string): MeetingDetail {
|
|
521
|
+
if (!DOMAIN_PATTERN.test(domain)) {
|
|
522
|
+
throw new MeetingStoreError('Invalid domain', 400, 'invalid_domain')
|
|
523
|
+
}
|
|
524
|
+
if (!MONTH_PATTERN.test(month)) {
|
|
525
|
+
throw new MeetingStoreError('Invalid month', 400, 'invalid_month')
|
|
526
|
+
}
|
|
527
|
+
if (!SAFE_FILENAME_PATTERN.test(filename) || basename(filename) !== filename) {
|
|
528
|
+
throw new MeetingStoreError('Invalid filename', 400, 'invalid_filename')
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
const rootReal = this.existingRootRealpath()
|
|
532
|
+
if (!rootReal) throw new MeetingStoreError('Meeting not found', 404, 'meeting_not_found')
|
|
533
|
+
const monthDir = join(this.root, month)
|
|
534
|
+
const monthReal = this.safeDirectoryRealpath(monthDir, rootReal)
|
|
535
|
+
if (!monthReal) throw new MeetingStoreError('Meeting not found', 404, 'meeting_not_found')
|
|
536
|
+
const content = this.safeReadMeeting(monthDir, monthReal, filename)
|
|
537
|
+
if (content === null) throw new MeetingStoreError('Meeting not found', 404, 'meeting_not_found')
|
|
538
|
+
const detail = parseMeeting(content, filename, month)
|
|
539
|
+
if (detail.domain !== domain) throw new MeetingStoreError('Meeting not found', 404, 'meeting_not_found')
|
|
540
|
+
return detail
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
private existingRootRealpath(): string | null {
|
|
544
|
+
if (!existsSync(this.root)) return null
|
|
545
|
+
const stat = lstatSync(this.root)
|
|
546
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
547
|
+
throw new MeetingStoreError('Unsafe recordings directory', 500, 'unsafe_recordings_store')
|
|
548
|
+
}
|
|
549
|
+
return realpathSync(this.root)
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
private safeDirectoryRealpath(path: string, parentReal: string): string | null {
|
|
553
|
+
try {
|
|
554
|
+
const stat = lstatSync(path)
|
|
555
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) return null
|
|
556
|
+
const real = realpathSync(path)
|
|
557
|
+
return isContained(parentReal, real) && dirname(real) === parentReal ? real : null
|
|
558
|
+
} catch {
|
|
559
|
+
return null
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
private safeReadMeeting(monthDir: string, monthReal: string, filename: string): string | null {
|
|
564
|
+
return this.safeReadFile(monthDir, monthReal, filename)
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
private safeReadFile(monthDir: string, monthReal: string, filename: string): string | null {
|
|
568
|
+
const filepath = join(monthDir, filename)
|
|
569
|
+
let fd: number | null = null
|
|
570
|
+
try {
|
|
571
|
+
const linkStat = lstatSync(filepath)
|
|
572
|
+
if (linkStat.isSymbolicLink() || !linkStat.isFile()) return null
|
|
573
|
+
const real = realpathSync(filepath)
|
|
574
|
+
if (!isContained(monthReal, real) || dirname(real) !== monthReal) return null
|
|
575
|
+
fd = openSync(filepath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0))
|
|
576
|
+
const stat = fstatSync(fd)
|
|
577
|
+
if (!stat.isFile() || stat.size > MAX_MEETING_BYTES) return null
|
|
578
|
+
return readFileSync(fd, 'utf8')
|
|
579
|
+
} catch {
|
|
580
|
+
return null
|
|
581
|
+
} finally {
|
|
582
|
+
if (fd !== null) {
|
|
583
|
+
try { closeSync(fd) } catch { /* already closed */ }
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
let defaultMeetingStore: MeetingStore | null = null
|
|
590
|
+
|
|
591
|
+
export function getMeetingStore(): MeetingStore {
|
|
592
|
+
defaultMeetingStore ??= new MeetingStore()
|
|
593
|
+
return defaultMeetingStore
|
|
594
|
+
}
|
package/server/routes/health.ts
CHANGED
|
@@ -113,6 +113,7 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
113
113
|
cos_pipeline: COS_MODE,
|
|
114
114
|
whisper: isWhisperLocalAvailable(),
|
|
115
115
|
promptRecovery: true,
|
|
116
|
+
meetingFinalization: true,
|
|
116
117
|
iphoneAsrCandidates: process.env.COS_IOS_ASR_CANDIDATES === '1',
|
|
117
118
|
mediaProcessingReady: await isMediaProcessingReady(),
|
|
118
119
|
g2LensVariant: G2_LENS_VARIANT_CAPABILITY,
|