doatlas-2 / lib /db /src /schema /userMemoryFacts.ts
Iostream-Li's picture
Add files using upload-large-folder tool
9c12e58 verified
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 `<memory_fact>`
* 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;