Guard swarm voice rendering on listeners
This commit is contained in:
parent
f338eb4854
commit
2c0b0a5781
7 changed files with 110 additions and 3 deletions
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.`,
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
68
packages/core/src/repo-face-voice-presence.ts
Normal file
68
packages/core/src/repo-face-voice-presence.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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."}
|
||||
|
|
|
|||
Loading…
Reference in a new issue