@gotcos/glasses-server 6.13.0 → 6.14.1

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.
@@ -42,9 +42,8 @@ export interface WhisperSegment {
42
42
  words?: WhisperWord[]
43
43
  }
44
44
 
45
- interface WhisperVerboseResponse {
46
- text: string
47
- segments?: WhisperSegment[]
45
+ interface WhisperJsonResponse {
46
+ text?: unknown
48
47
  }
49
48
 
50
49
  // Resolve whisper.cpp binaries across Homebrew prefixes (Apple Silicon
@@ -144,6 +143,7 @@ let cliAvailable = false
144
143
  let serverAvailable = false
145
144
  let serverProcess: ReturnType<typeof spawn> | null = null
146
145
  const ownedServerChildren = new Set<ChildProcess>()
146
+ const ownedHqChildren = new Set<ReturnType<typeof spawn>>()
147
147
 
148
148
  // Circuit breaker: track consecutive server failures to detect hung process
149
149
  let serverConsecutiveFailures = 0
@@ -389,7 +389,9 @@ async function startWhisperServerAttempt(preflightCompleted = false): Promise<vo
389
389
  console.log('[whisper-local] Starting whisper-server...')
390
390
 
391
391
  const child = spawn(WHISPER_SERVER, serverArgs, {
392
- stdio: ['ignore', 'pipe', 'pipe'],
392
+ // whisper-server writes per-inference diagnostics. Unread pipes eventually
393
+ // fill and block the daemon, so the supervisor must not leave them buffered.
394
+ stdio: 'ignore',
393
395
  detached: false, // Dies with parent
394
396
  })
395
397
  serverProcess = child
@@ -455,6 +457,10 @@ export function stopWhisperServer(): void {
455
457
  serverAvailable = false
456
458
  console.log('[whisper-local] whisper-server stopped')
457
459
  }
460
+ for (const proc of ownedHqChildren) {
461
+ try { proc.kill('SIGKILL') } catch { /* already exited */ }
462
+ }
463
+ ownedHqChildren.clear()
458
464
  }
459
465
 
460
466
  export function isWhisperLocalAvailable(): boolean {
@@ -523,7 +529,11 @@ async function reconcileWhisperServerHealth(): Promise<boolean> {
523
529
  * Falls back to turbo weights if large-v3 not on disk or COS_BATCH_LARGE_V3=0.
524
530
  * Falls back to transcribeLocal if whisper-cli unavailable entirely.
525
531
  */
526
- export async function transcribeHighQuality(audioBuffer: Buffer, context?: string): Promise<{ text: string; words?: WhisperWord[] }> {
532
+ export async function transcribeHighQuality(
533
+ audioBuffer: Buffer,
534
+ context?: string,
535
+ opts: { priority?: 'interactive' | 'batch' } = {},
536
+ ): Promise<{ text: string; words?: WhisperWord[] }> {
527
537
  if (!cliAvailable) {
528
538
  // Fall back to server (no beam search available via HTTP API)
529
539
  return transcribeLocal(audioBuffer, context)
@@ -541,12 +551,13 @@ export async function transcribeHighQuality(audioBuffer: Buffer, context?: strin
541
551
  writeFileSync(tmpWav, audioBuffer)
542
552
 
543
553
  const text = await new Promise<string>((resolve, reject) => {
554
+ const isolateBatchFromLiveMetal = opts.priority === 'batch'
544
555
  const args = [
545
556
  '-m', modelPath,
546
557
  '-f', tmpWav,
547
- '-t', '16', // Use more threads for batch (no real-time pressure)
558
+ '-t', isolateBatchFromLiveMetal ? '8' : '16',
548
559
  '-l', 'en',
549
- '-fa', // Flash attention — Metal win, same flag streaming uses
560
+ ...(isolateBatchFromLiveMetal ? ['-ng'] : ['-fa']),
550
561
  '-bs', '5', // Beam search width 5 (default disabled)
551
562
  '-bo', '5', // Best-of-5 candidates (default 2)
552
563
  '--no-timestamps',
@@ -562,6 +573,7 @@ export async function transcribeHighQuality(audioBuffer: Buffer, context?: strin
562
573
  const proc = spawn(WHISPER_CLI, args, {
563
574
  stdio: ['ignore', 'pipe', 'pipe'],
564
575
  })
576
+ ownedHqChildren.add(proc)
565
577
 
566
578
  let stdout = ''
567
579
  let stderr = ''
@@ -573,13 +585,24 @@ export async function transcribeHighQuality(audioBuffer: Buffer, context?: strin
573
585
  // multiplier × beam-search overhead = ~240s safety ceiling for HQ.
574
586
  // Turbo retains the old 60s ceiling.
575
587
  const timeoutMs = useLargeV3 ? 240_000 : 60_000
588
+ let timedOut = false
589
+ let forceKill: ReturnType<typeof setTimeout> | null = null
576
590
  const timeout = setTimeout(() => {
577
- proc.kill('SIGTERM')
578
- reject(new Error(`whisper-cli HQ timeout (${timeoutMs / 1000}s, model=${useLargeV3 ? 'large-v3' : 'turbo'})`))
591
+ timedOut = true
592
+ try { proc.kill('SIGTERM') } catch { /* already exited */ }
593
+ forceKill = setTimeout(() => {
594
+ try { proc.kill('SIGKILL') } catch { /* already exited */ }
595
+ }, 2_000)
579
596
  }, timeoutMs)
580
597
 
581
598
  proc.on('close', (code) => {
599
+ ownedHqChildren.delete(proc)
582
600
  clearTimeout(timeout)
601
+ if (forceKill) clearTimeout(forceKill)
602
+ if (timedOut) {
603
+ reject(new Error(`whisper-cli HQ timeout (${timeoutMs / 1000}s, model=${useLargeV3 ? 'large-v3' : 'turbo'})`))
604
+ return
605
+ }
583
606
  if (code !== 0) {
584
607
  reject(new Error(`whisper-cli HQ exit ${code}: ${stderr.trim().slice(0, 200)}`))
585
608
  return
@@ -588,7 +611,9 @@ export async function transcribeHighQuality(audioBuffer: Buffer, context?: strin
588
611
  })
589
612
 
590
613
  proc.on('error', (err) => {
614
+ ownedHqChildren.delete(proc)
591
615
  clearTimeout(timeout)
616
+ if (forceKill) clearTimeout(forceKill)
592
617
  reject(new Error(`whisper-cli HQ spawn error: ${err.message}`))
593
618
  })
594
619
  })
@@ -626,14 +651,17 @@ function buildPrompt(context?: string, isQuiet?: boolean): string {
626
651
 
627
652
  /**
628
653
  * Transcribe via whisper-server (persistent daemon, ~50-100ms).
629
- * Returns text + optional word-level timestamps from DTW alignment.
654
+ *
655
+ * Compact JSON intentionally avoids whisper.cpp's verbose_json language field,
656
+ * which can receive a null C string after VAD returns no speech and crash the
657
+ * native server before an HTTP response exists.
630
658
  */
631
659
  async function transcribeViaServer(audioBuffer: Buffer, context?: string, isQuiet?: boolean): Promise<{ text: string; words?: WhisperWord[] }> {
632
660
  const formData = new FormData()
633
661
  // Convert Buffer to Uint8Array to satisfy Blob's BlobPart type constraint
634
662
  const blob = new Blob([new Uint8Array(audioBuffer)], { type: 'audio/wav' })
635
663
  formData.append('file', blob, 'recording.wav')
636
- formData.append('response_format', 'verbose_json') // includes segments[].words[] with DTW
664
+ formData.append('response_format', 'json')
637
665
  formData.append('prompt', buildPrompt(context, isQuiet))
638
666
  // Anti-hallucination handled by client-side filter + context filtering.
639
667
  // Whisper-level entropy/logprob thresholds were too aggressive — silently dropped
@@ -650,20 +678,11 @@ async function transcribeViaServer(audioBuffer: Buffer, context?: string, isQuie
650
678
  throw new Error(`whisper-server ${response.status}: ${await response.text()}`)
651
679
  }
652
680
 
653
- const result = await response.json() as WhisperVerboseResponse
654
- const text = result.text?.trim() || ''
655
-
656
- // Extract word-level timestamps from DTW-aligned segments (defensive — may be absent)
657
- let words: WhisperWord[] | undefined
658
- if (result.segments && result.segments.length > 0) {
659
- const extracted = result.segments.flatMap(s => {
660
- if (!s.words || !Array.isArray(s.words)) return []
661
- return s.words.filter(w => typeof w.start === 'number' && typeof w.end === 'number')
662
- })
663
- if (extracted.length > 0) words = extracted
681
+ const result = await response.json() as WhisperJsonResponse
682
+ if (typeof result.text !== 'string') {
683
+ throw new Error('whisper-server returned invalid compact JSON: missing string text')
664
684
  }
665
-
666
- return { text, words }
685
+ return { text: result.text.trim() }
667
686
  }
668
687
 
669
688
  /**
@@ -0,0 +1,59 @@
1
+ // Bookmark endpoints — save/retrieve individual messages
2
+ import { Router } from 'express'
3
+ import { loadBookmarks, addBookmark, deleteBookmark, getBookmark } from '../lib/bookmarks.js'
4
+
5
+ export const bookmarksRouter = Router()
6
+
7
+ // GET /api/bookmarks — list all bookmarks (newest first)
8
+ bookmarksRouter.get('/bookmarks', (_req, res) => {
9
+ const bookmarks = loadBookmarks()
10
+ // Return newest first
11
+ res.json({ bookmarks: [...bookmarks].reverse() })
12
+ })
13
+
14
+ // GET /api/bookmarks/:id — get a single bookmark
15
+ bookmarksRouter.get('/bookmarks/:id', (req, res) => {
16
+ const id = parseInt(req.params.id, 10)
17
+ if (isNaN(id)) {
18
+ res.status(400).json({ error: 'Invalid bookmark ID' })
19
+ return
20
+ }
21
+ const bookmark = getBookmark(id)
22
+ if (!bookmark) {
23
+ res.status(404).json({ error: 'Bookmark not found' })
24
+ return
25
+ }
26
+ res.json(bookmark)
27
+ })
28
+
29
+ // POST /api/bookmarks — save a new bookmark (optional attachment refs)
30
+ bookmarksRouter.post('/bookmarks', (req, res) => {
31
+ const { query, text, messageIndex, originalTimestamp, attachments } = req.body
32
+ if (!query || !text) {
33
+ res.status(400).json({ error: 'query and text are required' })
34
+ return
35
+ }
36
+ const bookmark = addBookmark(
37
+ query,
38
+ text,
39
+ messageIndex ?? 0,
40
+ originalTimestamp ?? Date.now(),
41
+ attachments,
42
+ )
43
+ res.json({ bookmark })
44
+ })
45
+
46
+ // DELETE /api/bookmarks/:id — delete a bookmark
47
+ bookmarksRouter.delete('/bookmarks/:id', (req, res) => {
48
+ const id = parseInt(req.params.id, 10)
49
+ if (isNaN(id)) {
50
+ res.status(400).json({ error: 'Invalid bookmark ID' })
51
+ return
52
+ }
53
+ const deleted = deleteBookmark(id)
54
+ if (!deleted) {
55
+ res.status(404).json({ error: 'Bookmark not found' })
56
+ return
57
+ }
58
+ res.json({ deleted: true })
59
+ })
@@ -0,0 +1,153 @@
1
+ // Transcription glossary — runtime-editable positive vocabulary, exact
2
+ // corrections, and negative cleanup rules. Persists into .cos-profile.json and
3
+ // busts the profile + decoder caches so edits take effect WITHOUT a server
4
+ // restart. Auto token-protected by the /api middleware (server/index.ts:124).
5
+ //
6
+ // GET /api/transcription-glossary → { vocabulary, corrections, negative_rules }
7
+ // PUT /api/transcription-glossary → same shape; PARTIAL (any omitted field is
8
+ // left unchanged). whisper_corrections is stored as a JSON STRING in the
9
+ // profile (legacy decoder contract), so the route encodes/decodes it here.
10
+
11
+ import { Router } from 'express'
12
+ import { errMsg } from '../lib/utils.js'
13
+ import {
14
+ getVocabulary,
15
+ getNegativeRules,
16
+ loadProfileField,
17
+ updateProfileFields,
18
+ } from '../lib/profile.js'
19
+ import { resetDecoderCaches } from '../lib/whisper-local.js'
20
+ import { resetVocabEchoCache } from '../lib/hallucination-filter.js'
21
+ import { validateNegativeRule } from '../lib/hallucination-filter.js'
22
+
23
+ export const glossaryRouter = Router()
24
+
25
+ const MAX_TERMS = 500
26
+ const MAX_TERM_LEN = 100
27
+ const MAX_RULES = 500
28
+
29
+ // Positive vocab is injected into the Whisper decoder prompt; URLs/emails/paths
30
+ // there induce ".com"/handle hallucinations on quiet audio — reject them.
31
+ function looksLikeUrlEmailPath(s: string): boolean {
32
+ return /(?:https?:\/\/|www\.)/i.test(s)
33
+ || /@[\w.-]+\.\w/.test(s)
34
+ || /\.(?:com|net|org|io|ai|co|gov|edu|app|dev)\b/i.test(s)
35
+ || /[/\\]/.test(s)
36
+ }
37
+
38
+ function readCorrections(): Record<string, string> {
39
+ try {
40
+ const raw = loadProfileField('whisper_corrections', '')
41
+ if (!raw) return {}
42
+ const parsed = JSON.parse(raw)
43
+ return (parsed && typeof parsed === 'object' && !Array.isArray(parsed))
44
+ ? parsed as Record<string, string>
45
+ : {}
46
+ } catch {
47
+ return {}
48
+ }
49
+ }
50
+
51
+ function currentGlossary() {
52
+ return {
53
+ vocabulary: getVocabulary(),
54
+ corrections: readCorrections(),
55
+ negative_rules: getNegativeRules(),
56
+ }
57
+ }
58
+
59
+ // Serialize PUTs so two writers can't clobber the read-modify-write. (The RMW in
60
+ // updateProfileFields is synchronous today, but this guards against future async
61
+ // drift and satisfies the write-lock contract.)
62
+ let putChain: Promise<unknown> = Promise.resolve()
63
+
64
+ glossaryRouter.get('/transcription-glossary', (_req, res) => {
65
+ try {
66
+ res.json(currentGlossary())
67
+ } catch (err) {
68
+ res.status(500).json({ error: errMsg(err) })
69
+ }
70
+ })
71
+
72
+ glossaryRouter.put('/transcription-glossary', async (req, res) => {
73
+ const body = req.body ?? {}
74
+ const patch: Record<string, unknown> = {}
75
+
76
+ // ── vocabulary (positive spellings → decoder prompt + fuzzy targets) ──
77
+ if (body.vocabulary !== undefined) {
78
+ if (!Array.isArray(body.vocabulary)) {
79
+ return res.status(400).json({ error: 'vocabulary must be an array of strings' })
80
+ }
81
+ if (body.vocabulary.length > MAX_TERMS) {
82
+ return res.status(400).json({ error: `too many vocabulary terms (max ${MAX_TERMS})` })
83
+ }
84
+ const vocab: string[] = []
85
+ for (const raw of body.vocabulary) {
86
+ if (typeof raw !== 'string') return res.status(400).json({ error: 'vocabulary entries must be strings' })
87
+ const term = raw.trim()
88
+ if (!term) continue
89
+ if (term.length > MAX_TERM_LEN) return res.status(400).json({ error: `vocabulary term too long: "${term.slice(0, 40)}…"` })
90
+ if (looksLikeUrlEmailPath(term)) {
91
+ return res.status(400).json({ error: `vocabulary cannot contain URLs/emails/paths: "${term}" — they induce hallucinations in the decoder prompt` })
92
+ }
93
+ vocab.push(term)
94
+ }
95
+ patch.vocabulary = vocab
96
+ }
97
+
98
+ // ── corrections (bad → good) — persisted as a JSON STRING ──
99
+ if (body.corrections !== undefined) {
100
+ if (typeof body.corrections !== 'object' || body.corrections === null || Array.isArray(body.corrections)) {
101
+ return res.status(400).json({ error: 'corrections must be an object { "bad": "good" }' })
102
+ }
103
+ const entries = Object.entries(body.corrections as Record<string, unknown>)
104
+ if (entries.length > MAX_TERMS) return res.status(400).json({ error: `too many corrections (max ${MAX_TERMS})` })
105
+ const map: Record<string, string> = {}
106
+ for (const [bad, good] of entries) {
107
+ const b = bad.trim()
108
+ if (!b) continue
109
+ if (typeof good !== 'string') return res.status(400).json({ error: `correction "${bad}" must map to a string` })
110
+ if (b.length > MAX_TERM_LEN || good.length > MAX_TERM_LEN) {
111
+ return res.status(400).json({ error: `correction too long near "${b.slice(0, 40)}"` })
112
+ }
113
+ map[b] = good.trim()
114
+ }
115
+ patch.whisper_corrections = JSON.stringify(map)
116
+ }
117
+
118
+ // ── negative rules (whole/strip/replace/flag) ──
119
+ if (body.negative_rules !== undefined) {
120
+ if (!Array.isArray(body.negative_rules)) {
121
+ return res.status(400).json({ error: 'negative_rules must be an array of strings' })
122
+ }
123
+ if (body.negative_rules.length > MAX_RULES) {
124
+ return res.status(400).json({ error: `too many negative rules (max ${MAX_RULES})` })
125
+ }
126
+ const rules: string[] = []
127
+ for (const raw of body.negative_rules) {
128
+ if (typeof raw !== 'string') return res.status(400).json({ error: 'negative_rules entries must be strings' })
129
+ const v = validateNegativeRule(raw)
130
+ if (!v.ok) return res.status(400).json({ error: `invalid rule "${raw.slice(0, 60)}": ${v.error}` })
131
+ rules.push(raw)
132
+ }
133
+ patch.negative_rules = rules
134
+ }
135
+
136
+ if (Object.keys(patch).length === 0) {
137
+ return res.status(400).json({ error: 'nothing to update (send vocabulary, corrections, and/or negative_rules)' })
138
+ }
139
+
140
+ try {
141
+ // Cache-bust chain: updateProfileFields() writes atomically + clears the ROOT
142
+ // profileCache; resetDecoderCaches() clears the two derived decoder snapshots.
143
+ // Both are required for an edit to reach the decoder without a restart.
144
+ await (putChain = putChain.catch(() => {}).then(() => {
145
+ updateProfileFields(patch)
146
+ resetDecoderCaches()
147
+ resetVocabEchoCache() // vocab matcher in hallucination-filter is profile-derived too
148
+ }))
149
+ return res.json(currentGlossary())
150
+ } catch (err) {
151
+ return res.status(500).json({ error: errMsg(err) })
152
+ }
153
+ })
@@ -0,0 +1,101 @@
1
+ import { Router } from 'express'
2
+ import {
3
+ buildHandoffPromptContext,
4
+ claimHandoff,
5
+ createHandoff,
6
+ getHandoff,
7
+ getLatestHandoff,
8
+ type HandoffCreateInput,
9
+ } from '../lib/handoff-store.js'
10
+ import { getAvailableCliSessionId } from '../lib/claude-bridge.js'
11
+ import { getCodexExecutionCwd, getCodexTrustMode } from '../lib/codex-run-ledger.js'
12
+ import { normalizeHandoffCode } from '../../shared/handoff-intent.js'
13
+
14
+ export const handoffsRouter = Router()
15
+
16
+ function runtimeExpiresAt(): string {
17
+ return new Date(Date.now() + 2 * 60 * 60_000).toISOString()
18
+ }
19
+
20
+ function enrichRuntime(body: Record<string, any>): HandoffCreateInput {
21
+ const input: HandoffCreateInput = { ...body }
22
+ const runtime = body.runtime && typeof body.runtime === 'object' ? { ...body.runtime } : {}
23
+
24
+ if (runtime.codex?.codexThreadId) {
25
+ runtime.codex = {
26
+ ...runtime.codex,
27
+ cwd: runtime.codex.cwd ?? getCodexExecutionCwd(),
28
+ trustMode: runtime.codex.trustMode ?? getCodexTrustMode(),
29
+ expiresAt: runtime.codex.expiresAt ?? runtimeExpiresAt(),
30
+ }
31
+ }
32
+
33
+ if (runtime.claude?.cliSessionId) {
34
+ runtime.claude = {
35
+ ...runtime.claude,
36
+ expiresAt: runtime.claude.expiresAt ?? runtimeExpiresAt(),
37
+ }
38
+ } else if (typeof body.sessionId === 'string') {
39
+ const cliSessionId = getAvailableCliSessionId(body.sessionId)
40
+ if (cliSessionId) {
41
+ runtime.claude = {
42
+ cliSessionId,
43
+ model: typeof body.model === 'string' ? body.model : undefined,
44
+ expiresAt: runtimeExpiresAt(),
45
+ }
46
+ }
47
+ }
48
+
49
+ if (runtime.codex || runtime.claude) input.runtime = runtime
50
+ return input
51
+ }
52
+
53
+ handoffsRouter.post('/handoffs', async (req, res) => {
54
+ try {
55
+ const body = req.body && typeof req.body === 'object' ? req.body : {}
56
+ const handoff = await createHandoff(enrichRuntime(body))
57
+ res.status(201).json({ handoff, context: buildHandoffPromptContext(handoff) })
58
+ } catch (err: any) {
59
+ res.status(500).json({ error: err?.message ?? 'handoff create failed' })
60
+ }
61
+ })
62
+
63
+ handoffsRouter.get('/handoffs/latest', async (req, res) => {
64
+ try {
65
+ const handoff = await getLatestHandoff({
66
+ source: typeof req.query.source === 'string' ? req.query.source : undefined,
67
+ target: typeof req.query.target === 'string' ? req.query.target : undefined,
68
+ createdBy: typeof req.query.createdBy === 'string' ? req.query.createdBy : undefined,
69
+ deviceId: typeof req.query.deviceId === 'string' ? req.query.deviceId : undefined,
70
+ })
71
+ if (!handoff) return res.status(404).json({ error: 'handoff not found' })
72
+ res.json({ handoff, context: buildHandoffPromptContext(handoff) })
73
+ } catch (err: any) {
74
+ res.status(500).json({ error: err?.message ?? 'handoff lookup failed' })
75
+ }
76
+ })
77
+
78
+ handoffsRouter.get('/handoffs/:code', async (req, res) => {
79
+ const code = normalizeHandoffCode(req.params.code)
80
+ if (!code) return res.status(404).json({ error: 'handoff not found' })
81
+ try {
82
+ const handoff = await getHandoff(code)
83
+ if (!handoff) return res.status(404).json({ error: 'handoff not found' })
84
+ res.json({ handoff, context: buildHandoffPromptContext(handoff) })
85
+ } catch (err: any) {
86
+ res.status(500).json({ error: err?.message ?? 'handoff lookup failed' })
87
+ }
88
+ })
89
+
90
+ handoffsRouter.post('/handoffs/:code/claim', async (req, res) => {
91
+ const code = normalizeHandoffCode(req.params.code)
92
+ if (!code) return res.status(404).json({ error: 'handoff not found' })
93
+ try {
94
+ const claimedBy = typeof req.body?.claimedBy === 'string' ? req.body.claimedBy : 'unknown'
95
+ const handoff = await claimHandoff(code, claimedBy)
96
+ if (!handoff) return res.status(404).json({ error: 'handoff not found' })
97
+ res.json({ handoff, context: buildHandoffPromptContext(handoff) })
98
+ } catch (err: any) {
99
+ res.status(500).json({ error: err?.message ?? 'handoff claim failed' })
100
+ }
101
+ })
@@ -0,0 +1,33 @@
1
+ import { Router } from 'express'
2
+ import {
3
+ PromptEditValidationError,
4
+ applyPromptEdit,
5
+ normalizePromptEditInput,
6
+ } from '../lib/prompt-edit.js'
7
+ import { errMsg } from '../lib/utils.js'
8
+
9
+ export const promptEditRouter = Router()
10
+
11
+ promptEditRouter.post('/prompt-edit', async (req, res) => {
12
+ const abort = new AbortController()
13
+ // Abort the model spawn ONLY if the client disconnects before we respond.
14
+ // Must listen on `res`, not `req`: express.json() fully drains the request
15
+ // body stream before this handler runs, so `req` emits 'close' on the next
16
+ // tick and would abort EVERY edit mid-flight (instant 500 "Prompt edit
17
+ // aborted"). `res` 'close' + !writableEnded fires only on a genuine premature
18
+ // disconnect. (House pattern: see routes/query.ts.)
19
+ res.on('close', () => {
20
+ if (!res.writableEnded) abort.abort()
21
+ })
22
+ try {
23
+ const input = normalizePromptEditInput(req.body)
24
+ const revisedText = await applyPromptEdit(input, abort.signal)
25
+ res.json({ revisedText })
26
+ } catch (err) {
27
+ if (err instanceof PromptEditValidationError) {
28
+ res.status(err.status).json({ error: err.message })
29
+ return
30
+ }
31
+ res.status(500).json({ error: errMsg(err) })
32
+ }
33
+ })
@@ -0,0 +1,76 @@
1
+ import { Router } from 'express'
2
+ import { readFileSync } from 'node:fs'
3
+ import { resolve } from 'node:path'
4
+ import { atomicWriteFileSync } from '../lib/atomic-fs.js'
5
+ import { acquireMaintenance, getRecoveryActivityStatus } from '../lib/recovery-activity.js'
6
+ import { getWhisperHealth, restartWhisperServer } from '../lib/whisper-local.js'
7
+ import { serverMetrics } from '../lib/server-metrics.js'
8
+
9
+ export const recoveryRouter = Router()
10
+ const COOLDOWN_MS = 60_000
11
+ const cooldownPath = resolve(import.meta.dirname, '../data/.recovery_restart.json')
12
+
13
+ function lastRestartAt(): number {
14
+ try { return Number(JSON.parse(readFileSync(cooldownPath, 'utf8'))?.at) || 0 } catch { return 0 }
15
+ }
16
+
17
+ recoveryRouter.get('/live', (_req, res) => {
18
+ res.setHeader('Cache-Control', 'no-store')
19
+ res.json({
20
+ status: 'ok', bootId: serverMetrics.bootId, pid: process.pid,
21
+ uptimeSeconds: Math.round((Date.now() - serverMetrics.startedAt) / 1000),
22
+ managed: process.env.COS_HARNESS === 'daemon',
23
+ })
24
+ })
25
+
26
+ recoveryRouter.get('/recovery/status', (_req, res) => {
27
+ res.json({
28
+ bootId: serverMetrics.bootId,
29
+ managed: process.env.COS_HARNESS === 'daemon',
30
+ whisper: getWhisperHealth(),
31
+ asr: { hqActive: false, hqQueued: 0, fastRestarting: false }, // public build: no HQ/fast ASR scheduler in this server
32
+ activity: getRecoveryActivityStatus(),
33
+ })
34
+ })
35
+
36
+ recoveryRouter.post('/recovery/whisper/restart', async (_req, res) => {
37
+ const gate = acquireMaintenance()
38
+ if (!gate.ok) {
39
+ gate.release()
40
+ return res.status(409).json({ error: 'Recovery blocked by active work', reason: 'recovery_busy', busy: gate.busy })
41
+ }
42
+ try {
43
+ const result = await restartWhisperServer()
44
+ res.status(result.status === 'failed' ? 503 : 200).json(result)
45
+ } finally { gate.release() }
46
+ })
47
+
48
+ recoveryRouter.post('/recovery/server/restart', (_req, res) => {
49
+ if (process.env.COS_HARNESS !== 'daemon') {
50
+ return res.status(409).json({ error: 'Server is not managed by the COS LaunchAgent', reason: 'restart_unmanaged' })
51
+ }
52
+ const elapsed = Date.now() - lastRestartAt()
53
+ if (elapsed < COOLDOWN_MS) {
54
+ return res.status(429).json({ error: 'Restart cooldown active', reason: 'restart_cooldown', retryAfterMs: COOLDOWN_MS - elapsed })
55
+ }
56
+ const gate = acquireMaintenance()
57
+ if (!gate.ok) {
58
+ gate.release()
59
+ return res.status(409).json({ error: 'Restart blocked by active work', reason: 'recovery_busy', busy: gate.busy })
60
+ }
61
+ atomicWriteFileSync(cooldownPath, JSON.stringify({ at: Date.now(), bootId: serverMetrics.bootId }))
62
+ res.status(202).json({ accepted: true, oldBootId: serverMetrics.bootId })
63
+ // Schedule independently of the response socket. The phone may change
64
+ // network/close the sheet immediately after receiving 202; that must not
65
+ // cancel an accepted restart or strand maintenance forever.
66
+ const timer = setTimeout(() => {
67
+ if (process.env.COS_DISABLE_SELF_RESTART === '1') { gate.release(); return }
68
+ try {
69
+ process.kill(process.pid, 'SIGTERM')
70
+ } catch (error) {
71
+ gate.release()
72
+ console.error('[recovery] Failed to signal managed server restart:', error)
73
+ }
74
+ }, 350)
75
+ timer.unref?.()
76
+ })