@gotcos/glasses-server 6.45.4 → 6.46.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.
@@ -0,0 +1,527 @@
1
+ /** Durable held naming: server-stored preview, per-copy receipts, explicit recovery. */
2
+ import { createHash, randomUUID } from 'node:crypto'
3
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, unlinkSync } from 'node:fs'
4
+ import { dirname, join, resolve } from 'node:path'
5
+ import { dataPath } from './data-dir.js'
6
+ import { durableAtomicWriteFileSync } from './atomic-fs.js'
7
+ import { collectHeldSamples, discardHeldSamples, enrollHeldGroup, HeldGroupError, sampleKey, suggestProfile, previewDiscard, projectHeldEnrollment, type HeldSampleRef } from './held-voice-groups.js'
8
+ import { readVoiceProfiles, rawCosineSimilarity } from './speaker-embeddings.js'
9
+ import { getOwnerSpeakerLabel } from './profile.js'
10
+ import { coherentClusterContaining, VOICE_COHERENCE_FLOOR } from './voice-enrolment-selection.js'
11
+ import { readChunkEmbeddings } from './chunk-embedding-store.js'
12
+ import { findCosOperationsMeetingBySessionId, resolveCosOperationsDir } from './cos-operations-meetings.js'
13
+ import { MeetingStore } from './meeting-store.js'
14
+ import { appendCorrection, closeInterruptedCorrections, pendingCorrections, readCorrections, appliedCorrections, type CorrectionRow } from './meeting-corrections.js'
15
+ import { patchValue, relabelSpeakerPositions, undoSpeakerMarkdown, strippedTranscript, validatedRawMap, type SpeakerPatch } from './meeting-speaker-labels.js'
16
+ import { acquireMeetingSyncLock } from './meeting-sync-lock.js'
17
+
18
+ export const NAMING_CAPABILITIES = {preview:true,apply:true,undo:true,version:1} as const
19
+ const PREVIEW_TTL = 15 * 60 * 1000
20
+ const HIGH_COUNT = 25
21
+ const hash = (value: unknown) => createHash('sha256').update(typeof value === 'string' ? value : JSON.stringify(value)).digest('hex')
22
+ const errorText = (error: unknown) => error instanceof Error ? error.message : String(error)
23
+ const plainRef = ({sessionId,chunkIndex}: HeldSampleRef) => ({sessionId,chunkIndex})
24
+ const foldName = (name: string) => name.normalize('NFKC').toLocaleLowerCase('und')
25
+ export function resolveStoredName(name: string): {name:string;owner:boolean} {
26
+ const stored = readVoiceProfiles().profiles.filter(p=>foldName(p.name) === foldName(name.trim()))
27
+ 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
+ const owner = getOwnerSpeakerLabel()
29
+ 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
+ }
32
+ function voiceVersion(name: string) {
33
+ const path = dataPath('voice-profiles.json')
34
+ 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
+ }
37
+ function evidenceVersion(refs:HeldSampleRef[]) {
38
+ 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'):''})))
39
+ }
40
+ function assertMarkdownWords(before:string,after:string) {
41
+ const clean=(s:string)=>s.replace(/\n?<!-- speaker-labels-newer-than-graph -->\n?/g,'')
42
+ if(strippedTranscript(clean(before))!==strippedTranscript(clean(after)))throw new Error('Transcript word-preservation assertion failed')
43
+ }
44
+ function pruneExpiredPreviews() {
45
+ const dir=dataPath('meeting-corrections')
46
+ if(!existsSync(dir))return
47
+ for(const f of readdirSync(dir).filter(f=>/^preview-[a-f0-9]+\.json$/.test(f)).slice(0,100)){
48
+ try{const p=JSON.parse(readFileSync(join(dir,f),'utf8'));if(Date.parse(p.expiresAt)<Date.now())unlinkSync(join(dir,f))}catch{/* unreadable previews are rejected by their reader */}
49
+ }
50
+ }
51
+ interface CopyPlan {
52
+ copy:'local'|'operations'
53
+ correctionRevision:number
54
+ lifecycleRevision:number
55
+ sidecarHash:string
56
+ markdownHash:string
57
+ rawMap:number[]
58
+ blended:boolean
59
+ }
60
+ interface MeetingPlan {
61
+ sessionId:string
62
+ status:'ready'|'blocked'|'applied'|'failed'|'reverted'
63
+ error?:string
64
+ namedChunks:number[]
65
+ widerChunks:number[]
66
+ labelled:number
67
+ roomRisk:boolean
68
+ widerScores?:Array<{chunkIndex:number;similarity:number;agreeing:number;roomOnly:boolean}>
69
+ playback:Array<{sessionId:string;chunkIndex:number;position:number}>
70
+ copies:CopyPlan[]
71
+ }
72
+ interface StoredPreview {
73
+ previewHash:string
74
+ expiresAt:string
75
+ speaker:string
76
+ owner:boolean
77
+ requiresListening:boolean
78
+ voiceVersion:ReturnType<typeof voiceVersion>
79
+ evidenceHash:string
80
+ eligibleMembers:HeldSampleRef[]
81
+ projectedVoiceHash:string
82
+ plan:ReturnType<typeof enrollHeldGroup>
83
+ members:HeldSampleRef[]
84
+ meetings:MeetingPlan[]
85
+ memberOutcomes:Array<HeldSampleRef & {status:string}>
86
+ }
87
+ interface CopyReceipt {
88
+ copy:'local'|'operations'
89
+ status:'pending'|'sidecar_written'|'applied'|'reverted'|'failed'
90
+ error?:string
91
+ patches:SpeakerPatch[]
92
+ beforeSidecar:string
93
+ afterSidecar:string
94
+ beforeMarkdown:string
95
+ afterMarkdown:string
96
+ blended:boolean
97
+ }
98
+ interface BatchMeeting extends MeetingPlan { intentId:string;receipts:CopyReceipt[] }
99
+ export interface NamingMemberOutcome extends HeldSampleRef {
100
+ status:'applied'|'no_transcript_position'|'skipped'|'failed'|'reverted'|'unknown'
101
+ enrollmentStatus:'enrolled'|'represented'|'not_enrolled'|'unknown'
102
+ labelStatus:'labelled'|'no_transcript_position'|'unchanged'|'failed'|'reverted'|'unknown'
103
+ audioStatus:'deleted'|'retained'|'missing'|'unknown'
104
+ position:number|null
105
+ previewStatus?:string
106
+ reason?:string
107
+ }
108
+ interface BatchRecord {
109
+ batchId:string
110
+ speaker:string
111
+ at:string
112
+ status:'pending'|'applied'|'partial'|'interrupted'|'reverted'
113
+ previewHash:string
114
+ members:HeldSampleRef[]
115
+ requestedMembers?:HeldSampleRef[]
116
+ memberOutcomes?:NamingMemberOutcome[]
117
+ meetings:BatchMeeting[]
118
+ enrolled:number
119
+ profileEmbeddings?:number
120
+ plan?:ReturnType<typeof enrollHeldGroup>
121
+ deleted:number
122
+ deleteMembers?:HeldSampleRef[]
123
+ voiceVersion:ReturnType<typeof voiceVersion>
124
+ lockMode:'sync'|'standalone'
125
+ }
126
+ function recordPath(kind:'preview'|'batch',id:string) {
127
+ if (!/^[a-f0-9-]{16,80}$/.test(id)) throw new HeldGroupError(400,'invalid_handle','Invalid naming handle')
128
+ return dataPath('meeting-corrections',`${kind}-${id}.json`)
129
+ }
130
+ function writeRecord(kind:'preview'|'batch',id:string,value:unknown) {
131
+ if(kind==='batch') {
132
+ const batch=value as BatchRecord
133
+ if(batch.memberOutcomes)batch.memberOutcomes=batchMembers(batch)
134
+ }
135
+ const path=recordPath(kind,id)
136
+ mkdirSync(dirname(path),{recursive:true,mode:0o700})
137
+ durableAtomicWriteFileSync(path,JSON.stringify(value,null,2)+'\n',{mode:0o600})
138
+ }
139
+ function previewMemberStatus(p:StoredPreview,ref:HeldSampleRef):string {
140
+ const key=sampleKey(ref)
141
+ return p.memberOutcomes.find(o=>sampleKey(o)===key)?.status
142
+ ??(p.plan.leftBehind.some(o=>sampleKey(o)===key)?'left_held':p.plan.notReady.some(o=>sampleKey(o)===key)?'not_ready':'missing')
143
+ }
144
+ function initialMemberOutcomes(p:StoredPreview):NamingMemberOutcome[] {
145
+ return p.members.map(ref=>{
146
+ const previewStatus=previewMemberStatus(p,ref),eligible=['ready','no_transcript_position'].includes(previewStatus)
147
+ const position=p.meetings.find(m=>m.sessionId===ref.sessionId)?.copies[0]?.rawMap.indexOf(ref.chunkIndex)??-1
148
+ return {...plainRef(ref),previewStatus,position:position<0?null:position,status:eligible?'unknown':'skipped',enrollmentStatus:eligible?'unknown':'not_enrolled',labelStatus:'unchanged',audioStatus:previewStatus==='missing'?'missing':'retained',...(!eligible?{reason:previewStatus}:{})}
149
+ })
150
+ }
151
+ /** Preserve exact enrollment/deletion receipts; derive label completion from
152
+ * durable meeting receipts, including partial writes and later Undo retries. */
153
+ function batchMembers(batch:BatchRecord):NamingMemberOutcome[] {
154
+ if(!batch.memberOutcomes)return(batch.requestedMembers??batch.members).map(ref=>({...plainRef(ref),status:'unknown',enrollmentStatus:'unknown',labelStatus:'unknown',audioStatus:'unknown',position:null,reason:'legacy_receipt_has_no_member_outcome'}))
155
+ return batch.memberOutcomes.map(member=>{
156
+ if(!['ready','no_transcript_position'].includes(member.previewStatus??''))return member
157
+ const meeting=batch.meetings.find(m=>m.sessionId===member.sessionId)
158
+ if(!meeting)return member
159
+ if(meeting.status==='reverted')return{...member,status:'reverted',labelStatus:member.position===null?'no_transcript_position':'reverted',reason:undefined}
160
+ if(meeting.status==='failed'||meeting.error||meeting.receipts.some(r=>r.status==='failed'))return{...member,status:'failed',labelStatus:'failed',reason:meeting.error??'One or more meeting copies failed'}
161
+ if(meeting.status==='applied')return{...member,status:member.position===null?'no_transcript_position':'applied',labelStatus:member.position===null?'no_transcript_position':'labelled',reason:member.enrollmentStatus==='not_enrolled'?'Selected voice sample was refused by the profile store':undefined}
162
+ return{...member,status:'unknown',labelStatus:meeting.receipts.length?'unknown':'unchanged'}
163
+ })
164
+ }
165
+ function readRecord<T>(kind:'preview'|'batch',id:string):T {
166
+ try { return JSON.parse(readFileSync(recordPath(kind,id),'utf8')) as T }
167
+ catch (e) {if(e instanceof HeldGroupError) throw e;throw new HeldGroupError(409,'preview_expired','This naming preview is unavailable. Preview again.')}
168
+ }
169
+ function assertFile(path:string) {
170
+ const stat = lstatSync(path)
171
+ if (!stat.isFile() || stat.isSymbolicLink() || (stat.mode & 0o222) === 0) throw new Error('Meeting copy is missing, unsafe, or read-only')
172
+ if (/ \d+(\.[A-Za-z0-9-]+)*\.(md|json)$/.test(path)) throw new Error('iCloud conflict copy is not canonical')
173
+ }
174
+ let sessionBusy:(sessionId:string)=>boolean=()=>false
175
+ export function setNamingSessionLiveness(check:(sessionId:string)=>boolean):void {sessionBusy=check}
176
+ interface ResolvedCopy {copy:'local'|'operations';sidecarPath:string;meetingPath:string;doc:Record<string,any>;raw:string;markdown:string;blended:boolean}
177
+ function checkedRevision(value:unknown):number {
178
+ if(value===undefined||value===null)return 0
179
+ if(typeof value!=='number'||!Number.isSafeInteger(value)||value<0)throw new Error('Invalid meeting revision')
180
+ return value
181
+ }
182
+ function resolveCopies(sessionId:string):ResolvedCopy[] {
183
+ if(sessionBusy(sessionId)||existsSync(dataPath('meeting-finalization-jobs',`${sessionId}.json`)))throw new Error('Meeting is recording or finalizing; retry after it finishes')
184
+ const local = new MeetingStore().findBySessionId(sessionId)
185
+ if (!local) throw new Error('Local meeting copy is missing')
186
+ const paths:Array<{copy:'local'|'operations';sidecarPath:string;meetingPath:string}> = [{copy:'local',sidecarPath:local.sidecarPath,meetingPath:local.filepath}]
187
+ const operations = resolveCosOperationsDir()
188
+ if (operations) {
189
+ const found = findCosOperationsMeetingBySessionId(sessionId)
190
+ if (!found) throw new Error('Operations meeting copy is missing; retry after sync')
191
+ paths.push({copy:'operations',sidecarPath:found.sidecarPath,meetingPath:found.meetingPath})
192
+ } else if (process.env.COS_OPERATIONS_DIR?.trim() || process.env.COS_SCRIPTS_DIR?.trim()) {
193
+ // An explicitly configured but unavailable iCloud root is not standalone.
194
+ throw new Error('Configured operations folder is unavailable; retry after sync')
195
+ }
196
+ return paths.map(p=>{
197
+ assertFile(p.sidecarPath)
198
+ const raw=readFileSync(p.sidecarPath,'utf8'),doc=JSON.parse(raw)
199
+ if(doc.sessionId!==sessionId) throw new Error('Meeting identity changed')
200
+ checkedRevision(doc.correctionRevision);checkedRevision(doc.lifecycleRevision)
201
+ let meetingPath=p.meetingPath,blended=false
202
+ if(!existsSync(meetingPath) && p.copy==='operations' && operations) {
203
+ const parent = Array.isArray(doc.blended_into)?doc.blended_into[0]:doc.claimed_parent
204
+ if(typeof parent!=='string') throw new Error('Operations markdown is missing')
205
+ meetingPath=resolve(operations,parent)
206
+ if(!meetingPath.startsWith(realpathSync(operations)+'/')) throw new Error('Unsafe blended parent path')
207
+ blended=true
208
+ }
209
+ assertFile(meetingPath)
210
+ const markdown=readFileSync(meetingPath,'utf8')
211
+ if ((p.copy === 'local' && doc.finalizationState && doc.finalizationState !== 'complete') || doc.finalizationRequired === true || ['recording','saving','finalizing'].includes(doc.lifecycleState) || ['pending','running','queued'].includes(doc.enrichmentState) || doc.syncState === 'claimed') throw new Error('Meeting is recording, finalizing, or pending enrichment; retry after it finishes')
212
+ if(/<!-- g2-hq-state: pending -->/.test(markdown)||['queued','running'].includes(doc.hqState)) throw new Error('HQ transcription is running; preview again after it finishes')
213
+ return {...p,meetingPath,doc,raw,markdown,blended}
214
+ })
215
+ }
216
+ const snapshot=(c:ResolvedCopy):CopyPlan=>({copy:c.copy,correctionRevision:Number(c.doc.correctionRevision??0),lifecycleRevision:Number(c.doc.lifecycleRevision??0),sidecarHash:hash(c.raw),markdownHash:hash(c.markdown),rawMap:validatedRawMap(c.doc),blended:c.blended})
217
+ function previewView(p:StoredPreview) {
218
+ return {success:true,kind:'preview',...p.plan,dryRun:true,previewHash:p.previewHash,expiresAt:p.expiresAt,speaker:p.speaker,owner:p.owner,ownerWarning:p.owner?'This is the device owner. Listen and acknowledge before applying.':undefined,requiresListening:p.requiresListening,meetings:p.meetings,members:p.members.map(r=>({...r,status:previewMemberStatus(p,r)})),namingCapabilities:NAMING_CAPABILITIES,message:`Preview: ${p.plan.coherent} held samples; ${p.meetings.reduce((n,m)=>n+m.labelled,0)} transcript positions. ${p.requiresListening?'Listen to the wider matches before Apply.':''}`}
219
+ }
220
+ export function previewHeldNaming(name:string,members:HeldSampleRef[]) {
221
+ pruneExpiredPreviews()
222
+ const resolved=resolveStoredName(name)
223
+ const plan=enrollHeldGroup(resolved.name,members,{dryRun:true})
224
+ const core=plan.coreMembers??[]
225
+ const only=new Map<string,Set<number>>()
226
+ for(const r of core){const set=only.get(r.sessionId)??new Set<number>();set.add(r.chunkIndex);only.set(r.sessionId,set)}
227
+ const held=collectHeldSamples({only,budgetMs:2000}).samples
228
+ const vectors=new Map(held.map(s=>[sampleKey(s),s.embedding]))
229
+ if(resolved.owner){
230
+ const owner=readVoiceProfiles().profiles.find(p=>p.name===resolved.name)
231
+ if(!owner || held.some(s=>!owner.embeddings.some(e=>rawCosineSimilarity(s.embedding,new Float32Array(e))>=0.65))) throw new HeldGroupError(422,'owner_similarity_low','These samples do not reach the owner verification floor (0.65). Nothing changed.')
232
+ }
233
+ const meetings:MeetingPlan[]=[], memberOutcomes:StoredPreview['memberOutcomes']=[]
234
+ const resolvedMeetings=new Map<string,ResolvedCopy[]>()
235
+ const unidentified=(copies:ResolvedCopy[],position:number)=>copies.every(c=>/^(ext|unknown|unidentified(?:\s+\d+)?|speaker\s*\d+)$/i.test(String(c.doc.chunks[position]?.speaker??'')))
236
+ // First establish admissible named evidence across EVERY copy. A skipped
237
+ // member never participates in either the enrollment projection or clustering.
238
+ for(const sessionId of [...new Set(core.map(s=>s.sessionId))].sort()) {
239
+ const refs=core.filter(s=>s.sessionId===sessionId)
240
+ const meeting:MeetingPlan={sessionId,status:'ready',namedChunks:[],widerChunks:[],labelled:0,roomRisk:false,playback:[],copies:[]}
241
+ try {
242
+ if(pendingCorrections(sessionId).length) throw new Error('An interrupted correction needs review')
243
+ const copies=resolveCopies(sessionId)
244
+ meeting.copies=copies.map(snapshot)
245
+ const raw=meeting.copies[0].rawMap
246
+ if(meeting.copies.some(c=>JSON.stringify(c.rawMap)!==JSON.stringify(raw))) throw new Error('Meeting copies disagree on raw chunk mapping')
247
+ if(copies.some(c=>hash(c.doc.chunks.map((row:any)=>row.speaker))!==hash(copies[0].doc.chunks.map((row:any)=>row.speaker))))throw new Error('Meeting copies disagree on speaker labels')
248
+ for(const ref of refs) memberOutcomes.push({...ref,status:raw.includes(ref.chunkIndex)?unidentified(copies,raw.indexOf(ref.chunkIndex))?'ready':'no_longer_Ext':'no_transcript_position'})
249
+ meeting.namedChunks=refs.map(r=>raw.indexOf(r.chunkIndex)).filter(i=>i>=0&&unidentified(copies,i))
250
+ meeting.labelled=meeting.namedChunks.length
251
+ // Prove the named edit before admitting the meeting as training evidence.
252
+ for(const c of copies)if(meeting.labelled)relabelSpeakerPositions(c.doc,c.blended?'':c.markdown,meeting.namedChunks,resolved.name)
253
+ resolvedMeetings.set(sessionId,copies)
254
+ }catch(e){meeting.status='blocked';meeting.error=errorText(e);meeting.labelled=0;for(const ref of refs){const prior=memberOutcomes.find(o=>sampleKey(o)===sampleKey(ref));if(prior)prior.status='blocked';else memberOutcomes.push({...ref,status:'blocked'})}}
255
+ meetings.push(meeting)
256
+ }
257
+ const eligibleMembers=core.filter(r=>['ready','no_transcript_position'].includes(memberOutcomes.find(o=>sampleKey(o)===sampleKey(r))?.status??''))
258
+ const projection=eligibleMembers.length?projectHeldEnrollment(resolved.name,eligibleMembers):null
259
+ const updated=projection?.profiles??readVoiceProfiles().profiles
260
+ const projectedVoiceHash=hash(updated.map(p=>({name:p.name,embeddings:p.embeddings,sources:p.sources})))
261
+ const augmented=updated.find(p=>p.name===resolved.name)
262
+ const storedTarget=readVoiceProfiles().profiles.find(p=>p.name===resolved.name)
263
+ for(const meeting of meetings){
264
+ if(meeting.status!=='ready')continue
265
+ const sessionId=meeting.sessionId,copies=resolvedMeetings.get(sessionId)!,raw=meeting.copies[0].rawMap
266
+ const refs=eligibleMembers.filter(s=>s.sessionId===sessionId)
267
+ if(!refs.length)continue
268
+ const bank=[...new Map(readChunkEmbeddings(sessionId).rows.map(row=>[row.i,row])).values()]
269
+ const seeds=refs.map(r=>vectors.get(sampleKey(r))).filter((v):v is Float32Array=>!!v)
270
+ const seedRaw=new Set(refs.map(r=>r.chunkIndex))
271
+ const candidates=bank.filter(r=>raw.includes(r.i)&&!seedRaw.has(r.i)&&unidentified(copies,raw.indexOf(r.i)))
272
+ .filter(r=>!(storedTarget?.embeddings??[]).some(e=>rawCosineSimilarity(r.embedding,new Float32Array(e))>=0.999))
273
+ const matches=candidates.flatMap(r=>{
274
+ // Ordinary suggestions exclude the owner; deliberate owner naming keeps
275
+ // the same scorer and adds the owner's stricter 0.65 verification gate.
276
+ const suggestion=suggestProfile(r.embedding,updated,{ownerLabel:resolved.owner?undefined:getOwnerSpeakerLabel()})
277
+ if(suggestion?.name!==resolved.name || (resolved.owner&&suggestion.similarity<0.65))return[]
278
+ const agreeing=(augmented?.embeddings??[]).flatMap((e,i)=>rawCosineSimilarity(r.embedding,new Float32Array(e))>=VOICE_COHERENCE_FLOOR?[augmented?.sources?.[i]??'unknown']:[])
279
+ const roomOnly=agreeing.length>0&&agreeing.every(source=>source.endsWith(`:${sessionId}`))
280
+ return[{...r,suggestion,roomOnly}]
281
+ })
282
+ const cluster=coherentClusterContaining(seeds,matches.map(r=>r.embedding),VOICE_COHERENCE_FLOOR)
283
+ const wider=cluster.members.filter(i=>i>=seeds.length).map(i=>matches[i-seeds.length])
284
+ meeting.widerChunks=wider.map(r=>raw.indexOf(r.i))
285
+ meeting.widerScores=wider.map(r=>({chunkIndex:r.i,similarity:r.suggestion.similarity,agreeing:r.suggestion.agreeing,roomOnly:r.roomOnly}))
286
+ meeting.labelled=new Set([...meeting.namedChunks,...meeting.widerChunks]).size
287
+ meeting.roomRisk=wider.some(r=>r.roomOnly)
288
+ meeting.playback=[...new Set([...meeting.namedChunks,...meeting.widerChunks])].map(position=>({sessionId,position,chunkIndex:raw[position]}))
289
+ for(const c of copies)if(meeting.labelled)relabelSpeakerPositions(c.doc,c.blended?'':c.markdown,[...meeting.namedChunks,...meeting.widerChunks],resolved.name)
290
+ }
291
+ const version=voiceVersion(resolved.name)
292
+ const evidenceHash=evidenceVersion(members)
293
+ const payload={evidenceHash,eligibleMembers,projectedVoiceHash,selectedMembers:projection?.selectedMembers??[],members:members.map(plainRef),resolvedName:resolved.name,perMeeting:meetings.map(m=>({sessionId:m.sessionId,perCopyRevs:m.copies,chunkLists:[m.namedChunks,m.widerChunks]})),voiceVersion:version,cluster:core}
294
+ const previewHash=hash(payload)
295
+ const preview:StoredPreview={previewHash,expiresAt:new Date(Date.now()+PREVIEW_TTL).toISOString(),speaker:resolved.name,owner:resolved.owner,requiresListening:meetings.some(m=>m.widerChunks.length>=HIGH_COUNT),voiceVersion:version,evidenceHash,eligibleMembers,projectedVoiceHash,plan,members:members.map(plainRef),meetings,memberOutcomes}
296
+ writeRecord('preview',previewHash,preview)
297
+ return previewView(preview)
298
+ }
299
+ function batchView(batch:BatchRecord) {
300
+ return {success:true,...batch.plan,dryRun:false,kind:batch.status==='reverted'?'undone':'applied',batchId:batch.batchId,undoHandle:batch.batchId,speaker:batch.speaker,status:batch.status,partial:batch.status==='partial'||batch.status==='interrupted',members:batchMembers(batch),meetings:batch.meetings.map(m=>({...m,labelsNewerThanGraph:m.receipts.some(r=>['applied','sidecar_written','reverted'].includes(r.status)),receipts:m.receipts.map(r=>({copy:r.copy,status:r.status,error:r.error}))})),enrolled:batch.enrolled,profileEmbeddings:batch.profileEmbeddings??batch.plan?.profileEmbeddings??0,deleted:batch.deleted,lockMode:batch.lockMode,namingCapabilities:NAMING_CAPABILITIES,message:'Voice samples kept for training. Search refreshes at up to 10 meetings per sync; graph labels may lag.'}
301
+ }
302
+ let mutationBusy=false
303
+ export async function applyHeldNaming(name:string,members:HeldSampleRef[],previewHash:string,options:{confirm?:boolean;ownerAck?:boolean;listened?:boolean}={}) {
304
+ if(!options.confirm) throw new HeldGroupError(400,'confirmation_required','Preview first, then confirm Apply.')
305
+ const preview=readRecord<StoredPreview>('preview',previewHash)
306
+ if(resolveStoredName(name).name!==preview.speaker||resolveStoredName(name).owner!==preview.owner||hash(members.map(plainRef))!==hash(preview.members)) throw new HeldGroupError(409,'preview_mismatch','The name or samples changed. Preview again.')
307
+ if(preview.owner&&!options.ownerAck) throw new HeldGroupError(400,'owner_confirmation_required','Acknowledge that these samples are the device owner.')
308
+ const prior=listNamingBatches().find(b=>b.previewHash===previewHash)
309
+ if(prior) {
310
+ if(prior.deleteMembers) {const missing=new Set(previewDiscard(prior.deleteMembers).missing.map(sampleKey));prior.deleted=missing.size;for(const member of prior.memberOutcomes??[])if(missing.has(sampleKey(member)))member.audioStatus='deleted';writeRecord('batch',prior.batchId,prior)}
311
+ return batchView(prior)
312
+ }
313
+ if(Date.now()>Date.parse(preview.expiresAt)) throw new HeldGroupError(409,'preview_expired','The preview expired. Preview again.')
314
+ if(preview.requiresListening&&!options.listened) throw new HeldGroupError(400,'listening_required','Listen to the wider matches before applying this many labels.')
315
+ if(mutationBusy) throw new HeldGroupError(409,'naming_running','Another naming is running; retry.')
316
+ mutationBusy=true
317
+ let lock:Awaited<ReturnType<typeof acquireMeetingSyncLock>>|undefined
318
+ try {
319
+ try{lock=await acquireMeetingSyncLock()}catch(e){throw new HeldGroupError(409,'sync_running',errorText(e))}
320
+ if(hash(voiceVersion(preview.speaker))!==hash(preview.voiceVersion)) throw new HeldGroupError(409,'voice_version_changed','Voice profiles changed. Preview again.')
321
+ if(evidenceVersion(preview.members)!==preview.evidenceHash)throw new HeldGroupError(409,'evidence_changed','Voice evidence changed. Preview again.')
322
+ // Re-read held membership, not just the unchanged profile mtime.
323
+ const current=enrollHeldGroup(preview.speaker,preview.members,{dryRun:true})
324
+ if(hash(current.coreMembers)!==hash(preview.plan.coreMembers)) throw new HeldGroupError(409,'members_changed','Held samples changed. Preview again.')
325
+ const batch:BatchRecord={batchId:randomUUID(),speaker:preview.speaker,at:new Date().toISOString(),status:'pending',previewHash,members:preview.plan.coreMembers??[],requestedMembers:preview.members,memberOutcomes:initialMemberOutcomes(preview),meetings:preview.meetings.map(m=>({...m,intentId:randomUUID(),receipts:[]})),enrolled:0,deleted:0,voiceVersion:preview.voiceVersion,lockMode:lock.mode,plan:preview.plan}
326
+ // Validate all copies at write-time before changing voice store or recording intents.
327
+ for(const meeting of batch.meetings){
328
+ if(meeting.status!=='ready')continue
329
+ try{const copies=resolveCopies(meeting.sessionId);if(hash(copies.map(snapshot))!==hash(meeting.copies))throw new Error('Meeting changed since preview');if(pendingCorrections(meeting.sessionId).length)throw new Error('Another correction is pending')}
330
+ catch(e){meeting.status='failed';meeting.error=errorText(e)}
331
+ }
332
+ const ready=new Set(batch.meetings.filter(m=>m.status==='ready').map(m=>m.sessionId))
333
+ const eligible=preview.eligibleMembers.filter(r=>ready.has(r.sessionId))
334
+ if(preview.meetings.some(m=>m.widerChunks.length>0)&&hash(eligible)!==hash(preview.eligibleMembers))throw new HeldGroupError(409,'eligible_evidence_changed','A meeting providing voice evidence changed. Preview again before wider labels apply.')
335
+ if(eligible.length){const projected=projectHeldEnrollment(preview.speaker,eligible);if(preview.meetings.some(m=>m.widerChunks.length>0)&&hash(projected.profiles.map(p=>({name:p.name,embeddings:p.embeddings,sources:p.sources})))!==preview.projectedVoiceHash)throw new HeldGroupError(409,'projected_voice_changed','The projected voice changed. Preview again.')}
336
+ if(!eligible.length) throw new HeldGroupError(409,'meetings_changed','No meeting can safely apply. Preview again.',{meetings:batch.meetings})
337
+ writeRecord('batch',batch.batchId,batch)
338
+ let enrollment:ReturnType<typeof enrollHeldGroup>
339
+ try{enrollment=enrollHeldGroup(batch.speaker,eligible,{deleteAudio:false})}
340
+ catch(e){
341
+ batch.status='partial'
342
+ // A known pre-write refusal differs from an interrupted/throwing store,
343
+ // whose individual enrollment result cannot safely be reconstructed.
344
+ if(e instanceof HeldGroupError&&['nothing_enrolled','speaker_model_unavailable','no_samples','incoherent'].includes(e.reason))for(const member of batch.memberOutcomes??[])if(member.enrollmentStatus==='unknown')member.enrollmentStatus='not_enrolled'
345
+ for(const m of batch.meetings)if(m.status==='ready'){m.status='failed';m.error=errorText(e)}
346
+ writeRecord('batch',batch.batchId,batch);throw e
347
+ }
348
+ batch.enrolled=enrollment.enrolled
349
+ const accepted=new Set(enrollment.enrolledMembers?.map(sampleKey)),rejected=new Set(enrollment.rejectedMembers?.map(sampleKey)),represented=new Set(enrollment.coreMembers?.map(sampleKey))
350
+ for(const member of batch.memberOutcomes??[]){const key=sampleKey(member);if(accepted.has(key))member.enrollmentStatus='enrolled';else if(rejected.has(key))member.enrollmentStatus='not_enrolled';else if(represented.has(key))member.enrollmentStatus='represented';else if(member.enrollmentStatus==='unknown')member.enrollmentStatus='not_enrolled'}
351
+ batch.profileEmbeddings=readVoiceProfiles().profiles.find(p=>p.name===batch.speaker)?.embeddings.length??0
352
+ writeRecord('batch',batch.batchId,batch)
353
+ for(const meeting of batch.meetings){
354
+ if(meeting.status!=='ready')continue
355
+ const positions=[...new Set([...meeting.namedChunks,...meeting.widerChunks])].sort((a,b)=>a-b)
356
+ const row:CorrectionRow={id:meeting.intentId,phase:'intent',at:batch.at,from:'held samples',to:batch.speaker,chunks:positions,scope:'meeting',source:`ext-group:${meeting.sessionId}`,batchId:batch.batchId}
357
+ try{
358
+ const copies=resolveCopies(meeting.sessionId)
359
+ if(hash(copies.map(snapshot))!==hash(meeting.copies))throw new Error('Meeting changed at write-time')
360
+ if(meeting.widerChunks.length){
361
+ const bank=new Map(readChunkEmbeddings(meeting.sessionId).rows.map(r=>[r.i,r.embedding]))
362
+ const actual=readVoiceProfiles().profiles
363
+ for(const position of meeting.widerChunks){const emb=bank.get(meeting.copies[0].rawMap[position]);const suggestion=emb?suggestProfile(emb,actual,{ownerLabel:preview.owner?undefined:getOwnerSpeakerLabel()}):null;if(suggestion?.name!==batch.speaker||(preview.owner&&suggestion.similarity<0.65))throw new Error('Enrolled voice no longer supports a wider match; preview again')}
364
+ }
365
+ for(const c of copies){
366
+ const edit=relabelSpeakerPositions(c.doc,c.blended?'':c.markdown,positions,batch.speaker,batch.batchId)
367
+ meeting.receipts.push({copy:c.copy,status:'pending',patches:edit.patches,beforeSidecar:c.raw,afterSidecar:JSON.stringify(edit.doc,null,2)+'\n',beforeMarkdown:c.markdown,afterMarkdown:c.blended?c.markdown+(c.markdown.includes('<!-- speaker-labels-newer-than-graph -->')?'':'\n<!-- speaker-labels-newer-than-graph -->\n'):edit.markdown,blended:c.blended})
368
+ }
369
+ row.priorLabels=Object.fromEntries(positions.map(i=>[String(i),String(copies[0].doc.chunks[i].speaker??'')]))
370
+ writeRecord('batch',batch.batchId,batch)
371
+ if(!appendCorrection(meeting.sessionId,row))throw new Error('Cannot write correction intent')
372
+ for(const receipt of meeting.receipts){
373
+ // Resolve by sessionId AGAIN: preview paths and earlier paths are never authority.
374
+ const current=resolveCopies(meeting.sessionId).find(c=>c.copy===receipt.copy)!
375
+ if(current.raw!==receipt.beforeSidecar||current.markdown!==receipt.beforeMarkdown)throw new Error('Meeting changed immediately before copy write')
376
+ durableAtomicWriteFileSync(current.sidecarPath,receipt.afterSidecar,{mode:0o600})
377
+ receipt.status='sidecar_written';writeRecord('batch',batch.batchId,batch)
378
+ assertMarkdownWords(receipt.beforeMarkdown,receipt.afterMarkdown)
379
+ durableAtomicWriteFileSync(current.meetingPath,receipt.afterMarkdown,{mode:0o600})
380
+ receipt.status='applied';writeRecord('batch',batch.batchId,batch)
381
+ }
382
+ if(!appendCorrection(meeting.sessionId,{...row,phase:'applied',proseStale:true}))throw new Error('Cannot record applied correction')
383
+ if(positions.length&&!appendCorrection(meeting.sessionId,{...row,phase:'confirmed-chunks'}))throw new Error('Cannot record chunk confirmation')
384
+ meeting.status='applied'
385
+ }catch(e){meeting.status='failed';meeting.error=errorText(e);appendCorrection(meeting.sessionId,{...row,phase:'failed',error:meeting.error})}
386
+ writeRecord('batch',batch.batchId,batch)
387
+ }
388
+ batch.status=batch.meetings.every(m=>m.status==='applied')?'applied':'partial'
389
+ // This receipt is durable BEFORE deleting any audio, including a crash midway in deletion.
390
+ writeRecord('batch',batch.batchId,batch)
391
+ const applied=new Set(batch.meetings.filter(m=>m.status==='applied').map(m=>m.sessionId))
392
+ batch.deleteMembers=(enrollment.coreMembers??[]).filter(r=>applied.has(r.sessionId))
393
+ writeRecord('batch',batch.batchId,batch)
394
+ const removed=new Set(discardHeldSamples(batch.deleteMembers).removed.map(sampleKey))
395
+ batch.deleted=removed.size
396
+ for(const member of batch.memberOutcomes??[])if(removed.has(sampleKey(member)))member.audioStatus='deleted'
397
+ writeRecord('batch',batch.batchId,batch)
398
+ return batchView(batch)
399
+ }finally{lock?.release();mutationBusy=false}
400
+ }
401
+
402
+ export function listNamingBatches():BatchRecord[]{
403
+ const dir=dataPath('meeting-corrections')
404
+ if(!existsSync(dir))return[]
405
+ return readdirSync(dir).filter(f=>/^batch-[a-f0-9-]+\.json$/.test(f)).flatMap(f=>{try{return[JSON.parse(readFileSync(join(dir,f),'utf8')) as BatchRecord]}catch{return[]}}).sort((a,b)=>b.at.localeCompare(a.at))
406
+ }
407
+ export function namingBatchList(){return{namingCapabilities:NAMING_CAPABILITIES,batches:listNamingBatches().map(b=>({...batchView(b),previewHash:b.previewHash}))}}
408
+ export function recoverInterruptedNaming():number {
409
+ const closed=closeInterruptedCorrections()
410
+ for(const batch of listNamingBatches())if(batch.status==='pending'){batch.status='interrupted';for(const m of batch.meetings)if(m.status==='ready'){m.status='failed';m.error='Interrupted naming — review to resume or revert'}writeRecord('batch',batch.batchId,batch)}
411
+ return closed
412
+ }
413
+ export function resumeHeldNaming(batchId:string){
414
+ const batch=readRecord<BatchRecord>('batch',batchId)
415
+ const pending=batch.members.filter(r=>batch.meetings.find(m=>m.sessionId===r.sessionId)?.status!=='applied')
416
+ if(batch.meetings.some(m=>m.receipts.some(r=>r.status==='sidecar_written'||r.status==='pending'&&r.beforeSidecar!==r.afterSidecar)))throw new HeldGroupError(409,'revert_required','This interrupted naming may have written a copy. Undo it before previewing again.')
417
+ return previewHeldNaming(batch.speaker,pending)
418
+ }
419
+ export async function undoHeldNaming(batchId:string,confirm:boolean){
420
+ if(!confirm)throw new HeldGroupError(400,'confirmation_required','Confirm Undo to restore these labels. Enrolled voice samples stay kept.')
421
+ const batch=readRecord<BatchRecord>('batch',batchId)
422
+ if(batch.status==='reverted')return{...batchView(batch),samplesKept:true,audioRestored:false}
423
+ if(mutationBusy)throw new HeldGroupError(409,'naming_running','Another naming is running; retry.')
424
+ mutationBusy=true
425
+ let lock:Awaited<ReturnType<typeof acquireMeetingSyncLock>>|undefined
426
+ try{
427
+ try{lock=await acquireMeetingSyncLock()}catch(e){throw new HeldGroupError(409,'sync_running',errorText(e))}
428
+ for(const meeting of batch.meetings){
429
+ if(!meeting.receipts.length)continue
430
+ const undoId=randomUUID(),row:CorrectionRow={id:undoId,phase:'intent',at:new Date().toISOString(),from:batch.speaker,to:'prior labels',chunks:[...meeting.namedChunks,...meeting.widerChunks],scope:'meeting',batchId:batch.batchId,source:'undo'}
431
+ try{
432
+ const later=appliedCorrections(meeting.sessionId).filter(r=>r.batchId!==batchId&&r.at>=batch.at)
433
+ if(later.some(r=>r.chunks.length===0||r.chunks.some(i=>row.chunks.includes(i))))throw new Error('A later batch touched these positions; undo it first')
434
+ if(!appendCorrection(meeting.sessionId,row))throw new Error('Cannot record undo intent')
435
+ for(const receipt of meeting.receipts){
436
+ if(receipt.status==='reverted')continue
437
+ try{
438
+ const c=resolveCopies(meeting.sessionId).find(c=>c.copy===receipt.copy)
439
+ if(!c)throw new Error('Meeting copy missing')
440
+ const doc=structuredClone(c.doc)
441
+ const before=JSON.parse(receipt.beforeSidecar)
442
+ if(c.raw!==receipt.beforeSidecar){
443
+ for(const patch of [...receipt.patches].reverse())patchValue(doc,patch,true)
444
+ doc.speakers=[...new Set(doc.chunks.map((ch:any)=>ch.speaker).filter(Boolean))]
445
+ doc.correctionRevision=Number(doc.correctionRevision??0)+1
446
+ doc.labelsNewerThanGraph=true
447
+ }
448
+ const md=receipt.blended?(c.markdown.includes('<!-- speaker-labels-newer-than-graph -->')?c.markdown:c.markdown+'\n<!-- speaker-labels-newer-than-graph -->\n'):undoSpeakerMarkdown(receipt.beforeMarkdown,receipt.afterMarkdown,c.markdown,doc.speakers??[])
449
+ const clean=(s:string)=>s.replace(/\n?<!-- speaker-labels-newer-than-graph -->\n?/g,'')
450
+ if(strippedTranscript(clean(md))!==strippedTranscript(clean(c.markdown)))throw new Error('Undo word-preservation assertion failed')
451
+ assertFile(c.sidecarPath);assertFile(c.meetingPath)
452
+ durableAtomicWriteFileSync(c.sidecarPath,JSON.stringify(c.raw===receipt.beforeSidecar?before:doc,null,2)+'\n',{mode:0o600})
453
+ durableAtomicWriteFileSync(c.meetingPath,md,{mode:0o600})
454
+ receipt.status='reverted';delete receipt.error;writeRecord('batch',batch.batchId,batch)
455
+ }catch(e){receipt.status='failed';receipt.error=errorText(e)}
456
+ }
457
+ if(meeting.receipts.every(r=>r.status==='reverted')){if(!appendCorrection(meeting.sessionId,{...row,phase:'reverted'}))throw new Error('Cannot record undo outcome');meeting.status='reverted';delete meeting.error}
458
+ else throw new Error('One or more meeting copies could not be restored')
459
+ }catch(e){meeting.error=errorText(e);appendCorrection(meeting.sessionId,{...row,phase:'failed',error:meeting.error})}
460
+ writeRecord('batch',batch.batchId,batch)
461
+ }
462
+ batch.status=batch.meetings.filter(m=>m.receipts.length).every(m=>m.status==='reverted')?'reverted':'partial'
463
+ writeRecord('batch',batch.batchId,batch)
464
+ return{...batchView(batch),kind:'undone',samplesKept:true,audioRestored:false}
465
+ }finally{lock?.release();mutationBusy=false}
466
+ }
467
+
468
+ /** Profile merge fan-out uses the exact correction primitive and receipts, with
469
+ * enrolment/audio deletion off. Dry run neither records a preview nor writes a ledger. */
470
+ 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}> {
471
+ if(options.operationsDir && resolveCosOperationsDir()!==realpathSync(options.operationsDir)) throw new Error('Operations root differs from the requested fan-out root')
472
+ if(from===to||!from.trim()||!to.trim())return{copies:[]}
473
+ if(options.apply&&mutationBusy)throw new Error('Another naming is running; retry')
474
+ let lock:Awaited<ReturnType<typeof acquireMeetingSyncLock>>|undefined
475
+ let batch:BatchRecord|undefined
476
+ let row:CorrectionRow|undefined
477
+ if(options.apply)mutationBusy=true
478
+ try{
479
+ if(options.apply)lock=await acquireMeetingSyncLock()
480
+ if(pendingCorrections(sessionId).length)throw new Error('An interrupted correction needs review')
481
+ const copies=resolveCopies(sessionId)
482
+ const operations=copies.find(c=>c.copy==='operations')
483
+ if(options.expectedSidecarPath && (!operations || realpathSync(operations.sidecarPath)!==realpathSync(options.expectedSidecarPath)))throw new Error('Session resolves to a different sidecar; duplicate or archived copy')
484
+ const maps=copies.map(c=>validatedRawMap(c.doc))
485
+ if(maps.some(map=>hash(map)!==hash(maps[0])))throw new Error('Meeting copies disagree on raw mapping')
486
+ const positions:number[]=copies[0].doc.chunks.flatMap((c:any,i:number)=>c.speaker===from?[i]:[])
487
+ // Repair historical chunks-only renames too: HQ words still carry `from`
488
+ // even when the compacted chunk already says `to` (the 6.27.10 trap).
489
+ for(const c of copies)for(const segment of c.doc.batchSegments??[])for(const word of segment.speakerWords??[]){
490
+ if(word.speaker!==from)continue
491
+ const absolute=Number(segment.startElapsed??0)+Number(word.start)*1000
492
+ let position=-1,distance=Infinity
493
+ const raw=validatedRawMap(c.doc)
494
+ 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}})
495
+ if(distance>3500||position<0)throw new Error('Old HQ speaker has no safe raw chunk mapping')
496
+ if(![from,to].includes(c.doc.chunks[position].speaker))throw new Error('HQ speaker conflicts with an unrelated named chunk')
497
+ if(!positions.includes(position))positions.push(position)
498
+ }
499
+ positions.sort((a,b)=>a-b)
500
+ if(!positions.length)return{copies:[]}
501
+ 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')
502
+ const batchId=randomUUID()
503
+ const edits=copies.map(c=>relabelSpeakerPositions(c.doc,c.blended?'':c.markdown,positions,to,batchId))
504
+ 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}))
505
+ if(!options.apply)return{copies:results}
506
+ row={id:randomUUID(),phase:'intent',at:new Date().toISOString(),from,to,chunks:positions,scope:'meeting',batchId,source:'profile-merge',priorLabels:Object.fromEntries(positions.map((i:number)=>[String(i),String(copies[0].doc.chunks[i].speaker??'')]))}
507
+ const meeting:BatchMeeting={sessionId,status:'ready',namedChunks:positions,widerChunks:[],labelled:positions.length,roomRisk:false,playback:positions.map((position:number)=>({sessionId,position,chunkIndex:maps[0][position]})),copies:copies.map(snapshot),intentId:row.id,receipts:copies.map((c,i)=>({copy:c.copy,status:'pending',patches:edits[i].patches,beforeSidecar:c.raw,afterSidecar:JSON.stringify(edits[i].doc,null,2)+'\n',beforeMarkdown:c.markdown,afterMarkdown:c.blended?c.markdown+(c.markdown.includes('<!-- speaker-labels-newer-than-graph -->')?'':'\n<!-- speaker-labels-newer-than-graph -->\n'):edits[i].markdown,blended:c.blended}))}
508
+ batch={batchId,speaker:to,at:row.at,status:'pending',previewHash:'',members:[],meetings:[meeting],enrolled:0,deleted:0,voiceVersion:voiceVersion(to),lockMode:lock!.mode}
509
+ writeRecord('batch',batchId,batch)
510
+ if(!appendCorrection(sessionId,row))throw new Error('Cannot write correction intent')
511
+ for(const receipt of meeting.receipts){
512
+ const c=resolveCopies(sessionId).find(c=>c.copy===receipt.copy)!
513
+ if(c.raw!==receipt.beforeSidecar||c.markdown!==receipt.beforeMarkdown)throw new Error('Meeting changed before copy write')
514
+ durableAtomicWriteFileSync(c.sidecarPath,receipt.afterSidecar,{mode:0o600});receipt.status='sidecar_written';writeRecord('batch',batchId,batch)
515
+ assertMarkdownWords(receipt.beforeMarkdown,receipt.afterMarkdown)
516
+ durableAtomicWriteFileSync(c.meetingPath,receipt.afterMarkdown,{mode:0o600});receipt.status='applied';writeRecord('batch',batchId,batch)
517
+ }
518
+ if(!appendCorrection(sessionId,{...row,phase:'applied',proseStale:true}))throw new Error('Cannot record applied correction')
519
+ if(!appendCorrection(sessionId,{...row,phase:'confirmed-chunks'}))throw new Error('Cannot record chunk confirmation')
520
+ meeting.status='applied';batch.status='applied';writeRecord('batch',batchId,batch)
521
+ return{copies:results,batchId}
522
+ }catch(e){
523
+ if(batch){batch.status='partial';batch.meetings[0].status='failed';batch.meetings[0].error=errorText(e);writeRecord('batch',batch.batchId,batch)}
524
+ if(row)appendCorrection(sessionId,{...row,phase:'failed',error:errorText(e)})
525
+ throw e
526
+ }finally{lock?.release();if(options.apply)mutationBusy=false}
527
+ }