@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
|
@@ -183,6 +183,8 @@ export interface MeetingDetail extends MeetingMeta {
|
|
|
183
183
|
export interface SaveMeetingInput {
|
|
184
184
|
sessionId: string
|
|
185
185
|
title?: string
|
|
186
|
+
/** 6.99.0: the name typed in COS Control while it recorded. Filed beside the title (header row + sidecar `titleSteer`) so the title model can read it. */
|
|
187
|
+
titleSteer?: string
|
|
186
188
|
domain?: string
|
|
187
189
|
transcript: string
|
|
188
190
|
startTime: number
|
|
@@ -339,6 +341,48 @@ const SIDECAR_HEAD_BYTES = 4096
|
|
|
339
341
|
* a matching prefix alone cannot prove a valid committed capture. */
|
|
340
342
|
const SAVE_RECEIPT_SIDECAR_MAX_BYTES = 32 * 1024 * 1024
|
|
341
343
|
|
|
344
|
+
/** 6.99.7: findBySessionId read and parsed every saved sidecar (813 files, 1.8 GB on Miles's Mac) on the main thread
|
|
345
|
+
* for any session not yet saved, and Control asks that of each live recording every few seconds. Two live recordings
|
|
346
|
+
* froze the server for 4 to 17 s at a time, so dictation's 5 s capability check timed out ("Server stopped").
|
|
347
|
+
* A sidecar's top-level sessionId is remembered by file stamp (inode, size, mtime): a lookup now stats files and
|
|
348
|
+
* fully reads only the one that matches. null means the sidecar has no sessionId, so it can never match. */
|
|
349
|
+
const sidecarSessions = new Map<string, { stamp: string; sessionId: string | null }>()
|
|
350
|
+
const SIDECAR_SESSION_CACHE_MAX = 50_000
|
|
351
|
+
|
|
352
|
+
function sidecarStamp(path: string): string | undefined {
|
|
353
|
+
try {
|
|
354
|
+
const st = lstatSync(path, { throwIfNoEntry: false })
|
|
355
|
+
return st?.isFile() ? `${st.ino}:${st.size}:${st.mtimeMs}` : undefined
|
|
356
|
+
} catch {
|
|
357
|
+
return undefined
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function knownSidecarSession(path: string, stamp: string): string | null | undefined {
|
|
362
|
+
const entry = sidecarSessions.get(path)
|
|
363
|
+
return entry && entry.stamp === stamp ? entry.sessionId : undefined
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function rememberSidecarSession(path: string, stamp: string, sessionId: string | null): void {
|
|
367
|
+
if (sidecarSessions.size >= SIDECAR_SESSION_CACHE_MAX && !sidecarSessions.has(path)) sidecarSessions.clear()
|
|
368
|
+
sidecarSessions.set(path, { stamp, sessionId })
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** The top-level sessionId from a sidecar's head, only when nothing but scalar members precede it (so it cannot be a
|
|
372
|
+
* nested object's sessionId). Every saved sidecar starts `{"schemaVersion": N, "sessionId": ...`; a Mac capture puts
|
|
373
|
+
* its captureSource object first, so its head is undecided (undefined) and it is read in full once, then remembered. */
|
|
374
|
+
const SIDECAR_HEAD_SESSION = /^\s*\{(?:\s*"[A-Za-z0-9_]+"\s*:\s*(?:-?[0-9][0-9.eE+-]*|true|false|null|"(?:[^"\\]|\\.)*")\s*,)*\s*"sessionId"\s*:\s*"([A-Za-z0-9:_-]{3,96})"/
|
|
375
|
+
|
|
376
|
+
export function sidecarHeadSessionId(head: string | null): string | undefined {
|
|
377
|
+
if (!head) return undefined
|
|
378
|
+
return SIDECAR_HEAD_SESSION.exec(head)?.[1]
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Tests only. */
|
|
382
|
+
export function clearSidecarSessionCache(): void {
|
|
383
|
+
sidecarSessions.clear()
|
|
384
|
+
}
|
|
385
|
+
|
|
342
386
|
export class MeetingStore {
|
|
343
387
|
readonly root: string
|
|
344
388
|
|
|
@@ -362,6 +406,10 @@ export class MeetingStore {
|
|
|
362
406
|
const parts = localParts(input.startTime)
|
|
363
407
|
const fallbackTitle = `${mac ? 'Mac' : phone ? 'iPhone' : 'G2'} Recording ${parts.date} ${parts.hourMinute}`
|
|
364
408
|
const title = normalizeTitle(input.title, fallbackTitle)
|
|
409
|
+
// Kept faithful (slashes stay): it was normalized when typed. This only makes a hand-built input safe to file.
|
|
410
|
+
const titleSteerText = (input.titleSteer ?? '').normalize('NFKC').replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim()
|
|
411
|
+
// By code points, like the ledger: a UTF-16 slice could cut an emoji in half.
|
|
412
|
+
const titleSteer = Array.from(titleSteerText).slice(0, 120).join('')
|
|
365
413
|
const stem = filenameStem(title, `${mac ? 'Mac' : phone ? 'iPhone' : 'G2'}_Recording_${parts.hourMinute}`)
|
|
366
414
|
const durationMs = Number.isFinite(input.durationMs) ? Math.max(0, input.durationMs) : 0
|
|
367
415
|
const durationMin = Math.round(durationMs / 60_000)
|
|
@@ -405,6 +453,7 @@ export class MeetingStore {
|
|
|
405
453
|
...(phone ? ['| **Capture source** | iphone_microphone |', '| **Speaker policy** | anonymous |'] : []),
|
|
406
454
|
...(mac ? ['| **Capture source** | mac_system_audio |', '| **Captured voices** | Other participants only |'] : []),
|
|
407
455
|
`| **Domain** | ${escapeTableValue(domain)} |`,
|
|
456
|
+
...(titleSteer ? [`| **Title steer** | ${escapeTableValue(titleSteer)} |`] : []),
|
|
408
457
|
`| **Transfer integrity** | ${integrityValue} |`,
|
|
409
458
|
'| **Transcription quality** | streaming |',
|
|
410
459
|
'',
|
|
@@ -436,6 +485,7 @@ export class MeetingStore {
|
|
|
436
485
|
durationMs,
|
|
437
486
|
domain,
|
|
438
487
|
title,
|
|
488
|
+
...(titleSteer ? { titleSteer } : {}),
|
|
439
489
|
canonicalProvider: canonicalProvider(input.chunks),
|
|
440
490
|
providers,
|
|
441
491
|
providerCandidates: input.providerCandidates ?? {},
|
|
@@ -496,10 +546,21 @@ export class MeetingStore {
|
|
|
496
546
|
.sort()
|
|
497
547
|
.reverse()
|
|
498
548
|
for (const sidecarName of sidecars) {
|
|
549
|
+
// 6.99.7: skip a sidecar whose top-level sessionId is already known to differ, from the stamp cache or the
|
|
550
|
+
// file's head, before reading and parsing all of it. Unknown falls through to the full read, as before.
|
|
551
|
+
const sidecarPath = join(monthDir, sidecarName)
|
|
552
|
+
const stamp = sidecarStamp(sidecarPath)
|
|
553
|
+
let known = stamp ? knownSidecarSession(sidecarPath, stamp) : undefined
|
|
554
|
+
if (known === undefined) {
|
|
555
|
+
known = sidecarHeadSessionId(safeReadFileHead(monthDir, monthReal, sidecarName, SIDECAR_HEAD_BYTES))
|
|
556
|
+
if (known !== undefined && stamp) rememberSidecarSession(sidecarPath, stamp, known)
|
|
557
|
+
}
|
|
558
|
+
if (known !== undefined && known !== sessionId) continue
|
|
499
559
|
const sidecarText = safeReadFile(monthDir, monthReal, sidecarName, SAVE_RECEIPT_SIDECAR_MAX_BYTES)
|
|
500
560
|
if (sidecarText === null) continue
|
|
501
561
|
try {
|
|
502
562
|
const sidecar = JSON.parse(sidecarText) as Record<string, unknown>
|
|
563
|
+
if (stamp) rememberSidecarSession(sidecarPath, stamp, typeof sidecar.sessionId === 'string' ? sidecar.sessionId : null)
|
|
503
564
|
if (sidecar.sessionId !== sessionId) continue
|
|
504
565
|
if (sessionId.startsWith('phone_')) {
|
|
505
566
|
const capture = sidecar.captureSource as Record<string, unknown> | undefined
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// Title steer (6.99.0): a name the person types for a meeting WHILE it is recording.
|
|
2
|
+
//
|
|
3
|
+
// WHY. A live Mac recording or glasses meeting is listed as "Mac recording 10:39 PM" until the title model names it at
|
|
4
|
+
// save. Miles wants to rename it live in COS Control. The typed name is a STEER, not the final title: it shows
|
|
5
|
+
// everywhere at once, and at save the title model receives it as a strong hint (the pipeline keeps his names, companies
|
|
6
|
+
// and topic and may sharpen the wording). When a save carries no title of its own the steer becomes the title.
|
|
7
|
+
//
|
|
8
|
+
// WHAT THIS MODULE IS. One small durable ledger: sessionId -> { title, revision, updatedAt }. The shape is the one
|
|
9
|
+
// meeting-ask's context store uses: a per-session revision, and a write that names the revision it read
|
|
10
|
+
// (`expectedRevision`) so two clients editing never silently overwrite each other (409 `title_changed`).
|
|
11
|
+
//
|
|
12
|
+
// WHAT IT IS NOT. It never decides whether a session is live (the route does, from the session map), never touches the
|
|
13
|
+
// transcript, and never logs the title: titles carry names and companies. Callers log a LENGTH only.
|
|
14
|
+
|
|
15
|
+
import { existsSync, mkdirSync, readFileSync, renameSync } from 'node:fs'
|
|
16
|
+
import { dirname } from 'node:path'
|
|
17
|
+
import { durableAtomicWriteFileSync } from './atomic-fs.js'
|
|
18
|
+
import { dataPath } from './data-dir.js'
|
|
19
|
+
|
|
20
|
+
export const TITLE_STEER_FILE_NAME = 'meeting-title-steer.json'
|
|
21
|
+
/** Longest steer, in characters (code points) after normalization. */
|
|
22
|
+
export const TITLE_STEER_MAX_CHARS = 120
|
|
23
|
+
/** An entry untouched this long is dropped. A meeting is saved or abandoned well inside it. */
|
|
24
|
+
export const TITLE_STEER_RETAIN_MS = 7 * 24 * 60 * 60_000
|
|
25
|
+
const SESSION_ID = /^[A-Za-z0-9:_-]{3,96}$/
|
|
26
|
+
|
|
27
|
+
export class TitleSteerError extends Error {
|
|
28
|
+
constructor(readonly reason: 'invalid_session_id' | 'invalid_title' | 'invalid_revision' | 'title_changed' | 'session_not_live' | 'save_in_progress' | 'steer_persistence_failed', readonly status: number) {
|
|
29
|
+
super(reason)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** What GET and PUT answer, and what a client holds: the title (null = none), the revision to send back, when it changed. */
|
|
34
|
+
export interface TitleSteerView {
|
|
35
|
+
sessionId: string
|
|
36
|
+
title: string | null
|
|
37
|
+
revision: number
|
|
38
|
+
updatedAt: string | null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface TitleSteerEntry { title: string | null; revision: number; updatedAt: number }
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The advertised capability flag. `1` is the protocol version of this contract; an older server omits the key, which is
|
|
45
|
+
* how a client knows not to show the rename control.
|
|
46
|
+
*/
|
|
47
|
+
export const TITLE_STEER_CAPABILITY = 1 as const
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* NFKC, control characters to spaces, invisible direction/zero-width marks dropped, whitespace collapsed, trimmed.
|
|
51
|
+
* Returns '' for "no title". Throws invalid_title for a non-string, or one still over the cap after trimming.
|
|
52
|
+
* Characters, not UTF-16 units: an emoji counts once.
|
|
53
|
+
*/
|
|
54
|
+
export function normalizeTitleSteer(raw: unknown): string {
|
|
55
|
+
if (raw === null || raw === undefined) return ''
|
|
56
|
+
if (typeof raw !== 'string') throw new TitleSteerError('invalid_title', 400)
|
|
57
|
+
const text = raw
|
|
58
|
+
.normalize('NFKC')
|
|
59
|
+
.replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ')
|
|
60
|
+
.replace(/[\u200b-\u200f\u202a-\u202e\u2060-\u2069\ufeff]/g, '')
|
|
61
|
+
.replace(/\s+/g, ' ')
|
|
62
|
+
.trim()
|
|
63
|
+
if (Array.from(text).length > TITLE_STEER_MAX_CHARS) throw new TitleSteerError('invalid_title', 400)
|
|
64
|
+
return text
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
68
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function readEntries(path: string): Record<string, TitleSteerEntry> {
|
|
72
|
+
if (!existsSync(path)) return Object.create(null)
|
|
73
|
+
try {
|
|
74
|
+
const parsed = JSON.parse(readFileSync(path, 'utf-8')) as unknown
|
|
75
|
+
if (!isRecord(parsed) || !isRecord(parsed.entries)) return Object.create(null)
|
|
76
|
+
// Null prototype: a session id like `constructor` or `__proto__` fits the id pattern and must read as absent, not as Object's own members.
|
|
77
|
+
const out: Record<string, TitleSteerEntry> = Object.create(null)
|
|
78
|
+
for (const [id, raw] of Object.entries(parsed.entries)) {
|
|
79
|
+
if (!SESSION_ID.test(id) || !isRecord(raw)) continue
|
|
80
|
+
const { title, revision, updatedAt } = raw
|
|
81
|
+
if (!Number.isSafeInteger(revision) || (revision as number) < 1 || !Number.isFinite(updatedAt)) continue
|
|
82
|
+
if (title !== null && typeof title !== 'string') continue
|
|
83
|
+
// A hand-edited or older file must not smuggle in what a PUT would refuse.
|
|
84
|
+
let clean: string | null = null
|
|
85
|
+
if (typeof title === 'string') { try { clean = normalizeTitleSteer(title) || null } catch { continue } }
|
|
86
|
+
out[id] = { title: clean, revision: revision as number, updatedAt: updatedAt as number }
|
|
87
|
+
}
|
|
88
|
+
return out
|
|
89
|
+
} catch {
|
|
90
|
+
// A corrupt ledger is set aside, never silently trusted or deleted.
|
|
91
|
+
try { renameSync(path, `${path}.corrupt.${Date.now()}`) } catch { /* the next write replaces it */ }
|
|
92
|
+
return Object.create(null)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface TitleSteerStore {
|
|
97
|
+
readonly path: string
|
|
98
|
+
/** The current steer, or { title: null, revision: 0, updatedAt: null } when none was ever set. */
|
|
99
|
+
get(sessionId: string): TitleSteerView
|
|
100
|
+
/** Just the text a save may use (null when none or cleared). Never throws, whatever the id. */
|
|
101
|
+
title(sessionId: string): string | null
|
|
102
|
+
/**
|
|
103
|
+
* Set (a non-empty title) or clear ('' or null) the steer. `expectedRevision` must equal the current revision (0 when
|
|
104
|
+
* none), else 409 title_changed. Clearing where nothing was ever set is a no-op that keeps revision 0. Persisted
|
|
105
|
+
* before it returns; a failed write changes nothing.
|
|
106
|
+
*/
|
|
107
|
+
set(sessionId: string, rawTitle: unknown, expectedRevision: unknown): TitleSteerView
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function createTitleSteerStore(options: { path?: string; now?: () => number } = {}): TitleSteerStore {
|
|
111
|
+
const path = options.path ?? dataPath(TITLE_STEER_FILE_NAME)
|
|
112
|
+
const now = options.now ?? Date.now
|
|
113
|
+
let cache: Record<string, TitleSteerEntry> | null = null
|
|
114
|
+
|
|
115
|
+
const load = (): Record<string, TitleSteerEntry> => {
|
|
116
|
+
if (!cache) cache = readEntries(path)
|
|
117
|
+
return cache
|
|
118
|
+
}
|
|
119
|
+
const prune = (entries: Record<string, TitleSteerEntry>, at: number) => {
|
|
120
|
+
for (const [id, entry] of Object.entries(entries)) if (at - entry.updatedAt > TITLE_STEER_RETAIN_MS) delete entries[id]
|
|
121
|
+
}
|
|
122
|
+
const view = (sessionId: string, entry: TitleSteerEntry | undefined): TitleSteerView => entry
|
|
123
|
+
? { sessionId, title: entry.title, revision: entry.revision, updatedAt: new Date(entry.updatedAt).toISOString() }
|
|
124
|
+
: { sessionId, title: null, revision: 0, updatedAt: null }
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
path,
|
|
128
|
+
get(sessionId) {
|
|
129
|
+
if (!SESSION_ID.test(sessionId)) throw new TitleSteerError('invalid_session_id', 400)
|
|
130
|
+
const entries = load()
|
|
131
|
+
prune(entries, now())
|
|
132
|
+
return view(sessionId, entries[sessionId])
|
|
133
|
+
},
|
|
134
|
+
title(sessionId) {
|
|
135
|
+
const entries = load()
|
|
136
|
+
prune(entries, now())
|
|
137
|
+
// No id check: the ledger has a null prototype and its keys are all valid ids, so an odd id simply is not there.
|
|
138
|
+
return entries[sessionId]?.title ?? null
|
|
139
|
+
},
|
|
140
|
+
set(sessionId, rawTitle, expectedRevision) {
|
|
141
|
+
if (!SESSION_ID.test(sessionId)) throw new TitleSteerError('invalid_session_id', 400)
|
|
142
|
+
if (!Number.isSafeInteger(expectedRevision) || (expectedRevision as number) < 0) throw new TitleSteerError('invalid_revision', 400)
|
|
143
|
+
const title = normalizeTitleSteer(rawTitle)
|
|
144
|
+
const at = now()
|
|
145
|
+
const entries = load()
|
|
146
|
+
prune(entries, at)
|
|
147
|
+
const current = entries[sessionId]
|
|
148
|
+
if ((current?.revision ?? 0) !== expectedRevision) throw new TitleSteerError('title_changed', 409)
|
|
149
|
+
if (!current && !title) return view(sessionId, undefined)
|
|
150
|
+
const next: TitleSteerEntry = { title: title || null, revision: (current?.revision ?? 0) + 1, updatedAt: at }
|
|
151
|
+
// Persist a copy first: a failed write must leave the cache, and so the revision, where it was.
|
|
152
|
+
const draft: Record<string, TitleSteerEntry> = Object.assign(Object.create(null), entries)
|
|
153
|
+
draft[sessionId] = next
|
|
154
|
+
try {
|
|
155
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 })
|
|
156
|
+
durableAtomicWriteFileSync(path, JSON.stringify({ schemaVersion: 1, entries: draft }, null, 2), { mode: 0o600 })
|
|
157
|
+
} catch {
|
|
158
|
+
throw new TitleSteerError('steer_persistence_failed', 503)
|
|
159
|
+
}
|
|
160
|
+
entries[sessionId] = next
|
|
161
|
+
return view(sessionId, next)
|
|
162
|
+
},
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
let defaultStore: TitleSteerStore | null = null
|
|
167
|
+
/** The process-wide store, created on first use (never at import: tests import this module). */
|
|
168
|
+
export function getTitleSteerStore(): TitleSteerStore {
|
|
169
|
+
if (!defaultStore) defaultStore = createTitleSteerStore()
|
|
170
|
+
return defaultStore
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// A title COS Control or the store invents when nobody named the meeting: "Mac recording 9:05 PM", "Zoom meeting 10:01 AM",
|
|
174
|
+
// "Glasses meeting 10:10 PM", "iPhone microphone 4:30 PM", and the store's own "Mac Recording 2026-11-03 2105" / "G2 Recording ...".
|
|
175
|
+
// Control 0.5.365 sends one on every Mac save, so without this a steer could never become the title there. Anchored at both
|
|
176
|
+
// ends on purpose: a real title that merely STARTS like one ("Mac recording pipeline review") must win.
|
|
177
|
+
const CLOCK = String.raw`\d{1,2}[:_]\d{2}(?:[:_]\d{2})?(?:[ _-]?(?:AM|PM))?`
|
|
178
|
+
const STAMP = String.raw`(?:\d{4}-\d{2}-\d{2}(?:[ _-]+\d{4})?|\d{4})`
|
|
179
|
+
const PLACEHOLDER_TITLES: RegExp[] = [
|
|
180
|
+
new RegExp(String.raw`^(?:Mac|G2|iPhone)[ _-]+Recording(?:[ _-]+(?:${CLOCK}|${STAMP}))?$`, 'i'),
|
|
181
|
+
new RegExp(String.raw`^Glasses[ _-]+meeting(?:[ _-]+${CLOCK})?$`, 'i'),
|
|
182
|
+
new RegExp(String.raw`^iPhone[ _-]+microphone(?:[ _-]+${CLOCK})?$`, 'i'),
|
|
183
|
+
// "<App> meeting 10:01 AM": the app's name, then a 12-hour clock (a macOS clock may use a narrow no-break space, which the `\s` collapse turns into a space). The AM/PM is required here so "Q3 meeting 10:30" stays a title.
|
|
184
|
+
new RegExp(String.raw`^[\w .-]{1,40}?[ _-]meeting[ _-]+\d{1,2}[:_]\d{2}[ _-]?(?:AM|PM)$`, 'i'),
|
|
185
|
+
]
|
|
186
|
+
export function isPlaceholderTitle(title: string): boolean {
|
|
187
|
+
const text = title.replace(/\s+/g, ' ').trim()
|
|
188
|
+
return PLACEHOLDER_TITLES.some(pattern => pattern.test(text))
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* The title a save files under. A title the request sent wins, untouched, unless it is a placeholder and a steer exists: then
|
|
193
|
+
* the steer names the meeting (a placeholder with no steer is kept, as before). A blank or non-string title is none.
|
|
194
|
+
* Undefined when neither exists, so the store files its own fallback.
|
|
195
|
+
*/
|
|
196
|
+
export function effectiveSaveTitle(sentTitle: unknown, steer: string | null): string | undefined {
|
|
197
|
+
const sent = typeof sentTitle === 'string' && sentTitle.trim() ? sentTitle : null
|
|
198
|
+
if (sent !== null && !(steer !== null && isPlaceholderTitle(sent))) return sent
|
|
199
|
+
return steer ?? undefined
|
|
200
|
+
}
|
|
@@ -19,10 +19,16 @@ export function isTailscaleIpv4(value: string): boolean {
|
|
|
19
19
|
return !!octets && octets[0] === 100 && octets[1] >= 64 && octets[1] <= 127
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/** Tailscale IPv6 ULA range: fd7a:115c:a1e0::/48. */
|
|
23
|
+
export function isTailscaleIpv6(value: string): boolean {
|
|
24
|
+
const clean = normalizeRemoteIp(value).trim().toLowerCase()
|
|
25
|
+
return clean.startsWith('fd7a:115c:a1e0:')
|
|
26
|
+
}
|
|
27
|
+
|
|
22
28
|
export function isAllowedNetworkIp(value: string): boolean {
|
|
23
29
|
const clean = normalizeRemoteIp(value)
|
|
24
30
|
if (clean === '127.0.0.1' || clean === '::1') return true
|
|
25
|
-
if (isTailscaleIpv4(clean)) return true
|
|
31
|
+
if (isTailscaleIpv4(clean) || isTailscaleIpv6(clean)) return true
|
|
26
32
|
|
|
27
33
|
const octets = parseIpv4(clean)
|
|
28
34
|
if (!octets) return false
|
|
@@ -31,11 +37,51 @@ export function isAllowedNetworkIp(value: string): boolean {
|
|
|
31
37
|
(octets[0] === 192 && octets[1] === 168)
|
|
32
38
|
}
|
|
33
39
|
|
|
34
|
-
|
|
40
|
+
/**
|
|
41
|
+
* Normalize a Tailscale MagicDNS hostname (`Self.DNSName` from `tailscale status --json`).
|
|
42
|
+
* Strips one trailing dot, lowercases, and requires a strict `<node>.<tailnet>.ts.net` shape.
|
|
43
|
+
*/
|
|
44
|
+
export function normalizeTailscaleDnsName(value: string | undefined | null): string | null {
|
|
45
|
+
if (typeof value !== 'string') return null
|
|
46
|
+
const trimmed = value.trim().toLowerCase().replace(/\.$/, '')
|
|
47
|
+
if (!trimmed || trimmed.length > 253) return null
|
|
48
|
+
if (!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+\.ts\.net$/.test(trimmed)) {
|
|
49
|
+
return null
|
|
50
|
+
}
|
|
51
|
+
return trimmed
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface AllowedOriginOptions {
|
|
55
|
+
/** The Mac's own verified `Self.DNSName` from `tailscale status --json`. Never a suffix wildcard. */
|
|
56
|
+
selfDnsName?: string | null
|
|
57
|
+
/**
|
|
58
|
+
* When set, any explicit port in `origin` must match one of these numbers (e.g. `[3141, 3143]`).
|
|
59
|
+
* Prevents another server on the same host (`localhost:8080`, `<mac>.ts.net:8080`) from passing
|
|
60
|
+
* CORS or riding a host-scoped cookie session.
|
|
61
|
+
*/
|
|
62
|
+
allowedPorts?: readonly number[]
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function isAllowedNetworkOrigin(origin: string, options: AllowedOriginOptions = {}): boolean {
|
|
35
66
|
try {
|
|
36
67
|
const url = new URL(origin)
|
|
37
68
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') return false
|
|
38
|
-
|
|
69
|
+
const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, '')
|
|
70
|
+
const selfDns = normalizeTailscaleDnsName(options.selfDnsName)
|
|
71
|
+
const hostAllowed = host === 'localhost'
|
|
72
|
+
|| isAllowedNetworkIp(host)
|
|
73
|
+
|| (selfDns !== null && host === selfDns)
|
|
74
|
+
if (!hostAllowed) return false
|
|
75
|
+
|
|
76
|
+
if (options.allowedPorts && options.allowedPorts.length > 0) {
|
|
77
|
+
const effectivePort = url.port
|
|
78
|
+
? Number(url.port)
|
|
79
|
+
: (url.protocol === 'https:' ? 443 : 80)
|
|
80
|
+
if (!Number.isInteger(effectivePort) || !options.allowedPorts.includes(effectivePort)) {
|
|
81
|
+
return false
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return true
|
|
39
85
|
} catch {
|
|
40
86
|
return false
|
|
41
87
|
}
|
|
@@ -57,11 +103,43 @@ export function isLoopbackAddress(value: string | undefined | null): boolean {
|
|
|
57
103
|
|| address.startsWith('127.') || address.startsWith('::ffff:127.')
|
|
58
104
|
}
|
|
59
105
|
|
|
106
|
+
const PROXY_FORWARDED_HEADERS = [
|
|
107
|
+
'forwarded',
|
|
108
|
+
'x-forwarded-for',
|
|
109
|
+
'x-forwarded-host',
|
|
110
|
+
'x-forwarded-proto',
|
|
111
|
+
'x-real-ip',
|
|
112
|
+
'tailscale-user-login',
|
|
113
|
+
'tailscale-user-name',
|
|
114
|
+
'tailscale-user-profile-pic',
|
|
115
|
+
'tailscale-headers-info',
|
|
116
|
+
] as const
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* True if a request carries reverse-proxy or `tailscale serve` headers.
|
|
120
|
+
* A local proxy (like `tailscale serve --bg 3141`) connects over `127.0.0.1`, so
|
|
121
|
+
* `isLoopbackSocket` must refuse any request carrying proxy headers to prevent remote
|
|
122
|
+
* tailnet peers from masquerading as the Mac owner on `loopbackOnly` / `macOnly` routes.
|
|
123
|
+
*/
|
|
124
|
+
export function hasProxyForwardedHeaders(headers: Record<string, unknown> | undefined | null): boolean {
|
|
125
|
+
if (!headers || typeof headers !== 'object') return false
|
|
126
|
+
for (const name of PROXY_FORWARDED_HEADERS) {
|
|
127
|
+
const val = headers[name]
|
|
128
|
+
if (typeof val === 'string' && val.trim().length > 0) return true
|
|
129
|
+
if (Array.isArray(val) && val.length > 0) return true
|
|
130
|
+
}
|
|
131
|
+
return false
|
|
132
|
+
}
|
|
133
|
+
|
|
60
134
|
/**
|
|
61
|
-
* Loopback check on the SOCKET, never `req.ip` or `X-Forwarded-For
|
|
62
|
-
*
|
|
135
|
+
* Loopback check on the SOCKET, never `req.ip` or `X-Forwarded-For` — and false if any
|
|
136
|
+
* reverse-proxy or `tailscale serve` header is present.
|
|
63
137
|
*/
|
|
64
|
-
export function isLoopbackSocket(req: {
|
|
138
|
+
export function isLoopbackSocket(req: {
|
|
139
|
+
socket?: { remoteAddress?: string | undefined } | null
|
|
140
|
+
headers?: Record<string, unknown> | null
|
|
141
|
+
}): boolean {
|
|
142
|
+
if (hasProxyForwardedHeaders(req.headers)) return false
|
|
65
143
|
return isLoopbackAddress(req.socket?.remoteAddress)
|
|
66
144
|
}
|
|
67
145
|
|
|
@@ -234,6 +234,11 @@ export const QUESTIONS_POLL_INTERVAL_MS = 10_000
|
|
|
234
234
|
export const POLL_FUTURE_SKEW_MS = 5_000
|
|
235
235
|
/** The `client` values of a questions poll that count as a live answerer. */
|
|
236
236
|
export const ANSWERING_CLIENTS: ReadonlySet<string> = new Set(['glasses', 'phone'])
|
|
237
|
+
/**
|
|
238
|
+
* 6.99.12: the one `client` value a COS Control Web poll (browser session cookie, never the token) counts under,
|
|
239
|
+
* sent only while the page shows the cards. Kept apart from ANSWERING_CLIENTS: a token poll naming it never counts.
|
|
240
|
+
*/
|
|
241
|
+
export const WEB_ANSWERING_CLIENT = 'web'
|
|
237
242
|
/** The questions API's own version, advertised at /api/models. */
|
|
238
243
|
export const SESSION_QUESTIONS_PROTOCOL_VERSION = 1
|
|
239
244
|
/** How often a parked item re-reads the desk. */
|
|
@@ -1781,6 +1786,11 @@ export interface SessionQuestionsCapability {
|
|
|
1781
1786
|
* ms (`COS_PERMISSION_BROKER_AWAY_RECENT_CLIENT_S`); 0 means it must be polling right now.
|
|
1782
1787
|
*/
|
|
1783
1788
|
awayRecentClientMs: number
|
|
1789
|
+
/**
|
|
1790
|
+
* 6.99.12: the questions route takes the COS Control Web session too, and a poll with `client=web` counts as a
|
|
1791
|
+
* live answering client. A page reads this before it calls the route (an older server answers a cookie 401).
|
|
1792
|
+
*/
|
|
1793
|
+
webClient: true
|
|
1784
1794
|
}
|
|
1785
1795
|
|
|
1786
1796
|
/**
|
|
@@ -1803,5 +1813,6 @@ export function sessionQuestionsCapability(): SessionQuestionsCapability {
|
|
|
1803
1813
|
liveWindowMs: CLIENT_LIVE_WINDOW_MS,
|
|
1804
1814
|
awayHoldMs,
|
|
1805
1815
|
awayRecentClientMs,
|
|
1816
|
+
webClient: true,
|
|
1806
1817
|
}
|
|
1807
1818
|
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drop it P2 (plan PLAN_drop_it_2026-11-04 section 5, decided in section 10): which pings may interrupt.
|
|
3
|
+
*
|
|
4
|
+
* Every ping lands silently in the pinned COS chat. This module only decides which of them also get a banner
|
|
5
|
+
* (a macOS notification, and for reminders a lens push). Pure: the ledger of past banners, the clock and the
|
|
6
|
+
* zone are inputs, so canary 6 can drive a fixture week on a fake clock.
|
|
7
|
+
*
|
|
8
|
+
* The budget (Miles, 2026-11-04 15:08, "go with your picks"):
|
|
9
|
+
* - 6 banners a weekday, at most 2 per source, at least 45 minutes apart.
|
|
10
|
+
* - Reminders he set are exempt: they banner at any hour and do not count against the budget.
|
|
11
|
+
* - Quiet hours follow Control's rule (C Models.swift:14394-14399): after 6 PM, before 6 AM, and weekends.
|
|
12
|
+
* No other banner then. Items held over quiet hours post at 6:00 as one "While you were off" item, and the
|
|
13
|
+
* first banner of a weekday is allowed at 8:00.
|
|
14
|
+
* - On by default for reminders and his own drops' resolutions; other sources are off until he turns them on
|
|
15
|
+
* (the first-week review of the tap-through rate).
|
|
16
|
+
* - Several due at once merge into one "N updates" banner, which counts once.
|
|
17
|
+
*/
|
|
18
|
+
import { localClock } from './morning-brief-schedule.js'
|
|
19
|
+
|
|
20
|
+
export const PING_SOURCES = ['reminder', 'resolution', 'prep', 'agent', 'followups', 'stale', 'checkin'] as const
|
|
21
|
+
export type PingSource = typeof PING_SOURCES[number]
|
|
22
|
+
/** Ranking (plan section 5): reminder, his own resolution, prep, agent finished, follow-ups, stale or red, check-in. */
|
|
23
|
+
export const PING_RANK: Record<PingSource, number> = { reminder: 0, resolution: 1, prep: 2, agent: 3, followups: 4, stale: 5, checkin: 6 }
|
|
24
|
+
|
|
25
|
+
export const PING_BUDGET = {
|
|
26
|
+
perWeekday: 6,
|
|
27
|
+
perSource: 2,
|
|
28
|
+
gapMs: 45 * 60_000,
|
|
29
|
+
/** Control's quiet hours: a weekday from 6:00 to 18:00 is work time. */
|
|
30
|
+
dayStartMinutes: 6 * 60,
|
|
31
|
+
dayEndMinutes: 18 * 60,
|
|
32
|
+
/** The first non-reminder banner of a weekday waits until 8:00. */
|
|
33
|
+
firstBannerMinutes: 8 * 60,
|
|
34
|
+
} as const
|
|
35
|
+
|
|
36
|
+
/** The budget in the units the code uses. 6.99.11 settings (drop-p2-settings.ts budgetFrom) fill it; these are the defaults. */
|
|
37
|
+
export interface BudgetOptions {
|
|
38
|
+
perWeekday: number
|
|
39
|
+
perSource: number
|
|
40
|
+
gapMs: number
|
|
41
|
+
/** Quiet from start to end, minutes past midnight; start after end wraps midnight; equal means no quiet hours. */
|
|
42
|
+
quietStartMinutes: number
|
|
43
|
+
quietEndMinutes: number
|
|
44
|
+
weekendsQuiet: boolean
|
|
45
|
+
firstBannerMinutes: number
|
|
46
|
+
}
|
|
47
|
+
export const DEFAULT_BUDGET: BudgetOptions = {
|
|
48
|
+
perWeekday: PING_BUDGET.perWeekday, perSource: PING_BUDGET.perSource, gapMs: PING_BUDGET.gapMs,
|
|
49
|
+
quietStartMinutes: PING_BUDGET.dayEndMinutes, quietEndMinutes: PING_BUDGET.dayStartMinutes, weekendsQuiet: true,
|
|
50
|
+
firstBannerMinutes: PING_BUDGET.firstBannerMinutes,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Decided 2026-11-04: banners on for reminders and his own drops' resolutions; the rest off for the first week. */
|
|
54
|
+
export const DEFAULT_BANNER_SOURCES: readonly PingSource[] = ['reminder', 'resolution']
|
|
55
|
+
|
|
56
|
+
export interface BannerRecord {
|
|
57
|
+
/** Epoch ms the banner was shown. */
|
|
58
|
+
at: number
|
|
59
|
+
/** Every source the banner carried (a merged banner carries several). */
|
|
60
|
+
sources: PingSource[]
|
|
61
|
+
/** The feed item ids it announced. */
|
|
62
|
+
ids: string[]
|
|
63
|
+
}
|
|
64
|
+
export interface BannerCandidate { id: string; source: PingSource; createdAt: number }
|
|
65
|
+
export interface BannerPlan {
|
|
66
|
+
/** Banners to show now, each with the ids it announces. One merged banner has `merged: true`. */
|
|
67
|
+
banners: Array<{ ids: string[]; sources: PingSource[]; merged: boolean; exempt: boolean }>
|
|
68
|
+
/** Candidates that stay silent in the chat, with the reason. */
|
|
69
|
+
silent: Array<{ id: string; reason: SilentReason }>
|
|
70
|
+
}
|
|
71
|
+
export type SilentReason = 'source_off' | 'quiet_hours' | 'before_first_banner' | 'daily_cap' | 'source_cap' | 'too_soon'
|
|
72
|
+
|
|
73
|
+
/** Control's quiet rule at `nowMs` in `timezone`: weekends, before 6:00 and from 18:00. */
|
|
74
|
+
export function isQuietHours(nowMs: number, timezone: string, budget: BudgetOptions = DEFAULT_BUDGET): boolean {
|
|
75
|
+
const c = localClock(nowMs, timezone)
|
|
76
|
+
if (budget.weekendsQuiet && (c.weekday === 0 || c.weekday === 6)) return true
|
|
77
|
+
const { quietStartMinutes: start, quietEndMinutes: end } = budget
|
|
78
|
+
if (start === end) return false
|
|
79
|
+
return start > end ? c.minutes >= start || c.minutes < end : c.minutes >= start && c.minutes < end
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The local day's non-exempt banners in the ledger (reminders never count). */
|
|
83
|
+
function counted(ledger: readonly BannerRecord[], nowMs: number, timezone: string): BannerRecord[] {
|
|
84
|
+
const today = localClock(nowMs, timezone).day
|
|
85
|
+
return ledger.filter(b => b.at <= nowMs && localClock(b.at, timezone).day === today && b.sources.some(s => s !== 'reminder'))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Decide which `candidates` banner now. Reminders always banner (one each, never merged, never counted). The rest
|
|
90
|
+
* pass the source switch, quiet hours, 8:00, the daily cap, the per-source cap and the 45-minute gap, in that
|
|
91
|
+
* order; those that pass merge into one banner. The caller records each banner in the ledger before showing it.
|
|
92
|
+
*/
|
|
93
|
+
export function planBanners(
|
|
94
|
+
candidates: readonly BannerCandidate[],
|
|
95
|
+
ledger: readonly BannerRecord[],
|
|
96
|
+
nowMs: number,
|
|
97
|
+
timezone: string,
|
|
98
|
+
enabled: readonly PingSource[] = DEFAULT_BANNER_SOURCES,
|
|
99
|
+
budget: BudgetOptions = DEFAULT_BUDGET,
|
|
100
|
+
/** Ids that may banner although their source is switched off ("Ping me about" in Settings). Caps still apply. */
|
|
101
|
+
promoted: ReadonlySet<string> = new Set(),
|
|
102
|
+
): BannerPlan {
|
|
103
|
+
const plan: BannerPlan = { banners: [], silent: [] }
|
|
104
|
+
const sorted = [...candidates].sort((a, b) => PING_RANK[a.source] - PING_RANK[b.source] || b.createdAt - a.createdAt)
|
|
105
|
+
const rest: BannerCandidate[] = []
|
|
106
|
+
for (const c of sorted) {
|
|
107
|
+
if (c.source === 'reminder') {
|
|
108
|
+
// Exempt: any hour, never counted. A reminder is still subject to his switch (on by default).
|
|
109
|
+
if (enabled.includes('reminder')) plan.banners.push({ ids: [c.id], sources: ['reminder'], merged: false, exempt: true })
|
|
110
|
+
else plan.silent.push({ id: c.id, reason: 'source_off' })
|
|
111
|
+
} else rest.push(c)
|
|
112
|
+
}
|
|
113
|
+
if (!rest.length) return plan
|
|
114
|
+
|
|
115
|
+
const silence = (list: BannerCandidate[], reason: SilentReason) => { for (const c of list) plan.silent.push({ id: c.id, reason }) }
|
|
116
|
+
const allowed = (c: BannerCandidate) => enabled.includes(c.source) || promoted.has(c.id)
|
|
117
|
+
const on = rest.filter(allowed)
|
|
118
|
+
silence(rest.filter(c => !allowed(c)), 'source_off')
|
|
119
|
+
if (!on.length) return plan
|
|
120
|
+
if (isQuietHours(nowMs, timezone, budget)) { silence(on, 'quiet_hours'); return plan }
|
|
121
|
+
if (localClock(nowMs, timezone).minutes < budget.firstBannerMinutes) { silence(on, 'before_first_banner'); return plan }
|
|
122
|
+
|
|
123
|
+
const today = counted(ledger, nowMs, timezone)
|
|
124
|
+
if (today.length >= budget.perWeekday) { silence(on, 'daily_cap'); return plan }
|
|
125
|
+
const last = today.reduce((max, b) => Math.max(max, b.at), -Infinity)
|
|
126
|
+
if (nowMs - last < budget.gapMs) { silence(on, 'too_soon'); return plan }
|
|
127
|
+
|
|
128
|
+
const perSource = new Map<PingSource, number>()
|
|
129
|
+
for (const b of today) for (const s of new Set(b.sources)) perSource.set(s, (perSource.get(s) ?? 0) + 1)
|
|
130
|
+
const pass: BannerCandidate[] = []
|
|
131
|
+
for (const c of on) {
|
|
132
|
+
if ((perSource.get(c.source) ?? 0) >= budget.perSource) plan.silent.push({ id: c.id, reason: 'source_cap' })
|
|
133
|
+
else pass.push(c)
|
|
134
|
+
}
|
|
135
|
+
if (!pass.length) return plan
|
|
136
|
+
const sources = [...new Set(pass.map(c => c.source))]
|
|
137
|
+
plan.banners.push({ ids: pass.map(c => c.id), sources, merged: pass.length > 1, exempt: false })
|
|
138
|
+
return plan
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The banner's words. One item uses its own title; several say how many ("3 updates"). */
|
|
142
|
+
export function bannerTitle(count: number, single: string): string {
|
|
143
|
+
return count <= 1 ? single : `${count} updates`
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** A ledger trimmed to the last 8 days: enough for any weekday's count and the weekly stale rule. */
|
|
147
|
+
export function trimLedger(ledger: readonly BannerRecord[], nowMs: number): BannerRecord[] {
|
|
148
|
+
return ledger.filter(b => nowMs - b.at <= 8 * 86_400_000 && b.at <= nowMs + 60_000)
|
|
149
|
+
}
|