@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,58 @@
1
+ /**
2
+ * 6.99.10 (Drop it plan section 12): how many Work items in Your move wait on you, for `waiting` on
3
+ * GET /api/inbox/summary. It is the open-work list GET /api/work-board/activity/open serves (projectOpenWork), counting
4
+ * the items that want an answer or a look (needsInput, attention, done) and leaving out the ones still running: a run
5
+ * in progress is not waiting on you.
6
+ *
7
+ * The summary is polled, and the board is a bridge read, so one count is shared for `ttlMs` and two callers at once
8
+ * share one read. A read that fails answers null (the summary then counts the chat alone) and is not cached, so the
9
+ * next poll tries again.
10
+ */
11
+ import { projectOpenWork, readWorkJournal, type OpenWorkGroup } from './work-activity.js'
12
+
13
+ type Journal = ReturnType<typeof readWorkJournal>
14
+ type Rows = Parameters<typeof projectOpenWork>[1]
15
+
16
+ export interface YourMoveCounterDeps {
17
+ journal: () => Journal
18
+ /** The board rows, or null when the board cannot be read in time. */
19
+ rows: () => Promise<Rows | null>
20
+ now?: () => number
21
+ ttlMs?: number
22
+ }
23
+
24
+ export const YOUR_MOVE_TTL_MS = 30_000
25
+ const WAITING_GROUPS: readonly OpenWorkGroup[] = ['needsInput', 'attention', 'done']
26
+
27
+ /** The pure count: open-work items whose group waits on you. */
28
+ export function countYourMove(journal: Journal, rows: Rows, nowMs: number): number {
29
+ if (!journal.available) return 0
30
+ return projectOpenWork(journal, rows, nowMs).items.filter(item => WAITING_GROUPS.includes(item.group)).length
31
+ }
32
+
33
+ export function createYourMoveCounter(deps: YourMoveCounterDeps): () => Promise<number | null> {
34
+ const now = deps.now ?? (() => Date.now())
35
+ const ttl = deps.ttlMs ?? YOUR_MOVE_TTL_MS
36
+ let cached: { at: number; value: number } | null = null
37
+ let inflight: Promise<number | null> | null = null
38
+ return () => {
39
+ if (cached && now() - cached.at < ttl) return Promise.resolve(cached.value)
40
+ if (inflight) return inflight
41
+ inflight = (async () => {
42
+ try {
43
+ const journal = deps.journal()
44
+ if (!journal.available) return null
45
+ const rows = await deps.rows()
46
+ if (!rows) return null
47
+ const value = countYourMove(journal, rows, now())
48
+ cached = { at: now(), value }
49
+ return value
50
+ } catch {
51
+ return null
52
+ } finally {
53
+ inflight = null
54
+ }
55
+ })()
56
+ return inflight
57
+ }
58
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * 6.99.16: the COS thread (lib/cos-thread.ts). Auth comes from the parent /api mount. Every answer is
3
+ * `Cache-Control: private, no-store`. Gated exactly like the Drop it routes (routes/drops.ts): while Drop it is off,
4
+ * every route answers 404 `{ error: { code: 'drop_it_off' } }` and no file is created (the store opens on the first
5
+ * request after the feature is turned on). Advertised as `capabilities.dropIt.thread: { protocol: 1 }` while on.
6
+ *
7
+ * GET /cos-thread CosThreadAnswer: the thread and what the next Ask should do
8
+ * POST /cos-thread/new {} CosThreadAnswer (next.reason 'manual'): closes this thread, starts another
9
+ * POST /cos-thread/turn { clientId, sessionId, query, answer?, messageNo?, provider? } CosThreadAnswer
10
+ * GET /cos-thread/search ?q&limit { hits: [{ threadId, sessionId, at, query, answerHead, messageNo }], scanned }
11
+ * GET /cos-thread/threads ?limit { threads: [{ id, startedAt, endedAt, asks, segments, title }] }
12
+ */
13
+ import { Router, type Response } from 'express'
14
+ import { CosThread, CosThreadError, parseTurnInput } from '../lib/cos-thread.js'
15
+
16
+ export interface CosThreadRouterDependencies {
17
+ enabled: () => boolean
18
+ /** The store, opened on first use. Null when it cannot open. */
19
+ thread: () => CosThread | null
20
+ }
21
+
22
+ const fail = (res: Response, error: unknown) => {
23
+ if (error instanceof CosThreadError) return res.status(error.status).json({ error: { code: error.code } })
24
+ console.error('[cos-thread] request failed:', error instanceof Error ? error.name : 'error')
25
+ return res.status(500).json({ error: { code: 'cos_thread_failed' } })
26
+ }
27
+ /** A whole number in [min, max], the default when absent, or null when present and out of range. */
28
+ function intParam(raw: unknown, min: number, max: number, fallback: number): number | null {
29
+ if (raw === undefined) return fallback
30
+ if (typeof raw !== 'string' || !/^\d+$/.test(raw)) return null
31
+ const n = Number(raw)
32
+ return n >= min && n <= max ? n : null
33
+ }
34
+
35
+ export function createCosThreadRouter(deps: CosThreadRouterDependencies): Router {
36
+ const router = Router()
37
+ router.use('/cos-thread', (_req, res, next) => {
38
+ res.set('Cache-Control', 'private, no-store')
39
+ if (!deps.enabled()) return res.status(404).json({ error: { code: 'drop_it_off', message: 'Drop it is off on this server.' } })
40
+ next()
41
+ })
42
+ const withThread = (res: Response, fn: (t: CosThread) => unknown) => {
43
+ const thread = deps.thread()
44
+ if (!thread) return res.status(503).json({ error: { code: 'cos_thread_unavailable' } })
45
+ try { return fn(thread) } catch (e) { return fail(res, e) }
46
+ }
47
+
48
+ router.get('/cos-thread', (_req, res) => withThread(res, t => res.json(t.view())))
49
+
50
+ router.post('/cos-thread/new', (req, res) => {
51
+ const b = req.body
52
+ if (b !== undefined && (b === null || typeof b !== 'object' || Array.isArray(b) || Object.keys(b).length)) {
53
+ return res.status(400).json({ error: { code: 'invalid_request', message: 'Send {}.' } })
54
+ }
55
+ return withThread(res, t => res.json(t.startNew()))
56
+ })
57
+
58
+ router.post('/cos-thread/turn', (req, res) => {
59
+ const input = parseTurnInput(req.body)
60
+ if (!input) return res.status(400).json({ error: { code: 'invalid_turn' } })
61
+ return withThread(res, t => res.json(t.recordTurn(input)))
62
+ })
63
+
64
+ router.get('/cos-thread/search', (req, res) => {
65
+ const q = typeof req.query.q === 'string' ? req.query.q.trim() : ''
66
+ if (q.length < 2 || q.length > 200) return res.status(400).json({ error: { code: 'invalid_query', message: 'Search with 2 to 200 characters.' } })
67
+ const limit = intParam(req.query.limit, 1, 50, 20)
68
+ if (limit === null) return res.status(400).json({ error: { code: 'invalid_limit' } })
69
+ return withThread(res, t => res.json(t.search(q, limit)))
70
+ })
71
+
72
+ router.get('/cos-thread/threads', (req, res) => {
73
+ const limit = intParam(req.query.limit, 1, 100, 20)
74
+ if (limit === null) return res.status(400).json({ error: { code: 'invalid_limit' } })
75
+ return withThread(res, t => res.json(t.list(limit)))
76
+ })
77
+
78
+ return router
79
+ }
@@ -13,6 +13,8 @@ import {
13
13
  } from '../lib/display-bus.js'
14
14
  import { type DisplayTicketVerdict, explainDisplayTicket } from '../lib/display-ticket.js'
15
15
  import { timingSafeTokenEqual } from '../lib/token-auth.js'
16
+ import { isWebSessionAuthorized } from '../lib/web-session-auth.js'
17
+ import { getTailnetSelfName } from '../lib/tailnet-self-name.js'
16
18
 
17
19
  export const displayRouter = Router()
18
20
 
@@ -183,7 +185,15 @@ export function __resetDisplayStreamLogForTests(): void {
183
185
  * counter with connections that were never the problem.
184
186
  */
185
187
  function headerAuthorized(req: Request): boolean {
186
- return timingSafeTokenEqual(req.headers['x-cos-token'], process.env.COS_API_TOKEN ?? '')
188
+ const apiToken = process.env.COS_API_TOKEN ?? ''
189
+ if (timingSafeTokenEqual(req.headers['x-cos-token'], apiToken)) return true
190
+ // A COS Control Web browser: same origin rules as the /api gate (review note).
191
+ const port = Number.parseInt(process.env.PORT ?? '3141', 10)
192
+ const httpsPort = Number.parseInt(process.env.HTTPS_PORT ?? '3143', 10)
193
+ return isWebSessionAuthorized(req, apiToken, {
194
+ selfDnsName: getTailnetSelfName().currentSelfDnsName(),
195
+ allowedPorts: [port, httpsPort],
196
+ })
187
197
  }
188
198
 
189
199
  /**
@@ -0,0 +1,262 @@
1
+ /**
2
+ * Drop it P1 routes (plan sections 1 to 3 and 6). Auth comes from the parent /api mount. All answers are
3
+ * `Cache-Control: private, no-store`.
4
+ *
5
+ * Off by default (lib/drop-settings.ts): while off, every route here except /drops/settings answers
6
+ * 404 `{ error: { code: 'drop_it_off' } }`, and no drop or feed file is created. The stores open on the first request
7
+ * after the feature is turned on. GET /api/sessions/today/all-messages, /live-chats and /api/message-counter are not
8
+ * touched by anything here.
9
+ *
10
+ * GET /drops/settings { protocol, enabled, source, router }
11
+ * PUT /drops/settings { enabled } same answer
12
+ * POST /drops { dropId, text, source, hint?: { sessionId, label? }, file?, attachmentIds? } DropAnswer
13
+ * 6.99.13: attachmentIds, up to 5 fresh uploads (POST /api/media, /api/media/file), kept with the
14
+ * drop (lib/drop-attachments.ts). Words are still required. Advertised as dropIt.attachments.
15
+ * GET /drops ?q&kind&status&limit&before History
16
+ * GET /drops/:id DropAnswer
17
+ * POST /drops/:id/undo DropAnswer
18
+ * POST /drops/:id/move { dest, target?: { id, label, domain? } } DropAnswer
19
+ * POST /drops/:id/ask { clientJobId, generation } DropAnswer
20
+ * POST /drops/:id/sent DropAnswer
21
+ * GET /inbox/summary { unread, waiting, lastLine, lastAt, version }
22
+ * GET /inbox/feed ?after&limit { items, version, more }
23
+ * POST /inbox/read { through } { unread, waiting, version }
24
+ * POST /inbox/action { itemId, action: 'choose:<id>' | 'undo' } DropAnswer
25
+ * POST /inbox/action { itemId, action: 'dismiss' } { unread, waiting, version }
26
+ * P2:
27
+ * POST /inbox/action { itemId, action: 'when:tomorrow_9' | 'when:monday_9' | 'when:<ISO>' } DropAnswer
28
+ * POST /inbox/action { itemId, action: 'reminder_done' | 'snooze_1h' | 'snooze_tomorrow' } { item, drop, unread, waiting, version }
29
+ * GET /inbox/banners ?bootId&seq { bootId, seq, banners }
30
+ * GET /drops/reminders { reminders: [{ id, at, title, notifyId }] }
31
+ * POST /drops/search { q } DropSearchResult
32
+ *
33
+ * `waiting` (6.99.10, plan section 12) is a plain whole number: live questions, live items marked needsReply, and the
34
+ * Work items in Your move. It clears only when answered, done or dismissed; `unread` clears when the chat is opened.
35
+ */
36
+ import { Router, type Response } from 'express'
37
+ import { DropError, DropService, type DropInput, type HistoryStatus } from '../lib/drop-service.js'
38
+ import { DROP_DESTS, DROP_KINDS, DropStore, DropStoreError, InboxFeedStore, parseLink, type DropDest, type DropKind } from '../lib/drop-store.js'
39
+ import { dropItCapability, dropItEnabled, dropItEnvValue, writeDropItSetting } from '../lib/drop-settings.js'
40
+ import { dropDailyCap, dropRouterEnabled } from '../lib/drop-router.js'
41
+ import type { DropP2Runtime } from '../lib/drop-p2-runtime.js'
42
+ import type { DropHistorySearcher } from '../lib/drop-history-search.js'
43
+ import { pendingReminders } from '../lib/reminder-scheduler.js'
44
+ import { unavailable } from '../lib/work-search.js'
45
+ import { mergeP2Settings, P2_DEFAULTS, P2_SETTINGS_PROTOCOL, type P2Settings } from '../lib/drop-p2-settings.js'
46
+
47
+ export interface DropsRouterDependencies {
48
+ /** The service, opened on first use. Null when its stores cannot open (locked, unsafe, unreadable). */
49
+ service: () => DropService | null
50
+ enabled: () => boolean
51
+ routerStatus?: () => { on: boolean; dailyCap: number; usedToday?: number; breakerOpenUntil?: string | null }
52
+ writeSetting?: (enabled: boolean) => void
53
+ /**
54
+ * 6.99.10: how many Work items are in Your move, or null when the board cannot be read. Counted into `waiting`.
55
+ * Optional: without it `waiting` counts the chat alone. It must answer quickly (index.ts caches it).
56
+ */
57
+ yourMove?: () => Promise<number | null>
58
+ /** P2: the pings, reminders and follow-ups runtime, when it is running. Its banner outbox is what Control reads. */
59
+ p2?: () => DropP2Runtime | null
60
+ /** P2: History search by meaning. Absent: the search answers search_off and word search carries on. */
61
+ search?: () => DropHistorySearcher | null
62
+ /** 6.99.11: P2 settings (drop-p2-settings.ts createP2SettingsReader). Absent: GET answers the defaults, PUT 503. */
63
+ p2Settings?: { read(): P2Settings; write(next: P2Settings): P2Settings }
64
+ }
65
+ const P2_SECTIONS = new Set(['pings', 'followUps', 'checkin', 'reminders', 'banners', 'steer'])
66
+
67
+ /** How long the summary waits for the Your move count before answering without it. */
68
+ export const YOUR_MOVE_WAIT_MS = 1_500
69
+ async function boundedYourMove(read: (() => Promise<number | null>) | undefined): Promise<number | null> {
70
+ if (!read) return null
71
+ let timer: ReturnType<typeof setTimeout> | undefined
72
+ try {
73
+ return await Promise.race([read().catch(() => null), new Promise<null>(done => { timer = setTimeout(() => done(null), YOUR_MOVE_WAIT_MS) })])
74
+ } finally { if (timer) clearTimeout(timer) }
75
+ }
76
+
77
+ const HISTORY_STATUSES: readonly HistoryStatus[] = ['open', 'in_progress', 'done', 'undone']
78
+ const send = (res: Response, error: unknown) => {
79
+ if (error instanceof DropError || error instanceof DropStoreError) {
80
+ return res.status(error.status).json({ error: { code: error.code, message: error.message === error.code ? undefined : error.message } })
81
+ }
82
+ console.error('[drops] request failed:', error instanceof Error ? error.name : 'error')
83
+ return res.status(500).json({ error: { code: 'drop_failed' } })
84
+ }
85
+ const bad = (res: Response, code: string, message?: string) => res.status(400).json({ error: { code, ...(message ? { message } : {}) } })
86
+ const isRecord = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v)
87
+
88
+ /** Opens the two stores once, on the first request that needs them. */
89
+ export function lazyDropService(open: () => DropService): () => DropService | null {
90
+ let service: DropService | null = null
91
+ return () => {
92
+ if (service) return service
93
+ try { service = open() } catch (e) {
94
+ console.error('[drops] store unavailable:', e instanceof DropStoreError ? e.code : e instanceof Error ? e.name : 'error')
95
+ return null
96
+ }
97
+ return service
98
+ }
99
+ }
100
+ export function openDropService(dataRoot: (part: string) => string, deps: Omit<ConstructorParameters<typeof DropService>[0], 'drops' | 'feed'>): DropService {
101
+ const drops = new DropStore(dataRoot('drops'))
102
+ let feed: InboxFeedStore
103
+ try { feed = new InboxFeedStore(dataRoot('inbox')) } catch (e) { drops.close(); throw e }
104
+ return new DropService({ ...deps, drops, feed })
105
+ }
106
+
107
+ export function createDropsRouter(deps: DropsRouterDependencies): Router {
108
+ const router = Router()
109
+ router.use(['/drops', '/inbox'], (_req, res, next) => { res.set('Cache-Control', 'private, no-store'); next() })
110
+
111
+ const settingsView = () => ({ ...dropItCapability(), router: deps.routerStatus?.() ?? { on: dropRouterEnabled(), dailyCap: dropDailyCap() } })
112
+ router.get('/drops/settings', (_req, res) => res.json(settingsView()))
113
+ router.put('/drops/settings', (req, res) => {
114
+ if (!isRecord(req.body) || typeof req.body.enabled !== 'boolean' || Object.keys(req.body).length !== 1) return bad(res, 'invalid_settings', 'Send { "enabled": true } or { "enabled": false }.')
115
+ try { (deps.writeSetting ?? writeDropItSetting)(req.body.enabled) } catch {
116
+ return res.status(503).json({ error: { code: 'settings_unwritable' } })
117
+ }
118
+ // An environment value wins (COS_DROP_IT); the answer says so through `source`.
119
+ return res.json({ ...settingsView(), ...(dropItEnvValue() !== null ? { savedButEnvWins: true } : {}) })
120
+ })
121
+
122
+ // 6.99.11 P2 settings, readable and writable while Drop it is off (as the switch is), so they can be set first.
123
+ const p2View = (settings: P2Settings) => ({ protocol: P2_SETTINGS_PROTOCOL, settings, defaults: P2_DEFAULTS })
124
+ router.get('/drops/settings/p2', (_req, res) => res.json(p2View(deps.p2Settings?.read() ?? P2_DEFAULTS)))
125
+ router.put('/drops/settings/p2', (req, res) => {
126
+ const body = req.body
127
+ if (!isRecord(body) || !Object.keys(body).length || Object.keys(body).some(k => !P2_SECTIONS.has(k)) || Object.values(body).some(v => !isRecord(v))) {
128
+ return bad(res, 'invalid_settings', 'Send one or more sections: pings, followUps, checkin, reminders, banners, steer.')
129
+ }
130
+ if (!deps.p2Settings) return res.status(503).json({ error: { code: 'settings_unwritable' } })
131
+ try { return res.json(p2View(deps.p2Settings.write(mergeP2Settings(deps.p2Settings.read(), body)))) } catch {
132
+ return res.status(503).json({ error: { code: 'settings_unwritable' } })
133
+ }
134
+ })
135
+
136
+ // Everything else exists only while the feature is on.
137
+ router.use(['/drops', '/inbox'], (_req, res, next) => {
138
+ if (!deps.enabled()) return res.status(404).json({ error: { code: 'drop_it_off', message: 'Drop it is off on this server.' } })
139
+ next()
140
+ })
141
+ const withService = (res: Response, fn: (s: DropService) => unknown) => {
142
+ const service = deps.service()
143
+ if (!service) return res.status(503).json({ error: { code: 'drop_store_unavailable' } })
144
+ return fn(service)
145
+ }
146
+
147
+ router.post('/drops', (req, res) => withService(res, async service => {
148
+ const b = req.body
149
+ if (!isRecord(b) || Object.keys(b).some(k => !['dropId', 'text', 'source', 'hint', 'file', 'attachmentIds'].includes(k))) return bad(res, 'invalid_drop')
150
+ try { return res.json(await service.submit(b as unknown as DropInput)) } catch (e) { return send(res, e) }
151
+ }))
152
+
153
+ router.get('/drops', (req, res) => withService(res, async service => {
154
+ const q = typeof req.query.q === 'string' ? req.query.q.trim() : undefined
155
+ const kind = req.query.kind, status = req.query.status
156
+ if (q !== undefined && q.length > 200) return bad(res, 'invalid_query')
157
+ if (kind !== undefined && !(DROP_KINDS as readonly unknown[]).includes(kind)) return bad(res, 'invalid_kind')
158
+ if (status !== undefined && !(HISTORY_STATUSES as readonly unknown[]).includes(status)) return bad(res, 'invalid_status')
159
+ const limit = req.query.limit === undefined ? undefined : Number(req.query.limit)
160
+ if (limit !== undefined && (!Number.isInteger(limit) || limit < 1 || limit > 200)) return bad(res, 'invalid_limit')
161
+ const before = typeof req.query.before === 'string' ? req.query.before : undefined
162
+ try { return res.json(await service.history({ q: q || undefined, kind: kind as DropKind | undefined, status: status as HistoryStatus | undefined, limit, before })) }
163
+ catch (e) { return send(res, e) }
164
+ }))
165
+
166
+ // P2, registered before /drops/:id so 'reminders' is never read as a drop id.
167
+ router.get('/drops/reminders', (_req, res) => withService(res, async service => {
168
+ try { return res.json({ reminders: pendingReminders(service.p2Stores().drops) }) } catch (e) { return send(res, e) }
169
+ }))
170
+ router.get('/drops/:id', (req, res) => withService(res, service => {
171
+ try { return res.json(service.get(req.params.id)) } catch (e) { return send(res, e) }
172
+ }))
173
+ router.post('/drops/:id/undo', (req, res) => withService(res, async service => {
174
+ try { return res.json(await service.undo(req.params.id)) } catch (e) { return send(res, e) }
175
+ }))
176
+ router.post('/drops/:id/move', (req, res) => withService(res, async service => {
177
+ const b = req.body
178
+ if (!isRecord(b) || !(DROP_DESTS as readonly unknown[]).includes(b.dest)) return bad(res, 'invalid_move')
179
+ let target: { id: string; label: string; domain?: string } | undefined
180
+ if (b.target !== undefined) {
181
+ const t = b.target
182
+ if (!isRecord(t)) return bad(res, 'invalid_move')
183
+ target = { id: t.id as string, label: t.label as string, ...(t.domain !== undefined ? { domain: t.domain as string } : {}) }
184
+ // The store's own link rule (length, no control characters, id shape), checked before anything is undone or written.
185
+ try { parseLink({ kind: 'card', ...target }) } catch { return bad(res, 'invalid_move', 'That destination cannot be used.') }
186
+ }
187
+ const dest = b.dest as DropDest
188
+ if ((dest === 'new_card' && !target?.domain) || ((dest === 'existing_card') && (!target?.domain)) || ((dest === 'thread' || dest === 'session') && !target)) return bad(res, 'invalid_move', 'This destination needs a target.')
189
+ // A new card takes its domain as its target id.
190
+ const resolved = dest === 'new_card' && target ? { ...target, id: target.domain! } : target
191
+ try { return res.json(await service.move(req.params.id, { dest, target: resolved })) } catch (e) { return send(res, e) }
192
+ }))
193
+ router.post('/drops/:id/ask', (req, res) => withService(res, async service => {
194
+ const b = req.body
195
+ if (!isRecord(b) || typeof b.clientJobId !== 'string' || !Number.isSafeInteger(b.generation)) return bad(res, 'invalid_ask_link')
196
+ try { return res.json(await service.linkAsk(req.params.id, b.clientJobId, b.generation as number)) } catch (e) { return send(res, e) }
197
+ }))
198
+ router.post('/drops/:id/sent', (req, res) => withService(res, async service => {
199
+ try { return res.json(await service.holdSent(req.params.id)) } catch (e) { return send(res, e) }
200
+ }))
201
+
202
+ router.get('/inbox/summary', (_req, res) => withService(res, async service => {
203
+ try { await service.syncAsks() } catch { /* a summary never fails for a lookup */ }
204
+ const yourMove = await boundedYourMove(deps.yourMove)
205
+ try { return res.json(service.summary({ yourMove })) } catch (e) { return send(res, e) }
206
+ }))
207
+ router.get('/inbox/feed', (req, res) => withService(res, async service => {
208
+ const after = typeof req.query.after === 'string' ? req.query.after : undefined
209
+ const limit = req.query.limit === undefined ? undefined : Number(req.query.limit)
210
+ if (limit !== undefined && (!Number.isInteger(limit) || limit < 1 || limit > 200)) return bad(res, 'invalid_limit')
211
+ try { await service.syncAsks() } catch { /* the chat still reads */ }
212
+ try { return res.json(service.feed({ after, limit })) } catch (e) { return send(res, e) }
213
+ }))
214
+ router.post('/inbox/read', (req, res) => withService(res, async service => {
215
+ if (!isRecord(req.body) || typeof req.body.through !== 'string') return bad(res, 'invalid_read')
216
+ let out: { unread: number; waiting: number; version: number }
217
+ try { out = service.markRead(req.body.through) } catch (e) { return send(res, e) }
218
+ // The same `waiting` the summary gives, Work's Your move included.
219
+ const yourMove = await boundedYourMove(deps.yourMove)
220
+ return res.json({ ...out, waiting: service.summary({ yourMove }).waiting })
221
+ }))
222
+ router.post('/inbox/action', (req, res) => withService(res, async service => {
223
+ const b = req.body
224
+ if (!isRecord(b) || typeof b.itemId !== 'string' || typeof b.action !== 'string') return bad(res, 'invalid_action')
225
+ try {
226
+ if (b.action.startsWith('choose:')) return res.json(await service.choose(b.itemId, b.action.slice('choose:'.length)))
227
+ if (b.action === 'undo') {
228
+ return res.json(await service.undo(service.threadOf(b.itemId)))
229
+ }
230
+ if (b.action.startsWith('when:')) return res.json(await service.setReminderTime(b.itemId, b.action.slice('when:'.length)))
231
+ if (b.action === 'reminder_done' || b.action === 'snooze_1h' || b.action === 'snooze_tomorrow') {
232
+ const out = service.reminderAction(b.itemId, b.action)
233
+ const yourMove = await boundedYourMove(deps.yourMove)
234
+ const sum = service.summary({ yourMove })
235
+ return res.json({ ...out, unread: sum.unread, waiting: sum.waiting, version: sum.version })
236
+ }
237
+ if (b.action === 'dismiss') {
238
+ const out = service.dismiss(b.itemId)
239
+ const yourMove = await boundedYourMove(deps.yourMove)
240
+ return res.json({ ...out, waiting: service.summary({ yourMove }).waiting })
241
+ }
242
+ return bad(res, 'invalid_action')
243
+ } catch (e) { return send(res, e) }
244
+ }))
245
+ // ── P2 ──
246
+ router.get('/inbox/banners', (req, res) => withService(res, async () => {
247
+ const runtime = deps.p2?.()
248
+ if (!runtime) return res.status(503).json({ error: { code: 'pings_unavailable' } })
249
+ const bootId = typeof req.query.bootId === 'string' ? req.query.bootId : undefined
250
+ const seq = typeof req.query.seq === 'string' && /^\d{1,9}$/.test(req.query.seq) ? Number(req.query.seq) : undefined
251
+ return res.json(runtime.banners({ bootId, seq }))
252
+ }))
253
+ router.post('/drops/search', (req, res) => withService(res, async service => {
254
+ const searcher = deps.search?.()
255
+ if (!searcher) return res.json(unavailable('search_off'))
256
+ const q = isRecord(req.body) ? req.body.q : undefined
257
+ try { return res.json(await searcher.search(q, service.p2Stores().drops.list())) } catch (e) { return send(res, e) }
258
+ }))
259
+ return router
260
+ }
261
+
262
+ export { dropItEnabled }
@@ -1,3 +1,4 @@
1
+ import { dropItCapability } from '../lib/drop-settings.js'
1
2
  import { phoneMicrophoneMeetingsCapability } from '../lib/phone-recording.js'
2
3
  import { macLiveVoicesSnapshot } from '../lib/mac-window-replay.js'
3
4
  import { DICTATION_CLEAN_MODELS } from '../lib/dictation-clean-models.js'
@@ -77,8 +78,10 @@ import { getLocalTtsHealth, refreshLocalTtsHealth } from '../lib/tts-local.js'
77
78
  import { liveCuesCapability } from '../lib/live-cues-capability.js'
78
79
  import { liveCuesV2Advertised } from '../lib/live-cues-v2.js'
79
80
  import { liveMeetingCapability } from '../lib/live-provisional.js'
81
+ import { TITLE_STEER_CAPABILITY } from '../lib/meeting-title-steer.js'
80
82
  import { CUE_KINDS } from '../../shared/live-cues-v2.js'
81
83
  import { tailscaleNudgeHealthField } from '../lib/tailscale-nudge.js'
84
+ import { WEB_PHONE_LINK_CAPABILITY, webControlEnabled } from './web-control.js'
82
85
 
83
86
  /** 6.86.0: `capabilities.liveCues.kinds`, the cue kinds this server can write (additive; an older server omits it, which
84
87
  * is how a client knows to expect only reinforce and challenge). Which kinds a given meeting gets is in its arm answer. */
@@ -503,6 +506,10 @@ healthRouter.get('/health', async (_req, res) => {
503
506
  // reads it before it holds a token, and an older server omits it, which is how
504
507
  // the phone knows to say "Update COS on your Mac (server 6.67 or newer)".
505
508
  pairing: { version: PAIRING_PROTOCOL_VERSION },
509
+ // Open COS on the web from the glasses app: POST /api/web-session/phone-link (routes/web-control.ts).
510
+ // Present only while COS Control Web is on, which is when the route is mounted. An older
511
+ // server omits it, and the app then hides its row.
512
+ ...(webControlEnabled() ? { webPhoneLink: WEB_PHONE_LINK_CAPABILITY } : {}),
506
513
  // Advertised on the PUBLIC health route on purpose: a client deciding whether
507
514
  // to request a display ticket may not hold a usable token yet, and an old
508
515
  // server simply omits this key, which is how a new client detects it.
@@ -527,6 +534,10 @@ healthRouter.get('/health', async (_req, res) => {
527
534
  liveCues: withCueKinds(liveCues),
528
535
  ...(liveCuesV2Advertised() ? { liveCuesV2: liveCuesV2Advertised() } : {}),
529
536
  liveMeeting: liveMeetingCapability(),
537
+ // 6.99.0: GET/PUT /api/meeting/title-steer (lib/meeting-title-steer.ts). Absent on an older server.
538
+ titleSteer: TITLE_STEER_CAPABILITY,
539
+ // Drop it P1: the pinned COS chat (routes/drops.ts). Codes only; `enabled` is false unless turned on. Absent on an older server.
540
+ dropIt: dropItCapability(),
530
541
  // 6.77.0: codes only (this route is public): the protocol and whether the switch is on.
531
542
  transcriptCleanup: transcriptCleanupAdvertised(),
532
543
  richMedia: {
@@ -708,6 +719,7 @@ healthRouter.get('/models', async (req, res) => {
708
719
  // 6.78.0 Messages steer: present only with COS_MESSAGES_STEER on and a Claude CLI that
709
720
  // takes stream-json input (probed once). Absent, the app shows today's UI.
710
721
  ...steerCapabilityField(durableJobs.enabled),
722
+ ...(durableJobs.enabled ? { maintenanceWorkflows: ['meeting-consolidation-v1'] } : {}),
711
723
  },
712
724
  // 6.52.0: the session questions API and its client contract. A client polls
713
725
  // `GET /api/session-questions?client=glasses|phone` every `pollIntervalMs`; one
@@ -736,6 +748,10 @@ healthRouter.get('/models', async (req, res) => {
736
748
  liveCues: withCueKinds(liveCuesCapability()),
737
749
  ...(liveCuesV2Advertised() ? { liveCuesV2: liveCuesV2Advertised() } : {}),
738
750
  liveMeeting: liveMeetingCapability(),
751
+ // 6.99.0: GET/PUT /api/meeting/title-steer (lib/meeting-title-steer.ts). Absent on an older server.
752
+ titleSteer: TITLE_STEER_CAPABILITY,
753
+ // Drop it P1: the pinned COS chat (routes/drops.ts). Codes only; `enabled` is false unless turned on. Absent on an older server.
754
+ dropIt: dropItCapability(),
739
755
  richMedia: {
740
756
  video: richMedia.video,
741
757
  maxPromptVisuals: MAX_MODEL_IMAGE_INPUTS,
@@ -27,7 +27,7 @@ import {
27
27
  LIVE_CUES_GENERAL_LIMIT_BOUNDS, LIVE_CUES_GENERAL_LIMIT_NAMES, LIVE_CUES_V2_LIMIT_BOUNDS, LIVE_CUES_V2_LIMIT_NAMES, generalLimitInBounds, isCueMode, isGrounding, limitInBounds,
28
28
  type LiveCuesGeneralStoredLimits, type LiveCuesV2StoredLimits,
29
29
  } from '../lib/live-cues-v2-settings.js'
30
- import { LIVE_CUES_V2_LIMITS, LiveCuesV2ArmError, liveCuesV2, liveCuesV2Advertised, liveCuesV2Enabled, liveCuesV2MeetingCounters, liveCuesV2SettingsView, peekLiveCuesV2, setLiveCuesV2Enabled } from '../lib/live-cues-v2.js'
30
+ import { LiveCuesV2ArmError, resolveLiveCuesV2Limits, liveCuesV2, liveCuesV2Advertised, liveCuesV2Enabled, liveCuesV2MeetingCounters, liveCuesV2SettingsView, peekLiveCuesV2, setLiveCuesV2Enabled } from '../lib/live-cues-v2.js'
31
31
  import { LIVE_CUE_VERDICTS_PROTOCOL, LIVE_CUE_VERDICT_LIMITS, VerdictsUnreadableError, isCueVerdict, liveCueVerdicts } from '../lib/live-cue-verdicts.js'
32
32
 
33
33
  const SESSION_ID = /^[A-Za-z0-9:_-]{3,96}$/
@@ -183,13 +183,14 @@ liveCuesRouter.get('/live-cues/history', (req, res) => {
183
183
  try {
184
184
  const verdicts = liveCueVerdicts()
185
185
  const history = verdicts.history({ days, limit, unrated: unrated === '1', ...(sessionIds.length ? { sessionIds } : {}) })
186
- const C = LIVE_CUES_V2_LIMITS
186
+ // 6.99.0: the cap as set now (Settings or env), not the built-in 25: the cap can be raised to 50, writer calls stay twice it.
187
+ const capNow = resolveLiveCuesV2Limits().maxCuesPerMeeting.value, writerCapNow = 2 * capNow
187
188
  const meetings = sessionIds.length ? Object.fromEntries(sessionIds.map(id => {
188
189
  // The engine's counters expire 24 h after a meeting; past that the log's count stands in and writerCalls is unknown.
189
190
  const counters = liveCuesV2MeetingCounters(id)
190
- return [id, { cues: counters?.cues ?? verdicts.loggedCues(id), cap: C.maxCuesPerMeeting, writerCalls: counters?.writerCalls ?? null, writerCap: C.maxWriterCallsPerMeeting }]
191
+ return [id, { cues: counters?.cues ?? verdicts.loggedCues(id), cap: capNow, writerCalls: counters?.writerCalls ?? null, writerCap: writerCapNow }]
191
192
  })) : undefined
192
- return res.json({ ...history, maxCuesPerMeeting: C.maxCuesPerMeeting, ...(meetings ? { meetings } : {}) })
193
+ return res.json({ ...history, maxCuesPerMeeting: capNow, ...(meetings ? { meetings } : {}) })
193
194
  } catch (error) {
194
195
  if (error instanceof VerdictsUnreadableError) return res.status(503).json({ error: 'the verdicts file is unreadable', reason: error.code })
195
196
  return res.status(500).json({ error: 'history_unavailable' })