@gotcos/glasses-server 6.21.14 → 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,
@@ -685,6 +692,177 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
685
692
  })
686
693
  })
687
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
+
688
866
  // ── Unsaved-capture recovery (6.19.0) ─────────────────────────────────
689
867
  // Surface-only by decision (Miles, 2026-08-02): the server NEVER drives
690
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)})`)