@gotcos/glasses-server 6.21.13 → 6.21.14
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 +14 -0
- package/package.json +1 -1
- package/server/lib/cos-operations-meetings.ts +99 -1
- package/server/routes/meeting.ts +27 -10
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
## 6.21.14
|
|
2
|
+
|
|
3
|
+
- Carry `sessionId` on the COS operations meetings list as well. 6.21.13 added it
|
|
4
|
+
to the standalone store's lister, but a COS install serves its list from the
|
|
5
|
+
operations tree, so the field never appeared and every row was skipped as
|
|
6
|
+
unreviewable.
|
|
7
|
+
- Resolve a speaker review from the operations tree first when it is configured.
|
|
8
|
+
The same session exists in both trees under different names — the standalone
|
|
9
|
+
store keeps the raw capture name, operations keeps the titled copy — and the
|
|
10
|
+
list reads operations, so resolving the store first showed one title on the row
|
|
11
|
+
and a different one in the panel for the same meeting.
|
|
12
|
+
- Report which tree a review came from, and search every domain rather than
|
|
13
|
+
assuming personal.
|
|
14
|
+
|
|
1
15
|
## 6.21.13
|
|
2
16
|
|
|
3
17
|
- Carry each meeting's `sessionId` on the meetings list, so a Control row can
|
package/package.json
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* (~/.cos-glasses/data/recordings).
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
|
16
|
+
import { closeSync, existsSync, openSync, readdirSync, readFileSync, readSync, statSync } from 'node:fs'
|
|
17
17
|
import { basename, join, resolve } from 'node:path'
|
|
18
18
|
import type { MeetingDetail, MeetingMeta } from './meeting-store.js'
|
|
19
19
|
import { MEETING_SOURCE_MAX_BYTES } from './meeting-store.js'
|
|
@@ -31,6 +31,40 @@ const DETAIL_CHUNK_ESTIMATE_CHARS = 1700
|
|
|
31
31
|
|
|
32
32
|
export type CosOperationsMeetingMeta = MeetingMeta & { time?: string }
|
|
33
33
|
|
|
34
|
+
/** Enough to clear the sidecar's leading metadata keys whatever their order. */
|
|
35
|
+
const SIDECAR_HEAD_BYTES = 4096
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The sessionId recorded in a meeting's chunk sidecar, if it has one.
|
|
39
|
+
*
|
|
40
|
+
* Carried on the list so a Control row can open the per-meeting speaker review,
|
|
41
|
+
* which is keyed on the session rather than the filename.
|
|
42
|
+
*
|
|
43
|
+
* Reads only the head. A sidecar for a 32-minute meeting is ~1.3 MB, so reading
|
|
44
|
+
* them whole would make listing cost scale with total transcript size — and this
|
|
45
|
+
* lister already reads every markdown file it finds.
|
|
46
|
+
*/
|
|
47
|
+
function sidecarSessionId(monthDir: string, meetingFilename: string): string | undefined {
|
|
48
|
+
const sidecarName = meetingFilename.replace(/\.md$/, '.g2-chunks.json')
|
|
49
|
+
if (sidecarName === meetingFilename) return undefined
|
|
50
|
+
const path = join(monthDir, sidecarName)
|
|
51
|
+
let fd: number | null = null
|
|
52
|
+
try {
|
|
53
|
+
const stat = statSync(path)
|
|
54
|
+
if (!stat.isFile() || stat.size === 0) return undefined
|
|
55
|
+
fd = openSync(path, 'r')
|
|
56
|
+
const buffer = Buffer.alloc(Math.min(SIDECAR_HEAD_BYTES, stat.size))
|
|
57
|
+
const read = readSync(fd, buffer, 0, buffer.length, 0)
|
|
58
|
+
const match = buffer.subarray(0, read).toString('utf8')
|
|
59
|
+
.match(/"sessionId"\s*:\s*"([A-Za-z0-9:_-]{3,96})"/)
|
|
60
|
+
return match ? match[1] : undefined
|
|
61
|
+
} catch {
|
|
62
|
+
return undefined
|
|
63
|
+
} finally {
|
|
64
|
+
if (fd !== null) { try { closeSync(fd) } catch { /* already closed */ } }
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
34
68
|
function envPath(name: string): string | null {
|
|
35
69
|
const raw = process.env[name]?.trim()
|
|
36
70
|
if (!raw) return null
|
|
@@ -258,6 +292,68 @@ function withMeetingListInsights(meta: CosOperationsMeetingMeta, content: string
|
|
|
258
292
|
}
|
|
259
293
|
}
|
|
260
294
|
|
|
295
|
+
/**
|
|
296
|
+
* Locate a meeting in the operations tree by the sessionId in its sidecar.
|
|
297
|
+
*
|
|
298
|
+
* Needed because the same session exists in BOTH trees under different names:
|
|
299
|
+
* the standalone store keeps the raw capture name ("G2 Recording 2026-08-02
|
|
300
|
+
* 1717") while operations holds the titled copy ("Family Dinner And
|
|
301
|
+
* Commonwealth Games"). The meetings list reads operations, so anything keyed on
|
|
302
|
+
* a session has to resolve there too or the same meeting shows two different
|
|
303
|
+
* titles depending on which surface you are looking at.
|
|
304
|
+
*
|
|
305
|
+
* Scans sidecar heads rather than parsing them, so the cost is a 4 KB read per
|
|
306
|
+
* candidate and not the transcript.
|
|
307
|
+
*/
|
|
308
|
+
export function findCosOperationsMeetingBySessionId(sessionId: string): {
|
|
309
|
+
sidecarPath: string
|
|
310
|
+
meetingPath: string
|
|
311
|
+
filename: string
|
|
312
|
+
domain: string
|
|
313
|
+
month: string
|
|
314
|
+
title: string
|
|
315
|
+
} | null {
|
|
316
|
+
const operationsDir = resolveCosOperationsDir()
|
|
317
|
+
if (!operationsDir) return null
|
|
318
|
+
|
|
319
|
+
for (const domain of COS_MEETING_DOMAINS) {
|
|
320
|
+
const meetingsBase = join(operationsDir, domain, 'meetings')
|
|
321
|
+
let months: string[]
|
|
322
|
+
try {
|
|
323
|
+
months = readdirSync(meetingsBase).filter(d => /^\d{4}-\d{2}$/.test(d)).sort().reverse()
|
|
324
|
+
} catch { continue }
|
|
325
|
+
|
|
326
|
+
for (const month of months) {
|
|
327
|
+
const monthDir = join(meetingsBase, month)
|
|
328
|
+
let sidecars: string[]
|
|
329
|
+
try {
|
|
330
|
+
sidecars = readdirSync(monthDir).filter(f => f.endsWith('.g2-chunks.json')).sort().reverse()
|
|
331
|
+
} catch { continue }
|
|
332
|
+
|
|
333
|
+
for (const sidecarName of sidecars) {
|
|
334
|
+
const meetingFilename = sidecarName.replace(/\.g2-chunks\.json$/, '.md')
|
|
335
|
+
if (sidecarSessionId(monthDir, meetingFilename) !== sessionId) continue
|
|
336
|
+
const meetingPath = join(monthDir, meetingFilename)
|
|
337
|
+
let title = meetingFilename.replace(/\.md$/, '')
|
|
338
|
+
try {
|
|
339
|
+
const head = readFileSync(meetingPath, 'utf-8').slice(0, 4000)
|
|
340
|
+
const heading = head.match(/^#\s+(.+)$/m)?.[1]?.trim()
|
|
341
|
+
if (heading) title = heading
|
|
342
|
+
} catch { /* fall back to the filename stem */ }
|
|
343
|
+
return {
|
|
344
|
+
sidecarPath: join(monthDir, sidecarName),
|
|
345
|
+
meetingPath,
|
|
346
|
+
filename: meetingFilename,
|
|
347
|
+
domain,
|
|
348
|
+
month,
|
|
349
|
+
title,
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
return null
|
|
355
|
+
}
|
|
356
|
+
|
|
261
357
|
export function listCosOperationsMeetings(options: {
|
|
262
358
|
limit?: number
|
|
263
359
|
domain?: string
|
|
@@ -298,6 +394,8 @@ export function listCosOperationsMeetings(options: {
|
|
|
298
394
|
const content = readFileSync(filepath, 'utf-8')
|
|
299
395
|
const meta = withMeetingListInsights(parseMeetingMeta(content.slice(0, 4000), file, domain), content)
|
|
300
396
|
meta.month = month
|
|
397
|
+
const sessionId = sidecarSessionId(monthDir, file)
|
|
398
|
+
if (sessionId) meta.sessionId = sessionId
|
|
301
399
|
allMeetings.push(meta)
|
|
302
400
|
} catch { /* skip unreadable files */ }
|
|
303
401
|
}
|
package/server/routes/meeting.ts
CHANGED
|
@@ -64,6 +64,10 @@ import {
|
|
|
64
64
|
type TranscriptGapReport,
|
|
65
65
|
} from './transcribe-stream.js'
|
|
66
66
|
import { getServerInstanceId } from '../lib/server-instance-id.js'
|
|
67
|
+
import {
|
|
68
|
+
cosOperationsMeetingsConfigured,
|
|
69
|
+
findCosOperationsMeetingBySessionId,
|
|
70
|
+
} from '../lib/cos-operations-meetings.js'
|
|
67
71
|
import { getOwnerSpeakerLabel } from '../lib/profile.js'
|
|
68
72
|
import { reviewMeetingSpeakers, type ReviewChunk } from '../lib/meeting-speaker-review.js'
|
|
69
73
|
import {
|
|
@@ -629,20 +633,32 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
629
633
|
res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
|
|
630
634
|
return
|
|
631
635
|
}
|
|
632
|
-
|
|
633
|
-
|
|
636
|
+
// Prefer the COS operations copy when configured. The same session exists in
|
|
637
|
+
// both trees under different names — the standalone store keeps the raw
|
|
638
|
+
// capture name, operations holds the titled copy — and the meetings LIST
|
|
639
|
+
// reads operations. Resolving the store first would show one title on the row
|
|
640
|
+
// and a different one in this panel for the same meeting.
|
|
641
|
+
const operations = cosOperationsMeetingsConfigured()
|
|
642
|
+
? findCosOperationsMeetingBySessionId(sessionId)
|
|
643
|
+
: null
|
|
644
|
+
const saved = operations ? null : store.findBySessionId(sessionId)
|
|
645
|
+
if (!operations && !saved) {
|
|
634
646
|
res.status(404).json({ error: 'No saved meeting for this session', reason: 'meeting_not_found' })
|
|
635
647
|
return
|
|
636
648
|
}
|
|
649
|
+
const sidecarPath = operations?.sidecarPath ?? saved!.sidecarPath
|
|
650
|
+
const title = operations?.title ?? saved!.title
|
|
651
|
+
const domain = operations?.domain ?? saved!.domain
|
|
652
|
+
const filename = operations?.filename ?? saved!.filename
|
|
637
653
|
|
|
638
654
|
let chunks: unknown
|
|
639
655
|
try {
|
|
640
|
-
const raw = JSON.parse(readFileSync(
|
|
656
|
+
const raw = JSON.parse(readFileSync(sidecarPath, 'utf-8')) as Record<string, unknown>
|
|
641
657
|
chunks = Array.isArray(raw) ? raw : raw.chunks
|
|
642
658
|
} catch {
|
|
643
|
-
// Defensive:
|
|
644
|
-
// session, so a corrupt file 404s
|
|
645
|
-
//
|
|
659
|
+
// Defensive: both lookups above already read this sidecar to match the
|
|
660
|
+
// session, so a corrupt file 404s and never reaches here. This covers the
|
|
661
|
+
// narrow race where it becomes unreadable in between. Either
|
|
646
662
|
// way the answer is never 200-with-no-voices, which would read as
|
|
647
663
|
// "nobody spoke" and invite naming voices that were never analysed.
|
|
648
664
|
res.status(422).json({ error: 'Chunk sidecar is missing or unreadable', reason: 'sidecar_unreadable' })
|
|
@@ -660,10 +676,11 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
660
676
|
res.set('Cache-Control', 'private, no-store')
|
|
661
677
|
res.json({
|
|
662
678
|
sessionId,
|
|
663
|
-
title
|
|
664
|
-
domain
|
|
665
|
-
filename
|
|
666
|
-
|
|
679
|
+
title,
|
|
680
|
+
domain,
|
|
681
|
+
filename,
|
|
682
|
+
source: operations ? 'cos_operations' : 'standalone_recordings',
|
|
683
|
+
...(saved ? { durationMin: saved.durationMin } : {}),
|
|
667
684
|
...review,
|
|
668
685
|
})
|
|
669
686
|
})
|