From 42b17a0fd6ca9c600dd5d534e7bcefc1511f8742 Mon Sep 17 00:00:00 2001 From: bittergreen Date: Mon, 1 Jun 2026 11:02:25 +0800 Subject: [PATCH 1/3] fix: default-disable dream plugin --- src/memos/dream/plugin.py | 1 + src/memos/plugins/base.py | 1 + src/memos/plugins/manager.py | 11 +- tests/plugins/test_plugin_component_init.py | 236 ++++++++++++++++++++ 4 files changed, 247 insertions(+), 2 deletions(-) create mode 100644 tests/plugins/test_plugin_component_init.py diff --git a/src/memos/dream/plugin.py b/src/memos/dream/plugin.py index fbe098df7..ef3291d53 100644 --- a/src/memos/dream/plugin.py +++ b/src/memos/dream/plugin.py @@ -46,6 +46,7 @@ class CommunityDreamPlugin(MemOSPlugin): version = "0.1.0" description = "Built-in Dream plugin" priority = 10 + enabled_by_default = False def on_load(self) -> None: self.context: dict[str, Any] = {"shared": {}, "configs": {}} diff --git a/src/memos/plugins/base.py b/src/memos/plugins/base.py index b10b7c824..24d356bbf 100644 --- a/src/memos/plugins/base.py +++ b/src/memos/plugins/base.py @@ -28,6 +28,7 @@ class MemOSPlugin: version: str = "0.0.0" description: str = "" priority: int = 0 + enabled_by_default: bool = True _app: FastAPI | None = None diff --git a/src/memos/plugins/manager.py b/src/memos/plugins/manager.py index a4478a22d..b18618098 100644 --- a/src/memos/plugins/manager.py +++ b/src/memos/plugins/manager.py @@ -39,7 +39,14 @@ def _parse_plugin_names(value: str | None) -> set[str]: @classmethod def _is_plugin_enabled(cls, plugin: MemOSPlugin) -> bool: disabled = cls._parse_plugin_names(os.getenv("MEMOS_DISABLED_PLUGINS")) - return plugin.name not in disabled + if plugin.name in disabled: + return False + + if plugin.enabled_by_default: + return True + + enabled = cls._parse_plugin_names(os.getenv("MEMOS_ENABLED_PLUGINS")) + return plugin.name in enabled @staticmethod def _select_plugin_winners( @@ -121,7 +128,7 @@ def discover(self) -> None: for plugin_name, plugin in winners.items(): if not self._is_plugin_enabled(plugin): logger.info( - "Plugin discovered but disabled: %s v%s (MEMOS_DISABLED_PLUGINS)", + "Plugin discovered but disabled: %s v%s", plugin.name, plugin.version, ) diff --git a/tests/plugins/test_plugin_component_init.py b/tests/plugins/test_plugin_component_init.py new file mode 100644 index 000000000..49a12f625 --- /dev/null +++ b/tests/plugins/test_plugin_component_init.py @@ -0,0 +1,236 @@ +from __future__ import annotations + +from collections import namedtuple + + +class TestPluginComponentInit: + def test_build_plugin_context_shapes_expected_sections(self): + from memos.plugins.component_bootstrap import build_plugin_context + + context = build_plugin_context( + graph_db="graph", + embedder="embedder", + default_cube_config="cube", + nli_client_config={"base_url": "http://nli"}, + mem_reader_config="mem_reader", + reranker_config="reranker", + feedback_reranker_config="feedback_reranker", + internet_retriever_config="internet", + ) + + assert context["shared"]["graph_db"] == "graph" + assert context["shared"]["embedder"] == "embedder" + assert context["configs"]["default_cube_config"] == "cube" + assert context["configs"]["nli_client_config"]["base_url"] == "http://nli" + assert "components" not in context + + def test_manager_calls_init_components(self): + from memos.plugins.base import MemOSPlugin + from memos.plugins.manager import PluginManager + + class DummyPlugin(MemOSPlugin): + name = "dummy" + + def on_load(self) -> None: + self.calls = 0 + + def init_components(self, context: dict) -> None: + self.calls += 1 + context["components"]["dummy"] = "ok" + + plugin = DummyPlugin() + plugin.on_load() + manager = PluginManager() + manager._plugins[plugin.name] = plugin + context = {"components": {}} + + manager.init_components(context) + + assert plugin.calls == 1 + assert context["components"]["dummy"] == "ok" + + def test_discover_is_idempotent(self, monkeypatch): + import importlib.metadata + + from memos.plugins.base import MemOSPlugin + from memos.plugins.manager import PluginManager + + EntryPoint = namedtuple("EntryPoint", ["name", "load"]) + + class DummyPlugin(MemOSPlugin): + name = "dummy" + + load_calls = {"count": 0} + + def load_plugin(): + load_calls["count"] += 1 + return DummyPlugin + + class EntryPoints(list): + def select(self, *, group): + assert group == "memos.plugins" + return self + + monkeypatch.setattr( + importlib.metadata, + "entry_points", + lambda: EntryPoints([EntryPoint(name="dummy", load=load_plugin)]), + ) + + manager = PluginManager() + manager.discover() + manager.discover() + + assert load_calls["count"] == 1 + assert list(manager.plugins) == ["dummy"] + + def test_discover_loads_plugins_by_default(self, monkeypatch): + import importlib.metadata + + from memos.plugins.base import MemOSPlugin + from memos.plugins.manager import PluginManager + + EntryPoint = namedtuple("EntryPoint", ["name", "load"]) + + class OptInPlugin(MemOSPlugin): + name = "regular" + + class EntryPoints(list): + def select(self, *, group): + assert group == "memos.plugins" + return self + + monkeypatch.delenv("MEMOS_DISABLED_PLUGINS", raising=False) + monkeypatch.setattr( + importlib.metadata, + "entry_points", + lambda: EntryPoints([EntryPoint(name="regular", load=lambda: OptInPlugin)]), + ) + + manager = PluginManager() + manager.discover() + + assert list(manager.plugins) == ["regular"] + + def test_discover_skips_plugins_disabled_by_default(self, monkeypatch): + import importlib.metadata + + from memos.plugins.base import MemOSPlugin + from memos.plugins.manager import PluginManager + + EntryPoint = namedtuple("EntryPoint", ["name", "load"]) + + class DreamLikePlugin(MemOSPlugin): + name = "dream" + enabled_by_default = False + + class EntryPoints(list): + def select(self, *, group): + assert group == "memos.plugins" + return self + + monkeypatch.delenv("MEMOS_DISABLED_PLUGINS", raising=False) + monkeypatch.delenv("MEMOS_ENABLED_PLUGINS", raising=False) + monkeypatch.setattr( + importlib.metadata, + "entry_points", + lambda: EntryPoints([EntryPoint(name="dream", load=lambda: DreamLikePlugin)]), + ) + + manager = PluginManager() + manager.discover() + + assert list(manager.plugins) == [] + + def test_discover_loads_default_disabled_plugin_when_enabled(self, monkeypatch): + import importlib.metadata + + from memos.plugins.base import MemOSPlugin + from memos.plugins.manager import PluginManager + + EntryPoint = namedtuple("EntryPoint", ["name", "load"]) + + class DreamLikePlugin(MemOSPlugin): + name = "dream" + enabled_by_default = False + + class EntryPoints(list): + def select(self, *, group): + assert group == "memos.plugins" + return self + + monkeypatch.delenv("MEMOS_DISABLED_PLUGINS", raising=False) + monkeypatch.setenv("MEMOS_ENABLED_PLUGINS", "dream") + monkeypatch.setattr( + importlib.metadata, + "entry_points", + lambda: EntryPoints([EntryPoint(name="dream", load=lambda: DreamLikePlugin)]), + ) + + manager = PluginManager() + manager.discover() + + assert list(manager.plugins) == ["dream"] + + def test_discover_skips_disabled_plugin(self, monkeypatch): + import importlib.metadata + + from memos.plugins.base import MemOSPlugin + from memos.plugins.manager import PluginManager + + EntryPoint = namedtuple("EntryPoint", ["name", "load"]) + + class DisabledPlugin(MemOSPlugin): + name = "disabled_one" + + class EntryPoints(list): + def select(self, *, group): + assert group == "memos.plugins" + return self + + monkeypatch.setenv("MEMOS_DISABLED_PLUGINS", "disabled_one") + monkeypatch.setattr( + importlib.metadata, + "entry_points", + lambda: EntryPoints([EntryPoint(name="disabled_one", load=lambda: DisabledPlugin)]), + ) + + manager = PluginManager() + manager.discover() + + assert list(manager.plugins) == [] + + def test_discover_disabled_env_overrides_enabled_env(self, monkeypatch): + import importlib.metadata + + from memos.plugins.base import MemOSPlugin + from memos.plugins.manager import PluginManager + + EntryPoint = namedtuple("EntryPoint", ["name", "load"]) + + class DreamLikePlugin(MemOSPlugin): + name = "dream" + enabled_by_default = False + + class EntryPoints(list): + def select(self, *, group): + assert group == "memos.plugins" + return self + + monkeypatch.setenv("MEMOS_DISABLED_PLUGINS", "dream") + monkeypatch.setenv("MEMOS_ENABLED_PLUGINS", "dream") + monkeypatch.setattr( + importlib.metadata, + "entry_points", + lambda: EntryPoints([EntryPoint(name="dream", load=lambda: DreamLikePlugin)]), + ) + + manager = PluginManager() + manager.discover() + + assert list(manager.plugins) == [] + + def test_builtin_dream_plugin_is_disabled_by_default(self): + from memos.dream import CommunityDreamPlugin + + assert CommunityDreamPlugin.enabled_by_default is False From 20210b12affe7229f95f7177cc25c38412f8f171 Mon Sep 17 00:00:00 2001 From: bittergreen Date: Mon, 1 Jun 2026 11:20:51 +0800 Subject: [PATCH 2/3] docs: add readme for dream --- src/memos/dream/README.md | 129 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 src/memos/dream/README.md diff --git a/src/memos/dream/README.md b/src/memos/dream/README.md new file mode 100644 index 000000000..3fe7dab09 --- /dev/null +++ b/src/memos/dream/README.md @@ -0,0 +1,129 @@ +# Dream Plugin + +Dream is an optional MemOS community feature, currently in beta. It explores how +an agent can reflect on recently added memories outside the foreground request +path, then consolidate them into higher-level context, insights, and diary +entries. The implementation is intentionally lightweight and extensible, and we +welcome the community to help improve the signal policy, prompts, persistence, +diary experience, and downstream integrations. + +## Status and Enablement + +Dream is disabled by default. + +With no plugin environment variables configured, MemOS loads ordinary plugins as +before, but it does not load the built-in `dream` plugin. Enable it explicitly: + +```bash +MEMOS_ENABLED_PLUGINS=dream +``` + +`MEMOS_DISABLED_PLUGINS` has the highest priority, so this keeps Dream disabled: + +```bash +MEMOS_ENABLED_PLUGINS=dream +MEMOS_DISABLED_PLUGINS=dream +``` + +## Current Capabilities + +When enabled, the built-in `CommunityDreamPlugin` provides: + +- signal capture from successful memory add operations; +- deterministic Dream metadata enrichment during fine extraction; +- scheduler-driven Dream execution through the `dream.execute` hook; +- manual cube-level triggering through `POST /dream/trigger/cube`; +- Dream diary querying through `POST /dream/diary`; +- `Context` recall merged into normal search results. + +The default pipeline is: + +1. Build or update `Context` nodes from pending memories. +2. Form Dream motives from recently added source memories. +3. Recall related `UserMemory` and `LongTermMemory` nodes. +4. Use the configured LLM to produce at most one insight per motive. +5. Generate a human-readable Dream diary entry. +6. Persist valid insight actions and diary entries to the graph database. + +If no LLM is available, Dream can still run the pipeline, but fallback reasoning +does not write new insight memories because zero-confidence actions are skipped. + +## Usage + +Enable Dream and start the API service: + +```bash +export MEMOS_ENABLED_PLUGINS=dream +make serve +``` + +Check plugin health: + +```bash +curl http://127.0.0.1:8000/dream/diary/health +``` + +Manually submit a cube-level Dream task: + +```bash +curl -X POST "http://127.0.0.1:8000/dream/trigger/cube?cube_id=&user_id=&user_name=" +``` + +Query recent Dream diary entries: + +```bash +curl -X POST http://127.0.0.1:8000/dream/diary \ + -H "Content-Type: application/json" \ + -d '{"cube_id": "", "filter": {"limit": 5}}' +``` + +Fetch one diary entry: + +```bash +curl -X POST http://127.0.0.1:8000/dream/diary \ + -H "Content-Type: application/json" \ + -d '{"cube_id": "", "filter": {"task_id": "dream_diary_xxx"}}' +``` + +## Configuration + +Plugin loading: + +- `MEMOS_ENABLED_PLUGINS=dream`: enable Dream. +- `MEMOS_DISABLED_PLUGINS=dream`: disable Dream, even if it is also enabled. + +Dream-specific options: + +- `MEMOS_DREAM_HEURISTIC_ENRICHER`: defaults to `on`. +- `MEMOS_DREAM_ENRICH_OVERWRITE`: defaults to `off`. +- `MEMOS_DREAM_CONTEXT_ENABLED`: defaults to `on`. +- `MEMOS_DREAM_CONTEXT_SUMMARY_LLM`: defaults to `on`. +- `MEMOS_DREAM_CONTEXT_BINDING_LLM`: defaults to `on`. +- `MEMOS_DREAM_CONTEXT_BINDING_MIN_GROUP_SIZE`: defaults to `2`. +- `MEMOS_DREAM_CONTEXT_BINDING_MAX_GROUP_SIZE`: defaults to `30`. +- `MEMOS_DREAM_CONTEXT_BINDING_CONFIDENCE_THRESHOLD`: defaults to `0.65`. + +## Beta Limitations + +- Signals are stored in memory and do not survive process restarts. +- The automatic trigger policy is currently a simple pending-memory threshold. +- The built-in signal source focuses on new-memory accumulation. Conflict, + feedback, frequency, and fragmentation signals are extension points. +- The diary and surfacing experience is still early. +- Write-back policies for update, merge, archive, and long-term maintenance are + available as extension directions, not complete product behavior. + +## Contributing + +Dream is designed as a community-building surface. Good places to contribute: + +- better trigger policies and signal stores; +- stronger motive formation and recall strategies; +- safer and more useful reasoning prompts; +- richer diary generation and user-facing surfacing; +- memory lifecycle and maintenance policies; +- alternative `dream` plugin implementations with higher priority. + +Projects can ship their own plugin with the same logical name (`dream`). When +multiple providers expose `dream`, the plugin manager keeps the implementation +with the highest priority. From 96de57f63e4f85be0ae3b710d148bc7f4b8cd22c Mon Sep 17 00:00:00 2001 From: bittergreen Date: Mon, 1 Jun 2026 11:30:46 +0800 Subject: [PATCH 3/3] docs: add readme for plugins --- src/memos/plugins/README.md | 173 ++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 src/memos/plugins/README.md diff --git a/src/memos/plugins/README.md b/src/memos/plugins/README.md new file mode 100644 index 000000000..2ae204f28 --- /dev/null +++ b/src/memos/plugins/README.md @@ -0,0 +1,173 @@ +# MemOS Plugin System + +This directory contains the Python plugin framework for MemOS. It is used by +the API service and scheduler runtime to load in-process extensions. + +The framework currently supports: + +- Python package discovery through the `memos.plugins` entry-point group. +- Plugin lifecycle hooks: `on_load`, `init_components`, `init_app`, + `on_shutdown`. +- FastAPI router and middleware registration. +- Hook callbacks for add/search/mem-reader/memory-version/Dream extension + points. +- Runtime component injection, such as graph DB, embedder, LLM, and configs. +- Enable/disable controls through environment variables. +- Priority-based selection when multiple packages provide the same logical + plugin. + +It is not a remote sandbox, marketplace, or hot-reload system. Plugins run in +the same Python process as MemOS, so callbacks should be fast, defensive, and +careful with side effects. + +## Files + +| File | Purpose | +| ---- | ------- | +| `base.py` | `MemOSPlugin` base class and registration helpers. | +| `manager.py` | Entry-point discovery, enable/disable logic, lifecycle orchestration. | +| `hook_defs.py` | Core hook names and hook specs. | +| `hooks.py` | Hook registry, trigger helpers, and `@hookable`. | +| `component_bootstrap.py` | Builds the context passed to `init_components`. | + +Useful references: + +- Built-in plugin example: `src/memos/dream/plugin.py` +- API startup: `src/memos/api/server_api.py` +- Component bootstrap: `src/memos/api/handlers/component_init.py` +- Tests: `tests/plugins/` + +## Lifecycle + +Plugins are discovered from installed Python entry points: + +```toml +[project.entry-points."memos.plugins"] +my_plugin = "my_plugin.plugin:MyPlugin" +``` + +During startup, `PluginManager`: + +1. Loads each entry point and instantiates the plugin class. +2. Keeps only instances that inherit from `MemOSPlugin`. +3. Resolves duplicate logical names by `priority`. +4. Applies enable/disable environment variables. +5. Calls `on_load()`. +6. Calls `init_components(context)` when runtime components are ready. +7. Calls `init_app()` after binding the FastAPI app. + +`on_shutdown()` is called when plugins are shut down. + +Environment variables: + +- `MEMOS_DISABLED_PLUGINS`: comma-separated plugin names to disable. +- `MEMOS_ENABLED_PLUGINS`: comma-separated plugin names to enable when + `enabled_by_default = False`. + +Disable wins if a plugin appears in both lists. + +## Minimal Plugin + +```python +from fastapi import APIRouter + +from memos.plugins import H, MemOSPlugin + + +class MyPlugin(MemOSPlugin): + name = "my_plugin" + version = "0.1.0" + description = "Example MemOS plugin" + priority = 0 + enabled_by_default = True + + def on_load(self) -> None: + self.register_hook(H.SEARCH_AFTER, self.on_search_after) + + def init_components(self, context: dict) -> None: + self.context = context + + def init_app(self) -> None: + router = APIRouter(prefix="/my-plugin", tags=["my-plugin"]) + + @router.get("/health") + def health() -> dict[str, object]: + return {"plugin": self.name, "version": self.version} + + self.register_router(router) + + def on_search_after(self, *, request, result, **kwargs): + return result + + def on_shutdown(self) -> None: + self.context = {} +``` + +Install the package into the same Python environment as MemOS: + +```bash +pip install -e /path/to/my_plugin +``` + +Then restart the MemOS service. + +## Hooks + +Core hook names are exposed through `memos.plugins.H`. + +Common hooks include: + +| Hook | Purpose | +| ---- | ------- | +| `add.before` / `add.after` | Modify add requests or results. | +| `search.before` / `search.after` | Modify search requests or results. | +| `search.memory_results` | Add result buckets before thresholding, dedup, and reranking. | +| `mem_reader.pre_extract` | Customize memory-reader extraction prompts. | +| `memory_items.after_fine_extract` | Post-process extracted memory items. | +| `memory_version.prepare_updates` | Prepare versioned-memory candidates. | +| `memory_version.apply_updates` | Apply versioned-memory updates. | +| `memory_version.apply_feedback_update` | Apply version semantics during feedback updates. | +| `dream.execute` | Execute the active Dream pipeline. | + +Hooks may define a `pipe_key`. If a callback returns a non-`None` value, that +value replaces the piped argument for the next callback. Returning `None` means +"leave the current value unchanged". + +Plugin-owned hooks should be declared inside the plugin package with +`define_hook`, not added to core `hook_defs.py`. + +## Runtime Context + +`init_components(context)` receives a mutable context with: + +- `context["shared"]`: runtime objects such as `graph_db`, `embedder`, `llm`, + `mem_scheduler`, and scheduler submit handles. +- `context["configs"]`: default cube, NLI, mem-reader, reranker, feedback + reranker, and internet retriever configs. + +Keep the context reference if your plugin needs values that may be attached +later during bootstrap. + +## Development Notes + +- Namespace plugin routes, for example `/my-plugin/...`. +- Keep hook callbacks narrow and resilient; do not let optional features break + core add/search paths. +- Guard optional third-party imports with clear installation messages. +- Use `memos.log.get_logger(__name__)`; do not print secrets, vectors, or raw + user data. +- Make `on_shutdown()` idempotent. +- Add tests for hook registration, piped returns, component context handling, + router registration, and enable/disable behavior. + +For core framework changes, run: + +```bash +poetry run pytest tests/plugins/ -q +``` + +## Related Plugin-Like Projects + +The TypeScript/OpenClaw/Hermes projects under `apps/` are separate host +integrations. They are not loaded by this Python `PluginManager` unless they +also publish a Python entry point under `memos.plugins`.