From 0f1aba036fe38400f7ae88eed034c3294d7bba94 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 12 Jun 2026 17:15:49 +0100 Subject: [PATCH] Route repo Face SAY lines through Weksa speech --- .env.example | 3 + apps/worker/src/index.ts | 97 +++++++++++++++++++++++++++ apps/worker/src/weksa-speech.ts | 115 ++++++++++++++++++++++++++++++++ packages/config/src/index.ts | 13 ++++ state/evidence.jsonl | 1 + 5 files changed, 229 insertions(+) create mode 100644 apps/worker/src/weksa-speech.ts diff --git a/.env.example b/.env.example index 6f8e9d9..8a7aeaa 100644 --- a/.env.example +++ b/.env.example @@ -67,6 +67,9 @@ REPO_FACE_RUMINATION_PASSES=3 REPO_FACE_BIRTH_MODE=plan REPO_FACE_BIRTH_EXECUTOR=codex-exec REPO_FACE_GITHUB_ACTIONS_ENABLED=false +REPO_FACE_WEKSA_SPEECH_ENABLED=false +WEKSA_DAEMON_BASE_URL=http://127.0.0.1:8813 +WEKSA_DAEMON_TIMEOUT_MS=120000 REPO_FACE_HEARTBEATS_ENABLED=false REPO_FACE_HEARTBEAT_STATE_PATH=.voidbot/status/repo-face-heartbeats.json REPO_FACE_HEARTBEAT_TASK_NAME=VoidBot Repo Face Heartbeats diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts index e005b54..4012fc5 100644 --- a/apps/worker/src/index.ts +++ b/apps/worker/src/index.ts @@ -62,8 +62,13 @@ import { parseRepoIdentityPostIntents, type RepoIdentityPostIntent, } from "./repo-face-speech.js"; +import { WeksaSpeechClient, type WeksaMimoReceipt } from "./weksa-speech.js"; const config = loadConfig(); +const weksaSpeechClient = new WeksaSpeechClient({ + baseUrl: config.repoFaceWeksaSpeech.daemonBaseUrl, + timeoutMs: config.repoFaceWeksaSpeech.timeoutMs, +}); const REPO_IDENTITY_PROPOSAL_PR_SENTINEL = "VOIDBOT_REPO_IDENTITY_PROPOSAL_PR:"; const REPO_IDENTITY_PR_COMMENT_SENTINEL = "VOIDBOT_REPO_IDENTITY_PR_COMMENT:"; const REPO_IDENTITY_UPDATE_REQUEST_SENTINEL = "VOIDBOT_REPO_IDENTITY_UPDATE_REQUEST:"; @@ -1394,6 +1399,14 @@ async function postRepoIdentityIntent(job: JobRecord, intent: RepoIdentityPostIn const message = error instanceof Error ? error.message : String(error); console.warn(`Could not record repo identity delivery receipt for job ${job.id}: ${message}`); }); + await maybeRenderRepoIdentityWeksaSpeech({ + job, + identity, + channelId, + content, + replyToMessageId: intent.replyToMessageId, + messageId: posted.messageId, + }); console.log( `Posted repo identity ${identity.id} to Discord channel ${channelId} from job ${job.id} via ${posted.transport} message ${posted.messageId}.`, ); @@ -1431,6 +1444,90 @@ async function resolveRepoIdentityForJobIntent( return findRepoDiscordIdentity(registry, identityId); } +async function maybeRenderRepoIdentityWeksaSpeech(input: { + job: JobRecord; + identity: NonNullable>; + channelId: string; + content: string; + replyToMessageId?: string; + messageId: string; +}): Promise { + if (!config.repoFaceWeksaSpeech.enabled) { + return; + } + if (!input.identity.faceStatePath) { + console.warn( + `Skipped Weksa speech for repo identity ${input.identity.id}: no faceStatePath is registered.`, + ); + return; + } + + try { + const receipt = await weksaSpeechClient.renderRepoFaceSpeech({ + jobId: input.job.id, + identityId: input.identity.id, + displayName: input.identity.displayName, + repoName: input.identity.repoName, + personaStatePath: input.identity.faceStatePath, + channelId: input.channelId, + messageId: input.messageId, + replyToMessageId: input.replyToMessageId, + content: input.content, + }); + await recordRepoIdentityWeksaSpeechReceipt({ + ...input, + receipt, + }); + console.log( + `Rendered Weksa/MiMo speech for repo identity ${input.identity.id} message ${input.messageId}: ${receipt.artifacts?.audio ?? "audio artifact not reported"}.`, + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + await auditLog.record({ + type: "repo_identity.weksa_speech_failed", + actorId: input.job.requester.id, + jobId: input.job.id, + provider: input.job.provider, + details: { + identityId: input.identity.id, + channelId: input.channelId, + messageId: input.messageId, + error: message, + }, + }); + console.warn(`Weksa speech failed for repo identity ${input.identity.id} job ${input.job.id}: ${message}`); + } +} + +async function recordRepoIdentityWeksaSpeechReceipt(input: { + job: JobRecord; + identity: NonNullable>; + channelId: string; + content: string; + replyToMessageId?: string; + messageId: string; + receipt: WeksaMimoReceipt; +}): Promise { + await auditLog.record({ + type: "repo_identity.weksa_speech_rendered", + actorId: input.job.requester.id, + jobId: input.job.id, + provider: input.job.provider, + details: { + identityId: input.identity.id, + channelId: input.channelId, + messageId: input.messageId, + replyToMessageId: input.replyToMessageId, + requestId: input.receipt.request_id, + receiptSchema: input.receipt.schema_version, + audioArtifact: input.receipt.artifacts?.audio, + receiptArtifact: input.receipt.artifacts?.receipt, + audioBytes: input.receipt.provider_response?.audio_bytes, + spokenContentLength: input.content.length, + }, + }); +} + async function loadRegisteredFaceRepoRegistry() { return faceRegistryAsRepoDiscordRegistry( await loadFaceIdentityRegistry(config.repoDiscordIdentitiesPath), diff --git a/apps/worker/src/weksa-speech.ts b/apps/worker/src/weksa-speech.ts new file mode 100644 index 0000000..e40ecf2 --- /dev/null +++ b/apps/worker/src/weksa-speech.ts @@ -0,0 +1,115 @@ +export interface WeksaSpeechClientOptions { + baseUrl: string; + timeoutMs: number; +} + +export interface WeksaRepoFaceSpeechInput { + jobId: string; + identityId: string; + displayName: string; + repoName: string; + personaStatePath: string; + channelId: string; + messageId: string; + content: string; + replyToMessageId?: string; +} + +export interface WeksaMimoReceipt { + schema_version?: string; + request_id?: string; + ok?: boolean; + provider?: string; + model?: string; + artifacts?: { + audio?: string; + receipt?: string; + interlingua_packet?: string; + mimo_request?: string; + }; + provider_response?: { + audio_bytes?: number; + }; +} + +export class WeksaSpeechClient { + private readonly baseUrl: string; + private readonly timeoutMs: number; + + constructor(options: WeksaSpeechClientOptions) { + this.baseUrl = options.baseUrl.replace(/\/+$/, ""); + this.timeoutMs = options.timeoutMs; + } + + async renderRepoFaceSpeech(input: WeksaRepoFaceSpeechInput): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), this.timeoutMs); + try { + const response = await fetch(`${this.baseUrl}/speech-provider/mimo/voicedesign`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + signal: controller.signal, + body: JSON.stringify({ + request_id: buildRequestId(input), + persona_state_path: input.personaStatePath, + speaker_agent_id: input.identityId, + thought_text: input.content, + spoken_text: input.content, + scene: `Discord channel ${input.channelId}`, + addressee: "Discord room", + intent: input.content, + performance_register: { + label: `${input.displayName} Discord voice`, + medium: "Discord voice channel playback", + delivery_archetype: `${input.displayName} speaks an approved VoidBot SAY line`, + }, + delivery_controls: [ + "Discord-room conversational delivery", + "preserve the approved text exactly", + `repo Face for ${input.repoName}`, + ], + forbidden_traits: [ + "generic assistant voice", + "rewriting source meaning", + "changing the approved Discord text", + ], + private_interpretation: + "VoidBot already approved this SAY line for public text delivery; Weksa is only projecting it into spoken audio.", + intended_effect: "Make the approved public Discord line audible without changing its meaning.", + }), + }); + + const text = await response.text(); + if (!response.ok) { + throw new Error(`Weksa speech request failed with ${response.status}: ${text}`); + } + return JSON.parse(text) as WeksaMimoReceipt; + } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + throw new Error(`Weksa speech request timed out after ${this.timeoutMs}ms.`); + } + throw error; + } finally { + clearTimeout(timeout); + } + } +} + +function buildRequestId(input: WeksaRepoFaceSpeechInput): string { + return [ + "voidbot", + sanitizeId(input.identityId), + sanitizeId(input.jobId).slice(0, 16), + sanitizeId(input.messageId).slice(0, 24), + ].filter(Boolean).join("-"); +} + +function sanitizeId(value: string): string { + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index df54d48..e65baec 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -108,6 +108,9 @@ const envSchema = z.object({ REPO_FACE_BIRTH_EXECUTOR: z.enum(["model-runtime", "openai-runtime"]).default("model-runtime"), REPO_FACE_GITHUB_ACTIONS_ENABLED: booleanFromEnv.default(false), REPO_FACE_BIFROST_ENABLED: booleanFromEnv.default(false), + REPO_FACE_WEKSA_SPEECH_ENABLED: booleanFromEnv.default(false), + WEKSA_DAEMON_BASE_URL: z.string().url().default("http://127.0.0.1:8813"), + WEKSA_DAEMON_TIMEOUT_MS: z.coerce.number().int().positive().default(120000), BIFROST_ROOT: z.string().min(1).default("E:/Projects/Bifrost"), BIFROST_DISCORD_CHANNEL_ID: optionalNonEmptyString, REPO_FACE_HEARTBEATS_ENABLED: booleanFromEnv.default(false), @@ -183,6 +186,11 @@ export interface AppConfig { repoFaceBirthExecutor: "model-runtime" | "openai-runtime"; repoFaceGithubActionsEnabled: boolean; repoFaceBifrostEnabled: boolean; + repoFaceWeksaSpeech: { + enabled: boolean; + daemonBaseUrl: string; + timeoutMs: number; + }; bifrostRoot: string; bifrostDiscordChannelId?: string; repoFaceHeartbeats: { @@ -424,6 +432,11 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig { repoFaceBirthExecutor: parsed.REPO_FACE_BIRTH_EXECUTOR, repoFaceGithubActionsEnabled: parsed.REPO_FACE_GITHUB_ACTIONS_ENABLED, repoFaceBifrostEnabled: parsed.REPO_FACE_BIFROST_ENABLED, + repoFaceWeksaSpeech: { + enabled: parsed.REPO_FACE_WEKSA_SPEECH_ENABLED, + daemonBaseUrl: parsed.WEKSA_DAEMON_BASE_URL.replace(/\/+$/, ""), + timeoutMs: parsed.WEKSA_DAEMON_TIMEOUT_MS, + }, bifrostRoot: resolve(parsed.BIFROST_ROOT), bifrostDiscordChannelId: parsed.BIFROST_DISCORD_CHANNEL_ID, repoFaceHeartbeats: { diff --git a/state/evidence.jsonl b/state/evidence.jsonl index af6813a..fe0e3b7 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -352,3 +352,4 @@ {"ts":"2026-06-12T11:57:27Z","type":"implementation","status":"ok","note":"Vili animation daemon created and deployed: GameCult/Vili runs on Raven at http://10.77.0.4:8824 with Eve provider vili.animation, Raven scheduled task GameCult\\Vili, Odin seed/discovery wired, Idunn target vili active, and Starfire smoke verifies Kimodo image, NVIDIA runtime, RTX 4060 Ti visibility, and kimodo_gen --help. Full generation still needs a Raven Hugging Face token with gated Meta-Llama-3-8B-Instruct access."} {"ts":"2026-06-12T13:07:40Z","type":"implementation","status":"ok","note":"Raven Hugging Face token from gamecult-ops/huggingface-bifrost was installed into Ubuntu-24.04 at /root/.cache/huggingface/token, Vili now mounts/passes it into Kimodo containers, /health reports huggingFaceToken: present, Starfire smoke passes, and a Kimodo container verified gated metadata access to meta-llama/Meta-Llama-3-8B-Instruct. Token value was not logged."} {"ts":"2026-06-12T14:12:05Z","type":"verification","status":"ok","note":"Kimodo real generation now works on Raven through Vili. Direct smoke generated E:\\Projects\\Vili\\.vili\\artifacts\\kimodo-real-smoke-live\\motion.npz after first-run cache warmup. Vili HTTP POST /motion/generate succeeded for prompt 'A person gives a small friendly wave.' with duration=1, diffusionSteps=2, seed=10, producing E:\\Projects\\Vili\\.vili\\artifacts\\vili-1781273290152\\motion.npz. Key fixes: writable Hugging Face cache mount and file-based Kimodo input_folder/meta.json prompt handoff."} +{"ts":"2026-06-12T16:01:29Z","type":"implementation","status":"ok","note":"Added gated repo Face Weksa speech projection after parent-approved Discord SAY delivery. Weksa remains utterance/MiMo artifact owner; VoidBot records audit receipts and text delivery stays authoritative if speech rendering fails."}