"""OpenSpace runtime settings service. User-visible runtime and product settings should read and write through this module rather than ad-hoc JSON helpers. """ from __future__ import annotations import asyncio import json import os import tempfile import time from dataclasses import dataclass, field from pathlib import Path from typing import Any, Awaitable, Callable, Literal, Mapping, MutableMapping, Sequence from openspace.utils.logging import Logger logger = Logger.get_logger(__name__) SettingSource = Literal[ "defaults", "userSettings", "projectSettings", "localSettings", "envSettings", "runtimeSettings", ] EditableSettingSource = Literal["userSettings", "projectSettings", "localSettings"] OPENSPACE_CONFIG_HOME_ENV = "OPENSPACE_CONFIG_HOME" # OpenSpace has not published a SchemaStore entry for settings.json yet. Keep # this unset instead of advertising a public URL that returns 404. OPENSPACE_SETTINGS_SCHEMA_URL: str | None = None EDITABLE_SOURCES: tuple[EditableSettingSource, ...] = ( "userSettings", "projectSettings", "localSettings", ) FILE_SETTING_SOURCES: tuple[EditableSettingSource, ...] = EDITABLE_SOURCES MERGE_SOURCES: tuple[SettingSource, ...] = ( "defaults", "userSettings", "projectSettings", "localSettings", "envSettings", "runtimeSettings", ) THEME_OPTIONS = ("dark", "light") EDITOR_MODE_OPTIONS = ("normal", "vim") NOTIFICATION_CHANNEL_OPTIONS = ( "auto", "iterm2", "iterm2_with_bell", "terminal_bell", "kitty", "ghostty", "notifications_disabled", ) TEAMMATE_MODE_OPTIONS = ("auto", "tmux", "in-process") PERMISSION_MODE_OPTIONS = ( "acceptEdits", "bypassPermissions", "default", "dontAsk", "plan", ) MEMORY_MODE_OPTIONS = ("direct", "daily_log") SANDBOX_PLATFORM_OPTIONS = ("macos", "linux", "wsl2", "windows") @dataclass(slots=True) class SettingsError: source: SettingSource path: str | None key_path: str message: str invalid_value: Any = None @dataclass(slots=True) class SettingsSourceSnapshot: source: SettingSource path: str | None settings: dict[str, Any] @dataclass(slots=True) class AutoDreamSettings: enabled: bool = False min_hours: float = 24.0 min_sessions: int = 5 scan_interval_seconds: float = 600.0 model: str | None = None def to_json(self) -> dict[str, Any]: return { "enabled": self.enabled, "minHours": self.min_hours, "minSessions": self.min_sessions, "scanIntervalSeconds": self.scan_interval_seconds, "model": self.model, } @dataclass(slots=True) class DailyLogSettings: retention_days: int = 90 consolidate_on_dream: bool = True def to_json(self) -> dict[str, Any]: return { "retentionDays": self.retention_days, "consolidateOnDream": self.consolidate_on_dream, } @dataclass(slots=True) class MemorySettings: mode: str = "direct" daily_log: DailyLogSettings = field(default_factory=DailyLogSettings) def to_json(self) -> dict[str, Any]: return { "mode": self.mode, "dailyLog": self.daily_log.to_json(), } @dataclass(slots=True) class AttachmentSettings: todo_reminder_enabled: bool = True def to_json(self) -> dict[str, Any]: return { "todoReminderEnabled": self.todo_reminder_enabled, } @dataclass(slots=True) class PermissionSettings: allow: list[str] = field(default_factory=list) deny: list[str] = field(default_factory=list) ask: list[str] = field(default_factory=list) default_mode: str = "default" additional_directories: list[str] = field(default_factory=list) def to_json(self) -> dict[str, Any]: return { "allow": list(self.allow), "deny": list(self.deny), "ask": list(self.ask), "defaultMode": self.default_mode, "additionalDirectories": list(self.additional_directories), } @dataclass(slots=True) class SandboxNetworkSettings: allowed_domains: list[str] = field(default_factory=list) allow_managed_domains_only: bool = False allow_unix_sockets: list[str] = field(default_factory=list) allow_all_unix_sockets: bool = False allow_local_binding: bool = False http_proxy_port: int | None = None socks_proxy_port: int | None = None def to_json(self) -> dict[str, Any]: return { "allowedDomains": list(self.allowed_domains), "allowManagedDomainsOnly": self.allow_managed_domains_only, "allowUnixSockets": list(self.allow_unix_sockets), "allowAllUnixSockets": self.allow_all_unix_sockets, "allowLocalBinding": self.allow_local_binding, "httpProxyPort": self.http_proxy_port, "socksProxyPort": self.socks_proxy_port, } @dataclass(slots=True) class SandboxFilesystemSettings: allow_write: list[str] = field(default_factory=list) deny_write: list[str] = field(default_factory=list) deny_read: list[str] = field(default_factory=list) allow_read: list[str] = field(default_factory=list) allow_managed_read_paths_only: bool = False def to_json(self) -> dict[str, Any]: return { "allowWrite": list(self.allow_write), "denyWrite": list(self.deny_write), "denyRead": list(self.deny_read), "allowRead": list(self.allow_read), "allowManagedReadPathsOnly": self.allow_managed_read_paths_only, } @dataclass(slots=True) class SandboxSettings: enabled: bool = False fail_if_unavailable: bool = False enabled_platforms: list[str] | None = None auto_allow_bash_if_sandboxed: bool = True allow_unsandboxed_commands: bool = True network: SandboxNetworkSettings = field(default_factory=SandboxNetworkSettings) filesystem: SandboxFilesystemSettings = field(default_factory=SandboxFilesystemSettings) ignore_violations: dict[str, list[str]] = field(default_factory=dict) enable_weaker_nested_sandbox: bool = False enable_weaker_network_isolation: bool = False excluded_commands: list[str] = field(default_factory=list) ripgrep_command: str | None = None ripgrep_args: list[str] = field(default_factory=list) def to_json(self) -> dict[str, Any]: data: dict[str, Any] = { "enabled": self.enabled, "failIfUnavailable": self.fail_if_unavailable, "autoAllowBashIfSandboxed": self.auto_allow_bash_if_sandboxed, "allowUnsandboxedCommands": self.allow_unsandboxed_commands, "network": self.network.to_json(), "filesystem": self.filesystem.to_json(), "ignoreViolations": { key: list(values) for key, values in self.ignore_violations.items() }, "enableWeakerNestedSandbox": self.enable_weaker_nested_sandbox, "enableWeakerNetworkIsolation": self.enable_weaker_network_isolation, "excludedCommands": list(self.excluded_commands), } if self.enabled_platforms is not None: data["enabledPlatforms"] = list(self.enabled_platforms) if self.ripgrep_command is not None or self.ripgrep_args: data["ripgrep"] = { "command": self.ripgrep_command or "rg", "args": list(self.ripgrep_args), } return data @dataclass(slots=True) class UISettings: theme: str = "dark" editor_mode: str = "normal" verbose: bool = False preferred_notif_channel: str = "auto" show_turn_duration: bool = True terminal_progress_bar_enabled: bool = True def to_json(self) -> dict[str, Any]: return { "theme": self.theme, "editorMode": self.editor_mode, "verbose": self.verbose, "preferredNotifChannel": self.preferred_notif_channel, "showTurnDuration": self.show_turn_duration, "terminalProgressBarEnabled": self.terminal_progress_bar_enabled, } @dataclass(slots=True) class EngineSettings: auto_compact_enabled: bool = True file_checkpointing_enabled: bool = True todo_feature_enabled: bool = True model: str | None = None always_thinking_enabled: bool | None = None permissions: PermissionSettings = field(default_factory=PermissionSettings) language: str | None = None auto_memory_enabled: bool = True auto_memory_directory: str | None = None auto_dream: AutoDreamSettings = field(default_factory=AutoDreamSettings) memory: MemorySettings = field(default_factory=MemorySettings) sandbox: SandboxSettings = field(default_factory=SandboxSettings) def to_json(self) -> dict[str, Any]: return { "autoCompactEnabled": self.auto_compact_enabled, "fileCheckpointingEnabled": self.file_checkpointing_enabled, "todoFeatureEnabled": self.todo_feature_enabled, "model": self.model, "alwaysThinkingEnabled": self.always_thinking_enabled, "permissions": self.permissions.to_json(), "language": self.language, "autoMemoryEnabled": self.auto_memory_enabled, "autoMemoryDirectory": self.auto_memory_directory, "autoDream": self.auto_dream.to_json(), "memory": self.memory.to_json(), "sandbox": self.sandbox.to_json(), } @dataclass(slots=True) class ExperimentalSettings: teammate_mode: str = "auto" output_style: str = "default" attachments: AttachmentSettings = field(default_factory=AttachmentSettings) def to_json(self) -> dict[str, Any]: return { "teammateMode": self.teammate_mode, "outputStyle": self.output_style, "attachments": self.attachments.to_json(), } @dataclass(slots=True) class Settings: ui: UISettings = field(default_factory=UISettings) engine: EngineSettings = field(default_factory=EngineSettings) experimental: ExperimentalSettings = field(default_factory=ExperimentalSettings) schema: str | None = None def to_json(self) -> dict[str, Any]: data: dict[str, Any] = {} data.update(self.ui.to_json()) data.update(self.engine.to_json()) data.update(self.experimental.to_json()) if self.schema is not None: data["$schema"] = self.schema return data def get_path(self, key: str, default: Any = None) -> Any: return _get_path(self.to_json(), key, default) @dataclass(slots=True) class RuntimeSettingsState: remote_session: bool = False remote_memory_dir: Path | None = None cli_model: str | None = None permission_mode: str | None = None always_thinking_enabled: bool | None = None @dataclass(slots=True) class SettingsSnapshot: settings: Settings raw: dict[str, Any] sources: list[SettingsSourceSnapshot] errors: list[SettingsError] cwd: str project_root: str loaded_at: float _CACHE: dict[tuple[Any, ...], SettingsSnapshot] = {} _SUBSCRIBERS: list[Callable[[SettingsSnapshot], Awaitable[None] | None]] = [] _WATCH_TASK: asyncio.Task[None] | None = None _WATCH_CWD: str | None = None _WATCH_INTERVAL_S = 1.0 _WATCH_MTIMES: dict[str, float | None] = {} _INTERNAL_WRITES: dict[str, float] = {} _WRITABLE_SETTING_KEYS: frozenset[str] = frozenset( { "theme", "editorMode", "verbose", "preferredNotifChannel", "showTurnDuration", "terminalProgressBarEnabled", "autoCompactEnabled", "fileCheckpointingEnabled", "todoFeatureEnabled", "model", "alwaysThinkingEnabled", "permissions.allow", "permissions.deny", "permissions.ask", "permissions.defaultMode", "permissions.additionalDirectories", "hooks", "language", "autoMemoryEnabled", "autoMemoryDirectory", "autoDream.enabled", "autoDream.minHours", "autoDream.minSessions", "autoDream.scanIntervalSeconds", "autoDream.model", "memory.mode", "memory.dailyLog.retentionDays", "memory.dailyLog.consolidateOnDream", "sandbox.enabled", "sandbox.failIfUnavailable", "sandbox.enabledPlatforms", "sandbox.autoAllowBashIfSandboxed", "sandbox.allowUnsandboxedCommands", "sandbox.network.allowedDomains", "sandbox.network.allowManagedDomainsOnly", "sandbox.network.allowUnixSockets", "sandbox.network.allowAllUnixSockets", "sandbox.network.allowLocalBinding", "sandbox.network.httpProxyPort", "sandbox.network.socksProxyPort", "sandbox.filesystem.allowWrite", "sandbox.filesystem.denyWrite", "sandbox.filesystem.denyRead", "sandbox.filesystem.allowRead", "sandbox.filesystem.allowManagedReadPathsOnly", "sandbox.ignoreViolations", "sandbox.enableWeakerNestedSandbox", "sandbox.enableWeakerNetworkIsolation", "sandbox.excludedCommands", "sandbox.ripgrep.command", "sandbox.ripgrep.args", "teammateMode", "outputStyle", "attachments.todoReminderEnabled", } ) def get_openspace_config_home_dir(config_home: str | Path | None = None) -> Path: if config_home is not None: return Path(config_home).expanduser().resolve() env_home = os.environ.get(OPENSPACE_CONFIG_HOME_ENV) if env_home: return Path(env_home).expanduser().resolve() return (Path.home() / ".openspace").resolve() def get_project_root(cwd: str | Path | None = None) -> Path: try: from openspace.services.memory.paths import find_project_root return find_project_root(cwd) except Exception: return Path(cwd or os.getcwd()).expanduser().resolve() def get_settings_path_for_source( source: SettingSource, cwd: str | Path | None = None, ) -> Path | None: if source == "userSettings": return get_openspace_config_home_dir() / "settings.json" if source == "projectSettings": return get_project_root(cwd) / ".openspace" / "settings.json" if source == "localSettings": return get_project_root(cwd) / ".openspace" / "settings.local.json" return None def get_settings_for_source( source: SettingSource, cwd: str | Path | None = None, *, strict: bool = False, ) -> dict[str, Any] | None: if source == "defaults": return _default_raw_settings() if source == "envSettings": return _env_settings() if source == "runtimeSettings": return None path = get_settings_path_for_source(source, cwd) if path is None or not path.is_file(): return None result = _parse_settings_file(path, source, strict=strict) if strict and result.settings is None and result.errors: first = result.errors[0] raise ValueError(f"{first.key_path}: {first.message}") return result.settings def get_settings( cwd: str | Path | None = None, *, refresh: bool = False, runtime: RuntimeSettingsState | Mapping[str, Any] | None = None, ) -> Settings: return get_settings_with_errors(cwd, refresh=refresh, runtime=runtime).settings def get_settings_with_errors( cwd: str | Path | None = None, *, refresh: bool = False, runtime: RuntimeSettingsState | Mapping[str, Any] | None = None, ) -> SettingsSnapshot: resolved_cwd = str(Path(cwd or os.getcwd()).expanduser().resolve()) project_root = str(get_project_root(resolved_cwd)) runtime_state = _coerce_runtime_state(runtime) cache_key = _cache_key(resolved_cwd, project_root, runtime_state) if not refresh and cache_key in _CACHE: return _CACHE[cache_key] raw: dict[str, Any] = {} sources: list[SettingsSourceSnapshot] = [] errors: list[SettingsError] = [] defaults = _default_raw_settings() raw = _merge_settings(raw, defaults) sources.append(SettingsSourceSnapshot("defaults", None, defaults)) for source in FILE_SETTING_SOURCES: path = get_settings_path_for_source(source, resolved_cwd) if path is None: continue if not path.is_file(): continue result = _parse_settings_file(path, source) errors.extend(result.errors) if result.settings is None: continue raw = _merge_settings(raw, result.settings) if result.settings: sources.append(SettingsSourceSnapshot(source, str(path), result.settings)) env_raw = _env_settings() if env_raw: raw = _merge_settings(raw, env_raw) sources.append(SettingsSourceSnapshot("envSettings", None, env_raw)) runtime_raw = _runtime_settings(runtime_state) if runtime_raw: raw = _merge_settings(raw, runtime_raw) sources.append(SettingsSourceSnapshot("runtimeSettings", None, runtime_raw)) settings, normalization_errors = _settings_from_raw(raw, "runtimeSettings", None) errors.extend(normalization_errors) snapshot = SettingsSnapshot( settings=settings, raw=raw, sources=sources, errors=errors, cwd=resolved_cwd, project_root=project_root, loaded_at=time.time(), ) _CACHE[cache_key] = snapshot return snapshot def get_setting( key: str, default: Any = None, *, cwd: str | Path | None = None, refresh: bool = False, runtime: RuntimeSettingsState | Mapping[str, Any] | None = None, ) -> Any: snapshot = get_settings_with_errors(cwd, refresh=refresh, runtime=runtime) return _get_path(snapshot.raw, key, snapshot.settings.get_path(key, default)) def update_setting( key: str, value: Any, *, cwd: str | Path | None = None, source: EditableSettingSource = "userSettings", ) -> SettingsSnapshot: if key not in _WRITABLE_SETTING_KEYS: raise ValueError(f"Unknown setting: {key}") existing = get_settings_for_source(source, cwd, strict=False) or {} update = _build_path_update(key, value) updated = _merge_for_update(existing, update) update_settings_for_source(source, updated, cwd=cwd) return notify_settings_changed(source, cwd) def update_settings_for_source( source: EditableSettingSource, settings: Mapping[str, Any], cwd: str | Path | None = None, ) -> SettingsSnapshot: if source not in EDITABLE_SOURCES: raise ValueError(f"source {source!r} is not editable in OpenSpace") path = get_settings_path_for_source(source, cwd) if path is None: raise ValueError(f"no settings path defined for source {source!r}") data = dict(settings) parse = _validate_settings_data(data, source, str(path), strict=True) fatal_update_errors = [ err for err in parse.errors if not err.message.startswith("Invalid permission rule") and not err.message.startswith("Non-string") ] if fatal_update_errors: first = fatal_update_errors[0] raise ValueError(f"{first.key_path}: {first.message}") _atomic_write_json(path, parse.settings or data) if source == "localSettings": _ensure_local_settings_gitignored(path) _INTERNAL_WRITES[str(path)] = time.time() return notify_settings_changed(source, cwd) save_settings_for_source = update_settings_for_source def get_effective_settings(cwd: str | Path | None = None) -> dict[str, Any]: return get_settings_with_errors(cwd).raw def is_settings_file(path: str | Path, cwd: str | Path | None = None) -> bool: candidate = Path(path).expanduser() try: candidate = candidate.resolve() except OSError: candidate = candidate.absolute() for source in FILE_SETTING_SOURCES: source_path = get_settings_path_for_source(source, cwd) if source_path is not None and candidate == source_path.resolve(): return True # Match OpenSpace's broad "settings under config dir" guard for FileEdit, while # keeping ordinary project files named settings.json editable. return ( candidate.name in {"settings.json", "settings.local.json"} and candidate.parent.name == ".openspace" ) def validate_settings_edit( path: str | Path, new_content: str, old_content: str | None = None, *, cwd: str | Path | None = None, ) -> str | None: if not is_settings_file(path, cwd): return None if old_content is not None: before = _validate_settings_content( old_content, "localSettings", str(path), strict=True, ) if before.settings is None or before.errors: return None after = _validate_settings_content( new_content, "localSettings", str(path), strict=True, ) if after.settings is not None and not after.errors: return None lines = [ "OpenSpace settings.json validation failed after edit:", *[f"- {err.key_path or ''}: {err.message}" for err in after.errors[:8]], "Do not update settings unless the final file is valid JSON matching the OpenSpace settings schema.", ] if len(after.errors) > 8: lines.insert(-1, f"... {len(after.errors) - 8} more validation errors") return "\n".join(lines) def subscribe_settings( callback: Callable[[SettingsSnapshot], Awaitable[None] | None], ) -> Callable[[], None]: _SUBSCRIBERS.append(callback) def unsubscribe() -> None: if callback in _SUBSCRIBERS: _SUBSCRIBERS.remove(callback) return unsubscribe def start_settings_watcher( cwd: str | Path | None = None, *, interval_s: float = 1.0, ) -> None: global _WATCH_TASK, _WATCH_CWD, _WATCH_INTERVAL_S, _WATCH_MTIMES if _WATCH_TASK is not None and not _WATCH_TASK.done(): return _WATCH_CWD = str(Path(cwd or os.getcwd()).expanduser().resolve()) _WATCH_INTERVAL_S = max(float(interval_s), 0.1) _WATCH_MTIMES = { str(path): _file_mtime(path) for path in _watched_paths(_WATCH_CWD) } try: loop = asyncio.get_running_loop() except RuntimeError: return _WATCH_TASK = loop.create_task(_watch_settings_loop()) async def stop_settings_watcher() -> None: global _WATCH_TASK task = _WATCH_TASK _WATCH_TASK = None if task is None: return task.cancel() try: await task except asyncio.CancelledError: pass def notify_settings_changed( source: SettingSource, cwd: str | Path | None = None, ) -> SettingsSnapshot: del source reset_settings_cache() snapshot = get_settings_with_errors(cwd, refresh=True) for callback in list(_SUBSCRIBERS): try: result = callback(snapshot) if asyncio.iscoroutine(result): try: asyncio.get_running_loop().create_task(result) except RuntimeError: pass except Exception: logger.debug("Settings subscriber failed", exc_info=True) return snapshot def reset_settings_cache() -> None: _CACHE.clear() def _watched_paths(cwd: str | Path | None) -> list[Path]: return [ path for source in FILE_SETTING_SOURCES for path in [get_settings_path_for_source(source, cwd)] if path is not None ] async def _watch_settings_loop() -> None: while True: await asyncio.sleep(_WATCH_INTERVAL_S) cwd = _WATCH_CWD or os.getcwd() changed = False for path in _watched_paths(cwd): key = str(path) current = _file_mtime(path) previous = _WATCH_MTIMES.get(key) if current != previous: _WATCH_MTIMES[key] = current changed = True if changed: notify_settings_changed("localSettings", cwd) def _file_mtime(path: Path) -> float | None: try: return path.stat().st_mtime except OSError: return None @dataclass(slots=True) class _ParseResult: settings: dict[str, Any] | None errors: list[SettingsError] def _parse_settings_file( path: Path, source: SettingSource, *, strict: bool = False, ) -> _ParseResult: try: content = path.read_text(encoding="utf-8") except OSError as exc: if strict: raise logger.warning("Failed to read settings file %s: %s", path, exc) return _ParseResult(None, []) return _validate_settings_content(content, source, str(path), strict=strict) def _validate_settings_content( content: str, source: SettingSource, path: str | None, *, strict: bool, ) -> _ParseResult: stripped = content.strip() if not stripped: return _ParseResult({}, []) try: data = json.loads(content) except json.JSONDecodeError as exc: return _ParseResult( None, [ SettingsError( source=source, path=path, key_path="", message=f"Invalid JSON: {exc.msg}", ) ], ) if not isinstance(data, dict): return _ParseResult( None, [ SettingsError( source=source, path=path, key_path="", message="Settings file must contain a JSON object", invalid_value=data, ) ], ) return _validate_settings_data(data, source, path, strict=strict) def _validate_settings_data( data: Mapping[str, Any], source: SettingSource, path: str | None, *, strict: bool, ) -> _ParseResult: working = dict(data) errors: list[SettingsError] = [] _filter_attachment_settings(working, source, path, errors, strict=strict) _filter_permission_rules(working, source, path, errors) known_errors = _validate_known_schema(working, source, path, strict=strict) errors.extend(known_errors) fatal_errors = [ err for err in known_errors if not (err.message.startswith("Unrecognized field") and not strict) ] if fatal_errors: return _ParseResult(None, errors) if source == "projectSettings" and "autoMemoryDirectory" in working: working = dict(working) working.pop("autoMemoryDirectory", None) errors.append( SettingsError( source=source, path=path, key_path="autoMemoryDirectory", message="autoMemoryDirectory is ignored in projectSettings for security", ) ) return _ParseResult(working, errors) def _filter_attachment_settings( data: MutableMapping[str, Any], source: SettingSource, path: str | None, errors: list[SettingsError], *, strict: bool, ) -> None: if strict: return attachments = data.get("attachments") if not isinstance(attachments, dict): return copied: dict[str, Any] = {} for key, value in attachments.items(): if key == "todoReminderEnabled": copied[key] = value continue errors.append( SettingsError( source=source, path=path, key_path=f"attachments.{key}", message=( "Unsupported attachment setting ignored; only " "attachments.todoReminderEnabled is public" ), invalid_value=value, ) ) data["attachments"] = copied def _filter_permission_rules( data: MutableMapping[str, Any], source: SettingSource, path: str | None, errors: list[SettingsError], ) -> None: permissions = data.get("permissions") if not isinstance(permissions, dict): return try: from openspace.grounding.core.permissions.types import parse_rule_value except Exception: parse_rule_value = None # type: ignore[assignment] copied = dict(permissions) for behavior in ("allow", "deny", "ask"): raw_rules = copied.get(behavior) if not isinstance(raw_rules, list): continue filtered: list[str] = [] for rule in raw_rules: if not isinstance(rule, str): errors.append( SettingsError( source=source, path=path, key_path=f"permissions.{behavior}", message=f"Non-string value in {behavior} array was removed", invalid_value=rule, ) ) continue if parse_rule_value is not None: try: parse_rule_value(rule) except ValueError as exc: errors.append( SettingsError( source=source, path=path, key_path=f"permissions.{behavior}", message=f'Invalid permission rule "{rule}" was skipped: {exc}', invalid_value=rule, ) ) continue filtered.append(rule) copied[behavior] = filtered data["permissions"] = copied def _validate_known_schema( data: Mapping[str, Any], source: SettingSource, path: str | None, *, strict: bool, ) -> list[SettingsError]: errors: list[SettingsError] = [] known_top = set(_default_raw_settings()) | {"$schema"} if strict: for key in data: if key not in known_top: errors.append( SettingsError( source=source, path=path, key_path=key, message=f"Unrecognized field: {key}", invalid_value=data.get(key), ) ) def err(key_path: str, message: str, value: Any) -> None: errors.append(SettingsError(source, path, key_path, message, value)) _expect_enum(data, "theme", THEME_OPTIONS, err) _expect_enum(data, "editorMode", EDITOR_MODE_OPTIONS, err) _expect_bool(data, "verbose", err) _expect_enum(data, "preferredNotifChannel", NOTIFICATION_CHANNEL_OPTIONS, err) _expect_bool(data, "autoCompactEnabled", err) _expect_bool(data, "fileCheckpointingEnabled", err) _expect_bool(data, "showTurnDuration", err) _expect_bool(data, "terminalProgressBarEnabled", err) _expect_bool(data, "todoFeatureEnabled", err) _expect_enum(data, "teammateMode", TEAMMATE_MODE_OPTIONS, err) _expect_optional_string(data, "model", err) _expect_optional_bool(data, "alwaysThinkingEnabled", err) _expect_optional_string(data, "language", err) _expect_bool(data, "autoMemoryEnabled", err) _expect_optional_string(data, "autoMemoryDirectory", err) _expect_optional_string(data, "outputStyle", err) _expect_optional_string(data, "$schema", err) permissions = data.get("permissions") if permissions is not None: if not isinstance(permissions, Mapping): err("permissions", "Expected object", permissions) else: for behavior in ("allow", "deny", "ask"): _expect_string_list(permissions, behavior, err, f"permissions.{behavior}") _expect_enum( permissions, "defaultMode", PERMISSION_MODE_OPTIONS, err, "permissions.defaultMode", ) _expect_string_list( permissions, "additionalDirectories", err, "permissions.additionalDirectories", ) hooks = data.get("hooks") if hooks is not None: _validate_hooks_schema(hooks, err) auto_dream = data.get("autoDream") if auto_dream is not None: if not isinstance(auto_dream, Mapping): err("autoDream", "Expected object", auto_dream) else: _expect_bool(auto_dream, "enabled", err, "autoDream.enabled") _expect_positive_number(auto_dream, "minHours", err, "autoDream.minHours") _expect_positive_int(auto_dream, "minSessions", err, "autoDream.minSessions") _expect_positive_number( auto_dream, "scanIntervalSeconds", err, "autoDream.scanIntervalSeconds", ) _expect_optional_string(auto_dream, "model", err, "autoDream.model") memory = data.get("memory") if memory is not None: if not isinstance(memory, Mapping): err("memory", "Expected object", memory) else: _expect_enum(memory, "mode", MEMORY_MODE_OPTIONS, err, "memory.mode") daily = memory.get("dailyLog") if daily is not None: if not isinstance(daily, Mapping): err("memory.dailyLog", "Expected object", daily) else: _expect_positive_int( daily, "retentionDays", err, "memory.dailyLog.retentionDays", ) _expect_bool( daily, "consolidateOnDream", err, "memory.dailyLog.consolidateOnDream", ) attachments = data.get("attachments") if attachments is not None: if not isinstance(attachments, Mapping): err("attachments", "Expected object", attachments) else: if strict: for key in attachments: if key != "todoReminderEnabled": err( f"attachments.{key}", f"Unrecognized field: {key}", attachments.get(key), ) _expect_bool(attachments, "todoReminderEnabled", err, "attachments.todoReminderEnabled") sandbox = data.get("sandbox") if sandbox is not None: _validate_sandbox_schema(sandbox, err) return errors def _validate_sandbox_schema( sandbox: Any, err: Callable[[str, str, Any], None], ) -> None: if not isinstance(sandbox, Mapping): err("sandbox", "Expected object", sandbox) return _expect_bool(sandbox, "enabled", err, "sandbox.enabled") _expect_bool(sandbox, "failIfUnavailable", err, "sandbox.failIfUnavailable") if "enabledPlatforms" in sandbox: value = sandbox["enabledPlatforms"] if not isinstance(value, list) or any( item not in SANDBOX_PLATFORM_OPTIONS for item in value ): err( "sandbox.enabledPlatforms", "Expected array containing only: " + ", ".join(SANDBOX_PLATFORM_OPTIONS), value, ) _expect_bool( sandbox, "autoAllowBashIfSandboxed", err, "sandbox.autoAllowBashIfSandboxed", ) _expect_bool( sandbox, "allowUnsandboxedCommands", err, "sandbox.allowUnsandboxedCommands", ) _expect_bool( sandbox, "enableWeakerNestedSandbox", err, "sandbox.enableWeakerNestedSandbox", ) _expect_bool( sandbox, "enableWeakerNetworkIsolation", err, "sandbox.enableWeakerNetworkIsolation", ) _expect_string_list( sandbox, "excludedCommands", err, "sandbox.excludedCommands", ) ignore = sandbox.get("ignoreViolations") if ignore is not None: if not isinstance(ignore, Mapping): err("sandbox.ignoreViolations", "Expected object", ignore) else: for key, value in ignore.items(): if not isinstance(key, str) or not isinstance(value, list) or any( not isinstance(item, str) for item in value ): err( "sandbox.ignoreViolations", "Expected object mapping strings to arrays of strings", ignore, ) break network = sandbox.get("network") if network is not None: if not isinstance(network, Mapping): err("sandbox.network", "Expected object", network) else: _expect_string_list( network, "allowedDomains", err, "sandbox.network.allowedDomains", ) _expect_bool( network, "allowManagedDomainsOnly", err, "sandbox.network.allowManagedDomainsOnly", ) _expect_string_list( network, "allowUnixSockets", err, "sandbox.network.allowUnixSockets", ) _expect_bool( network, "allowAllUnixSockets", err, "sandbox.network.allowAllUnixSockets", ) _expect_bool( network, "allowLocalBinding", err, "sandbox.network.allowLocalBinding", ) _expect_non_negative_int( network, "httpProxyPort", err, "sandbox.network.httpProxyPort", ) _expect_non_negative_int( network, "socksProxyPort", err, "sandbox.network.socksProxyPort", ) filesystem = sandbox.get("filesystem") if filesystem is not None: if not isinstance(filesystem, Mapping): err("sandbox.filesystem", "Expected object", filesystem) else: for key in ("allowWrite", "denyWrite", "denyRead", "allowRead"): _expect_string_list( filesystem, key, err, f"sandbox.filesystem.{key}", ) _expect_bool( filesystem, "allowManagedReadPathsOnly", err, "sandbox.filesystem.allowManagedReadPathsOnly", ) ripgrep = sandbox.get("ripgrep") if ripgrep is not None: if not isinstance(ripgrep, Mapping): err("sandbox.ripgrep", "Expected object", ripgrep) else: _expect_optional_string( ripgrep, "command", err, "sandbox.ripgrep.command", ) _expect_string_list(ripgrep, "args", err, "sandbox.ripgrep.args") def _validate_hooks_schema( hooks: Any, err: Callable[[str, str, Any], None], ) -> None: if not isinstance(hooks, Mapping): err("hooks", "Expected object", hooks) return try: from openspace.services.tooling.hooks import is_hook_event except Exception: is_hook_event = lambda value: isinstance( # type: ignore[assignment] value, str, ) for event_name, matchers in hooks.items(): event_path = f"hooks.{event_name}" if not isinstance(event_name, str) or not is_hook_event(event_name): err(event_path, f"Unsupported hook event: {event_name}", matchers) continue if not isinstance(matchers, list): err(event_path, "Expected array of matcher objects", matchers) continue for index, matcher in enumerate(matchers): matcher_path = f"{event_path}.{index}" if not isinstance(matcher, Mapping): err(matcher_path, "Expected matcher object", matcher) continue if "matcher" in matcher and not isinstance( matcher.get("matcher"), str, ): err( f"{matcher_path}.matcher", "Expected string", matcher.get("matcher"), ) hook_items = matcher.get("hooks") if not isinstance(hook_items, list): err(f"{matcher_path}.hooks", "Expected array of hooks", hook_items) continue for hook_index, hook in enumerate(hook_items): hook_path = f"{matcher_path}.hooks.{hook_index}" if not isinstance(hook, Mapping): err(hook_path, "Expected hook object", hook) continue hook_type = str(hook.get("type") or "command") if hook_type not in {"command", "prompt", "http", "agent"}: err( f"{hook_path}.type", "Expected command, prompt, http, or agent", hook.get("type"), ) if hook_type == "command" and not isinstance(hook.get("command"), str): err( f"{hook_path}.command", "Expected string", hook.get("command"), ) if hook_type in {"prompt", "agent"} and not isinstance( hook.get("prompt"), str, ): err(f"{hook_path}.prompt", "Expected string", hook.get("prompt")) if hook_type == "http" and not isinstance(hook.get("url"), str): err(f"{hook_path}.url", "Expected string", hook.get("url")) def _settings_from_raw( raw: Mapping[str, Any], source: SettingSource, path: str | None, ) -> tuple[Settings, list[SettingsError]]: parse = _validate_settings_data(raw, source, path, strict=False) data = parse.settings or _default_raw_settings() permissions_raw = _as_mapping(data.get("permissions")) auto_dream_raw = _as_mapping(data.get("autoDream")) memory_raw = _as_mapping(data.get("memory")) daily_log_raw = _as_mapping(memory_raw.get("dailyLog")) attachments_raw = _as_mapping(data.get("attachments")) sandbox_raw = _as_mapping(data.get("sandbox")) sandbox_network_raw = _as_mapping(sandbox_raw.get("network")) sandbox_filesystem_raw = _as_mapping(sandbox_raw.get("filesystem")) sandbox_ripgrep_raw = _as_mapping(sandbox_raw.get("ripgrep")) auto_dream = AutoDreamSettings( enabled=bool(_value_or(auto_dream_raw.get("enabled"), False)), min_hours=float(_value_or(auto_dream_raw.get("minHours"), 24.0)), min_sessions=int(_value_or(auto_dream_raw.get("minSessions"), 5)), scan_interval_seconds=float( _value_or(auto_dream_raw.get("scanIntervalSeconds"), 600.0) ), model=_optional_str(auto_dream_raw.get("model")), ) settings = Settings( ui=UISettings( theme=str(_value_or(data.get("theme"), "dark")), editor_mode=str(_value_or(data.get("editorMode"), "normal")), verbose=bool(_value_or(data.get("verbose"), False)), preferred_notif_channel=str( _value_or(data.get("preferredNotifChannel"), "auto") ), show_turn_duration=bool(_value_or(data.get("showTurnDuration"), True)), terminal_progress_bar_enabled=bool( _value_or(data.get("terminalProgressBarEnabled"), True) ), ), engine=EngineSettings( auto_compact_enabled=bool( _value_or(data.get("autoCompactEnabled"), True) ), file_checkpointing_enabled=bool( _value_or(data.get("fileCheckpointingEnabled"), True) ), todo_feature_enabled=bool(_value_or(data.get("todoFeatureEnabled"), True)), model=_optional_str(data.get("model")), always_thinking_enabled=_optional_bool(data.get("alwaysThinkingEnabled")), permissions=PermissionSettings( allow=_string_list(permissions_raw.get("allow")), deny=_string_list(permissions_raw.get("deny")), ask=_string_list(permissions_raw.get("ask")), default_mode=str( _value_or(permissions_raw.get("defaultMode"), "default") ), additional_directories=_string_list( permissions_raw.get("additionalDirectories") ), ), language=_optional_str(data.get("language")), auto_memory_enabled=bool(_value_or(data.get("autoMemoryEnabled"), True)), auto_memory_directory=_optional_str(data.get("autoMemoryDirectory")), auto_dream=auto_dream, memory=MemorySettings( mode=str(_value_or(memory_raw.get("mode"), "direct")), daily_log=DailyLogSettings( retention_days=int( _value_or(daily_log_raw.get("retentionDays"), 90) ), consolidate_on_dream=bool( _value_or(daily_log_raw.get("consolidateOnDream"), True) ), ), ), sandbox=SandboxSettings( enabled=bool(_value_or(sandbox_raw.get("enabled"), False)), fail_if_unavailable=bool( _value_or(sandbox_raw.get("failIfUnavailable"), False) ), enabled_platforms=( _string_list(sandbox_raw.get("enabledPlatforms")) if isinstance(sandbox_raw.get("enabledPlatforms"), list) else None ), auto_allow_bash_if_sandboxed=bool( _value_or(sandbox_raw.get("autoAllowBashIfSandboxed"), True) ), allow_unsandboxed_commands=bool( _value_or(sandbox_raw.get("allowUnsandboxedCommands"), True) ), network=SandboxNetworkSettings( allowed_domains=_string_list( sandbox_network_raw.get("allowedDomains") ), allow_managed_domains_only=bool( _value_or( sandbox_network_raw.get("allowManagedDomainsOnly"), False, ) ), allow_unix_sockets=_string_list( sandbox_network_raw.get("allowUnixSockets") ), allow_all_unix_sockets=bool( _value_or(sandbox_network_raw.get("allowAllUnixSockets"), False) ), allow_local_binding=bool( _value_or(sandbox_network_raw.get("allowLocalBinding"), False) ), http_proxy_port=_optional_int( sandbox_network_raw.get("httpProxyPort") ), socks_proxy_port=_optional_int( sandbox_network_raw.get("socksProxyPort") ), ), filesystem=SandboxFilesystemSettings( allow_write=_string_list(sandbox_filesystem_raw.get("allowWrite")), deny_write=_string_list(sandbox_filesystem_raw.get("denyWrite")), deny_read=_string_list(sandbox_filesystem_raw.get("denyRead")), allow_read=_string_list(sandbox_filesystem_raw.get("allowRead")), allow_managed_read_paths_only=bool( _value_or( sandbox_filesystem_raw.get("allowManagedReadPathsOnly"), False, ) ), ), ignore_violations=_ignore_violations( sandbox_raw.get("ignoreViolations") ), enable_weaker_nested_sandbox=bool( _value_or(sandbox_raw.get("enableWeakerNestedSandbox"), False) ), enable_weaker_network_isolation=bool( _value_or(sandbox_raw.get("enableWeakerNetworkIsolation"), False) ), excluded_commands=_string_list(sandbox_raw.get("excludedCommands")), ripgrep_command=_optional_str(sandbox_ripgrep_raw.get("command")), ripgrep_args=_string_list(sandbox_ripgrep_raw.get("args")), ), ), experimental=ExperimentalSettings( teammate_mode=str(_value_or(data.get("teammateMode"), "auto")), output_style=str(_value_or(data.get("outputStyle"), "default")), attachments=AttachmentSettings( todo_reminder_enabled=bool( _value_or(attachments_raw.get("todoReminderEnabled"), True) ), ), ), schema=_optional_str(data.get("$schema")), ) return settings, parse.errors def _default_raw_settings() -> dict[str, Any]: return { "theme": "dark", "editorMode": "normal", "verbose": False, "preferredNotifChannel": "auto", "autoCompactEnabled": True, "fileCheckpointingEnabled": True, "showTurnDuration": True, "terminalProgressBarEnabled": True, "todoFeatureEnabled": True, "teammateMode": "auto", "model": None, "alwaysThinkingEnabled": None, "permissions": { "allow": [], "deny": [], "ask": [], "defaultMode": "default", "additionalDirectories": [], }, "hooks": {}, "language": None, "autoMemoryEnabled": True, "autoMemoryDirectory": None, "autoDream": { "enabled": False, "minHours": 24.0, "minSessions": 5, "scanIntervalSeconds": 600.0, "model": None, }, "memory": { "mode": "direct", "dailyLog": { "retentionDays": 90, "consolidateOnDream": True, }, }, "sandbox": { "enabled": False, "failIfUnavailable": False, "autoAllowBashIfSandboxed": True, "allowUnsandboxedCommands": True, "network": { "allowedDomains": [], "allowManagedDomainsOnly": False, "allowUnixSockets": [], "allowAllUnixSockets": False, "allowLocalBinding": False, "httpProxyPort": None, "socksProxyPort": None, }, "filesystem": { "allowWrite": [], "denyWrite": [], "denyRead": [], "allowRead": [], "allowManagedReadPathsOnly": False, }, "ignoreViolations": {}, "enableWeakerNestedSandbox": False, "enableWeakerNetworkIsolation": False, "excludedCommands": [], }, "outputStyle": "default", "attachments": { "todoReminderEnabled": True, }, } def _env_settings() -> dict[str, Any]: out: dict[str, Any] = {} model = os.environ.get("OPENSPACE_MODEL") if model: out["model"] = model theme = os.environ.get("OPENSPACE_THEME") if theme: out["theme"] = theme auto_compact = _env_bool("OPENSPACE_AUTO_COMPACT_ENABLED") if auto_compact is not None: out["autoCompactEnabled"] = auto_compact if _is_truthy(os.environ.get("DISABLE_COMPACT")) or _is_truthy( os.environ.get("DISABLE_AUTO_COMPACT") ): out["autoCompactEnabled"] = False auto_mem_disabled = os.environ.get("OPENSPACE_DISABLE_AUTO_MEMORY") if _is_truthy(auto_mem_disabled): out["autoMemoryEnabled"] = False elif _is_defined_falsy(auto_mem_disabled): out["autoMemoryEnabled"] = True if _is_truthy(os.environ.get("OPENSPACE_SIMPLE")): out["autoMemoryEnabled"] = False auto_mem_dir = os.environ.get("OPENSPACE_AUTO_MEMORY_DIRECTORY") if auto_mem_dir: out["autoMemoryDirectory"] = auto_mem_dir auto_dream = _env_bool("OPENSPACE_AUTO_DREAM_ENABLED") disabled_dream = os.environ.get("OPENSPACE_DISABLE_AUTO_DREAM") if _is_truthy(disabled_dream): auto_dream = False elif _is_defined_falsy(disabled_dream): auto_dream = True auto_dream_obj: dict[str, Any] = {} if auto_dream is not None: auto_dream_obj["enabled"] = auto_dream _env_number( "OPENSPACE_AUTO_DREAM_MIN_HOURS", auto_dream_obj, "minHours", int_only=False, ) _env_number( "OPENSPACE_AUTO_DREAM_MIN_SESSIONS", auto_dream_obj, "minSessions", int_only=True, ) _env_number( "OPENSPACE_AUTO_DREAM_SCAN_INTERVAL_SECONDS", auto_dream_obj, "scanIntervalSeconds", int_only=False, ) dream_model = os.environ.get("OPENSPACE_MEMORY_DREAM_MODEL") if dream_model: auto_dream_obj["model"] = dream_model if auto_dream_obj: out["autoDream"] = auto_dream_obj memory_obj: dict[str, Any] = {} memory_mode = os.environ.get("OPENSPACE_MEMORY_MODE") if memory_mode: memory_obj["mode"] = _normalize_memory_mode(memory_mode) daily_obj: dict[str, Any] = {} _env_number( "OPENSPACE_MEMORY_DAILY_LOG_RETENTION_DAYS", daily_obj, "retentionDays", int_only=True, ) daily_consolidate = _env_bool("OPENSPACE_MEMORY_DAILY_LOG_CONSOLIDATE_ON_DREAM") if daily_consolidate is not None: daily_obj["consolidateOnDream"] = daily_consolidate if daily_obj: memory_obj["dailyLog"] = daily_obj if memory_obj: out["memory"] = memory_obj if _is_truthy(os.environ.get("OPENSPACE_DISABLE_THINKING")): out["alwaysThinkingEnabled"] = False thinking = _env_bool("OPENSPACE_ALWAYS_THINKING_ENABLED") if thinking is not None: out["alwaysThinkingEnabled"] = thinking todo = _env_bool("OPENSPACE_TODO_FEATURE_ENABLED") if todo is not None: out["todoFeatureEnabled"] = todo sandbox_obj: dict[str, Any] = {} sandbox_enabled = _env_bool("OPENSPACE_SANDBOX_ENABLED") if sandbox_enabled is not None: sandbox_obj["enabled"] = sandbox_enabled fail_unavailable = _env_bool("OPENSPACE_SANDBOX_FAIL_IF_UNAVAILABLE") if fail_unavailable is not None: sandbox_obj["failIfUnavailable"] = fail_unavailable if sandbox_obj: out["sandbox"] = sandbox_obj return out def _runtime_settings(runtime: RuntimeSettingsState) -> dict[str, Any]: out: dict[str, Any] = {} if runtime.cli_model: out["model"] = runtime.cli_model if runtime.permission_mode: out["permissions"] = {"defaultMode": runtime.permission_mode} if runtime.always_thinking_enabled is not None: out["alwaysThinkingEnabled"] = runtime.always_thinking_enabled return out def _coerce_runtime_state( runtime: RuntimeSettingsState | Mapping[str, Any] | None, ) -> RuntimeSettingsState: if runtime is None: return RuntimeSettingsState( remote_session=_is_truthy(os.environ.get("OPENSPACE_REMOTE")), remote_memory_dir=( Path(os.environ["OPENSPACE_REMOTE_MEMORY_DIR"]).expanduser().resolve() if os.environ.get("OPENSPACE_REMOTE_MEMORY_DIR") else None ), ) if isinstance(runtime, RuntimeSettingsState): return runtime remote_memory_dir = runtime.get("remote_memory_dir") return RuntimeSettingsState( remote_session=bool(runtime.get("remote_session", False)), remote_memory_dir=( Path(remote_memory_dir).expanduser().resolve() if remote_memory_dir else None ), cli_model=_optional_str(runtime.get("cli_model")), permission_mode=_optional_str(runtime.get("permission_mode")), always_thinking_enabled=_optional_bool(runtime.get("always_thinking_enabled")), ) def _cache_key( cwd: str, project_root: str, runtime: RuntimeSettingsState, ) -> tuple[Any, ...]: file_state = tuple( (str(path), _file_mtime(path)) for path in _watched_paths(cwd) ) env_state = tuple( (key, os.environ.get(key)) for key in ( "OPENSPACE_CONFIG_HOME", "OPENSPACE_MODEL", "OPENSPACE_THEME", "DISABLE_COMPACT", "DISABLE_AUTO_COMPACT", "OPENSPACE_AUTO_COMPACT_ENABLED", "OPENSPACE_DISABLE_AUTO_MEMORY", "OPENSPACE_SIMPLE", "OPENSPACE_AUTO_MEMORY_DIRECTORY", "OPENSPACE_AUTO_DREAM_ENABLED", "OPENSPACE_DISABLE_AUTO_DREAM", "OPENSPACE_AUTO_DREAM_MIN_HOURS", "OPENSPACE_AUTO_DREAM_MIN_SESSIONS", "OPENSPACE_AUTO_DREAM_SCAN_INTERVAL_SECONDS", "OPENSPACE_MEMORY_DREAM_MODEL", "OPENSPACE_MEMORY_MODE", "OPENSPACE_MEMORY_DAILY_LOG_RETENTION_DAYS", "OPENSPACE_MEMORY_DAILY_LOG_CONSOLIDATE_ON_DREAM", "OPENSPACE_DISABLE_THINKING", "OPENSPACE_ALWAYS_THINKING_ENABLED", "OPENSPACE_TODO_FEATURE_ENABLED", "OPENSPACE_SANDBOX_ENABLED", "OPENSPACE_SANDBOX_FAIL_IF_UNAVAILABLE", ) ) runtime_state = ( runtime.remote_session, str(runtime.remote_memory_dir) if runtime.remote_memory_dir else None, runtime.cli_model, runtime.permission_mode, runtime.always_thinking_enabled, ) return (cwd, project_root, file_state, env_state, runtime_state) def _atomic_write_json(path: Path, data: Mapping[str, Any]) -> None: path.parent.mkdir(parents=True, exist_ok=True) fd, tmp_name = tempfile.mkstemp( prefix=".settings-", suffix=".json.tmp", dir=str(path.parent), ) tmp_path = Path(tmp_name) try: with os.fdopen(fd, "w", encoding="utf-8") as handle: json.dump(dict(data), handle, indent=2, ensure_ascii=False) handle.write("\n") os.replace(tmp_path, path) except Exception: try: tmp_path.unlink() except OSError: pass raise def _ensure_local_settings_gitignored(path: Path) -> None: gitignore = path.parent.parent / ".gitignore" rel = ".openspace/settings.local.json" try: existing = gitignore.read_text(encoding="utf-8") if gitignore.exists() else "" if rel not in {line.strip() for line in existing.splitlines()}: with gitignore.open("a", encoding="utf-8") as handle: if existing and not existing.endswith("\n"): handle.write("\n") handle.write(rel + "\n") except OSError: logger.debug("Failed to update %s", gitignore, exc_info=True) def _merge_settings(base: Mapping[str, Any], update: Mapping[str, Any]) -> dict[str, Any]: result = dict(base) for key, value in update.items(): old_value = result.get(key) if isinstance(old_value, Mapping) and isinstance(value, Mapping): result[key] = _merge_settings(old_value, value) elif isinstance(old_value, list) and isinstance(value, list): result[key] = _dedupe([*old_value, *value]) else: result[key] = value return result def _merge_for_update(base: Mapping[str, Any], update: Mapping[str, Any]) -> dict[str, Any]: result = dict(base) for key, value in update.items(): if value is None: result.pop(key, None) continue old_value = result.get(key) if isinstance(old_value, Mapping) and isinstance(value, Mapping): nested = _merge_for_update(old_value, value) if nested: result[key] = nested else: result.pop(key, None) else: result[key] = value return result def _build_path_update(key: str, value: Any) -> dict[str, Any]: parts = [part for part in key.split(".") if part] if not parts: raise ValueError("setting key must not be empty") current: dict[str, Any] = {parts[-1]: value} for part in reversed(parts[:-1]): current = {part: current} return current def _get_path(data: Mapping[str, Any], key: str, default: Any = None) -> Any: current: Any = data for part in key.split("."): if isinstance(current, Mapping) and part in current: current = current[part] else: return default return current def _dedupe(values: Sequence[Any]) -> list[Any]: result: list[Any] = [] for value in values: if value not in result: result.append(value) return result def _as_mapping(value: Any) -> Mapping[str, Any]: return value if isinstance(value, Mapping) else {} def _string_list(value: Any) -> list[str]: return [item for item in value if isinstance(item, str)] if isinstance(value, list) else [] def _optional_str(value: Any) -> str | None: return value if isinstance(value, str) else None def _optional_bool(value: Any) -> bool | None: return value if isinstance(value, bool) else None def _optional_int(value: Any) -> int | None: return value if isinstance(value, int) and not isinstance(value, bool) else None def _ignore_violations(value: Any) -> dict[str, list[str]]: if not isinstance(value, Mapping): return {} out: dict[str, list[str]] = {} for key, entries in value.items(): if isinstance(key, str): out[key] = _string_list(entries) return out def _value_or(value: Any, default: Any) -> Any: return default if value is None else value def _normalize_memory_mode(value: str) -> str: raw = value.strip().lower() if raw in {"daily-log", "dailylog", "logs"}: return "daily_log" return raw def _is_truthy(value: str | None) -> bool: return value is not None and value.strip().lower() in {"1", "true", "yes", "on"} def _is_defined_falsy(value: str | None) -> bool: return value is not None and value.strip().lower() in {"0", "false", "no", "off", ""} def _env_bool(name: str) -> bool | None: value = os.environ.get(name) if _is_truthy(value): return True if _is_defined_falsy(value): return False return None def _env_number(name: str, target: dict[str, Any], key: str, *, int_only: bool) -> None: raw = os.environ.get(name) if not raw: return try: parsed: Any = int(raw) if int_only else float(raw) except ValueError: return if parsed > 0: target[key] = parsed def _expect_bool( data: Mapping[str, Any], key: str, err: Callable[[str, str, Any], None], key_path: str | None = None, ) -> None: if key in data and not isinstance(data[key], bool): err(key_path or key, "Expected boolean", data[key]) def _expect_optional_bool( data: Mapping[str, Any], key: str, err: Callable[[str, str, Any], None], key_path: str | None = None, ) -> None: if key in data and data[key] is not None and not isinstance(data[key], bool): err(key_path or key, "Expected boolean or null", data[key]) def _expect_optional_string( data: Mapping[str, Any], key: str, err: Callable[[str, str, Any], None], key_path: str | None = None, ) -> None: if key in data and data[key] is not None and not isinstance(data[key], str): err(key_path or key, "Expected string or null", data[key]) def _expect_enum( data: Mapping[str, Any], key: str, options: Sequence[str], err: Callable[[str, str, Any], None], key_path: str | None = None, ) -> None: if key in data and data[key] not in options: err(key_path or key, f"Invalid value. Expected one of: {', '.join(options)}", data[key]) def _expect_string_list( data: Mapping[str, Any], key: str, err: Callable[[str, str, Any], None], key_path: str | None = None, ) -> None: if key not in data: return value = data[key] if not isinstance(value, list) or any(not isinstance(item, str) for item in value): err(key_path or key, "Expected array of strings", value) def _expect_positive_number( data: Mapping[str, Any], key: str, err: Callable[[str, str, Any], None], key_path: str | None = None, ) -> None: if key not in data: return value = data[key] if not isinstance(value, (int, float)) or isinstance(value, bool) or value <= 0: err(key_path or key, "Expected positive number", value) def _expect_positive_int( data: Mapping[str, Any], key: str, err: Callable[[str, str, Any], None], key_path: str | None = None, ) -> None: if key not in data: return value = data[key] if not isinstance(value, int) or isinstance(value, bool) or value <= 0: err(key_path or key, "Expected positive integer", value) def _expect_non_negative_int( data: Mapping[str, Any], key: str, err: Callable[[str, str, Any], None], key_path: str | None = None, ) -> None: if key not in data or data[key] is None: return value = data[key] if not isinstance(value, int) or isinstance(value, bool) or value < 0: err(key_path or key, "Expected non-negative integer or null", value) __all__ = [ "AttachmentSettings", "AutoDreamSettings", "DailyLogSettings", "EditableSettingSource", "EngineSettings", "ExperimentalSettings", "MemorySettings", "PermissionSettings", "RuntimeSettingsState", "SandboxFilesystemSettings", "SandboxNetworkSettings", "SandboxSettings", "SettingSource", "Settings", "SettingsError", "SettingsSnapshot", "SettingsSourceSnapshot", "UISettings", "get_effective_settings", "get_openspace_config_home_dir", "get_project_root", "get_setting", "get_settings", "get_settings_for_source", "get_settings_path_for_source", "get_settings_with_errors", "is_settings_file", "notify_settings_changed", "reset_settings_cache", "save_settings_for_source", "start_settings_watcher", "stop_settings_watcher", "subscribe_settings", "update_setting", "update_settings_for_source", "validate_settings_edit", ]