@gotcos/glasses-server 6.91.1 → 6.93.0
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/.env.example +64 -2
- package/CHANGELOG.md +154 -29
- package/README.md +90 -1
- package/docs/active-query-jobs.md +1 -1
- package/docs/test-plan-6.71.7.md +1 -1
- package/managed-runtime-contract.json +10 -1
- package/package.json +2 -1
- package/server/lib/ask-session-id.ts +2 -1
- package/server/lib/claude-circuit.ts +8 -4
- package/server/lib/claude-text-runner.ts +113 -0
- package/server/lib/dictation-fillers-fixture-cases.ts +214 -0
- package/server/lib/dictation-fillers.ts +30 -0
- package/server/lib/dictation-meaning-guard.ts +47 -0
- package/server/lib/dictation-polish-pipeline.ts +90 -12
- package/server/lib/embedding-eviction.ts +60 -2
- package/server/lib/fuzzy-correct.ts +1 -1
- package/server/lib/hallucination-filter.ts +1 -1
- package/server/lib/imported-library-rows.ts +4 -2
- package/server/lib/live-cos-ask.ts +3 -2
- package/server/lib/mac-live-voices.ts +57 -0
- package/server/lib/mac-recording-asr.ts +16 -8
- package/server/lib/mac-speaker-projection.ts +211 -0
- package/server/lib/mac-stream-diarizer.ts +185 -0
- package/server/lib/mac-voice-assignment.ts +361 -0
- package/server/lib/mac-voice-labels.ts +70 -8
- package/server/lib/mac-window-replay.ts +271 -0
- package/server/lib/mac-window-voices.ts +390 -0
- package/server/lib/meeting-actions.ts +1 -1
- package/server/lib/meeting-ask-owner.ts +4 -4
- package/server/lib/meeting-ask-transcript-sources.ts +41 -3
- package/server/lib/meeting-audio-archive.ts +4 -0
- package/server/lib/meeting-batch-transcribe.ts +7 -1
- package/server/lib/meeting-context-keys.ts +6 -3
- package/server/lib/meeting-corrections.ts +4 -4
- package/server/lib/meeting-fact-check.ts +3 -3
- package/server/lib/meeting-parse.ts +1 -1
- package/server/lib/meeting-relabel-enrolment.ts +8 -8
- package/server/lib/meeting-relabel.ts +3 -3
- package/server/lib/meeting-scribe-content.ts +1 -1
- package/server/lib/meeting-speaker-review.ts +8 -8
- package/server/lib/meeting-store.ts +19 -9
- package/server/lib/nemotron-final.ts +114 -9
- package/server/lib/nemotron-live.ts +58 -6
- package/server/lib/phone-recording.ts +114 -0
- package/server/lib/prompt-edit.ts +84 -3
- package/server/lib/prompt-tail-guard.ts +1 -1
- package/server/lib/speaker-embeddings.ts +154 -28
- package/server/lib/speaker-name.ts +1 -1
- package/server/lib/stranded-session-actions.ts +7 -2
- package/server/lib/token-audit.ts +6 -1
- package/server/lib/training-audio-provenance.ts +3 -2
- package/server/lib/transcript-cleanup-context.ts +3 -3
- package/server/lib/transcript-cleanup-model.ts +1 -1
- package/server/lib/transcript-cleanup-spans.ts +10 -10
- package/server/lib/transcript-cleanup-text.ts +4 -4
- package/server/lib/transcript-cleanup.ts +1 -1
- package/server/lib/unsaved-audio-quarantine.ts +10 -1
- package/server/lib/voice-directory.ts +1 -1
- package/server/lib/voice-evidence-guard.ts +2 -1
- package/server/lib/voice-profile-store.ts +11 -5
- package/server/lib/whisper-local.ts +1 -1
- package/server/routes/dictation-polish-settings.ts +8 -1
- package/server/routes/health.ts +8 -0
- package/server/routes/meeting.ts +189 -19
- package/server/routes/meetings.ts +9 -5
- package/server/routes/prompt-drafts.ts +3 -2
- package/server/routes/prompt-edit.ts +47 -5
- package/server/routes/transcribe-stream.ts +140 -15
- package/server/routes/voice.ts +12 -4
- package/server/scripts/dictation-parity-6920-mutation-gate.mjs +229 -0
- package/server/scripts/generate-dictation-fillers-fixture.ts +18 -0
- package/server/scripts/mac-voice-assignment-6930-mutation-gate.mjs +225 -0
- package/server/scripts/mac-window-voices-6930-mutation-gate.mjs +193 -0
|
@@ -85,6 +85,14 @@ export interface LiveNamingInput {
|
|
|
85
85
|
/** The wearer's label (`owner_speaker_label`), for the owner guard. */
|
|
86
86
|
owner?: string | null
|
|
87
87
|
identify: (wav: Buffer) => VoiceprintResult | null
|
|
88
|
+
/** Mac-only capture-local continuity; never an identity or a channel ID. */
|
|
89
|
+
anonymousVoice?: (wav: Buffer) => string | null
|
|
90
|
+
/**
|
|
91
|
+
* 6.93.0 window replay (mac-window-voices.ts): the confirmed `Voice N` for an
|
|
92
|
+
* activity channel, already carried across chunk boundaries, or null. When set it
|
|
93
|
+
* replaces `anonymousVoice`; a real name from `identify` still wins.
|
|
94
|
+
*/
|
|
95
|
+
anonymousChannel?: (channel: number) => string | null
|
|
88
96
|
}
|
|
89
97
|
|
|
90
98
|
export type LiveNamingResult =
|
|
@@ -122,7 +130,7 @@ function isRealLabel(label: string): boolean {
|
|
|
122
130
|
* - in a split chunk the WEARER is named only at owner-verify confidence
|
|
123
131
|
* (OWNER_VERIFY_SIMILARITY). A full search can return the wearer from 0.55,
|
|
124
132
|
* and the client's label is the glasses' default wearer label, not identity
|
|
125
|
-
* evidence: on 2026-10-02 both named other people (
|
|
133
|
+
* evidence: on 2026-10-02 both named other people (Jasper, Corin) as the
|
|
126
134
|
* wearer. Either becomes Ext and is counted (`ownerGuard`).
|
|
127
135
|
* A name is never invented.
|
|
128
136
|
* The segments' texts joined with one space equal `text` exactly.
|
|
@@ -173,7 +181,7 @@ export function nameChunkSegments(input: LiveNamingInput): LiveNamingResult {
|
|
|
173
181
|
}
|
|
174
182
|
if (track.channel === dominant) { names.set(track.channel, ownerSafe(input.chunkVoiceprint)); continue }
|
|
175
183
|
// The client's label is the glasses' default wearer label, not evidence of
|
|
176
|
-
// who this voice is (6.61.0 named
|
|
184
|
+
// who this voice is (6.61.0 named Jasper Quillen as MU through it).
|
|
177
185
|
const dominantName = names.get(dominant)?.speaker ?? input.chunkVoiceprint.speaker
|
|
178
186
|
const client = input.clientSpeaker.trim()
|
|
179
187
|
if (isRealLabel(client) && client !== dominantName) ownerGuard.client_label++
|
|
@@ -181,6 +189,30 @@ export function nameChunkSegments(input: LiveNamingInput): LiveNamingResult {
|
|
|
181
189
|
}
|
|
182
190
|
}
|
|
183
191
|
|
|
192
|
+
// Live anonymous labels, one of two paths (never both, window wins):
|
|
193
|
+
// - anonymousChannel (6.93.0 window replay): every track takes the label its
|
|
194
|
+
// channel was already proven to hold, short turns included, because the
|
|
195
|
+
// crosswalk (not this gate) required the clean proof;
|
|
196
|
+
// - anonymousVoice (6.92.1 anchors, below): only a clean, sufficiently long
|
|
197
|
+
// isolated track may establish continuity; short or mixed tracks cannot
|
|
198
|
+
// inherit the dominant chunk's anonymous identity.
|
|
199
|
+
if (input.anonymousChannel) for (const track of ordered) {
|
|
200
|
+
// The window crosswalk already required clean overlap and two clean proof
|
|
201
|
+
// chunks for this track, so a short turn by a confirmed voice keeps its label.
|
|
202
|
+
const current = names.get(track.channel)
|
|
203
|
+
if (current && isRealLabel(current.speaker)) continue
|
|
204
|
+
const label = input.anonymousChannel(track.channel)
|
|
205
|
+
if (label && isVoiceLabel(label)) names.set(track.channel, { speaker: label, similarity: 0 })
|
|
206
|
+
} else if (input.anonymousVoice && pcm) for (const track of ordered) {
|
|
207
|
+
const current = names.get(track.channel)
|
|
208
|
+
if (current && isRealLabel(current.speaker)) continue
|
|
209
|
+
if (track.exclusiveSec < NAME_TRACK_MIN_SEC || track.exclusiveSec / track.activeSec < 0.9) continue
|
|
210
|
+
const isolated = exclusiveTrackWav(activity, track.channel, pcm)
|
|
211
|
+
if (!isolated) continue
|
|
212
|
+
const label = input.anonymousVoice(isolated)
|
|
213
|
+
if (label && isVoiceLabel(label)) names.set(track.channel, { speaker: label, similarity: 0 })
|
|
214
|
+
}
|
|
215
|
+
|
|
184
216
|
const segments: ChunkSpeakerSegment[] = []
|
|
185
217
|
const nameOf = (channel: number): VoiceprintResult => names.get(channel) ?? input.chunkVoiceprint
|
|
186
218
|
for (const run of runsOf(labels)) {
|
|
@@ -269,6 +301,10 @@ export interface LiveLabelInput {
|
|
|
269
301
|
/** The wearer's label, for the owner guard on split chunks. */
|
|
270
302
|
owner?: string | null
|
|
271
303
|
identify: (wav: Buffer) => VoiceprintResult | null
|
|
304
|
+
/** Mac-only capture-local continuity; never an identity or a channel ID. */
|
|
305
|
+
anonymousVoice?: (wav: Buffer) => string | null
|
|
306
|
+
/** 6.93.0 window replay; see LiveNamingInput. */
|
|
307
|
+
anonymousChannel?: (channel: number) => string | null
|
|
272
308
|
clock?: () => number
|
|
273
309
|
}
|
|
274
310
|
|
|
@@ -334,6 +370,8 @@ export async function resolveLiveLabels(input: LiveLabelInput): Promise<LiveLabe
|
|
|
334
370
|
clientSpeaker: input.clientSpeaker,
|
|
335
371
|
owner: input.owner,
|
|
336
372
|
identify: input.identify,
|
|
373
|
+
anonymousVoice: input.anonymousVoice,
|
|
374
|
+
anonymousChannel: input.anonymousChannel,
|
|
337
375
|
})
|
|
338
376
|
if (!named.ok) return voiceprintOnly(named.reason, ms)
|
|
339
377
|
const changed = named.speaker !== input.voiceprint.speaker
|
|
@@ -409,6 +447,15 @@ export interface NemotronLiveDeps {
|
|
|
409
447
|
}
|
|
410
448
|
|
|
411
449
|
export const LIVE_BUDGET_MS = 1500
|
|
450
|
+
/** The audio length LIVE_BUDGET_MS was sized for: one 6 s chunk (both transports cut 6 s chunks). */
|
|
451
|
+
export const LIVE_BUDGET_REFERENCE_AUDIO_SEC = 6
|
|
452
|
+
/**
|
|
453
|
+
* The budget for a run over `audioSec` of audio: LIVE_BUDGET_MS per reference chunk,
|
|
454
|
+
* never less than one chunk's budget. A 30 s window is five chunks' worth, not one.
|
|
455
|
+
*/
|
|
456
|
+
export function liveBudgetForAudioSec(audioSec: number): number {
|
|
457
|
+
return Math.max(LIVE_BUDGET_MS, Math.ceil(LIVE_BUDGET_MS * (Number.isFinite(audioSec) ? audioSec : 0) / LIVE_BUDGET_REFERENCE_AUDIO_SEC))
|
|
458
|
+
}
|
|
412
459
|
export const LIVE_MAX_CONCURRENT = 2
|
|
413
460
|
export const WARM_TIMEOUT_MS = 180_000
|
|
414
461
|
export const WARM_RETRY_DELAYS_MS = [5_000, 30_000]
|
|
@@ -600,8 +647,13 @@ export class NemotronLiveRuntime {
|
|
|
600
647
|
return null
|
|
601
648
|
}
|
|
602
649
|
|
|
603
|
-
/**
|
|
604
|
-
|
|
650
|
+
/**
|
|
651
|
+
* Run Nemotron on one chunk WAV within the live budget. Never throws.
|
|
652
|
+
* `timeoutMs` replaces the per-chunk budget (a longer clip). `timeoutTripsBreaker: false`
|
|
653
|
+
* keeps a timeout of THIS run out of the shared breaker (6.93.0: a window timeout has a
|
|
654
|
+
* per-chunk fallback and must not cool Nemotron for G2 and every other capture).
|
|
655
|
+
*/
|
|
656
|
+
async diarize(wavPath: string, options: { timeoutMs?: number; timeoutTripsBreaker?: boolean } = {}): Promise<LiveDiarizeOutcome> {
|
|
605
657
|
const blocked = this.gate()
|
|
606
658
|
if (blocked) return { ok: false, reason: blocked }
|
|
607
659
|
this.inFlight++
|
|
@@ -611,7 +663,7 @@ export class NemotronLiveRuntime {
|
|
|
611
663
|
cli: this.cli!,
|
|
612
664
|
modelsDir: this.modelsDir!,
|
|
613
665
|
wavPath,
|
|
614
|
-
timeoutMs: this.budget(),
|
|
666
|
+
timeoutMs: options.timeoutMs ?? this.budget(),
|
|
615
667
|
computeUnits: 'ane',
|
|
616
668
|
niceness: 10,
|
|
617
669
|
tmpRoot: this.deps.tmpRoot?.(),
|
|
@@ -621,7 +673,7 @@ export class NemotronLiveRuntime {
|
|
|
621
673
|
return { ok: true, run }
|
|
622
674
|
} catch (error) {
|
|
623
675
|
const reason: LiveFallbackReason = error instanceof NemotronRunError ? error.reason : 'error'
|
|
624
|
-
if (RUN_FAILURES.has(reason)) {
|
|
676
|
+
if (RUN_FAILURES.has(reason) && !(reason === 'timeout' && options.timeoutTripsBreaker === false)) {
|
|
625
677
|
this.consecutiveFailures++
|
|
626
678
|
if (this.consecutiveFailures >= BREAKER_FAILURES && this.state === 'ready') {
|
|
627
679
|
// The breaker (QA 6.61.0): stop spawning. Every chunk takes the voiceprint
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/** Explicit iPhone microphone provenance. Default-off future protocol; never G2 or Mac audio. */
|
|
2
|
+
import { constants, closeSync, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { dataPath } from './data-dir.js'
|
|
5
|
+
import { durableAtomicWriteFileSync } from './atomic-fs.js'
|
|
6
|
+
import { macError, validateMacWav } from './mac-recording.js'
|
|
7
|
+
import { readSpeakerPolicy, writeSpeakerPolicy } from './meeting-speaker-policy.js'
|
|
8
|
+
import { readSttSettings } from './stt-settings.js'
|
|
9
|
+
import { parakeetInstalled } from './parakeet.js'
|
|
10
|
+
|
|
11
|
+
export const PHONE_SOURCE_TYPE = 'iphone_microphone' as const
|
|
12
|
+
export const PHONE_CAPTURE_ID = /^phone_(?:[0-9]{13}_)?[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/
|
|
13
|
+
export interface PhoneRecording { version: 1; captureId: string; sessionId: string; sourceType: typeof PHONE_SOURCE_TYPE; speakerPolicy: 'anonymous'; startTime: number; closedAt?: number }
|
|
14
|
+
export function phoneMicrophoneMeetingsCapability(serverInstanceId: string | null) {
|
|
15
|
+
let reason = 'disabled'
|
|
16
|
+
if (process.env.COS_IPHONE_MICROPHONE_MEETINGS === '1') {
|
|
17
|
+
try { reason = !serverInstanceId ? 'server_identity_unavailable' : readSttSettings().hq !== 'parakeet-ultra' ? 'parakeet_not_selected' : !parakeetInstalled().installed ? 'parakeet_not_installed' : 'ready' }
|
|
18
|
+
catch { reason = 'parakeet_unavailable' }
|
|
19
|
+
}
|
|
20
|
+
return { enabled: reason === 'ready', protocolVersion: 1 as const, sourceType: PHONE_SOURCE_TYPE, speakerPolicy: 'anonymous' as const, asrModel: 'parakeet-ultra', dictionary: 'server-profile-corrections', reason }
|
|
21
|
+
}
|
|
22
|
+
/** Phone IDs and the new source marker must agree, including on rejected/replayed requests. */
|
|
23
|
+
export function assertPhoneProvenance(id: unknown, fields: Record<string, unknown> | undefined): boolean {
|
|
24
|
+
// A repeated query key arrives as an array; it is never a phone ID and never a 500.
|
|
25
|
+
if (typeof id !== 'string') {
|
|
26
|
+
if (fields?.sourceType === PHONE_SOURCE_TYPE) throw macError(400, 'invalid_phone_capture_id', 'Invalid iPhone capture ID')
|
|
27
|
+
return false
|
|
28
|
+
}
|
|
29
|
+
const phone = id.startsWith('phone_')
|
|
30
|
+
// Anonymous is a shared speaker policy, not evidence of a phone source.
|
|
31
|
+
if (!phone && fields?.sourceType !== PHONE_SOURCE_TYPE) return false
|
|
32
|
+
if (!PHONE_CAPTURE_ID.test(id) || fields?.sourceType !== PHONE_SOURCE_TYPE || fields?.speakerPolicy !== 'anonymous') throw macError(400, 'phone_provenance_required', 'iPhone microphone capture requires its phone ID, source type and anonymous policy')
|
|
33
|
+
return true
|
|
34
|
+
}
|
|
35
|
+
function pathFor(id: string): string {
|
|
36
|
+
if (!PHONE_CAPTURE_ID.test(id)) throw macError(400, 'invalid_phone_capture_id', 'Invalid iPhone capture ID')
|
|
37
|
+
return join(dataPath('phone-recordings'), id + '.json')
|
|
38
|
+
}
|
|
39
|
+
export function readPhoneRecording(id: string): PhoneRecording | null {
|
|
40
|
+
if (!id.startsWith('phone_')) return null
|
|
41
|
+
const path = pathFor(id)
|
|
42
|
+
try {
|
|
43
|
+
if (lstatSync(path).isSymbolicLink()) throw Error('symbolic link')
|
|
44
|
+
const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK)
|
|
45
|
+
try {
|
|
46
|
+
const stat = fstatSync(fd)
|
|
47
|
+
if (!stat.isFile() || stat.size > 4096) throw Error('invalid metadata')
|
|
48
|
+
const value = JSON.parse(readFileSync(fd, 'utf8'))
|
|
49
|
+
if (value.version !== 1 || value.captureId !== id || value.sessionId !== id || value.sourceType !== PHONE_SOURCE_TYPE || value.speakerPolicy !== 'anonymous' || !Number.isSafeInteger(value.startTime) || value.startTime <= 0 || (value.closedAt !== undefined && (!Number.isSafeInteger(value.closedAt) || value.closedAt <= 0))) throw Error('invalid metadata')
|
|
50
|
+
return value
|
|
51
|
+
} finally { closeSync(fd) }
|
|
52
|
+
} catch (error) {
|
|
53
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null
|
|
54
|
+
throw macError(409, 'phone_capture_metadata_unreadable', 'iPhone capture provenance is unreadable; audio is retained')
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
export function requirePhoneRecording(id: string): PhoneRecording {
|
|
58
|
+
const value = readPhoneRecording(id)
|
|
59
|
+
if (!value) throw macError(409, 'phone_capture_provenance_missing', 'iPhone capture provenance is missing; audio is retained')
|
|
60
|
+
if (readSpeakerPolicy(id).mode !== 'anonymous' || readSpeakerPolicy(id).revision === 'unreadable') throw macError(409, 'phone_capture_policy_changed', 'iPhone microphone capture is anonymous')
|
|
61
|
+
return value
|
|
62
|
+
}
|
|
63
|
+
export function admitPhoneRecording(id: string, startTime: number | undefined, serverInstanceId: string): PhoneRecording {
|
|
64
|
+
const capability = phoneMicrophoneMeetingsCapability(serverInstanceId)
|
|
65
|
+
if (!capability.enabled) throw macError(503, 'phone_microphone_' + capability.reason, 'iPhone microphone recording is unavailable: ' + capability.reason)
|
|
66
|
+
const prior = readPhoneRecording(id)
|
|
67
|
+
if (prior) {
|
|
68
|
+
if (prior.closedAt) throw macError(410, 'session_closed', 'Capture is already saved')
|
|
69
|
+
if (startTime !== undefined && Number(startTime) !== prior.startTime) throw macError(409, 'phone_capture_conflict', 'Capture ID already belongs to another start time')
|
|
70
|
+
return requirePhoneRecording(id)
|
|
71
|
+
}
|
|
72
|
+
// Never retroactively turn an old/unlabelled recording into a phone capture.
|
|
73
|
+
if (existsSync(dataPath('active-sessions', id + '.json')) || existsSync(dataPath('session-audio', id)) || existsSync(dataPath('meeting-audio', id)) || existsSync(dataPath('pending-batch', id)) || existsSync(dataPath('unsaved-audio', id))) throw macError(409, 'phone_capture_provenance_missing', 'Existing audio has no admitted iPhone provenance')
|
|
74
|
+
const at = Number(startTime)
|
|
75
|
+
const epoch = /^phone_([0-9]{13})_/.exec(id)?.[1]
|
|
76
|
+
if (epoch && Number(epoch) !== at) throw macError(409, 'phone_capture_conflict', 'Capture ID does not match its original start time')
|
|
77
|
+
if (!Number.isSafeInteger(at) || at <= 0 || at > Date.now() + 300000) throw macError(400, 'phone_start_time_required', 'Original iPhone capture start time required')
|
|
78
|
+
const policy = readSpeakerPolicy(id)
|
|
79
|
+
if (policy.revision !== 'unset' && policy.mode !== 'anonymous') throw macError(409, 'phone_capture_policy_conflict', 'Capture has an incompatible speaker policy')
|
|
80
|
+
if (policy.revision === 'unset') writeSpeakerPolicy(id, 'anonymous', policy.revision)
|
|
81
|
+
const value: PhoneRecording = { version: 1, captureId: id, sessionId: id, sourceType: PHONE_SOURCE_TYPE, speakerPolicy: 'anonymous', startTime: at }
|
|
82
|
+
mkdirSync(dataPath('phone-recordings'), { recursive: true, mode: 0o700 })
|
|
83
|
+
durableAtomicWriteFileSync(pathFor(id), JSON.stringify(value), { mode: 0o600 })
|
|
84
|
+
return value
|
|
85
|
+
}
|
|
86
|
+
export function closePhoneRecording(id: string): void {
|
|
87
|
+
const value = requirePhoneRecording(id)
|
|
88
|
+
if (!value.closedAt) durableAtomicWriteFileSync(pathFor(id), JSON.stringify({ ...value, closedAt: Date.now() }), { mode: 0o600 })
|
|
89
|
+
}
|
|
90
|
+
export function validatePhoneWav(audio: Buffer): number {
|
|
91
|
+
try { return validateMacWav(audio) } catch { throw macError(400, 'invalid_phone_audio', 'Expected a bounded canonical 16-kHz mono PCM16 WAV') }
|
|
92
|
+
}
|
|
93
|
+
/** Phone ingress numbers are canonical decimal strings, never coerced arrays, hex, exponents or blanks. */
|
|
94
|
+
const PHONE_DECIMAL = /^(?:0|[1-9][0-9]{0,15})$/
|
|
95
|
+
export function phoneQueryInteger(value: unknown, reason: string, message: string): number {
|
|
96
|
+
if (typeof value !== 'string' || !PHONE_DECIMAL.test(value) || !Number.isSafeInteger(Number(value))) throw macError(400, reason, message)
|
|
97
|
+
return Number(value)
|
|
98
|
+
}
|
|
99
|
+
/** A receipt precedes ASR so retry after decode failure cannot overwrite accepted raw audio. */
|
|
100
|
+
export function persistPhoneChunkReceipt(id: string, chunkIndex: number, audioSha256: string, elapsed: number, durationMs: number): void {
|
|
101
|
+
requirePhoneRecording(id)
|
|
102
|
+
const dir = dataPath('phone-recording-chunks', id), path = join(dir, chunkIndex + '.json')
|
|
103
|
+
const value = { chunkIndex, audioSha256, elapsed, durationMs }
|
|
104
|
+
try {
|
|
105
|
+
const stat = lstatSync(path)
|
|
106
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 1024) throw Error('invalid receipt')
|
|
107
|
+
if (JSON.stringify(JSON.parse(readFileSync(path, 'utf8'))) !== JSON.stringify(value)) throw Error('conflict')
|
|
108
|
+
return
|
|
109
|
+
} catch (error) {
|
|
110
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw macError(409, 'phone_chunk_conflict', 'Chunk index holds different audio/timing or unreadable metadata')
|
|
111
|
+
}
|
|
112
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
113
|
+
durableAtomicWriteFileSync(path, JSON.stringify(value), { mode: 0o600 })
|
|
114
|
+
}
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process'
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync } from 'node:fs'
|
|
3
|
+
import { dirname } from 'node:path'
|
|
2
4
|
import { claudeKeySecretsGuardArgs } from './key-secrets-guard.js'
|
|
5
|
+
import { runClaudeText } from './claude-text-runner.js'
|
|
6
|
+
import { createBreaker } from './claude-circuit.js'
|
|
7
|
+
import { readSwitch } from './env-switch.js'
|
|
8
|
+
import { atomicWriteFileSync } from './atomic-fs.js'
|
|
9
|
+
import { dataPath } from './data-dir.js'
|
|
10
|
+
import { localDay } from './dictation-polish-budget.js'
|
|
3
11
|
|
|
4
12
|
export const PROMPT_EDIT_DRAFT_MAX_CHARS = 24_000
|
|
5
13
|
export const PROMPT_EDIT_INSTRUCTION_MAX_CHARS = 2_000
|
|
@@ -178,17 +186,90 @@ export function spawnClaudeText(prompt: string, opts: SpawnClaudeTextOptions = {
|
|
|
178
186
|
})
|
|
179
187
|
}
|
|
180
188
|
|
|
189
|
+
export const PROMPT_EDIT_SYSTEM_PROMPT = 'You revise dictated prompt drafts. Output only the revised prompt text.'
|
|
190
|
+
export const promptEditModel = (env: NodeJS.ProcessEnv = process.env) => env.COS_PROMPT_EDIT_MODEL || 'sonnet'
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* G2's prompt edit (POST /api/prompt-edit). 6.92.0: through the isolated text runner
|
|
194
|
+
* (claude-text-runner.ts: no tools, MCP, hooks or session, a scratch directory), with the same
|
|
195
|
+
* model, effort and 45 s timeout settings as before.
|
|
196
|
+
*/
|
|
181
197
|
export async function applyPromptEdit(input: PromptEditInput, signal?: AbortSignal): Promise<string> {
|
|
182
|
-
return
|
|
183
|
-
model:
|
|
198
|
+
return runClaudeText(buildPromptEditPrompt(input), {
|
|
199
|
+
model: promptEditModel(),
|
|
184
200
|
effort: process.env.COS_PROMPT_EDIT_EFFORT || 'low',
|
|
185
201
|
timeoutMs: Number.parseInt(process.env.COS_PROMPT_EDIT_TIMEOUT_MS || '45000', 10),
|
|
186
|
-
systemPrompt:
|
|
202
|
+
systemPrompt: PROMPT_EDIT_SYSTEM_PROMPT,
|
|
187
203
|
label: 'Prompt edit',
|
|
188
204
|
signal,
|
|
189
205
|
})
|
|
190
206
|
}
|
|
191
207
|
|
|
208
|
+
// ── 6.92.0 (plan r3, A-W0b): how prompt edit stops ───────────────────
|
|
209
|
+
//
|
|
210
|
+
// Every `claude -p` caller answers how it stops. Prompt edit had no answer through 6.91.1:
|
|
211
|
+
// - master switch: COS_PROMPT_EDIT (readSwitch; on by default, 0/false/off answers 503);
|
|
212
|
+
// - daily cap: COS_PROMPT_EDIT_DAILY_CAP (default 200 a local day) in its OWN counter file, never
|
|
213
|
+
// the dictation polish counter, so editing a prompt never spends Mac dictation's budget;
|
|
214
|
+
// - breaker: 3 consecutive CLI failures open it for 10 minutes; a client abort is neutral;
|
|
215
|
+
// - an audit row per call that ran (caller prompt_edit, codes and sizes only).
|
|
216
|
+
// Refusals are text/plain bodies of 24 characters or fewer, so G2 6.10.654 renders
|
|
217
|
+
// "Edit failed.\nAPI <status>: <body>" in 40 characters or fewer.
|
|
218
|
+
|
|
219
|
+
export const PROMPT_EDIT_REFUSALS = {
|
|
220
|
+
cap: { status: 429, body: 'Edit limit reached' },
|
|
221
|
+
off: { status: 503, body: 'Edit is off' },
|
|
222
|
+
paused: { status: 503, body: 'Edit paused' },
|
|
223
|
+
failed: { status: 500, body: 'Edit failed' },
|
|
224
|
+
} as const
|
|
225
|
+
export type PromptEditRefusal = keyof typeof PROMPT_EDIT_REFUSALS
|
|
226
|
+
|
|
227
|
+
export const PROMPT_EDIT_DEFAULT_DAILY_CAP = 200
|
|
228
|
+
export const PROMPT_EDIT_BREAKER_FAILURES = 3
|
|
229
|
+
export const PROMPT_EDIT_BREAKER_MS = 10 * 60 * 1000
|
|
230
|
+
export const PROMPT_EDIT_COUNT_FILE = '.prompt_edit_count.json'
|
|
231
|
+
|
|
232
|
+
export function promptEditEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
233
|
+
return readSwitch(env.COS_PROMPT_EDIT, true)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function promptEditDailyCap(env: NodeJS.ProcessEnv = process.env): number {
|
|
237
|
+
const trimmed = env.COS_PROMPT_EDIT_DAILY_CAP?.trim() ?? ''
|
|
238
|
+
if (!/^\d+$/.test(trimmed)) return PROMPT_EDIT_DEFAULT_DAILY_CAP
|
|
239
|
+
const value = Number(trimmed)
|
|
240
|
+
return value > 0 ? value : PROMPT_EDIT_DEFAULT_DAILY_CAP
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export const promptEditBreaker = createBreaker({
|
|
244
|
+
label: 'prompt-edit', maxFailures: PROMPT_EDIT_BREAKER_FAILURES, cooldownMs: PROMPT_EDIT_BREAKER_MS,
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
const countFile = () => dataPath(PROMPT_EDIT_COUNT_FILE)
|
|
248
|
+
|
|
249
|
+
export function promptEditCountToday(): number {
|
|
250
|
+
try {
|
|
251
|
+
const raw = JSON.parse(readFileSync(countFile(), 'utf8'))
|
|
252
|
+
if (raw?.date !== localDay() || !Number.isInteger(raw.count) || raw.count < 0) return 0
|
|
253
|
+
return raw.count
|
|
254
|
+
} catch { return 0 }
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function recordPromptEditCall(): void {
|
|
258
|
+
try {
|
|
259
|
+
const file = countFile()
|
|
260
|
+
if (!existsSync(dirname(file))) mkdirSync(dirname(file), { recursive: true })
|
|
261
|
+
atomicWriteFileSync(file, JSON.stringify({ date: localDay(), count: promptEditCountToday() + 1 }), { mode: 0o600 })
|
|
262
|
+
} catch {}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** The refusal for this request, before any call, or null when the edit may run. Switch, breaker, cap. */
|
|
266
|
+
export function promptEditRefusal(env: NodeJS.ProcessEnv = process.env): PromptEditRefusal | null {
|
|
267
|
+
if (!promptEditEnabled(env)) return 'off'
|
|
268
|
+
if (promptEditBreaker.isOpen()) return 'paused'
|
|
269
|
+
if (promptEditCountToday() >= promptEditDailyCap(env)) return 'cap'
|
|
270
|
+
return null
|
|
271
|
+
}
|
|
272
|
+
|
|
192
273
|
// ── Outbound dictation auto-clean ────────────────────────────────────
|
|
193
274
|
export const AUTOCLEAN_MAX_CHARS = 8_000
|
|
194
275
|
|
|
@@ -55,7 +55,7 @@ export const TAIL_WINDOW_SEC = 10
|
|
|
55
55
|
* no free-text tail — so it matches a sentence that IS the filler and nothing
|
|
56
56
|
* that merely starts with its words. The re-validation of 2026-09-12 caught
|
|
57
57
|
* `.{0,30}` tails letting "Thanks for watching the demo.", "Transcript from the
|
|
58
|
-
*
|
|
58
|
+
* Elliot call is in the folder." and "Subscribe me to the newsletter." through.
|
|
59
59
|
* "See you later", "the end", "transcribed by" and "translated by" are
|
|
60
60
|
* deliberately absent: each occurs in real dictation ("Translated by Friday."),
|
|
61
61
|
* and whisper's inventions are the caption-credit shapes below. A filler this
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Speaker embedding extraction and verification using sherpa-onnx
|
|
2
|
-
import { chooseEviction, tierBreakdown } from './embedding-eviction.js'
|
|
2
|
+
import { chooseEviction, isMacAssign, macAssignCapFor, tierBreakdown } from './embedding-eviction.js'
|
|
3
3
|
// Wraps ECAPA-TDNN model for voiceprint-based speaker classification.
|
|
4
4
|
// Falls back gracefully if model is missing — amplitude classification continues.
|
|
5
5
|
//
|
|
@@ -323,27 +323,8 @@ export function enrollEmbedding(name: string, embedding: Float32Array, source: s
|
|
|
323
323
|
// dropOldestEmbedding keeps sources[] in lockstep — the old inline
|
|
324
324
|
// `sources?.shift()` no-opped whenever sources was undefined or short,
|
|
325
325
|
// permanently offsetting provenance from the samples it described.
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
// four profiles at cap would lose their only human-supplied sample to
|
|
329
|
-
// FIFO while unverified attendee-metadata samples sat untouched.
|
|
330
|
-
// alignedSources is defence-in-depth here, not load-bearing: loadProfileStore
|
|
331
|
-
// already pads sources[] to match embeddings[], so a ragged array cannot
|
|
332
|
-
// reach this line (mutation-verified — swapping it for profile.sources is
|
|
333
|
-
// unobservable through this path). It is kept for any future caller that
|
|
334
|
-
// builds a profile without going through the loader, and is unit-tested
|
|
335
|
-
// directly in voice-profile-store.test.ts.
|
|
336
|
-
const choice = chooseEviction(alignedSources(profile), source, MAX_EMBEDDINGS_PER_SPEAKER)
|
|
337
|
-
const { droppedSource } = choice
|
|
338
|
-
? dropEmbeddingAt(profile, choice.index)
|
|
339
|
-
: dropOldestEmbedding(profile)
|
|
340
|
-
console.log(
|
|
341
|
-
`[speaker] Profile full for "${name}" (${MAX_EMBEDDINGS_PER_SPEAKER}) — `
|
|
342
|
-
+ `${choice ? choice.reason : 'no provenance available, dropping the oldest'} `
|
|
343
|
-
+ `[dropped ${droppedSource ?? 'unknown'}, incoming ${source}]`,
|
|
344
|
-
)
|
|
345
|
-
rebuildSpeakerInManager(name, profile.embeddings)
|
|
346
|
-
}
|
|
326
|
+
const room = makeRoomForSample(name, profile, source)
|
|
327
|
+
if (!room.ok) return { success: false, dim: extractor.dim, error: room.error }
|
|
347
328
|
}
|
|
348
329
|
|
|
349
330
|
// Add to manager — if manager rejects (internal dedup), force via remove+readd
|
|
@@ -351,7 +332,11 @@ export function enrollEmbedding(name: string, embedding: Float32Array, source: s
|
|
|
351
332
|
if (!added) {
|
|
352
333
|
// Manager's internal dedup rejected it — force-add by rebuilding
|
|
353
334
|
// Persist first so we have the complete embedding list
|
|
354
|
-
|
|
335
|
+
try {
|
|
336
|
+
persistProfile(name, embedding, source)
|
|
337
|
+
} catch (err: unknown) {
|
|
338
|
+
return { success: false, dim: extractor.dim, error: `Profile store write failed: ${errMsg(err)}` }
|
|
339
|
+
}
|
|
355
340
|
const updatedStore = loadProfileStore()
|
|
356
341
|
const updatedProfile = updatedStore.profiles.find(p => p.name === name)
|
|
357
342
|
if (updatedProfile) {
|
|
@@ -363,12 +348,99 @@ export function enrollEmbedding(name: string, embedding: Float32Array, source: s
|
|
|
363
348
|
}
|
|
364
349
|
|
|
365
350
|
// Persist to disk with provenance
|
|
366
|
-
|
|
351
|
+
try {
|
|
352
|
+
persistProfile(name, embedding, source)
|
|
353
|
+
} catch (err: unknown) {
|
|
354
|
+
return { success: false, dim: extractor.dim, error: `Profile store write failed: ${errMsg(err)}` }
|
|
355
|
+
}
|
|
367
356
|
|
|
368
357
|
console.log(`[speaker] Enrolled "${name}" (${extractor.dim}-dim, source: ${source})`)
|
|
369
358
|
return { success: true, dim: extractor.dim }
|
|
370
359
|
}
|
|
371
360
|
|
|
361
|
+
/**
|
|
362
|
+
* Make room in a full profile for one incoming sample, by the provenance policy.
|
|
363
|
+
* Also enforces the per-profile cap on Mac-assigned samples (6.93.0), which can bind
|
|
364
|
+
* below the profile cap. Returns not-ok only when a Mac-assigned sample would have to
|
|
365
|
+
* displace something other than another Mac-assigned sample.
|
|
366
|
+
*/
|
|
367
|
+
function makeRoomForSample(name: string, profile: VoiceProfile, source: string): { ok: true } | { ok: false; error: string } {
|
|
368
|
+
const macCount = isMacAssign(source) ? alignedSources(profile).filter(s => isMacAssign(s)).length : 0
|
|
369
|
+
const macAtCap = isMacAssign(source) && macCount >= macAssignCapFor(profile.embeddings.length, macCount)
|
|
370
|
+
if (profile.embeddings.length < MAX_EMBEDDINGS_PER_SPEAKER && !macAtCap) return { ok: true }
|
|
371
|
+
// Weakest PROVENANCE goes, not the oldest sample. Age is the wrong axis:
|
|
372
|
+
// four profiles at cap would lose their only human-supplied sample to
|
|
373
|
+
// FIFO while unverified attendee-metadata samples sat untouched.
|
|
374
|
+
// alignedSources is defence-in-depth here, not load-bearing: loadProfileStore
|
|
375
|
+
// already pads sources[] to match embeddings[], so a ragged array cannot
|
|
376
|
+
// reach this line (mutation-verified — swapping it for profile.sources is
|
|
377
|
+
// unobservable through this path). It is kept for any future caller that
|
|
378
|
+
// builds a profile without going through the loader, and is unit-tested
|
|
379
|
+
// directly in voice-profile-store.test.ts.
|
|
380
|
+
const choice = chooseEviction(alignedSources(profile), source, MAX_EMBEDDINGS_PER_SPEAKER)
|
|
381
|
+
if (choice?.refuse) {
|
|
382
|
+
console.log(`[speaker] Profile "${name}" full (${profile.embeddings.length}/${MAX_EMBEDDINGS_PER_SPEAKER}): ${choice.reason} [incoming ${source}]`)
|
|
383
|
+
return { ok: false, error: 'Profile is full and holds no Mac-assigned sample to replace' }
|
|
384
|
+
}
|
|
385
|
+
const { droppedSource } = choice
|
|
386
|
+
? dropEmbeddingAt(profile, choice.index)
|
|
387
|
+
: dropOldestEmbedding(profile)
|
|
388
|
+
console.log(
|
|
389
|
+
`[speaker] Profile full for "${name}" (${MAX_EMBEDDINGS_PER_SPEAKER}) — `
|
|
390
|
+
+ `${choice ? choice.reason : 'no provenance available, dropping the oldest'} `
|
|
391
|
+
+ `[dropped ${droppedSource ?? 'unknown'}, incoming ${source}]`,
|
|
392
|
+
)
|
|
393
|
+
rebuildSpeakerInManager(name, profile.embeddings)
|
|
394
|
+
return { ok: true }
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
export interface BatchEnrollItem { embedding: Float32Array; source: string }
|
|
398
|
+
export interface BatchEnrollResult {
|
|
399
|
+
/** Samples that landed AND were written to disk. Zero when the write failed. */
|
|
400
|
+
enrolled: number
|
|
401
|
+
/** Samples dropped before the write (too close to an existing one, or a Mac-assigned sample with nothing it may replace). */
|
|
402
|
+
declined: number
|
|
403
|
+
/** False when the store refused or failed the single write; nothing from this batch is then in memory or on disk. */
|
|
404
|
+
persisted: boolean
|
|
405
|
+
error?: string
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* 6.93.0: add several samples to an EXISTING profile with ONE read-modify-write of the store.
|
|
410
|
+
* Calling `enrollEmbedding` per sample re-parsed and durably rewrote the whole store each
|
|
411
|
+
* time (about 10 MB, up to 20 times, on the event loop). Same dedup, same provenance
|
|
412
|
+
* eviction, same Mac-assigned cap. A write failure is reported, never swallowed, and the
|
|
413
|
+
* in-memory store and the speaker manager are put back to what is on disk.
|
|
414
|
+
*/
|
|
415
|
+
export function enrollEmbeddingsBatch(name: string, items: BatchEnrollItem[]): BatchEnrollResult {
|
|
416
|
+
if (!extractor || !manager) return { enrolled: 0, declined: items.length, persisted: false, error: 'Speaker embeddings not initialized' }
|
|
417
|
+
const store = loadProfileStore()
|
|
418
|
+
const profile = store.profiles.find(p => p.name === name)
|
|
419
|
+
if (!profile) return { enrolled: 0, declined: items.length, persisted: false, error: 'No such profile' }
|
|
420
|
+
let added = 0
|
|
421
|
+
let declined = 0
|
|
422
|
+
for (const item of items) {
|
|
423
|
+
if (profile.embeddings.some(existing => rawCosineSimilarity(item.embedding, new Float32Array(existing)) > 0.95)) { declined++; continue }
|
|
424
|
+
const room = makeRoomForSample(name, profile, item.source)
|
|
425
|
+
if (!room.ok) { declined++; continue }
|
|
426
|
+
appendEmbedding(profile, Array.from(item.embedding), item.source)
|
|
427
|
+
added++
|
|
428
|
+
}
|
|
429
|
+
if (added === 0) {
|
|
430
|
+
// Nothing was appended, but makeRoom may have rebuilt the manager; disk is still the truth.
|
|
431
|
+
return { enrolled: 0, declined, persisted: true }
|
|
432
|
+
}
|
|
433
|
+
try {
|
|
434
|
+
if (!writeProfileStore(store)) throw new Error('the store refused the write')
|
|
435
|
+
} catch (err: unknown) {
|
|
436
|
+
restoreProfileFromDisk(name)
|
|
437
|
+
return { enrolled: 0, declined: items.length, persisted: false, error: `Profile store write failed: ${errMsg(err)}` }
|
|
438
|
+
}
|
|
439
|
+
rebuildSpeakerInManager(name, profile.embeddings)
|
|
440
|
+
console.log(`[speaker] Batch-enrolled ${added} sample(s) for "${name}" (${profile.embeddings.length} total, one write)`)
|
|
441
|
+
return { enrolled: added, declined, persisted: true }
|
|
442
|
+
}
|
|
443
|
+
|
|
372
444
|
/** Identify speaker from WAV audio buffer.
|
|
373
445
|
* Phase 4: optionally scope search to expected attendees for fewer false positives. */
|
|
374
446
|
export function identifySpeaker(
|
|
@@ -526,11 +598,17 @@ export interface MergeReport {
|
|
|
526
598
|
samplesAfter: number
|
|
527
599
|
droppedToCap: number
|
|
528
600
|
refused?: { name: string; similarity: number; floor: number }[]
|
|
601
|
+
/** Set when the merge was refused because it would leave the target with no
|
|
602
|
+
* samples at all. Nothing is written when this is present. */
|
|
603
|
+
refusal?: { code: 'merge_would_empty_profile'; message: string }
|
|
529
604
|
}
|
|
530
605
|
|
|
606
|
+
/** The code a merge reports when it would leave an empty profile. */
|
|
607
|
+
export const MERGE_WOULD_EMPTY_PROFILE = 'merge_would_empty_profile'
|
|
608
|
+
|
|
531
609
|
/**
|
|
532
|
-
* Fold profiles together — two names for one person, e.g. "
|
|
533
|
-
* "
|
|
610
|
+
* Fold profiles together — two names for one person, e.g. "Theo M" and
|
|
611
|
+
* "Theo Marsh" at 0.843.
|
|
534
612
|
*
|
|
535
613
|
* Fails closed below MERGE_SIMILARITY_FLOOR unless `force`. That guard is the
|
|
536
614
|
* whole safety story: a wrong merge destroys BOTH identities at once, and the
|
|
@@ -577,6 +655,21 @@ export function mergeSpeakerProfiles(
|
|
|
577
655
|
if (refused.length > 0) report.refused = refused
|
|
578
656
|
if (eligible.length === 0) return report
|
|
579
657
|
|
|
658
|
+
// A merge may never leave the target with nothing. Mac-assigned samples are
|
|
659
|
+
// trimmed to the person's other samples, so two profiles that hold only Mac
|
|
660
|
+
// samples would merge down to zero and the person would stop being recognised.
|
|
661
|
+
// Checked on a throwaway copy so a dry run and a real merge refuse identically.
|
|
662
|
+
const wouldBe = mergeProfilesInStore(
|
|
663
|
+
JSON.parse(JSON.stringify(store)) as ProfileStore, into, eligible, { cap: MAX_EMBEDDINGS_PER_SPEAKER },
|
|
664
|
+
)
|
|
665
|
+
if (wouldBe.mergedFrom.length > 0 && wouldBe.samplesAfter === 0) {
|
|
666
|
+
report.refusal = {
|
|
667
|
+
code: MERGE_WOULD_EMPTY_PROFILE,
|
|
668
|
+
message: `Merging into "${into}" would leave it with no voice samples. Nothing was changed.`,
|
|
669
|
+
}
|
|
670
|
+
return report
|
|
671
|
+
}
|
|
672
|
+
|
|
580
673
|
if (options.dryRun) {
|
|
581
674
|
// Report on a throwaway copy so nothing is mutated by a preview.
|
|
582
675
|
const preview = JSON.parse(JSON.stringify(store)) as ProfileStore
|
|
@@ -659,6 +752,17 @@ export function extractEmbedding(wavBuffer: Buffer): Float32Array | null {
|
|
|
659
752
|
return extractEmbeddingFromWav(wavBuffer)
|
|
660
753
|
}
|
|
661
754
|
|
|
755
|
+
/**
|
|
756
|
+
* Test seam: install a fake runtime (see __fixtures__/fake-speaker-runtime.ts) so the real enrolment, eviction and store-write
|
|
757
|
+
* code runs without the model. Pass null to remove it. Never called by the server.
|
|
758
|
+
*/
|
|
759
|
+
export function __installSpeakerRuntimeForTests(runtime: { extractor: unknown; manager: unknown } | null): void {
|
|
760
|
+
extractor = runtime ? runtime.extractor : null
|
|
761
|
+
manager = runtime ? runtime.manager : null
|
|
762
|
+
initialized = runtime !== null
|
|
763
|
+
_cachedProfileStore = null
|
|
764
|
+
}
|
|
765
|
+
|
|
662
766
|
/** Check if the embedding system is available */
|
|
663
767
|
export function isEmbeddingAvailable(): boolean {
|
|
664
768
|
return extractor !== null && manager !== null
|
|
@@ -948,7 +1052,27 @@ function invalidateProfileCache(): void {
|
|
|
948
1052
|
_cachedProfileStore = null
|
|
949
1053
|
}
|
|
950
1054
|
|
|
951
|
-
/**
|
|
1055
|
+
/**
|
|
1056
|
+
* Put the in-memory store and the speaker manager back to what is on disk after a failed write.
|
|
1057
|
+
* Without this the cache keeps samples the disk never received: `enrolled` would overstate
|
|
1058
|
+
* what survives a restart, and the identifier would match on voiceprints that are not stored.
|
|
1059
|
+
*/
|
|
1060
|
+
function restoreProfileFromDisk(name: string): void {
|
|
1061
|
+
invalidateProfileCache()
|
|
1062
|
+
try {
|
|
1063
|
+
const fresh = loadProfileStore().profiles.find(p => p.name === name)
|
|
1064
|
+
if (fresh && fresh.embeddings.length > 0) rebuildSpeakerInManager(name, fresh.embeddings)
|
|
1065
|
+
else if (manager?.contains(name)) manager.remove(name)
|
|
1066
|
+
} catch (err: unknown) {
|
|
1067
|
+
console.error(`[speaker] Could not restore "${name}" from disk after a failed write:`, errMsg(err))
|
|
1068
|
+
} finally {
|
|
1069
|
+
// Whatever that load saw (a store that was unreadable at that instant included) must not be pinned: the next reader reloads.
|
|
1070
|
+
invalidateProfileCache()
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
/** Persist a speaker profile to disk with provenance tracking. THROWS when the store cannot be written
|
|
1075
|
+
* (6.93.0: it used to log and swallow, so `enrollEmbedding` reported success for a sample only in memory). */
|
|
952
1076
|
function persistProfile(name: string, embedding: Float32Array, source: string = 'manual'): void {
|
|
953
1077
|
try {
|
|
954
1078
|
const store = loadProfileStore()
|
|
@@ -960,9 +1084,11 @@ function persistProfile(name: string, embedding: Float32Array, source: string =
|
|
|
960
1084
|
}
|
|
961
1085
|
appendEmbedding(profile, Array.from(embedding), source)
|
|
962
1086
|
|
|
963
|
-
writeProfileStore(store)
|
|
1087
|
+
if (!writeProfileStore(store)) throw new Error('the store refused the write')
|
|
964
1088
|
} catch (err: unknown) {
|
|
965
1089
|
console.error('[speaker] Profile persist error:', errMsg(err))
|
|
1090
|
+
restoreProfileFromDisk(name)
|
|
1091
|
+
throw err instanceof Error ? err : new Error(errMsg(err))
|
|
966
1092
|
}
|
|
967
1093
|
}
|
|
968
1094
|
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
|
|
20
20
|
/** Longest plausible human name we will store. */
|
|
21
21
|
export const MAX_SPEAKER_NAME_CHARS = 40
|
|
22
|
-
/** Most words a name may have ("Maria del
|
|
22
|
+
/** Most words a name may have ("Maria del Cora Ruiz" is four). */
|
|
23
23
|
export const MAX_SPEAKER_NAME_WORDS = 4
|
|
24
24
|
|
|
25
25
|
/** Words that mean "the wearer", never a third party's name. */
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { requirePhoneRecording, PHONE_SOURCE_TYPE } from './phone-recording.js'
|
|
2
|
+
import { getServerInstanceId } from './server-instance-id.js'
|
|
1
3
|
// What the sweeper DOES about a stranded session: draft it, then promote it.
|
|
2
4
|
//
|
|
3
5
|
// Two actions, deliberately asymmetric.
|
|
@@ -295,10 +297,13 @@ export async function promoteStrandedSession(
|
|
|
295
297
|
const controller = new AbortController()
|
|
296
298
|
const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? 600_000)
|
|
297
299
|
try {
|
|
300
|
+
const phone = sessionId.startsWith('phone_') ? requirePhoneRecording(sessionId) : null
|
|
301
|
+
const pin = phone ? getServerInstanceId() : null
|
|
302
|
+
if (phone && !pin) return { ok: false, status: 0, reason: 'server_identity_unavailable' }
|
|
298
303
|
const res = await doFetch(`http://127.0.0.1:${options.port}/api/meeting/save`, {
|
|
299
304
|
method: 'POST',
|
|
300
|
-
headers: { 'Content-Type': 'application/json', 'X-Cos-Token': options.token },
|
|
301
|
-
body: JSON.stringify({ sessionId, title: STRANDED_PROMOTE_TITLE }),
|
|
305
|
+
headers: { 'Content-Type': 'application/json', 'X-Cos-Token': options.token, ...(pin ? { 'X-COS-Server-Instance': pin, 'X-COS-Recording-Origin': 'stranded-sweep' } : {}) },
|
|
306
|
+
body: JSON.stringify({ sessionId, title: STRANDED_PROMOTE_TITLE, ...(phone ? { sourceType: PHONE_SOURCE_TYPE, speakerPolicy: 'anonymous' } : {}) }),
|
|
302
307
|
signal: controller.signal,
|
|
303
308
|
})
|
|
304
309
|
let payload: Record<string, unknown> = {}
|