Skip to content

Commit fe3a1a0

Browse files
committed
Improved API layer
1 parent 1473a41 commit fe3a1a0

9 files changed

Lines changed: 266 additions & 103 deletions

File tree

crates/mnemos-core/src/facade.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -199,9 +199,10 @@ impl Mnemos {
199199
Ok(id.0)
200200
}
201201

202-
/// Store a memory with explicit content bytes.
202+
/// Store a memory with explicit content bytes optionally in a namespace.
203203
pub fn remember_with_content(
204204
&self,
205+
namespace: &str,
205206
content: Vec<u8>,
206207
embedding: Vec<f32>,
207208
metadata: Option<HashMap<String, String>>,
@@ -215,7 +216,7 @@ impl Mnemos {
215216
.unwrap_or_default()
216217
.as_secs();
217218

218-
let mut entry = MemoryEntry::new(id, "default".to_string(), content, ts)
219+
let mut entry = MemoryEntry::new(id, namespace.to_string(), content, ts)
219220
.with_embedding(embedding);
220221
if let Some(meta) = metadata {
221222
entry.metadata = meta;

crates/mnemos-py/.gitignore

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,6 @@
11
.venv/
2-
.env
2+
.env
3+
*.so
4+
*.dylib
5+
*.pyd
6+
__pycache__

crates/mnemos-py/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ version = "0.1.0"
44
edition = "2024"
55

66
[lib]
7-
name = "mnemos"
7+
name = "_mnemos"
88
crate-type = ["cdylib"]
99

1010
[dependencies]
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
from .client import Mnemos, Namespace
2+
from ._mnemos import Hit, Memory, Stats, MnemosError
3+
4+
__all__ = ["Mnemos", "Namespace", "Hit", "Memory", "Stats", "MnemosError"]

crates/mnemos-py/mnemos/client.py

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import typing as t
2+
3+
from ._mnemos import MnemosError, Hit, Memory, Stats
4+
from . import _mnemos
5+
6+
7+
class Namespace:
8+
"""
9+
A scoped context for Mnemos operations.
10+
Automatically applies the namespace name to all store/query operations.
11+
"""
12+
def __init__(self, db: "Mnemos", name: str):
13+
self._db = db
14+
self.name = name
15+
16+
def remember(
17+
self,
18+
text: str,
19+
embedding: t.Union[t.List[float], None] = None,
20+
metadata: t.Union[t.Dict[str, str], None] = None
21+
) -> int:
22+
"""
23+
Store a new memory in this namespace.
24+
Currently requires an embedding.
25+
"""
26+
if embedding is None:
27+
raise MnemosError(f"Embedding is currently required natively.")
28+
29+
return self._db._inner.remember_embedding(
30+
embedding=embedding,
31+
metadata=metadata,
32+
namespace=self.name,
33+
content=text,
34+
)
35+
36+
def ask(self, query: str, embedding: t.Union[t.List[float], None] = None, top_k: int = 5) -> t.List[Hit]:
37+
"""
38+
Query for memories in this namespace using an embedding.
39+
Currently requires an embedding.
40+
Note: The underlying C API currently searches globally. We will refine this.
41+
"""
42+
if embedding is None:
43+
raise MnemosError(f"Embedding is currently required natively.")
44+
45+
# In the future, ask will be constrained by namespace.
46+
# For now, it delegates to the backend.
47+
hits = self._db._inner.ask_embedding(embedding=embedding, top_k=top_k)
48+
# Manually filter by namespace since core `ask` doesn't enforce it yet
49+
filtered_hits = []
50+
for hit in hits:
51+
m = self._db.get(hit.id)
52+
if m.namespace == self.name:
53+
filtered_hits.append(hit)
54+
if len(filtered_hits) == top_k:
55+
break
56+
return filtered_hits
57+
58+
59+
class Mnemos:
60+
"""
61+
Pythonic interface to the Mnemos embedded vector + graph database.
62+
"""
63+
def __init__(self, path: str, dimension: int):
64+
try:
65+
self._inner = _mnemos.Mnemos.open(path, dimension=dimension)
66+
except Exception as e:
67+
raise MnemosError(str(e))
68+
69+
@classmethod
70+
def open(cls, path: str, dimension: int) -> "Mnemos":
71+
"""
72+
Open or create a Mnemos database.
73+
74+
Args:
75+
path: Directory path for the database files.
76+
dimension: Vector embedding dimension (required).
77+
"""
78+
return cls(path, dimension)
79+
80+
def namespace(self, name: str) -> Namespace:
81+
"""
82+
Return a scoped Namespace context.
83+
"""
84+
return Namespace(self, name)
85+
86+
def remember(
87+
self,
88+
text: str,
89+
embedding: t.Union[t.List[float], None] = None,
90+
metadata: t.Union[t.Dict[str, str], None] = None,
91+
namespace: str = "default",
92+
) -> int:
93+
"""
94+
Store a new memory.
95+
"""
96+
if embedding is None:
97+
raise MnemosError(f"Embedding is currently required natively.")
98+
return self._inner.remember_embedding(
99+
embedding=embedding,
100+
metadata=metadata,
101+
namespace=namespace,
102+
content=text,
103+
)
104+
105+
def ask(self, query: str, embedding: t.Union[t.List[float], None] = None, top_k: int = 5) -> t.List[Hit]:
106+
"""
107+
Query the database by embedding vector similarity.
108+
"""
109+
if embedding is None:
110+
raise MnemosError(f"Embedding is currently required natively.")
111+
return self._inner.ask_embedding(embedding=embedding, top_k=top_k)
112+
113+
def get(self, mid: int) -> Memory:
114+
"""Retrieve a full memory by ID."""
115+
return self._inner.get(mid)
116+
117+
def connect(self, from_id: int, to_id: int, relation: str):
118+
"""Create an edge between two memories."""
119+
self._inner.connect(from_id, to_id, relation)
120+
121+
def compact(self):
122+
"""Compact on-disk segment storage."""
123+
self._inner.compact()
124+
125+
def checkpoint(self):
126+
"""Force a checkpoint (snapshot states + truncate WAL)."""
127+
self._inner.checkpoint()
128+
129+
def stats(self) -> Stats:
130+
"""Get database statistics."""
131+
return self._inner.stats()
132+
133+
def __repr__(self) -> str:
134+
return repr(self._inner).replace("Mnemos(", "MnemosClient(")
135+
136+
def __len__(self) -> int:
137+
return len(self._inner)
138+
139+
def __enter__(self):
140+
return self
141+
142+
def __exit__(self, exc_type, exc_value, traceback):
143+
return False

crates/mnemos-py/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,4 @@ license = { text = "GPL-3.0" }
1111

1212
[tool.maturin]
1313
features = ["pyo3/extension-module"]
14+
module-name = "mnemos._mnemos"

crates/mnemos-py/src/lib.rs

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -214,15 +214,16 @@ impl PyMnemos {
214214
/// Raises:
215215
/// MnemosError: If the embedding dimension is wrong.
216216
#[pyo3(
217-
text_signature = "(self, embedding, *, metadata=None, namespace='default')",
218-
signature = (embedding, *, metadata=None, namespace="default".to_string())
217+
text_signature = "(self, embedding, *, metadata=None, namespace='default', content='')",
218+
signature = (embedding, *, metadata=None, namespace="default".to_string(), content="".to_string())
219219
)]
220220
fn remember_embedding(
221221
&self,
222222
py: Python<'_>,
223223
embedding: Vec<f32>,
224224
metadata: Option<HashMap<String, String>>,
225225
namespace: String,
226+
content: String,
226227
) -> PyResult<u64> {
227228
if embedding.len() != self.dimension {
228229
return Err(MnemosError::new_err(format!(
@@ -233,8 +234,11 @@ impl PyMnemos {
233234
}
234235

235236
let id = py.allow_threads(|| {
236-
self.inner
237-
.remember_in_namespace(&namespace, embedding, metadata)
237+
if content.is_empty() {
238+
self.inner.remember_in_namespace(&namespace, embedding, metadata)
239+
} else {
240+
self.inner.remember_with_content(&namespace, content.into_bytes(), embedding, metadata)
241+
}
238242
}).map_err(to_py_err)?;
239243
Ok(id)
240244
}
@@ -379,7 +383,7 @@ impl PyMnemos {
379383

380384
/// Mnemos — embedded vector + graph memory for AI agents.
381385
#[pymodule]
382-
fn mnemos(m: &Bound<'_, PyModule>) -> PyResult<()> {
386+
fn _mnemos(m: &Bound<'_, PyModule>) -> PyResult<()> {
383387
m.add_class::<PyMnemos>()?;
384388
m.add_class::<PyHit>()?;
385389
m.add_class::<PyMemory>()?;

crates/mnemos-py/test_smoke.py

Lines changed: 60 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,63 +1,85 @@
1-
from mnemos import Mnemos
1+
import pytest
2+
import os
3+
import shutil
4+
from mnemos import Mnemos, MnemosError
25

3-
def test_mnemos():
4-
print("Testing Mnemos PyO3 bindings...")
5-
6+
DB_PATH = "/tmp/mnemos_test_py"
7+
8+
@pytest.fixture(autouse=True)
9+
def cleanup():
10+
if os.path.exists(DB_PATH):
11+
shutil.rmtree(DB_PATH)
12+
yield
13+
if os.path.exists(DB_PATH):
14+
shutil.rmtree(DB_PATH)
15+
16+
def test_mnemos_basic_flow():
617
# 1. Open with dimension 3
7-
db = Mnemos.open("/tmp/mnemos_test_py", dimension=3)
18+
db = Mnemos.open(DB_PATH, dimension=3)
819

920
# 2. Store memory
10-
mid = db.remember_embedding([1.0, 0.0, 0.0])
11-
print(f"Stored memory: {mid}")
21+
mid = db.remember("Hello world", embedding=[1.0, 0.0, 0.0])
1222

1323
# 3. Ask
14-
hits = db.ask_embedding([1.0, 0.0, 0.0])
15-
print(f"Found {len(hits)} hits, top score={hits[0].score:.3f}")
24+
hits = db.ask("world", embedding=[1.0, 0.0, 0.0])
25+
assert len(hits) == 1
1626
assert hits[0].id == mid
1727

1828
# 4. Get full memory
1929
mem = db.get(mid)
20-
print(f"Memory: {mem}")
2130
assert mem.namespace == "default"
2231
assert mem.id == mid
32+
assert bytes(mem.content).decode("utf-8") == "Hello world"
2333

2434
# 5. Connect
25-
mid2 = db.remember_embedding([0.0, 1.0, 0.0])
35+
mid2 = db.remember("Goodbye", embedding=[0.0, 1.0, 0.0])
2636
db.connect(mid, mid2, "related")
27-
print(f"Connected {mid} -> {mid2}")
2837

29-
# 6. Stats
38+
# 6. Stats & Len
3039
stats = db.stats()
31-
print(f"Stats: {stats}")
3240
assert stats.vector_dimension == 3
3341
assert stats.entries == 2
42+
assert len(db) == 2
3443

35-
# 7. Stress - error handling
36-
try:
37-
db.remember_embedding([1.0, 0.0]) # Wrong dimension
38-
assert False, "Should have raised an error"
39-
except Exception as e:
40-
print(f"Caught expected error: {e}")
44+
# 7. Compact and Checkpoint
45+
db.compact()
46+
db.checkpoint()
4147

42-
try:
43-
db2 = Mnemos.open("/tmp/mnemos_test_py", dimension=4) # Dimension mismatch on open
44-
assert False, "Should have raised an error"
45-
except Exception as e:
46-
print(f"Caught expected error: {e}")
4748

48-
# 8. Compact
49-
db.compact()
50-
print("Compacted successfully")
49+
def test_mnemos_namespaces():
50+
db = Mnemos.open(DB_PATH, dimension=3)
51+
52+
agent_a = db.namespace("agent_a")
53+
agent_b = db.namespace("agent_b")
5154

52-
# 9. Checkpoint
53-
db.checkpoint()
54-
print("Checkpointed successfully")
55+
id_a = agent_a.remember("I am Agent A", embedding=[1.0, 0.0, 0.0])
56+
agent_b.remember("I am Agent B", embedding=[0.0, 1.0, 0.0])
57+
58+
assert db.get(id_a).namespace == "agent_a"
59+
60+
# Test ask filters by namespace using the wrapper
61+
hits_a = agent_a.ask("Agent A", embedding=[1.0, 0.0, 0.0])
62+
assert len(hits_a) == 1
63+
assert hits_a[0].id == id_a
64+
65+
# Context manager test
66+
with Mnemos.open(DB_PATH, dimension=3) as db_ctx:
67+
assert len(db_ctx) == 2
68+
69+
70+
def test_mnemos_error_handling():
71+
db = Mnemos.open(DB_PATH, dimension=3)
5572

56-
print("All tests passed!")
73+
# Wrong dimension map
74+
with pytest.raises(MnemosError, match="embedding dimension mismatch"):
75+
db.remember("Wrong dim", embedding=[1.0, 0.0])
76+
77+
# Missing embedding required
78+
with pytest.raises(MnemosError, match="Embedding is currently required natively"):
79+
db.remember("No embedding")
5780

58-
if __name__ == "__main__":
59-
import shutil
60-
import os
61-
if os.path.exists("/tmp/mnemos_test_py"):
62-
shutil.rmtree("/tmp/mnemos_test_py")
63-
test_mnemos()
81+
# Wrong dimension on open — must first write something with dim=3
82+
mid = db.remember("Seed", embedding=[1.0, 0.0, 0.0])
83+
db.checkpoint() # flush so the mismatch check sees entries > 0
84+
with pytest.raises(MnemosError, match="(?i)dimension mismatch"):
85+
Mnemos.open(DB_PATH, dimension=4)

0 commit comments

Comments
 (0)