@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.
- package/CHANGELOG.md +33 -0
- package/managed-runtime-contract.json +7 -0
- package/package.json +1 -1
- package/server/index.ts +2 -0
- package/server/lib/diarizer-backend.ts +24 -2
- package/server/lib/dictation-benchmark.ts +197 -0
- package/server/lib/dictation-stt.ts +61 -2
- package/server/lib/display-bus.ts +1 -1
- package/server/lib/jev.ts +12 -2
- package/server/lib/live-cues-engine.ts +3 -0
- package/server/lib/live-cues-v2-prompts.ts +41 -0
- package/server/lib/live-cues-v2.ts +639 -0
- package/server/lib/parakeet-cli-trust.ts +262 -0
- package/server/lib/parakeet.ts +156 -29
- package/server/lib/stt-runtime.ts +43 -11
- package/server/lib/stt-settings.ts +88 -3
- package/server/lib/transcribe-audio.ts +83 -11
- package/server/lib/work-chat.ts +28 -4
- package/server/routes/display.ts +2 -2
- package/server/routes/health.ts +3 -0
- package/server/routes/live-cues.ts +48 -3
- package/server/routes/stt-settings.ts +55 -4
- package/server/routes/transcribe-stream.ts +2 -1
- package/server/routes/transcribe.ts +65 -15
- package/server/routes/work-chat.ts +4 -2
- package/shared/live-cues-v2.ts +77 -0
|
@@ -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 ||
|
|
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', (
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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({
|
|
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
|
|
42
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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
|
+
}
|