@gotcos/glasses-server 6.16.3 → 6.16.5

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
@@ -91,6 +91,13 @@ BIND_HOST=0.0.0.0
91
91
  # COS_TTS_LOCAL_DISABLE=1 # disable the local sidecar explicitly
92
92
  # COS_TTS_PRONUNCIATIONS_JSON={"Exampleco":{"local":"[Exampleco](/ɪgzˈæmpəlkoʊ/)","openai":"ig-ZAM-pul-co"}}
93
93
 
94
+ # ── WELCOME WEATHER (optional) ──────────────────────────────────────────
95
+ # Phone GPS (Even Hub location permission) is the primary source. The Mac only
96
+ # proxies Open-Meteo. If the phone denies location, set a home fallback:
97
+ # COS_WEATHER_DEFAULT_LAT=30.5788
98
+ # COS_WEATHER_DEFAULT_LON=-97.8531
99
+ # COS_WEATHER_DEFAULT_CITY=Home
100
+
94
101
  # ── FULL COS PIPELINE (optional — leave unset for standalone) ────────────
95
102
  # Power users running the COS Starter Kit can point the glasses at their
96
103
  # pipeline to inherit live tasks/calendar/people context. Omit for standalone.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,22 @@
1
+ ## 6.16.5
2
+
3
+ - **Welcome weather restored on managed installs.** Ported authenticated
4
+ `GET /api/welcome-context` (Open-Meteo + reverse-geocode). Phone sends
5
+ `lat`/`lon` from Even Hub geolocation; Mac never invents a home city.
6
+ Without coords: last process coords → optional `COS_WEATHER_DEFAULT_*` →
7
+ omit weather. New coords await reverse-geocode (3s) before first JSON so
8
+ the city label is not stale. Calendar `nextEvent` stays optional via the
9
+ COS python bridge with public-safe OOO filtering only.
10
+
11
+ ## 6.16.4
12
+
13
+ - **Cursor Agent on legacy `/api/query`.** 6.16.3 only forwarded
14
+ `cursorExecutionMode` on durable jobs. Most installs still run with
15
+ `durableQueryJobs: false`, so the companion fell back to `/api/query` and
16
+ Cursor stayed Ask (Shell Rejected). Legacy route now accepts the field;
17
+ Cursor omit/unknown defaults to **agent** (Settings default). Explicit `ask`
18
+ still forces read-only. Durable runtime uses the same default.
19
+
1
20
  ## 6.16.3
2
21
 
3
22
  - **Cursor Agent mode on durable jobs.** Glasses Settings → Agent now reaches
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.16.3",
3
+ "version": "6.16.5",
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
@@ -37,6 +37,7 @@ import { handoffsRouter } from './routes/handoffs.js'
37
37
  import { recoveryRouter } from './routes/recovery.js'
38
38
  import { promptEditRouter } from './routes/prompt-edit.js'
39
39
  import { bookmarksRouter } from './routes/bookmarks.js'
40
+ import { welcomeContextRouter } from './routes/welcome-context.js'
40
41
  import { prewarmContext } from './lib/context-builder.js'
41
42
  import { preWarmCLI } from './lib/claude-bridge.js'
42
43
  import { getCodexRunConfig } from './lib/codex-run-ledger.js'
@@ -245,6 +246,7 @@ app.use('/api', handoffsRouter)
245
246
  app.use('/api', recoveryRouter)
246
247
  app.use('/api', promptEditRouter)
247
248
  app.use('/api', bookmarksRouter)
249
+ app.use('/api', welcomeContextRouter)
248
250
 
249
251
  // OpenAI-compatible endpoint for the G2 Agent (ER "Add Agent")
250
252
  // Mounted at root — routes are /v1/chat/completions and /v1/models
@@ -13,6 +13,7 @@ import {
13
13
  isCursorModel,
14
14
  normalizeEffortPreference,
15
15
  normalizeModelPreference,
16
+ type CursorExecutionMode,
16
17
  type ModelPreference,
17
18
  } from '../../shared/model-preference.js'
18
19
  import { mergeMediaAttachmentRefs } from '../../shared/media-attachment.js'
@@ -238,8 +239,15 @@ const runner: QueryJobRunner = async ({ jobId, turnId, request, signal, callback
238
239
  {
239
240
  abortSignal: signal,
240
241
  effort: validEffort,
241
- // Companion sends agent|ask; omit/unknown ask (safe default for old clients).
242
- cursorExecutionMode: request.cursorExecutionMode === 'agent' ? 'agent' : 'ask',
242
+ // Glasses Settings default is Agent. Explicit ask stays ask; omit agent
243
+ // for Cursor so legacy clients aren't stuck in Ask when durable is off.
244
+ ...(validModel && isCursorModel(validModel)
245
+ ? {
246
+ cursorExecutionMode: (
247
+ request.cursorExecutionMode === 'ask' ? 'ask' : 'agent'
248
+ ) as CursorExecutionMode,
249
+ }
250
+ : {}),
243
251
  clientJobId: request.clientJobId,
244
252
  generation: request.generation,
245
253
  sessionLockHeld: true,
@@ -5,7 +5,11 @@ import { Router } from 'express'
5
5
  import { callModelStreaming } from '../lib/model-router.js'
6
6
  import { emitDisplay } from '../lib/display-bus.js'
7
7
  import { errMsg } from '../lib/utils.js'
8
- import { normalizeEffortPreference, normalizeModelPreference } from '../../shared/model-preference.js'
8
+ import {
9
+ isCursorModel,
10
+ normalizeEffortPreference,
11
+ normalizeModelPreference,
12
+ } from '../../shared/model-preference.js'
9
13
  import { QueryAttachmentError, resolveQueryAttachments } from '../lib/query-attachments.js'
10
14
  import { getMediaStore } from '../lib/media-store.js'
11
15
  import { mergeMediaAttachmentRefs } from '../../shared/media-attachment.js'
@@ -38,6 +42,9 @@ queryRouter.post('/query', async (req, res) => {
38
42
  const activityToolMode = req.body.activityToolMode === 'off' || req.body.activityToolMode === 'preview'
39
43
  ? req.body.activityToolMode
40
44
  : 'status'
45
+ // Glasses Settings default is Agent. Only an explicit "ask" stays read-only —
46
+ // omit/unknown must not silently force Ask (6.16.3 durable-only gap).
47
+ const cursorExecutionMode = req.body.cursorExecutionMode === 'ask' ? 'ask' as const : 'agent' as const
41
48
 
42
49
  // Resolve durable attachment ids and legacy base64 images through one
43
50
  // validation/normalization path before opening SSE.
@@ -170,7 +177,11 @@ queryRouter.post('/query', async (req, res) => {
170
177
  ? { query: String(reference.query), response: String(reference.response) }
171
178
  : undefined,
172
179
  validGlobalMsgNum,
173
- { abortSignal: abortController.signal, effort: validEffort },
180
+ {
181
+ abortSignal: abortController.signal,
182
+ effort: validEffort,
183
+ ...(validModel && isCursorModel(validModel) ? { cursorExecutionMode } : {}),
184
+ },
174
185
  )
175
186
  } catch (err: unknown) {
176
187
  maintenanceLease.release()
@@ -0,0 +1,284 @@
1
+ // GET /api/welcome-context — weather + optional next event for glasses home.
2
+ // Weather uses phone-supplied lat/lon (Even Hub WebView geolocation). The Mac
3
+ // only proxies Open-Meteo / reverse-geocode — it is not "server location".
4
+
5
+ import { Router } from 'express'
6
+ import { callPython } from '../lib/python-bridge.js'
7
+
8
+ export const welcomeContextRouter = Router()
9
+
10
+ const WEATHER_TTL_MS = 30 * 60_000
11
+ const GEOCODE_TIMEOUT_MS = 3_000
12
+ const FORECAST_TIMEOUT_MS = 3_000
13
+ const CALENDAR_TTL_MS = 2 * 60_000
14
+ const COORD_MOVE_DEG = 0.05 // ~5km
15
+
16
+ type WeatherPayload = { temp: string; desc: string; location: string }
17
+ type NextEventPayload = { title: string; time: string; inMinutes?: number }
18
+
19
+ let cachedWeather: WeatherPayload | null = null
20
+ let weatherFetchedAt = 0
21
+ let lastKnownLat: number | undefined
22
+ let lastKnownLon: number | undefined
23
+ let lastKnownCity = ''
24
+
25
+ let cachedNextEvent: NextEventPayload | null = null
26
+ let calendarFetchedAt = 0
27
+
28
+ /** Full WMO 4677 — discrete codes, exact match required. */
29
+ export const WMO_CODES: Record<number, string> = {
30
+ 0: 'Clear',
31
+ 1: 'Mostly Clear',
32
+ 2: 'Partly Cloudy',
33
+ 3: 'Overcast',
34
+ 45: 'Foggy',
35
+ 48: 'Rime Fog',
36
+ 51: 'Light Drizzle',
37
+ 53: 'Drizzle',
38
+ 55: 'Heavy Drizzle',
39
+ 56: 'Freezing Drizzle',
40
+ 57: 'Icy Drizzle',
41
+ 61: 'Light Rain',
42
+ 63: 'Rain',
43
+ 65: 'Heavy Rain',
44
+ 66: 'Freezing Rain',
45
+ 67: 'Icy Rain',
46
+ 71: 'Light Snow',
47
+ 73: 'Snow',
48
+ 75: 'Heavy Snow',
49
+ 77: 'Snow Grains',
50
+ 80: 'Light Showers',
51
+ 81: 'Showers',
52
+ 82: 'Violent Showers',
53
+ 85: 'Snow Showers',
54
+ 86: 'Heavy Snow Showers',
55
+ 95: 'Thunderstorm',
56
+ 96: 'T-Storm + Hail',
57
+ 99: 'Severe T-Storm',
58
+ }
59
+
60
+ export function wmoDescription(code: number): string {
61
+ if (code in WMO_CODES) return WMO_CODES[code]
62
+ if (code >= 95) return 'Thunderstorm'
63
+ if (code >= 80) return 'Showers'
64
+ if (code >= 71) return 'Snow'
65
+ if (code >= 61) return 'Rain'
66
+ if (code >= 51) return 'Drizzle'
67
+ if (code >= 45) return 'Foggy'
68
+ if (code >= 1) return 'Cloudy'
69
+ return 'Clear'
70
+ }
71
+
72
+ export function parseCoord(raw: unknown, kind: 'lat' | 'lon'): number | undefined {
73
+ if (raw == null || raw === '') return undefined
74
+ const n = typeof raw === 'number' ? raw : parseFloat(String(raw))
75
+ if (!Number.isFinite(n)) return undefined
76
+ if (kind === 'lat' && (n < -90 || n > 90)) return undefined
77
+ if (kind === 'lon' && (n < -180 || n > 180)) return undefined
78
+ return n
79
+ }
80
+
81
+ function envDefaultCoords(): { lat: number; lon: number; city: string } | null {
82
+ const lat = parseCoord(process.env.COS_WEATHER_DEFAULT_LAT, 'lat')
83
+ const lon = parseCoord(process.env.COS_WEATHER_DEFAULT_LON, 'lon')
84
+ if (lat == null || lon == null) return null
85
+ const city = (process.env.COS_WEATHER_DEFAULT_CITY || '').trim()
86
+ return { lat, lon, city }
87
+ }
88
+
89
+ /** Resolve coords: phone query → last successful this process → optional env home. */
90
+ export function resolveWeatherCoords(
91
+ queryLat?: number,
92
+ queryLon?: number,
93
+ ): { lat: number; lon: number; source: 'query' | 'last' | 'env' } | null {
94
+ if (queryLat != null && queryLon != null) {
95
+ return { lat: queryLat, lon: queryLon, source: 'query' }
96
+ }
97
+ if (lastKnownLat != null && lastKnownLon != null) {
98
+ return { lat: lastKnownLat, lon: lastKnownLon, source: 'last' }
99
+ }
100
+ const env = envDefaultCoords()
101
+ if (env) return { lat: env.lat, lon: env.lon, source: 'env' }
102
+ return null
103
+ }
104
+
105
+ async function reverseGeocode(lat: number, lon: number): Promise<string> {
106
+ try {
107
+ const res = await fetch(
108
+ `https://api.bigdatacloud.net/data/reverse-geocode-client?latitude=${lat}&longitude=${lon}&localityLanguage=en`,
109
+ { signal: AbortSignal.timeout(GEOCODE_TIMEOUT_MS) },
110
+ )
111
+ if (!res.ok) return lastKnownCity
112
+ const data = await res.json() as {
113
+ city?: string
114
+ locality?: string
115
+ principalSubdivision?: string
116
+ principalSubdivisionCode?: string
117
+ countryCode?: string
118
+ }
119
+ const city = data.city || data.locality || data.principalSubdivision || ''
120
+ const region = data.principalSubdivisionCode?.split('-')[1] || data.countryCode || ''
121
+ if (city) {
122
+ lastKnownCity = region ? `${city}, ${region}` : city
123
+ }
124
+ return lastKnownCity
125
+ } catch {
126
+ return lastKnownCity
127
+ }
128
+ }
129
+
130
+ export async function fetchWeather(
131
+ queryLat?: number,
132
+ queryLon?: number,
133
+ ): Promise<WeatherPayload | null> {
134
+ const resolved = resolveWeatherCoords(queryLat, queryLon)
135
+ if (!resolved) return null
136
+
137
+ const { lat: useLat, lon: useLon, source } = resolved
138
+ const coordsChanged = source === 'query'
139
+ && (
140
+ lastKnownLat == null
141
+ || lastKnownLon == null
142
+ || Math.abs(useLat - lastKnownLat) > COORD_MOVE_DEG
143
+ || Math.abs(useLon - lastKnownLon) > COORD_MOVE_DEG
144
+ )
145
+
146
+ if (coordsChanged) {
147
+ lastKnownLat = useLat
148
+ lastKnownLon = useLon
149
+ weatherFetchedAt = 0
150
+ // Blocker fix: wait for city before first paint (capped).
151
+ await reverseGeocode(useLat, useLon)
152
+ } else if (source === 'env' && !lastKnownCity) {
153
+ const env = envDefaultCoords()
154
+ if (env?.city) lastKnownCity = env.city
155
+ lastKnownLat = useLat
156
+ lastKnownLon = useLon
157
+ } else if (lastKnownLat == null) {
158
+ lastKnownLat = useLat
159
+ lastKnownLon = useLon
160
+ }
161
+
162
+ if (cachedWeather && Date.now() - weatherFetchedAt < WEATHER_TTL_MS) {
163
+ return cachedWeather
164
+ }
165
+
166
+ try {
167
+ const res = await fetch(
168
+ `https://api.open-meteo.com/v1/forecast?latitude=${useLat}&longitude=${useLon}&current=temperature_2m,weather_code&temperature_unit=fahrenheit&timezone=auto`,
169
+ { signal: AbortSignal.timeout(FORECAST_TIMEOUT_MS) },
170
+ )
171
+ if (!res.ok) return cachedWeather
172
+
173
+ const data = await res.json() as {
174
+ current?: { temperature_2m?: number; weather_code?: number }
175
+ }
176
+ const temp = data.current?.temperature_2m
177
+ const code = data.current?.weather_code
178
+ if (typeof temp !== 'number' || typeof code !== 'number') return cachedWeather
179
+
180
+ if (!lastKnownCity && source === 'env') {
181
+ const env = envDefaultCoords()
182
+ if (env?.city) lastKnownCity = env.city
183
+ }
184
+ if (!lastKnownCity) {
185
+ await reverseGeocode(useLat, useLon)
186
+ }
187
+
188
+ cachedWeather = {
189
+ temp: `${Math.round(temp)}\u00B0F`,
190
+ desc: wmoDescription(code),
191
+ location: lastKnownCity || `${useLat.toFixed(2)}, ${useLon.toFixed(2)}`,
192
+ }
193
+ weatherFetchedAt = Date.now()
194
+ return cachedWeather
195
+ } catch {
196
+ return cachedWeather
197
+ }
198
+ }
199
+
200
+ /** Public-safe filter only — no operator-specific title rules. */
201
+ function isGenericNoiseEvent(title: string): boolean {
202
+ const t = (title || '').toLowerCase()
203
+ return /\booo\b|out of office|vacation|\bpto\b/i.test(t)
204
+ }
205
+
206
+ async function fetchNextEvent(): Promise<NextEventPayload | null> {
207
+ if (cachedNextEvent && Date.now() - calendarFetchedAt < CALENDAR_TTL_MS) {
208
+ return cachedNextEvent
209
+ }
210
+
211
+ try {
212
+ const cal = await callPython(['calendar']) as {
213
+ today_remaining?: Array<{
214
+ title?: string
215
+ start_time?: string
216
+ start?: string
217
+ is_all_day?: boolean
218
+ }>
219
+ next_event?: { title?: string; start_time?: string }
220
+ minutes_until_next?: number
221
+ } | null
222
+
223
+ const remaining = cal?.today_remaining ?? []
224
+ const meaningful = remaining.find((evt) => {
225
+ if (evt.is_all_day) return false
226
+ return !isGenericNoiseEvent(evt.title || '')
227
+ })
228
+
229
+ if (meaningful) {
230
+ const now = Date.now()
231
+ const eventTime = new Date(meaningful.start_time || meaningful.start || '').getTime()
232
+ const minsUntil = Number.isFinite(eventTime)
233
+ ? Math.max(0, Math.round((eventTime - now) / 60_000))
234
+ : undefined
235
+ cachedNextEvent = {
236
+ title: meaningful.title || 'Meeting',
237
+ time: meaningful.start_time || meaningful.start || '',
238
+ ...(minsUntil != null ? { inMinutes: minsUntil } : {}),
239
+ }
240
+ } else if (cal?.next_event && !isGenericNoiseEvent(cal.next_event.title || '')) {
241
+ cachedNextEvent = {
242
+ title: cal.next_event.title || 'Meeting',
243
+ time: cal.next_event.start_time || '',
244
+ ...(cal.minutes_until_next != null ? { inMinutes: cal.minutes_until_next } : {}),
245
+ }
246
+ } else {
247
+ cachedNextEvent = null
248
+ }
249
+
250
+ calendarFetchedAt = Date.now()
251
+ return cachedNextEvent
252
+ } catch {
253
+ return cachedNextEvent
254
+ }
255
+ }
256
+
257
+ /** Test helper — reset module caches between cases. */
258
+ export function _resetWelcomeContextCachesForTests(): void {
259
+ cachedWeather = null
260
+ weatherFetchedAt = 0
261
+ lastKnownLat = undefined
262
+ lastKnownLon = undefined
263
+ lastKnownCity = ''
264
+ cachedNextEvent = null
265
+ calendarFetchedAt = 0
266
+ }
267
+
268
+ welcomeContextRouter.get('/welcome-context', async (req, res) => {
269
+ const lat = parseCoord(req.query.lat, 'lat')
270
+ const lon = parseCoord(req.query.lon, 'lon')
271
+ // Both required together; one alone is ignored.
272
+ const pairLat = lat != null && lon != null ? lat : undefined
273
+ const pairLon = lat != null && lon != null ? lon : undefined
274
+
275
+ const [weather, nextEvent] = await Promise.all([
276
+ fetchWeather(pairLat, pairLon),
277
+ fetchNextEvent(),
278
+ ])
279
+
280
+ res.json({
281
+ ...(weather ? { weather } : {}),
282
+ ...(nextEvent ? { nextEvent } : {}),
283
+ })
284
+ })