src/voice/voiceToolCallMemory.ts

import { executeSharedMemoryToolWrite } from "../memory/memoryToolRuntime.ts"; import { clamp } from "../utils.ts"; import { MEMORY_SENSITIVE_PATTERN_RE, VOICE_MEMORY_WRITE_MAX_PER_MINUTE } from "./voiceSessionManager.constants.ts"; import { normalizeInlineText } from "./voiceSessionHelpers.ts"; import { invalidateSessionBehavioralMemoryCache } from "./voiceSessionMemoryCache.ts"; import { ensureSessionToolRuntimeState } from "./voiceToolCallToolRegistry.ts"; import { throwIfAborted } from "../tools/abortError.ts"; import type { VoiceRealtimeToolSettings, VoiceSession, VoiceToolRuntimeSessionLike } from "./voiceSessionTypes.ts"; import type { VoiceToolCallArgs, VoiceToolCallManager } from "./voiceToolCallTypes.ts";

const SELF_SUBJECT = "self"; const LORE_SUBJECT = "lore";

type ToolRuntimeSession = VoiceSession | VoiceToolRuntimeSessionLike;

type VoiceMemoryToolOptions = { session?: ToolRuntimeSession | null; settings?: VoiceRealtimeToolSettings | null; args?: VoiceToolCallArgs; signal?: AbortSignal; };

type VoiceConversationSearchToolOptions = { session?: ToolRuntimeSession | null; args?: VoiceToolCallArgs; signal?: AbortSignal; };

function asRecord(value: unknown): Record<string, unknown> | null { return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null; }

export async function executeVoiceConversationSearchTool( manager: VoiceToolCallManager, { session, args, signal }: VoiceConversationSearchToolOptions ) { throwIfAborted(signal, "Voice conversation search cancelled"); if ( (!manager.store || typeof manager.store.searchConversationWindows !== "function") && typeof manager.memory?.searchConversationHistory !== "function" ) { return { ok: false, matches: [], error: "conversation_history_unavailable" }; } const query = normalizeInlineText(args?.query, 220); if (!query) { return { ok: false, matches: [], error: "query_required" }; }

const scope = String(args?.scope || "channel").trim().toLowerCase(); const matches = typeof manager.memory?.searchConversationHistory === "function" ? await manager.memory.searchConversationHistory({ guildId: String(session?.guildId || "").trim(), channelId: scope === "guild" ? null : session?.textChannelId || null, queryText: query, settings: session?.settingsSnapshot || manager.store.getSettings?.() || {}, trace: { guildId: session?.guildId || null, channelId: scope === "guild" ? null : session?.textChannelId || null, userId: session?.lastRealtimeToolCallerUserId || null, source: "voice_realtime_tool_conversation_search" }, limit: clamp(Math.floor(Number(args?.top_k || 3)), 1, 4), maxAgeHours: clamp(Math.floor(Number(args?.max_age_hours || 24 * 7)), 1, 24 * 30), before: 1, after: 1 }) : manager.store.searchConversationWindows({ guildId: String(session?.guildId || "").trim(), channelId: scope === "guild" ? null : session?.textChannelId || null, queryText: query, limit: clamp(Math.floor(Number(args?.top_k || 3)), 1, 4), maxAgeHours: clamp(Math.floor(Number(args?.max_age_hours || 24 * 7)), 1, 24 * 30), before: 1, after: 1 });

return { ok: true, matches: Array.isArray(matches) ? matches : [] }; }

export async function executeVoiceMemoryWriteTool( manager: VoiceToolCallManager, { session, settings, args, signal }: VoiceMemoryToolOptions ) { throwIfAborted(signal, "Voice memory write cancelled"); const dedupe = asRecord(args?.dedupe); if ( !manager.memory || typeof manager.memory.searchDurableFacts !== "function" || typeof manager.memory.rememberDirectiveLineDetailed !== "function" ) { return { ok: false, written: [], skipped: [], error: "memory_unavailable" }; }

const runtimeSession = ensureSessionToolRuntimeState(manager, session); if (!runtimeSession) { return { ok: false, written: [], skipped: [], error: "session_unavailable" }; }

const now = Date.now(); const recentWindow = (Array.isArray(runtimeSession.memoryWriteWindow) ? runtimeSession.memoryWriteWindow : []) .map((value) => Number(value)) .filter((value) => Number.isFinite(value) && now - value <= 60_000); runtimeSession.memoryWriteWindow = recentWindow;

const remainingWriteCapacity = Math.max(0, VOICE_MEMORY_WRITE_MAX_PER_MINUTE - recentWindow.length); if (remainingWriteCapacity <= 0) { return { ok: false, written: [], skipped: [], error: "write_rate_limited" }; }

const result = await executeSharedMemoryToolWrite({ runtime: { memory: manager.memory }, settings, guildId: String(session?.guildId || "").trim(), channelId: session?.textChannelId || null, actorUserId: session?.lastRealtimeToolCallerUserId || null, namespace: args?.namespace, items: Array.isArray(args?.items) ? args.items : [], trace: { guildId: session?.guildId || null, channelId: session?.textChannelId || null, userId: session?.lastRealtimeToolCallerUserId || null, source: "voice_realtime_tool_memory_write" }, sourceMessageIdPrefix: voice-tool-${String(session?.id || "session")}, sourceText: "", limit: remainingWriteCapacity, dedupeThreshold: clamp(Number(dedupe?.threshold), 0, 1) || 0.9, sensitivePattern: MEMORY_SENSITIVE_PATTERN_RE });

if (result.ok && result.written.length > 0) { for (let i = 0; i < result.written.length; i += 1) { runtimeSession.memoryWriteWindow.push(now); } runtimeSession.memoryWriteWindow = runtimeSession.memoryWriteWindow .map((value) => Number(value)) .filter((value) => Number.isFinite(value) && now - value <= 60_000);

const writtenSubjects = new Set(
  result.written
    .map((entry) => String(entry?.subject || "").trim())
    .filter(Boolean)
);
if (writtenSubjects.has(SELF_SUBJECT) || writtenSubjects.has(LORE_SUBJECT)) {
  manager.refreshSessionGuildFactProfile?.(runtimeSession as VoiceSession);
}
for (const subject of writtenSubjects) {
  if (subject === SELF_SUBJECT || subject === LORE_SUBJECT) continue;
  manager.refreshSessionUserFactProfile?.(runtimeSession as VoiceSession, subject);
}
invalidateSessionBehavioralMemoryCache(runtimeSession);

}

return result; }