@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
package/server/routes/meeting.ts
CHANGED
|
@@ -19,7 +19,7 @@ import { existsSync, readdirSync, readFileSync, rmSync, statSync, unlinkSync } f
|
|
|
19
19
|
import { isWorthRecovering } from '../lib/quarantine-auto-recover.js'
|
|
20
20
|
import { basename, resolve } from 'node:path'
|
|
21
21
|
import { createHash } from 'node:crypto'
|
|
22
|
-
import { Router, type RequestHandler } from 'express'
|
|
22
|
+
import { Router, type RequestHandler, type Response } from 'express'
|
|
23
23
|
import { emitDisplay } from '../lib/display-bus.js'
|
|
24
24
|
import { createStopRequestStore, isStopRequestRefusal, STOP_REQUEST_CLAIM_WINDOW_MS, type StopRequest, type StopRequestStore } from '../lib/meeting-stop-requests.js'
|
|
25
25
|
import { appendAutonomousSaveLog, getStopIntentStore, type StopIntent, type StopIntentStore } from '../lib/meeting-stop-intents.js'
|
|
@@ -153,6 +153,7 @@ import {
|
|
|
153
153
|
getSessionChunks,
|
|
154
154
|
getSessionProviderCandidates,
|
|
155
155
|
getSessionStartTime,
|
|
156
|
+
publishLiveTitleSteer,
|
|
156
157
|
getSessionTranscript,
|
|
157
158
|
getMeetingSessionStatus,
|
|
158
159
|
getStrandedCaptures,
|
|
@@ -175,6 +176,7 @@ import {
|
|
|
175
176
|
import { derivedSourcesFor, findDerivedRecord } from '../lib/imported-library-rows.js'
|
|
176
177
|
import { assertVoiceEvidenceSource } from '../lib/voice-evidence-guard.js'
|
|
177
178
|
import { domainForMeeting, resolveDomains } from '../lib/domains.js'
|
|
179
|
+
import { effectiveSaveTitle, getTitleSteerStore, normalizeTitleSteer, TitleSteerError, type TitleSteerStore } from '../lib/meeting-title-steer.js'
|
|
178
180
|
import { getOwnerSpeakerLabel } from '../lib/profile.js'
|
|
179
181
|
import {
|
|
180
182
|
DEATTRIBUTED_PREFIX,
|
|
@@ -319,6 +321,8 @@ interface MeetingSessionSource {
|
|
|
319
321
|
delete(sessionId: string, options?: { preserveAudio?: boolean }): void
|
|
320
322
|
/** 6.53.3: live, closed within its retention, or unknown (`getMeetingSessionStatus`). */
|
|
321
323
|
getState?(sessionId: string): 'active' | 'closed' | 'missing'
|
|
324
|
+
/** 6.99.0: write a changed title steer into the live session's record (transcribe-stream publishLiveTitleSteer). */
|
|
325
|
+
publishTitleSteer?(sessionId: string): boolean
|
|
322
326
|
/** 6.81.0: the received/ASR ledger the server's own save of a stopped meeting reads. */
|
|
323
327
|
getLedger?(sessionId: string): SessionLedger
|
|
324
328
|
}
|
|
@@ -340,6 +344,8 @@ function defaultSessionLedger(sessionId: string): SessionLedger {
|
|
|
340
344
|
|
|
341
345
|
export interface MeetingRouteDependencies {
|
|
342
346
|
store?: MeetingStore
|
|
347
|
+
/** 6.99.0: the live title steer ledger (lib/meeting-title-steer.ts). Tests inject one on a scratch path. */
|
|
348
|
+
titleSteer?: TitleSteerStore
|
|
343
349
|
/** 6.93.0: is any meeting capture (glasses, Mac or iPhone) live right now? Assigning a Mac voice to a profile is refused while one is. */
|
|
344
350
|
captureActive?: () => boolean
|
|
345
351
|
sessions?: MeetingSessionSource
|
|
@@ -656,6 +662,7 @@ const defaultSessionSource: MeetingSessionSource = {
|
|
|
656
662
|
moveAudioToPending: moveSessionAudioToPending,
|
|
657
663
|
delete: deleteSession,
|
|
658
664
|
getState: defaultSessionState,
|
|
665
|
+
publishTitleSteer: publishLiveTitleSteer,
|
|
659
666
|
}
|
|
660
667
|
|
|
661
668
|
function countWords(text: string): number {
|
|
@@ -794,6 +801,7 @@ const macAssignInFlight = new Set<string>()
|
|
|
794
801
|
|
|
795
802
|
export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router {
|
|
796
803
|
const store = deps.store ?? getMeetingStore()
|
|
804
|
+
const titleSteer = deps.titleSteer ?? getTitleSteerStore()
|
|
797
805
|
const sessions = deps.sessions ?? defaultSessionSource
|
|
798
806
|
const runBatch = deps.runBatch ?? runMeetingBatchPipeline
|
|
799
807
|
const scheduleBackground = deps.scheduleBackground ?? (task => { void task })
|
|
@@ -886,6 +894,43 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
886
894
|
})
|
|
887
895
|
autoDeps.onCreate?.(autonomousFinalizer)
|
|
888
896
|
|
|
897
|
+
// ── 6.99.0: title steer ─────────────────────────────────────────────────────────────
|
|
898
|
+
// A name typed in COS Control while a Mac recording or glasses meeting is live. It is a hint the title model receives
|
|
899
|
+
// at save (and the title itself when the save sends none), never the final word. See lib/meeting-title-steer.ts.
|
|
900
|
+
// The title is never logged here or anywhere: names and companies. Length only.
|
|
901
|
+
const steerFailure = (res: Response, error: unknown, sessionId: string) => {
|
|
902
|
+
if (error instanceof TitleSteerError) {
|
|
903
|
+
res.status(error.status).json({ error: error.reason, reason: error.reason, ...(error.reason === 'title_changed' ? { current: titleSteer.get(sessionId) } : {}) })
|
|
904
|
+
return
|
|
905
|
+
}
|
|
906
|
+
console.error('[meeting/title-steer] Unexpected failure:', error instanceof Error ? error.name : 'unknown')
|
|
907
|
+
res.status(500).json({ error: 'title_steer_error', reason: 'title_steer_error' })
|
|
908
|
+
}
|
|
909
|
+
router.get('/meeting/title-steer', (req, res) => {
|
|
910
|
+
const sessionId = typeof req.query.sessionId === 'string' ? req.query.sessionId : ''
|
|
911
|
+
res.set('Cache-Control', 'private, no-store')
|
|
912
|
+
try { res.json(titleSteer.get(sessionId)) } catch (error) { steerFailure(res, error, sessionId) }
|
|
913
|
+
})
|
|
914
|
+
router.put('/meeting/title-steer', (req, res) => {
|
|
915
|
+
const body = (req.body ?? {}) as Record<string, unknown>
|
|
916
|
+
const sessionId = typeof body.sessionId === 'string' ? body.sessionId : ''
|
|
917
|
+
res.set('Cache-Control', 'private, no-store')
|
|
918
|
+
try {
|
|
919
|
+
// Malformed input is a 400 whether or not the session is live; only a well-formed request earns a 404/409.
|
|
920
|
+
titleSteer.get(sessionId)
|
|
921
|
+
normalizeTitleSteer(body.title)
|
|
922
|
+
if (!Number.isSafeInteger(body.expectedRevision) || (body.expectedRevision as number) < 0) throw new TitleSteerError('invalid_revision', 400)
|
|
923
|
+
if ((sessions.getState ?? defaultSessionState)(sessionId) !== 'active') throw new TitleSteerError('session_not_live', 404)
|
|
924
|
+
// The save reads the steer once, then awaits before it closes the session. A rename in that gap would answer 200 and miss the
|
|
925
|
+
// filed meeting, so it is refused: the meeting is being filed under the name it had.
|
|
926
|
+
if (savingSessions.has(sessionId)) throw new TitleSteerError('save_in_progress', 409)
|
|
927
|
+
const view = titleSteer.set(sessionId, body.title, body.expectedRevision)
|
|
928
|
+
;(sessions.publishTitleSteer ?? (() => false))(sessionId)
|
|
929
|
+
console.log(`[meeting/title-steer] ${view.title ? 'set' : 'cleared'} ${sessionId} length=${view.title ? Array.from(view.title).length : 0} revision=${view.revision}`)
|
|
930
|
+
res.json(view)
|
|
931
|
+
} catch (error) { steerFailure(res, error, sessionId) }
|
|
932
|
+
})
|
|
933
|
+
|
|
889
934
|
let recoveryPendingLogged = false
|
|
890
935
|
router.get('/meeting/sessions/:sessionId/status', (req, res) => {
|
|
891
936
|
const sessionId = String(req.params.sessionId ?? '')
|
|
@@ -963,6 +1008,8 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
963
1008
|
disposition: publicDisposition(disposition),
|
|
964
1009
|
// 6.81.0, additive: the Stop the server holds for this meeting, if any. Older clients ignore it.
|
|
965
1010
|
stopIntent: intent ? publicStopIntent(intent) : null,
|
|
1011
|
+
// 6.99.0, additive: the name typed while it recorded (null = none). Older clients ignore it.
|
|
1012
|
+
titleSteer: titleSteer.title(sessionId),
|
|
966
1013
|
})
|
|
967
1014
|
})
|
|
968
1015
|
|
|
@@ -1371,14 +1418,19 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1371
1418
|
// save — infer from the content instead of filing everything under one
|
|
1372
1419
|
// domain. Keyword scoring, no model call: this runs on every save, and a
|
|
1373
1420
|
// per-meeting LLM call would breach the recurring-caller rule.
|
|
1421
|
+
// 6.99.0: the title steer (a name typed while it recorded) is the title when the request sends none, and is filed
|
|
1422
|
+
// beside the title either way so the title model can read it. It feeds the domain through the title, as a title does.
|
|
1423
|
+
const steer = titleSteer.title(sessionId)
|
|
1424
|
+
const savedTitle = effectiveSaveTitle(body?.title, steer)
|
|
1374
1425
|
const filedDomain = domainForMeeting(
|
|
1375
1426
|
body?.domain as string | undefined,
|
|
1376
|
-
`${
|
|
1427
|
+
`${savedTitle ?? ''}\n${transcript}`,
|
|
1377
1428
|
resolveCosOperationsDir(),
|
|
1378
1429
|
)
|
|
1379
1430
|
const saved = store.save({
|
|
1380
1431
|
sessionId,
|
|
1381
|
-
title:
|
|
1432
|
+
title: savedTitle,
|
|
1433
|
+
...(steer ? { titleSteer: steer } : {}),
|
|
1382
1434
|
domain: filedDomain,
|
|
1383
1435
|
transcript: cleanFinalTranscript(transcript),
|
|
1384
1436
|
startTime,
|
|
@@ -3496,12 +3548,16 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
3496
3548
|
words: item.words,
|
|
3497
3549
|
canonical: true,
|
|
3498
3550
|
}))
|
|
3551
|
+
// 6.99.0: same rule as POST /meeting/save: the title steer names the meeting when the request does not.
|
|
3552
|
+
const steer = titleSteer.title(sessionId)
|
|
3553
|
+
const recoveredTitle = effectiveSaveTitle(body.title, steer)
|
|
3499
3554
|
const saved = store.save({
|
|
3500
3555
|
sessionId,
|
|
3501
|
-
title:
|
|
3556
|
+
title: recoveredTitle,
|
|
3557
|
+
...(steer ? { titleSteer: steer } : {}),
|
|
3502
3558
|
domain: domainForMeeting(
|
|
3503
3559
|
typeof body.domain === 'string' && body.domain.trim() ? body.domain : undefined,
|
|
3504
|
-
`${
|
|
3560
|
+
`${recoveredTitle ?? ''}\n${transcript}`,
|
|
3505
3561
|
resolveCosOperationsDir(),
|
|
3506
3562
|
),
|
|
3507
3563
|
transcript,
|
|
@@ -30,6 +30,13 @@
|
|
|
30
30
|
// so a surface that only shows a count can poll without making the Mac hold questions it
|
|
31
31
|
// cannot answer. It checks X-Cos-Token itself as well, in constant time, and a request
|
|
32
32
|
// without it is refused and never counted, whatever sits in front of this router.
|
|
33
|
+
// 6.99.12: COS Control Web reads the same list with its signed browser session (the
|
|
34
|
+
// `cos_web_session` cookie, verified by `webSessionAuthorized`, the check the /api gate runs).
|
|
35
|
+
// The hook token still opens nothing here. A browser poll counts as a live answering client
|
|
36
|
+
// only with `?client=web`, which the page sends only while it shows the cards (a visible tab);
|
|
37
|
+
// the same 30 s window as the phone. A cookie poll naming `glasses` or `phone`, or no client,
|
|
38
|
+
// never counts, and a token poll naming `web` never counts either. A browser never reports
|
|
39
|
+
// wearer presence, so it never enables the away hold.
|
|
33
40
|
// 6.60.0: a poll with `&hold=1&presenceAgeMs=<int>` (COS Glasses 6.9.563 and later) records
|
|
34
41
|
// wearer presence for the away hold instead of the 6.59.0 liveness; a `hold=1` poll without
|
|
35
42
|
// a valid `presenceAgeMs` makes no client live and enables no hold. The response adds
|
|
@@ -40,6 +47,7 @@ import { timingSafeTokenEqual } from '../lib/token-auth.js'
|
|
|
40
47
|
import {
|
|
41
48
|
ANSWERING_CLIENTS,
|
|
42
49
|
CLIENT_LIVE_WINDOW_MS,
|
|
50
|
+
WEB_ANSWERING_CLIENT,
|
|
43
51
|
SESSION_QUESTIONS_PROTOCOL_VERSION,
|
|
44
52
|
type BrokerAdmission,
|
|
45
53
|
type HookReplyChannel,
|
|
@@ -138,8 +146,13 @@ export function createPermissionBrokerHookRouter(deps: PermissionBrokerHookRoute
|
|
|
138
146
|
|
|
139
147
|
export interface SessionQuestionsRouterDeps {
|
|
140
148
|
broker: PermissionBroker
|
|
141
|
-
/** The pairing token (X-Cos-Token). A poll counts as a live client only with it. */
|
|
149
|
+
/** The pairing token (X-Cos-Token). A poll counts as a live client only with it, or with the web session below. */
|
|
142
150
|
apiToken: () => string
|
|
151
|
+
/**
|
|
152
|
+
* 6.99.12: whether this request carries a valid COS Control Web session (the cookie, its epoch, and the origin
|
|
153
|
+
* rules: `isWebSessionAuthorized` in production). Absent: only the token opens the list, as before.
|
|
154
|
+
*/
|
|
155
|
+
webSessionAuthorized?: (req: Request) => boolean
|
|
143
156
|
/** Where a counted poll is recorded; `noteQuestionsPoll` in production. */
|
|
144
157
|
notePoll?: (at: number, poll: QuestionsPollFacts) => void
|
|
145
158
|
now?: () => number
|
|
@@ -171,14 +184,21 @@ export function createSessionQuestionsRouter(deps: SessionQuestionsRouterDeps):
|
|
|
171
184
|
res.set('Cache-Control', 'private, no-store')
|
|
172
185
|
let expected = ''
|
|
173
186
|
try { expected = deps.apiToken() } catch { expected = '' }
|
|
187
|
+
const byToken = timingSafeTokenEqual(req.headers['x-cos-token'], expected)
|
|
188
|
+
let byWebSession = false
|
|
189
|
+
if (!byToken && deps.webSessionAuthorized) {
|
|
190
|
+
try { byWebSession = deps.webSessionAuthorized(req) === true } catch { byWebSession = false }
|
|
191
|
+
}
|
|
174
192
|
// A hook token, or nothing: refused, and never a live client.
|
|
175
|
-
if (!
|
|
193
|
+
if (!byToken && !byWebSession) {
|
|
176
194
|
res.status(401).json({ error: 'unauthorized' })
|
|
177
195
|
return
|
|
178
196
|
}
|
|
179
197
|
// Only a client that says it can answer (and shows the cards) counts as live.
|
|
180
198
|
const client = typeof req.query.client === 'string' ? req.query.client : ''
|
|
181
|
-
if (ANSWERING_CLIENTS.has(client)) notePoll(now(), questionsPollFacts(req.query as Record<string, unknown>))
|
|
199
|
+
if (byToken && ANSWERING_CLIENTS.has(client)) notePoll(now(), questionsPollFacts(req.query as Record<string, unknown>))
|
|
200
|
+
// 6.99.13 (Miles, option B): a browser poll never counts as live, so an open web tab never holds a question
|
|
201
|
+
// back from the Mac's own pop-up. The web still lists and answers what the glasses or phone are holding.
|
|
182
202
|
const health = deps.broker.health()
|
|
183
203
|
// Listed first: a deadline the timer has not caught yet is settled by the listing.
|
|
184
204
|
const items = deps.broker.list()
|
|
@@ -128,6 +128,7 @@ import {
|
|
|
128
128
|
import { feedLiveCueTranscript } from '../lib/live-cues-engine.js'
|
|
129
129
|
import { armMacLiveCues, type MacLiveCuesAnswer } from '../lib/live-cues-v2.js'
|
|
130
130
|
import { liveProvisional } from '../lib/live-provisional.js'
|
|
131
|
+
import { getTitleSteerStore, TITLE_STEER_CAPABILITY } from '../lib/meeting-title-steer.js'
|
|
131
132
|
import { chunkLevel, restoreChunkLevels, withChunkLevel, type ChunkLevel } from '../lib/chunk-levels.js'
|
|
132
133
|
import { preemptMetalBatchForLive, registerLiveActivityProbe } from '../lib/whisper-metal-gate.js'
|
|
133
134
|
import {
|
|
@@ -792,6 +793,8 @@ function persistSessionRequired(sessionId: string, certified?:MacSpeakerLivePrep
|
|
|
792
793
|
startTime: session.startTime,
|
|
793
794
|
lastActivityAt: session.lastActivityAt,
|
|
794
795
|
title: session.title,
|
|
796
|
+
// 6.99.0: the name typed in COS Control while recording (lib/meeting-title-steer.ts). Control reads this file directly.
|
|
797
|
+
...titleSteerField(sessionId),
|
|
795
798
|
// `chunks` = legacy dense form, kept for backward compatibility with
|
|
796
799
|
// existing readers; `chunksIndexed` preserves original indices so gap
|
|
797
800
|
// detection survives recovery. Recovery prefers chunksIndexed.
|
|
@@ -818,6 +821,34 @@ function persistSessionRequired(sessionId: string, certified?:MacSpeakerLivePrep
|
|
|
818
821
|
}
|
|
819
822
|
}
|
|
820
823
|
|
|
824
|
+
/** `{ titleSteer }` for the active-session record: the live name, or nothing (the key is absent, never an empty string). */
|
|
825
|
+
function titleSteerField(sessionId: string): { titleSteer?: string } {
|
|
826
|
+
const title = getTitleSteerStore().title(sessionId)
|
|
827
|
+
return title ? { titleSteer: title } : {}
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/**
|
|
831
|
+
* 6.99.0: put a changed title steer into the live session's record NOW, so COS Control (which reads the active-session
|
|
832
|
+
* file, not a route) shows it without waiting for the next chunk. A targeted edit of one key, not persistSessionRequired:
|
|
833
|
+
* that rebuilds the whole record from memory and would re-judge the Mac speaker projection outside the chunk path. A
|
|
834
|
+
* session with no record yet gets the key at its first persist. Returns whether the file was updated; never throws.
|
|
835
|
+
*/
|
|
836
|
+
export function publishLiveTitleSteer(sessionId: string): boolean {
|
|
837
|
+
try {
|
|
838
|
+
if (!sessions.has(sessionId)) return false
|
|
839
|
+
const filePath = resolve(CHUNK_PERSIST_DIR, `${sessionId}.json`)
|
|
840
|
+
const stat = lstatSync(filePath)
|
|
841
|
+
if (!stat.isFile() || stat.size > 256 * 1024 * 1024) return false // lstat: a symlink is not a file here
|
|
842
|
+
const record = JSON.parse(readFileSync(filePath, 'utf-8')) as Record<string, unknown>
|
|
843
|
+
if (record.sessionId !== sessionId) return false
|
|
844
|
+
const { titleSteer, ...rest } = record
|
|
845
|
+
const field = titleSteerField(sessionId)
|
|
846
|
+
if (titleSteer === field.titleSteer) return true
|
|
847
|
+
durableAtomicWriteFileSync(filePath, JSON.stringify({ ...rest, ...field }), { mode: 0o600 })
|
|
848
|
+
return true
|
|
849
|
+
} catch { return false }
|
|
850
|
+
}
|
|
851
|
+
|
|
821
852
|
function persistSessionBestEffort(sessionId: string): void {
|
|
822
853
|
try { persistSessionRequired(sessionId) } catch { /* recovery cleanup is non-admission work */ }
|
|
823
854
|
}
|
|
@@ -2300,6 +2331,14 @@ function emptyChunkResponse(
|
|
|
2300
2331
|
}
|
|
2301
2332
|
}
|
|
2302
2333
|
|
|
2334
|
+
/**
|
|
2335
|
+
* 6.99.16: an iPhone recording's live lines come from the same server Whisper as a G2 meeting,
|
|
2336
|
+
* prompted with the profile vocabulary, so the dictionary shows while recording (Miles,
|
|
2337
|
+
* 2026-11-05: "as close to the g2 stack as possible"). Parakeet takes no prompt. The saved
|
|
2338
|
+
* transcript is unchanged: strict Parakeet, as the capability's asrModel says.
|
|
2339
|
+
*/
|
|
2340
|
+
export const PHONE_LIVE_ENGINE: 'server-whisper' | 'parakeet-ultra' = 'server-whisper'
|
|
2341
|
+
|
|
2303
2342
|
async function transcribeWithServerWhisper(audioBuffer: Buffer, whisperAudio: Buffer, whisperContext: string, isQuiet: boolean): Promise<{ text: string; words?: WhisperWord[]; backend: string }> {
|
|
2304
2343
|
// The worker owns reconciliation of stale health. Always attempt local ASR
|
|
2305
2344
|
// once; an availability snapshot must not divert meeting audio to cloud.
|
|
@@ -2608,7 +2647,7 @@ async function processStreamChunk(opts: {
|
|
|
2608
2647
|
model = candidate.model
|
|
2609
2648
|
mode = candidate.mode
|
|
2610
2649
|
latencyMs = candidate.latencyMs
|
|
2611
|
-
} else if (phoneCapture) {
|
|
2650
|
+
} else if (phoneCapture && PHONE_LIVE_ENGINE === 'parakeet-ultra') {
|
|
2612
2651
|
const result = await transcribePhoneParakeet(whisperAudio, quiet.speechSec)
|
|
2613
2652
|
rawText = result.text; words = result.words; backend = result.backend; asrProvider = result.asrProvider; model = result.model; latencyMs = result.latencyMs
|
|
2614
2653
|
} else if (MAC_CAPTURE_ID.test(sessionId) && stt.hq === 'parakeet-ultra') {
|
|
@@ -2945,7 +2984,7 @@ export function assertMacCaptureComplete(sessionId: string, expectedChunkCount:
|
|
|
2945
2984
|
transcribeStreamRouter.get('/mac-recording/capabilities', (_req, res) => {
|
|
2946
2985
|
// 6.83.2: `discard` advertises POST /api/meeting/sessions/:id/discard for a stranded no-sound capture. Older
|
|
2947
2986
|
// servers omit it, and COS Control then discards only its own local copy and says so.
|
|
2948
|
-
res.set('Cache-Control', 'private, no-store').json({ available: true, ...MAC_AUDIO, serverInstanceId: getServerInstanceId(), discard: true })
|
|
2987
|
+
res.set('Cache-Control', 'private, no-store').json({ available: true, ...MAC_AUDIO, serverInstanceId: getServerInstanceId(), discard: true, titleSteer: TITLE_STEER_CAPABILITY })
|
|
2949
2988
|
})
|
|
2950
2989
|
function requireMacPin(req: { get(name: string): string | undefined }): string {
|
|
2951
2990
|
const pin = req.get('X-COS-Server-Instance')?.trim()
|
|
@@ -2969,12 +3008,12 @@ transcribeStreamRouter.post('/mac-recording/start', (req, res) => {
|
|
|
2969
3008
|
if (!session.chunks.some(Boolean)) session.startTime = recording.startTime
|
|
2970
3009
|
persistSessionRequired(recording.sessionId)
|
|
2971
3010
|
// Arming happens only after the recording is admitted, and its answer never fails the start (armMacLiveCues returns a
|
|
2972
|
-
// reason code instead of throwing). Without `liveCues: true` the answer is
|
|
3011
|
+
// reason code instead of throwing). Without `liveCues: true` the answer is what 6.85.0 sent plus the additive `titleSteer` (6.99.0).
|
|
2973
3012
|
let liveCues: MacLiveCuesAnswer | undefined
|
|
2974
3013
|
if (req.body.liveCues === true) {
|
|
2975
3014
|
try { liveCues = armMacLiveCues(recording.sessionId) } catch { liveCues = { armed: false, reason: 'unavailable' } }
|
|
2976
3015
|
}
|
|
2977
|
-
res.json({ ...MAC_AUDIO, ...recording, replayed, serverInstanceId, ...(liveCues ? { liveCues } : {}) })
|
|
3016
|
+
res.json({ ...MAC_AUDIO, ...recording, replayed, serverInstanceId, ...(liveCues ? { liveCues } : {}), titleSteer: getTitleSteerStore().title(recording.sessionId) })
|
|
2978
3017
|
} catch (error) { sendStreamError(res, error) } finally { lease?.release() }
|
|
2979
3018
|
})
|
|
2980
3019
|
const macChunkLocks = new Set<string>()
|