@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.
- package/CHANGELOG.md +103 -0
- package/README.md +30 -0
- package/package.json +1 -1
- package/server/index.ts +10 -0
- package/server/lib/message-reservations.ts +59 -0
- package/server/lib/morning-brief-config.ts +546 -0
- package/server/lib/morning-brief-coverage.ts +376 -0
- package/server/lib/morning-brief-prompt.ts +263 -0
- package/server/lib/morning-brief-runtime.ts +203 -0
- package/server/lib/morning-brief-schedule.ts +158 -0
- package/server/lib/morning-brief-scheduler.ts +412 -0
- package/server/lib/query-job-coordinator.ts +6 -0
- package/server/lib/query-job-runtime.ts +9 -0
- package/server/lib/query-job-store.ts +28 -0
- package/server/routes/health.ts +20 -0
- package/server/routes/message-ref.ts +6 -1
- package/server/routes/morning-brief.ts +94 -0
|
@@ -0,0 +1,546 @@
|
|
|
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
|
+
/** The sections this run was asked for, in order, from the config at fire time. */
|
|
510
|
+
sections?: Array<{ id: MorningBriefSourceId; label: string }>
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
export interface MorningBriefLedger {
|
|
514
|
+
v: 1
|
|
515
|
+
runs: MorningBriefRun[]
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
export function loadMorningBriefLedger(paths: MorningBriefStorePaths): MorningBriefLedger {
|
|
519
|
+
const loaded = loadJsonOrQuarantine<unknown>(paths.runs)
|
|
520
|
+
if (loaded.status !== 'ok' || !loaded.data || typeof loaded.data !== 'object') return { v: 1, runs: [] }
|
|
521
|
+
const raw = (loaded.data as { runs?: unknown }).runs
|
|
522
|
+
const runs = (Array.isArray(raw) ? raw : []).filter(isRunRecord)
|
|
523
|
+
return { v: 1, runs }
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function isRunRecord(value: unknown): value is MorningBriefRun {
|
|
527
|
+
if (!value || typeof value !== 'object') return false
|
|
528
|
+
const run = value as Record<string, unknown>
|
|
529
|
+
return typeof run.id === 'string'
|
|
530
|
+
&& typeof run.day === 'string'
|
|
531
|
+
&& (run.trigger === 'scheduled' || run.trigger === 'manual')
|
|
532
|
+
&& typeof run.firedAt === 'string'
|
|
533
|
+
&& typeof run.clientJobId === 'string'
|
|
534
|
+
&& typeof run.sessionId === 'string'
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
export function saveMorningBriefLedger(paths: MorningBriefStorePaths, ledger: MorningBriefLedger): void {
|
|
538
|
+
ensurePrivateDir(paths.runs)
|
|
539
|
+
const runs = ledger.runs.slice(-MORNING_BRIEF_LIMITS.retainedRuns)
|
|
540
|
+
durableAtomicWriteFileSync(paths.runs, `${JSON.stringify({ v: 1, runs }, null, 2)}\n`, { mode: 0o600 })
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/** Public shape: the config plus the catalog, never the prompt. */
|
|
544
|
+
export function describeMorningBriefSources(): readonly MorningBriefSourceSpec[] {
|
|
545
|
+
return MORNING_BRIEF_SOURCES
|
|
546
|
+
}
|