import type { Agent } from "agents"; import { OperationFailure } from "./operation-result"; import { DEFAULT_INSTRUCTIONS, parseInstructions, type InstructionSettings, } from "../shared/instructions"; import { MAX_MEMORIES, MEMORY_SEARCH_LIMIT, memorySearchQuery, parseMemoryContent, validateMemoryId, validateMemoryVersion, type MemoryFact, } from "../shared/memory"; export class PersonalMemory { constructor(private readonly sql: Agent["sql"]) {} initialize() { this.sql`CREATE TABLE IF NOT EXISTS flarebot_instructions ( singleton INTEGER PRIMARY KEY CHECK (singleton = 1), instructions TEXT, updated_at TEXT )`; this .sql`INSERT OR IGNORE INTO flarebot_instructions (singleton) VALUES (1)`; this.sql`CREATE TABLE IF NOT EXISTS flarebot_memories ( id TEXT PRIMARY KEY, content TEXT, version INTEGER NOT NULL, createdAt TEXT NOT NULL, updatedAt TEXT NOT NULL )`; this.sql`CREATE VIRTUAL TABLE IF NOT EXISTS flarebot_memories_search USING fts5(id UNINDEXED, content, tokenize = 'porter unicode61')`; this .sql`CREATE TRIGGER IF NOT EXISTS flarebot_memory_insert AFTER INSERT ON flarebot_memories WHEN new.content IS NOT NULL BEGIN INSERT INTO flarebot_memories_search(id, content) VALUES (new.id, new.content); END`; this .sql`CREATE TRIGGER IF NOT EXISTS flarebot_memory_update AFTER UPDATE ON flarebot_memories BEGIN DELETE FROM flarebot_memories_search WHERE id = old.id; INSERT INTO flarebot_memories_search(id, content) SELECT new.id, new.content WHERE new.content IS NOT NULL; END`; } getInstructionSettings(): InstructionSettings { const row = this.instructionSettingsRow(); return { instructions: row.instructions ?? DEFAULT_INSTRUCTIONS, defaultInstructions: DEFAULT_INSTRUCTIONS, customized: row.instructions !== null, updatedAt: row.updated_at, }; } updateInstructions(value: unknown): InstructionSettings { const instructions = parseInstructions(value); this.sql`UPDATE flarebot_instructions SET instructions = ${instructions}, updated_at = ${new Date().toISOString()} WHERE singleton = 1`; return this.getInstructionSettings(); } resetInstructions(): InstructionSettings { this.sql`UPDATE flarebot_instructions SET instructions = NULL, updated_at = ${new Date().toISOString()} WHERE singleton = 1`; return this.getInstructionSettings(); } private instructionSettingsRow() { return this.sql<{ instructions: string | null; updated_at: string | null; }>`SELECT instructions, updated_at FROM flarebot_instructions WHERE singleton = 1`[0]; } readInstructions(): string { return this.instructionSettingsRow().instructions ?? DEFAULT_INSTRUCTIONS; } listMemories(): MemoryFact[] { return this.sql`SELECT * FROM flarebot_memories WHERE content IS NOT NULL ORDER BY createdAt DESC, id LIMIT ${MAX_MEMORIES}`; } addMemory(value: unknown): MemoryFact { return this.insertMemory(crypto.randomUUID(), value); } insertMemory(id: string, value: unknown): MemoryFact { const content = parseMemoryContent(value); const existing = this .sql`SELECT * FROM flarebot_memories WHERE id = ${id}`[0]; // Keep content-free tombstones: a replay after deletion must never resurrect a fact. if (existing) { if (existing.content === null) throw new OperationFailure("memory_deleted"); return existing; } const [{ count }] = this.sql<{ count: number }>`SELECT count(*) AS count FROM flarebot_memories WHERE content IS NOT NULL`; if (count >= MAX_MEMORIES) throw new OperationFailure("memory_full"); const now = new Date().toISOString(); this .sql`INSERT INTO flarebot_memories VALUES (${id}, ${content}, 1, ${now}, ${now})`; return { id, content, version: 1, createdAt: now, updatedAt: now }; } updateMemory(id: unknown, value: unknown, version: unknown): MemoryFact { validateMemoryId(id); validateMemoryVersion(version); const content = parseMemoryContent(value); const [updated] = this.sql`UPDATE flarebot_memories SET content = ${content}, version = version + 1, updatedAt = ${new Date().toISOString()} WHERE id = ${id} AND content IS NOT NULL AND version = ${version} RETURNING *`; if (!updated) throw new OperationFailure("memory_edit_conflict"); return updated; } deleteMemory(id: unknown, version: unknown): { deleted: true } { validateMemoryId(id); validateMemoryVersion(version); const [existing] = this .sql`SELECT * FROM flarebot_memories WHERE id = ${id}`; if (!existing || existing.content === null) return { deleted: true }; if (existing.version !== version) throw new OperationFailure("memory_delete_conflict"); this.sql`UPDATE flarebot_memories SET content = NULL, version = version + 1, updatedAt = ${new Date().toISOString()} WHERE id = ${id}`; return { deleted: true }; } searchMemories(value: unknown): MemoryFact[] { const query = memorySearchQuery(value); if (!query) return []; return this.sql`SELECT m.* FROM flarebot_memories_search JOIN flarebot_memories m ON m.id = flarebot_memories_search.id WHERE flarebot_memories_search MATCH ${query} AND m.content IS NOT NULL ORDER BY bm25(flarebot_memories_search), m.id LIMIT ${MEMORY_SEARCH_LIMIT}`; } }