|
| 1 | +# sqlrite-agent — a Python LLM agent with persistent memory in SQLRite |
| 2 | + |
| 3 | +A small CLI chat agent whose entire long-term memory lives in **one |
| 4 | +`.sqlrite` file on disk**. Every turn the agent: |
| 5 | + |
| 6 | +1. Embeds the user's message. |
| 7 | +2. Hybrid-searches its memory: top-k vector KNN (HNSW) over past |
| 8 | + messages and summaries, plus keyword recall over the structured |
| 9 | + `facts` table. |
| 10 | +3. Injects the recalled context into the system prompt. |
| 11 | +4. Sends prompt + recent turns to an LLM and prints the reply. |
| 12 | +5. Writes the new turn back to the same SQLRite database. |
| 13 | + |
| 14 | +Close the process, reopen it days later — your assistant still knows |
| 15 | +your dog's name. No Postgres, no Pinecone, no Redis. One file. |
| 16 | + |
| 17 | +> **Why this example?** Single-file embedded storage is the *right* |
| 18 | +> architecture for a local agent, and SQLRite's HNSW vector index + |
| 19 | +> structured SQL gives you semantic *and* deterministic recall from one |
| 20 | +> store. This is a place where the database genuinely fits the |
| 21 | +> workload, not just a demo. |
| 22 | +
|
| 23 | +## Architecture |
| 24 | + |
| 25 | +```mermaid |
| 26 | +flowchart LR |
| 27 | + User[/"User input"/] --> Embed["Embedder<br/>(hash / OpenAI / local)"] |
| 28 | + Embed --> Recall["Memory.recall()"] |
| 29 | + Recall -->|vector KNN| Msgs[("messages<br/>HNSW(embedding)")] |
| 30 | + Recall -->|vector KNN| Sums[("summaries<br/>HNSW(embedding)")] |
| 31 | + Recall -->|keyword| Facts[("facts<br/>SQL")] |
| 32 | + Recall --> Prompt["Prompt assembly<br/>(system + recent turns)"] |
| 33 | + Prompt --> LLM["LLM<br/>(Anthropic / echo)"] |
| 34 | + LLM --> Reply[/"Assistant reply"/] |
| 35 | + Reply --> Writeback["Memory.log_message()"] |
| 36 | + Writeback --> Msgs |
| 37 | + User -.-> Writeback |
| 38 | +``` |
| 39 | + |
| 40 | +Three tables, one file: |
| 41 | + |
| 42 | +| Table | Purpose | Indexed how | |
| 43 | +|-------------|-------------------------------------------------------------|------------------------------------------| |
| 44 | +| `messages` | Every user / assistant turn, plus its 384-dim embedding. | HNSW on `embedding` | |
| 45 | +| `summaries` | Periodic rollups for old context that's too long to inline. | HNSW on `embedding` | |
| 46 | +| `facts` | Structured `(subject, predicate, object)` triples extracted from user turns. | Plain SQL — keyword recall | |
| 47 | + |
| 48 | +## Install |
| 49 | + |
| 50 | +```bash |
| 51 | +# 1. Clone the rust_sqlite repo (this example ships inside it). |
| 52 | +git clone https://github.com/joaoh82/rust_sqlite |
| 53 | +cd rust_sqlite/examples/python-agent |
| 54 | + |
| 55 | +# 2. Create a virtualenv and install the example. |
| 56 | +python3 -m venv .venv && source .venv/bin/activate |
| 57 | +pip install -e . |
| 58 | + |
| 59 | +# 3. (Optional) Install an LLM provider extra. Without one you get |
| 60 | +# the offline "echo" agent — the recall pipeline still runs. |
| 61 | +pip install 'sqlrite-agent[anthropic]' # default LLM |
| 62 | +# pip install 'sqlrite-agent[openai]' # use OpenAI embeddings too |
| 63 | +# pip install 'sqlrite-agent[local-embeddings]' # 384-dim sentence-transformer |
| 64 | +``` |
| 65 | + |
| 66 | +The `sqlrite` Python wheel comes from PyPI automatically (pinned to the |
| 67 | +0.10.x release that introduced `VECTOR(N)` + HNSW indexes). |
| 68 | + |
| 69 | +## Run |
| 70 | + |
| 71 | +```bash |
| 72 | +# Zero config — runs against a fresh in-memory hash embedder and the |
| 73 | +# offline echo "LLM". You see the recall pipeline work end-to-end |
| 74 | +# without an API key. |
| 75 | +python -m sqlrite_agent |
| 76 | + |
| 77 | +# With Anthropic — set ANTHROPIC_API_KEY and run. |
| 78 | +export ANTHROPIC_API_KEY=sk-ant-... |
| 79 | +python -m sqlrite_agent |
| 80 | + |
| 81 | +# Pick where the DB lives (default: ~/.sqlrite-agent.sqlrite). |
| 82 | +python -m sqlrite_agent --db ./my-agent.sqlrite |
| 83 | + |
| 84 | +# Multiple parallel conversations in one DB. |
| 85 | +python -m sqlrite_agent --conversation work |
| 86 | +python -m sqlrite_agent --conversation personal |
| 87 | + |
| 88 | +# Force a specific embedder. |
| 89 | +python -m sqlrite_agent --embedder local # sentence-transformers |
| 90 | +python -m sqlrite_agent --embedder openai # text-embedding-3-small |
| 91 | +``` |
| 92 | + |
| 93 | +## CLI commands |
| 94 | + |
| 95 | +While the REPL is running, anything starting with `/` is a command: |
| 96 | + |
| 97 | +| Command | What it does | |
| 98 | +|--------------------|----------------------------------------------------------------| |
| 99 | +| `/help` | Show all commands. | |
| 100 | +| `/stats` | Counts of messages, summaries, facts. | |
| 101 | +| `/facts` | List every extracted fact. | |
| 102 | +| `/recent` | Last 10 turns in chronological order. | |
| 103 | +| `/recall <query>` | Show what *would* be recalled for a query, without replying. | |
| 104 | +| `/summarize` | Summarize the last 20 turns into a single `summaries` row. | |
| 105 | +| `/quit` | Exit. `Ctrl-D` also works. | |
| 106 | + |
| 107 | +## 60-second demo script |
| 108 | + |
| 109 | +Run this top-to-bottom to see persistent memory survive a process |
| 110 | +restart. Uses the zero-key default (`hash` embedder + `echo` chat). |
| 111 | + |
| 112 | +```bash |
| 113 | +# Session 1 — drop some facts, then quit. |
| 114 | +$ python -m sqlrite_agent --db agent.sqlrite |
| 115 | +sqlrite-agent 0.1.0 — db=agent.sqlrite, ... |
| 116 | + loaded memory: 0 messages, 0 summaries, 0 facts. |
| 117 | + |
| 118 | +you> My dog's name is Mochi. |
| 119 | +agent> [echo agent ...] |
| 120 | +
|
| 121 | +you> Mochi loves carrots more than treats. |
| 122 | +agent> [echo agent ...] |
| 123 | +
|
| 124 | +you> I live in Lisbon, Portugal. |
| 125 | +agent> [echo agent ...] |
| 126 | +
|
| 127 | +you> /facts |
| 128 | + user.dog.name = Mochi |
| 129 | + user.location = Lisbon, Portugal |
| 130 | +
|
| 131 | +you> /quit |
| 132 | +
|
| 133 | +# Session 2 — fresh process, same DB. |
| 134 | +$ python -m sqlrite_agent --db agent.sqlrite |
| 135 | +sqlrite-agent 0.1.0 — db=agent.sqlrite, ... |
| 136 | + loaded memory: 6 messages, 0 summaries, 2 facts. |
| 137 | +
|
| 138 | +you> What's my dog's name? |
| 139 | + [recalled: 1 facts, 0 summaries, 4 messages] |
| 140 | +agent> [echo agent ... — but the recall block above includes |
| 141 | + user.dog.name = Mochi] |
| 142 | +``` |
| 143 | +
|
| 144 | +With `ANTHROPIC_API_KEY` set, the second turn answers "Mochi" instead |
| 145 | +of the canned echo because the LLM sees the recalled fact in its |
| 146 | +system prompt. |
| 147 | +
|
| 148 | +## Open the DB yourself with the SQLRite REPL |
| 149 | +
|
| 150 | +The memory file is plain SQLRite — open it from anywhere: |
| 151 | +
|
| 152 | +```bash |
| 153 | +$ cargo install sqlrite-engine # or grab a binary from GitHub Releases |
| 154 | +$ sqlrite agent.sqlrite |
| 155 | +SQLRite v0.10.0 |
| 156 | +sqlrite> SELECT role, content FROM messages ORDER BY id LIMIT 5; |
| 157 | +sqlrite> SELECT subject, predicate, object FROM facts; |
| 158 | +sqlrite> SELECT id, content |
| 159 | + ...> FROM messages |
| 160 | + ...> ORDER BY vec_distance_cosine(embedding, (SELECT embedding FROM messages WHERE id = 1)) |
| 161 | + ...> LIMIT 3; |
| 162 | +``` |
| 163 | +
|
| 164 | +This is the demo's whole point: **the agent's memory is just SQL**. |
| 165 | +You can query it, back it up, copy it between machines, or load it |
| 166 | +into the Node / Go / WASM SDK without converting anything. |
| 167 | +
|
| 168 | +## How recall works |
| 169 | +
|
| 170 | +`Memory.recall(query)` runs three searches in parallel and merges the |
| 171 | +results. Pseudocode: |
| 172 | +
|
| 173 | +```python |
| 174 | +embedding = embedder.embed(query) |
| 175 | +keywords = query_keywords(query) # filtered to content words |
| 176 | +
|
| 177 | +vector_messages = SELECT ... FROM messages |
| 178 | + ORDER BY vec_distance_cosine(embedding, ?) |
| 179 | + LIMIT k_messages |
| 180 | +
|
| 181 | +vector_summaries = SELECT ... FROM summaries |
| 182 | + ORDER BY vec_distance_cosine(embedding, ?) |
| 183 | + LIMIT k_summaries |
| 184 | +
|
| 185 | +lexical_messages = SELECT ... FROM messages |
| 186 | + WHERE fts_match(content, ?) |
| 187 | + ORDER BY bm25_score(content, ?) DESC |
| 188 | + LIMIT k_messages -- Phase 8, BM25 over the inverted index |
| 189 | +
|
| 190 | +facts = SELECT * FROM facts |
| 191 | + WHERE subject LIKE ... |
| 192 | + OR predicate LIKE ... |
| 193 | + OR object LIKE ... |
| 194 | + LIMIT k_facts |
| 195 | +``` |
| 196 | +
|
| 197 | +The vector and lexical message lists are merged in Python (dedupe by |
| 198 | +`id`, vector ranking primary) — that's the simplest correct shape for |
| 199 | +hybrid retrieval: vector finds conceptual neighbors even with zero |
| 200 | +lexical overlap, and BM25 surfaces exact-term matches the vector |
| 201 | +embedding might rank too low. See [`docs/fts.md`](../../docs/fts.md) |
| 202 | +for the BM25 surface and [`examples/hybrid-retrieval/`](../hybrid-retrieval/) |
| 203 | +for an example that fuses both into a single `ORDER BY` arithmetic. |
| 204 | + |
| 205 | +## Embedding-provider tradeoffs |
| 206 | + |
| 207 | +| Provider | Dependencies | API key | Real semantic recall | First-run friction | |
| 208 | +|----------------|-----------------------|---------|----------------------|--------------------| |
| 209 | +| `hash` (default) | None — stdlib only | No | Bag-of-words approximation only. Good enough for the demo, mediocre for real RAG. | Zero. | |
| 210 | +| `openai` | `openai` package | `OPENAI_API_KEY` | Excellent. `text-embedding-3-small` constrained to 384 dims. | ~30s install. | |
| 211 | +| `local` | `sentence-transformers` (~500 MB with torch) | No | Excellent. `all-MiniLM-L6-v2`, fully offline. | ~5 min install. | |
| 212 | + |
| 213 | +Swap with `--embedder hash | openai | local`. The dimension is fixed |
| 214 | +at 384 to match `VECTOR(384)` in the schema; if you need a different |
| 215 | +dim, change `DEFAULT_DIM` in `sqlrite_agent/db.py` and start with a |
| 216 | +fresh DB. |
| 217 | + |
| 218 | +## Known simplifications |
| 219 | + |
| 220 | +This is an *example*, not a production agent. Things v1 punts on: |
| 221 | + |
| 222 | +- **Memory eviction.** No automatic rolling-window or summarize-and-evict |
| 223 | + loop yet — run `/summarize` manually when the conversation grows |
| 224 | + unwieldy. |
| 225 | +- **Fact extraction.** Six hand-written regex patterns. A real agent |
| 226 | + would call the LLM to extract facts so it catches phrasings the |
| 227 | + regex misses. Easy upgrade: wrap an LLM call in `facts.extract_facts`. |
| 228 | +- **Single-query hybrid.** The agent merges vector hits and BM25 hits |
| 229 | + in Python. The engine also supports a single SQL query that fuses |
| 230 | + both into one `ORDER BY` arithmetic (`0.5 * bm25_score(...) + 0.5 * |
| 231 | + (1.0 - vec_distance_cosine(...))`) — see [`examples/hybrid-retrieval/`](../hybrid-retrieval/). |
| 232 | + The merge approach handles conceptual queries with no token overlap; |
| 233 | + the single-query approach is tighter when you always want BM25 to |
| 234 | + pre-filter. Pick per workload. |
| 235 | +- **Concurrency.** The agent assumes single-user single-process. The |
| 236 | + engine supports concurrent reads + a single writer via fs2 advisory |
| 237 | + locks; running two `sqlrite-agent` processes against the same DB |
| 238 | + works, but they won't see each other's in-flight writes until commit. |
| 239 | + |
| 240 | +## Development |
| 241 | + |
| 242 | +```bash |
| 243 | +# Tests run fully offline with the hash embedder and echo chat — no |
| 244 | +# API keys, no network. |
| 245 | +pip install -e '.[dev]' |
| 246 | +pytest |
| 247 | +``` |
| 248 | + |
| 249 | +## Layout |
| 250 | + |
| 251 | +``` |
| 252 | +examples/python-agent/ |
| 253 | +├── pyproject.toml # package metadata + pinned sqlrite dep |
| 254 | +├── README.md # this file |
| 255 | +├── sqlrite_agent/ |
| 256 | +│ ├── __init__.py |
| 257 | +│ ├── __main__.py # python -m sqlrite_agent → cli.main() |
| 258 | +│ ├── agent.py # turn loop, prompt assembly, summarization |
| 259 | +│ ├── chat.py # LLM provider abstraction (Anthropic / Echo) |
| 260 | +│ ├── cli.py # interactive REPL + slash commands |
| 261 | +│ ├── db.py # schema, migrations, all SQL |
| 262 | +│ ├── embeddings.py # Embedder abstraction (hash / OpenAI / local) |
| 263 | +│ ├── facts.py # regex-based fact extractor |
| 264 | +│ ├── memory.py # hybrid recall over messages + summaries + facts |
| 265 | +│ └── sqlutil.py # safe SQL-literal inlining |
| 266 | +└── tests/ # offline pytest suite (31 tests) |
| 267 | +``` |
| 268 | + |
| 269 | +The agent binds only to the SQLRite Python SDK's documented public |
| 270 | +surface (`sqlrite.connect`, `Connection`, `Cursor`). It does not reach |
| 271 | +into internals. |
| 272 | +
|
| 273 | +## License |
| 274 | +
|
| 275 | +MIT — same as the rest of the rust_sqlite repo. |
0 commit comments