@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.
- package/.env.example +21 -0
- package/CHANGELOG.md +74 -0
- package/README.md +49 -0
- package/managed-runtime-contract.json +6 -1
- package/package.json +1 -1
- 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-store.ts +10 -1
- package/server/lib/profile.ts +24 -2
- package/server/lib/speaker-embeddings.ts +169 -6
- package/server/routes/health.ts +17 -0
- package/server/routes/live-cues.ts +58 -0
- package/server/routes/meeting.ts +43 -12
- package/server/routes/transcribe-stream.ts +17 -2
- package/server/routes/voice.ts +9 -3
|
@@ -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
|
-
|
|
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
|
'',
|
package/server/lib/profile.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
68
|
-
|
|
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:
|
|
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
|
package/server/routes/health.ts
CHANGED
|
@@ -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
|
})
|