@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.
- package/CHANGELOG.md +83 -0
- package/package.json +1 -1
- package/server/index.ts +5 -0
- package/server/lib/ask-session-id.ts +5 -0
- package/server/lib/decision-provider.ts +163 -0
- package/server/lib/decision-settings.ts +72 -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/glossary-learn.ts +221 -0
- package/server/lib/imported-library-rows.ts +62 -4
- package/server/lib/jev.ts +3 -3
- package/server/lib/lens-gist-engines.ts +7 -2
- package/server/lib/live-cos-ask.ts +2 -1
- package/server/lib/mac-recording-asr.ts +59 -0
- package/server/lib/mac-recording.ts +111 -0
- package/server/lib/meeting-actions.ts +1 -0
- package/server/lib/meeting-ask-follow-up.ts +2 -2
- package/server/lib/meeting-ask-runner.ts +26 -5
- package/server/lib/meeting-ask-store.ts +2 -1
- package/server/lib/meeting-batch-transcribe.ts +19 -4
- package/server/lib/meeting-context-keys.ts +7 -4
- package/server/lib/meeting-parse.ts +2 -0
- package/server/lib/meeting-store.ts +62 -9
- package/server/lib/parakeet-cli-trust.ts +262 -0
- package/server/lib/parakeet.ts +156 -29
- package/server/lib/session-recommendation.ts +12 -9
- package/server/lib/speaker-embeddings.ts +2 -2
- package/server/lib/stranded-session-actions.ts +15 -0
- package/server/lib/stt-runtime.ts +43 -11
- package/server/lib/stt-settings.ts +88 -3
- package/server/lib/subq-key.ts +38 -0
- package/server/lib/transcribe-audio.ts +87 -7
- package/server/lib/work-activity.ts +2 -2
- package/server/lib/work-chat.ts +113 -13
- package/server/lib/work-completion.ts +11 -8
- package/server/lib/work-intake-store.ts +2 -1
- package/server/lib/work-search.ts +32 -12
- package/server/routes/client-instance.ts +1 -1
- package/server/routes/decision-provider.ts +36 -0
- package/server/routes/glossary.ts +143 -11
- package/server/routes/jev.ts +3 -2
- package/server/routes/meeting.ts +44 -12
- package/server/routes/meetings.ts +42 -4
- package/server/routes/stt-settings.ts +55 -4
- package/server/routes/subq-key.ts +27 -0
- package/server/routes/transcribe-stream.ts +115 -9
- package/server/routes/transcribe.ts +74 -6
- package/server/routes/work-chat.ts +10 -4
- 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
|
|
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
|
-
|
|
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 {
|
|
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)}`)
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
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, {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
})
|