@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.
- package/CHANGELOG.md +25 -0
- package/README.md +24 -0
- package/bin/cli.cjs +22 -0
- package/bin/hooks/cos-session-hook +43 -0
- package/managed-runtime-contract.json +7 -1
- package/package.json +8 -2
- package/server/index.ts +85 -0
- package/server/lib/claude-hooks-installer.ts +403 -0
- package/server/lib/claude-session-registry.ts +25 -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/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/session-hook-events.ts +200 -0
- package/server/lib/session-hook-ledger.ts +129 -0
- package/server/lib/session-hook-spool.ts +264 -0
- package/server/lib/session-hooks-runtime.ts +229 -0
- package/server/lib/session-signal-store.ts +361 -0
- package/server/lib/session-state-derive.ts +211 -0
- package/server/lib/voice-evidence-guard.ts +87 -0
- package/server/routes/agent-sessions.ts +56 -6
- package/server/routes/claude-sessions.ts +32 -5
- package/server/routes/fireflies-key.ts +102 -0
- package/server/routes/health.ts +2 -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 +177 -50
- package/server/routes/session-hooks.ts +70 -0
- package/server/routes/voice.ts +18 -0
- package/server/scripts/hooks-cli.ts +48 -0
- package/server/lib/__fixtures__/query-jobs-6.43.3/2099-01-01.jsonl +0 -2
|
@@ -1,23 +1,23 @@
|
|
|
1
1
|
import {
|
|
2
2
|
chmodSync,
|
|
3
|
-
closeSync,
|
|
4
|
-
constants,
|
|
5
3
|
existsSync,
|
|
6
|
-
fstatSync,
|
|
7
4
|
lstatSync,
|
|
8
5
|
mkdirSync,
|
|
9
|
-
openSync,
|
|
10
|
-
readFileSync,
|
|
11
|
-
readSync,
|
|
12
6
|
readdirSync,
|
|
13
|
-
realpathSync,
|
|
14
7
|
unlinkSync,
|
|
15
8
|
} from 'node:fs'
|
|
16
9
|
import { createHash } from 'node:crypto'
|
|
17
|
-
import { basename,
|
|
10
|
+
import { basename, join, resolve } from 'node:path'
|
|
18
11
|
import { durableAtomicWriteFileSync } from './atomic-fs.js'
|
|
19
12
|
import { dataPath } from './data-dir.js'
|
|
20
|
-
import { FALLBACK_DOMAIN,
|
|
13
|
+
import { FALLBACK_DOMAIN, isSafeDomainName } from './domains.js'
|
|
14
|
+
import {
|
|
15
|
+
existingRootRealpath,
|
|
16
|
+
safeDirectoryRealpath,
|
|
17
|
+
safeReadFile,
|
|
18
|
+
safeReadFileHead,
|
|
19
|
+
} from './meeting-file-guards.js'
|
|
20
|
+
import { parseField, parseMeeting, toMeta } from './meeting-parse.js'
|
|
21
21
|
import type {
|
|
22
22
|
ProviderCandidateRecord,
|
|
23
23
|
IndexedTranscriptChunk,
|
|
@@ -28,6 +28,9 @@ import type {
|
|
|
28
28
|
const MONTH_PATTERN = /^\d{4}-(0[1-9]|1[0-2])$/
|
|
29
29
|
const DAY_PATTERN = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/
|
|
30
30
|
const DAY_FILE_PREFIX = /^(\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01]))_/
|
|
31
|
+
|
|
32
|
+
/** Enough to clear a scribe's metadata table whatever order its rows are in. */
|
|
33
|
+
const DOMAIN_HEAD_BYTES = 4096
|
|
31
34
|
const LIST_CAP = 50
|
|
32
35
|
const LIST_CAP_SCOPED = 200
|
|
33
36
|
|
|
@@ -51,9 +54,19 @@ export function meetingDayCountsFromNames(names: string[]): Array<{ date: string
|
|
|
51
54
|
|
|
52
55
|
const SAFE_FILENAME_PATTERN = /^\d{4}-\d{2}-\d{2}_[A-Za-z0-9][A-Za-z0-9_-]{0,95}\.md$/
|
|
53
56
|
const DOMAIN_PATTERN = /^[a-z][a-z0-9_]{0,31}$/
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
+
|
|
58
|
+
// The parser and the filesystem guards moved to their own modules in 6.47.0 so
|
|
59
|
+
// the imported library can share them rather than grow a second copy. They are
|
|
60
|
+
// re-exported here because every existing caller and test imports them from
|
|
61
|
+
// this module, and a shared helper is not a reason to move anyone's import.
|
|
62
|
+
export {
|
|
63
|
+
MEETING_SOURCE_MAX_BYTES,
|
|
64
|
+
boundedMeetingSource,
|
|
65
|
+
parseField,
|
|
66
|
+
parseMeeting,
|
|
67
|
+
toMeta,
|
|
68
|
+
} from './meeting-parse.js'
|
|
69
|
+
|
|
57
70
|
|
|
58
71
|
export class MeetingStoreError extends Error {
|
|
59
72
|
constructor(
|
|
@@ -88,10 +101,39 @@ export interface MeetingMeta {
|
|
|
88
101
|
decisionCount?: number
|
|
89
102
|
actionCount?: number
|
|
90
103
|
attendeeCount?: number
|
|
91
|
-
/** Additive archive identity. Older companions ignore these fields.
|
|
92
|
-
|
|
104
|
+
/** Additive archive identity. Older companions ignore these fields.
|
|
105
|
+
*
|
|
106
|
+
* `imported` is a meeting this Mac never recorded (Fireflies, 6.47.0) and
|
|
107
|
+
* `blended` is a record DERIVED from other records — a merge of one meeting's
|
|
108
|
+
* Fireflies transcript with its G2 captures, or one piece of a split long
|
|
109
|
+
* recording. Both are read-only: their content is a function of their inputs,
|
|
110
|
+
* so editing one would be overwritten by the next re-derive. */
|
|
111
|
+
librarySource?: 'direct_library' | 'cos_operations' | 'standalone_recordings' | 'imported' | 'blended'
|
|
93
112
|
recordId?: string
|
|
94
113
|
mutable?: boolean
|
|
114
|
+
/** The vendor's own id for an imported meeting (the Fireflies transcript id).
|
|
115
|
+
* Carried so no client has to re-derive `imported:fireflies:<h16>` from a hash
|
|
116
|
+
* rule written in a comment. Absent on every non-imported row. */
|
|
117
|
+
vendorId?: string
|
|
118
|
+
/** Where the meeting actually came from, when `domain` is a routing value
|
|
119
|
+
* rather than the meeting's own domain. Every imported row carries
|
|
120
|
+
* `domain: 'imported'`, so without this a Quilt call and a personal one are
|
|
121
|
+
* indistinguishable on the row. */
|
|
122
|
+
originDomain?: string
|
|
123
|
+
/** How a `blended` record was derived. Absent on every other source. */
|
|
124
|
+
derivedKind?: 'merge' | 'split'
|
|
125
|
+
/** The action that produced a derived record, so Control can offer its Undo. */
|
|
126
|
+
actionId?: string
|
|
127
|
+
/** Every G2 capture a merged record holds, in start order. `sessionId` carries
|
|
128
|
+
* the earliest of them so session-keyed surfaces keep working. */
|
|
129
|
+
g2SessionIds?: string[]
|
|
130
|
+
/** A split piece carries no `sessionId` of its own — it is a span of a longer
|
|
131
|
+
* recording, not a capture — but names the capture whose content defined it. */
|
|
132
|
+
sourceSessionId?: string
|
|
133
|
+
/** 0-based position of a split piece inside its source recording. */
|
|
134
|
+
pieceIndex?: number
|
|
135
|
+
/** Inputs of a derived record, for the detail view's source links. */
|
|
136
|
+
sources?: Array<{ kind: 'g2' | 'fireflies'; id: string; recordId?: string }>
|
|
95
137
|
/** Present only when the server can state a truthful local record. */
|
|
96
138
|
canonicalRecord?: string
|
|
97
139
|
/** Additive. Unique sidecar speakers + whether a human correction landed. */
|
|
@@ -241,180 +283,9 @@ function canonicalProvider(chunks: TranscriptChunk[]): 'server-whisper' | 'iphon
|
|
|
241
283
|
return 'mixed'
|
|
242
284
|
}
|
|
243
285
|
|
|
244
|
-
function parseField(content: string, field: string): string {
|
|
245
|
-
const escaped = field.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
246
|
-
const table = content.match(new RegExp(`\\*\\*${escaped}\\*\\*\\s*\\|\\s*(.+)`, 'i'))
|
|
247
|
-
if (table) return table[1].replace(/\s*\|?\s*$/, '').trim()
|
|
248
|
-
const plain = content.match(new RegExp(`\\*\\*${escaped}:\\*\\*\\s*(.+)`, 'i'))
|
|
249
|
-
return plain ? plain[1].trim() : ''
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
function extractSection(content: string, headings: string[], toEnd = false): string {
|
|
253
|
-
for (const heading of headings) {
|
|
254
|
-
const escaped = heading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
255
|
-
const pattern = toEnd
|
|
256
|
-
? new RegExp(`##\\s+${escaped}\\s*\\n([\\s\\S]*)$`, 'i')
|
|
257
|
-
: new RegExp(`##\\s+${escaped}\\s*\\n([\\s\\S]*?)(?=\\n##\\s|$)`, 'i')
|
|
258
|
-
const match = content.match(pattern)
|
|
259
|
-
if (match) return match[1].trim()
|
|
260
|
-
}
|
|
261
|
-
return ''
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
function parseListSection(content: string, headings: string[], limit: number): string[] {
|
|
265
|
-
const section = extractSection(content, headings)
|
|
266
|
-
if (!section) return []
|
|
267
|
-
return section
|
|
268
|
-
.split('\n')
|
|
269
|
-
.filter(line => /^\s*[-*]\s+/.test(line))
|
|
270
|
-
.map(line => line.replace(/^\s*[-*]\s+/, '').trim())
|
|
271
|
-
.filter(Boolean)
|
|
272
|
-
.slice(0, limit)
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
function parseActions(content: string): MeetingActionItem[] {
|
|
276
|
-
const section = extractSection(content, ['Action Items', 'Tasks', 'Next Steps'])
|
|
277
|
-
if (!section) return []
|
|
278
|
-
return section
|
|
279
|
-
.split('\n')
|
|
280
|
-
.filter(line => /^\s*(?:[-*]|\[[ xX]\])\s+/.test(line))
|
|
281
|
-
.map(line => {
|
|
282
|
-
const cleaned = line
|
|
283
|
-
.replace(/^\s*[-*]\s+/, '')
|
|
284
|
-
.replace(/^\[[ xX]\]\s*/, '')
|
|
285
|
-
.replace(/`\[REVIEW\]`\s*/i, '')
|
|
286
|
-
.trim()
|
|
287
|
-
const ownerMatch = cleaned.match(/\(\*\*(.+?)\*\*\)\s*$/)
|
|
288
|
-
return {
|
|
289
|
-
task: ownerMatch ? cleaned.replace(ownerMatch[0], '').trim() : cleaned,
|
|
290
|
-
owner: ownerMatch ? ownerMatch[1] : '',
|
|
291
|
-
}
|
|
292
|
-
})
|
|
293
|
-
.filter(item => item.task.length > 0)
|
|
294
|
-
.slice(0, 15)
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
function parseAttendees(content: string): string[] {
|
|
298
|
-
return parseListSection(content, ['Attendees'], 20).map(line => {
|
|
299
|
-
const match = line.match(/^\*\*(.+?)\*\*/) || line.match(/^([^(]+)/)
|
|
300
|
-
return match ? match[1].trim() : line
|
|
301
|
-
})
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
function parseDurationMinutes(duration: string): number | undefined {
|
|
305
|
-
if (!duration) return undefined
|
|
306
|
-
const value = duration.toLowerCase()
|
|
307
|
-
const colon = value.match(/\b(\d+):(\d{2})\b/)
|
|
308
|
-
if (colon) return Number(colon[1]) * 60 + Number(colon[2])
|
|
309
|
-
let total = 0
|
|
310
|
-
const hours = value.match(/(\d+(?:\.\d+)?)\s*(?:h|hr|hrs|hour|hours)\b/)
|
|
311
|
-
if (hours) total += Math.round(Number(hours[1]) * 60)
|
|
312
|
-
const minutes = value.match(/(\d+)\s*(?:m|min|mins|minute|minutes)\b/)
|
|
313
|
-
if (minutes) total += Number(minutes[1])
|
|
314
|
-
return total > 0 ? total : undefined
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
function parseMeeting(content: string, filename: string, month: string): MeetingDetail {
|
|
318
|
-
const heading = content.match(/^#\s+(.+)$/m)?.[1]?.trim()
|
|
319
|
-
const date = parseField(content, 'Date') || filename.match(/^(\d{4}-\d{2}-\d{2})/)?.[1] || 'unknown'
|
|
320
|
-
const domain = parseField(content, 'Domain') || FALLBACK_DOMAIN
|
|
321
|
-
const duration = parseField(content, 'Duration')
|
|
322
|
-
const transcript = extractSection(content, ['Transcript'], true)
|
|
323
|
-
const storedSummary = extractSection(content, ['Summary'])
|
|
324
|
-
// A placeholder is not a summary. Until 6.37 this returned the TRANSCRIPT as
|
|
325
|
-
// the summary, which put the transcript in the summary slot on every surface
|
|
326
|
-
// and read as "the summary is broken" — the bug this replaces. The transcript
|
|
327
|
-
// is returned in its own `transcript` field and each surface renders it in
|
|
328
|
-
// its own section. An empty summary is the honest state, and every consumer
|
|
329
|
-
// already handles it: display-pages.ts falls back to 'No summary available.',
|
|
330
|
-
// and COS Control's Copy-summary button correctly disables on empty.
|
|
331
|
-
const summary = !storedSummary || /standalone recording|summary unavailable/i.test(storedSummary)
|
|
332
|
-
? ''
|
|
333
|
-
: storedSummary
|
|
334
|
-
const topics = parseListSection(content, ['Topics Discussed'], 10)
|
|
335
|
-
const decisions = parseListSection(content, ['Decisions', 'Decisions Made'], 10)
|
|
336
|
-
const actionItems = parseActions(content)
|
|
337
|
-
const attendees = parseAttendees(content)
|
|
338
|
-
const source = boundedMeetingSource(content)
|
|
339
|
-
|
|
340
|
-
return {
|
|
341
|
-
filename,
|
|
342
|
-
title: heading || basename(filename, '.md').split('_').slice(1).join(' ') || 'Untitled Meeting',
|
|
343
|
-
date,
|
|
344
|
-
domain,
|
|
345
|
-
// Shared derivation. This was slice(0,2), which rendered sprocket_rocket as
|
|
346
|
-
// "SP" while cos-operations-meetings rendered it "SR" — two schemes in one
|
|
347
|
-
// codebase, disagreeing with each other.
|
|
348
|
-
domainAbbr: domainAbbreviation(domain),
|
|
349
|
-
source: parseField(content, 'Source'),
|
|
350
|
-
duration,
|
|
351
|
-
...(parseDurationMinutes(duration) !== undefined ? { durationMinutes: parseDurationMinutes(duration) } : {}),
|
|
352
|
-
month,
|
|
353
|
-
summary,
|
|
354
|
-
topics,
|
|
355
|
-
decisions,
|
|
356
|
-
actionItems,
|
|
357
|
-
attendees,
|
|
358
|
-
transcript,
|
|
359
|
-
...source,
|
|
360
|
-
}
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
/** Return enough canonical source for grounded meeting follow-ups without
|
|
364
|
-
* allowing an unexpectedly large archive record to inflate every response.
|
|
365
|
-
* The boundary backs up over UTF-8 continuation bytes so the prefix never
|
|
366
|
-
* ends with a replacement character. */
|
|
367
|
-
export function boundedMeetingSource(content: string): { sourceContent: string; sourceTruncated: boolean } {
|
|
368
|
-
const bytes = Buffer.from(content, 'utf8')
|
|
369
|
-
if (bytes.length <= MEETING_SOURCE_MAX_BYTES) return { sourceContent: content, sourceTruncated: false }
|
|
370
|
-
let end = MEETING_SOURCE_MAX_BYTES
|
|
371
|
-
while (end > 0 && (bytes[end] & 0xc0) === 0x80) end -= 1
|
|
372
|
-
return { sourceContent: bytes.subarray(0, end).toString('utf8'), sourceTruncated: true }
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
function toMeta(detail: MeetingDetail, sessionId?: string): MeetingMeta {
|
|
376
|
-
// Must mirror what the reader actually renders (display-pages.ts
|
|
377
|
-
// formatMeetingDetailBody), or the list row advertises a page count the
|
|
378
|
-
// reader does not honour. The transcript is part of that body since 6.37;
|
|
379
|
-
// omitting it here reported every standalone meeting as ~1p.
|
|
380
|
-
const detailCharEstimate = [
|
|
381
|
-
detail.title,
|
|
382
|
-
detail.date,
|
|
383
|
-
detail.duration || detail.source,
|
|
384
|
-
detail.summary,
|
|
385
|
-
detail.topics.join('\n'),
|
|
386
|
-
detail.decisions.join('\n'),
|
|
387
|
-
detail.actionItems.map(item => `${item.owner ? `[${item.owner}] ` : ''}${item.task}`).join('\n'),
|
|
388
|
-
detail.attendees.join(', '),
|
|
389
|
-
detail.transcript,
|
|
390
|
-
].join('\n\n').trim().length
|
|
391
|
-
return {
|
|
392
|
-
filename: detail.filename,
|
|
393
|
-
...(sessionId ? { sessionId } : {}),
|
|
394
|
-
title: detail.title,
|
|
395
|
-
date: detail.date,
|
|
396
|
-
domain: detail.domain,
|
|
397
|
-
domainAbbr: detail.domainAbbr,
|
|
398
|
-
source: detail.source,
|
|
399
|
-
duration: detail.duration,
|
|
400
|
-
...(detail.durationMinutes !== undefined ? { durationMinutes: detail.durationMinutes } : {}),
|
|
401
|
-
month: detail.month,
|
|
402
|
-
detailCharEstimate,
|
|
403
|
-
estimatedDetailPages: Math.max(1, Math.ceil(detailCharEstimate / DETAIL_CHUNK_ESTIMATE_CHARS)),
|
|
404
|
-
topicCount: detail.topics.length,
|
|
405
|
-
decisionCount: detail.decisions.length,
|
|
406
|
-
actionCount: detail.actionItems.length,
|
|
407
|
-
attendeeCount: detail.attendees.length,
|
|
408
|
-
}
|
|
409
|
-
}
|
|
410
|
-
|
|
411
286
|
/** Enough to clear the sidecar's leading metadata keys whatever their order. */
|
|
412
287
|
const SIDECAR_HEAD_BYTES = 4096
|
|
413
288
|
|
|
414
|
-
function isContained(parent: string, child: string): boolean {
|
|
415
|
-
return child === parent || child.startsWith(`${parent}${sep}`)
|
|
416
|
-
}
|
|
417
|
-
|
|
418
289
|
export class MeetingStore {
|
|
419
290
|
readonly root: string
|
|
420
291
|
|
|
@@ -548,24 +419,24 @@ export class MeetingStore {
|
|
|
548
419
|
/** Durable idempotency lookup for a client retry after its save response was lost. */
|
|
549
420
|
findBySessionId(rawSessionId: string): SavedMeeting | null {
|
|
550
421
|
const sessionId = normalizeSessionId(rawSessionId)
|
|
551
|
-
const rootReal = this.
|
|
422
|
+
const rootReal = this.rootRealpath()
|
|
552
423
|
if (!rootReal) return null
|
|
553
424
|
for (const month of readdirSync(this.root).filter(name => MONTH_PATTERN.test(name)).sort().reverse()) {
|
|
554
425
|
const monthDir = join(this.root, month)
|
|
555
|
-
const monthReal =
|
|
426
|
+
const monthReal = safeDirectoryRealpath(monthDir, rootReal)
|
|
556
427
|
if (!monthReal) continue
|
|
557
428
|
const sidecars = readdirSync(monthDir)
|
|
558
429
|
.filter(name => /^\d{4}-\d{2}-\d{2}_[A-Za-z0-9][A-Za-z0-9_-]{0,95}\.g2-chunks\.json$/.test(name))
|
|
559
430
|
.sort()
|
|
560
431
|
.reverse()
|
|
561
432
|
for (const sidecarName of sidecars) {
|
|
562
|
-
const sidecarText =
|
|
433
|
+
const sidecarText = safeReadFile(monthDir, monthReal, sidecarName)
|
|
563
434
|
if (sidecarText === null) continue
|
|
564
435
|
try {
|
|
565
436
|
const sidecar = JSON.parse(sidecarText) as Record<string, unknown>
|
|
566
437
|
if (sidecar.sessionId !== sessionId) continue
|
|
567
438
|
const filename = sidecarName.replace(/\.g2-chunks\.json$/, '.md')
|
|
568
|
-
const markdown =
|
|
439
|
+
const markdown = safeReadFile(monthDir, monthReal, filename)
|
|
569
440
|
if (markdown === null) continue
|
|
570
441
|
const detail = parseMeeting(markdown, filename, month)
|
|
571
442
|
const durationMs = typeof sidecar.durationMs === 'number' && Number.isFinite(sidecar.durationMs)
|
|
@@ -602,18 +473,18 @@ export class MeetingStore {
|
|
|
602
473
|
if (options.day && !DAY_PATTERN.test(options.day)) {
|
|
603
474
|
throw new MeetingStoreError('Invalid day filter', 400, 'invalid_day')
|
|
604
475
|
}
|
|
605
|
-
const rootReal = this.
|
|
476
|
+
const rootReal = this.rootRealpath()
|
|
606
477
|
if (!rootReal) return []
|
|
607
478
|
const meetings: MeetingMeta[] = []
|
|
608
479
|
|
|
609
480
|
for (const month of readdirSync(this.root).filter(name => MONTH_PATTERN.test(name)).sort().reverse()) {
|
|
610
481
|
if (options.month && month !== options.month) continue
|
|
611
482
|
const monthDir = join(this.root, month)
|
|
612
|
-
const monthReal =
|
|
483
|
+
const monthReal = safeDirectoryRealpath(monthDir, rootReal)
|
|
613
484
|
if (!monthReal) continue
|
|
614
485
|
for (const filename of readdirSync(monthDir).filter(name => SAFE_FILENAME_PATTERN.test(name)).sort().reverse()) {
|
|
615
486
|
try {
|
|
616
|
-
const content =
|
|
487
|
+
const content = safeReadFile(monthDir, monthReal, filename)
|
|
617
488
|
if (content === null) continue
|
|
618
489
|
const detail = parseMeeting(content, filename, month)
|
|
619
490
|
if (domain !== 'all' && detail.domain !== domain) continue
|
|
@@ -632,21 +503,52 @@ export class MeetingStore {
|
|
|
632
503
|
|
|
633
504
|
/** Folder names only. Used by the Control calendar pager. */
|
|
634
505
|
listMonths(): string[] {
|
|
635
|
-
const rootReal = this.
|
|
506
|
+
const rootReal = this.rootRealpath()
|
|
636
507
|
if (!rootReal) return []
|
|
637
508
|
return readdirSync(this.root)
|
|
638
|
-
.filter(name => MONTH_PATTERN.test(name) &&
|
|
509
|
+
.filter(name => MONTH_PATTERN.test(name) && safeDirectoryRealpath(join(this.root, name), rootReal))
|
|
639
510
|
.sort()
|
|
640
511
|
.reverse()
|
|
641
512
|
}
|
|
642
513
|
|
|
643
|
-
|
|
514
|
+
/**
|
|
515
|
+
* How many recordings this store holds per day in one month.
|
|
516
|
+
*
|
|
517
|
+
* WITHOUT A DOMAIN this is a FILENAME scan: uncapped, opens nothing, and therefore able to
|
|
518
|
+
* describe a whole month the 50-row list cannot. That property is why the calendar uses it.
|
|
519
|
+
*
|
|
520
|
+
* WITH A DOMAIN it has to read, because a recording's domain is inside the file and not in
|
|
521
|
+
* its name. A count that ignored the filter answered "3 meetings" for a day whose filtered
|
|
522
|
+
* list showed one, which is the 6.46.x upgrade bug: on a Mac with zero imports the
|
|
523
|
+
* domain-filtered calendar counted every recording in the store, whatever domain it was.
|
|
524
|
+
*/
|
|
525
|
+
listDayCounts(month: string, domain = 'all'): Array<{ date: string; count: number }> {
|
|
644
526
|
if (!MONTH_PATTERN.test(month)) return []
|
|
645
|
-
const rootReal = this.
|
|
527
|
+
const rootReal = this.rootRealpath()
|
|
646
528
|
if (!rootReal) return []
|
|
647
529
|
const monthDir = join(this.root, month)
|
|
648
|
-
const monthReal =
|
|
530
|
+
const monthReal = safeDirectoryRealpath(monthDir, rootReal)
|
|
649
531
|
if (!monthReal) return []
|
|
532
|
+
if (domain !== 'all') {
|
|
533
|
+
if (!isSafeDomainName(domain)) return []
|
|
534
|
+
const counts = new Map<string, number>()
|
|
535
|
+
// Deliberately NOT `list()`: that caps at 200 rows for a scoped query, and a day count
|
|
536
|
+
// that silently stops counting is worse than one that costs a read. One month, heads
|
|
537
|
+
// only, through the same safe reader the list uses.
|
|
538
|
+
for (const filename of readdirSync(monthDir).filter(name => SAFE_FILENAME_PATTERN.test(name))) {
|
|
539
|
+
try {
|
|
540
|
+
const content = safeReadFileHead(monthDir, monthReal, filename, DOMAIN_HEAD_BYTES)
|
|
541
|
+
if (content === null) continue
|
|
542
|
+
if (parseField(content, 'Domain') !== domain) continue
|
|
543
|
+
const date = filename.match(DAY_FILE_PREFIX)?.[1]
|
|
544
|
+
if (!date) continue
|
|
545
|
+
counts.set(date, (counts.get(date) ?? 0) + 1)
|
|
546
|
+
} catch {
|
|
547
|
+
// One unreadable entry must not hide the rest of the month.
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
return [...counts.entries()].sort((a, b) => a[0].localeCompare(b[0])).map(([date, count]) => ({ date, count }))
|
|
551
|
+
}
|
|
650
552
|
return meetingDayCountsFromNames(
|
|
651
553
|
readdirSync(monthDir).filter(name => SAFE_FILENAME_PATTERN.test(name) || name.endsWith('.md')),
|
|
652
554
|
)
|
|
@@ -663,71 +565,23 @@ export class MeetingStore {
|
|
|
663
565
|
throw new MeetingStoreError('Invalid filename', 400, 'invalid_filename')
|
|
664
566
|
}
|
|
665
567
|
|
|
666
|
-
const rootReal = this.
|
|
568
|
+
const rootReal = this.rootRealpath()
|
|
667
569
|
if (!rootReal) throw new MeetingStoreError('Meeting not found', 404, 'meeting_not_found')
|
|
668
570
|
const monthDir = join(this.root, month)
|
|
669
|
-
const monthReal =
|
|
571
|
+
const monthReal = safeDirectoryRealpath(monthDir, rootReal)
|
|
670
572
|
if (!monthReal) throw new MeetingStoreError('Meeting not found', 404, 'meeting_not_found')
|
|
671
|
-
const content =
|
|
573
|
+
const content = safeReadFile(monthDir, monthReal, filename)
|
|
672
574
|
if (content === null) throw new MeetingStoreError('Meeting not found', 404, 'meeting_not_found')
|
|
673
575
|
const detail = parseMeeting(content, filename, month)
|
|
674
576
|
if (detail.domain !== domain) throw new MeetingStoreError('Meeting not found', 404, 'meeting_not_found')
|
|
675
577
|
return detail
|
|
676
578
|
}
|
|
677
579
|
|
|
678
|
-
private
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
}
|
|
684
|
-
return realpathSync(this.root)
|
|
685
|
-
}
|
|
686
|
-
|
|
687
|
-
private safeDirectoryRealpath(path: string, parentReal: string): string | null {
|
|
688
|
-
try {
|
|
689
|
-
const stat = lstatSync(path)
|
|
690
|
-
if (stat.isSymbolicLink() || !stat.isDirectory()) return null
|
|
691
|
-
const real = realpathSync(path)
|
|
692
|
-
return isContained(parentReal, real) && dirname(real) === parentReal ? real : null
|
|
693
|
-
} catch {
|
|
694
|
-
return null
|
|
695
|
-
}
|
|
696
|
-
}
|
|
697
|
-
|
|
698
|
-
private safeReadMeeting(monthDir: string, monthReal: string, filename: string): string | null {
|
|
699
|
-
return this.safeReadFile(monthDir, monthReal, filename)
|
|
700
|
-
}
|
|
701
|
-
|
|
702
|
-
/** Read only the first `bytes` of a file, with the same symlink,
|
|
703
|
-
* containment, and O_NOFOLLOW guards as safeReadFile.
|
|
704
|
-
*
|
|
705
|
-
* Exists so `list()` can lift one field out of a chunk sidecar without
|
|
706
|
-
* reading it whole: sidecars run to megabytes (1.3 MB for a 32-minute
|
|
707
|
-
* meeting) and would also trip safeReadFile's MAX_MEETING_BYTES cap, which
|
|
708
|
-
* is sized for markdown. */
|
|
709
|
-
private safeReadFileHead(monthDir: string, monthReal: string, filename: string, bytes: number): string | null {
|
|
710
|
-
const filepath = join(monthDir, filename)
|
|
711
|
-
let fd: number | null = null
|
|
712
|
-
try {
|
|
713
|
-
const linkStat = lstatSync(filepath)
|
|
714
|
-
if (linkStat.isSymbolicLink() || !linkStat.isFile()) return null
|
|
715
|
-
const real = realpathSync(filepath)
|
|
716
|
-
if (!isContained(monthReal, real) || dirname(real) !== monthReal) return null
|
|
717
|
-
fd = openSync(filepath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0))
|
|
718
|
-
const stat = fstatSync(fd)
|
|
719
|
-
if (!stat.isFile()) return null
|
|
720
|
-
const buffer = Buffer.alloc(Math.min(bytes, stat.size))
|
|
721
|
-
if (buffer.length === 0) return ''
|
|
722
|
-
const read = readSync(fd, buffer, 0, buffer.length, 0)
|
|
723
|
-
return buffer.subarray(0, read).toString('utf8')
|
|
724
|
-
} catch {
|
|
725
|
-
return null
|
|
726
|
-
} finally {
|
|
727
|
-
if (fd !== null) {
|
|
728
|
-
try { closeSync(fd) } catch { /* already closed */ }
|
|
729
|
-
}
|
|
730
|
-
}
|
|
580
|
+
private rootRealpath(): string | null {
|
|
581
|
+
return existingRootRealpath(
|
|
582
|
+
this.root,
|
|
583
|
+
() => new MeetingStoreError('Unsafe recordings directory', 500, 'unsafe_recordings_store'),
|
|
584
|
+
)
|
|
731
585
|
}
|
|
732
586
|
|
|
733
587
|
/** The sessionId recorded in a meeting's chunk sidecar, if it has one.
|
|
@@ -738,32 +592,11 @@ export class MeetingStore {
|
|
|
738
592
|
private sidecarSessionId(monthDir: string, monthReal: string, meetingFilename: string): string | undefined {
|
|
739
593
|
const sidecarName = meetingFilename.replace(/\.md$/, '.g2-chunks.json')
|
|
740
594
|
if (sidecarName === meetingFilename) return undefined
|
|
741
|
-
const head =
|
|
595
|
+
const head = safeReadFileHead(monthDir, monthReal, sidecarName, SIDECAR_HEAD_BYTES)
|
|
742
596
|
if (!head) return undefined
|
|
743
597
|
const match = head.match(/"sessionId"\s*:\s*"([A-Za-z0-9:_-]{3,96})"/)
|
|
744
598
|
return match ? match[1] : undefined
|
|
745
599
|
}
|
|
746
|
-
|
|
747
|
-
private safeReadFile(monthDir: string, monthReal: string, filename: string): string | null {
|
|
748
|
-
const filepath = join(monthDir, filename)
|
|
749
|
-
let fd: number | null = null
|
|
750
|
-
try {
|
|
751
|
-
const linkStat = lstatSync(filepath)
|
|
752
|
-
if (linkStat.isSymbolicLink() || !linkStat.isFile()) return null
|
|
753
|
-
const real = realpathSync(filepath)
|
|
754
|
-
if (!isContained(monthReal, real) || dirname(real) !== monthReal) return null
|
|
755
|
-
fd = openSync(filepath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0))
|
|
756
|
-
const stat = fstatSync(fd)
|
|
757
|
-
if (!stat.isFile() || stat.size > MAX_MEETING_BYTES) return null
|
|
758
|
-
return readFileSync(fd, 'utf8')
|
|
759
|
-
} catch {
|
|
760
|
-
return null
|
|
761
|
-
} finally {
|
|
762
|
-
if (fd !== null) {
|
|
763
|
-
try { closeSync(fd) } catch { /* already closed */ }
|
|
764
|
-
}
|
|
765
|
-
}
|
|
766
|
-
}
|
|
767
600
|
}
|
|
768
601
|
|
|
769
602
|
let defaultMeetingStore: MeetingStore | null = null
|