@gotcos/glasses-server 6.18.8 → 6.20.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.
@@ -5,9 +5,11 @@
5
5
  import { existsSync, readdirSync, readFileSync, statSync, writeFileSync, unlinkSync } from 'node:fs'
6
6
  import { basename, join } from 'node:path'
7
7
  import { dataPath } from './data-dir.js'
8
+ import { listActiveRecoveries } from './unsaved-audio-quarantine.js'
8
9
 
9
10
  export const BATCH_PROGRESS_FILENAME = '_batch_progress.json'
10
11
  export const BATCH_PENDING_MARKER = '_batch_pending.marker'
12
+ export const BATCH_TERMINAL_FILENAME = '_batch_terminal.json'
11
13
 
12
14
  export type MeetingBatchPhase =
13
15
  | 'queued'
@@ -44,6 +46,75 @@ export interface MeetingSyncSnapshot {
44
46
  label: string
45
47
  blocksRestart: boolean
46
48
  meetings: MeetingSyncMeeting[]
49
+ /** Batches that reached a terminal outcome but whose WAVs are deliberately
50
+ * retained for retry (rejected quality, failed persist). Additive field —
51
+ * older consumers ignore it. Never counts toward active/blocksRestart. */
52
+ retained: MeetingSyncRetainedMeeting[]
53
+ }
54
+
55
+ export type MeetingBatchOutcome = 'accepted' | 'rejected' | 'failed'
56
+
57
+ export interface MeetingBatchTerminal {
58
+ schemaVersion: 1
59
+ meetingId: string
60
+ outcome: MeetingBatchOutcome
61
+ reason?: string
62
+ at: string
63
+ }
64
+
65
+ export interface MeetingSyncRetainedMeeting {
66
+ meetingId: string
67
+ outcome: MeetingBatchOutcome
68
+ reason: string | null
69
+ chunkFiles: number
70
+ at: string
71
+ label: string
72
+ }
73
+
74
+ /** Record the batch's terminal outcome next to its retained WAVs. Before this
75
+ * file existed (≤6.18.8), a rejected batch's dir kept rendering as active
76
+ * "HQ polish · N chunks" with blocksRestart:true for the full 12h retention —
77
+ * the status conflated "work running" with "evidence retained". */
78
+ export function writeMeetingBatchTerminal(
79
+ audioDir: string,
80
+ input: { outcome: MeetingBatchOutcome; reason?: string; meetingId?: string },
81
+ ): void {
82
+ const payload: MeetingBatchTerminal = {
83
+ schemaVersion: 1,
84
+ meetingId: input.meetingId ?? basename(audioDir),
85
+ outcome: input.outcome,
86
+ ...(input.reason ? { reason: input.reason } : {}),
87
+ at: new Date().toISOString(),
88
+ }
89
+ try {
90
+ writeFileSync(join(audioDir, BATCH_TERMINAL_FILENAME), `${JSON.stringify(payload)}\n`, {
91
+ encoding: 'utf8',
92
+ mode: 0o600,
93
+ })
94
+ } catch {
95
+ // Status only — never fail the pipeline for a status write.
96
+ }
97
+ }
98
+
99
+ /** A retry invalidates the previous terminal state. */
100
+ export function clearMeetingBatchTerminal(audioDir: string): void {
101
+ const path = join(audioDir, BATCH_TERMINAL_FILENAME)
102
+ try {
103
+ if (existsSync(path)) unlinkSync(path)
104
+ } catch { /* ignore */ }
105
+ }
106
+
107
+ function readTerminalFile(dir: string): MeetingBatchTerminal | null {
108
+ const path = join(dir, BATCH_TERMINAL_FILENAME)
109
+ if (!existsSync(path)) return null
110
+ try {
111
+ const raw = JSON.parse(readFileSync(path, 'utf8')) as MeetingBatchTerminal
112
+ if (raw?.schemaVersion !== 1) return null
113
+ if (raw.outcome !== 'accepted' && raw.outcome !== 'rejected' && raw.outcome !== 'failed') return null
114
+ return raw
115
+ } catch {
116
+ return null
117
+ }
47
118
  }
48
119
 
49
120
  function pendingBatchRoot(): string {
@@ -111,6 +182,11 @@ export function clearMeetingBatchProgress(audioDir: string): void {
111
182
  } catch { /* ignore */ }
112
183
  }
113
184
 
185
+ /** Public read for surfaces outside this module (orphan recovery progress). */
186
+ export function readMeetingBatchProgress(dir: string): MeetingBatchProgress | null {
187
+ return readProgressFile(dir)
188
+ }
189
+
114
190
  function readProgressFile(dir: string): MeetingBatchProgress | null {
115
191
  const path = join(dir, BATCH_PROGRESS_FILENAME)
116
192
  if (!existsSync(path)) return null
@@ -140,8 +216,9 @@ export function getMeetingSyncSnapshot(
140
216
  root: string = pendingBatchRoot(),
141
217
  ): MeetingSyncSnapshot {
142
218
  const meetings: MeetingSyncMeeting[] = []
219
+ const retained: MeetingSyncRetainedMeeting[] = []
143
220
  if (!existsSync(root)) {
144
- return { active: false, percent: null, label: 'Idle', blocksRestart: false, meetings }
221
+ return { active: false, percent: null, label: 'Idle', blocksRestart: false, meetings, retained }
145
222
  }
146
223
 
147
224
  let dirs: string[] = []
@@ -154,7 +231,7 @@ export function getMeetingSyncSnapshot(
154
231
  }
155
232
  })
156
233
  } catch {
157
- return { active: false, percent: null, label: 'Idle', blocksRestart: false, meetings }
234
+ return { active: false, percent: null, label: 'Idle', blocksRestart: false, meetings, retained }
158
235
  }
159
236
 
160
237
  for (const name of dirs) {
@@ -165,6 +242,47 @@ export function getMeetingSyncSnapshot(
165
242
  chunkFiles = readdirSync(dir).filter(f => f.endsWith('.wav')).length
166
243
  } catch { /* ignore */ }
167
244
 
245
+ // A terminal outcome ends the meeting's ACTIVE life. Its WAVs stay for
246
+ // retry, reported as retained — never as running work. The gate is
247
+ // progress==null ONLY: the pending marker is refreshed every segment and
248
+ // every 60s during the run, so it is always fresh the moment a terminal
249
+ // is written — gating on marker freshness left the phantom alive for the
250
+ // first 15 minutes, exactly the post-meeting Update Server window. A
251
+ // genuine retry clears the terminal first (runMeetingBatchPipeline) and
252
+ // immediately writes queued progress, so progress presence is the true
253
+ // live signal.
254
+ const terminal = readTerminalFile(dir)
255
+ if (terminal && progress == null) {
256
+ const reasonSuffix = terminal.reason ? `: ${terminal.reason}` : ''
257
+ retained.push({
258
+ meetingId: terminal.meetingId || name,
259
+ outcome: terminal.outcome,
260
+ reason: terminal.reason ?? null,
261
+ chunkFiles,
262
+ at: terminal.at,
263
+ label: `Retained (${terminal.outcome}${reasonSuffix}) · ${chunkFiles} chunk${chunkFiles === 1 ? '' : 's'}`,
264
+ })
265
+ continue
266
+ }
267
+
268
+ // Backfill: a dir with WAVs, no progress, no fresh marker, and NO terminal
269
+ // file is a batch that ended before 6.19.0 existed (or whose terminal
270
+ // write failed). Pre-6.19.0 semantics rendered these as phantom active
271
+ // work with blocksRestart for the rest of the 12h retention — and the
272
+ // first boot after an upgrade is exactly when the user watches COS
273
+ // Control. Classify them as retained with an honest unknown outcome.
274
+ if (progress == null && !markerFresh(dir) && chunkFiles > 0) {
275
+ retained.push({
276
+ meetingId: name,
277
+ outcome: 'failed',
278
+ reason: 'pre-terminal batch (ended before 6.19.0 or terminal write lost)',
279
+ chunkFiles,
280
+ at: new Date(0).toISOString(),
281
+ label: `Retained (unknown outcome) · ${chunkFiles} chunk${chunkFiles === 1 ? '' : 's'}`,
282
+ })
283
+ continue
284
+ }
285
+
168
286
  const active = markerFresh(dir) || progress != null
169
287
  if (!active && chunkFiles === 0) continue
170
288
 
@@ -198,8 +316,36 @@ export function getMeetingSyncSnapshot(
198
316
  meetings.push({ ...row, label: labelFor(row) })
199
317
  }
200
318
 
319
+ // Active orphan recoveries decode in the quarantine root, which this scan
320
+ // never visits — surface them as active rows or COS Control shows "Idle"
321
+ // with blocksRestart:false while a 20-90 minute decode holds the
322
+ // maintenance lease, and an Update Server drain walks blind into its 90s
323
+ // timeout and hard-fails to Repair. Same contract as meeting_batch_finalization.
324
+ for (const recovery of listActiveRecoveries()) {
325
+ const progress = readProgressFile(recovery.dirPath)
326
+ const percent = progress && progress.segmentsTotal > 0
327
+ ? clampPercent(progress.segmentsDone, progress.segmentsTotal)
328
+ : null
329
+ const row: Omit<MeetingSyncMeeting, 'label'> = {
330
+ meetingId: recovery.sessionId,
331
+ phase: progress?.phase ?? 'queued',
332
+ percent,
333
+ segmentsDone: progress && progress.segmentsTotal > 0 ? progress.segmentsDone : null,
334
+ segmentsTotal: progress && progress.segmentsTotal > 0 ? progress.segmentsTotal : null,
335
+ chunkFiles: progress?.chunkFiles ?? 0,
336
+ updatedAt: progress?.updatedAt ?? null,
337
+ }
338
+ meetings.push({
339
+ ...row,
340
+ label: `Recovering unsaved capture${percent != null ? ` ${percent}%` : ''} · do not update/restart`,
341
+ })
342
+ }
343
+
201
344
  if (meetings.length === 0) {
202
- return { active: false, percent: null, label: 'Idle', blocksRestart: false, meetings }
345
+ const label = retained.length > 0
346
+ ? `Idle · ${retained.length} retained batch${retained.length === 1 ? '' : 'es'}`
347
+ : 'Idle'
348
+ return { active: false, percent: null, label, blocksRestart: false, meetings, retained }
203
349
  }
204
350
 
205
351
  const withPercent = meetings.filter(m => m.percent != null)
@@ -217,5 +363,6 @@ export function getMeetingSyncSnapshot(
217
363
  label,
218
364
  blocksRestart: true,
219
365
  meetings,
366
+ retained,
220
367
  }
221
368
  }
@@ -9,7 +9,9 @@ import { transcribeHighQuality, type WhisperWord } from './whisper-local.js'
9
9
  import { isMetalBatchPreempted } from './whisper-metal-gate.js'
10
10
  import {
11
11
  clearMeetingBatchProgress,
12
+ clearMeetingBatchTerminal,
12
13
  writeMeetingBatchProgress,
14
+ writeMeetingBatchTerminal,
13
15
  } from './meeting-batch-progress.js'
14
16
  import type { IndexedTranscriptChunk } from '../routes/transcribe-stream.js'
15
17
  import {
@@ -165,7 +167,7 @@ function mapWordsToSpeakers(
165
167
  })
166
168
  }
167
169
 
168
- async function transcribeSegments(
170
+ export async function transcribeSegments(
169
171
  audioDir: string,
170
172
  segments: BatchSegment[],
171
173
  entries: IndexedTranscriptChunk[],
@@ -237,6 +239,16 @@ async function transcribeSegments(
237
239
 
238
240
  let batchQueueTail: Promise<void> = Promise.resolve()
239
241
 
242
+ /** Chain arbitrary HQ-decoder work onto the same serialization tail the batch
243
+ * pipeline uses. Orphan recovery MUST go through this: transcribeSegments has
244
+ * no internal queue, so calling it directly would run a second (or third)
245
+ * 16-thread large-v3 decoder in parallel with a live post-meeting batch. */
246
+ export function enqueueSerializedHqWork<T>(work: () => Promise<T>): Promise<T> {
247
+ const job = batchQueueTail.then(work)
248
+ batchQueueTail = job.then(() => undefined, () => undefined)
249
+ return job
250
+ }
251
+
240
252
  /** Serialize 16-thread HQ decoders across meetings on a public user's Mac. */
241
253
  export function runMeetingBatchPipeline(
242
254
  audioDir: string,
@@ -246,6 +258,8 @@ export function runMeetingBatchPipeline(
246
258
  // Lease immediately, including time spent behind another HQ decoder. Without
247
259
  // this, the two-hour cleanup could delete a queued meeting before it starts.
248
260
  refreshPendingLease(audioDir)
261
+ // A retry invalidates any prior terminal outcome — live signals must win.
262
+ clearMeetingBatchTerminal(audioDir)
249
263
  writeMeetingBatchProgress(audioDir, {
250
264
  phase: 'queued',
251
265
  segmentsDone: 0,
@@ -278,7 +292,12 @@ async function runMeetingBatchPipelineNow(
278
292
  return { transcriptionQuality: 'streaming' }
279
293
  }
280
294
  const segments = segmentTranscriptChunks(entries)
281
- if (segments.length === 0) return { transcriptionQuality: 'streaming' }
295
+ if (segments.length === 0) {
296
+ // Terminal too: WAVs exist but nothing is transcribable. Without this,
297
+ // the dir re-creates the exact phantom-active state W2 removes.
298
+ writeMeetingBatchTerminal(audioDir, { outcome: 'failed', reason: 'no_segments' })
299
+ return { transcriptionQuality: 'streaming' }
300
+ }
282
301
 
283
302
  writeMeetingBatchProgress(audioDir, {
284
303
  phase: 'hq_polish',
@@ -302,12 +321,20 @@ async function runMeetingBatchPipelineNow(
302
321
  + `${qualityReport.streamingWordCount} live words, `
303
322
  + `${(qualityReport.duplicateWordRatio * 100).toFixed(1)}% duplicate`,
304
323
  )
324
+ // Terminal: the batch RAN and lost. WAVs stay for retry, but status must
325
+ // stop reporting active work (pre-6.19.0 this looked like 12h of
326
+ // "HQ polish · N chunks" with blocksRestart:true after the work ended).
327
+ writeMeetingBatchTerminal(audioDir, { outcome: 'rejected', reason: qualityReport.reason })
305
328
  return { transcriptionQuality: 'streaming', qualityReport }
306
329
  }
307
330
 
308
331
  return { transcriptionQuality: 'batch', batchTranscript, batchSegments, qualityReport }
309
332
  } catch (error) {
310
333
  console.error(`[meeting-batch] Pipeline failed: ${error instanceof Error ? error.message : String(error)}`)
334
+ writeMeetingBatchTerminal(audioDir, {
335
+ outcome: 'failed',
336
+ reason: error instanceof Error ? error.message.slice(0, 200) : String(error).slice(0, 200),
337
+ })
311
338
  return { transcriptionQuality: 'streaming' }
312
339
  }
313
340
  }
@@ -8,6 +8,10 @@ import { atomicWriteFileSync } from './atomic-fs.js'
8
8
 
9
9
  const APP_ROOT = resolve(import.meta.dirname, '../..')
10
10
 
11
+ const PLACEHOLDER_OWNER_NAMES = new Set(['your name', 'user'])
12
+ const PLACEHOLDER_VOCABULARY = new Set(['nameone', 'nametwo', 'yourcompany', 'productname'])
13
+ const PLACEHOLDER_CORRECTIONS = new Set(['soundalike\u0000yourname'])
14
+
11
15
  /** The profile in the data home. Survives updates; the APP_ROOT copy does not. */
12
16
  export function homeProfilePath(): string {
13
17
  return resolve(homedir(), '.cos-glasses', '.cos-profile.json')
@@ -81,7 +85,8 @@ export function loadProfileField(field: string, fallback: string): string {
81
85
  }
82
86
 
83
87
  export function getOwnerName(): string {
84
- return loadProfileField('owner_name', 'User')
88
+ const value = loadProfileField('owner_name', 'User').trim()
89
+ return !value || PLACEHOLDER_OWNER_NAMES.has(value.toLowerCase()) ? 'User' : value
85
90
  }
86
91
 
87
92
  /** Short speaker label for the glasses wearer, used by diarization to fast-path
@@ -92,7 +97,81 @@ export function getOwnerSpeakerLabel(): string {
92
97
 
93
98
  export function getVocabulary(): string[] {
94
99
  const profile = loadProfile()
95
- return Array.isArray(profile.vocabulary) ? profile.vocabulary as string[] : []
100
+ if (!Array.isArray(profile.vocabulary)) return []
101
+ const seen = new Set<string>()
102
+ return (profile.vocabulary as unknown[]).flatMap(value => {
103
+ if (typeof value !== 'string') return []
104
+ const term = value.trim()
105
+ const key = term.toLowerCase()
106
+ if (!term || PLACEHOLDER_VOCABULARY.has(key) || seen.has(key)) return []
107
+ seen.add(key)
108
+ return [term]
109
+ })
110
+ }
111
+
112
+ /** Typed correction map shared by every decoder caller. The legacy profile
113
+ * stores this field as a JSON string, while hand-authored profiles sometimes
114
+ * use an object; accept both and ignore the factory example pair. */
115
+ export function getWhisperCorrections(): Record<string, string> {
116
+ const raw = loadProfile().whisper_corrections
117
+ let parsed: unknown = raw
118
+ if (typeof raw === 'string') {
119
+ try { parsed = JSON.parse(raw) } catch { return {} }
120
+ }
121
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}
122
+
123
+ const corrections: Record<string, string> = {}
124
+ for (const [sourceRaw, targetRaw] of Object.entries(parsed as Record<string, unknown>)) {
125
+ if (typeof targetRaw !== 'string') continue
126
+ const source = sourceRaw.trim()
127
+ const target = targetRaw.trim()
128
+ if (!source || !target) continue
129
+ if (PLACEHOLDER_CORRECTIONS.has(`${source.toLowerCase()}\u0000${target.toLowerCase()}`)) continue
130
+ corrections[source] = target
131
+ }
132
+ return corrections
133
+ }
134
+
135
+ export interface TranscriptionProfileStatus {
136
+ configured: boolean
137
+ ownerConfigured: boolean
138
+ vocabularyTerms: number
139
+ ignoredPlaceholderTerms: number
140
+ ignoredPlaceholderCorrection: boolean
141
+ }
142
+
143
+ /** Path-free setup truth for startup warnings, health, and COS Control. */
144
+ export function getTranscriptionProfileStatus(): TranscriptionProfileStatus {
145
+ const profile = loadProfile()
146
+ const rawOwner = typeof profile.owner_name === 'string' ? profile.owner_name.trim() : ''
147
+ const rawVocabulary = Array.isArray(profile.vocabulary)
148
+ ? (profile.vocabulary as unknown[]).filter((value): value is string => typeof value === 'string')
149
+ : []
150
+ const ignoredPlaceholderTerms = rawVocabulary.filter(term => PLACEHOLDER_VOCABULARY.has(term.trim().toLowerCase())).length
151
+ const rawCorrections = (() => {
152
+ const value = profile.whisper_corrections
153
+ if (typeof value === 'string') {
154
+ try { return JSON.parse(value) as unknown } catch { return null }
155
+ }
156
+ return value
157
+ })()
158
+ const ignoredPlaceholderCorrection = Boolean(
159
+ rawCorrections
160
+ && typeof rawCorrections === 'object'
161
+ && !Array.isArray(rawCorrections)
162
+ && Object.entries(rawCorrections as Record<string, unknown>).some(([source, target]) =>
163
+ typeof target === 'string'
164
+ && PLACEHOLDER_CORRECTIONS.has(`${source.trim().toLowerCase()}\u0000${target.trim().toLowerCase()}`)),
165
+ )
166
+ const ownerConfigured = Boolean(rawOwner) && !PLACEHOLDER_OWNER_NAMES.has(rawOwner.toLowerCase())
167
+ const vocabularyTerms = getVocabulary().length
168
+ return {
169
+ configured: ownerConfigured || vocabularyTerms > 0 || Object.keys(getWhisperCorrections()).length > 0,
170
+ ownerConfigured,
171
+ vocabularyTerms,
172
+ ignoredPlaceholderTerms,
173
+ ignoredPlaceholderCorrection,
174
+ }
96
175
  }
97
176
 
98
177
  export function getSystemContext(): string {