@gotcos/glasses-server 6.66.0 → 6.70.0

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.
@@ -1,4 +1,5 @@
1
1
  import { DICTATION_CLEAN_MODELS } from '../lib/dictation-clean-models.js'
2
+ import { PAIRING_PROTOCOL_VERSION } from '../lib/glasses-pairing.js'
2
3
  import { taskBridgeAvailable } from '../lib/task-bridge.js'
3
4
  import { Router } from 'express'
4
5
  import { isWorthRecovering } from '../lib/quarantine-auto-recover.js'
@@ -26,6 +27,7 @@ import {
26
27
  } from '../lib/whisper-local.js'
27
28
  import { getOpenAIWhisperBudgetState } from '../lib/openai-whisper-budget.js'
28
29
  import { getMeetingSummaryBudgetState } from '../lib/meeting-summary-budget.js'
30
+ import { meetingWebCapability } from '../lib/meeting-ask-web.js'
29
31
  import { lensGistPublicHealth } from '../lib/lens-gist.js'
30
32
  import { meetingSummaryLLMEnabled } from '../lib/meeting-summary.js'
31
33
  import { getKeyStatus } from '../lib/openai-key.js'
@@ -464,7 +466,12 @@ healthRouter.get('/health', async (_req, res) => {
464
466
  review_audio: reviewAudio,
465
467
  ...(voiceProvenance ? { voice_provenance: voiceProvenance } : {}),
466
468
  capabilities: {
469
+ meetingAsk: { protocolVersion: 2, durableHistory: true, attachmentContext: true, webRetrieval: meetingWebCapability().available },
467
470
  dictationCleanup: { models: DICTATION_CLEAN_MODELS },
471
+ // 6.67.0: glasses pairing (docs/pairing-contract.md). Public on purpose: the phone
472
+ // reads it before it holds a token, and an older server omits it, which is how
473
+ // the phone knows to say "Update COS on your Mac (server 6.67 or newer)".
474
+ pairing: { version: PAIRING_PROTOCOL_VERSION },
468
475
  // Advertised on the PUBLIC health route on purpose: a client deciding whether
469
476
  // to request a display ticket may not hold a usable token yet, and an old
470
477
  // server simply omits this key, which is how a new client detects it.
@@ -625,6 +632,7 @@ healthRouter.get('/models', async (req, res) => {
625
632
  // Changing that is a breaking change that needs a client version gate first.
626
633
  ...(apiToken ? { displayStreamTicket: mintDisplayTicket(apiToken) } : {}),
627
634
  capabilities: {
635
+ meetingAsk: { protocolVersion: 2, durableHistory: true, attachmentContext: true, webRetrieval: meetingWebCapability().available },
628
636
  dictationCleanup: { models: DICTATION_CLEAN_MODELS },
629
637
  durableQueryJobs: {
630
638
  enabled: durableJobs.enabled,
@@ -0,0 +1,19 @@
1
+ import { Router } from 'express'
2
+ import { AskError, normalizeAsk } from '../lib/live-cos-ask.js'
3
+ import { maintenanceAdmissionsOpen } from '../lib/maintenance-lifecycle.js'
4
+ import { meetingAskRuntime } from '../lib/meeting-ask-runtime.js'
5
+ export const liveCosAskRouter = Router()
6
+ liveCosAskRouter.post('/meetings/live-ask', async (req, res) => {
7
+ res.set('Cache-Control', 'no-store')
8
+ const controller = new AbortController()
9
+ const timer = setTimeout(() => controller.abort(), 150_000)
10
+ res.once('close', () => { if (!res.writableEnded) controller.abort() })
11
+ try {
12
+ if (!maintenanceAdmissionsOpen()) throw new AskError('maintenance', 503)
13
+ // Authentication is provided by index.ts before all /api routes. No model-owned tools.
14
+ const input = normalizeAsk(req.body)
15
+ res.json(await meetingAskRuntime.legacy(input, controller.signal))
16
+ } catch (error) {
17
+ if (!res.destroyed) res.status(error instanceof AskError ? error.status : 503).json({ error: error instanceof AskError ? error.reason : 'haiku_unavailable' })
18
+ } finally { clearTimeout(timer) }
19
+ })
@@ -0,0 +1,128 @@
1
+ import { Router, type Response } from 'express';
2
+ import { AskError } from '../lib/live-cos-ask.js';
3
+ import { meetingAskRuntime, type MeetingAskRuntime } from '../lib/meeting-ask-runtime.js';
4
+ import { getSessionChunkEntries, getSessionStartTime } from './transcribe-stream.js';
5
+ import { ASK_SESSION } from '../lib/meeting-ask-store.js';
6
+ import { redactSecrets } from '../lib/lens-gist-engines.js';
7
+ import type { MeetingAskExchange, MeetingAskFile } from '../../shared/meeting-ask.js';
8
+ function failure(res: Response, error: unknown) { res.status(error instanceof AskError ? error.status : 503).json({ error: error instanceof AskError ? error.reason : 'meeting_assistant_unavailable' }); }
9
+ /** Safe Markdown export. Citations are namespaced to the request because T1 is answer-local. */
10
+ export function exportMeetingAsk(exchanges: MeetingAskExchange[], files: MeetingAskFile[]): string {
11
+ const parts = ['## Meeting assistant history'];
12
+ for (const e of exchanges) {
13
+ parts.push(`### ${e.createdAt} · ${e.action}\n\nQuestion: ${e.question || e.action}\n\nStatus: ${e.status}\nModel: ${e.model ?? `${e.selectedModel.engine}:${e.selectedModel.model}`}\nTranscript through: ${e.throughMs} ms`);
14
+ if (e.answer)
15
+ parts.push(e.answer.replace(/\[([THFW]\d+)\]/g, (_, id) => `[${e.requestId}:${id}]`));
16
+ for (const s of e.sources)
17
+ parts.push(`[${e.requestId}:${s.id}] ${s.title}${s.url ? ` — ${s.url}` : ''}\n${s.text}`);
18
+ if (e.notices.length)
19
+ parts.push('Coverage: ' + e.notices.join(' '));
20
+ if (e.error)
21
+ parts.push('Outcome: ' + e.error);
22
+ }
23
+ parts.push('## Meeting files');
24
+ const revisions = new Map<string, MeetingAskFile>();
25
+ for (const e of exchanges)
26
+ for (const f of e.files)
27
+ if (!revisions.has(`${f.id}@${f.revision}`) || f.status === 'ready')
28
+ revisions.set(`${f.id}@${f.revision}`, f);
29
+ // Current removal/failure is lifecycle metadata, never a replacement for admitted evidence.
30
+ for (const f of files) {
31
+ const key = `${f.id}@${f.revision}`, admitted = revisions.get(key);
32
+ if (!admitted)
33
+ revisions.set(key, f);
34
+ else if (admitted.status !== f.status)
35
+ parts.push(`Current file status: ${f.name} (${f.status}) · ${key}\nHistorical admitted context is retained below.`);
36
+ }
37
+ for (const f of revisions.values())
38
+ parts.push(`### ${f.name} (${f.status})\nReference: ${f.id}@${f.revision}\n${f.text || 'No extracted text available.'}`);
39
+ const text = parts.join('\n\n');
40
+ if (Buffer.byteLength(text, 'utf8') > 850000)
41
+ throw new AskError('export_too_large', 413);
42
+ return text;
43
+ }
44
+ export function createMeetingAskRouter(runtime: MeetingAskRuntime = meetingAskRuntime) {
45
+ const router = Router();
46
+ router.use((_q, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
47
+ router.get('/meeting-ask/config', async (_q, res) => { try {
48
+ res.json(await runtime.config());
49
+ }
50
+ catch (e) {
51
+ failure(res, e);
52
+ } });
53
+ router.put('/meeting-ask/config', async (req, res) => { try {
54
+ res.json(await runtime.setConfig(req.body));
55
+ }
56
+ catch (e) {
57
+ failure(res, e);
58
+ } });
59
+ router.post('/meeting-ask/requests', async (req, res) => { try {
60
+ res.status(202).json({ exchange: await runtime.submit(req.body) });
61
+ }
62
+ catch (e) {
63
+ failure(res, e);
64
+ } });
65
+ router.get('/meeting-ask/requests/:id', (req, res) => { try {
66
+ res.json({ exchange: runtime.get(String(req.params.id)) });
67
+ }
68
+ catch (e) {
69
+ failure(res, e);
70
+ } });
71
+ router.post('/meeting-ask/requests/:id/cancel', (req, res) => { try {
72
+ res.json({ exchange: runtime.cancel(String(req.params.id), req.body?.serverInstanceId) });
73
+ }
74
+ catch (e) {
75
+ failure(res, e);
76
+ } });
77
+ router.get('/meeting-ask/history', (req, res) => { try {
78
+ const offset = Number(req.query.offset ?? 0), limit = Number(req.query.limit ?? 50);
79
+ const order = req.query.order ?? 'asc';
80
+ if (!Number.isFinite(offset) || !Number.isFinite(limit) || !['asc', 'desc'].includes(String(order)))
81
+ throw new AskError('invalid_page');
82
+ res.json(runtime.history(String(req.query.sessionId ?? ''), req.query.serverInstanceId, offset, limit, order as 'asc' | 'desc'));
83
+ }
84
+ catch (e) {
85
+ failure(res, e);
86
+ } });
87
+ router.put('/meeting-ask/files', (req, res) => { try {
88
+ res.json({ files: runtime.putFiles(req.body?.sessionId, req.body?.serverInstanceId, req.body?.files) });
89
+ }
90
+ catch (e) {
91
+ failure(res, e);
92
+ } });
93
+ router.get('/meeting-ask/export', (req, res) => {
94
+ try {
95
+ const exchanges: MeetingAskExchange[] = [];
96
+ let offset: number | null = 0;
97
+ let files: MeetingAskFile[] = [];
98
+ do {
99
+ const page = runtime.history(String(req.query.sessionId ?? ''), req.query.serverInstanceId, offset, 100);
100
+ exchanges.push(...page.exchanges);
101
+ files = page.files;
102
+ offset = page.nextOffset;
103
+ } while (offset !== null);
104
+ res.json({ text: exportMeetingAsk(exchanges, files) });
105
+ }
106
+ catch (e) {
107
+ failure(res, e);
108
+ }
109
+ });
110
+ router.get('/meeting-ask/transcript', (req, res) => {
111
+ try {
112
+ runtime.identity(req.query.serverInstanceId);
113
+ const sessionId = String(req.query.sessionId ?? '');
114
+ if (!ASK_SESSION.test(sessionId))
115
+ throw new AskError('invalid_session_id');
116
+ const chunks = getSessionChunkEntries(sessionId), start = getSessionStartTime(sessionId);
117
+ if (!chunks || start === null)
118
+ throw new AskError('session_not_available', 404);
119
+ const turns = chunks.filter(e => e.chunk.text.trim()).map(e => ({ id: e.chunkIndex, elapsedMs: Math.max(0, e.chunk.elapsed), speaker: e.chunk.speaker, text: redactSecrets(e.chunk.text) }));
120
+ res.json({ turns, elapsedMs: Math.max(Date.now() - start, ...turns.map(t => t.elapsedMs), 0), notices: [] });
121
+ }
122
+ catch (e) {
123
+ failure(res, e);
124
+ }
125
+ });
126
+ return router;
127
+ }
128
+ export const meetingAskRouter = createMeetingAskRouter();
@@ -0,0 +1,121 @@
1
+ // Glasses pairing routes (6.67.0). Contract: docs/pairing-contract.md.
2
+ //
3
+ // Mounted under /api AFTER requireApiToken and BEFORE the mutation-lease catch-all and
4
+ // the global 10 MB JSON parser:
5
+ // - POST /pairing/claim and GET /pairing/claim/:nonce are public (exact matches in
6
+ // isPublicApiRequest); everything else here needs the token AND a loopback socket.
7
+ // - The routes write no files, so they never take the mutation lease. During a drain or
8
+ // shutdown, minting, claiming, status and decisions answer 503 `draining`. The poll is
9
+ // still served: it writes nothing, and a token the user already allowed must still
10
+ // reach the phone if the update gate closes between Allow and the next poll.
11
+ // - Each route parses its own body with a 1 KB cap.
12
+ //
13
+ // Every address decision reads `req.socket.remoteAddress`, never `req.ip` or
14
+ // X-Forwarded-For. Codes, nonces, tokens and QR text are never logged (lib/glasses-pairing.ts
15
+ // logs the IP, the result and a 2-character hash of the code).
16
+
17
+ import express, { Router, type ErrorRequestHandler, type Request, type RequestHandler, type Response } from 'express'
18
+ import { isLoopbackSocket } from '../lib/network-policy.js'
19
+ import {
20
+ PAIRING_NONCE_PATTERN,
21
+ PAIRING_REASON_MESSAGE,
22
+ PAIRING_REASON_STATUS,
23
+ PairingError,
24
+ type PairingReason,
25
+ type PairingState,
26
+ } from '../lib/glasses-pairing.js'
27
+
28
+ export interface PairingRouterOptions {
29
+ /** Read per request so a restart created after mount is honoured in tests. */
30
+ state: () => PairingState
31
+ /** True while the server is shutting down or the maintenance gate is closed. */
32
+ isDraining: () => boolean
33
+ }
34
+
35
+ function sendReason(res: Response, reason: PairingReason, retryAfterSeconds?: number, status?: number): void {
36
+ if (retryAfterSeconds != null) res.setHeader('Retry-After', String(retryAfterSeconds))
37
+ res.status(status ?? PAIRING_REASON_STATUS[reason]).json({ reason, message: PAIRING_REASON_MESSAGE[reason] })
38
+ }
39
+
40
+ function sendError(res: Response, error: unknown): void {
41
+ if (error instanceof PairingError) return sendReason(res, error.reason, error.retryAfterSeconds)
42
+ console.error('[pairing] internal error:', error instanceof Error ? error.message : 'unknown')
43
+ res.status(500).json({ reason: 'internal', message: 'Pairing failed on the Mac. Try again.' })
44
+ }
45
+
46
+ function socketIp(req: Request): string {
47
+ return req.socket?.remoteAddress ?? ''
48
+ }
49
+
50
+ export function createPairingRouter(options: PairingRouterOptions): Router {
51
+ const router = Router()
52
+ const smallJson = express.json({ limit: '1kb' })
53
+
54
+ const notDraining: RequestHandler = (_req, res, next) => {
55
+ if (options.isDraining()) return sendReason(res, 'draining', 30)
56
+ next()
57
+ }
58
+ const loopbackOnly: RequestHandler = (req, res, next) => {
59
+ if (!isLoopbackSocket(req)) return sendReason(res, 'not_loopback')
60
+ next()
61
+ }
62
+ const body = (req: Request): Record<string, unknown> =>
63
+ req.body && typeof req.body === 'object' && !Array.isArray(req.body) ? req.body as Record<string, unknown> : {}
64
+
65
+ router.post('/pairing/code', notDraining, loopbackOnly, smallJson, (req, res) => {
66
+ const input = body(req)
67
+ if (input.allowLan !== undefined && typeof input.allowLan !== 'boolean') return sendReason(res, 'bad_request')
68
+ try {
69
+ res.json(options.state().mint({ allowLan: input.allowLan === true }))
70
+ } catch (error) { sendError(res, error) }
71
+ })
72
+
73
+ router.post('/pairing/claim', notDraining, smallJson, (req, res) => {
74
+ const input = body(req)
75
+ try {
76
+ options.state().claim({ code: input.code, nonce: input.nonce }, socketIp(req))
77
+ res.status(202).json({ pending: true })
78
+ } catch (error) { sendError(res, error) }
79
+ })
80
+
81
+ router.get('/pairing/claim/:nonce', (req, res) => {
82
+ // Express routes HEAD to a GET handler. Only a real GET may read (and so spend) the
83
+ // token; api-auth.ts already keeps HEAD behind the token, this holds it here too.
84
+ if (req.method !== 'GET') {
85
+ res.setHeader('Allow', 'GET')
86
+ return sendReason(res, 'bad_request', undefined, 405)
87
+ }
88
+ const nonce = req.params.nonce
89
+ if (typeof nonce !== 'string' || !PAIRING_NONCE_PATTERN.test(nonce)) return sendReason(res, 'bad_request')
90
+ try {
91
+ res.setHeader('Cache-Control', 'no-store')
92
+ res.json(options.state().poll(nonce, socketIp(req)))
93
+ } catch (error) { sendError(res, error) }
94
+ })
95
+
96
+ router.get('/pairing/status', notDraining, loopbackOnly, (_req, res) => {
97
+ try {
98
+ res.setHeader('Cache-Control', 'no-store')
99
+ res.json(options.state().status())
100
+ } catch (error) { sendError(res, error) }
101
+ })
102
+
103
+ router.post('/pairing/decision', notDraining, loopbackOnly, smallJson, (req, res) => {
104
+ const input = body(req)
105
+ try {
106
+ options.state().decide(input.nonce, input.allow)
107
+ res.json({ ok: true })
108
+ } catch (error) { sendError(res, error) }
109
+ })
110
+
111
+ // A body over 1 KB or one that is not JSON answers in the contract's shape, not
112
+ // Express's HTML error page. Scoped to this router's own parsers.
113
+ const parseError: ErrorRequestHandler = (error, req, res, next) => {
114
+ if (!req.path.startsWith('/pairing/')) return next(error)
115
+ if (res.headersSent) return next(error)
116
+ sendReason(res, 'bad_request')
117
+ }
118
+ router.use(parseError)
119
+
120
+ return router
121
+ }
@@ -0,0 +1,106 @@
1
+ /** Meeting assistance v2. Additive to the legacy /api/meetings/live-ask contract. */
2
+ export type MeetingAskAction = 'catch_up' | 'summary' | 'questions' | 'actions' | 'ask';
3
+ export type MeetingAskEngine = 'claude' | 'codex' | 'cursor' | 'ollama';
4
+ export interface MeetingAskModel {
5
+ engine: MeetingAskEngine;
6
+ model: string;
7
+ }
8
+ export interface MeetingAskTurn {
9
+ id: number;
10
+ elapsedMs: number;
11
+ speaker: string;
12
+ text: string;
13
+ }
14
+ export interface MeetingAskFile {
15
+ id: string;
16
+ revision: string;
17
+ name: string;
18
+ status: 'ready' | 'pending' | 'unsupported' | 'failed' | 'removed' | 'secret-held';
19
+ text: string;
20
+ notice?: string;
21
+ }
22
+ export interface MeetingAskSource {
23
+ id: string;
24
+ kind: 'transcript' | 'memory' | 'meeting' | 'file' | 'web';
25
+ title: string;
26
+ text: string;
27
+ turnId?: number;
28
+ elapsedMs?: number;
29
+ sessionId?: string;
30
+ recordId?: string;
31
+ memoryId?: string;
32
+ domain?: string;
33
+ month?: string;
34
+ filename?: string;
35
+ fileId?: string;
36
+ revision?: string;
37
+ url?: string;
38
+ retrievedAt?: string;
39
+ retrievalId?: string;
40
+ }
41
+ export interface MeetingAskSubmit {
42
+ requestId: string;
43
+ sessionId: string;
44
+ serverInstanceId: string;
45
+ action: MeetingAskAction;
46
+ question: string;
47
+ elapsedMs: number;
48
+ turns: MeetingAskTurn[];
49
+ model?: MeetingAskModel;
50
+ files?: Array<{
51
+ id: string;
52
+ revision: string;
53
+ }>;
54
+ retryOf?: string;
55
+ }
56
+ export type MeetingAskStatus = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' | 'interrupted';
57
+ export interface MeetingAskExchange {
58
+ requestId: string;
59
+ sessionId: string;
60
+ serverInstanceId: string;
61
+ action: MeetingAskAction;
62
+ question: string;
63
+ status: MeetingAskStatus;
64
+ createdAt: string;
65
+ updatedAt: string;
66
+ completedAt?: string;
67
+ selectedModel: MeetingAskModel;
68
+ model?: string;
69
+ answer?: string;
70
+ sources: MeetingAskSource[];
71
+ notices: string[];
72
+ throughMs: number;
73
+ files: MeetingAskFile[];
74
+ error?: string;
75
+ retryOf?: string;
76
+ }
77
+ export interface MeetingAskCapabilities {
78
+ protocolVersion: 2;
79
+ durableHistory: true;
80
+ attachmentContext: true;
81
+ webRetrieval: boolean;
82
+ }
83
+ export interface MeetingAskConfig {
84
+ protocolVersion: 2;
85
+ serverInstanceId: string;
86
+ selected: MeetingAskModel;
87
+ models: Array<MeetingAskModel & {
88
+ available: boolean;
89
+ reason?: string;
90
+ }>;
91
+ web: {
92
+ available: boolean;
93
+ reason?: string;
94
+ engine?: string;
95
+ };
96
+ capabilities: MeetingAskCapabilities;
97
+ }
98
+ /** All paths under /api/meeting-ask. GET config; PUT config {engine,model}.
99
+ * POST requests MeetingAskSubmit -> 202 {exchange}; GET requests/:requestId -> {exchange}.
100
+ * POST requests/:requestId/cancel {serverInstanceId} -> {exchange}.
101
+ * GET history?sessionId=...&serverInstanceId=...&offset=0&limit=50&order=asc|desc -> {exchanges,nextOffset,files}.
102
+ * PUT files {sessionId,serverInstanceId,files:MeetingAskFile[]} -> {files}. Upsert only; removed is explicit.
103
+ * GET export?sessionId=...&serverInstanceId=... -> {text}; composes persisted Ask/file context,
104
+ * client prepends its current canonical transcript. All operations use X-Cos-Token.
105
+ * GET config is the capability probe (404 means old server); health.capabilities.meetingAsk mirrors capabilities.
106
+ */