Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,29 @@ The proxy binary is automatically downloaded from [CLIProxyAPI](https://github.c
| `sessions.max_sessions` | int | `500` | Max active (non-archived) sessions before cleanup |
| `sessions.cron_session_mode` | string | `per_run` | `per_run` (unique session per cron run) or `reuse` (shared session per job) |

## Retention

Opt-in `nerve.db` maintenance. Disabled by default. When enabled, a background
pass every `interval_hours` drops the verbose `blocks`/`thinking` JSON of old,
already-memorized messages (keeping the rendered `content`), prunes append-only
telemetry and file snapshots older than `retention_days`, and checkpoints the
WAL. This frees space inside the database but does not shrink the file on disk;
run `nerve db vacuum` once (with the daemon stopped) to reclaim it.

| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `retention.enabled` | bool | `false` | Master switch for the background retention pass |
| `retention.retention_full_days` | int | `30` | Compact `blocks`/`thinking` of memorized messages older than this |
| `retention.retention_days` | int | `90` | Prune telemetry and file snapshots older than this |
| `retention.interval_hours` | int | `24` | How often the background pass runs |

Manual commands (run regardless of `enabled`):

- `nerve db prune [--dry-run]` runs one pass immediately. `--dry-run` reports
what would change without mutating.
- `nerve db vacuum` rewrites the file to reclaim freed pages. It takes a write
lock, so stop the daemon first.

## Cron

| Key | Type | Default | Description |
Expand Down
117 changes: 117 additions & 0 deletions nerve/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1560,5 +1560,122 @@ def backfill_timestamps(ctx: click.Context, dry_run: bool) -> None:
click.echo(f"\n{'Would update' if dry_run else 'Updated'} {updated} items, skipped {skipped}")


def _fmt_bytes(n: int) -> str:
"""Render a byte count as MB with one decimal."""
return f"{(n or 0) / (1024 * 1024):.1f} MB"


@main.group(name="db")
def db_group() -> None:
"""Database maintenance (prune old data, vacuum to reclaim space)."""


@db_group.command("prune")
@click.option("--dry-run", is_flag=True, help="Report what would change without mutating")
@click.pass_context
def db_prune(ctx: click.Context, dry_run: bool) -> None:
"""Compact old memorized messages and prune old telemetry + file snapshots.

Uses the configured retention windows (retention.retention_full_days for
message compaction, retention.retention_days for telemetry/snapshots).
Frees space inside the DB; run ``nerve db vacuum`` afterwards to shrink the
file on disk.
"""
from nerve.db import Database

config = ctx.obj["config"]
db_path = Path("~/.nerve/nerve.db").expanduser()
if not db_path.exists():
click.echo(f"[ERR] Database not found: {db_path}")
ctx.exit(1)
return

full_days = config.retention.retention_full_days
days = config.retention.retention_days
click.echo(
f"{'[dry-run] ' if dry_run else ''}Pruning {db_path} "
f"(compact messages > {full_days}d, telemetry/snapshots > {days}d)"
)

async def _run() -> None:
database = Database(db_path, workspace=config.workspace)
await database.connect()
try:
report = await database.run_retention(
retention_days=days,
retention_full_days=full_days,
dry_run=dry_run,
)
finally:
await database.close()

verb = "Would compact" if dry_run else "Compacted"
click.echo(
f" {verb} {report['messages_compacted']} messages "
f"(~{_fmt_bytes(report['message_bytes_reclaimed'])} of blocks/thinking)"
)
verb = "Would delete" if dry_run else "Deleted"
click.echo(f" {verb} {report['telemetry_deleted']} telemetry rows:")
for table, n in report["by_table"].items():
click.echo(f" {table}: {n}")
click.echo(
f" {verb} {report['snapshots_deleted']} file snapshots "
f"(~{_fmt_bytes(report['snapshot_bytes_reclaimed'])})"
)
if not dry_run:
click.echo("\nFreed space inside the DB. Run `nerve db vacuum` to shrink the file.")

asyncio.run(_run())


@db_group.command("vacuum")
@click.pass_context
def db_vacuum(ctx: click.Context) -> None:
"""Rewrite the DB file to reclaim freed pages (shrinks the file on disk).

VACUUM takes a write lock and cannot run while the daemon holds the DB.
Stop the daemon first (`nerve stop`) for a clean run.
"""
from nerve.db import Database

config = ctx.obj["config"]
db_path = Path("~/.nerve/nerve.db").expanduser()
if not db_path.exists():
click.echo(f"[ERR] Database not found: {db_path}")
ctx.exit(1)
return

running, _pid = _get_daemon_status()
if running:
click.echo(
"[WARN] Nerve daemon is running. VACUUM needs an exclusive lock and "
"will likely fail with 'database is locked'. Run `nerve stop` first."
)

size_before = db_path.stat().st_size
click.echo(f"Vacuuming {db_path} ({_fmt_bytes(size_before)})...")

async def _run() -> None:
database = Database(db_path, workspace=config.workspace)
await database.connect()
try:
await database.vacuum()
finally:
await database.close()

try:
asyncio.run(_run())
except Exception as e:
click.echo(f"[ERR] VACUUM failed: {e}")
ctx.exit(1)
return

size_after = db_path.stat().st_size
click.echo(
f"Done. {_fmt_bytes(size_before)} -> {_fmt_bytes(size_after)} "
f"(reclaimed {_fmt_bytes(size_before - size_after)})"
)


if __name__ == "__main__":
main()
34 changes: 34 additions & 0 deletions nerve/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -533,6 +533,38 @@ def from_dict(cls, d: dict) -> SessionsConfig:
)


@dataclass
class RetentionConfig:
"""Opt-in nerve.db retention: message compaction + telemetry pruning.

Disabled by default so an upstream merge mutates no existing user's data;
the operator opts in locally. When enabled, a background pass every
``interval_hours`` drops the verbose ``blocks``/``thinking`` JSON of old,
already-memorized, non-starred, non-active messages (keeping ``content``),
prunes append-only telemetry + file snapshots older than
``retention_days``, and checkpoints the WAL. The file is only shrunk by the
explicit ``nerve db vacuum`` command (VACUUM takes a write lock).

``retention_full_days`` is the message-compaction window (default 30);
``retention_days`` is the telemetry/snapshot window (default 90). Both
ints are clamped ``>= 1``.
"""

enabled: bool = False
retention_days: int = 90
retention_full_days: int = 30
interval_hours: int = 24

@classmethod
def from_dict(cls, d: dict) -> RetentionConfig:
return cls(
enabled=bool(d.get("enabled", False)),
retention_days=max(1, int(d.get("retention_days", 90))),
retention_full_days=max(1, int(d.get("retention_full_days", 30))),
interval_hours=max(1, int(d.get("interval_hours", 24))),
)


@dataclass
class AuthConfig:
password_hash: str = ""
Expand Down Expand Up @@ -957,6 +989,7 @@ class NerveConfig:
cron: CronConfig = field(default_factory=CronConfig)
backup: BackupConfig = field(default_factory=BackupConfig)
sessions: SessionsConfig = field(default_factory=SessionsConfig)
retention: RetentionConfig = field(default_factory=RetentionConfig)
auth: AuthConfig = field(default_factory=AuthConfig)
channels: ChannelsConfig = field(default_factory=ChannelsConfig)
notifications: NotificationsConfig = field(default_factory=NotificationsConfig)
Expand Down Expand Up @@ -1073,6 +1106,7 @@ def from_dict(cls, d: dict) -> NerveConfig:
cron=CronConfig.from_dict(d.get("cron", {})),
backup=BackupConfig.from_dict(d.get("backup", {})),
sessions=SessionsConfig.from_dict(d.get("sessions", {})),
retention=RetentionConfig.from_dict(d.get("retention", {})),
auth=AuthConfig.from_dict(d.get("auth", {})),
channels=ChannelsConfig.from_dict(d.get("channels", {})),
notifications=NotificationsConfig.from_dict(d.get("notifications", {})),
Expand Down
2 changes: 2 additions & 0 deletions nerve/db/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from nerve.db.audit import AuditStore
from nerve.db.cron import CronStore
from nerve.db.files import FileStore
from nerve.db.maintenance import MaintenanceStore
from nerve.db.mcp import McpStore
from nerve.db.messages import MessageStore
from nerve.db.migrations.runner import discover_migrations, run_migrations
Expand Down Expand Up @@ -53,6 +54,7 @@ class Database(
UsageStore,
FileStore,
WakeupStore,
MaintenanceStore,
):
"""Async SQLite database wrapper.
Expand Down
Loading