@gotcos/glasses-server 6.36.17 → 6.36.18

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,9 @@
1
+ ## 6.36.18
2
+ - **Meetings list now carries voice-assignment tags.** Each row includes
3
+ `voiceReview` from the sidecar head (`speakers[]`) plus whether a human
4
+ correction landed in the ledger. Control paints NEW / N to name / REVIEWED
5
+ without opening each meeting. Still a 4 KB head read — not a chunk parse.
6
+
1
7
  ## 6.36.17
2
8
  - **Naming a new person from a wrong existing label now creates their voice profile.**
3
9
  Enrolment after `POST /relabel` only fired when `from` was a placeholder (`Ext`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.36.17",
3
+ "version": "6.36.18",
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": {
@@ -18,6 +18,7 @@ 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, meetingDayCountsFromNames, meetingListLimit } from './meeting-store.js'
21
+ import { meetingVoiceReview, parseSidecarListHead } from './meeting-voice-review.js'
21
22
 
22
23
  /**
23
24
  * The four domains of ONE user's COS. Retained as the documented example layout
@@ -151,30 +152,35 @@ const SIDECAR_HEAD_BYTES = 4096
151
152
  * them whole would make listing cost scale with total transcript size — and this
152
153
  * lister already reads every markdown file it finds.
153
154
  */
154
- export function sidecarSessionId(monthDir: string, meetingFilename: string): string | undefined {
155
+ export function sidecarListHints(monthDir: string, meetingFilename: string): {
156
+ sessionId?: string
157
+ speakers: string[]
158
+ } {
155
159
  const sidecarName = meetingFilename.replace(/\.md$/, '.g2-chunks.json')
156
- if (sidecarName === meetingFilename) return undefined
160
+ if (sidecarName === meetingFilename) return { speakers: [] }
157
161
  const path = join(monthDir, sidecarName)
158
162
  let fd: number | null = null
159
163
  try {
160
164
  const linkStat = lstatSync(path)
161
- if (linkStat.isSymbolicLink() || !linkStat.isFile() || linkStat.size === 0) return undefined
165
+ if (linkStat.isSymbolicLink() || !linkStat.isFile() || linkStat.size === 0) return { speakers: [] }
162
166
  const real = realpathSync(path)
163
- if (dirname(real) !== realpathSync(monthDir)) return undefined
167
+ if (dirname(real) !== realpathSync(monthDir)) return { speakers: [] }
164
168
  const stat = statSync(real)
165
169
  fd = openSync(path, 'r')
166
170
  const buffer = Buffer.alloc(Math.min(SIDECAR_HEAD_BYTES, stat.size))
167
171
  const read = readSync(fd, buffer, 0, buffer.length, 0)
168
- const match = buffer.subarray(0, read).toString('utf8')
169
- .match(/"sessionId"\s*:\s*"([A-Za-z0-9:_-]{3,96})"/)
170
- return match ? match[1] : undefined
172
+ return parseSidecarListHead(buffer.subarray(0, read).toString('utf8'))
171
173
  } catch {
172
- return undefined
174
+ return { speakers: [] }
173
175
  } finally {
174
176
  if (fd !== null) { try { closeSync(fd) } catch { /* already closed */ } }
175
177
  }
176
178
  }
177
179
 
180
+ export function sidecarSessionId(monthDir: string, meetingFilename: string): string | undefined {
181
+ return sidecarListHints(monthDir, meetingFilename).sessionId
182
+ }
183
+
178
184
  function envPath(name: string): string | null {
179
185
  const raw = process.env[name]?.trim()
180
186
  if (!raw) return null
@@ -563,8 +569,11 @@ export function listCosOperationsMeetings(options: {
563
569
  meta.recordId = `ops:${domain}:${month}:${file}`
564
570
  meta.mutable = true
565
571
  meta.canonicalRecord = `operations/${domain}/meetings/${month}/${file}`
566
- const sessionId = sidecarSessionId(monthDir, file)
567
- if (sessionId) meta.sessionId = sessionId
572
+ const hints = sidecarListHints(monthDir, file)
573
+ if (hints.sessionId) meta.sessionId = hints.sessionId
574
+ if (hints.speakers.length > 0) {
575
+ meta.voiceReview = meetingVoiceReview(hints.speakers, hints.sessionId)
576
+ }
568
577
  if (options.day && meta.date !== options.day) continue
569
578
  allMeetings.push(meta)
570
579
  } catch { /* skip unreadable files */ }
@@ -94,6 +94,13 @@ export interface MeetingMeta {
94
94
  mutable?: boolean
95
95
  /** Present only when the server can state a truthful local record. */
96
96
  canonicalRecord?: string
97
+ /** Additive. Unique sidecar speakers + whether a human correction landed. */
98
+ voiceReview?: {
99
+ voices: number
100
+ unattributedVoices: number
101
+ namedVoices: number
102
+ humanTouched: boolean
103
+ }
97
104
  }
98
105
 
99
106
  export interface MeetingActionItem {
@@ -0,0 +1,53 @@
1
+ // Cheap voice-assignment stats for the meetings LIST.
2
+ //
3
+ // The Speakers panel's "Meetings to review" row cannot open every sidecar just
4
+ // to paint a tag. The unique `speakers` array sits at the top of the sidecar,
5
+ // so a 4 KB head read already used for `sessionId` is enough. Segment counts
6
+ // stay on the per-meeting review route.
7
+
8
+ import { isUnattributed } from './meeting-speaker-review.js'
9
+ import { readCorrections } from './meeting-corrections.js'
10
+
11
+ export interface MeetingVoiceReview {
12
+ voices: number
13
+ unattributedVoices: number
14
+ namedVoices: number
15
+ humanTouched: boolean
16
+ }
17
+
18
+ export function parseSidecarListHead(head: string): {
19
+ sessionId?: string
20
+ speakers: string[]
21
+ } {
22
+ const sessionId = head.match(/"sessionId"\s*:\s*"([A-Za-z0-9:_-]{3,96})"/)?.[1]
23
+ const block = head.match(/"speakers"\s*:\s*\[([^\]]*)\]/)
24
+ const speakers = block
25
+ ? [...block[1].matchAll(/"((?:\\.|[^"\\])*)"/g)].map(match => match[1].replace(/\\"/g, '"'))
26
+ : []
27
+ return { sessionId, speakers }
28
+ }
29
+
30
+ export function meetingVoiceReview(
31
+ speakers: string[],
32
+ sessionId?: string,
33
+ ): MeetingVoiceReview {
34
+ const labels = [...new Set(speakers.map(name => name.trim()).filter(Boolean))]
35
+ const unattributedVoices = labels.filter(isUnattributed).length
36
+ return {
37
+ voices: labels.length,
38
+ unattributedVoices,
39
+ namedVoices: Math.max(0, labels.length - unattributedVoices),
40
+ humanTouched: sessionWasHumanTouched(sessionId),
41
+ }
42
+ }
43
+
44
+ export function sessionWasHumanTouched(sessionId?: string): boolean {
45
+ if (!sessionId) return false
46
+ try {
47
+ return readCorrections(sessionId).rows.some(
48
+ row => row.phase === 'applied' || row.phase === 'confirmed',
49
+ )
50
+ } catch {
51
+ return false
52
+ }
53
+ }