Iostream-Li's picture
Add files using upload-large-folder tool
5871090 verified
/**
* 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<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),
};
}
// ---------- CRUD
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;
}
/** Soft-archive a fact: hidden from listing/ranking, kept on disk. */
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;
}
/** Restore an archived fact. */
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;
}
/**
* 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<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)),
);
// 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<number> {
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<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),
),
);
}
}