@gotcos/glasses-server 6.5.0 → 6.6.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 +5 -0
- package/CHANGELOG.md +25 -0
- package/README.md +5 -1
- package/package.json +1 -1
- package/server/index.ts +4 -6
- package/server/lib/display-bus.ts +61 -3
- package/server/lib/server-instance-id.ts +55 -0
- package/server/lib/server-metrics.ts +7 -0
- package/server/routes/display.ts +43 -22
- package/server/routes/health.ts +3 -2
package/.env.example
CHANGED
|
@@ -23,6 +23,11 @@ BIND_HOST=0.0.0.0
|
|
|
23
23
|
# ~/.cos-glasses/data/media alongside the standalone conversation/archive data.
|
|
24
24
|
# COS_MEDIA_ROOT=/path/on-a-local-volume/media
|
|
25
25
|
|
|
26
|
+
# Optional logical server identity location. Most installs should keep the
|
|
27
|
+
# default (~/.cos-glasses/server-instance-id) so reconnects can verify the same
|
|
28
|
+
# server after Wi-Fi/Tailscale changes and process restarts.
|
|
29
|
+
# COS_SERVER_INSTANCE_ID_PATH=/path/to/server-instance-id
|
|
30
|
+
|
|
26
31
|
# ── THE LLM (chat) ──────────────────────────────────────────────────────
|
|
27
32
|
# Chat runs through your LOCAL agent CLI — NOT an API key:
|
|
28
33
|
# Opus / Fable / Sonnet -> Claude Code CLI (https://claude.ai/download, then `claude login`)
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 6.6.0
|
|
4
|
+
|
|
5
|
+
Reconnect compatibility for COS Glasses build 188, without importing private
|
|
6
|
+
COS day-context or Mac service-control behavior into the public package.
|
|
7
|
+
|
|
8
|
+
- **Stable logical server identity.** The server creates one atomic UUID under
|
|
9
|
+
`~/.cos-glasses/server-instance-id`, preserves it across process and network
|
|
10
|
+
restarts, and returns it from authenticated `/api/models` probes. Files are
|
|
11
|
+
mode `0600`; identity is minted only after every required listener binds.
|
|
12
|
+
- **Boot-scoped display cursors.** Display events receive one publish-owned ID
|
|
13
|
+
before fan-out, so multiple subscribers see the same cursor and cannot
|
|
14
|
+
duplicate replay records. Each process boot has a distinct UUID.
|
|
15
|
+
- **Deterministic reconnect handshake.** `/api/display-stream` emits `ready`
|
|
16
|
+
before application events, accepts boot/event cursors, replays the last 200
|
|
17
|
+
publish-owned events, and reports typed `boot_changed`, `cursor_ahead`, or
|
|
18
|
+
`buffer_overflow` gaps so clients reconcile durable history instead of
|
|
19
|
+
guessing or silently dropping replies.
|
|
20
|
+
- **Privacy boundary preserved.** Authenticated query activity remains off the
|
|
21
|
+
unauthenticated global display bus. The npm server does not include private
|
|
22
|
+
daily evidence exports, personal COS paths, launchd ownership, or remote
|
|
23
|
+
machine-restart controls.
|
|
24
|
+
- **Backward compatible.** Older clients can continue opening the same SSE
|
|
25
|
+
endpoint and ignoring the additive `ready`, cursor metadata, and replay-gap
|
|
26
|
+
events.
|
|
27
|
+
|
|
3
28
|
## 6.5.0
|
|
4
29
|
|
|
5
30
|
Durable phone photos and assistant-selected output images for COS Glasses
|
package/README.md
CHANGED
|
@@ -16,7 +16,10 @@ downloads the local voice model when needed, writes `~/.cos-glasses/.env`, and
|
|
|
16
16
|
starts the server on `0.0.0.0:3141`. On boot it prints
|
|
17
17
|
an **API token** — paste that into the COS Glasses app. Only one COS Glasses
|
|
18
18
|
server may run on a Mac at a time; a second `npx` or source runner exits before
|
|
19
|
-
opening ports or touching shared conversation/media state.
|
|
19
|
+
opening ports or touching shared conversation/media state. Version 6.6.0 also
|
|
20
|
+
gives that server a durable identity and boot-scoped display replay, allowing
|
|
21
|
+
build 188+ to reconnect after a Tailscale, Wi-Fi, or process interruption
|
|
22
|
+
without silently losing completed replies.
|
|
20
23
|
|
|
21
24
|
## Requirements
|
|
22
25
|
|
|
@@ -87,6 +90,7 @@ BIND_HOST=0.0.0.0 npm run start:server
|
|
|
87
90
|
## Troubleshooting
|
|
88
91
|
|
|
89
92
|
- *Phone can't connect* — check `BIND_HOST=0.0.0.0`, the same Tailscale account on both devices, and the correct `100.x` IP + token.
|
|
93
|
+
- *Safari connects but the app does not* — confirm `npx @gotcos/glasses-server@latest` is 6.6.0+, then use the app's server reconnect/edit control to verify the current URL and token. Do not run a second source or `npx` server alongside it.
|
|
90
94
|
- *AI queries fail* — run `claude --version` / `codex --version`, then `claude login` / `codex login`.
|
|
91
95
|
- *Voice getting billed?* — install `whisper-cpp` for free local transcription.
|
|
92
96
|
- *Photos unavailable?* — install `ffmpeg`, restart the server, and confirm `/api/health` reports `features.mediaProcessingReady: true`.
|
package/package.json
CHANGED
package/server/index.ts
CHANGED
|
@@ -38,6 +38,8 @@ import { initSpeakerEmbeddings } from './lib/speaker-embeddings.js'
|
|
|
38
38
|
import { logActiveSessionsOnShutdown, startAutoSnapshot } from './lib/conversation.js'
|
|
39
39
|
import { getMediaStore } from './lib/media-store.js'
|
|
40
40
|
import { listenRequiredServers, type RequiredListener } from './lib/listener-startup.js'
|
|
41
|
+
import { serverMetrics } from './lib/server-metrics.js'
|
|
42
|
+
import { initializeServerInstanceId } from './lib/server-instance-id.js'
|
|
41
43
|
|
|
42
44
|
const app = express()
|
|
43
45
|
const PORT = parseInt(process.env.PORT ?? '3141', 10)
|
|
@@ -69,12 +71,6 @@ if (API_TOKEN_AUTO) {
|
|
|
69
71
|
} catch { /* read-only home — token stays per-session */ }
|
|
70
72
|
}
|
|
71
73
|
|
|
72
|
-
// Server metrics — shared with /api/health for monitoring
|
|
73
|
-
export const serverMetrics = {
|
|
74
|
-
startedAt: Date.now(),
|
|
75
|
-
requestCount: 0,
|
|
76
|
-
}
|
|
77
|
-
|
|
78
74
|
// IP allowlist — only accept connections from localhost, meshnet, and private networks.
|
|
79
75
|
// Blocks untrusted public access (coffee-shop WiFi, the open internet) while keeping all
|
|
80
76
|
// local + meshnet (Tailscale/CGNAT) + LAN consumers working.
|
|
@@ -218,10 +214,12 @@ const httpServer = createHttpServer(app)
|
|
|
218
214
|
listeners.push({ server: httpServer, port: PORT, host: BIND_HOST, label: 'HTTP' })
|
|
219
215
|
|
|
220
216
|
listenRequiredServers(listeners).then(() => {
|
|
217
|
+
const serverInstanceId = initializeServerInstanceId()
|
|
221
218
|
if (listeners.some(listener => listener.label === 'HTTPS')) {
|
|
222
219
|
console.log(`[COS API] HTTPS server running on https://${BIND_HOST}:${HTTPS_PORT}`)
|
|
223
220
|
}
|
|
224
221
|
console.log(`[COS API] HTTP server running on http://${BIND_HOST}:${PORT}`)
|
|
222
|
+
console.log(`[COS API] Server instance: ${serverInstanceId}`)
|
|
225
223
|
console.log(`[COS API] Mode: ${COS_MODE ? 'COS pipeline' : 'standalone'}`)
|
|
226
224
|
|
|
227
225
|
// Print ADDRESSES THE PHONE CAN ACTUALLY REACH. The bind address (0.0.0.0) is
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// to all connected glasses clients (SSE display-stream subscribers)
|
|
3
3
|
|
|
4
4
|
import { EventEmitter } from 'node:events'
|
|
5
|
+
import { serverMetrics } from './server-metrics.js'
|
|
5
6
|
|
|
6
7
|
const bus = new EventEmitter()
|
|
7
8
|
bus.setMaxListeners(20) // Multiple glasses clients
|
|
@@ -11,11 +12,68 @@ export interface DisplayEvent {
|
|
|
11
12
|
data: Record<string, unknown>
|
|
12
13
|
}
|
|
13
14
|
|
|
14
|
-
export
|
|
15
|
-
|
|
15
|
+
export interface PublishedDisplayEvent extends DisplayEvent {
|
|
16
|
+
bootId: string
|
|
17
|
+
eventId: number
|
|
18
|
+
publishedAt: string
|
|
16
19
|
}
|
|
17
20
|
|
|
18
|
-
export
|
|
21
|
+
export interface DisplayReplayResult {
|
|
22
|
+
events: PublishedDisplayEvent[]
|
|
23
|
+
gap: boolean
|
|
24
|
+
reason?: 'boot_changed' | 'cursor_ahead' | 'buffer_overflow'
|
|
25
|
+
oldestEventId: number
|
|
26
|
+
latestEventId: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const REPLAY_BUFFER_SIZE = 200
|
|
30
|
+
let eventId = 0
|
|
31
|
+
const replayBuffer: PublishedDisplayEvent[] = []
|
|
32
|
+
|
|
33
|
+
export function emitDisplay(event: DisplayEvent): PublishedDisplayEvent {
|
|
34
|
+
const published: PublishedDisplayEvent = {
|
|
35
|
+
...event,
|
|
36
|
+
bootId: serverMetrics.bootId,
|
|
37
|
+
eventId: ++eventId,
|
|
38
|
+
publishedAt: new Date().toISOString(),
|
|
39
|
+
}
|
|
40
|
+
replayBuffer.push(published)
|
|
41
|
+
if (replayBuffer.length > REPLAY_BUFFER_SIZE) replayBuffer.shift()
|
|
42
|
+
bus.emit('display', published)
|
|
43
|
+
return published
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function onDisplay(listener: (event: PublishedDisplayEvent) => void): () => void {
|
|
19
47
|
bus.on('display', listener)
|
|
20
48
|
return () => { bus.off('display', listener) }
|
|
21
49
|
}
|
|
50
|
+
|
|
51
|
+
export function getDisplayWatermark(): { bootId: string; eventId: number } {
|
|
52
|
+
return { bootId: serverMetrics.bootId, eventId }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function replayDisplayEvents(bootId: string | null, afterEventId: number): DisplayReplayResult {
|
|
56
|
+
const oldestEventId = replayBuffer[0]?.eventId ?? eventId + 1
|
|
57
|
+
const latestEventId = eventId
|
|
58
|
+
if (bootId && bootId !== serverMetrics.bootId) {
|
|
59
|
+
return { events: [], gap: true, reason: 'boot_changed', oldestEventId, latestEventId }
|
|
60
|
+
}
|
|
61
|
+
if (afterEventId > latestEventId) {
|
|
62
|
+
return { events: [], gap: true, reason: 'cursor_ahead', oldestEventId, latestEventId }
|
|
63
|
+
}
|
|
64
|
+
if (afterEventId > 0 && afterEventId < oldestEventId - 1) {
|
|
65
|
+
return { events: [], gap: true, reason: 'buffer_overflow', oldestEventId, latestEventId }
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
events: replayBuffer.filter(item => item.eventId > afterEventId),
|
|
69
|
+
gap: false,
|
|
70
|
+
oldestEventId,
|
|
71
|
+
latestEventId,
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function __resetDisplayBusForTests(): void {
|
|
76
|
+
eventId = 0
|
|
77
|
+
replayBuffer.splice(0)
|
|
78
|
+
bus.removeAllListeners('display')
|
|
79
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import {
|
|
3
|
+
chmodSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
renameSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
} from 'node:fs'
|
|
9
|
+
import { homedir } from 'node:os'
|
|
10
|
+
import { dirname, join } from 'node:path'
|
|
11
|
+
|
|
12
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
|
13
|
+
|
|
14
|
+
let initializedId: string | null = null
|
|
15
|
+
|
|
16
|
+
export function defaultServerInstanceIdPath(): string {
|
|
17
|
+
return process.env.COS_SERVER_INSTANCE_ID_PATH
|
|
18
|
+
?? join(homedir(), '.cos-glasses', 'server-instance-id')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Initialize only after every required listener binds. A failed or half-bound
|
|
23
|
+
* process must not mint an identity that a client later trusts as healthy.
|
|
24
|
+
*/
|
|
25
|
+
export function initializeServerInstanceId(path = defaultServerInstanceIdPath()): string {
|
|
26
|
+
if (initializedId) return initializedId
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const existing = readFileSync(path, 'utf8').trim()
|
|
30
|
+
if (UUID_RE.test(existing)) {
|
|
31
|
+
chmodSync(path, 0o600)
|
|
32
|
+
initializedId = existing
|
|
33
|
+
return existing
|
|
34
|
+
}
|
|
35
|
+
} catch {
|
|
36
|
+
// Missing or invalid state is replaced atomically below.
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 })
|
|
40
|
+
const id = randomUUID()
|
|
41
|
+
const tmp = `${path}.tmp-${process.pid}-${randomUUID()}`
|
|
42
|
+
writeFileSync(tmp, `${id}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' })
|
|
43
|
+
renameSync(tmp, path)
|
|
44
|
+
chmodSync(path, 0o600)
|
|
45
|
+
initializedId = id
|
|
46
|
+
return id
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function getServerInstanceId(): string | null {
|
|
50
|
+
return initializedId
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function __resetServerInstanceIdForTests(): void {
|
|
54
|
+
initializedId = null
|
|
55
|
+
}
|
package/server/routes/display.ts
CHANGED
|
@@ -2,15 +2,28 @@
|
|
|
2
2
|
// Any connected glasses client receives real-time query responses
|
|
3
3
|
// regardless of which interface submitted the query
|
|
4
4
|
|
|
5
|
-
import { Router } from 'express'
|
|
6
|
-
import {
|
|
5
|
+
import { Router, type Response } from 'express'
|
|
6
|
+
import {
|
|
7
|
+
emitDisplay,
|
|
8
|
+
getDisplayWatermark,
|
|
9
|
+
onDisplay,
|
|
10
|
+
replayDisplayEvents,
|
|
11
|
+
type PublishedDisplayEvent,
|
|
12
|
+
} from '../lib/display-bus.js'
|
|
7
13
|
|
|
8
14
|
export const displayRouter = Router()
|
|
9
15
|
|
|
10
|
-
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
16
|
+
function writeEvent(res: Response, event: PublishedDisplayEvent): void {
|
|
17
|
+
const data = JSON.stringify({
|
|
18
|
+
...event.data,
|
|
19
|
+
_cosDisplayCursor: {
|
|
20
|
+
bootId: event.bootId,
|
|
21
|
+
eventId: event.eventId,
|
|
22
|
+
publishedAt: event.publishedAt,
|
|
23
|
+
},
|
|
24
|
+
})
|
|
25
|
+
res.write(`id: ${event.bootId}:${event.eventId}\nevent: ${event.type}\ndata: ${data}\n\n`)
|
|
26
|
+
}
|
|
14
27
|
|
|
15
28
|
displayRouter.get('/display-stream', (req, res) => {
|
|
16
29
|
res.writeHead(200, {
|
|
@@ -25,15 +38,29 @@ displayRouter.get('/display-stream', (req, res) => {
|
|
|
25
38
|
// Tell EventSource to retry quickly on disconnect (3s instead of browser default ~5-10s)
|
|
26
39
|
res.write('retry: 3000\n\n')
|
|
27
40
|
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
41
|
+
const headerCursor = String(req.headers['last-event-id'] ?? '')
|
|
42
|
+
const [headerBootId, headerEventId] = headerCursor.includes(':')
|
|
43
|
+
? headerCursor.split(':', 2)
|
|
44
|
+
: ['', headerCursor]
|
|
45
|
+
const cursorBootId = String(req.query.bootId ?? headerBootId ?? '') || null
|
|
46
|
+
const cursorEventId = Number(req.query.eventId ?? headerEventId ?? 0)
|
|
47
|
+
const replay = replayDisplayEvents(cursorBootId, Number.isFinite(cursorEventId) ? cursorEventId : 0)
|
|
48
|
+
|
|
49
|
+
// Ready is a transport handshake, not proof that replay was consumed. It
|
|
50
|
+
// must precede application events so build 188 can finish admission first.
|
|
51
|
+
const watermark = getDisplayWatermark()
|
|
52
|
+
res.write(`event: ready\ndata: ${JSON.stringify(watermark)}\n\n`)
|
|
53
|
+
if (replay.gap) {
|
|
54
|
+
res.write(`event: replay_gap\ndata: ${JSON.stringify({
|
|
55
|
+
reason: replay.reason,
|
|
56
|
+
requested: { bootId: cursorBootId, eventId: cursorEventId },
|
|
57
|
+
watermark,
|
|
58
|
+
oldestEventId: replay.oldestEventId,
|
|
59
|
+
})}\n\n`)
|
|
60
|
+
} else {
|
|
61
|
+
for (const event of replay.events) writeEvent(res, event)
|
|
62
|
+
if (replay.events.length > 0) {
|
|
63
|
+
console.log(`[display-bus] Replayed ${replay.events.length} publish-owned events after ${cursorEventId}`)
|
|
37
64
|
}
|
|
38
65
|
}
|
|
39
66
|
|
|
@@ -43,13 +70,7 @@ displayRouter.get('/display-stream', (req, res) => {
|
|
|
43
70
|
}, 15_000)
|
|
44
71
|
|
|
45
72
|
const unsub = onDisplay((event) => {
|
|
46
|
-
|
|
47
|
-
const data = JSON.stringify(event.data)
|
|
48
|
-
// Buffer for replay
|
|
49
|
-
replayBuffer.push({ id: eventId, type: event.type, data })
|
|
50
|
-
if (replayBuffer.length > REPLAY_BUFFER_SIZE) replayBuffer.shift()
|
|
51
|
-
// Send with id for Last-Event-ID tracking
|
|
52
|
-
try { res.write(`id: ${eventId}\nevent: ${event.type}\ndata: ${data}\n\n`) } catch { /* client gone */ }
|
|
73
|
+
try { writeEvent(res, event) } catch { /* client gone */ }
|
|
53
74
|
})
|
|
54
75
|
|
|
55
76
|
req.on('close', () => {
|
package/server/routes/health.ts
CHANGED
|
@@ -3,7 +3,8 @@ import { execFile } from 'node:child_process'
|
|
|
3
3
|
import { statSync } from 'node:fs'
|
|
4
4
|
import { resolve } from 'node:path'
|
|
5
5
|
import { COS_SCRIPTS_DIR, COS_MODE, PYTHON_BIN } from '../lib/python-bridge.js'
|
|
6
|
-
import { serverMetrics } from '../
|
|
6
|
+
import { serverMetrics } from '../lib/server-metrics.js'
|
|
7
|
+
import { getServerInstanceId } from '../lib/server-instance-id.js'
|
|
7
8
|
import { isSileroAvailable } from '../lib/vad-silero.js'
|
|
8
9
|
import { getAvailableCliSessionId } from '../lib/claude-bridge.js'
|
|
9
10
|
import { isWhisperLocalAvailable, getWhisperHealth } from '../lib/whisper-local.js'
|
|
@@ -133,7 +134,7 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
133
134
|
// authenticated by the global /api middleware; ?refresh=1 forces discovery.
|
|
134
135
|
healthRouter.get('/models', async (req, res) => {
|
|
135
136
|
const catalog = await getCodexModelCatalog(req.query.refresh === '1')
|
|
136
|
-
res.json(catalog)
|
|
137
|
+
res.json({ ...catalog, serverInstanceId: getServerInstanceId() })
|
|
137
138
|
})
|
|
138
139
|
|
|
139
140
|
// GET /api/cli-session — returns current CLI session ID for cross-device resume
|