Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@ All notable changes to HotMem will be documented in this file.

Format follows [Keep a Changelog](https://keepachangelog.com/).

## [0.1.7] - 2026-06-15

### Added
- Async Python client with async equivalents of the synchronous SDK methods.
- Compressed `.jsonl.gz` swap hydrate and snapshot support.

### Fixed
- Swap endpoints now return clear API errors for unsupported swap formats.
- Malformed compressed swap files now report actionable hydration errors.

## [0.1.6] - 2026-06-09

### Added
Expand Down
12 changes: 8 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ hotmem serve
hotmem serve --port 8711 --mount ./data/hotmem
hotmem serve --db ./my.sqlite
hotmem hydrate --file swap.jsonl --db ./my.sqlite
hotmem hydrate --file swap.jsonl.gz --db ./my.sqlite
hotmem snapshot --file swap.jsonl --db ./my.sqlite
hotmem status
```
Expand Down Expand Up @@ -107,9 +108,12 @@ with HotMemClient("http://127.0.0.1:8711") as client:

Any directory can be a HotMem mount. The mount contains:

- `hotmem.sqlite` — the database
- `swap.jsonl` — portable JSONL backup
- `manifest.json` — mount metadata
- `hotmem.sqlite` - the database
- `swap.jsonl` - portable JSONL backup
- `manifest.json` - mount metadata

Plain `.jsonl` is the canonical portable swap format. HotMem can also hydrate
from and snapshot to `.jsonl.gz` for compressed archives.

```bash
hotmem serve --mount /mnt/usb/hotmem # portable memory on USB
Expand Down Expand Up @@ -154,4 +158,4 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup and guidelines.

## License

MIT see [LICENSE](LICENSE).
MIT - see [LICENSE](LICENSE).
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "hotmem"
version = "0.1.6"
version = "0.1.7"
description = "A local-first memory sidecar for agent applications"
readme = "README.md"
requires-python = ">=3.11"
Expand Down
4 changes: 2 additions & 2 deletions src/hotmem/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""HotMem A local-first memory sidecar for agent applications."""
"""HotMem - A local-first memory sidecar for agent applications."""

__version__ = "0.1.6"
__version__ = "0.1.7"
86 changes: 84 additions & 2 deletions src/hotmem/client.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""HotMem client Python SDK for the memory sidecar.
"""HotMem client - Python SDK for the memory sidecar.

Purpose:
Provide a simple, typed client for the HotMem HTTP API.
Expand All @@ -11,9 +11,11 @@
.health() -> dict
.hydrate(file?) -> dict
.snapshot(file?) -> dict
AsyncHotMemClient(base_url)
Async equivalent of HotMemClient.

Deps: httpx
Extension: add async client, retry logic, or connection pooling here.
Extension: add retry logic or connection pooling here.
"""

from __future__ import annotations
Expand Down Expand Up @@ -101,3 +103,83 @@ def __enter__(self) -> HotMemClient:

def __exit__(self, *args: object) -> None:
self.close()


class AsyncHotMemClient:
"""Asynchronous client for the HotMem sidecar API."""

def __init__(self, base_url: str = "http://127.0.0.1:8711") -> None:
self.base_url = base_url.rstrip("/")
self._client = httpx.AsyncClient(base_url=self.base_url, timeout=30.0)

async def health(self) -> dict[str, Any]:
"""Check server health."""
resp = await self._client.get("/v1/health")
resp.raise_for_status()
return resp.json()

async def add(
self,
identifier: str,
fact: str,
*,
source: str = "",
importance: float = 0.5,
metadata: dict[str, Any] | None = None,
ttl_seconds: int | None = None,
) -> dict[str, Any]:
"""Add a fact to memory."""
payload = {
"identifier": identifier,
"fact": fact,
"source": source,
"importance": importance,
"metadata": metadata or {},
}
if ttl_seconds is not None:
payload["ttl_seconds"] = ttl_seconds
resp = await self._client.post("/v1/add", json=payload)
resp.raise_for_status()
return resp.json()

async def search(
self,
query: str,
top_k: int = 5,
max_chars: int | None = None,
) -> list[dict[str, Any]]:
"""Search memories and return LLM-ready message objects."""
payload: dict[str, Any] = {"query": query, "top_k": top_k}
if max_chars is not None:
payload["max_chars"] = max_chars
resp = await self._client.post("/v1/search", json=payload)
resp.raise_for_status()
return resp.json()["memories"]

async def hydrate(self, file: str | None = None) -> dict[str, Any]:
"""Trigger swap file hydration."""
payload: dict[str, Any] = {}
if file:
payload["file"] = file
resp = await self._client.post("/v1/hydrate", json=payload)
resp.raise_for_status()
return resp.json()

async def snapshot(self, file: str | None = None) -> dict[str, Any]:
"""Trigger database snapshot to swap file."""
payload: dict[str, Any] = {}
if file:
payload["file"] = file
resp = await self._client.post("/v1/snapshot", json=payload)
resp.raise_for_status()
return resp.json()

async def close(self) -> None:
"""Close the underlying HTTP client."""
await self._client.aclose()

async def __aenter__(self) -> AsyncHotMemClient:
return self

async def __aexit__(self, *args: object) -> None:
await self.close()
14 changes: 10 additions & 4 deletions src/hotmem/server.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""HotMem server FastAPI app with traced endpoints.
"""HotMem server - FastAPI app with traced endpoints.

Purpose:
HTTP sidecar serving memory operations on a single port.
Expand All @@ -22,7 +22,7 @@
from pathlib import Path
from typing import Any

from fastapi import FastAPI, Request
from fastapi import FastAPI, HTTPException, Request
from pydantic import BaseModel, Field

from hotmem.db import MemoryDB
Expand Down Expand Up @@ -190,7 +190,10 @@ async def search(req: SearchRequest):
async def hydrate(req: HydrateRequest):
db: MemoryDB = _state["db"]
swap = req.file or _state.get("swap_path") or "swap.jsonl"
result = swap_hydrate(db, swap)
try:
result = swap_hydrate(db, swap)
except ValueError as err:
raise HTTPException(status_code=400, detail=str(err)) from err
return {
"loaded": result.loaded,
"skipped_dupes": result.skipped_dupes,
Expand All @@ -200,7 +203,10 @@ async def hydrate(req: HydrateRequest):
async def snapshot(req: SnapshotRequest):
db: MemoryDB = _state["db"]
swap = req.file or _state.get("swap_path") or "swap.jsonl"
result = swap_snapshot(db, swap)
try:
result = swap_snapshot(db, swap)
except ValueError as err:
raise HTTPException(status_code=400, detail=str(err)) from err
return {
"exported": result.exported,
"path": result.path,
Expand Down
139 changes: 91 additions & 48 deletions src/hotmem/swap.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""HotMem swap JSONL hydration and snapshot.
"""HotMem swap - JSONL hydration and snapshot.

Purpose:
Load memories from a swap file (JSONL) into the database, and export
Expand All @@ -17,11 +17,15 @@

import base64
import binascii
import gzip
import hashlib
import json
import uuid
from collections.abc import Iterator
from contextlib import contextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import TextIO

from hotmem.db import MemoryDB, MemoryRecord
from hotmem.embed import EMBEDDING_DIM, EMBEDDING_MODEL, embed_text, pack_embedding
Expand All @@ -47,6 +51,40 @@ class SnapshotResult:
path: str


def _swap_format(path: Path) -> str:
"""Return the supported swap format for path, or raise a clear error."""
suffixes = [suffix.lower() for suffix in path.suffixes]
if suffixes[-1:] == [".jsonl"]:
return "jsonl"
if suffixes[-2:] == [".jsonl", ".gz"]:
return "jsonl.gz"

supported = ".jsonl, .jsonl.gz"
raise ValueError(f"unsupported swap file extension for {path}; supported: {supported}")


@contextmanager
def _open_swap_read(path: Path) -> Iterator[TextIO]:
swap_format = _swap_format(path)
if swap_format == "jsonl.gz":
with gzip.open(path, "rt") as f:
yield f
else:
with open(path) as f:
yield f


@contextmanager
def _open_swap_write(path: Path) -> Iterator[TextIO]:
swap_format = _swap_format(path)
if swap_format == "jsonl.gz":
with gzip.open(path, "wt") as f:
yield f
else:
with open(path, "w") as f:
yield f


def _metadata_json(record: dict) -> str:
metadata = record.get("metadata")
if metadata is not None:
Expand Down Expand Up @@ -77,9 +115,9 @@ def _stored_embedding(record: dict) -> bytes | None:


def hydrate(db: MemoryDB, swap_path: str | Path) -> HydrateResult:
"""Load memories from a JSONL swap file into the database.
"""Load memories from a JSONL or JSONL.GZ swap file into the database.

Deduplicates by content_hash skips rows that already exist in the DB.
Deduplicates by content_hash - skips rows that already exist in the DB.
"""
swap_path = Path(swap_path)
if not swap_path.exists():
Expand All @@ -95,50 +133,55 @@ def hydrate(db: MemoryDB, swap_path: str | Path) -> HydrateResult:
reused_embeddings = 0
computed_embeddings = 0

with open(swap_path) as f:
for line in f:
bytes_read += len(line.encode())
line = line.strip()
if not line:
continue
record = json.loads(line)
parsed += 1

identifier = record.get("identifier", "")
fact_text = record.get("fact_text", "")
content_hash = record.get("content_hash") or compute_content_hash(
identifier, fact_text
)

if content_hash in seen_hashes:
skipped += 1
continue
seen_hashes.add(content_hash)

blob = _stored_embedding(record)
if blob is None:
vec = embed_text(fact_text)
blob = pack_embedding(vec)
computed_embeddings += 1
else:
reused_embeddings += 1

records.append(
MemoryRecord(
id=record.get("id", uuid.uuid4().hex),
identifier=identifier,
fact_text=fact_text,
embedding=blob,
embedding_dim=record.get("embedding_dim", EMBEDDING_DIM),
embedding_model=record.get("embedding_model", EMBEDDING_MODEL),
source=record.get("source", "swap"),
importance=record.get("importance", 0.5),
metadata_json=_metadata_json(record),
content_hash=content_hash,
ttl_seconds=record.get("ttl_seconds"),
created_at=record.get("created_at"),
try:
with _open_swap_read(swap_path) as f:
for line in f:
bytes_read += len(line.encode())
line = line.strip()
if not line:
continue
record = json.loads(line)
parsed += 1

identifier = record.get("identifier", "")
fact_text = record.get("fact_text", "")
content_hash = record.get("content_hash") or compute_content_hash(
identifier, fact_text
)

if content_hash in seen_hashes:
skipped += 1
continue
seen_hashes.add(content_hash)

blob = _stored_embedding(record)
if blob is None:
vec = embed_text(fact_text)
blob = pack_embedding(vec)
computed_embeddings += 1
else:
reused_embeddings += 1

records.append(
MemoryRecord(
id=record.get("id", uuid.uuid4().hex),
identifier=identifier,
fact_text=fact_text,
embedding=blob,
embedding_dim=record.get("embedding_dim", EMBEDDING_DIM),
embedding_model=record.get("embedding_model", EMBEDDING_MODEL),
source=record.get("source", "swap"),
importance=record.get("importance", 0.5),
metadata_json=_metadata_json(record),
content_hash=content_hash,
ttl_seconds=record.get("ttl_seconds"),
created_at=record.get("created_at"),
)
)
)
except (EOFError, OSError) as err:
if _swap_format(swap_path) == "jsonl.gz":
raise ValueError(f"malformed compressed swap file: {swap_path}") from err
raise

loaded = db.insert_many_ignore(records)
skipped += len(records) - loaded
Expand Down Expand Up @@ -166,12 +209,12 @@ def snapshot(
*,
include_embeddings: bool = True,
) -> SnapshotResult:
"""Export all memories from the database to a JSONL swap file."""
"""Export all memories from the database to a JSONL or JSONL.GZ swap file."""
swap_path = Path(swap_path)

with Timer() as t:
rows = db.all_rows(include_embedding=include_embeddings)
with open(swap_path, "w") as f:
with _open_swap_write(swap_path) as f:
for row in rows:
embedding = row.pop("embedding", None)
if embedding is not None:
Expand Down
Loading
Loading