9f81c01603
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.
210 lines
5.7 KiB
TypeScript
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;
|
|
}
|
|
}
|