@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.
- package/CHANGELOG.md +149 -0
- package/package.json +2 -1
- package/server/index.ts +147 -9
- 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/display-bus.ts +3 -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 +739 -0
- package/server/lib/drop-settings.ts +45 -0
- package/server/lib/drop-store.ts +428 -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/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 +11 -0
- 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/display.ts +11 -1
- package/server/routes/drops.ts +260 -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 +34 -3
- package/server/routes/web-control.ts +569 -0
- package/server/routes/welcome-context.ts +2 -1
- package/server/web-control/app.css +519 -0
- package/server/web-control/app.js +3643 -0
- package/server/web-control/control.css +312 -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 +157 -0
- package/server/web-control/panes/cos-chat.js +973 -0
- package/server/web-control/panes/dictation.js +173 -0
- package/server/web-control/panes/meetings.css +257 -0
- package/server/web-control/panes/meetings.js +2264 -0
- package/server/web-control/panes/messages.js +711 -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,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
|
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|