diff --git a/README.md b/README.md index ca0c456c..789dccf8 100644 --- a/README.md +++ b/README.md @@ -433,7 +433,7 @@ pip install soul-protocol[mcp] SOUL_PATH=aria.soul soul-mcp ``` -24 tools and 3 resources for Claude Code, Cursor, or any MCP-compatible client. See [integrations](docs/integrations.md). +31 tools (26 soul + 5 context), 3 resources, and 2 prompts for Claude Code, Cursor, or any MCP-compatible client — including `soul_sync`, a one-call auto-recall + auto-observe convenience tool. See [integrations](docs/integrations.md). --- diff --git a/src/soul_protocol/cli/setup.py b/src/soul_protocol/cli/setup.py index 4f10208a..cb0c9bef 100644 --- a/src/soul_protocol/cli/setup.py +++ b/src/soul_protocol/cli/setup.py @@ -32,6 +32,9 @@ 2. Call `soul_state` to check current mood and energy **During work:** +- Call `soul_sync` after each user/agent exchange — it auto-recalls relevant + memories and saves the turn in one call (`user_input`, `agent_output`, + optional `query`) - `soul_observe` after key decisions, completed tasks, or important conversations - `soul_remember` for facts that should persist across sessions diff --git a/src/soul_protocol/mcp/server.py b/src/soul_protocol/mcp/server.py index cfec0f47..27105ea7 100644 --- a/src/soul_protocol/mcp/server.py +++ b/src/soul_protocol/mcp/server.py @@ -22,6 +22,8 @@ # the active soul (no birth, no seed) so agents can self-evaluate their # current state. Accepts yaml_path or yaml_string. Returns the EvalResult # as JSON. +# Updated: 2026-05 — Added soul_sync: one-call auto-recall + auto-observe so agents +# capture a turn and fetch relevant memories in a single round-trip. # Updated: 2026-04-29 (#42) — Trust chain tools: ``soul_verify`` returns chain # integrity status; ``soul_audit`` returns the human-readable timeline of # signed actions, with optional action_prefix and limit. JSON-only output — @@ -69,6 +71,7 @@ from ..runtime.cognitive.adapters.mcp_sampling import MCPSamplingEngine from ..runtime.context import LCMContext from ..runtime.exceptions import SoulProtocolError +from ..runtime.middleware import AutoObserveMiddleware from ..runtime.soul import Soul from ..runtime.types import Interaction, MemoryType, Mood @@ -188,6 +191,12 @@ async def check_and_reload(self, key: str) -> None: if not lock: return async with lock: + if key in self._modified: + # The soul has unsaved in-memory changes (e.g. a recent + # soul_remember / soul_observe). Reloading from disk here + # would silently discard those changes, so skip until they + # are persisted by soul_save / shutdown auto-save. + return current_mtime = self._get_mtime(path) if current_mtime <= self._mtimes.get(key, 0.0): return # already up-to-date (or reloaded by another caller) @@ -705,6 +714,56 @@ async def soul_recall( return json.dumps({"count": len(memories), "soul": s.name, "memories": memories}) +@mcp.tool +async def soul_sync( + user_input: str, + agent_output: str = "", + query: str | None = None, + limit: int = 5, + channel: str = "mcp", + soul: str | None = None, + user_id: str | None = None, + layer: str | None = None, + domain: str | None = None, + ctx: Context | None = None, +) -> str: + """Capture a conversation turn and optionally recall memories in one call. + + Combines auto-recall + auto-observe so agents only need a single call per + exchange instead of remembering `soul_recall` then `soul_observe` separately. + When `query` is provided, relevant memories are recalled and returned first; + the turn is then observed through the full psychology pipeline regardless. + + Args: + user_input: What the user said (required). + agent_output: What the agent responded (optional; omit for user-only turns). + query: Optional recall query. When set, relevant memories are returned + alongside the observation result. Omit to observe without recall. + limit: Maximum recall results to return (default 5). + channel: Source channel identifier. + soul: Target soul name (uses active soul if omitted). + user_id: Attribute the turn to a specific user (multi-user souls, #46). + layer: Restrict recall to a single memory layer (#41). Optional. + domain: Restrict recall to a single domain sub-namespace (#41). Optional. + """ + if ctx is not None: + _get_or_create_engine(ctx) + s = await _resolve_soul(soul) + result = await AutoObserveMiddleware(s).turn( + user_input, + agent_output, + query=query, + limit=limit, + user_id=user_id, + layer=layer, + domain=domain, + channel=channel, + ) + _registry.mark_modified(soul) + result["status"] = "observed" + return json.dumps(result) + + @mcp.tool async def soul_reflect( soul: str | None = None, @@ -882,6 +941,7 @@ async def soul_save( _registry._paths[key] = save_path if key: _registry._mtimes[key] = _registry._get_mtime(save_path) + _registry._modified.discard(key) else: await s.save() save_path = str(Path.home() / ".soul" / s.did) diff --git a/src/soul_protocol/runtime/middleware/__init__.py b/src/soul_protocol/runtime/middleware/__init__.py index d5b42fb1..b871b574 100644 --- a/src/soul_protocol/runtime/middleware/__init__.py +++ b/src/soul_protocol/runtime/middleware/__init__.py @@ -2,13 +2,17 @@ # Created: 2026-04-29 (#41) — New runtime package for soul middleware. The # first occupant is DomainIsolationMiddleware, which wraps a Soul and # enforces a domain allow-list on every read and write. +# Updated: 2026-05 — Added AutoObserveMiddleware, which wraps a Soul so every +# conversation turn is captured automatically (auto-recall + auto-observe). from __future__ import annotations +from soul_protocol.runtime.middleware.auto_observe import AutoObserveMiddleware from soul_protocol.runtime.middleware.domain_isolation import ( DomainIsolationMiddleware, ) __all__ = [ + "AutoObserveMiddleware", "DomainIsolationMiddleware", ] diff --git a/src/soul_protocol/runtime/middleware/auto_observe.py b/src/soul_protocol/runtime/middleware/auto_observe.py new file mode 100644 index 00000000..63e149f7 --- /dev/null +++ b/src/soul_protocol/runtime/middleware/auto_observe.py @@ -0,0 +1,123 @@ +# middleware/auto_observe.py — Auto-capture conversation turns with optional recall. +# Created: 2026-05 — Wraps a Soul so every conversation turn is captured without +# the caller remembering to call Soul.observe(). A single turn() entry point +# performs optional auto-recall (when a query is given) followed by mandatory +# auto-observe, returning recalled memories plus the soul's updated state. +# This is the reusable runtime primitive behind the MCP ``soul_sync`` tool and +# the intended request/response-path hook for gateways and SDKs. + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from soul_protocol.runtime.types import Interaction + +if TYPE_CHECKING: + from soul_protocol.runtime.soul import Soul + + +class AutoObserveMiddleware: + """Wrap a :class:`Soul` so turns are captured automatically. + + A single :meth:`turn` call performs optional auto-recall (when ``query`` + is given) and mandatory auto-observe, returning recalled memories plus the + soul's updated state. Callers never need to remember to call + :meth:`Soul.observe` themselves — ideal for gateways, SDKs, and agents + that sit in the request/response path. + + Construction:: + + middleware = AutoObserveMiddleware(soul) + result = await middleware.turn( + user_input, agent_output, query="...", user_id="..." + ) + """ + + def __init__( + self, + soul: Soul, + *, + recall_limit: int = 5, + default_channel: str = "auto", + ) -> None: + self._soul = soul + self._recall_limit = recall_limit + self._default_channel = default_channel + + @property + def soul(self) -> Soul: + """The wrapped soul.""" + return self._soul + + async def turn( + self, + user_input: str, + agent_output: str = "", + *, + query: str | None = None, + limit: int | None = None, + user_id: str | None = None, + layer: str | None = None, + domain: str | None = None, + channel: str | None = None, + ) -> dict[str, Any]: + """Capture one conversation turn (auto-recall + auto-observe). + + Args: + user_input: What the user said. + agent_output: What the agent responded (optional; omit for + user-only turns). + query: Optional recall query. When set, relevant memories are + returned before the turn is observed. + limit: Max recall results (defaults to ``recall_limit``). + user_id: Attribute the turn to a user (multi-user souls, #46). + layer: Restrict recall to a single layer (#41). Optional. + domain: Sub-namespace for the written memories (#41). Optional. + channel: Source channel (defaults to ``default_channel``). + + Returns: + A dict with ``soul``, ``mood``, ``energy``, ``user_id``, + ``recalled`` and ``memories`` keys. + """ + memories: list[dict[str, Any]] = [] + if query: + results = await self._soul.recall( + query, + limit=limit if limit is not None else self._recall_limit, + user_id=user_id, + layer=layer, + domain=domain, + ) + memories = [ + { + "id": r.id, + "type": r.type.value, + "layer": r.layer or r.type.value, + "domain": r.domain or "default", + "content": r.content, + "importance": r.importance, + "emotion": r.emotion, + "user_id": r.user_id, + } + for r in results + ] + + await self._soul.observe( + Interaction( + user_input=user_input, + agent_output=agent_output, + channel=channel or self._default_channel, + ), + user_id=user_id, + domain=domain or "default", + ) + + state = self._soul.state + return { + "soul": self._soul.name, + "mood": state.mood.value, + "energy": round(state.energy, 1), + "user_id": user_id, + "recalled": len(memories), + "memories": memories, + } diff --git a/tests/test_mcp/test_client_seams.py b/tests/test_mcp/test_client_seams.py index dd34c949..fcfcddd2 100644 --- a/tests/test_mcp/test_client_seams.py +++ b/tests/test_mcp/test_client_seams.py @@ -58,6 +58,7 @@ "soul_state", "soul_supersede", "soul_switch", + "soul_sync", "soul_update", "soul_verify", } diff --git a/tests/test_mcp/test_server.py b/tests/test_mcp/test_server.py index da88e25c..669b18cd 100644 --- a/tests/test_mcp/test_server.py +++ b/tests/test_mcp/test_server.py @@ -752,6 +752,45 @@ async def test_auto_reload_on_external_change(tmp_path): assert any("external memory" in m["content"] for m in data["memories"]) +async def test_modified_soul_not_reloaded_by_watcher(tmp_path): + """A soul with unsaved in-memory changes is NOT discarded by auto-reload. + + Regression: calling soul_remember (in-memory, unsaved) followed by an + external .soul overwrite must not cause the in-memory fact to vanish when + the next tool call triggers check_and_reload(). + """ + from soul_protocol import Soul + + soul = await Soul.birth("DirtySoul", values=["testing"]) + zip_path = tmp_path / "dirty.soul" + await soul.export(str(zip_path)) + + with _env_context("SOUL_PATH", str(zip_path)), _env_context("SOUL_DIR", None): + async with Client(mcp) as client: + # 1) Unsaved in-memory change (marks the soul as modified). + await client.call_tool( + "soul_remember", + {"content": "unsaved soya chaap fact", "importance": 6}, + ) + + # 2) Externally overwrite the .soul file (simulating another process). + external_soul = await Soul.awaken(str(zip_path)) + await external_soul.remember("external overwrite", importance=9) + await external_soul.export(str(zip_path)) + + # 3) Any tool call routes through check_and_reload(). + await client.call_tool("soul_recall", {"query": "trigger reload", "limit": 5}) + + # 4) The unsaved in-memory fact must still be present. + result = await client.call_tool("soul_recall", {"query": "soya chaap", "limit": 5}) + data = json.loads(result.data) + assert data["count"] != 0, ( + "Unsaved in-memory change was discarded by auto-reload. " + f"Got {data['count']} results." + ) + assert any("soya chaap" in m["content"] for m in data["memories"]) + + async def test_background_watcher_reloads_on_change(tmp_path): """Background file watcher detects changes and reloads without any tool call.""" from soul_protocol import Soul @@ -1159,3 +1198,47 @@ async def test_soul_reinstate_tool(): data = json.loads(result.data) assert data["status"] == "reinstated" assert data["weight"] == 1.0 + + +# --- soul_sync MCP tool --- + + +async def test_soul_sync_observe_only(): + """soul_sync captures a turn and observes without a recall query.""" + async with Client(mcp) as client: + await _birth(client) + result = await client.call_tool( + "soul_sync", + {"user_input": "I love Python", "agent_output": "Python is great!"}, + ) + data = json.loads(result.data) + assert data["status"] == "observed" + assert data["soul"] == "TestBot" + assert data["recalled"] == 0 + assert data["memories"] == [] + assert "mood" in data + assert "energy" in data + + +async def test_soul_sync_with_recall(): + """soul_sync recalls relevant memories when a query is provided.""" + async with Client(mcp) as client: + await _birth(client) + await client.call_tool( + "soul_remember", + {"content": "The user's favorite language is Python", "importance": 8}, + ) + result = await client.call_tool( + "soul_sync", + { + "user_input": "What's my favorite language?", + "agent_output": "You like Python.", + "query": "favorite language", + }, + ) + data = json.loads(result.data) + assert data["status"] == "observed" + assert data["recalled"] >= 1 + assert isinstance(data["memories"], list) + assert len(data["memories"]) == data["recalled"] + assert any("language" in m["content"].lower() for m in data["memories"]) diff --git a/tests/test_memory_layers/test_auto_observe_middleware.py b/tests/test_memory_layers/test_auto_observe_middleware.py new file mode 100644 index 00000000..5f190726 --- /dev/null +++ b/tests/test_memory_layers/test_auto_observe_middleware.py @@ -0,0 +1,58 @@ +# tests/test_memory_layers/test_auto_observe_middleware.py — Auto-capture turns. +# Created: 2026-05 — Verifies AutoObserveMiddleware wraps a Soul so a single +# turn() call performs optional auto-recall plus mandatory auto-observe, +# without the caller remembering to call Soul.observe() themselves. + +from __future__ import annotations + +import pytest + +from soul_protocol.runtime.middleware import AutoObserveMiddleware +from soul_protocol.runtime.soul import Soul +from soul_protocol.runtime.types import MemoryEntry, MemoryType + + +@pytest.fixture +async def soul() -> Soul: + return await Soul.birth(name="Auto", archetype="auto observe test") + + +@pytest.mark.asyncio +async def test_turn_observes_without_query(soul): + mw = AutoObserveMiddleware(soul) + result = await mw.turn("I love Python", "Python is great!") + assert result["soul"] == "Auto" + assert result["recalled"] == 0 + assert result["memories"] == [] + assert "mood" in result + assert "energy" in result + + +@pytest.mark.asyncio +async def test_turn_recalls_when_query_provided(soul): + await soul._memory.add( + MemoryEntry( + type=MemoryType.SEMANTIC, + content="The user's favorite language is Python", + importance=8, + domain="default", + ) + ) + mw = AutoObserveMiddleware(soul) + result = await mw.turn( + "What's my favorite language?", "You like Python.", query="favorite language" + ) + assert result["recalled"] >= 1 + assert isinstance(result["memories"], list) + assert any("language" in m["content"].lower() for m in result["memories"]) + + +@pytest.mark.asyncio +async def test_turn_writes_a_memory(soul): + mw = AutoObserveMiddleware(soul) + # "my name is X" hits a deterministic heuristic FACT_PATTERN, avoiding + # any dependency on LLM-driven extraction in this unit test. + await mw.turn("my name is Alex", "Hi Alex.") + facts = soul._memory._semantic.facts() + epi = soul._memory._episodic.entries() + assert facts or epi, "expected observe to store at least one memory"