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