@gotcos/glasses-server 6.18.7 → 6.18.8
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 +11 -0
- package/package.json +1 -1
- package/server/lib/archive.ts +3 -0
- package/server/lib/conversation.ts +1 -0
- package/server/lib/maintenance-lifecycle.ts +1 -0
- package/server/lib/query-job-runtime.ts +2 -2
- package/server/lib/transcribe-audio.ts +14 -15
- package/server/lib/whisper-local.ts +49 -30
- package/server/routes/prompt-drafts.ts +77 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
## 6.18.8
|
|
2
|
+
|
|
3
|
+
- **Prompt draft peeks for live ASR.** `POST /api/prompt-drafts/:draftId/peek`
|
|
4
|
+
runs Turbo locally, emits `prompt_transcript` with `provisional: true` +
|
|
5
|
+
`peekGen`, and does **not** advance the recovery ledger. Drop-on-busy,
|
|
6
|
+
`learnInline=false`, and `affectsCircuit: false` so peeks cannot trip or
|
|
7
|
+
success-reset the Whisper breaker.
|
|
8
|
+
- **Interactive HQ keeps short utterance heads.** Compose HQ skips light
|
|
9
|
+
enhance (highpass was truncating "device, just for…") and omits CLI `--vad`
|
|
10
|
+
as defense-in-depth. Meeting/batch HQ paths unchanged.
|
|
11
|
+
|
|
1
12
|
## 6.18.7
|
|
2
13
|
|
|
3
14
|
- **Phone Restart no longer leaves the server Stopped.** Control LaunchAgent
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotcos/glasses-server",
|
|
3
|
-
"version": "6.18.
|
|
3
|
+
"version": "6.18.8",
|
|
4
4
|
"description": "COS Glasses \u2014 self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, or Cursor Agent CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/server/lib/archive.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { secureExistingPrivateFile } from './secure-user-config.js'
|
|
|
15
15
|
|
|
16
16
|
import type { Exchange } from './conversation.js'
|
|
17
17
|
import { resolveExchangePairModel } from './conversation.js'
|
|
18
|
+
import type { ModelPreference } from '../../shared/model-preference.js'
|
|
18
19
|
|
|
19
20
|
import { dataPath } from './data-dir.js'
|
|
20
21
|
const ARCHIVE_DIR = dataPath('archive')
|
|
@@ -60,6 +61,8 @@ export interface SessionToArchive {
|
|
|
60
61
|
contextBreaks: number[]
|
|
61
62
|
createdAt: number
|
|
62
63
|
lastActivity: number
|
|
64
|
+
/** Optional session-level model fallback for pre-stamp exchange pairs. */
|
|
65
|
+
modelPreference?: ModelPreference | null
|
|
63
66
|
}
|
|
64
67
|
|
|
65
68
|
// ── Directory management ────────────────────────────────────
|
|
@@ -46,6 +46,7 @@ export type MaintenanceWorkKind =
|
|
|
46
46
|
| 'meeting_batch_finalization'
|
|
47
47
|
| 'prompt_draft_write'
|
|
48
48
|
| 'prompt_draft_warm'
|
|
49
|
+
| 'prompt_draft_peek'
|
|
49
50
|
| 'prompt_draft_finalize'
|
|
50
51
|
// Live Cues pipeline (planner + memory hops + insight). Held for the
|
|
51
52
|
// pipeline's in-flight duration so an Update Server drain cannot unload the
|
|
@@ -114,7 +114,7 @@ async function projectPublicConversationTerminal(
|
|
|
114
114
|
request.globalMsgNum,
|
|
115
115
|
request.attachmentRefs,
|
|
116
116
|
request.messageEra,
|
|
117
|
-
request.model,
|
|
117
|
+
normalizeModelPreference(request.model),
|
|
118
118
|
)
|
|
119
119
|
reconcileExchangeByJobIdentity(
|
|
120
120
|
request.sessionId,
|
|
@@ -124,7 +124,7 @@ async function projectPublicConversationTerminal(
|
|
|
124
124
|
request.globalMsgNum,
|
|
125
125
|
mergeMediaAttachmentRefs(outputAttachments, existingOutputAttachments),
|
|
126
126
|
request.messageEra,
|
|
127
|
-
request.model,
|
|
127
|
+
normalizeModelPreference(request.model),
|
|
128
128
|
)
|
|
129
129
|
flushConversationToDisk()
|
|
130
130
|
}
|
|
@@ -13,7 +13,6 @@ import {
|
|
|
13
13
|
estimateAudioSeconds,
|
|
14
14
|
OpenAIWhisperBudgetExhaustedError,
|
|
15
15
|
} from './openai-whisper-budget.js'
|
|
16
|
-
import { enhanceAudio } from './audio-enhance.js'
|
|
17
16
|
import {
|
|
18
17
|
stripInlineHallucinationsOneShot,
|
|
19
18
|
isFullHallucination,
|
|
@@ -63,12 +62,6 @@ export class NoSpeechDetectedError extends Error {
|
|
|
63
62
|
// ceiling (anything longer is a dictation, not a query — use meetings instead).
|
|
64
63
|
const HQ_MAX_SECONDS = 60
|
|
65
64
|
|
|
66
|
-
/** Short interactive clips use light enhance (highpass only). Override via env. */
|
|
67
|
-
function hqEnhanceLightMaxSeconds(): number {
|
|
68
|
-
const value = Number.parseInt(process.env.COS_HQ_ENHANCE_LIGHT_MAX_SEC || '15', 10)
|
|
69
|
-
return Number.isFinite(value) && value >= 0 ? value : 15
|
|
70
|
-
}
|
|
71
|
-
|
|
72
65
|
function unavailableAfterLocalFailure(): TranscriptionUnavailableError | null {
|
|
73
66
|
const fallback = getTranscriptionPolicySnapshot()
|
|
74
67
|
if (fallback.openaiFallbackReady) return null
|
|
@@ -146,10 +139,16 @@ export function resolveTranscribeMode(raw: unknown): TranscribeMode {
|
|
|
146
139
|
|
|
147
140
|
export async function transcribeAudioBuffer(
|
|
148
141
|
audioBuffer: Buffer,
|
|
149
|
-
opts: {
|
|
142
|
+
opts: {
|
|
143
|
+
mode?: TranscribeMode
|
|
144
|
+
policy?: TranscriptionBackendPolicy
|
|
145
|
+
/** When false, turbo/server failures do not move the shared meeting circuit breaker. */
|
|
146
|
+
affectsCircuit?: boolean
|
|
147
|
+
} = {},
|
|
150
148
|
): Promise<TranscribeAudioResult> {
|
|
151
149
|
const requestedMode = opts.mode ?? 'hq'
|
|
152
150
|
const policy = opts.policy ?? 'automatic'
|
|
151
|
+
const affectsCircuit = opts.affectsCircuit !== false
|
|
153
152
|
const audioSeconds = estimateAudioSeconds(audioBuffer)
|
|
154
153
|
const effectiveMode: TranscribeMode =
|
|
155
154
|
requestedMode === 'hq' && audioSeconds > HQ_MAX_SECONDS ? 'fast' : requestedMode
|
|
@@ -166,14 +165,14 @@ export async function transcribeAudioBuffer(
|
|
|
166
165
|
|
|
167
166
|
if (effectiveMode === 'hq') {
|
|
168
167
|
try {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
const result = await transcribeHighQuality(
|
|
168
|
+
// A0 (2026-07-30): ffmpeg enhance light (highpass=f=80) was measured dropping
|
|
169
|
+
// leading speech on compose ("device just for your awareness"). Meeting batch
|
|
170
|
+
// still enhances in meeting-batch-transcribe.ts — this path is prompt/interactive only.
|
|
171
|
+
const result = await transcribeHighQuality(audioBuffer, undefined, { priority: 'interactive' })
|
|
173
172
|
text = result.text
|
|
174
173
|
actualQuality = result.actualQuality
|
|
175
174
|
if (result.actualQuality === 'hq') {
|
|
176
|
-
backend =
|
|
175
|
+
backend = 'hq-large-v3'
|
|
177
176
|
} else {
|
|
178
177
|
backend = result.backend === 'whisper-cli' ? 'fast-cli-turbo' : 'fast-local-server'
|
|
179
178
|
degradationReason = result.degradationReason ?? 'hq_unavailable'
|
|
@@ -182,7 +181,7 @@ export async function transcribeAudioBuffer(
|
|
|
182
181
|
console.warn(`[transcribe] HQ path failed, falling back to fast: ${hqErr.message}`)
|
|
183
182
|
degradationReason = 'hq_decode_failed'
|
|
184
183
|
try {
|
|
185
|
-
const result = await transcribeLocal(audioBuffer)
|
|
184
|
+
const result = await transcribeLocal(audioBuffer, undefined, undefined, { affectsCircuit })
|
|
186
185
|
text = result.text
|
|
187
186
|
backend = `fast-local-${result.backend}`
|
|
188
187
|
actualQuality = 'fast'
|
|
@@ -202,7 +201,7 @@ export async function transcribeAudioBuffer(
|
|
|
202
201
|
}
|
|
203
202
|
} else if (effectiveMode === 'fast') {
|
|
204
203
|
try {
|
|
205
|
-
const result = await transcribeLocal(audioBuffer)
|
|
204
|
+
const result = await transcribeLocal(audioBuffer, undefined, undefined, { affectsCircuit })
|
|
206
205
|
text = result.text
|
|
207
206
|
backend = `fast-local-${result.backend}`
|
|
208
207
|
actualQuality = 'fast'
|
|
@@ -137,6 +137,12 @@ const BATCH_LARGE_V3_ENABLED = process.env.COS_BATCH_LARGE_V3 !== '0'
|
|
|
137
137
|
const VAD_MODEL_PATH = join(process.env.HOME ?? homedir(), '.local/share/whisper-models/ggml-silero-v5.1.2.bin')
|
|
138
138
|
const VAD_ENABLED = process.env.COS_WHISPER_VAD !== '0'
|
|
139
139
|
|
|
140
|
+
/** Batch meeting HQ keeps CLI --vad. Interactive/prompt HQ must not — VAD was
|
|
141
|
+
* measured dropping leading speech on compose ("device just for your awareness"). */
|
|
142
|
+
export function hqCliVadEnabled(priority?: 'interactive' | 'batch'): boolean {
|
|
143
|
+
return priority === 'batch' && VAD_ENABLED && existsSync(VAD_MODEL_PATH)
|
|
144
|
+
}
|
|
145
|
+
|
|
140
146
|
/** Pick the batch model path. Prefer large-v3 when enabled + on disk; fall
|
|
141
147
|
* back to turbo otherwise. Logged once per process so we know which decoder
|
|
142
148
|
* actually ran when reviewing a meeting later. */
|
|
@@ -732,7 +738,6 @@ export async function transcribeHighQuality(
|
|
|
732
738
|
|
|
733
739
|
const modelPath = resolveBatchModel()
|
|
734
740
|
const useLargeV3 = modelPath === BATCH_MODEL_LARGE_V3
|
|
735
|
-
const useVad = VAD_ENABLED && existsSync(VAD_MODEL_PATH)
|
|
736
741
|
|
|
737
742
|
try {
|
|
738
743
|
writeFileSync(tmpWav, audioBuffer)
|
|
@@ -741,6 +746,9 @@ export async function transcribeHighQuality(
|
|
|
741
746
|
// that is explicitly OUT of batch device policy. Only the long post-meeting
|
|
742
747
|
// batch is admission-controlled against live ASR.
|
|
743
748
|
const isBatch = opts.priority === 'batch'
|
|
749
|
+
// A0 (2026-07-30): CLI --vad on interactive/prompt HQ can drop leading speech
|
|
750
|
+
// (measured: "device just for your awareness"). Keep VAD for meeting batch only.
|
|
751
|
+
const useVad = hqCliVadEnabled(opts.priority)
|
|
744
752
|
const decision: { device: 'metal' | 'cpu'; reason: string; metalEnabled: boolean } = isBatch
|
|
745
753
|
? (opts.forceCpu
|
|
746
754
|
? { device: 'cpu', reason: 'preempt_retry', metalEnabled: batchHqMetalEnabled() }
|
|
@@ -774,9 +782,8 @@ export async function transcribeHighQuality(
|
|
|
774
782
|
args.push('-ojf', '-of', outBase)
|
|
775
783
|
}
|
|
776
784
|
if (useVad) {
|
|
777
|
-
//
|
|
778
|
-
//
|
|
779
|
-
// failure mode even with large-v3's more permissive decoder.
|
|
785
|
+
// Batch-only: strips silence windows before decode. Interactive/prompt HQ
|
|
786
|
+
// omits this — VAD was measured dropping real leading speech on compose.
|
|
780
787
|
args.push('--vad', '--vad-model', VAD_MODEL_PATH)
|
|
781
788
|
}
|
|
782
789
|
const proc = spawn(WHISPER_CLI, args, {
|
|
@@ -1076,8 +1083,14 @@ export function resetDecoderCaches(): void {
|
|
|
1076
1083
|
* auto-restart the server process in the background and throw immediately so the
|
|
1077
1084
|
* caller can use cloud while the server recovers (~20s model load).
|
|
1078
1085
|
*/
|
|
1079
|
-
export async function transcribeLocal(
|
|
1086
|
+
export async function transcribeLocal(
|
|
1087
|
+
audioBuffer: Buffer,
|
|
1088
|
+
context?: string,
|
|
1089
|
+
isQuiet?: boolean,
|
|
1090
|
+
opts?: { affectsCircuit?: boolean },
|
|
1091
|
+
): Promise<{ text: string; backend: 'server' | 'cli'; words?: WhisperWord[] }> {
|
|
1080
1092
|
const start = Date.now()
|
|
1093
|
+
const affectsCircuit = opts?.affectsCircuit !== false
|
|
1081
1094
|
|
|
1082
1095
|
if (!serverAvailable) {
|
|
1083
1096
|
await reconcileWhisperServerHealth()
|
|
@@ -1094,34 +1107,38 @@ export async function transcribeLocal(audioBuffer: Buffer, context?: string, isQ
|
|
|
1094
1107
|
const text = applyCorrections(result.text)
|
|
1095
1108
|
const words = result.words?.map(w => ({ ...w, word: applyCorrections(w.word) }))
|
|
1096
1109
|
const elapsed = Date.now() - start
|
|
1097
|
-
// Reset circuit breaker on success
|
|
1098
|
-
if (serverConsecutiveFailures > 0) {
|
|
1110
|
+
// Reset circuit breaker on success (skip for peek / non-circuit callers)
|
|
1111
|
+
if (affectsCircuit && serverConsecutiveFailures > 0) {
|
|
1099
1112
|
console.log(`[whisper-local] Server recovered after ${serverConsecutiveFailures} consecutive failure(s)`)
|
|
1100
1113
|
serverConsecutiveFailures = 0
|
|
1101
1114
|
}
|
|
1102
1115
|
console.log(`[whisper-local] Server transcribed in ${elapsed}ms (${words?.length ?? 0} words): "${text.slice(0, 80)}${text.length > 80 ? '...' : ''}"`)
|
|
1103
1116
|
return { text, backend: 'server', words }
|
|
1104
1117
|
} catch (err: any) {
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1118
|
+
if (affectsCircuit) {
|
|
1119
|
+
serverConsecutiveFailures++
|
|
1120
|
+
const isTimeout = err.message.includes('timeout') || err.message.includes('aborted')
|
|
1121
|
+
const isDead = err.message.includes('ECONNREFUSED') || err.message.includes('fetch failed')
|
|
1122
|
+
|
|
1123
|
+
if (isDead) {
|
|
1124
|
+
serverAvailable = false
|
|
1125
|
+
console.error(`[whisper-local] Server DEAD (ECONNREFUSED) — marked unavailable. Consecutive failures: ${serverConsecutiveFailures}`)
|
|
1126
|
+
} else if (isTimeout) {
|
|
1127
|
+
// Server process exists but is hung — mark unavailable so we stop trying
|
|
1128
|
+
serverAvailable = false
|
|
1129
|
+
console.error(`[whisper-local] Server HUNG (timeout) — marked unavailable. Consecutive failures: ${serverConsecutiveFailures}`)
|
|
1130
|
+
}
|
|
1117
1131
|
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1132
|
+
// Circuit breaker: auto-restart after threshold
|
|
1133
|
+
if (serverConsecutiveFailures >= SERVER_FAILURE_THRESHOLD && !serverRestarting) {
|
|
1134
|
+
console.error(`[whisper-local] ⚠ CIRCUIT BREAKER OPEN — ${serverConsecutiveFailures} consecutive failures. Auto-restarting server...`)
|
|
1135
|
+
// Non-blocking restart in background
|
|
1136
|
+
void restartWhisperServer()
|
|
1137
|
+
} else if (serverConsecutiveFailures < SERVER_FAILURE_THRESHOLD) {
|
|
1138
|
+
console.warn(`[whisper-local] Server failed (${serverConsecutiveFailures}/${SERVER_FAILURE_THRESHOLD} before restart): ${err.message}`)
|
|
1139
|
+
}
|
|
1140
|
+
} else {
|
|
1141
|
+
console.warn(`[whisper-local] Peek/non-circuit server failure (breaker untouched): ${err.message}`)
|
|
1125
1142
|
}
|
|
1126
1143
|
|
|
1127
1144
|
// Throw to let caller fall to OpenAI cloud (1-3s) — much faster than CLI cold-start (11s)
|
|
@@ -1132,10 +1149,12 @@ export async function transcribeLocal(audioBuffer: Buffer, context?: string, isQ
|
|
|
1132
1149
|
// Server not available — still count toward circuit breaker so auto-restart can fire.
|
|
1133
1150
|
// Without this, the counter stalls after the first failure marks serverAvailable=false
|
|
1134
1151
|
// and subsequent calls never increment, so restart never triggers.
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1152
|
+
if (affectsCircuit) {
|
|
1153
|
+
serverConsecutiveFailures++
|
|
1154
|
+
if (serverConsecutiveFailures >= SERVER_FAILURE_THRESHOLD && !serverRestarting) {
|
|
1155
|
+
console.error(`[whisper-local] CIRCUIT BREAKER OPEN — ${serverConsecutiveFailures} consecutive failures (server unavailable). Auto-restarting...`)
|
|
1156
|
+
void restartWhisperServer()
|
|
1157
|
+
}
|
|
1139
1158
|
}
|
|
1140
1159
|
|
|
1141
1160
|
// Throw so the caller applies the configured recovery policy. CLI is
|
|
@@ -61,6 +61,9 @@ const modeQualityJobs = new Map<string, Promise<string>>()
|
|
|
61
61
|
const finalizeJobs = new Map<string, Promise<any>>()
|
|
62
62
|
let warmTail: Promise<void> = Promise.resolve()
|
|
63
63
|
let hqWarmTail: Promise<void> = Promise.resolve()
|
|
64
|
+
/** Peek decodes — drop-while-busy; never chained onto warmTail (HOL vs HQ). */
|
|
65
|
+
let peekTail: Promise<void> = Promise.resolve()
|
|
66
|
+
let peekBusy = false
|
|
64
67
|
|
|
65
68
|
/** Speculative HQ warm while speaking. Set COS_HQ_SPECULATIVE_WARM=0 to restore Fast-only warm. */
|
|
66
69
|
function speculativeHqWarmEnabled(): boolean {
|
|
@@ -372,6 +375,80 @@ promptDraftsRouter.post('/prompt-drafts/start', (req, res) => {
|
|
|
372
375
|
}
|
|
373
376
|
})
|
|
374
377
|
|
|
378
|
+
/**
|
|
379
|
+
* Provisional Turbo peek — cosmetic lens UX only.
|
|
380
|
+
* Does NOT save chunks, advance the recovery ledger, or write warm/final transcripts.
|
|
381
|
+
* Drop-while-busy: overlapping peeks return 204 without queueing behind HQ warm.
|
|
382
|
+
*/
|
|
383
|
+
promptDraftsRouter.post('/prompt-drafts/:draftId/peek', async (req, res) => {
|
|
384
|
+
try {
|
|
385
|
+
const meta = loadPromptDraftMeta(req.params.draftId)
|
|
386
|
+
if (!meta) return void res.status(404).json({ error: 'draft not found' })
|
|
387
|
+
|
|
388
|
+
const rawIndex = Array.isArray(req.query.chunkIndex) ? req.query.chunkIndex[0] : req.query.chunkIndex
|
|
389
|
+
const chunkIndex = Number(rawIndex)
|
|
390
|
+
if (!Number.isInteger(chunkIndex) || chunkIndex < 0) {
|
|
391
|
+
return void res.status(400).json({ error: 'chunkIndex required' })
|
|
392
|
+
}
|
|
393
|
+
const rawGen = Array.isArray(req.query.peekGen) ? req.query.peekGen[0] : req.query.peekGen
|
|
394
|
+
const peekGen = Number(rawGen)
|
|
395
|
+
if (!Number.isInteger(peekGen) || peekGen < 0) {
|
|
396
|
+
return void res.status(400).json({ error: 'peekGen required' })
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (peekBusy) {
|
|
400
|
+
return void res.status(204).send()
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const audio = await readRawBody(req)
|
|
404
|
+
if (!audio.length) return void res.status(400).json({ error: 'empty audio' })
|
|
405
|
+
if (audio.length > MAX_CHUNK_BYTES) return void res.status(413).json({ error: 'audio chunk too large' })
|
|
406
|
+
|
|
407
|
+
const lease = acquireMaintenanceWork('prompt_draft_peek', {
|
|
408
|
+
allowDuringDrain: true,
|
|
409
|
+
phase: 'queued',
|
|
410
|
+
})
|
|
411
|
+
|
|
412
|
+
peekBusy = true
|
|
413
|
+
const draftId = req.params.draftId
|
|
414
|
+
peekTail = peekTail.then(async () => {
|
|
415
|
+
lease.setPhase('active')
|
|
416
|
+
try {
|
|
417
|
+
const result = await transcribeAudioBuffer(audio, {
|
|
418
|
+
mode: 'fast',
|
|
419
|
+
policy: 'local-only',
|
|
420
|
+
affectsCircuit: false,
|
|
421
|
+
})
|
|
422
|
+
const text = sanitizeTranscript(draftId, result.text, false)
|
|
423
|
+
if (!text) return
|
|
424
|
+
if (!loadPromptDraftMeta(draftId)) return
|
|
425
|
+
emitDisplay({
|
|
426
|
+
type: 'prompt_transcript',
|
|
427
|
+
data: { draftId, chunkIndex, text, provisional: true, peekGen },
|
|
428
|
+
})
|
|
429
|
+
} catch (err: any) {
|
|
430
|
+
if (!(err instanceof NoSpeechDetectedError)) {
|
|
431
|
+
console.warn(`[prompt-draft] peek failed ${draftId}/${chunkIndex}: ${err?.message ?? err}`)
|
|
432
|
+
}
|
|
433
|
+
} finally {
|
|
434
|
+
peekBusy = false
|
|
435
|
+
lease.release()
|
|
436
|
+
}
|
|
437
|
+
}).catch(() => {
|
|
438
|
+
peekBusy = false
|
|
439
|
+
lease.release()
|
|
440
|
+
})
|
|
441
|
+
|
|
442
|
+
res.json({ draftId, chunkIndex, peekGen, accepted: true })
|
|
443
|
+
} catch (err: any) {
|
|
444
|
+
if (err instanceof MaintenanceLifecycleError) {
|
|
445
|
+
if (err.retryAfterSeconds != null) res.setHeader('Retry-After', String(err.retryAfterSeconds))
|
|
446
|
+
return void res.status(err.status).json({ ...maintenanceErrorPayload(err), draftPreserved: true })
|
|
447
|
+
}
|
|
448
|
+
res.status(err.status ?? (err.message === 'draft not found' ? 404 : 500)).json({ error: err.message })
|
|
449
|
+
}
|
|
450
|
+
})
|
|
451
|
+
|
|
375
452
|
promptDraftsRouter.post('/prompt-drafts/:draftId/chunks', async (req, res) => {
|
|
376
453
|
let maintenanceLease: MaintenanceWorkLease | undefined
|
|
377
454
|
try {
|