From eca45908d14b25dd49d0ea72cb0adfbc2cacea83 Mon Sep 17 00:00:00 2001 From: R4vager Date: Tue, 19 May 2026 23:44:47 -0400 Subject: [PATCH 1/2] locus coeruleus Phase 1: schema + read/CRUD tools --- CHANGELOG.md | 29 ++ MCP_SERVER.md | 3 +- db/migrations/067_locus_coeruleus.sql | 87 ++++ docs/proposals/brain_region_coverage.md | 2 +- docs/proposals/locus_coeruleus.md | 131 +++++ src/agentmemory/db/init_schema.sql | 94 ++++ src/agentmemory/mcp_server.py | 14 + src/agentmemory/mcp_tools_locus_coeruleus.py | 486 +++++++++++++++++++ tests/test_mcp_tools_locus_coeruleus.py | 134 +++++ 9 files changed, 978 insertions(+), 2 deletions(-) create mode 100644 db/migrations/067_locus_coeruleus.sql create mode 100644 docs/proposals/locus_coeruleus.md create mode 100644 src/agentmemory/mcp_tools_locus_coeruleus.py create mode 100644 tests/test_mcp_tools_locus_coeruleus.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c17766f..c3e673a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,35 @@ Format follows [Keep a Changelog](https://keepachangelog.com/). ## [Unreleased] +### Added — Locus Coeruleus Phase 1 (schema + read+CRUD tools) + +Adds the LC norepinephrine-readiness layer that the brain-region +coverage audit had flagged as missing: a concrete place for surprise, +novelty, and explicit alert events to become phasic LC activations +before Phase 2 wires them into `bg_modulators.lc_ne`. + +- **Migration 067** — `lc_triggers`, `lc_firings`, and `lc_state`. + The trigger catalog is seeded with cerebellum high prediction error, + BG large TD error, novel entity sighting, and explicit user alert + rows. `lc_state` is a single broadcast row seeded to `tonic_mid` + with `ne_reservoir=0.5`. Indexes cover recent firings, agent + history, trigger history, and source-table trigger lookup. + +- **`agentmemory.mcp_tools_locus_coeruleus`** — five Phase 1 tools: + `lc_status`, `lc_fire`, `lc_register_trigger`, `lc_signal_history`, + and `lc_set_mode`. `lc_status` reads the existing + `bg_modulators.lc_ne` dial when present, but no LC tool writes that + BG modulator in Phase 1. + +- **Docs/tests** — proposal at `docs/proposals/locus_coeruleus.md`, + MCP category entry, coverage-tracker update, and focused pytest + coverage for migration seeds, empty status, idempotent trigger + registration, manual firing, mode validation, and history filters. + +Phase 2 remains explicitly out of scope: no cerebellum/BG/novelty +auto-fire hookpoints and no NE broadcast writes to `bg_modulators.lc_ne` +land in this change. + ### Added — issue #116 Phase 1-A: retrieval pathway log External architecture memo (issue #116, "Thalamus, Basal Ganglia, and diff --git a/MCP_SERVER.md b/MCP_SERVER.md index f26c00c..0f4a04b 100644 --- a/MCP_SERVER.md +++ b/MCP_SERVER.md @@ -50,7 +50,7 @@ docker run -v ~/.agentmemory:/data -e BRAIN_DB=/data/brain.db brainctl The `CMD` defaults to `brainctl-mcp`, so the container runs the MCP server over stdio. -## Available Tools (260) +## Available Tools (265) | Tool | Description | |------|-------------| @@ -155,6 +155,7 @@ server over stdio. | Affect | `affect_classify`, `affect_log`, `affect_check`, `affect_monitor` | Emotional state tracking | | Thalamus (Phase 1+2, shadow gate) | `thalamus_status`, `thalamus_salience`, `thalamus_relay_create`, `thalamus_gate_set`, `thalamus_burst`, `thalamus_mode_set`, `thalamus_shadow_stats` | Typed routing layer + integrated salience scoring + shadow-mode gate consult on every W(m) write (see `docs/proposals/thalamus.md`) | | Basal Ganglia (Phase 1+2+3 + holds + cascade) | `bg_status`, `bg_action_register`, `bg_modulator_set`, `bg_td_emit`, `bg_shadow_stats`, `bg_sweep_traces`, `bg_weights_show`, `bg_hold_trigger`, `bg_hold_release`, `bg_holds_active` | Five parallel loops + opponent Go/NoGo learning from real outcomes (three-factor rule) + dispatch shadow + outcome→δ wired into `outcome_annotate` + hyperdirect holds + cascade to thalamus (see `docs/proposals/basal_ganglia.md`) | +| Locus Coeruleus (Phase 1, NE readiness) | `lc_status`, `lc_fire`, `lc_register_trigger`, `lc_signal_history`, `lc_set_mode` | Surprise / novelty trigger catalog + LC activation log + single-row tonic/phasic state. Reads `bg_modulators.lc_ne`; Phase 2 will write it from cerebellum/BG/novelty events (see `docs/proposals/locus_coeruleus.md`) | | Cerebellum (Phase 1+2+3, predict/observe + auto-wire) | `cerebellum_status`, `cerebellum_module_register`, `cerebellum_predict`, `cerebellum_observe` | Forward-model layer per cortical partner (motor/oculomotor/dlpfc/lofc/acc) × 3 prediction kinds. Marr-Albus sparse expansion + supervised LTD update. Boundary markers fire on \|δ_forward\|≥0.5 → workspace broadcasts + BG TD-error bus. Auto-wired into MCP dispatch. Confidence → thalamus salience precision (see `docs/proposals/cerebellum.md`) | | Amygdala (Phase 1, valence tagging) | `amygdala_status`, `amygdala_tag`, `amygdala_query_valence`, `amygdala_extinguish` | Rapid one-shot valence/threat tagging per entity/agent/context. Saturating tanh update caps single-event movement at ±0.5 (anti-PTSD). Reconsolidation: query opens 1h labile window where next tag uses 4× learning rate. Extinction = context-keyed inhibitory overlay (ITC-analog), not erasure (see `docs/proposals/amygdala.md`) | | Hippocampal subfields (Phase 1, DG/CA3 audit) | `hippocampus_dg_separate`, `hippocampus_dg_check`, `hippocampus_ca3_complete`, `hippocampus_subfields_status` | DG pattern-separation at write time + CA3 pattern-completion at retrieval, audit-only in Phase 1. Decisions: deduplicate (sim≥0.97), separate (sim≥0.85), passthrough (sim<0.85) | diff --git a/db/migrations/067_locus_coeruleus.sql b/db/migrations/067_locus_coeruleus.sql new file mode 100644 index 0000000..704ccbe --- /dev/null +++ b/db/migrations/067_locus_coeruleus.sql @@ -0,0 +1,87 @@ +-- Migration 067: locus coeruleus subsystem — Phase 1 schema +-- +-- Implements Phase 1 of the LC proposal at +-- docs/proposals/locus_coeruleus.md. LC is the global surprise / +-- norepinephrine-readiness broadcaster that sits between prediction-error +-- sources (cerebellum, BG, novelty events) and the downstream +-- bg_modulators.lc_ne dial. +-- +-- Phase 1 is inspection-only / additive: schema + read/CRUD tools. +-- No dispatch behavior changes. No writes to bg_modulators.lc_ne happen +-- in this phase; Phase 2 owns shadow wiring and NE broadcast. +-- +-- Five biological invariants encoded here: +-- 1. Phasic and tonic-shift firing modes are explicit event classes. +-- 2. LC state is a single broadcast row, not per-agent private state. +-- 3. Trigger taxonomy separates prediction error, TD error, novelty, +-- and explicit alert sources. +-- 4. Norepinephrine delta is recorded as a gain budget, not content. +-- 5. Thresholds stay data-driven and seedable for later calibration. +-- +-- Rollback, if needed before live adoption: +-- BEGIN; +-- DROP TABLE IF EXISTS lc_firings; +-- DROP TABLE IF EXISTS lc_state; +-- DROP TABLE IF EXISTS lc_triggers; +-- DELETE FROM schema_version WHERE version = 67; +-- COMMIT; +-- +-- IDEMPOTENT: IF NOT EXISTS guards object creation; seed rows use +-- INSERT OR IGNORE so repeated application does not duplicate state. + +CREATE TABLE IF NOT EXISTS lc_triggers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + source_table TEXT NOT NULL CHECK(source_table IN ('cerebellum_predictions','bg_td_events','memory_events','other')), + threshold_field TEXT, + threshold_value REAL, + default_ne_delta REAL NOT NULL DEFAULT 0.0, + description TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now')) +); +CREATE INDEX IF NOT EXISTS idx_lc_triggers_source_table ON lc_triggers(source_table); + +CREATE TABLE IF NOT EXISTS lc_firings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + fired_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now')), + agent_id TEXT, + trigger_id INTEGER, + trigger_source_event_id INTEGER, + surprise_magnitude REAL NOT NULL DEFAULT 0.0, + ne_delta_applied REAL NOT NULL DEFAULT 0.0, + mode TEXT NOT NULL CHECK(mode IN ('phasic','tonic_shift')), + context_hash TEXT, + notes TEXT, + FOREIGN KEY (trigger_id) REFERENCES lc_triggers(id) ON DELETE SET NULL +); +CREATE INDEX IF NOT EXISTS idx_lc_firings_fired_at ON lc_firings(fired_at DESC); +CREATE INDEX IF NOT EXISTS idx_lc_firings_agent_fired ON lc_firings(agent_id, fired_at); +CREATE INDEX IF NOT EXISTS idx_lc_firings_trigger_fired ON lc_firings(trigger_id, fired_at); + +CREATE TABLE IF NOT EXISTS lc_state ( + id INTEGER PRIMARY KEY CHECK (id = 1), + mode TEXT NOT NULL CHECK(mode IN ('phasic_ready','tonic_high','tonic_mid','tonic_low')), + ne_reservoir REAL NOT NULL DEFAULT 0.5, + last_phasic_at TEXT, + last_tonic_shift_at TEXT, + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now')) +); + +INSERT OR IGNORE INTO lc_triggers + (name, source_table, threshold_field, threshold_value, default_ne_delta, description) +VALUES + ('cerebellum_high_pe', 'cerebellum_predictions', 'delta_forward', 0.5, 0.15, + 'Cerebellum prediction error above threshold; surprise source for phasic LC.'), + ('bg_large_td_error', 'bg_td_events', 'delta', 0.6, 0.10, + 'Basal-ganglia TD error above threshold; value surprise source for LC.'), + ('novel_entity_sighting', 'memory_events', 'event_type', NULL, 0.05, + 'Novel observation event, especially new entity sightings.'), + ('explicit_user_alert', 'other', NULL, NULL, 0.20, + 'Manual or user-declared alert that should raise global NE readiness.'); + +INSERT OR IGNORE INTO lc_state (id, mode, ne_reservoir) +VALUES (1, 'tonic_mid', 0.5); + +INSERT OR IGNORE INTO schema_version (version, description, applied_at) +VALUES (67, 'locus coeruleus Phase 1: triggers, firings, single-row LC state', + strftime('%Y-%m-%dT%H:%M:%S', 'now')); diff --git a/docs/proposals/brain_region_coverage.md b/docs/proposals/brain_region_coverage.md index b734578..c542d34 100644 --- a/docs/proposals/brain_region_coverage.md +++ b/docs/proposals/brain_region_coverage.md @@ -14,13 +14,13 @@ Verdict per region: ✅ well-modelled · 🟡 partial / under-wired · 🟥 miss | **Theory-of-Mind / TPJ-mPFC** | model other minds | `mcp_tools_tom.py`, perspective tables, belief_conflicts | | **Belief system / dlPFC inference** | hold and revise probabilistic beliefs | `belief_*` tools, AGM-style credibility-weighted merge | | **Reflexion / posterior parietal lesson-learning** | failure-driven self-correction | `reflexion_*` tools, CONTEXT_LOSS / HALLUCINATION / TOOL_MISUSE categories | +| **Locus Coeruleus (NE) — Phase 1** | global surprise / reset signal, tonic/phasic norepinephrine readiness | `lc_triggers`, `lc_firings`, `lc_state`, and `lc_*` MCP tools. Phase 1 is manual/read+CRUD; automatic cerebellum/BG/novelty wiring is Phase 2. | ## 🟡 Partial — substrate exists but not wired or only one half present | Region | What it does (bio) | brainctl state | Gap | |---|---|---|---| | **Brainstem / Ascending Reticular Activating System** | global arousal broadcast via diffuse fan-out | `neuromodulation_state` table holds org-level arousal/focus | Not wired into retrieval/admission. The proposed thalamus mode-broadcast layer is the missing fan-out. | -| **Locus Coeruleus (NE)** | global surprise / reset signal | `neurostate.norepinephrine` in proposed schema | No concrete LC-analog signal currently *fires* on prediction-error to reset attention. | | **Nucleus Basalis (ACh)** | broaden receptive fields, raise responsiveness | Same — `neurostate.acetylcholine` proposed, not yet emitting | No actual cholinergic-mode admission-loosening tied to attended sectors. | | **Hypothalamus / allostasis** | homeostatic set-points + drives | `mcp_tools_allostatic.py` — demand_forecast, allostatic_prime | Has *prediction*; has no set-points (need-states) that *generate drives*. The system can't "feel hungry for data" or "need consolidation." | | **Amygdala** | rapid valence tagging, fear conditioning, one-shot threat learning | `affect_*` tools classify valence/arousal lexically | Affect is a classifier, not a memory modulator. No "this kind of input previously caused a problem → preemptively bias suppression on that channel" loop. No fast-track fear learning that bypasses W(m). | diff --git a/docs/proposals/locus_coeruleus.md b/docs/proposals/locus_coeruleus.md new file mode 100644 index 0000000..cd744c8 --- /dev/null +++ b/docs/proposals/locus_coeruleus.md @@ -0,0 +1,131 @@ +# Proposal: The Locus Coeruleus Subsystem for brainctl + +**Status:** Design proposal, Phase 1 implemented as schema + read/CRUD tools. +**Authors:** Codex GPT-5 (implementation synthesis) following the brain-region subsystem pattern. +**Date:** 2026-05-20 +**Scope:** New subsystem; additive and inspection-only in Phase 1. No dispatch, retrieval, write-gate, or neuromodulator behavior changes. + +--- + +## TL;DR + +brainctl already has two high-quality surprise sources: cerebellum prediction errors (`cerebellum_predictions.delta_forward`) and basal-ganglia TD errors (`bg_td_events.delta`). What it lacked was the small brainstem broadcaster that turns surprise into a global norepinephrine readiness signal. The locus coeruleus (LC) supplies that layer: a trigger catalog, an activation log, and a single current-state row that records whether the system is phasic-ready, tonically high, tonically mid, or tonically low. + +Architecturally, LC sits between fast error detectors and the existing `bg_modulators.lc_ne` dial. Phase 1 only observes and records; Phase 2 will wire cerebellum/BG/novelty events into LC firing and then broadcast norepinephrine into `bg_modulators.lc_ne`. + +``` +cerebellum_predictions.delta_forward +bg_td_events.delta +memory_events novelty / observation +explicit user alert + | + v + locus_coeruleus + - lc_triggers + - lc_firings + - lc_state + | + v +bg_modulators.lc_ne (Phase 2 write target; Phase 1 reads only) +``` + +--- + +## Convergent Principles + +1. **Two firing modes, two control problems.** LC phasic bursts are brief, stimulus-locked gain increases; tonic level is the slower arousal background. brainctl models both separately: `lc_firings.mode` captures phasic versus tonic-shift events, while `lc_state.mode` captures the current operating regime. + +2. **P3-like "something changed" signal.** LC tracks the orienting response to salient, novel, or unexpected stimuli. In brainctl, that maps to prediction errors, TD errors, novel entity observations, and explicit user alerts. + +3. **Aston-Jones adaptive gain.** Mid tonic plus phasic bursts supports exploitation and focused task performance; high tonic favors exploration and labile attention; low tonic maps to drowsy disengagement. LC state therefore uses `tonic_low`, `tonic_mid`, `tonic_high`, and `phasic_ready`. + +4. **Norepinephrine gates plasticity.** LC-NE does not encode content. It adjusts gain and learning readiness, including LTP/LTD sensitivity in downstream circuits. In brainctl, that downstream dial is already present as `bg_modulators.lc_ne`; LC owns the event semantics that will eventually set it. + +5. **Trigger taxonomy matters.** "Surprise" is not one source. Phase 1 separates cerebellar prediction error, BG TD error, novelty/observation events, and explicit user alerts so Phase 2 can tune thresholds and default NE deltas independently. + +--- + +## Architectural Placement + +LC is an interrupt-style broadcaster, not a selector. Cerebellum and BG detect error in their own domains; LC decides whether the error is behaviorally salient enough to raise global gain. + +``` + fast prediction / value errors + | + +------------------+------------------+ + | | + v v + cerebellum_predictions bg_td_events + delta_forward delta + | | + +------------------+------------------+ + | + v + lc_triggers catalog + threshold + NE delta + | + v + lc_firings log + | + v + lc_state row + | + v + bg_modulators.lc_ne + Phase 2 writes; Phase 1 status reads only +``` + +The LC subsystem deliberately stays out of the current `mcp_server.py` shadow hookpoints. Phase 1 exposes manual firing and inspection tools so operators can verify schema shape and semantics before automatic wiring. + +--- + +## Phase 1 Schema + +Migration `067_locus_coeruleus.sql` adds three tables: + +- **`lc_triggers`** - seedable trigger catalog. Each row defines an event class, its source table, threshold field/value, default NE delta, and description. +- **`lc_firings`** - timestamped activation log. Each row records agent, trigger, source event id, surprise magnitude, NE delta that would be applied, firing mode, context hash, and notes. +- **`lc_state`** - single-row current LC mode and NE reservoir, seeded as `id=1`, `mode='tonic_mid'`, `ne_reservoir=0.5`. + +Seed triggers: + +| Trigger | Source | Field | Threshold | Default NE delta | +|---|---|---|---:|---:| +| `cerebellum_high_pe` | `cerebellum_predictions` | `delta_forward` | 0.5 | 0.15 | +| `bg_large_td_error` | `bg_td_events` | `delta` | 0.6 | 0.10 | +| `novel_entity_sighting` | `memory_events` | `event_type` | null | 0.05 | +| `explicit_user_alert` | `other` | null | null | 0.20 | + +Indexes support recent-status reads, agent-specific history, trigger-specific history, and source-table trigger lookup. + +--- + +## Phase 1 MCP Tool Surface + +Five tools ship under the `lc_*` namespace: + +- **`lc_status(agent_id=None) -> dict`** - returns `lc_state`, current `bg_modulators.lc_ne` if present, and a last-24h firing summary. +- **`lc_fire(trigger_name, surprise_magnitude, agent_id=None, source_event_id=None, notes=None) -> dict`** - manually logs a phasic LC firing by trigger name. Phase 1 updates LC's own state and log only; it does not write `bg_modulators.lc_ne`. +- **`lc_register_trigger(name, source_table, threshold_field, threshold_value, default_ne_delta, description) -> dict`** - idempotent trigger UPSERT. Source table is validated against the Phase 1 taxonomy. +- **`lc_signal_history(limit=20, since=None, agent_id=None, trigger_id=None) -> list[dict]`** - recent firing history with optional filters and pagination limit. +- **`lc_set_mode(mode, reason=None) -> dict`** - validates and updates `lc_state.mode`. Used by future shadow consults and manual inspection. + +--- + +## Phase 2/3/4 Sketch + +**Phase 2 - Shadow wiring.** Listen to cerebellum `delta_forward`, BG `delta`, novel observation events, and explicit alert events. Insert `lc_firings` automatically when trigger thresholds pass. Mirror the computed NE delta into `bg_modulators.lc_ne` in shadow mode with audit records and no behavior change. + +**Phase 3 - Gain coupling.** Use LC-NE as a read-path and write-path gain signal: broader retrieval under high tonic NE, lower admission thresholds for surprising sources, and higher salience precision for LC-tagged sectors. + +**Phase 4 - Calibration and enforcement.** Learn trigger thresholds and NE deltas from downstream outcomes. Couple LC mode to BG action selection and thalamus salience after enough shadow data accumulates. + +--- + +## DoD for Phase 1 + +- Migration `067_locus_coeruleus.sql` applies idempotently to a fresh DB, a `/tmp` copy of live `brain.db`, and live `brain.db` after backup. +- Seed triggers and the single `lc_state` row exist after migration. +- The five `lc_*` MCP tools are registered and discoverable from `agentmemory.mcp_server`. +- Focused pytest coverage verifies migration seeds, empty status, idempotent trigger registration, firing round-trip, mode validation, and history filtering. +- `MCP_SERVER.md`, `CHANGELOG.md`, and `brain_region_coverage.md` document LC Phase 1 without touching NB-owned files or Phase 2 hookpoints. diff --git a/src/agentmemory/db/init_schema.sql b/src/agentmemory/db/init_schema.sql index 5d5dd3e..0f1200d 100644 --- a/src/agentmemory/db/init_schema.sql +++ b/src/agentmemory/db/init_schema.sql @@ -2743,3 +2743,97 @@ SELECT 3, n, 'coarse:' || n, 'coarse-grained grid cell ' || n FROM ( UNION SELECT 4 UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9 UNION SELECT 10 UNION SELECT 11 UNION SELECT 12 UNION SELECT 13 UNION SELECT 14 UNION SELECT 15); + +-- ---- 066_retrieval_pathway_log.sql ---- +-- Sidecar observation log for memory_search dispatches. Records the +-- pathway fingerprint (mode, table_distribution, intent, profile, +-- candidate counts, latency) per retrieval. Independent of bg_td_events +-- by design. +CREATE TABLE IF NOT EXISTS retrieval_pathway_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + fired_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now')), + agent_id TEXT, + project TEXT, + query TEXT, + query_hash TEXT, + mode TEXT, + table_distribution TEXT, + tables_searched TEXT, + candidate_count_pre INTEGER, + candidate_count_post INTEGER, + rrf_contribution_ratio REAL, + intent_label TEXT, + active_profile TEXT, + suppressed_strategies TEXT, + embedding_model_version TEXT, + latency_ms INTEGER, + benchmark_mode INTEGER NOT NULL DEFAULT 0, + linked_td_event_id INTEGER +); +CREATE INDEX IF NOT EXISTS idx_rpl_recent + ON retrieval_pathway_log(fired_at); +CREATE INDEX IF NOT EXISTS idx_rpl_agent + ON retrieval_pathway_log(agent_id, fired_at); +CREATE INDEX IF NOT EXISTS idx_rpl_mode + ON retrieval_pathway_log(mode, fired_at); +CREATE INDEX IF NOT EXISTS idx_rpl_intent + ON retrieval_pathway_log(intent_label, fired_at); +CREATE INDEX IF NOT EXISTS idx_rpl_unlinked + ON retrieval_pathway_log(linked_td_event_id) WHERE linked_td_event_id IS NULL; + +-- ---- 067_locus_coeruleus.sql ---- +-- Locus coeruleus Phase 1: surprise / novelty trigger catalog, activation +-- log, and single-row tonic/phasic state. Phase 1 reads but does not write +-- bg_modulators.lc_ne. +CREATE TABLE IF NOT EXISTS lc_triggers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + source_table TEXT NOT NULL CHECK(source_table IN ('cerebellum_predictions','bg_td_events','memory_events','other')), + threshold_field TEXT, + threshold_value REAL, + default_ne_delta REAL NOT NULL DEFAULT 0.0, + description TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now')) +); +CREATE INDEX IF NOT EXISTS idx_lc_triggers_source_table ON lc_triggers(source_table); + +CREATE TABLE IF NOT EXISTS lc_firings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + fired_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now')), + agent_id TEXT, + trigger_id INTEGER, + trigger_source_event_id INTEGER, + surprise_magnitude REAL NOT NULL DEFAULT 0.0, + ne_delta_applied REAL NOT NULL DEFAULT 0.0, + mode TEXT NOT NULL CHECK(mode IN ('phasic','tonic_shift')), + context_hash TEXT, + notes TEXT, + FOREIGN KEY (trigger_id) REFERENCES lc_triggers(id) ON DELETE SET NULL +); +CREATE INDEX IF NOT EXISTS idx_lc_firings_fired_at ON lc_firings(fired_at DESC); +CREATE INDEX IF NOT EXISTS idx_lc_firings_agent_fired ON lc_firings(agent_id, fired_at); +CREATE INDEX IF NOT EXISTS idx_lc_firings_trigger_fired ON lc_firings(trigger_id, fired_at); + +CREATE TABLE IF NOT EXISTS lc_state ( + id INTEGER PRIMARY KEY CHECK (id = 1), + mode TEXT NOT NULL CHECK(mode IN ('phasic_ready','tonic_high','tonic_mid','tonic_low')), + ne_reservoir REAL NOT NULL DEFAULT 0.5, + last_phasic_at TEXT, + last_tonic_shift_at TEXT, + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now')) +); + +INSERT OR IGNORE INTO lc_triggers + (name, source_table, threshold_field, threshold_value, default_ne_delta, description) +VALUES + ('cerebellum_high_pe', 'cerebellum_predictions', 'delta_forward', 0.5, 0.15, + 'Cerebellum prediction error above threshold; surprise source for phasic LC.'), + ('bg_large_td_error', 'bg_td_events', 'delta', 0.6, 0.10, + 'Basal-ganglia TD error above threshold; value surprise source for LC.'), + ('novel_entity_sighting', 'memory_events', 'event_type', NULL, 0.05, + 'Novel observation event, especially new entity sightings.'), + ('explicit_user_alert', 'other', NULL, NULL, 0.20, + 'Manual or user-declared alert that should raise global NE readiness.'); + +INSERT OR IGNORE INTO lc_state (id, mode, ne_reservoir) +VALUES (1, 'tonic_mid', 0.5); diff --git a/src/agentmemory/mcp_server.py b/src/agentmemory/mcp_server.py index 95e5f45..b1c7363 100755 --- a/src/agentmemory/mcp_server.py +++ b/src/agentmemory/mcp_server.py @@ -60,6 +60,7 @@ mcp_tools_insula, mcp_tools_knowledge, mcp_tools_lifecycle, + mcp_tools_locus_coeruleus, mcp_tools_meb, mcp_tools_merge, mcp_tools_neuro, @@ -103,6 +104,7 @@ mcp_tools_insula, mcp_tools_knowledge, mcp_tools_lifecycle, + mcp_tools_locus_coeruleus, mcp_tools_meb, mcp_tools_merge, mcp_tools_neuro, @@ -3187,6 +3189,18 @@ def _resolve_allowed_tools() -> frozenset[str] | None: _ALLOWED_TOOLS: frozenset[str] | None = _resolve_allowed_tools() +def _build_dispatch() -> dict[str, Any]: + """Build a tool dispatch map for smoke tests and introspection.""" + dispatch: dict[str, Any] = {} + for tool in TOOLS: + fn = globals().get(f"tool_{tool.name}") + if callable(fn): + dispatch[tool.name] = fn + for _m in _EXT_MODULES: + dispatch.update(_m.DISPATCH) + return dispatch + + @app.list_tools() async def list_tools() -> list[Tool]: _lifecycle.touch_activity() diff --git a/src/agentmemory/mcp_tools_locus_coeruleus.py b/src/agentmemory/mcp_tools_locus_coeruleus.py new file mode 100644 index 0000000..6edfb7c --- /dev/null +++ b/src/agentmemory/mcp_tools_locus_coeruleus.py @@ -0,0 +1,486 @@ +"""brainctl MCP tools — locus coeruleus inspection and trigger catalog. + +Phase 1 of the LC subsystem per docs/proposals/locus_coeruleus.md. The +locus coeruleus is the norepinephrine-readiness broadcaster that records +surprise-triggered activations. Phase 1 is additive: schema + read/CRUD +tools only. It does not update bg_modulators.lc_ne; that wiring belongs to +Phase 2 shadow mode. +""" +from __future__ import annotations + +import logging +import sqlite3 +from pathlib import Path +from typing import Any + +from mcp.types import Tool + +from agentmemory.lib.mcp_helpers import open_db, rows_to_list +from agentmemory.paths import get_db_path + +logger = logging.getLogger(__name__) + +DB_PATH: Path = get_db_path() + +VALID_SOURCE_TABLES = {"cerebellum_predictions", "bg_td_events", "memory_events", "other"} +VALID_LC_STATE_MODES = {"phasic_ready", "tonic_high", "tonic_mid", "tonic_low"} + + +def _db() -> sqlite3.Connection: + return open_db(str(DB_PATH)) + + +def _table_exists(conn: sqlite3.Connection, name: str) -> bool: + return bool( + conn.execute( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", + (name,), + ).fetchone() + ) + + +def _require_schema(conn: sqlite3.Connection) -> str | None: + missing = [ + table + for table in ("lc_triggers", "lc_firings", "lc_state") + if not _table_exists(conn, table) + ] + if missing: + return "locus coeruleus schema missing tables: " + ", ".join(missing) + return None + + +def _ensure_state(conn: sqlite3.Connection) -> None: + conn.execute( + """ + INSERT OR IGNORE INTO lc_state (id, mode, ne_reservoir) + VALUES (1, 'tonic_mid', 0.5) + """ + ) + + +def _to_float(value: Any, field: str) -> tuple[float | None, str | None]: + if value is None: + return None, None + try: + return float(value), None + except (TypeError, ValueError): + return None, f"{field} must be numeric" + + +def _clamp01(value: float) -> float: + return max(0.0, min(1.0, value)) + + +def _current_bg_lc_ne(conn: sqlite3.Connection) -> dict[str, Any] | None: + if not _table_exists(conn, "bg_modulators"): + return None + row = conn.execute( + "SELECT id, lc_ne, updated_at, set_by FROM bg_modulators WHERE id = 1" + ).fetchone() + return dict(row) if row else None + + +def tool_lc_status(agent_id: str | None = None, **kw: Any) -> dict[str, Any]: + """Return LC state and last-24h firing summary. + + Phase 1 also reads bg_modulators.lc_ne when present, but never writes it. + """ + db = _db() + try: + schema_error = _require_schema(db) + if schema_error: + return {"ok": False, "error": schema_error} + _ensure_state(db) + db.commit() + + state_row = db.execute("SELECT * FROM lc_state WHERE id = 1").fetchone() + + where = ["f.fired_at >= strftime('%Y-%m-%dT%H:%M:%S', 'now', '-1 day')"] + params: list[Any] = [] + if agent_id: + where.append("f.agent_id = ?") + params.append(agent_id) + where_sql = "WHERE " + " AND ".join(where) + + summary = db.execute( + f""" + SELECT COUNT(*) AS count, + ROUND(COALESCE(AVG(f.surprise_magnitude), 0.0), 4) AS mean_surprise_magnitude, + ROUND(COALESCE(AVG(f.ne_delta_applied), 0.0), 4) AS mean_ne_delta_applied, + SUM(CASE WHEN f.mode = 'tonic_shift' THEN 1 ELSE 0 END) AS mode_transitions + FROM lc_firings f + {where_sql} + """, # nosec B608 + params, + ).fetchone() + + recent = db.execute( + f""" + SELECT f.id, f.fired_at, f.agent_id, f.trigger_id, t.name AS trigger_name, + f.trigger_source_event_id, f.surprise_magnitude, + f.ne_delta_applied, f.mode, f.context_hash, f.notes + FROM lc_firings f + LEFT JOIN lc_triggers t ON t.id = f.trigger_id + {where_sql} + ORDER BY f.fired_at DESC, f.id DESC + LIMIT 10 + """, # nosec B608 + params, + ).fetchall() + + return { + "ok": True, + "agent_filter": agent_id, + "state": dict(state_row) if state_row else None, + "bg_modulators_lc_ne": _current_bg_lc_ne(db), + "recent_24h": dict(summary) if summary else { + "count": 0, + "mean_surprise_magnitude": 0.0, + "mean_ne_delta_applied": 0.0, + "mode_transitions": 0, + }, + "recent_firings": rows_to_list(recent), + } + except Exception as exc: + logger.exception("lc_status failed") + return {"ok": False, "error": str(exc)} + finally: + db.close() + + +def tool_lc_fire( + trigger_name: str, + surprise_magnitude: float, + agent_id: str | None = None, + source_event_id: int | None = None, + notes: str | None = None, + **kw: Any, +) -> dict[str, Any]: + """Manually log a phasic LC activation by trigger name. + + Phase 1 updates LC's own activation log and reservoir only. It does not + write bg_modulators.lc_ne. + """ + if not trigger_name or not isinstance(trigger_name, str): + return {"ok": False, "error": "trigger_name is required"} + magnitude, magnitude_error = _to_float(surprise_magnitude, "surprise_magnitude") + if magnitude_error: + return {"ok": False, "error": magnitude_error} + + db = _db() + try: + schema_error = _require_schema(db) + if schema_error: + return {"ok": False, "error": schema_error} + _ensure_state(db) + + trigger = db.execute( + "SELECT * FROM lc_triggers WHERE name = ?", + (trigger_name,), + ).fetchone() + if trigger is None: + return {"ok": False, "error": f"unknown LC trigger: {trigger_name}"} + + ne_delta = float(trigger["default_ne_delta"] or 0.0) + cursor = db.execute( + """ + INSERT INTO lc_firings + (agent_id, trigger_id, trigger_source_event_id, + surprise_magnitude, ne_delta_applied, mode, notes) + VALUES (?, ?, ?, ?, ?, 'phasic', ?) + """, + (agent_id, trigger["id"], source_event_id, magnitude, ne_delta, notes), + ) + db.execute( + """ + UPDATE lc_state + SET ne_reservoir = MIN(1.0, MAX(0.0, ne_reservoir + ?)), + last_phasic_at = strftime('%Y-%m-%dT%H:%M:%S', 'now'), + updated_at = strftime('%Y-%m-%dT%H:%M:%S', 'now') + WHERE id = 1 + """, + (ne_delta,), + ) + db.commit() + state = db.execute("SELECT * FROM lc_state WHERE id = 1").fetchone() + return { + "ok": True, + "firing_id": cursor.lastrowid, + "trigger_id": trigger["id"], + "trigger_name": trigger["name"], + "ne_delta_applied": ne_delta, + "mode": "phasic", + "lc_state_mode": state["mode"] if state else None, + } + except Exception as exc: + logger.exception("lc_fire failed") + return {"ok": False, "error": str(exc)} + finally: + db.close() + + +def tool_lc_register_trigger( + name: str, + source_table: str, + threshold_field: str | None = None, + threshold_value: float | None = None, + default_ne_delta: float = 0.0, + description: str | None = None, + **kw: Any, +) -> dict[str, Any]: + """Idempotent UPSERT into lc_triggers keyed by trigger name.""" + if not name or not isinstance(name, str): + return {"ok": False, "error": "name is required"} + if source_table not in VALID_SOURCE_TABLES: + return {"ok": False, "error": f"source_table must be one of {sorted(VALID_SOURCE_TABLES)}"} + threshold_float, threshold_error = _to_float(threshold_value, "threshold_value") + if threshold_error: + return {"ok": False, "error": threshold_error} + delta_float, delta_error = _to_float(default_ne_delta, "default_ne_delta") + if delta_error: + return {"ok": False, "error": delta_error} + + db = _db() + try: + schema_error = _require_schema(db) + if schema_error: + return {"ok": False, "error": schema_error} + db.execute( + """ + INSERT INTO lc_triggers + (name, source_table, threshold_field, threshold_value, + default_ne_delta, description) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(name) DO UPDATE SET + source_table = excluded.source_table, + threshold_field = excluded.threshold_field, + threshold_value = excluded.threshold_value, + default_ne_delta = excluded.default_ne_delta, + description = excluded.description + """, + ( + name, + source_table, + threshold_field, + threshold_float, + delta_float, + description, + ), + ) + db.commit() + row = db.execute("SELECT * FROM lc_triggers WHERE name = ?", (name,)).fetchone() + return {"ok": True, "trigger": dict(row) if row else None} + except Exception as exc: + logger.exception("lc_register_trigger failed") + return {"ok": False, "error": str(exc)} + finally: + db.close() + + +def tool_lc_signal_history( + limit: int = 20, + since: str | None = None, + agent_id: str | None = None, + trigger_id: int | None = None, + **kw: Any, +) -> list[dict[str, Any]]: + """Return recent LC firing history with optional filters.""" + try: + limit_int = max(1, min(int(limit or 20), 200)) + except (TypeError, ValueError): + limit_int = 20 + + db = _db() + try: + schema_error = _require_schema(db) + if schema_error: + return [{"ok": False, "error": schema_error}] + clauses: list[str] = [] + params: list[Any] = [] + if since: + clauses.append("f.fired_at >= ?") + params.append(since) + if agent_id: + clauses.append("f.agent_id = ?") + params.append(agent_id) + if trigger_id is not None: + clauses.append("f.trigger_id = ?") + params.append(int(trigger_id)) + where_sql = ("WHERE " + " AND ".join(clauses)) if clauses else "" + rows = db.execute( + f""" + SELECT f.id, f.fired_at, f.agent_id, f.trigger_id, t.name AS trigger_name, + f.trigger_source_event_id, f.surprise_magnitude, + f.ne_delta_applied, f.mode, f.context_hash, f.notes + FROM lc_firings f + LEFT JOIN lc_triggers t ON t.id = f.trigger_id + {where_sql} + ORDER BY f.fired_at DESC, f.id DESC + LIMIT ? + """, # nosec B608 + params + [limit_int], + ).fetchall() + return rows_to_list(rows) + except Exception as exc: + logger.exception("lc_signal_history failed") + return [{"ok": False, "error": str(exc)}] + finally: + db.close() + + +def tool_lc_set_mode(mode: str, reason: str | None = None, **kw: Any) -> dict[str, Any]: + """Update the single-row LC state mode after validation.""" + if mode not in VALID_LC_STATE_MODES: + return {"ok": False, "error": f"mode must be one of {sorted(VALID_LC_STATE_MODES)}"} + + db = _db() + try: + schema_error = _require_schema(db) + if schema_error: + return {"ok": False, "error": schema_error} + _ensure_state(db) + + if mode.startswith("tonic_"): + db.execute( + """ + UPDATE lc_state + SET mode = ?, + last_tonic_shift_at = strftime('%Y-%m-%dT%H:%M:%S', 'now'), + updated_at = strftime('%Y-%m-%dT%H:%M:%S', 'now') + WHERE id = 1 + """, + (mode,), + ) + else: + db.execute( + """ + UPDATE lc_state + SET mode = ?, + updated_at = strftime('%Y-%m-%dT%H:%M:%S', 'now') + WHERE id = 1 + """, + (mode,), + ) + + db.execute( + """ + INSERT INTO lc_firings + (surprise_magnitude, ne_delta_applied, mode, notes) + VALUES (0.0, 0.0, 'tonic_shift', ?) + """, + (reason,), + ) + db.commit() + row = db.execute("SELECT * FROM lc_state WHERE id = 1").fetchone() + return {"ok": True, "state": dict(row) if row else None, "reason": reason} + except Exception as exc: + logger.exception("lc_set_mode failed") + return {"ok": False, "error": str(exc)} + finally: + db.close() + + +TOOLS: list[Tool] = [ + Tool( + name="lc_status", + description=( + "Inspect the locus coeruleus subsystem: current LC state, current " + "bg_modulators.lc_ne value if present, and last-24h firing summary " + "(count, mean surprise magnitude, mean NE delta, tonic-shift count)." + ), + inputSchema={ + "type": "object", + "properties": { + "agent_id": {"type": "string", "description": "Optional agent filter for firing summary"}, + }, + }, + ), + Tool( + name="lc_fire", + description=( + "Manually log a phasic LC firing by trigger name. Inserts lc_firings " + "and updates lc_state only. Phase 1 does not write bg_modulators.lc_ne." + ), + inputSchema={ + "type": "object", + "properties": { + "trigger_name": {"type": "string", "description": "Name in lc_triggers"}, + "surprise_magnitude": {"type": "number"}, + "agent_id": {"type": "string"}, + "source_event_id": {"type": "integer"}, + "notes": {"type": "string"}, + }, + "required": ["trigger_name", "surprise_magnitude"], + }, + ), + Tool( + name="lc_register_trigger", + description=( + "Register or update an LC trigger. Idempotent UPSERT by name. " + "source_table must be cerebellum_predictions, bg_td_events, " + "memory_events, or other." + ), + inputSchema={ + "type": "object", + "properties": { + "name": {"type": "string"}, + "source_table": {"type": "string", "enum": sorted(VALID_SOURCE_TABLES)}, + "threshold_field": {"type": "string"}, + "threshold_value": {"type": "number"}, + "default_ne_delta": {"type": "number"}, + "description": {"type": "string"}, + }, + "required": ["name", "source_table", "default_ne_delta"], + }, + ), + Tool( + name="lc_signal_history", + description=( + "Return LC firing history with optional filters: since timestamp, " + "agent_id, trigger_id, and limit. Sorted newest first." + ), + inputSchema={ + "type": "object", + "properties": { + "limit": {"type": "integer", "default": 20}, + "since": {"type": "string"}, + "agent_id": {"type": "string"}, + "trigger_id": {"type": "integer"}, + }, + }, + ), + Tool( + name="lc_set_mode", + description=( + "Set the single-row LC mode. Valid modes: phasic_ready, tonic_high, " + "tonic_mid, tonic_low. Records a tonic_shift audit row." + ), + inputSchema={ + "type": "object", + "properties": { + "mode": {"type": "string", "enum": sorted(VALID_LC_STATE_MODES)}, + "reason": {"type": "string"}, + }, + "required": ["mode"], + }, + ), +] + +_LC_TOOLS = { + "lc_status": tool_lc_status, + "lc_fire": tool_lc_fire, + "lc_register_trigger": tool_lc_register_trigger, + "lc_signal_history": tool_lc_signal_history, + "lc_set_mode": tool_lc_set_mode, +} + +DISPATCH: dict[str, Any] = { + name: (lambda _func=func, **kw: _func(**kw)) + for name, func in _LC_TOOLS.items() +} + + +def register_tools() -> tuple[list[Tool], dict[str, Any]]: + """Return tool descriptors and dispatch map for mcp_server integration.""" + return TOOLS, DISPATCH diff --git a/tests/test_mcp_tools_locus_coeruleus.py b/tests/test_mcp_tools_locus_coeruleus.py new file mode 100644 index 0000000..1699154 --- /dev/null +++ b/tests/test_mcp_tools_locus_coeruleus.py @@ -0,0 +1,134 @@ +"""Tests for locus coeruleus Phase 1 (schema + read/CRUD tools).""" +import sqlite3 +import sys +from pathlib import Path + +SRC = Path(__file__).resolve().parent.parent / "src" +if str(SRC) not in sys.path: + sys.path.insert(0, str(SRC)) + +from agentmemory.mcp_tools_locus_coeruleus import ( + tool_lc_fire, + tool_lc_register_trigger, + tool_lc_set_mode, + tool_lc_signal_history, + tool_lc_status, +) + + +class _NoCloseConn: + def __init__(self, conn): + object.__setattr__(self, "_conn", conn) + + def close(self): + return None + + def __getattr__(self, name): + return getattr(self._conn, name) + + +def _apply_migration(conn: sqlite3.Connection) -> None: + conn.execute( + "CREATE TABLE IF NOT EXISTS schema_version " + "(version INTEGER PRIMARY KEY, description TEXT, applied_at TEXT)" + ) + migration = Path(__file__).resolve().parent.parent / "db" / "migrations" / "067_locus_coeruleus.sql" + with open(migration) as f: + conn.executescript(f.read()) + + +def _patched(conn: sqlite3.Connection): + import agentmemory.mcp_tools_locus_coeruleus as m + + original_open_db = m.open_db + m.open_db = lambda x: _NoCloseConn(conn) # type: ignore[assignment] + return m, original_open_db + + +def test_migration_applies_and_seeds(): + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + _apply_migration(conn) + names = {r[0] for r in conn.execute("SELECT name FROM lc_triggers").fetchall()} + state = conn.execute("SELECT id, mode, ne_reservoir FROM lc_state WHERE id=1").fetchone() + assert names == {"cerebellum_high_pe", "bg_large_td_error", "novel_entity_sighting", "explicit_user_alert"} + assert tuple(state) == (1, "tonic_mid", 0.5) + + +def test_lc_status_empty_db(): + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + _apply_migration(conn) + mod, original_open_db = _patched(conn) + try: + result = tool_lc_status() + assert result["ok"] is True + assert result["state"]["mode"] == "tonic_mid" + assert result["recent_24h"]["count"] == 0 + finally: + mod.open_db = original_open_db + + +def test_lc_register_trigger_idempotent(): + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + _apply_migration(conn) + mod, original_open_db = _patched(conn) + try: + first = tool_lc_register_trigger("test_trigger", "other", None, None, 0.07, "first") + second = tool_lc_register_trigger("test_trigger", "other", None, None, 0.08, "second") + count = conn.execute("SELECT COUNT(*) FROM lc_triggers WHERE name='test_trigger'").fetchone()[0] + assert first["ok"] is True and second["ok"] is True + assert count == 1 + assert second["trigger"]["default_ne_delta"] == 0.08 + finally: + mod.open_db = original_open_db + + +def test_lc_fire_round_trip(): + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + _apply_migration(conn) + mod, original_open_db = _patched(conn) + try: + result = tool_lc_fire("cerebellum_high_pe", 0.73, agent_id="agent-a", source_event_id=42) + row = conn.execute( + "SELECT surprise_magnitude, ne_delta_applied, mode FROM lc_firings WHERE id=?", + (result["firing_id"],), + ).fetchone() + assert result["ok"] is True + assert result["ne_delta_applied"] == 0.15 + assert tuple(row) == (0.73, 0.15, "phasic") + finally: + mod.open_db = original_open_db + + +def test_lc_set_mode_validates(): + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + _apply_migration(conn) + mod, original_open_db = _patched(conn) + try: + bad = tool_lc_set_mode("invalid") + good = tool_lc_set_mode("tonic_high", reason="test") + mode = conn.execute("SELECT mode FROM lc_state WHERE id=1").fetchone()[0] + assert bad["ok"] is False + assert good["ok"] is True + assert mode == "tonic_high" + finally: + mod.open_db = original_open_db + + +def test_lc_signal_history_filters_and_pagination(): + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + _apply_migration(conn) + mod, original_open_db = _patched(conn) + try: + tool_lc_fire("cerebellum_high_pe", 0.73, agent_id="agent-a") + tool_lc_fire("bg_large_td_error", 0.9, agent_id="agent-b") + history = tool_lc_signal_history(limit=1, agent_id="agent-a") + assert len(history) == 1 + assert history[0]["agent_id"] == "agent-a" + finally: + mod.open_db = original_open_db From 2b8e89d16549481874b7d4eea185d8b375fdc583 Mon Sep 17 00:00:00 2001 From: R4vager Date: Tue, 19 May 2026 23:45:30 -0400 Subject: [PATCH 2/2] record LC Phase 1 Codex run report --- research/codex-track-reports.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 research/codex-track-reports.md diff --git a/research/codex-track-reports.md b/research/codex-track-reports.md new file mode 100644 index 0000000..fac73cb --- /dev/null +++ b/research/codex-track-reports.md @@ -0,0 +1,11 @@ +# Codex track reports — auto-appended + +## 2026-05-20 — Locus Coeruleus Phase 1 + +- Branch: `brain-regions-lc-phase-1` +- PR: https://github.com/TSchonleber/brainctl/pull/121 +- Commit: `eca4590` (`locus coeruleus Phase 1: schema + read/CRUD tools`) +- Scope shipped: `067_locus_coeruleus.sql`, LC proposal, `mcp_tools_locus_coeruleus.py`, MCP registration, focused tests, MCP docs, coverage tracker, changelog, and init-schema parity for 066/067. +- Live DB: backed up to `/Users/r4vager/agentmemory/backups/brain.db.pre-lc-20260520T033749Z.db`; migration 067 applied live; `lc_triggers` has 4 seed rows; `lc_state` is `tonic_mid`; `bg_modulators.lc_ne` remained `0.5`. +- Verification: `/tmp` migration copy applied cleanly; `python3 -m pytest tests/test_mcp_tools_locus_coeruleus.py -x` passed; exact `_build_dispatch()` LC discoverability command returned all 5 tools; clean LC-only worktree full suite passed with `2249 passed, 28 skipped, 2 xfailed`. +- Coordination note: the shared checkout contains untracked NB files from Claude's parallel branch; none were staged or committed here.