@gotcos/glasses-server 6.98.0 → 6.99.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +158 -0
- package/package.json +2 -1
- package/server/index.ts +147 -9
- package/server/lib/api-auth.ts +31 -14
- package/server/lib/claude-bridge.ts +21 -2
- package/server/lib/codex-bridge.ts +21 -2
- package/server/lib/display-bus.ts +3 -0
- package/server/lib/drop-destinations.ts +134 -0
- package/server/lib/drop-history-search.ts +97 -0
- package/server/lib/drop-p2-boot.ts +60 -0
- package/server/lib/drop-p2-runtime.ts +237 -0
- package/server/lib/drop-p2-settings.ts +174 -0
- package/server/lib/drop-resolution.ts +168 -0
- package/server/lib/drop-router.ts +325 -0
- package/server/lib/drop-service.ts +739 -0
- package/server/lib/drop-settings.ts +45 -0
- package/server/lib/drop-store.ts +428 -0
- package/server/lib/drop-when.ts +265 -0
- package/server/lib/glasses-pairing.ts +15 -0
- package/server/lib/learning-needs-you.ts +111 -0
- package/server/lib/live-cues-v2-data.ts +336 -26
- package/server/lib/live-cues-v2-settings.ts +2 -1
- package/server/lib/live-cues-v2.ts +9 -5
- package/server/lib/meeting-consolidation-run.ts +169 -0
- package/server/lib/meeting-store.ts +61 -0
- package/server/lib/meeting-title-steer.ts +200 -0
- package/server/lib/network-policy.ts +84 -6
- package/server/lib/permission-broker.ts +11 -0
- package/server/lib/ping-budget.ts +149 -0
- package/server/lib/ping-engine.ts +245 -0
- package/server/lib/ping-feed-sink.ts +33 -0
- package/server/lib/ping-sources.ts +192 -0
- package/server/lib/query-job-coordinator.ts +5 -2
- package/server/lib/query-job-runtime.ts +45 -4
- package/server/lib/query-job-types.ts +10 -1
- package/server/lib/reminder-scheduler.ts +153 -0
- package/server/lib/tailnet-self-name.ts +103 -0
- package/server/lib/tailscale-nudge.ts +40 -2
- package/server/lib/task-store.ts +32 -0
- package/server/lib/web-ask-admission.ts +56 -0
- package/server/lib/web-live-chunks.ts +273 -0
- package/server/lib/web-memories-bridge.ts +323 -0
- package/server/lib/web-session-auth.ts +492 -0
- package/server/lib/web-usage-bridge.ts +154 -0
- package/server/lib/work-search.ts +14 -1
- package/server/lib/your-move-count.ts +58 -0
- package/server/routes/display.ts +11 -1
- package/server/routes/drops.ts +260 -0
- package/server/routes/health.ts +16 -0
- package/server/routes/live-cues.ts +5 -4
- package/server/routes/meeting.ts +61 -5
- package/server/routes/permission-broker.ts +23 -3
- package/server/routes/transcribe-stream.ts +34 -3
- package/server/routes/web-control.ts +569 -0
- package/server/routes/welcome-context.ts +2 -1
- package/server/web-control/app.css +519 -0
- package/server/web-control/app.js +3643 -0
- package/server/web-control/control.css +312 -0
- package/server/web-control/fonts/DMSans.ttf +0 -0
- package/server/web-control/fonts/Fraunces.ttf +0 -0
- package/server/web-control/fonts/JetBrainsMono.ttf +0 -0
- package/server/web-control/index.html +275 -0
- package/server/web-control/mark.svg +1 -0
- package/server/web-control/memories/d3.min.js +2 -0
- package/server/web-control/memories/graph-explorer.css +200 -0
- package/server/web-control/memories/graph-explorer.js +748 -0
- package/server/web-control/memories/memories-app.js +1561 -0
- package/server/web-control/memories/memories-theme.css +73 -0
- package/server/web-control/memories/memories.html +72 -0
- package/server/web-control/memories/memory-stewardship.js +63 -0
- package/server/web-control/memories/memory-workspace.js +216 -0
- package/server/web-control/panes/cos-chat.css +157 -0
- package/server/web-control/panes/cos-chat.js +973 -0
- package/server/web-control/panes/dictation.js +173 -0
- package/server/web-control/panes/meetings.css +257 -0
- package/server/web-control/panes/meetings.js +2264 -0
- package/server/web-control/panes/messages.js +711 -0
- package/server/web-control/panes/questions.css +32 -0
- package/server/web-control/panes/questions.js +275 -0
- package/server/web-control/panes/sessions.css +452 -0
- package/server/web-control/panes/settings.js +292 -0
- package/server/web-control/panes/speakers.css +269 -0
- package/server/web-control/panes/speakers.js +2755 -0
- package/server/web-control/panes/threads.js +200 -0
- package/server/web-control/panes/work.css +261 -0
- package/server/web-control/panes/work.js +1667 -0
- package/server/web-control/shell.js +294 -0
- package/server/web-control/theme.js +25 -0
- package/server/web-control/vendor/qrcode-encoder.js +10 -0
|
@@ -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
|
+
}
|
package/server/routes/display.ts
CHANGED
|
@@ -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
|
-
|
|
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,260 @@
|
|
|
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? } DropAnswer
|
|
13
|
+
* GET /drops ?q&kind&status&limit&before History
|
|
14
|
+
* GET /drops/:id DropAnswer
|
|
15
|
+
* POST /drops/:id/undo DropAnswer
|
|
16
|
+
* POST /drops/:id/move { dest, target?: { id, label, domain? } } DropAnswer
|
|
17
|
+
* POST /drops/:id/ask { clientJobId, generation } DropAnswer
|
|
18
|
+
* POST /drops/:id/sent DropAnswer
|
|
19
|
+
* GET /inbox/summary { unread, waiting, lastLine, lastAt, version }
|
|
20
|
+
* GET /inbox/feed ?after&limit { items, version, more }
|
|
21
|
+
* POST /inbox/read { through } { unread, waiting, version }
|
|
22
|
+
* POST /inbox/action { itemId, action: 'choose:<id>' | 'undo' } DropAnswer
|
|
23
|
+
* POST /inbox/action { itemId, action: 'dismiss' } { unread, waiting, version }
|
|
24
|
+
* P2:
|
|
25
|
+
* POST /inbox/action { itemId, action: 'when:tomorrow_9' | 'when:monday_9' | 'when:<ISO>' } DropAnswer
|
|
26
|
+
* POST /inbox/action { itemId, action: 'reminder_done' | 'snooze_1h' | 'snooze_tomorrow' } { item, drop, unread, waiting, version }
|
|
27
|
+
* GET /inbox/banners ?bootId&seq { bootId, seq, banners }
|
|
28
|
+
* GET /drops/reminders { reminders: [{ id, at, title, notifyId }] }
|
|
29
|
+
* POST /drops/search { q } DropSearchResult
|
|
30
|
+
*
|
|
31
|
+
* `waiting` (6.99.10, plan section 12) is a plain whole number: live questions, live items marked needsReply, and the
|
|
32
|
+
* Work items in Your move. It clears only when answered, done or dismissed; `unread` clears when the chat is opened.
|
|
33
|
+
*/
|
|
34
|
+
import { Router, type Response } from 'express'
|
|
35
|
+
import { DropError, DropService, type DropInput, type HistoryStatus } from '../lib/drop-service.js'
|
|
36
|
+
import { DROP_DESTS, DROP_KINDS, DropStore, DropStoreError, InboxFeedStore, parseLink, type DropDest, type DropKind } from '../lib/drop-store.js'
|
|
37
|
+
import { dropItCapability, dropItEnabled, dropItEnvValue, writeDropItSetting } from '../lib/drop-settings.js'
|
|
38
|
+
import { dropDailyCap, dropRouterEnabled } from '../lib/drop-router.js'
|
|
39
|
+
import type { DropP2Runtime } from '../lib/drop-p2-runtime.js'
|
|
40
|
+
import type { DropHistorySearcher } from '../lib/drop-history-search.js'
|
|
41
|
+
import { pendingReminders } from '../lib/reminder-scheduler.js'
|
|
42
|
+
import { unavailable } from '../lib/work-search.js'
|
|
43
|
+
import { mergeP2Settings, P2_DEFAULTS, P2_SETTINGS_PROTOCOL, type P2Settings } from '../lib/drop-p2-settings.js'
|
|
44
|
+
|
|
45
|
+
export interface DropsRouterDependencies {
|
|
46
|
+
/** The service, opened on first use. Null when its stores cannot open (locked, unsafe, unreadable). */
|
|
47
|
+
service: () => DropService | null
|
|
48
|
+
enabled: () => boolean
|
|
49
|
+
routerStatus?: () => { on: boolean; dailyCap: number; usedToday?: number; breakerOpenUntil?: string | null }
|
|
50
|
+
writeSetting?: (enabled: boolean) => void
|
|
51
|
+
/**
|
|
52
|
+
* 6.99.10: how many Work items are in Your move, or null when the board cannot be read. Counted into `waiting`.
|
|
53
|
+
* Optional: without it `waiting` counts the chat alone. It must answer quickly (index.ts caches it).
|
|
54
|
+
*/
|
|
55
|
+
yourMove?: () => Promise<number | null>
|
|
56
|
+
/** P2: the pings, reminders and follow-ups runtime, when it is running. Its banner outbox is what Control reads. */
|
|
57
|
+
p2?: () => DropP2Runtime | null
|
|
58
|
+
/** P2: History search by meaning. Absent: the search answers search_off and word search carries on. */
|
|
59
|
+
search?: () => DropHistorySearcher | null
|
|
60
|
+
/** 6.99.11: P2 settings (drop-p2-settings.ts createP2SettingsReader). Absent: GET answers the defaults, PUT 503. */
|
|
61
|
+
p2Settings?: { read(): P2Settings; write(next: P2Settings): P2Settings }
|
|
62
|
+
}
|
|
63
|
+
const P2_SECTIONS = new Set(['pings', 'followUps', 'checkin', 'reminders', 'banners', 'steer'])
|
|
64
|
+
|
|
65
|
+
/** How long the summary waits for the Your move count before answering without it. */
|
|
66
|
+
export const YOUR_MOVE_WAIT_MS = 1_500
|
|
67
|
+
async function boundedYourMove(read: (() => Promise<number | null>) | undefined): Promise<number | null> {
|
|
68
|
+
if (!read) return null
|
|
69
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
70
|
+
try {
|
|
71
|
+
return await Promise.race([read().catch(() => null), new Promise<null>(done => { timer = setTimeout(() => done(null), YOUR_MOVE_WAIT_MS) })])
|
|
72
|
+
} finally { if (timer) clearTimeout(timer) }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const HISTORY_STATUSES: readonly HistoryStatus[] = ['open', 'in_progress', 'done', 'undone']
|
|
76
|
+
const send = (res: Response, error: unknown) => {
|
|
77
|
+
if (error instanceof DropError || error instanceof DropStoreError) {
|
|
78
|
+
return res.status(error.status).json({ error: { code: error.code, message: error.message === error.code ? undefined : error.message } })
|
|
79
|
+
}
|
|
80
|
+
console.error('[drops] request failed:', error instanceof Error ? error.name : 'error')
|
|
81
|
+
return res.status(500).json({ error: { code: 'drop_failed' } })
|
|
82
|
+
}
|
|
83
|
+
const bad = (res: Response, code: string, message?: string) => res.status(400).json({ error: { code, ...(message ? { message } : {}) } })
|
|
84
|
+
const isRecord = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v)
|
|
85
|
+
|
|
86
|
+
/** Opens the two stores once, on the first request that needs them. */
|
|
87
|
+
export function lazyDropService(open: () => DropService): () => DropService | null {
|
|
88
|
+
let service: DropService | null = null
|
|
89
|
+
return () => {
|
|
90
|
+
if (service) return service
|
|
91
|
+
try { service = open() } catch (e) {
|
|
92
|
+
console.error('[drops] store unavailable:', e instanceof DropStoreError ? e.code : e instanceof Error ? e.name : 'error')
|
|
93
|
+
return null
|
|
94
|
+
}
|
|
95
|
+
return service
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
export function openDropService(dataRoot: (part: string) => string, deps: Omit<ConstructorParameters<typeof DropService>[0], 'drops' | 'feed'>): DropService {
|
|
99
|
+
const drops = new DropStore(dataRoot('drops'))
|
|
100
|
+
let feed: InboxFeedStore
|
|
101
|
+
try { feed = new InboxFeedStore(dataRoot('inbox')) } catch (e) { drops.close(); throw e }
|
|
102
|
+
return new DropService({ ...deps, drops, feed })
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function createDropsRouter(deps: DropsRouterDependencies): Router {
|
|
106
|
+
const router = Router()
|
|
107
|
+
router.use(['/drops', '/inbox'], (_req, res, next) => { res.set('Cache-Control', 'private, no-store'); next() })
|
|
108
|
+
|
|
109
|
+
const settingsView = () => ({ ...dropItCapability(), router: deps.routerStatus?.() ?? { on: dropRouterEnabled(), dailyCap: dropDailyCap() } })
|
|
110
|
+
router.get('/drops/settings', (_req, res) => res.json(settingsView()))
|
|
111
|
+
router.put('/drops/settings', (req, res) => {
|
|
112
|
+
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 }.')
|
|
113
|
+
try { (deps.writeSetting ?? writeDropItSetting)(req.body.enabled) } catch {
|
|
114
|
+
return res.status(503).json({ error: { code: 'settings_unwritable' } })
|
|
115
|
+
}
|
|
116
|
+
// An environment value wins (COS_DROP_IT); the answer says so through `source`.
|
|
117
|
+
return res.json({ ...settingsView(), ...(dropItEnvValue() !== null ? { savedButEnvWins: true } : {}) })
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
// 6.99.11 P2 settings, readable and writable while Drop it is off (as the switch is), so they can be set first.
|
|
121
|
+
const p2View = (settings: P2Settings) => ({ protocol: P2_SETTINGS_PROTOCOL, settings, defaults: P2_DEFAULTS })
|
|
122
|
+
router.get('/drops/settings/p2', (_req, res) => res.json(p2View(deps.p2Settings?.read() ?? P2_DEFAULTS)))
|
|
123
|
+
router.put('/drops/settings/p2', (req, res) => {
|
|
124
|
+
const body = req.body
|
|
125
|
+
if (!isRecord(body) || !Object.keys(body).length || Object.keys(body).some(k => !P2_SECTIONS.has(k)) || Object.values(body).some(v => !isRecord(v))) {
|
|
126
|
+
return bad(res, 'invalid_settings', 'Send one or more sections: pings, followUps, checkin, reminders, banners, steer.')
|
|
127
|
+
}
|
|
128
|
+
if (!deps.p2Settings) return res.status(503).json({ error: { code: 'settings_unwritable' } })
|
|
129
|
+
try { return res.json(p2View(deps.p2Settings.write(mergeP2Settings(deps.p2Settings.read(), body)))) } catch {
|
|
130
|
+
return res.status(503).json({ error: { code: 'settings_unwritable' } })
|
|
131
|
+
}
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
// Everything else exists only while the feature is on.
|
|
135
|
+
router.use(['/drops', '/inbox'], (_req, res, next) => {
|
|
136
|
+
if (!deps.enabled()) return res.status(404).json({ error: { code: 'drop_it_off', message: 'Drop it is off on this server.' } })
|
|
137
|
+
next()
|
|
138
|
+
})
|
|
139
|
+
const withService = (res: Response, fn: (s: DropService) => unknown) => {
|
|
140
|
+
const service = deps.service()
|
|
141
|
+
if (!service) return res.status(503).json({ error: { code: 'drop_store_unavailable' } })
|
|
142
|
+
return fn(service)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
router.post('/drops', (req, res) => withService(res, async service => {
|
|
146
|
+
const b = req.body
|
|
147
|
+
if (!isRecord(b) || Object.keys(b).some(k => !['dropId', 'text', 'source', 'hint', 'file'].includes(k))) return bad(res, 'invalid_drop')
|
|
148
|
+
try { return res.json(await service.submit(b as unknown as DropInput)) } catch (e) { return send(res, e) }
|
|
149
|
+
}))
|
|
150
|
+
|
|
151
|
+
router.get('/drops', (req, res) => withService(res, async service => {
|
|
152
|
+
const q = typeof req.query.q === 'string' ? req.query.q.trim() : undefined
|
|
153
|
+
const kind = req.query.kind, status = req.query.status
|
|
154
|
+
if (q !== undefined && q.length > 200) return bad(res, 'invalid_query')
|
|
155
|
+
if (kind !== undefined && !(DROP_KINDS as readonly unknown[]).includes(kind)) return bad(res, 'invalid_kind')
|
|
156
|
+
if (status !== undefined && !(HISTORY_STATUSES as readonly unknown[]).includes(status)) return bad(res, 'invalid_status')
|
|
157
|
+
const limit = req.query.limit === undefined ? undefined : Number(req.query.limit)
|
|
158
|
+
if (limit !== undefined && (!Number.isInteger(limit) || limit < 1 || limit > 200)) return bad(res, 'invalid_limit')
|
|
159
|
+
const before = typeof req.query.before === 'string' ? req.query.before : undefined
|
|
160
|
+
try { return res.json(await service.history({ q: q || undefined, kind: kind as DropKind | undefined, status: status as HistoryStatus | undefined, limit, before })) }
|
|
161
|
+
catch (e) { return send(res, e) }
|
|
162
|
+
}))
|
|
163
|
+
|
|
164
|
+
// P2, registered before /drops/:id so 'reminders' is never read as a drop id.
|
|
165
|
+
router.get('/drops/reminders', (_req, res) => withService(res, async service => {
|
|
166
|
+
try { return res.json({ reminders: pendingReminders(service.p2Stores().drops) }) } catch (e) { return send(res, e) }
|
|
167
|
+
}))
|
|
168
|
+
router.get('/drops/:id', (req, res) => withService(res, service => {
|
|
169
|
+
try { return res.json(service.get(req.params.id)) } catch (e) { return send(res, e) }
|
|
170
|
+
}))
|
|
171
|
+
router.post('/drops/:id/undo', (req, res) => withService(res, async service => {
|
|
172
|
+
try { return res.json(await service.undo(req.params.id)) } catch (e) { return send(res, e) }
|
|
173
|
+
}))
|
|
174
|
+
router.post('/drops/:id/move', (req, res) => withService(res, async service => {
|
|
175
|
+
const b = req.body
|
|
176
|
+
if (!isRecord(b) || !(DROP_DESTS as readonly unknown[]).includes(b.dest)) return bad(res, 'invalid_move')
|
|
177
|
+
let target: { id: string; label: string; domain?: string } | undefined
|
|
178
|
+
if (b.target !== undefined) {
|
|
179
|
+
const t = b.target
|
|
180
|
+
if (!isRecord(t)) return bad(res, 'invalid_move')
|
|
181
|
+
target = { id: t.id as string, label: t.label as string, ...(t.domain !== undefined ? { domain: t.domain as string } : {}) }
|
|
182
|
+
// The store's own link rule (length, no control characters, id shape), checked before anything is undone or written.
|
|
183
|
+
try { parseLink({ kind: 'card', ...target }) } catch { return bad(res, 'invalid_move', 'That destination cannot be used.') }
|
|
184
|
+
}
|
|
185
|
+
const dest = b.dest as DropDest
|
|
186
|
+
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.')
|
|
187
|
+
// A new card takes its domain as its target id.
|
|
188
|
+
const resolved = dest === 'new_card' && target ? { ...target, id: target.domain! } : target
|
|
189
|
+
try { return res.json(await service.move(req.params.id, { dest, target: resolved })) } catch (e) { return send(res, e) }
|
|
190
|
+
}))
|
|
191
|
+
router.post('/drops/:id/ask', (req, res) => withService(res, async service => {
|
|
192
|
+
const b = req.body
|
|
193
|
+
if (!isRecord(b) || typeof b.clientJobId !== 'string' || !Number.isSafeInteger(b.generation)) return bad(res, 'invalid_ask_link')
|
|
194
|
+
try { return res.json(await service.linkAsk(req.params.id, b.clientJobId, b.generation as number)) } catch (e) { return send(res, e) }
|
|
195
|
+
}))
|
|
196
|
+
router.post('/drops/:id/sent', (req, res) => withService(res, async service => {
|
|
197
|
+
try { return res.json(await service.holdSent(req.params.id)) } catch (e) { return send(res, e) }
|
|
198
|
+
}))
|
|
199
|
+
|
|
200
|
+
router.get('/inbox/summary', (_req, res) => withService(res, async service => {
|
|
201
|
+
try { await service.syncAsks() } catch { /* a summary never fails for a lookup */ }
|
|
202
|
+
const yourMove = await boundedYourMove(deps.yourMove)
|
|
203
|
+
try { return res.json(service.summary({ yourMove })) } catch (e) { return send(res, e) }
|
|
204
|
+
}))
|
|
205
|
+
router.get('/inbox/feed', (req, res) => withService(res, async service => {
|
|
206
|
+
const after = typeof req.query.after === 'string' ? req.query.after : undefined
|
|
207
|
+
const limit = req.query.limit === undefined ? undefined : Number(req.query.limit)
|
|
208
|
+
if (limit !== undefined && (!Number.isInteger(limit) || limit < 1 || limit > 200)) return bad(res, 'invalid_limit')
|
|
209
|
+
try { await service.syncAsks() } catch { /* the chat still reads */ }
|
|
210
|
+
try { return res.json(service.feed({ after, limit })) } catch (e) { return send(res, e) }
|
|
211
|
+
}))
|
|
212
|
+
router.post('/inbox/read', (req, res) => withService(res, async service => {
|
|
213
|
+
if (!isRecord(req.body) || typeof req.body.through !== 'string') return bad(res, 'invalid_read')
|
|
214
|
+
let out: { unread: number; waiting: number; version: number }
|
|
215
|
+
try { out = service.markRead(req.body.through) } catch (e) { return send(res, e) }
|
|
216
|
+
// The same `waiting` the summary gives, Work's Your move included.
|
|
217
|
+
const yourMove = await boundedYourMove(deps.yourMove)
|
|
218
|
+
return res.json({ ...out, waiting: service.summary({ yourMove }).waiting })
|
|
219
|
+
}))
|
|
220
|
+
router.post('/inbox/action', (req, res) => withService(res, async service => {
|
|
221
|
+
const b = req.body
|
|
222
|
+
if (!isRecord(b) || typeof b.itemId !== 'string' || typeof b.action !== 'string') return bad(res, 'invalid_action')
|
|
223
|
+
try {
|
|
224
|
+
if (b.action.startsWith('choose:')) return res.json(await service.choose(b.itemId, b.action.slice('choose:'.length)))
|
|
225
|
+
if (b.action === 'undo') {
|
|
226
|
+
return res.json(await service.undo(service.threadOf(b.itemId)))
|
|
227
|
+
}
|
|
228
|
+
if (b.action.startsWith('when:')) return res.json(await service.setReminderTime(b.itemId, b.action.slice('when:'.length)))
|
|
229
|
+
if (b.action === 'reminder_done' || b.action === 'snooze_1h' || b.action === 'snooze_tomorrow') {
|
|
230
|
+
const out = service.reminderAction(b.itemId, b.action)
|
|
231
|
+
const yourMove = await boundedYourMove(deps.yourMove)
|
|
232
|
+
const sum = service.summary({ yourMove })
|
|
233
|
+
return res.json({ ...out, unread: sum.unread, waiting: sum.waiting, version: sum.version })
|
|
234
|
+
}
|
|
235
|
+
if (b.action === 'dismiss') {
|
|
236
|
+
const out = service.dismiss(b.itemId)
|
|
237
|
+
const yourMove = await boundedYourMove(deps.yourMove)
|
|
238
|
+
return res.json({ ...out, waiting: service.summary({ yourMove }).waiting })
|
|
239
|
+
}
|
|
240
|
+
return bad(res, 'invalid_action')
|
|
241
|
+
} catch (e) { return send(res, e) }
|
|
242
|
+
}))
|
|
243
|
+
// ── P2 ──
|
|
244
|
+
router.get('/inbox/banners', (req, res) => withService(res, async () => {
|
|
245
|
+
const runtime = deps.p2?.()
|
|
246
|
+
if (!runtime) return res.status(503).json({ error: { code: 'pings_unavailable' } })
|
|
247
|
+
const bootId = typeof req.query.bootId === 'string' ? req.query.bootId : undefined
|
|
248
|
+
const seq = typeof req.query.seq === 'string' && /^\d{1,9}$/.test(req.query.seq) ? Number(req.query.seq) : undefined
|
|
249
|
+
return res.json(runtime.banners({ bootId, seq }))
|
|
250
|
+
}))
|
|
251
|
+
router.post('/drops/search', (req, res) => withService(res, async service => {
|
|
252
|
+
const searcher = deps.search?.()
|
|
253
|
+
if (!searcher) return res.json(unavailable('search_off'))
|
|
254
|
+
const q = isRecord(req.body) ? req.body.q : undefined
|
|
255
|
+
try { return res.json(await searcher.search(q, service.p2Stores().drops.list())) } catch (e) { return send(res, e) }
|
|
256
|
+
}))
|
|
257
|
+
return router
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export { dropItEnabled }
|
package/server/routes/health.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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' })
|
package/server/routes/meeting.ts
CHANGED
|
@@ -19,7 +19,7 @@ import { existsSync, readdirSync, readFileSync, rmSync, statSync, unlinkSync } f
|
|
|
19
19
|
import { isWorthRecovering } from '../lib/quarantine-auto-recover.js'
|
|
20
20
|
import { basename, resolve } from 'node:path'
|
|
21
21
|
import { createHash } from 'node:crypto'
|
|
22
|
-
import { Router, type RequestHandler } from 'express'
|
|
22
|
+
import { Router, type RequestHandler, type Response } from 'express'
|
|
23
23
|
import { emitDisplay } from '../lib/display-bus.js'
|
|
24
24
|
import { createStopRequestStore, isStopRequestRefusal, STOP_REQUEST_CLAIM_WINDOW_MS, type StopRequest, type StopRequestStore } from '../lib/meeting-stop-requests.js'
|
|
25
25
|
import { appendAutonomousSaveLog, getStopIntentStore, type StopIntent, type StopIntentStore } from '../lib/meeting-stop-intents.js'
|
|
@@ -153,6 +153,7 @@ import {
|
|
|
153
153
|
getSessionChunks,
|
|
154
154
|
getSessionProviderCandidates,
|
|
155
155
|
getSessionStartTime,
|
|
156
|
+
publishLiveTitleSteer,
|
|
156
157
|
getSessionTranscript,
|
|
157
158
|
getMeetingSessionStatus,
|
|
158
159
|
getStrandedCaptures,
|
|
@@ -175,6 +176,7 @@ import {
|
|
|
175
176
|
import { derivedSourcesFor, findDerivedRecord } from '../lib/imported-library-rows.js'
|
|
176
177
|
import { assertVoiceEvidenceSource } from '../lib/voice-evidence-guard.js'
|
|
177
178
|
import { domainForMeeting, resolveDomains } from '../lib/domains.js'
|
|
179
|
+
import { effectiveSaveTitle, getTitleSteerStore, normalizeTitleSteer, TitleSteerError, type TitleSteerStore } from '../lib/meeting-title-steer.js'
|
|
178
180
|
import { getOwnerSpeakerLabel } from '../lib/profile.js'
|
|
179
181
|
import {
|
|
180
182
|
DEATTRIBUTED_PREFIX,
|
|
@@ -319,6 +321,8 @@ interface MeetingSessionSource {
|
|
|
319
321
|
delete(sessionId: string, options?: { preserveAudio?: boolean }): void
|
|
320
322
|
/** 6.53.3: live, closed within its retention, or unknown (`getMeetingSessionStatus`). */
|
|
321
323
|
getState?(sessionId: string): 'active' | 'closed' | 'missing'
|
|
324
|
+
/** 6.99.0: write a changed title steer into the live session's record (transcribe-stream publishLiveTitleSteer). */
|
|
325
|
+
publishTitleSteer?(sessionId: string): boolean
|
|
322
326
|
/** 6.81.0: the received/ASR ledger the server's own save of a stopped meeting reads. */
|
|
323
327
|
getLedger?(sessionId: string): SessionLedger
|
|
324
328
|
}
|
|
@@ -340,6 +344,8 @@ function defaultSessionLedger(sessionId: string): SessionLedger {
|
|
|
340
344
|
|
|
341
345
|
export interface MeetingRouteDependencies {
|
|
342
346
|
store?: MeetingStore
|
|
347
|
+
/** 6.99.0: the live title steer ledger (lib/meeting-title-steer.ts). Tests inject one on a scratch path. */
|
|
348
|
+
titleSteer?: TitleSteerStore
|
|
343
349
|
/** 6.93.0: is any meeting capture (glasses, Mac or iPhone) live right now? Assigning a Mac voice to a profile is refused while one is. */
|
|
344
350
|
captureActive?: () => boolean
|
|
345
351
|
sessions?: MeetingSessionSource
|
|
@@ -656,6 +662,7 @@ const defaultSessionSource: MeetingSessionSource = {
|
|
|
656
662
|
moveAudioToPending: moveSessionAudioToPending,
|
|
657
663
|
delete: deleteSession,
|
|
658
664
|
getState: defaultSessionState,
|
|
665
|
+
publishTitleSteer: publishLiveTitleSteer,
|
|
659
666
|
}
|
|
660
667
|
|
|
661
668
|
function countWords(text: string): number {
|
|
@@ -794,6 +801,7 @@ const macAssignInFlight = new Set<string>()
|
|
|
794
801
|
|
|
795
802
|
export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router {
|
|
796
803
|
const store = deps.store ?? getMeetingStore()
|
|
804
|
+
const titleSteer = deps.titleSteer ?? getTitleSteerStore()
|
|
797
805
|
const sessions = deps.sessions ?? defaultSessionSource
|
|
798
806
|
const runBatch = deps.runBatch ?? runMeetingBatchPipeline
|
|
799
807
|
const scheduleBackground = deps.scheduleBackground ?? (task => { void task })
|
|
@@ -886,6 +894,43 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
886
894
|
})
|
|
887
895
|
autoDeps.onCreate?.(autonomousFinalizer)
|
|
888
896
|
|
|
897
|
+
// ── 6.99.0: title steer ─────────────────────────────────────────────────────────────
|
|
898
|
+
// A name typed in COS Control while a Mac recording or glasses meeting is live. It is a hint the title model receives
|
|
899
|
+
// at save (and the title itself when the save sends none), never the final word. See lib/meeting-title-steer.ts.
|
|
900
|
+
// The title is never logged here or anywhere: names and companies. Length only.
|
|
901
|
+
const steerFailure = (res: Response, error: unknown, sessionId: string) => {
|
|
902
|
+
if (error instanceof TitleSteerError) {
|
|
903
|
+
res.status(error.status).json({ error: error.reason, reason: error.reason, ...(error.reason === 'title_changed' ? { current: titleSteer.get(sessionId) } : {}) })
|
|
904
|
+
return
|
|
905
|
+
}
|
|
906
|
+
console.error('[meeting/title-steer] Unexpected failure:', error instanceof Error ? error.name : 'unknown')
|
|
907
|
+
res.status(500).json({ error: 'title_steer_error', reason: 'title_steer_error' })
|
|
908
|
+
}
|
|
909
|
+
router.get('/meeting/title-steer', (req, res) => {
|
|
910
|
+
const sessionId = typeof req.query.sessionId === 'string' ? req.query.sessionId : ''
|
|
911
|
+
res.set('Cache-Control', 'private, no-store')
|
|
912
|
+
try { res.json(titleSteer.get(sessionId)) } catch (error) { steerFailure(res, error, sessionId) }
|
|
913
|
+
})
|
|
914
|
+
router.put('/meeting/title-steer', (req, res) => {
|
|
915
|
+
const body = (req.body ?? {}) as Record<string, unknown>
|
|
916
|
+
const sessionId = typeof body.sessionId === 'string' ? body.sessionId : ''
|
|
917
|
+
res.set('Cache-Control', 'private, no-store')
|
|
918
|
+
try {
|
|
919
|
+
// Malformed input is a 400 whether or not the session is live; only a well-formed request earns a 404/409.
|
|
920
|
+
titleSteer.get(sessionId)
|
|
921
|
+
normalizeTitleSteer(body.title)
|
|
922
|
+
if (!Number.isSafeInteger(body.expectedRevision) || (body.expectedRevision as number) < 0) throw new TitleSteerError('invalid_revision', 400)
|
|
923
|
+
if ((sessions.getState ?? defaultSessionState)(sessionId) !== 'active') throw new TitleSteerError('session_not_live', 404)
|
|
924
|
+
// The save reads the steer once, then awaits before it closes the session. A rename in that gap would answer 200 and miss the
|
|
925
|
+
// filed meeting, so it is refused: the meeting is being filed under the name it had.
|
|
926
|
+
if (savingSessions.has(sessionId)) throw new TitleSteerError('save_in_progress', 409)
|
|
927
|
+
const view = titleSteer.set(sessionId, body.title, body.expectedRevision)
|
|
928
|
+
;(sessions.publishTitleSteer ?? (() => false))(sessionId)
|
|
929
|
+
console.log(`[meeting/title-steer] ${view.title ? 'set' : 'cleared'} ${sessionId} length=${view.title ? Array.from(view.title).length : 0} revision=${view.revision}`)
|
|
930
|
+
res.json(view)
|
|
931
|
+
} catch (error) { steerFailure(res, error, sessionId) }
|
|
932
|
+
})
|
|
933
|
+
|
|
889
934
|
let recoveryPendingLogged = false
|
|
890
935
|
router.get('/meeting/sessions/:sessionId/status', (req, res) => {
|
|
891
936
|
const sessionId = String(req.params.sessionId ?? '')
|
|
@@ -963,6 +1008,8 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
963
1008
|
disposition: publicDisposition(disposition),
|
|
964
1009
|
// 6.81.0, additive: the Stop the server holds for this meeting, if any. Older clients ignore it.
|
|
965
1010
|
stopIntent: intent ? publicStopIntent(intent) : null,
|
|
1011
|
+
// 6.99.0, additive: the name typed while it recorded (null = none). Older clients ignore it.
|
|
1012
|
+
titleSteer: titleSteer.title(sessionId),
|
|
966
1013
|
})
|
|
967
1014
|
})
|
|
968
1015
|
|
|
@@ -1371,14 +1418,19 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1371
1418
|
// save — infer from the content instead of filing everything under one
|
|
1372
1419
|
// domain. Keyword scoring, no model call: this runs on every save, and a
|
|
1373
1420
|
// per-meeting LLM call would breach the recurring-caller rule.
|
|
1421
|
+
// 6.99.0: the title steer (a name typed while it recorded) is the title when the request sends none, and is filed
|
|
1422
|
+
// beside the title either way so the title model can read it. It feeds the domain through the title, as a title does.
|
|
1423
|
+
const steer = titleSteer.title(sessionId)
|
|
1424
|
+
const savedTitle = effectiveSaveTitle(body?.title, steer)
|
|
1374
1425
|
const filedDomain = domainForMeeting(
|
|
1375
1426
|
body?.domain as string | undefined,
|
|
1376
|
-
`${
|
|
1427
|
+
`${savedTitle ?? ''}\n${transcript}`,
|
|
1377
1428
|
resolveCosOperationsDir(),
|
|
1378
1429
|
)
|
|
1379
1430
|
const saved = store.save({
|
|
1380
1431
|
sessionId,
|
|
1381
|
-
title:
|
|
1432
|
+
title: savedTitle,
|
|
1433
|
+
...(steer ? { titleSteer: steer } : {}),
|
|
1382
1434
|
domain: filedDomain,
|
|
1383
1435
|
transcript: cleanFinalTranscript(transcript),
|
|
1384
1436
|
startTime,
|
|
@@ -3496,12 +3548,16 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
3496
3548
|
words: item.words,
|
|
3497
3549
|
canonical: true,
|
|
3498
3550
|
}))
|
|
3551
|
+
// 6.99.0: same rule as POST /meeting/save: the title steer names the meeting when the request does not.
|
|
3552
|
+
const steer = titleSteer.title(sessionId)
|
|
3553
|
+
const recoveredTitle = effectiveSaveTitle(body.title, steer)
|
|
3499
3554
|
const saved = store.save({
|
|
3500
3555
|
sessionId,
|
|
3501
|
-
title:
|
|
3556
|
+
title: recoveredTitle,
|
|
3557
|
+
...(steer ? { titleSteer: steer } : {}),
|
|
3502
3558
|
domain: domainForMeeting(
|
|
3503
3559
|
typeof body.domain === 'string' && body.domain.trim() ? body.domain : undefined,
|
|
3504
|
-
`${
|
|
3560
|
+
`${recoveredTitle ?? ''}\n${transcript}`,
|
|
3505
3561
|
resolveCosOperationsDir(),
|
|
3506
3562
|
),
|
|
3507
3563
|
transcript,
|
|
@@ -30,6 +30,13 @@
|
|
|
30
30
|
// so a surface that only shows a count can poll without making the Mac hold questions it
|
|
31
31
|
// cannot answer. It checks X-Cos-Token itself as well, in constant time, and a request
|
|
32
32
|
// without it is refused and never counted, whatever sits in front of this router.
|
|
33
|
+
// 6.99.12: COS Control Web reads the same list with its signed browser session (the
|
|
34
|
+
// `cos_web_session` cookie, verified by `webSessionAuthorized`, the check the /api gate runs).
|
|
35
|
+
// The hook token still opens nothing here. A browser poll counts as a live answering client
|
|
36
|
+
// only with `?client=web`, which the page sends only while it shows the cards (a visible tab);
|
|
37
|
+
// the same 30 s window as the phone. A cookie poll naming `glasses` or `phone`, or no client,
|
|
38
|
+
// never counts, and a token poll naming `web` never counts either. A browser never reports
|
|
39
|
+
// wearer presence, so it never enables the away hold.
|
|
33
40
|
// 6.60.0: a poll with `&hold=1&presenceAgeMs=<int>` (COS Glasses 6.9.563 and later) records
|
|
34
41
|
// wearer presence for the away hold instead of the 6.59.0 liveness; a `hold=1` poll without
|
|
35
42
|
// a valid `presenceAgeMs` makes no client live and enables no hold. The response adds
|
|
@@ -40,6 +47,7 @@ import { timingSafeTokenEqual } from '../lib/token-auth.js'
|
|
|
40
47
|
import {
|
|
41
48
|
ANSWERING_CLIENTS,
|
|
42
49
|
CLIENT_LIVE_WINDOW_MS,
|
|
50
|
+
WEB_ANSWERING_CLIENT,
|
|
43
51
|
SESSION_QUESTIONS_PROTOCOL_VERSION,
|
|
44
52
|
type BrokerAdmission,
|
|
45
53
|
type HookReplyChannel,
|
|
@@ -138,8 +146,13 @@ export function createPermissionBrokerHookRouter(deps: PermissionBrokerHookRoute
|
|
|
138
146
|
|
|
139
147
|
export interface SessionQuestionsRouterDeps {
|
|
140
148
|
broker: PermissionBroker
|
|
141
|
-
/** The pairing token (X-Cos-Token). A poll counts as a live client only with it. */
|
|
149
|
+
/** The pairing token (X-Cos-Token). A poll counts as a live client only with it, or with the web session below. */
|
|
142
150
|
apiToken: () => string
|
|
151
|
+
/**
|
|
152
|
+
* 6.99.12: whether this request carries a valid COS Control Web session (the cookie, its epoch, and the origin
|
|
153
|
+
* rules: `isWebSessionAuthorized` in production). Absent: only the token opens the list, as before.
|
|
154
|
+
*/
|
|
155
|
+
webSessionAuthorized?: (req: Request) => boolean
|
|
143
156
|
/** Where a counted poll is recorded; `noteQuestionsPoll` in production. */
|
|
144
157
|
notePoll?: (at: number, poll: QuestionsPollFacts) => void
|
|
145
158
|
now?: () => number
|
|
@@ -171,14 +184,21 @@ export function createSessionQuestionsRouter(deps: SessionQuestionsRouterDeps):
|
|
|
171
184
|
res.set('Cache-Control', 'private, no-store')
|
|
172
185
|
let expected = ''
|
|
173
186
|
try { expected = deps.apiToken() } catch { expected = '' }
|
|
187
|
+
const byToken = timingSafeTokenEqual(req.headers['x-cos-token'], expected)
|
|
188
|
+
let byWebSession = false
|
|
189
|
+
if (!byToken && deps.webSessionAuthorized) {
|
|
190
|
+
try { byWebSession = deps.webSessionAuthorized(req) === true } catch { byWebSession = false }
|
|
191
|
+
}
|
|
174
192
|
// A hook token, or nothing: refused, and never a live client.
|
|
175
|
-
if (!
|
|
193
|
+
if (!byToken && !byWebSession) {
|
|
176
194
|
res.status(401).json({ error: 'unauthorized' })
|
|
177
195
|
return
|
|
178
196
|
}
|
|
179
197
|
// Only a client that says it can answer (and shows the cards) counts as live.
|
|
180
198
|
const client = typeof req.query.client === 'string' ? req.query.client : ''
|
|
181
|
-
if (ANSWERING_CLIENTS.has(client)) notePoll(now(), questionsPollFacts(req.query as Record<string, unknown>))
|
|
199
|
+
if (byToken && ANSWERING_CLIENTS.has(client)) notePoll(now(), questionsPollFacts(req.query as Record<string, unknown>))
|
|
200
|
+
// 6.99.12: a browser counts only as `web`, and never with presence (no away hold from a tab).
|
|
201
|
+
else if (byWebSession && client === WEB_ANSWERING_CLIENT) notePoll(now(), {})
|
|
182
202
|
const health = deps.broker.health()
|
|
183
203
|
// Listed first: a deadline the timer has not caught yet is settled by the listing.
|
|
184
204
|
const items = deps.broker.list()
|