Python library¶
The MCP server is a thin shell over MemoryStore, which you can import and use
directly. Same database, same behaviour, no MCP involved.
Basics¶
from localmem_mcp import MemoryStore
store = MemoryStore() # ~/.localmem/memories.db
store.add(
"We chose SQLite over Postgres because it ships in a single file",
tags=["decision", "architecture"],
source="design-review",
)
for hit in store.search("what database are we using?"):
print(f"{hit.score:.3f} {hit.memory.content}")
The store is a context manager, which is the tidiest way to use it:
with MemoryStore(db_path="./project.db") as store:
store.add("Deploys go out on Thursdays", tags=["ops"])
print(store.count())
Storing¶
memory = store.add(
content="Priya prefers async updates over standups",
tags=["team", "preference"],
source="1:1 notes",
metadata={"confidence": 0.9, "person": "Priya"},
)
memory.id # 7
memory.tags # ["team", "preference"] — normalized
memory.created_at # "2026-08-14T11:31:00+00:00"
metadata is an arbitrary JSON-serializable dict, stored alongside the memory
and returned intact. It isn't searched — use tags for anything you want to
filter on.
Tags are lowercased, trimmed, and de-duplicated in order. A string is accepted
too and split on commas, so tags="work,urgent" and tags=["work", "urgent"]
are equivalent.
Empty content raises ValueError.
Searching¶
results = store.search(
query="deployment schedule",
limit=10,
tags=["ops"], # must carry ALL of these
min_score=0.3,
offset=0, # skip this many ranked results
)
for result in results:
print(result.score, result.memory.id, result.memory.content)
| Argument | Type | Default | Description |
|---|---|---|---|
query |
str |
required | What you're trying to remember, in natural language. |
limit |
int |
5 |
Maximum memories to return. |
tags |
Iterable[str] \| str |
None |
Only consider memories carrying all of these tags. |
min_score |
float |
0.0 |
Drop results scoring below this, on a 0–1 scale. |
offset |
int |
0 |
Skip this many ranked results before applying limit. |
Each result is a SearchResult wrapping a Memory and a score — cosine
similarity plus a bounded keyword bonus, on a 0–1 scale. See
How search works.
offset is applied after ranking, so search(q, limit=5, offset=5) is the
second page of search(q, limit=5). Ranking is deterministic (score
descending, ties broken by id), so pages neither overlap nor skip. Negative
offsets are clamped to 0, and an offset past the end returns [].
Reading, updating, and deleting¶
memory = store.get(7) # Memory | None
memories, total = store.list(
tags=["ops"], limit=20, offset=0, order="newest"
) # tuple[list[Memory], int]
recent = store.recent(limit=10) # newest first
recent = store.recent(limit=10, tags=["ops"])
updated = store.update(7, content="Priya prefers written updates over standups")
store.update(7, tags=["team", "preference"]) # omit content to skip re-embedding
store.delete(7) # True if it existed
removed = store.delete_many(tags=["scratch"]) # int — count removed
removed = store.delete_many(older_than_days=90)
preview = store.matching(tags=["stale"]) # list[Memory] — what a bulk delete would remove
store.count() # int
store.stats() # {"db_path": …, "memories": …, "embedding_model": …}
list() enumerates stored memories with tag filtering, ordering ("newest" or "oldest"), and pagination (limit, offset). It performs query construction and tag filtering directly in SQL without invoking embedding models or semantic search, returning a (memories, total_count) tuple.
update() corrects a memory in place. Only the fields you pass are changed —
omitting content leaves the embedding alone, so retagging is instant. Changing
content re-embeds the memory so search finds the correction. created_at is
preserved, updated_at is refreshed, and a missing id returns None rather
than raising.
delete_many() bulk-deletes by tag and/or age and requires at least one filter
— an unfiltered call raises ValueError so the store can't be wiped by
accident. matching() applies the same filters without deleting, so you can
preview what delete_many() would remove first.
Serializing¶
Memory and SearchResult are dataclasses with to_dict() — the same shape
the MCP tools return:
import json
memories = [m.to_dict() for m in store.recent(limit=100)]
print(json.dumps(memories, indent=2))
Exporting and importing¶
export_records() yields plain dicts, oldest first, streaming rather than
loading everything at once. import_records() reads them back — from any
iterable of JSONL strings, so an open file works directly.
import json
from localmem_mcp import MemoryStore, import_records
with MemoryStore(db_path="./old.db") as source, open("memories.jsonl", "w") as out:
for record in source.export_records(tags=["work"]):
out.write(json.dumps(record) + "\n")
with MemoryStore(db_path="./new.db") as target, open("memories.jsonl") as handle:
report = import_records(target, handle)
print(report.imported, report.skipped_duplicates, report.errors)
Imported memories are re-embedded by the target store — vectors in the file are
ignored, since one produced by a different model would not be detectably wrong,
it would just stop matching. created_at is preserved, records whose content
already exists are skipped unless you pass allow_duplicates=True, and
dry_run=True reports what would happen without writing. Malformed lines land
in report.errors with their line number instead of raising.
Choosing a database and model¶
With no db_path, the same resolution order as everything else applies:
LOCALMEM_DB_PATH, then LOCALMEM_HOME, then ~/.localmem/memories.db. See
Configuration.
Custom embedders¶
MemoryStore accepts any object with a name attribute and an
embed(texts) -> list[list[float]] method. That's the Embedder protocol:
from localmem_mcp import MemoryStore
class MyEmbedder:
name = "my-embedder"
def embed(self, texts):
return [my_model.encode(t) for t in texts]
store = MemoryStore(db_path="./custom.db", embedder=MyEmbedder())
This exists mainly so tests can run offline — the project's own suite uses a deterministic bag-of-words stub instead of downloading a model. It's also the extension point if you want a different embedding backend.
Embedders aren't interchangeable mid-database
Vectors from different models can't be compared. localmem records the model
name and dimension per row and scores mismatched dimensions as 0.0, so
results degrade rather than mislead — but a database should stick to one
embedder.
Threading and processes¶
The connection is opened with check_same_thread=False and writes are guarded
by a lock, so a single MemoryStore is safe to share across threads.
Across processes, SQLite's WAL mode handles concurrent readers alongside one writer — a CLI command can read while an MCP server holds the file open. Heavy concurrent writing from many processes isn't a workload this is built for.
Performance notes¶
- The first embed call is slow — that's the model loading (and downloading,
the very first time). It's lazy, so constructing a
MemoryStoreis cheap. - Search scans every row. Exact, no index to maintain, and imperceptible at personal scale. See why.
- Embedding dominates. Both
add()andsearch()embed one string; the SQLite work either side of that is negligible.
Full API¶
Every public class and method is documented in the Python API reference.