@gotcos/glasses-server 6.73.14 → 6.75.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.
@@ -14,6 +14,14 @@ import {
14
14
  LiveCuesArmError,
15
15
  } from '../lib/live-cues-engine.js'
16
16
  import { liveCuesCapability } from '../lib/live-cues-capability.js'
17
+ import { LiveCuesV2ArmError, liveCuesV2, liveCuesV2Advertised, liveCuesV2Enabled } from '../lib/live-cues-v2.js'
18
+
19
+ const SESSION_ID = /^[A-Za-z0-9:_-]{3,96}$/
20
+ const CUE_ID = /^[A-Za-z0-9-]{8,64}$/
21
+ function bodySessionId(body: unknown): string {
22
+ const raw = (body as { sessionId?: unknown })?.sessionId
23
+ return typeof raw === 'string' ? raw.trim() : ''
24
+ }
17
25
 
18
26
  export const liveCuesRouter = Router()
19
27
 
@@ -23,13 +31,17 @@ liveCuesRouter.post('/live-cues/start', async (req, res) => {
23
31
  : ''
24
32
  // A start with no sessionId is refused, never silently armed on a global
25
33
  // counter — the per-meeting cap is only real when it is session-scoped.
26
- if (!sessionId || !/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) {
34
+ if (!sessionId || !SESSION_ID.test(sessionId)) {
27
35
  return res.status(400).json({ error: 'sessionId required', reason: 'missing_session_id' })
28
36
  }
29
37
  try {
38
+ if (liveCuesV2Enabled()) return res.json(liveCuesV2().arm(sessionId))
30
39
  const result = await armLiveCues(sessionId)
31
40
  return res.json(result)
32
41
  } catch (error) {
42
+ if (error instanceof LiveCuesV2ArmError) {
43
+ return res.status(error.status).json({ error: error.message, reason: error.code, protocol: 2 })
44
+ }
33
45
  if (error instanceof LiveCuesArmError) {
34
46
  return res.status(error.status).json({ error: error.message, reason: error.code })
35
47
  }
@@ -41,18 +53,51 @@ liveCuesRouter.post('/live-cues/stop', (req, res) => {
41
53
  const sessionId = typeof (req.body as { sessionId?: unknown })?.sessionId === 'string'
42
54
  ? String((req.body as { sessionId: string }).sessionId).trim()
43
55
  : ''
44
- if (!sessionId) {
56
+ if (!sessionId || (liveCuesV2Enabled() && !SESSION_ID.test(sessionId))) {
45
57
  return res.status(400).json({ error: 'sessionId required', reason: 'missing_session_id' })
46
58
  }
47
59
  // Shape matches the existing client stop handler: it reads nudgesGenerated
48
60
  // for its log line and hydrates meetingNudgeHistory from nudges when SSE
49
61
  // missed events (the 200-event replay buffer is flooded by transcript_chunk).
62
+ if (liveCuesV2Enabled()) return res.json(liveCuesV2().end(sessionId))
50
63
  return res.json(disarmLiveCues(sessionId))
51
64
  })
52
65
 
53
- liveCuesRouter.get('/live-cues/status', (_req, res) => {
66
+ liveCuesRouter.get('/live-cues/status', (req, res) => {
67
+ const sessionId = typeof req.query.sessionId === 'string' ? req.query.sessionId.trim() : ''
68
+ // v2 with a sessionId answers the per-meeting shape; without one the v1 shape is unchanged (plus v2 capability).
69
+ if (sessionId && liveCuesV2Enabled()) {
70
+ if (!SESSION_ID.test(sessionId)) return res.status(400).json({ error: 'invalid sessionId', reason: 'invalid_session_id' })
71
+ return res.json(liveCuesV2().status(sessionId))
72
+ }
54
73
  res.json({
55
74
  capability: liveCuesCapability(),
56
75
  sessions: getLiveCuesStatus(),
76
+ ...(liveCuesV2Advertised() ? { v2: liveCuesV2Advertised() } : {}),
57
77
  })
58
78
  })
79
+
80
+ // POST /live-cues/pause { sessionId, paused, seq? } -> { ok, paused, applied, pauseSeq } (v2 only; the phone keeps its own local pause)
81
+ liveCuesRouter.post('/live-cues/pause', (req, res) => {
82
+ const sessionId = bodySessionId(req.body)
83
+ const paused = (req.body as { paused?: unknown })?.paused
84
+ const seq = (req.body as { seq?: unknown })?.seq
85
+ if (!SESSION_ID.test(sessionId) || typeof paused !== 'boolean' || (seq !== undefined && !(typeof seq === 'number' && Number.isSafeInteger(seq) && seq > 0))) {
86
+ return res.status(400).json({ error: 'sessionId and boolean paused required', reason: 'invalid_request' })
87
+ }
88
+ if (!liveCuesV2Enabled()) return res.status(404).json({ error: 'live cues v2 is off', reason: 'v2_disabled' })
89
+ return res.json(liveCuesV2().setPaused(sessionId, paused, seq as number | undefined))
90
+ })
91
+
92
+ // POST /live-cues/feedback { sessionId, cueId, vote: 'up'|'down', reason? } -> { ok, knownCue }
93
+ liveCuesRouter.post('/live-cues/feedback', (req, res) => {
94
+ const body = (req.body ?? {}) as { cueId?: unknown; vote?: unknown; reason?: unknown }
95
+ const sessionId = bodySessionId(req.body)
96
+ const cueId = typeof body.cueId === 'string' ? body.cueId.trim() : ''
97
+ if (!SESSION_ID.test(sessionId) || !CUE_ID.test(cueId) || (body.vote !== 'up' && body.vote !== 'down')
98
+ || (body.reason !== undefined && typeof body.reason !== 'string')) {
99
+ return res.status(400).json({ error: 'sessionId, cueId and vote (up|down) required', reason: 'invalid_request' })
100
+ }
101
+ if (!liveCuesV2Enabled()) return res.status(404).json({ error: 'live cues v2 is off', reason: 'v2_disabled' })
102
+ return res.json(liveCuesV2().recordFeedback({ sessionId, cueId, vote: body.vote, reason: body.reason as string | undefined }))
103
+ })
@@ -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)}`)
@@ -2443,7 +2443,8 @@ async function processStreamChunk(opts: {
2443
2443
  // block on a cue, and no LLM runs on this path. .catch() is required — a
2444
2444
  // bare `void` on a rejecting promise is an unhandled rejection, which Node
2445
2445
  // throws on by default. All gates live inside the feed.
2446
- void feedLiveCueTranscript(sessionId, trimmedText, Boolean(fallbackReason)).catch(() => {})
2446
+ // The ASR await above lets a late chunk land after Stop or save; a closed session never feeds cues.
2447
+ if (!isSessionDeleted(sessionId)) void feedLiveCueTranscript(sessionId, trimmedText, Boolean(fallbackReason)).catch(() => {})
2447
2448
 
2448
2449
  console.log(`[perf] TOTAL request: ${(performance.now() - tReq).toFixed(1)}ms | chunk #${chunkIndex} | ${audioBuffer.length}b | rms=${Math.round(rms)} q=${isQuiet ? 1 : 0} gate=${quiet.gate}${quiet.speechSec === null ? '' : ` speech=${quiet.speechSec.toFixed(2)}s`} fe=${stt.frontEnd} | ${asrProvider} | "${trimmedText.slice(0, 50)}"`)
2449
2450
  return canonicalChunkResponse(chunk, sessionId, chunkIndex)
@@ -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,
@@ -22,8 +27,55 @@ import {
22
27
  export const transcribeRouter = Router()
23
28
  // A Mac client checks this before transferring microphone audio. Older servers must not
24
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).
25
32
  transcribeRouter.get('/transcribe/capabilities', (_req, res) => {
26
- res.json({ desktopDictationVersion: 1, requiredEngines: ['parakeet-ultra'], localOnly: true, dictionary: 'cos' })
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
+ }
27
79
  })
28
80
 
29
81
  function resolveMode(req: { body?: { mode?: string }; query?: { mode?: string | string[] } }) {
@@ -38,21 +90,13 @@ transcribeRouter.post('/transcribe', async (req, res) => {
38
90
  let maintenanceLease: MaintenanceWorkLease | undefined
39
91
  try {
40
92
  maintenanceLease = acquireMaintenanceWork('one_shot_transcription')
41
- const chunks: Buffer[] = []
42
- const requiredEngine = req.query.requiredEngine
43
- if (requiredEngine !== undefined && requiredEngine !== 'parakeet-ultra') {
93
+ const rawEngine = req.query.requiredEngine
94
+ if (rawEngine !== undefined && (typeof rawEngine !== 'string' || !REQUIRED_ENGINES.has(rawEngine))) {
44
95
  return res.status(400).json({ error: 'invalid_required_engine' })
45
96
  }
46
- let size = 0
47
- for await (const chunk of req) {
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)
54
- }
55
- 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' })
56
100
 
57
101
  if (audioBuffer.length < 100) {
58
102
  return res.status(400).json({ error: 'audio too short' })
@@ -74,6 +118,9 @@ transcribeRouter.post('/transcribe', async (req, res) => {
74
118
  // 6.73.0, additive: which engine decoded it and, when the selection could not run, why.
75
119
  engine: result.engine,
76
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 } : {}),
77
124
  })
78
125
  } catch (err: any) {
79
126
  if (err instanceof MaintenanceLifecycleError) {
@@ -95,10 +142,13 @@ transcribeRouter.post('/transcribe', async (req, res) => {
95
142
  }
96
143
  if (err instanceof TranscriptionUnavailableError) {
97
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.
98
147
  return res.status(err.status).json({
99
148
  error: err.message,
100
149
  reason: err.reason,
101
- retryable: true,
150
+ retryable: err.hint ? err.hint.retryable : true,
151
+ ...(err.hint ? { action: err.hint.action, detail: err.hint.detail } : {}),
102
152
  })
103
153
  }
104
154
  res.status(500).json({ error: err.message })
@@ -1,12 +1,14 @@
1
1
  import { Router } from 'express'
2
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
+ 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'
4
4
  import { runMeetingAskModel } from '../lib/meeting-ask-runner.js'
5
5
  import { acquireMaintenanceWork, maintenanceAdmissionsOpen } from '../lib/maintenance-lifecycle.js'
6
6
  export function createWorkChatRouter(deps = {run:runMeetingAskModel, admissions:maintenanceAdmissionsOpen, lease:()=>acquireMaintenanceWork('work_chat')}) {
7
7
  const router=Router(); let running=false
8
8
  router.use('/work/chat', (_req,res,next)=>{res.set('Cache-Control','private, no-store');next()})
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
+ // 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}))
10
12
  router.post('/work/chat',async(req,res)=>{
11
13
  let input
12
14
  try { if (Buffer.byteLength(JSON.stringify(req.body))>100000) throw new Error(); input=parseWorkChatInput(req.body) }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Live cues v2 wire contract (server 6.75 candidate, companion 6.10.641 candidate).
3
+ * Additive to v1: old clients keep receiving `coaching_nudge` and ignore the new named event.
4
+ * The companion keeps a byte-identical copy at src/lib/live-cues-v2-contract.ts.
5
+ */
6
+ export const LIVE_CUES_V2_PROTOCOL = 2 as const
7
+ export const CUE_HEADLINE_MAX = 64
8
+ export const CUE_DETAIL_MAX = 220
9
+ export const CUE_FEEDBACK_REASON_MAX = 200
10
+
11
+ export type CueKind = 'reinforce' | 'challenge'
12
+
13
+ /** A past meeting the cue drew on (0 to 2 per cue). */
14
+ export interface CueSourceRef { id: string; date: string; title: string }
15
+
16
+ /** SSE named event `coaching_nudge_v2` (emitted BEFORE its v1 `coaching_nudge` twin), and the rows of GET /api/live-cues/status?sessionId=. */
17
+ export interface LiveCueV2 {
18
+ protocol: 2
19
+ cueId: string
20
+ sessionId: string
21
+ /** <= 64 chars: the lens footer line. */
22
+ headline: string
23
+ /** <= 220 chars: the full cue, shown on tap and in the phone Cues sheet. */
24
+ detail: string
25
+ kind: CueKind
26
+ sources: CueSourceRef[]
27
+ /** Admission probability from the gate, 0 to 1. */
28
+ gate: number
29
+ createdAt: number
30
+ model: string
31
+ }
32
+
33
+ /** v1 `coaching_nudge` as the v2 engine emits it for old clients (headline only). */
34
+ export interface LiveCueV1Compat { nudge: string; type: CueKind; priority: 2; cueId: string; sessionId: string; protocol: 2 }
35
+
36
+ /** `reason` when unavailable: master_flag_off, api_key_in_env, jev_not_configured, claude_not_found, budget_exhausted,
37
+ * jev_cap_reached, jev_breaker_open. */
38
+ export interface LiveCuesV2Capability { available: boolean; reason?: string; protocol: 2; model: string }
39
+
40
+ /** Why cues are not coming for this meeting. `active` means evaluations are running normally (silence is a choice). */
41
+ export type LiveCuesV2SessionState = 'active' | 'paused' | 'ended' | 'stopped'
42
+ export type LiveCuesV2StopReason = 'breaker_open' | 'meeting_cap' | 'budget_exhausted' | 'jev_cap_reached' | 'jev_breaker_open' | 'jev_not_configured'
43
+
44
+ export interface LiveCuesV2SessionStatus {
45
+ sessionId: string
46
+ state: LiveCuesV2SessionState
47
+ /** Present when state is `stopped`. */
48
+ reason?: LiveCuesV2StopReason
49
+ paused: boolean
50
+ ended: boolean
51
+ /** The pause request sequence the server last applied (0 when none). */
52
+ pauseSeq: number
53
+ cues: LiveCueV2[]
54
+ evaluations: number
55
+ writerCalls: number
56
+ }
57
+
58
+ /** GET /api/live-cues/status?sessionId=X (v2 server). Without sessionId the v1 array shape is unchanged. */
59
+ export interface LiveCuesV2StatusResponse { protocol: 2; enabled: boolean; session: LiveCuesV2SessionStatus | null }
60
+
61
+ /** POST /api/live-cues/pause -> { ok, paused, applied, pauseSeq }. `seq` is a per-meeting counter from the phone; the server
62
+ * ignores a request whose seq is not newer than the last applied one (applied: false) so out-of-order posts cannot win. */
63
+ export interface LiveCuesPauseRequest { sessionId: string; paused: boolean; seq?: number }
64
+ /** POST /api/live-cues/feedback */
65
+ export interface LiveCuesFeedbackRequest { sessionId: string; cueId: string; vote: 'up' | 'down'; reason?: string }
66
+
67
+ export function isLiveCueV2(value: unknown): value is LiveCueV2 {
68
+ if (!value || typeof value !== 'object') return false
69
+ const v = value as Record<string, unknown>
70
+ return v.protocol === 2 && typeof v.cueId === 'string' && v.cueId.length > 0 && typeof v.sessionId === 'string'
71
+ && typeof v.headline === 'string' && v.headline.length > 0 && v.headline.length <= CUE_HEADLINE_MAX
72
+ && typeof v.detail === 'string' && v.detail.length > 0 && v.detail.length <= CUE_DETAIL_MAX
73
+ && (v.kind === 'reinforce' || v.kind === 'challenge')
74
+ && Array.isArray(v.sources) && v.sources.length <= 2
75
+ && v.sources.every(s => !!s && typeof (s as CueSourceRef).id === 'string' && typeof (s as CueSourceRef).date === 'string' && typeof (s as CueSourceRef).title === 'string')
76
+ && typeof v.gate === 'number' && typeof v.createdAt === 'number' && typeof v.model === 'string'
77
+ }