@gotcos/glasses-server 6.63.0 → 6.63.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,3 +1,8 @@
1
+ ## 6.63.1 — 2026-10-06 — Weather survives a restart
2
+
3
+ - Home weather survives a server restart. The last good weather and place are kept in `<data>/welcome-weather.json`; when a forecast lookup fails (timeout, HTTP error, bad payload), the route answers with that saved weather if it is under 3 hours old, never older. A failed forecast or place lookup is now logged once per kind per 10 minutes, with the cause and whether a saved weather was served. Before, after a restart the first failed lookup sent the glasses no weather and nothing said why. Pairs with glasses 6.10.603, which also retries on Home and keeps its own last good weather.
4
+ - Security: `proxy-addr` 2.0.8 (through Express), for GHSA-jqcg-44mw-7w3h (IP spoofing through an IPv4-mapped IPv6 trust subnet). Lockfile only.
5
+
1
6
  ## 6.63.0 — 2026-10-05 — Background forks
2
7
 
3
8
  - Background forks. `POST /api/agent-sessions/:provider/:threadId/fork` accepts an optional `clientForkId`: once every gate passes it answers 202 and runs the fork in the background, and `GET /api/agent-session-forks/:clientForkId` returns `running` or the exact answer the synchronous route would have sent. A repeated id replays its job and never makes a second copy. Outcomes are kept in `<data>/fork-jobs.json` (7 days, 200 rows); a fork still running when the server stops reads back as orphan-possible ("look in Sessions before forking again"), never as failed. Without the id the route is unchanged. Why: on 2026-10-05 COS Control waited 5 minutes for a 6.5-minute fork that succeeded and told Miles the server had stopped. Pairs with COS Control 0.5.257; older clients keep the synchronous route.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.63.0",
3
+ "version": "6.63.1",
4
4
  "description": "COS Glasses: self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, Cursor Agent CLI, or local Ollama",
5
5
  "type": "module",
6
6
  "bin": {
package/server/index.ts CHANGED
@@ -106,7 +106,7 @@ import { recoveryRouter } from './routes/recovery.js'
106
106
  import { promptEditRouter } from './routes/prompt-edit.js'
107
107
  import { lensGistRouter } from './routes/lens-gist.js'
108
108
  import { bookmarksRouter } from './routes/bookmarks.js'
109
- import { welcomeContextRouter } from './routes/welcome-context.js'
109
+ import { setWeatherPersistence, welcomeContextRouter } from './routes/welcome-context.js'
110
110
  import { liveCuesRouter } from './routes/live-cues.js'
111
111
  import { tasksRouter } from './routes/tasks.js'
112
112
  import { memoryRouter } from './routes/memory.js'
@@ -143,6 +143,7 @@ import { createWorkIntakeRouter } from './routes/work-intake.js'
143
143
  import { createWorkHandoffRequestsRouter } from './routes/work-handoff-requests.js'
144
144
  import { createJevRouter } from './routes/jev.js'
145
145
  import { dataPath } from './lib/data-dir.js'
146
+ import { loadJsonOrQuarantine, durableAtomicWriteFileSync } from './lib/atomic-fs.js'
146
147
  import { WorkIntakeStore, createOptionalWorkIntakeStore } from './lib/work-intake-store.js'
147
148
  import { WorkHandoffRequestStore, createOptionalWorkHandoffRequestStore, handoffRequestCapabilities } from './lib/work-handoff-requests.js'
148
149
  import { createWorkBoardReader } from './lib/work-board-reader.js'
@@ -1065,6 +1066,11 @@ app.use('/api', recoveryRouter)
1065
1066
  app.use('/api', promptEditRouter)
1066
1067
  app.use('/api', lensGistRouter)
1067
1068
  app.use('/api', bookmarksRouter)
1069
+ // 6.63.1: the last good weather and place survive a restart (<data>/welcome-weather.json).
1070
+ setWeatherPersistence({
1071
+ load: () => { const r = loadJsonOrQuarantine<unknown>(dataPath('welcome-weather.json')); return r.status === 'ok' ? r.data : null },
1072
+ save: (value) => durableAtomicWriteFileSync(dataPath('welcome-weather.json'), `${JSON.stringify(value)}\n`),
1073
+ })
1068
1074
  app.use('/api', welcomeContextRouter)
1069
1075
  app.use('/api', liveCuesRouter)
1070
1076
  app.use('/api', tasksRouter)
@@ -25,6 +25,72 @@ let lastKnownCity = ''
25
25
  let cachedNextEvent: NextEventPayload | null = null
26
26
  let calendarFetchedAt = 0
27
27
 
28
+ // 6.63.1: after a server restart the first forecast lookup can fail and, with nothing cached, the
29
+ // glasses Home got no weather until the app was relaunched. The last good weather and
30
+ // place now survive a restart, and a failed lookup says why in the log (it was silent).
31
+ /** A saved weather older than this is never served after a restart. */
32
+ export const WEATHER_PERSIST_MAX_AGE_MS = 3 * 60 * 60_000
33
+ const WEATHER_FAILURE_LOG_MS = 10 * 60_000
34
+
35
+ export interface WeatherPersistence {
36
+ load(): unknown
37
+ save(value: unknown): void
38
+ }
39
+ let weatherPersistence: WeatherPersistence | null = null
40
+ let weatherRestored = false
41
+ const lastFailureLogAt = new Map<string, number>()
42
+
43
+ /** Wired by the server (index.ts, `<data>/welcome-weather.json`); off by default so tests touch nothing. */
44
+ export function setWeatherPersistence(p: WeatherPersistence | null): void {
45
+ weatherPersistence = p
46
+ weatherRestored = false
47
+ }
48
+
49
+ function restoreWeatherOnce(): void {
50
+ if (weatherRestored || !weatherPersistence) return
51
+ weatherRestored = true
52
+ try {
53
+ const v = weatherPersistence.load() as Record<string, unknown> | null
54
+ if (!v || typeof v !== 'object') return
55
+ const at = typeof v.fetchedAt === 'number' ? v.fetchedAt : 0
56
+ const w = v.weather as WeatherPayload | undefined
57
+ if (typeof v.lat === 'number' && typeof v.lon === 'number' && lastKnownLat == null) { lastKnownLat = v.lat; lastKnownLon = v.lon }
58
+ if (typeof v.city === 'string' && !lastKnownCity) lastKnownCity = v.city
59
+ const fresh = at > 0 && at <= Date.now() + 60_000 && Date.now() - at <= WEATHER_PERSIST_MAX_AGE_MS
60
+ if (fresh && w && typeof w.temp === 'string' && typeof w.desc === 'string' && !cachedWeather) {
61
+ cachedWeather = { temp: w.temp, desc: w.desc, location: typeof w.location === 'string' ? w.location : '' }
62
+ // Expired for the TTL check, so the next request still tries a fresh lookup; kept as the fallback.
63
+ weatherFetchedAt = 0
64
+ }
65
+ } catch (error) {
66
+ console.warn(`[welcome-context] saved weather unreadable: ${error instanceof Error ? error.message : error}`)
67
+ }
68
+ }
69
+
70
+ function persistWeather(): void {
71
+ if (!weatherPersistence) return
72
+ try {
73
+ weatherPersistence.save({ lat: lastKnownLat ?? null, lon: lastKnownLon ?? null, city: lastKnownCity, weather: cachedWeather, fetchedAt: weatherFetchedAt })
74
+ } catch (error) {
75
+ console.warn(`[welcome-context] saving weather failed: ${error instanceof Error ? error.message : error}`)
76
+ }
77
+ }
78
+
79
+ /** One line per failure kind per 10 minutes: the cause, never the coordinates. */
80
+ function noteWeatherFailure(kind: 'forecast' | 'geocode', reason: string): void {
81
+ const key = `${kind}:${reason}`
82
+ const now = Date.now()
83
+ if (now - (lastFailureLogAt.get(key) ?? 0) < WEATHER_FAILURE_LOG_MS) return
84
+ lastFailureLogAt.set(key, now)
85
+ console.warn(`[welcome-context] ${kind} lookup failed: ${reason}${cachedWeather ? ' (serving the last good weather)' : ' (no weather to serve)'}`)
86
+ }
87
+
88
+ function failureReason(error: unknown): string {
89
+ const name = error instanceof Error ? error.name : ''
90
+ if (name === 'TimeoutError' || name === 'AbortError') return 'timeout'
91
+ return error instanceof Error ? (error.message.slice(0, 80) || name || 'error') : 'error'
92
+ }
93
+
28
94
  /** Full WMO 4677 — discrete codes, exact match required. */
29
95
  export const WMO_CODES: Record<number, string> = {
30
96
  0: 'Clear',
@@ -108,7 +174,7 @@ async function reverseGeocode(lat: number, lon: number): Promise<string> {
108
174
  `https://api.bigdatacloud.net/data/reverse-geocode-client?latitude=${lat}&longitude=${lon}&localityLanguage=en`,
109
175
  { signal: AbortSignal.timeout(GEOCODE_TIMEOUT_MS) },
110
176
  )
111
- if (!res.ok) return lastKnownCity
177
+ if (!res.ok) { noteWeatherFailure('geocode', `http ${res.status}`); return lastKnownCity }
112
178
  const data = await res.json() as {
113
179
  city?: string
114
180
  locality?: string
@@ -122,7 +188,8 @@ async function reverseGeocode(lat: number, lon: number): Promise<string> {
122
188
  lastKnownCity = region ? `${city}, ${region}` : city
123
189
  }
124
190
  return lastKnownCity
125
- } catch {
191
+ } catch (error) {
192
+ noteWeatherFailure('geocode', failureReason(error))
126
193
  return lastKnownCity
127
194
  }
128
195
  }
@@ -144,6 +211,7 @@ export async function fetchWeather(
144
211
  queryLat?: number,
145
212
  queryLon?: number,
146
213
  ): Promise<WeatherPayload | null> {
214
+ restoreWeatherOnce()
147
215
  const resolved = resolveWeatherCoords(queryLat, queryLon)
148
216
  if (!resolved) return null
149
217
 
@@ -183,14 +251,14 @@ export async function fetchWeather(
183
251
  `https://api.open-meteo.com/v1/forecast?latitude=${useLat}&longitude=${useLon}&current=temperature_2m,weather_code&temperature_unit=fahrenheit&timezone=auto`,
184
252
  { signal: AbortSignal.timeout(FORECAST_TIMEOUT_MS) },
185
253
  )
186
- if (!res.ok) return cachedWeather
254
+ if (!res.ok) { noteWeatherFailure('forecast', `http ${res.status}`); return cachedWeather }
187
255
 
188
256
  const data = await res.json() as {
189
257
  current?: { temperature_2m?: number; weather_code?: number }
190
258
  }
191
259
  const temp = data.current?.temperature_2m
192
260
  const code = data.current?.weather_code
193
- if (typeof temp !== 'number' || typeof code !== 'number') return cachedWeather
261
+ if (typeof temp !== 'number' || typeof code !== 'number') { noteWeatherFailure('forecast', 'bad payload'); return cachedWeather }
194
262
 
195
263
  await ensureCityLabel(useLat, useLon, source)
196
264
 
@@ -200,8 +268,10 @@ export async function fetchWeather(
200
268
  location: lastKnownCity || `${useLat.toFixed(2)}, ${useLon.toFixed(2)}`,
201
269
  }
202
270
  weatherFetchedAt = Date.now()
271
+ persistWeather()
203
272
  return cachedWeather
204
- } catch {
273
+ } catch (error) {
274
+ noteWeatherFailure('forecast', failureReason(error))
205
275
  return cachedWeather
206
276
  }
207
277
  }
@@ -272,6 +342,9 @@ export function _resetWelcomeContextCachesForTests(): void {
272
342
  lastKnownCity = ''
273
343
  cachedNextEvent = null
274
344
  calendarFetchedAt = 0
345
+ weatherPersistence = null
346
+ weatherRestored = false
347
+ lastFailureLogAt.clear()
275
348
  }
276
349
 
277
350
  welcomeContextRouter.get('/welcome-context', async (req, res) => {