mem01-engine 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mem01/__init__.py +51 -0
- mem01/api/__init__.py +1 -0
- mem01/api/app.py +247 -0
- mem01/client.py +240 -0
- mem01/embeddings/__init__.py +7 -0
- mem01/embeddings/base.py +22 -0
- mem01/embeddings/fake.py +40 -0
- mem01/embeddings/openai_embedder.py +83 -0
- mem01/env.py +52 -0
- mem01/ids.py +15 -0
- mem01/llm/__init__.py +10 -0
- mem01/llm/anthropic.py +93 -0
- mem01/llm/base.py +30 -0
- mem01/llm/fake.py +35 -0
- mem01/llm/openai_compat.py +83 -0
- mem01/metrics.py +42 -0
- mem01/read/__init__.py +15 -0
- mem01/read/conflict.py +85 -0
- mem01/read/pack.py +73 -0
- mem01/read/rank.py +100 -0
- mem01/read/recall.py +83 -0
- mem01/read/search.py +127 -0
- mem01/runtime.py +102 -0
- mem01/store/__init__.py +20 -0
- mem01/store/base.py +95 -0
- mem01/store/factory.py +68 -0
- mem01/store/memory_store.py +211 -0
- mem01/store/postgres_store.py +511 -0
- mem01/tokens.py +19 -0
- mem01/types.py +234 -0
- mem01/write/__init__.py +6 -0
- mem01/write/apply_ops.py +356 -0
- mem01/write/extractor.py +171 -0
- mem01_engine-0.1.0.dist-info/METADATA +352 -0
- mem01_engine-0.1.0.dist-info/RECORD +37 -0
- mem01_engine-0.1.0.dist-info/WHEEL +4 -0
- mem01_engine-0.1.0.dist-info/licenses/LICENSE +21 -0
mem01/env.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Load environment variables from a local .env file.
|
|
2
|
+
|
|
3
|
+
Where to put secrets:
|
|
4
|
+
mem01/.env ← preferred (gitignored)
|
|
5
|
+
open-source/.env ← also checked (parent)
|
|
6
|
+
process environment ← always wins if already set
|
|
7
|
+
|
|
8
|
+
Copy the template:
|
|
9
|
+
cp .env.example .env
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def load_env(*, override: bool = False) -> list[Path]:
|
|
19
|
+
"""Load .env files if python-dotenv is installed. Returns paths loaded."""
|
|
20
|
+
try:
|
|
21
|
+
from dotenv import load_dotenv
|
|
22
|
+
except ImportError:
|
|
23
|
+
return []
|
|
24
|
+
|
|
25
|
+
here = Path(__file__).resolve()
|
|
26
|
+
candidates = [
|
|
27
|
+
here.parents[2] / ".env", # mem01/.env (src/mem01/env.py → mem01/)
|
|
28
|
+
here.parents[3] / ".env", # open-source/.env
|
|
29
|
+
Path.cwd() / ".env",
|
|
30
|
+
]
|
|
31
|
+
loaded: list[Path] = []
|
|
32
|
+
seen: set[Path] = set()
|
|
33
|
+
for path in candidates:
|
|
34
|
+
path = path.resolve()
|
|
35
|
+
if path in seen or not path.is_file():
|
|
36
|
+
continue
|
|
37
|
+
seen.add(path)
|
|
38
|
+
load_dotenv(path, override=override)
|
|
39
|
+
loaded.append(path)
|
|
40
|
+
return loaded
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def require_openai_key() -> str:
|
|
44
|
+
load_env()
|
|
45
|
+
key = os.environ.get("OPENAI_API_KEY", "").strip()
|
|
46
|
+
if not key:
|
|
47
|
+
raise RuntimeError(
|
|
48
|
+
"OPENAI_API_KEY not set. Create mem01/.env from .env.example:\n"
|
|
49
|
+
" cp .env.example .env\n"
|
|
50
|
+
" # edit .env and paste your key\n"
|
|
51
|
+
)
|
|
52
|
+
return key
|
mem01/ids.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Stable identifiers for beliefs.
|
|
2
|
+
|
|
3
|
+
Why a helper (not raw uuid in every call site):
|
|
4
|
+
- One place to change id format later (ULID, prefixed ids, …)
|
|
5
|
+
- Call sites stay readable: `new_belief_id()` instead of uuid boilerplate
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import uuid
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def new_belief_id() -> str:
|
|
14
|
+
"""Return a new unique belief id (UUID4 hex with bel_ prefix)."""
|
|
15
|
+
return f"bel_{uuid.uuid4().hex}"
|
mem01/llm/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""LLM adapters for the write path only (extract_ops).
|
|
2
|
+
|
|
3
|
+
Read path must never import this package for default recall.
|
|
4
|
+
Any provider works if it implements LLMClient — OpenAI-shape, Claude, etc.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from mem01.llm.base import ChatMessage, LLMClient
|
|
8
|
+
from mem01.llm.fake import FakeLLM
|
|
9
|
+
|
|
10
|
+
__all__ = ["ChatMessage", "FakeLLM", "LLMClient"]
|
mem01/llm/anthropic.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Optional Anthropic Claude client (native Messages API — not OpenAI-shaped).
|
|
2
|
+
|
|
3
|
+
Requires ANTHROPIC_API_KEY or api_key=. Uses stdlib urllib; `anthropic` package optional.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import urllib.error
|
|
11
|
+
import urllib.request
|
|
12
|
+
|
|
13
|
+
from mem01.llm.base import ChatMessage
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AnthropicLLM:
|
|
17
|
+
"""Claude via https://api.anthropic.com/v1/messages."""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
*,
|
|
22
|
+
api_key: str | None = None,
|
|
23
|
+
model: str = "claude-sonnet-4-20250514",
|
|
24
|
+
base_url: str = "https://api.anthropic.com",
|
|
25
|
+
max_tokens: int = 2048,
|
|
26
|
+
anthropic_version: str = "2023-06-01",
|
|
27
|
+
) -> None:
|
|
28
|
+
self.api_key = api_key or os.environ.get("ANTHROPIC_API_KEY") or ""
|
|
29
|
+
self.model = model
|
|
30
|
+
self.base_url = base_url.rstrip("/")
|
|
31
|
+
self.max_tokens = max_tokens
|
|
32
|
+
self.anthropic_version = anthropic_version
|
|
33
|
+
|
|
34
|
+
def complete(
|
|
35
|
+
self,
|
|
36
|
+
messages: list[ChatMessage],
|
|
37
|
+
*,
|
|
38
|
+
temperature: float = 0.0,
|
|
39
|
+
) -> str:
|
|
40
|
+
if not self.api_key:
|
|
41
|
+
raise RuntimeError(
|
|
42
|
+
"AnthropicLLM requires api_key or ANTHROPIC_API_KEY."
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
system_parts: list[str] = []
|
|
46
|
+
api_messages: list[dict] = []
|
|
47
|
+
for m in messages:
|
|
48
|
+
if m.role == "system":
|
|
49
|
+
system_parts.append(m.content)
|
|
50
|
+
else:
|
|
51
|
+
# Anthropic only allows user/assistant in messages list
|
|
52
|
+
role = m.role if m.role in ("user", "assistant") else "user"
|
|
53
|
+
api_messages.append({"role": role, "content": m.content})
|
|
54
|
+
|
|
55
|
+
if not api_messages:
|
|
56
|
+
raise ValueError("AnthropicLLM.complete needs at least one non-system message")
|
|
57
|
+
|
|
58
|
+
body: dict = {
|
|
59
|
+
"model": self.model,
|
|
60
|
+
"max_tokens": self.max_tokens,
|
|
61
|
+
"temperature": temperature,
|
|
62
|
+
"messages": api_messages,
|
|
63
|
+
}
|
|
64
|
+
if system_parts:
|
|
65
|
+
body["system"] = "\n\n".join(system_parts)
|
|
66
|
+
|
|
67
|
+
url = f"{self.base_url}/v1/messages"
|
|
68
|
+
data = json.dumps(body).encode("utf-8")
|
|
69
|
+
req = urllib.request.Request(
|
|
70
|
+
url,
|
|
71
|
+
data=data,
|
|
72
|
+
method="POST",
|
|
73
|
+
headers={
|
|
74
|
+
"Content-Type": "application/json",
|
|
75
|
+
"x-api-key": self.api_key,
|
|
76
|
+
"anthropic-version": self.anthropic_version,
|
|
77
|
+
},
|
|
78
|
+
)
|
|
79
|
+
try:
|
|
80
|
+
with urllib.request.urlopen(req, timeout=120) as resp:
|
|
81
|
+
payload = json.loads(resp.read().decode("utf-8"))
|
|
82
|
+
except urllib.error.HTTPError as e:
|
|
83
|
+
detail = e.read().decode("utf-8", errors="replace")
|
|
84
|
+
raise RuntimeError(f"Anthropic API error {e.code}: {detail}") from e
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
blocks = payload["content"]
|
|
88
|
+
texts = [b["text"] for b in blocks if b.get("type") == "text"]
|
|
89
|
+
if not texts:
|
|
90
|
+
raise KeyError("no text blocks")
|
|
91
|
+
return "\n".join(texts)
|
|
92
|
+
except (KeyError, TypeError) as e:
|
|
93
|
+
raise RuntimeError(f"unexpected Anthropic response: {payload!r}") from e
|
mem01/llm/base.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""LLMClient protocol — provider-agnostic chat completion.
|
|
2
|
+
|
|
3
|
+
Why not hardcode OpenAI or Claude:
|
|
4
|
+
- Extractor only needs text out for JSON ops
|
|
5
|
+
- OpenAI-compatible, Anthropic Messages, LiteLLM, local models all fit
|
|
6
|
+
behind the same complete() method
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Literal, Protocol, runtime_checkable
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel, Field
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ChatMessage(BaseModel):
|
|
17
|
+
role: Literal["system", "user", "assistant"]
|
|
18
|
+
content: str = Field(min_length=1)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@runtime_checkable
|
|
22
|
+
class LLMClient(Protocol):
|
|
23
|
+
def complete(
|
|
24
|
+
self,
|
|
25
|
+
messages: list[ChatMessage],
|
|
26
|
+
*,
|
|
27
|
+
temperature: float = 0.0,
|
|
28
|
+
) -> str:
|
|
29
|
+
"""Return assistant text (extractor will parse JSON from this)."""
|
|
30
|
+
...
|
mem01/llm/fake.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Scripted LLM for tests — no API keys, no network."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from mem01.llm.base import ChatMessage
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class FakeLLM:
|
|
9
|
+
"""Returns a fixed response, or the next item from a queue of responses."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, response: str | list[str] = "[]") -> None:
|
|
12
|
+
if isinstance(response, str):
|
|
13
|
+
self._responses = [response]
|
|
14
|
+
else:
|
|
15
|
+
self._responses = list(response)
|
|
16
|
+
if not self._responses:
|
|
17
|
+
self._responses = ["[]"]
|
|
18
|
+
self._index = 0
|
|
19
|
+
self.calls: list[list[ChatMessage]] = []
|
|
20
|
+
|
|
21
|
+
def complete(
|
|
22
|
+
self,
|
|
23
|
+
messages: list[ChatMessage],
|
|
24
|
+
*,
|
|
25
|
+
temperature: float = 0.0,
|
|
26
|
+
) -> str:
|
|
27
|
+
self.calls.append(list(messages))
|
|
28
|
+
# Last scripted response repeats if we run out (stable for retries)
|
|
29
|
+
idx = min(self._index, len(self._responses) - 1)
|
|
30
|
+
self._index += 1
|
|
31
|
+
return self._responses[idx]
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def call_count(self) -> int:
|
|
35
|
+
return len(self.calls)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Optional OpenAI-compatible chat client (OpenAI, many proxies, some hosts).
|
|
2
|
+
|
|
3
|
+
Not required for Claude — use anthropic.py or a router (e.g. LiteLLM) instead.
|
|
4
|
+
Only imported when you construct OpenAICompatLLM; core mem01 does not need openai.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import urllib.error
|
|
12
|
+
import urllib.request
|
|
13
|
+
|
|
14
|
+
from mem01.llm.base import ChatMessage
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class OpenAICompatLLM:
|
|
18
|
+
"""Minimal HTTP client for POST /v1/chat/completions.
|
|
19
|
+
|
|
20
|
+
Uses stdlib urllib so `openai` package is optional.
|
|
21
|
+
Set base_url to any OpenAI-compatible endpoint.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
*,
|
|
27
|
+
api_key: str | None = None,
|
|
28
|
+
model: str = "gpt-5.6-sol",
|
|
29
|
+
base_url: str = "https://api.openai.com/v1",
|
|
30
|
+
) -> None:
|
|
31
|
+
if api_key is None:
|
|
32
|
+
try:
|
|
33
|
+
from mem01.env import load_env
|
|
34
|
+
|
|
35
|
+
load_env()
|
|
36
|
+
except Exception:
|
|
37
|
+
pass
|
|
38
|
+
self.api_key = os.environ.get("OPENAI_API_KEY") or ""
|
|
39
|
+
else:
|
|
40
|
+
# Explicit "" means "no key" (tests); do not fall back to env
|
|
41
|
+
self.api_key = api_key
|
|
42
|
+
self.model = model
|
|
43
|
+
self.base_url = base_url.rstrip("/")
|
|
44
|
+
|
|
45
|
+
def complete(
|
|
46
|
+
self,
|
|
47
|
+
messages: list[ChatMessage],
|
|
48
|
+
*,
|
|
49
|
+
temperature: float = 0.0,
|
|
50
|
+
) -> str:
|
|
51
|
+
if not self.api_key:
|
|
52
|
+
raise RuntimeError(
|
|
53
|
+
"OpenAICompatLLM requires api_key or OPENAI_API_KEY. "
|
|
54
|
+
"For Claude use AnthropicLLM (or another LLMClient adapter)."
|
|
55
|
+
)
|
|
56
|
+
url = f"{self.base_url}/chat/completions"
|
|
57
|
+
body = {
|
|
58
|
+
"model": self.model,
|
|
59
|
+
"messages": [{"role": m.role, "content": m.content} for m in messages],
|
|
60
|
+
}
|
|
61
|
+
if self.model != "gpt-5.6-sol":
|
|
62
|
+
body["temperature"] = temperature
|
|
63
|
+
data = json.dumps(body).encode("utf-8")
|
|
64
|
+
req = urllib.request.Request(
|
|
65
|
+
url,
|
|
66
|
+
data=data,
|
|
67
|
+
method="POST",
|
|
68
|
+
headers={
|
|
69
|
+
"Content-Type": "application/json",
|
|
70
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
71
|
+
},
|
|
72
|
+
)
|
|
73
|
+
try:
|
|
74
|
+
with urllib.request.urlopen(req, timeout=60) as resp:
|
|
75
|
+
payload = json.loads(resp.read().decode("utf-8"))
|
|
76
|
+
except urllib.error.HTTPError as e:
|
|
77
|
+
detail = e.read().decode("utf-8", errors="replace")
|
|
78
|
+
raise RuntimeError(f"OpenAI-compatible API error {e.code}: {detail}") from e
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
return payload["choices"][0]["message"]["content"]
|
|
82
|
+
except (KeyError, IndexError, TypeError) as e:
|
|
83
|
+
raise RuntimeError(f"unexpected OpenAI-compatible response: {payload!r}") from e
|
mem01/metrics.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Lightweight in-process metrics for cost/latency gates.
|
|
2
|
+
|
|
3
|
+
Why:
|
|
4
|
+
- Product success bar needs tokens_used, latency, llm_calls — not accuracy alone
|
|
5
|
+
- No external deps (Prometheus later); attach numbers to recall/remember results
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import time
|
|
11
|
+
from contextlib import contextmanager
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Iterator
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class TimerResult:
|
|
18
|
+
latency_ms: float
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@contextmanager
|
|
22
|
+
def timer() -> Iterator[TimerResult]:
|
|
23
|
+
"""Context manager that records wall-clock latency in milliseconds."""
|
|
24
|
+
result = TimerResult(latency_ms=0.0)
|
|
25
|
+
start = time.perf_counter()
|
|
26
|
+
try:
|
|
27
|
+
yield result
|
|
28
|
+
finally:
|
|
29
|
+
result.latency_ms = (time.perf_counter() - start) * 1000.0
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class Counter:
|
|
34
|
+
"""Simple named counters (llm_calls, etc.)."""
|
|
35
|
+
|
|
36
|
+
values: dict[str, float] = field(default_factory=dict)
|
|
37
|
+
|
|
38
|
+
def incr(self, name: str, amount: float = 1.0) -> None:
|
|
39
|
+
self.values[name] = self.values.get(name, 0.0) + amount
|
|
40
|
+
|
|
41
|
+
def get(self, name: str, default: float = 0.0) -> float:
|
|
42
|
+
return self.values.get(name, default)
|
mem01/read/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Read pipeline: search → conflict → rank → pack (no LLM on default path)."""
|
|
2
|
+
|
|
3
|
+
from mem01.read.conflict import filter_conflicts
|
|
4
|
+
from mem01.read.pack import pack_beliefs
|
|
5
|
+
from mem01.read.rank import rank_candidates
|
|
6
|
+
from mem01.read.recall import recall
|
|
7
|
+
from mem01.read.search import search_beliefs
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"filter_conflicts",
|
|
11
|
+
"pack_beliefs",
|
|
12
|
+
"rank_candidates",
|
|
13
|
+
"recall",
|
|
14
|
+
"search_beliefs",
|
|
15
|
+
]
|
mem01/read/conflict.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Conflict-safe filtering on read candidates — no LLM.
|
|
2
|
+
|
|
3
|
+
Why this exists (product wedge):
|
|
4
|
+
- Even with SUPERSEDE on write, near-duplicates or slipped ADDs can both be active
|
|
5
|
+
- Default recall must not inject two opposite truths when we can detect them
|
|
6
|
+
- Deterministic rules only — keeps hot path cheap (mem0-class latency/$)
|
|
7
|
+
|
|
8
|
+
v1 rules (mode=current):
|
|
9
|
+
1. Drop non-active statuses
|
|
10
|
+
2. Drop outside validity window (valid_from / valid_to)
|
|
11
|
+
3. If multiple candidates share metadata topic_key, keep best (score, then confidence, then newer)
|
|
12
|
+
|
|
13
|
+
mode=history (audit / temporal questions):
|
|
14
|
+
- Keep active + superseded (+ optional invalidated)
|
|
15
|
+
- Do NOT collapse topic_key to one winner — both SF (active) and NY (superseded) surface
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from datetime import datetime
|
|
21
|
+
from typing import Literal
|
|
22
|
+
|
|
23
|
+
from mem01.types import BeliefStatus, ScoredBelief, utc_now
|
|
24
|
+
|
|
25
|
+
HistoryMode = Literal["current", "history"]
|
|
26
|
+
|
|
27
|
+
_HISTORY_STATUSES = frozenset(
|
|
28
|
+
{
|
|
29
|
+
BeliefStatus.ACTIVE,
|
|
30
|
+
BeliefStatus.SUPERSEDED,
|
|
31
|
+
BeliefStatus.INVALIDATED,
|
|
32
|
+
}
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def filter_conflicts(
|
|
37
|
+
candidates: list[ScoredBelief],
|
|
38
|
+
*,
|
|
39
|
+
at: datetime | None = None,
|
|
40
|
+
mode: HistoryMode = "current",
|
|
41
|
+
) -> list[ScoredBelief]:
|
|
42
|
+
"""Return a filtered subset of *candidates* (order preserved among survivors)."""
|
|
43
|
+
when = at or utc_now()
|
|
44
|
+
if mode == "history":
|
|
45
|
+
return [c for c in candidates if c.belief.status in _HISTORY_STATUSES]
|
|
46
|
+
current = [c for c in candidates if _is_eligible(c, when)]
|
|
47
|
+
return _dedupe_by_topic_key(current)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _is_eligible(scored: ScoredBelief, when: datetime) -> bool:
|
|
51
|
+
b = scored.belief
|
|
52
|
+
if b.status != BeliefStatus.ACTIVE:
|
|
53
|
+
return False
|
|
54
|
+
# Reuse Belief.is_current for validity window
|
|
55
|
+
return b.is_current(when)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _dedupe_by_topic_key(candidates: list[ScoredBelief]) -> list[ScoredBelief]:
|
|
59
|
+
"""For each topic_key, keep a single winner; beliefs without topic_key all pass."""
|
|
60
|
+
best_by_topic: dict[str, ScoredBelief] = {}
|
|
61
|
+
no_topic: list[ScoredBelief] = []
|
|
62
|
+
|
|
63
|
+
for c in candidates:
|
|
64
|
+
key = c.belief.metadata.get("topic_key")
|
|
65
|
+
if not key or not isinstance(key, str):
|
|
66
|
+
no_topic.append(c)
|
|
67
|
+
continue
|
|
68
|
+
prev = best_by_topic.get(key)
|
|
69
|
+
if prev is None or _better(c, prev):
|
|
70
|
+
best_by_topic[key] = c
|
|
71
|
+
|
|
72
|
+
# Preserve original relative order: walk candidates, emit if selected
|
|
73
|
+
winners = set(id(v) for v in best_by_topic.values())
|
|
74
|
+
winners.update(id(c) for c in no_topic)
|
|
75
|
+
return [c for c in candidates if id(c) in winners]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _better(a: ScoredBelief, b: ScoredBelief) -> bool:
|
|
79
|
+
"""True if *a* should replace *b* for the same topic_key."""
|
|
80
|
+
if a.score != b.score:
|
|
81
|
+
return a.score > b.score
|
|
82
|
+
if a.belief.confidence != b.belief.confidence:
|
|
83
|
+
return a.belief.confidence > b.belief.confidence
|
|
84
|
+
# Newer updated_at wins
|
|
85
|
+
return a.belief.updated_at >= b.belief.updated_at
|
mem01/read/pack.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Budgeted packing — fit memories under max_memory_tokens.
|
|
2
|
+
|
|
3
|
+
Why first-class budgets:
|
|
4
|
+
- Product constraint: same or better quality at mem0-class token cost
|
|
5
|
+
- Unlimited top-k is how memory becomes expensive and slow for the agent LLM
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from mem01.tokens import estimate_tokens
|
|
11
|
+
from mem01.types import Belief, BeliefStatus, PackedMemory, ScoredBelief
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def format_belief_line(belief: Belief, *, show_status: bool = False) -> str:
|
|
15
|
+
"""One line injected into the agent prompt for a belief.
|
|
16
|
+
|
|
17
|
+
History mode tags status so the agent can tell current vs past facts apart
|
|
18
|
+
(e.g. medical audit: superseded allergy still visible, clearly labeled).
|
|
19
|
+
"""
|
|
20
|
+
if show_status or belief.status != BeliefStatus.ACTIVE:
|
|
21
|
+
return f"- [{belief.status.value}] {belief.content}"
|
|
22
|
+
return f"- {belief.content}"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def pack_beliefs(
|
|
26
|
+
candidates: list[ScoredBelief],
|
|
27
|
+
*,
|
|
28
|
+
max_memory_tokens: int = 800,
|
|
29
|
+
candidate_count: int | None = None,
|
|
30
|
+
show_status: bool = False,
|
|
31
|
+
) -> PackedMemory:
|
|
32
|
+
"""Greedy pack highest-score candidates until the token budget is hit.
|
|
33
|
+
|
|
34
|
+
Assumes *candidates* are already conflict-filtered and preferably ranked
|
|
35
|
+
highest-first. If not ranked, we sort by score descending here.
|
|
36
|
+
"""
|
|
37
|
+
if max_memory_tokens <= 0:
|
|
38
|
+
return PackedMemory(
|
|
39
|
+
beliefs=[],
|
|
40
|
+
text="",
|
|
41
|
+
tokens_used=0,
|
|
42
|
+
max_memory_tokens=max_memory_tokens,
|
|
43
|
+
candidate_count=candidate_count if candidate_count is not None else len(candidates),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
ordered = sorted(candidates, key=lambda c: c.score, reverse=True)
|
|
47
|
+
chosen: list[Belief] = []
|
|
48
|
+
lines: list[str] = []
|
|
49
|
+
tokens_used = 0
|
|
50
|
+
|
|
51
|
+
for c in ordered:
|
|
52
|
+
line = format_belief_line(c.belief, show_status=show_status)
|
|
53
|
+
# +1 for newline between lines
|
|
54
|
+
extra = estimate_tokens(line if not lines else "\n" + line)
|
|
55
|
+
if tokens_used + extra > max_memory_tokens:
|
|
56
|
+
continue # try smaller later items? v1 skip; greedy by score only
|
|
57
|
+
# Strict: if this one doesn't fit, skip it (don't stop entirely —
|
|
58
|
+
# a shorter lower-score line might still fit)
|
|
59
|
+
lines.append(line)
|
|
60
|
+
chosen.append(c.belief)
|
|
61
|
+
tokens_used += extra
|
|
62
|
+
|
|
63
|
+
text = "\n".join(lines)
|
|
64
|
+
# Recompute exact estimate on final text for honest metrics
|
|
65
|
+
tokens_used = estimate_tokens(text) if text else 0
|
|
66
|
+
|
|
67
|
+
return PackedMemory(
|
|
68
|
+
beliefs=chosen,
|
|
69
|
+
text=text,
|
|
70
|
+
tokens_used=tokens_used,
|
|
71
|
+
max_memory_tokens=max_memory_tokens,
|
|
72
|
+
candidate_count=candidate_count if candidate_count is not None else len(candidates),
|
|
73
|
+
)
|
mem01/read/rank.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Rank memory candidates for packing.
|
|
2
|
+
|
|
3
|
+
Why not pure cosine alone:
|
|
4
|
+
- Recency and confidence matter for long-lived agents (stale near-matches lose)
|
|
5
|
+
- Single tunable score keeps pack() simple: sort desc, greedy fill
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import math
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
|
|
13
|
+
from mem01.types import ScoredBelief, utc_now
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def score_candidate(
|
|
17
|
+
scored: ScoredBelief,
|
|
18
|
+
*,
|
|
19
|
+
now: datetime | None = None,
|
|
20
|
+
similarity_weight: float = 0.6,
|
|
21
|
+
recency_weight: float = 0.25,
|
|
22
|
+
confidence_weight: float = 0.15,
|
|
23
|
+
) -> float:
|
|
24
|
+
"""Combine similarity, recency, and confidence into one rank score."""
|
|
25
|
+
when = now or utc_now()
|
|
26
|
+
sim = max(0.0, float(scored.score)) # cosine can be negative; floor for ranking
|
|
27
|
+
conf = max(0.0, min(1.0, scored.belief.confidence))
|
|
28
|
+
|
|
29
|
+
# Recency: half-life ~30 days (smooth decay, never zero)
|
|
30
|
+
age_seconds = max(0.0, (when - scored.belief.updated_at).total_seconds())
|
|
31
|
+
half_life = 30.0 * 24 * 3600
|
|
32
|
+
recency = math.exp(-math.log(2) * age_seconds / half_life)
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
similarity_weight * sim
|
|
36
|
+
+ recency_weight * recency
|
|
37
|
+
+ confidence_weight * conf
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def rank_candidates(
|
|
42
|
+
candidates: list[ScoredBelief],
|
|
43
|
+
*,
|
|
44
|
+
now: datetime | None = None,
|
|
45
|
+
) -> list[ScoredBelief]:
|
|
46
|
+
"""Return candidates sorted by composite score (highest first).
|
|
47
|
+
|
|
48
|
+
Note: returns new ScoredBeliefs with .score set to the composite rank
|
|
49
|
+
so downstream pack can sort on score alone if needed. Original similarity
|
|
50
|
+
is replaced — call rank only after conflict filter.
|
|
51
|
+
"""
|
|
52
|
+
when = now or utc_now()
|
|
53
|
+
ranked: list[ScoredBelief] = []
|
|
54
|
+
for c in candidates:
|
|
55
|
+
s = score_candidate(c, now=when)
|
|
56
|
+
ranked.append(ScoredBelief(belief=c.belief, score=s))
|
|
57
|
+
ranked.sort(key=lambda x: x.score, reverse=True)
|
|
58
|
+
return ranked
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _jaccard(a: set[str], b: set[str]) -> float:
|
|
62
|
+
if not a or not b:
|
|
63
|
+
return 0.0
|
|
64
|
+
return len(a & b) / len(a | b)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def mmr_select(
|
|
68
|
+
ranked: list[ScoredBelief],
|
|
69
|
+
*,
|
|
70
|
+
lambda_weight: float = 0.75,
|
|
71
|
+
) -> list[ScoredBelief]:
|
|
72
|
+
"""Greedy maximal-marginal-relevance reorder to reduce near-duplicate picks.
|
|
73
|
+
|
|
74
|
+
Multi-hop and listing questions need *different* beliefs packed together;
|
|
75
|
+
pure score ordering lets one topic cluster crowd out the rest of the
|
|
76
|
+
budget. Uses token Jaccard between belief contents so it works with any
|
|
77
|
+
store and never touches embeddings on the hot path. O(n^2), n <= recall k.
|
|
78
|
+
"""
|
|
79
|
+
if len(ranked) <= 2:
|
|
80
|
+
return list(ranked)
|
|
81
|
+
|
|
82
|
+
from mem01.read.search import tokenize
|
|
83
|
+
|
|
84
|
+
tokens = {c.belief.id: set(tokenize(c.belief.content)) for c in ranked}
|
|
85
|
+
remaining = list(ranked)
|
|
86
|
+
selected: list[ScoredBelief] = [remaining.pop(0)]
|
|
87
|
+
while remaining:
|
|
88
|
+
best_idx = 0
|
|
89
|
+
best_val = float("-inf")
|
|
90
|
+
for i, cand in enumerate(remaining):
|
|
91
|
+
redundancy = max(
|
|
92
|
+
_jaccard(tokens[cand.belief.id], tokens[s.belief.id])
|
|
93
|
+
for s in selected
|
|
94
|
+
)
|
|
95
|
+
val = lambda_weight * cand.score - (1.0 - lambda_weight) * redundancy
|
|
96
|
+
if val > best_val:
|
|
97
|
+
best_val = val
|
|
98
|
+
best_idx = i
|
|
99
|
+
selected.append(remaining.pop(best_idx))
|
|
100
|
+
return selected
|