Detect stale facts in LLM agent memory stores
Project description
memlint
Lint your LLM agent's memory before it lies to you.
memlint detects stale facts in an LLM agent's memory store before they are injected into the context window. It scores each fact by age, confirmation history, and contradiction signals, then tells you which ones to flag, refresh, or discard.
Works with RAG pipelines, vector databases (Pinecone, Qdrant, Chroma, Weaviate, pgvector), LangChain, LangGraph, Mem0, and any agent framework that retrieves memory before prompting.
What it gives you:
create_memory_metadata: generate timestamped metadata to store alongside your vectors at embedding timeconfirm_fact/confirm_facts: reset the decay clock when a user re-states somethingreport.enrich_metadata(docs): merge staleness scores back into your original docs, ready to upsert in one calldetector.when_stale(fact): project when a fact will cross each threshold, for proactive refresh scheduling- Configurable decay rates per category, so you can tune lifespans to your use case
- Semantic contradiction detection: flags
"User works at Acme"as stale when"User left Acme"exists, even without shared keywords
The problem
LLM agents that work across sessions store facts about the user and world - where they live, where they work, what they're building. These facts go stale when the real world changes but the memory doesn't. A fact like "User works at xyz" stays in memory after a job change. The agent retrieves it, injects it, and answers confidently with wrong information.
memlint catches this before it happens.
Why not just use recency ranking?
Recency ranking softly downranks older memories at retrieval time. It does not tell you which specific facts are wrong or why. A 2-year-old identity fact ("name is X") should stay; a 3-month-old employment fact ("works at xyz") might already be wrong.
memlint scores by fact type, not just age, because a location changes on a different timescale than a project dependency, which changes on a different timescale than a name. It also detects contradictions (two facts about the same topic where a newer one exists) and confirmation signals (facts the user has re-stated recently are less likely to be stale).
Recency ranking is retrieval optimization. memlint is memory auditing. They solve different problems.
Installation
pip install memlint
With optional LLM-assisted classification:
pip install memlint[llm]
memlint[llm] installs langchain-core, langchain-openai, and langchain. For other backends use the named extras:
pip install memlint[anthropic] # Anthropic Claude
pip install memlint[nvidia] # NVIDIA NIM
pip install memlint[ollama] # Ollama (local models)
pip install memlint[bedrock] # AWS Bedrock
Or install any LangChain integration directly. Any object with an invoke() or ainvoke() method works. No LangChain dependency required for the core library.
Quick Start
from memlint import StaleDetector, MemoryFact, create_memory_metadata, confirm_fact
# 1. at embedding time, generate metadata to store with your vector
metadata = create_memory_metadata(current_default=True)
# returns: {"created_at": "...", "source": "user", "confirmation_count": 0}
# store this in Pinecone, Qdrant, Chroma, JSON, anywhere
# 2. at retrieval time, load into MemoryFact and check
facts = [MemoryFact(id=doc["id"], content=doc["text"], **doc["metadata"]) for doc in retrieved_docs]
detector = StaleDetector()
report = detector.check(facts)
print(f"Flagged: {len(report.flagged)} / {report.total_facts}")
for result in report.flagged:
print(f" [{result.staleness_level.value.upper()}] {result.content}")
print(f" Reason: {result.reason}")
# 3. write scores back into your DB in one call
enriched = report.enrich_metadata(retrieved_docs)
collection.upsert(vectors=enriched)
# 4. when a user re-states a fact, confirm it to reset its decay clock
fact = facts[0] # whichever fact the user re-stated
updated = confirm_fact(fact)
CLI Usage
Check all facts:
memlint check memories.json
Show only stale and expired:
memlint check memories.json --only-flagged
Output raw JSON:
memlint check memories.json --json
Parse Mem0 format:
memlint check memories.json --format mem0
Sample output:
╭──────────┬────────────────────────────────────────┬────────────┬─────┬───────┬─────────┬─────────╮
│ ID │ Content │ Category │ Age │ Score │ Level │ Action │
├──────────┼────────────────────────────────────────┼────────────┼─────┼───────┼─────────┼─────────┤
│ mem_004 │ User works at XYZ as a senior cons... │ employment │ 279 │ 0.70 │ STALE │ flag │
│ mem_006 │ User debugged a LangGraph memory is... │ episodic │ 29 │ 1.00 │ EXPIRED │ discard │
╰──────────┴────────────────────────────────────────┴────────────┴─────┴───────┴─────────┴─────────╯
Checked 8 facts: 1 fresh, 2 aging, 3 stale, 2 expired
Staleness Score Explained
Each fact is assigned a category with a natural lifespan:
| Category | Examples | Typical Valid Window |
|---|---|---|
location |
"lives in Delhi", "office in Sector 5" | 6–24 months |
employment |
"works at xyz", "role is consultant" | 6–18 months |
project |
"building pract-agents", "using Pinecone" | 1–6 months |
preference |
"prefers Python", "uses dark mode" | 3–12 months |
relationship |
"manager is X", "team has 5 people" | 3–12 months |
identity |
"name is X", "speaks Hindi" | Very long/permanent |
episodic |
"debugged a LangGraph issue today" | Days to weeks |
system_fact |
"Python version is 3.10", "npm v9" | 1–3 months |
Score thresholds:
0.0 – 0.29→ FRESH (safe to use)0.30 – 0.59→ AGING (use with caution)0.60 – 0.79→ STALE (flag before injecting)0.80 – 1.0→ EXPIRED (do not inject without reconfirmation)
Contradiction Detection
memlint flags a fact as contradicted when a newer fact conflicts with it. No LLM needed.
Two signals trigger contradiction:
1. Shared anchor keyword (existing): both facts share a category keyword like "works at", "lives in", "project".
"User works at Acme" + "User works at Google" -> older one flagged
2. Negation or transition signal + shared entity (semantic): one fact contains a negation word ("left", "quit", "no longer", "moved from", "resigned") and both facts mention the same named entity.
"User works at Acme" + "User left Acme" -> flagged (negation + shared entity)
"User lives in Delhi" + "User moved from Delhi to Mumbai" -> flagged
"User is building with Pinecone" + "User migrated from Pinecone" -> flagged
A contradicted fact gets a +0.40 score penalty on top of its age-based score.
LLM-assisted Classification (optional)
By default memlint works fully offline. No LLM is ever called unless you explicitly enable it.
The optional LLM path is used only for fact classification: deciding whether a fact is employment, location, project, etc. That category determines the decay rate. Rule-based keyword matching handles this well for most facts. The LLM improves accuracy on ambiguous ones.
# default: fully offline, rule-based classification
detector = StaleDetector()
# optional: LLM-assisted classification
detector = StaleDetector(use_llm=True, llm=your_llm)
Any object with invoke() / ainvoke() works as llm. If the LLM call fails for any reason, it silently falls back to rule-based classification.
Adapters
JSON: default format:
from memlint.adapters.json_adapter import load_from_json
facts = load_from_json("memories.json")
Expected JSON structure (created_at and all fields except id and content are optional):
[
{
"id": "mem_001",
"content": "User works at Acme Corp",
"created_at": "2025-08-31T09:00:00",
"source": "user",
"confirmation_count": 0,
"last_confirmed_at": null
}
]
If created_at is missing, it defaults to the current UTC time (facts without a timestamp are treated as brand new and will score FRESH).
Mem0: maps memory to content, updated_at to last_confirmed_at:
from memlint.adapters.mem0_adapter import load_from_mem0
facts = load_from_mem0("mem0_export.json")
LangChain: two tools: check_memory_staleness and filter_stale_memories (see below).
LangChain / LangGraph Integration
from memlint.adapters.langchain_tool import (
check_memory_staleness,
filter_stale_memories,
)
# In a LangGraph node: filter before injecting memories into the LLM
safe_facts_json = filter_stale_memories.invoke({"facts_json": memories_json_string})
Requires pip install memlint[llm].
RAG and Vector DB Integration
Drop memlint between your vector DB retrieval step and context injection. Works with any store that returns documents with a timestamp in metadata.
from memlint import StaleDetector, MemoryFact, create_memory_metadata
# At embedding time, generate metadata and store it alongside your vector
metadata = create_memory_metadata(current_default=True)
collection.upsert(id="mem_001", vector=embedding, metadata=metadata)
# At retrieval time, load directly into MemoryFact
detector = StaleDetector()
results = collection.query(query_texts=[user_query], n_results=10)
facts = [
MemoryFact(id=doc["id"], content=doc["text"], **doc["metadata"])
for doc in results
]
# only inject facts that are still fresh
safe = detector.filter_safe(facts)
context = "\n".join(f.content for f in safe)
Async version for async RAG chains:
safe = await detector.filter_safe_async(facts)
Works with any LLM backend for optional classification: OpenAI, Anthropic, NVIDIA NIM, Ollama, AWS Bedrock, or any object with an invoke() / ainvoke() method.
Reconfirming Facts
When a user re-states a fact, confirm it to reset its decay clock:
from memlint import confirm_fact, confirm_facts
# single fact
updated = confirm_fact(fact)
# batch
updated_facts = confirm_facts(facts)
# store updated facts back to your DB
for f in updated_facts:
collection.update(id=f.id, metadata={"confirmation_count": f.confirmation_count,
"last_confirmed_at": f.last_confirmed_at.isoformat()})
confirm_fact returns a new fact. It never mutates the original.
Exporting Scores Back to Your DB
After running a check, enrich your original documents with staleness scores and upsert them back. One call, drop-in ready:
report = detector.check(facts)
# merges memlint fields into your original docs, originals are not mutated
enriched = report.enrich_metadata(original_docs)
# enriched[0] = {"id": "mem_001", "text": "...", "created_at": "...",
# "memlint_score": 0.72, "memlint_level": "stale",
# "memlint_age_days": 120, "memlint_checked_at": "2026-06-07T..."}
collection.upsert(vectors=enriched)
If your docs use a different ID field, pass id_key:
enriched = report.enrich_metadata(original_docs, id_key="fact_id")
Next time, pre-filter at query level before loading into Python:
results = collection.query(
query_texts=[user_query],
where={"memlint_level": {"$nin": ["stale", "expired"]}},
)
Configurable Decay Rates
By default, memlint uses built-in decay rates per category. You can override any of them when creating a detector:
from memlint import StaleDetector, FactCategory
detector = StaleDetector(decay_rates={
FactCategory.EMPLOYMENT: 0.005, # slower decay (default is 0.0025)
FactCategory.PROJECT: 0.010, # faster decay (default is 0.006)
})
Only the categories you specify are overridden. All others use the built-in defaults. Useful when your use case has different memory lifespans than the defaults (e.g., a platform where job changes happen more slowly, or project facts go stale faster).
Default rates for reference:
| Category | Default rate | Typical half-life |
|---|---|---|
identity |
0.0005 | ~1600 days |
location |
0.0020 | ~400 days |
employment |
0.0025 | ~320 days |
relationship |
0.0025 | ~320 days |
preference |
0.0030 | ~270 days |
system_fact |
0.0100 | ~80 days |
project |
0.0060 | ~133 days |
episodic |
0.0500 | ~16 days |
Decay Preview
Find out when a fact will cross each staleness threshold, so you can schedule proactive memory refreshes:
schedule = detector.when_stale(fact)
print(schedule["aging"]) # datetime when score crosses 0.30
print(schedule["stale"]) # datetime when score crosses 0.60
print(schedule["expired"]) # datetime when score crosses 0.80
# use it to decide when to schedule a refresh
days_until_expired = (schedule["expired"] - fact.created_at).days
Dates in the past mean the threshold has already been crossed. The projection is based on current age and confirmation count and does not account for future confirmations or contradictions.
Contributing
Open an issue or pull request at https://github.com/Bhavye2003Developer/memlint. See CONTRIBUTING.md for details.
License
MIT License - see LICENSE for details.
Copyright (c) 2026 MatrixEscaper
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file memlint-0.1.6.tar.gz.
File metadata
- Download URL: memlint-0.1.6.tar.gz
- Upload date:
- Size: 28.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.2
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
f77296a468e49567293b006a175e928d08c95b0587fbf41c41c925acec425507
|
|
| MD5 |
cd04fd3de671812b04063d48228a5b92
|
|
| BLAKE2b-256 |
947200fdbf04621a6db848599e74442d27a87b897bb49717e64c08cbc8aae51a
|
File details
Details for the file memlint-0.1.6-py3-none-any.whl.
File metadata
- Download URL: memlint-0.1.6-py3-none-any.whl
- Upload date:
- Size: 22.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.2
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a2f6194372530622486e53636bcdb6010fcea928f576727938996242dd1f1e87
|
|
| MD5 |
27a7b3011fdeeaf5037dbb3adff03e2f
|
|
| BLAKE2b-256 |
b45f5d210faddbe466035d7939a421689ccc61095d62a9741f7ace931ed79152
|