@gotcos/glasses-server 6.18.3 → 6.18.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,11 @@
1
+ ## 6.18.4
2
+
3
+ - **Meeting sync progress on `/api/health`.** Post-meeting HQ polish writes
4
+ `_batch_progress.json` under `pending-batch/<meetingId>/` and publishes
5
+ `meeting_sync` on health (`active`, `percent`, `label`, `blocksRestart`,
6
+ per-meeting rows). COS Control 0.3.0+ shows this as a status row so Update /
7
+ Restart drain is no longer a black box during long Whisper batch jobs.
8
+
1
9
  ## 6.18.3
2
10
 
3
11
  > Ships as 6.18.3. There is no published 6.18.2 — that version number was bumped
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.18.3",
3
+ "version": "6.18.4",
4
4
  "description": "COS Glasses \u2014 self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, or Cursor Agent CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,221 @@
1
+ // Meeting HQ polish progress for COS Control / health.
2
+ // Written next to pending-batch audio so a draining Update can show % complete
3
+ // instead of a silent "degraded" row while Whisper chews through a long save.
4
+
5
+ import { existsSync, readdirSync, readFileSync, statSync, writeFileSync, unlinkSync } from 'node:fs'
6
+ import { basename, join } from 'node:path'
7
+ import { dataPath } from './data-dir.js'
8
+
9
+ export const BATCH_PROGRESS_FILENAME = '_batch_progress.json'
10
+ export const BATCH_PENDING_MARKER = '_batch_pending.marker'
11
+
12
+ export type MeetingBatchPhase =
13
+ | 'queued'
14
+ | 'hq_polish'
15
+ | 'quality_check'
16
+ | 'persisting'
17
+ | 'done'
18
+
19
+ export interface MeetingBatchProgress {
20
+ schemaVersion: 1
21
+ meetingId: string
22
+ phase: MeetingBatchPhase
23
+ segmentsDone: number
24
+ segmentsTotal: number
25
+ chunkFiles?: number
26
+ updatedAt: string
27
+ startedAt: string
28
+ }
29
+
30
+ export interface MeetingSyncMeeting {
31
+ meetingId: string
32
+ phase: MeetingBatchPhase | 'pending'
33
+ percent: number | null
34
+ segmentsDone: number | null
35
+ segmentsTotal: number | null
36
+ chunkFiles: number
37
+ label: string
38
+ updatedAt: string | null
39
+ }
40
+
41
+ export interface MeetingSyncSnapshot {
42
+ active: boolean
43
+ percent: number | null
44
+ label: string
45
+ blocksRestart: boolean
46
+ meetings: MeetingSyncMeeting[]
47
+ }
48
+
49
+ function pendingBatchRoot(): string {
50
+ return dataPath('pending-batch')
51
+ }
52
+
53
+ function clampPercent(done: number, total: number): number {
54
+ if (total <= 0) return 0
55
+ return Math.max(0, Math.min(100, Math.round((done / total) * 100)))
56
+ }
57
+
58
+ function labelFor(meeting: Omit<MeetingSyncMeeting, 'label'>): string {
59
+ if (meeting.percent != null && meeting.segmentsTotal != null && meeting.segmentsTotal > 0) {
60
+ return `HQ polish ${meeting.percent}% (${meeting.segmentsDone}/${meeting.segmentsTotal})`
61
+ }
62
+ if (meeting.chunkFiles > 0) {
63
+ return `HQ polish · ${meeting.chunkFiles} chunk${meeting.chunkFiles === 1 ? '' : 's'}`
64
+ }
65
+ return 'HQ polish · pending'
66
+ }
67
+
68
+ export function writeMeetingBatchProgress(
69
+ audioDir: string,
70
+ input: {
71
+ phase: MeetingBatchPhase
72
+ segmentsDone: number
73
+ segmentsTotal: number
74
+ meetingId?: string
75
+ startedAt?: string
76
+ },
77
+ ): void {
78
+ const meetingId = input.meetingId ?? basename(audioDir)
79
+ const path = join(audioDir, BATCH_PROGRESS_FILENAME)
80
+ let startedAt = input.startedAt
81
+ if (!startedAt && existsSync(path)) {
82
+ try {
83
+ const prior = JSON.parse(readFileSync(path, 'utf8')) as MeetingBatchProgress
84
+ if (typeof prior.startedAt === 'string') startedAt = prior.startedAt
85
+ } catch { /* replace */ }
86
+ }
87
+ const payload: MeetingBatchProgress = {
88
+ schemaVersion: 1,
89
+ meetingId,
90
+ phase: input.phase,
91
+ segmentsDone: Math.max(0, input.segmentsDone),
92
+ segmentsTotal: Math.max(0, input.segmentsTotal),
93
+ updatedAt: new Date().toISOString(),
94
+ startedAt: startedAt ?? new Date().toISOString(),
95
+ }
96
+ try {
97
+ const wavs = readdirSync(audioDir).filter(name => name.endsWith('.wav')).length
98
+ payload.chunkFiles = wavs
99
+ } catch { /* optional */ }
100
+ try {
101
+ writeFileSync(path, `${JSON.stringify(payload)}\n`, { encoding: 'utf8', mode: 0o600 })
102
+ } catch {
103
+ // Progress is observability only — never fail HQ polish for a status write.
104
+ }
105
+ }
106
+
107
+ export function clearMeetingBatchProgress(audioDir: string): void {
108
+ const path = join(audioDir, BATCH_PROGRESS_FILENAME)
109
+ try {
110
+ if (existsSync(path)) unlinkSync(path)
111
+ } catch { /* ignore */ }
112
+ }
113
+
114
+ function readProgressFile(dir: string): MeetingBatchProgress | null {
115
+ const path = join(dir, BATCH_PROGRESS_FILENAME)
116
+ if (!existsSync(path)) return null
117
+ try {
118
+ const raw = JSON.parse(readFileSync(path, 'utf8')) as MeetingBatchProgress
119
+ if (raw?.schemaVersion !== 1) return null
120
+ if (typeof raw.meetingId !== 'string') return null
121
+ if (typeof raw.segmentsTotal !== 'number' || typeof raw.segmentsDone !== 'number') return null
122
+ return raw
123
+ } catch {
124
+ return null
125
+ }
126
+ }
127
+
128
+ function markerFresh(dir: string, maxAgeMs = 15 * 60_000): boolean {
129
+ const marker = join(dir, BATCH_PENDING_MARKER)
130
+ if (!existsSync(marker)) return false
131
+ try {
132
+ return Date.now() - statSync(marker).mtimeMs <= maxAgeMs
133
+ } catch {
134
+ return false
135
+ }
136
+ }
137
+
138
+ /** Snapshot of pending HQ polish work for /api/health and COS Control. */
139
+ export function getMeetingSyncSnapshot(
140
+ root: string = pendingBatchRoot(),
141
+ ): MeetingSyncSnapshot {
142
+ const meetings: MeetingSyncMeeting[] = []
143
+ if (!existsSync(root)) {
144
+ return { active: false, percent: null, label: 'Idle', blocksRestart: false, meetings }
145
+ }
146
+
147
+ let dirs: string[] = []
148
+ try {
149
+ dirs = readdirSync(root).filter(name => {
150
+ try {
151
+ return statSync(join(root, name)).isDirectory()
152
+ } catch {
153
+ return false
154
+ }
155
+ })
156
+ } catch {
157
+ return { active: false, percent: null, label: 'Idle', blocksRestart: false, meetings }
158
+ }
159
+
160
+ for (const name of dirs) {
161
+ const dir = join(root, name)
162
+ const progress = readProgressFile(dir)
163
+ let chunkFiles = 0
164
+ try {
165
+ chunkFiles = readdirSync(dir).filter(f => f.endsWith('.wav')).length
166
+ } catch { /* ignore */ }
167
+
168
+ const active = markerFresh(dir) || progress != null
169
+ if (!active && chunkFiles === 0) continue
170
+
171
+ if (progress) {
172
+ const percent = progress.segmentsTotal > 0
173
+ ? clampPercent(progress.segmentsDone, progress.segmentsTotal)
174
+ : null
175
+ const row: Omit<MeetingSyncMeeting, 'label'> = {
176
+ meetingId: progress.meetingId || name,
177
+ phase: progress.phase,
178
+ percent,
179
+ segmentsDone: progress.segmentsTotal > 0 ? progress.segmentsDone : null,
180
+ segmentsTotal: progress.segmentsTotal > 0 ? progress.segmentsTotal : null,
181
+ chunkFiles: progress.chunkFiles ?? chunkFiles,
182
+ updatedAt: progress.updatedAt,
183
+ }
184
+ meetings.push({ ...row, label: labelFor(row) })
185
+ continue
186
+ }
187
+
188
+ if (!markerFresh(dir) && chunkFiles === 0) continue
189
+ const row: Omit<MeetingSyncMeeting, 'label'> = {
190
+ meetingId: name,
191
+ phase: 'pending',
192
+ percent: null,
193
+ segmentsDone: null,
194
+ segmentsTotal: null,
195
+ chunkFiles,
196
+ updatedAt: null,
197
+ }
198
+ meetings.push({ ...row, label: labelFor(row) })
199
+ }
200
+
201
+ if (meetings.length === 0) {
202
+ return { active: false, percent: null, label: 'Idle', blocksRestart: false, meetings }
203
+ }
204
+
205
+ const withPercent = meetings.filter(m => m.percent != null)
206
+ const percent = withPercent.length === meetings.length
207
+ ? Math.round(withPercent.reduce((sum, m) => sum + (m.percent ?? 0), 0) / meetings.length)
208
+ : null
209
+
210
+ const label = meetings.length === 1
211
+ ? meetings[0]!.label
212
+ : `${meetings.length} meetings syncing` + (percent != null ? ` · ${percent}%` : '')
213
+
214
+ return {
215
+ active: true,
216
+ percent,
217
+ label,
218
+ blocksRestart: true,
219
+ meetings,
220
+ }
221
+ }
@@ -3,10 +3,14 @@
3
3
  // The candidate is never canonical until batch-transcript-quality accepts it.
4
4
 
5
5
  import { existsSync, readFileSync, readdirSync, utimesSync, writeFileSync } from 'node:fs'
6
- import { join, resolve } from 'node:path'
6
+ import { basename, join, resolve } from 'node:path'
7
7
  import { enhanceAudio } from './audio-enhance.js'
8
8
  import { transcribeHighQuality, type WhisperWord } from './whisper-local.js'
9
9
  import { isMetalBatchPreempted } from './whisper-metal-gate.js'
10
+ import {
11
+ clearMeetingBatchProgress,
12
+ writeMeetingBatchProgress,
13
+ } from './meeting-batch-progress.js'
10
14
  import type { IndexedTranscriptChunk } from '../routes/transcribe-stream.js'
11
15
  import {
12
16
  evaluateBatchQuality,
@@ -167,6 +171,13 @@ async function transcribeSegments(
167
171
  entries: IndexedTranscriptChunk[],
168
172
  ): Promise<BatchResult[]> {
169
173
  const results: BatchResult[] = []
174
+ const meetingId = basename(audioDir)
175
+ writeMeetingBatchProgress(audioDir, {
176
+ phase: 'hq_polish',
177
+ segmentsDone: 0,
178
+ segmentsTotal: segments.length,
179
+ meetingId,
180
+ })
170
181
  for (const segment of segments) {
171
182
  try {
172
183
  refreshPendingLease(audioDir)
@@ -202,11 +213,23 @@ async function transcribeSegments(
202
213
  speakerWords: mapWordsToSpeakers(words, segment, entries),
203
214
  })
204
215
  refreshPendingLease(audioDir)
216
+ writeMeetingBatchProgress(audioDir, {
217
+ phase: 'hq_polish',
218
+ segmentsDone: results.length,
219
+ segmentsTotal: segments.length,
220
+ meetingId,
221
+ })
205
222
  } catch (error) {
206
223
  console.error(
207
224
  `[meeting-batch] Segment ${segment.startChunkIdx}-${segment.endChunkIdx} failed: `
208
225
  + `${error instanceof Error ? error.message : String(error)}`,
209
226
  )
227
+ writeMeetingBatchProgress(audioDir, {
228
+ phase: 'hq_polish',
229
+ segmentsDone: results.length,
230
+ segmentsTotal: segments.length,
231
+ meetingId,
232
+ })
210
233
  }
211
234
  }
212
235
  return results
@@ -223,13 +246,22 @@ export function runMeetingBatchPipeline(
223
246
  // Lease immediately, including time spent behind another HQ decoder. Without
224
247
  // this, the two-hour cleanup could delete a queued meeting before it starts.
225
248
  refreshPendingLease(audioDir)
249
+ writeMeetingBatchProgress(audioDir, {
250
+ phase: 'queued',
251
+ segmentsDone: 0,
252
+ segmentsTotal: 0,
253
+ meetingId: basename(audioDir),
254
+ })
226
255
  const lease = setInterval(() => refreshPendingLease(audioDir), 60_000)
227
256
  lease.unref()
228
257
  const job = batchQueueTail.then(() => runMeetingBatchPipelineNow(
229
258
  audioDir,
230
259
  entries,
231
260
  streamingWordCount,
232
- )).finally(() => clearInterval(lease))
261
+ )).finally(() => {
262
+ clearInterval(lease)
263
+ clearMeetingBatchProgress(audioDir)
264
+ })
233
265
  batchQueueTail = job.then(() => undefined, () => undefined)
234
266
  return job
235
267
  }
@@ -248,7 +280,19 @@ async function runMeetingBatchPipelineNow(
248
280
  const segments = segmentTranscriptChunks(entries)
249
281
  if (segments.length === 0) return { transcriptionQuality: 'streaming' }
250
282
 
283
+ writeMeetingBatchProgress(audioDir, {
284
+ phase: 'hq_polish',
285
+ segmentsDone: 0,
286
+ segmentsTotal: segments.length,
287
+ meetingId: basename(audioDir),
288
+ })
251
289
  const batchSegments = await transcribeSegments(audioDir, segments, entries)
290
+ writeMeetingBatchProgress(audioDir, {
291
+ phase: 'quality_check',
292
+ segmentsDone: segments.length,
293
+ segmentsTotal: segments.length,
294
+ meetingId: basename(audioDir),
295
+ })
252
296
  const batchTranscript = batchSegments.map(result => result.text).join(' ')
253
297
  const qualityReport = evaluateBatchQuality(batchSegments, streamingWordCount)
254
298
  if (!qualityReport.accepted) {
@@ -37,6 +37,7 @@ import { getServerGenerationId } from '../lib/managed-runtime.js'
37
37
  import { maintenanceLifecycle } from '../lib/maintenance-lifecycle.js'
38
38
  import { getLocalTtsHealth, refreshLocalTtsHealth } from '../lib/tts-local.js'
39
39
  import { liveCuesCapability } from '../lib/live-cues-capability.js'
40
+ import { getMeetingSyncSnapshot } from '../lib/meeting-batch-progress.js'
40
41
 
41
42
  export const healthRouter = Router()
42
43
 
@@ -259,6 +260,7 @@ healthRouter.get('/health', async (_req, res) => {
259
260
  // agent binary paths stay on the authenticated /api/models surface.
260
261
  const cursorSnapshot = getCursorModelCatalogSnapshot()
261
262
  const { agentBinary: _cursorAgentBinary, ...cursor_models } = cursorSnapshot
263
+ const meeting_sync = getMeetingSyncSnapshot()
262
264
  res.json({
263
265
  ...checks,
264
266
  server_version: managedServerVersion(),
@@ -273,6 +275,7 @@ healthRouter.get('/health', async (_req, res) => {
273
275
  tts_local,
274
276
  codex_models,
275
277
  cursor_models,
278
+ meeting_sync,
276
279
  capabilities: {
277
280
  transcription: { ...transcription, hq: transcriptionHq },
278
281
  recovery,