memor-db 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.
- memor/__init__.py +12 -0
- memor/config.py +56 -0
- memor/engine.py +124 -0
- memor/extractor.py +66 -0
- memor/resolver.py +103 -0
- memor/retriever.py +175 -0
- memor/store.py +226 -0
- memor/testing/__init__.py +3 -0
- memor/testing/adapter.py +40 -0
- memor/testing/cases.json +190 -0
- memor/testing/plugin.py +99 -0
- memor_db-0.1.0.dist-info/METADATA +177 -0
- memor_db-0.1.0.dist-info/RECORD +16 -0
- memor_db-0.1.0.dist-info/WHEEL +5 -0
- memor_db-0.1.0.dist-info/entry_points.txt +2 -0
- memor_db-0.1.0.dist-info/top_level.txt +1 -0
memor/__init__.py
ADDED
|
@@ -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"]
|
memor/config.py
ADDED
|
@@ -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
|
memor/engine.py
ADDED
|
@@ -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
|
+
|
memor/extractor.py
ADDED
|
@@ -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
|
memor/resolver.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Decides what to do when a newly extracted fact might conflict with an existing one.
|
|
3
|
+
|
|
4
|
+
Three outcomes:
|
|
5
|
+
- ADD : genuinely new fact, no conflict (e.g. new predicate, or same
|
|
6
|
+
predicate but object is additive like "hobbies")
|
|
7
|
+
- UPDATE : same subject+predicate, different object, and the predicate is
|
|
8
|
+
"single-valued" (e.g. lives_in, works_at) -> invalidate old, add new
|
|
9
|
+
- DUPLICATE : same subject+predicate+object already active -> no-op
|
|
10
|
+
|
|
11
|
+
This uses a cheap heuristic first (fast path) and only calls the LLM to judge
|
|
12
|
+
ambiguous cases (slow path) -- worth highlighting in your writeup as a
|
|
13
|
+
cost/latency optimization, which is exactly the kind of production concern that
|
|
14
|
+
scores well in hackathon judging.
|
|
15
|
+
"""
|
|
16
|
+
import json
|
|
17
|
+
import sqlite3
|
|
18
|
+
from typing import Literal
|
|
19
|
+
from pydantic import BaseModel
|
|
20
|
+
from tenacity import retry, wait_exponential, stop_after_attempt, retry_if_exception_type
|
|
21
|
+
from memor.extractor import Fact
|
|
22
|
+
from memor.config import chat
|
|
23
|
+
import groq
|
|
24
|
+
import logging
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
# Predicates where only one value should be "active" at a time.
|
|
29
|
+
SINGLE_VALUED_PREDICATES = {"lives_in", "works_at", "has_role", "studies_at"}
|
|
30
|
+
|
|
31
|
+
JUDGE_PROMPT = """You are a Memory Conflict Resolver. Your job is to decide if a new fact CONTRADICTS an old fact.
|
|
32
|
+
|
|
33
|
+
Old fact: {old_subject} {old_predicate} {old_object}
|
|
34
|
+
New fact: {new_subject} {new_predicate} {new_object}
|
|
35
|
+
|
|
36
|
+
Rules for deciding:
|
|
37
|
+
1. If they cannot both be true at the same time (e.g. marital status, current primary residence, political affiliation), they contradict. Choose "UPDATE".
|
|
38
|
+
2. If they can both be true simultaneously (e.g. owning multiple different pets, having multiple hobbies, having multiple favorite movies/foods/colors, speaking multiple languages, playing multiple instruments), they do NOT contradict. Choose "ADD".
|
|
39
|
+
3. When in doubt about personal preferences or skills, assume the user can like or do multiple things. Choose "ADD".
|
|
40
|
+
|
|
41
|
+
Example 1:
|
|
42
|
+
Old fact: user owns_pet dog
|
|
43
|
+
New fact: user owns_pet cat
|
|
44
|
+
Explanation: You can own both a dog and a cat. They do not contradict.
|
|
45
|
+
Result: "ADD"
|
|
46
|
+
|
|
47
|
+
Example 2:
|
|
48
|
+
Old fact: user diet vegan
|
|
49
|
+
New fact: user diet carnivore
|
|
50
|
+
Explanation: You cannot be both vegan and carnivore. They contradict.
|
|
51
|
+
Result: "UPDATE"
|
|
52
|
+
|
|
53
|
+
Analyze the facts, and output ONLY valid JSON in this format:
|
|
54
|
+
{{"decision": "UPDATE" or "ADD"}}
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
class Resolution(BaseModel):
|
|
58
|
+
decision: Literal["ADD", "UPDATE"]
|
|
59
|
+
|
|
60
|
+
@retry(
|
|
61
|
+
wait=wait_exponential(multiplier=1, min=2, max=10),
|
|
62
|
+
stop=stop_after_attempt(3),
|
|
63
|
+
retry=retry_if_exception_type((groq.APIConnectionError, groq.RateLimitError, json.JSONDecodeError, ValueError))
|
|
64
|
+
)
|
|
65
|
+
def _ask_llm_to_judge(row, new_fact: Fact) -> str:
|
|
66
|
+
raw = chat(
|
|
67
|
+
[{"role": "user", "content": JUDGE_PROMPT.format(
|
|
68
|
+
old_subject=row["subject"], old_predicate=row["predicate"],
|
|
69
|
+
old_object=row["object"], new_subject=new_fact.subject,
|
|
70
|
+
new_predicate=new_fact.predicate, new_object=new_fact.object,
|
|
71
|
+
)}],
|
|
72
|
+
json_mode=True,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
parsed = json.loads(raw)
|
|
77
|
+
result = Resolution(**parsed)
|
|
78
|
+
return result.decision
|
|
79
|
+
except Exception as e:
|
|
80
|
+
raise ValueError(f"Failed to parse or validate LLM output: {raw}") from e
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def resolve(new_fact: Fact, existing_rows) -> tuple[str, "sqlite3.Row | None"]:
|
|
84
|
+
"""Returns (decision, existing_row_to_invalidate_or_None)."""
|
|
85
|
+
for row in existing_rows:
|
|
86
|
+
if row["predicate"] != new_fact.predicate:
|
|
87
|
+
continue
|
|
88
|
+
if row["object"].strip().lower() == new_fact.object.strip().lower():
|
|
89
|
+
return "DUPLICATE", row
|
|
90
|
+
|
|
91
|
+
# Fast path: known single-valued predicate -> always UPDATE.
|
|
92
|
+
if new_fact.predicate in SINGLE_VALUED_PREDICATES:
|
|
93
|
+
logger.info("Fast-path hit (UPDATE) for predicate: %s", new_fact.predicate)
|
|
94
|
+
return "UPDATE", row
|
|
95
|
+
|
|
96
|
+
# Slow path: ask the LLM to judge
|
|
97
|
+
logger.info("Slow-path LLM check for predicate: %s (old: %s, new: %s)", new_fact.predicate, row["object"], new_fact.object)
|
|
98
|
+
decision = _ask_llm_to_judge(row, new_fact)
|
|
99
|
+
|
|
100
|
+
if decision == "UPDATE":
|
|
101
|
+
return "UPDATE", row
|
|
102
|
+
|
|
103
|
+
return "ADD", None
|
memor/retriever.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Hybrid retrieval over the fact store.
|
|
3
|
+
|
|
4
|
+
Why three signals instead of just vector similarity:
|
|
5
|
+
- Vector (sentence-transformers) catches semantic/paraphrase matches: query "where does he live" matches fact "lives_in".
|
|
6
|
+
- BM25 catches exact term matches that embeddings sometimes under-weight,
|
|
7
|
+
especially for names and rare tokens: "Bangalore" should score high even if
|
|
8
|
+
the embedding space doesn't separate cities well.
|
|
9
|
+
- Entity overlap is a cheap, high-precision signal: if the query names a subject
|
|
10
|
+
that also appears in the fact, that's strong evidence, and it doesn't need any
|
|
11
|
+
model at all.
|
|
12
|
+
|
|
13
|
+
Fusing them (rather than picking one) is the actual lesson here -- vector-only
|
|
14
|
+
retrieval is the single most common reason "memory" systems retrieve the wrong
|
|
15
|
+
thing at the wrong time.
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
Swap-in points for later:
|
|
19
|
+
- Replace TF-IDF with sentence-transformers or an API embedding model for real
|
|
20
|
+
semantic similarity (drop-in: just change `_vectorize`).
|
|
21
|
+
- Replace the LLM rerank pass with a trained cross-encoder if you want lower
|
|
22
|
+
latency at scale.
|
|
23
|
+
"""
|
|
24
|
+
import re
|
|
25
|
+
import sqlite3
|
|
26
|
+
import logging
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
from sentence_transformers import SentenceTransformer, util
|
|
31
|
+
from rank_bm25 import BM25Okapi
|
|
32
|
+
|
|
33
|
+
from memor.store import get_all_active_facts, get_multihop_paths
|
|
34
|
+
from memor.config import chat
|
|
35
|
+
|
|
36
|
+
RERANK_PROMPT = """Query: "{query}"
|
|
37
|
+
Candidate fact: "{fact_text}"
|
|
38
|
+
|
|
39
|
+
On a scale of 0-10, how relevant is this fact to answering the query?
|
|
40
|
+
Respond ONLY with a JSON object: {{"score": <int>}}
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _tokenize(text: str) -> list[str]:
|
|
45
|
+
return re.findall(r"[a-z0-9]+", text.lower())
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _fact_to_text(row) -> str:
|
|
49
|
+
if row.get("predicate") == "multihop_path":
|
|
50
|
+
return row["object"]
|
|
51
|
+
return f"{row['subject']} {row['predicate'].replace('_', ' ')} {row['object']}"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class RetrievalResult:
|
|
56
|
+
fact_text: str
|
|
57
|
+
row: dict
|
|
58
|
+
vector_score: float
|
|
59
|
+
bm25_score: float
|
|
60
|
+
entity_score: float
|
|
61
|
+
fused_score: float
|
|
62
|
+
rerank_score: float | None = None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class HybridRetriever:
|
|
66
|
+
def __init__(self, weights: dict[str, float] | None = None):
|
|
67
|
+
# Tunable fusion weights -- worth an ablation table in your writeup
|
|
68
|
+
# (show accuracy with each signal turned off).
|
|
69
|
+
self.weights = weights or {"vector": 0.4, "bm25": 0.4, "entity": 0.2}
|
|
70
|
+
self._rows = []
|
|
71
|
+
self._texts = []
|
|
72
|
+
self._embedder = SentenceTransformer('all-MiniLM-L6-v2')
|
|
73
|
+
self._embeddings = None
|
|
74
|
+
self._bm25 = None
|
|
75
|
+
|
|
76
|
+
def fit(self, user_id: str, rows=None):
|
|
77
|
+
base_rows = rows if rows is not None else get_all_active_facts(user_id)
|
|
78
|
+
self._rows = [dict(r) for r in base_rows]
|
|
79
|
+
|
|
80
|
+
# Inject multi-hop paths into the corpus to enable GraphRAG-style retrieval
|
|
81
|
+
try:
|
|
82
|
+
paths = get_multihop_paths(user_id, max_depth=2)
|
|
83
|
+
for p in paths:
|
|
84
|
+
self._rows.append({
|
|
85
|
+
"subject": "multi-hop",
|
|
86
|
+
"predicate": "multihop_path",
|
|
87
|
+
"object": p
|
|
88
|
+
})
|
|
89
|
+
except Exception as e:
|
|
90
|
+
logger.error(f"Failed to build multihop paths: {e}")
|
|
91
|
+
|
|
92
|
+
self._texts = [_fact_to_text(r) for r in self._rows]
|
|
93
|
+
|
|
94
|
+
if not self._texts:
|
|
95
|
+
return self
|
|
96
|
+
|
|
97
|
+
self._embeddings = self._embedder.encode(self._texts, convert_to_tensor=True)
|
|
98
|
+
|
|
99
|
+
tokenized_corpus = [_tokenize(t) for t in self._texts]
|
|
100
|
+
self._bm25 = BM25Okapi(tokenized_corpus)
|
|
101
|
+
return self
|
|
102
|
+
|
|
103
|
+
def _entity_score(self, query: str, row) -> float:
|
|
104
|
+
q_tokens = set(_tokenize(query))
|
|
105
|
+
entity_tokens = set(_tokenize(row["subject"])) | set(_tokenize(row["object"]))
|
|
106
|
+
if not entity_tokens:
|
|
107
|
+
return 0.0
|
|
108
|
+
overlap = q_tokens & entity_tokens
|
|
109
|
+
return len(overlap) / len(entity_tokens)
|
|
110
|
+
|
|
111
|
+
def _normalize(self, scores: list[float]) -> list[float]:
|
|
112
|
+
if not scores:
|
|
113
|
+
return scores
|
|
114
|
+
lo, hi = min(scores), max(scores)
|
|
115
|
+
if hi == lo:
|
|
116
|
+
return [0.0 for _ in scores]
|
|
117
|
+
return [(s - lo) / (hi - lo) for s in scores]
|
|
118
|
+
|
|
119
|
+
def retrieve(self, query: str, k: int = 5, rerank: bool = True,
|
|
120
|
+
rerank_pool: int = 8) -> list[RetrievalResult]:
|
|
121
|
+
if not self._rows:
|
|
122
|
+
return []
|
|
123
|
+
|
|
124
|
+
q_emb = self._embedder.encode(query, convert_to_tensor=True)
|
|
125
|
+
vector_scores = util.cos_sim(q_emb, self._embeddings)[0].cpu().tolist()
|
|
126
|
+
bm25_scores = self._bm25.get_scores(_tokenize(query)).tolist()
|
|
127
|
+
entity_scores = [self._entity_score(query, r) for r in self._rows]
|
|
128
|
+
|
|
129
|
+
v_norm = self._normalize(vector_scores)
|
|
130
|
+
b_norm = self._normalize(bm25_scores)
|
|
131
|
+
e_norm = entity_scores # already 0-1
|
|
132
|
+
|
|
133
|
+
results = []
|
|
134
|
+
for i, row in enumerate(self._rows):
|
|
135
|
+
fused = (
|
|
136
|
+
self.weights["vector"] * v_norm[i]
|
|
137
|
+
+ self.weights["bm25"] * b_norm[i]
|
|
138
|
+
+ self.weights["entity"] * e_norm[i]
|
|
139
|
+
)
|
|
140
|
+
results.append(RetrievalResult(
|
|
141
|
+
fact_text=self._texts[i], row=row,
|
|
142
|
+
vector_score=v_norm[i], bm25_score=b_norm[i],
|
|
143
|
+
entity_score=e_norm[i], fused_score=fused,
|
|
144
|
+
))
|
|
145
|
+
|
|
146
|
+
results.sort(key=lambda r: r.fused_score, reverse=True)
|
|
147
|
+
|
|
148
|
+
if not rerank:
|
|
149
|
+
logger.debug(f"Retrieved {len(results)} facts (fast-path)")
|
|
150
|
+
return results[:k]
|
|
151
|
+
|
|
152
|
+
# Only rerank the top pool -- reranking the whole corpus with an LLM
|
|
153
|
+
# doesn't scale, this is the standard "retrieve wide, rerank narrow" pattern.
|
|
154
|
+
pool = results[:rerank_pool]
|
|
155
|
+
|
|
156
|
+
def _rerank_one(r):
|
|
157
|
+
try:
|
|
158
|
+
raw = chat(
|
|
159
|
+
[{"role": "user", "content": RERANK_PROMPT.format(
|
|
160
|
+
query=query, fact_text=r.fact_text)}],
|
|
161
|
+
json_mode=True,
|
|
162
|
+
)
|
|
163
|
+
import json
|
|
164
|
+
r.rerank_score = json.loads(raw).get("score", 0)
|
|
165
|
+
except Exception:
|
|
166
|
+
r.rerank_score = 0
|
|
167
|
+
return r
|
|
168
|
+
|
|
169
|
+
import concurrent.futures
|
|
170
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=min(len(pool), 4)) as executor:
|
|
171
|
+
list(executor.map(_rerank_one, pool))
|
|
172
|
+
|
|
173
|
+
pool.sort(key=lambda r: r.rerank_score, reverse=True)
|
|
174
|
+
logger.debug(f"Reranked top {len(pool)} facts via LLM (parallel)")
|
|
175
|
+
return pool[:k]
|
memor/store.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Bitemporal fact store. Every fact has:
|
|
3
|
+
- valid_from / valid_to -> when the fact was true in the real world
|
|
4
|
+
- recorded_at -> when *we* learned about it (lets you replay
|
|
5
|
+
"what did the system believe on date X")
|
|
6
|
+
|
|
7
|
+
This is the piece that separates a real memory engine from a vector store with
|
|
8
|
+
extra steps: a vector DB has no concept of "this fact used to be true."
|
|
9
|
+
"""
|
|
10
|
+
import sqlite3
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from contextlib import contextmanager
|
|
13
|
+
|
|
14
|
+
DB_PATH = "memory.db"
|
|
15
|
+
|
|
16
|
+
SCHEMA = """
|
|
17
|
+
CREATE TABLE IF NOT EXISTS facts (
|
|
18
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
19
|
+
user_id TEXT NOT NULL,
|
|
20
|
+
subject TEXT NOT NULL,
|
|
21
|
+
predicate TEXT NOT NULL,
|
|
22
|
+
object TEXT NOT NULL,
|
|
23
|
+
valid_from TEXT NOT NULL,
|
|
24
|
+
valid_to TEXT, -- NULL means "still active"
|
|
25
|
+
recorded_at TEXT NOT NULL,
|
|
26
|
+
source_text TEXT
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
CREATE TABLE IF NOT EXISTS facts_archive (
|
|
30
|
+
id INTEGER PRIMARY KEY, -- keep original id
|
|
31
|
+
user_id TEXT NOT NULL,
|
|
32
|
+
subject TEXT NOT NULL,
|
|
33
|
+
predicate TEXT NOT NULL,
|
|
34
|
+
object TEXT NOT NULL,
|
|
35
|
+
valid_from TEXT NOT NULL,
|
|
36
|
+
valid_to TEXT NOT NULL,
|
|
37
|
+
recorded_at TEXT NOT NULL,
|
|
38
|
+
source_text TEXT,
|
|
39
|
+
archived_at TEXT NOT NULL
|
|
40
|
+
);
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
@contextmanager
|
|
44
|
+
def _conn():
|
|
45
|
+
conn = sqlite3.connect(DB_PATH, timeout=30)
|
|
46
|
+
conn.row_factory = sqlite3.Row
|
|
47
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
48
|
+
try:
|
|
49
|
+
yield conn
|
|
50
|
+
conn.commit()
|
|
51
|
+
finally:
|
|
52
|
+
conn.close()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def init_db():
|
|
56
|
+
with _conn() as conn:
|
|
57
|
+
# Check current schema version
|
|
58
|
+
version = conn.execute("PRAGMA user_version").fetchone()[0]
|
|
59
|
+
|
|
60
|
+
if version == 0:
|
|
61
|
+
# First time setup or upgrading from old V0 (initial schema)
|
|
62
|
+
# Create table if it doesn't exist
|
|
63
|
+
conn.executescript(SCHEMA)
|
|
64
|
+
|
|
65
|
+
# If the table already existed but was missing user_id (V0 schema), we need to add it
|
|
66
|
+
# We can check if the column exists by PRAGMA table_info
|
|
67
|
+
columns = [info["name"] for info in conn.execute("PRAGMA table_info(facts)").fetchall()]
|
|
68
|
+
if columns and "user_id" not in columns:
|
|
69
|
+
# Upgrade existing V0 table
|
|
70
|
+
conn.execute("ALTER TABLE facts ADD COLUMN user_id TEXT DEFAULT 'default'")
|
|
71
|
+
|
|
72
|
+
# Bump version to 1
|
|
73
|
+
conn.execute("PRAGMA user_version = 1")
|
|
74
|
+
version = 1
|
|
75
|
+
|
|
76
|
+
if version == 1:
|
|
77
|
+
# Upgrade to V2: Create facts_archive
|
|
78
|
+
conn.execute("CREATE TABLE IF NOT EXISTS facts_archive ("
|
|
79
|
+
"id INTEGER PRIMARY KEY,"
|
|
80
|
+
"user_id TEXT NOT NULL,"
|
|
81
|
+
"subject TEXT NOT NULL,"
|
|
82
|
+
"predicate TEXT NOT NULL,"
|
|
83
|
+
"object TEXT NOT NULL,"
|
|
84
|
+
"valid_from TEXT NOT NULL,"
|
|
85
|
+
"valid_to TEXT NOT NULL,"
|
|
86
|
+
"recorded_at TEXT NOT NULL,"
|
|
87
|
+
"source_text TEXT,"
|
|
88
|
+
"archived_at TEXT NOT NULL)")
|
|
89
|
+
conn.execute("PRAGMA user_version = 2")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def get_active_facts(user_id: str, subject: str, predicate: str | None = None) -> list[sqlite3.Row]:
|
|
93
|
+
with _conn() as conn:
|
|
94
|
+
if predicate:
|
|
95
|
+
return conn.execute(
|
|
96
|
+
"SELECT * FROM facts WHERE user_id=? AND subject=? AND predicate=? AND valid_to IS NULL",
|
|
97
|
+
(user_id, subject, predicate),
|
|
98
|
+
).fetchall()
|
|
99
|
+
return conn.execute(
|
|
100
|
+
"SELECT * FROM facts WHERE user_id=? AND subject=? AND valid_to IS NULL", (user_id, subject)
|
|
101
|
+
).fetchall()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def insert_fact(user_id: str, subject: str, predicate: str, obj: str, source_text: str = ""):
|
|
105
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
106
|
+
with _conn() as conn:
|
|
107
|
+
conn.execute(
|
|
108
|
+
"INSERT INTO facts (user_id, subject, predicate, object, valid_from, valid_to, "
|
|
109
|
+
"recorded_at, source_text) VALUES (?, ?, ?, ?, ?, NULL, ?, ?)",
|
|
110
|
+
(user_id, subject, predicate, obj, now, now, source_text),
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def invalidate_fact(user_id: str, fact_id: int):
|
|
115
|
+
"""Set valid_to on a fact, but only if it belongs to the given user (defense-in-depth)."""
|
|
116
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
117
|
+
with _conn() as conn:
|
|
118
|
+
conn.execute("UPDATE facts SET valid_to=? WHERE id=? AND user_id=?", (now, fact_id, user_id))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def get_all_active_facts(user_id: str) -> list[sqlite3.Row]:
|
|
122
|
+
"""Corpus for the retriever -- every currently-valid fact, scoped to a specific user."""
|
|
123
|
+
with _conn() as conn:
|
|
124
|
+
return conn.execute("SELECT * FROM facts WHERE user_id=? AND valid_to IS NULL", (user_id,)).fetchall()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def all_facts_including_history(user_id: str, subject: str) -> list[sqlite3.Row]:
|
|
128
|
+
"""Useful for the 'what did we used to believe' queries / debugging the resolver."""
|
|
129
|
+
with _conn() as conn:
|
|
130
|
+
return conn.execute(
|
|
131
|
+
"SELECT * FROM facts WHERE user_id=? AND subject=? ORDER BY recorded_at", (user_id, subject)
|
|
132
|
+
).fetchall()
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def get_multihop_paths(user_id: str, max_depth: int = 2) -> list[str]:
|
|
136
|
+
"""
|
|
137
|
+
Graph traversal using SQLite WITH RECURSIVE.
|
|
138
|
+
Returns concatenated text paths for all connected facts up to max_depth.
|
|
139
|
+
"""
|
|
140
|
+
query = f"""
|
|
141
|
+
WITH RECURSIVE multihop(subject, object, path, depth) AS (
|
|
142
|
+
-- Base case: All active facts
|
|
143
|
+
SELECT subject, object, subject || ' ' || REPLACE(predicate, '_', ' ') || ' ' || object, 1
|
|
144
|
+
FROM facts
|
|
145
|
+
WHERE user_id = ? AND valid_to IS NULL
|
|
146
|
+
|
|
147
|
+
UNION
|
|
148
|
+
|
|
149
|
+
-- Recursive step: Find facts where the new subject matches the previous object (case-insensitive)
|
|
150
|
+
SELECT f.subject, f.object, m.path || ' -> ' || f.subject || ' ' || REPLACE(f.predicate, '_', ' ') || ' ' || f.object, m.depth + 1
|
|
151
|
+
FROM facts f
|
|
152
|
+
JOIN multihop m ON LOWER(TRIM(m.object)) = LOWER(TRIM(f.subject))
|
|
153
|
+
WHERE f.user_id = ? AND f.valid_to IS NULL AND m.depth < ?
|
|
154
|
+
)
|
|
155
|
+
-- We only return paths of length > 1, because length 1 facts are already in the base corpus.
|
|
156
|
+
SELECT path FROM multihop WHERE depth > 1;
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
args = [user_id, user_id, max_depth]
|
|
160
|
+
|
|
161
|
+
with _conn() as conn:
|
|
162
|
+
rows = conn.execute(query, tuple(args)).fetchall()
|
|
163
|
+
return [r["path"] for r in rows]
|
|
164
|
+
|
|
165
|
+
def prune_history(older_than_days: int) -> int:
|
|
166
|
+
"""
|
|
167
|
+
Archive and hard-delete inactive facts older than N days.
|
|
168
|
+
Operates in batches of 500 to prevent locking the database.
|
|
169
|
+
"""
|
|
170
|
+
import logging
|
|
171
|
+
logger = logging.getLogger(__name__)
|
|
172
|
+
|
|
173
|
+
if older_than_days < 0:
|
|
174
|
+
raise ValueError("older_than_days must be >= 0")
|
|
175
|
+
|
|
176
|
+
batch_size = 500
|
|
177
|
+
total_archived = 0
|
|
178
|
+
now = datetime.now(timezone.utc)
|
|
179
|
+
|
|
180
|
+
# Calculate cutoff in Python so it matches the isoformat exactly
|
|
181
|
+
from datetime import timedelta
|
|
182
|
+
cutoff = (now - timedelta(days=older_than_days)).isoformat()
|
|
183
|
+
now_iso = now.isoformat()
|
|
184
|
+
|
|
185
|
+
# We use a separate connection loop because we want to commit between batches
|
|
186
|
+
# so we don't hold the write lock during the entire prune.
|
|
187
|
+
while True:
|
|
188
|
+
with _conn() as conn:
|
|
189
|
+
# 1. Identify a batch of stale rows
|
|
190
|
+
select_query = f"""
|
|
191
|
+
SELECT id, user_id, subject, predicate, object, valid_from, valid_to, recorded_at, source_text
|
|
192
|
+
FROM facts
|
|
193
|
+
WHERE valid_to IS NOT NULL AND valid_to < ?
|
|
194
|
+
LIMIT {batch_size}
|
|
195
|
+
"""
|
|
196
|
+
stale_rows = conn.execute(select_query, (cutoff,)).fetchall()
|
|
197
|
+
|
|
198
|
+
if not stale_rows:
|
|
199
|
+
break
|
|
200
|
+
|
|
201
|
+
# 2. Archive them
|
|
202
|
+
archive_query = """
|
|
203
|
+
INSERT INTO facts_archive
|
|
204
|
+
(id, user_id, subject, predicate, object, valid_from, valid_to, recorded_at, source_text, archived_at)
|
|
205
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
206
|
+
"""
|
|
207
|
+
archive_data = [
|
|
208
|
+
(r["id"], r["user_id"], r["subject"], r["predicate"], r["object"],
|
|
209
|
+
r["valid_from"], r["valid_to"], r["recorded_at"], r["source_text"], now_iso)
|
|
210
|
+
for r in stale_rows
|
|
211
|
+
]
|
|
212
|
+
conn.executemany(archive_query, archive_data)
|
|
213
|
+
|
|
214
|
+
# 3. Delete them from the hot table
|
|
215
|
+
ids_to_delete = [(r["id"],) for r in stale_rows]
|
|
216
|
+
conn.executemany("DELETE FROM facts WHERE id = ?", ids_to_delete)
|
|
217
|
+
|
|
218
|
+
total_archived += len(stale_rows)
|
|
219
|
+
logger.info(f"Archived {len(stale_rows)} stale facts...")
|
|
220
|
+
|
|
221
|
+
if total_archived > 0:
|
|
222
|
+
logger.info(f"Prune complete. Total facts archived: {total_archived}")
|
|
223
|
+
|
|
224
|
+
return total_archived
|
|
225
|
+
|
|
226
|
+
|
memor/testing/adapter.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import List, Dict
|
|
3
|
+
|
|
4
|
+
class MemoryBackendAdapter(ABC):
|
|
5
|
+
"""
|
|
6
|
+
Abstract interface that all memory backends must implement to be tested
|
|
7
|
+
by the temporal memory benchmark suite.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
@abstractmethod
|
|
11
|
+
def add_fact(self, user_id: str, text: str) -> None:
|
|
12
|
+
"""
|
|
13
|
+
Add a factual statement to the memory backend synchronously.
|
|
14
|
+
The backend should extract and store the facts within this call.
|
|
15
|
+
"""
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
@abstractmethod
|
|
19
|
+
def get_all_active_facts(self, user_id: str) -> List[Dict[str, str]]:
|
|
20
|
+
"""
|
|
21
|
+
Return a list of dictionaries for all currently active facts.
|
|
22
|
+
Each dictionary must contain at least 'predicate' and 'object' keys.
|
|
23
|
+
"""
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
@abstractmethod
|
|
27
|
+
def query(self, user_id: str, query_text: str, k: int = 3) -> List[str]:
|
|
28
|
+
"""
|
|
29
|
+
Query the memory backend and return a list of contextual strings.
|
|
30
|
+
These strings should contain the facts relevant to the query_text.
|
|
31
|
+
"""
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
@abstractmethod
|
|
35
|
+
def clear(self, user_id: str) -> None:
|
|
36
|
+
"""
|
|
37
|
+
Clear all facts for the given user_id.
|
|
38
|
+
Used to reset state between tests.
|
|
39
|
+
"""
|
|
40
|
+
pass
|
memor/testing/cases.json
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"id": "case_1_location_update",
|
|
4
|
+
"statements": [
|
|
5
|
+
"I live in Pune right now.",
|
|
6
|
+
"I just got a new job and moved to Bangalore!"
|
|
7
|
+
],
|
|
8
|
+
"expected_active": [
|
|
9
|
+
{
|
|
10
|
+
"predicate": "lives_in",
|
|
11
|
+
"object": "Bangalore"
|
|
12
|
+
}
|
|
13
|
+
],
|
|
14
|
+
"query": "Where do I live?"
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"id": "case_2_diet_additive",
|
|
18
|
+
"statements": [
|
|
19
|
+
"I really love eating pizza.",
|
|
20
|
+
"I'm also a huge fan of sushi."
|
|
21
|
+
],
|
|
22
|
+
"expected_active": [
|
|
23
|
+
{
|
|
24
|
+
"predicate": "likes_food",
|
|
25
|
+
"object": "pizza"
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"predicate": "likes_food",
|
|
29
|
+
"object": "sushi"
|
|
30
|
+
}
|
|
31
|
+
],
|
|
32
|
+
"query": "What food do I like?"
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"id": "case_3_diet_contradiction",
|
|
36
|
+
"statements": [
|
|
37
|
+
"I am a strict vegan.",
|
|
38
|
+
"I gave up my diet, I only eat meat now, full carnivore."
|
|
39
|
+
],
|
|
40
|
+
"expected_active": [
|
|
41
|
+
{
|
|
42
|
+
"predicate": "diet",
|
|
43
|
+
"object": "carnivore"
|
|
44
|
+
}
|
|
45
|
+
],
|
|
46
|
+
"query": "What is my diet?"
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"id": "case_4_pet_additive",
|
|
50
|
+
"statements": [
|
|
51
|
+
"I have a dog named Max.",
|
|
52
|
+
"I just adopted a cat today."
|
|
53
|
+
],
|
|
54
|
+
"expected_active": [
|
|
55
|
+
{
|
|
56
|
+
"predicate": "owns_pet",
|
|
57
|
+
"object": "dog"
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
"predicate": "owns_pet",
|
|
61
|
+
"object": "cat"
|
|
62
|
+
}
|
|
63
|
+
],
|
|
64
|
+
"query": "What pets do I own?"
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
"id": "case_5_job_update",
|
|
68
|
+
"statements": [
|
|
69
|
+
"I work at Google.",
|
|
70
|
+
"I quit Google and just joined OpenAI."
|
|
71
|
+
],
|
|
72
|
+
"expected_active": [
|
|
73
|
+
{
|
|
74
|
+
"predicate": "works_at",
|
|
75
|
+
"object": "OpenAI"
|
|
76
|
+
}
|
|
77
|
+
],
|
|
78
|
+
"query": "Where do I work?"
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
"id": "case_6_hobby_additive",
|
|
82
|
+
"statements": [
|
|
83
|
+
"I play the guitar.",
|
|
84
|
+
"I've started learning the piano."
|
|
85
|
+
],
|
|
86
|
+
"expected_active": [
|
|
87
|
+
{
|
|
88
|
+
"predicate": "plays_instrument",
|
|
89
|
+
"object": "guitar"
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
"predicate": "plays_instrument",
|
|
93
|
+
"object": "piano"
|
|
94
|
+
}
|
|
95
|
+
],
|
|
96
|
+
"query": "What instruments do I play?"
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
"id": "case_7_relationship_update",
|
|
100
|
+
"statements": [
|
|
101
|
+
"I am single.",
|
|
102
|
+
"I just got married last week!"
|
|
103
|
+
],
|
|
104
|
+
"expected_active": [
|
|
105
|
+
{
|
|
106
|
+
"predicate": "marital_status",
|
|
107
|
+
"object": "married"
|
|
108
|
+
}
|
|
109
|
+
],
|
|
110
|
+
"query": "What is my marital status?"
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
"id": "case_8_language_additive",
|
|
114
|
+
"statements": [
|
|
115
|
+
"I speak English natively.",
|
|
116
|
+
"I learned Spanish in college."
|
|
117
|
+
],
|
|
118
|
+
"expected_active": [
|
|
119
|
+
{
|
|
120
|
+
"predicate": "speaks_language",
|
|
121
|
+
"object": "English"
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
"predicate": "speaks_language",
|
|
125
|
+
"object": "Spanish"
|
|
126
|
+
}
|
|
127
|
+
],
|
|
128
|
+
"query": "What languages do I speak?"
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
"id": "case_9_device_update",
|
|
132
|
+
"statements": [
|
|
133
|
+
"I use an iPhone 13.",
|
|
134
|
+
"I broke my phone and bought an Android."
|
|
135
|
+
],
|
|
136
|
+
"expected_active": [
|
|
137
|
+
{
|
|
138
|
+
"predicate": "uses_device",
|
|
139
|
+
"object": "Android"
|
|
140
|
+
}
|
|
141
|
+
],
|
|
142
|
+
"query": "What phone do I use?"
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
"id": "case_10_duplicate_fact",
|
|
146
|
+
"statements": [
|
|
147
|
+
"My favorite color is blue.",
|
|
148
|
+
"I really like the color blue."
|
|
149
|
+
],
|
|
150
|
+
"expected_active": [
|
|
151
|
+
{
|
|
152
|
+
"predicate": "favorite_color",
|
|
153
|
+
"object": "blue"
|
|
154
|
+
}
|
|
155
|
+
],
|
|
156
|
+
"query": "What is my favorite color?"
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
"id": "case_11_multihop_base",
|
|
160
|
+
"statements": [
|
|
161
|
+
"My brother is named Alex.",
|
|
162
|
+
"Alex adopted a golden retriever dog named Buddy."
|
|
163
|
+
],
|
|
164
|
+
"expected_active": [
|
|
165
|
+
{
|
|
166
|
+
"predicate": "is_named",
|
|
167
|
+
"object": "Alex"
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
"predicate": "has_pet",
|
|
171
|
+
"object": "Buddy"
|
|
172
|
+
}
|
|
173
|
+
],
|
|
174
|
+
"query": "What kind of dog does my brother have?"
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
"id": "case_12_multihop_contradiction",
|
|
178
|
+
"statements": [
|
|
179
|
+
"My friend Sarah lives in Austin.",
|
|
180
|
+
"Sarah actually moved to Seattle last month."
|
|
181
|
+
],
|
|
182
|
+
"expected_active": [
|
|
183
|
+
{
|
|
184
|
+
"predicate": "lives_in",
|
|
185
|
+
"object": "Seattle"
|
|
186
|
+
}
|
|
187
|
+
],
|
|
188
|
+
"query": "Where does my friend Sarah live?"
|
|
189
|
+
}
|
|
190
|
+
]
|
memor/testing/plugin.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import logging
|
|
5
|
+
from typing import List, Dict
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
from tenacity import retry, wait_exponential, stop_after_attempt
|
|
9
|
+
from memor.config import chat
|
|
10
|
+
from memor.testing.adapter import MemoryBackendAdapter
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
class EvalResult(BaseModel):
|
|
15
|
+
match: bool
|
|
16
|
+
|
|
17
|
+
@retry(wait=wait_exponential(multiplier=1, min=2, max=10), stop=stop_after_attempt(3))
|
|
18
|
+
def _is_semantic_match(expected: dict, actual_facts: List[Dict[str, str]] | List[str]) -> bool:
|
|
19
|
+
if not actual_facts:
|
|
20
|
+
return False
|
|
21
|
+
|
|
22
|
+
prompt = f"""You are evaluating a memory extraction system.
|
|
23
|
+
We expected the system to remember this fact: {expected}
|
|
24
|
+
|
|
25
|
+
The system currently remembers these facts (or retrieved text): {actual_facts}
|
|
26
|
+
|
|
27
|
+
Is the expected fact semantically present in the system's current memories/text?
|
|
28
|
+
Reply true if the expected fact is a logical subset of the actual facts (ignore any extra facts, just ensure the expected information is captured).
|
|
29
|
+
Examples of perfect matches: 'speaks_natively=English' matches 'speaks_language=English', 'learned_language=Spanish in college' matches 'speaks_language=Spanish', and 'has_pet=dog named Max' matches 'owns_pet=dog'.
|
|
30
|
+
Reply ONLY with JSON: {{"match": true}} or {{"match": false}}"""
|
|
31
|
+
|
|
32
|
+
raw = chat([{"role": "user", "content": prompt}], json_mode=True)
|
|
33
|
+
try:
|
|
34
|
+
parsed = json.loads(raw)
|
|
35
|
+
return EvalResult(**parsed).match
|
|
36
|
+
except Exception as e:
|
|
37
|
+
logger.error(f"LLM Eval parsing error: {e}")
|
|
38
|
+
return False
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def load_cases():
|
|
42
|
+
base_dir = os.path.dirname(__file__)
|
|
43
|
+
cases_path = os.path.join(base_dir, "cases.json")
|
|
44
|
+
with open(cases_path, "r") as f:
|
|
45
|
+
return json.load(f)
|
|
46
|
+
|
|
47
|
+
CASES = load_cases()
|
|
48
|
+
|
|
49
|
+
def pytest_generate_tests(metafunc):
|
|
50
|
+
"""
|
|
51
|
+
Automatically injects test_temporal_memory_benchmark into the pytest session
|
|
52
|
+
if the function exists in the user's test suite, parametrizing it with all cases.
|
|
53
|
+
Alternatively, we can just define the test function here directly, but pytest plugins
|
|
54
|
+
usually provide fixtures or collect specific files.
|
|
55
|
+
|
|
56
|
+
Actually, since we want to *provide* the test itself, we can define a base class
|
|
57
|
+
that users can inherit from, or just define the test directly in this plugin namespace.
|
|
58
|
+
"""
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
# We define the actual test case that Pytest will collect if this plugin is loaded.
|
|
62
|
+
# Users must provide a `memor_adapter` fixture that yields an instance of MemoryBackendAdapter.
|
|
63
|
+
|
|
64
|
+
class BaseTemporalMemoryBenchmark:
|
|
65
|
+
@pytest.mark.parametrize("case", CASES, ids=[c["id"] for c in CASES])
|
|
66
|
+
def test_temporal_memory_benchmark(self, memor_adapter: MemoryBackendAdapter, case):
|
|
67
|
+
"""
|
|
68
|
+
End-to-End Pipeline Evaluation for a single benchmark case.
|
|
69
|
+
Validates both the Write Path and the Read Path using the provided MemoryBackendAdapter.
|
|
70
|
+
"""
|
|
71
|
+
case_id = case["id"]
|
|
72
|
+
statements = case["statements"]
|
|
73
|
+
expected_active = case["expected_active"]
|
|
74
|
+
query = case["query"]
|
|
75
|
+
|
|
76
|
+
user_id = f"user_bench_{case_id}"
|
|
77
|
+
|
|
78
|
+
memor_adapter.clear(user_id)
|
|
79
|
+
|
|
80
|
+
# 1. INGESTION (WRITE PATH)
|
|
81
|
+
for stmt in statements:
|
|
82
|
+
memor_adapter.add_fact(user_id, stmt)
|
|
83
|
+
|
|
84
|
+
# 2. VERIFY WRITE PATH (Storage State)
|
|
85
|
+
active_rows = memor_adapter.get_all_active_facts(user_id)
|
|
86
|
+
|
|
87
|
+
for exp in expected_active:
|
|
88
|
+
assert _is_semantic_match(exp, active_rows), (
|
|
89
|
+
f"Write Path Failure: Expected fact {exp} missing or contradicted in store.\nActual: {active_rows}"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
# 3. VERIFY READ PATH (Hybrid Retrieval + Multi-hop)
|
|
93
|
+
retrieved_texts = memor_adapter.query(user_id, query, k=3)
|
|
94
|
+
|
|
95
|
+
for exp in expected_active:
|
|
96
|
+
assert _is_semantic_match(exp, retrieved_texts), (
|
|
97
|
+
f"Read Path Failure: Retriever failed to surface expected fact {exp} for query '{query}'.\nRetrieved context: {retrieved_texts}"
|
|
98
|
+
)
|
|
99
|
+
|
|
@@ -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,16 @@
|
|
|
1
|
+
memor/__init__.py,sha256=rm96Rj6Mpclf3QD0uJf96nRRGVTzA5hpTVIbmD88ZJs,435
|
|
2
|
+
memor/config.py,sha256=ZFVQgvprR5DovobqcxnXBpOlRVeTI4YkjQhF69Lh4Gg,1821
|
|
3
|
+
memor/engine.py,sha256=CvKCL8x_lezBBstJ6gC0TXq3f5qqvVVbGvckTYqi5gI,5127
|
|
4
|
+
memor/extractor.py,sha256=hi7r-RfsDM3t5mj76WlCs_DblCpu7V4VtrAZhpr9ys0,2554
|
|
5
|
+
memor/resolver.py,sha256=xTwELRy6Rv7C4BV5gKj4-pu8nVjHVJmEo3mKNYgkejk,4205
|
|
6
|
+
memor/retriever.py,sha256=16MWDPPVAF3hTkeX0H8P1QGYbK4xlwBdB8ny8pvu76o,6405
|
|
7
|
+
memor/store.py,sha256=C-JOdjewmIBVmdhwjgnHjfsc0NCEf1RiAhW3tHTJyT4,8734
|
|
8
|
+
memor/testing/__init__.py,sha256=ze15iEfiSMUgeRCQaMgrF9szwI_4mC-iLODZ5CW4jXM,78
|
|
9
|
+
memor/testing/adapter.py,sha256=1NQVQpEuKSI7yz2-fwCCJRo7ollbBTzgZ4VtI8ykrcg,1257
|
|
10
|
+
memor/testing/cases.json,sha256=SRqYtQTHOZ9-te0QTstmBNcnvV7rIDOFwKQQGZ4X6vY,4192
|
|
11
|
+
memor/testing/plugin.py,sha256=j5YBr56xQAAhXYzKwAxMzE25aN14O-EXeh34PvSapZ0,4013
|
|
12
|
+
memor_db-0.1.0.dist-info/METADATA,sha256=cbYbY6w_vpaxv1gupQBddPMjJOHR7toZL5DqdAb9pIc,7832
|
|
13
|
+
memor_db-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
14
|
+
memor_db-0.1.0.dist-info/entry_points.txt,sha256=KtG70E8eY8wNVltXEOyP1Vk9-JKO_LK64wIDmsKNciM,50
|
|
15
|
+
memor_db-0.1.0.dist-info/top_level.txt,sha256=XWNkuLOmIbKZzvUo36F2CF4U3OeM3LbenmhDvi1YpWw,6
|
|
16
|
+
memor_db-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
memor
|