diff --git a/README.md b/README.md index 913a4a3..c814506 100644 --- a/README.md +++ b/README.md @@ -72,9 +72,6 @@ from xmemory import MemoryBank bank = MemoryBank( bank_id="my-agent", db_url="postgresql://localhost/mydb", - llm_provider="openai", - llm_model="gpt-4o-mini", - embed_model="text-embedding-3-small" ) # Retain a memory @@ -83,27 +80,27 @@ bank.retain("User prefers dark mode in all applications") # Recall relevant memories results = bank.recall("What are the user's UI preferences?") for memory in results: - print(f"[{memory.confidence:.2f}] {memory.text}") + print(memory.text) # Get memory stats stats = bank.stats() -print(f"Total memories: {stats.total}, Avg confidence: {stats.avg_confidence}") +print(f"Total memories: {stats.total_memories}, Avg confidence: {stats.avg_confidence}") ``` ## Core Features -### 1. Semantic Deduplication +### 1. Semantic Recall -Automatically detects and removes semantically similar memories: +Use lexical search immediately, or semantic search when embeddings are available: ```python -# These will be deduplicated -bank.retain("The project uses Python 3.11") -bank.retain("Python 3.11 is used for the project") # → duplicate detected, skipped +bank.recall("Python 3.11") +bank.recall( + "UI preferences", + query_embedding=[0.1] * 384, +) ``` -**Result:** 11.45% memory reduction in production. - ### 2. Confidence Scoring Every memory gets a confidence score (0.0–1.0) based on: @@ -118,47 +115,36 @@ high_confidence = [m for m in results if m.confidence >= 0.8] **Production stats:** Average confidence 0.953 across 11,687 memories. -### 3. Temporal Invalidation +### 3. Persistence and Filtering -Memories are automatically archived when they become outdated: +Memories can be stored with fact types, tags, and context: ```python -bank.retain("Server is running version 2.1") # Later... -bank.retain("Server upgraded to version 2.3") # → old memory archived +bank.retain( + "Server is running version 2.1", + fact_type="world", + context="Deployment note", + tags=["ops", "server"], +) ``` -**Production stats:** 29.86% of memories naturally archived. - ### 4. Graph Traversal Navigate related memories through a knowledge graph: ```sql SELECT * FROM graph_recall( - embedding => (SELECT embedding FROM memory_units WHERE id = 'target-id'), - bank_id => 'my-agent', + query_embedding => (SELECT embedding FROM memory_units WHERE id = 'target-id'), + query_bank_id => 'my-agent', top_k => 10, expansion_depth => 2, min_weight => 0.5 ); ``` -**Production stats:** 820,016 links (entity: 648K, temporal: 87K, semantic: 83K). - ### 5. Cross-Session Consolidation -Link memories across different agents and channels: - -```python -# Agent A retains -bank_a.retain("Database migration scheduled for Friday") - -# Agent B discovers the link -results = bank_b.recall("any scheduled changes?") -# → Finds the migration memory via cross-bank link -``` - -**Production stats:** 34,168 cross-bank links across 24 memory banks. +The schema supports cross-bank linking through `memory_links`, but any higher-level consolidation workflow should be verified against the current code before relying on it. ## API Reference @@ -166,14 +152,11 @@ results = bank_b.recall("any scheduled changes?") | Operation | Method | Description | |-----------|--------|-------------| -| `recall` | POST | Semantic search with optional graph expansion | -| `retain` | POST | Store new memories with automatic fact extraction | +| `recall` | POST | Lexical search by default, semantic search when embeddings are provided | +| `retain` | POST | Store new memories with fact type, context, tags, and optional embedding | | `list` | GET | List memories with filtering and pagination | -| `consolidate` | POST | Trigger cross-bank consolidation | | `stats` | GET | Memory statistics (count, confidence, links) | -| `graph` | GET | Knowledge graph visualization data | -| `entities` | GET | Named entities extracted from memories | -| `tags` | GET | Memory tags and categories | +| `delete` | DELETE | Delete a memory by ID within the active bank | ### Memory Types diff --git a/pyproject.toml b/pyproject.toml index 70a092b..f57325d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ name = "xmemory-framework" version = "1.0.0" description = "Persistent semantic memory framework for AI agents" readme = "README.md" -license = {text = "Apache-2.0"} +license = "Apache-2.0" requires-python = ">=3.10" authors = [ {name = "Jose Manuel Sabarís García", email = "joker@openclaw.ai"} @@ -16,7 +16,6 @@ keywords = ["memory", "ai", "agents", "semantic", "vector", "pgvector"] classifiers = [ "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", - "License :: OSI Approved :: Apache Software License", "Programming Language :: Python :: 3", "Topic :: Scientific/Engineering :: Artificial Intelligence", ] @@ -53,7 +52,6 @@ Issues = "https://github.com/llllJokerllll/xMemory-framework/issues" [tool.pytest.ini_options] testpaths = ["tests"] -asyncio_mode = "auto" [tool.ruff] target-version = "py310" @@ -63,3 +61,6 @@ line-length = 100 python_version = "3.10" warn_return_any = true warn_unused_configs = true + +[tool.setuptools] +packages = ["xmemory"] diff --git a/tests/test_bank.py b/tests/test_bank.py index fea6c76..c13665e 100644 --- a/tests/test_bank.py +++ b/tests/test_bank.py @@ -86,6 +86,55 @@ def test_recall_has_latency(self, bank): result = bank.recall("test") assert result.latency_ms >= 0 + def test_recall_without_embedding_uses_lexical_fallback(self, monkeypatch): + class FakeCursor: + def __init__(self): + self.executed = [] + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def execute(self, query, params): + self.executed.append((query, params)) + + def fetchall(self): + return [ + ( + "123", + "User prefers dark mode", + "observation", + None, + "Settings", + ["ui"], + None, + None, + 0, + {}, + ) + ] + + class FakeConnection: + def __init__(self): + self.cursor_obj = FakeCursor() + + def cursor(self): + return self.cursor_obj + + fake_conn = FakeConnection() + bank = MB(bank_id="test-bank", db_url="postgresql://localhost/xmemory_test") + monkeypatch.setattr(bank, "_get_conn", lambda: fake_conn) + + result = bank.recall("dark mode") + + assert result.total == 1 + assert result.memories[0].text == "User prefers dark mode" + query, params = fake_conn.cursor_obj.executed[0] + assert "ILIKE" in query + assert params[1] == "%dark mode%" + # ─── List Tests ─────────────────────────────────────────────────────── diff --git a/xmemory/bank.py b/xmemory/bank.py index 7aaccf5..7a58706 100644 --- a/xmemory/bank.py +++ b/xmemory/bank.py @@ -11,7 +11,7 @@ import psycopg from pgvector.psycopg import register_vector -from xmemory.models import Memory, MemoryStats, RecallResult, RetainRequest, RetainResult +from xmemory.models import Memory, MemoryStats, RecallResult, RetainResult class MemoryBank: @@ -120,7 +120,7 @@ def recall( Semantic search for relevant memories. Uses vector similarity (cosine) for ranking. - Optionally expands results via graph traversal. + Falls back to lexical search when no embedding is provided. """ start = time.time() conn = self._get_conn() @@ -163,6 +163,44 @@ def recall( metadata=row[9] or {}, ) ) + else: + with conn.cursor() as cur: + fact_filter = "" + like_query = f"%{query}%" + params: list = [self.bank_id, like_query, like_query, top_k] + if fact_types: + placeholders = ",".join(["%s"] * len(fact_types)) + fact_filter = f" AND fact_type IN ({placeholders})" + params = [self.bank_id, like_query, like_query] + fact_types + [top_k] + + cur.execute( + f""" + SELECT id, text, fact_type, confidence_score, context, + tags, mentioned_at, created_at, access_count, metadata + FROM memory_units + WHERE bank_id = %s + AND (text ILIKE %s OR context ILIKE %s) + {fact_filter} + ORDER BY created_at DESC + LIMIT %s + """, + params, + ) + for row in cur.fetchall(): + memories.append( + Memory( + id=str(row[0]), + text=row[1], + fact_type=row[2], + confidence=float(row[3]) if row[3] else None, + context=row[4] or "", + tags=row[5] or [], + mentioned_at=row[6], + created_at=row[7], + access_count=row[8] or 0, + metadata=row[9] or {}, + ) + ) elapsed = (time.time() - start) * 1000 return RecallResult( diff --git a/xmemory/models.py b/xmemory/models.py index 2e98c02..a879aae 100644 --- a/xmemory/models.py +++ b/xmemory/models.py @@ -5,12 +5,14 @@ from datetime import datetime from typing import Optional -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field class Memory(BaseModel): """A single memory unit.""" + model_config = ConfigDict(from_attributes=True) + id: str text: str fact_type: str = "observation" @@ -22,9 +24,6 @@ class Memory(BaseModel): metadata: dict = Field(default_factory=dict) access_count: int = 0 - class Config: - from_attributes = True - class RecallResult(BaseModel): """Result from a recall query."""