@gotcos/glasses-server 6.94.1 → 6.94.3
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 +26 -0
- package/README.md +27 -0
- package/package.json +2 -1
- package/server/index.ts +6 -0
- package/server/lib/decision-provider.ts +4 -2
- package/server/lib/held-naming-batches.ts +8 -7
- package/server/lib/held-voice-groups.ts +5 -4
- package/server/lib/keyed-sessions.ts +21 -1
- package/server/lib/mac-speaker-machine-evidence.ts +111 -0
- package/server/lib/mac-speaker-profile-extractor.ts +41 -0
- package/server/lib/mac-speaker-profile-finalizer.ts +257 -0
- package/server/lib/mac-speaker-protection.ts +22 -0
- package/server/lib/mac-speaker-reconcile.ts +300 -0
- package/server/lib/mac-speaker-repair-projection.ts +301 -0
- package/server/lib/mac-speaker-repair-render.ts +105 -0
- package/server/lib/mac-speaker-windows.ts +239 -0
- package/server/lib/mac-voice-assignment.ts +2 -1
- package/server/lib/meeting-corrections.ts +2 -1
- package/server/lib/meeting-relabel-enrolment.ts +2 -1
- package/server/lib/meeting-relabel.ts +17 -10
- package/server/lib/meeting-scribe-content.ts +8 -1
- package/server/lib/meeting-speaker-review.ts +46 -7
- package/server/lib/nemotron-final.ts +64 -14
- package/server/lib/session-find-ask.ts +281 -0
- package/server/lib/session-find-collect.ts +715 -0
- package/server/lib/session-find-redact.ts +254 -0
- package/server/lib/session-find.ts +345 -0
- package/server/lib/speaker-calibration-log.ts +3 -2
- package/server/lib/speaker-embeddings.ts +46 -35
- package/server/lib/speaker-name-nfc.ts +37 -0
- package/server/lib/speaker-rename-fanout.ts +7 -6
- package/server/lib/speaker-trainer.ts +2 -1
- package/server/lib/voice-directory.ts +7 -2
- package/server/lib/voice-profile-store.ts +24 -14
- package/server/routes/decision-provider.ts +4 -1
- package/server/routes/jev.ts +5 -1
- package/server/routes/meeting.ts +43 -15
- package/server/routes/session-find.ts +73 -0
- package/server/routes/voice.ts +25 -18
- package/server/scripts/mac-speaker-repair.ts +324 -0
package/server/routes/meeting.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
import { runQualifiedMacProfilePass } from '../lib/mac-speaker-profile-finalizer.js'
|
|
2
|
+
import { readMacSpeakerMachineEvidence } from '../lib/mac-speaker-machine-evidence.js'
|
|
1
3
|
import { dataPath } from '../lib/data-dir.js'
|
|
2
4
|
import { PHONE_CAPTURE_ID, PHONE_SOURCE_TYPE, assertPhoneProvenance, readPhoneRecording, requirePhoneRecording, closePhoneRecording } from '../lib/phone-recording.js'
|
|
3
5
|
import { MAC_CAPTURE_ID, readMacRecording, requireMacRecording, macChunkReceipt, closeMacRecording } from '../lib/mac-recording.js'
|
|
6
|
+
import { bodyName, sameRecordId, sameSpeakerName } from '../lib/speaker-name-nfc.js'
|
|
4
7
|
import { readSpeakerPolicy, writeSpeakerPolicy, isSpeakerMode } from '../lib/meeting-speaker-policy.js'
|
|
5
8
|
// POST /api/meeting/save — finalize an existing transcribe-stream session into
|
|
6
9
|
// the standalone public meeting store. The live transcript and chunk metadata
|
|
@@ -259,7 +262,7 @@ function markdownUnreadableReason(operations: { mergedScribeConflict?: string[]
|
|
|
259
262
|
}
|
|
260
263
|
|
|
261
264
|
function requestedRecordMatches(requested: unknown, expected: string): boolean {
|
|
262
|
-
return requested == null || requested === '' || requested
|
|
265
|
+
return requested == null || requested === '' || sameRecordId(requested, expected)
|
|
263
266
|
}
|
|
264
267
|
|
|
265
268
|
interface MeetingSessionSource {
|
|
@@ -309,6 +312,7 @@ export interface MeetingRouteDependencies {
|
|
|
309
312
|
emit?: typeof emitDisplay
|
|
310
313
|
finalizationJobs?: MeetingFinalizationJobStore
|
|
311
314
|
finalDiarize?: FinalDiarize
|
|
315
|
+
qualifiedProfilePass?: typeof runQualifiedMacProfilePass
|
|
312
316
|
/** Control's stop requests for live G2 meetings (lib/meeting-stop-requests.ts). */
|
|
313
317
|
stopRequests?: StopRequestStore
|
|
314
318
|
/** 6.77.0: the post-save transcript cleanup (lib/transcript-cleanup.ts). */
|
|
@@ -334,6 +338,7 @@ interface FinalizationRuntime {
|
|
|
334
338
|
scheduleBackground: (task: Promise<void>) => void
|
|
335
339
|
sessions?: MeetingSessionSource
|
|
336
340
|
finalDiarize?: FinalDiarize
|
|
341
|
+
qualifiedProfilePass?: typeof runQualifiedMacProfilePass
|
|
337
342
|
transcriptCleanup?: TranscriptCleanupRunner
|
|
338
343
|
}
|
|
339
344
|
|
|
@@ -367,7 +372,7 @@ function validReplayEntries(raw: unknown[]): IndexedTranscriptChunk[] | null {
|
|
|
367
372
|
return entries.length > 0 ? entries : null
|
|
368
373
|
}
|
|
369
374
|
|
|
370
|
-
function scheduleFinalizationJob(job: MeetingFinalizationJob, runtime: FinalizationRuntime): void {
|
|
375
|
+
export function scheduleFinalizationJob(job: MeetingFinalizationJob, runtime: FinalizationRuntime): void {
|
|
371
376
|
const key = `${runtime.finalizationJobs.root}:${job.sessionId}`
|
|
372
377
|
if (activeFinalizationJobs.has(key)) return
|
|
373
378
|
activeFinalizationJobs.add(key)
|
|
@@ -452,8 +457,12 @@ function scheduleFinalizationJob(job: MeetingFinalizationJob, runtime: Finalizat
|
|
|
452
457
|
sessionId: current.sessionId,
|
|
453
458
|
runBatch: runtime.runBatch,
|
|
454
459
|
finalDiarize: runtime.finalDiarize ?? runNemotronFinalPass,
|
|
460
|
+
qualifiedProfilePass: runtime.qualifiedProfilePass,
|
|
455
461
|
})
|
|
456
462
|
} else {
|
|
463
|
+
// A torn profile transaction must block filing even if pending audio
|
|
464
|
+
// vanished. Missing audio never waives journal/proof recovery gates.
|
|
465
|
+
if (MAC_CAPTURE_ID.test(current.sessionId)) await (runtime.qualifiedProfilePass ?? runQualifiedMacProfilePass)({ meetingPath: current.meetingPath, sidecarPath: current.sidecarPath, audioDir: current.audioDir ?? '', resumeOnly: true })
|
|
457
466
|
console.warn(`[meeting/save] Pending HQ audio missing for ${current.sessionId}; preserving streaming canonical`)
|
|
458
467
|
}
|
|
459
468
|
current = runtime.finalizationJobs.save({
|
|
@@ -720,6 +729,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
720
729
|
const emit = deps.emit ?? emitDisplay
|
|
721
730
|
const finalizationJobs = deps.finalizationJobs ?? new MeetingFinalizationJobStore()
|
|
722
731
|
const finalDiarize = deps.finalDiarize ?? runNemotronFinalPass
|
|
732
|
+
const qualifiedProfilePass = deps.qualifiedProfilePass ?? runQualifiedMacProfilePass
|
|
723
733
|
const transcriptCleanup = deps.transcriptCleanup
|
|
724
734
|
const router = Router()
|
|
725
735
|
const captureActive = deps.captureActive ?? (() => getTranscriptionSessionLiveness().live > 0)
|
|
@@ -1206,6 +1216,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1206
1216
|
scheduleBackground,
|
|
1207
1217
|
sessions,
|
|
1208
1218
|
finalDiarize,
|
|
1219
|
+
qualifiedProfilePass,
|
|
1209
1220
|
})
|
|
1210
1221
|
if (origin === 'phone') notePhoneAfterAutonomousSave(sessionId, 'save_replay')
|
|
1211
1222
|
respond(200, publicSaveResponse(alreadySaved, true), true)
|
|
@@ -1403,6 +1414,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1403
1414
|
scheduleBackground,
|
|
1404
1415
|
sessions,
|
|
1405
1416
|
finalDiarize,
|
|
1417
|
+
qualifiedProfilePass,
|
|
1406
1418
|
transcriptCleanup,
|
|
1407
1419
|
})
|
|
1408
1420
|
}
|
|
@@ -1659,9 +1671,10 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1659
1671
|
const filename = operations?.filename ?? direct?.filename ?? saved!.filename
|
|
1660
1672
|
|
|
1661
1673
|
let chunks: unknown
|
|
1674
|
+
let sidecar: Record<string, unknown>
|
|
1662
1675
|
try {
|
|
1663
|
-
|
|
1664
|
-
chunks = Array.isArray(
|
|
1676
|
+
sidecar = JSON.parse(readFileSync(sidecarPath, 'utf-8')) as Record<string, unknown>
|
|
1677
|
+
chunks = Array.isArray(sidecar) ? sidecar : sidecar.chunks
|
|
1665
1678
|
} catch {
|
|
1666
1679
|
// Defensive: both lookups above already read this sidecar to match the
|
|
1667
1680
|
// session, so a corrupt file 404s and never reaches here. This covers the
|
|
@@ -1678,8 +1691,8 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1678
1691
|
|
|
1679
1692
|
// Raw capture indices, so a phrase can address its own audio. Position in
|
|
1680
1693
|
// `chunks` is NOT the WAV number — see attachRawChunkIndices.
|
|
1681
|
-
const sidecar = (JSON.parse(readFileSync(sidecarPath, 'utf-8')) ?? {}) as Record<string, unknown>
|
|
1682
1694
|
const withIndices = attachRawChunkIndices(chunks as ReviewChunk[], sidecar.chunkEntries)
|
|
1695
|
+
const machineEvidence = readMacSpeakerMachineEvidence(sidecar)
|
|
1683
1696
|
const review = reviewMeetingSpeakers(withIndices, {
|
|
1684
1697
|
owner: getOwnerSpeakerLabel(),
|
|
1685
1698
|
// Labels a human already vouched for in this meeting. Without this the
|
|
@@ -1687,6 +1700,8 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1687
1700
|
// confirms the same voice forever.
|
|
1688
1701
|
confirmed: confirmedLabels(sessionId),
|
|
1689
1702
|
confirmedChunks: confirmedChunks(sessionId),
|
|
1703
|
+
machineQualifiedPositions: machineEvidence.positions,
|
|
1704
|
+
machineEvidenceStatus: machineEvidence.status,
|
|
1690
1705
|
phrasesPerVoice: Math.max(1, Math.min(6, Number(req.query.phrases) || 3)),
|
|
1691
1706
|
// The sidecar's own durationMs is the meeting's true end. Deriving it from
|
|
1692
1707
|
// max(elapsed) uses the START of the last chunk, which made the final
|
|
@@ -1791,10 +1806,13 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1791
1806
|
}
|
|
1792
1807
|
const chunks = rawChunks as ReviewChunk[]
|
|
1793
1808
|
|
|
1809
|
+
const machineEvidence = readMacSpeakerMachineEvidence(sidecar)
|
|
1794
1810
|
const review = reviewMeetingSpeakers(attachRawChunkIndices(chunks, sidecar.chunkEntries), {
|
|
1795
1811
|
owner: getOwnerSpeakerLabel(),
|
|
1796
1812
|
confirmed: confirmedLabels(sessionId),
|
|
1797
1813
|
confirmedChunks: confirmedChunks(sessionId),
|
|
1814
|
+
machineQualifiedPositions: machineEvidence.positions,
|
|
1815
|
+
machineEvidenceStatus: machineEvidence.status,
|
|
1798
1816
|
// This route renders no phrases; the default of 3 per voice computed and
|
|
1799
1817
|
// discarded 48 transcript excerpts on a 16-voice meeting.
|
|
1800
1818
|
phrasesPerVoice: 1,
|
|
@@ -1818,6 +1836,8 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1818
1836
|
// "voice matching" for the wearer exemption and for names a human typed.
|
|
1819
1837
|
isOwner: v.isOwner,
|
|
1820
1838
|
confirmedByHuman: v.confirmedByHuman,
|
|
1839
|
+
machineQualified: v.machineQualified,
|
|
1840
|
+
machineQualifiedSegments: v.machineQualifiedSegments,
|
|
1821
1841
|
}))
|
|
1822
1842
|
|
|
1823
1843
|
// A missing .md is not fatal — the review and the sidecar still describe the
|
|
@@ -1948,7 +1968,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1948
1968
|
res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
|
|
1949
1969
|
return
|
|
1950
1970
|
}
|
|
1951
|
-
const speaker =
|
|
1971
|
+
const speaker = bodyName(req.body?.speaker).trim()
|
|
1952
1972
|
if (!speaker) {
|
|
1953
1973
|
res.status(400).json({ error: 'speaker is required', reason: 'invalid_label' })
|
|
1954
1974
|
return
|
|
@@ -1981,7 +2001,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1981
2001
|
}
|
|
1982
2002
|
|
|
1983
2003
|
// Only rows that ACTUALLY landed, and only those that named this speaker.
|
|
1984
|
-
const rows = appliedCorrections(sessionId).filter(r => r.to
|
|
2004
|
+
const rows = appliedCorrections(sessionId).filter(r => sameSpeakerName(r.to, speaker) && r.chunks.length > 0)
|
|
1985
2005
|
if (rows.length === 0) {
|
|
1986
2006
|
res.status(404).json({ error: `No applied correction named "${speaker}" in this meeting`, reason: 'no_correction' })
|
|
1987
2007
|
return
|
|
@@ -2028,8 +2048,8 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
2028
2048
|
return
|
|
2029
2049
|
}
|
|
2030
2050
|
|
|
2031
|
-
const from =
|
|
2032
|
-
const to =
|
|
2051
|
+
const from = bodyName(req.body?.from)
|
|
2052
|
+
const to = bodyName(req.body?.to)
|
|
2033
2053
|
const chunks = Array.isArray(req.body?.chunks)
|
|
2034
2054
|
? (req.body.chunks as unknown[]).filter((n): n is number => Number.isInteger(n) && (n as number) >= 0)
|
|
2035
2055
|
: []
|
|
@@ -2382,7 +2402,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
2382
2402
|
res.status(evidenceRefusal.status).json(evidenceRefusal.body)
|
|
2383
2403
|
return
|
|
2384
2404
|
}
|
|
2385
|
-
const label =
|
|
2405
|
+
const label = bodyName(req.body?.label)
|
|
2386
2406
|
const bad = invalidLabelReason(label)
|
|
2387
2407
|
if (bad) {
|
|
2388
2408
|
res.status(400).json({ error: `label: ${bad}`, reason: 'invalid_label' })
|
|
@@ -2430,7 +2450,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
2430
2450
|
const doc = JSON.parse(readFileSync(sidecarPath, 'utf-8')) as Record<string, unknown>
|
|
2431
2451
|
const rows = Array.isArray(doc.chunks) ? doc.chunks : []
|
|
2432
2452
|
carried = rows.filter(r => r && typeof r === 'object'
|
|
2433
|
-
&& (r as Record<string, unknown>).speaker
|
|
2453
|
+
&& sameSpeakerName((r as Record<string, unknown>).speaker, label)).length
|
|
2434
2454
|
} catch {
|
|
2435
2455
|
res.status(500).json({ error: 'Could not read the chunk sidecar', reason: 'sidecar_unreadable' })
|
|
2436
2456
|
return
|
|
@@ -2473,7 +2493,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
2473
2493
|
res.status(evidenceRefusal.status).json(evidenceRefusal.body)
|
|
2474
2494
|
return
|
|
2475
2495
|
}
|
|
2476
|
-
const from =
|
|
2496
|
+
const from = bodyName(req.body?.from)
|
|
2477
2497
|
const bad = invalidLabelReason(from)
|
|
2478
2498
|
if (bad) {
|
|
2479
2499
|
res.status(400).json({ error: `from: ${bad}`, reason: 'invalid_label' })
|
|
@@ -2549,7 +2569,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
2549
2569
|
if (Array.isArray(parsed.chunks)) {
|
|
2550
2570
|
for (const c of parsed.chunks) {
|
|
2551
2571
|
const sp = (c as { speaker?: unknown } | null)?.speaker
|
|
2552
|
-
if (typeof sp === 'string' && !existingSpeakers.
|
|
2572
|
+
if (typeof sp === 'string' && !existingSpeakers.some(x => sameSpeakerName(x, sp))) existingSpeakers.push(sp)
|
|
2553
2573
|
}
|
|
2554
2574
|
}
|
|
2555
2575
|
} catch { /* the relabel below reports a corrupt sidecar */ }
|
|
@@ -2562,7 +2582,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
2562
2582
|
|
|
2563
2583
|
// What this would remove from the profile, and what it CANNOT reach.
|
|
2564
2584
|
// readVoiceProfiles returns a ProfileStore, not an array.
|
|
2565
|
-
const profile = readVoiceProfiles().profiles.find(p => p.name
|
|
2585
|
+
const profile = readVoiceProfiles().profiles.find(p => sameSpeakerName(p.name, from))
|
|
2566
2586
|
const profileSources: Array<string | undefined> = profile
|
|
2567
2587
|
? Array.from({ length: profile.embeddings.length }, (_, i) => profile.sources?.[i])
|
|
2568
2588
|
: []
|
|
@@ -3152,7 +3172,7 @@ function synthesizeEntriesFromChunkWavs(audioDir: string): RecoveredCapture {
|
|
|
3152
3172
|
return { entries, startTime, durationMs }
|
|
3153
3173
|
}
|
|
3154
3174
|
|
|
3155
|
-
async function finalizeBatch(options: {
|
|
3175
|
+
export async function finalizeBatch(options: {
|
|
3156
3176
|
audioDir: string
|
|
3157
3177
|
entries: IndexedTranscriptChunk[]
|
|
3158
3178
|
streamingWordCount: number
|
|
@@ -3161,7 +3181,11 @@ async function finalizeBatch(options: {
|
|
|
3161
3181
|
sessionId?: string
|
|
3162
3182
|
runBatch: NonNullable<MeetingRouteDependencies['runBatch']>
|
|
3163
3183
|
finalDiarize?: FinalDiarize
|
|
3184
|
+
qualifiedProfilePass?: typeof runQualifiedMacProfilePass
|
|
3164
3185
|
}): Promise<void> {
|
|
3186
|
+
const qualifiedProfilePass = options.qualifiedProfilePass ?? runQualifiedMacProfilePass
|
|
3187
|
+
const mac = MAC_CAPTURE_ID.test(options.sessionId ?? '')
|
|
3188
|
+
if (mac && (await qualifiedProfilePass({ ...options, resumeOnly: true })).status === 'applied') return
|
|
3165
3189
|
const result = await options.runBatch(
|
|
3166
3190
|
options.audioDir,
|
|
3167
3191
|
options.entries,
|
|
@@ -3226,6 +3250,10 @@ async function finalizeBatch(options: {
|
|
|
3226
3250
|
}
|
|
3227
3251
|
}
|
|
3228
3252
|
|
|
3253
|
+
// Exact qualified profile evidence runs before raw deletion and filing.
|
|
3254
|
+
// Prepared-transaction failures propagate: mixed generations cannot be filed.
|
|
3255
|
+
if (mac && metadataPersisted) await qualifiedProfilePass(options)
|
|
3256
|
+
|
|
3229
3257
|
if (canDeletePendingBatchAudio(transcriptApplied, metadataPersisted)) {
|
|
3230
3258
|
rmSync(options.audioDir, { recursive: true, force: true })
|
|
3231
3259
|
} else {
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /api/agent-sessions/find (Control 0.5.363): which of this Mac's agent sessions the person means, by meaning.
|
|
3
|
+
* Auth comes from the parent /api mount. See lib/session-find.ts for what leaves the Mac, the cost controls and the gates.
|
|
4
|
+
*
|
|
5
|
+
* Body `{ query, consentedProvider, stage }`, exactly those keys. `query` is a string; the words are redacted and cut at 200
|
|
6
|
+
* code points before use. `consentedProvider` is `"jev"`, `"subq"` or `null`: the provider the person agreed to send to.
|
|
7
|
+
* `stage` is `"first"` (the newest 254 sessions) or `"full"` (the next 254 too, unless the first already settles it).
|
|
8
|
+
*
|
|
9
|
+
* A search that cannot run answers HTTP 200 with `available: false`, a `reason` and a `message`, so Control keeps its word
|
|
10
|
+
* search and says one line about why. A malformed body is 400. A walk that fails (the session store, the keyed-sessions file) is
|
|
11
|
+
* also a 200 with `reason: failed`, so Control keeps its word search. 503 `session_find_unavailable` is left for an unexpected
|
|
12
|
+
* error outside the finder's own handling: try later.
|
|
13
|
+
* An older server answers 404, which Control reads as "no meaning search here".
|
|
14
|
+
*
|
|
15
|
+
* A closed connection stops the walk and any stage that has not started. A model request already sent is not aborted (it is
|
|
16
|
+
* billed either way). The query and the sessions never reach a log line.
|
|
17
|
+
*/
|
|
18
|
+
import { Router, type Response } from 'express'
|
|
19
|
+
import { isAbortError } from '../lib/session-find-collect.js'
|
|
20
|
+
import { SESSION_FIND_LIMITS, SessionFinder, unavailableAnswer, type SessionFindAnswer } from '../lib/session-find.js'
|
|
21
|
+
|
|
22
|
+
export interface SessionFindRouteDependencies {
|
|
23
|
+
finder: Pick<SessionFinder, 'find'>
|
|
24
|
+
now: () => number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const BODY_KEYS = new Set(['query', 'consentedProvider', 'stage'])
|
|
28
|
+
/** One log line per degrade reason per 10 minutes: Control falls back quietly, so the server says why. Fixed words only, never the query or a session. */
|
|
29
|
+
export const SESSION_FIND_LOG_EVERY_MS = 10 * 60_000
|
|
30
|
+
|
|
31
|
+
export function createSessionFindRouter(overrides: Partial<SessionFindRouteDependencies> = {}): Router {
|
|
32
|
+
let finder: Pick<SessionFinder, 'find'> | null = overrides.finder ?? null
|
|
33
|
+
const now = overrides.now ?? Date.now
|
|
34
|
+
const lastLogged = new Map<string, number>()
|
|
35
|
+
const logDegrade = (reason: string) => {
|
|
36
|
+
const at = now()
|
|
37
|
+
const last = lastLogged.get(reason)
|
|
38
|
+
if (last !== undefined && at - last < SESSION_FIND_LOG_EVERY_MS) return
|
|
39
|
+
lastLogged.set(reason, at)
|
|
40
|
+
console.warn(`[session-find] unavailable: ${reason}`)
|
|
41
|
+
}
|
|
42
|
+
const router = Router()
|
|
43
|
+
router.use('/agent-sessions/find', (_req, res, next) => { res.set('Cache-Control', 'private, no-store'); next() })
|
|
44
|
+
|
|
45
|
+
router.post('/agent-sessions/find', async (req, res: Response) => {
|
|
46
|
+
const body = req.body
|
|
47
|
+
const bad = () => res.status(400).json({ error: { code: 'invalid_find_request',
|
|
48
|
+
message: 'Send a query, the provider you consented to (jev, subq or null) and a stage of first or full.' } })
|
|
49
|
+
if (!body || typeof body !== 'object' || Array.isArray(body) || Object.keys(body).some(key => !BODY_KEYS.has(key))) return bad()
|
|
50
|
+
if (typeof body.query !== 'string' || body.query.length > SESSION_FIND_LIMITS.queryRawMax) return bad()
|
|
51
|
+
if (body.consentedProvider !== null && body.consentedProvider !== undefined && body.consentedProvider !== 'jev' && body.consentedProvider !== 'subq') return bad()
|
|
52
|
+
if (body.stage !== 'first' && body.stage !== 'full') return bad()
|
|
53
|
+
|
|
54
|
+
const controller = new AbortController()
|
|
55
|
+
// `close` also fires when a response has finished normally; only a connection that closed before the answer counts.
|
|
56
|
+
res.on('close', () => { if (!res.writableEnded) controller.abort() })
|
|
57
|
+
let answer: SessionFindAnswer
|
|
58
|
+
try {
|
|
59
|
+
finder ??= new SessionFinder()
|
|
60
|
+
answer = await finder.find({ query: body.query, consentedProvider: body.consentedProvider ?? null, stage: body.stage }, controller.signal)
|
|
61
|
+
} catch (error) {
|
|
62
|
+
if (isAbortError(error) || controller.signal.aborted) return
|
|
63
|
+
console.error('[session-find] search failed')
|
|
64
|
+
return res.status(503).json({ error: { code: 'session_find_unavailable', message: 'Meaning search could not read your sessions. Try again shortly.' } })
|
|
65
|
+
}
|
|
66
|
+
if (controller.signal.aborted) return
|
|
67
|
+
if (!answer.available && answer.reason) logDegrade(answer.reason)
|
|
68
|
+
return res.json(answer)
|
|
69
|
+
})
|
|
70
|
+
return router
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export { unavailableAnswer }
|
package/server/routes/voice.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { errMsg } from '../lib/utils.js'
|
|
|
5
5
|
import { readdirSync, readFileSync, unlinkSync, existsSync, rmdirSync, rmSync } from 'node:fs'
|
|
6
6
|
import { resolve } from 'node:path'
|
|
7
7
|
import { checkSpeakerName } from '../lib/speaker-name.js'
|
|
8
|
+
import { bodyName, nfc, sameSpeakerName } from '../lib/speaker-name-nfc.js'
|
|
8
9
|
import { enrollSpeaker, isEnrolled, getAllSpeakerNames, identifySpeaker, extractEmbedding, enrollEmbedding, getEmbeddingCount, removeSpeakerProfile, readVoiceProfiles, mergeSpeakerProfiles } from '../lib/speaker-embeddings.js'
|
|
9
10
|
import { statSync } from 'node:fs'
|
|
10
11
|
import { trainFromFireflies, getTrainingStatus } from '../lib/speaker-trainer.js'
|
|
@@ -51,7 +52,9 @@ voiceRouter.post('/voice/enroll', async (req, res) => {
|
|
|
51
52
|
// Default to the configured wearer label ('Me' unless owner_speaker_label
|
|
52
53
|
// is set). Hardcoding one user's initials here enrolled every other install
|
|
53
54
|
// under a stranger's name.
|
|
54
|
-
|
|
55
|
+
// `?name=a&name=b` is not a name; falling through to the owner's label would enroll a stranger's voice as the owner.
|
|
56
|
+
if (req.query.name !== undefined && typeof req.query.name !== 'string') return res.status(400).json({ error: 'name must be a single value' })
|
|
57
|
+
const name = nfc(typeof req.query.name === 'string' && req.query.name ? req.query.name : getOwnerSpeakerLabel())
|
|
55
58
|
|
|
56
59
|
// An old client can still send a whole spoken sentence as the name (the
|
|
57
60
|
// "enroll my voice" fall-through). Refuse it here: a junk profile can
|
|
@@ -115,7 +118,8 @@ voiceRouter.post('/voice/identify', async (req, res) => {
|
|
|
115
118
|
// POST /api/voice/train — train voiceprints from Fireflies meeting audio
|
|
116
119
|
voiceRouter.post('/voice/train', async (req, res) => {
|
|
117
120
|
try {
|
|
118
|
-
const { speakerNames, minSegments, minSegmentDuration, limit, maxEmbeddingsPerSpeaker, fresh } = req.body ?? {}
|
|
121
|
+
const { speakerNames: rawSpeakerNames, minSegments, minSegmentDuration, limit, maxEmbeddingsPerSpeaker, fresh } = req.body ?? {}
|
|
122
|
+
const speakerNames = Array.isArray(rawSpeakerNames) ? rawSpeakerNames.map((n: unknown) => typeof n === 'string' ? nfc(n) : n) : rawSpeakerNames
|
|
119
123
|
const report = await trainFromFireflies({
|
|
120
124
|
speakerNames,
|
|
121
125
|
minSegments,
|
|
@@ -160,7 +164,7 @@ const DEFAULT_MAX_TRAIN_PER_SPEAKER = 10
|
|
|
160
164
|
|
|
161
165
|
voiceRouter.post('/voice/train-g2', async (req, res) => {
|
|
162
166
|
try {
|
|
163
|
-
const targetSpeaker = req.body?.speaker
|
|
167
|
+
const targetSpeaker = typeof req.body?.speaker === 'string' ? nfc(req.body.speaker) : undefined
|
|
164
168
|
const confirmAll = req.body?.confirmAllSpeakers === true
|
|
165
169
|
const dryRun = req.body?.dryRun === true
|
|
166
170
|
const maxPerSpeaker = Number.isFinite(req.body?.maxPerSpeaker)
|
|
@@ -361,7 +365,8 @@ voiceRouter.get('/voice/ext-audio', (_req, res) => {
|
|
|
361
365
|
// If sessionId provided, only enroll from that session. Otherwise, enroll from all ext sessions.
|
|
362
366
|
voiceRouter.post('/voice/enroll-ext', async (req, res) => {
|
|
363
367
|
try {
|
|
364
|
-
const { name, sessionId } = req.body ?? {}
|
|
368
|
+
const { name: rawName, sessionId } = req.body ?? {}
|
|
369
|
+
const name = typeof rawName === 'string' ? nfc(rawName) : rawName
|
|
365
370
|
if (!name || typeof name !== 'string' || name.length < 2) {
|
|
366
371
|
return res.status(400).json({ error: 'name is required (min 2 chars)' })
|
|
367
372
|
}
|
|
@@ -485,7 +490,7 @@ function newestWav(dirPath: string): string | null {
|
|
|
485
490
|
// GET /api/voice/profiles/:name/sample — hear what a stored profile sounds like.
|
|
486
491
|
voiceRouter.get('/voice/profiles/:name/sample', (req, res) => {
|
|
487
492
|
res.set('Cache-Control', 'private, no-store')
|
|
488
|
-
const name = String(req.params.name ?? '')
|
|
493
|
+
const name = nfc(String(req.params.name ?? ''))
|
|
489
494
|
const dirPath = speakerDirPath(AUDIO_SAVE_DIR, name)
|
|
490
495
|
if (!dirPath) {
|
|
491
496
|
res.status(400).json({ error: 'Invalid speaker name', reason: 'invalid_speaker' })
|
|
@@ -587,7 +592,7 @@ voiceRouter.post('/voice/held-groups/enroll', async (req, res) => {
|
|
|
587
592
|
try {
|
|
588
593
|
const nameCheck = checkSpeakerName(req.body?.name, { ownerLabel: getOwnerSpeakerLabel() })
|
|
589
594
|
if (!nameCheck.ok) return res.status(400).json({ success:false,error:nameCheck.message,reason:nameCheck.reason })
|
|
590
|
-
const name = resolveStoredName(String(req.body.name).trim())
|
|
595
|
+
const name = resolveStoredName(nfc(String(req.body.name).trim()))
|
|
591
596
|
// Held samples are audio this Mac captured. A member pointing at an imported
|
|
592
597
|
// or derived record is not held audio, so it never reaches the preview.
|
|
593
598
|
//
|
|
@@ -679,7 +684,7 @@ voiceRouter.get('/voice/profiles', (_req, res) => {
|
|
|
679
684
|
return {
|
|
680
685
|
name: p.name,
|
|
681
686
|
embeddings: p.embeddings.length,
|
|
682
|
-
isOwner: p.name
|
|
687
|
+
isOwner: sameSpeakerName(p.name, owner),
|
|
683
688
|
sources: bySource,
|
|
684
689
|
// 6.44.15: a profile whose name on disk was a spoken sentence was
|
|
685
690
|
// renamed at load; the client offers a rename.
|
|
@@ -793,21 +798,23 @@ function fanOutTotals(runs: readonly SpeakerRenameFanOut[]): {
|
|
|
793
798
|
// knows something the audio does not, and it is logged.
|
|
794
799
|
voiceRouter.post('/voice/merge-profiles', async (req, res) => {
|
|
795
800
|
try {
|
|
796
|
-
const into =
|
|
801
|
+
const into = bodyName(req.body?.into).trim()
|
|
797
802
|
const rawFrom = req.body?.from
|
|
798
803
|
const from = (Array.isArray(rawFrom) ? rawFrom : [rawFrom])
|
|
799
804
|
.filter((n: unknown): n is string => typeof n === 'string' && n.trim().length > 0)
|
|
800
|
-
.map((n: string) => n.trim())
|
|
805
|
+
.map((n: string) => nfc(n.trim()))
|
|
801
806
|
|
|
802
807
|
if (!into || from.length === 0) {
|
|
803
808
|
return res.status(400).json({ error: 'into (string) and from (string or string[]) are required' })
|
|
804
809
|
}
|
|
805
|
-
|
|
810
|
+
// The same name twice is refused, unless the store holds it twice (a profile written decomposed and its composed twin): those are two
|
|
811
|
+
// profiles and merging them is the point.
|
|
812
|
+
if (from.some(n => sameSpeakerName(n, into)) && readVoiceProfiles().profiles.filter(p => sameSpeakerName(p.name, into)).length < 2) {
|
|
806
813
|
return res.status(400).json({ error: 'into and from must differ' })
|
|
807
814
|
}
|
|
808
815
|
|
|
809
816
|
const owner = getOwnerSpeakerLabel()
|
|
810
|
-
if (from.
|
|
817
|
+
if (from.some(n => sameSpeakerName(n, owner))) {
|
|
811
818
|
// Absorbing the owner label would delete the profile the live
|
|
812
819
|
// identification path checks FIRST, on every chunk.
|
|
813
820
|
return res.status(400).json({
|
|
@@ -907,11 +914,11 @@ voiceRouter.post('/voice/delete-person', (req, res) => {
|
|
|
907
914
|
if (!name || typeof name !== 'string' || name.trim().length < 2) {
|
|
908
915
|
return res.status(400).json({ error: 'name is required (min 2 chars)' })
|
|
909
916
|
}
|
|
910
|
-
const target = name.trim()
|
|
917
|
+
const target = nfc(name.trim())
|
|
911
918
|
const dryRun = req.body?.dryRun === true
|
|
912
919
|
|
|
913
920
|
if (req.body?.confirm !== true && !dryRun) {
|
|
914
|
-
const existing = readVoiceProfiles().profiles.
|
|
921
|
+
const existing = readVoiceProfiles().profiles.filter(p => sameSpeakerName(p.name, target))
|
|
915
922
|
const audioDir = speakerDirPath(AUDIO_SAVE_DIR, target)
|
|
916
923
|
let wavs = 0
|
|
917
924
|
if (audioDir && existsSync(audioDir)) {
|
|
@@ -922,8 +929,8 @@ voiceRouter.post('/voice/delete-person', (req, res) => {
|
|
|
922
929
|
error: 'confirmation required',
|
|
923
930
|
message: `Deleting "${target}" is not reversible. Pass { confirm: true } to proceed.`,
|
|
924
931
|
wouldRemove: {
|
|
925
|
-
profile: existing
|
|
926
|
-
embeddings: existing
|
|
932
|
+
profile: existing.length,
|
|
933
|
+
embeddings: existing.reduce((n, p) => n + p.embeddings.length, 0),
|
|
927
934
|
trainingAudioFiles: wavs,
|
|
928
935
|
calibrationRows: calibration.removed,
|
|
929
936
|
},
|
|
@@ -931,7 +938,7 @@ voiceRouter.post('/voice/delete-person', (req, res) => {
|
|
|
931
938
|
}
|
|
932
939
|
|
|
933
940
|
if (dryRun) {
|
|
934
|
-
const existing = readVoiceProfiles().profiles.
|
|
941
|
+
const existing = readVoiceProfiles().profiles.filter(p => sameSpeakerName(p.name, target))
|
|
935
942
|
const audioDir = speakerDirPath(AUDIO_SAVE_DIR, target)
|
|
936
943
|
let wavs = 0
|
|
937
944
|
if (audioDir && existsSync(audioDir)) {
|
|
@@ -942,8 +949,8 @@ voiceRouter.post('/voice/delete-person', (req, res) => {
|
|
|
942
949
|
name: target,
|
|
943
950
|
dryRun: true,
|
|
944
951
|
removed: {
|
|
945
|
-
profiles: existing
|
|
946
|
-
embeddings: existing
|
|
952
|
+
profiles: existing.length,
|
|
953
|
+
embeddings: existing.reduce((n, p) => n + p.embeddings.length, 0),
|
|
947
954
|
trainingAudioFiles: wavs,
|
|
948
955
|
calibrationRows: calibration.removed,
|
|
949
956
|
},
|