Guard swarm voice rendering on listeners

This commit is contained in:
Meta Vens 2026-06-13 20:43:20 +01:00
commit 2c0b0a5781
7 changed files with 110 additions and 3 deletions

View file

@ -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=

View file

@ -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;
}

View file

@ -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.`,

View file

@ -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: {

View file

@ -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";

View file

@ -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<void> {
await mkdir(dirname(path), { recursive: true });
await writeFile(path, `${JSON.stringify(snapshot, null, 2)}\n`, "utf8");
}
export async function loadRepoFaceVoicePresenceSnapshot(
path: string,
): Promise<RepoFaceVoicePresenceSnapshot | undefined> {
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<RepoFaceVoicePresenceSnapshot>;
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);
}

View file

@ -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."}