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 ADDED
@@ -0,0 +1,3 @@
1
+ """HotMem — A local-first memory sidecar for agent applications."""
2
+
3
+ __version__ = "0.1.0"
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ hotmem = hotmem.cli:main
@@ -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