@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.
- package/CHANGELOG.md +4 -0
- package/package.json +2 -2
- package/server/index.ts +14 -0
- package/server/lib/bookmarks.ts +96 -0
- package/server/lib/handoff-store.ts +404 -0
- 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/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
|
@@ -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
|
+
})
|