src/memory/memoryManager.ts

import fs from "node:fs/promises"; import path from "node:path"; import { clamp01, clampInt } from "../normalization/numbers.ts"; import { sleep } from "../normalization/time.ts"; import { getMemorySettings } from "../settings/agentStack.ts"; import { LORE_SUBJECT, OWNER_SUBJECT, SELF_SUBJECT, buildFactEmbeddingPayload, buildHighlightsSection, cleanDailyEntryContent, computeChannelScopeScore, computeLexicalFactScore, computeRecencyScore, computeTemporalDecayMultiplier, extractStableTokens, formatDateLocal, formatTypedFactForMemory, isBehavioralDirectiveLikeFactText, isUnsafeMemoryFactText, isInstructionLikeFactText, normalizeEvidenceText, normalizeFactType, normalizeHighlightText, normalizeLoreFactForDisplay, normalizeMemoryLineInput, normalizeQueryEmbeddingText, normalizeStoredFactText, normalizeSelfFactForDisplay, parseDailyEntryLineWithScope, passesHybridRelevanceGate, rerankWithMmr, resolveDirectiveScopeConfig, sanitizeInline, } from "./memoryHelpers.ts"; import { runDailyReflection } from "./dailyReflection.ts"; import { runMicroReflection } from "./microReflection.ts"; import type { MemoryFactRow } from "../store/storeMemory.ts";

// Daily transcript journals are stored as YYYY-MM-DD.md files. const DAILY_FILE_PATTERN = /^\d{4}-\d{2}-\d{2}.md$/;

// Hybrid memory retrieval tuning: controls candidate breadth vs ranking cost. const HYBRID_FACT_LIMIT = 10; const HYBRID_CANDIDATE_MULTIPLIER = 6; const HYBRID_MAX_CANDIDATES = 90; const HYBRID_MAX_VECTOR_BACKFILL_PER_QUERY = 8;

// Query embedding cache for repeated lookups during short bursts. const QUERY_EMBEDDING_CACHE_TTL_MS = 60 * 1000; const QUERY_EMBEDDING_CACHE_MAX_ENTRIES = 256;

// Text-channel micro-reflection trigger windows and cooldown behavior. const TEXT_MICRO_REFLECTION_SILENCE_MS = 10 * 60 * 1000; const TEXT_MICRO_REFLECTION_LOOKBACK_MS = 30 * 60 * 1000; const TEXT_MICRO_REFLECTION_CONTEXT_PRESSURE_MARGIN = 4; const TEXT_MICRO_REFLECTION_CONTEXT_PRESSURE_COOLDOWN_MS = 2 * 60 * 1000;

// Canonical fact type labels used by directive/fact filtering paths. const GUIDANCE_FACT_TYPE = "guidance"; const BEHAVIORAL_FACT_TYPE = "behavioral";

// Limits for prompt construction and hybrid reranking behavior. const FULL_MEMORY_DUMP_LIMIT = 200; const HYBRID_RECENT_CANDIDATE_LIMIT = 24; const HYBRID_MMR_LAMBDA = 0.7; const HYBRID_TEMPORAL_DECAY_HALF_LIFE_DAYS = 90; const HYBRID_TEMPORAL_DECAY_MIN_MULTIPLIER = 0.2;

// Per-section caps to keep memory prompts focused and bounded. const MAX_USER_PROFILE_FACTS = 20; const MAX_USER_GUIDANCE_FACTS = 8; const MAX_GUILD_SELF_FACTS = 10; const MAX_GUILD_LORE_FACTS = 10; const MAX_GUILD_GUIDANCE_FACTS = 12; const MAX_PROFILE_GUIDANCE_FACTS = 24; const MAX_PRIMARY_PARTICIPANT_FACTS = 12; const MAX_SECONDARY_PARTICIPANT_FACTS = 6; const MAX_SECONDARY_RELEVANT_FACTS = 3; const MAX_CONVERSATION_QUERY_CHARS = 320; const MAX_BEHAVIORAL_QUERY_CHARS = 420; const MAX_SECTION_FACTS = 6; const MAX_PEOPLE_FACTS_PER_SUBJECT = 6; const MAX_DIRECTIVE_EVIDENCE_CHARS = 220;

// Ingest worker backpressure and shutdown drain behavior. const MAX_INGEST_QUEUE_SIZE = 400; const DEFAULT_INGEST_DRAIN_TIMEOUT_MS = 5_000; const MIN_INGEST_DRAIN_TIMEOUT_MS = 100; const INGEST_QUEUE_POLL_INTERVAL_MS = 25;

// Database fetch caps for reflection, profiles, and markdown snapshots. const FACT_PROFILE_LOAD_LIMIT = 120; const REFLECTION_EXISTING_FACT_LIMIT = 200; const MEMORY_MARKDOWN_REFRESH_DEBOUNCE_MS = 1000; const MARKDOWN_RECENT_DAILY_DAYS = 3; const MARKDOWN_RECENT_DAILY_MAX_ENTRIES = 120; const MARKDOWN_HIGHLIGHT_MAX_ITEMS = 24; const MARKDOWN_RECENT_DAILY_FILES = 5; const MAX_MEMORY_SUBJECTS = 80; const PEOPLE_FACT_TOTAL_LIMIT_MIN = 200; const PEOPLE_FACT_TOTAL_LIMIT_MAX = 1200; const PEOPLE_FACT_TOTAL_LIMIT_MULTIPLIER = 10; const FACT_SECTION_SUBJECT_FETCH_LIMIT = 32;

// Settling delay before micro-reflection runs after queued text events. const DEFAULT_MICRO_REFLECTION_SETTLE_TIMEOUT_MS = 8_000; const MIN_MICRO_REFLECTION_SETTLE_TIMEOUT_MS = 100; const RECENT_VOICE_SESSION_SUMMARY_WINDOW_MINUTES = 30; const RECENT_VOICE_SESSION_SUMMARY_LIMIT = 2; const RECENT_VOICE_SESSION_SUMMARY_RETENTION_HOURS = 24; const VOICE_PRE_COMPACTION_MAX_FACTS = 4; const VOICE_PRE_COMPACTION_MAX_ENTRIES = 24; const VOICE_PRE_COMPACTION_MAX_TOTAL_CHARS = 3_200;

function toIsoOrNull(value: unknown) { const numeric = Number(value); if (Number.isFinite(numeric) && numeric > 0) { return new Date(numeric).toISOString(); } const normalized = String(value || "").trim(); if (!normalized) return null; const parsed = Date.parse(normalized); if (!Number.isFinite(parsed)) return null; return new Date(parsed).toISOString(); }

function sortProfileFacts(rows: T[]) { return [...(Array.isArray(rows) ? rows : [])].sort((left, right) => { const confidenceDelta = Number(right?.confidence || 0) - Number(left?.confidence || 0); if (Math.abs(confidenceDelta) > 1e-6) return confidenceDelta; const updatedDelta = Date.parse(String(right?.updated_at || "")) - Date.parse(String(left?.updated_at || "")); if (updatedDelta !== 0) return updatedDelta; return Number(right?.id || 0) - Number(left?.id || 0); }); }

function buildPromptSubjectLabel(subject: string, subjectLabels: Record<string, string> = {}) { const normalizedSubject = String(subject || "").trim(); if (!normalizedSubject) return "unknown"; if (subjectLabels[normalizedSubject]) return String(subjectLabels[normalizedSubject]).trim() || normalizedSubject; if (normalizedSubject === SELF_SUBJECT) return "Bot"; if (normalizedSubject === LORE_SUBJECT) return "Shared lore"; return normalizedSubject; }

function decoratePromptFactRows(rows: MemoryFactRow[], subjectLabels: Record<string, string> = {}) { return sortProfileFacts(rows).map((row) => ({ ...row, subjectLabel: buildPromptSubjectLabel(String(row?.subject || ""), subjectLabels) })); }

function dedupePromptFactRows(rows: Array<MemoryFactRow & { subjectLabel?: string }>) { const seen = new Set(); const deduped: Array<MemoryFactRow & { subjectLabel?: string }> = []; for (const row of rows) { const key = [ String(row?.id || ""), String(row?.subject || ""), String(row?.fact_type || ""), String(row?.fact || "") ].join("::"); if (seen.has(key)) continue; seen.add(key); deduped.push(row); } return deduped; }

function mergeUniqueFactCandidates(...groups: Array<Array | null | undefined>) { const merged = new Map<number, MemoryFactRow>(); for (const group of groups) { for (const row of Array.isArray(group) ? group : []) { const rowId = Number(row?.id); if (!Number.isInteger(rowId) || rowId <= 0) continue; const existing = merged.get(rowId); if (!existing) { merged.set(rowId, row); continue; } merged.set(rowId, { ...existing, ...row, lexical_score: Number.isFinite(Number(row?.lexical_score)) ? Number(row.lexical_score) : Number.isFinite(Number(existing?.lexical_score)) ? Number(existing.lexical_score) : null, semantic_score: Number.isFinite(Number(row?.semantic_score)) ? Number(row.semantic_score) : Number.isFinite(Number(existing?.semantic_score)) ? Number(existing.semantic_score) : null }); } } return [...merged.values()]; }

export class MemoryManager { store; llm; memoryFilePath; memoryDirPath; pendingWrite; initializedDailyFiles; ingestQueue; ingestQueuedJobs; ingestWorkerActive; maxIngestQueue; queryEmbeddingCache; queryEmbeddingInFlight; dailyLogMessageIds; textMicroReflectionTimers; textMicroReflectionState; microReflectionInFlight;

constructor({ store, llm, memoryFilePath }) { this.store = store; this.llm = llm; this.memoryFilePath = memoryFilePath; this.memoryDirPath = path.dirname(memoryFilePath); this.pendingWrite = false; this.initializedDailyFiles = new Set(); this.ingestQueue = []; this.ingestQueuedJobs = new Map(); this.ingestWorkerActive = false; this.maxIngestQueue = MAX_INGEST_QUEUE_SIZE; this.queryEmbeddingCache = new Map(); this.queryEmbeddingInFlight = new Map(); this.dailyLogMessageIds = new Map(); this.textMicroReflectionTimers = new Map(); this.textMicroReflectionState = new Map(); this.microReflectionInFlight = new Set(); }

async ingestMessage({ messageId, authorId, authorName, content, isBot = false, settings, trace = { guildId: null, channelId: null, userId: null, source: null } }) { const normalizedMessageId = String(messageId || "").trim(); if (!normalizedMessageId) return false;

const existingJob = this.ingestQueuedJobs.get(normalizedMessageId);
if (existingJob?.promise) {
  return existingJob.promise;
}

this.recordVoiceTranscriptMessage({
  messageId: normalizedMessageId,
  authorId,
  authorName,
  content,
  isBot,
  trace
});

if (this.ingestQueue.length >= this.maxIngestQueue) {
  const dropped = this.ingestQueue.shift();
  if (dropped?.messageId) {
    this.ingestQueuedJobs.delete(dropped.messageId);
  }
  if (typeof dropped?.resolve === "function") {
    dropped.resolve(false);
  }
  this.logMemoryError("ingest_queue_overflow", "ingest queue full; dropping oldest message", {
    droppedMessageId: dropped?.messageId || null
  });
}

let resolveJob = (_value = false) => undefined;
const promise = new Promise<boolean>((resolve) => {
  resolveJob = resolve;
});

const job = {
  messageId: normalizedMessageId,
  authorId: String(authorId || "").trim(),
  authorName: String(authorName || "unknown"),
  content,
  isBot: Boolean(isBot),
  settings,
  trace,
  resolve: resolveJob,
  promise
};
this.ingestQueue.push(job);
this.ingestQueuedJobs.set(normalizedMessageId, job);
void this.runIngestWorker();
return promise;

}

recordVoiceTranscriptMessage({ messageId, authorId, authorName, content, isBot = false, trace = { guildId: null, channelId: null, userId: null, source: null } }) { if (!String(messageId || "").startsWith("voice-")) return; if (typeof this.store?.recordMessage !== "function") return;

const cleanedContent = cleanDailyEntryContent(content);
const normalizedChannelId = String(trace?.channelId || "").trim();
const normalizedAuthorId = String(authorId || trace?.userId || "").trim();
if (!cleanedContent || !normalizedChannelId || !normalizedAuthorId) return;

try {
  this.store.recordMessage({
    messageId: String(messageId),
    guildId: String(trace?.guildId || "").trim() || null,
    channelId: normalizedChannelId,
    authorId: normalizedAuthorId,
    authorName: String(authorName || "unknown")
      .replace(/\s+/g, " ")
      .trim() || "unknown",
    isBot: Boolean(isBot),
    content: cleanedContent
  });
} catch (error) {
  this.logMemoryError("voice_history_record", error, {
    messageId: String(messageId || ""),
    userId: normalizedAuthorId,
    channelId: normalizedChannelId
  });
}

}

async runIngestWorker() { if (this.ingestWorkerActive) return; this.ingestWorkerActive = true;

try {
  while (this.ingestQueue.length) {
    const job = this.ingestQueue.shift();
    if (!job) continue;
    this.ingestQueuedJobs.delete(job.messageId);
    try {
      await this.processIngestMessage(job);
      if (typeof job.resolve === "function") job.resolve(true);
    } catch (error) {
      this.logMemoryError("ingest_worker", error, {
        messageId: job.messageId,
        userId: job.authorId
      });
      if (typeof job.resolve === "function") job.resolve(false);
    }
  }
} finally {
  this.ingestWorkerActive = false;
}

}

async processIngestMessage({ messageId, authorId, authorName, content, isBot = false, settings = null, trace = { guildId: null, channelId: null, userId: null, source: null } }) { const cleanedContent = cleanDailyEntryContent(content); if (!cleanedContent) return; const scopeGuildId = String(trace?.guildId || "").trim(); const scopeChannelId = String(trace?.channelId || "").trim();

const source = String(trace?.source || "").trim();
const isVoice = source.startsWith("voice");

try {
  await this.appendDailyLogEntry({
    messageId,
    authorId,
    authorName,
    guildId: scopeGuildId,
    channelId: scopeChannelId,
    content: cleanedContent,
    isVoice
  });
  this.queueMemoryRefresh();
  void this.ensureConversationMessageVector({
    messageId,
    content: cleanedContent,
    settings,
    trace
  });
  if (!isBot) {
    this.scheduleTextChannelMicroReflection({
      messageId,
      guildId: scopeGuildId,
      channelId: scopeChannelId,
      settings
    });
  }
} catch (error) {
  this.logMemoryError("daily_log_write", error, { messageId, userId: authorId });
}

}

async drainIngestQueue({ timeoutMs = DEFAULT_INGEST_DRAIN_TIMEOUT_MS } = {}) { const timeout = Math.max(MIN_INGEST_DRAIN_TIMEOUT_MS, Number(timeoutMs) || DEFAULT_INGEST_DRAIN_TIMEOUT_MS); const deadline = Date.now() + timeout; while ((this.ingestWorkerActive || this.ingestQueue.length) && Date.now() < deadline) { await sleep(INGEST_QUEUE_POLL_INTERVAL_MS); } }

loadUserFactProfile({ userId, guildId: _guildId }: { userId?: string | null; guildId?: string | null }) { const normalizedUserId = String(userId || "").trim(); if (!normalizedUserId) { return { userFacts: [] as MemoryFactRow[], guidanceFacts: [] as MemoryFactRow[] }; }

const rows = this.store.getFactsForScope({
  scope: "user",
  subjectIds: [normalizedUserId],
  limit: FACT_PROFILE_LOAD_LIMIT
});
const guidanceFacts = rows.filter((row) => String(row?.fact_type || "").trim() === GUIDANCE_FACT_TYPE);
const userFacts = rows.filter((row) => {
  const factType = String(row?.fact_type || "").trim();
  return factType !== GUIDANCE_FACT_TYPE && factType !== BEHAVIORAL_FACT_TYPE;
});

return {
  userFacts: sortProfileFacts(userFacts).slice(0, MAX_USER_PROFILE_FACTS),
  guidanceFacts: decoratePromptFactRows(guidanceFacts, { [normalizedUserId]: normalizedUserId }).slice(0, MAX_USER_GUIDANCE_FACTS)
};

}

loadGuildFactProfile({ guildId }: { guildId?: string | null }) { const normalizedGuildId = String(guildId || "").trim(); if (!normalizedGuildId) { return { selfFacts: [] as MemoryFactRow[], loreFacts: [] as MemoryFactRow[], guidanceFacts: [] as MemoryFactRow[] }; }

const rows = this.store.getFactsForScope({
  scope: "guild",
  guildId: normalizedGuildId,
  subjectIds: [SELF_SUBJECT, LORE_SUBJECT],
  limit: FACT_PROFILE_LOAD_LIMIT
});
const guidanceFacts = rows.filter((row) => String(row?.fact_type || "").trim() === GUIDANCE_FACT_TYPE);
const regularFacts = rows.filter((row) => {
  const factType = String(row?.fact_type || "").trim();
  return factType !== GUIDANCE_FACT_TYPE && factType !== BEHAVIORAL_FACT_TYPE;
});

return {
  selfFacts: sortProfileFacts(
    regularFacts.filter((row) => String(row.subject || "").trim() === SELF_SUBJECT)
  ).slice(0, MAX_GUILD_SELF_FACTS),
  loreFacts: sortProfileFacts(
    regularFacts.filter((row) => String(row.subject || "").trim() === LORE_SUBJECT)
  ).slice(0, MAX_GUILD_LORE_FACTS),
  guidanceFacts: decoratePromptFactRows(guidanceFacts, {
    [SELF_SUBJECT]: "Bot",
    [LORE_SUBJECT]: "Shared lore"
  }).slice(0, MAX_GUILD_GUIDANCE_FACTS)
};

}

loadOwnerFactProfile() { const rows = this.store.getFactsForScope({ scope: "owner", subjectIds: [OWNER_SUBJECT], limit: FACT_PROFILE_LOAD_LIMIT }); const guidanceFacts = rows.filter((row) => String(row?.fact_type || "").trim() === GUIDANCE_FACT_TYPE); const ownerFacts = rows.filter((row) => { const factType = String(row?.fact_type || "").trim(); return factType !== GUIDANCE_FACT_TYPE && factType !== BEHAVIORAL_FACT_TYPE; }); return { ownerFacts: sortProfileFacts(ownerFacts).slice(0, MAX_USER_PROFILE_FACTS), guidanceFacts: decoratePromptFactRows(guidanceFacts, { [OWNER_SUBJECT]: "Owner private" }).slice(0, MAX_USER_GUIDANCE_FACTS) }; }

loadExistingFactsForReflection({ guildId, subjectIds }: { guildId: string; subjectIds: string[] }) { const normalizedGuildId = String(guildId || "").trim(); if (!normalizedGuildId || !subjectIds.length) return []; const guildRows = this.store.getFactProfileRows({ guildId: normalizedGuildId, scope: "guild", subjects: subjectIds, limit: REFLECTION_EXISTING_FACT_LIMIT }); const userRows = this.store.getFactProfileRows({ scope: "user", subjects: subjectIds, limit: REFLECTION_EXISTING_FACT_LIMIT }); const rows = mergeUniqueFactCandidates(guildRows, userRows); return rows.map((row) => ({ id: Number(row.id || 0), subject: String(row.subject || ""), fact: String(row.fact || ""), fact_type: String(row.fact_type || "other") })); }

loadFactProfile({ userId, guildId, participantIds = [], participantNames = {}, includeOwner = false }: { userId?: string | null; guildId?: string | null; participantIds?: string[]; participantNames?: Record<string, string>; includeOwner?: boolean; }) { const normalizedUserId = String(userId || "").trim() || null; const normalizedParticipantIds = [ ...new Set( (Array.isArray(participantIds) ? participantIds : []) .map((value) => String(value || "").trim()) .filter(Boolean) ) ]; if (normalizedUserId && !normalizedParticipantIds.includes(normalizedUserId)) { normalizedParticipantIds.unshift(normalizedUserId); } const normalizedGuildId = String(guildId || "").trim();

const subjectLabels: Record<string, string> = {
  [SELF_SUBJECT]: "Bot",
  [LORE_SUBJECT]: "Shared lore",
  [OWNER_SUBJECT]: "Owner private"
};
for (const participantId of normalizedParticipantIds) {
  subjectLabels[participantId] = String(participantNames?.[participantId] || participantId).trim() || participantId;
}

const userRows = this.store.getFactsForScope({
  scope: "user",
  subjectIds: [...normalizedParticipantIds, SELF_SUBJECT],
  limit: Math.max(120, (normalizedParticipantIds.length + 1) * 40)
});
const guildRows = normalizedGuildId
  ? this.store.getFactsForScope({
    scope: "guild",
    guildId: normalizedGuildId,
    subjectIds: [...normalizedParticipantIds, SELF_SUBJECT, LORE_SUBJECT],
    limit: Math.max(120, (normalizedParticipantIds.length + 2) * 40)
  })
  : [];
const rows = mergeUniqueFactCandidates(userRows, guildRows);
const ownerRows = includeOwner
  ? this.store.getFactsForScope({
    scope: "owner",
    subjectIds: [OWNER_SUBJECT],
    limit: 80
  })
  : [];
const mergedRows = mergeUniqueFactCandidates(rows, ownerRows);
const regularFacts = sortProfileFacts(
  mergedRows.filter((row) => {
    const factType = String(row?.fact_type || "").trim();
    return factType !== GUIDANCE_FACT_TYPE && factType !== BEHAVIORAL_FACT_TYPE;
  })
);
const guidanceFacts = dedupePromptFactRows(
  decoratePromptFactRows(
    mergedRows.filter((row) => String(row?.fact_type || "").trim() === GUIDANCE_FACT_TYPE),
    subjectLabels
  )
).slice(0, MAX_PROFILE_GUIDANCE_FACTS);
const participants = normalizedParticipantIds.map((participantId) => {
  const participantFacts = regularFacts.filter((row) => String(row?.subject || "").trim() === participantId);
  return {
    userId: participantId,
    displayName: subjectLabels[participantId],
    isPrimary: participantId === normalizedUserId,
    facts: participantFacts.slice(0, participantId === normalizedUserId ? MAX_PRIMARY_PARTICIPANT_FACTS : MAX_SECONDARY_PARTICIPANT_FACTS)
  };
});
const primaryProfile = participants.find((entry) => entry.isPrimary) || participants[0] || null;
const selfFacts = regularFacts.filter((row) => String(row?.subject || "").trim() === SELF_SUBJECT).slice(0, MAX_GUILD_SELF_FACTS);
const loreFacts = regularFacts
  .filter((row) => String(row?.subject || "").trim() === LORE_SUBJECT)
  .slice(0, MAX_GUILD_LORE_FACTS);
const ownerFacts = regularFacts
  .filter((row) => String(row?.subject || "").trim() === OWNER_SUBJECT)
  .slice(0, MAX_USER_PROFILE_FACTS);
const secondaryFacts = participants
  .filter((entry) => !entry.isPrimary)
  .flatMap((entry) => entry.facts.slice(0, MAX_SECONDARY_RELEVANT_FACTS));

return {
  participantProfiles: participants.map((entry) => ({
    userId: entry.userId,
    displayName: entry.displayName,
    isPrimary: entry.isPrimary,
    facts: entry.facts
  })),
  selfFacts,
  loreFacts,
  userFacts: Array.isArray(primaryProfile?.facts) ? primaryProfile.facts : [],
  ownerFacts,
  relevantFacts: [...secondaryFacts, ...selfFacts, ...loreFacts, ...ownerFacts],
  guidanceFacts
};

}

async loadBehavioralFactsForPrompt({ guildId, channelId = null, queryText, participantIds = [], settings, trace = {}, limit = 8 }: { guildId: string; channelId?: string | null; queryText: string; participantIds?: string[]; settings?: Record<string, unknown> | null; trace?: Record<string, unknown>; limit?: number; }) { const normalizedGuildId = String(guildId || "").trim(); const normalizedQueryText = String(queryText || "").replace(/\s+/g, " ").trim().slice(0, MAX_BEHAVIORAL_QUERY_CHARS); if (!normalizedGuildId || !normalizedQueryText) return [];

const subjectIds = [
  ...new Set(
    [SELF_SUBJECT, LORE_SUBJECT, ...(Array.isArray(participantIds) ? participantIds : [])]
      .map((value) => String(value || "").trim())
      .filter(Boolean)
  )
];
const rows = await this.searchDurableFacts({
  guildId: normalizedGuildId,
  scope: "guild",
  channelId: String(channelId || "").trim() || null,
  queryText: normalizedQueryText,
  subjectIds,
  factTypes: [BEHAVIORAL_FACT_TYPE],
  settings,
  trace,
  limit: clampInt(limit, 1, 12)
});
return decoratePromptFactRows(rows as MemoryFactRow[], {
  [SELF_SUBJECT]: "Bot",
  [LORE_SUBJECT]: "Shared lore"
}).slice(0, clampInt(limit, 1, 12));

}

async ensureConversationMessageVector({ messageId, content, settings, trace = {} }: { messageId?: string | null; content?: string | null; settings?: Record<string, unknown> | null; trace?: Record<string, unknown>; }) { const normalizedMessageId = String(messageId || "").trim(); const payload = cleanDailyEntryContent(content); if (!normalizedMessageId || !payload) return null; if (!this.llm?.isEmbeddingReady?.()) return null; if (typeof this.store?.upsertMessageVectorNative !== "function") return null;

try {
  const embedded = await this.llm.embedText({
    settings,
    text: payload,
    trace: {
      ...trace,
      source: String(trace?.source || "conversation_message_embed")
    }
  });
  const vector = Array.isArray(embedded?.embedding)
    ? embedded.embedding.map((value) => Number(value))
    : [];
  const model = String(embedded?.model || "").trim();
  if (!vector.length || !model) return null;
  this.store.upsertMessageVectorNative({
    messageId: normalizedMessageId,
    model,
    embedding: vector
  });
  return {
    model,
    dims: vector.length,
    embedding: vector
  };
} catch {
  return null;
}

}

async searchConversationHistory({ guildId, channelId = null, queryText, settings, trace = {}, limit = 3, maxAgeHours = 24 * 7, before = 1, after = 1 }: { guildId?: string | null; channelId?: string | null; queryText: string; settings?: Record<string, unknown> | null; trace?: Record<string, unknown>; limit?: number; maxAgeHours?: number; before?: number; after?: number; }) { const normalizedGuildId = String(guildId || "").trim() || null; const normalizedChannelId = String(channelId || "").trim() || null; const normalizedQuery = String(queryText || "").replace(/\s+/g, " ").trim().slice(0, MAX_CONVERSATION_QUERY_CHARS); if ((!normalizedGuildId && !normalizedChannelId) || !normalizedQuery) return [];

try {
  const queryEmbedding = await this.getQueryEmbeddingForRetrieval({
    queryText: normalizedQuery,
    settings,
    trace: {
      ...trace,
      source: String(trace?.source || "conversation_history_query")
    }
  });
  if (
    queryEmbedding?.embedding?.length &&
    queryEmbedding?.model &&
    typeof this.store?.searchConversationWindowsByEmbedding === "function"
  ) {
    const semanticWindows = this.store.searchConversationWindowsByEmbedding({
      guildId: normalizedGuildId,
      channelId: normalizedChannelId,
      queryEmbedding: queryEmbedding.embedding,
      model: queryEmbedding.model,
      limit: clampInt(limit, 1, 8),
      maxAgeHours: clampInt(maxAgeHours, 1, 24 * 30),
      before: clampInt(before, 0, 4),
      after: clampInt(after, 0, 4)
    });
    if (Array.isArray(semanticWindows) && semanticWindows.length > 0) {
      return semanticWindows;
    }
  }
} catch {
  // Fall back to lexical history search below.
}

if (typeof this.store?.searchConversationWindows !== "function") return [];
return this.store.searchConversationWindows({
  guildId: normalizedGuildId,
  channelId: normalizedChannelId,
  queryText: normalizedQuery,
  limit: clampInt(limit, 1, 8),
  maxAgeHours: clampInt(maxAgeHours, 1, 24 * 30),
  before: clampInt(before, 0, 4),
  after: clampInt(after, 0, 4)
});

}

isVoiceConversationMessage(messageId = "") { return String(messageId || "").trim().startsWith("voice-"); }

scheduleTextChannelMicroReflection({ messageId, guildId, channelId, settings }: { messageId?: string | null; guildId?: string | null; channelId?: string | null; settings?: Record<string, unknown> | null; }) { const normalizedGuildId = String(guildId || "").trim(); const normalizedChannelId = String(channelId || "").trim(); const normalizedMessageId = String(messageId || "").trim(); if (!normalizedGuildId || !normalizedChannelId || !normalizedMessageId) return; if (this.isVoiceConversationMessage(normalizedMessageId)) return;

const resolvedSettings = settings || this.store.getSettings?.() || null;
const memorySettings = getMemorySettings(resolvedSettings);
if (!memorySettings.enabled || !memorySettings.reflection?.enabled) return;

const key = `${normalizedGuildId}:${normalizedChannelId}`;
const now = Date.now();
const currentTimer = this.textMicroReflectionTimers.get(key);
if (currentTimer) {
  clearTimeout(currentTimer);
}

const previousState = this.textMicroReflectionState.get(key) || {};
this.textMicroReflectionState.set(key, {
  ...previousState,
  guildId: normalizedGuildId,
  channelId: normalizedChannelId,
  lastMessageAtMs: now,
  lastMessageId: normalizedMessageId,
  settings: resolvedSettings
});

void this.maybeRunContextPressureMicroReflection({
  key,
  state: this.textMicroReflectionState.get(key),
  memorySettings
});

const timer = setTimeout(() => {
  this.textMicroReflectionTimers.delete(key);
  void this.runTextChannelMicroReflection(key).catch((error) => {
    this.logMemoryError("text_micro_reflection", error, {
      guildId: normalizedGuildId,
      channelId: normalizedChannelId
    });
  });
}, TEXT_MICRO_REFLECTION_SILENCE_MS);
this.textMicroReflectionTimers.set(key, timer);

}

async maybeRunContextPressureMicroReflection({ key, state, memorySettings }: { key: string; state: Record<string, unknown> | undefined; memorySettings: ReturnType; }) { if (!this.store?.getMessagesInWindow) return; if (!state || typeof state !== "object") return;

const guildId = String(state.guildId || "").trim();
const channelId = String(state.channelId || "").trim();
const lastMessageAtMs = Number(state.lastMessageAtMs || 0);
if (!guildId || !channelId || !lastMessageAtMs) return;

const inFlightKey = `text:${guildId}:${channelId}`;
if (this.microReflectionInFlight.has(inFlightKey)) return;

const now = Date.now();
const lastContextPressureAtMs = Number(state.lastContextPressureAtMs || 0);
if (lastContextPressureAtMs > 0 && now - lastContextPressureAtMs < TEXT_MICRO_REFLECTION_CONTEXT_PRESSURE_COOLDOWN_MS) {
  return;
}

const maxRecentMessages = clampInt(memorySettings.promptSlice?.maxRecentMessages, 4, 120);
const threshold = Math.max(6, maxRecentMessages - TEXT_MICRO_REFLECTION_CONTEXT_PRESSURE_MARGIN);
const processedThroughMs = Number(state.processedThroughMs || 0);
const sinceMs = Math.max(0, Math.max(processedThroughMs, lastMessageAtMs - TEXT_MICRO_REFLECTION_LOOKBACK_MS));

const entries = this.store.getMessagesInWindow({
  guildId,
  channelId,
  sinceIso: new Date(sinceMs).toISOString(),
  untilIso: new Date(lastMessageAtMs).toISOString(),
  limit: Math.max(maxRecentMessages * 2, 120)
});
const humanCount = (Array.isArray(entries) ? entries : []).filter((entry) => {
  const messageId = String(entry?.message_id || "");
  if (messageId.startsWith("reaction:")) return false;
  return !entry?.is_bot;
}).length;
if (humanCount < threshold) return;

this.textMicroReflectionState.set(key, {
  ...state,
  lastContextPressureAtMs: now
});

const startedAt = Date.now();
void this.runTextChannelMicroReflection(key, {
  trigger: "text_context_pressure",
  untilMs: lastMessageAtMs
})
  .then((result) => {
    this.store.logAction?.({
      kind: "text_runtime",
      guildId,
      channelId,
      content: "memory_micro_reflection_context_pressure",
      metadata: {
        trigger: "text_context_pressure",
        ok: Boolean(result?.ok),
        reason: result?.reason || null,
        humanCount,
        threshold,
        durationMs: Math.max(0, Date.now() - startedAt)
      }
    });
  })
  .catch((error) => {
    this.logMemoryError("text_micro_reflection_context_pressure", error, {
      guildId,
      channelId,
      humanCount,
      threshold
    });
  });

}

async runTextChannelMicroReflection( key = "", { trigger = "text_channel_silence", untilMs = null }: { trigger?: "text_channel_silence" | "text_context_pressure"; untilMs?: number | null; } = {} ) { const state = this.textMicroReflectionState.get(String(key || "").trim()) || null; if (!state) return { ok: false, reason: "state_missing" };

const guildId = String(state.guildId || "").trim();
const channelId = String(state.channelId || "").trim();
const lastMessageAtMs = Number(state.lastMessageAtMs || 0);
const reflectionUntilMs = Number.isFinite(Number(untilMs))
  ? Math.min(lastMessageAtMs, Number(untilMs))
  : lastMessageAtMs;
const settings = state.settings || this.store.getSettings?.() || null;
const memorySettings = getMemorySettings(settings);
if (!guildId || !channelId || !reflectionUntilMs || !memorySettings.enabled || !memorySettings.reflection?.enabled) {
  return { ok: false, reason: "state_invalid" };
}

const inFlightKey = `text:${guildId}:${channelId}`;
if (this.microReflectionInFlight.has(inFlightKey)) {
  return { ok: false, reason: "already_running" };
}

const processedThroughMs = Number(state.processedThroughMs || 0);
const sinceMs = Math.max(processedThroughMs, reflectionUntilMs - TEXT_MICRO_REFLECTION_LOOKBACK_MS);
const persistProcessedThrough = (value: number) => {
  const latestStateRaw = this.textMicroReflectionState.get(key);
  const latestState = latestStateRaw && typeof latestStateRaw === "object"
    ? latestStateRaw as Record<string, unknown>
    : state;
  const previousProcessedThroughMs = Number(latestState.processedThroughMs || 0);
  this.textMicroReflectionState.set(key, {
    ...latestState,
    processedThroughMs: Math.max(previousProcessedThroughMs, value)
  });
};
const entries = this.store.getMessagesInWindow({
  guildId,
  channelId,
  sinceIso: new Date(sinceMs).toISOString(),
  untilIso: new Date(reflectionUntilMs).toISOString(),
  limit: 120
});
const normalizedEntries = (Array.isArray(entries) ? entries : [])
  .filter((entry) => !String(entry?.message_id || "").startsWith("reaction:"))
  .map((entry) => ({
    timestampIso: String(entry?.created_at || "").trim(),
    timestampMs: Date.parse(String(entry?.created_at || "")),
    author: String(entry?.author_name || "unknown").trim() || "unknown",
    authorId: String(entry?.author_id || "").trim() || null,
    isBot: Boolean(entry?.is_bot),
    content: String(entry?.content || "").trim()
  }))
  .filter((entry) => !entry.isBot)
  .filter((entry) => entry.content);
if (!normalizedEntries.length) {
  persistProcessedThrough(reflectionUntilMs);
  return { ok: false, reason: "no_entries" };
}

this.microReflectionInFlight.add(inFlightKey);
try {
  const result = await runMicroReflection({
    memory: this,
    store: this.store,
    llm: this.llm,
    settings,
    guildId,
    channelId,
    trigger,
    sourceMessageId: `micro_reflection_text_${trigger}_${guildId}_${channelId}_${reflectionUntilMs}`,
    entries: normalizedEntries
  });
  persistProcessedThrough(reflectionUntilMs);
  return result;
} finally {
  this.microReflectionInFlight.delete(inFlightKey);
}

}

async runVoiceSessionMicroReflection({ guildId, channelId = null, sessionId, settings, startedAtMs, transcriptTurns = [], pendingMemoryIngest = null }: { guildId?: string | null; channelId?: string | null; sessionId?: string | null; settings?: Record<string, unknown> | null; startedAtMs?: number | null; transcriptTurns?: Array<Record<string, unknown>>; pendingMemoryIngest?: Promise | null; }) { const normalizedGuildId = String(guildId || "").trim(); const normalizedSessionId = String(sessionId || "").trim() || "session"; const resolvedSettings = settings || this.store.getSettings?.() || null; const memorySettings = getMemorySettings(resolvedSettings); if (!normalizedGuildId || !memorySettings.enabled || !memorySettings.reflection?.enabled) { return { ok: false, reason: "memory_reflection_disabled" }; }

const inFlightKey = `voice:${normalizedGuildId}:${normalizedSessionId}`;
if (this.microReflectionInFlight.has(inFlightKey)) {
  return { ok: false, reason: "already_running" };
}

if (pendingMemoryIngest) {
  try {
    await pendingMemoryIngest;
  } catch {
    // Best effort. The transcript timeline below is still enough to reflect on.
  }
}

const normalizedEntries = (Array.isArray(transcriptTurns) ? transcriptTurns : [])
  .filter((turn) => {
    const kind = String(turn?.kind || "speech").trim();
    return kind === "speech" || !kind;
  })
  .map((turn) => ({
    timestampIso: Number.isFinite(Number(turn?.at))
      ? new Date(Number(turn.at)).toISOString()
      : "",
    timestampMs: Number(turn?.at) || 0,
    author: String(turn?.speakerName || "unknown").trim() || "unknown",
    authorId: String(turn?.userId || "").trim() || null,
    isBot: String(turn?.role || "").trim() === "assistant",
    content: String(turn?.text || "").trim()
  }))
  .filter((entry) => entry.content);
if (!normalizedEntries.length) {
  return { ok: false, reason: "no_entries" };
}

const sessionStartMs = Number.isFinite(Number(startedAtMs)) ? Number(startedAtMs) : 0;
const scopedEntries = sessionStartMs > 0
  ? normalizedEntries.filter((entry) => entry.timestampMs >= sessionStartMs)
  : normalizedEntries;

this.microReflectionInFlight.add(inFlightKey);
try {
  return await runMicroReflection({
    memory: this,
    store: this.store,
    llm: this.llm,
    settings: resolvedSettings,
    guildId: normalizedGuildId,
    channelId: String(channelId || "").trim() || null,
    trigger: "voice_session_end",
    sourceMessageId: `micro_reflection_voice_${normalizedGuildId}_${normalizedSessionId}`,
    entries: scopedEntries
  });
} finally {
  this.microReflectionInFlight.delete(inFlightKey);
}

}

async runVoiceCompactionMiniReflection({ guildId, channelId = null, sessionId, batchStart = 0, settings, transcriptTurns = [] }: { guildId?: string | null; channelId?: string | null; sessionId?: string | null; batchStart?: number; settings?: Record<string, unknown> | null; transcriptTurns?: Array<Record<string, unknown>>; }) { const normalizedGuildId = String(guildId || "").trim(); const normalizedSessionId = String(sessionId || "").trim() || "session"; const resolvedSettings = settings || this.store.getSettings?.() || null; const memorySettings = getMemorySettings(resolvedSettings); if (!normalizedGuildId || !memorySettings.enabled || !memorySettings.reflection?.enabled) { return { ok: false, reason: "memory_reflection_disabled" }; }

const inFlightKey = `voice_compaction:${normalizedGuildId}:${normalizedSessionId}:${Math.max(0, Math.floor(Number(batchStart) || 0))}`;
if (this.microReflectionInFlight.has(inFlightKey)) {
  return { ok: false, reason: "already_running" };
}

const normalizedEntries = (Array.isArray(transcriptTurns) ? transcriptTurns : [])
  .filter((turn) => {
    const kind = String(turn?.kind || "speech").trim();
    return kind === "speech" || !kind;
  })
  .map((turn) => ({
    timestampIso: Number.isFinite(Number(turn?.at))
      ? new Date(Number(turn.at)).toISOString()
      : "",
    timestampMs: Number(turn?.at) || 0,
    author: String(turn?.speakerName || "unknown").trim() || "unknown",
    authorId: String(turn?.userId || "").trim() || null,
    isBot: String(turn?.role || "").trim() === "assistant",
    content: String(turn?.text || "").trim()
  }))
  .filter((entry) => entry.content);
if (!normalizedEntries.length) {
  return { ok: false, reason: "no_entries" };
}

this.microReflectionInFlight.add(inFlightKey);
try {
  return await runMicroReflection({
    memory: this,
    store: this.store,
    llm: this.llm,
    settings: resolvedSettings,
    guildId: normalizedGuildId,
    channelId: String(channelId || "").trim() || null,
    trigger: "voice_pre_compaction",
    sourceMessageId: `micro_reflection_voice_compaction_${normalizedGuildId}_${normalizedSessionId}_${Math.max(0, Math.floor(Number(batchStart) || 0))}`,
    entries: normalizedEntries,
    maxFacts: VOICE_PRE_COMPACTION_MAX_FACTS,
    maxEntries: VOICE_PRE_COMPACTION_MAX_ENTRIES,
    maxTotalChars: VOICE_PRE_COMPACTION_MAX_TOTAL_CHARS
  });
} finally {
  this.microReflectionInFlight.delete(inFlightKey);
}

}

persistVoiceSessionSummary({ sessionId, guildId, channelId = null, summaryText, startedAtMs = null, endedAtMs = null }: { sessionId: string; guildId: string; channelId?: string | null; summaryText: string; startedAtMs?: number | null; endedAtMs?: number | null; }) { const normalizedSessionId = String(sessionId || "").trim(); const normalizedGuildId = String(guildId || "").trim(); const normalizedChannelId = String(channelId || "").trim(); const normalizedSummaryText = String(summaryText || "").replace(/\s+/g, " ").trim(); if (!normalizedSessionId || !normalizedGuildId || !normalizedChannelId || !normalizedSummaryText) { return false; }

const persisted = this.store.upsertSessionSummary?.({
  sessionId: normalizedSessionId,
  guildId: normalizedGuildId,
  channelId: normalizedChannelId,
  summaryText: normalizedSummaryText,
  startedAt: toIsoOrNull(startedAtMs),
  endedAt: toIsoOrNull(endedAtMs) || new Date().toISOString(),
  modality: "voice"
});
if (!persisted) return false;

this.store.pruneExpiredSessionSummaries?.({
  retentionHours: RECENT_VOICE_SESSION_SUMMARY_RETENTION_HOURS
});
this.store.logAction({
  kind: "memory_runtime",
  guildId: normalizedGuildId,
  channelId: normalizedChannelId,
  userId: null,
  content: "voice_session_summary_persisted",
  metadata: {
    sessionId: normalizedSessionId,
    summaryChars: normalizedSummaryText.length
  }
});
return true;

}

getRecentVoiceSessionSummariesForPrompt({ guildId, channelId = null, referenceAtMs = null, limit = RECENT_VOICE_SESSION_SUMMARY_LIMIT, windowMinutes = RECENT_VOICE_SESSION_SUMMARY_WINDOW_MINUTES }: { guildId: string; channelId?: string | null; referenceAtMs?: number | null; limit?: number; windowMinutes?: number; }) { const normalizedGuildId = String(guildId || "").trim(); const normalizedChannelId = String(channelId || "").trim(); if (!normalizedGuildId || !normalizedChannelId) return [];

const referenceMs = Number.isFinite(Number(referenceAtMs)) && Number(referenceAtMs) > 0
  ? Number(referenceAtMs)
  : Date.now();
const boundedWindowMinutes = clampInt(windowMinutes, 1, 24 * 60);
const rows = this.store.getRecentSessionSummaries?.({
  guildId: normalizedGuildId,
  channelId: normalizedChannelId,
  modality: "voice",
  sinceIso: new Date(referenceMs - boundedWindowMinutes * 60_000).toISOString(),
  beforeIso: new Date(referenceMs).toISOString(),
  limit: clampInt(limit, 1, 4)
}) || [];

return rows
  .map((row) => {
    const endedAt = String(row?.ended_at || "").trim() || null;
    const summaryText = String(row?.summary_text || "").replace(/\s+/g, " ").trim();
    if (!endedAt || !summaryText) return null;
    const endedAtMs = Date.parse(endedAt);
    return {
      sessionId: String(row?.session_id || "").trim() || null,
      guildId: String(row?.guild_id || "").trim() || null,
      channelId: String(row?.channel_id || "").trim() || null,
      endedAt,
      ageMinutes: Number.isFinite(endedAtMs)
        ? Math.max(0, Math.round((referenceMs - endedAtMs) / 60_000))
        : null,
      summaryText
    };
  })
  .filter((row) => row !== null);

}

async searchDurableFacts({ guildId = null, scope = "all", channelId = null, queryText, subjectIds = null, factTypes = null, settings = null, trace = {}, limit = HYBRID_FACT_LIMIT }: { guildId?: string | null; scope?: "user" | "guild" | "owner" | "owner_private" | "all"; channelId?: string | null; queryText: string; subjectIds?: string[] | null; factTypes?: string[] | null; settings?: Record<string, unknown> | null; trace?: Record<string, unknown>; limit?: number; }) { const scopeGuildId = String(guildId || "").trim() || null; const rawScopeMode = String(scope || "all").trim().toLowerCase(); const scopeMode = rawScopeMode === "user" || rawScopeMode === "guild" || rawScopeMode === "owner" || rawScopeMode === "owner_private" || rawScopeMode === "all" ? rawScopeMode : "all"; const normalizedTrace = trace && typeof trace === "object" ? trace as Record<string, unknown> : {}; const normalizedSubjectIds = [ ...new Set( (Array.isArray(subjectIds) ? subjectIds : []) .map((value) => String(value || "").trim()) .filter(Boolean) ) ]; const hasSubjectFilter = normalizedSubjectIds.length > 0; const scopedSubjectIds = normalizedSubjectIds.length ? normalizedSubjectIds : null; const includeUserScope = scopeMode === "user" || scopeMode === "owner_private" || (scopeMode === "all" && (!scopeGuildId || hasSubjectFilter)); const includeGuildScope = (scopeMode === "all" || scopeMode === "guild" || scopeMode === "owner_private") && Boolean(scopeGuildId); const includeOwnerScope = scopeMode === "owner" || scopeMode === "owner_private"; if (!includeUserScope && !includeGuildScope && !includeOwnerScope) return [];

const isFullMemoryQuery = queryText === "__ALL__";
const boundedLimit = isFullMemoryQuery
  ? clampInt(limit, 1, FULL_MEMORY_DUMP_LIMIT)
  : clampInt(limit, 1, 24);

if (isFullMemoryQuery) {
  const rows = mergeUniqueFactCandidates(
    includeUserScope
      ? this.store.getFactsForScope?.({
        scope: "user",
        subjectIds: scopedSubjectIds,
        factTypes,
        limit: Math.max(boundedLimit, 24)
      }) || []
      : [],
    includeGuildScope
      ? this.store.getFactsForScope?.({
        scope: "guild",
        guildId: scopeGuildId,
        subjectIds: scopedSubjectIds,
        factTypes,
        limit: Math.max(boundedLimit, 24)
      }) || []
      : [],
    includeOwnerScope
      ? this.store.getFactsForScope?.({
        scope: "owner",
        subjectIds: scopedSubjectIds,
        factTypes,
        limit: Math.max(boundedLimit, 24)
      }) || []
      : []
  );
  const sortedRows = sortProfileFacts(rows);
  return sortedRows.map((row) => ({
    id: row.id,
    created_at: row.created_at,
    updated_at: row.updated_at,
    scope: row.scope,
    guild_id: row.guild_id,
    channel_id: row.channel_id,
    user_id: row.user_id,
    subject: row.subject,
    fact: row.fact,
    fact_type: row.fact_type,
    evidence_text: row.evidence_text,
    source_message_id: row.source_message_id,
    confidence: row.confidence,
    score: 0,
    semanticScore: 0,
    lexicalScore: 0
  })).slice(0, boundedLimit);
}

const query = String(queryText || "").trim();
const candidateLimit = Math.min(
  HYBRID_MAX_CANDIDATES,
  Math.max(boundedLimit * HYBRID_CANDIDATE_MULTIPLIER, boundedLimit)
);
const recentCandidateLimit = Math.min(
  HYBRID_RECENT_CANDIDATE_LIMIT,
  Math.max(boundedLimit * 2, boundedLimit)
);
const queryTokens = extractStableTokens(query, 8);

const recentCandidates: MemoryFactRow[] = [];
const lexicalCandidates: MemoryFactRow[] = [];
const semanticCandidates: MemoryFactRow[] = [];

const queryEmbedding =
  typeof this.store.searchMemoryFactsByEmbedding === "function"
    ? await this.getQueryEmbeddingForRetrieval({
      queryText: query,
      settings,
      trace: {
        ...normalizedTrace,
        source: String(normalizedTrace.source || "memory_semantic_candidates")
      }
    }).catch(() => null)
    : null;

const collectScopeCandidates = (factScope: "user" | "guild" | "owner") => {
  if (factScope === "guild" && !scopeGuildId) return;
  const scopeGuild = factScope === "guild" ? scopeGuildId : null;
  const recentRows = this.store.getFactsForScope?.({
    scope: factScope,
    guildId: scopeGuild,
    subjectIds: scopedSubjectIds,
    factTypes,
    limit: recentCandidateLimit
  }) || [];
  recentCandidates.push(...recentRows);

  const lexicalRows = this.store.searchMemoryFactsLexical?.({
    scope: factScope,
    guildId: scopeGuild,
    subjectIds: scopedSubjectIds,
    factTypes,
    queryText: query,
    queryTokens,
    limit: candidateLimit
  }) || [];
  lexicalCandidates.push(...lexicalRows);

  if (queryEmbedding?.embedding?.length && queryEmbedding?.model) {
    const semanticRows = this.store.searchMemoryFactsByEmbedding?.({
      scope: factScope,
      guildId: scopeGuild,
      subjectIds: scopedSubjectIds,
      factTypes,
      model: queryEmbedding.model,
      queryEmbedding: queryEmbedding.embedding,
      limit: candidateLimit
    }) || [];
    semanticCandidates.push(...semanticRows);
  }
};

if (includeUserScope) collectScopeCandidates("user");
if (includeGuildScope) collectScopeCandidates("guild");
if (includeOwnerScope) collectScopeCandidates("owner");

const candidates = mergeUniqueFactCandidates(
  semanticCandidates,
  lexicalCandidates,
  recentCandidates
);
if (!candidates.length) return [];

const ranked = await this.rankHybridCandidates({
  candidates,
  queryText,
  settings,
  trace: normalizedTrace,
  channelId,
  requireRelevanceGate: true
});

return ranked.slice(0, boundedLimit).map((row) => ({
  id: row.id,
  created_at: row.created_at,
  updated_at: row.updated_at,
  scope: row.scope,
  guild_id: row.guild_id,
  channel_id: row.channel_id,
  user_id: row.user_id,
  subject: row.subject,
  fact: row.fact,
  fact_type: row.fact_type,
  evidence_text: row.evidence_text,
  source_message_id: row.source_message_id,
  confidence: row.confidence,
  score: row._score,
  semanticScore: row._semanticScore,
  lexicalScore: row._lexicalScore
}));

}

async rankHybridCandidates({ candidates, queryText, settings, trace = {}, channelId = null, requireRelevanceGate = false }) { const query = String(queryText || "").trim(); const queryTokens = extractStableTokens(query, 32); const queryCompact = normalizeHighlightText(query); const normalizedChannelId = String(channelId || "").trim(); const semanticScores = await this.getSemanticScoreMap({ candidates, queryText: query, settings, trace }); const semanticAvailable = semanticScores.size > 0;

const scored = candidates.map((row) => {
  const lexicalScore = Number.isFinite(Number(row?.lexical_score))
    ? clamp01(Number(row.lexical_score), 0)
    : computeLexicalFactScore(row, { queryTokens, queryCompact });
  const semanticScore = semanticScores.get(Number(row.id)) || 0;
  const recencyScore = computeRecencyScore(row.created_at);
  const confidenceScore = clamp01(row.confidence, 0.5);
  const channelScore = computeChannelScopeScore(row.channel_id, normalizedChannelId);
  const combined = semanticAvailable
    ? 0.5 * semanticScore + 0.28 * lexicalScore + 0.1 * confidenceScore + 0.07 * recencyScore + 0.05 * channelScore
    : 0.75 * lexicalScore + 0.1 * confidenceScore + 0.1 * recencyScore + 0.05 * channelScore;
  const temporalMultiplier = computeTemporalDecayMultiplier({
    createdAtIso: row.created_at,
    factType: row.fact_type,
    halfLifeDays: HYBRID_TEMPORAL_DECAY_HALF_LIFE_DAYS,
    minMultiplier: HYBRID_TEMPORAL_DECAY_MIN_MULTIPLIER
  });
  const decayedScore = combined * temporalMultiplier;

  return {
    ...row,
    _score: Number(decayedScore.toFixed(6)),
    _semanticScore: Number(semanticScore.toFixed(6)),
    _lexicalScore: Number(lexicalScore.toFixed(6))
  };
});

const sorted = scored.sort((a, b) => {
  if (b._score !== a._score) return b._score - a._score;
  return Date.parse(b.created_at || "") - Date.parse(a.created_at || "");
});

if (!queryTokens.length && !semanticAvailable) {
  return sorted;
}

const filtered = sorted.filter((row) =>
  passesHybridRelevanceGate({
    row,
    semanticAvailable
  }));
if (filtered.length) {
  return rerankWithMmr(filtered, { lambda: HYBRID_MMR_LAMBDA });
}
if (requireRelevanceGate) return [];
return rerankWithMmr(sorted, { lambda: HYBRID_MMR_LAMBDA });

}

buildQueryEmbeddingCacheKey({ queryText, settings }) { const normalizedQuery = normalizeQueryEmbeddingText(queryText); if (!normalizedQuery) return ""; const resolvedModel = String(this.llm?.resolveEmbeddingModel?.(settings) || "").trim().toLowerCase() || "default"; return ${resolvedModel} ${normalizedQuery}; }

getCachedQueryEmbedding(cacheKey) { if (!cacheKey) return null; const now = Date.now(); const cached = this.queryEmbeddingCache.get(cacheKey) || null; if (!cached) return null; if (now >= Number(cached.expiresAt || 0)) { this.queryEmbeddingCache.delete(cacheKey); return null; } return { embedding: Array.isArray(cached.embedding) ? [...cached.embedding] : [], model: String(cached.model || "") }; }

setCachedQueryEmbedding(cacheKey, value) { if (!cacheKey) return; const embedding = Array.isArray(value?.embedding) ? value.embedding.map((item) => Number(item)) : []; const model = String(value?.model || "").trim(); if (!embedding.length || !model) return;

const now = Date.now();
this.queryEmbeddingCache.set(cacheKey, {
  embedding,
  model,
  expiresAt: now + QUERY_EMBEDDING_CACHE_TTL_MS
});

for (const [key, entry] of this.queryEmbeddingCache.entries()) {
  if (now < Number(entry?.expiresAt || 0)) continue;
  this.queryEmbeddingCache.delete(key);
}
while (this.queryEmbeddingCache.size > QUERY_EMBEDDING_CACHE_MAX_ENTRIES) {
  const oldestKey = this.queryEmbeddingCache.keys().next().value;
  if (!oldestKey) break;
  this.queryEmbeddingCache.delete(oldestKey);
}

}

async getQueryEmbeddingForRetrieval({ queryText, settings, trace = {} }) { const query = normalizeQueryEmbeddingText(queryText); if (query.length < 3) return null;

const cacheKey = this.buildQueryEmbeddingCacheKey({ queryText: query, settings });
if (!cacheKey) return null;

const cached = this.getCachedQueryEmbedding(cacheKey);
if (cached?.embedding?.length && cached?.model) {
  return cached;
}

const inFlight = this.queryEmbeddingInFlight.get(cacheKey);
if (inFlight) {
  return await inFlight;
}

const task = (async () => {
  const queryEmbeddingResult = await this.llm.embedText({
    settings,
    text: query,
    trace: {
      ...trace,
      source: String((trace as Record<string, unknown>)?.source || "memory_query")
    }
  });
  const queryEmbedding = Array.isArray(queryEmbeddingResult?.embedding)
    ? queryEmbeddingResult.embedding.map((value) => Number(value))
    : [];
  const model = String(queryEmbeddingResult?.model || "").trim();
  if (!queryEmbedding.length || !model) return null;

  const result = {
    embedding: queryEmbedding,
    model
  };
  this.setCachedQueryEmbedding(cacheKey, result);
  return result;
})();

this.queryEmbeddingInFlight.set(cacheKey, task);
try {
  return await task;
} finally {
  this.queryEmbeddingInFlight.delete(cacheKey);
}

}

async getSemanticScoreMap({ candidates, queryText, settings, trace = {} }) { if (!this.llm?.isEmbeddingReady?.()) return new Map();

const query = String(queryText || "").trim();
if (query.length < 3) return new Map();

let queryEmbeddingResult = null;
try {
  queryEmbeddingResult = await this.getQueryEmbeddingForRetrieval({
    queryText: query,
    settings,
    trace
  });
} catch {
  return new Map();
}

const queryEmbedding = Array.isArray(queryEmbeddingResult?.embedding)
  ? queryEmbeddingResult.embedding
  : [];
const model = String(queryEmbeddingResult?.model || "").trim();
if (!queryEmbedding.length || !model) return new Map();

const factIds = candidates
  .map((row) => Number(row.id))
  .filter((value) => Number.isInteger(value) && value > 0);
if (!factIds.length) return new Map();

const scoreMap = new Map();
const scoredFactIds = new Set();
const collectNativeScores = (ids) => {
  const rows = this.store.getMemoryFactVectorNativeScores?.({
    factIds: ids,
    model,
    queryEmbedding
  });
  if (!Array.isArray(rows) || !rows.length) return;
  for (const row of rows) {
    const factId = Number(row?.fact_id);
    const score = Number(row?.score);
    if (!Number.isInteger(factId) || factId <= 0) continue;
    scoredFactIds.add(factId);
    if (Number.isFinite(score) && score > 0) {
      scoreMap.set(factId, score);
    }
  }
};
collectNativeScores(factIds);

const unresolvedFactIds = factIds.filter((factId) => !scoredFactIds.has(factId));
if (!unresolvedFactIds.length) return scoreMap;

let backfilled = 0;
const unresolvedSet = new Set(unresolvedFactIds);
for (const row of candidates) {
  const factId = Number(row.id);
  if (!unresolvedSet.has(factId)) continue;
  if (!Number.isInteger(factId) || factId <= 0) continue;
  if (backfilled >= HYBRID_MAX_VECTOR_BACKFILL_PER_QUERY) break;

  const embedding = await this.ensureFactVector({
    factRow: row,
    model,
    settings,
    trace: {
      ...trace,
      source: "memory_fact"
    }
  });
  if (embedding?.length) {
    backfilled += 1;
  }
}

if (backfilled > 0) {
  collectNativeScores(unresolvedFactIds);
}

return scoreMap;

}

async ensureFactVector({ factRow, model = "", settings, trace = {} }) { const factId = Number(factRow?.id); if (!Number.isInteger(factId) || factId <= 0) return null;

const resolvedModel = String(model || this.llm?.resolveEmbeddingModel?.(settings) || "").trim();
if (!resolvedModel) return null;

const existing = this.store.getMemoryFactVectorNative?.(factId, resolvedModel);
if (existing?.length) return existing;

try {
  const payload = buildFactEmbeddingPayload(factRow);
  if (!payload) return null;
  const embedded = await this.llm.embedText({
    settings,
    text: payload,
    trace
  });
  const vector = Array.isArray(embedded?.embedding)
    ? embedded.embedding.map((value) => Number(value))
    : [];
  if (!vector.length) return null;

  this.store.upsertMemoryFactVectorNative({
    factId,
    model: embedded.model || resolvedModel,
    embedding: vector
  });
  return vector;
} catch {
  return null;
}

}

async queueMemoryRefresh() { if (this.pendingWrite) return; this.pendingWrite = true;

setTimeout(async () => {
  try {
    await this.refreshMemoryMarkdown();
  } catch (error) {
    this.logMemoryError("curation_refresh", error);
  } finally {
    this.pendingWrite = false;
  }
}, MEMORY_MARKDOWN_REFRESH_DEBOUNCE_MS);

}

async refreshMemoryMarkdown() { const markdown = await this.buildMemoryMarkdown(); await fs.mkdir(this.memoryDirPath, { recursive: true }); await fs.writeFile(this.memoryFilePath, markdown, "utf8"); }

async buildMemoryMarkdown({ guildId = null }: { guildId?: string | null } = {}) { const normalizedGuildId = String(guildId || "").trim() || null; const peopleSection = this.buildPeopleSection(normalizedGuildId); const selfSection = this.buildSelfSection(MAX_SECTION_FACTS, normalizedGuildId); const ownerSection = normalizedGuildId ? [] : this.buildOwnerSection(MAX_SECTION_FACTS); const recentDailyEntries = await this.getRecentDailyEntries({ days: MARKDOWN_RECENT_DAILY_DAYS, maxEntries: MARKDOWN_RECENT_DAILY_MAX_ENTRIES, guildId: normalizedGuildId }); const highlightsSection = buildHighlightsSection(recentDailyEntries, MARKDOWN_HIGHLIGHT_MAX_ITEMS); const loreSection = this.buildLoreSection(MAX_SECTION_FACTS, normalizedGuildId); const dailyFiles = await this.getRecentDailyFiles(MARKDOWN_RECENT_DAILY_FILES); const dailyFilesLine = dailyFiles.length ? dailyFiles.map((filePath) => memory/${path.basename(filePath)}).join(", ") : "(No daily files yet.)"; const scopeLine = normalizedGuildId ? _Operator-facing summary for guild \${normalizedGuildId}`. Runtime prompts use indexed durable facts + retrieval, not this markdown file directly._` : "Operator-facing summary. Runtime prompts use indexed durable facts + retrieval, not this markdown file directly.";

return [
  "# Durable Memory Snapshot",
  "",
  scopeLine,
  "",
  "## People (Durable Facts)",
  ...(peopleSection.length ? peopleSection : ["- (No stable people facts yet.)"]),
  "",
  "## Bot Self Memory",
  ...(selfSection.length ? selfSection : ["- (No durable self-memory lines yet.)"]),
  "",
  ...(normalizedGuildId ? [] : [
    "## Owner Private",
    ...(ownerSection.length ? ownerSection : ["- (No owner-private durable memory lines yet.)"]),
    ""
  ]),
  "## Ongoing Lore",
  ...(loreSection.length ? loreSection : ["- (No durable lore lines yet.)"]),
  "",
  "## Recent Journal Highlights",
  ...(highlightsSection.length ? highlightsSection : ["- (No recent highlights yet.)"]),
  "",
  "## Source Daily Logs",
  "- Daily logs are append-only in `memory/YYYY-MM-DD.md`.",
  normalizedGuildId
    ? `- Recent files: ${dailyFilesLine} (entries filtered to guild \`${normalizedGuildId}\`).`
    : `- Recent files: ${dailyFilesLine}`
].join("

"); }

async readMemoryMarkdown({ guildId = null }: { guildId?: string | null } = {}) { const normalizedGuildId = String(guildId || "").trim() || null; if (normalizedGuildId) { return await this.buildMemoryMarkdown({ guildId: normalizedGuildId }); }

try {
  return await fs.readFile(this.memoryFilePath, "utf8");
} catch {
  return "# Memory

(no memory file yet)"; } }

async purgeGuildMemory({ guildId }: { guildId?: string | null } = {}) { const normalizedGuildId = String(guildId || "").trim(); if (!normalizedGuildId) { return { ok: false, reason: "guild_required", guildId: null, durableFactsDeleted: 0, durableFactVectorsDeleted: 0, conversationMessagesDeleted: 0, conversationVectorsDeleted: 0, reflectionEventsDeleted: 0, sessionSummariesDeleted: 0, journalEntriesDeleted: 0, journalFilesTouched: 0, summaryRefreshed: false } as const; }

this.clearScheduledTextMicroReflectionsForGuild(normalizedGuildId);

try {
  await this.drainIngestQueue({ timeoutMs: 8_000 });
} catch {
  // Best effort. The purge below is still the source of truth.
}

await this.waitForGuildMicroReflectionsToSettle(normalizedGuildId, 8_000);

const durableResult =
  typeof this.store?.deleteMemoryFactsForGuild === "function"
    ? this.store.deleteMemoryFactsForGuild(normalizedGuildId)
    : { factsDeleted: 0, vectorsDeleted: 0 };
const messageResult =
  typeof this.store?.deleteMessagesForGuild === "function"
    ? this.store.deleteMessagesForGuild(normalizedGuildId)
    : { messagesDeleted: 0, vectorsDeleted: 0 };
const reflectionResult =
  typeof this.store?.deleteMemoryReflectionRunsForGuild === "function"
    ? this.store.deleteMemoryReflectionRunsForGuild(normalizedGuildId)
    : { deleted: 0 };
const sessionSummaryResult =
  typeof this.store?.deleteSessionSummariesForGuild === "function"
    ? this.store.deleteSessionSummariesForGuild(normalizedGuildId)
    : { deleted: 0 };
const journalResult = await this.purgeGuildEntriesFromDailyLogs(normalizedGuildId);

let summaryRefreshed = false;
try {
  await this.refreshMemoryMarkdown();
  summaryRefreshed = true;
} catch {
  summaryRefreshed = false;
}

return {
  ok: true,
  reason: "deleted",
  guildId: normalizedGuildId,
  durableFactsDeleted: Number(durableResult?.factsDeleted || 0),
  durableFactVectorsDeleted: Number(durableResult?.vectorsDeleted || 0),
  conversationMessagesDeleted: Number(messageResult?.messagesDeleted || 0),
  conversationVectorsDeleted: Number(messageResult?.vectorsDeleted || 0),
  reflectionEventsDeleted: Number(reflectionResult?.deleted || 0),
  sessionSummariesDeleted: Number(sessionSummaryResult?.deleted || 0),
  journalEntriesDeleted: Number(journalResult?.entriesDeleted || 0),
  journalFilesTouched: Number(journalResult?.filesTouched || 0),
  summaryRefreshed
} as const;

}

buildPeopleSection(guildId: string | null = null) { const normalizedGuildId = String(guildId || "").trim() || null; const subjects = this.store .getMemorySubjects(MAX_MEMORY_SUBJECTS, normalizedGuildId ? { guildId: normalizedGuildId } : null) .filter((subjectRow) => subjectRow.subject !== LORE_SUBJECT && subjectRow.subject !== SELF_SUBJECT); const factsByScopedSubject = this.getPeopleFactsByScopedSubject(subjects); const peopleLines = [];

for (const subjectRow of subjects) {
  const scopedSubjectKey = [
    String(subjectRow.scope || "guild").trim(),
    String(subjectRow.guild_id || "").trim(),
    String(subjectRow.subject || "").trim()
  ].join("::");
  const rows = factsByScopedSubject.get(scopedSubjectKey) || [];
  const cleaned = [
    ...new Set(
      rows
        .map((row) => formatTypedFactForMemory(row.fact, row.fact_type))
        .filter(Boolean)
    )
  ].slice(0, MAX_PEOPLE_FACTS_PER_SUBJECT);
  if (!cleaned.length) continue;
  const scopeLabel = normalizedGuildId
    ? ""
    : String(subjectRow.scope || "") === "guild"
      ? subjectRow.guild_id ? `[guild:${subjectRow.guild_id}] ` : ""
      : "[user] ";
  peopleLines.push(`- ${scopeLabel}${subjectRow.subject}: ${cleaned.join(" | ")}`);
}

return peopleLines;

}

getPeopleFactsByScopedSubject(subjectRows = []) { const subjectsByScope = new Map(); for (const subjectRow of subjectRows) { const scope = String(subjectRow?.scope || "guild").trim().toLowerCase(); const guildId = String(subjectRow?.guild_id || "").trim(); const subjectId = String(subjectRow?.subject || "").trim(); if (!subjectId) continue; if (scope !== "guild" && scope !== "user") continue; const scopeKey = ${scope}::${guildId}; const existing = subjectsByScope.get(scopeKey) || []; if (!existing.includes(subjectId)) { existing.push(subjectId); } subjectsByScope.set(scopeKey, existing); }

const factsByScopedSubject = new Map();
for (const [scopeKey, subjectIds] of subjectsByScope.entries()) {
  const [scopeType, guildId = ""] = String(scopeKey || "").split("::");
  const rows = this.store.getFactsForSubjectsScoped({
    scope: scopeType,
    guildId: guildId || null,
    subjectIds,
    perSubjectLimit: MAX_PEOPLE_FACTS_PER_SUBJECT,
    totalLimit: Math.min(
      PEOPLE_FACT_TOTAL_LIMIT_MAX,
      Math.max(PEOPLE_FACT_TOTAL_LIMIT_MIN, subjectIds.length * PEOPLE_FACT_TOTAL_LIMIT_MULTIPLIER)
    )
  });

  for (const row of rows) {
    const rowScope = String(row?.scope || "guild").trim();
    const scopedGuildId = String(row?.guild_id || "").trim();
    const scopedSubjectId = String(row?.subject || "").trim();
    if (!scopedSubjectId) continue;
    const scopedSubjectKey = `${rowScope}::${scopedGuildId}::${scopedSubjectId}`;
    const existing = factsByScopedSubject.get(scopedSubjectKey) || [];
    if (existing.length >= MAX_PEOPLE_FACTS_PER_SUBJECT) continue;
    existing.push(row);
    factsByScopedSubject.set(scopedSubjectKey, existing);
  }
}

return factsByScopedSubject;

}

buildSelfSection(maxItems = MAX_SECTION_FACTS, guildId: string | null = null) { const normalizedGuildId = String(guildId || "").trim() || null; const rows = this.store.getFactsForSubjectScoped( SELF_SUBJECT, FACT_SECTION_SUBJECT_FETCH_LIMIT, normalizedGuildId ? { guildId: normalizedGuildId } : null ); const durableSelfLines = []; const seen = new Set(); for (const row of rows) { const normalized = normalizeSelfFactForDisplay(row.fact); if (!normalized) continue; const key = ${row.guild_id || ""}:${normalized.toLowerCase()}; if (seen.has(key)) continue; seen.add(key); const scopeLabel = normalizedGuildId ? "" : row.guild_id ? [guild:${row.guild_id}] : ""; durableSelfLines.push(- ${scopeLabel}${normalized}); } return durableSelfLines.slice(0, Math.max(1, maxItems)); }

buildOwnerSection(maxItems = MAX_SECTION_FACTS) { const rows = this.store.getFactsForSubjectScoped( OWNER_SUBJECT, FACT_SECTION_SUBJECT_FETCH_LIMIT, { scope: "owner" } ); const durableOwnerLines = []; const seen = new Set(); for (const row of rows) { const normalized = normalizeSelfFactForDisplay(row.fact); if (!normalized) continue; const key = normalized.toLowerCase(); if (seen.has(key)) continue; seen.add(key); durableOwnerLines.push(- ${normalized}); } return durableOwnerLines.slice(0, Math.max(1, maxItems)); }

buildLoreSection(maxItems = MAX_SECTION_FACTS, guildId: string | null = null) { const normalizedGuildId = String(guildId || "").trim() || null; const rows = this.store.getFactsForSubjectScoped( LORE_SUBJECT, FACT_SECTION_SUBJECT_FETCH_LIMIT, normalizedGuildId ? { guildId: normalizedGuildId } : null ); const durableLoreLines = []; const seen = new Set(); for (const row of rows) { const normalized = normalizeLoreFactForDisplay(row.fact); if (!normalized) continue; const key = ${row.guild_id || ""}:${normalized.toLowerCase()}; if (seen.has(key)) continue; seen.add(key); const scopeLabel = normalizedGuildId ? "" : row.guild_id ? [guild:${row.guild_id}] : ""; durableLoreLines.push(- ${scopeLabel}${normalized}); } return durableLoreLines.slice(0, Math.max(1, maxItems)); }

async rememberDirectiveLineDetailed({ line, sourceMessageId, userId, guildId, channelId = null, sourceText = "", scope = "lore", subjectOverride = null, factType = null, confidence = null, validationMode = "strict", evidenceText = null, supersedesFactText = null }: { line: string; sourceMessageId?: string | null; userId?: string | null; guildId?: string | null; channelId?: string | null; sourceText?: string; scope?: string; subjectOverride?: string | null; factType?: string | null; confidence?: number | null; validationMode?: string; evidenceText?: string | null; supersedesFactText?: string | null; }) { const scopeGuildId = String(guildId || "").trim() || null; const scopeConfig = resolveDirectiveScopeConfig(scope); const memoryScope = scopeConfig.scope === "lore" ? "guild" : scopeConfig.scope === "owner" ? "owner" : "user"; if (memoryScope === "guild" && !scopeGuildId) { return { ok: false, reason: "guild_required" }; } const durableGuildId = memoryScope === "guild" ? scopeGuildId : null; const subject = subjectOverride ? String(subjectOverride).trim() : scopeConfig.subject; const normalizedFactType = normalizeFactType(factType || scopeConfig.defaultFactType); if (!subject) { return { ok: false, reason: "subject_required" }; } const scopedUserId = (memoryScope === "user" || memoryScope === "owner") && subject !== SELF_SUBJECT ? String(subjectOverride || userId || subject).trim() || null : null;

const cleaned = normalizeMemoryLineInput(line);
if (!cleaned) {
  return {
    ok: false,
    reason: "empty_fact"
  };
}
const normalizedValidationMode =
  String(validationMode || "").trim().toLowerCase() === "minimal" ? "minimal" : "strict";
const allowsBehavioralInstruction =
  normalizedFactType === GUIDANCE_FACT_TYPE || normalizedFactType === BEHAVIORAL_FACT_TYPE;
if (normalizedValidationMode === "strict") {
  if (isUnsafeMemoryFactText(cleaned)) {
    return { ok: false, reason: "unsafe_instruction" };
  }
  if (
    !allowsBehavioralInstruction &&
    (isBehavioralDirectiveLikeFactText(cleaned) || isInstructionLikeFactText(cleaned))
  ) {
    return { ok: false, reason: "instruction_like" };
  }
}

const factText = normalizeStoredFactText(cleaned);
const normalizedEvidenceText = evidenceText
  ? sanitizeInline(evidenceText, MAX_DIRECTIVE_EVIDENCE_CHARS)
  : normalizeEvidenceText(sourceText, sourceText);
const normalizedConfidence = clamp01(
  Number.isFinite(Number(confidence)) ? Number(confidence) : 0.72,
  0.72
);
// If this fact supersedes an older one (reflection merge), update in-place.
const normalizedSupersedesText = supersedesFactText
  ? String(supersedesFactText || "").replace(/\s+/g, " ").trim()
  : null;
let supersededFact = null;
if (normalizedSupersedesText && normalizedSupersedesText !== factText) {
  supersededFact = this.store.getMemoryFactBySubjectAndFact?.({
    scope: memoryScope,
    guildId: durableGuildId,
    userId: scopedUserId,
    subject,
    fact: normalizedSupersedesText
  }) || null;
  if (supersededFact && typeof this.store.updateMemoryFact === "function") {
    const updateResult = this.store.updateMemoryFact({
      scope: memoryScope,
      guildId: durableGuildId,
      userId: scopedUserId,
      factId: supersededFact.id,
      subject,
      fact: factText,
      factType: normalizedFactType,
      evidenceText: normalizedEvidenceText,
      confidence: Math.max(normalizedConfidence, Number(supersededFact.confidence || 0))
    });
    if (updateResult?.ok) {
      const updatedRow = updateResult.row || this.store.getMemoryFactBySubjectAndFact({
        scope: memoryScope,
        guildId: durableGuildId,
        userId: scopedUserId,
        subject,
        fact: factText
      });
      this.store.logAction({
        kind: "memory_fact",
        guildId: scopeGuildId,
        channelId: channelId ? String(channelId) : null,
        userId,
        messageId: sourceMessageId,
        content: factText,
        metadata: {
          actorName: userId ? String(userId) : null,
          factId: Number(updatedRow?.id || supersededFact.id || 0) || null,
          subject,
          fact: factText,
          factType: normalizedFactType,
          confidence: Number(updatedRow?.confidence ?? normalizedConfidence),
          evidenceText: normalizedEvidenceText,
          source: scopeConfig.traceSource,
          reason: "merged_superseded",
          supersededFact: normalizedSupersedesText,
          scope: scopeConfig.scope,
          memoryScope,
          channelId: channelId ? String(channelId) : null,
          sourceMessageId
        }
      });
      if (updatedRow) {
        void this.ensureFactVector({
          factRow: updatedRow,
          settings: null,
          trace: { userId, source: scopeConfig.traceSource }
        });
      }
      this.queueMemoryRefresh();
      return {
        ok: true,
        reason: "merged_superseded",
        factText,
        scope: scopeConfig.scope,
        subject,
        factType: normalizedFactType,
        isNew: false
      };
    }
    // If update failed (e.g. duplicate), fall through to normal insert path.
  }
}

const existingFact = this.store.getMemoryFactBySubjectAndFact({
  scope: memoryScope,
  guildId: durableGuildId,
  userId: scopedUserId,
  subject,
  fact: factText
});
const inserted = this.store.addMemoryFact({
  scope: memoryScope,
  guildId: durableGuildId,
  channelId: channelId ? String(channelId) : null,
  userId: scopedUserId,
  subject,
  fact: factText,
  factType: normalizedFactType,
  evidenceText: normalizedEvidenceText,
  sourceMessageId,
  confidence: normalizedConfidence
});

if (!inserted) {
  return {
    ok: false,
    reason: "store_rejected",
    factText,
    scope: scopeConfig.scope,
    subject
  };
}

const factRow = this.store.getMemoryFactBySubjectAndFact({
  scope: memoryScope,
  guildId: durableGuildId,
  userId: scopedUserId,
  subject,
  fact: factText
});
this.store.logAction({
  kind: "memory_fact",
  guildId: scopeGuildId,
  channelId: channelId ? String(channelId) : null,
  userId,
  messageId: sourceMessageId,
  content: factText,
  metadata: {
    actorName: userId ? String(userId) : null,
    factId: Number(factRow?.id || existingFact?.id || 0) || null,
    subject,
    fact: factText,
    factType: normalizedFactType,
    confidence: Number(factRow?.confidence ?? existingFact?.confidence ?? normalizedConfidence),
    evidenceText: normalizedEvidenceText,
    source: scopeConfig.traceSource,
    reason: existingFact ? "updated_existing" : "added_new",
    scope: scopeConfig.scope,
    memoryScope,
    channelId: channelId ? String(channelId) : null,
    sourceMessageId
  }
});
this.store.archiveOldFactsForSubject({
  scope: memoryScope,
  guildId: durableGuildId,
  userId: scopedUserId,
  subject,
  keep: scopeConfig.keep
});

if (factRow) {
  void this.ensureFactVector({
    factRow,
    settings: null,
    trace: {
      userId,
      source: scopeConfig.traceSource
    }
  });
}
this.queueMemoryRefresh();
return {
  ok: true,
  reason: existingFact ? "updated_existing" : "added_new",
  factText,
  scope: scopeConfig.scope,
  subject,
  factType: normalizedFactType,
  isNew: !existingFact
};

}

async rememberDirectiveLine(args) { const result = await this.rememberDirectiveLineDetailed(args); return Boolean(result?.ok); }

async appendDailyLogEntry({ messageId = "", authorId, authorName, guildId = "", channelId = "", content, isVoice = false }) { const now = new Date(); const dateKey = formatDateLocal(now); const dailyFilePath = path.join(this.memoryDirPath, ${dateKey}.md); const safeAuthorName = sanitizeInline(authorName || "unknown", 80); const safeAuthorId = sanitizeInline(authorId || "unknown", 40); const safeMessageId = sanitizeInline(messageId || "", 40); const safeGuildId = sanitizeInline(guildId || "", 40); const safeChannelId = sanitizeInline(channelId || "", 40); const scopeFragment = [ safeGuildId ? guild:${safeGuildId} : "", safeChannelId ? channel:${safeChannelId} : "", safeMessageId ? message:${safeMessageId} : "", isVoice ? "voice" : "" ] .filter(Boolean) .join(" "); const scopedContent = scopeFragment ? [${scopeFragment}] ${content} : content; const line = - ${now.toISOString()} | ${safeAuthorName} (${safeAuthorId}) | ${scopedContent};

await fs.mkdir(this.memoryDirPath, { recursive: true });
await this.ensureDailyLogHeader(dailyFilePath, dateKey);
if (safeMessageId) {
  const knownMessageIds = await this.getDailyLogMessageIds(dailyFilePath);
  if (knownMessageIds.has(safeMessageId)) return;
  await fs.appendFile(dailyFilePath, `${line}

, "utf8"); knownMessageIds.add(safeMessageId); return; } await fs.appendFile(dailyFilePath, ${line} `, "utf8"); }

clearScheduledTextMicroReflectionsForGuild(guildId: string) { const normalizedGuildId = String(guildId || "").trim(); if (!normalizedGuildId) return;

for (const [key, timer] of this.textMicroReflectionTimers.entries()) {
  if (!String(key || "").startsWith(`${normalizedGuildId}:`)) continue;
  clearTimeout(timer);
  this.textMicroReflectionTimers.delete(key);
}

for (const key of this.textMicroReflectionState.keys()) {
  if (String(key || "").startsWith(`${normalizedGuildId}:`)) {
    this.textMicroReflectionState.delete(key);
  }
}

}

async waitForGuildMicroReflectionsToSettle(guildId: string, timeoutMs = DEFAULT_MICRO_REFLECTION_SETTLE_TIMEOUT_MS) { const normalizedGuildId = String(guildId || "").trim(); if (!normalizedGuildId) return;

const deadline = Date.now() + Math.max(MIN_MICRO_REFLECTION_SETTLE_TIMEOUT_MS, Number(timeoutMs) || DEFAULT_MICRO_REFLECTION_SETTLE_TIMEOUT_MS);
while (Date.now() < deadline) {
  let hasInFlight = false;
  for (const key of this.microReflectionInFlight) {
    if (
      String(key || "").startsWith(`text:${normalizedGuildId}:`) ||
      String(key || "").startsWith(`voice:${normalizedGuildId}:`)
    ) {
      hasInFlight = true;
      break;
    }
  }
  if (!hasInFlight) return;
  await sleep(INGEST_QUEUE_POLL_INTERVAL_MS);
}

}

async getDailyLogMessageIds(dailyFilePath) { const cacheKey = String(dailyFilePath || "").trim(); if (!cacheKey) return new Set(); const cached = this.dailyLogMessageIds.get(cacheKey); if (cached) return cached;

const messageIds = new Set();
try {
  const existing = await fs.readFile(cacheKey, "utf8");
  for (const line of existing.split("

")) { const match = line.match(/\bmessage:([^]\s]+)/u); if (match?.[1]) { messageIds.add(String(match[1]).trim()); } } } catch { // Ignore missing/unreadable daily file and bootstrap with an empty index. } this.dailyLogMessageIds.set(cacheKey, messageIds); return messageIds; }

async ensureDailyLogHeader(dailyFilePath, dateKey) { if (this.initializedDailyFiles.has(dailyFilePath)) return;

try {
  await fs.access(dailyFilePath);
} catch {
  const header = [
    `# Daily Memory Log ${dateKey}`,
    "",
    "- Append-only chat journal used to distill `memory/MEMORY.md`.",
    "",
    "## Entries",
    ""
  ].join("

");

  try {
    await fs.writeFile(dailyFilePath, header, { encoding: "utf8", flag: "wx" });
  } catch (error) {
    if (error?.code !== "EEXIST") throw error;
  }
}

this.initializedDailyFiles.add(dailyFilePath);

}

async purgeGuildEntriesFromDailyLogs(guildId: string) { const normalizedGuildId = String(guildId || "").trim(); if (!normalizedGuildId) { return { entriesDeleted: 0, filesTouched: 0 }; }

let dailyFileNames: string[] = [];
try {
  dailyFileNames = (await fs.readdir(this.memoryDirPath))
    .filter((name) => DAILY_FILE_PATTERN.test(name))
    .sort();
} catch {
  return {
    entriesDeleted: 0,
    filesTouched: 0
  };
}

let entriesDeleted = 0;
let filesTouched = 0;
for (const fileName of dailyFileNames) {
  const dailyFilePath = path.join(this.memoryDirPath, fileName);
  let text = "";
  try {
    text = await fs.readFile(dailyFilePath, "utf8");
  } catch {
    continue;
  }

  const lines = text.split("

"); const keptLines: string[] = []; let fileRemovedCount = 0; for (const line of lines) { const parsed = parseDailyEntryLineWithScope(line); if (parsed && String(parsed.guildId || "").trim() === normalizedGuildId) { fileRemovedCount += 1; continue; } keptLines.push(line); }

  if (!fileRemovedCount) continue;

  while (keptLines.length > 0 && keptLines[keptLines.length - 1] === "") {
    keptLines.pop();
  }
  await fs.writeFile(dailyFilePath, `${keptLines.join("

")} `, "utf8"); this.dailyLogMessageIds.delete(dailyFilePath); this.initializedDailyFiles.add(dailyFilePath); entriesDeleted += fileRemovedCount; filesTouched += 1; }

return {
  entriesDeleted,
  filesTouched
};

}

async getRecentDailyFiles(limit = 5) { try { const entries = await fs.readdir(this.memoryDirPath); return entries .filter((name) => DAILY_FILE_PATTERN.test(name)) .sort() .reverse() .slice(0, Math.max(1, limit)) .map((name) => path.join(this.memoryDirPath, name)); } catch { return []; } }

async getRecentDailyEntries({ days = 3, maxEntries = 120, guildId = null } = {}) { const files = await this.getRecentDailyFiles(days); const normalizedGuildId = String(guildId || "").trim() || null; const entries = [];

for (const filePath of files) {
  let text = "";
  try {
    text = await fs.readFile(filePath, "utf8");
  } catch {
    continue;
  }

  for (const line of text.split("

")) { const parsed = parseDailyEntryLineWithScope(line); if (!parsed) continue; if (normalizedGuildId && String(parsed.guildId || "").trim() !== normalizedGuildId) continue; entries.push({ author: parsed.author, text: parsed.content, timestampMs: parsed.timestampMs }); } }

entries.sort((a, b) => b.timestampMs - a.timestampMs);
return entries.slice(0, Math.max(1, maxEntries));

}

logMemoryError(scope, error, metadata = null) { try { this.store.logAction({ kind: "bot_error", content: memory_${scope}: ${String(error?.message || error)}, metadata }); } catch { // Avoid cascading failures while handling memory errors. } }

async runDailyReflection(settings) { if (!settings?.memory?.enabled || !settings?.memory?.reflection?.enabled) return; return await runDailyReflection({ memory: this, store: this.store, llm: this.llm, settings }); } }

export const __memoryTestables = { computeChannelScopeScore, computeTemporalDecayMultiplier, passesHybridRelevanceGate, rerankWithMmr, isInstructionLikeFactText };