@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.
Files changed (98) hide show
  1. package/CHANGELOG.md +227 -0
  2. package/package.json +2 -1
  3. package/server/index.ts +170 -11
  4. package/server/lib/api-auth.ts +31 -14
  5. package/server/lib/claude-bridge.ts +21 -2
  6. package/server/lib/codex-bridge.ts +21 -2
  7. package/server/lib/cos-thread.ts +582 -0
  8. package/server/lib/display-bus.ts +3 -0
  9. package/server/lib/drop-attachments.ts +55 -0
  10. package/server/lib/drop-destinations.ts +134 -0
  11. package/server/lib/drop-history-search.ts +97 -0
  12. package/server/lib/drop-p2-boot.ts +60 -0
  13. package/server/lib/drop-p2-runtime.ts +237 -0
  14. package/server/lib/drop-p2-settings.ts +174 -0
  15. package/server/lib/drop-resolution.ts +168 -0
  16. package/server/lib/drop-router.ts +325 -0
  17. package/server/lib/drop-service.ts +830 -0
  18. package/server/lib/drop-settings.ts +56 -0
  19. package/server/lib/drop-store.ts +450 -0
  20. package/server/lib/drop-when.ts +265 -0
  21. package/server/lib/glasses-pairing.ts +15 -0
  22. package/server/lib/learning-needs-you.ts +111 -0
  23. package/server/lib/live-cues-v2-settings.ts +2 -1
  24. package/server/lib/live-cues-v2.ts +6 -4
  25. package/server/lib/media-store.ts +80 -0
  26. package/server/lib/meeting-batch-transcribe.ts +15 -2
  27. package/server/lib/meeting-consolidation-run.ts +169 -0
  28. package/server/lib/meeting-store.ts +61 -0
  29. package/server/lib/meeting-title-steer.ts +200 -0
  30. package/server/lib/network-policy.ts +84 -6
  31. package/server/lib/permission-broker.ts +13 -0
  32. package/server/lib/phone-recording.ts +1 -1
  33. package/server/lib/ping-budget.ts +149 -0
  34. package/server/lib/ping-engine.ts +245 -0
  35. package/server/lib/ping-feed-sink.ts +33 -0
  36. package/server/lib/ping-sources.ts +192 -0
  37. package/server/lib/query-job-coordinator.ts +5 -2
  38. package/server/lib/query-job-runtime.ts +45 -4
  39. package/server/lib/query-job-types.ts +10 -1
  40. package/server/lib/reminder-scheduler.ts +153 -0
  41. package/server/lib/tailnet-self-name.ts +103 -0
  42. package/server/lib/tailscale-nudge.ts +40 -2
  43. package/server/lib/task-store.ts +32 -0
  44. package/server/lib/web-ask-admission.ts +56 -0
  45. package/server/lib/web-live-chunks.ts +273 -0
  46. package/server/lib/web-memories-bridge.ts +323 -0
  47. package/server/lib/web-session-auth.ts +492 -0
  48. package/server/lib/web-usage-bridge.ts +154 -0
  49. package/server/lib/work-search.ts +14 -1
  50. package/server/lib/your-move-count.ts +58 -0
  51. package/server/routes/cos-thread.ts +79 -0
  52. package/server/routes/display.ts +11 -1
  53. package/server/routes/drops.ts +262 -0
  54. package/server/routes/health.ts +16 -0
  55. package/server/routes/live-cues.ts +5 -4
  56. package/server/routes/meeting.ts +61 -5
  57. package/server/routes/permission-broker.ts +23 -3
  58. package/server/routes/transcribe-stream.ts +43 -4
  59. package/server/routes/web-control.ts +569 -0
  60. package/server/routes/welcome-context.ts +2 -1
  61. package/server/scripts/web-live-cls/cdp.mjs +57 -0
  62. package/server/scripts/web-live-cls/measure.mjs +80 -0
  63. package/server/scripts/web-live-cls/mock.mjs +97 -0
  64. package/server/scripts/web-live-cls/render.mjs +26 -0
  65. package/server/web-control/app.css +519 -0
  66. package/server/web-control/app.js +3643 -0
  67. package/server/web-control/control.css +325 -0
  68. package/server/web-control/fonts/DMSans.ttf +0 -0
  69. package/server/web-control/fonts/Fraunces.ttf +0 -0
  70. package/server/web-control/fonts/JetBrainsMono.ttf +0 -0
  71. package/server/web-control/index.html +275 -0
  72. package/server/web-control/mark.svg +1 -0
  73. package/server/web-control/memories/d3.min.js +2 -0
  74. package/server/web-control/memories/graph-explorer.css +200 -0
  75. package/server/web-control/memories/graph-explorer.js +748 -0
  76. package/server/web-control/memories/memories-app.js +1561 -0
  77. package/server/web-control/memories/memories-theme.css +73 -0
  78. package/server/web-control/memories/memories.html +72 -0
  79. package/server/web-control/memories/memory-stewardship.js +63 -0
  80. package/server/web-control/memories/memory-workspace.js +216 -0
  81. package/server/web-control/panes/cos-chat.css +174 -0
  82. package/server/web-control/panes/cos-chat.js +1118 -0
  83. package/server/web-control/panes/dictation.js +173 -0
  84. package/server/web-control/panes/meetings.css +314 -0
  85. package/server/web-control/panes/meetings.js +2568 -0
  86. package/server/web-control/panes/messages.js +718 -0
  87. package/server/web-control/panes/questions.css +32 -0
  88. package/server/web-control/panes/questions.js +275 -0
  89. package/server/web-control/panes/sessions.css +452 -0
  90. package/server/web-control/panes/settings.js +292 -0
  91. package/server/web-control/panes/speakers.css +269 -0
  92. package/server/web-control/panes/speakers.js +2755 -0
  93. package/server/web-control/panes/threads.js +200 -0
  94. package/server/web-control/panes/work.css +261 -0
  95. package/server/web-control/panes/work.js +1667 -0
  96. package/server/web-control/shell.js +294 -0
  97. package/server/web-control/theme.js +25 -0
  98. package/server/web-control/vendor/qrcode-encoder.js +10 -0
@@ -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
- `${(body?.title as string | undefined) ?? ''}\n${transcript}`,
1427
+ `${savedTitle ?? ''}\n${transcript}`,
1377
1428
  resolveCosOperationsDir(),
1378
1429
  )
1379
1430
  const saved = store.save({
1380
1431
  sessionId,
1381
- title: body?.title as string | undefined,
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: typeof body.title === 'string' && body.title.trim() ? body.title : undefined,
3556
+ title: recoveredTitle,
3557
+ ...(steer ? { titleSteer: steer } : {}),
3502
3558
  domain: domainForMeeting(
3503
3559
  typeof body.domain === 'string' && body.domain.trim() ? body.domain : undefined,
3504
- `${typeof body.title === 'string' ? body.title : ''}\n${transcript}`,
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 (!timingSafeTokenEqual(req.headers['x-cos-token'], expected)) {
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 byte for byte what 6.85.0 sent.
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>()