@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 +7 -0
- package/CHANGELOG.md +10 -0
- package/package.json +1 -1
- package/server/index.ts +2 -0
- package/server/routes/welcome-context.ts +284 -0
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
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}¤t=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
|
+
})
|