@gotcos/glasses-server 6.98.1 → 6.99.12

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.
Files changed (88) hide show
  1. package/CHANGELOG.md +149 -0
  2. package/package.json +2 -1
  3. package/server/index.ts +147 -9
  4. package/server/lib/api-auth.ts +31 -14
  5. package/server/lib/claude-bridge.ts +21 -2
  6. package/server/lib/codex-bridge.ts +21 -2
  7. package/server/lib/display-bus.ts +3 -0
  8. package/server/lib/drop-destinations.ts +134 -0
  9. package/server/lib/drop-history-search.ts +97 -0
  10. package/server/lib/drop-p2-boot.ts +60 -0
  11. package/server/lib/drop-p2-runtime.ts +237 -0
  12. package/server/lib/drop-p2-settings.ts +174 -0
  13. package/server/lib/drop-resolution.ts +168 -0
  14. package/server/lib/drop-router.ts +325 -0
  15. package/server/lib/drop-service.ts +739 -0
  16. package/server/lib/drop-settings.ts +45 -0
  17. package/server/lib/drop-store.ts +428 -0
  18. package/server/lib/drop-when.ts +265 -0
  19. package/server/lib/glasses-pairing.ts +15 -0
  20. package/server/lib/learning-needs-you.ts +111 -0
  21. package/server/lib/live-cues-v2-settings.ts +2 -1
  22. package/server/lib/live-cues-v2.ts +6 -4
  23. package/server/lib/meeting-consolidation-run.ts +169 -0
  24. package/server/lib/meeting-store.ts +61 -0
  25. package/server/lib/meeting-title-steer.ts +200 -0
  26. package/server/lib/network-policy.ts +84 -6
  27. package/server/lib/permission-broker.ts +11 -0
  28. package/server/lib/ping-budget.ts +149 -0
  29. package/server/lib/ping-engine.ts +245 -0
  30. package/server/lib/ping-feed-sink.ts +33 -0
  31. package/server/lib/ping-sources.ts +192 -0
  32. package/server/lib/query-job-coordinator.ts +5 -2
  33. package/server/lib/query-job-runtime.ts +45 -4
  34. package/server/lib/query-job-types.ts +10 -1
  35. package/server/lib/reminder-scheduler.ts +153 -0
  36. package/server/lib/tailnet-self-name.ts +103 -0
  37. package/server/lib/tailscale-nudge.ts +40 -2
  38. package/server/lib/task-store.ts +32 -0
  39. package/server/lib/web-ask-admission.ts +56 -0
  40. package/server/lib/web-live-chunks.ts +273 -0
  41. package/server/lib/web-memories-bridge.ts +323 -0
  42. package/server/lib/web-session-auth.ts +492 -0
  43. package/server/lib/web-usage-bridge.ts +154 -0
  44. package/server/lib/work-search.ts +14 -1
  45. package/server/lib/your-move-count.ts +58 -0
  46. package/server/routes/display.ts +11 -1
  47. package/server/routes/drops.ts +260 -0
  48. package/server/routes/health.ts +16 -0
  49. package/server/routes/live-cues.ts +5 -4
  50. package/server/routes/meeting.ts +61 -5
  51. package/server/routes/permission-broker.ts +23 -3
  52. package/server/routes/transcribe-stream.ts +34 -3
  53. package/server/routes/web-control.ts +569 -0
  54. package/server/routes/welcome-context.ts +2 -1
  55. package/server/web-control/app.css +519 -0
  56. package/server/web-control/app.js +3643 -0
  57. package/server/web-control/control.css +312 -0
  58. package/server/web-control/fonts/DMSans.ttf +0 -0
  59. package/server/web-control/fonts/Fraunces.ttf +0 -0
  60. package/server/web-control/fonts/JetBrainsMono.ttf +0 -0
  61. package/server/web-control/index.html +275 -0
  62. package/server/web-control/mark.svg +1 -0
  63. package/server/web-control/memories/d3.min.js +2 -0
  64. package/server/web-control/memories/graph-explorer.css +200 -0
  65. package/server/web-control/memories/graph-explorer.js +748 -0
  66. package/server/web-control/memories/memories-app.js +1561 -0
  67. package/server/web-control/memories/memories-theme.css +73 -0
  68. package/server/web-control/memories/memories.html +72 -0
  69. package/server/web-control/memories/memory-stewardship.js +63 -0
  70. package/server/web-control/memories/memory-workspace.js +216 -0
  71. package/server/web-control/panes/cos-chat.css +157 -0
  72. package/server/web-control/panes/cos-chat.js +973 -0
  73. package/server/web-control/panes/dictation.js +173 -0
  74. package/server/web-control/panes/meetings.css +257 -0
  75. package/server/web-control/panes/meetings.js +2264 -0
  76. package/server/web-control/panes/messages.js +711 -0
  77. package/server/web-control/panes/questions.css +32 -0
  78. package/server/web-control/panes/questions.js +275 -0
  79. package/server/web-control/panes/sessions.css +452 -0
  80. package/server/web-control/panes/settings.js +292 -0
  81. package/server/web-control/panes/speakers.css +269 -0
  82. package/server/web-control/panes/speakers.js +2755 -0
  83. package/server/web-control/panes/threads.js +200 -0
  84. package/server/web-control/panes/work.css +261 -0
  85. package/server/web-control/panes/work.js +1667 -0
  86. package/server/web-control/shell.js +294 -0
  87. package/server/web-control/theme.js +25 -0
  88. package/server/web-control/vendor/qrcode-encoder.js +10 -0
@@ -0,0 +1,245 @@
1
+ /**
2
+ * Drop it P2 (plan PLAN_drop_it_2026-11-04 section 5): the ping engine.
3
+ *
4
+ * Reads existing signals only (ping-sources.ts). Runs no script, calls no model (canary 9 pins the import graph).
5
+ * Every item lands silently in the pinned COS chat; ping-budget.ts decides which also banner.
6
+ *
7
+ * One delivery path for every source, polled or pushed:
8
+ * 1. Drop what was already posted (its key is in the feed), consumed by a digest, already held, or expired.
9
+ * 2. Quiet hours (Control's rule): work pings are held, not created. Reminders he set and resolutions of his own
10
+ * drops post at once (a resolution silently; the budget never banners it in quiet hours).
11
+ * 3. From 8:00 on a weekday, what was held posts as ONE "While you were off" item. A held item whose source no
12
+ * longer reports it (the thread moved, the meeting started) is dropped as expired.
13
+ * 4. The budget picks the banners. The ledger is written BEFORE any banner is shown, so a crash between the two
14
+ * can lose a banner but never repeat one.
15
+ *
16
+ * Nothing here logs words: only counts.
17
+ */
18
+ import { existsSync, lstatSync, mkdirSync, readFileSync } from 'node:fs'
19
+ import { join, resolve } from 'node:path'
20
+ import { durableAtomicWriteFileSync } from './atomic-fs.js'
21
+ import { acquireServerInstanceLock, type ServerInstanceLock } from './server-instance-lock.js'
22
+ import { localClock } from './morning-brief-schedule.js'
23
+ import { DEFAULT_BANNER_SOURCES, DEFAULT_BUDGET, isQuietHours, PING_SOURCES, planBanners, trimLedger, bannerTitle, type BannerRecord, type BudgetOptions, type PingSource } from './ping-budget.js'
24
+ import { bannerSourcesFrom, budgetFrom, matchesSteer, steerWords, timeMinutes, type P2Settings } from './drop-p2-settings.js'
25
+ import {
26
+ agentCandidate, checkinCandidates, digestCandidate, followupCandidates, prepCandidates, signalCandidates, staleCandidates,
27
+ type IntakeRow, type PingCandidate, type SignalRow, type ThreadRow,
28
+ } from './ping-sources.js'
29
+
30
+ /** Sources posted at once even in quiet hours (plan section 5). */
31
+ const ANY_HOUR: ReadonlySet<PingSource> = new Set<PingSource>(['reminder', 'resolution'])
32
+ /** Sources the engine polls; a held one of these that the next read no longer reports has expired. */
33
+ const POLLED: ReadonlySet<PingSource> = new Set<PingSource>(['prep', 'followups', 'stale', 'checkin'])
34
+ export const PING_STATE_LIMITS = { held: 200, consumed: 5_000, consumedDays: 30, fileBytes: 4_000_000 } as const
35
+
36
+ export interface PingSnapshot {
37
+ nextEvent?: { title?: unknown; time?: unknown } | null
38
+ intakeOpen?: readonly IntakeRow[] | null
39
+ threads?: readonly ThreadRow[] | null
40
+ signals?: readonly SignalRow[] | null
41
+ }
42
+
43
+ export interface PingState {
44
+ ledger: BannerRecord[]
45
+ held: PingCandidate[]
46
+ /** Keys a digest carried, so they never post again on their own. */
47
+ consumed: Array<{ key: string; at: number }>
48
+ }
49
+
50
+ const isRecord = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v)
51
+
52
+ function parseState(raw: unknown): PingState {
53
+ const state: PingState = { ledger: [], held: [], consumed: [] }
54
+ if (!isRecord(raw)) return state
55
+ if (Array.isArray(raw.ledger)) for (const b of raw.ledger) {
56
+ if (!isRecord(b) || typeof b.at !== 'number' || !Array.isArray(b.sources) || !Array.isArray(b.ids)) continue
57
+ const sources = b.sources.filter((s): s is PingSource => (PING_SOURCES as readonly string[]).includes(s as string))
58
+ if (sources.length) state.ledger.push({ at: b.at, sources, ids: b.ids.filter((i): i is string => typeof i === 'string').slice(0, 50) })
59
+ }
60
+ if (Array.isArray(raw.held)) for (const h of raw.held.slice(0, PING_STATE_LIMITS.held)) {
61
+ if (isRecord(h) && typeof h.key === 'string' && typeof h.line === 'string' && (PING_SOURCES as readonly string[]).includes(h.source as string)) state.held.push(h as unknown as PingCandidate)
62
+ }
63
+ if (Array.isArray(raw.consumed)) for (const c of raw.consumed) {
64
+ if (isRecord(c) && typeof c.key === 'string' && typeof c.at === 'number') state.consumed.push({ key: c.key, at: c.at })
65
+ }
66
+ return state
67
+ }
68
+
69
+ /** `data/pings/state.json`: the banner ledger, held items and consumed keys. One writer, 0700 folder, 0600 file. */
70
+ export class PingStateStore {
71
+ private state: PingState
72
+ private readonly path: string
73
+ private readonly lock: ServerInstanceLock
74
+ private closed = false
75
+ constructor(root: string) {
76
+ const dir = resolve(root)
77
+ mkdirSync(dir, { recursive: true, mode: 0o700 })
78
+ const st = lstatSync(dir)
79
+ if (!st.isDirectory() || st.isSymbolicLink() || st.uid !== process.getuid?.() || (st.mode & 0o077)) throw new Error('ping_store_unsafe')
80
+ this.path = join(dir, 'state.json')
81
+ this.lock = acquireServerInstanceLock({ lockDir: join(dir, 'writer.lock') })
82
+ let raw: unknown = null
83
+ try {
84
+ if (existsSync(this.path)) {
85
+ const f = lstatSync(this.path)
86
+ if (f.isFile() && !f.isSymbolicLink() && f.size <= PING_STATE_LIMITS.fileBytes && !(f.mode & 0o077)) raw = JSON.parse(readFileSync(this.path, 'utf8'))
87
+ }
88
+ } catch { raw = null } // a damaged state file costs at most a repeated digest key check: the feed keys still hold
89
+ this.state = parseState(isRecord(raw) && raw.version === 1 ? raw : null)
90
+ }
91
+ read(): PingState { return structuredClone(this.state) }
92
+ write(next: PingState, nowMs: number): void {
93
+ if (this.closed) throw new Error('ping_store_closed')
94
+ const cutoff = nowMs - PING_STATE_LIMITS.consumedDays * 86_400_000
95
+ const state: PingState = {
96
+ ledger: trimLedger(next.ledger, nowMs),
97
+ held: next.held.slice(-PING_STATE_LIMITS.held),
98
+ consumed: next.consumed.filter(c => c.at >= cutoff).slice(-PING_STATE_LIMITS.consumed),
99
+ }
100
+ durableAtomicWriteFileSync(this.path, JSON.stringify({ version: 1, ...state }) + '\n')
101
+ this.state = state
102
+ }
103
+ close(): void { if (this.closed) return; this.closed = true; this.lock.release() }
104
+ }
105
+
106
+ /** Where items land: the pinned chat's feed. `post` is idempotent on the key and returns the feed item id. */
107
+ export interface PingFeedSink {
108
+ hasKey(key: string): boolean
109
+ post(candidate: PingCandidate): string
110
+ }
111
+ export interface PingBanner { feedIds: string[]; title: string; line: string; sources: PingSource[]; exempt: boolean; notifyId?: string }
112
+
113
+ export interface PingEngineDeps {
114
+ state: Pick<PingStateStore, 'read' | 'write'>
115
+ feed: PingFeedSink
116
+ /** Reads the polled sources. Each field it cannot read is left out, and that source stays quiet this tick. */
117
+ read: () => Promise<PingSnapshot>
118
+ /** Shows a banner (macOS notification; a reminder also pushes to the lens). Called only after the ledger write. */
119
+ banner: (b: PingBanner) => void
120
+ now?: () => number
121
+ timezone: () => string
122
+ enabled?: () => readonly PingSource[]
123
+ /** 6.99.11 settings. When given, they decide the budget, quiet hours, sources, check-in, steer and banners. */
124
+ settings?: () => P2Settings
125
+ }
126
+
127
+ export interface PingTickResult { posted: number; held: number; released: number; expired: number; banners: number }
128
+
129
+ export class PingEngine {
130
+ private readonly now: () => number
131
+ constructor(private readonly deps: PingEngineDeps) { this.now = deps.now ?? (() => Date.now()) }
132
+
133
+ /** Poll the sources and deliver. Never throws: a source that fails is skipped. */
134
+ async tick(): Promise<PingTickResult> {
135
+ const nowMs = this.now(), tz = this.deps.timezone()
136
+ let snap: PingSnapshot = {}
137
+ try { snap = await this.deps.read() } catch { snap = {} }
138
+ const polled: PingCandidate[] = []
139
+ const safely = (fn: () => PingCandidate[]) => { try { polled.push(...fn()) } catch { /* one bad source never stops the others */ } }
140
+ const set = this.settingsNow()
141
+ const src = set?.pings.sources
142
+ const pingsOn = set ? set.pings.on : true
143
+ if (pingsOn && (src?.prep ?? true)) safely(() => prepCandidates(snap.nextEvent, nowMs, tz))
144
+ if (pingsOn && (src?.followups ?? true)) safely(() => followupCandidates(snap.intakeOpen, nowMs))
145
+ if (pingsOn && (src?.stale ?? true)) safely(() => staleCandidates(snap.threads, nowMs, tz))
146
+ if (pingsOn && (src?.signals ?? true)) safely(() => signalCandidates(snap.signals, nowMs))
147
+ if (set ? set.checkin.on : true) safely(() => checkinCandidates(nowMs, tz, set ? { weekday: set.checkin.weekday, minutes: timeMinutes(set.checkin.time) } : undefined))
148
+ // Sources this snapshot could read: a held item from a source we could not read this tick is kept, not expired.
149
+ const read = new Set<PingSource>(['checkin'])
150
+ if (snap.nextEvent !== undefined) read.add('prep')
151
+ if (snap.intakeOpen !== undefined) read.add('followups')
152
+ if (snap.threads !== undefined && snap.signals !== undefined) read.add('stale')
153
+ return this.deliver(polled, nowMs, tz, { keys: new Set(polled.map(c => c.key)), sources: read })
154
+ }
155
+
156
+ /** Deliver pushed candidates now: a reminder that fired, a resolution, an agent that finished. */
157
+ push(candidates: PingCandidate[]): PingTickResult {
158
+ const nowMs = this.now()
159
+ return this.deliver(candidates, nowMs, this.deps.timezone(), null)
160
+ }
161
+
162
+ /** A pushed agent event, through the template. Off with the pings switch or the agent source switch. */
163
+ pushAgent(ev: Parameters<typeof agentCandidate>[0]): PingTickResult {
164
+ const set = this.settingsNow()
165
+ if (set && (!set.pings.on || !set.pings.sources.agent)) return { posted: 0, held: 0, released: 0, expired: 0, banners: 0 }
166
+ const c = agentCandidate(ev)
167
+ return c ? this.push([c]) : { posted: 0, held: 0, released: 0, expired: 0, banners: 0 }
168
+ }
169
+
170
+ private settingsNow(): P2Settings | null {
171
+ try { return this.deps.settings?.() ?? null } catch { return null }
172
+ }
173
+
174
+ private deliver(incoming: PingCandidate[], nowMs: number, tz: string, current: { keys: Set<string>; sources: Set<PingSource> } | null): PingTickResult {
175
+ const result: PingTickResult = { posted: 0, held: 0, released: 0, expired: 0, banners: 0 }
176
+ const set = this.settingsNow()
177
+ const budget: BudgetOptions = set ? budgetFrom(set) : DEFAULT_BUDGET
178
+ const leaveAlone = set ? steerWords(set.steer.leaveAlone) : [], about = set ? steerWords(set.steer.about) : []
179
+ const words = (c: PingCandidate) => `${c.title ?? ''} ${c.line}`
180
+ // "Leave alone" mutes a ping by its words. Never a reminder he set, never a follow-up to his own drop.
181
+ if (leaveAlone.length) incoming = incoming.filter(c => c.source === 'reminder' || c.source === 'resolution' || !matchesSteer(words(c), leaveAlone))
182
+ const state = this.deps.state.read()
183
+ const consumed = new Set(state.consumed.map(c => c.key))
184
+ const heldKeys = new Set(state.held.map(h => h.key))
185
+ const quiet = isQuietHours(nowMs, tz, budget)
186
+ const posted: Array<{ candidate: PingCandidate; feedId: string }> = []
187
+ let changed = false
188
+
189
+ const seen = new Set<string>()
190
+ for (const c of incoming) {
191
+ if (seen.has(c.key)) continue
192
+ seen.add(c.key)
193
+ if (this.deps.feed.hasKey(c.key) || consumed.has(c.key) || heldKeys.has(c.key)) continue
194
+ if (c.expiresAt !== undefined && c.expiresAt <= nowMs) { result.expired++; continue }
195
+ if (quiet && !ANY_HOUR.has(c.source)) {
196
+ state.held.push(c); heldKeys.add(c.key); result.held++; changed = true
197
+ continue
198
+ }
199
+ posted.push({ candidate: c, feedId: this.deps.feed.post(c) }); result.posted++
200
+ }
201
+
202
+ // Release what quiet hours held, as one item, from 8:00 on a weekday.
203
+ if (!quiet && state.held.length && localClock(nowMs, tz).minutes >= budget.firstBannerMinutes) {
204
+ const live = state.held.filter(h => {
205
+ if (h.expiresAt !== undefined && h.expiresAt <= nowMs) return false
206
+ if (current && POLLED.has(h.source) && current.sources.has(h.source) && !current.keys.has(h.key)) return false
207
+ return !this.deps.feed.hasKey(h.key)
208
+ })
209
+ result.expired += state.held.length - live.length
210
+ const digest = digestCandidate(live, nowMs, tz)
211
+ if (digest && !this.deps.feed.hasKey(digest.key)) {
212
+ posted.push({ candidate: digest, feedId: this.deps.feed.post(digest) }); result.posted++
213
+ }
214
+ result.released = live.length
215
+ for (const h of state.held) state.consumed.push({ key: h.key, at: nowMs })
216
+ state.held = []; changed = true
217
+ }
218
+
219
+ if (posted.length) {
220
+ const enabled = set ? bannerSourcesFrom(set) : this.deps.enabled?.() ?? DEFAULT_BANNER_SOURCES
221
+ // "Ping me about": a ping whose words match may banner although its kind is silent. The caps still hold.
222
+ const promoted = new Set(about.length ? posted.filter(p => matchesSteer(words(p.candidate), about)).map(p => p.feedId) : [])
223
+ const plan = planBanners(posted.map(p => ({ id: p.feedId, source: p.candidate.source, createdAt: p.candidate.createdAt })), state.ledger, nowMs, tz, enabled, budget, promoted)
224
+ const banners: PingBanner[] = plan.banners.map(b => {
225
+ const items = b.ids.map(id => posted.find(p => p.feedId === id)!.candidate)
226
+ return {
227
+ feedIds: b.ids, sources: b.sources, exempt: b.exempt, title: bannerTitle(items.length, items[0].title ?? 'COS'),
228
+ line: items.length === 1 ? items[0].line : items.map(i => i.title ?? i.line).join(', '),
229
+ ...(items.length === 1 && items[0].notifyId ? { notifyId: items[0].notifyId } : {}),
230
+ }
231
+ })
232
+ for (const b of plan.banners) state.ledger.push({ at: nowMs, sources: b.sources, ids: b.ids })
233
+ if (plan.banners.length) changed = true
234
+ if (changed) { this.deps.state.write(state, nowMs); changed = false }
235
+ for (const b of banners) {
236
+ try { this.deps.banner(b); result.banners++ } catch { /* a notifier failure never fails delivery: the item is in the chat */ }
237
+ }
238
+ }
239
+ if (changed) this.deps.state.write(state, nowMs)
240
+ if (result.posted || result.held || result.released || result.banners) {
241
+ console.log(`[pings] posted=${result.posted} held=${result.held} released=${result.released} expired=${result.expired} banners=${result.banners}`)
242
+ }
243
+ return result
244
+ }
245
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Drop it P2: where pings land. A PingCandidate becomes one item in the pinned COS chat (InboxFeedStore), under its
3
+ * exactly-once key. The feed store checks the key and writes in one synchronous step, so a key can be inserted once.
4
+ *
5
+ * - A resolution is a `followup`, threaded under its drop. Every other source is a `ping`, its own thread.
6
+ * - Born unread, from `cos`. Nothing here leaves the Mac; the feed is the chat.
7
+ */
8
+ import { randomBytes } from 'node:crypto'
9
+ import type { FeedItem, InboxFeedStore, DropLink } from './drop-store.js'
10
+ import type { PingFeedSink } from './ping-engine.js'
11
+ import type { PingCandidate } from './ping-sources.js'
12
+ import { clip } from './ping-sources.js'
13
+
14
+ const feedId = () => `fi_${randomBytes(16).toString('hex')}`
15
+
16
+ export function toFeedItem(c: PingCandidate, nowMs: number): FeedItem {
17
+ const id = feedId()
18
+ return {
19
+ id, kind: c.source === 'resolution' ? 'followup' : 'ping', threadId: c.threadId ?? id,
20
+ createdAt: new Date(nowMs).toISOString(), readAt: null, source: 'cos',
21
+ ...(c.title ? { title: clip(c.title, 200) } : {}),
22
+ line: c.line.slice(0, 2_000), links: c.links.slice(0, 12) as DropLink[], actions: c.actions.slice(0, 6), state: 'live', key: c.key,
23
+ // Counts toward "waiting on you" (drop-service isWaiting) until dismissed, answered or done.
24
+ ...(c.needsReply === true ? { needsReply: true } : {}),
25
+ }
26
+ }
27
+
28
+ export function feedSink(feed: Pick<InboxFeedStore, 'byKey' | 'insert'>, now: () => number = () => Date.now()): PingFeedSink {
29
+ return {
30
+ hasKey: key => feed.byKey(key) !== undefined,
31
+ post: c => feed.insert([toFeedItem(c, now())])[0].id,
32
+ }
33
+ }
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Drop it P2 (plan PLAN_drop_it_2026-11-04 section 5): what COS may ping about, read from signals that already exist.
3
+ *
4
+ * Every function here is pure: a snapshot in, candidates out. They run no script and call no model; the words come
5
+ * from fixed templates filled with the source's own titles and counts (canary 9). Each candidate carries the
6
+ * exactly-once key the plan names, so a source read twice, or again after a restart, yields the same key.
7
+ *
8
+ * | Source | Read | Key |
9
+ * | prep | the next calendar event | prep:<event hash>:<local day> |
10
+ * | followups | open Work intake items, by meeting | followups:<meeting record id hash> |
11
+ * | stale | a thread whose is_stale is true | stale:<thread id>:<ISO week> |
12
+ * | signal | a red Signal row | signal:<Signal.id> |
13
+ * | checkin | the clock, Monday from 9:00 | checkin:<ISO week> |
14
+ * | agent | a linked session's turn ended (pushed) | agent:<session id>:<turn end> |
15
+ *
16
+ * Reminders and resolutions come from the drop store (reminder-scheduler.ts, drop-resolution.ts), not from here.
17
+ */
18
+ import { createHash } from 'node:crypto'
19
+ import { localClock } from './morning-brief-schedule.js'
20
+ import { clockLabel } from './drop-when.js'
21
+ import { PING_RANK, type PingSource } from './ping-budget.js'
22
+
23
+ export interface PingLink { kind: 'card' | 'session' | 'handoff' | 'thread' | 'memory' | 'meeting' | 'file' | 'reminder' | 'ask'; id: string; label: string; domain?: string }
24
+ export interface PingCandidate {
25
+ source: PingSource
26
+ /** Exactly-once key (plan section 4). */
27
+ key: string
28
+ title?: string
29
+ line: string
30
+ links: PingLink[]
31
+ actions: Array<{ id: string; label: string }>
32
+ /** Epoch ms the source saw it. */
33
+ createdAt: number
34
+ /** Epoch ms after which it is no longer worth posting (prep: the meeting start). */
35
+ expiresAt?: number
36
+ /** Counts toward "waiting on you" until answered, done or dismissed (plan section 12). */
37
+ needsReply?: boolean
38
+ /**
39
+ * The notification identifier the banner uses. A reminder uses `reminder-<drop id>`, the same id Control gives its
40
+ * local backup notification, so a server fire replaces Control's instead of doubling it (plan section 7).
41
+ */
42
+ notifyId?: string
43
+ /** The drop a follow-up answers (it threads under that drop in the chat); a ping without one is its own thread. */
44
+ threadId?: string
45
+ }
46
+
47
+ export const PING_TEXT = { title: 120, line: 400, digestLines: 5 } as const
48
+ /** Prep pings go out 15 minutes before a meeting (plan section 5). */
49
+ export const PREP_LEAD_MS = 15 * 60_000
50
+ /** A thread is pinged about as stale at most once a week (the key carries the ISO week). */
51
+ export const CHECKIN = { weekday: 1, minutes: 9 * 60, line: "What's one thing this week you don't want to forget? I'll remind you." } as const
52
+
53
+ const hash = (s: string) => createHash('sha256').update(s).digest('hex').slice(0, 16)
54
+ const CONTROL = /[\x00-\x1f\x7f]+/g
55
+ /** One line of a source's own words, cleaned and capped: never a model's. */
56
+ export function clip(text: unknown, max: number): string {
57
+ if (typeof text !== 'string') return ''
58
+ const t = text.replace(CONTROL, ' ').replace(/\s+/g, ' ').trim()
59
+ return t.length <= max ? t : t.slice(0, max - 1).trimEnd() + '…'
60
+ }
61
+ const ID_SAFE = /^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,199}$/
62
+
63
+ /** ISO 8601 week key, "2026-W45", for the local day. */
64
+ export function isoWeek(nowMs: number, timezone: string): string {
65
+ const [y, m, d] = localClock(nowMs, timezone).day.split('-').map(Number)
66
+ const date = new Date(Date.UTC(y, m - 1, d))
67
+ const dow = date.getUTCDay() || 7
68
+ date.setUTCDate(date.getUTCDate() + 4 - dow)
69
+ const yearStart = Date.UTC(date.getUTCFullYear(), 0, 1)
70
+ const week = Math.ceil(((date.getTime() - yearStart) / 86_400_000 + 1) / 7)
71
+ return `${date.getUTCFullYear()}-W${String(week).padStart(2, '0')}`
72
+ }
73
+
74
+ /** Prep: the next meeting, inside 15 minutes of its start. Expires at the start. */
75
+ export function prepCandidates(next: { title?: unknown; time?: unknown } | null | undefined, nowMs: number, timezone: string): PingCandidate[] {
76
+ if (!next) return []
77
+ const start = typeof next.time === 'string' ? Date.parse(next.time) : NaN
78
+ const title = clip(next.title, PING_TEXT.title)
79
+ if (!Number.isFinite(start) || !title) return []
80
+ if (start <= nowMs || start - nowMs > PREP_LEAD_MS) return []
81
+ const day = localClock(start, timezone).day
82
+ return [{
83
+ source: 'prep', key: `prep:${hash(`${title}\n${next.time}`)}:${day}`,
84
+ title, line: `${title} at ${clockLabel(localClock(start, timezone).minutes)}. Prep me?`,
85
+ links: [], actions: [{ id: 'prep', label: 'Prep me' }, { id: 'dismiss', label: 'Not now' }],
86
+ createdAt: nowMs, expiresAt: start,
87
+ }]
88
+ }
89
+
90
+ export interface IntakeRow { id: string; status: string; meeting: { recordId: string; title: string; domain: string } }
91
+ /** Follow-ups ready: open intake items grouped by meeting, one ping per meeting. */
92
+ export function followupCandidates(open: readonly IntakeRow[] | null | undefined, nowMs: number): PingCandidate[] {
93
+ if (!open?.length) return []
94
+ const byMeeting = new Map<string, IntakeRow[]>()
95
+ for (const row of open) {
96
+ if (!row?.meeting?.recordId) continue
97
+ byMeeting.set(row.meeting.recordId, [...(byMeeting.get(row.meeting.recordId) ?? []), row])
98
+ }
99
+ return [...byMeeting.entries()].map(([recordId, rows]) => {
100
+ const title = clip(rows[0].meeting.title, PING_TEXT.title) || 'a meeting'
101
+ const n = rows.length
102
+ return {
103
+ source: 'followups' as const, key: `followups:${hash(recordId)}`,
104
+ title, line: `${n} follow-up${n === 1 ? '' : 's'} from ${title}.`,
105
+ links: [{ kind: 'meeting' as const, id: `m_${hash(recordId)}`, label: title, domain: rows[0].meeting.domain }],
106
+ actions: [{ id: 'review', label: 'Review' }, { id: 'dismiss', label: 'Dismiss' }],
107
+ createdAt: nowMs, needsReply: true,
108
+ }
109
+ })
110
+ }
111
+
112
+ export interface ThreadRow { id?: unknown; name?: unknown; is_stale?: unknown; is_resolved?: unknown; last_seen?: unknown }
113
+ /** Stale: a thread the tracker marks stale and not resolved. At most weekly per thread (the key has the week). */
114
+ export function staleCandidates(threads: readonly ThreadRow[] | null | undefined, nowMs: number, timezone: string): PingCandidate[] {
115
+ if (!threads?.length) return []
116
+ const week = isoWeek(nowMs, timezone)
117
+ const out: PingCandidate[] = []
118
+ for (const t of threads) {
119
+ if (t?.is_stale !== true || t.is_resolved === true || typeof t.id !== 'string' || !ID_SAFE.test(t.id)) continue
120
+ const name = clip(t.name, PING_TEXT.title)
121
+ if (!name) continue
122
+ const last = typeof t.last_seen === 'string' ? Date.parse(t.last_seen) : NaN
123
+ const days = Number.isFinite(last) ? Math.max(1, Math.floor((nowMs - last) / 86_400_000)) : null
124
+ out.push({
125
+ source: 'stale', key: `stale:${t.id}:${week}`, title: name,
126
+ line: days ? `${name} quiet for ${days} days.` : `${name} has gone quiet.`,
127
+ links: [{ kind: 'thread', id: t.id, label: name }], actions: [{ id: 'open', label: 'Open' }],
128
+ createdAt: nowMs,
129
+ })
130
+ }
131
+ return out
132
+ }
133
+
134
+ export interface SignalRow { id?: unknown; severity?: unknown; title?: unknown; context?: unknown }
135
+ /** Red signals, by their own title and the cache's existing context.explanation. One ping per Signal.id. */
136
+ export function signalCandidates(signals: readonly SignalRow[] | null | undefined, nowMs: number): PingCandidate[] {
137
+ if (!signals?.length) return []
138
+ const out: PingCandidate[] = []
139
+ for (const s of signals) {
140
+ if (s?.severity !== 'red' || typeof s.id !== 'string' || !ID_SAFE.test(s.id)) continue
141
+ const title = clip(s.title, PING_TEXT.title)
142
+ if (!title) continue
143
+ const ctx = s.context && typeof s.context === 'object' ? (s.context as { explanation?: unknown }).explanation : undefined
144
+ const why = clip(ctx, PING_TEXT.line - title.length - 2)
145
+ out.push({
146
+ source: 'stale', key: `signal:${s.id}`, title,
147
+ line: why ? `${title}. ${why}` : `${title}.`, links: [], actions: [{ id: 'dismiss', label: 'Dismiss' }],
148
+ createdAt: nowMs,
149
+ })
150
+ }
151
+ return out
152
+ }
153
+
154
+ /** The weekly check-in, Monday from 9:00 local, once per ISO week. His reply becomes a reminder. */
155
+ export function checkinCandidates(nowMs: number, timezone: string, when: { weekday: number; minutes: number } = CHECKIN): PingCandidate[] {
156
+ const c = localClock(nowMs, timezone)
157
+ if (c.weekday !== when.weekday || c.minutes < when.minutes) return []
158
+ return [{
159
+ source: 'checkin', key: `checkin:${isoWeek(nowMs, timezone)}`, line: CHECKIN.line,
160
+ links: [], actions: [{ id: 'reply', label: 'Reply' }, { id: 'dismiss', label: 'Skip this week' }],
161
+ createdAt: nowMs, needsReply: true,
162
+ }]
163
+ }
164
+
165
+ const PROVIDER_NAME: Record<string, string> = { claude: 'Claude', codex: 'Codex', cursor: 'Cursor', grok: 'Grok' }
166
+ /** Agent finished: a session linked to a drop or a Work handoff ended its turn. Pushed by the caller, never polled. */
167
+ export function agentCandidate(ev: { provider?: unknown; sessionId?: unknown; turnEndedAt?: unknown; label?: unknown; failed?: unknown }): PingCandidate | null {
168
+ if (typeof ev.sessionId !== 'string' || !ID_SAFE.test(ev.sessionId) || typeof ev.turnEndedAt !== 'number' || !Number.isFinite(ev.turnEndedAt)) return null
169
+ const who = PROVIDER_NAME[typeof ev.provider === 'string' ? ev.provider : ''] ?? 'Your agent'
170
+ const label = clip(ev.label, PING_TEXT.title) || 'its task'
171
+ return {
172
+ source: 'agent', key: `agent:${ev.sessionId}:${Math.floor(ev.turnEndedAt)}`, title: label,
173
+ line: ev.failed === true ? `${who} stopped on ${label}. It needs a look.` : `${who} finished ${label}.`,
174
+ links: [{ kind: 'session', id: ev.sessionId, label }], actions: [{ id: 'open_result', label: 'Open result' }],
175
+ createdAt: ev.turnEndedAt, needsReply: ev.failed === true,
176
+ }
177
+ }
178
+
179
+ /** The one "While you were off" item that carries what quiet hours held (plan section 5). */
180
+ export function digestCandidate(held: readonly PingCandidate[], nowMs: number, timezone: string): PingCandidate | null {
181
+ if (!held.length) return null
182
+ const lines = held.slice(0, PING_TEXT.digestLines).map(h => `- ${h.line}`)
183
+ if (held.length > PING_TEXT.digestLines) lines.push(`- and ${held.length - PING_TEXT.digestLines} more`)
184
+ const best = [...held].sort((a, b) => PING_RANK[a.source] - PING_RANK[b.source])[0]
185
+ const links = held.flatMap(h => h.links).slice(0, 12)
186
+ return {
187
+ source: best.source, key: `offhours:${localClock(nowMs, timezone).day}`,
188
+ title: held.length === 1 ? 'While you were off' : `While you were off: ${held.length} updates`,
189
+ line: lines.join('\n'), links, actions: [{ id: 'open', label: 'Open' }],
190
+ createdAt: nowMs, needsReply: held.some(h => h.needsReply),
191
+ }
192
+ }
@@ -1,3 +1,4 @@
1
+ import { CONSOLIDATION_JOB_MS } from './meeting-consolidation-run.js'
1
2
  import { getOrCreateSession } from './conversation.js'
2
3
  import {
3
4
  QueryJobNotFoundError,
@@ -723,8 +724,10 @@ export class QueryJobCoordinator {
723
724
  private armProviderDeadline(active: ActiveRun): void {
724
725
  if (active.providerTimeoutTimer || active.terminalPersisted || active.persistenceFailed) return
725
726
  // On the monotonic steer clock (P7 S7): decideSteer compares it with the bridge's wall clock.
726
- active.deadlineAt = steerClock() + this.providerTimeoutMs
727
- active.providerTimeoutTimer = setTimeout(() => active.resolveProviderTimeout(), this.providerTimeoutMs)
727
+ const timeoutMs = active.request.maintenanceWorkflow === 'meeting-consolidation-v1'
728
+ ? CONSOLIDATION_JOB_MS : this.providerTimeoutMs
729
+ active.deadlineAt = steerClock() + timeoutMs
730
+ active.providerTimeoutTimer = setTimeout(() => active.resolveProviderTimeout(), timeoutMs)
728
731
  active.providerTimeoutTimer.unref?.()
729
732
  }
730
733
 
@@ -1,3 +1,6 @@
1
+ import { resolveProviderWorkDir } from './launch-dir.js'
2
+ import { COS_SCRIPTS_DIR } from './python-bridge.js'
3
+ import { consolidationModelRunner } from './meeting-consolidation-run.js'
1
4
  import { routineAuditCaller } from './routines-config.js'
2
5
  import { jobAuditCaller } from './job-audit-caller.js'
3
6
  import { carriesBoundTo } from './agent-session-binding-store.js'
@@ -8,7 +11,9 @@ import { emitDisplay } from './display-bus.js'
8
11
  import { resolveQueryAttachments } from './query-attachments.js'
9
12
  import { getMediaStore } from './media-store.js'
10
13
  import { dataPath } from './data-dir.js'
11
- import { currentMessageEra, LEGACY_MESSAGE_ERA } from './message-era.js'
14
+ import { currentMessageEra, exchangeBelongsToEra, LEGACY_MESSAGE_ERA } from './message-era.js'
15
+ import { isWebAsk, prepareWebAsk } from './web-ask-admission.js'
16
+ import { maxGlobalMsgNumInDir } from '../routes/message-ref.js'
12
17
  import { durableQueryJobsEnabled, messagesSteerEnabled, messagesTrailEnabled } from './query-job-feature.js'
13
18
  import { claudeSupportsSteer } from './claude-cli-support.js'
14
19
  import { steerProviderFor } from './claude-steer.js'
@@ -25,6 +30,9 @@ import {
25
30
  import { mergeMediaAttachmentRefs } from '../../shared/media-attachment.js'
26
31
  import { attachmentHistoryPrefix, defaultAttachmentRequest } from '../../shared/media-attachment.js'
27
32
  import {
33
+ createSession,
34
+ getActiveSessions,
35
+ getSessionRaw,
28
36
  findExchangesByJobIdentity,
29
37
  flushConversationToDisk,
30
38
  getSessionBinding,
@@ -44,7 +52,7 @@ import {
44
52
  } from './query-job-types.js'
45
53
  import { acquireMaintenanceWork } from './maintenance-lifecycle.js'
46
54
  import type { JobTrailDraft } from './job-trail.js'
47
- import { registerMessageReservationSource } from './message-reservations.js'
55
+ import { maxReservedGlobalMsgNum, registerMessageReservationSource } from './message-reservations.js'
48
56
  import { bootResumeGate } from './boot-resume.js'
49
57
  import { keyAnswerCompletionFields, keyAnswerDoneFields } from './key-answer-fields.js'
50
58
 
@@ -69,9 +77,36 @@ export class QueryJobAdmissionPreparationError extends Error {
69
77
  * the strict QueryJobRequest parser before persistence. Once a reset era
70
78
  * exists, reject pre-era clients before they stamp five-digit numbers into
71
79
  * the fresh namespace. */
80
+ /** 6.99.10: the next-number ceiling for a web Ask, the same arithmetic /api/message-counter serves. */
81
+ function webAskCurrentMax(): number {
82
+ const era = currentMessageEra()
83
+ let liveMax = 0
84
+ for (const session of getActiveSessions()) {
85
+ for (const exchange of (session as { exchanges?: Array<{ globalMsgNum?: unknown; messageEra?: unknown }> }).exchanges ?? []) {
86
+ if (!exchangeBelongsToEra(exchange, era)) continue
87
+ if (typeof exchange?.globalMsgNum === 'number' && exchange.globalMsgNum > liveMax) liveMax = exchange.globalMsgNum
88
+ }
89
+ }
90
+ return Math.max(liveMax, maxGlobalMsgNumInDir(dataPath('archive'), era), maxReservedGlobalMsgNum(era))
91
+ }
92
+
72
93
  export async function preparePublicDurableQueryAdmission(raw: unknown): Promise<unknown> {
73
94
  if (!raw || typeof raw !== 'object') return raw
74
- const input = raw as Record<string, unknown>
95
+ let input = raw as Record<string, unknown>
96
+ // 6.99.10: Ask from COS Control Web (`source: 'web'`): the server picks the conversation and mints the message
97
+ // number (lib/web-ask-admission.ts). Every other caller is untouched.
98
+ if (isWebAsk(input) && !carriesBoundTo(input)) {
99
+ input = await prepareWebAsk(input, {
100
+ currentMax: webAskCurrentMax,
101
+ activeEra: currentMessageEra,
102
+ sessionExists: id => !!getSessionRaw(id),
103
+ createSession,
104
+ priorJob: async (clientJobId, generation) => {
105
+ const job = await queryJobCoordinator.getByClientGeneration(clientJobId, generation)
106
+ return job ? { sessionId: job.sessionId, globalMsgNum: job.globalMsgNum, messageEra: job.messageEra } : undefined
107
+ },
108
+ })
109
+ }
75
110
 
76
111
  // Plan 4.2: an attached turn must NEVER silently degrade into an ordinary COS
77
112
  // turn. The binding lives in the route path rather than the body, so a
@@ -255,7 +290,13 @@ const runner: QueryJobRunner = async ({ jobId, turnId, request, signal, callback
255
290
  ? { ...keyProfileLinkageProvider(keyTurn), resolvedModel: keyTurn.binding }
256
291
  : { provider: providerFor(model), resolvedModel: model }
257
292
 
258
- await callModelStreaming(
293
+ const runModel = request.maintenanceWorkflow === 'meeting-consolidation-v1'
294
+ ? consolidationModelRunner(callModelStreaming, {
295
+ root: dataPath('meeting-consolidation'),
296
+ workspace: resolveProviderWorkDir({ scriptsDir: COS_SCRIPTS_DIR }),
297
+ runId: `${request.clientJobId}-g${request.generation}`,
298
+ }) : callModelStreaming
299
+ await runModel(
259
300
  request.query,
260
301
  request.sessionId,
261
302
  {
@@ -134,6 +134,8 @@ export interface QueryJobRequest {
134
134
  * it names the session, it does not change what is asked.
135
135
  */
136
136
  sessionName?: string
137
+ /** Explicit user-started, checkpointed maintenance. Changes execution semantics. */
138
+ maintenanceWorkflow?: 'meeting-consolidation-v1'
137
139
  /**
138
140
  * Key lanes (QA U9 / C10): run THIS turn on the key profile it names without binding the
139
141
  * session to it (the plan-limit "run this one on your key" offer). Kept only with a
@@ -599,6 +601,12 @@ export function parseQueryJobRequest(raw: unknown, options: ParseQueryJobRequest
599
601
  const handoffCode = optionalString(input.handoffCode, 'handoff_code', 128)
600
602
  const clientQueueItemId = optionalString(input.clientQueueItemId, 'client_queue_item_id', 120)
601
603
  const sessionName = sanitizeSessionName(input.sessionName)
604
+ const maintenanceWorkflow = input.maintenanceWorkflow
605
+ if (maintenanceWorkflow != null && (maintenanceWorkflow !== 'meeting-consolidation-v1'
606
+ || !['opus', 'codex-frontier'].includes(model ?? '') || input.dispatch != null || input.steerable === true
607
+ || (Array.isArray(input.attachmentIds) && input.attachmentIds.length > 0))) {
608
+ throw new QueryJobValidationError('invalid_maintenance_workflow')
609
+ }
602
610
  const globalMsgNum = input.globalMsgNum == null ? undefined : Number(input.globalMsgNum)
603
611
  if (globalMsgNum != null && (!Number.isSafeInteger(globalMsgNum) || globalMsgNum < 1)) {
604
612
  throw new QueryJobValidationError('invalid_global_msg_num')
@@ -670,6 +678,7 @@ export function parseQueryJobRequest(raw: unknown, options: ParseQueryJobRequest
670
678
  ...(input.handoffLatest === true ? { handoffLatest: true } : {}),
671
679
  ...(clientQueueItemId ? { clientQueueItemId } : {}),
672
680
  ...(sessionName ? { sessionName } : {}),
681
+ ...(maintenanceWorkflow === 'meeting-consolidation-v1' ? { maintenanceWorkflow } as const : {}),
673
682
  ...(input.keyTurnOnly === true && isProfilePreference(model) ? { keyTurnOnly: true as const } : {}),
674
683
  ...(input.steerable === true ? { steerable: true as const } : {}),
675
684
  attachmentIds,
@@ -735,7 +744,7 @@ export const FINGERPRINT_KEYS = [
735
744
  'clientJobId', 'generation', 'query', 'sessionId', 'model', 'effort',
736
745
  'cursorExecutionMode', 'messageEra', 'globalMsgNum', 'reference', 'handoffCode',
737
746
  'handoffLatest', 'clientQueueItemId', 'attachmentIds', 'attachmentRefs',
738
- 'activityToolMode', 'keyTurnOnly',
747
+ 'activityToolMode', 'keyTurnOnly', 'maintenanceWorkflow',
739
748
  ] as const satisfies readonly (keyof QueryJobRequest)[]
740
749
 
741
750
  /** The request keys deliberately OUTSIDE the identity. Every key of