@gotcos/glasses-server 6.99.33 → 7.0.18

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 (133) hide show
  1. package/.env.example +37 -0
  2. package/CHANGELOG.md +587 -0
  3. package/README.md +3 -1
  4. package/docs/web-key-verifier-contract.md +378 -0
  5. package/managed-runtime-contract.json +12 -1
  6. package/package.json +10 -3
  7. package/server/bootstrap.ts +10 -0
  8. package/server/index.ts +155 -30
  9. package/server/lib/agent-session-output-images.ts +627 -49
  10. package/server/lib/agent-session-recent.ts +321 -0
  11. package/server/lib/agent-session-store.ts +27 -14
  12. package/server/lib/agent-session-tool-images.ts +224 -0
  13. package/server/lib/agent-session-turns.ts +19 -2
  14. package/server/lib/api-auth.ts +43 -1
  15. package/server/lib/ask-work-plan.ts +3 -1
  16. package/server/lib/calendar-meetings.ts +70 -0
  17. package/server/lib/calendar-source.ts +536 -0
  18. package/server/lib/claude-hooks-installer.ts +111 -12
  19. package/server/lib/codex-bridge.ts +4 -1
  20. package/server/lib/codex-run-ledger.ts +29 -0
  21. package/server/lib/context-builder.ts +2 -1
  22. package/server/lib/conversation.ts +56 -28
  23. package/server/lib/cors-reject-log.ts +51 -0
  24. package/server/lib/disk-io-meter.ts +202 -0
  25. package/server/lib/drop-p2-runtime.ts +1 -1
  26. package/server/lib/drop-service.ts +47 -12
  27. package/server/lib/drop-store.ts +31 -5
  28. package/server/lib/event-loop-stall.ts +62 -0
  29. package/server/lib/full-hold-engine.ts +114 -0
  30. package/server/lib/full-hold.ts +124 -0
  31. package/server/lib/g2-turn-provenance.ts +128 -9
  32. package/server/lib/learning-needs-you.ts +24 -7
  33. package/server/lib/listener-startup.ts +18 -0
  34. package/server/lib/live-events.ts +533 -0
  35. package/server/lib/local-path-redact.ts +272 -0
  36. package/server/lib/meeting-ask-orientation.ts +40 -6
  37. package/server/lib/meeting-ask-runner.ts +30 -10
  38. package/server/lib/meeting-batch-transcribe.ts +32 -5
  39. package/server/lib/morning-brief-runtime.ts +4 -2
  40. package/server/lib/native-mac-caller.ts +29 -0
  41. package/server/lib/permission-broker.ts +443 -11
  42. package/server/lib/ping-engine.ts +1 -1
  43. package/server/lib/ping-sources.ts +11 -6
  44. package/server/lib/preview-content.ts +172 -0
  45. package/server/lib/preview-error-code.ts +10 -0
  46. package/server/lib/preview-folders-setting.ts +133 -0
  47. package/server/lib/preview-log-throttle.ts +18 -0
  48. package/server/lib/preview-thumbs.ts +139 -0
  49. package/server/lib/secret-path-guard.ts +83 -0
  50. package/server/lib/session-cache-writer.ts +8 -1
  51. package/server/lib/session-files-setting.ts +88 -0
  52. package/server/lib/session-hook-events.ts +23 -1
  53. package/server/lib/session-hooks-runtime.ts +34 -0
  54. package/server/lib/session-links.ts +280 -0
  55. package/server/lib/session-log.ts +313 -4
  56. package/server/lib/session-state-derive.ts +36 -0
  57. package/server/lib/session-stream-events.ts +25 -1
  58. package/server/lib/thread-turn-queue.ts +10 -0
  59. package/server/lib/today-brief.ts +640 -85
  60. package/server/lib/turn-end-ask.ts +320 -0
  61. package/server/lib/turn-footer.ts +3 -3
  62. package/server/lib/web-control-integrity.ts +160 -0
  63. package/server/lib/web-control-offnet-page.ts +58 -0
  64. package/server/lib/web-control-static.ts +53 -9
  65. package/server/lib/web-key-authority.ts +372 -0
  66. package/server/lib/web-key-envelope.ts +98 -0
  67. package/server/lib/web-key-handoff.ts +135 -0
  68. package/server/lib/web-key-origin.ts +21 -0
  69. package/server/lib/web-key-passkey.ts +155 -0
  70. package/server/lib/web-key-registry.ts +825 -0
  71. package/server/lib/web-key-signer-app.ts +152 -0
  72. package/server/lib/web-memories-bridge.ts +3 -1
  73. package/server/lib/web-session-auth.ts +133 -15
  74. package/server/lib/whisper-local.ts +93 -7
  75. package/server/lib/whisper-preview.ts +5 -3
  76. package/server/lib/work-activity.ts +56 -6
  77. package/server/lib/work-orchestrator/snapshot.ts +17 -3
  78. package/server/lib/work-review-runtime.ts +17 -4
  79. package/server/lib/work-session-activity.ts +260 -0
  80. package/server/lib/your-move-count.ts +9 -3
  81. package/server/routes/agent-session-bindings.ts +29 -1
  82. package/server/routes/agent-session-stream.ts +23 -0
  83. package/server/routes/agent-sessions.ts +146 -11
  84. package/server/routes/ask-work-plan.ts +4 -1
  85. package/server/routes/claude-sessions.ts +5 -1
  86. package/server/routes/drops.ts +7 -2
  87. package/server/routes/g2-turns.ts +35 -0
  88. package/server/routes/health.ts +46 -1
  89. package/server/routes/live-events.ts +334 -0
  90. package/server/routes/permission-broker.ts +95 -5
  91. package/server/routes/query-jobs.ts +3 -0
  92. package/server/routes/session-preview.ts +200 -0
  93. package/server/routes/sessions.ts +15 -24
  94. package/server/routes/thread-turn-queue.ts +223 -12
  95. package/server/routes/today-brief.ts +134 -44
  96. package/server/routes/web-control.ts +37 -4
  97. package/server/routes/web-key.ts +284 -0
  98. package/server/routes/welcome-context.ts +28 -4
  99. package/server/routes/work-board.ts +9 -2
  100. package/server/routes/work-orchestrator.ts +12 -7
  101. package/server/scripts/write-web-control-manifest.mjs +43 -0
  102. package/server/web-control/.manifest.json +44 -0
  103. package/server/web-control/app.css +76 -1
  104. package/server/web-control/app.js +2247 -98
  105. package/server/web-control/apple-touch-icon.png +0 -0
  106. package/server/web-control/control.css +25 -0
  107. package/server/web-control/index.html +21 -3
  108. package/server/web-control/memory-review.js +100 -0
  109. package/server/web-control/panes/cos-chat.css +94 -9
  110. package/server/web-control/panes/cos-chat.js +628 -44
  111. package/server/web-control/panes/meetings.css +18 -2
  112. package/server/web-control/panes/meetings.js +24 -8
  113. package/server/web-control/panes/memories.css +17 -2
  114. package/server/web-control/panes/memories.js +127 -42
  115. package/server/web-control/panes/messages.js +21 -2
  116. package/server/web-control/panes/preview.js +318 -0
  117. package/server/web-control/panes/questions.js +162 -21
  118. package/server/web-control/panes/reader-media.js +409 -23
  119. package/server/web-control/panes/sessions.css +32 -3
  120. package/server/web-control/panes/settings.js +97 -6
  121. package/server/web-control/panes/today-actions.css +46 -0
  122. package/server/web-control/panes/today-actions.js +119 -17
  123. package/server/web-control/panes/work.css +13 -0
  124. package/server/web-control/panes/work.js +188 -37
  125. package/server/web-control/shell.js +17 -14
  126. package/server/web-signer/.manifest.json +11 -0
  127. package/server/web-signer/index.html +74 -0
  128. package/server/web-signer/signer-proof.js +348 -0
  129. package/server/web-signer/signer-store.js +39 -0
  130. package/server/web-signer/signer.css +23 -0
  131. package/server/web-signer/signer.js +341 -0
  132. package/shared/web-approval-packets.golden.json +124 -0
  133. package/shared/web-turn-envelope.ts +271 -0
@@ -0,0 +1,536 @@
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
+ import { distinctMeetings, isCalendarHold, type MeetingFields } from './calendar-meetings.js'
26
+
27
+ export const SNAPSHOT_FRESH_MS = 2 * 3600_000
28
+ export const SNAPSHOT_MAX_AGE_MS = 24 * 3600_000
29
+ export const SNAPSHOT_MAX_BYTES = 4_000_000
30
+
31
+ export type CalendarAccess = 'granted' | 'denied' | 'restricted' | 'notDetermined'
32
+ export type CalendarSourceType = 'exchange' | 'caldav' | 'subscription' | 'birthdays' | 'local' | 'other'
33
+
34
+ export interface SnapshotCalendar {
35
+ id: string
36
+ title: string
37
+ account: string | null
38
+ sourceType: CalendarSourceType
39
+ /** Shared or family calendars: events with no attendees there are not the owner's meetings. */
40
+ shared: boolean
41
+ selected: boolean
42
+ }
43
+
44
+ export interface SnapshotAttendee { name: string | null; domain: string | null }
45
+
46
+ export interface SnapshotEvent {
47
+ calendarId: string
48
+ /** EKEvent.eventIdentifier (device-local). */
49
+ eventId: string | null
50
+ /** calendarItemExternalIdentifier (server-side id, stable across devices). */
51
+ externalId: string | null
52
+ /** The occurrence's ORIGINAL start (EKEvent.occurrenceDate), so a moved occurrence keeps its key. */
53
+ occurrenceStart: string
54
+ start: string
55
+ end: string | null
56
+ allDay: boolean
57
+ /** timeZone == nil in EventKit. */
58
+ floating: boolean
59
+ title: string
60
+ myStatus: 'accepted' | 'declined' | 'tentative' | 'pending' | 'none'
61
+ isOrganizer: boolean
62
+ attendees: SnapshotAttendee[]
63
+ /** Allowlisted hosts only (zoom.us, teams.microsoft.com, meet.google.com). */
64
+ meetingUrl: string | null
65
+ /** Today round 2: EventKit's status said canceled (only when the writer sends `status: "canceled"`). */
66
+ canceled?: boolean
67
+ }
68
+
69
+ export interface CalendarSnapshot {
70
+ version: 1
71
+ writtenAt: string
72
+ windowStart: string
73
+ windowEnd: string
74
+ access: CalendarAccess
75
+ calendars: SnapshotCalendar[]
76
+ events: SnapshotEvent[]
77
+ }
78
+
79
+ export type CalendarSourceName = 'snapshot' | 'snapshot-stale' | 'bridge' | 'none'
80
+
81
+ export interface SnapshotRead {
82
+ /** 'missing' means no file: callers fall back to the bridge, as before P1. */
83
+ state: 'missing' | 'invalid' | 'denied' | 'unselected' | 'fresh' | 'aging' | 'expired'
84
+ snapshot: CalendarSnapshot | null
85
+ ageMs: number | null
86
+ }
87
+
88
+ const SOURCE_TYPES: readonly CalendarSourceType[] = ['exchange', 'caldav', 'subscription', 'birthdays', 'local', 'other']
89
+ const STATUSES = ['accepted', 'declined', 'tentative', 'pending', 'none'] as const
90
+ const ACCESS: readonly CalendarAccess[] = ['granted', 'denied', 'restricted', 'notDetermined']
91
+ const MEETING_HOSTS = /^https:\/\/([a-z0-9-]+\.)*(zoom\.us|teams\.microsoft\.com|meet\.google\.com)(\/|$)/i
92
+
93
+ export function snapshotPath(): string {
94
+ return dataPath('calendar', 'snapshot.json')
95
+ }
96
+
97
+ const str = (v: unknown, max = 512): string | null => typeof v === 'string' && v.length > 0 && v.length <= max ? v : null
98
+ const iso = (v: unknown): string | null => {
99
+ if (typeof v !== 'string' || v.length > 64) return null
100
+ const t = Date.parse(v)
101
+ return Number.isFinite(t) ? new Date(t).toISOString() : null
102
+ }
103
+
104
+ /** Validate an untrusted snapshot. Bad rows are dropped; a bad envelope rejects the whole file. */
105
+ export function parseSnapshot(raw: unknown): CalendarSnapshot | null {
106
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
107
+ const o = raw as Record<string, unknown>
108
+ if (o.version !== 1) return null
109
+ const writtenAt = iso(o.writtenAt), windowStart = iso(o.windowStart), windowEnd = iso(o.windowEnd)
110
+ const access = ACCESS.includes(o.access as CalendarAccess) ? o.access as CalendarAccess : null
111
+ if (!writtenAt || !access) return null
112
+ if (access !== 'granted') {
113
+ // A tombstone carries no calendars or events, whatever the file says.
114
+ return { version: 1, writtenAt, windowStart: windowStart ?? writtenAt, windowEnd: windowEnd ?? writtenAt, access, calendars: [], events: [] }
115
+ }
116
+ if (!windowStart || !windowEnd || !Array.isArray(o.calendars) || !Array.isArray(o.events)) return null
117
+ const calendars: SnapshotCalendar[] = []
118
+ for (const c of o.calendars.slice(0, 500)) {
119
+ if (!c || typeof c !== 'object') continue
120
+ const r = c as Record<string, unknown>
121
+ const id = str(r.id), title = str(r.title, 300)
122
+ if (!id || title === null) continue
123
+ calendars.push({
124
+ id,
125
+ title,
126
+ account: str(r.account, 300),
127
+ sourceType: SOURCE_TYPES.includes(r.sourceType as CalendarSourceType) ? r.sourceType as CalendarSourceType : 'other',
128
+ shared: r.shared === true,
129
+ selected: r.selected === true,
130
+ })
131
+ }
132
+ const known = new Set(calendars.map(c => c.id))
133
+ const events: SnapshotEvent[] = []
134
+ for (const e of o.events.slice(0, 5000)) {
135
+ if (!e || typeof e !== 'object') continue
136
+ const r = e as Record<string, unknown>
137
+ const calendarId = str(r.calendarId), start = iso(r.start), occurrenceStart = iso(r.occurrenceStart) ?? start
138
+ const title = typeof r.title === 'string' ? r.title.slice(0, 500) : null
139
+ if (!calendarId || !known.has(calendarId) || !start || !occurrenceStart || title === null) continue
140
+ const attendees = Array.isArray(r.attendees)
141
+ ? r.attendees.slice(0, 200).flatMap((a): SnapshotAttendee[] => {
142
+ if (!a || typeof a !== 'object') return []
143
+ const x = a as Record<string, unknown>
144
+ // macOS fills a participant's name with their address when there is no display name. Names only, never an
145
+ // address: a name that looks like one is dropped (the domain stays). Same rule as Control's attendeeName.
146
+ const name = str(x.name, 200)
147
+ return [{ name: name && !name.includes('@') ? name : null, domain: str(x.domain, 253) }]
148
+ })
149
+ : []
150
+ const url = typeof r.meetingUrl === 'string' && r.meetingUrl.length <= 2048 && MEETING_HOSTS.test(r.meetingUrl) ? r.meetingUrl : null
151
+ events.push({
152
+ calendarId,
153
+ eventId: str(r.eventId),
154
+ externalId: str(r.externalId),
155
+ occurrenceStart,
156
+ start,
157
+ end: iso(r.end),
158
+ allDay: r.allDay === true,
159
+ floating: r.floating === true,
160
+ title,
161
+ myStatus: STATUSES.includes(r.myStatus as never) ? r.myStatus as SnapshotEvent['myStatus'] : 'none',
162
+ isOrganizer: r.isOrganizer === true,
163
+ attendees,
164
+ meetingUrl: url,
165
+ ...(typeof r.status === 'string' && /^cancel+ed$/i.test(r.status.trim()) ? { canceled: true } : {}),
166
+ })
167
+ }
168
+ return { version: 1, writtenAt, windowStart, windowEnd, access, calendars, events }
169
+ }
170
+
171
+ let cache: { mtimeMs: number; size: number; snapshot: CalendarSnapshot | null } | null = null
172
+
173
+ /** Test seam. */
174
+ export function resetCalendarSourceCache(): void { cache = null }
175
+
176
+ /**
177
+ * Server-side switch: COS_CALENDAR_SNAPSHOT=0 ignores the snapshot and uses the bridge as before. On by default; only
178
+ * `0` (after trimming) turns it off, so `false` or `off` leave it on. QA W21: calendar_context.py once read the
179
+ * snapshot only when the switch was 1, the opposite default. Its `snapshot_reading_enabled` now has this polarity, and
180
+ * calendar-source.test.ts runs one table against both readers.
181
+ */
182
+ export function snapshotReadingEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
183
+ return env.COS_CALENDAR_SNAPSHOT?.trim() !== '0'
184
+ }
185
+
186
+ export function readSnapshot(now = Date.now(), path = snapshotPath()): SnapshotRead {
187
+ if (!snapshotReadingEnabled()) return { state: 'missing', snapshot: null, ageMs: null }
188
+ let st
189
+ try { st = lstatSync(path) } catch { return { state: 'missing', snapshot: null, ageMs: null } }
190
+ if (!st.isFile() || st.size > SNAPSHOT_MAX_BYTES) return { state: 'invalid', snapshot: null, ageMs: null }
191
+ let snapshot: CalendarSnapshot | null
192
+ if (cache && cache.mtimeMs === st.mtimeMs && cache.size === st.size) {
193
+ snapshot = cache.snapshot
194
+ } else {
195
+ try { snapshot = parseSnapshot(JSON.parse(readFileSync(path, 'utf8'))) } catch { snapshot = null }
196
+ cache = { mtimeMs: st.mtimeMs, size: st.size, snapshot }
197
+ }
198
+ if (!snapshot) return { state: 'invalid', snapshot: null, ageMs: null }
199
+ const ageMs = Math.max(0, now - Date.parse(snapshot.writtenAt))
200
+ if (snapshot.access !== 'granted') return { state: 'denied', snapshot, ageMs }
201
+ // QA W20: granted with nothing picked is not configured. Keep the previous source until the first selection.
202
+ if (!snapshot.calendars.some(c => c.selected)) return { state: 'unselected', snapshot, ageMs }
203
+ if (ageMs > SNAPSHOT_MAX_AGE_MS || now > Date.parse(snapshot.windowEnd)) return { state: 'expired', snapshot, ageMs }
204
+ if (ageMs <= SNAPSHOT_FRESH_MS) return { state: 'fresh', snapshot, ageMs }
205
+ return { state: 'aging', snapshot, ageMs }
206
+ }
207
+
208
+ // ---------------------------------------------------------------------------
209
+ // Selection, dedupe and the prep filter
210
+ // ---------------------------------------------------------------------------
211
+
212
+ const HOLIDAY_OR_BIRTHDAY = /\b(holidays?|birthdays?)\b/i
213
+ /** A family calendar under any name ("Family", "Ukaoma Family", "Family calendar"). */
214
+ const FAMILY_TITLE = /\bfamily\b/i
215
+ /** Travel-shaped titles: the bridge's own `looks_like_flight` (calendar_context CalendarEvent.to_dict), case-folded. */
216
+ const TRAVEL_TITLE = /\bflight\b|\b(wn|aa|ua|dl|as|b6|nk|f9)\s*\d{2,4}\b/i
217
+
218
+ /** A calendar other people write to: shared, a family calendar by name. Its events are not the owner's by default. */
219
+ export function isFamilyCalendar(calendar: SnapshotCalendar): boolean {
220
+ return calendar.shared || FAMILY_TITLE.test(calendar.title)
221
+ }
222
+
223
+ export function isTravelShaped(e: SnapshotEvent): boolean {
224
+ return TRAVEL_TITLE.test(e.title)
225
+ }
226
+
227
+ /** Events from selected calendars only, deduped across accounts. Order: start time. */
228
+ export function selectedEvents(snapshot: CalendarSnapshot): SnapshotEvent[] {
229
+ const selected = new Set(snapshot.calendars.filter(c => c.selected).map(c => c.id))
230
+ const rows = snapshot.events.filter(e => selected.has(e.calendarId))
231
+ rows.sort((a, b) => Date.parse(a.start) - Date.parse(b.start))
232
+ return dedupeEvents(rows)
233
+ }
234
+
235
+ /**
236
+ * The same meeting invited on Exchange and on Gmail shows up twice. Merge only a true duplicate of the same meeting on
237
+ * another calendar: the external id and occurrence match, or the title (trimmed, case-folded), start and end all match
238
+ * AND the two share a named attendee or the same meeting link. Two events from the SAME calendar are never merged: two
239
+ * 10:00 "Busy" holds are two holds. QA 7.0.0: the shape fallback once keyed on the set of attendee DOMAINS, so two
240
+ * different "Weekly sync" meetings at one time with different people from one company merged into one.
241
+ */
242
+ export function dedupeEvents(rows: SnapshotEvent[]): SnapshotEvent[] {
243
+ const out: SnapshotEvent[] = []
244
+ const byExternal = new Map<string, SnapshotEvent>()
245
+ const byShape = new Map<string, SnapshotEvent[]>()
246
+ for (const e of rows) {
247
+ const extKey = e.externalId ? `${e.externalId}|${e.occurrenceStart}` : null
248
+ const shapeKey = `${e.title.trim().toLowerCase()}|${e.start}|${e.end ?? ''}`
249
+ const byId = extKey ? byExternal.get(extKey) : undefined
250
+ const dup = (byId && byId.calendarId !== e.calendarId) ||
251
+ (byShape.get(shapeKey) ?? []).some(p => p.calendarId !== e.calendarId && sameMeeting(p, e))
252
+ if (dup) continue
253
+ out.push(e)
254
+ if (extKey && !byExternal.has(extKey)) byExternal.set(extKey, e)
255
+ const bucket = byShape.get(shapeKey)
256
+ if (bucket) bucket.push(e); else byShape.set(shapeKey, [e])
257
+ }
258
+ return out
259
+ }
260
+
261
+ /** Same-shaped events that are one meeting: a shared named attendee (name and domain) or the same meeting link. */
262
+ function sameMeeting(a: SnapshotEvent, b: SnapshotEvent): boolean {
263
+ const la = meetingLink(a.meetingUrl), lb = meetingLink(b.meetingUrl)
264
+ if (la && la === lb) return true
265
+ const named = (e: SnapshotEvent) => new Set(e.attendees.filter(x => !!x.name && !!x.name.trim()).map(x => `${x.name!.trim().toLowerCase()}|${(x.domain ?? '').toLowerCase()}`))
266
+ const na = named(a)
267
+ if (!na.size) return false
268
+ for (const k of named(b)) if (na.has(k)) return true
269
+ return false
270
+ }
271
+
272
+ /** A meeting link without its query or fragment (a Zoom pwd, a tracking tag), lower-cased host, no trailing slash. */
273
+ function meetingLink(url: string | null): string | null {
274
+ if (!url) return null
275
+ try {
276
+ const u = new URL(url)
277
+ return `${u.protocol}//${u.host.toLowerCase()}${u.pathname.replace(/\/+$/, '')}`
278
+ } catch { return null }
279
+ }
280
+
281
+ export function isMultiDay(e: SnapshotEvent): boolean {
282
+ if (!e.end) return false
283
+ return Date.parse(e.end) - Date.parse(e.start) > 24 * 3600_000
284
+ }
285
+
286
+ /**
287
+ * Whether an event may become next(), prep, Today's list or Meeting Ask's match. Declined, all-day, multi-day,
288
+ * birthday and holiday calendars, and shared-calendar events with no attendees never qualify (shared-calendar flights
289
+ * are not the owner's travel).
290
+ *
291
+ * QA W23, the family rule. It was an exact "Family" title or a shared calendar, so a flight on another editable
292
+ * calendar (iCloud "Calendar", a TripIt import) still qualified. Now:
293
+ * - a family calendar matches by word ("Ukaoma Family"), not by exact title;
294
+ * - a travel-shaped event (the bridge's flight rule) with no attendees never qualifies, on any calendar;
295
+ * - a travel-shaped event on a family or shared calendar never qualifies, even with attendees (a flight a relative
296
+ * invited the owner to is still their travel, not a meeting).
297
+ * A non-travel event with real attendees on a family calendar (a school meeting) still qualifies, and an
298
+ * attendee-less hold on the owner's own calendar is unchanged.
299
+ */
300
+ export function isPrepWorthy(e: SnapshotEvent, calendar: SnapshotCalendar | undefined): boolean {
301
+ if (e.myStatus === 'declined' || e.allDay || isMultiDay(e)) return false
302
+ if (!calendar) return false
303
+ if (calendar.sourceType === 'birthdays' || HOLIDAY_OR_BIRTHDAY.test(calendar.title)) return false
304
+ const sharedLike = isFamilyCalendar(calendar) || calendar.sourceType === 'subscription'
305
+ const travel = isTravelShaped(e)
306
+ if (sharedLike && (travel || e.attendees.length === 0)) return false
307
+ if (travel && e.attendees.length === 0) return false
308
+ return true
309
+ }
310
+
311
+ // ---------------------------------------------------------------------------
312
+ // Bridge-shaped output
313
+ // ---------------------------------------------------------------------------
314
+
315
+ /**
316
+ * One event in the starter-kit bridge's dict shape, plus the occurrence fields bridgeOccurrence reads.
317
+ *
318
+ * `domain` and `matched_person` stay null here: the bridge's classifier (people_context.classify_event_by_attendees)
319
+ * lives in Python people context, which this standalone server does not have. Meeting Ask, the one caller that needs a
320
+ * domain, asks the bridge's `calendar-classify` for its candidates (QA W21, meeting-ask-orientation.ts).
321
+ */
322
+ export function bridgeEvent(e: SnapshotEvent, calendar: SnapshotCalendar | undefined): Record<string, unknown> {
323
+ const start = Date.parse(e.start)
324
+ const end = e.end ? Date.parse(e.end) : NaN
325
+ return {
326
+ title: e.title,
327
+ start_time: e.start,
328
+ end_time: e.end,
329
+ location: null,
330
+ is_all_day: e.allDay,
331
+ duration_minutes: e.allDay || !Number.isFinite(end) ? 60 : Math.round((end - start) / 60_000),
332
+ domain: null,
333
+ matched_person: null,
334
+ attendees: e.attendees.map(a => a.name).filter((n): n is string => !!n),
335
+ calendar_name: calendar?.title ?? null,
336
+ // The bridge's ownership words: family calendars, then travel it cannot verify, then the owner's own.
337
+ ownership: calendar && isFamilyCalendar(calendar) ? 'family_shared' : isTravelShaped(e) && e.attendees.length === 0 ? 'unverified_flight' : 'miles',
338
+ meeting_url: e.meetingUrl,
339
+ prep_worthy: isPrepWorthy(e, calendar),
340
+ // Occurrence identity for prep (welcome-context bridgeOccurrence reads these keys).
341
+ provider: 'eventkit',
342
+ account: calendar?.account ?? null,
343
+ calendar_id: e.calendarId,
344
+ event_id: e.eventId,
345
+ external_id: e.externalId,
346
+ occurrence_start: e.occurrenceStart,
347
+ ...(e.canceled ? { canceled: true } : {}),
348
+ }
349
+ }
350
+
351
+ function localDayBounds(now: number): { start: number; end: number } {
352
+ const d = new Date(now)
353
+ const start = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime()
354
+ const end = new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1).getTime()
355
+ return { start, end }
356
+ }
357
+
358
+ /** The bridge `calendar` dict, built from a snapshot. `stale` marks an aging snapshot used because the bridge is down. */
359
+ export function snapshotToBridgeShape(snapshot: CalendarSnapshot, now = Date.now(), stale = false): Record<string, unknown> {
360
+ const cals = new Map(snapshot.calendars.map(c => [c.id, c]))
361
+ const { start: dayStart, end: dayEnd } = localDayBounds(now)
362
+ const events = selectedEvents(snapshot)
363
+ const endOf = (e: SnapshotEvent) => (e.end ? Date.parse(e.end) : Date.parse(e.start) + 3600_000)
364
+ const today = events.filter(e => { const s = Date.parse(e.start); return s >= dayStart && s < dayEnd })
365
+ const remaining = today.filter(e => endOf(e) > now)
366
+ const timed = (e: SnapshotEvent) => isPrepWorthy(e, cals.get(e.calendarId))
367
+ const current = remaining.find(e => timed(e) && Date.parse(e.start) <= now && endOf(e) > now) ?? null
368
+ const next = events.find(e => timed(e) && Date.parse(e.start) > now) ?? null
369
+ // 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.
370
+ const tomorrowEnd = localDayBounds(dayEnd).end
371
+ const tomorrow = events.filter(e => { const s = Date.parse(e.start); return s >= dayEnd && s < tomorrowEnd })
372
+ const asDict = (e: SnapshotEvent) => bridgeEvent(e, cals.get(e.calendarId))
373
+ const ageMinutes = Math.max(0, (now - Date.parse(snapshot.writtenAt)) / 60_000)
374
+ return {
375
+ current_event: current ? asDict(current) : null,
376
+ next_event: next ? asDict(next) : null,
377
+ minutes_until_next: next ? Math.max(0, Math.round((Date.parse(next.start) - now) / 60_000)) : null,
378
+ today_events: today.map(asDict),
379
+ today_remaining: remaining.map(asDict),
380
+ tomorrow_events_count: tomorrow.length,
381
+ week_events_count: events.filter(e => Date.parse(e.start) >= dayStart).length,
382
+ meetings_today_count: today.length,
383
+ meetings_remaining_count: remaining.length,
384
+ is_in_meeting: current !== null,
385
+ free_until: next ? next.start : null,
386
+ data_source: stale ? 'eventkit_stale' : 'eventkit',
387
+ cached_at: snapshot.writtenAt,
388
+ cache_age_minutes: Math.round(ageMinutes * 10) / 10,
389
+ is_stale: stale,
390
+ }
391
+ }
392
+
393
+ /** The no-calendar answer. `error` makes calendarAnswerWasRead() report "not connected", never an empty day. */
394
+ export function noCalendarShape(reason: string): Record<string, unknown> {
395
+ return { error: reason, today_events: [], today_remaining: [], next_event: null, meetings_today_count: 0, meetings_remaining_count: 0, data_source: 'none', is_stale: false }
396
+ }
397
+
398
+ export interface CalendarRead {
399
+ source: CalendarSourceName
400
+ data: unknown
401
+ /** Snapshot age when the snapshot was used or considered. */
402
+ ageMs: number | null
403
+ }
404
+
405
+ let lastSource: CalendarSourceName | null = null
406
+
407
+ /**
408
+ * The one call the four callers make instead of callPython(['calendar']).
409
+ * Returns the bridge dict shape whichever source answered.
410
+ */
411
+ export async function readCalendar(timeoutMs = 30_000, now = Date.now()): Promise<CalendarRead> {
412
+ const snap = readSnapshot(now)
413
+ const done = (r: CalendarRead): CalendarRead => { lastSource = r.source; return r }
414
+ if (snap.state === 'missing' || snap.state === 'invalid' || snap.state === 'unselected') {
415
+ // Pre-P1 behaviour, unchanged. Unselected (QA W20): Connect with nothing picked keeps this source too.
416
+ return done({ source: 'bridge', data: await callPython(['calendar'], timeoutMs), ageMs: null })
417
+ }
418
+ if (snap.state === 'denied') return done({ source: 'none', data: noCalendarShape('calendar_access_denied'), ageMs: snap.ageMs })
419
+ if (snap.state === 'fresh') return done({ source: 'snapshot', data: snapshotToBridgeShape(snap.snapshot!, now), ageMs: snap.ageMs })
420
+ // aging or expired: the bridge wins when it really reads.
421
+ if (pythonBridgeAvailable()) {
422
+ try {
423
+ const data = await callPython(['calendar'], timeoutMs)
424
+ if (data && typeof data === 'object' && !Array.isArray(data) && !('error' in data)) {
425
+ return done({ source: 'bridge', data, ageMs: snap.ageMs })
426
+ }
427
+ } catch { /* fall through to the labeled snapshot */ }
428
+ }
429
+ if (snap.state === 'aging') return done({ source: 'snapshot-stale', data: snapshotToBridgeShape(snap.snapshot!, now, true), ageMs: snap.ageMs })
430
+ return done({ source: 'none', data: noCalendarShape('calendar_snapshot_expired'), ageMs: snap.ageMs })
431
+ }
432
+
433
+ /** Counts only, for the authenticated /api/models capabilities. Never titles. */
434
+ export function calendarStatus(now = Date.now()): {
435
+ source: CalendarSourceName | 'unknown'
436
+ snapshot: SnapshotRead['state']
437
+ ageMs: number | null
438
+ access: CalendarAccess | null
439
+ calendars: number
440
+ selected: number
441
+ } {
442
+ const snap = readSnapshot(now)
443
+ return {
444
+ source: lastSource ?? 'unknown',
445
+ snapshot: snap.state,
446
+ ageMs: snap.ageMs,
447
+ access: snap.snapshot?.access ?? null,
448
+ calendars: snap.snapshot?.calendars.length ?? 0,
449
+ selected: snap.snapshot?.calendars.filter(c => c.selected).length ?? 0,
450
+ }
451
+ }
452
+
453
+ /**
454
+ * A bridge-shaped row that may be listed as a meeting: timed, and not marked unworthy by the prep filter. A bridge row
455
+ * without prep_worthy counts (the bridge already drops declined and skips all-day). Today and Meeting Ask both use it.
456
+ */
457
+ export function countsAsMeeting(row: Record<string, unknown> | null | undefined): boolean {
458
+ return !!row && row.is_all_day !== true && row.prep_worthy !== false
459
+ }
460
+
461
+ /**
462
+ * Today for Control Web and desktop: selected calendars only, the next 5 timed meetings that count (Today round 2,
463
+ * Miles 2026-11-09: was 3). QA 7.0.0: all-day and non-prep events (declined, holidays, attendee-less shared events) are
464
+ * filtered out BEFORE the 5 are taken, so they never use up the slots Today's "Later" shows. QA W23: remainingCount
465
+ * counts the same rows, so a declined meeting or a family flight is not "remaining".
466
+ *
467
+ * Today round 2: `endedRecently` lists the meetings that count which ended within the last CALENDAR_ENDED_RECENT_MS
468
+ * (2 h), newest end first, at most CALENDAR_ENDED_MAX. They come from `today_events` (the snapshot's and the bridge's
469
+ * own list of every event today, past ones included), with the same end rule the snapshot uses (no end: an hour after
470
+ * the start). The Today summary uses it to know a meeting is over, so its prep is past. Additive: the older fields keep
471
+ * their names and meaning (desktop Control 0.5.384 reads next, remainingCount, stale and asOf).
472
+ *
473
+ * Today round 2 follow-up: canceled events and duplicates (the same title at the same start) are dropped BEFORE next,
474
+ * remainingCount and endedRecently are taken (calendar-meetings.ts, the helper the Today summary uses too), so
475
+ * remainingCount counts real, distinct meetings and no slot is spent twice on one.
476
+ */
477
+ export const CALENDAR_NEXT_MAX = 5
478
+ export const CALENDAR_ENDED_RECENT_MS = 2 * 3600_000
479
+ export const CALENDAR_ENDED_MAX = 5
480
+ /** `hold` (Prep on every Next up row, 11/09): a hold or status block by its title (calendar-meetings.ts isCalendarHold). */
481
+ export interface CalendarTodayRow { title: string; start: string; end: string | null; allDay: boolean; calendar: string | null; meetingUrl: string | null; hold: boolean }
482
+ export interface CalendarEndedRow { title: string; start: string; end: string }
483
+ export interface CalendarTodayView {
484
+ connected: boolean
485
+ source: CalendarSourceName
486
+ asOf: string | null
487
+ stale: boolean
488
+ remainingCount: number
489
+ next: CalendarTodayRow[]
490
+ endedRecently: CalendarEndedRow[]
491
+ }
492
+ export async function calendarToday(now = Date.now()): Promise<CalendarTodayView> {
493
+ const read = await readCalendar(8_000, now)
494
+ const data = read.data as Record<string, unknown> | null
495
+ const connected = !!data && typeof data === 'object' && !('error' in data) && (read.source !== 'bridge' || pythonBridgeAvailable())
496
+ const rows = connected && Array.isArray(data!.today_remaining) ? data!.today_remaining as Array<Record<string, unknown>> : []
497
+ const counted = distinctMeetings(rows.filter(countsAsMeeting), bridgeMeetingFields)
498
+ const next = counted.slice(0, CALENDAR_NEXT_MAX).map(r => ({
499
+ title: typeof r.title === 'string' ? r.title : 'Event',
500
+ start: String(r.start_time ?? ''),
501
+ end: typeof r.end_time === 'string' ? r.end_time : null,
502
+ allDay: r.is_all_day === true,
503
+ calendar: typeof r.calendar_name === 'string' ? r.calendar_name : null,
504
+ meetingUrl: typeof r.meeting_url === 'string' ? r.meeting_url : null,
505
+ hold: isCalendarHold(r.title),
506
+ }))
507
+ const all = connected && Array.isArray(data!.today_events) ? data!.today_events as Array<Record<string, unknown>> : []
508
+ return {
509
+ connected,
510
+ source: read.source,
511
+ asOf: typeof data?.cached_at === 'string' ? data.cached_at : null,
512
+ stale: data?.is_stale === true,
513
+ remainingCount: connected ? counted.length : 0,
514
+ next,
515
+ endedRecently: endedRecently(all, now),
516
+ }
517
+ }
518
+
519
+ /** A bridge-shaped row's meeting identity, for calendar-meetings.ts. */
520
+ export function bridgeMeetingFields(r: Record<string, unknown>): MeetingFields {
521
+ return { title: r.title, start: r.start_time, status: r.status, canceled: r.canceled }
522
+ }
523
+
524
+ /** Distinct, not canceled meetings that count and ended within CALENDAR_ENDED_RECENT_MS of `now`, newest end first. */
525
+ export function endedRecently(rows: Array<Record<string, unknown>>, now: number): CalendarEndedRow[] {
526
+ const out: Array<CalendarEndedRow & { endMs: number }> = []
527
+ for (const r of distinctMeetings(rows.filter(countsAsMeeting), bridgeMeetingFields)) {
528
+ const startMs = Date.parse(String(r.start_time ?? ''))
529
+ if (!Number.isFinite(startMs)) continue
530
+ const endRaw = typeof r.end_time === 'string' && r.end_time ? Date.parse(r.end_time) : NaN
531
+ const endMs = Number.isFinite(endRaw) ? endRaw : startMs + 3600_000
532
+ if (endMs > now || now - endMs > CALENDAR_ENDED_RECENT_MS) continue
533
+ out.push({ title: typeof r.title === 'string' ? r.title : 'Event', start: new Date(startMs).toISOString(), end: new Date(endMs).toISOString(), endMs })
534
+ }
535
+ return out.sort((a, b) => b.endMs - a.endMs).slice(0, CALENDAR_ENDED_MAX).map(({ endMs: _e, ...r }) => r)
536
+ }