hotmem 0.1.3__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.
- hotmem/__init__.py +3 -0
- hotmem/cli.py +118 -0
- hotmem/client.py +100 -0
- hotmem/db.py +152 -0
- hotmem/embed.py +69 -0
- hotmem/mount.py +69 -0
- hotmem/py.typed +0 -0
- hotmem/search.py +100 -0
- hotmem/server.py +206 -0
- hotmem/swap.py +120 -0
- hotmem/trace.py +129 -0
- hotmem-0.1.3.dist-info/METADATA +169 -0
- hotmem-0.1.3.dist-info/RECORD +17 -0
- hotmem-0.1.3.dist-info/WHEEL +5 -0
- hotmem-0.1.3.dist-info/entry_points.txt +2 -0
- hotmem-0.1.3.dist-info/licenses/LICENSE +21 -0
- hotmem-0.1.3.dist-info/top_level.txt +1 -0
hotmem/__init__.py
ADDED
hotmem/cli.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""HotMem CLI — command-line interface for the memory sidecar.
|
|
2
|
+
|
|
3
|
+
Purpose:
|
|
4
|
+
Provide serve, hydrate, snapshot, and status commands.
|
|
5
|
+
Entry point: `hotmem` (registered in pyproject.toml).
|
|
6
|
+
|
|
7
|
+
Interface:
|
|
8
|
+
main() — Click group with subcommands
|
|
9
|
+
|
|
10
|
+
Deps: click, uvicorn, hotmem.server, hotmem.mount, hotmem.db, hotmem.swap, hotmem.trace
|
|
11
|
+
Extension: add new subcommands (e.g. `hotmem inspect`, `hotmem gc`) here.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import tempfile
|
|
17
|
+
|
|
18
|
+
import click
|
|
19
|
+
|
|
20
|
+
from hotmem.trace import get_tracer
|
|
21
|
+
|
|
22
|
+
_trace = get_tracer("cli")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@click.group()
|
|
26
|
+
@click.version_option(package_name="hotmem")
|
|
27
|
+
def main():
|
|
28
|
+
"""HotMem — local-first memory sidecar for agent applications."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@main.command()
|
|
32
|
+
@click.option("--port", default=8711, type=int, help="Port to listen on.")
|
|
33
|
+
@click.option("--mount", default=None, type=click.Path(), help="Mount directory path.")
|
|
34
|
+
@click.option("--db", "db_path", default=None, type=click.Path(), help="Explicit database path.")
|
|
35
|
+
@click.option("--host", default="127.0.0.1", help="Host to bind to.")
|
|
36
|
+
def serve(port: int, mount: str | None, db_path: str | None, host: str):
|
|
37
|
+
"""Start the HotMem sidecar server."""
|
|
38
|
+
import uvicorn
|
|
39
|
+
|
|
40
|
+
from hotmem.mount import bootstrap_mount
|
|
41
|
+
from hotmem.server import create_app
|
|
42
|
+
|
|
43
|
+
swap_path = None
|
|
44
|
+
|
|
45
|
+
if mount:
|
|
46
|
+
config = bootstrap_mount(mount)
|
|
47
|
+
db_path = str(config.db_path)
|
|
48
|
+
swap_path = str(config.swap_path)
|
|
49
|
+
elif not db_path:
|
|
50
|
+
db_path = tempfile.mktemp(suffix=".sqlite", prefix="hotmem_")
|
|
51
|
+
_trace.warn(
|
|
52
|
+
"serve", "no mount or db path specified, using temp db",
|
|
53
|
+
detail={"path": db_path},
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
app = create_app(db_path=db_path, swap_path=swap_path, port=port)
|
|
57
|
+
|
|
58
|
+
_trace.info(
|
|
59
|
+
"serve", f"starting server on {host}:{port}",
|
|
60
|
+
detail={"db": db_path, "mount": mount},
|
|
61
|
+
)
|
|
62
|
+
uvicorn.run(app, host=host, port=port, log_level="warning")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@main.command()
|
|
66
|
+
@click.option(
|
|
67
|
+
"--file", "swap_file", default="swap.jsonl",
|
|
68
|
+
type=click.Path(), help="Swap file path.",
|
|
69
|
+
)
|
|
70
|
+
@click.option("--db", "db_path", required=True, type=click.Path(), help="Database path.")
|
|
71
|
+
def hydrate(swap_file: str, db_path: str):
|
|
72
|
+
"""Load a swap file into the database."""
|
|
73
|
+
from hotmem.db import MemoryDB
|
|
74
|
+
from hotmem.swap import hydrate as do_hydrate
|
|
75
|
+
|
|
76
|
+
db = MemoryDB(db_path)
|
|
77
|
+
result = do_hydrate(db, swap_file)
|
|
78
|
+
db.close()
|
|
79
|
+
|
|
80
|
+
click.echo(f"Loaded: {result.loaded}, Skipped dupes: {result.skipped_dupes}")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@main.command()
|
|
84
|
+
@click.option(
|
|
85
|
+
"--file", "swap_file", default="swap.jsonl",
|
|
86
|
+
type=click.Path(), help="Output swap file path.",
|
|
87
|
+
)
|
|
88
|
+
@click.option("--db", "db_path", required=True, type=click.Path(), help="Database path.")
|
|
89
|
+
def snapshot(swap_file: str, db_path: str):
|
|
90
|
+
"""Export database memories to a swap file."""
|
|
91
|
+
from hotmem.db import MemoryDB
|
|
92
|
+
from hotmem.swap import snapshot as do_snapshot
|
|
93
|
+
|
|
94
|
+
db = MemoryDB(db_path)
|
|
95
|
+
result = do_snapshot(db, swap_file)
|
|
96
|
+
db.close()
|
|
97
|
+
|
|
98
|
+
click.echo(f"Exported: {result.exported} → {result.path}")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@main.command()
|
|
102
|
+
@click.option("--port", default=8711, type=int, help="Port to check.")
|
|
103
|
+
@click.option("--host", default="127.0.0.1", help="Host to check.")
|
|
104
|
+
def status(port: int, host: str):
|
|
105
|
+
"""Check if a HotMem server is running."""
|
|
106
|
+
import httpx
|
|
107
|
+
|
|
108
|
+
url = f"http://{host}:{port}/v1/health"
|
|
109
|
+
try:
|
|
110
|
+
resp = httpx.get(url, timeout=3.0)
|
|
111
|
+
data = resp.json()
|
|
112
|
+
click.echo(f"Status: {data['status']}")
|
|
113
|
+
click.echo(f"Memories: {data['memory_count']}")
|
|
114
|
+
click.echo(f"DB: {data['db_path']}")
|
|
115
|
+
click.echo(f"Uptime: {data['uptime_s']}s")
|
|
116
|
+
except httpx.ConnectError as err:
|
|
117
|
+
click.echo(f"No HotMem server found at {url}", err=True)
|
|
118
|
+
raise SystemExit(1) from err
|
hotmem/client.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""HotMem client — Python SDK for the memory sidecar.
|
|
2
|
+
|
|
3
|
+
Purpose:
|
|
4
|
+
Provide a simple, typed client for the HotMem HTTP API.
|
|
5
|
+
Designed for direct use in agent applications and SPA backends.
|
|
6
|
+
|
|
7
|
+
Interface:
|
|
8
|
+
HotMemClient(base_url)
|
|
9
|
+
.add(identifier, fact, ...) -> dict
|
|
10
|
+
.search(query, top_k, max_chars?) -> list[MessageObject]
|
|
11
|
+
.health() -> dict
|
|
12
|
+
.hydrate(file?) -> dict
|
|
13
|
+
.snapshot(file?) -> dict
|
|
14
|
+
|
|
15
|
+
Deps: httpx
|
|
16
|
+
Extension: add async client, retry logic, or connection pooling here.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
import httpx
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class HotMemClient:
|
|
27
|
+
"""Synchronous client for the HotMem sidecar API."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, base_url: str = "http://127.0.0.1:8711") -> None:
|
|
30
|
+
self.base_url = base_url.rstrip("/")
|
|
31
|
+
self._client = httpx.Client(base_url=self.base_url, timeout=30.0)
|
|
32
|
+
|
|
33
|
+
def health(self) -> dict[str, Any]:
|
|
34
|
+
"""Check server health."""
|
|
35
|
+
resp = self._client.get("/v1/health")
|
|
36
|
+
resp.raise_for_status()
|
|
37
|
+
return resp.json()
|
|
38
|
+
|
|
39
|
+
def add(
|
|
40
|
+
self,
|
|
41
|
+
identifier: str,
|
|
42
|
+
fact: str,
|
|
43
|
+
*,
|
|
44
|
+
source: str = "",
|
|
45
|
+
importance: float = 0.5,
|
|
46
|
+
metadata: dict[str, Any] | None = None,
|
|
47
|
+
) -> dict[str, Any]:
|
|
48
|
+
"""Add a fact to memory."""
|
|
49
|
+
payload = {
|
|
50
|
+
"identifier": identifier,
|
|
51
|
+
"fact": fact,
|
|
52
|
+
"source": source,
|
|
53
|
+
"importance": importance,
|
|
54
|
+
"metadata": metadata or {},
|
|
55
|
+
}
|
|
56
|
+
resp = self._client.post("/v1/add", json=payload)
|
|
57
|
+
resp.raise_for_status()
|
|
58
|
+
return resp.json()
|
|
59
|
+
|
|
60
|
+
def search(
|
|
61
|
+
self,
|
|
62
|
+
query: str,
|
|
63
|
+
top_k: int = 5,
|
|
64
|
+
max_chars: int | None = None,
|
|
65
|
+
) -> list[dict[str, Any]]:
|
|
66
|
+
"""Search memories and return LLM-ready message objects."""
|
|
67
|
+
payload: dict[str, Any] = {"query": query, "top_k": top_k}
|
|
68
|
+
if max_chars is not None:
|
|
69
|
+
payload["max_chars"] = max_chars
|
|
70
|
+
resp = self._client.post("/v1/search", json=payload)
|
|
71
|
+
resp.raise_for_status()
|
|
72
|
+
return resp.json()["memories"]
|
|
73
|
+
|
|
74
|
+
def hydrate(self, file: str | None = None) -> dict[str, Any]:
|
|
75
|
+
"""Trigger swap file hydration."""
|
|
76
|
+
payload: dict[str, Any] = {}
|
|
77
|
+
if file:
|
|
78
|
+
payload["file"] = file
|
|
79
|
+
resp = self._client.post("/v1/hydrate", json=payload)
|
|
80
|
+
resp.raise_for_status()
|
|
81
|
+
return resp.json()
|
|
82
|
+
|
|
83
|
+
def snapshot(self, file: str | None = None) -> dict[str, Any]:
|
|
84
|
+
"""Trigger database snapshot to swap file."""
|
|
85
|
+
payload: dict[str, Any] = {}
|
|
86
|
+
if file:
|
|
87
|
+
payload["file"] = file
|
|
88
|
+
resp = self._client.post("/v1/snapshot", json=payload)
|
|
89
|
+
resp.raise_for_status()
|
|
90
|
+
return resp.json()
|
|
91
|
+
|
|
92
|
+
def close(self) -> None:
|
|
93
|
+
"""Close the underlying HTTP client."""
|
|
94
|
+
self._client.close()
|
|
95
|
+
|
|
96
|
+
def __enter__(self) -> HotMemClient:
|
|
97
|
+
return self
|
|
98
|
+
|
|
99
|
+
def __exit__(self, *args: object) -> None:
|
|
100
|
+
self.close()
|
hotmem/db.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""HotMem database — SQLite storage with cosine similarity UDF.
|
|
2
|
+
|
|
3
|
+
Purpose:
|
|
4
|
+
Manage the memories table: create schema, insert, query, count.
|
|
5
|
+
Registers a pure-python cosine similarity function as a SQLite UDF
|
|
6
|
+
so vector search runs inside the DB engine.
|
|
7
|
+
|
|
8
|
+
Interface:
|
|
9
|
+
MemoryDB(db_path: str | Path)
|
|
10
|
+
.insert(id, identifier, fact_text, embedding_blob, ...) -> None
|
|
11
|
+
.search_all() -> list[Row]
|
|
12
|
+
.count() -> int
|
|
13
|
+
.all_rows() -> list[Row]
|
|
14
|
+
.exists(content_hash: str) -> bool
|
|
15
|
+
.close() -> None
|
|
16
|
+
|
|
17
|
+
Deps: hotmem.embed (for unpack_embedding), hotmem.trace
|
|
18
|
+
Extension: add indexes, FTS5, or WAL mode tuning here.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import math
|
|
24
|
+
import sqlite3
|
|
25
|
+
import struct
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from hotmem.embed import EMBEDDING_DIM
|
|
30
|
+
from hotmem.trace import get_tracer
|
|
31
|
+
|
|
32
|
+
_trace = get_tracer("db")
|
|
33
|
+
|
|
34
|
+
_SCHEMA = f"""
|
|
35
|
+
CREATE TABLE IF NOT EXISTS memories (
|
|
36
|
+
id TEXT PRIMARY KEY,
|
|
37
|
+
identifier TEXT NOT NULL,
|
|
38
|
+
fact_text TEXT NOT NULL,
|
|
39
|
+
embedding BLOB,
|
|
40
|
+
embedding_dim INTEGER DEFAULT {EMBEDDING_DIM},
|
|
41
|
+
embedding_model TEXT DEFAULT '',
|
|
42
|
+
source TEXT DEFAULT '',
|
|
43
|
+
importance REAL DEFAULT 0.5,
|
|
44
|
+
metadata_json TEXT DEFAULT '{{}}',
|
|
45
|
+
content_hash TEXT DEFAULT '',
|
|
46
|
+
created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
|
47
|
+
);
|
|
48
|
+
CREATE INDEX IF NOT EXISTS idx_memories_identifier ON memories(identifier);
|
|
49
|
+
CREATE INDEX IF NOT EXISTS idx_memories_content_hash ON memories(content_hash);
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _cosine_similarity(blob_a: bytes | None, blob_b: bytes | None) -> float | None:
|
|
54
|
+
"""SQLite UDF: cosine similarity between two packed float32 blobs."""
|
|
55
|
+
if blob_a is None or blob_b is None:
|
|
56
|
+
return None
|
|
57
|
+
n = len(blob_a) // 4
|
|
58
|
+
a = struct.unpack(f"{n}f", blob_a)
|
|
59
|
+
b = struct.unpack(f"{n}f", blob_b)
|
|
60
|
+
dot = sum(x * y for x, y in zip(a, b, strict=True))
|
|
61
|
+
norm_a = math.sqrt(sum(x * x for x in a))
|
|
62
|
+
norm_b = math.sqrt(sum(x * x for x in b))
|
|
63
|
+
if norm_a == 0 or norm_b == 0:
|
|
64
|
+
return 0.0
|
|
65
|
+
return dot / (norm_a * norm_b)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class MemoryDB:
|
|
69
|
+
"""SQLite-backed memory store with cosine similarity UDF."""
|
|
70
|
+
|
|
71
|
+
def __init__(self, db_path: str | Path) -> None:
|
|
72
|
+
self.db_path = str(db_path)
|
|
73
|
+
self._conn = sqlite3.connect(self.db_path, check_same_thread=False)
|
|
74
|
+
self._conn.row_factory = sqlite3.Row
|
|
75
|
+
self._conn.execute("PRAGMA journal_mode=WAL")
|
|
76
|
+
self._conn.execute("PRAGMA synchronous=NORMAL")
|
|
77
|
+
self._conn.create_function("cosine_sim", 2, _cosine_similarity)
|
|
78
|
+
self._conn.executescript(_SCHEMA)
|
|
79
|
+
_trace.info("init", "database opened", detail={"path": self.db_path})
|
|
80
|
+
|
|
81
|
+
def insert(
|
|
82
|
+
self,
|
|
83
|
+
id: str,
|
|
84
|
+
identifier: str,
|
|
85
|
+
fact_text: str,
|
|
86
|
+
embedding: bytes,
|
|
87
|
+
*,
|
|
88
|
+
embedding_dim: int = EMBEDDING_DIM,
|
|
89
|
+
embedding_model: str = "",
|
|
90
|
+
source: str = "",
|
|
91
|
+
importance: float = 0.5,
|
|
92
|
+
metadata_json: str = "{}",
|
|
93
|
+
content_hash: str = "",
|
|
94
|
+
) -> None:
|
|
95
|
+
"""Insert a memory row."""
|
|
96
|
+
self._conn.execute(
|
|
97
|
+
"""INSERT OR REPLACE INTO memories
|
|
98
|
+
(id, identifier, fact_text, embedding, embedding_dim, embedding_model,
|
|
99
|
+
source, importance, metadata_json, content_hash)
|
|
100
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
101
|
+
(
|
|
102
|
+
id,
|
|
103
|
+
identifier,
|
|
104
|
+
fact_text,
|
|
105
|
+
embedding,
|
|
106
|
+
embedding_dim,
|
|
107
|
+
embedding_model,
|
|
108
|
+
source,
|
|
109
|
+
importance,
|
|
110
|
+
metadata_json,
|
|
111
|
+
content_hash,
|
|
112
|
+
),
|
|
113
|
+
)
|
|
114
|
+
self._conn.commit()
|
|
115
|
+
_trace.debug("insert", f"stored memory {id[:8]}…", detail={"identifier": identifier})
|
|
116
|
+
|
|
117
|
+
def search_with_cosine(self, query_embedding: bytes) -> list[dict[str, Any]]:
|
|
118
|
+
"""Return all memories with their cosine similarity to the query embedding."""
|
|
119
|
+
rows = self._conn.execute(
|
|
120
|
+
"""SELECT id, identifier, fact_text, importance, metadata_json, source,
|
|
121
|
+
cosine_sim(embedding, ?) AS cosine_score
|
|
122
|
+
FROM memories
|
|
123
|
+
ORDER BY cosine_score DESC""",
|
|
124
|
+
(query_embedding,),
|
|
125
|
+
).fetchall()
|
|
126
|
+
return [dict(r) for r in rows]
|
|
127
|
+
|
|
128
|
+
def count(self) -> int:
|
|
129
|
+
"""Return total number of stored memories."""
|
|
130
|
+
row = self._conn.execute("SELECT COUNT(*) FROM memories").fetchone()
|
|
131
|
+
return row[0]
|
|
132
|
+
|
|
133
|
+
def all_rows(self) -> list[dict[str, Any]]:
|
|
134
|
+
"""Return all memory rows as dicts (for snapshot export)."""
|
|
135
|
+
rows = self._conn.execute(
|
|
136
|
+
"""SELECT id, identifier, fact_text, embedding_dim, embedding_model,
|
|
137
|
+
source, importance, metadata_json, content_hash, created_at
|
|
138
|
+
FROM memories"""
|
|
139
|
+
).fetchall()
|
|
140
|
+
return [dict(r) for r in rows]
|
|
141
|
+
|
|
142
|
+
def exists(self, content_hash: str) -> bool:
|
|
143
|
+
"""Check if a memory with this content hash already exists."""
|
|
144
|
+
row = self._conn.execute(
|
|
145
|
+
"SELECT 1 FROM memories WHERE content_hash = ? LIMIT 1", (content_hash,)
|
|
146
|
+
).fetchone()
|
|
147
|
+
return row is not None
|
|
148
|
+
|
|
149
|
+
def close(self) -> None:
|
|
150
|
+
"""Close the database connection."""
|
|
151
|
+
self._conn.close()
|
|
152
|
+
_trace.info("close", "database closed", detail={"path": self.db_path})
|
hotmem/embed.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""HotMem embedding — deterministic hash-based embedder for MVP.
|
|
2
|
+
|
|
3
|
+
Purpose:
|
|
4
|
+
Convert text into a fixed-dimension float vector using deterministic hashing.
|
|
5
|
+
No external model required. Provides real cosine-similarity semantics via
|
|
6
|
+
character n-gram hashing.
|
|
7
|
+
|
|
8
|
+
Interface:
|
|
9
|
+
embed_text(text: str) -> list[float]
|
|
10
|
+
pack_embedding(vec: list[float]) -> bytes
|
|
11
|
+
unpack_embedding(blob: bytes) -> list[float]
|
|
12
|
+
EMBEDDING_DIM: int
|
|
13
|
+
EMBEDDING_MODEL: str
|
|
14
|
+
|
|
15
|
+
Deps: none (stdlib only)
|
|
16
|
+
Extension: replace embed_text() with a real model call (sentence-transformers, OpenAI, etc.).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import hashlib
|
|
22
|
+
import math
|
|
23
|
+
import struct
|
|
24
|
+
|
|
25
|
+
from hotmem.trace import Timer, get_tracer
|
|
26
|
+
|
|
27
|
+
_trace = get_tracer("embed")
|
|
28
|
+
|
|
29
|
+
EMBEDDING_DIM = 64
|
|
30
|
+
EMBEDDING_MODEL = "hotmem-hash-v1"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def embed_text(text: str) -> list[float]:
|
|
34
|
+
"""Produce a deterministic embedding vector from text.
|
|
35
|
+
|
|
36
|
+
Uses overlapping character trigrams hashed into buckets, then L2-normalized.
|
|
37
|
+
Semantically similar strings share trigrams and thus produce closer vectors.
|
|
38
|
+
"""
|
|
39
|
+
with Timer() as t:
|
|
40
|
+
vec = [0.0] * EMBEDDING_DIM
|
|
41
|
+
text_lower = text.lower()
|
|
42
|
+
|
|
43
|
+
# Hash overlapping trigrams into embedding buckets
|
|
44
|
+
for i in range(max(1, len(text_lower) - 2)):
|
|
45
|
+
gram = text_lower[i : i + 3]
|
|
46
|
+
h = int(hashlib.md5(gram.encode(), usedforsecurity=False).hexdigest(), 16)
|
|
47
|
+
bucket = h % EMBEDDING_DIM
|
|
48
|
+
# Use upper bits for sign/magnitude
|
|
49
|
+
sign = 1.0 if (h >> 64) % 2 == 0 else -1.0
|
|
50
|
+
vec[bucket] += sign
|
|
51
|
+
|
|
52
|
+
# L2 normalize
|
|
53
|
+
norm = math.sqrt(sum(x * x for x in vec))
|
|
54
|
+
if norm > 0:
|
|
55
|
+
vec = [x / norm for x in vec]
|
|
56
|
+
|
|
57
|
+
_trace.debug("compute", "embedded text", detail={"chars": len(text), "ms": round(t.ms, 2)})
|
|
58
|
+
return vec
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def pack_embedding(vec: list[float]) -> bytes:
|
|
62
|
+
"""Pack float vector into a compact binary blob (float32 array)."""
|
|
63
|
+
return struct.pack(f"{len(vec)}f", *vec)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def unpack_embedding(blob: bytes) -> list[float]:
|
|
67
|
+
"""Unpack binary blob back into float vector."""
|
|
68
|
+
count = len(blob) // 4
|
|
69
|
+
return list(struct.unpack(f"{count}f", blob))
|
hotmem/mount.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""HotMem mount — portable memory directory management.
|
|
2
|
+
|
|
3
|
+
Purpose:
|
|
4
|
+
Bootstrap and manage a mount directory that contains hotmem.sqlite,
|
|
5
|
+
swap.jsonl, and manifest.json. Any directory can become a HotMem mount.
|
|
6
|
+
|
|
7
|
+
Interface:
|
|
8
|
+
MountConfig(mount_path) — resolves paths within the mount
|
|
9
|
+
bootstrap_mount(mount_path) -> MountConfig — creates dir + manifest if needed
|
|
10
|
+
|
|
11
|
+
Deps: hotmem.trace
|
|
12
|
+
Extension: add manifest versioning, remote mount sync, or encryption here.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from datetime import UTC, datetime
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from hotmem import __version__
|
|
23
|
+
from hotmem.trace import get_tracer
|
|
24
|
+
|
|
25
|
+
_trace = get_tracer("mount")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class MountConfig:
|
|
30
|
+
"""Resolved paths within a HotMem mount directory."""
|
|
31
|
+
|
|
32
|
+
mount_path: Path
|
|
33
|
+
db_path: Path
|
|
34
|
+
swap_path: Path
|
|
35
|
+
manifest_path: Path
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def bootstrap_mount(mount_path: str | Path) -> MountConfig:
|
|
39
|
+
"""Ensure a mount directory exists with the expected structure.
|
|
40
|
+
|
|
41
|
+
Creates the directory and manifest.json if they don't exist.
|
|
42
|
+
Returns resolved paths for db, swap, and manifest files.
|
|
43
|
+
"""
|
|
44
|
+
mount_path = Path(mount_path).resolve()
|
|
45
|
+
mount_path.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
|
|
47
|
+
config = MountConfig(
|
|
48
|
+
mount_path=mount_path,
|
|
49
|
+
db_path=mount_path / "hotmem.sqlite",
|
|
50
|
+
swap_path=mount_path / "swap.jsonl",
|
|
51
|
+
manifest_path=mount_path / "manifest.json",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
if not config.manifest_path.exists():
|
|
55
|
+
manifest = {
|
|
56
|
+
"hotmem_version": __version__,
|
|
57
|
+
"created_at": datetime.now(UTC).isoformat(),
|
|
58
|
+
"mount_path": str(mount_path),
|
|
59
|
+
}
|
|
60
|
+
config.manifest_path.write_text(json.dumps(manifest, indent=2) + "\n")
|
|
61
|
+
_trace.info(
|
|
62
|
+
"bootstrap", "created mount directory", detail={"path": str(mount_path)}
|
|
63
|
+
)
|
|
64
|
+
else:
|
|
65
|
+
_trace.info(
|
|
66
|
+
"bootstrap", "using existing mount", detail={"path": str(mount_path)}
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
return config
|
hotmem/py.typed
ADDED
|
File without changes
|
hotmem/search.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""HotMem search — hybrid ranking with message-shaped output.
|
|
2
|
+
|
|
3
|
+
Purpose:
|
|
4
|
+
Given a query, embed it, retrieve candidates from the DB, apply hybrid scoring
|
|
5
|
+
(cosine + keyword overlap + importance), and return LLM-ready message objects.
|
|
6
|
+
|
|
7
|
+
Interface:
|
|
8
|
+
search_memories(db, query, top_k, max_chars?) -> list[MessageObject]
|
|
9
|
+
|
|
10
|
+
Deps: hotmem.db, hotmem.embed, hotmem.trace
|
|
11
|
+
Extension: add reranking, decay weighting, or MMR diversity here.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from hotmem.db import MemoryDB
|
|
19
|
+
from hotmem.embed import embed_text, pack_embedding
|
|
20
|
+
from hotmem.trace import Timer, get_tracer
|
|
21
|
+
|
|
22
|
+
_trace = get_tracer("search")
|
|
23
|
+
|
|
24
|
+
# Scoring weights
|
|
25
|
+
W_COSINE = 0.6
|
|
26
|
+
W_KEYWORD = 0.2
|
|
27
|
+
W_IMPORTANCE = 0.2
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _keyword_overlap(query: str, text: str) -> float:
|
|
31
|
+
"""Compute Jaccard-like keyword overlap between query and text."""
|
|
32
|
+
q_words = set(query.lower().split())
|
|
33
|
+
t_words = set(text.lower().split())
|
|
34
|
+
if not q_words:
|
|
35
|
+
return 0.0
|
|
36
|
+
overlap = q_words & t_words
|
|
37
|
+
return len(overlap) / len(q_words)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def search_memories(
|
|
41
|
+
db: MemoryDB,
|
|
42
|
+
query: str,
|
|
43
|
+
top_k: int = 5,
|
|
44
|
+
max_chars: int | None = None,
|
|
45
|
+
) -> list[dict[str, Any]]:
|
|
46
|
+
"""Search memories and return ranked, LLM-ready message objects.
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
List of dicts with keys: role, content, memory_id, identifier, score
|
|
50
|
+
"""
|
|
51
|
+
with Timer() as t:
|
|
52
|
+
# Embed the query
|
|
53
|
+
query_vec = embed_text(query)
|
|
54
|
+
query_blob = pack_embedding(query_vec)
|
|
55
|
+
|
|
56
|
+
# Get all candidates with cosine scores from DB
|
|
57
|
+
candidates = db.search_with_cosine(query_blob)
|
|
58
|
+
|
|
59
|
+
# Apply hybrid scoring
|
|
60
|
+
scored = []
|
|
61
|
+
for row in candidates:
|
|
62
|
+
cosine_score = row.get("cosine_score") or 0.0
|
|
63
|
+
keyword_score = _keyword_overlap(query, row["fact_text"])
|
|
64
|
+
importance = row.get("importance", 0.5)
|
|
65
|
+
|
|
66
|
+
final_score = (
|
|
67
|
+
W_COSINE * cosine_score + W_KEYWORD * keyword_score + W_IMPORTANCE * importance
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
scored.append({**row, "final_score": final_score})
|
|
71
|
+
|
|
72
|
+
# Sort by final score descending, take top_k
|
|
73
|
+
scored.sort(key=lambda x: x["final_score"], reverse=True)
|
|
74
|
+
top = scored[:top_k]
|
|
75
|
+
|
|
76
|
+
# Build message objects
|
|
77
|
+
messages = []
|
|
78
|
+
char_budget = max_chars
|
|
79
|
+
for item in top:
|
|
80
|
+
content = item["fact_text"]
|
|
81
|
+
if char_budget is not None:
|
|
82
|
+
if char_budget <= 0:
|
|
83
|
+
break
|
|
84
|
+
content = content[:char_budget]
|
|
85
|
+
char_budget -= len(content)
|
|
86
|
+
|
|
87
|
+
messages.append({
|
|
88
|
+
"role": "system",
|
|
89
|
+
"content": content,
|
|
90
|
+
"memory_id": item["id"],
|
|
91
|
+
"identifier": item["identifier"],
|
|
92
|
+
"score": round(item["final_score"], 4),
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
_trace.info(
|
|
96
|
+
"rank",
|
|
97
|
+
f"searched {len(candidates)} memories, returned {len(messages)}",
|
|
98
|
+
detail={"query_len": len(query), "top_k": top_k, "ms": round(t.ms, 2)},
|
|
99
|
+
)
|
|
100
|
+
return messages
|
hotmem/server.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""HotMem server — FastAPI app with traced endpoints.
|
|
2
|
+
|
|
3
|
+
Purpose:
|
|
4
|
+
HTTP sidecar serving memory operations on a single port.
|
|
5
|
+
5 endpoints under /v1: health, add, search, hydrate, snapshot.
|
|
6
|
+
Every response includes X-HotMem-Trace-Id header and trace_ms timing.
|
|
7
|
+
|
|
8
|
+
Interface:
|
|
9
|
+
create_app(db_path, swap_path?) -> FastAPI
|
|
10
|
+
The app is created with a lifespan that opens the DB and optionally hydrates.
|
|
11
|
+
|
|
12
|
+
Deps: fastapi, hotmem.db, hotmem.embed, hotmem.search, hotmem.swap, hotmem.trace
|
|
13
|
+
Extension: add middleware, CORS, rate limiting, or new endpoint groups here.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import time
|
|
20
|
+
import uuid
|
|
21
|
+
from contextlib import asynccontextmanager
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from fastapi import FastAPI, Request
|
|
26
|
+
from pydantic import BaseModel, Field
|
|
27
|
+
|
|
28
|
+
from hotmem.db import MemoryDB
|
|
29
|
+
from hotmem.embed import EMBEDDING_DIM, EMBEDDING_MODEL, embed_text, pack_embedding
|
|
30
|
+
from hotmem.search import search_memories
|
|
31
|
+
from hotmem.swap import compute_content_hash
|
|
32
|
+
from hotmem.swap import hydrate as swap_hydrate
|
|
33
|
+
from hotmem.swap import snapshot as swap_snapshot
|
|
34
|
+
from hotmem.trace import Timer, get_tracer, new_trace_id
|
|
35
|
+
|
|
36
|
+
_trace = get_tracer("server")
|
|
37
|
+
|
|
38
|
+
# ── App state (set during lifespan) ──────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
_state: dict[str, Any] = {}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ── Request / Response models ────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class AddRequest(BaseModel):
|
|
47
|
+
identifier: str
|
|
48
|
+
fact: str
|
|
49
|
+
source: str = ""
|
|
50
|
+
importance: float = Field(default=0.5, ge=0.0, le=1.0)
|
|
51
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class SearchRequest(BaseModel):
|
|
55
|
+
query: str
|
|
56
|
+
top_k: int = Field(default=5, ge=1, le=100)
|
|
57
|
+
max_chars: int | None = None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class HydrateRequest(BaseModel):
|
|
61
|
+
file: str | None = None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class SnapshotRequest(BaseModel):
|
|
65
|
+
file: str | None = None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# ── Lifespan ─────────────────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@asynccontextmanager
|
|
72
|
+
async def lifespan(app: FastAPI):
|
|
73
|
+
"""Open DB, optionally hydrate from swap, yield, then close."""
|
|
74
|
+
db_path = _state["db_path"]
|
|
75
|
+
swap_path = _state.get("swap_path")
|
|
76
|
+
|
|
77
|
+
db = MemoryDB(db_path)
|
|
78
|
+
_state["db"] = db
|
|
79
|
+
_state["start_time"] = time.time()
|
|
80
|
+
|
|
81
|
+
# Auto-hydrate if swap file exists
|
|
82
|
+
if swap_path and Path(swap_path).exists():
|
|
83
|
+
result = swap_hydrate(db, swap_path)
|
|
84
|
+
_trace.info(
|
|
85
|
+
"startup",
|
|
86
|
+
f"auto-hydrated {result.loaded} memories",
|
|
87
|
+
detail={"swap_path": str(swap_path)},
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
_trace.info(
|
|
91
|
+
"startup", "server ready",
|
|
92
|
+
detail={"db_path": str(db_path), "port": _state.get("port", 8711)},
|
|
93
|
+
)
|
|
94
|
+
yield
|
|
95
|
+
db.close()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# ── App factory ──────────────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def create_app(
|
|
102
|
+
db_path: str | Path,
|
|
103
|
+
swap_path: str | Path | None = None,
|
|
104
|
+
port: int = 8711,
|
|
105
|
+
) -> FastAPI:
|
|
106
|
+
"""Create and configure the FastAPI application."""
|
|
107
|
+
_state["db_path"] = str(db_path)
|
|
108
|
+
_state["swap_path"] = str(swap_path) if swap_path else None
|
|
109
|
+
_state["port"] = port
|
|
110
|
+
|
|
111
|
+
app = FastAPI(
|
|
112
|
+
title="HotMem",
|
|
113
|
+
description="Local-first memory sidecar for agent applications",
|
|
114
|
+
version="0.1.0",
|
|
115
|
+
lifespan=lifespan,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
# ── Trace middleware ─────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
@app.middleware("http")
|
|
121
|
+
async def trace_middleware(request: Request, call_next):
|
|
122
|
+
trace_id = new_trace_id()
|
|
123
|
+
with Timer() as t:
|
|
124
|
+
response = await call_next(request)
|
|
125
|
+
response.headers["X-HotMem-Trace-Id"] = trace_id
|
|
126
|
+
_trace.info(
|
|
127
|
+
"request",
|
|
128
|
+
f"{request.method} {request.url.path}",
|
|
129
|
+
detail={"ms": round(t.ms, 2), "status": response.status_code},
|
|
130
|
+
trace_id=trace_id,
|
|
131
|
+
)
|
|
132
|
+
return response
|
|
133
|
+
|
|
134
|
+
# ── Endpoints ────────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
@app.get("/v1/health")
|
|
137
|
+
async def health():
|
|
138
|
+
db: MemoryDB = _state["db"]
|
|
139
|
+
return {
|
|
140
|
+
"status": "ok",
|
|
141
|
+
"memory_count": db.count(),
|
|
142
|
+
"db_path": _state["db_path"],
|
|
143
|
+
"uptime_s": round(time.time() - _state["start_time"], 1),
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
@app.post("/v1/add")
|
|
147
|
+
async def add_memory(req: AddRequest):
|
|
148
|
+
db: MemoryDB = _state["db"]
|
|
149
|
+
with Timer() as t:
|
|
150
|
+
memory_id = uuid.uuid4().hex
|
|
151
|
+
content_hash = compute_content_hash(req.identifier, req.fact)
|
|
152
|
+
vec = embed_text(req.fact)
|
|
153
|
+
blob = pack_embedding(vec)
|
|
154
|
+
|
|
155
|
+
db.insert(
|
|
156
|
+
id=memory_id,
|
|
157
|
+
identifier=req.identifier,
|
|
158
|
+
fact_text=req.fact,
|
|
159
|
+
embedding=blob,
|
|
160
|
+
embedding_dim=EMBEDDING_DIM,
|
|
161
|
+
embedding_model=EMBEDDING_MODEL,
|
|
162
|
+
source=req.source,
|
|
163
|
+
importance=req.importance,
|
|
164
|
+
metadata_json=json.dumps(req.metadata),
|
|
165
|
+
content_hash=content_hash,
|
|
166
|
+
)
|
|
167
|
+
return {
|
|
168
|
+
"memory_id": memory_id,
|
|
169
|
+
"content_hash": content_hash,
|
|
170
|
+
"trace_ms": round(t.ms, 2),
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
@app.post("/v1/search")
|
|
174
|
+
async def search(req: SearchRequest):
|
|
175
|
+
db: MemoryDB = _state["db"]
|
|
176
|
+
with Timer() as t:
|
|
177
|
+
messages = search_memories(
|
|
178
|
+
db, query=req.query, top_k=req.top_k, max_chars=req.max_chars
|
|
179
|
+
)
|
|
180
|
+
return {
|
|
181
|
+
"memories": messages,
|
|
182
|
+
"count": len(messages),
|
|
183
|
+
"trace_ms": round(t.ms, 2),
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
@app.post("/v1/hydrate")
|
|
187
|
+
async def hydrate(req: HydrateRequest):
|
|
188
|
+
db: MemoryDB = _state["db"]
|
|
189
|
+
swap = req.file or _state.get("swap_path") or "swap.jsonl"
|
|
190
|
+
result = swap_hydrate(db, swap)
|
|
191
|
+
return {
|
|
192
|
+
"loaded": result.loaded,
|
|
193
|
+
"skipped_dupes": result.skipped_dupes,
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
@app.post("/v1/snapshot")
|
|
197
|
+
async def snapshot(req: SnapshotRequest):
|
|
198
|
+
db: MemoryDB = _state["db"]
|
|
199
|
+
swap = req.file or _state.get("swap_path") or "swap.jsonl"
|
|
200
|
+
result = swap_snapshot(db, swap)
|
|
201
|
+
return {
|
|
202
|
+
"exported": result.exported,
|
|
203
|
+
"path": result.path,
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return app
|
hotmem/swap.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""HotMem swap — JSONL hydration and snapshot.
|
|
2
|
+
|
|
3
|
+
Purpose:
|
|
4
|
+
Load memories from a swap file (JSONL) into the database, and export
|
|
5
|
+
the current database state back to a swap file. Deduplicates on content_hash.
|
|
6
|
+
|
|
7
|
+
Interface:
|
|
8
|
+
hydrate(db, swap_path) -> HydrateResult
|
|
9
|
+
snapshot(db, swap_path) -> SnapshotResult
|
|
10
|
+
compute_content_hash(identifier, fact_text) -> str
|
|
11
|
+
|
|
12
|
+
Deps: hotmem.db, hotmem.embed, hotmem.trace
|
|
13
|
+
Extension: add compression, encryption, or remote swap sources here.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import hashlib
|
|
19
|
+
import json
|
|
20
|
+
import uuid
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from hotmem.db import MemoryDB
|
|
25
|
+
from hotmem.embed import EMBEDDING_DIM, EMBEDDING_MODEL, embed_text, pack_embedding
|
|
26
|
+
from hotmem.trace import Timer, get_tracer
|
|
27
|
+
|
|
28
|
+
_trace = get_tracer("swap")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def compute_content_hash(identifier: str, fact_text: str) -> str:
|
|
32
|
+
"""SHA-256 hash of identifier + fact_text for deduplication."""
|
|
33
|
+
return hashlib.sha256(f"{identifier}:{fact_text}".encode()).hexdigest()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class HydrateResult:
|
|
38
|
+
loaded: int
|
|
39
|
+
skipped_dupes: int
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class SnapshotResult:
|
|
44
|
+
exported: int
|
|
45
|
+
path: str
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def hydrate(db: MemoryDB, swap_path: str | Path) -> HydrateResult:
|
|
49
|
+
"""Load memories from a JSONL swap file into the database.
|
|
50
|
+
|
|
51
|
+
Deduplicates by content_hash — skips rows that already exist in the DB.
|
|
52
|
+
"""
|
|
53
|
+
swap_path = Path(swap_path)
|
|
54
|
+
if not swap_path.exists():
|
|
55
|
+
_trace.warn("hydrate", "swap file not found", detail={"path": str(swap_path)})
|
|
56
|
+
return HydrateResult(loaded=0, skipped_dupes=0)
|
|
57
|
+
|
|
58
|
+
with Timer() as t:
|
|
59
|
+
loaded = 0
|
|
60
|
+
skipped = 0
|
|
61
|
+
|
|
62
|
+
with open(swap_path) as f:
|
|
63
|
+
for line in f:
|
|
64
|
+
line = line.strip()
|
|
65
|
+
if not line:
|
|
66
|
+
continue
|
|
67
|
+
record = json.loads(line)
|
|
68
|
+
|
|
69
|
+
identifier = record.get("identifier", "")
|
|
70
|
+
fact_text = record.get("fact_text", "")
|
|
71
|
+
content_hash = record.get(
|
|
72
|
+
"content_hash", compute_content_hash(identifier, fact_text)
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
if db.exists(content_hash):
|
|
76
|
+
skipped += 1
|
|
77
|
+
continue
|
|
78
|
+
|
|
79
|
+
# Compute embedding for the fact
|
|
80
|
+
vec = embed_text(fact_text)
|
|
81
|
+
blob = pack_embedding(vec)
|
|
82
|
+
|
|
83
|
+
db.insert(
|
|
84
|
+
id=record.get("id", uuid.uuid4().hex),
|
|
85
|
+
identifier=identifier,
|
|
86
|
+
fact_text=fact_text,
|
|
87
|
+
embedding=blob,
|
|
88
|
+
embedding_dim=record.get("embedding_dim", EMBEDDING_DIM),
|
|
89
|
+
embedding_model=record.get("embedding_model", EMBEDDING_MODEL),
|
|
90
|
+
source=record.get("source", "swap"),
|
|
91
|
+
importance=record.get("importance", 0.5),
|
|
92
|
+
metadata_json=json.dumps(record.get("metadata", {})),
|
|
93
|
+
content_hash=content_hash,
|
|
94
|
+
)
|
|
95
|
+
loaded += 1
|
|
96
|
+
|
|
97
|
+
_trace.info(
|
|
98
|
+
"hydrate",
|
|
99
|
+
f"hydrated {loaded} memories, skipped {skipped} dupes",
|
|
100
|
+
detail={"path": str(swap_path), "ms": round(t.ms, 2)},
|
|
101
|
+
)
|
|
102
|
+
return HydrateResult(loaded=loaded, skipped_dupes=skipped)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def snapshot(db: MemoryDB, swap_path: str | Path) -> SnapshotResult:
|
|
106
|
+
"""Export all memories from the database to a JSONL swap file."""
|
|
107
|
+
swap_path = Path(swap_path)
|
|
108
|
+
|
|
109
|
+
with Timer() as t:
|
|
110
|
+
rows = db.all_rows()
|
|
111
|
+
with open(swap_path, "w") as f:
|
|
112
|
+
for row in rows:
|
|
113
|
+
f.write(json.dumps(row, default=str) + "\n")
|
|
114
|
+
|
|
115
|
+
_trace.info(
|
|
116
|
+
"snapshot",
|
|
117
|
+
f"exported {len(rows)} memories",
|
|
118
|
+
detail={"path": str(swap_path), "ms": round(t.ms, 2)},
|
|
119
|
+
)
|
|
120
|
+
return SnapshotResult(exported=len(rows), path=str(swap_path))
|
hotmem/trace.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""HotMem tracing — structured, component-tagged logging for agent observability.
|
|
2
|
+
|
|
3
|
+
Purpose:
|
|
4
|
+
Provide structured JSON-line logs to stderr so agents can grep by component
|
|
5
|
+
and correlate operations across the sidecar lifecycle.
|
|
6
|
+
|
|
7
|
+
Interface:
|
|
8
|
+
get_tracer(component: str) -> Tracer
|
|
9
|
+
new_trace_id() -> str
|
|
10
|
+
|
|
11
|
+
Deps: none (stdlib only)
|
|
12
|
+
Extension: swap the formatter or add sinks (file, HTTP) by subclassing Tracer.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import logging
|
|
19
|
+
import sys
|
|
20
|
+
import time
|
|
21
|
+
import uuid
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def new_trace_id() -> str:
|
|
25
|
+
"""Generate a short unique trace ID."""
|
|
26
|
+
return uuid.uuid4().hex[:12]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class _JsonFormatter(logging.Formatter):
|
|
30
|
+
"""Emit each log record as a single JSON line."""
|
|
31
|
+
|
|
32
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
33
|
+
entry = {
|
|
34
|
+
"ts": self.formatTime(record, "%Y-%m-%dT%H:%M:%S"),
|
|
35
|
+
"component": getattr(record, "component", "unknown"),
|
|
36
|
+
"level": record.levelname,
|
|
37
|
+
"op": getattr(record, "op", ""),
|
|
38
|
+
"msg": record.getMessage(),
|
|
39
|
+
}
|
|
40
|
+
detail = getattr(record, "detail", None)
|
|
41
|
+
if detail is not None:
|
|
42
|
+
entry["detail"] = detail
|
|
43
|
+
trace_id = getattr(record, "trace_id", None)
|
|
44
|
+
if trace_id is not None:
|
|
45
|
+
entry["trace_id"] = trace_id
|
|
46
|
+
return json.dumps(entry, default=str)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Tracer:
|
|
50
|
+
"""Component-scoped structured logger.
|
|
51
|
+
|
|
52
|
+
Usage:
|
|
53
|
+
tracer = get_tracer("db")
|
|
54
|
+
tracer.info("init", "database opened", detail={"path": "/tmp/hotmem.sqlite"})
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(self, component: str) -> None:
|
|
58
|
+
self.component = component
|
|
59
|
+
self._logger = logging.getLogger(f"hotmem.{component}")
|
|
60
|
+
if not self._logger.handlers:
|
|
61
|
+
handler = logging.StreamHandler(sys.stderr)
|
|
62
|
+
handler.setFormatter(_JsonFormatter())
|
|
63
|
+
self._logger.addHandler(handler)
|
|
64
|
+
self._logger.setLevel(logging.DEBUG)
|
|
65
|
+
self._logger.propagate = False
|
|
66
|
+
|
|
67
|
+
def _log(
|
|
68
|
+
self,
|
|
69
|
+
level: int,
|
|
70
|
+
op: str,
|
|
71
|
+
msg: str,
|
|
72
|
+
*,
|
|
73
|
+
detail: dict | None = None,
|
|
74
|
+
trace_id: str | None = None,
|
|
75
|
+
) -> None:
|
|
76
|
+
self._logger.log(
|
|
77
|
+
level,
|
|
78
|
+
msg,
|
|
79
|
+
extra={
|
|
80
|
+
"component": self.component,
|
|
81
|
+
"op": op,
|
|
82
|
+
"detail": detail,
|
|
83
|
+
"trace_id": trace_id,
|
|
84
|
+
},
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
def info(
|
|
88
|
+
self, op: str, msg: str, *, detail: dict | None = None, trace_id: str | None = None
|
|
89
|
+
) -> None:
|
|
90
|
+
self._log(logging.INFO, op, msg, detail=detail, trace_id=trace_id)
|
|
91
|
+
|
|
92
|
+
def debug(
|
|
93
|
+
self, op: str, msg: str, *, detail: dict | None = None, trace_id: str | None = None
|
|
94
|
+
) -> None:
|
|
95
|
+
self._log(logging.DEBUG, op, msg, detail=detail, trace_id=trace_id)
|
|
96
|
+
|
|
97
|
+
def error(
|
|
98
|
+
self, op: str, msg: str, *, detail: dict | None = None, trace_id: str | None = None
|
|
99
|
+
) -> None:
|
|
100
|
+
self._log(logging.ERROR, op, msg, detail=detail, trace_id=trace_id)
|
|
101
|
+
|
|
102
|
+
def warn(
|
|
103
|
+
self, op: str, msg: str, *, detail: dict | None = None, trace_id: str | None = None
|
|
104
|
+
) -> None:
|
|
105
|
+
self._log(logging.WARNING, op, msg, detail=detail, trace_id=trace_id)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
_tracers: dict[str, Tracer] = {}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def get_tracer(component: str) -> Tracer:
|
|
112
|
+
"""Get or create a tracer for the given component name."""
|
|
113
|
+
if component not in _tracers:
|
|
114
|
+
_tracers[component] = Tracer(component)
|
|
115
|
+
return _tracers[component]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class Timer:
|
|
119
|
+
"""Context manager that measures elapsed milliseconds."""
|
|
120
|
+
|
|
121
|
+
def __init__(self) -> None:
|
|
122
|
+
self.ms: float = 0.0
|
|
123
|
+
|
|
124
|
+
def __enter__(self) -> Timer:
|
|
125
|
+
self._start = time.perf_counter()
|
|
126
|
+
return self
|
|
127
|
+
|
|
128
|
+
def __exit__(self, *args: object) -> None:
|
|
129
|
+
self.ms = (time.perf_counter() - self._start) * 1000
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hotmem
|
|
3
|
+
Version: 0.1.3
|
|
4
|
+
Summary: A local-first memory sidecar for agent applications
|
|
5
|
+
Author-email: valiantone <zrjohn@yahoo.com>
|
|
6
|
+
Project-URL: Homepage, https://github.com/valiantone/hotmem
|
|
7
|
+
Project-URL: Repository, https://github.com/valiantone/hotmem
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Requires-Python: >=3.11
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Requires-Dist: click<9,>=8.1
|
|
18
|
+
Requires-Dist: fastapi<1,>=0.115
|
|
19
|
+
Requires-Dist: httpx<1,>=0.28
|
|
20
|
+
Requires-Dist: uvicorn<1,>=0.30
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: pytest<10,>=9; extra == "dev"
|
|
23
|
+
Requires-Dist: ruff<1,>=0.15; extra == "dev"
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
# HotMem
|
|
27
|
+
|
|
28
|
+
A local-first memory sidecar for agent applications. One SQLite DB. One port: 8711.
|
|
29
|
+
|
|
30
|
+
HotMem provides fast, queryable working memory with hybrid vector + keyword search. Store facts, retrieve them ranked, and get back LLM-ready message objects you can stitch directly into prompts.
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install hotmem@git+https://github.com/KnowGuard-AI/HotMem.git
|
|
36
|
+
# or
|
|
37
|
+
uv add hotmem@git+https://github.com/KnowGuard-AI/HotMem.git
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Or add to `requirements.txt`:
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
hotmem @ git+https://github.com/KnowGuard-AI/HotMem.git
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Quick Start
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
# Start with a mount directory (portable memory)
|
|
50
|
+
hotmem serve --mount ./hotmem
|
|
51
|
+
|
|
52
|
+
# Or just start (uses temp DB)
|
|
53
|
+
hotmem serve
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## CLI
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
hotmem serve --port 8711 --mount ./data/hotmem
|
|
60
|
+
hotmem serve --db ./my.sqlite
|
|
61
|
+
hotmem hydrate --file swap.jsonl --db ./my.sqlite
|
|
62
|
+
hotmem snapshot --file swap.jsonl --db ./my.sqlite
|
|
63
|
+
hotmem status
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## API
|
|
67
|
+
|
|
68
|
+
All endpoints under `/v1`. Default: `http://127.0.0.1:8711`
|
|
69
|
+
|
|
70
|
+
### `GET /v1/health`
|
|
71
|
+
|
|
72
|
+
```json
|
|
73
|
+
{"status": "ok", "memory_count": 42, "db_path": "...", "uptime_s": 120.5}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### `POST /v1/add`
|
|
77
|
+
|
|
78
|
+
```json
|
|
79
|
+
{"identifier": "vendor_x", "fact": "Invoice total was $5000", "importance": 0.8}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### `POST /v1/search`
|
|
83
|
+
|
|
84
|
+
```json
|
|
85
|
+
{"query": "duplicate invoice risk", "top_k": 5, "max_chars": 1500}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Returns ranked message objects ready for LLM stitching:
|
|
89
|
+
|
|
90
|
+
```json
|
|
91
|
+
{
|
|
92
|
+
"memories": [
|
|
93
|
+
{"role": "system", "content": "...", "memory_id": "...", "identifier": "...", "score": 0.87}
|
|
94
|
+
],
|
|
95
|
+
"count": 5,
|
|
96
|
+
"trace_ms": 2.1
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### `POST /v1/hydrate`
|
|
101
|
+
|
|
102
|
+
```json
|
|
103
|
+
{"file": "swap.jsonl"}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### `POST /v1/snapshot`
|
|
107
|
+
|
|
108
|
+
```json
|
|
109
|
+
{"file": "swap.jsonl"}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Python Client
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
from hotmem.client import HotMemClient
|
|
116
|
+
|
|
117
|
+
with HotMemClient("http://127.0.0.1:8711") as client:
|
|
118
|
+
client.add("vendor_x", "Invoice total $5000", importance=0.8)
|
|
119
|
+
|
|
120
|
+
memories = client.search("duplicate invoice risk", top_k=5, max_chars=1500)
|
|
121
|
+
|
|
122
|
+
# memories are LLM-ready message objects
|
|
123
|
+
messages = memories + [{"role": "user", "content": "Analyze this vendor."}]
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## Mounting
|
|
127
|
+
|
|
128
|
+
Any directory can be a HotMem mount. The mount contains:
|
|
129
|
+
|
|
130
|
+
- `hotmem.sqlite` — the database
|
|
131
|
+
- `swap.jsonl` — portable JSONL backup
|
|
132
|
+
- `manifest.json` — mount metadata
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
hotmem serve --mount /mnt/usb/hotmem # portable memory on USB
|
|
136
|
+
hotmem serve --mount ./data/hotmem # local project memory
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## Development
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
uv sync # install deps
|
|
143
|
+
uv run pytest # run tests
|
|
144
|
+
uv run ruff check src/ tests/ # lint
|
|
145
|
+
uv run ruff format src/ tests/ # format
|
|
146
|
+
uv build # build wheel
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
## Architecture (for agents)
|
|
150
|
+
|
|
151
|
+
Each source module is independently extensible with self-documenting headers:
|
|
152
|
+
|
|
153
|
+
| Module | Purpose | Extension Point |
|
|
154
|
+
|--------|---------|------------------|
|
|
155
|
+
| `trace.py` | Structured JSON logging | Add sinks, formatters |
|
|
156
|
+
| `embed.py` | Hash-based embedder (dim=64) | Swap for real model |
|
|
157
|
+
| `db.py` | SQLite + cosine UDF | Add FTS5, indexes |
|
|
158
|
+
| `search.py` | Hybrid ranking | Add reranking, MMR |
|
|
159
|
+
| `swap.py` | JSONL hydrate/snapshot | Add compression |
|
|
160
|
+
| `mount.py` | Directory bootstrap | Add remote sync |
|
|
161
|
+
| `server.py` | FastAPI endpoints | Add CORS, new routes |
|
|
162
|
+
| `cli.py` | Click CLI | Add subcommands |
|
|
163
|
+
| `client.py` | httpx SDK | Add async client |
|
|
164
|
+
|
|
165
|
+
Every operation emits structured JSON traces to stderr with component tags:
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
hotmem serve --mount ./data 2>&1 | grep '"component": "search"'
|
|
169
|
+
```
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
hotmem/__init__.py,sha256=EmW5IF0PNAG3PdmtYaPkjw9YI__9awrAk-kEukxbtKA,93
|
|
2
|
+
hotmem/cli.py,sha256=PJW7q5IjL3_zf-JQx3ne0I5iHofFEKZYs5Waf1uxhsQ,3750
|
|
3
|
+
hotmem/client.py,sha256=9-fj0DeznjHjkNmkZgfDivc0VoGPJoX5nXrXKYSBjLE,2998
|
|
4
|
+
hotmem/db.py,sha256=Kt1H9ClOZnuNhqtibR7NwyoPn6-Ce33Unqax6l2-SAY,5397
|
|
5
|
+
hotmem/embed.py,sha256=oB0whKhrOIMT7eWggxZkC3c78hzvBXpTZVhbW6wt9Po,2173
|
|
6
|
+
hotmem/mount.py,sha256=JH65_PLSWIC0x9fPpmp6D5o7dt6txtG1ybQHdp2oj4A,2010
|
|
7
|
+
hotmem/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
hotmem/search.py,sha256=xfO7vIQYIsoTHeusLqyVmF8C5saO3nqLuoryUfkDjqU,3036
|
|
9
|
+
hotmem/server.py,sha256=1DIARxg33fZA8IGShc2PaAL9w70UvwH6ES7HxoNpdcs,6835
|
|
10
|
+
hotmem/swap.py,sha256=Yh5Jb4MkhWvonEyA8xzydIIdB9a7Nvbu4G6fNDZTJZE,3811
|
|
11
|
+
hotmem/trace.py,sha256=Pa2hLUirIseKRdAtoLHV2CdMRmRBETlera2wPEC1MmU,3809
|
|
12
|
+
hotmem-0.1.3.dist-info/licenses/LICENSE,sha256=picLsifdD2sT3GzbjjehqkLGX09itju9Xj6znNG1P9w,1067
|
|
13
|
+
hotmem-0.1.3.dist-info/METADATA,sha256=xZ2LSJm61AnTHopuY40TdCoVBcOqFzc03MISaWGIV24,4384
|
|
14
|
+
hotmem-0.1.3.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
15
|
+
hotmem-0.1.3.dist-info/entry_points.txt,sha256=G9jNJ3CmvaaURNyTLroK3SSyr9Ku924bjNBOCDRc9z4,43
|
|
16
|
+
hotmem-0.1.3.dist-info/top_level.txt,sha256=NEoA1mQ9SNCFkQ_Z5axO__Hj4NwTQmF10dA2lAyDhp4,7
|
|
17
|
+
hotmem-0.1.3.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 valiantone
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
hotmem
|