@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,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
|
+
}
|