@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 CHANGED
@@ -116,3 +116,24 @@ 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
+ # ── LIVE CUES (optional — requires the FULL COS PIPELINE above) ──────────
121
+ # Live meeting coaching cues on the lens: transcript window -> Composer
122
+ # planner -> Qdrant -> LightRAG -> Composer insight -> coaching_nudge.
123
+ # Requires COS_SCRIPTS_DIR (memory hops) plus the Cursor Agent CLI.
124
+ # Set these in ~/.cos-glasses/.env — NOT the LaunchAgent plist: COS Control
125
+ # refuses unknown env keys and rebuilds plist env from its manifest, so a
126
+ # hand-edited plist value is dropped by the next Update Server.
127
+ # COS_LIVE_CUES=1 # master switch (kill switch: set 0 + restart)
128
+ # COS_LIVE_CUES_MODEL=cursor-composer # only supported value; others fail closed
129
+ # COS_LIVE_CUES_LIGHTRAG_RESERVE=120 # leave >=N of the shared 200/day query pool
130
+ # COS_LIVE_CUES_AUTO=0 # debug only: arm without the phone toggle
131
+ #
132
+ # COS_LIVE_CUES_GRAPH=1 — LightRAG entity hop. OFF by default; measure before
133
+ # enabling. On a 31k-entity / 66k-relationship graph we measured p50 73.5s and
134
+ # max 117s across 5 real explores, so only 1 in 5 finished inside the 40s hop
135
+ # budget. The rest would spend 40s of the 60s pipeline wall AND two calls from
136
+ # the shared 200/day query pool only to end up degraded. Time your own graph:
137
+ # time <scripts>/venv/bin/python3 <scripts>/lightrag_search.py --explore "Some Entity"
138
+ # Consistently under ~30s, turn it on. Otherwise leave it off — cues still get
139
+ # memory grounding from Qdrant, which returns in ~1.5s.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,77 @@
1
+ ## 6.18.0
2
+
3
+ - **G2 save → operations sync restored on the managed public server.** After
4
+ `meeting/save` (+ HQ batch when present), the server stages the durable
5
+ recording into `operations/personal/meetings/` with pipeline markers and runs
6
+ `sync_meetings.py --g2-only --g2-file` (same enrichment runner as the private
7
+ app). Without this, Control installs finished HQ locally but never wrote
8
+ `(G2)` scribes into COS — Jul 27 regression after the 6.17.0 managed cutover.
9
+ Pending-batch TTL raised to 12h and skips purge while a batch lease is held.
10
+ - **Live Cues (opt-in): live meeting coaching on the lens.** With
11
+ `COS_LIVE_CUES=1` and the companion's Live cues toggle On, a live meeting
12
+ runs transcript window → Composer planner → Qdrant → Composer insight → a
13
+ `coaching_nudge` flash on the glasses. Requires the full COS pipeline
14
+ (`COS_SCRIPTS_DIR`) plus the Cursor Agent CLI; Composer 2.5 is the only
15
+ supported cue model and anything else fails closed.
16
+ - **The LightRAG graph hop ships OFF, on measured evidence.** Across 5 real
17
+ `--explore` calls against a 31k-entity / 66k-relationship graph: p50
18
+ **73.5s**, max 117.2s, min 14.8s — only 1 of 5 finished inside the 40s hop
19
+ budget. Left on by default it would spend 40s of the 60s pipeline wall plus
20
+ two calls from the shared 200/day query pool to produce a degraded cue about
21
+ 80% of the time. `COS_LIVE_CUES_GRAPH=1` opts in after you time your own
22
+ graph. Graph-off is a configured normal, not a degradation, so it renders no
23
+ mark; cues still get memory grounding from Qdrant in ~1.5s.
24
+ - **Cost containment is structural, not advisory.** Per-meeting cap (8
25
+ pipelines), single-flight, 60s floor, 30s cooldown, a 60s pipeline wall
26
+ (deliberately under COS Control's 90s drain window), a consecutive-failure
27
+ breaker, a persisted weekly Composer ceiling, and a LightRAG reserve that
28
+ stops graph hops while fewer than `COS_LIVE_CUES_LIGHTRAG_RESERVE` calls
29
+ remain in the shared 200/day query pool. Every skipped pipeline logs its
30
+ reason — a silent cap is treated as a defect.
31
+ - **Zero side effects on existing paths.** Cue asks use a dedicated Composer
32
+ spawn: no conversation history, no Cursor run-ledger entries, no Telegram
33
+ notifications, and token audit under its own `live-cues` source so `g2-query`
34
+ keeps measuring only real user queries. Transcription never awaits the
35
+ pipeline. Cues degrade honestly: a cue produced without graph grounding
36
+ carries `degraded` + a reason, and Cursor's exit-0 auth failure output is
37
+ detected rather than rendered as a cue.
38
+ - **Health truthfulness.** `features.liveCues` plus `capabilities.liveCues`
39
+ (`{ available, reason }`) publish from one helper on BOTH `/api/health` and
40
+ `/api/models`, with user-safe reasons only. Config lives in
41
+ `~/.cos-glasses/.env` — never the LaunchAgent plist, which COS Control
42
+ rebuilds from its manifest. Kill switch: `COS_LIVE_CUES=0` + restart.
43
+
44
+ ## 6.17.0
45
+
46
+ - **Speaker diarization is now a bolt-on any install can enable.** The ~26 MB
47
+ voiceprint model stays out of the npm package, but the loader no longer looks
48
+ only inside the package (where a managed install has no copy, and where a
49
+ hand-placed one is destroyed by the next update). Resolution order:
50
+ `COS_SPEAKER_MODEL_PATH` → `~/.cos-glasses/models/` → bundled `server/models/`
51
+ (source checkouts). Put the model in `~/.cos-glasses/models/` and restart.
52
+ - **A bad model file can no longer take the server down.** onnxruntime aborts
53
+ the process on a malformed or mismatched model rather than throwing, which
54
+ under a KeepAlive LaunchAgent became a permanent restart loop that killed
55
+ queries, meetings, and transcription over an optional feature. The file is now
56
+ screened structurally and loaded in a throwaway child process first, so a
57
+ truncated download or an HTML error page saved as `.onnx` disables diarization
58
+ and nothing else.
59
+ - **`/api/health` reports `speaker_id`** as `active` / `unavailable` / `error`,
60
+ so a server running without voiceprints is no longer indistinguishable from
61
+ one doing real diarization. `error` means a model is installed but the runtime
62
+ rejected it.
63
+ - **Profile config is read from the data home.** `.cos-profile.json` is now
64
+ loaded from `~/.cos-glasses/` when present, ahead of the package-root copy.
65
+ Managed installs previously resolved it inside the generation directory, where
66
+ no profile exists and every field silently took its default — losing
67
+ transcription vocabulary, whisper corrections, and the wearer label.
68
+ - **`/api/voice/enroll` and `/api/voice/status` no longer hardcode `MU`.** Both
69
+ use `owner_speaker_label` (default `Me`), matching what identification already
70
+ used. **Upgrade note:** an install that enrolled under the old default holds a
71
+ profile named `MU`; `/api/voice/status` will report `enrolled: false` until you
72
+ set `owner_speaker_label` to `MU`. Do that rather than re-enrolling, which
73
+ splits one voice across two profiles.
74
+
1
75
  ## 6.16.9
2
76
 
3
77
  - **Message-era reset visible to a running server.** `currentMessageEraState`
package/README.md CHANGED
@@ -163,6 +163,55 @@ Telegram activity export is disabled by default even when a private COS
163
163
  pipeline contains `.telegram_config.json`; enable it only with the explicit
164
164
  `COS_TELEGRAM_NOTIFICATIONS=1` opt-in.
165
165
 
166
+ ## Speaker diarization (opt-in)
167
+
168
+ Without a voiceprint model this server does not classify speakers at all — it
169
+ passes through whatever label the client sends (`Unknown` when the client sends
170
+ nothing; the COS companion sends its own wearer/`Ext` labels). Named
171
+ per-speaker diarization needs a ~26 MB voiceprint model that is deliberately
172
+ **not** shipped in the npm package, so it is a bolt-on:
173
+
174
+ ```bash
175
+ mkdir -p ~/.cos-glasses/models
176
+ # put 3dspeaker_speech_eres2net_sv_en_voxceleb_16k.onnx there, then restart
177
+ ```
178
+
179
+ The server searches, in order: `COS_SPEAKER_MODEL_PATH` (explicit full path to
180
+ the `.onnx`), `~/.cos-glasses/models/`, then a bundled `server/models/` copy
181
+ (source checkouts only). **Use the data home, not the installed package** —
182
+ anything inside the package is destroyed by the next update, while
183
+ `~/.cos-glasses/` survives.
184
+
185
+ Use an **absolute** path if you set `COS_SPEAKER_MODEL_PATH` — a relative one
186
+ resolves against the working directory, which under the managed LaunchAgent is
187
+ the installed package.
188
+
189
+ Verify with `/api/health` → `speaker_id`:
190
+
191
+ | Value | Meaning |
192
+ |---|---|
193
+ | `active` | model loaded, diarization running |
194
+ | `unavailable` | no model found — labels come from the client |
195
+ | `error` | a model is present but the runtime rejected it (see the startup log) |
196
+
197
+ The model is read once at startup, so restart after adding it. A corrupt or
198
+ mismatched `.onnx` is screened and probed in a child process first, so a bad
199
+ download disables diarization instead of taking the server down — but it does
200
+ mean a wrong file fails silently apart from that log line.
201
+
202
+ The wearer's label comes from `owner_speaker_label` in
203
+ `~/.cos-glasses/.cos-profile.json` (default `Me`); set it to match the profile
204
+ name you enrol under. Train voices via `/api/voice/enroll?name=…` (the default
205
+ name is `owner_speaker_label`); profiles persist in
206
+ `~/.cos-glasses/data/voice-profiles.json`.
207
+
208
+ **Upgrading from an older server:** before this release the enrollment default
209
+ was hardcoded to `MU`, so an existing install may hold a profile under that name
210
+ while `owner_speaker_label` resolves to `Me`. `/api/voice/status` will then
211
+ report `enrolled: false`. Set `owner_speaker_label` to `MU` to keep the existing
212
+ voiceprints rather than re-enrolling, which would split the same voice across two
213
+ profiles.
214
+
166
215
  ## HQ dictation
167
216
 
168
217
  Prompt dictation defaults to HQ. The phone owns the preference: **Fast mode
@@ -13,7 +13,12 @@
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"
17
22
  ],
18
23
  "maintenance": {
19
24
  "scope": "cross_boot",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.16.9",
3
+ "version": "6.18.0",
4
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": {
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
+ }