@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.
- package/CHANGELOG.md +91 -0
- package/package.json +1 -1
- package/server/lib/chunk-embedding-store.ts +260 -0
- 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 +178 -0
- 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,
|
|
@@ -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)})`)
|