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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/nooa-memory/src/nooa_memory/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ accreted over time**, modeled on how the brain uses memory.
When `enabled=False` (or not installed), the agent is byte-for-byte unchanged — the
*additive guarantee* (regression-tested).

A disabled installation does not construct an embedder or open its database.
Explicitly accessing the manager's store or calling its operations initializes
the resources on demand; agent-facing memory tools remain disabled. Uninstalling
an unused disabled manager does not create resources either.

---

## Quick start
Expand Down
66 changes: 47 additions & 19 deletions packages/nooa-memory/src/nooa_memory/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import threading
import time
from collections.abc import Callable
from functools import cached_property
from typing import Any

from nooa.agent import Agent
Expand Down Expand Up @@ -141,24 +142,7 @@ def __init__(
# the TUI pass their stable per-agent key; "" writes to the shared
# namespace); the honest library default is the agent's class name.
self.owner = self.config.owner if self.config.owner is not None else type(agent).__name__
self.embedder = embedder or get_embedder(self.config.embedding)
self.store = self._make_store(agent)
self.retrieval = RetrievalEngine(
self.store,
self.embedder,
self.config.retrieval,
access_log_cap=self.config.observability.access_log_cap,
)
# Engines consolidate at ROLE scope: instance scope would fragment
# knowledge per session (and reflection must fold across instances).
self.reflection_engine = ReflectionEngine(
self.store,
self.embedder,
self.config.reflection,
self.config.forget,
owner=self.role,
)
self.forgetting = ForgettingEngine(self.store, self.config.forget, owner=self.role)
self._provided_embedder = embedder
self._reasoner = reasoner
self._reconciler = reconciler
self.stats = MemoryStats()
Expand All @@ -174,9 +158,50 @@ def __init__(
self._last_query_hash: int | None = None
self._primed = False

if self.config.enabled:
# Keep enabled installs eager, including backend validation.
_ = self.retrieval, self.reflection_engine, self.forgetting
self._install_hooks()

@cached_property
def embedder(self) -> Embedder:
"""Reuse the supplied embedder or construct the configured backend on first use."""
return self._provided_embedder or get_embedder(self.config.embedding)

@cached_property
def store(self) -> MemoryStore:
"""Open the configured memory database once, only when storage is needed."""
return self._make_store(self.agent)

@cached_property
def retrieval(self) -> RetrievalEngine:
"""Build the recall engine against this manager's shared store and embedder."""
return RetrievalEngine(
self.store,
self.embedder,
self.config.retrieval,
access_log_cap=self.config.observability.access_log_cap,
)

@cached_property
def reflection_engine(self) -> ReflectionEngine:
"""Initialize role-scoped reflection lazily, sharing the manager's resources."""
# Engines consolidate at ROLE scope, folding knowledge across instances.
return ReflectionEngine(
self.store,
self.embedder,
self.config.reflection,
self.config.forget,
owner=self.role,
)

@cached_property
def forgetting(self) -> ForgettingEngine:
"""Initialize role-scoped forgetting against the cached memory store."""
return ForgettingEngine(self.store, self.config.forget, owner=self.role)

def _make_store(self, agent: Agent) -> MemoryStore:
"""Open explicit or agent-default storage using the configured embedding dimension."""
path = self.config.path or self._default_path(agent)
return MemoryStore(path, vector_config=self.config.vector, embedding_dim=self.embedder.dim)

Expand Down Expand Up @@ -244,7 +269,10 @@ def uninstall(self) -> None:
if not task.done():
task.cancel()
self._pending.clear()
self.store.close()
# Uninstalling an unused disabled manager must not initialize its store.
store = self.__dict__.get("store")
if store is not None:
store.close()
if getattr(self.agent, "_memory", None) is self:
del self.agent._memory # type: ignore[attr-defined]

Expand Down
40 changes: 40 additions & 0 deletions packages/nooa-memory/tests/memory/test_memory_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,12 +64,52 @@ def test_uninstall_removes_all_hooks(agent):


def test_disabled_install_is_inert(agent):
"""Disabled installation does not register hooks or enable agent-facing memory tools."""
mgr = MemoryManager.install(agent, config=MemoryConfig(enabled=False, path=":memory:"))
assert mgr._unsubs == []
assert len(agent.event_manager._middleware["agent_call"]) == 0


def test_disabled_install_does_not_create_database(agent, tmp_path):
"""Installing and uninstalling an unused disabled manager leaves the filesystem alone."""
path = tmp_path / "unused" / "memory.sqlite"
mgr = MemoryManager.install(agent, config=MemoryConfig(enabled=False, path=str(path)))
try:
assert not path.parent.exists()
finally:
mgr.uninstall()
assert not path.parent.exists()


def test_disabled_install_does_not_construct_embedder(agent, monkeypatch):
"""An unused disabled manager never needs to construct an embedding backend."""

def unexpected_embedder(config):
"""Fail immediately if disabled installation tries to initialize embedding resources."""
pytest.fail("disabled installation constructed an embedder")

monkeypatch.setattr("nooa_memory.manager.get_embedder", unexpected_embedder)
mgr = MemoryManager.install(agent, config=MemoryConfig(enabled=False, path=":memory:"))
mgr.uninstall()


def test_disabled_manager_explicit_use_initializes_resources_once(agent, tmp_path):
"""Direct manager operations lazily reuse resources without enabling agent-facing hooks."""
path = tmp_path / "memory.sqlite"
mgr = MemoryManager.install(agent, config=MemoryConfig(enabled=False, path=str(path)))
try:
mid = mgr.remember("explicit manager operation")
assert path.exists()
store = mgr.store
assert mgr.store is store
assert store.get(mid).content == "explicit manager operation"
assert mgr._unsubs == []
finally:
mgr.uninstall()


def test_install_replaces_existing_manager_without_leaking_hooks(agent):
"""Replacing a manager unregisters the previous hooks before installing new ones."""
first = _install(agent)
second = _install(agent)

Expand Down