From d3d5d199a8e1a8168dca74b4c40d491a19528701 Mon Sep 17 00:00:00 2001 From: Joao Henrique Machado Silva Date: Tue, 12 May 2026 18:49:36 +0200 Subject: [PATCH] feat(examples): SQLR-39 Python LLM agent with persistent memory in SQLRite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first SQLR-38 example app. A Python CLI chat agent whose long-term memory is one .sqlrite file. Each turn embeds the user input, runs hybrid recall (vector KNN via HNSW + BM25 via Phase 8 fts_match), assembles a system prompt from the recalled facts/summaries/messages, calls the LLM, and writes the new turn back. Persists across process restarts because the entire memory layer is a regular SQLRite database — the demo's whole point. Package: examples/python-agent/ (Python 3.11+, pinned to sqlrite>=0.10,<0.11). Binds only to the SDK's documented surface (sqlrite.connect, Connection, Cursor); no internals. Layers: - db.py: schema + migrations + SQL (3 tables, HNSW + FTS indexes) - sqlutil.py: safe SQL-literal inlining (the SDK doesn't bind params yet) - embeddings.py: hash / OpenAI / sentence-transformers - facts.py: regex-based (subject, predicate, object) extraction - memory.py: hybrid recall (vector + lexical + facts) with merge - chat.py: Anthropic / Echo (offline fallback so zero-key first run works) - agent.py: turn loop, prompt assembly, manual summarize_window() - cli.py: interactive REPL + /facts /recall /summarize slash commands 31 offline tests; runs end-to-end without an API key. Adds /examples landing page to sqlritedb.com (nav link + sitemap entry). Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/README.md | 19 + examples/python-agent/.gitignore | 12 + examples/python-agent/README.md | 275 ++++++++++++++ examples/python-agent/pyproject.toml | 55 +++ .../python-agent/sqlrite_agent/__init__.py | 4 + .../python-agent/sqlrite_agent/__main__.py | 4 + examples/python-agent/sqlrite_agent/agent.py | 163 ++++++++ examples/python-agent/sqlrite_agent/chat.py | 113 ++++++ examples/python-agent/sqlrite_agent/cli.py | 194 ++++++++++ examples/python-agent/sqlrite_agent/db.py | 349 ++++++++++++++++++ .../python-agent/sqlrite_agent/embeddings.py | 141 +++++++ examples/python-agent/sqlrite_agent/facts.py | 137 +++++++ examples/python-agent/sqlrite_agent/memory.py | 216 +++++++++++ .../python-agent/sqlrite_agent/sqlutil.py | 39 ++ examples/python-agent/tests/conftest.py | 38 ++ examples/python-agent/tests/test_agent.py | 100 +++++ examples/python-agent/tests/test_db.py | 172 +++++++++ examples/python-agent/tests/test_facts.py | 57 +++ examples/python-agent/tests/test_memory.py | 85 +++++ examples/python-agent/tests/test_sqlutil.py | 28 ++ web/src/app/examples/page.tsx | 244 ++++++++++++ web/src/app/sitemap.ts | 1 + web/src/components/nav.tsx | 8 + 23 files changed, 2454 insertions(+) create mode 100644 examples/python-agent/.gitignore create mode 100644 examples/python-agent/README.md create mode 100644 examples/python-agent/pyproject.toml create mode 100644 examples/python-agent/sqlrite_agent/__init__.py create mode 100644 examples/python-agent/sqlrite_agent/__main__.py create mode 100644 examples/python-agent/sqlrite_agent/agent.py create mode 100644 examples/python-agent/sqlrite_agent/chat.py create mode 100644 examples/python-agent/sqlrite_agent/cli.py create mode 100644 examples/python-agent/sqlrite_agent/db.py create mode 100644 examples/python-agent/sqlrite_agent/embeddings.py create mode 100644 examples/python-agent/sqlrite_agent/facts.py create mode 100644 examples/python-agent/sqlrite_agent/memory.py create mode 100644 examples/python-agent/sqlrite_agent/sqlutil.py create mode 100644 examples/python-agent/tests/conftest.py create mode 100644 examples/python-agent/tests/test_agent.py create mode 100644 examples/python-agent/tests/test_db.py create mode 100644 examples/python-agent/tests/test_facts.py create mode 100644 examples/python-agent/tests/test_memory.py create mode 100644 examples/python-agent/tests/test_sqlutil.py create mode 100644 web/src/app/examples/page.tsx diff --git a/examples/README.md b/examples/README.md index 0c8f917..040da24 100644 --- a/examples/README.md +++ b/examples/README.md @@ -16,6 +16,14 @@ Phase 5 lands these incrementally — each sub-phase fills in one language. The See [docs/roadmap.md](../docs/roadmap.md) for what each sub-phase delivers. +## End-to-end example apps + +Beyond the per-SDK quick-start tours above, the [SQLR-38 umbrella](../docs/roadmap.md) tracks longer, opinionated example apps that exercise SQLRite end-to-end in real-world shapes: + +| App | Language / SDK | What it shows | Directory | +|---|---|---|---| +| LLM agent with persistent memory | Python | Vector + lexical recall, fact extraction, summaries — all in one `.sqlrite` file | [`python-agent/`](python-agent/) | + ## Running the Rust quickstart ```bash @@ -63,6 +71,17 @@ python examples/python/hello.py Mirrors the Rust quickstart shape via the DB-API: `sqlrite.connect(":memory:")` → `cursor.execute` → iterate tuples, plus a BEGIN/ROLLBACK block. See [`python/hello.py`](python/hello.py) and [`sdk/python/README.md`](../sdk/python/README.md) for the full API tour. +## Running the Python LLM agent (SQLR-39) + +```bash +cd examples/python-agent +python3 -m venv .venv && source .venv/bin/activate +pip install -e . +python -m sqlrite_agent # works offline; no API key required +``` + +A full CLI chat agent whose long-term memory is one `.sqlrite` file. Embeds each turn, hybrid-searches over past messages and a structured `facts` table on every recall, and survives process restarts. Read [`python-agent/README.md`](python-agent/README.md) for the demo script and architecture diagram. + ## Running the Node.js sample ```bash diff --git a/examples/python-agent/.gitignore b/examples/python-agent/.gitignore new file mode 100644 index 0000000..6e6bf97 --- /dev/null +++ b/examples/python-agent/.gitignore @@ -0,0 +1,12 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +.pytest_cache/ +.coverage +build/ +dist/ + +# Local agent state. +*.sqlrite +*.sqlrite-wal +*.sqlrite-lock diff --git a/examples/python-agent/README.md b/examples/python-agent/README.md new file mode 100644 index 0000000..71aec0f --- /dev/null +++ b/examples/python-agent/README.md @@ -0,0 +1,275 @@ +# sqlrite-agent — a Python LLM agent with persistent memory in SQLRite + +A small CLI chat agent whose entire long-term memory lives in **one +`.sqlrite` file on disk**. Every turn the agent: + +1. Embeds the user's message. +2. Hybrid-searches its memory: top-k vector KNN (HNSW) over past + messages and summaries, plus keyword recall over the structured + `facts` table. +3. Injects the recalled context into the system prompt. +4. Sends prompt + recent turns to an LLM and prints the reply. +5. Writes the new turn back to the same SQLRite database. + +Close the process, reopen it days later — your assistant still knows +your dog's name. No Postgres, no Pinecone, no Redis. One file. + +> **Why this example?** Single-file embedded storage is the *right* +> architecture for a local agent, and SQLRite's HNSW vector index + +> structured SQL gives you semantic *and* deterministic recall from one +> store. This is a place where the database genuinely fits the +> workload, not just a demo. + +## Architecture + +```mermaid +flowchart LR + User[/"User input"/] --> Embed["Embedder
(hash / OpenAI / local)"] + Embed --> Recall["Memory.recall()"] + Recall -->|vector KNN| Msgs[("messages
HNSW(embedding)")] + Recall -->|vector KNN| Sums[("summaries
HNSW(embedding)")] + Recall -->|keyword| Facts[("facts
SQL")] + Recall --> Prompt["Prompt assembly
(system + recent turns)"] + Prompt --> LLM["LLM
(Anthropic / echo)"] + LLM --> Reply[/"Assistant reply"/] + Reply --> Writeback["Memory.log_message()"] + Writeback --> Msgs + User -.-> Writeback +``` + +Three tables, one file: + +| Table | Purpose | Indexed how | +|-------------|-------------------------------------------------------------|------------------------------------------| +| `messages` | Every user / assistant turn, plus its 384-dim embedding. | HNSW on `embedding` | +| `summaries` | Periodic rollups for old context that's too long to inline. | HNSW on `embedding` | +| `facts` | Structured `(subject, predicate, object)` triples extracted from user turns. | Plain SQL — keyword recall | + +## Install + +```bash +# 1. Clone the rust_sqlite repo (this example ships inside it). +git clone https://github.com/joaoh82/rust_sqlite +cd rust_sqlite/examples/python-agent + +# 2. Create a virtualenv and install the example. +python3 -m venv .venv && source .venv/bin/activate +pip install -e . + +# 3. (Optional) Install an LLM provider extra. Without one you get +# the offline "echo" agent — the recall pipeline still runs. +pip install 'sqlrite-agent[anthropic]' # default LLM +# pip install 'sqlrite-agent[openai]' # use OpenAI embeddings too +# pip install 'sqlrite-agent[local-embeddings]' # 384-dim sentence-transformer +``` + +The `sqlrite` Python wheel comes from PyPI automatically (pinned to the +0.10.x release that introduced `VECTOR(N)` + HNSW indexes). + +## Run + +```bash +# Zero config — runs against a fresh in-memory hash embedder and the +# offline echo "LLM". You see the recall pipeline work end-to-end +# without an API key. +python -m sqlrite_agent + +# With Anthropic — set ANTHROPIC_API_KEY and run. +export ANTHROPIC_API_KEY=sk-ant-... +python -m sqlrite_agent + +# Pick where the DB lives (default: ~/.sqlrite-agent.sqlrite). +python -m sqlrite_agent --db ./my-agent.sqlrite + +# Multiple parallel conversations in one DB. +python -m sqlrite_agent --conversation work +python -m sqlrite_agent --conversation personal + +# Force a specific embedder. +python -m sqlrite_agent --embedder local # sentence-transformers +python -m sqlrite_agent --embedder openai # text-embedding-3-small +``` + +## CLI commands + +While the REPL is running, anything starting with `/` is a command: + +| Command | What it does | +|--------------------|----------------------------------------------------------------| +| `/help` | Show all commands. | +| `/stats` | Counts of messages, summaries, facts. | +| `/facts` | List every extracted fact. | +| `/recent` | Last 10 turns in chronological order. | +| `/recall ` | Show what *would* be recalled for a query, without replying. | +| `/summarize` | Summarize the last 20 turns into a single `summaries` row. | +| `/quit` | Exit. `Ctrl-D` also works. | + +## 60-second demo script + +Run this top-to-bottom to see persistent memory survive a process +restart. Uses the zero-key default (`hash` embedder + `echo` chat). + +```bash +# Session 1 — drop some facts, then quit. +$ python -m sqlrite_agent --db agent.sqlrite +sqlrite-agent 0.1.0 — db=agent.sqlrite, ... + loaded memory: 0 messages, 0 summaries, 0 facts. + +you> My dog's name is Mochi. +agent> [echo agent ...] + +you> Mochi loves carrots more than treats. +agent> [echo agent ...] + +you> I live in Lisbon, Portugal. +agent> [echo agent ...] + +you> /facts + user.dog.name = Mochi + user.location = Lisbon, Portugal + +you> /quit + +# Session 2 — fresh process, same DB. +$ python -m sqlrite_agent --db agent.sqlrite +sqlrite-agent 0.1.0 — db=agent.sqlrite, ... + loaded memory: 6 messages, 0 summaries, 2 facts. + +you> What's my dog's name? + [recalled: 1 facts, 0 summaries, 4 messages] +agent> [echo agent ... — but the recall block above includes + user.dog.name = Mochi] +``` + +With `ANTHROPIC_API_KEY` set, the second turn answers "Mochi" instead +of the canned echo because the LLM sees the recalled fact in its +system prompt. + +## Open the DB yourself with the SQLRite REPL + +The memory file is plain SQLRite — open it from anywhere: + +```bash +$ cargo install sqlrite-engine # or grab a binary from GitHub Releases +$ sqlrite agent.sqlrite +SQLRite v0.10.0 +sqlrite> SELECT role, content FROM messages ORDER BY id LIMIT 5; +sqlrite> SELECT subject, predicate, object FROM facts; +sqlrite> SELECT id, content + ...> FROM messages + ...> ORDER BY vec_distance_cosine(embedding, (SELECT embedding FROM messages WHERE id = 1)) + ...> LIMIT 3; +``` + +This is the demo's whole point: **the agent's memory is just SQL**. +You can query it, back it up, copy it between machines, or load it +into the Node / Go / WASM SDK without converting anything. + +## How recall works + +`Memory.recall(query)` runs three searches in parallel and merges the +results. Pseudocode: + +```python +embedding = embedder.embed(query) +keywords = query_keywords(query) # filtered to content words + +vector_messages = SELECT ... FROM messages + ORDER BY vec_distance_cosine(embedding, ?) + LIMIT k_messages + +vector_summaries = SELECT ... FROM summaries + ORDER BY vec_distance_cosine(embedding, ?) + LIMIT k_summaries + +lexical_messages = SELECT ... FROM messages + WHERE fts_match(content, ?) + ORDER BY bm25_score(content, ?) DESC + LIMIT k_messages -- Phase 8, BM25 over the inverted index + +facts = SELECT * FROM facts + WHERE subject LIKE ... + OR predicate LIKE ... + OR object LIKE ... + LIMIT k_facts +``` + +The vector and lexical message lists are merged in Python (dedupe by +`id`, vector ranking primary) — that's the simplest correct shape for +hybrid retrieval: vector finds conceptual neighbors even with zero +lexical overlap, and BM25 surfaces exact-term matches the vector +embedding might rank too low. See [`docs/fts.md`](../../docs/fts.md) +for the BM25 surface and [`examples/hybrid-retrieval/`](../hybrid-retrieval/) +for an example that fuses both into a single `ORDER BY` arithmetic. + +## Embedding-provider tradeoffs + +| Provider | Dependencies | API key | Real semantic recall | First-run friction | +|----------------|-----------------------|---------|----------------------|--------------------| +| `hash` (default) | None — stdlib only | No | Bag-of-words approximation only. Good enough for the demo, mediocre for real RAG. | Zero. | +| `openai` | `openai` package | `OPENAI_API_KEY` | Excellent. `text-embedding-3-small` constrained to 384 dims. | ~30s install. | +| `local` | `sentence-transformers` (~500 MB with torch) | No | Excellent. `all-MiniLM-L6-v2`, fully offline. | ~5 min install. | + +Swap with `--embedder hash | openai | local`. The dimension is fixed +at 384 to match `VECTOR(384)` in the schema; if you need a different +dim, change `DEFAULT_DIM` in `sqlrite_agent/db.py` and start with a +fresh DB. + +## Known simplifications + +This is an *example*, not a production agent. Things v1 punts on: + +- **Memory eviction.** No automatic rolling-window or summarize-and-evict + loop yet — run `/summarize` manually when the conversation grows + unwieldy. +- **Fact extraction.** Six hand-written regex patterns. A real agent + would call the LLM to extract facts so it catches phrasings the + regex misses. Easy upgrade: wrap an LLM call in `facts.extract_facts`. +- **Single-query hybrid.** The agent merges vector hits and BM25 hits + in Python. The engine also supports a single SQL query that fuses + both into one `ORDER BY` arithmetic (`0.5 * bm25_score(...) + 0.5 * + (1.0 - vec_distance_cosine(...))`) — see [`examples/hybrid-retrieval/`](../hybrid-retrieval/). + The merge approach handles conceptual queries with no token overlap; + the single-query approach is tighter when you always want BM25 to + pre-filter. Pick per workload. +- **Concurrency.** The agent assumes single-user single-process. The + engine supports concurrent reads + a single writer via fs2 advisory + locks; running two `sqlrite-agent` processes against the same DB + works, but they won't see each other's in-flight writes until commit. + +## Development + +```bash +# Tests run fully offline with the hash embedder and echo chat — no +# API keys, no network. +pip install -e '.[dev]' +pytest +``` + +## Layout + +``` +examples/python-agent/ +├── pyproject.toml # package metadata + pinned sqlrite dep +├── README.md # this file +├── sqlrite_agent/ +│ ├── __init__.py +│ ├── __main__.py # python -m sqlrite_agent → cli.main() +│ ├── agent.py # turn loop, prompt assembly, summarization +│ ├── chat.py # LLM provider abstraction (Anthropic / Echo) +│ ├── cli.py # interactive REPL + slash commands +│ ├── db.py # schema, migrations, all SQL +│ ├── embeddings.py # Embedder abstraction (hash / OpenAI / local) +│ ├── facts.py # regex-based fact extractor +│ ├── memory.py # hybrid recall over messages + summaries + facts +│ └── sqlutil.py # safe SQL-literal inlining +└── tests/ # offline pytest suite (31 tests) +``` + +The agent binds only to the SQLRite Python SDK's documented public +surface (`sqlrite.connect`, `Connection`, `Cursor`). It does not reach +into internals. + +## License + +MIT — same as the rest of the rust_sqlite repo. diff --git a/examples/python-agent/pyproject.toml b/examples/python-agent/pyproject.toml new file mode 100644 index 0000000..0c9fa1e --- /dev/null +++ b/examples/python-agent/pyproject.toml @@ -0,0 +1,55 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "sqlrite-agent" +version = "0.1.0" +description = "Example: a Python CLI chat agent with persistent long-term memory backed entirely by SQLRite (vector + lexical recall, periodic summarization, structured fact extraction)." +readme = "README.md" +requires-python = ">=3.11" +license = { text = "MIT" } +authors = [{ name = "SQLRite contributors" }] +keywords = ["sqlrite", "llm", "agent", "rag", "memory", "vector-search"] +classifiers = [ + "Development Status :: 4 - Beta", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "License :: OSI Approved :: MIT License", + "Topic :: Database", + "Topic :: Scientific/Engineering :: Artificial Intelligence", +] + +# The agent binds only to the public SQLRite Python SDK surface +# (`sqlrite.connect`, `Connection`, `Cursor`). Pinned to the 0.10.x +# release that introduced VECTOR + HNSW + the `ask` feature. +dependencies = [ + "sqlrite>=0.10.0,<0.11.0", +] + +[project.optional-dependencies] +# Cloud LLM + embedding providers — install whichever you want. +anthropic = ["anthropic>=0.40"] +openai = ["openai>=1.40"] +# Local embedding model — heavy install (~500 MB with torch), but +# means no API key is needed for the embedding half of recall. +local-embeddings = ["sentence-transformers>=2.7"] +# Everything at once for a full demo. +all = ["anthropic>=0.40", "openai>=1.40", "sentence-transformers>=2.7"] +dev = ["pytest>=7", "pytest-cov"] + +[project.scripts] +sqlrite-agent = "sqlrite_agent.cli:main" + +[project.urls] +Homepage = "https://sqlritedb.com" +Repository = "https://github.com/joaoh82/rust_sqlite" +"Bug Tracker" = "https://github.com/joaoh82/rust_sqlite/issues" + +[tool.setuptools.packages.find] +where = ["."] +include = ["sqlrite_agent*"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-ra --tb=short" diff --git a/examples/python-agent/sqlrite_agent/__init__.py b/examples/python-agent/sqlrite_agent/__init__.py new file mode 100644 index 0000000..f1c2d86 --- /dev/null +++ b/examples/python-agent/sqlrite_agent/__init__.py @@ -0,0 +1,4 @@ +"""sqlrite_agent — an example LLM chat agent with persistent memory in SQLRite.""" + +__version__ = "0.1.0" +__all__ = ["__version__"] diff --git a/examples/python-agent/sqlrite_agent/__main__.py b/examples/python-agent/sqlrite_agent/__main__.py new file mode 100644 index 0000000..4e09525 --- /dev/null +++ b/examples/python-agent/sqlrite_agent/__main__.py @@ -0,0 +1,4 @@ +from sqlrite_agent.cli import main + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/python-agent/sqlrite_agent/agent.py b/examples/python-agent/sqlrite_agent/agent.py new file mode 100644 index 0000000..1bf5a24 --- /dev/null +++ b/examples/python-agent/sqlrite_agent/agent.py @@ -0,0 +1,163 @@ +"""Top-level orchestrator: assemble prompts, call the LLM, persist turns.""" + +from __future__ import annotations + +import time +from dataclasses import dataclass +from typing import Optional + +from sqlrite_agent.chat import ChatProvider +from sqlrite_agent.memory import Memory, Recall +from sqlrite_agent.db import Fact, Message, Summary + +SYSTEM_RULES = """You are a helpful assistant with persistent memory. \ +Every turn you receive a "Memory" block recalled from a SQLRite database \ +of past conversations. Treat the structured facts in that block as \ +authoritative; treat summaries and recalled messages as context to \ +inform your reply. When the user asks a question that the memory can \ +answer, use the memory — do not say "I don't remember" if the answer \ +is right there. Reply concisely.""" + + +@dataclass(frozen=True) +class Turn: + """One round-trip: user message in, assistant reply out, plus recall.""" + + user_message: str + assistant_reply: str + recall: Recall + + +class ChatAgent: + def __init__( + self, + *, + memory: Memory, + chat: ChatProvider, + conversation_id: str = "default", + recent_window: int = 6, + ) -> None: + self.memory = memory + self.chat = chat + self.conversation_id = conversation_id + self.recent_window = recent_window + + # ------------------------------------------------------------------ + # Turn loop + + def turn(self, user_input: str) -> Turn: + recall = self.memory.recall(user_input, conversation_id=self.conversation_id) + recent = self.memory.recent( + conversation_id=self.conversation_id, limit=self.recent_window + ) + + system = self._assemble_system(recall) + messages = self._assemble_messages(recent, user_input) + reply = self.chat.complete(system=system, messages=messages) + + self.memory.log_message( + conversation_id=self.conversation_id, + role="user", + content=user_input, + ) + self.memory.log_message( + conversation_id=self.conversation_id, + role="assistant", + content=reply, + extract_user_facts=False, + ) + + return Turn(user_message=user_input, assistant_reply=reply, recall=recall) + + # ------------------------------------------------------------------ + # Summarization (manual; the README documents this as a known + # simplification — automatic eviction would belong in v2). + + def summarize_window( + self, *, last_n: int = 20, summarizer: Optional[ChatProvider] = None + ) -> Optional[str]: + """Summarize the most recent ``last_n`` turns and write to ``summaries``. + + Uses ``self.chat`` to do the summarization unless ``summarizer`` + is passed in (handy for tests). Returns the summary text, or + ``None`` if there's nothing to summarize. + """ + recent = self.memory.recent( + conversation_id=self.conversation_id, limit=last_n + ) + if not recent: + return None + + chat = summarizer or self.chat + transcript = "\n".join(f"{m.role}: {m.content}" for m in recent) + prompt_messages = [ + { + "role": "user", + "content": ( + "Summarize the following conversation in 3-5 sentences. " + "Preserve concrete facts (names, places, preferences, dates). " + "Write in third person ('the user', 'the assistant').\n\n" + f"{transcript}" + ), + } + ] + summary = chat.complete( + system="You are a precise note-taker.", + messages=prompt_messages, + ) + if not summary.strip(): + return None + + self.memory.log_summary( + conversation_id=self.conversation_id, + start_ts=recent[0].ts, + end_ts=recent[-1].ts, + content=summary, + ) + return summary + + # ------------------------------------------------------------------ + # Internals + + def _assemble_system(self, recall: Recall) -> str: + sections: list[str] = [SYSTEM_RULES.strip(), ""] + + if recall.facts: + sections.append("# Known facts (from past conversations)") + for f in recall.facts: + sections.append(f"- {f.subject}.{f.predicate} = {f.object}") + sections.append("") + + if recall.summaries: + sections.append("# Summaries of older context") + for s in recall.summaries: + ts = _fmt_ts(s.end_ts) + sections.append(f"- ({ts}) {s.content}") + sections.append("") + + if recall.messages: + sections.append("# Relevant past messages") + for m in recall.messages: + ts = _fmt_ts(m.ts) + preview = m.content.strip().replace("\n", " ") + if len(preview) > 280: + preview = preview[:277] + "..." + sections.append(f"- ({ts}) {m.role}: {preview}") + sections.append("") + + return "\n".join(sections).strip() + + def _assemble_messages( + self, recent: list[Message], current_user_input: str + ) -> list[dict[str, str]]: + out: list[dict[str, str]] = [] + for m in recent: + if m.role not in ("user", "assistant"): + continue + out.append({"role": m.role, "content": m.content}) + out.append({"role": "user", "content": current_user_input}) + return out + + +def _fmt_ts(ts: int) -> str: + return time.strftime("%Y-%m-%d %H:%M", time.localtime(ts)) diff --git a/examples/python-agent/sqlrite_agent/chat.py b/examples/python-agent/sqlrite_agent/chat.py new file mode 100644 index 0000000..25aa1d7 --- /dev/null +++ b/examples/python-agent/sqlrite_agent/chat.py @@ -0,0 +1,113 @@ +"""Chat (LLM) providers. + +Two implementations: + +* :class:`AnthropicChat` — the default. Reads ``ANTHROPIC_API_KEY`` + from the environment. +* :class:`EchoChat` — deterministic offline fake. Echoes recalled + context back; used by the test suite and as the fallback when no + API key is configured so ``python -m sqlrite_agent`` runs end-to-end + on a fresh machine without surprises. + +Both implement :class:`ChatProvider`. +""" + +from __future__ import annotations + +import os +from typing import Protocol + + +class ChatProvider(Protocol): + """Single-shot completion given a system prompt + a message list.""" + + def complete(self, *, system: str, messages: list[dict[str, str]]) -> str: ... + + +# --------------------------------------------------------------------------- +# Anthropic — default provider. + + +class AnthropicChat: + """Claude via the ``anthropic`` SDK.""" + + def __init__( + self, + *, + api_key: str | None = None, + model: str = "claude-haiku-4-5", + max_tokens: int = 512, + ) -> None: + try: + from anthropic import Anthropic # type: ignore[import-not-found] + except ImportError as e: # pragma: no cover - import guard + raise RuntimeError( + "install the 'anthropic' extra to use AnthropicChat: " + "`pip install 'sqlrite-agent[anthropic]'`" + ) from e + + self.model = model + self.max_tokens = max_tokens + self._client = Anthropic(api_key=api_key or os.environ.get("ANTHROPIC_API_KEY")) + + def complete(self, *, system: str, messages: list[dict[str, str]]) -> str: + resp = self._client.messages.create( + model=self.model, + max_tokens=self.max_tokens, + system=system, + messages=messages, + ) + out: list[str] = [] + for block in resp.content: + text = getattr(block, "text", None) + if text: + out.append(text) + return "".join(out).strip() + + +# --------------------------------------------------------------------------- +# Echo — deterministic, offline. + + +class EchoChat: + """A stand-in for an LLM that returns the system prompt + last turn. + + Useful for two things: + + 1. Tests — completion output is deterministic. + 2. Zero-key first-run — users without an API key can still see the + recall pipeline work end to end. The "agent" replies are obviously + canned, but the prompt assembly is real. + """ + + def complete(self, *, system: str, messages: list[dict[str, str]]) -> str: + last_user = next( + (m["content"] for m in reversed(messages) if m.get("role") == "user"), + "", + ) + return ( + "[echo agent — no LLM configured; set ANTHROPIC_API_KEY for real replies]\n" + f"I heard: {last_user!r}\n" + "(The system prompt recalled context above this line — that's the part " + "this example is showing off. The reply itself is canned.)" + ) + + +# --------------------------------------------------------------------------- +# Factory + + +def build_chat(name: str | None) -> ChatProvider: + """Pick a provider from ``name``. + + Names: ``anthropic``, ``echo``, or ``auto`` (default). ``auto`` + picks Anthropic if ``ANTHROPIC_API_KEY`` is set, otherwise Echo. + """ + if not name or name == "auto": + name = "anthropic" if os.environ.get("ANTHROPIC_API_KEY") else "echo" + name = name.lower() + if name == "anthropic": + return AnthropicChat() + if name == "echo": + return EchoChat() + raise ValueError(f"unknown chat provider: {name!r}") diff --git a/examples/python-agent/sqlrite_agent/cli.py b/examples/python-agent/sqlrite_agent/cli.py new file mode 100644 index 0000000..3fd5506 --- /dev/null +++ b/examples/python-agent/sqlrite_agent/cli.py @@ -0,0 +1,194 @@ +"""Interactive CLI: ``python -m sqlrite_agent`` or ``sqlrite-agent``.""" + +from __future__ import annotations + +import argparse +import os +import sys +from typing import Optional + +from sqlrite_agent import __version__ +from sqlrite_agent.agent import ChatAgent +from sqlrite_agent.chat import build_chat +from sqlrite_agent.db import DEFAULT_DIM, AgentDB +from sqlrite_agent.embeddings import build_embedder +from sqlrite_agent.memory import Memory + +DEFAULT_DB_PATH = os.path.expanduser("~/.sqlrite-agent.sqlrite") + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="sqlrite-agent", + description=( + "A Python CLI chat agent with long-term memory backed by SQLRite. " + "Vector recall over past turns + summaries, plus structured fact " + "extraction. The entire memory layer is one file on disk." + ), + ) + parser.add_argument( + "--db", + default=DEFAULT_DB_PATH, + help=f"path to the SQLRite memory file (default: {DEFAULT_DB_PATH})", + ) + parser.add_argument( + "--conversation", + default="default", + help="conversation id (lets one DB host multiple parallel threads)", + ) + parser.add_argument( + "--embedder", + choices=["hash", "openai", "local"], + default="hash", + help=( + "embedding provider. 'hash' is the zero-dep default; swap to " + "'openai' or 'local' (sentence-transformers) for real semantic recall." + ), + ) + parser.add_argument( + "--chat", + choices=["auto", "anthropic", "echo"], + default="auto", + help=( + "LLM provider. 'auto' picks anthropic if ANTHROPIC_API_KEY is set, " + "else 'echo' (offline, canned replies)." + ), + ) + parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}") + return parser + + +def main(argv: Optional[list[str]] = None) -> int: + args = build_parser().parse_args(argv) + + embedder = build_embedder(args.embedder, dim=DEFAULT_DIM) + db = AgentDB(args.db, dim=embedder.dim) + memory = Memory(db, embedder) + chat = build_chat(args.chat) + agent = ChatAgent(memory=memory, chat=chat, conversation_id=args.conversation) + + _print_banner(args, chat.__class__.__name__, embedder.__class__.__name__, memory) + + try: + return _repl(agent, memory) + finally: + db.close() + + +def _repl(agent: ChatAgent, memory: Memory) -> int: + while True: + try: + user_input = input("you> ").strip() + except (EOFError, KeyboardInterrupt): + print() + return 0 + if not user_input: + continue + + if user_input.startswith("/"): + if _handle_slash(user_input, agent, memory): + continue + return 0 + + turn = agent.turn(user_input) + if turn.recall.facts or turn.recall.messages or turn.recall.summaries: + print( + f" [recalled: {len(turn.recall.facts)} facts, " + f"{len(turn.recall.summaries)} summaries, " + f"{len(turn.recall.messages)} messages]" + ) + print(f"agent> {turn.assistant_reply}\n") + + +def _handle_slash(cmd: str, agent: ChatAgent, memory: Memory) -> bool: + """Returns True to keep the REPL running, False to exit.""" + parts = cmd[1:].split(maxsplit=1) + name = parts[0].lower() if parts else "" + arg = parts[1] if len(parts) > 1 else "" + + if name in ("quit", "exit", "q"): + return False + + if name == "help": + _print_help() + return True + + if name == "stats": + s = memory.stats() + print( + f" messages={s['messages']}, summaries={s['summaries']}, " + f"facts={s['facts']}" + ) + return True + + if name == "facts": + rows = memory.all_facts(limit=50) + if not rows: + print(" (no facts extracted yet)") + for f in rows: + print(f" {f.subject}.{f.predicate} = {f.object}") + return True + + if name == "recent": + rows = memory.recent(conversation_id=agent.conversation_id, limit=10) + for m in rows: + preview = m.content.replace("\n", " ") + if len(preview) > 100: + preview = preview[:97] + "..." + print(f" [{m.id}] {m.role}: {preview}") + return True + + if name == "recall": + if not arg: + print(" usage: /recall ") + return True + r = memory.recall(arg, conversation_id=agent.conversation_id) + print(f" facts: {len(r.facts)}, summaries: {len(r.summaries)}, messages: {len(r.messages)}") + for f in r.facts[:5]: + print(f" fact: {f.subject}.{f.predicate} = {f.object}") + for m in r.messages[:5]: + preview = m.content.replace("\n", " ") + if len(preview) > 100: + preview = preview[:97] + "..." + print(f" msg [{m.id}] {m.role}: {preview}") + return True + + if name == "summarize": + summary = agent.summarize_window() + if summary: + print(f" summary written:\n {summary}") + else: + print(" (nothing to summarize)") + return True + + print(f" unknown command: /{name}. Try /help.") + return True + + +def _print_banner(args, chat_cls: str, emb_cls: str, memory: Memory) -> None: + s = memory.stats() + print( + f"sqlrite-agent {__version__} — db={args.db}, " + f"conversation={args.conversation}, embedder={emb_cls}, chat={chat_cls}" + ) + print( + f" loaded memory: {s['messages']} messages, " + f"{s['summaries']} summaries, {s['facts']} facts. " + "Type /help for commands, Ctrl-D to quit." + ) + + +def _print_help() -> None: + print( + """ /help this message + /stats counts of messages, summaries, facts + /facts list all extracted facts + /recent last 10 turns (chronological) + /recall show what would be recalled for a query, without replying + /summarize summarize the last 20 turns and store the summary + /quit exit (Ctrl-D also works)""" + ) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/examples/python-agent/sqlrite_agent/db.py b/examples/python-agent/sqlrite_agent/db.py new file mode 100644 index 0000000..866fa60 --- /dev/null +++ b/examples/python-agent/sqlrite_agent/db.py @@ -0,0 +1,349 @@ +"""SQLRite-backed storage for the chat agent. + +Owns: schema migrations, embedding dimension, all SQL — every other module +calls into ``Memory`` (in ``memory.py``) rather than touching SQL directly. +""" + +from __future__ import annotations + +import time +from contextlib import contextmanager +from dataclasses import dataclass +from typing import Iterator, Optional + +import sqlrite + +from sqlrite_agent.sqlutil import q + +# Vector dimension — fixed at agent boot. Must match whatever your +# embedder produces. 384 is a common sentence-transformer default and +# what the SQLR-39 ticket sketched out. +DEFAULT_DIM = 384 +SCHEMA_VERSION = 1 + + +@dataclass(frozen=True) +class Message: + id: int + conversation_id: str + role: str + content: str + ts: int + + +@dataclass(frozen=True) +class Summary: + id: int + conversation_id: str + start_ts: int + end_ts: int + content: str + + +@dataclass(frozen=True) +class Fact: + id: int + subject: str + predicate: str + object: str + source_message_id: Optional[int] + ts: int + + +class AgentDB: + """Thin wrapper around a SQLRite ``Connection`` with the agent's schema.""" + + def __init__(self, path: str, *, dim: int = DEFAULT_DIM) -> None: + self.path = path + self.dim = dim + self._conn = sqlrite.connect(path) + self._migrate() + + # ------------------------------------------------------------------ + # Migrations + + def _migrate(self) -> None: + cur = self._conn.cursor() + # The SQLRite engine's `CREATE TABLE IF NOT EXISTS` currently + # still errors when the table exists; detect a pre-existing + # schema by trying to read the version table directly. + try: + cur.execute("SELECT version FROM schema_version") + row = cur.fetchone() + current = int(row[0]) if row else 0 + except sqlrite.SQLRiteError: + cur.execute("CREATE TABLE schema_version (version INTEGER PRIMARY KEY)") + current = 0 + + if current < 1: + self._apply_v1() + cur.execute(f"INSERT INTO schema_version (version) VALUES ({SCHEMA_VERSION})") + + def _apply_v1(self) -> None: + cur = self._conn.cursor() + dim = self.dim + cur.execute( + f""" + CREATE TABLE messages ( + id INTEGER PRIMARY KEY, + conversation_id TEXT NOT NULL, + role TEXT NOT NULL, + content TEXT NOT NULL, + ts INTEGER NOT NULL, + embedding VECTOR({dim}) + ) + """ + ) + cur.execute( + f""" + CREATE TABLE summaries ( + id INTEGER PRIMARY KEY, + conversation_id TEXT NOT NULL, + start_ts INTEGER NOT NULL, + end_ts INTEGER NOT NULL, + content TEXT NOT NULL, + embedding VECTOR({dim}) + ) + """ + ) + cur.execute( + """ + CREATE TABLE facts ( + id INTEGER PRIMARY KEY, + subject TEXT NOT NULL, + predicate TEXT NOT NULL, + object TEXT NOT NULL, + source_message_id INTEGER, + ts INTEGER NOT NULL + ) + """ + ) + # HNSW indexes — kick in automatically when the executor sees + # ORDER BY vec_distance_*(embedding, [...]) LIMIT k. + cur.execute("CREATE INDEX idx_messages_emb ON messages USING hnsw (embedding)") + cur.execute("CREATE INDEX idx_summaries_emb ON summaries USING hnsw (embedding)") + # FTS / BM25 inverted indexes — kick in automatically when the + # executor sees WHERE fts_match(content, 'q') ORDER BY + # bm25_score(content, 'q') DESC LIMIT k. Phase 8 (engine). + cur.execute("CREATE INDEX idx_messages_fts ON messages USING fts (content)") + cur.execute("CREATE INDEX idx_summaries_fts ON summaries USING fts (content)") + + # ------------------------------------------------------------------ + # Writes + + def insert_message( + self, + *, + conversation_id: str, + role: str, + content: str, + embedding: list[float], + ts: Optional[int] = None, + ) -> int: + ts = ts or int(time.time()) + sql = ( + "INSERT INTO messages (conversation_id, role, content, ts, embedding) " + f"VALUES ({q(conversation_id)}, {q(role)}, {q(content)}, {q(ts)}, {q(embedding)})" + ) + cur = self._conn.cursor() + cur.execute(sql) + return self._last_rowid("messages") + + def insert_summary( + self, + *, + conversation_id: str, + start_ts: int, + end_ts: int, + content: str, + embedding: list[float], + ) -> int: + sql = ( + "INSERT INTO summaries (conversation_id, start_ts, end_ts, content, embedding) " + f"VALUES ({q(conversation_id)}, {q(start_ts)}, {q(end_ts)}, {q(content)}, {q(embedding)})" + ) + cur = self._conn.cursor() + cur.execute(sql) + return self._last_rowid("summaries") + + def insert_fact( + self, + *, + subject: str, + predicate: str, + object_: str, + source_message_id: Optional[int] = None, + ts: Optional[int] = None, + ) -> int: + ts = ts or int(time.time()) + src = q(source_message_id) if source_message_id is not None else "NULL" + sql = ( + "INSERT INTO facts (subject, predicate, object, source_message_id, ts) " + f"VALUES ({q(subject)}, {q(predicate)}, {q(object_)}, {src}, {q(ts)})" + ) + cur = self._conn.cursor() + cur.execute(sql) + return self._last_rowid("facts") + + def _last_rowid(self, table: str) -> int: + cur = self._conn.cursor() + cur.execute(f"SELECT id FROM {table} ORDER BY id DESC LIMIT 1") + row = cur.fetchone() + return int(row[0]) if row else -1 + + # ------------------------------------------------------------------ + # Reads + + def recent_messages(self, *, conversation_id: str, limit: int) -> list[Message]: + sql = ( + "SELECT id, conversation_id, role, content, ts FROM messages " + f"WHERE conversation_id = {q(conversation_id)} " + f"ORDER BY id DESC LIMIT {int(limit)}" + ) + cur = self._conn.cursor() + cur.execute(sql) + rows = list(cur.fetchall()) + rows.reverse() # chronological + return [Message(*r) for r in rows] + + def vector_search_messages( + self, + *, + embedding: list[float], + k: int, + conversation_id: Optional[str] = None, + ) -> list[Message]: + """Top-k messages by cosine distance to ``embedding``. + + ``conversation_id`` filters by conversation if provided. Without + it, we search the entire memory (useful for cross-session recall). + """ + where = ( + f"WHERE conversation_id = {q(conversation_id)} " + if conversation_id is not None + else "" + ) + sql = ( + "SELECT id, conversation_id, role, content, ts FROM messages " + f"{where}" + f"ORDER BY vec_distance_cosine(embedding, {q(embedding)}) " + f"LIMIT {int(k)}" + ) + cur = self._conn.cursor() + cur.execute(sql) + return [Message(*r) for r in cur.fetchall()] + + def vector_search_summaries( + self, + *, + embedding: list[float], + k: int, + conversation_id: Optional[str] = None, + ) -> list[Summary]: + where = ( + f"WHERE conversation_id = {q(conversation_id)} " + if conversation_id is not None + else "" + ) + sql = ( + "SELECT id, conversation_id, start_ts, end_ts, content FROM summaries " + f"{where}" + f"ORDER BY vec_distance_cosine(embedding, {q(embedding)}) " + f"LIMIT {int(k)}" + ) + cur = self._conn.cursor() + cur.execute(sql) + return [Summary(*r) for r in cur.fetchall()] + + def lexical_search_messages( + self, + *, + keywords: list[str], + k: int, + conversation_id: Optional[str] = None, + ) -> list[Message]: + """BM25-ranked recall over messages. + + Builds an any-term FTS query from ``keywords`` and lets the + engine's Phase 8 ``try_fts_probe`` optimizer hook serve top-k + from the inverted index in O(query-terms × k log k). A + ``conversation_id`` filter, if provided, is applied as a + scalar post-filter (see ``docs/fts.md`` — "filtered FTS"). + """ + if not keywords: + return [] + query = " ".join(keywords) + conv_clause = ( + f"AND conversation_id = {q(conversation_id)} " if conversation_id else "" + ) + sql = ( + "SELECT id, conversation_id, role, content, ts FROM messages " + f"WHERE fts_match(content, {q(query)}) {conv_clause}" + f"ORDER BY bm25_score(content, {q(query)}) DESC LIMIT {int(k)}" + ) + cur = self._conn.cursor() + cur.execute(sql) + return [Message(*r) for r in cur.fetchall()] + + def search_facts(self, *, keywords: list[str], k: int = 20) -> list[Fact]: + if not keywords: + return [] + keyword_clauses = " OR ".join( + f"subject LIKE {q('%' + kw + '%')} OR " + f"predicate LIKE {q('%' + kw + '%')} OR " + f"object LIKE {q('%' + kw + '%')}" + for kw in keywords + ) + sql = ( + "SELECT id, subject, predicate, object, source_message_id, ts FROM facts " + f"WHERE {keyword_clauses} " + f"ORDER BY ts DESC LIMIT {int(k)}" + ) + cur = self._conn.cursor() + cur.execute(sql) + return [Fact(*r) for r in cur.fetchall()] + + def all_facts(self, limit: int = 100) -> list[Fact]: + sql = ( + "SELECT id, subject, predicate, object, source_message_id, ts FROM facts " + f"ORDER BY ts DESC LIMIT {int(limit)}" + ) + cur = self._conn.cursor() + cur.execute(sql) + return [Fact(*r) for r in cur.fetchall()] + + def messages_in_window( + self, *, conversation_id: str, start_ts: int, end_ts: int + ) -> list[Message]: + sql = ( + "SELECT id, conversation_id, role, content, ts FROM messages " + f"WHERE conversation_id = {q(conversation_id)} " + f"AND ts >= {q(start_ts)} AND ts <= {q(end_ts)} " + "ORDER BY id ASC" + ) + cur = self._conn.cursor() + cur.execute(sql) + return [Message(*r) for r in cur.fetchall()] + + def count(self, table: str) -> int: + cur = self._conn.cursor() + cur.execute(f"SELECT COUNT(*) FROM {table}") + row = cur.fetchone() + return int(row[0]) if row else 0 + + # ------------------------------------------------------------------ + # Lifecycle + + def close(self) -> None: + self._conn.close() + + @contextmanager + def transaction(self) -> Iterator[None]: + cur = self._conn.cursor() + cur.execute("BEGIN") + try: + yield + self._conn.commit() + except Exception: + self._conn.rollback() + raise diff --git a/examples/python-agent/sqlrite_agent/embeddings.py b/examples/python-agent/sqlrite_agent/embeddings.py new file mode 100644 index 0000000..4a97821 --- /dev/null +++ b/examples/python-agent/sqlrite_agent/embeddings.py @@ -0,0 +1,141 @@ +"""Embedding providers. + +Three implementations: + +* :class:`HashEmbedder` — deterministic hash-bag-of-words. No deps, no + API key. The default so the example runs end-to-end on a fresh + machine. Semantic quality is mediocre; good enough for the demo's + 10-turn conversation, not good enough for real RAG. +* :class:`OpenAIEmbedder` — ``text-embedding-3-small`` with explicit + ``dimensions=384`` so it matches the schema. +* :class:`LocalEmbedder` — sentence-transformers (``all-MiniLM-L6-v2``, + natively 384 dims). Best zero-key option for real semantic recall, + at the cost of ~500 MB of torch. + +Pick one at agent boot. All three implement :class:`Embedder`. +""" + +from __future__ import annotations + +import hashlib +import math +import os +import re +from typing import Protocol + +DEFAULT_DIM = 384 +_TOKEN_RE = re.compile(r"[A-Za-z0-9]+") + + +class Embedder(Protocol): + dim: int + + def embed(self, text: str) -> list[float]: ... + + +# --------------------------------------------------------------------------- +# Hash bag-of-words — the zero-dependency default. + + +class HashEmbedder: + """Token-hash → fixed-dim vector. + + Each token's MD5 picks a bucket; we increment that bucket. The + final vector is L2-normalized so cosine distance is meaningful. + Two texts sharing tokens end up with overlapping non-zero buckets + and a small cosine distance. + + This is a placeholder for real embeddings. Swap in OpenAI or + sentence-transformers for production. + """ + + def __init__(self, dim: int = DEFAULT_DIM) -> None: + self.dim = dim + + def embed(self, text: str) -> list[float]: + vec = [0.0] * self.dim + tokens = _TOKEN_RE.findall(text.lower()) + if not tokens: + return vec + for tok in tokens: + h = hashlib.md5(tok.encode("utf-8")).digest() + # First 4 bytes → bucket; next byte's sign bit → sign. + bucket = int.from_bytes(h[:4], "little") % self.dim + sign = 1.0 if (h[4] & 1) == 0 else -1.0 + vec[bucket] += sign + norm = math.sqrt(sum(v * v for v in vec)) + if norm > 0: + vec = [v / norm for v in vec] + return vec + + +# --------------------------------------------------------------------------- +# OpenAI — text-embedding-3-small with dimensions=384. + + +class OpenAIEmbedder: + """``text-embedding-3-small`` constrained to ``dim`` dimensions.""" + + def __init__(self, *, dim: int = DEFAULT_DIM, api_key: str | None = None) -> None: + try: + from openai import OpenAI # type: ignore[import-not-found] + except ImportError as e: # pragma: no cover - import guard + raise RuntimeError( + "install the 'openai' extra to use OpenAIEmbedder: " + "`pip install 'sqlrite-agent[openai]'`" + ) from e + + self.dim = dim + self._OpenAI = OpenAI + self._client = OpenAI(api_key=api_key or os.environ.get("OPENAI_API_KEY")) + + def embed(self, text: str) -> list[float]: + resp = self._client.embeddings.create( + model="text-embedding-3-small", + input=text, + dimensions=self.dim, + ) + return list(resp.data[0].embedding) + + +# --------------------------------------------------------------------------- +# sentence-transformers — local, no API key. + + +class LocalEmbedder: + """sentence-transformers ``all-MiniLM-L6-v2`` (384-dim by default).""" + + def __init__(self, *, model_name: str = "sentence-transformers/all-MiniLM-L6-v2") -> None: + try: + from sentence_transformers import SentenceTransformer # type: ignore[import-not-found] + except ImportError as e: # pragma: no cover - import guard + raise RuntimeError( + "install the 'local-embeddings' extra to use LocalEmbedder: " + "`pip install 'sqlrite-agent[local-embeddings]'`" + ) from e + + self._model = SentenceTransformer(model_name) + self.dim = self._model.get_sentence_embedding_dimension() + + def embed(self, text: str) -> list[float]: + return [float(x) for x in self._model.encode(text, normalize_embeddings=True)] + + +# --------------------------------------------------------------------------- +# Factory + + +def build_embedder(name: str, *, dim: int = DEFAULT_DIM) -> Embedder: + """Build an embedder from a short string name. + + Names: ``hash``, ``openai``, ``local``. Raises ``ValueError`` for + anything else — callers should validate before calling. + """ + name = name.lower() + if name == "hash": + return HashEmbedder(dim=dim) + if name == "openai": + return OpenAIEmbedder(dim=dim) + if name == "local": + return LocalEmbedder() + raise ValueError(f"unknown embedder: {name!r} (expected 'hash', 'openai', or 'local')") diff --git a/examples/python-agent/sqlrite_agent/facts.py b/examples/python-agent/sqlrite_agent/facts.py new file mode 100644 index 0000000..7d7f085 --- /dev/null +++ b/examples/python-agent/sqlrite_agent/facts.py @@ -0,0 +1,137 @@ +"""Lightweight fact extraction. + +Pulls structured ``(subject, predicate, object)`` triples out of user +messages with regex heuristics. Deliberately conservative — we'd +rather miss a fact than hallucinate one. The agent surfaces facts via +plain SQL on the ``facts`` table, so a few well-chosen patterns beat +calling an LLM on every turn. + +Production agents would call the LLM here; the demo keeps it offline +so the example runs without an API key. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass + + +@dataclass(frozen=True) +class ExtractedFact: + subject: str + predicate: str + object: str + + +# (regex, fact-builder) pairs. The builder receives the regex match +# and returns an ``ExtractedFact``. Order matters — first match wins +# for a given sentence. +_PATTERNS: list[tuple[re.Pattern[str], "callable"]] = [ # type: ignore[type-arg] + # "my dog's name is Mochi" / "my dog is called Mochi" + ( + re.compile( + r"\bmy\s+([a-zA-Z][a-zA-Z\s]{0,40}?)(?:'s)?\s+(?:name\s+is|is\s+called)\s+([A-Z][\w'-]{1,40})", + re.IGNORECASE, + ), + lambda m: ExtractedFact( + subject=f"user.{_slug(m.group(1))}", + predicate="name", + object=m.group(2), + ), + ), + # "I live in " / "I'm from " + ( + re.compile( + r"\bI(?:\s+am|'m)?\s+(?:live\s+in|from|based\s+in)\s+([A-Z][\w\s,]{1,60})", + re.IGNORECASE, + ), + lambda m: ExtractedFact( + subject="user", + predicate="location", + object=_clean(m.group(1)), + ), + ), + # "I work as a " / "I'm a " + ( + re.compile( + r"\bI(?:\s+am|'m)?\s+(?:work\s+as\s+a|a)\s+([a-zA-Z][a-zA-Z\s]{1,40})", + re.IGNORECASE, + ), + lambda m: ExtractedFact( + subject="user", + predicate="role", + object=_clean(m.group(1)), + ), + ), + # "My favorite is " + ( + re.compile( + r"\bmy\s+favou?rite\s+([a-zA-Z][a-zA-Z\s]{1,30})\s+is\s+([\w\s'-]{1,60})", + re.IGNORECASE, + ), + lambda m: ExtractedFact( + subject="user", + predicate=f"favorite_{_slug(m.group(1))}", + object=_clean(m.group(2)), + ), + ), + # "I like " / "I love " + ( + re.compile( + r"\bI\s+(?:like|love|enjoy)\s+([\w\s'-]{1,60})", + re.IGNORECASE, + ), + lambda m: ExtractedFact( + subject="user", + predicate="likes", + object=_clean(m.group(1)), + ), + ), + # "I have a named " + ( + re.compile( + r"\bI\s+have\s+a\s+([a-zA-Z][a-zA-Z\s]{1,30})\s+(?:named|called)\s+([A-Z][\w'-]{1,40})", + re.IGNORECASE, + ), + lambda m: ExtractedFact( + subject=f"user.{_slug(m.group(1))}", + predicate="name", + object=m.group(2), + ), + ), +] + + +def extract_facts(text: str) -> list[ExtractedFact]: + """Pull every distinct fact out of ``text``. + + Splits on sentence boundaries, runs each pattern, deduplicates. + """ + facts: list[ExtractedFact] = [] + seen: set[tuple[str, str, str]] = set() + for sentence in _sentences(text): + for pattern, builder in _PATTERNS: + m = pattern.search(sentence) + if not m: + continue + fact = builder(m) + key = (fact.subject, fact.predicate, fact.object) + if key in seen: + continue + seen.add(key) + facts.append(fact) + # One fact per sentence to keep the heuristics tractable. + break + return facts + + +def _sentences(text: str) -> list[str]: + return [s.strip() for s in re.split(r"(?<=[.!?])\s+|\n", text) if s.strip()] + + +def _slug(s: str) -> str: + return re.sub(r"[^a-z0-9]+", "_", s.strip().lower()).strip("_") + + +def _clean(s: str) -> str: + return s.strip().rstrip(".,!?;:'\" ") diff --git a/examples/python-agent/sqlrite_agent/memory.py b/examples/python-agent/sqlrite_agent/memory.py new file mode 100644 index 0000000..501889c --- /dev/null +++ b/examples/python-agent/sqlrite_agent/memory.py @@ -0,0 +1,216 @@ +"""Memory: store messages and recall them by hybrid (vector + lexical) search. + +This is the only module the chat loop calls directly. Everything else +in this package is plumbing. +""" + +from __future__ import annotations + +import re +import time +from dataclasses import dataclass +from typing import Optional + +from sqlrite_agent.db import AgentDB, Fact, Message, Summary +from sqlrite_agent.embeddings import Embedder +from sqlrite_agent.facts import extract_facts + + +@dataclass(frozen=True) +class Recall: + """What the agent recalled in response to a query. + + The three buckets serve different roles in the prompt: + + * ``facts`` — deterministic, exact recall from structured triples. + Always trustworthy because it came from earlier user statements + via the regex extractor. + * ``summaries`` — periodic rollups of older turns. Wide context, + lossy. Good for "what was this conversation about" recall. + * ``messages`` — individual past turns ranked by hybrid similarity. + High precision, narrow context. + """ + + facts: list[Fact] + summaries: list[Summary] + messages: list[Message] + + +_KEYWORD_RE = re.compile(r"[A-Za-z][A-Za-z0-9]{2,}") +_STOP = frozenset( + { + "the", "and", "for", "with", "you", "your", "this", "that", "from", + "are", "was", "were", "have", "has", "had", "but", "not", "what", + "when", "where", "which", "who", "why", "how", "into", "about", + "did", "does", "doing", "done", "been", "being", "than", "then", + } +) + + +def query_keywords(text: str, *, limit: int = 6) -> list[str]: + """Extract content keywords from a query for the lexical recall step.""" + seen: set[str] = set() + out: list[str] = [] + for tok in _KEYWORD_RE.findall(text.lower()): + if tok in _STOP or tok in seen: + continue + seen.add(tok) + out.append(tok) + if len(out) >= limit: + break + return out + + +class Memory: + """High-level operations on the agent's persistent memory.""" + + def __init__(self, db: AgentDB, embedder: Embedder) -> None: + if embedder.dim != db.dim: + raise ValueError( + f"embedder dim {embedder.dim} does not match db dim {db.dim} " + f"(file format pins the schema's VECTOR({db.dim}))" + ) + self._db = db + self._embedder = embedder + + # ------------------------------------------------------------------ + # Writes + + def log_message( + self, + *, + conversation_id: str, + role: str, + content: str, + extract_user_facts: bool = True, + ) -> int: + """Embed and persist a chat turn. + + For user messages, also runs the heuristic fact extractor and + writes any extracted triples — wired in here (rather than at the + call site) so callers can't forget. + """ + embedding = self._embedder.embed(content) + ts = int(time.time()) + msg_id = self._db.insert_message( + conversation_id=conversation_id, + role=role, + content=content, + embedding=embedding, + ts=ts, + ) + if extract_user_facts and role == "user": + for fact in extract_facts(content): + self._db.insert_fact( + subject=fact.subject, + predicate=fact.predicate, + object_=fact.object, + source_message_id=msg_id, + ts=ts, + ) + return msg_id + + def log_summary( + self, + *, + conversation_id: str, + start_ts: int, + end_ts: int, + content: str, + ) -> int: + embedding = self._embedder.embed(content) + return self._db.insert_summary( + conversation_id=conversation_id, + start_ts=start_ts, + end_ts=end_ts, + content=content, + embedding=embedding, + ) + + # ------------------------------------------------------------------ + # Reads + + def recall( + self, + query: str, + *, + conversation_id: Optional[str] = None, + k_messages: int = 4, + k_summaries: int = 2, + k_facts: int = 10, + ) -> Recall: + """Hybrid recall for ``query``. + + Strategy: + 1. Embed the query and pull top-k messages + summaries by cosine + distance (vector / semantic half). + 2. Extract keywords and pull additional messages via LIKE (lexical + fallback for Phase 8 BM25, which isn't shipped yet). + 3. Pull keyword-matched facts directly from the structured table. + 4. Merge and dedupe by id, preserving the vector-search ranking + and appending lexical-only hits afterward. + """ + embedding = self._embedder.embed(query) + keywords = query_keywords(query) + + vec_hits = self._db.vector_search_messages( + embedding=embedding, + k=k_messages, + conversation_id=conversation_id, + ) + lex_hits = self._db.lexical_search_messages( + keywords=keywords, + k=k_messages, + conversation_id=conversation_id, + ) + + messages = _merge_ranked(vec_hits, lex_hits, key=lambda m: m.id)[:k_messages * 2] + + summaries = self._db.vector_search_summaries( + embedding=embedding, + k=k_summaries, + conversation_id=conversation_id, + ) + facts = self._db.search_facts(keywords=keywords, k=k_facts) + + return Recall(facts=facts, summaries=summaries, messages=messages) + + def recent(self, *, conversation_id: str, limit: int = 6) -> list[Message]: + return self._db.recent_messages(conversation_id=conversation_id, limit=limit) + + def messages_in_window( + self, *, conversation_id: str, start_ts: int, end_ts: int + ) -> list[Message]: + return self._db.messages_in_window( + conversation_id=conversation_id, + start_ts=start_ts, + end_ts=end_ts, + ) + + def all_facts(self, limit: int = 100) -> list[Fact]: + return self._db.all_facts(limit=limit) + + def stats(self) -> dict[str, int]: + return { + "messages": self._db.count("messages"), + "summaries": self._db.count("summaries"), + "facts": self._db.count("facts"), + } + + +def _merge_ranked(primary, secondary, *, key): + seen: set = set() + out = [] + for item in primary: + k = key(item) + if k in seen: + continue + seen.add(k) + out.append(item) + for item in secondary: + k = key(item) + if k in seen: + continue + seen.add(k) + out.append(item) + return out diff --git a/examples/python-agent/sqlrite_agent/sqlutil.py b/examples/python-agent/sqlrite_agent/sqlutil.py new file mode 100644 index 0000000..8bc2c4b --- /dev/null +++ b/examples/python-agent/sqlrite_agent/sqlutil.py @@ -0,0 +1,39 @@ +"""SQL-literal helpers. + +The SQLRite Python SDK does not support prepared-statement parameter +binding yet (deferred to engine Phase 5a.2 — see ``sdk/python/README.md``). +Every value must therefore be inlined into the SQL string. These helpers +keep that inlining safe and consistent across the agent. +""" + +from __future__ import annotations + +from typing import Iterable + + +def q(value: object) -> str: + """Render ``value`` as a SQLRite literal safe for direct inlining. + + Strings get single-quote-escaped (``'`` → ``''``). Numbers and bools + pass through. ``None`` becomes ``NULL``. Lists / tuples of floats + become the ``[x, y, z]`` syntax SQLRite uses for VECTOR literals. + """ + if value is None: + return "NULL" + if isinstance(value, bool): + # Cover bool before int — bool is a subclass of int. + return "1" if value else "0" + if isinstance(value, (int, float)): + return repr(value) + if isinstance(value, (list, tuple)): + return vec_literal(value) + if isinstance(value, str): + escaped = value.replace("'", "''") + return f"'{escaped}'" + raise TypeError(f"cannot inline {type(value).__name__} into SQL") + + +def vec_literal(vec: Iterable[float]) -> str: + """Render a vector as the SQLRite ``[v1, v2, ...]`` literal.""" + parts = ", ".join(f"{float(x):.6f}" for x in vec) + return f"[{parts}]" diff --git a/examples/python-agent/tests/conftest.py b/examples/python-agent/tests/conftest.py new file mode 100644 index 0000000..72a8327 --- /dev/null +++ b/examples/python-agent/tests/conftest.py @@ -0,0 +1,38 @@ +"""Shared pytest fixtures. + +Every test runs against a fresh ``AgentDB`` backed by an in-memory +SQLRite database — no temp files, no API keys, no network. +""" + +from __future__ import annotations + +import pytest + +from sqlrite_agent.chat import EchoChat +from sqlrite_agent.db import AgentDB +from sqlrite_agent.embeddings import HashEmbedder +from sqlrite_agent.memory import Memory + + +@pytest.fixture +def db(): + d = AgentDB(":memory:") + try: + yield d + finally: + d.close() + + +@pytest.fixture +def embedder(): + return HashEmbedder() + + +@pytest.fixture +def memory(db, embedder): + return Memory(db, embedder) + + +@pytest.fixture +def chat(): + return EchoChat() diff --git a/examples/python-agent/tests/test_agent.py b/examples/python-agent/tests/test_agent.py new file mode 100644 index 0000000..f2bae0b --- /dev/null +++ b/examples/python-agent/tests/test_agent.py @@ -0,0 +1,100 @@ +"""Top-level agent tests: turn loop, prompt assembly, summarization.""" + +from __future__ import annotations + +from sqlrite_agent.agent import ChatAgent +from sqlrite_agent.chat import ChatProvider + + +class CapturingChat: + """A test double that records what the agent asked it.""" + + def __init__(self, reply: str = "ok") -> None: + self.reply = reply + self.last_system: str = "" + self.last_messages: list[dict[str, str]] = [] + + def complete(self, *, system: str, messages: list[dict[str, str]]) -> str: + self.last_system = system + self.last_messages = list(messages) + return self.reply + + +def test_turn_writes_user_and_assistant_messages(memory): + chat: ChatProvider = CapturingChat(reply="hi there") + agent = ChatAgent(memory=memory, chat=chat, conversation_id="c1") + + turn = agent.turn("Hello!") + assert turn.assistant_reply == "hi there" + + s = memory.stats() + assert s["messages"] == 2 # one user, one assistant + + +def test_turn_assembles_system_prompt_with_recalled_facts(memory): + # Seed a known fact, then issue a related query in a NEW turn. + memory.log_message( + conversation_id="c1", + role="user", + content="My dog's name is Mochi.", + ) + + chat = CapturingChat(reply="cool") + agent = ChatAgent(memory=memory, chat=chat, conversation_id="c1") + agent.turn("Tell me about Mochi") + + assert "user.dog.name = Mochi" in chat.last_system + + +def test_turn_includes_recent_chat_history(memory): + chat = CapturingChat(reply="ok") + agent = ChatAgent(memory=memory, chat=chat, conversation_id="c1") + + agent.turn("first message") + agent.turn("second message") + + # Third turn should see both prior turns in the message list. + agent.turn("third message") + user_contents = [m["content"] for m in chat.last_messages if m["role"] == "user"] + assert "first message" in user_contents + assert "second message" in user_contents + assert "third message" in user_contents + + +def test_summarize_window_writes_a_summary(memory): + chat = CapturingChat(reply="The user talked about their dog and the weather.") + agent = ChatAgent(memory=memory, chat=chat, conversation_id="c1") + agent.turn("My dog's name is Mochi.") + agent.turn("The weather in Lisbon is sunny.") + + summary = agent.summarize_window(last_n=10) + assert summary is not None + assert memory.stats()["summaries"] == 1 + + +def test_recall_survives_db_reopen(tmp_path, embedder): + """The headline demo: memory across process restarts.""" + from sqlrite_agent.db import AgentDB + from sqlrite_agent.memory import Memory + + path = str(tmp_path / "agent.sqlrite") + + # --- session 1 --- + db = AgentDB(path) + memory = Memory(db, embedder) + chat = CapturingChat(reply="ok") + agent = ChatAgent(memory=memory, chat=chat, conversation_id="c1") + agent.turn("My dog's name is Mochi.") + agent.turn("Mochi loves carrots.") + db.close() + + # --- session 2: fresh process, same DB --- + db = AgentDB(path) + memory = Memory(db, embedder) + chat = CapturingChat(reply="great") + agent = ChatAgent(memory=memory, chat=chat, conversation_id="c1") + + agent.turn("What does Mochi eat?") + # The system prompt for the new turn should contain the fact we + # stored in session 1. That's the entire point of the demo. + assert "Mochi" in chat.last_system diff --git a/examples/python-agent/tests/test_db.py b/examples/python-agent/tests/test_db.py new file mode 100644 index 0000000..068a28e --- /dev/null +++ b/examples/python-agent/tests/test_db.py @@ -0,0 +1,172 @@ +"""DB-layer tests: schema, inserts, vector recall, lexical fallback.""" + +from __future__ import annotations + +import time + +from sqlrite_agent.db import AgentDB + + +def test_schema_creates_three_tables_and_records_version(db: AgentDB): + cur = db._conn.cursor() # noqa: SLF001 — test reaches into internals + cur.execute("SELECT version FROM schema_version") + row = cur.fetchone() + assert row == (1,) + + +def test_insert_and_fetch_message(db: AgentDB, embedder): + msg_id = db.insert_message( + conversation_id="c1", + role="user", + content="hello world", + embedding=embedder.embed("hello world"), + ) + assert msg_id >= 1 + + rows = db.recent_messages(conversation_id="c1", limit=5) + assert len(rows) == 1 + assert rows[0].role == "user" + assert rows[0].content == "hello world" + + +def test_insert_handles_single_quotes_in_content(db: AgentDB, embedder): + # The agent must survive any user content. This is the SQL-injection + # smoke test for the q() inlining helper. + payload = "I'm here; '); DROP TABLE messages; --" + db.insert_message( + conversation_id="c1", + role="user", + content=payload, + embedding=embedder.embed(payload), + ) + rows = db.recent_messages(conversation_id="c1", limit=5) + assert rows[0].content == payload + # Schema is intact. + cur = db._conn.cursor() # noqa: SLF001 + cur.execute("SELECT COUNT(*) FROM messages") + assert cur.fetchone() == (1,) + + +def test_vector_search_orders_by_cosine_distance(db: AgentDB, embedder): + db.insert_message( + conversation_id="c1", + role="user", + content="my dog mochi loves carrots", + embedding=embedder.embed("my dog mochi loves carrots"), + ) + db.insert_message( + conversation_id="c1", + role="user", + content="the weather in lisbon is sunny today", + embedding=embedder.embed("the weather in lisbon is sunny today"), + ) + + query = "what does mochi like to eat" + hits = db.vector_search_messages( + embedding=embedder.embed(query), k=2, conversation_id="c1" + ) + assert len(hits) == 2 + # The mochi/carrots row shares tokens with the query, so it should + # rank above the weather/lisbon row under our hash embedder. + assert "mochi" in hits[0].content + + +def test_lexical_search_messages(db: AgentDB, embedder): + db.insert_message( + conversation_id="c1", + role="user", + content="alice loves running", + embedding=embedder.embed("alice loves running"), + ) + db.insert_message( + conversation_id="c1", + role="user", + content="bob plays the piano", + embedding=embedder.embed("bob plays the piano"), + ) + hits = db.lexical_search_messages( + keywords=["alice"], k=10, conversation_id="c1" + ) + assert len(hits) == 1 + assert "alice" in hits[0].content + + +def test_lexical_search_ranks_by_bm25(db: AgentDB, embedder): + # Two rows share the term 'database'; only one shares 'embedded'. + # BM25 should put the row with more matching terms (and rarer ones) + # ahead of the row with just one common-ish term. + for body in ( + "redis is an in-memory database that caches values", + "sqlrite is an embedded database engine", + "postgres is a relational database server", + "rust is a systems programming language", + ): + db.insert_message( + conversation_id="c1", + role="user", + content=body, + embedding=embedder.embed(body), + ) + + hits = db.lexical_search_messages( + keywords=["embedded", "database"], k=10, conversation_id="c1" + ) + assert hits, "FTS should find at least one match" + assert "embedded database" in hits[0].content + + +def test_lexical_search_handles_unmatched_query(db: AgentDB, embedder): + db.insert_message( + conversation_id="c1", + role="user", + content="alice loves running", + embedding=embedder.embed("alice loves running"), + ) + # Query terms that aren't in any document — fts_match returns no + # rows, which the agent must tolerate (vector recall still runs). + hits = db.lexical_search_messages( + keywords=["nonexistentterm"], k=10, conversation_id="c1" + ) + assert hits == [] + + +def test_facts_round_trip(db: AgentDB): + db.insert_fact(subject="user.dog", predicate="name", object_="Mochi") + db.insert_fact(subject="user", predicate="location", object_="Lisbon") + found = db.search_facts(keywords=["mochi"]) + assert len(found) == 1 + assert found[0].subject == "user.dog" + + +def test_messages_in_window(db: AgentDB, embedder): + base = int(time.time()) + for i in range(5): + db.insert_message( + conversation_id="c1", + role="user", + content=f"msg {i}", + embedding=embedder.embed(f"msg {i}"), + ts=base + i, + ) + window = db.messages_in_window( + conversation_id="c1", start_ts=base + 1, end_ts=base + 3 + ) + assert [m.content for m in window] == ["msg 1", "msg 2", "msg 3"] + + +def test_persists_across_reopen(tmp_path, embedder): + path = str(tmp_path / "agent.sqlrite") + db = AgentDB(path) + db.insert_message( + conversation_id="c1", + role="user", + content="i live in lisbon", + embedding=embedder.embed("i live in lisbon"), + ) + db.close() + + db2 = AgentDB(path) + rows = db2.recent_messages(conversation_id="c1", limit=5) + assert len(rows) == 1 + assert rows[0].content == "i live in lisbon" + db2.close() diff --git a/examples/python-agent/tests/test_facts.py b/examples/python-agent/tests/test_facts.py new file mode 100644 index 0000000..6ccd677 --- /dev/null +++ b/examples/python-agent/tests/test_facts.py @@ -0,0 +1,57 @@ +from sqlrite_agent.facts import extract_facts + + +def test_extracts_dog_name(): + facts = extract_facts("My dog's name is Mochi.") + assert any( + f.subject == "user.dog" and f.predicate == "name" and f.object == "Mochi" + for f in facts + ) + + +def test_extracts_dog_name_alt_phrasing(): + facts = extract_facts("My dog is called Mochi.") + assert any( + f.subject == "user.dog" and f.predicate == "name" and f.object == "Mochi" + for f in facts + ) + + +def test_extracts_location(): + facts = extract_facts("I'm from Lisbon.") + assert any( + f.subject == "user" and f.predicate == "location" and f.object == "Lisbon" + for f in facts + ) + + +def test_extracts_favorite_thing(): + facts = extract_facts("My favorite color is blue.") + assert any( + f.subject == "user" + and f.predicate == "favorite_color" + and f.object == "blue" + for f in facts + ) + + +def test_extracts_pet(): + facts = extract_facts("I have a cat named Whiskers.") + assert any( + f.subject == "user.cat" + and f.predicate == "name" + and f.object == "Whiskers" + for f in facts + ) + + +def test_no_false_positives_on_neutral_text(): + facts = extract_facts("Hello there. The weather is fine.") + assert facts == [] + + +def test_dedupes_within_one_message(): + facts = extract_facts( + "My dog's name is Mochi. By the way, my dog is called Mochi." + ) + assert sum(1 for f in facts if f.object == "Mochi") == 1 diff --git a/examples/python-agent/tests/test_memory.py b/examples/python-agent/tests/test_memory.py new file mode 100644 index 0000000..5a042df --- /dev/null +++ b/examples/python-agent/tests/test_memory.py @@ -0,0 +1,85 @@ +"""Memory-layer tests: recall combines vector + lexical + facts.""" + +from __future__ import annotations + +from sqlrite_agent.memory import Memory, query_keywords + + +def test_keywords_filter_stop_words(): + kws = query_keywords("what is the weather like in lisbon today") + assert "lisbon" in kws + assert "the" not in kws + assert "what" not in kws + + +def test_log_user_message_extracts_facts(memory: Memory): + memory.log_message( + conversation_id="c1", + role="user", + content="My dog's name is Mochi.", + ) + facts = memory.all_facts() + assert any( + f.subject == "user.dog" and f.object == "Mochi" for f in facts + ) + + +def test_log_assistant_does_not_extract_facts(memory: Memory): + memory.log_message( + conversation_id="c1", + role="assistant", + content="Your dog's name is Mochi.", # assistant echoing back + ) + assert memory.all_facts() == [] + + +def test_recall_pulls_messages_summaries_and_facts(memory: Memory): + memory.log_message( + conversation_id="c1", + role="user", + content="My dog's name is Mochi.", + ) + memory.log_message( + conversation_id="c1", + role="user", + content="Mochi loves carrots more than treats.", + ) + memory.log_message( + conversation_id="c1", + role="user", + content="The weather in Lisbon is sunny today.", + ) + + r = memory.recall("what does mochi like to eat", conversation_id="c1") + assert any("Mochi" in f.object for f in r.facts) + assert any("mochi" in m.content.lower() for m in r.messages) + + +def test_recall_works_without_conversation_filter(memory: Memory): + memory.log_message(conversation_id="c1", role="user", content="apples are red") + memory.log_message(conversation_id="c2", role="user", content="bananas are yellow") + r = memory.recall("color of an apple") + contents = " ".join(m.content for m in r.messages) + assert "apple" in contents + + +def test_recall_persists_across_reopen(tmp_path, embedder): + from sqlrite_agent.db import AgentDB + + path = str(tmp_path / "mem.sqlrite") + db = AgentDB(path) + mem = Memory(db, embedder) + mem.log_message( + conversation_id="c1", + role="user", + content="My favorite color is blue.", + ) + db.close() + + db2 = AgentDB(path) + mem2 = Memory(db2, embedder) + r = mem2.recall("what color do i like", conversation_id="c1") + assert any( + f.predicate == "favorite_color" and f.object == "blue" for f in r.facts + ) + db2.close() diff --git a/examples/python-agent/tests/test_sqlutil.py b/examples/python-agent/tests/test_sqlutil.py new file mode 100644 index 0000000..16be316 --- /dev/null +++ b/examples/python-agent/tests/test_sqlutil.py @@ -0,0 +1,28 @@ +from sqlrite_agent.sqlutil import q, vec_literal + + +def test_q_escapes_single_quotes(): + assert q("it's") == "'it''s'" + + +def test_q_handles_none_and_numbers(): + assert q(None) == "NULL" + assert q(42) == "42" + assert q(True) == "1" + assert q(False) == "0" + + +def test_q_vectors_use_brackets(): + assert q([1.0, 2.0]) == "[1.000000, 2.000000]" + + +def test_vec_literal_rounds_floats(): + out = vec_literal([0.1, -0.5, 1.234567]) + assert out == "[0.100000, -0.500000, 1.234567]" + + +def test_q_rejects_unknown_types(): + import pytest + + with pytest.raises(TypeError): + q(object()) diff --git a/web/src/app/examples/page.tsx b/web/src/app/examples/page.tsx new file mode 100644 index 0000000..1819a13 --- /dev/null +++ b/web/src/app/examples/page.tsx @@ -0,0 +1,244 @@ +import Link from "next/link"; +import type { Metadata } from "next"; +import { Footer } from "@/components/footer"; +import { Nav } from "@/components/nav"; +import { SITE } from "@/lib/site"; + +const TITLE = "Examples"; +const DESCRIPTION = + "End-to-end example apps built on SQLRite — the embedded SQL + vector database in Rust. Real-world shapes: AI agents, RAG knowledge bases, local-first desktop apps, browser-only SQL playgrounds, and edge collectors."; + +export const metadata: Metadata = { + title: TITLE, + description: DESCRIPTION, + alternates: { canonical: "/examples" }, + openGraph: { + type: "website", + siteName: "SQLRite", + locale: "en_US", + url: `${SITE.url}/examples`, + title: `${TITLE} · SQLRite`, + description: DESCRIPTION, + }, + twitter: { + card: "summary_large_image", + site: SITE.twitterHandle, + creator: SITE.twitterHandle, + title: `${TITLE} · SQLRite`, + description: DESCRIPTION, + }, +}; + +const itemListJsonLd = { + "@context": "https://schema.org", + "@type": "ItemList", + name: "SQLRite Examples", + description: DESCRIPTION, + url: `${SITE.url}/examples`, + itemListElement: [ + { + "@type": "ListItem", + position: 1, + item: { + "@type": "SoftwareSourceCode", + name: "Python LLM agent with persistent memory", + url: `${SITE.repo}/tree/main/examples/python-agent`, + programmingLanguage: "Python", + description: + "A CLI chat agent whose long-term memory is a single .sqlrite file. Vector recall via HNSW, lexical recall via BM25, and a structured facts table for deterministic retrieval.", + }, + }, + ], +}; + +type Example = { + status: "shipped" | "planned"; + title: string; + blurb: string; + bullets: string[]; + language: string; + repoPath: string; + features: string[]; +}; + +const EXAMPLES: Example[] = [ + { + status: "shipped", + title: "Python LLM agent with long-term memory", + blurb: + "A CLI chat agent whose entire long-term memory is one local .sqlrite file. Embeds each turn, hybrid-searches messages + summaries + a structured facts table on every recall, and persists across process restarts. No Postgres, no Redis, no Pinecone — just one file.", + bullets: [ + "Vector KNN over past turns via HNSW, plus BM25 keyword recall via fts_match / bm25_score", + "Heuristic fact extraction into a (subject, predicate, object) table — surfaced via plain SQL", + "Zero-config first-run with a hash embedder + offline echo agent; swap in OpenAI / sentence-transformers / Anthropic via CLI flags", + "31 offline tests; runs end-to-end without an API key", + ], + language: "Python 3.11+", + repoPath: "examples/python-agent", + features: ["HNSW", "VECTOR(384)", "BM25 / FTS", "PyO3 SDK"], + }, +]; + +const pillStyle: React.CSSProperties = { + fontSize: 11, + padding: "2px 8px", + border: "1px solid var(--color-line)", + borderRadius: 999, + color: "var(--color-fg-mute)", + fontFamily: + "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace", + whiteSpace: "nowrap", +}; + +const cardStyle: React.CSSProperties = { + border: "1px solid var(--color-line)", + borderLeft: "2px solid var(--color-accent)", + borderRadius: 8, + padding: "28px 28px 24px 28px", + background: "var(--color-bg-card)", + display: "flex", + flexDirection: "column", + gap: 16, +}; + +export default function ExamplesIndexPage() { + return ( + <> +