Skip to content
Closed
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
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,10 @@ All settings are environment-driven (or `.env`):
| `MNEMA_DREAM_ENABLED` | `false` | Auto Dream background consolidation |
| `MNEMA_DREAM_INTERVAL_SECONDS` | `3600` | Seconds between dream cycles |
| `MNEMA_DREAM_DECAY_THRESHOLD` | `0.05` | Decay cutoff for forgetting during dreams |
| `MNEMA_SMART_FORGET_ENABLED` | `false` | Opt-in LLM veto on decay deletions (see [Smart forgetting](#-smart-forgetting-opt-in)) |
| `MNEMA_JUDGE_MODEL` | — | Chat model for the smart-forgetting judge (required when enabled) |
| `MNEMA_JUDGE_API_KEY` | — | Judge endpoint key (only sent when set) |
| `MNEMA_JUDGE_BASE_URL` | `https://api.openai.com/v1` | OpenAI-compatible judge endpoint |
| `MNEMA_LOG_LEVEL` | `WARNING` | `DEBUG` \| `INFO` \| `WARNING` \| `ERROR` — verbose logs for bug reports |

---
Expand Down Expand Up @@ -565,6 +569,34 @@ score = 0.7·vector + 0.2·keyword + 0.1·decay

where `decay = recency(half-life) × frequency × importance`.

### 🤖 Smart forgetting (opt-in)

By default Mnema is **deliberately LLM-free**: decay deletions come from the
formula above alone. If you want a second opinion on borderline memories, you
can opt in to an LLM judge that vets only the records the formula already
selected for deletion:

- The judge can only **rescue** — it never adds new deletion candidates.
- **Fail-safe = KEEP** — any judge error, timeout, or unparseable answer
keeps the memory.
- Disabled by default; when `MNEMA_SMART_FORGET_ENABLED` is unset/`false`,
no judge is constructed and **no LLM call is ever made**.

| Variable | Default | Description |
|---|---|---|
| `MNEMA_SMART_FORGET_ENABLED` | `false` | Master opt-in for the LLM judge |
| `MNEMA_JUDGE_MODEL` | — | Chat model id (required when enabled) |
| `MNEMA_JUDGE_API_KEY` | — | Sent as `Authorization: Bearer` only when set |
| `MNEMA_JUDGE_BASE_URL` | `https://api.openai.com/v1` | Any OpenAI-compatible endpoint |

Fully local example with Ollama (no paid key needed):

```bash
export MNEMA_SMART_FORGET_ENABLED=true
export MNEMA_JUDGE_BASE_URL=http://localhost:11434/v1
export MNEMA_JUDGE_MODEL=qwen3:8b
```

---

## 🗺️ Roadmap
Expand Down
32 changes: 32 additions & 0 deletions packages/mnema-python/src/mnema/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,34 @@ class MnemaConfig(BaseSettings):
),
)

# --- Smart forgetting (opt-in LLM judge) -----------------------------
smart_forget_enabled: bool = Field(
default=False,
description=(
"Opt-in: let a configured LLM veto decay-sweep deletions. When "
"False (default) no judge is constructed and no LLM is ever "
"called."
),
)
judge_model: str | None = Field(
default=None,
description="Chat model id for the smart-forgetting judge (required when enabled)",
)
judge_api_key: str | None = Field(
default=None,
description=(
"API key for the judge endpoint, sent as 'Authorization: Bearer' "
"only when set (local servers like Ollama need none)"
),
)
judge_base_url: str = Field(
default="https://api.openai.com/v1",
description=(
"OpenAI-compatible base URL for the judge endpoint "
"(use http://localhost:11434/v1 for Ollama)"
),
)

# --- Logging / diagnostics -------------------------------------------
log_level: str = Field(
default="WARNING",
Expand Down Expand Up @@ -222,6 +250,10 @@ def validate_runtime(self) -> None:
raise ConfigError(
"embedding='nomic' requires MNEMA_NOMIC_API_KEY to be set."
)
if self.smart_forget_enabled and not self.judge_model:
raise ConfigError(
"smart_forget_enabled=True requires MNEMA_JUDGE_MODEL to be set."
)


def load_config(**overrides: object) -> MnemaConfig:
Expand Down
167 changes: 167 additions & 0 deletions packages/mnema-python/src/mnema/judge.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
"""Smart forgetting — an opt-in LLM veto on the decay sweep.

Mnema is *deliberately* LLM-free by default (see :mod:`mnema.summarize`).
This module is the single, strictly opt-in exception: when
``MNEMA_SMART_FORGET_ENABLED=true``, each memory the pure decay formula has
already selected for deletion (``score <= threshold``) is asked KEEP/FORGET
by a configured chat model, and only explicit FORGET verdicts are deleted.

Two conservative guarantees hold throughout:

* The LLM can only **rescue**, never condemn — memories above the decay
threshold are never shown to the judge.
* **Fail-safe = KEEP** — any error, timeout, HTTP failure, or unparseable
response means the memory is kept. Deletion on ambiguity is forbidden.

The pure helpers (:func:`build_forget_prompt`, :func:`parse_forget_verdict`)
do no I/O so they are trivially testable; :class:`OpenAICompatibleJudge` is a
thin async httpx client modelled on the Ollama embedding provider.
"""

from __future__ import annotations

import logging
from abc import ABC, abstractmethod

import httpx

from mnema.config import MnemaConfig
from mnema.models import MemoryRecord

logger = logging.getLogger("mnema.judge")


def build_forget_prompt(record: MemoryRecord, decay_score: float) -> str:
"""Build the KEEP/FORGET prompt for one decay-sweep candidate. Pure."""
tags = ", ".join(record.tags) if record.tags else "(none)"
return (
"You are the forgetting judge of a long-term memory store. A memory "
"has been selected for deletion by a heuristic decay formula. Decide "
"whether it is truly no longer relevant.\n"
"\n"
"Answer with a first token of exactly KEEP or FORGET, optionally "
"followed by a one-line reason.\n"
"\n"
f"Memory: {record.text}\n"
f"Tags: {tags}\n"
f"Importance (1-10): {int(record.importance)}\n"
f"Decay score (0-1, lower means more forgotten): {decay_score:.3f}\n"
)


def parse_forget_verdict(text: str) -> bool | None:
"""Parse the judge's answer. Pure.

Returns ``True`` for FORGET, ``False`` for KEEP, and ``None`` for
anything unparseable (empty, ambiguous, or a near-miss like
"FORGETFUL") — callers must treat ``None`` as KEEP.
"""
stripped = (text or "").strip()
if not stripped:
return None
first_token = stripped.split(maxsplit=1)[0].lower()
if first_token == "forget":
return True
if first_token == "keep":
return False
return None


class MemoryJudge(ABC):
"""Pluggable KEEP/FORGET judge for decay-sweep candidates.

Implementations must **never raise** from :meth:`should_forget` — any
failure returns ``False`` (keep) and logs the reason.
"""

@abstractmethod
async def should_forget(self, record: MemoryRecord, decay_score: float) -> bool:
"""Return True only when the judge confidently votes FORGET."""

async def aclose(self) -> None:
"""Release resources. Default no-op."""
return None


class OpenAICompatibleJudge(MemoryJudge):
"""Judge backed by any OpenAI-compatible chat completions endpoint.

With ``judge_base_url=http://localhost:11434/v1`` this works against
Ollama and other local servers, so no paid API key is ever required.
"""

def __init__(self, config: MnemaConfig) -> None:
self._model = config.judge_model
self._base_url = config.judge_base_url.rstrip("/")
headers = {}
if config.judge_api_key:
headers["Authorization"] = f"Bearer {config.judge_api_key}"
self._client = httpx.AsyncClient(base_url=self._base_url, headers=headers)

async def should_forget(self, record: MemoryRecord, decay_score: float) -> bool:
"""Ask the endpoint KEEP/FORGET; fail-safe to KEEP on any problem."""
try:
response = await self._client.post(
"/chat/completions",
json={
"model": self._model,
"messages": [
{
"role": "user",
"content": build_forget_prompt(record, decay_score),
}
],
"temperature": 0,
"max_tokens": 64,
},
)
response.raise_for_status()
data = response.json()
content = data["choices"][0]["message"]["content"]
except Exception as exc:
logger.info(
"smart-forget judge call failed for memory %s; keeping it: %s",
record.id,
exc,
)
return False

verdict = parse_forget_verdict(content if isinstance(content, str) else "")
if verdict is None:
logger.info(
"smart-forget judge returned an unparseable verdict for memory "
"%s; keeping it: %r",
record.id,
content,
)
return False
logger.debug(
"smart-forget judge verdict for memory %s: %s (%r)",
record.id,
"FORGET" if verdict else "KEEP",
content,
)
return verdict

async def aclose(self) -> None:
await self._client.aclose()


def make_judge(config: MnemaConfig) -> MemoryJudge | None:
"""Build a judge from config, or ``None`` when smart mode is disabled.

When ``smart_forget_enabled`` is False (the default), no judge is
constructed and no LLM call is possible.
"""
if not config.smart_forget_enabled:
return None
return OpenAICompatibleJudge(config)


__all__ = [
"MemoryJudge",
"OpenAICompatibleJudge",
"build_forget_prompt",
"make_judge",
"parse_forget_verdict",
]
45 changes: 45 additions & 0 deletions packages/mnema-python/src/mnema/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from __future__ import annotations

import contextlib
import logging
import time
from collections.abc import Callable, Sequence

Expand All @@ -16,6 +17,7 @@
from mnema.decay import DecayParams, combine, decay_score
from mnema.embeddings import EmbeddingProvider, make_embedding
from mnema.errors import MemoryNotFoundError, ScopeError
from mnema.judge import MemoryJudge, make_judge
from mnema.models import (
Importance,
MemoryRecord,
Expand All @@ -26,6 +28,8 @@
)
from mnema.summarize import SummarizationPlan, plan_summarization

logger = logging.getLogger("mnema.service")


def _coerce_importance(value: int | Importance) -> Importance:
"""Convert any int 1–10 to the nearest :class:`Importance` level.
Expand Down Expand Up @@ -55,11 +59,13 @@ def __init__(
*,
backend: VectorBackend | None = None,
embedding: EmbeddingProvider | None = None,
judge: MemoryJudge | None = None,
) -> None:
self.config = config or load_config()
self.config.validate_runtime()
self._backend = backend or make_backend(self.config)
self._embedding = embedding or make_embedding(self.config)
self._judge = judge or make_judge(self.config)
self._decay = DecayParams(
half_life_days=self.config.decay_half_life_days,
floor=self.config.decay_floor,
Expand Down Expand Up @@ -278,6 +284,10 @@ async def apply_decay(
) -> list[MemoryRecord]:
"""Compute decay scores and (optionally) forget below ``threshold``.

When smart forgetting is enabled (``MNEMA_SMART_FORGET_ENABLED``),
each candidate is additionally vetted by the configured judge, which
can only rescue memories — never condemn new ones.

Args:
scope: Restrict the sweep to one scope, or None for all.
threshold: Decay score below which a memory is a forget candidate.
Expand All @@ -300,11 +310,44 @@ async def apply_decay(
if score <= threshold:
candidates.append(record.model_copy(update={"score": score}))

if self._judge is not None:
candidates = await self._veto_with_judge(candidates)

if not dry_run:
for c in candidates:
await self._backend.delete(c.id)
return candidates

async def _veto_with_judge(
self, candidates: list[MemoryRecord]
) -> list[MemoryRecord]:
"""Let the smart-forgetting judge veto (rescue) candidates.

The judge can only narrow the candidate list, never widen it, and a
judge failure always keeps the memory (fail-safe = KEEP).
"""
judge = self._judge
if judge is None:
return candidates
condemned: list[MemoryRecord] = []
for c in candidates:
score = float(c.score) if c.score is not None else 0.0
try:
forget = await judge.should_forget(c, score)
except Exception as exc:
# A conforming judge never raises; if one does, fail safe.
logger.warning(
"smart-forget judge raised for memory %s; keeping it: %s",
c.id,
exc,
)
forget = False
if forget:
condemned.append(c)
else:
logger.info("smart-forget judge rescued memory %s", c.id)
return condemned

async def summarize(
self,
*,
Expand All @@ -324,6 +367,8 @@ async def summarize(
async def aclose(self) -> None:
await self._embedding.aclose()
await self._backend.aclose()
if self._judge is not None:
await self._judge.aclose()

# --- private --------------------------------------------------------
async def _touch(self, record: MemoryRecord) -> None:
Expand Down
Loading
Loading