agentic-memory-ai 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.
- agentic_memory/__init__.py +49 -0
- agentic_memory/adapters/__init__.py +4 -0
- agentic_memory/adapters/openai.py +70 -0
- agentic_memory/adapters/voyageai.py +62 -0
- agentic_memory/decay.py +93 -0
- agentic_memory/memory.py +243 -0
- agentic_memory/retrieval/__init__.py +5 -0
- agentic_memory/retrieval/conflict.py +145 -0
- agentic_memory/retrieval/embedder.py +83 -0
- agentic_memory/retrieval/multi_signal.py +121 -0
- agentic_memory/store/__init__.py +4 -0
- agentic_memory/store/file.py +100 -0
- agentic_memory/store/local.py +57 -0
- agentic_memory/types.py +143 -0
- agentic_memory/utils.py +42 -0
- agentic_memory_ai-0.1.0.dist-info/METADATA +472 -0
- agentic_memory_ai-0.1.0.dist-info/RECORD +18 -0
- agentic_memory_ai-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""agentic-memory: Framework-agnostic memory layer for AI agents."""
|
|
2
|
+
|
|
3
|
+
from .memory import AgentMemory
|
|
4
|
+
from .decay import DecayEngine
|
|
5
|
+
from .types import (
|
|
6
|
+
MemoryEntry,
|
|
7
|
+
MemoryType,
|
|
8
|
+
ImportanceLevel,
|
|
9
|
+
DecayPolicy,
|
|
10
|
+
DecayConfig,
|
|
11
|
+
DecayTypeConfig,
|
|
12
|
+
RetrievalQuery,
|
|
13
|
+
RetrievalResult,
|
|
14
|
+
ConflictResult,
|
|
15
|
+
ConflictAction,
|
|
16
|
+
Checkpoint,
|
|
17
|
+
TaskNode,
|
|
18
|
+
TaskStatus,
|
|
19
|
+
)
|
|
20
|
+
from .store import LocalStore, FileStore
|
|
21
|
+
from .retrieval import BuiltinEmbedder, MultiSignalRetriever, ConflictDetector
|
|
22
|
+
from .utils import cosine_similarity, generate_id
|
|
23
|
+
|
|
24
|
+
__version__ = "0.1.0"
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"AgentMemory",
|
|
28
|
+
"DecayEngine",
|
|
29
|
+
"MemoryEntry",
|
|
30
|
+
"MemoryType",
|
|
31
|
+
"ImportanceLevel",
|
|
32
|
+
"DecayPolicy",
|
|
33
|
+
"DecayConfig",
|
|
34
|
+
"DecayTypeConfig",
|
|
35
|
+
"RetrievalQuery",
|
|
36
|
+
"RetrievalResult",
|
|
37
|
+
"ConflictResult",
|
|
38
|
+
"ConflictAction",
|
|
39
|
+
"Checkpoint",
|
|
40
|
+
"TaskNode",
|
|
41
|
+
"TaskStatus",
|
|
42
|
+
"LocalStore",
|
|
43
|
+
"FileStore",
|
|
44
|
+
"BuiltinEmbedder",
|
|
45
|
+
"MultiSignalRetriever",
|
|
46
|
+
"ConflictDetector",
|
|
47
|
+
"cosine_similarity",
|
|
48
|
+
"generate_id",
|
|
49
|
+
]
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OpenAI embedder adapter.
|
|
3
|
+
Requires: pip install httpx (or aiohttp)
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
from agentic_memory import AgentMemory
|
|
7
|
+
from agentic_memory.adapters import OpenAIEmbedder
|
|
8
|
+
|
|
9
|
+
memory = AgentMemory(
|
|
10
|
+
embedder=OpenAIEmbedder(api_key="sk-...")
|
|
11
|
+
)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
from urllib.request import Request, urlopen
|
|
18
|
+
from typing import Optional
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class OpenAIEmbedder:
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
api_key: str,
|
|
25
|
+
model: str = "text-embedding-3-small",
|
|
26
|
+
dim: int = 1536,
|
|
27
|
+
base_url: str = "https://api.openai.com/v1",
|
|
28
|
+
) -> None:
|
|
29
|
+
self._api_key = api_key
|
|
30
|
+
self._model = model
|
|
31
|
+
self._dim = dim
|
|
32
|
+
self._base_url = base_url
|
|
33
|
+
|
|
34
|
+
def dimensions(self) -> int:
|
|
35
|
+
return self._dim
|
|
36
|
+
|
|
37
|
+
async def embed(self, text: str) -> list[float]:
|
|
38
|
+
result = self._call([text])
|
|
39
|
+
return result[0]
|
|
40
|
+
|
|
41
|
+
async def embed_batch(self, texts: list[str]) -> list[list[float]]:
|
|
42
|
+
if not texts:
|
|
43
|
+
return []
|
|
44
|
+
return self._call(texts)
|
|
45
|
+
|
|
46
|
+
def _call(self, input_texts: list[str]) -> list[list[float]]:
|
|
47
|
+
payload = json.dumps({
|
|
48
|
+
"model": self._model,
|
|
49
|
+
"input": input_texts,
|
|
50
|
+
"dimensions": self._dim,
|
|
51
|
+
}).encode("utf-8")
|
|
52
|
+
|
|
53
|
+
req = Request(
|
|
54
|
+
f"{self._base_url}/embeddings",
|
|
55
|
+
data=payload,
|
|
56
|
+
headers={
|
|
57
|
+
"Content-Type": "application/json",
|
|
58
|
+
"Authorization": f"Bearer {self._api_key}",
|
|
59
|
+
},
|
|
60
|
+
method="POST",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
with urlopen(req) as resp:
|
|
64
|
+
if resp.status != 200:
|
|
65
|
+
raise RuntimeError(f"OpenAI embedding failed ({resp.status})")
|
|
66
|
+
data = json.loads(resp.read())
|
|
67
|
+
|
|
68
|
+
# Sort by index (OpenAI may return out of order)
|
|
69
|
+
items = sorted(data["data"], key=lambda d: d["index"])
|
|
70
|
+
return [d["embedding"] for d in items]
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Voyage AI embedder adapter (high-quality, cheaper than OpenAI).
|
|
3
|
+
Requires: VOYAGE_API_KEY
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
from agentic_memory import AgentMemory
|
|
7
|
+
from agentic_memory.adapters import VoyageEmbedder
|
|
8
|
+
|
|
9
|
+
memory = AgentMemory(
|
|
10
|
+
embedder=VoyageEmbedder(api_key="voyage-...")
|
|
11
|
+
)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
from urllib.request import Request, urlopen
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class VoyageEmbedder:
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
api_key: str,
|
|
24
|
+
model: str = "voyage-3-lite",
|
|
25
|
+
) -> None:
|
|
26
|
+
self._api_key = api_key
|
|
27
|
+
self._model = model
|
|
28
|
+
|
|
29
|
+
def dimensions(self) -> int:
|
|
30
|
+
return 512 if "lite" in self._model else 1024
|
|
31
|
+
|
|
32
|
+
async def embed(self, text: str) -> list[float]:
|
|
33
|
+
result = self._call([text])
|
|
34
|
+
return result[0]
|
|
35
|
+
|
|
36
|
+
async def embed_batch(self, texts: list[str]) -> list[list[float]]:
|
|
37
|
+
if not texts:
|
|
38
|
+
return []
|
|
39
|
+
return self._call(texts)
|
|
40
|
+
|
|
41
|
+
def _call(self, input_texts: list[str]) -> list[list[float]]:
|
|
42
|
+
payload = json.dumps({
|
|
43
|
+
"model": self._model,
|
|
44
|
+
"input": input_texts,
|
|
45
|
+
}).encode("utf-8")
|
|
46
|
+
|
|
47
|
+
req = Request(
|
|
48
|
+
"https://api.voyageai.com/v1/embeddings",
|
|
49
|
+
data=payload,
|
|
50
|
+
headers={
|
|
51
|
+
"Content-Type": "application/json",
|
|
52
|
+
"Authorization": f"Bearer {self._api_key}",
|
|
53
|
+
},
|
|
54
|
+
method="POST",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
with urlopen(req) as resp:
|
|
58
|
+
if resp.status != 200:
|
|
59
|
+
raise RuntimeError(f"Voyage AI embedding failed ({resp.status})")
|
|
60
|
+
data = json.loads(resp.read())
|
|
61
|
+
|
|
62
|
+
return [d["embedding"] for d in data["data"]]
|
agentic_memory/decay.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Typed decay engine - different memory types decay at different rates."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
|
|
8
|
+
from .types import (
|
|
9
|
+
MemoryEntry,
|
|
10
|
+
MemoryType,
|
|
11
|
+
ImportanceLevel,
|
|
12
|
+
DecayPolicy,
|
|
13
|
+
DecayConfig,
|
|
14
|
+
DecayTypeConfig,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
DAY_MS = 24 * 60 * 60 * 1000
|
|
18
|
+
|
|
19
|
+
DEFAULT_DECAY_CONFIG = DecayConfig(
|
|
20
|
+
policies={
|
|
21
|
+
MemoryType.CONSTRAINT: DecayTypeConfig(policy=DecayPolicy.NONE),
|
|
22
|
+
MemoryType.PREFERENCE: DecayTypeConfig(policy=DecayPolicy.EXPONENTIAL, half_life=30 * DAY_MS),
|
|
23
|
+
MemoryType.FACT: DecayTypeConfig(policy=DecayPolicy.EXPONENTIAL, half_life=90 * DAY_MS),
|
|
24
|
+
MemoryType.TASK: DecayTypeConfig(policy=DecayPolicy.STEP, max_age=7 * DAY_MS),
|
|
25
|
+
MemoryType.EPISODIC: DecayTypeConfig(policy=DecayPolicy.EXPONENTIAL, half_life=14 * DAY_MS),
|
|
26
|
+
},
|
|
27
|
+
default_policy=DecayPolicy.EXPONENTIAL,
|
|
28
|
+
default_half_life=30 * DAY_MS,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class DecayEngine:
|
|
33
|
+
"""
|
|
34
|
+
Typed decay engine.
|
|
35
|
+
Different memory types decay at different rates.
|
|
36
|
+
Hard constraints (allergies, legal requirements) never decay.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self, config: DecayConfig | None = None) -> None:
|
|
40
|
+
if config is None:
|
|
41
|
+
self._config = DEFAULT_DECAY_CONFIG
|
|
42
|
+
else:
|
|
43
|
+
# Merge with defaults
|
|
44
|
+
merged_policies = {**DEFAULT_DECAY_CONFIG.policies, **config.policies}
|
|
45
|
+
self._config = DecayConfig(
|
|
46
|
+
policies=merged_policies,
|
|
47
|
+
default_policy=config.default_policy,
|
|
48
|
+
default_half_life=config.default_half_life,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
def compute_decayed_confidence(self, entry: MemoryEntry) -> float:
|
|
52
|
+
"""Compute effective confidence after temporal decay."""
|
|
53
|
+
if entry.importance == ImportanceLevel.HARD:
|
|
54
|
+
return entry.confidence
|
|
55
|
+
|
|
56
|
+
type_config = self._config.policies.get(entry.type)
|
|
57
|
+
policy = type_config.policy if type_config else self._config.default_policy
|
|
58
|
+
|
|
59
|
+
dt = datetime.fromisoformat(entry.updated_at)
|
|
60
|
+
if dt.tzinfo is None:
|
|
61
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
62
|
+
age_ms = (datetime.now(timezone.utc) - dt).total_seconds() * 1000
|
|
63
|
+
|
|
64
|
+
if policy == DecayPolicy.NONE:
|
|
65
|
+
return entry.confidence
|
|
66
|
+
|
|
67
|
+
if policy == DecayPolicy.LINEAR:
|
|
68
|
+
rate = type_config.rate_per_day if type_config and type_config.rate_per_day else 0.01
|
|
69
|
+
age_days = age_ms / DAY_MS
|
|
70
|
+
return max(0.0, entry.confidence - rate * age_days)
|
|
71
|
+
|
|
72
|
+
if policy == DecayPolicy.EXPONENTIAL:
|
|
73
|
+
half_life = (
|
|
74
|
+
type_config.half_life
|
|
75
|
+
if type_config and type_config.half_life
|
|
76
|
+
else self._config.default_half_life
|
|
77
|
+
)
|
|
78
|
+
decay_factor = math.exp(-0.693 * age_ms / half_life)
|
|
79
|
+
return entry.confidence * decay_factor
|
|
80
|
+
|
|
81
|
+
if policy == DecayPolicy.STEP:
|
|
82
|
+
max_age = type_config.max_age if type_config and type_config.max_age else 7 * DAY_MS
|
|
83
|
+
return 0.0 if age_ms > max_age else entry.confidence
|
|
84
|
+
|
|
85
|
+
return entry.confidence
|
|
86
|
+
|
|
87
|
+
def filter_decayed(self, entries: list[MemoryEntry], threshold: float = 0.05) -> list[MemoryEntry]:
|
|
88
|
+
"""Filter out memories that have decayed below threshold."""
|
|
89
|
+
return [e for e in entries if self.compute_decayed_confidence(e) >= threshold]
|
|
90
|
+
|
|
91
|
+
def get_expired(self, entries: list[MemoryEntry], threshold: float = 0.01) -> list[MemoryEntry]:
|
|
92
|
+
"""Get entries that should be cleaned up."""
|
|
93
|
+
return [e for e in entries if self.compute_decayed_confidence(e) < threshold]
|
agentic_memory/memory.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""AgentMemory - the main entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
|
|
7
|
+
from .types import (
|
|
8
|
+
MemoryEntry,
|
|
9
|
+
MemoryType,
|
|
10
|
+
ImportanceLevel,
|
|
11
|
+
RetrievalQuery,
|
|
12
|
+
RetrievalResult,
|
|
13
|
+
ConflictResult,
|
|
14
|
+
Checkpoint,
|
|
15
|
+
TaskNode,
|
|
16
|
+
DecayConfig,
|
|
17
|
+
)
|
|
18
|
+
from .utils import generate_id, now
|
|
19
|
+
from .store.local import LocalStore
|
|
20
|
+
from .retrieval.embedder import BuiltinEmbedder
|
|
21
|
+
from .retrieval.multi_signal import MultiSignalRetriever
|
|
22
|
+
from .retrieval.conflict import ConflictDetector
|
|
23
|
+
from .decay import DecayEngine
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class AgentMemory:
|
|
27
|
+
"""
|
|
28
|
+
Framework-agnostic memory layer for AI agents.
|
|
29
|
+
|
|
30
|
+
Example::
|
|
31
|
+
|
|
32
|
+
memory = AgentMemory()
|
|
33
|
+
|
|
34
|
+
await memory.store(
|
|
35
|
+
content="User prefers dark mode",
|
|
36
|
+
type=MemoryType.PREFERENCE,
|
|
37
|
+
scope="user:123",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
results = await memory.retrieve(RetrievalQuery(query="UI preferences"))
|
|
41
|
+
conflicts = await memory.check_conflicts("User prefers light mode", "user:123")
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
*,
|
|
47
|
+
store: Any = None,
|
|
48
|
+
embedder: Any = None,
|
|
49
|
+
decay: DecayConfig | None = None,
|
|
50
|
+
default_scope: str = "default",
|
|
51
|
+
max_checkpoints: int = 10,
|
|
52
|
+
) -> None:
|
|
53
|
+
self._backend = store if store is not None else LocalStore()
|
|
54
|
+
self._embedder = embedder if embedder is not None else BuiltinEmbedder()
|
|
55
|
+
self._retriever = MultiSignalRetriever(self._backend, self._embedder)
|
|
56
|
+
self._conflict_detector = ConflictDetector(self._backend, self._embedder)
|
|
57
|
+
self._decay_engine = DecayEngine(decay)
|
|
58
|
+
self._checkpoints: dict[str, Checkpoint] = {}
|
|
59
|
+
self._default_scope = default_scope
|
|
60
|
+
self._max_checkpoints = max_checkpoints
|
|
61
|
+
|
|
62
|
+
# ─── Store ───
|
|
63
|
+
|
|
64
|
+
async def store(
|
|
65
|
+
self,
|
|
66
|
+
content: str,
|
|
67
|
+
*,
|
|
68
|
+
type: MemoryType = MemoryType.FACT,
|
|
69
|
+
scope: Optional[str] = None,
|
|
70
|
+
importance: ImportanceLevel = ImportanceLevel.SOFT,
|
|
71
|
+
confidence: float = 1.0,
|
|
72
|
+
metadata: Optional[dict[str, Any]] = None,
|
|
73
|
+
) -> MemoryEntry:
|
|
74
|
+
"""Store a new memory entry."""
|
|
75
|
+
if not content or not content.strip():
|
|
76
|
+
raise ValueError("Memory content cannot be empty")
|
|
77
|
+
|
|
78
|
+
# Train before embed so IDF stats include this document
|
|
79
|
+
if isinstance(self._embedder, BuiltinEmbedder):
|
|
80
|
+
self._embedder.train([content])
|
|
81
|
+
|
|
82
|
+
embedding = await self._embedder.embed(content)
|
|
83
|
+
|
|
84
|
+
entry = MemoryEntry(
|
|
85
|
+
id=generate_id(),
|
|
86
|
+
content=content,
|
|
87
|
+
type=type,
|
|
88
|
+
scope=scope or self._default_scope,
|
|
89
|
+
importance=importance,
|
|
90
|
+
confidence=confidence,
|
|
91
|
+
embedding=embedding,
|
|
92
|
+
metadata=metadata or {},
|
|
93
|
+
created_at=now(),
|
|
94
|
+
updated_at=now(),
|
|
95
|
+
version=1,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
await self._backend.set(entry)
|
|
99
|
+
return entry
|
|
100
|
+
|
|
101
|
+
async def update(
|
|
102
|
+
self,
|
|
103
|
+
id: str,
|
|
104
|
+
*,
|
|
105
|
+
content: Optional[str] = None,
|
|
106
|
+
type: Optional[MemoryType] = None,
|
|
107
|
+
importance: Optional[ImportanceLevel] = None,
|
|
108
|
+
confidence: Optional[float] = None,
|
|
109
|
+
metadata: Optional[dict[str, Any]] = None,
|
|
110
|
+
) -> Optional[MemoryEntry]:
|
|
111
|
+
"""Update an existing memory entry."""
|
|
112
|
+
entry = await self._backend.get(id)
|
|
113
|
+
if entry is None:
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
if content is not None:
|
|
117
|
+
if not content.strip():
|
|
118
|
+
raise ValueError("Memory content cannot be empty")
|
|
119
|
+
entry.content = content
|
|
120
|
+
|
|
121
|
+
if type is not None:
|
|
122
|
+
entry.type = type
|
|
123
|
+
if importance is not None:
|
|
124
|
+
entry.importance = importance
|
|
125
|
+
if confidence is not None:
|
|
126
|
+
entry.confidence = confidence
|
|
127
|
+
if metadata is not None:
|
|
128
|
+
entry.metadata = metadata
|
|
129
|
+
|
|
130
|
+
entry.updated_at = now()
|
|
131
|
+
entry.version += 1
|
|
132
|
+
|
|
133
|
+
# Re-embed if content changed
|
|
134
|
+
if content is not None:
|
|
135
|
+
if isinstance(self._embedder, BuiltinEmbedder):
|
|
136
|
+
self._embedder.train([content])
|
|
137
|
+
entry.embedding = await self._embedder.embed(content)
|
|
138
|
+
|
|
139
|
+
await self._backend.set(entry)
|
|
140
|
+
return entry
|
|
141
|
+
|
|
142
|
+
async def get(self, id: str) -> Optional[MemoryEntry]:
|
|
143
|
+
"""Get a memory by ID."""
|
|
144
|
+
return await self._backend.get(id)
|
|
145
|
+
|
|
146
|
+
async def delete(self, id: str) -> bool:
|
|
147
|
+
"""Delete a memory by ID."""
|
|
148
|
+
return await self._backend.delete(id)
|
|
149
|
+
|
|
150
|
+
async def get_all(self, scope: Optional[str] = None) -> list[MemoryEntry]:
|
|
151
|
+
"""Get all memories, optionally filtered by scope."""
|
|
152
|
+
return await self._backend.get_all(scope)
|
|
153
|
+
|
|
154
|
+
async def clear(self, scope: Optional[str] = None) -> None:
|
|
155
|
+
"""Clear all memories, optionally for a specific scope."""
|
|
156
|
+
return await self._backend.clear(scope)
|
|
157
|
+
|
|
158
|
+
# ─── Retrieve ───
|
|
159
|
+
|
|
160
|
+
async def retrieve(self, query: RetrievalQuery) -> list[RetrievalResult]:
|
|
161
|
+
"""Multi-signal retrieval."""
|
|
162
|
+
return await self._retriever.retrieve(query)
|
|
163
|
+
|
|
164
|
+
# ─── Conflict Detection ───
|
|
165
|
+
|
|
166
|
+
async def check_conflicts(
|
|
167
|
+
self, content: str, scope: Optional[str] = None
|
|
168
|
+
) -> list[ConflictResult]:
|
|
169
|
+
"""Check if content conflicts with stored memories."""
|
|
170
|
+
return await self._conflict_detector.check(
|
|
171
|
+
content, scope or self._default_scope
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
# ─── Decay ───
|
|
175
|
+
|
|
176
|
+
def get_decayed_confidence(self, entry: MemoryEntry) -> float:
|
|
177
|
+
"""Get the current effective confidence after decay."""
|
|
178
|
+
return self._decay_engine.compute_decayed_confidence(entry)
|
|
179
|
+
|
|
180
|
+
async def cleanup(
|
|
181
|
+
self, scope: Optional[str] = None, threshold: float = 0.01
|
|
182
|
+
) -> int:
|
|
183
|
+
"""Clean up expired memories. Returns count deleted."""
|
|
184
|
+
entries = await self._backend.get_all(scope)
|
|
185
|
+
expired = self._decay_engine.get_expired(entries, threshold)
|
|
186
|
+
for entry in expired:
|
|
187
|
+
await self._backend.delete(entry.id)
|
|
188
|
+
return len(expired)
|
|
189
|
+
|
|
190
|
+
# ─── Checkpointing ───
|
|
191
|
+
|
|
192
|
+
async def checkpoint(
|
|
193
|
+
self,
|
|
194
|
+
*,
|
|
195
|
+
task_graph: list[TaskNode],
|
|
196
|
+
summary: str,
|
|
197
|
+
tool_outputs: Optional[dict[str, Any]] = None,
|
|
198
|
+
active_memory_ids: Optional[list[str]] = None,
|
|
199
|
+
) -> Checkpoint:
|
|
200
|
+
"""Create a checkpoint of current task state."""
|
|
201
|
+
cp = Checkpoint(
|
|
202
|
+
id=generate_id(),
|
|
203
|
+
task_graph=task_graph,
|
|
204
|
+
summary=summary,
|
|
205
|
+
tool_outputs=tool_outputs or {},
|
|
206
|
+
active_memory_ids=active_memory_ids or [],
|
|
207
|
+
created_at=now(),
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
self._checkpoints[cp.id] = cp
|
|
211
|
+
|
|
212
|
+
# Enforce max checkpoints (LRU eviction)
|
|
213
|
+
if len(self._checkpoints) > self._max_checkpoints:
|
|
214
|
+
oldest_key = next(iter(self._checkpoints))
|
|
215
|
+
del self._checkpoints[oldest_key]
|
|
216
|
+
|
|
217
|
+
return cp
|
|
218
|
+
|
|
219
|
+
async def rehydrate(
|
|
220
|
+
self, checkpoint_id: str
|
|
221
|
+
) -> Optional[dict[str, Any]]:
|
|
222
|
+
"""Rehydrate from a checkpoint. Returns checkpoint + memories."""
|
|
223
|
+
cp = self._checkpoints.get(checkpoint_id)
|
|
224
|
+
if cp is None:
|
|
225
|
+
return None
|
|
226
|
+
|
|
227
|
+
memories: list[MemoryEntry] = []
|
|
228
|
+
for mid in cp.active_memory_ids:
|
|
229
|
+
entry = await self._backend.get(mid)
|
|
230
|
+
if entry is not None:
|
|
231
|
+
memories.append(entry)
|
|
232
|
+
|
|
233
|
+
return {"checkpoint": cp, "memories": memories}
|
|
234
|
+
|
|
235
|
+
def get_latest_checkpoint(self) -> Optional[Checkpoint]:
|
|
236
|
+
"""Get the latest checkpoint."""
|
|
237
|
+
if not self._checkpoints:
|
|
238
|
+
return None
|
|
239
|
+
return list(self._checkpoints.values())[-1]
|
|
240
|
+
|
|
241
|
+
def list_checkpoints(self) -> list[Checkpoint]:
|
|
242
|
+
"""List all checkpoints."""
|
|
243
|
+
return list(self._checkpoints.values())
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Conflict detector for finding contradictions in stored memories."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any, Optional
|
|
7
|
+
|
|
8
|
+
from ..types import MemoryEntry, ConflictResult, ConflictAction, ImportanceLevel
|
|
9
|
+
from ..utils import cosine_similarity
|
|
10
|
+
|
|
11
|
+
NEGATION_PATTERNS = [
|
|
12
|
+
re.compile(p, re.IGNORECASE) for p in [
|
|
13
|
+
r"\bnot\b", r"\bnever\b", r"\bno\b", r"\bdon't\b", r"\bdoesn't\b",
|
|
14
|
+
r"\bwon't\b", r"\bcan't\b", r"\bhate\b", r"\bdislike\b", r"\bavoid\b",
|
|
15
|
+
r"\bstop\b", r"\bquit\b", r"\bremove\b", r"\bdelete\b",
|
|
16
|
+
r"\binstead of\b", r"\brather than\b", r"\bno longer\b",
|
|
17
|
+
]
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
CHANGE_PATTERNS = [
|
|
21
|
+
re.compile(p, re.IGNORECASE) for p in [
|
|
22
|
+
r"\bnow\b", r"\bactually\b", r"\bchanged?\b", r"\bswitch",
|
|
23
|
+
r"\bprefer\b", r"\bwant\b", r"\bused to\b", r"\banymore\b",
|
|
24
|
+
]
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
ANTONYM_PAIRS: list[tuple[re.Pattern, re.Pattern]] = [
|
|
28
|
+
(re.compile(a, re.IGNORECASE), re.compile(b, re.IGNORECASE))
|
|
29
|
+
for a, b in [
|
|
30
|
+
(r"\blike\b", r"\bdislike\b"),
|
|
31
|
+
(r"\blove\b", r"\bhate\b"),
|
|
32
|
+
(r"\byes\b", r"\bno\b"),
|
|
33
|
+
(r"\btrue\b", r"\bfalse\b"),
|
|
34
|
+
(r"\bvegetarian\b", r"\bmeat\b"),
|
|
35
|
+
(r"\bvegan\b", r"\bmeat\b|\bdairy\b"),
|
|
36
|
+
(r"\bmorning\b", r"\bevening\b|\bnight\b"),
|
|
37
|
+
(r"\blight\b", r"\bdark\b"),
|
|
38
|
+
(r"\bhot\b", r"\bcold\b"),
|
|
39
|
+
(r"\bfast\b", r"\bslow\b"),
|
|
40
|
+
(r"\benable\b", r"\bdisable\b"),
|
|
41
|
+
(r"\ballow\b", r"\bblock\b|\bdeny\b"),
|
|
42
|
+
(r"\baccept\b", r"\breject\b"),
|
|
43
|
+
]
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ConflictDetector:
|
|
48
|
+
"""
|
|
49
|
+
Conflict detector.
|
|
50
|
+
Compares new content against stored memories to find contradictions.
|
|
51
|
+
Uses semantic similarity + negation detection.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
store: Any,
|
|
57
|
+
embedder: Any,
|
|
58
|
+
topic_threshold: float = 0.6,
|
|
59
|
+
) -> None:
|
|
60
|
+
self._store = store
|
|
61
|
+
self._embedder = embedder
|
|
62
|
+
self._topic_threshold = topic_threshold
|
|
63
|
+
|
|
64
|
+
async def check(self, content: str, scope: Optional[str] = None) -> list[ConflictResult]:
|
|
65
|
+
entries = await self._store.get_all(scope)
|
|
66
|
+
if not entries:
|
|
67
|
+
return []
|
|
68
|
+
|
|
69
|
+
content_embedding = await self._embedder.embed(content)
|
|
70
|
+
conflicts: list[ConflictResult] = []
|
|
71
|
+
|
|
72
|
+
for entry in entries:
|
|
73
|
+
entry_embedding = entry.embedding or await self._embedder.embed(entry.content)
|
|
74
|
+
similarity = cosine_similarity(content_embedding, entry_embedding)
|
|
75
|
+
|
|
76
|
+
if similarity < self._topic_threshold:
|
|
77
|
+
continue
|
|
78
|
+
|
|
79
|
+
contradiction_score = self._score_contradiction(content, entry.content)
|
|
80
|
+
if contradiction_score <= 0:
|
|
81
|
+
continue
|
|
82
|
+
|
|
83
|
+
confidence = min(1.0, similarity * 0.5 + contradiction_score * 0.5)
|
|
84
|
+
|
|
85
|
+
conflicts.append(ConflictResult(
|
|
86
|
+
incoming=content,
|
|
87
|
+
stored=entry,
|
|
88
|
+
confidence=confidence,
|
|
89
|
+
action=self._suggest_action(entry, confidence),
|
|
90
|
+
reason=self._generate_reason(content, entry, contradiction_score),
|
|
91
|
+
))
|
|
92
|
+
|
|
93
|
+
conflicts.sort(key=lambda c: c.confidence, reverse=True)
|
|
94
|
+
return conflicts
|
|
95
|
+
|
|
96
|
+
def _score_contradiction(self, incoming: str, stored: str) -> float:
|
|
97
|
+
score = 0.0
|
|
98
|
+
|
|
99
|
+
incoming_negated = any(p.search(incoming) for p in NEGATION_PATTERNS)
|
|
100
|
+
stored_negated = any(p.search(stored) for p in NEGATION_PATTERNS)
|
|
101
|
+
|
|
102
|
+
if incoming_negated != stored_negated:
|
|
103
|
+
score += 0.5
|
|
104
|
+
|
|
105
|
+
if any(p.search(incoming) for p in CHANGE_PATTERNS):
|
|
106
|
+
score += 0.3
|
|
107
|
+
|
|
108
|
+
score += self._check_antonyms(incoming, stored) * 0.4
|
|
109
|
+
|
|
110
|
+
return min(1.0, score)
|
|
111
|
+
|
|
112
|
+
@staticmethod
|
|
113
|
+
def _check_antonyms(a: str, b: str) -> float:
|
|
114
|
+
matches = 0
|
|
115
|
+
for word1, word2 in ANTONYM_PAIRS:
|
|
116
|
+
if (word1.search(a) and word2.search(b)) or (word2.search(a) and word1.search(b)):
|
|
117
|
+
matches += 1
|
|
118
|
+
return min(1.0, matches * 0.5)
|
|
119
|
+
|
|
120
|
+
@staticmethod
|
|
121
|
+
def _suggest_action(stored: MemoryEntry, confidence: float) -> ConflictAction:
|
|
122
|
+
if stored.importance == ImportanceLevel.HARD:
|
|
123
|
+
return ConflictAction.CLARIFY
|
|
124
|
+
if confidence > 0.7 and stored.importance == ImportanceLevel.SOFT:
|
|
125
|
+
return ConflictAction.OVERRIDE
|
|
126
|
+
if stored.importance == ImportanceLevel.EPHEMERAL:
|
|
127
|
+
return ConflictAction.OVERRIDE
|
|
128
|
+
return ConflictAction.CLARIFY
|
|
129
|
+
|
|
130
|
+
@staticmethod
|
|
131
|
+
def _generate_reason(incoming: str, stored: MemoryEntry, contradiction_score: float) -> str:
|
|
132
|
+
if stored.importance == ImportanceLevel.HARD:
|
|
133
|
+
return (
|
|
134
|
+
f'Conflicts with a hard constraint: "{stored.content}". '
|
|
135
|
+
"This memory was marked as non-negotiable - please confirm the change."
|
|
136
|
+
)
|
|
137
|
+
if contradiction_score > 0.7:
|
|
138
|
+
return (
|
|
139
|
+
f'Directly contradicts stored memory: "{stored.content}". '
|
|
140
|
+
"The new statement appears to reverse a previous preference."
|
|
141
|
+
)
|
|
142
|
+
return (
|
|
143
|
+
f'Potentially conflicts with: "{stored.content}". '
|
|
144
|
+
"The statements may be inconsistent."
|
|
145
|
+
)
|