@gotcos/glasses-server 6.16.9 → 6.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +21 -0
- package/CHANGELOG.md +74 -0
- package/README.md +49 -0
- package/managed-runtime-contract.json +6 -1
- package/package.json +1 -1
- package/server/index.ts +12 -0
- package/server/lib/g2-enrichment-runner.ts +179 -0
- package/server/lib/g2-ops-handoff.ts +136 -0
- package/server/lib/live-cues-capability.ts +83 -0
- package/server/lib/live-cues-cursor.ts +167 -0
- package/server/lib/live-cues-engine.ts +461 -0
- package/server/lib/live-cues-memory.ts +202 -0
- package/server/lib/live-cues-prompt.ts +98 -0
- package/server/lib/maintenance-lifecycle.ts +6 -0
- package/server/lib/meeting-store.ts +10 -1
- package/server/lib/profile.ts +24 -2
- package/server/lib/speaker-embeddings.ts +169 -6
- package/server/routes/health.ts +17 -0
- package/server/routes/live-cues.ts +58 -0
- package/server/routes/meeting.ts +43 -12
- package/server/routes/transcribe-stream.ts +17 -2
- package/server/routes/voice.ts +9 -3
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
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
|
+
}
|