import { pgTable, text, timestamp, integer, real, index, } from "drizzle-orm/pg-core"; import { users } from "./users"; /** * Long-term, cross-session memory facts about the user. * * Populated either by the model (auto-extraction via the `` * supervisor tag) or by the user (explicit "remember this" action / * Settings UI). Caps are enforced server-side regardless of UI: at most * 100 facts per user, ~2000 tokens injected per turn, and at most 5 new * auto-extracted facts per turn (manual creates always succeed up to the * 100-fact cap, evicting the lowest-salience oldest fact when full). * * The `normalized` column is a lowercased, whitespace-collapsed, * punctuation-stripped form of `content` used to dedupe near-identical * facts: re-emitting the same fact bumps `useCount` and refreshes * `salience` instead of inserting a duplicate row. */ export const userMemoryFacts = pgTable( "user_memory_facts", { id: text("id").primaryKey(), userId: text("user_id") .notNull() .references(() => users.id, { onDelete: "cascade" }), /** preference | fact | interest | domain | terminology | summary */ kind: text("kind").notNull().default("fact"), /** Raw user-visible fact text (≤500 chars). */ content: text("content").notNull(), /** Lowercased / whitespace-collapsed dedupe key. */ normalized: text("normalized").notNull(), /** Stable SHA-256 hex of `normalized` for fast / index-friendly dedupe. */ contentHash: text("content_hash").notNull().default(""), /** Soft-archive flag: archived facts are excluded from listing/ranking * but are kept on disk so users can restore them. Eviction archives * rather than hard-deletes; the explicit "Forget" action does delete. */ archived: integer("archived").notNull().default(0), /** Model self-reported confidence at extraction time (0..1). */ confidence: real("confidence").notNull().default(0.7), /** Ranking weight for per-turn injection (0..1). Manual = 0.9. */ salience: real("salience").notNull().default(0.5), /** "auto" (model emitted) or "manual" (user clicked remember). */ source: text("source").notNull().default("auto"), /** Originating message (nullable for fully manual facts). */ sourceMessageId: text("source_message_id"), /** Originating conversation (nullable). */ conversationId: text("conversation_id"), /** Number of turns the fact was injected into. */ useCount: integer("use_count").notNull().default(0), createdAt: timestamp("created_at", { withTimezone: true }) .notNull() .defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }) .notNull() .defaultNow(), lastUsedAt: timestamp("last_used_at", { withTimezone: true }), }, (t) => ({ userIdx: index("user_memory_facts_user_idx").on(t.userId), normalizedIdx: index("user_memory_facts_normalized_idx").on( t.userId, t.normalized, ), contentHashIdx: index("user_memory_facts_content_hash_idx").on( t.userId, t.contentHash, ), archivedIdx: index("user_memory_facts_archived_idx").on( t.userId, t.archived, ), }), ); export type UserMemoryFact = typeof userMemoryFacts.$inferSelect; export type InsertUserMemoryFact = typeof userMemoryFacts.$inferInsert;