src/voice/thoughtEngine.ts

import { clamp } from "../utils.ts"; import { RECENT_ENGAGEMENT_WINDOW_MS, VOICE_THOUGHT_LOOP_BUSY_RETRY_MS, VOICE_THOUGHT_MAX_CHARS } from "./voiceSessionManager.constants.ts"; import { normalizeVoiceText } from "./voiceSessionHelpers.ts"; import type { DeferredActionQueue } from "./deferredActionQueue.ts"; import type { TurnProcessor } from "./turnProcessor.ts"; import type { MusicPlaybackPhase, VoicePendingAmbientThought, VoiceSession, VoiceTranscriptTimelineEntry } from "./voiceSessionTypes.ts"; import { musicPhaseIsActive } from "./voiceSessionTypes.ts";

type ThoughtSettings = Record<string, unknown> | null;

interface ThoughtConfigLike { enabled: boolean; eagerness: number; minSilenceSeconds: number; minSecondsBetweenThoughts: number; }

interface ThoughtTopicalityBias { topicTetherStrength: number; randomInspirationStrength: number; phase: string; promptHint: string; }

interface VoiceThoughtDecision { action: "speak_now" | "hold" | "drop"; reason: string; finalThought?: string | null; memoryFactCount?: number; usedMemory?: boolean; llmResponse?: string | null; llmProvider?: string | null; llmModel?: string | null; error?: string | null; }

type ThoughtStoreLike = { getSettings: () => ThoughtSettings; logAction: (entry: { kind: string; guildId?: string | null; channelId?: string | null; userId?: string | null; content: string; metadata?: Record<string, unknown>; }) => void; };

interface ThoughtEngineHost { client: { user?: { id?: string | null; } | null; }; store: ThoughtStoreLike; resolveVoiceThoughtEngineConfig: (settings: ThoughtSettings) => ThoughtConfigLike; buildVoiceConversationContext: (args: { session: VoiceSession; userId?: string | null; directAddressed?: boolean; participantCount?: number | null; now?: number; }) => { attentionMode: "ACTIVE" | "AMBIENT"; }; isCommandOnlyActive: (session: VoiceSession, settings?: ThoughtSettings) => boolean; getMusicPhase: (session: VoiceSession) => MusicPlaybackPhase; getOutputChannelState: (session: VoiceSession) => { locked: boolean; lockReason?: string | null; }; hasDeferredTurnBlockingActiveCapture: (session: VoiceSession) => boolean; turnProcessor: Pick<TurnProcessor, "getRealtimeTurnBacklogSize">; deferredActionQueue: Pick<DeferredActionQueue, "getDeferredQueuedUserTurns">; countHumanVoiceParticipants: (session: VoiceSession) => number; generateVoiceThoughtCandidate: (args: { session: VoiceSession; settings: ThoughtSettings; config: ThoughtConfigLike; trigger?: string; pendingThought?: VoicePendingAmbientThought | null; }) => Promise; loadVoiceThoughtMemoryFacts: (args: { session: VoiceSession; settings: ThoughtSettings; thoughtCandidate: string; }) => Promise<unknown[]>; evaluateVoiceThoughtDecision: (args: { session: VoiceSession; settings: ThoughtSettings; thoughtCandidate: string; memoryFacts: unknown[]; topicalityBias: ThoughtTopicalityBias; pendingThought?: VoicePendingAmbientThought | null; }) => Promise; deliverVoiceThoughtCandidate: (args: { session: VoiceSession; settings: ThoughtSettings; thoughtCandidate: string; trigger?: string; }) => Promise; resolveVoiceThoughtTopicalityBias: (args: { silenceMs?: number; minSilenceSeconds?: number; minSecondsBetweenThoughts?: number; }) => ThoughtTopicalityBias; appendTranscriptTimelineEntry: ( session: VoiceSession, entry: VoiceTranscriptTimelineEntry | null ) => void; }

export class ThoughtEngine { constructor(private readonly host: ThoughtEngineHost) {}

clearVoiceThoughtLoopTimer(session: VoiceSession) { if (!session) return; if (session.thoughtLoopTimer) { clearTimeout(session.thoughtLoopTimer); session.thoughtLoopTimer = null; } session.nextThoughtAt = 0; }

private getPendingAmbientThought(session: VoiceSession | null | undefined) { const pendingThought = session?.pendingAmbientThought; if (!pendingThought || typeof pendingThought !== "object") return null; const currentText = normalizeVoiceText(pendingThought.currentText || "", VOICE_THOUGHT_MAX_CHARS); if (!currentText) return null; return { ...pendingThought, draftText: normalizeVoiceText(pendingThought.draftText || currentText, VOICE_THOUGHT_MAX_CHARS) || currentText, currentText, invalidationReason: String(pendingThought.invalidationReason || "").trim() || null } satisfies VoicePendingAmbientThought; }

private clearPendingAmbientThought( session: VoiceSession, { reason = "cleared", now = Date.now(), trigger = "timer" }: { reason?: string; now?: number; trigger?: string; } = {} ) { const pendingThought = this.getPendingAmbientThought(session); session.pendingAmbientThought = null; if (!pendingThought) return null; this.store.logAction({ kind: "voice_runtime", guildId: session.guildId, channelId: session.textChannelId, userId: this.botUserId, content: "voice_pending_thought_cleared", metadata: { sessionId: session.id, trigger, reason, thoughtId: pendingThought.id, thoughtText: pendingThought.currentText, thoughtRevision: pendingThought.revision, ageMs: Math.max(0, Math.round(now - Number(pendingThought.createdAt || now))) } }); return pendingThought; }

private resolvePendingThoughtRevisitDelayMs(config: ThoughtConfigLike) { const minIntervalMs = Math.max(1_000, Math.round(Number(config.minSecondsBetweenThoughts || 0) * 1_000)); return Math.max(1_500, Math.min(minIntervalMs, 10_000)); }

private resolvePendingThoughtExpiryMs(config: ThoughtConfigLike) { const minIntervalMs = Math.max(1_000, Math.round(Number(config.minSecondsBetweenThoughts || 0) * 1_000)); return Math.max(60_000, minIntervalMs * 4); }

private resolvePendingThoughtExpiryAt( existingThought: VoicePendingAmbientThought | null, config: ThoughtConfigLike, now = Date.now() ) { const createdAt = Number(existingThought?.createdAt || now); const boundedExpiryAt = createdAt + this.resolvePendingThoughtExpiryMs(config); const previousExpiryAt = Number(existingThought?.expiresAt || 0); return previousExpiryAt > 0 ? Math.min(previousExpiryAt, boundedExpiryAt) : boundedExpiryAt; }

private pendingThoughtIsExpired( pendingThought: VoicePendingAmbientThought | null | undefined, config: ThoughtConfigLike, now = Date.now() ) { if (!pendingThought) return false; const expiresAt = this.resolvePendingThoughtExpiryAt(pendingThought, config, now); return expiresAt > 0 && now >= expiresAt; }

private upsertPendingAmbientThought({ session, config, now = Date.now(), trigger = "timer", thoughtDraft = "", thoughtText = "", decision }: { session: VoiceSession; config: ThoughtConfigLike; now?: number; trigger?: string; thoughtDraft: string; thoughtText: string; decision: VoiceThoughtDecision; }) { const existingThought = this.getPendingAmbientThought(session); const normalizedDraft = normalizeVoiceText(thoughtDraft, VOICE_THOUGHT_MAX_CHARS); const normalizedThought = normalizeVoiceText(thoughtText, VOICE_THOUGHT_MAX_CHARS); if (!normalizedThought) { return this.clearPendingAmbientThought(session, { reason: "empty_hold_thought", now, trigger }); } const expiresAt = this.resolvePendingThoughtExpiryAt(existingThought, config, now); if (expiresAt <= now) { return this.clearPendingAmbientThought(session, { reason: "expired", now, trigger }); } const nextThought: VoicePendingAmbientThought = { id: existingThought?.id || ${session.id}:thought:${now.toString(36)}, status: "queued", trigger: String(trigger || existingThought?.trigger || "timer"), draftText: normalizedDraft || existingThought?.draftText || normalizedThought, currentText: normalizedThought, createdAt: existingThought?.createdAt || now, updatedAt: now, basisAt: now, notBeforeAt: now + this.resolvePendingThoughtRevisitDelayMs(config), expiresAt, revision: existingThought ? Math.max(1, Number(existingThought.revision || 1)) + 1 : 1, lastDecisionReason: String(decision.reason || "").trim() || null, lastDecisionAction: "hold", memoryFactCount: Math.max(0, Number(decision.memoryFactCount || 0)), usedMemory: Boolean(decision.usedMemory), invalidatedAt: null, invalidatedByUserId: null, invalidationReason: null }; session.pendingAmbientThought = nextThought; this.store.logAction({ kind: "voice_runtime", guildId: session.guildId, channelId: session.textChannelId, userId: this.botUserId, content: existingThought ? "voice_pending_thought_updated" : "voice_pending_thought_created", metadata: { sessionId: session.id, trigger, thoughtId: nextThought.id, thoughtText: nextThought.currentText, draftText: nextThought.draftText, thoughtRevision: nextThought.revision, notBeforeAt: new Date(nextThought.notBeforeAt).toISOString(), expiresAt: new Date(nextThought.expiresAt).toISOString(), reason: nextThought.lastDecisionReason, usedMemory: nextThought.usedMemory, memoryFactCount: nextThought.memoryFactCount } }); return nextThought; }

private resolveNextLoopDelayMs({ session, config, now = Date.now() }: { session: VoiceSession; config: ThoughtConfigLike; now?: number; }) { const pendingThought = this.getPendingAmbientThought(session); if (pendingThought) { return Math.max(200, Number(pendingThought.notBeforeAt || now) - now); } return Math.max(200, Math.round(Number(config.minSecondsBetweenThoughts || 0) * 1_000)); }

private recordThoughtInTranscript( session: VoiceSession, thoughtText: string, now = Date.now() ) { const normalized = normalizeVoiceText(thoughtText, VOICE_THOUGHT_MAX_CHARS); if (!normalized || !session || session.ending) return; this.host.appendTranscriptTimelineEntry(session, { kind: "thought", role: "assistant", userId: this.botUserId, speakerName: "YOU", text: normalized, at: now }); }

markPendingAmbientThoughtStale( session: VoiceSession | null | undefined, { userId = null, reason = "room_activity", now = Date.now() }: { userId?: string | null; reason?: string; now?: number; } = {} ) { const pendingThought = this.getPendingAmbientThought(session); if (!session || !pendingThought) return false; session.pendingAmbientThought = { ...pendingThought, status: "reconsider", updatedAt: now, basisAt: now, invalidatedAt: now, invalidatedByUserId: String(userId || "").trim() || null, invalidationReason: String(reason || "").trim() || pendingThought.invalidationReason || null }; return true; }

scheduleVoiceThoughtLoop({ session, settings = null, delayMs = null }: { session: VoiceSession; settings?: ThoughtSettings; delayMs?: number | null; }) { if (!session || session.ending) return; const resolvedSettings = settings || session.settingsSnapshot || this.store.getSettings(); const thoughtConfig = this.host.resolveVoiceThoughtEngineConfig(resolvedSettings); this.clearVoiceThoughtLoopTimer(session); if (!thoughtConfig.enabled) return;

const defaultDelayMs = thoughtConfig.minSilenceSeconds * 1000;
const requestedDelayMs = Number(delayMs);
const waitMs = Math.max(
  120,
  Number.isFinite(requestedDelayMs) ? Math.round(Number(delayMs)) : defaultDelayMs
);
session.nextThoughtAt = Date.now() + waitMs;
session.thoughtLoopTimer = setTimeout(() => {
  session.thoughtLoopTimer = null;
  session.nextThoughtAt = 0;
  this.spawnVoiceThoughtLoop({
    session,
    settings: session.settingsSnapshot || this.store.getSettings(),
    trigger: "timer"
  });
}, waitMs);

}

private spawnVoiceThoughtLoop({ session, settings = null, trigger = "timer" }: { session: VoiceSession; settings?: ThoughtSettings; trigger?: string; }) { void this.maybeRunVoiceThoughtLoop({ session, settings, trigger }).catch((error: unknown) => { session.thoughtLoopBusy = false; this.store.logAction({ kind: "voice_error", guildId: session.guildId, channelId: session.textChannelId, userId: this.botUserId, content: voice_thought_loop_schedule_failed: ${String((error as Error)?.message || error)}, metadata: { sessionId: session.id, mode: session.mode, trigger: String(trigger || "timer") } }); if (session.ending) return; this.scheduleVoiceThoughtLoop({ session, settings: settings || session.settingsSnapshot || this.store.getSettings(), delayMs: VOICE_THOUGHT_LOOP_BUSY_RETRY_MS }); }); }

evaluateVoiceThoughtLoopGate({ session, settings = null, config = null, now = Date.now() }: { session: VoiceSession; settings?: ThoughtSettings; config?: ThoughtConfigLike | null; now?: number; }) { if (!session || session.ending) { return { allow: false, reason: "session_inactive", retryAfterMs: VOICE_THOUGHT_LOOP_BUSY_RETRY_MS }; }

const thoughtConfig = config || this.host.resolveVoiceThoughtEngineConfig(settings);
if (!thoughtConfig.enabled) {
  return {
    allow: false,
    reason: "thought_engine_disabled",
    retryAfterMs: thoughtConfig.minSilenceSeconds * 1000
  };
}

if (this.host.isCommandOnlyActive(session, settings)) {
  return {
    allow: false,
    reason: "command_only_mode",
    retryAfterMs: thoughtConfig.minSilenceSeconds * 1000
  };
}

if (musicPhaseIsActive(this.host.getMusicPhase(session))) {
  return {
    allow: false,
    reason: "music_playback_active",
    retryAfterMs: thoughtConfig.minSilenceSeconds * 1000
  };
}

const minSilenceMs = thoughtConfig.minSilenceSeconds * 1000;
const minIntervalMs = thoughtConfig.minSecondsBetweenThoughts * 1000;
const silentDurationMs = Math.max(0, now - Number(session.lastActivityAt || 0));
if (silentDurationMs < minSilenceMs) {
  return {
    allow: false,
    reason: "silence_window_not_met",
    retryAfterMs: Math.max(200, minSilenceMs - silentDurationMs)
  };
}

const pendingThought = this.getPendingAmbientThought(session);
const pendingThoughtNotBeforeMs = Math.max(0, Number(pendingThought?.notBeforeAt || 0) - now);
if (pendingThought && pendingThoughtNotBeforeMs > 0) {
  return {
    allow: false,
    reason: "pending_thought_backoff",
    retryAfterMs: Math.max(300, pendingThoughtNotBeforeMs)
  };
}
if (!pendingThought) {
  const sinceLastAttemptMs = Math.max(0, now - Number(session.lastThoughtAttemptAt || 0));
  if (sinceLastAttemptMs < minIntervalMs) {
    return {
      allow: false,
      reason: "thought_attempt_cooldown",
      retryAfterMs: Math.max(300, minIntervalMs - sinceLastAttemptMs)
    };
  }
}

const conversationContext = this.host.buildVoiceConversationContext({
  session,
  userId: null,
  directAddressed: false,
  now
});
if (conversationContext.attentionMode === "ACTIVE") {
  const activeSignalAges = [
    Number(session.lastAssistantReplyAt || 0) > 0
      ? Math.max(0, now - Number(session.lastAssistantReplyAt || 0))
      : null,
    Number(session.lastDirectAddressAt || 0) > 0
      ? Math.max(0, now - Number(session.lastDirectAddressAt || 0))
      : null
  ].filter((value): value is number => Number.isFinite(value) && value >= 0);
  const retryAfterMs = activeSignalAges.length > 0
    ? Math.max(
      500,
      ...activeSignalAges.map((ageMs) => Math.max(0, RECENT_ENGAGEMENT_WINDOW_MS - ageMs))
    )
    : VOICE_THOUGHT_LOOP_BUSY_RETRY_MS;
  return {
    allow: false,
    reason: "attention_active",
    retryAfterMs
  };
}

if (session.thoughtLoopBusy) {
  return {
    allow: false,
    reason: "thought_loop_busy",
    retryAfterMs: VOICE_THOUGHT_LOOP_BUSY_RETRY_MS
  };
}
const outputChannelState = this.host.getOutputChannelState(session);
if (outputChannelState.locked) {
  return {
    allow: false,
    reason: "bot_turn_open",
    retryAfterMs: VOICE_THOUGHT_LOOP_BUSY_RETRY_MS,
    outputLockReason: outputChannelState.lockReason
  };
}
if (this.host.hasDeferredTurnBlockingActiveCapture(session)) {
  return {
    allow: false,
    reason: "active_user_capture",
    retryAfterMs: VOICE_THOUGHT_LOOP_BUSY_RETRY_MS
  };
}
if (Number(session.pendingFileAsrTurns || 0) > 0) {
  return {
    allow: false,
    reason: "pending_stt_turns",
    retryAfterMs: VOICE_THOUGHT_LOOP_BUSY_RETRY_MS
  };
}
if (this.host.turnProcessor.getRealtimeTurnBacklogSize(session) > 0) {
  return {
    allow: false,
    reason: "pending_realtime_turns",
    retryAfterMs: VOICE_THOUGHT_LOOP_BUSY_RETRY_MS
  };
}
if (this.host.deferredActionQueue.getDeferredQueuedUserTurns(session).length > 0) {
  return {
    allow: false,
    reason: "pending_deferred_turns",
    retryAfterMs: VOICE_THOUGHT_LOOP_BUSY_RETRY_MS
  };
}
if (this.host.countHumanVoiceParticipants(session) <= 0) {
  return {
    allow: false,
    reason: "no_human_participants",
    retryAfterMs: minSilenceMs
  };
}

return {
  allow: true,
  reason: "ok",
  retryAfterMs: minIntervalMs
};

}

async maybeRunVoiceThoughtLoop({ session, settings = null, trigger = "timer" }: { session: VoiceSession; settings?: ThoughtSettings; trigger?: string; }) { if (!session || session.ending) return false; const resolvedSettings = settings || session.settingsSnapshot || this.store.getSettings(); const thoughtConfig = this.host.resolveVoiceThoughtEngineConfig(resolvedSettings); if (!thoughtConfig.enabled) { this.clearVoiceThoughtLoopTimer(session); return false; }

const loopStartedAt = Date.now();
const pendingThoughtBeforeGate = this.getPendingAmbientThought(session);
if (this.pendingThoughtIsExpired(pendingThoughtBeforeGate, thoughtConfig, loopStartedAt)) {
  this.clearPendingAmbientThought(session, {
    reason: "expired",
    now: loopStartedAt,
    trigger
  });
}

const gate = this.evaluateVoiceThoughtLoopGate({
  session,
  settings: resolvedSettings,
  config: thoughtConfig
});
if (!gate.allow) {
  this.scheduleVoiceThoughtLoop({
    session,
    settings: resolvedSettings,
    delayMs: gate.retryAfterMs
  });
  return false;
}

const pendingThought = this.getPendingAmbientThought(session);
const isPendingThoughtPass = Boolean(pendingThought);
const thoughtChance = clamp(Number(thoughtConfig?.eagerness) || 0, 0, 100) / 100;
const now = Date.now();
session.lastThoughtAttemptAt = now;
if (!isPendingThoughtPass && thoughtChance <= 0) {
  this.scheduleVoiceThoughtLoop({
    session,
    settings: resolvedSettings,
    delayMs: thoughtConfig.minSecondsBetweenThoughts * 1000
  });
  return false;
}

const roll = Math.random();
if (!isPendingThoughtPass && roll > thoughtChance) {
  this.store.logAction({
    kind: "voice_runtime",
    guildId: session.guildId,
    channelId: session.textChannelId,
    userId: this.botUserId,
    content: "voice_thought_skipped_probability",
    metadata: {
      sessionId: session.id,
      mode: session.mode,
      trigger: String(trigger || "timer"),
      thoughtEagerness: Math.round(thoughtChance * 100),
      roll: Number(roll.toFixed(5))
    }
  });
  this.scheduleVoiceThoughtLoop({
    session,
    settings: resolvedSettings,
    delayMs: thoughtConfig.minSecondsBetweenThoughts * 1000
  });
  return false;
}

session.thoughtLoopBusy = true;
try {
  const thoughtDraft = await this.host.generateVoiceThoughtCandidate({
    session,
    settings: resolvedSettings,
    config: thoughtConfig,
    trigger,
    pendingThought
  });
  const normalizedThoughtDraft = normalizeVoiceText(
    thoughtDraft || pendingThought?.currentText || "",
    VOICE_THOUGHT_MAX_CHARS
  );
  if (!normalizedThoughtDraft) {
    if (pendingThought) {
      this.clearPendingAmbientThought(session, {
        reason: "pending_thought_evaporated",
        now,
        trigger
      });
    }
    this.store.logAction({
      kind: "voice_runtime",
      guildId: session.guildId,
      channelId: session.textChannelId,
      userId: this.botUserId,
      content: "voice_thought_generation_skip",
      metadata: {
        sessionId: session.id,
        mode: session.mode,
        trigger: String(trigger || "timer"),
        hadPendingThought: isPendingThoughtPass,
        pendingThoughtId: pendingThought?.id || null
      }
    });
    return false;
  }

  const thoughtMemoryFacts = await this.host.loadVoiceThoughtMemoryFacts({
    session,
    settings: resolvedSettings,
    thoughtCandidate: normalizedThoughtDraft
  });
  const thoughtTopicalityBias = this.host.resolveVoiceThoughtTopicalityBias({
    silenceMs: Math.max(0, Date.now() - Number(session.lastActivityAt || 0)),
    minSilenceSeconds: thoughtConfig.minSilenceSeconds,
    minSecondsBetweenThoughts: thoughtConfig.minSecondsBetweenThoughts
  });
  const decision = await this.host.evaluateVoiceThoughtDecision({
    session,
    settings: resolvedSettings,
    thoughtCandidate: normalizedThoughtDraft,
    memoryFacts: thoughtMemoryFacts,
    topicalityBias: thoughtTopicalityBias,
    pendingThought
  });
  this.store.logAction({
    kind: "voice_runtime",
    guildId: session.guildId,
    channelId: session.textChannelId,
    userId: this.botUserId,
    content: "voice_thought_decision",
    metadata: {
      sessionId: session.id,
      mode: session.mode,
      trigger: String(trigger || "timer"),
      action: decision.action,
      allow: decision.action === "speak_now",
      reason: decision.reason,
      hadPendingThought: isPendingThoughtPass,
      pendingThoughtId: pendingThought?.id || null,
      pendingThoughtRevision: pendingThought?.revision || null,
      thoughtDraft: normalizedThoughtDraft,
      finalThought: decision.finalThought || null,
      memoryFactCount: Number(decision.memoryFactCount || 0),
      usedMemory: Boolean(decision.usedMemory),
      topicTetherStrength: thoughtTopicalityBias.topicTetherStrength,
      randomInspirationStrength: thoughtTopicalityBias.randomInspirationStrength,
      topicDriftPhase: thoughtTopicalityBias.phase,
      topicDriftHint: thoughtTopicalityBias.promptHint,
      llmResponse: decision.llmResponse || null,
      llmProvider: decision.llmProvider || null,
      llmModel: decision.llmModel || null,
      error: decision.error || null
    }
  });
  const finalThought = normalizeVoiceText(
    decision.finalThought || normalizedThoughtDraft,
    VOICE_THOUGHT_MAX_CHARS
  );
  if (decision.action === "drop") {
    if (finalThought) {
      this.recordThoughtInTranscript(session, finalThought, now);
    }
    this.clearPendingAmbientThought(session, {
      reason: decision.reason || "llm_drop",
      now,
      trigger
    });
    return false;
  }
  if (!finalThought) {
    this.clearPendingAmbientThought(session, {
      reason: "empty_final_thought",
      now,
      trigger
    });
    return false;
  }
  if (decision.action === "hold") {
    this.recordThoughtInTranscript(session, finalThought, now);
    this.upsertPendingAmbientThought({
      session,
      config: thoughtConfig,
      now,
      trigger,
      thoughtDraft: normalizedThoughtDraft,
      thoughtText: finalThought,
      decision
    });
    return false;
  }

  const spoken = await this.host.deliverVoiceThoughtCandidate({
    session,
    settings: resolvedSettings,
    thoughtCandidate: finalThought,
    trigger
  });
  if (spoken) {
    const deliveredAt = Date.now();
    session.lastThoughtSpokenAt = deliveredAt;
    this.clearPendingAmbientThought(session, {
      reason: "spoken",
      now: deliveredAt,
      trigger
    });
  } else {
    this.upsertPendingAmbientThought({
      session,
      config: thoughtConfig,
      now: Date.now(),
      trigger,
      thoughtDraft: normalizedThoughtDraft,
      thoughtText: finalThought,
      decision: {
        ...decision,
        action: "hold",
        reason: "delivery_failed"
      }
    });
  }
  return spoken;
} catch (error) {
  this.store.logAction({
    kind: "voice_error",
    guildId: session.guildId,
    channelId: session.textChannelId,
    userId: this.botUserId,
    content: `voice_thought_loop_failed: ${String(error?.message || error)}`,
    metadata: {
      sessionId: session.id,
      mode: session.mode,
      trigger: String(trigger || "timer")
    }
  });
  return false;
} finally {
  session.thoughtLoopBusy = false;
  this.scheduleVoiceThoughtLoop({
    session,
    settings: resolvedSettings,
    delayMs: this.resolveNextLoopDelayMs({
      session,
      config: thoughtConfig,
      now: Date.now()
    })
  });
}

}

private get botUserId() { return this.host.client.user?.id || null; }

private get store() { return this.host.store; } }