@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.
- package/.env.example +37 -0
- package/CHANGELOG.md +587 -0
- package/README.md +3 -1
- package/docs/web-key-verifier-contract.md +378 -0
- package/managed-runtime-contract.json +12 -1
- package/package.json +10 -3
- package/server/bootstrap.ts +10 -0
- package/server/index.ts +155 -30
- package/server/lib/agent-session-output-images.ts +627 -49
- package/server/lib/agent-session-recent.ts +321 -0
- package/server/lib/agent-session-store.ts +27 -14
- package/server/lib/agent-session-tool-images.ts +224 -0
- package/server/lib/agent-session-turns.ts +19 -2
- package/server/lib/api-auth.ts +43 -1
- package/server/lib/ask-work-plan.ts +3 -1
- package/server/lib/calendar-meetings.ts +70 -0
- package/server/lib/calendar-source.ts +536 -0
- package/server/lib/claude-hooks-installer.ts +111 -12
- package/server/lib/codex-bridge.ts +4 -1
- package/server/lib/codex-run-ledger.ts +29 -0
- package/server/lib/context-builder.ts +2 -1
- package/server/lib/conversation.ts +56 -28
- package/server/lib/cors-reject-log.ts +51 -0
- package/server/lib/disk-io-meter.ts +202 -0
- package/server/lib/drop-p2-runtime.ts +1 -1
- package/server/lib/drop-service.ts +47 -12
- package/server/lib/drop-store.ts +31 -5
- package/server/lib/event-loop-stall.ts +62 -0
- package/server/lib/full-hold-engine.ts +114 -0
- package/server/lib/full-hold.ts +124 -0
- package/server/lib/g2-turn-provenance.ts +128 -9
- package/server/lib/learning-needs-you.ts +24 -7
- package/server/lib/listener-startup.ts +18 -0
- package/server/lib/live-events.ts +533 -0
- package/server/lib/local-path-redact.ts +272 -0
- package/server/lib/meeting-ask-orientation.ts +40 -6
- package/server/lib/meeting-ask-runner.ts +30 -10
- package/server/lib/meeting-batch-transcribe.ts +32 -5
- package/server/lib/morning-brief-runtime.ts +4 -2
- package/server/lib/native-mac-caller.ts +29 -0
- package/server/lib/permission-broker.ts +443 -11
- package/server/lib/ping-engine.ts +1 -1
- package/server/lib/ping-sources.ts +11 -6
- package/server/lib/preview-content.ts +172 -0
- package/server/lib/preview-error-code.ts +10 -0
- package/server/lib/preview-folders-setting.ts +133 -0
- package/server/lib/preview-log-throttle.ts +18 -0
- package/server/lib/preview-thumbs.ts +139 -0
- package/server/lib/secret-path-guard.ts +83 -0
- package/server/lib/session-cache-writer.ts +8 -1
- package/server/lib/session-files-setting.ts +88 -0
- package/server/lib/session-hook-events.ts +23 -1
- package/server/lib/session-hooks-runtime.ts +34 -0
- package/server/lib/session-links.ts +280 -0
- package/server/lib/session-log.ts +313 -4
- package/server/lib/session-state-derive.ts +36 -0
- package/server/lib/session-stream-events.ts +25 -1
- package/server/lib/thread-turn-queue.ts +10 -0
- package/server/lib/today-brief.ts +640 -85
- package/server/lib/turn-end-ask.ts +320 -0
- package/server/lib/turn-footer.ts +3 -3
- package/server/lib/web-control-integrity.ts +160 -0
- package/server/lib/web-control-offnet-page.ts +58 -0
- package/server/lib/web-control-static.ts +53 -9
- package/server/lib/web-key-authority.ts +372 -0
- package/server/lib/web-key-envelope.ts +98 -0
- package/server/lib/web-key-handoff.ts +135 -0
- package/server/lib/web-key-origin.ts +21 -0
- package/server/lib/web-key-passkey.ts +155 -0
- package/server/lib/web-key-registry.ts +825 -0
- package/server/lib/web-key-signer-app.ts +152 -0
- package/server/lib/web-memories-bridge.ts +3 -1
- package/server/lib/web-session-auth.ts +133 -15
- package/server/lib/whisper-local.ts +93 -7
- package/server/lib/whisper-preview.ts +5 -3
- package/server/lib/work-activity.ts +56 -6
- package/server/lib/work-orchestrator/snapshot.ts +17 -3
- package/server/lib/work-review-runtime.ts +17 -4
- package/server/lib/work-session-activity.ts +260 -0
- package/server/lib/your-move-count.ts +9 -3
- package/server/routes/agent-session-bindings.ts +29 -1
- package/server/routes/agent-session-stream.ts +23 -0
- package/server/routes/agent-sessions.ts +146 -11
- package/server/routes/ask-work-plan.ts +4 -1
- package/server/routes/claude-sessions.ts +5 -1
- package/server/routes/drops.ts +7 -2
- package/server/routes/g2-turns.ts +35 -0
- package/server/routes/health.ts +46 -1
- package/server/routes/live-events.ts +334 -0
- package/server/routes/permission-broker.ts +95 -5
- package/server/routes/query-jobs.ts +3 -0
- package/server/routes/session-preview.ts +200 -0
- package/server/routes/sessions.ts +15 -24
- package/server/routes/thread-turn-queue.ts +223 -12
- package/server/routes/today-brief.ts +134 -44
- package/server/routes/web-control.ts +37 -4
- package/server/routes/web-key.ts +284 -0
- package/server/routes/welcome-context.ts +28 -4
- package/server/routes/work-board.ts +9 -2
- package/server/routes/work-orchestrator.ts +12 -7
- package/server/scripts/write-web-control-manifest.mjs +43 -0
- package/server/web-control/.manifest.json +44 -0
- package/server/web-control/app.css +76 -1
- package/server/web-control/app.js +2247 -98
- package/server/web-control/apple-touch-icon.png +0 -0
- package/server/web-control/control.css +25 -0
- package/server/web-control/index.html +21 -3
- package/server/web-control/memory-review.js +100 -0
- package/server/web-control/panes/cos-chat.css +94 -9
- package/server/web-control/panes/cos-chat.js +628 -44
- package/server/web-control/panes/meetings.css +18 -2
- package/server/web-control/panes/meetings.js +24 -8
- package/server/web-control/panes/memories.css +17 -2
- package/server/web-control/panes/memories.js +127 -42
- package/server/web-control/panes/messages.js +21 -2
- package/server/web-control/panes/preview.js +318 -0
- package/server/web-control/panes/questions.js +162 -21
- package/server/web-control/panes/reader-media.js +409 -23
- package/server/web-control/panes/sessions.css +32 -3
- package/server/web-control/panes/settings.js +97 -6
- package/server/web-control/panes/today-actions.css +46 -0
- package/server/web-control/panes/today-actions.js +119 -17
- package/server/web-control/panes/work.css +13 -0
- package/server/web-control/panes/work.js +188 -37
- package/server/web-control/shell.js +17 -14
- package/server/web-signer/.manifest.json +11 -0
- package/server/web-signer/index.html +74 -0
- package/server/web-signer/signer-proof.js +348 -0
- package/server/web-signer/signer-store.js +39 -0
- package/server/web-signer/signer.css +23 -0
- package/server/web-signer/signer.js +341 -0
- package/shared/web-approval-packets.golden.json +124 -0
- 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
|
+
}
|