@gotcos/glasses-server 6.42.1 → 6.43.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,203 @@
1
+ // Morning brief — the one production scheduler, wired to the real coordinator.
2
+ //
3
+ // Mirrors query-job-runtime.ts: the module owns the singleton so the router,
4
+ // health, and index.ts all see the same instance, and tests construct their
5
+ // own MorningBriefScheduler with fake deps instead of importing this file.
6
+
7
+ import { existsSync, readFileSync, statSync } from 'node:fs'
8
+ import { homedir } from 'node:os'
9
+ import { basename, join, resolve } from 'node:path'
10
+ import { getActiveSessions, createSession } from './conversation.js'
11
+ import { currentMessageEra, exchangeBelongsToEra } from './message-era.js'
12
+ import { maxGlobalMsgNumInDir } from '../routes/message-ref.js'
13
+ import { dataPath } from './data-dir.js'
14
+ import { getOwnerName } from './profile.js'
15
+ import { durableQueryJobsEnabled } from './query-job-feature.js'
16
+ import { queryJobCoordinator } from './query-job-runtime.js'
17
+ import { maintenanceAdmissionsOpen } from './maintenance-lifecycle.js'
18
+ import { maxReservedGlobalMsgNum, registerMessageReservationSource } from './message-reservations.js'
19
+ import { morningBriefPaths } from './morning-brief-config.js'
20
+ import { MorningBriefScheduler } from './morning-brief-scheduler.js'
21
+ import {
22
+ MorningBriefCoverageService,
23
+ type CalendarProbe,
24
+ type ContextProbe,
25
+ type MeetingsProbe,
26
+ type ReflectionProbe,
27
+ type SkillProbe,
28
+ type TasksProbe,
29
+ } from './morning-brief-coverage.js'
30
+ import {
31
+ listCosOperationsMeetingDays,
32
+ listCosOperationsMeetingMonths,
33
+ listDirectLibraryMeetingDays,
34
+ listDirectLibraryMeetingMonths,
35
+ resolveMeetingLibrary,
36
+ } from './cos-operations-meetings.js'
37
+ import { getMeetingStore } from './meeting-store.js'
38
+ import { COS_SCRIPTS_DIR, callPython, contextSourceAvailable, pythonBridgeAvailable } from './python-bridge.js'
39
+ import { resolveProviderWorkDir } from './launch-dir.js'
40
+
41
+ /** Highest stamped message number in the active era: live sessions, the day
42
+ * archives, AND every number a not-yet-projected job or brief already holds.
43
+ * The same arithmetic /api/message-counter serves the phone. */
44
+ export function currentMessageMax(): number {
45
+ const era = currentMessageEra()
46
+ let liveMax = 0
47
+ for (const session of getActiveSessions()) {
48
+ for (const exchange of (session as { exchanges?: Array<{ globalMsgNum?: unknown; messageEra?: unknown }> }).exchanges ?? []) {
49
+ if (!exchangeBelongsToEra(exchange, era)) continue
50
+ if (typeof exchange?.globalMsgNum === 'number' && exchange.globalMsgNum > liveMax) liveMax = exchange.globalMsgNum
51
+ }
52
+ }
53
+ return Math.max(liveMax, maxGlobalMsgNumInDir(dataPath('archive'), era), maxReservedGlobalMsgNum(era))
54
+ }
55
+
56
+ // ── Coverage probes (the same wells COS Control's Activity tiles read) ────────
57
+
58
+ function sumDayCounts(months: string[], days: (month: string) => Array<{ count: number }>): number {
59
+ let total = 0
60
+ for (const month of months) for (const day of days(month)) total += day.count
61
+ return total
62
+ }
63
+
64
+ export function probeMeetings(): MeetingsProbe | null {
65
+ const library = resolveMeetingLibrary()
66
+ if (library.layout === 'direct') {
67
+ const months = listDirectLibraryMeetingMonths()
68
+ return { count: sumDayCounts(months, listDirectLibraryMeetingDays), newestMonth: months[0] ?? null, layout: library.layout }
69
+ }
70
+ if (library.layout === 'multi_domain') {
71
+ const months = listCosOperationsMeetingMonths('all')
72
+ return { count: sumDayCounts(months, month => listCosOperationsMeetingDays(month, 'all')), newestMonth: months[0] ?? null, layout: library.layout }
73
+ }
74
+ if (library.layout === 'invalid_explicit_root') return null
75
+ const store = getMeetingStore()
76
+ const months = store.listMonths()
77
+ return { count: sumDayCounts(months, month => store.listDayCounts(month)), newestMonth: months[0] ?? null, layout: 'standalone' }
78
+ }
79
+
80
+ export async function probeContext(): Promise<ContextProbe | null> {
81
+ if (contextSourceAvailable() === null) return null
82
+ const data = await callPython(['context-status'], 8_000) as {
83
+ memory?: { available?: unknown; total?: unknown; state?: unknown; reason?: unknown }
84
+ threads?: { available?: unknown; total?: unknown; active?: unknown; state?: unknown; reason?: unknown }
85
+ } | null
86
+ if (!data || typeof data !== 'object') return null
87
+ const num = (value: unknown) => typeof value === 'number' && Number.isFinite(value) ? value : 0
88
+ const state = (part?: { state?: unknown; reason?: unknown }) =>
89
+ typeof part?.state === 'string' ? part.state : typeof part?.reason === 'string' ? part.reason : undefined
90
+ return {
91
+ memory: { available: data.memory?.available === true, total: num(data.memory?.total), state: state(data.memory) },
92
+ threads: { available: data.threads?.available === true, total: num(data.threads?.total), active: num(data.threads?.active), state: state(data.threads) },
93
+ }
94
+ }
95
+
96
+ export async function probeCalendar(): Promise<CalendarProbe | null> {
97
+ if (!pythonBridgeAvailable()) return null
98
+ const data = await callPython(['calendar'], 8_000) as { meetings_today_count?: unknown; today_events?: unknown; data_source?: unknown } | null
99
+ if (!data || typeof data !== 'object') return null
100
+ const today = typeof data.meetings_today_count === 'number'
101
+ ? data.meetings_today_count
102
+ : Array.isArray(data.today_events) ? data.today_events.length : 0
103
+ return { todayCount: Math.max(0, Math.trunc(today)), ...(typeof data.data_source === 'string' ? { source: data.data_source } : {}) }
104
+ }
105
+
106
+ export async function probeTasks(): Promise<TasksProbe | null> {
107
+ if (!pythonBridgeAvailable()) return null
108
+ const data = await callPython(['tasks'], 8_000) as Record<string, unknown> | null
109
+ if (!data || typeof data !== 'object') return null
110
+ let open = 0
111
+ let files = 0
112
+ for (const rows of Object.values(data)) {
113
+ if (!Array.isArray(rows)) continue
114
+ files += 1
115
+ for (const row of rows) {
116
+ if (row && typeof row === 'object' && (row as { is_checked?: unknown }).is_checked !== true) open += 1
117
+ }
118
+ }
119
+ return { open, files }
120
+ }
121
+
122
+ const REFLECTION_LOG_MAX_BYTES = 8 * 1024 * 1024
123
+
124
+ export function probeReflection(): ReflectionProbe | null {
125
+ if (!COS_SCRIPTS_DIR) return null
126
+ const file = resolve(COS_SCRIPTS_DIR, '.cos_reflect_log.jsonl')
127
+ let stat
128
+ try { stat = statSync(file) } catch { return null }
129
+ if (!stat.isFile()) return null
130
+ const newestAt = stat.mtime.toISOString()
131
+ if (stat.size > REFLECTION_LOG_MAX_BYTES) return { entries: -1, newestAt }
132
+ let entries = 0
133
+ try {
134
+ for (const line of readFileSync(file, 'utf8').split('\n')) if (line.trim()) entries += 1
135
+ } catch { return null }
136
+ return { entries, newestAt }
137
+ }
138
+
139
+ const SKILL_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,63}$/
140
+
141
+ /** Where a skill would be found, in the order the prompt tells the agent to
142
+ * look. Returns a folder LABEL, never an absolute path. */
143
+ export function probeSkill(rawName: string, workDir = resolveProviderWorkDir({ scriptsDir: COS_SCRIPTS_DIR }), home = homedir()): SkillProbe {
144
+ const name = rawName.trim().replace(/^\//, '')
145
+ if (!SKILL_NAME_RE.test(name) || basename(name) !== name) return { found: false }
146
+ const nested = name.replace(/:/g, '/')
147
+ const candidates: Array<[string, string]> = [
148
+ ['.claude/skills', join(workDir, '.claude', 'skills', name, 'SKILL.md')],
149
+ ['.agents/skills', join(workDir, '.agents', 'skills', name, 'SKILL.md')],
150
+ ['.agents/skills', join(workDir, '.agents', 'skills', nested, 'SKILL.md')],
151
+ ['.claude/commands', join(workDir, '.claude', 'commands', `${name}.md`)],
152
+ ['.claude/commands', join(workDir, '.claude', 'commands', `${nested}.md`)],
153
+ ['~/.claude/skills', join(home, '.claude', 'skills', name, 'SKILL.md')],
154
+ ['~/.claude/commands', join(home, '.claude', 'commands', `${name}.md`)],
155
+ ['~/.codex/prompts', join(home, '.codex', 'prompts', `${name}.md`)],
156
+ ]
157
+ for (const [where, path] of candidates) {
158
+ try { if (existsSync(path)) return { found: true, where } } catch { /* unreadable: keep looking */ }
159
+ }
160
+ return { found: false }
161
+ }
162
+
163
+ let scheduler: MorningBriefScheduler | null = null
164
+ let unregisterReservations: (() => void) | null = null
165
+
166
+ export function getMorningBriefScheduler(): MorningBriefScheduler {
167
+ if (!scheduler) {
168
+ const instance = new MorningBriefScheduler({
169
+ paths: morningBriefPaths(),
170
+ submit: raw => queryJobCoordinator.submit(raw),
171
+ findByClientGeneration: (clientJobId, generation) => queryJobCoordinator.getByClientGeneration(clientJobId, generation),
172
+ getSnapshot: jobId => queryJobCoordinator.getSnapshot(jobId),
173
+ createSession,
174
+ currentMessageEra,
175
+ currentMessageMax,
176
+ ownerName: getOwnerName,
177
+ durableJobsEnabled: durableQueryJobsEnabled,
178
+ admissionsOpen: maintenanceAdmissionsOpen,
179
+ coverage: new MorningBriefCoverageService({
180
+ meetings: probeMeetings,
181
+ context: probeContext,
182
+ calendar: probeCalendar,
183
+ tasks: probeTasks,
184
+ reflection: probeReflection,
185
+ skill: name => probeSkill(name),
186
+ }),
187
+ })
188
+ // The ledger row exists before the job does; its number must count.
189
+ unregisterReservations = registerMessageReservationSource(() => instance.liveReservations(currentMessageEra()))
190
+ scheduler = instance
191
+ }
192
+ return scheduler
193
+ }
194
+
195
+ export function startMorningBriefScheduler(): void {
196
+ getMorningBriefScheduler().start()
197
+ }
198
+
199
+ export function stopMorningBriefScheduler(): void {
200
+ scheduler?.stop()
201
+ unregisterReservations?.()
202
+ unregisterReservations = null
203
+ }
@@ -0,0 +1,158 @@
1
+ // Morning brief — pure schedule arithmetic in the user's zone.
2
+ //
3
+ // No dependency: Node's Intl is enough to read a wall clock in an IANA zone,
4
+ // and the two operations the scheduler needs — "what local day and minute is
5
+ // it now" and "when is HH:MM on day D as an instant" — are both derivable from
6
+ // it. Kept free of timers and I/O so the fire/no-fire decision is a table of
7
+ // inputs a test can enumerate: the Mac asleep through the slot, a weekend, a
8
+ // zone change, a DST morning, a second tick in the same minute.
9
+
10
+ import type { MorningBriefConfig, MorningBriefRun } from './morning-brief-config.js'
11
+ import { MORNING_BRIEF_LIMITS } from './morning-brief-config.js'
12
+
13
+ export interface LocalClock {
14
+ /** YYYY-MM-DD in the zone. */
15
+ day: string
16
+ /** Minutes since local midnight, 0..1439. */
17
+ minutes: number
18
+ /** 0 = Sunday … 6 = Saturday, in the zone. */
19
+ weekday: number
20
+ /** HH:MM in the zone. */
21
+ time: string
22
+ }
23
+
24
+ const WEEKDAY_INDEX: Record<string, number> = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 }
25
+ const formatterCache = new Map<string, Intl.DateTimeFormat>()
26
+
27
+ function formatter(timezone: string): Intl.DateTimeFormat {
28
+ let cached = formatterCache.get(timezone)
29
+ if (!cached) {
30
+ cached = new Intl.DateTimeFormat('en-US', {
31
+ timeZone: timezone,
32
+ hourCycle: 'h23',
33
+ weekday: 'short',
34
+ year: 'numeric',
35
+ month: '2-digit',
36
+ day: '2-digit',
37
+ hour: '2-digit',
38
+ minute: '2-digit',
39
+ })
40
+ formatterCache.set(timezone, cached)
41
+ }
42
+ return cached
43
+ }
44
+
45
+ /** Read the wall clock at `nowMs` in `timezone`. */
46
+ export function localClock(nowMs: number, timezone: string): LocalClock {
47
+ const parts = formatter(timezone).formatToParts(new Date(nowMs))
48
+ const get = (type: Intl.DateTimeFormatPartTypes) => parts.find(part => part.type === type)?.value ?? ''
49
+ // `hourCycle: 'h23'` is honoured by modern ICU, but "24" has been observed
50
+ // from older builds at midnight. Normalise defensively.
51
+ const hour = Number(get('hour')) % 24
52
+ const minute = Number(get('minute'))
53
+ return {
54
+ day: `${get('year')}-${get('month')}-${get('day')}`,
55
+ minutes: hour * 60 + minute,
56
+ weekday: WEEKDAY_INDEX[get('weekday')] ?? new Date(nowMs).getUTCDay(),
57
+ time: `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`,
58
+ }
59
+ }
60
+
61
+ export function parseTime(time: string): number {
62
+ const [h, m] = time.split(':').map(Number)
63
+ return h * 60 + m
64
+ }
65
+
66
+ /** Shift a YYYY-MM-DD key by whole days (calendar arithmetic, zone-agnostic). */
67
+ export function shiftDay(day: string, delta: number): string {
68
+ const [y, m, d] = day.split('-').map(Number)
69
+ const date = new Date(Date.UTC(y, m - 1, d + delta))
70
+ return `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, '0')}-${String(date.getUTCDate()).padStart(2, '0')}`
71
+ }
72
+
73
+ /**
74
+ * The instant at which `day` reaches `minutes` past midnight in `timezone`.
75
+ * Two-pass offset correction: read the zone's wall clock at a UTC guess, apply
76
+ * the difference, and re-check once so a DST transition between the guess and
77
+ * the target lands on the right side. On a non-existent local time (spring
78
+ * forward) this returns the first instant after the gap.
79
+ */
80
+ export function zonedInstant(day: string, minutes: number, timezone: string): number {
81
+ const [y, m, d] = day.split('-').map(Number)
82
+ let guess = Date.UTC(y, m - 1, d, Math.floor(minutes / 60), minutes % 60)
83
+ for (let pass = 0; pass < 2; pass++) {
84
+ const clock = localClock(guess, timezone)
85
+ const dayDelta = daysBetween(clock.day, day)
86
+ const diffMinutes = dayDelta * 1440 + (minutes - clock.minutes)
87
+ if (diffMinutes === 0) break
88
+ guess += diffMinutes * 60_000
89
+ }
90
+ return guess
91
+ }
92
+
93
+ function daysBetween(fromDay: string, toDay: string): number {
94
+ const [fy, fm, fd] = fromDay.split('-').map(Number)
95
+ const [ty, tm, td] = toDay.split('-').map(Number)
96
+ return Math.round((Date.UTC(ty, tm - 1, td) - Date.UTC(fy, fm - 1, fd)) / 86_400_000)
97
+ }
98
+
99
+ /** ISO instant of the next scheduled fire at or after `nowMs`, or null when
100
+ * the brief is disabled or no weekday is selected. */
101
+ export function nextScheduledFire(config: MorningBriefConfig, nowMs: number): string | null {
102
+ if (!config.enabled || config.days.length === 0) return null
103
+ const target = parseTime(config.time)
104
+ const clock = localClock(nowMs, config.timezone)
105
+ for (let offset = 0; offset <= 7; offset++) {
106
+ const day = shiftDay(clock.day, offset)
107
+ const instant = zonedInstant(day, target, config.timezone)
108
+ const weekday = localClock(instant, config.timezone).weekday
109
+ if (!config.days.includes(weekday)) continue
110
+ if (instant >= nowMs) return new Date(instant).toISOString()
111
+ }
112
+ return null
113
+ }
114
+
115
+ export type FireDecision =
116
+ | { fire: true; day: string; attempt: number; resume?: MorningBriefRun }
117
+ | { fire: false; reason: 'disabled' | 'not_a_brief_day' | 'before_slot' | 'window_closed' | 'already_fired' | 'in_flight' | 'attempts_exhausted' | 'backoff' }
118
+
119
+ /**
120
+ * Should the scheduled brief fire on this tick? The ledger is the memory: one
121
+ * completed submission per local day, bounded retries for a submission that
122
+ * failed (coordinator shutting down, store degraded), and a row that has no
123
+ * job id and no error is a crash between ledger write and admission — resumed
124
+ * by client identity rather than re-run.
125
+ */
126
+ export function decideScheduledFire(
127
+ config: MorningBriefConfig,
128
+ runs: readonly MorningBriefRun[],
129
+ nowMs: number,
130
+ ): FireDecision {
131
+ if (!config.enabled) return { fire: false, reason: 'disabled' }
132
+ const clock = localClock(nowMs, config.timezone)
133
+ if (!config.days.includes(clock.weekday)) return { fire: false, reason: 'not_a_brief_day' }
134
+ const target = parseTime(config.time)
135
+ if (clock.minutes < target) return { fire: false, reason: 'before_slot' }
136
+ if (clock.minutes - target > config.catchUpMinutes) return { fire: false, reason: 'window_closed' }
137
+
138
+ const today = runs.filter(run => run.day === clock.day && run.trigger === 'scheduled')
139
+ const accepted = today.find(run => run.jobId)
140
+ if (accepted) return { fire: false, reason: 'already_fired' }
141
+ const last = today.at(-1)
142
+ if (!last) return { fire: true, day: clock.day, attempt: 1 }
143
+ const lastAt = Date.parse(last.firedAt)
144
+ if (!last.submitError) {
145
+ // Ledger row exists, no job id, no error: submission never reported back.
146
+ // Give it a moment (the write happens just before submit), then resume by
147
+ // identity so a lost admission is adopted instead of duplicated.
148
+ if (Number.isFinite(lastAt) && nowMs - lastAt < MORNING_BRIEF_LIMITS.attemptSpacingMs) {
149
+ return { fire: false, reason: 'in_flight' }
150
+ }
151
+ return { fire: true, day: clock.day, attempt: last.attempt, resume: last }
152
+ }
153
+ if (last.attempt >= MORNING_BRIEF_LIMITS.scheduledAttemptsPerDay) return { fire: false, reason: 'attempts_exhausted' }
154
+ if (Number.isFinite(lastAt) && nowMs - lastAt < MORNING_BRIEF_LIMITS.attemptSpacingMs) {
155
+ return { fire: false, reason: 'backoff' }
156
+ }
157
+ return { fire: true, day: clock.day, attempt: last.attempt + 1 }
158
+ }