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/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