@gotcos/glasses-server 6.99.33 → 7.0.7
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/.env.example +18 -0
- package/CHANGELOG.md +417 -0
- package/README.md +3 -1
- package/managed-runtime-contract.json +3 -0
- package/package.json +1 -1
- package/server/index.ts +29 -7
- package/server/lib/agent-session-output-images.ts +627 -49
- package/server/lib/agent-session-store.ts +24 -11
- package/server/lib/agent-session-tool-images.ts +224 -0
- package/server/lib/agent-session-turns.ts +19 -2
- package/server/lib/api-auth.ts +6 -0
- package/server/lib/ask-work-plan.ts +3 -1
- package/server/lib/calendar-source.ts +491 -0
- package/server/lib/codex-bridge.ts +4 -1
- package/server/lib/codex-run-ledger.ts +29 -0
- package/server/lib/context-builder.ts +2 -1
- package/server/lib/drop-p2-runtime.ts +1 -1
- package/server/lib/drop-store.ts +10 -4
- package/server/lib/learning-needs-you.ts +24 -7
- package/server/lib/live-events.ts +533 -0
- package/server/lib/local-path-redact.ts +272 -0
- package/server/lib/meeting-ask-orientation.ts +40 -6
- package/server/lib/meeting-ask-runner.ts +30 -10
- package/server/lib/morning-brief-runtime.ts +4 -2
- package/server/lib/native-mac-caller.ts +29 -0
- package/server/lib/ping-engine.ts +1 -1
- package/server/lib/ping-sources.ts +11 -6
- package/server/lib/preview-content.ts +172 -0
- package/server/lib/preview-error-code.ts +10 -0
- package/server/lib/preview-folders-setting.ts +133 -0
- package/server/lib/preview-log-throttle.ts +18 -0
- package/server/lib/preview-thumbs.ts +139 -0
- package/server/lib/secret-path-guard.ts +80 -0
- package/server/lib/session-files-setting.ts +88 -0
- package/server/lib/session-links.ts +280 -0
- package/server/lib/thread-turn-queue.ts +10 -0
- package/server/lib/today-brief.ts +119 -28
- package/server/lib/web-memories-bridge.ts +3 -1
- package/server/lib/work-activity.ts +56 -6
- package/server/lib/work-session-activity.ts +260 -0
- package/server/lib/your-move-count.ts +9 -3
- package/server/routes/agent-session-bindings.ts +13 -0
- package/server/routes/agent-session-stream.ts +16 -0
- package/server/routes/agent-sessions.ts +146 -11
- package/server/routes/ask-work-plan.ts +4 -1
- package/server/routes/claude-sessions.ts +5 -1
- package/server/routes/health.ts +35 -0
- package/server/routes/live-events.ts +329 -0
- package/server/routes/permission-broker.ts +69 -5
- package/server/routes/session-preview.ts +200 -0
- package/server/routes/thread-turn-queue.ts +207 -3
- package/server/routes/today-brief.ts +44 -9
- package/server/routes/welcome-context.ts +23 -3
- package/server/routes/work-board.ts +9 -2
- package/server/web-control/app.css +40 -1
- package/server/web-control/app.js +1309 -56
- package/server/web-control/control.css +25 -0
- package/server/web-control/index.html +7 -2
- package/server/web-control/memory-review.js +100 -0
- package/server/web-control/panes/cos-chat.css +42 -3
- package/server/web-control/panes/cos-chat.js +267 -15
- package/server/web-control/panes/meetings.css +18 -2
- package/server/web-control/panes/meetings.js +24 -8
- package/server/web-control/panes/memories.css +17 -2
- package/server/web-control/panes/memories.js +127 -42
- package/server/web-control/panes/messages.js +21 -2
- package/server/web-control/panes/preview.js +318 -0
- package/server/web-control/panes/questions.js +28 -8
- package/server/web-control/panes/reader-media.js +409 -23
- package/server/web-control/panes/sessions.css +32 -3
- package/server/web-control/panes/settings.js +96 -5
- package/server/web-control/panes/today-actions.css +11 -0
- package/server/web-control/panes/today-actions.js +45 -12
- package/server/web-control/panes/work.css +10 -0
- package/server/web-control/panes/work.js +10 -2
- package/server/web-control/shell.js +17 -14
|
@@ -24,13 +24,13 @@ import {
|
|
|
24
24
|
admitToQueue, drainDecision, queueableRefusal, queuePosition,
|
|
25
25
|
MAX_DELIVERY_ATTEMPTS, type DrainObservation, type QueuedThreadTurn,
|
|
26
26
|
queueReconcileEnabled, queueResendEnabled, queuedTurnReasonCopy, sessionSteerAvailability, sessionSteerEnabled,
|
|
27
|
-
type SessionSteerAvailability,
|
|
27
|
+
steerMidturnEnabled, type SessionSteerAvailability,
|
|
28
28
|
} from '../lib/thread-turn-queue.js'
|
|
29
29
|
import { readQueue, writeQueue, queuedThreadKeys } from '../lib/thread-turn-queue-store.js'
|
|
30
30
|
import type { DeliveryEvidence } from '../lib/queue-turn-evidence.js'
|
|
31
31
|
import { continueLiveEnabled } from '../lib/session-peer-inbox-deps.js'
|
|
32
32
|
import { PEER_VERIFY_TIMEOUT_MS } from '../lib/session-peer-inbox.js'
|
|
33
|
-
import { attachmentsDigest, FOOTER_LINE_REFUSED_COPY, hasFooterShapedLine, stripG2TurnFooter, TURN_ID_IN_USE_COPY, unvouched, type G2TurnProvenance, type OriginVerdict } from '../lib/g2-turn-provenance.js'
|
|
33
|
+
import { attachmentsDigest, FOOTER_LINE_REFUSED_COPY, hasFooterShapedLine, sealMatches, stripG2TurnFooter, TURN_ID_IN_USE_COPY, unvouched, type G2TurnProvenance, type OriginVerdict } from '../lib/g2-turn-provenance.js'
|
|
34
34
|
import { defaultTurnAttachmentStore, keepTurnAttachments, parseTurnAttachmentIds, TURN_ATTACHMENT_COPY, turnAttachmentManifest, type TurnAttachmentStore } from '../lib/session-turn-attachments.js'
|
|
35
35
|
|
|
36
36
|
/**
|
|
@@ -115,6 +115,23 @@ export interface ThreadTurnQueueDeps {
|
|
|
115
115
|
begin: (provider: string, threadId: string, clientTurnId: string, mode: 'turn' | 'steer', now: number) => boolean
|
|
116
116
|
settle: (provider: string, threadId: string, clientTurnId: string, exit: 'sent' | 'not_sent' | 'unknown', now: number) => boolean
|
|
117
117
|
}
|
|
118
|
+
/**
|
|
119
|
+
* Steer mid-turn (COS_STEER_MIDTURN), optional: ask for a drain pass for this thread now, so a
|
|
120
|
+
* turn parked at a busy session that can take it mid-turn goes in within seconds instead of at
|
|
121
|
+
* the next 20 s sweep. Production: the shared drain kick (one pass in flight, ever). Absent: the
|
|
122
|
+
* sweep still delivers it, only later.
|
|
123
|
+
*/
|
|
124
|
+
kickDrain?: (threadId: string) => void
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Can this thread take a parked turn mid-turn right now (COS_STEER_MIDTURN)? The same shared rule
|
|
129
|
+
* as Session steer, plus the switch and the two members the drain's steer needs. Never throws.
|
|
130
|
+
*/
|
|
131
|
+
export function midTurnAvailabilityFor(deps: Pick<ThreadTurnQueueDeps, 'steerProbe' | 'deliverLiveTurn' | 'continueLive' | 'attempts'>, provider: string, threadId: string, gateReason: string | null): SessionSteerAvailability | null {
|
|
132
|
+
if (!steerMidturnEnabled()) return null
|
|
133
|
+
if (typeof deps.deliverLiveTurn !== 'function' || !deps.attempts) return { available: false, reason: 'no_outside_channel' }
|
|
134
|
+
return steerAvailabilityFor(deps, provider, threadId, gateReason)
|
|
118
135
|
}
|
|
119
136
|
|
|
120
137
|
/**
|
|
@@ -210,6 +227,159 @@ function isRetryableDelivery(outcome: { reason?: string; serverRetryable?: boole
|
|
|
210
227
|
return queueableRefusal(outcome.reason)
|
|
211
228
|
}
|
|
212
229
|
|
|
230
|
+
/**
|
|
231
|
+
* The verdict a drain hop with no parked origin is judged by: the loopback call it is. The front
|
|
232
|
+
* door reaches the same verdict through `originOf` on the drainer's own request (local socket,
|
|
233
|
+
* token shown), so a steered turn and a front-door turn are ledgered alike. Never footed.
|
|
234
|
+
*/
|
|
235
|
+
function localDrainVerdict(at: number): OriginVerdict {
|
|
236
|
+
return {
|
|
237
|
+
origin: { remote: '127.0.0.1', loopback: true, ownAddress: true, clientInstance: null, client: 'local', tokenPresented: true, webSession: false },
|
|
238
|
+
verifiedOrigin: false,
|
|
239
|
+
reason: 'local_socket',
|
|
240
|
+
judgedAt: at,
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** What one mid-turn attempt did to its row: delivered, settled some other way, or left waiting. */
|
|
245
|
+
type MidTurnOutcome = 'delivered' | 'settled' | 'held'
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Steer mid-turn (COS_STEER_MIDTURN, Miles 2026-11-07): put ONE waiting row into the running turn
|
|
249
|
+
* of a busy Claude desk session through its peer inbox. Claude Code takes the frame at once (its
|
|
250
|
+
* `enqueue` row is the proof `deliverLiveTurn` waits for) and folds it in at its next tool step.
|
|
251
|
+
*
|
|
252
|
+
* Called only by `drainThread`, only for a row the drain would otherwise hold behind a busy gate,
|
|
253
|
+
* and only in FIFO order (the caller stops at the first row that holds).
|
|
254
|
+
*
|
|
255
|
+
* WHO MAY BE STEERED: a row never attempted (`attempts === 0`: no front-door attempt, no earlier
|
|
256
|
+
* steer, no restart requeue, so nothing about it is unknown), with no files (the peer inbox
|
|
257
|
+
* carries words only), on a thread the shared steer rule says can take it now.
|
|
258
|
+
*
|
|
259
|
+
* PROVENANCE IS CHECKED HERE AS THE TURNS ROUTE CHECKS IT, because this hop does not go through
|
|
260
|
+
* that front door: footer-shaped words are refused, the parked origin is carried (a single-use
|
|
261
|
+
* carry token, spent in this process), its seal must name this provider, thread and no files,
|
|
262
|
+
* a turn id held for another turn is refused, and only the server writes the footer. A refusal
|
|
263
|
+
* settles the row `refused` with its own reason, so it reads "Not sent" with the reason line.
|
|
264
|
+
*
|
|
265
|
+
* NEVER TWICE, in the same order as `steerTurn`: the intent row (`delivering`, `mode: 'steer'`,
|
|
266
|
+
* `journal: 1`, this boot) durably, then the attempt `pending`, then the frame, then the
|
|
267
|
+
* verdict, journal first. Unverified, a failed write or a throw: `refused`, `steer_unverified`,
|
|
268
|
+
* `deliveryState: 'unknown'`, never resent. Nothing reached the session: back to waiting, the
|
|
269
|
+
* attempt given back, so the turn-end front door sends it as before.
|
|
270
|
+
*
|
|
271
|
+
* NO AWAIT BEFORE THE INTENT ROW IS WRITTEN: the caller read this row as `waiting` in the same
|
|
272
|
+
* synchronous step, so a cancel cannot slip between that read and the `delivering` write.
|
|
273
|
+
*/
|
|
274
|
+
async function steerHeldTurn(
|
|
275
|
+
provider: string,
|
|
276
|
+
threadId: string,
|
|
277
|
+
turn: QueuedThreadTurn,
|
|
278
|
+
gateReason: string | null,
|
|
279
|
+
deps: ThreadTurnQueueDeps,
|
|
280
|
+
): Promise<MidTurnOutcome> {
|
|
281
|
+
if (turn.status !== 'waiting' || turn.attempts !== 0) return 'held'
|
|
282
|
+
if (Array.isArray(turn.attachmentIds) && turn.attachmentIds.length > 0) return 'held'
|
|
283
|
+
const availability = midTurnAvailabilityFor(deps, provider, threadId, gateReason)
|
|
284
|
+
if (!availability || !availability.available) return 'held'
|
|
285
|
+
const { clientTurnId, prompt } = turn
|
|
286
|
+
|
|
287
|
+
const rewrite = (apply: (row: QueuedThreadTurn) => void): QueuedThreadTurn | null => {
|
|
288
|
+
const latest = readQueue(provider, threadId, deps.now())
|
|
289
|
+
const row = latest.find(t => t.clientTurnId === clientTurnId)
|
|
290
|
+
if (!row) return null
|
|
291
|
+
apply(row)
|
|
292
|
+
writeQueue(provider, threadId, latest, { durable: true })
|
|
293
|
+
return row
|
|
294
|
+
}
|
|
295
|
+
const refuse = (reason: string): MidTurnOutcome => {
|
|
296
|
+
const at = deps.now()
|
|
297
|
+
rewrite(row => { row.status = 'refused'; row.reason = reason; row.settledAt = at })
|
|
298
|
+
deps.provenance?.forgetQueued(provider, threadId, clientTurnId)
|
|
299
|
+
console.warn(`[thread-turn-queue] mid-turn refused provider=${provider} clientTurnId=${clientTurnId} reason=${reason}`)
|
|
300
|
+
return 'settled'
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// The front door's provenance gates, in its order.
|
|
304
|
+
let verdict: OriginVerdict | null = null
|
|
305
|
+
if (deps.provenance) {
|
|
306
|
+
if (hasFooterShapedLine(prompt)) return refuse('footer_line_refused')
|
|
307
|
+
const token = deps.provenance.issueCarry({ provider, threadId, clientTurnId, prompt })
|
|
308
|
+
const carried = token ? deps.provenance.takeCarry(token, clientTurnId, prompt) : null
|
|
309
|
+
// No carry could be made: leave it for the front door, which decides with its own carry.
|
|
310
|
+
if (carried === null) return 'held'
|
|
311
|
+
if (carried.seal !== null && !sealMatches(carried.seal, { provider, threadId, attachments: '' })) return refuse('queued_turn_tampered')
|
|
312
|
+
verdict = carried.verdict ?? localDrainVerdict(deps.now())
|
|
313
|
+
if (deps.provenance.reusesTurnId(clientTurnId, { prompt, verdict, provider, sessionId: threadId }, { continues: true })) return refuse('turn_id_in_use')
|
|
314
|
+
}
|
|
315
|
+
const sent = deps.provenance && verdict ? deps.provenance.footed(prompt, clientTurnId, verdict, provider) : prompt
|
|
316
|
+
const ledger = (via: string): void => {
|
|
317
|
+
if (deps.provenance && verdict) deps.provenance.record({ turnId: clientTurnId, prompt, verdict, provider, sessionId: threadId, via, continues: true })
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// 1. The intent row, durably.
|
|
321
|
+
const intent = rewrite(row => {
|
|
322
|
+
row.status = 'delivering'
|
|
323
|
+
row.attempts = 1
|
|
324
|
+
row.mode = 'steer'
|
|
325
|
+
row.journal = 1
|
|
326
|
+
if (typeof deps.bootId === 'string' && deps.bootId.length > 0) row.deliveringBootId = deps.bootId
|
|
327
|
+
else delete row.deliveringBootId
|
|
328
|
+
})
|
|
329
|
+
if (!intent) return 'held'
|
|
330
|
+
const backToWaiting = (row: QueuedThreadTurn): void => {
|
|
331
|
+
row.status = 'waiting'
|
|
332
|
+
row.attempts = 0
|
|
333
|
+
delete row.mode
|
|
334
|
+
delete row.journal
|
|
335
|
+
delete row.deliveringBootId
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// 2. The attempt `pending`, durably, before any frame.
|
|
339
|
+
let begun = false
|
|
340
|
+
try { begun = deps.attempts!.begin(provider, threadId, clientTurnId, 'steer', deps.now()) === true } catch { begun = false }
|
|
341
|
+
if (!begun) {
|
|
342
|
+
rewrite(backToWaiting)
|
|
343
|
+
console.warn(`[thread-turn-queue] mid-turn not attempted provider=${provider} clientTurnId=${clientTurnId}: the attempt journal could not record it`)
|
|
344
|
+
return 'held'
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// 3. The frame.
|
|
348
|
+
const startedAt = deps.now()
|
|
349
|
+
let live: { ok: boolean; reason: string } | null = null
|
|
350
|
+
try {
|
|
351
|
+
live = await deps.deliverLiveTurn!({ provider, sessionId: threadId, prompt: sent, verifyTimeoutMs: PEER_VERIFY_TIMEOUT_MS, clientTurnId })
|
|
352
|
+
} catch (error) {
|
|
353
|
+
console.error(`[thread-turn-queue] mid-turn delivery threw: ${error instanceof Error ? error.message : error}`)
|
|
354
|
+
live = null
|
|
355
|
+
}
|
|
356
|
+
const at = deps.now()
|
|
357
|
+
const journal = (exit: 'sent' | 'not_sent' | 'unknown'): void => {
|
|
358
|
+
try { deps.attempts!.settle(provider, threadId, clientTurnId, exit, at) } catch { /* the reconcile reads `pending` as unknown */ }
|
|
359
|
+
}
|
|
360
|
+
console.log(`[thread-turn-queue] mid-turn provider=${provider} clientTurnId=${clientTurnId} hop=${live?.reason ?? 'threw'} ms=${Math.max(0, at - startedAt)} waitedMs=${Math.max(0, at - turn.queuedAt)}`)
|
|
361
|
+
|
|
362
|
+
// 4. The verdict, journal first, then the row.
|
|
363
|
+
if (live?.ok === true) {
|
|
364
|
+
journal('sent')
|
|
365
|
+
ledger('drain-steer')
|
|
366
|
+
rewrite(row => { row.status = 'delivered'; row.reason = 'steered'; row.settledAt = at })
|
|
367
|
+
deps.provenance?.forgetQueued(provider, threadId, clientTurnId)
|
|
368
|
+
return 'delivered'
|
|
369
|
+
}
|
|
370
|
+
if (live === null || live.reason === 'unverified' || live.reason === 'write_failed') {
|
|
371
|
+
journal('unknown')
|
|
372
|
+
ledger('drain-steer-unverified')
|
|
373
|
+
rewrite(row => { row.status = 'refused'; row.reason = 'steer_unverified'; row.deliveryState = 'unknown'; row.settledAt = at })
|
|
374
|
+
deps.provenance?.forgetQueued(provider, threadId, clientTurnId)
|
|
375
|
+
return 'settled'
|
|
376
|
+
}
|
|
377
|
+
// The transport's own word that nothing reached a session: the row waits for the turn end.
|
|
378
|
+
journal('not_sent')
|
|
379
|
+
rewrite(backToWaiting)
|
|
380
|
+
return 'held'
|
|
381
|
+
}
|
|
382
|
+
|
|
213
383
|
/**
|
|
214
384
|
* One drain pass over one thread.
|
|
215
385
|
*
|
|
@@ -243,9 +413,14 @@ export async function drainThread(
|
|
|
243
413
|
if (turn.status !== 'waiting') deps.provenance?.forgetQueued(provider, threadId, turn.clientTurnId)
|
|
244
414
|
}
|
|
245
415
|
|
|
416
|
+
// Steer mid-turn (COS_STEER_MIDTURN): FIFO per thread. Once a row ahead cannot go in mid-turn
|
|
417
|
+
// (or is still being delivered), nothing behind it may jump it; it waits for the turn end too.
|
|
418
|
+
let midTurnOrderHeld = false
|
|
419
|
+
|
|
246
420
|
for (const snapshot of queue) {
|
|
247
421
|
const turn = readQueue(provider, threadId, deps.now()).find(t => t.clientTurnId === snapshot.clientTurnId)
|
|
248
422
|
if (!turn) continue
|
|
423
|
+
if (turn.status === 'delivering') midTurnOrderHeld = true
|
|
249
424
|
if (turn.status !== 'waiting') continue
|
|
250
425
|
|
|
251
426
|
// Reuse only refusal within this synchronous pass. Rechecking a busy thread
|
|
@@ -284,7 +459,21 @@ export async function drainThread(
|
|
|
284
459
|
}
|
|
285
460
|
const decision = drainDecision(turn, seen, deps.now())
|
|
286
461
|
|
|
287
|
-
if (decision === 'hold') {
|
|
462
|
+
if (decision === 'hold') {
|
|
463
|
+
// Steer mid-turn: never during a cancel hold (the person said stop), only for the first
|
|
464
|
+
// rows in order, and only where the shared steer rule says this session can take it now,
|
|
465
|
+
// which also means the hold is the busy GATE: the rule answers only for a refusal that
|
|
466
|
+
// names a desk run (an attachable thread has no reason). Anything else holds as before.
|
|
467
|
+
const cancelHeld = typeof seen.cancelHoldUntil === 'number' && Number.isFinite(seen.cancelHoldUntil) && deps.now() < seen.cancelHoldUntil
|
|
468
|
+
if (!midTurnOrderHeld && !cancelHeld) {
|
|
469
|
+
const outcome = await steerHeldTurn(provider, threadId, turn, gate.reason, deps)
|
|
470
|
+
if (outcome === 'delivered') { delivered += 1; continue }
|
|
471
|
+
if (outcome === 'settled') { retired += 1; continue }
|
|
472
|
+
}
|
|
473
|
+
midTurnOrderHeld = true
|
|
474
|
+
held += 1
|
|
475
|
+
continue
|
|
476
|
+
}
|
|
288
477
|
if (decision === 'expire' || decision === 'give_up') {
|
|
289
478
|
turn.status = decision === 'expire' ? 'expired' : 'refused'
|
|
290
479
|
turn.reason = decision === 'expire' ? 'queued_turn_expired' : 'delivery_attempts_exhausted'
|
|
@@ -710,12 +899,27 @@ export function createThreadTurnQueueRouter(deps: ThreadTurnQueueDeps): Router {
|
|
|
710
899
|
|
|
711
900
|
writeQueue(provider, threadId, admitted.queue)
|
|
712
901
|
if (deps.provenance && verdict) deps.provenance.record({ turnId: clientTurnId, prompt, verdict, provider, sessionId: threadId, via: 'queue' })
|
|
902
|
+
// Steer mid-turn (COS_STEER_MIDTURN): the park answer stays the 202 every client reads (a
|
|
903
|
+
// lens that sees anything else says "could not queue"); the drain is asked to run now, and it
|
|
904
|
+
// puts the words into the running turn. `landsAt` (additive, only with the switch on) tells a
|
|
905
|
+
// client which line to show: `next_step` only when this row is first in line, has no files,
|
|
906
|
+
// and the session can take it now; `turn_end` otherwise.
|
|
907
|
+
const midTurn = midTurnAvailabilityFor(deps, provider, threadId, gate.reason)
|
|
908
|
+
let landsAt: 'next_step' | 'turn_end' | null = null
|
|
909
|
+
if (midTurn) {
|
|
910
|
+
const firstInLine = queuePosition(admitted.queue, clientTurnId) === 0 && !admitted.queue.some(t => t.status === 'delivering')
|
|
911
|
+
landsAt = midTurn.available && firstInLine && attach.ids.length === 0 ? 'next_step' : 'turn_end'
|
|
912
|
+
if (landsAt === 'next_step') {
|
|
913
|
+
try { deps.kickDrain?.(threadId) } catch { /* the 20 s sweep still delivers it */ }
|
|
914
|
+
}
|
|
915
|
+
}
|
|
713
916
|
return res.status(202).json({
|
|
714
917
|
queued: true,
|
|
715
918
|
clientTurnId,
|
|
716
919
|
position: queuePosition(admitted.queue, clientTurnId),
|
|
717
920
|
waitingOn: gate.reason,
|
|
718
921
|
...(steerFallback !== null ? { steerFallback } : {}),
|
|
922
|
+
...(landsAt !== null ? { landsAt } : {}),
|
|
719
923
|
})
|
|
720
924
|
})
|
|
721
925
|
|
|
@@ -4,10 +4,19 @@
|
|
|
4
4
|
* - GET /api/today/brief the cached summary; never generates.
|
|
5
5
|
* - POST /api/today/brief an open Today's ask, with the rows it read (titles, states, how long, last lines,
|
|
6
6
|
* what needs you). Generates only when the switch is on, the inputs changed, 10 minutes
|
|
7
|
-
* passed and the day's
|
|
7
|
+
* passed and the day's cap (Summaries per day, 7.0.5) is not spent. The Work cards in progress come from the
|
|
8
8
|
* server's own shared board read, never the body.
|
|
9
|
+
* 7.0.1: `"force": true` in the body is the Today refresh button. It skips the
|
|
10
|
+
* 10-minute interval and the "inputs unchanged" check, at most once every 30 s,
|
|
11
|
+
* inside the same daily cap. A throttled one answers reason `refresh_throttled` and
|
|
12
|
+
* `retryAfterMs`.
|
|
13
|
+
* 7.0.5: `"reason": "reconnect"` says the client's read just recovered from a failed
|
|
14
|
+
* one: that start skips the 10-minute interval, at most once per 10 minutes. The
|
|
15
|
+
* "inputs unchanged" check, the breaker, the switch and the daily cap still apply.
|
|
9
16
|
* - GET /api/today/brief/settings the switch and what set it.
|
|
10
17
|
* - POST /api/today/brief/settings { enabled: boolean }, the Settings toggle. COS_TODAY_BRIEF_LLM, when set, still wins.
|
|
18
|
+
* - GET /api/today/brief/settings/cap 7.0.5 "Summaries per day": { dailyCap, min: 1, max: 100, default: 100, usedToday }.
|
|
19
|
+
* - PUT /api/today/brief/settings/cap { dailyCap: number }, clamped to 1 to 100 and saved; answers the same shape.
|
|
11
20
|
* Auth is the parent /api gate (token or web session cookie). Every answer is private, no-store.
|
|
12
21
|
*/
|
|
13
22
|
import { Router } from 'express'
|
|
@@ -61,7 +70,7 @@ export async function readSubagentsFor(keys: string[], now = Date.now()): Promis
|
|
|
61
70
|
return out
|
|
62
71
|
}
|
|
63
72
|
|
|
64
|
-
/** The server's own brief: the data folder,
|
|
73
|
+
/** The server's own brief: the data folder, Sonnet (7.0.1) through the isolated text runner, one audit row per start. */
|
|
65
74
|
export function defaultTodayBrief(): TodayBrief {
|
|
66
75
|
return createTodayBrief({
|
|
67
76
|
stateFile: dataPath(TODAY_BRIEF_STATE_FILE),
|
|
@@ -95,6 +104,18 @@ export function workInProgress(rows: TaskBoardRow[] | null): BriefWork[] {
|
|
|
95
104
|
return out.sort((a, b) => Number(b.running) - Number(a.running)).slice(0, 12)
|
|
96
105
|
}
|
|
97
106
|
|
|
107
|
+
/**
|
|
108
|
+
* The body without `force` and `reason`, and what they asked. Anything but a boolean `force` is a bad request; 7.0.5
|
|
109
|
+
* `reason` may only be "reconnect".
|
|
110
|
+
*/
|
|
111
|
+
export function splitForce(raw: unknown): { body: unknown; force: boolean; reconnect: boolean } {
|
|
112
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw) || !('force' in raw || 'reason' in raw)) return { body: raw, force: false, reconnect: false }
|
|
113
|
+
const { force, reason, ...rest } = raw as Record<string, unknown>
|
|
114
|
+
if (force !== undefined && typeof force !== 'boolean') throw new TodayBriefInputError('invalid_today_brief')
|
|
115
|
+
if (reason !== undefined && reason !== 'reconnect') throw new TodayBriefInputError('invalid_today_brief')
|
|
116
|
+
return { body: rest, force: force === true, reconnect: reason === 'reconnect' }
|
|
117
|
+
}
|
|
118
|
+
|
|
98
119
|
export function createTodayBriefRouter(deps: TodayBriefRouterDeps): Router {
|
|
99
120
|
const router = Router()
|
|
100
121
|
router.use('/today/brief', (_req, res, next) => { res.set('Cache-Control', 'private, no-store'); next() })
|
|
@@ -108,18 +129,21 @@ export function createTodayBriefRouter(deps: TodayBriefRouterDeps): Router {
|
|
|
108
129
|
if (Buffer.byteLength(JSON.stringify(req.body ?? null)) > TODAY_BRIEF_BODY_MAX_BYTES) {
|
|
109
130
|
return res.status(413).json({ error: { code: 'today_brief_too_large', message: 'Too much for the Today summary.' } })
|
|
110
131
|
}
|
|
132
|
+
// 7.0.1: `force` is the refresh button's flag, taken off before the rows are read (the rows reject unknown keys).
|
|
133
|
+
const { body, force, reconnect } = splitForce(req.body)
|
|
111
134
|
// The switch first: off reads nothing, not even the board.
|
|
112
135
|
if (!deps.brief.settings().enabled) {
|
|
113
|
-
normalizeTodayBriefInputs(
|
|
136
|
+
normalizeTodayBriefInputs(body)
|
|
114
137
|
return res.json(deps.brief.peek())
|
|
115
138
|
}
|
|
116
|
-
// Every gate that needs no reads goes first (switch, recording, drain, breaker, daily cap, interval
|
|
117
|
-
// answers from the page's rows alone and reads neither the board nor any
|
|
118
|
-
|
|
119
|
-
const
|
|
139
|
+
// Every gate that needs no reads goes first (switch, recording, drain, breaker, daily cap, interval or, forced,
|
|
140
|
+
// the 30 s refresh floor): a gated ask answers from the page's rows alone and reads neither the board nor any
|
|
141
|
+
// transcript.
|
|
142
|
+
const pageInputs = normalizeTodayBriefInputs(body)
|
|
143
|
+
const closed = deps.brief.gate({ force, reconnect })
|
|
120
144
|
if (closed) return res.json(deps.brief.askGated(pageInputs, closed))
|
|
121
145
|
const work = workInProgress(await deps.board().catch(() => null))
|
|
122
|
-
let inputs = normalizeTodayBriefInputs(
|
|
146
|
+
let inputs = normalizeTodayBriefInputs(body, work)
|
|
123
147
|
const keys = subagentLookups(inputs)
|
|
124
148
|
if (keys.length && deps.subagents) {
|
|
125
149
|
let timer: ReturnType<typeof setTimeout> | undefined
|
|
@@ -127,7 +151,7 @@ export function createTodayBriefRouter(deps: TodayBriefRouterDeps): Router {
|
|
|
127
151
|
const readings = await Promise.race([deps.subagents(keys).catch(() => new Map<string, SubagentReading>()), late]).finally(() => clearTimeout(timer))
|
|
128
152
|
inputs = withSubagents(inputs, readings, (deps.now ?? Date.now)())
|
|
129
153
|
}
|
|
130
|
-
res.json(await deps.brief.ask(inputs, todayBriefFingerprint(pageInputs)))
|
|
154
|
+
res.json(await deps.brief.ask(inputs, todayBriefFingerprint(pageInputs), { force, reconnect }))
|
|
131
155
|
} catch (error) {
|
|
132
156
|
if (error instanceof TodayBriefInputError) return res.status(400).json({ error: { code: error.code, message: 'That is not a Today summary request.' } })
|
|
133
157
|
console.warn(`[today-brief] ask failed (${error instanceof Error ? error.name : 'error'})`)
|
|
@@ -144,5 +168,16 @@ export function createTodayBriefRouter(deps: TodayBriefRouterDeps): Router {
|
|
|
144
168
|
}
|
|
145
169
|
try { res.json(deps.brief.setEnabled(body.enabled)) } catch { failed(res) }
|
|
146
170
|
})
|
|
171
|
+
// 7.0.5 "Summaries per day" (Control Web Settings and COS Control use the same route).
|
|
172
|
+
router.get('/today/brief/settings/cap', (_req, res) => {
|
|
173
|
+
try { res.json(deps.brief.cap()) } catch { failed(res) }
|
|
174
|
+
})
|
|
175
|
+
router.put('/today/brief/settings/cap', (req, res) => {
|
|
176
|
+
const body = req.body
|
|
177
|
+
if (!body || typeof body !== 'object' || Array.isArray(body) || Object.keys(body).some(k => k !== 'dailyCap') || typeof body.dailyCap !== 'number' || !Number.isFinite(body.dailyCap)) {
|
|
178
|
+
return res.status(400).json({ error: { code: 'invalid_today_brief_cap', message: 'Send { dailyCap: a number from 1 to 100 }.' } })
|
|
179
|
+
}
|
|
180
|
+
try { res.json(deps.brief.setDailyCap(body.dailyCap)) } catch { failed(res) }
|
|
181
|
+
})
|
|
147
182
|
return router
|
|
148
183
|
}
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { Router } from 'express'
|
|
6
6
|
import { callPython, pythonBridgeAvailable } from '../lib/python-bridge.js'
|
|
7
|
+
import { calendarToday, readCalendar } from '../lib/calendar-source.js'
|
|
7
8
|
import { parsePrepOccurrence } from '../lib/prep-store.js'
|
|
8
9
|
import type { PrepOccurrence } from '../../shared/prep.js'
|
|
9
10
|
|
|
@@ -34,7 +35,8 @@ export function bridgeOccurrence(evt: Record<string, unknown> | null | undefined
|
|
|
34
35
|
calendarId: pick('calendar_id', 'calendarId'),
|
|
35
36
|
eventId: pick('event_id', 'eventId') ?? null,
|
|
36
37
|
externalId: pick('external_id', 'externalId') ?? null,
|
|
37
|
-
|
|
38
|
+
// occurrence_start (EventKit's original occurrence date) first, so a moved occurrence keeps its draft.
|
|
39
|
+
startsAt: pick('occurrence_start', 'start_time', 'start', 'startsAt'),
|
|
38
40
|
endsAt: pick('end_time', 'end', 'endsAt') ?? null,
|
|
39
41
|
}) ?? undefined
|
|
40
42
|
} catch { return undefined }
|
|
@@ -327,7 +329,8 @@ export async function fetchNextEvent(): Promise<{ event: NextEventPayload | null
|
|
|
327
329
|
}
|
|
328
330
|
|
|
329
331
|
try {
|
|
330
|
-
const
|
|
332
|
+
const calRead = await readCalendar()
|
|
333
|
+
const cal = calRead.data as {
|
|
331
334
|
today_remaining?: Array<{
|
|
332
335
|
title?: string
|
|
333
336
|
start_time?: string
|
|
@@ -338,10 +341,14 @@ export async function fetchNextEvent(): Promise<{ event: NextEventPayload | null
|
|
|
338
341
|
minutes_until_next?: number
|
|
339
342
|
} | null
|
|
340
343
|
|
|
341
|
-
|
|
344
|
+
// The EventKit snapshot is a real read without the bridge; 'none' (denied or expired) never is.
|
|
345
|
+
const sourceReady = calRead.source === 'bridge' ? pythonBridgeAvailable() : calRead.source !== 'none'
|
|
346
|
+
const read = calendarAnswerWasRead(sourceReady, cal)
|
|
342
347
|
const remaining = cal?.today_remaining ?? []
|
|
343
348
|
const meaningful = remaining.find((evt) => {
|
|
344
349
|
if (evt.is_all_day) return false
|
|
350
|
+
// Snapshot rows say whether they may become next/prep (declined, shared-calendar travel, holidays).
|
|
351
|
+
if ((evt as { prep_worthy?: unknown }).prep_worthy === false) return false
|
|
345
352
|
return !isGenericNoiseEvent(evt.title || '')
|
|
346
353
|
})
|
|
347
354
|
|
|
@@ -394,6 +401,19 @@ export function _resetWelcomeContextCachesForTests(): void {
|
|
|
394
401
|
lastFailureLogAt.clear()
|
|
395
402
|
}
|
|
396
403
|
|
|
404
|
+
/**
|
|
405
|
+
* PH plan P1: Today's calendar for Control Web and desktop. Behind the /api token gate. Selected calendars only, the
|
|
406
|
+
* next 3 events. `connected: false` means "Calendar not connected", never an empty day.
|
|
407
|
+
*/
|
|
408
|
+
welcomeContextRouter.get('/calendar/today', async (_req, res) => {
|
|
409
|
+
try {
|
|
410
|
+
res.set('Cache-Control', 'no-store')
|
|
411
|
+
res.json(await calendarToday())
|
|
412
|
+
} catch {
|
|
413
|
+
res.status(503).json({ error: 'calendar_unavailable' })
|
|
414
|
+
}
|
|
415
|
+
})
|
|
416
|
+
|
|
397
417
|
welcomeContextRouter.get('/welcome-context', async (req, res) => {
|
|
398
418
|
const lat = parseCoord(req.query.lat, 'lat')
|
|
399
419
|
const lon = parseCoord(req.query.lon, 'lon')
|
|
@@ -6,6 +6,7 @@ import { listBoard, listBoardWithDates, workBoardCapabilities, setTaskWorkStage,
|
|
|
6
6
|
import { createWorkBoardReader, WORK_BOARD_READ_LIMITS, WorkBoardTimeoutError, type WorkBoardReader } from '../lib/work-board-reader.js'
|
|
7
7
|
import type { MeetingDescriptor } from '../lib/work-review-store.js'
|
|
8
8
|
import { isSafeDomainName } from '../lib/domains.js'
|
|
9
|
+
import { workSessionActivity, waitingSessionIds } from '../lib/work-session-activity.js'
|
|
9
10
|
import { resolveSavedMeetingDetail } from './meetings.js'
|
|
10
11
|
import type { MeetingDetail } from '../lib/meeting-store.js'
|
|
11
12
|
|
|
@@ -28,11 +29,13 @@ export interface WorkBoardDependencies {
|
|
|
28
29
|
requests: () => Pick<WorkActivityCapabilities, 'requests' | 'requestsInbox' | 'requestsConsumerSeenAt'>
|
|
29
30
|
/** 6.59.0: the bounded, shared board read (5 s). Defaults to one over `list`. */
|
|
30
31
|
readBoard?: WorkBoardReader<TaskBoardRow>
|
|
32
|
+
/** 7.0.1: each linked session's newest activity (lib/work-session-activity.ts), for the open list's supersede rule. */
|
|
33
|
+
sessionActivity: (sessionIds: readonly string[]) => Promise<ReadonlyMap<string, number>>
|
|
31
34
|
now: () => number
|
|
32
35
|
}
|
|
33
36
|
const defaults: WorkBoardDependencies = { journal: () => readWorkJournal(), list: listBoard, capabilities: workBoardCapabilities,
|
|
34
37
|
stage: setTaskWorkStage, link: linkTaskMeeting, edit: editWorkTask, capture: captureWorkItem, captureCapabilities: workIntakeCapabilities, resolveMeeting: resolveSavedMeetingDetail,
|
|
35
|
-
requests: () => ({ requests: 0, requestsInbox: 0, requestsConsumerSeenAt: null }), now: () => Date.now() }
|
|
38
|
+
requests: () => ({ requests: 0, requestsInbox: 0, requestsConsumerSeenAt: null }), sessionActivity: ids => workSessionActivity(ids), now: () => Date.now() }
|
|
36
39
|
/** How old a board read the activity and open-work glances accept (the reader shares one read between them). */
|
|
37
40
|
export const WORK_GLANCE_BOARD_AGE_MS = 10_000
|
|
38
41
|
|
|
@@ -82,7 +85,11 @@ export function createWorkBoardRouter(overrides: Partial<WorkBoardDependencies>
|
|
|
82
85
|
if (!journal.available) return res.json({ version: 1, available: false, reason: journal.reason, capabilities, items: [], total: 0, truncated: false })
|
|
83
86
|
const rows = await glanceBoard('open work')
|
|
84
87
|
if (!rows) return res.json({ version: 1, available: false, reason: 'Work board is unavailable. Refresh before trying again.', capabilities, items: [], total: 0, truncated: false })
|
|
85
|
-
|
|
88
|
+
// 7.0.1: a waiting item whose session moved on since its report is not waiting. Only those sessions are read; a
|
|
89
|
+
// read that fails keeps the list as it was.
|
|
90
|
+
const now = deps.now(), first = projectOpenWork(journal, rows, now), waiting = waitingSessionIds(first.items)
|
|
91
|
+
const sessionActivityMs = waiting.length ? await deps.sessionActivity(waiting).catch(() => undefined) : undefined
|
|
92
|
+
return res.json({ version: 1, available: true, capabilities, ...(sessionActivityMs?.size ? projectOpenWork(journal, rows, now, { sessionActivityMs }) : first) })
|
|
86
93
|
})
|
|
87
94
|
router.get('/work-board', async (_req, res) => {
|
|
88
95
|
try {
|
|
@@ -186,6 +186,11 @@ select.field {
|
|
|
186
186
|
padding: 4px calc(var(--gutter) + var(--safe-right)) calc(var(--safe-bottom) + 96px) calc(var(--gutter) + var(--safe-left));
|
|
187
187
|
}
|
|
188
188
|
.pane { max-width: 880px; }
|
|
189
|
+
/* 7.0.2 (A2, the Duo audit): the COS chat route ends at its composer (panes/messages.js sets ws-chat). The 96 px above
|
|
190
|
+
(and the 40 px from 600 px up) held room for a bottom bar that is gone; under the sticky composer it was blank page
|
|
191
|
+
that a focused box scrolled to and the closed keyboard left on screen. Two columns from 860 px keep a small margin. */
|
|
192
|
+
.workspace.ws-chat { padding-bottom: 0; }
|
|
193
|
+
@media (min-width: 860px) { .workspace.ws-chat { padding-bottom: calc(var(--safe-bottom) + 16px); } }
|
|
189
194
|
.pane-head { margin: 8px 0 18px; }
|
|
190
195
|
.pane-title { font: 500 40px/1.02 var(--display); letter-spacing: -0.015em; margin: 0; }
|
|
191
196
|
.pane-sub { color: var(--sub); margin: 6px 0 0; font-size: 15px; }
|
|
@@ -288,6 +293,11 @@ select.field {
|
|
|
288
293
|
.next-up .when { font: 500 24px/1 var(--display); white-space: nowrap; }
|
|
289
294
|
.next-up .what { font-weight: 600; }
|
|
290
295
|
.next-up .sub { color: var(--sub); font-size: 14px; }
|
|
296
|
+
.next-later { padding: 0 16px 14px; font-size: 14px; }
|
|
297
|
+
.next-later .later-row { display: flex; gap: 12px; padding: 3px 0; min-width: 0; }
|
|
298
|
+
.next-later .later-when { color: var(--sub); font-variant-numeric: tabular-nums; white-space: nowrap; min-width: 4.5em; }
|
|
299
|
+
.next-later .later-what { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
300
|
+
.next-later .later-asof { color: var(--sub); margin-top: 4px; }
|
|
291
301
|
|
|
292
302
|
/* ── Lists (sessions, meetings, messages) ────────────────── */
|
|
293
303
|
.list { background: var(--panel); border: 1px solid var(--panel-border); border-radius: var(--radius-lg); overflow: hidden; box-shadow: var(--shadow); }
|
|
@@ -507,6 +517,28 @@ select.field {
|
|
|
507
517
|
.pane.reading .pane-head.with-ask { display: flex; }
|
|
508
518
|
}
|
|
509
519
|
|
|
520
|
+
/* Duo tables: the divider between a list and its reader (app.js splitKit, W.splitKit), for Memories and Messages.
|
|
521
|
+
Hidden everywhere; each pane shows it only inside its own two-column breakpoint (panes/memories.css 760 px,
|
|
522
|
+
panes/cos-chat.css 860 px), so a phone never sees it. Sessions keeps its own copy of these rules (panes/sessions.css). */
|
|
523
|
+
.split-handle, .split-full-btn { display: none; }
|
|
524
|
+
.split-handle {
|
|
525
|
+
justify-content: center; align-items: center; position: sticky; top: calc(var(--safe-top) + 12px); align-self: start;
|
|
526
|
+
height: calc(100dvh - var(--safe-top) - 24px); cursor: col-resize; touch-action: none; user-select: none; -webkit-user-select: none;
|
|
527
|
+
-webkit-touch-callout: none; outline: none; z-index: 2;
|
|
528
|
+
}
|
|
529
|
+
/* The line is the split's own gap; the grip shows where to take it. */
|
|
530
|
+
.split-handle::after { content: ''; width: 4px; height: 36px; border-radius: 2px; background: var(--panel-border); transition: background 120ms ease, height 120ms ease; }
|
|
531
|
+
.split-handle:hover::after, .split-handle:focus-visible::after, .split-dragging > .split-handle::after { background: var(--accent); height: 52px; }
|
|
532
|
+
.split-handle:focus-visible { box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--accent) 45%, transparent); border-radius: 6px; }
|
|
533
|
+
/* A finger gets at least a 44 px target, 13 px over each pane's edge; a mouse keeps the divider's own column. */
|
|
534
|
+
@media (pointer: coarse) { .split-handle::before { content: ''; position: absolute; top: 0; bottom: 0; left: -13px; right: -13px; } }
|
|
535
|
+
/* While dragging, nothing in the panes selects or takes the pointer. */
|
|
536
|
+
.split-dragging { user-select: none; -webkit-user-select: none; cursor: col-resize; }
|
|
537
|
+
.split-dragging > :not(.split-handle) { pointer-events: none; }
|
|
538
|
+
.split-full-btn { margin-left: auto; flex: none; }
|
|
539
|
+
@media (pointer: coarse) { .split-full-btn { min-height: 44px; } }
|
|
540
|
+
@media (prefers-reduced-motion: reduce) { .split-handle::after { transition: none; } }
|
|
541
|
+
|
|
510
542
|
/* ── Desktop ─────────────────────────────────────────────── */
|
|
511
543
|
@media (min-width: 1100px) and (min-height: 721px) {
|
|
512
544
|
:root { --gutter: 40px; }
|
|
@@ -554,7 +586,8 @@ select.field {
|
|
|
554
586
|
/* Folded phones: every button and field is at least 44 px tall (design QA: .cx-btn came in six heights from 30 px). A
|
|
555
587
|
minimum only, so it grows the compact ones and leaves the taller ones; the per-pane phone heights that fought it are
|
|
556
588
|
gone. */
|
|
557
|
-
|
|
589
|
+
/* 7.0.2 (A8): a short touch screen at any width too (the unfolded Duo, 951x539 in Comet, is touch first). */
|
|
590
|
+
@media (max-width: 599px), (pointer: coarse) and (max-height: 720px) {
|
|
558
591
|
.cx-btn, .field { min-height: 44px !important; }
|
|
559
592
|
}
|
|
560
593
|
/* Touch screens: the small controls the canary measured under 44 px (nav 30, switches 30, a chip's remove and Retry 36,
|
|
@@ -566,6 +599,12 @@ select.field {
|
|
|
566
599
|
.cc-chip-x, .cc-attach, .cc-camera { min-width: 44px !important; min-height: 44px !important; }
|
|
567
600
|
.cx-switch button::before { content: ''; position: absolute; inset: -7px -4px; }
|
|
568
601
|
}
|
|
602
|
+
/* 7.0.2 (A8): a short touch screen 600 px wide or more (the unfolded Duo, 951x539) keeps the phone Ask sheet: full width
|
|
603
|
+
from the top, no centred desktop card with 8 % of a 539 px screen spent above it (app.js isPhone says phone too). */
|
|
604
|
+
@media (min-width: 600px) and (pointer: coarse) and (max-height: 720px) {
|
|
605
|
+
.sheet-backdrop { padding-top: 0; }
|
|
606
|
+
.sheet { max-width: none; border-radius: 0 0 20px 20px; padding: calc(var(--safe-top) + 10px) var(--gutter) 14px; max-height: calc(100dvh - 90px); box-shadow: none; }
|
|
607
|
+
}
|
|
569
608
|
/* 6.99.30 (QA): an element with the hidden attribute is hidden, whatever display its classes give it ("Retry image"
|
|
570
609
|
showed under loaded images: .link-btn's inline-flex beat [hidden]). */
|
|
571
610
|
[hidden] { display: none !important; }
|