Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/code/src/main/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,12 @@ app.commandLine.appendSwitch("enable-logging", "file");
app.commandLine.appendSwitch("log-file", chromiumLogPath);
app.commandLine.appendSwitch("log-level", "0");

// Allow programmatic audio playback without a prior user gesture. The agent
// speaks (and completion sounds ring) autonomously, with no click at that
// moment, so Chromium's default gesture requirement would silently reject
// HTMLMediaElement.play(). Must be set before app "ready".
app.commandLine.appendSwitch("autoplay-policy", "no-user-gesture-required");

// In dev, expose the renderer over CDP (:9222 by default) for the
// test-electron-app skill. electron-vite launches Electron itself, so this is
// set in-process rather than via a CLI flag. POSTHOG_CODE_CDP_PORT matches the
Expand Down
5 changes: 5 additions & 0 deletions apps/code/src/main/di/bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,10 @@ import type {
ISecureStoreService,
SECURE_STORE_SERVICE,
} from "@posthog/workspace-server/services/secure-store/identifiers";
import type {
ISpeechSynthesizer,
SPEECH_SYNTHESIZER_SERVICE,
} from "@posthog/workspace-server/services/speech/identifiers";
import type {
SUSPENSION_FILE_WATCHER,
SUSPENSION_SERVICE,
Expand Down Expand Up @@ -460,6 +464,7 @@ export interface MainBindings {
[MAIN_SECURE_STORE_BACKEND]: typeof rendererStore;
[MAIN_SECURE_STORE_SERVICE]: SecureStoreService;
[SECURE_STORE_SERVICE]: ISecureStoreService;
[SPEECH_SYNTHESIZER_SERVICE]: ISpeechSynthesizer;
[LOGS_SERVICE]: ILogsService;
[MAIN_ENCRYPTION_SERVICE]: EncryptionService;
[MAIN_DISCORD_PRESENCE_SERVICE]: DiscordPresenceService;
Expand Down
6 changes: 6 additions & 0 deletions apps/code/src/main/di/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ import { SECURE_STORE_SERVICE } from "@posthog/workspace-server/services/secure-
import { shellModule } from "@posthog/workspace-server/services/shell/shell.module";
import { skillsModule } from "@posthog/workspace-server/services/skills/skills.module";
import { skillsMarketplaceModule } from "@posthog/workspace-server/services/skills-marketplace/skills-marketplace.module";
import { SPEECH_SYNTHESIZER_SERVICE } from "@posthog/workspace-server/services/speech/identifiers";
import {
SUSPENSION_FILE_WATCHER,
SUSPENSION_SERVICE,
Expand Down Expand Up @@ -258,6 +259,7 @@ import { DiscordPresenceService } from "../services/discord-presence/service";
import { EncryptionService } from "../services/encryption/service";
import { SecureStoreService } from "../services/secure-store/service";
import { settingsStore } from "../services/settingsStore";
import { ElevenLabsSpeechService } from "../services/speech/service";
import { WorkspaceServerService } from "../services/workspace-server/service";
import { getUserDataDir, isDevBuild } from "../utils/env";
import { logger } from "../utils/logger";
Expand Down Expand Up @@ -723,6 +725,10 @@ container
.to(SecureStoreService)
.inSingletonScope();
container.bind(SECURE_STORE_SERVICE).toService(MAIN_SECURE_STORE_SERVICE);
container
.bind(SPEECH_SYNTHESIZER_SERVICE)
.to(ElevenLabsSpeechService)
.inSingletonScope();
container.bind(LOGS_SERVICE).toDynamicValue((ctx) => {
const ws = ctx.get<WorkspaceClient>(MAIN_WORKSPACE_CLIENT);
return {
Expand Down
96 changes: 96 additions & 0 deletions apps/code/src/main/services/speech/service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { logger } from "@main/utils/logger";
import {
type ISecureStoreService,
SECURE_STORE_SERVICE,
} from "@posthog/workspace-server/services/secure-store/identifiers";
import {
ELEVENLABS_API_KEY_STORE_KEY,
type ISpeechSynthesizer,
type SpeechSynthesisResult,
} from "@posthog/workspace-server/services/speech/identifiers";
import { inject, injectable } from "inversify";

const log = logger.scope("speech");

// Expressive default voice (Eleven v3). Overridable per call via voiceId.
const DEFAULT_VOICE_ID = "goT3UYdM9bhm0n2lmKQx";
const MODEL_ID = "eleven_v3";
const OUTPUT_FORMAT = "mp3_44100_128";
// Cache synthesized audio by voice+text so repeated lines (e.g. the "finished"
// backstop for a given task) aren't re-billed/re-fetched. Bounded LRU — audio
// is ~100KB, so this caps at a few MB.
const CACHE_MAX = 32;

/**
* ElevenLabs-backed speech synthesizer. Reads the API key from encrypted secure
* storage and returns MP3 bytes to the renderer, which plays them (host-neutral
* playback). When no key is configured or synthesis fails, returns null so the
* renderer falls back to the system voice. Best-effort — never throws.
*/
@injectable()
export class ElevenLabsSpeechService implements ISpeechSynthesizer {
// voice+text -> audioBase64; insertion-ordered for LRU eviction.
private readonly cache = new Map<string, string>();

constructor(
@inject(SECURE_STORE_SERVICE)
private readonly secureStore: ISecureStoreService,
) {}

async synthesize(
text: string,
voiceId?: string,
): Promise<SpeechSynthesisResult | null> {
const apiKey = this.secureStore.getItem(ELEVENLABS_API_KEY_STORE_KEY);
if (!apiKey) {
log.info("No ElevenLabs key configured — using system voice");
return null;
}
const voice = voiceId?.trim() || DEFAULT_VOICE_ID;

const cacheKey = `${voice}:${text}`;
const cached = this.cache.get(cacheKey);
if (cached !== undefined) {
// Refresh recency (delete + re-set moves it to the end).
this.cache.delete(cacheKey);
this.cache.set(cacheKey, cached);
log.info(`Speech cache hit (voice=${voice}, chars=${text.length})`);
return { audioBase64: cached, mimeType: "audio/mpeg" };
}

log.info(
`Synthesizing via ElevenLabs (voice=${voice}, chars=${text.length})`,
);
try {
const res = await fetch(
`https://api.elevenlabs.io/v1/text-to-speech/${voice}?output_format=${OUTPUT_FORMAT}`,
{
method: "POST",
headers: { "xi-api-key": apiKey, "content-type": "application/json" },
body: JSON.stringify({
text,
model_id: MODEL_ID,
voice_settings: { stability: 0.5 },
}),
},
);
if (!res.ok) {
const detail = await res.text().catch(() => "");
log.warn(
`ElevenLabs failed: HTTP ${res.status} ${detail.slice(0, 300)}`,
);
return null;
}
const buffer = Buffer.from(await res.arrayBuffer());
const audioBase64 = buffer.toString("base64");
this.cache.set(cacheKey, audioBase64);
if (this.cache.size > CACHE_MAX) {
this.cache.delete(this.cache.keys().next().value as string);
}
return { audioBase64, mimeType: "audio/mpeg" };
} catch (error) {
log.warn("ElevenLabs synthesis error", error);
return null;
}
}
}
2 changes: 2 additions & 0 deletions apps/code/src/main/trpc/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import { shellRouter } from "@posthog/host-router/routers/shell.router";
import { skillsRouter } from "@posthog/host-router/routers/skills.router";
import { slackIntegrationRouter } from "@posthog/host-router/routers/slack-integration.router";
import { sleepRouter } from "@posthog/host-router/routers/sleep.router";
import { speechRouter } from "@posthog/host-router/routers/speech.router";
import { suspensionRouter } from "@posthog/host-router/routers/suspension.router";
import { uiRouter } from "@posthog/host-router/routers/ui.router";
import { updatesRouter } from "@posthog/host-router/routers/updates.router";
Expand Down Expand Up @@ -98,6 +99,7 @@ export const trpcRouter = router({
suspension: suspensionRouter,
secureStore: secureStoreRouter,
shell: shellRouter,
speech: speechRouter,
skills: skillsRouter,
slackIntegration: slackIntegrationRouter,
ui: uiRouter,
Expand Down
2 changes: 2 additions & 0 deletions apps/code/src/renderer/desktop-contributions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { githubConnectModule } from "@posthog/core/integrations/githubConnect.mo
import { onboardingModule } from "@posthog/core/onboarding/onboarding.module";
import { setupCoreModule } from "@posthog/core/setup/setup.module";
import { skillsCoreModule } from "@posthog/core/skills/skills.module";
import { speechCoreModule } from "@posthog/core/speech/speech.module";
import { CONTRIBUTION } from "@posthog/di/contribution";
import { agentUiModule } from "@posthog/ui/features/agent/agent.module";
import { authUiModule } from "@posthog/ui/features/auth/auth.module";
Expand Down Expand Up @@ -50,6 +51,7 @@ export function registerDesktopContributions(): void {
setupCoreModule,
setupUiModule,
skillsCoreModule,
speechCoreModule,
workspaceUiModule,
]) {
container.load(module);
Expand Down
102 changes: 102 additions & 0 deletions apps/code/src/renderer/desktop-services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,20 @@ import {
type SessionService,
} from "@posthog/core/sessions/sessionService";
import { SETUP_STORE } from "@posthog/core/setup/identifiers";
import {
SPEECH_SETTINGS_PROVIDER,
SPEECH_USER_NAME_PROVIDER,
type SpeechSettingsProvider,
type UserNameProvider,
} from "@posthog/core/speech/identifiers";
import { resolveService } from "@posthog/di/container";
import { ROOT_LOGGER, type RootLogger } from "@posthog/di/logger";
import {
type INotifications,
NOTIFICATIONS_SERVICE,
type NotificationTarget,
} from "@posthog/platform/notifications";
import { type ISpeech, SPEECH_SERVICE } from "@posthog/platform/speech";
import {
type Adapter,
AUTORESEARCH_FLAG,
Expand All @@ -58,6 +65,7 @@ import {
AUTH_SIDE_EFFECTS,
type IAuthSideEffects,
} from "@posthog/ui/features/auth/identifiers";
import { authKeys } from "@posthog/ui/features/auth/useCurrentUser";
import {
FEATURE_FLAGS,
type FeatureFlags,
Expand All @@ -77,22 +85,36 @@ import {
ACTIVE_VIEW_PROVIDER,
type IActiveView,
type INotificationSettings,
type ISpeechNotifySettings,
NOTIFICATION_SETTINGS_PROVIDER,
SPEECH_NOTIFY_SETTINGS,
} from "@posthog/ui/features/notifications/identifiers";
import { OnboardingGithubConnectClient } from "@posthog/ui/features/onboarding/githubConnectClientImpl";
import {
AGENT_PROMPT_SENDER,
type AgentPromptSender,
} from "@posthog/ui/features/sessions/agentPromptSender";
import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore";
import {
type ISpeechKeyStore,
SPEECH_KEY_STORE,
} from "@posthog/ui/features/settings/speechKeyStore";
import { getCurrentMatches } from "@posthog/ui/router/navigationBridge";
import { HEDGEHOG_MODE_HOST } from "@posthog/ui/shell/hedgehogModeHost";
import { posthogFeatureFlags } from "@posthog/ui/shell/posthogAnalyticsImpl";
import type { ImperativeQueryClient } from "@posthog/ui/shell/queryClient";
import { IMPERATIVE_QUERY_CLIENT } from "@posthog/ui/shell/queryClient";
import {
FILE_PATH_RESOLVER,
type FilePathResolver,
} from "@posthog/ui/utils/getFilePath";
import {
isSpeechSupported,
playAudioBase64,
speakSystemVoice,
stopSpeech,
} from "@posthog/ui/utils/speech";
import { ELEVENLABS_API_KEY_STORE_KEY } from "@posthog/workspace-server/services/speech/identifiers";
import { container } from "@renderer/di/container";
import { RendererAuthSideEffects } from "@renderer/platform-adapters/auth-side-effects";
import { gitCacheKeyProvider } from "@renderer/platform-adapters/git-cache-keys";
Expand Down Expand Up @@ -329,6 +351,86 @@ container.bind<FileWatcherClient>(FILE_WATCHER_CLIENT).toConstantValue({
stop: (repoPath: string) => trpcClient.fileWatcher.stop.mutate({ repoPath }),
});

// Spoken notifications: synthesize in the host (ElevenLabs, key stays there),
// play in the renderer from a blob URL (host-neutral). Fall back to the system
// voice when no key is set or synthesis fails. speak() resolves when playback
// ends, so the core queue serializes utterances.
const speechLog = logger.scope("speech-adapter");
container.bind<ISpeech>(SPEECH_SERVICE).toConstantValue({
isSupported: () => isSpeechSupported(),
speak: async (text, opts) => {
try {
const result = await hostTrpcClient.speech.synthesize.query({
text,
voiceId: opts?.voiceId || undefined,
});
if (result?.audioBase64) {
await playAudioBase64(result.audioBase64, result.mimeType);
return;
}
} catch (err) {
speechLog.warn("Synthesis failed; using system voice", err);
}
await speakSystemVoice(text);
},
stop: () => stopSpeech(),
});

container
.bind<SpeechSettingsProvider>(SPEECH_SETTINGS_PROVIDER)
.toConstantValue({
get: () => {
const s = useSettingsStore.getState();
return {
enabled: s.spokenNotifications,
voiceId: s.elevenLabsVoiceId || undefined,
};
},
});

container.bind<UserNameProvider>(SPEECH_USER_NAME_PROVIDER).toConstantValue({
getFirstName: () => {
try {
const qc = container.get<ImperativeQueryClient>(IMPERATIVE_QUERY_CLIENT);
for (const [, data] of qc.getQueriesData({
queryKey: authKeys.currentUsers(),
})) {
const first = (
data as { first_name?: string | null } | undefined
)?.first_name?.trim();
if (first) return first;
}
} catch {
// best-effort — no name means we just skip the "Hey <name>" prefix
}
return undefined;
},
});

container.bind<ISpeechKeyStore>(SPEECH_KEY_STORE).toConstantValue({
save: (apiKey) =>
hostTrpcClient.secureStore.setItem
.query({ key: ELEVENLABS_API_KEY_STORE_KEY, value: apiKey })
.then(() => {}),
clear: () =>
hostTrpcClient.secureStore.removeItem
.query({ key: ELEVENLABS_API_KEY_STORE_KEY })
.then(() => {}),
});

container.bind<ISpeechNotifySettings>(SPEECH_NOTIFY_SETTINGS).toConstantValue({
get: () => {
const s = useSettingsStore.getState();
return {
enabled: s.spokenNotifications,
needsInput: s.spokenNotifyNeedsInput,
completion: s.spokenNotifyCompletion,
progress: s.spokenNotifyProgress,
focusMode: s.spokenFocusMode,
};
},
});

container
.bind<FeatureFlags>(FEATURE_FLAGS)
.toConstantValue(posthogFeatureFlags);
Expand Down
18 changes: 18 additions & 0 deletions apps/code/src/renderer/di/bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,12 @@ import {
import { type ISetupStore, SETUP_STORE } from "@posthog/core/setup/identifiers";
import { SKILLS_WORKSPACE_CLIENT } from "@posthog/core/skills/identifiers";
import type { SkillsWorkspaceClient } from "@posthog/core/skills/teamSkillsService";
import {
SPEECH_SETTINGS_PROVIDER,
SPEECH_USER_NAME_PROVIDER,
type SpeechSettingsProvider,
type UserNameProvider,
} from "@posthog/core/speech/identifiers";
import {
TASK_CREATION_EFFECTS,
TASK_CREATION_HOST,
Expand Down Expand Up @@ -137,6 +143,7 @@ import {
type INotifications,
NOTIFICATIONS_SERVICE,
} from "@posthog/platform/notifications";
import { type ISpeech, SPEECH_SERVICE } from "@posthog/platform/speech";
import {
AUTH_SIDE_EFFECTS,
type IAuthSideEffects,
Expand Down Expand Up @@ -188,7 +195,9 @@ import {
ACTIVE_VIEW_PROVIDER,
type IActiveView,
type INotificationSettings,
type ISpeechNotifySettings,
NOTIFICATION_SETTINGS_PROVIDER,
SPEECH_NOTIFY_SETTINGS,
} from "@posthog/ui/features/notifications/identifiers";
import {
AGENT_PROMPT_SENDER,
Expand All @@ -202,6 +211,10 @@ import {
DEV_MODE_CLIENT,
type DevModeClient,
} from "@posthog/ui/features/settings/devModeClient";
import {
type ISpeechKeyStore,
SPEECH_KEY_STORE,
} from "@posthog/ui/features/settings/speechKeyStore";
import {
SHELL_CLIENT,
type ShellClient,
Expand Down Expand Up @@ -316,6 +329,11 @@ export interface RendererBindings {
[NOTIFICATIONS_SERVICE]: INotifications;
[NOTIFICATION_SETTINGS_PROVIDER]: INotificationSettings;
[ACTIVE_VIEW_PROVIDER]: IActiveView;
[SPEECH_SERVICE]: ISpeech;
[SPEECH_SETTINGS_PROVIDER]: SpeechSettingsProvider;
[SPEECH_USER_NAME_PROVIDER]: UserNameProvider;
[SPEECH_NOTIFY_SETTINGS]: ISpeechNotifySettings;
[SPEECH_KEY_STORE]: ISpeechKeyStore;
[FILE_WATCHER_CLIENT]: FileWatcherClient;
[FEATURE_FLAGS]: FeatureFlags;
[AUTH_SIDE_EFFECTS]: IAuthSideEffects;
Expand Down
Loading
Loading