@gotcos/glasses-server 6.42.1 → 6.43.0

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,263 @@
1
+ // Morning brief — the prompt composer.
2
+ //
3
+ // Pure: config + clock in, one prompt string out. The prompt is what the user's
4
+ // own COS agent receives as a durable query job at the scheduled minute, so it
5
+ // carries everything the agent needs and nothing the server cannot vouch for:
6
+ //
7
+ // - the read-only contract (nobody is watching; never send, create, or edit);
8
+ // - the sections, in the user's order, each with its window and its own
9
+ // "unavailable" rule so a missing connector produces one honest line, not a
10
+ // fabricated one;
11
+ // - the glasses formatting contract (plain text, short labelled lines) so the
12
+ // result renders on a 576x288 lens without a second pass.
13
+ //
14
+ // Deterministic for a given (config, day): the scheduled slot, not the actual
15
+ // firing minute, is what appears in the prompt. That is what lets a retry after
16
+ // a crashed submission re-admit as the SAME job by client identity instead of
17
+ // conflicting on a different prompt body.
18
+
19
+ import type { MorningBriefConfig, MorningBriefSource, MorningBriefSourceId } from './morning-brief-config.js'
20
+ import { MORNING_BRIEF_SOURCES } from './morning-brief-config.js'
21
+ import { parseTime, shiftDay } from './morning-brief-schedule.js'
22
+
23
+ /** Well under the 48,000-char durable-job ceiling, with room for the largest
24
+ * legal combination of free-text options. */
25
+ export const MORNING_BRIEF_PROMPT_MAX_CHARS = 16_000
26
+
27
+ export interface MorningBriefPromptInput {
28
+ config: MorningBriefConfig
29
+ /** Local calendar day the brief is for (YYYY-MM-DD in config.timezone). */
30
+ day: string
31
+ ownerName: string
32
+ trigger: 'scheduled' | 'manual'
33
+ }
34
+
35
+ const WEEKDAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
36
+ const MONTH_NAMES = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']
37
+
38
+ function describeDay(day: string): string {
39
+ const [y, m, d] = day.split('-').map(Number)
40
+ const weekday = WEEKDAY_NAMES[new Date(Date.UTC(y, m - 1, d)).getUTCDay()]
41
+ return `${weekday}, ${MONTH_NAMES[m - 1]} ${d}, ${y}`
42
+ }
43
+
44
+ function lastBusinessDay(day: string): string {
45
+ let candidate = shiftDay(day, -1)
46
+ for (let i = 0; i < 7; i++) {
47
+ const [y, m, d] = candidate.split('-').map(Number)
48
+ const weekday = new Date(Date.UTC(y, m - 1, d)).getUTCDay()
49
+ if (weekday !== 0 && weekday !== 6) return candidate
50
+ candidate = shiftDay(candidate, -1)
51
+ }
52
+ return candidate
53
+ }
54
+
55
+ function plural(n: number, unit: string): string {
56
+ return `${n} ${unit}${n === 1 ? '' : 's'}`
57
+ }
58
+
59
+ function str(options: MorningBriefSource['options'], key: string): string {
60
+ const value = options[key]
61
+ return typeof value === 'string' ? value.trim() : ''
62
+ }
63
+
64
+ function num(options: MorningBriefSource['options'], key: string, fallback: number): number {
65
+ const value = options[key]
66
+ return typeof value === 'number' && Number.isFinite(value) ? value : fallback
67
+ }
68
+
69
+ function bool(options: MorningBriefSource['options'], key: string, fallback: boolean): boolean {
70
+ const value = options[key]
71
+ return typeof value === 'boolean' ? value : fallback
72
+ }
73
+
74
+ /** One section's instruction. Returns null when the source has nothing to say
75
+ * (a custom section with no instruction, a skill with no name). */
76
+ export function sectionInstruction(source: MorningBriefSource, day: string): { label: string; body: string } | null {
77
+ const o = source.options
78
+ switch (source.id) {
79
+ case 'calendar':
80
+ return {
81
+ label: 'CALENDAR',
82
+ body: [
83
+ `Today's commitments in time order, each on one line: start time, title, who it is with when that matters.`,
84
+ `Name the first commitment of the day and how much open time exists before it.`,
85
+ bool(o, 'includeTomorrow', false) ? `Then tomorrow's first commitment on one line.` : '',
86
+ `Read every calendar this workspace can reach (connectors, local calendar helpers, cached calendar files). If none can be read, write "Calendar: unavailable" with the reason.`,
87
+ ].filter(Boolean).join(' '),
88
+ }
89
+ case 'meetings': {
90
+ const lookback = num(o, 'lookbackDays', 3)
91
+ const horizon = num(o, 'horizonDays', 7)
92
+ return {
93
+ label: 'FROM RECENT MEETINGS',
94
+ body: [
95
+ `Meetings synced in the last ${plural(lookback, 'day')} (the last business day before ${day} was ${lastBusinessDay(day)}).`,
96
+ `Surface only items with a hard edge: a decision that was made, a deadline inside the next ${plural(horizon, 'day')}, a dollar figure, or a named owner.`,
97
+ `Read the meeting's summary and decisions; never paste an extracted action-item list verbatim, and when a number or date matters, quote it from the transcript.`,
98
+ `Three to five items ranked by consequence, one line each: the decision or open question, then the hard edge.`,
99
+ `If nothing decision-grade happened, say so in one line.`,
100
+ ].join(' '),
101
+ }
102
+ }
103
+ case 'tasks': {
104
+ const horizon = num(o, 'horizonDays', 7)
105
+ const overdue = bool(o, 'includeOverdue', true)
106
+ return {
107
+ label: 'DUE',
108
+ body: [
109
+ `Open tasks from this workspace's task files due within ${plural(horizon, 'day')}${overdue ? ', overdue items first' : ''}.`,
110
+ `One line each: the task, its owner if not the wearer, and the date. Cap at seven. Skip anything already marked done.`,
111
+ ].join(' '),
112
+ }
113
+ }
114
+ case 'waiting': {
115
+ const lookback = num(o, 'lookbackDays', 7)
116
+ return {
117
+ label: 'WAITING ON YOU',
118
+ body: [
119
+ `Across every channel this workspace can read (Slack, email, chat connectors), from the last ${plural(lookback, 'day')}:`,
120
+ `direct mentions with no reply from the wearer, questions addressed to the wearer with no answer beneath them, and threads that moved after the wearer's last message.`,
121
+ `Up to five, ranked by consequence. Each line names the channel or sender, who is waiting, and the ask.`,
122
+ `Never claim something is unread; report only what is verifiable. If no channel can be read, write "Waiting on you: unavailable" with the reason.`,
123
+ ].join(' '),
124
+ }
125
+ }
126
+ case 'knowledge': {
127
+ const lookback = num(o, 'lookbackDays', 7)
128
+ return {
129
+ label: 'MOVING',
130
+ body: [
131
+ `From memory, threads, and the knowledge graph this workspace keeps: the two or three relationships, projects, or people that moved in the last ${plural(lookback, 'day')} and why it matters today.`,
132
+ `One line each. Cite the thread or entity by name. Skip this section silently if the workspace has no memory or graph.`,
133
+ ].join(' '),
134
+ }
135
+ }
136
+ case 'reflection':
137
+ return {
138
+ label: 'CARRY THIS',
139
+ body: [
140
+ `From recent reflection logs, journal entries, or correction records in this workspace: the one theme that recurs across at least two periods.`,
141
+ `State the pattern in one line and the single behaviour to carry into today in a second line. Do not grade. Skip silently if there is no reflection history.`,
142
+ ].join(' '),
143
+ }
144
+ case 'health':
145
+ return {
146
+ label: 'BODY',
147
+ body: `Last night's sleep and readiness from any connected health source, one line, with the one adjustment it implies. Skip silently if no health source is connected.`,
148
+ }
149
+ case 'reading': {
150
+ const text = str(o, 'text') || 'proverbs'
151
+ const [, , d] = day.split('-').map(Number)
152
+ if (text.toLowerCase() === 'proverbs') {
153
+ return {
154
+ label: 'OPENING',
155
+ body: [
156
+ `Proverbs chapter ${d} (the calendar day) in the public-domain King James Version, presented as numbered verses.`,
157
+ `Then one verse from it that genuinely speaks to today's shape, named, with a two-sentence connection. Never substitute a copyrighted translation; if the exact KJV text cannot be verified, say so and skip the chapter.`,
158
+ ].join(' '),
159
+ }
160
+ }
161
+ return {
162
+ label: 'OPENING',
163
+ body: `A short public-domain reading from "${text}" matched to today's date, then one line on why it fits. Never reproduce copyrighted text.`,
164
+ }
165
+ }
166
+ case 'pulse': {
167
+ const instruction = str(o, 'instruction')
168
+ return {
169
+ label: 'PULSE',
170
+ body: [
171
+ instruction
172
+ ? `The numbers the wearer steers by: ${instruction}`
173
+ : `The numbers the wearer steers by, from any dashboard, report, or metrics connector this workspace has.`,
174
+ `Use the most recent complete period (yesterday, or the last business day). Render quantitative comparisons as bar fills with a direction glyph and the delta, for example "Grocery 162 ██████████ ▼ -7%".`,
175
+ `Name the strongest positive and the clearest pain. If the source cannot be read, write "Pulse: unavailable" with the reason; never imply data you did not pull.`,
176
+ ].join(' '),
177
+ }
178
+ }
179
+ case 'skill': {
180
+ const name = str(o, 'name')
181
+ if (!name) return null
182
+ const slash = name.startsWith('/') ? name : `/${name}`
183
+ return {
184
+ label: `SKILL ${slash}`,
185
+ body: [
186
+ `Run this workspace's ${slash} skill exactly as it is defined (look under .claude/skills, .agents/skills, .claude/commands, and ~/.codex/prompts) and use its output for this section.`,
187
+ `Do not summarise it away; it was written for this purpose. If the skill does not exist, write one line saying so and continue with the other sections.`,
188
+ ].join(' '),
189
+ }
190
+ }
191
+ case 'custom': {
192
+ const instruction = str(o, 'instruction')
193
+ if (!instruction) return null
194
+ return { label: 'ALSO', body: instruction }
195
+ }
196
+ }
197
+ }
198
+
199
+ const KNOWN_IDS = new Set<MorningBriefSourceId>(MORNING_BRIEF_SOURCES.map(spec => spec.id))
200
+
201
+ /** Compose the brief prompt. Enabled sources become numbered sections in the
202
+ * user's order; a section whose source has nothing to say is skipped. */
203
+ export function composeMorningBriefPrompt(input: MorningBriefPromptInput): string {
204
+ const { config, day, ownerName, trigger } = input
205
+ const slotMinutes = parseTime(config.time)
206
+ const slot = `${String(Math.floor(slotMinutes / 60)).padStart(2, '0')}:${String(slotMinutes % 60).padStart(2, '0')}`
207
+ const sections = config.sources
208
+ .filter(source => source.enabled && KNOWN_IDS.has(source.id))
209
+ .map(source => sectionInstruction(source, day))
210
+ .filter((section): section is { label: string; body: string } => section !== null)
211
+
212
+ const skillOnly = sections.length === 1 && sections[0].label.startsWith('SKILL ')
213
+
214
+ const lines: string[] = []
215
+ lines.push(`Morning brief for ${ownerName || 'the wearer'}. ${describeDay(day)}, ${slot} ${config.timezone}.${trigger === 'manual' ? ' Requested now rather than on the schedule.' : ''}`)
216
+ lines.push('')
217
+ lines.push(
218
+ 'This is the start-of-day brief that waits in the COS Glasses inbox before the wearer opens it. Nobody is watching this run: do not ask questions, do not pause for confirmation, and do not stop early. ' +
219
+ 'It is read-only. Do not send messages or email, create or change calendar events, edit tasks, or write files other than any journal or log a skill you run is already designed to keep.',
220
+ )
221
+ lines.push('')
222
+ lines.push(
223
+ 'Evidence discipline: every line must come from something you actually read in this workspace or through its connectors. ' +
224
+ 'When a source cannot be read, give that section one line, "<Section>: unavailable (reason)", and move on. Never invent a meeting, a message, a number, or a name. ' +
225
+ 'An aside is not a finding; include only items with a hard edge (a decision, a date, a dollar figure, an owner).',
226
+ )
227
+ lines.push('')
228
+
229
+ if (sections.length === 0) {
230
+ lines.push('No sections are enabled. Reply with exactly one line: "Morning brief: no sources selected. Choose sources in COS Control or the companion app."')
231
+ } else if (skillOnly) {
232
+ lines.push('Content:')
233
+ lines.push(`1. ${sections[0].label}. ${sections[0].body}`)
234
+ } else {
235
+ lines.push('Sections, in this order, each opened by its label on its own line:')
236
+ sections.forEach((section, index) => {
237
+ lines.push(`${index + 1}. ${section.label}. ${section.body}`)
238
+ })
239
+ }
240
+ lines.push('')
241
+
242
+ if (config.closingInstruction) {
243
+ lines.push(`Also: ${config.closingInstruction}`)
244
+ lines.push('')
245
+ }
246
+
247
+ lines.push(
248
+ 'Format for the glasses: plain text only. No markdown headings, tables, or bullet symbols; a section is its label on one line followed by short lines. ' +
249
+ 'Keep every line under 60 characters where you can, because the lens is 576 pixels wide and wraps silently. Keep the whole brief under about 60 lines; ' +
250
+ (skillOnly
251
+ ? 'if the skill\'s own output format is longer, keep it intact rather than trimming it.'
252
+ : 'trim from the bottom of each section, not from the top.'),
253
+ )
254
+ lines.push(
255
+ 'End with one line beginning "Order your energy:" naming the single posture for the day, grounded in the sections above.',
256
+ )
257
+ lines.push('Do not say "here is" or "I found". Do not add a preamble or a sign-off.')
258
+
259
+ const prompt = lines.join('\n')
260
+ return prompt.length > MORNING_BRIEF_PROMPT_MAX_CHARS
261
+ ? `${prompt.slice(0, MORNING_BRIEF_PROMPT_MAX_CHARS - 1)}…`
262
+ : prompt
263
+ }
@@ -0,0 +1,58 @@
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 { getActiveSessions, createSession } from './conversation.js'
8
+ import { currentMessageEra, exchangeBelongsToEra } from './message-era.js'
9
+ import { maxGlobalMsgNumInDir } from '../routes/message-ref.js'
10
+ import { dataPath } from './data-dir.js'
11
+ import { getOwnerName } from './profile.js'
12
+ import { durableQueryJobsEnabled } from './query-job-feature.js'
13
+ import { queryJobCoordinator } from './query-job-runtime.js'
14
+ import { maintenanceAdmissionsOpen } from './maintenance-lifecycle.js'
15
+ import { morningBriefPaths } from './morning-brief-config.js'
16
+ import { MorningBriefScheduler } from './morning-brief-scheduler.js'
17
+
18
+ /** Highest stamped message number in the active era: live sessions plus the
19
+ * day archives. The same arithmetic /api/message-counter serves the phone. */
20
+ export function currentMessageMax(): number {
21
+ const era = currentMessageEra()
22
+ let liveMax = 0
23
+ for (const session of getActiveSessions()) {
24
+ for (const exchange of (session as { exchanges?: Array<{ globalMsgNum?: unknown; messageEra?: unknown }> }).exchanges ?? []) {
25
+ if (!exchangeBelongsToEra(exchange, era)) continue
26
+ if (typeof exchange?.globalMsgNum === 'number' && exchange.globalMsgNum > liveMax) liveMax = exchange.globalMsgNum
27
+ }
28
+ }
29
+ return Math.max(liveMax, maxGlobalMsgNumInDir(dataPath('archive'), era))
30
+ }
31
+
32
+ let scheduler: MorningBriefScheduler | null = null
33
+
34
+ export function getMorningBriefScheduler(): MorningBriefScheduler {
35
+ if (!scheduler) {
36
+ scheduler = new MorningBriefScheduler({
37
+ paths: morningBriefPaths(),
38
+ submit: raw => queryJobCoordinator.submit(raw),
39
+ findByClientGeneration: (clientJobId, generation) => queryJobCoordinator.getByClientGeneration(clientJobId, generation),
40
+ getSnapshot: jobId => queryJobCoordinator.getSnapshot(jobId),
41
+ createSession,
42
+ currentMessageEra,
43
+ currentMessageMax,
44
+ ownerName: getOwnerName,
45
+ durableJobsEnabled: durableQueryJobsEnabled,
46
+ admissionsOpen: maintenanceAdmissionsOpen,
47
+ })
48
+ }
49
+ return scheduler
50
+ }
51
+
52
+ export function startMorningBriefScheduler(): void {
53
+ getMorningBriefScheduler().start()
54
+ }
55
+
56
+ export function stopMorningBriefScheduler(): void {
57
+ scheduler?.stop()
58
+ }
@@ -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
+ }