@gotcos/glasses-server 6.2.0 → 6.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 6.2.1
4
+
5
+ Foolproofing release — driven by an adversarial onboarding QA pass.
6
+
7
+ - **The server now prints URLs the phone can actually use.** Boot output lists
8
+ your real addresses (`http://100.x.x.x:3141` labeled Tailscale, LAN IPs labeled
9
+ same-Wi-Fi) instead of only the un-pasteable bind address `0.0.0.0`.
10
+ - **Auto-generated API tokens survive restarts.** First boot saves the token to
11
+ `~/.cos-glasses/.env`, so re-running the server no longer silently rotates the
12
+ credential your app already saved (the "worked yesterday, 401 today" trap).
13
+ - **Starter-Kit COS inheritance is real now.** Run `npx @gotcos/glasses-server`
14
+ from your COS folder and glasses chat loads its brain: the launcher records
15
+ your launch directory, and when it contains `.cos/manifest.json`, `AGENTS.md`,
16
+ or `CLAUDE.md`, Claude/Codex spawn there (explicit `COS_SCRIPTS_DIR` still wins).
17
+ - **Transfer-integrity report actually surfaces.** 6.2.0 recorded lost chunks but
18
+ never returned them; the offline-session `finalize` response now includes
19
+ `transferIntegrity` (received/expected/missing/completeness) and a gap-aware
20
+ `transcript` with inline `[… audio gap …]` markers.
21
+ - **One default model everywhere: Sonnet.** The query router, the OpenAI-compat
22
+ surface, and CLI pre-warm all default to Sonnet (was a mix of Opus and Haiku).
23
+ Set `COS_G2_DEFAULT_MODEL` to override; per-query picks unchanged.
24
+
3
25
  ## 6.2.0
4
26
 
5
27
  Reliability release — ports the hardening the full COS Glasses app shipped in June.
package/bin/cli.cjs CHANGED
@@ -13,6 +13,12 @@ const { homedir } = require('os')
13
13
  const PKG_ROOT = resolve(__dirname, '..')
14
14
  const CONFIG_DIR = join(homedir(), '.cos-glasses')
15
15
 
16
+ // Record where the user ran `npx @gotcos/glasses-server` from. The server spawns
17
+ // with cwd = PKG_ROOT (the npx cache), so without this the user's Starter-Kit COS
18
+ // (AGENTS.md / CLAUDE.md / .cos/) in their launch folder would never be seen.
19
+ // Chat spawns of claude/codex use this dir when it contains a COS brain.
20
+ if (!process.env.COS_LAUNCH_DIR) process.env.COS_LAUNCH_DIR = process.cwd()
21
+
16
22
  const green = (s) => `\x1b[32m${s}\x1b[0m`
17
23
  const red = (s) => `\x1b[31m${s}\x1b[0m`
18
24
  const yellow = (s) => `\x1b[33m${s}\x1b[0m`
@@ -185,6 +191,12 @@ if (!process.env.BIND_HOST) {
185
191
  }
186
192
 
187
193
  // Step 7: start the bundled server
194
+ try {
195
+ const ld = process.env.COS_LAUNCH_DIR
196
+ if (ld && (existsSync(join(ld, '.cos', 'manifest.json')) || existsSync(join(ld, 'AGENTS.md')) || existsSync(join(ld, 'CLAUDE.md')))) {
197
+ console.log(green(' ✓') + ` COS detected in ${dim(ld)} — glasses chat will load its brain`)
198
+ }
199
+ } catch { /* detection is best-effort */ }
188
200
  console.log('')
189
201
  console.log(dim(' Starting server...'))
190
202
  console.log('')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.2.0",
3
+ "version": "6.2.1",
4
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",
5
5
  "type": "module",
6
6
  "bin": {
package/server/index.ts CHANGED
@@ -7,7 +7,9 @@ import cors from 'cors'
7
7
  import path from 'node:path'
8
8
  import { fileURLToPath } from 'node:url'
9
9
  import { createServer as createHttpsServer } from 'node:https'
10
- import { readFileSync, existsSync } from 'node:fs'
10
+ import { readFileSync, existsSync, appendFileSync, mkdirSync } from 'node:fs'
11
+ import { networkInterfaces, homedir } from 'node:os'
12
+ import { join } from 'node:path'
11
13
  import { execSync } from 'node:child_process'
12
14
  import { randomBytes } from 'node:crypto'
13
15
  import { healthRouter } from './routes/health.js'
@@ -43,6 +45,18 @@ const BIND_HOST = process.env.BIND_HOST ?? '0.0.0.0'
43
45
  const API_TOKEN_AUTO = !process.env.COS_API_TOKEN
44
46
  const API_TOKEN = process.env.COS_API_TOKEN ?? `_${randomBytes(32).toString('base64url')}`
45
47
  process.env.COS_API_TOKEN = API_TOKEN // make available to routes that check it
48
+ // Persist an auto-generated token to ~/.cos-glasses/.env so it SURVIVES restarts.
49
+ // Without this, every re-run mints a new token and the app's saved token starts
50
+ // returning 401 — the symptom looks like a broken app, not a rotated credential.
51
+ let API_TOKEN_PERSISTED = false
52
+ if (API_TOKEN_AUTO) {
53
+ try {
54
+ const envDir = join(homedir(), '.cos-glasses')
55
+ mkdirSync(envDir, { recursive: true })
56
+ appendFileSync(join(envDir, '.env'), `\n# auto-generated by the server so the app token survives restarts\nCOS_API_TOKEN=${API_TOKEN}\n`)
57
+ API_TOKEN_PERSISTED = true
58
+ } catch { /* read-only home — token stays per-session */ }
59
+ }
46
60
 
47
61
  // Server metrics — shared with /api/health for monitoring
48
62
  export const serverMetrics = {
@@ -178,11 +192,31 @@ app.listen(PORT, BIND_HOST, () => {
178
192
  console.log(`[COS API] HTTP server running on http://${BIND_HOST}:${PORT}`)
179
193
  console.log(`[COS API] Mode: ${COS_MODE ? 'COS pipeline' : 'standalone'}`)
180
194
 
195
+ // Print ADDRESSES THE PHONE CAN ACTUALLY REACH. The bind address (0.0.0.0) is
196
+ // not paste-able — enumerate real interfaces and label the Tailscale one.
197
+ try {
198
+ const nets = networkInterfaces()
199
+ const addrs: Array<{ ip: string; label: string }> = []
200
+ for (const [name, infos] of Object.entries(nets)) {
201
+ for (const info of infos ?? []) {
202
+ if (info.family !== 'IPv4' || info.internal) continue
203
+ const isTailscale = info.address.startsWith('100.') || name.startsWith('utun') || name.startsWith('tailscale')
204
+ addrs.push({ ip: info.address, label: isTailscale ? 'Tailscale — works from anywhere' : `${name} — same Wi-Fi only` })
205
+ }
206
+ }
207
+ if (addrs.length > 0) {
208
+ addrs.sort((a, b) => Number(b.label.startsWith('Tailscale')) - Number(a.label.startsWith('Tailscale')))
209
+ console.log('')
210
+ console.log('[COS API] Server URL for the COS Glasses app:')
211
+ for (const a of addrs) console.log(`[COS API] http://${a.ip}:${PORT} (${a.label})`)
212
+ }
213
+ } catch { /* interface enumeration is best-effort */ }
214
+
181
215
  // Print the full API token when auto-generated — the user pastes it into the app.
182
216
  if (API_TOKEN_AUTO) {
183
217
  console.log('')
184
218
  console.log(`[COS API] API Token: ${API_TOKEN}`)
185
- console.log('[COS API] ^ paste this into the COS Glasses app (set COS_API_TOKEN in .env for a fixed token)')
219
+ 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)'))
186
220
  console.log('')
187
221
  }
188
222
 
@@ -12,11 +12,12 @@ import { join } from 'node:path'
12
12
  import { appendFileSync } from 'node:fs'
13
13
  import crypto from 'node:crypto'
14
14
  import { COS_SCRIPTS_DIR } from './python-bridge.js'
15
+ import { cosBrainDir } from './launch-dir.js'
15
16
  import { logTokenAudit } from './token-audit.js'
16
17
  import { buildSystemPrompt, buildLightweightSystemPrompt, buildPrewarmSystemPrompt, getCachedContextInstant } from './context-builder.js'
17
18
  import { getHistory, addExchange, formatHistoryForPrompt, getOrCreateSession, isNewSession, markSessionNotified, getSessionModel, getSessionRaw, replaceLastExchangeWithSummary, type ModelPreference, type PromptReference } from './conversation.js'
18
19
  import { notifySessionStart, notifyExchange } from './telegram-notify.js'
19
- import { isClaudeModel, type ClaudeModelPreference } from '../../shared/model-preference.js'
20
+ import { isClaudeModel, DEFAULT_MODEL, type ClaudeModelPreference } from '../../shared/model-preference.js'
20
21
  import {
21
22
  finishClaudeRun,
22
23
  getClaudeEffortLevel,
@@ -182,7 +183,7 @@ export async function preWarmCLI(): Promise<void> {
182
183
 
183
184
  const proc = spawn('claude', [
184
185
  '-p',
185
- '--model', 'opus', // Must match default query model — --resume inherits session model
186
+ '--model', DEFAULT_MODEL, // Must match default query model — --resume inherits session model
186
187
  '--effort', getClaudeEffortLevel(),
187
188
  '--output-format', 'stream-json',
188
189
  '--verbose',
@@ -191,7 +192,7 @@ export async function preWarmCLI(): Promise<void> {
191
192
  ], {
192
193
  stdio: ['pipe', 'pipe', 'pipe'],
193
194
  env,
194
- cwd: COS_SCRIPTS_DIR ?? process.cwd(),
195
+ cwd: COS_SCRIPTS_DIR ?? cosBrainDir() ?? process.cwd(),
195
196
  })
196
197
 
197
198
  let buffer = ''
@@ -419,7 +420,7 @@ export async function callClaudeStreaming(
419
420
  // Strip CLAUDECODE env var so claude -p doesn't think it's nested
420
421
  const env = { ...process.env }
421
422
  delete env.CLAUDECODE
422
- const cliCwd = COS_SCRIPTS_DIR ?? process.cwd()
423
+ const cliCwd = COS_SCRIPTS_DIR ?? cosBrainDir() ?? process.cwd()
423
424
  const inactivityMs = INACTIVITY_BY_MODEL[resolvedModel]
424
425
  const defaultWallMax = WALL_MAX_BY_MODEL[resolvedModel]
425
426
  const wallMax = isExtendedQuery(query) ? WALL_MAX_EXTENDED_MS : defaultWallMax
@@ -2,6 +2,7 @@ import crypto from 'node:crypto'
2
2
  import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs'
3
3
  import { dirname, resolve } from 'node:path'
4
4
  import { COS_SCRIPTS_DIR } from './python-bridge.js'
5
+ import { cosBrainDir } from './launch-dir.js'
5
6
  import { CODEX_ENGINE_SESSION_TTL_MS, type CodexTrustMode } from './codex-engine-sessions.js'
6
7
  import {
7
8
  CODEX_HIGH_REASONING_EFFORT,
@@ -80,6 +81,10 @@ export function getCodexExecutionCwd(): string {
80
81
  const configured = process.env.CODEX_GLASSES_WORKDIR?.trim()
81
82
  if (configured) return resolve(configured)
82
83
  if (COS_SCRIPTS_DIR) return resolve(COS_SCRIPTS_DIR, '..', '..')
84
+ // A Starter-Kit COS in the directory the user launched npx from — Codex
85
+ // loads its AGENTS.md brain natively when run there.
86
+ const brain = cosBrainDir()
87
+ if (brain) return brain
83
88
  // Last resort when neither CODEX_GLASSES_WORKDIR nor COS_SCRIPTS_DIR is set:
84
89
  // the server's own working dir (codex glasses is an optional, env-configured feature).
85
90
  return process.cwd()
@@ -0,0 +1,29 @@
1
+ // COS brain auto-detection for the "glasses inherit your COS" promise.
2
+ //
3
+ // The npx launcher (bin/cli.cjs) records the directory the user ran `npx
4
+ // @gotcos/glasses-server` from as COS_LAUNCH_DIR before re-spawning the server
5
+ // with cwd = the package root. If that launch directory contains a COS brain
6
+ // (a Starter-Kit scaffold: .cos/manifest.json, AGENTS.md, or CLAUDE.md), chat
7
+ // spawns of claude/codex use IT as their working directory — so the CLIs load
8
+ // the user's brain exactly as they would in a terminal session in that folder.
9
+ //
10
+ // Precedence stays: COS_SCRIPTS_DIR (full COS pipeline) > detected brain dir >
11
+ // process.cwd(). Explicit config always wins.
12
+ import { existsSync } from 'node:fs'
13
+ import { join, resolve } from 'node:path'
14
+
15
+ let cached: string | null | undefined
16
+
17
+ /** The user's launch directory IF it contains a COS brain; otherwise null. */
18
+ export function cosBrainDir(): string | null {
19
+ if (cached !== undefined) return cached
20
+ const raw = process.env.COS_LAUNCH_DIR?.trim()
21
+ if (!raw) { cached = null; return cached }
22
+ const dir = resolve(raw)
23
+ const hasBrain =
24
+ existsSync(join(dir, '.cos', 'manifest.json')) ||
25
+ existsSync(join(dir, 'AGENTS.md')) ||
26
+ existsSync(join(dir, 'CLAUDE.md'))
27
+ cached = hasBrain ? dir : null
28
+ return cached
29
+ }
@@ -6,7 +6,7 @@
6
6
  import { Router } from 'express'
7
7
  import { preWarmCLI, logLatency } from '../lib/claude-bridge.js'
8
8
  import { callModelStreaming } from '../lib/model-router.js'
9
- import { normalizeModelPreference, type ModelPreference } from '../../shared/model-preference.js'
9
+ import { normalizeModelPreference, DEFAULT_MODEL, type ModelPreference } from '../../shared/model-preference.js'
10
10
  import { tryInstantResponse } from '../lib/response-cache.js'
11
11
  import crypto from 'node:crypto'
12
12
 
@@ -79,7 +79,7 @@ function resolveModel(model?: string, _query?: string): ModelPreference {
79
79
  if (model === 'cos-sonnet') return 'sonnet'
80
80
  if (model === 'cos-haiku') return 'haiku'
81
81
  if (model === 'cos-codex-high' || model === 'cos-codex') return 'codex-high'
82
- return normalizeModelPreference(process.env.COS_G2_DEFAULT_MODEL) ?? 'haiku'
82
+ return normalizeModelPreference(process.env.COS_G2_DEFAULT_MODEL) ?? DEFAULT_MODEL
83
83
  }
84
84
 
85
85
  const MODEL_NAMES: Record<ModelPreference, string> = {
@@ -1170,11 +1170,16 @@ transcribeStreamRouter.post('/transcribe-stream/offline-sessions/:sessionId/fina
1170
1170
  const chunks = getSessionChunks(sessionId)
1171
1171
  if (!chunks || chunks.length === 0) throw makeHttpError(404, 'offline session has no chunks', 'session_not_found')
1172
1172
  await drainSessionAudioWrites(sessionId)
1173
- const transcript = getSessionTranscript(sessionId) ?? ''
1173
+ // Gap-aware assembly: chunks lost in transit surface as inline
1174
+ // "[… audio gap …]" markers instead of being silently stitched over.
1175
+ const transcript = getSessionTranscript(sessionId, { withGaps: true }) ?? ''
1176
+ const transferIntegrity = analyzeTranscriptGaps(sessionId)
1174
1177
  res.json({
1175
1178
  sessionId,
1176
1179
  chunks: chunks.length,
1177
1180
  transcriptChars: transcript.length,
1181
+ transcript,
1182
+ transferIntegrity,
1178
1183
  readyToSave: true,
1179
1184
  })
1180
1185
  } catch (err: unknown) {
@@ -2,7 +2,7 @@ export type ClaudeModelPreference = 'opus' | 'sonnet' | 'haiku'
2
2
  export type CodexModelPreference = 'codex-high'
3
3
  export type ModelPreference = ClaudeModelPreference | CodexModelPreference
4
4
 
5
- export const DEFAULT_MODEL = 'opus' as const
5
+ export const DEFAULT_MODEL = 'sonnet' as const
6
6
  export const CODEX_HIGH_MODEL: CodexModelPreference = 'codex-high'
7
7
 
8
8
  // Optional codex model passed to `codex exec --model`. Empty (the default) means