@gotcos/glasses-server 6.42.1 → 6.43.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,376 @@
1
+ // Morning brief — what each source can actually reach, and what a run produced.
2
+ //
3
+ // The Sources list names an INSTRUCTION ("Meetings", "Knowledge graph"); the
4
+ // server can also say how deep the well is behind it: 2,312 meetings stored,
5
+ // 6,705 memories and 66 threads, the /good-morning skill found under
6
+ // .claude/skills. Miles (2026-09-01): "I'm assuming we should see all of these
7
+ // stats when we turn it on." Those are the same numbers COS Control's Activity
8
+ // tiles show, read from the same places, so the card agrees with the tiles.
9
+ //
10
+ // Two halves, both pure over injected probes so tests never touch a venv:
11
+ // - coverage: per-source state + one summary line, probes cached for a few
12
+ // minutes (they walk the meeting library and shell to the Python bridge);
13
+ // - section outcomes: after a run, which sections the answer actually opened,
14
+ // which came back "<Section>: unavailable", and which were silently skipped.
15
+ //
16
+ // A source the server cannot see at all (Slack, health, dashboards) is
17
+ // `runtime`: the COS brain resolves it when the brief runs. That is a fact,
18
+ // not a failure, and the summary says so instead of showing a dash.
19
+
20
+ import type { MorningBriefConfig, MorningBriefSource, MorningBriefSourceId } from './morning-brief-config.js'
21
+ import { MORNING_BRIEF_SOURCES } from './morning-brief-config.js'
22
+ import { sectionInstruction } from './morning-brief-prompt.js'
23
+
24
+ export type MorningBriefCoverageState = 'ready' | 'empty' | 'unavailable' | 'runtime'
25
+
26
+ export interface MorningBriefSourceCoverage {
27
+ id: MorningBriefSourceId
28
+ state: MorningBriefCoverageState
29
+ /** One line for a settings row. Never a path, never a prompt. */
30
+ summary: string
31
+ counts?: Record<string, number>
32
+ }
33
+
34
+ export interface MorningBriefCoverage {
35
+ checkedAt: string
36
+ ttlMs: number
37
+ sources: MorningBriefSourceCoverage[]
38
+ }
39
+
40
+ export interface MeetingsProbe { count: number; newestMonth: string | null; layout: string }
41
+ export interface ContextProbe {
42
+ memory: { available: boolean; total: number; state?: string }
43
+ threads: { available: boolean; total: number; active?: number; state?: string }
44
+ }
45
+ export interface CalendarProbe { todayCount: number; source?: string }
46
+ export interface TasksProbe { open: number; files: number }
47
+ export interface ReflectionProbe { entries: number; newestAt: string | null }
48
+ export interface SkillProbe { found: boolean; where?: string }
49
+
50
+ /** Every probe is optional: a standalone install has none of the pipeline
51
+ * ones, and `null` means "the server cannot see this" (runtime), not zero. */
52
+ export interface MorningBriefCoverageProbes {
53
+ meetings?: () => MeetingsProbe | null
54
+ context?: () => Promise<ContextProbe | null>
55
+ calendar?: () => Promise<CalendarProbe | null>
56
+ tasks?: () => Promise<TasksProbe | null>
57
+ reflection?: () => ReflectionProbe | null
58
+ skill?: (name: string) => SkillProbe
59
+ }
60
+
61
+ export const MORNING_BRIEF_COVERAGE_TTL_MS = 5 * 60_000
62
+ export const MORNING_BRIEF_COVERAGE_PROBE_TIMEOUT_MS = 8_000
63
+
64
+ interface ProbeResults {
65
+ meetings: MeetingsProbe | null | undefined
66
+ context: ContextProbe | null | undefined
67
+ calendar: CalendarProbe | null | undefined
68
+ tasks: TasksProbe | null | undefined
69
+ reflection: ReflectionProbe | null | undefined
70
+ }
71
+
72
+ function n(value: number): string {
73
+ return Math.max(0, Math.trunc(value)).toLocaleString('en-US')
74
+ }
75
+
76
+ function plural(count: number, unit: string): string {
77
+ return `${n(count)} ${unit}${count === 1 ? '' : 's'}`
78
+ }
79
+
80
+ function str(options: MorningBriefSource['options'], key: string): string {
81
+ const value = options[key]
82
+ return typeof value === 'string' ? value.trim() : ''
83
+ }
84
+
85
+ function monthLabel(month: string | null): string {
86
+ if (!month || !/^\d{4}-\d{2}$/.test(month)) return ''
87
+ const [y, m] = month.split('-').map(Number)
88
+ const names = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
89
+ return names[m - 1] ? `${names[m - 1]} ${y}` : ''
90
+ }
91
+
92
+ function dayLabel(iso: string | null): string {
93
+ if (!iso) return ''
94
+ const ms = Date.parse(iso)
95
+ if (!Number.isFinite(ms)) return ''
96
+ const d = new Date(ms)
97
+ const names = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
98
+ return `${names[d.getUTCMonth()]} ${d.getUTCDate()}`
99
+ }
100
+
101
+ function unavailableReason(state?: string): string {
102
+ switch (state) {
103
+ case 'qdrant_unavailable': return 'memory store unreachable'
104
+ case 'bridge_error': return 'pipeline bridge failed'
105
+ case 'bridge_missing': return 'pipeline bridge missing'
106
+ case 'thread_store_unavailable': return 'thread store unreachable'
107
+ default: return state ? state.replace(/_/g, ' ') : 'unreachable'
108
+ }
109
+ }
110
+
111
+ const RUNTIME_SUMMARY: Record<'waiting' | 'health' | 'pulse', string> = {
112
+ waiting: 'Slack, email, and chat connectors are read by your COS when the brief runs.',
113
+ health: 'Any connected health source (Oura or similar) is read when the brief runs.',
114
+ pulse: 'Dashboards and metrics connectors are read when the brief runs.',
115
+ }
116
+
117
+ /** The coverage row for one source, from probe results already gathered. */
118
+ export function describeSourceCoverage(
119
+ source: MorningBriefSource,
120
+ probes: ProbeResults,
121
+ skillProbe?: (name: string) => SkillProbe,
122
+ ): MorningBriefSourceCoverage {
123
+ const o = source.options
124
+ switch (source.id) {
125
+ case 'calendar': {
126
+ const probe = probes.calendar
127
+ if (probe === undefined || probe === null) {
128
+ return { id: source.id, state: 'runtime', summary: 'Calendars are read through your COS connectors when the brief runs.' }
129
+ }
130
+ const counts = { today: probe.todayCount }
131
+ if (probe.todayCount > 0) {
132
+ return { id: source.id, state: 'ready', summary: `${plural(probe.todayCount, 'event')} on today's calendar${probe.source ? ` (${probe.source.replace(/_/g, ' ')})` : ''}.`, counts }
133
+ }
134
+ return { id: source.id, state: 'empty', summary: 'Calendar reachable; nothing on it today.', counts }
135
+ }
136
+ case 'meetings': {
137
+ const probe = probes.meetings
138
+ if (probe === undefined || probe === null) {
139
+ return { id: source.id, state: 'empty', summary: 'No meeting library configured yet.' }
140
+ }
141
+ const counts = { stored: probe.count }
142
+ if (probe.count > 0) {
143
+ const newest = monthLabel(probe.newestMonth)
144
+ return { id: source.id, state: 'ready', summary: `${plural(probe.count, 'meeting')} stored${newest ? `, newest ${newest}` : ''}.`, counts }
145
+ }
146
+ return { id: source.id, state: 'empty', summary: 'Meeting library is empty so far.', counts }
147
+ }
148
+ case 'tasks': {
149
+ const probe = probes.tasks
150
+ if (probe === undefined || probe === null) {
151
+ return { id: source.id, state: 'runtime', summary: 'Task files in your workspace are read when the brief runs.' }
152
+ }
153
+ const counts = { open: probe.open, files: probe.files }
154
+ if (probe.open > 0) {
155
+ return { id: source.id, state: 'ready', summary: `${plural(probe.open, 'open task')} across ${plural(probe.files, 'task file')}.`, counts }
156
+ }
157
+ return { id: source.id, state: 'empty', summary: 'Task files reachable; nothing open.', counts }
158
+ }
159
+ case 'waiting':
160
+ return { id: source.id, state: 'runtime', summary: RUNTIME_SUMMARY.waiting }
161
+ case 'knowledge': {
162
+ const probe = probes.context
163
+ if (probe === undefined || probe === null) {
164
+ return { id: source.id, state: 'empty', summary: 'No memory or thread store yet; this section skips itself.' }
165
+ }
166
+ const memoryOk = probe.memory.available
167
+ const threadsOk = probe.threads.available
168
+ const counts = { memories: probe.memory.total, threads: probe.threads.total, activeThreads: probe.threads.active ?? 0 }
169
+ if (!memoryOk && !threadsOk) {
170
+ return { id: source.id, state: 'unavailable', summary: `Memory and threads unreachable (${unavailableReason(probe.memory.state)}).`, counts }
171
+ }
172
+ const parts: string[] = []
173
+ if (memoryOk) parts.push(plural(probe.memory.total, 'memory').replace('memorys', 'memories'))
174
+ else parts.push(`memories ${unavailableReason(probe.memory.state)}`)
175
+ if (threadsOk) {
176
+ const active = probe.threads.active ?? 0
177
+ parts.push(`${plural(probe.threads.total, 'thread')}${active > 0 ? ` (${n(active)} active)` : ''}`)
178
+ } else {
179
+ parts.push(`threads ${unavailableReason(probe.threads.state)}`)
180
+ }
181
+ const total = (memoryOk ? probe.memory.total : 0) + (threadsOk ? probe.threads.total : 0)
182
+ return {
183
+ id: source.id,
184
+ state: total > 0 ? (memoryOk && threadsOk ? 'ready' : 'unavailable') : 'empty',
185
+ summary: `${parts.join(' · ')}.`,
186
+ counts,
187
+ }
188
+ }
189
+ case 'reflection': {
190
+ const probe = probes.reflection
191
+ if (probe === undefined || probe === null) {
192
+ return { id: source.id, state: 'runtime', summary: 'Journals and reflection logs in your workspace are read when the brief runs.' }
193
+ }
194
+ const counts = { entries: probe.entries }
195
+ if (probe.entries > 0) {
196
+ const newest = dayLabel(probe.newestAt)
197
+ return { id: source.id, state: 'ready', summary: `${plural(probe.entries, 'reflection')} on record${newest ? `, newest ${newest}` : ''}.`, counts }
198
+ }
199
+ return { id: source.id, state: 'empty', summary: 'Reflection log present but empty.', counts }
200
+ }
201
+ case 'health':
202
+ return { id: source.id, state: 'runtime', summary: RUNTIME_SUMMARY.health }
203
+ case 'reading': {
204
+ const text = str(o, 'text') || 'proverbs'
205
+ return text.toLowerCase() === 'proverbs'
206
+ ? { id: source.id, state: 'ready', summary: 'Public-domain KJV Proverbs, one chapter per calendar day. Nothing to connect.' }
207
+ : { id: source.id, state: 'ready', summary: `Public-domain reading from "${text}", matched to the date.` }
208
+ }
209
+ case 'pulse':
210
+ return { id: source.id, state: 'runtime', summary: str(o, 'instruction') ? `${RUNTIME_SUMMARY.pulse} Instruction set.` : RUNTIME_SUMMARY.pulse }
211
+ case 'skill': {
212
+ const name = str(o, 'name')
213
+ if (!name) return { id: source.id, state: 'empty', summary: 'Name a skill to run as this section.' }
214
+ const slash = name.startsWith('/') ? name : `/${name}`
215
+ if (!skillProbe) return { id: source.id, state: 'runtime', summary: `${slash} is looked up in the workspace when the brief runs.` }
216
+ const probe = skillProbe(name)
217
+ return probe.found
218
+ ? { id: source.id, state: 'ready', summary: `${slash} found under ${probe.where ?? 'the workspace'}.` }
219
+ : { id: source.id, state: 'unavailable', summary: `${slash} not found under .claude/skills, .agents/skills, .claude/commands, or ~/.codex/prompts.` }
220
+ }
221
+ case 'custom': {
222
+ const instruction = str(o, 'instruction')
223
+ return instruction
224
+ ? { id: source.id, state: 'ready', summary: 'Runs as written.' }
225
+ : { id: source.id, state: 'empty', summary: 'Write an instruction to add this section.' }
226
+ }
227
+ }
228
+ }
229
+
230
+ function withTimeout<T>(work: Promise<T>, ms: number): Promise<T | null> {
231
+ return new Promise<T | null>(resolve => {
232
+ const timer = setTimeout(() => resolve(null), ms)
233
+ timer.unref?.()
234
+ work.then(value => { clearTimeout(timer); resolve(value) }, () => { clearTimeout(timer); resolve(null) })
235
+ })
236
+ }
237
+
238
+ export interface MorningBriefCoverageServiceOptions {
239
+ now?: () => number
240
+ ttlMs?: number
241
+ probeTimeoutMs?: number
242
+ log?: (line: string) => void
243
+ }
244
+
245
+ /**
246
+ * Cached probe results + per-call source rows. The probes are the expensive
247
+ * part (a library walk, a Python shell-out); the rows are recomputed on every
248
+ * call from the CURRENT config so a renamed skill or a cleared instruction is
249
+ * reflected immediately without re-probing.
250
+ */
251
+ export class MorningBriefCoverageService {
252
+ private cache: { at: number; results: ProbeResults } | null = null
253
+ private inFlight: Promise<ProbeResults> | null = null
254
+ private readonly now: () => number
255
+ private readonly ttlMs: number
256
+ private readonly probeTimeoutMs: number
257
+
258
+ constructor(private readonly probes: MorningBriefCoverageProbes, options: MorningBriefCoverageServiceOptions = {}) {
259
+ this.now = options.now ?? Date.now
260
+ this.ttlMs = options.ttlMs ?? MORNING_BRIEF_COVERAGE_TTL_MS
261
+ this.probeTimeoutMs = options.probeTimeoutMs ?? MORNING_BRIEF_COVERAGE_PROBE_TIMEOUT_MS
262
+ }
263
+
264
+ invalidate(): void {
265
+ this.cache = null
266
+ }
267
+
268
+ async describe(config: MorningBriefConfig, force = false): Promise<MorningBriefCoverage> {
269
+ const results = await this.results(force)
270
+ const sources = config.sources.map(source => describeSourceCoverage(source, results, this.probes.skill))
271
+ return { checkedAt: new Date(this.cache?.at ?? this.now()).toISOString(), ttlMs: this.ttlMs, sources }
272
+ }
273
+
274
+ private results(force: boolean): Promise<ProbeResults> {
275
+ if (!force && this.cache && this.now() - this.cache.at < this.ttlMs) return Promise.resolve(this.cache.results)
276
+ if (this.inFlight) return this.inFlight
277
+ this.inFlight = this.probeAll().then(results => {
278
+ this.cache = { at: this.now(), results }
279
+ return results
280
+ }).finally(() => { this.inFlight = null })
281
+ return this.inFlight
282
+ }
283
+
284
+ private async probeAll(): Promise<ProbeResults> {
285
+ const sync = <T>(fn: (() => T | null) | undefined): T | null | undefined => {
286
+ if (!fn) return undefined
287
+ try { return fn() } catch { return null }
288
+ }
289
+ const async = <T>(fn: (() => Promise<T | null>) | undefined): Promise<T | null | undefined> => {
290
+ if (!fn) return Promise.resolve(undefined)
291
+ let started: Promise<T | null>
292
+ try { started = fn() } catch { return Promise.resolve(null) }
293
+ return withTimeout(started, this.probeTimeoutMs)
294
+ }
295
+ const [context, calendar, tasks] = await Promise.all([
296
+ async(this.probes.context),
297
+ async(this.probes.calendar),
298
+ async(this.probes.tasks),
299
+ ])
300
+ return {
301
+ meetings: sync(this.probes.meetings),
302
+ context,
303
+ calendar,
304
+ tasks,
305
+ reflection: sync(this.probes.reflection),
306
+ }
307
+ }
308
+ }
309
+
310
+ // ── Section outcomes ──────────────────────────────────────────────────────────
311
+
312
+ export interface MorningBriefSectionRef {
313
+ id: MorningBriefSourceId
314
+ label: string
315
+ }
316
+
317
+ export type MorningBriefSectionState = 'present' | 'unavailable' | 'skipped' | 'missing' | 'pending'
318
+
319
+ export interface MorningBriefSectionOutcome extends MorningBriefSectionRef {
320
+ state: MorningBriefSectionState
321
+ }
322
+
323
+ const KNOWN_IDS = new Set<MorningBriefSourceId>(MORNING_BRIEF_SOURCES.map(spec => spec.id))
324
+
325
+ /** Sources that opt out silently when there is nothing behind them. */
326
+ const SILENT_SKIP = new Set<MorningBriefSourceId>(['knowledge', 'reflection', 'health'])
327
+
328
+ /** The sections a brief for `day` will carry, in order, from the config the
329
+ * run was fired with. Stored on the ledger row so a later config change does
330
+ * not rewrite what an old run was asked for. */
331
+ export function briefSections(config: MorningBriefConfig, day: string): MorningBriefSectionRef[] {
332
+ const out: MorningBriefSectionRef[] = []
333
+ for (const source of config.sources) {
334
+ if (!source.enabled || !KNOWN_IDS.has(source.id)) continue
335
+ const section = sectionInstruction(source, day)
336
+ if (section) out.push({ id: source.id, label: section.label })
337
+ }
338
+ return out
339
+ }
340
+
341
+ function normalize(line: string): string {
342
+ return line.replace(/[*_#`>]/g, '').replace(/\s+/g, ' ').trim().toLowerCase()
343
+ }
344
+
345
+ /**
346
+ * Read a finished brief back against the sections it was asked for. Purely
347
+ * textual: a label on its own line (or opening one) is "present"; the
348
+ * "<Section>: unavailable" contract from the prompt is "unavailable"; a
349
+ * silent-skip section with no label is "skipped"; anything else is "missing".
350
+ * A skill section has no label in the skill-only prompt, so it is "present"
351
+ * unless the answer says the skill was not found.
352
+ */
353
+ export function sectionOutcomes(sections: readonly MorningBriefSectionRef[], answer: string): MorningBriefSectionOutcome[] {
354
+ const lines = answer.split(/\r?\n/).map(normalize).filter(Boolean)
355
+ const text = lines.join('\n')
356
+ return sections.map(section => {
357
+ const label = normalize(section.label)
358
+ if (section.id === 'skill') {
359
+ if (lines.length === 0) return { ...section, state: 'missing' as const }
360
+ const slash = label.replace(/^skill\s+/, '')
361
+ const notFound = new RegExp(`${escapeRegExp(slash)}[^\\n]{0,80}(does not exist|not found|no such skill|could not (?:find|locate)|is not defined)`, 'i')
362
+ const notFoundBefore = new RegExp(`(does not exist|not found|no such skill|could not (?:find|locate))[^\\n]{0,80}${escapeRegExp(slash)}`, 'i')
363
+ return { ...section, state: notFound.test(text) || notFoundBefore.test(text) ? 'unavailable' as const : 'present' as const }
364
+ }
365
+ // The prompt's unavailable line uses a sentence-case name, not the shouted label.
366
+ const unavailable = new RegExp(`^${escapeRegExp(label)}\\s*[:\\-–—]\\s*unavailable`, 'i')
367
+ if (lines.some(line => unavailable.test(line))) return { ...section, state: 'unavailable' as const }
368
+ const opened = lines.some(line => line === label || line.startsWith(`${label}:`) || line.startsWith(`${label} -`) || line.startsWith(`${label} —`))
369
+ if (opened) return { ...section, state: 'present' as const }
370
+ return { ...section, state: SILENT_SKIP.has(section.id) ? 'skipped' as const : 'missing' as const }
371
+ })
372
+ }
373
+
374
+ function escapeRegExp(value: string): string {
375
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
376
+ }
@@ -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
+ }