@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.21.13",
3
+ "version": "6.21.14",
4
4
  "description": "COS Glasses — self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, or Cursor Agent CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
  }
@@ -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
- const saved = store.findBySessionId(sessionId)
633
- if (!saved) {
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(saved.sidecarPath, 'utf-8')) as Record<string, unknown>
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: findBySessionId already parsed this sidecar to match the
644
- // session, so a corrupt file 404s above and never reaches here. This
645
- // covers the narrow race where it becomes unreadable in between. Either
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: saved.title,
664
- domain: saved.domain,
665
- filename: saved.filename,
666
- durationMin: saved.durationMin,
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
  })