| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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"; |
|
|
| |
| 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, |
| }; |
| } |
|
|
| |
|
|
| export async function getMemoryConfig(userId: string): Promise<MemoryConfig> { |
| 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), |
| }; |
| } |
|
|
| |
|
|
| export async function listFacts( |
| userId: string, |
| opts: { includeArchived?: boolean } = {}, |
| ): Promise<PublicMemoryFact[]> { |
| 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<number> { |
| const where = opts.includeArchived |
| ? eq(userMemoryFacts.userId, userId) |
| : and( |
| eq(userMemoryFacts.userId, userId), |
| eq(userMemoryFacts.archived, 0), |
| ); |
| const rows = await db |
| .select({ n: sql<number>`count(*)::int` }) |
| .from(userMemoryFacts) |
| .where(where); |
| return rows[0]?.n ?? 0; |
| } |
|
|
| |
| export async function archiveFact( |
| userId: string, |
| id: string, |
| ): Promise<PublicMemoryFact | null> { |
| 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; |
| } |
|
|
| |
| export async function unarchiveFact( |
| userId: string, |
| id: string, |
| ): Promise<PublicMemoryFact | null> { |
| 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; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| 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); |
| |
| |
| |
| 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 }; |
| } |
|
|
| |
| |
| 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<PublicMemoryFact | null> { |
| 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<typeof userMemoryFacts.$inferInsert> & { |
| 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<boolean> { |
| const res = await db |
| .delete(userMemoryFacts) |
| .where( |
| and(eq(userMemoryFacts.id, id), eq(userMemoryFacts.userId, userId)), |
| ); |
| |
| |
| return !!res; |
| } |
|
|
| export async function clearFacts(userId: string): Promise<number> { |
| const before = await countFacts(userId); |
| await db.delete(userMemoryFacts).where(eq(userMemoryFacts.userId, userId)); |
| return before; |
| } |
|
|
| |
| |
| |
| |
| |
| |
|
|
| export async function rankFactsForTurn( |
| userId: string, |
| currentText: string, |
| cfg: MemoryConfig, |
| ): Promise<MemoryInjection> { |
| 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<void> { |
| 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), |
| ), |
| ); |
| } |
| } |
|
|