Introduce context compaction: optimize conversation context persistence, reduce redundant tokens, and enhance summary handling.
CI / check (22) (push) Failing after 4m51s
CI / check (24) (push) Failing after 4m51s

This commit is contained in:
2026-10-16 14:09:23 +02:00
parent 99aff83a23
commit be4cce67f7
6 changed files with 90 additions and 23 deletions
+6
View File
@@ -10,3 +10,9 @@ UPLOAD_MAX_PIXELS=30000000
VISION_MODEL=gemma3:4b VISION_MODEL=gemma3:4b
VISION_DEBUG=false VISION_DEBUG=false
VISION_DEBUG_SHA256_ENABLED=false VISION_DEBUG_SHA256_ENABLED=false
# Required to enable automatic context compaction; use the exact hosted Ollama tag.
CONTEXT_MODEL=
CONTEXT_COMPACTION_TRIGGER_TOKENS=6000
CONTEXT_COMPACTION_RECENT_TOKENS=2500
CONTEXT_COMPACTION_MIN_MESSAGES=4
CONTEXT_COMPACTION_MAX_CHARS=8000
+7 -2
View File
@@ -69,9 +69,14 @@ export function createConfig(overrides = {}) {
visionDebugSha256Enabled: bool(process.env.VISION_DEBUG_SHA256_ENABLED, false), visionDebugSha256Enabled: bool(process.env.VISION_DEBUG_SHA256_ENABLED, false),
chatLimit: positiveInt(process.env.CHAT_LIMIT, 10), chatLimit: positiveInt(process.env.CHAT_LIMIT, 10),
chatHistoryLimit: positiveInt(process.env.CHAT_HISTORY_LIMIT, 20), chatHistoryLimit: positiveInt(process.env.CHAT_HISTORY_LIMIT, 20),
chatSummaryUpdateEveryMessages: positiveInt(process.env.CHAT_SUMMARY_UPDATE_EVERY_MESSAGES, 8), chatSummaryUpdateEveryMessages: process.env.CHAT_SUMMARY_UPDATE_EVERY_MESSAGES == null ? 8 : nonNegativeInt(process.env.CHAT_SUMMARY_UPDATE_EVERY_MESSAGES, 8),
chatSummaryMaxChars: positiveInt(process.env.CHAT_SUMMARY_MAX_CHARS, 2200), chatSummaryMaxChars: positiveInt(process.env.CHAT_SUMMARY_MAX_CHARS, 2200),
contextSummaryModel: process.env.CONTEXT_SUMMARY_MODEL || "nomic-embed-text:latest", // The only model used for persisted context compaction. Leave unset to disable it.
contextModel: process.env.CONTEXT_MODEL || null,
contextCompactionTriggerTokens: positiveInt(process.env.CONTEXT_COMPACTION_TRIGGER_TOKENS, 6000),
contextCompactionRecentTokens: positiveInt(process.env.CONTEXT_COMPACTION_RECENT_TOKENS, 2500),
contextCompactionMinMessages: positiveInt(process.env.CONTEXT_COMPACTION_MIN_MESSAGES, 4),
contextCompactionMaxChars: positiveInt(process.env.CONTEXT_COMPACTION_MAX_CHARS, 8000),
chatMaxRetries: nonNegativeInt(process.env.CHAT_MAX_RETRIES, 5), chatMaxRetries: nonNegativeInt(process.env.CHAT_MAX_RETRIES, 5),
titleModel: process.env.TITLE_MODEL || "nomic-embed-text:latest", titleModel: process.env.TITLE_MODEL || "nomic-embed-text:latest",
modelApiBaseUrl: process.env.MODEL_API_BASE_URL || "https://ai.krishd.ch/api", modelApiBaseUrl: process.env.MODEL_API_BASE_URL || "https://ai.krishd.ch/api",
+2
View File
@@ -121,6 +121,7 @@ export function createChatStatements(db) {
updateChatPersonality: db.prepare("UPDATE chat_sessions SET personality_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND username = ?"), updateChatPersonality: db.prepare("UPDATE chat_sessions SET personality_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND username = ?"),
updateChatSessionScene: db.prepare("UPDATE chat_sessions SET scenario_prompt = ?, scenario_summary = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND username = ?"), updateChatSessionScene: db.prepare("UPDATE chat_sessions SET scenario_prompt = ?, scenario_summary = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND username = ?"),
updateChatContextSummary: db.prepare("UPDATE chat_sessions SET context_summary = ?, context_summary_message_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND username = ?"), updateChatContextSummary: db.prepare("UPDATE chat_sessions SET context_summary = ?, context_summary_message_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND username = ?"),
updateChatContextSummaryIfUnchanged: db.prepare("UPDATE chat_sessions SET context_summary = ?, context_summary_message_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND username = ? AND context_summary IS ? AND context_summary_message_id = ?"),
updateChatGenerationSettings: db.prepare(` updateChatGenerationSettings: db.prepare(`
UPDATE chat_sessions UPDATE chat_sessions
SET preferred_model = ?, temperature = ?, context_length = ?, response_length = ?, system_instruction = ?, updated_at = CURRENT_TIMESTAMP SET preferred_model = ?, temperature = ?, context_length = ?, response_length = ?, system_instruction = ?, updated_at = CURRENT_TIMESTAMP
@@ -448,6 +449,7 @@ const failChatTurnTransaction = db.transaction((chatId, userMessageId, errorMess
updateChatPersonality: (chatId, username, personalityId) => statements.updateChatPersonality.run(personalityId, chatId, username), updateChatPersonality: (chatId, username, personalityId) => statements.updateChatPersonality.run(personalityId, chatId, username),
updateChatScene: (chatId, username, scenarioPrompt, scenarioSummary) => statements.updateChatSessionScene.run(scenarioPrompt, scenarioSummary, chatId, username), updateChatScene: (chatId, username, scenarioPrompt, scenarioSummary) => statements.updateChatSessionScene.run(scenarioPrompt, scenarioSummary, chatId, username),
updateChatContextSummary: (chatId, username, summary, messageId) => statements.updateChatContextSummary.run(summary, Number(messageId || 0), chatId, username), updateChatContextSummary: (chatId, username, summary, messageId) => statements.updateChatContextSummary.run(summary, Number(messageId || 0), chatId, username),
updateChatContextSummaryIfUnchanged: (chatId, username, summary, messageId, previousSummary, previousMessageId) => statements.updateChatContextSummaryIfUnchanged.run(summary, Number(messageId || 0), chatId, username, previousSummary, Number(previousMessageId || 0)),
getPersonaChatState: (chatId, username) => statements.getPersonaChatState.get(chatId, username) || null, getPersonaChatState: (chatId, username) => statements.getPersonaChatState.get(chatId, username) || null,
updatePersonaChatState(username, chat, state) { updatePersonaChatState(username, chat, state) {
statements.upsertPersonaChatState.run( statements.upsertPersonaChatState.run(
+67 -15
View File
@@ -22,6 +22,7 @@ import { SearXNGSearchProvider, WebSearchError, decideWebSearch, retrieveWebSear
import { supportsReasoning } from "../../public/js/modelCapabilities.js"; import { supportsReasoning } from "../../public/js/modelCapabilities.js";
import {VISION_EVIDENCE_GUIDANCE} from "./visionAnalysis.js"; import {VISION_EVIDENCE_GUIDANCE} from "./visionAnalysis.js";
import {createConversationContextBuilder} from "./conversationContext.js"; import {createConversationContextBuilder} from "./conversationContext.js";
import {COMPACTION_SCHEMA, COMPACTION_SYSTEM_PROMPT, buildCompactionRequest, estimateTokens, isCompactionEligibleMessage, normalizeCompaction} from "./contextCompaction.js";
export function createChatService(repositories, modelService, config, webSearchProvider = new SearXNGSearchProvider(config), attachmentService = null, visionDebugService = null) { export function createChatService(repositories, modelService, config, webSearchProvider = new SearXNGSearchProvider(config), attachmentService = null, visionDebugService = null) {
const formatChatMessage = (row, related = null) => { const formatChatMessage = (row, related = null) => {
@@ -39,7 +40,6 @@ export function createChatService(repositories, modelService, config, webSearchP
return message && attachments?.length ? { ...message, attachments } : message; return message && attachments?.length ? { ...message, attachments } : message;
}; };
const chatGenerationQueues = new Map(); const chatGenerationQueues = new Map();
const summaryQueues = new Map();
const activeGenerations = new Map(); const activeGenerations = new Map();
const regeneratingMessages = new Map(); const regeneratingMessages = new Map();
const maxRetries = Number(config.chatMaxRetries ?? 5); const maxRetries = Number(config.chatMaxRetries ?? 5);
@@ -152,7 +152,7 @@ export function createChatService(repositories, modelService, config, webSearchP
signal?.throwIfAborted(); signal?.throwIfAborted();
const title = String(JSON.parse(response)?.title || "").replace(/[\r\n]+/g, " ").replace(/["“”']/g, "").replace(/\s+/g, " ").trim(); const title = String(JSON.parse(response)?.title || "").replace(/[\r\n]+/g, " ").replace(/["“”']/g, "").replace(/\s+/g, " ").trim();
if (title && title.length <= 70 && title.split(/\s+/).length <= 8) return title; if (title && title.length <= 70 && title.split(/\s+/).length <= 8) return title;
} catch (error) { } catch {
signal?.throwIfAborted(); signal?.throwIfAborted();
} }
return "New chat"; return "New chat";
@@ -183,7 +183,7 @@ export function createChatService(repositories, modelService, config, webSearchP
return `${normalized.slice(0, Math.max(0, config.chatSummaryMaxChars - 3)).trimEnd()}...`; return `${normalized.slice(0, Math.max(0, config.chatSummaryMaxChars - 3)).trimEnd()}...`;
}; };
const maybeUpdateContextSummary = async ({ user, chatId, selectedModel }) => { const maybeUpdateContextSummary = async ({ user, chatId }) => {
if (config.chatSummaryUpdateEveryMessages <= 0) return; if (config.chatSummaryUpdateEveryMessages <= 0) return;
const session = repositories.getChat(chatId, user); const session = repositories.getChat(chatId, user);
if (!session) return; if (!session) return;
@@ -199,7 +199,8 @@ export function createChatService(repositories, modelService, config, webSearchP
if (summarizable.length < config.chatSummaryUpdateEveryMessages) return; if (summarizable.length < config.chatSummaryUpdateEveryMessages) return;
const previousSummary = String(session.context_summary || "").trim(); const previousSummary = String(session.context_summary || "").trim();
const summary = await modelService.generateReply(config.contextSummaryModel || "nomic-embed-text:latest", [ if (!config.contextModel) return;
const summary = await modelService.generateReply(config.contextModel, [
{ {
role: "system", role: "system",
content: [ content: [
@@ -237,14 +238,62 @@ export function createChatService(repositories, modelService, config, webSearchP
}; };
const scheduleContextSummaryUpdate = ({ user, chatId, selectedModel }) => { const scheduleContextSummaryUpdate = ({ user, chatId, selectedModel }) => {
const previous = summaryQueues.get(chatId) || Promise.resolve(); // Compaction is performed synchronously before the next main-model request.
const current = previous void user; void chatId; void selectedModel; void updateContextSummaryWithoutBreakingReply;
.catch(() => undefined) };
.then(() => updateContextSummaryWithoutBreakingReply({ user, chatId, selectedModel }));
summaryQueues.set(chatId, current); const ensureContextCompaction = async ({user, chatId, currentMessage, signal = null}) => {
void current.finally(() => { if (!config.contextModel) return null;
if (summaryQueues.get(chatId) === current) summaryQueues.delete(chatId); const session = repositories.getChat(chatId, user);
}); if (!session) return null;
const allMessages = repositories.listActiveBranchMessages(chatId)
.filter(isCompactionEligibleMessage);
const previousSummary = String(session.context_summary || "").trim();
const compactedThrough = Number(session.context_summary_message_id || 0);
const activeMessages = allMessages.filter(message => Number(message.id) > compactedThrough);
const contextTokens = estimateTokens(previousSummary) + activeMessages.reduce((sum, item) => sum + estimateTokens(item.content), 0) + estimateTokens(currentMessage);
if (contextTokens < Number(config.contextCompactionTriggerTokens || 6000)) return null;
let recentTokens = 0;
let splitIndex = activeMessages.length;
for (let index = activeMessages.length - 1; index >= 0; index -= 1) {
recentTokens += estimateTokens(activeMessages[index].content);
if (recentTokens > Number(config.contextCompactionRecentTokens || 2500) && activeMessages.length - index >= 2) {
splitIndex = index;
break;
}
}
const summarizable = activeMessages.slice(0, splitIndex);
if (summarizable.length < Number(config.contextCompactionMinMessages || 4)) return null;
const latestSummarized = summarizable.at(-1);
const startedAt = Date.now();
console.info("CONTEXT COMPACTION", JSON.stringify({conversationId: chatId, triggered: true, contextTokens,
compactedTokens: summarizable.reduce((sum, item) => sum + estimateTokens(item.content), 0), messages: summarizable.length}));
let rawResult;
try {
rawResult = await modelService.generateReply(config.contextModel, [
{role: "system", content: COMPACTION_SYSTEM_PROMPT},
{role: "user", content: buildCompactionRequest(previousSummary, summarizable)}
], {signal, reasoning: false, generation: {temperature: 0, responseLength: 1600}, format: COMPACTION_SCHEMA});
} catch (error) {
console.warn("CONTEXT COMPACTION FAILED", JSON.stringify({conversationId: chatId, error: String(error?.message || error)}));
return null;
}
const compacted = normalizeCompaction(rawResult, Number(config.contextCompactionMaxChars || 8000));
if (!compacted) {
console.warn("CONTEXT COMPACTION INVALID", JSON.stringify({conversationId: chatId, durationMs: Date.now() - startedAt,
resultChars: typeof rawResult === "string" ? rawResult.length : null}));
return null;
}
const serialized = JSON.stringify(compacted);
const update = repositories.updateChatContextSummaryIfUnchanged?.(chatId, user, serialized, latestSummarized.id,
session.context_summary, session.context_summary_message_id);
if (repositories.updateChatContextSummaryIfUnchanged && !update?.changes) {
console.warn("CONTEXT COMPACTION STALE", JSON.stringify({conversationId: chatId, durationMs: Date.now() - startedAt}));
return repositories.getChat(chatId, user);
}
console.info("CONTEXT COMPACTION COMPLETE", JSON.stringify({conversationId: chatId, durationMs: Date.now() - startedAt,
compactedMessages: summarizable.length, resultChars: serialized.length}));
return {...session, context_summary: serialized, context_summary_message_id: latestSummarized.id};
}; };
const getOrCreatePersonaChat = (user, persona, userPersonaId = null, scenarioPrompt = null, scenarioSummary = null) => { const getOrCreatePersonaChat = (user, persona, userPersonaId = null, scenarioPrompt = null, scenarioSummary = null) => {
@@ -420,7 +469,6 @@ export function createChatService(repositories, modelService, config, webSearchP
const generateChatTurn = ({ user, chatId, message, model, attachments = [], reuseChatId = null, userMessageId: resumeMessageId = null, webSearch = true, reasoning = false, onStatus = null, onChunk = null, onThinking = null, signal = null, parentMessageId = undefined, requestId = null }) => const generateChatTurn = ({ user, chatId, message, model, attachments = [], reuseChatId = null, userMessageId: resumeMessageId = null, webSearch = true, reasoning = false, onStatus = null, onChunk = null, onThinking = null, signal = null, parentMessageId = undefined, requestId = null }) =>
withChatGenerationLock(chatId, async () => { withChatGenerationLock(chatId, async () => {
await (summaryQueues.get(chatId) || Promise.resolve());
const session = repositories.getChat(chatId, user); const session = repositories.getChat(chatId, user);
if (!session) return { error: "Chat not found", status: 404 }; if (!session) return { error: "Chat not found", status: 404 };
if (parentMessageId !== undefined) { if (parentMessageId !== undefined) {
@@ -522,6 +570,11 @@ export function createChatService(repositories, modelService, config, webSearchP
})); }));
} }
} }
const compactedSession = await ensureContextCompaction({user, chatId, currentMessage: message, signal: activeSignal});
if (compactedSession) {
session.context_summary = compactedSession.context_summary;
session.context_summary_message_id = compactedSession.context_summary_message_id;
}
onStatus?.({ status: "generating", searchState, ...(sources !== null ? { sourceCount: sources.length } : {}) }); onStatus?.({ status: "generating", searchState, ...(sources !== null ? { sourceCount: sources.length } : {}) });
payload = buildConversationContext({ user, chatId, session, message: null, upToMessageId: userMessageId, payload = buildConversationContext({ user, chatId, session, message: null, upToMessageId: userMessageId,
historyOverride: imageHistory, visionSelection, contextSummaryOverride: session.context_summary, sources }); historyOverride: imageHistory, visionSelection, contextSummaryOverride: session.context_summary, sources });
@@ -681,7 +734,7 @@ export function createChatService(repositories, modelService, config, webSearchP
}, },
persistChatMessageRetryState, persistChatMessageRetryState,
buildMessagesPayload: buildConversationContext, buildMessagesPayload: buildConversationContext,
async generateInitialChatTitle({user, chatId, message, model, signal = null}) { async generateInitialChatTitle({user, chatId, message, signal = null}) {
const session = repositories.getChat(chatId, user); const session = repositories.getChat(chatId, user);
if (!session) return {error: "Chat not found", status: 404}; if (!session) return {error: "Chat not found", status: 404};
if (session.assistant_persona_id || session.title !== "New chat" || repositories.getLatestChatMessage(chatId)) { if (session.assistant_persona_id || session.title !== "New chat" || repositories.getLatestChatMessage(chatId)) {
@@ -703,7 +756,6 @@ export function createChatService(repositories, modelService, config, webSearchP
if (chatGenerationQueues.has(chatId)) return { error: "A response is already being generated", status: 409 }; if (chatGenerationQueues.has(chatId)) return { error: "A response is already being generated", status: 409 };
regeneratingMessages.set(chatId, targetMessage.id); regeneratingMessages.set(chatId, targetMessage.id);
return withChatGenerationLock(chatId, async () => { return withChatGenerationLock(chatId, async () => {
await (summaryQueues.get(chatId) || Promise.resolve());
const session = repositories.getChat(chatId, user); const session = repositories.getChat(chatId, user);
if (!session) return { error: "Chat not found", status: 404 }; if (!session) return { error: "Chat not found", status: 404 };
+3 -1
View File
@@ -28,9 +28,11 @@ export function createConversationContextBuilder({repositories, attachmentServic
if (!activeSession) return null; if (!activeSession) return null;
const activePersona = repositories.getAssistantPersonaForChat(chatId, user); const activePersona = repositories.getAssistantPersonaForChat(chatId, user);
const activeUserPersona = getUserPersonaForSession(chatId, user, activePersona); const activeUserPersona = getUserPersonaForSession(chatId, user, activePersona);
const history = historyOverride || (upToMessageId == null let history = historyOverride || (upToMessageId == null
? repositories.getRecentChatMessages(chatId).reverse() ? repositories.getRecentChatMessages(chatId).reverse()
: repositories.getRecentChatMessagesUpToId(chatId, upToMessageId).reverse()); : repositories.getRecentChatMessagesUpToId(chatId, upToMessageId).reverse());
const compactedThrough = Number(activeSession.context_summary_message_id || 0);
if (compactedThrough) history = history.filter(entry => Number(entry.id || 0) > compactedThrough);
const conversation = message == null ? history : [...history, {role: "user", content: message}]; const conversation = message == null ? history : [...history, {role: "user", content: message}];
const messagesPayload = []; const messagesPayload = [];
// Search results stay untrusted user context, followed by their grounding rule. // Search results stay untrusted user context, followed by their grounding rule.
+5 -5
View File
@@ -754,10 +754,10 @@ test("chat context summary rolls up older messages into future payloads", async
let latestPayload = []; let latestPayload = [];
let summaryModel = null; let summaryModel = null;
const modelService = { const modelService = {
async generateReply(model, messagesPayload) { async generateReply(model, messagesPayload, options = {}) {
if (messagesPayload.some((message) => message.content.includes("Merge the previous summary"))) { if (options.format && messagesPayload.some((message) => message.content.includes("Newly expired conversation records"))) {
summaryModel = model; summaryModel = model;
return "Summary: user greeted the assistant and asked for continuity."; return JSON.stringify({summary: "user greeted the assistant and asked for continuity.", current_goals: [], important_facts: ["user greeted the assistant"], decisions: [], constraints: [], technical_state: [], open_tasks: [], problems_and_solutions: [], user_preferences: [], important_exact_values: []});
} }
latestPayload = messagesPayload; latestPayload = messagesPayload;
replyCount += 1; replyCount += 1;
@@ -781,7 +781,7 @@ test("chat context summary rolls up older messages into future payloads", async
}; };
const server = await startTestServer({ const server = await startTestServer({
modelService, modelService,
config: {chatHistoryLimit: 2, chatSummaryUpdateEveryMessages: 2} config: {chatHistoryLimit: 2, chatSummaryUpdateEveryMessages: 2, contextModel: "configured:context", contextCompactionTriggerTokens: 1, contextCompactionRecentTokens: 1, contextCompactionMinMessages: 2}
}); });
const jar = new CookieJar(); const jar = new CookieJar();
@@ -812,7 +812,7 @@ test("chat context summary rolls up older messages into future payloads", async
assert.ok(latestPayload.some((message) => message.role === "system" && message.content.includes("CONVERSATION SUMMARY"))); assert.ok(latestPayload.some((message) => message.role === "system" && message.content.includes("CONVERSATION SUMMARY")));
assert.ok(latestPayload.some((message) => message.content.includes("user greeted the assistant"))); assert.ok(latestPayload.some((message) => message.content.includes("user greeted the assistant")));
assert.equal(summaryModel, "nomic-embed-text:latest"); assert.equal(summaryModel, "configured:context");
await server.close(); await server.close();
}); });