@gotcos/glasses-server 6.98.1 → 6.99.16
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 +227 -0
- package/package.json +2 -1
- package/server/index.ts +170 -11
- package/server/lib/api-auth.ts +31 -14
- package/server/lib/claude-bridge.ts +21 -2
- package/server/lib/codex-bridge.ts +21 -2
- package/server/lib/cos-thread.ts +582 -0
- package/server/lib/display-bus.ts +3 -0
- package/server/lib/drop-attachments.ts +55 -0
- package/server/lib/drop-destinations.ts +134 -0
- package/server/lib/drop-history-search.ts +97 -0
- package/server/lib/drop-p2-boot.ts +60 -0
- package/server/lib/drop-p2-runtime.ts +237 -0
- package/server/lib/drop-p2-settings.ts +174 -0
- package/server/lib/drop-resolution.ts +168 -0
- package/server/lib/drop-router.ts +325 -0
- package/server/lib/drop-service.ts +830 -0
- package/server/lib/drop-settings.ts +56 -0
- package/server/lib/drop-store.ts +450 -0
- package/server/lib/drop-when.ts +265 -0
- package/server/lib/glasses-pairing.ts +15 -0
- package/server/lib/learning-needs-you.ts +111 -0
- package/server/lib/live-cues-v2-settings.ts +2 -1
- package/server/lib/live-cues-v2.ts +6 -4
- package/server/lib/media-store.ts +80 -0
- package/server/lib/meeting-batch-transcribe.ts +15 -2
- package/server/lib/meeting-consolidation-run.ts +169 -0
- package/server/lib/meeting-store.ts +61 -0
- package/server/lib/meeting-title-steer.ts +200 -0
- package/server/lib/network-policy.ts +84 -6
- package/server/lib/permission-broker.ts +13 -0
- package/server/lib/phone-recording.ts +1 -1
- package/server/lib/ping-budget.ts +149 -0
- package/server/lib/ping-engine.ts +245 -0
- package/server/lib/ping-feed-sink.ts +33 -0
- package/server/lib/ping-sources.ts +192 -0
- package/server/lib/query-job-coordinator.ts +5 -2
- package/server/lib/query-job-runtime.ts +45 -4
- package/server/lib/query-job-types.ts +10 -1
- package/server/lib/reminder-scheduler.ts +153 -0
- package/server/lib/tailnet-self-name.ts +103 -0
- package/server/lib/tailscale-nudge.ts +40 -2
- package/server/lib/task-store.ts +32 -0
- package/server/lib/web-ask-admission.ts +56 -0
- package/server/lib/web-live-chunks.ts +273 -0
- package/server/lib/web-memories-bridge.ts +323 -0
- package/server/lib/web-session-auth.ts +492 -0
- package/server/lib/web-usage-bridge.ts +154 -0
- package/server/lib/work-search.ts +14 -1
- package/server/lib/your-move-count.ts +58 -0
- package/server/routes/cos-thread.ts +79 -0
- package/server/routes/display.ts +11 -1
- package/server/routes/drops.ts +262 -0
- package/server/routes/health.ts +16 -0
- package/server/routes/live-cues.ts +5 -4
- package/server/routes/meeting.ts +61 -5
- package/server/routes/permission-broker.ts +23 -3
- package/server/routes/transcribe-stream.ts +43 -4
- package/server/routes/web-control.ts +569 -0
- package/server/routes/welcome-context.ts +2 -1
- package/server/scripts/web-live-cls/cdp.mjs +57 -0
- package/server/scripts/web-live-cls/measure.mjs +80 -0
- package/server/scripts/web-live-cls/mock.mjs +97 -0
- package/server/scripts/web-live-cls/render.mjs +26 -0
- package/server/web-control/app.css +519 -0
- package/server/web-control/app.js +3643 -0
- package/server/web-control/control.css +325 -0
- package/server/web-control/fonts/DMSans.ttf +0 -0
- package/server/web-control/fonts/Fraunces.ttf +0 -0
- package/server/web-control/fonts/JetBrainsMono.ttf +0 -0
- package/server/web-control/index.html +275 -0
- package/server/web-control/mark.svg +1 -0
- package/server/web-control/memories/d3.min.js +2 -0
- package/server/web-control/memories/graph-explorer.css +200 -0
- package/server/web-control/memories/graph-explorer.js +748 -0
- package/server/web-control/memories/memories-app.js +1561 -0
- package/server/web-control/memories/memories-theme.css +73 -0
- package/server/web-control/memories/memories.html +72 -0
- package/server/web-control/memories/memory-stewardship.js +63 -0
- package/server/web-control/memories/memory-workspace.js +216 -0
- package/server/web-control/panes/cos-chat.css +174 -0
- package/server/web-control/panes/cos-chat.js +1118 -0
- package/server/web-control/panes/dictation.js +173 -0
- package/server/web-control/panes/meetings.css +314 -0
- package/server/web-control/panes/meetings.js +2568 -0
- package/server/web-control/panes/messages.js +718 -0
- package/server/web-control/panes/questions.css +32 -0
- package/server/web-control/panes/questions.js +275 -0
- package/server/web-control/panes/sessions.css +452 -0
- package/server/web-control/panes/settings.js +292 -0
- package/server/web-control/panes/speakers.css +269 -0
- package/server/web-control/panes/speakers.js +2755 -0
- package/server/web-control/panes/threads.js +200 -0
- package/server/web-control/panes/work.css +261 -0
- package/server/web-control/panes/work.js +1667 -0
- package/server/web-control/shell.js +294 -0
- package/server/web-control/theme.js +25 -0
- 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
|
-
|
|
727
|
-
|
|
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
|
|