@gotcos/glasses-server 6.43.0 → 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 +34 -0
- package/README.md +7 -0
- package/package.json +1 -1
- package/server/lib/message-reservations.ts +59 -0
- package/server/lib/morning-brief-config.ts +2 -0
- package/server/lib/morning-brief-coverage.ts +376 -0
- package/server/lib/morning-brief-runtime.ts +149 -4
- package/server/lib/morning-brief-scheduler.ts +54 -3
- 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/message-ref.ts +6 -1
- package/server/routes/morning-brief.ts +15 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,37 @@
|
|
|
1
|
+
## 6.43.1
|
|
2
|
+
|
|
3
|
+
Two things the first night of the morning brief taught us, plus the depth
|
|
4
|
+
behind each source.
|
|
5
|
+
|
|
6
|
+
- **A running brief no longer collides with your next message.** The brief
|
|
7
|
+
reserved #74 at 22:18 and was still running when the phone minted #74 for
|
|
8
|
+
the next prompt at 22:25, because `/api/message-counter` only counted
|
|
9
|
+
exchanges that had already been projected. Numbers held by admitted,
|
|
10
|
+
not-yet-terminal durable jobs (and by the brief's own ledger row, which
|
|
11
|
+
exists before the job does) now count toward the ceiling. New
|
|
12
|
+
`lib/message-reservations.ts` registry; the job store keeps the number on
|
|
13
|
+
its always-in-memory identity so the answer is synchronous and survives a
|
|
14
|
+
restart.
|
|
15
|
+
- **Sources show what is behind them.** `GET /api/morning-brief` now carries
|
|
16
|
+
`coverage`: one row per source with a state (`ready`, `empty`,
|
|
17
|
+
`unavailable`, `runtime`) and a summary line such as "2,312 meetings
|
|
18
|
+
stored, newest Sep 2026", "6,705 memories · 66 threads (12 active)", or
|
|
19
|
+
"/good-morning found under .claude/skills". Same wells COS Control's
|
|
20
|
+
Activity tiles read (meeting library, context status, the pipeline bridge
|
|
21
|
+
for calendar and tasks, the reflection log). Probes are cached for five
|
|
22
|
+
minutes and bounded to eight seconds each; a source the server cannot see
|
|
23
|
+
(Slack, health, dashboards) says so as `runtime` instead of showing a dash.
|
|
24
|
+
`GET /api/morning-brief/coverage?refresh=1` re-probes on demand.
|
|
25
|
+
- **Each run records what it was asked for and what came back.** The ledger
|
|
26
|
+
row stores the sections the brief was composed with, and once the job
|
|
27
|
+
completes the run view reports each as `present`, `unavailable` (the
|
|
28
|
+
prompt's "<Section>: unavailable" line), `skipped` (silent-skip sections
|
|
29
|
+
with nothing behind them), or `missing`. A skill section is present unless
|
|
30
|
+
the answer says the skill was not found.
|
|
31
|
+
|
|
32
|
+
No config migration: existing `config.json` and `runs.json` files load as
|
|
33
|
+
they are; older run rows simply have no section outcome.
|
|
34
|
+
|
|
1
35
|
## 6.43.0
|
|
2
36
|
|
|
3
37
|
The brief is waiting before you ask for it.
|
package/README.md
CHANGED
|
@@ -231,6 +231,13 @@ doubles it; a Mac asleep at the slot still fires inside a three-hour catch-up
|
|
|
231
231
|
window; "Run now" is capped at five a day. Off entirely when Background jobs
|
|
232
232
|
are off. The brief is read-only by contract.
|
|
233
233
|
|
|
234
|
+
Since 6.43.1 the same `GET /api/morning-brief` response carries `coverage`,
|
|
235
|
+
one row per source saying what the server can see behind it (meetings stored,
|
|
236
|
+
memories and threads, whether the named skill exists) and every run reports
|
|
237
|
+
which sections the answer actually opened. Numbers reserved by a running
|
|
238
|
+
brief count toward `/api/message-counter`, so the phone never mints the same
|
|
239
|
+
`#NNN` twice.
|
|
240
|
+
|
|
234
241
|
## Speaker diarization (opt-in)
|
|
235
242
|
|
|
236
243
|
Without a voiceprint model this server does not classify speakers at all — it
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotcos/glasses-server",
|
|
3
|
-
"version": "6.43.
|
|
3
|
+
"version": "6.43.1",
|
|
4
4
|
"description": "COS Glasses \u2014 self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, Cursor Agent CLI, or local Ollama",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Message-number reservations that are not exchanges yet.
|
|
2
|
+
//
|
|
3
|
+
// The phone mints its own #NNN from the `max` that /api/message-counter
|
|
4
|
+
// reports, and a durable job carries the number it was minted with until its
|
|
5
|
+
// terminal projection writes the exchange into the session. Between admission
|
|
6
|
+
// and projection the number existed nowhere the counter looked, so a second
|
|
7
|
+
// producer minted it again: on 2026-09-01 the scheduled morning brief reserved
|
|
8
|
+
// #74 at 22:18:40 and the phone handed #74 to "Cross surface" at 22:25:15,
|
|
9
|
+
// because its counter only re-syncs at boot and the server said max was 73.
|
|
10
|
+
//
|
|
11
|
+
// Every holder of a not-yet-projected number registers a source here. The
|
|
12
|
+
// counter route and the brief's own reservation read the union. Sources
|
|
13
|
+
// register themselves rather than being imported, because the morning-brief
|
|
14
|
+
// runtime already imports the counter route (a direct import would cycle).
|
|
15
|
+
|
|
16
|
+
import { exchangeBelongsToEra } from './message-era.js'
|
|
17
|
+
|
|
18
|
+
export interface MessageReservation {
|
|
19
|
+
globalMsgNum: number
|
|
20
|
+
messageEra?: string
|
|
21
|
+
/** Diagnostic owner label, e.g. `job:<id>` or `brief:<day>`. Never a prompt. */
|
|
22
|
+
owner: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type MessageReservationSource = () => readonly MessageReservation[]
|
|
26
|
+
|
|
27
|
+
const sources = new Set<MessageReservationSource>()
|
|
28
|
+
|
|
29
|
+
export function registerMessageReservationSource(source: MessageReservationSource): () => void {
|
|
30
|
+
sources.add(source)
|
|
31
|
+
return () => { sources.delete(source) }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Every live reservation in `era`. A source that throws contributes nothing;
|
|
35
|
+
* the counter must never fail because one holder is mid-shutdown. */
|
|
36
|
+
export function reservedGlobalMsgNums(era: string): MessageReservation[] {
|
|
37
|
+
const out: MessageReservation[] = []
|
|
38
|
+
for (const source of sources) {
|
|
39
|
+
let items: readonly MessageReservation[]
|
|
40
|
+
try { items = source() } catch { continue }
|
|
41
|
+
for (const item of items) {
|
|
42
|
+
if (!Number.isSafeInteger(item.globalMsgNum) || item.globalMsgNum <= 0) continue
|
|
43
|
+
if (!exchangeBelongsToEra(item, era)) continue
|
|
44
|
+
out.push(item)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return out
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function maxReservedGlobalMsgNum(era: string): number {
|
|
51
|
+
let max = 0
|
|
52
|
+
for (const item of reservedGlobalMsgNums(era)) if (item.globalMsgNum > max) max = item.globalMsgNum
|
|
53
|
+
return max
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Test-only. */
|
|
57
|
+
export function resetMessageReservationSourcesForTests(): void {
|
|
58
|
+
sources.clear()
|
|
59
|
+
}
|
|
@@ -506,6 +506,8 @@ export interface MorningBriefRun {
|
|
|
506
506
|
submitError?: { code: string; message: string }
|
|
507
507
|
/** Terminal state copied from the job when last observed. */
|
|
508
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 }>
|
|
509
511
|
}
|
|
510
512
|
|
|
511
513
|
export interface MorningBriefLedger {
|
|
@@ -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
|
+
}
|
|
@@ -4,6 +4,9 @@
|
|
|
4
4
|
// health, and index.ts all see the same instance, and tests construct their
|
|
5
5
|
// own MorningBriefScheduler with fake deps instead of importing this file.
|
|
6
6
|
|
|
7
|
+
import { existsSync, readFileSync, statSync } from 'node:fs'
|
|
8
|
+
import { homedir } from 'node:os'
|
|
9
|
+
import { basename, join, resolve } from 'node:path'
|
|
7
10
|
import { getActiveSessions, createSession } from './conversation.js'
|
|
8
11
|
import { currentMessageEra, exchangeBelongsToEra } from './message-era.js'
|
|
9
12
|
import { maxGlobalMsgNumInDir } from '../routes/message-ref.js'
|
|
@@ -12,11 +15,32 @@ import { getOwnerName } from './profile.js'
|
|
|
12
15
|
import { durableQueryJobsEnabled } from './query-job-feature.js'
|
|
13
16
|
import { queryJobCoordinator } from './query-job-runtime.js'
|
|
14
17
|
import { maintenanceAdmissionsOpen } from './maintenance-lifecycle.js'
|
|
18
|
+
import { maxReservedGlobalMsgNum, registerMessageReservationSource } from './message-reservations.js'
|
|
15
19
|
import { morningBriefPaths } from './morning-brief-config.js'
|
|
16
20
|
import { MorningBriefScheduler } from './morning-brief-scheduler.js'
|
|
21
|
+
import {
|
|
22
|
+
MorningBriefCoverageService,
|
|
23
|
+
type CalendarProbe,
|
|
24
|
+
type ContextProbe,
|
|
25
|
+
type MeetingsProbe,
|
|
26
|
+
type ReflectionProbe,
|
|
27
|
+
type SkillProbe,
|
|
28
|
+
type TasksProbe,
|
|
29
|
+
} from './morning-brief-coverage.js'
|
|
30
|
+
import {
|
|
31
|
+
listCosOperationsMeetingDays,
|
|
32
|
+
listCosOperationsMeetingMonths,
|
|
33
|
+
listDirectLibraryMeetingDays,
|
|
34
|
+
listDirectLibraryMeetingMonths,
|
|
35
|
+
resolveMeetingLibrary,
|
|
36
|
+
} from './cos-operations-meetings.js'
|
|
37
|
+
import { getMeetingStore } from './meeting-store.js'
|
|
38
|
+
import { COS_SCRIPTS_DIR, callPython, contextSourceAvailable, pythonBridgeAvailable } from './python-bridge.js'
|
|
39
|
+
import { resolveProviderWorkDir } from './launch-dir.js'
|
|
17
40
|
|
|
18
|
-
/** Highest stamped message number in the active era: live sessions
|
|
19
|
-
*
|
|
41
|
+
/** Highest stamped message number in the active era: live sessions, the day
|
|
42
|
+
* archives, AND every number a not-yet-projected job or brief already holds.
|
|
43
|
+
* The same arithmetic /api/message-counter serves the phone. */
|
|
20
44
|
export function currentMessageMax(): number {
|
|
21
45
|
const era = currentMessageEra()
|
|
22
46
|
let liveMax = 0
|
|
@@ -26,14 +50,122 @@ export function currentMessageMax(): number {
|
|
|
26
50
|
if (typeof exchange?.globalMsgNum === 'number' && exchange.globalMsgNum > liveMax) liveMax = exchange.globalMsgNum
|
|
27
51
|
}
|
|
28
52
|
}
|
|
29
|
-
return Math.max(liveMax, maxGlobalMsgNumInDir(dataPath('archive'), era))
|
|
53
|
+
return Math.max(liveMax, maxGlobalMsgNumInDir(dataPath('archive'), era), maxReservedGlobalMsgNum(era))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ── Coverage probes (the same wells COS Control's Activity tiles read) ────────
|
|
57
|
+
|
|
58
|
+
function sumDayCounts(months: string[], days: (month: string) => Array<{ count: number }>): number {
|
|
59
|
+
let total = 0
|
|
60
|
+
for (const month of months) for (const day of days(month)) total += day.count
|
|
61
|
+
return total
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function probeMeetings(): MeetingsProbe | null {
|
|
65
|
+
const library = resolveMeetingLibrary()
|
|
66
|
+
if (library.layout === 'direct') {
|
|
67
|
+
const months = listDirectLibraryMeetingMonths()
|
|
68
|
+
return { count: sumDayCounts(months, listDirectLibraryMeetingDays), newestMonth: months[0] ?? null, layout: library.layout }
|
|
69
|
+
}
|
|
70
|
+
if (library.layout === 'multi_domain') {
|
|
71
|
+
const months = listCosOperationsMeetingMonths('all')
|
|
72
|
+
return { count: sumDayCounts(months, month => listCosOperationsMeetingDays(month, 'all')), newestMonth: months[0] ?? null, layout: library.layout }
|
|
73
|
+
}
|
|
74
|
+
if (library.layout === 'invalid_explicit_root') return null
|
|
75
|
+
const store = getMeetingStore()
|
|
76
|
+
const months = store.listMonths()
|
|
77
|
+
return { count: sumDayCounts(months, month => store.listDayCounts(month)), newestMonth: months[0] ?? null, layout: 'standalone' }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function probeContext(): Promise<ContextProbe | null> {
|
|
81
|
+
if (contextSourceAvailable() === null) return null
|
|
82
|
+
const data = await callPython(['context-status'], 8_000) as {
|
|
83
|
+
memory?: { available?: unknown; total?: unknown; state?: unknown; reason?: unknown }
|
|
84
|
+
threads?: { available?: unknown; total?: unknown; active?: unknown; state?: unknown; reason?: unknown }
|
|
85
|
+
} | null
|
|
86
|
+
if (!data || typeof data !== 'object') return null
|
|
87
|
+
const num = (value: unknown) => typeof value === 'number' && Number.isFinite(value) ? value : 0
|
|
88
|
+
const state = (part?: { state?: unknown; reason?: unknown }) =>
|
|
89
|
+
typeof part?.state === 'string' ? part.state : typeof part?.reason === 'string' ? part.reason : undefined
|
|
90
|
+
return {
|
|
91
|
+
memory: { available: data.memory?.available === true, total: num(data.memory?.total), state: state(data.memory) },
|
|
92
|
+
threads: { available: data.threads?.available === true, total: num(data.threads?.total), active: num(data.threads?.active), state: state(data.threads) },
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function probeCalendar(): Promise<CalendarProbe | null> {
|
|
97
|
+
if (!pythonBridgeAvailable()) return null
|
|
98
|
+
const data = await callPython(['calendar'], 8_000) as { meetings_today_count?: unknown; today_events?: unknown; data_source?: unknown } | null
|
|
99
|
+
if (!data || typeof data !== 'object') return null
|
|
100
|
+
const today = typeof data.meetings_today_count === 'number'
|
|
101
|
+
? data.meetings_today_count
|
|
102
|
+
: Array.isArray(data.today_events) ? data.today_events.length : 0
|
|
103
|
+
return { todayCount: Math.max(0, Math.trunc(today)), ...(typeof data.data_source === 'string' ? { source: data.data_source } : {}) }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function probeTasks(): Promise<TasksProbe | null> {
|
|
107
|
+
if (!pythonBridgeAvailable()) return null
|
|
108
|
+
const data = await callPython(['tasks'], 8_000) as Record<string, unknown> | null
|
|
109
|
+
if (!data || typeof data !== 'object') return null
|
|
110
|
+
let open = 0
|
|
111
|
+
let files = 0
|
|
112
|
+
for (const rows of Object.values(data)) {
|
|
113
|
+
if (!Array.isArray(rows)) continue
|
|
114
|
+
files += 1
|
|
115
|
+
for (const row of rows) {
|
|
116
|
+
if (row && typeof row === 'object' && (row as { is_checked?: unknown }).is_checked !== true) open += 1
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return { open, files }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const REFLECTION_LOG_MAX_BYTES = 8 * 1024 * 1024
|
|
123
|
+
|
|
124
|
+
export function probeReflection(): ReflectionProbe | null {
|
|
125
|
+
if (!COS_SCRIPTS_DIR) return null
|
|
126
|
+
const file = resolve(COS_SCRIPTS_DIR, '.cos_reflect_log.jsonl')
|
|
127
|
+
let stat
|
|
128
|
+
try { stat = statSync(file) } catch { return null }
|
|
129
|
+
if (!stat.isFile()) return null
|
|
130
|
+
const newestAt = stat.mtime.toISOString()
|
|
131
|
+
if (stat.size > REFLECTION_LOG_MAX_BYTES) return { entries: -1, newestAt }
|
|
132
|
+
let entries = 0
|
|
133
|
+
try {
|
|
134
|
+
for (const line of readFileSync(file, 'utf8').split('\n')) if (line.trim()) entries += 1
|
|
135
|
+
} catch { return null }
|
|
136
|
+
return { entries, newestAt }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const SKILL_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,63}$/
|
|
140
|
+
|
|
141
|
+
/** Where a skill would be found, in the order the prompt tells the agent to
|
|
142
|
+
* look. Returns a folder LABEL, never an absolute path. */
|
|
143
|
+
export function probeSkill(rawName: string, workDir = resolveProviderWorkDir({ scriptsDir: COS_SCRIPTS_DIR }), home = homedir()): SkillProbe {
|
|
144
|
+
const name = rawName.trim().replace(/^\//, '')
|
|
145
|
+
if (!SKILL_NAME_RE.test(name) || basename(name) !== name) return { found: false }
|
|
146
|
+
const nested = name.replace(/:/g, '/')
|
|
147
|
+
const candidates: Array<[string, string]> = [
|
|
148
|
+
['.claude/skills', join(workDir, '.claude', 'skills', name, 'SKILL.md')],
|
|
149
|
+
['.agents/skills', join(workDir, '.agents', 'skills', name, 'SKILL.md')],
|
|
150
|
+
['.agents/skills', join(workDir, '.agents', 'skills', nested, 'SKILL.md')],
|
|
151
|
+
['.claude/commands', join(workDir, '.claude', 'commands', `${name}.md`)],
|
|
152
|
+
['.claude/commands', join(workDir, '.claude', 'commands', `${nested}.md`)],
|
|
153
|
+
['~/.claude/skills', join(home, '.claude', 'skills', name, 'SKILL.md')],
|
|
154
|
+
['~/.claude/commands', join(home, '.claude', 'commands', `${name}.md`)],
|
|
155
|
+
['~/.codex/prompts', join(home, '.codex', 'prompts', `${name}.md`)],
|
|
156
|
+
]
|
|
157
|
+
for (const [where, path] of candidates) {
|
|
158
|
+
try { if (existsSync(path)) return { found: true, where } } catch { /* unreadable: keep looking */ }
|
|
159
|
+
}
|
|
160
|
+
return { found: false }
|
|
30
161
|
}
|
|
31
162
|
|
|
32
163
|
let scheduler: MorningBriefScheduler | null = null
|
|
164
|
+
let unregisterReservations: (() => void) | null = null
|
|
33
165
|
|
|
34
166
|
export function getMorningBriefScheduler(): MorningBriefScheduler {
|
|
35
167
|
if (!scheduler) {
|
|
36
|
-
|
|
168
|
+
const instance = new MorningBriefScheduler({
|
|
37
169
|
paths: morningBriefPaths(),
|
|
38
170
|
submit: raw => queryJobCoordinator.submit(raw),
|
|
39
171
|
findByClientGeneration: (clientJobId, generation) => queryJobCoordinator.getByClientGeneration(clientJobId, generation),
|
|
@@ -44,7 +176,18 @@ export function getMorningBriefScheduler(): MorningBriefScheduler {
|
|
|
44
176
|
ownerName: getOwnerName,
|
|
45
177
|
durableJobsEnabled: durableQueryJobsEnabled,
|
|
46
178
|
admissionsOpen: maintenanceAdmissionsOpen,
|
|
179
|
+
coverage: new MorningBriefCoverageService({
|
|
180
|
+
meetings: probeMeetings,
|
|
181
|
+
context: probeContext,
|
|
182
|
+
calendar: probeCalendar,
|
|
183
|
+
tasks: probeTasks,
|
|
184
|
+
reflection: probeReflection,
|
|
185
|
+
skill: name => probeSkill(name),
|
|
186
|
+
}),
|
|
47
187
|
})
|
|
188
|
+
// The ledger row exists before the job does; its number must count.
|
|
189
|
+
unregisterReservations = registerMessageReservationSource(() => instance.liveReservations(currentMessageEra()))
|
|
190
|
+
scheduler = instance
|
|
48
191
|
}
|
|
49
192
|
return scheduler
|
|
50
193
|
}
|
|
@@ -55,4 +198,6 @@ export function startMorningBriefScheduler(): void {
|
|
|
55
198
|
|
|
56
199
|
export function stopMorningBriefScheduler(): void {
|
|
57
200
|
scheduler?.stop()
|
|
201
|
+
unregisterReservations?.()
|
|
202
|
+
unregisterReservations = null
|
|
58
203
|
}
|
|
@@ -36,6 +36,14 @@ import {
|
|
|
36
36
|
serverTimezone,
|
|
37
37
|
} from './morning-brief-config.js'
|
|
38
38
|
import { composeMorningBriefPrompt } from './morning-brief-prompt.js'
|
|
39
|
+
import {
|
|
40
|
+
briefSections,
|
|
41
|
+
sectionOutcomes,
|
|
42
|
+
type MorningBriefCoverage,
|
|
43
|
+
type MorningBriefCoverageService,
|
|
44
|
+
type MorningBriefSectionOutcome,
|
|
45
|
+
} from './morning-brief-coverage.js'
|
|
46
|
+
import type { MessageReservation } from './message-reservations.js'
|
|
39
47
|
import { decideScheduledFire, localClock, nextScheduledFire } from './morning-brief-schedule.js'
|
|
40
48
|
import type { QueryJobSnapshot } from './query-job-types.js'
|
|
41
49
|
import { isTerminalQueryJobStatus } from './query-job-types.js'
|
|
@@ -57,6 +65,8 @@ export interface MorningBriefSchedulerDeps {
|
|
|
57
65
|
ownerName: () => string
|
|
58
66
|
durableJobsEnabled: () => boolean
|
|
59
67
|
admissionsOpen: () => boolean
|
|
68
|
+
/** Per-source reach (counts behind each source). Absent on a bare harness. */
|
|
69
|
+
coverage?: MorningBriefCoverageService
|
|
60
70
|
now?: () => number
|
|
61
71
|
tickMs?: number
|
|
62
72
|
log?: (line: string) => void
|
|
@@ -69,12 +79,14 @@ export class MorningBriefRunError extends Error {
|
|
|
69
79
|
}
|
|
70
80
|
}
|
|
71
81
|
|
|
72
|
-
export interface MorningBriefRunView extends MorningBriefRun {
|
|
82
|
+
export interface MorningBriefRunView extends Omit<MorningBriefRun, 'sections'> {
|
|
73
83
|
status: string
|
|
74
84
|
completedAt?: string
|
|
75
85
|
error?: { code: string; message: string }
|
|
76
86
|
/** First ~120 chars of the answer, for a list row. Never the whole brief. */
|
|
77
87
|
preview?: string
|
|
88
|
+
/** Which asked-for sections the answer opened, once the run is terminal. */
|
|
89
|
+
sections?: MorningBriefSectionOutcome[]
|
|
78
90
|
}
|
|
79
91
|
|
|
80
92
|
export interface MorningBriefStatus {
|
|
@@ -241,6 +253,7 @@ export class MorningBriefScheduler {
|
|
|
241
253
|
sessionId,
|
|
242
254
|
messageEra,
|
|
243
255
|
globalMsgNum,
|
|
256
|
+
sections: resume?.sections ?? briefSections(this.config, day),
|
|
244
257
|
}
|
|
245
258
|
// Ledger first. A crash after this line is a resume, not a second brief.
|
|
246
259
|
this.replaceRun(run)
|
|
@@ -291,21 +304,26 @@ export class MorningBriefScheduler {
|
|
|
291
304
|
const rows = this.ledger.runs.slice(-Math.max(1, Math.min(limit, MORNING_BRIEF_LIMITS.retainedRuns))).reverse()
|
|
292
305
|
const views: MorningBriefRunView[] = []
|
|
293
306
|
for (const run of rows) {
|
|
307
|
+
const pending = run.sections?.length ? run.sections.map(section => ({ ...section, state: 'pending' as const })) : undefined
|
|
294
308
|
if (!run.jobId) {
|
|
295
|
-
views.push({ ...run, status: run.submitError ? 'submit_failed' : 'submitting', ...(run.submitError ? { error: run.submitError } : {}) })
|
|
309
|
+
views.push({ ...run, sections: pending, status: run.submitError ? 'submit_failed' : 'submitting', ...(run.submitError ? { error: run.submitError } : {}) })
|
|
296
310
|
continue
|
|
297
311
|
}
|
|
298
312
|
const snapshot = await this.deps.getSnapshot(run.jobId).catch(() => undefined)
|
|
299
313
|
if (!snapshot) {
|
|
300
|
-
views.push({ ...run, status: run.lastKnownStatus ?? 'unknown' })
|
|
314
|
+
views.push({ ...run, sections: pending, status: run.lastKnownStatus ?? 'unknown' })
|
|
301
315
|
continue
|
|
302
316
|
}
|
|
303
317
|
if (snapshot.status !== run.lastKnownStatus && isTerminalQueryJobStatus(snapshot.status)) {
|
|
304
318
|
this.replaceRun({ ...run, lastKnownStatus: snapshot.status })
|
|
305
319
|
}
|
|
306
320
|
const answer = snapshot.response ?? snapshot.partialText ?? ''
|
|
321
|
+
const sections = run.sections?.length && snapshot.status === 'completed'
|
|
322
|
+
? sectionOutcomes(run.sections, snapshot.response ?? '')
|
|
323
|
+
: pending
|
|
307
324
|
views.push({
|
|
308
325
|
...run,
|
|
326
|
+
sections,
|
|
309
327
|
status: snapshot.status,
|
|
310
328
|
...(snapshot.completedAt ? { completedAt: snapshot.completedAt } : {}),
|
|
311
329
|
...(snapshot.error ? { error: { code: snapshot.error.code, message: snapshot.error.message } } : {}),
|
|
@@ -315,6 +333,39 @@ export class MorningBriefScheduler {
|
|
|
315
333
|
return views
|
|
316
334
|
}
|
|
317
335
|
|
|
336
|
+
/** Per-source reach for the settings surfaces. `null` when the harness has
|
|
337
|
+
* no probes. `force` re-runs the probes instead of serving the cache. */
|
|
338
|
+
async coverage(force = false): Promise<MorningBriefCoverage | null> {
|
|
339
|
+
if (!this.deps.coverage) return null
|
|
340
|
+
try {
|
|
341
|
+
return await this.deps.coverage.describe(this.config, force)
|
|
342
|
+
} catch (error) {
|
|
343
|
+
this.log(`coverage failed: ${(error as Error).message}`)
|
|
344
|
+
return null
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Numbers this scheduler has minted for runs that are not terminal yet: the
|
|
350
|
+
* ledger row exists BEFORE the job does, and the job's own reservation
|
|
351
|
+
* (query-job-store identities) ends the moment its terminal projection
|
|
352
|
+
* writes the exchange. Bounded to a day so a crashed row cannot pin the
|
|
353
|
+
* counter forever; the resume path reuses the same number anyway.
|
|
354
|
+
*/
|
|
355
|
+
liveReservations(era: string): MessageReservation[] {
|
|
356
|
+
const floor = this.now() - 24 * 60 * 60_000
|
|
357
|
+
const out: MessageReservation[] = []
|
|
358
|
+
for (const run of this.ledger.runs) {
|
|
359
|
+
if (typeof run.globalMsgNum !== 'number' || run.submitError) continue
|
|
360
|
+
if (run.lastKnownStatus && isTerminalQueryJobStatus(run.lastKnownStatus as never)) continue
|
|
361
|
+
const fired = Date.parse(run.firedAt)
|
|
362
|
+
if (!Number.isFinite(fired) || fired < floor) continue
|
|
363
|
+
if (era !== run.messageEra) continue
|
|
364
|
+
out.push({ globalMsgNum: run.globalMsgNum, messageEra: run.messageEra, owner: `brief:${run.day}` })
|
|
365
|
+
}
|
|
366
|
+
return out
|
|
367
|
+
}
|
|
368
|
+
|
|
318
369
|
async status(): Promise<MorningBriefStatus> {
|
|
319
370
|
const [lastRun] = await this.listRuns(1)
|
|
320
371
|
const gate: MorningBriefStatus['gate'] = !this.deps.durableJobsEnabled()
|
|
@@ -596,6 +596,12 @@ export class QueryJobCoordinator {
|
|
|
596
596
|
}
|
|
597
597
|
}
|
|
598
598
|
|
|
599
|
+
/** Message numbers held by admitted, not-yet-terminal jobs (see
|
|
600
|
+
* lib/message-reservations.ts). Sync: read straight off the store's identities. */
|
|
601
|
+
liveMessageReservations(): Array<{ jobId: string; sessionId: string; messageEra?: string; globalMsgNum: number }> {
|
|
602
|
+
return this.store.listLiveMessageReservations()
|
|
603
|
+
}
|
|
604
|
+
|
|
599
605
|
getHealth(): QueryJobCoordinatorHealth {
|
|
600
606
|
return {
|
|
601
607
|
activeRuns: this.active.size,
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
type QueryJobSnapshot,
|
|
34
34
|
} from './query-job-types.js'
|
|
35
35
|
import { acquireMaintenanceWork } from './maintenance-lifecycle.js'
|
|
36
|
+
import { registerMessageReservationSource } from './message-reservations.js'
|
|
36
37
|
|
|
37
38
|
const TOOL_STATUS_MESSAGES: Record<string, string> = {
|
|
38
39
|
WebSearch: 'Searching web...',
|
|
@@ -338,6 +339,14 @@ export const queryJobCoordinator = new QueryJobCoordinator(queryJobStore, runner
|
|
|
338
339
|
acquireMaintenanceWork: () => acquireMaintenanceWork('durable_query', { phase: 'queued' }),
|
|
339
340
|
})
|
|
340
341
|
|
|
342
|
+
// A job's number is a live reservation from admission until its terminal
|
|
343
|
+
// projection; the counter must see it or the phone mints it again.
|
|
344
|
+
registerMessageReservationSource(() => queryJobCoordinator.liveMessageReservations().map(item => ({
|
|
345
|
+
globalMsgNum: item.globalMsgNum,
|
|
346
|
+
...(item.messageEra ? { messageEra: item.messageEra } : {}),
|
|
347
|
+
owner: `job:${item.jobId}`,
|
|
348
|
+
})))
|
|
349
|
+
|
|
341
350
|
export function initQueryJobRuntime() {
|
|
342
351
|
if (!durableQueryJobsEnabled()) return Promise.resolve(queryJobCoordinator.getHealth())
|
|
343
352
|
return queryJobCoordinator.init()
|
|
@@ -69,6 +69,11 @@ interface QueryJobIdentity {
|
|
|
69
69
|
status: QueryJobStatus
|
|
70
70
|
updatedAt: string
|
|
71
71
|
orphanFenceUntil?: string
|
|
72
|
+
/** The number and era the client minted for this job, carried on the
|
|
73
|
+
* always-in-memory identity so a live reservation can be enumerated without
|
|
74
|
+
* hydrating the journal. See lib/message-reservations.ts. */
|
|
75
|
+
messageEra?: string
|
|
76
|
+
globalMsgNum?: number
|
|
72
77
|
}
|
|
73
78
|
|
|
74
79
|
export interface QueryJobMutationResult {
|
|
@@ -541,6 +546,8 @@ export class QueryJobStore {
|
|
|
541
546
|
status: record.status,
|
|
542
547
|
updatedAt: record.persistedAt,
|
|
543
548
|
...(snapshot.orphanFenceUntil ? { orphanFenceUntil: snapshot.orphanFenceUntil } : {}),
|
|
549
|
+
...(typeof hydrated.request.messageEra === 'string' ? { messageEra: hydrated.request.messageEra } : {}),
|
|
550
|
+
...(typeof hydrated.request.globalMsgNum === 'number' ? { globalMsgNum: hydrated.request.globalMsgNum } : {}),
|
|
544
551
|
}
|
|
545
552
|
this.identitiesByJobId.set(identity.jobId, identity)
|
|
546
553
|
this.identitiesByKey.set(identityKey(identity.clientJobId, identity.generation), identity)
|
|
@@ -1018,6 +1025,27 @@ export class QueryJobStore {
|
|
|
1018
1025
|
return out
|
|
1019
1026
|
}
|
|
1020
1027
|
|
|
1028
|
+
/**
|
|
1029
|
+
* Numbers held by jobs that are admitted but not yet terminal. Synchronous
|
|
1030
|
+
* and in-memory on purpose: /api/message-counter answers on every phone
|
|
1031
|
+
* send, and identities are never evicted (only hydrated bodies are).
|
|
1032
|
+
* Before init the map is empty, so the answer is an honest nothing.
|
|
1033
|
+
*/
|
|
1034
|
+
listLiveMessageReservations(): Array<{ jobId: string; sessionId: string; messageEra?: string; globalMsgNum: number }> {
|
|
1035
|
+
const out: Array<{ jobId: string; sessionId: string; messageEra?: string; globalMsgNum: number }> = []
|
|
1036
|
+
for (const identity of this.identitiesByJobId.values()) {
|
|
1037
|
+
if (isTerminalQueryJobStatus(identity.status)) continue
|
|
1038
|
+
if (typeof identity.globalMsgNum !== 'number') continue
|
|
1039
|
+
out.push({
|
|
1040
|
+
jobId: identity.jobId,
|
|
1041
|
+
sessionId: identity.sessionId,
|
|
1042
|
+
...(identity.messageEra ? { messageEra: identity.messageEra } : {}),
|
|
1043
|
+
globalMsgNum: identity.globalMsgNum,
|
|
1044
|
+
})
|
|
1045
|
+
}
|
|
1046
|
+
return out
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1021
1049
|
async findByClientGeneration(clientJobId: string, generation: number): Promise<QueryJobSnapshot | undefined> {
|
|
1022
1050
|
await this.ensureInitialized()
|
|
1023
1051
|
const identity = this.identitiesByKey.get(identityKey(clientJobId.toLowerCase(), generation))
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
exchangeBelongsToEra,
|
|
27
27
|
} from '../lib/message-era.js'
|
|
28
28
|
import { MessageEraResetError, resetLiveMessageEra } from '../lib/message-era-reset.js'
|
|
29
|
+
import { maxReservedGlobalMsgNum } from '../lib/message-reservations.js'
|
|
29
30
|
|
|
30
31
|
// v6.3.0 — read archives from the SAME persistent location the archive-mirror
|
|
31
32
|
// writes to (~/.cos-glasses/data/archive via dataPath), not a package-relative
|
|
@@ -248,5 +249,9 @@ messageRefRouter.get('/message-counter', (_req, res) => {
|
|
|
248
249
|
if (typeof ex?.globalMsgNum === 'number' && ex.globalMsgNum > liveMax) liveMax = ex.globalMsgNum
|
|
249
250
|
}
|
|
250
251
|
}
|
|
251
|
-
|
|
252
|
+
// 6.43.1 — numbers minted for jobs that are admitted but not yet projected
|
|
253
|
+
// (a running morning brief, a desk session's durable job) are part of the
|
|
254
|
+
// ceiling. Without them the phone re-minted #74 on 2026-09-01.
|
|
255
|
+
const reservedMax = maxReservedGlobalMsgNum(era)
|
|
256
|
+
res.json({ max: Math.max(liveMax, maxGlobalMsgNumInDir(ARCHIVE_DIR, era), reservedMax), era })
|
|
252
257
|
})
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
// GET /api/morning-brief config + source catalog + status + recent runs
|
|
4
4
|
// PUT /api/morning-brief patch the config (validated; 400 on a bad field)
|
|
5
5
|
// POST /api/morning-brief/run fire a brief now (202; 409 while one runs; 429 past the daily cap)
|
|
6
|
-
// GET /api/morning-brief/runs recent runs with live job status
|
|
6
|
+
// GET /api/morning-brief/runs recent runs with live job status, message numbers, section outcomes
|
|
7
|
+
// GET /api/morning-brief/coverage what each source can reach right now (?refresh=1 re-probes)
|
|
7
8
|
// GET /api/morning-brief/preview the exact prompt today's brief would send
|
|
8
9
|
//
|
|
9
10
|
// Authenticated by the global /api middleware like every other settings route.
|
|
@@ -24,6 +25,7 @@ export function createMorningBriefRouter(scheduler: () => MorningBriefScheduler)
|
|
|
24
25
|
sources: describeMorningBriefSources(),
|
|
25
26
|
status: await instance.status(),
|
|
26
27
|
runs: await instance.listRuns(7),
|
|
28
|
+
coverage: await instance.coverage(),
|
|
27
29
|
}
|
|
28
30
|
}
|
|
29
31
|
|
|
@@ -72,6 +74,18 @@ export function createMorningBriefRouter(scheduler: () => MorningBriefScheduler)
|
|
|
72
74
|
}
|
|
73
75
|
})
|
|
74
76
|
|
|
77
|
+
router.get('/morning-brief/coverage', async (req, res) => {
|
|
78
|
+
const refresh = req.query.refresh === '1' || req.query.refresh === 'true'
|
|
79
|
+
try {
|
|
80
|
+
const coverage = await scheduler().coverage(refresh)
|
|
81
|
+
if (!coverage) return res.status(503).json({ error: { code: 'coverage_unavailable', message: 'This server has no coverage probes.' } })
|
|
82
|
+
res.json({ coverage })
|
|
83
|
+
} catch (error) {
|
|
84
|
+
console.error('[morning-brief] coverage failed:', error)
|
|
85
|
+
res.status(500).json({ error: { code: 'coverage_failed', message: 'Could not read source coverage.' } })
|
|
86
|
+
}
|
|
87
|
+
})
|
|
88
|
+
|
|
75
89
|
router.get('/morning-brief/preview', (_req, res) => {
|
|
76
90
|
res.type('text/plain').send(scheduler().previewPrompt('scheduled'))
|
|
77
91
|
})
|