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."),