@gotcos/glasses-server 6.17.0 → 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 +43 -0
- package/managed-runtime-contract.json +6 -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-store.ts +10 -1
- 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 +17 -2
|
@@ -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/routes/health.ts
CHANGED
|
@@ -36,6 +36,7 @@ import { managedRuntimeCapability, managedServerVersion } from '../lib/managed-r
|
|
|
36
36
|
import { getServerGenerationId } from '../lib/managed-runtime.js'
|
|
37
37
|
import { maintenanceLifecycle } from '../lib/maintenance-lifecycle.js'
|
|
38
38
|
import { getLocalTtsHealth, refreshLocalTtsHealth } from '../lib/tts-local.js'
|
|
39
|
+
import { liveCuesCapability } from '../lib/live-cues-capability.js'
|
|
39
40
|
|
|
40
41
|
export const healthRouter = Router()
|
|
41
42
|
|
|
@@ -203,6 +204,9 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
203
204
|
const recovery = managedRuntimeCapability()
|
|
204
205
|
const maintenance = maintenanceLifecycle.snapshot()
|
|
205
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()
|
|
206
210
|
const features = {
|
|
207
211
|
claude: claudeAvailable,
|
|
208
212
|
codex: codexAvailable,
|
|
@@ -219,6 +223,7 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
219
223
|
durableQueryJobsProtocol: durableJobs.protocolVersion,
|
|
220
224
|
localFirstMeetings: localFirstMeetings !== null,
|
|
221
225
|
transcriptionPolicy: transcription.mode,
|
|
226
|
+
liveCues: liveCues.available,
|
|
222
227
|
}
|
|
223
228
|
const voice = {
|
|
224
229
|
available: keyStatus.hasKey || tts_local.ready,
|
|
@@ -277,6 +282,7 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
277
282
|
carriedAcrossBoot: maintenance.operation?.carriedAcrossBoot ?? false,
|
|
278
283
|
},
|
|
279
284
|
cliDebug: CLI_DEBUG_CAPABILITY,
|
|
285
|
+
liveCues,
|
|
280
286
|
...(localFirstMeetings ? { localFirstMeetings } : {}),
|
|
281
287
|
},
|
|
282
288
|
// /api/health is intentionally unauthenticated for setup diagnostics.
|
|
@@ -319,6 +325,10 @@ healthRouter.get('/models', async (req, res) => {
|
|
|
319
325
|
transcription: { ...transcription, hq: transcriptionHq },
|
|
320
326
|
cliDebug: CLI_DEBUG_CAPABILITY,
|
|
321
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(),
|
|
322
332
|
...(localFirstMeetings ? { localFirstMeetings } : {}),
|
|
323
333
|
},
|
|
324
334
|
})
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Live Cues routes. Authenticated by the global /api X-Cos-Token middleware
|
|
2
|
+
// (index.ts:148) like every other /api route — no route-local auth needed.
|
|
3
|
+
//
|
|
4
|
+
// POST /live-cues/start { sessionId } -> { armed, sessionId, pipelinesUsed }
|
|
5
|
+
// POST /live-cues/stop { sessionId } -> { nudgesGenerated, nudges }
|
|
6
|
+
// GET /live-cues/status -> engine snapshot + capability
|
|
7
|
+
|
|
8
|
+
import { Router } from 'express'
|
|
9
|
+
import { errMsg } from '../lib/utils.js'
|
|
10
|
+
import {
|
|
11
|
+
armLiveCues,
|
|
12
|
+
disarmLiveCues,
|
|
13
|
+
getLiveCuesStatus,
|
|
14
|
+
LiveCuesArmError,
|
|
15
|
+
} from '../lib/live-cues-engine.js'
|
|
16
|
+
import { liveCuesCapability } from '../lib/live-cues-capability.js'
|
|
17
|
+
|
|
18
|
+
export const liveCuesRouter = Router()
|
|
19
|
+
|
|
20
|
+
liveCuesRouter.post('/live-cues/start', async (req, res) => {
|
|
21
|
+
const sessionId = typeof (req.body as { sessionId?: unknown })?.sessionId === 'string'
|
|
22
|
+
? String((req.body as { sessionId: string }).sessionId).trim()
|
|
23
|
+
: ''
|
|
24
|
+
// A start with no sessionId is refused, never silently armed on a global
|
|
25
|
+
// counter — the per-meeting cap is only real when it is session-scoped.
|
|
26
|
+
if (!sessionId || !/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) {
|
|
27
|
+
return res.status(400).json({ error: 'sessionId required', reason: 'missing_session_id' })
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
const result = await armLiveCues(sessionId)
|
|
31
|
+
return res.json(result)
|
|
32
|
+
} catch (error) {
|
|
33
|
+
if (error instanceof LiveCuesArmError) {
|
|
34
|
+
return res.status(error.status).json({ error: error.message, reason: error.code })
|
|
35
|
+
}
|
|
36
|
+
return res.status(500).json({ error: errMsg(error) })
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
liveCuesRouter.post('/live-cues/stop', (req, res) => {
|
|
41
|
+
const sessionId = typeof (req.body as { sessionId?: unknown })?.sessionId === 'string'
|
|
42
|
+
? String((req.body as { sessionId: string }).sessionId).trim()
|
|
43
|
+
: ''
|
|
44
|
+
if (!sessionId) {
|
|
45
|
+
return res.status(400).json({ error: 'sessionId required', reason: 'missing_session_id' })
|
|
46
|
+
}
|
|
47
|
+
// Shape matches the existing client stop handler: it reads nudgesGenerated
|
|
48
|
+
// for its log line and hydrates meetingNudgeHistory from nudges when SSE
|
|
49
|
+
// missed events (the 200-event replay buffer is flooded by transcript_chunk).
|
|
50
|
+
return res.json(disarmLiveCues(sessionId))
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
liveCuesRouter.get('/live-cues/status', (_req, res) => {
|
|
54
|
+
res.json({
|
|
55
|
+
capability: liveCuesCapability(),
|
|
56
|
+
sessions: getLiveCuesStatus(),
|
|
57
|
+
})
|
|
58
|
+
})
|
package/server/routes/meeting.ts
CHANGED
|
@@ -41,6 +41,13 @@ import {
|
|
|
41
41
|
} from './transcribe-stream.js'
|
|
42
42
|
import { getServerInstanceId } from '../lib/server-instance-id.js'
|
|
43
43
|
import { acquireMaintenanceWork, type MaintenanceWorkLease } from '../lib/maintenance-lifecycle.js'
|
|
44
|
+
import { handoffMeetingToOperations } from '../lib/g2-ops-handoff.js'
|
|
45
|
+
|
|
46
|
+
function cosOpsPipelineConfigured(): boolean {
|
|
47
|
+
// Read env live (not the module-load COS_SCRIPTS_DIR const) so unit tests that
|
|
48
|
+
// clear ops env stay standalone, and Control-updated env is visible.
|
|
49
|
+
return Boolean(process.env.COS_SCRIPTS_DIR?.trim())
|
|
50
|
+
}
|
|
44
51
|
|
|
45
52
|
interface MeetingSessionSource {
|
|
46
53
|
getTranscript(sessionId: string): string | null
|
|
@@ -313,24 +320,48 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
313
320
|
allowDuringDrain: true,
|
|
314
321
|
phase: 'queued',
|
|
315
322
|
})
|
|
316
|
-
const task = Promise.resolve().then(() => {
|
|
323
|
+
const task = Promise.resolve().then(async () => {
|
|
317
324
|
batchLease.setPhase('active')
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
325
|
+
try {
|
|
326
|
+
await finalizeBatch({
|
|
327
|
+
audioDir: pendingAudioDir,
|
|
328
|
+
entries: chunkEntries.map(entry => ({ ...entry, chunk: { ...entry.chunk } })),
|
|
329
|
+
streamingWordCount: countWords(transcript),
|
|
330
|
+
meetingPath: saved.filepath,
|
|
331
|
+
sidecarPath: saved.sidecarPath,
|
|
332
|
+
runBatch,
|
|
333
|
+
})
|
|
334
|
+
} catch (error) {
|
|
335
|
+
// Raw audio deliberately remains for bounded cleanup / retry.
|
|
336
|
+
console.error(
|
|
337
|
+
`[meeting/save] Batch finalization failed for ${sessionId}: `
|
|
338
|
+
+ `${error instanceof Error ? error.message : String(error)}`,
|
|
339
|
+
)
|
|
340
|
+
}
|
|
341
|
+
// Always hand off the durable local scribe (streaming or batch) into
|
|
342
|
+
// operations/ when COS pipeline is configured — this is what was
|
|
343
|
+
// missing on managed public server and left today's G2 files unsynced.
|
|
344
|
+
if (cosOpsPipelineConfigured()) {
|
|
345
|
+
await handoffMeetingToOperations(saved.filepath)
|
|
346
|
+
}
|
|
326
347
|
}).catch(error => {
|
|
327
|
-
// Raw audio deliberately remains for the existing two-hour cleanup.
|
|
328
348
|
console.error(
|
|
329
|
-
`[meeting/save]
|
|
349
|
+
`[meeting/save] G2 ops handoff failed for ${sessionId}: `
|
|
330
350
|
+ `${error instanceof Error ? error.message : String(error)}`,
|
|
331
351
|
)
|
|
332
352
|
}).finally(() => batchLease.release())
|
|
333
353
|
scheduleBackground(task)
|
|
354
|
+
} else if (cosOpsPipelineConfigured()) {
|
|
355
|
+
// No HQ batch (no audio / incomplete writes) — still hand off the
|
|
356
|
+
// streaming scribe into operations when COS pipeline is configured.
|
|
357
|
+
scheduleBackground(
|
|
358
|
+
handoffMeetingToOperations(saved.filepath).catch(error => {
|
|
359
|
+
console.error(
|
|
360
|
+
`[meeting/save] G2 ops handoff failed for ${sessionId}: `
|
|
361
|
+
+ `${error instanceof Error ? error.message : String(error)}`,
|
|
362
|
+
)
|
|
363
|
+
}),
|
|
364
|
+
)
|
|
334
365
|
}
|
|
335
366
|
} catch (error) {
|
|
336
367
|
if (error instanceof MeetingStoreError) {
|
|
@@ -399,7 +430,7 @@ async function finalizeBatch(options: {
|
|
|
399
430
|
if (canDeletePendingBatchAudio(transcriptApplied, metadataPersisted)) {
|
|
400
431
|
rmSync(options.audioDir, { recursive: true, force: true })
|
|
401
432
|
} else {
|
|
402
|
-
console.warn('[meeting/save] Pending raw audio retained for bounded
|
|
433
|
+
console.warn('[meeting/save] Pending raw audio retained for bounded cleanup')
|
|
403
434
|
}
|
|
404
435
|
}
|
|
405
436
|
|
|
@@ -46,8 +46,10 @@ import { getServerInstanceId } from '../lib/server-instance-id.js'
|
|
|
46
46
|
import {
|
|
47
47
|
acquireMaintenanceWork,
|
|
48
48
|
maintenanceAdmissionsOpen,
|
|
49
|
+
maintenanceLifecycle,
|
|
49
50
|
type MaintenanceWorkLease,
|
|
50
51
|
} from '../lib/maintenance-lifecycle.js'
|
|
52
|
+
import { feedLiveCueTranscript } from '../lib/live-cues-engine.js'
|
|
51
53
|
|
|
52
54
|
function ensurePrivateDirectory(path: string): void {
|
|
53
55
|
if (!existsSync(path)) mkdirSync(path, { recursive: true, mode: 0o700 })
|
|
@@ -636,11 +638,15 @@ setInterval(() => {
|
|
|
636
638
|
}
|
|
637
639
|
}
|
|
638
640
|
} catch {}
|
|
639
|
-
// Purge stale pending-batch dirs
|
|
641
|
+
// Purge stale pending-batch dirs. HQ large-v3 on long meetings + Control
|
|
642
|
+
// restart can exceed 2h (2026-07-27: two sessions purged before batch).
|
|
643
|
+
// Use 12h, and never purge while a meeting_batch_finalization lease is held.
|
|
640
644
|
// Age measured by _batch_pending.marker mtime (set by moveSessionAudioToPending), NOT the first
|
|
641
645
|
// chunk's mtime — chunk files keep their original write-time across the atomic rename, so for
|
|
642
646
|
// meetings > 1 hour, chunk mtimes would always look stale. Fallback: dir mtime for older marker-less dirs.
|
|
643
647
|
try {
|
|
648
|
+
const batchBusy = ((maintenanceLifecycle.snapshot().activeByKind as Record<string, number>)
|
|
649
|
+
.meeting_batch_finalization ?? 0) > 0
|
|
644
650
|
for (const dir of readdirSync(PENDING_BATCH_DIR)) {
|
|
645
651
|
const dirPath = resolve(PENDING_BATCH_DIR, dir)
|
|
646
652
|
try {
|
|
@@ -655,7 +661,11 @@ setInterval(() => {
|
|
|
655
661
|
// Fallback for pre-v5.4.3 dirs: use directory ctime (changes on rename)
|
|
656
662
|
ageSource = statSync(dirPath).ctimeMs
|
|
657
663
|
}
|
|
658
|
-
if (Date.now() - ageSource >
|
|
664
|
+
if (Date.now() - ageSource > 12 * 60 * 60 * 1000) {
|
|
665
|
+
if (batchBusy) {
|
|
666
|
+
console.warn(`[cleanup] Retaining stale pending-batch while batch lease held: ${dir}`)
|
|
667
|
+
continue
|
|
668
|
+
}
|
|
659
669
|
rmSync(dirPath, { recursive: true, force: true })
|
|
660
670
|
console.log(`[cleanup] Purged stale pending-batch: ${dir}`)
|
|
661
671
|
}
|
|
@@ -1538,6 +1548,11 @@ async function processStreamChunk(opts: {
|
|
|
1538
1548
|
console.log(`[perf] persistSession: ${(performance.now() - tPersist).toFixed(1)}ms (${session.chunks.filter(c => c).length} chunks)`)
|
|
1539
1549
|
|
|
1540
1550
|
emitDisplay({ type: 'transcript_chunk', data: { text: trimmedText, speaker, chunkIndex, elapsed, sessionId } })
|
|
1551
|
+
// Live Cues feed — fire-and-forget, NEVER awaited: transcription must not
|
|
1552
|
+
// block on a cue, and no LLM runs on this path. .catch() is required — a
|
|
1553
|
+
// bare `void` on a rejecting promise is an unhandled rejection, which Node
|
|
1554
|
+
// throws on by default. All gates live inside the feed.
|
|
1555
|
+
void feedLiveCueTranscript(sessionId, trimmedText, Boolean(fallbackReason)).catch(() => {})
|
|
1541
1556
|
|
|
1542
1557
|
console.log(`[perf] TOTAL request: ${(performance.now() - tReq).toFixed(1)}ms | chunk #${chunkIndex} | ${audioBuffer.length}b | rms=${Math.round(rms)} q=${isQuiet ? 1 : 0} | ${asrProvider} | "${trimmedText.slice(0, 50)}"`)
|
|
1543
1558
|
return canonicalChunkResponse(chunk, sessionId, chunkIndex)
|