@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.
- package/CHANGELOG.md +35 -0
- package/managed-runtime-contract.json +7 -0
- package/package.json +1 -1
- package/server/index.ts +2 -0
- package/server/lib/display-bus.ts +3 -1
- package/server/lib/jev.ts +12 -2
- package/server/lib/live-cos-ask.ts +77 -14
- package/server/lib/live-cues-engine.ts +3 -0
- package/server/lib/live-cues-v2-prompts.ts +41 -0
- package/server/lib/live-cues-v2.ts +639 -0
- package/server/lib/meeting-ask-owner.ts +144 -0
- package/server/lib/meeting-ask-runtime.ts +28 -6
- package/server/lib/meeting-stop-requests.ts +123 -0
- package/server/routes/display.ts +2 -2
- package/server/routes/health.ts +3 -0
- package/server/routes/live-cues.ts +48 -3
- package/server/routes/meeting-ask.ts +11 -1
- package/server/routes/meeting.ts +112 -0
- package/server/routes/transcribe-stream.ts +2 -1
- package/shared/live-cues-v2.ts +77 -0
- package/shared/meeting-ask.ts +23 -1
package/server/routes/meeting.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/shared/meeting-ask.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|