@gotcos/glasses-server 6.12.7 → 6.14.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 +11 -0
- package/README.md +10 -0
- package/bin/cli.cjs +12 -0
- package/bin/managed-server.cjs +28 -0
- package/managed-runtime-contract.json +23 -0
- package/package.json +6 -3
- package/server/index.ts +106 -35
- package/server/lib/bookmarks.ts +96 -0
- package/server/lib/handoff-store.ts +404 -0
- package/server/lib/launch-dir.ts +13 -7
- package/server/lib/maintenance-lifecycle.ts +735 -0
- package/server/lib/managed-runtime.ts +44 -0
- package/server/lib/openai-tts-budget.ts +142 -0
- package/server/lib/prompt-edit.ts +224 -0
- package/server/lib/query-job-coordinator.ts +36 -4
- package/server/lib/query-job-runtime.ts +38 -26
- 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/routes/bookmarks.ts +59 -0
- package/server/routes/glossary.ts +153 -0
- package/server/routes/handoffs.ts +101 -0
- package/server/routes/health.ts +15 -12
- package/server/routes/maintenance.ts +160 -0
- package/server/routes/meeting.ts +25 -8
- package/server/routes/openai-compat.ts +82 -36
- package/server/routes/prompt-drafts.ts +51 -8
- package/server/routes/prompt-edit.ts +33 -0
- package/server/routes/query.ts +52 -24
- package/server/routes/recovery.ts +76 -0
- package/server/routes/transcribe-stream.ts +49 -3
- package/server/routes/transcribe.ts +14 -0
- package/server/routes/tts.ts +709 -0
- package/server/routes/voice.ts +317 -0
- package/shared/handoff-intent.ts +90 -0
|
@@ -43,6 +43,11 @@ import {
|
|
|
43
43
|
type IndexRange,
|
|
44
44
|
} from '../lib/local-first-meetings-contract.js'
|
|
45
45
|
import { getServerInstanceId } from '../lib/server-instance-id.js'
|
|
46
|
+
import {
|
|
47
|
+
acquireMaintenanceWork,
|
|
48
|
+
maintenanceAdmissionsOpen,
|
|
49
|
+
type MaintenanceWorkLease,
|
|
50
|
+
} from '../lib/maintenance-lifecycle.js'
|
|
46
51
|
|
|
47
52
|
function ensurePrivateDirectory(path: string): void {
|
|
48
53
|
if (!existsSync(path)) mkdirSync(path, { recursive: true, mode: 0o700 })
|
|
@@ -264,6 +269,12 @@ interface StreamChunkCompletionResponse {
|
|
|
264
269
|
|
|
265
270
|
const closedSessionRecords = new Map<string, ClosedTranscriptSession>()
|
|
266
271
|
|
|
272
|
+
/** Conservative lifecycle count used by the local service manager. A restart
|
|
273
|
+
* is unsafe while any live transcription session owns audio state. */
|
|
274
|
+
export function getActiveTranscriptionSessionCount(): number {
|
|
275
|
+
return sessions.size
|
|
276
|
+
}
|
|
277
|
+
|
|
267
278
|
// Incremental chunk persistence — survive server restarts
|
|
268
279
|
const CHUNK_PERSIST_DIR = dataPath('active-sessions')
|
|
269
280
|
ensurePrivateDirectory(CHUNK_PERSIST_DIR)
|
|
@@ -600,12 +611,17 @@ export function isSessionDeleted(sessionId: string): boolean {
|
|
|
600
611
|
return deletedSessions.has(sessionId)
|
|
601
612
|
}
|
|
602
613
|
|
|
603
|
-
//
|
|
604
|
-
|
|
605
|
-
|
|
614
|
+
// A committed cross-boot maintenance operation owns durable state until the
|
|
615
|
+
// trusted controller adopts the candidate. Boot recovery must not mutate or
|
|
616
|
+
// promote sessions while that gate is closed.
|
|
617
|
+
if (maintenanceAdmissionsOpen()) {
|
|
618
|
+
recoverClosedSessions()
|
|
619
|
+
recoverSessions()
|
|
620
|
+
}
|
|
606
621
|
|
|
607
622
|
// Auto-cleanup sessions idle for the advertised retention horizon.
|
|
608
623
|
setInterval(() => {
|
|
624
|
+
if (!maintenanceAdmissionsOpen()) return
|
|
609
625
|
const cutoff = Date.now() - LOCAL_FIRST_MEETING_IDLE_RETENTION_MS
|
|
610
626
|
for (const [id, session] of sessions) {
|
|
611
627
|
if (session.lastActivityAt < cutoff) {
|
|
@@ -1550,10 +1566,14 @@ function sendStreamError(res: { status: (code: number) => { json: (body: unknown
|
|
|
1550
1566
|
}
|
|
1551
1567
|
|
|
1552
1568
|
transcribeStreamRouter.post('/transcribe-stream', async (req, res) => {
|
|
1569
|
+
let maintenanceLease: MaintenanceWorkLease | undefined
|
|
1553
1570
|
try {
|
|
1554
1571
|
// Reject a wrong Mac before consuming or persisting any upload bytes.
|
|
1555
1572
|
assertPinnedServerIdentity(req.get('X-COS-Server-Instance'), req.query.serverInstanceId)
|
|
1556
1573
|
const sessionId = (req.query.sessionId as string) || `g2_${Date.now()}`
|
|
1574
|
+
maintenanceLease = acquireMaintenanceWork('recording_chunk', {
|
|
1575
|
+
allowDuringDrain: sessions.has(sessionId),
|
|
1576
|
+
})
|
|
1557
1577
|
const chunkIndex = parseInt((req.query.chunkIndex as string) || '0', 10)
|
|
1558
1578
|
const clientSpeaker = (req.query.speaker as string) || 'Unknown'
|
|
1559
1579
|
const clientElapsedRaw = Number(req.query.elapsed)
|
|
@@ -1575,15 +1595,21 @@ transcribeStreamRouter.post('/transcribe-stream', async (req, res) => {
|
|
|
1575
1595
|
}))
|
|
1576
1596
|
} catch (err: unknown) {
|
|
1577
1597
|
sendStreamError(res, err)
|
|
1598
|
+
} finally {
|
|
1599
|
+
maintenanceLease?.release()
|
|
1578
1600
|
}
|
|
1579
1601
|
})
|
|
1580
1602
|
|
|
1581
1603
|
transcribeStreamRouter.post('/transcribe-stream/offline-sessions/start', async (req, res) => {
|
|
1604
|
+
let maintenanceLease: MaintenanceWorkLease | undefined
|
|
1582
1605
|
try {
|
|
1583
1606
|
if (!isIosAsrCandidateEnabled()) throw makeHttpError(403, 'iPhone ASR candidates disabled', 'iphone_asr_disabled')
|
|
1584
1607
|
const body = req.body ?? {}
|
|
1585
1608
|
const sessionId = String(body.sessionId ?? '')
|
|
1586
1609
|
validateSessionId(sessionId)
|
|
1610
|
+
maintenanceLease = acquireMaintenanceWork('recording_chunk', {
|
|
1611
|
+
allowDuringDrain: sessions.has(sessionId),
|
|
1612
|
+
})
|
|
1587
1613
|
if (isSessionDeleted(sessionId)) throw makeHttpError(410, 'session is closed', 'session_closed')
|
|
1588
1614
|
const session = getSession(sessionId)
|
|
1589
1615
|
const startTime = typeof body.startTime === 'number' && Number.isFinite(body.startTime)
|
|
@@ -1596,14 +1622,20 @@ transcribeStreamRouter.post('/transcribe-stream/offline-sessions/start', async (
|
|
|
1596
1622
|
res.json({ sessionId, startTime: session.startTime, chunks: session.chunks.filter(Boolean).length })
|
|
1597
1623
|
} catch (err: unknown) {
|
|
1598
1624
|
sendStreamError(res, err)
|
|
1625
|
+
} finally {
|
|
1626
|
+
maintenanceLease?.release()
|
|
1599
1627
|
}
|
|
1600
1628
|
})
|
|
1601
1629
|
|
|
1602
1630
|
transcribeStreamRouter.post('/transcribe-stream/offline-sessions/:sessionId/chunks', async (req, res) => {
|
|
1631
|
+
let maintenanceLease: MaintenanceWorkLease | undefined
|
|
1603
1632
|
try {
|
|
1604
1633
|
if (!isIosAsrCandidateEnabled()) throw makeHttpError(403, 'iPhone ASR candidates disabled', 'iphone_asr_disabled')
|
|
1605
1634
|
const sessionId = String(req.params.sessionId ?? '')
|
|
1606
1635
|
validateSessionId(sessionId)
|
|
1636
|
+
maintenanceLease = acquireMaintenanceWork('recording_chunk', {
|
|
1637
|
+
allowDuringDrain: sessions.has(sessionId),
|
|
1638
|
+
})
|
|
1607
1639
|
if (isSessionDeleted(sessionId)) throw makeHttpError(410, 'session is closed', 'session_closed')
|
|
1608
1640
|
const body = req.body ?? {}
|
|
1609
1641
|
const chunkIndex = Number(body.chunkIndex)
|
|
@@ -1636,14 +1668,20 @@ transcribeStreamRouter.post('/transcribe-stream/offline-sessions/:sessionId/chun
|
|
|
1636
1668
|
res.json({ ...result, offlineReplay: true })
|
|
1637
1669
|
} catch (err: unknown) {
|
|
1638
1670
|
sendStreamError(res, err)
|
|
1671
|
+
} finally {
|
|
1672
|
+
maintenanceLease?.release()
|
|
1639
1673
|
}
|
|
1640
1674
|
})
|
|
1641
1675
|
|
|
1642
1676
|
transcribeStreamRouter.post('/transcribe-stream/offline-sessions/:sessionId/finalize', async (req, res) => {
|
|
1677
|
+
let maintenanceLease: MaintenanceWorkLease | undefined
|
|
1643
1678
|
try {
|
|
1644
1679
|
if (!isIosAsrCandidateEnabled()) throw makeHttpError(403, 'iPhone ASR candidates disabled', 'iphone_asr_disabled')
|
|
1645
1680
|
const sessionId = String(req.params.sessionId ?? '')
|
|
1646
1681
|
validateSessionId(sessionId)
|
|
1682
|
+
maintenanceLease = acquireMaintenanceWork('recording_chunk', {
|
|
1683
|
+
allowDuringDrain: sessions.has(sessionId),
|
|
1684
|
+
})
|
|
1647
1685
|
if (isSessionDeleted(sessionId)) throw makeHttpError(410, 'session is closed', 'session_closed')
|
|
1648
1686
|
const chunks = getSessionChunks(sessionId)
|
|
1649
1687
|
if (!chunks || chunks.length === 0) throw makeHttpError(404, 'offline session has no chunks', 'session_not_found')
|
|
@@ -1662,16 +1700,22 @@ transcribeStreamRouter.post('/transcribe-stream/offline-sessions/:sessionId/fina
|
|
|
1662
1700
|
})
|
|
1663
1701
|
} catch (err: unknown) {
|
|
1664
1702
|
sendStreamError(res, err)
|
|
1703
|
+
} finally {
|
|
1704
|
+
maintenanceLease?.release()
|
|
1665
1705
|
}
|
|
1666
1706
|
})
|
|
1667
1707
|
|
|
1668
1708
|
transcribeStreamRouter.post('/transcribe-stream/candidates', async (req, res) => {
|
|
1709
|
+
let maintenanceLease: MaintenanceWorkLease | undefined
|
|
1669
1710
|
try {
|
|
1670
1711
|
if (!isIosAsrCandidateEnabled()) throw makeHttpError(403, 'iPhone ASR candidates disabled', 'iphone_asr_disabled')
|
|
1671
1712
|
const body = req.body ?? {}
|
|
1672
1713
|
const sessionId = String(body.sessionId ?? '')
|
|
1673
1714
|
const chunkIndex = Number(body.chunkIndex)
|
|
1674
1715
|
validateSessionId(sessionId)
|
|
1716
|
+
maintenanceLease = acquireMaintenanceWork('recording_chunk', {
|
|
1717
|
+
allowDuringDrain: sessions.has(sessionId),
|
|
1718
|
+
})
|
|
1675
1719
|
validateChunkIndex(chunkIndex)
|
|
1676
1720
|
if (isSessionDeleted(sessionId)) throw makeHttpError(410, 'session is closed', 'session_closed')
|
|
1677
1721
|
if (chunkIndex > 0 && !sessions.has(sessionId)) {
|
|
@@ -1702,6 +1746,8 @@ transcribeStreamRouter.post('/transcribe-stream/candidates', async (req, res) =>
|
|
|
1702
1746
|
res.json(result)
|
|
1703
1747
|
} catch (err: unknown) {
|
|
1704
1748
|
sendStreamError(res, err)
|
|
1749
|
+
} finally {
|
|
1750
|
+
maintenanceLease?.release()
|
|
1705
1751
|
}
|
|
1706
1752
|
})
|
|
1707
1753
|
|
|
@@ -11,6 +11,12 @@ import {
|
|
|
11
11
|
OpenAIWhisperBudgetExhaustedError,
|
|
12
12
|
TranscriptionUnavailableError,
|
|
13
13
|
} from '../lib/transcribe-audio.js'
|
|
14
|
+
import {
|
|
15
|
+
acquireMaintenanceWork,
|
|
16
|
+
MaintenanceLifecycleError,
|
|
17
|
+
maintenanceErrorPayload,
|
|
18
|
+
type MaintenanceWorkLease,
|
|
19
|
+
} from '../lib/maintenance-lifecycle.js'
|
|
14
20
|
|
|
15
21
|
export const transcribeRouter = Router()
|
|
16
22
|
|
|
@@ -23,7 +29,9 @@ function resolveMode(req: { body?: { mode?: string }; query?: { mode?: string |
|
|
|
23
29
|
|
|
24
30
|
// Accept raw binary body up to 25MB (Whisper limit).
|
|
25
31
|
transcribeRouter.post('/transcribe', async (req, res) => {
|
|
32
|
+
let maintenanceLease: MaintenanceWorkLease | undefined
|
|
26
33
|
try {
|
|
34
|
+
maintenanceLease = acquireMaintenanceWork('one_shot_transcription')
|
|
27
35
|
const chunks: Buffer[] = []
|
|
28
36
|
for await (const chunk of req) {
|
|
29
37
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
|
|
@@ -38,6 +46,10 @@ transcribeRouter.post('/transcribe', async (req, res) => {
|
|
|
38
46
|
console.log(`[perf] /transcribe: ${result.elapsedMs.toFixed(1)}ms | mode=${result.mode} | ${result.backend} | ${result.audioBytes}b | ${result.text.length} chars`)
|
|
39
47
|
res.json({ text: result.text, backend: result.backend, mode: result.mode })
|
|
40
48
|
} catch (err: any) {
|
|
49
|
+
if (err instanceof MaintenanceLifecycleError) {
|
|
50
|
+
if (err.retryAfterSeconds != null) res.setHeader('Retry-After', String(err.retryAfterSeconds))
|
|
51
|
+
return res.status(err.status).json(maintenanceErrorPayload(err))
|
|
52
|
+
}
|
|
41
53
|
if (err instanceof NoSpeechDetectedError) {
|
|
42
54
|
console.log(`[perf] /transcribe: DROPPED (hallucination or empty): ${err.rawText.length} chars`)
|
|
43
55
|
return res.status(204).send()
|
|
@@ -60,5 +72,7 @@ transcribeRouter.post('/transcribe', async (req, res) => {
|
|
|
60
72
|
})
|
|
61
73
|
}
|
|
62
74
|
res.status(500).json({ error: err.message })
|
|
75
|
+
} finally {
|
|
76
|
+
maintenanceLease?.release()
|
|
63
77
|
}
|
|
64
78
|
})
|