@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.
@@ -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
  '',
@@ -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
+ })
@@ -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
- return finalizeBatch({
319
- audioDir: pendingAudioDir,
320
- entries: chunkEntries.map(entry => ({ ...entry, chunk: { ...entry.chunk } })),
321
- streamingWordCount: countWords(transcript),
322
- meetingPath: saved.filepath,
323
- sidecarPath: saved.sidecarPath,
324
- runBatch,
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] Batch finalization failed for ${sessionId}: `
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 two-hour cleanup')
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 older than 2 hours after move (batch should complete in minutes).
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 > 2 * 60 * 60 * 1000) {
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)