@gotcos/glasses-server 6.74.0 → 6.76.1

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.
@@ -10,6 +10,7 @@ import { resolve } from 'node:path'
10
10
  import { createHash } from 'node:crypto'
11
11
  import { Router } from 'express'
12
12
  import { emitDisplay } from '../lib/display-bus.js'
13
+ import { createStopRequestStore, isStopRequestRefusal, STOP_REQUEST_CLAIM_WINDOW_MS, type StopRequest, type StopRequestStore } from '../lib/meeting-stop-requests.js'
13
14
  import { cleanTranscriptLines } from '../lib/hallucination-filter.js'
14
15
  import { durableAtomicWriteFileSync } from '../lib/atomic-fs.js'
15
16
  import { appendCorrection, appliedCorrections, pendingCorrections } from '../lib/meeting-corrections.js'
@@ -240,6 +241,8 @@ export interface MeetingRouteDependencies {
240
241
  emit?: typeof emitDisplay
241
242
  finalizationJobs?: MeetingFinalizationJobStore
242
243
  finalDiarize?: FinalDiarize
244
+ /** Control's stop requests for live G2 meetings (lib/meeting-stop-requests.ts). */
245
+ stopRequests?: StopRequestStore
243
246
  }
244
247
 
245
248
  const activeFinalizationJobs = new Set<string>()
@@ -557,6 +560,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
557
560
  const finalDiarize = deps.finalDiarize ?? runNemotronFinalPass
558
561
  const router = Router()
559
562
  const savingSessions = new Set<string>()
563
+ const stopRequests = deps.stopRequests ?? createStopRequestStore()
560
564
  setNamingSessionLiveness(id => sessions.getStartTime(id) !== null || savingSessions.has(id) || activeFinalizationJobs.has(id))
561
565
 
562
566
  let recoveryPendingLogged = false
@@ -624,6 +628,114 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
624
628
  })
625
629
  })
626
630
 
631
+ // ── Stop a live G2 meeting from the Mac (COS Control) ────────────────────────────
632
+ // The Mac never saves a live G2 session itself (lib/meeting-stop-requests.ts says why).
633
+ // It asks the glasses: POST records the request and puts `meeting_stop_requested` on the
634
+ // display bus; the companion claims it within the claim window and runs its own Stop;
635
+ // Control polls GET until the meeting is saved, or says no glasses answered.
636
+ const stopRequestBody = (request: StopRequest, extra: Record<string, unknown> = {}) => ({
637
+ sessionId: request.sessionId,
638
+ requestId: request.requestId,
639
+ state: request.state,
640
+ requestedAt: request.requestedAt,
641
+ claimedAt: request.claimedAt,
642
+ refusal: request.refusal,
643
+ claimWindowMs: STOP_REQUEST_CLAIM_WINDOW_MS,
644
+ ...extra,
645
+ })
646
+ const stopRequestSessionId = (req: { params: Record<string, unknown> }, res: { status(code: number): { json(body: unknown): void } }): string | null => {
647
+ const sessionId = String(req.params.sessionId ?? '')
648
+ if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) {
649
+ res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
650
+ return null
651
+ }
652
+ if (MAC_CAPTURE_ID.test(sessionId)) {
653
+ // A Mac recording stops from Control itself; it has no glasses to ask.
654
+ res.status(400).json({ error: 'A Mac recording stops from Record on Mac', reason: 'mac_capture' })
655
+ return null
656
+ }
657
+ return sessionId
658
+ }
659
+
660
+ router.post('/meeting/sessions/:sessionId/stop-request', (req, res) => {
661
+ const sessionId = stopRequestSessionId(req, res)
662
+ if (!sessionId) return
663
+ res.set('Cache-Control', 'private, no-store')
664
+ const saved = store.findBySessionId(sessionId)
665
+ if (saved) {
666
+ res.json({ sessionId, state: 'saved', saveReceipt: publicSaveResponse(saved, true) })
667
+ return
668
+ }
669
+ if (savingSessions.has(sessionId)) {
670
+ res.json({ sessionId, state: 'finalizing' })
671
+ return
672
+ }
673
+ const live = (sessions.getState ?? defaultSessionState)(sessionId)
674
+ if (live !== 'active') {
675
+ res.status(409).json({ error: 'This meeting is not recording', reason: 'not_live', state: live })
676
+ return
677
+ }
678
+ const body = req.body as Record<string, unknown> | undefined
679
+ const source = typeof body?.source === 'string' && /^[a-z][a-z0-9_-]{0,31}$/.test(body.source) ? body.source : 'control'
680
+ const request = stopRequests.request(sessionId, source)
681
+ try {
682
+ emit({
683
+ type: 'meeting_stop_requested',
684
+ data: { sessionId, requestId: request.requestId, source, claimWindowMs: STOP_REQUEST_CLAIM_WINDOW_MS },
685
+ })
686
+ } catch (error) {
687
+ console.warn('[meeting/stop-request] Display notification failed:', error)
688
+ }
689
+ console.log(`[meeting/stop-request] ${source} asked the glasses to stop ${sessionId} (${request.requestId})`)
690
+ res.status(202).json(stopRequestBody(request))
691
+ })
692
+
693
+ // The companion's answer: `accept` claims the request (then it stops), `refuse` says why not.
694
+ router.post('/meeting/sessions/:sessionId/stop-request/:requestId/claim', (req, res) => {
695
+ const sessionId = stopRequestSessionId(req, res)
696
+ if (!sessionId) return
697
+ res.set('Cache-Control', 'private, no-store')
698
+ const requestId = String(req.params.requestId ?? '')
699
+ const body = req.body as Record<string, unknown> | undefined
700
+ const decision = body?.decision
701
+ if (decision !== 'accept' && decision !== 'refuse') {
702
+ res.status(400).json({ error: 'decision must be accept or refuse', reason: 'invalid_decision' })
703
+ return
704
+ }
705
+ if (decision === 'refuse' && !isStopRequestRefusal(body?.reason)) {
706
+ res.status(400).json({ error: 'Unknown refusal reason', reason: 'invalid_refusal' })
707
+ return
708
+ }
709
+ const result = decision === 'accept'
710
+ ? stopRequests.claim(sessionId, requestId)
711
+ : stopRequests.refuse(sessionId, requestId, body!.reason as Parameters<StopRequestStore['refuse']>[2])
712
+ if (!result.ok) {
713
+ const status = result.reason === 'unknown_request' ? 404 : 409
714
+ res.status(status).json({ error: 'Stop request is not open', reason: result.reason })
715
+ return
716
+ }
717
+ console.log(`[meeting/stop-request] glasses ${decision === 'accept' ? 'claimed' : 'refused'} ${result.request.requestId}${result.request.refusal ? ` (${result.request.refusal})` : ''}`)
718
+ res.json(stopRequestBody(result.request))
719
+ })
720
+
721
+ // Control's poll. A saved meeting answers `saved` with its receipt, whatever the request says.
722
+ router.get('/meeting/sessions/:sessionId/stop-request/:requestId', (req, res) => {
723
+ const sessionId = stopRequestSessionId(req, res)
724
+ if (!sessionId) return
725
+ res.set('Cache-Control', 'private, no-store')
726
+ const request = stopRequests.get(sessionId, String(req.params.requestId ?? ''))
727
+ if (!request) {
728
+ res.status(404).json({ error: 'Unknown stop request', reason: 'unknown_request' })
729
+ return
730
+ }
731
+ const saved = store.findBySessionId(sessionId)
732
+ if (saved) {
733
+ res.json(stopRequestBody(request, { state: 'saved', saveReceipt: publicSaveResponse(saved, true) }))
734
+ return
735
+ }
736
+ res.json(stopRequestBody(request, savingSessions.has(sessionId) ? { finalizing: true } : {}))
737
+ })
738
+
627
739
  router.post('/meeting/save', async (req, res) => {
628
740
  let lockedSessionId: string | null = null
629
741
  let maintenanceLease: MaintenanceWorkLease | undefined
@@ -2443,7 +2443,8 @@ async function processStreamChunk(opts: {
2443
2443
  // block on a cue, and no LLM runs on this path. .catch() is required — a
2444
2444
  // bare `void` on a rejecting promise is an unhandled rejection, which Node
2445
2445
  // throws on by default. All gates live inside the feed.
2446
- void feedLiveCueTranscript(sessionId, trimmedText, Boolean(fallbackReason)).catch(() => {})
2446
+ // The ASR await above lets a late chunk land after Stop or save; a closed session never feeds cues.
2447
+ if (!isSessionDeleted(sessionId)) void feedLiveCueTranscript(sessionId, trimmedText, Boolean(fallbackReason)).catch(() => {})
2447
2448
 
2448
2449
  console.log(`[perf] TOTAL request: ${(performance.now() - tReq).toFixed(1)}ms | chunk #${chunkIndex} | ${audioBuffer.length}b | rms=${Math.round(rms)} q=${isQuiet ? 1 : 0} gate=${quiet.gate}${quiet.speechSec === null ? '' : ` speech=${quiet.speechSec.toFixed(2)}s`} fe=${stt.frontEnd} | ${asrProvider} | "${trimmedText.slice(0, 50)}"`)
2449
2450
  return canonicalChunkResponse(chunk, sessionId, chunkIndex)
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Live cues v2 wire contract (server 6.75 candidate, companion 6.10.641 candidate).
3
+ * Additive to v1: old clients keep receiving `coaching_nudge` and ignore the new named event.
4
+ * The companion keeps a byte-identical copy at src/lib/live-cues-v2-contract.ts.
5
+ */
6
+ export const LIVE_CUES_V2_PROTOCOL = 2 as const
7
+ export const CUE_HEADLINE_MAX = 64
8
+ export const CUE_DETAIL_MAX = 220
9
+ export const CUE_FEEDBACK_REASON_MAX = 200
10
+
11
+ export type CueKind = 'reinforce' | 'challenge'
12
+
13
+ /** A past meeting the cue drew on (0 to 2 per cue). */
14
+ export interface CueSourceRef { id: string; date: string; title: string }
15
+
16
+ /** SSE named event `coaching_nudge_v2` (emitted BEFORE its v1 `coaching_nudge` twin), and the rows of GET /api/live-cues/status?sessionId=. */
17
+ export interface LiveCueV2 {
18
+ protocol: 2
19
+ cueId: string
20
+ sessionId: string
21
+ /** <= 64 chars: the lens footer line. */
22
+ headline: string
23
+ /** <= 220 chars: the full cue, shown on tap and in the phone Cues sheet. */
24
+ detail: string
25
+ kind: CueKind
26
+ sources: CueSourceRef[]
27
+ /** Admission probability from the gate, 0 to 1. */
28
+ gate: number
29
+ createdAt: number
30
+ model: string
31
+ }
32
+
33
+ /** v1 `coaching_nudge` as the v2 engine emits it for old clients (headline only). */
34
+ export interface LiveCueV1Compat { nudge: string; type: CueKind; priority: 2; cueId: string; sessionId: string; protocol: 2 }
35
+
36
+ /** `reason` when unavailable: master_flag_off, api_key_in_env, jev_not_configured, claude_not_found, budget_exhausted,
37
+ * jev_cap_reached, jev_breaker_open. */
38
+ export interface LiveCuesV2Capability { available: boolean; reason?: string; protocol: 2; model: string }
39
+
40
+ /** Why cues are not coming for this meeting. `active` means evaluations are running normally (silence is a choice). */
41
+ export type LiveCuesV2SessionState = 'active' | 'paused' | 'ended' | 'stopped'
42
+ export type LiveCuesV2StopReason = 'breaker_open' | 'meeting_cap' | 'budget_exhausted' | 'jev_cap_reached' | 'jev_breaker_open' | 'jev_not_configured'
43
+
44
+ export interface LiveCuesV2SessionStatus {
45
+ sessionId: string
46
+ state: LiveCuesV2SessionState
47
+ /** Present when state is `stopped`. */
48
+ reason?: LiveCuesV2StopReason
49
+ paused: boolean
50
+ ended: boolean
51
+ /** The pause request sequence the server last applied (0 when none). */
52
+ pauseSeq: number
53
+ cues: LiveCueV2[]
54
+ evaluations: number
55
+ writerCalls: number
56
+ }
57
+
58
+ /** GET /api/live-cues/status?sessionId=X (v2 server). Without sessionId the v1 array shape is unchanged. */
59
+ export interface LiveCuesV2StatusResponse { protocol: 2; enabled: boolean; session: LiveCuesV2SessionStatus | null }
60
+
61
+ /** POST /api/live-cues/pause -> { ok, paused, applied, pauseSeq }. `seq` is a per-meeting counter from the phone; the server
62
+ * ignores a request whose seq is not newer than the last applied one (applied: false) so out-of-order posts cannot win. */
63
+ export interface LiveCuesPauseRequest { sessionId: string; paused: boolean; seq?: number }
64
+ /** POST /api/live-cues/feedback */
65
+ export interface LiveCuesFeedbackRequest { sessionId: string; cueId: string; vote: 'up' | 'down'; reason?: string }
66
+
67
+ export function isLiveCueV2(value: unknown): value is LiveCueV2 {
68
+ if (!value || typeof value !== 'object') return false
69
+ const v = value as Record<string, unknown>
70
+ return v.protocol === 2 && typeof v.cueId === 'string' && v.cueId.length > 0 && typeof v.sessionId === 'string'
71
+ && typeof v.headline === 'string' && v.headline.length > 0 && v.headline.length <= CUE_HEADLINE_MAX
72
+ && typeof v.detail === 'string' && v.detail.length > 0 && v.detail.length <= CUE_DETAIL_MAX
73
+ && (v.kind === 'reinforce' || v.kind === 'challenge')
74
+ && Array.isArray(v.sources) && v.sources.length <= 2
75
+ && v.sources.every(s => !!s && typeof (s as CueSourceRef).id === 'string' && typeof (s as CueSourceRef).date === 'string' && typeof (s as CueSourceRef).title === 'string')
76
+ && typeof v.gate === 'number' && typeof v.createdAt === 'number' && typeof v.model === 'string'
77
+ }
@@ -40,7 +40,8 @@ export interface MeetingAskSource {
40
40
  /** Server-owned accepted post-stop quotation; no live turn target or precise word clock. */
41
41
  finalizedTranscript?: boolean
42
42
  id: string;
43
- kind: 'transcript' | 'memory' | 'meeting' | 'file' | 'web';
43
+ /** 'profile' (server 6.76.0): the COS owner card from the profile; cited with an H id like other COS history. */
44
+ kind: 'transcript' | 'memory' | 'meeting' | 'file' | 'web' | 'profile';
44
45
  title: string;
45
46
  text: string;
46
47
  turnId?: number;
@@ -115,6 +116,8 @@ export function meetingAskFromLine(sections: readonly MeetingAskSection[] | unde
115
116
  } }));
116
117
  parts.push(hosts.length ? `the web, ${hosts.slice(0, 2).join(', ')}` : 'the web');
117
118
  }
119
+ if (used.some(s => s.kind === 'profile'))
120
+ parts.push('COS profile');
118
121
  if (used.some(s => s.kind === 'memory'))
119
122
  parts.push('COS memory');
120
123
  const meetings = unique(used.filter(s => s.kind === 'meeting').map(s => {
@@ -170,6 +173,8 @@ export interface MeetingAskExchange {
170
173
  files: MeetingAskFile[];
171
174
  error?: string;
172
175
  retryOf?: string;
176
+ /** Server 6.76.0: how long the answer took on the server and in which phase. Not the meeting position; that is throughMs. */
177
+ timings?: MeetingAskTimings;
173
178
  /** Server 6.71.13 (capabilities.factCheckScope): what a completed Fact check covered. Named-claim checks and follow-ups carry none. */
174
179
  factCheckRange?: MeetingAskFactCheckRange;
175
180
  }
@@ -178,6 +183,23 @@ export interface MeetingAskExchange {
178
183
  * check covers the whole meeting (full); each later one covers only what is new since the last completed
179
184
  * check's throughMs. A check that found nothing new still completes with claims 0, so the next one moves on.
180
185
  */
186
+ /**
187
+ * One phase of one answer, in wall-clock ms on the server (6.76.0). plan is a model call that asked for a lookup,
188
+ * answer the one that wrote the draft, review the grounding check, public_topic the fact-check topic selection,
189
+ * fact_check the whole fact-check route. forced marks the COS lookup a person question always gets.
190
+ */
191
+ export interface MeetingAskPhaseTiming {
192
+ phase: 'plan' | 'answer' | 'review' | 'cos_search' | 'web_search' | 'public_topic' | 'fact_check';
193
+ ms: number;
194
+ forced?: true;
195
+ failed?: true;
196
+ results?: number;
197
+ }
198
+ export interface MeetingAskTimings {
199
+ /** From the server starting the request to its final state. */
200
+ totalMs: number;
201
+ phases: MeetingAskPhaseTiming[];
202
+ }
181
203
  export interface MeetingAskFactCheckRange {
182
204
  fromMs: number;
183
205
  throughMs: number;