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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

---

Expand Down
3 changes: 3 additions & 0 deletions src/soul_protocol/cli/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
60 changes: 60 additions & 0 deletions src/soul_protocol/mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 —
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions src/soul_protocol/runtime/middleware/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]
123 changes: 123 additions & 0 deletions src/soul_protocol/runtime/middleware/auto_observe.py
Original file line number Diff line number Diff line change
@@ -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,
}
1 change: 1 addition & 0 deletions tests/test_mcp/test_client_seams.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
"soul_state",
"soul_supersede",
"soul_switch",
"soul_sync",
"soul_update",
"soul_verify",
}
Expand Down
83 changes: 83 additions & 0 deletions tests/test_mcp/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"])
Loading
Loading