/** * Server-side conversation memory. Persists each run's user goal + final * assistant answer per session, and recalls the recent turns so a run has * context even when the plugin sends little/no history. Independent of the * plugin's own carry-forward. */ import { getDb } from '../db/pool.js'; import { newId } from '../lib/crypto.js'; import type { RunContextMessage } from './types.js'; const MAX_RECALL = 12; // turns to prepend const MAX_CONTENT = 4000; // clip very long turns export function saveTurn(session_id: string, role: 'user' | 'assistant', content: string, user_id?: string): void { if (!session_id || !content) return; getDb().prepare( 'INSERT INTO conversation_turns (id, session_id, user_id, role, content) VALUES (?,?,?,?,?)', ).run(newId(), session_id, user_id ?? null, role, content.slice(0, MAX_CONTENT)); } export function recallTurns(session_id: string, limit = MAX_RECALL): RunContextMessage[] { if (!session_id) return []; const rows = getDb().prepare( 'SELECT role, content FROM conversation_turns WHERE session_id = ? ORDER BY created_at DESC LIMIT ?', ).all(session_id, limit) as { role: string; content: string }[]; return rows .reverse() .map((r) => ({ role: r.role === 'assistant' ? 'assistant' : 'user', content: r.content }) as RunContextMessage); } /** * Merge recalled memory with the context the plugin sent, de-duplicating * so we don't double-feed turns the plugin already included. Memory is * prepended (older), then the plugin's context (newer). */ export function mergeContext(session_id: string, pluginContext: RunContextMessage[]): RunContextMessage[] { const recalled = recallTurns(session_id); if (recalled.length === 0) return pluginContext; const seen = new Set(pluginContext.map((m) => `${m.role}:${m.content.slice(0, 80)}`)); const fromMemory = recalled.filter((m) => !seen.has(`${m.role}:${m.content.slice(0, 80)}`)); return [...fromMemory, ...pluginContext]; }