@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
package/src/config.ts
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// Per-machine spoke configuration, shared by every OpenCode process on the machine and edited
|
|
2
|
+
// from the /hub dialog. Holds the machine token, so it is written with mode 0600.
|
|
3
|
+
import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"
|
|
4
|
+
import { homedir } from "node:os"
|
|
5
|
+
import path from "node:path"
|
|
6
|
+
import { hubEnv, type Env } from "./env"
|
|
7
|
+
import { APP_DIR, configBase, legacyPaths, migrateFirst } from "./migrate"
|
|
8
|
+
|
|
9
|
+
export type Mode = "off" | "always" | "session"
|
|
10
|
+
|
|
11
|
+
export interface VoiceConfig {
|
|
12
|
+
readonly server: string
|
|
13
|
+
readonly mode: Mode
|
|
14
|
+
/** Session IDs exposed when mode is "session". */
|
|
15
|
+
readonly sessions: readonly string[]
|
|
16
|
+
readonly machineName: string
|
|
17
|
+
/** Agent the voice orchestrator delegates work to in conversation mode. */
|
|
18
|
+
readonly subagent: string
|
|
19
|
+
/** Start a local hub in the background when the server is loopback and nothing answers (default true). */
|
|
20
|
+
readonly autoStartLocalHub: boolean
|
|
21
|
+
/** Read open sessions and workspaces from the Herdr CLI (default true; the service process only). */
|
|
22
|
+
readonly herdr: boolean
|
|
23
|
+
/** History window of the session listing: root sessions from the last `days` days, at most `max`. */
|
|
24
|
+
readonly list: ListWindow
|
|
25
|
+
/** Fast model the voice agent talks with in conversation mode ("provider/model[#variant]"). */
|
|
26
|
+
readonly voiceModel?: string
|
|
27
|
+
/** Model subagents work on; defaults to the session's model before conversation mode. */
|
|
28
|
+
readonly workModel?: string
|
|
29
|
+
readonly machineID?: string
|
|
30
|
+
readonly token?: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ListWindow {
|
|
34
|
+
readonly days: number
|
|
35
|
+
readonly max: number
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const DEFAULT_LIST: ListWindow = Object.freeze({ days: 30, max: 100 })
|
|
39
|
+
const LIST_LIMITS = { days: 365, max: 500 } as const
|
|
40
|
+
|
|
41
|
+
export const DEFAULT_SERVER = "http://127.0.0.1:4097"
|
|
42
|
+
export const DEFAULT_SUBAGENT = "general"
|
|
43
|
+
const AGENT_ID = /^[A-Za-z0-9_.-]{1,64}$/
|
|
44
|
+
const MODEL_REF = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.:\/-]+(?:#[A-Za-z0-9_.-]+)?$/
|
|
45
|
+
|
|
46
|
+
export interface ModelRef {
|
|
47
|
+
readonly providerID: string
|
|
48
|
+
readonly id: string
|
|
49
|
+
readonly variant?: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** "provider/model[#variant]"; the model ID may itself contain slashes (e.g. openrouter/anthropic/x). */
|
|
53
|
+
export function parseModelRef(value: string): ModelRef | undefined {
|
|
54
|
+
if (!MODEL_REF.test(value)) return undefined
|
|
55
|
+
const [ref, variant] = value.split("#") as [string, string | undefined]
|
|
56
|
+
const slash = ref.indexOf("/")
|
|
57
|
+
return { providerID: ref.slice(0, slash), id: ref.slice(slash + 1), ...(variant ? { variant } : {}) }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** OpenCode reports a model selected without a variant as variant "default". */
|
|
61
|
+
export function explicitVariant(ref: ModelRef): string | undefined {
|
|
62
|
+
return ref.variant && ref.variant !== "default" ? ref.variant : undefined
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Drops the implicit "default" variant so the ref can be passed back to OpenCode tools. */
|
|
66
|
+
export function plainModelRef(ref: ModelRef): ModelRef {
|
|
67
|
+
const variant = explicitVariant(ref)
|
|
68
|
+
return { providerID: ref.providerID, id: ref.id, ...(variant ? { variant } : {}) }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function formatModelRef(ref: ModelRef): string {
|
|
72
|
+
const variant = explicitVariant(ref)
|
|
73
|
+
return `${ref.providerID}/${ref.id}${variant ? `#${variant}` : ""}`
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const modelRef = (v: unknown) => (typeof v === "string" && v.length <= 200 && MODEL_REF.test(v) ? v : undefined)
|
|
77
|
+
|
|
78
|
+
/** $HUB_SPOKE_CONFIG (legacy $OPENCODE_HUB_CONFIG, $OPENCODE_VOICE_CONFIG), else ~/.config/hub/config.json. */
|
|
79
|
+
export function configPath(env: Env = process.env, home: string = homedir()): string {
|
|
80
|
+
const explicit = hubEnv(env).HUB_SPOKE_CONFIG
|
|
81
|
+
if (explicit) return explicit
|
|
82
|
+
return path.join(configBase(env, home), APP_DIR, "config.json")
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* configPath, after copying the legacy ~/.config/opencode-hub/config.json (else the opencode-voice one) to
|
|
87
|
+
* the default location on first start (never for an explicit path; the old files are kept).
|
|
88
|
+
*/
|
|
89
|
+
export function prepareConfigPath(env: Env = process.env, home: string = homedir(), log?: (message: string) => void): string {
|
|
90
|
+
const file = configPath(env, home)
|
|
91
|
+
if (!hubEnv(env).HUB_SPOKE_CONFIG) {
|
|
92
|
+
migrateFirst(legacyPaths(configBase(env, home), "config.json"), file, log ? { log } : {})
|
|
93
|
+
}
|
|
94
|
+
return file
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function defaults(machineName: string): VoiceConfig {
|
|
98
|
+
return { server: DEFAULT_SERVER, mode: "off", sessions: [], machineName, subagent: DEFAULT_SUBAGENT, autoStartLocalHub: true, herdr: true, list: { ...DEFAULT_LIST } }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Normalizes untrusted JSON into a config, falling back to defaults field by field. */
|
|
102
|
+
export function normalize(raw: unknown, machineName: string): VoiceConfig {
|
|
103
|
+
const base = defaults(machineName)
|
|
104
|
+
if (typeof raw !== "object" || raw === null) return base
|
|
105
|
+
const r = raw as Record<string, unknown>
|
|
106
|
+
const server = typeof r.server === "string" && /^https?:\/\/[^\s]+$/.test(r.server) ? r.server.replace(/\/+$/, "") : base.server
|
|
107
|
+
const mode: Mode = r.mode === "always" || r.mode === "session" || r.mode === "off" ? r.mode : base.mode
|
|
108
|
+
const sessions = Array.isArray(r.sessions) ? [...new Set(r.sessions.filter((s): s is string => typeof s === "string" && s.startsWith("ses")))] : []
|
|
109
|
+
return {
|
|
110
|
+
server,
|
|
111
|
+
mode,
|
|
112
|
+
sessions,
|
|
113
|
+
machineName: typeof r.machineName === "string" && r.machineName ? r.machineName : machineName,
|
|
114
|
+
subagent: typeof r.subagent === "string" && AGENT_ID.test(r.subagent) ? r.subagent : base.subagent,
|
|
115
|
+
autoStartLocalHub: r.autoStartLocalHub !== false,
|
|
116
|
+
herdr: r.herdr !== false,
|
|
117
|
+
list: listWindow(r.list),
|
|
118
|
+
...(modelRef(r.voiceModel) ? { voiceModel: modelRef(r.voiceModel) } : {}),
|
|
119
|
+
...(modelRef(r.workModel) ? { workModel: modelRef(r.workModel) } : {}),
|
|
120
|
+
...(typeof r.machineID === "string" && r.machineID ? { machineID: r.machineID } : {}),
|
|
121
|
+
...(typeof r.token === "string" && r.token ? { token: r.token } : {}),
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const inRange = (v: unknown, max: number): v is number => Number.isInteger(v) && (v as number) >= 1 && (v as number) <= max
|
|
126
|
+
|
|
127
|
+
/** Each field of the history window falls back to its default when missing or out of range. */
|
|
128
|
+
function listWindow(raw: unknown): ListWindow {
|
|
129
|
+
const r = typeof raw === "object" && raw !== null && !Array.isArray(raw) ? (raw as Record<string, unknown>) : {}
|
|
130
|
+
return {
|
|
131
|
+
days: inRange(r.days, LIST_LIMITS.days) ? r.days : DEFAULT_LIST.days,
|
|
132
|
+
max: inRange(r.max, LIST_LIMITS.max) ? r.max : DEFAULT_LIST.max,
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function readConfig(file: string, machineName: string): VoiceConfig {
|
|
137
|
+
try {
|
|
138
|
+
return normalize(JSON.parse(readFileSync(file, "utf8")), machineName)
|
|
139
|
+
} catch {
|
|
140
|
+
return defaults(machineName)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Atomic write with 0600 permissions. */
|
|
145
|
+
export function writeConfig(file: string, config: VoiceConfig): void {
|
|
146
|
+
mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 })
|
|
147
|
+
const tmp = `${file}.${process.pid}.tmp`
|
|
148
|
+
writeFileSync(tmp, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 })
|
|
149
|
+
chmodSync(tmp, 0o600)
|
|
150
|
+
renameSync(tmp, file)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Changing the server URL drops the machine credentials, which belong to the old server. */
|
|
154
|
+
export function withServer(config: VoiceConfig, server: string): VoiceConfig {
|
|
155
|
+
const next = normalize({ ...config, server }, config.machineName)
|
|
156
|
+
if (next.server === config.server) return next
|
|
157
|
+
const { machineID: _id, token: _token, ...rest } = next
|
|
158
|
+
return rest
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function isExposed(config: VoiceConfig, sessionID: string): boolean {
|
|
162
|
+
if (config.mode === "always") return true
|
|
163
|
+
if (config.mode === "session") return config.sessions.includes(sessionID)
|
|
164
|
+
return false
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function setSession(config: VoiceConfig, sessionID: string, on: boolean): VoiceConfig {
|
|
168
|
+
const sessions = on ? [...new Set([...config.sessions, sessionID])] : config.sessions.filter((id) => id !== sessionID)
|
|
169
|
+
return { ...config, sessions }
|
|
170
|
+
}
|
package/src/env.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Environment variables of Hub. The project was called opencode-voice, then opencode-hub: their VOICE_*,
|
|
2
|
+
// OPENCODE_VOICE_* and OPENCODE_HUB_* names are still read as fallbacks (the new HUB_* name wins, then the
|
|
3
|
+
// most recent legacy name), with a one-time deprecation warning per legacy name. Code reads only the new names from hubEnv(env).
|
|
4
|
+
|
|
5
|
+
export type Env = Readonly<Record<string, string | undefined>>
|
|
6
|
+
|
|
7
|
+
/** New name → legacy names, most recent first. */
|
|
8
|
+
export const LEGACY_ENV: Readonly<Record<string, readonly string[]>> = Object.freeze({
|
|
9
|
+
HUB_HOST: ["VOICE_HOST"],
|
|
10
|
+
HUB_PORT: ["VOICE_PORT"],
|
|
11
|
+
HUB_PUBLIC_URL: ["VOICE_PUBLIC_URL"],
|
|
12
|
+
HUB_STATE_DIR: ["VOICE_STATE_DIR"],
|
|
13
|
+
HUB_CONFIG: ["VOICE_HUB_CONFIG"],
|
|
14
|
+
HUB_AUTH: ["VOICE_AUTH"],
|
|
15
|
+
HUB_OIDC_ISSUER: ["VOICE_OIDC_ISSUER"],
|
|
16
|
+
HUB_OIDC_CLIENT_ID: ["VOICE_OIDC_CLIENT_ID"],
|
|
17
|
+
HUB_OIDC_CLIENT_SECRET: ["VOICE_OIDC_CLIENT_SECRET"],
|
|
18
|
+
HUB_OIDC_ALLOWED: ["VOICE_OIDC_ALLOWED"],
|
|
19
|
+
HUB_OIDC_INSECURE_ISSUER: ["VOICE_OIDC_INSECURE_ISSUER"],
|
|
20
|
+
HUB_ADMIN_EMAILS: ["VOICE_ADMIN_EMAILS"],
|
|
21
|
+
HUB_STT_MODEL: ["VOICE_STT_MODEL"],
|
|
22
|
+
HUB_TTS_MODEL: ["VOICE_TTS_MODEL"],
|
|
23
|
+
HUB_VOICE: ["VOICE_VOICE"],
|
|
24
|
+
HUB_MAX_AUDIO_BYTES: ["VOICE_MAX_AUDIO_BYTES"],
|
|
25
|
+
HUB_MAX_SPEECH_CHARS: ["VOICE_MAX_SPEECH_CHARS"],
|
|
26
|
+
HUB_TURN_TIMEOUT_MS: ["VOICE_TURN_TIMEOUT_MS"],
|
|
27
|
+
HUB_VAPID_SUBJECT: ["VOICE_VAPID_SUBJECT"],
|
|
28
|
+
/** The spoke (plugin) config file: OPENCODE_HUB_CONFIG in 0.5, OPENCODE_VOICE_CONFIG before. */
|
|
29
|
+
HUB_SPOKE_CONFIG: ["OPENCODE_HUB_CONFIG", "OPENCODE_VOICE_CONFIG"],
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
export interface HubEnvOptions {
|
|
33
|
+
/** Where deprecation warnings go (default console.warn with the [hub] prefix). */
|
|
34
|
+
readonly warn?: (message: string) => void
|
|
35
|
+
/** Legacy names already warned about (default: once per process). */
|
|
36
|
+
readonly seen?: Set<string>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const processSeen = new Set<string>()
|
|
40
|
+
const defaultWarn = (message: string) => console.warn(`[hub] warning: ${message}`)
|
|
41
|
+
|
|
42
|
+
/** A copy of `env` with every unset HUB_* name filled from its legacy name. Never echoes values. */
|
|
43
|
+
export function hubEnv(env: Env = process.env, options: HubEnvOptions = {}): Env {
|
|
44
|
+
const warn = options.warn ?? defaultWarn
|
|
45
|
+
const seen = options.seen ?? processSeen
|
|
46
|
+
const out: Record<string, string | undefined> = { ...env }
|
|
47
|
+
for (const [name, legacies] of Object.entries(LEGACY_ENV)) {
|
|
48
|
+
// The name that supplies the value: the new one when set, else the first legacy one set.
|
|
49
|
+
let winner = env[name] !== undefined ? name : undefined
|
|
50
|
+
for (const legacy of legacies) {
|
|
51
|
+
if (env[legacy] === undefined) continue
|
|
52
|
+
const ignored = winner !== undefined
|
|
53
|
+
if (!ignored) {
|
|
54
|
+
winner = legacy
|
|
55
|
+
out[name] = env[legacy]
|
|
56
|
+
}
|
|
57
|
+
if (seen.has(legacy)) continue
|
|
58
|
+
seen.add(legacy)
|
|
59
|
+
warn(ignored ? `${legacy} is ignored because ${winner} is set; remove ${legacy}` : `${legacy} is deprecated; rename it to ${name}`)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return out
|
|
63
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// Hub server plugin: a thin "spoke" that connects this OpenCode process to the hub. The web
|
|
2
|
+
// console, phones and (optional) Voice speech live on the hub; this side only exposes sessions, forwards
|
|
3
|
+
// their events, and runs remote / voice turns and permission replies on request.
|
|
4
|
+
import { readFileSync } from "node:fs"
|
|
5
|
+
import { hostname } from "node:os"
|
|
6
|
+
import { Plugin } from "@opencode/plugin"
|
|
7
|
+
import { DEFAULT_SUBAGENT, prepareConfigPath } from "./config"
|
|
8
|
+
import { HubRpc, VoiceRpc } from "./rpc"
|
|
9
|
+
import { checkServer, parseServerInput } from "./setup"
|
|
10
|
+
import { defineVoiceAgent, orchestratorParts } from "./spoke/conversation"
|
|
11
|
+
import { herdrRunner } from "./spoke/herdr"
|
|
12
|
+
import { listServiceSessions } from "./spoke/listing"
|
|
13
|
+
import { ensureLocalHub } from "./spoke/localhub"
|
|
14
|
+
import { replyPermission, type PermissionLocation } from "./spoke/locations"
|
|
15
|
+
import { foregroundEndpoint, formOutcome } from "./spoke/own-server"
|
|
16
|
+
import { joinProcess, type Membership } from "./spoke/singleton"
|
|
17
|
+
import { detectKind, Spoke, SpokeError, type FormAction, type SpokeHost } from "./spoke/spoke"
|
|
18
|
+
|
|
19
|
+
type Context = Parameters<Parameters<typeof Plugin.define>[0]["setup"]>[0]
|
|
20
|
+
|
|
21
|
+
const VOICE_STYLE =
|
|
22
|
+
"This message came from a voice conversation and your reply will be read aloud. " +
|
|
23
|
+
"Answer in at most three short sentences of plain speech: no markdown, code blocks, tables, or lists. " +
|
|
24
|
+
"Keep using tools as needed; only the final reply must be short."
|
|
25
|
+
|
|
26
|
+
function pluginVersion(): string {
|
|
27
|
+
try {
|
|
28
|
+
return String(JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version ?? "0.0.0")
|
|
29
|
+
} catch {
|
|
30
|
+
return "0.0.0"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The most recent root sessions of the local OpenCode service with their workspace (used by the
|
|
36
|
+
* service's own spoke for the phone's session browser): the history window plus `include`.
|
|
37
|
+
*/
|
|
38
|
+
async function listRecent(options?: { days: number; max: number; include: readonly string[] }) {
|
|
39
|
+
const { Service } = await import("@opencode/client/service")
|
|
40
|
+
const { OpenCode } = await import("@opencode/client")
|
|
41
|
+
const endpoint = await Service.discover()
|
|
42
|
+
if (!endpoint) return []
|
|
43
|
+
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) ?? {} })
|
|
44
|
+
return listServiceSessions(client, options ?? {})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A client for this process's own OpenCode server, when it can be reached (see src/spoke/own-server.ts). */
|
|
48
|
+
async function ownClient(kind: "service" | "standalone") {
|
|
49
|
+
const { OpenCode } = await import("@opencode/client")
|
|
50
|
+
if (kind === "service") {
|
|
51
|
+
const { Service } = await import("@opencode/client/service")
|
|
52
|
+
const endpoint = await Service.discover()
|
|
53
|
+
return endpoint ? OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) ?? {} }) : undefined
|
|
54
|
+
}
|
|
55
|
+
const endpoint = foregroundEndpoint(process.argv, process.env)
|
|
56
|
+
return endpoint ? OpenCode.make({ baseUrl: endpoint.url, headers: { authorization: endpoint.authorization } }) : undefined
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Answers or cancels a form through the own server's API (the plugin API has no forms). */
|
|
60
|
+
async function replyForm(kind: "service" | "standalone", action: FormAction): Promise<"replied" | "gone" | "unavailable"> {
|
|
61
|
+
const client = await ownClient(kind).catch(() => undefined)
|
|
62
|
+
if (!client) return "unavailable"
|
|
63
|
+
try {
|
|
64
|
+
if ("cancel" in action) await client.session.form.cancel({ sessionID: action.sessionID, formID: action.formID })
|
|
65
|
+
else await client.session.form.reply({ sessionID: action.sessionID, formID: action.formID, answer: action.answer as Record<string, string | number | boolean | string[]> })
|
|
66
|
+
return "replied"
|
|
67
|
+
} catch (cause) {
|
|
68
|
+
if (formOutcome(cause) === "gone") return "gone"
|
|
69
|
+
throw cause
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Plugin API results may arrive bare or wrapped as { location, data }. */
|
|
74
|
+
function unwrap<T>(value: unknown, isBare: (v: any) => boolean): T {
|
|
75
|
+
const v = value as any
|
|
76
|
+
return (v && !isBare(v) && v.data !== undefined ? v.data : v) as T
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** This instance's location-scoped permission API (OpenCode keeps pending permissions per location). */
|
|
80
|
+
function permissionLocation(ctx: Context): PermissionLocation {
|
|
81
|
+
const permission = ctx.permission as any
|
|
82
|
+
return {
|
|
83
|
+
directory: ctx.location.directory,
|
|
84
|
+
get: (input) => permission.get(input),
|
|
85
|
+
reply: (input) => permission.reply(input),
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** `locations`: the permission APIs of every plugin instance in this process (one per location). */
|
|
90
|
+
function hostFrom(ctx: Context, locations: () => PermissionLocation[], kind: "service" | "standalone"): SpokeHost {
|
|
91
|
+
const session = ctx.session as any
|
|
92
|
+
const agent = ctx.agent as any
|
|
93
|
+
const mcp = (ctx as any).mcp
|
|
94
|
+
const vcs = (ctx as any).vcs
|
|
95
|
+
return {
|
|
96
|
+
directory: ctx.location.directory,
|
|
97
|
+
opencodeVersion: ctx.app?.version ?? "unknown",
|
|
98
|
+
events: (signal) => ctx.event.subscribe({ signal }) as AsyncIterable<any>,
|
|
99
|
+
prompt: (input) => session.prompt(input),
|
|
100
|
+
context: (sessionID) => session.context({ sessionID }),
|
|
101
|
+
listModels: async () => unwrap<ReadonlyArray<unknown>>(await (ctx.model as any).list({}), Array.isArray),
|
|
102
|
+
getSession: async (sessionID) => unwrap(await session.get({ sessionID }), (v) => typeof v.id === "string"),
|
|
103
|
+
replyPermission: ({ directory, ...input }) => replyPermission(locations(), input, directory),
|
|
104
|
+
replyForm: (action) => replyForm(kind, action),
|
|
105
|
+
interrupt: (sessionID) => session.interrupt({ sessionID }),
|
|
106
|
+
switchAgent: (sessionID, id) => session.switchAgent({ sessionID, agent: id }),
|
|
107
|
+
switchModel: (sessionID, model) => session.switchModel({ sessionID, model }),
|
|
108
|
+
hasAgent: async (id) => {
|
|
109
|
+
try {
|
|
110
|
+
const info = unwrap<{ id?: string } | undefined>(await agent.get({ agentID: id }), (v) => typeof v.id === "string")
|
|
111
|
+
return info?.id === id
|
|
112
|
+
} catch {
|
|
113
|
+
return false
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
defaultAgent: async () => {
|
|
117
|
+
const list = unwrap<Array<{ id: string; mode?: string; hidden?: boolean }>>(await agent.list({}), Array.isArray)
|
|
118
|
+
return list?.find((a) => a.mode !== "subagent" && !a.hidden)?.id
|
|
119
|
+
},
|
|
120
|
+
listRecent,
|
|
121
|
+
createSession: async ({ directory, title }) =>
|
|
122
|
+
unwrap<{ id: string }>(await session.create({ location: { directory }, ...(title ? { title } : {}) }), (v) => typeof v.id === "string"),
|
|
123
|
+
...(mcp?.list ? { mcpList: async (directory: string) => unwrap<ReadonlyArray<unknown>>(await mcp.list({ location: { directory } }), Array.isArray) } : {}),
|
|
124
|
+
...(vcs?.diff ? { vcsDiff: async (directory: string) => unwrap<ReadonlyArray<unknown>>(await vcs.diff({ mode: "working", location: { directory } }), Array.isArray) } : {}),
|
|
125
|
+
...(vcs?.get
|
|
126
|
+
? {
|
|
127
|
+
vcsBranch: async (directory: string) => {
|
|
128
|
+
const info = unwrap<{ branch?: { current?: string } }>(await vcs.get({ location: { directory } }), (v) => "branch" in v)
|
|
129
|
+
return typeof info?.branch?.current === "string" ? info.branch.current : undefined
|
|
130
|
+
},
|
|
131
|
+
}
|
|
132
|
+
: {}),
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export default Plugin.define({
|
|
137
|
+
id: "hub",
|
|
138
|
+
async setup(ctx) {
|
|
139
|
+
const kind = detectKind(process.argv)
|
|
140
|
+
const membership: Membership<Spoke> = joinProcess(
|
|
141
|
+
(processID) =>
|
|
142
|
+
new Spoke(hostFrom(ctx, () => membership.locals<PermissionLocation>(), kind), {
|
|
143
|
+
configFile: prepareConfigPath(),
|
|
144
|
+
machineName: hostname(),
|
|
145
|
+
kind,
|
|
146
|
+
processID,
|
|
147
|
+
pid: process.pid,
|
|
148
|
+
pluginVersion: pluginVersion(),
|
|
149
|
+
// Loopback servers only, and only when autoStartLocalHub is on (see src/spoke/localhub.ts).
|
|
150
|
+
localHub: (server) => ensureLocalHub(server),
|
|
151
|
+
// One Herdr reader per machine: the background service's spoke (see src/spoke/herdr.ts).
|
|
152
|
+
...(kind === "service" ? { herdr: herdrRunner() } : {}),
|
|
153
|
+
}),
|
|
154
|
+
undefined,
|
|
155
|
+
permissionLocation(ctx),
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
try {
|
|
159
|
+
await ctx.agent.transform((editor) => defineVoiceAgent(editor as any))
|
|
160
|
+
} catch (cause) {
|
|
161
|
+
console.error("[hub] could not register the voice agent", cause)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
await ctx.session.hook("context", (event) => {
|
|
166
|
+
const spoke = membership.active()
|
|
167
|
+
if (spoke?.isVoiceTurn(event.sessionID)) event.system.push({ type: "text", text: VOICE_STYLE })
|
|
168
|
+
const parts = orchestratorParts({
|
|
169
|
+
agent: event.agent,
|
|
170
|
+
conversation: spoke?.inConversation(event.sessionID) ?? false,
|
|
171
|
+
subagent: spoke?.subagent() ?? DEFAULT_SUBAGENT,
|
|
172
|
+
workModel: spoke?.workModelFor(event.sessionID),
|
|
173
|
+
})
|
|
174
|
+
for (const text of parts) event.system.push({ type: "text", text })
|
|
175
|
+
})
|
|
176
|
+
} catch (cause) {
|
|
177
|
+
console.error("[hub] could not register the context hook", cause)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const run = async <T>(rpc: { error: (...args: any[]) => any }, action: (spoke: Spoke) => T | Promise<T>) => {
|
|
181
|
+
const spoke = membership.active()
|
|
182
|
+
if (!spoke) return rpc.error("unavailable", "The hub spoke is not running in this OpenCode process", {})
|
|
183
|
+
try {
|
|
184
|
+
return await action(spoke)
|
|
185
|
+
} catch (cause) {
|
|
186
|
+
if (cause instanceof SpokeError) return rpc.error(cause.name, cause.message, {})
|
|
187
|
+
return rpc.error("server_error", cause instanceof Error ? cause.message : String(cause), {})
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
const input = (value: unknown) => (value ?? {}) as Record<string, any>
|
|
191
|
+
|
|
192
|
+
const handlers = {
|
|
193
|
+
status: (value, rpc) => run(rpc, (s) => s.status(input(value).sessionID)),
|
|
194
|
+
setServer: (value, rpc) => run(rpc, (s) => (s.setServer(String(input(value).server ?? "")), {})),
|
|
195
|
+
checkServer: async (value, rpc) => {
|
|
196
|
+
const parsed = parseServerInput(String(input(value).server ?? ""))
|
|
197
|
+
if (!parsed.ok) return rpc.error("invalid", parsed.error, {})
|
|
198
|
+
return checkServer(parsed.url)
|
|
199
|
+
},
|
|
200
|
+
startLocalHub: (_value, rpc) => run(rpc, (s) => s.startLocalHub()),
|
|
201
|
+
enroll: (_value, rpc) => run(rpc, (s) => s.enroll()),
|
|
202
|
+
setMode: (value, rpc) => run(rpc, (s) => (s.setMode(input(value).mode), {})),
|
|
203
|
+
setSession: (value, rpc) =>
|
|
204
|
+
run(rpc, (s) => (s.setSession(String(input(value).sessionID ?? ""), Boolean(input(value).on)), {})),
|
|
205
|
+
pairCode: (_value, rpc) => run(rpc, (s) => s.pairCode()),
|
|
206
|
+
handoff: (value, rpc) => run(rpc, (s) => s.handoff(String(input(value).sessionID ?? ""))),
|
|
207
|
+
openLink: (value, rpc) =>
|
|
208
|
+
run(rpc, (s) => s.openLink(input(value).sessionID ? String(input(value).sessionID) : undefined, input(value).voice === true)),
|
|
209
|
+
conversation: (value, rpc) =>
|
|
210
|
+
run(rpc, (s) => s.setConversation(String(input(value).sessionID ?? ""), Boolean(input(value).on))),
|
|
211
|
+
formActions: (value, rpc) =>
|
|
212
|
+
run(rpc, (s) => {
|
|
213
|
+
const { waitMs = 0 } = input(value)
|
|
214
|
+
if (typeof waitMs !== "number" || !Number.isFinite(waitMs)) throw new SpokeError("invalid", "Expected {waitMs: number}")
|
|
215
|
+
return s.formActions(Math.max(0, Math.min(waitMs, 25_000))) as Promise<{ actions: Array<Record<string, unknown>> }>
|
|
216
|
+
}),
|
|
217
|
+
presence: (value, rpc) =>
|
|
218
|
+
run(rpc, (s) => {
|
|
219
|
+
const { sessionIDs, reporter } = input(value)
|
|
220
|
+
if (!Array.isArray(sessionIDs)) throw new SpokeError("invalid", "Expected {sessionIDs: string[]}")
|
|
221
|
+
s.presence(sessionIDs.filter((id): id is string => typeof id === "string").slice(0, 200), typeof reporter === "string" ? reporter.slice(0, 100) : undefined)
|
|
222
|
+
return {}
|
|
223
|
+
}),
|
|
224
|
+
} satisfies Parameters<typeof ctx.rpc.register<typeof HubRpc>>[1]
|
|
225
|
+
// "hub", plus the opencode-voice id "voice" for clients built against the old name (see src/rpc.ts).
|
|
226
|
+
for (const definition of [HubRpc, VoiceRpc]) {
|
|
227
|
+
try {
|
|
228
|
+
await ctx.rpc.register(definition, handlers)
|
|
229
|
+
} catch (cause) {
|
|
230
|
+
console.error(`[hub] could not register RPC methods (${definition.id})`, cause)
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return () => membership.leave()
|
|
235
|
+
},
|
|
236
|
+
})
|
package/src/key.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface KeySources {
|
|
2
|
+
active(integrationID: string): Promise<unknown>
|
|
3
|
+
resolve(connection: any): Promise<unknown>
|
|
4
|
+
env: Readonly<Record<string, string | undefined>>
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/** OpenCode's openrouter integration first, then OPENROUTER_API_KEY, then the plugin option. */
|
|
8
|
+
export async function resolveOpenRouterKey(sources: KeySources, optionKey: string | undefined): Promise<string | undefined> {
|
|
9
|
+
try {
|
|
10
|
+
const connection = await sources.active("openrouter")
|
|
11
|
+
if (connection) {
|
|
12
|
+
const value = (await sources.resolve(connection)) as { type?: unknown; key?: unknown } | undefined
|
|
13
|
+
if (value?.type === "key" && typeof value.key === "string" && value.key) return value.key
|
|
14
|
+
}
|
|
15
|
+
} catch {}
|
|
16
|
+
return sources.env.OPENROUTER_API_KEY || optionKey || undefined
|
|
17
|
+
}
|
package/src/loopback.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Loopback detection, shared by the hub (CSRF origins for a local hub, first-machine auto-approve) and the
|
|
2
|
+
// spoke (auto-starting a local hub). Only names that can never leave the machine count: localhost,
|
|
3
|
+
// 127.0.0.0/8 and ::1. Request addresses are a hint only, never authentication.
|
|
4
|
+
|
|
5
|
+
/** Hostnames as they appear in URLs (`[::1]`) or in config (`::1`). */
|
|
6
|
+
export function isLoopbackHost(host: string): boolean {
|
|
7
|
+
const h = host.trim().toLowerCase().replace(/^\[(.*)\]$/, "$1")
|
|
8
|
+
if (h === "localhost" || h === "::1") return true
|
|
9
|
+
const ip = /^127\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(h)
|
|
10
|
+
return ip !== null && ip.slice(1).every((part) => Number(part) <= 255)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** An http(s) URL whose host is loopback. */
|
|
14
|
+
export function isLoopbackURL(url: string): boolean {
|
|
15
|
+
try {
|
|
16
|
+
const parsed = new URL(url)
|
|
17
|
+
return (parsed.protocol === "http:" || parsed.protocol === "https:") && isLoopbackHost(parsed.hostname)
|
|
18
|
+
} catch {
|
|
19
|
+
return false
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A peer address (e.g. Bun's server.requestIP) on loopback, including IPv4-mapped IPv6. */
|
|
24
|
+
export function isLoopbackAddress(ip: string | undefined | null): boolean {
|
|
25
|
+
if (!ip) return false
|
|
26
|
+
const address = ip.toLowerCase().replace(/^::ffff:/, "")
|
|
27
|
+
return address !== "localhost" && isLoopbackHost(address)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const LOOPBACK_NAMES = ["localhost", "127.0.0.1", "[::1]"] as const
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Origins a browser may send for this public URL. A loopback public URL is reachable as localhost,
|
|
34
|
+
* 127.0.0.1 and [::1] (same scheme and port); any other public URL has exactly one origin.
|
|
35
|
+
*/
|
|
36
|
+
export function loopbackOrigins(publicURL: string): Set<string> {
|
|
37
|
+
const url = new URL(publicURL)
|
|
38
|
+
if (!isLoopbackHost(url.hostname)) return new Set([url.origin])
|
|
39
|
+
const port = url.port ? `:${url.port}` : ""
|
|
40
|
+
return new Set([url.origin, ...LOOPBACK_NAMES.map((name) => `${url.protocol}//${name}${port}`)])
|
|
41
|
+
}
|
package/src/migrate.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// One-time migration from the legacy locations to the Hub ones. The project was opencode-voice (<= 0.4),
|
|
2
|
+
// then opencode-hub (0.5), and is now Hub. When the new file or directory is missing, the most recent
|
|
3
|
+
// legacy one that exists (opencode-hub first, then opencode-voice) is copied (file modes kept, e.g. 0600 for tokens)
|
|
4
|
+
// through a temporary sibling that is renamed into place, so a partial copy never looks migrated. The
|
|
5
|
+
// legacy copy is never touched or deleted. Callers skip migration for paths set explicitly by env.
|
|
6
|
+
import {
|
|
7
|
+
chmodSync,
|
|
8
|
+
copyFileSync,
|
|
9
|
+
existsSync,
|
|
10
|
+
lstatSync,
|
|
11
|
+
mkdirSync,
|
|
12
|
+
readdirSync,
|
|
13
|
+
readlinkSync,
|
|
14
|
+
renameSync,
|
|
15
|
+
rmSync,
|
|
16
|
+
symlinkSync,
|
|
17
|
+
} from "node:fs"
|
|
18
|
+
import path from "node:path"
|
|
19
|
+
import type { Env } from "./env"
|
|
20
|
+
|
|
21
|
+
export const APP_DIR = "hub"
|
|
22
|
+
/** Legacy directory names under ~/.config and ~/.local/state, most recent first. */
|
|
23
|
+
export const LEGACY_APP_DIRS: readonly string[] = Object.freeze(["opencode-hub", "opencode-voice"])
|
|
24
|
+
|
|
25
|
+
/** $XDG_CONFIG_HOME or ~/.config. */
|
|
26
|
+
export const configBase = (env: Env, home: string): string => env.XDG_CONFIG_HOME || path.join(home, ".config")
|
|
27
|
+
/** ~/.local/state. */
|
|
28
|
+
export const stateBase = (home: string): string => path.join(home, ".local", "state")
|
|
29
|
+
|
|
30
|
+
export type MigrationResult = "migrated" | "none" | "failed"
|
|
31
|
+
|
|
32
|
+
export interface MigrateOptions {
|
|
33
|
+
/** One line when something was copied or the copy failed (default console.log / console.warn). */
|
|
34
|
+
readonly log?: (message: string) => void
|
|
35
|
+
/** Top-level entry names of a legacy directory not to copy (e.g. a lock file). */
|
|
36
|
+
readonly skip?: readonly string[]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const exists = (p: string) => existsSync(p) || isLink(p)
|
|
40
|
+
function isLink(p: string): boolean {
|
|
41
|
+
try {
|
|
42
|
+
return lstatSync(p).isSymbolicLink()
|
|
43
|
+
} catch {
|
|
44
|
+
return false
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function copyEntry(src: string, dst: string, skip: readonly string[] = []): void {
|
|
49
|
+
const stat = lstatSync(src)
|
|
50
|
+
if (stat.isDirectory()) {
|
|
51
|
+
mkdirSync(dst, { mode: 0o700 })
|
|
52
|
+
for (const name of readdirSync(src)) {
|
|
53
|
+
if (!skip.includes(name)) copyEntry(path.join(src, name), path.join(dst, name))
|
|
54
|
+
}
|
|
55
|
+
chmodSync(dst, stat.mode & 0o7777)
|
|
56
|
+
} else if (stat.isFile()) {
|
|
57
|
+
copyFileSync(src, dst)
|
|
58
|
+
chmodSync(dst, stat.mode & 0o7777)
|
|
59
|
+
} else if (stat.isSymbolicLink()) {
|
|
60
|
+
symlinkSync(readlinkSync(src), dst)
|
|
61
|
+
}
|
|
62
|
+
// sockets, FIFOs and devices are not state worth copying
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Copies `legacy` to `target` when only the legacy one exists. Never throws. */
|
|
66
|
+
export function migrateLegacy(legacy: string, target: string, options: MigrateOptions = {}): MigrationResult {
|
|
67
|
+
if (exists(target) || !exists(legacy)) return "none"
|
|
68
|
+
const tmp = `${target}.migrating-${process.pid}-${Date.now()}`
|
|
69
|
+
try {
|
|
70
|
+
mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 })
|
|
71
|
+
copyEntry(legacy, tmp, options.skip)
|
|
72
|
+
try {
|
|
73
|
+
renameSync(tmp, target)
|
|
74
|
+
} catch (cause) {
|
|
75
|
+
// Another process migrated first: theirs stands.
|
|
76
|
+
if (exists(target)) {
|
|
77
|
+
rmSync(tmp, { recursive: true, force: true })
|
|
78
|
+
return "none"
|
|
79
|
+
}
|
|
80
|
+
throw cause
|
|
81
|
+
}
|
|
82
|
+
} catch (cause) {
|
|
83
|
+
rmSync(tmp, { recursive: true, force: true })
|
|
84
|
+
const message = `could not migrate ${legacy} to ${target}: ${cause instanceof Error ? cause.message : cause}`
|
|
85
|
+
;(options.log ?? ((m: string) => console.warn(`[hub] warning: ${m}`)))(message)
|
|
86
|
+
return "failed"
|
|
87
|
+
}
|
|
88
|
+
;(options.log ?? ((m: string) => console.log(`[hub] ${m}`)))(
|
|
89
|
+
`migrated ${legacy} to ${target} (the project is now Hub; the old copy is kept)`,
|
|
90
|
+
)
|
|
91
|
+
return "migrated"
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Copies the first existing of `legacies` (most recent first) to `target` when the target is missing.
|
|
96
|
+
* Never throws; the legacy copies are never touched.
|
|
97
|
+
*/
|
|
98
|
+
export function migrateFirst(legacies: readonly string[], target: string, options: MigrateOptions = {}): MigrationResult {
|
|
99
|
+
if (exists(target)) return "none"
|
|
100
|
+
const legacy = legacies.find(exists)
|
|
101
|
+
return legacy === undefined ? "none" : migrateLegacy(legacy, target, options)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The legacy copies of `<base>/<APP_DIR>/<...rest>`, most recent first. */
|
|
105
|
+
export const legacyPaths = (base: string, ...rest: string[]): string[] => LEGACY_APP_DIRS.map((dir) => path.join(base, dir, ...rest))
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
// Compatibility re-exports: the speech providers live in src/central/speech/.
|
|
2
|
+
export { OpenRouter, OpenRouterError, OPENROUTER_BASE_URL, type OpenRouterOptions } from "./central/speech/openrouter"
|
|
3
|
+
export { audioFormat, SpeechError, type SpeechProvider } from "./central/speech/provider"
|
package/src/options.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export interface VoiceOptions {
|
|
2
|
+
readonly host: string
|
|
3
|
+
readonly port: number
|
|
4
|
+
/** Base URL used in /hub links when the page is reached through a proxy (e.g. tailscale serve). */
|
|
5
|
+
readonly publicURL?: string
|
|
6
|
+
readonly sttModel: string
|
|
7
|
+
readonly ttsModel: string
|
|
8
|
+
readonly voice: string
|
|
9
|
+
/** Fallback when neither the OpenCode openrouter integration nor OPENROUTER_API_KEY is set. */
|
|
10
|
+
readonly apiKey?: string
|
|
11
|
+
readonly maxAudioBytes: number
|
|
12
|
+
readonly maxSpeechChars: number
|
|
13
|
+
readonly turnTimeoutMs: number
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const string = (value: unknown, fallback: string) => (typeof value === "string" && value ? value : fallback)
|
|
17
|
+
const positive = (value: unknown, fallback: number) =>
|
|
18
|
+
typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback
|
|
19
|
+
|
|
20
|
+
export function parseOptions(raw: Readonly<Record<string, unknown>>): VoiceOptions {
|
|
21
|
+
return {
|
|
22
|
+
host: string(raw.host, "127.0.0.1"),
|
|
23
|
+
port: positive(raw.port, 4097),
|
|
24
|
+
...(typeof raw.publicURL === "string" && raw.publicURL ? { publicURL: raw.publicURL.replace(/\/+$/, "") } : {}),
|
|
25
|
+
sttModel: string(raw.sttModel, "openai/gpt-4o-mini-transcribe"),
|
|
26
|
+
ttsModel: string(raw.ttsModel, "microsoft/mai-voice-2.1-flash"),
|
|
27
|
+
voice: string(raw.voice, "en-US-Harper:MAI-Voice-2.1-Flash"),
|
|
28
|
+
...(typeof raw.apiKey === "string" && raw.apiKey ? { apiKey: raw.apiKey } : {}),
|
|
29
|
+
maxAudioBytes: positive(raw.maxAudioBytes, 10 * 1024 * 1024),
|
|
30
|
+
maxSpeechChars: positive(raw.maxSpeechChars, 800),
|
|
31
|
+
turnTimeoutMs: positive(raw.turnTimeoutMs, 10 * 60 * 1000),
|
|
32
|
+
}
|
|
33
|
+
}
|