@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.
Files changed (88) hide show
  1. package/CHANGELOG.md +149 -0
  2. package/package.json +2 -1
  3. package/server/index.ts +147 -9
  4. package/server/lib/api-auth.ts +31 -14
  5. package/server/lib/claude-bridge.ts +21 -2
  6. package/server/lib/codex-bridge.ts +21 -2
  7. package/server/lib/display-bus.ts +3 -0
  8. package/server/lib/drop-destinations.ts +134 -0
  9. package/server/lib/drop-history-search.ts +97 -0
  10. package/server/lib/drop-p2-boot.ts +60 -0
  11. package/server/lib/drop-p2-runtime.ts +237 -0
  12. package/server/lib/drop-p2-settings.ts +174 -0
  13. package/server/lib/drop-resolution.ts +168 -0
  14. package/server/lib/drop-router.ts +325 -0
  15. package/server/lib/drop-service.ts +739 -0
  16. package/server/lib/drop-settings.ts +45 -0
  17. package/server/lib/drop-store.ts +428 -0
  18. package/server/lib/drop-when.ts +265 -0
  19. package/server/lib/glasses-pairing.ts +15 -0
  20. package/server/lib/learning-needs-you.ts +111 -0
  21. package/server/lib/live-cues-v2-settings.ts +2 -1
  22. package/server/lib/live-cues-v2.ts +6 -4
  23. package/server/lib/meeting-consolidation-run.ts +169 -0
  24. package/server/lib/meeting-store.ts +61 -0
  25. package/server/lib/meeting-title-steer.ts +200 -0
  26. package/server/lib/network-policy.ts +84 -6
  27. package/server/lib/permission-broker.ts +11 -0
  28. package/server/lib/ping-budget.ts +149 -0
  29. package/server/lib/ping-engine.ts +245 -0
  30. package/server/lib/ping-feed-sink.ts +33 -0
  31. package/server/lib/ping-sources.ts +192 -0
  32. package/server/lib/query-job-coordinator.ts +5 -2
  33. package/server/lib/query-job-runtime.ts +45 -4
  34. package/server/lib/query-job-types.ts +10 -1
  35. package/server/lib/reminder-scheduler.ts +153 -0
  36. package/server/lib/tailnet-self-name.ts +103 -0
  37. package/server/lib/tailscale-nudge.ts +40 -2
  38. package/server/lib/task-store.ts +32 -0
  39. package/server/lib/web-ask-admission.ts +56 -0
  40. package/server/lib/web-live-chunks.ts +273 -0
  41. package/server/lib/web-memories-bridge.ts +323 -0
  42. package/server/lib/web-session-auth.ts +492 -0
  43. package/server/lib/web-usage-bridge.ts +154 -0
  44. package/server/lib/work-search.ts +14 -1
  45. package/server/lib/your-move-count.ts +58 -0
  46. package/server/routes/display.ts +11 -1
  47. package/server/routes/drops.ts +260 -0
  48. package/server/routes/health.ts +16 -0
  49. package/server/routes/live-cues.ts +5 -4
  50. package/server/routes/meeting.ts +61 -5
  51. package/server/routes/permission-broker.ts +23 -3
  52. package/server/routes/transcribe-stream.ts +34 -3
  53. package/server/routes/web-control.ts +569 -0
  54. package/server/routes/welcome-context.ts +2 -1
  55. package/server/web-control/app.css +519 -0
  56. package/server/web-control/app.js +3643 -0
  57. package/server/web-control/control.css +312 -0
  58. package/server/web-control/fonts/DMSans.ttf +0 -0
  59. package/server/web-control/fonts/Fraunces.ttf +0 -0
  60. package/server/web-control/fonts/JetBrainsMono.ttf +0 -0
  61. package/server/web-control/index.html +275 -0
  62. package/server/web-control/mark.svg +1 -0
  63. package/server/web-control/memories/d3.min.js +2 -0
  64. package/server/web-control/memories/graph-explorer.css +200 -0
  65. package/server/web-control/memories/graph-explorer.js +748 -0
  66. package/server/web-control/memories/memories-app.js +1561 -0
  67. package/server/web-control/memories/memories-theme.css +73 -0
  68. package/server/web-control/memories/memories.html +72 -0
  69. package/server/web-control/memories/memory-stewardship.js +63 -0
  70. package/server/web-control/memories/memory-workspace.js +216 -0
  71. package/server/web-control/panes/cos-chat.css +157 -0
  72. package/server/web-control/panes/cos-chat.js +973 -0
  73. package/server/web-control/panes/dictation.js +173 -0
  74. package/server/web-control/panes/meetings.css +257 -0
  75. package/server/web-control/panes/meetings.js +2264 -0
  76. package/server/web-control/panes/messages.js +711 -0
  77. package/server/web-control/panes/questions.css +32 -0
  78. package/server/web-control/panes/questions.js +275 -0
  79. package/server/web-control/panes/sessions.css +452 -0
  80. package/server/web-control/panes/settings.js +292 -0
  81. package/server/web-control/panes/speakers.css +269 -0
  82. package/server/web-control/panes/speakers.js +2755 -0
  83. package/server/web-control/panes/threads.js +200 -0
  84. package/server/web-control/panes/work.css +261 -0
  85. package/server/web-control/panes/work.js +1667 -0
  86. package/server/web-control/shell.js +294 -0
  87. package/server/web-control/theme.js +25 -0
  88. 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
- export function isAllowedNetworkOrigin(origin: string): boolean {
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
- return url.hostname === 'localhost' || isAllowedNetworkIp(url.hostname)
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`. The server never
62
- * enables trust-proxy, but reading the socket keeps this true even if someone does.
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: { socket?: { remoteAddress?: string | undefined } | null }): boolean {
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
+ }