@gotcos/glasses-server 6.21.14 → 6.21.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.
@@ -7,6 +7,44 @@ 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 { isSampleFromSession, untraceableSampleCount } from '../lib/training-audio-provenance.js'
13
+ import {
14
+ listMeetingAudioChunks,
15
+ meetingAudioChunkPath,
16
+ meetingAudioRetentionDays,
17
+ } from '../lib/meeting-audio-archive.js'
18
+ import { readVoiceProfiles, retractEmbeddingsBySource } from '../lib/speaker-embeddings.js'
19
+
20
+ /**
21
+ * The label a de-attributed voice takes, numbered within its meeting.
22
+ *
23
+ * NOT a shared `Ext`. De-attributing to one label folded every corrected voice
24
+ * into a single row — on the 2026-08-06 Ditto meeting Miles named five wrong
25
+ * attributions, and collapsing them would have destroyed his ability to tell
26
+ * those five voices apart, which is precisely what he needs playback for next.
27
+ *
28
+ * `Unidentified N` is prefix-matched by isUnattributed(), so the review panel
29
+ * treats the row as unnamed and autoEnroll skips it — a de-attributed stretch
30
+ * cannot re-poison a profile — while staying separable.
31
+ */
32
+ function nextDeattributedLabel(existing: string[]): string {
33
+ const used = new Set(
34
+ existing
35
+ .map(s => new RegExp(`^${DEATTRIBUTED_PREFIX} (\\d+)$`).exec(s)?.[1])
36
+ .filter((n): n is string => Boolean(n))
37
+ .map(Number),
38
+ )
39
+ let n = 1
40
+ while (used.has(n)) n++
41
+ return `${DEATTRIBUTED_PREFIX} ${n}`
42
+ }
43
+ import {
44
+ invalidLabelReason,
45
+ relabelMeetingMarkdown,
46
+ relabelSidecarJson,
47
+ } from '../lib/meeting-relabel.js'
10
48
  import {
11
49
  getMeetingStore,
12
50
  MeetingStore,
@@ -69,7 +107,13 @@ import {
69
107
  findCosOperationsMeetingBySessionId,
70
108
  } from '../lib/cos-operations-meetings.js'
71
109
  import { getOwnerSpeakerLabel } from '../lib/profile.js'
72
- import { reviewMeetingSpeakers, type ReviewChunk } from '../lib/meeting-speaker-review.js'
110
+ import {
111
+ DEATTRIBUTED_PREFIX,
112
+ attachRawChunkIndices,
113
+ isUnattributed,
114
+ reviewMeetingSpeakers,
115
+ type ReviewChunk,
116
+ } from '../lib/meeting-speaker-review.js'
73
117
  import {
74
118
  acquireMaintenanceWork,
75
119
  maintenanceAdmissionsOpen,
@@ -669,9 +713,18 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
669
713
  return
670
714
  }
671
715
 
672
- const review = reviewMeetingSpeakers(chunks as ReviewChunk[], {
716
+ // Raw capture indices, so a phrase can address its own audio. Position in
717
+ // `chunks` is NOT the WAV number — see attachRawChunkIndices.
718
+ const sidecar = (JSON.parse(readFileSync(sidecarPath, 'utf-8')) ?? {}) as Record<string, unknown>
719
+ const withIndices = attachRawChunkIndices(chunks as ReviewChunk[], sidecar.chunkEntries)
720
+ const review = reviewMeetingSpeakers(withIndices, {
673
721
  owner: getOwnerSpeakerLabel(),
674
722
  phrasesPerVoice: Math.max(1, Math.min(6, Number(req.query.phrases) || 3)),
723
+ // The sidecar's own durationMs is the meeting's true end. Deriving it from
724
+ // max(elapsed) uses the START of the last chunk, which made the final
725
+ // timeline span end where it began — a 1.5pt sliver labelled "1s" for what
726
+ // may be a long closing monologue.
727
+ durationMs: typeof sidecar.durationMs === 'number' ? sidecar.durationMs : undefined,
675
728
  })
676
729
  res.set('Cache-Control', 'private, no-store')
677
730
  res.json({
@@ -685,6 +738,459 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
685
738
  })
686
739
  })
687
740
 
741
+
742
+ // ── Per-meeting speaker relabel (6.21.16) ─────────────────────────────
743
+ //
744
+ // Corrects who a voice was in ONE meeting. Deliberately not a global merge:
745
+ // Miles, on the design — "changing it doesn't mean that all previous chunks
746
+ // should also be moved. It should be meeting by meeting, with the goal of
747
+ // hardening or refining the voice profiles." The identifier mishearing a voice
748
+ // in one room is not evidence that every past attribution was wrong.
749
+ //
750
+ // ORDER IS THE WHOLE DESIGN. The ledger intent is written BEFORE any file is
751
+ // touched, and a failed intent write aborts without mutating anything. A
752
+ // process that dies mid-rewrite therefore leaves a visible pending correction
753
+ // rather than a silently half-relabelled meeting.
754
+ router.post('/meeting/:sessionId/relabel', (req, res) => {
755
+ res.set('Cache-Control', 'private, no-store')
756
+ const sessionId = String(req.params.sessionId ?? '')
757
+ if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) {
758
+ res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
759
+ return
760
+ }
761
+
762
+ const from = typeof req.body?.from === 'string' ? req.body.from : ''
763
+ const to = typeof req.body?.to === 'string' ? req.body.to : ''
764
+ const chunks = Array.isArray(req.body?.chunks)
765
+ ? (req.body.chunks as unknown[]).filter((n): n is number => Number.isInteger(n) && (n as number) >= 0)
766
+ : []
767
+ if (Array.isArray(req.body?.chunks) && chunks.length !== req.body.chunks.length) {
768
+ res.status(400).json({ error: 'chunks must be non-negative integers', reason: 'invalid_chunks' })
769
+ return
770
+ }
771
+ for (const [which, label] of [['from', from], ['to', to]] as const) {
772
+ const bad = invalidLabelReason(label)
773
+ if (bad) {
774
+ res.status(400).json({ error: `${which}: ${bad}`, reason: 'invalid_label' })
775
+ return
776
+ }
777
+ }
778
+ if (from === to) {
779
+ res.status(400).json({ error: 'from and to are the same label', reason: 'noop_relabel' })
780
+ return
781
+ }
782
+
783
+ // Same resolution as GET /speakers — operations tree first when configured,
784
+ // so the panel and the correction act on the same copy of the meeting.
785
+ const operations = cosOperationsMeetingsConfigured()
786
+ ? findCosOperationsMeetingBySessionId(sessionId)
787
+ : null
788
+ const saved = operations ? null : store.findBySessionId(sessionId)
789
+ if (!operations && !saved) {
790
+ res.status(404).json({ error: 'No saved meeting for this session', reason: 'meeting_not_found' })
791
+ return
792
+ }
793
+ const sidecarPath = operations?.sidecarPath ?? saved!.sidecarPath
794
+ const meetingPath = operations?.meetingPath ?? saved!.filepath
795
+ const title = operations?.title ?? saved!.title
796
+
797
+ let sidecarRaw: string
798
+ try {
799
+ sidecarRaw = readFileSync(sidecarPath, 'utf-8')
800
+ } catch {
801
+ res.status(422).json({ error: 'Chunk sidecar is missing or unreadable', reason: 'sidecar_unreadable' })
802
+ return
803
+ }
804
+
805
+ const plan = relabelSidecarJson(sidecarRaw, from, to, chunks)
806
+ if (!plan.ok) {
807
+ res.status(422).json({ error: plan.error, reason: 'relabel_rejected' })
808
+ return
809
+ }
810
+
811
+ // The markdown may only be rewritten when EVERY chunk carrying `from` is
812
+ // covered. Its turn segmentation does not match the sidecar's, so a partial
813
+ // relabel has no way to know which turns the selected chunks became.
814
+ let markdownRaw: string | null = null
815
+ let markdownPlan: ReturnType<typeof relabelMeetingMarkdown> | null = null
816
+ if (plan.value.coveredAllWithLabel) {
817
+ try {
818
+ markdownRaw = readFileSync(meetingPath, 'utf-8')
819
+ markdownPlan = relabelMeetingMarkdown(markdownRaw, from, to)
820
+ } catch {
821
+ markdownRaw = null // sidecar-only correction; reported in the response
822
+ }
823
+ }
824
+ const md = markdownPlan?.ok ? markdownPlan.value : null
825
+
826
+ const surfaces = {
827
+ sidecar: plan.value.changed.length,
828
+ attendees: md?.attendees ?? 0,
829
+ transcript: md?.transcript ?? 0,
830
+ }
831
+ const preview = {
832
+ sessionId,
833
+ title,
834
+ from,
835
+ to,
836
+ scope: 'meeting' as const,
837
+ chunks: plan.value.changed,
838
+ surfaces,
839
+ partial: !plan.value.coveredAllWithLabel,
840
+ remainingWithFrom: plan.value.remainingWithFrom,
841
+ speakersAfter: plan.value.speakers,
842
+ proseStale: md?.proseStale ?? false,
843
+ proseHits: md?.proseHits ?? [],
844
+ markdownSkipped: !plan.value.coveredAllWithLabel
845
+ ? 'partial relabel: transcript turns cannot be mapped to chunk indices'
846
+ : markdownRaw === null ? 'meeting markdown unreadable' : null,
847
+ }
848
+
849
+ if (req.body?.dryRun === true || req.body?.confirm !== true) {
850
+ res.status(req.body?.dryRun === true ? 200 : 400).json({
851
+ ...(req.body?.dryRun === true ? {} : { error: 'confirmation required', reason: 'confirmation_required' }),
852
+ message: `Relabelling ${surfaces.sidecar} chunk(s) from "${from}" to "${to}" in this meeting`
853
+ + (preview.proseStale
854
+ ? '. The summary and decisions still name the old speaker and are NOT rewritten — '
855
+ + `prose refers to people by first name (${preview.proseHits.join(', ')}), `
856
+ + 'so substituting it could rewrite a sentence about someone else.'
857
+ : '.'),
858
+ ...preview,
859
+ })
860
+ return
861
+ }
862
+
863
+ // A prior correction that never closed means this meeting's files may already
864
+ // be half-written. Refuse rather than layering a second rewrite on top.
865
+ const stalled = pendingCorrections(sessionId)
866
+ if (stalled.length > 0 && req.body?.force !== true) {
867
+ res.status(409).json({
868
+ error: 'a previous correction on this meeting never completed',
869
+ reason: 'correction_pending',
870
+ pending: stalled.map(r => ({ id: r.id, at: r.at, from: r.from, to: r.to })),
871
+ message: 'Its files may be partly rewritten. Re-check the meeting, then pass { force: true } to proceed.',
872
+ })
873
+ return
874
+ }
875
+
876
+ const id = `${sessionId}:${from}>${to}:${Date.now().toString(36)}`
877
+ const at = new Date().toISOString()
878
+ // Intent first. If this cannot be written, nothing is mutated — an
879
+ // unrecorded rewrite is the exact failure the ledger exists to prevent.
880
+ if (!appendCorrection(sessionId, { id, phase: 'intent', at, from, to, chunks: plan.value.changed, scope: 'meeting' })) {
881
+ res.status(500).json({
882
+ error: 'could not record the correction, so nothing was changed',
883
+ reason: 'ledger_unwritable',
884
+ })
885
+ return
886
+ }
887
+
888
+ try {
889
+ durableAtomicWriteFileSync(sidecarPath, plan.value.json, { mode: 0o600 })
890
+ if (md && (md.attendees > 0 || md.transcript > 0)) {
891
+ durableAtomicWriteFileSync(meetingPath, md.markdown, { mode: 0o600 })
892
+ }
893
+ } catch (err: unknown) {
894
+ const message = err instanceof Error ? err.message : String(err)
895
+ appendCorrection(sessionId, {
896
+ id, phase: 'failed', at: new Date().toISOString(), from, to,
897
+ chunks: plan.value.changed, scope: 'meeting', error: message,
898
+ })
899
+ res.status(500).json({ error: `relabel failed: ${message}`, reason: 'write_failed' })
900
+ return
901
+ }
902
+
903
+ appendCorrection(sessionId, {
904
+ id, phase: 'applied', at: new Date().toISOString(), from, to,
905
+ chunks: plan.value.changed, scope: 'meeting', surfaces,
906
+ proseStale: preview.proseStale,
907
+ })
908
+
909
+ res.json({ ok: true, correctionId: id, ...preview })
910
+ })
911
+
912
+
913
+ // ── De-attribution (6.21.18) ──────────────────────────────────────────
914
+ //
915
+ // The inverse of naming an unknown voice: this voice was NOT that person.
916
+ //
917
+ // Miles, on the 2026-08-06 Ditto meeting: none of the eleven attributed
918
+ // voices were actually in the room, and there was no way to say so. Naming an
919
+ // unknown was possible; un-naming a wrong guess was not.
920
+ //
921
+ // It undoes MORE than a label. When a voice is falsely attributed the
922
+ // identifier may also have auto-enrolled those segments into that person's
923
+ // profile, so the wrong voice is now part of what the system thinks they sound
924
+ // like and will keep matching. Removing only the label fixes the transcript and
925
+ // leaves the profile poisoned — the exact mechanism that grew the phantom
926
+ // "Erick Hernandez" from 3 mislabelled seeds to 18 samples.
927
+ router.post('/meeting/:sessionId/deattribute', (req, res) => {
928
+ res.set('Cache-Control', 'private, no-store')
929
+ const sessionId = String(req.params.sessionId ?? '')
930
+ if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) {
931
+ res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
932
+ return
933
+ }
934
+ const from = typeof req.body?.from === 'string' ? req.body.from : ''
935
+ const bad = invalidLabelReason(from)
936
+ if (bad) {
937
+ res.status(400).json({ error: `from: ${bad}`, reason: 'invalid_label' })
938
+ return
939
+ }
940
+ if (isUnattributed(from)) {
941
+ res.status(400).json({ error: `"${from}" is already unattributed`, reason: 'already_unattributed' })
942
+ return
943
+ }
944
+ const chunks = Array.isArray(req.body?.chunks)
945
+ ? (req.body.chunks as unknown[]).filter((n): n is number => Number.isInteger(n) && (n as number) >= 0)
946
+ : []
947
+ if (Array.isArray(req.body?.chunks) && chunks.length !== req.body.chunks.length) {
948
+ res.status(400).json({ error: 'chunks must be non-negative integers', reason: 'invalid_chunks' })
949
+ return
950
+ }
951
+ // Retracting the training samples is the part that improves accuracy over
952
+ // time, so it defaults ON. Opt out to fix a transcript without touching the
953
+ // profile.
954
+ const retractTraining = req.body?.retractTraining !== false
955
+
956
+ const operations = cosOperationsMeetingsConfigured()
957
+ ? findCosOperationsMeetingBySessionId(sessionId)
958
+ : null
959
+ const saved = operations ? null : store.findBySessionId(sessionId)
960
+ if (!operations && !saved) {
961
+ res.status(404).json({ error: 'No saved meeting for this session', reason: 'meeting_not_found' })
962
+ return
963
+ }
964
+ const sidecarPath = operations?.sidecarPath ?? saved!.sidecarPath
965
+ const meetingPath = operations?.meetingPath ?? saved!.filepath
966
+ const title = operations?.title ?? saved!.title
967
+
968
+ let sidecarRaw: string
969
+ try {
970
+ sidecarRaw = readFileSync(sidecarPath, 'utf-8')
971
+ } catch {
972
+ res.status(422).json({ error: 'Chunk sidecar is missing or unreadable', reason: 'sidecar_unreadable' })
973
+ return
974
+ }
975
+
976
+ // A NUMBERED unidentified label, unique within this meeting, so removing
977
+ // several wrong names does not merge those voices together.
978
+ let existingSpeakers: string[] = []
979
+ try {
980
+ const parsed = JSON.parse(sidecarRaw) as { speakers?: unknown; chunks?: unknown }
981
+ existingSpeakers = Array.isArray(parsed.speakers)
982
+ ? parsed.speakers.filter((x): x is string => typeof x === 'string')
983
+ : []
984
+ // Also scan the chunks: a previous de-attribution may have left a label
985
+ // that never made it into `speakers`.
986
+ if (Array.isArray(parsed.chunks)) {
987
+ for (const c of parsed.chunks) {
988
+ const sp = (c as { speaker?: unknown } | null)?.speaker
989
+ if (typeof sp === 'string' && !existingSpeakers.includes(sp)) existingSpeakers.push(sp)
990
+ }
991
+ }
992
+ } catch { /* the relabel below reports a corrupt sidecar */ }
993
+ const DEATTRIBUTED_LABEL = nextDeattributedLabel(existingSpeakers)
994
+ const plan = relabelSidecarJson(sidecarRaw, from, DEATTRIBUTED_LABEL, chunks)
995
+ if (!plan.ok) {
996
+ res.status(422).json({ error: plan.error, reason: 'deattribute_rejected' })
997
+ return
998
+ }
999
+
1000
+ // What this would remove from the profile, and what it CANNOT reach.
1001
+ // readVoiceProfiles returns a ProfileStore, not an array.
1002
+ const profile = readVoiceProfiles().profiles.find(p => p.name === from)
1003
+ const profileSources: Array<string | undefined> = profile
1004
+ ? Array.from({ length: profile.embeddings.length }, (_, i) => profile.sources?.[i])
1005
+ : []
1006
+ const traceable = profileSources.filter(sourceString => isSampleFromSession(sourceString, sessionId)).length
1007
+ const untraceable = untraceableSampleCount(profileSources)
1008
+
1009
+ let markdownPlan: ReturnType<typeof relabelMeetingMarkdown> | null = null
1010
+ let markdownUnreadable = false
1011
+ if (plan.value.coveredAllWithLabel) {
1012
+ try {
1013
+ markdownPlan = relabelMeetingMarkdown(
1014
+ readFileSync(meetingPath, 'utf-8'), from, DEATTRIBUTED_LABEL,
1015
+ // Remove the attendee bullet outright: renaming it would write
1016
+ // `- Unidentified 2` into the list as though it were a person.
1017
+ { removeAttendee: true },
1018
+ )
1019
+ } catch { markdownPlan = null; markdownUnreadable = true }
1020
+ }
1021
+ const md = markdownPlan?.ok ? markdownPlan.value : null
1022
+
1023
+ const surfaces = {
1024
+ sidecar: plan.value.changed.length,
1025
+ attendees: md?.attendees ?? 0,
1026
+ transcript: md?.transcript ?? 0,
1027
+ }
1028
+ const preview = {
1029
+ sessionId,
1030
+ title,
1031
+ from,
1032
+ to: DEATTRIBUTED_LABEL,
1033
+ // 39% of operations sidecars have no .md beside them (the document was
1034
+ // archived). Without this the response reports sidecar changes and zero
1035
+ // markdown changes with no hint the document was never touched.
1036
+ markdownSkipped: !plan.value.coveredAllWithLabel
1037
+ ? 'partial de-attribution: transcript turns cannot be mapped to chunk indices'
1038
+ : markdownUnreadable ? 'meeting markdown unreadable' : null,
1039
+ scope: 'meeting' as const,
1040
+ chunks: plan.value.changed,
1041
+ surfaces,
1042
+ partial: !plan.value.coveredAllWithLabel,
1043
+ speakersAfter: plan.value.speakers,
1044
+ training: {
1045
+ retract: retractTraining,
1046
+ wouldRetract: retractTraining ? traceable : 0,
1047
+ profileSamples: profileSources.length,
1048
+ // Honest about reach: samples written before train-g2 started stamping
1049
+ // the session cannot be tied to a meeting and survive this.
1050
+ untraceable,
1051
+ },
1052
+ proseStale: md?.proseStale ?? false,
1053
+ proseHits: md?.proseHits ?? [],
1054
+ }
1055
+
1056
+ if (req.body?.dryRun === true || req.body?.confirm !== true) {
1057
+ const notes: string[] = []
1058
+ if (retractTraining && traceable > 0) {
1059
+ notes.push(`${traceable} training sample(s) from this meeting will be removed from "${from}"`)
1060
+ }
1061
+ if (retractTraining && traceable === 0) {
1062
+ notes.push(`no training sample from this meeting is traceable to "${from}", so the profile is unchanged`)
1063
+ }
1064
+ if (untraceable > 0) {
1065
+ notes.push(`${untraceable} older sample(s) on "${from}" carry no meeting provenance and cannot be retracted`)
1066
+ }
1067
+ res.status(req.body?.dryRun === true ? 200 : 400).json({
1068
+ ...(req.body?.dryRun === true ? {} : { error: 'confirmation required', reason: 'confirmation_required' }),
1069
+ message: `Removing "${from}" from ${surfaces.sidecar} segment(s) of this meeting. ${notes.join('. ')}`.trim(),
1070
+ ...preview,
1071
+ })
1072
+ return
1073
+ }
1074
+
1075
+ const stalled = pendingCorrections(sessionId)
1076
+ if (stalled.length > 0 && req.body?.force !== true) {
1077
+ res.status(409).json({
1078
+ error: 'a previous correction on this meeting never completed',
1079
+ reason: 'correction_pending',
1080
+ pending: stalled.map(r => ({ id: r.id, at: r.at, from: r.from, to: r.to })),
1081
+ })
1082
+ return
1083
+ }
1084
+
1085
+ const id = `${sessionId}:${from}>deattributed:${Date.now().toString(36)}`
1086
+ const at = new Date().toISOString()
1087
+ if (!appendCorrection(sessionId, {
1088
+ id, phase: 'intent', at, from, to: DEATTRIBUTED_LABEL, chunks: plan.value.changed, scope: 'meeting',
1089
+ })) {
1090
+ res.status(500).json({
1091
+ error: 'could not record the de-attribution, so nothing was changed',
1092
+ reason: 'ledger_unwritable',
1093
+ })
1094
+ return
1095
+ }
1096
+
1097
+ let retracted = 0
1098
+ try {
1099
+ durableAtomicWriteFileSync(sidecarPath, plan.value.json, { mode: 0o600 })
1100
+ if (md && (md.attendees > 0 || md.transcript > 0)) {
1101
+ durableAtomicWriteFileSync(meetingPath, md.markdown, { mode: 0o600 })
1102
+ }
1103
+ // Profile retraction happens AFTER the transcript is corrected: if the
1104
+ // write fails, the ledger shows an unclosed intent and the profile has not
1105
+ // yet been touched, which is the recoverable order.
1106
+ if (retractTraining && traceable > 0) {
1107
+ retracted = retractEmbeddingsBySource(from, s => isSampleFromSession(s, sessionId)).removed
1108
+ }
1109
+ } catch (err: unknown) {
1110
+ const message = err instanceof Error ? err.message : String(err)
1111
+ appendCorrection(sessionId, {
1112
+ id, phase: 'failed', at: new Date().toISOString(), from, to: DEATTRIBUTED_LABEL,
1113
+ chunks: plan.value.changed, scope: 'meeting', error: message,
1114
+ })
1115
+ res.status(500).json({ error: `de-attribution failed: ${message}`, reason: 'write_failed' })
1116
+ return
1117
+ }
1118
+
1119
+ appendCorrection(sessionId, {
1120
+ id, phase: 'applied', at: new Date().toISOString(), from, to: DEATTRIBUTED_LABEL,
1121
+ chunks: plan.value.changed, scope: 'meeting', surfaces, proseStale: preview.proseStale,
1122
+ })
1123
+
1124
+ res.json({
1125
+ ok: true,
1126
+ correctionId: id,
1127
+ ...preview,
1128
+ training: { ...preview.training, retracted },
1129
+ })
1130
+ })
1131
+
1132
+
1133
+ // ── Review playback (6.21.18) ─────────────────────────────────────────
1134
+ //
1135
+ // Miles: "I can quickly play that, and I'm going to hear the voice and know
1136
+ // immediately who the speaker is. As a final confirmation."
1137
+ //
1138
+ // A phrase in the panel is a weaker signal than three seconds of the actual
1139
+ // voice. Retention is 7 days (see meeting-audio-archive), so this answers with
1140
+ // 404 + a reason once the window has passed rather than pretending the audio
1141
+ // was never there.
1142
+ router.get('/meeting/:sessionId/audio/:chunkIndex', (req, res) => {
1143
+ res.set('Cache-Control', 'private, no-store')
1144
+ const sessionId = String(req.params.sessionId ?? '')
1145
+ if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) {
1146
+ res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
1147
+ return
1148
+ }
1149
+ const chunkIndex = Number(req.params.chunkIndex)
1150
+ if (!Number.isInteger(chunkIndex) || chunkIndex < 0) {
1151
+ res.status(400).json({ error: 'Invalid chunkIndex', reason: 'invalid_chunk_index' })
1152
+ return
1153
+ }
1154
+ const path = meetingAudioChunkPath(sessionId, chunkIndex)
1155
+ if (!path) {
1156
+ const retained = listMeetingAudioChunks(sessionId)
1157
+ res.status(404).json({
1158
+ error: retained.length === 0
1159
+ ? 'No audio retained for this meeting'
1160
+ : `Chunk ${chunkIndex} is not retained`,
1161
+ reason: retained.length === 0 ? 'audio_not_retained' : 'chunk_not_retained',
1162
+ // Which chunks CAN be played, so a UI can offer the nearest instead of
1163
+ // just failing.
1164
+ retainedChunks: retained.length,
1165
+ firstRetained: retained[0] ?? null,
1166
+ lastRetained: retained[retained.length - 1] ?? null,
1167
+ })
1168
+ return
1169
+ }
1170
+ res.type('audio/wav')
1171
+ res.sendFile(path)
1172
+ })
1173
+
1174
+ /** What audio a meeting still has, so the panel can show play buttons only
1175
+ * where they will work. */
1176
+ router.get('/meeting/:sessionId/audio', (req, res) => {
1177
+ res.set('Cache-Control', 'private, no-store')
1178
+ const sessionId = String(req.params.sessionId ?? '')
1179
+ if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) {
1180
+ res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
1181
+ return
1182
+ }
1183
+ const chunks = listMeetingAudioChunks(sessionId)
1184
+ res.json({
1185
+ sessionId,
1186
+ retained: chunks.length > 0,
1187
+ chunks,
1188
+ // Config read, not a filesystem walk: this route used to stat every
1189
+ // retained chunk to report one number.
1190
+ retentionDays: meetingAudioRetentionDays(),
1191
+ })
1192
+ })
1193
+
688
1194
  // ── Unsaved-capture recovery (6.19.0) ─────────────────────────────────
689
1195
  // Surface-only by decision (Miles, 2026-08-02): the server NEVER drives
690
1196
  // recovery on its own. It lists what the quarantine holds, and one
@@ -38,6 +38,11 @@ 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'
45
+ import { archiveSessionAudio, runMeetingAudioRetention } from '../lib/meeting-audio-archive.js'
41
46
  import {
42
47
  countChunkWavs,
43
48
  purgeExpiredQuarantine,
@@ -750,6 +755,22 @@ setInterval(() => {
750
755
  }
751
756
  } catch {}
752
757
 
758
+ // Meeting audio for review: 7-day window, then an 8 GB budget as a backstop.
759
+ // Order matters — sweep expiry FIRST so the cap only ever evicts audio still
760
+ // inside its window instead of racing the sweeper for the same files.
761
+ try {
762
+ const { swept, capped } = runMeetingAudioRetention()
763
+ if (swept.removed.length > 0) {
764
+ console.log(`[meeting-audio] Retention swept ${swept.removed.length} session(s), freed ${(swept.bytesFreed / 1e6).toFixed(1)} MB`)
765
+ }
766
+ if (capped.evicted.length > 0) {
767
+ console.warn(
768
+ `[meeting-audio] Over budget — evicted ${capped.evicted.length} oldest session(s): `
769
+ + `${(capped.bytesBefore / 1e9).toFixed(2)} GB -> ${(capped.bytesAfter / 1e9).toFixed(2)} GB`,
770
+ )
771
+ }
772
+ } catch { /* non-critical */ }
773
+
753
774
  // Purge training-audio WAVs past the retention window. Per file, not per
754
775
  // directory: chunks for one speaker accumulate over weeks, so an
755
776
  // all-or-nothing directory check would either keep month-old audio alive
@@ -788,6 +809,16 @@ setInterval(() => {
788
809
  }
789
810
  } catch {}
790
811
 
812
+ // Sweep expired chunk embeddings. Same shape as the audio sweeps, but a
813
+ // separate window: these are non-invertible timbre vectors, not speech, and
814
+ // the correction loop needs them to survive a weekend.
815
+ try {
816
+ const swept = sweepExpiredChunkEmbeddings(Date.now())
817
+ if (swept.removed.length > 0) {
818
+ console.log(`[chunk-embeddings] Purged ${swept.removed.length} expired session file(s), ${swept.retained.length} retained`)
819
+ }
820
+ } catch {}
821
+
791
822
  // Purge ext-audio dirs older than 72 hours
792
823
  try {
793
824
  if (existsSync(EXT_AUDIO_DIR)) {
@@ -1054,6 +1085,20 @@ export function moveSessionAudioToPending(sessionId: string): string | null {
1054
1085
  destDir = resolve(PENDING_BATCH_DIR, `${sessionId}_${suffix}`)
1055
1086
  }
1056
1087
  }
1088
+ // Retain a reviewable copy BEFORE the rename. Hard links, so this costs no
1089
+ // extra disk and the audio outlives the batch pipeline's own cleanup — which
1090
+ // is what used to end the audio's life entirely (session-audio held 0 files
1091
+ // on 2026-08-06, so the review panel could never play anything back).
1092
+ try {
1093
+ const archived = archiveSessionAudio(sessionId, srcDir)
1094
+ if (archived.linked + archived.copied > 0) {
1095
+ console.log(
1096
+ `[meeting-audio] Retained ${archived.linked + archived.copied} chunk(s) for review: ${sessionId}`
1097
+ + (archived.copied ? ` (${archived.copied} copied, link unavailable)` : '')
1098
+ + (archived.failed ? ` — ${archived.failed} failed` : ''),
1099
+ )
1100
+ }
1101
+ } catch { /* review audio is never worth failing a save for */ }
1057
1102
  // Rename is atomic on same filesystem
1058
1103
  renameSync(srcDir, destDir)
1059
1104
  try { chmodSync(destDir, 0o700) } catch {}
@@ -1498,6 +1543,20 @@ function identifyChunkSpeaker(audioBuffer: Buffer, sessionId: string, chunkIndex
1498
1543
  console.log(`[perf] identifySpeaker: ${(performance.now() - tEmb).toFixed(1)}ms`)
1499
1544
  if (!embeddingResult) return { speaker: clientSpeaker, similarity: 0 }
1500
1545
 
1546
+ // Bank the embedding before anything else can return. This is the only moment
1547
+ // it exists: identifySpeaker computes it per chunk and nothing else keeps it,
1548
+ // so a correction made later has no acoustic evidence without this write.
1549
+ // Deliberately includes 'Ext' — an unidentified voice is the case with no
1550
+ // other way to be trained.
1551
+ if (embeddingResult.embedding) {
1552
+ appendChunkEmbedding(sessionId, {
1553
+ i: chunkIndex,
1554
+ speaker: embeddingResult.speaker,
1555
+ similarity: embeddingResult.similarity,
1556
+ embedding: embeddingResult.embedding,
1557
+ })
1558
+ }
1559
+
1501
1560
  let speaker = embeddingResult.speaker
1502
1561
  if (speaker !== clientSpeaker) {
1503
1562
  console.log(`[speaker] Embedding: ${speaker} vs Amplitude: ${clientSpeaker} (sim: ${embeddingResult.similarity.toFixed(2)})`)