Files
flynn/src/session/manager.ts
T
William Valentin 9f81c01603 feat(session): persist model tier overrides per session
Store per-session config in SQLite and route /model and /reset through command fast-paths so channel sessions keep independent model selection across reconnects and restarts.
2026-02-13 01:04:26 -08:00

210 lines
5.7 KiB
TypeScript

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<string, ManagedSession> = 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;
}
}