import type { Message } from '../models/types.js'; import type { SessionStore } from './store.js'; import { auditLogger } from '../audit/index.js'; import { SessionIndexer } from './indexer.js'; import { SessionSearch, type HistorySearchResult } from './search.js'; export interface Session { id: string; addMessage(message: Message): void; getHistory(): Message[]; clear(): void; replaceHistory(messages: Message[]): void; getConfig(key: string): string | undefined; setConfig(key: string, value: string): void; deleteConfig(key: string): void; } export class ManagedSession implements Session { constructor( public readonly id: string, private store: SessionStore, private history: Message[] = [], private indexer?: SessionIndexer, ) {} addMessage(message: Message): Message { const messageWithTimestamp: Message = { ...message, timestamp: Date.now(), }; this.history.push(messageWithTimestamp); const content = typeof message.content === 'string' ? message.content : JSON.stringify(message.content); const metadata = this.indexer?.indexText(content); this.store.addMessage(this.id, messageWithTimestamp, metadata); auditLogger?.sessionMessage({ session_id: this.id, role: message.role, content_length: typeof message.content === 'string' ? message.content.length : JSON.stringify(message.content).length, }); return messageWithTimestamp; } getHistory(): Message[] { return [...this.history]; } clear(): void { const messageCount = this.history.length; this.history = []; this.store.clearSession(this.id); auditLogger?.sessionDelete({ session_id: this.id, message_count: messageCount, }); } /** * Replace the entire session history with new messages. * Used after compaction to persist the compacted state. * Updates both in-memory history and SQLite storage atomically. */ replaceHistory(messages: Message[]): void { this.history = [...messages]; this.store.replaceMessages(this.id, messages); } setHistory(messages: Message[]): void { this.history = [...messages]; } getConfig(key: string): string | undefined { return this.store.getSessionConfig(this.id, key); } setConfig(key: string, value: string): void { this.store.setSessionConfig(this.id, key, value); } deleteConfig(key: string): void { this.store.deleteSessionConfig(this.id, key); } } export class SessionManager { private sessions: Map = new Map(); private indexer?: SessionIndexer; private search?: SessionSearch; constructor(private store: SessionStore, historyIndexConfig?: { enabled: boolean; maxKeywords: number; searchLimit: number; minScore: number; }) { if (historyIndexConfig?.enabled) { this.indexer = new SessionIndexer({ maxKeywords: historyIndexConfig.maxKeywords, }); this.search = new SessionSearch(store, { limit: historyIndexConfig.searchLimit, minScore: historyIndexConfig.minScore, }); } } private makeSessionId(frontend: string, userId: string): string { return `${frontend}:${userId}`; } getSession(frontend: string, userId: string): ManagedSession { const id = this.makeSessionId(frontend, userId); let session = this.sessions.get(id); if (!session) { const history = this.store.getMessages(id); session = new ManagedSession(id, this.store, history, this.indexer); this.sessions.set(id, session); auditLogger?.sessionCreate({ session_id: id, frontend, user_id: userId, }); } return session; } transferSession( fromFrontend: string, fromUserId: string, toFrontend: string, toUserId: string, ): void { const fromSession = this.getSession(fromFrontend, fromUserId); const toSession = this.getSession(toFrontend, toUserId); const history = fromSession.getHistory(); // Clear target and copy history toSession.clear(); for (const message of history) { toSession.addMessage(message); } auditLogger?.sessionTransfer(fromSession.id, toSession.id, history.length); } listSessions(): string[] { return Array.from(this.sessions.keys()); } closeSession(frontend: string, userId: string): void { const id = this.makeSessionId(frontend, userId); this.sessions.delete(id); } /** Remove sessions from the in-memory cache by their IDs. */ evictSessions(sessionIds: string[]): void { for (const id of sessionIds) { this.sessions.delete(id); } } /** Get a session config value. */ getSessionConfig(frontend: string, userId: string, key: string): string | undefined { const session = this.getSession(frontend, userId); return session.getConfig(key); } /** Set a session config value. */ setSessionConfig(frontend: string, userId: string, key: string, value: string): void { const session = this.getSession(frontend, userId); session.setConfig(key, value); } /** Delete a session config value. */ deleteSessionConfig(frontend: string, userId: string, key: string): void { const session = this.getSession(frontend, userId); session.deleteConfig(key); } searchHistory(query: string, opts?: { limit?: number; sessionId?: string }): HistorySearchResult[] { if (!this.search) { return []; } return this.search.search(query, opts); } reindexHistory(): number { if (!this.indexer) { return 0; } const rows = this.store.getAllMessagesWithMetadata(); for (const row of rows) { const metadata = this.indexer.indexText(row.content); this.store.updateMessageMetadata(row.id, metadata); } return rows.length; } }