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.
@@ -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
+ ```
@@ -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