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:
William Valentin
2026-02-13 01:04:26 -08:00
parent 3472a0b926
commit 9f81c01603
35 changed files with 1438 additions and 144 deletions
+26
View File
@@ -31,6 +31,7 @@ describe('compactHistory', () => {
thresholdPct: 80,
keepTurns: 2, // keeps last 4 messages
summaryMaxTokens: 1024,
importanceThreshold: 1,
};
it('returns no-op when messages count is at or below keepTurns threshold', async () => {
@@ -100,6 +101,7 @@ describe('compactHistory', () => {
expect(DEFAULT_COMPACTION_CONFIG.thresholdPct).toBe(80);
expect(DEFAULT_COMPACTION_CONFIG.keepTurns).toBe(4);
expect(DEFAULT_COMPACTION_CONFIG.summaryMaxTokens).toBe(1024);
expect(DEFAULT_COMPACTION_CONFIG.importanceThreshold).toBe(1);
});
it('shifts leading assistant messages from toKeep into toCompact to ensure user-first', async () => {
@@ -120,4 +122,28 @@ describe('compactHistory', () => {
expect(result.messages[1].role).toBe('user');
expect(result.messages[1].content).toBe('Message 6');
});
it('preserves high-importance older turns instead of compacting them', async () => {
const messages: Message[] = [
{ role: 'user', content: 'hello' },
{ role: 'assistant', content: 'hi' },
{ role: 'user', content: 'I prefer concise responses and markdown tables.' },
{ role: 'assistant', content: 'noted' },
{ role: 'user', content: 'Message 4' },
{ role: 'assistant', content: 'Message 5' },
{ role: 'user', content: 'Message 6' },
{ role: 'assistant', content: 'Message 7' },
];
const orchestrator = makeMockOrchestrator();
const result = await compactHistory({
messages,
orchestrator,
config: { ...config, importanceThreshold: 0.45 },
});
expect(result.messages.some(msg => typeof msg.content === 'string' && msg.content.includes('I prefer concise responses'))).toBe(true);
expect(result.messages.some(msg => typeof msg.content === 'string' && msg.content.includes('[Summary of earlier conversation]'))).toBe(true);
expect(result.messages.length).toBeGreaterThan(5);
});
});
+33 -5
View File
@@ -4,6 +4,7 @@ import type { MemoryStore } from '../memory/store.js';
import { COMPACTION_SYSTEM_PROMPT, MEMORY_EXTRACTION_PROMPT } from '../backends/native/prompts.js';
import { estimateMessageTokens } from './tokens.js';
import { getMessageText } from '../models/media.js';
import { selectImportantMessages } from './weighting.js';
export interface CompactionConfig {
/** Percentage of context window that triggers compaction (default: 80). */
@@ -12,6 +13,8 @@ export interface CompactionConfig {
keepTurns: number;
/** Maximum tokens for the compaction summary response. */
summaryMaxTokens: number;
/** Preserve messages at or above this importance score from compaction. */
importanceThreshold: number;
}
export interface CompactionResult {
@@ -29,6 +32,7 @@ export const DEFAULT_COMPACTION_CONFIG: CompactionConfig = {
thresholdPct: 80,
keepTurns: 4,
summaryMaxTokens: 1024,
importanceThreshold: 1,
};
export async function compactHistory(opts: {
@@ -56,10 +60,34 @@ export async function compactHistory(opts: {
// Ensure toKeep starts with a user message to avoid assistant→assistant
// after the compaction summary (which has role 'assistant').
while (toKeep.length > 0 && toKeep[0].role === 'assistant') {
toCompact.push(toKeep.shift()!);
const shifted = toKeep.shift();
if (!shifted) {
break;
}
toCompact.push(shifted);
}
const formattedConversation = toCompact.map((msg) => `${msg.role}: ${getMessageText(msg)}`).join('\n\n');
const preservedImportant = selectImportantMessages(toCompact, {
threshold: config.importanceThreshold,
maxMessages: Math.max(1, config.keepTurns),
});
const preservedSet = new Set(preservedImportant.map(item => item.index));
const toSummarize = toCompact.filter((_, index) => !preservedSet.has(index));
const formattedConversation = toSummarize.map((msg) => `${msg.role}: ${getMessageText(msg)}`).join('\n\n');
const preservedMessages = preservedImportant.map(item => item.message);
if (formattedConversation.trim().length === 0) {
const compactedMessages = [...preservedMessages, ...toKeep];
return {
messages: compactedMessages,
compactedCount: messages.length - compactedMessages.length,
tokensBefore: estimateMessageTokens(messages),
tokensAfter: estimateMessageTokens(compactedMessages),
};
}
const tier = orchestrator.getDelegationTier('compaction');
@@ -99,9 +127,9 @@ export async function compactHistory(opts: {
}
return {
messages: [summaryMessage, ...toKeep],
compactedCount: toCompact.length,
messages: [...preservedMessages, summaryMessage, ...toKeep],
compactedCount: toSummarize.length,
tokensBefore: estimateMessageTokens(messages),
tokensAfter: estimateMessageTokens([summaryMessage, ...toKeep]),
tokensAfter: estimateMessageTokens([...preservedMessages, summaryMessage, ...toKeep]),
};
}