@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,544 @@
1
+ // Morning brief — configuration, source catalog, and the runs ledger.
2
+ //
3
+ // WHY THE SERVER OWNS THIS. The brief has to exist before the wearer opens
4
+ // anything: the companion WebView is suspended or dead at 07:00, and Even Hub
5
+ // wipes its storage on every close. The only process awake at that hour is the
6
+ // glasses server, so the schedule, the source list, and the record of what
7
+ // fired live here, under the data home, and every surface (COS Control, the
8
+ // companion, curl) reads and writes the same file through /api/morning-brief.
9
+ //
10
+ // WHY SOURCES ARE DECLARATIVE. The server does not read calendars, Slack, or
11
+ // the knowledge graph. The user's own COS brain does, through whatever skills
12
+ // and connectors it already has. So a "source" here is an instruction the
13
+ // composer turns into one section of the prompt, and a user personalises the
14
+ // brief by choosing sources and their windows rather than by editing prose.
15
+ // Miles (2026-09-01): "the user should be able to define the different sources
16
+ // that will be pulled into their brief so that it's as useful as possible."
17
+
18
+ import { chmodSync, mkdirSync } from 'node:fs'
19
+ import { dirname } from 'node:path'
20
+ import { durableAtomicWriteFileSync, loadJsonOrQuarantine } from './atomic-fs.js'
21
+ import { dataPath } from './data-dir.js'
22
+ import {
23
+ normalizeEffortPreference,
24
+ normalizeModelPreference,
25
+ type EffortPreference,
26
+ type ModelPreference,
27
+ } from '../../shared/model-preference.js'
28
+
29
+ export const MORNING_BRIEF_PROTOCOL_VERSION = 1 as const
30
+ export const MORNING_BRIEF_CONFIG_VERSION = 1 as const
31
+
32
+ export const MORNING_BRIEF_LIMITS = Object.freeze({
33
+ /** How late after the slot a missed fire may still happen (Mac was asleep). */
34
+ maxCatchUpMinutes: 12 * 60,
35
+ defaultCatchUpMinutes: 3 * 60,
36
+ /** Free-text option ceiling. The whole prompt is bounded separately. */
37
+ instructionChars: 1_000,
38
+ skillNameChars: 64,
39
+ /** "Run now" presses per local day. The scheduled fire is on top of this. */
40
+ manualRunsPerDay: 5,
41
+ /** Submission attempts for one scheduled slot before the day is given up. */
42
+ scheduledAttemptsPerDay: 3,
43
+ /** Minimum spacing between those attempts. */
44
+ attemptSpacingMs: 2 * 60_000,
45
+ /** Ledger retention. */
46
+ retainedRuns: 60,
47
+ })
48
+
49
+ export type MorningBriefSourceId =
50
+ | 'calendar'
51
+ | 'meetings'
52
+ | 'tasks'
53
+ | 'waiting'
54
+ | 'knowledge'
55
+ | 'reflection'
56
+ | 'health'
57
+ | 'reading'
58
+ | 'pulse'
59
+ | 'skill'
60
+ | 'custom'
61
+
62
+ export type MorningBriefOptionSpec =
63
+ | { type: 'boolean'; default: boolean; label: string }
64
+ | { type: 'integer'; default: number; min: number; max: number; label: string; unit?: string }
65
+ | { type: 'text'; default: string; maxChars: number; label: string; placeholder?: string }
66
+
67
+ export interface MorningBriefSourceSpec {
68
+ id: MorningBriefSourceId
69
+ label: string
70
+ /** One sentence a settings screen can show. */
71
+ description: string
72
+ defaultEnabled: boolean
73
+ options: Record<string, MorningBriefOptionSpec>
74
+ }
75
+
76
+ export type MorningBriefSourceOptions = Record<string, boolean | number | string>
77
+
78
+ export interface MorningBriefSource {
79
+ id: MorningBriefSourceId
80
+ enabled: boolean
81
+ options: MorningBriefSourceOptions
82
+ }
83
+
84
+ export interface MorningBriefConfig {
85
+ v: typeof MORNING_BRIEF_CONFIG_VERSION
86
+ enabled: boolean
87
+ /** 24h wall-clock "HH:MM" in `timezone`. */
88
+ time: string
89
+ /** IANA zone. Defaults to the Mac's zone at first load. */
90
+ timezone: string
91
+ /** 0 = Sunday … 6 = Saturday. */
92
+ days: number[]
93
+ catchUpMinutes: number
94
+ model?: ModelPreference
95
+ effort?: EffortPreference
96
+ /** Ordered: section order in the brief. */
97
+ sources: MorningBriefSource[]
98
+ /** Appended to every brief, after the sections. */
99
+ closingInstruction: string
100
+ updatedAt: string
101
+ }
102
+
103
+ /**
104
+ * The catalog. Order here is the DEFAULT section order and the order a
105
+ * settings screen lists them. Every id is stable: it is persisted in the
106
+ * config file and appears in the prompt, so renaming one is a migration.
107
+ */
108
+ export const MORNING_BRIEF_SOURCES: readonly MorningBriefSourceSpec[] = Object.freeze<MorningBriefSourceSpec[]>([
109
+ {
110
+ id: 'calendar',
111
+ label: 'Calendar',
112
+ description: "Today's meetings and the first commitment of the day, from any calendar this COS can read.",
113
+ defaultEnabled: true,
114
+ options: {
115
+ includeTomorrow: { type: 'boolean', default: false, label: 'Include tomorrow' },
116
+ },
117
+ },
118
+ {
119
+ id: 'meetings',
120
+ label: 'Meetings',
121
+ description: 'Decisions, deadlines, and owed items from recently synced meetings.',
122
+ defaultEnabled: true,
123
+ options: {
124
+ lookbackDays: { type: 'integer', default: 3, min: 1, max: 14, label: 'Look back', unit: 'days' },
125
+ horizonDays: { type: 'integer', default: 7, min: 1, max: 30, label: 'Due within', unit: 'days' },
126
+ },
127
+ },
128
+ {
129
+ id: 'tasks',
130
+ label: 'Tasks',
131
+ description: 'Open tasks due or overdue inside the horizon, from the task files this COS keeps.',
132
+ defaultEnabled: true,
133
+ options: {
134
+ horizonDays: { type: 'integer', default: 7, min: 1, max: 30, label: 'Due within', unit: 'days' },
135
+ includeOverdue: { type: 'boolean', default: true, label: 'Include overdue' },
136
+ },
137
+ },
138
+ {
139
+ id: 'waiting',
140
+ label: 'Waiting on you',
141
+ description: 'Unanswered mentions and asks across Slack, email, and other connected channels.',
142
+ defaultEnabled: true,
143
+ options: {
144
+ lookbackDays: { type: 'integer', default: 7, min: 1, max: 30, label: 'Look back', unit: 'days' },
145
+ },
146
+ },
147
+ {
148
+ id: 'knowledge',
149
+ label: 'Knowledge graph',
150
+ description: 'Threads, people, and relationships that moved recently in memory or the knowledge graph.',
151
+ defaultEnabled: false,
152
+ options: {
153
+ lookbackDays: { type: 'integer', default: 7, min: 1, max: 30, label: 'Look back', unit: 'days' },
154
+ },
155
+ },
156
+ {
157
+ id: 'reflection',
158
+ label: 'Reflection',
159
+ description: 'The recurring theme from recent reflections or corrections, and one behaviour to carry today.',
160
+ defaultEnabled: false,
161
+ options: {},
162
+ },
163
+ {
164
+ id: 'health',
165
+ label: 'Health',
166
+ description: "Last night's sleep and readiness, when a health source is connected.",
167
+ defaultEnabled: false,
168
+ options: {},
169
+ },
170
+ {
171
+ id: 'reading',
172
+ label: 'Opening reading',
173
+ description: 'A short public-domain reading matched to the date.',
174
+ defaultEnabled: false,
175
+ options: {
176
+ text: { type: 'text', default: 'proverbs', maxChars: 64, label: 'Text', placeholder: 'proverbs' },
177
+ },
178
+ },
179
+ {
180
+ id: 'pulse',
181
+ label: 'Metrics pulse',
182
+ description: 'A daily read of the numbers you steer by, from a connected dashboard or report.',
183
+ defaultEnabled: false,
184
+ options: {
185
+ instruction: {
186
+ type: 'text',
187
+ default: '',
188
+ maxChars: MORNING_BRIEF_LIMITS.instructionChars,
189
+ label: 'What to pull',
190
+ placeholder: 'e.g. Leads and opportunities month-to-date by focus industry, versus last month',
191
+ },
192
+ },
193
+ },
194
+ {
195
+ id: 'skill',
196
+ label: 'Workspace skill',
197
+ description: "Run one of this COS's own skills and use its output as the brief.",
198
+ defaultEnabled: false,
199
+ options: {
200
+ name: { type: 'text', default: '', maxChars: MORNING_BRIEF_LIMITS.skillNameChars, label: 'Skill', placeholder: '/good-morning' },
201
+ },
202
+ },
203
+ {
204
+ id: 'custom',
205
+ label: 'Custom section',
206
+ description: 'Your own instruction for one more section.',
207
+ defaultEnabled: false,
208
+ options: {
209
+ instruction: {
210
+ type: 'text',
211
+ default: '',
212
+ maxChars: MORNING_BRIEF_LIMITS.instructionChars,
213
+ label: 'Instruction',
214
+ placeholder: 'e.g. List the three customer renewals closest to their date',
215
+ },
216
+ },
217
+ },
218
+ ])
219
+
220
+ const SOURCE_BY_ID = new Map(MORNING_BRIEF_SOURCES.map(spec => [spec.id, spec]))
221
+ const TIME_RE = /^([01]\d|2[0-3]):([0-5]\d)$/
222
+ const CONTROL_RE = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g
223
+ const SKILL_NAME_RE = /^\/?[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/
224
+
225
+ export class MorningBriefConfigError extends Error {
226
+ constructor(readonly code: string, message = code) {
227
+ super(message)
228
+ this.name = 'MorningBriefConfigError'
229
+ }
230
+ }
231
+
232
+ export function serverTimezone(): string {
233
+ try {
234
+ const zone = Intl.DateTimeFormat().resolvedOptions().timeZone
235
+ if (zone && isValidTimezone(zone)) return zone
236
+ } catch { /* fall through */ }
237
+ return 'UTC'
238
+ }
239
+
240
+ export function isValidTimezone(zone: unknown): zone is string {
241
+ if (typeof zone !== 'string' || !zone.trim() || zone.length > 64) return false
242
+ try {
243
+ new Intl.DateTimeFormat('en-US', { timeZone: zone })
244
+ return true
245
+ } catch {
246
+ return false
247
+ }
248
+ }
249
+
250
+ export function defaultSources(): MorningBriefSource[] {
251
+ return MORNING_BRIEF_SOURCES.map(spec => ({
252
+ id: spec.id,
253
+ enabled: spec.defaultEnabled,
254
+ options: defaultOptions(spec),
255
+ }))
256
+ }
257
+
258
+ function defaultOptions(spec: MorningBriefSourceSpec): MorningBriefSourceOptions {
259
+ const out: MorningBriefSourceOptions = {}
260
+ for (const [key, option] of Object.entries(spec.options)) out[key] = option.default
261
+ return out
262
+ }
263
+
264
+ export function defaultMorningBriefConfig(now = new Date()): MorningBriefConfig {
265
+ return {
266
+ v: MORNING_BRIEF_CONFIG_VERSION,
267
+ // On by default: the brief IS the start-of-day promise of the product, and
268
+ // one bounded provider run per local day is the cost. Off is one toggle.
269
+ enabled: true,
270
+ time: '07:00',
271
+ timezone: serverTimezone(),
272
+ days: [1, 2, 3, 4, 5],
273
+ catchUpMinutes: MORNING_BRIEF_LIMITS.defaultCatchUpMinutes,
274
+ sources: defaultSources(),
275
+ closingInstruction: '',
276
+ updatedAt: now.toISOString(),
277
+ }
278
+ }
279
+
280
+ function cleanText(value: unknown, maxChars: number): string {
281
+ if (typeof value !== 'string') return ''
282
+ return value.replace(CONTROL_RE, '').replace(/\r\n?/g, '\n').trim().slice(0, maxChars)
283
+ }
284
+
285
+ function normalizeOptions(spec: MorningBriefSourceSpec, raw: unknown, previous?: MorningBriefSourceOptions): MorningBriefSourceOptions {
286
+ const input = raw && typeof raw === 'object' ? raw as Record<string, unknown> : {}
287
+ const out: MorningBriefSourceOptions = {}
288
+ for (const [key, option] of Object.entries(spec.options)) {
289
+ const candidate = key in input ? input[key] : previous?.[key]
290
+ switch (option.type) {
291
+ case 'boolean':
292
+ out[key] = typeof candidate === 'boolean' ? candidate : option.default
293
+ break
294
+ case 'integer': {
295
+ const n = typeof candidate === 'number' ? candidate : Number(candidate)
296
+ out[key] = Number.isSafeInteger(n) ? Math.min(option.max, Math.max(option.min, n)) : option.default
297
+ break
298
+ }
299
+ case 'text': {
300
+ const text = cleanText(candidate, option.maxChars)
301
+ if (spec.id === 'skill' && key === 'name' && text && !SKILL_NAME_RE.test(text)) {
302
+ throw new MorningBriefConfigError('invalid_skill_name', 'A skill name is a slash name like /good-morning.')
303
+ }
304
+ out[key] = text || option.default
305
+ break
306
+ }
307
+ }
308
+ }
309
+ return out
310
+ }
311
+
312
+ /**
313
+ * Normalise an untrusted source list against the catalog. Order is preserved
314
+ * for known ids, unknown ids are dropped, and every catalog id missing from the
315
+ * input is appended with its defaults — so a config written by an older build
316
+ * gains new sources DISABLED rather than silently on.
317
+ */
318
+ export function normalizeSources(raw: unknown, previous?: MorningBriefSource[]): MorningBriefSource[] {
319
+ const previousById = new Map((previous ?? []).map(source => [source.id, source]))
320
+ const seen = new Set<MorningBriefSourceId>()
321
+ const out: MorningBriefSource[] = []
322
+ for (const entry of Array.isArray(raw) ? raw : []) {
323
+ if (!entry || typeof entry !== 'object') continue
324
+ const id = (entry as { id?: unknown }).id
325
+ if (typeof id !== 'string') continue
326
+ const spec = SOURCE_BY_ID.get(id as MorningBriefSourceId)
327
+ if (!spec || seen.has(spec.id)) continue
328
+ seen.add(spec.id)
329
+ const prior = previousById.get(spec.id)
330
+ const enabledRaw = (entry as { enabled?: unknown }).enabled
331
+ out.push({
332
+ id: spec.id,
333
+ enabled: typeof enabledRaw === 'boolean' ? enabledRaw : prior?.enabled ?? spec.defaultEnabled,
334
+ options: normalizeOptions(spec, (entry as { options?: unknown }).options, prior?.options),
335
+ })
336
+ }
337
+ for (const spec of MORNING_BRIEF_SOURCES) {
338
+ if (seen.has(spec.id)) continue
339
+ const prior = previousById.get(spec.id)
340
+ out.push(prior
341
+ ? { id: spec.id, enabled: prior.enabled, options: normalizeOptions(spec, prior.options) }
342
+ : { id: spec.id, enabled: spec.defaultEnabled, options: defaultOptions(spec) })
343
+ }
344
+ return out
345
+ }
346
+
347
+ function normalizeDays(raw: unknown, fallback: number[]): number[] {
348
+ if (!Array.isArray(raw)) return fallback
349
+ return [...new Set(raw
350
+ .map(value => (typeof value === 'number' ? value : Number(value)))
351
+ .filter(value => Number.isInteger(value) && value >= 0 && value <= 6))]
352
+ .sort((a, b) => a - b)
353
+ }
354
+
355
+ /**
356
+ * Apply an untrusted patch to a known-good config. Every field is validated;
357
+ * an invalid field throws rather than silently keeping the old value, because
358
+ * a settings screen that shows "Saved" over a rejected time is the failure
359
+ * this is guarding against.
360
+ */
361
+ export function applyMorningBriefPatch(current: MorningBriefConfig, raw: unknown, now = new Date()): MorningBriefConfig {
362
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
363
+ throw new MorningBriefConfigError('invalid_patch', 'Expected a JSON object.')
364
+ }
365
+ const patch = raw as Record<string, unknown>
366
+ const next: MorningBriefConfig = { ...current, sources: current.sources.map(source => ({ ...source, options: { ...source.options } })) }
367
+
368
+ if ('enabled' in patch) {
369
+ if (typeof patch.enabled !== 'boolean') throw new MorningBriefConfigError('invalid_enabled', 'enabled must be true or false.')
370
+ next.enabled = patch.enabled
371
+ }
372
+ if ('time' in patch) {
373
+ const time = typeof patch.time === 'string' ? patch.time.trim() : ''
374
+ if (!TIME_RE.test(time)) throw new MorningBriefConfigError('invalid_time', 'time must be HH:MM in 24-hour form, e.g. 07:00.')
375
+ next.time = time
376
+ }
377
+ if ('timezone' in patch) {
378
+ if (!isValidTimezone(patch.timezone)) throw new MorningBriefConfigError('invalid_timezone', 'timezone must be an IANA zone such as America/Chicago.')
379
+ next.timezone = patch.timezone
380
+ }
381
+ if ('days' in patch) {
382
+ if (!Array.isArray(patch.days)) throw new MorningBriefConfigError('invalid_days', 'days must be a list of weekday numbers, 0 (Sunday) to 6.')
383
+ next.days = normalizeDays(patch.days, current.days)
384
+ }
385
+ if ('catchUpMinutes' in patch) {
386
+ const minutes = Number(patch.catchUpMinutes)
387
+ if (!Number.isSafeInteger(minutes) || minutes < 0 || minutes > MORNING_BRIEF_LIMITS.maxCatchUpMinutes) {
388
+ throw new MorningBriefConfigError('invalid_catch_up', `catchUpMinutes must be 0 to ${MORNING_BRIEF_LIMITS.maxCatchUpMinutes}.`)
389
+ }
390
+ next.catchUpMinutes = minutes
391
+ }
392
+ if ('model' in patch) {
393
+ if (patch.model == null || patch.model === '') {
394
+ delete next.model
395
+ } else {
396
+ const model = normalizeModelPreference(patch.model)
397
+ if (!model) throw new MorningBriefConfigError('invalid_model', 'Unknown model preference.')
398
+ next.model = model
399
+ }
400
+ }
401
+ if ('effort' in patch) {
402
+ if (patch.effort == null || patch.effort === '') {
403
+ delete next.effort
404
+ } else {
405
+ const effort = normalizeEffortPreference(patch.effort)
406
+ if (!effort) throw new MorningBriefConfigError('invalid_effort', 'Unknown effort preference.')
407
+ next.effort = effort
408
+ }
409
+ }
410
+ if ('sources' in patch) {
411
+ if (!Array.isArray(patch.sources)) throw new MorningBriefConfigError('invalid_sources', 'sources must be a list.')
412
+ next.sources = normalizeSources(patch.sources, current.sources)
413
+ }
414
+ if ('closingInstruction' in patch) {
415
+ if (patch.closingInstruction != null && typeof patch.closingInstruction !== 'string') {
416
+ throw new MorningBriefConfigError('invalid_closing', 'closingInstruction must be text.')
417
+ }
418
+ next.closingInstruction = cleanText(patch.closingInstruction, MORNING_BRIEF_LIMITS.instructionChars)
419
+ }
420
+ next.updatedAt = now.toISOString()
421
+ return next
422
+ }
423
+
424
+ /** Coerce whatever is on disk into a valid config. Never throws: a damaged
425
+ * field falls back to its default so one bad byte cannot silence the brief. */
426
+ export function coerceMorningBriefConfig(raw: unknown, now = new Date()): MorningBriefConfig {
427
+ const base = defaultMorningBriefConfig(now)
428
+ if (!raw || typeof raw !== 'object') return base
429
+ const input = raw as Record<string, unknown>
430
+ const time = typeof input.time === 'string' && TIME_RE.test(input.time.trim()) ? input.time.trim() : base.time
431
+ const catchUp = Number(input.catchUpMinutes)
432
+ const model = normalizeModelPreference(input.model)
433
+ const effort = normalizeEffortPreference(input.effort)
434
+ let sources: MorningBriefSource[]
435
+ try { sources = normalizeSources(input.sources) } catch { sources = base.sources }
436
+ return {
437
+ v: MORNING_BRIEF_CONFIG_VERSION,
438
+ enabled: typeof input.enabled === 'boolean' ? input.enabled : base.enabled,
439
+ time,
440
+ timezone: isValidTimezone(input.timezone) ? input.timezone : base.timezone,
441
+ days: normalizeDays(input.days, base.days),
442
+ catchUpMinutes: Number.isSafeInteger(catchUp) && catchUp >= 0 && catchUp <= MORNING_BRIEF_LIMITS.maxCatchUpMinutes
443
+ ? catchUp : base.catchUpMinutes,
444
+ ...(model ? { model } : {}),
445
+ ...(effort ? { effort } : {}),
446
+ sources,
447
+ closingInstruction: cleanText(input.closingInstruction, MORNING_BRIEF_LIMITS.instructionChars),
448
+ updatedAt: typeof input.updatedAt === 'string' && !Number.isNaN(Date.parse(input.updatedAt))
449
+ ? input.updatedAt : base.updatedAt,
450
+ }
451
+ }
452
+
453
+ // ── Persistence ───────────────────────────────────────────────────────────────
454
+
455
+ export interface MorningBriefStorePaths {
456
+ config: string
457
+ runs: string
458
+ }
459
+
460
+ export function morningBriefPaths(root?: string): MorningBriefStorePaths {
461
+ const configuredRoot = root ?? process.env.COS_MORNING_BRIEF_DIR?.trim()
462
+ const base = configuredRoot ? configuredRoot : dataPath('morning-brief')
463
+ return { config: `${base}/config.json`, runs: `${base}/runs.json` }
464
+ }
465
+
466
+ function ensurePrivateDir(file: string): void {
467
+ const dir = dirname(file)
468
+ mkdirSync(dir, { recursive: true, mode: 0o700 })
469
+ try { chmodSync(dir, 0o700) } catch { /* best effort */ }
470
+ }
471
+
472
+ export function loadMorningBriefConfig(paths: MorningBriefStorePaths, now = new Date()): { config: MorningBriefConfig; fresh: boolean; quarantinedAs?: string } {
473
+ const loaded = loadJsonOrQuarantine<unknown>(paths.config)
474
+ if (loaded.status === 'ok') return { config: coerceMorningBriefConfig(loaded.data, now), fresh: false }
475
+ const config = defaultMorningBriefConfig(now)
476
+ return loaded.status === 'corrupt'
477
+ ? { config, fresh: true, quarantinedAs: loaded.quarantinedAs }
478
+ : { config, fresh: true }
479
+ }
480
+
481
+ export function saveMorningBriefConfig(paths: MorningBriefStorePaths, config: MorningBriefConfig): void {
482
+ ensurePrivateDir(paths.config)
483
+ durableAtomicWriteFileSync(paths.config, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 })
484
+ }
485
+
486
+ // ── Runs ledger ───────────────────────────────────────────────────────────────
487
+
488
+ export type MorningBriefTrigger = 'scheduled' | 'manual'
489
+
490
+ export interface MorningBriefRun {
491
+ /** Stable ledger id. */
492
+ id: string
493
+ /** Local calendar day (YYYY-MM-DD in the config timezone) the run belongs to. */
494
+ day: string
495
+ trigger: MorningBriefTrigger
496
+ attempt: number
497
+ firedAt: string
498
+ clientJobId: string
499
+ generation: 1
500
+ sessionId: string
501
+ messageEra?: string
502
+ globalMsgNum?: number
503
+ /** Present once the coordinator accepted the job. */
504
+ jobId?: string
505
+ /** Present when submission itself failed (the job never existed). */
506
+ submitError?: { code: string; message: string }
507
+ /** Terminal state copied from the job when last observed. */
508
+ lastKnownStatus?: string
509
+ }
510
+
511
+ export interface MorningBriefLedger {
512
+ v: 1
513
+ runs: MorningBriefRun[]
514
+ }
515
+
516
+ export function loadMorningBriefLedger(paths: MorningBriefStorePaths): MorningBriefLedger {
517
+ const loaded = loadJsonOrQuarantine<unknown>(paths.runs)
518
+ if (loaded.status !== 'ok' || !loaded.data || typeof loaded.data !== 'object') return { v: 1, runs: [] }
519
+ const raw = (loaded.data as { runs?: unknown }).runs
520
+ const runs = (Array.isArray(raw) ? raw : []).filter(isRunRecord)
521
+ return { v: 1, runs }
522
+ }
523
+
524
+ function isRunRecord(value: unknown): value is MorningBriefRun {
525
+ if (!value || typeof value !== 'object') return false
526
+ const run = value as Record<string, unknown>
527
+ return typeof run.id === 'string'
528
+ && typeof run.day === 'string'
529
+ && (run.trigger === 'scheduled' || run.trigger === 'manual')
530
+ && typeof run.firedAt === 'string'
531
+ && typeof run.clientJobId === 'string'
532
+ && typeof run.sessionId === 'string'
533
+ }
534
+
535
+ export function saveMorningBriefLedger(paths: MorningBriefStorePaths, ledger: MorningBriefLedger): void {
536
+ ensurePrivateDir(paths.runs)
537
+ const runs = ledger.runs.slice(-MORNING_BRIEF_LIMITS.retainedRuns)
538
+ durableAtomicWriteFileSync(paths.runs, `${JSON.stringify({ v: 1, runs }, null, 2)}\n`, { mode: 0o600 })
539
+ }
540
+
541
+ /** Public shape: the config plus the catalog, never the prompt. */
542
+ export function describeMorningBriefSources(): readonly MorningBriefSourceSpec[] {
543
+ return MORNING_BRIEF_SOURCES
544
+ }