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 ADDED
@@ -0,0 +1,4 @@
1
+ from .engine import MemoryEngine
2
+ from .models import Edge, Memory, ScoredMemory
3
+
4
+ __all__ = ["MemoryEngine", "Memory", "Edge", "ScoredMemory"]
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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any