@gotcos/glasses-server 6.21.31 → 6.21.33
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/.env.example +16 -5
- package/CHANGELOG.md +40 -0
- package/README.md +22 -0
- package/package.json +1 -1
- package/server/lib/adaptive-playback-audio.ts +384 -0
- package/server/lib/cos-operations-meetings.ts +230 -12
- package/server/lib/meeting-audio-archive.ts +41 -8
- package/server/lib/meeting-store.ts +8 -1
- package/server/routes/health.ts +12 -1
- package/server/routes/meeting.ts +121 -17
- package/server/routes/meetings.ts +91 -4
|
@@ -4,17 +4,17 @@
|
|
|
4
4
|
* When configured, G2 "Review Meetings" reads markdown from a COS-style tree:
|
|
5
5
|
* {operationsDir}/{domain}/meetings/YYYY-MM/*.md
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* 3. COS_SCRIPTS_DIR/.. — classic Starter Kit layout (operations/scripts → operations)
|
|
7
|
+
* Read resolution accepts COS_MEETINGS_ROOT as a direct YYYY-MM library.
|
|
8
|
+
* Write/enrichment resolution remains COS_OPERATIONS_DIR, a legacy
|
|
9
|
+
* multi-domain COS_MEETINGS_ROOT, then COS_SCRIPTS_DIR/...
|
|
11
10
|
*
|
|
12
11
|
* Standalone installs leave all of these unset and keep using MeetingStore
|
|
13
12
|
* (~/.cos-glasses/data/recordings).
|
|
14
13
|
*/
|
|
15
14
|
|
|
16
|
-
import { closeSync, existsSync, openSync, readdirSync, readFileSync, readSync, statSync } from 'node:fs'
|
|
17
|
-
import {
|
|
15
|
+
import { closeSync, existsSync, lstatSync, openSync, readdirSync, readFileSync, readSync, realpathSync, statSync } from 'node:fs'
|
|
16
|
+
import { createHash } from 'node:crypto'
|
|
17
|
+
import { basename, dirname, join, resolve } from 'node:path'
|
|
18
18
|
import { discoveredDomains, domainAbbreviation as deriveAbbr, isSafeDomainName as safeName } from './domains.js'
|
|
19
19
|
import type { MeetingDetail, MeetingMeta } from './meeting-store.js'
|
|
20
20
|
import { MEETING_SOURCE_MAX_BYTES } from './meeting-store.js'
|
|
@@ -41,6 +41,103 @@ const DETAIL_CHUNK_ESTIMATE_CHARS = 1700
|
|
|
41
41
|
|
|
42
42
|
export type CosOperationsMeetingMeta = MeetingMeta & { time?: string }
|
|
43
43
|
|
|
44
|
+
export type MeetingLibraryLayout = 'direct' | 'multi_domain' | 'standalone' | 'invalid_explicit_root'
|
|
45
|
+
|
|
46
|
+
export interface MeetingLibraryInspection {
|
|
47
|
+
layout: MeetingLibraryLayout
|
|
48
|
+
root: string | null
|
|
49
|
+
rootFingerprint: string | null
|
|
50
|
+
meetingCount: number
|
|
51
|
+
warnings: string[]
|
|
52
|
+
domains: string[]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const MONTH_PATTERN = /^\d{4}-(0[1-9]|1[0-2])$/
|
|
56
|
+
const MAX_LIST_CANDIDATES = 2_000
|
|
57
|
+
const MAX_LIST_FILE_BYTES = 100_000
|
|
58
|
+
const MAX_DETAIL_FILE_BYTES = 10 * 1024 * 1024
|
|
59
|
+
|
|
60
|
+
function rootFingerprint(root: string): string {
|
|
61
|
+
return createHash('sha256').update(root).digest('hex').slice(0, 16)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function safeRoot(path: string): string | null {
|
|
65
|
+
try {
|
|
66
|
+
const stat = lstatSync(path)
|
|
67
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) return null
|
|
68
|
+
return realpathSync(path)
|
|
69
|
+
} catch { return null }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function safeChildDirectory(parentReal: string, path: string): string | null {
|
|
73
|
+
try {
|
|
74
|
+
const stat = lstatSync(path)
|
|
75
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) return null
|
|
76
|
+
const real = realpathSync(path)
|
|
77
|
+
return dirname(real) === parentReal ? real : null
|
|
78
|
+
} catch { return null }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function safeRegularFile(parentReal: string, path: string, maxBytes = MAX_LIST_FILE_BYTES): string | null {
|
|
82
|
+
try {
|
|
83
|
+
const stat = lstatSync(path)
|
|
84
|
+
if (stat.isSymbolicLink() || !stat.isFile() || stat.size > maxBytes) return null
|
|
85
|
+
const real = realpathSync(path)
|
|
86
|
+
if (dirname(real) !== parentReal) return null
|
|
87
|
+
return readFileSync(real, 'utf8')
|
|
88
|
+
} catch { return null }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function safeBoundedRegularFile(parentReal: string, path: string, maxReadBytes: number): { content: string; truncated: boolean } | null {
|
|
92
|
+
let fd: number | null = null
|
|
93
|
+
try {
|
|
94
|
+
const stat = lstatSync(path)
|
|
95
|
+
if (stat.isSymbolicLink() || !stat.isFile() || stat.size > MAX_DETAIL_FILE_BYTES) return null
|
|
96
|
+
const real = realpathSync(path)
|
|
97
|
+
if (dirname(real) !== parentReal) return null
|
|
98
|
+
fd = openSync(real, 'r')
|
|
99
|
+
const buffer = Buffer.alloc(Math.min(maxReadBytes, stat.size))
|
|
100
|
+
const read = readSync(fd, buffer, 0, buffer.length, 0)
|
|
101
|
+
return { content: buffer.subarray(0, read).toString('utf8'), truncated: stat.size > read }
|
|
102
|
+
} catch { return null }
|
|
103
|
+
finally {
|
|
104
|
+
if (fd !== null) { try { closeSync(fd) } catch { /* already closed */ } }
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function inspectMeetingLibraryPath(path: string): MeetingLibraryInspection {
|
|
109
|
+
const requested = resolve(path)
|
|
110
|
+
const root = safeRoot(requested)
|
|
111
|
+
if (!root) {
|
|
112
|
+
return { layout: 'invalid_explicit_root', root: null, rootFingerprint: null, meetingCount: 0, warnings: ['folder is missing, unreadable, or unsafe'], domains: [] }
|
|
113
|
+
}
|
|
114
|
+
const entries = (() => { try { return readdirSync(root) } catch { return [] } })()
|
|
115
|
+
const directMonths = entries.filter(name => MONTH_PATTERN.test(name) && safeChildDirectory(root, join(root, name))).sort()
|
|
116
|
+
const domains = entries.filter(safeName).filter(name => {
|
|
117
|
+
const domainReal = safeChildDirectory(root, join(root, name))
|
|
118
|
+
if (!domainReal) return false
|
|
119
|
+
return safeChildDirectory(domainReal, join(domainReal, 'meetings')) != null
|
|
120
|
+
}).sort()
|
|
121
|
+
const warnings: string[] = []
|
|
122
|
+
if (directMonths.length > 0 && domains.length > 0) {
|
|
123
|
+
return { layout: 'invalid_explicit_root', root, rootFingerprint: rootFingerprint(root), meetingCount: 0, warnings: ['folder mixes direct months and domain/meetings trees'], domains }
|
|
124
|
+
}
|
|
125
|
+
const layout: MeetingLibraryLayout = directMonths.length > 0 ? 'direct' : domains.length > 0 ? 'multi_domain' : 'invalid_explicit_root'
|
|
126
|
+
if (layout === 'invalid_explicit_root') {
|
|
127
|
+
const nearMisses = entries.filter(safeName).filter(name => {
|
|
128
|
+
const child = safeChildDirectory(root, join(root, name))
|
|
129
|
+
if (!child) return false
|
|
130
|
+
try {
|
|
131
|
+
return readdirSync(child).some(entry => MONTH_PATTERN.test(entry) && safeChildDirectory(child, join(child, entry)))
|
|
132
|
+
} catch { return false }
|
|
133
|
+
})
|
|
134
|
+
warnings.push(nearMisses.length > 0
|
|
135
|
+
? `missing meetings/ inside: ${nearMisses.slice(0, 6).join(', ')}`
|
|
136
|
+
: 'expected YYYY-MM folders or <domain>/meetings/YYYY-MM')
|
|
137
|
+
}
|
|
138
|
+
return { layout, root, rootFingerprint: rootFingerprint(root), meetingCount: 0, warnings, domains }
|
|
139
|
+
}
|
|
140
|
+
|
|
44
141
|
/** Enough to clear the sidecar's leading metadata keys whatever their order. */
|
|
45
142
|
const SIDECAR_HEAD_BYTES = 4096
|
|
46
143
|
|
|
@@ -60,8 +157,11 @@ function sidecarSessionId(monthDir: string, meetingFilename: string): string | u
|
|
|
60
157
|
const path = join(monthDir, sidecarName)
|
|
61
158
|
let fd: number | null = null
|
|
62
159
|
try {
|
|
63
|
-
const
|
|
64
|
-
if (!
|
|
160
|
+
const linkStat = lstatSync(path)
|
|
161
|
+
if (linkStat.isSymbolicLink() || !linkStat.isFile() || linkStat.size === 0) return undefined
|
|
162
|
+
const real = realpathSync(path)
|
|
163
|
+
if (dirname(real) !== realpathSync(monthDir)) return undefined
|
|
164
|
+
const stat = statSync(real)
|
|
65
165
|
fd = openSync(path, 'r')
|
|
66
166
|
const buffer = Buffer.alloc(Math.min(SIDECAR_HEAD_BYTES, stat.size))
|
|
67
167
|
const read = readSync(fd, buffer, 0, buffer.length, 0)
|
|
@@ -84,12 +184,17 @@ function envPath(name: string): string | null {
|
|
|
84
184
|
/** Resolve the COS operations directory, or null in standalone mode.
|
|
85
185
|
* Reads process.env on each call so tests and Control env updates stay live. */
|
|
86
186
|
export function resolveCosOperationsDir(): string | null {
|
|
87
|
-
const
|
|
88
|
-
if (
|
|
187
|
+
const operations = envPath('COS_OPERATIONS_DIR')
|
|
188
|
+
if (operations && inspectMeetingLibraryPath(operations).layout === 'multi_domain') return safeRoot(operations)
|
|
189
|
+
// Backward compatibility: before 6.21.33 COS_MEETINGS_ROOT was documented as
|
|
190
|
+
// an alias for COS_OPERATIONS_DIR. Preserve that meaning only for a real
|
|
191
|
+
// multi-domain tree; a direct library is never a write destination.
|
|
192
|
+
const legacy = envPath('COS_MEETINGS_ROOT')
|
|
193
|
+
if (legacy && inspectMeetingLibraryPath(legacy).layout === 'multi_domain') return safeRoot(legacy)
|
|
89
194
|
const scriptsDir = envPath('COS_SCRIPTS_DIR')
|
|
90
195
|
if (scriptsDir) {
|
|
91
196
|
const inferred = resolve(scriptsDir, '..')
|
|
92
|
-
if (
|
|
197
|
+
if (inspectMeetingLibraryPath(inferred).layout === 'multi_domain') return safeRoot(inferred)
|
|
93
198
|
}
|
|
94
199
|
return null
|
|
95
200
|
}
|
|
@@ -98,6 +203,19 @@ export function cosOperationsMeetingsConfigured(): boolean {
|
|
|
98
203
|
return resolveCosOperationsDir() != null
|
|
99
204
|
}
|
|
100
205
|
|
|
206
|
+
export function resolveMeetingLibrary(): MeetingLibraryInspection {
|
|
207
|
+
const explicit = envPath('COS_MEETINGS_ROOT')
|
|
208
|
+
if (explicit) return inspectMeetingLibraryPath(explicit)
|
|
209
|
+
const operations = resolveCosOperationsDir()
|
|
210
|
+
if (operations) return inspectMeetingLibraryPath(operations)
|
|
211
|
+
return { layout: 'standalone', root: null, rootFingerprint: null, meetingCount: 0, warnings: [], domains: [] }
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function meetingLibraryConfigured(): boolean {
|
|
215
|
+
const layout = resolveMeetingLibrary().layout
|
|
216
|
+
return layout === 'direct' || layout === 'multi_domain'
|
|
217
|
+
}
|
|
218
|
+
|
|
101
219
|
function boundedMeetingSource(content: string): { sourceContent: string; sourceTruncated: boolean } {
|
|
102
220
|
const bytes = Buffer.from(content, 'utf8')
|
|
103
221
|
if (bytes.length <= MEETING_SOURCE_MAX_BYTES) return { sourceContent: content, sourceTruncated: false }
|
|
@@ -364,6 +482,41 @@ export function findCosOperationsMeetingBySessionId(sessionId: string): {
|
|
|
364
482
|
return null
|
|
365
483
|
}
|
|
366
484
|
|
|
485
|
+
export type MeetingLibraryRecord = NonNullable<ReturnType<typeof findCosOperationsMeetingBySessionId>> & {
|
|
486
|
+
recordId?: string
|
|
487
|
+
librarySource?: 'direct_library' | 'cos_operations'
|
|
488
|
+
mutable?: boolean
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** Read resolver for direct libraries. Mutations must require recordId and
|
|
492
|
+
* mutable=true; the legacy operations resolver above remains writable. */
|
|
493
|
+
export function findDirectLibraryMeetingBySessionId(sessionId: string): MeetingLibraryRecord | null {
|
|
494
|
+
const inspection = resolveMeetingLibrary()
|
|
495
|
+
if (inspection.layout !== 'direct' || !inspection.root) return null
|
|
496
|
+
const root = inspection.root
|
|
497
|
+
const months = readdirSync(root).filter(name => MONTH_PATTERN.test(name)).sort().reverse()
|
|
498
|
+
for (const month of months) {
|
|
499
|
+
const monthDir = safeChildDirectory(root, join(root, month))
|
|
500
|
+
if (!monthDir) continue
|
|
501
|
+
for (const sidecarName of readdirSync(monthDir).filter(name => name.endsWith('.g2-chunks.json')).sort().reverse()) {
|
|
502
|
+
const filename = sidecarName.replace(/\.g2-chunks\.json$/, '.md')
|
|
503
|
+
if (sidecarSessionId(monthDir, filename) !== sessionId) continue
|
|
504
|
+
const meetingPath = join(monthDir, filename)
|
|
505
|
+
const bounded = safeBoundedRegularFile(monthDir, meetingPath, MEETING_SOURCE_MAX_BYTES)
|
|
506
|
+
if (bounded == null) continue
|
|
507
|
+
const title = bounded.content.match(/^#\s+(.+)$/m)?.[1]?.trim() || filename.replace(/\.md$/, '')
|
|
508
|
+
return {
|
|
509
|
+
sidecarPath: join(monthDir, sidecarName), meetingPath, filename,
|
|
510
|
+
domain: 'library', month, title,
|
|
511
|
+
recordId: `direct:${month}:${filename}`,
|
|
512
|
+
librarySource: 'direct_library',
|
|
513
|
+
mutable: false,
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
return null
|
|
518
|
+
}
|
|
519
|
+
|
|
367
520
|
export function listCosOperationsMeetings(options: {
|
|
368
521
|
limit?: number
|
|
369
522
|
domain?: string
|
|
@@ -387,7 +540,6 @@ export function listCosOperationsMeetings(options: {
|
|
|
387
540
|
.filter(d => /^\d{4}-\d{2}$/.test(d))
|
|
388
541
|
.sort()
|
|
389
542
|
.reverse()
|
|
390
|
-
.slice(0, 3)
|
|
391
543
|
|
|
392
544
|
for (const month of months) {
|
|
393
545
|
const monthDir = join(meetingsBase, month)
|
|
@@ -403,6 +555,10 @@ export function listCosOperationsMeetings(options: {
|
|
|
403
555
|
const content = readFileSync(filepath, 'utf-8')
|
|
404
556
|
const meta = withMeetingListInsights(parseMeetingMeta(content.slice(0, 4000), file, domain), content)
|
|
405
557
|
meta.month = month
|
|
558
|
+
meta.librarySource = 'cos_operations'
|
|
559
|
+
meta.recordId = `ops:${domain}:${month}:${file}`
|
|
560
|
+
meta.mutable = true
|
|
561
|
+
meta.canonicalRecord = `operations/${domain}/meetings/${month}/${file}`
|
|
406
562
|
const sessionId = sidecarSessionId(monthDir, file)
|
|
407
563
|
if (sessionId) meta.sessionId = sessionId
|
|
408
564
|
allMeetings.push(meta)
|
|
@@ -417,6 +573,35 @@ export function listCosOperationsMeetings(options: {
|
|
|
417
573
|
return allMeetings.slice(0, limit)
|
|
418
574
|
}
|
|
419
575
|
|
|
576
|
+
export function listDirectLibraryMeetings(options: { limit?: number } = {}): CosOperationsMeetingMeta[] {
|
|
577
|
+
const inspection = resolveMeetingLibrary()
|
|
578
|
+
if (inspection.layout !== 'direct' || !inspection.root) return []
|
|
579
|
+
const limit = Math.min(Math.max(options.limit ?? 20, 1), 50)
|
|
580
|
+
const all: CosOperationsMeetingMeta[] = []
|
|
581
|
+
let candidates = 0
|
|
582
|
+
for (const month of readdirSync(inspection.root).filter(name => MONTH_PATTERN.test(name)).sort().reverse()) {
|
|
583
|
+
const monthDir = safeChildDirectory(inspection.root, join(inspection.root, month))
|
|
584
|
+
if (!monthDir) continue
|
|
585
|
+
for (const file of readdirSync(monthDir).filter(name => name.endsWith('.md')).sort().reverse()) {
|
|
586
|
+
if (++candidates > MAX_LIST_CANDIDATES) break
|
|
587
|
+
const bounded = safeBoundedRegularFile(monthDir, join(monthDir, file), MAX_LIST_FILE_BYTES)
|
|
588
|
+
if (bounded == null) continue
|
|
589
|
+
const content = bounded.content
|
|
590
|
+
const meta = withMeetingListInsights(parseMeetingMeta(content.slice(0, 4000), file, 'library'), content)
|
|
591
|
+
meta.month = month
|
|
592
|
+
meta.librarySource = 'direct_library'
|
|
593
|
+
meta.recordId = `direct:${month}:${file}`
|
|
594
|
+
meta.mutable = false
|
|
595
|
+
const sessionId = sidecarSessionId(monthDir, file)
|
|
596
|
+
if (sessionId) meta.sessionId = sessionId
|
|
597
|
+
all.push(meta)
|
|
598
|
+
}
|
|
599
|
+
if (candidates > MAX_LIST_CANDIDATES) break
|
|
600
|
+
}
|
|
601
|
+
all.sort(compareMeetingsNewestFirst)
|
|
602
|
+
return all.slice(0, limit)
|
|
603
|
+
}
|
|
604
|
+
|
|
420
605
|
export function getCosOperationsMeetingDetail(
|
|
421
606
|
domain: string,
|
|
422
607
|
month: string,
|
|
@@ -473,3 +658,36 @@ export function getCosOperationsMeetingDetail(
|
|
|
473
658
|
...source,
|
|
474
659
|
}
|
|
475
660
|
}
|
|
661
|
+
|
|
662
|
+
export function getDirectLibraryMeetingDetail(month: string, filename: string): MeetingDetail | null {
|
|
663
|
+
const inspection = resolveMeetingLibrary()
|
|
664
|
+
if (inspection.layout !== 'direct' || !inspection.root) return null
|
|
665
|
+
if (!MONTH_PATTERN.test(month) || basename(filename) !== filename || !filename.endsWith('.md')) return null
|
|
666
|
+
const monthDir = safeChildDirectory(inspection.root, join(inspection.root, month))
|
|
667
|
+
if (!monthDir) return null
|
|
668
|
+
let resolvedFilename = filename
|
|
669
|
+
let bounded = safeBoundedRegularFile(monthDir, join(monthDir, resolvedFilename), MEETING_SOURCE_MAX_BYTES)
|
|
670
|
+
if (bounded == null) {
|
|
671
|
+
const candidates = readdirSync(monthDir).filter(name => name.endsWith('.md')).slice(0, MAX_LIST_CANDIDATES)
|
|
672
|
+
.map(name => ({ filename: name, content: safeRegularFile(monthDir, join(monthDir, name), 4_000) ?? '' }))
|
|
673
|
+
const renamed = matchRenamedMeetingFilename(filename, candidates)
|
|
674
|
+
if (!renamed) return null
|
|
675
|
+
resolvedFilename = renamed
|
|
676
|
+
bounded = safeBoundedRegularFile(monthDir, join(monthDir, resolvedFilename), MEETING_SOURCE_MAX_BYTES)
|
|
677
|
+
if (bounded == null) return null
|
|
678
|
+
}
|
|
679
|
+
const content = bounded.content
|
|
680
|
+
const meta = parseMeetingMeta(content, resolvedFilename, 'library')
|
|
681
|
+
meta.month = month
|
|
682
|
+
meta.librarySource = 'direct_library'
|
|
683
|
+
meta.recordId = `direct:${month}:${resolvedFilename}`
|
|
684
|
+
meta.mutable = false
|
|
685
|
+
const sessionId = sidecarSessionId(monthDir, resolvedFilename)
|
|
686
|
+
if (sessionId) meta.sessionId = sessionId
|
|
687
|
+
const source = { sourceContent: content, sourceTruncated: bounded.truncated }
|
|
688
|
+
return {
|
|
689
|
+
...meta,
|
|
690
|
+
summary: extractSummary(content), topics: extractTopics(content), decisions: extractDecisions(content),
|
|
691
|
+
actionItems: extractActionItems(content), attendees: extractAttendees(content), transcript: content, ...source,
|
|
692
|
+
}
|
|
693
|
+
}
|
|
@@ -60,6 +60,11 @@ function isChunkWav(name: string): boolean {
|
|
|
60
60
|
return /^chunk_\d+\.wav$/.test(name)
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
/** Derived playback copies count against the cap but never define retention age. */
|
|
64
|
+
function isDerivedPlaybackWav(name: string): boolean {
|
|
65
|
+
return /^playback_v\d+_\d+\.wav$/.test(name)
|
|
66
|
+
}
|
|
67
|
+
|
|
63
68
|
export interface ArchiveResult {
|
|
64
69
|
linked: number
|
|
65
70
|
/** Files that had to be copied because the link failed (e.g. cross-device). */
|
|
@@ -110,19 +115,40 @@ export function archiveSessionAudio(sessionId: string, sourceDir: string): Archi
|
|
|
110
115
|
}
|
|
111
116
|
|
|
112
117
|
/** Bytes and age for one archived session. */
|
|
113
|
-
function sessionSize(dir: string): {
|
|
114
|
-
|
|
118
|
+
function sessionSize(dir: string): {
|
|
119
|
+
bytes: number
|
|
120
|
+
mtimeMs: number
|
|
121
|
+
files: number
|
|
122
|
+
derivedFiles: number
|
|
123
|
+
otherEntries: number
|
|
124
|
+
statFailures: number
|
|
125
|
+
readable: boolean
|
|
126
|
+
} {
|
|
127
|
+
let bytes = 0, mtimeMs = 0, files = 0, derivedFiles = 0
|
|
128
|
+
let otherEntries = 0, statFailures = 0, readable = false
|
|
115
129
|
try {
|
|
116
|
-
|
|
130
|
+
const names = readdirSync(dir)
|
|
131
|
+
readable = true
|
|
132
|
+
for (const name of names) {
|
|
133
|
+
if (!isChunkWav(name) && !isDerivedPlaybackWav(name)) {
|
|
134
|
+
otherEntries++
|
|
135
|
+
continue
|
|
136
|
+
}
|
|
117
137
|
try {
|
|
118
138
|
const st = statSync(join(dir, name))
|
|
119
139
|
bytes += st.size
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
140
|
+
// A replay created six days after capture must not buy the raw evidence
|
|
141
|
+
// another seven days. Only immutable raw chunks determine session age.
|
|
142
|
+
if (isChunkWav(name)) {
|
|
143
|
+
files++
|
|
144
|
+
mtimeMs = Math.max(mtimeMs, st.mtimeMs)
|
|
145
|
+
} else {
|
|
146
|
+
derivedFiles++
|
|
147
|
+
}
|
|
148
|
+
} catch { statFailures++ }
|
|
123
149
|
}
|
|
124
150
|
} catch { /* unreadable dir reports zero */ }
|
|
125
|
-
return { bytes, mtimeMs, files }
|
|
151
|
+
return { bytes, mtimeMs, files, derivedFiles, otherEntries, statFailures, readable }
|
|
126
152
|
}
|
|
127
153
|
|
|
128
154
|
export interface SweepResult {
|
|
@@ -145,7 +171,14 @@ export function sweepMeetingAudio(nowMs: number, ttlMs = meetingAudioTtlMs()): S
|
|
|
145
171
|
try { names = readdirSync(root) } catch { return out }
|
|
146
172
|
for (const name of names) {
|
|
147
173
|
const dir = join(root, name)
|
|
148
|
-
const { bytes, mtimeMs } = sessionSize(dir)
|
|
174
|
+
const { bytes, mtimeMs, files, derivedFiles, otherEntries, statFailures, readable } = sessionSize(dir)
|
|
175
|
+
// Delete only a provably cache-only directory. Any unknown entry or failed
|
|
176
|
+
// stat may be retained evidence, so ambiguity fails closed to preservation.
|
|
177
|
+
if (readable && files === 0 && derivedFiles > 0 && otherEntries === 0 && statFailures === 0) {
|
|
178
|
+
try { rmSync(dir, { recursive: true, force: true }); out.removed.push(name); out.bytesFreed += bytes }
|
|
179
|
+
catch { out.retained.push(name) }
|
|
180
|
+
continue
|
|
181
|
+
}
|
|
149
182
|
if (mtimeMs <= 0) { out.retained.push(name); continue }
|
|
150
183
|
if (nowMs - mtimeMs > ttlMs) {
|
|
151
184
|
try { rmSync(dir, { recursive: true, force: true }); out.removed.push(name); out.bytesFreed += bytes }
|
|
@@ -52,6 +52,7 @@ export interface MeetingMeta {
|
|
|
52
52
|
sessionId?: string
|
|
53
53
|
title: string
|
|
54
54
|
date: string
|
|
55
|
+
time?: string
|
|
55
56
|
domain: string
|
|
56
57
|
domainAbbr: string
|
|
57
58
|
source: string
|
|
@@ -64,6 +65,12 @@ export interface MeetingMeta {
|
|
|
64
65
|
decisionCount?: number
|
|
65
66
|
actionCount?: number
|
|
66
67
|
attendeeCount?: number
|
|
68
|
+
/** Additive archive identity. Older companions ignore these fields. */
|
|
69
|
+
librarySource?: 'direct_library' | 'cos_operations' | 'standalone_recordings'
|
|
70
|
+
recordId?: string
|
|
71
|
+
mutable?: boolean
|
|
72
|
+
/** Present only when the server can state a truthful local record. */
|
|
73
|
+
canonicalRecord?: string
|
|
67
74
|
}
|
|
68
75
|
|
|
69
76
|
export interface MeetingActionItem {
|
|
@@ -437,7 +444,7 @@ export class MeetingStore {
|
|
|
437
444
|
// When COS ops is configured, use the private-app pipeline markers so
|
|
438
445
|
// sync_meetings.py --g2-file will enrich (and reclassify domain). Plain
|
|
439
446
|
// "Standalone recording" summaries are treated as already-final and skipped.
|
|
440
|
-
...(process.env.COS_SCRIPTS_DIR
|
|
447
|
+
...(process.env.COS_SCRIPTS_DIR
|
|
441
448
|
? [
|
|
442
449
|
'<!-- g2-needs-domain-review -->',
|
|
443
450
|
'',
|
package/server/routes/health.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { profileProvenanceSummary, speakerModelState, speakerReadiness } from '.
|
|
|
10
10
|
import { chunkEmbeddingStoreStats } from '../lib/chunk-embedding-store.js'
|
|
11
11
|
import { correctionStoreStats } from '../lib/meeting-corrections.js'
|
|
12
12
|
import { meetingAudioStats } from '../lib/meeting-audio-archive.js'
|
|
13
|
+
import { adaptivePlaybackStatus } from '../lib/adaptive-playback-audio.js'
|
|
13
14
|
import { getAvailableCliSessionId } from '../lib/claude-bridge.js'
|
|
14
15
|
import {
|
|
15
16
|
isWhisperLocalAvailable,
|
|
@@ -46,6 +47,7 @@ import { getHealthStaticProbes } from '../lib/health-static-probes.js'
|
|
|
46
47
|
import { getEarlyMeetingSyncSnapshot } from '../lib/g2-ops-handoff.js'
|
|
47
48
|
import { getProgressiveHqSnapshot } from '../lib/meeting-batch-transcribe.js'
|
|
48
49
|
import { getMeetingFinalizationSnapshot } from '../lib/meeting-finalization-jobs.js'
|
|
50
|
+
import { resolveMeetingLibrary } from '../lib/cos-operations-meetings.js'
|
|
49
51
|
|
|
50
52
|
export const healthRouter = Router()
|
|
51
53
|
|
|
@@ -125,7 +127,10 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
125
127
|
// `pending` is the number that matters here: an intent that never closed means
|
|
126
128
|
// some meeting's files may be half-rewritten.
|
|
127
129
|
const speakerCorrections = correctionStoreStats()
|
|
128
|
-
const reviewAudio =
|
|
130
|
+
const reviewAudio = {
|
|
131
|
+
...meetingAudioStats(),
|
|
132
|
+
adaptivePlayback: adaptivePlaybackStatus(),
|
|
133
|
+
}
|
|
129
134
|
// `noHumanSample` is the one to read: a profile with no human-verified sample
|
|
130
135
|
// is trained entirely on labels the system chose for itself.
|
|
131
136
|
const voiceProvenance = speakerId.state === 'active' ? profileProvenanceSummary() : null
|
|
@@ -230,6 +235,7 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
230
235
|
recovered: item.recovered,
|
|
231
236
|
})),
|
|
232
237
|
}
|
|
238
|
+
const meetingLibrary = resolveMeetingLibrary()
|
|
233
239
|
res.json({
|
|
234
240
|
...checks,
|
|
235
241
|
server_version: managedServerVersion(),
|
|
@@ -245,6 +251,11 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
245
251
|
codex_models,
|
|
246
252
|
cursor_models,
|
|
247
253
|
meeting_sync,
|
|
254
|
+
meeting_library: {
|
|
255
|
+
layout: meetingLibrary.layout,
|
|
256
|
+
ready: meetingLibrary.layout !== 'invalid_explicit_root',
|
|
257
|
+
warningCount: meetingLibrary.warnings.length,
|
|
258
|
+
},
|
|
248
259
|
unsaved_captures,
|
|
249
260
|
chunk_embeddings: chunkEmbeddings,
|
|
250
261
|
speaker_corrections: speakerCorrections,
|