personal memory agent
0
fork

Configure Feed

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

Merge branch 'hopper-x3ekpexm-retention-default-onboarding'

+292 -14
+1
apps/home/workspace.html
··· 953 953 <div class="pulse-welcome"> 954 954 <h2>welcome to your home page</h2> 955 955 <p>this is where your day comes together — narrative summaries, calendar events, tasks, routines, and the people in your network. as solstone captures and processes your day, sections will appear here automatically.</p> 956 + <p>your recordings are kept for 7 days by default, then cleaned up after processing. you can change this anytime in <a href="/app/settings#storage">settings</a>.</p> 956 957 <a href="/app/health">check system health →</a> 957 958 </div> 958 959 {% endif %}
+4 -4
docs/JOURNAL.md
··· 153 153 154 154 The `retention` block controls automatic cleanup of layer 1 raw media (audio recordings, video captures, screen diffs) while preserving all layer 2 extracts and layer 3 agent outputs. Three modes control when raw media is deleted: 155 155 156 - - `"keep"` – retain raw media indefinitely (default) 157 - - `"days"` – delete raw media after `raw_media_days` days, once the segment has finished processing 156 + - `"keep"` – retain raw media indefinitely 157 + - `"days"` – delete raw media after `raw_media_days` days, once the segment has finished processing (default: 7 days) 158 158 - `"processed"` – delete raw media as soon as the segment has finished processing 159 159 160 160 ```json ··· 176 176 ``` 177 177 178 178 Fields: 179 - - `raw_media` (string) – Retention mode: `"keep"`, `"days"`, or `"processed"`. Default: `"processed"`. 180 - - `raw_media_days` (integer or null) – Number of days to retain raw media when mode is `"days"`. Required when `raw_media` is `"days"`, ignored otherwise. 179 + - `raw_media` (string) – Retention mode: `"keep"`, `"days"`, or `"processed"`. Default: `"days"`. 180 + - `raw_media_days` (integer or null) – Number of days to retain raw media when mode is `"days"`. Default: `7`. Required when `raw_media` is `"days"`, ignored otherwise. 181 181 - `per_stream` (object) – Per-stream overrides keyed by stream name. Each entry supports `raw_media` and `raw_media_days`. Omitted fields inherit from the global retention settings. 182 182 183 183 "Raw media" means layer 1 capture files only: audio files (`.flac`, `.opus`, `.ogg`, `.m4a`, `.wav`), video files (`.webm`, `.mov`, `.mp4`), and screen diffs (`monitor_*_diff.png`).
+4 -2
tests/test_retention.py
··· 223 223 class TestRetentionConfig: 224 224 def test_default_policy(self): 225 225 cfg = RetentionConfig() 226 - assert cfg.policy_for_stream("default").mode == "processed" 226 + assert cfg.policy_for_stream("default").mode == "days" 227 + assert cfg.policy_for_stream("default").days == 7 227 228 228 229 def test_per_stream_override(self): 229 230 cfg = RetentionConfig( ··· 246 247 def test_default_config(self, monkeypatch): 247 248 monkeypatch.setattr("think.utils.get_config", lambda: {}) 248 249 cfg = load_retention_config() 249 - assert cfg.default.mode == "processed" 250 + assert cfg.default.mode == "days" 251 + assert cfg.default.days == 7 250 252 assert cfg.per_stream == {} 251 253 252 254 def test_custom_config(self, monkeypatch):
+163
tests/test_retention_config_cli.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + import json 5 + from pathlib import Path 6 + 7 + import pytest 8 + from typer.testing import CliRunner 9 + 10 + from think.call import call_app 11 + 12 + runner = CliRunner() 13 + 14 + 15 + @pytest.fixture 16 + def journal_env(tmp_path, monkeypatch): 17 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 18 + return tmp_path 19 + 20 + 21 + def _write_config(journal_path: Path, config: dict) -> None: 22 + config_dir = journal_path / "config" 23 + config_dir.mkdir(parents=True, exist_ok=True) 24 + (config_dir / "journal.json").write_text( 25 + json.dumps(config, indent=2) + "\n", encoding="utf-8" 26 + ) 27 + 28 + 29 + def _load_json(path: Path) -> dict: 30 + return json.loads(path.read_text(encoding="utf-8")) 31 + 32 + 33 + def test_show_default(journal_env): 34 + result = runner.invoke(call_app, ["journal", "retention", "config"]) 35 + 36 + assert result.exit_code == 0 37 + payload = json.loads(result.output) 38 + assert payload == {"default": {"mode": "days", "days": 7}, "per_stream": {}} 39 + 40 + 41 + def test_show_custom(journal_env): 42 + _write_config(journal_env, {"retention": {"raw_media": "keep"}}) 43 + 44 + result = runner.invoke(call_app, ["journal", "retention", "config"]) 45 + 46 + assert result.exit_code == 0 47 + payload = json.loads(result.output) 48 + assert payload["default"]["mode"] == "keep" 49 + 50 + 51 + def test_set_mode_and_days(journal_env): 52 + result = runner.invoke( 53 + call_app, ["journal", "retention", "config", "--mode", "days", "--days", "30"] 54 + ) 55 + 56 + assert result.exit_code == 0 57 + payload = json.loads(result.output) 58 + assert payload["default"] == {"mode": "days", "days": 30} 59 + 60 + config_path = journal_env / "config" / "journal.json" 61 + saved = _load_json(config_path) 62 + assert saved["retention"]["raw_media"] == "days" 63 + assert saved["retention"]["raw_media_days"] == 30 64 + assert config_path.stat().st_mode & 0o777 == 0o600 65 + 66 + 67 + def test_set_mode_days_without_days_flag(journal_env): 68 + result = runner.invoke( 69 + call_app, ["journal", "retention", "config", "--mode", "days"] 70 + ) 71 + 72 + assert result.exit_code == 1 73 + assert "--days is required when mode is 'days'." in result.output 74 + 75 + 76 + def test_set_per_stream(journal_env): 77 + result = runner.invoke( 78 + call_app, 79 + [ 80 + "journal", 81 + "retention", 82 + "config", 83 + "--stream", 84 + "plaud", 85 + "--mode", 86 + "processed", 87 + ], 88 + ) 89 + 90 + assert result.exit_code == 0 91 + saved = _load_json(journal_env / "config" / "journal.json") 92 + assert saved["retention"]["per_stream"]["plaud"]["raw_media"] == "processed" 93 + 94 + 95 + def test_clear_per_stream(journal_env): 96 + _write_config( 97 + journal_env, 98 + { 99 + "retention": { 100 + "raw_media": "days", 101 + "raw_media_days": 7, 102 + "per_stream": {"plaud": {"raw_media": "processed"}}, 103 + } 104 + }, 105 + ) 106 + 107 + result = runner.invoke( 108 + call_app, 109 + ["journal", "retention", "config", "--stream", "plaud", "--clear"], 110 + ) 111 + 112 + assert result.exit_code == 0 113 + saved = _load_json(journal_env / "config" / "journal.json") 114 + assert saved["retention"].get("per_stream") is None 115 + 116 + 117 + def test_clear_without_stream(journal_env): 118 + result = runner.invoke(call_app, ["journal", "retention", "config", "--clear"]) 119 + 120 + assert result.exit_code == 1 121 + assert "--clear requires --stream" in result.output 122 + 123 + 124 + def test_invalid_mode(journal_env): 125 + result = runner.invoke( 126 + call_app, ["journal", "retention", "config", "--mode", "invalid"] 127 + ) 128 + 129 + assert result.exit_code == 1 130 + assert "Invalid mode: invalid. Must be keep, days, or processed." in result.output 131 + 132 + 133 + def test_clear_with_mode_rejected(journal_env): 134 + result = runner.invoke( 135 + call_app, 136 + ["journal", "retention", "config", "--stream", "plaud", "--clear", "--mode", "keep"], 137 + ) 138 + 139 + assert result.exit_code == 1 140 + assert "--clear cannot be combined with --mode or --days" in result.output 141 + 142 + 143 + def test_negative_days_rejected(journal_env): 144 + result = runner.invoke( 145 + call_app, ["journal", "retention", "config", "--mode", "keep", "--days", "-1"] 146 + ) 147 + 148 + assert result.exit_code == 1 149 + assert "--days must be a positive integer" in result.output 150 + 151 + 152 + def test_action_logged(journal_env): 153 + result = runner.invoke( 154 + call_app, ["journal", "retention", "config", "--mode", "keep"] 155 + ) 156 + 157 + assert result.exit_code == 0 158 + 159 + action_files = list((journal_env / "config" / "actions").glob("*.jsonl")) 160 + assert len(action_files) == 1 161 + entries = action_files[0].read_text(encoding="utf-8").strip().splitlines() 162 + payload = json.loads(entries[-1]) 163 + assert payload["action"] == "retention_config"
+2 -2
think/journal_default.json
··· 33 33 "proposal_count": 0 34 34 }, 35 35 "retention": { 36 - "raw_media": "keep", 37 - "raw_media_days": null, 36 + "raw_media": "days", 37 + "raw_media_days": 7, 38 38 "per_stream": {}, 39 39 "storage_warning_disk_percent": 80, 40 40 "storage_warning_raw_media_gb": null
+6 -6
think/retention.py
··· 6 6 Manages the lifecycle of raw media files (layer 1 captures) in journal segments. 7 7 Three retention modes: 8 8 - keep: retain raw media indefinitely 9 - - days: delete raw media after N days, once processing is complete 10 - - processed: delete raw media as soon as processing completes (default) 9 + - days: delete raw media after N days, once processing is complete (default: 7) 10 + - processed: delete raw media as soon as processing completes 11 11 12 12 Safety invariant: never delete raw media from segments that haven't finished 13 13 processing. All completion checks must pass before any deletion. ··· 142 142 class RetentionPolicy: 143 143 """Retention policy for a single scope (global or per-stream).""" 144 144 145 - mode: str = "processed" # "keep", "days", or "processed" 146 - days: int | None = None 145 + mode: str = "days" # "keep", "days", or "processed" 146 + days: int | None = 7 147 147 148 148 def is_eligible(self, segment_age_days: int) -> bool: 149 149 """Check if a segment's raw media should be purged under this policy.""" ··· 175 175 config = get_config() 176 176 retention = config.get("retention", {}) 177 177 178 - mode = retention.get("raw_media", "processed") 179 - days = retention.get("raw_media_days") 178 + mode = retention.get("raw_media", "days") 179 + days = retention.get("raw_media_days", 7) 180 180 default = RetentionPolicy(mode=mode, days=days) 181 181 182 182 per_stream: dict[str, RetentionPolicy] = {}
+112
think/tools/call.py
··· 950 950 ) 951 951 952 952 953 + @retention_app.command() 954 + def config( 955 + mode: str | None = typer.Option( 956 + None, "--mode", help="Retention mode: keep, days, or processed." 957 + ), 958 + days: int | None = typer.Option( 959 + None, "--days", help="Days to retain (required when mode is 'days')." 960 + ), 961 + stream: str | None = typer.Option( 962 + None, "--stream", help="Apply to a specific stream instead of global." 963 + ), 964 + clear: bool = typer.Option( 965 + False, "--clear", help="Clear per-stream override (requires --stream)." 966 + ), 967 + ) -> None: 968 + """Show or update retention configuration.""" 969 + import os 970 + 971 + from think.retention import load_retention_config 972 + from think.utils import get_config, get_journal 973 + 974 + if mode is None and days is None and not clear: 975 + cfg = load_retention_config() 976 + result = { 977 + "default": {"mode": cfg.default.mode, "days": cfg.default.days}, 978 + "per_stream": { 979 + name: {"mode": policy.mode, "days": policy.days} 980 + for name, policy in cfg.per_stream.items() 981 + }, 982 + } 983 + typer.echo(json.dumps(result, indent=2)) 984 + return 985 + 986 + if clear: 987 + if not stream: 988 + typer.echo("--clear requires --stream", err=True) 989 + raise typer.Exit(1) 990 + if mode is not None or days is not None: 991 + typer.echo("--clear cannot be combined with --mode or --days", err=True) 992 + raise typer.Exit(1) 993 + 994 + if mode is not None and mode not in ("keep", "days", "processed"): 995 + typer.echo( 996 + f"Invalid mode: {mode}. Must be keep, days, or processed.", err=True 997 + ) 998 + raise typer.Exit(1) 999 + 1000 + if mode == "days" and days is None: 1001 + typer.echo("--days is required when mode is 'days'.", err=True) 1002 + raise typer.Exit(1) 1003 + 1004 + if days is not None and days < 1: 1005 + typer.echo("--days must be a positive integer.", err=True) 1006 + raise typer.Exit(1) 1007 + 1008 + journal_config = get_config() 1009 + retention = journal_config.setdefault("retention", {}) 1010 + 1011 + if clear: 1012 + ps = retention.get("per_stream", {}) 1013 + if stream in ps: 1014 + del ps[stream] 1015 + if not ps: 1016 + retention.pop("per_stream", None) 1017 + log_call_action( 1018 + facet=None, 1019 + action="retention_config", 1020 + params={"stream": stream, "clear": True}, 1021 + ) 1022 + elif stream: 1023 + ps = retention.setdefault("per_stream", {}) 1024 + entry = ps.setdefault(stream, {}) 1025 + if mode is not None: 1026 + entry["raw_media"] = mode 1027 + if days is not None: 1028 + entry["raw_media_days"] = days 1029 + log_call_action( 1030 + facet=None, 1031 + action="retention_config", 1032 + params={"stream": stream, "mode": mode, "days": days}, 1033 + ) 1034 + else: 1035 + if mode is not None: 1036 + retention["raw_media"] = mode 1037 + if days is not None: 1038 + retention["raw_media_days"] = days 1039 + log_call_action( 1040 + facet=None, 1041 + action="retention_config", 1042 + params={"mode": mode, "days": days}, 1043 + ) 1044 + 1045 + config_dir = Path(get_journal()) / "config" 1046 + config_dir.mkdir(parents=True, exist_ok=True) 1047 + config_path = config_dir / "journal.json" 1048 + 1049 + with open(config_path, "w", encoding="utf-8") as f: 1050 + json.dump(journal_config, f, indent=2, ensure_ascii=False) 1051 + f.write("\n") 1052 + os.chmod(config_path, 0o600) 1053 + 1054 + cfg = load_retention_config() 1055 + result = { 1056 + "default": {"mode": cfg.default.mode, "days": cfg.default.days}, 1057 + "per_stream": { 1058 + name: {"mode": policy.mode, "days": policy.days} 1059 + for name, policy in cfg.per_stream.items() 1060 + }, 1061 + } 1062 + typer.echo(json.dumps(result, indent=2)) 1063 + 1064 + 953 1065 @app.command(name="storage-summary") 954 1066 def storage_summary( 955 1067 json_output: bool = typer.Option(False, "--json", help="Output as JSON."),