diff --git a/.env.example b/.env.example index 386c4a3..ecdf802 100644 --- a/.env.example +++ b/.env.example @@ -75,6 +75,7 @@ REPO_FACE_DISCORD_VOICE_ENABLED=false REPO_FACE_DISCORD_VOICE_CHANNEL_ID= REPO_FACE_DISCORD_VOICE_OUTBOX_PATH=.voidbot/status/repo-face-voice-outbox.jsonl REPO_FACE_DISCORD_VOICE_PLAYED_PATH=.voidbot/status/repo-face-voice-played.jsonl +REPO_FACE_DISCORD_VOICE_PRESENCE_PATH=.voidbot/status/repo-face-voice-presence.json REPO_FACE_DISCORD_VOICE_POLL_INTERVAL_MS=5000 METAME_OWNER_VOICE_ENABLED=false METAME_OWNER_VOICE_SOURCE_CHANNEL_ID= diff --git a/apps/bot/src/repo-face-voice-playback.ts b/apps/bot/src/repo-face-voice-playback.ts index 546cd6e..ed5e8a7 100644 --- a/apps/bot/src/repo-face-voice-playback.ts +++ b/apps/bot/src/repo-face-voice-playback.ts @@ -17,6 +17,7 @@ import { loadRepoFaceVoiceOutboxEntries, loadRepoFaceVoicePlayedIds, type RepoFaceVoiceOutboxEntry, + writeRepoFaceVoicePresenceSnapshot, } from "@voidbot/core"; export interface RepoFaceVoicePlaybackOptions { @@ -24,6 +25,7 @@ export interface RepoFaceVoicePlaybackOptions { channelId?: string; outboxPath: string; playedPath: string; + presencePath: string; pollIntervalMs: number; } @@ -67,6 +69,22 @@ class RepoFaceVoicePlaybackRuntime { return; } + let channel: VoiceBasedChannel; + try { + channel = await this.resolveVoiceChannel(); + const humanListenerCount = countHumanListeners(channel); + await writeRepoFaceVoicePresenceSnapshot(this.options.presencePath, { + schemaVersion: "voidbot.repo_face_voice_presence.v1", + channelId: channel.id, + observedAt: new Date().toISOString(), + humanListenerCount, + botListenerCount: channel.members.size - humanListenerCount, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.warn(`Repo Face voice presence poll failed: ${message}`); + return; + } const entries = await loadRepoFaceVoiceOutboxEntries(this.options.outboxPath); if (entries.length === 0) { return; @@ -88,7 +106,7 @@ class RepoFaceVoicePlaybackRuntime { this.active = true; try { const channel = await this.resolveVoiceChannel(); - if (!hasHumanListener(channel)) { + if (countHumanListeners(channel) <= 0) { console.log( `Skipped repo Face voice playback for ${entry.id}: no human listeners in channel ${this.options.channelId}.`, ); @@ -153,6 +171,6 @@ class RepoFaceVoicePlaybackRuntime { } } -function hasHumanListener(channel: VoiceBasedChannel): boolean { - return channel.members.some((member) => !member.user.bot); +function countHumanListeners(channel: VoiceBasedChannel): number { + return channel.members.filter((member) => !member.user.bot).size; } diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts index 4b5c228..a4011fa 100644 --- a/apps/worker/src/index.ts +++ b/apps/worker/src/index.ts @@ -15,7 +15,9 @@ import { applyVoidSelfStateOperation, findRepoDiscordIdentity, faceRegistryAsRepoDiscordRegistry, + hasFreshHumanRepoFaceVoiceListener, isRepoDiscordIdentityAllowedInChannel, + loadRepoFaceVoicePresenceSnapshot, type JobQueue, loadFaceIdentityRegistry, loadSystemMessageCatalog, @@ -1458,6 +1460,19 @@ async function maybeRenderRepoIdentityWeksaSpeech(input: { if (!config.repoFaceWeksaSpeech.enabled) { return; } + if (!config.repoFaceDiscordVoice.enabled) { + return; + } + const voicePresence = await loadRepoFaceVoicePresenceSnapshot(config.repoFaceDiscordVoice.presencePath); + if (!hasFreshHumanRepoFaceVoiceListener({ + snapshot: voicePresence, + channelId: config.repoFaceDiscordVoice.channelId, + })) { + console.log( + `Skipped Weksa speech for repo identity ${input.identity.id} message ${input.messageId}: no fresh human listener signal for configured Aquarium voice channel.`, + ); + return; + } if (!input.identity.faceStatePath) { console.warn( `Skipped Weksa speech for repo identity ${input.identity.id}: no faceStatePath is registered.`, diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index 41d7551..e606ddc 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -116,6 +116,7 @@ const envSchema = z.object({ REPO_FACE_DISCORD_VOICE_CHANNEL_ID: optionalNonEmptyString, REPO_FACE_DISCORD_VOICE_OUTBOX_PATH: z.string().min(1).default(".voidbot/status/repo-face-voice-outbox.jsonl"), REPO_FACE_DISCORD_VOICE_PLAYED_PATH: z.string().min(1).default(".voidbot/status/repo-face-voice-played.jsonl"), + REPO_FACE_DISCORD_VOICE_PRESENCE_PATH: z.string().min(1).default(".voidbot/status/repo-face-voice-presence.json"), REPO_FACE_DISCORD_VOICE_POLL_INTERVAL_MS: z.coerce.number().int().positive().default(5000), METAME_OWNER_VOICE_ENABLED: booleanFromEnv.default(false), METAME_OWNER_VOICE_SOURCE_CHANNEL_ID: optionalNonEmptyString, @@ -207,6 +208,7 @@ export interface AppConfig { channelId?: string; outboxPath: string; playedPath: string; + presencePath: string; pollIntervalMs: number; }; metameOwnerVoice: { @@ -467,6 +469,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig { channelId: parsed.REPO_FACE_DISCORD_VOICE_CHANNEL_ID, outboxPath: resolve(parsed.REPO_FACE_DISCORD_VOICE_OUTBOX_PATH), playedPath: resolve(parsed.REPO_FACE_DISCORD_VOICE_PLAYED_PATH), + presencePath: resolve(parsed.REPO_FACE_DISCORD_VOICE_PRESENCE_PATH), pollIntervalMs: parsed.REPO_FACE_DISCORD_VOICE_POLL_INTERVAL_MS, }, metameOwnerVoice: { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 28c3c50..8c56611 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -10,6 +10,7 @@ export * from "./repo-discord-identities"; export * from "./repo-face-heartbeat-queue"; export * from "./repo-face-initializer"; export * from "./repo-face-rest"; +export * from "./repo-face-voice-presence"; export * from "./repo-face-voice-outbox"; export * from "./situational-social-read"; export * from "./state-storage"; diff --git a/packages/core/src/repo-face-voice-presence.ts b/packages/core/src/repo-face-voice-presence.ts new file mode 100644 index 0000000..72e5d2a --- /dev/null +++ b/packages/core/src/repo-face-voice-presence.ts @@ -0,0 +1,68 @@ +import { readFile, writeFile, mkdir } from "node:fs/promises"; +import { dirname } from "node:path"; + +export interface RepoFaceVoicePresenceSnapshot { + schemaVersion: "voidbot.repo_face_voice_presence.v1"; + channelId: string; + observedAt: string; + humanListenerCount: number; + botListenerCount: number; +} + +export async function writeRepoFaceVoicePresenceSnapshot( + path: string, + snapshot: RepoFaceVoicePresenceSnapshot, +): Promise { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, `${JSON.stringify(snapshot, null, 2)}\n`, "utf8"); +} + +export async function loadRepoFaceVoicePresenceSnapshot( + path: string, +): Promise { + const content = await readFile(path, "utf8").catch((error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") { + return undefined; + } + throw error; + }); + if (!content) { + return undefined; + } + const parsed = JSON.parse(content) as Partial; + if ( + parsed.schemaVersion !== "voidbot.repo_face_voice_presence.v1" || + typeof parsed.channelId !== "string" || + typeof parsed.observedAt !== "string" || + typeof parsed.humanListenerCount !== "number" + ) { + return undefined; + } + return { + schemaVersion: "voidbot.repo_face_voice_presence.v1", + channelId: parsed.channelId, + observedAt: parsed.observedAt, + humanListenerCount: parsed.humanListenerCount, + botListenerCount: typeof parsed.botListenerCount === "number" ? parsed.botListenerCount : 0, + }; +} + +export function hasFreshHumanRepoFaceVoiceListener(input: { + snapshot: RepoFaceVoicePresenceSnapshot | undefined; + channelId: string | undefined; + now?: Date; + maxAgeMs?: number; +}): boolean { + if (!input.snapshot || !input.channelId || input.snapshot.channelId !== input.channelId) { + return false; + } + if (input.snapshot.humanListenerCount <= 0) { + return false; + } + const observedAt = Date.parse(input.snapshot.observedAt); + if (!Number.isFinite(observedAt)) { + return false; + } + const nowMs = (input.now ?? new Date()).getTime(); + return nowMs - observedAt <= (input.maxAgeMs ?? 30_000); +} diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 8ce5ed8..0787af9 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -358,3 +358,4 @@ {"ts":"2026-06-13T08:34:42Z","type":"implementation","status":"ok","note":"Created native Moobs Persona CultCache state at .voidbot/private/personas/moobs/moobs.cc from D:/Downloads/Moobs.txt; Moobs remains outside repo Face/swarm registries and is for iVixey's separate Discord."} {"ts":"2026-06-13T12:09:58Z","type":"verification","status":"ok","note":"2026-06-13 local moderation heartbeat was re-enabled by setting VOIDBOT_MODERATION_HEARTBEAT_ENABLED=true while VOID_MODERATION_ENFORCEMENT_MODE=policy. A direct ModerationHeartbeatOnly pass completed ok in policy mode, observed 0 new messages past cursor 2026-06-12T18:40:31.845Z, proposed/applied 0 operations, and left openCaseCount=0."} {"ts":"2026-06-13T19:11:52Z","type":"implementation","status":"ok","note":"Disabled Metame owner voice locally and hardened the voice path: when re-enabled, owner Aquarium text only renders through Weksa if the configured Aquarium voice channel currently has a non-bot listener; playback also skips empty voice channels and logs connection errors instead of crashing the bot."} +{"ts":"2026-06-13T19:42:18Z","type":"implementation","status":"ok","note":"Generalized the voice listener invariant from Metame-only to the whole repo Face swarm voice feature. Local voice is disabled via REPO_FACE_WEKSA_SPEECH_ENABLED=false and REPO_FACE_DISCORD_VOICE_ENABLED=false. When re-enabled, the bot publishes a fresh Aquarium voice presence projection and the worker refuses Weksa/MiMo rendering unless Discord voice is enabled and a non-bot listener is present."}