@huynle/hub 0.0.0-stage → 0.6.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 +269 -0
- package/Dockerfile +51 -0
- package/LICENSE +21 -0
- package/README.md +1029 -2
- package/bin/hub.ts +20 -0
- package/docker/hub-deps.ts +42 -0
- package/package.json +85 -4
- package/rpc.ts +2 -0
- package/scripts/hub-local.sh +28 -0
- package/scripts/io.github.huynle.hub.plist +42 -0
- package/server.ts +3 -0
- package/src/central/assets.ts +74 -0
- package/src/central/auth.ts +271 -0
- package/src/central/config.ts +310 -0
- package/src/central/http.ts +1247 -0
- package/src/central/main.ts +136 -0
- package/src/central/oidc.ts +295 -0
- package/src/central/push-target.ts +97 -0
- package/src/central/push.ts +285 -0
- package/src/central/registry.ts +1153 -0
- package/src/central/speech/models.ts +89 -0
- package/src/central/speech/openai.ts +66 -0
- package/src/central/speech/openrouter.ts +71 -0
- package/src/central/speech/provider.ts +74 -0
- package/src/central/speech/service.ts +168 -0
- package/src/central/speech/settings.ts +375 -0
- package/src/central/spoke-ws.ts +300 -0
- package/src/central/store.ts +653 -0
- package/src/config.ts +170 -0
- package/src/env.ts +63 -0
- package/src/index.ts +236 -0
- package/src/key.ts +17 -0
- package/src/loopback.ts +41 -0
- package/src/migrate.ts +105 -0
- package/src/openrouter.ts +3 -0
- package/src/options.ts +33 -0
- package/src/protocol.ts +650 -0
- package/src/reply.ts +27 -0
- package/src/rpc.ts +98 -0
- package/src/setup.ts +124 -0
- package/src/speakable.ts +35 -0
- package/src/spoke/client.ts +272 -0
- package/src/spoke/conversation.ts +93 -0
- package/src/spoke/enroll.ts +79 -0
- package/src/spoke/herdr.ts +346 -0
- package/src/spoke/listing.ts +108 -0
- package/src/spoke/localhub.ts +287 -0
- package/src/spoke/locations.ts +34 -0
- package/src/spoke/own-server.ts +32 -0
- package/src/spoke/presence.ts +45 -0
- package/src/spoke/singleton.ts +103 -0
- package/src/spoke/spoke.ts +1212 -0
- package/src/spoke/summary.ts +117 -0
- package/src/spoke/timeline.ts +93 -0
- package/src/spoke/tracker.ts +345 -0
- package/src/spoke/watch.ts +78 -0
- package/src/status.ts +232 -0
- package/src/tui-commands.ts +53 -0
- package/src/tui-forms.ts +77 -0
- package/src/tui-presence.ts +75 -0
- package/src/tui.ts +479 -0
- package/src/turns.ts +94 -0
- package/src/voice-command.ts +65 -0
- package/tui.ts +2 -0
- package/web/dist/apple-touch-icon.png +0 -0
- package/web/dist/assets/index-DClBvfLL.css +1 -0
- package/web/dist/assets/index-Djo_yF0a.js +12 -0
- package/web/dist/favicon.ico +0 -0
- package/web/dist/icon-192.png +0 -0
- package/web/dist/icon-512.png +0 -0
- package/web/dist/icon-maskable-512.png +0 -0
- package/web/dist/icon.svg +18 -0
- package/web/dist/index.html +25 -0
- package/web/dist/manifest.webmanifest +18 -0
- package/web/dist/sw.js +151 -0
- package/web/dist/vad-worklet.js +27 -0
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
// Speech settings (persisted in state.json): which provider, model, voice, key and base URL to use for
|
|
2
|
+
// speech-to-text and text-to-speech. Precedence, per direction and field: the user's own settings (multi-
|
|
3
|
+
// user hubs) > the saved server default > hub.json (`speech`) > the hub's environment (OPENROUTER_API_KEY,
|
|
4
|
+
// VOICE_STT_MODEL, VOICE_TTS_MODEL, VOICE_VOICE). Keys are only ever shown masked ({set, last4, source});
|
|
5
|
+
// a user sees last4 of their own key only.
|
|
6
|
+
import { ValidationError } from "../auth"
|
|
7
|
+
import { OPENROUTER_BASE_URL } from "./openrouter"
|
|
8
|
+
|
|
9
|
+
export const SPEECH_PROVIDERS = ["openrouter", "openai-compatible"] as const
|
|
10
|
+
export type SpeechProviderName = (typeof SPEECH_PROVIDERS)[number]
|
|
11
|
+
|
|
12
|
+
export interface StoredStt {
|
|
13
|
+
provider?: SpeechProviderName
|
|
14
|
+
model?: string
|
|
15
|
+
apiKey?: string
|
|
16
|
+
baseURL?: string
|
|
17
|
+
language?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface StoredTts {
|
|
21
|
+
provider?: SpeechProviderName
|
|
22
|
+
model?: string
|
|
23
|
+
voice?: string
|
|
24
|
+
apiKey?: string
|
|
25
|
+
baseURL?: string
|
|
26
|
+
speed?: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface StoredSpeech {
|
|
30
|
+
stt?: StoredStt
|
|
31
|
+
tts?: StoredTts
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Hub environment defaults (see CentralConfig). */
|
|
35
|
+
export interface SpeechDefaults {
|
|
36
|
+
/** OPENROUTER_API_KEY; only ever used with the openrouter provider. */
|
|
37
|
+
readonly apiKey?: string
|
|
38
|
+
readonly sttModel: string
|
|
39
|
+
readonly ttsModel: string
|
|
40
|
+
readonly voice: string
|
|
41
|
+
/** Speech settings from hub.json, layered under the saved ones. */
|
|
42
|
+
readonly file?: StoredSpeech
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** user: the caller's own key; settings: the saved server default; config: hub.json; env: OPENROUTER_API_KEY. */
|
|
46
|
+
export type KeySource = "user" | "settings" | "config" | "env" | "none"
|
|
47
|
+
|
|
48
|
+
export interface KeyView {
|
|
49
|
+
readonly set: boolean
|
|
50
|
+
readonly last4?: string
|
|
51
|
+
readonly source: KeySource
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Effective settings of one direction, including the key to use (never sent to clients). */
|
|
55
|
+
export interface Resolved {
|
|
56
|
+
readonly provider: SpeechProviderName
|
|
57
|
+
readonly model: string
|
|
58
|
+
readonly baseURL: string
|
|
59
|
+
readonly apiKey?: string
|
|
60
|
+
readonly keySource: KeySource
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface ResolvedStt extends Resolved {
|
|
64
|
+
readonly language?: string
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface ResolvedTts extends Resolved {
|
|
68
|
+
readonly voice: string
|
|
69
|
+
readonly speed?: number
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface SttView {
|
|
73
|
+
readonly provider: SpeechProviderName
|
|
74
|
+
readonly model: string
|
|
75
|
+
readonly baseURL?: string
|
|
76
|
+
readonly language?: string
|
|
77
|
+
readonly apiKey: KeyView
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface TtsView {
|
|
81
|
+
readonly provider: SpeechProviderName
|
|
82
|
+
readonly model: string
|
|
83
|
+
readonly voice: string
|
|
84
|
+
readonly baseURL?: string
|
|
85
|
+
readonly speed?: number
|
|
86
|
+
readonly apiKey: KeyView
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** GET /api/settings/speech */
|
|
90
|
+
export interface SpeechView {
|
|
91
|
+
readonly stt: SttView
|
|
92
|
+
readonly tts: TtsView
|
|
93
|
+
readonly voiceHints: Readonly<Record<string, readonly string[]>>
|
|
94
|
+
/** Whether each direction is usable (see speechReady). */
|
|
95
|
+
readonly ready: SpeechReady
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** A user's own saved fields, key masked (source "user"). */
|
|
99
|
+
export interface OwnSpeechView {
|
|
100
|
+
readonly stt: Omit<StoredStt, "apiKey"> & { readonly apiKey?: KeyView }
|
|
101
|
+
readonly tts: Omit<StoredTts, "apiKey"> & { readonly apiKey?: KeyView }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** GET /api/settings/speech for a user of a multi-user hub: effective settings plus where they come from. */
|
|
105
|
+
export interface UserSpeechView extends SpeechView {
|
|
106
|
+
readonly scope: "user"
|
|
107
|
+
/** The server default (keys: set and source only). */
|
|
108
|
+
readonly default: { readonly stt: SttView; readonly tts: TtsView }
|
|
109
|
+
readonly user: OwnSpeechView
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Voices known to work, keyed by model ID prefix (voices are model-specific). */
|
|
113
|
+
export const VOICE_HINTS: Readonly<Record<string, readonly string[]>> = Object.freeze({
|
|
114
|
+
"microsoft/mai-voice": ["en-US-Harper:MAI-Voice-2.1-Flash"],
|
|
115
|
+
"hexgrad/kokoro-82m": ["af_heart", "af_bella", "af_nicole", "af_sky", "am_adam", "am_michael", "bf_emma", "bm_george"],
|
|
116
|
+
"openai/": ["alloy", "ash", "ballad", "coral", "echo", "fable", "nova", "onyx", "sage", "shimmer", "verse"],
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
export const MAX_NAME_CHARS = 200
|
|
120
|
+
export const MIN_SPEED = 0.5
|
|
121
|
+
export const MAX_SPEED = 2
|
|
122
|
+
const MAX_KEY_CHARS = 500
|
|
123
|
+
const MAX_URL_CHARS = 500
|
|
124
|
+
/** Keys go into an Authorization header: printable ASCII without spaces. */
|
|
125
|
+
const KEY_PATTERN = /^[\x21-\x7e]+$/
|
|
126
|
+
const LANGUAGE_PATTERN = /^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{1,8}){0,3}$/
|
|
127
|
+
const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"])
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Hosts on a private network, where plain http:// is acceptable for self-hosted speech servers:
|
|
131
|
+
* loopback, RFC 1918 and Tailscale (100.64/10) addresses, single-label names (Docker services) and
|
|
132
|
+
* .local / .lan / .internal / .home.arpa names.
|
|
133
|
+
*/
|
|
134
|
+
export function isPrivateHost(hostname: string): boolean {
|
|
135
|
+
if (LOCAL_HOSTS.has(hostname)) return true
|
|
136
|
+
const ip = hostname.match(/^(\d{1,3})\.(\d{1,3})\.\d{1,3}\.\d{1,3}$/)
|
|
137
|
+
if (ip) {
|
|
138
|
+
const [a, b] = [Number(ip[1]), Number(ip[2])]
|
|
139
|
+
return a === 10 || a === 127 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 100 && b >= 64 && b <= 127)
|
|
140
|
+
}
|
|
141
|
+
if (!hostname.includes(".")) return /^[a-z0-9-]+$/i.test(hostname)
|
|
142
|
+
return /\.(local|lan|internal|home\.arpa)$/i.test(hostname)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* One direction's saved settings over hub.json's. The file's section is ignored when the saved provider
|
|
147
|
+
* differs from it, and its key is dropped when the saved base URL differs: a key never follows the
|
|
148
|
+
* settings to another endpoint.
|
|
149
|
+
*/
|
|
150
|
+
export function layer<T extends StoredStt | StoredTts>(file: T | undefined, saved: T | undefined): T | undefined {
|
|
151
|
+
if (!file) return saved
|
|
152
|
+
if (saved?.provider !== undefined && saved.provider !== (file.provider ?? "openrouter")) return saved
|
|
153
|
+
const merged: T = { ...file, ...saved }
|
|
154
|
+
if (saved?.baseURL !== undefined && saved.baseURL !== file.baseURL && saved.apiKey === undefined) delete merged.apiKey
|
|
155
|
+
return merged
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function resolve(
|
|
159
|
+
own: { user?: StoredStt | StoredTts; saved?: StoredStt | StoredTts },
|
|
160
|
+
merged: StoredStt | StoredTts | undefined,
|
|
161
|
+
model: string,
|
|
162
|
+
defaults: SpeechDefaults,
|
|
163
|
+
): Resolved {
|
|
164
|
+
const provider = merged?.provider ?? "openrouter"
|
|
165
|
+
const envKey = provider === "openrouter" ? defaults.apiKey : undefined
|
|
166
|
+
const key = merged?.apiKey
|
|
167
|
+
const apiKey = key ?? envKey
|
|
168
|
+
return {
|
|
169
|
+
provider,
|
|
170
|
+
model: merged?.model ?? model,
|
|
171
|
+
baseURL: merged?.baseURL ?? OPENROUTER_BASE_URL,
|
|
172
|
+
...(apiKey ? { apiKey } : {}),
|
|
173
|
+
keySource: key ? (own.user?.apiKey === key ? "user" : own.saved?.apiKey === key ? "settings" : "config") : envKey ? "env" : "none",
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** One direction's layers merged: user over the saved server default over hub.json. */
|
|
178
|
+
function merged<K extends "stt" | "tts">(kind: K, stored: StoredSpeech, defaults: SpeechDefaults, user?: StoredSpeech): StoredSpeech[K] {
|
|
179
|
+
const lower = layer(defaults.file?.[kind], stored[kind]) as StoredSpeech[K]
|
|
180
|
+
return user ? (layer(lower, user[kind]) as StoredSpeech[K]) : lower
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** The server default and hub.json merged: the layer under a user's own settings. */
|
|
184
|
+
export function lowerLayers(stored: StoredSpeech, defaults: SpeechDefaults): StoredSpeech {
|
|
185
|
+
const stt = merged("stt", stored, defaults)
|
|
186
|
+
const tts = merged("tts", stored, defaults)
|
|
187
|
+
return { ...(stt ? { stt } : {}), ...(tts ? { tts } : {}) }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function resolveStt(stored: StoredSpeech, defaults: SpeechDefaults, user?: StoredSpeech): ResolvedStt {
|
|
191
|
+
const stt = merged("stt", stored, defaults, user)
|
|
192
|
+
return { ...resolve({ user: user?.stt, saved: stored.stt }, stt, defaults.sttModel, defaults), ...(stt?.language ? { language: stt.language } : {}) }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function resolveTts(stored: StoredSpeech, defaults: SpeechDefaults, user?: StoredSpeech): ResolvedTts {
|
|
196
|
+
const tts = merged("tts", stored, defaults, user)
|
|
197
|
+
return {
|
|
198
|
+
...resolve({ user: user?.tts, saved: stored.tts }, tts, defaults.ttsModel, defaults),
|
|
199
|
+
voice: tts?.voice ?? defaults.voice,
|
|
200
|
+
...(tts?.speed !== undefined ? { speed: tts.speed } : {}),
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** `own`: last4 only for the caller's own key (user views), else for any key (the server default's view). */
|
|
205
|
+
function keyView(resolved: Resolved, own?: "user"): KeyView {
|
|
206
|
+
const key = resolved.apiKey
|
|
207
|
+
if (!key) return { set: false, source: "none" }
|
|
208
|
+
const last4 = key.length >= 12 && (own === undefined || resolved.keySource === "user")
|
|
209
|
+
return { set: true, ...(last4 ? { last4: key.slice(-4) } : {}), source: resolved.keySource }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function sections(stored: StoredSpeech, defaults: SpeechDefaults, user: StoredSpeech | undefined, own?: "user") {
|
|
213
|
+
const stt = resolveStt(stored, defaults, user)
|
|
214
|
+
const tts = resolveTts(stored, defaults, user)
|
|
215
|
+
const sttView: SttView = {
|
|
216
|
+
provider: stt.provider,
|
|
217
|
+
model: stt.model,
|
|
218
|
+
...(merged("stt", stored, defaults, user)?.baseURL ? { baseURL: stt.baseURL } : {}),
|
|
219
|
+
...(stt.language ? { language: stt.language } : {}),
|
|
220
|
+
apiKey: keyView(stt, own),
|
|
221
|
+
}
|
|
222
|
+
const ttsView: TtsView = {
|
|
223
|
+
provider: tts.provider,
|
|
224
|
+
model: tts.model,
|
|
225
|
+
voice: tts.voice,
|
|
226
|
+
...(merged("tts", stored, defaults, user)?.baseURL ? { baseURL: tts.baseURL } : {}),
|
|
227
|
+
...(tts.speed !== undefined ? { speed: tts.speed } : {}),
|
|
228
|
+
apiKey: keyView(tts, own),
|
|
229
|
+
}
|
|
230
|
+
return { stt: sttView, tts: ttsView, ready: speechReady(stt, tts) }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Settings as shown to the phone (the server default's scope): effective values, keys masked. */
|
|
234
|
+
export function speechView(stored: StoredSpeech, defaults: SpeechDefaults): SpeechView {
|
|
235
|
+
const { stt, tts, ready } = sections(stored, defaults, undefined)
|
|
236
|
+
return { stt, tts, voiceHints: VOICE_HINTS, ready }
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* A user's view: effective settings (own → server default → hub.json → env), the server default and the
|
|
241
|
+
* user's own fields. Keys that are not the user's own show only whether they are set and where from.
|
|
242
|
+
*/
|
|
243
|
+
export function userSpeechView(stored: StoredSpeech, defaults: SpeechDefaults, user: StoredSpeech): UserSpeechView {
|
|
244
|
+
const { stt, tts, ready } = sections(stored, defaults, user, "user")
|
|
245
|
+
const fallback = sections(stored, defaults, undefined, "user")
|
|
246
|
+
const own = <T extends StoredStt | StoredTts>(section: T | undefined) => {
|
|
247
|
+
const { apiKey, ...rest } = section ?? ({} as T)
|
|
248
|
+
const view = { ...rest } as Omit<T, "apiKey"> & { apiKey?: KeyView }
|
|
249
|
+
if (apiKey) view.apiKey = { set: true, ...(apiKey.length >= 12 ? { last4: apiKey.slice(-4) } : {}), source: "user" }
|
|
250
|
+
return view
|
|
251
|
+
}
|
|
252
|
+
return {
|
|
253
|
+
stt,
|
|
254
|
+
tts,
|
|
255
|
+
voiceHints: VOICE_HINTS,
|
|
256
|
+
ready,
|
|
257
|
+
scope: "user",
|
|
258
|
+
default: { stt: fallback.stt, tts: fallback.tts },
|
|
259
|
+
user: { stt: own(user.stt), tts: own(user.tts) },
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const isObj = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
|
|
264
|
+
|
|
265
|
+
/** https:// anywhere, or plain http:// to a private-network host (self-hosted speech servers). Credentials, query and fragment refused. */
|
|
266
|
+
export function validBaseURL(raw: unknown, field: string): string {
|
|
267
|
+
if (typeof raw !== "string" || raw.length > MAX_URL_CHARS) throw new ValidationError(`${field} must be a URL`)
|
|
268
|
+
let url: URL
|
|
269
|
+
try {
|
|
270
|
+
url = new URL(raw.trim())
|
|
271
|
+
} catch {
|
|
272
|
+
throw new ValidationError(`${field} must be a URL`)
|
|
273
|
+
}
|
|
274
|
+
const local = url.protocol === "http:" && isPrivateHost(url.hostname)
|
|
275
|
+
if (url.protocol !== "https:" && !local) {
|
|
276
|
+
throw new ValidationError(`${field} must start with https:// (http:// only for private-network hosts)`)
|
|
277
|
+
}
|
|
278
|
+
if (url.username || url.password || url.search || url.hash) {
|
|
279
|
+
throw new ValidationError(`${field} must not contain credentials, a query or a fragment`)
|
|
280
|
+
}
|
|
281
|
+
return url.toString().replace(/\/+$/, "")
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
type Section = Record<string, unknown>
|
|
285
|
+
|
|
286
|
+
/** Applies one optional text field: null or "" clears, a string sets (validated), absent keeps. */
|
|
287
|
+
function text(next: Section, input: Section, key: string, field: string, check?: (value: string) => boolean): void {
|
|
288
|
+
if (!(key in input)) return
|
|
289
|
+
const value = input[key]
|
|
290
|
+
if (value === null || value === "") return void delete next[key]
|
|
291
|
+
if (typeof value !== "string") throw new ValidationError(`${field} must be a string`)
|
|
292
|
+
const trimmed = value.trim()
|
|
293
|
+
if (!trimmed || trimmed.length > MAX_NAME_CHARS || /[\x00-\x1f\x7f]/.test(trimmed) || (check && !check(trimmed))) {
|
|
294
|
+
throw new ValidationError(`${field} is not valid`)
|
|
295
|
+
}
|
|
296
|
+
next[key] = trimmed
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function section<T extends StoredStt | StoredTts>(prev: T | undefined, input: unknown, kind: "stt" | "tts", file?: T): T | undefined {
|
|
300
|
+
if (input === undefined) return prev
|
|
301
|
+
if (!isObj(input)) throw new ValidationError(`${kind} must be an object`)
|
|
302
|
+
const next: Section = { ...prev }
|
|
303
|
+
|
|
304
|
+
if ("provider" in input) {
|
|
305
|
+
if (input.provider === null) delete next.provider
|
|
306
|
+
else if (!SPEECH_PROVIDERS.includes(input.provider as SpeechProviderName)) {
|
|
307
|
+
throw new ValidationError(`${kind}.provider must be one of ${SPEECH_PROVIDERS.join(", ")}`)
|
|
308
|
+
} else next.provider = input.provider
|
|
309
|
+
}
|
|
310
|
+
text(next, input, "model", `${kind}.model`)
|
|
311
|
+
if (kind === "stt") text(next, input, "language", "stt.language", (v) => LANGUAGE_PATTERN.test(v))
|
|
312
|
+
if (kind === "tts") {
|
|
313
|
+
text(next, input, "voice", "tts.voice")
|
|
314
|
+
if ("speed" in input) {
|
|
315
|
+
const speed = input.speed
|
|
316
|
+
if (speed === null) delete next.speed
|
|
317
|
+
else if (typeof speed !== "number" || !Number.isFinite(speed) || speed < MIN_SPEED || speed > MAX_SPEED) {
|
|
318
|
+
throw new ValidationError(`tts.speed must be a number from ${MIN_SPEED} to ${MAX_SPEED}`)
|
|
319
|
+
} else next.speed = speed
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
if ("baseURL" in input) {
|
|
323
|
+
if (input.baseURL === null || input.baseURL === "") delete next.baseURL
|
|
324
|
+
else next.baseURL = validBaseURL(input.baseURL, `${kind}.baseURL`)
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if ("apiKey" in input) {
|
|
328
|
+
const key = input.apiKey
|
|
329
|
+
if (key === null || key === "") delete next.apiKey
|
|
330
|
+
else if (typeof key !== "string" || key.trim().length > MAX_KEY_CHARS || !KEY_PATTERN.test(key.trim())) {
|
|
331
|
+
// Never echo the value: it may be a real key with a typo.
|
|
332
|
+
throw new ValidationError(`${kind}.apiKey must be a single token of printable characters`)
|
|
333
|
+
} else next.apiKey = key.trim()
|
|
334
|
+
} else if ((prev?.provider ?? file?.provider ?? "openrouter") !== (next.provider ?? file?.provider ?? "openrouter") || prev?.baseURL !== next.baseURL) {
|
|
335
|
+
// A stored key never follows the settings to a different endpoint.
|
|
336
|
+
delete next.apiKey
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const effective = layer(file, next as T)
|
|
340
|
+
if (effective?.provider === "openai-compatible" && !effective.baseURL) {
|
|
341
|
+
throw new ValidationError(`${kind}.baseURL is required for the openai-compatible provider`)
|
|
342
|
+
}
|
|
343
|
+
return next as T
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** Partial update from untrusted input (validated together with hub.json's layer); throws ValidationError and returns a new object. */
|
|
347
|
+
export function updateSpeech(prev: StoredSpeech, input: unknown, file: StoredSpeech = {}): StoredSpeech {
|
|
348
|
+
if (!isObj(input)) throw new ValidationError("Expected {stt?, tts?}")
|
|
349
|
+
const stt = section(prev.stt, input.stt, "stt", file.stt)
|
|
350
|
+
const tts = section(prev.tts, input.tts, "tts", file.tts)
|
|
351
|
+
return { ...(stt ? { stt } : {}), ...(tts ? { tts } : {}) }
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export interface SpeechReady {
|
|
355
|
+
readonly stt: boolean
|
|
356
|
+
readonly tts: boolean
|
|
357
|
+
/** Why speech is not usable yet (absent when both directions are ready). */
|
|
358
|
+
readonly reason?: string
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Whether a direction can work at all: openrouter needs a key, openai-compatible a base URL. */
|
|
362
|
+
function missing(resolved: Resolved): string | undefined {
|
|
363
|
+
if (resolved.provider === "openrouter") return resolved.apiKey ? undefined : "an OpenRouter API key"
|
|
364
|
+
return resolved.baseURL ? undefined : "a base URL"
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
export function speechReady(stt: Resolved, tts: Resolved): SpeechReady {
|
|
368
|
+
const needs = [
|
|
369
|
+
["Speech-to-text", missing(stt)],
|
|
370
|
+
["Text-to-speech", missing(tts)],
|
|
371
|
+
].filter((entry): entry is [string, string] => entry[1] !== undefined)
|
|
372
|
+
const ready = { stt: !missing(stt), tts: !missing(tts) }
|
|
373
|
+
if (needs.length === 0) return ready
|
|
374
|
+
return { ...ready, reason: `${needs.map(([what, need]) => `${what} needs ${need}`).join("; ")}. Set it in Settings → Speech.` }
|
|
375
|
+
}
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
// Spoke WebSocket endpoint (/spoke/ws): bearer-token authentication, hello/welcome handshake, frame
|
|
2
|
+
// validation, heartbeats, server→spoke request/response correlation, and spoke→server requests.
|
|
3
|
+
import type { Server, ServerWebSocket, WebSocketHandler } from "bun"
|
|
4
|
+
import {
|
|
5
|
+
encode,
|
|
6
|
+
MAX_FRAME,
|
|
7
|
+
parseSpokeMessage,
|
|
8
|
+
PROTOCOL_VERSION,
|
|
9
|
+
type Req,
|
|
10
|
+
type ResultFor,
|
|
11
|
+
type ServerMessage,
|
|
12
|
+
type ServerRequest,
|
|
13
|
+
type SpokeRequest,
|
|
14
|
+
} from "../protocol"
|
|
15
|
+
import type { Auth } from "./auth"
|
|
16
|
+
import type { PushDetail, PushExtra, PushKind } from "./push"
|
|
17
|
+
import type { Registry } from "./registry"
|
|
18
|
+
import { newID, type Store } from "./store"
|
|
19
|
+
|
|
20
|
+
/** Push notifications (implemented by Push in push.ts) to `ownerID`'s devices. Returns the number of devices notified. */
|
|
21
|
+
export interface Notifier {
|
|
22
|
+
notify(kind: PushKind, sessionID: string, title: string, detail?: PushDetail, body?: string, ownerID?: string, extra?: PushExtra): Promise<number>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The spoke answered with an error, or disconnected before answering. */
|
|
26
|
+
export class SpokeError extends Error {
|
|
27
|
+
constructor(message: string) {
|
|
28
|
+
super(message)
|
|
29
|
+
this.name = "SpokeError"
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A request that would not fit in one protocol frame (the spoke would drop it and never answer). */
|
|
34
|
+
export class RequestTooLargeError extends Error {
|
|
35
|
+
constructor(op: string) {
|
|
36
|
+
super(`The ${op} request is too large to send to OpenCode`)
|
|
37
|
+
this.name = "RequestTooLargeError"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class RequestTimeoutError extends Error {
|
|
42
|
+
constructor(op: string) {
|
|
43
|
+
super(`OpenCode did not answer the ${op} request in time`)
|
|
44
|
+
this.name = "RequestTimeoutError"
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface SpokeSocketData {
|
|
49
|
+
readonly connID: string
|
|
50
|
+
readonly machineID: string
|
|
51
|
+
readonly machineName: string
|
|
52
|
+
ready: boolean
|
|
53
|
+
lastSeen: number
|
|
54
|
+
helloTimer?: ReturnType<typeof setTimeout>
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
type Socket = ServerWebSocket<SpokeSocketData>
|
|
58
|
+
|
|
59
|
+
export interface SpokeHubOptions {
|
|
60
|
+
readonly store: Store
|
|
61
|
+
readonly auth: Auth
|
|
62
|
+
readonly registry: Registry
|
|
63
|
+
readonly publicURL: string
|
|
64
|
+
readonly notifier: Notifier
|
|
65
|
+
/** Sends a message to the SSE subscribers of `tenant` (a user ID). */
|
|
66
|
+
readonly broadcast: (message: { type: string; [key: string]: unknown }, tenant: string) => void
|
|
67
|
+
readonly turnTimeoutMs: number
|
|
68
|
+
/** Added to turnTimeoutMs for the hub-side wait (default TURN_GRACE_MS). */
|
|
69
|
+
readonly turnGraceMs?: number
|
|
70
|
+
readonly requestTimeoutMs?: number
|
|
71
|
+
readonly helloTimeoutMs?: number
|
|
72
|
+
readonly heartbeatMs?: number
|
|
73
|
+
readonly staleMs?: number
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
interface Pending {
|
|
77
|
+
readonly connID: string
|
|
78
|
+
readonly op: string
|
|
79
|
+
readonly resolve: (value: unknown) => void
|
|
80
|
+
readonly reject: (error: Error) => void
|
|
81
|
+
readonly timer: ReturnType<typeof setTimeout>
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Extra time the hub waits past the turn timeout so the spoke's own timeout result arrives first. */
|
|
85
|
+
export const TURN_GRACE_MS = 15_000
|
|
86
|
+
/** Creating a session and opening it in a new Herdr tab (the spoke allows `herdr agent start` 20 s). */
|
|
87
|
+
export const CREATE_IN_HERDR_TIMEOUT_MS = 45_000
|
|
88
|
+
|
|
89
|
+
const CLOSE_PROTOCOL = 4000
|
|
90
|
+
const CLOSE_REVOKED = 4001
|
|
91
|
+
const CLOSE_STALE = 4002
|
|
92
|
+
|
|
93
|
+
export class SpokeHub {
|
|
94
|
+
private readonly sockets = new Map<string, Socket>()
|
|
95
|
+
private readonly pending = new Map<string, Pending>()
|
|
96
|
+
private readonly heartbeat: ReturnType<typeof setInterval>
|
|
97
|
+
private seq = 0
|
|
98
|
+
readonly websocket: WebSocketHandler<SpokeSocketData>
|
|
99
|
+
|
|
100
|
+
constructor(private readonly options: SpokeHubOptions) {
|
|
101
|
+
const staleMs = options.staleMs ?? 60_000
|
|
102
|
+
this.heartbeat = setInterval(() => {
|
|
103
|
+
const now = Date.now()
|
|
104
|
+
for (const ws of this.sockets.values()) {
|
|
105
|
+
if (now - ws.data.lastSeen > staleMs) ws.close(CLOSE_STALE, "heartbeat timeout")
|
|
106
|
+
else ws.ping()
|
|
107
|
+
}
|
|
108
|
+
}, options.heartbeatMs ?? 20_000)
|
|
109
|
+
this.heartbeat.unref?.()
|
|
110
|
+
|
|
111
|
+
this.websocket = {
|
|
112
|
+
open: (ws) => this.open(ws),
|
|
113
|
+
message: (ws, raw) => this.message(ws, raw),
|
|
114
|
+
close: (ws) => this.closed(ws),
|
|
115
|
+
pong: (ws) => this.seen(ws),
|
|
116
|
+
idleTimeout: 0,
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Authenticates and upgrades /spoke/ws. Returns a Response on failure, undefined when upgraded. */
|
|
121
|
+
upgrade(request: Request, server: Server<SpokeSocketData>): Response | undefined {
|
|
122
|
+
const match = /^Bearer\s+(\S+)$/i.exec(request.headers.get("authorization") ?? "")
|
|
123
|
+
const machine = match?.[1] ? this.options.store.findMachine(match[1]) : undefined
|
|
124
|
+
const owner = machine && this.options.store.user(machine.ownerID)
|
|
125
|
+
if (!machine || !owner || owner.disabled) return Response.json({ error: "Invalid or revoked machine token" }, { status: 401 })
|
|
126
|
+
const data: SpokeSocketData = {
|
|
127
|
+
connID: newID("conn"),
|
|
128
|
+
machineID: machine.id,
|
|
129
|
+
machineName: machine.name,
|
|
130
|
+
ready: false,
|
|
131
|
+
lastSeen: Date.now(),
|
|
132
|
+
}
|
|
133
|
+
if (server.upgrade(request, { data })) return undefined
|
|
134
|
+
return Response.json({ error: "Expected a WebSocket upgrade" }, { status: 426 })
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Sends a request to a connected spoke and resolves with its result. */
|
|
138
|
+
request<R extends ServerRequest>(connID: string, req: R): Promise<ResultFor[R["op"]]> {
|
|
139
|
+
const ws = this.sockets.get(connID)
|
|
140
|
+
if (!ws?.data.ready) return Promise.reject(new SpokeError("OpenCode process disconnected"))
|
|
141
|
+
const id = `r${++this.seq}`
|
|
142
|
+
const frame = encode({ t: "req", id, req })
|
|
143
|
+
if (frame.length > MAX_FRAME) return Promise.reject(new RequestTooLargeError(req.op))
|
|
144
|
+
const base = this.options.requestTimeoutMs ?? 15_000
|
|
145
|
+
const timeoutMs =
|
|
146
|
+
req.op === "turn"
|
|
147
|
+
? this.options.turnTimeoutMs + (this.options.turnGraceMs ?? TURN_GRACE_MS)
|
|
148
|
+
: req.op === "create" && req.herdrWorkspace
|
|
149
|
+
? Math.max(base, CREATE_IN_HERDR_TIMEOUT_MS) // the spoke waits for OpenCode to start in the new tab
|
|
150
|
+
: base
|
|
151
|
+
return new Promise((resolve, reject) => {
|
|
152
|
+
const timer = setTimeout(() => {
|
|
153
|
+
this.pending.delete(id)
|
|
154
|
+
reject(new RequestTimeoutError(req.op))
|
|
155
|
+
}, timeoutMs)
|
|
156
|
+
this.pending.set(id, { connID, op: req.op, resolve: resolve as (v: unknown) => void, reject, timer })
|
|
157
|
+
ws.send(frame)
|
|
158
|
+
})
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Disconnects every socket of a machine (after revocation). */
|
|
162
|
+
closeMachine(machineID: string, reason: string): void {
|
|
163
|
+
for (const ws of this.sockets.values()) if (ws.data.machineID === machineID) this.bye(ws, reason, CLOSE_REVOKED)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
stop(): void {
|
|
167
|
+
clearInterval(this.heartbeat)
|
|
168
|
+
for (const ws of this.sockets.values()) this.bye(ws, "server shutting down", 1001)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private open(ws: Socket): void {
|
|
172
|
+
this.sockets.set(ws.data.connID, ws)
|
|
173
|
+
ws.data.helloTimer = setTimeout(() => {
|
|
174
|
+
if (!ws.data.ready) this.bye(ws, "hello timeout")
|
|
175
|
+
}, this.options.helloTimeoutMs ?? 10_000)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private seen(ws: Socket): void {
|
|
179
|
+
ws.data.lastSeen = Date.now()
|
|
180
|
+
if (ws.data.ready) this.options.registry.touch(ws.data.connID)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private message(ws: Socket, raw: string | Buffer): void {
|
|
184
|
+
this.seen(ws)
|
|
185
|
+
const message = parseSpokeMessage(raw)
|
|
186
|
+
if (!message) return this.bye(ws, "protocol error")
|
|
187
|
+
const { registry } = this.options
|
|
188
|
+
const { connID } = ws.data
|
|
189
|
+
|
|
190
|
+
if (!ws.data.ready) {
|
|
191
|
+
if (message.t !== "hello") return this.bye(ws, "protocol error")
|
|
192
|
+
if (message.v !== PROTOCOL_VERSION) {
|
|
193
|
+
return this.bye(ws, `unsupported protocol version ${message.v}; server speaks version ${PROTOCOL_VERSION}`)
|
|
194
|
+
}
|
|
195
|
+
clearTimeout(ws.data.helloTimer)
|
|
196
|
+
ws.data.ready = true
|
|
197
|
+
registry.connect(connID, { id: ws.data.machineID, name: ws.data.machineName }, message)
|
|
198
|
+
this.options.store.touchMachine(ws.data.machineID)
|
|
199
|
+
this.send(ws, { t: "welcome", v: PROTOCOL_VERSION, machineID: ws.data.machineID, publicURL: this.options.publicURL })
|
|
200
|
+
return
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
switch (message.t) {
|
|
204
|
+
case "hello":
|
|
205
|
+
return this.bye(ws, "protocol error")
|
|
206
|
+
case "sessions":
|
|
207
|
+
registry.setSessions(connID, message.sessions)
|
|
208
|
+
return this.resendConversations(connID)
|
|
209
|
+
case "terminals":
|
|
210
|
+
return registry.setTerminals(connID, message)
|
|
211
|
+
case "event":
|
|
212
|
+
return registry.observe(connID, message.event)
|
|
213
|
+
case "req":
|
|
214
|
+
void this.answer(ws, message)
|
|
215
|
+
return
|
|
216
|
+
case "res": {
|
|
217
|
+
const pending = this.pending.get(message.id)
|
|
218
|
+
if (!pending || pending.connID !== connID) return
|
|
219
|
+
this.pending.delete(message.id)
|
|
220
|
+
clearTimeout(pending.timer)
|
|
221
|
+
if (message.ok) pending.resolve(message.result)
|
|
222
|
+
else pending.reject(new SpokeError(message.error || `OpenCode ${pending.op} request failed`))
|
|
223
|
+
return
|
|
224
|
+
}
|
|
225
|
+
case "bye":
|
|
226
|
+
ws.close(1000, "bye")
|
|
227
|
+
return
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
private async answer(ws: Socket, frame: Req<SpokeRequest>): Promise<void> {
|
|
232
|
+
try {
|
|
233
|
+
const result = await this.handle(ws, frame.req)
|
|
234
|
+
this.send(ws, { t: "res", id: frame.id, ok: true, result })
|
|
235
|
+
} catch (cause) {
|
|
236
|
+
this.send(ws, { t: "res", id: frame.id, ok: false, error: cause instanceof Error ? cause.message : String(cause) })
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** The machine's current owner (read live: ownership can move, e.g. on migration). */
|
|
241
|
+
ownerOf(machineID: string): string | undefined {
|
|
242
|
+
return this.options.store.machines().find((m) => m.id === machineID)?.ownerID
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
private async handle(ws: Socket, req: SpokeRequest): Promise<ResultFor[SpokeRequest["op"]]> {
|
|
246
|
+
const { auth, publicURL } = this.options
|
|
247
|
+
const owner = this.ownerOf(ws.data.machineID)
|
|
248
|
+
if (!owner) throw new Error("This machine is no longer enrolled")
|
|
249
|
+
switch (req.op) {
|
|
250
|
+
case "pairCode": {
|
|
251
|
+
const { code, expiresAt } = auth.issuePairCode(owner)
|
|
252
|
+
return { code, url: `${publicURL}/pair`, expiresAt }
|
|
253
|
+
}
|
|
254
|
+
case "handoff": {
|
|
255
|
+
// Only the machine owner's own (or not yet reported) sessions: never another user's title or ID.
|
|
256
|
+
const tenant = this.options.registry.sessionTenant(req.sessionID)
|
|
257
|
+
if (tenant !== undefined && tenant !== owner) throw new Error("Unknown session")
|
|
258
|
+
this.options.broadcast({ type: "handoff", sessionID: req.sessionID }, owner)
|
|
259
|
+
const title = (tenant && this.options.registry.status(req.sessionID)?.[0]?.title) || req.sessionID
|
|
260
|
+
return { notified: await this.options.notifier.notify("handoff", req.sessionID, title, undefined, undefined, owner) }
|
|
261
|
+
}
|
|
262
|
+
case "openLink": {
|
|
263
|
+
const { code } = auth.issueLoginCode(owner)
|
|
264
|
+
const next = req.sessionID ? `/s/${encodeURIComponent(req.sessionID)}${req.voice ? "?voice=1" : ""}` : "/"
|
|
265
|
+
return { url: `${publicURL}/login?code=${code}&next=${next}` }
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Tells a (re)connected owner about sessions the phone has in conversation mode. */
|
|
271
|
+
private resendConversations(connID: string): void {
|
|
272
|
+
for (const sessionID of this.options.registry.conversationResends(connID)) {
|
|
273
|
+
this.request(connID, { op: "conversation", sessionID, on: true }).catch((cause) => {
|
|
274
|
+
console.error("[hub] re-sending conversation mode failed:", cause instanceof Error ? cause.message : cause)
|
|
275
|
+
})
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
private closed(ws: Socket): void {
|
|
280
|
+
clearTimeout(ws.data.helloTimer)
|
|
281
|
+
const { connID } = ws.data
|
|
282
|
+
this.sockets.delete(connID)
|
|
283
|
+
for (const [id, pending] of this.pending) {
|
|
284
|
+
if (pending.connID !== connID) continue
|
|
285
|
+
this.pending.delete(id)
|
|
286
|
+
clearTimeout(pending.timer)
|
|
287
|
+
pending.reject(new SpokeError("OpenCode process disconnected"))
|
|
288
|
+
}
|
|
289
|
+
if (ws.data.ready) this.options.registry.disconnect(connID)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
private bye(ws: Socket, reason: string, code = CLOSE_PROTOCOL): void {
|
|
293
|
+
this.send(ws, { t: "bye", reason })
|
|
294
|
+
ws.close(code, reason)
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
private send(ws: Socket, message: ServerMessage): void {
|
|
298
|
+
ws.send(encode(message))
|
|
299
|
+
}
|
|
300
|
+
}
|