From 0f8646950672e3a9bdf64fb011a6f5795791df8d Mon Sep 17 00:00:00 2001 From: R4vager Date: Wed, 20 May 2026 01:24:56 -0400 Subject: [PATCH] Memory Aging Phase 1: synaptic tagging-and-capture (Avenue 2) Frey & Morris late-LTP: tag at encoding + PRPs within window = memory persists. brainctl analog: W(m) gate = tag; recall within window = capture. Phase 1 = inspection + manual tag/capture/sweep (shadow mode default). Phase 2 auto-tags on memory_add. Phase 3 demotes uncaptured. Phase 4 enforces aggressive demotion of unrecalled tags. - Migration 078: 3 tables (memory_aging_state, memory_tags, memory_capture_events). Default capture_window_hours=24, demotion_tier='unconsolidated', enforcement_mode='shadow'. - 6 MCP tools: status, tag, capture, sweep, set, tag_get. - 10 tests. Co-Authored-By: Claude Opus 4.7 (1M context) --- db/migrations/078_memory_aging.sql | 81 +++++ src/agentmemory/mcp_server.py | 2 + src/agentmemory/mcp_tools_memory_aging.py | 366 ++++++++++++++++++++++ tests/test_mcp_tools_memory_aging.py | 148 +++++++++ 4 files changed, 597 insertions(+) create mode 100644 db/migrations/078_memory_aging.sql create mode 100644 src/agentmemory/mcp_tools_memory_aging.py create mode 100644 tests/test_mcp_tools_memory_aging.py diff --git a/db/migrations/078_memory_aging.sql b/db/migrations/078_memory_aging.sql new file mode 100644 index 0000000..22a30e4 --- /dev/null +++ b/db/migrations/078_memory_aging.sql @@ -0,0 +1,81 @@ +-- Migration 078: memory aging — synaptic tagging-and-capture +-- +-- Avenue 2 from research/autonomous-research-avenues-2026-05-20.md. +-- Frey & Morris's synaptic tagging-and-capture hypothesis: memory's +-- late-LTP requires both a TAG (at the synapse during initial encoding) +-- AND plasticity-related proteins (PRPs) showing up within ~1 hour. +-- +-- brainctl analog: W(m) gate is the *tag* — "this is plausibly worth +-- keeping". What's missing is the **capture** step that decides +-- whether the memory actually lasts past short-term, conditional on +-- a follow-up signal (typically recall within a critical window). +-- +-- Phase 1 ships: +-- memory_tags — per-memory tag with capture deadline + status +-- memory_capture_events — log of capture events (recall, association) +-- that "consume" the PRP-equivalent +-- memory_aging_state — single-row config (capture_window_hours, +-- demotion_tier, default decay aggressiveness) +-- +-- Phase 1 = inspection + manual tag/capture. Phase 2 auto-tags on +-- memory_add. Phase 3 demotes uncaptured tags to a side tier +-- (memories_unconsolidated). Phase 4 enforces aggressive demotion. +-- +-- Rollback: +-- DROP TABLE IF EXISTS memory_capture_events; +-- DROP TABLE IF EXISTS memory_tags; +-- DROP TABLE IF EXISTS memory_aging_state; +-- DELETE FROM schema_version WHERE version = 78; +-- +-- IDEMPOTENT. + +CREATE TABLE IF NOT EXISTS memory_aging_state ( + id INTEGER PRIMARY KEY CHECK (id = 1), + capture_window_hours INTEGER NOT NULL DEFAULT 24 CHECK(capture_window_hours > 0), + demotion_tier TEXT NOT NULL DEFAULT 'unconsolidated' CHECK(demotion_tier IN ( + 'unconsolidated', 'cold_storage', 'retired' + )), + enforcement_mode TEXT NOT NULL DEFAULT 'shadow' CHECK(enforcement_mode IN ( + 'shadow', 'enforce', 'disabled' + )), + total_tags INTEGER NOT NULL DEFAULT 0, + total_captured INTEGER NOT NULL DEFAULT 0, + total_demoted INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now')) +); +INSERT OR IGNORE INTO memory_aging_state (id) VALUES (1); + +CREATE TABLE IF NOT EXISTS memory_tags ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + memory_id INTEGER NOT NULL UNIQUE, + tagged_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now')), + capture_deadline TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'tagged' CHECK(status IN ( + 'tagged', 'captured', 'expired', 'demoted' + )), + captured_at TEXT, + capture_count INTEGER NOT NULL DEFAULT 0, + demoted_at TEXT, + notes TEXT +); +CREATE INDEX IF NOT EXISTS idx_mt_status ON memory_tags(status, tagged_at); +CREATE INDEX IF NOT EXISTS idx_mt_deadline ON memory_tags(capture_deadline) WHERE status = 'tagged'; +CREATE INDEX IF NOT EXISTS idx_mt_memory ON memory_tags(memory_id); + +CREATE TABLE IF NOT EXISTS memory_capture_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + captured_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now')), + memory_id INTEGER NOT NULL, + tag_id INTEGER REFERENCES memory_tags(id) ON DELETE SET NULL, + capture_kind TEXT NOT NULL CHECK(capture_kind IN ( + 'recall', 'reconsolidation', 'association', 'manual_capture', 'other' + )), + agent_id TEXT, + notes TEXT +); +CREATE INDEX IF NOT EXISTS idx_mce_recent ON memory_capture_events(captured_at); +CREATE INDEX IF NOT EXISTS idx_mce_kind ON memory_capture_events(capture_kind, captured_at); + +INSERT OR IGNORE INTO schema_version (version, description, applied_at) +VALUES (78, 'memory aging Phase 1: synaptic tagging-and-capture (3 tables, shadow mode default)', + strftime('%Y-%m-%dT%H:%M:%S', 'now')); diff --git a/src/agentmemory/mcp_server.py b/src/agentmemory/mcp_server.py index 95e5f45..426eab9 100755 --- a/src/agentmemory/mcp_server.py +++ b/src/agentmemory/mcp_server.py @@ -61,6 +61,7 @@ mcp_tools_knowledge, mcp_tools_lifecycle, mcp_tools_meb, + mcp_tools_memory_aging, mcp_tools_merge, mcp_tools_neuro, mcp_tools_pfc, @@ -104,6 +105,7 @@ mcp_tools_knowledge, mcp_tools_lifecycle, mcp_tools_meb, + mcp_tools_memory_aging, mcp_tools_merge, mcp_tools_neuro, mcp_tools_pfc, diff --git a/src/agentmemory/mcp_tools_memory_aging.py b/src/agentmemory/mcp_tools_memory_aging.py new file mode 100644 index 0000000..814bd1d --- /dev/null +++ b/src/agentmemory/mcp_tools_memory_aging.py @@ -0,0 +1,366 @@ +"""brainctl MCP tools — memory aging (synaptic tagging-and-capture). + +Phase 1 per research-avenues memo Avenue 2. Frey & Morris's late-LTP +biology: a tag at encoding + PRPs within a critical window → memory +persists. Without PRPs → memory decays. + +brainctl analog: W(m) gate = tag; recall within window = PRP capture. +Phase 1 = inspection + manual tag/capture/sweep. Phase 2 auto-tags +on memory_add. Phase 3 demotes uncaptured tags. Phase 4 enforces. + +Default enforcement_mode = 'shadow' — bookkeeping only, no demotion. +""" +from __future__ import annotations + +import sqlite3 +from collections.abc import Iterable +from pathlib import Path +from typing import Any + +from mcp.types import Tool + +from agentmemory.lib.mcp_helpers import open_db +from agentmemory.paths import get_db_path + +DB_PATH: Path = get_db_path() + +VALID_STATUSES = {"tagged", "captured", "expired", "demoted"} +VALID_CAPTURE_KINDS = {"recall", "reconsolidation", "association", "manual_capture", "other"} +VALID_DEMOTION_TIERS = {"unconsolidated", "cold_storage", "retired"} +VALID_ENFORCEMENT_MODES = {"shadow", "enforce", "disabled"} + + +def _db() -> sqlite3.Connection: + return open_db(str(DB_PATH)) + + +def _rows(rows: Iterable[sqlite3.Row]) -> list[dict[str, Any]]: + return [dict(r) for r in rows] + + +def _require_schema(conn: sqlite3.Connection) -> str | None: + for t in ("memory_aging_state", "memory_tags", "memory_capture_events"): + if not conn.execute( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (t,) + ).fetchone(): + return f"memory_aging schema missing: {t}. Run `brainctl migrate` (078)." + return None + + +def tool_memory_aging_status(**_kw: Any) -> dict[str, Any]: + with _db() as conn: + conn.row_factory = sqlite3.Row + err = _require_schema(conn) + if err: + return {"error": err} + state = conn.execute("SELECT * FROM memory_aging_state WHERE id = 1").fetchone() + by_status = _rows(conn.execute( + "SELECT status, COUNT(*) AS n FROM memory_tags GROUP BY status" + ).fetchall()) + expiring_soon = conn.execute( + """ + SELECT COUNT(*) FROM memory_tags + WHERE status = 'tagged' AND capture_deadline <= datetime('now', '+1 hour') + """ + ).fetchone()[0] + already_overdue = conn.execute( + """ + SELECT COUNT(*) FROM memory_tags + WHERE status = 'tagged' AND capture_deadline < datetime('now') + """ + ).fetchone()[0] + recent_captures = _rows(conn.execute( + "SELECT * FROM memory_capture_events ORDER BY id DESC LIMIT 5" + ).fetchall()) + return { + "ok": True, + "state": dict(state) if state else None, + "tags_by_status": by_status, + "tagged_expiring_within_1h": expiring_soon, + "tagged_already_overdue": already_overdue, + "recent_captures": recent_captures, + } + + +def tool_memory_tag( + memory_id: int, window_hours: int | None = None, + notes: str | None = None, + **_kw: Any, +) -> dict[str, Any]: + """Tag a memory with a capture deadline. + + Idempotent: if memory_id already has a tag, returns the existing + one without changing the deadline. To extend a tag, call + memory_recapture_tag. + """ + with _db() as conn: + conn.row_factory = sqlite3.Row + err = _require_schema(conn) + if err: + return {"error": err} + existing = conn.execute( + "SELECT * FROM memory_tags WHERE memory_id = ?", (int(memory_id),) + ).fetchone() + if existing: + return {"ok": True, "tag": dict(existing), "preexisting": True} + # Use state's default window if window_hours not specified + if window_hours is None: + state = conn.execute("SELECT capture_window_hours FROM memory_aging_state WHERE id = 1").fetchone() + window_hours = int(state["capture_window_hours"]) if state else 24 + if window_hours <= 0: + return {"error": "window_hours must be > 0"} + cur = conn.execute( + """ + INSERT INTO memory_tags (memory_id, capture_deadline, status, notes) + VALUES (?, datetime('now', ? || ' hours'), 'tagged', ?) + """, + (int(memory_id), f"+{int(window_hours)}", notes), + ) + tag_id = cur.lastrowid + conn.execute( + "UPDATE memory_aging_state SET total_tags = total_tags + 1, updated_at = strftime('%Y-%m-%dT%H:%M:%S', 'now') WHERE id = 1" + ) + conn.commit() + row = conn.execute("SELECT * FROM memory_tags WHERE id = ?", (tag_id,)).fetchone() + return {"ok": True, "tag": dict(row) if row else None, "preexisting": False} + + +def tool_memory_capture( + memory_id: int, capture_kind: str = "recall", + agent_id: str | None = None, notes: str | None = None, + **_kw: Any, +) -> dict[str, Any]: + """Record a capture event for a memory. If the memory is in + 'tagged' status, flips it to 'captured'. Otherwise logs the event + against the existing status.""" + if capture_kind not in VALID_CAPTURE_KINDS: + return {"error": f"invalid capture_kind {capture_kind!r}; expected {sorted(VALID_CAPTURE_KINDS)}"} + with _db() as conn: + conn.row_factory = sqlite3.Row + err = _require_schema(conn) + if err: + return {"error": err} + tag = conn.execute( + "SELECT * FROM memory_tags WHERE memory_id = ?", (int(memory_id),) + ).fetchone() + cur = conn.execute( + """ + INSERT INTO memory_capture_events + (memory_id, tag_id, capture_kind, agent_id, notes) + VALUES (?, ?, ?, ?, ?) + """, + (int(memory_id), tag["id"] if tag else None, capture_kind, agent_id, notes), + ) + event_id = cur.lastrowid + flipped = False + if tag and tag["status"] == "tagged": + conn.execute( + """ + UPDATE memory_tags SET + status = 'captured', + captured_at = strftime('%Y-%m-%dT%H:%M:%S', 'now'), + capture_count = capture_count + 1 + WHERE id = ? + """, + (tag["id"],), + ) + conn.execute( + "UPDATE memory_aging_state SET total_captured = total_captured + 1 WHERE id = 1" + ) + flipped = True + elif tag: + conn.execute( + "UPDATE memory_tags SET capture_count = capture_count + 1 WHERE id = ?", + (tag["id"],), + ) + conn.commit() + return { + "ok": True, "event_id": event_id, "memory_id": int(memory_id), + "tag_id": tag["id"] if tag else None, + "flipped_to_captured": flipped, + } + + +def tool_memory_aging_sweep(**_kw: Any) -> dict[str, Any]: + """Sweep for tags past their capture deadline. In shadow mode, + just counts. In enforce mode, transitions them to 'expired' (and + increments total_demoted).""" + with _db() as conn: + conn.row_factory = sqlite3.Row + err = _require_schema(conn) + if err: + return {"error": err} + state = conn.execute("SELECT * FROM memory_aging_state WHERE id = 1").fetchone() + overdue = conn.execute( + """ + SELECT id, memory_id, capture_deadline FROM memory_tags + WHERE status = 'tagged' AND capture_deadline < datetime('now') + """ + ).fetchall() + n = len(overdue) + if state["enforcement_mode"] == "enforce" and n > 0: + ids = [r["id"] for r in overdue] + conn.executemany( + """ + UPDATE memory_tags SET + status = 'expired', + demoted_at = strftime('%Y-%m-%dT%H:%M:%S', 'now') + WHERE id = ? + """, + [(i,) for i in ids], + ) + conn.execute( + "UPDATE memory_aging_state SET total_demoted = total_demoted + ? WHERE id = 1", + (n,), + ) + conn.commit() + return {"ok": True, "swept_count": n, "demoted_count": n, "enforcement_mode": state["enforcement_mode"]} + return {"ok": True, "swept_count": n, "demoted_count": 0, "enforcement_mode": state["enforcement_mode"]} + + +def tool_memory_aging_set( + capture_window_hours: int | None = None, + demotion_tier: str | None = None, + enforcement_mode: str | None = None, + **_kw: Any, +) -> dict[str, Any]: + if capture_window_hours is not None and capture_window_hours <= 0: + return {"error": "capture_window_hours must be > 0"} + if demotion_tier is not None and demotion_tier not in VALID_DEMOTION_TIERS: + return {"error": f"invalid demotion_tier; expected {sorted(VALID_DEMOTION_TIERS)}"} + if enforcement_mode is not None and enforcement_mode not in VALID_ENFORCEMENT_MODES: + return {"error": f"invalid enforcement_mode; expected {sorted(VALID_ENFORCEMENT_MODES)}"} + with _db() as conn: + conn.row_factory = sqlite3.Row + err = _require_schema(conn) + if err: + return {"error": err} + updates, params = [], [] + if capture_window_hours is not None: + updates.append("capture_window_hours = ?"); params.append(int(capture_window_hours)) + if demotion_tier is not None: + updates.append("demotion_tier = ?"); params.append(demotion_tier) + if enforcement_mode is not None: + updates.append("enforcement_mode = ?"); params.append(enforcement_mode) + if not updates: + return {"error": "no fields to update"} + updates.append("updated_at = strftime('%Y-%m-%dT%H:%M:%S', 'now')") + conn.execute(f"UPDATE memory_aging_state SET {', '.join(updates)} WHERE id = 1", tuple(params)) + conn.commit() + state = conn.execute("SELECT * FROM memory_aging_state WHERE id = 1").fetchone() + return {"ok": True, "state": dict(state) if state else None} + + +def tool_memory_tag_get(memory_id: int, **_kw: Any) -> dict[str, Any]: + with _db() as conn: + conn.row_factory = sqlite3.Row + err = _require_schema(conn) + if err: + return {"error": err} + tag = conn.execute( + "SELECT * FROM memory_tags WHERE memory_id = ?", (int(memory_id),) + ).fetchone() + if not tag: + return {"ok": True, "tag": None, "captures": []} + captures = _rows(conn.execute( + "SELECT * FROM memory_capture_events WHERE memory_id = ? ORDER BY id DESC LIMIT 50", + (int(memory_id),), + ).fetchall()) + return {"ok": True, "tag": dict(tag), "captures": captures} + + +TOOLS: list[Tool] = [ + Tool( + name="memory_aging_status", + description="Memory aging state + tags by status + expiring-soon counts + recent captures.", + inputSchema={"type": "object", "properties": {}}, + ), + Tool( + name="memory_tag", + description=( + "Tag a memory with a capture deadline (idempotent — re-tagging returns the " + "existing tag without changing the deadline). window_hours defaults to " + "state.capture_window_hours." + ), + inputSchema={ + "type": "object", + "properties": { + "memory_id": {"type": "integer"}, + "window_hours": {"type": "integer"}, + "notes": {"type": "string"}, + }, + "required": ["memory_id"], + }, + ), + Tool( + name="memory_capture", + description=( + "Record a capture event (recall / reconsolidation / association / manual). " + "If the memory is in 'tagged' status, flips to 'captured'. capture_kind ∈ " + "{recall, reconsolidation, association, manual_capture, other}." + ), + inputSchema={ + "type": "object", + "properties": { + "memory_id": {"type": "integer"}, + "capture_kind": {"type": "string", "enum": sorted(VALID_CAPTURE_KINDS), "default": "recall"}, + "agent_id": {"type": "string"}, + "notes": {"type": "string"}, + }, + "required": ["memory_id"], + }, + ), + Tool( + name="memory_aging_sweep", + description=( + "Sweep for tags past their capture_deadline. In shadow mode: counts only. " + "In enforce mode: transitions overdue tags to 'expired' and increments " + "total_demoted." + ), + inputSchema={"type": "object", "properties": {}}, + ), + Tool( + name="memory_aging_set", + description=( + "Update memory_aging_state. capture_window_hours > 0; demotion_tier ∈ " + "{unconsolidated, cold_storage, retired}; enforcement_mode ∈ " + "{shadow, enforce, disabled}." + ), + inputSchema={ + "type": "object", + "properties": { + "capture_window_hours": {"type": "integer"}, + "demotion_tier": {"type": "string", "enum": sorted(VALID_DEMOTION_TIERS)}, + "enforcement_mode": {"type": "string", "enum": sorted(VALID_ENFORCEMENT_MODES)}, + }, + }, + ), + Tool( + name="memory_tag_get", + description="Get a memory's tag + the last 50 capture events.", + inputSchema={ + "type": "object", + "properties": {"memory_id": {"type": "integer"}}, + "required": ["memory_id"], + }, + ), +] + + +_MA_TOOLS = { + "memory_aging_status": tool_memory_aging_status, + "memory_tag": tool_memory_tag, + "memory_capture": tool_memory_capture, + "memory_aging_sweep": tool_memory_aging_sweep, + "memory_aging_set": tool_memory_aging_set, + "memory_tag_get": tool_memory_tag_get, +} + +DISPATCH: dict[str, Any] = { + name: (lambda _func=func, **kw: _func(**kw)) + for name, func in _MA_TOOLS.items() +} + + +def register_tools() -> tuple[list[Tool], dict[str, Any]]: + return TOOLS, DISPATCH diff --git a/tests/test_mcp_tools_memory_aging.py b/tests/test_mcp_tools_memory_aging.py new file mode 100644 index 0000000..0941938 --- /dev/null +++ b/tests/test_mcp_tools_memory_aging.py @@ -0,0 +1,148 @@ +"""Tests for mcp_tools_memory_aging — Phase 1.""" +from __future__ import annotations + +import sqlite3 +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +MIGRATION_078 = REPO_ROOT / "db" / "migrations" / "078_memory_aging.sql" + + +def _bootstrap(conn): + conn.executescript( + "CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY, description TEXT, applied_at TEXT);" + ) + + +def _apply(db_path): + conn = sqlite3.connect(str(db_path)) + try: + _bootstrap(conn) + conn.executescript(MIGRATION_078.read_text()) + conn.commit() + finally: + conn.close() + + +def _make_db(tmp_path, monkeypatch): + db = tmp_path / "brain.db" + _apply(db) + from agentmemory import mcp_tools_memory_aging as mod + monkeypatch.setattr(mod, "DB_PATH", db) + return mod + + +def test_migration_with_defaults(tmp_path): + db = tmp_path / "brain.db" + _apply(db) + conn = sqlite3.connect(str(db)) + try: + state = conn.execute( + "SELECT capture_window_hours, demotion_tier, enforcement_mode FROM memory_aging_state" + ).fetchone() + assert state == (24, "unconsolidated", "shadow") + finally: + conn.close() + + +def test_tag_creates_with_deadline(tmp_path, monkeypatch): + mod = _make_db(tmp_path, monkeypatch) + out = mod.tool_memory_tag(memory_id=42) + assert out["ok"] is True + assert out["preexisting"] is False + assert out["tag"]["status"] == "tagged" + + +def test_tag_idempotent(tmp_path, monkeypatch): + mod = _make_db(tmp_path, monkeypatch) + first = mod.tool_memory_tag(memory_id=42, window_hours=12) + second = mod.tool_memory_tag(memory_id=42, window_hours=99) # ignored + assert first["tag"]["id"] == second["tag"]["id"] + assert second["preexisting"] is True + + +def test_capture_flips_tag(tmp_path, monkeypatch): + mod = _make_db(tmp_path, monkeypatch) + mod.tool_memory_tag(memory_id=42) + out = mod.tool_memory_capture(memory_id=42, capture_kind="recall", agent_id="a1") + assert out["ok"] is True + assert out["flipped_to_captured"] is True + tag = mod.tool_memory_tag_get(memory_id=42) + assert tag["tag"]["status"] == "captured" + assert tag["tag"]["capture_count"] == 1 + + +def test_capture_increments_count_after_first(tmp_path, monkeypatch): + mod = _make_db(tmp_path, monkeypatch) + mod.tool_memory_tag(memory_id=42) + mod.tool_memory_capture(memory_id=42) + mod.tool_memory_capture(memory_id=42) + tag = mod.tool_memory_tag_get(memory_id=42) + assert tag["tag"]["capture_count"] == 2 + assert tag["tag"]["status"] == "captured" # still captured + + +def test_capture_validates_kind(tmp_path, monkeypatch): + mod = _make_db(tmp_path, monkeypatch) + out = mod.tool_memory_capture(memory_id=1, capture_kind="bogus") + assert "error" in out + + +def test_sweep_shadow_counts_only(tmp_path, monkeypatch): + mod = _make_db(tmp_path, monkeypatch) + # Force-expire by creating a tag and then setting deadline in the past + mod.tool_memory_tag(memory_id=99) + conn = sqlite3.connect(str(tmp_path / "brain.db")) + try: + conn.execute( + "UPDATE memory_tags SET capture_deadline = datetime('now', '-1 hour') WHERE memory_id = 99" + ) + conn.commit() + finally: + conn.close() + out = mod.tool_memory_aging_sweep() + assert out["swept_count"] == 1 + assert out["demoted_count"] == 0 # shadow + # Tag should still be 'tagged' in shadow mode + tag = mod.tool_memory_tag_get(memory_id=99) + assert tag["tag"]["status"] == "tagged" + + +def test_sweep_enforce_demotes(tmp_path, monkeypatch): + mod = _make_db(tmp_path, monkeypatch) + mod.tool_memory_aging_set(enforcement_mode="enforce") + mod.tool_memory_tag(memory_id=99) + conn = sqlite3.connect(str(tmp_path / "brain.db")) + try: + conn.execute( + "UPDATE memory_tags SET capture_deadline = datetime('now', '-1 hour') WHERE memory_id = 99" + ) + conn.commit() + finally: + conn.close() + out = mod.tool_memory_aging_sweep() + assert out["swept_count"] == 1 + assert out["demoted_count"] == 1 + tag = mod.tool_memory_tag_get(memory_id=99) + assert tag["tag"]["status"] == "expired" + + +def test_set_validates(tmp_path, monkeypatch): + mod = _make_db(tmp_path, monkeypatch) + assert "error" in mod.tool_memory_aging_set(capture_window_hours=0) + assert "error" in mod.tool_memory_aging_set(demotion_tier="bogus") + assert "error" in mod.tool_memory_aging_set(enforcement_mode="bogus") + assert "error" in mod.tool_memory_aging_set() + + +def test_status_aggregates(tmp_path, monkeypatch): + mod = _make_db(tmp_path, monkeypatch) + mod.tool_memory_tag(memory_id=1) + mod.tool_memory_tag(memory_id=2) + mod.tool_memory_capture(memory_id=1) + out = mod.tool_memory_aging_status() + assert out["ok"] is True + by_status = {row["status"]: row["n"] for row in out["tags_by_status"]} + assert by_status.get("captured") == 1 + assert by_status.get("tagged") == 1 + assert out["state"]["total_tags"] >= 2