personal memory agent
0

Configure Feed

Select the types of activity you want to include in your feed.

feat(sol-initiated): per-category self-mute clear markers

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.

+323 -41
+28 -4
solstone/convey/sol_initiated/policy.py
··· 6 6 from __future__ import annotations 7 7 8 8 from datetime import datetime 9 + from typing import TypedDict 9 10 10 11 from solstone.convey.sol_initiated.copy import ( 11 12 KIND_OWNER_CHAT_DISMISSED, ··· 18 19 ) 19 20 from solstone.convey.sol_initiated.dedup import _is_live_for_dedup 20 21 from solstone.convey.sol_initiated.settings import SolVoiceSettings 22 + 23 + 24 + class CategoryMuteState(TypedDict): 25 + muted: bool 26 + expires_ts: int | None 21 27 22 28 23 29 def check_mute_window( ··· 70 76 now_ms: int, 71 77 ) -> str | None: 72 78 """Throttle a category after a recent owner dismissal.""" 79 + state = compute_category_mute_state(settings, events_today, category, now_ms) 80 + if state["muted"]: 81 + return THROTTLE_CATEGORY_SELF_MUTE 82 + return None 83 + 84 + 85 + def compute_category_mute_state( 86 + settings: SolVoiceSettings, 87 + events_today: list[dict], 88 + category: str, 89 + now_ms: int, 90 + ) -> CategoryMuteState: 91 + """Return the current self-mute state for a category.""" 73 92 mute_ms = settings.category_self_mute_hours * 3_600_000 74 93 if mute_ms <= 0: 75 - return None 94 + return {"muted": False, "expires_ts": None} 76 95 77 96 request_categories: dict[str, str] = {} 97 + latest_dismissed_ts: int | None = None 78 98 for event in events_today: 79 99 kind = event.get("kind") 80 100 if kind == KIND_SOL_CHAT_REQUEST: ··· 85 105 if kind != KIND_OWNER_CHAT_DISMISSED: 86 106 continue 87 107 dismissed_ts = int(event.get("ts", 0) or 0) 88 - if dismissed_ts <= settings.category_self_mute_clear_marker_ts: 108 + clear_marker_ts = settings.category_self_mute_clear_markers.get(category, 0) 109 + if dismissed_ts <= clear_marker_ts: 89 110 continue 90 111 if now_ms - dismissed_ts > mute_ms: 91 112 continue 92 113 request_category = request_categories.get(str(event.get("request_id") or "")) 93 114 if request_category == category: 94 - return THROTTLE_CATEGORY_SELF_MUTE 95 - return None 115 + latest_dismissed_ts = max(latest_dismissed_ts or 0, dismissed_ts) 116 + 117 + if latest_dismissed_ts is None: 118 + return {"muted": False, "expires_ts": None} 119 + return {"muted": True, "expires_ts": latest_dismissed_ts + mute_ms} 96 120 97 121 98 122 def check_category_cap(
+256 -32
solstone/convey/sol_initiated/settings.py
··· 5 5 6 6 from __future__ import annotations 7 7 8 + import copy 9 + import fcntl 10 + import json 8 11 import logging 9 - from dataclasses import dataclass 12 + import os 13 + import tempfile 14 + from dataclasses import dataclass, field 15 + from pathlib import Path 16 + from typing import Any 10 17 11 18 from solstone.convey.sol_initiated.copy import CATEGORY_CAP_DEFAULTS 12 19 from solstone.convey.sol_initiated.dedup import parse_dedupe_window 13 - from solstone.think.utils import get_config 20 + from solstone.think.utils import get_config, get_journal 14 21 15 22 logger = logging.getLogger(__name__) 23 + _MISSING = object() 16 24 17 25 18 26 @dataclass(frozen=True) ··· 29 37 rate_floor_minutes: int 30 38 mute_window: MuteWindowSettings 31 39 category_self_mute_hours: int 32 - category_self_mute_clear_marker_ts: int 33 40 default_dedupe_window: str 41 + category_self_mute_clear_markers: dict[str, int] = field(default_factory=dict) 42 + system_notifications_macos: bool = False 43 + system_notifications_linux: bool = False 44 + debug_show_throttled: bool = False 45 + 46 + def to_dict(self) -> dict[str, Any]: 47 + """Return the on-disk ``sol_voice`` shape.""" 48 + return { 49 + "daily_cap": self.daily_cap, 50 + "category_caps": dict(self.category_caps), 51 + "rate_floor_minutes": self.rate_floor_minutes, 52 + "mute_window": { 53 + "enabled": self.mute_window.enabled, 54 + "start_hour_local": self.mute_window.start_hour_local, 55 + "end_hour_local": self.mute_window.end_hour_local, 56 + }, 57 + "category_self_mute_hours": self.category_self_mute_hours, 58 + "category_self_mute_clear_markers": dict( 59 + self.category_self_mute_clear_markers 60 + ), 61 + "default_dedupe_window": self.default_dedupe_window, 62 + "system_notifications": { 63 + "macos": self.system_notifications_macos, 64 + "linux": self.system_notifications_linux, 65 + }, 66 + "debug_show_throttled": self.debug_show_throttled, 67 + } 34 68 35 69 36 70 DEFAULT_SETTINGS = SolVoiceSettings( ··· 43 77 end_hour_local=7, 44 78 ), 45 79 category_self_mute_hours=24, 46 - category_self_mute_clear_marker_ts=0, 80 + category_self_mute_clear_markers={}, 47 81 default_dedupe_window="24h", 82 + system_notifications_macos=False, 83 + system_notifications_linux=False, 84 + debug_show_throttled=False, 48 85 ) 49 86 50 87 51 88 def load_settings() -> SolVoiceSettings: 52 89 """Load sol-initiated chat settings, falling back field-by-field.""" 53 - raw_root = get_config().get("sol_voice") 90 + return _parse_settings(get_config().get("sol_voice"), strict=False) 91 + 92 + 93 + def save_settings(updates: dict[str, Any]) -> SolVoiceSettings: 94 + """Deep-merge and persist sol-initiated chat settings.""" 95 + if not isinstance(updates, dict): 96 + raise ValueError("sol_voice update must be an object") 97 + 98 + config = get_config() 99 + raw_root = config.get("sol_voice") 54 100 if not isinstance(raw_root, dict): 55 - _warn_invalid("sol_voice", raw_root) 56 101 raw_root = {} 57 102 58 - raw_mute = raw_root.get("mute_window") 59 - if not isinstance(raw_mute, dict): 60 - _warn_invalid("mute_window", raw_mute) 103 + merged = _deep_merge(raw_root, updates) 104 + settings = _parse_settings(merged, strict=True) 105 + config["sol_voice"] = settings.to_dict() 106 + _write_config_atomic(config) 107 + return settings 108 + 109 + 110 + def _parse_settings(raw_root: object, *, strict: bool) -> SolVoiceSettings: 111 + if not isinstance(raw_root, dict): 112 + _reject("sol_voice", raw_root, strict) 113 + raw_root = {} 114 + 115 + raw_mute = raw_root.get("mute_window", _MISSING) 116 + if raw_mute is _MISSING: 117 + raw_mute = {} 118 + elif not isinstance(raw_mute, dict): 119 + _reject("mute_window", raw_mute, strict) 61 120 raw_mute = {} 62 121 122 + raw_notifications = raw_root.get("system_notifications", _MISSING) 123 + if raw_notifications is _MISSING: 124 + raw_notifications = {} 125 + elif not isinstance(raw_notifications, dict): 126 + _reject("system_notifications", raw_notifications, strict) 127 + raw_notifications = {} 128 + 129 + if strict: 130 + _validate_known_keys( 131 + "sol_voice", 132 + raw_root, 133 + { 134 + "daily_cap", 135 + "category_caps", 136 + "rate_floor_minutes", 137 + "mute_window", 138 + "category_self_mute_hours", 139 + "category_self_mute_clear_markers", 140 + "default_dedupe_window", 141 + "system_notifications", 142 + "debug_show_throttled", 143 + }, 144 + ) 145 + _validate_known_keys( 146 + "mute_window", 147 + raw_mute, 148 + {"enabled", "start_hour_local", "end_hour_local"}, 149 + ) 150 + _validate_known_keys( 151 + "system_notifications", raw_notifications, {"macos", "linux"} 152 + ) 153 + 63 154 return SolVoiceSettings( 64 155 daily_cap=_nonnegative_int( 65 156 "daily_cap", 66 - raw_root.get("daily_cap"), 157 + raw_root.get("daily_cap", _MISSING), 67 158 DEFAULT_SETTINGS.daily_cap, 159 + strict, 68 160 ), 69 - category_caps=_category_caps(raw_root.get("category_caps")), 161 + category_caps=_category_caps(raw_root.get("category_caps", _MISSING), strict), 70 162 rate_floor_minutes=_nonnegative_int( 71 163 "rate_floor_minutes", 72 - raw_root.get("rate_floor_minutes"), 164 + raw_root.get("rate_floor_minutes", _MISSING), 73 165 DEFAULT_SETTINGS.rate_floor_minutes, 166 + strict, 74 167 ), 75 168 mute_window=MuteWindowSettings( 76 169 enabled=_bool( 77 170 "mute_window.enabled", 78 - raw_mute.get("enabled"), 171 + raw_mute.get("enabled", _MISSING), 79 172 DEFAULT_SETTINGS.mute_window.enabled, 173 + strict, 80 174 ), 81 175 start_hour_local=_hour( 82 176 "mute_window.start_hour_local", 83 - raw_mute.get("start_hour_local"), 177 + raw_mute.get("start_hour_local", _MISSING), 84 178 DEFAULT_SETTINGS.mute_window.start_hour_local, 179 + strict, 85 180 ), 86 181 end_hour_local=_hour( 87 182 "mute_window.end_hour_local", 88 - raw_mute.get("end_hour_local"), 183 + raw_mute.get("end_hour_local", _MISSING), 89 184 DEFAULT_SETTINGS.mute_window.end_hour_local, 185 + strict, 90 186 ), 91 187 ), 92 188 category_self_mute_hours=_nonnegative_int( 93 189 "category_self_mute_hours", 94 - raw_root.get("category_self_mute_hours"), 190 + raw_root.get("category_self_mute_hours", _MISSING), 95 191 DEFAULT_SETTINGS.category_self_mute_hours, 192 + strict, 96 193 ), 97 - category_self_mute_clear_marker_ts=_nonnegative_int( 98 - "category_self_mute_clear_marker_ts", 99 - raw_root.get("category_self_mute_clear_marker_ts"), 100 - DEFAULT_SETTINGS.category_self_mute_clear_marker_ts, 194 + category_self_mute_clear_markers=_category_self_mute_clear_markers( 195 + raw_root.get("category_self_mute_clear_markers", _MISSING), 196 + strict, 101 197 ), 102 198 default_dedupe_window=_dedupe_window( 103 - raw_root.get("default_dedupe_window"), 199 + raw_root.get("default_dedupe_window", _MISSING), 104 200 DEFAULT_SETTINGS.default_dedupe_window, 201 + strict, 202 + ), 203 + system_notifications_macos=_bool( 204 + "system_notifications.macos", 205 + raw_notifications.get("macos", _MISSING), 206 + DEFAULT_SETTINGS.system_notifications_macos, 207 + strict, 208 + ), 209 + system_notifications_linux=_bool( 210 + "system_notifications.linux", 211 + raw_notifications.get("linux", _MISSING), 212 + DEFAULT_SETTINGS.system_notifications_linux, 213 + strict, 214 + ), 215 + debug_show_throttled=_bool( 216 + "debug_show_throttled", 217 + raw_root.get("debug_show_throttled", _MISSING), 218 + DEFAULT_SETTINGS.debug_show_throttled, 219 + strict, 105 220 ), 106 221 ) 107 222 ··· 114 229 ) 115 230 116 231 117 - def _nonnegative_int(key: str, raw_value: object, default: int) -> int: 232 + def _reject(key: str, raw_value: object, strict: bool) -> None: 233 + if raw_value is _MISSING: 234 + return 235 + if strict: 236 + raise ValueError(f"{key} has invalid value: {raw_value!r}") 237 + _warn_invalid(key, raw_value) 238 + 239 + 240 + def _validate_known_keys( 241 + key: str, raw_value: dict[str, Any], allowed: set[str] 242 + ) -> None: 243 + for candidate in raw_value: 244 + if candidate not in allowed: 245 + raise ValueError(f"{key}.{candidate} is not a recognized setting") 246 + 247 + 248 + def _nonnegative_int(key: str, raw_value: object, default: int, strict: bool) -> int: 249 + if raw_value is _MISSING: 250 + return default 118 251 if ( 119 252 isinstance(raw_value, int) 120 253 and not isinstance(raw_value, bool) 121 254 and raw_value >= 0 122 255 ): 123 256 return raw_value 124 - _warn_invalid(key, raw_value) 257 + _reject(key, raw_value, strict) 125 258 return default 126 259 127 260 128 - def _bool(key: str, raw_value: object, default: bool) -> bool: 261 + def _bool(key: str, raw_value: object, default: bool, strict: bool) -> bool: 262 + if raw_value is _MISSING: 263 + return default 129 264 if isinstance(raw_value, bool): 130 265 return raw_value 131 - _warn_invalid(key, raw_value) 266 + _reject(key, raw_value, strict) 132 267 return default 133 268 134 269 135 - def _hour(key: str, raw_value: object, default: int) -> int: 270 + def _hour(key: str, raw_value: object, default: int, strict: bool) -> int: 271 + if raw_value is _MISSING: 272 + return default 136 273 if ( 137 274 isinstance(raw_value, int) 138 275 and not isinstance(raw_value, bool) 139 276 and 0 <= raw_value <= 23 140 277 ): 141 278 return raw_value 142 - _warn_invalid(key, raw_value) 279 + _reject(key, raw_value, strict) 143 280 return default 144 281 145 282 146 - def _category_caps(raw_value: object) -> dict[str, int]: 283 + def _category_caps(raw_value: object, strict: bool = False) -> dict[str, int]: 284 + if raw_value is _MISSING: 285 + raw_value = {} 147 286 if not isinstance(raw_value, dict): 148 - _warn_invalid("category_caps", raw_value) 287 + _reject("category_caps", raw_value, strict) 149 288 raw_value = {} 150 289 151 290 caps: dict[str, int] = {} 152 291 for category, default in CATEGORY_CAP_DEFAULTS.items(): 153 292 caps[category] = _nonnegative_int( 154 293 f"category_caps.{category}", 155 - raw_value.get(category), 294 + raw_value.get(category, _MISSING), 156 295 default, 296 + strict, 297 + ) 298 + for category, value in raw_value.items(): 299 + if category in caps: 300 + continue 301 + if not isinstance(category, str): 302 + _reject(f"category_caps.{category!r}", category, strict) 303 + continue 304 + caps[category] = _nonnegative_int( 305 + f"category_caps.{category}", 306 + value, 307 + 0, 308 + strict, 157 309 ) 158 310 return caps 159 311 160 312 161 - def _dedupe_window(raw_value: object, default: str) -> str: 313 + def _category_self_mute_clear_markers( 314 + raw_value: object, 315 + strict: bool, 316 + ) -> dict[str, int]: 317 + if raw_value is _MISSING: 318 + return {} 319 + if not isinstance(raw_value, dict): 320 + _reject("category_self_mute_clear_markers", raw_value, strict) 321 + return {} 322 + 323 + markers: dict[str, int] = {} 324 + for category, marker in raw_value.items(): 325 + if not isinstance(category, str): 326 + _reject(f"category_self_mute_clear_markers.{category!r}", category, strict) 327 + continue 328 + if isinstance(marker, int) and not isinstance(marker, bool) and marker >= 0: 329 + markers[category] = marker 330 + continue 331 + _reject(f"category_self_mute_clear_markers.{category}", marker, strict) 332 + return markers 333 + 334 + 335 + def _dedupe_window(raw_value: object, default: str, strict: bool) -> str: 336 + if raw_value is _MISSING: 337 + return default 162 338 if isinstance(raw_value, str): 163 339 try: 164 340 parse_dedupe_window(raw_value) ··· 166 342 pass 167 343 else: 168 344 return raw_value 169 - _warn_invalid("default_dedupe_window", raw_value) 345 + _reject("default_dedupe_window", raw_value, strict) 170 346 return default 347 + 348 + 349 + def _deep_merge(base: object, updates: dict[str, Any]) -> dict[str, Any]: 350 + if not isinstance(base, dict): 351 + base = {} 352 + merged = copy.deepcopy(base) 353 + for key, value in updates.items(): 354 + if isinstance(value, dict) and isinstance(merged.get(key), dict): 355 + merged[key] = _deep_merge(merged[key], value) 356 + continue 357 + merged[key] = copy.deepcopy(value) 358 + return merged 359 + 360 + 361 + def _write_config_atomic(config: dict[str, Any]) -> None: 362 + config_path = Path(get_journal()) / "config" / "journal.json" 363 + config_path.parent.mkdir(parents=True, exist_ok=True) 364 + with config_path.open("a+", encoding="utf-8") as lock_file: 365 + fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX) 366 + temp_path: Path | None = None 367 + try: 368 + fd, raw_temp_path = tempfile.mkstemp( 369 + dir=config_path.parent, 370 + prefix=".journal.", 371 + suffix=".tmp", 372 + text=True, 373 + ) 374 + temp_path = Path(raw_temp_path) 375 + with os.fdopen(fd, "w", encoding="utf-8") as handle: 376 + json.dump(config, handle, indent=2, ensure_ascii=False) 377 + handle.write("\n") 378 + handle.flush() 379 + os.fsync(handle.fileno()) 380 + os.replace(temp_path, config_path) 381 + os.chmod(config_path, 0o600) 382 + _fsync_dir(config_path.parent) 383 + finally: 384 + if temp_path is not None and temp_path.exists(): 385 + temp_path.unlink() 386 + fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN) 387 + 388 + 389 + def _fsync_dir(path: Path) -> None: 390 + dir_fd = os.open(path, os.O_DIRECTORY) 391 + try: 392 + os.fsync(dir_fd) 393 + finally: 394 + os.close(dir_fd)
+7 -2
solstone/think/journal_default.json
··· 65 65 "end_hour_local": 7 66 66 }, 67 67 "category_self_mute_hours": 24, 68 - "category_self_mute_clear_marker_ts": 0, 69 - "default_dedupe_window": "24h" 68 + "category_self_mute_clear_markers": {}, 69 + "default_dedupe_window": "24h", 70 + "system_notifications": { 71 + "macos": false, 72 + "linux": false 73 + }, 74 + "debug_show_throttled": false 70 75 }, 71 76 "convey": { 72 77 "allow_network_access": false,
+1 -1
tests/test_chat_stream_sol_initiated.py
··· 59 59 "end_hour_local": 7, 60 60 }, 61 61 "category_self_mute_hours": category_self_mute_hours, 62 - "category_self_mute_clear_marker_ts": 0, 62 + "category_self_mute_clear_markers": {}, 63 63 "default_dedupe_window": "24h", 64 64 } 65 65 }
+31 -2
tests/test_sol_initiated_policy.py
··· 40 40 "rate_floor_minutes": 20, 41 41 "mute_window": MuteWindowSettings(False, 22, 7), 42 42 "category_self_mute_hours": 24, 43 - "category_self_mute_clear_marker_ts": 0, 43 + "category_self_mute_clear_markers": {}, 44 44 "default_dedupe_window": "24h", 45 45 } 46 46 values.update(overrides) ··· 98 98 assert check_category_self_mute(settings, events, CATEGORIES[0], 3_000) is None 99 99 100 100 101 + def test_category_clear_marker_isolated_per_category() -> None: 102 + first_category = CATEGORIES[0] 103 + second_category = CATEGORIES[1] 104 + settings = _settings( 105 + category_self_mute_hours=2, 106 + category_self_mute_clear_markers={first_category: 2_500}, 107 + ) 108 + events = [ 109 + _request(first_category, ts=1_000), 110 + _request(second_category, ts=1_100), 111 + { 112 + "kind": KIND_OWNER_CHAT_DISMISSED, 113 + "ts": 2_000, 114 + "request_id": "r-1000", 115 + }, 116 + { 117 + "kind": KIND_OWNER_CHAT_DISMISSED, 118 + "ts": 2_100, 119 + "request_id": "r-1100", 120 + }, 121 + ] 122 + 123 + assert check_category_self_mute(settings, events, first_category, 3_000) is None 124 + assert ( 125 + check_category_self_mute(settings, events, second_category, 3_000) 126 + == THROTTLE_CATEGORY_SELF_MUTE 127 + ) 128 + 129 + 101 130 def test_category_and_daily_caps_count_requests() -> None: 102 131 settings = _settings( 103 132 daily_cap=2, category_caps={**CATEGORY_CAP_DEFAULTS, CATEGORIES[0]: 1} ··· 126 155 "end_hour_local": 7, 127 156 }, 128 157 "category_self_mute_hours": 0, 129 - "category_self_mute_clear_marker_ts": 0, 158 + "category_self_mute_clear_markers": {}, 130 159 "default_dedupe_window": "24h", 131 160 } 132 161 },