diff --git a/packages/desktop/src-tauri/src/commands/settings.rs b/packages/desktop/src-tauri/src/commands/settings.rs index 85bdded9..1947d83a 100644 --- a/packages/desktop/src-tauri/src/commands/settings.rs +++ b/packages/desktop/src-tauri/src/commands/settings.rs @@ -300,6 +300,38 @@ fn sanitize_settings_update(payload: &Value) -> Value { } } + // Memory limit fields + if let Some(Value::Number(n)) = obj.get("memoryLimitHistorical") { + let parsed = n + .as_u64() + .or_else(|| n.as_i64().and_then(|v| if v >= 0 { Some(v as u64) } else { None })) + .or_else(|| n.as_f64().map(|v| v.round().max(0.0) as u64)); + if let Some(value) = parsed { + let clamped = value.max(10).min(500); + result_obj.insert("memoryLimitHistorical".to_string(), json!(clamped)); + } + } + if let Some(Value::Number(n)) = obj.get("memoryLimitViewport") { + let parsed = n + .as_u64() + .or_else(|| n.as_i64().and_then(|v| if v >= 0 { Some(v as u64) } else { None })) + .or_else(|| n.as_f64().map(|v| v.round().max(0.0) as u64)); + if let Some(value) = parsed { + let clamped = value.max(20).min(500); + result_obj.insert("memoryLimitViewport".to_string(), json!(clamped)); + } + } + if let Some(Value::Number(n)) = obj.get("memoryLimitActiveSession") { + let parsed = n + .as_u64() + .or_else(|| n.as_i64().and_then(|v| if v >= 0 { Some(v as u64) } else { None })) + .or_else(|| n.as_f64().map(|v| v.round().max(0.0) as u64)); + if let Some(value) = parsed { + let clamped = value.max(30).min(1000); + result_obj.insert("memoryLimitActiveSession".to_string(), json!(clamped)); + } + } + // Array fields if let Some(arr) = obj.get("approvedDirectories") { result_obj.insert( diff --git a/packages/ui/src/components/sections/openchamber/MemoryLimitsSettings.tsx b/packages/ui/src/components/sections/openchamber/MemoryLimitsSettings.tsx new file mode 100644 index 00000000..9e38b624 --- /dev/null +++ b/packages/ui/src/components/sections/openchamber/MemoryLimitsSettings.tsx @@ -0,0 +1,277 @@ +import React from 'react'; +import { RiInformationLine } from '@remixicon/react'; +import { NumberInput } from '@/components/ui/number-input'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { useDeviceInfo } from '@/lib/device'; +import { useUIStore } from '@/stores/useUIStore'; +import { updateDesktopSettings } from '@/lib/persistence'; +import { getDesktopSettings, isDesktopRuntime } from '@/lib/desktop'; +import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; +import { DEFAULT_MEMORY_LIMITS, DEFAULT_ACTIVE_SESSION_WINDOW } from '@/stores/types/sessionTypes'; + +const MIN_HISTORICAL = 10; +const MAX_HISTORICAL = 500; +const MIN_VIEWPORT = 20; +const MAX_VIEWPORT = 500; +const MIN_ACTIVE = 30; +const MAX_ACTIVE = 1000; + +export const MemoryLimitsSettings: React.FC = () => { + const { isMobile } = useDeviceInfo(); + + const memoryLimitHistorical = useUIStore((state) => state.memoryLimitHistorical); + const memoryLimitViewport = useUIStore((state) => state.memoryLimitViewport); + const memoryLimitActiveSession = useUIStore((state) => state.memoryLimitActiveSession); + const setMemoryLimitHistorical = useUIStore((state) => state.setMemoryLimitHistorical); + const setMemoryLimitViewport = useUIStore((state) => state.setMemoryLimitViewport); + const setMemoryLimitActiveSession = useUIStore((state) => state.setMemoryLimitActiveSession); + + const [isLoading, setIsLoading] = React.useState(true); + + // Load settings from server on mount + React.useEffect(() => { + const loadSettings = async () => { + try { + let data: { memoryLimitHistorical?: number; memoryLimitViewport?: number; memoryLimitActiveSession?: number } | null = null; + + // 1. Desktop runtime (Tauri) + if (isDesktopRuntime()) { + data = await getDesktopSettings(); + } else { + // 2. Runtime settings API (VSCode) + const runtimeSettings = getRegisteredRuntimeAPIs()?.settings; + if (runtimeSettings) { + try { + const result = await runtimeSettings.load(); + const settings = result?.settings as Record | undefined; + if (settings) { + data = { + memoryLimitHistorical: typeof settings.memoryLimitHistorical === 'number' ? settings.memoryLimitHistorical : undefined, + memoryLimitViewport: typeof settings.memoryLimitViewport === 'number' ? settings.memoryLimitViewport : undefined, + memoryLimitActiveSession: typeof settings.memoryLimitActiveSession === 'number' ? settings.memoryLimitActiveSession : undefined, + }; + } + } catch { + // Fall through to fetch + } + } + + // 3. Fetch API (Web) + if (!data) { + const response = await fetch('/api/config/settings', { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + if (response.ok) { + data = await response.json(); + } + } + } + + if (data) { + if (typeof data.memoryLimitHistorical === 'number') { + setMemoryLimitHistorical(data.memoryLimitHistorical); + } + if (typeof data.memoryLimitViewport === 'number') { + setMemoryLimitViewport(data.memoryLimitViewport); + } + if (typeof data.memoryLimitActiveSession === 'number') { + setMemoryLimitActiveSession(data.memoryLimitActiveSession); + } + } + } catch (error) { + console.warn('Failed to load memory limits settings:', error); + } finally { + setIsLoading(false); + } + }; + loadSettings(); + }, [setMemoryLimitHistorical, setMemoryLimitViewport, setMemoryLimitActiveSession]); + + const persistSetting = React.useCallback(async (key: string, value: number) => { + try { + await updateDesktopSettings({ [key]: value }); + + if (!isDesktopRuntime()) { + const response = await fetch('/api/config/settings', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ [key]: value }), + }); + if (!response.ok) { + console.warn(`Failed to save ${key} to server:`, response.status, response.statusText); + } + } + } catch (error) { + console.warn(`Failed to save ${key}:`, error); + } + }, []); + + const handleHistoricalChange = React.useCallback((value: number) => { + setMemoryLimitHistorical(value); + persistSetting('memoryLimitHistorical', value); + }, [setMemoryLimitHistorical, persistSetting]); + + const handleViewportChange = React.useCallback((value: number) => { + setMemoryLimitViewport(value); + persistSetting('memoryLimitViewport', value); + }, [setMemoryLimitViewport, persistSetting]); + + const handleActiveSessionChange = React.useCallback((value: number) => { + setMemoryLimitActiveSession(value); + persistSetting('memoryLimitActiveSession', value); + }, [setMemoryLimitActiveSession, persistSetting]); + + if (isLoading) { + return null; + } + + return ( +
+
+
+

Message Memory

+ + + + + + Control how many messages are kept in memory for performance optimization.
+ Lower values use less memory but may require reloading older messages. +
+
+
+
+ +
+ + + + + +
+
+ ); +}; + +interface MemoryLimitRowProps { + label: string; + description: string; + value: number; + defaultValue: number; + min: number; + max: number; + onChange: (value: number) => void; + isMobile: boolean; +} + +const MemoryLimitRow: React.FC = ({ + label, + description, + value, + defaultValue, + min, + max, + onChange, + isMobile, +}) => { + const [draft, setDraft] = React.useState(String(value)); + + React.useEffect(() => { + setDraft(String(value)); + }, [value]); + + const handleMobileChange = React.useCallback((e: React.ChangeEvent) => { + const nextValue = e.target.value; + setDraft(nextValue); + if (nextValue.trim() === '') { + return; + } + const parsed = Number(nextValue); + if (!Number.isFinite(parsed)) { + return; + } + const clamped = Math.min(max, Math.max(min, Math.round(parsed))); + onChange(clamped); + }, [min, max, onChange]); + + const handleMobileBlur = React.useCallback(() => { + if (draft.trim() === '') { + setDraft(String(value)); + return; + } + const parsed = Number(draft); + if (!Number.isFinite(parsed)) { + setDraft(String(value)); + return; + } + const clamped = Math.min(max, Math.max(min, Math.round(parsed))); + onChange(clamped); + setDraft(String(clamped)); + }, [draft, value, min, max, onChange]); + + const isDefault = value === defaultValue; + + return ( +
+
+
+ {label} + {description} +
+
+ {!isDefault && ( + (default: {defaultValue}) + )} + {isMobile ? ( + + ) : ( + + )} +
+
+
+ ); +}; diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx index 9a316daa..bee370fa 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { OpenChamberVisualSettings } from './OpenChamberVisualSettings'; import { AboutSettings } from './AboutSettings'; import { SessionRetentionSettings } from './SessionRetentionSettings'; +import { MemoryLimitsSettings } from './MemoryLimitsSettings'; import { DefaultsSettings } from './DefaultsSettings'; import { GitSettings } from './GitSettings'; import { WorktreeSectionContent } from './WorktreeSectionContent'; @@ -83,7 +84,7 @@ const ChatSectionContent: React.FC = () => { return ; }; -// Sessions section: Default model & agent, Session retention +// Sessions section: Default model & agent, Session retention, Memory limits const SessionsSectionContent: React.FC = () => { return (
@@ -91,6 +92,9 @@ const SessionsSectionContent: React.FC = () => {
+
+ +
); }; diff --git a/packages/ui/src/hooks/useMessageSync.ts b/packages/ui/src/hooks/useMessageSync.ts index 1e57831a..75bfe6aa 100644 --- a/packages/ui/src/hooks/useMessageSync.ts +++ b/packages/ui/src/hooks/useMessageSync.ts @@ -1,7 +1,7 @@ import React from 'react'; import type { AssistantMessage, Message, Part } from '@opencode-ai/sdk/v2'; import { useSessionStore } from '@/stores/useSessionStore'; -import { MEMORY_LIMITS } from '@/stores/types/sessionTypes'; +import { getMemoryLimits } from '@/stores/types/sessionTypes'; import { opencodeClient } from '@/lib/opencode/client'; import { readSessionCursor } from '@/lib/messageCursorPersistence'; import { extractTextFromPart } from '@/stores/utils/messageUtils'; @@ -126,8 +126,9 @@ export const useMessageSync = () => { const currentMessages = (messages.get(currentSessionId) || []) as SessionMessageRecord[]; const memoryState = useSessionStore.getState().sessionMemoryState.get(currentSessionId); - const targetLimit = memoryState?.isStreaming ? MEMORY_LIMITS.VIEWPORT_MESSAGES : MEMORY_LIMITS.HISTORICAL_MESSAGES; - const fetchLimit = targetLimit + MEMORY_LIMITS.FETCH_BUFFER; + const memLimits = getMemoryLimits(); + const targetLimit = memoryState?.isStreaming ? memLimits.VIEWPORT_MESSAGES : memLimits.HISTORICAL_MESSAGES; + const fetchLimit = targetLimit + memLimits.FETCH_BUFFER; const latestMessages = (await opencodeClient.getSessionMessages(currentSessionId, fetchLimit)) as SessionMessageRecord[]; const cursorRecord = await readSessionCursor(currentSessionId); diff --git a/packages/ui/src/lib/desktop.ts b/packages/ui/src/lib/desktop.ts index 34d45016..04c60529 100644 --- a/packages/ui/src/lib/desktop.ts +++ b/packages/ui/src/lib/desktop.ts @@ -61,6 +61,11 @@ export type DesktopSettings = { queueModeEnabled?: boolean; gitmojiEnabled?: boolean; + // Memory limits for message viewport management + memoryLimitHistorical?: number; // Default fetch limit when loading/syncing (default: 90) + memoryLimitViewport?: number; // Trim target when leaving session (default: 120) + memoryLimitActiveSession?: number; // Trim target for active session (default: 180) + // User-added skills catalogs (persisted to ~/.config/openchamber/settings.json) skillCatalogs?: SkillCatalogConfig[]; }; diff --git a/packages/ui/src/lib/persistence.ts b/packages/ui/src/lib/persistence.ts index 831f856a..79e7aae0 100644 --- a/packages/ui/src/lib/persistence.ts +++ b/packages/ui/src/lib/persistence.ts @@ -185,6 +185,17 @@ const applyDesktopUiPreferences = (settings: DesktopSettings) => { store.setAutoDeleteAfterDays(normalized); } } + + if (typeof settings.memoryLimitHistorical === 'number' && Number.isFinite(settings.memoryLimitHistorical)) { + store.setMemoryLimitHistorical(settings.memoryLimitHistorical); + } + if (typeof settings.memoryLimitViewport === 'number' && Number.isFinite(settings.memoryLimitViewport)) { + store.setMemoryLimitViewport(settings.memoryLimitViewport); + } + if (typeof settings.memoryLimitActiveSession === 'number' && Number.isFinite(settings.memoryLimitActiveSession)) { + store.setMemoryLimitActiveSession(settings.memoryLimitActiveSession); + } + if (typeof settings.queueModeEnabled === 'boolean' && settings.queueModeEnabled !== queueStore.queueModeEnabled) { queueStore.setQueueMode(settings.queueModeEnabled); } @@ -273,6 +284,16 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => { result.queueModeEnabled = candidate.queueModeEnabled; } + if (typeof candidate.memoryLimitHistorical === 'number' && Number.isFinite(candidate.memoryLimitHistorical)) { + result.memoryLimitHistorical = candidate.memoryLimitHistorical; + } + if (typeof candidate.memoryLimitViewport === 'number' && Number.isFinite(candidate.memoryLimitViewport)) { + result.memoryLimitViewport = candidate.memoryLimitViewport; + } + if (typeof candidate.memoryLimitActiveSession === 'number' && Number.isFinite(candidate.memoryLimitActiveSession)) { + result.memoryLimitActiveSession = candidate.memoryLimitActiveSession; + } + const skillCatalogs = sanitizeSkillCatalogs(candidate.skillCatalogs); if (skillCatalogs) { result.skillCatalogs = skillCatalogs; diff --git a/packages/ui/src/stores/messageStore.ts b/packages/ui/src/stores/messageStore.ts index 841a99c7..8239797c 100644 --- a/packages/ui/src/stores/messageStore.ts +++ b/packages/ui/src/stores/messageStore.ts @@ -5,7 +5,7 @@ import type { Message, Part } from "@opencode-ai/sdk/v2"; import { opencodeClient } from "@/lib/opencode/client"; import { isExecutionForkMetaText } from "@/lib/messages/executionMeta"; import type { SessionMemoryState, MessageStreamLifecycle, AttachedFile } from "./types/sessionTypes"; -import { MEMORY_LIMITS } from "./types/sessionTypes"; +import { MEMORY_LIMITS, getMemoryLimits } from "./types/sessionTypes"; import { touchStreamingLifecycle, removeLifecycleEntries, @@ -385,10 +385,12 @@ export const useMessageStore = create()( pendingAssistantHeaderSessions: new Set(), pendingUserMessageMetaBySession: new Map(), - loadMessages: async (sessionId: string, limit: number = MEMORY_LIMITS.HISTORICAL_MESSAGES) => { + loadMessages: async (sessionId: string, limit?: number) => { + const memLimits = getMemoryLimits(); + const effectiveLimit = limit ?? memLimits.HISTORICAL_MESSAGES; const isStreaming = get().sessionMemoryState.get(sessionId)?.isStreaming; - const targetLimit = isStreaming ? MEMORY_LIMITS.VIEWPORT_MESSAGES : limit; - const fetchLimit = isStreaming ? undefined : targetLimit + MEMORY_LIMITS.FETCH_BUFFER; + const targetLimit = isStreaming ? memLimits.VIEWPORT_MESSAGES : effectiveLimit; + const fetchLimit = isStreaming ? undefined : targetLimit + memLimits.FETCH_BUFFER; const allMessages = await executeWithSessionDirectory(sessionId, () => opencodeClient.getSessionMessages(sessionId, fetchLimit)); // Filter out reverted messages first @@ -2227,10 +2229,11 @@ export const useMessageStore = create()( }; }, - trimToViewportWindow: (sessionId: string, targetSize: number = MEMORY_LIMITS.VIEWPORT_MESSAGES, currentSessionId?: string) => { + trimToViewportWindow: (sessionId: string, targetSize?: number, currentSessionId?: string) => { + const effectiveTargetSize = targetSize ?? getMemoryLimits().VIEWPORT_MESSAGES; const state = get(); const sessionMessages = state.messages.get(sessionId); - if (!sessionMessages || sessionMessages.length <= targetSize) { + if (!sessionMessages || sessionMessages.length <= effectiveTargetSize) { return; } @@ -2246,11 +2249,11 @@ export const useMessageStore = create()( } const anchor = memoryState.viewportAnchor || sessionMessages.length - 1; - let start = Math.max(0, anchor - Math.floor(targetSize / 2)); - const end = Math.min(sessionMessages.length, start + targetSize); + let start = Math.max(0, anchor - Math.floor(effectiveTargetSize / 2)); + const end = Math.min(sessionMessages.length, start + effectiveTargetSize); - if (end === sessionMessages.length && end - start < targetSize) { - start = Math.max(0, end - targetSize); + if (end === sessionMessages.length && end - start < effectiveTargetSize) { + start = Math.max(0, end - effectiveTargetSize); } const trimmedMessages = sessionMessages.slice(start, end); diff --git a/packages/ui/src/stores/types/sessionTypes.ts b/packages/ui/src/stores/types/sessionTypes.ts index 93e41771..4e2f5644 100644 --- a/packages/ui/src/stores/types/sessionTypes.ts +++ b/packages/ui/src/stores/types/sessionTypes.ts @@ -51,7 +51,8 @@ export interface SessionContextUsage { lastMessageId?: string; } -export const MEMORY_LIMITS = { +// Default memory limits (can be overridden via settings) +export const DEFAULT_MEMORY_LIMITS = { MAX_SESSIONS: 3, VIEWPORT_MESSAGES: 120, HISTORICAL_MESSAGES: 90, @@ -61,7 +62,35 @@ export const MEMORY_LIMITS = { ZOMBIE_TIMEOUT: 10 * 60 * 1000, } as const; -export const ACTIVE_SESSION_WINDOW = 180; +export const DEFAULT_ACTIVE_SESSION_WINDOW = 180; + +// Dynamic memory limits accessor - reads directly from UI store. +// NOTE: do not use require() here (breaks in browser/desktop runtime bundles). +import { useUIStore } from "../useUIStore"; + +export const getMemoryLimits = () => { + const state = useUIStore.getState?.(); + if (!state) { + return DEFAULT_MEMORY_LIMITS; + } + return { + ...DEFAULT_MEMORY_LIMITS, + HISTORICAL_MESSAGES: state.memoryLimitHistorical ?? DEFAULT_MEMORY_LIMITS.HISTORICAL_MESSAGES, + VIEWPORT_MESSAGES: state.memoryLimitViewport ?? DEFAULT_MEMORY_LIMITS.VIEWPORT_MESSAGES, + }; +}; + +export const getActiveSessionWindow = () => { + const state = useUIStore.getState?.(); + if (!state) { + return DEFAULT_ACTIVE_SESSION_WINDOW; + } + return state.memoryLimitActiveSession ?? DEFAULT_ACTIVE_SESSION_WINDOW; +}; + +// Legacy exports for backward compatibility (use getMemoryLimits() for dynamic values) +export const MEMORY_LIMITS = DEFAULT_MEMORY_LIMITS; +export const ACTIVE_SESSION_WINDOW = DEFAULT_ACTIVE_SESSION_WINDOW; export type NewSessionDraftState = { open: boolean; diff --git a/packages/ui/src/stores/useSessionStore.ts b/packages/ui/src/stores/useSessionStore.ts index f6ffa46c..6bea47d2 100644 --- a/packages/ui/src/stores/useSessionStore.ts +++ b/packages/ui/src/stores/useSessionStore.ts @@ -5,7 +5,7 @@ import type { Session, Message, Part } from "@opencode-ai/sdk/v2"; import type { PermissionRequest, PermissionResponse } from "@/types/permission"; import type { QuestionRequest } from "@/types/question"; import type { SessionStore, AttachedFile, EditPermissionMode } from "./types/sessionTypes"; -import { ACTIVE_SESSION_WINDOW, MEMORY_LIMITS } from "./types/sessionTypes"; +import { getActiveSessionWindow, getMemoryLimits } from "./types/sessionTypes"; import { useSessionStore as useSessionManagementStore } from "./sessionStore"; import { useMessageStore } from "./messageStore"; @@ -273,7 +273,7 @@ export const useSessionStore = create()( get().updateViewportAnchor(previousSessionId, previousMessages.length - 1); } - get().trimToViewportWindow(previousSessionId, MEMORY_LIMITS.VIEWPORT_MESSAGES); + get().trimToViewportWindow(previousSessionId, getMemoryLimits().VIEWPORT_MESSAGES); } } @@ -287,7 +287,7 @@ export const useSessionStore = create()( await get().loadMessages(id); } - get().trimToViewportWindow(id, ACTIVE_SESSION_WINDOW); + get().trimToViewportWindow(id, getActiveSessionWindow()); // Analyze session messages to extract agent/model/variant choices // This ensures context is available even when ModelControls isn't mounted diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index 8ca178f2..a7216528 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -43,6 +43,9 @@ interface UIStore { autoDeleteEnabled: boolean; autoDeleteAfterDays: number; autoDeleteLastRunAt: number | null; + memoryLimitHistorical: number; + memoryLimitViewport: number; + memoryLimitActiveSession: number; toolCallExpansion: 'collapsed' | 'activity' | 'detailed'; fontSize: number; @@ -87,6 +90,9 @@ interface UIStore { setAutoDeleteEnabled: (value: boolean) => void; setAutoDeleteAfterDays: (days: number) => void; setAutoDeleteLastRunAt: (timestamp: number | null) => void; + setMemoryLimitHistorical: (value: number) => void; + setMemoryLimitViewport: (value: number) => void; + setMemoryLimitActiveSession: (value: number) => void; setToolCallExpansion: (value: 'collapsed' | 'activity' | 'detailed') => void; setFontSize: (size: number) => void; setPadding: (size: number) => void; @@ -141,6 +147,9 @@ export const useUIStore = create()( autoDeleteEnabled: false, autoDeleteAfterDays: 30, autoDeleteLastRunAt: null, + memoryLimitHistorical: 90, + memoryLimitViewport: 120, + memoryLimitActiveSession: 180, toolCallExpansion: 'collapsed', fontSize: 100, padding: 100, @@ -296,6 +305,21 @@ export const useUIStore = create()( set({ autoDeleteLastRunAt: timestamp }); }, + setMemoryLimitHistorical: (value) => { + const clamped = Math.max(10, Math.min(500, Math.round(value))); + set({ memoryLimitHistorical: clamped }); + }, + + setMemoryLimitViewport: (value) => { + const clamped = Math.max(20, Math.min(500, Math.round(value))); + set({ memoryLimitViewport: clamped }); + }, + + setMemoryLimitActiveSession: (value) => { + const clamped = Math.max(30, Math.min(1000, Math.round(value))); + set({ memoryLimitActiveSession: clamped }); + }, + setToolCallExpansion: (value) => { set({ toolCallExpansion: value }); }, @@ -527,6 +551,9 @@ export const useUIStore = create()( autoDeleteEnabled: state.autoDeleteEnabled, autoDeleteAfterDays: state.autoDeleteAfterDays, autoDeleteLastRunAt: state.autoDeleteLastRunAt, + memoryLimitHistorical: state.memoryLimitHistorical, + memoryLimitViewport: state.memoryLimitViewport, + memoryLimitActiveSession: state.memoryLimitActiveSession, toolCallExpansion: state.toolCallExpansion, fontSize: state.fontSize, padding: state.padding, diff --git a/packages/web/server/index.js b/packages/web/server/index.js index b05aa96e..c0afcf82 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -642,6 +642,17 @@ const sanitizeSettingsUpdate = (payload) => { result.gitmojiEnabled = candidate.gitmojiEnabled; } + // Memory limits for message viewport management + if (typeof candidate.memoryLimitHistorical === 'number' && Number.isFinite(candidate.memoryLimitHistorical)) { + result.memoryLimitHistorical = Math.max(10, Math.min(500, Math.round(candidate.memoryLimitHistorical))); + } + if (typeof candidate.memoryLimitViewport === 'number' && Number.isFinite(candidate.memoryLimitViewport)) { + result.memoryLimitViewport = Math.max(20, Math.min(500, Math.round(candidate.memoryLimitViewport))); + } + if (typeof candidate.memoryLimitActiveSession === 'number' && Number.isFinite(candidate.memoryLimitActiveSession)) { + result.memoryLimitActiveSession = Math.max(30, Math.min(1000, Math.round(candidate.memoryLimitActiveSession))); + } + const skillCatalogs = sanitizeSkillCatalogs(candidate.skillCatalogs); if (skillCatalogs) { result.skillCatalogs = skillCatalogs;