@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.
- package/CHANGELOG.md +105 -0
- package/package.json +1 -1
- package/server/lib/chunk-embedding-store.ts +260 -0
- package/server/lib/cos-operations-meetings.ts +99 -1
- package/server/lib/embedding-eviction.ts +133 -0
- package/server/lib/meeting-corrections.ts +218 -0
- package/server/lib/meeting-relabel.ts +267 -0
- package/server/lib/speaker-embeddings.ts +63 -9
- package/server/lib/voice-profile-store.ts +36 -1
- package/server/routes/health.ts +16 -1
- package/server/routes/meeting.ts +205 -10
- package/server/routes/transcribe-stream.ts +28 -0
package/server/routes/health.ts
CHANGED
|
@@ -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,
|
package/server/routes/meeting.ts
CHANGED
|
@@ -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
|
-
|
|
633
|
-
|
|
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(
|
|
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:
|
|
644
|
-
// session, so a corrupt file 404s
|
|
645
|
-
//
|
|
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
|
|
664
|
-
domain
|
|
665
|
-
filename
|
|
666
|
-
|
|
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)})`)
|