@gotcos/glasses-server 6.99.29 → 7.0.7

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.
Files changed (80) hide show
  1. package/.env.example +18 -0
  2. package/CHANGELOG.md +475 -0
  3. package/README.md +3 -1
  4. package/managed-runtime-contract.json +3 -0
  5. package/package.json +1 -1
  6. package/server/index.ts +29 -7
  7. package/server/lib/agent-session-output-images.ts +627 -49
  8. package/server/lib/agent-session-store.ts +24 -11
  9. package/server/lib/agent-session-tool-images.ts +224 -0
  10. package/server/lib/agent-session-turns.ts +19 -2
  11. package/server/lib/api-auth.ts +6 -0
  12. package/server/lib/ask-work-plan.ts +3 -1
  13. package/server/lib/calendar-source.ts +491 -0
  14. package/server/lib/codex-bridge.ts +4 -1
  15. package/server/lib/codex-run-ledger.ts +29 -0
  16. package/server/lib/context-builder.ts +2 -1
  17. package/server/lib/drop-p2-runtime.ts +1 -1
  18. package/server/lib/drop-store.ts +10 -4
  19. package/server/lib/learning-needs-you.ts +24 -7
  20. package/server/lib/live-events.ts +533 -0
  21. package/server/lib/local-path-redact.ts +272 -0
  22. package/server/lib/meeting-ask-orientation.ts +40 -6
  23. package/server/lib/meeting-ask-runner.ts +30 -10
  24. package/server/lib/morning-brief-runtime.ts +4 -2
  25. package/server/lib/native-mac-caller.ts +29 -0
  26. package/server/lib/ping-engine.ts +1 -1
  27. package/server/lib/ping-sources.ts +11 -6
  28. package/server/lib/preview-content.ts +172 -0
  29. package/server/lib/preview-error-code.ts +10 -0
  30. package/server/lib/preview-folders-setting.ts +133 -0
  31. package/server/lib/preview-log-throttle.ts +18 -0
  32. package/server/lib/preview-thumbs.ts +139 -0
  33. package/server/lib/secret-path-guard.ts +80 -0
  34. package/server/lib/session-files-setting.ts +88 -0
  35. package/server/lib/session-links.ts +280 -0
  36. package/server/lib/thread-turn-queue.ts +10 -0
  37. package/server/lib/today-brief.ts +119 -28
  38. package/server/lib/web-memories-bridge.ts +3 -1
  39. package/server/lib/work-activity.ts +56 -6
  40. package/server/lib/work-session-activity.ts +260 -0
  41. package/server/lib/your-move-count.ts +9 -3
  42. package/server/routes/agent-session-bindings.ts +13 -0
  43. package/server/routes/agent-session-stream.ts +16 -0
  44. package/server/routes/agent-sessions.ts +146 -11
  45. package/server/routes/ask-work-plan.ts +4 -1
  46. package/server/routes/claude-sessions.ts +5 -1
  47. package/server/routes/health.ts +35 -0
  48. package/server/routes/live-events.ts +329 -0
  49. package/server/routes/permission-broker.ts +69 -5
  50. package/server/routes/session-preview.ts +200 -0
  51. package/server/routes/thread-turn-queue.ts +207 -3
  52. package/server/routes/today-brief.ts +44 -9
  53. package/server/routes/welcome-context.ts +23 -3
  54. package/server/routes/work-board.ts +9 -2
  55. package/server/web-control/app.css +79 -9
  56. package/server/web-control/app.js +1543 -65
  57. package/server/web-control/control.css +37 -6
  58. package/server/web-control/index.html +8 -2
  59. package/server/web-control/memory-review.js +100 -0
  60. package/server/web-control/panes/cos-chat.css +51 -6
  61. package/server/web-control/panes/cos-chat.js +268 -16
  62. package/server/web-control/panes/meetings.css +23 -4
  63. package/server/web-control/panes/meetings.js +25 -9
  64. package/server/web-control/panes/memories.css +18 -3
  65. package/server/web-control/panes/memories.js +130 -50
  66. package/server/web-control/panes/messages.js +21 -2
  67. package/server/web-control/panes/preview.js +318 -0
  68. package/server/web-control/panes/questions.css +20 -4
  69. package/server/web-control/panes/questions.js +127 -26
  70. package/server/web-control/panes/reader-media.js +409 -23
  71. package/server/web-control/panes/sessions.css +50 -10
  72. package/server/web-control/panes/settings.js +96 -5
  73. package/server/web-control/panes/speakers.css +3 -5
  74. package/server/web-control/panes/speakers.js +7 -4
  75. package/server/web-control/panes/threads.js +3 -2
  76. package/server/web-control/panes/today-actions.css +49 -18
  77. package/server/web-control/panes/today-actions.js +217 -50
  78. package/server/web-control/panes/work.css +11 -1
  79. package/server/web-control/panes/work.js +10 -2
  80. package/server/web-control/shell.js +20 -14
@@ -0,0 +1,491 @@
1
+ // Calendar source (PH plan P1, 2026-11-07).
2
+ //
3
+ // COS Control reads macOS Calendar through EventKit (Exchange, iCloud, every
4
+ // Google account, subscribed ICS) and writes a local snapshot to
5
+ // dataPath('calendar', 'snapshot.json'). This module is the ONE reader of that
6
+ // snapshot. It applies a single staleness rule and hands callers the same dict
7
+ // shape the starter-kit bridge's `calendar` command returns, so the four
8
+ // existing callers swap one call and nothing else.
9
+ //
10
+ // Staleness rule (plan P1):
11
+ // snapshot age <= 2 h -> snapshot
12
+ // older than 2 h and the bridge reads -> bridge
13
+ // older than 2 h, bridge does not read -> snapshot, labeled "as of"
14
+ // older than 24 h, past windowEnd, or denied -> none
15
+ // No snapshot file at all keeps today's behaviour exactly: the bridge. So does a
16
+ // granted snapshot with no calendar picked yet (QA W20): Connect before the first
17
+ // pick is "not set up", never an empty day.
18
+ //
19
+ // Privacy: event titles never go to a log line from here. Errors log the
20
+ // reason code only.
21
+
22
+ import { lstatSync, readFileSync } from 'node:fs'
23
+ import { dataPath } from './data-dir.js'
24
+ import { callPython, pythonBridgeAvailable } from './python-bridge.js'
25
+
26
+ export const SNAPSHOT_FRESH_MS = 2 * 3600_000
27
+ export const SNAPSHOT_MAX_AGE_MS = 24 * 3600_000
28
+ export const SNAPSHOT_MAX_BYTES = 4_000_000
29
+
30
+ export type CalendarAccess = 'granted' | 'denied' | 'restricted' | 'notDetermined'
31
+ export type CalendarSourceType = 'exchange' | 'caldav' | 'subscription' | 'birthdays' | 'local' | 'other'
32
+
33
+ export interface SnapshotCalendar {
34
+ id: string
35
+ title: string
36
+ account: string | null
37
+ sourceType: CalendarSourceType
38
+ /** Shared or family calendars: events with no attendees there are not the owner's meetings. */
39
+ shared: boolean
40
+ selected: boolean
41
+ }
42
+
43
+ export interface SnapshotAttendee { name: string | null; domain: string | null }
44
+
45
+ export interface SnapshotEvent {
46
+ calendarId: string
47
+ /** EKEvent.eventIdentifier (device-local). */
48
+ eventId: string | null
49
+ /** calendarItemExternalIdentifier (server-side id, stable across devices). */
50
+ externalId: string | null
51
+ /** The occurrence's ORIGINAL start (EKEvent.occurrenceDate), so a moved occurrence keeps its key. */
52
+ occurrenceStart: string
53
+ start: string
54
+ end: string | null
55
+ allDay: boolean
56
+ /** timeZone == nil in EventKit. */
57
+ floating: boolean
58
+ title: string
59
+ myStatus: 'accepted' | 'declined' | 'tentative' | 'pending' | 'none'
60
+ isOrganizer: boolean
61
+ attendees: SnapshotAttendee[]
62
+ /** Allowlisted hosts only (zoom.us, teams.microsoft.com, meet.google.com). */
63
+ meetingUrl: string | null
64
+ }
65
+
66
+ export interface CalendarSnapshot {
67
+ version: 1
68
+ writtenAt: string
69
+ windowStart: string
70
+ windowEnd: string
71
+ access: CalendarAccess
72
+ calendars: SnapshotCalendar[]
73
+ events: SnapshotEvent[]
74
+ }
75
+
76
+ export type CalendarSourceName = 'snapshot' | 'snapshot-stale' | 'bridge' | 'none'
77
+
78
+ export interface SnapshotRead {
79
+ /** 'missing' means no file: callers fall back to the bridge, as before P1. */
80
+ state: 'missing' | 'invalid' | 'denied' | 'unselected' | 'fresh' | 'aging' | 'expired'
81
+ snapshot: CalendarSnapshot | null
82
+ ageMs: number | null
83
+ }
84
+
85
+ const SOURCE_TYPES: readonly CalendarSourceType[] = ['exchange', 'caldav', 'subscription', 'birthdays', 'local', 'other']
86
+ const STATUSES = ['accepted', 'declined', 'tentative', 'pending', 'none'] as const
87
+ const ACCESS: readonly CalendarAccess[] = ['granted', 'denied', 'restricted', 'notDetermined']
88
+ const MEETING_HOSTS = /^https:\/\/([a-z0-9-]+\.)*(zoom\.us|teams\.microsoft\.com|meet\.google\.com)(\/|$)/i
89
+
90
+ export function snapshotPath(): string {
91
+ return dataPath('calendar', 'snapshot.json')
92
+ }
93
+
94
+ const str = (v: unknown, max = 512): string | null => typeof v === 'string' && v.length > 0 && v.length <= max ? v : null
95
+ const iso = (v: unknown): string | null => {
96
+ if (typeof v !== 'string' || v.length > 64) return null
97
+ const t = Date.parse(v)
98
+ return Number.isFinite(t) ? new Date(t).toISOString() : null
99
+ }
100
+
101
+ /** Validate an untrusted snapshot. Bad rows are dropped; a bad envelope rejects the whole file. */
102
+ export function parseSnapshot(raw: unknown): CalendarSnapshot | null {
103
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
104
+ const o = raw as Record<string, unknown>
105
+ if (o.version !== 1) return null
106
+ const writtenAt = iso(o.writtenAt), windowStart = iso(o.windowStart), windowEnd = iso(o.windowEnd)
107
+ const access = ACCESS.includes(o.access as CalendarAccess) ? o.access as CalendarAccess : null
108
+ if (!writtenAt || !access) return null
109
+ if (access !== 'granted') {
110
+ // A tombstone carries no calendars or events, whatever the file says.
111
+ return { version: 1, writtenAt, windowStart: windowStart ?? writtenAt, windowEnd: windowEnd ?? writtenAt, access, calendars: [], events: [] }
112
+ }
113
+ if (!windowStart || !windowEnd || !Array.isArray(o.calendars) || !Array.isArray(o.events)) return null
114
+ const calendars: SnapshotCalendar[] = []
115
+ for (const c of o.calendars.slice(0, 500)) {
116
+ if (!c || typeof c !== 'object') continue
117
+ const r = c as Record<string, unknown>
118
+ const id = str(r.id), title = str(r.title, 300)
119
+ if (!id || title === null) continue
120
+ calendars.push({
121
+ id,
122
+ title,
123
+ account: str(r.account, 300),
124
+ sourceType: SOURCE_TYPES.includes(r.sourceType as CalendarSourceType) ? r.sourceType as CalendarSourceType : 'other',
125
+ shared: r.shared === true,
126
+ selected: r.selected === true,
127
+ })
128
+ }
129
+ const known = new Set(calendars.map(c => c.id))
130
+ const events: SnapshotEvent[] = []
131
+ for (const e of o.events.slice(0, 5000)) {
132
+ if (!e || typeof e !== 'object') continue
133
+ const r = e as Record<string, unknown>
134
+ const calendarId = str(r.calendarId), start = iso(r.start), occurrenceStart = iso(r.occurrenceStart) ?? start
135
+ const title = typeof r.title === 'string' ? r.title.slice(0, 500) : null
136
+ if (!calendarId || !known.has(calendarId) || !start || !occurrenceStart || title === null) continue
137
+ const attendees = Array.isArray(r.attendees)
138
+ ? r.attendees.slice(0, 200).flatMap((a): SnapshotAttendee[] => {
139
+ if (!a || typeof a !== 'object') return []
140
+ const x = a as Record<string, unknown>
141
+ // macOS fills a participant's name with their address when there is no display name. Names only, never an
142
+ // address: a name that looks like one is dropped (the domain stays). Same rule as Control's attendeeName.
143
+ const name = str(x.name, 200)
144
+ return [{ name: name && !name.includes('@') ? name : null, domain: str(x.domain, 253) }]
145
+ })
146
+ : []
147
+ const url = typeof r.meetingUrl === 'string' && r.meetingUrl.length <= 2048 && MEETING_HOSTS.test(r.meetingUrl) ? r.meetingUrl : null
148
+ events.push({
149
+ calendarId,
150
+ eventId: str(r.eventId),
151
+ externalId: str(r.externalId),
152
+ occurrenceStart,
153
+ start,
154
+ end: iso(r.end),
155
+ allDay: r.allDay === true,
156
+ floating: r.floating === true,
157
+ title,
158
+ myStatus: STATUSES.includes(r.myStatus as never) ? r.myStatus as SnapshotEvent['myStatus'] : 'none',
159
+ isOrganizer: r.isOrganizer === true,
160
+ attendees,
161
+ meetingUrl: url,
162
+ })
163
+ }
164
+ return { version: 1, writtenAt, windowStart, windowEnd, access, calendars, events }
165
+ }
166
+
167
+ let cache: { mtimeMs: number; size: number; snapshot: CalendarSnapshot | null } | null = null
168
+
169
+ /** Test seam. */
170
+ export function resetCalendarSourceCache(): void { cache = null }
171
+
172
+ /**
173
+ * Server-side switch: COS_CALENDAR_SNAPSHOT=0 ignores the snapshot and uses the bridge as before. On by default; only
174
+ * `0` (after trimming) turns it off, so `false` or `off` leave it on. QA W21: calendar_context.py once read the
175
+ * snapshot only when the switch was 1, the opposite default. Its `snapshot_reading_enabled` now has this polarity, and
176
+ * calendar-source.test.ts runs one table against both readers.
177
+ */
178
+ export function snapshotReadingEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
179
+ return env.COS_CALENDAR_SNAPSHOT?.trim() !== '0'
180
+ }
181
+
182
+ export function readSnapshot(now = Date.now(), path = snapshotPath()): SnapshotRead {
183
+ if (!snapshotReadingEnabled()) return { state: 'missing', snapshot: null, ageMs: null }
184
+ let st
185
+ try { st = lstatSync(path) } catch { return { state: 'missing', snapshot: null, ageMs: null } }
186
+ if (!st.isFile() || st.size > SNAPSHOT_MAX_BYTES) return { state: 'invalid', snapshot: null, ageMs: null }
187
+ let snapshot: CalendarSnapshot | null
188
+ if (cache && cache.mtimeMs === st.mtimeMs && cache.size === st.size) {
189
+ snapshot = cache.snapshot
190
+ } else {
191
+ try { snapshot = parseSnapshot(JSON.parse(readFileSync(path, 'utf8'))) } catch { snapshot = null }
192
+ cache = { mtimeMs: st.mtimeMs, size: st.size, snapshot }
193
+ }
194
+ if (!snapshot) return { state: 'invalid', snapshot: null, ageMs: null }
195
+ const ageMs = Math.max(0, now - Date.parse(snapshot.writtenAt))
196
+ if (snapshot.access !== 'granted') return { state: 'denied', snapshot, ageMs }
197
+ // QA W20: granted with nothing picked is not configured. Keep the previous source until the first selection.
198
+ if (!snapshot.calendars.some(c => c.selected)) return { state: 'unselected', snapshot, ageMs }
199
+ if (ageMs > SNAPSHOT_MAX_AGE_MS || now > Date.parse(snapshot.windowEnd)) return { state: 'expired', snapshot, ageMs }
200
+ if (ageMs <= SNAPSHOT_FRESH_MS) return { state: 'fresh', snapshot, ageMs }
201
+ return { state: 'aging', snapshot, ageMs }
202
+ }
203
+
204
+ // ---------------------------------------------------------------------------
205
+ // Selection, dedupe and the prep filter
206
+ // ---------------------------------------------------------------------------
207
+
208
+ const HOLIDAY_OR_BIRTHDAY = /\b(holidays?|birthdays?)\b/i
209
+ /** A family calendar under any name ("Family", "Ukaoma Family", "Family calendar"). */
210
+ const FAMILY_TITLE = /\bfamily\b/i
211
+ /** Travel-shaped titles: the bridge's own `looks_like_flight` (calendar_context CalendarEvent.to_dict), case-folded. */
212
+ const TRAVEL_TITLE = /\bflight\b|\b(wn|aa|ua|dl|as|b6|nk|f9)\s*\d{2,4}\b/i
213
+
214
+ /** A calendar other people write to: shared, a family calendar by name. Its events are not the owner's by default. */
215
+ export function isFamilyCalendar(calendar: SnapshotCalendar): boolean {
216
+ return calendar.shared || FAMILY_TITLE.test(calendar.title)
217
+ }
218
+
219
+ export function isTravelShaped(e: SnapshotEvent): boolean {
220
+ return TRAVEL_TITLE.test(e.title)
221
+ }
222
+
223
+ /** Events from selected calendars only, deduped across accounts. Order: start time. */
224
+ export function selectedEvents(snapshot: CalendarSnapshot): SnapshotEvent[] {
225
+ const selected = new Set(snapshot.calendars.filter(c => c.selected).map(c => c.id))
226
+ const rows = snapshot.events.filter(e => selected.has(e.calendarId))
227
+ rows.sort((a, b) => Date.parse(a.start) - Date.parse(b.start))
228
+ return dedupeEvents(rows)
229
+ }
230
+
231
+ /**
232
+ * The same meeting invited on Exchange and on Gmail shows up twice. Merge only a true duplicate of the same meeting on
233
+ * another calendar: the external id and occurrence match, or the title (trimmed, case-folded), start and end all match
234
+ * AND the two share a named attendee or the same meeting link. Two events from the SAME calendar are never merged: two
235
+ * 10:00 "Busy" holds are two holds. QA 7.0.0: the shape fallback once keyed on the set of attendee DOMAINS, so two
236
+ * different "Weekly sync" meetings at one time with different people from one company merged into one.
237
+ */
238
+ export function dedupeEvents(rows: SnapshotEvent[]): SnapshotEvent[] {
239
+ const out: SnapshotEvent[] = []
240
+ const byExternal = new Map<string, SnapshotEvent>()
241
+ const byShape = new Map<string, SnapshotEvent[]>()
242
+ for (const e of rows) {
243
+ const extKey = e.externalId ? `${e.externalId}|${e.occurrenceStart}` : null
244
+ const shapeKey = `${e.title.trim().toLowerCase()}|${e.start}|${e.end ?? ''}`
245
+ const byId = extKey ? byExternal.get(extKey) : undefined
246
+ const dup = (byId && byId.calendarId !== e.calendarId) ||
247
+ (byShape.get(shapeKey) ?? []).some(p => p.calendarId !== e.calendarId && sameMeeting(p, e))
248
+ if (dup) continue
249
+ out.push(e)
250
+ if (extKey && !byExternal.has(extKey)) byExternal.set(extKey, e)
251
+ const bucket = byShape.get(shapeKey)
252
+ if (bucket) bucket.push(e); else byShape.set(shapeKey, [e])
253
+ }
254
+ return out
255
+ }
256
+
257
+ /** Same-shaped events that are one meeting: a shared named attendee (name and domain) or the same meeting link. */
258
+ function sameMeeting(a: SnapshotEvent, b: SnapshotEvent): boolean {
259
+ const la = meetingLink(a.meetingUrl), lb = meetingLink(b.meetingUrl)
260
+ if (la && la === lb) return true
261
+ const named = (e: SnapshotEvent) => new Set(e.attendees.filter(x => !!x.name && !!x.name.trim()).map(x => `${x.name!.trim().toLowerCase()}|${(x.domain ?? '').toLowerCase()}`))
262
+ const na = named(a)
263
+ if (!na.size) return false
264
+ for (const k of named(b)) if (na.has(k)) return true
265
+ return false
266
+ }
267
+
268
+ /** A meeting link without its query or fragment (a Zoom pwd, a tracking tag), lower-cased host, no trailing slash. */
269
+ function meetingLink(url: string | null): string | null {
270
+ if (!url) return null
271
+ try {
272
+ const u = new URL(url)
273
+ return `${u.protocol}//${u.host.toLowerCase()}${u.pathname.replace(/\/+$/, '')}`
274
+ } catch { return null }
275
+ }
276
+
277
+ export function isMultiDay(e: SnapshotEvent): boolean {
278
+ if (!e.end) return false
279
+ return Date.parse(e.end) - Date.parse(e.start) > 24 * 3600_000
280
+ }
281
+
282
+ /**
283
+ * Whether an event may become next(), prep, Today's list or Meeting Ask's match. Declined, all-day, multi-day,
284
+ * birthday and holiday calendars, and shared-calendar events with no attendees never qualify (shared-calendar flights
285
+ * are not the owner's travel).
286
+ *
287
+ * QA W23, the family rule. It was an exact "Family" title or a shared calendar, so a flight on another editable
288
+ * calendar (iCloud "Calendar", a TripIt import) still qualified. Now:
289
+ * - a family calendar matches by word ("Ukaoma Family"), not by exact title;
290
+ * - a travel-shaped event (the bridge's flight rule) with no attendees never qualifies, on any calendar;
291
+ * - a travel-shaped event on a family or shared calendar never qualifies, even with attendees (a flight a relative
292
+ * invited the owner to is still their travel, not a meeting).
293
+ * A non-travel event with real attendees on a family calendar (a school meeting) still qualifies, and an
294
+ * attendee-less hold on the owner's own calendar is unchanged.
295
+ */
296
+ export function isPrepWorthy(e: SnapshotEvent, calendar: SnapshotCalendar | undefined): boolean {
297
+ if (e.myStatus === 'declined' || e.allDay || isMultiDay(e)) return false
298
+ if (!calendar) return false
299
+ if (calendar.sourceType === 'birthdays' || HOLIDAY_OR_BIRTHDAY.test(calendar.title)) return false
300
+ const sharedLike = isFamilyCalendar(calendar) || calendar.sourceType === 'subscription'
301
+ const travel = isTravelShaped(e)
302
+ if (sharedLike && (travel || e.attendees.length === 0)) return false
303
+ if (travel && e.attendees.length === 0) return false
304
+ return true
305
+ }
306
+
307
+ // ---------------------------------------------------------------------------
308
+ // Bridge-shaped output
309
+ // ---------------------------------------------------------------------------
310
+
311
+ /**
312
+ * One event in the starter-kit bridge's dict shape, plus the occurrence fields bridgeOccurrence reads.
313
+ *
314
+ * `domain` and `matched_person` stay null here: the bridge's classifier (people_context.classify_event_by_attendees)
315
+ * lives in Python people context, which this standalone server does not have. Meeting Ask, the one caller that needs a
316
+ * domain, asks the bridge's `calendar-classify` for its candidates (QA W21, meeting-ask-orientation.ts).
317
+ */
318
+ export function bridgeEvent(e: SnapshotEvent, calendar: SnapshotCalendar | undefined): Record<string, unknown> {
319
+ const start = Date.parse(e.start)
320
+ const end = e.end ? Date.parse(e.end) : NaN
321
+ return {
322
+ title: e.title,
323
+ start_time: e.start,
324
+ end_time: e.end,
325
+ location: null,
326
+ is_all_day: e.allDay,
327
+ duration_minutes: e.allDay || !Number.isFinite(end) ? 60 : Math.round((end - start) / 60_000),
328
+ domain: null,
329
+ matched_person: null,
330
+ attendees: e.attendees.map(a => a.name).filter((n): n is string => !!n),
331
+ calendar_name: calendar?.title ?? null,
332
+ // The bridge's ownership words: family calendars, then travel it cannot verify, then the owner's own.
333
+ ownership: calendar && isFamilyCalendar(calendar) ? 'family_shared' : isTravelShaped(e) && e.attendees.length === 0 ? 'unverified_flight' : 'miles',
334
+ meeting_url: e.meetingUrl,
335
+ prep_worthy: isPrepWorthy(e, calendar),
336
+ // Occurrence identity for prep (welcome-context bridgeOccurrence reads these keys).
337
+ provider: 'eventkit',
338
+ account: calendar?.account ?? null,
339
+ calendar_id: e.calendarId,
340
+ event_id: e.eventId,
341
+ external_id: e.externalId,
342
+ occurrence_start: e.occurrenceStart,
343
+ }
344
+ }
345
+
346
+ function localDayBounds(now: number): { start: number; end: number } {
347
+ const d = new Date(now)
348
+ const start = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime()
349
+ const end = new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1).getTime()
350
+ return { start, end }
351
+ }
352
+
353
+ /** The bridge `calendar` dict, built from a snapshot. `stale` marks an aging snapshot used because the bridge is down. */
354
+ export function snapshotToBridgeShape(snapshot: CalendarSnapshot, now = Date.now(), stale = false): Record<string, unknown> {
355
+ const cals = new Map(snapshot.calendars.map(c => [c.id, c]))
356
+ const { start: dayStart, end: dayEnd } = localDayBounds(now)
357
+ const events = selectedEvents(snapshot)
358
+ const endOf = (e: SnapshotEvent) => (e.end ? Date.parse(e.end) : Date.parse(e.start) + 3600_000)
359
+ const today = events.filter(e => { const s = Date.parse(e.start); return s >= dayStart && s < dayEnd })
360
+ const remaining = today.filter(e => endOf(e) > now)
361
+ const timed = (e: SnapshotEvent) => isPrepWorthy(e, cals.get(e.calendarId))
362
+ const current = remaining.find(e => timed(e) && Date.parse(e.start) <= now && endOf(e) > now) ?? null
363
+ const next = events.find(e => timed(e) && Date.parse(e.start) > now) ?? null
364
+ // Tomorrow ends at the NEXT local midnight, not 24 h after this one (a DST day is 23 or 25 hours). QA 7.0.0.
365
+ const tomorrowEnd = localDayBounds(dayEnd).end
366
+ const tomorrow = events.filter(e => { const s = Date.parse(e.start); return s >= dayEnd && s < tomorrowEnd })
367
+ const asDict = (e: SnapshotEvent) => bridgeEvent(e, cals.get(e.calendarId))
368
+ const ageMinutes = Math.max(0, (now - Date.parse(snapshot.writtenAt)) / 60_000)
369
+ return {
370
+ current_event: current ? asDict(current) : null,
371
+ next_event: next ? asDict(next) : null,
372
+ minutes_until_next: next ? Math.max(0, Math.round((Date.parse(next.start) - now) / 60_000)) : null,
373
+ today_events: today.map(asDict),
374
+ today_remaining: remaining.map(asDict),
375
+ tomorrow_events_count: tomorrow.length,
376
+ week_events_count: events.filter(e => Date.parse(e.start) >= dayStart).length,
377
+ meetings_today_count: today.length,
378
+ meetings_remaining_count: remaining.length,
379
+ is_in_meeting: current !== null,
380
+ free_until: next ? next.start : null,
381
+ data_source: stale ? 'eventkit_stale' : 'eventkit',
382
+ cached_at: snapshot.writtenAt,
383
+ cache_age_minutes: Math.round(ageMinutes * 10) / 10,
384
+ is_stale: stale,
385
+ }
386
+ }
387
+
388
+ /** The no-calendar answer. `error` makes calendarAnswerWasRead() report "not connected", never an empty day. */
389
+ export function noCalendarShape(reason: string): Record<string, unknown> {
390
+ return { error: reason, today_events: [], today_remaining: [], next_event: null, meetings_today_count: 0, meetings_remaining_count: 0, data_source: 'none', is_stale: false }
391
+ }
392
+
393
+ export interface CalendarRead {
394
+ source: CalendarSourceName
395
+ data: unknown
396
+ /** Snapshot age when the snapshot was used or considered. */
397
+ ageMs: number | null
398
+ }
399
+
400
+ let lastSource: CalendarSourceName | null = null
401
+
402
+ /**
403
+ * The one call the four callers make instead of callPython(['calendar']).
404
+ * Returns the bridge dict shape whichever source answered.
405
+ */
406
+ export async function readCalendar(timeoutMs = 30_000, now = Date.now()): Promise<CalendarRead> {
407
+ const snap = readSnapshot(now)
408
+ const done = (r: CalendarRead): CalendarRead => { lastSource = r.source; return r }
409
+ if (snap.state === 'missing' || snap.state === 'invalid' || snap.state === 'unselected') {
410
+ // Pre-P1 behaviour, unchanged. Unselected (QA W20): Connect with nothing picked keeps this source too.
411
+ return done({ source: 'bridge', data: await callPython(['calendar'], timeoutMs), ageMs: null })
412
+ }
413
+ if (snap.state === 'denied') return done({ source: 'none', data: noCalendarShape('calendar_access_denied'), ageMs: snap.ageMs })
414
+ if (snap.state === 'fresh') return done({ source: 'snapshot', data: snapshotToBridgeShape(snap.snapshot!, now), ageMs: snap.ageMs })
415
+ // aging or expired: the bridge wins when it really reads.
416
+ if (pythonBridgeAvailable()) {
417
+ try {
418
+ const data = await callPython(['calendar'], timeoutMs)
419
+ if (data && typeof data === 'object' && !Array.isArray(data) && !('error' in data)) {
420
+ return done({ source: 'bridge', data, ageMs: snap.ageMs })
421
+ }
422
+ } catch { /* fall through to the labeled snapshot */ }
423
+ }
424
+ if (snap.state === 'aging') return done({ source: 'snapshot-stale', data: snapshotToBridgeShape(snap.snapshot!, now, true), ageMs: snap.ageMs })
425
+ return done({ source: 'none', data: noCalendarShape('calendar_snapshot_expired'), ageMs: snap.ageMs })
426
+ }
427
+
428
+ /** Counts only, for the authenticated /api/models capabilities. Never titles. */
429
+ export function calendarStatus(now = Date.now()): {
430
+ source: CalendarSourceName | 'unknown'
431
+ snapshot: SnapshotRead['state']
432
+ ageMs: number | null
433
+ access: CalendarAccess | null
434
+ calendars: number
435
+ selected: number
436
+ } {
437
+ const snap = readSnapshot(now)
438
+ return {
439
+ source: lastSource ?? 'unknown',
440
+ snapshot: snap.state,
441
+ ageMs: snap.ageMs,
442
+ access: snap.snapshot?.access ?? null,
443
+ calendars: snap.snapshot?.calendars.length ?? 0,
444
+ selected: snap.snapshot?.calendars.filter(c => c.selected).length ?? 0,
445
+ }
446
+ }
447
+
448
+ /**
449
+ * A bridge-shaped row that may be listed as a meeting: timed, and not marked unworthy by the prep filter. A bridge row
450
+ * without prep_worthy counts (the bridge already drops declined and skips all-day). Today and Meeting Ask both use it.
451
+ */
452
+ export function countsAsMeeting(row: Record<string, unknown> | null | undefined): boolean {
453
+ return !!row && row.is_all_day !== true && row.prep_worthy !== false
454
+ }
455
+
456
+ /**
457
+ * Today for Control Web and desktop: selected calendars only, the next 3 timed meetings that count. QA 7.0.0: all-day
458
+ * and non-prep events (declined, holidays, attendee-less shared events) are filtered out BEFORE the 3 are taken, so they
459
+ * never use up the slots Today's "Later" shows. QA W23: remainingCount counts the same rows, so a declined meeting or
460
+ * a family flight is not "remaining".
461
+ */
462
+ export async function calendarToday(now = Date.now()): Promise<{
463
+ connected: boolean
464
+ source: CalendarSourceName
465
+ asOf: string | null
466
+ stale: boolean
467
+ remainingCount: number
468
+ next: Array<{ title: string; start: string; end: string | null; allDay: boolean; calendar: string | null; meetingUrl: string | null }>
469
+ }> {
470
+ const read = await readCalendar(8_000, now)
471
+ const data = read.data as Record<string, unknown> | null
472
+ const connected = !!data && typeof data === 'object' && !('error' in data) && (read.source !== 'bridge' || pythonBridgeAvailable())
473
+ const rows = connected && Array.isArray(data!.today_remaining) ? data!.today_remaining as Array<Record<string, unknown>> : []
474
+ const counted = rows.filter(countsAsMeeting)
475
+ const next = counted.slice(0, 3).map(r => ({
476
+ title: typeof r.title === 'string' ? r.title : 'Event',
477
+ start: String(r.start_time ?? ''),
478
+ end: typeof r.end_time === 'string' ? r.end_time : null,
479
+ allDay: r.is_all_day === true,
480
+ calendar: typeof r.calendar_name === 'string' ? r.calendar_name : null,
481
+ meetingUrl: typeof r.meeting_url === 'string' ? r.meeting_url : null,
482
+ }))
483
+ return {
484
+ connected,
485
+ source: read.source,
486
+ asOf: typeof data?.cached_at === 'string' ? data.cached_at : null,
487
+ stale: data?.is_stale === true,
488
+ remainingCount: connected ? counted.length : 0,
489
+ next,
490
+ }
491
+ }
@@ -60,6 +60,8 @@ import {
60
60
  } from './claude-tool-access.js'
61
61
  import {
62
62
  classifyCodexError,
63
+ codexTerminalUsageLimitError,
64
+ safeCodexUsageLimitMessage,
63
65
  extractCodexThreadId,
64
66
  finishCodexRun,
65
67
  getCodexExecutionCwd,
@@ -382,6 +384,7 @@ export function runCodexKeyProfileTurn(
382
384
 
383
385
  function safeCodexUserError(message: string): string {
384
386
  const code = classifyCodexError(message)
387
+ if (code === 'provider_limit') return safeCodexUsageLimitMessage(message)
385
388
  if (code === 'codex.cli_unavailable') return 'Codex CLI unavailable. Check server Settings.'
386
389
  if (code === 'codex.auth_error') return 'Codex auth failed. Run codex login on the Mac.'
387
390
  if (code === 'codex.timeout') return 'Codex timed out. Retry or start a new chat.'
@@ -1021,7 +1024,7 @@ export async function callCodexStreaming(
1021
1024
  rawProviderError,
1022
1025
  structuredProviderError,
1023
1026
  )
1024
- finalizeError(authenticationError ?? `codex-bridge: ${rawProviderError}`)
1027
+ finalizeError(authenticationError ?? codexTerminalUsageLimitError(event) ?? `codex-bridge: ${rawProviderError}`)
1025
1028
  }
1026
1029
  }
1027
1030
 
@@ -215,8 +215,37 @@ export function redactForCodexLedger(value: string, maxChars = ERROR_PREVIEW_CHA
215
215
  .slice(0, maxChars)
216
216
  }
217
217
 
218
+ /** Only a validated provider retry date may cross the terminal error boundary. */
219
+ export function safeCodexUsageLimitMessage(message: string): string {
220
+ const fallback = 'Codex usage limit reached. Try again later or choose another model.'
221
+ // Bound work on untrusted terminal text; do not preserve links, identifiers or prose.
222
+ const retry = /(?:try again|resets?) at (Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|Jun(?:e)?|Jul(?:y)?|Aug(?:ust)?|Sep(?:tember)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?)\s+(\d{1,2})(?:st|nd|rd|th)?,?\s+(20\d{2})\s+(\d{1,2}):(\d{2})\s*(AM|PM)\b/i.exec(message.slice(0, 8_000))
223
+ if (!retry) return fallback
224
+ const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
225
+ const month = months.findIndex(value => value.toLowerCase() === retry[1].slice(0, 3).toLowerCase())
226
+ const day = Number(retry[2]), year = Number(retry[3]), hour = Number(retry[4]), minute = Number(retry[5])
227
+ const date = new Date(Date.UTC(year, month, day))
228
+ if (date.getUTCMonth() !== month || date.getUTCDate() !== day || hour < 1 || hour > 12 || minute > 59) return fallback
229
+ return `Codex usage limit reached. Try again at ${months[month]} ${day}, ${year} ${hour}:${retry[5]} ${retry[6].toUpperCase()} (provider time), or choose another model.`
230
+ }
231
+
232
+ /** Codex JSON terminal errors carry the quota code separately from their message. */
233
+ export function codexTerminalUsageLimitError(event: unknown): string | undefined {
234
+ if (!event || typeof event !== 'object') return undefined
235
+ const value = event as Record<string, unknown>
236
+ if (value.type !== 'error' && value.type !== 'turn.failed') return undefined
237
+ const error = value.error && typeof value.error === 'object' ? value.error as Record<string, unknown> : undefined
238
+ const code = error?.codex_error_info ?? value.codex_error_info
239
+ if (code !== 'usage_limit_exceeded') return undefined
240
+ const message = typeof error?.message === 'string' ? error.message
241
+ : typeof value.message === 'string' ? value.message
242
+ : typeof value.error === 'string' ? value.error : ''
243
+ return safeCodexUsageLimitMessage(message)
244
+ }
245
+
218
246
  export function classifyCodexError(message: string): string {
219
247
  const text = message.toLowerCase()
248
+ if (/\busage_limit_exceeded\b|^codex usage limit reached\./.test(text)) return 'provider_limit'
220
249
  if (/command not found|enoent|not found/.test(text)) return 'codex.cli_unavailable'
221
250
  if (/permission|denied|sandbox|read-only|operation not permitted/.test(text)) return 'codex.permission_denied'
222
251
  if (/auth|login|sign in|unauthorized|forbidden|token/.test(text)) return 'codex.auth_error'
@@ -2,6 +2,7 @@
2
2
  // Caches context for 60s to avoid hammering Python scripts every query
3
3
 
4
4
  import { callPython, COS_MODE } from './python-bridge.js'
5
+ import { readCalendar } from './calendar-source.js'
5
6
  import { getOwnerName } from './profile.js'
6
7
 
7
8
  interface ContextCache {
@@ -76,7 +77,7 @@ async function fetchLiveContext(): Promise<string> {
76
77
 
77
78
  // Fetch calendar and tasks in parallel
78
79
  const [calResult, taskResult] = await Promise.allSettled([
79
- callPython(['calendar']),
80
+ readCalendar().then(r => r.data),
80
81
  callPython(['tasks']),
81
82
  ])
82
83
 
@@ -38,7 +38,7 @@ export interface DropP2Deps {
38
38
  timezone: () => string
39
39
  now?: () => number
40
40
  /** The next calendar event; undefined when the calendar could not be read. */
41
- nextEvent?: () => Promise<{ title?: unknown; time?: unknown } | null | undefined>
41
+ nextEvent?: () => Promise<{ title?: unknown; time?: unknown; occurrence?: unknown } | null | undefined>
42
42
  /** Open Work intake items; undefined when the store is not available. */
43
43
  intakeOpen?: () => IntakeRow[] | undefined
44
44
  /** operations/scripts, for the thread and signal caches (read only; no script is run). */
@@ -15,6 +15,8 @@ import { existsSync, lstatSync, mkdirSync, readFileSync } from 'node:fs'
15
15
  import { join, resolve } from 'node:path'
16
16
  import { durableAtomicWriteFileSync } from './atomic-fs.js'
17
17
  import { acquireServerInstanceLock, type ServerInstanceLock } from './server-instance-lock.js'
18
+ import { parsePrepOccurrence } from './prep-store.js'
19
+ import type { PrepOccurrence } from '../../shared/prep.js'
18
20
 
19
21
  export class DropStoreError extends Error {
20
22
  constructor(readonly code: string, readonly status = 409) { super(code) }
@@ -81,9 +83,10 @@ export const FEED_KINDS = ['drop', 'receipt', 'question', 'followup', 'ping', 'a
81
83
  export type FeedKind = typeof FEED_KINDS[number]
82
84
  /**
83
85
  * 6.99.19: the prep card's "Prep me" may carry the event it is about, so a client can open a Prep draft for it
84
- * (ping-sources.ts prepCandidates). Display fields only; occurrence is null until the calendar sends event ids.
86
+ * (ping-sources.ts prepCandidates). Display fields plus, since the EventKit calendar source (PH plan P1), the event's
87
+ * occurrence identity when the calendar gave one. Null when it did not (the starter-kit bridge sends no event ids).
85
88
  */
86
- export interface FeedActionPrep { title: string; startsAt: string; occurrence: null }
89
+ export interface FeedActionPrep { title: string; startsAt: string; occurrence: PrepOccurrence | null }
87
90
  export interface FeedAction { id: string; label: string; prep?: FeedActionPrep }
88
91
  export interface FeedItem {
89
92
  id: string
@@ -245,8 +248,11 @@ export function parseDropRecord(raw: unknown): DropRecord {
245
248
  function feedActionPrep(raw: unknown): FeedActionPrep | undefined {
246
249
  if (!record(raw)) return undefined
247
250
  const title = str(raw.title, 300), startsAt = typeof raw.startsAt === 'string' && raw.startsAt.length <= 64 && Number.isFinite(Date.parse(raw.startsAt)) ? raw.startsAt : null
248
- if (!title || !startsAt || raw.occurrence !== null) return undefined
249
- return { title, startsAt, occurrence: null }
251
+ if (!title || !startsAt) return undefined
252
+ // Older rows carry occurrence: null. A present but invalid identity drops to null, never a guess.
253
+ let occurrence: PrepOccurrence | null = null
254
+ if (raw.occurrence != null) { try { occurrence = parsePrepOccurrence(raw.occurrence) } catch { occurrence = null } }
255
+ return { title, startsAt, occurrence }
250
256
  }
251
257
 
252
258
  export function parseFeedItem(raw: unknown): FeedItem {