@gotcos/glasses-server 6.16.4 → 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,13 @@
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
+
1
11
  ## 6.16.4
2
12
 
3
13
  - **Cursor Agent on legacy `/api/query`.** 6.16.3 only forwarded
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.16.4",
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
@@ -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
+ })