diff --git a/cli/src/test/kotlin/io/askimo/core/config/AppConfigTest.kt b/cli/src/test/kotlin/io/askimo/core/config/AppConfigTest.kt index 61ee2c017..ed3952faf 100644 --- a/cli/src/test/kotlin/io/askimo/core/config/AppConfigTest.kt +++ b/cli/src/test/kotlin/io/askimo/core/config/AppConfigTest.kt @@ -181,20 +181,79 @@ class AppConfigTest { fun `updateChatField should handle all fields`() { var config = ChatConfig() - // Test top-level fields config = updateChatFieldHelper(config, "maxTokens", 10000) assertEquals(10000, config.maxTokens) + } + + @Test + fun `updateMemoryField should handle mode - swaps to full preset`() { + var config = MemoryConfig() + + config = updateMemoryFieldHelper(config, "mode", MemoryMode.COMPACT) + assertEquals(MemoryMode.COMPACT, config.mode) + assertEquals(0.25, config.summarizationThreshold, 0.001) + assertEquals(3, config.protectedRecentTurns) + assertEquals(0.30, config.memoryBudgetFraction, 0.001) + + config = updateMemoryFieldHelper(config, "mode", "DETAIL") + assertEquals(MemoryMode.DETAIL, config.mode) + assertEquals(0.60, config.summarizationThreshold, 0.001) + assertEquals(10, config.protectedRecentTurns) + assertEquals(0.50, config.memoryBudgetFraction, 0.001) + } - config = updateChatFieldHelper(config, "summarizationThreshold", 0.8) - assertEquals(0.8, config.summarizationThreshold, 0.001) + @Test + fun `updateMemoryField should handle all numeric fields`() { + var config = MemoryConfig() - config = updateChatFieldHelper(config, "enableAsyncSummarization", false) - assertFalse(config.enableAsyncSummarization) + config = updateMemoryFieldHelper(config, "summarizationThreshold", 0.5) + assertEquals(0.5, config.summarizationThreshold, 0.001) - // Verify all fields are correct after multiple updates - assertEquals(10000, config.maxTokens) - assertEquals(0.8, config.summarizationThreshold, 0.001) - assertFalse(config.enableAsyncSummarization) + config = updateMemoryFieldHelper(config, "protectedRecentTurns", 8) + assertEquals(8, config.protectedRecentTurns) + + config = updateMemoryFieldHelper(config, "summarizationPruneFraction", 0.7) + assertEquals(0.7, config.summarizationPruneFraction, 0.001) + + config = updateMemoryFieldHelper(config, "maxKeyFacts", 40) + assertEquals(40, config.maxKeyFacts) + + config = updateMemoryFieldHelper(config, "maxMainTopics", 20) + assertEquals(20, config.maxMainTopics) + + config = updateMemoryFieldHelper(config, "maxSummaryLength", 3000) + assertEquals(3000, config.maxSummaryLength) + + config = updateMemoryFieldHelper(config, "memoryBudgetFraction", 0.45) + assertEquals(0.45, config.memoryBudgetFraction, 0.001) + } + + @Test + fun `MemoryConfig preset values should be correct`() { + val compact = MemoryConfig.COMPACT + assertEquals(MemoryMode.COMPACT, compact.mode) + assertEquals(0.25, compact.summarizationThreshold, 0.001) + assertEquals(3, compact.protectedRecentTurns) + assertEquals(0.30, compact.memoryBudgetFraction, 0.001) + + val balanced = MemoryConfig.BALANCED + assertEquals(MemoryMode.BALANCED, balanced.mode) + assertEquals(0.40, balanced.summarizationThreshold, 0.001) + assertEquals(6, balanced.protectedRecentTurns) + assertEquals(0.40, balanced.memoryBudgetFraction, 0.001) + + val detail = MemoryConfig.DETAIL + assertEquals(MemoryMode.DETAIL, detail.mode) + assertEquals(0.60, detail.summarizationThreshold, 0.001) + assertEquals(10, detail.protectedRecentTurns) + assertEquals(0.50, detail.memoryBudgetFraction, 0.001) + } + + @Test + fun `MemoryConfig preset factory should return correct preset`() { + assertEquals(MemoryConfig.preset(MemoryMode.COMPACT), MemoryConfig.COMPACT) + assertEquals(MemoryConfig.preset(MemoryMode.BALANCED), MemoryConfig.BALANCED) + assertEquals(MemoryConfig.preset(MemoryMode.DETAIL), MemoryConfig.DETAIL) } @Test @@ -252,6 +311,17 @@ class AppConfigTest { return method.invoke(AppConfig, config, field, value) as ChatConfig } + private fun updateMemoryFieldHelper(config: MemoryConfig, field: String, value: Any): MemoryConfig { + val method = AppConfig::class.java.getDeclaredMethod( + "updateMemoryField", + MemoryConfig::class.java, + String::class.java, + Any::class.java, + ) + method.isAccessible = true + return method.invoke(AppConfig, config, field, value) as MemoryConfig + } + private fun updateDeveloperFieldHelper(config: DeveloperConfig, field: String, value: Any): DeveloperConfig { val method = AppConfig::class.java.getDeclaredMethod( "updateDeveloperField", diff --git a/cli/src/test/kotlin/io/askimo/core/memory/TokenAwareSummarizingMemoryTest.kt b/cli/src/test/kotlin/io/askimo/core/memory/TokenAwareSummarizingMemoryTest.kt index e8da1d508..b03ac6e6e 100644 --- a/cli/src/test/kotlin/io/askimo/core/memory/TokenAwareSummarizingMemoryTest.kt +++ b/cli/src/test/kotlin/io/askimo/core/memory/TokenAwareSummarizingMemoryTest.kt @@ -10,6 +10,7 @@ import dev.langchain4j.data.message.SystemMessage import dev.langchain4j.data.message.UserMessage import io.askimo.core.chat.domain.SessionMemory import io.askimo.core.chat.repository.SessionMemoryRepository +import io.askimo.core.config.AppConfig import io.askimo.core.context.AppContext import io.askimo.core.providers.ChatClient import io.askimo.test.extensions.AskimoTestHome @@ -542,17 +543,20 @@ class TokenAwareSummarizingMemoryTest { // ── Helper ─────────────────────────────────────────────────────────────── private fun createMemory( - summarizationThreshold: Double = 0.4, // matches updated default + summarizationThreshold: Double = 0.4, asyncSummarization: Boolean = true, tokenEstimator: (ChatMessage) -> Int = TokenAwareSummarizingMemory.defaultTokenEstimator(), summarizationTimeoutSeconds: Long = 30, - ): TokenAwareSummarizingMemory = TokenAwareSummarizingMemory( - appContext = mockAppContext, - sessionId = sessionId, - sessionMemoryRepository = mockRepository, - tokenEstimator = tokenEstimator, - summarizationThreshold = summarizationThreshold, - asyncSummarization = asyncSummarization, - summarizationTimeoutSeconds = summarizationTimeoutSeconds, - ) + ): TokenAwareSummarizingMemory { + // Drive threshold through AppConfig — @AskimoTestHome resets config between tests + AppConfig.updateField("memory.summarizationThreshold", summarizationThreshold) + return TokenAwareSummarizingMemory( + appContext = mockAppContext, + sessionId = sessionId, + sessionMemoryRepository = mockRepository, + tokenEstimator = tokenEstimator, + asyncSummarization = asyncSummarization, + summarizationTimeoutSeconds = summarizationTimeoutSeconds, + ) + } } diff --git a/desktop-shared/src/main/resources/i18n/messages.properties b/desktop-shared/src/main/resources/i18n/messages.properties index 003f1b88f..54376c131 100644 --- a/desktop-shared/src/main/resources/i18n/messages.properties +++ b/desktop-shared/src/main/resources/i18n/messages.properties @@ -1147,6 +1147,20 @@ settings.models.description=Configure global AI model behavior shared across all settings.models.max.tool.calling.round.trips=Max Tool-Calling Round Trips settings.models.max.tool.calling.round.trips.hint=Maximum number of tool calls the AI can make in a single response (default: 10). Higher values allow more complex agent tasks but increase latency and cost. +# Memory Settings +settings.memory.title=Memory Settings +settings.memory.description=Controls how the AI manages conversation history to stay within the model's context window. +settings.memory.mode=Memory Mode +settings.memory.mode.compact=Compact +settings.memory.mode.compact.hint=Lower token cost, less detail +settings.memory.mode.compact.description=Aggressive summarization. Lower token cost, summarizes early and often. Best for very long sessions or cost-sensitive models. +settings.memory.mode.balanced=Balanced +settings.memory.mode.balanced.hint=Good quality/cost trade-off +settings.memory.mode.balanced.description=Good quality/cost trade-off. Fires summarization at 40% of the memory budget. Suitable for most sessions. +settings.memory.mode.detail=Detail +settings.memory.mode.detail.hint=High fidelity, more tokens +settings.memory.mode.detail.description=Minimal summarization. Keeps more verbatim turns and richer summaries. Best for short sessions or when precise recall matters. + ## Settings - Utility Models settings.utility.models.title=Utility Models settings.utility.models.uses.selected=(uses selected model) diff --git a/desktop-shared/src/main/resources/i18n/messages_de.properties b/desktop-shared/src/main/resources/i18n/messages_de.properties index 02280445c..e3a035491 100644 --- a/desktop-shared/src/main/resources/i18n/messages_de.properties +++ b/desktop-shared/src/main/resources/i18n/messages_de.properties @@ -1137,6 +1137,20 @@ settings.models.description=Konfigurieren Sie das globale KI-Modellverhalten, da settings.models.max.tool.calling.round.trips=Maximale Tool-Aufruf-Runden settings.models.max.tool.calling.round.trips.hint=Maximale Anzahl an Tool-Aufrufen, die die KI in einer einzelnen Antwort durchführen kann (Standard: 10). Höhere Werte ermöglichen komplexere Agenten-Aufgaben, erhöhen jedoch Latenz und Kosten. +# Memory Settings +settings.memory.title=Speichereinstellungen +settings.memory.description=Steuert, wie die KI den Gesprächsverlauf verwaltet, um im Kontextfenster des Modells zu bleiben. +settings.memory.mode=Speichermodus +settings.memory.mode.compact=Kompakt +settings.memory.mode.compact.hint=Geringere Token-Kosten, weniger Details +settings.memory.mode.compact.description=Aggressives Zusammenfassen. Geringe Token-Kosten, häufiges Zusammenfassen. Ideal für sehr lange Sitzungen oder kostensensitive Modelle. +settings.memory.mode.balanced=Ausgewogen +settings.memory.mode.balanced.hint=Gutes Qualitäts-/Kostenverhältnis +settings.memory.mode.balanced.description=Gutes Qualitäts-/Kostenverhältnis. Löst die Zusammenfassung bei 40 % des Speicherbudgets aus. Geeignet für die meisten Sitzungen. +settings.memory.mode.detail=Detailliert +settings.memory.mode.detail.hint=Hohe Wiedergabetreue, mehr Tokens +settings.memory.mode.detail.description=Minimales Zusammenfassen. Behält mehr wörtliche Gesprächsrunden und reichhaltigere Zusammenfassungen bei. Ideal für kurze Sitzungen oder wenn präziser Abruf wichtig ist. + # Settings - Utility Models settings.utility.models.title=Hilfsmodelle settings.utility.models.uses.selected=(verwendet das ausgewählte Modell) diff --git a/desktop-shared/src/main/resources/i18n/messages_es.properties b/desktop-shared/src/main/resources/i18n/messages_es.properties index 83c82ecd8..2e6d77830 100644 --- a/desktop-shared/src/main/resources/i18n/messages_es.properties +++ b/desktop-shared/src/main/resources/i18n/messages_es.properties @@ -1133,6 +1133,20 @@ settings.models.description=Configura el comportamiento global de los modelos de settings.models.max.tool.calling.round.trips=Máximo de rondas de llamadas a herramientas settings.models.max.tool.calling.round.trips.hint=Número máximo de llamadas a herramientas que la IA puede realizar en una sola respuesta (predeterminado: 10). Valores más altos permiten tareas de agente más complejas pero aumentan la latencia y el costo. +# Memory Settings +settings.memory.title=Configuración de memoria +settings.memory.description=Controla cómo la IA gestiona el historial de conversación para mantenerse dentro del contexto del modelo. +settings.memory.mode=Modo de memoria +settings.memory.mode.compact=Compacto +settings.memory.mode.compact.hint=Menor costo de tokens, menos detalle +settings.memory.mode.compact.description=Resumen agresivo. Menor costo de tokens, resume pronto y con frecuencia. Ideal para sesiones muy largas o modelos sensibles al costo. +settings.memory.mode.balanced=Equilibrado +settings.memory.mode.balanced.hint=Buen equilibrio calidad/costo +settings.memory.mode.balanced.description=Buen equilibrio calidad/costo. Activa el resumen al 40% del presupuesto de memoria. Adecuado para la mayoría de sesiones. +settings.memory.mode.detail=Detallado +settings.memory.mode.detail.hint=Alta fidelidad, más tokens +settings.memory.mode.detail.description=Resumen mínimo. Conserva más turnos literales y resúmenes más ricos. Ideal para sesiones cortas o cuando el recuerdo preciso es importante. + # Settings - Utility Models settings.utility.models.title=Modelos de utilidad settings.utility.models.uses.selected=(usa el modelo seleccionado) diff --git a/desktop-shared/src/main/resources/i18n/messages_fr.properties b/desktop-shared/src/main/resources/i18n/messages_fr.properties index bb70bc42d..e0a9f1385 100644 --- a/desktop-shared/src/main/resources/i18n/messages_fr.properties +++ b/desktop-shared/src/main/resources/i18n/messages_fr.properties @@ -1134,6 +1134,20 @@ settings.models.description=Configurez le comportement global du modèle IA part settings.models.max.tool.calling.round.trips=Nombre maximal d'allers-retours d'appel d'outils settings.models.max.tool.calling.round.trips.hint=Nombre maximum d'appels d'outils que l'IA peut effectuer dans une seule réponse (défaut : 10). Des valeurs plus élevées permettent des tâches d'agent plus complexes mais augmentent la latence et le coût. +# Memory Settings +settings.memory.title=Paramètres de mémoire +settings.memory.description=Contrôle la façon dont l'IA gère l'historique des conversations pour rester dans la fenêtre de contexte du modèle. +settings.memory.mode=Mode mémoire +settings.memory.mode.compact=Compact +settings.memory.mode.compact.hint=Coût en tokens réduit, moins de détails +settings.memory.mode.compact.description=Résumé agressif. Faible coût en tokens, résumé fréquent. Idéal pour les très longues sessions ou les modèles sensibles aux coûts. +settings.memory.mode.balanced=Équilibré +settings.memory.mode.balanced.hint=Bon équilibre qualité/coût +settings.memory.mode.balanced.description=Bon équilibre qualité/coût. Déclenche le résumé à 40 % du budget mémoire. Convient à la plupart des sessions. +settings.memory.mode.detail=Détaillé +settings.memory.mode.detail.hint=Haute fidélité, plus de tokens +settings.memory.mode.detail.description=Résumé minimal. Conserve plus de tours verbatim et des résumés plus riches. Idéal pour les sessions courtes ou lorsque le rappel précis est important. + # Settings - Utility Models settings.utility.models.title=Modèles utilitaires settings.utility.models.uses.selected=(utilise le modèle sélectionné) diff --git a/desktop-shared/src/main/resources/i18n/messages_ja_JP.properties b/desktop-shared/src/main/resources/i18n/messages_ja_JP.properties index f5e6a8c0a..f7a50eab5 100644 --- a/desktop-shared/src/main/resources/i18n/messages_ja_JP.properties +++ b/desktop-shared/src/main/resources/i18n/messages_ja_JP.properties @@ -1135,6 +1135,20 @@ settings.models.description=すべてのプロバイダーで共有されるグ settings.models.max.tool.calling.round.trips=最大ツール呼び出しラウンドトリップ数 settings.models.max.tool.calling.round.trips.hint=AI が 1 回の応答で実行できるツール呼び出しの最大回数です(デフォルト: 10)。値を大きくするほど複雑なエージェントタスクが可能になりますが、レイテンシとコストが増加します。 +# Memory Settings +settings.memory.title=メモリ設定 +settings.memory.description=AIがモデルのコンテキストウィンドウ内に収まるよう会話履歴を管理する方法を制御します。 +settings.memory.mode=メモリモード +settings.memory.mode.compact=コンパクト +settings.memory.mode.compact.hint=トークンコストが低く、詳細が少ない +settings.memory.mode.compact.description=積極的な要約。トークンコストが低く、早期かつ頻繁に要約します。非常に長いセッションやコストに敏感なモデルに最適です。 +settings.memory.mode.balanced=バランス +settings.memory.mode.balanced.hint=品質とコストの良好なバランス +settings.memory.mode.balanced.description=品質とコストの良好なバランス。メモリ予算の40%で要約が実行されます。ほとんどのセッションに適しています。 +settings.memory.mode.detail=詳細 +settings.memory.mode.detail.hint=高精度、より多くのトークン +settings.memory.mode.detail.description=最小限の要約。より多くの原文の会話と豊富な要約を保持します。短いセッションや正確な記憶が重要な場合に最適です。 + # Settings - Utility Models settings.utility.models.title=ユーティリティモデル settings.utility.models.uses.selected=(選択されたモデルを使用) diff --git a/desktop-shared/src/main/resources/i18n/messages_ko_KR.properties b/desktop-shared/src/main/resources/i18n/messages_ko_KR.properties index c0667fdb0..23c654edf 100644 --- a/desktop-shared/src/main/resources/i18n/messages_ko_KR.properties +++ b/desktop-shared/src/main/resources/i18n/messages_ko_KR.properties @@ -1134,6 +1134,20 @@ settings.models.description=모든 공급자에서 공유되는 전역 AI 모델 settings.models.max.tool.calling.round.trips=최대 도구 호출 라운드 트립 settings.models.max.tool.calling.round.trips.hint=AI가 단일 응답에서 수행할 수 있는 최대 도구 호출 횟수입니다 (기본값: 10). 값이 높을수록 복잡한 에이전트 작업이 가능하지만 지연 시간과 비용이 증가합니다. +# Memory Settings +settings.memory.title=메모리 설정 +settings.memory.description=AI가 모델의 컨텍스트 창 안에서 대화 기록을 관리하는 방식을 제어합니다. +settings.memory.mode=메모리 모드 +settings.memory.mode.compact=간결 +settings.memory.mode.compact.hint=낮은 토큰 비용, 적은 상세 정보 +settings.memory.mode.compact.description=적극적 요약. 토큰 비용이 낮고 자주 요약합니다. 매우 긴 세션이나 비용에 민감한 모델에 적합합니다. +settings.memory.mode.balanced=균형 +settings.memory.mode.balanced.hint=품질과 비용의 적절한 균형 +settings.memory.mode.balanced.description=품질과 비용의 적절한 균형. 메모리 예산의 40%에서 요약이 시작됩니다. 대부분의 세션에 적합합니다. +settings.memory.mode.detail=상세 +settings.memory.mode.detail.hint=높은 정확도, 더 많은 토큰 +settings.memory.mode.detail.description=최소 요약. 더 많은 원문 대화와 풍부한 요약을 유지합니다. 짧은 세션이나 정확한 기억이 중요할 때 적합합니다. + # Settings - Utility Models settings.utility.models.title=유틸리티 모델 settings.utility.models.uses.selected=(선택된 모델 사용) diff --git a/desktop-shared/src/main/resources/i18n/messages_pt_BR.properties b/desktop-shared/src/main/resources/i18n/messages_pt_BR.properties index 679dbede1..533937645 100644 --- a/desktop-shared/src/main/resources/i18n/messages_pt_BR.properties +++ b/desktop-shared/src/main/resources/i18n/messages_pt_BR.properties @@ -1137,6 +1137,20 @@ settings.models.description=Configure o comportamento global do modelo de IA com settings.models.max.tool.calling.round.trips=Máximo de rodadas de chamadas de ferramentas settings.models.max.tool.calling.round.trips.hint=Número máximo de chamadas de ferramentas que a IA pode fazer em uma única resposta (padrão: 10). Valores mais altos permitem tarefas de agente mais complexas, mas aumentam a latência e o custo. +# Memory Settings +settings.memory.title=Configurações de memória +settings.memory.description=Controla como a IA gerencia o histórico de conversas para se manter dentro da janela de contexto do modelo. +settings.memory.mode=Modo de memória +settings.memory.mode.compact=Compacto +settings.memory.mode.compact.hint=Menor custo de tokens, menos detalhes +settings.memory.mode.compact.description=Resumo agressivo. Menor custo de tokens, resume cedo e com frequência. Ideal para sessões muito longas ou modelos sensíveis ao custo. +settings.memory.mode.balanced=Equilibrado +settings.memory.mode.balanced.hint=Bom equilíbrio qualidade/custo +settings.memory.mode.balanced.description=Bom equilíbrio qualidade/custo. Aciona o resumo a 40% do orçamento de memória. Adequado para a maioria das sessões. +settings.memory.mode.detail=Detalhado +settings.memory.mode.detail.hint=Alta fidelidade, mais tokens +settings.memory.mode.detail.description=Resumo mínimo. Mantém mais turnos verbatim e resumos mais ricos. Ideal para sessões curtas ou quando o recall preciso é importante. + # Settings - Utility Models settings.utility.models.title=Modelos utilitários settings.utility.models.uses.selected=(usa o modelo selecionado) diff --git a/desktop-shared/src/main/resources/i18n/messages_vi_VN.properties b/desktop-shared/src/main/resources/i18n/messages_vi_VN.properties index 58fb69c13..9191a7478 100644 --- a/desktop-shared/src/main/resources/i18n/messages_vi_VN.properties +++ b/desktop-shared/src/main/resources/i18n/messages_vi_VN.properties @@ -1133,6 +1133,20 @@ settings.models.description=Cấu hình hành vi mô hình AI toàn cục đư settings.models.max.tool.calling.round.trips=Số lượt gọi công cụ tối đa settings.models.max.tool.calling.round.trips.hint=Số lần gọi công cụ tối đa mà AI có thể thực hiện trong một phản hồi (mặc định: 10). Giá trị cao hơn cho phép các tác vụ agent phức tạp hơn nhưng tăng độ trễ và chi phí. +# Memory Settings +settings.memory.title=Cài đặt bộ nhớ +settings.memory.description=Kiểm soát cách AI quản lý lịch sử hội thoại để duy trì trong cửa sổ ngữ cảnh của mô hình. +settings.memory.mode=Chế độ bộ nhớ +settings.memory.mode.compact=Gọn nhẹ +settings.memory.mode.compact.hint=Chi phí token thấp, ít chi tiết hơn +settings.memory.mode.compact.description=Tóm tắt tích cực. Chi phí token thấp, tóm tắt sớm và thường xuyên. Phù hợp cho các phiên rất dài hoặc mô hình nhạy cảm với chi phí. +settings.memory.mode.balanced=Cân bằng +settings.memory.mode.balanced.hint=Cân bằng tốt giữa chất lượng và chi phí +settings.memory.mode.balanced.description=Cân bằng tốt giữa chất lượng và chi phí. Kích hoạt tóm tắt ở 40% ngân sách bộ nhớ. Phù hợp với hầu hết các phiên. +settings.memory.mode.detail=Chi tiết +settings.memory.mode.detail.hint=Độ trung thực cao, nhiều token hơn +settings.memory.mode.detail.description=Tóm tắt tối thiểu. Giữ lại nhiều lượt hội thoại nguyên văn và tóm tắt phong phú hơn. Phù hợp cho các phiên ngắn hoặc khi cần nhớ chính xác. + # Settings - Utility Models settings.utility.models.title=Mô hình tiện ích settings.utility.models.uses.selected=(sử dụng mô hình đã chọn) diff --git a/desktop-shared/src/main/resources/i18n/messages_zh_CN.properties b/desktop-shared/src/main/resources/i18n/messages_zh_CN.properties index f27b6bc66..eb4a7f1c1 100644 --- a/desktop-shared/src/main/resources/i18n/messages_zh_CN.properties +++ b/desktop-shared/src/main/resources/i18n/messages_zh_CN.properties @@ -1135,6 +1135,20 @@ settings.models.description=配置所有提供商共享的全局 AI 模型行为 settings.models.max.tool.calling.round.trips=最大工具调用轮次 settings.models.max.tool.calling.round.trips.hint=AI 在单次响应中可以进行的最大工具调用次数(默认:10)。值越高允许执行越复杂的智能体任务,但会增加延迟和成本。 +# Memory Settings +settings.memory.title=记忆设置 +settings.memory.description=控制 AI 如何管理对话历史以保持在模型上下文窗口范围内。 +settings.memory.mode=记忆模式 +settings.memory.mode.compact=紧凑 +settings.memory.mode.compact.hint=较低 token 消耗,细节较少 +settings.memory.mode.compact.description=积极摘要。token 消耗低,频繁摘要。适合超长会话或对成本敏感的模型。 +settings.memory.mode.balanced=均衡 +settings.memory.mode.balanced.hint=质量与成本的良好平衡 +settings.memory.mode.balanced.description=质量与成本的良好平衡。在记忆预算的 40% 时触发摘要。适合大多数会话。 +settings.memory.mode.detail=详细 +settings.memory.mode.detail.hint=高保真,更多 token +settings.memory.mode.detail.description=最少摘要。保留更多原文对话和更丰富的摘要。适合短会话或需要精确记忆的场景。 + # Settings - Utility Models settings.utility.models.title=实用模型 settings.utility.models.uses.selected=(使用所选模型) diff --git a/desktop-shared/src/main/resources/i18n/messages_zh_TW.properties b/desktop-shared/src/main/resources/i18n/messages_zh_TW.properties index 44eae833d..a33aecce8 100644 --- a/desktop-shared/src/main/resources/i18n/messages_zh_TW.properties +++ b/desktop-shared/src/main/resources/i18n/messages_zh_TW.properties @@ -1136,6 +1136,20 @@ settings.models.description=設定所有提供商共享的全域 AI 模型行為 settings.models.max.tool.calling.round.trips=最大工具呼叫來回次數 settings.models.max.tool.calling.round.trips.hint=AI 在單次回應中可執行的最大工具呼叫次數(預設:10)。值越高可執行越複雜的智能體任務,但會增加延遲與成本。 +# Memory Settings +settings.memory.title=記憶設定 +settings.memory.description=控制 AI 如何管理對話歷史以保持在模型上下文視窗範圍內。 +settings.memory.mode=記憶模式 +settings.memory.mode.compact=精簡 +settings.memory.mode.compact.hint=較低 token 消耗,細節較少 +settings.memory.mode.compact.description=積極摘要。token 消耗低,頻繁摘要。適合超長會話或對成本敏感的模型。 +settings.memory.mode.balanced=均衡 +settings.memory.mode.balanced.hint=品質與成本的良好平衡 +settings.memory.mode.balanced.description=品質與成本的良好平衡。在記憶預算的 40% 時觸發摘要。適合大多數會話。 +settings.memory.mode.detail=詳細 +settings.memory.mode.detail.hint=高保真,更多 token +settings.memory.mode.detail.description=最少摘要。保留更多原文對話和更豐富的摘要。適合短會話或需要精確記憶的場景。 + # Settings - Utility Models settings.utility.models.title=工具模型 settings.utility.models.uses.selected=(使用所選模型) diff --git a/desktop/src/main/kotlin/io/askimo/desktop/settings/AdvancedSettingsSection.kt b/desktop/src/main/kotlin/io/askimo/desktop/settings/AdvancedSettingsSection.kt index c053474ec..a4eaa9f0d 100644 --- a/desktop/src/main/kotlin/io/askimo/desktop/settings/AdvancedSettingsSection.kt +++ b/desktop/src/main/kotlin/io/askimo/desktop/settings/AdvancedSettingsSection.kt @@ -51,6 +51,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import io.askimo.core.analytics.Analytics import io.askimo.core.config.AppConfig +import io.askimo.core.config.MemoryMode import io.askimo.core.i18n.LocalizationManager import io.askimo.core.logging.LogLevel import io.askimo.core.logging.LoggingService @@ -119,6 +120,9 @@ fun advancedSettingsSection() { // Models Configuration Section modelsConfigurationSection() + // Memory Configuration Section + memoryConfigurationSection() + // Analytics Section analyticsSection() @@ -665,6 +669,113 @@ private fun modelsConfigurationSection() { } } +@Composable +private fun memoryConfigurationSection() { + var currentMode by remember { mutableStateOf(AppConfig.memory.mode) } + var dropdownExpanded by remember { mutableStateOf(false) } + + Card( + modifier = Modifier.fillMaxWidth(), + colors = AppComponents.bannerCardColors(), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(Spacing.large), + verticalArrangement = Arrangement.spacedBy(Spacing.large), + ) { + Text( + text = stringResource("settings.memory.title"), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + + Text( + text = stringResource("settings.memory.description"), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.7f), + ) + + // Mode selector — label + dropdown (same pattern as web search backend) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource("settings.memory.mode"), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.weight(1f).padding(end = Spacing.large), + ) + + Box(modifier = Modifier.widthIn(min = 160.dp, max = 240.dp)) { + Card( + modifier = Modifier + .fillMaxWidth() + .clickableCard { dropdownExpanded = true }, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource("settings.memory.mode.${currentMode.name.lowercase()}"), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f).padding(end = Spacing.small), + ) + Icon( + Icons.Default.Edit, + contentDescription = "Change memory mode", + tint = MaterialTheme.colorScheme.onSurface, + ) + } + } + + AppComponents.dropdownMenu( + expanded = dropdownExpanded, + onDismissRequest = { dropdownExpanded = false }, + ) { + MemoryMode.entries.forEachIndexed { index, mode -> + AppComponents.themedDropdownMenuItem( + text = { + Column(verticalArrangement = Arrangement.spacedBy(Spacing.extraSmall)) { + Text( + text = stringResource("settings.memory.mode.${mode.name.lowercase()}"), + style = MaterialTheme.typography.bodyMedium, + ) + Text( + text = stringResource("settings.memory.mode.${mode.name.lowercase()}.description"), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + onClick = { + currentMode = mode + AppConfig.updateField("memory.mode", mode) + dropdownExpanded = false + }, + isSelected = mode == currentMode, + showDivider = index < MemoryMode.entries.lastIndex, + ) + } + } + } + } + } + } +} + @Composable private fun ragBooleanField( label: String, diff --git a/shared/src/main/kotlin/io/askimo/core/chat/service/ChatSessionService.kt b/shared/src/main/kotlin/io/askimo/core/chat/service/ChatSessionService.kt index e34cc46e8..aad8f046d 100644 --- a/shared/src/main/kotlin/io/askimo/core/chat/service/ChatSessionService.kt +++ b/shared/src/main/kotlin/io/askimo/core/chat/service/ChatSessionService.kt @@ -198,7 +198,6 @@ class ChatSessionService( sessionId = sessionId, sessionMemoryRepository = sessionMemoryRepository, userMemoryRepository = DatabaseManager.getInstance().getUserMemoryRepository(), - asyncSummarization = true, summarizationTimeoutSeconds = AppConfig.chat.summarizationTimeoutSeconds, ) } diff --git a/shared/src/main/kotlin/io/askimo/core/config/AppConfig.kt b/shared/src/main/kotlin/io/askimo/core/config/AppConfig.kt index 69391edc8..879703761 100644 --- a/shared/src/main/kotlin/io/askimo/core/config/AppConfig.kt +++ b/shared/src/main/kotlin/io/askimo/core/config/AppConfig.kt @@ -242,12 +242,129 @@ data class ProxyConfig( data class ChatConfig( val maxTokens: Int = 8000, - val summarizationThreshold: Double = 0.75, - val enableAsyncSummarization: Boolean = true, val summarizationTimeoutSeconds: Long = 300, val defaultResponseAILocale: String? = null, ) +/** + * Memory quality/cost preset modes. + * + * - [COMPACT] – Aggressive summarization. Fires early, prunes hard, keeps small summaries. + * Best for long sessions or cost-sensitive models. + * - [BALANCED] – Current defaults. Good quality/cost trade-off for general use. + * - [DETAIL] – Minimal summarization. Keeps more verbatim turns and richer summaries. + * Best for short sessions, coding, or precise recall tasks. + */ +enum class MemoryMode { COMPACT, BALANCED, DETAIL } + +/** + * Memory configuration. All fields are required and non-null. + * Select a [mode] to load the full preset via [MemoryConfig.preset]; the mode field is + * kept alongside the values so it can be persisted and displayed in settings. + * + * Preset values by mode: + * ``` + * Field | COMPACT | BALANCED | DETAIL + * ---------------------------|---------|----------|------- + * summarizationThreshold | 0.25 | 0.40 | 0.60 + * protectedRecentTurns | 3 | 6 | 10 + * summarizationPruneFraction | 0.80 | 0.65 | 0.50 + * maxKeyFacts | 15 | 30 | 50 + * maxMainTopics | 8 | 15 | 25 + * maxSummaryLength (chars) | 1000 | 2000 | 4000 + * memoryBudgetFraction | 0.30 | 0.40 | 0.50 + * ``` + * + * Preset trade-offs: + * ``` + * Mode | Token cost | Context quality | Best for + * ---------|------------|-----------------|--------------------------- + * COMPACT | Low | Less detail | Long sessions, cheap models + * BALANCED | Medium | Medium | General use (default) + * DETAIL | High | High fidelity | Short sessions, coding + * ``` + */ +data class MemoryConfig( + val mode: MemoryMode = MemoryMode.BALANCED, + /** + * Fraction of [memoryBudgetFraction] × context-window tokens at which summarization + * is triggered. Lower values fire earlier. Range: 0.0–1.0. + */ + val summarizationThreshold: Double = 0.40, + /** + * Number of the most-recent conversation messages always kept verbatim and + * never included in a summarization batch. + */ + val protectedRecentTurns: Int = 6, + /** + * Fraction of eligible (non-protected) messages pruned in each summarization cycle, + * oldest first. Range: 0.0–1.0. + */ + val summarizationPruneFraction: Double = 0.65, + /** + * Maximum number of distinct key facts retained in the structured conversation summary. + */ + val maxKeyFacts: Int = 30, + /** + * Maximum number of distinct topic labels tracked across summary merges. + */ + val maxMainTopics: Int = 15, + /** + * Character cap for the fallback extractive summary when AI summarization is unavailable. + */ + val maxSummaryLength: Int = 2000, + /** + * Fraction of the model's total context window reserved for conversation history. + * Range: 0.0–1.0. + */ + val memoryBudgetFraction: Double = 0.40, +) { + companion object { + /** Returns the full preset [MemoryConfig] for the given [mode]. */ + fun preset(mode: MemoryMode): MemoryConfig = when (mode) { + MemoryMode.COMPACT -> COMPACT + MemoryMode.BALANCED -> BALANCED + MemoryMode.DETAIL -> DETAIL + } + + /** Aggressive summarization — lower token cost, less detail. */ + val COMPACT = MemoryConfig( + mode = MemoryMode.COMPACT, + summarizationThreshold = 0.25, + protectedRecentTurns = 3, + summarizationPruneFraction = 0.80, + maxKeyFacts = 15, + maxMainTopics = 8, + maxSummaryLength = 1000, + memoryBudgetFraction = 0.30, + ) + + /** Current defaults — good quality/cost trade-off. */ + val BALANCED = MemoryConfig( + mode = MemoryMode.BALANCED, + summarizationThreshold = 0.40, + protectedRecentTurns = 6, + summarizationPruneFraction = 0.65, + maxKeyFacts = 30, + maxMainTopics = 15, + maxSummaryLength = 2000, + memoryBudgetFraction = 0.40, + ) + + /** Minimal summarization — high fidelity, more tokens consumed. */ + val DETAIL = MemoryConfig( + mode = MemoryMode.DETAIL, + summarizationThreshold = 0.60, + protectedRecentTurns = 10, + summarizationPruneFraction = 0.50, + maxKeyFacts = 50, + maxMainTopics = 25, + maxSummaryLength = 4000, + memoryBudgetFraction = 0.50, + ) + } +} + /** * RAG (Retrieval-Augmented Generation) configuration. * Controls how relevant documents are retrieved from the knowledge base. @@ -398,6 +515,7 @@ data class AppConfigData( val indexing: IndexingConfig = IndexingConfig(), val developer: DeveloperConfig = DeveloperConfig(), val chat: ChatConfig = ChatConfig(), + val memory: MemoryConfig = MemoryConfig(), val rag: RagConfig = RagConfig(), val models: ModelsConfig = ModelsConfig(), val proxy: ProxyConfig = ProxyConfig(), @@ -413,6 +531,7 @@ object AppConfig { val indexing: IndexingConfig get() = delegate.indexing val developer: DeveloperConfig get() = delegate.developer val chat: ChatConfig get() = delegate.chat + val memory: MemoryConfig get() = delegate.memory val rag: RagConfig get() = delegate.rag val models: ModelsConfig get() = delegate.models val context: AppContextParams get() = delegate.context @@ -570,11 +689,19 @@ object AppConfig { chat: max_tokens: ${'$'}{ASKIMO_CHAT_MAX_TOKENS:8000} - summarization_threshold: ${'$'}{ASKIMO_CHAT_SUMMARIZATION_THRESHOLD:0.75} summarization_timeout_seconds: ${'$'}{ASKIMO_CHAT_SUMMARIZATION_TIMEOUT:60} - enable_async_summarization: ${'$'}{ASKIMO_CHAT_ENABLE_ASYNC_SUMMARIZATION:true} default_response_ai_locale: ${'$'}{ASKIMO_CHAT_DEFAULT_RESPONSE_LOCALE:} + memory: + mode: ${'$'}{ASKIMO_MEMORY_MODE:BALANCED} + summarization_threshold: ${'$'}{ASKIMO_MEMORY_SUMMARIZATION_THRESHOLD:0.40} + protected_recent_turns: ${'$'}{ASKIMO_MEMORY_PROTECTED_RECENT_TURNS:6} + summarization_prune_fraction: ${'$'}{ASKIMO_MEMORY_SUMMARIZATION_PRUNE_FRACTION:0.65} + max_key_facts: ${'$'}{ASKIMO_MEMORY_MAX_KEY_FACTS:30} + max_main_topics: ${'$'}{ASKIMO_MEMORY_MAX_MAIN_TOPICS:15} + max_summary_length: ${'$'}{ASKIMO_MEMORY_MAX_SUMMARY_LENGTH:2000} + memory_budget_fraction: ${'$'}{ASKIMO_MEMORY_BUDGET_FRACTION:0.40} + rag: vector_search_max_results: ${'$'}{ASKIMO_RAG_VECTOR_SEARCH_MAX_RESULTS:20} vector_search_min_score: ${'$'}{ASKIMO_RAG_VECTOR_SEARCH_MIN_SCORE:0.3} @@ -688,7 +815,8 @@ object AppConfig { } val interpolated = interpolateEnv(migrated) try { - mapper.readValue(interpolated) + val loaded = mapper.readValue(interpolated) + loaded.copy(memory = normalizeMemoryConfig(loaded.memory)) } catch (e: Exception) { log.displayError("Config parse failed at $path ", e) envFallback() @@ -724,8 +852,6 @@ object AppConfig { "projectTypes:" to "project_types:", "excludePaths:" to "exclude_paths:", "maxTokens:" to "max_tokens:", - "summarizationThreshold:" to "summarization_threshold:", - "enableAsyncSummarization:" to "enable_async_summarization:", "summarizationTimeoutSeconds:" to "summarization_timeout_seconds:", "defaultResponseAILocale:" to "default_response_ai_locale:", "vectorSearchMaxResults:" to "vector_search_max_results:", @@ -817,8 +943,6 @@ object AppConfig { fun envList(k: String, def: String): Set = System.getenv(k)?.split(",")?.map { it.trim() }?.toSet() ?: def.split(",").map { it.trim() }.toSet() - fun envNullableInt(k: String) = System.getenv(k)?.toIntOrNull() - val emb = EmbeddingConfig( maxCharsPerChunk = envInt("ASKIMO_EMBED_MAX_CHARS_PER_CHUNK", 4000), @@ -861,9 +985,7 @@ object AppConfig { val chat = ChatConfig( maxTokens = envInt("ASKIMO_CHAT_MAX_TOKENS", 8000), - summarizationThreshold = envDouble("ASKIMO_CHAT_SUMMARIZATION_THRESHOLD", 0.75), summarizationTimeoutSeconds = envLong("ASKIMO_CHAT_SUMMARIZATION_TIMEOUT", 300L), - enableAsyncSummarization = System.getenv("ASKIMO_CHAT_ENABLE_ASYNC_SUMMARIZATION")?.toBoolean() ?: true, defaultResponseAILocale = System.getenv("ASKIMO_CHAT_DEFAULT_RESPONSE_LOCALE")?.takeIf { it.isNotBlank() }, ) @@ -956,7 +1078,21 @@ object AppConfig { enabled = System.getenv("ASKIMO_WEB_SEARCH_ENABLED")?.toBoolean() ?: true, ) - return AppConfigData(emb, r, t, idx, dev, chat, rag, models, proxy, webSearch = webSearch) + val memoryMode = System.getenv("ASKIMO_MEMORY_MODE") + ?.let { runCatching { MemoryMode.valueOf(it) }.getOrNull() } + ?: MemoryMode.BALANCED + val memoryBase = MemoryConfig.preset(memoryMode) + val memory = memoryBase.copy( + summarizationThreshold = System.getenv("ASKIMO_MEMORY_SUMMARIZATION_THRESHOLD")?.toDoubleOrNull() ?: memoryBase.summarizationThreshold, + protectedRecentTurns = System.getenv("ASKIMO_MEMORY_PROTECTED_RECENT_TURNS")?.toIntOrNull() ?: memoryBase.protectedRecentTurns, + summarizationPruneFraction = System.getenv("ASKIMO_MEMORY_SUMMARIZATION_PRUNE_FRACTION")?.toDoubleOrNull() ?: memoryBase.summarizationPruneFraction, + maxKeyFacts = System.getenv("ASKIMO_MEMORY_MAX_KEY_FACTS")?.toIntOrNull() ?: memoryBase.maxKeyFacts, + maxMainTopics = System.getenv("ASKIMO_MEMORY_MAX_MAIN_TOPICS")?.toIntOrNull() ?: memoryBase.maxMainTopics, + maxSummaryLength = System.getenv("ASKIMO_MEMORY_MAX_SUMMARY_LENGTH")?.toIntOrNull() ?: memoryBase.maxSummaryLength, + memoryBudgetFraction = System.getenv("ASKIMO_MEMORY_BUDGET_FRACTION")?.toDoubleOrNull() ?: memoryBase.memoryBudgetFraction, + ) + + return AppConfigData(emb, r, t, idx, dev, chat, memory = memory, rag = rag, models = models, proxy = proxy, webSearch = webSearch) } /** @@ -1061,6 +1197,8 @@ object AppConfig { "chat" -> current.copy(chat = updateChatField(current.chat, field, value)) + "memory" -> current.copy(memory = updateMemoryField(current.memory, field, value)) + "rag" -> current.copy(rag = updateRagField(current.rag, field, value)) "models" -> current.copy(models = updateModelsField(current.models, field, value)) @@ -1093,6 +1231,31 @@ object AppConfig { } } + /** + * After YAML deserialization, checks whether [MemoryConfig] fields are consistent with + * the selected [MemoryMode]. If a non-BALANCED mode is selected but all numeric fields + * still hold the BALANCED defaults (e.g. the YAML had `null` values that Jackson defaulted), + * the config is auto-resolved to the correct mode preset and a warning is logged. + */ + private fun normalizeMemoryConfig(memory: MemoryConfig): MemoryConfig { + if (memory.mode == MemoryMode.BALANCED) return memory + val balanced = MemoryConfig.BALANCED + val looksDefaulted = memory.summarizationThreshold == balanced.summarizationThreshold && + memory.protectedRecentTurns == balanced.protectedRecentTurns && + memory.summarizationPruneFraction == balanced.summarizationPruneFraction && + memory.maxKeyFacts == balanced.maxKeyFacts && + memory.maxMainTopics == balanced.maxMainTopics && + memory.maxSummaryLength == balanced.maxSummaryLength && + memory.memoryBudgetFraction == balanced.memoryBudgetFraction + return if (looksDefaulted) { + val resolved = MemoryConfig.preset(memory.mode) + log.warn("Memory config fields appear unset (defaulted to BALANCED) but mode=${memory.mode}. Auto-resolving to ${memory.mode} preset.") + resolved + } else { + memory + } + } + private fun updateAnalyticsField(config: AnalyticsConfig, field: String, value: Any): AnalyticsConfig = when (field) { "opted_in" -> config.copy(optedIn = value as Boolean) "endpoint" -> config.copy(endpoint = value as String) @@ -1125,10 +1288,6 @@ object AppConfig { private fun updateChatField(config: ChatConfig, field: String, value: Any): ChatConfig = when (field) { "maxTokens" -> config.copy(maxTokens = value as Int) - "summarizationThreshold" -> config.copy(summarizationThreshold = (value as Number).toDouble()) - - "enableAsyncSummarization" -> config.copy(enableAsyncSummarization = value as Boolean) - "defaultResponseAILocale" -> { val newLocale = if (value is String && value.isBlank()) null else value as? String EventBus.post( @@ -1140,6 +1299,34 @@ object AppConfig { else -> config } + private fun updateMemoryField(config: MemoryConfig, field: String, value: Any): MemoryConfig = when (field) { + "mode" -> { + val newMode = when (value) { + is MemoryMode -> value + else -> runCatching { MemoryMode.valueOf(value.toString()) }.getOrElse { config.mode } + } + // Swapping mode replaces the entire config with the mode's preset. + // Individual fields can still be overridden afterwards via separate updateField calls. + MemoryConfig.preset(newMode) + } + + "summarizationThreshold" -> config.copy(summarizationThreshold = (value as Number).toDouble()) + + "protectedRecentTurns" -> config.copy(protectedRecentTurns = (value as Number).toInt()) + + "summarizationPruneFraction" -> config.copy(summarizationPruneFraction = (value as Number).toDouble()) + + "maxKeyFacts" -> config.copy(maxKeyFacts = (value as Number).toInt()) + + "maxMainTopics" -> config.copy(maxMainTopics = (value as Number).toInt()) + + "maxSummaryLength" -> config.copy(maxSummaryLength = (value as Number).toInt()) + + "memoryBudgetFraction" -> config.copy(memoryBudgetFraction = (value as Number).toDouble()) + + else -> config + } + private fun updateRagField(config: RagConfig, field: String, value: Any): RagConfig = when (field) { "vectorSearchMaxResults" -> config.copy(vectorSearchMaxResults = value as Int) "vectorSearchMinScore" -> config.copy(vectorSearchMinScore = (value as Number).toDouble()) diff --git a/shared/src/main/kotlin/io/askimo/core/memory/TokenAwareSummarizingMemory.kt b/shared/src/main/kotlin/io/askimo/core/memory/TokenAwareSummarizingMemory.kt index 3e918beb9..72085d68c 100644 --- a/shared/src/main/kotlin/io/askimo/core/memory/TokenAwareSummarizingMemory.kt +++ b/shared/src/main/kotlin/io/askimo/core/memory/TokenAwareSummarizingMemory.kt @@ -13,6 +13,7 @@ import dev.langchain4j.memory.ChatMemory import io.askimo.core.chat.domain.SessionMemory import io.askimo.core.chat.repository.SessionMemoryRepository import io.askimo.core.chat.repository.UserMemoryRepository +import io.askimo.core.config.AppConfig import io.askimo.core.context.AppContext import io.askimo.core.context.MessageRole import io.askimo.core.logging.logger @@ -111,9 +112,9 @@ data class UserMemorySummary( * * ## What gets summarized * - * The last [PROTECTED_RECENT_TURNS] messages are always kept verbatim so the AI + * The last `protectedRecentTurns` messages are always kept verbatim so the AI * always sees full-fidelity context for the most recent exchanges. Of the - * remaining candidates, [SUMMARIZATION_PRUNE_FRACTION] (65 %) are compressed + * remaining candidates, `summarizationPruneFraction` are compressed * and pruned oldest-first each cycle, creating generous breathing room before * the next trigger fires. * @@ -139,8 +140,7 @@ data class UserMemorySummary( * @param sessionId The session ID this memory belongs to (required for persistence) * @param sessionMemoryRepository Repository for persisting memory state (required) * @param tokenEstimator Function to estimate token count for a message (default: words × 1.75) - * @param summarizationThreshold Fraction (0.0–1.0) of maxTokens at which summarization fires (default 0.4) - * @param asyncSummarization Whether to run summarization asynchronously (default true) + * @param asyncSummarization Whether to run summarization asynchronously (default true; set false in tests only) * @param summarizationTimeoutSeconds Timeout for summarization operations in seconds (default 300) */ class TokenAwareSummarizingMemory( @@ -149,7 +149,6 @@ class TokenAwareSummarizingMemory( private val sessionMemoryRepository: SessionMemoryRepository, private val userMemoryRepository: UserMemoryRepository? = null, private val tokenEstimator: (ChatMessage) -> Int = defaultTokenEstimator(), - private val summarizationThreshold: Double = 0.4, asyncSummarization: Boolean = true, private val summarizationTimeoutSeconds: Long = 300, ) : ChatMemory, @@ -187,9 +186,8 @@ class TokenAwareSummarizingMemory( * reflects the latest context size from ModelCapabilitiesCache (which may be updated * as the system learns the actual model capabilities). * - * Uses 40% of the model's context window, leaving room for: - * - Current conversation in request (~40%) - * - AI response (20% reserved in ChatRequestTransformers) + * Uses `AppConfig.memory.memoryBudgetFraction` of the model's context window, + * leaving room for the current request and AI response. */ private val maxTokens: Int get() = calculateMaxTokensFromCurrentModel() @@ -200,11 +198,13 @@ class TokenAwareSummarizingMemory( val modelKey = ModelCapabilitiesCache.modelKey(provider, model) val contextSize = ModelCapabilitiesCache.get(modelKey).contextSize - val memoryAllocation = (contextSize * 0.4).toInt() + val budgetFraction = AppConfig.memory.memoryBudgetFraction + val memoryAllocation = (contextSize * budgetFraction).toInt() log.debug( - "Calculated maxTokens for memory: {} (40% of {} tokens from {})", + "Calculated maxTokens for memory: {} ({}% of {} tokens from {})", memoryAllocation, + (budgetFraction * 100).toInt(), contextSize, modelKey, ) @@ -232,7 +232,7 @@ class TokenAwareSummarizingMemory( persistToDatabase() val totalTokens = estimateTotalTokens() - val threshold = (maxTokens * summarizationThreshold).toInt() + val threshold = (maxTokens * AppConfig.memory.summarizationThreshold).toInt() log.debug("Current tokens: $totalTokens, Threshold: $threshold, Max: $maxTokens") @@ -306,7 +306,7 @@ class TokenAwareSummarizingMemory( ) val totalTokens = estimateTotalTokens() - val threshold = (maxTokens * summarizationThreshold).toInt() + val threshold = (maxTokens * AppConfig.memory.summarizationThreshold).toInt() log.debug( "After batch load - Total tokens: {}, Threshold: {}, Max: {}", @@ -398,8 +398,8 @@ class TokenAwareSummarizingMemory( messages.filterNot { it.type() == ChatMessageType.SYSTEM } } if (conversationMessages.isNotEmpty()) { - val summarizableCandidates = (conversationMessages.size - PROTECTED_RECENT_TURNS).coerceAtLeast(0) - val messagesToSummarizeCount = (summarizableCandidates * SUMMARIZATION_PRUNE_FRACTION).toInt().coerceAtLeast(if (summarizableCandidates > 0) 1 else conversationMessages.size) + val summarizableCandidates = (conversationMessages.size - AppConfig.memory.protectedRecentTurns).coerceAtLeast(0) + val messagesToSummarizeCount = (summarizableCandidates * AppConfig.memory.summarizationPruneFraction).toInt().coerceAtLeast(if (summarizableCandidates > 0) 1 else conversationMessages.size) val messagesToSummarize = conversationMessages.take(messagesToSummarizeCount) generateBasicSummary(messagesToSummarize) pruneMessages(messagesToSummarize) @@ -435,23 +435,23 @@ class TokenAwareSummarizingMemory( if (conversationMessages.isEmpty()) return - // Always protect the most recent turns verbatim. Summarize 65% of the + // Always protect the most recent turns verbatim. Summarize the configured fraction of the // remaining candidates (oldest first) so each cycle creates significant // breathing room before the next trigger fires. - val summarizableCandidates = (conversationMessages.size - PROTECTED_RECENT_TURNS).coerceAtLeast(0) - val messagesToSummarizeCount = (summarizableCandidates * SUMMARIZATION_PRUNE_FRACTION).toInt().coerceAtLeast( + val summarizableCandidates = (conversationMessages.size - AppConfig.memory.protectedRecentTurns).coerceAtLeast(0) + val messagesToSummarizeCount = (summarizableCandidates * AppConfig.memory.summarizationPruneFraction).toInt().coerceAtLeast( if (summarizableCandidates > 0) 1 else 0, ) if (messagesToSummarizeCount == 0) { - log.debug("Not enough non-protected messages to summarize yet (total: ${conversationMessages.size}, protected: $PROTECTED_RECENT_TURNS)") + log.debug("Not enough non-protected messages to summarize yet (total: ${conversationMessages.size}, protected: ${AppConfig.memory.protectedRecentTurns})") return } // Copy messages to avoid holding lock during AI call val messagesToSummarize = conversationMessages.take(messagesToSummarizeCount) - log.info("Summarizing $messagesToSummarizeCount out of ${conversationMessages.size} conversation messages (protected last $PROTECTED_RECENT_TURNS)") + log.info("Summarizing $messagesToSummarizeCount out of ${conversationMessages.size} conversation messages (protected last ${AppConfig.memory.protectedRecentTurns})") try { generateStructuredSummary(messagesToSummarize) @@ -515,20 +515,15 @@ class TokenAwareSummarizingMemory( private fun mergeWithExistingSummary(newSummary: SessionConversationSummary): SessionConversationSummary { val existing = structuredSummary ?: return newSummary - // Merge keyFacts using LRU-by-relevance: existing facts come first, then new - // facts are re-inserted at the end (removing the old entry first so that - // updating an existing key refreshes its "recency" position). When the total - // exceeds MAX_KEY_FACTS the oldest entries at the front are dropped — these are - // facts that have not appeared in any recent summarization cycle and are - // therefore the least likely to still be relevant. + // Merge keyFacts using LRU-by-relevance val mergedFacts = LinkedHashMap(existing.keyFacts) newSummary.keyFacts.forEach { (k, v) -> - mergedFacts.remove(k) // reset insertion order so updated keys move to back + mergedFacts.remove(k) mergedFacts[k] = v } - val cappedFacts = if (mergedFacts.size > MAX_KEY_FACTS) { + val cappedFacts = if (mergedFacts.size > AppConfig.memory.maxKeyFacts) { mergedFacts.entries - .drop(mergedFacts.size - MAX_KEY_FACTS) + .drop(mergedFacts.size - AppConfig.memory.maxKeyFacts) .associate { it.toPair() } } else { mergedFacts.toMap() @@ -537,7 +532,7 @@ class TokenAwareSummarizingMemory( // Cap mainTopics — takeLast keeps the most recently observed topics. val mergedTopics = (existing.mainTopics + newSummary.mainTopics) .distinct() - .takeLast(MAX_MAIN_TOPICS) + .takeLast(AppConfig.memory.maxMainTopics) return SessionConversationSummary( keyFacts = cappedFacts, @@ -569,7 +564,7 @@ class TokenAwareSummarizingMemory( if (messagesToSummarize.size > 6) { appendLine("... (${messagesToSummarize.size - 4} more messages)") } - }.take(MAX_SUMMARY_LENGTH) + }.take(AppConfig.memory.maxSummaryLength) basicSummary = newSummary log.info("Generated basic summary (${newSummary.length} chars)") @@ -730,35 +725,6 @@ class TokenAwareSummarizingMemory( } companion object { - private const val MAX_SUMMARY_LENGTH = 2000 - - /** - * Number of most-recent conversation messages always kept verbatim. - * These are never included in a summarization batch, ensuring the AI - * always sees full-fidelity context for the latest exchanges. - */ - private const val PROTECTED_RECENT_TURNS = 6 - - /** - * Fraction of the summarizable (non-protected) messages to compress - * and prune each summarization cycle. 0.65 means 65% of candidates are - * removed, leaving more breathing room before the next cycle fires. - */ - private const val SUMMARIZATION_PRUNE_FRACTION = 0.65 - - /** - * Maximum number of key facts retained in the merged structured summary. - * Uses LRU-by-relevance eviction: facts that appear in recent summarization - * cycles are refreshed to the back of the map; facts not seen recently drift - * to the front and are dropped when this cap is exceeded. - */ - private const val MAX_KEY_FACTS = 30 - - /** - * Maximum number of distinct main topics retained across summary merges. - * The most recently observed topics are kept (takeLast semantics). - */ - private const val MAX_MAIN_TOPICS = 15 /** * Default token estimator: word count × 1.75.