openmem-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.
- openmem/__init__.py +4 -0
- openmem/activation.py +35 -0
- openmem/conflict.py +59 -0
- openmem/engine.py +182 -0
- openmem/models.py +40 -0
- openmem/scoring.py +118 -0
- openmem/store.py +206 -0
- openmem_engine-0.1.0.dist-info/METADATA +166 -0
- openmem_engine-0.1.0.dist-info/RECORD +10 -0
- openmem_engine-0.1.0.dist-info/WHEEL +4 -0
openmem/__init__.py
ADDED
openmem/activation.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from .store import SQLiteStore
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def spread_activation(
|
|
10
|
+
seed_activations: dict[str, float],
|
|
11
|
+
store: SQLiteStore,
|
|
12
|
+
max_hops: int = 2,
|
|
13
|
+
decay_per_hop: float = 0.5,
|
|
14
|
+
) -> dict[str, float]:
|
|
15
|
+
"""Spreading activation over the memory graph.
|
|
16
|
+
|
|
17
|
+
Starting from seed nodes (typically BM25 hits), propagates activation
|
|
18
|
+
along edges with decay per hop. Returns memory_id → activation_score.
|
|
19
|
+
"""
|
|
20
|
+
activations = dict(seed_activations)
|
|
21
|
+
frontier = set(seed_activations.keys())
|
|
22
|
+
|
|
23
|
+
for hop in range(max_hops):
|
|
24
|
+
next_frontier: dict[str, float] = {}
|
|
25
|
+
for node_id in frontier:
|
|
26
|
+
for edge, neighbor in store.get_neighbors(node_id):
|
|
27
|
+
spread = activations[node_id] * edge.weight * (decay_per_hop ** (hop + 1))
|
|
28
|
+
if spread > activations.get(neighbor.id, 0):
|
|
29
|
+
next_frontier[neighbor.id] = spread
|
|
30
|
+
activations[neighbor.id] = spread
|
|
31
|
+
frontier = set(next_frontier.keys())
|
|
32
|
+
if not frontier:
|
|
33
|
+
break
|
|
34
|
+
|
|
35
|
+
return activations
|
openmem/conflict.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from .models import ScoredMemory
|
|
6
|
+
from .scoring import recency_score, strength_score
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from .store import SQLiteStore
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def detect_and_resolve_conflicts(
|
|
13
|
+
scored: list[ScoredMemory],
|
|
14
|
+
store: SQLiteStore,
|
|
15
|
+
now: float | None = None,
|
|
16
|
+
) -> list[ScoredMemory]:
|
|
17
|
+
"""Scan activated memories for contradicts edges. Demote the weaker side."""
|
|
18
|
+
if len(scored) < 2:
|
|
19
|
+
return scored
|
|
20
|
+
|
|
21
|
+
id_set = {s.memory.id for s in scored}
|
|
22
|
+
by_id = {s.memory.id: s for s in scored}
|
|
23
|
+
demoted: set[str] = set()
|
|
24
|
+
|
|
25
|
+
for sm in scored:
|
|
26
|
+
edges = store.get_edges(sm.memory.id)
|
|
27
|
+
for edge in edges:
|
|
28
|
+
if edge.rel_type != "contradicts":
|
|
29
|
+
continue
|
|
30
|
+
other_id = edge.target_id if edge.source_id == sm.memory.id else edge.source_id
|
|
31
|
+
if other_id not in id_set or other_id in demoted or sm.memory.id in demoted:
|
|
32
|
+
continue
|
|
33
|
+
|
|
34
|
+
# Rank by strength * confidence * recency
|
|
35
|
+
a = sm
|
|
36
|
+
b = by_id[other_id]
|
|
37
|
+
rank_a = a.memory.strength * a.memory.confidence * recency_score(a.memory, now)
|
|
38
|
+
rank_b = b.memory.strength * b.memory.confidence * recency_score(b.memory, now)
|
|
39
|
+
|
|
40
|
+
loser_id = b.memory.id if rank_a >= rank_b else a.memory.id
|
|
41
|
+
demoted.add(loser_id)
|
|
42
|
+
|
|
43
|
+
# Apply demotion: halve the score of the weaker contradicting memory
|
|
44
|
+
result = []
|
|
45
|
+
for sm in scored:
|
|
46
|
+
if sm.memory.id in demoted:
|
|
47
|
+
result.append(
|
|
48
|
+
ScoredMemory(
|
|
49
|
+
memory=sm.memory,
|
|
50
|
+
score=sm.score * 0.3,
|
|
51
|
+
activation=sm.activation,
|
|
52
|
+
components={**sm.components, "conflict_demoted": True},
|
|
53
|
+
)
|
|
54
|
+
)
|
|
55
|
+
else:
|
|
56
|
+
result.append(sm)
|
|
57
|
+
|
|
58
|
+
result.sort(key=lambda s: s.score, reverse=True)
|
|
59
|
+
return result
|
openmem/engine.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
from .activation import spread_activation
|
|
7
|
+
from .conflict import detect_and_resolve_conflicts
|
|
8
|
+
from .models import Edge, Memory, ScoredMemory
|
|
9
|
+
from .scoring import compete
|
|
10
|
+
from .store import SQLiteStore
|
|
11
|
+
|
|
12
|
+
# Rough token estimate: ~4 chars per token
|
|
13
|
+
CHARS_PER_TOKEN = 4
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class MemoryEngine:
|
|
17
|
+
"""Main entry point for the cognitive memory engine."""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
db_path: str = ":memory:",
|
|
22
|
+
max_hops: int = 2,
|
|
23
|
+
decay_per_hop: float = 0.5,
|
|
24
|
+
weights: dict[str, float] | None = None,
|
|
25
|
+
):
|
|
26
|
+
self.store = SQLiteStore(db_path)
|
|
27
|
+
self.max_hops = max_hops
|
|
28
|
+
self.decay_per_hop = decay_per_hop
|
|
29
|
+
self.weights = weights
|
|
30
|
+
|
|
31
|
+
def add(
|
|
32
|
+
self,
|
|
33
|
+
text: str,
|
|
34
|
+
type: str = "fact",
|
|
35
|
+
entities: list[str] | None = None,
|
|
36
|
+
confidence: float = 1.0,
|
|
37
|
+
gist: str | None = None,
|
|
38
|
+
) -> Memory:
|
|
39
|
+
"""Add a new memory."""
|
|
40
|
+
now = time.time()
|
|
41
|
+
mem = Memory(
|
|
42
|
+
type=type,
|
|
43
|
+
text=text,
|
|
44
|
+
gist=gist,
|
|
45
|
+
entities=entities or [],
|
|
46
|
+
created_at=now,
|
|
47
|
+
updated_at=now,
|
|
48
|
+
confidence=confidence,
|
|
49
|
+
)
|
|
50
|
+
return self.store.add_memory(mem)
|
|
51
|
+
|
|
52
|
+
def link(
|
|
53
|
+
self,
|
|
54
|
+
source_id: str,
|
|
55
|
+
target_id: str,
|
|
56
|
+
rel_type: str = "mentions",
|
|
57
|
+
weight: float = 0.5,
|
|
58
|
+
) -> Edge:
|
|
59
|
+
"""Create an edge between two memories."""
|
|
60
|
+
edge = Edge(
|
|
61
|
+
source_id=source_id,
|
|
62
|
+
target_id=target_id,
|
|
63
|
+
rel_type=rel_type,
|
|
64
|
+
weight=weight,
|
|
65
|
+
)
|
|
66
|
+
return self.store.add_edge(edge)
|
|
67
|
+
|
|
68
|
+
def recall(
|
|
69
|
+
self,
|
|
70
|
+
query: str,
|
|
71
|
+
top_k: int = 5,
|
|
72
|
+
token_budget: int = 2000,
|
|
73
|
+
) -> list[ScoredMemory]:
|
|
74
|
+
"""Recall memories relevant to the query.
|
|
75
|
+
|
|
76
|
+
Pipeline: FTS5/BM25 → seed activation → spreading activation →
|
|
77
|
+
scoring competition → conflict resolution → token-budgeted output.
|
|
78
|
+
"""
|
|
79
|
+
now = time.time()
|
|
80
|
+
|
|
81
|
+
# Step 1: Lexical trigger via BM25
|
|
82
|
+
bm25_hits = self.store.search_bm25(query, limit=top_k * 4)
|
|
83
|
+
if not bm25_hits:
|
|
84
|
+
return []
|
|
85
|
+
|
|
86
|
+
# Normalize BM25 scores to [0, 1] for seeding
|
|
87
|
+
max_score = max(s for _, s in bm25_hits) if bm25_hits else 1.0
|
|
88
|
+
if max_score == 0:
|
|
89
|
+
max_score = 1.0
|
|
90
|
+
seed_activations = {mid: score / max_score for mid, score in bm25_hits}
|
|
91
|
+
|
|
92
|
+
# Step 2: Spreading activation
|
|
93
|
+
activations = spread_activation(
|
|
94
|
+
seed_activations,
|
|
95
|
+
self.store,
|
|
96
|
+
max_hops=self.max_hops,
|
|
97
|
+
decay_per_hop=self.decay_per_hop,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
# Step 3: Load all activated memories
|
|
101
|
+
memories = {}
|
|
102
|
+
for mid in activations:
|
|
103
|
+
mem = self.store.get_memory(mid)
|
|
104
|
+
if mem:
|
|
105
|
+
memories[mid] = mem
|
|
106
|
+
|
|
107
|
+
# Step 4: Competition scoring
|
|
108
|
+
scored = compete(activations, memories, weights=self.weights, now=now)
|
|
109
|
+
|
|
110
|
+
# Step 5: Conflict resolution
|
|
111
|
+
scored = detect_and_resolve_conflicts(scored, self.store, now=now)
|
|
112
|
+
|
|
113
|
+
# Step 6: Token-budget packing
|
|
114
|
+
char_budget = token_budget * CHARS_PER_TOKEN
|
|
115
|
+
packed: list[ScoredMemory] = []
|
|
116
|
+
used_chars = 0
|
|
117
|
+
for sm in scored:
|
|
118
|
+
text_len = len(sm.memory.text)
|
|
119
|
+
if used_chars + text_len > char_budget and packed:
|
|
120
|
+
break
|
|
121
|
+
packed.append(sm)
|
|
122
|
+
used_chars += text_len
|
|
123
|
+
if len(packed) >= top_k:
|
|
124
|
+
break
|
|
125
|
+
|
|
126
|
+
# Step 7: Update access stats for returned memories
|
|
127
|
+
for sm in packed:
|
|
128
|
+
self.store.update_access(sm.memory.id)
|
|
129
|
+
|
|
130
|
+
return packed
|
|
131
|
+
|
|
132
|
+
def reinforce(self, memory_id: str) -> None:
|
|
133
|
+
"""Explicitly boost a memory's strength."""
|
|
134
|
+
mem = self.store.get_memory(memory_id)
|
|
135
|
+
if not mem:
|
|
136
|
+
return
|
|
137
|
+
mem.strength = min(1.0, mem.strength + 0.1)
|
|
138
|
+
mem.access_count += 1
|
|
139
|
+
mem.last_accessed = time.time()
|
|
140
|
+
self.store.update_memory(mem)
|
|
141
|
+
|
|
142
|
+
def supersede(self, old_id: str, new_id: str) -> None:
|
|
143
|
+
"""Mark old memory as superseded and link to the new one."""
|
|
144
|
+
old = self.store.get_memory(old_id)
|
|
145
|
+
if old:
|
|
146
|
+
old.status = "superseded"
|
|
147
|
+
self.store.update_memory(old)
|
|
148
|
+
self.link(new_id, old_id, rel_type="same_as", weight=0.3)
|
|
149
|
+
|
|
150
|
+
def contradict(self, id_a: str, id_b: str) -> None:
|
|
151
|
+
"""Mark two memories as contradicting each other."""
|
|
152
|
+
self.link(id_a, id_b, rel_type="contradicts", weight=0.8)
|
|
153
|
+
|
|
154
|
+
def decay_all(self) -> None:
|
|
155
|
+
"""Run a decay pass over all memories, reducing strength by natural decay."""
|
|
156
|
+
now = time.time()
|
|
157
|
+
for mem in self.store.all_memories():
|
|
158
|
+
days = (now - mem.updated_at) / 86400.0
|
|
159
|
+
if days < 0.01:
|
|
160
|
+
continue
|
|
161
|
+
decay = math.exp(-0.01 * days)
|
|
162
|
+
mem.strength = max(0.0, min(1.0, mem.strength * decay))
|
|
163
|
+
self.store.update_memory(mem)
|
|
164
|
+
|
|
165
|
+
def stats(self) -> dict:
|
|
166
|
+
"""Return summary statistics about the memory store."""
|
|
167
|
+
memories = self.store.all_memories()
|
|
168
|
+
edges = []
|
|
169
|
+
for m in memories:
|
|
170
|
+
edges.extend(self.store.get_edges(m.id))
|
|
171
|
+
# Deduplicate edges (they appear for both endpoints)
|
|
172
|
+
unique_edges = {e.id: e for e in edges}
|
|
173
|
+
|
|
174
|
+
strengths = [m.strength for m in memories]
|
|
175
|
+
return {
|
|
176
|
+
"memory_count": len(memories),
|
|
177
|
+
"edge_count": len(unique_edges),
|
|
178
|
+
"avg_strength": sum(strengths) / len(strengths) if strengths else 0,
|
|
179
|
+
"active_count": sum(1 for m in memories if m.status == "active"),
|
|
180
|
+
"superseded_count": sum(1 for m in memories if m.status == "superseded"),
|
|
181
|
+
"contradicted_count": sum(1 for m in memories if m.status == "contradicted"),
|
|
182
|
+
}
|
openmem/models.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
import uuid
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class Memory:
|
|
10
|
+
id: str = field(default_factory=lambda: uuid.uuid4().hex)
|
|
11
|
+
type: str = "fact" # decision | fact | preference | incident | plan | constraint
|
|
12
|
+
text: str = ""
|
|
13
|
+
gist: str | None = None
|
|
14
|
+
entities: list[str] = field(default_factory=list)
|
|
15
|
+
created_at: float = field(default_factory=time.time)
|
|
16
|
+
updated_at: float = field(default_factory=time.time)
|
|
17
|
+
strength: float = 1.0 # 0–1, decays over time, reinforced on access
|
|
18
|
+
confidence: float = 1.0 # 0–1, set at creation
|
|
19
|
+
access_count: int = 0
|
|
20
|
+
last_accessed: float | None = None
|
|
21
|
+
status: str = "active" # active | superseded | contradicted
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class Edge:
|
|
26
|
+
id: str = field(default_factory=lambda: uuid.uuid4().hex)
|
|
27
|
+
source_id: str = ""
|
|
28
|
+
target_id: str = ""
|
|
29
|
+
rel_type: str = "mentions" # mentions | supports | contradicts | depends_on | same_as
|
|
30
|
+
weight: float = 0.5 # 0–1
|
|
31
|
+
created_at: float = field(default_factory=time.time)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class ScoredMemory:
|
|
36
|
+
memory: Memory
|
|
37
|
+
score: float # final competition score
|
|
38
|
+
activation: float # raw activation (seed + spread)
|
|
39
|
+
components: dict = field(default_factory=dict)
|
|
40
|
+
# breakdown: {activation, recency, strength, confidence}
|
openmem/scoring.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
from .models import Memory, ScoredMemory
|
|
7
|
+
|
|
8
|
+
# Recency decay: half-life ~14 days
|
|
9
|
+
LAMBDA_RECENCY = 0.05
|
|
10
|
+
# Strength natural decay rate
|
|
11
|
+
ALPHA_DECAY = 0.01
|
|
12
|
+
# Reinforcement factor
|
|
13
|
+
BETA_REINFORCE = 0.1
|
|
14
|
+
|
|
15
|
+
# Default competition weights
|
|
16
|
+
DEFAULT_WEIGHTS = {
|
|
17
|
+
"activation": 0.5,
|
|
18
|
+
"recency": 0.2,
|
|
19
|
+
"strength": 0.2,
|
|
20
|
+
"confidence": 0.1,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
# Status penalties
|
|
24
|
+
STATUS_PENALTY = {
|
|
25
|
+
"active": 1.0,
|
|
26
|
+
"superseded": 0.5,
|
|
27
|
+
"contradicted": 0.3,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def recency_score(memory: Memory, now: float | None = None) -> float:
|
|
32
|
+
"""Exponential recency decay. Uses last_accessed if available, else created_at."""
|
|
33
|
+
if now is None:
|
|
34
|
+
now = time.time()
|
|
35
|
+
t_ref = memory.last_accessed if memory.last_accessed is not None else memory.created_at
|
|
36
|
+
days_elapsed = (now - t_ref) / 86400.0
|
|
37
|
+
return math.exp(-LAMBDA_RECENCY * days_elapsed)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def strength_score(memory: Memory, now: float | None = None) -> float:
|
|
41
|
+
"""Strength with reinforcement and natural decay, clamped to [0, 1]."""
|
|
42
|
+
if now is None:
|
|
43
|
+
now = time.time()
|
|
44
|
+
days_since_creation = (now - memory.created_at) / 86400.0
|
|
45
|
+
raw = memory.strength * (1 + memory.access_count) ** BETA_REINFORCE * math.exp(
|
|
46
|
+
-ALPHA_DECAY * days_since_creation
|
|
47
|
+
)
|
|
48
|
+
return max(0.0, min(1.0, raw))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _normalize(values: dict[str, float]) -> dict[str, float]:
|
|
52
|
+
"""Min-max normalize to [0, 1]. If all values are equal, return 1.0 for all."""
|
|
53
|
+
if not values:
|
|
54
|
+
return {}
|
|
55
|
+
max_v = max(values.values())
|
|
56
|
+
min_v = min(values.values())
|
|
57
|
+
span = max_v - min_v
|
|
58
|
+
if span == 0:
|
|
59
|
+
return {k: 1.0 for k in values}
|
|
60
|
+
return {k: (v - min_v) / span for k, v in values.items()}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def compete(
|
|
64
|
+
activations: dict[str, float],
|
|
65
|
+
memories: dict[str, Memory],
|
|
66
|
+
weights: dict[str, float] | None = None,
|
|
67
|
+
now: float | None = None,
|
|
68
|
+
) -> list[ScoredMemory]:
|
|
69
|
+
"""Score and rank activated memories using the competition model.
|
|
70
|
+
|
|
71
|
+
Returns ScoredMemory list sorted by descending score.
|
|
72
|
+
"""
|
|
73
|
+
if now is None:
|
|
74
|
+
now = time.time()
|
|
75
|
+
w = weights or DEFAULT_WEIGHTS
|
|
76
|
+
|
|
77
|
+
if not activations:
|
|
78
|
+
return []
|
|
79
|
+
|
|
80
|
+
# Compute raw component scores
|
|
81
|
+
raw_activation = {mid: activations[mid] for mid in activations if mid in memories}
|
|
82
|
+
raw_recency = {mid: recency_score(memories[mid], now) for mid in raw_activation}
|
|
83
|
+
raw_strength = {mid: strength_score(memories[mid], now) for mid in raw_activation}
|
|
84
|
+
|
|
85
|
+
# Normalize activation and strength
|
|
86
|
+
norm_activation = _normalize(raw_activation)
|
|
87
|
+
norm_strength = _normalize(raw_strength)
|
|
88
|
+
|
|
89
|
+
results = []
|
|
90
|
+
for mid in raw_activation:
|
|
91
|
+
mem = memories[mid]
|
|
92
|
+
components = {
|
|
93
|
+
"activation": norm_activation[mid],
|
|
94
|
+
"recency": raw_recency[mid],
|
|
95
|
+
"strength": norm_strength[mid],
|
|
96
|
+
"confidence": mem.confidence,
|
|
97
|
+
}
|
|
98
|
+
score = (
|
|
99
|
+
w["activation"] * components["activation"]
|
|
100
|
+
+ w["recency"] * components["recency"]
|
|
101
|
+
+ w["strength"] * components["strength"]
|
|
102
|
+
+ w["confidence"] * components["confidence"]
|
|
103
|
+
)
|
|
104
|
+
# Apply status penalty
|
|
105
|
+
penalty = STATUS_PENALTY.get(mem.status, 1.0)
|
|
106
|
+
score *= penalty
|
|
107
|
+
|
|
108
|
+
results.append(
|
|
109
|
+
ScoredMemory(
|
|
110
|
+
memory=mem,
|
|
111
|
+
score=score,
|
|
112
|
+
activation=activations[mid],
|
|
113
|
+
components=components,
|
|
114
|
+
)
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
results.sort(key=lambda s: s.score, reverse=True)
|
|
118
|
+
return results
|
openmem/store.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sqlite3
|
|
5
|
+
import time
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from .models import Edge, Memory
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SQLiteStore:
|
|
12
|
+
def __init__(self, db_path: str = ":memory:"):
|
|
13
|
+
self.conn = sqlite3.connect(db_path)
|
|
14
|
+
self.conn.row_factory = sqlite3.Row
|
|
15
|
+
self.conn.execute("PRAGMA journal_mode=WAL")
|
|
16
|
+
self.conn.execute("PRAGMA foreign_keys=ON")
|
|
17
|
+
self._create_tables()
|
|
18
|
+
|
|
19
|
+
def _create_tables(self) -> None:
|
|
20
|
+
self.conn.executescript("""
|
|
21
|
+
CREATE TABLE IF NOT EXISTS memories (
|
|
22
|
+
id TEXT PRIMARY KEY,
|
|
23
|
+
type TEXT NOT NULL DEFAULT 'fact',
|
|
24
|
+
text TEXT NOT NULL,
|
|
25
|
+
gist TEXT,
|
|
26
|
+
entities TEXT NOT NULL DEFAULT '[]',
|
|
27
|
+
created_at REAL NOT NULL,
|
|
28
|
+
updated_at REAL NOT NULL,
|
|
29
|
+
strength REAL NOT NULL DEFAULT 1.0,
|
|
30
|
+
confidence REAL NOT NULL DEFAULT 1.0,
|
|
31
|
+
access_count INTEGER NOT NULL DEFAULT 0,
|
|
32
|
+
last_accessed REAL,
|
|
33
|
+
status TEXT NOT NULL DEFAULT 'active'
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
CREATE TABLE IF NOT EXISTS edges (
|
|
37
|
+
id TEXT PRIMARY KEY,
|
|
38
|
+
source_id TEXT NOT NULL,
|
|
39
|
+
target_id TEXT NOT NULL,
|
|
40
|
+
rel_type TEXT NOT NULL DEFAULT 'mentions',
|
|
41
|
+
weight REAL NOT NULL DEFAULT 0.5,
|
|
42
|
+
created_at REAL NOT NULL,
|
|
43
|
+
FOREIGN KEY (source_id) REFERENCES memories(id),
|
|
44
|
+
FOREIGN KEY (target_id) REFERENCES memories(id)
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
|
|
48
|
+
id UNINDEXED,
|
|
49
|
+
text,
|
|
50
|
+
gist,
|
|
51
|
+
entities,
|
|
52
|
+
content='memories',
|
|
53
|
+
content_rowid='rowid'
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
-- Triggers to keep FTS in sync
|
|
57
|
+
CREATE TRIGGER IF NOT EXISTS memories_ai AFTER INSERT ON memories BEGIN
|
|
58
|
+
INSERT INTO memories_fts(rowid, id, text, gist, entities)
|
|
59
|
+
VALUES (new.rowid, new.id, new.text, new.gist, new.entities);
|
|
60
|
+
END;
|
|
61
|
+
|
|
62
|
+
CREATE TRIGGER IF NOT EXISTS memories_ad AFTER DELETE ON memories BEGIN
|
|
63
|
+
INSERT INTO memories_fts(memories_fts, rowid, id, text, gist, entities)
|
|
64
|
+
VALUES ('delete', old.rowid, old.id, old.text, old.gist, old.entities);
|
|
65
|
+
END;
|
|
66
|
+
|
|
67
|
+
CREATE TRIGGER IF NOT EXISTS memories_au AFTER UPDATE ON memories BEGIN
|
|
68
|
+
INSERT INTO memories_fts(memories_fts, rowid, id, text, gist, entities)
|
|
69
|
+
VALUES ('delete', old.rowid, old.id, old.text, old.gist, old.entities);
|
|
70
|
+
INSERT INTO memories_fts(rowid, id, text, gist, entities)
|
|
71
|
+
VALUES (new.rowid, new.id, new.text, new.gist, new.entities);
|
|
72
|
+
END;
|
|
73
|
+
""")
|
|
74
|
+
self.conn.commit()
|
|
75
|
+
|
|
76
|
+
def _row_to_memory(self, row: sqlite3.Row) -> Memory:
|
|
77
|
+
return Memory(
|
|
78
|
+
id=row["id"],
|
|
79
|
+
type=row["type"],
|
|
80
|
+
text=row["text"],
|
|
81
|
+
gist=row["gist"],
|
|
82
|
+
entities=json.loads(row["entities"]),
|
|
83
|
+
created_at=row["created_at"],
|
|
84
|
+
updated_at=row["updated_at"],
|
|
85
|
+
strength=row["strength"],
|
|
86
|
+
confidence=row["confidence"],
|
|
87
|
+
access_count=row["access_count"],
|
|
88
|
+
last_accessed=row["last_accessed"],
|
|
89
|
+
status=row["status"],
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
def _row_to_edge(self, row: sqlite3.Row) -> Edge:
|
|
93
|
+
return Edge(
|
|
94
|
+
id=row["id"],
|
|
95
|
+
source_id=row["source_id"],
|
|
96
|
+
target_id=row["target_id"],
|
|
97
|
+
rel_type=row["rel_type"],
|
|
98
|
+
weight=row["weight"],
|
|
99
|
+
created_at=row["created_at"],
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
def add_memory(self, memory: Memory) -> Memory:
|
|
103
|
+
entities_json = json.dumps(memory.entities)
|
|
104
|
+
self.conn.execute(
|
|
105
|
+
"""INSERT INTO memories (id, type, text, gist, entities, created_at,
|
|
106
|
+
updated_at, strength, confidence, access_count, last_accessed, status)
|
|
107
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
108
|
+
(
|
|
109
|
+
memory.id, memory.type, memory.text, memory.gist, entities_json,
|
|
110
|
+
memory.created_at, memory.updated_at, memory.strength,
|
|
111
|
+
memory.confidence, memory.access_count, memory.last_accessed,
|
|
112
|
+
memory.status,
|
|
113
|
+
),
|
|
114
|
+
)
|
|
115
|
+
self.conn.commit()
|
|
116
|
+
return memory
|
|
117
|
+
|
|
118
|
+
def add_edge(self, edge: Edge) -> Edge:
|
|
119
|
+
self.conn.execute(
|
|
120
|
+
"""INSERT INTO edges (id, source_id, target_id, rel_type, weight, created_at)
|
|
121
|
+
VALUES (?, ?, ?, ?, ?, ?)""",
|
|
122
|
+
(edge.id, edge.source_id, edge.target_id, edge.rel_type,
|
|
123
|
+
edge.weight, edge.created_at),
|
|
124
|
+
)
|
|
125
|
+
self.conn.commit()
|
|
126
|
+
return edge
|
|
127
|
+
|
|
128
|
+
def get_memory(self, memory_id: str) -> Optional[Memory]:
|
|
129
|
+
row = self.conn.execute(
|
|
130
|
+
"SELECT * FROM memories WHERE id = ?", (memory_id,)
|
|
131
|
+
).fetchone()
|
|
132
|
+
return self._row_to_memory(row) if row else None
|
|
133
|
+
|
|
134
|
+
def get_edges(self, memory_id: str) -> list[Edge]:
|
|
135
|
+
rows = self.conn.execute(
|
|
136
|
+
"SELECT * FROM edges WHERE source_id = ? OR target_id = ?",
|
|
137
|
+
(memory_id, memory_id),
|
|
138
|
+
).fetchall()
|
|
139
|
+
return [self._row_to_edge(r) for r in rows]
|
|
140
|
+
|
|
141
|
+
def get_neighbors(self, memory_id: str) -> list[tuple[Edge, Memory]]:
|
|
142
|
+
edges = self.get_edges(memory_id)
|
|
143
|
+
result = []
|
|
144
|
+
for edge in edges:
|
|
145
|
+
neighbor_id = edge.target_id if edge.source_id == memory_id else edge.source_id
|
|
146
|
+
neighbor = self.get_memory(neighbor_id)
|
|
147
|
+
if neighbor:
|
|
148
|
+
result.append((edge, neighbor))
|
|
149
|
+
return result
|
|
150
|
+
|
|
151
|
+
def search_bm25(self, query: str, limit: int = 20) -> list[tuple[str, float]]:
|
|
152
|
+
"""FTS5 MATCH with BM25 ranking. Returns (memory_id, bm25_score) pairs."""
|
|
153
|
+
# Escape special FTS5 characters in the query
|
|
154
|
+
safe_query = self._escape_fts_query(query)
|
|
155
|
+
if not safe_query.strip():
|
|
156
|
+
return []
|
|
157
|
+
rows = self.conn.execute(
|
|
158
|
+
"""SELECT id, bm25(memories_fts) as rank
|
|
159
|
+
FROM memories_fts
|
|
160
|
+
WHERE memories_fts MATCH ?
|
|
161
|
+
ORDER BY rank
|
|
162
|
+
LIMIT ?""",
|
|
163
|
+
(safe_query, limit),
|
|
164
|
+
).fetchall()
|
|
165
|
+
# bm25() returns negative scores (lower = better match), negate for positive scores
|
|
166
|
+
return [(row["id"], -row["rank"]) for row in rows]
|
|
167
|
+
|
|
168
|
+
def _escape_fts_query(self, query: str) -> str:
|
|
169
|
+
"""Turn a raw user query into a safe FTS5 query by quoting each token."""
|
|
170
|
+
tokens = query.split()
|
|
171
|
+
if not tokens:
|
|
172
|
+
return ""
|
|
173
|
+
# Quote each token to avoid FTS5 syntax errors from special chars
|
|
174
|
+
quoted = ['"' + t.replace('"', '""') + '"' for t in tokens]
|
|
175
|
+
return " OR ".join(quoted)
|
|
176
|
+
|
|
177
|
+
def update_access(self, memory_id: str) -> None:
|
|
178
|
+
now = time.time()
|
|
179
|
+
self.conn.execute(
|
|
180
|
+
"""UPDATE memories SET access_count = access_count + 1,
|
|
181
|
+
last_accessed = ?, updated_at = ? WHERE id = ?""",
|
|
182
|
+
(now, now, memory_id),
|
|
183
|
+
)
|
|
184
|
+
self.conn.commit()
|
|
185
|
+
|
|
186
|
+
def update_memory(self, memory: Memory) -> None:
|
|
187
|
+
entities_json = json.dumps(memory.entities)
|
|
188
|
+
self.conn.execute(
|
|
189
|
+
"""UPDATE memories SET type=?, text=?, gist=?, entities=?,
|
|
190
|
+
updated_at=?, strength=?, confidence=?, access_count=?,
|
|
191
|
+
last_accessed=?, status=? WHERE id=?""",
|
|
192
|
+
(
|
|
193
|
+
memory.type, memory.text, memory.gist, entities_json,
|
|
194
|
+
memory.updated_at, memory.strength, memory.confidence,
|
|
195
|
+
memory.access_count, memory.last_accessed, memory.status,
|
|
196
|
+
memory.id,
|
|
197
|
+
),
|
|
198
|
+
)
|
|
199
|
+
self.conn.commit()
|
|
200
|
+
|
|
201
|
+
def all_memories(self) -> list[Memory]:
|
|
202
|
+
rows = self.conn.execute("SELECT * FROM memories").fetchall()
|
|
203
|
+
return [self._row_to_memory(r) for r in rows]
|
|
204
|
+
|
|
205
|
+
def close(self) -> None:
|
|
206
|
+
self.conn.close()
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: openmem-engine
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Cognitive memory engine for AI agents — human-inspired retrieval via activation, competition, and reconstruction
|
|
5
|
+
Project-URL: Homepage, https://github.com/dunkinfrunkin/OpenMem
|
|
6
|
+
Project-URL: Documentation, https://dunkinfrunkin.github.io/OpenMem/
|
|
7
|
+
Project-URL: Repository, https://github.com/dunkinfrunkin/OpenMem
|
|
8
|
+
Project-URL: Issues, https://github.com/dunkinfrunkin/OpenMem/issues
|
|
9
|
+
Author: OpenMem
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
Keywords: agents,ai,cognitive,llm,memory,sqlite
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
24
|
+
Provides-Extra: mcp
|
|
25
|
+
Requires-Dist: mcp>=1.0; extra == 'mcp'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# OpenMem
|
|
29
|
+
|
|
30
|
+
Deterministic memory engine for AI agents. Retrieves context via BM25 lexical search, graph-based spreading activation, and human-inspired competition scoring. SQLite-backed, zero dependencies.
|
|
31
|
+
|
|
32
|
+
## How it works
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
Query → FTS5/BM25 (lexical trigger)
|
|
36
|
+
→ Seed Activation
|
|
37
|
+
→ Spreading Activation (graph edges, max 2 hops)
|
|
38
|
+
→ Recency + Strength + Confidence weighting
|
|
39
|
+
→ Competition (score-based ranking)
|
|
40
|
+
→ Context Pack (token-budgeted output)
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
No vectors, no embeddings, no LLM in the retrieval loop. The LLM is the consumer, not the retriever.
|
|
44
|
+
|
|
45
|
+
## Install
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pip install openmem-engine
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Or from source:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
git clone https://github.com/yourorg/openmem.git
|
|
55
|
+
cd openmem
|
|
56
|
+
pip install -e ".[dev]"
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Quick start
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
from openmem import MemoryEngine
|
|
63
|
+
|
|
64
|
+
engine = MemoryEngine() # in-memory, or MemoryEngine("memories.db") for persistence
|
|
65
|
+
|
|
66
|
+
# Store memories
|
|
67
|
+
m1 = engine.add("We chose SQLite over Postgres for simplicity", type="decision", entities=["SQLite", "Postgres"])
|
|
68
|
+
m2 = engine.add("Postgres has better concurrent write support", type="fact", entities=["Postgres"])
|
|
69
|
+
|
|
70
|
+
# Link related memories
|
|
71
|
+
engine.link(m1.id, m2.id, "supports")
|
|
72
|
+
|
|
73
|
+
# Recall
|
|
74
|
+
results = engine.recall("Why did we pick SQLite?")
|
|
75
|
+
for r in results:
|
|
76
|
+
print(f"{r.score:.3f} | {r.memory.text}")
|
|
77
|
+
# 0.800 | We chose SQLite over Postgres for simplicity
|
|
78
|
+
# 0.500 | Postgres has better concurrent write support
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Claude Code plugin
|
|
82
|
+
|
|
83
|
+
Install OpenMem as a Claude Code plugin to get persistent memory across sessions:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
pip install openmem-engine "mcp>=1.0"
|
|
87
|
+
claude plugin install --path ./plugin
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Once installed, you get three slash commands:
|
|
91
|
+
|
|
92
|
+
| Command | Description |
|
|
93
|
+
|---------|-------------|
|
|
94
|
+
| `/openmem:recall` | Recall memories relevant to the current conversation |
|
|
95
|
+
| `/openmem:store` | Store key facts, decisions, and preferences from the conversation |
|
|
96
|
+
| `/openmem:status` | Show memory store statistics |
|
|
97
|
+
|
|
98
|
+
The plugin also registers an MCP server with 7 tools (`memory_store`, `memory_recall`, `memory_link`, `memory_reinforce`, `memory_supersede`, `memory_contradict`, `memory_stats`) that Claude can call automatically.
|
|
99
|
+
|
|
100
|
+
Memories persist in `~/.openmem/memories.db` by default (override with the `OPENMEM_DB` env var).
|
|
101
|
+
|
|
102
|
+
## Usage with an LLM agent
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
engine = MemoryEngine("project.db")
|
|
106
|
+
|
|
107
|
+
# Agent stores what it learns
|
|
108
|
+
engine.add("User prefers TypeScript over JavaScript", type="preference", entities=["TypeScript", "JavaScript"])
|
|
109
|
+
engine.add("Auth system uses JWT with 24h expiry", type="decision", entities=["JWT", "auth"])
|
|
110
|
+
engine.add("The /api/users endpoint returns 500 on empty payload", type="incident", entities=["/api/users"])
|
|
111
|
+
|
|
112
|
+
# Before each LLM call, recall relevant context
|
|
113
|
+
results = engine.recall("set up authentication", top_k=5, token_budget=2000)
|
|
114
|
+
context = "\n".join(r.memory.text for r in results)
|
|
115
|
+
|
|
116
|
+
prompt = f"""Relevant context from previous work:
|
|
117
|
+
{context}
|
|
118
|
+
|
|
119
|
+
User request: {user_message}"""
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## API
|
|
123
|
+
|
|
124
|
+
### `MemoryEngine(db_path=":memory:", **config)`
|
|
125
|
+
|
|
126
|
+
| Method | Description |
|
|
127
|
+
|--------|-------------|
|
|
128
|
+
| `add(text, type="fact", entities=None, confidence=1.0, gist=None)` | Store a memory |
|
|
129
|
+
| `link(source_id, target_id, rel_type, weight=0.5)` | Create an edge between memories |
|
|
130
|
+
| `recall(query, top_k=5, token_budget=2000)` | Retrieve relevant memories |
|
|
131
|
+
| `reinforce(memory_id)` | Boost a memory's strength |
|
|
132
|
+
| `supersede(old_id, new_id)` | Mark a memory as outdated |
|
|
133
|
+
| `contradict(id_a, id_b)` | Flag two memories as contradicting |
|
|
134
|
+
| `decay_all()` | Run decay pass over all memories |
|
|
135
|
+
| `stats()` | Get summary statistics |
|
|
136
|
+
|
|
137
|
+
### Memory types
|
|
138
|
+
|
|
139
|
+
`fact` · `decision` · `preference` · `incident` · `plan` · `constraint`
|
|
140
|
+
|
|
141
|
+
### Edge types
|
|
142
|
+
|
|
143
|
+
`mentions` · `supports` · `contradicts` · `depends_on` · `same_as`
|
|
144
|
+
|
|
145
|
+
## Retrieval model
|
|
146
|
+
|
|
147
|
+
**Recency** — Exponential decay with ~14-day half-life. Recently accessed memories surface first.
|
|
148
|
+
|
|
149
|
+
**Strength** — Reinforced on access, decays naturally over time. Frequently recalled memories persist.
|
|
150
|
+
|
|
151
|
+
**Spreading activation** — Memories linked by edges activate their neighbors. A query hitting one memory pulls in related context up to 2 hops away.
|
|
152
|
+
|
|
153
|
+
**Competition** — Final score combines activation (50%), recency (20%), strength (20%), and confidence (10%). Superseded memories are penalized 50%, contradicted ones 70%.
|
|
154
|
+
|
|
155
|
+
**Conflict resolution** — When two contradicting memories both activate, the weaker one (by strength × confidence × recency) gets demoted.
|
|
156
|
+
|
|
157
|
+
## Tests
|
|
158
|
+
|
|
159
|
+
```bash
|
|
160
|
+
pip install -e ".[dev]"
|
|
161
|
+
pytest tests/ -v
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
## License
|
|
165
|
+
|
|
166
|
+
MIT
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
openmem/__init__.py,sha256=jBfOWsajHfAKbAk3JQnv9UK4gE569lu9VHY2U4U8qzw,142
|
|
2
|
+
openmem/activation.py,sha256=FPcOG-nWAC32WpFwT_QSIwRdk_NS1gB07oxoPQ4fdRA,1134
|
|
3
|
+
openmem/conflict.py,sha256=t06c_ChuDv2VNrVLH_1j70E0sXJep_VInzwgVmlMdGU,1927
|
|
4
|
+
openmem/engine.py,sha256=ubb8jczkl6K_4Wg2t_97bXfMWjqDfHWlqWmBbkvnZ4w,5902
|
|
5
|
+
openmem/models.py,sha256=tcWFqfFTHOfsa_eG-b1CcNKaplU1IUlK9KWZ72JaCMw,1330
|
|
6
|
+
openmem/scoring.py,sha256=yeZQyHM7WQg4FrqitOUPrcuTqeXLhS47bImR2EKAbtU,3518
|
|
7
|
+
openmem/store.py,sha256=WIhtSRQD7K7kAU0GXc7cPbZyEIbSJXZiENMUGmwIg14,8057
|
|
8
|
+
openmem_engine-0.1.0.dist-info/METADATA,sha256=ojampS4bUOsLSkxhGDQzM4Pe2AQyNPJBHtOBc6JYwwA,5756
|
|
9
|
+
openmem_engine-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
10
|
+
openmem_engine-0.1.0.dist-info/RECORD,,
|