@gotcos/glasses-server 6.98.0 → 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 (89) hide show
  1. package/CHANGELOG.md +158 -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-data.ts +336 -26
  22. package/server/lib/live-cues-v2-settings.ts +2 -1
  23. package/server/lib/live-cues-v2.ts +9 -5
  24. package/server/lib/meeting-consolidation-run.ts +169 -0
  25. package/server/lib/meeting-store.ts +61 -0
  26. package/server/lib/meeting-title-steer.ts +200 -0
  27. package/server/lib/network-policy.ts +84 -6
  28. package/server/lib/permission-broker.ts +11 -0
  29. package/server/lib/ping-budget.ts +149 -0
  30. package/server/lib/ping-engine.ts +245 -0
  31. package/server/lib/ping-feed-sink.ts +33 -0
  32. package/server/lib/ping-sources.ts +192 -0
  33. package/server/lib/query-job-coordinator.ts +5 -2
  34. package/server/lib/query-job-runtime.ts +45 -4
  35. package/server/lib/query-job-types.ts +10 -1
  36. package/server/lib/reminder-scheduler.ts +153 -0
  37. package/server/lib/tailnet-self-name.ts +103 -0
  38. package/server/lib/tailscale-nudge.ts +40 -2
  39. package/server/lib/task-store.ts +32 -0
  40. package/server/lib/web-ask-admission.ts +56 -0
  41. package/server/lib/web-live-chunks.ts +273 -0
  42. package/server/lib/web-memories-bridge.ts +323 -0
  43. package/server/lib/web-session-auth.ts +492 -0
  44. package/server/lib/web-usage-bridge.ts +154 -0
  45. package/server/lib/work-search.ts +14 -1
  46. package/server/lib/your-move-count.ts +58 -0
  47. package/server/routes/display.ts +11 -1
  48. package/server/routes/drops.ts +260 -0
  49. package/server/routes/health.ts +16 -0
  50. package/server/routes/live-cues.ts +5 -4
  51. package/server/routes/meeting.ts +61 -5
  52. package/server/routes/permission-broker.ts +23 -3
  53. package/server/routes/transcribe-stream.ts +34 -3
  54. package/server/routes/web-control.ts +569 -0
  55. package/server/routes/welcome-context.ts +2 -1
  56. package/server/web-control/app.css +519 -0
  57. package/server/web-control/app.js +3643 -0
  58. package/server/web-control/control.css +312 -0
  59. package/server/web-control/fonts/DMSans.ttf +0 -0
  60. package/server/web-control/fonts/Fraunces.ttf +0 -0
  61. package/server/web-control/fonts/JetBrainsMono.ttf +0 -0
  62. package/server/web-control/index.html +275 -0
  63. package/server/web-control/mark.svg +1 -0
  64. package/server/web-control/memories/d3.min.js +2 -0
  65. package/server/web-control/memories/graph-explorer.css +200 -0
  66. package/server/web-control/memories/graph-explorer.js +748 -0
  67. package/server/web-control/memories/memories-app.js +1561 -0
  68. package/server/web-control/memories/memories-theme.css +73 -0
  69. package/server/web-control/memories/memories.html +72 -0
  70. package/server/web-control/memories/memory-stewardship.js +63 -0
  71. package/server/web-control/memories/memory-workspace.js +216 -0
  72. package/server/web-control/panes/cos-chat.css +157 -0
  73. package/server/web-control/panes/cos-chat.js +973 -0
  74. package/server/web-control/panes/dictation.js +173 -0
  75. package/server/web-control/panes/meetings.css +257 -0
  76. package/server/web-control/panes/meetings.js +2264 -0
  77. package/server/web-control/panes/messages.js +711 -0
  78. package/server/web-control/panes/questions.css +32 -0
  79. package/server/web-control/panes/questions.js +275 -0
  80. package/server/web-control/panes/sessions.css +452 -0
  81. package/server/web-control/panes/settings.js +292 -0
  82. package/server/web-control/panes/speakers.css +269 -0
  83. package/server/web-control/panes/speakers.js +2755 -0
  84. package/server/web-control/panes/threads.js +200 -0
  85. package/server/web-control/panes/work.css +261 -0
  86. package/server/web-control/panes/work.js +1667 -0
  87. package/server/web-control/shell.js +294 -0
  88. package/server/web-control/theme.js +25 -0
  89. package/server/web-control/vendor/qrcode-encoder.js +10 -0
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Drop it P2 (plan PLAN_drop_it_2026-11-04 section 5, decided in section 10): which pings may interrupt.
3
+ *
4
+ * Every ping lands silently in the pinned COS chat. This module only decides which of them also get a banner
5
+ * (a macOS notification, and for reminders a lens push). Pure: the ledger of past banners, the clock and the
6
+ * zone are inputs, so canary 6 can drive a fixture week on a fake clock.
7
+ *
8
+ * The budget (Miles, 2026-11-04 15:08, "go with your picks"):
9
+ * - 6 banners a weekday, at most 2 per source, at least 45 minutes apart.
10
+ * - Reminders he set are exempt: they banner at any hour and do not count against the budget.
11
+ * - Quiet hours follow Control's rule (C Models.swift:14394-14399): after 6 PM, before 6 AM, and weekends.
12
+ * No other banner then. Items held over quiet hours post at 6:00 as one "While you were off" item, and the
13
+ * first banner of a weekday is allowed at 8:00.
14
+ * - On by default for reminders and his own drops' resolutions; other sources are off until he turns them on
15
+ * (the first-week review of the tap-through rate).
16
+ * - Several due at once merge into one "N updates" banner, which counts once.
17
+ */
18
+ import { localClock } from './morning-brief-schedule.js'
19
+
20
+ export const PING_SOURCES = ['reminder', 'resolution', 'prep', 'agent', 'followups', 'stale', 'checkin'] as const
21
+ export type PingSource = typeof PING_SOURCES[number]
22
+ /** Ranking (plan section 5): reminder, his own resolution, prep, agent finished, follow-ups, stale or red, check-in. */
23
+ export const PING_RANK: Record<PingSource, number> = { reminder: 0, resolution: 1, prep: 2, agent: 3, followups: 4, stale: 5, checkin: 6 }
24
+
25
+ export const PING_BUDGET = {
26
+ perWeekday: 6,
27
+ perSource: 2,
28
+ gapMs: 45 * 60_000,
29
+ /** Control's quiet hours: a weekday from 6:00 to 18:00 is work time. */
30
+ dayStartMinutes: 6 * 60,
31
+ dayEndMinutes: 18 * 60,
32
+ /** The first non-reminder banner of a weekday waits until 8:00. */
33
+ firstBannerMinutes: 8 * 60,
34
+ } as const
35
+
36
+ /** The budget in the units the code uses. 6.99.11 settings (drop-p2-settings.ts budgetFrom) fill it; these are the defaults. */
37
+ export interface BudgetOptions {
38
+ perWeekday: number
39
+ perSource: number
40
+ gapMs: number
41
+ /** Quiet from start to end, minutes past midnight; start after end wraps midnight; equal means no quiet hours. */
42
+ quietStartMinutes: number
43
+ quietEndMinutes: number
44
+ weekendsQuiet: boolean
45
+ firstBannerMinutes: number
46
+ }
47
+ export const DEFAULT_BUDGET: BudgetOptions = {
48
+ perWeekday: PING_BUDGET.perWeekday, perSource: PING_BUDGET.perSource, gapMs: PING_BUDGET.gapMs,
49
+ quietStartMinutes: PING_BUDGET.dayEndMinutes, quietEndMinutes: PING_BUDGET.dayStartMinutes, weekendsQuiet: true,
50
+ firstBannerMinutes: PING_BUDGET.firstBannerMinutes,
51
+ }
52
+
53
+ /** Decided 2026-11-04: banners on for reminders and his own drops' resolutions; the rest off for the first week. */
54
+ export const DEFAULT_BANNER_SOURCES: readonly PingSource[] = ['reminder', 'resolution']
55
+
56
+ export interface BannerRecord {
57
+ /** Epoch ms the banner was shown. */
58
+ at: number
59
+ /** Every source the banner carried (a merged banner carries several). */
60
+ sources: PingSource[]
61
+ /** The feed item ids it announced. */
62
+ ids: string[]
63
+ }
64
+ export interface BannerCandidate { id: string; source: PingSource; createdAt: number }
65
+ export interface BannerPlan {
66
+ /** Banners to show now, each with the ids it announces. One merged banner has `merged: true`. */
67
+ banners: Array<{ ids: string[]; sources: PingSource[]; merged: boolean; exempt: boolean }>
68
+ /** Candidates that stay silent in the chat, with the reason. */
69
+ silent: Array<{ id: string; reason: SilentReason }>
70
+ }
71
+ export type SilentReason = 'source_off' | 'quiet_hours' | 'before_first_banner' | 'daily_cap' | 'source_cap' | 'too_soon'
72
+
73
+ /** Control's quiet rule at `nowMs` in `timezone`: weekends, before 6:00 and from 18:00. */
74
+ export function isQuietHours(nowMs: number, timezone: string, budget: BudgetOptions = DEFAULT_BUDGET): boolean {
75
+ const c = localClock(nowMs, timezone)
76
+ if (budget.weekendsQuiet && (c.weekday === 0 || c.weekday === 6)) return true
77
+ const { quietStartMinutes: start, quietEndMinutes: end } = budget
78
+ if (start === end) return false
79
+ return start > end ? c.minutes >= start || c.minutes < end : c.minutes >= start && c.minutes < end
80
+ }
81
+
82
+ /** The local day's non-exempt banners in the ledger (reminders never count). */
83
+ function counted(ledger: readonly BannerRecord[], nowMs: number, timezone: string): BannerRecord[] {
84
+ const today = localClock(nowMs, timezone).day
85
+ return ledger.filter(b => b.at <= nowMs && localClock(b.at, timezone).day === today && b.sources.some(s => s !== 'reminder'))
86
+ }
87
+
88
+ /**
89
+ * Decide which `candidates` banner now. Reminders always banner (one each, never merged, never counted). The rest
90
+ * pass the source switch, quiet hours, 8:00, the daily cap, the per-source cap and the 45-minute gap, in that
91
+ * order; those that pass merge into one banner. The caller records each banner in the ledger before showing it.
92
+ */
93
+ export function planBanners(
94
+ candidates: readonly BannerCandidate[],
95
+ ledger: readonly BannerRecord[],
96
+ nowMs: number,
97
+ timezone: string,
98
+ enabled: readonly PingSource[] = DEFAULT_BANNER_SOURCES,
99
+ budget: BudgetOptions = DEFAULT_BUDGET,
100
+ /** Ids that may banner although their source is switched off ("Ping me about" in Settings). Caps still apply. */
101
+ promoted: ReadonlySet<string> = new Set(),
102
+ ): BannerPlan {
103
+ const plan: BannerPlan = { banners: [], silent: [] }
104
+ const sorted = [...candidates].sort((a, b) => PING_RANK[a.source] - PING_RANK[b.source] || b.createdAt - a.createdAt)
105
+ const rest: BannerCandidate[] = []
106
+ for (const c of sorted) {
107
+ if (c.source === 'reminder') {
108
+ // Exempt: any hour, never counted. A reminder is still subject to his switch (on by default).
109
+ if (enabled.includes('reminder')) plan.banners.push({ ids: [c.id], sources: ['reminder'], merged: false, exempt: true })
110
+ else plan.silent.push({ id: c.id, reason: 'source_off' })
111
+ } else rest.push(c)
112
+ }
113
+ if (!rest.length) return plan
114
+
115
+ const silence = (list: BannerCandidate[], reason: SilentReason) => { for (const c of list) plan.silent.push({ id: c.id, reason }) }
116
+ const allowed = (c: BannerCandidate) => enabled.includes(c.source) || promoted.has(c.id)
117
+ const on = rest.filter(allowed)
118
+ silence(rest.filter(c => !allowed(c)), 'source_off')
119
+ if (!on.length) return plan
120
+ if (isQuietHours(nowMs, timezone, budget)) { silence(on, 'quiet_hours'); return plan }
121
+ if (localClock(nowMs, timezone).minutes < budget.firstBannerMinutes) { silence(on, 'before_first_banner'); return plan }
122
+
123
+ const today = counted(ledger, nowMs, timezone)
124
+ if (today.length >= budget.perWeekday) { silence(on, 'daily_cap'); return plan }
125
+ const last = today.reduce((max, b) => Math.max(max, b.at), -Infinity)
126
+ if (nowMs - last < budget.gapMs) { silence(on, 'too_soon'); return plan }
127
+
128
+ const perSource = new Map<PingSource, number>()
129
+ for (const b of today) for (const s of new Set(b.sources)) perSource.set(s, (perSource.get(s) ?? 0) + 1)
130
+ const pass: BannerCandidate[] = []
131
+ for (const c of on) {
132
+ if ((perSource.get(c.source) ?? 0) >= budget.perSource) plan.silent.push({ id: c.id, reason: 'source_cap' })
133
+ else pass.push(c)
134
+ }
135
+ if (!pass.length) return plan
136
+ const sources = [...new Set(pass.map(c => c.source))]
137
+ plan.banners.push({ ids: pass.map(c => c.id), sources, merged: pass.length > 1, exempt: false })
138
+ return plan
139
+ }
140
+
141
+ /** The banner's words. One item uses its own title; several say how many ("3 updates"). */
142
+ export function bannerTitle(count: number, single: string): string {
143
+ return count <= 1 ? single : `${count} updates`
144
+ }
145
+
146
+ /** A ledger trimmed to the last 8 days: enough for any weekday's count and the weekly stale rule. */
147
+ export function trimLedger(ledger: readonly BannerRecord[], nowMs: number): BannerRecord[] {
148
+ return ledger.filter(b => nowMs - b.at <= 8 * 86_400_000 && b.at <= nowMs + 60_000)
149
+ }
@@ -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