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.
This commit is contained in:
@@ -1,2 +1,6 @@
|
||||
export { SessionStore, parseDuration } from './store.js';
|
||||
export { SessionManager, ManagedSession, type Session } from './manager.js';
|
||||
export { SessionIndexer, tokenize } from './indexer.js';
|
||||
export type { HistoryMetadata, HistoryIndexerConfig } from './indexer.js';
|
||||
export { SessionSearch } from './search.js';
|
||||
export type { HistorySearchResult, HistorySearchConfig } from './search.js';
|
||||
|
||||
@@ -58,4 +58,37 @@ describe('SessionManager', () => {
|
||||
expect(sessions).toContain('telegram:user-123');
|
||||
expect(sessions).toContain('tui:local');
|
||||
});
|
||||
|
||||
it('indexes and searches history when enabled', () => {
|
||||
manager = new SessionManager(store, {
|
||||
enabled: true,
|
||||
maxKeywords: 8,
|
||||
searchLimit: 10,
|
||||
minScore: 0.1,
|
||||
});
|
||||
|
||||
const session = manager.getSession('telegram', 'user-123');
|
||||
session.addMessage({ role: 'user', content: 'deploy backend api' });
|
||||
|
||||
const results = manager.searchHistory('deploy backend');
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results[0].sessionId).toBe('telegram:user-123');
|
||||
});
|
||||
|
||||
it('reindexHistory is safe and idempotent', () => {
|
||||
manager = new SessionManager(store, {
|
||||
enabled: true,
|
||||
maxKeywords: 8,
|
||||
searchLimit: 10,
|
||||
minScore: 0.1,
|
||||
});
|
||||
|
||||
const session = manager.getSession('telegram', 'user-abc');
|
||||
session.addMessage({ role: 'user', content: 'history indexing test' });
|
||||
|
||||
const first = manager.reindexHistory();
|
||||
const second = manager.reindexHistory();
|
||||
expect(first).toBeGreaterThan(0);
|
||||
expect(second).toBe(first);
|
||||
});
|
||||
});
|
||||
|
||||
+54
-10
@@ -1,6 +1,8 @@
|
||||
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;
|
||||
@@ -18,6 +20,7 @@ export class ManagedSession implements Session {
|
||||
public readonly id: string,
|
||||
private store: SessionStore,
|
||||
private history: Message[] = [],
|
||||
private indexer?: SessionIndexer,
|
||||
) {}
|
||||
|
||||
addMessage(message: Message): Message {
|
||||
@@ -26,16 +29,20 @@ export class ManagedSession implements Session {
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
this.history.push(messageWithTimestamp);
|
||||
this.store.addMessage(this.id, 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
|
||||
content_length: typeof message.content === 'string'
|
||||
? message.content.length
|
||||
: JSON.stringify(message.content).length,
|
||||
});
|
||||
|
||||
|
||||
return messageWithTimestamp;
|
||||
}
|
||||
|
||||
@@ -47,7 +54,7 @@ export class ManagedSession implements Session {
|
||||
const messageCount = this.history.length;
|
||||
this.history = [];
|
||||
this.store.clearSession(this.id);
|
||||
|
||||
|
||||
auditLogger?.sessionDelete({
|
||||
session_id: this.id,
|
||||
message_count: messageCount,
|
||||
@@ -83,8 +90,25 @@ export class ManagedSession implements Session {
|
||||
|
||||
export class SessionManager {
|
||||
private sessions: Map<string, ManagedSession> = new Map();
|
||||
private indexer?: SessionIndexer;
|
||||
private search?: SessionSearch;
|
||||
|
||||
constructor(private store: SessionStore) {}
|
||||
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}`;
|
||||
@@ -96,9 +120,9 @@ export class SessionManager {
|
||||
let session = this.sessions.get(id);
|
||||
if (!session) {
|
||||
const history = this.store.getMessages(id);
|
||||
session = new ManagedSession(id, this.store, history);
|
||||
session = new ManagedSession(id, this.store, history, this.indexer);
|
||||
this.sessions.set(id, session);
|
||||
|
||||
|
||||
auditLogger?.sessionCreate({
|
||||
session_id: id,
|
||||
frontend,
|
||||
@@ -125,7 +149,7 @@ export class SessionManager {
|
||||
for (const message of history) {
|
||||
toSession.addMessage(message);
|
||||
}
|
||||
|
||||
|
||||
auditLogger?.sessionTransfer(fromSession.id, toSession.id, history.length);
|
||||
}
|
||||
|
||||
@@ -162,4 +186,24 @@ export class SessionManager {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { SessionStore } from './store.js';
|
||||
import { SessionIndexer } from './indexer.js';
|
||||
import { unlinkSync, existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
@@ -63,6 +64,16 @@ describe('SessionStore', () => {
|
||||
expect(sessions).toContain('session-b');
|
||||
});
|
||||
|
||||
it('stores and retrieves message metadata for indexed history', () => {
|
||||
const indexer = new SessionIndexer({ maxKeywords: 5 });
|
||||
const metadata = indexer.indexText('deploy backend release');
|
||||
store.addMessage('session-meta', { role: 'user', content: 'deploy backend release' }, metadata);
|
||||
|
||||
const rows = store.getMessagesWithMetadata('session-meta');
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].metadata?.keywords).toContain('deploy');
|
||||
});
|
||||
|
||||
describe('pairing persistence', () => {
|
||||
it('getPairingStore returns a PairingStore', () => {
|
||||
const pairingStore = store.getPairingStore();
|
||||
|
||||
+75
-5
@@ -1,6 +1,7 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import type { Message } from '../models/types.js';
|
||||
import type { PairingStore, ApprovedSender } from '../channels/pairing.js';
|
||||
import type { HistoryMetadata } from './indexer.js';
|
||||
|
||||
/** Parse a duration string like '30d', '7d', '12h' to milliseconds. Returns null if invalid or '0'. */
|
||||
export function parseDuration(s: string): number | null {
|
||||
@@ -44,13 +45,18 @@ export class SessionStore {
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_session_config_session ON session_config(session_id);
|
||||
`);
|
||||
|
||||
const messageColumns = this.db.prepare('PRAGMA table_info(messages)').all() as Array<{ name: string }>;
|
||||
if (!messageColumns.some(column => column.name === 'metadata')) {
|
||||
this.db.exec('ALTER TABLE messages ADD COLUMN metadata TEXT');
|
||||
}
|
||||
}
|
||||
|
||||
addMessage(sessionId: string, message: Message): void {
|
||||
addMessage(sessionId: string, message: Message, metadata?: HistoryMetadata): void {
|
||||
const stmt = this.db.prepare(
|
||||
'INSERT INTO messages (session_id, role, content) VALUES (?, ?, ?)',
|
||||
'INSERT INTO messages (session_id, role, content, metadata) VALUES (?, ?, ?, ?)',
|
||||
);
|
||||
stmt.run(sessionId, message.role, message.content);
|
||||
stmt.run(sessionId, message.role, message.content, metadata ? JSON.stringify(metadata) : null);
|
||||
}
|
||||
|
||||
getMessages(sessionId: string): Message[] {
|
||||
@@ -75,10 +81,10 @@ export class SessionStore {
|
||||
this.db.prepare('DELETE FROM messages WHERE session_id = ?').run(sessionId);
|
||||
// Re-insert in order
|
||||
const insert = this.db.prepare(
|
||||
'INSERT INTO messages (session_id, role, content) VALUES (?, ?, ?)',
|
||||
'INSERT INTO messages (session_id, role, content, metadata) VALUES (?, ?, ?, ?)',
|
||||
);
|
||||
for (const msg of messages) {
|
||||
insert.run(sessionId, msg.role, msg.content);
|
||||
insert.run(sessionId, msg.role, msg.content, null);
|
||||
}
|
||||
});
|
||||
transaction();
|
||||
@@ -194,4 +200,68 @@ export class SessionStore {
|
||||
close(): void {
|
||||
this.db.close();
|
||||
}
|
||||
|
||||
getMessagesWithMetadata(sessionId: string): Array<{
|
||||
id: number;
|
||||
sessionId: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
createdAt: number;
|
||||
metadata: HistoryMetadata | null;
|
||||
}> {
|
||||
const stmt = this.db.prepare(
|
||||
'SELECT id, session_id, role, content, created_at, metadata FROM messages WHERE session_id = ? ORDER BY id ASC',
|
||||
);
|
||||
const rows = stmt.all(sessionId) as Array<{
|
||||
id: number;
|
||||
session_id: string;
|
||||
role: string;
|
||||
content: string;
|
||||
created_at: number;
|
||||
metadata: string | null;
|
||||
}>;
|
||||
|
||||
return rows.map(row => ({
|
||||
id: row.id,
|
||||
sessionId: row.session_id,
|
||||
role: row.role as 'user' | 'assistant',
|
||||
content: row.content,
|
||||
createdAt: row.created_at,
|
||||
metadata: row.metadata ? JSON.parse(row.metadata) as HistoryMetadata : null,
|
||||
}));
|
||||
}
|
||||
|
||||
getAllMessagesWithMetadata(): Array<{
|
||||
id: number;
|
||||
sessionId: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
createdAt: number;
|
||||
metadata: HistoryMetadata | null;
|
||||
}> {
|
||||
const stmt = this.db.prepare(
|
||||
'SELECT id, session_id, role, content, created_at, metadata FROM messages ORDER BY id ASC',
|
||||
);
|
||||
const rows = stmt.all() as Array<{
|
||||
id: number;
|
||||
session_id: string;
|
||||
role: string;
|
||||
content: string;
|
||||
created_at: number;
|
||||
metadata: string | null;
|
||||
}>;
|
||||
|
||||
return rows.map(row => ({
|
||||
id: row.id,
|
||||
sessionId: row.session_id,
|
||||
role: row.role as 'user' | 'assistant',
|
||||
content: row.content,
|
||||
createdAt: row.created_at,
|
||||
metadata: row.metadata ? JSON.parse(row.metadata) as HistoryMetadata : null,
|
||||
}));
|
||||
}
|
||||
|
||||
updateMessageMetadata(messageId: number, metadata: HistoryMetadata): void {
|
||||
this.db.prepare('UPDATE messages SET metadata = ? WHERE id = ?').run(JSON.stringify(metadata), messageId);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user