@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
|
+
"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(() =>
|
|
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) {
|
package/server/routes/health.ts
CHANGED
|
@@ -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,
|