@gotcos/glasses-server 6.18.7 → 6.19.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.
- package/.env.example +11 -0
- package/CHANGELOG.md +52 -0
- package/README.md +6 -0
- package/managed-runtime-contract.json +1 -0
- package/package.json +1 -1
- package/server/lib/archive.ts +3 -0
- package/server/lib/conversation.ts +1 -0
- package/server/lib/maintenance-lifecycle.ts +9 -0
- package/server/lib/meeting-batch-progress.ts +150 -3
- package/server/lib/meeting-batch-transcribe.ts +29 -2
- package/server/lib/query-job-runtime.ts +2 -2
- package/server/lib/transcribe-audio.ts +14 -15
- package/server/lib/unsaved-audio-quarantine.ts +314 -0
- package/server/lib/whisper-local.ts +49 -30
- package/server/routes/health.ts +16 -0
- package/server/routes/meeting.ts +281 -2
- package/server/routes/prompt-drafts.ts +77 -0
- package/server/routes/transcribe-stream.ts +65 -13
package/.env.example
CHANGED
|
@@ -139,6 +139,17 @@ BIND_HOST=0.0.0.0
|
|
|
139
139
|
# keeps working if the default ever flips to Metal-on.
|
|
140
140
|
# COS_BATCH_HQ_FORCE_CPU=1
|
|
141
141
|
|
|
142
|
+
# ── UNSAVED-CAPTURE QUARANTINE (6.19.0) ─────────────────────────────────
|
|
143
|
+
# Meeting audio whose save never landed is QUARANTINED, never deleted. It
|
|
144
|
+
# surfaces on /api/health (unsaved_captures) and, with COS Control 0.3.1+,
|
|
145
|
+
# as an "Unsaved captures" row in the status card. One authenticated call
|
|
146
|
+
# recovers a capture into a durable meeting scribe:
|
|
147
|
+
# curl -X POST -H "x-cos-token: $COS_API_TOKEN" \
|
|
148
|
+
# http://127.0.0.1:3141/api/meeting/orphans/<sessionId>/recover
|
|
149
|
+
# Quarantined audio expires on this retention clock (hours, clamped 1-720).
|
|
150
|
+
# 72 covers a long weekend away from the Mac.
|
|
151
|
+
# COS_UNSAVED_AUDIO_RETENTION_HOURS=72
|
|
152
|
+
|
|
142
153
|
# ── LIVE CUES (optional — requires the FULL COS PIPELINE above) ──────────
|
|
143
154
|
# Live meeting coaching cues on the lens: transcript window -> Composer
|
|
144
155
|
# planner -> Qdrant -> LightRAG -> Composer insight -> coaching_nudge.
|
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,55 @@
|
|
|
1
|
+
## 6.19.0
|
|
2
|
+
|
|
3
|
+
Meeting audio is evidence. This release stops the server from ever deleting an
|
|
4
|
+
unsaved capture, and makes batch status stop lying about finished work.
|
|
5
|
+
|
|
6
|
+
- **Unsaved-capture quarantine (the 2026-08-01 data-loss fix).** The
|
|
7
|
+
session-audio purge (boot sweep + 60s interval + non-saved session close)
|
|
8
|
+
DELETED any directory not tracked in memory once the 4h idle retention
|
|
9
|
+
passed — an offline meeting whose deferred save never landed lost its
|
|
10
|
+
full-fidelity audio within a minute. Two real meetings were destroyed this
|
|
11
|
+
way on 2026-08-01; only speaker-enrollment fragments survived. Audio-bearing
|
|
12
|
+
directories are now MOVED to `data/unsaved-audio/` with a manifest, never
|
|
13
|
+
deleted in place. Empty directories are still cleaned. A failed quarantine
|
|
14
|
+
move leaves the source untouched. Quarantine expires on
|
|
15
|
+
`COS_UNSAVED_AUDIO_RETENTION_HOURS` (default 72, clamped 1–720) — the only
|
|
16
|
+
place quarantined audio is ever deleted.
|
|
17
|
+
- **Unsaved captures are visible.** `/api/health` gains `unsaved_captures`
|
|
18
|
+
(count + compact items, same exposure level as `meeting_sync`). The
|
|
19
|
+
authenticated `GET /api/meeting/orphans` returns full detail.
|
|
20
|
+
- **Miles-triggered recovery, surface-only by decision (2026-08-02).**
|
|
21
|
+
`POST /api/meeting/orphans/:sessionId/recover` batch-transcribes the
|
|
22
|
+
quarantined WAVs (same segment/enhance/Metal-preempt contract as HQ polish,
|
|
23
|
+
under a new `orphan_recovery` maintenance lease), writes a durable scribe,
|
|
24
|
+
and hands off to operations when the COS pipeline is configured. Idempotent
|
|
25
|
+
via the save-receipt short-circuit; the server never drives recovery on its
|
|
26
|
+
own, and audio stays in quarantine until the retention clock — a failed
|
|
27
|
+
recovery is retryable.
|
|
28
|
+
- **Rejected HQ batches release their status.** A terminal batch outcome
|
|
29
|
+
(rejected quality, pipeline failure, accepted-but-unpersisted) now writes
|
|
30
|
+
`_batch_terminal.json` next to the retained WAVs. `meeting_sync` reports
|
|
31
|
+
those as `retained` — never as active work — so a rejected batch no longer
|
|
32
|
+
shows "HQ polish · N chunks" with `blocksRestart: true` for the 12h WAV
|
|
33
|
+
retention after the work already finished (observed on
|
|
34
|
+
meeting_1785695339502_mvqm0p, reason `repetitive-output`). A retry clears
|
|
35
|
+
the terminal record; live progress always wins. `meeting_sync.retained` is
|
|
36
|
+
additive — older consumers ignore it.
|
|
37
|
+
- Deferred by design: the realtime-model fallback port (W3) ships in its own
|
|
38
|
+
release. The app-side module has diverged ~1,100 lines from this repo's
|
|
39
|
+
whisper path; transplanting it alongside the data-loss fix would couple the
|
|
40
|
+
release's safest change to its riskiest. No default flips either way.
|
|
41
|
+
|
|
42
|
+
## 6.18.8
|
|
43
|
+
|
|
44
|
+
- **Prompt draft peeks for live ASR.** `POST /api/prompt-drafts/:draftId/peek`
|
|
45
|
+
runs Turbo locally, emits `prompt_transcript` with `provisional: true` +
|
|
46
|
+
`peekGen`, and does **not** advance the recovery ledger. Drop-on-busy,
|
|
47
|
+
`learnInline=false`, and `affectsCircuit: false` so peeks cannot trip or
|
|
48
|
+
success-reset the Whisper breaker.
|
|
49
|
+
- **Interactive HQ keeps short utterance heads.** Compose HQ skips light
|
|
50
|
+
enhance (highpass was truncating "device, just for…") and omits CLI `--vad`
|
|
51
|
+
as defense-in-depth. Meeting/batch HQ paths unchanged.
|
|
52
|
+
|
|
1
53
|
## 6.18.7
|
|
2
54
|
|
|
3
55
|
- **Phone Restart no longer leaves the server Stopped.** Control LaunchAgent
|
package/README.md
CHANGED
|
@@ -123,6 +123,12 @@ range is the exact Tailscale/CGNAT allocation (`100.64.0.0/10`), not all of
|
|
|
123
123
|
locally through a network interruption. Reconnecting reconciles the exact
|
|
124
124
|
chunks already stored by the Mac, uploads only missing audio, and finalizes
|
|
125
125
|
through an idempotent save receipt without duplicating the meeting.
|
|
126
|
+
- Since 6.19.0, meeting audio whose save never lands is quarantined for 72 hours
|
|
127
|
+
(`COS_UNSAVED_AUDIO_RETENTION_HOURS`) instead of being cleaned up, surfaces on
|
|
128
|
+
`/api/health` as `unsaved_captures`, and can be recovered into a durable
|
|
129
|
+
meeting scribe with one authenticated call
|
|
130
|
+
(`POST /api/meeting/orphans/:sessionId/recover`; list via
|
|
131
|
+
`GET /api/meeting/orphans`).
|
|
126
132
|
- Local whisper.cpp transcription (free and local-only by default). OpenAI
|
|
127
133
|
Whisper fallback is optional and requires both the exact
|
|
128
134
|
`COS_OPENAI_WHISPER_FALLBACK=1` opt-in and a configured key; a key alone never
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotcos/glasses-server",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.19.0",
|
|
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": {
|
package/server/lib/archive.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { secureExistingPrivateFile } from './secure-user-config.js'
|
|
|
15
15
|
|
|
16
16
|
import type { Exchange } from './conversation.js'
|
|
17
17
|
import { resolveExchangePairModel } from './conversation.js'
|
|
18
|
+
import type { ModelPreference } from '../../shared/model-preference.js'
|
|
18
19
|
|
|
19
20
|
import { dataPath } from './data-dir.js'
|
|
20
21
|
const ARCHIVE_DIR = dataPath('archive')
|
|
@@ -60,6 +61,8 @@ export interface SessionToArchive {
|
|
|
60
61
|
contextBreaks: number[]
|
|
61
62
|
createdAt: number
|
|
62
63
|
lastActivity: number
|
|
64
|
+
/** Optional session-level model fallback for pre-stamp exchange pairs. */
|
|
65
|
+
modelPreference?: ModelPreference | null
|
|
63
66
|
}
|
|
64
67
|
|
|
65
68
|
// ── Directory management ────────────────────────────────────
|
|
@@ -46,6 +46,7 @@ export type MaintenanceWorkKind =
|
|
|
46
46
|
| 'meeting_batch_finalization'
|
|
47
47
|
| 'prompt_draft_write'
|
|
48
48
|
| 'prompt_draft_warm'
|
|
49
|
+
| 'prompt_draft_peek'
|
|
49
50
|
| 'prompt_draft_finalize'
|
|
50
51
|
// Live Cues pipeline (planner + memory hops + insight). Held for the
|
|
51
52
|
// pipeline's in-flight duration so an Update Server drain cannot unload the
|
|
@@ -53,6 +54,14 @@ export type MaintenanceWorkKind =
|
|
|
53
54
|
// budget must stay under COS Control's 90s drain timeout (main.swift:1853)
|
|
54
55
|
// or every drain that catches a cue in flight hard-fails to Repair.
|
|
55
56
|
| 'live_cue_pipeline'
|
|
57
|
+
// Miles-triggered recovery of a quarantined unsaved capture (6.19.0):
|
|
58
|
+
// batch-transcribes retained WAVs into a durable scribe. Held for the whole
|
|
59
|
+
// background run so an Update Server drain waits for it like any batch.
|
|
60
|
+
// Long runs are made visible: the active-recovery registry renders a
|
|
61
|
+
// meeting_sync row with blocksRestart so COS Control warns BEFORE
|
|
62
|
+
// committing a drain into a decode that outlives its 90s timeout
|
|
63
|
+
// (main.swift:1963 waitForRestartProof).
|
|
64
|
+
| 'orphan_recovery'
|
|
56
65
|
|
|
57
66
|
export type MaintenanceWorkPhase = 'queued' | 'active'
|
|
58
67
|
export type MaintenanceOperationScope = 'same_boot' | 'cross_boot'
|
|
@@ -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
|
-
|
|
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)
|
|
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
|
}
|
|
@@ -114,7 +114,7 @@ async function projectPublicConversationTerminal(
|
|
|
114
114
|
request.globalMsgNum,
|
|
115
115
|
request.attachmentRefs,
|
|
116
116
|
request.messageEra,
|
|
117
|
-
request.model,
|
|
117
|
+
normalizeModelPreference(request.model),
|
|
118
118
|
)
|
|
119
119
|
reconcileExchangeByJobIdentity(
|
|
120
120
|
request.sessionId,
|
|
@@ -124,7 +124,7 @@ async function projectPublicConversationTerminal(
|
|
|
124
124
|
request.globalMsgNum,
|
|
125
125
|
mergeMediaAttachmentRefs(outputAttachments, existingOutputAttachments),
|
|
126
126
|
request.messageEra,
|
|
127
|
-
request.model,
|
|
127
|
+
normalizeModelPreference(request.model),
|
|
128
128
|
)
|
|
129
129
|
flushConversationToDisk()
|
|
130
130
|
}
|
|
@@ -13,7 +13,6 @@ import {
|
|
|
13
13
|
estimateAudioSeconds,
|
|
14
14
|
OpenAIWhisperBudgetExhaustedError,
|
|
15
15
|
} from './openai-whisper-budget.js'
|
|
16
|
-
import { enhanceAudio } from './audio-enhance.js'
|
|
17
16
|
import {
|
|
18
17
|
stripInlineHallucinationsOneShot,
|
|
19
18
|
isFullHallucination,
|
|
@@ -63,12 +62,6 @@ export class NoSpeechDetectedError extends Error {
|
|
|
63
62
|
// ceiling (anything longer is a dictation, not a query — use meetings instead).
|
|
64
63
|
const HQ_MAX_SECONDS = 60
|
|
65
64
|
|
|
66
|
-
/** Short interactive clips use light enhance (highpass only). Override via env. */
|
|
67
|
-
function hqEnhanceLightMaxSeconds(): number {
|
|
68
|
-
const value = Number.parseInt(process.env.COS_HQ_ENHANCE_LIGHT_MAX_SEC || '15', 10)
|
|
69
|
-
return Number.isFinite(value) && value >= 0 ? value : 15
|
|
70
|
-
}
|
|
71
|
-
|
|
72
65
|
function unavailableAfterLocalFailure(): TranscriptionUnavailableError | null {
|
|
73
66
|
const fallback = getTranscriptionPolicySnapshot()
|
|
74
67
|
if (fallback.openaiFallbackReady) return null
|
|
@@ -146,10 +139,16 @@ export function resolveTranscribeMode(raw: unknown): TranscribeMode {
|
|
|
146
139
|
|
|
147
140
|
export async function transcribeAudioBuffer(
|
|
148
141
|
audioBuffer: Buffer,
|
|
149
|
-
opts: {
|
|
142
|
+
opts: {
|
|
143
|
+
mode?: TranscribeMode
|
|
144
|
+
policy?: TranscriptionBackendPolicy
|
|
145
|
+
/** When false, turbo/server failures do not move the shared meeting circuit breaker. */
|
|
146
|
+
affectsCircuit?: boolean
|
|
147
|
+
} = {},
|
|
150
148
|
): Promise<TranscribeAudioResult> {
|
|
151
149
|
const requestedMode = opts.mode ?? 'hq'
|
|
152
150
|
const policy = opts.policy ?? 'automatic'
|
|
151
|
+
const affectsCircuit = opts.affectsCircuit !== false
|
|
153
152
|
const audioSeconds = estimateAudioSeconds(audioBuffer)
|
|
154
153
|
const effectiveMode: TranscribeMode =
|
|
155
154
|
requestedMode === 'hq' && audioSeconds > HQ_MAX_SECONDS ? 'fast' : requestedMode
|
|
@@ -166,14 +165,14 @@ export async function transcribeAudioBuffer(
|
|
|
166
165
|
|
|
167
166
|
if (effectiveMode === 'hq') {
|
|
168
167
|
try {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
const result = await transcribeHighQuality(
|
|
168
|
+
// A0 (2026-07-30): ffmpeg enhance light (highpass=f=80) was measured dropping
|
|
169
|
+
// leading speech on compose ("device just for your awareness"). Meeting batch
|
|
170
|
+
// still enhances in meeting-batch-transcribe.ts — this path is prompt/interactive only.
|
|
171
|
+
const result = await transcribeHighQuality(audioBuffer, undefined, { priority: 'interactive' })
|
|
173
172
|
text = result.text
|
|
174
173
|
actualQuality = result.actualQuality
|
|
175
174
|
if (result.actualQuality === 'hq') {
|
|
176
|
-
backend =
|
|
175
|
+
backend = 'hq-large-v3'
|
|
177
176
|
} else {
|
|
178
177
|
backend = result.backend === 'whisper-cli' ? 'fast-cli-turbo' : 'fast-local-server'
|
|
179
178
|
degradationReason = result.degradationReason ?? 'hq_unavailable'
|
|
@@ -182,7 +181,7 @@ export async function transcribeAudioBuffer(
|
|
|
182
181
|
console.warn(`[transcribe] HQ path failed, falling back to fast: ${hqErr.message}`)
|
|
183
182
|
degradationReason = 'hq_decode_failed'
|
|
184
183
|
try {
|
|
185
|
-
const result = await transcribeLocal(audioBuffer)
|
|
184
|
+
const result = await transcribeLocal(audioBuffer, undefined, undefined, { affectsCircuit })
|
|
186
185
|
text = result.text
|
|
187
186
|
backend = `fast-local-${result.backend}`
|
|
188
187
|
actualQuality = 'fast'
|
|
@@ -202,7 +201,7 @@ export async function transcribeAudioBuffer(
|
|
|
202
201
|
}
|
|
203
202
|
} else if (effectiveMode === 'fast') {
|
|
204
203
|
try {
|
|
205
|
-
const result = await transcribeLocal(audioBuffer)
|
|
204
|
+
const result = await transcribeLocal(audioBuffer, undefined, undefined, { affectsCircuit })
|
|
206
205
|
text = result.text
|
|
207
206
|
backend = `fast-local-${result.backend}`
|
|
208
207
|
actualQuality = 'fast'
|