@gotcos/glasses-server 6.73.5 → 6.74.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.
Files changed (50) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/package.json +1 -1
  3. package/server/index.ts +5 -0
  4. package/server/lib/ask-session-id.ts +5 -0
  5. package/server/lib/decision-provider.ts +163 -0
  6. package/server/lib/decision-settings.ts +72 -0
  7. package/server/lib/diarizer-backend.ts +24 -2
  8. package/server/lib/dictation-benchmark.ts +197 -0
  9. package/server/lib/dictation-stt.ts +61 -2
  10. package/server/lib/glossary-learn.ts +221 -0
  11. package/server/lib/imported-library-rows.ts +62 -4
  12. package/server/lib/jev.ts +3 -3
  13. package/server/lib/lens-gist-engines.ts +7 -2
  14. package/server/lib/live-cos-ask.ts +2 -1
  15. package/server/lib/mac-recording-asr.ts +59 -0
  16. package/server/lib/mac-recording.ts +111 -0
  17. package/server/lib/meeting-actions.ts +1 -0
  18. package/server/lib/meeting-ask-follow-up.ts +2 -2
  19. package/server/lib/meeting-ask-runner.ts +26 -5
  20. package/server/lib/meeting-ask-store.ts +2 -1
  21. package/server/lib/meeting-batch-transcribe.ts +19 -4
  22. package/server/lib/meeting-context-keys.ts +7 -4
  23. package/server/lib/meeting-parse.ts +2 -0
  24. package/server/lib/meeting-store.ts +62 -9
  25. package/server/lib/parakeet-cli-trust.ts +262 -0
  26. package/server/lib/parakeet.ts +156 -29
  27. package/server/lib/session-recommendation.ts +12 -9
  28. package/server/lib/speaker-embeddings.ts +2 -2
  29. package/server/lib/stranded-session-actions.ts +15 -0
  30. package/server/lib/stt-runtime.ts +43 -11
  31. package/server/lib/stt-settings.ts +88 -3
  32. package/server/lib/subq-key.ts +38 -0
  33. package/server/lib/transcribe-audio.ts +87 -7
  34. package/server/lib/work-activity.ts +2 -2
  35. package/server/lib/work-chat.ts +113 -13
  36. package/server/lib/work-completion.ts +11 -8
  37. package/server/lib/work-intake-store.ts +2 -1
  38. package/server/lib/work-search.ts +32 -12
  39. package/server/routes/client-instance.ts +1 -1
  40. package/server/routes/decision-provider.ts +36 -0
  41. package/server/routes/glossary.ts +143 -11
  42. package/server/routes/jev.ts +3 -2
  43. package/server/routes/meeting.ts +44 -12
  44. package/server/routes/meetings.ts +42 -4
  45. package/server/routes/stt-settings.ts +55 -4
  46. package/server/routes/subq-key.ts +27 -0
  47. package/server/routes/transcribe-stream.ts +115 -9
  48. package/server/routes/transcribe.ts +74 -6
  49. package/server/routes/work-chat.ts +10 -4
  50. package/server/routes/work-search.ts +2 -2
@@ -18,6 +18,7 @@ import { meetingListLimit } from '../lib/meeting-store.js'
18
18
  import { searchMeetingLibrary } from '../lib/meeting-library-search.js'
19
19
  import { meetingContextFields, meetingContextKeys, readOnlyContext } from '../lib/meeting-context-keys.js'
20
20
  import { g2RecordingsReachOperations } from '../lib/g2-ops-handoff.js'
21
+ import { MAC_CAPTURE_ID } from '../lib/mac-recording.js'
21
22
  import {
22
23
  type ImportedMeetingLibrary,
23
24
  IMPORTED_DOMAIN,
@@ -67,11 +68,36 @@ function withStandaloneIdentity(meeting: MeetingMeta): MeetingMeta {
67
68
  export function withMeetingContext<T extends MeetingMeta>(meeting: T, source: MeetingMeta['librarySource'] = meeting.librarySource): T {
68
69
  if (meeting.contextKeys !== undefined) return meeting
69
70
  if (source === 'standalone_recordings') {
70
- return { ...meeting, ...meetingContextFields(meetingContextKeys({ g2SessionId: meeting.sessionId })) }
71
+ return { ...meeting, ...meetingContextFields(meetingContextKeys({ g2SessionId: meeting.sessionId, sourceType: meeting.sourceType })) }
71
72
  }
72
73
  return { ...meeting, ...readOnlyContext() }
73
74
  }
74
75
 
76
+ /** A Mac capture's session id, as routes/meeting.ts keys the pipeline skip. */
77
+ export function isMacCaptureId(sessionId: string | undefined): boolean {
78
+ return typeof sessionId === 'string' && MAC_CAPTURE_ID.test(sessionId)
79
+ }
80
+
81
+ /**
82
+ * Operations rows plus the store's Mac recordings (6.73.14), newest first by date and time, capped at `limit`. Only Mac
83
+ * rows are added: a G2 store row is the pipeline's to file, and its operations copy is the row. A Mac row is left out
84
+ * when an operations row holds its session, or when `held` does (the sessions merged scribes declare), the same rule
85
+ * the day count applies (imported-library-rows macStoreDayCounts). Operations rows are never deduplicated against each
86
+ * other or reordered among themselves, but a newer Mac row can push the oldest of them past `limit`.
87
+ */
88
+ export function withMacStoreRows(operations: MeetingMeta[], storeRows: MeetingMeta[], limit: number, held: ReadonlySet<string> = new Set()): MeetingMeta[] {
89
+ const sessions = new Set(operations.map(row => row.sessionId).filter(Boolean))
90
+ const mac = storeRows
91
+ // Keyed on the sidecar's session, as the save route keys the pipeline skip (MAC_CAPTURE_ID). The row's sourceType
92
+ // comes from the markdown's Capture source line instead, which an edited or older record can lack.
93
+ .filter(row => isMacCaptureId(row.sessionId) && !sessions.has(row.sessionId) && !held.has(row.sessionId!))
94
+ .map(withStandaloneIdentity)
95
+ if (mac.length === 0) return operations
96
+ return [...operations, ...mac]
97
+ .sort((a, b) => `${b.date}T${b.time || '00:00'}`.localeCompare(`${a.date}T${a.time || '00:00'}`) || b.filename.localeCompare(a.filename))
98
+ .slice(0, Math.max(limit, 1))
99
+ }
100
+
75
101
  function mergeMeetingSources(groups: MeetingMeta[][], limit: number, max = 50): MeetingMeta[] {
76
102
  const seenSessions = new Set<string>()
77
103
  const seenExact = new Set<string>()
@@ -213,7 +239,7 @@ function savedMeetingDetailWithContext(
213
239
  const source = detail.librarySource ?? (detail.recordId?.startsWith('standalone:') ? 'standalone_recordings' : undefined)
214
240
  if (source === 'standalone_recordings' && detail.contextKeys === undefined) {
215
241
  const sessionId = detail.sessionId || store.storedSessionId(descriptor.month, detail.filename || descriptor.filename)
216
- return { ...detail, ...meetingContextFields(meetingContextKeys({ g2SessionId: sessionId })) }
242
+ return { ...detail, ...meetingContextFields(meetingContextKeys({ g2SessionId: sessionId, sourceType: detail.sourceType })) }
217
243
  }
218
244
  return withMeetingContext(detail, source)
219
245
  }
@@ -312,7 +338,11 @@ export function createMeetingsRouter(
312
338
  day: filters.day,
313
339
  })
314
340
  const declaredSessions = supersededFromRows(rows).g2Sessions
315
- const meetings = dropSupersededRows(rows, { g2Sessions: declaredSessions, importRecordIds: new Set<string>(), isEmpty: declaredSessions.size === 0 })
341
+ const operationsMeetings = dropSupersededRows(rows, { g2Sessions: declaredSessions, importRecordIds: new Set<string>(), isEmpty: declaredSessions.size === 0 })
342
+ // 6.73.14 (Miles 2026-10-15): routes/meeting.ts never hands a Mac recording to the pipeline (MAC_CAPTURE_ID),
343
+ // so on a pipeline Mac it lives only in this store and never appeared in Meetings; Control's hand-off read
344
+ // "Saved meeting not found yet" forever. The store's Mac rows join the operations rows here.
345
+ const meetings = withMacStoreRows(operationsMeetings, store.list({ ...listOptions, sessionMatch: isMacCaptureId }), limit, declaredSessions)
316
346
  // ONLY WHEN THE LIST SAW THE WHOLE MONTH. The row list is capped, and a capped
317
347
  // list knows about fewer merged scribes than the month holds, so handing its
318
348
  // sessions to an UNCAPPED day count would subtract too little and put the dot
@@ -320,7 +350,15 @@ export function createMeetingsRouter(
320
350
  const listSawWholeMonth = rows.length < limit
321
351
  res.json({
322
352
  meetings: meetings.map(meeting => withMeetingContext(meeting)),
323
- months: listCosOperationsMeetingMonths(domain),
353
+ // 6.73.14: a month holding only Mac recordings still reaches the calendar pager. Only store months the
354
+ // operations tree lacks are read, so a usual request reads nothing extra.
355
+ months: (() => {
356
+ const operationsMonths = listCosOperationsMeetingMonths(domain)
357
+ const known = new Set(operationsMonths)
358
+ const macOnly = store.listMonths().filter(month => !known.has(month)
359
+ && store.list({ month, domain, limit: 1, sessionMatch: isMacCaptureId }).length > 0)
360
+ return macOnly.length ? uniqueSortedMonths([operationsMonths, macOnly]) : operationsMonths
361
+ })(),
324
362
  // The rows above already read every scribe in this month and told us which
325
363
  // sessions the merged ones hold. Handing that over is the difference between
326
364
  // one read per file and two on every month request.
@@ -4,14 +4,20 @@
4
4
  // PUT /api/stt-settings partial body: {"hq":"parakeet-ultra"}, {"resolver":false}, ...
5
5
  // POST /api/stt-settings/parakeet/install install the pinned Parakeet CLI and models (202; poll GET)
6
6
  // body {} | {"localSource":"/abs/path"} | {"localSource":"...","allowDownload":true}
7
+ // 6.74.0: {"cliPath":"/Applications/COS Control.app/Contents/Helpers/fluidaudiocli","allowDownload":true}
8
+ // 507 insufficient_disk, 422 cli_untrusted. allowDownload:false without localSource never downloads.
7
9
  // DELETE /api/stt-settings/parakeet/install cancel a running install (nothing partial is kept)
10
+ // POST /api/stt-settings/parakeet/cli 6.74.0: COS Control registers its signed CLI and gets the verdict
8
11
  //
9
12
  // Clients find the route with GET: a 404 means an older server, so they hide the switcher.
10
13
  // The contract is shared with COS Control and the phone (see README "Speech-to-text settings").
11
14
 
12
15
  import { Router } from 'express'
13
16
  import { errMsg } from '../lib/utils.js'
14
- import { cancelParakeetInstall, installParakeet, parakeetInstalled, parakeetInstallRunning, type InstallOptions } from '../lib/parakeet.js'
17
+ import {
18
+ cancelParakeetInstall, findPinnedCli, installParakeet, parakeetDiskCheck, parakeetInstalled, parakeetInstallRunning, type InstallOptions,
19
+ } from '../lib/parakeet.js'
20
+ import { isCliPathShape, offerControlCli, trustCli, type CliTrust } from '../lib/parakeet-cli-trust.js'
15
21
  import { currentSttCapabilities, ensurePreviewWarm, parakeetStatus } from '../lib/stt-runtime.js'
16
22
  import {
17
23
  applySettingsPatch, readLastSttJob, readSttSettings, settingsView, SttSettingsError, writeSttSettings,
@@ -23,12 +29,22 @@ export interface SttSettingsRouterDeps {
23
29
  capabilities?: () => SttCapabilities
24
30
  install?: (options: InstallOptions) => Promise<void>
25
31
  onChanged?: () => void
32
+ /** 6.74.0, tests: the disk precheck and the CLI verdict. */
33
+ diskCheck?: () => { ok: boolean; freeBytes: number | null; requiredBytes: number }
34
+ trust?: (path: string) => Promise<CliTrust>
35
+ pinned?: (path: string) => Promise<boolean>
26
36
  }
27
37
 
28
38
  export function createSttSettingsRouter(deps: SttSettingsRouterDeps): Router {
29
39
  const router = Router()
30
40
  const caps = deps.capabilities ?? currentSttCapabilities
31
41
  const install = deps.install ?? installParakeet
42
+ const diskCheck = deps.diskCheck ?? (() => parakeetDiskCheck())
43
+ const trust = deps.trust ?? (path => trustCli(path))
44
+ const pinned = deps.pinned ?? (async (path: string) => (await findPinnedCli([path])) !== null)
45
+ const cliVerdict = (t: CliTrust) => t.trusted
46
+ ? { trusted: true, trust: t.trust, teamId: t.teamId, version: t.version, versionSource: t.versionSource }
47
+ : { trusted: false, reason: t.reason, detail: t.detail }
32
48
  const view = () => settingsView(readSttSettings(), caps(), parakeetStatus(deps.recordingLive()), readLastSttJob())
33
49
 
34
50
  router.get('/stt-settings', (_req, res) => {
@@ -48,19 +64,54 @@ export function createSttSettingsRouter(deps: SttSettingsRouterDeps): Router {
48
64
  }
49
65
  })
50
66
 
51
- router.post('/stt-settings/parakeet/install', (req, res) => {
67
+ // 6.74.0: COS Control says where its signed CLI is. The verdict is the same check the
68
+ // install makes; a trusted path also makes GET report Parakeet as installable.
69
+ router.post('/stt-settings/parakeet/cli', async (req, res) => {
70
+ try {
71
+ const cliPath = (req.body ?? {}).cliPath
72
+ if (!isCliPathShape(cliPath)) return void res.status(400).json({ error: 'cliPath must be an absolute path to fluidaudiocli.' })
73
+ const t = await trust(cliPath)
74
+ if (t.trusted) offerControlCli(cliPath)
75
+ res.status(t.trusted ? 200 : 422).json(cliVerdict(t))
76
+ } catch (err) {
77
+ res.status(500).json({ error: errMsg(err) })
78
+ }
79
+ })
80
+
81
+ router.post('/stt-settings/parakeet/install', async (req, res) => {
52
82
  try {
53
83
  if (deps.recordingLive()) return void res.status(409).json({ error: 'A recording is live. Install Parakeet after it is saved.' })
54
84
  if (parakeetInstalled().installed && !parakeetInstallRunning()) return void res.status(200).json({ started: false, ...view() })
55
- const body = (req.body ?? {}) as { localSource?: unknown; allowDownload?: unknown }
85
+ const body = (req.body ?? {}) as { localSource?: unknown; allowDownload?: unknown; cliPath?: unknown }
56
86
  const raw = body.localSource
57
87
  if (raw !== undefined && (typeof raw !== 'string' || !raw.startsWith('/') || raw.length > 1024)) {
58
88
  return void res.status(400).json({ error: 'localSource must be an absolute path.' })
59
89
  }
60
90
  if (body.allowDownload !== undefined && typeof body.allowDownload !== 'boolean') return void res.status(400).json({ error: 'allowDownload must be true or false.' })
91
+ if (body.cliPath !== undefined && !isCliPathShape(body.cliPath)) return void res.status(400).json({ error: 'cliPath must be an absolute path to fluidaudiocli.' })
92
+ if (!parakeetInstallRunning()) {
93
+ const disk = diskCheck()
94
+ if (!disk.ok) {
95
+ return void res.status(507).json({
96
+ error: `Parakeet needs ${(disk.requiredBytes / 1e9).toFixed(1)} GB free on this Mac. Free some space, then install again. Whisper keeps working.`,
97
+ reason: 'insufficient_disk', freeBytes: disk.freeBytes, requiredBytes: disk.requiredBytes,
98
+ })
99
+ }
100
+ if (typeof body.cliPath === 'string' && !(await pinned(body.cliPath))) {
101
+ const t = await trust(body.cliPath)
102
+ if (!t.trusted) {
103
+ return void res.status(422).json({ error: 'The speech tool in COS Control did not pass its check. Reinstall COS Control, then install again.', reason: 'cli_untrusted', cli: cliVerdict(t) })
104
+ }
105
+ offerControlCli(body.cliPath)
106
+ }
107
+ }
61
108
  // Only bytes whose SHA-256 matches the pinned manifest are ever copied from it.
62
109
  // A named localSource that lacks a file fails plainly unless allowDownload is true.
63
- const options: InstallOptions = raw ? { localSources: [raw], requireLocal: body.allowDownload !== true } : {}
110
+ // 6.74.0: allowDownload:false with no localSource also never downloads.
111
+ const options: InstallOptions = {
112
+ ...(raw ? { localSources: [raw], requireLocal: body.allowDownload !== true } : body.allowDownload === false ? { requireLocal: true } : {}),
113
+ ...(typeof body.cliPath === 'string' ? { cliCandidates: [body.cliPath] } : {}),
114
+ }
64
115
  if (!parakeetInstallRunning()) {
65
116
  void install(options).then(() => { ensurePreviewWarm(); deps.onChanged?.() }).catch(error => {
66
117
  console.warn(`[stt] Parakeet install failed: ${errMsg(error)}`)
@@ -0,0 +1,27 @@
1
+ /** Same authenticated push-only key workflow as Jev. No key values or upstream bodies are returned. */
2
+ import { Router } from 'express'
3
+ import { existsSync, unlinkSync } from 'node:fs'
4
+ import { resetSubqDecisionClients } from '../lib/decision-provider.js'
5
+ import { saveSubqKey, subqKeyStatus, SUBQ_KEY_FILE, validateSubqKey } from '../lib/subq-key.js'
6
+ import { secureExistingPrivateFile } from '../lib/secure-user-config.js'
7
+ interface SubqKeyRouteDeps { status: typeof subqKeyStatus; save: (key:string)=>void; validate: typeof validateSubqKey; remove:()=>void; reset:()=>void }
8
+ export function createSubqKeyRouter(overrides: Partial<SubqKeyRouteDeps> = {}): Router {
9
+ const deps:SubqKeyRouteDeps={status:()=>subqKeyStatus(),save:key=>saveSubqKey(key),validate:validateSubqKey,remove:()=>{secureExistingPrivateFile(SUBQ_KEY_FILE);if(existsSync(SUBQ_KEY_FILE))unlinkSync(SUBQ_KEY_FILE)},reset:resetSubqDecisionClients,...overrides}
10
+ const router=Router();router.use('/subq-key',(_req,res,next)=>{res.set('Cache-Control','private, no-store');next()})
11
+ router.get('/subq-key/status',(_req,res)=>res.json(deps.status()))
12
+ router.post('/subq-key/set',async(req,res)=>{
13
+ const raw=req.body?.key
14
+ if (!req.body||typeof req.body!=='object'||Object.keys(req.body).some(k=>k!=='key')||typeof raw!=='string') return void res.status(400).json({error:{code:'invalid_subq_key',message:'Paste a SubQ API key.'}})
15
+ const key=raw.trim()
16
+ if(key.length<16||key.length>512||/[\s\x00-\x1f\x7f]/.test(key)) return void res.status(400).json({error:{code:'invalid_subq_key',message:'That does not look like a SubQ API key.'}})
17
+ const check=await deps.validate(key)
18
+ if(!check.ok)return void res.status(400).json({error:{code:'subq_key_not_accepted',message:check.reason,upstreamStatus:check.status}})
19
+ try{deps.save(key);deps.reset()}catch{return void res.status(500).json({error:{code:'subq_key_not_saved',message:'The key could not be saved. Check the server data folder.'}})}
20
+ res.json({ok:true,...deps.status()})
21
+ })
22
+ router.delete('/subq-key',(_req,res)=>{
23
+ try{deps.remove();deps.reset()}catch{return void res.status(500).json({error:{code:'subq_key_not_removed',message:'The saved key could not be removed.'}})}
24
+ res.json({ok:true,...deps.status()})
25
+ })
26
+ return router
27
+ }
@@ -1,3 +1,6 @@
1
+ import { MacParakeetUnavailableError, transcribeMacParakeet } from '../lib/mac-recording-asr.js'
2
+ import { getMeetingStore } from '../lib/meeting-store.js'
3
+ import { MAC_AUDIO, MAC_CAPTURE_ID, persistMacChunkReceipt, macError, registerMacRecording, requireMacRecording, readMacRecording, validateMacWav } from '../lib/mac-recording.js'
1
4
  import { AUTO_ENROLL_THRESHOLD } from '../lib/voiceprint-floor.js'
2
5
  import { readSpeakerPolicy, policyLabel, type SpeakerMode } from '../lib/meeting-speaker-policy.js'
3
6
  // POST /api/transcribe-stream — Streaming transcription for continuous meeting capture
@@ -19,6 +22,7 @@ import {
19
22
  clearStrandedDraft,
20
23
  listStrandedDrafts,
21
24
  releaseStrandedState,
25
+ restoresAtBoot,
22
26
  shouldCloseAfterFailedPromote,
23
27
  sweepStrandedSessions,
24
28
  promoteStrandedSession,
@@ -277,7 +281,7 @@ export interface TranscriptChunk {
277
281
  /** Capture-time policy survives batch and final passes; absent means legacy Standard. */
278
282
  speakerPolicy?: SpeakerMode
279
283
  words?: WhisperWord[] // word-level timestamps from DTW alignment
280
- asrProvider?: 'server-whisper' | 'iphone-whisperkit-beta'
284
+ asrProvider?: 'server-whisper' | 'server-parakeet' | 'iphone-whisperkit-beta'
281
285
  backend?: string
282
286
  model?: string
283
287
  mode?: string
@@ -286,6 +290,7 @@ export interface TranscriptChunk {
286
290
  audioSha256?: string
287
291
  canonical?: boolean
288
292
  evenHubSpeakerRole?: EvenSpeakerRoleHistogram
293
+ audioDurationMs?: number
289
294
  /** 6.61.0: who spoke when inside this chunk, in time order. Present when
290
295
  * Nemotron separated the chunk's voices; `speaker` is then the dominant name. */
291
296
  segments?: ChunkSpeakerSegment[]
@@ -365,6 +370,8 @@ interface EmptyTranscriptCompletion {
365
370
  elapsed: number
366
371
  backend?: string
367
372
  asrProvider?: string
373
+ model?: string
374
+ latencyMs?: number
368
375
  fallbackReason?: string
369
376
  audioSha256?: string
370
377
  canonical: false
@@ -381,6 +388,8 @@ interface StreamChunkCompletionResponse {
381
388
  canonical: boolean
382
389
  backend?: string
383
390
  asrProvider?: string
391
+ model?: string
392
+ latencyMs?: number
384
393
  fallbackReason?: string
385
394
  /** 6.61.0, additive: the chunk's speaker turns in time order. Their texts
386
395
  * joined with one space equal `text`; `speaker` stays the dominant name. */
@@ -820,7 +829,7 @@ function recoverSessions(): void {
820
829
  if (data.sessionId && (hasChunks || Array.isArray(data.receivedIndices) || Number.isFinite(lastActivityAt))) {
821
830
  // Active retention is idle-based. Long meetings are not purged merely
822
831
  // because their original start time is old.
823
- if (Date.now() - lastActivityAt < LOCAL_FIRST_MEETING_IDLE_RETENTION_MS) {
832
+ if (restoresAtBoot(data.sessionId, Date.now() - lastActivityAt, LOCAL_FIRST_MEETING_IDLE_RETENTION_MS)) {
824
833
  const chunks: TranscriptChunk[] = []
825
834
  if (indexed) {
826
835
  for (const e of indexed) {
@@ -1520,7 +1529,11 @@ export function getSessionChunkEntries(sessionId: string): IndexedTranscriptChun
1520
1529
  elapsed: empty.elapsed,
1521
1530
  similarity: 0,
1522
1531
  backend: empty.backend,
1523
- asrProvider: empty.asrProvider === 'iphone-whisperkit-beta'
1532
+ ...(empty.model ? { model: empty.model } : {}),
1533
+ ...(empty.latencyMs !== undefined ? { latencyMs: empty.latencyMs } : {}),
1534
+ asrProvider: empty.asrProvider === 'server-parakeet'
1535
+ ? 'server-parakeet'
1536
+ : empty.asrProvider === 'iphone-whisperkit-beta'
1524
1537
  ? 'iphone-whisperkit-beta'
1525
1538
  : empty.asrProvider === 'server-whisper'
1526
1539
  ? 'server-whisper'
@@ -1547,6 +1560,9 @@ export function getSessionAsrCompletedIndices(sessionId: string): number[] {
1547
1560
  }
1548
1561
 
1549
1562
  function scheduleSessionProgressiveHq(sessionId: string): void {
1563
+ // Progressive checkpoints are Whisper-only and may not bypass a Mac's
1564
+ // explicit Parakeet choice, even if the separate HQ canary is enabled.
1565
+ if (MAC_CAPTURE_ID.test(sessionId) && readSttSettings().hq === 'parakeet-ultra') return
1550
1566
  // Preserve the default-off contract at the route boundary. In addition to
1551
1567
  // avoiding any cache work, this keeps legacy/local-first transcription
1552
1568
  // independent of the progressive HQ capability surface when the canary is
@@ -2016,6 +2032,8 @@ function canonicalChunkResponse(
2016
2032
  canonical: true,
2017
2033
  backend: existing.backend,
2018
2034
  asrProvider: existing.asrProvider,
2035
+ ...(existing.model ? { model: existing.model } : {}),
2036
+ ...(existing.latencyMs !== undefined ? { latencyMs: existing.latencyMs } : {}),
2019
2037
  fallbackReason: existing.fallbackReason,
2020
2038
  ...(existing.segments?.length ? { segments: existing.segments } : {}),
2021
2039
  }
@@ -2072,7 +2090,7 @@ function identifyChunkSpeaker(audioBuffer: Buffer, sessionId: string, chunkIndex
2072
2090
  if (!isEmbeddingAvailable() || audioDurationSec < VOICEPRINT_MIN_AUDIO_SEC) return policyLabel({ speaker: clientSpeaker, similarity: 0 }, policy)
2073
2091
 
2074
2092
  const tEmb = performance.now()
2075
- const embeddingResult = identifySpeaker(audioBuffer, expectedSpeakers, { minSimilarity: policy === 'high-confidence' ? AUTO_ENROLL_THRESHOLD : undefined })
2093
+ const embeddingResult = identifySpeaker(audioBuffer, expectedSpeakers, { minSimilarity: policy === 'high-confidence' ? AUTO_ENROLL_THRESHOLD : undefined, ...(MAC_CAPTURE_ID.test(sessionId) ? { prioritizeOwner: false, event: 'mac-system-audio' } : {}) })
2076
2094
  console.log(`[perf] identifySpeaker: ${(performance.now() - tEmb).toFixed(1)}ms`)
2077
2095
  if (!embeddingResult) return policyLabel({ speaker: clientSpeaker, similarity: 0 }, policy)
2078
2096
 
@@ -2080,8 +2098,9 @@ function identifyChunkSpeaker(audioBuffer: Buffer, sessionId: string, chunkIndex
2080
2098
  // it exists: identifySpeaker computes it per chunk and nothing else keeps it,
2081
2099
  // so a correction made later has no acoustic evidence without this write.
2082
2100
  // Deliberately includes 'Ext' — an unidentified voice is the case with no
2083
- // other way to be trained.
2084
- if (embeddingResult.embedding) {
2101
+ // other way to be trained. Mac playback is recognition-only: never bank
2102
+ // its voiceprints or create auxiliary training-audio copies.
2103
+ if (embeddingResult.embedding && !MAC_CAPTURE_ID.test(sessionId)) {
2085
2104
  appendChunkEmbedding(sessionId, {
2086
2105
  i: chunkIndex,
2087
2106
  speaker: policyLabel(embeddingResult, policy).speaker,
@@ -2117,7 +2136,7 @@ function identifyChunkSpeaker(audioBuffer: Buffer, sessionId: string, chunkIndex
2117
2136
  }
2118
2137
  }
2119
2138
 
2120
- if (speaker === 'Ext' && audioBuffer.length >= 16000) {
2139
+ if (speaker === 'Ext' && audioBuffer.length >= 16000 && !MAC_CAPTURE_ID.test(sessionId)) {
2121
2140
  const extSessionDir = resolve(EXT_AUDIO_DIR, sessionId)
2122
2141
  ensurePrivateDirectory(extSessionDir)
2123
2142
  const extCount = extAudioCounts.get(sessionId) ?? 0
@@ -2154,6 +2173,7 @@ async function processStreamChunk(opts: {
2154
2173
  clientElapsed?: number
2155
2174
  /** Original client recording start, applied only before canonical chunks. */
2156
2175
  startTimeOverride?: number
2176
+ audioDurationMs?: number
2157
2177
  evenHubSpeakerRole?: EvenSpeakerRoleHistogram
2158
2178
  }): Promise<StreamChunkCompletionResponse> {
2159
2179
  const { sessionId, chunkIndex, clientSpeaker, audioBuffer, candidate } = opts
@@ -2215,7 +2235,7 @@ async function processStreamChunk(opts: {
2215
2235
 
2216
2236
  // Pin identity policy at acceptance, before persistence, ASR or diarization
2217
2237
  // yields. A later loosening applies to the next chunk, never this audio.
2218
- const capturePolicy = readSpeakerPolicy(sessionId).mode
2238
+ const capturePolicy = MAC_CAPTURE_ID.test(sessionId) ? (requireMacRecording(sessionId), 'high-confidence' as const) : readSpeakerPolicy(sessionId).mode
2219
2239
  await persistRawSessionAudioChunk(sessionId, chunkIndex, audioBuffer)
2220
2240
  // Commit the received-index ledger only after the canonical raw WAV is
2221
2241
  // durable. A failure is typed non-2xx and a retry remains safe.
@@ -2250,7 +2270,7 @@ async function processStreamChunk(opts: {
2250
2270
  let rawText = ''
2251
2271
  let words: WhisperWord[] | undefined
2252
2272
  let backend = 'candidate'
2253
- let asrProvider: 'server-whisper' | 'iphone-whisperkit-beta' = 'server-whisper'
2273
+ let asrProvider: 'server-whisper' | 'server-parakeet' | 'iphone-whisperkit-beta' = 'server-whisper'
2254
2274
  let model: string | undefined
2255
2275
  let mode: string | undefined
2256
2276
  let latencyMs: number | undefined
@@ -2263,6 +2283,16 @@ async function processStreamChunk(opts: {
2263
2283
  model = candidate.model
2264
2284
  mode = candidate.mode
2265
2285
  latencyMs = candidate.latencyMs
2286
+ } else if (MAC_CAPTURE_ID.test(sessionId) && stt.hq === 'parakeet-ultra') {
2287
+ // Use the stored choice directly. effectiveStt/preview intentionally fall
2288
+ // back when unavailable, which is not an acceptable canonical Mac commit.
2289
+ const result = await transcribeMacParakeet(whisperAudio, quiet.speechSec)
2290
+ rawText = result.text
2291
+ words = result.words
2292
+ backend = result.backend
2293
+ asrProvider = result.asrProvider
2294
+ model = result.model
2295
+ latencyMs = result.latencyMs
2266
2296
  } else {
2267
2297
  const t0 = performance.now()
2268
2298
  const result = await transcribeWithServerWhisper(audioBuffer, whisperAudio, whisperContext, isQuiet)
@@ -2343,6 +2373,8 @@ async function processStreamChunk(opts: {
2343
2373
  elapsed,
2344
2374
  backend,
2345
2375
  asrProvider,
2376
+ ...(model ? { model } : {}),
2377
+ ...(latencyMs !== undefined ? { latencyMs } : {}),
2346
2378
  fallbackReason: sanitized.fallbackReason || fallbackReason,
2347
2379
  audioSha256,
2348
2380
  canonical: false,
@@ -2358,6 +2390,7 @@ async function processStreamChunk(opts: {
2358
2390
  const chunk: TranscriptChunk = {
2359
2391
  text: trimmedText,
2360
2392
  speakerPolicy: capturePolicy,
2393
+ ...(opts.audioDurationMs !== undefined ? { audioDurationMs: opts.audioDurationMs } : {}),
2361
2394
  speaker,
2362
2395
  elapsed,
2363
2396
  similarity,
@@ -2417,6 +2450,9 @@ async function processStreamChunk(opts: {
2417
2450
  }
2418
2451
 
2419
2452
  function sendStreamError(res: { status: (code: number) => { json: (body: unknown) => unknown } }, err: unknown): unknown {
2453
+ if (err instanceof MacParakeetUnavailableError) {
2454
+ return res.status(err.status).json({ error: err.message, reason: err.reason, retryable: true, model: err.model })
2455
+ }
2420
2456
  if (err instanceof TranscriptionUnavailableError) {
2421
2457
  console.warn(`[transcribe-stream] ${err.message}`)
2422
2458
  return res.status(err.status).json({
@@ -2507,12 +2543,82 @@ transcribeStreamRouter.post('/transcribe-stream/preview', async (req, res) => {
2507
2543
  }
2508
2544
  })
2509
2545
 
2546
+ /** Unlike the received ledger, only these canonical completions acknowledge ASR.
2547
+ * A native stop supplies the total captured chunks, including its last tail. */
2548
+ export function assertMacCaptureComplete(sessionId: string, expectedChunkCount: unknown): void {
2549
+ if (!Number.isInteger(expectedChunkCount) || Number(expectedChunkCount) < 1 || Number(expectedChunkCount) > 10000) {
2550
+ throw macError(400, 'mac_chunk_count_required', 'Mac save requires the total captured chunk count')
2551
+ }
2552
+ const session = sessions.get(sessionId)
2553
+ const expected = Number(expectedChunkCount)
2554
+ const missingIndices: number[] = []
2555
+ for (let index = 0; index < expected; index++) {
2556
+ if (!session?.chunks[index] && !session?.emptyCompletions?.[String(index)]) missingIndices.push(index)
2557
+ }
2558
+ if (missingIndices.length || (session?.maxChunkIndex ?? -1) >= expected) {
2559
+ throw Object.assign(macError(409, 'mac_capture_incomplete', 'Mac audio is not fully acknowledged; retry pending chunks before saving'), { missingIndices })
2560
+ }
2561
+ }
2562
+
2563
+ // Manual Mac pilot: independent from iPhone candidate and cue rollout gates.
2564
+ transcribeStreamRouter.get('/mac-recording/capabilities', (_req, res) => {
2565
+ res.set('Cache-Control', 'private, no-store').json({ available: true, ...MAC_AUDIO, serverInstanceId: getServerInstanceId() })
2566
+ })
2567
+ function requireMacPin(req: { get(name: string): string | undefined }): string {
2568
+ const pin = req.get('X-COS-Server-Instance')?.trim()
2569
+ if (!pin) throw macError(400, 'server_identity_pin_required', 'Server identity pin required')
2570
+ return assertPinnedServerIdentity(pin, undefined)
2571
+ }
2572
+ transcribeStreamRouter.post('/mac-recording/start', (req, res) => {
2573
+ let lease: MaintenanceWorkLease | undefined
2574
+ try {
2575
+ const serverInstanceId = requireMacPin(req)
2576
+ if (typeof req.body?.captureId !== 'string' || !MAC_CAPTURE_ID.test(req.body.captureId)) throw macError(400, 'invalid_capture_id', 'Invalid Mac capture ID')
2577
+ if (isSessionDeleted(req.body.captureId) || getMeetingStore().findBySessionId(req.body.captureId)) throw macError(410, 'session_closed', 'Capture is closed')
2578
+ // An existing non-Mac session may not acquire new source metadata.
2579
+ if (sessions.has(req.body.captureId) && !readMacRecording(req.body.captureId)) throw macError(409, 'mac_capture_conflict', 'Capture ID already exists')
2580
+ lease = acquireRecordingChunkLease({ allowDuringDrain: sessions.has(req.body.captureId) })
2581
+ const { recording, replayed } = registerMacRecording(req.body)
2582
+ const session = getSession(recording.sessionId)
2583
+ if (!session.chunks.some(Boolean)) session.startTime = recording.startTime
2584
+ persistSessionRequired(recording.sessionId)
2585
+ res.json({ ...MAC_AUDIO, ...recording, replayed, serverInstanceId })
2586
+ } catch (error) { sendStreamError(res, error) } finally { lease?.release() }
2587
+ })
2588
+ const macChunkLocks = new Set<string>()
2589
+ transcribeStreamRouter.post('/mac-recording/:captureId/chunks', async (req, res) => {
2590
+ let lease: MaintenanceWorkLease | undefined
2591
+ let locked = false
2592
+ const id = String(req.params.captureId ?? '')
2593
+ try {
2594
+ requireMacPin(req)
2595
+ const recording = requireMacRecording(id)
2596
+ if (isSessionDeleted(id)) throw macError(410, 'session_closed', 'Capture is closed')
2597
+ const chunkIndex = Number(req.query.chunkIndex)
2598
+ validateChunkIndex(chunkIndex)
2599
+ const elapsed = Number(req.query.elapsed)
2600
+ if (!Number.isFinite(elapsed) || elapsed < 0 || elapsed > 24 * 60 * 60 * 1000) throw macError(400, 'invalid_elapsed', 'Valid capture elapsed time required')
2601
+ if (macChunkLocks.has(id)) throw macError(409, 'mac_chunk_in_progress', 'Another chunk is processing; retry the same chunk after its receipt')
2602
+ macChunkLocks.add(id); locked = true
2603
+ lease = acquireRecordingChunkLease({ allowDuringDrain: sessions.has(id) })
2604
+ const audio = await readBoundedRawBody(req, MAC_AUDIO.maxChunkBytes)
2605
+ const duration = validateMacWav(audio)
2606
+ if (elapsed < duration) throw macError(400, 'invalid_elapsed', 'Chunk end precedes its duration')
2607
+ const session = getSession(id)
2608
+ persistMacChunkReceipt(id, { chunkIndex, endElapsed: elapsed, durationMs: duration, audioSha256: sha256Hex(audio) })
2609
+ const prior = session.chunks[chunkIndex] ?? session.emptyCompletions?.[String(chunkIndex)]
2610
+ if (prior?.audioSha256 && prior.audioSha256 !== sha256Hex(audio)) throw macError(409, 'mac_chunk_conflict', 'Chunk index already holds different audio')
2611
+ res.json(await processStreamChunk({ sessionId: id, chunkIndex, clientSpeaker: 'Ext', audioBuffer: audio, clientElapsed: elapsed - duration, audioDurationMs: duration, startTimeOverride: recording.startTime }))
2612
+ } catch (error) { sendStreamError(res, error) } finally { if (locked) macChunkLocks.delete(id); lease?.release() }
2613
+ })
2614
+
2510
2615
  transcribeStreamRouter.post('/transcribe-stream', async (req, res) => {
2511
2616
  let maintenanceLease: MaintenanceWorkLease | undefined
2512
2617
  try {
2513
2618
  // Reject a wrong Mac before consuming or persisting any upload bytes.
2514
2619
  assertPinnedServerIdentity(req.get('X-COS-Server-Instance'), req.query.serverInstanceId)
2515
2620
  const sessionId = (req.query.sessionId as string) || `g2_${Date.now()}`
2621
+ if (sessionId.startsWith('mac_')) throw macError(400, 'mac_transport_required', 'Use the Mac recording transport for Mac captures')
2516
2622
  maintenanceLease = acquireRecordingChunkLease({
2517
2623
  allowDuringDrain: sessions.has(sessionId),
2518
2624
  })
@@ -1,5 +1,7 @@
1
1
  // POST /api/transcribe: one-shot transcription (message-query path). 6.73.0: the engine
2
2
  // follows the STT settings (docs/dictation-stt-contract.md); Whisper is the fallback.
3
+ // 6.74.0: Mac dictation (requiredEngine=local) follows its own dictation setting and
4
+ // never leaves this Mac; POST /api/transcribe/benchmark is the onboarding check.
3
5
  //
4
6
  // The actual Whisper + cleanup pipeline lives in server/lib/transcribe-audio.ts so
5
7
  // prompt-draft recovery and the legacy one-shot path share exactly the same behavior.
@@ -12,6 +14,9 @@ import {
12
14
  OpenAIWhisperBudgetExhaustedError,
13
15
  TranscriptionUnavailableError,
14
16
  } from '../lib/transcribe-audio.js'
17
+ import {
18
+ BENCHMARK_MAX_BYTES, BenchmarkRefusedError, parseBenchmarkEngines, runDictationBenchmark,
19
+ } from '../lib/dictation-benchmark.js'
15
20
  import {
16
21
  acquireMaintenanceWork,
17
22
  MaintenanceLifecycleError,
@@ -20,6 +25,58 @@ import {
20
25
  } from '../lib/maintenance-lifecycle.js'
21
26
 
22
27
  export const transcribeRouter = Router()
28
+ // A Mac client checks this before transferring microphone audio. Older servers must not
29
+ // silently ignore the requested engine and use a configured cloud fallback.
30
+ // 6.74.0: desktopDictationVersion stays 1, because COS Control 0.5.316 and earlier accept
31
+ // exactly 1. desktopDictationVersions lists 2: requiredEngine=local (the dictation setting).
32
+ transcribeRouter.get('/transcribe/capabilities', (_req, res) => {
33
+ res.json({
34
+ desktopDictationVersion: 1,
35
+ desktopDictationVersions: [1, 2],
36
+ requiredEngines: ['parakeet-ultra', 'local'],
37
+ localOnly: true,
38
+ dictionary: 'cos',
39
+ benchmark: 1,
40
+ })
41
+ })
42
+
43
+ const REQUIRED_ENGINES = new Set(['parakeet-ultra', 'local'])
44
+
45
+ async function readCapped(req: AsyncIterable<unknown>, cap: number): Promise<Buffer | null> {
46
+ const chunks: Buffer[] = []
47
+ let size = 0
48
+ for await (const chunk of req) {
49
+ const part = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array)
50
+ size += part.length
51
+ if (size > cap) return null
52
+ chunks.push(part)
53
+ }
54
+ return Buffer.concat(chunks)
55
+ }
56
+
57
+ // POST /api/transcribe/benchmark?engines=whisper,parakeet-ultra: raw 16 kHz WAV body.
58
+ // Bounded in dictation-benchmark.ts (one at a time, never while recording, timeouts).
59
+ transcribeRouter.post('/transcribe/benchmark', async (req, res) => {
60
+ let maintenanceLease: MaintenanceWorkLease | undefined
61
+ try {
62
+ const engines = parseBenchmarkEngines(req.query.engines)
63
+ if (!engines) return res.status(400).json({ error: 'engines must list whisper and/or parakeet-ultra.', reason: 'invalid_engine' })
64
+ maintenanceLease = acquireMaintenanceWork('one_shot_transcription')
65
+ const audio = await readCapped(req, BENCHMARK_MAX_BYTES)
66
+ if (!audio) return res.status(413).json({ error: 'The sample is longer than two minutes.', reason: 'audio_too_large' })
67
+ const result = await runDictationBenchmark(audio, engines)
68
+ res.json(result)
69
+ } catch (err: any) {
70
+ if (err instanceof MaintenanceLifecycleError) {
71
+ if (err.retryAfterSeconds != null) res.setHeader('Retry-After', String(err.retryAfterSeconds))
72
+ return res.status(err.status).json(maintenanceErrorPayload(err))
73
+ }
74
+ if (err instanceof BenchmarkRefusedError) return res.status(err.status).json({ error: err.message, reason: err.reason })
75
+ res.status(500).json({ error: err.message })
76
+ } finally {
77
+ maintenanceLease?.release()
78
+ }
79
+ })
23
80
 
24
81
  function resolveMode(req: { body?: { mode?: string }; query?: { mode?: string | string[] } }) {
25
82
  return resolveTranscribeMode(
@@ -33,17 +90,22 @@ transcribeRouter.post('/transcribe', async (req, res) => {
33
90
  let maintenanceLease: MaintenanceWorkLease | undefined
34
91
  try {
35
92
  maintenanceLease = acquireMaintenanceWork('one_shot_transcription')
36
- const chunks: Buffer[] = []
37
- for await (const chunk of req) {
38
- chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
93
+ const rawEngine = req.query.requiredEngine
94
+ if (rawEngine !== undefined && (typeof rawEngine !== 'string' || !REQUIRED_ENGINES.has(rawEngine))) {
95
+ return res.status(400).json({ error: 'invalid_required_engine' })
39
96
  }
40
- const audioBuffer = Buffer.concat(chunks)
97
+ const requiredEngine = rawEngine as 'parakeet-ultra' | 'local' | undefined
98
+ const audioBuffer = await readCapped(req, requiredEngine ? 3_840_044 : 25 * 1024 * 1024)
99
+ if (!audioBuffer) return res.status(413).json({ error: 'audio_too_large' })
41
100
 
42
101
  if (audioBuffer.length < 100) {
43
102
  return res.status(400).json({ error: 'audio too short' })
44
103
  }
45
104
 
46
- const result = await transcribeAudioBuffer(audioBuffer, { mode: resolveMode(req) })
105
+ const result = await transcribeAudioBuffer(audioBuffer, {
106
+ mode: resolveMode(req),
107
+ ...(requiredEngine ? { requiredEngine, policy: 'local-only' as const, affectsCircuit: false } : {}),
108
+ })
47
109
  console.log(`[perf] /transcribe: ${result.elapsedMs.toFixed(1)}ms | mode=${result.mode} | ${result.backend}${result.engineReason ? ` (${result.engineReason})` : ''} | ${result.audioBytes}b | ${result.text.length} chars`)
48
110
  res.json({
49
111
  text: result.text,
@@ -56,6 +118,9 @@ transcribeRouter.post('/transcribe', async (req, res) => {
56
118
  // 6.73.0, additive: which engine decoded it and, when the selection could not run, why.
57
119
  engine: result.engine,
58
120
  ...(result.engineReason ? { engineReason: result.engineReason } : {}),
121
+ // 6.74.0, Mac dictation only.
122
+ ...(result.dictationSetting ? { dictationSetting: result.dictationSetting } : {}),
123
+ ...(result.fallbackReason ? { fallbackReason: result.fallbackReason } : {}),
59
124
  })
60
125
  } catch (err: any) {
61
126
  if (err instanceof MaintenanceLifecycleError) {
@@ -77,10 +142,13 @@ transcribeRouter.post('/transcribe', async (req, res) => {
77
142
  }
78
143
  if (err instanceof TranscriptionUnavailableError) {
79
144
  console.warn(`[transcribe] ${err.message}`)
145
+ // The 6.73.x body for every caller; Mac dictation on requiredEngine=local adds
146
+ // the one action it offers, and says when a retry cannot succeed.
80
147
  return res.status(err.status).json({
81
148
  error: err.message,
82
149
  reason: err.reason,
83
- retryable: true,
150
+ retryable: err.hint ? err.hint.retryable : true,
151
+ ...(err.hint ? { action: err.hint.action, detail: err.hint.detail } : {}),
84
152
  })
85
153
  }
86
154
  res.status(500).json({ error: err.message })
@@ -1,11 +1,14 @@
1
1
  import { Router } from 'express'
2
- import { parseWorkChatInput, parseWorkChatAnswer, WORK_CHAT_SYSTEM } from '../lib/work-chat.js'
2
+ import { parseWorkChatInput, parseWorkChatAnswer, WORK_CHAT_SYSTEM, WORK_CHAT_PLANNERS, WORK_CHAT_EFFORTS, WORK_CHAT_DEFAULT_EFFORT,
3
+ WORK_CHAT_OUTPUT_TOKENS, WORK_CHAT_THINKING_TOKENS, WORK_CHAT_DEADLINE_MS, WORK_CHAT_MAX_ASSIGNMENTS, WORK_CHAT_MAX_ITEMS, WORK_CHAT_MAX_REFERENCES } from '../lib/work-chat.js'
3
4
  import { runMeetingAskModel } from '../lib/meeting-ask-runner.js'
4
5
  import { acquireMaintenanceWork, maintenanceAdmissionsOpen } from '../lib/maintenance-lifecycle.js'
5
6
  export function createWorkChatRouter(deps = {run:runMeetingAskModel, admissions:maintenanceAdmissionsOpen, lease:()=>acquireMaintenanceWork('work_chat')}) {
6
7
  const router=Router(); let running=false
7
8
  router.use('/work/chat', (_req,res,next)=>{res.set('Cache-Control','private, no-store');next()})
8
- router.get('/work/chat',(_req,res)=>res.json({protocolVersion:1,planner:'sonnet',automaticProviders:['claude','codex'],maxAssignments:3}))
9
+ // 6.73.15: queueRounds means sessions may carry openWork, and the planner routes around sessions with earlier work open.
10
+ // references means the input may carry read-only reference sessions (never targets).
11
+ router.get('/work/chat',(_req,res)=>res.json({protocolVersion:1,planner:'sonnet',planners:WORK_CHAT_PLANNERS,effort:WORK_CHAT_DEFAULT_EFFORT,efforts:WORK_CHAT_EFFORTS,automaticProviders:['claude','codex'],maxAssignments:WORK_CHAT_MAX_ASSIGNMENTS,contextItems:true,maxContextItems:WORK_CHAT_MAX_ITEMS,queueRounds:true,references:true,maxReferences:WORK_CHAT_MAX_REFERENCES}))
9
12
  router.post('/work/chat',async(req,res)=>{
10
13
  let input
11
14
  try { if (Buffer.byteLength(JSON.stringify(req.body))>100000) throw new Error(); input=parseWorkChatInput(req.body) }
@@ -17,9 +20,12 @@ export function createWorkChatRouter(deps = {run:runMeetingAskModel, admissions:
17
20
  res.on('close',abort)
18
21
  try {
19
22
  lease=deps.lease()
20
- const out=await deps.run({engine:'claude',model:'sonnet'},JSON.stringify(input),WORK_CHAT_SYSTEM,controller.signal,undefined,{caller:'work_chat_'+input.mode})
23
+ // Planner and effort select how the model runs; they are not evidence, so they never reach the prompt.
24
+ const {planner='sonnet',effort=WORK_CHAT_DEFAULT_EFFORT,...prompt}=input
25
+ const out=await deps.run({engine:'claude',model:planner},JSON.stringify(prompt),WORK_CHAT_SYSTEM,controller.signal,undefined,{caller:'work_chat_'+input.mode},
26
+ {effort,maxOutputTokens:WORK_CHAT_OUTPUT_TOKENS,maxThinkingTokens:WORK_CHAT_THINKING_TOKENS[effort],deadlineMs:WORK_CHAT_DEADLINE_MS})
21
27
  const answer=parseWorkChatAnswer(out.text,input)
22
- if (!res.destroyed) return res.json({protocolVersion:1,...answer,model:out.model??'claude:sonnet (requested)'})
28
+ if (!res.destroyed) return res.json({protocolVersion:1,...answer,model:out.model??`claude:${planner} (requested)`,effort})
23
29
  } catch {if(!res.destroyed) return res.status(503).json({error:'work_chat_unavailable',message:'No assignment was dispatched. Your saved request can be retried.'})}
24
30
  finally {res.off('close',abort);lease?.release();running=false}
25
31
  })