@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.
Files changed (40) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +27 -0
  3. package/package.json +2 -1
  4. package/server/index.ts +6 -0
  5. package/server/lib/decision-provider.ts +4 -2
  6. package/server/lib/held-naming-batches.ts +8 -7
  7. package/server/lib/held-voice-groups.ts +5 -4
  8. package/server/lib/keyed-sessions.ts +21 -1
  9. package/server/lib/mac-speaker-machine-evidence.ts +111 -0
  10. package/server/lib/mac-speaker-profile-extractor.ts +41 -0
  11. package/server/lib/mac-speaker-profile-finalizer.ts +257 -0
  12. package/server/lib/mac-speaker-protection.ts +22 -0
  13. package/server/lib/mac-speaker-reconcile.ts +300 -0
  14. package/server/lib/mac-speaker-repair-projection.ts +301 -0
  15. package/server/lib/mac-speaker-repair-render.ts +105 -0
  16. package/server/lib/mac-speaker-windows.ts +239 -0
  17. package/server/lib/mac-voice-assignment.ts +2 -1
  18. package/server/lib/meeting-corrections.ts +2 -1
  19. package/server/lib/meeting-relabel-enrolment.ts +2 -1
  20. package/server/lib/meeting-relabel.ts +17 -10
  21. package/server/lib/meeting-scribe-content.ts +8 -1
  22. package/server/lib/meeting-speaker-review.ts +46 -7
  23. package/server/lib/nemotron-final.ts +64 -14
  24. package/server/lib/session-find-ask.ts +281 -0
  25. package/server/lib/session-find-collect.ts +715 -0
  26. package/server/lib/session-find-redact.ts +254 -0
  27. package/server/lib/session-find.ts +345 -0
  28. package/server/lib/speaker-calibration-log.ts +3 -2
  29. package/server/lib/speaker-embeddings.ts +46 -35
  30. package/server/lib/speaker-name-nfc.ts +37 -0
  31. package/server/lib/speaker-rename-fanout.ts +7 -6
  32. package/server/lib/speaker-trainer.ts +2 -1
  33. package/server/lib/voice-directory.ts +7 -2
  34. package/server/lib/voice-profile-store.ts +24 -14
  35. package/server/routes/decision-provider.ts +4 -1
  36. package/server/routes/jev.ts +5 -1
  37. package/server/routes/meeting.ts +43 -15
  38. package/server/routes/session-find.ts +73 -0
  39. package/server/routes/voice.ts +25 -18
  40. package/server/scripts/mac-speaker-repair.ts +324 -0
package/CHANGELOG.md CHANGED
@@ -1,3 +1,29 @@
1
+ ## 6.94.3 - Search sessions by meaning, and accented speaker names
2
+
3
+ 2026-11-01. Pairs with COS Control 0.5.363. Control 0.5.361 and older keep working and never call the new route.
4
+
5
+ **New, and off until you say yes in Control.**
6
+
7
+ - `POST /api/agent-sessions/find` asks your decision model (Jev by default, SubQ if chosen) which recent sessions a few typed words refer to, and returns up to 5 rows in order of likelihood. It answers only for the provider you consented to in Control, and refuses before reading any session otherwise.
8
+ - Each search sends the typed words, plus the title and first 150 characters of the first prompt from up to 254 recent sessions, then up to 508 if the first pass finds nothing strong. Emails, URLs, links to an IP address or localhost, home-folder paths (not every file path) and key-shaped text are removed first. Names and other details are not. SubQ keeps what it is sent. Archived, keyed and scheduled sessions are left out.
9
+ - Its own daily caps (3M tokens per provider), breaker and `COS_SESSION_FIND=0` switch. `GET /api/decision-provider` adds `capabilities.sessionFind`.
10
+
11
+ **Default-on, no setting.**
12
+
13
+ - Accented speaker names now match in either Unicode form. "Yes, this is Brigitta Pólya" failed with "No chunk in this meeting is labelled" because Control's helper hands names over decomposed. Meeting and voice routes read names as NFC, compare both sides, and store new names as NFC. Merge, delete-person, calibration, the rename fan-out and the embeddings manager match a stored name in either form, and a meeting record id matches in either form too (a decomposed id from Control's helper opens the same record). Merge refuses if any stored copy of a name is below the similarity floor. Names already saved are not rewritten.
14
+
15
+ No other route changes. The session list and search are unchanged.
16
+
17
+ ## 6.94.2 - Scoped profile matches for saved Mac recordings
18
+
19
+ 2026-10-28. Compatible with COS Control 0.5.352; no Control or glasses package change required.
20
+
21
+ - Saved Mac recordings can match existing profiles using two independent, coherent long audio windows. Names apply only to supported spans at the unchanged .88 identity floor and .05 separation margin. This does not enroll or train profiles.
22
+ - Durable repair evidence distinguishes machine matches from human confirmations and protects words, timestamps, existing corrections, retained audio and interrupted-save recovery. The review no longer treats one scored occurrence as proof for every repaired occurrence.
23
+ - Existing named spans no longer prevent anonymous refinement of the remainder of a strict Mac recording. Historical recordings require explicit validated repair; upgrading does not silently rewrite them.
24
+ - Known limitation: live numbering still loses continuity across long gaps, and some anonymous groups contain mixed voices. This release does not claim full-call deduplication, an attendee count, or recognition of every speaker.
25
+ - Qualification: 9,704 tests passed, 14 skipped, zero failed; typecheck passed. Isolated natural-recording runs preserved all speech and timestamps, accepted exact supported spans, and withheld incoherent or insufficient matches.
26
+
1
27
  ## 6.94.1 - Mac filing waits for a pipeline that can title a Mac recording
2
28
 
3
29
  2026-10-27. Pairs with COS operations `sync_meetings.py` on branch `claude/mac-library-cos` (the `mac_title: 1` capability). Works with COS Control 0.5.351; Control needs no change.
package/README.md CHANGED
@@ -699,6 +699,33 @@ flag a Mac rename is text only. `GET /api/health` reports `capabilities.macLiveV
699
699
  The saved meeting is relabeled by the whole-meeting pass. Live numbers are kept;
700
700
  voices the live path did not prove are numbered above them at save.
701
701
 
702
+ ### Session find by meaning (6.94.3)
703
+
704
+ `POST /api/agent-sessions/find` backs ⌘K in COS Control 0.5.363. It is off until the person says yes for one provider in Control. See `docs/decision-providers.md` for what leaves the Mac, the caps and `COS_SESSION_FIND=0`.
705
+
706
+ ### Saved Mac profile reconciliation (6.94.2)
707
+
708
+ After transcription and diarization, eligible Mac recordings receive a bounded
709
+ local voice-profile pass. It checks up to twelve 28–30 second windows across the
710
+ call against the existing profile cohort. Two independent windows must agree;
711
+ each needs profile similarity of at least .88, a .05 margin over alternatives,
712
+ and coherent full/half-window voice evidence. Only the supported audio spans
713
+ receive a name. Other occurrences of the same live Voice number remain unchanged.
714
+
715
+ This pass does not train profiles, infer identities from attendees, or change the
716
+ transcript words and timestamps. Existing human corrections are protected. A
717
+ durable journal preserves recovery across interrupted writes; retained audio is
718
+ not released over an unfinished repair. Unsupported or ambiguous evidence leaves
719
+ the existing labels in place. The pass uses one bounded CPU worker and can skip
720
+ when its budget or prerequisites are unavailable.
721
+
722
+ The speaker review distinguishes qualified machine matches from human
723
+ confirmations. A pre-existing named span no longer prevents anonymous refinement
724
+ of the rest of a strict Mac recording. Live voice numbering still has the window
725
+ limits above: this release does not promise meeting-wide speaker deduplication
726
+ or an attendee count. Older recordings require an explicit, validated repair;
727
+ they are not silently rewritten on upgrade.
728
+
702
729
  ### iPhone microphone meetings (6.93.0, off by default)
703
730
 
704
731
  With `COS_IPHONE_MICROPHONE_MEETINGS=1`, HQ set to Parakeet Ultra and Parakeet
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.94.1",
3
+ "version": "6.94.3",
4
4
  "description": "COS Glasses: self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, Cursor Agent CLI, or local Ollama",
5
5
  "type": "module",
6
6
  "bin": {
@@ -52,6 +52,7 @@
52
52
  "!server/scripts/work-intake-mutation-gate.mjs",
53
53
  "!server/scripts/work-progress-mutation-gate.mjs",
54
54
  "!server/scripts/work-search-mutation-gate.mjs",
55
+ "!server/scripts/session-find-redact-mutation-gate.mjs",
55
56
  "!server/scripts/fact-check-mutation-gate.mjs",
56
57
  "!server/scripts/stale-session-mutation-gate.mjs",
57
58
  "!server/scripts/autonomous-save-mutation-gate.mjs",
package/server/index.ts CHANGED
@@ -159,6 +159,8 @@ import { createWorkHandoffRequestsRouter } from './routes/work-handoff-requests.
159
159
  import { createSubqKeyRouter } from './routes/subq-key.js'
160
160
  import { createDecisionProviderRouter } from './routes/decision-provider.js'
161
161
  import { createJevRouter } from './routes/jev.js'
162
+ import { createSessionFindRouter } from './routes/session-find.js'
163
+ import { isSessionFindPost } from './lib/session-find.js'
162
164
  import { createWorkChatRouter } from './routes/work-chat.js'
163
165
  import { createWorkOrchestratorRouter } from './routes/work-orchestrator.js'
164
166
  import { createWorkLensRoundsRouter } from './routes/work-lens-rounds.js'
@@ -394,6 +396,8 @@ app.use('/api', (req, res, next) => {
394
396
  // 6.65.0: a POST that only reads (Work search: a board read and one Jev question) is not a mutation. See
395
397
  // lib/work-search.ts isReadOnlyApiPost for why holding the lease would hurt and why skipping it is safe.
396
398
  if (isReadOnlyApiPost(req.method, req.path)) return next()
399
+ // Session find (Control 0.5.363) reads the session store and asks a model; it writes only its own token ledger. See lib/session-find.ts.
400
+ if (isSessionFindPost(req.method, req.path)) return next()
397
401
  // V2 video original chunks are bounded to the advertised session size (1 MiB
398
402
  // on new sessions, 256 KiB on leftover drafts) and commit through the upload
399
403
  // registry's own generation lock. Holding the global mutation lease while
@@ -856,6 +860,8 @@ app.use('/api', createJevRouter(workReviewRuntime ? { review: async id => workRe
856
860
  app.use('/api', createDecisionProviderRouter())
857
861
  app.use('/api', createSubqKeyRouter())
858
862
  app.use('/api', createWorkSearchRouter())
863
+ // Find a session by meaning (Control 0.5.363): consent per provider, its own ledgers and breakers (lib/session-find.ts).
864
+ app.use('/api', createSessionFindRouter())
859
865
  app.use('/api', createWorkChatRouter())
860
866
  // 6.83.0 Work orchestrator: Control's Work snapshot, the planner input and plan usage (docs/work-orchestrator.md).
861
867
  app.use('/api', createWorkOrchestratorRouter())
@@ -154,11 +154,13 @@ export function resetSubqDecisionClients(): void { configuredSubqClients.forEach
154
154
  /** A SubQ client built outside createDecisionClient (live cues, 6.76.2) still starts clean when a new key is saved. */
155
155
  export function trackSubqDecisionClient(client: SubqDecisionClient): SubqDecisionClient { configuredSubqClients.add(client); return client }
156
156
  /** Snapshot selection per workflow request. Separate clients and cache identities prevent stale cross-provider answers. */
157
- export function createDecisionClient(useCase: DecisionUseCase, jev: Pick<JevClient, 'ask'> = new JevClient(), env: NodeJS.ProcessEnv = process.env): DecisionClient {
157
+ export function createDecisionClient(useCase: DecisionUseCase, jev: Pick<JevClient, 'ask'> = new JevClient(), env: NodeJS.ProcessEnv = process.env,
158
+ /** Optional: a SubQ ledger file and daily cap of this feature's own. Without it SubQ uses `subq-<useCase>-usage.json` and COS_SUBQ_DAILY_TOKENS. */
159
+ subq: { usageFile?: string; cap?: () => number } = {}): DecisionClient {
158
160
  const clients: Partial<Record<DecisionProvider, DecisionClient>> = {}
159
161
  const snapshot = (): DecisionClient => {
160
162
  const selected = selectedDecisionProvider(useCase, env)
161
- if (!clients[selected]) clients[selected] = selected === 'subq' ? new SubqDecisionClient(fetch, () => new Date(), dataPath(`subq-${useCase}-usage.json`)) : {
163
+ if (!clients[selected]) clients[selected] = selected === 'subq' ? new SubqDecisionClient(fetch, () => new Date(), subq.usageFile ?? dataPath(`subq-${useCase}-usage.json`), undefined, subq.cap) : {
162
164
  provider: 'jev', async ask(state, questions, estimate, options) {
163
165
  if (options?.images?.length) throw new DecisionProviderError('decision_images_unsupported')
164
166
  return { ...await jev.ask(state, questions, estimate, options), provider: 'jev' }
@@ -1,4 +1,5 @@
1
1
  /** Durable held naming: server-stored preview, per-copy receipts, explicit recovery. */
2
+ import { nfc, sameSpeakerName } from './speaker-name-nfc.js'
2
3
  import { createHash, randomUUID } from 'node:crypto'
3
4
  import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, unlinkSync } from 'node:fs'
4
5
  import { dirname, join, resolve } from 'node:path'
@@ -27,12 +28,12 @@ export function resolveStoredName(name: string): {name:string;owner:boolean} {
27
28
  if (stored.length > 1) throw new HeldGroupError(400,'ambiguous_name','Multiple stored voices have that spelling. Rename the duplicate profiles first.',{names:stored.map(p=>p.name)})
28
29
  const owner = getOwnerSpeakerLabel()
29
30
  const isOwner = foldName(owner) === foldName(name.trim()) || (stored[0] && foldName(stored[0].name) === foldName(owner))
30
- return {name:isOwner ? owner : stored[0]?.name ?? name.trim(),owner:!!isOwner}
31
+ return {name:isOwner ? owner : stored[0]?.name ?? nfc(name.trim()),owner:!!isOwner}
31
32
  }
32
33
  function voiceVersion(name: string) {
33
34
  const path = dataPath('voice-profiles.json')
34
35
  const profiles = readVoiceProfiles().profiles
35
- return {storeMtime:existsSync(path)?statSync(path).mtimeMs:0,profileCount:profiles.length,targetSamples:profiles.find(p=>p.name===name)?.embeddings.length ?? 0}
36
+ return {storeMtime:existsSync(path)?statSync(path).mtimeMs:0,profileCount:profiles.length,targetSamples:profiles.find(p=>sameSpeakerName(p.name,name))?.embeddings.length ?? 0}
36
37
  }
37
38
  function evidenceVersion(refs:HeldSampleRef[]) {
38
39
  return hash([...new Set(refs.map(r=>r.sessionId))].sort().map(sessionId=>({sessionId,rows:readChunkEmbeddings(sessionId).rows.map(r=>({i:r.i,embedding:Array.from(r.embedding)})),cache:existsSync(dataPath('held-voice-embeddings',`${sessionId}.json`))?readFileSync(dataPath('held-voice-embeddings',`${sessionId}.json`),'utf8'):''})))
@@ -472,7 +473,7 @@ export async function undoHeldNaming(batchId:string,confirm:boolean){
472
473
  * enrolment/audio deletion off. Dry run neither records a preview nor writes a ledger. */
473
474
  export async function renameMeetingSpeaker(sessionId:string,from:string,to:string,options:{apply?:boolean;operationsDir?:string;expectedSidecarPath?:string}={}) : Promise<{copies:Array<{sidecarPath:string;meetingPath:string;labels:number;transcript:number;attendees:number;unresolvedTurns:number}>;batchId?:string;error?:string}> {
474
475
  if(options.operationsDir && resolveCosOperationsDir()!==realpathSync(options.operationsDir)) throw new Error('Operations root differs from the requested fan-out root')
475
- if(from===to||!from.trim()||!to.trim())return{copies:[]}
476
+ if(sameSpeakerName(from,to)||!from.trim()||!to.trim())return{copies:[]}
476
477
  if(options.apply&&mutationBusy)throw new Error('Another naming is running; retry')
477
478
  let lock:Awaited<ReturnType<typeof acquireMeetingSyncLock>>|undefined
478
479
  let batch:BatchRecord|undefined
@@ -486,22 +487,22 @@ export async function renameMeetingSpeaker(sessionId:string,from:string,to:strin
486
487
  if(options.expectedSidecarPath && (!operations || realpathSync(operations.sidecarPath)!==realpathSync(options.expectedSidecarPath)))throw new Error('Session resolves to a different sidecar; duplicate or archived copy')
487
488
  const maps=copies.map(c=>validatedRawMap(c.doc))
488
489
  if(maps.some(map=>hash(map)!==hash(maps[0])))throw new Error('Meeting copies disagree on raw mapping')
489
- const positions:number[]=copies[0].doc.chunks.flatMap((c:any,i:number)=>c.speaker===from?[i]:[])
490
+ const positions:number[]=copies[0].doc.chunks.flatMap((c:any,i:number)=>sameSpeakerName(c.speaker,from)?[i]:[])
490
491
  // Repair historical chunks-only renames too: HQ words still carry `from`
491
492
  // even when the compacted chunk already says `to` (the 6.27.10 trap).
492
493
  for(const c of copies)for(const segment of c.doc.batchSegments??[])for(const word of segment.speakerWords??[]){
493
- if(word.speaker!==from)continue
494
+ if(!sameSpeakerName(word.speaker,from))continue
494
495
  const absolute=Number(segment.startElapsed??0)+Number(word.start)*1000
495
496
  let position=-1,distance=Infinity
496
497
  const raw=validatedRawMap(c.doc)
497
498
  raw.forEach((index,i)=>{if(index<segment.startChunkIdx||index>segment.endChunkIdx)return;const d=Math.abs(Number(c.doc.chunks[i].elapsed)-absolute);if(d<distance){distance=d;position=i}})
498
499
  if(distance>3500||position<0)throw new Error('Old HQ speaker has no safe raw chunk mapping')
499
- if(![from,to].includes(c.doc.chunks[position].speaker))throw new Error('HQ speaker conflicts with an unrelated named chunk')
500
+ if(![from,to].some(n=>sameSpeakerName(n,c.doc.chunks[position].speaker)))throw new Error('HQ speaker conflicts with an unrelated named chunk')
500
501
  if(!positions.includes(position))positions.push(position)
501
502
  }
502
503
  positions.sort((a,b)=>a-b)
503
504
  if(!positions.length)return{copies:[]}
504
- if(copies.some(c=>positions.some((i:number)=>![from,to].includes(c.doc.chunks[i]?.speaker))))throw new Error('Meeting copies disagree on prior speaker labels')
505
+ if(copies.some(c=>positions.some((i:number)=>![from,to].some(n=>sameSpeakerName(n,c.doc.chunks[i]?.speaker)))))throw new Error('Meeting copies disagree on prior speaker labels')
505
506
  const batchId=randomUUID()
506
507
  const edits=copies.map(c=>relabelSpeakerPositions(c.doc,c.blended?'':c.markdown,positions,to,batchId))
507
508
  const results=copies.map((c,i)=>({sidecarPath:c.sidecarPath,meetingPath:c.meetingPath,labels:positions.length,transcript:edits[i].transcript,attendees:edits[i].attendees,unresolvedTurns:edits[i].unresolvedTurns}))
@@ -62,6 +62,7 @@ import {
62
62
  greedyDiversitySelect,
63
63
  pairwiseSimilarityMatrix,
64
64
  } from './voice-enrolment-selection.js'
65
+ import { sameSpeakerName } from './speaker-name-nfc.js'
65
66
 
66
67
  /** Where decoded-on-demand vectors are kept. One JSON file per held session. */
67
68
  export const HELD_EMBEDDING_CACHE_DIR = 'held-voice-embeddings'
@@ -432,12 +433,12 @@ export function suggestProfile(embedding: Float32Array, profiles: VoiceProfile[]
432
433
  if (!best || candidate.score > best.score) best = candidate
433
434
  }
434
435
  if (!best || best.anchor === null) return null
435
- const winner = profiles.find(p => p.name === best!.name)!
436
+ const winner = profiles.find(p => sameSpeakerName(p.name, best!.name))!
436
437
  // Near-duplicate profile pairs represent an existing identity ambiguity, not
437
438
  // independent competing acoustic evidence. Do not let that pair erase the
438
439
  // margin against genuinely different voices. Never merge or rename here.
439
440
  const runnerUp = candidates.filter(c => c.name !== best!.name).filter(c => {
440
- const other = profiles.find(p => p.name === c.name)!
441
+ const other = profiles.find(p => sameSpeakerName(p.name, c.name))!
441
442
  return profileSimilarity(winner, other) < HELD_NEAR_PROFILE_SIMILARITY
442
443
  }).reduce((score, c) => Math.max(score, c.score), 0)
443
444
  const margin = best.score - runnerUp
@@ -754,7 +755,7 @@ export function enrollHeldGroup(name: string, refs: HeldSampleRef[], opts: { dry
754
755
  const core = expand(coreReps)
755
756
  const leftBehind = expand(reps.filter(i => !coreSet.has(i))).map(s => ({ sessionId: s.sessionId, chunkIndex: s.chunkIndex })).sort(compareRefs)
756
757
 
757
- const existing = readVoiceProfiles().profiles.find(p => p.name === name)
758
+ const existing = readVoiceProfiles().profiles.find(p => sameSpeakerName(p.name, name))
758
759
  // A primitive snapshot, taken BEFORE the loop: the store hands back its live
759
760
  // `embeddings` array by reference, and enrolment appends to that same array
760
761
  // until the first write invalidates the cache. Reading `.length` afterwards
@@ -826,7 +827,7 @@ export function projectHeldEnrollment(name: string, refs: HeldSampleRef[]): {
826
827
  const profiles = readVoiceProfiles().profiles.map(profile => ({
827
828
  ...profile, embeddings: profile.embeddings.map(row => [...row]), sources: [...(profile.sources ?? [])],
828
829
  }))
829
- let target = profiles.find(profile => profile.name === name)
830
+ let target = profiles.find(profile => sameSpeakerName(profile.name, name))
830
831
  if (!target) { target = { name, embeddings: [], sources: [] }; profiles.push(target) }
831
832
  const selectedMembers: HeldSampleRef[] = []
832
833
  for (const embedding of selected) {
@@ -10,7 +10,7 @@
10
10
  // every check below is one failed stat. Reading never creates. Remove all keys (plan 12) keeps
11
11
  // this file on purpose: a keyed transcript stays refused after the keys are gone. A manual
12
12
  // `claude --resume` in a terminal is documented, not blocked.
13
- import { statSync } from 'node:fs'
13
+ import { existsSync, lstatSync, readFileSync, statSync } from 'node:fs'
14
14
  import { dataPath } from './data-dir.js'
15
15
  import { readPrivateJsonFile, writePrivateJsonFile } from './key-store.js'
16
16
  import { registerKeyLanesStatusProvider } from './key-lanes.js'
@@ -83,6 +83,26 @@ export function listKeyedSessions(): KeyedSessionRecord[] {
83
83
  return [...byId.values()]
84
84
  }
85
85
 
86
+ /**
87
+ * The same records, read so that a damaged file cannot pass for an empty one. listKeyedSessions reads a corrupt or unreadable
88
+ * file as "none", which is right for the places that refuse a keyed session (they fail open on a file nobody can read) and wrong for
89
+ * a place that must NOT send keyed text anywhere: a corrupt file sent keyed session text to a model in a probe. A missing file is
90
+ * none; a file that exists and is not the store's shape throws. Session find uses this; nothing else does.
91
+ */
92
+ export function listKeyedSessionsStrict(): KeyedSessionRecord[] {
93
+ const byId = new Map<string, KeyedSessionRecord>()
94
+ const file = keyedSessionsPath()
95
+ if (existsSync(file)) {
96
+ const st = lstatSync(file)
97
+ if (st.isSymbolicLink() || !st.isFile()) throw new Error('keyed sessions file is not a plain file')
98
+ const parsed = JSON.parse(readFileSync(file, 'utf8')) as Partial<KeyedSessionsFile> | null
99
+ if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.sessions) || !parsed.sessions.every(isRecord)) throw new Error('keyed sessions file is not readable')
100
+ for (const r of parsed.sessions) byId.set(r.id, r)
101
+ }
102
+ for (const r of memory.values()) if (!byId.has(r.id)) byId.set(r.id, r)
103
+ return [...byId.values()]
104
+ }
105
+
86
106
  /** True once anything was ever recorded. The cheap short-circuit every refusing site starts with. */
87
107
  export function anyKeyedSessions(): boolean {
88
108
  return memory.size > 0 || fileRecords().length > 0
@@ -0,0 +1,111 @@
1
+ /** Read-only bridge from a labels-only repair receipt to scoped machine claims.
2
+ * Capture similarities, human confirmations and profile training are untouched.
3
+ * Persisted bytes are trusted only after current speech/raw-map/receipt checks.
4
+ */
5
+ import {
6
+ hashMacSpeakerDocument, hashMacSpeakerMachineBinding, validateMacSpeakerTargetEvidence,
7
+ type MacSpeakerMachineEvidence, type MacSpeakerRepairPlan,
8
+ } from './mac-speaker-repair-projection.js'
9
+ import { checkSpeakerName } from './speaker-name.js'
10
+ type Doc = Record<string, any>
11
+ const SHA = /^[a-f0-9]{64}$/
12
+ const uint = (v: unknown): v is number => Number.isSafeInteger(v) && Number(v) >= 0
13
+ const equal = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b)
14
+ function refuse(reason: string): never { throw new Error(reason) }
15
+ export interface QualifiedMachinePosition {
16
+ label: string
17
+ rawChunkIndex: number
18
+ evidenceId: string
19
+ receiptHash: string
20
+ modelSHA256: string
21
+ profileSHA256: string
22
+ algorithmVersion: string
23
+ independentWindows: number
24
+ minimumSimilarity: number
25
+ minimumMargin: number
26
+ }
27
+ export interface MacMachineEvidenceRead {
28
+ status: 'absent' | 'qualified' | 'invalid'
29
+ reason?: string
30
+ /** Compacted positions, obtained by the strict raw join below. Never guess
31
+ * positions from raw indexes, text similarity or repeated phrases. */
32
+ positions: Map<number, QualifiedMachinePosition>
33
+ }
34
+ export function readMacSpeakerMachineEvidence(value: unknown): MacMachineEvidenceRead {
35
+ const positions = new Map<number, QualifiedMachinePosition>()
36
+ const doc = value as Doc | null
37
+ const evidence = doc?.speakerRepair?.machineEvidence as MacSpeakerMachineEvidence | undefined
38
+ if (!evidence) return { status: 'absent', positions }
39
+ try {
40
+ if (!doc || doc.sourceType !== 'mac_system_audio' || typeof doc.sessionId !== 'string' || !doc.sessionId.startsWith('mac_')
41
+ || doc.finalizationState !== 'complete' || doc.claimPending === true || doc.active === true || doc.isActive === true || doc.isMerged || doc.mergedFrom || doc.sourceSessionIds) refuse('source_not_closed_original')
42
+ // Deliberately global: any later human revision invalidates all machine
43
+ // claims until requalification. Real ledger confirmations still prevail.
44
+ if (evidence.version !== 1 || !uint(evidence.correctionRevision) || evidence.correctionRevision < 1 || doc.correctionRevision !== evidence.correctionRevision) refuse('revision_drift')
45
+ if (!SHA.test(evidence.documentBinding) || hashMacSpeakerMachineBinding(doc) !== evidence.documentBinding) refuse('speech_binding_mismatch')
46
+ const receipt = evidence.receipt
47
+ if (!receipt || receipt.version !== 1 || !SHA.test(receipt.receiptHash) || receipt.id !== doc.speakerRepair.id || receipt.evidenceHash !== doc.speakerRepair.evidenceHash
48
+ || !SHA.test(receipt.evidenceHash) || !Array.isArray(receipt.assignments) || !receipt.assignments.length || receipt.assignments.length > 65536
49
+ || !Array.isArray(receipt.patches) || receipt.patches.length > 1_000_000) refuse('receipt_invalid')
50
+ const { receiptHash, ...unsigned } = receipt
51
+ if (hashMacSpeakerDocument(unsigned) !== receiptHash) refuse('receipt_invalid')
52
+ if (!Array.isArray(doc.chunks) || !Array.isArray(doc.chunkEntries) || doc.chunks.length > 65536 || doc.chunkEntries.length > 65536) refuse('raw_map_ambiguous')
53
+ const byRaw = new Map<number, { position: number; chunk: Doc }>()
54
+ let position = 0, previous = -1
55
+ for (const entry of doc.chunkEntries) {
56
+ if (!entry || !uint(entry.chunkIndex) || entry.chunkIndex <= previous) refuse('raw_map_ambiguous')
57
+ previous = entry.chunkIndex
58
+ if (typeof entry.chunk?.text !== 'string' || !entry.chunk.text.trim()) continue
59
+ const chunk = doc.chunks[position]
60
+ if (!chunk || ['text', 'speaker', 'elapsed', 'audioDurationMs', 'audioSha256', 'segments', 'finalSegments', 'words'].some(key => !equal(chunk[key], entry.chunk[key]))) refuse('raw_map_ambiguous')
61
+ byRaw.set(entry.chunkIndex, { position, chunk }); position++
62
+ }
63
+ if (position !== doc.chunks.length) refuse('raw_map_ambiguous')
64
+ const source = structuredClone(doc)
65
+ // Reconstruct only the original label surfaces from the validated receipt.
66
+ // Lifecycle/prose/profile metadata are never inverse-applied by this reader.
67
+ const paths = new Set<string>()
68
+ for (const patch of [...receipt.patches].reverse()) {
69
+ if (!Array.isArray(patch.path) || !patch.path.length || patch.path.length > 8 || patch.path.some(k => !['string', 'number'].includes(typeof k) || ['__proto__', 'prototype', 'constructor'].includes(String(k)))) refuse('receipt_invalid')
70
+ const rootSpeakers = patch.path.length === 1 && patch.path[0] === 'speakers'
71
+ if (!rootSpeakers && (!['chunks', 'chunkEntries', 'batchSegments'].includes(String(patch.path[0])) || patch.path.at(-1) !== 'speaker')) refuse('receipt_invalid')
72
+ const key = JSON.stringify(patch.path)
73
+ if (paths.has(key)) refuse('receipt_invalid')
74
+ paths.add(key)
75
+ let row = source
76
+ for (const part of patch.path.slice(0, -1)) {
77
+ if (!row || typeof row !== 'object' || !Object.hasOwn(row, part)) refuse('receipt_invalid')
78
+ row = row[part]
79
+ }
80
+ const last = patch.path.at(-1)!
81
+ if (!row || !equal(row[last], patch.after)) refuse('receipt_label_drift')
82
+ if (patch.before === undefined) delete row[last]
83
+ else row[last] = structuredClone(patch.before)
84
+ }
85
+ if (!evidence.rawAudioSampleCounts || typeof evidence.rawAudioSampleCounts !== 'object') refuse('receipt_invalid')
86
+ const plan: MacSpeakerRepairPlan = { id: receipt.id, evidenceHash: receipt.evidenceHash, expectedDocumentHash: receipt.beforeHash,
87
+ protectedRawChunkIndices: [], rawAudioSampleCounts: evidence.rawAudioSampleCounts, assignments: receipt.assignments, withheldBatchWords: receipt.withheldBatchWords }
88
+ const seen = new Set<number>()
89
+ for (const assignment of receipt.assignments) {
90
+ if (!uint(assignment.rawChunkIndex) || seen.has(assignment.rawChunkIndex)) refuse('receipt_invalid')
91
+ seen.add(assignment.rawChunkIndex)
92
+ const proof = assignment.targetEvidence
93
+ if (!proof) continue // Anonymous recurrence cannot authorize a name.
94
+ if (!checkSpeakerName(assignment.to).ok || !proof.windows?.length || proof.windows.length > 128) refuse('proof_rejected')
95
+ try { validateMacSpeakerTargetEvidence(source, plan, assignment) } catch { refuse('proof_rejected') }
96
+ const current = byRaw.get(assignment.rawChunkIndex)
97
+ if (!current || current.chunk.speaker !== assignment.to || !SHA.test(current.chunk.audioSha256)) refuse('receipt_label_drift')
98
+ const restored = source.chunkEntries.find((entry: Doc) => entry.chunkIndex === assignment.rawChunkIndex)?.chunk
99
+ if (restored?.speaker !== assignment.from) refuse('receipt_label_drift')
100
+ positions.set(current.position, {
101
+ label: assignment.to, rawChunkIndex: assignment.rawChunkIndex, evidenceId: assignment.evidenceId, receiptHash,
102
+ modelSHA256: proof.modelSha256, profileSHA256: proof.profileSha256, algorithmVersion: proof.algorithmVersion,
103
+ independentWindows: proof.windows.length, minimumSimilarity: Math.min(...proof.windows.map(w => w.similarity)),
104
+ minimumMargin: Math.min(...proof.windows.map(w => w.similarity - w.runnerUpSimilarity)),
105
+ })
106
+ }
107
+ return { status: 'qualified', positions }
108
+ } catch (error) {
109
+ return { status: 'invalid', positions: new Map(), reason: error instanceof Error ? error.message : 'proof_rejected' }
110
+ }
111
+ }
@@ -0,0 +1,41 @@
1
+ /** Isolated, killable read-only extraction. Native model work never runs on the
2
+ * server event loop and never reaches recognition/enrollment/calibration APIs. */
3
+ import { spawn } from 'node:child_process'
4
+ import { createRequire } from 'node:module'
5
+ const require = createRequire(import.meta.url)
6
+ const worker = `
7
+ const fs = require('node:fs');
8
+ const sherpa = require(process.argv[1]);
9
+ const model = process.argv[2];
10
+ const extractor = new sherpa.SpeakerEmbeddingExtractor({model, numThreads:1, provider:'cpu'});
11
+ let size=0, parts=[];
12
+ process.stdin.on('data', b=>{size+=b.length;if(size>960000)process.exit(2);parts.push(b)});
13
+ process.stdin.on('end',()=>{
14
+ const pcm=Buffer.concat(parts); if(pcm.length<896000||pcm.length%2)process.exit(3);
15
+ const embed=(b)=>{const samples=new Float32Array(b.length/2);for(let i=0;i<samples.length;i++)samples[i]=b.readInt16LE(i*2)/32768;
16
+ const stream=extractor.createStream();stream.acceptWaveform({samples,sampleRate:16000});stream.inputFinished();
17
+ if(!extractor.isReady(stream))throw Error('not ready');return Array.from(extractor.compute(stream));};
18
+ const half=Math.floor(pcm.length/4)*2; process.stdout.write(JSON.stringify([embed(pcm),embed(pcm.subarray(0,half)),embed(pcm.subarray(half))]));
19
+ });`
20
+ export async function extractQualifiedMacWindow(pcm: Buffer, modelPath: string, timeoutMs: number, deps: { spawn?: typeof spawn; modulePath?: string } = {}): Promise<readonly number[][] | null> {
21
+ if (pcm.length < 896000 || pcm.length > 960000 || pcm.length % 2 || timeoutMs <= 0) return null
22
+ let modulePath: string
23
+ try { modulePath = deps.modulePath ?? require.resolve('sherpa-onnx-node') } catch { return null }
24
+ return new Promise(resolve => {
25
+ const child = (deps.spawn ?? spawn)(process.execPath, ['-e', worker, modulePath, modelPath], { stdio: ['pipe', 'pipe', 'pipe'] })
26
+ const output: Buffer[] = []; let size = 0, settled = false, aborted = false
27
+ const finish = (result: readonly number[][] | null) => { if (settled) return; settled = true; clearTimeout(timer); resolve(result) }
28
+ const abort = () => { aborted = true; child.kill('SIGKILL') }
29
+ // Keep ownership until close confirms native termination. Releasing on
30
+ // kill() alone permits a second native job while the first still exits.
31
+ const timer = setTimeout(abort, timeoutMs)
32
+ child.stdout.on('data', b => { size += b.length; if (size > 131072) abort(); else if (!aborted) output.push(Buffer.from(b)) })
33
+ // Drain diagnostics without retaining native stderr or logging private input.
34
+ child.stderr.on('data', () => {})
35
+ child.on('error', () => { aborted = true; if (!child.pid) finish(null); else child.kill('SIGKILL') }); child.stdin.on('error', () => {})
36
+ child.on('close', code => {
37
+ try { const rows = JSON.parse(Buffer.concat(output).toString()); finish(!aborted && code === 0 && Array.isArray(rows) && rows.length === 3 ? rows : null) } catch { finish(null) }
38
+ })
39
+ child.stdin.end(pcm)
40
+ })
41
+ }