@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.
- package/CHANGELOG.md +16 -0
- package/package.json +1 -1
- package/server/index.ts +14 -0
- package/server/lib/bookmarks.ts +96 -0
- package/server/lib/handoff-store.ts +404 -0
- package/server/lib/meeting-batch-transcribe.ts +1 -1
- package/server/lib/openai-tts-budget.ts +142 -0
- package/server/lib/prompt-edit.ts +224 -0
- package/server/lib/recovery-activity.ts +168 -0
- package/server/lib/speaker-trainer.ts +532 -0
- package/server/lib/tts-cache.ts +596 -0
- package/server/lib/whisper-local.ts +43 -24
- package/server/routes/bookmarks.ts +59 -0
- package/server/routes/glossary.ts +153 -0
- package/server/routes/handoffs.ts +101 -0
- package/server/routes/prompt-edit.ts +33 -0
- package/server/routes/recovery.ts +76 -0
- package/server/routes/tts.ts +709 -0
- package/server/routes/voice.ts +317 -0
- package/shared/handoff-intent.ts +90 -0
|
@@ -42,9 +42,8 @@ export interface WhisperSegment {
|
|
|
42
42
|
words?: WhisperWord[]
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
interface
|
|
46
|
-
text
|
|
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
|
-
|
|
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(
|
|
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',
|
|
558
|
+
'-t', isolateBatchFromLiveMetal ? '8' : '16',
|
|
548
559
|
'-l', 'en',
|
|
549
|
-
'-
|
|
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
|
-
|
|
578
|
-
|
|
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
|
-
*
|
|
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', '
|
|
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
|
|
654
|
-
|
|
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
|
+
})
|