improve: reduce agent startup cost from TTS imports (#109344)

* perf(tts): split lightweight settings imports

* fix(tts): drop redundant internal exports

* fix(tts): remove unused settings type import

* docs(sdk): refresh speech settings API baseline

* fix(tts): align SDK surface gates

* fix(tts): ignore non-object preference roots
This commit is contained in:
Peter Steinberger
2026-07-16 15:50:59 -07:00
committed by GitHub
parent a70d583f24
commit 6d20432a29
28 changed files with 604 additions and 587 deletions
@@ -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
+1
View File
@@ -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 |
+1
View File
@@ -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 |
+4
View File
@@ -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"
+15 -11
View File
@@ -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,
@@ -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 };
});
}
+391
View File
@@ -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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
export function hasOwnProperty(value: object, key: string): boolean {
return Object.hasOwn(value, key);
}
function normalizeProviderConfigMap(
value: unknown,
): Record<string, SpeechProviderConfig> | undefined {
const rawMap = asProviderConfigMap(value);
if (Object.keys(rawMap).length === 0) {
return undefined;
}
const next: Record<string, SpeechProviderConfig> = {};
for (const [providerId, providerConfig] of Object.entries(rawMap)) {
const normalized = normalizeConfiguredSpeechProviderId(providerId) ?? providerId;
next[normalized] = asProviderConfig(providerConfig);
}
return next;
}
function collectTtsPersonas(raw: TtsConfig): Record<string, ResolvedTtsPersona> {
const rawPersonas = asProviderConfigMap(raw.personas);
const personas: Record<string, ResolvedTtsPersona> = {};
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<ResolvedTtsPersona, "id">;
personas[normalizedId] = {
...persona,
id: normalizedId,
provider: normalizeConfiguredSpeechProviderId(persona.provider) ?? persona.provider,
providers: normalizeProviderConfigMap(persona.providers),
};
}
return personas;
}
function collectDirectProviderConfigEntries(raw: TtsConfig): Record<string, SpeechProviderConfig> {
const entries: Record<string, SpeechProviderConfig> = {};
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<string, unknown>)) {
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;
}
+1 -1
View File
@@ -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;
+22 -432
View File
@@ -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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function hasOwnProperty(value: object, key: string): boolean {
return Object.hasOwn(value, key);
}
function normalizeProviderConfigMap(
value: unknown,
): Record<string, SpeechProviderConfig> | undefined {
const rawMap = asProviderConfigMap(value);
if (Object.keys(rawMap).length === 0) {
return undefined;
}
const next: Record<string, SpeechProviderConfig> = {};
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<string, ResolvedTtsPersona> {
const rawPersonas = asProviderConfigMap(raw.personas);
const personas: Record<string, ResolvedTtsPersona> = {};
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<ResolvedTtsPersona, "id">;
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<string, SpeechProviderConfig> {
const entries: Record<string, SpeechProviderConfig> = {};
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<string, unknown>)) {
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);
+3
View File
@@ -147,6 +147,9 @@ export const pluginSdkDocMetadata = {
"speech-core": {
category: "provider",
},
"speech-settings": {
category: "provider",
},
"realtime-voice": {
category: "provider",
},
+1
View File
@@ -95,6 +95,7 @@
"agent-runtime",
"simple-completion-runtime",
"speech-core",
"speech-settings",
"tts-runtime",
"plugin-runtime",
"skills-runtime",
+6 -3
View File
@@ -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(
+1 -1
View File
@@ -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),
}));
@@ -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),
}));
+1 -1
View File
@@ -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),
}));
@@ -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,
}));
@@ -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),
}));
+1 -1
View File
@@ -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),
}));
+1 -1
View File
@@ -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";
@@ -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),
}));
+7
View File
@@ -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";
+27
View File
@@ -35,6 +35,33 @@ async function withStatusTempHome(run: (home: string) => Promise<void>): 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");
+9 -113
View File
@@ -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<TtsUserPrefs>(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,
};
}
+11
View File
@@ -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",
+22 -16
View File
@@ -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<SummarizeTextDeps> | undefined;
function loadDefaultSummarizeTextDeps(): Promise<SummarizeTextDeps> {
// 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<SummarizeResult> {
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,
+5
View File
@@ -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";
+14
View File
@@ -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");
});
});
-1
View File
@@ -3,7 +3,6 @@
* Implementation stays in plugin-sdk/tts-runtime so provider surfaces share one contract.
*/
export {
buildTtsSystemPromptHint,
getLastTtsAttempt,
getResolvedSpeechProviderConfig,
getTtsMaxLength,