@gotcos/glasses-server 6.73.5 → 6.73.14

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 (42) hide show
  1. package/CHANGELOG.md +62 -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/glossary-learn.ts +221 -0
  8. package/server/lib/imported-library-rows.ts +62 -4
  9. package/server/lib/jev.ts +3 -3
  10. package/server/lib/lens-gist-engines.ts +7 -2
  11. package/server/lib/live-cos-ask.ts +2 -1
  12. package/server/lib/mac-recording-asr.ts +59 -0
  13. package/server/lib/mac-recording.ts +111 -0
  14. package/server/lib/meeting-actions.ts +1 -0
  15. package/server/lib/meeting-ask-follow-up.ts +2 -2
  16. package/server/lib/meeting-ask-runner.ts +26 -5
  17. package/server/lib/meeting-ask-store.ts +2 -1
  18. package/server/lib/meeting-batch-transcribe.ts +19 -4
  19. package/server/lib/meeting-context-keys.ts +7 -4
  20. package/server/lib/meeting-parse.ts +2 -0
  21. package/server/lib/meeting-store.ts +62 -9
  22. package/server/lib/session-recommendation.ts +12 -9
  23. package/server/lib/speaker-embeddings.ts +2 -2
  24. package/server/lib/stranded-session-actions.ts +15 -0
  25. package/server/lib/subq-key.ts +38 -0
  26. package/server/lib/transcribe-audio.ts +9 -1
  27. package/server/lib/work-activity.ts +2 -2
  28. package/server/lib/work-chat.ts +87 -11
  29. package/server/lib/work-completion.ts +11 -8
  30. package/server/lib/work-intake-store.ts +2 -1
  31. package/server/lib/work-search.ts +32 -12
  32. package/server/routes/client-instance.ts +1 -1
  33. package/server/routes/decision-provider.ts +36 -0
  34. package/server/routes/glossary.ts +143 -11
  35. package/server/routes/jev.ts +3 -2
  36. package/server/routes/meeting.ts +44 -12
  37. package/server/routes/meetings.ts +42 -4
  38. package/server/routes/subq-key.ts +27 -0
  39. package/server/routes/transcribe-stream.ts +115 -9
  40. package/server/routes/transcribe.ts +20 -2
  41. package/server/routes/work-chat.ts +8 -4
  42. package/server/routes/work-search.ts +2 -2
@@ -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
  })
@@ -20,6 +20,11 @@ import {
20
20
  } from '../lib/maintenance-lifecycle.js'
21
21
 
22
22
  export const transcribeRouter = Router()
23
+ // A Mac client checks this before transferring microphone audio. Older servers must not
24
+ // silently ignore the requested engine and use a configured cloud fallback.
25
+ transcribeRouter.get('/transcribe/capabilities', (_req, res) => {
26
+ res.json({ desktopDictationVersion: 1, requiredEngines: ['parakeet-ultra'], localOnly: true, dictionary: 'cos' })
27
+ })
23
28
 
24
29
  function resolveMode(req: { body?: { mode?: string }; query?: { mode?: string | string[] } }) {
25
30
  return resolveTranscribeMode(
@@ -34,8 +39,18 @@ transcribeRouter.post('/transcribe', async (req, res) => {
34
39
  try {
35
40
  maintenanceLease = acquireMaintenanceWork('one_shot_transcription')
36
41
  const chunks: Buffer[] = []
42
+ const requiredEngine = req.query.requiredEngine
43
+ if (requiredEngine !== undefined && requiredEngine !== 'parakeet-ultra') {
44
+ return res.status(400).json({ error: 'invalid_required_engine' })
45
+ }
46
+ let size = 0
37
47
  for await (const chunk of req) {
38
- chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
48
+ const part = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
49
+ size += part.length
50
+ if (size > (requiredEngine ? 3_840_044 : 25 * 1024 * 1024)) {
51
+ return res.status(413).json({ error: 'audio_too_large' })
52
+ }
53
+ chunks.push(part)
39
54
  }
40
55
  const audioBuffer = Buffer.concat(chunks)
41
56
 
@@ -43,7 +58,10 @@ transcribeRouter.post('/transcribe', async (req, res) => {
43
58
  return res.status(400).json({ error: 'audio too short' })
44
59
  }
45
60
 
46
- const result = await transcribeAudioBuffer(audioBuffer, { mode: resolveMode(req) })
61
+ const result = await transcribeAudioBuffer(audioBuffer, {
62
+ mode: resolveMode(req),
63
+ ...(requiredEngine ? { requiredEngine, policy: 'local-only' as const, affectsCircuit: false } : {}),
64
+ })
47
65
  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
66
  res.json({
49
67
  text: result.text,
@@ -1,11 +1,12 @@
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 } 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
+ 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}))
9
10
  router.post('/work/chat',async(req,res)=>{
10
11
  let input
11
12
  try { if (Buffer.byteLength(JSON.stringify(req.body))>100000) throw new Error(); input=parseWorkChatInput(req.body) }
@@ -17,9 +18,12 @@ export function createWorkChatRouter(deps = {run:runMeetingAskModel, admissions:
17
18
  res.on('close',abort)
18
19
  try {
19
20
  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})
21
+ // Planner and effort select how the model runs; they are not evidence, so they never reach the prompt.
22
+ const {planner='sonnet',effort=WORK_CHAT_DEFAULT_EFFORT,...prompt}=input
23
+ const out=await deps.run({engine:'claude',model:planner},JSON.stringify(prompt),WORK_CHAT_SYSTEM,controller.signal,undefined,{caller:'work_chat_'+input.mode},
24
+ {effort,maxOutputTokens:WORK_CHAT_OUTPUT_TOKENS,maxThinkingTokens:WORK_CHAT_THINKING_TOKENS[effort],deadlineMs:WORK_CHAT_DEADLINE_MS})
21
25
  const answer=parseWorkChatAnswer(out.text,input)
22
- if (!res.destroyed) return res.json({protocolVersion:1,...answer,model:out.model??'claude:sonnet (requested)'})
26
+ if (!res.destroyed) return res.json({protocolVersion:1,...answer,model:out.model??`claude:${planner} (requested)`,effort})
23
27
  } 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
28
  finally {res.off('close',abort);lease?.release();running=false}
25
29
  })
@@ -8,7 +8,7 @@
8
8
  import { Router } from 'express'
9
9
  import { listBoard } from '../lib/task-store.js'
10
10
  import { isSafeDomainName } from '../lib/domains.js'
11
- import { queryLength, sharedWorkSearchJevClient, selectCandidates, unavailable, workSearchEnabled, WorkSearcher, WORK_SEARCH_LIMITS,
11
+ import { queryLength, sharedWorkSearchDecisionClient, selectCandidates, unavailable, workSearchEnabled, WorkSearcher, WORK_SEARCH_LIMITS,
12
12
  WORK_SEARCH_SCOPES, type WorkSearchResult, type WorkSearchScope } from '../lib/work-search.js'
13
13
 
14
14
  export interface WorkSearchRouteDependencies {
@@ -23,7 +23,7 @@ const TASK_ID = /^[a-f0-9]{12}$/
23
23
  export const WORK_SEARCH_LOG_EVERY_MS = 10 * 60_000
24
24
 
25
25
  export function createWorkSearchRouter(overrides: Partial<WorkSearchRouteDependencies> = {}): Router {
26
- const deps: WorkSearchRouteDependencies = { searcher: new WorkSearcher(sharedWorkSearchJevClient()), list: () => listBoard(),
26
+ const deps: WorkSearchRouteDependencies = { searcher: new WorkSearcher(sharedWorkSearchDecisionClient()), list: () => listBoard(),
27
27
  enabled: () => workSearchEnabled(), ...overrides }
28
28
  const router = Router()
29
29
  const logged = new Map<string, number>()