diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 42f51e769aa9..3cc94669279e 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,2 +1,2 @@ -739fe620027bad069221aef1e6ec68bef0448c31a1573f70802a461b212c3d7d plugin-sdk-api-baseline.json -08eb0ae2bd2c3b32e04661cde03260aa97462ba64a4944307a0bdf98f4f98a75 plugin-sdk-api-baseline.jsonl +336b76c2642b15524c672158d7209dda8f22efe4b5ce88f5e3ee6674dc293882 plugin-sdk-api-baseline.json +26fd03f65ea0893b3592f3b070d9c5f2b1ff7d2a89271cdf0b6579de20c569c9 plugin-sdk-api-baseline.jsonl diff --git a/docs/plugins/sdk-migration.md b/docs/plugins/sdk-migration.md index 8f9c4c66a7f3..090b698716e3 100644 --- a/docs/plugins/sdk-migration.md +++ b/docs/plugins/sdk-migration.md @@ -502,6 +502,7 @@ SDK. | `plugin-sdk/text-chunking` | Text chunking helpers | Outbound text and offset-preserving range chunking helpers | | `plugin-sdk/speech` | Speech helpers | Speech provider types plus provider-facing directive, registry, validation helpers, and OpenAI-compatible TTS builder | | `plugin-sdk/speech-core` | Shared speech core | Speech provider types, registry, directives, normalization | + | `plugin-sdk/speech-settings` | Speech settings | Lightweight TTS config resolution and normalization primitives without provider registries or synthesis runtime | | `plugin-sdk/realtime-transcription` | Realtime transcription helpers | Provider types, registry helpers, and shared WebSocket session helper | | `plugin-sdk/realtime-voice` | Realtime voice helpers | Provider types, registry/resolution helpers, bridge session helpers, shared agent talk-back queues, active-run voice control, transcript/event health, echo suppression, consult question matching, forced-consult coordination, turn-context tracking, output activity tracking, and fast context consult helpers | | `plugin-sdk/image-generation` | Image-generation helpers | Image generation provider types plus image asset/data URL helpers and the OpenAI-compatible image provider builder | diff --git a/docs/plugins/sdk-subpaths.md b/docs/plugins/sdk-subpaths.md index 47cb725b3750..2388d083abc6 100644 --- a/docs/plugins/sdk-subpaths.md +++ b/docs/plugins/sdk-subpaths.md @@ -357,6 +357,7 @@ usage endpoint failed or returned no usable usage data. | `plugin-sdk/text-chunking` | Outbound text and offset-preserving range chunking, markdown chunking/render helpers, quote-aware HTML tag tokenization, markdown table conversion, directive-tag stripping, and safe-text utilities | | `plugin-sdk/speech` | Speech provider types plus provider-facing directive, registry, validation, OpenAI-compatible TTS builder, and speech helper exports | | `plugin-sdk/speech-core` | Shared speech provider types, registry, directive, normalization, and speech helper exports | + | `plugin-sdk/speech-settings` | Lightweight TTS config resolution and normalization primitives without provider registries or synthesis runtime | | `plugin-sdk/realtime-transcription` | Realtime transcription provider types, registry helpers, and shared WebSocket session helper | | `plugin-sdk/realtime-bootstrap-context` | Realtime profile bootstrap helper for bounded `IDENTITY.md`, `USER.md`, and `SOUL.md` context injection | | `plugin-sdk/realtime-voice` | Realtime voice provider types, registry helpers, and shared realtime voice behavior helpers, including output activity tracking | diff --git a/package.json b/package.json index 23d27e20f0c2..1a5a6f8eaebe 100644 --- a/package.json +++ b/package.json @@ -573,6 +573,10 @@ "types": "./dist/plugin-sdk/speech-core.d.ts", "default": "./dist/plugin-sdk/speech-core.js" }, + "./plugin-sdk/speech-settings": { + "types": "./dist/plugin-sdk/speech-settings.d.ts", + "default": "./dist/plugin-sdk/speech-settings.js" + }, "./plugin-sdk/tts-runtime": { "types": "./dist/plugin-sdk/tts-runtime.d.ts", "default": "./dist/plugin-sdk/tts-runtime.js" diff --git a/packages/speech-core/runtime-api.ts b/packages/speech-core/runtime-api.ts index d6de5c9d14b9..1ab0fc0ddf61 100644 --- a/packages/speech-core/runtime-api.ts +++ b/packages/speech-core/runtime-api.ts @@ -2,29 +2,35 @@ // helpers used by speech-capable plugins. export { buildTtsSystemPromptHint, - getLastTtsAttempt, - getResolvedSpeechProviderConfig, getTtsMaxLength, getTtsPersona, - getTtsProvider, isSummarizationEnabled, isTtsEnabled, - isTtsProviderConfigured, - listSpeechVoices, listTtsPersonas, - maybeApplyTtsToPayload, - resolveExplicitTtsOverrides, resolveTtsAutoMode, resolveTtsConfig, resolveTtsPrefsPath, - resolveTtsProviderOrder, - setLastTtsAttempt, + type ResolvedTtsConfig, + type ResolvedTtsModelOverrides, +} from "./src/tts-settings.js"; +export { setSummarizationEnabled, setTtsAutoMode, setTtsEnabled, setTtsMaxLength, setTtsPersona, setTtsProvider, +} from "./src/tts-settings-writes.js"; +export { + getLastTtsAttempt, + getResolvedSpeechProviderConfig, + getTtsProvider, + isTtsProviderConfigured, + listSpeechVoices, + maybeApplyTtsToPayload, + resolveExplicitTtsOverrides, + resolveTtsProviderOrder, + setLastTtsAttempt, synthesizeSpeech, streamSpeech, textToSpeech, @@ -32,8 +38,6 @@ export { textToSpeechTelephony, testApi as _test, testApi, - type ResolvedTtsConfig, - type ResolvedTtsModelOverrides, type TtsDirectiveOverrides, type TtsDirectiveParseResult, type TtsResult, diff --git a/packages/speech-core/src/tts-settings-writes.ts b/packages/speech-core/src/tts-settings-writes.ts new file mode 100644 index 000000000000..01188251a771 --- /dev/null +++ b/packages/speech-core/src/tts-settings-writes.ts @@ -0,0 +1,54 @@ +// TTS preference mutations stay off the agent prompt's read-only import path. +import path from "node:path"; +import type { TtsAutoMode, TtsProvider } from "openclaw/plugin-sdk/config-contracts"; +import { privateFileStoreSync } from "openclaw/plugin-sdk/security-runtime"; +import { canonicalizeSpeechProviderId } from "openclaw/plugin-sdk/speech-core"; +import { normalizeTtsPersonaId, readTtsPrefs, type TtsUserPrefs } from "./tts-settings.js"; + +function updateTtsPrefs(prefsPath: string, update: (prefs: TtsUserPrefs) => void): void { + const prefs = readTtsPrefs(prefsPath); + update(prefs); + privateFileStoreSync(path.dirname(prefsPath)).writeText( + path.basename(prefsPath), + JSON.stringify(prefs, null, 2), + ); +} + +export function setTtsAutoMode(prefsPath: string, mode: TtsAutoMode): void { + updateTtsPrefs(prefsPath, (prefs) => { + const next = { ...prefs.tts }; + delete next.enabled; + next.auto = mode; + prefs.tts = next; + }); +} + +export function setTtsEnabled(prefsPath: string, enabled: boolean): void { + setTtsAutoMode(prefsPath, enabled ? "always" : "off"); +} + +export function setTtsPersona(prefsPath: string, persona: string | null | undefined): void { + updateTtsPrefs(prefsPath, (prefs) => { + const next = { ...prefs.tts }; + next.persona = normalizeTtsPersonaId(persona) ?? null; + prefs.tts = next; + }); +} + +export function setTtsProvider(prefsPath: string, provider: TtsProvider): void { + updateTtsPrefs(prefsPath, (prefs) => { + prefs.tts = { ...prefs.tts, provider: canonicalizeSpeechProviderId(provider) ?? provider }; + }); +} + +export function setTtsMaxLength(prefsPath: string, maxLength: number): void { + updateTtsPrefs(prefsPath, (prefs) => { + prefs.tts = { ...prefs.tts, maxLength }; + }); +} + +export function setSummarizationEnabled(prefsPath: string, enabled: boolean): void { + updateTtsPrefs(prefsPath, (prefs) => { + prefs.tts = { ...prefs.tts, summarize: enabled }; + }); +} diff --git a/packages/speech-core/src/tts-settings.ts b/packages/speech-core/src/tts-settings.ts new file mode 100644 index 000000000000..fd788d35090c --- /dev/null +++ b/packages/speech-core/src/tts-settings.ts @@ -0,0 +1,391 @@ +// Lightweight TTS settings resolution shared by agent prompts, status, and speech runtime. +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; +import type { + OpenClawConfig, + ResolvedTtsPersona, + TtsAutoMode, + TtsConfig, + TtsModelOverrideConfig, + TtsProvider, +} from "openclaw/plugin-sdk/config-contracts"; +import { + getRuntimeConfigSnapshot, + getRuntimeConfigSourceSnapshot, + selectApplicableRuntimeConfig, +} from "openclaw/plugin-sdk/runtime-config-snapshot"; +import type { SpeechProviderConfig } from "openclaw/plugin-sdk/speech-core"; +import { + normalizeSpeechProviderId, + normalizeTtsAutoMode, + resolveEffectiveTtsConfig, + type ResolvedTtsConfig, + type ResolvedTtsModelOverrides, + type TtsConfigResolutionContext, +} from "openclaw/plugin-sdk/speech-settings"; +import { + normalizeOptionalLowercaseString, + normalizeOptionalString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; +import { resolveConfigDir, resolveUserPath } from "openclaw/plugin-sdk/text-utility-runtime"; +import { withSpeakerSelectionCompat } from "../speaker.js"; + +export type { ResolvedTtsConfig, ResolvedTtsModelOverrides }; + +export const DEFAULT_TTS_TIMEOUT_MS = 30_000; +const DEFAULT_TTS_MAX_LENGTH = 1500; +const DEFAULT_TTS_SUMMARIZE = true; +const DEFAULT_MAX_TEXT_LENGTH = 4096; + +export type TtsUserPrefs = { + tts?: { + auto?: TtsAutoMode; + enabled?: boolean; + provider?: TtsProvider; + persona?: string | null; + maxLength?: number; + summarize?: boolean; + }; +}; + +function resolveConfiguredTtsAutoMode(raw: TtsConfig): TtsAutoMode { + return normalizeTtsAutoMode(raw.auto) ?? (raw.enabled ? "always" : "off"); +} + +export function normalizeConfiguredSpeechProviderId( + providerId: string | undefined, +): TtsProvider | undefined { + const normalized = normalizeSpeechProviderId(providerId); + if (!normalized) { + return undefined; + } + return normalized === "edge" ? "microsoft" : normalized; +} + +export function normalizeTtsPersonaId(personaId: string | null | undefined): string | undefined { + return normalizeOptionalLowercaseString(personaId ?? undefined); +} + +function resolveTtsPrefsPathValue(prefsPath: string | undefined): string { + if (prefsPath?.trim()) { + return resolveUserPath(prefsPath.trim()); + } + const envPath = process.env.OPENCLAW_TTS_PREFS?.trim(); + if (envPath) { + return resolveUserPath(envPath); + } + return path.join(resolveConfigDir(process.env), "settings", "tts.json"); +} + +export function resolveModelOverridePolicy( + overrides: TtsModelOverrideConfig | undefined, +): ResolvedTtsModelOverrides { + const enabled = overrides?.enabled ?? true; + if (!enabled) { + return { + enabled: false, + allowText: false, + allowProvider: false, + allowVoice: false, + allowModelId: false, + allowVoiceSettings: false, + allowNormalization: false, + allowSeed: false, + }; + } + const allow = (value: boolean | undefined, defaultValue = true) => value ?? defaultValue; + return { + enabled: true, + allowText: allow(overrides?.allowText), + allowProvider: allow(overrides?.allowProvider, false), + allowVoice: allow(overrides?.allowVoice), + allowModelId: allow(overrides?.allowModelId), + allowVoiceSettings: allow(overrides?.allowVoiceSettings), + allowNormalization: allow(overrides?.allowNormalization), + allowSeed: allow(overrides?.allowSeed), + }; +} + +export function resolveTtsRuntimeConfig(cfg: OpenClawConfig): OpenClawConfig { + return ( + selectApplicableRuntimeConfig({ + inputConfig: cfg, + runtimeConfig: getRuntimeConfigSnapshot(), + runtimeSourceConfig: getRuntimeConfigSourceSnapshot(), + }) ?? cfg + ); +} + +export function asProviderConfig(value: unknown): SpeechProviderConfig { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? withSpeakerSelectionCompat(value as SpeechProviderConfig) + : {}; +} + +export function asProviderConfigMap(value: unknown): Record { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : {}; +} + +export function hasOwnProperty(value: object, key: string): boolean { + return Object.hasOwn(value, key); +} + +function normalizeProviderConfigMap( + value: unknown, +): Record | undefined { + const rawMap = asProviderConfigMap(value); + if (Object.keys(rawMap).length === 0) { + return undefined; + } + const next: Record = {}; + for (const [providerId, providerConfig] of Object.entries(rawMap)) { + const normalized = normalizeConfiguredSpeechProviderId(providerId) ?? providerId; + next[normalized] = asProviderConfig(providerConfig); + } + return next; +} + +function collectTtsPersonas(raw: TtsConfig): Record { + const rawPersonas = asProviderConfigMap(raw.personas); + const personas: Record = {}; + for (const [id, value] of Object.entries(rawPersonas)) { + const normalizedId = normalizeTtsPersonaId(id); + if (!normalizedId || typeof value !== "object" || value === null || Array.isArray(value)) { + continue; + } + const persona = value as Omit; + personas[normalizedId] = { + ...persona, + id: normalizedId, + provider: normalizeConfiguredSpeechProviderId(persona.provider) ?? persona.provider, + providers: normalizeProviderConfigMap(persona.providers), + }; + } + return personas; +} + +function collectDirectProviderConfigEntries(raw: TtsConfig): Record { + const entries: Record = {}; + const rawProviders = asProviderConfigMap(raw.providers); + for (const [providerId, value] of Object.entries(rawProviders)) { + const normalized = normalizeConfiguredSpeechProviderId(providerId) ?? providerId; + entries[normalized] = asProviderConfig(value); + } + const reservedKeys = new Set([ + "auto", + "enabled", + "maxTextLength", + "mode", + "modelOverrides", + "persona", + "personas", + "prefsPath", + "provider", + "providers", + "summaryModel", + "timeoutMs", + ]); + for (const [key, value] of Object.entries(raw as Record)) { + if (reservedKeys.has(key)) { + continue; + } + if (typeof value !== "object" || value === null || Array.isArray(value)) { + continue; + } + const normalized = normalizeConfiguredSpeechProviderId(key) ?? key; + entries[normalized] ??= asProviderConfig(value); + } + return entries; +} + +export function resolveTtsConfig( + cfgInput: OpenClawConfig, + contextOrAgentId?: string | TtsConfigResolutionContext, +): ResolvedTtsConfig { + const cfg = resolveTtsRuntimeConfig(cfgInput); + const raw: TtsConfig = resolveEffectiveTtsConfig(cfg, contextOrAgentId); + const providerSource = raw.provider ? "config" : "default"; + const timeoutMs = raw.timeoutMs ?? DEFAULT_TTS_TIMEOUT_MS; + const timeoutMsSource = raw.timeoutMs === undefined ? "default" : "config"; + return { + auto: resolveConfiguredTtsAutoMode(raw), + mode: raw.mode ?? "final", + provider: + normalizeConfiguredSpeechProviderId(raw.provider) ?? + (providerSource === "config" ? (normalizeOptionalLowercaseString(raw.provider) ?? "") : ""), + providerSource, + persona: normalizeTtsPersonaId(raw.persona), + personas: collectTtsPersonas(raw), + summaryModel: normalizeOptionalString(raw.summaryModel), + modelOverrides: resolveModelOverridePolicy(raw.modelOverrides), + providerConfigs: collectDirectProviderConfigEntries(raw), + prefsPath: raw.prefsPath, + maxTextLength: raw.maxTextLength ?? DEFAULT_MAX_TEXT_LENGTH, + timeoutMs, + timeoutMsSource, + rawConfig: raw, + sourceConfig: cfg, + }; +} + +export function resolveTtsPrefsPath(config: ResolvedTtsConfig): string { + return resolveTtsPrefsPathValue(config.prefsPath); +} + +export function readTtsPrefs(prefsPath: string): TtsUserPrefs { + try { + if (!existsSync(prefsPath)) { + return {}; + } + const parsed: unknown = JSON.parse(readFileSync(prefsPath, "utf8")); + return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as TtsUserPrefs) + : {}; + } catch { + return {}; + } +} + +function resolveTtsAutoModeFromPrefs(prefs: TtsUserPrefs): TtsAutoMode | undefined { + const auto = normalizeTtsAutoMode(prefs.tts?.auto); + if (auto) { + return auto; + } + if (typeof prefs.tts?.enabled === "boolean") { + return prefs.tts.enabled ? "always" : "off"; + } + return undefined; +} + +export function resolveTtsAutoMode(params: { + config: ResolvedTtsConfig; + prefsPath: string; + sessionAuto?: string; +}): TtsAutoMode { + const sessionAuto = normalizeTtsAutoMode(params.sessionAuto); + if (sessionAuto) { + return sessionAuto; + } + return resolveTtsAutoModeFromPrefs(readTtsPrefs(params.prefsPath)) ?? params.config.auto; +} + +function resolveTtsPersonaIdFromPrefs( + config: ResolvedTtsConfig, + prefs: TtsUserPrefs, +): string | undefined { + if (prefs.tts && hasOwnProperty(prefs.tts, "persona")) { + return normalizeTtsPersonaId(prefs.tts.persona); + } + return normalizeTtsPersonaId(config.persona); +} + +export function resolveTtsPersonaFromPrefs( + config: ResolvedTtsConfig, + prefs: TtsUserPrefs, +): ResolvedTtsPersona | undefined { + const personaId = resolveTtsPersonaIdFromPrefs(config, prefs); + return personaId ? config.personas[personaId] : undefined; +} + +type ResolvedTtsSettingsSnapshot = { + autoMode: TtsAutoMode; + config: ResolvedTtsConfig; + maxLength: number; + persona?: ResolvedTtsPersona; + personaId?: string; + preferredProvider?: TtsProvider; + prefsPath: string; + summarize: boolean; +}; + +export function resolveTtsSettingsSnapshot(params: { + cfg: OpenClawConfig; + sessionAuto?: string; + agentId?: string; + channelId?: string; + accountId?: string; +}): ResolvedTtsSettingsSnapshot { + const config = resolveTtsConfig(params.cfg, { + agentId: params.agentId, + channelId: params.channelId, + accountId: params.accountId, + }); + const prefsPath = resolveTtsPrefsPath(config); + const prefs = readTtsPrefs(prefsPath); + const personaId = resolveTtsPersonaIdFromPrefs(config, prefs); + const persona = personaId ? config.personas[personaId] : undefined; + const preferredProvider = + normalizeConfiguredSpeechProviderId(prefs.tts?.provider) ?? + normalizeConfiguredSpeechProviderId(persona?.provider) ?? + (config.providerSource === "config" + ? (normalizeConfiguredSpeechProviderId(config.provider) ?? config.provider) + : undefined); + return { + autoMode: + normalizeTtsAutoMode(params.sessionAuto) ?? resolveTtsAutoModeFromPrefs(prefs) ?? config.auto, + config, + maxLength: prefs.tts?.maxLength ?? DEFAULT_TTS_MAX_LENGTH, + ...(persona ? { persona } : {}), + ...(personaId ? { personaId } : {}), + ...(preferredProvider ? { preferredProvider } : {}), + prefsPath, + summarize: prefs.tts?.summarize ?? DEFAULT_TTS_SUMMARIZE, + }; +} + +export function buildTtsSystemPromptHint( + cfg: OpenClawConfig, + agentId?: string, +): string | undefined { + const settings = resolveTtsSettingsSnapshot({ cfg, agentId }); + if (settings.autoMode === "off") { + return undefined; + } + const autoHint = + settings.autoMode === "inbound" + ? "Only use TTS when the user's last message includes audio/voice." + : settings.autoMode === "tagged" + ? "Only use TTS when you include [[tts:key=value]] directives or a [[tts:text]]...[[/tts:text]] block." + : undefined; + return [ + "Voice (TTS) is enabled.", + autoHint, + settings.persona + ? `Active TTS persona: ${settings.persona.label ?? settings.persona.id}${settings.persona.description ? ` - ${settings.persona.description}` : ""}.` + : undefined, + `Keep spoken text ≤${settings.maxLength} chars to avoid auto-summary (summary ${settings.summarize ? "on" : "off"}).`, + "If workspace context (especially MEMORY.md) tells you not to use [[tts:...]] or to use a local/non-tagged voice workflow, follow that workspace instruction instead.", + "Use [[tts:...]] and optional [[tts:text]]...[[/tts:text]] to control voice/expressiveness.", + ] + .filter(Boolean) + .join("\n"); +} + +export function isTtsEnabled( + config: ResolvedTtsConfig, + prefsPath: string, + sessionAuto?: string, +): boolean { + return resolveTtsAutoMode({ config, prefsPath, sessionAuto }) !== "off"; +} + +export function getTtsPersona( + config: ResolvedTtsConfig, + prefsPath: string, +): ResolvedTtsPersona | undefined { + return resolveTtsPersonaFromPrefs(config, readTtsPrefs(prefsPath)); +} + +export function listTtsPersonas(config: ResolvedTtsConfig): ResolvedTtsPersona[] { + return Object.values(config.personas).toSorted((left, right) => left.id.localeCompare(right.id)); +} + +export function getTtsMaxLength(prefsPath: string): number { + return readTtsPrefs(prefsPath).tts?.maxLength ?? DEFAULT_TTS_MAX_LENGTH; +} + +export function isSummarizationEnabled(prefsPath: string): boolean { + return readTtsPrefs(prefsPath).tts?.summarize ?? DEFAULT_TTS_SUMMARIZE; +} diff --git a/packages/speech-core/src/tts.test.ts b/packages/speech-core/src/tts.test.ts index 4c097506e5af..76cd5d08e11f 100644 --- a/packages/speech-core/src/tts.test.ts +++ b/packages/speech-core/src/tts.test.ts @@ -122,7 +122,7 @@ const { setTtsMaxLength, synthesizeSpeech, textToSpeechTelephony, -} = await import("./tts.js"); +} = await import("../runtime-api.js"); const nativeVoiceNoteChannels = ["discord", "feishu", "matrix", "telegram", "whatsapp"] as const; diff --git a/packages/speech-core/src/tts.ts b/packages/speech-core/src/tts.ts index 0a3a400debc5..e15bcdc3374c 100644 --- a/packages/speech-core/src/tts.ts +++ b/packages/speech-core/src/tts.ts @@ -1,13 +1,9 @@ // Speech Core module implements tts behavior. -import { existsSync, readFileSync } from "node:fs"; -import path from "node:path"; import { resolveChannelTtsVoiceDelivery } from "openclaw/plugin-sdk/channel-targets"; import type { OpenClawConfig, ResolvedTtsPersona, - TtsAutoMode, TtsConfig, - TtsModelOverrideConfig, TtsProvider, } from "openclaw/plugin-sdk/config-contracts"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; @@ -19,24 +15,14 @@ import { resolveSendableOutboundReplyParts, type ReplyPayload, } from "openclaw/plugin-sdk/reply-payload"; -import { - getRuntimeConfigSnapshot, - getRuntimeConfigSourceSnapshot, - selectApplicableRuntimeConfig, -} from "openclaw/plugin-sdk/runtime-config-snapshot"; import { isVerbose, logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { tempWorkspaceSync, resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/sandbox"; -import { privateFileStoreSync } from "openclaw/plugin-sdk/security-runtime"; import { canonicalizeSpeechProviderId, getSpeechProvider, listSpeechProviders, normalizeSpeechProviderId, - normalizeTtsAutoMode, parseTtsDirectives, - resolveEffectiveTtsConfig, - type ResolvedTtsConfig, - type ResolvedTtsModelOverrides, scheduleCleanup, summarizeText, type SpeechProviderPlugin, @@ -45,19 +31,13 @@ import { type SpeechVoiceOption, type TtsDirectiveOverrides, type TtsDirectiveParseResult, - type TtsConfigResolutionContext, } from "openclaw/plugin-sdk/speech-core"; import { normalizeLowercaseStringOrEmpty, - normalizeOptionalLowercaseString, normalizeOptionalString, } from "openclaw/plugin-sdk/string-coerce-runtime"; import { stripMarkdown } from "openclaw/plugin-sdk/text-chunking"; -import { - resolveConfigDir, - resolveUserPath, - truncateUtf16Safe, -} from "openclaw/plugin-sdk/text-utility-runtime"; +import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import { withSpeakerSelectionCompat } from "../speaker.js"; import { resolvePrimaryVoiceProviderCandidate, @@ -69,18 +49,26 @@ import { type VoiceModelProvider, type VoiceProviderCandidate, } from "../voice-models.js"; +import { + DEFAULT_TTS_TIMEOUT_MS, + asProviderConfig, + asProviderConfigMap, + getTtsMaxLength, + getTtsPersona, + hasOwnProperty, + isSummarizationEnabled, + normalizeConfiguredSpeechProviderId, + readTtsPrefs as readPrefs, + resolveModelOverridePolicy, + resolveTtsConfig, + resolveTtsPersonaFromPrefs, + resolveTtsPrefsPath, + resolveTtsRuntimeConfig, + resolveTtsSettingsSnapshot, + type ResolvedTtsConfig, +} from "./tts-settings.js"; -export type { - ResolvedTtsConfig, - ResolvedTtsModelOverrides, - TtsDirectiveOverrides, - TtsDirectiveParseResult, -}; - -const DEFAULT_TIMEOUT_MS = 30_000; -const DEFAULT_TTS_MAX_LENGTH = 1500; -const DEFAULT_TTS_SUMMARIZE = true; -const DEFAULT_MAX_TEXT_LENGTH = 4096; +export type { TtsDirectiveOverrides, TtsDirectiveParseResult }; function resolvePositiveTimeoutMs(timeoutMs: number | undefined): number | undefined { return typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs > 0 @@ -97,22 +85,11 @@ function resolveSpeechProviderTimeoutMs(params: { return resolvePositiveTimeoutMs(params.timeoutMs) ?? params.config.timeoutMs; } if (params.config.timeoutMsSource !== "default") { - return resolvePositiveTimeoutMs(params.config.timeoutMs) ?? DEFAULT_TIMEOUT_MS; + return resolvePositiveTimeoutMs(params.config.timeoutMs) ?? DEFAULT_TTS_TIMEOUT_MS; } return resolvePositiveTimeoutMs(params.provider.defaultTimeoutMs) ?? params.config.timeoutMs; } -type TtsUserPrefs = { - tts?: { - auto?: TtsAutoMode; - enabled?: boolean; - provider?: TtsProvider; - persona?: string | null; - maxLength?: number; - summarize?: boolean; - }; -}; - type TtsAttemptReasonCode = | "success" | "no_provider_registered" @@ -219,64 +196,6 @@ type TtsStatusEntry = { let lastTtsAttempt: TtsStatusEntry | undefined; -function resolveConfiguredTtsAutoMode(raw: TtsConfig): TtsAutoMode { - return normalizeTtsAutoMode(raw.auto) ?? (raw.enabled ? "always" : "off"); -} - -function normalizeConfiguredSpeechProviderId( - providerId: string | undefined, -): TtsProvider | undefined { - const normalized = normalizeSpeechProviderId(providerId); - if (!normalized) { - return undefined; - } - return normalized === "edge" ? "microsoft" : normalized; -} - -function normalizeTtsPersonaId(personaId: string | null | undefined): string | undefined { - return normalizeOptionalLowercaseString(personaId ?? undefined); -} - -function resolveTtsPrefsPathValue(prefsPath: string | undefined): string { - if (prefsPath?.trim()) { - return resolveUserPath(prefsPath.trim()); - } - const envPath = process.env.OPENCLAW_TTS_PREFS?.trim(); - if (envPath) { - return resolveUserPath(envPath); - } - return path.join(resolveConfigDir(process.env), "settings", "tts.json"); -} - -function resolveModelOverridePolicy( - overrides: TtsModelOverrideConfig | undefined, -): ResolvedTtsModelOverrides { - const enabled = overrides?.enabled ?? true; - if (!enabled) { - return { - enabled: false, - allowText: false, - allowProvider: false, - allowVoice: false, - allowModelId: false, - allowVoiceSettings: false, - allowNormalization: false, - allowSeed: false, - }; - } - const allow = (value: boolean | undefined, defaultValue = true) => value ?? defaultValue; - return { - enabled: true, - allowText: allow(overrides?.allowText), - allowProvider: allow(overrides?.allowProvider, false), - allowVoice: allow(overrides?.allowVoice), - allowModelId: allow(overrides?.allowModelId), - allowVoiceSettings: allow(overrides?.allowVoiceSettings), - allowNormalization: allow(overrides?.allowNormalization), - allowSeed: allow(overrides?.allowSeed), - }; -} - function resolveConfiguredSpeechVoiceModelRefs(cfg: OpenClawConfig | undefined): VoiceModelRef[] { const effectiveCfg = cfg ? resolveTtsRuntimeConfig(cfg) : undefined; return resolveSupportedVoiceModelRefs({ @@ -344,66 +263,6 @@ function sortSpeechProvidersForAutoSelection(cfg?: OpenClawConfig) { }); } -function resolveTtsRuntimeConfig(cfg: OpenClawConfig): OpenClawConfig { - return ( - selectApplicableRuntimeConfig({ - inputConfig: cfg, - runtimeConfig: getRuntimeConfigSnapshot(), - runtimeSourceConfig: getRuntimeConfigSourceSnapshot(), - }) ?? cfg - ); -} - -function asProviderConfig(value: unknown): SpeechProviderConfig { - return typeof value === "object" && value !== null && !Array.isArray(value) - ? (value as SpeechProviderConfig) - : {}; -} - -function asProviderConfigMap(value: unknown): Record { - return typeof value === "object" && value !== null && !Array.isArray(value) - ? (value as Record) - : {}; -} - -function hasOwnProperty(value: object, key: string): boolean { - return Object.hasOwn(value, key); -} - -function normalizeProviderConfigMap( - value: unknown, -): Record | undefined { - const rawMap = asProviderConfigMap(value); - if (Object.keys(rawMap).length === 0) { - return undefined; - } - const next: Record = {}; - for (const [providerId, providerConfig] of Object.entries(rawMap)) { - const normalized = normalizeConfiguredSpeechProviderId(providerId) ?? providerId; - next[normalized] = withSpeakerSelectionCompat(asProviderConfig(providerConfig)); - } - return next; -} - -function collectTtsPersonas(raw: TtsConfig): Record { - const rawPersonas = asProviderConfigMap(raw.personas); - const personas: Record = {}; - for (const [id, value] of Object.entries(rawPersonas)) { - const normalizedId = normalizeTtsPersonaId(id); - if (!normalizedId || typeof value !== "object" || value === null || Array.isArray(value)) { - continue; - } - const persona = value as Omit; - personas[normalizedId] = { - ...persona, - id: normalizedId, - provider: normalizeConfiguredSpeechProviderId(persona.provider) ?? persona.provider, - providers: normalizeProviderConfigMap(persona.providers), - }; - } - return personas; -} - function resolvePersonaProviderConfig( persona: ResolvedTtsPersona | undefined, providerId: string, @@ -538,40 +397,6 @@ function resolveLazyProviderConfig( return next; } -function collectDirectProviderConfigEntries(raw: TtsConfig): Record { - const entries: Record = {}; - const rawProviders = asProviderConfigMap(raw.providers); - for (const [providerId, value] of Object.entries(rawProviders)) { - const normalized = normalizeConfiguredSpeechProviderId(providerId) ?? providerId; - entries[normalized] = withSpeakerSelectionCompat(asProviderConfig(value)); - } - const reservedKeys = new Set([ - "auto", - "enabled", - "maxTextLength", - "mode", - "modelOverrides", - "persona", - "personas", - "prefsPath", - "provider", - "providers", - "summaryModel", - "timeoutMs", - ]); - for (const [key, value] of Object.entries(raw as Record)) { - if (reservedKeys.has(key)) { - continue; - } - if (typeof value !== "object" || value === null || Array.isArray(value)) { - continue; - } - const normalized = normalizeConfiguredSpeechProviderId(key) ?? key; - entries[normalized] ??= withSpeakerSelectionCompat(asProviderConfig(value)); - } - return entries; -} - export function getResolvedSpeechProviderConfig( config: ResolvedTtsConfig, providerId: string, @@ -602,176 +427,6 @@ function getResolvedSpeechProviderConfigForVoiceModel(params: { return resolveLazyProviderConfig(params.config, canonical, effectiveCfg, params.voiceModel); } -export function resolveTtsConfig( - cfgInput: OpenClawConfig, - contextOrAgentId?: string | TtsConfigResolutionContext, -): ResolvedTtsConfig { - let cfg = cfgInput; - cfg = resolveTtsRuntimeConfig(cfg); - const raw: TtsConfig = resolveEffectiveTtsConfig(cfg, contextOrAgentId); - const providerSource = raw.provider ? "config" : "default"; - const timeoutMs = raw.timeoutMs ?? DEFAULT_TIMEOUT_MS; - const timeoutMsSource = raw.timeoutMs === undefined ? "default" : "config"; - const auto = resolveConfiguredTtsAutoMode(raw); - const persona = normalizeTtsPersonaId(raw.persona); - return { - auto, - mode: raw.mode ?? "final", - provider: - normalizeConfiguredSpeechProviderId(raw.provider) ?? - (providerSource === "config" ? (normalizeOptionalLowercaseString(raw.provider) ?? "") : ""), - providerSource, - persona, - personas: collectTtsPersonas(raw), - summaryModel: normalizeOptionalString(raw.summaryModel), - modelOverrides: resolveModelOverridePolicy(raw.modelOverrides), - providerConfigs: collectDirectProviderConfigEntries(raw), - prefsPath: raw.prefsPath, - maxTextLength: raw.maxTextLength ?? DEFAULT_MAX_TEXT_LENGTH, - timeoutMs, - timeoutMsSource, - rawConfig: raw, - sourceConfig: cfg, - }; -} - -export function resolveTtsPrefsPath(config: ResolvedTtsConfig): string { - return resolveTtsPrefsPathValue(config.prefsPath); -} - -function resolveTtsAutoModeFromPrefs(prefs: TtsUserPrefs): TtsAutoMode | undefined { - const auto = normalizeTtsAutoMode(prefs.tts?.auto); - if (auto) { - return auto; - } - if (typeof prefs.tts?.enabled === "boolean") { - return prefs.tts.enabled ? "always" : "off"; - } - return undefined; -} - -export function resolveTtsAutoMode(params: { - config: ResolvedTtsConfig; - prefsPath: string; - sessionAuto?: string; -}): TtsAutoMode { - const sessionAuto = normalizeTtsAutoMode(params.sessionAuto); - if (sessionAuto) { - return sessionAuto; - } - const prefsAuto = resolveTtsAutoModeFromPrefs(readPrefs(params.prefsPath)); - if (prefsAuto) { - return prefsAuto; - } - return params.config.auto; -} - -function resolveEffectiveTtsAutoState(params: { - cfg: OpenClawConfig; - sessionAuto?: string; - agentId?: string; - channelId?: string; - accountId?: string; -}): { - autoMode: TtsAutoMode; - prefsPath: string; -} { - const raw: TtsConfig = resolveEffectiveTtsConfig(params.cfg, { - agentId: params.agentId, - channelId: params.channelId, - accountId: params.accountId, - }); - const prefsPath = resolveTtsPrefsPathValue(raw.prefsPath); - const sessionAuto = normalizeTtsAutoMode(params.sessionAuto); - if (sessionAuto) { - return { autoMode: sessionAuto, prefsPath }; - } - const prefsAuto = resolveTtsAutoModeFromPrefs(readPrefs(prefsPath)); - if (prefsAuto) { - return { autoMode: prefsAuto, prefsPath }; - } - return { - autoMode: resolveConfiguredTtsAutoMode(raw), - prefsPath, - }; -} - -export function buildTtsSystemPromptHint( - cfgInput: OpenClawConfig, - agentId?: string, -): string | undefined { - let cfg = cfgInput; - cfg = resolveTtsRuntimeConfig(cfg); - const { autoMode, prefsPath } = resolveEffectiveTtsAutoState({ cfg, agentId }); - if (autoMode === "off") { - return undefined; - } - const configForTest = resolveTtsConfig(cfg, agentId); - const persona = getTtsPersona(configForTest, prefsPath); - const maxLength = getTtsMaxLength(prefsPath); - const summarize = isSummarizationEnabled(prefsPath) ? "on" : "off"; - const autoHint = - autoMode === "inbound" - ? "Only use TTS when the user's last message includes audio/voice." - : autoMode === "tagged" - ? "Only use TTS when you include [[tts:key=value]] directives or a [[tts:text]]...[[/tts:text]] block." - : undefined; - return [ - "Voice (TTS) is enabled.", - autoHint, - persona - ? `Active TTS persona: ${persona.label ?? persona.id}${persona.description ? ` - ${persona.description}` : ""}.` - : undefined, - `Keep spoken text ≤${maxLength} chars to avoid auto-summary (summary ${summarize}).`, - "If workspace context (especially MEMORY.md) tells you not to use [[tts:...]] or to use a local/non-tagged voice workflow, follow that workspace instruction instead.", - "Use [[tts:...]] and optional [[tts:text]]...[[/tts:text]] to control voice/expressiveness.", - ] - .filter(Boolean) - .join("\n"); -} - -function readPrefs(prefsPath: string): TtsUserPrefs { - try { - if (!existsSync(prefsPath)) { - return {}; - } - return JSON.parse(readFileSync(prefsPath, "utf8")) as TtsUserPrefs; - } catch { - return {}; - } -} - -function atomicWriteFileSync(filePath: string, content: string): void { - privateFileStoreSync(path.dirname(filePath)).writeText(path.basename(filePath), content); -} - -function updatePrefs(prefsPath: string, update: (prefs: TtsUserPrefs) => void): void { - const prefs = readPrefs(prefsPath); - update(prefs); - atomicWriteFileSync(prefsPath, JSON.stringify(prefs, null, 2)); -} - -export function isTtsEnabled( - config: ResolvedTtsConfig, - prefsPath: string, - sessionAuto?: string, -): boolean { - return resolveTtsAutoMode({ config, prefsPath, sessionAuto }) !== "off"; -} - -export function setTtsAutoMode(prefsPath: string, mode: TtsAutoMode): void { - updatePrefs(prefsPath, (prefs) => { - const next = { ...prefs.tts }; - delete next.enabled; - next.auto = mode; - prefs.tts = next; - }); -} - -export function setTtsEnabled(prefsPath: string, enabled: boolean): void { - setTtsAutoMode(prefsPath, enabled ? "always" : "off"); -} - export function getTtsProvider(config: ResolvedTtsConfig, prefsPath: string): TtsProvider { const prefs = readPrefs(prefsPath); const prefsProvider = @@ -811,44 +466,6 @@ export function getTtsProvider(config: ResolvedTtsConfig, prefsPath: string): Tt return config.provider; } -function resolveTtsPersonaFromPrefs( - config: ResolvedTtsConfig, - prefs: TtsUserPrefs, -): ResolvedTtsPersona | undefined { - if (prefs.tts && hasOwnProperty(prefs.tts, "persona")) { - const prefsPersona = normalizeTtsPersonaId(prefs.tts.persona); - return prefsPersona ? config.personas[prefsPersona] : undefined; - } - const configPersona = normalizeTtsPersonaId(config.persona); - return configPersona ? config.personas[configPersona] : undefined; -} - -export function getTtsPersona( - config: ResolvedTtsConfig, - prefsPath: string, -): ResolvedTtsPersona | undefined { - return resolveTtsPersonaFromPrefs(config, readPrefs(prefsPath)); -} - -export function listTtsPersonas(config: ResolvedTtsConfig): ResolvedTtsPersona[] { - return Object.values(config.personas).toSorted((left, right) => left.id.localeCompare(right.id)); -} - -export function setTtsPersona(prefsPath: string, persona: string | null | undefined): void { - updatePrefs(prefsPath, (prefs) => { - const next = { ...prefs.tts }; - const normalized = normalizeTtsPersonaId(persona); - next.persona = normalized ?? null; - prefs.tts = next; - }); -} - -export function setTtsProvider(prefsPath: string, provider: TtsProvider): void { - updatePrefs(prefsPath, (prefs) => { - prefs.tts = { ...prefs.tts, provider: canonicalizeSpeechProviderId(provider) ?? provider }; - }); -} - export function resolveExplicitTtsOverrides(params: { cfg: OpenClawConfig; prefsPath?: string; @@ -917,28 +534,6 @@ export function resolveExplicitTtsOverrides(params: { }; } -export function getTtsMaxLength(prefsPath: string): number { - const prefs = readPrefs(prefsPath); - return prefs.tts?.maxLength ?? DEFAULT_TTS_MAX_LENGTH; -} - -export function setTtsMaxLength(prefsPath: string, maxLength: number): void { - updatePrefs(prefsPath, (prefs) => { - prefs.tts = { ...prefs.tts, maxLength }; - }); -} - -export function isSummarizationEnabled(prefsPath: string): boolean { - const prefs = readPrefs(prefsPath); - return prefs.tts?.summarize ?? DEFAULT_TTS_SUMMARIZE; -} - -export function setSummarizationEnabled(prefsPath: string, enabled: boolean): void { - updatePrefs(prefsPath, (prefs) => { - prefs.tts = { ...prefs.tts, summarize: enabled }; - }); -} - export function getLastTtsAttempt(): TtsStatusEntry | undefined { return lastTtsAttempt; } @@ -1955,7 +1550,7 @@ export async function maybeApplyTtsToPayload(params: { return params.payload; } const cfg = resolveTtsRuntimeConfig(params.cfg); - const { autoMode, prefsPath } = resolveEffectiveTtsAutoState({ + const { autoMode, config, prefsPath } = resolveTtsSettingsSnapshot({ cfg, sessionAuto: params.ttsAuto, agentId: params.agentId, @@ -1965,11 +1560,6 @@ export async function maybeApplyTtsToPayload(params: { if (autoMode === "off") { return params.payload; } - const config = resolveTtsConfig(cfg, { - agentId: params.agentId, - channelId: params.channel, - accountId: params.accountId, - }); const activeProvider = getTtsProvider(config, prefsPath); const reply = resolveSendableOutboundReplyParts(params.payload); diff --git a/scripts/lib/plugin-sdk-doc-metadata.ts b/scripts/lib/plugin-sdk-doc-metadata.ts index 7c8ba5fd3665..74c9bb20a3e6 100644 --- a/scripts/lib/plugin-sdk-doc-metadata.ts +++ b/scripts/lib/plugin-sdk-doc-metadata.ts @@ -147,6 +147,9 @@ export const pluginSdkDocMetadata = { "speech-core": { category: "provider", }, + "speech-settings": { + category: "provider", + }, "realtime-voice": { category: "provider", }, diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index 2254c0828968..1f8083deddf8 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -95,6 +95,7 @@ "agent-runtime", "simple-completion-runtime", "speech-core", + "speech-settings", "tts-runtime", "plugin-runtime", "skills-runtime", diff --git a/scripts/plugin-sdk-surface-report.mjs b/scripts/plugin-sdk-surface-report.mjs index cfeafdd8170e..3f9c669861bc 100644 --- a/scripts/plugin-sdk-surface-report.mjs +++ b/scripts/plugin-sdk-surface-report.mjs @@ -210,7 +210,8 @@ export function readPluginSdkSurfaceBudgets(env = process.env) { publicEntrypoints: readPluginSdkSurfaceBudgetEnv( "OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_ENTRYPOINTS", // Registry sweep: 77 packages, zero fetch failures; retired dead channel-ingress facade. - 328, + // +1: speech-settings keeps agent prompt imports off the synthesis/runtime graph. + 329, env, ), // ScopeTree adds six channel-policy exports, mirrored by compat, including three functions. @@ -242,9 +243,10 @@ export function readPluginSdkSurfaceBudgets(env = process.env) { // +1: shared speech-provider API key resolver. // +32: shared channel setup, config-schema, policy, and status helpers. // +2: shared channel replay-guard factory and claim handle. + // +6: lightweight speech settings types, normalizers, and config resolver. // Harvest: retired AudioConfig type -1. // +6: shared progress receipt tracker + compositor snapshot across channel barrels. - 7991, + 7997, env, ), publicFunctionExports: readPluginSdkSurfaceBudgetEnv( @@ -266,7 +268,8 @@ export function readPluginSdkSurfaceBudgets(env = process.env) { // +24: shared channel setup, config-schema, policy, and status helpers. // +1: shared channel replay-guard factory. // +3: receipt tracker/snapshot callables across channel barrels. - 4468, + // +3: lightweight speech settings normalizers and config resolver. + 4471, env, ), publicDeprecatedExports: readPluginSdkSurfaceBudgetEnv( diff --git a/src/agents/cli-runner.reliability.test.ts b/src/agents/cli-runner.reliability.test.ts index b8f438e15522..cb2e42f5cdf5 100644 --- a/src/agents/cli-runner.reliability.test.ts +++ b/src/agents/cli-runner.reliability.test.ts @@ -85,7 +85,7 @@ vi.mock("../skills/research/autocapture.js", () => ({ runSkillResearchAutoCapture: vi.fn(async () => undefined), })); -vi.mock("../tts/tts.js", () => ({ +vi.mock("../tts/tts-settings.js", () => ({ buildTtsSystemPromptHint: vi.fn(() => undefined), })); diff --git a/src/agents/cli-runner/helpers.system-prompt.test.ts b/src/agents/cli-runner/helpers.system-prompt.test.ts index e63871ac97bb..d07c3fd216be 100644 --- a/src/agents/cli-runner/helpers.system-prompt.test.ts +++ b/src/agents/cli-runner/helpers.system-prompt.test.ts @@ -3,7 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { clearPluginCommands, registerPluginCommand } from "../../plugins/commands.js"; import { buildCliAgentSystemPrompt } from "./helpers.js"; -vi.mock("../../tts/tts.js", () => ({ +vi.mock("../../tts/tts-settings.js", () => ({ buildTtsSystemPromptHint: vi.fn(() => undefined), })); diff --git a/src/agents/cli-runner/prepare.test.ts b/src/agents/cli-runner/prepare.test.ts index a9678cd1b781..e06fe24184e1 100644 --- a/src/agents/cli-runner/prepare.test.ts +++ b/src/agents/cli-runner/prepare.test.ts @@ -75,7 +75,7 @@ vi.mock("../../plugin-sdk/anthropic-cli.js", () => ({ isClaudeCliProvider: (providerId: string) => providerId === "claude-cli", })); -vi.mock("../../tts/tts.js", () => ({ +vi.mock("../../tts/tts-settings.js", () => ({ buildTtsSystemPromptHint: vi.fn(() => undefined), })); diff --git a/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.test-support.ts b/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.test-support.ts index 56b35b97a6ce..e0611070c409 100644 --- a/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.test-support.ts +++ b/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.test-support.ts @@ -444,7 +444,7 @@ vi.mock("../../../infra/net/undici-global-dispatcher.js", () => ({ hoisted.ensureGlobalUndiciStreamTimeoutsMock(...args), })); -vi.mock("../../../tts/tts.js", () => ({ +vi.mock("../../../tts/tts-settings.js", () => ({ buildTtsSystemPromptHint: () => undefined, })); diff --git a/src/agents/embedded-agent-runner/system-prompt.test.ts b/src/agents/embedded-agent-runner/system-prompt.test.ts index 5f565a52bcfe..202439a8f166 100644 --- a/src/agents/embedded-agent-runner/system-prompt.test.ts +++ b/src/agents/embedded-agent-runner/system-prompt.test.ts @@ -8,7 +8,7 @@ import { import type { AgentSession } from "../sessions/index.js"; import { applySystemPromptToSession, buildEmbeddedSystemPrompt } from "./system-prompt.js"; -vi.mock("../../tts/tts.js", () => ({ +vi.mock("../../tts/tts-settings.js", () => ({ buildTtsSystemPromptHint: vi.fn(() => undefined), })); diff --git a/src/agents/system-prompt-config.test.ts b/src/agents/system-prompt-config.test.ts index f10b0c9c2185..f23f1f9389e2 100644 --- a/src/agents/system-prompt-config.test.ts +++ b/src/agents/system-prompt-config.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { buildConfiguredAgentSystemPrompt } from "./system-prompt-config.js"; -vi.mock("../tts/tts.js", () => ({ +vi.mock("../tts/tts-settings.js", () => ({ buildTtsSystemPromptHint: vi.fn(() => undefined), })); diff --git a/src/agents/system-prompt-config.ts b/src/agents/system-prompt-config.ts index 9995a80b90d1..c95e2829bdc4 100644 --- a/src/agents/system-prompt-config.ts +++ b/src/agents/system-prompt-config.ts @@ -5,7 +5,7 @@ * prompt so callers do not duplicate owner, TTS, alias, memory, or FS policy. */ import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { buildTtsSystemPromptHint } from "../tts/tts.js"; +import { buildTtsSystemPromptHint } from "../tts/tts-settings.js"; import { resolveAgentConfig } from "./agent-scope.js"; import { buildModelAliasLines } from "./model-alias-lines.js"; import { resolveOwnerDisplaySetting } from "./owner-display.js"; diff --git a/src/auto-reply/reply/commands-system-prompt.test.ts b/src/auto-reply/reply/commands-system-prompt.test.ts index d8f335d7cd05..7aaabe8ce990 100644 --- a/src/auto-reply/reply/commands-system-prompt.test.ts +++ b/src/auto-reply/reply/commands-system-prompt.test.ts @@ -70,7 +70,7 @@ vi.mock("../../agents/agent-tools.js", () => ({ createOpenClawCodingTools: createOpenClawCodingToolsMock, })); -vi.mock("../../tts/tts.js", () => ({ +vi.mock("../../tts/tts-settings.js", () => ({ buildTtsSystemPromptHint: vi.fn(() => undefined), })); diff --git a/src/plugin-sdk/speech-settings.ts b/src/plugin-sdk/speech-settings.ts new file mode 100644 index 000000000000..34b4e5d3fb20 --- /dev/null +++ b/src/plugin-sdk/speech-settings.ts @@ -0,0 +1,7 @@ +// Lightweight speech settings primitives for package-owned TTS configuration. +// Keep provider registries and synthesis runtimes out of this entrypoint. + +export type { ResolvedTtsConfig, ResolvedTtsModelOverrides } from "../tts/tts-types.js"; +export { normalizeSpeechProviderId } from "../tts/provider-registry-core.js"; +export { normalizeTtsAutoMode } from "../tts/tts-auto-mode.js"; +export { resolveEffectiveTtsConfig, type TtsConfigResolutionContext } from "../tts/tts-config.js"; diff --git a/src/tts/status-config.test.ts b/src/tts/status-config.test.ts index 56a027a7d2e7..ab4f5557da56 100644 --- a/src/tts/status-config.test.ts +++ b/src/tts/status-config.test.ts @@ -35,6 +35,33 @@ async function withStatusTempHome(run: (home: string) => Promise): Promise } describe("resolveStatusTtsSnapshot", () => { + it("treats null prefs as empty settings", async () => { + await withStatusTempHome(async (home) => { + const prefsPath = path.join(home, ".openclaw", "settings", "tts.json"); + fs.mkdirSync(path.dirname(prefsPath), { recursive: true }); + fs.writeFileSync(prefsPath, "null"); + + expect( + resolveStatusTtsSnapshot({ + cfg: { + messages: { + tts: { + auto: "always", + provider: "edge", + prefsPath, + }, + }, + } as OpenClawConfig, + }), + ).toEqual({ + autoMode: "always", + provider: "microsoft", + maxLength: 1500, + summarize: true, + }); + }); + }); + it("uses prefs overrides without loading speech providers", async () => { await withStatusTempHome(async (home) => { const prefsPath = path.join(home, ".openclaw", "settings", "tts.json"); diff --git a/src/tts/status-config.ts b/src/tts/status-config.ts index d5a55c5e8555..6f0bccb34f9e 100644 --- a/src/tts/status-config.ts +++ b/src/tts/status-config.ts @@ -1,34 +1,13 @@ // TTS status config helpers resolve status output paths for speech generation. -import path from "node:path"; import { isRecord as isObjectRecord } from "@openclaw/normalization-core/record-coerce"; -import { - normalizeOptionalLowercaseString, - normalizeOptionalString, -} from "@openclaw/normalization-core/string-coerce"; import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import type { OpenClawConfig } from "../config/types.js"; import type { TtsAutoMode, TtsConfig, TtsProvider } from "../config/types.tts.js"; -import { tryReadJsonSync } from "../infra/json-files.js"; -import { resolveConfigDir, resolveUserPath } from "../utils.js"; -import { normalizeTtsAutoMode } from "./tts-auto-mode.js"; -import { resolveEffectiveTtsConfig, type TtsConfigResolutionContext } from "./tts-config.js"; +import { resolveTtsSettingsSnapshot } from "./tts-settings.js"; -const DEFAULT_TTS_MAX_LENGTH = 1500; -const DEFAULT_TTS_SUMMARIZE = true; const DEFAULT_OPENAI_TTS_BASE_URL = "https://api.openai.com/v1"; const MAX_STATUS_DETAIL_LENGTH = 96; -type TtsUserPrefs = { - tts?: { - auto?: TtsAutoMode; - enabled?: boolean; - provider?: TtsProvider; - persona?: string | null; - maxLength?: number; - summarize?: boolean; - }; -}; - type TtsStatusSnapshot = { autoMode: TtsAutoMode; provider: TtsProvider; @@ -42,68 +21,6 @@ type TtsStatusSnapshot = { summarize: boolean; }; -function resolveConfiguredTtsAutoMode(raw: TtsConfig): TtsAutoMode { - return normalizeTtsAutoMode(raw.auto) ?? (raw.enabled ? "always" : "off"); -} - -function normalizeConfiguredSpeechProviderId( - providerId: string | undefined, -): TtsProvider | undefined { - const normalized = normalizeOptionalLowercaseString(providerId); - if (!normalized) { - return undefined; - } - return normalized === "edge" ? "microsoft" : normalized; -} - -function normalizeTtsPersonaId(personaId: string | null | undefined): string | undefined { - return normalizeOptionalLowercaseString(personaId ?? undefined); -} - -function resolvePersonaPreferredProvider( - raw: TtsConfig, - personaId: string | undefined, -): TtsProvider | undefined { - if (!personaId || !raw.personas) { - return undefined; - } - for (const [id, persona] of Object.entries(raw.personas)) { - if (normalizeTtsPersonaId(id) !== personaId) { - continue; - } - const provider = normalizeConfiguredSpeechProviderId(persona.provider) ?? persona.provider; - return normalizeOptionalString(provider); - } - return undefined; -} - -function resolveTtsPrefsPathValue(prefsPath: string | undefined): string { - const configuredPath = normalizeOptionalString(prefsPath); - if (configuredPath) { - return resolveUserPath(configuredPath); - } - const envPath = normalizeOptionalString(process.env.OPENCLAW_TTS_PREFS); - if (envPath) { - return resolveUserPath(envPath); - } - return path.join(resolveConfigDir(process.env), "settings", "tts.json"); -} - -function readPrefs(prefsPath: string): TtsUserPrefs { - return tryReadJsonSync(prefsPath) ?? {}; -} - -function resolveTtsAutoModeFromPrefs(prefs: TtsUserPrefs): TtsAutoMode | undefined { - const auto = normalizeTtsAutoMode(prefs.tts?.auto); - if (auto) { - return auto; - } - if (typeof prefs.tts?.enabled === "boolean") { - return prefs.tts.enabled ? "always" : "off"; - } - return undefined; -} - function normalizeStatusDetail( value: unknown, maxLength = MAX_STATUS_DETAIL_LENGTH, @@ -225,39 +142,18 @@ export function resolveStatusTtsSnapshot(params: { channelId?: string; accountId?: string; }): TtsStatusSnapshot | null { - const context: TtsConfigResolutionContext = { - agentId: params.agentId, - channelId: params.channelId, - accountId: params.accountId, - }; - const raw: TtsConfig = resolveEffectiveTtsConfig(params.cfg, context); - const prefsPath = resolveTtsPrefsPathValue(raw.prefsPath); - const prefs = readPrefs(prefsPath); - const autoMode = - normalizeTtsAutoMode(params.sessionAuto) ?? - resolveTtsAutoModeFromPrefs(prefs) ?? - resolveConfiguredTtsAutoMode(raw); - - if (autoMode === "off") { + const settings = resolveTtsSettingsSnapshot(params); + if (settings.autoMode === "off") { return null; } - - const persona = - prefs.tts && Object.hasOwn(prefs.tts, "persona") - ? normalizeTtsPersonaId(prefs.tts.persona) - : normalizeTtsPersonaId(raw.persona); - const provider = - normalizeConfiguredSpeechProviderId(prefs.tts?.provider) ?? - resolvePersonaPreferredProvider(raw, persona) ?? - normalizeConfiguredSpeechProviderId(raw.provider) ?? - "auto"; + const provider = settings.preferredProvider ?? "auto"; return { - autoMode, + autoMode: settings.autoMode, provider, - ...resolveStatusProviderDetails(raw, provider), - ...(persona ? { persona } : {}), - maxLength: prefs.tts?.maxLength ?? DEFAULT_TTS_MAX_LENGTH, - summarize: prefs.tts?.summarize ?? DEFAULT_TTS_SUMMARIZE, + ...resolveStatusProviderDetails(settings.config.rawConfig ?? {}, provider), + ...(settings.personaId ? { persona: settings.personaId } : {}), + maxLength: settings.maxLength, + summarize: settings.summarize, }; } diff --git a/src/tts/tts-core.test.ts b/src/tts/tts-core.test.ts index 54cc6149499d..e9cec008a427 100644 --- a/src/tts/tts-core.test.ts +++ b/src/tts/tts-core.test.ts @@ -1,4 +1,5 @@ // TTS core tests cover provider selection, synthesis, and error handling. +import { readFileSync } from "node:fs"; import { describe, expect, it, vi } from "vitest"; import type { AssistantMessage, Model, Usage } from "../llm/types.js"; import { MAX_TIMER_TIMEOUT_MS } from "../shared/number-coercion.js"; @@ -33,6 +34,16 @@ const usage: Usage = { }; describe("TTS core", () => { + it("keeps summarization-only LLM modules lazy", () => { + const source = readFileSync(new URL("./tts-core.ts", import.meta.url), "utf8"); + + expect(source).toContain('import("../llm/stream.js")'); + expect(source).toContain('import("../agents/simple-completion-runtime.js")'); + expect(source).not.toContain('from "../llm/stream.js"'); + expect(source).not.toContain('from "../agents/simple-completion-runtime.js"'); + expect(source).not.toContain('from "../agents/model-auth.js"'); + }); + it("resolves the first non-blank speech provider API key", () => { expect(resolveSpeechProviderApiKey(undefined, " \t", " provider-key ", "fallback")).toBe( "provider-key", diff --git a/src/tts/tts-core.ts b/src/tts/tts-core.ts index 7ea76eee22f5..eb78f7fd4652 100644 --- a/src/tts/tts-core.ts +++ b/src/tts/tts-core.ts @@ -1,15 +1,12 @@ // TTS core coordinates text preparation, provider selection, and speech output. import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; -import { requireApiKey } from "../agents/model-auth.js"; import { buildModelAliasIndex, resolveDefaultModelForAgent, resolveModelRefFromString, type ModelRef, } from "../agents/model-selection.js"; -import { prepareSimpleCompletionModel } from "../agents/simple-completion-runtime.js"; import type { OpenClawConfig } from "../config/types.js"; -import { completeSimple } from "../llm/stream.js"; import type { TextContent } from "../llm/types.js"; import { resolveTimerTimeoutMs } from "../shared/number-coercion.js"; import type { ResolvedTtsConfig } from "./tts-types.js"; @@ -23,17 +20,25 @@ export { } from "./tts-provider-helpers.js"; type SummarizeTextDeps = { - completeSimple: typeof completeSimple; - prepareSimpleCompletionModel: typeof prepareSimpleCompletionModel; - requireApiKey: typeof requireApiKey; + completeSimple: typeof import("../llm/stream.js").completeSimple; + prepareSimpleCompletionModel: typeof import("../agents/simple-completion-runtime.js").prepareSimpleCompletionModel; + requireApiKey: typeof import("../agents/model-auth.js").requireApiKey; }; -function resolveDefaultSummarizeTextDeps(): SummarizeTextDeps { - return { - completeSimple, - prepareSimpleCompletionModel, +let defaultSummarizeTextDepsPromise: Promise | undefined; + +function loadDefaultSummarizeTextDeps(): Promise { + // Speech provider imports should not initialize the LLM stack. Load it only + // when synthesis actually needs summarization, then reuse the module bindings. + return (defaultSummarizeTextDepsPromise ??= Promise.all([ + import("../llm/stream.js"), + import("../agents/simple-completion-runtime.js"), + import("../agents/model-auth.js"), + ]).then(([stream, completionRuntime, { requireApiKey }]) => ({ + completeSimple: stream.completeSimple, + prepareSimpleCompletionModel: completionRuntime.prepareSimpleCompletionModel, requireApiKey, - }; + }))); } type SummarizeResult = { @@ -83,7 +88,7 @@ export async function summarizeText( config: ResolvedTtsConfig; timeoutMs: number; }, - deps: SummarizeTextDeps = resolveDefaultSummarizeTextDeps(), + deps?: SummarizeTextDeps, ): Promise { const { text, targetLength, cfg, config, timeoutMs } = params; if (targetLength < 100 || targetLength > 10_000) { @@ -91,10 +96,11 @@ export async function summarizeText( } const startTime = Date.now(); + const resolvedDeps = deps ?? (await loadDefaultSummarizeTextDeps()); const { ref } = resolveSummaryModelRef(cfg, config); // Dynamic model discovery precedes the request timeout, matching the established // summarization contract. The timeout below bounds only the completion request. - const prepared = await deps.prepareSimpleCompletionModel({ + const prepared = await resolvedDeps.prepareSimpleCompletionModel({ cfg, provider: ref.provider, modelId: ref.model, @@ -104,7 +110,7 @@ export async function summarizeText( throw new Error(prepared.error); } const completionModel = prepared.model; - const apiKey = deps.requireApiKey(prepared.auth, ref.provider); + const providerKey = resolvedDeps.requireApiKey(prepared.auth, ref.provider); try { const controller = new AbortController(); @@ -114,7 +120,7 @@ export async function summarizeText( try { // Keep summarization on the simple-completion path so provider auth, // aliases, and timeout behavior match other lightweight model calls. - const res = await deps.completeSimple( + const res = await resolvedDeps.completeSimple( completionModel, { messages: [ @@ -130,7 +136,7 @@ export async function summarizeText( ], }, { - apiKey, + apiKey: providerKey, maxTokens: Math.ceil(targetLength / 2), temperature: 0.3, signal: controller.signal, diff --git a/src/tts/tts-settings.ts b/src/tts/tts-settings.ts new file mode 100644 index 000000000000..a30c9c550262 --- /dev/null +++ b/src/tts/tts-settings.ts @@ -0,0 +1,5 @@ +// Lightweight core facade for TTS settings used by agent and status hot paths. +export { + buildTtsSystemPromptHint, + resolveTtsSettingsSnapshot, +} from "../../packages/speech-core/src/tts-settings.js"; diff --git a/src/tts/tts.test.ts b/src/tts/tts.test.ts index 2c85ad886fa0..06f025b45d90 100644 --- a/src/tts/tts.test.ts +++ b/src/tts/tts.test.ts @@ -16,4 +16,18 @@ describe("tts runtime facade", () => { expect(runtimeFacadeSource).toContain('from "../../packages/speech-core/runtime-api.js";'); expect(runtimeFacadeSource).not.toContain('dirName: "speech-core"'); }); + + it("keeps agent prompt TTS settings off the synthesis runtime chain", () => { + const agentConfigSource = readSource("../agents/system-prompt-config.ts"); + const settingsFacadeSource = readSource("./tts-settings.ts"); + const packageSettingsSource = readSource("../../packages/speech-core/src/tts-settings.ts"); + + expect(agentConfigSource).toContain('from "../tts/tts-settings.js";'); + expect(settingsFacadeSource).toContain( + 'from "../../packages/speech-core/src/tts-settings.js";', + ); + expect(settingsFacadeSource).not.toContain("tts-runtime"); + expect(packageSettingsSource).toContain('from "openclaw/plugin-sdk/speech-settings";'); + expect(packageSettingsSource).not.toContain("plugin-sdk/media-runtime"); + }); }); diff --git a/src/tts/tts.ts b/src/tts/tts.ts index 71819b1114fd..dc2ecddee26b 100644 --- a/src/tts/tts.ts +++ b/src/tts/tts.ts @@ -3,7 +3,6 @@ * Implementation stays in plugin-sdk/tts-runtime so provider surfaces share one contract. */ export { - buildTtsSystemPromptHint, getLastTtsAttempt, getResolvedSpeechProviderConfig, getTtsMaxLength,