memor-db 0.1.0__tar.gz
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.
- memor_db-0.1.0/PKG-INFO +177 -0
- memor_db-0.1.0/README.md +157 -0
- memor_db-0.1.0/memor/__init__.py +12 -0
- memor_db-0.1.0/memor/config.py +56 -0
- memor_db-0.1.0/memor/engine.py +124 -0
- memor_db-0.1.0/memor/extractor.py +66 -0
- memor_db-0.1.0/memor/resolver.py +103 -0
- memor_db-0.1.0/memor/retriever.py +175 -0
- memor_db-0.1.0/memor/store.py +226 -0
- memor_db-0.1.0/memor/testing/__init__.py +3 -0
- memor_db-0.1.0/memor/testing/adapter.py +40 -0
- memor_db-0.1.0/memor/testing/cases.json +190 -0
- memor_db-0.1.0/memor/testing/plugin.py +99 -0
- memor_db-0.1.0/memor_db.egg-info/PKG-INFO +177 -0
- memor_db-0.1.0/memor_db.egg-info/SOURCES.txt +22 -0
- memor_db-0.1.0/memor_db.egg-info/dependency_links.txt +1 -0
- memor_db-0.1.0/memor_db.egg-info/entry_points.txt +2 -0
- memor_db-0.1.0/memor_db.egg-info/requires.txt +10 -0
- memor_db-0.1.0/memor_db.egg-info/top_level.txt +1 -0
- memor_db-0.1.0/pyproject.toml +40 -0
- memor_db-0.1.0/setup.cfg +4 -0
- memor_db-0.1.0/tests/test_benchmark.py +8 -0
- memor_db-0.1.0/tests/test_pruning.py +64 -0
- memor_db-0.1.0/tests/test_resolver.py +191 -0
memor_db-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: memor-db
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A local-first, embedded bitemporal memory engine for LLM agents.
|
|
5
|
+
Author: Memor Contributors
|
|
6
|
+
Classifier: Programming Language :: Python :: 3
|
|
7
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
8
|
+
Classifier: Operating System :: OS Independent
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
Requires-Dist: groq>=0.11.0
|
|
12
|
+
Requires-Dist: python-dotenv>=1.0.1
|
|
13
|
+
Requires-Dist: pydantic>=2.7.0
|
|
14
|
+
Requires-Dist: rich>=13.7.0
|
|
15
|
+
Requires-Dist: tenacity>=8.3.0
|
|
16
|
+
Requires-Dist: rank_bm25>=0.2.2
|
|
17
|
+
Requires-Dist: sentence-transformers>=2.7.0
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: pytest>=8.2.0; extra == "dev"
|
|
20
|
+
|
|
21
|
+
<p align="center">
|
|
22
|
+
<img src="assets/logo.png" alt="Memor Logo" width="380"/>
|
|
23
|
+
</p>
|
|
24
|
+
|
|
25
|
+
<p align="center">
|
|
26
|
+
<a href="https://github.com/Dilraj07/Memor/actions/workflows/ci.yml"><img src="https://github.com/Dilraj07/Memor/actions/workflows/ci.yml/badge.svg" alt="CI Status"></a>
|
|
27
|
+
<a href="https://www.python.org/downloads/"><img src="https://img.shields.io/badge/python-3.10%20%7C%203.11%20%7C%203.12-blue.svg?style=flat-square" alt="Python Version"></a>
|
|
28
|
+
<a href="https://github.com/Dilraj07/Memor"><img src="https://img.shields.io/badge/architecture-local--first%20bitemporal-0ea5e9.svg?style=flat-square" alt="Architecture"></a>
|
|
29
|
+
</p>
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
**Memor** is an embedded, local-first bitemporal memory and retrieval engine for LLM agents.
|
|
34
|
+
|
|
35
|
+
Standard vector databases store static embeddings of chat chunks. Over time, as users change their location, job, or preferences, chunk-based retrieval suffers from temporal drift—returning stale or contradictory facts. Memor solves this by extracting structured Subject-Predicate-Object facts and storing them in a bitemporal SQLite engine that tracks exactly when every fact became true, when it was invalidated, and when it was recorded.
|
|
36
|
+
|
|
37
|
+
## Key Capabilities
|
|
38
|
+
|
|
39
|
+
- **Zero-Infrastructure Embedded Storage**: Runs entirely on a local SQLite file (`memory.db`) with Write-Ahead Logging (WAL). No vector database clusters or cloud services required.
|
|
40
|
+
- **Bitemporal Fact Audit Log**: Tracks both world time (`valid_from` to `valid_to`) and system transaction time (`recorded_at`). Previous states are never silently overwritten.
|
|
41
|
+
- **Dual-Path Contradiction Resolution**: Fast-path heuristics instantly update single-valued predicates (e.g., `lives_in`, `works_at`). Ambiguous updates route through an LLM judge to determine whether new facts replace or append to existing knowledge.
|
|
42
|
+
- **Hybrid Retrieval Pipeline**: Combines BM25 lexical search, dense semantic embeddings (`sentence-transformers`), and entity overlap scoring.
|
|
43
|
+
- **Recursive Multi-Hop Graph Traversal**: Automatically traverses relationship chains (e.g., `user -> brother -> Alex -> owns_pet -> dog`) using SQLite `WITH RECURSIVE` common table expressions (CTEs) without requiring an external graph database.
|
|
44
|
+
- **Parallel LLM Reranking**: Reranks top candidates concurrently across thread pools for sub-second relevance refinement.
|
|
45
|
+
- **Bounded Garbage Collection**: Includes explicit, batch-based pruning (`engine.prune(older_than_days=N)`) that safely archives expired records to an immutable `facts_archive` table before deletion.
|
|
46
|
+
- **Strict Multi-Tenancy & Thread Safety**: All operations are strictly isolated by `user_id` and protected by thread locks and backpressure queues.
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## Architecture Flow
|
|
51
|
+
|
|
52
|
+
```mermaid
|
|
53
|
+
graph TD
|
|
54
|
+
A[User Turn / Dialogue] --> B[Async Extractor<br/>Structured Pydantic Schema]
|
|
55
|
+
B --> C{Conflict Resolver}
|
|
56
|
+
|
|
57
|
+
C -->|Single-Valued Predicate<br/>e.g. lives_in| D[Fast Path: Invalidate Old Row]
|
|
58
|
+
C -->|Ambiguous Predicate| E[Slow Path: LLM Judge]
|
|
59
|
+
|
|
60
|
+
E -->|Contradiction Detected| D
|
|
61
|
+
E -->|Additive Fact| F[Insert New Fact<br/>valid_to = NULL]
|
|
62
|
+
D --> F
|
|
63
|
+
|
|
64
|
+
F --> G[(Bitemporal SQLite Store<br/>memory.db WAL)]
|
|
65
|
+
|
|
66
|
+
subgraph Retrieval Pipeline
|
|
67
|
+
H[Query Text] --> I[BM25 Lexical Score]
|
|
68
|
+
H --> J[Dense Vector Similarity]
|
|
69
|
+
H --> K[Entity Overlap Score]
|
|
70
|
+
G --> L[WITH RECURSIVE<br/>Multi-Hop Graph CTE]
|
|
71
|
+
L --> M[Candidate Fusion Engine]
|
|
72
|
+
I --> M
|
|
73
|
+
J --> M
|
|
74
|
+
K --> M
|
|
75
|
+
M --> N[Parallel ThreadPool Reranker]
|
|
76
|
+
N --> O[Top-K High-Precision Context]
|
|
77
|
+
end
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
## Installation
|
|
83
|
+
|
|
84
|
+
Install directly from the repository root or via pip:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
git clone https://github.com/Dilraj07/Memor.git
|
|
88
|
+
cd Memor
|
|
89
|
+
pip install -e .
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Set your API provider credentials in `.env` or as an environment variable:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
export GROQ_API_KEY="gsk_your_api_key_here"
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## Quickstart
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
import time
|
|
104
|
+
from memor.engine import MemoryEngine
|
|
105
|
+
|
|
106
|
+
# Initialize the embedded memory engine
|
|
107
|
+
engine = MemoryEngine()
|
|
108
|
+
user_id = "tenant_8842"
|
|
109
|
+
|
|
110
|
+
# 1. Add dialogue asynchronously (zero latency overhead on chat loops)
|
|
111
|
+
engine.add_memory(user_id, "I live in San Francisco and work as a kernel developer.")
|
|
112
|
+
engine.add_memory(user_id, "My brother Alex recently adopted a golden retriever.")
|
|
113
|
+
|
|
114
|
+
# 2. Add an update that supersedes a previous state
|
|
115
|
+
engine.add_memory(user_id, "I just relocated to Seattle last weekend.")
|
|
116
|
+
|
|
117
|
+
# 3. Flush pending background extraction queues before synchronous queries
|
|
118
|
+
engine.flush(user_id)
|
|
119
|
+
|
|
120
|
+
# 4. Retrieve precise context (automatically combines active facts & multi-hop chains)
|
|
121
|
+
context = engine.query(user_id, "Where does the user live and what pet does their family own?", k=3)
|
|
122
|
+
print(context)
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
**Output:**
|
|
126
|
+
```text
|
|
127
|
+
- user lives in Seattle (Relevance: 9.50)
|
|
128
|
+
- user -> brother -> Alex -> adopted -> golden retriever (Relevance: 9.10)
|
|
129
|
+
- user works as kernel developer (Relevance: 7.20)
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
---
|
|
133
|
+
|
|
134
|
+
## How Bitemporal State Tracking Works
|
|
135
|
+
|
|
136
|
+
When a user's state changes, overwriting the record destroys temporal context. Memor instead updates the previous fact's `valid_to` timestamp and inserts the new state:
|
|
137
|
+
|
|
138
|
+
| `id` | `user_id` | `subject` | `predicate` | `object` | `valid_from` | `valid_to` | `status` |
|
|
139
|
+
|---|---|---|---|---|---|---|---|
|
|
140
|
+
| 101 | `tenant_8842` | `user` | `lives_in` | `San Francisco` | `2026-01-10T10:00:00Z` | `2026-07-11T14:30:00Z` | *Superceded* |
|
|
141
|
+
| 102 | `tenant_8842` | `user` | `lives_in` | `Seattle` | `2026-07-11T14:30:00Z` | `NULL` | **Active** |
|
|
142
|
+
|
|
143
|
+
Retrieval queries filter by `WHERE valid_to IS NULL` by default, ensuring immediate access to current truths while preserving the full historical timeline for auditing or time-travel queries.
|
|
144
|
+
|
|
145
|
+
---
|
|
146
|
+
|
|
147
|
+
## API Reference
|
|
148
|
+
|
|
149
|
+
### `MemoryEngine`
|
|
150
|
+
|
|
151
|
+
#### `add_memory(user_id: str, text: str, sync: bool = False) -> None`
|
|
152
|
+
Submits natural language text for background fact extraction and bitemporal resolution.
|
|
153
|
+
- `sync=False`: Non-blocking submission to the internal worker pool. Applies backpressure if queue depth exceeds 100 turns.
|
|
154
|
+
- `sync=True`: Synchronous extraction and persistence.
|
|
155
|
+
|
|
156
|
+
#### `flush(user_id: str) -> None`
|
|
157
|
+
Blocks execution until all pending asynchronous extraction futures for `user_id` complete. Recommended before reading immediate read-after-write state.
|
|
158
|
+
|
|
159
|
+
#### `query(user_id: str, query_text: str, k: int = 5) -> str`
|
|
160
|
+
Runs multi-signal hybrid retrieval (lexical + dense embedding + graph traversal) and returns the top `k` reranked factual statements formatted for LLM prompt injection.
|
|
161
|
+
|
|
162
|
+
#### `prune(older_than_days: int) -> int`
|
|
163
|
+
Garbage collection utility. Moves invalidated records older than `older_than_days` into `facts_archive` and hard-deletes them from the active index in bounded transactional batches of 500.
|
|
164
|
+
|
|
165
|
+
#### `shutdown() -> None`
|
|
166
|
+
Gracefully terminates background thread pools and executor queues.
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
170
|
+
## Testing & Verification
|
|
171
|
+
|
|
172
|
+
Run the automated test suite across extraction, bitemporal pruning, and retrieval benchmarks:
|
|
173
|
+
|
|
174
|
+
```bash
|
|
175
|
+
pip install -e ".[dev]"
|
|
176
|
+
pytest tests/ -v
|
|
177
|
+
```
|
memor_db-0.1.0/README.md
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="assets/logo.png" alt="Memor Logo" width="380"/>
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<p align="center">
|
|
6
|
+
<a href="https://github.com/Dilraj07/Memor/actions/workflows/ci.yml"><img src="https://github.com/Dilraj07/Memor/actions/workflows/ci.yml/badge.svg" alt="CI Status"></a>
|
|
7
|
+
<a href="https://www.python.org/downloads/"><img src="https://img.shields.io/badge/python-3.10%20%7C%203.11%20%7C%203.12-blue.svg?style=flat-square" alt="Python Version"></a>
|
|
8
|
+
<a href="https://github.com/Dilraj07/Memor"><img src="https://img.shields.io/badge/architecture-local--first%20bitemporal-0ea5e9.svg?style=flat-square" alt="Architecture"></a>
|
|
9
|
+
</p>
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
**Memor** is an embedded, local-first bitemporal memory and retrieval engine for LLM agents.
|
|
14
|
+
|
|
15
|
+
Standard vector databases store static embeddings of chat chunks. Over time, as users change their location, job, or preferences, chunk-based retrieval suffers from temporal drift—returning stale or contradictory facts. Memor solves this by extracting structured Subject-Predicate-Object facts and storing them in a bitemporal SQLite engine that tracks exactly when every fact became true, when it was invalidated, and when it was recorded.
|
|
16
|
+
|
|
17
|
+
## Key Capabilities
|
|
18
|
+
|
|
19
|
+
- **Zero-Infrastructure Embedded Storage**: Runs entirely on a local SQLite file (`memory.db`) with Write-Ahead Logging (WAL). No vector database clusters or cloud services required.
|
|
20
|
+
- **Bitemporal Fact Audit Log**: Tracks both world time (`valid_from` to `valid_to`) and system transaction time (`recorded_at`). Previous states are never silently overwritten.
|
|
21
|
+
- **Dual-Path Contradiction Resolution**: Fast-path heuristics instantly update single-valued predicates (e.g., `lives_in`, `works_at`). Ambiguous updates route through an LLM judge to determine whether new facts replace or append to existing knowledge.
|
|
22
|
+
- **Hybrid Retrieval Pipeline**: Combines BM25 lexical search, dense semantic embeddings (`sentence-transformers`), and entity overlap scoring.
|
|
23
|
+
- **Recursive Multi-Hop Graph Traversal**: Automatically traverses relationship chains (e.g., `user -> brother -> Alex -> owns_pet -> dog`) using SQLite `WITH RECURSIVE` common table expressions (CTEs) without requiring an external graph database.
|
|
24
|
+
- **Parallel LLM Reranking**: Reranks top candidates concurrently across thread pools for sub-second relevance refinement.
|
|
25
|
+
- **Bounded Garbage Collection**: Includes explicit, batch-based pruning (`engine.prune(older_than_days=N)`) that safely archives expired records to an immutable `facts_archive` table before deletion.
|
|
26
|
+
- **Strict Multi-Tenancy & Thread Safety**: All operations are strictly isolated by `user_id` and protected by thread locks and backpressure queues.
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## Architecture Flow
|
|
31
|
+
|
|
32
|
+
```mermaid
|
|
33
|
+
graph TD
|
|
34
|
+
A[User Turn / Dialogue] --> B[Async Extractor<br/>Structured Pydantic Schema]
|
|
35
|
+
B --> C{Conflict Resolver}
|
|
36
|
+
|
|
37
|
+
C -->|Single-Valued Predicate<br/>e.g. lives_in| D[Fast Path: Invalidate Old Row]
|
|
38
|
+
C -->|Ambiguous Predicate| E[Slow Path: LLM Judge]
|
|
39
|
+
|
|
40
|
+
E -->|Contradiction Detected| D
|
|
41
|
+
E -->|Additive Fact| F[Insert New Fact<br/>valid_to = NULL]
|
|
42
|
+
D --> F
|
|
43
|
+
|
|
44
|
+
F --> G[(Bitemporal SQLite Store<br/>memory.db WAL)]
|
|
45
|
+
|
|
46
|
+
subgraph Retrieval Pipeline
|
|
47
|
+
H[Query Text] --> I[BM25 Lexical Score]
|
|
48
|
+
H --> J[Dense Vector Similarity]
|
|
49
|
+
H --> K[Entity Overlap Score]
|
|
50
|
+
G --> L[WITH RECURSIVE<br/>Multi-Hop Graph CTE]
|
|
51
|
+
L --> M[Candidate Fusion Engine]
|
|
52
|
+
I --> M
|
|
53
|
+
J --> M
|
|
54
|
+
K --> M
|
|
55
|
+
M --> N[Parallel ThreadPool Reranker]
|
|
56
|
+
N --> O[Top-K High-Precision Context]
|
|
57
|
+
end
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## Installation
|
|
63
|
+
|
|
64
|
+
Install directly from the repository root or via pip:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
git clone https://github.com/Dilraj07/Memor.git
|
|
68
|
+
cd Memor
|
|
69
|
+
pip install -e .
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Set your API provider credentials in `.env` or as an environment variable:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
export GROQ_API_KEY="gsk_your_api_key_here"
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## Quickstart
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
import time
|
|
84
|
+
from memor.engine import MemoryEngine
|
|
85
|
+
|
|
86
|
+
# Initialize the embedded memory engine
|
|
87
|
+
engine = MemoryEngine()
|
|
88
|
+
user_id = "tenant_8842"
|
|
89
|
+
|
|
90
|
+
# 1. Add dialogue asynchronously (zero latency overhead on chat loops)
|
|
91
|
+
engine.add_memory(user_id, "I live in San Francisco and work as a kernel developer.")
|
|
92
|
+
engine.add_memory(user_id, "My brother Alex recently adopted a golden retriever.")
|
|
93
|
+
|
|
94
|
+
# 2. Add an update that supersedes a previous state
|
|
95
|
+
engine.add_memory(user_id, "I just relocated to Seattle last weekend.")
|
|
96
|
+
|
|
97
|
+
# 3. Flush pending background extraction queues before synchronous queries
|
|
98
|
+
engine.flush(user_id)
|
|
99
|
+
|
|
100
|
+
# 4. Retrieve precise context (automatically combines active facts & multi-hop chains)
|
|
101
|
+
context = engine.query(user_id, "Where does the user live and what pet does their family own?", k=3)
|
|
102
|
+
print(context)
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
**Output:**
|
|
106
|
+
```text
|
|
107
|
+
- user lives in Seattle (Relevance: 9.50)
|
|
108
|
+
- user -> brother -> Alex -> adopted -> golden retriever (Relevance: 9.10)
|
|
109
|
+
- user works as kernel developer (Relevance: 7.20)
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
## How Bitemporal State Tracking Works
|
|
115
|
+
|
|
116
|
+
When a user's state changes, overwriting the record destroys temporal context. Memor instead updates the previous fact's `valid_to` timestamp and inserts the new state:
|
|
117
|
+
|
|
118
|
+
| `id` | `user_id` | `subject` | `predicate` | `object` | `valid_from` | `valid_to` | `status` |
|
|
119
|
+
|---|---|---|---|---|---|---|---|
|
|
120
|
+
| 101 | `tenant_8842` | `user` | `lives_in` | `San Francisco` | `2026-01-10T10:00:00Z` | `2026-07-11T14:30:00Z` | *Superceded* |
|
|
121
|
+
| 102 | `tenant_8842` | `user` | `lives_in` | `Seattle` | `2026-07-11T14:30:00Z` | `NULL` | **Active** |
|
|
122
|
+
|
|
123
|
+
Retrieval queries filter by `WHERE valid_to IS NULL` by default, ensuring immediate access to current truths while preserving the full historical timeline for auditing or time-travel queries.
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
## API Reference
|
|
128
|
+
|
|
129
|
+
### `MemoryEngine`
|
|
130
|
+
|
|
131
|
+
#### `add_memory(user_id: str, text: str, sync: bool = False) -> None`
|
|
132
|
+
Submits natural language text for background fact extraction and bitemporal resolution.
|
|
133
|
+
- `sync=False`: Non-blocking submission to the internal worker pool. Applies backpressure if queue depth exceeds 100 turns.
|
|
134
|
+
- `sync=True`: Synchronous extraction and persistence.
|
|
135
|
+
|
|
136
|
+
#### `flush(user_id: str) -> None`
|
|
137
|
+
Blocks execution until all pending asynchronous extraction futures for `user_id` complete. Recommended before reading immediate read-after-write state.
|
|
138
|
+
|
|
139
|
+
#### `query(user_id: str, query_text: str, k: int = 5) -> str`
|
|
140
|
+
Runs multi-signal hybrid retrieval (lexical + dense embedding + graph traversal) and returns the top `k` reranked factual statements formatted for LLM prompt injection.
|
|
141
|
+
|
|
142
|
+
#### `prune(older_than_days: int) -> int`
|
|
143
|
+
Garbage collection utility. Moves invalidated records older than `older_than_days` into `facts_archive` and hard-deletes them from the active index in bounded transactional batches of 500.
|
|
144
|
+
|
|
145
|
+
#### `shutdown() -> None`
|
|
146
|
+
Gracefully terminates background thread pools and executor queues.
|
|
147
|
+
|
|
148
|
+
---
|
|
149
|
+
|
|
150
|
+
## Testing & Verification
|
|
151
|
+
|
|
152
|
+
Run the automated test suite across extraction, bitemporal pruning, and retrieval benchmarks:
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
pip install -e ".[dev]"
|
|
156
|
+
pytest tests/ -v
|
|
157
|
+
```
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
# Set up a generic logger for the memor library.
|
|
4
|
+
# We attach a NullHandler so that by default, the library is completely silent.
|
|
5
|
+
# Downstream applications (like chat.py) can attach their own handlers
|
|
6
|
+
# (e.g., StreamHandler) and set the log level to INFO or DEBUG to see the logs.
|
|
7
|
+
logger = logging.getLogger("memor")
|
|
8
|
+
logger.addHandler(logging.NullHandler())
|
|
9
|
+
|
|
10
|
+
from .engine import MemoryEngine
|
|
11
|
+
|
|
12
|
+
__all__ = ["MemoryEngine"]
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Central config. Uses Groq's OpenAI-compatible client, but you can swap this for
|
|
3
|
+
any provider (OpenAI, Anthropic, local Ollama via OpenAI-compat shim) without
|
|
4
|
+
touching the rest of the engine — everything downstream just calls `chat()`.
|
|
5
|
+
"""
|
|
6
|
+
import os
|
|
7
|
+
from dotenv import load_dotenv
|
|
8
|
+
from groq import Groq
|
|
9
|
+
|
|
10
|
+
load_dotenv()
|
|
11
|
+
|
|
12
|
+
_client = None
|
|
13
|
+
MODEL = os.environ.get("GROQ_MODEL", "llama-3.1-8b-instant")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_client() -> Groq:
|
|
17
|
+
global _client
|
|
18
|
+
if _client is None:
|
|
19
|
+
api_key = os.environ.get("GROQ_API_KEY")
|
|
20
|
+
if not api_key:
|
|
21
|
+
raise RuntimeError(
|
|
22
|
+
"GROQ_API_KEY is not set. Add it to your .env file or set it as an environment variable. "
|
|
23
|
+
"Get a free key at https://console.groq.com/keys"
|
|
24
|
+
)
|
|
25
|
+
_client = Groq(api_key=api_key)
|
|
26
|
+
return _client
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def chat(messages: list[dict], json_mode: bool = False, temperature: float = 0.0) -> str:
|
|
30
|
+
"""Single entry point for all LLM calls in this project. Keep the rest of the
|
|
31
|
+
codebase provider-agnostic by routing everything through here."""
|
|
32
|
+
kwargs = {}
|
|
33
|
+
if json_mode:
|
|
34
|
+
kwargs["response_format"] = {"type": "json_object"}
|
|
35
|
+
client = get_client()
|
|
36
|
+
resp = client.chat.completions.create(
|
|
37
|
+
model=MODEL,
|
|
38
|
+
messages=messages,
|
|
39
|
+
temperature=temperature,
|
|
40
|
+
**kwargs,
|
|
41
|
+
)
|
|
42
|
+
return resp.choices[0].message.content
|
|
43
|
+
|
|
44
|
+
def chat_stream(messages: list[dict], temperature: float = 0.7):
|
|
45
|
+
"""Generator that yields streaming chunks for interactive chat."""
|
|
46
|
+
client = get_client()
|
|
47
|
+
resp = client.chat.completions.create(
|
|
48
|
+
model=MODEL,
|
|
49
|
+
messages=messages,
|
|
50
|
+
temperature=temperature,
|
|
51
|
+
stream=True,
|
|
52
|
+
)
|
|
53
|
+
for chunk in resp:
|
|
54
|
+
content = chunk.choices[0].delta.content
|
|
55
|
+
if content is not None:
|
|
56
|
+
yield content
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import concurrent.futures
|
|
2
|
+
import threading
|
|
3
|
+
import queue
|
|
4
|
+
from memor.store import init_db, get_active_facts, insert_fact, invalidate_fact, prune_history
|
|
5
|
+
from memor.extractor import extract_facts
|
|
6
|
+
from memor.resolver import resolve
|
|
7
|
+
from memor.retriever import HybridRetriever
|
|
8
|
+
import logging
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger("memor")
|
|
11
|
+
|
|
12
|
+
_MAX_QUEUE_SIZE = 100
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class MemoryEngine:
|
|
16
|
+
def __init__(self):
|
|
17
|
+
init_db()
|
|
18
|
+
self._executor = concurrent.futures.ThreadPoolExecutor(max_workers=1)
|
|
19
|
+
self._retrievers: dict[str, HybridRetriever] = {}
|
|
20
|
+
self._retriever_lock = threading.Lock()
|
|
21
|
+
self._futures_lock = threading.Lock()
|
|
22
|
+
self._pending_futures: dict[str, list[concurrent.futures.Future]] = {}
|
|
23
|
+
|
|
24
|
+
def add_memory(self, user_id: str, text: str, sync: bool = False):
|
|
25
|
+
"""
|
|
26
|
+
Adds a user message to the memory engine to be processed for durable facts.
|
|
27
|
+
By default, runs in the background. If sync=True, blocks until extraction is done.
|
|
28
|
+
|
|
29
|
+
NOTE: When sync=False (default), a subsequent query() call may return stale
|
|
30
|
+
results if the background extraction has not yet completed. Use sync=True or
|
|
31
|
+
call flush(user_id) before querying if you need read-after-write consistency.
|
|
32
|
+
"""
|
|
33
|
+
if sync:
|
|
34
|
+
self._process_turn(user_id, text)
|
|
35
|
+
else:
|
|
36
|
+
with self._futures_lock:
|
|
37
|
+
pending = self._pending_futures.get(user_id, [])
|
|
38
|
+
# Clean up completed futures
|
|
39
|
+
pending = [f for f in pending if not f.done()]
|
|
40
|
+
if len(pending) >= _MAX_QUEUE_SIZE:
|
|
41
|
+
logger.warning(f"Backpressure: dropping memory for user {user_id}, queue full ({_MAX_QUEUE_SIZE})")
|
|
42
|
+
return
|
|
43
|
+
future = self._executor.submit(self._process_turn, user_id, text)
|
|
44
|
+
pending.append(future)
|
|
45
|
+
self._pending_futures[user_id] = pending
|
|
46
|
+
|
|
47
|
+
def flush(self, user_id: str):
|
|
48
|
+
"""
|
|
49
|
+
Block until all pending background writes for this user are complete.
|
|
50
|
+
Call this before query() if you need read-after-write consistency.
|
|
51
|
+
"""
|
|
52
|
+
with self._futures_lock:
|
|
53
|
+
pending = self._pending_futures.pop(user_id, [])
|
|
54
|
+
for f in pending:
|
|
55
|
+
f.result() # blocks until done
|
|
56
|
+
|
|
57
|
+
def _process_turn(self, user_id: str, text: str):
|
|
58
|
+
try:
|
|
59
|
+
facts = extract_facts(text)
|
|
60
|
+
if not facts:
|
|
61
|
+
return
|
|
62
|
+
|
|
63
|
+
for fact in facts:
|
|
64
|
+
# Narrow to the specific predicate to avoid N unnecessary LLM judge calls
|
|
65
|
+
existing_rows = get_active_facts(user_id, fact.subject, fact.predicate)
|
|
66
|
+
|
|
67
|
+
decision, old_row = resolve(fact, existing_rows)
|
|
68
|
+
|
|
69
|
+
if decision == "ADD":
|
|
70
|
+
insert_fact(user_id, fact.subject, fact.predicate, fact.object, fact.source_text)
|
|
71
|
+
logger.info(f"[WRITE] ADD -> {fact.predicate}={fact.object}")
|
|
72
|
+
self._invalidate_cache(user_id)
|
|
73
|
+
elif decision == "UPDATE" and old_row:
|
|
74
|
+
invalidate_fact(user_id, old_row["id"])
|
|
75
|
+
insert_fact(user_id, fact.subject, fact.predicate, fact.object, fact.source_text)
|
|
76
|
+
logger.info(f"[WRITE] UPDATE -> {fact.predicate}: '{old_row['object']}' -> '{fact.object}'")
|
|
77
|
+
self._invalidate_cache(user_id)
|
|
78
|
+
else:
|
|
79
|
+
logger.debug(f"[WRITE] DUPLICATE (no-op) -> {fact.predicate}={fact.object}")
|
|
80
|
+
except Exception as e:
|
|
81
|
+
logger.error(f"Background memory extraction failed: {e}", exc_info=True)
|
|
82
|
+
|
|
83
|
+
def _invalidate_cache(self, user_id: str):
|
|
84
|
+
with self._retriever_lock:
|
|
85
|
+
self._retrievers.pop(user_id, None)
|
|
86
|
+
|
|
87
|
+
def prune(self, older_than_days: int) -> int:
|
|
88
|
+
"""
|
|
89
|
+
Archive and hard-delete all inactive facts older than N days.
|
|
90
|
+
Returns the number of facts archived.
|
|
91
|
+
"""
|
|
92
|
+
return prune_history(older_than_days)
|
|
93
|
+
|
|
94
|
+
def query(self, user_id: str, query_text: str, k: int = 5) -> str:
|
|
95
|
+
"""
|
|
96
|
+
Retrieves the top k most relevant facts for the query.
|
|
97
|
+
Returns a formatted string of context.
|
|
98
|
+
|
|
99
|
+
NOTE: If add_memory() was called with sync=False, results may be stale
|
|
100
|
+
if background extraction has not yet completed. Call flush(user_id) first
|
|
101
|
+
if you need read-after-write consistency.
|
|
102
|
+
"""
|
|
103
|
+
with self._retriever_lock:
|
|
104
|
+
retriever = self._retrievers.get(user_id)
|
|
105
|
+
|
|
106
|
+
if retriever is None:
|
|
107
|
+
retriever = HybridRetriever().fit(user_id)
|
|
108
|
+
with self._retriever_lock:
|
|
109
|
+
self._retrievers[user_id] = retriever
|
|
110
|
+
|
|
111
|
+
results = retriever.retrieve(query_text, k=k)
|
|
112
|
+
|
|
113
|
+
if not results:
|
|
114
|
+
return "No relevant context found."
|
|
115
|
+
|
|
116
|
+
context_lines = []
|
|
117
|
+
for r in results:
|
|
118
|
+
context_lines.append(f"- {r.fact_text} (Relevance: {r.rerank_score or r.fused_score:.2f})")
|
|
119
|
+
|
|
120
|
+
return "\n".join(context_lines)
|
|
121
|
+
|
|
122
|
+
def shutdown(self):
|
|
123
|
+
self._executor.shutdown(wait=True)
|
|
124
|
+
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Turns a raw conversation turn into structured, timestamped facts.
|
|
3
|
+
|
|
4
|
+
This is the first place naive memory projects cut corners — they either skip
|
|
5
|
+
extraction entirely (raw text -> embedding) or extract with no schema. Structured
|
|
6
|
+
extraction is what makes conflict resolution and temporal queries possible later.
|
|
7
|
+
"""
|
|
8
|
+
import json
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from pydantic import BaseModel, Field
|
|
11
|
+
from tenacity import retry, wait_exponential, stop_after_attempt, retry_if_exception_type
|
|
12
|
+
from memor.config import chat
|
|
13
|
+
import groq
|
|
14
|
+
|
|
15
|
+
EXTRACTION_PROMPT = """You extract durable facts about the user from a message.
|
|
16
|
+
Return ONLY a JSON object matching this schema: {{"facts": [{{"subject": str, "predicate": str, "object": str}}]}}
|
|
17
|
+
|
|
18
|
+
Rules:
|
|
19
|
+
- NEVER extract facts from questions (e.g. "Where do I live?"). ONLY extract facts from declarative statements.
|
|
20
|
+
- Extract all factual information about the user (preferences, hobbies, relationships, job, devices, colors, pets, location).
|
|
21
|
+
- subject is usually "user" unless the fact is about someone else the user mentions.
|
|
22
|
+
- predicate should be a short normalized relation, e.g. "lives_in", "works_at", "prefers", "has_role".
|
|
23
|
+
- The object MUST contain the full context. For example, for "I have a dog named Max", the object must be "dog named Max", not just "Max".
|
|
24
|
+
- If there are no durable facts, return {{"facts": []}}.
|
|
25
|
+
|
|
26
|
+
Message: "{message}"
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Fact(BaseModel):
|
|
31
|
+
subject: str
|
|
32
|
+
predicate: str
|
|
33
|
+
object: str
|
|
34
|
+
extracted_at: str = Field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
|
35
|
+
source_text: str = ""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ExtractionResult(BaseModel):
|
|
39
|
+
facts: list[Fact]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@retry(
|
|
43
|
+
wait=wait_exponential(multiplier=1, min=2, max=10),
|
|
44
|
+
stop=stop_after_attempt(3),
|
|
45
|
+
retry=retry_if_exception_type((groq.APIConnectionError, groq.RateLimitError, json.JSONDecodeError, ValueError))
|
|
46
|
+
)
|
|
47
|
+
def extract_facts(message: str) -> list[Fact]:
|
|
48
|
+
raw = chat(
|
|
49
|
+
[{"role": "user", "content": EXTRACTION_PROMPT.format(message=message)}],
|
|
50
|
+
json_mode=True,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# Let Pydantic validate the structured output. If it fails (ValueError),
|
|
54
|
+
# tenacity will automatically retry the LLM call up to 3 times.
|
|
55
|
+
try:
|
|
56
|
+
parsed = json.loads(raw)
|
|
57
|
+
result = ExtractionResult(**parsed)
|
|
58
|
+
except Exception as e:
|
|
59
|
+
# Re-raise to trigger tenacity retry
|
|
60
|
+
raise ValueError(f"Failed to parse or validate LLM output: {raw}") from e
|
|
61
|
+
|
|
62
|
+
# Annotate with source text
|
|
63
|
+
for f in result.facts:
|
|
64
|
+
f.source_text = message
|
|
65
|
+
|
|
66
|
+
return result.facts
|