@gotcos/glasses-server 6.92.0 → 6.94.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.
Files changed (63) hide show
  1. package/.env.example +30 -1
  2. package/CHANGELOG.md +72 -29
  3. package/README.md +70 -0
  4. package/docs/active-query-jobs.md +1 -1
  5. package/docs/test-plan-6.71.7.md +1 -1
  6. package/managed-runtime-contract.json +2 -0
  7. package/package.json +2 -1
  8. package/server/lib/ask-session-id.ts +2 -1
  9. package/server/lib/embedding-eviction.ts +60 -2
  10. package/server/lib/fuzzy-correct.ts +1 -1
  11. package/server/lib/g2-enrichment-runner.ts +7 -0
  12. package/server/lib/g2-ops-handoff.ts +204 -2
  13. package/server/lib/hallucination-filter.ts +1 -1
  14. package/server/lib/imported-library-rows.ts +4 -2
  15. package/server/lib/live-cos-ask.ts +3 -2
  16. package/server/lib/mac-live-voices.ts +57 -0
  17. package/server/lib/mac-recording-asr.ts +16 -8
  18. package/server/lib/mac-speaker-projection.ts +211 -0
  19. package/server/lib/mac-stream-diarizer.ts +185 -0
  20. package/server/lib/mac-voice-assignment.ts +361 -0
  21. package/server/lib/mac-voice-labels.ts +70 -8
  22. package/server/lib/mac-window-replay.ts +271 -0
  23. package/server/lib/mac-window-voices.ts +390 -0
  24. package/server/lib/meeting-actions.ts +1 -1
  25. package/server/lib/meeting-ask-owner.ts +4 -4
  26. package/server/lib/meeting-ask-transcript-sources.ts +41 -3
  27. package/server/lib/meeting-audio-archive.ts +4 -0
  28. package/server/lib/meeting-batch-transcribe.ts +7 -1
  29. package/server/lib/meeting-context-keys.ts +6 -3
  30. package/server/lib/meeting-corrections.ts +4 -4
  31. package/server/lib/meeting-fact-check.ts +3 -3
  32. package/server/lib/meeting-parse.ts +1 -1
  33. package/server/lib/meeting-relabel-enrolment.ts +8 -8
  34. package/server/lib/meeting-relabel.ts +3 -3
  35. package/server/lib/meeting-scribe-content.ts +1 -1
  36. package/server/lib/meeting-speaker-review.ts +8 -8
  37. package/server/lib/meeting-store.ts +19 -9
  38. package/server/lib/nemotron-final.ts +114 -9
  39. package/server/lib/nemotron-live.ts +58 -6
  40. package/server/lib/phone-recording.ts +114 -0
  41. package/server/lib/prompt-tail-guard.ts +1 -1
  42. package/server/lib/speaker-embeddings.ts +154 -28
  43. package/server/lib/speaker-name.ts +1 -1
  44. package/server/lib/stranded-session-actions.ts +7 -2
  45. package/server/lib/training-audio-provenance.ts +3 -2
  46. package/server/lib/transcript-cleanup-context.ts +3 -3
  47. package/server/lib/transcript-cleanup-model.ts +1 -1
  48. package/server/lib/transcript-cleanup-spans.ts +10 -10
  49. package/server/lib/transcript-cleanup-text.ts +4 -4
  50. package/server/lib/transcript-cleanup.ts +1 -1
  51. package/server/lib/unsaved-audio-quarantine.ts +10 -1
  52. package/server/lib/voice-directory.ts +1 -1
  53. package/server/lib/voice-evidence-guard.ts +2 -1
  54. package/server/lib/voice-profile-store.ts +11 -5
  55. package/server/lib/whisper-local.ts +1 -1
  56. package/server/routes/__fixtures__/g2-filing-golden-6.93.0.json +62 -0
  57. package/server/routes/health.ts +8 -0
  58. package/server/routes/meeting.ts +231 -20
  59. package/server/routes/meetings.ts +9 -5
  60. package/server/routes/transcribe-stream.ts +140 -15
  61. package/server/routes/voice.ts +12 -4
  62. package/server/scripts/mac-voice-assignment-6930-mutation-gate.mjs +225 -0
  63. package/server/scripts/mac-window-voices-6930-mutation-gate.mjs +193 -0
@@ -65,8 +65,8 @@ function distance(a: string, b: string): number {
65
65
  }
66
66
  /**
67
67
  * One heard word against one name word. Short words must match exactly. Otherwise one edit (two from
68
- * seven letters), or one edit after a leading y the recognizer added before a vowel: Ukoma and Yukoma
69
- * both read as Ukaoma.
68
+ * seven letters), or one edit after a leading y the recognizer added before a vowel: Ukmya and Yukmya
69
+ * both read as Ukamya.
70
70
  */
71
71
  export function nameWordMatches(heard: string, name: string): boolean {
72
72
  const a = fold(heard), b = fold(name)
@@ -82,8 +82,8 @@ const LEAD_IN = new Set(['is', 'was', 'about', 'with', 'for', 'from', 'to', 'of'
82
82
  export interface OwnerMatch { heard: string; owner: string }
83
83
  /**
84
84
  * Whether the question names the owner, allowing ASR spellings. Full name: first and last words, in
85
- * order and adjacent. Surname alone: only after a lead-in word ("who is Ukoma"), never after another
86
- * first name ("Rafael Ukaoma" is someone else). First name alone: only when it is the whole subject of
85
+ * order and adjacent. Surname alone: only after a lead-in word ("who is Ukmya"), never after another
86
+ * first name ("Felix Ukamya" is someone else). First name alone: only when it is the whole subject of
87
87
  * a who question, compared exactly.
88
88
  */
89
89
  export function matchOwnerName(question: string, ownerName: string | null | undefined): OwnerMatch | null {
@@ -3,11 +3,49 @@ import { readBoundedFile, type AskTranscriptSources } from './meeting-ask-transc
3
3
  import { getMeetingStore } from './meeting-store.js';
4
4
  import { dataPath } from './data-dir.js';
5
5
  import { join } from 'node:path';
6
+ import { requirePhoneRecording, PHONE_SOURCE_TYPE, type PhoneRecording } from './phone-recording.js';
7
+ import { AskError } from './live-cos-ask.js';
8
+
9
+ function phoneRecording(id: string): PhoneRecording | null {
10
+ if (!id.startsWith('phone_')) return null;
11
+ try { return requirePhoneRecording(id); }
12
+ catch (error) { const e = error as { reason?: string; status?: number }; throw new AskError(e.reason ?? 'phone_capture_metadata_unreadable', e.status ?? 409); }
13
+ }
14
+ function anonymousChunks(chunks: unknown): boolean {
15
+ return Array.isArray(chunks) && chunks.every(c => c && typeof c === 'object' && c.speaker === 'Ext' && c.speakerPolicy === 'anonymous' && (c.segments === undefined || (Array.isArray(c.segments) && c.segments.every((s: any) => s && s.speaker === 'Ext'))));
16
+ }
17
+ /** Ask may not recover a phone journal that audio/session recovery refused. */
18
+ function validatePhoneDocument(text: string | null, phone: PhoneRecording): void {
19
+ try {
20
+ const doc = JSON.parse(text ?? 'null'), capture = doc?.captureSource;
21
+ if (!doc || doc.sessionId !== phone.sessionId || doc.startTime !== phone.startTime || doc.sourceType !== PHONE_SOURCE_TYPE || doc.speakerPolicy !== 'anonymous' || capture?.version !== 1 || capture?.captureId !== phone.captureId || capture?.sessionId !== phone.sessionId || capture?.startTime !== phone.startTime || capture?.sourceType !== PHONE_SOURCE_TYPE || capture?.speakerPolicy !== 'anonymous') throw Error('provenance');
22
+ let found = false;
23
+ for (const [key, field] of [['chunks', null], ['chunksIndexed', 'c'], ['chunkEntries', 'chunk']] as const) {
24
+ if (doc[key] === undefined) continue;
25
+ if (!Array.isArray(doc[key]) || !anonymousChunks(field ? doc[key].map((entry: any) => entry?.[field]) : doc[key])) throw Error('anonymous transcript');
26
+ found = true;
27
+ }
28
+ if (!found || (doc.batchSegments !== undefined && (!Array.isArray(doc.batchSegments) || !doc.batchSegments.every((s: any) => s && s.speaker === 'Ext')))) throw Error('anonymous transcript');
29
+ } catch { throw new AskError('phone_transcript_provenance_unreadable', 409); }
30
+ }
6
31
  export const transcriptSources: AskTranscriptSources = {
7
- live: id => { const entries = getSessionChunkEntries(id), startTime = getSessionStartTime(id); return entries && startTime !== null ? { entries, startTime } : null; },
32
+ live: id => {
33
+ const phone = phoneRecording(id), entries = getSessionChunkEntries(id), startTime = getSessionStartTime(id);
34
+ if (phone && entries && (phone.closedAt || startTime !== phone.startTime || !anonymousChunks(entries.map(e => e.chunk)))) throw new AskError('phone_transcript_provenance_unreadable', 409);
35
+ return entries && startTime !== null ? { entries, startTime } : null;
36
+ },
8
37
  status: id => getMeetingSessionStatus(id),
9
- activeFile: id => readBoundedFile(join(dataPath('active-sessions'), `${id}.json`)),
10
- saved: id => getMeetingStore().findBySessionId(id),
38
+ activeFile: id => {
39
+ const phone = phoneRecording(id), text = readBoundedFile(join(dataPath('active-sessions'), `${id}.json`));
40
+ if (phone && text) validatePhoneDocument(text, phone);
41
+ return text;
42
+ },
43
+ saved: id => {
44
+ const phone = phoneRecording(id), saved = getMeetingStore().findBySessionId(id);
45
+ if (phone && saved) validatePhoneDocument(readBoundedFile(saved.sidecarPath), phone);
46
+ if (phone?.closedAt && !saved) throw new AskError('phone_saved_metadata_unreadable', 409);
47
+ return saved;
48
+ },
11
49
  read: readBoundedFile,
12
50
  now: Date.now,
13
51
  };
@@ -111,6 +111,10 @@ export function archiveSessionAudio(sessionId: string, sourceDir: string): Archi
111
111
  }
112
112
  try { out.bytes += statSync(to).size } catch { /* counted as linked regardless */ }
113
113
  }
114
+ // Source metadata follows the same retained audio, without counting it as a chunk.
115
+ if (sessionId.startsWith('phone_')) {
116
+ try { copyFileSync(join(sourceDir, '_capture.json'), join(dest, '_capture.json')) } catch { /* durable registry and saved sidecar retain the source too */ }
117
+ }
114
118
  return out
115
119
  }
116
120
 
@@ -1,3 +1,4 @@
1
+ import { PHONE_CAPTURE_ID, requirePhoneRecording } from './phone-recording.js'
1
2
  // Generic post-meeting transcription. Raw WAV chunks are concatenated into
2
3
  // larger windows so Whisper gets enough context to improve the live stream.
3
4
  // The candidate is never canonical until batch-transcript-quality accepts it.
@@ -466,12 +467,16 @@ export interface HqJobPlan {
466
467
  secondOpinion: SecondOpinionId
467
468
  resolver: boolean
468
469
  frontEnd: FrontEndId
469
- /** Mac canonical Parakeet must never silently substitute Whisper. */
470
+ /** Canonical Mac/phone Parakeet must never silently substitute Whisper. */
470
471
  strictPrimary?: true
471
472
  }
472
473
 
473
474
  export function currentHqJobPlan(sessionId?: string): HqJobPlan {
474
475
  const selected = readSttSettings()
476
+ if (sessionId && PHONE_CAPTURE_ID.test(sessionId)) {
477
+ requirePhoneRecording(sessionId)
478
+ return { engine: 'parakeet-ultra', secondOpinion: 'off', resolver: false, frontEnd: selected.frontEnd, strictPrimary: true }
479
+ }
475
480
  if (sessionId && MAC_CAPTURE_ID.test(sessionId) && selected.hq === 'parakeet-ultra') {
476
481
  return { engine: selected.hq, secondOpinion: selected.secondOpinion, resolver: selected.resolver, frontEnd: selected.frontEnd, strictPrimary: true }
477
482
  }
@@ -789,6 +794,7 @@ export async function transcribeSegments(
789
794
  plan: HqJobPlan,
790
795
  deps: HqDecodeDeps = defaultHqDeps,
791
796
  ): Promise<BatchResult[]> {
797
+ if (expectedSessionId?.startsWith('phone_')) { requirePhoneRecording(expectedSessionId); plan = { ...plan, engine: 'parakeet-ultra', secondOpinion: 'off', resolver: false, strictPrimary: true } }
792
798
  if (expectedSessionId && MAC_CAPTURE_ID.test(expectedSessionId) && plan.engine === 'parakeet-ultra') plan = { ...plan, strictPrimary: true }
793
799
  const results: BatchResult[] = []
794
800
  const meetingId = basename(audioDir)
@@ -1,3 +1,4 @@
1
+ import { PHONE_CAPTURE_ID } from './phone-recording.js'
1
2
  import { readMacRecording } from './mac-recording.js'
2
3
  /**
3
4
  * Meeting context keys (6.64.0): the ids COS Control files a meeting's dropped files under.
@@ -12,6 +13,7 @@ import { readMacRecording } from './mac-recording.js'
12
13
  * - `g2:<sessionId>` from its `.g2-chunks.json` sidecar (the speaker system's key; moves with every G2 rename);
13
14
  * - `ff:<id>` from its `.fireflies.json` sidecar;
14
15
  * - `mac:<captureId>` from an admitted Mac source and its saved sourceType;
16
+ * - `phone:<captureId>` from an admitted iPhone microphone source;
15
17
  * - `g2:<id>` for every `<!-- g2-session -->` the file declares, which is how a merged meeting also shows the files of
16
18
  * the capture it absorbed.
17
19
  * Measured 2026-10-06: 649 of 656 meetings from August to October carry at least one.
@@ -25,7 +27,7 @@ import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, realpat
25
27
  import { dirname, join } from 'node:path'
26
28
 
27
29
  /** A key as Control stores it. The id part is the server's own marker grammar (`G2_SESSION_MARKER`). */
28
- export const MEETING_CONTEXT_KEY = /^(?:g2|ff|mac):[A-Za-z0-9:_-]{3,96}$/
30
+ export const MEETING_CONTEXT_KEY = /^(?:(?:ff|mac|g2):(?!phone_)[A-Za-z0-9:_-]{3,96}|phone:phone_(?:[0-9]{13}_)?[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12})$/
29
31
  const SOURCE_ID = /^[A-Za-z0-9:_-]{3,96}$/
30
32
 
31
33
  const SIDECAR_HEAD_BYTES = 4096
@@ -101,8 +103,9 @@ export function firefliesSidecarId(monthDir: string, meetingFilename: string): s
101
103
  export function meetingContextKeys(sources: { g2SessionId?: string; firefliesId?: string; declared?: readonly string[]; sourceType?: string }): string[] {
102
104
  const keys: string[] = []
103
105
  const add = (prefix: 'g2' | 'ff' | 'mac', id: string | undefined) => {
104
- if (!id || !SOURCE_ID.test(id)) return
105
- const actualPrefix = prefix === 'g2' && (sources.sourceType === 'mac_system_audio' || readMacRecording(id)) ? 'mac' : prefix
106
+ if (!id || !SOURCE_ID.test(id) || (prefix !== 'g2' && id.startsWith('phone_'))) return
107
+ if (prefix === 'g2' && (id.startsWith('phone_') || sources.sourceType === 'iphone_microphone') && (!PHONE_CAPTURE_ID.test(id) || sources.sourceType !== 'iphone_microphone')) return
108
+ const actualPrefix = prefix === 'g2' && sources.sourceType === 'iphone_microphone' ? 'phone' : prefix === 'g2' && (sources.sourceType === 'mac_system_audio' || readMacRecording(id)) ? 'mac' : prefix
106
109
  const key = `${actualPrefix}:${id}`
107
110
  if (!keys.includes(key)) keys.push(key)
108
111
  }
@@ -4,7 +4,7 @@
4
4
  // chunk sidecar, the attendee list, the transcript turn labels. If that is all
5
5
  // that happens, three things become impossible:
6
6
  //
7
- // 1. UNDO. Once "Luke H" has become "Luke Henry" in the sidecar, nothing
7
+ // 1. UNDO. Once "Theo M" has become "Theo Marsh" in the sidecar, nothing
8
8
  // remembers it was ever anything else. A mistaken correction is permanent.
9
9
  // 2. CRASH RECOVERY. A rewrite touches several files. Die between them and the
10
10
  // meeting is half-corrected with no trace of what was intended.
@@ -18,7 +18,7 @@
18
18
  //
19
19
  // WHAT IS DELIBERATELY NOT CORRECTED. The meeting markdown's Summary, Topics,
20
20
  // Decisions and Action Items are LLM prose that refers to people by BARE FIRST
21
- // NAME ("Jeremy pushed back", "Chris raised Beamer sentiment"). Verified on a
21
+ // NAME ("Jasper pushed back", "Chris raised Beamer sentiment"). Verified on a
22
22
  // real scribe: 6 of 12 speakers appear that way. This org has two Alexes, two
23
23
  // Jordans and two Chrises, so a find/replace on a first name in narrative text
24
24
  // would silently rewrite a sentence about a different person. Prose is left
@@ -255,8 +255,8 @@ export function appliedCorrections(sessionId: string): CorrectionRow[] {
255
255
  /**
256
256
  * Follow a chain of applied corrections to the label a voice now carries.
257
257
  *
258
- * Chains happen: a voice labelled 'Ext' is corrected to 'Luke H', then later to
259
- * 'Luke Henry'. Asking "what is Ext now?" must answer 'Luke Henry', not stop at
258
+ * Chains happen: a voice labelled 'Ext' is corrected to 'Theo M', then later to
259
+ * 'Theo Marsh'. Asking "what is Ext now?" must answer 'Theo Marsh', not stop at
260
260
  * the first hop. Cycles are possible if a human corrects A→B then B→A, so the
261
261
  * walk is bounded by the number of corrections and returns the last label
262
262
  * reached rather than looping.
@@ -151,7 +151,7 @@ const LEAD_WORDS = new Set('i im am we were are it its is starting leaving going
151
151
  export const US_STATE_CODES = new Set('AL AK AZ AR CA CO CT DE FL GA HI ID IL IN IA KS KY LA ME MD MA MI MN MS MO MT NE NV NH NJ NM NY NC ND OH OK OR PA RI SC SD TN TX UT VT VA WA WV WI WY DC PR GU VI AS MP'.split(' '))
152
152
  /**
153
153
  * Larger US cities and the owner's Austin metro: a typed "City, ST" origin must name one of these (static, no
154
- * private data), so "Talia TX" or "Silas PM" are never places.
154
+ * private data), so "Talia TX" or "Elliot PM" are never places.
155
155
  */
156
156
  const US_CITIES = new Set(`new york|los angeles|chicago|houston|phoenix|philadelphia|san antonio|san diego|dallas|san jose|austin|jacksonville|fort worth|columbus|charlotte|san francisco|indianapolis|seattle|denver|washington|boston|el paso|nashville|detroit|oklahoma city|portland|las vegas|memphis|louisville|baltimore|milwaukee|albuquerque|tucson|fresno|mesa|sacramento|atlanta|kansas city|colorado springs|omaha|raleigh|miami|long beach|virginia beach|oakland|minneapolis|tulsa|tampa|arlington|new orleans|wichita|cleveland|bakersfield|aurora|anaheim|honolulu|santa ana|riverside|corpus christi|lexington|henderson|stockton|saint paul|st paul|st louis|saint louis|cincinnati|pittsburgh|greensboro|anchorage|plano|lincoln|orlando|irvine|newark|durham|chula vista|toledo|fort wayne|st petersburg|laredo|jersey city|chandler|madison|lubbock|scottsdale|reno|buffalo|gilbert|glendale|north las vegas|winston salem|chesapeake|norfolk|fremont|garland|irving|hialeah|richmond|boise|spokane|baton rouge|tacoma|san bernardino|modesto|fontana|des moines|santa clarita|fayetteville|birmingham|oxnard|rochester|grand rapids|huntsville|salt lake city|frisco|yonkers|amarillo|huntington beach|mckinney|montgomery|augusta|akron|little rock|tempe|overland park|grand prairie|tallahassee|cape coral|mobile|knoxville|shreveport|worcester|sioux falls|chattanooga|brownsville|fort lauderdale|providence|santa rosa|peoria|salem|eugene|cary|fort collins|springfield|jackson|alexandria|lancaster|pasadena|naperville|bellevue|killeen|midland|odessa|savannah|syracuse|denton|waco|charleston|hartford|new haven|stamford|columbia|santa clara|ann arbor|cambridge|lansing|evansville|college station|beaumont|provo|boulder|tyler|las cruces|green bay|san mateo|burbank|south bend|davenport|tuscaloosa|san angelo|round rock|cedar park|georgetown|leander|pflugerville|san marcos|new braunfels|liberty hill|hutto|kyle|buda|lakeway|bee cave|temple|bryan|abilene|wichita falls|galveston|mcallen|edinburg|pearland|sugar land|the woodlands|conroe|katy|allen|richardson|carrollton|lewisville|flower mound|mesquite|harlingen|key west|palm springs|santa fe|flagstaff|billings|fargo|bismarck|cheyenne|casper|missoula|juneau|burlington|portsmouth|manchester|wilmington|dover|annapolis|trenton|albany|montpelier|concord|augusta|bangor|duluth|des plaines|evanston|topeka|lawrence|olathe|independence|st joseph|joplin|branson|springdale|bentonville|rogers|fayetteville|hot springs|jonesboro|norman|edmond|lafayette|lake charles|monroe|gulfport|biloxi|hattiesburg|pensacola|gainesville|daytona beach|sarasota|naples|fort myers|clearwater|lakeland|ocala|asheville|greenville|myrtle beach|columbia|athens|macon|dothan|chattanooga|knoxville|murfreesboro|clarksville|bowling green|owensboro|dayton|youngstown|canton|erie|scranton|allentown|reading|harrisburg|lancaster|altoona|morgantown|roanoke|lynchburg|charlottesville|norfolk|hampton|newport news`.split('|'))
157
157
  /** A full city label the user typed: a listed US city, then a real state code ("Dallas, TX", "San Antonio TX"). */
@@ -225,8 +225,8 @@ function takePlace(raw: readonly string[], low: readonly string[], start: number
225
225
  /**
226
226
  * A question that only supplies the starting place of the pending route claim: "from <place>" or
227
227
  * "<place> to <destination>", where the destination is a place the claim names and every other word is a
228
- * request, travel or route word (or the owner's own name). "Gina to send the deck by Friday?" and "What did we
229
- * hear from Silas Larson today?" are ordinary questions, never an origin.
228
+ * request, travel or route word (or the owner's own name). "Mira to send the deck by Friday?" and "What did we
229
+ * hear from Elliot Sterling today?" are ordinary questions, never an origin.
230
230
  */
231
231
  export function originClarification(question: string, ctx: OriginContext): OwnerOrigin | null {
232
232
  const { raw, low } = tokensOf(question)
@@ -134,7 +134,7 @@ export function parseMeeting(content: string, filename: string, month: string):
134
134
  // codebase, disagreeing with each other.
135
135
  domainAbbr: domainAbbreviation(domain),
136
136
  source: parseField(content, 'Source'),
137
- ...(parseField(content, 'Capture source') === 'mac_system_audio' ? { sourceType: 'mac_system_audio' as const } : {}),
137
+ ...(['mac_system_audio', 'iphone_microphone'].includes(parseField(content, 'Capture source')) ? { sourceType: parseField(content, 'Capture source') as 'mac_system_audio' | 'iphone_microphone' } : {}),
138
138
  duration,
139
139
  ...(parseDurationMinutes(duration) !== undefined ? { durationMinutes: parseDurationMinutes(duration) } : {}),
140
140
  month,
@@ -1,4 +1,4 @@
1
- // Turning "that unidentified voice was Kirstyn Blum" into a real voice profile.
1
+ // Turning "that unidentified voice was Wren Hale" into a real voice profile.
2
2
  //
3
3
  // WHY THIS EXISTS. Relabelling was TEXT ONLY: it rewrote `speaker` strings in the
4
4
  // meeting sidecar and never touched the voice store. Naming a voice over 109
@@ -6,8 +6,8 @@
6
6
  // appeared in /api/voice/profiles, the review panel still offered her as `new
7
7
  // name` inside the SAME meeting, no later meeting could match her, and there was
8
8
  // no profile to accumulate further chunks against. Verified live 2026-08-13:
9
- // `Kirstyn Blum` appears 70x in one sidecar and 112x in another while
10
- // voice-profiles.json held 77 profiles and no Kirstyn.
9
+ // `Wren Hale` appears 70x in one sidecar and 112x in another while
10
+ // voice-profiles.json held 77 profiles and no Wren.
11
11
  //
12
12
  // ─────────────────────────────────────────────────────────────────────────────
13
13
  // WHY THIS IS ITS OWN MODULE AND WHY IT IS THIS PARANOID
@@ -189,12 +189,12 @@ export interface EnrolNamedVoiceInput {
189
189
  * Enrol the voice a human just named.
190
190
  *
191
191
  * SCOPED to a real target name. The reviewer is assigning THESE chunks to `to`.
192
- * That is a first training run when `to` has no profile (the live case: Nick
193
- * Gurney → Milo LeBaron, 2026-08-20 — 19 chunks relabelled, 78 profiles, no
194
- * Milo) and an append when the profile already exists. Global fold of two
192
+ * That is a first training run when `to` has no profile (the live case: Cole
193
+ * Garner → Leo Ashby, 2026-08-20 — 19 chunks relabelled, 78 profiles, no
194
+ * Leo) and an append when the profile already exists. Global fold of two
195
195
  * identities remains `merge-profiles`. Per-meeting chunk assignment is not
196
196
  * that, and gating on `from` being a placeholder made a wrong existing label
197
- * (the identifier's 0.55 Nick match) create no profile at all.
197
+ * (the identifier's 0.55 Cole match) create no profile at all.
198
198
  *
199
199
  * Still skipped: a placeholder `to` (Ext → Unidentified 2), and an empty
200
200
  * `changed` list. An EXISTING name is appended to without a prompt, and
@@ -217,7 +217,7 @@ export function enrolNamedVoice(input: EnrolNamedVoiceInput): EnrolmentReport {
217
217
  // Not an enrolment at all. `attempted: 0` with `skipped: null` is how the
218
218
  // caller tells this apart from an enrolment that found no candidates.
219
219
  // `from` is deliberately NOT gated: a wrong existing label is how a new
220
- // person first appears in review (Nick Gurney → Milo LeBaron). Gating on
220
+ // person first appears in review (Cole Garner → Leo Ashby). Gating on
221
221
  // placeholder-only `from` labelled the meeting and taught the store nothing.
222
222
  if (isPlaceholderLabel(to) || changed.length === 0) return IDLE
223
223
 
@@ -7,7 +7,7 @@
7
7
  // index-parallel rendering of the chunk sidecar. Measured on a real meeting
8
8
  // (2026-08-06 Health Score V2): 135 sidecar chunks collapse to 46 speaker runs,
9
9
  // while the markdown carries 70 turns — and they disagree on who spoke, with the
10
- // markdown's 4th turn attributed to Joe Karbowski where the sidecar's 4th run
10
+ // markdown's 4th turn attributed to Jonas Karlin where the sidecar's 4th run
11
11
  // says Riley Example. The transcript was rendered from a different
12
12
  // segmentation pass. So:
13
13
  //
@@ -213,8 +213,8 @@ export function relabelMeetingMarkdown(
213
213
  if (att) {
214
214
  const body = out.slice(att.start, att.end)
215
215
  // Anchored at BOTH ends, and `[ \t]` rather than `\s` so a pattern can never
216
- // run past its own line. Without the end anchor, `- Luke H` matches the
217
- // PREFIX of `- Luke Henry` and deleting it leaves the fragment `enry`.
216
+ // run past its own line. Without the end anchor, `- Theo M` matches the
217
+ // PREFIX of `- Theo Marsh` and deleting it leaves the fragment `enry`.
218
218
  const line = (label: string) => `^-[ \t]+${escapeRegExp(label)}[ \t]*$`
219
219
  // A de-attribution must DELETE the attendee bullet, never rename it. Renaming
220
220
  // writes `- Unidentified 2` into the attendee list as though it were a
@@ -14,7 +14,7 @@
14
14
  // whose verdict we would be applying — rewriting them is circular and
15
15
  // destroys the raw record the reviewer needs to judge for themselves.
16
16
  // 2. A name in the prose is frequently a person MENTIONED, not one who SPOKE.
17
- // An action item naming Jessica Thompson does not claim she was in the room.
17
+ // An action item naming Joanna Thornley does not claim she was in the room.
18
18
  // Stripping it would replace one wrong claim with a different one.
19
19
  //
20
20
  // So the output carries an ALLOWLIST: who voice matching confirmed, plus a plain
@@ -8,13 +8,13 @@
8
8
  // It also carries the diagnostic that actually catches a broken profile.
9
9
  // Similarity between profile centroids does NOT distinguish "two people who
10
10
  // sound alike" from "one voice split across two labels" — measured on the live
11
- // store, Chris Krubeck and Luke Henry sat at 0.85 while genuinely being
11
+ // store, Alex Kellar and Theo Marsh sat at 0.85 while genuinely being
12
12
  // separate rows. What discriminates is RUN LENGTH within a single meeting:
13
13
  //
14
- // MU + Chris Krubeck (known two people) : flip rate 0.069, mean run 30.0
15
- // Luke H + Luke Henry : flip rate 0.361, mean run 2.79
16
- // Chris Krubeck + Luke Henry : flip rate 0.381, mean run 3.47
17
- // Scott Taylor + Dylan Jackson : flip rate 0.344, mean run 2.80
14
+ // MU + Alex Kellar (known two people) : flip rate 0.069, mean run 30.0
15
+ // Theo M + Theo Marsh : flip rate 0.361, mean run 2.79
16
+ // Alex Kellar + Theo Marsh : flip rate 0.381, mean run 3.47
17
+ // Scott Taylor + Dalton Jaynes : flip rate 0.344, mean run 2.80
18
18
  //
19
19
  // Real conversation holds the floor for ~30 consecutive segments. A pair that
20
20
  // swaps every ~3 is the identifier oscillating mid-turn, which means those two
@@ -84,8 +84,8 @@ export const CONFIDENT_SIMILARITY = 0.65
84
84
  * The identifier accepts a match at SEARCH_THRESHOLD = 0.55, so a single segment
85
85
  * scoring 0.55 currently arrives in the panel wearing somebody's full name. On
86
86
  * the user's 2026-08-06 Ditto meeting that produced Riley Example (1 segment,
87
- * 0.60), Luke Henry (1 segment, 0.55), Dylan Jackson (2 segments, 0.58) and
88
- * Jamie Sharif (3 segments, 0.58) — and he confirmed none of them were in the
87
+ * 0.60), Theo Marsh (1 segment, 0.55), Dalton Jaynes (2 segments, 0.58) and
88
+ * Jamie Sharpe (3 segments, 0.58) — and he confirmed none of them were in the
89
89
  * room. Presenting those as names is the defect; the reviewer then has to undo
90
90
  * an assertion the system should never have made.
91
91
  *
@@ -374,7 +374,7 @@ const FILLER = /^(?:yeah|yep|okay|ok|right|sure|mm+|uh+|um+|hmm+|got it|exactly|
374
374
  *
375
375
  * Length matters (a longer line carries more voice), but so does specificity:
376
376
  * a proper noun, a number, or a domain term is what makes someone say "that's
377
- * Graham." A long line of pleasantries scores lower than a short line naming a
377
+ * Gordon." A long line of pleasantries scores lower than a short line naming a
378
378
  * brand and a figure.
379
379
  */
380
380
  export function phraseScore(text: string): number {
@@ -1,3 +1,4 @@
1
+ import { PHONE_CAPTURE_ID, PHONE_SOURCE_TYPE, requirePhoneRecording, closePhoneRecording } from './phone-recording.js'
1
2
  import { isVoiceLabel } from './mac-voice-labels.js'
2
3
  import { MAC_SOURCE_TYPE, readMacRecording, closeMacRecording } from './mac-recording.js'
3
4
  import {
@@ -96,7 +97,7 @@ export interface MeetingMeta {
96
97
  domain: string
97
98
  domainAbbr: string
98
99
  source: string
99
- sourceType?: 'mac_system_audio'
100
+ sourceType?: 'mac_system_audio' | 'iphone_microphone'
100
101
  duration: string
101
102
  durationMinutes?: number
102
103
  month: string
@@ -198,7 +199,7 @@ export interface SaveMeetingInput {
198
199
 
199
200
  export interface SavedMeeting {
200
201
  captureId?: string
201
- sourceType?: typeof MAC_SOURCE_TYPE
202
+ sourceType?: typeof MAC_SOURCE_TYPE | typeof PHONE_SOURCE_TYPE
202
203
  filepath: string
203
204
  sidecarPath: string
204
205
  filename: string
@@ -350,10 +351,13 @@ export class MeetingStore {
350
351
  if (!transcript) throw new MeetingStoreError('Transcript is empty', 400, 'empty_transcript')
351
352
 
352
353
  const mac = readMacRecording(sessionId)
354
+ const phone = sessionId.startsWith('phone_') ? requirePhoneRecording(sessionId) : null
355
+ if (phone?.closedAt) throw new MeetingStoreError('iPhone capture is already saved', 409, 'phone_saved_metadata_unreadable')
356
+ if (phone && input.chunks.some(c => c.speaker !== 'Ext' || c.speakerPolicy !== 'anonymous' || c.segments?.some(s => s.speaker !== 'Ext'))) throw new MeetingStoreError('iPhone microphone transcript must remain anonymous', 409, 'phone_speaker_policy_violation')
353
357
  const parts = localParts(input.startTime)
354
- const fallbackTitle = `${mac ? 'Mac' : 'G2'} Recording ${parts.date} ${parts.hourMinute}`
358
+ const fallbackTitle = `${mac ? 'Mac' : phone ? 'iPhone' : 'G2'} Recording ${parts.date} ${parts.hourMinute}`
355
359
  const title = normalizeTitle(input.title, fallbackTitle)
356
- const stem = filenameStem(title, `${mac ? 'Mac' : 'G2'}_Recording_${parts.hourMinute}`)
360
+ const stem = filenameStem(title, `${mac ? 'Mac' : phone ? 'iPhone' : 'G2'}_Recording_${parts.hourMinute}`)
357
361
  const durationMs = Number.isFinite(input.durationMs) ? Math.max(0, input.durationMs) : 0
358
362
  const durationMin = Math.round(durationMs / 60_000)
359
363
 
@@ -392,7 +396,8 @@ export class MeetingStore {
392
396
  `| **Date** | ${parts.date} |`,
393
397
  `| **Time** | ${parts.time} |`,
394
398
  `| **Duration** | ${durationMin} minutes |`,
395
- mac ? '| **Source** | Mac System Audio |' : '| **Source** | G2 Glasses |',
399
+ mac ? '| **Source** | Mac System Audio |' : phone ? '| **Source** | iPhone Microphone |' : '| **Source** | G2 Glasses |',
400
+ ...(phone ? ['| **Capture source** | iphone_microphone |', '| **Speaker policy** | anonymous |'] : []),
396
401
  ...(mac ? ['| **Capture source** | mac_system_audio |', '| **Captured voices** | Other participants only |'] : []),
397
402
  `| **Domain** | ${escapeTableValue(domain)} |`,
398
403
  `| **Transfer integrity** | ${integrityValue} |`,
@@ -403,7 +408,7 @@ export class MeetingStore {
403
408
  // When COS ops is configured, use the private-app pipeline markers so
404
409
  // sync_meetings.py --g2-file will enrich (and reclassify domain). Plain
405
410
  // "Standalone recording" summaries are treated as already-final and skipped.
406
- ...(process.env.COS_SCRIPTS_DIR && !mac
411
+ ...(process.env.COS_SCRIPTS_DIR && !mac && !phone
407
412
  ? [
408
413
  '<!-- g2-needs-domain-review -->',
409
414
  '',
@@ -420,7 +425,7 @@ export class MeetingStore {
420
425
  const providers = [...new Set(input.chunks.map(chunk => chunk.asrProvider || 'server-whisper'))]
421
426
  const sidecar = {
422
427
  schemaVersion: 2,
423
- ...(mac ? { sourceType: MAC_SOURCE_TYPE, captureSource: mac } : {}),
428
+ ...(mac ? { sourceType: MAC_SOURCE_TYPE, captureSource: mac } : phone ? { sourceType: PHONE_SOURCE_TYPE, speakerPolicy: 'anonymous', captureSource: phone } : {}),
424
429
  sessionId,
425
430
  startTime: input.startTime,
426
431
  durationMs,
@@ -458,8 +463,9 @@ export class MeetingStore {
458
463
  }
459
464
 
460
465
  if (mac) closeMacRecording(sessionId)
466
+ if (phone) closePhoneRecording(sessionId)
461
467
  return {
462
- ...(mac ? { captureId: sessionId, sourceType: MAC_SOURCE_TYPE } : {}),
468
+ ...(mac ? { captureId: sessionId, sourceType: MAC_SOURCE_TYPE } : phone ? { captureId: sessionId, sourceType: PHONE_SOURCE_TYPE } : {}),
463
469
  filepath,
464
470
  sidecarPath,
465
471
  filename,
@@ -490,6 +496,10 @@ export class MeetingStore {
490
496
  try {
491
497
  const sidecar = JSON.parse(sidecarText) as Record<string, unknown>
492
498
  if (sidecar.sessionId !== sessionId) continue
499
+ if (sessionId.startsWith('phone_')) {
500
+ const capture = sidecar.captureSource as Record<string, unknown> | undefined
501
+ if (sidecar.sourceType !== PHONE_SOURCE_TYPE || sidecar.speakerPolicy !== 'anonymous' || capture?.version !== 1 || capture?.captureId !== sessionId || capture?.sessionId !== sessionId || capture?.sourceType !== PHONE_SOURCE_TYPE || capture?.speakerPolicy !== 'anonymous') continue
502
+ }
493
503
  const filename = sidecarName.replace(/\.g2-chunks\.json$/, '.md')
494
504
  const markdown = safeReadFile(monthDir, monthReal, filename)
495
505
  if (markdown === null) continue
@@ -498,7 +508,7 @@ export class MeetingStore {
498
508
  ? Math.max(0, sidecar.durationMs)
499
509
  : 0
500
510
  return {
501
- ...(sidecar.sourceType === MAC_SOURCE_TYPE ? { captureId: sessionId, sourceType: MAC_SOURCE_TYPE } : {}),
511
+ ...(sidecar.sourceType === MAC_SOURCE_TYPE ? { captureId: sessionId, sourceType: MAC_SOURCE_TYPE } : sidecar.sourceType === PHONE_SOURCE_TYPE && PHONE_CAPTURE_ID.test(sessionId) && sidecar.speakerPolicy === 'anonymous' ? { captureId: sessionId, sourceType: PHONE_SOURCE_TYPE } : {}),
502
512
  filepath: join(monthDir, filename),
503
513
  sidecarPath: join(monthDir, sidecarName),
504
514
  filename,
@@ -416,14 +416,18 @@ export function relabelBatchSegments(
416
416
  const original = typeof word.originalSpeaker === 'string' ? word.originalSpeaker : word.speaker
417
417
  const start = batchWordToTimeline(segment, Number(word.start), timeline)
418
418
  const end = batchWordToTimeline(segment, Number(word.end), timeline)
419
- let name: string | null = null
420
- if (start) {
419
+ // Existing live anonymous turns are established capture evidence. A
420
+ // meeting-wide channel may refine unknown speech, never overwrite them.
421
+ const live = start ? liveSpeakerAt(start.window.chunk, start.inChunk, owner) : null
422
+ let name: string | null = isVoiceLabel(live) ? live : null
423
+ if (name === null && start) {
421
424
  const channel = channelForSpan(a, start.at, Math.max(start.at, end?.window === start.window ? end.at : start.at + 0.01))
422
425
  if (channel !== null && map.has(channel)) { name = map.get(channel)!; stats.viaChannel++ }
423
426
  }
427
+ if (isVoiceLabel(live)) stats.kept++
424
428
  if (name === null) {
425
429
  stats.kept++
426
- name = (start && liveSpeakerAt(start.window.chunk, start.inChunk, owner))
430
+ name = live
427
431
  ?? (start ? guardOwnerName(original, start.window.chunk.similarity, start.window.chunk, owner) : original)
428
432
  }
429
433
  if (name !== word.speaker) stats.relabeled++
@@ -477,15 +481,20 @@ export function relabelStreamingChunks(
477
481
  const times = streamingTokenTimes(chunk, tokens, window, a)
478
482
  const base = guardOwnerName(typeof chunk.originalSpeaker === 'string' ? chunk.originalSpeaker : chunk.speaker, chunk.similarity, chunk, owner)
479
483
  const labels: string[] = []
484
+ const liveTokens = (chunk.segments ?? []).flatMap(segment => splitTokens(String(segment.text ?? '')).map(token => ({ token, speaker: segment.speaker })))
485
+ const exactLive = liveTokens.length === tokens.length && liveTokens.every((part, i) => part.token === tokens[i]) ? liveTokens : null
480
486
  let carry: string | null = null
481
487
  for (let i = 0; i < tokens.length; i++) {
482
- if (!isWordToken(tokens[i]) || !times) { labels.push(carry ?? liveSpeakerAt(chunk, 0, owner) ?? base); continue }
488
+ const established = exactLive?.[i].speaker
489
+ if (!isWordToken(tokens[i]) || !times) { labels.push(isVoiceLabel(established) ? established! : carry ?? liveSpeakerAt(chunk, 0, owner) ?? base); continue }
483
490
  stats.total++
484
491
  const span = times[i]
485
492
  const channel = channelForSpan(a, window.start + span.start, window.start + Math.max(span.end, span.start + 0.01))
486
493
  let name: string
487
- if (channel !== null && map.has(channel)) { name = map.get(channel)!; stats.viaChannel++ }
488
- else { name = liveSpeakerAt(chunk, span.start, owner) ?? base; stats.kept++ }
494
+ const live = isVoiceLabel(established) ? established! : liveSpeakerAt(chunk, span.start, owner)
495
+ if (isVoiceLabel(live)) { name = live!; stats.kept++ }
496
+ else if (channel !== null && map.has(channel)) { name = map.get(channel)!; stats.viaChannel++ }
497
+ else { name = live ?? base; stats.kept++ }
489
498
  labels.push(name)
490
499
  carry = name
491
500
  }
@@ -1121,6 +1130,85 @@ export function mapChannelsToVoices(windows: TimelineWindow[], a: Activity, opti
1121
1130
  return { channels: evidence.filter(row => row.accepted).map(row => row.channel), evidence, eligible }
1122
1131
  }
1123
1132
 
1133
+ /**
1134
+ * The label a chunk had LIVE. A final pass rewrites `speaker` and keeps the live one in
1135
+ * `originalSpeaker`, so a re-run (resumed finalization, nemotron-replay) must read that,
1136
+ * or it takes its own earlier numbers for live evidence and numbers from max + 1.
1137
+ */
1138
+ function liveLabelOf(chunk: { speaker?: unknown; originalSpeaker?: unknown } | null | undefined): unknown {
1139
+ return typeof chunk?.originalSpeaker === 'string' ? chunk.originalSpeaker : chunk?.speaker
1140
+ }
1141
+
1142
+ /** Which final channels each established live turn overlaps (a turn is a claim on every channel active inside it). */
1143
+ function liveClaims(windows: TimelineWindow[], a: Activity): Map<number, Set<string>> {
1144
+ const claims = new Map<number, Set<string>>()
1145
+ for (const window of windows) for (const segment of window.chunk.segments ?? []) {
1146
+ if (!isVoiceLabel(segment.speaker)) continue
1147
+ const timed = Number.isFinite(segment.startSec) && Number.isFinite(segment.endSec)
1148
+ && Number(segment.startSec) >= 0 && Number(segment.endSec) <= window.end - window.start
1149
+ && Number(segment.endSec) > Number(segment.startSec)
1150
+ const start = timed ? window.start + Math.max(0, Number(segment.startSec)) : window.start
1151
+ const end = timed ? Math.min(window.end, window.start + Number(segment.endSec)) : window.end
1152
+ const [f0,f1] = frameRange(a, start, end)
1153
+ for (let channel = 0; channel < NEMOTRON_CHANNELS; channel++) {
1154
+ let overlaps = false
1155
+ for (let f=f0;f<f1;f++) if (isActive(a,f,channel)) { overlaps = true; break }
1156
+ if (!overlaps) continue
1157
+ const labels = claims.get(channel) ?? new Set<string>()
1158
+ labels.add(segment.speaker); claims.set(channel, labels)
1159
+ }
1160
+ }
1161
+ return claims
1162
+ }
1163
+
1164
+ /** Crosswalk final channels to EXISTING live placeholders. A pure live chunk
1165
+ * must belong overwhelmingly to one final channel. Contradictory channels or a
1166
+ * live label split across channels are withheld; no numbers are reassigned. */
1167
+ export function mapChannelsToLiveVoices(windows: TimelineWindow[], a: Activity): Map<number, string> {
1168
+ const votes = new Map<number, Set<string>>()
1169
+ for (const window of windows) {
1170
+ const chunk = window.chunk
1171
+ const liveLabel = liveLabelOf(chunk) as string
1172
+ if (!isVoiceLabel(liveLabel) || !chunk.segments?.length || chunk.segments.some(s => s.speaker !== liveLabel)) continue
1173
+ const [f0,f1] = frameRange(a, window.start, window.end)
1174
+ let speech = 0
1175
+ const exclusive = new Array<number>(NEMOTRON_CHANNELS).fill(0)
1176
+ for (let f=f0;f<f1;f++) {
1177
+ const active: number[]=[]
1178
+ for (let c=0;c<NEMOTRON_CHANNELS;c++) if(isActive(a,f,c)) active.push(c)
1179
+ if(active.length) speech++
1180
+ if(active.length===1) exclusive[active[0]]++
1181
+ }
1182
+ const channel = exclusive.indexOf(Math.max(...exclusive))
1183
+ if(!speech || exclusive[channel]/speech < 0.9) continue
1184
+ const labels = votes.get(channel) ?? new Set<string>()
1185
+ labels.add(liveLabel); votes.set(channel,labels)
1186
+ }
1187
+ // A mixed live chunk cannot establish a crosswalk, but every established
1188
+ // turn in it CAN contradict one. A final diarizer that merges two live
1189
+ // voices into one channel must not transfer one voice's label to the other.
1190
+ const claims = liveClaims(windows, a)
1191
+ const proposed = [...votes].filter(([,labels])=>labels.size===1).map(([channel,labels])=>[channel,[...labels][0]] as const)
1192
+ // Count claims on conflicting channels too. Dropping a contradictory channel
1193
+ // must not make its label appear exclusive to a different channel.
1194
+ return new Map(proposed.filter(([channel,label]) =>
1195
+ [...votes.values()].filter(labels=>labels.has(label)).length===1
1196
+ && [...(claims.get(channel) ?? [])].every(claim => claim === label)
1197
+ && [...claims.values()].filter(labels=>labels.has(label)).length===1))
1198
+ }
1199
+
1200
+ /**
1201
+ * 6.93.0 QA W3: confident final channels that NO established live turn touches.
1202
+ * A capture whose live labels missed a voice (a track the live path never proved,
1203
+ * or one it lost) still gets that voice numbered at save, above every live number,
1204
+ * as 6.92.0 would have numbered it. A channel any live `Voice N` turn overlaps is
1205
+ * never in this set: its words keep the live label, and the rest stay `Ext`.
1206
+ */
1207
+ export function unclaimedFinalChannels(windows: TimelineWindow[], a: Activity, channels: number[]): number[] {
1208
+ const claims = liveClaims(windows, a)
1209
+ return channels.filter(channel => !claims.has(channel))
1210
+ }
1211
+
1124
1212
  /** `Voice 1` for the first channel, `Voice 2` for the next, and so on. */
1125
1213
  export function numberVoices(channels: number[]): Map<number, string> {
1126
1214
  return new Map(channels.map((channel, i) => [channel, voiceLabel(i + 1)]))
@@ -1226,11 +1314,28 @@ async function unnamedVoicePass(input: UnnamedInput): Promise<FinalPassOutcome>
1226
1314
  const aligned = textNext.length === chunks.length && textNext.every((entry, i) => entry.chunk.text === chunks[i]?.text)
1227
1315
  if (!aligned) return { status: 'skipped', reason: 'chunk_map_ambiguous', unnamed: true, mode, excluded }
1228
1316
 
1229
- let channels = [...found.channels]
1230
- let map = numberVoices(channels)
1317
+ // With no live Voice label (COS_MAC_LIVE_VOICES=off, or a capture the live path never
1318
+ // numbered) nothing is restricted and every confident channel is numbered, exactly as 6.92.0.
1319
+ const liveNumbers = entries.flatMap(entry => [liveLabelOf(entry.chunk), ...(Array.isArray(entry.chunk?.segments) ? entry.chunk.segments : []).map(segment => segment?.speaker)])
1320
+ .filter(isVoiceLabel).map(label => Number(String(label).slice(6)))
1321
+ const liveMap = liveNumbers.length ? mapChannelsToLiveVoices(timeline.windows, activity) : null
1322
+ // W3: a channel the live path never proved is numbered above every live number
1323
+ // unless a live turn contradicts it. Words that already carry a live number keep it.
1324
+ const unproven = liveMap
1325
+ ? unclaimedFinalChannels(timeline.windows, activity, found.channels.filter(channel => !liveMap.has(channel))).slice(0, Math.max(0, 99 - Math.max(...liveNumbers)))
1326
+ : []
1327
+ let channels = found.channels.filter(channel => !liveMap || liveMap.has(channel) || unproven.includes(channel))
1328
+ const labelsFor = (kept: number[]) => {
1329
+ if (!liveMap) return numberVoices(kept)
1330
+ const out = new Map<number, string>()
1331
+ let next = Math.max(...liveNumbers)
1332
+ for (const channel of kept) out.set(channel, liveMap.get(channel) ?? voiceLabel(++next))
1333
+ return out
1334
+ }
1335
+ let map = labelsFor(channels)
1231
1336
  let labelled: UnnamedLabelling | null = null
1232
1337
  for (let round = 0; round <= NEMOTRON_CHANNELS && channels.length; round++) {
1233
- map = numberVoices(channels)
1338
+ map = labelsFor(channels)
1234
1339
  const attempt = labelUnnamed(input, mode, map)
1235
1340
  if ('status' in attempt) return { ...attempt, unnamed: true, mode, excluded }
1236
1341
  const inTurns = voicesIn(attempt.next)