@gotcos/glasses-server 6.21.13 → 6.21.17

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.
@@ -6,7 +6,9 @@ import { serverMetrics } from '../lib/server-metrics.js'
6
6
  import { getServerInstanceId } from '../lib/server-instance-id.js'
7
7
  import { localFirstMeetingsCapability } from '../lib/local-first-meetings-contract.js'
8
8
  import { isSileroAvailable } from '../lib/vad-silero.js'
9
- import { speakerModelState, speakerReadiness } from '../lib/speaker-embeddings.js'
9
+ import { profileProvenanceSummary, speakerModelState, speakerReadiness } from '../lib/speaker-embeddings.js'
10
+ import { chunkEmbeddingStoreStats } from '../lib/chunk-embedding-store.js'
11
+ import { correctionStoreStats } from '../lib/meeting-corrections.js'
10
12
  import { getAvailableCliSessionId } from '../lib/claude-bridge.js'
11
13
  import {
12
14
  isWhisperLocalAvailable,
@@ -116,6 +118,16 @@ healthRouter.get('/health', async (_req, res) => {
116
118
  const speakerId = speakerModelState()
117
119
  checks.speaker_id = speakerId.state
118
120
 
121
+ // Visible so the correction loop can be seen banking evidence rather than
122
+ // trusted to be. Counts only — no session ids on an unauthenticated surface.
123
+ const chunkEmbeddings = chunkEmbeddingStoreStats()
124
+ // `pending` is the number that matters here: an intent that never closed means
125
+ // some meeting's files may be half-rewritten.
126
+ const speakerCorrections = correctionStoreStats()
127
+ // `noHumanSample` is the one to read: a profile with no human-verified sample
128
+ // is trained entirely on labels the system chose for itself.
129
+ const voiceProvenance = speakerId.state === 'active' ? profileProvenanceSummary() : null
130
+
119
131
  // Health is unauthenticated. Publish only availability; the actual CLI
120
132
  // session id is a resumable runtime handle and belongs on authenticated
121
133
  // query/debug surfaces.
@@ -232,6 +244,9 @@ healthRouter.get('/health', async (_req, res) => {
232
244
  cursor_models,
233
245
  meeting_sync,
234
246
  unsaved_captures,
247
+ chunk_embeddings: chunkEmbeddings,
248
+ speaker_corrections: speakerCorrections,
249
+ ...(voiceProvenance ? { voice_provenance: voiceProvenance } : {}),
235
250
  capabilities: {
236
251
  transcription: {
237
252
  ...transcription,
@@ -7,6 +7,13 @@ import { resolve } from 'node:path'
7
7
  import { Router } from 'express'
8
8
  import { emitDisplay } from '../lib/display-bus.js'
9
9
  import { cleanTranscriptLines } from '../lib/hallucination-filter.js'
10
+ import { durableAtomicWriteFileSync } from '../lib/atomic-fs.js'
11
+ import { appendCorrection, pendingCorrections } from '../lib/meeting-corrections.js'
12
+ import {
13
+ invalidLabelReason,
14
+ relabelMeetingMarkdown,
15
+ relabelSidecarJson,
16
+ } from '../lib/meeting-relabel.js'
10
17
  import {
11
18
  getMeetingStore,
12
19
  MeetingStore,
@@ -64,6 +71,10 @@ import {
64
71
  type TranscriptGapReport,
65
72
  } from './transcribe-stream.js'
66
73
  import { getServerInstanceId } from '../lib/server-instance-id.js'
74
+ import {
75
+ cosOperationsMeetingsConfigured,
76
+ findCosOperationsMeetingBySessionId,
77
+ } from '../lib/cos-operations-meetings.js'
67
78
  import { getOwnerSpeakerLabel } from '../lib/profile.js'
68
79
  import { reviewMeetingSpeakers, type ReviewChunk } from '../lib/meeting-speaker-review.js'
69
80
  import {
@@ -629,20 +640,32 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
629
640
  res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
630
641
  return
631
642
  }
632
- const saved = store.findBySessionId(sessionId)
633
- if (!saved) {
643
+ // Prefer the COS operations copy when configured. The same session exists in
644
+ // both trees under different names — the standalone store keeps the raw
645
+ // capture name, operations holds the titled copy — and the meetings LIST
646
+ // reads operations. Resolving the store first would show one title on the row
647
+ // and a different one in this panel for the same meeting.
648
+ const operations = cosOperationsMeetingsConfigured()
649
+ ? findCosOperationsMeetingBySessionId(sessionId)
650
+ : null
651
+ const saved = operations ? null : store.findBySessionId(sessionId)
652
+ if (!operations && !saved) {
634
653
  res.status(404).json({ error: 'No saved meeting for this session', reason: 'meeting_not_found' })
635
654
  return
636
655
  }
656
+ const sidecarPath = operations?.sidecarPath ?? saved!.sidecarPath
657
+ const title = operations?.title ?? saved!.title
658
+ const domain = operations?.domain ?? saved!.domain
659
+ const filename = operations?.filename ?? saved!.filename
637
660
 
638
661
  let chunks: unknown
639
662
  try {
640
- const raw = JSON.parse(readFileSync(saved.sidecarPath, 'utf-8')) as Record<string, unknown>
663
+ const raw = JSON.parse(readFileSync(sidecarPath, 'utf-8')) as Record<string, unknown>
641
664
  chunks = Array.isArray(raw) ? raw : raw.chunks
642
665
  } 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
666
+ // Defensive: both lookups above already read this sidecar to match the
667
+ // session, so a corrupt file 404s and never reaches here. This covers the
668
+ // narrow race where it becomes unreadable in between. Either
646
669
  // way the answer is never 200-with-no-voices, which would read as
647
670
  // "nobody spoke" and invite naming voices that were never analysed.
648
671
  res.status(422).json({ error: 'Chunk sidecar is missing or unreadable', reason: 'sidecar_unreadable' })
@@ -660,14 +683,186 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
660
683
  res.set('Cache-Control', 'private, no-store')
661
684
  res.json({
662
685
  sessionId,
663
- title: saved.title,
664
- domain: saved.domain,
665
- filename: saved.filename,
666
- durationMin: saved.durationMin,
686
+ title,
687
+ domain,
688
+ filename,
689
+ source: operations ? 'cos_operations' : 'standalone_recordings',
690
+ ...(saved ? { durationMin: saved.durationMin } : {}),
667
691
  ...review,
668
692
  })
669
693
  })
670
694
 
695
+
696
+ // ── Per-meeting speaker relabel (6.21.16) ─────────────────────────────
697
+ //
698
+ // Corrects who a voice was in ONE meeting. Deliberately not a global merge:
699
+ // Miles, on the design — "changing it doesn't mean that all previous chunks
700
+ // should also be moved. It should be meeting by meeting, with the goal of
701
+ // hardening or refining the voice profiles." The identifier mishearing a voice
702
+ // in one room is not evidence that every past attribution was wrong.
703
+ //
704
+ // ORDER IS THE WHOLE DESIGN. The ledger intent is written BEFORE any file is
705
+ // touched, and a failed intent write aborts without mutating anything. A
706
+ // process that dies mid-rewrite therefore leaves a visible pending correction
707
+ // rather than a silently half-relabelled meeting.
708
+ router.post('/meeting/:sessionId/relabel', (req, res) => {
709
+ res.set('Cache-Control', 'private, no-store')
710
+ const sessionId = String(req.params.sessionId ?? '')
711
+ if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) {
712
+ res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
713
+ return
714
+ }
715
+
716
+ const from = typeof req.body?.from === 'string' ? req.body.from : ''
717
+ const to = typeof req.body?.to === 'string' ? req.body.to : ''
718
+ const chunks = Array.isArray(req.body?.chunks)
719
+ ? (req.body.chunks as unknown[]).filter((n): n is number => Number.isInteger(n) && (n as number) >= 0)
720
+ : []
721
+ if (Array.isArray(req.body?.chunks) && chunks.length !== req.body.chunks.length) {
722
+ res.status(400).json({ error: 'chunks must be non-negative integers', reason: 'invalid_chunks' })
723
+ return
724
+ }
725
+ for (const [which, label] of [['from', from], ['to', to]] as const) {
726
+ const bad = invalidLabelReason(label)
727
+ if (bad) {
728
+ res.status(400).json({ error: `${which}: ${bad}`, reason: 'invalid_label' })
729
+ return
730
+ }
731
+ }
732
+ if (from === to) {
733
+ res.status(400).json({ error: 'from and to are the same label', reason: 'noop_relabel' })
734
+ return
735
+ }
736
+
737
+ // Same resolution as GET /speakers — operations tree first when configured,
738
+ // so the panel and the correction act on the same copy of the meeting.
739
+ const operations = cosOperationsMeetingsConfigured()
740
+ ? findCosOperationsMeetingBySessionId(sessionId)
741
+ : null
742
+ const saved = operations ? null : store.findBySessionId(sessionId)
743
+ if (!operations && !saved) {
744
+ res.status(404).json({ error: 'No saved meeting for this session', reason: 'meeting_not_found' })
745
+ return
746
+ }
747
+ const sidecarPath = operations?.sidecarPath ?? saved!.sidecarPath
748
+ const meetingPath = operations?.meetingPath ?? saved!.filepath
749
+ const title = operations?.title ?? saved!.title
750
+
751
+ let sidecarRaw: string
752
+ try {
753
+ sidecarRaw = readFileSync(sidecarPath, 'utf-8')
754
+ } catch {
755
+ res.status(422).json({ error: 'Chunk sidecar is missing or unreadable', reason: 'sidecar_unreadable' })
756
+ return
757
+ }
758
+
759
+ const plan = relabelSidecarJson(sidecarRaw, from, to, chunks)
760
+ if (!plan.ok) {
761
+ res.status(422).json({ error: plan.error, reason: 'relabel_rejected' })
762
+ return
763
+ }
764
+
765
+ // The markdown may only be rewritten when EVERY chunk carrying `from` is
766
+ // covered. Its turn segmentation does not match the sidecar's, so a partial
767
+ // relabel has no way to know which turns the selected chunks became.
768
+ let markdownRaw: string | null = null
769
+ let markdownPlan: ReturnType<typeof relabelMeetingMarkdown> | null = null
770
+ if (plan.value.coveredAllWithLabel) {
771
+ try {
772
+ markdownRaw = readFileSync(meetingPath, 'utf-8')
773
+ markdownPlan = relabelMeetingMarkdown(markdownRaw, from, to)
774
+ } catch {
775
+ markdownRaw = null // sidecar-only correction; reported in the response
776
+ }
777
+ }
778
+ const md = markdownPlan?.ok ? markdownPlan.value : null
779
+
780
+ const surfaces = {
781
+ sidecar: plan.value.changed.length,
782
+ attendees: md?.attendees ?? 0,
783
+ transcript: md?.transcript ?? 0,
784
+ }
785
+ const preview = {
786
+ sessionId,
787
+ title,
788
+ from,
789
+ to,
790
+ scope: 'meeting' as const,
791
+ chunks: plan.value.changed,
792
+ surfaces,
793
+ partial: !plan.value.coveredAllWithLabel,
794
+ remainingWithFrom: plan.value.remainingWithFrom,
795
+ speakersAfter: plan.value.speakers,
796
+ proseStale: md?.proseStale ?? false,
797
+ proseHits: md?.proseHits ?? [],
798
+ markdownSkipped: !plan.value.coveredAllWithLabel
799
+ ? 'partial relabel: transcript turns cannot be mapped to chunk indices'
800
+ : markdownRaw === null ? 'meeting markdown unreadable' : null,
801
+ }
802
+
803
+ if (req.body?.dryRun === true || req.body?.confirm !== true) {
804
+ res.status(req.body?.dryRun === true ? 200 : 400).json({
805
+ ...(req.body?.dryRun === true ? {} : { error: 'confirmation required', reason: 'confirmation_required' }),
806
+ message: `Relabelling ${surfaces.sidecar} chunk(s) from "${from}" to "${to}" in this meeting`
807
+ + (preview.proseStale
808
+ ? '. The summary and decisions still name the old speaker and are NOT rewritten — '
809
+ + `prose refers to people by first name (${preview.proseHits.join(', ')}), `
810
+ + 'so substituting it could rewrite a sentence about someone else.'
811
+ : '.'),
812
+ ...preview,
813
+ })
814
+ return
815
+ }
816
+
817
+ // A prior correction that never closed means this meeting's files may already
818
+ // be half-written. Refuse rather than layering a second rewrite on top.
819
+ const stalled = pendingCorrections(sessionId)
820
+ if (stalled.length > 0 && req.body?.force !== true) {
821
+ res.status(409).json({
822
+ error: 'a previous correction on this meeting never completed',
823
+ reason: 'correction_pending',
824
+ pending: stalled.map(r => ({ id: r.id, at: r.at, from: r.from, to: r.to })),
825
+ message: 'Its files may be partly rewritten. Re-check the meeting, then pass { force: true } to proceed.',
826
+ })
827
+ return
828
+ }
829
+
830
+ const id = `${sessionId}:${from}>${to}:${Date.now().toString(36)}`
831
+ const at = new Date().toISOString()
832
+ // Intent first. If this cannot be written, nothing is mutated — an
833
+ // unrecorded rewrite is the exact failure the ledger exists to prevent.
834
+ if (!appendCorrection(sessionId, { id, phase: 'intent', at, from, to, chunks: plan.value.changed, scope: 'meeting' })) {
835
+ res.status(500).json({
836
+ error: 'could not record the correction, so nothing was changed',
837
+ reason: 'ledger_unwritable',
838
+ })
839
+ return
840
+ }
841
+
842
+ try {
843
+ durableAtomicWriteFileSync(sidecarPath, plan.value.json, { mode: 0o600 })
844
+ if (md && (md.attendees > 0 || md.transcript > 0)) {
845
+ durableAtomicWriteFileSync(meetingPath, md.markdown, { mode: 0o600 })
846
+ }
847
+ } catch (err: unknown) {
848
+ const message = err instanceof Error ? err.message : String(err)
849
+ appendCorrection(sessionId, {
850
+ id, phase: 'failed', at: new Date().toISOString(), from, to,
851
+ chunks: plan.value.changed, scope: 'meeting', error: message,
852
+ })
853
+ res.status(500).json({ error: `relabel failed: ${message}`, reason: 'write_failed' })
854
+ return
855
+ }
856
+
857
+ appendCorrection(sessionId, {
858
+ id, phase: 'applied', at: new Date().toISOString(), from, to,
859
+ chunks: plan.value.changed, scope: 'meeting', surfaces,
860
+ proseStale: preview.proseStale,
861
+ })
862
+
863
+ res.json({ ok: true, correctionId: id, ...preview })
864
+ })
865
+
671
866
  // ── Unsaved-capture recovery (6.19.0) ─────────────────────────────────
672
867
  // Surface-only by decision (Miles, 2026-08-02): the server NEVER drives
673
868
  // recovery on its own. It lists what the quarantine holds, and one
@@ -38,6 +38,10 @@ import {
38
38
  import { transcribeWhisperMeetingPreview } from '../lib/whisper-preview.js'
39
39
  import { dataPath } from '../lib/data-dir.js'
40
40
  import { ageHours, partitionExpiredAudio } from '../lib/audio-retention.js'
41
+ import {
42
+ appendChunkEmbedding,
43
+ sweepExpiredChunkEmbeddings,
44
+ } from '../lib/chunk-embedding-store.js'
41
45
  import {
42
46
  countChunkWavs,
43
47
  purgeExpiredQuarantine,
@@ -788,6 +792,16 @@ setInterval(() => {
788
792
  }
789
793
  } catch {}
790
794
 
795
+ // Sweep expired chunk embeddings. Same shape as the audio sweeps, but a
796
+ // separate window: these are non-invertible timbre vectors, not speech, and
797
+ // the correction loop needs them to survive a weekend.
798
+ try {
799
+ const swept = sweepExpiredChunkEmbeddings(Date.now())
800
+ if (swept.removed.length > 0) {
801
+ console.log(`[chunk-embeddings] Purged ${swept.removed.length} expired session file(s), ${swept.retained.length} retained`)
802
+ }
803
+ } catch {}
804
+
791
805
  // Purge ext-audio dirs older than 72 hours
792
806
  try {
793
807
  if (existsSync(EXT_AUDIO_DIR)) {
@@ -1498,6 +1512,20 @@ function identifyChunkSpeaker(audioBuffer: Buffer, sessionId: string, chunkIndex
1498
1512
  console.log(`[perf] identifySpeaker: ${(performance.now() - tEmb).toFixed(1)}ms`)
1499
1513
  if (!embeddingResult) return { speaker: clientSpeaker, similarity: 0 }
1500
1514
 
1515
+ // Bank the embedding before anything else can return. This is the only moment
1516
+ // it exists: identifySpeaker computes it per chunk and nothing else keeps it,
1517
+ // so a correction made later has no acoustic evidence without this write.
1518
+ // Deliberately includes 'Ext' — an unidentified voice is the case with no
1519
+ // other way to be trained.
1520
+ if (embeddingResult.embedding) {
1521
+ appendChunkEmbedding(sessionId, {
1522
+ i: chunkIndex,
1523
+ speaker: embeddingResult.speaker,
1524
+ similarity: embeddingResult.similarity,
1525
+ embedding: embeddingResult.embedding,
1526
+ })
1527
+ }
1528
+
1501
1529
  let speaker = embeddingResult.speaker
1502
1530
  if (speaker !== clientSpeaker) {
1503
1531
  console.log(`[speaker] Embedding: ${speaker} vs Amplitude: ${clientSpeaker} (sim: ${embeddingResult.similarity.toFixed(2)})`)