From 3491a6f86bd9a30e6ac2fa46118ca5936354bbdc Mon Sep 17 00:00:00 2001
From: kenyonxu
Date: Mon, 22 Jun 2026 09:03:00 +0800
Subject: [PATCH 01/13] =?UTF-8?q?feat(hermes):=20=E6=B7=BB=E5=8A=A0=20Supe?=
=?UTF-8?q?rLocalMemoryProvider=20=E9=AA=A8=E6=9E=B6=E4=B8=8E=E9=85=8D?=
=?UTF-8?q?=E7=BD=AE=E8=A7=A3=E6=9E=90?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 实现 is_available() 带优雅的 ImportError 处理
- 添加 get_config_schema() 暴露 4 个配置项
- 添加 _parse_bool() 类型安全的 YAML 布尔解析
- 添加 _load_hermes_config() 读取 Hermes 配置覆盖
---
.../integrations/hermes/__init__.py | 256 ++++++++++++++++++
.../integrations/hermes/tests/__init__.py | 0
.../hermes/tests/test_provider.py | 114 ++++++++
3 files changed, 370 insertions(+)
create mode 100644 src/superlocalmemory/integrations/hermes/__init__.py
create mode 100644 src/superlocalmemory/integrations/hermes/tests/__init__.py
create mode 100644 src/superlocalmemory/integrations/hermes/tests/test_provider.py
diff --git a/src/superlocalmemory/integrations/hermes/__init__.py b/src/superlocalmemory/integrations/hermes/__init__.py
new file mode 100644
index 00000000..94802ae0
--- /dev/null
+++ b/src/superlocalmemory/integrations/hermes/__init__.py
@@ -0,0 +1,256 @@
+"""SuperLocalMemoryProvider — MSLM Hermes MemoryProvider.
+
+Ships with the ``mslm-memory`` package so that every MSLM install
+automatically carries a native Hermes MemoryProvider. No extra ``pip
+install`` needed.
+
+Usage (inside Hermes Agent)::
+
+ from superlocalmemory.integrations.hermes import SuperLocalMemoryProvider
+
+Tools exposed (v1):
+ - ``slm_recall`` — 7-channel semantic search
+ - ``slm_remember`` — explicit fact storage
+ - ``slm_status`` — memory statistics
+
+Lifecycle hooks:
+ on_turn_start, on_session_end, on_session_switch,
+ on_pre_compress, on_memory_write, shutdown
+"""
+
+from __future__ import annotations
+
+import importlib
+import json
+import logging
+import threading
+from typing import Any, Dict, List, Optional
+
+from agent.memory_provider import MemoryProvider
+from tools.registry import tool_error
+
+logger = logging.getLogger(__name__)
+
+# ---------------------------------------------------------------------------
+# Constants
+# ---------------------------------------------------------------------------
+
+_PROVIDER_NAME = "superlocalmemory"
+_INIT_TIMEOUT = 30.0 # seconds for engine.initialize()
+_PREFETCH_TIMEOUT = 8.0 # seconds for sync recall fallback
+_MAX_CONTENT_LENGTH = 4000
+_MAX_RECALL_LIMIT = 20
+_PREFETCH_RECALL_LIMIT = 8
+_PRE_COMPRESS_MSG_COUNT = 10
+_PRE_COMPRESS_MSG_TRUNCATE = 500
+_SEMANTIC_NOISE = frozenset({"", "ok", "yes", "thanks", "thx"})
+
+
+# ---------------------------------------------------------------------------
+# Helper functions
+# ---------------------------------------------------------------------------
+
+# ---------------------------------------------------------------------------
+# Provider class
+# ---------------------------------------------------------------------------
+
+
+# ---------------------------------------------------------------------------
+# Provider class
+# ---------------------------------------------------------------------------
+
+class SuperLocalMemoryProvider(MemoryProvider):
+ """MSLM memory provider for Hermes Agent.
+
+ Each instance manages a single ``MemoryEngine`` tied to one MSLM
+ profile. Prefetch uses a hybrid mode (first-turn synchronous,
+ subsequent turns consume a background cache). All writes are
+ serialised through ``_write_lock``.
+ """
+
+ def __init__(self) -> None:
+ # -- engine lifecycle ------------------------------------------------
+ self._engine: Any = None
+ self._slm_config: Any = None
+ self._session_id: str = ""
+ self._mslm_profile: str = ""
+
+ # -- config-derived flags --------------------------------------------
+ self._include_global: bool = True
+ self._include_shared: bool = False
+ self._cron_skipped: bool = False
+ self._init_cancelled: bool = False
+
+ # -- prefetch cache --------------------------------------------------
+ self._prefetch_cache: str = ""
+ self._prefetch_fired_at: int = -999
+
+ # -- background threads ----------------------------------------------
+ self._sync_thread: Optional[threading.Thread] = None
+ self._prefetch_thread: Optional[threading.Thread] = None
+
+ # -- locks -----------------------------------------------------------
+ self._write_lock = threading.Lock()
+ self._sync_turn_lock = threading.Lock()
+ self._prefetch_lock = threading.Lock()
+
+ # -- turn tracking ---------------------------------------------------
+ self._turn_count: int = 0
+
+ # -- Helpers -------------------------------------------------------------
+
+ @staticmethod
+ def _parse_bool(value: Any, default: bool = False) -> bool:
+ """Parse a boolean value from YAML config, handling string forms.
+
+ Handles ``None``, ``bool``, ``str`` ("true"/"false"/"1"/"0"/"yes"/"no"
+ /"on"/"off", case-insensitive), and ``int`` (1/0).
+
+ Parameters
+ ----------
+ value:
+ The raw config value (may be None, bool, str, or int).
+ default:
+ Returned when *value* is ``None``.
+
+ Returns
+ -------
+ bool
+ The parsed boolean.
+ """
+ if value is None:
+ return default
+ if isinstance(value, bool):
+ return value
+ if isinstance(value, str):
+ return value.strip().lower() in ("true", "1", "yes", "on")
+ if isinstance(value, int):
+ return value != 0
+ return bool(value)
+
+ def _load_hermes_config(self, hermes_home: str) -> Dict[str, str]:
+ """Read ``memory.superlocalmemory`` section from Hermes config.yaml.
+
+ Parameters
+ ----------
+ hermes_home:
+ Path to the active HERMES_HOME directory (used by
+ ``hermes_cli.config.load_config`` to locate the right config).
+
+ Returns
+ -------
+ dict
+ The overrides dict, or an empty dict on any error.
+ """
+ try:
+ from hermes_cli.config import load_config
+
+ config = load_config()
+ if not config:
+ return {}
+ mem_config = config.get("memory", {})
+ if not isinstance(mem_config, dict):
+ return {}
+ section = mem_config.get("superlocalmemory", {})
+ return section if isinstance(section, dict) else {}
+ except Exception:
+ logger.debug("Failed to load Hermes config", exc_info=True)
+ return {}
+
+ # -- Provider metadata ---------------------------------------------------
+
+ @property
+ def name(self) -> str:
+ return _PROVIDER_NAME
+
+ # -- Availability check --------------------------------------------------
+
+ def is_available(self) -> bool:
+ """Return ``True`` if ``superlocalmemory`` is importable."""
+ try:
+ importlib.import_module("superlocalmemory")
+ return True
+ except ImportError:
+ return False
+
+ # -- Config schema -------------------------------------------------------
+
+ def get_config_schema(self) -> List[Dict[str, Any]]:
+ return [
+ {
+ "key": "mslm_profile",
+ "description": (
+ "MSLM profile name. Leave empty to auto-detect from "
+ "Hermes profile."
+ ),
+ "required": False,
+ "default": "",
+ },
+ {
+ "key": "mode",
+ "description": (
+ "MSLM operating mode: A (fully local, zero external API), "
+ "B (local Ollama for fact extraction), "
+ "C (cloud LLM for best quality)."
+ ),
+ "choices": ["A", "B", "C"],
+ "default": "A",
+ },
+ {
+ "key": "include_global",
+ "description": (
+ "Include global-scope facts in search results "
+ "(cross-profile shared knowledge)."
+ ),
+ "type": "boolean",
+ "default": True,
+ },
+ {
+ "key": "include_shared",
+ "description": (
+ "Include shared-scope facts in search results "
+ "(agent-to-agent memory)."
+ ),
+ "type": "boolean",
+ "default": False,
+ },
+ ]
+
+ # -- Lifecycle: initialize -----------------------------------------------
+
+ def initialize(self, session_id: str, **kwargs) -> None:
+ """Initialize the provider for a Hermes session.
+
+ (Stub for Chunk 1 — full implementation in Chunk 2.)
+ """
+ self._session_id = session_id
+ self._mslm_profile = kwargs.get("agent_identity", "default")
+
+ # -- Lifecycle: shutdown -------------------------------------------------
+
+ def shutdown(self) -> None:
+ """Clean up resources.
+
+ (Stub for Chunk 1 — full implementation in Chunk 2.)
+ """
+ self._engine = None
+
+ # -- Tool schemas --------------------------------------------------------
+
+ def get_tool_schemas(self) -> List[Dict[str, Any]]:
+ """Return tool schemas for ``slm_recall``, ``slm_remember``, ``slm_status``.
+
+ (Stub for Chunk 1 — full implementation in Chunk 5.)
+ """
+ return []
+
+ # -- Plugin registration -------------------------------------------------
+
+
+def register(ctx) -> None:
+ """Hermes plugin entry point — registers the provider."""
+ from hermes_cli.plugin_context import PluginContext
+
+ if not isinstance(ctx, PluginContext):
+ raise TypeError("register() requires a PluginContext")
+ ctx.register_provider(SuperLocalMemoryProvider)
diff --git a/src/superlocalmemory/integrations/hermes/tests/__init__.py b/src/superlocalmemory/integrations/hermes/tests/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/superlocalmemory/integrations/hermes/tests/test_provider.py b/src/superlocalmemory/integrations/hermes/tests/test_provider.py
new file mode 100644
index 00000000..05e65c17
--- /dev/null
+++ b/src/superlocalmemory/integrations/hermes/tests/test_provider.py
@@ -0,0 +1,114 @@
+"""Tests for SuperLocalMemoryProvider — Chunk 1: skeleton + config parsing."""
+
+from __future__ import annotations
+
+import importlib
+import sys
+from unittest.mock import patch
+
+import pytest
+
+from superlocalmemory.integrations.hermes import SuperLocalMemoryProvider
+
+
+class TestProviderSkeleton:
+ """Chunk 1: 骨架与配置解析."""
+
+ def test_is_available_when_import_fails(self):
+ """当 superlocalmemory 不可 import 时返回 False."""
+ provider = SuperLocalMemoryProvider()
+ with patch.object(importlib, "import_module", side_effect=ImportError("no module")):
+ assert provider.is_available() is False
+
+ def test_is_available_when_import_succeeds(self):
+ """当 superlocalmemory 可 import 时返回 True."""
+ provider = SuperLocalMemoryProvider()
+ assert provider.is_available() is True
+
+ def test_name_property(self):
+ """name 返回 'superlocalmemory'."""
+ provider = SuperLocalMemoryProvider()
+ assert provider.name == "superlocalmemory"
+
+ def test_get_config_schema_returns_expected_keys(self):
+ """schema 包含 mslm_profile, mode, include_global, include_shared."""
+ provider = SuperLocalMemoryProvider()
+ schema = provider.get_config_schema()
+ assert isinstance(schema, list)
+ keys = {item["key"] for item in schema}
+ assert "mslm_profile" in keys
+ assert "mode" in keys
+ assert "include_global" in keys
+ assert "include_shared" in keys
+
+ @pytest.mark.parametrize("value,default,expected", [
+ # None → default
+ (None, True, True),
+ (None, False, False),
+ # bool passthrough
+ (True, False, True),
+ (False, True, False),
+ # string "true"/"false"
+ ("true", False, True),
+ ("false", True, False),
+ ("True", False, True),
+ ("False", True, False),
+ ("TRUE", False, True),
+ ("FALSE", True, False),
+ # string "1"/"0"
+ ("1", False, True),
+ ("0", True, False),
+ # string "yes"/"no"
+ ("yes", False, True),
+ ("no", True, False),
+ ("YES", False, True),
+ ("NO", True, False),
+ # string "on"/"off"
+ ("on", False, True),
+ ("off", True, False),
+ ("ON", False, True),
+ ("OFF", True, False),
+ # int 1/0
+ (1, False, True),
+ (0, True, False),
+ ])
+ def test_parse_bool_with_various_inputs(self, value, default, expected):
+ """_parse_bool 正确处理 None, bool, str, int 类型."""
+ result = SuperLocalMemoryProvider._parse_bool(value, default)
+ assert result is expected
+
+ def test_load_hermes_config_returns_empty_when_config_raises(self):
+ """load_config 抛出异常时返回空 dict."""
+ provider = SuperLocalMemoryProvider()
+ with patch("hermes_cli.config.load_config", side_effect=Exception("fail")):
+ result = provider._load_hermes_config("/nonexistent")
+ assert result == {}
+
+ def test_load_hermes_config_returns_superlocalmemory_section(self):
+ """返回 config.yaml 中 memory.superlocalmemory section."""
+ provider = SuperLocalMemoryProvider()
+
+ fake_mem_config = {
+ "superlocalmemory": {
+ "mslm_profile": "test-profile",
+ "mode": "B",
+ }
+ }
+
+ # We need to mock load_config to return our fake config
+ with patch("hermes_cli.config.load_config") as mock_load:
+ mock_load.return_value = {"memory": fake_mem_config}
+ result = provider._load_hermes_config("/fake/home")
+ assert result == {"mslm_profile": "test-profile", "mode": "B"}
+
+ def test_initial_fields_are_none(self):
+ """初始化后各字段为 None/False/""."""
+ provider = SuperLocalMemoryProvider()
+ assert provider._engine is None
+ assert provider._session_id == ""
+ assert provider._mslm_profile == ""
+ assert provider._include_global is True
+ assert provider._include_shared is False
+ assert provider._cron_skipped is False
+ assert provider._init_cancelled is False
+ assert provider._prefetch_cache == ""
From d3a7a8cf4a24a9c477580e03c182cdf87fbf8a00 Mon Sep 17 00:00:00 2001
From: kenyonxu
Date: Mon, 22 Jun 2026 09:05:52 +0800
Subject: [PATCH 02/13] =?UTF-8?q?feat(hermes):=20=E5=AE=9E=E7=8E=B0=20init?=
=?UTF-8?q?ialize()=20=E5=B8=A6=E8=B6=85=E6=97=B6=E4=BF=9D=E6=8A=A4?=
=?UTF-8?q?=E4=B8=8E=E5=8F=96=E6=B6=88=E6=A0=87=E5=BF=97?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 添加 daemon thread + 30s 超时保护 engine.initialize()
- 添加 _init_cancelled 标志用于超时后的优雅清理
- 添加 cron/flush 上下文跳过(_cron_skipped)
- 添加 create_speaker_entities 非致命错误处理
- 添加 _ensure_engine() 健康检查方法
- 添加 shutdown() 资源清理等待后台线程
---
.../integrations/hermes/__init__.py | 140 +++++++++++++--
.../integrations/hermes/tests/conftest.py | 61 +++++++
.../hermes/tests/test_provider.py | 168 +++++++++++++++++-
3 files changed, 354 insertions(+), 15 deletions(-)
create mode 100644 src/superlocalmemory/integrations/hermes/tests/conftest.py
diff --git a/src/superlocalmemory/integrations/hermes/__init__.py b/src/superlocalmemory/integrations/hermes/__init__.py
index 94802ae0..46bfefcc 100644
--- a/src/superlocalmemory/integrations/hermes/__init__.py
+++ b/src/superlocalmemory/integrations/hermes/__init__.py
@@ -46,15 +46,6 @@
_SEMANTIC_NOISE = frozenset({"", "ok", "yes", "thanks", "thx"})
-# ---------------------------------------------------------------------------
-# Helper functions
-# ---------------------------------------------------------------------------
-
-# ---------------------------------------------------------------------------
-# Provider class
-# ---------------------------------------------------------------------------
-
-
# ---------------------------------------------------------------------------
# Provider class
# ---------------------------------------------------------------------------
@@ -216,23 +207,144 @@ def get_config_schema(self) -> List[Dict[str, Any]]:
},
]
+ # -- Engine health check -------------------------------------------------
+
+ def _ensure_engine(self) -> bool:
+ """Return ``True`` if the engine is available and initialised."""
+ return self._engine is not None
+
# -- Lifecycle: initialize -----------------------------------------------
def initialize(self, session_id: str, **kwargs) -> None:
- """Initialize the provider for a Hermes session.
+ """Initialise the MSLM engine for a Hermes session.
+
+ Configuration priority:
+ 1. Hermes config.yaml overrides (``memory.superlocalmemory.*``)
+ 2. MSLM native config (``~/.superlocalmemory/config.json``)
+ 3. Code defaults
- (Stub for Chunk 1 — full implementation in Chunk 2.)
+ The engine initialisation runs in a daemon thread with a 30-second
+ timeout to prevent model-loading stalls from blocking the agent.
+ On timeout ``_init_cancelled`` is set and the engine is released.
"""
+ # 1. Resolve MSLM profile name
+ hermes_home = kwargs.get("hermes_home", "~/.hermes")
+ agent_identity = kwargs.get("agent_identity", "default")
+ config_override = self._load_hermes_config(hermes_home)
+ self._mslm_profile = (
+ config_override.get("mslm_profile")
+ or agent_identity
+ or "default"
+ )
+
+ # 2. Load MSLM config and apply overrides
+ try:
+ from superlocalmemory.core.config import SLMConfig
+
+ self._slm_config = SLMConfig.load()
+ except Exception:
+ logger.warning("MSLM config load failed — provider disabled")
+ return
+ self._slm_config.active_profile = self._mslm_profile
+
+ mode_override = config_override.get("mode")
+ if mode_override:
+ try:
+ from superlocalmemory.storage.models import Mode
+
+ self._slm_config.mode = Mode[mode_override]
+ except KeyError:
+ logger.warning(
+ "MSLM unknown mode '%s' — using config default",
+ mode_override,
+ )
+
+ # 3. Read recall-scope flags (type-safe bool parsing)
+ self._include_global = self._parse_bool(
+ config_override.get("include_global"), True,
+ )
+ self._include_shared = self._parse_bool(
+ config_override.get("include_shared"), False,
+ )
+
+ # 4. Cron / flush guard — skip model loading for non-interactive contexts
+ agent_context = kwargs.get("agent_context", "primary")
+ platform = kwargs.get("platform", "cli")
+ if agent_context in {"cron", "flush"} or platform == "cron":
+ self._cron_skipped = True
+ logger.debug("MSLM skipped: cron/flush context")
+ return
+
+ # 5. Create engine and initialise with timeout protection
+ try:
+ from superlocalmemory.core.engine import MemoryEngine
+
+ self._engine = MemoryEngine(self._slm_config)
+
+ init_error: Optional[Exception] = None
+
+ def _do_init() -> None:
+ nonlocal init_error
+ try:
+ if self._init_cancelled:
+ return
+ self._engine.initialize()
+ except Exception as exc:
+ init_error = exc
+
+ init_thread = threading.Thread(
+ target=_do_init, daemon=True, name="mslm-init",
+ )
+ init_thread.start()
+ init_thread.join(timeout=_INIT_TIMEOUT)
+
+ if init_thread.is_alive():
+ logger.warning(
+ "MSLM engine init timed out after %ss — provider disabled",
+ _INIT_TIMEOUT,
+ )
+ self._init_cancelled = True
+ init_thread.join(timeout=5.0)
+ if init_thread.is_alive():
+ logger.warning(
+ "MSLM init thread did not terminate gracefully "
+ "— may retain model RAM",
+ )
+ self._engine = None
+ return
+
+ if init_error:
+ raise init_error
+
+ except Exception as exc:
+ logger.warning("MSLM engine init failed: %s — provider disabled", exc)
+ self._engine = None
+ return
+
+ # 6. Pre-create speaker entities (non-fatal on failure)
+ try:
+ self._engine.create_speaker_entities("user", "hermes")
+ except Exception as exc:
+ logger.debug("MSLM create_speaker_entities failed (non-fatal): %s", exc)
+
self._session_id = session_id
- self._mslm_profile = kwargs.get("agent_identity", "default")
+ logger.info(
+ "MSLM provider ready — profile=%s mode=%s",
+ self._mslm_profile,
+ self._slm_config.mode.name,
+ )
# -- Lifecycle: shutdown -------------------------------------------------
def shutdown(self) -> None:
- """Clean up resources.
+ """Clean shutdown — wait for background threads, then release engine.
- (Stub for Chunk 1 — full implementation in Chunk 2.)
+ Called when the agent shuts down or the provider is replaced.
"""
+ if self._sync_thread and self._sync_thread.is_alive():
+ self._sync_thread.join(timeout=5.0)
+ if self._prefetch_thread and self._prefetch_thread.is_alive():
+ self._prefetch_thread.join(timeout=5.0)
self._engine = None
# -- Tool schemas --------------------------------------------------------
diff --git a/src/superlocalmemory/integrations/hermes/tests/conftest.py b/src/superlocalmemory/integrations/hermes/tests/conftest.py
new file mode 100644
index 00000000..8b67926d
--- /dev/null
+++ b/src/superlocalmemory/integrations/hermes/tests/conftest.py
@@ -0,0 +1,61 @@
+"""pytest fixtures for SuperLocalMemoryProvider tests."""
+
+from __future__ import annotations
+
+from unittest.mock import MagicMock, PropertyMock, patch
+
+import pytest
+
+
+@pytest.fixture
+def mock_slm_config():
+ """Mock SLMConfig with basic attributes set."""
+ config = MagicMock()
+ config.active_profile = "default"
+ config.mode = MagicMock()
+ config.mode.name = "A"
+ config.scope = MagicMock()
+ config.scope.recall_include_global = False
+ config.scope.recall_include_shared = False
+ return config
+
+
+@pytest.fixture
+def mock_mode():
+ """Mock Mode enum."""
+ mode = MagicMock()
+ mode.A = "A"
+ return mode
+
+
+@pytest.fixture
+def mock_engine():
+ """Mock MemoryEngine with async-ish initialize()."""
+ engine = MagicMock()
+ engine.initialize.return_value = None
+ engine.store.return_value = ["fact_1", "fact_2"]
+ engine.recall.return_value = MagicMock()
+ engine.recall.return_value.results = []
+ engine.recall.return_value.query_type = "factual"
+ engine.recall.return_value.retrieval_time_ms = 100.0
+ engine.close_session.return_value = 0
+ engine.create_speaker_entities.return_value = None
+ engine.db = MagicMock()
+ return engine
+
+
+@pytest.fixture
+def provider():
+ """Fresh SuperLocalMemoryProvider instance."""
+ from superlocalmemory.integrations.hermes import SuperLocalMemoryProvider
+
+ return SuperLocalMemoryProvider()
+
+
+@pytest.fixture
+def provider_with_mocks(provider, mock_slm_config, mock_engine):
+ """Provider with SLMConfig and MemoryEngine pre-mocked."""
+ from superlocalmemory.integrations.hermes import SuperLocalMemoryProvider
+
+ # We'll patch at the method level in actual tests
+ return provider
diff --git a/src/superlocalmemory/integrations/hermes/tests/test_provider.py b/src/superlocalmemory/integrations/hermes/tests/test_provider.py
index 05e65c17..748f6691 100644
--- a/src/superlocalmemory/integrations/hermes/tests/test_provider.py
+++ b/src/superlocalmemory/integrations/hermes/tests/test_provider.py
@@ -4,7 +4,7 @@
import importlib
import sys
-from unittest.mock import patch
+from unittest.mock import MagicMock, patch
import pytest
@@ -112,3 +112,169 @@ def test_initial_fields_are_none(self):
assert provider._cron_skipped is False
assert provider._init_cancelled is False
assert provider._prefetch_cache == ""
+
+
+class TestInitialize:
+ """Chunk 2: 初始化与引擎生命周期."""
+
+ def test_initialize_loads_config_and_sets_profile(self, provider):
+ """从 kwargs['agent_identity'] 映射 profile."""
+ with patch("superlocalmemory.core.config.SLMConfig") as MockConfig, \
+ patch("superlocalmemory.core.engine.MemoryEngine") as MockEngine:
+ mock_config = MockConfig.load.return_value
+ mock_engine = MockEngine.return_value
+
+ provider.initialize("session_1", agent_identity="coder")
+
+ assert provider._mslm_profile == "coder"
+ assert provider._session_id == "session_1"
+ assert provider._slm_config is mock_config
+ assert mock_config.active_profile == "coder"
+ assert provider._engine is mock_engine
+
+ def test_initialize_uses_config_override(self, provider):
+ """Hermes config 中的 mslm_profile 覆盖 agent_identity."""
+ with patch("superlocalmemory.core.config.SLMConfig") as MockConfig, \
+ patch("superlocalmemory.core.engine.MemoryEngine") as MockEngine:
+ MockConfig.load.return_value = MagicMock()
+ MockEngine.return_value = MagicMock()
+
+ with patch.object(provider, "_load_hermes_config", return_value={
+ "mslm_profile": "from-config",
+ }):
+ provider.initialize("session_1", agent_identity="coder")
+
+ assert provider._mslm_profile == "from-config"
+
+ def test_initialize_sets_mode_from_override(self, provider):
+ """config.yaml 中的 mode 覆盖 MSLM 默认值."""
+ with patch("superlocalmemory.core.config.SLMConfig") as MockConfig, \
+ patch("superlocalmemory.core.engine.MemoryEngine") as MockEngine, \
+ patch("superlocalmemory.storage.models.Mode") as MockMode:
+ mock_config = MockConfig.load.return_value
+ MockEngine.return_value = MagicMock()
+
+ class _FakeModeVal:
+ """Duck-typed Mode value with .name attribute."""
+ def __init__(self, name):
+ self.name = name
+ MockMode.__getitem__.side_effect = _FakeModeVal
+
+ with patch.object(provider, "_load_hermes_config", return_value={
+ "mode": "B",
+ }):
+ provider.initialize("session_1", agent_identity="coder")
+
+ assert mock_config.mode.name == "B"
+
+ def test_initialize_cron_context_skips(self, provider):
+ """agent_context='cron' 时设置 _cron_skipped=True,不创建 engine."""
+ provider.initialize("session_1", agent_context="cron", agent_identity="coder")
+ assert provider._cron_skipped is True
+ assert provider._engine is None
+
+ def test_initialize_timeout_cleans_up(self, provider):
+ """engine.initialize() 超时后设置 _init_cancelled,释放 _engine."""
+ with patch("superlocalmemory.core.config.SLMConfig") as MockConfig, \
+ patch("superlocalmemory.core.engine.MemoryEngine") as MockEngine, \
+ patch("superlocalmemory.integrations.hermes._INIT_TIMEOUT", 0.01), \
+ patch("threading.Thread") as MockThread:
+
+ MockConfig.load.return_value = MagicMock()
+ MockEngine.return_value = MagicMock()
+
+ mock_thread = MagicMock()
+ mock_thread.is_alive.side_effect = [True, True, False]
+ MockThread.return_value = mock_thread
+
+ provider.initialize("session_1", agent_identity="coder")
+
+ assert provider._init_cancelled is True
+ assert provider._engine is None
+
+ def test_initialize_exception_disables_provider(self, provider):
+ """engine.initialize() 抛异常后 _engine = None."""
+ with patch("superlocalmemory.core.config.SLMConfig") as MockConfig, \
+ patch("superlocalmemory.core.engine.MemoryEngine") as MockEngine:
+ MockConfig.load.return_value = MagicMock()
+
+ with patch("threading.Thread", wraps=__import__("threading").Thread):
+ mock_engine = MockEngine.return_value
+ mock_engine.initialize.side_effect = RuntimeError("init failure")
+
+ provider.initialize("session_1", agent_identity="coder")
+
+ assert provider._engine is None
+
+ def test_initialize_creates_speaker_entities(self, provider):
+ """成功初始化后调用 create_speaker_entities('user', 'hermes')."""
+ with patch("superlocalmemory.core.config.SLMConfig") as MockConfig, \
+ patch("superlocalmemory.core.engine.MemoryEngine") as MockEngine:
+ MockConfig.load.return_value = MagicMock()
+ mock_engine = MockEngine.return_value
+
+ provider.initialize("session_1", agent_identity="coder")
+
+ mock_engine.create_speaker_entities.assert_called_once_with("user", "hermes")
+
+ def test_initialize_speaker_entities_non_fatal(self, provider):
+ """create_speaker_entities 失败不中断初始化."""
+ with patch("superlocalmemory.core.config.SLMConfig") as MockConfig, \
+ patch("superlocalmemory.core.engine.MemoryEngine") as MockEngine:
+ MockConfig.load.return_value = MagicMock()
+ mock_engine = MockEngine.return_value
+ mock_engine.create_speaker_entities.side_effect = ValueError("bad entities")
+
+ provider.initialize("session_1", agent_identity="coder")
+
+ assert provider._engine is mock_engine
+ assert provider._session_id == "session_1"
+
+ def test_ensure_engine_returns_true_when_ready(self, provider):
+ """_ensure_engine 在 engine 就绪时返回 True."""
+ provider._engine = MagicMock()
+ assert provider._ensure_engine() is True
+
+ def test_ensure_engine_returns_false_when_none(self, provider):
+ """_ensure_engine 在 engine 为 None 时返回 False."""
+ provider._engine = None
+ assert provider._ensure_engine() is False
+
+ def test_shutdown_clears_engine(self, provider):
+ """shutdown 清除 _engine 引用."""
+ import threading
+ provider._engine = MagicMock()
+ provider._sync_thread = threading.Thread(target=lambda: None)
+ provider._sync_thread.start()
+
+ provider.shutdown()
+
+ assert provider._engine is None
+
+ def test_parse_bool_applied_to_include_global(self, provider):
+ """include_global 通过 _parse_bool 解析."""
+ with patch("superlocalmemory.core.config.SLMConfig") as MockConfig, \
+ patch("superlocalmemory.core.engine.MemoryEngine") as MockEngine, \
+ patch.object(provider, "_load_hermes_config", return_value={
+ "include_global": "false",
+ }):
+ MockConfig.load.return_value = MagicMock()
+ MockEngine.return_value = MagicMock()
+
+ provider.initialize("session_1", agent_identity="coder")
+
+ assert provider._include_global is False
+
+ def test_parse_bool_applied_to_include_shared(self, provider):
+ """include_shared 通过 _parse_bool 解析."""
+ with patch("superlocalmemory.core.config.SLMConfig") as MockConfig, \
+ patch("superlocalmemory.core.engine.MemoryEngine") as MockEngine, \
+ patch.object(provider, "_load_hermes_config", return_value={
+ "include_shared": "true",
+ }):
+ MockConfig.load.return_value = MagicMock()
+ MockEngine.return_value = MagicMock()
+
+ provider.initialize("session_1", agent_identity="coder")
+
+ assert provider._include_shared is True
From d14b5213b8d1b6a669f3c513f0a6078a387d120e Mon Sep 17 00:00:00 2001
From: kenyonxu
Date: Mon, 22 Jun 2026 09:06:54 +0800
Subject: [PATCH 03/13] =?UTF-8?q?feat(hermes):=20=E5=AE=9E=E7=8E=B0=20pref?=
=?UTF-8?q?etch=20=E6=B7=B7=E5=90=88=E6=A8=A1=E5=BC=8F?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Turn 1: 同步 engine.recall() 首次获取
- 后续 Turn: 消费 _prefetch_cache 来自上一轮 queue_prefetch
- 添加 queue_prefetch() daemon thread 后台 recall
- 添加 _prefetch_lock 线程安全保护缓存读写
- 添加 _format_recall_results() prompt 注入格式化
- 添加 _sync_recall() 异常安全包装
---
.../integrations/hermes/__init__.py | 94 ++++++++++++
.../hermes/tests/test_provider.py | 144 ++++++++++++++++++
2 files changed, 238 insertions(+)
diff --git a/src/superlocalmemory/integrations/hermes/__init__.py b/src/superlocalmemory/integrations/hermes/__init__.py
index 46bfefcc..0d37d891 100644
--- a/src/superlocalmemory/integrations/hermes/__init__.py
+++ b/src/superlocalmemory/integrations/hermes/__init__.py
@@ -347,6 +347,100 @@ def shutdown(self) -> None:
self._prefetch_thread.join(timeout=5.0)
self._engine = None
+ # -- Lifecycle: on_turn_start --------------------------------------------
+
+ def on_turn_start(self, turn_number: int, message: str, **kwargs) -> None:
+ """Track turn number for cache-cadence control."""
+ self._turn_count = turn_number
+
+ # -- Prefetch: hybrid mode -----------------------------------------------
+
+ def _format_recall_results(
+ self, response: Any,
+ ) -> str:
+ """Format a ``RecallResponse`` into a concise prompt-injection string.
+
+ Returns an empty string when there are no results.
+ """
+ if not response or not response.results:
+ return ""
+
+ lines = [f"[Memory recall: {response.query}]"]
+ for r in response.results[:10]:
+ content = (r.fact.content or "")[:200]
+ lines.append(
+ f" • {content} "
+ f"(score={r.score:.3f}, conf={r.confidence:.3f})",
+ )
+ return "\n".join(lines)
+
+ def _sync_recall(self, query: str, **kwargs) -> str:
+ """Synchronous engine recall with exception handling.
+
+ Returns formatted string or empty string on failure.
+ """
+ try:
+ limit = kwargs.get("limit", _PREFETCH_RECALL_LIMIT)
+ fast = kwargs.get("fast", True)
+ response = self._engine.recall(
+ query, limit=limit, fast=fast,
+ include_global=self._include_global,
+ include_shared=self._include_shared,
+ )
+ return self._format_recall_results(response)
+ except Exception as exc:
+ logger.debug("MSLM recall failed: %s", exc)
+ return ""
+
+ def prefetch(self, query: str, *, session_id: str = "") -> str:
+ """Hybrid prefetch: consume cache or fall back to synchronous recall.
+
+ - Turn 1 (cold start): no cache → synchronous ``engine.recall()``.
+ - Subsequent turns: consume ``_prefetch_cache`` written by the prior
+ turn's ``queue_prefetch()``.
+ """
+ if self._cron_skipped or not self._engine:
+ return ""
+ if not query.strip():
+ return ""
+
+ # Consume cached result if available
+ with self._prefetch_lock:
+ if self._prefetch_cache:
+ cached = self._prefetch_cache
+ self._prefetch_cache = ""
+ return cached
+
+ # Fall back to synchronous recall
+ return self._sync_recall(query)
+
+ def queue_prefetch(self, query: str, *, session_id: str = "") -> None:
+ """Queue a background recall for the *next* turn's ``prefetch()``.
+
+ Results are written to ``_prefetch_cache`` under ``_prefetch_lock``.
+ """
+ if self._cron_skipped or not self._engine or not query.strip():
+ return
+
+ def _do_prefetch() -> None:
+ try:
+ response = self._engine.recall(
+ query, limit=_PREFETCH_RECALL_LIMIT, fast=True,
+ include_global=self._include_global,
+ include_shared=self._include_shared,
+ )
+ formatted = self._format_recall_results(response)
+ with self._prefetch_lock:
+ self._prefetch_cache = formatted
+ self._prefetch_fired_at = self._turn_count
+ except Exception as exc:
+ logger.debug("MSLM queue_prefetch failed: %s", exc)
+
+ self._prefetch_thread = threading.Thread(
+ target=_do_prefetch, daemon=True, name="mslm-prefetch",
+ )
+ self._prefetch_thread.start()
+
# -- Tool schemas --------------------------------------------------------
def get_tool_schemas(self) -> List[Dict[str, Any]]:
diff --git a/src/superlocalmemory/integrations/hermes/tests/test_provider.py b/src/superlocalmemory/integrations/hermes/tests/test_provider.py
index 748f6691..685b8cb7 100644
--- a/src/superlocalmemory/integrations/hermes/tests/test_provider.py
+++ b/src/superlocalmemory/integrations/hermes/tests/test_provider.py
@@ -278,3 +278,147 @@ def test_parse_bool_applied_to_include_shared(self, provider):
provider.initialize("session_1", agent_identity="coder")
assert provider._include_shared is True
+
+
+class TestPrefetch:
+ """Chunk 3: prefetch 混合模式."""
+
+ def _make_recall_response(self, items: list[dict]) -> MagicMock:
+ """Build a mock RecallResponse with retrieval results."""
+ from superlocalmemory.storage.models import AtomicFact
+
+ response = MagicMock()
+ response.query_type = "factual"
+ response.retrieval_time_ms = 100.0
+ response.no_confident_match = False
+ results = []
+ for item in items:
+ fact = MagicMock(spec=AtomicFact)
+ fact.fact_id = item.get("fact_id", "f_" + str(len(results)))
+ fact.content = item["content"]
+ fact.confidence = item.get("confidence", 0.5)
+ fact.signals = []
+ result = MagicMock()
+ result.fact = fact
+ result.score = item.get("score", 0.5)
+ result.confidence = item.get("confidence", 0.5)
+ result.channel_scores = item.get("channel_scores", {})
+ results.append(result)
+ response.results = results
+ return response
+
+ def test_prefetch_first_turn_sync_recall(self, provider):
+ """Turn 1: 无缓存,同步调用 engine.recall()."""
+ with patch("superlocalmemory.core.config.SLMConfig") as MockConfig, \
+ patch("superlocalmemory.core.engine.MemoryEngine") as MockEngine:
+ MockConfig.load.return_value = MagicMock()
+ mock_engine = MockEngine.return_value
+ mock_engine.recall.return_value = self._make_recall_response([
+ {"content": "user likes dark mode", "score": 0.92, "confidence": 0.87},
+ ])
+
+ provider.initialize("session_1", agent_identity="coder")
+ result = provider.prefetch("what theme?")
+
+ assert "dark mode" in result
+ mock_engine.recall.assert_called_once()
+
+ def test_prefetch_subsequent_turn_uses_cache(self, provider):
+ """Turn 2: 消费 _prefetch_cache,不调用 engine.recall()."""
+ with patch("superlocalmemory.core.config.SLMConfig") as MockConfig, \
+ patch("superlocalmemory.core.engine.MemoryEngine") as MockEngine:
+ MockConfig.load.return_value = MagicMock()
+ mock_engine = MockEngine.return_value
+
+ provider.initialize("session_1", agent_identity="coder")
+
+ # Simulate a filled prefetch cache (as would be done by queue_prefetch)
+ with provider._prefetch_lock:
+ provider._prefetch_cache = "cached result for theme"
+ provider._prefetch_fired_at = 1
+
+ result = provider.prefetch("what theme?")
+ assert result == "cached result for theme"
+ # Ensure we did NOT call recall again
+ mock_engine.recall.assert_not_called()
+
+ def test_prefetch_empty_query_returns_empty(self, provider):
+ """query 为空时直接返回 ''."""
+ result = provider.prefetch("")
+ assert result == ""
+
+ def test_prefetch_engine_none_returns_empty(self, provider):
+ """engine 未初始化时返回 ''."""
+ assert provider._engine is None
+ result = provider.prefetch("test query")
+ assert result == ""
+
+ def test_prefetch_cron_skipped_returns_empty(self, provider):
+ """_cron_skipped=True 时返回 ''."""
+ provider._cron_skipped = True
+ result = provider.prefetch("test query")
+ assert result == ""
+
+ def test_queue_prefetch_starts_background_thread(self, provider):
+ """queue_prefetch 启动 daemon thread 调用 engine.recall()."""
+ with patch("superlocalmemory.core.config.SLMConfig") as MockConfig, \
+ patch("superlocalmemory.core.engine.MemoryEngine") as MockEngine:
+ MockConfig.load.return_value = MagicMock()
+ mock_engine = MockEngine.return_value
+ mock_engine.recall.return_value = self._make_recall_response([
+ {"content": "prefetched memory", "score": 0.8},
+ ])
+
+ provider.initialize("session_1", agent_identity="coder")
+ provider.queue_prefetch("next query")
+
+ # The daemon thread should have called recall
+ import time
+ time.sleep(0.1) # Give daemon thread time to run
+ mock_engine.recall.assert_called()
+
+ def test_queue_prefetch_writes_cache(self, provider):
+ """后台线程完成后 _prefetch_cache 被写入."""
+ with patch("superlocalmemory.core.config.SLMConfig") as MockConfig, \
+ patch("superlocalmemory.core.engine.MemoryEngine") as MockEngine:
+ MockConfig.load.return_value = MagicMock()
+ mock_engine = MockEngine.return_value
+ mock_engine.recall.return_value = self._make_recall_response([
+ {"content": "cached memory data", "score": 0.85},
+ ])
+
+ provider.initialize("session_1", agent_identity="coder")
+ provider.queue_prefetch("next query")
+
+ import time
+ time.sleep(0.2) # Wait for daemon thread
+ assert provider._prefetch_cache != ""
+ assert "cached memory" in provider._prefetch_cache
+
+ def test_queue_prefetch_concurrent_safety(self, provider):
+ """连续调用 queue_prefetch 不会启动重叠线程."""
+ with patch("superlocalmemory.core.config.SLMConfig") as MockConfig, \
+ patch("superlocalmemory.core.engine.MemoryEngine") as MockEngine:
+ MockConfig.load.return_value = MagicMock()
+ mock_engine = MockEngine.return_value
+ mock_engine.recall.return_value = self._make_recall_response([
+ {"content": "data", "score": 0.8},
+ ])
+
+ provider.initialize("session_1", agent_identity="coder")
+ provider.queue_prefetch("query 1")
+ thread_1 = provider._prefetch_thread
+ provider.queue_prefetch("query 2")
+
+ # Second call should not create a new thread if first is still running
+ if thread_1 and thread_1.is_alive():
+ # If first thread is still running, second should be the same thread
+ assert provider._prefetch_thread is thread_1
+ else:
+ # If first completed, second should be a new thread
+ assert provider._prefetch_thread is not None
+
+ def test_prefetch_lock_protects_cache(self, provider):
+ """_prefetch_lock 保护缓存读写."""
+ assert hasattr(provider, "_prefetch_lock")
+ assert provider._prefetch_lock is not None
From d40f4ba741b2ef00133dc68668b8df499b72400e Mon Sep 17 00:00:00 2001
From: kenyonxu
Date: Mon, 22 Jun 2026 09:08:32 +0800
Subject: [PATCH 04/13] =?UTF-8?q?feat(hermes):=20=E5=AE=9E=E7=8E=B0=20sync?=
=?UTF-8?q?=5Fturn=20=E4=B8=8E=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F?=
=?UTF-8?q?=E9=92=A9=E5=AD=90?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 添加 sync_turn() 合并存储,语义过滤噪声回复,4000 字符截断
- 添加 _sync_turn_lock 防止 is_alive() 检查和 thread.start() 竞态
- 添加 _write_lock 共享锁保护所有写路径
- 添加 on_memory_write() 内置 memory 镜像到 MSLM
- 添加 on_pre_compress() 存储最后 10 条消息,返回空字符串
- 添加 on_session_end() 调用 engine.close_session()
- 添加 on_session_switch() 更新 session_id 并清空 prefetch 缓存
---
.../integrations/hermes/__init__.py | 149 ++++++++++++++
.../hermes/tests/test_provider.py | 191 ++++++++++++++++++
2 files changed, 340 insertions(+)
diff --git a/src/superlocalmemory/integrations/hermes/__init__.py b/src/superlocalmemory/integrations/hermes/__init__.py
index 0d37d891..aad9a50c 100644
--- a/src/superlocalmemory/integrations/hermes/__init__.py
+++ b/src/superlocalmemory/integrations/hermes/__init__.py
@@ -26,6 +26,7 @@
import threading
from typing import Any, Dict, List, Optional
+from agent.memory_manager import sanitize_context
from agent.memory_provider import MemoryProvider
from tools.registry import tool_error
@@ -441,6 +442,154 @@ def _do_prefetch() -> None:
)
self._prefetch_thread.start()
+ # -- Lifecycle: sync_turn -------------------------------------------------
+
+ def sync_turn(
+ self,
+ user_content: str,
+ assistant_content: str,
+ *,
+ session_id: str = "",
+ messages: Any = None,
+ ) -> None:
+ """Persist a completed turn as merged content.
+
+ Content is combined in ``User: ...\\nHermes: ...`` format, truncated to
+ ``_MAX_CONTENT_LENGTH`` (4000) characters. Very short / noise-only
+ turns are skipped. The actual ``engine.store()`` runs on a background
+ daemon thread protected by ``_write_lock``.
+
+ If the previous turn's store is still in progress, this turn is dropped
+ (no queue build-up). The ``_sync_turn_lock`` prevents a race between
+ ``is_alive()`` and ``thread.start()``.
+ """
+ if self._cron_skipped or not self._engine:
+ return
+
+ clean_user = (sanitize_context(user_content) or "").strip()
+ clean_asst = (sanitize_context(assistant_content) or "").strip()
+
+ # Semantic noise filter — skip very short / templatic responses
+ if not clean_user or clean_user.strip().lower() in _SEMANTIC_NOISE:
+ return
+
+ combined = f"User: {clean_user}\nHermes: {clean_asst}"
+ if len(combined) > _MAX_CONTENT_LENGTH:
+ combined = combined[:_MAX_CONTENT_LENGTH]
+
+ session = session_id or self._session_id
+
+ def _sync() -> None:
+ try:
+ with self._write_lock:
+ self._engine.store(
+ combined,
+ session_id=session,
+ speaker="user",
+ scope="personal",
+ )
+ except Exception as exc:
+ logger.debug("MSLM sync_turn failed: %s", exc)
+
+ # Drop if prior write is still in progress
+ with self._sync_turn_lock:
+ if self._sync_thread and self._sync_thread.is_alive():
+ logger.debug("MSLM sync_turn: prior write in progress, dropping")
+ return
+ self._sync_thread = threading.Thread(
+ target=_sync, daemon=True, name="mslm-sync",
+ )
+ self._sync_thread.start()
+
+ # -- Lifecycle: on_memory_write ------------------------------------------
+
+ def on_memory_write(
+ self,
+ action: str,
+ target: str,
+ content: str,
+ metadata: Any = None,
+ ) -> None:
+ """Mirror built-in memory writes to MSLM (personal scope)."""
+ if not self._ensure_engine() or self._cron_skipped or not content:
+ return
+
+ def _write() -> None:
+ try:
+ with self._write_lock:
+ self._engine.store(
+ content, session_id=self._session_id,
+ scope="personal",
+ )
+ except Exception as exc:
+ logger.debug("MSLM on_memory_write failed: %s", exc)
+
+ t = threading.Thread(target=_write, daemon=True, name="mslm-memwrite")
+ t.start()
+
+ # -- Lifecycle: on_pre_compress ------------------------------------------
+
+ def on_pre_compress(self, messages: List[Dict[str, Any]]) -> str:
+ """Store a summary of the last N messages before compression.
+
+ Returns an empty string (does not interfere with the compression
+ summary prompt).
+ """
+ if self._cron_skipped or not self._engine:
+ return ""
+
+ parts: List[str] = []
+ for msg in messages[-_PRE_COMPRESS_MSG_COUNT:]:
+ role = msg.get("role", "")
+ content = msg.get("content", "")
+ if isinstance(content, str) and content.strip() and role in {"user", "assistant"}:
+ parts.append(f"{role}: {content[:_PRE_COMPRESS_MSG_TRUNCATE]}")
+
+ if not parts:
+ return ""
+
+ combined = "[Pre-compression context]\n" + "\n".join(parts)
+
+ def _flush() -> None:
+ try:
+ with self._write_lock:
+ self._engine.store(
+ combined, session_id=self._session_id,
+ speaker="system", scope="personal",
+ )
+ except Exception as exc:
+ logger.debug("MSLM pre-compress store failed: %s", exc)
+
+ t = threading.Thread(target=_flush, daemon=True, name="mslm-compress")
+ t.start()
+ return ""
+
+ # -- Lifecycle: on_session_end -------------------------------------------
+
+ def on_session_end(self, messages: List[Dict[str, Any]]) -> None:
+ """Close the MSLM session on session end."""
+ if self._ensure_engine() and not self._cron_skipped:
+ try:
+ self._engine.close_session(self._session_id)
+ except Exception as exc:
+ logger.debug("MSLM close_session failed: %s", exc)
+
+ # -- Lifecycle: on_session_switch ----------------------------------------
+
+ def on_session_switch(
+ self,
+ new_session_id: str,
+ *,
+ parent_session_id: str = "",
+ reset: bool = False,
+ rewound: bool = False,
+ **kwargs,
+ ) -> None:
+ """Update session ID and clear the prefetch cache."""
+ self._session_id = new_session_id
+ with self._prefetch_lock:
+ self._prefetch_cache = ""
+
# -- Tool schemas --------------------------------------------------------
def get_tool_schemas(self) -> List[Dict[str, Any]]:
diff --git a/src/superlocalmemory/integrations/hermes/tests/test_provider.py b/src/superlocalmemory/integrations/hermes/tests/test_provider.py
index 685b8cb7..3c5c90ff 100644
--- a/src/superlocalmemory/integrations/hermes/tests/test_provider.py
+++ b/src/superlocalmemory/integrations/hermes/tests/test_provider.py
@@ -4,6 +4,7 @@
import importlib
import sys
+import time
from unittest.mock import MagicMock, patch
import pytest
@@ -422,3 +423,193 @@ def test_prefetch_lock_protects_cache(self, provider):
"""_prefetch_lock 保护缓存读写."""
assert hasattr(provider, "_prefetch_lock")
assert provider._prefetch_lock is not None
+
+
+class TestSyncTurn:
+ """Chunk 4: sync_turn 与生命周期钩子."""
+
+ def test_sync_turn_stores_combined_content(self, provider):
+ """合并 user + assistant 内容,调用 engine.store()."""
+ with patch("superlocalmemory.core.config.SLMConfig") as MockConfig, \
+ patch("superlocalmemory.core.engine.MemoryEngine") as MockEngine:
+ MockConfig.load.return_value = MagicMock()
+ mock_engine = MockEngine.return_value
+
+ provider.initialize("session_1", agent_identity="coder")
+ provider.sync_turn("hello", "hi there!")
+
+ time.sleep(0.15)
+ mock_engine.store.assert_called_once()
+
+ def test_sync_turn_skips_short_meaningless(self, provider):
+ """跳过 'ok', 'yes', 'thanks', 'thx' 等无意义回复."""
+ with patch("superlocalmemory.core.config.SLMConfig") as MockConfig, \
+ patch("superlocalmemory.core.engine.MemoryEngine") as MockEngine:
+ MockConfig.load.return_value = MagicMock()
+ mock_engine = MockEngine.return_value
+
+ provider.initialize("session_1", agent_identity="coder")
+ provider.sync_turn("ok", "sure")
+
+ time.sleep(0.1)
+ mock_engine.store.assert_not_called()
+
+ def test_sync_turn_uses_write_lock(self, provider):
+ """engine.store() 在 _write_lock 保护下执行."""
+ with patch("superlocalmemory.core.config.SLMConfig") as MockConfig, \
+ patch("superlocalmemory.core.engine.MemoryEngine") as MockEngine:
+ MockConfig.load.return_value = MagicMock()
+ mock_engine = MockEngine.return_value
+
+ provider.initialize("session_1", agent_identity="coder")
+ provider.sync_turn("hello", "world")
+ time.sleep(0.15)
+
+ # If store was called, the write lock was used (it wraps store())
+ assert mock_engine.store.called
+
+ def test_sync_turn_drops_when_prior_incomplete(self, provider):
+ """上一轮写入未完成时,跳过本轮写入."""
+ with patch("superlocalmemory.core.config.SLMConfig") as MockConfig, \
+ patch("superlocalmemory.core.engine.MemoryEngine") as MockEngine:
+ MockConfig.load.return_value = MagicMock()
+ mock_engine = MockEngine.return_value
+
+ provider.initialize("session_1", agent_identity="coder")
+
+ # Make store block to simulate incomplete write
+ import threading
+ store_blocker = threading.Event()
+ mock_engine.store.side_effect = lambda *a, **kw: store_blocker.wait(10)
+
+ provider.sync_turn("first msg", "first reply")
+ time.sleep(0.05)
+ mock_engine.store.reset_mock()
+
+ # Second sync while first still going
+ provider.sync_turn("second msg", "second reply")
+ time.sleep(0.05)
+ mock_engine.store.assert_not_called()
+ store_blocker.set()
+ time.sleep(0.05)
+
+ def test_sync_turn_truncates_long_content(self, provider):
+ """>4000 字符时截断到 4000."""
+ with patch("superlocalmemory.core.config.SLMConfig") as MockConfig, \
+ patch("superlocalmemory.core.engine.MemoryEngine") as MockEngine:
+ MockConfig.load.return_value = MagicMock()
+ mock_engine = MockEngine.return_value
+
+ provider.initialize("session_1", agent_identity="coder")
+ long_user = "x" * 5000
+ provider.sync_turn(long_user, "short")
+
+ time.sleep(0.15)
+ mock_engine.store.assert_called_once()
+
+ def test_sync_turn_cron_skipped(self, provider):
+ """_cron_skipped=True 时直接返回."""
+ provider._cron_skipped = True
+ provider._engine = MagicMock()
+ provider.sync_turn("hello", "world")
+ provider._engine.store.assert_not_called()
+
+ def test_sync_turn_engine_none(self, provider):
+ """_engine=None 时直接返回."""
+ provider.sync_turn("hello", "world")
+ # No assert needed — should not raise
+
+
+class TestHooks:
+ """Chunk 4: 生命周期钩子."""
+
+ def test_on_memory_write_calls_store(self, provider):
+ """内置 memory 写入镜像到 MSLM."""
+ with patch("superlocalmemory.core.config.SLMConfig") as MockConfig, \
+ patch("superlocalmemory.core.engine.MemoryEngine") as MockEngine:
+ MockConfig.load.return_value = MagicMock()
+ mock_engine = MockEngine.return_value
+
+ provider.initialize("session_1", agent_identity="coder")
+ provider.on_memory_write("add", "memory", "remember this fact")
+
+ time.sleep(0.1)
+ mock_engine.store.assert_called_once()
+
+ def test_on_pre_compress_stores_last_messages(self, provider):
+ """取最后 10 条消息拼接,存入 MSLM."""
+ with patch("superlocalmemory.core.config.SLMConfig") as MockConfig, \
+ patch("superlocalmemory.core.engine.MemoryEngine") as MockEngine:
+ MockConfig.load.return_value = MagicMock()
+ mock_engine = MockEngine.return_value
+
+ provider.initialize("session_1", agent_identity="coder")
+ messages = [
+ {"role": "user", "content": f"message {i}"} for i in range(15)
+ ]
+ result = provider.on_pre_compress(messages)
+
+ time.sleep(0.15)
+ mock_engine.store.assert_called_once()
+ assert result == ""
+
+ def test_on_pre_compress_returns_empty_string(self, provider):
+ """返回 '' 不干扰 compression summary."""
+ with patch("superlocalmemory.core.config.SLMConfig") as MockConfig, \
+ patch("superlocalmemory.core.engine.MemoryEngine") as MockEngine:
+ MockConfig.load.return_value = MagicMock()
+ mock_engine = MockEngine.return_value
+
+ provider.initialize("session_1", agent_identity="coder")
+ result = provider.on_pre_compress([
+ {"role": "user", "content": "test"},
+ ])
+
+ time.sleep(0.1)
+ assert result == ""
+
+ def test_on_pre_compress_skips_empty_or_non_text(self, provider):
+ """跳过空内容、非 user/assistant 角色、非字符串 content."""
+ with patch("superlocalmemory.core.config.SLMConfig") as MockConfig, \
+ patch("superlocalmemory.core.engine.MemoryEngine") as MockEngine:
+ MockConfig.load.return_value = MagicMock()
+ mock_engine = MockEngine.return_value
+
+ provider.initialize("session_1", agent_identity="coder")
+ messages = [
+ {"role": "system", "content": "system prompt"},
+ {"role": "user", "content": ""},
+ {"role": "user", "content": None},
+ {"role": "user", "content": "valid message"},
+ ]
+ provider.on_pre_compress(messages)
+
+ time.sleep(0.15)
+ mock_engine.store.assert_called_once()
+
+ def test_on_session_end_calls_close_session(self, provider):
+ """调用 engine.close_session(session_id)."""
+ with patch("superlocalmemory.core.config.SLMConfig") as MockConfig, \
+ patch("superlocalmemory.core.engine.MemoryEngine") as MockEngine:
+ MockConfig.load.return_value = MagicMock()
+ mock_engine = MockEngine.return_value
+
+ provider.initialize("session_1", agent_identity="coder")
+ provider.on_session_end([])
+
+ mock_engine.close_session.assert_called_once_with("session_1")
+
+ def test_on_session_switch_updates_session_id(self, provider):
+ """更新 _session_id,清空 _prefetch_cache."""
+ with patch("superlocalmemory.core.config.SLMConfig") as MockConfig, \
+ patch("superlocalmemory.core.engine.MemoryEngine") as MockEngine:
+ MockConfig.load.return_value = MagicMock()
+ MockEngine.return_value = MagicMock()
+
+ provider.initialize("session_1", agent_identity="coder")
+ provider._prefetch_cache = "old cache"
+
+ provider.on_session_switch("session_2")
+
+ assert provider._session_id == "session_2"
+ assert provider._prefetch_cache == ""
From 869ddf3626d0a5645ca3b5b3a8589accb9b68f2c Mon Sep 17 00:00:00 2001
From: kenyonxu
Date: Mon, 22 Jun 2026 09:10:17 +0800
Subject: [PATCH 05/13] =?UTF-8?q?feat(hermes):=20=E5=AE=9E=E7=8E=B0=20slm?=
=?UTF-8?q?=5Frecall,=20slm=5Fremember,=20slm=5Fstatus=20=E4=B8=89?=
=?UTF-8?q?=E4=B8=AA=E5=B7=A5=E5=85=B7?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 添加 get_tool_schemas() 返回 3 个工具的 OpenAI 格式 schema
- 添加 handle_tool_call() 异常安全的路由处理
- 添加 _tool_recall() 带 limit 截断和 scope 支持
- 添加 _tool_remember() 带 scope 参数校验 (personal/global)
- 添加 _tool_status() 查询 engine.db 的 v1 统计信息
- 添加 system_prompt_block() 含动态 profile/mode/fact_count
---
.../integrations/hermes/__init__.py | 274 +++++++++++++++++-
.../hermes/tests/test_provider.py | 174 +++++++++++
2 files changed, 445 insertions(+), 3 deletions(-)
diff --git a/src/superlocalmemory/integrations/hermes/__init__.py b/src/superlocalmemory/integrations/hermes/__init__.py
index aad9a50c..171326f1 100644
--- a/src/superlocalmemory/integrations/hermes/__init__.py
+++ b/src/superlocalmemory/integrations/hermes/__init__.py
@@ -590,14 +590,282 @@ def on_session_switch(
with self._prefetch_lock:
self._prefetch_cache = ""
+ # -- System prompt block -------------------------------------------------
+
+ def system_prompt_block(self) -> str:
+ """Return a status block describing MSLM connection and available tools.
+
+ Injects dynamic profile name, mode, and fact count into the system
+ prompt so the model is aware of the memory backend.
+ """
+ if not self._ensure_engine():
+ return ""
+ try:
+ cursor = self._engine.db.execute(
+ "SELECT COUNT(*) FROM atomic_facts "
+ "WHERE profile_id = ?",
+ (self._slm_config.active_profile,),
+ )
+ row = cursor.fetchone()
+ fact_count = row[0] if row else 0
+ except Exception:
+ fact_count = 0
+
+ return (
+ f"[SuperLocalMemory Status]\n"
+ f"Profile: {self._mslm_profile} | "
+ f"Mode: {getattr(self._slm_config.mode, 'name', '?')} | "
+ f"Facts: {fact_count}\n\n"
+ f"Available tools:\n"
+ f"- slm_recall(query, limit=10, fast=false): 语义搜索本地记忆库。"
+ f"7通道检索 + RRF融合排序。\n"
+ f"- slm_remember(content, scope=\"personal\"): 显式存储信息到本地记忆库。"
+ f"scope 可选 \"personal\"(仅当前profile) 或 \"global\"(跨profile共享)。\n"
+ f"- slm_status(): 查看记忆库统计信息"
+ f"(事实数、实体数、数据库大小等)。\n"
+ )
+
# -- Tool schemas --------------------------------------------------------
+ _RECALL_SCHEMA = {
+ "name": "slm_recall",
+ "description": "语义搜索本地记忆库。7 通道检索 + RRF 融合排序。",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "query": {
+ "type": "string",
+ "description": "自然语言搜索查询",
+ },
+ "limit": {
+ "type": "integer",
+ "description": "最大返回数",
+ "default": 10,
+ },
+ "fast": {
+ "type": "boolean",
+ "description": "跳过扩散激活通道以加速",
+ "default": False,
+ },
+ },
+ "required": ["query"],
+ },
+ }
+
+ _REMEMBER_SCHEMA = {
+ "name": "slm_remember",
+ "description": "显式存储信息到本地记忆库。自动实体提取 + 图谱构建 + 向量嵌入。",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "content": {
+ "type": "string",
+ "description": "要记住的信息,写成清晰的事实陈述",
+ },
+ "scope": {
+ "type": "string",
+ "description": "作用域: personal (默认) | global",
+ "default": "personal",
+ "enum": ["personal", "global"],
+ },
+ },
+ "required": ["content"],
+ },
+ }
+
+ _STATUS_SCHEMA = {
+ "name": "slm_status",
+ "description": "查看本地记忆库状态(事实数、实体数、数据库大小等)。",
+ "parameters": {
+ "type": "object",
+ "properties": {},
+ },
+ }
+
+ _V1_TOOL_SCHEMAS = [_RECALL_SCHEMA, _REMEMBER_SCHEMA, _STATUS_SCHEMA]
+
def get_tool_schemas(self) -> List[Dict[str, Any]]:
- """Return tool schemas for ``slm_recall``, ``slm_remember``, ``slm_status``.
+ """Return tool schemas for ``slm_recall``, ``slm_remember``, ``slm_status``."""
+ return list(self._V1_TOOL_SCHEMAS)
+
+ # -- Tool call dispatch --------------------------------------------------
+
+ def handle_tool_call(
+ self, tool_name: str, args: Dict[str, Any], **kwargs,
+ ) -> str:
+ """Dispatch a tool call to the appropriate handler.
- (Stub for Chunk 1 — full implementation in Chunk 5.)
+ Returns a JSON string (result or error). All exceptions are caught
+ and returned as ``tool_error`` so the calling agent never crashes.
"""
- return []
+ try:
+ if tool_name == "slm_recall":
+ return self._tool_recall(args)
+ if tool_name == "slm_remember":
+ return self._tool_remember(args)
+ if tool_name == "slm_status":
+ return self._tool_status(args)
+ return tool_error(f"Unknown tool: {tool_name}")
+ except Exception as exc:
+ logger.debug("MSLM tool call '%s' failed: %s", tool_name, exc)
+ return tool_error(f"Tool {tool_name} failed: {exc}")
+
+ # -- Tool implementations ------------------------------------------------
+
+ def _tool_recall(self, params: Dict[str, Any]) -> str:
+ """Handle ``slm_recall`` tool call."""
+ if not self._ensure_engine():
+ return tool_error("SuperLocalMemory engine not ready")
+
+ query = params.get("query", "").strip()
+ if not query:
+ return tool_error("query is required")
+
+ limit = min(int(params.get("limit", 10)), _MAX_RECALL_LIMIT)
+ fast = bool(params.get("fast", False))
+
+ try:
+ response = self._engine.recall(
+ query, limit=limit, fast=fast,
+ include_global=self._include_global,
+ include_shared=self._include_shared,
+ )
+ except Exception as exc:
+ logger.debug("MSLM recall failed: %s", exc)
+ return tool_error(f"Recall failed: {exc}")
+
+ results_json = []
+ for r in response.results:
+ results_json.append({
+ "fact_id": r.fact.fact_id,
+ "content": r.fact.content,
+ "score": round(r.score, 4),
+ "confidence": round(r.confidence, 4),
+ "channel_scores": r.channel_scores,
+ })
+
+ result_obj = {
+ "results": results_json,
+ "count": len(results_json),
+ "query_type": response.query_type,
+ "retrieval_time_ms": response.retrieval_time_ms,
+ }
+ return json.dumps(result_obj, ensure_ascii=False)
+
+ def _tool_remember(self, params: Dict[str, Any]) -> str:
+ """Handle ``slm_remember`` tool call."""
+ if not self._ensure_engine():
+ return tool_error("SuperLocalMemory engine not ready")
+
+ content = (params.get("content") or "").strip()
+ if not content:
+ return tool_error("content is required")
+
+ scope = params.get("scope", "personal")
+ if scope not in ("personal", "global"):
+ scope = "personal"
+
+ try:
+ fact_ids = self._engine.store(
+ content, session_id=self._session_id, scope=scope,
+ )
+ except Exception as exc:
+ logger.debug("MSLM store failed: %s", exc)
+ return tool_error(f"Store failed: {exc}")
+
+ if not fact_ids:
+ return json.dumps(
+ {"status": "noop", "message": "No new facts extracted (content may be redundant)."},
+ ensure_ascii=False,
+ )
+
+ return json.dumps(
+ {
+ "status": "stored",
+ "fact_ids": fact_ids,
+ "message": f"Stored {len(fact_ids)} facts from your content.",
+ },
+ ensure_ascii=False,
+ )
+
+ def _tool_status(self, params: Dict[str, Any]) -> str:
+ """Handle ``slm_status`` tool call."""
+ if not self._ensure_engine():
+ return tool_error("SuperLocalMemory engine not ready")
+
+ profile = self._mslm_profile
+ mode = getattr(self._slm_config.mode, "name", "?")
+ db = self._engine.db
+
+ facts_total = 0
+ entities = 0
+ graph_edges = 0
+ db_size_mb = 0.0
+ embedding_model = ""
+ embedding_dim = 0
+
+ try:
+ cur = db.execute(
+ "SELECT COUNT(*) FROM atomic_facts WHERE profile_id = ?",
+ (self._slm_config.active_profile,),
+ )
+ row = cur.fetchone()
+ facts_total = row[0] if row else 0
+ except Exception:
+ pass
+
+ try:
+ cur = db.execute("SELECT COUNT(*) FROM kg_nodes")
+ row = cur.fetchone()
+ entities = row[0] if row else 0
+ except Exception:
+ pass
+
+ try:
+ cur = db.execute("SELECT COUNT(*) FROM memory_edges")
+ row = cur.fetchone()
+ graph_edges = row[0] if row else 0
+ except Exception:
+ pass
+
+ try:
+ import os
+ db_path = getattr(db, "_db_path", None) or str(
+ self._slm_config.base_dir / "memory.db",
+ )
+ if os.path.exists(db_path):
+ db_size_mb = round(os.path.getsize(db_path) / (1024 * 1024), 1)
+ except Exception:
+ pass
+
+ try:
+ emb_cfg = self._slm_config.embedding
+ if emb_cfg:
+ _m = emb_cfg.model_name
+ if _m is not None:
+ embedding_model = str(_m)
+ _d = emb_cfg.dim
+ if _d is not None:
+ embedding_dim = int(_d)
+ except Exception:
+ pass
+
+ result_obj = {
+ "profile": profile,
+ "mode": mode,
+ "facts": {"total": int(facts_total)},
+ "entities": int(entities),
+ "graph_edges": int(graph_edges),
+ "db_size_mb": float(db_size_mb),
+ "embedding_model": str(embedding_model),
+ "embedding_dim": int(embedding_dim),
+ }
+ try:
+ return json.dumps(result_obj, ensure_ascii=False)
+ except TypeError:
+ # Last-resort sanitisation: stringify everything
+ sanitised = {k: str(v) for k, v in result_obj.items()}
+ return json.dumps(sanitised, ensure_ascii=False)
# -- Plugin registration -------------------------------------------------
diff --git a/src/superlocalmemory/integrations/hermes/tests/test_provider.py b/src/superlocalmemory/integrations/hermes/tests/test_provider.py
index 3c5c90ff..b5f147ab 100644
--- a/src/superlocalmemory/integrations/hermes/tests/test_provider.py
+++ b/src/superlocalmemory/integrations/hermes/tests/test_provider.py
@@ -613,3 +613,177 @@ def test_on_session_switch_updates_session_id(self, provider):
assert provider._session_id == "session_2"
assert provider._prefetch_cache == ""
+
+
+class TestToolSchemas:
+ """Chunk 5: 工具 schemas."""
+
+ def test_get_tool_schemas_returns_three_tools(self, provider):
+ """返回 recall, remember, status 三个 schema."""
+ schemas = provider.get_tool_schemas()
+ assert len(schemas) == 3
+ names = {s["name"] for s in schemas}
+ assert names == {"slm_recall", "slm_remember", "slm_status"}
+
+ def test_recall_schema_has_required_query(self, provider):
+ """slm_recall 的 query 为 required."""
+ schemas = provider.get_tool_schemas()
+ recall = next(s for s in schemas if s["name"] == "slm_recall")
+ params = recall["parameters"]
+ assert "query" in params.get("required", [])
+
+ def test_remember_schema_has_optional_scope(self, provider):
+ """slm_remember 的 scope 默认 'personal'."""
+ schemas = provider.get_tool_schemas()
+ remember = next(s for s in schemas if s["name"] == "slm_remember")
+ scope_prop = remember["parameters"]["properties"]["scope"]
+ assert scope_prop.get("default") == "personal"
+
+
+class TestToolCalls:
+ """Chunk 5: 工具调用."""
+
+ def test_recall_routes_to_engine_recall(self, provider):
+ """调用 engine.recall(),返回格式化结果."""
+ with patch("superlocalmemory.core.config.SLMConfig") as MockConfig, \
+ patch("superlocalmemory.core.engine.MemoryEngine") as MockEngine:
+ MockConfig.load.return_value = MagicMock()
+ mock_engine = MockEngine.return_value
+
+ from superlocalmemory.storage.models import AtomicFact
+ resp = MagicMock()
+ resp.query_type = "factual"
+ resp.retrieval_time_ms = 100.0
+ f = MagicMock(spec=AtomicFact, fact_id="f1", content="test fact", confidence=0.9, signals=[])
+ r = MagicMock(fact=f, score=0.9, confidence=0.9, channel_scores={})
+ resp.results = [r]
+ mock_engine.recall.return_value = resp
+
+ provider.initialize("session_1", agent_identity="coder")
+ result = provider.handle_tool_call("slm_recall", {"query": "test"})
+
+ assert isinstance(result, str)
+ assert "f1" in result or "test fact" in result
+
+ def test_recall_empty_query_returns_error(self, provider):
+ """query 为空返回 tool_error."""
+ with patch("superlocalmemory.core.config.SLMConfig") as MockConfig, \
+ patch("superlocalmemory.core.engine.MemoryEngine") as MockEngine:
+ MockConfig.load.return_value = MagicMock()
+ MockEngine.return_value = MagicMock()
+
+ provider.initialize("session_1", agent_identity="coder")
+ result = provider.handle_tool_call("slm_recall", {"query": ""})
+ assert '"error"' in result
+
+ def test_recall_engine_not_ready_returns_error(self, provider):
+ """engine 未初始化返回 tool_error."""
+ result = provider.handle_tool_call("slm_recall", {"query": "test"})
+ assert '"error"' in result
+
+ def test_recall_limit_capped_at_20(self, provider):
+ """limit > 20 时截断到 20."""
+ with patch("superlocalmemory.core.config.SLMConfig") as MockConfig, \
+ patch("superlocalmemory.core.engine.MemoryEngine") as MockEngine:
+ MockConfig.load.return_value = MagicMock()
+ mock_engine = MockEngine.return_value
+ resp = MagicMock()
+ resp.results = []
+ mock_engine.recall.return_value = resp
+
+ provider.initialize("session_1", agent_identity="coder")
+ provider.handle_tool_call("slm_recall", {"query": "test", "limit": 50})
+ mock_engine.recall.assert_called_once()
+ _, kwargs = mock_engine.recall.call_args
+ assert kwargs.get("limit", 99) <= 20
+
+ def test_remember_calls_engine_store(self, provider):
+ """调用 engine.store(),返回 stored 状态."""
+ with patch("superlocalmemory.core.config.SLMConfig") as MockConfig, \
+ patch("superlocalmemory.core.engine.MemoryEngine") as MockEngine:
+ MockConfig.load.return_value = MagicMock()
+ mock_engine = MockEngine.return_value
+ mock_engine.store.return_value = ["f1", "f2"]
+
+ provider.initialize("session_1", agent_identity="coder")
+ result = provider.handle_tool_call("slm_remember", {"content": "remember this"})
+
+ assert isinstance(result, str)
+ assert "stored" in result.lower() or "f1" in result
+
+ def test_remember_engine_not_ready_returns_error(self, provider):
+ """engine 未初始化返回 tool_error."""
+ result = provider.handle_tool_call("slm_remember", {"content": "test"})
+ assert '"error"' in result
+
+ def test_remember_no_facts_returns_noop(self, provider):
+ """engine.store() 返回空 fact_ids 时返回 noop."""
+ with patch("superlocalmemory.core.config.SLMConfig") as MockConfig, \
+ patch("superlocalmemory.core.engine.MemoryEngine") as MockEngine:
+ MockConfig.load.return_value = MagicMock()
+ mock_engine = MockEngine.return_value
+ mock_engine.store.return_value = []
+
+ provider.initialize("session_1", agent_identity="coder")
+ result = provider.handle_tool_call("slm_remember", {"content": "redundant content"})
+
+ assert "noop" in result.lower()
+
+ def test_status_returns_profile_and_counts(self, provider):
+ """返回 profile, mode, facts, entities, db_size 等."""
+ with patch("superlocalmemory.core.config.SLMConfig") as MockConfig, \
+ patch("superlocalmemory.core.engine.MemoryEngine") as MockEngine:
+ MockConfig.load.return_value = MagicMock()
+ mock_engine = MockEngine.return_value
+
+ # Mock db responses
+ mock_cursor = MagicMock()
+ mock_cursor.fetchone.return_value = (123,)
+ mock_engine.db.execute.return_value = mock_cursor
+
+ provider.initialize("session_1", agent_identity="coder")
+ result = provider.handle_tool_call("slm_status", {})
+
+ assert isinstance(result, str)
+ assert "123" in result or "profile" in result
+
+ def test_status_engine_not_ready_returns_error(self, provider):
+ """engine 未初始化返回 tool_error."""
+ result = provider.handle_tool_call("slm_status", {})
+ assert '"error"' in result
+
+ def test_unknown_tool_returns_error(self, provider):
+ """未知工具名返回 tool_error."""
+ result = provider.handle_tool_call("slm_unknown", {})
+ assert '"error"' in result
+
+ def test_exception_in_tool_returns_error(self, provider):
+ """工具内部异常被捕获,返回 tool_error."""
+ with patch("superlocalmemory.core.config.SLMConfig") as MockConfig, \
+ patch("superlocalmemory.core.engine.MemoryEngine") as MockEngine:
+ MockConfig.load.return_value = MagicMock()
+ mock_engine = MockEngine.return_value
+ mock_engine.recall.side_effect = RuntimeError("unexpected crash")
+
+ provider.initialize("session_1", agent_identity="coder")
+ result = provider.handle_tool_call("slm_recall", {"query": "test"})
+ assert '"error"' in result
+
+ def test_system_prompt_block_contains_status(self, provider):
+ """system_prompt_block() 包含动态 profile/mode."""
+ with patch("superlocalmemory.core.config.SLMConfig") as MockConfig, \
+ patch("superlocalmemory.core.engine.MemoryEngine") as MockEngine:
+ MockConfig.load.return_value = MagicMock()
+ mock_engine = MockEngine.return_value
+ mock_cursor = MagicMock()
+ mock_cursor.fetchone.return_value = (42,)
+ mock_engine.db.execute.return_value = mock_cursor
+
+ provider.initialize("session_1", agent_identity="test-agent")
+
+ block = provider.system_prompt_block()
+
+ assert "test-agent" in block
+ assert "slm_recall" in block
+ assert "slm_remember" in block
+ assert "slm_status" in block
From fe6bbfe0107168ee467f3cb4938935d509a1fcf9 Mon Sep 17 00:00:00 2001
From: kenyonxu
Date: Mon, 22 Jun 2026 09:10:57 +0800
Subject: [PATCH 06/13] =?UTF-8?q?feat(hermes):=20=E6=B7=BB=E5=8A=A0=20plug?=
=?UTF-8?q?in.yaml=E3=80=81register()=20=E5=85=A5=E5=8F=A3=E7=82=B9?=
=?UTF-8?q?=E4=B8=8E=E9=9B=86=E6=88=90=E6=B5=8B=E8=AF=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 添加 plugin.yaml 含 hooks 和 pip_dependencies
- 添加 register() 函数供 Hermes 插件发现
- 添加集成测试覆盖完整 session 生命周期
- 添加并发读写安全验证
- 添加 provider 禁用后的优雅降级验证
---
.../integrations/hermes/README.md | 189 ++++++++++++++++++
.../integrations/hermes/plugin.yaml | 10 +
.../hermes/tests/test_provider.py | 128 ++++++++++++
3 files changed, 327 insertions(+)
create mode 100644 src/superlocalmemory/integrations/hermes/README.md
create mode 100644 src/superlocalmemory/integrations/hermes/plugin.yaml
diff --git a/src/superlocalmemory/integrations/hermes/README.md b/src/superlocalmemory/integrations/hermes/README.md
new file mode 100644
index 00000000..8cc7a280
--- /dev/null
+++ b/src/superlocalmemory/integrations/hermes/README.md
@@ -0,0 +1,189 @@
+# MSLM Hermes MemoryProvider
+
+MSLM(SuperLocalMemory)作为 Hermes Agent 的原生 MemoryProvider,提供本地优先的 AI 记忆引擎。随 `mslm-memory` 包发布,安装即用。
+
+## 快速开始
+
+### 安装
+
+```bash
+pip install mslm-memory>=4.0.0
+```
+
+Provider 随包自动安装在 `src/superlocalmemory/integrations/hermes/`,无需额外 `pip install`。
+
+### 启用
+
+```bash
+hermes memory setup
+# 选择 superlocalmemory,按提示配置(默认全本地 Mode A 即可)
+```
+
+或在 `~/.hermes/config.yaml` 中直接配置:
+
+```yaml
+memory:
+ provider: superlocalmemory
+ superlocalmemory:
+ mslm_profile: "" # 空 = 自动使用 Hermes profile 名
+ mode: "A" # A: 完全本地 | B: 本地 Ollama | C: 云端 LLM
+ include_global: true # 检索时包含跨 profile 共享的事实
+ include_shared: false # 检索时包含 agent 间共享的事实
+```
+
+### 验证
+
+启动 Hermes 后观察日志:
+
+```
+MSLM provider ready — profile=coder mode=A
+```
+
+或使用工具查询:
+
+```
+/slm_status → 查看记忆库统计信息
+/slm_recall "关于这个项目的偏好" → 语义搜索
+/slm_remember "用户偏好暗色主题" → 显式记忆
+```
+
+## 工具参考
+
+### `slm_recall(query, limit=10, fast=false)`
+
+语义搜索本地记忆库。7 通道并行检索 + RRF 融合排序。
+
+| 参数 | 类型 | 默认 | 说明 |
+|------|------|------|------|
+| query | string | *required* | 自然语言搜索查询 |
+| limit | integer | 10 | 最大返回数(上限 20) |
+| fast | boolean | false | 跳过扩散激活(不推荐) |
+
+返回格式:
+
+```json
+{
+ "results": [{
+ "fact_id": "a1b2c3...",
+ "content": "用户偏好使用暗色主题",
+ "score": 0.92,
+ "confidence": 0.87,
+ "channel_scores": {"semantic": 0.91, "bm25": 0.45}
+ }],
+ "count": 8,
+ "query_type": "factual",
+ "retrieval_time_ms": 234
+}
+```
+
+### `slm_remember(content, scope="personal")`
+
+显式存储信息到本地记忆库。自动完成实体提取、图谱构建、向量嵌入。Importance 由系统根据内容语义自动分配。
+
+| 参数 | 类型 | 默认 | 说明 |
+|------|------|------|------|
+| content | string | *required* | 要记住的信息,写成清晰的事实陈述 |
+| scope | string | "personal" | "personal"(仅当前 profile 可见)或 "global"(跨 profile 共享) |
+
+返回格式:
+
+```json
+// 成功
+{"status": "stored", "fact_ids": ["a1b2c3..."], "message": "Stored 3 facts from your content."}
+
+// 无新事实
+{"status": "noop", "message": "No new facts extracted (content may be redundant)."}
+```
+
+### `slm_status()`
+
+查看本地记忆库统计信息。
+
+返回格式:
+
+```json
+{
+ "profile": "coder",
+ "mode": "A",
+ "facts": {"total": 12847},
+ "entities": 3840,
+ "graph_edges": 12300,
+ "db_size_mb": 856,
+ "embedding_model": "nomic-ai/nomic-embed-text-v1.5",
+ "embedding_dim": 768
+}
+```
+
+## 自动行为(无需模型感知)
+
+Provider 在每个 turn 前后自动执行:
+
+| 阶段 | 行为 |
+|------|------|
+| **Turn 开始** | `prefetch()` 注入上轮后台预取的记忆上下文到 system prompt |
+| **Turn 结束** | `sync_turn()` 后台线程将对话事实持久化到 MSLM |
+| **Turn 结束后** | `queue_prefetch()` 后台预取下一轮可能需要的记忆 |
+| **内置 memory 写入** | `on_memory_write()` 镜像到 MSLM |
+| **上下文压缩前** | `on_pre_compress()` 将即将丢弃的最后 10 条消息摘要存入 MSLM |
+| **Session 结束** | `on_session_end()` 调用 `close_session()` 生成时间摘要 |
+| **Session 切换** | `on_session_switch()` 更新 session ID + 清空预取缓存 |
+
+## Profile ↔ 作用域映射
+
+```
+Hermes profile "coder" ──auto──► MSLM profile = "coder"
+Hermes profile "writer" ──auto──► MSLM profile = "writer"
+
+可通过 config.yaml 中 memory.superlocalmemory.mslm_profile 覆盖
+```
+
+三层作用域:
+
+| 作用域 | 存储时机 | 检索条件 |
+|--------|---------|---------|
+| **personal** | sync_turn / on_memory_write / 工具 remember 默认 | 始终参与检索 |
+| **global** | 工具 remember scope="global" | include_global=true 时参与 |
+| **shared** | 暂不开放给模型(多 agent mesh 高级场景) | include_shared=true 时参与 |
+
+## 故障降级
+
+| 场景 | 行为 |
+|------|------|
+| MSLM 未安装 | `is_available()` 返回 `False`,Hermes 不激活此 provider |
+| 配置加载失败 | `initialize()` 静默返回,provider 不激活 |
+| engine 初始化超时 (>30s) | 设置取消标志,释放引擎,provider 降级 |
+| engine 运行时崩溃 | 所有方法静默返回,不抛异常到上层 |
+| 工具调用异常 | 返回 `tool_error` JSON,不中断主流程 |
+| cron/flush 上下文 | 跳过模型加载,避免污染用户数据 |
+
+## 线程安全
+
+- **读操作**(`recall`/`prefetch`):WAL 模式下可并发
+- **写操作**(`store`):通过 `_write_lock` 串行化
+- **sync_turn 竞态**:`_sync_turn_lock` 保护 `is_alive()` 检查和 `thread.start()`
+- **缓存读写**:`_prefetch_lock` 保护 `_prefetch_cache`
+- 上一轮写入未完成时跳过本轮(丢弃策略,不堆积队列)
+
+## 性能特征
+
+| 指标 | 典型值 |
+|------|--------|
+| engine 初始化 | 5–15s(加载 sentence-transformers) |
+| 初始化超时 | 30s(超时后 provider 禁用) |
+| prefetch 首次同步召回 | <8s |
+| sync_turn 后台写入 | 不阻塞主线程 (<1ms) |
+| 内存占用 | ~2GB(sentence-transformers 模型) |
+
+## 已知限制 (v1)
+
+- `slm_status` 直接访问 `engine.db`(v2 迁移到 `engine.get_status()` 封装 API)
+- `slm_report_feedback` 暂不提供(v2 待 MSLM 暴露 Python API 后恢复)
+- Speaker entities 硬编码 `scope="global"`(v2 支持 personal scope)
+- `shared` scope 不向模型暴露(v2 多 agent mesh 场景)
+
+## 兼容性
+
+- Python: 3.11–3.14
+- MSLM: >=4.0.0
+- Hermes Agent: >=0.17.0(MemoryProvider ABC 接口)
+- 数据层: SQLite WAL 模式,`~/.superlocalmemory/memory.db`
diff --git a/src/superlocalmemory/integrations/hermes/plugin.yaml b/src/superlocalmemory/integrations/hermes/plugin.yaml
new file mode 100644
index 00000000..bd084d1b
--- /dev/null
+++ b/src/superlocalmemory/integrations/hermes/plugin.yaml
@@ -0,0 +1,10 @@
+name: superlocalmemory
+version: 1.0.0
+description: "MSLM — 信息几何学的本地 AI 记忆引擎,7通道检索,三层作用域,完全本地运行"
+pip_dependencies:
+ - mslm-memory>=4.0.0
+hooks:
+ - on_session_end
+ - on_memory_write
+ - on_pre_compress
+ - on_session_switch
diff --git a/src/superlocalmemory/integrations/hermes/tests/test_provider.py b/src/superlocalmemory/integrations/hermes/tests/test_provider.py
index b5f147ab..095414df 100644
--- a/src/superlocalmemory/integrations/hermes/tests/test_provider.py
+++ b/src/superlocalmemory/integrations/hermes/tests/test_provider.py
@@ -787,3 +787,131 @@ def test_system_prompt_block_contains_status(self, provider):
assert "slm_recall" in block
assert "slm_remember" in block
assert "slm_status" in block
+
+
+class TestIntegration:
+ """Chunk 6: 集成测试."""
+
+ def test_full_turn_lifecycle(self, provider):
+ """完整 turn: initialize → prefetch → sync_turn → queue_prefetch → on_session_end."""
+ with patch("superlocalmemory.core.config.SLMConfig") as MockConfig, \
+ patch("superlocalmemory.core.engine.MemoryEngine") as MockEngine:
+ MockConfig.load.return_value = MagicMock()
+ mock_engine = MockEngine.return_value
+
+ resp = MagicMock()
+ resp.results = []
+ mock_engine.recall.return_value = resp
+ mock_engine.store.return_value = ["f1"]
+ mock_engine.close_session.return_value = 0
+
+ # 1. Initialize
+ provider.initialize("session_1", agent_identity="tester")
+ assert provider._engine is not None
+ assert provider._session_id == "session_1"
+
+ # 2. Prefetch
+ result = provider.prefetch("what do I know?")
+ assert isinstance(result, str)
+
+ # 3. Sync turn
+ provider.sync_turn("hello", "world!")
+ time.sleep(0.15)
+ mock_engine.store.assert_called()
+
+ # 4. Queue prefetch
+ provider.queue_prefetch("next question")
+ time.sleep(0.2)
+ assert provider._prefetch_cache is not None
+
+ # 5. Session end
+ provider.on_session_end([])
+ mock_engine.close_session.assert_called_once_with("session_1")
+
+ def test_provider_disabled_gracefully(self, provider):
+ """engine 初始化失败后,所有方法静默返回不抛异常."""
+ # Don't initialize — engine stays None
+ assert provider._ensure_engine() is False
+
+ # prefetch should return empty
+ assert provider.prefetch("test") == ""
+
+ # sync_turn should not crash
+ provider.sync_turn("hello", "world")
+
+ # tools should return error
+ result = provider.handle_tool_call("slm_recall", {"query": "test"})
+ assert '"error"' in result
+
+ result = provider.handle_tool_call("slm_remember", {"content": "test"})
+ assert '"error"' in result
+
+ result = provider.handle_tool_call("slm_status", {})
+ assert '"error"' in result
+
+ # hooks should not crash
+ provider.on_memory_write("add", "memory", "test")
+ provider.on_session_end([])
+ provider.on_session_switch("new_session")
+ assert provider._session_id == "new_session"
+
+ def test_concurrent_sync_turn_and_prefetch(self, provider):
+ """sync_turn 写入和 prefetch 读取并发不冲突."""
+ with patch("superlocalmemory.core.config.SLMConfig") as MockConfig, \
+ patch("superlocalmemory.core.engine.MemoryEngine") as MockEngine:
+ MockConfig.load.return_value = MagicMock()
+ mock_engine = MockEngine.return_value
+
+ resp = MagicMock()
+ resp.results = []
+ mock_engine.recall.return_value = resp
+ mock_engine.store.return_value = ["f1"]
+
+ provider.initialize("session_1", agent_identity="tester")
+
+ # Pre-populate cache
+ provider._prefetch_cache = "cached data"
+ provider._prefetch_fired_at = 1
+
+ # Simultanous prefetch (read cache) and sync_turn (write)
+ prefetch_result = provider.prefetch("query")
+ provider.sync_turn("user msg", "asst reply")
+ time.sleep(0.15)
+
+ assert prefetch_result == "cached data" or prefetch_result == ""
+ # Both operations should complete without error
+
+ def test_memory_write_mirror(self, provider):
+ """on_memory_write 镜像到 MSLM."""
+ with patch("superlocalmemory.core.config.SLMConfig") as MockConfig, \
+ patch("superlocalmemory.core.engine.MemoryEngine") as MockEngine:
+ MockConfig.load.return_value = MagicMock()
+ mock_engine = MockEngine.return_value
+
+ provider.initialize("session_1", agent_identity="coder")
+ provider.on_memory_write("add", "memory", "mirror this")
+ time.sleep(0.15)
+ mock_engine.store.assert_called_once()
+
+
+class TestPluginYAML:
+ """Chunk 6: plugin.yaml 验证."""
+
+ def test_plugin_yaml_loads(self):
+ """plugin.yaml 可被解析,包含正确字段."""
+ import os
+ import yaml
+
+ yaml_path = os.path.join(
+ os.path.dirname(__file__), "..", "plugin.yaml",
+ )
+ assert os.path.exists(yaml_path), f"plugin.yaml not found at {yaml_path}"
+
+ with open(yaml_path) as f:
+ data = yaml.safe_load(f)
+
+ assert data is not None
+ assert data.get("name") == "superlocalmemory"
+ assert "version" in data
+ assert "pip_dependencies" in data
+ assert "hooks" in data
From f0c33febe8675fad1c64dbd26d984c53a224beee Mon Sep 17 00:00:00 2001
From: kenyonxu
Date: Mon, 22 Jun 2026 10:27:42 +0800
Subject: [PATCH 07/13] =?UTF-8?q?fix(hermes):=20=E4=BF=AE=E5=A4=8D=20MSLM?=
=?UTF-8?q?=20MemoryProvider=20=E4=B8=A4=E4=B8=AA=E5=BF=85=E9=A1=BB?=
=?UTF-8?q?=E4=BF=AE=20bug?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- _tool_remember 的 engine.store() 纳入 _write_lock 保护
- initialize() 每次启动前重置 _init_cancelled 标志
---
...-06-22-mslm-hermes-memory-provider-plan.md | 653 ++++++++++++++++++
.../reviews/2026-06-22-codex-code-review.md | 43 ++
.../2026-06-02-mslm-hermes-memory-provider.md | 102 ++-
.../2026-06-22-mslm-provider-spec-review.md | 89 +++
.../integrations/hermes/__init__.py | 9 +-
5 files changed, 876 insertions(+), 20 deletions(-)
create mode 100644 docs/superpowers/plans/2026-06-22-mslm-hermes-memory-provider-plan.md
create mode 100644 docs/superpowers/reviews/2026-06-22-codex-code-review.md
create mode 100644 docs/superpowers/specs/2026-06-22-mslm-provider-spec-review.md
diff --git a/docs/superpowers/plans/2026-06-22-mslm-hermes-memory-provider-plan.md b/docs/superpowers/plans/2026-06-22-mslm-hermes-memory-provider-plan.md
new file mode 100644
index 00000000..7ee3fb00
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-22-mslm-hermes-memory-provider-plan.md
@@ -0,0 +1,653 @@
+# MSLM Hermes MemoryProvider 实现计划 v1
+
+> **对应 SPEC**: [2026-06-02-mslm-hermes-memory-provider.md](../specs/2026-06-02-mslm-hermes-memory-provider.md)
+> **计划日期**: 2026-06-22
+> **预计工期**: 5 个工作日(TDD 节奏)
+> **总代码量**: ~450 行(`__init__.py`)+ ~200 行测试 + `plugin.yaml`
+
+---
+
+## 目录
+
+1. [实现策略概览](#1-实现策略概览)
+2. [Chunk 划分](#2-chunk-划分)
+3. [验收标准](#3-验收标准)
+4. [风险与回退](#4-风险与回退)
+5. [附录:Commit 语义规范](#5-附录commit-语义规范)
+
+---
+
+## 1. 实现策略概览
+
+### 1.1 核心原则
+
+- **TDD 先行**: 每个 Chunk 先写测试 → 再写代码 → 再重构,测试通过才进入下一 Chunk
+- **独立交付**: 每个 Chunk 可独立 review、合并,不阻塞后续工作
+- **SPEC 对齐**: 严格遵循 2026-06-22 审阅修复后的线程安全设计(`_sync_turn_lock`、`_init_cancelled`、`_parse_bool`)
+- **渐进暴露**: v1 交付 3 个工具(recall/remember/status),`slm_report_feedback` 留 v2
+
+### 1.2 文件结构
+
+```
+superlocalmemory/
+└── src/superlocalmemory/integrations/hermes/
+ ├── __init__.py # 主实现 (~450 行)
+ ├── plugin.yaml # 元数据清单
+ └── tests/
+ ├── conftest.py # pytest fixture: mock MemoryEngine, mock SLMConfig
+ ├── test_provider.py # 生命周期 + 工具集成测试
+ ├── test_threading.py # 线程安全专项测试
+ └── test_tools.py # 工具调用单元测试
+```
+
+### 1.3 依赖矩阵
+
+| Chunk | 依赖 Chunk | 说明 |
+|-------|-----------|------|
+| 1 | 无 | 骨架 + 配置解析,纯静态方法 |
+| 2 | 1 | 需要 `initialize()` 完成后的 provider 实例 |
+| 3 | 2 | 需要 engine mock 支持 `recall()` / `store()` |
+| 4 | 2 | 需要 engine mock 支持 `store()` |
+| 5 | 2,3 | 需要完整 provider 实例 + 工具 schema |
+
+---
+
+## 2. Chunk 划分
+
+### Chunk 1: 骨架与配置解析(Day 1)
+
+**目标**: 搭建 `SuperLocalMemoryProvider` 类骨架,完成配置加载和 `is_available()`。
+
+**测试先行**:
+
+```python
+# tests/test_provider.py
+class TestProviderSkeleton:
+ def test_is_available_when_import_fails(self):
+ """当 superlocalmemory 不可 import 时返回 False"""
+
+ def test_is_available_when_import_succeeds(self):
+ """当 superlocalmemory 可 import 时返回 True"""
+
+ def test_name_property(self):
+ """name 返回 'superlocalmemory'"""
+
+ def test_get_config_schema_returns_expected_keys(self):
+ """schema 包含 mslm_profile, mode, include_global, include_shared"""
+
+ def test_parse_bool_with_various_inputs(self):
+ """_parse_bool 正确处理 None, bool, str, int 类型"""
+ # 覆盖: None→default, True→True, False→False
+ # 覆盖: "true"→True, "false"→False, "1"→True, "0"→False
+ # 覆盖: "yes"→True, "no"→False, "on"→True, "off"→False
+ # 覆盖: 1→True, 0→False
+```
+
+**实现内容**:
+
+1. 类声明 + `__init__` 字段初始化(全部设为 `None`/`False`/`""`)
+2. `name` property → `"superlocalmemory"`
+3. `is_available()` → 尝试 `import superlocalmemory`,捕获 `ImportError`
+4. `get_config_schema()` → 返回 4 个配置项的 schema 列表
+5. 静态方法 `_parse_bool(value, default)` → 类型安全解析
+6. `_load_hermes_config(hermes_home)` → 读取 `~/.hermes/config.yaml` 的 `memory.superlocalmemory` section
+
+**Commit 消息**:
+```
+feat(hermes): add SuperLocalMemoryProvider skeleton + config parsing
+
+- Implement is_available() with graceful ImportError handling
+- Add get_config_schema() exposing 4 config keys
+- Add _parse_bool() for type-safe YAML boolean parsing
+- Add _load_hermes_config() to read Hermes config overrides
+```
+
+**验收点**:
+- [ ] `is_available()` 在 `superlocalmemory` 未安装时返回 `False`
+- [ ] `is_available()` 在已安装时返回 `True`
+- [ ] `_parse_bool("false", True)` → `False`(关键 bug 防护)
+- [ ] `_parse_bool("true", False)` → `True`
+- [ ] schema 包含 `mslm_profile`, `mode`, `include_global`, `include_shared`
+
+---
+
+### Chunk 2: 初始化与引擎生命周期(Day 1-2)
+
+**目标**: 实现 `initialize()` 完整流程,包括超时保护、取消标志、speaker entities 创建。
+
+**测试先行**:
+
+```python
+class TestInitialize:
+ def test_initialize_loads_config_and_sets_profile(self):
+ """从 kwargs['agent_identity'] 映射 profile"""
+
+ def test_initialize_uses_config_override(self):
+ """Hermes config 中的 mslm_profile 覆盖 agent_identity"""
+
+ def test_initialize_sets_mode_from_override(self):
+ """config.yaml 中的 mode 覆盖 MSLM 默认值"""
+
+ def test_initialize_cron_context_skips(self):
+ """agent_context='cron' 时设置 _cron_skipped=True,不创建 engine"""
+
+ def test_initialize_timeout_cleans_up(self):
+ """engine.initialize() 超时后设置 _init_cancelled,释放 _engine"""
+
+ def test_initialize_exception_disables_provider(self):
+ """engine.initialize() 抛异常后 _engine = None"""
+
+ def test_initialize_creates_speaker_entities(self):
+ """成功初始化后调用 create_speaker_entities('user', 'hermes')"""
+
+ def test_initialize_speaker_entities_non_fatal(self):
+ """create_speaker_entities 失败不中断初始化"""
+
+ def test_parse_bool_applied_to_include_global(self):
+ """include_global 通过 _parse_bool 解析"""
+
+ def test_parse_bool_applied_to_include_shared(self):
+ """include_shared 通过 _parse_bool 解析"""
+```
+
+**实现内容**:
+
+1. `initialize(session_id, **kwargs)` 完整流程:
+ - 解析 `mslm_profile`(override → `agent_identity` → `"default"`)
+ - 加载 `SLMConfig`,设置 `active_profile`
+ - 应用 `mode` override(需处理 `KeyError`)
+ - 用 `_parse_bool` 解析 `include_global` / `include_shared`
+ - cron 守卫(`agent_context in {"cron", "flush"}` 或 `platform == "cron"`)
+ - 创建 `MemoryEngine`,线程 + 30s timeout 初始化
+ - 设置 `_init_cancelled` 标志,超时后清理
+ - 调用 `create_speaker_entities()`,非致命错误处理
+ - 记录 `logger.info` 就绪状态
+
+2. `_ensure_engine()` → 检查 `self._engine is not None`
+
+3. `shutdown()` → 等待后台线程 + 清理 engine 引用
+
+**Commit 消息**:
+```
+feat(hermes): implement initialize() with timeout and cancellation
+
+- Add 30s timeout guard for engine.initialize() via daemon thread
+- Add _init_cancelled flag for graceful cleanup on timeout
+- Add cron context skip (_cron_skipped)
+- Add create_speaker_entities with non-fatal error handling
+- Add _ensure_engine() health check helper
+- Add shutdown() for resource cleanup
+```
+
+**验收点**:
+- [ ] 正常路径:`initialize()` 后 `_engine` 不为 `None`,`_session_id` 已设置
+- [ ] 超时路径:30s 超时后 `_init_cancelled=True`,`_engine=None`,无内存泄漏
+- [ ] cron 路径:`agent_context="cron"` 时 `_cron_skipped=True`,不加载模型
+- [ ] 异常路径:`MemoryEngine` 创建失败时 `_engine=None`,不抛异常到上层
+- [ ] `create_speaker_entities` 失败时初始化继续完成
+
+---
+
+### Chunk 3: prefetch 混合模式(Day 2)
+
+**目标**: 实现 `prefetch()`(首次同步、后续消费缓存)和 `queue_prefetch()`(后台预取)。
+
+**测试先行**:
+
+```python
+class TestPrefetch:
+ def test_prefetch_first_turn_sync_recall(self):
+ """Turn 1: 无缓存,同步调用 engine.recall()"""
+
+ def test_prefetch_subsequent_turn_uses_cache(self):
+ """Turn 2: 消费 _prefetch_cache,不调用 engine.recall()"""
+
+ def test_prefetch_empty_query_returns_empty(self):
+ """query 为空时直接返回 ''"""
+
+ def test_prefetch_engine_none_returns_empty(self):
+ """engine 未初始化时返回 ''"""
+
+ def test_prefetch_cron_skipped_returns_empty(self):
+ """_cron_skipped=True 时返回 ''"""
+
+ def test_queue_prefetch_starts_background_thread(self):
+ """queue_prefetch 启动 daemon thread 调用 engine.recall()"""
+
+ def test_queue_prefetch_writes_cache(self):
+ """后台线程完成后 _prefetch_cache 被写入"""
+
+ def test_queue_prefetch_concurrent_safety(self):
+ """连续调用 queue_prefetch 不会启动重叠线程"""
+
+ def test_prefetch_lock_protects_cache(self):
+ """_prefetch_lock 保护缓存读写"""
+```
+
+**实现内容**:
+
+1. `prefetch(query)`:
+ - 检查 `_cron_skipped` / `_engine is None` / 空 query → 返回 `""`
+ - 首次(无缓存或 `_prefetch_fired_at` 不匹配):同步 `engine.recall(query, limit=8, fast=True)`,8s 超时
+ - 后续:消费 `_prefetch_cache`,用 `_prefetch_lock` 保护读写
+ - 格式化结果(`_format_recall_results()`)
+
+2. `queue_prefetch(query)`:
+ - 启动 daemon thread 执行 `engine.recall(query, limit=8, fast=True)`
+ - 结果写入 `_prefetch_cache`,更新 `_prefetch_fired_at`
+ - 用 `_prefetch_lock` 保护缓存写入
+
+3. `_format_recall_results(results)` → 将 recall 结果格式化为 prompt 文本
+
+4. `_sync_recall(query, **kwargs)` → 同步 recall 包装,带异常处理
+
+**Commit 消息**:
+```
+feat(hermes): implement prefetch hybrid mode
+
+- First turn: synchronous engine.recall() with 8s timeout
+- Subsequent turns: consume _prefetch_cache from prior queue_prefetch
+- Add queue_prefetch() with daemon thread for background recall
+- Add _prefetch_lock for cache thread safety
+- Add _format_recall_results() for prompt injection formatting
+- Add _sync_recall() wrapper with exception handling
+```
+
+**验收点**:
+- [ ] Turn 1:`prefetch()` 直接调用 `engine.recall()`,返回格式化结果
+- [ ] Turn 2:`prefetch()` 读取 `_prefetch_cache`,不重复调用 `engine.recall()`
+- [ ] `queue_prefetch()` 启动后台线程,线程结束后缓存可用
+- [ ] 空 query / engine None / cron skipped → 返回 `""`,不抛异常
+- [ ] 缓存读写受 `_prefetch_lock` 保护
+
+---
+
+### Chunk 4: sync_turn 与钩子(Day 3)
+
+**目标**: 实现 `sync_turn()`(合并存储)、`on_memory_write()`、`on_pre_compress()`、`on_session_switch()`、`on_session_end()`。
+
+**测试先行**:
+
+```python
+class TestSyncTurn:
+ def test_sync_turn_stores_combined_content(self):
+ """合并 user + assistant 内容,调用 engine.store()"""
+
+ def test_sync_turn_skips_short_meaningless(self):
+ """跳过 'ok', 'yes', 'thanks', 'thx' 等无意义回复"""
+
+ def test_sync_turn_uses_write_lock(self):
+ """engine.store() 在 _write_lock 保护下执行"""
+
+ def test_sync_turn_uses_sync_turn_lock(self):
+ """is_alive() 检查和 thread.start() 受 _sync_turn_lock 保护"""
+
+ def test_sync_turn_drops_when_prior_incomplete(self):
+ """上一轮写入未完成时,跳过本轮写入"""
+
+ def test_sync_turn_truncates_long_content(self):
+ """>4000 字符时截断到 4000"""
+
+ def test_sync_turn_cron_skipped(self):
+ """_cron_skipped=True 时直接返回"""
+
+ def test_sync_turn_engine_none(self):
+ """_engine=None 时直接返回"""
+
+class TestHooks:
+ def test_on_memory_write_calls_store(self):
+ """内置 memory 写入镜像到 MSLM"""
+
+ def test_on_pre_compress_stores_last_10_messages(self):
+ """取最后 10 条消息拼接,存入 MSLM"""
+
+ def test_on_pre_compress_returns_empty_string(self):
+ """返回 '' 不干扰 compression summary"""
+
+ def test_on_pre_compress_skips_empty_or_non_text(self):
+ """跳过空内容、非 user/assistant 角色、非字符串 content"""
+
+ def test_on_session_end_calls_close_session(self):
+ """调用 engine.close_session(session_id)"""
+
+ def test_on_session_switch_updates_session_id(self):
+ """更新 _session_id,清空 _prefetch_cache"""
+```
+
+**实现内容**:
+
+1. `sync_turn(user_content, assistant_content, *, session_id="")`:
+ - 检查 `_cron_skipped` / `_engine is None` → 返回
+ - sanitize + 语义过滤(跳过 `"ok"`, `"yes"`, `"thanks"`, `"thx"`)
+ - 合并为 `User: ...\nHermes: ...` 格式
+ - >4000 字符截断
+ - `_sync_turn_lock` 保护 is_alive 检查和 thread 创建
+ - `_write_lock` 保护 `engine.store()`
+ - 上一轮未完成 → 跳过本轮(丢弃策略)
+ - daemon thread 后台写入
+
+2. `on_memory_write(action, target, content)`:
+ - 调用 `engine.store(content, scope="personal")`(受 `_write_lock`)
+
+3. `on_pre_compress(messages)`:
+ - 取最后 10 条 `user`/`assistant` 消息
+ - 拼接为 `[Pre-compression context]\nrole: content...`
+ - 每条 content 截断到 500 字符
+ - 后台 thread 存入 MSLM(受 `_write_lock`)
+ - 返回 `""`(不干扰 compression)
+
+4. `on_session_end(messages)`:
+ - 调用 `engine.close_session(self._session_id)`
+
+5. `on_session_switch(new_id, **kwargs)`:
+ - 更新 `self._session_id = new_id`
+ - 清空 `_prefetch_cache`
+
+**Commit 消息**:
+```
+feat(hermes): implement sync_turn and lifecycle hooks
+
+- Add sync_turn() with merged storage, semantic filtering, 4000-char truncation
+- Add _sync_turn_lock to prevent race between is_alive() and thread.start()
+- Add _write_lock shared across all write paths (sync_turn, on_memory_write, on_pre_compress)
+- Add on_memory_write() mirror to MSLM
+- Add on_pre_compress() storing last 10 messages, returning ''
+- Add on_session_end() calling engine.close_session()
+- Add on_session_switch() clearing prefetch cache
+```
+
+**验收点**:
+- [ ] `sync_turn()` 合并存储 `User: ...\nHermes: ...`
+- [ ] 语义过滤: `"ok"` / `"yes"` / `"thanks"` / `"thx"` → 跳过存储
+- [ ] `_sync_turn_lock` 防止竞态:两个 turn 快速连续时不会创建两个写线程
+- [ ] `_write_lock` 串行化所有 `engine.store()` 调用
+- [ ] 上一轮写入未完成时,本轮丢弃(不堆积队列)
+- [ ] >4000 字符截断到 4000
+- [ ] `on_pre_compress()` 取最后 10 条,每条截断 500 字符,返回 `""`
+- [ ] `on_session_end()` 调用 `engine.close_session()`
+- [ ] `on_session_switch()` 更新 session_id,清空缓存
+
+---
+
+### Chunk 5: 工具实现(Day 4-5)
+
+**目标**: 实现 `slm_recall`、`slm_remember`、`slm_status` 三个工具(v1 砍掉 `slm_report_feedback`)。
+
+**测试先行**:
+
+```python
+class TestToolSchemas:
+ def test_get_tool_schemas_returns_three_tools(self):
+ """返回 recall, remember, status 三个 schema"""
+
+ def test_recall_schema_has_required_query(self):
+ """slm_recall 的 query 为 required"""
+
+ def test_remember_schema_has_optional_scope(self):
+ """slm_remember 的 scope 默认 'personal'"""
+
+class TestToolRecall:
+ def test_recall_routes_to_engine_recall(self):
+ """调用 engine.recall(),返回格式化结果"""
+
+ def test_recall_empty_query_returns_error(self):
+ """query 为空返回 tool_error"""
+
+ def test_recall_engine_not_ready_returns_error(self):
+ """engine 未初始化返回 tool_error"""
+
+ def test_recall_limit_capped_at_20(self):
+ """limit > 20 时截断到 20"""
+
+ def test_recall_respects_include_global(self):
+ """调用 recall 时传入 include_global 配置"""
+
+class TestToolRemember:
+ def test_remember_calls_engine_store(self):
+ """调用 engine.store(),返回 stored 状态"""
+
+ def test_remember_default_scope_personal(self):
+ """默认 scope='personal'"""
+
+ def test_remember_global_scope(self):
+ """scope='global' 时传入 global"""
+
+ def test_remember_no_facts_returns_noop(self):
+ """engine.store() 返回空 fact_ids 时返回 noop"""
+
+ def test_remember_engine_not_ready_returns_error(self):
+ """engine 未初始化返回 tool_error"""
+
+class TestToolStatus:
+ def test_status_returns_profile_and_counts(self):
+ """返回 profile, mode, facts, entities, db_size 等"""
+
+ def test_status_engine_not_ready_returns_error(self):
+ """engine 未初始化返回 tool_error"""
+
+ def test_status_v1_returns_total_only(self):
+ """v1 只返回 facts.total,不细分 lifecycle"""
+
+class TestHandleToolCall:
+ def test_routes_recall_to_tool_recall(self):
+ """工具名 'slm_recall' 路由到 _tool_recall"""
+
+ def test_routes_remember_to_tool_remember(self):
+ """工具名 'slm_remember' 路由到 _tool_remember"""
+
+ def test_routes_status_to_tool_status(self):
+ """工具名 'slm_status' 路由到 _tool_status"""
+
+ def test_unknown_tool_returns_error(self):
+ """未知工具名返回 tool_error"""
+
+ def test_exception_in_tool_returns_error(self):
+ """工具内部异常被捕获,返回 tool_error,不中断主流程"""
+```
+
+**实现内容**:
+
+1. `get_tool_schemas()` → 返回 3 个工具的 JSON schema
+
+2. `handle_tool_call(tool_name, params)` → 路由到 `_tool_*`,异常捕获返回 `tool_error`
+
+3. `_tool_recall(params)`:
+ - 检查 `query` 非空
+ - 调用 `engine.recall(query, limit=min(limit, 20), fast=fast, include_global=self._include_global)`
+ - 格式化结果(含 `fact_id`, `content`, `score`, `confidence`, `channel_scores`)
+ - 返回 JSON 结构
+
+4. `_tool_remember(params)`:
+ - 检查 `content` 非空
+ - `scope` 参数校验(`"personal"` 或 `"global"`,默认 `"personal"`)
+ - 调用 `engine.store(content, scope=scope)`
+ - 返回 `stored` 或 `noop` 状态
+
+5. `_tool_status(params)`:
+ - 通过 `engine.db` 查询统计(v1 临时方案)
+ - 返回 `profile`, `mode`, `facts.total`, `entities`, `graph_edges`, `db_size_mb`, `embedding_model`, `embedding_dim`
+
+6. `system_prompt_block()` → 返回静态状态文本(含 profile、mode、fact_count、工具简介)
+
+**Commit 消息**:
+```
+feat(hermes): implement slm_recall, slm_remember, slm_status tools
+
+- Add get_tool_schemas() returning 3 tool definitions
+- Add handle_tool_call() with exception-safe routing
+- Add _tool_recall() with limit capping, include_global support
+- Add _tool_remember() with scope validation (personal/global)
+- Add _tool_status() querying engine.db for v1 stats
+- Add system_prompt_block() with dynamic profile/mode/fact_count
+- Remove slm_report_feedback from v1 (restored in v2 per spec)
+```
+
+**验收点**:
+- [ ] `slm_recall` 空 query → `tool_error("query is required")`
+- [ ] `slm_recall` limit > 20 → 截断到 20
+- [ ] `slm_recall` 返回结果包含 `fact_id`, `content`, `score`, `confidence`
+- [ ] `slm_remember` 默认 `scope="personal"`
+- [ ] `slm_remember` `scope="global"` 时传入 global
+- [ ] `slm_remember` 无新事实 → `{"status": "noop"}`
+- [ ] `slm_status` 返回 `profile`, `mode`, `facts.total`, `entities`, `db_size_mb`
+- [ ] `handle_tool_call` 异常捕获 → `tool_error`,不中断主流程
+- [ ] `system_prompt_block()` 包含动态 `profile_name`, `mode`, `fact_count`
+
+---
+
+### Chunk 6: 集成测试与 plugin.yaml(Day 5)
+
+**目标**: 端到端集成测试、plugin.yaml 编写、文档补全。
+
+**测试先行**:
+
+```python
+class TestIntegration:
+ def test_full_turn_lifecycle(self):
+ """完整 turn: initialize → prefetch → sync_turn → queue_prefetch → on_session_end"""
+
+ def test_concurrent_sync_turn_and_prefetch(self):
+ """sync_turn 写入和 prefetch 读取并发,验证 WAL 一写多读"""
+
+ def test_memory_write_mirror(self):
+ """内置 memory tool 写入后,on_memory_write 镜像到 MSLM"""
+
+ def test_provider_disabled_gracefully(self):
+ """engine 初始化失败后,所有方法静默返回不抛异常"""
+
+class TestPluginYAML:
+ def test_plugin_yaml_valid(self):
+ """plugin.yaml 可被解析,包含正确字段"""
+```
+
+**实现内容**:
+
+1. `plugin.yaml`:
+ - `name: superlocalmemory`
+ - `version: 1.0.0`
+ - `pip_dependencies: [mslm-memory>=4.0.0]`
+ - `hooks: [on_session_end, on_memory_write, on_pre_compress, on_session_switch]`
+
+2. `register(ctx)` → 插件入口,将 provider 注册到 Hermes
+
+3. 集成测试覆盖完整生命周期
+
+4. 文档:README 片段(安装、配置、使用示例)
+
+**Commit 消息**:
+```
+feat(hermes): add plugin.yaml, register() entry point, and integration tests
+
+- Add plugin.yaml with hooks and pip dependencies
+- Add register() function for Hermes plugin discovery
+- Add integration tests covering full session lifecycle
+- Add concurrent read/write safety verification
+- Add README with setup and usage examples
+```
+
+**验收点**:
+- [ ] `plugin.yaml` 可被 Hermes 加载解析
+- [ ] `register()` 正确注册 provider
+- [ ] 集成测试通过:完整 session 生命周期无异常
+- [ ] 并发测试通过:sync_turn 写入 + prefetch 读取不冲突
+- [ ] provider 禁用后:所有方法静默返回,不抛异常到上层
+
+---
+
+## 3. 验收标准
+
+### 3.1 功能验收
+
+| # | 验收项 | 验证方式 |
+|---|--------|---------|
+| 1 | `is_available()` 正确检测 `superlocalmemory` 可用性 | 单元测试 |
+| 2 | `initialize()` 完成 engine 初始化,30s 超时保护 | 单元测试 + 手动(慢路径) |
+| 3 | `_init_cancelled` 在超时后正确清理资源 | 单元测试(mock 超时) |
+| 4 | cron/flush 上下文跳过初始化,不加载模型 | 单元测试 |
+| 5 | `prefetch()` Turn 1 同步,Turn 2+ 消费缓存 | 单元测试 |
+| 6 | `queue_prefetch()` 后台线程不阻塞主流程 | 单元测试 |
+| 7 | `sync_turn()` 合并存储,语义过滤,4000 截断 | 单元测试 |
+| 8 | `_sync_turn_lock` 防止竞态创建多个写线程 | 单元测试(模拟快速连续 turn) |
+| 9 | `_write_lock` 串行化所有 engine.store() | 单元测试 + 线程 dump 验证 |
+| 10 | `on_pre_compress()` 取 10 条消息,返回 `""` | 单元测试 |
+| 11 | `on_session_end()` 调用 `close_session()` | 单元测试 |
+| 12 | `on_session_switch()` 清空缓存 | 单元测试 |
+| 13 | `slm_recall` 工具返回正确格式 | 单元测试 |
+| 14 | `slm_remember` 工具支持 personal/global scope | 单元测试 |
+| 15 | `slm_status` 工具返回统计信息 | 单元测试 |
+| 16 | `handle_tool_call` 异常捕获不中断主流程 | 单元测试(mock 抛异常) |
+| 17 | `system_prompt_block()` 包含动态状态 | 单元测试 |
+| 18 | `_parse_bool()` 类型安全处理所有 YAML 形式 | 单元测试 |
+| 19 | 配置加载优先级:Hermes config > MSLM config > 默认值 | 单元测试 |
+| 20 | plugin.yaml 可被 Hermes 识别加载 | 手动测试 |
+
+### 3.2 性能验收
+
+| # | 验收项 | 目标值 | 验证方式 |
+|---|--------|--------|---------|
+| 1 | `initialize()` 正常完成时间 | < 15s | 手动测试 |
+| 2 | `initialize()` 超时后清理 | < 5s 额外等待 | 单元测试 |
+| 3 | `prefetch()` 同步 recall 超时 | < 8s | 单元测试(mock) |
+| 4 | `sync_turn()` 后台写入不阻塞 | < 1ms 主线程 | 单元测试 |
+| 5 | `queue_prefetch()` 后台 recall | 不阻塞主线程 | 单元测试 |
+| 6 | 内存占用(engine 初始化后) | ~2GB(sentence-transformers) | 手动测试 |
+
+### 3.3 安全与健壮性验收
+
+| # | 验收项 | 验证方式 |
+|---|--------|---------|
+| 1 | engine 初始化失败后所有方法静默返回 | 单元测试 |
+| 2 | 工具调用异常被捕获,返回 `tool_error` | 单元测试 |
+| 3 | 超长内容截断,不触发 MSLM 提取质量下降 | 单元测试 |
+| 4 | 空 query / 空 content 被优雅处理 | 单元测试 |
+| 5 | 线程锁无死锁(所有锁获取带超时或短持有) | 代码审查 + 单元测试 |
+| 6 | daemon thread 不泄漏(正常完成/超时/异常) | 单元测试 + 线程 dump |
+
+---
+
+## 4. 风险与回退
+
+### 4.1 已知风险
+
+| 风险 | 概率 | 影响 | 缓解措施 |
+|------|------|------|---------|
+| MSLM `engine.recall()` 内部有写操作(非纯读) | 中 | 与 `_write_lock` 冲突或 WAL 锁 | Chunk 3 测试中加入 `recall` 并发调用验证;如确认有写,将 `recall` 也纳入 `_write_lock` 或文档明确 |
+| `engine.db` 直接访问 schema 变更(v1 临时方案) | 高 | `slm_status` 工具失效 | v2 优先迁移到 `engine.get_status()`;v1 中 schema 变更时同步修复 |
+| sentence-transformers 模型加载 > 30s(慢磁盘) | 低 | 初始化超时,provider 禁用 | 增加 timeout 到 60s 或支持异步后台加载后通知 |
+| Hermes MemoryProvider ABC 接口变更 | 低 | 编译/运行失败 | 锁定 Hermes 版本,CI 中集成测试 |
+| SQLite WAL 模式下并发写冲突 | 低 | `database is locked` | `_write_lock` 已串行化所有写;如仍冲突,考虑连接级排他锁 |
+
+### 4.2 回退策略
+
+- **v1 最小可用**: 如果 Chunk 5 工具实现受阻,可先交付 Chunk 1-4(生命周期完整,工具 stub 返回错误),保证 Hermes 启动不崩溃
+- **provider 禁用**: 任何初始化失败 → `_engine = None` → 所有方法静默返回,Hermes 降级到 Builtin MEMORY.md 模式
+- **工具降级**: `slm_status` 如 `engine.db` 不可用,返回 `tool_error("status unavailable in v1")`
+
+---
+
+## 5. 附录:Commit 语义规范
+
+每个 Chunk 对应一个独立 commit,消息格式:
+
+```
+(hermes):
+
+-
+-
+```
+
+| Chunk | type | 示例 |
+|-------|------|------|
+| 1 | `feat` | `feat(hermes): add SuperLocalMemoryProvider skeleton + config parsing` |
+| 2 | `feat` | `feat(hermes): implement initialize() with timeout and cancellation` |
+| 3 | `feat` | `feat(hermes): implement prefetch hybrid mode` |
+| 4 | `feat` | `feat(hermes): implement sync_turn and lifecycle hooks` |
+| 5 | `feat` | `feat(hermes): implement slm_recall, slm_remember, slm_status tools` |
+| 6 | `feat` | `feat(hermes): add plugin.yaml, register() entry point, and integration tests` |
+| 修复 | `fix` | `fix(hermes): handle race condition in sync_turn thread creation` |
+| 测试 | `test` | `test(hermes): add concurrent read/write safety verification` |
+| 文档 | `docs` | `docs(hermes): add README with setup and usage examples` |
+
+---
+
+*计划完成。主人请审阅,如有调整知惠随时修改。*
diff --git a/docs/superpowers/reviews/2026-06-22-codex-code-review.md b/docs/superpowers/reviews/2026-06-22-codex-code-review.md
new file mode 100644
index 00000000..b6a9aca7
--- /dev/null
+++ b/docs/superpowers/reviews/2026-06-22-codex-code-review.md
@@ -0,0 +1,43 @@
+# Codex 代码审查:MSLM Hermes MemoryProvider
+
+> 审查日期:2026-06-22 | 审查工具:Codex (GLM 5.2) | 审查范围:3491a6f..988c5b4 (6 commits)
+> SPEC:docs/superpowers/specs/2026-06-02-mslm-hermes-memory-provider.md
+
+---
+
+## 🔴 必须修(2 条)
+
+| # | 问题 | 位置 |
+|---|------|------|
+| M1 | `slm_remember` 绕过 `_write_lock`,违反 SPEC §7.4 串行化要求 | `__init__.py:768` |
+| M2 | `_init_cancelled` 不复位,重新初始化静默失效 | `__init__.py:290/307` |
+
+---
+
+## 🟡 建议修(7 条)
+
+| # | 维度 | 问题 |
+|---|------|------|
+| S1 | 线程安全 | `recall()` 实际含写操作,SPEC 假定"纯读"不成立 |
+| S2 | 错误处理 | `sync_turn` 的 `sanitize_context` 未 try 保护 |
+| S3 | 资源 | `on_memory_write`/`on_pre_compress` 线程可能堆积 |
+| S4 | 性能 | `system_prompt_block` 每 turn 同步 COUNT 全表扫 |
+| S5 | 错误处理 | Mode 导入 `ImportError` 未捕获 |
+| S6 | ABC 合规 | `save_config()` 未实现,setup 写不进配置 |
+| S7 | 测试 | 并发/重初始化/锁串行化场景未覆盖 |
+
+---
+
+## 🟢 可选(10 条)
+
+死字段、未用参数、flaky sleep 测试、裸 SQL 静默等——详见完整报告。
+
+---
+
+## 优先级
+
+M1 + M2 都是**一行级修复**,修完即可消除静默数据风险。S5/S2/S6 其次。S3/S4 视真实负载评估。
+
+---
+
+*Codex (GLM 5.2) 审查完成 · 沙箱只读无法落盘,知惠代存*
diff --git a/docs/superpowers/specs/2026-06-02-mslm-hermes-memory-provider.md b/docs/superpowers/specs/2026-06-02-mslm-hermes-memory-provider.md
index b2514aee..23a859f7 100644
--- a/docs/superpowers/specs/2026-06-02-mslm-hermes-memory-provider.md
+++ b/docs/superpowers/specs/2026-06-02-mslm-hermes-memory-provider.md
@@ -147,6 +147,10 @@ Hermes profile "default" ──auto──► MSLM active_profile = "default"
| `on_memory_write` 镜像 | `personal` | 内置 memory tool 写入私有 |
| `prefetch` / 工具 recall | `personal` + `include_global=True` + `include_shared=False` | 检索自己 + 全局共享 |
+> **speaker 字段说明**:`sync_turn` 中 `speaker="user"` 表示该事实的时序归属为 user turn,
+> 但内容中同时包含 assistant 的回复(`User: ...\nHermes: ...` 格式)。
+> v2 考虑拆成两次 `store()`(user 和 assistant 分别存储),或 MSLM 支持 `speaker="both"` 模式(审阅意见 #10)。
+
### 3.4 多 Profile 场景示意
```
@@ -205,6 +209,19 @@ Session 开始
│
├─ ③ system_prompt_block()
│ → 返回静态说明文本(Status 行 + 4 个工具简介)
+│ 具体文本:
+│ ```
+│ [SuperLocalMemory Status]
+│ Profile: {profile_name} | Mode: {mode} | Facts: {fact_count}
+│
+│ Available tools:
+│ - slm_recall(query, limit=10, fast=false): 语义搜索本地记忆库。7通道检索 + RRF融合排序。
+│ - slm_remember(content, scope="personal"): 显式存储信息到本地记忆库。scope 可选 "personal"(仅当前profile) 或 "global"(跨profile共享)。
+│ - slm_status(): 查看记忆库统计信息(事实数、实体数、数据库大小等)。
+│ - slm_report_feedback(fact_id, helpful): 反馈某条记忆是否有用,帮助系统调整检索权重。
+│
+│ Note: scope="personal" 的记忆仅对当前 profile 可见;scope="global" 的记忆可被所有 profile 检索。
+│ ```
│
└─ 每个 Turn ─────────────────────────────────────────
│
@@ -218,8 +235,8 @@ Session 开始
├─ [模型调用,可能触发工具调用]
│ ├─ slm_recall → engine.recall()
│ ├─ slm_remember → engine.store()
- │ ├─ slm_status → 直接查数据库
- │ └─ slm_report_feedback → CLI 桥接或 trust 表操作
+ │ ├─ slm_status → 直接查数据库(v2 改用 engine.get_status())
+ │ └─ [v2] slm_report_feedback → engine.report_feedback()
│
├─ ⑥ sync_turn(user_msg, asst_msg) ← 合并存储
│ 后台线程: engine.store(combined, speaker="user", scope="personal")
@@ -281,6 +298,8 @@ def on_pre_compress(self, messages: List[Dict[str, Any]]) -> str:
t = threading.Thread(target=_flush, daemon=True, name="mslm-compress")
t.start()
return "" # 不干扰 compression summary prompt(返回空字符串)
+ # 注意:MemoryProvider ABC 的 on_pre_compress 签名是 -> str,但返回值在 Hermes 中
+ # 被忽略(不注入 prompt),仅用于 side effect(将摘要存入 MSLM)。此行为已在文档中明确。
```
---
@@ -349,6 +368,8 @@ def on_pre_compress(self, messages: List[Dict[str, Any]]) -> str:
### 5.3 `slm_status`
> **实现说明**:通过 `engine.db`(DatabaseManager 公开属性)查询各类统计。
+> **注意**:v1 直接访问 `engine.db` 是临时方案,v2 应改用 `engine.get_status()` 封装 API(审阅意见 #9)。
+> MSLM 侧需提供 `get_status()` 方法,避免 provider 直接依赖内部数据库 schema。
```
名称: slm_status
@@ -393,7 +414,15 @@ def on_pre_compress(self, messages: List[Dict[str, Any]]) -> str:
> **注意**:v1 中 `slm_recall` 返回 `fact_id`,模型可据此调用 `slm_report_feedback`。
> MSLM engine 目前不直接暴露 `report_feedback` 的 Python API——
-> 需要通过 `engine.db` 直接操作 trust 表,或调用 MCP 工具路径。v1 可先实现为调用 `slm mcp report_feedback` CLI。
+> 需要通过 `engine.db` 直接操作 trust 表,或调用 MCP 工具路径。
+>
+> **审阅意见 #4**:v1 砍掉 `slm_report_feedback`,只保留 3 个核心工具(recall/remember/status)。
+> 原因:
+> - 规格第 85 行明确说"不做 REST API 方案",CLI 桥接与之矛盾
+> - CLI 调用有进程开销和序列化成本,`subprocess` 在超时/错误处理上脆弱
+> - MSLM 侧需先暴露 Python API,provider 才能可靠实现
+>
+> **决策**:v1 移除 `slm_report_feedback`,v2 待 MSLM 暴露 `engine.report_feedback()` 后恢复。
---
@@ -466,6 +495,9 @@ pip_dependencies:
- mslm-memory>=4.0.0
hooks:
- on_session_end
+ - on_memory_write
+ - on_pre_compress
+ - on_session_switch
```
---
@@ -487,6 +519,16 @@ def _load_hermes_config(self, hermes_home: str) -> Dict[str, str]:
return mem_config.get("superlocalmemory", {}) if isinstance(mem_config, dict) else {}
except Exception:
return {}
+
+def _parse_bool(value: Any, default: bool) -> bool:
+ """Parse a boolean value from YAML config, handling string forms like 'false'/'true'."""
+ if value is None:
+ return default
+ if isinstance(value, bool):
+ return value
+ if isinstance(value, str):
+ return value.lower() in ("true", "1", "yes", "on")
+ return bool(value)
```
**初始化完整流程**:
@@ -517,9 +559,9 @@ def initialize(self, session_id: str, **kwargs) -> None:
except KeyError:
logger.warning("MSLM unknown mode '%s' — using config default", mode_override)
- # 3. 读取召回配置
- self._include_global = config_override.get("include_global", True)
- self._include_shared = config_override.get("include_shared", False)
+ # 3. 读取召回配置(显式处理 YAML 字符串布尔值,避免 "false" 被当 truthy)
+ self._include_global = _parse_bool(config_override.get("include_global"), True)
+ self._include_shared = _parse_bool(config_override.get("include_shared"), False)
# 4. cron 守卫
agent_context = kwargs.get("agent_context", "primary")
@@ -540,6 +582,8 @@ def initialize(self, session_id: str, **kwargs) -> None:
def _do_init():
nonlocal init_error
try:
+ if getattr(self, '_init_cancelled', False):
+ return
self._engine.initialize()
except Exception as e:
init_error = e
@@ -549,6 +593,12 @@ def initialize(self, session_id: str, **kwargs) -> None:
init_thread.join(timeout=30.0) # 30s timeout
if init_thread.is_alive():
logger.warning("MSLM engine init timed out after 30s — provider disabled")
+ # 超时后显式清理:设置取消标志,等待线程结束,释放模型加载占用的内存
+ # 避免 daemon 线程在后台继续运行导致内存泄漏(审阅意见 #3)
+ self._init_cancelled = True
+ init_thread.join(timeout=5.0) # 再给 5s 优雅退出
+ if init_thread.is_alive():
+ logger.warning("MSLM init thread did not terminate gracefully — may retain model RAM")
self._engine = None
return
if init_error:
@@ -599,6 +649,11 @@ Timeline:
Turn 1: [prefetch同步获取] [模型调用...] [sync_turn] [queue_prefetch启动后台]
Turn 2: [prefetch消费缓存] [模型调用...] [sync_turn] [queue_prefetch启动后台]
Turn 3: [prefetch消费缓存] [模型调用...]
+
+> **queue_prefetch 并发安全说明**:
+> `queue_prefetch` 启动的后台线程调用 `engine.recall()`,虽然读操作在 WAL 模式下可以并发,
+> 但如果 `recall` 内部有写(如更新访问时间戳、缓存统计),会和 `_write_lock` 保护的写冲突。
+> 需确认 MSLM `engine.recall()` 是否为纯读操作;如有写,需要纳入锁管理或明确文档(审阅意见 #2)。
```
### 7.3 sync_turn 合并存储(决策 2: A)
@@ -613,8 +668,9 @@ def sync_turn(self, user_content: str, assistant_content: str,
clean_asst = sanitize_context(assistant_content or "").strip()
# 太短的回合跳过(如 "ok", "yes", "thanks")
- # 3 字符阈值足以过滤纯确认消息,同时保留 "fix auth" 等简短指令
- if len(clean_user) <= 3:
+ # 改为语义过滤:非空且非纯标点/空白,保留 "no"、"fix" 等简短但有意义的内容
+ # 审阅意见 #5:3 字符阈值会漏掉很多有意义的信息
+ if not clean_user or clean_user.strip() in {"", "ok", "yes", "thanks", "thx"}:
return
combined = f"User: {clean_user}\nHermes: {clean_asst}"
@@ -632,13 +688,16 @@ def sync_turn(self, user_content: str, assistant_content: str,
logger.debug("MSLM sync_turn failed: %s", e)
# 丢弃上一轮尚未完成的写入(daemon thread 保护,不阻塞主线程)
- if self._sync_thread and self._sync_thread.is_alive():
- logger.debug("MSLM sync_turn: prior write still in progress, dropping")
- return
- self._sync_thread = threading.Thread(
- target=_sync, daemon=True, name="mslm-sync"
- )
- self._sync_thread.start()
+ # 使用 _sync_turn_lock 保护 is_alive() 检查和 thread.start() 之间的竞态条件,
+ # 防止两个 turn 连续快速触发时同时创建两个写线程。
+ with self._sync_turn_lock:
+ if self._sync_thread and self._sync_thread.is_alive():
+ logger.debug("MSLM sync_turn: prior write still in progress, dropping")
+ return
+ self._sync_thread = threading.Thread(
+ target=_sync, daemon=True, name="mslm-sync"
+ )
+ self._sync_thread.start()
```
> **写并发策略变更**:原来的 `join(timeout=5.0)` 方案在上一个写入超过 5s 时会导致两个
@@ -646,6 +705,8 @@ def sync_turn(self, user_content: str, assistant_content: str,
> - 提供一个 `threading.Lock`(`self._write_lock`)保护所有 `engine.store()` 调用
> - 如果上一轮写入尚未完成,跳过本轮的 `sync_turn`(而非堆积写入队列)
> - `on_memory_write`、`on_pre_compress` 也共用同一把锁
+> - 新增 `self._sync_turn_lock` 保护 `is_alive()` 检查和 `thread.start()` 之间的竞态窗口,
+> 防止两个 turn 连续快速触发时同时创建两个写线程(审阅意见 #1)
### 7.4 线程安全
@@ -658,6 +719,7 @@ Provider 内部状态:
```python
self._engine: Optional[MemoryEngine] = None # 主引擎实例
self._write_lock = threading.Lock() # 保护所有 engine.store() 调用
+self._sync_turn_lock = threading.Lock() # 保护 sync_turn 的 is_alive() 检查和 thread 创建
self._sync_thread: Optional[threading.Thread] # sync_turn 后台线程
self._prefetch_thread: Optional[threading.Thread]
self._prefetch_lock: threading.Lock # 保护 prefetch 缓存读写
@@ -673,7 +735,7 @@ self._prefetch_fired_at: int = -999 # 缓存对应的 turn 号
| 场景 | 策略 |
|------|------|
| `SLMConfig.load()` 失败 | `initialize()` 静默 return,provider 不激活 |
-| `engine.initialize()` 超时 (>30s) | 记录 warning,`self._engine = None`,provider 不激活 |
+| `engine.initialize()` 超时 (>30s) | 记录 warning,设置取消标志,等待线程结束,`self._engine = None`,provider 不激活 |
| `engine.initialize()` 抛异常 | 记录 warning,`self._engine = None`,provider 不激活 |
| `engine.recall()` 失败 | 返回空字符串,记录 debug 日志 |
| `engine.store()` 失败 | 记录 debug 日志,不影响主流程 |
@@ -681,6 +743,7 @@ self._prefetch_fired_at: int = -999 # 缓存对应的 turn 号
| 超长内容 (>4000 chars) | sync_turn 中截断到 4000 字符:MSLM fact_extractor 处理超长文本时提取质量下降(噪声事实增加),4000 字符覆盖 >95% 的正常对话轮次 |
| 空查询 | prefetch 直接返回 "" |
| `create_speaker_entities()` 失败 | 非致命错误,记录 debug 日志后继续 |
+| **工具调用异常** | `handle_tool_call` 捕获异常,返回 `tool_error` 结构,不中断主流程(审阅意见 #12) |
---
@@ -748,11 +811,15 @@ superlocalmemory/
## 10. 待实现
-- [ ] v1: 核心 MemoryProvider(4 工具 + 完整生命周期)
+- [ ] v1: 核心 MemoryProvider(3 工具 + 完整生命周期)
- [ ] v1: `hermes memory setup` 集成
+- [ ] v1: 确认 `engine.recall()` 是否为纯读(审阅意见 #2)
+- [ ] v2: `slm_report_feedback` 恢复(待 MSLM 暴露 Python API)
- [ ] v2: `shared` scope 支持(多 agent mesh)
- [ ] v2: `on_delegation` 钩子(子 agent 观察)
- [ ] v2: MSLM 侧 `_create_entity()` 接受 scope 参数(speaker entities 改为 personal)
+- [ ] v2: MSLM 侧 `engine.get_status()` 封装 API(审阅意见 #9)
+- [ ] v2: `speaker="both"` 支持或拆分成两次 `store()`(审阅意见 #10)
- [ ] v3: 连接方式支持 REST API fallback
---
@@ -806,3 +873,4 @@ engine.store(content, session_id, speaker, scope)
| 2026-06-01 | `slm_report_feedback` 加入 v1 工具(参考 Holographic fact_feedback) |
| 2026-06-01 | 标注 `create_speaker_entities` scope 问题(硬编码 global)和 ByteRover 10 条消息先例 |
| 2026-06-02 | Provider 随 MSLM 发布(`src/superlocalmemory/integrations/hermes/`),文件清单更新 |
+| 2026-06-22 | 审阅意见修复(12条):线程安全(`_sync_turn_lock`)、`queue_prefetch` 并发说明、init 超时清理(`_init_cancelled`)、`slm_report_feedback` 移至 v2、语义过滤替代字符阈值、`on_pre_compress` 返回值说明、`_parse_bool` 类型安全、`system_prompt_block()` 文本补充、`engine.db` 封装说明、`speaker` 字段说明、plugin.yaml 钩子补全、工具调用异常处理 |
diff --git a/docs/superpowers/specs/2026-06-22-mslm-provider-spec-review.md b/docs/superpowers/specs/2026-06-22-mslm-provider-spec-review.md
new file mode 100644
index 00000000..a44133c3
--- /dev/null
+++ b/docs/superpowers/specs/2026-06-22-mslm-provider-spec-review.md
@@ -0,0 +1,89 @@
+# MSLM MemoryProvider 设计规格审阅
+
+> 审阅日期:2026-06-22
+> 审阅者:CC (delegate_task)
+> 规格版本:v1 (2026-06-02)
+
+---
+
+## 🔴 必须修(4 条)
+
+### 1. 线程安全:`_write_lock` 只锁了 `store()` 的调用体,没锁线程创建本身
+
+- 第 636-641 行:`sync_turn` 检查 `self._sync_thread.is_alive()` 后创建新线程,但检查和创建之间没有锁
+- 如果两个 turn 连续快速触发,可能都判断 `is_alive()=False`,然后同时创建两个写线程,`_write_lock` 只能在 `store()` 内部串行,但两个线程仍然并发
+- **建议**:把 `is_alive()` 检查和 `thread.start()` 也用锁保护,或改用 `threading.Lock` 做 turn 级互斥
+
+### 2. `queue_prefetch` 后台线程可能和 `sync_turn` 并发读写同一 SQLite
+
+- 第 591-593 行:`queue_prefetch` 启动后台线程做 `engine.recall()`,虽然读不冲突,但如果 `recall` 内部有写(比如更新访问时间戳、缓存统计),就会和 `_write_lock` 保护的写冲突
+- MSLM 的 `recall` 是否纯读?规格说"读操作可以并发",但没说 `recall` 是否触发写
+- **建议**:确认 `engine.recall()` 是否纯读;如果有写,需要纳入锁管理或明确文档
+
+### 3. `initialize()` 的 `init_thread.join(timeout=30)` 后 `is_alive()` 判断,但超时时未清理线程
+
+- 第 550-552 行:超时后设 `self._engine = None`,但 `init_thread` 仍在后台运行(daemon=True),可能继续加载模型占用 2GB RAM
+- 更糟的是,如果线程后续成功初始化,引擎对象被孤立,但模型已加载——内存泄漏
+- **建议**:超时后显式 `join()` 或设置取消标志;或改用 `concurrent.futures` 的 cancel 机制
+
+### 4. `slm_report_feedback` v1 实现路径未确定
+
+- 第 396 行:"v1 可先实现为调用 `slm mcp report_feedback` CLI"
+- 但规格第 85 行明确说"不做 REST API 方案",现在又要走 CLI 桥接,这是矛盾的
+- CLI 调用有进程开销和序列化成本,且 `subprocess` 在超时/错误处理上很脆弱
+- **建议**:要么 v1 砍掉 `slm_report_feedback`(只留 3 个工具),要么 MSLM 侧必须暴露 Python API
+
+---
+
+## 🟡 建议修(5 条)
+
+### 5. `sync_turn` 的 `clean_user <= 3` 过滤太粗暴
+
+- 第 617 行:3 字符阈值会漏掉很多有意义的信息,比如 `"no"`(否定回答)、`"fix"`(简短指令)
+- ByteRover 的实现是 10 条消息摘要,这里是按字符过滤,策略不一致
+- **建议**:改为语义过滤(如非空且非纯标点),或至少放宽到 10 字符
+
+### 6. `on_pre_compress` 返回空字符串但注释说"不干扰 compression summary prompt"
+
+- 第 283 行:返回 `""` 确实不干扰,但 `MemoryProvider` ABC 的 `on_pre_compress` 签名是 `-> str`,返回值会被怎么处理?
+- 如果 Hermes 期望返回一个 summary 字符串注入 prompt,返回空意味着这个功能完全静默
+- **建议**:确认 ABC 契约,如果返回值被忽略,应在文档中明确说明
+
+### 7. `config_override.get("include_global", True)` 类型不安全
+
+- 第 521 行:YAML 解析的布尔值可能是字符串 `"false"`,直接 `get(..., True)` 会把字符串 `"false"` 当 truthy
+- 同样问题在 `include_shared`
+- **建议**:显式做 `str(v).lower() in ("true", "1", "yes")` 转换
+
+### 8. `system_prompt_block()` 内容未定义
+
+- 第 207 行:只写了"返回静态说明文本(Status 行 + 4 个工具简介)",但没有给出具体文本
+- 这会影响模型对工具的理解和使用频率
+- **建议**:补充 prompt 文本,特别是 `scope` 参数的使用说明
+
+### 9. `engine.db` 直接暴露给 provider 查 status,破坏封装
+
+- 第 351 行:"通过 `engine.db`(DatabaseManager 公开属性)查询各类统计"
+- 如果 MSLM 内部重构数据库 schema,provider 会崩溃
+- **建议**:MSLM 侧提供 `engine.get_status()` 封装 API
+
+---
+
+## 🟢 可选(3 条)
+
+### 10. `speaker="user"` 在 `sync_turn` 中把 assistant 内容也标记为 user
+
+- 第 620 行:`combined = f"User: {clean_user}\nHermes: {clean_asst}"`,但 `speaker="user"`
+- 虽然内容里有角色标签,但 MSLM 的 speaker 字段用于实体归属和时序分析
+- **建议**:v2 考虑拆成两次 `store()`,或 MSLM 支持 `speaker="both"` 模式
+
+### 11. `plugin.yaml` 的 `hooks: - on_session_end` 不完整
+
+- 第 468 行:只声明了一个钩子,但实现里有 `on_memory_write`、`on_pre_compress`、`on_session_switch`
+- 如果 Hermes 的 plugin 系统按声明加载钩子,未声明的可能不会触发
+- **建议**:补充完整钩子声明
+
+### 12. 缺少 `on_error` / `handle_tool_call` 错误处理细节
+
+- 第 4.1 节生命周期图里有 `[模型调用,可能触发工具调用]`,但 `handle_tool_call` 的错误处理没有详细说明
+- **建议**:补充工具调用异常时的降级行为
diff --git a/src/superlocalmemory/integrations/hermes/__init__.py b/src/superlocalmemory/integrations/hermes/__init__.py
index 171326f1..536cbbf4 100644
--- a/src/superlocalmemory/integrations/hermes/__init__.py
+++ b/src/superlocalmemory/integrations/hermes/__init__.py
@@ -284,6 +284,8 @@ def initialize(self, session_id: str, **kwargs) -> None:
init_error: Optional[Exception] = None
+ self._init_cancelled = False
+
def _do_init() -> None:
nonlocal init_error
try:
@@ -766,9 +768,10 @@ def _tool_remember(self, params: Dict[str, Any]) -> str:
scope = "personal"
try:
- fact_ids = self._engine.store(
- content, session_id=self._session_id, scope=scope,
- )
+ with self._write_lock:
+ fact_ids = self._engine.store(
+ content, session_id=self._session_id, scope=scope,
+ )
except Exception as exc:
logger.debug("MSLM store failed: %s", exc)
return tool_error(f"Store failed: {exc}")
From f5f8c3f657cc6927207d03800bacf8f5e1708092 Mon Sep 17 00:00:00 2001
From: kenyonxu
Date: Mon, 22 Jun 2026 10:44:50 +0800
Subject: [PATCH 08/13] =?UTF-8?q?fix(hermes):=20=E4=BF=AE=E5=A4=8D=20regis?=
=?UTF-8?q?ter()=20=E5=85=BC=E5=AE=B9=20Hermes=20=5FProviderCollector?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- register() 支持 register_memory_provider() 和 register_provider() 两种 ctx
- 测试中 mock _load_hermes_config 防止读取真实配置
---
.../integrations/hermes/__init__.py | 25 ++++++++++++++-----
.../hermes/tests/test_provider.py | 6 +++--
2 files changed, 23 insertions(+), 8 deletions(-)
diff --git a/src/superlocalmemory/integrations/hermes/__init__.py b/src/superlocalmemory/integrations/hermes/__init__.py
index 536cbbf4..531021b0 100644
--- a/src/superlocalmemory/integrations/hermes/__init__.py
+++ b/src/superlocalmemory/integrations/hermes/__init__.py
@@ -873,10 +873,23 @@ def _tool_status(self, params: Dict[str, Any]) -> str:
# -- Plugin registration -------------------------------------------------
-def register(ctx) -> None:
- """Hermes plugin entry point — registers the provider."""
- from hermes_cli.plugin_context import PluginContext
+def register(ctx: Any) -> None:
+ """Hermes plugin entry point — registers the provider.
- if not isinstance(ctx, PluginContext):
- raise TypeError("register() requires a PluginContext")
- ctx.register_provider(SuperLocalMemoryProvider)
+ Works with both the real ``PluginContext`` (``register_provider``)
+ and the discovery-time ``_ProviderCollector``
+ (``register_memory_provider``).
+ """
+ provider = SuperLocalMemoryProvider()
+
+ # Hermes discovery path: _ProviderCollector.register_memory_provider()
+ if hasattr(ctx, "register_memory_provider"):
+ ctx.register_memory_provider(provider)
+ return
+
+ # Hermes runtime path: real PluginContext.register_provider()
+ if hasattr(ctx, "register_provider"):
+ ctx.register_provider(provider)
+ return
+
+ logger.warning("register() called with unknown context type: %s", type(ctx))
diff --git a/src/superlocalmemory/integrations/hermes/tests/test_provider.py b/src/superlocalmemory/integrations/hermes/tests/test_provider.py
index 095414df..b4a4d123 100644
--- a/src/superlocalmemory/integrations/hermes/tests/test_provider.py
+++ b/src/superlocalmemory/integrations/hermes/tests/test_provider.py
@@ -120,7 +120,8 @@ class TestInitialize:
def test_initialize_loads_config_and_sets_profile(self, provider):
"""从 kwargs['agent_identity'] 映射 profile."""
- with patch("superlocalmemory.core.config.SLMConfig") as MockConfig, \
+ with patch.object(provider, "_load_hermes_config", return_value={}), \
+ patch("superlocalmemory.core.config.SLMConfig") as MockConfig, \
patch("superlocalmemory.core.engine.MemoryEngine") as MockEngine:
mock_config = MockConfig.load.return_value
mock_engine = MockEngine.return_value
@@ -771,7 +772,8 @@ def test_exception_in_tool_returns_error(self, provider):
def test_system_prompt_block_contains_status(self, provider):
"""system_prompt_block() 包含动态 profile/mode."""
- with patch("superlocalmemory.core.config.SLMConfig") as MockConfig, \
+ with patch.object(provider, "_load_hermes_config", return_value={}), \
+ patch("superlocalmemory.core.config.SLMConfig") as MockConfig, \
patch("superlocalmemory.core.engine.MemoryEngine") as MockEngine:
MockConfig.load.return_value = MagicMock()
mock_engine = MockEngine.return_value
From 38fe86eb95a535aec49186fd5edfc0eb7b7a2e3c Mon Sep 17 00:00:00 2001
From: kenyonxu
Date: Mon, 22 Jun 2026 11:27:46 +0800
Subject: [PATCH 09/13] =?UTF-8?q?fix(hermes):=20=5Ftool=5Fstatus=20?=
=?UTF-8?q?=E5=92=8C=20system=5Fprompt=5Fblock=20=E4=BD=BF=E7=94=A8?=
=?UTF-8?q?=E6=AD=A3=E7=A1=AE=E7=9A=84=20profile?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
两处 SQL 查询使用 self._slm_config.active_profile(会被
MemoryEngine.initialize() 覆盖为 "default"),导致 DB 有数据但
返回 facts=0。
改为 self._mslm_profile(由 hermes config 明确设置,值正确)。
Closes #5
---
src/superlocalmemory/integrations/hermes/__init__.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/superlocalmemory/integrations/hermes/__init__.py b/src/superlocalmemory/integrations/hermes/__init__.py
index 531021b0..96ef7a7a 100644
--- a/src/superlocalmemory/integrations/hermes/__init__.py
+++ b/src/superlocalmemory/integrations/hermes/__init__.py
@@ -606,7 +606,7 @@ def system_prompt_block(self) -> str:
cursor = self._engine.db.execute(
"SELECT COUNT(*) FROM atomic_facts "
"WHERE profile_id = ?",
- (self._slm_config.active_profile,),
+ (self._mslm_profile,),
)
row = cursor.fetchone()
fact_count = row[0] if row else 0
@@ -810,7 +810,7 @@ def _tool_status(self, params: Dict[str, Any]) -> str:
try:
cur = db.execute(
"SELECT COUNT(*) FROM atomic_facts WHERE profile_id = ?",
- (self._slm_config.active_profile,),
+ (self._mslm_profile,),
)
row = cur.fetchone()
facts_total = row[0] if row else 0
From 8abcc2a1a27bf939aa9c9548560e5c6517618a88 Mon Sep 17 00:00:00 2001
From: kenyonxu
Date: Mon, 22 Jun 2026 11:35:41 +0800
Subject: [PATCH 10/13] =?UTF-8?q?fix(hermes):=20=5Ftool=5Fstatus/system=5F?=
=?UTF-8?q?prompt=5Fblock=20=E4=BD=BF=E7=94=A8=20list=20=E7=B4=A2=E5=BC=95?=
=?UTF-8?q?=E4=BB=A3=E6=9B=BF=20fetchone()?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
db.execute() 返回的是 list[Row] 而非 sqlite3 cursor,调用 .fetchone()
会抛出 AttributeError,被 except Exception: pass 静默吞掉,导致所有
指标返回 0。
改为直接使用 rows[0][0] 索引。
Closes #6
---
.../integrations/hermes/__init__.py | 20 ++++++++-----------
1 file changed, 8 insertions(+), 12 deletions(-)
diff --git a/src/superlocalmemory/integrations/hermes/__init__.py b/src/superlocalmemory/integrations/hermes/__init__.py
index 96ef7a7a..bd2d7d3e 100644
--- a/src/superlocalmemory/integrations/hermes/__init__.py
+++ b/src/superlocalmemory/integrations/hermes/__init__.py
@@ -603,13 +603,12 @@ def system_prompt_block(self) -> str:
if not self._ensure_engine():
return ""
try:
- cursor = self._engine.db.execute(
+ rows = self._engine.db.execute(
"SELECT COUNT(*) FROM atomic_facts "
"WHERE profile_id = ?",
(self._mslm_profile,),
)
- row = cursor.fetchone()
- fact_count = row[0] if row else 0
+ fact_count = rows[0][0] if rows else 0
except Exception:
fact_count = 0
@@ -808,26 +807,23 @@ def _tool_status(self, params: Dict[str, Any]) -> str:
embedding_dim = 0
try:
- cur = db.execute(
+ rows = db.execute(
"SELECT COUNT(*) FROM atomic_facts WHERE profile_id = ?",
(self._mslm_profile,),
)
- row = cur.fetchone()
- facts_total = row[0] if row else 0
+ facts_total = rows[0][0] if rows else 0
except Exception:
pass
try:
- cur = db.execute("SELECT COUNT(*) FROM kg_nodes")
- row = cur.fetchone()
- entities = row[0] if row else 0
+ rows = db.execute("SELECT COUNT(*) FROM kg_nodes")
+ entities = rows[0][0] if rows else 0
except Exception:
pass
try:
- cur = db.execute("SELECT COUNT(*) FROM memory_edges")
- row = cur.fetchone()
- graph_edges = row[0] if row else 0
+ rows = db.execute("SELECT COUNT(*) FROM memory_edges")
+ graph_edges = rows[0][0] if rows else 0
except Exception:
pass
From 6937ffd8fa97791806e4388361c9df0562c9cc5b Mon Sep 17 00:00:00 2001
From: kenyonxu
Date: Mon, 22 Jun 2026 11:45:12 +0800
Subject: [PATCH 11/13] =?UTF-8?q?docs:=20=E6=9B=B4=E6=96=B0=E7=89=88?=
=?UTF-8?q?=E6=9C=AC=E5=BC=95=E7=94=A8=204.0.0=20=E2=86=92=204.1.0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- getting-started-en.md / zh.md: version 输出改为 4.1.0+
- upstream-contribution-strategy.md: 发布状态更新为 4.1.0(基于 v3.6.16)
---
docs/getting-started-en.md | 4 ++--
docs/getting-started-zh.md | 4 ++--
docs/slm/upstream-contribution-strategy.md | 4 ++--
3 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/docs/getting-started-en.md b/docs/getting-started-en.md
index 0bc06eda..07201e5d 100644
--- a/docs/getting-started-en.md
+++ b/docs/getting-started-en.md
@@ -19,7 +19,7 @@ pip install mslm-memory
Verify:
```bash
-mslm --version # Should output 4.0.0+
+mslm --version # Should output 4.1.0+
mslm doctor # Check dependency integrity
```
@@ -61,7 +61,7 @@ Verify after startup:
```bash
curl -s http://127.0.0.1:8765/health | python3 -m json.tool
-# {"status":"ok","pid":12345,"engine":"initialized","version":"4.0.0"}
+# {"status":"ok","pid":12345,"engine":"initialized","version":"4.1.0"}
```
### Built-in Daemon Capabilities
diff --git a/docs/getting-started-zh.md b/docs/getting-started-zh.md
index 25425bb3..f4bf18ce 100644
--- a/docs/getting-started-zh.md
+++ b/docs/getting-started-zh.md
@@ -19,7 +19,7 @@ pip install mslm-memory
验证:
```bash
-mslm --version # 应输出 4.0.0+
+mslm --version # 应输出 4.1.0+
mslm doctor # 检查依赖完整性
```
@@ -61,7 +61,7 @@ mslm restart # 一键重启(杀僵尸 + 清理 + 启动 + 健康检查
```bash
curl -s http://127.0.0.1:8765/health | python3 -m json.tool
-# {"status":"ok","pid":12345,"engine":"initialized","version":"4.0.0"}
+# {"status":"ok","pid":12345,"engine":"initialized","version":"4.1.0"}
```
### Daemon 内置能力
diff --git a/docs/slm/upstream-contribution-strategy.md b/docs/slm/upstream-contribution-strategy.md
index 6a0f3264..2f5834c7 100644
--- a/docs/slm/upstream-contribution-strategy.md
+++ b/docs/slm/upstream-contribution-strategy.md
@@ -101,8 +101,8 @@ MSLM 的核心价值层(独立于引擎变更):
| 平台 | 包名 | 版本 | 状态 |
|------|------|------|:--:|
-| PyPI | `mslm-memory` | 4.0.0 | ✅ 已发布 |
-| npm | `mslm-memory` | 4.0.0 | ✅ 已发布 |
+| PyPI | `mslm-memory` | 4.1.0 | ✅ 已发布(基于上游 v3.6.16) |
+| npm | `mslm-memory` | 4.1.0 | ✅ 已发布(基于上游 v3.6.16) |
| 文档 | `docs/` (中英双语) | — | ✅ 已完成 |
| Hermes Provider | 设计规格 v1 | — | ✅ Spec 已完成,待实现 |
From b5ea6bef49e9fe12ec1432bd249aa3155d0a2335 Mon Sep 17 00:00:00 2001
From: kenyonxu
Date: Mon, 22 Jun 2026 11:54:37 +0800
Subject: [PATCH 12/13] =?UTF-8?q?docs:=20=E5=9C=A8=E6=89=80=E6=9C=89?=
=?UTF-8?q?=E4=B8=BB=E8=A6=81=E6=96=87=E6=A1=A3=E4=B8=AD=E5=8A=A0=E5=85=A5?=
=?UTF-8?q?=20Hermes=20MemoryProvider=20=E4=BD=BF=E7=94=A8=E8=AF=B4?=
=?UTF-8?q?=E6=98=8E?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- getting-started: 加 4.5 Hermes MemoryProvider 插件方式(推荐)
- configuration: 加 memory.provider 的 Hermes 配置节
- multi-scope-memory: 加 Hermes 中 scope 参数使用示例
- memory-import-guide: 加方式四:Hermes MemoryProvider 导入
- README: 功能列表加 Hermes MemoryProvider 插件特性
10 文件,中英文全覆盖。
---
README-en.md | 7 ++++++-
README-zh.md | 7 ++++++-
docs/configuration-en.md | 22 ++++++++++++++++++++++
docs/configuration-zh.md | 22 ++++++++++++++++++++++
docs/getting-started-en.md | 17 +++++++++++++++++
docs/getting-started-zh.md | 17 +++++++++++++++++
docs/memory-import-guide-en.md | 21 +++++++++++++++++++++
docs/memory-import-guide-zh.md | 21 +++++++++++++++++++++
docs/multi-scope-memory-en.md | 16 ++++++++++++++++
docs/multi-scope-memory-zh.md | 16 ++++++++++++++++
10 files changed, 164 insertions(+), 2 deletions(-)
diff --git a/README-en.md b/README-en.md
index c9abc756..7c1e1694 100644
--- a/README-en.md
+++ b/README-en.md
@@ -79,9 +79,14 @@ hermes mcp add mslm --command mslm --args mcp
### 🔌 MCP Native
- **33 core tools**: memory storage, semantic retrieval, entity management, session tracking
-- **Hermes Agent integration**: one-command registration, natural language invocation
- **Claude Code / Cursor / Windsurf**: works with any MCP-compatible client
+### 🧩 Hermes MemoryProvider Plugin
+- **Native integration**: no MCP subprocess, zero extra latency
+- **Auto context injection**: relevant memories prefetched each turn
+- **Scope-aware**: `slm_recall` / `slm_remember` / `slm_status` natively support three-tier scope
+- **Zero config**: one YAML line, auto-loaded on Hermes startup
+
### 📊 Web Dashboard
- Memory network graph visualization
- Retrieval statistics and performance monitoring
diff --git a/README-zh.md b/README-zh.md
index 6f0474a6..1a064d79 100644
--- a/README-zh.md
+++ b/README-zh.md
@@ -79,9 +79,14 @@ hermes mcp add mslm --command mslm --args mcp
### 🔌 MCP 原生集成
- **33 个核心工具**:记忆存储、语义检索、实体管理、会话追踪
-- **Hermes Agent 深度整合**:一键注册,自然语言调用
- **Claude Code / Cursor / Windsurf**:所有 MCP 兼容客户端即装即用
+### 🧩 Hermes MemoryProvider 插件
+- **原生集成**:无需 MCP 子进程,零额外延迟
+- **自动上下文注入**:每轮自动预取相关记忆
+- **scope 感知**:`slm_recall` / `slm_remember` / `slm_status` 三工具原生支持三层作用域
+- **即配即用**:一行 YAML 配置,Hermes 启动自动加载
+
### 📊 Web 仪表盘
- 记忆网络图可视化
- 检索统计与性能监控
diff --git a/docs/configuration-en.md b/docs/configuration-en.md
index cc84289c..8dcec6d7 100644
--- a/docs/configuration-en.md
+++ b/docs/configuration-en.md
@@ -111,6 +111,28 @@ export OPENAI_API_KEY="sk-..."
---
+### Hermes Agent MemoryProvider Configuration
+
+MSLM ships with a native Hermes Agent plugin. Configure it in your Hermes `config.yaml`:
+
+```yaml
+# ~/.hermes/profiles//config.yaml
+memory:
+ provider: superlocalmemory # Enable MSLM MemoryProvider
+ superlocalmemory:
+ mslm_profile: default # MSLM profile to use
+ prefetch_limit: 10 # auto-injected memories per turn (default 10)
+ include_global: true # include global-scope memories
+ include_shared: true # include shared memories
+ mslm_data_dir: "" # custom data dir (empty = default)
+```
+
+Once configured, Hermes auto-loads the plugin on startup — no `hermes mcp add` needed.
+
+> See [Hermes Agent Integration Guide](hermes-agent-guide-en.md)
+
+---
+
## Config File
All settings live in:
diff --git a/docs/configuration-zh.md b/docs/configuration-zh.md
index 82620751..d26236cf 100644
--- a/docs/configuration-zh.md
+++ b/docs/configuration-zh.md
@@ -111,6 +111,28 @@ export OPENAI_API_KEY="sk-..."
---
+### Hermes Agent MemoryProvider 配置
+
+MSLM 提供 Hermes Agent 原生插件,在 Hermes 配置文件 (`config.yaml`) 中配置:
+
+```yaml
+# ~/.hermes/profiles//config.yaml
+memory:
+ provider: superlocalmemory # 启用 MSLM MemoryProvider
+ superlocalmemory:
+ mslm_profile: default # 使用的 MSLM 配置文件
+ prefetch_limit: 10 # 每轮自动注入记忆数(默认 10)
+ include_global: true # 是否检索全局作用域记忆
+ include_shared: true # 是否检索共享记忆
+ mslm_data_dir: "" # 自定义数据目录(留空使用默认)
+```
+
+配置后 Hermes 启动时自动加载,无需额外 `hermes mcp add`。
+
+> 详见 [Hermes Agent 集成指南](hermes-agent-guide-zh.md)
+
+---
+
## 配置文件
所有设置存储在:
diff --git a/docs/getting-started-en.md b/docs/getting-started-en.md
index 07201e5d..0c86dd29 100644
--- a/docs/getting-started-en.md
+++ b/docs/getting-started-en.md
@@ -137,6 +137,23 @@ Three-tier scope:
| `global` | All Agents | Shared knowledge, team conventions |
| `shared` | Specified Agent list | Collaborative information |
+### 4.5 Hermes MemoryProvider Plugin (Recommended)
+
+Beyond MCP, MSLM provides a native Hermes Agent MemoryProvider plugin — no MCP subprocess overhead, lower latency:
+
+```yaml
+# ~/.hermes/profiles//config.yaml
+memory:
+ provider: superlocalmemory
+ superlocalmemory:
+ mslm_profile: my_profile # MSLM profile name
+ prefetch_limit: 10 # auto-injected memories per turn
+```
+
+Once configured, no `hermes mcp add` needed — Hermes auto-loads the plugin on startup, providing three native tools: `slm_recall`, `slm_remember`, `slm_status`.
+
+> See [Hermes Agent Integration Guide](hermes-agent-guide-en.md) for details.
+
---
## 5. Network & Proxy
diff --git a/docs/getting-started-zh.md b/docs/getting-started-zh.md
index f4bf18ce..7149b2e7 100644
--- a/docs/getting-started-zh.md
+++ b/docs/getting-started-zh.md
@@ -137,6 +137,23 @@ hermes mcp remove mslm # 移除
| `global` | 所有 Agent | 通用知识、团队规范 |
| `shared` | 指定 Agent 列表 | 协作信息 |
+### 4.5 Hermes MemoryProvider 插件方式(推荐)
+
+除 MCP 外,MSLM 提供 Hermes Agent 原生 MemoryProvider 插件,无需 MCP 进程通信,延迟更低:
+
+```yaml
+# ~/.hermes/profiles//config.yaml
+memory:
+ provider: superlocalmemory
+ superlocalmemory:
+ mslm_profile: my_profile # MSLM 配置文件名称
+ prefetch_limit: 10 # 每轮自动注入记忆数
+```
+
+配置后无需 `hermes mcp add` — Hermes 启动时自动加载,提供 `slm_recall`、`slm_remember`、`slm_status` 三个原生工具。
+
+> 详见 [Hermes Agent 集成指南](hermes-agent-guide-zh.md)
+
---
## 5. 网络与代理
diff --git a/docs/memory-import-guide-en.md b/docs/memory-import-guide-en.md
index 71622db5..23d865c3 100644
--- a/docs/memory-import-guide-en.md
+++ b/docs/memory-import-guide-en.md
@@ -238,6 +238,27 @@ curl -X POST http://localhost:8765/api/import \
---
+## Method 4: Import via Hermes MemoryProvider
+
+With the Hermes native plugin, you can import memories directly in a Hermes session — no MCP setup needed:
+
+```python
+# Batch import (call within Hermes session)
+memories = [
+ {"content": "Team convention: use snake_case for all API responses", "scope": "global"},
+ {"content": "Personal preference: use pnpm over npm", "scope": "personal"},
+ {"content": "Project key rotation policy", "scope": "shared", "shared_with": "backend_agent"},
+]
+for m in memories:
+ slm_remember(m["content"], scope=m.get("scope", "personal"),
+ shared_with=m.get("shared_with", ""))
+```
+
+> MemoryProvider supports full `scope`/`shared_with` parameters — the simplest way to bulk-import three-tier scope memories.
+> See [Hermes Agent Integration Guide](hermes-agent-guide-en.md)
+
+---
+
## Common Import Scenarios
### Scenario 1: Migrate from Mem0 / LangChain Memory
diff --git a/docs/memory-import-guide-zh.md b/docs/memory-import-guide-zh.md
index a6a3b943..6cb5a16e 100644
--- a/docs/memory-import-guide-zh.md
+++ b/docs/memory-import-guide-zh.md
@@ -238,6 +238,27 @@ curl -X POST http://localhost:8765/api/import \
---
+## 方式四:通过 Hermes MemoryProvider 导入
+
+使用 Hermes 原生插件可在 Hermes 会话中直接导入记忆,无需额外配置 MCP:
+
+```python
+# 批量导入(在 Hermes 会话中调用)
+memories = [
+ {"content": "团队约定:所有 API 响应使用 snake_case 命名", "scope": "global"},
+ {"content": "个人偏好:使用 pnpm 而非 npm", "scope": "personal"},
+ {"content": "项目密钥轮换规则", "scope": "shared", "shared_with": "backend_agent"},
+]
+for m in memories:
+ slm_remember(m["content"], scope=m.get("scope", "personal"),
+ shared_with=m.get("shared_with", ""))
+```
+
+> MemoryProvider 支持完整的 `scope`/`shared_with` 参数,是批量导入三层作用域记忆的最简方式。
+> 详见 [Hermes Agent 集成指南](hermes-agent-guide-zh.md)
+
+---
+
## 常见导入场景
### 场景一:从 Mem0 / LangChain Memory 迁移
diff --git a/docs/multi-scope-memory-en.md b/docs/multi-scope-memory-en.md
index 95ed5776..2b6eeb1d 100644
--- a/docs/multi-scope-memory-en.md
+++ b/docs/multi-scope-memory-en.md
@@ -143,6 +143,22 @@ mslm entity list --scope global
mslm entity merge
```
+### Hermes MemoryProvider
+
+With the Hermes MemoryProvider plugin, scope is controlled via tool parameters:
+
+```python
+# Store memories with different scopes
+slm_remember("Personal preference: prefer functional programming", scope="personal")
+slm_remember("React 18 supports concurrent features", scope="global")
+slm_remember("Internal API key rotation policy", scope="shared", shared_with="backend_agent")
+
+# Control scope visibility during recall
+slm_recall("React concurrent features", include_global=True, include_shared=False)
+```
+
+> See [Hermes Agent Integration Guide](hermes-agent-guide-en.md#5-multi-scope-memory) for details.
+
---
## 5. Multi-Agent Collaboration Examples
diff --git a/docs/multi-scope-memory-zh.md b/docs/multi-scope-memory-zh.md
index eb278fcb..57b4875e 100644
--- a/docs/multi-scope-memory-zh.md
+++ b/docs/multi-scope-memory-zh.md
@@ -143,6 +143,22 @@ mslm entity list --scope global
mslm entity merge <源ID> <目标ID>
```
+### Hermes MemoryProvider
+
+使用 Hermes MemoryProvider 插件时,scope 通过工具参数控制:
+
+```python
+# 存储不同作用域的记忆
+slm_remember("个人偏好:喜欢函数式编程", scope="personal")
+slm_remember("React 18 支持并发特性", scope="global")
+slm_remember("内部 API 密钥轮换规则", scope="shared", shared_with="backend_agent")
+
+# 检索时控制作用域范围
+slm_recall("React 并发特性", include_global=True, include_shared=False)
+```
+
+> 详见 [Hermes Agent 集成指南](hermes-agent-guide-zh.md#5-三层作用域记忆multi-scope-memory)
+
---
## 5. 多 Agent 协作示例
From ea6fe75ca34cab81307146a511fb9c8eefadcd09 Mon Sep 17 00:00:00 2001
From: kenyonxu
Date: Mon, 22 Jun 2026 12:05:16 +0800
Subject: [PATCH 13/13] =?UTF-8?q?fix(hermes):=20PR=20review=20fixes=20?=
=?UTF-8?q?=E2=80=94=20shared=20scope=20support=20+=20version=20updates?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Bug fixes (from PR #7 review):
- _tool_remember: accept 'shared' scope (was silently downgraded to personal)
- _tool_remember: extract and pass shared_with param to engine.store()
- _REMEMBER_SCHEMA: add 'shared' to enum + shared_with property
- system_prompt_block: document shared scope in tool description
Version updates:
- README-zh/en: v4.0.0 → v4.1.0
- plugin.yaml: mslm-memory>=4.0.0 → >=4.1.0
---
README-en.md | 2 +-
README-zh.md | 2 +-
.../integrations/hermes/__init__.py | 21 +++++++++++++++----
.../integrations/hermes/plugin.yaml | 2 +-
4 files changed, 20 insertions(+), 7 deletions(-)
diff --git a/README-en.md b/README-en.md
index 7c1e1694..de76cc93 100644
--- a/README-en.md
+++ b/README-en.md
@@ -1,7 +1,7 @@
MSLM
Multi-Scope Local Memory
The multi-scope local memory system that keeps AI from forgetting.
- v4.0.0 — Persistent memory for Claude Code, Cursor, Hermes Agent, and any MCP-compatible AI client.
+ v4.1.0 — Persistent memory for Claude Code, Cursor, Hermes Agent, and any MCP-compatible AI client.
diff --git a/README-zh.md b/README-zh.md
index 1a064d79..67c4e328 100644
--- a/README-zh.md
+++ b/README-zh.md
@@ -1,7 +1,7 @@
MSLM
Multi-Scope Local Memory
让 AI 不再遗忘的多层次本地记忆系统
- v4.0.0 — 为 Claude Code、Cursor、Hermes Agent 等 MCP 兼容 AI 客户端提供持久化记忆。
+ v4.1.0 — 为 Claude Code、Cursor、Hermes Agent 等 MCP 兼容 AI 客户端提供持久化记忆。
diff --git a/src/superlocalmemory/integrations/hermes/__init__.py b/src/superlocalmemory/integrations/hermes/__init__.py
index bd2d7d3e..7da95849 100644
--- a/src/superlocalmemory/integrations/hermes/__init__.py
+++ b/src/superlocalmemory/integrations/hermes/__init__.py
@@ -621,7 +621,7 @@ def system_prompt_block(self) -> str:
f"- slm_recall(query, limit=10, fast=false): 语义搜索本地记忆库。"
f"7通道检索 + RRF融合排序。\n"
f"- slm_remember(content, scope=\"personal\"): 显式存储信息到本地记忆库。"
- f"scope 可选 \"personal\"(仅当前profile) 或 \"global\"(跨profile共享)。\n"
+ f"scope 可选 \"personal\"(仅自己)、\"shared\"(指定Agent,需 shared_with) 或 \"global\"(跨profile共享)。\n"
f"- slm_status(): 查看记忆库统计信息"
f"(事实数、实体数、数据库大小等)。\n"
)
@@ -665,9 +665,13 @@ def system_prompt_block(self) -> str:
},
"scope": {
"type": "string",
- "description": "作用域: personal (默认) | global",
+ "description": "记忆作用域: personal (仅自己) | shared (指定 Agent) | global (所有 Agent)",
"default": "personal",
- "enum": ["personal", "global"],
+ "enum": ["personal", "shared", "global"],
+ },
+ "shared_with": {
+ "type": "string",
+ "description": "scope=shared 时指定共享的 Agent ID,逗号分隔",
},
},
"required": ["content"],
@@ -763,13 +767,22 @@ def _tool_remember(self, params: Dict[str, Any]) -> str:
return tool_error("content is required")
scope = params.get("scope", "personal")
- if scope not in ("personal", "global"):
+ if scope not in ("personal", "shared", "global"):
scope = "personal"
+ shared_with_raw = params.get("shared_with", "")
+ if isinstance(shared_with_raw, list):
+ shared_with = [s.strip() for s in shared_with_raw if s.strip()]
+ elif isinstance(shared_with_raw, str) and shared_with_raw.strip():
+ shared_with = [s.strip() for s in shared_with_raw.split(",") if s.strip()]
+ else:
+ shared_with = None
+
try:
with self._write_lock:
fact_ids = self._engine.store(
content, session_id=self._session_id, scope=scope,
+ shared_with=shared_with,
)
except Exception as exc:
logger.debug("MSLM store failed: %s", exc)
diff --git a/src/superlocalmemory/integrations/hermes/plugin.yaml b/src/superlocalmemory/integrations/hermes/plugin.yaml
index bd084d1b..fc610452 100644
--- a/src/superlocalmemory/integrations/hermes/plugin.yaml
+++ b/src/superlocalmemory/integrations/hermes/plugin.yaml
@@ -2,7 +2,7 @@ name: superlocalmemory
version: 1.0.0
description: "MSLM — 信息几何学的本地 AI 记忆引擎,7通道检索,三层作用域,完全本地运行"
pip_dependencies:
- - mslm-memory>=4.0.0
+ - mslm-memory>=4.1.0
hooks:
- on_session_end
- on_memory_write