@gotcos/glasses-server 6.13.0 → 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.
@@ -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
+ })