@gotcos/glasses-server 6.94.4 → 6.98.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/CHANGELOG.md +147 -0
- package/README.md +52 -0
- package/managed-runtime-contract.json +13 -0
- package/package.json +3 -1
- package/server/index.ts +19 -1
- package/server/lib/attached-provider-adapter.ts +35 -1
- package/server/lib/claude-bridge.ts +47 -0
- package/server/lib/codex-bridge.ts +11 -1
- package/server/lib/codex-rollout-watermark.ts +42 -0
- package/server/lib/cursor-bridge.ts +2 -1
- package/server/lib/dictation-clean-models.ts +2 -0
- package/server/lib/dictation-clean.ts +2 -2
- package/server/lib/dictation-polish-pipeline.ts +22 -2
- package/server/lib/dictation-prewarm.ts +16 -0
- package/server/lib/direct-bridge.ts +14 -2
- package/server/lib/direct-sse.ts +6 -1
- package/server/lib/display-bus.ts +1 -1
- package/server/lib/fork-thread.ts +32 -1
- package/server/lib/held-naming-batches.ts +36 -0
- package/server/lib/job-audit-caller.ts +20 -0
- package/server/lib/lens-gist-engines.ts +17 -5
- package/server/lib/live-cos-ask.ts +29 -6
- package/server/lib/live-cue-verdicts.ts +43 -8
- package/server/lib/live-cues-engine.ts +4 -1
- package/server/lib/live-cues-filter.ts +50 -0
- package/server/lib/live-cues-general-prompts.ts +137 -0
- package/server/lib/live-cues-general-topic.ts +245 -0
- package/server/lib/live-cues-general.ts +715 -0
- package/server/lib/live-cues-v2-data.ts +453 -0
- package/server/lib/live-cues-v2-grounded.ts +262 -0
- package/server/lib/live-cues-v2-judge-b.ts +59 -0
- package/server/lib/live-cues-v2-names.ts +49 -0
- package/server/lib/live-cues-v2-notes.ts +8 -4
- package/server/lib/live-cues-v2-open-items.ts +132 -0
- package/server/lib/live-cues-v2-pacing.ts +45 -0
- package/server/lib/live-cues-v2-prompts-kinds.ts +9 -4
- package/server/lib/live-cues-v2-settings.ts +89 -10
- package/server/lib/live-cues-v2.ts +570 -47
- package/server/lib/mac-recording-asr.ts +6 -3
- package/server/lib/mac-speaker-call-human.ts +371 -0
- package/server/lib/mac-speaker-call-interval-human.ts +499 -0
- package/server/lib/mac-speaker-call-interval-projection.ts +233 -0
- package/server/lib/mac-speaker-call-original-view.ts +328 -0
- package/server/lib/mac-speaker-call-producer-worker.ts +615 -0
- package/server/lib/mac-speaker-call-producer.ts +539 -0
- package/server/lib/mac-speaker-call-store.ts +264 -0
- package/server/lib/mac-speaker-call-view.ts +373 -0
- package/server/lib/mac-speaker-live-compute.ts +72 -0
- package/server/lib/mac-speaker-live-memory.ts +414 -0
- package/server/lib/mac-speaker-live-projection.ts +126 -0
- package/server/lib/mac-speaker-live-save.ts +90 -0
- package/server/lib/mac-speaker-live-store.ts +71 -0
- package/server/lib/mac-speaker-live-worker.ts +58 -0
- package/server/lib/mac-speaker-live.ts +175 -0
- package/server/lib/mac-speaker-profile-extractor.ts +15 -10
- package/server/lib/mac-speaker-shadow-repair.ts +307 -0
- package/server/lib/mac-voice-assignment.ts +44 -26
- package/server/lib/mac-voice-display.ts +195 -0
- package/server/lib/mac-voice-naming-batch.ts +1067 -0
- package/server/lib/mac-voice-suggestions.ts +548 -0
- package/server/lib/maintenance-lifecycle.ts +2 -0
- package/server/lib/media-store.ts +2 -1
- package/server/lib/meeting-ask-pasted.ts +135 -0
- package/server/lib/meeting-ask-review.ts +8 -1
- package/server/lib/meeting-ask-runner.ts +4 -1
- package/server/lib/meeting-ask-runtime.ts +189 -17
- package/server/lib/meeting-ask-store.ts +2 -0
- package/server/lib/meeting-ask-web.ts +284 -13
- package/server/lib/meeting-batch-progress.ts +51 -5
- package/server/lib/meeting-batch-transcribe.ts +35 -2
- package/server/lib/meeting-fact-check.ts +3 -0
- package/server/lib/meeting-finalization-jobs.ts +69 -2
- package/server/lib/meeting-public-topic.ts +6 -1
- package/server/lib/meeting-speaker-review.ts +182 -17
- package/server/lib/meeting-store.ts +6 -1
- package/server/lib/parakeet.ts +51 -5
- package/server/lib/pending-batch-retention.ts +58 -0
- package/server/lib/private-reference.ts +38 -0
- package/server/lib/provider-usage.ts +561 -0
- package/server/lib/public-https-url.ts +32 -0
- package/server/lib/query-job-runtime.ts +3 -0
- package/server/lib/rich-media-safety.ts +23 -2
- package/server/lib/speaker-shadow-decide.ts +503 -0
- package/server/lib/speaker-shadow-models.ts +150 -0
- package/server/lib/speaker-shadow-numeric.ts +143 -0
- package/server/lib/speaker-shadow-pair-source.ts +91 -0
- package/server/lib/speaker-shadow-paired.ts +268 -0
- package/server/lib/speaker-shadow-report.ts +113 -0
- package/server/lib/speaker-shadow-timeline.ts +210 -0
- package/server/lib/speaker-shadow-worker.ts +312 -0
- package/server/lib/speaker-shadow.ts +788 -0
- package/server/lib/token-audit.ts +36 -6
- package/server/lib/transcript-cleanup.ts +3 -2
- package/server/lib/voice-directory.ts +8 -4
- package/server/lib/web-lookup-ledger.ts +151 -0
- package/server/lib/work-review-runtime.ts +2 -0
- package/server/lib/work-search.ts +3 -1
- package/server/lib/xlsx-text.ts +277 -0
- package/server/routes/health.ts +5 -0
- package/server/routes/live-cues.ts +36 -14
- package/server/routes/meeting.ts +506 -49
- package/server/routes/speaker-shadow.ts +79 -0
- package/server/routes/transcribe-stream.ts +114 -33
- package/server/routes/voice.ts +3 -0
- package/server/scripts/live-cues-v3-mutation-gate.mjs +311 -0
- package/server/scripts/mac-voice-assignment-6930-mutation-gate.mjs +5 -5
- package/shared/live-cues-v2.ts +38 -5
- package/shared/media-attachment.ts +2 -0
- package/shared/meeting-ask.ts +11 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Speaker merge shadow mode (6.95.0). Token-protected by the /api middleware. READ ONLY.
|
|
2
|
+
//
|
|
3
|
+
// GET /api/speaker-shadow/proposals the logged proposal records, newest first
|
|
4
|
+
// ?limit=1..100 (default 10) ?sessionId=mac_<uuid>
|
|
5
|
+
// GET /api/speaker-shadow/status the switch, queue, daily cap, breaker, last error and
|
|
6
|
+
// setup readiness (models, CLI, sandbox)
|
|
7
|
+
// GET /api/speaker-shadow/settings { enabled, source } source: env | setting | default
|
|
8
|
+
// POST /api/speaker-shadow/settings {"enabled": true|false}: this Mac's saved switch (no
|
|
9
|
+
// restart); 409 env_forced when COS_SPEAKER_SHADOW pins it
|
|
10
|
+
//
|
|
11
|
+
// The settings POST is the only write, and it writes only <data>/speaker-shadow/settings.json.
|
|
12
|
+
// Nothing on these routes changes a meeting, a label, a name or a profile. A 404 means a
|
|
13
|
+
// server older than 6.95.0.
|
|
14
|
+
|
|
15
|
+
import { Router } from 'express'
|
|
16
|
+
import { getSpeakerShadowRunner, readSpeakerShadowRecords, resolveSpeakerShadowSwitch, speakerShadowEnvSwitch, writeSpeakerShadowSetting } from '../lib/speaker-shadow.js'
|
|
17
|
+
import { speakerShadowRoot } from '../lib/speaker-shadow-models.js'
|
|
18
|
+
|
|
19
|
+
export interface SpeakerShadowRouterDeps {
|
|
20
|
+
root?: () => string
|
|
21
|
+
env?: NodeJS.ProcessEnv
|
|
22
|
+
snapshot?: () => ReturnType<ReturnType<typeof getSpeakerShadowRunner>['snapshot']>
|
|
23
|
+
/** Called after the setting turns shadow mode on (the runner picks up anything still queued). */
|
|
24
|
+
onEnabled?: () => void
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const SESSION_ID = /^mac_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
|
28
|
+
|
|
29
|
+
export function createSpeakerShadowRouter(deps: SpeakerShadowRouterDeps = {}): Router {
|
|
30
|
+
const router = Router()
|
|
31
|
+
const root = deps.root ?? speakerShadowRoot
|
|
32
|
+
const snapshot = deps.snapshot ?? (() => getSpeakerShadowRunner().snapshot({ detail: true }))
|
|
33
|
+
const env = deps.env ?? process.env
|
|
34
|
+
const onEnabled = deps.onEnabled ?? (() => getSpeakerShadowRunner().resume())
|
|
35
|
+
|
|
36
|
+
router.get('/speaker-shadow/proposals', async (req, res) => {
|
|
37
|
+
const rawLimit = Number(req.query.limit ?? 10)
|
|
38
|
+
if (!Number.isInteger(rawLimit) || rawLimit < 1 || rawLimit > 100) { res.status(400).json({ error: 'limit must be an integer from 1 to 100' }); return }
|
|
39
|
+
const sessionId = typeof req.query.sessionId === 'string' ? req.query.sessionId : undefined
|
|
40
|
+
if (sessionId !== undefined && !SESSION_ID.test(sessionId)) { res.status(400).json({ error: 'sessionId must be a mac_<uuid> session id' }); return }
|
|
41
|
+
try {
|
|
42
|
+
const records = await readSpeakerShadowRecords(root(), { limit: rawLimit, sessionId })
|
|
43
|
+
res.json({ logOnly: true, shadow: snapshot(), count: records.length, records })
|
|
44
|
+
} catch (error) {
|
|
45
|
+
res.status(500).json({ error: `could not read the shadow log: ${error instanceof Error ? error.message : String(error)}` })
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
router.get('/speaker-shadow/status', (_req, res) => {
|
|
50
|
+
res.json({ logOnly: true, ...snapshot() })
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
router.get('/speaker-shadow/settings', (_req, res) => {
|
|
54
|
+
res.set('Cache-Control', 'private, no-store')
|
|
55
|
+
res.json(resolveSpeakerShadowSwitch(env, root()))
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
router.post('/speaker-shadow/settings', (req, res) => {
|
|
59
|
+
res.set('Cache-Control', 'private, no-store')
|
|
60
|
+
const body = req.body as { enabled?: unknown } | undefined
|
|
61
|
+
if (!body || typeof body !== 'object' || Array.isArray(body) || typeof body.enabled !== 'boolean' || Object.keys(body).some(k => k !== 'enabled')) {
|
|
62
|
+
res.status(400).json({ error: 'invalid_request', expected: '{"enabled": true|false}' })
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
const pinned = speakerShadowEnvSwitch(env)
|
|
66
|
+
if (pinned !== null && pinned !== body.enabled) { res.status(409).json({ error: 'env_forced', ...resolveSpeakerShadowSwitch(env, root()) }); return }
|
|
67
|
+
try {
|
|
68
|
+
writeSpeakerShadowSetting(body.enabled, root())
|
|
69
|
+
} catch {
|
|
70
|
+
res.status(500).json({ error: 'settings_write_failed' })
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
const view = resolveSpeakerShadowSwitch(env, root())
|
|
74
|
+
if (view.enabled) { try { onEnabled() } catch { /* the next finished meeting starts it */ } }
|
|
75
|
+
res.json(view)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
return router
|
|
79
|
+
}
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
import { takeMacSpeakerLivePublication } from '../lib/mac-speaker-live-compute.js'
|
|
2
|
+
import type { MacSpeakerLivePreparedPublication } from '../lib/mac-speaker-live-worker.js'
|
|
3
|
+
import { createMacSpeakerLiveScheduler, macSpeakerLiveEnabled, assertMacSpeakerLivePublicationBindings, type MacSpeakerLiveOutcome, type MacSpeakerLiveFileBinding } from '../lib/mac-speaker-live.js'
|
|
4
|
+
import { hashMacSpeakerLive, type MacSpeakerLiveState } from '../lib/mac-speaker-live-memory.js'
|
|
5
|
+
import { macSpeakerLiveOriginalOnly, macSpeakerLiveVoiceHighWater } from '../lib/mac-speaker-live-store.js'
|
|
1
6
|
import { PHONE_CAPTURE_ID, PHONE_SOURCE_TYPE, assertPhoneProvenance, admitPhoneRecording, phoneQueryInteger, requirePhoneRecording, readPhoneRecording, validatePhoneWav, persistPhoneChunkReceipt } from '../lib/phone-recording.js'
|
|
2
7
|
import { matchMacLiveVoice, macAnonymousProjection, type MacLiveVoiceState } from '../lib/mac-live-voices.js'
|
|
3
8
|
import { chunkShowsVoiceLabel, macLiveVoicesMode } from '../lib/mac-voice-labels.js'
|
|
@@ -15,7 +20,7 @@ import { readSpeakerPolicy, policyLabel, type SpeakerMode } from '../lib/meeting
|
|
|
15
20
|
// Streams speaker-labeled transcript chunks for live meeting capture.
|
|
16
21
|
|
|
17
22
|
import { Router } from 'express'
|
|
18
|
-
import { createHash } from 'node:crypto'
|
|
23
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
19
24
|
import { chmodSync, readFileSync, writeFileSync, existsSync, lstatSync, mkdirSync, readdirSync, unlinkSync, rmSync, renameSync, statSync } from 'node:fs'
|
|
20
25
|
import { writeFile } from 'node:fs/promises'
|
|
21
26
|
import { resolve } from 'node:path'
|
|
@@ -86,6 +91,7 @@ import {
|
|
|
86
91
|
type EvenSpeakerRoleHistogram,
|
|
87
92
|
} from '../lib/even-hub-speaker-role.js'
|
|
88
93
|
import { archiveSessionAudio, runMeetingAudioRetention } from '../lib/meeting-audio-archive.js'
|
|
94
|
+
import { sweepStalePendingBatch } from '../lib/pending-batch-retention.js'
|
|
89
95
|
import {
|
|
90
96
|
DISCARDED_RETENTION_MS,
|
|
91
97
|
countChunkWavs,
|
|
@@ -339,6 +345,18 @@ export interface ProviderCandidateRecord {
|
|
|
339
345
|
}
|
|
340
346
|
|
|
341
347
|
interface TranscriptSession {
|
|
348
|
+
/** Capture-frozen opt-in; original ASR chunks remain immutable inputs. */
|
|
349
|
+
speakerIdentityPolicy?: 'mac-native-v1'
|
|
350
|
+
speakerGeneration?: string
|
|
351
|
+
speakerRevision?: number
|
|
352
|
+
speakerProjectionSHA256?: string
|
|
353
|
+
macSpeakerLive?: MacSpeakerLiveState
|
|
354
|
+
macSpeakerLiveQuarantineSHA256?: string
|
|
355
|
+
/** Ephemeral: recovered evidence cannot publish before asynchronous revalidation. */
|
|
356
|
+
macSpeakerLiveValidated?: boolean
|
|
357
|
+
macSpeakerLiveSourceBindingSHA256?: string
|
|
358
|
+
macSpeakerLiveFileBindings?: MacSpeakerLiveFileBinding[]
|
|
359
|
+
macSpeakerLiveStatus?: {reason:string;latencyMs:number;pendingWindows:number;completeEligibleSnapshot:boolean}
|
|
342
360
|
chunks: TranscriptChunk[]
|
|
343
361
|
/** Bounded anonymous acoustic anchors; active capture only, never training. */
|
|
344
362
|
macLiveVoices?: MacLiveVoiceState
|
|
@@ -370,6 +388,44 @@ interface TranscriptSession {
|
|
|
370
388
|
}
|
|
371
389
|
|
|
372
390
|
const sessions = new Map<string, TranscriptSession>()
|
|
391
|
+
function liveIndexed(session:TranscriptSession){return session.chunks.flatMap((c,i)=>c?.text?[{i,c}]:[])}
|
|
392
|
+
function liveSnapshot(captureId:string){
|
|
393
|
+
const s=sessions.get(captureId)
|
|
394
|
+
if(!maintenanceAdmissionsOpen()||!macSpeakerLiveEnabled()||!s||s.speakerIdentityPolicy!=='mac-native-v1'||!s.speakerGeneration||isSessionDeleted(captureId)||macChunkLocks.has(captureId))return null
|
|
395
|
+
return{captureId,generation:s.speakerGeneration,audioDir:resolve(SESSION_AUDIO_DIR,captureId),ledgerPath:dataPath('meeting-corrections',`${captureId}.jsonl`),
|
|
396
|
+
chunksIndexed:structuredClone(liveIndexed(s)) as unknown as {i:number;c:Record<string,unknown>}[],state:s.macSpeakerLive?structuredClone(s.macSpeakerLive):undefined,
|
|
397
|
+
voiceHighWater:macSpeakerLiveVoiceHighWater(s.chunks,Math.max(s.macWindowVoices?.voices??0,Number.isSafeInteger(s.macSpeakerLive?.voiceHighWater)?s.macSpeakerLive!.voiceHighWater:0)),revision:s.speakerRevision??0,previousDigest:s.speakerProjectionSHA256}
|
|
398
|
+
}
|
|
399
|
+
function commitMacSpeakerLive(captureId:string,outcome:MacSpeakerLiveOutcome):boolean{
|
|
400
|
+
const s=sessions.get(captureId)
|
|
401
|
+
if(!s||s.speakerIdentityPolicy!=='mac-native-v1'||!s.speakerGeneration||isSessionDeleted(captureId)||macChunkLocks.has(captureId)||hashMacSpeakerLive(liveIndexed(s))!==outcome.sourceBindingSHA256)return false
|
|
402
|
+
if(outcome.state&&(outcome.state.captureId!==captureId||outcome.state.generation!==s.speakerGeneration))return false
|
|
403
|
+
try{if(outcome.completeEligibleSnapshot)assertMacSpeakerLivePublicationBindings(outcome.fileBindings)}catch{
|
|
404
|
+
s.macSpeakerLiveValidated=false;s.macSpeakerLiveStatus={reason:'publication_binding_changed',latencyMs:outcome.latencyMs,pendingWindows:0,completeEligibleSnapshot:false};persistSessionBestEffort(captureId);return false
|
|
405
|
+
}
|
|
406
|
+
const certified=takeMacSpeakerLivePublication(outcome.capability,{captureId,generation:s.speakerGeneration,sourceBindingSHA256:outcome.sourceBindingSHA256,fileBindingsSHA256:hashMacSpeakerLive(outcome.fileBindings),revision:s.speakerRevision??0,
|
|
407
|
+
voiceHighWater:macSpeakerLiveVoiceHighWater(s.chunks,Math.max(s.macWindowVoices?.voices??0,Number.isSafeInteger(s.macSpeakerLive?.voiceHighWater)?s.macSpeakerLive!.voiceHighWater:0)),mode:'mac-native-v1'})
|
|
408
|
+
if(outcome.completeEligibleSnapshot&&!certified)return false
|
|
409
|
+
const previous={source:s.macSpeakerLiveSourceBindingSHA256,files:s.macSpeakerLiveFileBindings,state:s.macSpeakerLive,validated:s.macSpeakerLiveValidated,status:s.macSpeakerLiveStatus,window:s.macWindowVoices?structuredClone(s.macWindowVoices):undefined,revision:s.speakerRevision,digest:s.speakerProjectionSHA256}
|
|
410
|
+
if(certified)s.macSpeakerLive=certified.macSpeakerLive??outcome.state
|
|
411
|
+
s.macSpeakerLiveSourceBindingSHA256=outcome.sourceBindingSHA256;s.macSpeakerLiveFileBindings=outcome.fileBindings
|
|
412
|
+
s.macSpeakerLiveValidated=maintenanceAdmissionsOpen()&&macSpeakerLiveEnabled()&&!!certified&&!!outcome.state&&outcome.completeEligibleSnapshot
|
|
413
|
+
s.macSpeakerLiveStatus={reason:outcome.reason,latencyMs:outcome.latencyMs,pendingWindows:outcome.pendingWindows,completeEligibleSnapshot:outcome.completeEligibleSnapshot}
|
|
414
|
+
if(s.macWindowVoices&&s.macSpeakerLive)s.macWindowVoices.voices=Math.max(s.macWindowVoices.voices,s.macSpeakerLive.voiceHighWater)
|
|
415
|
+
try{persistSessionRequired(captureId,certified??undefined)}catch{Object.assign(s,{macSpeakerLiveSourceBindingSHA256:previous.source,macSpeakerLiveFileBindings:previous.files,macSpeakerLive:previous.state,macSpeakerLiveValidated:previous.validated,macSpeakerLiveStatus:previous.status,macWindowVoices:previous.window,speakerRevision:previous.revision,speakerProjectionSHA256:previous.digest});return false}
|
|
416
|
+
// Dedicated invalidation cursor; consumers fetch the durable full replacement.
|
|
417
|
+
emitDisplay({type:'mac_speaker_live_revision',data:{sessionId:captureId,speakerIdentityPolicy:'mac-native-v1',generation:s.speakerGeneration,speakerRevision:s.speakerRevision,speakerProjectionSHA256:s.speakerProjectionSHA256}})
|
|
418
|
+
return true
|
|
419
|
+
}
|
|
420
|
+
const macSpeakerLiveScheduler=createMacSpeakerLiveScheduler({snapshot:liveSnapshot,commit:commitMacSpeakerLive,lease:()=>acquireMaintenanceWork('mac_speaker_live'),runtime:{admit:captureId=>maintenanceAdmissionsOpen()&&macSpeakerLiveEnabled()&&sessions.has(captureId)&&!isSessionDeleted(captureId)&&!macChunkLocks.has(captureId)}})
|
|
421
|
+
export const __macSpeakerLiveForTests={snapshot:liveSnapshot,commit:commitMacSpeakerLive,persist:(id:string)=>persistSessionRequired(id),drain:()=>macSpeakerLiveScheduler.drain()}
|
|
422
|
+
/** Save consumers receive only the durable, fully validated exact-source snapshot. */
|
|
423
|
+
export function getMacSpeakerLiveSaveSnapshot(captureId:string):{state:MacSpeakerLiveState;sourceBindingSHA256:string}|null{
|
|
424
|
+
const s=sessions.get(captureId);if(!macSpeakerLiveEnabled()||!s?.macSpeakerLiveValidated||!s.macSpeakerLive||s.macSpeakerLiveStatus?.completeEligibleSnapshot!==true||s.macSpeakerLiveSourceBindingSHA256!==hashMacSpeakerLive(liveIndexed(s)))return null
|
|
425
|
+
try{assertMacSpeakerLivePublicationBindings(s.macSpeakerLiveFileBindings??[])}catch{return null}
|
|
426
|
+
return{state:structuredClone(s.macSpeakerLive),sourceBindingSHA256:hashMacSpeakerLive(liveIndexed(s))}
|
|
427
|
+
}
|
|
428
|
+
|
|
373
429
|
const CLOSED_SESSION_TTL_MS = LOCAL_FIRST_MEETING_IDLE_RETENTION_MS
|
|
374
430
|
const CLOSED_SESSIONS_FILE = dataPath('closed-transcript-sessions.json')
|
|
375
431
|
|
|
@@ -704,7 +760,7 @@ function recoverClosedSessions(): void {
|
|
|
704
760
|
|
|
705
761
|
/** Persist a session before acknowledging any chunk. Throws on failure so a
|
|
706
762
|
* client never interprets a non-durable index as accepted. */
|
|
707
|
-
function persistSessionRequired(sessionId: string): void {
|
|
763
|
+
function persistSessionRequired(sessionId: string, certified?:MacSpeakerLivePreparedPublication): void {
|
|
708
764
|
const session = sessions.get(sessionId)
|
|
709
765
|
if (!session) throw makeHttpError(404, 'session not found', 'session_not_found')
|
|
710
766
|
const filePath = resolve(CHUNK_PERSIST_DIR, `${sessionId}.json`)
|
|
@@ -715,8 +771,23 @@ function persistSessionRequired(sessionId: string): void {
|
|
|
715
771
|
const c = session.chunks[i]
|
|
716
772
|
if (c && c.text) chunksIndexed.push({ i, c })
|
|
717
773
|
}
|
|
774
|
+
if(session.macSpeakerLiveValidated){
|
|
775
|
+
let reason:string|undefined
|
|
776
|
+
if(!macSpeakerLiveEnabled())reason='feature_disabled'
|
|
777
|
+
else if(session.macSpeakerLiveSourceBindingSHA256!==hashMacSpeakerLive(chunksIndexed))reason='new_source_pending'
|
|
778
|
+
else try{assertMacSpeakerLivePublicationBindings(session.macSpeakerLiveFileBindings??[])}catch{reason='resource_binding_changed'}
|
|
779
|
+
if(reason){session.macSpeakerLiveValidated=false;session.macSpeakerLiveStatus={reason,latencyMs:0,pendingWindows:0,completeEligibleSnapshot:false}}
|
|
780
|
+
}
|
|
781
|
+
const live = session.speakerIdentityPolicy === 'mac-native-v1' && session.speakerGeneration
|
|
782
|
+
? (session.macSpeakerLiveValidated?certified:undefined)??macSpeakerLiveOriginalOnly({captureId:sessionId,generation:session.speakerGeneration,chunksIndexed:chunksIndexed as unknown as {i:number;c:Record<string,unknown>}[],state:session.macSpeakerLive,revision:session.speakerRevision??0,previousDigest:session.speakerProjectionSHA256}) : null
|
|
783
|
+
const projectionWithdrawn=!!session.macSpeakerLive?.projections.length&&!live?.macSpeakerLive?.projections.length
|
|
784
|
+
const liveStatus=live?.macSpeakerLive?.withheldReason?{reason:live.macSpeakerLive.withheldReason,latencyMs:0,pendingWindows:0,completeEligibleSnapshot:false}
|
|
785
|
+
:projectionWithdrawn&&session.macSpeakerLiveValidated?{reason:'projection_binding_changed',latencyMs:0,pendingWindows:0,completeEligibleSnapshot:false}
|
|
786
|
+
:!live?.macSpeakerLive?{reason:!macSpeakerLiveEnabled()?'feature_disabled':session.macSpeakerLiveStatus?.reason??'awaiting_qualified_evidence',latencyMs:session.macSpeakerLiveStatus?.latencyMs??0,pendingWindows:session.macSpeakerLiveStatus?.pendingWindows??0,completeEligibleSnapshot:false}
|
|
787
|
+
:session.macSpeakerLiveStatus??{reason:'awaiting_qualified_evidence',latencyMs:0,pendingWindows:0,completeEligibleSnapshot:false}
|
|
718
788
|
const data = JSON.stringify({
|
|
719
789
|
sessionId,
|
|
790
|
+
...(live ? {speakerIdentityPolicy:live.speakerIdentityPolicy,sourceType:live.sourceType,speakerGeneration:live.speakerGeneration,speakerRevision:live.speakerRevision,speakerProjectionSHA256:live.speakerProjectionSHA256,speakerProjectedChunksIndexed:live.speakerProjectedChunksIndexed,...(live.macSpeakerLive?{macSpeakerLive:live.macSpeakerLive}:{}),...('macSpeakerLiveRetainedState'in live?{macSpeakerLiveRetainedState:live.macSpeakerLiveRetainedState}:{}),...(('macSpeakerLiveQuarantineSHA256'in live?live.macSpeakerLiveQuarantineSHA256:undefined)?{macSpeakerLiveQuarantineSHA256:('macSpeakerLiveQuarantineSHA256'in live?live.macSpeakerLiveQuarantineSHA256:undefined)}:{}),...(session.macSpeakerLiveQuarantineSHA256?{macSpeakerLiveQuarantineSHA256:session.macSpeakerLiveQuarantineSHA256}:{}),macSpeakerLiveStatus:liveStatus} : {}),
|
|
720
791
|
...(PHONE_CAPTURE_ID.test(sessionId) ? { captureSource: requirePhoneRecording(sessionId), sourceType: PHONE_SOURCE_TYPE, speakerPolicy: 'anonymous' } : {}),
|
|
721
792
|
startTime: session.startTime,
|
|
722
793
|
lastActivityAt: session.lastActivityAt,
|
|
@@ -736,7 +807,11 @@ function persistSessionRequired(sessionId: string): void {
|
|
|
736
807
|
...(session.macWindowVoices !== undefined ? { macWindowVoices: session.macWindowVoices } : {}),
|
|
737
808
|
})
|
|
738
809
|
try {
|
|
810
|
+
if(certified&&session.macSpeakerLiveValidated){try{assertMacSpeakerLivePublicationBindings(session.macSpeakerLiveFileBindings??[])}catch{session.macSpeakerLiveValidated=false;session.macSpeakerLiveStatus={reason:'resource_binding_changed',latencyMs:0,pendingWindows:0,completeEligibleSnapshot:false};return persistSessionRequired(sessionId)}}
|
|
739
811
|
durableAtomicWriteFileSync(filePath, data, { mode: 0o600 })
|
|
812
|
+
if(live){session.macSpeakerLiveStatus=liveStatus;if(projectionWithdrawn)session.macSpeakerLiveValidated=false;session.macSpeakerLive=live.macSpeakerLive??('macSpeakerLiveRetainedState'in live?live.macSpeakerLiveRetainedState as MacSpeakerLiveState:undefined);session.speakerRevision=live.speakerRevision;session.speakerProjectionSHA256=live.speakerProjectionSHA256
|
|
813
|
+
if(('macSpeakerLiveQuarantineSHA256'in live?live.macSpeakerLiveQuarantineSHA256:undefined))session.macSpeakerLiveQuarantineSHA256=('macSpeakerLiveQuarantineSHA256'in live?live.macSpeakerLiveQuarantineSHA256:undefined)
|
|
814
|
+
if(live.invalid)session.macSpeakerLiveValidated=false}
|
|
740
815
|
} catch (error) {
|
|
741
816
|
console.error(`[transcribe-stream] Durable session write failed for ${sessionId}: ${errMsg(error)}`)
|
|
742
817
|
throw makeHttpError(503, 'meeting session persistence unavailable', 'session_persistence_failed')
|
|
@@ -918,6 +993,12 @@ function recoverSessions(): void {
|
|
|
918
993
|
for (const key of Object.keys(emptyCompletions)) insertSortedUnique(asrCompletedIndices, Number(key))
|
|
919
994
|
const session: TranscriptSession = {
|
|
920
995
|
chunks,
|
|
996
|
+
...(MAC_CAPTURE_ID.test(data.sessionId)&&data.speakerIdentityPolicy==='mac-native-v1'?{
|
|
997
|
+
speakerIdentityPolicy:'mac-native-v1' as const,speakerGeneration:typeof data.speakerGeneration==='string'&&data.speakerGeneration.length>0&&data.speakerGeneration.length<=128?data.speakerGeneration:randomUUID(),
|
|
998
|
+
speakerRevision:typeof data.speakerRevision==='number'?data.speakerRevision:Number.NaN,
|
|
999
|
+
speakerProjectionSHA256:data.speakerProjectionSHA256,macSpeakerLive:data.macSpeakerLiveRetainedState??data.macSpeakerLive,macSpeakerLiveValidated:false,
|
|
1000
|
+
macSpeakerLiveStatus:{reason:'recovery_revalidation_pending',latencyMs:0,pendingWindows:0,completeEligibleSnapshot:false},
|
|
1001
|
+
}:{}),
|
|
921
1002
|
startTime: data.startTime,
|
|
922
1003
|
title: data.title || '',
|
|
923
1004
|
lastActivityAt,
|
|
@@ -957,6 +1038,20 @@ function recoverSessions(): void {
|
|
|
957
1038
|
}
|
|
958
1039
|
sessions.set(data.sessionId, session)
|
|
959
1040
|
recoveredIds.add(data.sessionId)
|
|
1041
|
+
if(session.speakerIdentityPolicy==='mac-native-v1'){
|
|
1042
|
+
// Clear previously exposed claims durably before asynchronous model,
|
|
1043
|
+
// profile, ledger and every retained raw binding are revalidated.
|
|
1044
|
+
try{persistSessionRequired(data.sessionId)}catch(error){
|
|
1045
|
+
sessions.delete(data.sessionId);recoveredIds.delete(data.sessionId);rememberDeletedSession(data.sessionId)
|
|
1046
|
+
// Preserve evidence outside the directory read as active claims.
|
|
1047
|
+
// Do not adopt a capture whose mandatory withdrawal was not durable.
|
|
1048
|
+
ensurePrivateDirectory(STALE_ACTIVE_SESSION_DIR)
|
|
1049
|
+
renameSync(resolve(CHUNK_PERSIST_DIR,file),resolve(STALE_ACTIVE_SESSION_DIR,`${data.sessionId}.speaker-revalidation-${Date.now()}.json`))
|
|
1050
|
+
console.error(`[session-recovery] Speaker revalidation clear failed; capture quarantined: ${data.sessionId}`)
|
|
1051
|
+
continue
|
|
1052
|
+
}
|
|
1053
|
+
if(macSpeakerLiveEnabled())setImmediate(()=>macSpeakerLiveScheduler.schedule(data.sessionId))
|
|
1054
|
+
}
|
|
960
1055
|
if (cleaned > 0) {
|
|
961
1056
|
console.log(`[session-recovery] Cleaned inline hallucinations from ${cleaned} chunks`)
|
|
962
1057
|
persistSessionBestEffort(data.sessionId) // re-persist cleaned data to disk
|
|
@@ -1269,30 +1364,7 @@ setInterval(() => {
|
|
|
1269
1364
|
try {
|
|
1270
1365
|
const batchBusy = ((maintenanceLifecycle.snapshot().activeByKind as Record<string, number>)
|
|
1271
1366
|
.meeting_batch_finalization ?? 0) > 0
|
|
1272
|
-
|
|
1273
|
-
const dirPath = resolve(PENDING_BATCH_DIR, dir)
|
|
1274
|
-
try {
|
|
1275
|
-
const files = readdirSync(dirPath)
|
|
1276
|
-
if (files.length === 0) { rmSync(dirPath, { recursive: true, force: true }); continue }
|
|
1277
|
-
// Prefer the marker file mtime over first chunk mtime
|
|
1278
|
-
const markerPath = resolve(dirPath, '_batch_pending.marker')
|
|
1279
|
-
let ageSource: number
|
|
1280
|
-
if (existsSync(markerPath)) {
|
|
1281
|
-
ageSource = statSync(markerPath).mtimeMs
|
|
1282
|
-
} else {
|
|
1283
|
-
// Fallback for pre-v5.4.3 dirs: use directory ctime (changes on rename)
|
|
1284
|
-
ageSource = statSync(dirPath).ctimeMs
|
|
1285
|
-
}
|
|
1286
|
-
if (Date.now() - ageSource > 12 * 60 * 60 * 1000) {
|
|
1287
|
-
if (batchBusy) {
|
|
1288
|
-
console.warn(`[cleanup] Retaining stale pending-batch while batch lease held: ${dir}`)
|
|
1289
|
-
continue
|
|
1290
|
-
}
|
|
1291
|
-
rmSync(dirPath, { recursive: true, force: true })
|
|
1292
|
-
console.log(`[cleanup] Purged stale pending-batch: ${dir}`)
|
|
1293
|
-
}
|
|
1294
|
-
} catch {}
|
|
1295
|
-
}
|
|
1367
|
+
sweepStalePendingBatch(PENDING_BATCH_DIR, { now: Date.now(), batchBusy })
|
|
1296
1368
|
} catch {}
|
|
1297
1369
|
|
|
1298
1370
|
// Meeting audio for review: 7-day window, then an 8 GB budget as a backstop.
|
|
@@ -1385,13 +1457,19 @@ setInterval(() => {
|
|
|
1385
1457
|
}, 60_000)
|
|
1386
1458
|
|
|
1387
1459
|
/** Get or create a transcript session */
|
|
1388
|
-
export function getSession(sessionId: string): TranscriptSession {
|
|
1460
|
+
export function getSession(sessionId: string, admission: {newMacRegistry?:boolean} = {}): TranscriptSession {
|
|
1389
1461
|
const phone = sessionId.startsWith('phone_') ? requirePhoneRecording(sessionId) : null
|
|
1390
1462
|
let session = sessions.get(sessionId)
|
|
1391
1463
|
if (!session) {
|
|
1464
|
+
// The durable registry proves this capture was already admitted. A missing
|
|
1465
|
+
// or unreadable active snapshot is not permission to reset its generation,
|
|
1466
|
+
// acoustic evidence or Voice namespace. Only the initial registry creator
|
|
1467
|
+
// may create a new Mac session; replayed starts must recover existing state.
|
|
1468
|
+
if(MAC_CAPTURE_ID.test(sessionId)&&readMacRecording(sessionId)&&!admission.newMacRegistry)throw macError(409,'mac_speaker_state_missing','Capture state is unavailable; preserve this recording and start a new capture')
|
|
1392
1469
|
const now = Date.now()
|
|
1393
1470
|
session = {
|
|
1394
1471
|
chunks: [],
|
|
1472
|
+
...(MAC_CAPTURE_ID.test(sessionId)&&macSpeakerLiveEnabled()?{speakerIdentityPolicy:'mac-native-v1' as const,speakerGeneration:randomUUID(),speakerRevision:0,macSpeakerLiveValidated:false}:{}),
|
|
1395
1473
|
startTime: phone?.startTime ?? now,
|
|
1396
1474
|
lastActivityAt: now,
|
|
1397
1475
|
title: '',
|
|
@@ -2478,6 +2556,7 @@ async function processStreamChunk(opts: {
|
|
|
2478
2556
|
const macWindowWithheld = macWindowOn ? macWindowWithheldReason(session.macWindowVoices, session.chunks) : null
|
|
2479
2557
|
if (macWindowWithheld) noteMacWindowWithheld(sessionId, macWindowWithheld)
|
|
2480
2558
|
const macWindowState = macWindowOn ? macWindowStateFor(session.macWindowVoices, session.chunks) : null
|
|
2559
|
+
if(macWindowState&&session.speakerIdentityPolicy==='mac-native-v1')macWindowState.voices=Math.max(macWindowState.voices,session.macSpeakerLive?.voiceHighWater??0)
|
|
2481
2560
|
const macWindow = macWindowState
|
|
2482
2561
|
? startMacWindowDiarize({
|
|
2483
2562
|
sessionId,
|
|
@@ -2510,7 +2589,8 @@ async function processStreamChunk(opts: {
|
|
|
2510
2589
|
const whisperContext = recentWhisperContext(sessionId, session)
|
|
2511
2590
|
console.log(`[perf] whisperContext: ${whisperContext.length}b | session.chunks: ${session.chunks.length}`)
|
|
2512
2591
|
|
|
2513
|
-
const
|
|
2592
|
+
const nativeIdentity = session.speakerIdentityPolicy === 'mac-native-v1'
|
|
2593
|
+
const speakerPromise = Promise.resolve(nativeIdentity ? {speaker:'Ext',similarity:0} : identifyChunkSpeaker(audioBuffer, sessionId, chunkIndex, clientSpeaker, capturePolicy))
|
|
2514
2594
|
|
|
2515
2595
|
let rawText = ''
|
|
2516
2596
|
let words: WhisperWord[] | undefined
|
|
@@ -2603,7 +2683,7 @@ async function processStreamChunk(opts: {
|
|
|
2603
2683
|
owner: ownerLabelOrNull(),
|
|
2604
2684
|
...(macWindow ? {
|
|
2605
2685
|
anonymousChannel: (channel: number) => macWindow.result()?.labelOf(channel) ?? null,
|
|
2606
|
-
} : MAC_CAPTURE_ID.test(sessionId) && macLiveVoicesMode() === 'anchors' ? {
|
|
2686
|
+
} : !nativeIdentity && MAC_CAPTURE_ID.test(sessionId) && macLiveVoicesMode() === 'anchors' ? {
|
|
2607
2687
|
anonymousVoice: (wav: Buffer) => {
|
|
2608
2688
|
// Fail closed after an upgrade/recovery lacking acoustic anchors:
|
|
2609
2689
|
// never reuse Voice 1 for an unrelated person from a later chunk.
|
|
@@ -2614,7 +2694,7 @@ async function processStreamChunk(opts: {
|
|
|
2614
2694
|
} : {}),
|
|
2615
2695
|
identify: wav => {
|
|
2616
2696
|
const policy = capturePolicy
|
|
2617
|
-
if (policy === 'anonymous') return { speaker: 'Ext', similarity: 0 }
|
|
2697
|
+
if (nativeIdentity || policy === 'anonymous') return { speaker: 'Ext', similarity: 0 }
|
|
2618
2698
|
// 6.90.0: a Mac track is never checked against the wearer first (the wearer
|
|
2619
2699
|
// is not in Mac playback) and is tagged so its calibration rows are separable.
|
|
2620
2700
|
const mac = MAC_CAPTURE_ID.test(sessionId)
|
|
@@ -2715,6 +2795,7 @@ async function processStreamChunk(opts: {
|
|
|
2715
2795
|
console.log(`[perf] persistSession: ${(performance.now() - tPersist).toFixed(1)}ms (${session.chunks.filter(c => c).length} chunks)`)
|
|
2716
2796
|
|
|
2717
2797
|
scheduleSessionProgressiveHq(sessionId)
|
|
2798
|
+
if(nativeIdentity)macSpeakerLiveScheduler.schedule(sessionId)
|
|
2718
2799
|
|
|
2719
2800
|
emitDisplay({
|
|
2720
2801
|
type: 'transcript_chunk',
|
|
@@ -2725,7 +2806,7 @@ async function processStreamChunk(opts: {
|
|
|
2725
2806
|
// bare `void` on a rejecting promise is an unhandled rejection, which Node
|
|
2726
2807
|
// throws on by default. All gates live inside the feed.
|
|
2727
2808
|
// The ASR await above lets a late chunk land after Stop or save; a closed session never feeds cues.
|
|
2728
|
-
if (!isSessionDeleted(sessionId)) void feedLiveCueTranscript(sessionId, trimmedText, Boolean(fallbackReason)).catch(() => {})
|
|
2809
|
+
if (!isSessionDeleted(sessionId)) void feedLiveCueTranscript(sessionId, trimmedText, Boolean(fallbackReason), [speaker, ...(chunk.segments ?? []).map(segment => segment.speaker)]).catch(() => {})
|
|
2729
2810
|
|
|
2730
2811
|
// 6.85.0: timing and the character count only. This line runs for every chunk, so the meeting
|
|
2731
2812
|
// text itself never goes to the server log (transcribe-stream-log-privacy.test.ts).
|
|
@@ -2884,7 +2965,7 @@ transcribeStreamRouter.post('/mac-recording/start', (req, res) => {
|
|
|
2884
2965
|
if (sessions.has(req.body.captureId) && !readMacRecording(req.body.captureId)) throw macError(409, 'mac_capture_conflict', 'Capture ID already exists')
|
|
2885
2966
|
lease = acquireRecordingChunkLease({ allowDuringDrain: sessions.has(req.body.captureId) })
|
|
2886
2967
|
const { recording, replayed } = registerMacRecording(req.body)
|
|
2887
|
-
const session = getSession(recording.sessionId)
|
|
2968
|
+
const session = getSession(recording.sessionId,{newMacRegistry:!replayed})
|
|
2888
2969
|
if (!session.chunks.some(Boolean)) session.startTime = recording.startTime
|
|
2889
2970
|
persistSessionRequired(recording.sessionId)
|
|
2890
2971
|
// Arming happens only after the recording is admitted, and its answer never fails the start (armMacLiveCues returns a
|
|
@@ -2920,7 +3001,7 @@ transcribeStreamRouter.post('/mac-recording/:captureId/chunks', async (req, res)
|
|
|
2920
3001
|
const prior = session.chunks[chunkIndex] ?? session.emptyCompletions?.[String(chunkIndex)]
|
|
2921
3002
|
if (prior?.audioSha256 && prior.audioSha256 !== sha256Hex(audio)) throw macError(409, 'mac_chunk_conflict', 'Chunk index already holds different audio')
|
|
2922
3003
|
res.json(await processStreamChunk({ sessionId: id, chunkIndex, clientSpeaker: 'Ext', audioBuffer: audio, clientElapsed: elapsed - duration, audioDurationMs: duration, startTimeOverride: recording.startTime }))
|
|
2923
|
-
} catch (error) { sendStreamError(res, error) } finally { if (locked) macChunkLocks.delete(id); lease?.release() }
|
|
3004
|
+
} catch (error) { sendStreamError(res, error) } finally { if (locked) {macChunkLocks.delete(id);if(sessions.get(id)?.speakerIdentityPolicy==='mac-native-v1')macSpeakerLiveScheduler.schedule(id)}; lease?.release() }
|
|
2924
3005
|
})
|
|
2925
3006
|
|
|
2926
3007
|
const phoneChunkLocks = new Set<string>()
|
package/server/routes/voice.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import express, { Router } from 'express'
|
|
4
4
|
import { errMsg } from '../lib/utils.js'
|
|
5
|
+
import { forgetMacVoiceNamingPerson } from '../lib/mac-voice-naming-batch.js'
|
|
5
6
|
import { readdirSync, readFileSync, unlinkSync, existsSync, rmdirSync, rmSync } from 'node:fs'
|
|
6
7
|
import { resolve } from 'node:path'
|
|
7
8
|
import { checkSpeakerName } from '../lib/speaker-name.js'
|
|
@@ -959,6 +960,8 @@ voiceRouter.post('/voice/delete-person', (req, res) => {
|
|
|
959
960
|
|
|
960
961
|
// 1. Voice profile + sherpa manager registration.
|
|
961
962
|
const profileResult = removeSpeakerProfile(target)
|
|
963
|
+
// A deleted person's voiceprints must not survive in a naming batch's Undo journal.
|
|
964
|
+
forgetMacVoiceNamingPerson(target)
|
|
962
965
|
|
|
963
966
|
// 2. Saved G2 training audio for this person.
|
|
964
967
|
let trainingAudioFiles = 0
|