/** * Long-term cross-session memory service. * * Owns CRUD against `user_memory_facts`, normalisation/dedupe, the * per-turn ranker (salience × recency × keyword overlap, no embeddings), * a cheap token estimator, and the hard caps that apply regardless of * what the user's settings say: * * - at most 100 stored facts per user (eviction = lowest salience, * then oldest, on insert overflow); * - at most ~2000 tokens injected into the system prompt per turn; * - at most 5 newly auto-extracted facts persisted per turn (manual * creates from the user are not counted against this). */ import { and, desc, eq, sql } from "drizzle-orm"; import { db, userMemoryFacts, userSettings, type UserMemoryFact, type UserSettingsBlob, } from "@workspace/db"; import { newId } from "./ids"; import { defaultSettings } from "./defaults"; import { MEMORY_HARD_CAPS, VALID_KINDS, contentHash, estimateTokens, normalize, rankAndPackFacts, type MemoryConfig, type MemoryInjection, type PublicMemoryFact, } from "./memory-core"; // Re-export the pure helpers + types so existing call sites keep working. export { MEMORY_HARD_CAPS, VALID_KINDS, contentHash, estimateTokens, normalize, rankAndPackFacts, type MemoryConfig, type MemoryInjection, type PublicMemoryFact, }; function clampKind(kind: string | undefined | null): string { if (!kind) return "fact"; return VALID_KINDS.has(kind) ? kind : "fact"; } function clampUnit(n: unknown, dflt: number): number { if (typeof n !== "number" || !Number.isFinite(n)) return dflt; if (n < 0) return 0; if (n > 1) return 1; return n; } function clampContent(s: string): string { const t = String(s || "").trim(); return t.length > MEMORY_HARD_CAPS.contentChars ? t.slice(0, MEMORY_HARD_CAPS.contentChars) : t; } function clampMaxFacts(n: unknown): number { if (typeof n !== "number" || !Number.isFinite(n)) return MEMORY_HARD_CAPS.facts; return Math.max(1, Math.min(MEMORY_HARD_CAPS.facts, Math.floor(n))); } function clampMaxTokens(n: unknown): number { if (typeof n !== "number" || !Number.isFinite(n)) { return MEMORY_HARD_CAPS.tokensPerTurn; } return Math.max(0, Math.min(MEMORY_HARD_CAPS.tokensPerTurn, Math.floor(n))); } function toPublic(row: UserMemoryFact): PublicMemoryFact { return { id: row.id, kind: row.kind, content: row.content, confidence: row.confidence, salience: row.salience, source: (row.source as "auto" | "manual") || "auto", source_message_id: row.sourceMessageId, conversation_id: row.conversationId, use_count: row.useCount, archived: !!row.archived, created_at: row.createdAt.toISOString(), updated_at: row.updatedAt.toISOString(), last_used_at: row.lastUsedAt ? row.lastUsedAt.toISOString() : null, }; } // ---------- settings export async function getMemoryConfig(userId: string): Promise { const rows = await db .select() .from(userSettings) .where(eq(userSettings.userId, userId)) .limit(1); const blob = (rows[0]?.data ?? defaultSettings()) as UserSettingsBlob; const m = blob.long_term_memory ?? defaultSettings().long_term_memory; return { enabled: !!m.enabled, auto_extract: m.auto_extract !== false, max_facts: clampMaxFacts(m.max_facts), max_tokens_per_turn: clampMaxTokens(m.max_tokens_per_turn), }; } // ---------- CRUD export async function listFacts( userId: string, opts: { includeArchived?: boolean } = {}, ): Promise { const where = opts.includeArchived ? eq(userMemoryFacts.userId, userId) : and( eq(userMemoryFacts.userId, userId), eq(userMemoryFacts.archived, 0), ); const rows = await db .select() .from(userMemoryFacts) .where(where) .orderBy(desc(userMemoryFacts.salience), desc(userMemoryFacts.updatedAt)); return rows.map(toPublic); } export async function countFacts( userId: string, opts: { includeArchived?: boolean } = {}, ): Promise { const where = opts.includeArchived ? eq(userMemoryFacts.userId, userId) : and( eq(userMemoryFacts.userId, userId), eq(userMemoryFacts.archived, 0), ); const rows = await db .select({ n: sql`count(*)::int` }) .from(userMemoryFacts) .where(where); return rows[0]?.n ?? 0; } /** Soft-archive a fact: hidden from listing/ranking, kept on disk. */ export async function archiveFact( userId: string, id: string, ): Promise { await db .update(userMemoryFacts) .set({ archived: 1, updatedAt: new Date() }) .where( and(eq(userMemoryFacts.id, id), eq(userMemoryFacts.userId, userId)), ); const rows = await db .select() .from(userMemoryFacts) .where( and(eq(userMemoryFacts.id, id), eq(userMemoryFacts.userId, userId)), ) .limit(1); return rows[0] ? toPublic(rows[0]) : null; } /** Restore an archived fact. */ export async function unarchiveFact( userId: string, id: string, ): Promise { await db .update(userMemoryFacts) .set({ archived: 0, updatedAt: new Date() }) .where( and(eq(userMemoryFacts.id, id), eq(userMemoryFacts.userId, userId)), ); const rows = await db .select() .from(userMemoryFacts) .where( and(eq(userMemoryFacts.id, id), eq(userMemoryFacts.userId, userId)), ) .limit(1); return rows[0] ? toPublic(rows[0]) : null; } interface CreateArgs { kind?: string; content: string; confidence?: number; salience?: number; source?: "auto" | "manual"; sourceMessageId?: string | null; conversationId?: string | null; } /** * Insert a new fact, deduping by `normalized`. Returns the (possibly * pre-existing) fact along with a flag indicating whether a brand-new * row was created vs. an existing one was bumped. Enforces the per-user * `max_facts` cap by evicting the lowest-salience, then oldest, fact. */ export async function createOrBumpFact( userId: string, args: CreateArgs, cfg: MemoryConfig, ): Promise<{ fact: PublicMemoryFact; created: boolean }> { const content = clampContent(args.content); if (!content) { throw new Error("content is required"); } const normalized = normalize(content); if (!normalized) { throw new Error("content is empty after normalisation"); } const hash = contentHash(content); // Dedupe by stable hash first (cheap, index-friendly), fall back to // normalized text for legacy rows without a hash. Re-emitting an // archived fact also un-archives it. const existing = await db .select() .from(userMemoryFacts) .where( and( eq(userMemoryFacts.userId, userId), sql`(${userMemoryFacts.contentHash} = ${hash} OR (${userMemoryFacts.contentHash} = '' AND ${userMemoryFacts.normalized} = ${normalized}))`, ), ) .limit(1); if (existing[0]) { const row = existing[0]; const newSalience = Math.min( 1, Math.max(row.salience, clampUnit(args.salience, row.salience) + 0.05), ); const newConfidence = Math.max( row.confidence, clampUnit(args.confidence, row.confidence), ); await db .update(userMemoryFacts) .set({ salience: newSalience, confidence: newConfidence, useCount: row.useCount + 1, contentHash: hash, archived: 0, updatedAt: new Date(), }) .where(eq(userMemoryFacts.id, row.id)); const refreshed = (await db .select() .from(userMemoryFacts) .where(eq(userMemoryFacts.id, row.id)) .limit(1))[0]!; return { fact: toPublic(refreshed), created: false }; } // Eviction: enforce max_facts cap before insert. Archive the lowest // salience / oldest active rows rather than hard-deleting them. const cap = Math.min(cfg.max_facts, MEMORY_HARD_CAPS.facts); const total = await countFacts(userId); if (total >= cap) { const victims = await db .select({ id: userMemoryFacts.id }) .from(userMemoryFacts) .where( and( eq(userMemoryFacts.userId, userId), eq(userMemoryFacts.archived, 0), ), ) .orderBy(userMemoryFacts.salience, userMemoryFacts.createdAt) .limit(total - cap + 1); if (victims.length) { const ids = victims.map((v) => v.id); for (const id of ids) { await db .update(userMemoryFacts) .set({ archived: 1, updatedAt: new Date() }) .where(eq(userMemoryFacts.id, id)); } } } const id = newId("memf"); const row = { id, userId, kind: clampKind(args.kind), content, normalized, contentHash: hash, archived: 0, confidence: clampUnit(args.confidence, 0.7), salience: clampUnit(args.salience, args.source === "manual" ? 0.9 : 0.6), source: args.source === "manual" ? "manual" : "auto", sourceMessageId: args.sourceMessageId ?? null, conversationId: args.conversationId ?? null, useCount: 0, }; await db.insert(userMemoryFacts).values(row); const inserted = (await db .select() .from(userMemoryFacts) .where(eq(userMemoryFacts.id, id)) .limit(1))[0]!; return { fact: toPublic(inserted), created: true }; } export async function updateFact( userId: string, id: string, patch: { kind?: string; content?: string; salience?: number }, ): Promise { const rows = await db .select() .from(userMemoryFacts) .where( and(eq(userMemoryFacts.id, id), eq(userMemoryFacts.userId, userId)), ) .limit(1); if (!rows[0]) return null; const existing = rows[0]; const set: Partial & { updatedAt: Date; } = { updatedAt: new Date() }; if (typeof patch.kind === "string") set.kind = clampKind(patch.kind); if (typeof patch.content === "string") { const c = clampContent(patch.content); if (!c) throw new Error("content is required"); set.content = c; set.normalized = normalize(c); set.contentHash = contentHash(c); } if (typeof patch.salience === "number") { set.salience = clampUnit(patch.salience, existing.salience); } await db .update(userMemoryFacts) .set(set) .where(eq(userMemoryFacts.id, id)); const refreshed = (await db .select() .from(userMemoryFacts) .where(eq(userMemoryFacts.id, id)) .limit(1))[0]!; return toPublic(refreshed); } export async function deleteFact( userId: string, id: string, ): Promise { const res = await db .delete(userMemoryFacts) .where( and(eq(userMemoryFacts.id, id), eq(userMemoryFacts.userId, userId)), ); // Drizzle `delete` returns driver-specific shape; treat any throw as a // failure and otherwise assume success. return !!res; } export async function clearFacts(userId: string): Promise { const before = await countFacts(userId); await db.delete(userMemoryFacts).where(eq(userMemoryFacts.userId, userId)); return before; } // ---------- ranking + injection // // The pure ranker, prompt fragments, tokenizer and stopword list now // live in `./memory-core.ts` so they can be unit-tested without // pulling in `@workspace/db`. The async wrapper below loads the user's // facts and delegates to `rankAndPackFacts`. export async function rankFactsForTurn( userId: string, currentText: string, cfg: MemoryConfig, ): Promise { if (!cfg.enabled) { return { facts: [], fact_ids: [], injected_tokens: 0, text: null }; } const all = await listFacts(userId); return rankAndPackFacts(all, currentText, cfg); } export async function touchFacts( userId: string, factIds: string[], ): Promise { if (!factIds.length) return; const now = new Date(); for (const id of factIds) { await db .update(userMemoryFacts) .set({ lastUsedAt: now, useCount: sql`${userMemoryFacts.useCount} + 1`, }) .where( and( eq(userMemoryFacts.id, id), eq(userMemoryFacts.userId, userId), ), ); } }