@gotcos/glasses-server 6.98.1 → 6.99.16

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 (98) hide show
  1. package/CHANGELOG.md +227 -0
  2. package/package.json +2 -1
  3. package/server/index.ts +170 -11
  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/cos-thread.ts +582 -0
  8. package/server/lib/display-bus.ts +3 -0
  9. package/server/lib/drop-attachments.ts +55 -0
  10. package/server/lib/drop-destinations.ts +134 -0
  11. package/server/lib/drop-history-search.ts +97 -0
  12. package/server/lib/drop-p2-boot.ts +60 -0
  13. package/server/lib/drop-p2-runtime.ts +237 -0
  14. package/server/lib/drop-p2-settings.ts +174 -0
  15. package/server/lib/drop-resolution.ts +168 -0
  16. package/server/lib/drop-router.ts +325 -0
  17. package/server/lib/drop-service.ts +830 -0
  18. package/server/lib/drop-settings.ts +56 -0
  19. package/server/lib/drop-store.ts +450 -0
  20. package/server/lib/drop-when.ts +265 -0
  21. package/server/lib/glasses-pairing.ts +15 -0
  22. package/server/lib/learning-needs-you.ts +111 -0
  23. package/server/lib/live-cues-v2-settings.ts +2 -1
  24. package/server/lib/live-cues-v2.ts +6 -4
  25. package/server/lib/media-store.ts +80 -0
  26. package/server/lib/meeting-batch-transcribe.ts +15 -2
  27. package/server/lib/meeting-consolidation-run.ts +169 -0
  28. package/server/lib/meeting-store.ts +61 -0
  29. package/server/lib/meeting-title-steer.ts +200 -0
  30. package/server/lib/network-policy.ts +84 -6
  31. package/server/lib/permission-broker.ts +13 -0
  32. package/server/lib/phone-recording.ts +1 -1
  33. package/server/lib/ping-budget.ts +149 -0
  34. package/server/lib/ping-engine.ts +245 -0
  35. package/server/lib/ping-feed-sink.ts +33 -0
  36. package/server/lib/ping-sources.ts +192 -0
  37. package/server/lib/query-job-coordinator.ts +5 -2
  38. package/server/lib/query-job-runtime.ts +45 -4
  39. package/server/lib/query-job-types.ts +10 -1
  40. package/server/lib/reminder-scheduler.ts +153 -0
  41. package/server/lib/tailnet-self-name.ts +103 -0
  42. package/server/lib/tailscale-nudge.ts +40 -2
  43. package/server/lib/task-store.ts +32 -0
  44. package/server/lib/web-ask-admission.ts +56 -0
  45. package/server/lib/web-live-chunks.ts +273 -0
  46. package/server/lib/web-memories-bridge.ts +323 -0
  47. package/server/lib/web-session-auth.ts +492 -0
  48. package/server/lib/web-usage-bridge.ts +154 -0
  49. package/server/lib/work-search.ts +14 -1
  50. package/server/lib/your-move-count.ts +58 -0
  51. package/server/routes/cos-thread.ts +79 -0
  52. package/server/routes/display.ts +11 -1
  53. package/server/routes/drops.ts +262 -0
  54. package/server/routes/health.ts +16 -0
  55. package/server/routes/live-cues.ts +5 -4
  56. package/server/routes/meeting.ts +61 -5
  57. package/server/routes/permission-broker.ts +23 -3
  58. package/server/routes/transcribe-stream.ts +43 -4
  59. package/server/routes/web-control.ts +569 -0
  60. package/server/routes/welcome-context.ts +2 -1
  61. package/server/scripts/web-live-cls/cdp.mjs +57 -0
  62. package/server/scripts/web-live-cls/measure.mjs +80 -0
  63. package/server/scripts/web-live-cls/mock.mjs +97 -0
  64. package/server/scripts/web-live-cls/render.mjs +26 -0
  65. package/server/web-control/app.css +519 -0
  66. package/server/web-control/app.js +3643 -0
  67. package/server/web-control/control.css +325 -0
  68. package/server/web-control/fonts/DMSans.ttf +0 -0
  69. package/server/web-control/fonts/Fraunces.ttf +0 -0
  70. package/server/web-control/fonts/JetBrainsMono.ttf +0 -0
  71. package/server/web-control/index.html +275 -0
  72. package/server/web-control/mark.svg +1 -0
  73. package/server/web-control/memories/d3.min.js +2 -0
  74. package/server/web-control/memories/graph-explorer.css +200 -0
  75. package/server/web-control/memories/graph-explorer.js +748 -0
  76. package/server/web-control/memories/memories-app.js +1561 -0
  77. package/server/web-control/memories/memories-theme.css +73 -0
  78. package/server/web-control/memories/memories.html +72 -0
  79. package/server/web-control/memories/memory-stewardship.js +63 -0
  80. package/server/web-control/memories/memory-workspace.js +216 -0
  81. package/server/web-control/panes/cos-chat.css +174 -0
  82. package/server/web-control/panes/cos-chat.js +1118 -0
  83. package/server/web-control/panes/dictation.js +173 -0
  84. package/server/web-control/panes/meetings.css +314 -0
  85. package/server/web-control/panes/meetings.js +2568 -0
  86. package/server/web-control/panes/messages.js +718 -0
  87. package/server/web-control/panes/questions.css +32 -0
  88. package/server/web-control/panes/questions.js +275 -0
  89. package/server/web-control/panes/sessions.css +452 -0
  90. package/server/web-control/panes/settings.js +292 -0
  91. package/server/web-control/panes/speakers.css +269 -0
  92. package/server/web-control/panes/speakers.js +2755 -0
  93. package/server/web-control/panes/threads.js +200 -0
  94. package/server/web-control/panes/work.css +261 -0
  95. package/server/web-control/panes/work.js +1667 -0
  96. package/server/web-control/shell.js +294 -0
  97. package/server/web-control/theme.js +25 -0
  98. package/server/web-control/vendor/qrcode-encoder.js +10 -0
@@ -0,0 +1,265 @@
1
+ /**
2
+ * Drop it P2 (plan PLAN_drop_it_2026-11-04 section 7): when a reminder fires, read from his own words.
3
+ *
4
+ * Deterministic. No model, no network, no clock of its own: `now` and the zone are inputs, so every case is a
5
+ * row in a table. What it reads:
6
+ * - days: today, tonight, tomorrow, this morning/afternoon/evening, weekdays, "next week", ISO dates, "Nov 12";
7
+ * - times: "3pm", "3:30 p.m.", "15:00", "at 3", noon, midnight, morning (9:00), afternoon (14:00), evening (18:00);
8
+ * - offsets: "in 20 minutes", "in an hour", "in 3 days", "in 2 weeks".
9
+ *
10
+ * Defaults (plan section 7):
11
+ * - A day alone means 9:00 (tonight means 20:00).
12
+ * - Today's weekday means next week ("Thursday" said on a Thursday is a week out).
13
+ * - A bare hour means the next one: "at 3" at 10 AM is 3 PM today; at 4 PM it is 3 AM tomorrow. On a later day,
14
+ * 1 to 6 read as afternoon and 7 to 11 as morning.
15
+ * - A time with no day is the next time the clock shows it.
16
+ * - Nothing found: `at` is null and COS asks once (Tomorrow 9:00, Monday 9:00, Pick a time).
17
+ * - An explicit day and time already past ("today 3pm" at 4 PM) is refused as `past`, never moved silently.
18
+ */
19
+ import { localClock, shiftDay, taskInstant } from './morning-brief-schedule.js'
20
+
21
+ export const WHEN_DEFAULTS = { dayMinutes: 9 * 60, morning: 9 * 60, afternoon: 14 * 60, evening: 18 * 60, tonight: 20 * 60, noon: 12 * 60 } as const
22
+ /** The furthest a reminder may be set: a year out. Past that it is a typo, so COS asks. */
23
+ export const WHEN_MAX_AHEAD_MS = 366 * 86_400_000
24
+
25
+ export type WhenReason = 'none' | 'past' | 'too_far'
26
+ export interface WhenResult {
27
+ /** Epoch ms of the fire, or null when COS must ask. */
28
+ at: number | null
29
+ reason?: WhenReason
30
+ /** What the reminder is about, with "remind me", the time words and a joining "to" taken out. */
31
+ about: string
32
+ /** The receipt's words for the time: "today 3:00 PM", "tomorrow 9:00 AM", "Thu 9:00 AM", "Nov 12 9:00 AM". */
33
+ label?: string
34
+ }
35
+
36
+ const MONTHS: Record<string, number> = { jan: 1, feb: 2, mar: 3, apr: 4, may: 5, jun: 6, jul: 7, aug: 8, sep: 9, oct: 10, nov: 11, dec: 12 }
37
+ const WEEKDAYS: Record<string, number> = { sunday: 0, monday: 1, tuesday: 2, tues: 2, tue: 2, wednesday: 3, wed: 3, thursday: 4, thurs: 4, thur: 4, thu: 4, friday: 5, fri: 5, saturday: 6 }
38
+ const SHORT_DAY = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
39
+ const SHORT_MONTH = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
40
+
41
+ type Span = [number, number]
42
+ type DayPart = 'morning' | 'afternoon' | 'evening' | 'tonight'
43
+ interface Found { span: Span }
44
+
45
+ const PATTERNS = {
46
+ iso: /\b(\d{4})-(\d{2})-(\d{2})\b/,
47
+ monthDay: /\b(?:on\s+)?(jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|june?|july?|aug(?:ust)?|sept?(?:ember)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?)\.?\s+(\d{1,2})(?:st|nd|rd|th)?\b(?:,?\s+(\d{4})\b)?/,
48
+ offset: /\bin\s+(an?|one|two|three|\d{1,3})\s+(minutes?|mins?|hours?|hrs?|days?|weeks?)\b/,
49
+ relDay: /\b(today|tonight|tomorrow|tmrw|this\s+(?:morning|afternoon|evening))\b/,
50
+ nextWeek: /\bnext\s+week\b/,
51
+ weekday: /\b(?:(?:next|this|on)\s+)?(sunday|monday|tuesday|tues|tue|wednesday|wed|thursday|thurs|thur|thu|friday|fri|saturday)\b/,
52
+ ampm: /\b(?:at\s+)?(\d{1,2})(?::([0-5]\d))?\s*(a\.?m\.?|p\.?m\.?)(?=[^a-z]|$)/,
53
+ clock24: /\b(?:at\s+)?([01]?\d|2[0-3]):([0-5]\d)\b/,
54
+ bareHour: /\bat\s+(\d{1,2})\b(?!\s*(?:%|percent|[:.]\d))/,
55
+ noon: /\b(?:at\s+)?(noon|midnight)\b/,
56
+ part: /\b(?:in\s+the\s+)?(morning|afternoon|evening)\b/,
57
+ } as const
58
+
59
+ const NUMBER_WORDS: Record<string, number> = { a: 1, an: 1, one: 1, two: 2, three: 3 }
60
+
61
+ function find(re: RegExp, text: string, taken: Span[]): (RegExpExecArray & Found) | null {
62
+ const global = new RegExp(re.source, 'g')
63
+ for (let m = global.exec(text); m; m = global.exec(text)) {
64
+ const span: Span = [m.index, m.index + m[0].length]
65
+ if (taken.some(([a, b]) => span[0] < b && a < span[1])) continue
66
+ return Object.assign(m, { span })
67
+ }
68
+ return null
69
+ }
70
+
71
+ function daysBetween(fromDay: string, toDay: string): number {
72
+ const [fy, fm, fd] = fromDay.split('-').map(Number), [ty, tm, td] = toDay.split('-').map(Number)
73
+ return Math.round((Date.UTC(ty, tm - 1, td) - Date.UTC(fy, fm - 1, fd)) / 86_400_000)
74
+ }
75
+ function validDate(y: number, m: number, d: number): boolean {
76
+ if (m < 1 || m > 12 || d < 1 || d > 31) return false
77
+ const date = new Date(Date.UTC(y, m - 1, d))
78
+ return date.getUTCMonth() === m - 1 && date.getUTCDate() === d
79
+ }
80
+ const pad = (n: number) => String(n).padStart(2, '0')
81
+
82
+ /** "3:00 PM" for minutes past midnight. */
83
+ export function clockLabel(minutes: number): string {
84
+ const h = Math.floor(minutes / 60), m = minutes % 60
85
+ return `${h % 12 === 0 ? 12 : h % 12}:${pad(m)} ${h < 12 ? 'AM' : 'PM'}`
86
+ }
87
+
88
+ /** The receipt's words for an instant, relative to now: today, tomorrow, a weekday within six days, else the date. */
89
+ export function whenLabel(atMs: number, nowMs: number, timezone: string): string {
90
+ const at = localClock(atMs, timezone), now = localClock(nowMs, timezone)
91
+ const ahead = daysBetween(now.day, at.day)
92
+ const [y, mo, d] = at.day.split('-').map(Number)
93
+ const day = ahead === 0 ? 'today' : ahead === 1 ? 'tomorrow' : ahead > 1 && ahead < 7 ? SHORT_DAY[at.weekday]
94
+ : `${SHORT_DAY[at.weekday]} ${SHORT_MONTH[mo - 1]} ${d}${y !== Number(now.day.slice(0, 4)) ? `, ${y}` : ''}`
95
+ return `${day} ${clockLabel(at.minutes)}`
96
+ }
97
+
98
+ function about(text: string, spans: Span[]): string {
99
+ let out = text
100
+ for (const [a, b] of [...spans].sort((x, y) => y[0] - x[0])) out = out.slice(0, a) + ' ' + out.slice(b)
101
+ out = out.replace(/\s+/g, ' ').trim()
102
+ out = out.replace(/^(?:please\s+)?(?:remind\s+me|reminder|set\s+a\s+reminder|don'?t\s+let\s+me\s+forget)(?:\s*[:,-])?\s*/i, '')
103
+ out = out.replace(/^(?:at|on|by)\s+/i, '').replace(/^(?:to|that|about)\s+/i, '').replace(/\s+(?:at|on|by)$/i, '')
104
+ out = out.replace(/^[\s,.:;-]+|[\s,;:-]+$/g, '')
105
+ return out
106
+ }
107
+
108
+ /**
109
+ * Read a reminder time out of `text`. `nowMs` and `timezone` decide what "today", "Thursday" and "at 3" mean.
110
+ * Never throws; anything it cannot read gives `at: null` and a reason.
111
+ */
112
+ /** 6.99.11 settings: the time a day alone means (Settings, Reminders, default time). */
113
+ export interface WhenOptions { dayMinutes?: number }
114
+ const dayMinutesOf = (o?: WhenOptions) => o?.dayMinutes !== undefined && Number.isInteger(o.dayMinutes) && o.dayMinutes >= 0 && o.dayMinutes < 1440 ? o.dayMinutes : WHEN_DEFAULTS.dayMinutes
115
+
116
+ export function parseWhen(text: string, nowMs: number, timezone: string, options?: WhenOptions): WhenResult {
117
+ const source = typeof text === 'string' ? text.slice(0, 2_000) : ''
118
+ const lower = source.toLowerCase()
119
+ const now = localClock(nowMs, timezone)
120
+ const taken: Span[] = []
121
+ const take = (m: (RegExpExecArray & Found) | null) => { if (m) taken.push(m.span); return m }
122
+
123
+ // Times first, so "3:30" is never read as part of a date and "at 3" is not taken by the offset rule.
124
+ let minutes: number | null = null
125
+ let bareHour: number | null = null
126
+ const ampm = take(find(PATTERNS.ampm, lower, taken))
127
+ if (ampm) {
128
+ const h = Number(ampm[1]), m = ampm[2] ? Number(ampm[2]) : 0
129
+ if (h >= 1 && h <= 12) minutes = ((h % 12) + (ampm[3].startsWith('p') ? 12 : 0)) * 60 + m
130
+ }
131
+ let bareMinute = 0
132
+ if (minutes === null) {
133
+ const c = take(find(PATTERNS.clock24, lower, taken))
134
+ // "15:00" and "09:30" are a 24-hour clock; "3:30" with no am/pm is a bare hour like "at 3".
135
+ if (c && (Number(c[1]) > 12 || c[1].length === 2 && c[1].startsWith('0'))) minutes = Number(c[1]) * 60 + Number(c[2])
136
+ else if (c) { bareHour = Number(c[1]); bareMinute = Number(c[2]) }
137
+ }
138
+ if (minutes === null) {
139
+ const n = take(find(PATTERNS.noon, lower, taken))
140
+ if (n) minutes = n[1] === 'noon' ? WHEN_DEFAULTS.noon : 0
141
+ }
142
+ if (minutes === null && bareHour === null) {
143
+ const b = find(PATTERNS.bareHour, lower, taken)
144
+ if (b && Number(b[1]) >= 0 && Number(b[1]) <= 23) { take(b); bareHour = Number(b[1]) }
145
+ }
146
+
147
+ // Offsets ("in 20 minutes") are a whole answer on their own unless they name days or weeks.
148
+ const off = take(find(PATTERNS.offset, lower, taken))
149
+ if (off) {
150
+ const n = NUMBER_WORDS[off[1]] ?? Number(off[1]), unit = off[2]
151
+ if (n >= 1) {
152
+ if (/^(?:min|hour|hr)/.test(unit)) {
153
+ const at = nowMs + n * (unit.startsWith('h') ? 3_600_000 : 60_000)
154
+ return finish(source, taken, at, nowMs, timezone)
155
+ }
156
+ const day = shiftDay(now.day, n * (unit.startsWith('w') ? 7 : 1))
157
+ return resolve(day, minutes ?? (bareHour !== null ? pmBias(bareHour) + bareMinute : now.minutes))
158
+ }
159
+ }
160
+
161
+ // The day.
162
+ let day: string | null = null
163
+ let part: DayPart | null = null
164
+ const iso = take(find(PATTERNS.iso, lower, taken))
165
+ if (iso) {
166
+ const y = Number(iso[1]), m = Number(iso[2]), d = Number(iso[3])
167
+ if (validDate(y, m, d)) day = `${y}-${pad(m)}-${pad(d)}`
168
+ }
169
+ if (!day) {
170
+ const md = find(PATTERNS.monthDay, lower, taken)
171
+ if (md) {
172
+ const m = MONTHS[md[1].slice(0, 3)], d = Number(md[2])
173
+ let y = md[3] ? Number(md[3]) : Number(now.day.slice(0, 4))
174
+ if (validDate(y, m, d)) {
175
+ take(md)
176
+ let candidate = `${y}-${pad(m)}-${pad(d)}`
177
+ if (!md[3] && daysBetween(now.day, candidate) < 0) { y += 1; candidate = validDate(y, m, d) ? `${y}-${pad(m)}-${pad(d)}` : candidate }
178
+ day = candidate
179
+ }
180
+ }
181
+ }
182
+ if (!day) {
183
+ const rel = take(find(PATTERNS.relDay, lower, taken))
184
+ if (rel) {
185
+ const word = rel[1]
186
+ if (word === 'today') day = now.day
187
+ else if (word === 'tonight') { day = now.day; part = 'tonight' }
188
+ else if (word === 'tomorrow' || word === 'tmrw') day = shiftDay(now.day, 1)
189
+ else { day = now.day; part = word.replace(/^this\s+/, '') as DayPart }
190
+ }
191
+ }
192
+ if (!day && take(find(PATTERNS.nextWeek, lower, taken))) {
193
+ // Next week means its Monday (the weekly check-in's day), 9:00 unless he gave a time.
194
+ const toMonday = ((8 - now.weekday) % 7) || 7
195
+ day = shiftDay(now.day, toMonday)
196
+ }
197
+ if (!day) {
198
+ const wd = take(find(PATTERNS.weekday, lower, taken))
199
+ if (wd) {
200
+ const target = WEEKDAYS[wd[1]]
201
+ const ahead = ((target - now.weekday) + 7) % 7 || 7 // today's weekday means next week
202
+ day = shiftDay(now.day, ahead)
203
+ }
204
+ }
205
+ if (!part) {
206
+ const p = find(PATTERNS.part, lower, taken)
207
+ if (p && (day || minutes !== null || bareHour !== null)) { take(p); part = p[1] as DayPart }
208
+ }
209
+
210
+ if (!day && minutes === null && bareHour === null && !part) return { at: null, reason: 'none', about: about(source, taken) }
211
+
212
+ // A bare hour: the next time the clock shows it, or, on a later day, its daytime reading.
213
+ if (minutes === null && bareHour !== null) {
214
+ if (part === 'afternoon' || part === 'evening' || part === 'tonight') minutes = (bareHour % 12 + 12) * 60
215
+ else if (part === 'morning') minutes = (bareHour % 12) * 60
216
+ else if (bareHour > 12) minutes = bareHour * 60
217
+ else if (!day || day === now.day) {
218
+ const options = [bareHour % 12 * 60, (bareHour % 12 + 12) * 60].sort((a, b) => a - b)
219
+ const next = options.find(o => o + bareMinute > now.minutes)
220
+ if (next !== undefined) { minutes = next; day = day ?? now.day }
221
+ else if (!day) { minutes = options[0]; day = shiftDay(now.day, 1) }
222
+ else minutes = options[1] // "today at 3" after 3 PM: refused as past below
223
+ } else minutes = pmBias(bareHour)
224
+ minutes += bareMinute
225
+ }
226
+ if (minutes === null) minutes = part ? WHEN_DEFAULTS[part] : dayMinutesOf(options)
227
+
228
+ // A time with no day: the next time the clock shows it.
229
+ if (!day) day = minutes > now.minutes ? now.day : shiftDay(now.day, 1)
230
+ return resolve(day, minutes)
231
+
232
+ function pmBias(h: number): number { return h >= 13 ? h * 60 : h === 12 ? 12 * 60 : h >= 7 ? h * 60 : (h + 12) * 60 }
233
+ function resolve(d: string, mins: number): WhenResult {
234
+ const at = taskInstant(d, mins, timezone)
235
+ // Only an explicit day can land in the past ("today 3pm" at 4 PM): it is refused, never moved silently.
236
+ if (at <= nowMs) return { at: null, reason: 'past', about: about(source, taken) }
237
+ return finish(source, taken, at, nowMs, timezone)
238
+ }
239
+ }
240
+
241
+ function finish(source: string, taken: Span[], at: number, nowMs: number, timezone: string): WhenResult {
242
+ if (at - nowMs > WHEN_MAX_AHEAD_MS) return { at: null, reason: 'too_far', about: about(source, taken) }
243
+ return { at, about: about(source, taken), label: whenLabel(at, nowMs, timezone) }
244
+ }
245
+
246
+ /** The three times COS offers when it could not read one (plan section 7): Tomorrow 9:00 and Monday 9:00. */
247
+ export function whenSuggestions(nowMs: number, timezone: string, options?: WhenOptions): Array<{ id: string; label: string; at: number }> {
248
+ const now = localClock(nowMs, timezone)
249
+ const dm = dayMinutesOf(options), hm = `${Math.floor(dm / 60)}:${String(dm % 60).padStart(2, '0')}`
250
+ const tomorrow = taskInstant(shiftDay(now.day, 1), dm, timezone)
251
+ const toMonday = ((8 - now.weekday) % 7) || 7
252
+ const monday = taskInstant(shiftDay(now.day, toMonday), dm, timezone)
253
+ // The ids stay tomorrow_9 and monday_9 whatever the time: they name the choice, not the hour.
254
+ const out = [{ id: 'tomorrow_9', label: `Tomorrow ${hm}`, at: tomorrow }]
255
+ if (monday !== tomorrow) out.push({ id: 'monday_9', label: `Monday ${hm}`, at: monday })
256
+ return out
257
+ }
258
+
259
+ /** Snooze targets for a fired reminder: one hour from now, and tomorrow 9:00. */
260
+ export function snoozeTargets(nowMs: number, timezone: string, options?: WhenOptions & { snoozeMs?: number }): { hour: number; tomorrow9: number } {
261
+ const now = localClock(nowMs, timezone)
262
+ const snooze = options?.snoozeMs !== undefined && options.snoozeMs > 0 ? options.snoozeMs : 3_600_000
263
+ return { hour: nowMs + snooze, tomorrow9: taskInstant(shiftDay(now.day, 1), dayMinutesOf(options), timezone) }
264
+ }
265
+
@@ -425,6 +425,21 @@ export class PairingState {
425
425
  this.log(`[pairing] first authenticated request ip=${claim.ip}`)
426
426
  }
427
427
 
428
+ /**
429
+ * Whether `input` is the glasses code showing in COS Control right now. Read only: it never
430
+ * counts an attempt, consumes the code or creates a claim. COS Control Web asks this after a
431
+ * typed code fails as a web code, so it can say "that code is for the glasses app" instead of
432
+ * a bare "wrong code" (6.99.3; Miles typed the glasses code on the web page until locked out).
433
+ */
434
+ isLiveCode(input: unknown): boolean {
435
+ const at = this.now()
436
+ this.sweep(at)
437
+ const code = normalizePairingCode(input)
438
+ const current = this.current
439
+ if (!code || !current || current.status !== 'active' || at >= current.expiresAt) return false
440
+ return pairingCodesEqual(code, current.code)
441
+ }
442
+
428
443
  /** GET /pairing/status. */
429
444
  status(): PairingStatus {
430
445
  const at = this.now()
@@ -0,0 +1,111 @@
1
+ // The Memories "To review" count for COS Control Web: the gated needs-you list from
2
+ // `learning_events.py needs-you`, read the way Control's helper reads it (HelperSources
3
+ // main.swift runNeedsYouCLI: 10 s timeout, the same keys, cached about 90 s).
4
+ //
5
+ // WHY THE SERVER RUNS IT. Server 6.45.1 strips `needs_you` from /api/context/status, and
6
+ // Control overlays it on the Mac from this CLI. The browser has no such overlay, so the web
7
+ // Memories page read `learning.to_review` (an object of counts) as the count and showed
8
+ // "To review (NaN)". The bridge's status now carries this block instead, or null when the
9
+ // CLI is unavailable, never a guess.
10
+
11
+ import { execFile } from 'node:child_process'
12
+ import { existsSync } from 'node:fs'
13
+ import { join } from 'node:path'
14
+ import { COS_SCRIPTS_DIR } from './python-bridge.js'
15
+
16
+ export const NEEDS_YOU_TIMEOUT_MS = 10_000
17
+ /** Control: needsYouCacheTTL = 90 s (HelperSources main.swift 10969). */
18
+ export const NEEDS_YOU_TTL_MS = 90_000
19
+ /** Control: needsYouStaleFlagLimit = 3600 s (main.swift 10970): an older stale block loses reward_enabled. */
20
+ export const NEEDS_YOU_STALE_FLAG_LIMIT_MS = 3_600_000
21
+ const KEYS = ['count', 'gates', 'system_backlog', 'window_days', 'limit', 'partial', 'not_implemented', 'items', 'overflow', 'reward_enabled'] as const
22
+
23
+ export type NeedsYouBlock = Partial<Record<(typeof KEYS)[number], unknown>>
24
+
25
+ export interface NeedsYouDeps {
26
+ scriptsDir?: string | null
27
+ now?: () => number
28
+ /** Runs `file args` with a timeout; resolves { code, stdout } and never rejects. */
29
+ run?: (file: string, args: string[], timeoutMs: number) => Promise<{ code: number; stdout: string }>
30
+ }
31
+
32
+ /** The JSON object in the CLI's stdout: the whole of it, or the span from the first { to the last }. */
33
+ export function parseJsonObject(output: string): Record<string, unknown> | null {
34
+ const text = output.trim()
35
+ const attempt = (s: string): Record<string, unknown> | null => {
36
+ try {
37
+ const v = JSON.parse(s)
38
+ return v && typeof v === 'object' && !Array.isArray(v) ? v as Record<string, unknown> : null
39
+ } catch { return null }
40
+ }
41
+ const whole = attempt(text)
42
+ if (whole) return whole
43
+ const start = text.indexOf('{'), end = text.lastIndexOf('}')
44
+ return start >= 0 && end > start ? attempt(text.slice(start, end + 1)) : null
45
+ }
46
+
47
+ /** Control's count for the rail and the To review tab: count, capped by limit when both are numbers. */
48
+ export function needsYouCount(block: NeedsYouBlock | null): number | null {
49
+ if (!block) return null
50
+ const count = typeof block.count === 'number' ? block.count : Array.isArray(block.items) ? block.items.length : null
51
+ if (count === null) return null
52
+ return typeof block.limit === 'number' ? Math.min(count, block.limit) : count
53
+ }
54
+
55
+ function defaultRun(file: string, args: string[], timeoutMs: number): Promise<{ code: number; stdout: string }> {
56
+ return new Promise(resolve => {
57
+ execFile(file, args, { timeout: timeoutMs, maxBuffer: 4 * 1024 * 1024, env: process.env }, (err, stdout) => {
58
+ const code = err ? (typeof (err as { code?: unknown }).code === 'number' ? (err as { code: number }).code : 1) : 0
59
+ resolve({ code, stdout: String(stdout ?? '') })
60
+ })
61
+ })
62
+ }
63
+
64
+ export function createNeedsYouReader(deps: NeedsYouDeps = {}) {
65
+ const now = deps.now ?? (() => Date.now())
66
+ const run = deps.run ?? defaultRun
67
+ // Only a good read is kept (Control's fetchNeedsYouBlock, main.swift 10987-11001): a failed
68
+ // read is never cached, and falls back to the last good block, which drops reward_enabled
69
+ // once it is older than an hour so a stale flag cannot keep the ranking sentence alive.
70
+ let good: { at: number; block: NeedsYouBlock } | null = null
71
+ let inflight: Promise<NeedsYouBlock | null> | null = null
72
+
73
+ async function readOnce(): Promise<NeedsYouBlock | null> {
74
+ const dir = deps.scriptsDir !== undefined ? deps.scriptsDir : COS_SCRIPTS_DIR
75
+ if (!dir) return null
76
+ const python = join(dir, 'cos_python')
77
+ const script = join(dir, 'learning_events.py')
78
+ if (!existsSync(python) || !existsSync(script)) return null
79
+ const result = await run(python, [script, 'needs-you'], NEEDS_YOU_TIMEOUT_MS)
80
+ if (result.code !== 0) return null
81
+ const payload = parseJsonObject(result.stdout)
82
+ if (!payload || (payload.count === undefined && payload.items === undefined)) return null
83
+ const block: NeedsYouBlock = {}
84
+ for (const key of KEYS) if (payload[key] !== undefined) block[key] = payload[key]
85
+ return Object.keys(block).length ? block : null
86
+ }
87
+
88
+ function stale(): NeedsYouBlock | null {
89
+ if (!good) return null
90
+ const block: NeedsYouBlock = { ...good.block }
91
+ if (now() - good.at > NEEDS_YOU_STALE_FLAG_LIMIT_MS) delete block.reward_enabled
92
+ return block
93
+ }
94
+ /** A fresh block under NEEDS_YOU_TTL_MS old, else a new read, else the last good block; single flight; never throws. */
95
+ function read(): Promise<NeedsYouBlock | null> {
96
+ if (good && now() - good.at < NEEDS_YOU_TTL_MS) return Promise.resolve(good.block)
97
+ if (inflight) return inflight
98
+ inflight = readOnce().catch(() => null).then(block => {
99
+ if (block) { good = { at: now(), block }; return block }
100
+ return stale()
101
+ }).finally(() => { inflight = null })
102
+ return inflight
103
+ }
104
+ return { read }
105
+ }
106
+
107
+ let singleton: ReturnType<typeof createNeedsYouReader> | null = null
108
+ export function getNeedsYouReader(): ReturnType<typeof createNeedsYouReader> {
109
+ if (!singleton) singleton = createNeedsYouReader()
110
+ return singleton
111
+ }
@@ -28,7 +28,8 @@ export type LiveCuesV2SettingSource = 'env' | 'setting' | 'default'
28
28
  /** COS Control's live-cue spend limits (6.79.2). Bounds are inclusive; integer fields take whole numbers only. */
29
29
  export const LIVE_CUES_V2_LIMIT_BOUNDS = {
30
30
  gate: { min: 0.5, max: 0.95, default: 0.8, integer: false, env: 'COS_LIVE_CUES_V2_GATE' },
31
- maxCuesPerMeeting: { min: 1, max: 25, default: 25, integer: true, env: 'COS_LIVE_CUES_V2_MAX_PER_MEETING' },
31
+ // 6.99.0: the ceiling is 50 (Miles 2026-11-03, longer meetings); the default stays 25. Everything that clamps or validates this reads `.max`.
32
+ maxCuesPerMeeting: { min: 1, max: 50, default: 25, integer: true, env: 'COS_LIVE_CUES_V2_MAX_PER_MEETING' },
32
33
  cooldownSeconds: { min: 20, max: 900, default: 45, integer: true, env: 'COS_LIVE_CUES_V2_COOLDOWN_S' },
33
34
  dailyWriterCalls: { min: 1, max: 500, default: 150, integer: true, env: 'COS_LIVE_CUES_V2_DAILY_WRITER_CALLS' },
34
35
  } as const
@@ -90,8 +90,10 @@ export const LIVE_CUES_V2_LIMITS = {
90
90
  staleMs: 30_000,
91
91
  windowWords: 400,
92
92
  windowChars: 2_200,
93
- /** 25 a meeting (Miles 2026-10-19: 10 ran out 10 minutes into an HC call). Writer calls stay at twice the cue cap so
94
- * discarded drafts cannot end a meeting's cues early, and the weekly default keeps the same ~15 capped meetings. */
93
+ /** FALLBACK DEFAULTS ONLY (nothing reads these at run time): the live cap is `resolveLiveCuesV2Limits().maxCuesPerMeeting`, which
94
+ * Settings can raise to 50 (6.99.0) and env can lock. 25 a meeting (Miles 2026-10-19: 10 ran out 10 minutes into an HC call).
95
+ * Writer calls stay at twice the cue cap so discarded drafts cannot end a meeting's cues early. At 25 cues the weekly default
96
+ * of 750 writer calls covers ~15 capped meetings (50 calls each); at 50 cues it covers ~7 (100 calls each). */
95
97
  maxCuesPerMeeting: 25,
96
98
  maxWriterCallsPerMeeting: 50,
97
99
  defaultWeeklyWriterCalls: 750,
@@ -171,9 +173,9 @@ export function resolveLiveCuesV2Limits(env: NodeJS.ProcessEnv = process.env, se
171
173
  try { saved = settings?.limits?.() ?? {} } catch { saved = {} }
172
174
  const fromEnv: Record<LiveCuesV2LimitName, number | null> = {
173
175
  gate: envGate(env),
174
- maxCuesPerMeeting: envWhole(env, LIVE_CUES_V2_LIMIT_BOUNDS.maxCuesPerMeeting.env, 1, 25),
176
+ maxCuesPerMeeting: envWhole(env, LIVE_CUES_V2_LIMIT_BOUNDS.maxCuesPerMeeting.env, LIVE_CUES_V2_LIMIT_BOUNDS.maxCuesPerMeeting.min, LIVE_CUES_V2_LIMIT_BOUNDS.maxCuesPerMeeting.max),
175
177
  cooldownSeconds: envCooldownSeconds(env),
176
- dailyWriterCalls: envWhole(env, LIVE_CUES_V2_LIMIT_BOUNDS.dailyWriterCalls.env, 1, 500),
178
+ dailyWriterCalls: envWhole(env, LIVE_CUES_V2_LIMIT_BOUNDS.dailyWriterCalls.env, LIVE_CUES_V2_LIMIT_BOUNDS.dailyWriterCalls.min, LIVE_CUES_V2_LIMIT_BOUNDS.dailyWriterCalls.max),
177
179
  }
178
180
  const out = {} as LiveCuesV2Limits
179
181
  for (const name of LIVE_CUES_V2_LIMIT_NAMES) {
@@ -187,8 +187,18 @@ export interface MediaRecord {
187
187
  * the public attachment ref; it lets a lost finalize response or server
188
188
  * restart rediscover the one record that upload already published. */
189
189
  videoUploadId?: string
190
+ /** 6.99.13: the Drop it drop that holds this upload (pinForDrop). Kept apart
191
+ * from runId, which an Ask's association overwrites. Never exposed. */
192
+ dropId?: string
190
193
  }
191
194
 
195
+ /** A drop id (lowercase UUID v4), as lib/drop-store.ts DROP_ID. Kept local so
196
+ * the media store never imports the drop store. */
197
+ const DROP_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
198
+ /** The kinds a person uploads. Traffic frames and generated visuals carry
199
+ * their own content TTL, so they can never be pinned to a drop. */
200
+ const DROPPABLE_KINDS: ReadonlySet<string> = new Set(['user_photo', 'user_document', 'user_video'])
201
+
192
202
  interface MediaIndexFile {
193
203
  v: 1
194
204
  records: Record<string, MediaRecord>
@@ -324,6 +334,7 @@ function sanitizeRecord(raw: unknown): MediaRecord | null {
324
334
  ...(typeof r.videoUploadId === 'string' && /^vu_[0-9a-f]{24}$/.test(r.videoUploadId)
325
335
  ? { videoUploadId: r.videoUploadId }
326
336
  : {}),
337
+ ...(typeof r.dropId === 'string' && DROP_ID_RE.test(r.dropId) ? { dropId: r.dropId } : {}),
327
338
  }
328
339
  }
329
340
 
@@ -1212,6 +1223,75 @@ export class MediaStore {
1212
1223
  })
1213
1224
  }
1214
1225
 
1226
+ /**
1227
+ * 6.99.13 Drop it with files: bind fresh uploads to one drop so they outlive
1228
+ * the staged TTL (4 h). An associated record has no lifecycle TTL and GC
1229
+ * never touches it, so the bytes live as long as the drop holds them.
1230
+ * All-or-nothing, like reserve. Refused (nothing changes):
1231
+ * - unknown or deleted id media_not_found
1232
+ * - expired, or its bytes already gone media_expired
1233
+ * - queued with an Ask, sent with a message, held
1234
+ * by another drop, or not a person's upload media_conflict
1235
+ * - bytes missing on disk media_unavailable
1236
+ * Replaying the same drop is a no-op that answers the same refs.
1237
+ */
1238
+ pinForDrop(ids: string[], dropId: string): Promise<MediaAttachmentRef[]> {
1239
+ return this.withLock(() => {
1240
+ if (!DROP_ID_RE.test(dropId)) throw new MediaStoreError('media_conflict', 'invalid drop id')
1241
+ const now = Date.now()
1242
+ for (const id of ids) {
1243
+ const rec = isValidMediaId(id) ? this.records.get(id) : undefined
1244
+ if (!rec || rec.lifecycle === 'deleted') throw new MediaStoreError('media_not_found', `attachment ${id} not found`)
1245
+ if (rec.lifecycle === 'associated' && rec.dropId === dropId) continue
1246
+ if (rec.lifecycle === 'expired' || rec.contentRemoved || this.isContentExpired(rec, now)) {
1247
+ throw new MediaStoreError('media_expired', `attachment ${id} expired`)
1248
+ }
1249
+ if (!DROPPABLE_KINDS.has(rec.ref.kind)) throw new MediaStoreError('media_conflict', `attachment ${id} is not an upload`)
1250
+ if (rec.lifecycle !== 'staged') throw new MediaStoreError('media_conflict', `attachment ${id} is already in use`)
1251
+ if (!existsSync(this.absPath(rec.storagePath))) throw new MediaStoreError('media_unavailable', `attachment ${id} content unavailable`)
1252
+ }
1253
+ let dirty = false
1254
+ const refs: MediaAttachmentRef[] = []
1255
+ for (const id of ids) {
1256
+ const rec = this.records.get(id)!
1257
+ refs.push(rec.ref)
1258
+ if (rec.lifecycle === 'associated' && rec.dropId === dropId) continue
1259
+ rec.lifecycle = 'associated'
1260
+ rec.dropId = dropId
1261
+ rec.associatedAtMs = now
1262
+ rec.updatedAtMs = now
1263
+ dirty = true
1264
+ }
1265
+ if (dirty) this.saveIndex()
1266
+ return refs
1267
+ })
1268
+ }
1269
+
1270
+ /**
1271
+ * 6.99.13: Undo of a drop with files. Removes the bytes of the uploads this
1272
+ * drop pinned, and only those: a record another drop holds, or one an Ask
1273
+ * since bound to a message (globalMsgNum), is left alone. Safe to replay.
1274
+ * Answers how many were removed.
1275
+ */
1276
+ unpinDrop(ids: string[], dropId: string): Promise<number> {
1277
+ return this.withLock(() => {
1278
+ if (!DROP_ID_RE.test(dropId)) return 0
1279
+ const now = Date.now()
1280
+ let removed = 0
1281
+ for (const id of ids) {
1282
+ const rec = isValidMediaId(id) ? this.records.get(id) : undefined
1283
+ if (!rec || rec.lifecycle !== 'associated' || rec.dropId !== dropId || rec.globalMsgNum != null) continue
1284
+ this.removeAssetFiles(rec)
1285
+ rec.lifecycle = 'deleted'
1286
+ rec.contentRemoved = true
1287
+ rec.updatedAtMs = now
1288
+ removed++
1289
+ }
1290
+ if (removed) this.saveIndex()
1291
+ return removed
1292
+ })
1293
+ }
1294
+
1215
1295
  /** Hard delete — permitted only while unassociated (staged/reserved).
1216
1296
  * Associated records follow conversation retention instead. */
1217
1297
  deleteUnassociated(id: string): Promise<boolean> {
@@ -471,11 +471,24 @@ export interface HqJobPlan {
471
471
  strictPrimary?: true
472
472
  }
473
473
 
474
+ /**
475
+ * An iPhone recording's saved transcript. Parakeet stays the strict primary. Parakeet takes
476
+ * no prompt, so the dictionary's vocabulary reaches it only through a second opinion: when
477
+ * one is on, it is Whisper Large-v3 (prompted with the vocabulary) and the resolver keeps
478
+ * the user's choice, so its vocabulary rescue can write a dictionary term Parakeet missed.
479
+ * Off stays off. Before this, a phone plan forced both off and the dictionary never applied
480
+ * to iPhone recordings beyond the fixed corrections (Miles, 2026-11-05).
481
+ */
482
+ export function phoneHqPlan(selected: Pick<HqJobPlan, 'secondOpinion' | 'resolver' | 'frontEnd'>): HqJobPlan {
483
+ const secondOpinion = selected.secondOpinion === 'off' ? 'off' : 'large-v3'
484
+ return { engine: 'parakeet-ultra', secondOpinion, resolver: secondOpinion !== 'off' && selected.resolver, frontEnd: selected.frontEnd, strictPrimary: true }
485
+ }
486
+
474
487
  export function currentHqJobPlan(sessionId?: string): HqJobPlan {
475
488
  const selected = readSttSettings()
476
489
  if (sessionId && PHONE_CAPTURE_ID.test(sessionId)) {
477
490
  requirePhoneRecording(sessionId)
478
- return { engine: 'parakeet-ultra', secondOpinion: 'off', resolver: false, frontEnd: selected.frontEnd, strictPrimary: true }
491
+ return phoneHqPlan(selected)
479
492
  }
480
493
  if (sessionId && MAC_CAPTURE_ID.test(sessionId) && selected.hq === 'parakeet-ultra') {
481
494
  return { engine: selected.hq, secondOpinion: selected.secondOpinion, resolver: selected.resolver, frontEnd: selected.frontEnd, strictPrimary: true }
@@ -827,7 +840,7 @@ export async function transcribeSegments(
827
840
  plan: HqJobPlan,
828
841
  deps: HqDecodeDeps = defaultHqDeps,
829
842
  ): Promise<BatchResult[]> {
830
- if (expectedSessionId?.startsWith('phone_')) { requirePhoneRecording(expectedSessionId); plan = { ...plan, engine: 'parakeet-ultra', secondOpinion: 'off', resolver: false, strictPrimary: true } }
843
+ if (expectedSessionId?.startsWith('phone_')) { requirePhoneRecording(expectedSessionId); plan = phoneHqPlan(plan) }
831
844
  if (expectedSessionId && MAC_CAPTURE_ID.test(expectedSessionId) && plan.engine === 'parakeet-ultra') plan = { ...plan, strictPrimary: true }
832
845
  const results: BatchResult[] = []
833
846
  const meetingId = basename(audioDir)