@gotcos/glasses-server 6.17.0 → 6.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.env.example CHANGED
@@ -116,3 +116,42 @@ BIND_HOST=0.0.0.0
116
116
  # Telegram session/activity notifications remain OFF even if the COS scripts
117
117
  # directory contains .telegram_config.json. Enable export explicitly:
118
118
  # COS_TELEGRAM_NOTIFICATIONS=1
119
+
120
+ # ── POST-MEETING HQ TRANSCRIPTION DEVICE (optional) ──────────────────────
121
+ # After a meeting saves, the server re-transcribes it with whisper-cli
122
+ # large-v3 ("polish"). Since 6.14.1 that batch has run on CPU (-ng) so it can
123
+ # never fight a live meeting's ASR for the GPU — going meeting-to-meeting, the
124
+ # polish of meeting A overlaps the live capture of meeting B, and two Metal
125
+ # workloads degrade each other. The tax is that idle polish is slow too.
126
+ #
127
+ # COS_BATCH_HQ_METAL=1 opts into GPU-when-idle: each segment picks Metal only
128
+ # when nothing live is contending, and a meeting starting mid-batch preempts
129
+ # the GPU immediately (the interrupted segment is discarded and retried on CPU
130
+ # — a truncated transcript is never saved). Default is OFF; leave it unset
131
+ # until you have run a meeting-to-meeting smoke on your own machine.
132
+ # COS_BATCH_HQ_METAL=1
133
+ #
134
+ # COS_BATCH_HQ_FORCE_CPU=1 is the blunt rollback. It wins over everything and
135
+ # keeps working if the default ever flips to Metal-on.
136
+ # COS_BATCH_HQ_FORCE_CPU=1
137
+
138
+ # ── LIVE CUES (optional — requires the FULL COS PIPELINE above) ──────────
139
+ # Live meeting coaching cues on the lens: transcript window -> Composer
140
+ # planner -> Qdrant -> LightRAG -> Composer insight -> coaching_nudge.
141
+ # Requires COS_SCRIPTS_DIR (memory hops) plus the Cursor Agent CLI.
142
+ # Set these in ~/.cos-glasses/.env — NOT the LaunchAgent plist: COS Control
143
+ # refuses unknown env keys and rebuilds plist env from its manifest, so a
144
+ # hand-edited plist value is dropped by the next Update Server.
145
+ # COS_LIVE_CUES=1 # master switch (kill switch: set 0 + restart)
146
+ # COS_LIVE_CUES_MODEL=cursor-composer # only supported value; others fail closed
147
+ # COS_LIVE_CUES_LIGHTRAG_RESERVE=120 # leave >=N of the shared 200/day query pool
148
+ # COS_LIVE_CUES_AUTO=0 # debug only: arm without the phone toggle
149
+ #
150
+ # COS_LIVE_CUES_GRAPH=1 — LightRAG entity hop. OFF by default; measure before
151
+ # enabling. On a 31k-entity / 66k-relationship graph we measured p50 73.5s and
152
+ # max 117s across 5 real explores, so only 1 in 5 finished inside the 40s hop
153
+ # budget. The rest would spend 40s of the 60s pipeline wall AND two calls from
154
+ # the shared 200/day query pool only to end up degraded. Time your own graph:
155
+ # time <scripts>/venv/bin/python3 <scripts>/lightrag_search.py --explore "Some Entity"
156
+ # Consistently under ~30s, turn it on. Otherwise leave it off — cues still get
157
+ # memory grounding from Qdrant, which returns in ~1.5s.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,74 @@
1
+ ## 6.18.1
2
+
3
+ - **Post-meeting HQ polish can use the GPU when nothing live needs it — opt-in.**
4
+ 6.14.1 stopped batch polish from fighting a live meeting for Metal by pinning
5
+ it to CPU forever, which also taxed every idle polish. The device is now
6
+ chosen per segment: Metal only when `COS_BATCH_HQ_METAL=1` **and** nothing
7
+ live is contending. **Default is unchanged (always CPU)** until a
8
+ meeting-to-meeting smoke passes on real hardware; `COS_BATCH_HQ_FORCE_CPU=1`
9
+ remains the blunt rollback and wins over everything.
10
+ - **Live always wins the GPU, and a preempted segment is never half-saved.**
11
+ A meeting starting mid-batch preempts the Metal child two ways: on new
12
+ session creation (create only — an ordinary status read of a stale session
13
+ must not evict a healthy batch) and on `recording_chunk` lease acquire, which
14
+ covers recovery/reconnect paths that skip creation. The interrupted output is
15
+ **discarded unconditionally** — checked before the exit code, because SIGTERM
16
+ can race to a zero exit with partial stdout — and the same segment is retried
17
+ once on CPU, which cannot itself be preempted. A truncated transcript is
18
+ never written into a saved meeting; slow or failed beats silently wrong.
19
+ - **Waiting is distinguished from wedging.** A session counts as live only if
20
+ it was active within 180s. A cold orphan cannot pin batch to CPU — an
21
+ `active-sessions` entry sat untouched for 3+ hours on 2026-07-27, and an
22
+ "any session in the map" rule would have disabled the GPU path permanently
23
+ and silently. Prompt ASR and one-shot transcription count as contending
24
+ (same Metal family), and a second Metal batch never starts while one is in
25
+ flight. A failing liveness probe fails safe to CPU.
26
+ - Every batch segment logs `device`, `reason`, and `metalEnabled`, so "why was
27
+ polish slow today" is answerable after the fact.
28
+
29
+ ## 6.18.0
30
+
31
+ - **G2 save → operations sync restored on the managed public server.** After
32
+ `meeting/save` (+ HQ batch when present), the server stages the durable
33
+ recording into `operations/personal/meetings/` with pipeline markers and runs
34
+ `sync_meetings.py --g2-only --g2-file` (same enrichment runner as the private
35
+ app). Without this, Control installs finished HQ locally but never wrote
36
+ `(G2)` scribes into COS — Jul 27 regression after the 6.17.0 managed cutover.
37
+ Pending-batch TTL raised to 12h and skips purge while a batch lease is held.
38
+ - **Live Cues (opt-in): live meeting coaching on the lens.** With
39
+ `COS_LIVE_CUES=1` and the companion's Live cues toggle On, a live meeting
40
+ runs transcript window → Composer planner → Qdrant → Composer insight → a
41
+ `coaching_nudge` flash on the glasses. Requires the full COS pipeline
42
+ (`COS_SCRIPTS_DIR`) plus the Cursor Agent CLI; Composer 2.5 is the only
43
+ supported cue model and anything else fails closed.
44
+ - **The LightRAG graph hop ships OFF, on measured evidence.** Across 5 real
45
+ `--explore` calls against a 31k-entity / 66k-relationship graph: p50
46
+ **73.5s**, max 117.2s, min 14.8s — only 1 of 5 finished inside the 40s hop
47
+ budget. Left on by default it would spend 40s of the 60s pipeline wall plus
48
+ two calls from the shared 200/day query pool to produce a degraded cue about
49
+ 80% of the time. `COS_LIVE_CUES_GRAPH=1` opts in after you time your own
50
+ graph. Graph-off is a configured normal, not a degradation, so it renders no
51
+ mark; cues still get memory grounding from Qdrant in ~1.5s.
52
+ - **Cost containment is structural, not advisory.** Per-meeting cap (8
53
+ pipelines), single-flight, 60s floor, 30s cooldown, a 60s pipeline wall
54
+ (deliberately under COS Control's 90s drain window), a consecutive-failure
55
+ breaker, a persisted weekly Composer ceiling, and a LightRAG reserve that
56
+ stops graph hops while fewer than `COS_LIVE_CUES_LIGHTRAG_RESERVE` calls
57
+ remain in the shared 200/day query pool. Every skipped pipeline logs its
58
+ reason — a silent cap is treated as a defect.
59
+ - **Zero side effects on existing paths.** Cue asks use a dedicated Composer
60
+ spawn: no conversation history, no Cursor run-ledger entries, no Telegram
61
+ notifications, and token audit under its own `live-cues` source so `g2-query`
62
+ keeps measuring only real user queries. Transcription never awaits the
63
+ pipeline. Cues degrade honestly: a cue produced without graph grounding
64
+ carries `degraded` + a reason, and Cursor's exit-0 auth failure output is
65
+ detected rather than rendered as a cue.
66
+ - **Health truthfulness.** `features.liveCues` plus `capabilities.liveCues`
67
+ (`{ available, reason }`) publish from one helper on BOTH `/api/health` and
68
+ `/api/models`, with user-safe reasons only. Config lives in
69
+ `~/.cos-glasses/.env` — never the LaunchAgent plist, which COS Control
70
+ rebuilds from its manifest. Kill switch: `COS_LIVE_CUES=0` + restart.
71
+
1
72
  ## 6.17.0
2
73
 
3
74
  - **Speaker diarization is now a bolt-on any install can enable.** The ~26 MB
@@ -13,7 +13,14 @@
13
13
  "PORT",
14
14
  "HTTPS_PORT",
15
15
  "BIND_HOST",
16
- "COS_DURABLE_QUERY_JOBS"
16
+ "COS_DURABLE_QUERY_JOBS",
17
+ "COS_LIVE_CUES",
18
+ "COS_LIVE_CUES_MODEL",
19
+ "COS_LIVE_CUES_GRAPH",
20
+ "COS_LIVE_CUES_LIGHTRAG_RESERVE",
21
+ "COS_LIVE_CUES_AUTO",
22
+ "COS_BATCH_HQ_METAL",
23
+ "COS_BATCH_HQ_FORCE_CPU"
17
24
  ],
18
25
  "maintenance": {
19
26
  "scope": "cross_boot",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.17.0",
4
- "description": "COS Glasses \u2014 self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, or Cursor Agent CLI",
3
+ "version": "6.18.1",
4
+ "description": "COS Glasses self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, or Cursor Agent CLI",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "glasses-server": "bin/cli.cjs",
package/server/index.ts CHANGED
@@ -38,6 +38,8 @@ import { recoveryRouter } from './routes/recovery.js'
38
38
  import { promptEditRouter } from './routes/prompt-edit.js'
39
39
  import { bookmarksRouter } from './routes/bookmarks.js'
40
40
  import { welcomeContextRouter } from './routes/welcome-context.js'
41
+ import { liveCuesRouter } from './routes/live-cues.js'
42
+ import { shutdownLiveCues } from './lib/live-cues-engine.js'
41
43
  import { prewarmContext } from './lib/context-builder.js'
42
44
  import { preWarmCLI } from './lib/claude-bridge.js'
43
45
  import { getCodexRunConfig } from './lib/codex-run-ledger.js'
@@ -247,6 +249,7 @@ app.use('/api', recoveryRouter)
247
249
  app.use('/api', promptEditRouter)
248
250
  app.use('/api', bookmarksRouter)
249
251
  app.use('/api', welcomeContextRouter)
252
+ app.use('/api', liveCuesRouter)
250
253
 
251
254
  // OpenAI-compatible endpoint for the G2 Agent (ER "Add Agent")
252
255
  // Mounted at root — routes are /v1/chat/completions and /v1/models
@@ -281,6 +284,15 @@ async function gracefulShutdown(): Promise<void> {
281
284
  } catch (error) {
282
285
  console.error('[query-jobs] graceful interruption failed:', error)
283
286
  }
287
+ // Live Cues spawns detached trees (Cursor CLI, python with claude -p
288
+ // grandchildren) that would survive this process's death — kill them
289
+ // explicitly inside the 8s force-exit budget. Single-flight guarantees at
290
+ // most one tree per session, which fits.
291
+ try {
292
+ await shutdownLiveCues()
293
+ } catch (error) {
294
+ console.error('[live-cues] shutdown termination failed:', error)
295
+ }
284
296
  try { logActiveSessionsOnShutdown() } catch { /* best-effort flush */ }
285
297
  stopCodexModelCatalogRefresh()
286
298
  stopWhisperServer()
@@ -0,0 +1,179 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { existsSync } from 'node:fs'
3
+ import { isAbsolute } from 'node:path'
4
+
5
+ export const G2_RESULT_PREFIX = 'COS_G2_RESULT='
6
+
7
+ export interface G2EnrichmentOutcome {
8
+ status: 'enriched' | 'already-enriched' | 'blended'
9
+ path: string
10
+ title: string
11
+ }
12
+
13
+ export interface G2EnrichmentAttempt {
14
+ code: number | null
15
+ stdout: string
16
+ stderr: string
17
+ error?: string
18
+ }
19
+
20
+ export interface G2EnrichmentRunResult {
21
+ ok: boolean
22
+ attempts: number
23
+ outcome?: G2EnrichmentOutcome
24
+ error?: string
25
+ }
26
+
27
+ export interface G2EnrichmentOptions {
28
+ pythonBin: string
29
+ syncScript: string
30
+ scriptsDir: string
31
+ meetingFile: string
32
+ env: NodeJS.ProcessEnv
33
+ retryDelaysMs?: readonly number[]
34
+ timeoutMs?: number
35
+ killGraceMs?: number
36
+ onAttempt?: (message: string) => void
37
+ }
38
+
39
+ export interface G2EnrichmentDependencies {
40
+ runAttempt?: (options: G2EnrichmentOptions) => Promise<G2EnrichmentAttempt>
41
+ sleep?: (ms: number) => Promise<void>
42
+ fileExists?: (path: string) => boolean
43
+ }
44
+
45
+ const MAX_CAPTURE_CHARS = 64 * 1024
46
+
47
+ function appendBounded(current: string, chunk: Buffer | string): string {
48
+ const next = current + chunk.toString()
49
+ return next.length <= MAX_CAPTURE_CHARS ? next : next.slice(-MAX_CAPTURE_CHARS)
50
+ }
51
+
52
+ export function parseG2EnrichmentOutcome(stdout: string): G2EnrichmentOutcome | null {
53
+ const lines = stdout.split(/\r?\n/)
54
+ for (let index = lines.length - 1; index >= 0; index--) {
55
+ const line = lines[index].trim()
56
+ if (!line.startsWith(G2_RESULT_PREFIX)) continue
57
+ try {
58
+ const parsed = JSON.parse(line.slice(G2_RESULT_PREFIX.length)) as Partial<G2EnrichmentOutcome>
59
+ if (
60
+ (parsed.status === 'enriched' || parsed.status === 'already-enriched' || parsed.status === 'blended')
61
+ && typeof parsed.path === 'string'
62
+ && parsed.path.length > 0
63
+ && typeof parsed.title === 'string'
64
+ && parsed.title.length > 0
65
+ ) {
66
+ return parsed as G2EnrichmentOutcome
67
+ }
68
+ } catch {
69
+ return null
70
+ }
71
+ }
72
+ return null
73
+ }
74
+
75
+ export function buildExactG2SyncArgs(syncScript: string, meetingFile: string): string[] {
76
+ return [syncScript, '--g2-only', '--g2-file', meetingFile, '--quiet']
77
+ }
78
+
79
+ function signalChildTree(child: ReturnType<typeof spawn>, signal: NodeJS.Signals): boolean {
80
+ if (!child.pid) return false
81
+ try {
82
+ if (process.platform !== 'win32') {
83
+ process.kill(-child.pid, signal)
84
+ return true
85
+ }
86
+ } catch { /* fall back to the direct child */ }
87
+ return child.kill(signal)
88
+ }
89
+
90
+ export async function spawnG2SyncAttempt(options: G2EnrichmentOptions): Promise<G2EnrichmentAttempt> {
91
+ return await new Promise(resolve => {
92
+ const child = spawn(
93
+ options.pythonBin,
94
+ buildExactG2SyncArgs(options.syncScript, options.meetingFile),
95
+ {
96
+ cwd: options.scriptsDir,
97
+ stdio: ['ignore', 'pipe', 'pipe'],
98
+ env: options.env,
99
+ detached: process.platform !== 'win32',
100
+ },
101
+ )
102
+
103
+ let stdout = ''
104
+ let stderr = ''
105
+ let settled = false
106
+ let timedOut = false
107
+ let timeout: ReturnType<typeof setTimeout> | null = null
108
+ let killTimer: ReturnType<typeof setTimeout> | null = null
109
+ const finish = (attempt: G2EnrichmentAttempt): void => {
110
+ if (settled) return
111
+ settled = true
112
+ if (timeout) clearTimeout(timeout)
113
+ if (killTimer) clearTimeout(killTimer)
114
+ resolve(attempt)
115
+ }
116
+
117
+ child.stdout?.on('data', (chunk: Buffer) => { stdout = appendBounded(stdout, chunk) })
118
+ child.stderr?.on('data', (chunk: Buffer) => { stderr = appendBounded(stderr, chunk) })
119
+ child.on('error', error => finish({ code: null, stdout, stderr, error: error.message }))
120
+ child.on('close', code => finish(timedOut
121
+ ? { code, stdout, stderr, error: `exact G2 sync timed out after ${options.timeoutMs ?? 600_000}ms` }
122
+ : { code, stdout, stderr }))
123
+
124
+ timeout = setTimeout(() => {
125
+ timedOut = true
126
+ signalChildTree(child, 'SIGTERM')
127
+ killTimer = setTimeout(() => {
128
+ signalChildTree(child, 'SIGKILL')
129
+ }, Math.max(25, options.killGraceMs ?? 2_000))
130
+ }, options.timeoutMs ?? 600_000)
131
+ })
132
+ }
133
+
134
+ function attemptError(attempt: G2EnrichmentAttempt): string {
135
+ if (attempt.error) return attempt.error
136
+ const detail = attempt.stderr.trim() || attempt.stdout.trim()
137
+ if (attempt.code !== 0) return `exit ${attempt.code}${detail ? `: ${detail.slice(-500)}` : ''}`
138
+ return `exit 0 without verified ${G2_RESULT_PREFIX} outcome${detail ? `: ${detail.slice(-500)}` : ''}`
139
+ }
140
+
141
+ /**
142
+ * Run one exact-file enrichment job with bounded retries. This executes in the
143
+ * Mac server process, not the Even Hub WebView, so it cannot touch SDK timers
144
+ * or the proven meeting-capture path.
145
+ */
146
+ export async function runG2EnrichmentWithRetry(
147
+ options: G2EnrichmentOptions,
148
+ dependencies: G2EnrichmentDependencies = {},
149
+ ): Promise<G2EnrichmentRunResult> {
150
+ const retryDelaysMs = options.retryDelaysMs?.length
151
+ ? [...options.retryDelaysMs]
152
+ : [0, 15_000, 60_000]
153
+ const runAttempt = dependencies.runAttempt ?? spawnG2SyncAttempt
154
+ const sleep = dependencies.sleep ?? (async (ms: number) => await new Promise<void>(resolve => setTimeout(resolve, ms)))
155
+ const fileExists = dependencies.fileExists ?? existsSync
156
+ let lastError = 'exact G2 sync did not run'
157
+
158
+ for (let index = 0; index < retryDelaysMs.length; index++) {
159
+ const delayMs = Math.max(0, retryDelaysMs[index] ?? 0)
160
+ if (delayMs > 0) {
161
+ options.onAttempt?.(`retry ${index + 1}/${retryDelaysMs.length} in ${Math.round(delayMs / 1000)}s`)
162
+ await sleep(delayMs)
163
+ }
164
+
165
+ options.onAttempt?.(`attempt ${index + 1}/${retryDelaysMs.length}`)
166
+ const attempt = await runAttempt(options)
167
+ const outcome = attempt.code === 0 ? parseG2EnrichmentOutcome(attempt.stdout) : null
168
+ if (outcome && isAbsolute(outcome.path) && fileExists(outcome.path)) {
169
+ return { ok: true, attempts: index + 1, outcome }
170
+ }
171
+ lastError = outcome
172
+ ? `verified sentinel points to missing or non-absolute path: ${outcome.path}`
173
+ : attemptError(attempt)
174
+ options.onAttempt?.(`attempt ${index + 1} failed: ${lastError.slice(0, 500)}`)
175
+ }
176
+
177
+ return { ok: false, attempts: retryDelaysMs.length, error: lastError }
178
+ }
179
+
@@ -0,0 +1,136 @@
1
+ // Hand off a durable local G2 recording into COS operations/ and run exact
2
+ // enrichment. Private cos-glasses-app did this inline in meeting/save; the
3
+ // public managed package only wrote ~/.cos-glasses/data/recordings until
4
+ // 2026-07-27 — so HQ could finish while operations/ never got a (G2) scribe.
5
+ //
6
+ // Contract:
7
+ // 1. Keep the local recording as the glasses MeetingStore source of truth.
8
+ // 2. When COS_OPERATIONS_DIR / COS_SCRIPTS_DIR is set, stage a pipeline-ready
9
+ // copy under operations/personal/meetings/YYYY-MM/ (domain review marker
10
+ // lets sync_meetings reclassify).
11
+ // 3. Run sync_meetings.py --g2-only --g2-file with the private-app retry helper.
12
+
13
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
14
+ import { basename, dirname, join } from 'node:path'
15
+ import { resolveCosOperationsDir } from './cos-operations-meetings.js'
16
+ import { runG2EnrichmentWithRetry } from './g2-enrichment-runner.js'
17
+ import { COS_SCRIPTS_DIR, PYTHON_BIN } from './python-bridge.js'
18
+
19
+ const PENDING_SUMMARY = '*G2 recording — summary pending pipeline processing.*'
20
+ const DOMAIN_REVIEW_MARKER = '<!-- g2-needs-domain-review -->'
21
+
22
+ export function patchRecordingForG2Pipeline(markdown: string): string {
23
+ let text = markdown.replace(/\r\n?/g, '\n')
24
+ if (!/\|\s*\*\*Source\*\*\s*\|\s*G2 Glasses\s*\|/i.test(text) && !text.includes('| G2 Glasses')) {
25
+ text = text.replace(
26
+ /(\|\s*\*\*Source\*\*\s*\|)([^|\n]*)\|/,
27
+ '| **Source** | G2 Glasses |',
28
+ )
29
+ }
30
+ text = text.replace(
31
+ /\*Standalone recording[^*]*\*/i,
32
+ PENDING_SUMMARY,
33
+ )
34
+ if (!/summary pending pipeline processing/i.test(text)) {
35
+ if (/## Summary\n\n/.test(text)) {
36
+ text = text.replace(/## Summary\n\n/, `## Summary\n\n${PENDING_SUMMARY}\n\n`)
37
+ } else {
38
+ text = `${text.trimEnd()}\n\n## Summary\n\n${PENDING_SUMMARY}\n`
39
+ }
40
+ }
41
+ if (!text.includes('g2-needs-domain-review')) {
42
+ if (text.includes('## Summary')) {
43
+ text = text.replace('## Summary', `${DOMAIN_REVIEW_MARKER}\n\n## Summary`)
44
+ } else {
45
+ text = `${text.trimEnd()}\n\n${DOMAIN_REVIEW_MARKER}\n`
46
+ }
47
+ }
48
+ return text
49
+ }
50
+
51
+ /** Stage local recording into operations/personal for exact enrichment. */
52
+ export function stageRecordingIntoOperations(localMeetingPath: string): string | null {
53
+ const operationsDir = resolveCosOperationsDir()
54
+ if (!operationsDir) return null
55
+ if (!existsSync(localMeetingPath)) {
56
+ console.warn(`[g2-ops-handoff] Local meeting missing: ${localMeetingPath}`)
57
+ return null
58
+ }
59
+
60
+ const month = basename(dirname(localMeetingPath))
61
+ if (!/^\d{4}-\d{2}$/.test(month)) {
62
+ console.warn(`[g2-ops-handoff] Unexpected meeting month folder for ${localMeetingPath}`)
63
+ return null
64
+ }
65
+
66
+ const destDir = join(operationsDir, 'personal', 'meetings', month)
67
+ mkdirSync(destDir, { recursive: true })
68
+ const destPath = join(destDir, basename(localMeetingPath))
69
+ const patched = patchRecordingForG2Pipeline(readFileSync(localMeetingPath, 'utf8'))
70
+ writeFileSync(destPath, patched, { encoding: 'utf8', mode: 0o600 })
71
+
72
+ const stem = basename(localMeetingPath, '.md')
73
+ const localDir = dirname(localMeetingPath)
74
+ for (const companionName of [`${stem}.g2-chunks.json`, `${stem}.json`]) {
75
+ const companion = join(localDir, companionName)
76
+ if (existsSync(companion)) {
77
+ try {
78
+ copyFileSync(companion, join(destDir, companionName))
79
+ } catch (error) {
80
+ console.warn(
81
+ `[g2-ops-handoff] Sidecar copy failed for ${companionName}: `
82
+ + `${error instanceof Error ? error.message : String(error)}`,
83
+ )
84
+ }
85
+ }
86
+ }
87
+ return destPath
88
+ }
89
+
90
+ export async function handoffMeetingToOperations(localMeetingPath: string): Promise<void> {
91
+ if (!COS_SCRIPTS_DIR || !PYTHON_BIN) {
92
+ console.log('[meeting/save] Standalone mode — skipping G2 sync pipeline')
93
+ return
94
+ }
95
+ if (!existsSync(PYTHON_BIN)) {
96
+ console.warn(`[meeting/save] COS python missing at ${PYTHON_BIN} — skipping G2 sync`)
97
+ return
98
+ }
99
+
100
+ const staged = stageRecordingIntoOperations(localMeetingPath)
101
+ if (!staged) {
102
+ console.warn('[meeting/save] COS operations dir unset/unavailable — G2 sync skipped')
103
+ return
104
+ }
105
+
106
+ const syncScript = join(COS_SCRIPTS_DIR, 'sync_meetings.py')
107
+ if (!existsSync(syncScript)) {
108
+ console.warn(`[meeting/save] sync_meetings.py missing at ${syncScript}`)
109
+ return
110
+ }
111
+
112
+ const spawnPath = process.env.PATH?.includes('/opt/homebrew/bin')
113
+ ? process.env.PATH
114
+ : `/opt/homebrew/bin:${process.env.PATH || ''}`
115
+
116
+ const enrichment = await runG2EnrichmentWithRetry({
117
+ pythonBin: PYTHON_BIN,
118
+ syncScript,
119
+ scriptsDir: COS_SCRIPTS_DIR,
120
+ meetingFile: staged,
121
+ env: { ...process.env, PYTHONUNBUFFERED: '1', PATH: spawnPath },
122
+ onAttempt: message => console.log(`[meeting/save] G2 exact sync: ${message}`),
123
+ })
124
+
125
+ if (enrichment.ok && enrichment.outcome) {
126
+ console.log(
127
+ `[meeting/save] G2 pipeline verified after ${enrichment.attempts} attempt(s): `
128
+ + `${enrichment.outcome.title} → ${enrichment.outcome.path}`,
129
+ )
130
+ } else {
131
+ console.error(
132
+ `[meeting/save] G2 pipeline FAILED after ${enrichment.attempts} attempt(s): `
133
+ + `${enrichment.error ?? 'unknown exact-file enrichment failure'}`,
134
+ )
135
+ }
136
+ }
@@ -0,0 +1,83 @@
1
+ // Live Cues capability — ONE truth source for both health surfaces.
2
+ //
3
+ // Called from routes/health.ts at BOTH assembly sites (/api/health at :271 and
4
+ // /api/models at :314). Duplicated literals at two sites is exactly how the
5
+ // "patched one site" bug class recurs; the companion polls /api/models, so a
6
+ // value present only on /api/health leaves the indicator blind.
7
+ //
8
+ // /api/health is unauthenticated: `reason` stays a user-safe enum — no paths,
9
+ // no usernames, no counts.
10
+
11
+ import { existsSync } from 'node:fs'
12
+ import { resolve } from 'node:path'
13
+ import { COS_SCRIPTS_DIR, PYTHON_BIN } from './python-bridge.js'
14
+ import { resolveAgentBinary, resolveCursorModelOption } from './cursor-model-catalog.js'
15
+ import { CURSOR_COMPOSER_MODEL } from '../../shared/model-preference.js'
16
+
17
+ export type LiveCuesReason =
18
+ | 'master_flag_off'
19
+ | 'no_composer'
20
+ | 'no_cos_pipeline'
21
+ | 'no_memory_scripts'
22
+ | 'budget_exhausted'
23
+ | 'legacy_runtime'
24
+ | 'maintenance_drain_active'
25
+
26
+ export interface LiveCuesCapability {
27
+ available: boolean
28
+ reason?: LiveCuesReason
29
+ }
30
+
31
+ export function liveCuesEnabled(): boolean {
32
+ return process.env.COS_LIVE_CUES === '1'
33
+ }
34
+
35
+ /** Composer is the only supported v1 model. Anything else fails closed. */
36
+ export function liveCuesModelSupported(): boolean {
37
+ const requested = process.env.COS_LIVE_CUES_MODEL?.trim()
38
+ return !requested || requested === CURSOR_COMPOSER_MODEL
39
+ }
40
+
41
+ /** The graph hop is OPT-IN, not opt-out. Measured against a live 31k-entity /
42
+ * 66k-relationship LightRAG graph (2026-07-27, n=5 real explores): p50 73.5s,
43
+ * max 117.2s, and only 1 of 5 finished inside the 40s hop budget. Defaulting
44
+ * it ON would burn 40s of the 60s wall plus two calls from the shared 200/day
45
+ * pool to produce a DEGRADED cue roughly 80% of the time. Users with a
46
+ * smaller/faster graph opt in with COS_LIVE_CUES_GRAPH=1. */
47
+ export function liveCuesGraphEnabled(): boolean {
48
+ return process.env.COS_LIVE_CUES_GRAPH === '1'
49
+ }
50
+
51
+ export const LIVE_CUES_MEMORY_SCRIPTS = ['semantic_search.py', 'lightrag_search.py'] as const
52
+
53
+ export function memoryScriptsPresent(): boolean {
54
+ const scriptsDir = COS_SCRIPTS_DIR
55
+ const pythonBin = PYTHON_BIN
56
+ if (!scriptsDir || !pythonBin) return false
57
+ if (!existsSync(pythonBin)) return false
58
+ return LIVE_CUES_MEMORY_SCRIPTS.every(script => existsSync(resolve(scriptsDir, script)))
59
+ }
60
+
61
+ /** Injected by the engine so capability can reflect an exhausted weekly budget
62
+ * without a circular import. Absent hook = budget not exhausted. */
63
+ let budgetExhaustedProbe: (() => boolean) | null = null
64
+ export function registerLiveCuesBudgetProbe(probe: () => boolean): void {
65
+ budgetExhaustedProbe = probe
66
+ }
67
+
68
+ export function liveCuesCapability(): LiveCuesCapability {
69
+ if (!liveCuesEnabled()) return { available: false, reason: 'master_flag_off' }
70
+ // An unsupported model is a misconfiguration, not a missing Composer, but
71
+ // the public reason vocabulary maps it to no_composer: the actionable fact
72
+ // for the user is the same — the cue brain has no usable model.
73
+ if (!liveCuesModelSupported()) return { available: false, reason: 'no_composer' }
74
+ if (!COS_SCRIPTS_DIR) return { available: false, reason: 'no_cos_pipeline' }
75
+ if (!memoryScriptsPresent()) return { available: false, reason: 'no_memory_scripts' }
76
+ const option = resolveCursorModelOption(CURSOR_COMPOSER_MODEL)
77
+ // resolveCursorModelOption returns a TRUTHY object with id '' when the
78
+ // catalog is unavailable — the test must be !option?.id, never !option.
79
+ // isCursorProviderReady() is wrong here: it requires BOTH cursor slots.
80
+ if (!resolveAgentBinary() || !option?.id) return { available: false, reason: 'no_composer' }
81
+ if (budgetExhaustedProbe?.()) return { available: false, reason: 'budget_exhausted' }
82
+ return { available: true }
83
+ }