@gotcos/glasses-server 6.17.0 → 6.18.1
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 +39 -0
- package/CHANGELOG.md +71 -0
- package/managed-runtime-contract.json +8 -1
- package/package.json +2 -2
- package/server/index.ts +12 -0
- package/server/lib/g2-enrichment-runner.ts +179 -0
- package/server/lib/g2-ops-handoff.ts +136 -0
- package/server/lib/live-cues-capability.ts +83 -0
- package/server/lib/live-cues-cursor.ts +167 -0
- package/server/lib/live-cues-engine.ts +461 -0
- package/server/lib/live-cues-memory.ts +202 -0
- package/server/lib/live-cues-prompt.ts +98 -0
- package/server/lib/maintenance-lifecycle.ts +6 -0
- package/server/lib/meeting-batch-transcribe.ts +21 -1
- package/server/lib/meeting-store.ts +10 -1
- package/server/lib/whisper-local.ts +49 -6
- package/server/lib/whisper-metal-gate.ts +207 -0
- package/server/routes/health.ts +10 -0
- package/server/routes/live-cues.ts +58 -0
- package/server/routes/meeting.ts +43 -12
- package/server/routes/transcribe-stream.ts +54 -7
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// Live Cues memory hops.
|
|
2
|
+
//
|
|
3
|
+
// callPython() cannot be used here: it shells exactly one script,
|
|
4
|
+
// cos_api_bridge.py, whose dispatch has no semantic_search or lightrag_search
|
|
5
|
+
// verb — an unknown verb returns nothing useful, indistinguishable from an
|
|
6
|
+
// empty result. Both hops spawn python directly against the exported
|
|
7
|
+
// PYTHON_BIN / COS_SCRIPTS_DIR with their own preflight, timeout, and parse.
|
|
8
|
+
|
|
9
|
+
import { execFile, spawn, type ChildProcess } from 'node:child_process'
|
|
10
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
11
|
+
import { resolve } from 'node:path'
|
|
12
|
+
import { COS_SCRIPTS_DIR, PYTHON_BIN } from './python-bridge.js'
|
|
13
|
+
import { terminateProviderProcess } from './provider-process-lifecycle.js'
|
|
14
|
+
import { logTokenAudit } from './token-audit.js'
|
|
15
|
+
|
|
16
|
+
export interface SemanticHit {
|
|
17
|
+
title: string
|
|
18
|
+
date: string
|
|
19
|
+
domain: string
|
|
20
|
+
score: number
|
|
21
|
+
summary: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface Hop1Result {
|
|
25
|
+
ok: boolean
|
|
26
|
+
snippets: string[]
|
|
27
|
+
reason?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface Hop2Result {
|
|
31
|
+
ok: boolean
|
|
32
|
+
text: string | null
|
|
33
|
+
reason?: string
|
|
34
|
+
/** False when the python tree (which may hold claude -p grandchildren)
|
|
35
|
+
* could not be proven dead after a timeout kill. */
|
|
36
|
+
treeClosed: boolean
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const HOP1_TIMEOUT_MS = 15_000
|
|
40
|
+
const HOP2_TIMEOUT_MS = 40_000
|
|
41
|
+
|
|
42
|
+
// The LightRAG query pool is 200/day SHARED with /why, --explore, and prep,
|
|
43
|
+
// and it increments per LLM call (~2 per query), not per invocation. The
|
|
44
|
+
// reserve leaves headroom for interactive queries; it does NOT give cues an
|
|
45
|
+
// isolated pool (that would need a third pool in lightrag_adapter.py).
|
|
46
|
+
const LIGHTRAG_DAILY_CAP = 200
|
|
47
|
+
|
|
48
|
+
function lightragReserve(): number {
|
|
49
|
+
const raw = Number(process.env.COS_LIVE_CUES_LIGHTRAG_RESERVE ?? 120)
|
|
50
|
+
return Number.isFinite(raw) && raw >= 0 ? Math.floor(raw) : 120
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function lightragBudgetAllows(): boolean {
|
|
54
|
+
if (!COS_SCRIPTS_DIR) return false
|
|
55
|
+
try {
|
|
56
|
+
const counterPath = resolve(COS_SCRIPTS_DIR, '.lightrag_daily_calls.json')
|
|
57
|
+
if (!existsSync(counterPath)) return true
|
|
58
|
+
const parsed = JSON.parse(readFileSync(counterPath, 'utf-8')) as { date?: string; query?: number }
|
|
59
|
+
const today = new Date().toISOString().slice(0, 10)
|
|
60
|
+
if (parsed.date !== today) return true
|
|
61
|
+
const spent = Number.isFinite(parsed.query) ? Number(parsed.query) : 0
|
|
62
|
+
return (LIGHTRAG_DAILY_CAP - spent) > lightragReserve()
|
|
63
|
+
} catch {
|
|
64
|
+
// An unreadable counter fails toward NOT spending: the reserve exists to
|
|
65
|
+
// protect the interactive pool, and blind spending defeats it.
|
|
66
|
+
return false
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** hop1 — Qdrant semantic search. No LLM spend; plain execFile timeout is fine. */
|
|
71
|
+
export function semanticSearchHop(query: string): Promise<Hop1Result> {
|
|
72
|
+
const pythonBin = PYTHON_BIN
|
|
73
|
+
const scriptsDir = COS_SCRIPTS_DIR
|
|
74
|
+
if (!pythonBin || !scriptsDir) {
|
|
75
|
+
return Promise.resolve({ ok: false, snippets: [], reason: 'no_cos_pipeline' })
|
|
76
|
+
}
|
|
77
|
+
const script = resolve(scriptsDir, 'semantic_search.py')
|
|
78
|
+
if (!existsSync(script)) {
|
|
79
|
+
return Promise.resolve({ ok: false, snippets: [], reason: 'no_memory_scripts' })
|
|
80
|
+
}
|
|
81
|
+
return new Promise(resolveHop => {
|
|
82
|
+
execFile(
|
|
83
|
+
pythonBin,
|
|
84
|
+
// --min-score pinned explicitly: the CLI default is 0.25 but the
|
|
85
|
+
// programmatic default is 0.35, and inheriting the wrong one silently
|
|
86
|
+
// cuts recall.
|
|
87
|
+
[script, query, '--json', '--limit', '5', '--min-score', '0.25'],
|
|
88
|
+
{ cwd: scriptsDir, timeout: HOP1_TIMEOUT_MS, maxBuffer: 1024 * 1024 },
|
|
89
|
+
(error: Error | null, stdout: string | Buffer) => {
|
|
90
|
+
if (error) {
|
|
91
|
+
resolveHop({ ok: false, snippets: [], reason: 'qdrant_unreachable' })
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
const hits = JSON.parse(String(stdout)) as SemanticHit[]
|
|
96
|
+
const snippets = (Array.isArray(hits) ? hits : [])
|
|
97
|
+
.slice(0, 5)
|
|
98
|
+
.map(hit => `${hit.date ?? '?'} ${hit.title ?? 'Untitled'}: ${String(hit.summary ?? '').slice(0, 220)}`)
|
|
99
|
+
resolveHop({ ok: true, snippets })
|
|
100
|
+
} catch {
|
|
101
|
+
resolveHop({ ok: false, snippets: [], reason: 'qdrant_parse_error' })
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
)
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Strip the human chrome lightrag_search.py --explore prints around the
|
|
109
|
+
* answer. --explore returns before the --json branch — it can NEVER emit
|
|
110
|
+
* JSON, so this is a plain-text contract by construction. */
|
|
111
|
+
export function parseExploreOutput(raw: string): string {
|
|
112
|
+
return raw
|
|
113
|
+
.split('\n')
|
|
114
|
+
.filter(line => {
|
|
115
|
+
const trimmed = line.trim()
|
|
116
|
+
if (/^Exploring entity:/i.test(trimmed)) return false
|
|
117
|
+
if (/^-{10,}$/.test(trimmed)) return false
|
|
118
|
+
if (/^\[\d+(?:\.\d+)?s \| mode: /.test(trimmed)) return false
|
|
119
|
+
if (/^(Query|Mode):/i.test(trimmed)) return false
|
|
120
|
+
return true
|
|
121
|
+
})
|
|
122
|
+
.join('\n')
|
|
123
|
+
.trim()
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** hop2 — LightRAG entity exploration. Spends claude -p from the shared query
|
|
127
|
+
* pool; spawned detached because the python child holds claude grandchildren
|
|
128
|
+
* a plain timeout kill would orphan. */
|
|
129
|
+
export function lightragExploreHop(
|
|
130
|
+
entity: string,
|
|
131
|
+
onProcess?: (proc: ChildProcess) => void,
|
|
132
|
+
): Promise<Hop2Result> {
|
|
133
|
+
const pythonBin = PYTHON_BIN
|
|
134
|
+
const scriptsDir = COS_SCRIPTS_DIR
|
|
135
|
+
if (!pythonBin || !scriptsDir) {
|
|
136
|
+
return Promise.resolve({ ok: false, text: null, reason: 'no_cos_pipeline', treeClosed: true })
|
|
137
|
+
}
|
|
138
|
+
const script = resolve(scriptsDir, 'lightrag_search.py')
|
|
139
|
+
if (!existsSync(script)) {
|
|
140
|
+
return Promise.resolve({ ok: false, text: null, reason: 'no_memory_scripts', treeClosed: true })
|
|
141
|
+
}
|
|
142
|
+
if (!lightragBudgetAllows()) {
|
|
143
|
+
return Promise.resolve({ ok: false, text: null, reason: 'daily_graph_cap', treeClosed: true })
|
|
144
|
+
}
|
|
145
|
+
const startedAt = Date.now()
|
|
146
|
+
return new Promise(resolveHop => {
|
|
147
|
+
let settled = false
|
|
148
|
+
let stdout = ''
|
|
149
|
+
let timedOut = false
|
|
150
|
+
|
|
151
|
+
const proc = spawn(pythonBin, [script, '--explore', entity], {
|
|
152
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
153
|
+
cwd: scriptsDir,
|
|
154
|
+
env: { ...process.env },
|
|
155
|
+
detached: true,
|
|
156
|
+
})
|
|
157
|
+
onProcess?.(proc)
|
|
158
|
+
|
|
159
|
+
const finish = (result: Hop2Result) => {
|
|
160
|
+
if (settled) return
|
|
161
|
+
settled = true
|
|
162
|
+
clearTimeout(timer)
|
|
163
|
+
resolveHop(result)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const timer = setTimeout(() => {
|
|
167
|
+
timedOut = true
|
|
168
|
+
void terminateProviderProcess(proc).then(termination => {
|
|
169
|
+
finish({ ok: false, text: null, reason: 'graph_timeout', treeClosed: termination.closed })
|
|
170
|
+
})
|
|
171
|
+
}, HOP2_TIMEOUT_MS)
|
|
172
|
+
|
|
173
|
+
proc.on('error', () => finish({ ok: false, text: null, reason: 'graph_spawn_failed', treeClosed: !proc.pid }))
|
|
174
|
+
proc.stdout?.on('data', chunk => {
|
|
175
|
+
stdout += String(chunk)
|
|
176
|
+
if (stdout.length > 1024 * 1024) stdout = stdout.slice(-1024 * 1024)
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
proc.on('close', code => {
|
|
180
|
+
if (timedOut) return
|
|
181
|
+
const durationMs = Date.now() - startedAt
|
|
182
|
+
if (code !== 0) {
|
|
183
|
+
finish({ ok: false, text: null, reason: 'graph_failed', treeClosed: true })
|
|
184
|
+
return
|
|
185
|
+
}
|
|
186
|
+
const text = parseExploreOutput(stdout)
|
|
187
|
+
if (!text || /^LightRAG query failed/im.test(stdout)) {
|
|
188
|
+
finish({ ok: false, text: null, reason: 'graph_empty', treeClosed: true })
|
|
189
|
+
return
|
|
190
|
+
}
|
|
191
|
+
logTokenAudit({
|
|
192
|
+
source: 'live-cues',
|
|
193
|
+
model: 'lightrag',
|
|
194
|
+
inputChars: entity.length,
|
|
195
|
+
outputChars: text.length,
|
|
196
|
+
durationMs,
|
|
197
|
+
caller: 'live-cues-lightrag',
|
|
198
|
+
})
|
|
199
|
+
finish({ ok: true, text: text.slice(0, 4_000), treeClosed: true })
|
|
200
|
+
})
|
|
201
|
+
})
|
|
202
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Live Cues prompts — planner and insight, both Composer asks via the
|
|
2
|
+
// dedicated spawn (live-cues-cursor.ts). The bridge's system prompt is gone,
|
|
3
|
+
// so these prompts own EVERY constraint themselves, including the anti-markdown
|
|
4
|
+
// rule (the old bridge prompt forbade structured output, which is why the
|
|
5
|
+
// bridge cannot be used) and the attribution rule (with sparse voiceprints,
|
|
6
|
+
// diarization collapses to owner/Ext and a model will invent named speakers).
|
|
7
|
+
|
|
8
|
+
export interface PlannerResult {
|
|
9
|
+
query: string
|
|
10
|
+
entity: string | null
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface InsightResult {
|
|
14
|
+
nudge: string
|
|
15
|
+
type: string
|
|
16
|
+
priority: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const JSON_ONLY_RULES = [
|
|
20
|
+
'Output ONLY minified JSON on a single line. No markdown, no code fences, no prose before or after.',
|
|
21
|
+
'If you have nothing sharp to contribute, output the literal token null.',
|
|
22
|
+
].join(' ')
|
|
23
|
+
|
|
24
|
+
export function buildPlannerPrompt(transcriptWindow: string): string {
|
|
25
|
+
return [
|
|
26
|
+
'You plan memory lookups for a live-meeting coaching system.',
|
|
27
|
+
'Given the transcript window below, produce the single most valuable memory query.',
|
|
28
|
+
JSON_ONLY_RULES,
|
|
29
|
+
'Schema: {"query": string, "entity": string | null}',
|
|
30
|
+
'- "query": a semantic search phrase for past-meeting retrieval (topics, decisions, commitments). Under 12 words.',
|
|
31
|
+
'- "entity": ONE named person, company, or project from the transcript worth exploring in the knowledge graph, or null.',
|
|
32
|
+
'Output null when the window is small talk, logistics, or filler with no retrievable substance.',
|
|
33
|
+
'',
|
|
34
|
+
'TRANSCRIPT WINDOW:',
|
|
35
|
+
transcriptWindow,
|
|
36
|
+
].join('\n')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function buildInsightPrompt(input: {
|
|
40
|
+
transcriptWindow: string
|
|
41
|
+
memorySnippets: string[]
|
|
42
|
+
graphContext: string | null
|
|
43
|
+
}): string {
|
|
44
|
+
const memory = input.memorySnippets.length
|
|
45
|
+
? input.memorySnippets.map((snippet, index) => `${index + 1}. ${snippet}`).join('\n')
|
|
46
|
+
: '(no related past meetings found)'
|
|
47
|
+
return [
|
|
48
|
+
'You produce ONE live coaching cue for smart-glasses during a meeting.',
|
|
49
|
+
'The wearer sees at most 85 characters, so the cue must be a single sharp line.',
|
|
50
|
+
JSON_ONLY_RULES,
|
|
51
|
+
'Schema: {"nudge": string, "type": string, "priority": number}',
|
|
52
|
+
'- "nudge": max 85 characters, max 14 words. Plain text. No markdown, no asterisks, no em dashes.',
|
|
53
|
+
'- "type": a short snake_case category, e.g. commitment_check, past_context, open_question, risk_flag. NEVER the literal string coaching_nudge.',
|
|
54
|
+
'- "priority": 1 (glance-worthy) to 3 (say this now).',
|
|
55
|
+
'Rules:',
|
|
56
|
+
'- The cue must use the MEMORY or GRAPH context to surface an edge the room cannot see. Do not replay what was just said.',
|
|
57
|
+
'- Never attribute a statement to a named person unless the transcript line is labeled with that name. Prefer "someone raised X".',
|
|
58
|
+
'- A sharp question is a valid cue when context is thin.',
|
|
59
|
+
'- Output null unless the cue is genuinely worth interrupting a meeting for.',
|
|
60
|
+
'',
|
|
61
|
+
'TRANSCRIPT WINDOW:',
|
|
62
|
+
input.transcriptWindow,
|
|
63
|
+
'',
|
|
64
|
+
'MEMORY (related past meetings):',
|
|
65
|
+
memory,
|
|
66
|
+
'',
|
|
67
|
+
'GRAPH CONTEXT:',
|
|
68
|
+
input.graphContext ?? '(unavailable this cycle)',
|
|
69
|
+
].join('\n')
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Parse a Composer reply that was instructed to emit minified JSON or null.
|
|
73
|
+
* Tolerates accidental code fences; anything else unparseable returns null. */
|
|
74
|
+
export function parseJsonReply<T>(raw: string, validate: (value: unknown) => value is T): T | null {
|
|
75
|
+
const trimmed = raw.trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '').trim()
|
|
76
|
+
if (!trimmed || trimmed === 'null') return null
|
|
77
|
+
try {
|
|
78
|
+
const parsed: unknown = JSON.parse(trimmed)
|
|
79
|
+
return validate(parsed) ? parsed : null
|
|
80
|
+
} catch {
|
|
81
|
+
return null
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function isPlannerResult(value: unknown): value is PlannerResult {
|
|
86
|
+
if (!value || typeof value !== 'object') return false
|
|
87
|
+
const record = value as Record<string, unknown>
|
|
88
|
+
return typeof record.query === 'string' && record.query.trim().length > 0
|
|
89
|
+
&& (record.entity === null || typeof record.entity === 'string')
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function isInsightResult(value: unknown): value is InsightResult {
|
|
93
|
+
if (!value || typeof value !== 'object') return false
|
|
94
|
+
const record = value as Record<string, unknown>
|
|
95
|
+
return typeof record.nudge === 'string' && record.nudge.trim().length > 0
|
|
96
|
+
&& typeof record.type === 'string' && record.type.trim().length > 0
|
|
97
|
+
&& typeof record.priority === 'number' && Number.isFinite(record.priority)
|
|
98
|
+
}
|
|
@@ -47,6 +47,12 @@ export type MaintenanceWorkKind =
|
|
|
47
47
|
| 'prompt_draft_write'
|
|
48
48
|
| 'prompt_draft_warm'
|
|
49
49
|
| 'prompt_draft_finalize'
|
|
50
|
+
// Live Cues pipeline (planner + memory hops + insight). Held for the
|
|
51
|
+
// pipeline's in-flight duration so an Update Server drain cannot unload the
|
|
52
|
+
// server while a Cursor CLI or python child is live. The pipeline wall
|
|
53
|
+
// budget must stay under COS Control's 90s drain timeout (main.swift:1853)
|
|
54
|
+
// or every drain that catches a cue in flight hard-fails to Repair.
|
|
55
|
+
| 'live_cue_pipeline'
|
|
50
56
|
|
|
51
57
|
export type MaintenanceWorkPhase = 'queued' | 'active'
|
|
52
58
|
export type MaintenanceOperationScope = 'same_boot' | 'cross_boot'
|
|
@@ -6,6 +6,7 @@ import { existsSync, readFileSync, readdirSync, utimesSync, writeFileSync } from
|
|
|
6
6
|
import { join, resolve } from 'node:path'
|
|
7
7
|
import { enhanceAudio } from './audio-enhance.js'
|
|
8
8
|
import { transcribeHighQuality, type WhisperWord } from './whisper-local.js'
|
|
9
|
+
import { isMetalBatchPreempted } from './whisper-metal-gate.js'
|
|
9
10
|
import type { IndexedTranscriptChunk } from '../routes/transcribe-stream.js'
|
|
10
11
|
import {
|
|
11
12
|
evaluateBatchQuality,
|
|
@@ -172,7 +173,26 @@ async function transcribeSegments(
|
|
|
172
173
|
const combined = concatenateWavChunks(audioDir, segment.startChunkIdx, segment.endChunkIdx)
|
|
173
174
|
const enhanced = await enhanceAudio(combined)
|
|
174
175
|
const previousText = results.at(-1)?.text
|
|
175
|
-
|
|
176
|
+
let result
|
|
177
|
+
try {
|
|
178
|
+
result = await transcribeHighQuality(enhanced, previousText?.slice(-250), { priority: 'batch' })
|
|
179
|
+
} catch (error) {
|
|
180
|
+
// A live meeting took the GPU mid-segment. The truncated Metal output
|
|
181
|
+
// was already discarded upstream (BLOCKER contract) — it is never
|
|
182
|
+
// accepted. Retry this SAME segment once on CPU, which cannot itself be
|
|
183
|
+
// preempted, so a busy day degrades to slow rather than to a silently
|
|
184
|
+
// missing stretch of transcript.
|
|
185
|
+
if (!isMetalBatchPreempted(error)) throw error
|
|
186
|
+
console.log(
|
|
187
|
+
`[meeting-batch] Segment ${segment.startChunkIdx}-${segment.endChunkIdx} preempted off Metal; `
|
|
188
|
+
+ 'retrying once on CPU',
|
|
189
|
+
)
|
|
190
|
+
refreshPendingLease(audioDir)
|
|
191
|
+
result = await transcribeHighQuality(enhanced, previousText?.slice(-250), {
|
|
192
|
+
priority: 'batch',
|
|
193
|
+
forceCpu: true,
|
|
194
|
+
})
|
|
195
|
+
}
|
|
176
196
|
const text = previousText ? stripOverlap(result.text, previousText) : result.text
|
|
177
197
|
const words = result.words ?? []
|
|
178
198
|
results.push({
|
|
@@ -409,7 +409,16 @@ export class MeetingStore {
|
|
|
409
409
|
'',
|
|
410
410
|
'## Summary',
|
|
411
411
|
'',
|
|
412
|
-
|
|
412
|
+
// When COS ops is configured, use the private-app pipeline markers so
|
|
413
|
+
// sync_meetings.py --g2-file will enrich (and reclassify domain). Plain
|
|
414
|
+
// "Standalone recording" summaries are treated as already-final and skipped.
|
|
415
|
+
...(process.env.COS_SCRIPTS_DIR || process.env.COS_OPERATIONS_DIR || process.env.COS_MEETINGS_ROOT
|
|
416
|
+
? [
|
|
417
|
+
'<!-- g2-needs-domain-review -->',
|
|
418
|
+
'',
|
|
419
|
+
'*G2 recording — summary pending pipeline processing.*',
|
|
420
|
+
]
|
|
421
|
+
: ['*Standalone recording — canonical transcript shown in meeting detail.*']),
|
|
413
422
|
'',
|
|
414
423
|
'## Transcript',
|
|
415
424
|
'',
|
|
@@ -14,6 +14,13 @@ import { homedir } from 'node:os'
|
|
|
14
14
|
import crypto from 'node:crypto'
|
|
15
15
|
import { getVocabulary, getOwnerName } from './profile.js'
|
|
16
16
|
import { stripBrandUrls } from './hallucination-filter.js'
|
|
17
|
+
import {
|
|
18
|
+
batchHqMetalEnabled,
|
|
19
|
+
chooseBatchDevice,
|
|
20
|
+
MetalBatchPreemptedError,
|
|
21
|
+
registerMetalBatchChild,
|
|
22
|
+
unregisterMetalBatchChild,
|
|
23
|
+
} from './whisper-metal-gate.js'
|
|
17
24
|
|
|
18
25
|
// Prompt hardening flags (transcription quality, 2026-05-29):
|
|
19
26
|
// COS_PROMPT_V2 — drop the trailing '.' on the vocab prompt and join prompt+context
|
|
@@ -697,7 +704,10 @@ export function parseWhisperCliFullJson(raw: string): { text: string; words: Whi
|
|
|
697
704
|
export async function transcribeHighQuality(
|
|
698
705
|
audioBuffer: Buffer,
|
|
699
706
|
context?: string,
|
|
700
|
-
|
|
707
|
+
/** forceCpu: the batch pipeline's one CPU retry after a Metal preempt. It
|
|
708
|
+
* bypasses the gate entirely so the retry cannot itself be preempted into
|
|
709
|
+
* an infinite loop. */
|
|
710
|
+
opts: { priority?: 'interactive' | 'batch'; forceCpu?: boolean } = {},
|
|
701
711
|
): Promise<HighQualityTranscriptionResult> {
|
|
702
712
|
if (!cliAvailable) {
|
|
703
713
|
// Fall back to server (no beam search available via HTTP API)
|
|
@@ -727,22 +737,33 @@ export async function transcribeHighQuality(
|
|
|
727
737
|
try {
|
|
728
738
|
writeFileSync(tmpWav, audioBuffer)
|
|
729
739
|
|
|
740
|
+
// Interactive HQ keeps Metal unconditionally — a short, user-blocking decode
|
|
741
|
+
// that is explicitly OUT of batch device policy. Only the long post-meeting
|
|
742
|
+
// batch is admission-controlled against live ASR.
|
|
743
|
+
const isBatch = opts.priority === 'batch'
|
|
744
|
+
const decision: { device: 'metal' | 'cpu'; reason: string; metalEnabled: boolean } = isBatch
|
|
745
|
+
? (opts.forceCpu
|
|
746
|
+
? { device: 'cpu', reason: 'preempt_retry', metalEnabled: batchHqMetalEnabled() }
|
|
747
|
+
: chooseBatchDevice())
|
|
748
|
+
: { device: 'metal', reason: 'interactive', metalEnabled: batchHqMetalEnabled() }
|
|
749
|
+
const useMetal = decision.device === 'metal'
|
|
750
|
+
|
|
730
751
|
const text = await new Promise<string>((resolve, reject) => {
|
|
731
|
-
const isolateBatchFromLiveMetal = opts.priority === 'batch'
|
|
732
752
|
// Interactive HQ: narrower beam (default 2) for latency. Meeting batch keeps 5.
|
|
733
753
|
// Override: COS_HQ_BEAM_INTERACTIVE=N
|
|
734
754
|
const interactiveBeamRaw = Number.parseInt(process.env.COS_HQ_BEAM_INTERACTIVE || '2', 10)
|
|
735
755
|
const interactiveBeam = Number.isFinite(interactiveBeamRaw) && interactiveBeamRaw >= 1
|
|
736
756
|
? Math.min(interactiveBeamRaw, 5)
|
|
737
757
|
: 2
|
|
738
|
-
const beam =
|
|
758
|
+
const beam = isBatch ? 5 : interactiveBeam
|
|
739
759
|
const bestOf = beam
|
|
740
760
|
const args = [
|
|
741
761
|
'-m', modelPath,
|
|
742
762
|
'-f', tmpWav,
|
|
743
|
-
|
|
763
|
+
// CPU batch stays at 8 threads so it cannot starve live work of cores.
|
|
764
|
+
'-t', (isBatch && !useMetal) ? '8' : '16',
|
|
744
765
|
'-l', 'en',
|
|
745
|
-
...(
|
|
766
|
+
...(useMetal ? ['-fa'] : ['-ng']),
|
|
746
767
|
'-bs', String(beam),
|
|
747
768
|
'-bo', String(bestOf),
|
|
748
769
|
'--no-timestamps',
|
|
@@ -763,6 +784,16 @@ export async function transcribeHighQuality(
|
|
|
763
784
|
})
|
|
764
785
|
ownedHqChildren.add(proc)
|
|
765
786
|
|
|
787
|
+
// BLOCKER contract: a preempted Metal child is a HARD FAIL. Its stdout
|
|
788
|
+
// and its -ojf JSON are truncated mid-decode, and writing that into a
|
|
789
|
+
// saved meeting is silent transcript corruption — strictly worse than a
|
|
790
|
+
// slow or failed batch. We record the preempt BEFORE the signal lands so
|
|
791
|
+
// the close handler can never mistake a truncated run for a clean exit.
|
|
792
|
+
let preemptedReason: string | null = null
|
|
793
|
+
if (isBatch && useMetal) {
|
|
794
|
+
registerMetalBatchChild(proc, reason => { preemptedReason = reason })
|
|
795
|
+
}
|
|
796
|
+
|
|
766
797
|
let stdout = ''
|
|
767
798
|
let stderr = ''
|
|
768
799
|
proc.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString() })
|
|
@@ -785,8 +816,16 @@ export async function transcribeHighQuality(
|
|
|
785
816
|
|
|
786
817
|
proc.on('close', (code) => {
|
|
787
818
|
ownedHqChildren.delete(proc)
|
|
819
|
+
unregisterMetalBatchChild(proc)
|
|
788
820
|
clearTimeout(timeout)
|
|
789
821
|
if (forceKill) clearTimeout(forceKill)
|
|
822
|
+
// Preempt is checked FIRST and ignores the exit code: SIGTERM often
|
|
823
|
+
// yields a non-zero code, but a race could also let the child exit 0
|
|
824
|
+
// with partial output. Either way the text is discarded.
|
|
825
|
+
if (preemptedReason) {
|
|
826
|
+
reject(new MetalBatchPreemptedError(preemptedReason))
|
|
827
|
+
return
|
|
828
|
+
}
|
|
790
829
|
if (timedOut) {
|
|
791
830
|
reject(new Error(`whisper-cli HQ timeout (${timeoutMs / 1000}s, model=${useLargeV3 ? 'large-v3' : 'turbo'})`))
|
|
792
831
|
return
|
|
@@ -800,6 +839,7 @@ export async function transcribeHighQuality(
|
|
|
800
839
|
|
|
801
840
|
proc.on('error', (err) => {
|
|
802
841
|
ownedHqChildren.delete(proc)
|
|
842
|
+
unregisterMetalBatchChild(proc)
|
|
803
843
|
clearTimeout(timeout)
|
|
804
844
|
if (forceKill) clearTimeout(forceKill)
|
|
805
845
|
reject(new Error(`whisper-cli HQ spawn error: ${err.message}`))
|
|
@@ -831,7 +871,10 @@ export async function transcribeHighQuality(
|
|
|
831
871
|
console.log(
|
|
832
872
|
`[whisper-hq] Batch transcribed in ${elapsed}ms ` +
|
|
833
873
|
`(${modelTag}${useVad ? '+vad' : ''}${captureBatchWords ? '+words' : ''}` +
|
|
834
|
-
`${words ? `, ${words.length} words` : ''}
|
|
874
|
+
`${words ? `, ${words.length} words` : ''}` +
|
|
875
|
+
// Device forensics: without these, "why is polish slow today" is
|
|
876
|
+
// unanswerable after the fact.
|
|
877
|
+
`${isBatch ? `, device=${decision.device} reason=${decision.reason} metalEnabled=${decision.metalEnabled}` : ''}): ` +
|
|
835
878
|
`"${corrected.slice(0, 80)}${corrected.length > 80 ? '...' : ''}"`,
|
|
836
879
|
)
|
|
837
880
|
const metadata = useLargeV3
|