@gotcos/glasses-server 6.16.9 → 6.18.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.
@@ -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'
@@ -409,7 +409,16 @@ export class MeetingStore {
409
409
  '',
410
410
  '## Summary',
411
411
  '',
412
- '*Standalone recording canonical transcript shown in meeting detail.*',
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
  '',
@@ -1,16 +1,38 @@
1
1
  // Profile loader — reads user identity from .cos-profile.json (gitignored)
2
2
  // Falls back to generic defaults for users who haven't configured a profile
3
3
 
4
- import { readFileSync } from 'node:fs'
4
+ import { existsSync, readFileSync } from 'node:fs'
5
+ import { homedir } from 'node:os'
5
6
  import { resolve } from 'node:path'
6
7
  import { atomicWriteFileSync } from './atomic-fs.js'
7
8
 
8
9
  const APP_ROOT = resolve(import.meta.dirname, '../..')
10
+
11
+ /** The profile in the data home. Survives updates; the APP_ROOT copy does not. */
12
+ export function homeProfilePath(): string {
13
+ return resolve(homedir(), '.cos-glasses', '.cos-profile.json')
14
+ }
15
+
9
16
  // Single canonical path — used by BOTH the reader and the writer so a glossary
10
17
  // PUT can never write to a different file than the cache reads from. Lazy +
11
18
  // env-overridable (COS_PROFILE_PATH) so tests can target a temp file.
19
+ //
20
+ // APP_ROOT is the INSTALLED PACKAGE root. For a managed install that is inside
21
+ // the generation directory, which an update replaces wholesale — so a profile
22
+ // there is destroyed on every upgrade, and a fresh managed install has no
23
+ // profile at all (loadProfile() catches to {} and every field silently takes
24
+ // its default). Relying on COS_PROFILE_PATH alone is not enough either: the
25
+ // launcher rebuilds its environment from the release manifest rather than the
26
+ // existing plist, so an operator-set value is dropped by the next
27
+ // install/update/repair. Prefer the data home whenever a profile lives there.
28
+ //
29
+ // Order: explicit override → ~/.cos-glasses/.cos-profile.json → package root.
12
30
  function profilePath(): string {
13
- return process.env.COS_PROFILE_PATH || resolve(APP_ROOT, '.cos-profile.json')
31
+ const override = process.env.COS_PROFILE_PATH?.trim()
32
+ if (override) return resolve(override)
33
+ const home = homeProfilePath()
34
+ if (existsSync(home)) return home
35
+ return resolve(APP_ROOT, '.cos-profile.json')
14
36
  }
15
37
 
16
38
  let profileCache: Record<string, unknown> | null = null
@@ -8,18 +8,62 @@
8
8
  import { resolve } from 'node:path'
9
9
  import { errMsg } from './utils.js'
10
10
  import { getOwnerSpeakerLabel } from './profile.js'
11
- import { readFileSync, writeFileSync, existsSync, appendFileSync, mkdirSync } from 'node:fs'
11
+ import { readFileSync, writeFileSync, existsSync, appendFileSync, mkdirSync, statSync, openSync, readSync, closeSync } from 'node:fs'
12
+ import { homedir } from 'node:os'
13
+ import { spawnSync } from 'node:child_process'
12
14
  import { fileURLToPath } from 'node:url'
13
15
 
16
+ /** A real voiceprint model is ~26 MB; anything this small is a bad download. */
17
+ const MIN_MODEL_BYTES = 1_000_000
18
+ const PROBE_TIMEOUT_MS = 30_000
19
+
14
20
  // sherpa-onnx-node is CJS — use createRequire for ESM compat
15
21
  import { createRequire } from 'node:module'
16
22
  const require = createRequire(import.meta.url)
17
23
 
18
24
  const __dirname = fileURLToPath(new URL('.', import.meta.url))
19
25
 
20
- const MODEL_PATH = resolve(__dirname, '..', 'models',
21
- '3dspeaker_speech_eres2net_sv_en_voxceleb_16k.onnx')
22
26
  import { DATA_DIR } from './data-dir.js'
27
+
28
+ export const SPEAKER_MODEL_FILENAME = '3dspeaker_speech_eres2net_sv_en_voxceleb_16k.onnx'
29
+
30
+ /** Where the voiceprint model may live, in priority order:
31
+ *
32
+ * 1. COS_SPEAKER_MODEL_PATH — explicit override (full path to the .onnx).
33
+ * 2. ~/.cos-glasses/models/ — the bolt-on location. The model is ~26 MB and is
34
+ * deliberately NOT in the npm tarball, so a managed install has no bundled
35
+ * copy. The data home survives generation swaps; anything inside the
36
+ * installed package does not, and a model dropped there is destroyed by the
37
+ * next update.
38
+ * 3. server/models/ — bundled, which only exists in a source checkout.
39
+ *
40
+ * Anchored on homedir() rather than DATA_DIR/'..' on purpose: path.resolve is
41
+ * purely lexical, so deriving a sibling of a relocated COS_DATA_DIR could point
42
+ * the "durable" candidate at an unwritable root, or — if COS_DATA_DIR were ever
43
+ * set inside the package — collapse it back onto the very directory an update
44
+ * destroys, silently reintroducing the bug this ordering exists to fix.
45
+ *
46
+ * Diarization is opt-in by design: with no model the system stays on amplitude
47
+ * fallback (wearer vs Ext) rather than failing. speakerModelState() exists so
48
+ * that choice is VISIBLE in /api/health instead of silently degrading.
49
+ */
50
+ export function speakerModelCandidates(): string[] {
51
+ const override = process.env.COS_SPEAKER_MODEL_PATH?.trim()
52
+ return [
53
+ ...(override ? [resolve(override)] : []),
54
+ resolve(homedir(), '.cos-glasses', 'models', SPEAKER_MODEL_FILENAME),
55
+ resolve(__dirname, '..', 'models', SPEAKER_MODEL_FILENAME),
56
+ ]
57
+ }
58
+
59
+ function resolveSpeakerModelPath(): string | null {
60
+ // isFile, not existsSync: a directory passes an existence check and would be
61
+ // handed to the native loader as a model.
62
+ return speakerModelCandidates().find(p => {
63
+ try { return statSync(p).isFile() } catch { return false }
64
+ }) ?? null
65
+ }
66
+
23
67
  const PROFILES_PATH = resolve(DATA_DIR, 'voice-profiles.json')
24
68
  const CALIBRATION_LOG = resolve(DATA_DIR, 'speaker-calibration.jsonl')
25
69
 
@@ -58,14 +102,109 @@ interface ProfileStore {
58
102
  profiles: VoiceProfile[]
59
103
  }
60
104
 
105
+ /** Cheap structural screen for a downloaded model.
106
+ *
107
+ * Catches the common bad downloads — an HTML error/redirect page saved as
108
+ * .onnx, or a truncated transfer — before the file reaches the native runtime.
109
+ * ONNX is protobuf: field 1 (ir_version, varint) encodes as a leading 0x08.
110
+ * This is a screen, not validation; probeModelSafely() is the real gate. */
111
+ function looksLikeOnnxModel(path: string): boolean {
112
+ try {
113
+ if (statSync(path).size < MIN_MODEL_BYTES) return false
114
+ const head = Buffer.alloc(1)
115
+ const fd = openSync(path, 'r')
116
+ try { readSync(fd, head, 0, 1, 0) } finally { closeSync(fd) }
117
+ return head[0] === 0x08
118
+ } catch { return false }
119
+ }
120
+
121
+ /** Load the model in a throwaway child process first.
122
+ *
123
+ * onnxruntime does not throw on a malformed or mismatched model — it calls
124
+ * std::terminate, so the process dies with SIGABRT (or SIGSEGV for a valid-but-
125
+ * wrong model). No try/catch can intercept that. In a managed install the
126
+ * LaunchAgent has KeepAlive, so the death becomes a permanent restart loop that
127
+ * takes down queries, meetings, and transcription — the whole server, over an
128
+ * optional feature.
129
+ *
130
+ * Absorbing that crash in a child keeps a bad file a diarization problem
131
+ * instead of an outage. Cost is one short-lived process, once, and only when a
132
+ * model is actually present. */
133
+ function probeModelSafely(modelPath: string): { ok: true } | { ok: false; reason: string } {
134
+ const script =
135
+ "const{SpeakerEmbeddingExtractor}=require('sherpa-onnx-node');" +
136
+ "new SpeakerEmbeddingExtractor({model:process.argv[1],numThreads:1,provider:'cpu'});"
137
+ const probe = spawnSync(process.execPath, ['-e', script, modelPath], {
138
+ cwd: resolve(__dirname, '..', '..'), // package root, so sherpa-onnx-node resolves
139
+ timeout: PROBE_TIMEOUT_MS,
140
+ stdio: ['ignore', 'ignore', 'pipe'],
141
+ })
142
+ if (probe.signal) return { ok: false, reason: `native runtime aborted (${probe.signal})` }
143
+ if (probe.error) return { ok: false, reason: probe.error.message }
144
+ if (probe.status !== 0) {
145
+ const stderr = String(probe.stderr ?? '').trim().split('\n').pop() ?? ''
146
+ return { ok: false, reason: stderr || `probe exited ${probe.status}` }
147
+ }
148
+ return { ok: true }
149
+ }
150
+
61
151
  /** Initialize speaker embedding system. Returns false if model missing (graceful degradation). */
62
152
  export function initSpeakerEmbeddings(): boolean {
63
153
  if (initialized) return extractor !== null
64
154
 
65
155
  initialized = true
66
156
 
67
- if (!existsSync(MODEL_PATH)) {
68
- console.log('[speaker] Model not found at', MODEL_PATH, '— embedding disabled, using amplitude fallback')
157
+ const override = process.env.COS_SPEAKER_MODEL_PATH?.trim()
158
+ if (override) {
159
+ const overridePath = resolve(override)
160
+ let usable = false
161
+ try { usable = statSync(overridePath).isFile() } catch { usable = false }
162
+ if (!usable) {
163
+ // Falling through silently would leave the operator believing an override
164
+ // they mistyped (or pointed at a directory) is in effect.
165
+ console.warn(
166
+ `[speaker] COS_SPEAKER_MODEL_PATH=${overridePath} is not a readable file — ignoring it`,
167
+ 'and falling back to the remaining candidates.',
168
+ )
169
+ }
170
+ if (override !== overridePath) {
171
+ // Relative paths resolve against cwd, which under launchd is the installed
172
+ // package — a location the next update deletes.
173
+ console.warn(
174
+ `[speaker] COS_SPEAKER_MODEL_PATH is relative; resolved against the working directory to ${overridePath}.`,
175
+ 'Use an absolute path so it cannot move with the process.',
176
+ )
177
+ }
178
+ }
179
+
180
+ const modelPath = resolveSpeakerModelPath()
181
+ if (!modelPath) {
182
+ const boltOn = resolve(homedir(), '.cos-glasses', 'models')
183
+ console.log(
184
+ '[speaker] Voiceprint model not found — embedding disabled, speaker labels come from the client.',
185
+ `Searched: ${speakerModelCandidates().join(', ')}.`,
186
+ // Name the durable directory outright. An ordinal ("the second path")
187
+ // shifts with the override and pointed users at the package copy, which
188
+ // the next update deletes.
189
+ `To enable diarization put ${SPEAKER_MODEL_FILENAME} in ${boltOn}/ and restart.`,
190
+ )
191
+ return false
192
+ }
193
+
194
+ if (!looksLikeOnnxModel(modelPath)) {
195
+ console.error(
196
+ `[speaker] ${modelPath} does not look like an ONNX model (too small, or not protobuf) —`,
197
+ 'embedding disabled. A partial download or an HTML error page saved as .onnx does this.',
198
+ )
199
+ return false
200
+ }
201
+
202
+ const probe = probeModelSafely(modelPath)
203
+ if (!probe.ok) {
204
+ console.error(
205
+ `[speaker] ${modelPath} failed to load — embedding disabled. Reason: ${probe.reason}.`,
206
+ 'The server is otherwise unaffected; replace the model file and restart.',
207
+ )
69
208
  return false
70
209
  }
71
210
 
@@ -73,7 +212,7 @@ export function initSpeakerEmbeddings(): boolean {
73
212
  sherpaOnnx = require('sherpa-onnx-node')
74
213
 
75
214
  extractor = new sherpaOnnx.SpeakerEmbeddingExtractor({
76
- model: MODEL_PATH,
215
+ model: modelPath,
77
216
  numThreads: 2,
78
217
  provider: 'cpu',
79
218
  })
@@ -332,6 +471,30 @@ export function isEmbeddingAvailable(): boolean {
332
471
  return extractor !== null && manager !== null
333
472
  }
334
473
 
474
+ /** Reported on /api/health so an amplitude fallback is never mistaken for real
475
+ * diarization.
476
+ *
477
+ * `state` is the RUNTIME truth (isEmbeddingAvailable), not "is a model file on
478
+ * disk". Those diverge in both directions: a model deleted after a successful
479
+ * load leaves diarization working from memory, and a model present alongside a
480
+ * broken/ABI-mismatched sherpa-onnx never loads at all. `error` distinguishes
481
+ * that second case — a model is installed but the runtime rejected it — from a
482
+ * simply unconfigured install, which otherwise look identical to an operator.
483
+ *
484
+ * Declared after the module state it reads: hoisting it above `extractor`
485
+ * would make any import-time caller throw a ReferenceError on an
486
+ * unauthenticated endpoint. */
487
+ export function speakerModelState(): {
488
+ state: 'active' | 'unavailable' | 'error'
489
+ path: string | null
490
+ searched: string[]
491
+ } {
492
+ const path = resolveSpeakerModelPath()
493
+ const running = isEmbeddingAvailable()
494
+ const state = running ? 'active' : (path && initialized ? 'error' : 'unavailable')
495
+ return { state, path, searched: speakerModelCandidates() }
496
+ }
497
+
335
498
  /** Compute actual cosine similarity between two raw embedding vectors */
336
499
  export function rawCosineSimilarity(a: Float32Array, b: Float32Array): number {
337
500
  if (a.length !== b.length) return 0
@@ -7,6 +7,7 @@ import { serverMetrics } from '../lib/server-metrics.js'
7
7
  import { getServerInstanceId } from '../lib/server-instance-id.js'
8
8
  import { localFirstMeetingsCapability } from '../lib/local-first-meetings-contract.js'
9
9
  import { isSileroAvailable } from '../lib/vad-silero.js'
10
+ import { speakerModelState } from '../lib/speaker-embeddings.js'
10
11
  import { getAvailableCliSessionId } from '../lib/claude-bridge.js'
11
12
  import {
12
13
  isWhisperLocalAvailable,
@@ -35,6 +36,7 @@ import { managedRuntimeCapability, managedServerVersion } from '../lib/managed-r
35
36
  import { getServerGenerationId } from '../lib/managed-runtime.js'
36
37
  import { maintenanceLifecycle } from '../lib/maintenance-lifecycle.js'
37
38
  import { getLocalTtsHealth, refreshLocalTtsHealth } from '../lib/tts-local.js'
39
+ import { liveCuesCapability } from '../lib/live-cues-capability.js'
38
40
 
39
41
  export const healthRouter = Router()
40
42
 
@@ -176,6 +178,12 @@ healthRouter.get('/health', async (_req, res) => {
176
178
 
177
179
  checks.silero_vad = isSileroAvailable() ? 'active' : 'disabled'
178
180
 
181
+ // Speaker diarization is opt-in (the ~26 MB voiceprint model ships outside the
182
+ // npm tarball), so publish its state rather than letting the amplitude
183
+ // fallback masquerade as working diarization. Availability only — the resolved
184
+ // path is a local filesystem detail and health is unauthenticated.
185
+ checks.speaker_id = speakerModelState().state
186
+
179
187
  // Health is unauthenticated. Publish only availability; the actual CLI
180
188
  // session id is a resumable runtime handle and belongs on authenticated
181
189
  // query/debug surfaces.
@@ -196,6 +204,9 @@ healthRouter.get('/health', async (_req, res) => {
196
204
  const recovery = managedRuntimeCapability()
197
205
  const maintenance = maintenanceLifecycle.snapshot()
198
206
  const tts_local = getLocalTtsHealth()
207
+ // Computed once per request; the same value feeds features.liveCues and
208
+ // capabilities.liveCues so the two surfaces can never disagree.
209
+ const liveCues = liveCuesCapability()
199
210
  const features = {
200
211
  claude: claudeAvailable,
201
212
  codex: codexAvailable,
@@ -212,6 +223,7 @@ healthRouter.get('/health', async (_req, res) => {
212
223
  durableQueryJobsProtocol: durableJobs.protocolVersion,
213
224
  localFirstMeetings: localFirstMeetings !== null,
214
225
  transcriptionPolicy: transcription.mode,
226
+ liveCues: liveCues.available,
215
227
  }
216
228
  const voice = {
217
229
  available: keyStatus.hasKey || tts_local.ready,
@@ -270,6 +282,7 @@ healthRouter.get('/health', async (_req, res) => {
270
282
  carriedAcrossBoot: maintenance.operation?.carriedAcrossBoot ?? false,
271
283
  },
272
284
  cliDebug: CLI_DEBUG_CAPABILITY,
285
+ liveCues,
273
286
  ...(localFirstMeetings ? { localFirstMeetings } : {}),
274
287
  },
275
288
  // /api/health is intentionally unauthenticated for setup diagnostics.
@@ -312,6 +325,10 @@ healthRouter.get('/models', async (req, res) => {
312
325
  transcription: { ...transcription, hq: transcriptionHq },
313
326
  cliDebug: CLI_DEBUG_CAPABILITY,
314
327
  recovery: managedRuntimeCapability(),
328
+ // Same helper as /api/health — the companion's 15s liveness poll reads
329
+ // THIS surface, so a value present only on /api/health leaves the
330
+ // live-cues indicator blind.
331
+ liveCues: liveCuesCapability(),
315
332
  ...(localFirstMeetings ? { localFirstMeetings } : {}),
316
333
  },
317
334
  })