From 1bebfb4e8895102d1ae990a9d25bfa37f648c68a Mon Sep 17 00:00:00 2001 From: Jer Miller Date: Sat, 9 May 2026 13:13:13 -0600 Subject: [PATCH] feat(sol-initiated): per-category self-mute clear markers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename category_self_mute_clear_marker_ts (scalar) to category_self_mute_clear_markers (dict[str, int]). The lode-3 settings UI needs to clear self-mute per category, not globally. No back-compat shim — lode 1 just shipped and there are no users to migrate. Adds compute_category_mute_state() helper so policy and the new settings API share one source of truth for mute state. --- solstone/convey/sol_initiated/policy.py | 32 ++- solstone/convey/sol_initiated/settings.py | 288 +++++++++++++++++++--- solstone/think/journal_default.json | 9 +- tests/test_chat_stream_sol_initiated.py | 2 +- tests/test_sol_initiated_policy.py | 33 ++- 5 files changed, 323 insertions(+), 41 deletions(-) diff --git a/solstone/convey/sol_initiated/policy.py b/solstone/convey/sol_initiated/policy.py index 07603f50c..aa1ec853f 100644 --- a/solstone/convey/sol_initiated/policy.py +++ b/solstone/convey/sol_initiated/policy.py @@ -6,6 +6,7 @@ from __future__ import annotations from datetime import datetime +from typing import TypedDict from solstone.convey.sol_initiated.copy import ( KIND_OWNER_CHAT_DISMISSED, @@ -20,6 +21,11 @@ from solstone.convey.sol_initiated.dedup import _is_live_for_dedup from solstone.convey.sol_initiated.settings import SolVoiceSettings +class CategoryMuteState(TypedDict): + muted: bool + expires_ts: int | None + + def check_mute_window( settings: SolVoiceSettings, now_local_dt: datetime, @@ -70,11 +76,25 @@ def check_category_self_mute( now_ms: int, ) -> str | None: """Throttle a category after a recent owner dismissal.""" + state = compute_category_mute_state(settings, events_today, category, now_ms) + if state["muted"]: + return THROTTLE_CATEGORY_SELF_MUTE + return None + + +def compute_category_mute_state( + settings: SolVoiceSettings, + events_today: list[dict], + category: str, + now_ms: int, +) -> CategoryMuteState: + """Return the current self-mute state for a category.""" mute_ms = settings.category_self_mute_hours * 3_600_000 if mute_ms <= 0: - return None + return {"muted": False, "expires_ts": None} request_categories: dict[str, str] = {} + latest_dismissed_ts: int | None = None for event in events_today: kind = event.get("kind") if kind == KIND_SOL_CHAT_REQUEST: @@ -85,14 +105,18 @@ def check_category_self_mute( if kind != KIND_OWNER_CHAT_DISMISSED: continue dismissed_ts = int(event.get("ts", 0) or 0) - if dismissed_ts <= settings.category_self_mute_clear_marker_ts: + clear_marker_ts = settings.category_self_mute_clear_markers.get(category, 0) + if dismissed_ts <= clear_marker_ts: continue if now_ms - dismissed_ts > mute_ms: continue request_category = request_categories.get(str(event.get("request_id") or "")) if request_category == category: - return THROTTLE_CATEGORY_SELF_MUTE - return None + latest_dismissed_ts = max(latest_dismissed_ts or 0, dismissed_ts) + + if latest_dismissed_ts is None: + return {"muted": False, "expires_ts": None} + return {"muted": True, "expires_ts": latest_dismissed_ts + mute_ms} def check_category_cap( diff --git a/solstone/convey/sol_initiated/settings.py b/solstone/convey/sol_initiated/settings.py index 95b29a3ed..62e557eaf 100644 --- a/solstone/convey/sol_initiated/settings.py +++ b/solstone/convey/sol_initiated/settings.py @@ -5,14 +5,22 @@ from __future__ import annotations +import copy +import fcntl +import json import logging -from dataclasses import dataclass +import os +import tempfile +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any from solstone.convey.sol_initiated.copy import CATEGORY_CAP_DEFAULTS from solstone.convey.sol_initiated.dedup import parse_dedupe_window -from solstone.think.utils import get_config +from solstone.think.utils import get_config, get_journal logger = logging.getLogger(__name__) +_MISSING = object() @dataclass(frozen=True) @@ -29,8 +37,34 @@ class SolVoiceSettings: rate_floor_minutes: int mute_window: MuteWindowSettings category_self_mute_hours: int - category_self_mute_clear_marker_ts: int default_dedupe_window: str + category_self_mute_clear_markers: dict[str, int] = field(default_factory=dict) + system_notifications_macos: bool = False + system_notifications_linux: bool = False + debug_show_throttled: bool = False + + def to_dict(self) -> dict[str, Any]: + """Return the on-disk ``sol_voice`` shape.""" + return { + "daily_cap": self.daily_cap, + "category_caps": dict(self.category_caps), + "rate_floor_minutes": self.rate_floor_minutes, + "mute_window": { + "enabled": self.mute_window.enabled, + "start_hour_local": self.mute_window.start_hour_local, + "end_hour_local": self.mute_window.end_hour_local, + }, + "category_self_mute_hours": self.category_self_mute_hours, + "category_self_mute_clear_markers": dict( + self.category_self_mute_clear_markers + ), + "default_dedupe_window": self.default_dedupe_window, + "system_notifications": { + "macos": self.system_notifications_macos, + "linux": self.system_notifications_linux, + }, + "debug_show_throttled": self.debug_show_throttled, + } DEFAULT_SETTINGS = SolVoiceSettings( @@ -43,65 +77,146 @@ DEFAULT_SETTINGS = SolVoiceSettings( end_hour_local=7, ), category_self_mute_hours=24, - category_self_mute_clear_marker_ts=0, + category_self_mute_clear_markers={}, default_dedupe_window="24h", + system_notifications_macos=False, + system_notifications_linux=False, + debug_show_throttled=False, ) def load_settings() -> SolVoiceSettings: """Load sol-initiated chat settings, falling back field-by-field.""" - raw_root = get_config().get("sol_voice") + return _parse_settings(get_config().get("sol_voice"), strict=False) + + +def save_settings(updates: dict[str, Any]) -> SolVoiceSettings: + """Deep-merge and persist sol-initiated chat settings.""" + if not isinstance(updates, dict): + raise ValueError("sol_voice update must be an object") + + config = get_config() + raw_root = config.get("sol_voice") if not isinstance(raw_root, dict): - _warn_invalid("sol_voice", raw_root) raw_root = {} - raw_mute = raw_root.get("mute_window") - if not isinstance(raw_mute, dict): - _warn_invalid("mute_window", raw_mute) + merged = _deep_merge(raw_root, updates) + settings = _parse_settings(merged, strict=True) + config["sol_voice"] = settings.to_dict() + _write_config_atomic(config) + return settings + + +def _parse_settings(raw_root: object, *, strict: bool) -> SolVoiceSettings: + if not isinstance(raw_root, dict): + _reject("sol_voice", raw_root, strict) + raw_root = {} + + raw_mute = raw_root.get("mute_window", _MISSING) + if raw_mute is _MISSING: + raw_mute = {} + elif not isinstance(raw_mute, dict): + _reject("mute_window", raw_mute, strict) raw_mute = {} + raw_notifications = raw_root.get("system_notifications", _MISSING) + if raw_notifications is _MISSING: + raw_notifications = {} + elif not isinstance(raw_notifications, dict): + _reject("system_notifications", raw_notifications, strict) + raw_notifications = {} + + if strict: + _validate_known_keys( + "sol_voice", + raw_root, + { + "daily_cap", + "category_caps", + "rate_floor_minutes", + "mute_window", + "category_self_mute_hours", + "category_self_mute_clear_markers", + "default_dedupe_window", + "system_notifications", + "debug_show_throttled", + }, + ) + _validate_known_keys( + "mute_window", + raw_mute, + {"enabled", "start_hour_local", "end_hour_local"}, + ) + _validate_known_keys( + "system_notifications", raw_notifications, {"macos", "linux"} + ) + return SolVoiceSettings( daily_cap=_nonnegative_int( "daily_cap", - raw_root.get("daily_cap"), + raw_root.get("daily_cap", _MISSING), DEFAULT_SETTINGS.daily_cap, + strict, ), - category_caps=_category_caps(raw_root.get("category_caps")), + category_caps=_category_caps(raw_root.get("category_caps", _MISSING), strict), rate_floor_minutes=_nonnegative_int( "rate_floor_minutes", - raw_root.get("rate_floor_minutes"), + raw_root.get("rate_floor_minutes", _MISSING), DEFAULT_SETTINGS.rate_floor_minutes, + strict, ), mute_window=MuteWindowSettings( enabled=_bool( "mute_window.enabled", - raw_mute.get("enabled"), + raw_mute.get("enabled", _MISSING), DEFAULT_SETTINGS.mute_window.enabled, + strict, ), start_hour_local=_hour( "mute_window.start_hour_local", - raw_mute.get("start_hour_local"), + raw_mute.get("start_hour_local", _MISSING), DEFAULT_SETTINGS.mute_window.start_hour_local, + strict, ), end_hour_local=_hour( "mute_window.end_hour_local", - raw_mute.get("end_hour_local"), + raw_mute.get("end_hour_local", _MISSING), DEFAULT_SETTINGS.mute_window.end_hour_local, + strict, ), ), category_self_mute_hours=_nonnegative_int( "category_self_mute_hours", - raw_root.get("category_self_mute_hours"), + raw_root.get("category_self_mute_hours", _MISSING), DEFAULT_SETTINGS.category_self_mute_hours, + strict, ), - category_self_mute_clear_marker_ts=_nonnegative_int( - "category_self_mute_clear_marker_ts", - raw_root.get("category_self_mute_clear_marker_ts"), - DEFAULT_SETTINGS.category_self_mute_clear_marker_ts, + category_self_mute_clear_markers=_category_self_mute_clear_markers( + raw_root.get("category_self_mute_clear_markers", _MISSING), + strict, ), default_dedupe_window=_dedupe_window( - raw_root.get("default_dedupe_window"), + raw_root.get("default_dedupe_window", _MISSING), DEFAULT_SETTINGS.default_dedupe_window, + strict, + ), + system_notifications_macos=_bool( + "system_notifications.macos", + raw_notifications.get("macos", _MISSING), + DEFAULT_SETTINGS.system_notifications_macos, + strict, + ), + system_notifications_linux=_bool( + "system_notifications.linux", + raw_notifications.get("linux", _MISSING), + DEFAULT_SETTINGS.system_notifications_linux, + strict, + ), + debug_show_throttled=_bool( + "debug_show_throttled", + raw_root.get("debug_show_throttled", _MISSING), + DEFAULT_SETTINGS.debug_show_throttled, + strict, ), ) @@ -114,51 +229,112 @@ def _warn_invalid(key: str, raw_value: object) -> None: ) -def _nonnegative_int(key: str, raw_value: object, default: int) -> int: +def _reject(key: str, raw_value: object, strict: bool) -> None: + if raw_value is _MISSING: + return + if strict: + raise ValueError(f"{key} has invalid value: {raw_value!r}") + _warn_invalid(key, raw_value) + + +def _validate_known_keys( + key: str, raw_value: dict[str, Any], allowed: set[str] +) -> None: + for candidate in raw_value: + if candidate not in allowed: + raise ValueError(f"{key}.{candidate} is not a recognized setting") + + +def _nonnegative_int(key: str, raw_value: object, default: int, strict: bool) -> int: + if raw_value is _MISSING: + return default if ( isinstance(raw_value, int) and not isinstance(raw_value, bool) and raw_value >= 0 ): return raw_value - _warn_invalid(key, raw_value) + _reject(key, raw_value, strict) return default -def _bool(key: str, raw_value: object, default: bool) -> bool: +def _bool(key: str, raw_value: object, default: bool, strict: bool) -> bool: + if raw_value is _MISSING: + return default if isinstance(raw_value, bool): return raw_value - _warn_invalid(key, raw_value) + _reject(key, raw_value, strict) return default -def _hour(key: str, raw_value: object, default: int) -> int: +def _hour(key: str, raw_value: object, default: int, strict: bool) -> int: + if raw_value is _MISSING: + return default if ( isinstance(raw_value, int) and not isinstance(raw_value, bool) and 0 <= raw_value <= 23 ): return raw_value - _warn_invalid(key, raw_value) + _reject(key, raw_value, strict) return default -def _category_caps(raw_value: object) -> dict[str, int]: +def _category_caps(raw_value: object, strict: bool = False) -> dict[str, int]: + if raw_value is _MISSING: + raw_value = {} if not isinstance(raw_value, dict): - _warn_invalid("category_caps", raw_value) + _reject("category_caps", raw_value, strict) raw_value = {} caps: dict[str, int] = {} for category, default in CATEGORY_CAP_DEFAULTS.items(): caps[category] = _nonnegative_int( f"category_caps.{category}", - raw_value.get(category), + raw_value.get(category, _MISSING), default, + strict, + ) + for category, value in raw_value.items(): + if category in caps: + continue + if not isinstance(category, str): + _reject(f"category_caps.{category!r}", category, strict) + continue + caps[category] = _nonnegative_int( + f"category_caps.{category}", + value, + 0, + strict, ) return caps -def _dedupe_window(raw_value: object, default: str) -> str: +def _category_self_mute_clear_markers( + raw_value: object, + strict: bool, +) -> dict[str, int]: + if raw_value is _MISSING: + return {} + if not isinstance(raw_value, dict): + _reject("category_self_mute_clear_markers", raw_value, strict) + return {} + + markers: dict[str, int] = {} + for category, marker in raw_value.items(): + if not isinstance(category, str): + _reject(f"category_self_mute_clear_markers.{category!r}", category, strict) + continue + if isinstance(marker, int) and not isinstance(marker, bool) and marker >= 0: + markers[category] = marker + continue + _reject(f"category_self_mute_clear_markers.{category}", marker, strict) + return markers + + +def _dedupe_window(raw_value: object, default: str, strict: bool) -> str: + if raw_value is _MISSING: + return default if isinstance(raw_value, str): try: parse_dedupe_window(raw_value) @@ -166,5 +342,53 @@ def _dedupe_window(raw_value: object, default: str) -> str: pass else: return raw_value - _warn_invalid("default_dedupe_window", raw_value) + _reject("default_dedupe_window", raw_value, strict) return default + + +def _deep_merge(base: object, updates: dict[str, Any]) -> dict[str, Any]: + if not isinstance(base, dict): + base = {} + merged = copy.deepcopy(base) + for key, value in updates.items(): + if isinstance(value, dict) and isinstance(merged.get(key), dict): + merged[key] = _deep_merge(merged[key], value) + continue + merged[key] = copy.deepcopy(value) + return merged + + +def _write_config_atomic(config: dict[str, Any]) -> None: + config_path = Path(get_journal()) / "config" / "journal.json" + config_path.parent.mkdir(parents=True, exist_ok=True) + with config_path.open("a+", encoding="utf-8") as lock_file: + fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX) + temp_path: Path | None = None + try: + fd, raw_temp_path = tempfile.mkstemp( + dir=config_path.parent, + prefix=".journal.", + suffix=".tmp", + text=True, + ) + temp_path = Path(raw_temp_path) + with os.fdopen(fd, "w", encoding="utf-8") as handle: + json.dump(config, handle, indent=2, ensure_ascii=False) + handle.write("\n") + handle.flush() + os.fsync(handle.fileno()) + os.replace(temp_path, config_path) + os.chmod(config_path, 0o600) + _fsync_dir(config_path.parent) + finally: + if temp_path is not None and temp_path.exists(): + temp_path.unlink() + fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN) + + +def _fsync_dir(path: Path) -> None: + dir_fd = os.open(path, os.O_DIRECTORY) + try: + os.fsync(dir_fd) + finally: + os.close(dir_fd) diff --git a/solstone/think/journal_default.json b/solstone/think/journal_default.json index 12a6200af..b9821e7ab 100644 --- a/solstone/think/journal_default.json +++ b/solstone/think/journal_default.json @@ -65,8 +65,13 @@ "end_hour_local": 7 }, "category_self_mute_hours": 24, - "category_self_mute_clear_marker_ts": 0, - "default_dedupe_window": "24h" + "category_self_mute_clear_markers": {}, + "default_dedupe_window": "24h", + "system_notifications": { + "macos": false, + "linux": false + }, + "debug_show_throttled": false }, "convey": { "allow_network_access": false, diff --git a/tests/test_chat_stream_sol_initiated.py b/tests/test_chat_stream_sol_initiated.py index 6565ee1db..d4543247a 100644 --- a/tests/test_chat_stream_sol_initiated.py +++ b/tests/test_chat_stream_sol_initiated.py @@ -59,7 +59,7 @@ def _write_config( "end_hour_local": 7, }, "category_self_mute_hours": category_self_mute_hours, - "category_self_mute_clear_marker_ts": 0, + "category_self_mute_clear_markers": {}, "default_dedupe_window": "24h", } } diff --git a/tests/test_sol_initiated_policy.py b/tests/test_sol_initiated_policy.py index d3e45d355..caa94be8d 100644 --- a/tests/test_sol_initiated_policy.py +++ b/tests/test_sol_initiated_policy.py @@ -40,7 +40,7 @@ def _settings(**overrides) -> SolVoiceSettings: "rate_floor_minutes": 20, "mute_window": MuteWindowSettings(False, 22, 7), "category_self_mute_hours": 24, - "category_self_mute_clear_marker_ts": 0, + "category_self_mute_clear_markers": {}, "default_dedupe_window": "24h", } values.update(overrides) @@ -98,6 +98,35 @@ def test_category_self_mute_uses_dismissal_category() -> None: assert check_category_self_mute(settings, events, CATEGORIES[0], 3_000) is None +def test_category_clear_marker_isolated_per_category() -> None: + first_category = CATEGORIES[0] + second_category = CATEGORIES[1] + settings = _settings( + category_self_mute_hours=2, + category_self_mute_clear_markers={first_category: 2_500}, + ) + events = [ + _request(first_category, ts=1_000), + _request(second_category, ts=1_100), + { + "kind": KIND_OWNER_CHAT_DISMISSED, + "ts": 2_000, + "request_id": "r-1000", + }, + { + "kind": KIND_OWNER_CHAT_DISMISSED, + "ts": 2_100, + "request_id": "r-1100", + }, + ] + + assert check_category_self_mute(settings, events, first_category, 3_000) is None + assert ( + check_category_self_mute(settings, events, second_category, 3_000) + == THROTTLE_CATEGORY_SELF_MUTE + ) + + def test_category_and_daily_caps_count_requests() -> None: settings = _settings( daily_cap=2, category_caps={**CATEGORY_CAP_DEFAULTS, CATEGORIES[0]: 1} @@ -126,7 +155,7 @@ def test_start_chat_daily_cap_counts_current_utc_day_across_stream_days( "end_hour_local": 7, }, "category_self_mute_hours": 0, - "category_self_mute_clear_marker_ts": 0, + "category_self_mute_clear_markers": {}, "default_dedupe_window": "24h", } }, -- 2.51.2