src/bot/memorySlice.ts

import { collectMemoryFactHints } from "./botHelpers.ts"; import { isOwnerPrivateContext } from "../memory/memoryContext.ts"; import { clamp } from "../utils.ts"; import type { BotContext } from "./botContext.ts";

type MemoryTrace = Record<string, unknown> & { source?: string; };

type MemorySettings = { memory?: { enabled?: boolean; }; } & Record<string, unknown>;

type FactProfileSlice = { participantProfiles: Array<Record<string, unknown>>; selfFacts: Array<Record<string, unknown>>; loreFacts: Array<Record<string, unknown>>; ownerFacts: Array<Record<string, unknown>>; userFacts: Array<Record<string, unknown>>; relevantFacts: Array<Record<string, unknown>>; guidanceFacts: Array<Record<string, unknown>>; };

type LoadFactProfileOptions = { settings: MemorySettings; userId?: string | null; guildId?: string | null; channelId?: string | null; queryText?: string; recentMessages?: Array<Record<string, unknown>>; trace?: MemoryTrace; source?: string; };

export function emptyFactProfileSlice(): FactProfileSlice { return { participantProfiles: [], selfFacts: [], loreFacts: [], ownerFacts: [], userFacts: [], relevantFacts: [], guidanceFacts: [] }; }

export function normalizeFactProfileSlice(slice: unknown): FactProfileSlice { const value = slice && typeof slice === "object" && !Array.isArray(slice) ? slice as Record<string, unknown> : {}; return { participantProfiles: Array.isArray(value.participantProfiles) ? value.participantProfiles as Array<Record<string, unknown>> : [], selfFacts: Array.isArray(value.selfFacts) ? value.selfFacts as Array<Record<string, unknown>> : [], loreFacts: Array.isArray(value.loreFacts) ? value.loreFacts as Array<Record<string, unknown>> : [], ownerFacts: Array.isArray(value.ownerFacts) ? value.ownerFacts as Array<Record<string, unknown>> : [], userFacts: Array.isArray(value.userFacts) ? value.userFacts as Array<Record<string, unknown>> : [], relevantFacts: Array.isArray(value.relevantFacts) ? value.relevantFacts as Array<Record<string, unknown>> : [], guidanceFacts: Array.isArray(value.guidanceFacts) ? value.guidanceFacts as Array<Record<string, unknown>> : [] }; }

type BuildMediaMemoryFactsOptions = { userFacts?: Array<Record<string, unknown> | string>; relevantFacts?: Array<Record<string, unknown> | string>; maxItems?: number; };

type ScopedFallbackFactsOptions = { guildId?: string | null; channelId?: string | null; limit?: number; };

type LoadRelevantMemoryFactsOptions = { settings: MemorySettings; guildId: string; channelId?: string | null; queryText?: string; trace?: MemoryTrace; limit?: number; fallbackWhenNoMatch?: boolean; };

function collectConversationParticipants( recentMessages: Array<Record<string, unknown>> = [], { focusUserId = null, botUserId = null }: { focusUserId?: string | null; botUserId?: string | null; } = {} ) { const normalizedFocusUserId = String(focusUserId || "").trim() || null; const normalizedBotUserId = String(botUserId || "").trim() || null; const byUserId = new Map<string, string>();

for (const row of Array.isArray(recentMessages) ? recentMessages : []) { const userId = String(row?.author_id || row?.authorId || "").trim(); if (!userId || userId === normalizedBotUserId) continue; const displayName = String(row?.author_name || row?.authorName || "").trim() || userId; if (!byUserId.has(userId)) { byUserId.set(userId, displayName); } }

if (normalizedFocusUserId && !byUserId.has(normalizedFocusUserId)) { byUserId.set(normalizedFocusUserId, normalizedFocusUserId); }

const ordered = [...byUserId.entries()] .map(([userId, displayName]) => ({ userId, displayName, isPrimary: userId === normalizedFocusUserId })) .sort((a, b) => { if (a.isPrimary !== b.isPrimary) return a.isPrimary ? -1 : 1; return a.displayName.localeCompare(b.displayName); });

return { participantIds: ordered.map((entry) => entry.userId), participantNames: Object.fromEntries(ordered.map((entry) => [entry.userId, entry.displayName])) }; }

export function loadFactProfile( ctx: BotContext, { settings, userId = null, guildId, channelId = null, queryText: _queryText = "", recentMessages = [], trace: _trace = {}, source = "fact_profile" }: LoadFactProfileOptions ) { const empty = emptyFactProfileSlice(); if (!settings?.memory?.enabled || typeof ctx.memory?.loadFactProfile !== "function") { return empty; }

const normalizedGuildId = String(guildId || "").trim() || null; const normalizedUserId = String(userId || "").trim() || null; const normalizedChannelId = String(channelId || "").trim() || null; const normalizedSource = String(source || "fact_profile").trim() || "fact_profile"; const participants = collectConversationParticipants(recentMessages, { focusUserId: normalizedUserId, botUserId: ctx.botUserId || null });

try { const factProfile = normalizeFactProfileSlice(ctx.memory.loadFactProfile({ userId: normalizedUserId, guildId: normalizedGuildId, participantIds: participants.participantIds, participantNames: participants.participantNames, includeOwner: isOwnerPrivateContext({ guildId: normalizedGuildId, actorUserId: normalizedUserId }) })); return factProfile; } catch (error) { ctx.store.logAction({ kind: "bot_error", guildId: normalizedGuildId || null, channelId: normalizedChannelId, userId: normalizedUserId, content: ${normalizedSource}: ${String(error?.message || error)} }); return empty; } }

export function buildMediaMemoryFacts({ userFacts = [], relevantFacts = [], maxItems = 5 }: BuildMediaMemoryFactsOptions = {}) { const merged = [ ...(Array.isArray(userFacts) ? userFacts : []), ...(Array.isArray(relevantFacts) ? relevantFacts : []) ]; const max = clamp(Math.floor(Number(maxItems) || 5), 1, 8); return collectMemoryFactHints(merged, max); }

export function getScopedFallbackFacts( ctx: BotContext, { guildId, channelId = null, limit = 8 }: ScopedFallbackFactsOptions ) { const normalizedGuildId = String(guildId || "").trim(); if (!normalizedGuildId || typeof ctx.store?.getFactsForScope !== "function") return [];

const boundedLimit = clamp(Math.floor(Number(limit) || 8), 1, 24); const candidateLimit = clamp(boundedLimit * 4, boundedLimit, 120); const rows = ctx.store.getFactsForScope({ guildId: normalizedGuildId, limit: candidateLimit }); if (!rows.length) return [];

const normalizedChannelId = String(channelId || "").trim(); if (!normalizedChannelId) return rows.slice(0, boundedLimit);

const sameChannel = []; const noChannel = []; const otherChannel = []; for (const row of rows) { const rowChannelId = String(row?.channel_id || "").trim(); if (rowChannelId && rowChannelId === normalizedChannelId) { sameChannel.push(row); continue; } if (!rowChannelId) { noChannel.push(row); continue; } otherChannel.push(row); }

return [...sameChannel, ...noChannel, ...otherChannel].slice(0, boundedLimit); }

export async function loadRelevantMemoryFacts( ctx: BotContext, { settings, guildId, channelId = null, queryText = "", trace = {}, limit = 8, fallbackWhenNoMatch = true }: LoadRelevantMemoryFactsOptions ) { if (!settings?.memory?.enabled || !ctx.memory?.searchDurableFacts) return [];

const normalizedGuildId = String(guildId || "").trim(); if (!normalizedGuildId) return [];

const normalizedChannelId = String(channelId || "").trim() || null; const boundedLimit = clamp(Math.floor(Number(limit) || 8), 1, 24); const normalizedQuery = String(queryText || "") .replace(/\s+/g, " ") .trim() .slice(0, 320); if (!normalizedQuery) { return getScopedFallbackFacts(ctx, { guildId: normalizedGuildId, channelId: normalizedChannelId, limit: boundedLimit }); }

try { const results = await ctx.memory.searchDurableFacts({ guildId: normalizedGuildId, channelId: normalizedChannelId, queryText: normalizedQuery, settings, trace: { ...trace, source: trace?.source || "memory_context" }, limit: boundedLimit }); if (results.length || !fallbackWhenNoMatch) return results; return getScopedFallbackFacts(ctx, { guildId: normalizedGuildId, channelId: normalizedChannelId, limit: boundedLimit }); } catch (error) { ctx.store.logAction({ kind: "bot_error", guildId: normalizedGuildId, channelId: normalizedChannelId, content: memory_context: ${String(error?.message || error)}, metadata: { queryText: normalizedQuery.slice(0, 120), source: trace?.source || "memory_context" } }); return getScopedFallbackFacts(ctx, { guildId: normalizedGuildId, channelId: normalizedChannelId, limit: boundedLimit }); } }

export async function loadBehavioralMemoryFacts( ctx: BotContext, { settings, guildId, channelId = null, queryText = "", participantIds = [], trace = {}, limit = 8 }: { settings: MemorySettings; guildId: string; channelId?: string | null; queryText?: string; participantIds?: string[]; trace?: MemoryTrace; limit?: number; } ) { if (!settings?.memory?.enabled || typeof ctx.memory?.loadBehavioralFactsForPrompt !== "function") return [];

const normalizedGuildId = String(guildId || "").trim(); if (!normalizedGuildId) return [];

const normalizedQuery = String(queryText || "") .replace(/\s+/g, " ") .trim() .slice(0, 420); if (!normalizedQuery) return [];

try { return await ctx.memory.loadBehavioralFactsForPrompt({ guildId: normalizedGuildId, channelId: String(channelId || "").trim() || null, queryText: normalizedQuery, participantIds, settings, trace: { ...trace, source: trace?.source || "behavioral_memory_context" }, limit }); } catch (error) { ctx.store.logAction({ kind: "bot_error", guildId: normalizedGuildId, channelId: String(channelId || "").trim() || null, content: behavioral_memory_context: ${String(error?.message || error)}, metadata: { queryText: normalizedQuery.slice(0, 120), source: trace?.source || "behavioral_memory_context" } }); return []; } }