diff --git a/apps/home/workspace.html b/apps/home/workspace.html index a2626ee8a..0aba03081 100644 --- a/apps/home/workspace.html +++ b/apps/home/workspace.html @@ -953,6 +953,7 @@

welcome to your home page

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.

+

your recordings are kept for 7 days by default, then cleaned up after processing. you can change this anytime in settings.

check system health →
{% endif %} diff --git a/docs/JOURNAL.md b/docs/JOURNAL.md index 425395fb1..43edd7bab 100644 --- a/docs/JOURNAL.md +++ b/docs/JOURNAL.md @@ -153,8 +153,8 @@ Fields: 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: -- `"keep"` – retain raw media indefinitely (default) -- `"days"` – delete raw media after `raw_media_days` days, once the segment has finished processing +- `"keep"` – retain raw media indefinitely +- `"days"` – delete raw media after `raw_media_days` days, once the segment has finished processing (default: 7 days) - `"processed"` – delete raw media as soon as the segment has finished processing ```json @@ -176,8 +176,8 @@ The `retention` block controls automatic cleanup of layer 1 raw media (audio rec ``` Fields: -- `raw_media` (string) – Retention mode: `"keep"`, `"days"`, or `"processed"`. Default: `"processed"`. -- `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. +- `raw_media` (string) – Retention mode: `"keep"`, `"days"`, or `"processed"`. Default: `"days"`. +- `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. - `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. "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`). diff --git a/tests/test_retention.py b/tests/test_retention.py index 716d94773..2f0ea3565 100644 --- a/tests/test_retention.py +++ b/tests/test_retention.py @@ -223,7 +223,8 @@ class TestRetentionPolicy: class TestRetentionConfig: def test_default_policy(self): cfg = RetentionConfig() - assert cfg.policy_for_stream("default").mode == "processed" + assert cfg.policy_for_stream("default").mode == "days" + assert cfg.policy_for_stream("default").days == 7 def test_per_stream_override(self): cfg = RetentionConfig( @@ -246,7 +247,8 @@ class TestLoadRetentionConfig: def test_default_config(self, monkeypatch): monkeypatch.setattr("think.utils.get_config", lambda: {}) cfg = load_retention_config() - assert cfg.default.mode == "processed" + assert cfg.default.mode == "days" + assert cfg.default.days == 7 assert cfg.per_stream == {} def test_custom_config(self, monkeypatch): diff --git a/tests/test_retention_config_cli.py b/tests/test_retention_config_cli.py new file mode 100644 index 000000000..1b9bd6698 --- /dev/null +++ b/tests/test_retention_config_cli.py @@ -0,0 +1,163 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +import json +from pathlib import Path + +import pytest +from typer.testing import CliRunner + +from think.call import call_app + +runner = CliRunner() + + +@pytest.fixture +def journal_env(tmp_path, monkeypatch): + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) + return tmp_path + + +def _write_config(journal_path: Path, config: dict) -> None: + config_dir = journal_path / "config" + config_dir.mkdir(parents=True, exist_ok=True) + (config_dir / "journal.json").write_text( + json.dumps(config, indent=2) + "\n", encoding="utf-8" + ) + + +def _load_json(path: Path) -> dict: + return json.loads(path.read_text(encoding="utf-8")) + + +def test_show_default(journal_env): + result = runner.invoke(call_app, ["journal", "retention", "config"]) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload == {"default": {"mode": "days", "days": 7}, "per_stream": {}} + + +def test_show_custom(journal_env): + _write_config(journal_env, {"retention": {"raw_media": "keep"}}) + + result = runner.invoke(call_app, ["journal", "retention", "config"]) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload["default"]["mode"] == "keep" + + +def test_set_mode_and_days(journal_env): + result = runner.invoke( + call_app, ["journal", "retention", "config", "--mode", "days", "--days", "30"] + ) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload["default"] == {"mode": "days", "days": 30} + + config_path = journal_env / "config" / "journal.json" + saved = _load_json(config_path) + assert saved["retention"]["raw_media"] == "days" + assert saved["retention"]["raw_media_days"] == 30 + assert config_path.stat().st_mode & 0o777 == 0o600 + + +def test_set_mode_days_without_days_flag(journal_env): + result = runner.invoke( + call_app, ["journal", "retention", "config", "--mode", "days"] + ) + + assert result.exit_code == 1 + assert "--days is required when mode is 'days'." in result.output + + +def test_set_per_stream(journal_env): + result = runner.invoke( + call_app, + [ + "journal", + "retention", + "config", + "--stream", + "plaud", + "--mode", + "processed", + ], + ) + + assert result.exit_code == 0 + saved = _load_json(journal_env / "config" / "journal.json") + assert saved["retention"]["per_stream"]["plaud"]["raw_media"] == "processed" + + +def test_clear_per_stream(journal_env): + _write_config( + journal_env, + { + "retention": { + "raw_media": "days", + "raw_media_days": 7, + "per_stream": {"plaud": {"raw_media": "processed"}}, + } + }, + ) + + result = runner.invoke( + call_app, + ["journal", "retention", "config", "--stream", "plaud", "--clear"], + ) + + assert result.exit_code == 0 + saved = _load_json(journal_env / "config" / "journal.json") + assert saved["retention"].get("per_stream") is None + + +def test_clear_without_stream(journal_env): + result = runner.invoke(call_app, ["journal", "retention", "config", "--clear"]) + + assert result.exit_code == 1 + assert "--clear requires --stream" in result.output + + +def test_invalid_mode(journal_env): + result = runner.invoke( + call_app, ["journal", "retention", "config", "--mode", "invalid"] + ) + + assert result.exit_code == 1 + assert "Invalid mode: invalid. Must be keep, days, or processed." in result.output + + +def test_clear_with_mode_rejected(journal_env): + result = runner.invoke( + call_app, + ["journal", "retention", "config", "--stream", "plaud", "--clear", "--mode", "keep"], + ) + + assert result.exit_code == 1 + assert "--clear cannot be combined with --mode or --days" in result.output + + +def test_negative_days_rejected(journal_env): + result = runner.invoke( + call_app, ["journal", "retention", "config", "--mode", "keep", "--days", "-1"] + ) + + assert result.exit_code == 1 + assert "--days must be a positive integer" in result.output + + +def test_action_logged(journal_env): + result = runner.invoke( + call_app, ["journal", "retention", "config", "--mode", "keep"] + ) + + assert result.exit_code == 0 + + action_files = list((journal_env / "config" / "actions").glob("*.jsonl")) + assert len(action_files) == 1 + entries = action_files[0].read_text(encoding="utf-8").strip().splitlines() + payload = json.loads(entries[-1]) + assert payload["action"] == "retention_config" diff --git a/think/journal_default.json b/think/journal_default.json index ee5942684..c2ac2f369 100644 --- a/think/journal_default.json +++ b/think/journal_default.json @@ -33,8 +33,8 @@ "proposal_count": 0 }, "retention": { - "raw_media": "keep", - "raw_media_days": null, + "raw_media": "days", + "raw_media_days": 7, "per_stream": {}, "storage_warning_disk_percent": 80, "storage_warning_raw_media_gb": null diff --git a/think/retention.py b/think/retention.py index b8364d4ca..e1317f219 100644 --- a/think/retention.py +++ b/think/retention.py @@ -6,8 +6,8 @@ Manages the lifecycle of raw media files (layer 1 captures) in journal segments. Three retention modes: - keep: retain raw media indefinitely -- days: delete raw media after N days, once processing is complete -- processed: delete raw media as soon as processing completes (default) +- days: delete raw media after N days, once processing is complete (default: 7) +- processed: delete raw media as soon as processing completes Safety invariant: never delete raw media from segments that haven't finished processing. All completion checks must pass before any deletion. @@ -142,8 +142,8 @@ def _get_completion_files(segment_path: Path) -> list[Path]: class RetentionPolicy: """Retention policy for a single scope (global or per-stream).""" - mode: str = "processed" # "keep", "days", or "processed" - days: int | None = None + mode: str = "days" # "keep", "days", or "processed" + days: int | None = 7 def is_eligible(self, segment_age_days: int) -> bool: """Check if a segment's raw media should be purged under this policy.""" @@ -175,8 +175,8 @@ def load_retention_config() -> RetentionConfig: config = get_config() retention = config.get("retention", {}) - mode = retention.get("raw_media", "processed") - days = retention.get("raw_media_days") + mode = retention.get("raw_media", "days") + days = retention.get("raw_media_days", 7) default = RetentionPolicy(mode=mode, days=days) per_stream: dict[str, RetentionPolicy] = {} diff --git a/think/tools/call.py b/think/tools/call.py index b7df0dd2f..e1d70b7fb 100644 --- a/think/tools/call.py +++ b/think/tools/call.py @@ -950,6 +950,118 @@ def purge( ) +@retention_app.command() +def config( + mode: str | None = typer.Option( + None, "--mode", help="Retention mode: keep, days, or processed." + ), + days: int | None = typer.Option( + None, "--days", help="Days to retain (required when mode is 'days')." + ), + stream: str | None = typer.Option( + None, "--stream", help="Apply to a specific stream instead of global." + ), + clear: bool = typer.Option( + False, "--clear", help="Clear per-stream override (requires --stream)." + ), +) -> None: + """Show or update retention configuration.""" + import os + + from think.retention import load_retention_config + from think.utils import get_config, get_journal + + if mode is None and days is None and not clear: + cfg = load_retention_config() + result = { + "default": {"mode": cfg.default.mode, "days": cfg.default.days}, + "per_stream": { + name: {"mode": policy.mode, "days": policy.days} + for name, policy in cfg.per_stream.items() + }, + } + typer.echo(json.dumps(result, indent=2)) + return + + if clear: + if not stream: + typer.echo("--clear requires --stream", err=True) + raise typer.Exit(1) + if mode is not None or days is not None: + typer.echo("--clear cannot be combined with --mode or --days", err=True) + raise typer.Exit(1) + + if mode is not None and mode not in ("keep", "days", "processed"): + typer.echo( + f"Invalid mode: {mode}. Must be keep, days, or processed.", err=True + ) + raise typer.Exit(1) + + if mode == "days" and days is None: + typer.echo("--days is required when mode is 'days'.", err=True) + raise typer.Exit(1) + + if days is not None and days < 1: + typer.echo("--days must be a positive integer.", err=True) + raise typer.Exit(1) + + journal_config = get_config() + retention = journal_config.setdefault("retention", {}) + + if clear: + ps = retention.get("per_stream", {}) + if stream in ps: + del ps[stream] + if not ps: + retention.pop("per_stream", None) + log_call_action( + facet=None, + action="retention_config", + params={"stream": stream, "clear": True}, + ) + elif stream: + ps = retention.setdefault("per_stream", {}) + entry = ps.setdefault(stream, {}) + if mode is not None: + entry["raw_media"] = mode + if days is not None: + entry["raw_media_days"] = days + log_call_action( + facet=None, + action="retention_config", + params={"stream": stream, "mode": mode, "days": days}, + ) + else: + if mode is not None: + retention["raw_media"] = mode + if days is not None: + retention["raw_media_days"] = days + log_call_action( + facet=None, + action="retention_config", + params={"mode": mode, "days": days}, + ) + + config_dir = Path(get_journal()) / "config" + config_dir.mkdir(parents=True, exist_ok=True) + config_path = config_dir / "journal.json" + + with open(config_path, "w", encoding="utf-8") as f: + json.dump(journal_config, f, indent=2, ensure_ascii=False) + f.write("\n") + os.chmod(config_path, 0o600) + + cfg = load_retention_config() + result = { + "default": {"mode": cfg.default.mode, "days": cfg.default.days}, + "per_stream": { + name: {"mode": policy.mode, "days": policy.days} + for name, policy in cfg.per_stream.items() + }, + } + typer.echo(json.dumps(result, indent=2)) + + @app.command(name="storage-summary") def storage_summary( json_output: bool = typer.Option(False, "--json", help="Output as JSON."),