voltmem 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.
voltmem/__init__.py ADDED
@@ -0,0 +1,68 @@
1
+ """
2
+ VoltMem — Volatility-Adjusted Persistent Memory Layer
3
+ ======================================================
4
+
5
+ A pluggable memory layer for LLM applications and any system that needs
6
+ persistent context with principled staleness handling.
7
+
8
+ Core idea: not all memories age at the same rate. Stable knowledge
9
+ (personality traits, core preferences) should be protected strongly
10
+ against overwriting. Volatile knowledge (current tasks, emotional context,
11
+ location) should be held loosely and updated readily.
12
+
13
+ Quick start:
14
+ from voltmem import MemoryLayer
15
+
16
+ mem = MemoryLayer("my_app.db")
17
+
18
+ mem.write("User prefers direct communication", domain="core_preference")
19
+ mem.write("User is currently job hunting", domain="current_project")
20
+
21
+ # New observation — may or may not update existing memory
22
+ result = mem.observe(
23
+ content="User mentioned they accepted a job offer",
24
+ domain="current_project",
25
+ mismatch_magnitude=0.8,
26
+ source="explicit_statement",
27
+ )
28
+ print(result.action) # "audited" — volatile domain, high mismatch → updated
29
+
30
+ # Retrieve relevant memories for a query
31
+ results = mem.retrieve("career and work context")
32
+ for item, score in zip(results.items, results.scores):
33
+ print(f"[{score:.2f}] {item.content}")
34
+ """
35
+
36
+ from .memory import MemoryLayer, WriteResult, RetrieveResult
37
+ from .client import Memory, create_memory
38
+ from .domains import MemoryItem, DOMAIN_VOLATILITY, SOURCE_RELIABILITY
39
+ from .embeddings import EmbeddingSimilarity
40
+ from .extract import HeuristicExtractor, LLMExtractor, HeuristicFactExtractor, LLMFactExtractor
41
+ from .scoring import (
42
+ escalation_score,
43
+ retrieval_score,
44
+ staleness,
45
+ protection_weight,
46
+ )
47
+
48
+ __all__ = [
49
+ "Memory",
50
+ "create_memory",
51
+ "MemoryLayer",
52
+ "WriteResult",
53
+ "RetrieveResult",
54
+ "MemoryItem",
55
+ "DOMAIN_VOLATILITY",
56
+ "SOURCE_RELIABILITY",
57
+ "EmbeddingSimilarity",
58
+ "HeuristicExtractor",
59
+ "LLMExtractor",
60
+ "HeuristicFactExtractor",
61
+ "LLMFactExtractor",
62
+ "escalation_score",
63
+ "retrieval_score",
64
+ "staleness",
65
+ "protection_weight",
66
+ ]
67
+
68
+ __version__ = "0.1.0"
voltmem/client.py ADDED
@@ -0,0 +1,239 @@
1
+ """
2
+ Product-facing API — Mem0-shaped surface over MemoryLayer.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from pathlib import Path
8
+ from typing import Any, Callable, Optional, Union
9
+
10
+ from .embeddings import EmbeddingSimilarity
11
+ from .extract import HeuristicFactExtractor, LLMFactExtractor
12
+ from .memory import MemoryLayer, RetrieveResult, WriteResult
13
+
14
+ Message = dict[str, str]
15
+ AddInput = Union[str, Message, list[Message]]
16
+
17
+
18
+ def create_memory(
19
+ db_path: str | Path = "voltmem.db",
20
+ user_id: str = "default",
21
+ *,
22
+ embeddings: bool = True,
23
+ verbose: bool = False,
24
+ llm_extract: bool = False,
25
+ llm_domain: bool = False,
26
+ **kwargs: Any,
27
+ ) -> "Memory":
28
+ """Create a production-ready memory instance with sensible defaults.
29
+
30
+ Auto-detects an embedding backend when ``embeddings=True``
31
+ (sentence-transformers → Ollama → offline hashing fallback).
32
+
33
+ Parameters
34
+ ----------
35
+ llm_extract : bool
36
+ Use Ollama to extract atomic facts from conversation message lists.
37
+ llm_domain : bool
38
+ Use Ollama for domain classification and contradiction detection
39
+ inside ``remember()`` (passed to ``MemoryLayer`` as ``extractor``).
40
+ """
41
+ similarity_fn = kwargs.pop("similarity_fn", None)
42
+ if embeddings and similarity_fn is None:
43
+ similarity_fn = EmbeddingSimilarity(verbose=verbose)
44
+
45
+ fact_extractor = LLMFactExtractor() if llm_extract else HeuristicFactExtractor()
46
+
47
+ layer_kwargs = dict(kwargs)
48
+ if llm_domain:
49
+ from .extract import LLMExtractor
50
+ layer_kwargs["extractor"] = LLMExtractor()
51
+
52
+ return Memory(
53
+ user_id=user_id,
54
+ db_path=db_path,
55
+ similarity_fn=similarity_fn,
56
+ fact_extractor=fact_extractor,
57
+ **layer_kwargs,
58
+ )
59
+
60
+
61
+ class Memory:
62
+ """Current-truth memory for LLM agents.
63
+
64
+ Familiar ``add`` / ``search`` surface; VoltMem volatility engine underneath.
65
+ Volatile facts update; stable facts resist corruption; stale volatile memories
66
+ rank lower at retrieval.
67
+ """
68
+
69
+ def __init__(
70
+ self,
71
+ user_id: str = "default",
72
+ db_path: str | Path = ":memory:",
73
+ *,
74
+ layer: Optional[MemoryLayer] = None,
75
+ similarity_fn: Optional[Callable[[str, str], float]] = None,
76
+ fact_extractor: Optional[HeuristicFactExtractor] = None,
77
+ **kwargs: Any,
78
+ ):
79
+ self.user_id = user_id
80
+ self._fact_extractor = fact_extractor or HeuristicFactExtractor()
81
+ if layer is not None:
82
+ self._layer = (
83
+ layer if layer.namespace == user_id else layer.for_user(user_id)
84
+ )
85
+ self._owns_layer = False
86
+ else:
87
+ self._layer = MemoryLayer(
88
+ db_path,
89
+ similarity_fn=similarity_fn,
90
+ namespace=user_id,
91
+ **kwargs,
92
+ )
93
+ self._owns_layer = True
94
+
95
+ @property
96
+ def layer(self) -> MemoryLayer:
97
+ """Low-level ``MemoryLayer`` for advanced control."""
98
+ return self._layer
99
+
100
+ def add(
101
+ self,
102
+ data: AddInput,
103
+ *,
104
+ source: str = "explicit_statement",
105
+ extract: bool | None = None,
106
+ ) -> Union[dict[str, Any], list[dict[str, Any]]]:
107
+ """Store a fact or conversation turn(s).
108
+
109
+ Accepts a plain string, one message dict ``{"role": ..., "content": ...}``,
110
+ or a list of message dicts. For message lists, ``extract=True`` (default)
111
+ pulls atomic user facts before storing.
112
+ """
113
+ if isinstance(data, str):
114
+ return self._format_write(self._layer.remember(data, source=source))
115
+ if isinstance(data, dict):
116
+ return self._add_message(data, source=source)
117
+ if isinstance(data, list):
118
+ if not data:
119
+ return []
120
+ do_extract = extract if extract is not None else True
121
+ if do_extract:
122
+ return self._add_extracted_facts(data, source=source)
123
+ out = []
124
+ for msg in data:
125
+ if not isinstance(msg, dict):
126
+ raise TypeError("each message must be a dict with role/content")
127
+ result = self._add_message(msg, source=source)
128
+ if result is not None:
129
+ out.append(result)
130
+ return out
131
+ raise TypeError("add() expects str, message dict, or list of message dicts")
132
+
133
+ def search(
134
+ self,
135
+ query: str,
136
+ *,
137
+ limit: int = 5,
138
+ min_score: float = 0.0,
139
+ ) -> list[dict[str, Any]]:
140
+ """Retrieve current-truth memories ranked by relevance and freshness."""
141
+ result = self._layer.retrieve(
142
+ query, top_k=limit, min_score=min_score)
143
+ return self._format_retrieve(result)
144
+
145
+ def get_all(self) -> list[dict[str, Any]]:
146
+ """Return all active memories for this user."""
147
+ return [
148
+ self._format_item(item)
149
+ for item in self._layer._active()
150
+ ]
151
+
152
+ def get(self, memory_id: str) -> Optional[dict[str, Any]]:
153
+ """Return one memory by id, or None."""
154
+ info = self._layer.inspect(memory_id)
155
+ if info.get("error"):
156
+ return None
157
+ return info
158
+
159
+ def delete(self, memory_id: str) -> bool:
160
+ """Remove a memory by id. Returns True if deleted."""
161
+ item = self._layer._store.get(memory_id)
162
+ if not item or item.namespace != self.user_id:
163
+ return False
164
+ self._layer._store.delete(memory_id, namespace=self.user_id)
165
+ return True
166
+
167
+ def clear(self) -> None:
168
+ """Remove all memories for this user."""
169
+ self._layer.clear()
170
+
171
+ def summary(self) -> dict[str, Any]:
172
+ return self._layer.summary()
173
+
174
+ def close(self) -> None:
175
+ if self._owns_layer:
176
+ self._layer.close()
177
+
178
+ def __enter__(self) -> "Memory":
179
+ return self
180
+
181
+ def __exit__(self, *_: Any) -> None:
182
+ self.close()
183
+
184
+ # ── helpers ───────────────────────────────────────────────────────────────
185
+
186
+ def _add_extracted_facts(
187
+ self, messages: list[Message], *, source: str
188
+ ) -> list[dict[str, Any]]:
189
+ facts = self._fact_extractor.extract(messages)
190
+ out: list[dict[str, Any]] = []
191
+ for fact in facts:
192
+ src = fact.source or source
193
+ if fact.domain:
194
+ result = self._layer.remember(
195
+ fact.content, domain=fact.domain, source=src)
196
+ else:
197
+ result = self._layer.remember(fact.content, source=src)
198
+ out.append(self._format_write(result))
199
+ return out
200
+
201
+ def _add_message(
202
+ self, msg: Message, *, source: str
203
+ ) -> Optional[dict[str, Any]]:
204
+ role = msg.get("role", "user")
205
+ content = str(msg.get("content", "")).strip()
206
+ if not content:
207
+ return None
208
+ src = source if role == "user" else "assistant_response"
209
+ return self._format_write(self._layer.remember(content, source=src))
210
+
211
+ @staticmethod
212
+ def _format_write(result: WriteResult) -> dict[str, Any]:
213
+ item = result.item
214
+ return {
215
+ "id": item.id,
216
+ "memory": item.content,
217
+ "action": result.action,
218
+ "domain": item.domain,
219
+ "detail": result.detail,
220
+ }
221
+
222
+ @staticmethod
223
+ def _format_item(item: Any) -> dict[str, Any]:
224
+ return {
225
+ "id": item.id,
226
+ "memory": item.content,
227
+ "domain": item.domain,
228
+ "source": item.source,
229
+ "created_at": item.created_at,
230
+ "last_confirmed_at": item.last_confirmed_at,
231
+ }
232
+
233
+ def _format_retrieve(self, result: RetrieveResult) -> list[dict[str, Any]]:
234
+ out = []
235
+ for item, score in zip(result.items, result.scores):
236
+ row = self._format_item(item)
237
+ row["score"] = round(score, 4)
238
+ out.append(row)
239
+ return out
voltmem/domains.py ADDED
@@ -0,0 +1,102 @@
1
+ """
2
+ Domain volatility priors.
3
+
4
+ V_d is the expected rate of change for a given memory domain.
5
+ Higher = more volatile = weaker protection = lower mismatch threshold
6
+ to trigger an audit/update.
7
+
8
+ Scale: 0.0 (never changes) → 1.0 (changes very fast).
9
+ These are defaults; callers can override per-item or register custom domains.
10
+ """
11
+
12
+ from dataclasses import dataclass, field
13
+ from typing import Optional
14
+
15
+ # ── built-in domain priors ────────────────────────────────────────────────────
16
+
17
+ DOMAIN_VOLATILITY: dict[str, float] = {
18
+ # Slow-changing — protect hard
19
+ "personality_trait": 0.05, # how the user fundamentally operates
20
+ "core_preference": 0.08, # deep aesthetic / communication preferences
21
+ "biographical": 0.10, # birthplace, native language, background
22
+ "long_term_goal": 0.15, # career direction, life goals
23
+
24
+ # Medium — moderate protection
25
+ "professional_context": 0.30, # job, company, role (changes every few years)
26
+ "skill": 0.25, # competencies the user has
27
+ "relationship": 0.35, # people they work with / are close to
28
+
29
+ # Fast-changing — hold loosely
30
+ "current_project": 0.55, # what they're working on right now
31
+ "stated_preference": 0.45, # preferences stated in this period
32
+ "opinion": 0.50, # views that may shift
33
+ "location": 0.60, # where they are / living situation
34
+ "emotional_context": 0.80, # current mood, stress level
35
+ "current_task": 0.90, # immediate to-do
36
+ "transient_fact": 0.95, # anything clearly moment-specific
37
+ }
38
+
39
+ # Domains that usually hold a single "current truth" slot (mood, city, task).
40
+ # remember() uses a lower in-slot linking threshold for these.
41
+ SLOT_DOMAINS: frozenset[str] = frozenset({
42
+ "emotional_context",
43
+ "location",
44
+ "current_task",
45
+ "transient_fact",
46
+ })
47
+
48
+ # Related domains searched together when linking a new statement to existing
49
+ # memories (e.g. paraphrased prefs split across classifiers).
50
+ DOMAIN_SIBLINGS: dict[str, frozenset[str]] = {
51
+ "core_preference": frozenset({"core_preference", "stated_preference"}),
52
+ "stated_preference": frozenset({"core_preference", "stated_preference"}),
53
+ }
54
+
55
+ # Minimum semantic overlap to link a lone item in a volatile slot when the
56
+ # volatility-scaled threshold is not quite met.
57
+ SLOT_LINK_FLOOR: float = 0.30
58
+
59
+ # Source reliability weights (R_t in the escalation equation)
60
+ # Higher = more trustworthy signal for both write and mismatch detection
61
+ SOURCE_RELIABILITY: dict[str, float] = {
62
+ "explicit_statement": 1.0, # user directly stated it
63
+ "repeated_confirmation":1.2, # confirmed across multiple turns
64
+ "strong_inference": 0.7, # clearly implied
65
+ "weak_inference": 0.4, # loosely inferred from behaviour
66
+ "system_generated": 0.3, # injected by the wrapping system
67
+ }
68
+
69
+
70
+ @dataclass
71
+ class MemoryItem:
72
+ """A single unit of persistent memory."""
73
+ id: str
74
+ content: str # the actual stored fact/preference
75
+ domain: str # one of DOMAIN_VOLATILITY keys
76
+ source: str # one of SOURCE_RELIABILITY keys
77
+ namespace: str = "default" # tenant/user isolation key
78
+
79
+ # Equation terms — updated over time
80
+ repetition_count: int = 1 # C: how many times confirmed
81
+ volatility_ema: float = -1.0 # V_d EMA; -1 means use domain prior
82
+ mismatch_count: int = 0 # cumulative mismatch events
83
+ goal_delta: float = 0.0 # G_t: positive = helps goal, neg = hurts
84
+
85
+ # Timestamps (unix seconds)
86
+ created_at: float = 0.0
87
+ last_confirmed_at:float = 0.0
88
+ last_audited_at: float = 0.0
89
+
90
+ # Metadata
91
+ tags: list[str] = field(default_factory=list)
92
+ superseded_by: Optional[str] = None # id of item that replaced this one
93
+
94
+ @property
95
+ def is_active(self) -> bool:
96
+ return self.superseded_by is None
97
+
98
+ @property
99
+ def effective_volatility(self) -> float:
100
+ if self.volatility_ema >= 0:
101
+ return self.volatility_ema
102
+ return DOMAIN_VOLATILITY.get(self.domain, 0.5)
voltmem/embeddings.py ADDED
@@ -0,0 +1,161 @@
1
+ """
2
+ Pluggable embedding-based similarity for VoltMem retrieval.
3
+ ==========================================================
4
+
5
+ VoltMem's contribution is the volatility/freshness weighting applied *on top of*
6
+ a semantic-similarity signal. The core library ships with a dependency-free
7
+ keyword scorer; this module provides a drop-in embedding scorer for real semantic
8
+ retrieval:
9
+
10
+ from voltmem import MemoryLayer
11
+ from voltmem.embeddings import EmbeddingSimilarity
12
+
13
+ sim = EmbeddingSimilarity() # auto-detects the best available backend
14
+ mem = MemoryLayer(":memory:", similarity_fn=sim)
15
+
16
+ Backend auto-detection (first that works wins), overridable via backend=...:
17
+
18
+ 1. "sentence-transformers" — local, high quality (all-MiniLM-L6-v2 by default)
19
+ 2. "ollama" — local daemon at http://localhost:11434
20
+ (nomic-embed-text by default)
21
+ 3. "hashing" — deterministic, dependency-free fallback so the
22
+ library and benchmarks ALWAYS run offline. Lower
23
+ quality (bag-of-hashed-tokens), but reproducible.
24
+
25
+ The returned callable maps a (query, content) pair to a similarity in [0, 1]
26
+ (cosine similarity rescaled from [-1, 1]). Embeddings are cached per text.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import hashlib
32
+ import json
33
+ import math
34
+ import os
35
+ import time
36
+ import urllib.error
37
+ import urllib.request
38
+ from typing import Callable, Optional
39
+
40
+
41
+ def _cosine(a: list[float], b: list[float]) -> float:
42
+ dot = sum(x * y for x, y in zip(a, b))
43
+ na = math.sqrt(sum(x * x for x in a))
44
+ nb = math.sqrt(sum(y * y for y in b))
45
+ if na == 0.0 or nb == 0.0:
46
+ return 0.0
47
+ return dot / (na * nb)
48
+
49
+
50
+ class EmbeddingSimilarity:
51
+ """Callable (query, content) -> similarity in [0, 1] backed by embeddings."""
52
+
53
+ def __init__(
54
+ self,
55
+ backend: Optional[str] = None,
56
+ model: Optional[str] = None,
57
+ ollama_url: str = "http://localhost:11434",
58
+ dim: int = 256,
59
+ verbose: bool = False,
60
+ ):
61
+ self.model = model
62
+ self.ollama_url = ollama_url.rstrip("/")
63
+ self.dim = dim
64
+ self.verbose = verbose
65
+ self._cache: dict[str, list[float]] = {}
66
+ self._embed_fn: Callable[[str], list[float]]
67
+
68
+ # Try backends in order; a backend counts as usable only if it can
69
+ # actually produce an embedding (a running Ollama daemon with no embed
70
+ # model pulled, for example, must NOT win — it 404s on first use).
71
+ candidates = [backend] if backend else [
72
+ "sentence-transformers", "ollama", "hashing"]
73
+ errors = []
74
+ user_model = model
75
+ for cand in candidates:
76
+ try:
77
+ self.model = user_model # reset per attempt; don't leak labels
78
+ fn = self._make_backend(cand)
79
+ _ = fn("voltmem backend probe") # trial embed; raises if broken
80
+ self.backend = cand
81
+ self._embed_fn = fn
82
+ if self.verbose:
83
+ print(f"[EmbeddingSimilarity] using backend={cand} "
84
+ f"model={self.model}")
85
+ return
86
+ except Exception as e: # noqa: BLE001
87
+ errors.append(f"{cand}: {type(e).__name__}: {e}")
88
+ if self.verbose:
89
+ print(f"[EmbeddingSimilarity] backend {cand} unavailable "
90
+ f"({type(e).__name__})")
91
+ # hashing never fails, so we only get here if an explicit backend was bad
92
+ raise RuntimeError("No usable embedding backend. Tried:\n " +
93
+ "\n ".join(errors))
94
+
95
+ def _make_backend(self, backend: str) -> Callable[[str], list[float]]:
96
+ if backend == "sentence-transformers":
97
+ from sentence_transformers import SentenceTransformer
98
+ model_name = self.model or "all-MiniLM-L6-v2"
99
+ st = SentenceTransformer(model_name)
100
+ self.model = model_name
101
+
102
+ def embed(text: str) -> list[float]:
103
+ return [float(x) for x in st.encode(text, normalize_embeddings=False)]
104
+
105
+ return embed
106
+
107
+ if backend == "ollama":
108
+ model_name = self.model or "nomic-embed-text"
109
+ self.model = model_name
110
+ url = f"{self.ollama_url}/api/embeddings"
111
+
112
+ def embed(text: str) -> list[float]:
113
+ payload = json.dumps({"model": model_name, "prompt": text}).encode()
114
+ req = urllib.request.Request(
115
+ url, data=payload, headers={"Content-Type": "application/json"})
116
+ last_err: Exception | None = None
117
+ for attempt in range(5):
118
+ try:
119
+ with urllib.request.urlopen(req, timeout=60) as resp:
120
+ data = json.loads(resp.read().decode())
121
+ return [float(x) for x in data["embedding"]]
122
+ except urllib.error.HTTPError as e:
123
+ last_err = e
124
+ if e.code in (429, 500, 502, 503) and attempt < 4:
125
+ time.sleep(2 ** attempt)
126
+ continue
127
+ raise
128
+ raise last_err # type: ignore[misc]
129
+
130
+ return embed
131
+
132
+ # ── hashing fallback ──────────────────────────────────────────────────
133
+ self.model = self.model or f"hashing-{self.dim}"
134
+
135
+ def embed(text: str) -> list[float]:
136
+ vec = [0.0] * self.dim
137
+ tokens = text.lower().split()
138
+ for tok in tokens:
139
+ h = int(hashlib.md5(tok.encode()).hexdigest(), 16)
140
+ idx = h % self.dim
141
+ sign = 1.0 if (h >> 8) & 1 else -1.0
142
+ vec[idx] += sign
143
+ return vec
144
+
145
+ return embed
146
+
147
+ # ── public API ────────────────────────────────────────────────────────────
148
+ def embed(self, text: str) -> list[float]:
149
+ if text not in self._cache:
150
+ self._cache[text] = self._embed_fn(text)
151
+ return self._cache[text]
152
+
153
+ def __call__(self, query: str, content: str) -> float:
154
+ if not query or not content:
155
+ return 0.0
156
+ cos = _cosine(self.embed(query), self.embed(content))
157
+ # Return clamped raw cosine. We deliberately do NOT rescale [-1,1]->[0,1]:
158
+ # that compresses the dynamic range and collapses the gap between
159
+ # "related" and "unrelated" (which breaks similarity thresholds used by
160
+ # remember()). Clamping negatives to 0 is fine for retrieval and matching.
161
+ return max(0.0, min(1.0, cos))