@gotcos/glasses-server 7.0.7 → 7.0.18
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 +19 -0
- package/CHANGELOG.md +170 -0
- package/docs/web-key-verifier-contract.md +378 -0
- package/managed-runtime-contract.json +9 -1
- package/package.json +10 -3
- package/server/bootstrap.ts +10 -0
- package/server/index.ts +126 -23
- package/server/lib/agent-session-recent.ts +321 -0
- package/server/lib/agent-session-store.ts +3 -3
- package/server/lib/api-auth.ts +37 -1
- package/server/lib/calendar-meetings.ts +70 -0
- package/server/lib/calendar-source.ts +54 -9
- package/server/lib/claude-hooks-installer.ts +111 -12
- package/server/lib/conversation.ts +56 -28
- package/server/lib/cors-reject-log.ts +51 -0
- package/server/lib/disk-io-meter.ts +202 -0
- package/server/lib/drop-service.ts +47 -12
- package/server/lib/drop-store.ts +21 -1
- package/server/lib/event-loop-stall.ts +62 -0
- package/server/lib/full-hold-engine.ts +114 -0
- package/server/lib/full-hold.ts +124 -0
- package/server/lib/g2-turn-provenance.ts +128 -9
- package/server/lib/listener-startup.ts +18 -0
- package/server/lib/meeting-batch-transcribe.ts +32 -5
- package/server/lib/permission-broker.ts +443 -11
- package/server/lib/secret-path-guard.ts +4 -1
- package/server/lib/session-cache-writer.ts +8 -1
- package/server/lib/session-hook-events.ts +23 -1
- package/server/lib/session-hooks-runtime.ts +34 -0
- package/server/lib/session-log.ts +313 -4
- package/server/lib/session-state-derive.ts +36 -0
- package/server/lib/session-stream-events.ts +25 -1
- package/server/lib/today-brief.ts +522 -58
- package/server/lib/turn-end-ask.ts +320 -0
- package/server/lib/turn-footer.ts +3 -3
- package/server/lib/web-control-integrity.ts +160 -0
- package/server/lib/web-control-offnet-page.ts +58 -0
- package/server/lib/web-control-static.ts +53 -9
- package/server/lib/web-key-authority.ts +372 -0
- package/server/lib/web-key-envelope.ts +98 -0
- package/server/lib/web-key-handoff.ts +135 -0
- package/server/lib/web-key-origin.ts +21 -0
- package/server/lib/web-key-passkey.ts +155 -0
- package/server/lib/web-key-registry.ts +825 -0
- package/server/lib/web-key-signer-app.ts +152 -0
- package/server/lib/web-session-auth.ts +133 -15
- package/server/lib/whisper-local.ts +93 -7
- package/server/lib/whisper-preview.ts +5 -3
- package/server/lib/work-orchestrator/snapshot.ts +17 -3
- package/server/lib/work-review-runtime.ts +17 -4
- package/server/routes/agent-session-bindings.ts +16 -1
- package/server/routes/agent-session-stream.ts +7 -0
- package/server/routes/drops.ts +7 -2
- package/server/routes/g2-turns.ts +35 -0
- package/server/routes/health.ts +11 -1
- package/server/routes/live-events.ts +6 -1
- package/server/routes/permission-broker.ts +26 -0
- package/server/routes/query-jobs.ts +3 -0
- package/server/routes/sessions.ts +15 -24
- package/server/routes/thread-turn-queue.ts +16 -9
- package/server/routes/today-brief.ts +91 -36
- package/server/routes/web-control.ts +37 -4
- package/server/routes/web-key.ts +284 -0
- package/server/routes/welcome-context.ts +6 -2
- package/server/routes/work-orchestrator.ts +12 -7
- package/server/scripts/write-web-control-manifest.mjs +43 -0
- package/server/web-control/.manifest.json +44 -0
- package/server/web-control/app.css +36 -0
- package/server/web-control/app.js +953 -57
- package/server/web-control/apple-touch-icon.png +0 -0
- package/server/web-control/index.html +14 -1
- package/server/web-control/panes/cos-chat.css +52 -6
- package/server/web-control/panes/cos-chat.js +364 -32
- package/server/web-control/panes/questions.js +135 -14
- package/server/web-control/panes/settings.js +1 -1
- package/server/web-control/panes/today-actions.css +35 -0
- package/server/web-control/panes/today-actions.js +75 -6
- package/server/web-control/panes/work.css +3 -0
- package/server/web-control/panes/work.js +178 -35
- package/server/web-signer/.manifest.json +11 -0
- package/server/web-signer/index.html +74 -0
- package/server/web-signer/signer-proof.js +348 -0
- package/server/web-signer/signer-store.js +39 -0
- package/server/web-signer/signer.css +23 -0
- package/server/web-signer/signer.js +341 -0
- package/shared/web-approval-packets.golden.json +124 -0
- package/shared/web-turn-envelope.ts +271 -0
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Recent activity first" for the Today summary (Miles, 2026-11-09): what was said INTO a session lately and where its
|
|
3
|
+
* latest reply left off, with when, so the summary describes what a session is doing now instead of its first prompt.
|
|
4
|
+
*
|
|
5
|
+
* Why: each session's list `line` (composeDiscussionSummary) is its FIRST prompt plus the latest reply, cut at 180
|
|
6
|
+
* characters. On 11/09 the Codex orchestrator's line led with an ask from 10/11, 29 days old, and the model wrote
|
|
7
|
+
* "following up a transcription bakeoff" about a session that had just confirmed a release was live.
|
|
8
|
+
*
|
|
9
|
+
* readRecentActivity(provider, file, now) answers the last few asks (each with when and how it arrived), the latest
|
|
10
|
+
* reply and when the session last moved. Read-only display evidence: never imported by an attach, queue or write gate.
|
|
11
|
+
*
|
|
12
|
+
* - Claude: the last 8 MB of the transcript, memoized on mtime and size like readClaudeSubagents. User turns follow
|
|
13
|
+
* parseAgentSession's rules: sidechains, `isMeta` and `isCompactSummary` rows are not asks (a peer row is, unwrapped
|
|
14
|
+
* by unwrapPeerMessage), the verify footer comes off (withoutTurnFooter), wrapper tags go (STRIPPABLE_TAG_RE) and a
|
|
15
|
+
* pasted JSON block becomes "[pasted data]". A peer row, or a row that carried a COS verify footer, arrived from
|
|
16
|
+
* another screen or session: `via: 'peer'`, never attributed to the person. A peer row that is agent-to-agent
|
|
17
|
+
* protocol (a cross-session or agent-message envelope) is not an ask at all.
|
|
18
|
+
* - Codex: rollouts reach 32 GB with single lines of 44 MB (measured 11/09). An incremental forward scan keeps its byte
|
|
19
|
+
* offset in memory; the first pass reads at most the last 256 MB; a line over 1 MB is skipped without being decoded
|
|
20
|
+
* or parsed; reads are async, 4 MB at a time, and the scan yields between chunks. One scan per file at a time: a
|
|
21
|
+
* second caller waits on the first, and a scan the Today route stopped waiting for still finishes and fills the memo.
|
|
22
|
+
* Codex rows carry no sender marker, so only a COS verify footer marks one as sent in from another screen.
|
|
23
|
+
* - Cursor: the existing tail peek (the last user query and reply); its transcript has no timestamps, so every time is
|
|
24
|
+
* the file's mtime.
|
|
25
|
+
*
|
|
26
|
+
* `startedAt` and `firstAsk` are set only for a session whose file was created within the last day, for the
|
|
27
|
+
* "started today as" note. Opens refuse symlinks and non-regular files. Never throws: null when unreadable.
|
|
28
|
+
*/
|
|
29
|
+
import { constants } from 'node:fs'
|
|
30
|
+
import { open, stat } from 'node:fs/promises'
|
|
31
|
+
import {
|
|
32
|
+
STRIPPABLE_TAG_RE, createdFromCodexFilename, firstClaudeUserTitle, firstCursorUserTitle, isWrapperPrompt, parseJsonLine,
|
|
33
|
+
payloadText, codexUserText, peekCodexMeta, peekCursorDiscussion, proseSnippet, type AgentProvider,
|
|
34
|
+
} from './agent-session-store.js'
|
|
35
|
+
import { unwrapPeerMessage } from './session-stream-events.js'
|
|
36
|
+
import { stripG2TurnFooter } from './turn-footer.js'
|
|
37
|
+
|
|
38
|
+
export type RecentVia = 'typed' | 'peer'
|
|
39
|
+
export interface RecentAsk { at: number; text: string; via: RecentVia }
|
|
40
|
+
export interface RecentReply { at: number; text: string }
|
|
41
|
+
export interface RecentActivity {
|
|
42
|
+
/** The newest asks into the session, oldest first, at most RECENT_ASKS_KEPT. */
|
|
43
|
+
asks: RecentAsk[]
|
|
44
|
+
/** The newest assistant reply (Codex: its newest final answer, not commentary). */
|
|
45
|
+
reply: RecentReply | null
|
|
46
|
+
/** The newest record's time (ms), 0 when none was seen. */
|
|
47
|
+
movedAt: number
|
|
48
|
+
/** Only for a session created within the last day: when, and its first ask. */
|
|
49
|
+
startedAt?: number
|
|
50
|
+
firstAsk?: string
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const RECENT_ASKS_KEPT = 3
|
|
54
|
+
export const CLAUDE_RECENT_TAIL_BYTES = 8 * 1024 * 1024
|
|
55
|
+
export const CODEX_FIRST_PASS_BYTES = 256 * 1024 * 1024
|
|
56
|
+
/** A line longer than this is skipped unparsed (a 44 MB Codex line holds tool output, never an ask). */
|
|
57
|
+
export const RECENT_LINE_MAX_BYTES = 1024 * 1024
|
|
58
|
+
export const RECENT_CHUNK_BYTES = 4 * 1024 * 1024
|
|
59
|
+
const TEXT_KEEP = 1000
|
|
60
|
+
const MEMO_MAX = 64
|
|
61
|
+
const DAY_MS = 24 * 60 * 60 * 1000
|
|
62
|
+
|
|
63
|
+
// ── text ───────────────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
const PASTED = /<pasted_content\b[^>]*>([\s\S]*?)<\/pasted_content\b[^>]*>/gi
|
|
66
|
+
const WRAPPER_BLOCKS = /<(task-notification|system-reminder|relevant-memories|cache-health|daily-bulletin|cos-alarms|device-handoff|now|memory-stored|local-command-stdout|local-command-stderr|command-name|command-message|command-args|user_query)\b[^>]*>[\s\S]*?<\/\1>/gi
|
|
67
|
+
/** A peer row that is agent-to-agent protocol, not words for the session. */
|
|
68
|
+
const AGENT_ENVELOPE = /^\s*(?:<(?:cross-session-message|agent-message)\b|Message Type:|Subagent hand-?back\b)/i
|
|
69
|
+
/** A paragraph that is pasted data: it opens with { or [ and holds a "key": pair. */
|
|
70
|
+
const JSON_PARAGRAPH = /^\s*[[{][\s\S]*"\s*:/
|
|
71
|
+
|
|
72
|
+
/** One ask's words: pasted data replaced, wrapper blocks and tags out, one line, bounded. '' when nothing is left. */
|
|
73
|
+
export function cleanAskText(raw: string): string {
|
|
74
|
+
// Whole wrapper blocks go first (a background task's notification, a reminder, a slash command's parts). What is left
|
|
75
|
+
// is then judged as parseAgentSession's collectTurn judges a turn: still a wrapper (it opens with a tag the block list
|
|
76
|
+
// does not know), it is not an ask, and no tag is taken off so that its insides could read as words.
|
|
77
|
+
let s = raw.replace(WRAPPER_BLOCKS, ' ').replace(PASTED, (_m, inner: string) => {
|
|
78
|
+
const t = String(inner).trim()
|
|
79
|
+
return t.startsWith('{') || t.startsWith('[') ? '\n\n[pasted data]\n\n' : ` ${t} `
|
|
80
|
+
})
|
|
81
|
+
if (!s.trim() || isWrapperPrompt(s)) return ''
|
|
82
|
+
s = s.split(/\n\s*\n/).map(p => (JSON_PARAGRAPH.test(p) ? '[pasted data]' : p)).join('\n\n')
|
|
83
|
+
s = s.replace(STRIPPABLE_TAG_RE, ' ').replace(/\s+/g, ' ').trim()
|
|
84
|
+
if (!s || isWrapperPrompt(s) || s.startsWith('[Request interrupted') || s.startsWith('[image: source')) return ''
|
|
85
|
+
return s.slice(0, TEXT_KEEP)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** An ask's words and how it arrived: a footer means another screen (glasses, phone or web) sent it. */
|
|
89
|
+
function footerVia(text: string, peer: boolean): { text: string; via: RecentVia } {
|
|
90
|
+
const f = stripG2TurnFooter(text)
|
|
91
|
+
return { text: f.text, via: peer || f.turnId ? 'peer' : 'typed' }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const tsOf = (v: unknown): number => (typeof v === 'string' ? Date.parse(v) || 0 : 0)
|
|
95
|
+
|
|
96
|
+
interface Collector { asks: RecentAsk[]; reply: RecentReply | null; movedAt: number }
|
|
97
|
+
const emptyCollector = (): Collector => ({ asks: [], reply: null, movedAt: 0 })
|
|
98
|
+
function addAsk(c: Collector, ask: RecentAsk) {
|
|
99
|
+
c.asks.push(ask)
|
|
100
|
+
if (c.asks.length > RECENT_ASKS_KEPT) c.asks.shift()
|
|
101
|
+
}
|
|
102
|
+
const moved = (c: Collector, at: number) => { if (at > c.movedAt) c.movedAt = at }
|
|
103
|
+
|
|
104
|
+
/** One Claude record into the collector (parseAgentSession's user-turn rules). */
|
|
105
|
+
export function collectClaudeRecord(c: Collector, obj: Record<string, unknown>): void {
|
|
106
|
+
if (obj.isSidechain === true) return
|
|
107
|
+
const at = tsOf(obj.timestamp)
|
|
108
|
+
if (at && (obj.type === 'user' || obj.type === 'assistant' || obj.type === 'system')) moved(c, at)
|
|
109
|
+
if (obj.type !== 'user' && obj.type !== 'assistant') return
|
|
110
|
+
if (obj.type === 'user' && obj.toolUseResult) return
|
|
111
|
+
const message = obj.message && typeof obj.message === 'object' ? obj.message as Record<string, unknown> : null
|
|
112
|
+
const text = message ? payloadText(message) : null
|
|
113
|
+
if (!text) return
|
|
114
|
+
if (obj.type === 'assistant') {
|
|
115
|
+
const prose = proseSnippet(text, TEXT_KEEP)
|
|
116
|
+
if (prose) c.reply = { at, text: prose }
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
const isPeer = (obj.origin as { kind?: unknown } | undefined)?.kind === 'peer'
|
|
120
|
+
const peerWords = isPeer ? unwrapPeerMessage(text) : null
|
|
121
|
+
// Injected rows (a slash-command body, a compaction preamble) are not asks; a peer row is, once unwrapped.
|
|
122
|
+
if (peerWords === null && (obj.isMeta === true || obj.isCompactSummary === true)) return
|
|
123
|
+
if (peerWords !== null && AGENT_ENVELOPE.test(peerWords)) return
|
|
124
|
+
const { text: words, via } = footerVia(peerWords ?? text, peerWords !== null)
|
|
125
|
+
const clean = cleanAskText(words)
|
|
126
|
+
if (clean) addAsk(c, { at, text: clean, via })
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** One Codex rollout line into the collector. The caller has already checked its size. */
|
|
130
|
+
export function collectCodexLine(c: Collector, line: Buffer): void {
|
|
131
|
+
const head = line.subarray(0, 512).toString('utf8')
|
|
132
|
+
const stamp = /"timestamp":"([^"]{10,40})"/.exec(head)
|
|
133
|
+
const at = stamp ? Date.parse(stamp[1]!) || 0 : 0
|
|
134
|
+
if (at) moved(c, at)
|
|
135
|
+
// Role and type sit before the content in a rollout line, so the head decides without decoding the rest.
|
|
136
|
+
if (!head.includes('"type":"message"') || !(head.includes('"role":"user"') || head.includes('"role":"assistant"'))) return
|
|
137
|
+
const obj = parseJsonLine(line.toString('utf8'))
|
|
138
|
+
if (!obj || obj.type !== 'response_item' || !obj.payload || typeof obj.payload !== 'object') return
|
|
139
|
+
const p = obj.payload as Record<string, unknown>
|
|
140
|
+
if (p.type !== 'message') return
|
|
141
|
+
if (p.role === 'assistant') {
|
|
142
|
+
if (p.phase === 'commentary') return
|
|
143
|
+
const text = payloadText(p)
|
|
144
|
+
const prose = text ? proseSnippet(text, TEXT_KEEP) : ''
|
|
145
|
+
if (prose) c.reply = { at, text: prose }
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
if (p.role !== 'user') return
|
|
149
|
+
const text = codexUserText(p)
|
|
150
|
+
if (!text || isWrapperPrompt(text)) return
|
|
151
|
+
const { text: words, via } = footerVia(text, false)
|
|
152
|
+
const clean = cleanAskText(words)
|
|
153
|
+
if (clean) addAsk(c, { at, text: clean, via })
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ── reads ──────────────────────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
type Handle = Awaited<ReturnType<typeof open>>
|
|
159
|
+
async function openRegular(file: string): Promise<{ handle: Handle; size: number; mtimeMs: number; birthtimeMs: number; ino: number } | null> {
|
|
160
|
+
let handle: Handle | null = null
|
|
161
|
+
try {
|
|
162
|
+
handle = await open(file, constants.O_RDONLY | (constants.O_NONBLOCK ?? 0) | (constants.O_NOFOLLOW ?? 0))
|
|
163
|
+
const st = await handle.stat()
|
|
164
|
+
if (!st.isFile()) { await handle.close().catch(() => {}); return null }
|
|
165
|
+
return { handle, size: st.size, mtimeMs: st.mtimeMs, birthtimeMs: st.birthtimeMs, ino: st.ino }
|
|
166
|
+
} catch {
|
|
167
|
+
await handle?.close().catch(() => {})
|
|
168
|
+
return null
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
const yieldLoop = () => new Promise<void>(resolve => setImmediate(resolve))
|
|
172
|
+
|
|
173
|
+
const claudeMemo = new Map<string, { mtimeMs: number; size: number; value: Collector }>()
|
|
174
|
+
const remember = <V>(memo: Map<string, V>, key: string, value: V) => {
|
|
175
|
+
if (!memo.has(key) && memo.size >= MEMO_MAX) memo.delete(memo.keys().next().value!)
|
|
176
|
+
memo.set(key, value)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** A Claude transcript's tail (8 MB), memoized on mtime and size. */
|
|
180
|
+
async function readClaude(file: string, tailBytes = CLAUDE_RECENT_TAIL_BYTES): Promise<{ c: Collector; birthtimeMs: number } | null> {
|
|
181
|
+
const opened = await openRegular(file)
|
|
182
|
+
if (!opened) return null
|
|
183
|
+
const { handle, size, mtimeMs, birthtimeMs } = opened
|
|
184
|
+
try {
|
|
185
|
+
const hit = claudeMemo.get(file)
|
|
186
|
+
if (hit && hit.mtimeMs === mtimeMs && hit.size === size) return { c: hit.value, birthtimeMs }
|
|
187
|
+
const length = Math.min(size, tailBytes), start = size - length
|
|
188
|
+
const buffer = Buffer.alloc(length)
|
|
189
|
+
const read = await handle.read(buffer, 0, length, start)
|
|
190
|
+
const data = buffer.subarray(0, read.bytesRead)
|
|
191
|
+
const c = emptyCollector()
|
|
192
|
+
let pos = 0, n = 0
|
|
193
|
+
// A tail that starts mid-file has a cut first line: it fails to parse and is skipped like any bad line.
|
|
194
|
+
while (pos < data.length) {
|
|
195
|
+
let end = data.indexOf(0x0a, pos)
|
|
196
|
+
if (end < 0) end = data.length
|
|
197
|
+
if (end - pos > 0 && end - pos <= RECENT_LINE_MAX_BYTES) {
|
|
198
|
+
const obj = parseJsonLine(data.subarray(pos, end).toString('utf8'))
|
|
199
|
+
if (obj) collectClaudeRecord(c, obj)
|
|
200
|
+
}
|
|
201
|
+
pos = end + 1
|
|
202
|
+
if (++n % 2000 === 0) await yieldLoop()
|
|
203
|
+
}
|
|
204
|
+
remember(claudeMemo, file, { mtimeMs, size, value: c })
|
|
205
|
+
return { c, birthtimeMs }
|
|
206
|
+
} catch {
|
|
207
|
+
return null
|
|
208
|
+
} finally {
|
|
209
|
+
await handle.close().catch(() => {})
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
interface CodexScan { offset: number; size: number; partial: Buffer | null; skipping: boolean; c: Collector; running: Promise<void> | null; ino: number }
|
|
214
|
+
const codexMemo = new Map<string, CodexScan>()
|
|
215
|
+
|
|
216
|
+
/** Test seam: forget every memo (a new server process starts with none). */
|
|
217
|
+
export function resetRecentActivityMemo(): void { claudeMemo.clear(); codexMemo.clear() }
|
|
218
|
+
/** Test seam: the bytes of an unfinished line a Codex scan holds for its next read (never more than the line bound). */
|
|
219
|
+
export function codexScanHeldBytes(file: string): number { return codexMemo.get(file)?.partial?.length ?? 0 }
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Scan a Codex rollout forward from where the last scan stopped. A first pass, or a gap past `firstPassBytes`, starts
|
|
223
|
+
* that far from the end and drops the line it lands inside.
|
|
224
|
+
*/
|
|
225
|
+
async function scanCodex(file: string, firstPassBytes: number, chunkBytes: number): Promise<CodexScan | null> {
|
|
226
|
+
const opened = await openRegular(file)
|
|
227
|
+
if (!opened) return null
|
|
228
|
+
const { handle, size, ino } = opened
|
|
229
|
+
try {
|
|
230
|
+
let scan = codexMemo.get(file)
|
|
231
|
+
if (scan && (scan.ino !== ino || size < scan.offset)) scan = undefined // replaced or truncated: start over
|
|
232
|
+
if (!scan) {
|
|
233
|
+
scan = { offset: 0, size: 0, partial: null, skipping: false, c: emptyCollector(), running: null, ino }
|
|
234
|
+
remember(codexMemo, file, scan)
|
|
235
|
+
}
|
|
236
|
+
if (scan.running) { await scan.running; return scan }
|
|
237
|
+
const s = scan
|
|
238
|
+
const run = (async () => {
|
|
239
|
+
if (size - s.offset > firstPassBytes) {
|
|
240
|
+
s.offset = size - firstPassBytes
|
|
241
|
+
s.partial = null
|
|
242
|
+
s.skipping = s.offset > 0 // landed mid-line: that line is not whole
|
|
243
|
+
}
|
|
244
|
+
while (s.offset < size) {
|
|
245
|
+
const length = Math.min(chunkBytes, size - s.offset)
|
|
246
|
+
const chunk = Buffer.alloc(length)
|
|
247
|
+
const { bytesRead } = await handle.read(chunk, 0, length, s.offset)
|
|
248
|
+
if (bytesRead <= 0) break
|
|
249
|
+
s.offset += bytesRead
|
|
250
|
+
let buf = chunk.subarray(0, bytesRead)
|
|
251
|
+
if (s.partial) { buf = Buffer.concat([s.partial, buf]); s.partial = null }
|
|
252
|
+
let pos = 0
|
|
253
|
+
for (let nl = buf.indexOf(0x0a, pos); nl >= 0; nl = buf.indexOf(0x0a, pos)) {
|
|
254
|
+
if (s.skipping) s.skipping = false
|
|
255
|
+
else if (nl - pos > 0 && nl - pos <= RECENT_LINE_MAX_BYTES) collectCodexLine(s.c, buf.subarray(pos, nl))
|
|
256
|
+
pos = nl + 1
|
|
257
|
+
}
|
|
258
|
+
const rest = buf.subarray(pos)
|
|
259
|
+
// An unfinished line past the bound is never kept: the rest of it is skipped up to its newline. While skipping
|
|
260
|
+
// (no newline in this chunk at all), nothing is kept either.
|
|
261
|
+
if (!s.skipping) {
|
|
262
|
+
if (rest.length > RECENT_LINE_MAX_BYTES) s.skipping = true
|
|
263
|
+
else if (rest.length) s.partial = Buffer.from(rest)
|
|
264
|
+
}
|
|
265
|
+
await yieldLoop()
|
|
266
|
+
}
|
|
267
|
+
s.size = size
|
|
268
|
+
})()
|
|
269
|
+
s.running = run
|
|
270
|
+
try { await run } finally { s.running = null }
|
|
271
|
+
return s
|
|
272
|
+
} catch {
|
|
273
|
+
return null
|
|
274
|
+
} finally {
|
|
275
|
+
await handle.close().catch(() => {})
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export interface RecentReadOptions { claudeTailBytes?: number; codexFirstPassBytes?: number; chunkBytes?: number }
|
|
280
|
+
|
|
281
|
+
/** What was said into one session lately, its latest reply and when it last moved, or null when it cannot be read. */
|
|
282
|
+
export async function readRecentActivity(provider: AgentProvider, file: string, now = Date.now(), opts: RecentReadOptions = {}): Promise<RecentActivity | null> {
|
|
283
|
+
try {
|
|
284
|
+
let c: Collector, createdAt = 0, firstAsk = ''
|
|
285
|
+
if (provider === 'claude') {
|
|
286
|
+
const r = await readClaude(file, opts.claudeTailBytes)
|
|
287
|
+
if (!r) return null
|
|
288
|
+
c = r.c
|
|
289
|
+
createdAt = r.birthtimeMs
|
|
290
|
+
if (createdAt && now - createdAt < DAY_MS) firstAsk = (await firstClaudeUserTitle(file)) ?? ''
|
|
291
|
+
} else if (provider === 'codex') {
|
|
292
|
+
const s = await scanCodex(file, opts.codexFirstPassBytes ?? CODEX_FIRST_PASS_BYTES, opts.chunkBytes ?? RECENT_CHUNK_BYTES)
|
|
293
|
+
if (!s) return null
|
|
294
|
+
c = s.c
|
|
295
|
+
createdAt = Date.parse(createdFromCodexFilename(file.split('/').pop() ?? '') ?? '') || 0
|
|
296
|
+
if (createdAt && now - createdAt < DAY_MS) firstAsk = (await peekCodexMeta(file))?.title ?? ''
|
|
297
|
+
} else {
|
|
298
|
+
const st = await stat(file).catch(() => null)
|
|
299
|
+
if (!st?.isFile()) return null
|
|
300
|
+
const peek = await peekCursorDiscussion(file)
|
|
301
|
+
const at = st.mtimeMs
|
|
302
|
+
c = { asks: [], reply: null, movedAt: at }
|
|
303
|
+
const ask = peek.lastUser ? cleanAskText(peek.lastUser) : ''
|
|
304
|
+
if (ask) c.asks.push({ at, text: ask, via: 'typed' })
|
|
305
|
+
const reply = peek.latestAssistant ? proseSnippet(peek.latestAssistant, TEXT_KEEP) : ''
|
|
306
|
+
if (reply) c.reply = { at, text: reply }
|
|
307
|
+
createdAt = st.birthtimeMs
|
|
308
|
+
if (createdAt && now - createdAt < DAY_MS) firstAsk = (await firstCursorUserTitle(file)) ?? ''
|
|
309
|
+
}
|
|
310
|
+
// Copies, so a caller can never edit the memo.
|
|
311
|
+
const out: RecentActivity = {
|
|
312
|
+
asks: c.asks.filter(a => a.at <= now).map(a => ({ ...a })),
|
|
313
|
+
reply: c.reply && c.reply.at <= now ? { ...c.reply } : null,
|
|
314
|
+
movedAt: c.movedAt,
|
|
315
|
+
}
|
|
316
|
+
if (firstAsk && createdAt) { out.startedAt = createdAt; out.firstAsk = firstAsk }
|
|
317
|
+
return out
|
|
318
|
+
} catch {
|
|
319
|
+
return null
|
|
320
|
+
}
|
|
321
|
+
}
|
|
@@ -250,7 +250,7 @@ const STRIPPABLE_TAGS = [
|
|
|
250
250
|
'local-command-stdout', 'local-command-stderr', 'thinking',
|
|
251
251
|
]
|
|
252
252
|
|
|
253
|
-
const STRIPPABLE_TAG_RE = new RegExp(`</?(?:${STRIPPABLE_TAGS.join('|')})(?:\\s[^>]*)?/?>`, 'gi')
|
|
253
|
+
export const STRIPPABLE_TAG_RE = new RegExp(`</?(?:${STRIPPABLE_TAGS.join('|')})(?:\\s[^>]*)?/?>`, 'gi')
|
|
254
254
|
|
|
255
255
|
/**
|
|
256
256
|
* Fenced code and known tags out. Shared so `proseSnippet` and `latestAssistantReply`
|
|
@@ -995,7 +995,7 @@ export async function peekCodexMeta(path: string): Promise<{ id: string; cwd: st
|
|
|
995
995
|
return { id, cwd, title, subagent, created }
|
|
996
996
|
}
|
|
997
997
|
|
|
998
|
-
async function peekCursorDiscussion(path: string): Promise<{ lastUser: string | null; latestAssistant: string }> {
|
|
998
|
+
export async function peekCursorDiscussion(path: string): Promise<{ lastUser: string | null; latestAssistant: string }> {
|
|
999
999
|
const text = await readWindow(path, true)
|
|
1000
1000
|
let lastUser: string | null = null
|
|
1001
1001
|
for (const line of text.split('\n')) {
|
|
@@ -1010,7 +1010,7 @@ async function peekCursorDiscussion(path: string): Promise<{ lastUser: string |
|
|
|
1010
1010
|
return { lastUser, latestAssistant: latestAssistantFromWindow(text) }
|
|
1011
1011
|
}
|
|
1012
1012
|
|
|
1013
|
-
async function firstCursorUserTitle(path: string): Promise<string | null> {
|
|
1013
|
+
export async function firstCursorUserTitle(path: string): Promise<string | null> {
|
|
1014
1014
|
const text = await readWindow(path, false)
|
|
1015
1015
|
let fallback: string | null = null
|
|
1016
1016
|
for (const line of text.split('\n')) {
|
package/server/lib/api-auth.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { RequestHandler } from 'express'
|
|
2
2
|
import { timingSafeTokenEqual } from './token-auth.js'
|
|
3
3
|
import { PAIRING_NONCE_BODY } from './glasses-pairing.js'
|
|
4
|
-
import { isWebSessionAuthorized } from './web-session-auth.js'
|
|
4
|
+
import { formatSetWebSessionCookie, isWebSessionAuthorized, renewWebSessionCookieValue, verifyWebSessionCookieValue, webSessionCookieFrom } from './web-session-auth.js'
|
|
5
5
|
|
|
6
6
|
// Recovery/setup clients need these availability surfaces before they have a
|
|
7
7
|
// usable token. Keep private provider state and every mutation route out.
|
|
@@ -44,9 +44,31 @@ const DISPLAY_STREAM_CAPABILITY_PATH = /^\/display-stream\/\d{1,15}\.[0-9a-f]{64
|
|
|
44
44
|
// Minting, status and decisions stay behind the token AND a loopback socket.
|
|
45
45
|
const PAIRING_POLL_PATH = new RegExp(`^/pairing/claim/${PAIRING_NONCE_BODY}$`)
|
|
46
46
|
|
|
47
|
+
/** Set on a response whose request renewed its browser sign-in: `<new expiry ISO> <30-day cap ISO>`. The page reads it. */
|
|
48
|
+
export const WEB_SESSION_RENEWED_HEADER = 'X-COS-Web-Session'
|
|
49
|
+
|
|
47
50
|
/** `res.locals` key the gate sets to `'token'` when the X-COS-Token header matched. */
|
|
48
51
|
export const API_AUTH_BY_LOCAL = 'cosApiAuthBy'
|
|
49
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Final review S1 (lead 2026-11-10 02:09): `res.locals` key the gate sets ONLY for a request it let in by the browser's
|
|
55
|
+
* sign-in cookie: a check that the same cookie still verifies now (its expiry, the epoch "Sign out every browser" bumps,
|
|
56
|
+
* the 30-day cap). A stream that outlives its opening request calls it on every heartbeat (`streamSignInHolds`).
|
|
57
|
+
*/
|
|
58
|
+
export const WEB_SESSION_RECHECK_LOCAL = 'cosWebSessionRecheck'
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* For a long-lived stream, on each heartbeat: false once the browser sign-in that opened it no longer verifies, so the
|
|
62
|
+
* stream tears down. A request the API token let in has no re-check and always holds, as does one that never passed
|
|
63
|
+
* the gate (a route mounted without it in a test). The browser reconnects with whatever cookie it holds now; a renewed
|
|
64
|
+
* one verifies there.
|
|
65
|
+
*/
|
|
66
|
+
export function streamSignInHolds(res: { locals?: Record<string, unknown> }): boolean {
|
|
67
|
+
const recheck = res.locals?.[WEB_SESSION_RECHECK_LOCAL]
|
|
68
|
+
if (typeof recheck !== 'function') return true
|
|
69
|
+
try { return recheck() === true } catch { return false }
|
|
70
|
+
}
|
|
71
|
+
|
|
50
72
|
export function isPublicApiRequest(method: string, path: string): boolean {
|
|
51
73
|
if (PUBLIC_API_PATHS.has(path)) return true
|
|
52
74
|
if (method === 'POST' && path === '/pairing/claim') return true
|
|
@@ -92,6 +114,20 @@ export function requireApiToken(apiToken: string, options: RequireApiTokenOption
|
|
|
92
114
|
allowedPorts: options.allowedPorts?.(),
|
|
93
115
|
})
|
|
94
116
|
if (cookieAuth) {
|
|
117
|
+
// Final review S1: the cookie this request came in with, checked again by any stream it opens (above).
|
|
118
|
+
const opened = webSessionCookieFrom(req.headers.cookie)
|
|
119
|
+
res.locals[WEB_SESSION_RECHECK_LOCAL] = (): boolean => verifyWebSessionCookieValue(opened, apiToken, Date.now())
|
|
120
|
+
// 7.0.x (Miles 2026-11-09, "Renew while in use, 30-day cap"): only here, after the socket, cookie, CSRF and Origin
|
|
121
|
+
// checks above all passed, a cookie in its last 2 days gets a fresh one, never past 30 days from its first sign-in.
|
|
122
|
+
// Appended, so a route's own Set-Cookie (none today on gated routes) is never overwritten. A renewal that cannot
|
|
123
|
+
// be made (outside the window, at the cap) changes nothing.
|
|
124
|
+
try {
|
|
125
|
+
const renewed = renewWebSessionCookieValue(webSessionCookieFrom(req.headers.cookie), apiToken)
|
|
126
|
+
if (renewed) {
|
|
127
|
+
res.append('Set-Cookie', formatSetWebSessionCookie(renewed.value, Math.floor((renewed.expiresAtMs - Date.now()) / 1000), req.secure))
|
|
128
|
+
res.setHeader(WEB_SESSION_RENEWED_HEADER, new Date(renewed.expiresAtMs).toISOString() + ' ' + new Date(renewed.capAtMs).toISOString())
|
|
129
|
+
}
|
|
130
|
+
} catch { /* a failed renewal leaves the request and the old cookie as they were */ }
|
|
95
131
|
return next()
|
|
96
132
|
}
|
|
97
133
|
return res.status(401).json({
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Today round 2 (2026-11-09): which calendar rows are distinct, real meetings. ONE helper for the calendar API
|
|
3
|
+
* (calendar-source.ts calendarToday: next, remainingCount, endedRecently) and the Today summary (today-brief.ts
|
|
4
|
+
* withCalendar), so the two never disagree.
|
|
5
|
+
*
|
|
6
|
+
* - Canceled: a title that starts "Canceled:" or "Cancelled:" (how Exchange and Google rename a canceled occurrence),
|
|
7
|
+
* or a row whose `status` says canceled, or `canceled: true` (the snapshot's EventKit status, when it carries one;
|
|
8
|
+
* the 11/09 snapshot had no status field on any of its 154 events).
|
|
9
|
+
* - Distinct: the same title (trimmed, case-folded, spaces collapsed) at the same start is one meeting, whichever
|
|
10
|
+
* calendar it is on. The 11/09 live snapshot had 13 such pairs or triples, 12 of them on ONE Exchange calendar with
|
|
11
|
+
* different external ids (the same invite delivered twice), so "across calendars" alone would have left 12 of them.
|
|
12
|
+
* The first row of each wins, so callers keep their own order.
|
|
13
|
+
* No dependencies: the summary imports it without the calendar reader.
|
|
14
|
+
*/
|
|
15
|
+
export const CANCELED_TITLE = /^\s*(?:canceled|cancelled)\s*:/i
|
|
16
|
+
const CANCELED_STATUS = new Set(['canceled', 'cancelled'])
|
|
17
|
+
|
|
18
|
+
export interface MeetingFields { title: unknown; start: unknown; status?: unknown; canceled?: unknown }
|
|
19
|
+
|
|
20
|
+
export function isCanceledMeeting(row: MeetingFields): boolean {
|
|
21
|
+
if (row.canceled === true) return true
|
|
22
|
+
if (typeof row.status === 'string' && CANCELED_STATUS.has(row.status.trim().toLowerCase())) return true
|
|
23
|
+
return typeof row.title === 'string' && CANCELED_TITLE.test(row.title)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The identity of one meeting: its title and its start instant. */
|
|
27
|
+
export function meetingKey(row: MeetingFields): string {
|
|
28
|
+
const title = typeof row.title === 'string' ? row.title.trim().toLowerCase().replace(/\s+/g, ' ') : ''
|
|
29
|
+
const start = typeof row.start === 'number' ? row.start : Date.parse(String(row.start ?? ''))
|
|
30
|
+
return `${title}|${Number.isFinite(start) ? start : String(row.start ?? '')}`
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The rows that are real meetings, one per meeting, in the order given. */
|
|
34
|
+
export function distinctMeetings<T>(rows: readonly T[], fields: (row: T) => MeetingFields): T[] {
|
|
35
|
+
const seen = new Set<string>()
|
|
36
|
+
const out: T[] = []
|
|
37
|
+
for (const row of rows) {
|
|
38
|
+
const f = fields(row)
|
|
39
|
+
if (isCanceledMeeting(f)) continue
|
|
40
|
+
const key = meetingKey(f)
|
|
41
|
+
if (seen.has(key)) continue
|
|
42
|
+
seen.add(key)
|
|
43
|
+
out.push(row)
|
|
44
|
+
}
|
|
45
|
+
return out
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Prep on every Next up row (Miles typed 2026-11-09 13:00 CST): a calendar hold or status block is not a meeting to
|
|
50
|
+
* prep for. ONE list for the server: /api/calendar/today marks each row `hold`, /api/welcome-context marks nextEvent
|
|
51
|
+
* `hold`, and Control Web and desktop Control read that flag instead of keeping their own copy of the words.
|
|
52
|
+
*
|
|
53
|
+
* A title is a hold when, after leading punctuation ("[Hold] ...", "* Busy"), it starts with one of these words or
|
|
54
|
+
* phrases as a whole word, and it does not name someone with "with" ("Lunch with Jeremy" and "Focus group with the
|
|
55
|
+
* Jewel360 board" are meetings). Case and repeated spaces do not matter. A false "not a hold" only offers Prep on a hold;
|
|
56
|
+
* a false "hold" would hide Prep on a real meeting, so the rule stays narrow.
|
|
57
|
+
*/
|
|
58
|
+
export const CALENDAR_HOLD_WORDS: readonly string[] = Object.freeze([
|
|
59
|
+
'blocked', 'block', 'busy', 'hold', 'holds', 'focus', 'travel', 'personal', 'private', 'ooo', 'out of office', 'lunch',
|
|
60
|
+
'pto', 'vacation', 'commute', 'dnd', 'do not book', 'do not schedule',
|
|
61
|
+
])
|
|
62
|
+
// Letters and single spaces only, so the words need no escaping.
|
|
63
|
+
const HOLD_START = new RegExp(`^(?:${CALENDAR_HOLD_WORDS.join('|')})(?![a-z0-9])`)
|
|
64
|
+
|
|
65
|
+
export function isCalendarHold(title: unknown): boolean {
|
|
66
|
+
if (typeof title !== 'string') return false
|
|
67
|
+
const t = title.trim().toLowerCase().replace(/\s+/g, ' ').replace(/^[^a-z0-9]+/, '')
|
|
68
|
+
if (!t || !HOLD_START.test(t)) return false
|
|
69
|
+
return !/(?:^|[^a-z0-9])with(?![a-z0-9])/.test(t)
|
|
70
|
+
}
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
import { lstatSync, readFileSync } from 'node:fs'
|
|
23
23
|
import { dataPath } from './data-dir.js'
|
|
24
24
|
import { callPython, pythonBridgeAvailable } from './python-bridge.js'
|
|
25
|
+
import { distinctMeetings, isCalendarHold, type MeetingFields } from './calendar-meetings.js'
|
|
25
26
|
|
|
26
27
|
export const SNAPSHOT_FRESH_MS = 2 * 3600_000
|
|
27
28
|
export const SNAPSHOT_MAX_AGE_MS = 24 * 3600_000
|
|
@@ -61,6 +62,8 @@ export interface SnapshotEvent {
|
|
|
61
62
|
attendees: SnapshotAttendee[]
|
|
62
63
|
/** Allowlisted hosts only (zoom.us, teams.microsoft.com, meet.google.com). */
|
|
63
64
|
meetingUrl: string | null
|
|
65
|
+
/** Today round 2: EventKit's status said canceled (only when the writer sends `status: "canceled"`). */
|
|
66
|
+
canceled?: boolean
|
|
64
67
|
}
|
|
65
68
|
|
|
66
69
|
export interface CalendarSnapshot {
|
|
@@ -159,6 +162,7 @@ export function parseSnapshot(raw: unknown): CalendarSnapshot | null {
|
|
|
159
162
|
isOrganizer: r.isOrganizer === true,
|
|
160
163
|
attendees,
|
|
161
164
|
meetingUrl: url,
|
|
165
|
+
...(typeof r.status === 'string' && /^cancel+ed$/i.test(r.status.trim()) ? { canceled: true } : {}),
|
|
162
166
|
})
|
|
163
167
|
}
|
|
164
168
|
return { version: 1, writtenAt, windowStart, windowEnd, access, calendars, events }
|
|
@@ -340,6 +344,7 @@ export function bridgeEvent(e: SnapshotEvent, calendar: SnapshotCalendar | undef
|
|
|
340
344
|
event_id: e.eventId,
|
|
341
345
|
external_id: e.externalId,
|
|
342
346
|
occurrence_start: e.occurrenceStart,
|
|
347
|
+
...(e.canceled ? { canceled: true } : {}),
|
|
343
348
|
}
|
|
344
349
|
}
|
|
345
350
|
|
|
@@ -454,32 +459,52 @@ export function countsAsMeeting(row: Record<string, unknown> | null | undefined)
|
|
|
454
459
|
}
|
|
455
460
|
|
|
456
461
|
/**
|
|
457
|
-
* Today for Control Web and desktop: selected calendars only, the next
|
|
458
|
-
* and non-prep events (declined, holidays, attendee-less shared events) are
|
|
459
|
-
* never use up the slots Today's "Later" shows. QA W23: remainingCount
|
|
460
|
-
* a family flight is not "remaining".
|
|
462
|
+
* Today for Control Web and desktop: selected calendars only, the next 5 timed meetings that count (Today round 2,
|
|
463
|
+
* Miles 2026-11-09: was 3). QA 7.0.0: all-day and non-prep events (declined, holidays, attendee-less shared events) are
|
|
464
|
+
* filtered out BEFORE the 5 are taken, so they never use up the slots Today's "Later" shows. QA W23: remainingCount
|
|
465
|
+
* counts the same rows, so a declined meeting or a family flight is not "remaining".
|
|
466
|
+
*
|
|
467
|
+
* Today round 2: `endedRecently` lists the meetings that count which ended within the last CALENDAR_ENDED_RECENT_MS
|
|
468
|
+
* (2 h), newest end first, at most CALENDAR_ENDED_MAX. They come from `today_events` (the snapshot's and the bridge's
|
|
469
|
+
* own list of every event today, past ones included), with the same end rule the snapshot uses (no end: an hour after
|
|
470
|
+
* the start). The Today summary uses it to know a meeting is over, so its prep is past. Additive: the older fields keep
|
|
471
|
+
* their names and meaning (desktop Control 0.5.384 reads next, remainingCount, stale and asOf).
|
|
472
|
+
*
|
|
473
|
+
* Today round 2 follow-up: canceled events and duplicates (the same title at the same start) are dropped BEFORE next,
|
|
474
|
+
* remainingCount and endedRecently are taken (calendar-meetings.ts, the helper the Today summary uses too), so
|
|
475
|
+
* remainingCount counts real, distinct meetings and no slot is spent twice on one.
|
|
461
476
|
*/
|
|
462
|
-
export
|
|
477
|
+
export const CALENDAR_NEXT_MAX = 5
|
|
478
|
+
export const CALENDAR_ENDED_RECENT_MS = 2 * 3600_000
|
|
479
|
+
export const CALENDAR_ENDED_MAX = 5
|
|
480
|
+
/** `hold` (Prep on every Next up row, 11/09): a hold or status block by its title (calendar-meetings.ts isCalendarHold). */
|
|
481
|
+
export interface CalendarTodayRow { title: string; start: string; end: string | null; allDay: boolean; calendar: string | null; meetingUrl: string | null; hold: boolean }
|
|
482
|
+
export interface CalendarEndedRow { title: string; start: string; end: string }
|
|
483
|
+
export interface CalendarTodayView {
|
|
463
484
|
connected: boolean
|
|
464
485
|
source: CalendarSourceName
|
|
465
486
|
asOf: string | null
|
|
466
487
|
stale: boolean
|
|
467
488
|
remainingCount: number
|
|
468
|
-
next:
|
|
469
|
-
|
|
489
|
+
next: CalendarTodayRow[]
|
|
490
|
+
endedRecently: CalendarEndedRow[]
|
|
491
|
+
}
|
|
492
|
+
export async function calendarToday(now = Date.now()): Promise<CalendarTodayView> {
|
|
470
493
|
const read = await readCalendar(8_000, now)
|
|
471
494
|
const data = read.data as Record<string, unknown> | null
|
|
472
495
|
const connected = !!data && typeof data === 'object' && !('error' in data) && (read.source !== 'bridge' || pythonBridgeAvailable())
|
|
473
496
|
const rows = connected && Array.isArray(data!.today_remaining) ? data!.today_remaining as Array<Record<string, unknown>> : []
|
|
474
|
-
const counted = rows.filter(countsAsMeeting)
|
|
475
|
-
const next = counted.slice(0,
|
|
497
|
+
const counted = distinctMeetings(rows.filter(countsAsMeeting), bridgeMeetingFields)
|
|
498
|
+
const next = counted.slice(0, CALENDAR_NEXT_MAX).map(r => ({
|
|
476
499
|
title: typeof r.title === 'string' ? r.title : 'Event',
|
|
477
500
|
start: String(r.start_time ?? ''),
|
|
478
501
|
end: typeof r.end_time === 'string' ? r.end_time : null,
|
|
479
502
|
allDay: r.is_all_day === true,
|
|
480
503
|
calendar: typeof r.calendar_name === 'string' ? r.calendar_name : null,
|
|
481
504
|
meetingUrl: typeof r.meeting_url === 'string' ? r.meeting_url : null,
|
|
505
|
+
hold: isCalendarHold(r.title),
|
|
482
506
|
}))
|
|
507
|
+
const all = connected && Array.isArray(data!.today_events) ? data!.today_events as Array<Record<string, unknown>> : []
|
|
483
508
|
return {
|
|
484
509
|
connected,
|
|
485
510
|
source: read.source,
|
|
@@ -487,5 +512,25 @@ export async function calendarToday(now = Date.now()): Promise<{
|
|
|
487
512
|
stale: data?.is_stale === true,
|
|
488
513
|
remainingCount: connected ? counted.length : 0,
|
|
489
514
|
next,
|
|
515
|
+
endedRecently: endedRecently(all, now),
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/** A bridge-shaped row's meeting identity, for calendar-meetings.ts. */
|
|
520
|
+
export function bridgeMeetingFields(r: Record<string, unknown>): MeetingFields {
|
|
521
|
+
return { title: r.title, start: r.start_time, status: r.status, canceled: r.canceled }
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/** Distinct, not canceled meetings that count and ended within CALENDAR_ENDED_RECENT_MS of `now`, newest end first. */
|
|
525
|
+
export function endedRecently(rows: Array<Record<string, unknown>>, now: number): CalendarEndedRow[] {
|
|
526
|
+
const out: Array<CalendarEndedRow & { endMs: number }> = []
|
|
527
|
+
for (const r of distinctMeetings(rows.filter(countsAsMeeting), bridgeMeetingFields)) {
|
|
528
|
+
const startMs = Date.parse(String(r.start_time ?? ''))
|
|
529
|
+
if (!Number.isFinite(startMs)) continue
|
|
530
|
+
const endRaw = typeof r.end_time === 'string' && r.end_time ? Date.parse(r.end_time) : NaN
|
|
531
|
+
const endMs = Number.isFinite(endRaw) ? endRaw : startMs + 3600_000
|
|
532
|
+
if (endMs > now || now - endMs > CALENDAR_ENDED_RECENT_MS) continue
|
|
533
|
+
out.push({ title: typeof r.title === 'string' ? r.title : 'Event', start: new Date(startMs).toISOString(), end: new Date(endMs).toISOString(), endMs })
|
|
490
534
|
}
|
|
535
|
+
return out.sort((a, b) => b.endMs - a.endMs).slice(0, CALENDAR_ENDED_MAX).map(({ endMs: _e, ...r }) => r)
|
|
491
536
|
}
|