diff --git a/db/migrations/076_septum_theta.sql b/db/migrations/076_septum_theta.sql new file mode 100644 index 0000000..60f7dd5 --- /dev/null +++ b/db/migrations/076_septum_theta.sql @@ -0,0 +1,63 @@ +-- Migration 076: medial septum + theta rhythm — Phase 1 schema +-- +-- Avenue 8 from research/autonomous-research-avenues-2026-05-20.md. +-- Medial septum is the hippocampal theta pacemaker (4-8 Hz rhythm). +-- The cmd_search docstring already mentions "theta-gamma coupling" +-- ("Result count is capped at 7 × agent attention_budget_tier") but +-- there's no actual theta clock. +-- +-- Phase 1 ships: +-- septum_state — single row tracking current phase + bin + cycle count +-- septum_ticks — log of theta-cycle ticks (heartbeat) +-- septum_phase_locked_memories — index of which theta bin each +-- memory was written/recalled in +-- +-- Phase 1 = manual tick advancement + queries. Phase 2 = daemon-driven +-- automatic ticking on a configurable cadence. Phase 3 = phase-locked +-- memory_search (only memories from the current theta bin). +-- +-- Rollback: +-- DROP TABLE IF EXISTS septum_phase_locked_memories; +-- DROP TABLE IF EXISTS septum_ticks; +-- DROP TABLE IF EXISTS septum_state; +-- DELETE FROM schema_version WHERE version = 76; +-- +-- IDEMPOTENT. + +CREATE TABLE IF NOT EXISTS septum_state ( + id INTEGER PRIMARY KEY CHECK (id = 1), + theta_frequency_hz REAL NOT NULL DEFAULT 6.0 CHECK(theta_frequency_hz BETWEEN 4.0 AND 8.0), + theta_phase REAL NOT NULL DEFAULT 0.0 CHECK(theta_phase BETWEEN 0.0 AND 6.283185307), -- radians + theta_bin INTEGER NOT NULL DEFAULT 0 CHECK(theta_bin BETWEEN 0 AND 7), -- 8 bins per cycle (45°) + cycle_count INTEGER NOT NULL DEFAULT 0, + last_tick_at TEXT, + enabled INTEGER NOT NULL DEFAULT 0, -- 0=disabled, 1=enabled (Phase 2 daemon flag) + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now')) +); +INSERT OR IGNORE INTO septum_state (id) VALUES (1); + +CREATE TABLE IF NOT EXISTS septum_ticks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ticked_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now')), + cycle_count INTEGER NOT NULL, + theta_bin INTEGER NOT NULL, + triggered_by TEXT -- 'manual' | 'daemon' | 'aras_signal' +); +CREATE INDEX IF NOT EXISTS idx_septum_ticks_recent ON septum_ticks(ticked_at); +CREATE INDEX IF NOT EXISTS idx_septum_ticks_cycle ON septum_ticks(cycle_count); + +CREATE TABLE IF NOT EXISTS septum_phase_locked_memories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + memory_id INTEGER NOT NULL, + locked_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now')), + theta_bin INTEGER NOT NULL, + cycle_count INTEGER NOT NULL, + operation TEXT NOT NULL CHECK(operation IN ('write', 'recall', 'reconsolidate')), + UNIQUE (memory_id, locked_at, operation) +); +CREATE INDEX IF NOT EXISTS idx_splm_bin ON septum_phase_locked_memories(theta_bin, locked_at); +CREATE INDEX IF NOT EXISTS idx_splm_memory ON septum_phase_locked_memories(memory_id); + +INSERT OR IGNORE INTO schema_version (version, description, applied_at) +VALUES (76, 'septum + theta rhythm Phase 1: 3 tables for hippocampal theta pacemaker', + strftime('%Y-%m-%dT%H:%M:%S', 'now')); diff --git a/src/agentmemory/mcp_server.py b/src/agentmemory/mcp_server.py index 95e5f45..58cce6b 100755 --- a/src/agentmemory/mcp_server.py +++ b/src/agentmemory/mcp_server.py @@ -70,6 +70,7 @@ mcp_tools_reconcile, mcp_tools_reflexion, mcp_tools_scheduler, + mcp_tools_septum_theta, mcp_tools_telemetry, mcp_tools_temporal, mcp_tools_temporal_abstraction, @@ -113,6 +114,7 @@ mcp_tools_reconcile, mcp_tools_reflexion, mcp_tools_scheduler, + mcp_tools_septum_theta, mcp_tools_telemetry, mcp_tools_temporal, mcp_tools_temporal_abstraction, diff --git a/src/agentmemory/mcp_tools_septum_theta.py b/src/agentmemory/mcp_tools_septum_theta.py new file mode 100644 index 0000000..ebb3eb4 --- /dev/null +++ b/src/agentmemory/mcp_tools_septum_theta.py @@ -0,0 +1,269 @@ +"""brainctl MCP tools — medial septum + theta rhythm. + +Phase 1 per research-avenues memo Avenue 8. Hippocampal theta +pacemaker (4-8 Hz). Phase 1 = manual ticking + queries; Phase 2 daemon +auto-ticks; Phase 3 phase-locks memory_search to current theta bin. +""" +from __future__ import annotations + +import math +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_OPERATIONS = {"write", "recall", "reconsolidate"} +TWO_PI = 2.0 * math.pi + + +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 ("septum_state", "septum_ticks", "septum_phase_locked_memories"): + if not conn.execute( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (t,) + ).fetchone(): + return f"septum schema missing: {t}. Run `brainctl migrate` (076)." + return None + + +def _phase_to_bin(phase: float) -> int: + """Map theta_phase ∈ [0, 2π) to 8-bin index (45° each).""" + p = phase % TWO_PI + return int(p / (TWO_PI / 8)) % 8 + + +def tool_septum_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 septum_state WHERE id = 1").fetchone() + last_5 = _rows(conn.execute( + "SELECT * FROM septum_ticks ORDER BY id DESC LIMIT 5" + ).fetchall()) + bin_dist = _rows(conn.execute( + """ + SELECT theta_bin, COUNT(*) AS n + FROM septum_phase_locked_memories + WHERE locked_at >= datetime('now', '-1 hour') + GROUP BY theta_bin ORDER BY theta_bin + """ + ).fetchall()) + return { + "ok": True, + "state": dict(state) if state else None, + "last_5_ticks": last_5, + "phase_locks_by_bin_1h": bin_dist, + } + + +def tool_septum_tick(triggered_by: str = "manual", **_kw: Any) -> dict[str, Any]: + """Advance one tick of the theta rhythm. Each tick advances phase + by 2π/8 (one bin). After 8 ticks a full cycle completes.""" + with _db() as conn: + conn.row_factory = sqlite3.Row + err = _require_schema(conn) + if err: + return {"error": err} + state = conn.execute("SELECT * FROM septum_state WHERE id = 1").fetchone() + if not state: + return {"error": "septum_state seed row missing"} + # Advance phase by 2π/8 = π/4 + new_phase = (float(state["theta_phase"]) + (TWO_PI / 8.0)) % TWO_PI + new_bin = _phase_to_bin(new_phase) + new_cycle = int(state["cycle_count"]) + if new_bin < state["theta_bin"]: + # Wrapped around — completed a cycle + new_cycle += 1 + conn.execute( + """ + UPDATE septum_state SET + theta_phase = ?, theta_bin = ?, cycle_count = ?, + last_tick_at = strftime('%Y-%m-%dT%H:%M:%S', 'now'), + updated_at = strftime('%Y-%m-%dT%H:%M:%S', 'now') + WHERE id = 1 + """, + (new_phase, new_bin, new_cycle), + ) + cur = conn.execute( + """ + INSERT INTO septum_ticks (cycle_count, theta_bin, triggered_by) + VALUES (?, ?, ?) + """, + (new_cycle, new_bin, triggered_by), + ) + tick_id = cur.lastrowid + conn.commit() + return { + "ok": True, "tick_id": tick_id, + "theta_phase": new_phase, "theta_bin": new_bin, + "cycle_count": new_cycle, + } + + +def tool_septum_phase_lock( + memory_id: int, operation: str = "write", + **_kw: Any, +) -> dict[str, Any]: + """Record that a memory operation occurred at the current theta bin. + operation ∈ {write, recall, reconsolidate}.""" + if operation not in VALID_OPERATIONS: + return {"error": f"invalid operation {operation!r}; expected {sorted(VALID_OPERATIONS)}"} + with _db() as conn: + conn.row_factory = sqlite3.Row + err = _require_schema(conn) + if err: + return {"error": err} + state = conn.execute("SELECT theta_bin, cycle_count FROM septum_state WHERE id = 1").fetchone() + if not state: + return {"error": "septum_state seed row missing"} + cur = conn.execute( + """ + INSERT INTO septum_phase_locked_memories + (memory_id, theta_bin, cycle_count, operation) + VALUES (?, ?, ?, ?) + """, + (int(memory_id), int(state["theta_bin"]), int(state["cycle_count"]), operation), + ) + conn.commit() + return { + "ok": True, "lock_id": cur.lastrowid, + "memory_id": int(memory_id), "theta_bin": int(state["theta_bin"]), + "cycle_count": int(state["cycle_count"]), + } + + +def tool_septum_query_bin(theta_bin: int, limit: int = 50, **_kw: Any) -> dict[str, Any]: + """Return memory_ids phase-locked to a specific theta bin.""" + if not 0 <= theta_bin <= 7: + return {"error": "theta_bin must be in [0, 7]"} + limit = max(1, min(int(limit), 500)) + with _db() as conn: + conn.row_factory = sqlite3.Row + err = _require_schema(conn) + if err: + return {"error": err} + rows = conn.execute( + """ + SELECT id, memory_id, locked_at, theta_bin, cycle_count, operation + FROM septum_phase_locked_memories + WHERE theta_bin = ? + ORDER BY id DESC LIMIT ? + """, + (theta_bin, limit), + ).fetchall() + return {"ok": True, "theta_bin": theta_bin, "memories": _rows(rows)} + + +def tool_septum_set_frequency(theta_frequency_hz: float, **_kw: Any) -> dict[str, Any]: + """Update theta_frequency_hz. Valid range [4.0, 8.0] (biological theta band).""" + if not 4.0 <= theta_frequency_hz <= 8.0: + return {"error": "theta_frequency_hz must be in [4.0, 8.0] (biological theta band)"} + with _db() as conn: + conn.row_factory = sqlite3.Row + err = _require_schema(conn) + if err: + return {"error": err} + conn.execute( + """ + UPDATE septum_state SET + theta_frequency_hz = ?, + updated_at = strftime('%Y-%m-%dT%H:%M:%S', 'now') + WHERE id = 1 + """, + (float(theta_frequency_hz),), + ) + conn.commit() + state = conn.execute("SELECT * FROM septum_state WHERE id = 1").fetchone() + return {"ok": True, "state": dict(state) if state else None} + + +TOOLS: list[Tool] = [ + Tool( + name="septum_status", + description="Septum + theta state: frequency, current phase + bin (0-7), cycle count, last 5 ticks, 1h phase-lock distribution.", + inputSchema={"type": "object", "properties": {}}, + ), + Tool( + name="septum_tick", + description=( + "Advance one theta tick (one 8th of a cycle, 45°). Wraps to new cycle every 8 ticks. " + "Phase 1 is manual; Phase 2 daemon will auto-tick at theta_frequency_hz cadence." + ), + inputSchema={ + "type": "object", + "properties": { + "triggered_by": {"type": "string", "default": "manual"}, + }, + }, + ), + Tool( + name="septum_phase_lock", + description=( + "Stamp a memory operation with the current theta_bin + cycle. operation ∈ " + "{write, recall, reconsolidate}. Used by Phase 3 phase-locked memory_search." + ), + inputSchema={ + "type": "object", + "properties": { + "memory_id": {"type": "integer"}, + "operation": {"type": "string", "enum": sorted(VALID_OPERATIONS), "default": "write"}, + }, + "required": ["memory_id"], + }, + ), + Tool( + name="septum_query_bin", + description="List memory_ids phase-locked to a specific theta_bin (0-7). limit clamped to [1, 500].", + inputSchema={ + "type": "object", + "properties": { + "theta_bin": {"type": "integer", "minimum": 0, "maximum": 7}, + "limit": {"type": "integer", "default": 50}, + }, + "required": ["theta_bin"], + }, + ), + Tool( + name="septum_set_frequency", + description="Update theta_frequency_hz. Valid range [4.0, 8.0] (biological theta band).", + inputSchema={ + "type": "object", + "properties": {"theta_frequency_hz": {"type": "number"}}, + "required": ["theta_frequency_hz"], + }, + ), +] + + +_SEPTUM_TOOLS = { + "septum_status": tool_septum_status, + "septum_tick": tool_septum_tick, + "septum_phase_lock": tool_septum_phase_lock, + "septum_query_bin": tool_septum_query_bin, + "septum_set_frequency": tool_septum_set_frequency, +} + +DISPATCH: dict[str, Any] = { + name: (lambda _func=func, **kw: _func(**kw)) + for name, func in _SEPTUM_TOOLS.items() +} + + +def register_tools() -> tuple[list[Tool], dict[str, Any]]: + return TOOLS, DISPATCH diff --git a/tests/test_mcp_tools_septum_theta.py b/tests/test_mcp_tools_septum_theta.py new file mode 100644 index 0000000..adbc4a5 --- /dev/null +++ b/tests/test_mcp_tools_septum_theta.py @@ -0,0 +1,116 @@ +"""Tests for mcp_tools_septum_theta — Phase 1.""" +from __future__ import annotations + +import sqlite3 +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +MIGRATION_076 = REPO_ROOT / "db" / "migrations" / "076_septum_theta.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_076.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_septum_theta as mod + monkeypatch.setattr(mod, "DB_PATH", db) + return mod + + +def test_migration_seeds_state(tmp_path): + db = tmp_path / "brain.db" + _apply(db) + conn = sqlite3.connect(str(db)) + try: + state = conn.execute( + "SELECT theta_frequency_hz, theta_bin, cycle_count, enabled FROM septum_state" + ).fetchone() + assert state == (6.0, 0, 0, 0) + finally: + conn.close() + + +def test_tick_advances_phase_and_bin(tmp_path, monkeypatch): + mod = _make_db(tmp_path, monkeypatch) + out = mod.tool_septum_tick() + assert out["ok"] is True + assert out["theta_bin"] == 1 + assert out["cycle_count"] == 0 + + +def test_eight_ticks_complete_one_cycle(tmp_path, monkeypatch): + mod = _make_db(tmp_path, monkeypatch) + for _ in range(7): + mod.tool_septum_tick() + # 8th tick should wrap to bin 0 and increment cycle + final = mod.tool_septum_tick() + assert final["theta_bin"] == 0 + assert final["cycle_count"] == 1 + + +def test_phase_lock_records_current_bin(tmp_path, monkeypatch): + mod = _make_db(tmp_path, monkeypatch) + mod.tool_septum_tick() # bin 1 + mod.tool_septum_tick() # bin 2 + out = mod.tool_septum_phase_lock(memory_id=42, operation="write") + assert out["ok"] is True + assert out["theta_bin"] == 2 + + +def test_phase_lock_validates_operation(tmp_path, monkeypatch): + mod = _make_db(tmp_path, monkeypatch) + out = mod.tool_septum_phase_lock(memory_id=1, operation="bogus") + assert "error" in out + + +def test_query_bin_filters(tmp_path, monkeypatch): + mod = _make_db(tmp_path, monkeypatch) + # Lock memory 100 at bin 0 (initial) + mod.tool_septum_phase_lock(memory_id=100, operation="write") + # Tick to bin 3, lock memory 200 + for _ in range(3): + mod.tool_septum_tick() + mod.tool_septum_phase_lock(memory_id=200, operation="write") + bin_0 = mod.tool_septum_query_bin(theta_bin=0) + assert len(bin_0["memories"]) == 1 + assert bin_0["memories"][0]["memory_id"] == 100 + bin_3 = mod.tool_septum_query_bin(theta_bin=3) + assert len(bin_3["memories"]) == 1 + assert bin_3["memories"][0]["memory_id"] == 200 + + +def test_query_bin_validates(tmp_path, monkeypatch): + mod = _make_db(tmp_path, monkeypatch) + assert "error" in mod.tool_septum_query_bin(theta_bin=99) + + +def test_set_frequency_validates(tmp_path, monkeypatch): + mod = _make_db(tmp_path, monkeypatch) + assert "error" in mod.tool_septum_set_frequency(theta_frequency_hz=2.0) + assert "error" in mod.tool_septum_set_frequency(theta_frequency_hz=15.0) + out = mod.tool_septum_set_frequency(theta_frequency_hz=5.5) + assert out["ok"] is True + assert out["state"]["theta_frequency_hz"] == 5.5 + + +def test_status_returns_state(tmp_path, monkeypatch): + mod = _make_db(tmp_path, monkeypatch) + out = mod.tool_septum_status() + assert out["ok"] is True + assert out["state"]["theta_bin"] == 0 + assert out["last_5_ticks"] == []