@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,361 @@
1
+ // Morning brief — the scheduler.
2
+ //
3
+ // One 30-second tick, unref'd so it never keeps a shutting-down process alive.
4
+ // Each tick asks the pure decision function whether the scheduled slot has
5
+ // arrived for the configured zone, then fires by submitting a durable query
6
+ // job to the same coordinator that every phone-originated prompt uses. From
7
+ // that point on the brief is an ordinary job: it survives the phone being
8
+ // asleep, it is projected into the conversation store with a message number
9
+ // when it completes, and the companion's history hydration picks it up like
10
+ // any other reply.
11
+ //
12
+ // HOW IT STOPS (the question every new provider caller must answer):
13
+ // - one scheduled fire per local calendar day, remembered in a ledger that is
14
+ // written BEFORE the job is admitted;
15
+ // - a failed admission retries at most `scheduledAttemptsPerDay` times, two
16
+ // minutes apart, then the day is given up;
17
+ // - a crash between the ledger write and the admission is RESUMED by client
18
+ // identity (deterministic per day), never re-run;
19
+ // - "Run now" is capped per day and refused while a brief is already running;
20
+ // - the whole thing is inert when durable jobs are off (COS Control's
21
+ // "Background jobs" switch) or maintenance admissions are closed.
22
+
23
+ import { createHash, randomUUID } from 'node:crypto'
24
+ import {
25
+ MORNING_BRIEF_LIMITS,
26
+ applyMorningBriefPatch,
27
+ loadMorningBriefConfig,
28
+ loadMorningBriefLedger,
29
+ saveMorningBriefConfig,
30
+ saveMorningBriefLedger,
31
+ type MorningBriefConfig,
32
+ type MorningBriefLedger,
33
+ type MorningBriefRun,
34
+ type MorningBriefStorePaths,
35
+ type MorningBriefTrigger,
36
+ serverTimezone,
37
+ } from './morning-brief-config.js'
38
+ import { composeMorningBriefPrompt } from './morning-brief-prompt.js'
39
+ import { decideScheduledFire, localClock, nextScheduledFire } from './morning-brief-schedule.js'
40
+ import type { QueryJobSnapshot } from './query-job-types.js'
41
+ import { isTerminalQueryJobStatus } from './query-job-types.js'
42
+
43
+ export interface MorningBriefSubmission {
44
+ job: Pick<QueryJobSnapshot, 'jobId' | 'clientJobId' | 'generation' | 'status' | 'sessionId'>
45
+ created: boolean
46
+ }
47
+
48
+ export interface MorningBriefSchedulerDeps {
49
+ paths: MorningBriefStorePaths
50
+ submit: (request: Record<string, unknown>) => Promise<MorningBriefSubmission>
51
+ findByClientGeneration: (clientJobId: string, generation: number) => Promise<QueryJobSnapshot | undefined>
52
+ getSnapshot: (jobId: string) => Promise<QueryJobSnapshot>
53
+ createSession: () => string
54
+ currentMessageEra: () => string
55
+ /** Highest stamped number in the active era across live sessions and archives. */
56
+ currentMessageMax: () => number
57
+ ownerName: () => string
58
+ durableJobsEnabled: () => boolean
59
+ admissionsOpen: () => boolean
60
+ now?: () => number
61
+ tickMs?: number
62
+ log?: (line: string) => void
63
+ }
64
+
65
+ export class MorningBriefRunError extends Error {
66
+ constructor(readonly status: number, readonly code: string, message: string) {
67
+ super(message)
68
+ this.name = 'MorningBriefRunError'
69
+ }
70
+ }
71
+
72
+ export interface MorningBriefRunView extends MorningBriefRun {
73
+ status: string
74
+ completedAt?: string
75
+ error?: { code: string; message: string }
76
+ /** First ~120 chars of the answer, for a list row. Never the whole brief. */
77
+ preview?: string
78
+ }
79
+
80
+ export interface MorningBriefStatus {
81
+ protocolVersion: 1
82
+ enabled: boolean
83
+ time: string
84
+ timezone: string
85
+ serverTimezone: string
86
+ days: number[]
87
+ nextRunAt: string | null
88
+ lastRun: MorningBriefRunView | null
89
+ /** Why the scheduler would not fire right now, for a status line. */
90
+ gate: 'ready' | 'durable_jobs_off' | 'admissions_closed' | 'disabled'
91
+ }
92
+
93
+ export type TickResult =
94
+ | { fired: true; run: MorningBriefRun }
95
+ | { fired: false; reason: string }
96
+
97
+ /** Deterministic v4-shaped client id for one local day, so a retry after a
98
+ * crashed submission admits as the SAME job. The store dedupes on it. */
99
+ export function scheduledClientJobId(day: string, timezone: string): string {
100
+ const digest = createHash('sha256').update(`morning-brief|${timezone}|${day}`).digest()
101
+ const bytes = Buffer.from(digest.subarray(0, 16))
102
+ bytes[6] = (bytes[6] & 0x0f) | 0x40
103
+ bytes[8] = (bytes[8] & 0x3f) | 0x80
104
+ const hex = bytes.toString('hex')
105
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
106
+ }
107
+
108
+ function errorParts(error: unknown): { code: string; message: string } {
109
+ const code = typeof (error as { code?: unknown })?.code === 'string' ? (error as { code: string }).code : 'submit_failed'
110
+ const message = error instanceof Error ? error.message : String(error)
111
+ return { code: code.slice(0, 64), message: message.slice(0, 300) }
112
+ }
113
+
114
+ export class MorningBriefScheduler {
115
+ private config: MorningBriefConfig
116
+ private ledger: MorningBriefLedger
117
+ private timer: ReturnType<typeof setInterval> | null = null
118
+ private tickInFlight: Promise<TickResult> | null = null
119
+ private readonly now: () => number
120
+ private readonly log: (line: string) => void
121
+ readonly quarantinedConfig?: string
122
+
123
+ constructor(private readonly deps: MorningBriefSchedulerDeps) {
124
+ this.now = deps.now ?? Date.now
125
+ this.log = deps.log ?? ((line) => console.log(`[morning-brief] ${line}`))
126
+ const loaded = loadMorningBriefConfig(deps.paths, new Date(this.now()))
127
+ this.config = loaded.config
128
+ this.quarantinedConfig = loaded.quarantinedAs
129
+ if (loaded.fresh) {
130
+ // Persist the defaults so every surface reads one file from the start
131
+ // and so `updatedAt` is a real first-seen time.
132
+ try { saveMorningBriefConfig(deps.paths, this.config) } catch (error) {
133
+ this.log(`could not persist default config: ${(error as Error).message}`)
134
+ }
135
+ }
136
+ this.ledger = loadMorningBriefLedger(deps.paths)
137
+ }
138
+
139
+ start(): void {
140
+ if (this.timer) return
141
+ const tickMs = Math.max(1_000, this.deps.tickMs ?? 30_000)
142
+ this.timer = setInterval(() => { void this.tick() }, tickMs)
143
+ this.timer.unref?.()
144
+ this.log(`scheduled ${this.config.enabled ? `daily at ${this.config.time} ${this.config.timezone}` : 'off'} · next ${nextScheduledFire(this.config, this.now()) ?? 'none'}`)
145
+ }
146
+
147
+ stop(): void {
148
+ if (this.timer) clearInterval(this.timer)
149
+ this.timer = null
150
+ }
151
+
152
+ getConfig(): MorningBriefConfig {
153
+ return structuredClone(this.config)
154
+ }
155
+
156
+ updateConfig(patch: unknown): MorningBriefConfig {
157
+ const next = applyMorningBriefPatch(this.config, patch, new Date(this.now()))
158
+ saveMorningBriefConfig(this.deps.paths, next)
159
+ this.config = next
160
+ this.log(`config updated · ${next.enabled ? `daily at ${next.time} ${next.timezone}` : 'off'} · next ${nextScheduledFire(next, this.now()) ?? 'none'}`)
161
+ return structuredClone(next)
162
+ }
163
+
164
+ previewPrompt(trigger: MorningBriefTrigger = 'scheduled'): string {
165
+ const clock = localClock(this.now(), this.config.timezone)
166
+ return composeMorningBriefPrompt({ config: this.config, day: clock.day, ownerName: this.deps.ownerName(), trigger })
167
+ }
168
+
169
+ /** One scheduler pass. Serialised: a slow submission never overlaps the next tick. */
170
+ tick(): Promise<TickResult> {
171
+ if (this.tickInFlight) return this.tickInFlight
172
+ this.tickInFlight = this.runTick().finally(() => { this.tickInFlight = null })
173
+ return this.tickInFlight
174
+ }
175
+
176
+ private async runTick(): Promise<TickResult> {
177
+ if (!this.deps.durableJobsEnabled()) return { fired: false, reason: 'durable_jobs_off' }
178
+ if (!this.deps.admissionsOpen()) return { fired: false, reason: 'admissions_closed' }
179
+ const decision = decideScheduledFire(this.config, this.ledger.runs, this.now())
180
+ if (!decision.fire) return { fired: false, reason: decision.reason }
181
+ const run = await this.fire('scheduled', decision.day, decision.attempt, decision.resume)
182
+ return { fired: true, run }
183
+ }
184
+
185
+ /** "Run now" from a settings surface. Bounded per day; refused while a brief is live. */
186
+ async runNow(): Promise<MorningBriefRun> {
187
+ if (!this.deps.durableJobsEnabled()) {
188
+ throw new MorningBriefRunError(409, 'durable_jobs_off', 'Turn on Background jobs in COS Control to run the brief.')
189
+ }
190
+ if (!this.deps.admissionsOpen()) {
191
+ throw new MorningBriefRunError(503, 'admissions_closed', 'The server is in maintenance. Try again in a moment.')
192
+ }
193
+ const clock = localClock(this.now(), this.config.timezone)
194
+ const today = this.ledger.runs.filter(run => run.day === clock.day)
195
+ for (const run of today) {
196
+ if (!run.jobId) continue
197
+ const snapshot = await this.deps.getSnapshot(run.jobId).catch(() => undefined)
198
+ if (snapshot && !isTerminalQueryJobStatus(snapshot.status)) {
199
+ throw new MorningBriefRunError(409, 'brief_in_progress', 'A brief is already running. It will land in the inbox when it finishes.')
200
+ }
201
+ }
202
+ const manual = today.filter(run => run.trigger === 'manual' && run.jobId).length
203
+ if (manual >= MORNING_BRIEF_LIMITS.manualRunsPerDay) {
204
+ throw new MorningBriefRunError(429, 'manual_runs_exhausted', `Run now is limited to ${MORNING_BRIEF_LIMITS.manualRunsPerDay} briefs a day.`)
205
+ }
206
+ const run = await this.fire('manual', clock.day, manual + 1)
207
+ if (run.submitError) {
208
+ throw new MorningBriefRunError(503, run.submitError.code, run.submitError.message)
209
+ }
210
+ return run
211
+ }
212
+
213
+ private async fire(trigger: MorningBriefTrigger, day: string, attempt: number, resume?: MorningBriefRun): Promise<MorningBriefRun> {
214
+ const clientJobId = trigger === 'scheduled'
215
+ ? scheduledClientJobId(day, this.config.timezone)
216
+ : randomUUID()
217
+
218
+ // Resume: a ledger row with no job id. Adopt an admission the coordinator
219
+ // may already hold for this identity before considering a fresh submit.
220
+ if (resume) {
221
+ const existing = await this.deps.findByClientGeneration(clientJobId, 1).catch(() => undefined)
222
+ if (existing) {
223
+ const adopted: MorningBriefRun = { ...resume, jobId: existing.jobId, lastKnownStatus: existing.status }
224
+ this.replaceRun(adopted)
225
+ this.log(`adopted ${trigger} brief for ${day} as job ${existing.jobId}`)
226
+ return adopted
227
+ }
228
+ }
229
+
230
+ const sessionId = resume?.sessionId ?? this.deps.createSession()
231
+ const messageEra = this.deps.currentMessageEra()
232
+ const globalMsgNum = resume?.globalMsgNum ?? this.deps.currentMessageMax() + 1
233
+ const run: MorningBriefRun = {
234
+ id: resume?.id ?? randomUUID(),
235
+ day,
236
+ trigger,
237
+ attempt,
238
+ firedAt: new Date(this.now()).toISOString(),
239
+ clientJobId,
240
+ generation: 1,
241
+ sessionId,
242
+ messageEra,
243
+ globalMsgNum,
244
+ }
245
+ // Ledger first. A crash after this line is a resume, not a second brief.
246
+ this.replaceRun(run)
247
+
248
+ const prompt = composeMorningBriefPrompt({ config: this.config, day, ownerName: this.deps.ownerName(), trigger })
249
+ try {
250
+ const admission = await this.deps.submit({
251
+ clientJobId,
252
+ generation: 1,
253
+ query: prompt,
254
+ sessionId,
255
+ messageEra,
256
+ globalMsgNum,
257
+ ...(this.config.model ? { model: this.config.model } : {}),
258
+ ...(this.config.effort ? { effort: this.config.effort } : {}),
259
+ activityToolMode: 'status',
260
+ attachmentIds: [],
261
+ attachmentRefs: [],
262
+ })
263
+ const accepted: MorningBriefRun = { ...run, jobId: admission.job.jobId, lastKnownStatus: admission.job.status }
264
+ this.replaceRun(accepted)
265
+ this.log(`${trigger} brief for ${day} admitted as job ${admission.job.jobId} (#${globalMsgNum}${admission.created ? '' : ', already held'})`)
266
+ return accepted
267
+ } catch (error) {
268
+ const failed: MorningBriefRun = { ...run, submitError: errorParts(error) }
269
+ this.replaceRun(failed)
270
+ this.log(`${trigger} brief for ${day} attempt ${attempt} failed to submit: ${failed.submitError!.code}`)
271
+ return failed
272
+ }
273
+ }
274
+
275
+ private replaceRun(run: MorningBriefRun): void {
276
+ const index = this.ledger.runs.findIndex(existing => existing.id === run.id)
277
+ if (index >= 0) this.ledger.runs[index] = run
278
+ else this.ledger.runs.push(run)
279
+ if (this.ledger.runs.length > MORNING_BRIEF_LIMITS.retainedRuns) {
280
+ this.ledger.runs = this.ledger.runs.slice(-MORNING_BRIEF_LIMITS.retainedRuns)
281
+ }
282
+ try {
283
+ saveMorningBriefLedger(this.deps.paths, this.ledger)
284
+ } catch (error) {
285
+ this.log(`ledger write failed: ${(error as Error).message}`)
286
+ }
287
+ }
288
+
289
+ /** Ledger rows newest first, each with the job's live status folded in. */
290
+ async listRuns(limit = 14): Promise<MorningBriefRunView[]> {
291
+ const rows = this.ledger.runs.slice(-Math.max(1, Math.min(limit, MORNING_BRIEF_LIMITS.retainedRuns))).reverse()
292
+ const views: MorningBriefRunView[] = []
293
+ for (const run of rows) {
294
+ if (!run.jobId) {
295
+ views.push({ ...run, status: run.submitError ? 'submit_failed' : 'submitting', ...(run.submitError ? { error: run.submitError } : {}) })
296
+ continue
297
+ }
298
+ const snapshot = await this.deps.getSnapshot(run.jobId).catch(() => undefined)
299
+ if (!snapshot) {
300
+ views.push({ ...run, status: run.lastKnownStatus ?? 'unknown' })
301
+ continue
302
+ }
303
+ if (snapshot.status !== run.lastKnownStatus && isTerminalQueryJobStatus(snapshot.status)) {
304
+ this.replaceRun({ ...run, lastKnownStatus: snapshot.status })
305
+ }
306
+ const answer = snapshot.response ?? snapshot.partialText ?? ''
307
+ views.push({
308
+ ...run,
309
+ status: snapshot.status,
310
+ ...(snapshot.completedAt ? { completedAt: snapshot.completedAt } : {}),
311
+ ...(snapshot.error ? { error: { code: snapshot.error.code, message: snapshot.error.message } } : {}),
312
+ ...(answer ? { preview: answer.replace(/\s+/g, ' ').trim().slice(0, 120) } : {}),
313
+ })
314
+ }
315
+ return views
316
+ }
317
+
318
+ async status(): Promise<MorningBriefStatus> {
319
+ const [lastRun] = await this.listRuns(1)
320
+ const gate: MorningBriefStatus['gate'] = !this.deps.durableJobsEnabled()
321
+ ? 'durable_jobs_off'
322
+ : !this.deps.admissionsOpen()
323
+ ? 'admissions_closed'
324
+ : !this.config.enabled ? 'disabled' : 'ready'
325
+ return {
326
+ protocolVersion: 1,
327
+ enabled: this.config.enabled,
328
+ time: this.config.time,
329
+ timezone: this.config.timezone,
330
+ serverTimezone: serverTimezone(),
331
+ days: [...this.config.days],
332
+ nextRunAt: nextScheduledFire(this.config, this.now()),
333
+ lastRun: lastRun ?? null,
334
+ gate,
335
+ }
336
+ }
337
+
338
+ /** Public-health shape: no prompt, no session ids, no job ids. */
339
+ async capability(): Promise<{
340
+ protocolVersion: 1
341
+ enabled: boolean
342
+ time: string
343
+ timezone: string
344
+ nextRunAt: string | null
345
+ lastRunAt: string | null
346
+ lastRunStatus: string | null
347
+ gate: MorningBriefStatus['gate']
348
+ }> {
349
+ const status = await this.status()
350
+ return {
351
+ protocolVersion: 1,
352
+ enabled: status.enabled,
353
+ time: status.time,
354
+ timezone: status.timezone,
355
+ nextRunAt: status.nextRunAt,
356
+ lastRunAt: status.lastRun?.firedAt ?? null,
357
+ lastRunStatus: status.lastRun?.status ?? null,
358
+ gate: status.gate,
359
+ }
360
+ }
361
+ }
@@ -51,6 +51,7 @@ import { G2_LENS_VARIANT_CAPABILITY } from '../lib/media-store.js'
51
51
  import { MEDIA_CHUNKED_UPLOAD_ENABLED } from './media.js'
52
52
  import { durableQueryJobsCapability } from '../lib/query-job-feature.js'
53
53
  import { getQueryJobRuntimeHealth } from '../lib/query-job-runtime.js'
54
+ import { getMorningBriefScheduler } from '../lib/morning-brief-runtime.js'
54
55
  import { getTranscriptionPolicySnapshot } from '../lib/transcription-policy.js'
55
56
  import { CLI_DEBUG_CAPABILITY } from '../lib/cli-debug-view.js'
56
57
  import { managedRuntimeCapability, managedServerVersion } from '../lib/managed-runtime.js'
@@ -110,6 +111,14 @@ function durableQueryJobStatus() {
110
111
  }
111
112
  }
112
113
 
114
+ async function morningBriefCapabilityOrNull() {
115
+ try {
116
+ return await getMorningBriefScheduler().capability()
117
+ } catch {
118
+ return null
119
+ }
120
+ }
121
+
113
122
  healthRouter.get('/health', async (_req, res) => {
114
123
  const [, staticProbes] = await Promise.all([
115
124
  refreshLocalTtsHealth(),
@@ -199,6 +208,13 @@ healthRouter.get('/health', async (_req, res) => {
199
208
  // diverged, so a constant in both is guaranteed to drift. Absent means an
200
209
  // older server, and the client falls back to single-shot.
201
210
  const mediaLimits = await getMediaLimits({ chunkedUploadEnabled: MEDIA_CHUNKED_UPLOAD_ENABLED })
211
+ // Public on purpose: a settings screen decides whether to show the Morning
212
+ // brief card from health, before it holds a token. Times and gate only —
213
+ // no prompt, no session or job ids. Health must never 500 because of the
214
+ // brief, so BOTH a synchronous construction failure and a rejected status
215
+ // read collapse to "capability absent" (health-query-jobs.test mocks the
216
+ // conversation store to one export, which is exactly such a failure).
217
+ const morningBrief = await morningBriefCapabilityOrNull()
202
218
  const features = {
203
219
  claude: claudeAvailable,
204
220
  codex: codexAvailable,
@@ -216,6 +232,9 @@ healthRouter.get('/health', async (_req, res) => {
216
232
  g2LensVariant: G2_LENS_VARIANT_CAPABILITY,
217
233
  durableQueryJobs: durableJobs.enabled,
218
234
  durableQueryJobsProtocol: durableJobs.protocolVersion,
235
+ // Static: this build carries the scheduler. The live schedule is under
236
+ // capabilities.morningBrief and may be absent while the runtime boots.
237
+ morningBrief: true,
219
238
  localFirstMeetings: localFirstMeetings !== null,
220
239
  transcriptionPolicy: transcription.mode,
221
240
  liveCues: liveCues.available,
@@ -400,6 +419,7 @@ healthRouter.get('/health', async (_req, res) => {
400
419
  finalization: getMeetingFinalizationSnapshot(),
401
420
  },
402
421
  ...(localFirstMeetings ? { localFirstMeetings } : {}),
422
+ ...(morningBrief ? { morningBrief } : {}),
403
423
  },
404
424
  // /api/health is intentionally unauthenticated for setup diagnostics.
405
425
  // Publish capability only; job counts, retention identities, subscriber
@@ -0,0 +1,80 @@
1
+ // Morning brief — the settings and status surface.
2
+ //
3
+ // GET /api/morning-brief config + source catalog + status + recent runs
4
+ // PUT /api/morning-brief patch the config (validated; 400 on a bad field)
5
+ // POST /api/morning-brief/run fire a brief now (202; 409 while one runs; 429 past the daily cap)
6
+ // GET /api/morning-brief/runs recent runs with live job status and message numbers
7
+ // GET /api/morning-brief/preview the exact prompt today's brief would send
8
+ //
9
+ // Authenticated by the global /api middleware like every other settings route.
10
+ // Nothing here returns a brief's full text: the answer lives in the conversation
11
+ // store and the phone reads it through the same history route as any reply.
12
+
13
+ import { Router } from 'express'
14
+ import { MorningBriefConfigError, describeMorningBriefSources } from '../lib/morning-brief-config.js'
15
+ import { MorningBriefRunError, type MorningBriefScheduler } from '../lib/morning-brief-scheduler.js'
16
+
17
+ export function createMorningBriefRouter(scheduler: () => MorningBriefScheduler): Router {
18
+ const router = Router()
19
+
20
+ const describe = async () => {
21
+ const instance = scheduler()
22
+ return {
23
+ config: instance.getConfig(),
24
+ sources: describeMorningBriefSources(),
25
+ status: await instance.status(),
26
+ runs: await instance.listRuns(7),
27
+ }
28
+ }
29
+
30
+ router.get('/morning-brief', async (_req, res) => {
31
+ try {
32
+ res.json(await describe())
33
+ } catch (error) {
34
+ console.error('[morning-brief] describe failed:', error)
35
+ res.status(500).json({ error: { code: 'morning_brief_unavailable', message: 'Could not read the morning brief settings.' } })
36
+ }
37
+ })
38
+
39
+ router.put('/morning-brief', async (req, res) => {
40
+ try {
41
+ scheduler().updateConfig(req.body)
42
+ res.json(await describe())
43
+ } catch (error) {
44
+ if (error instanceof MorningBriefConfigError) {
45
+ return res.status(400).json({ error: { code: error.code, message: error.message } })
46
+ }
47
+ console.error('[morning-brief] update failed:', error)
48
+ res.status(500).json({ error: { code: 'morning_brief_save_failed', message: 'The settings could not be saved.' } })
49
+ }
50
+ })
51
+
52
+ router.post('/morning-brief/run', async (_req, res) => {
53
+ try {
54
+ const run = await scheduler().runNow()
55
+ res.status(202).json({ run })
56
+ } catch (error) {
57
+ if (error instanceof MorningBriefRunError) {
58
+ return res.status(error.status).json({ error: { code: error.code, message: error.message } })
59
+ }
60
+ console.error('[morning-brief] run now failed:', error)
61
+ res.status(500).json({ error: { code: 'morning_brief_run_failed', message: 'The brief could not be started.' } })
62
+ }
63
+ })
64
+
65
+ router.get('/morning-brief/runs', async (req, res) => {
66
+ const limit = Number.parseInt(String(req.query.limit ?? '14'), 10)
67
+ try {
68
+ res.json({ runs: await scheduler().listRuns(Number.isFinite(limit) ? limit : 14) })
69
+ } catch (error) {
70
+ console.error('[morning-brief] runs failed:', error)
71
+ res.status(500).json({ error: { code: 'morning_brief_unavailable', message: 'Could not read the morning brief runs.' } })
72
+ }
73
+ })
74
+
75
+ router.get('/morning-brief/preview', (_req, res) => {
76
+ res.type('text/plain').send(scheduler().previewPrompt('scheduled'))
77
+ })
78
+
79
+ return router
80
+ }