@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.
- package/CHANGELOG.md +227 -0
- package/package.json +2 -1
- package/server/index.ts +170 -11
- 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/cos-thread.ts +582 -0
- package/server/lib/display-bus.ts +3 -0
- package/server/lib/drop-attachments.ts +55 -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 +830 -0
- package/server/lib/drop-settings.ts +56 -0
- package/server/lib/drop-store.ts +450 -0
- package/server/lib/drop-when.ts +265 -0
- package/server/lib/glasses-pairing.ts +15 -0
- package/server/lib/learning-needs-you.ts +111 -0
- package/server/lib/live-cues-v2-settings.ts +2 -1
- package/server/lib/live-cues-v2.ts +6 -4
- package/server/lib/media-store.ts +80 -0
- package/server/lib/meeting-batch-transcribe.ts +15 -2
- 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 +13 -0
- package/server/lib/phone-recording.ts +1 -1
- 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/cos-thread.ts +79 -0
- package/server/routes/display.ts +11 -1
- package/server/routes/drops.ts +262 -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 +43 -4
- package/server/routes/web-control.ts +569 -0
- package/server/routes/welcome-context.ts +2 -1
- package/server/scripts/web-live-cls/cdp.mjs +57 -0
- package/server/scripts/web-live-cls/measure.mjs +80 -0
- package/server/scripts/web-live-cls/mock.mjs +97 -0
- package/server/scripts/web-live-cls/render.mjs +26 -0
- package/server/web-control/app.css +519 -0
- package/server/web-control/app.js +3643 -0
- package/server/web-control/control.css +325 -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 +174 -0
- package/server/web-control/panes/cos-chat.js +1118 -0
- package/server/web-control/panes/dictation.js +173 -0
- package/server/web-control/panes/meetings.css +314 -0
- package/server/web-control/panes/meetings.js +2568 -0
- package/server/web-control/panes/messages.js +718 -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
|
+
}
|
|
@@ -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
|
+
}
|
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,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 }
|
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' })
|