@gotcos/glasses-server 6.12.7 → 6.14.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/CHANGELOG.md CHANGED
@@ -1,3 +1,7 @@
1
+ ## 6.14.0
2
+
3
+ - Add voice (TTS + speaker) and additive glasses routes to the public server: `tts`, `voice`, `glossary`, `handoffs`, `recovery`, `prompt-edit`, `bookmarks`. Brings server-side voice + companion utilities to public installs; COS-integration routes remain private.
4
+
1
5
  # Changelog
2
6
 
3
7
  ## 6.12.7
@@ -500,3 +504,10 @@ it directly, with no second repository to clone.
500
504
  can reach the server over your mesh/LAN. The IP allowlist blocks public traffic.
501
505
  - **Persistent config** at `~/.cos-glasses/.env`.
502
506
  - Requires Node.js 20.11+.
507
+ # 6.13.0
508
+
509
+ - Added a non-interactive managed-server entrypoint for the COS Control macOS app.
510
+ - Added authenticated maintenance status and guarded local Whisper restart contracts.
511
+ - Added `--prepare-only` to the existing guided launcher so first-run dependencies can be prepared without leaving a second server process running.
512
+ - Added a provider-neutral managed work-folder setting while preserving the existing interactive launch-directory behavior.
513
+ - Kept the existing `npx @gotcos/glasses-server` foreground workflow fully compatible.
package/README.md CHANGED
@@ -11,6 +11,16 @@ API key is pasted into the phone for chat.
11
11
  npx --yes @gotcos/glasses-server@latest
12
12
  ```
13
13
 
14
+ For the optional COS Control macOS menu bar app, prepare dependencies without
15
+ leaving a foreground server running:
16
+
17
+ ```bash
18
+ npx --yes @gotcos/glasses-server@latest --prepare-only
19
+ ```
20
+
21
+ COS Control then installs the same npm package as a launchd-managed runtime.
22
+ The original foreground command remains supported and unchanged.
23
+
14
24
  The launcher checks Node, finds your CLI, checks voice and image processing,
15
25
  downloads the local voice model when needed, writes `~/.cos-glasses/.env`, and
16
26
  starts the server on `0.0.0.0:3141`. On boot it prints
package/bin/cli.cjs CHANGED
@@ -41,6 +41,7 @@ if (process.argv.includes('--help') || process.argv.includes('-h')) {
41
41
  console.log('')
42
42
  console.log(' Usage:')
43
43
  console.log(' npx --yes @gotcos/glasses-server@latest')
44
+ console.log(' npx --yes @gotcos/glasses-server@latest --prepare-only')
44
45
  console.log('')
45
46
  console.log(' Requirements:')
46
47
  console.log(' - Node.js 20.11+')
@@ -174,6 +175,17 @@ try {
174
175
  process.exit(1)
175
176
  }
176
177
 
178
+ // Controller probes are deliberately read-only. They verify Node, agent auth,
179
+ // and the packaged runtime without creating config, downloading models,
180
+ // changing permissions, or starting a listener.
181
+ if (process.argv.includes('--prepare-only')) {
182
+ console.log('')
183
+ console.log(green(' ✓ Non-mutating readiness check complete'))
184
+ console.log(' COS Control can perform guided installation without hidden setup side effects.')
185
+ console.log('')
186
+ process.exit(0)
187
+ }
188
+
177
189
  // Step 4: persistent config at ~/.cos-glasses/ (survives npx cache churn)
178
190
  function securePrivateDirectory(dir) {
179
191
  mkdirSync(dir, { recursive: true, mode: 0o700 })
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env node
2
+
3
+ // Non-interactive entrypoint for trusted local service managers such as
4
+ // COS Control. The existing glasses-server CLI remains the interactive setup
5
+ // path; this launcher assumes ~/.cos-glasses/.env is already configured.
6
+
7
+ const { resolve } = require('node:path')
8
+ const packageJson = require('../package.json')
9
+
10
+ const PKG_ROOT = resolve(__dirname, '..')
11
+
12
+ try {
13
+ require('tsx/cjs')
14
+ } catch {
15
+ console.error('[cos-managed] Package dependencies are incomplete; reinstall @gotcos/glasses-server.')
16
+ process.exit(1)
17
+ }
18
+
19
+ const workDir = process.env.COS_WORKDIR?.trim()
20
+ process.env.COS_MANAGED = '1'
21
+ process.env.COS_ENTRYPOINT = 'managed-server'
22
+ process.env.COS_SERVER_VERSION = packageJson.version
23
+ if (workDir) process.env.COS_LAUNCH_DIR = workDir
24
+
25
+ // The launchd-owned PID is the listener owner. Keeping the listener in this
26
+ // process removes the supervisor/child ambiguity that made lifecycle proof and
27
+ // crash receipts unreliable.
28
+ require(resolve(PKG_ROOT, 'server/index.ts'))
@@ -0,0 +1,23 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "contractVersion": 2,
4
+ "entrypoint": "bin/managed-server.cjs",
5
+ "listenerOwnership": "entrypoint-process",
6
+ "requiredEnvironment": [
7
+ "COS_API_TOKEN",
8
+ "COS_SERVER_GENERATION_ID",
9
+ "COS_MAINTENANCE_GATE_PATH"
10
+ ],
11
+ "optionalEnvironment": [
12
+ "COS_WORKDIR",
13
+ "PORT",
14
+ "HTTPS_PORT",
15
+ "BIND_HOST",
16
+ "COS_DURABLE_QUERY_JOBS"
17
+ ],
18
+ "maintenance": {
19
+ "scope": "cross_boot",
20
+ "adoptionRequired": true,
21
+ "networkRestartEndpoint": false
22
+ }
23
+ }
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.12.7",
4
- "description": "COS Glasses self-hosted AI heads-up-display server for Even G2 smart glasses, powered by your local Claude Code or Codex CLI",
3
+ "version": "6.14.0",
4
+ "description": "COS Glasses \u2014 self-hosted AI heads-up-display server for Even G2 smart glasses, powered by your local Claude Code or Codex CLI",
5
5
  "type": "module",
6
6
  "bin": {
7
- "glasses-server": "bin/cli.cjs"
7
+ "glasses-server": "bin/cli.cjs",
8
+ "glasses-server-managed": "bin/managed-server.cjs"
8
9
  },
9
10
  "scripts": {
10
11
  "start": "node bin/cli.cjs",
@@ -24,6 +25,8 @@
24
25
  ],
25
26
  "files": [
26
27
  "bin/cli.cjs",
28
+ "bin/managed-server.cjs",
29
+ "managed-runtime-contract.json",
27
30
  "server",
28
31
  "shared",
29
32
  "!server/**/*.test.ts",
package/server/index.ts CHANGED
@@ -28,6 +28,14 @@ import { sessionsRouter } from './routes/sessions.js'
28
28
  import { mediaRouter, mediaBodyParser } from './routes/media.js'
29
29
  import { promptDraftsRouter } from './routes/prompt-drafts.js'
30
30
  import { cliDebugRouter } from './routes/cli-debug.js'
31
+ import { maintenanceRouter } from './routes/maintenance.js'
32
+ import { ttsRouter } from './routes/tts.js'
33
+ import { voiceRouter } from './routes/voice.js'
34
+ import { glossaryRouter } from './routes/glossary.js'
35
+ import { handoffsRouter } from './routes/handoffs.js'
36
+ import { recoveryRouter } from './routes/recovery.js'
37
+ import { promptEditRouter } from './routes/prompt-edit.js'
38
+ import { bookmarksRouter } from './routes/bookmarks.js'
31
39
  import { prewarmContext } from './lib/context-builder.js'
32
40
  import { preWarmCLI } from './lib/claude-bridge.js'
33
41
  import { getCodexRunConfig } from './lib/codex-run-ledger.js'
@@ -58,6 +66,13 @@ import {
58
66
  isTailscaleIpv4,
59
67
  } from './lib/network-policy.js'
60
68
  import { timingSafeTokenEqual } from './lib/token-auth.js'
69
+ import { isManagedRuntime } from './lib/managed-runtime.js'
70
+ import {
71
+ acquireMaintenanceWork,
72
+ MaintenanceLifecycleError,
73
+ maintenanceAdmissionsOpen,
74
+ maintenanceErrorPayload,
75
+ } from './lib/maintenance-lifecycle.js'
61
76
 
62
77
  const app = express()
63
78
  const PORT = parseInt(process.env.PORT ?? '3141', 10)
@@ -74,6 +89,9 @@ const BIND_HOST = process.env.BIND_HOST ?? '0.0.0.0'
74
89
 
75
90
  // API token — auto-generate if not set so every session is authenticated.
76
91
  const API_TOKEN_AUTO = !process.env.COS_API_TOKEN
92
+ if (isManagedRuntime() && API_TOKEN_AUTO) {
93
+ throw new Error('Managed COS startup requires a pre-provisioned COS_API_TOKEN.')
94
+ }
77
95
  const API_TOKEN = process.env.COS_API_TOKEN ?? `_${randomBytes(32).toString('base64url')}`
78
96
  process.env.COS_API_TOKEN = API_TOKEN // make available to routes that check it
79
97
  // Persist an auto-generated token to ~/.cos-glasses/.env so it SURVIVES restarts.
@@ -135,6 +153,42 @@ app.use('/api', (req, res, next) => {
135
153
  next()
136
154
  })
137
155
 
156
+ // Fail-closed catch-all for mutation routes that do not own a more specific
157
+ // lifecycle lease below. This closes the admission/drain race for secondary
158
+ // state-changing APIs (media, sessions, settings, diagnostics) without
159
+ // double-owning provider and recording continuations whose routes retain work
160
+ // through their true terminal boundary.
161
+ app.use('/api', (req, res, next) => {
162
+ if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS') return next()
163
+ const lifecycleOwned = (req.path === '/query-jobs' && req.method === 'POST')
164
+ || req.path === '/query'
165
+ || req.path === '/transcribe'
166
+ || req.path.startsWith('/transcribe-stream')
167
+ || req.path === '/meeting/save'
168
+ || req.path.startsWith('/prompt-drafts')
169
+ || req.path.startsWith('/maintenance/drain')
170
+ if (lifecycleOwned) return next()
171
+
172
+ try {
173
+ const lease = acquireMaintenanceWork('api_mutation')
174
+ let released = false
175
+ const release = () => {
176
+ if (released) return
177
+ released = true
178
+ lease.release()
179
+ }
180
+ res.once('finish', release)
181
+ res.once('close', release)
182
+ next()
183
+ } catch (error) {
184
+ if (error instanceof MaintenanceLifecycleError) {
185
+ if (error.retryAfterSeconds != null) res.setHeader('Retry-After', String(error.retryAfterSeconds))
186
+ return res.status(error.status).json(maintenanceErrorPayload(error))
187
+ }
188
+ return res.status(500).json({ error: 'maintenance_internal_error', retryable: false })
189
+ }
190
+ })
191
+
138
192
  // Authenticate before parsing large upload bodies. The 16 MB allowance stays
139
193
  // scoped to /api/media; every other route retains the 10 MB ceiling.
140
194
  app.use('/api/media', mediaBodyParser)
@@ -167,6 +221,14 @@ app.use('/api', sessionsRouter)
167
221
  app.use('/api', mediaRouter)
168
222
  app.use('/api', promptDraftsRouter)
169
223
  app.use('/api', cliDebugRouter)
224
+ app.use('/api', maintenanceRouter)
225
+ app.use('/api', ttsRouter)
226
+ app.use('/api', voiceRouter)
227
+ app.use('/api', glossaryRouter)
228
+ app.use('/api', handoffsRouter)
229
+ app.use('/api', recoveryRouter)
230
+ app.use('/api', promptEditRouter)
231
+ app.use('/api', bookmarksRouter)
170
232
 
171
233
  // OpenAI-compatible endpoint for the G2 Agent (ER "Add Agent")
172
234
  // Mounted at root — routes are /v1/chat/completions and /v1/models
@@ -242,6 +304,7 @@ listeners.push({ server: httpServer, port: PORT, host: BIND_HOST, label: 'HTTP'
242
304
 
243
305
  listenRequiredServers(listeners).then(() => {
244
306
  const serverInstanceId = initializeServerInstanceId()
307
+ const startupAdmissionsOpen = maintenanceAdmissionsOpen()
245
308
  if (listeners.some(listener => listener.label === 'HTTPS')) {
246
309
  console.log(`[COS API] HTTPS server running on https://${BIND_HOST}:${HTTPS_PORT}`)
247
310
  }
@@ -249,17 +312,21 @@ listenRequiredServers(listeners).then(() => {
249
312
  console.log(`[COS API] Server instance: ${serverInstanceId}`)
250
313
  console.log(`[COS API] Mode: ${COS_MODE ? 'COS pipeline' : 'standalone'}`)
251
314
 
252
- void initQueryJobRuntime().then(health => {
253
- if (process.env.COS_DURABLE_QUERY_JOBS === '1') {
254
- console.log(`[COS API] Durable query jobs: ${health.store.state} · ${health.store.retainedIdentities} retained`)
255
- } else {
256
- console.log('[COS API] Durable query jobs: disabled (set COS_DURABLE_QUERY_JOBS=1 to enable)')
257
- }
258
- }).catch(error => {
259
- // The store remains degraded and rejects admission. Legacy /api/query is
260
- // still mounted, so disabling the feature flag is an immediate rollback.
261
- console.error('[COS API] Durable query-job store unavailable:', error)
262
- })
315
+ if (startupAdmissionsOpen) {
316
+ void initQueryJobRuntime().then(health => {
317
+ if (process.env.COS_DURABLE_QUERY_JOBS === '1') {
318
+ console.log(`[COS API] Durable query jobs: ${health.store.state} · ${health.store.retainedIdentities} retained`)
319
+ } else {
320
+ console.log('[COS API] Durable query jobs: disabled (set COS_DURABLE_QUERY_JOBS=1 to enable)')
321
+ }
322
+ }).catch(error => {
323
+ // The store remains degraded and rejects admission. Legacy /api/query is
324
+ // still mounted, so disabling the feature flag is an immediate rollback.
325
+ console.error('[COS API] Durable query-job store unavailable:', error)
326
+ })
327
+ } else {
328
+ console.log('[COS API] Startup maintenance gate is closed — durable recovery waits for controller adoption')
329
+ }
263
330
 
264
331
  // Print ADDRESSES THE PHONE CAN ACTUALLY REACH. The bind address (0.0.0.0) is
265
332
  // not paste-able — enumerate real interfaces and label the Tailscale one.
@@ -281,8 +348,10 @@ listenRequiredServers(listeners).then(() => {
281
348
  }
282
349
  } catch { /* interface enumeration is best-effort */ }
283
350
 
284
- // Print the full API token when auto-generated the user pastes it into the app.
285
- if (API_TOKEN_AUTO) {
351
+ // Interactive standalone startup may print a newly generated pairing token.
352
+ // Managed startup never generates or logs credentials; COS Control copies the
353
+ // pre-provisioned token through the local pasteboard flow instead.
354
+ if (API_TOKEN_AUTO && !isManagedRuntime()) {
286
355
  console.log('')
287
356
  console.log(`[COS API] API Token: ${API_TOKEN}`)
288
357
  console.log('[COS API] ^ paste this into the COS Glasses app' + (API_TOKEN_PERSISTED ? ' — saved to ~/.cos-glasses/.env so it stays the same across restarts' : ' (set COS_API_TOKEN in .env for a fixed token)'))
@@ -306,32 +375,34 @@ listenRequiredServers(listeners).then(() => {
306
375
  console.log(`[COS API] Codex workdir: ${codexConfig.cwd}`)
307
376
  // Refresh immediately and then periodically. The catalog module retains the
308
377
  // last-known-good snapshot if Codex is temporarily unavailable.
309
- startCodexModelCatalogRefresh()
378
+ if (startupAdmissionsOpen) {
379
+ startCodexModelCatalogRefresh()
310
380
 
311
- if (COS_MODE) {
312
- initSessionCache()
313
- // Pre-warm context cache so first query doesn't wait for the pipeline
314
- prewarmContext()
315
- }
316
- // Start local whisper-server (model stays in RAM for ~50ms transcription)
317
- startWhisperServer().catch(err => console.error('[startup] Whisper server error:', err))
318
- // Initialize speaker embeddings (voiceprint-based diarization) — fails soft if model absent
319
- const embeddingOk = initSpeakerEmbeddings()
320
- console.log(`[startup] Speaker embeddings: ${embeddingOk ? 'active' : 'disabled (model not found)'}`)
321
- // Initialize Silero VAD (silence trimming before Whisper) — fails soft if model absent
322
- const vadOk = initSileroVAD()
323
- console.log(`[startup] Silero VAD: ${vadOk ? 'active' : 'disabled (model not found)'}`)
381
+ if (COS_MODE) {
382
+ initSessionCache()
383
+ // Pre-warm context cache so first query doesn't wait for the pipeline
384
+ prewarmContext()
385
+ }
386
+ // Start local whisper-server (model stays in RAM for ~50ms transcription)
387
+ startWhisperServer().catch(err => console.error('[startup] Whisper server error:', err))
388
+ // Initialize speaker embeddings (voiceprint-based diarization) — fails soft if model absent
389
+ const embeddingOk = initSpeakerEmbeddings()
390
+ console.log(`[startup] Speaker embeddings: ${embeddingOk ? 'active' : 'disabled (model not found)'}`)
391
+ // Initialize Silero VAD (silence trimming before Whisper) — fails soft if model absent
392
+ const vadOk = initSileroVAD()
393
+ console.log(`[startup] Silero VAD: ${vadOk ? 'active' : 'disabled (model not found)'}`)
324
394
 
325
- // Pre-warm Claude only when installed so Codex-only startup stays quiet.
326
- if (claudeAvailable) {
327
- preWarmCLI().catch(err => console.error('[startup] CLI pre-warm error:', err))
328
- }
395
+ // Pre-warm Claude only when installed so Codex-only startup stays quiet.
396
+ if (claudeAvailable) {
397
+ preWarmCLI().catch(err => console.error('[startup] CLI pre-warm error:', err))
398
+ }
329
399
 
330
- // Auto-snapshot active sessions every 5 min (survives restarts)
331
- startAutoSnapshot(5 * 60_000)
400
+ // Auto-snapshot active sessions every 5 min (survives restarts)
401
+ startAutoSnapshot(5 * 60_000)
332
402
 
333
- // Durable media GC (staged/reserved expiry + generated-image content TTL).
334
- getMediaStore().startGC()
403
+ // Durable media GC (staged/reserved expiry + generated-image content TTL).
404
+ getMediaStore().startGC()
405
+ }
335
406
  }).catch((error: NodeJS.ErrnoException) => {
336
407
  console.error(`[COS API] Fatal listener startup: ${error.message}`)
337
408
  process.exit(error.code === 'EADDRINUSE' ? 75 : 74)
@@ -0,0 +1,96 @@
1
+ // Bookmarks — save individual messages for quick reference from glasses
2
+ // Stored as a flat JSON array in server/data/bookmarks.json
3
+
4
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
5
+ import { resolve, dirname } from 'node:path'
6
+ import { fileURLToPath } from 'node:url'
7
+ import { parseMediaAttachmentRefs, type MediaAttachmentRef } from '../../shared/media-attachment.js'
8
+
9
+ const __dirname = dirname(fileURLToPath(import.meta.url))
10
+ const DATA_DIR = resolve(__dirname, '..', 'data')
11
+ const BOOKMARKS_PATH = resolve(DATA_DIR, 'bookmarks.json')
12
+
13
+ // ── Interfaces ──────────────────────────────────────────────
14
+
15
+ export interface Bookmark {
16
+ id: number
17
+ query: string // original user query
18
+ text: string // COS response (plain text, markdown stripped)
19
+ label: string // short label for list display
20
+ savedAt: number // when bookmarked (epoch ms)
21
+ originalTimestamp: number // when message was originally received
22
+ messageIndex: number // which message # it was in the session
23
+ attachments?: MediaAttachmentRef[] // Release A — refs only, never bytes
24
+ }
25
+
26
+ // ── Read/Write ──────────────────────────────────────────────
27
+
28
+ function ensureDataDir(): void {
29
+ mkdirSync(DATA_DIR, { recursive: true })
30
+ }
31
+
32
+ export function loadBookmarks(): Bookmark[] {
33
+ try {
34
+ const raw = readFileSync(BOOKMARKS_PATH, 'utf-8')
35
+ return JSON.parse(raw) as Bookmark[]
36
+ } catch {
37
+ return []
38
+ }
39
+ }
40
+
41
+ function saveBookmarks(bookmarks: Bookmark[]): void {
42
+ ensureDataDir()
43
+ writeFileSync(BOOKMARKS_PATH, JSON.stringify(bookmarks, null, 2))
44
+ }
45
+
46
+ // ── Operations ──────────────────────────────────────────────
47
+
48
+ /** Save a message as a bookmark. Returns the new bookmark. Attachment refs
49
+ * are optional, validated through the strict parser (refs only, no bytes). */
50
+ export function addBookmark(
51
+ query: string,
52
+ text: string,
53
+ messageIndex: number,
54
+ originalTimestamp: number,
55
+ attachments?: unknown,
56
+ ): Bookmark {
57
+ const bookmarks = loadBookmarks()
58
+
59
+ // Auto-generate label from query (first 50 chars)
60
+ const label = query.length > 50 ? query.slice(0, 47) + '...' : query
61
+
62
+ // Next ID = max existing + 1
63
+ const nextId = bookmarks.length > 0 ? Math.max(...bookmarks.map(b => b.id)) + 1 : 1
64
+
65
+ const validRefs = parseMediaAttachmentRefs(attachments)
66
+ const bookmark: Bookmark = {
67
+ id: nextId,
68
+ query,
69
+ text,
70
+ label,
71
+ savedAt: Date.now(),
72
+ originalTimestamp,
73
+ messageIndex,
74
+ ...(validRefs.length > 0 ? { attachments: validRefs } : {}),
75
+ }
76
+
77
+ bookmarks.push(bookmark)
78
+ saveBookmarks(bookmarks)
79
+ return bookmark
80
+ }
81
+
82
+ /** Delete a bookmark by ID. Returns true if found and deleted. */
83
+ export function deleteBookmark(id: number): boolean {
84
+ const bookmarks = loadBookmarks()
85
+ const idx = bookmarks.findIndex(b => b.id === id)
86
+ if (idx === -1) return false
87
+ bookmarks.splice(idx, 1)
88
+ saveBookmarks(bookmarks)
89
+ return true
90
+ }
91
+
92
+ /** Get a single bookmark by ID */
93
+ export function getBookmark(id: number): Bookmark | null {
94
+ const bookmarks = loadBookmarks()
95
+ return bookmarks.find(b => b.id === id) ?? null
96
+ }