v0.20.0: at-rest encryption (AES-256-GCM) for all query-derived text

- crypto.py: AES-256-GCM encrypt/decrypt + ensure_key()
- Key auto-generated on first boot, stored as heartbeat_interval_ms in settings
- All 12 storage paths wired (SQLite + Qdrant)
- memory.py: FTS5 search replaced with Python-side matching
- 200/200 tests pass
This commit is contained in:
gramps
2026-07-14 13:22:32 -07:00
parent f6b01ec9ac
commit 30deb0db64
18 changed files with 176 additions and 79 deletions
+6 -19
View File
@@ -56,11 +56,12 @@ Refactored from single-file (`app.py`) into modules under project root:
| `db.py` | SQLite schema, connection factory, settings helpers, upload_context CRUD | | `db.py` | SQLite schema, connection factory, settings helpers, upload_context CRUD |
| `auth.py` | PIN-based guest/admin sessions, auth routes | | `auth.py` | PIN-based guest/admin sessions, auth routes |
| `security.py` | Rate limiting, origin checks, IP allowlist, audit/incident logging | | `security.py` | Rate limiting, origin checks, IP allowlist, audit/incident logging |
| `memory.py` | FTS5 memory CRUD, remember/forget command parsing | | `memory.py` | FTS5 memory CRUD (encrypted facts, Python-side matching), remember/forget command parsing |
| `search.py` | SearXNG integration, perplexity scoring, refusal detection | | `search.py` | SearXNG integration, perplexity scoring, refusal detection |
| `rag.py` | Qdrant vector search + system prompt assembly + chunk_text() helper | | `rag.py` | Qdrant vector search (encrypted payload text) + system prompt assembly + chunk_text() helper |
| `eviction.py` | Score-based RAG eviction engine | | `eviction.py` | Score-based RAG eviction engine |
| `gpu.py` | GPU stats — `rocm-smi` (AMD/Linux) or `system_profiler` (Apple Silicon/macOS) | | `gpu.py` | GPU stats — `rocm-smi` (AMD/Linux) or `system_profiler` (Apple Silicon/macOS) |
| `crypto.py` | AES-256-GCM encrypt/decrypt + key management (stored as `heartbeat_interval_ms` in settings) |
| `model_pull.py` | Startup model availability check + Ollama pull API | | `model_pull.py` | Startup model availability check + Ollama pull API |
| `triage.py` | Phi-4-mini-based query classification + cluster node selection | | `triage.py` | Phi-4-mini-based query classification + cluster node selection |
| `cluster.py` | Cluster node registry, event log, coordinator election, ping/pong, model swap handlers | | `cluster.py` | Cluster node registry, event log, coordinator election, ping/pong, model swap handlers |
@@ -136,19 +137,7 @@ All streaming endpoints yield `data: {json}\n\n`. Key shapes:
## Work State ## Work State
### Completed this session ### Completed this session
- **B7 (v0.19.0)** — Apple Silicon worker support. `gpu.py` now detects `sys.platform == "darwin"` and parses `system_profiler SPDisplaysDataType` for GPU model/VRAM instead of `rocm-smi`. `hardware.py` has darwin branch via `_get_vram_darwin()`. `node_agent/agent.py` reports VRAM on macOS via `system_profiler`. 5 new tests cover linux/darwin gpu paths, 3 new hardware tests cover darwin assessment + VRAM parsing. - **At-Rest Encryption (v0.20.0)** — `crypto.py` with AES-256-GCM encrypt/decrypt + `ensure_key()`. Key auto-generated on first boot, stored as `heartbeat_interval_ms` in settings (never exposed via API). All 12 storage call sites wired: `routers/chat.py`, `routers/search_route.py`, `routers/conversations.py`, `routers/completions.py`, `memory.py`, `db.py` (upload_context), `rag.py`, `routers/upload.py`, `routers/ingest.py`. `memory.py` FTS5 search replaced with Python-side matching (encrypted facts can't be FTS5-indexed). `app.py` lifespan calls `ensure_key()` after `init_db()`. 200/200 tests pass. Deployed to jarvis with fresh DB.
- **B5 (v0.19.1)** — Default model auto-pull on first start. `model_pull.py` with `ensure_model()` checks llama-server availability, falls back to Ollama pull API. Integrated into `app.py` lifespan. 11 tests cover all paths.
- **B6 (v0.19.2)** — Waterfall direction toggle. NEW/OLD topbar button toggles between newest-first and oldest-first ordering. `scrollToTop()` replaced by `scrollToLatest()` which checks direction. Preference persisted in localStorage.
- **Scroll-position fighting** — `scrollToTop()` now respects `_userScrolledAway` flag (100px threshold), skips auto-scroll when user is reading older content. `resetScrollLock()` called on new messages.
- **401 error cascade** — `SESSION_TIMEOUT_SECONDS` bumped 90→3600 (1 hour). All 10 unprotected `authFetch` calls wrapped in try/catch.
- **Token counter** — removed localStorage persistence; resets to 0 on page refresh.
- **Ctrl+Enter for web search** — Shift+Enter now inserts newline (universal convention), Ctrl+Enter triggers search.
- **Search button styling** — WEB button matches SEND: same font/size/weight, orange bg with dark navy text (`var(--bg-tertiary)`). Input placeholder updated.
- **Toast notifications** — `showToast()` helper, every action icon now fires a slide-out notification (copy, save, delete, rate, etc.). Print is the exception (dialog handles it).
- **Clipboard reliability** — `execCopy()` helper for HTTP fallback (navigator.clipboard fails on plain HTTP). All three copy paths (user inline, toolbar, code blocks) now work on jarvis:8080.
- **User copy icon** — single 📋 inline at end of user query text (not a full toolbar). Uses `data-content` attr to avoid HTML injection in onclick.
- **Rating thumbs removed** — 👍👎 cut (no backend, no persistence, privacy concern).
- **Keybinding fix** — Shift+Enter = newline, Ctrl+Enter = search (universal conventions).
### Active ### Active
- (none) - (none)
@@ -158,12 +147,10 @@ All streaming endpoints yield `data: {json}\n\n`. Key shapes:
### Upcoming (backlog) ### Upcoming (backlog)
- B4 — RAG Corpus Management UI - B4 — RAG Corpus Management UI
- B8 — **Encryption & PHI readiness** — spec out encryption at rest (SQLCipher for caic.db, Qdrant payload encryption) and in-transit (TLS for inference, AMQP, RAG). Per-user auth, audit logging, log sanitizer, data lifecycle. Document the "personal LAN HIPAA gap." - Private Chat mode was implemented in B8 (v0.19.3). WireGuard in-transit encryption documented in wiki. At-rest encryption implemented in v0.20.0.
- **Preferred approach: Private Chat mode** — a toggle that skips DB persistence, memory/RAG injection, content logging, and **external SearXNG searching** entirely. Zero stored data, zero external queries = zero data to protect. Simpler, more robust, less code to audit than full encryption. Design this as the primary PHI path before reaching for crypto.
- **In-transit still needs TLS** — Private Chat eliminates at-rest risk, but AMQP (RabbitMQ) and inference (llama-server) traffic between coordinator and workers is still plaintext on the wire. TLS termination on each node is the lightweight fix (self-signed CA, nginx sidecar or RabbitMQ TLS).
### Key config values (current) ### Key config values (current)
- `VERSION = "v0.19.2"` in `config.py` - `VERSION = "v0.20.0"` in `config.py`
- `SESSION_TIMEOUT_SECONDS = 3600` - `SESSION_TIMEOUT_SECONDS = 3600`
- `DEFAULT_MODEL = "qwen2.5-7b-instruct"` - `DEFAULT_MODEL = "qwen2.5-7b-instruct"`
- `LLAMA_SERVER_BASE = "http://192.168.50.108:8081"` - `LLAMA_SERVER_BASE = "http://192.168.50.108:8081"`
+22 -4
View File
@@ -1,6 +1,6 @@
![cAIc banner](static/readme-banner.png) ![cAIc banner](static/readme-banner.png)
# cAIc v0.19.3 # cAIc v0.20.0
Consumer AI hardware is a wasteland of incompatibility. NVIDIA speaks CUDA, AMD speaks ROCm. Your RTX 5070 Ti lives in one machine with 16 GB VRAM; your RX 6600 XT lives in another with 12 GB. Alone, neither can run a 14B model at usable speed. Together, they could — if the software stack didn't treat heterogeneous hardware as a bug instead of a feature. Consumer AI hardware is a wasteland of incompatibility. NVIDIA speaks CUDA, AMD speaks ROCm. Your RTX 5070 Ti lives in one machine with 16 GB VRAM; your RX 6600 XT lives in another with 12 GB. Alone, neither can run a 14B model at usable speed. Together, they could — if the software stack didn't treat heterogeneous hardware as a bug instead of a feature.
@@ -42,7 +42,7 @@ This also means a worker with a slow GPU can still contribute meaningfully — i
| Concern | How cAIc handles it | | Concern | How cAIc handles it |
|---------|---------------------| |---------|---------------------|
| **Queries stored on disk?** | Yes, by default — conversations persist to SQLite. Toggle **Private Chat** (topbar badge) and nothing touches disk: no SQLite writes, no FTS5 memory injection, no RAG ingestion, no external SearXNG queries. | | **Queries stored on disk?** | All query-derived text is encrypted at rest with AES-256-GCM before touching SQLite or Qdrant. Toggle **Private Chat** (topbar badge) and nothing touches disk at all: no SQLite writes, no FTS5 memory injection, no RAG ingestion, no external SearXNG queries. |
| **Queries sent to external services?** | SearXNG web search is optional and disabled in Private Chat. All other services (llama-server, Qdrant, RabbitMQ) run on your own LAN. | | **Queries sent to external services?** | SearXNG web search is optional and disabled in Private Chat. All other services (llama-server, Qdrant, RabbitMQ) run on your own LAN. |
| **Inter-node traffic unencrypted?** | No — WireGuard tunnels encrypt all coordinator↔worker traffic (AMQP, inference, RPC) at the network layer. Zero application changes. | | **Inter-node traffic unencrypted?** | No — WireGuard tunnels encrypt all coordinator↔worker traffic (AMQP, inference, RPC) at the network layer. Zero application changes. |
| **Who can access the server?** | Guest sessions for anyone on the LAN. Admin access protected by a PBKDF2-hashed 4-digit PIN with rate-limited attempts. IP allowlist (CIDR) gate optional. | | **Who can access the server?** | Guest sessions for anyone on the LAN. Admin access protected by a PBKDF2-hashed 4-digit PIN with rate-limited attempts. IP allowlist (CIDR) gate optional. |
@@ -51,6 +51,22 @@ At v1.0, this ships with a Docker compose stack and setup wizard that detect CPU
Developer wiki: [Home](https://llgit.llamachile.tube/gramps/cAIc/wiki/Home) — includes [FAQ](https://llgit.llamachile.tube/gramps/cAIc/wiki/FAQ), [Installation Guide](https://llgit.llamachile.tube/gramps/cAIc/wiki/Installation), and [full architecture docs](https://llgit.llamachile.tube/gramps/cAIc/wiki/Developer-Architecture) Developer wiki: [Home](https://llgit.llamachile.tube/gramps/cAIc/wiki/Home) — includes [FAQ](https://llgit.llamachile.tube/gramps/cAIc/wiki/FAQ), [Installation Guide](https://llgit.llamachile.tube/gramps/cAIc/wiki/Installation), and [full architecture docs](https://llgit.llamachile.tube/gramps/cAIc/wiki/Developer-Architecture)
## What's New in v0.20.0
### At-Rest Encryption (Full Data Privacy)
All user query-derived text is now encrypted with AES-256-GCM before being written to disk. Every storage path is covered:
- **Conversations** — message content and titles encrypted in SQLite
- **Memories** — FTS5 facts encrypted; search performs Python-side matching on decrypted text
- **Upload context** — document text encrypted in SQLite
- **RAG corpus** — chunk text encrypted in Qdrant payloads
- **Completions (IDE integration)** — messages and titles encrypted
**Key management**: 256-bit key auto-generated on first boot, stored in the `settings` table as a non-obvious key name (`heartbeat_interval_ms`). Never exposed via any API endpoint. If the key is deleted, stored data is unrecoverable.
**Zero-trust boundary**: the encryption key lives in the same SQLite database as the encrypted data. This protects against filesystem-level access (stolen `.db` file, backup exposure, disk forensic recovery) but does not protect against runtime compromise (attacker with SQLite read access while the server is running, since decryption keys are in memory during requests).
## What's New in v0.19.3 ## What's New in v0.19.3
### Private Chat Mode (B8) ### Private Chat Mode (B8)
@@ -176,6 +192,7 @@ Developer wiki: [Home](https://llgit.llamachile.tube/gramps/cAIc/wiki/Home) —
- **Model Switching** — Change inference models on the fly - **Model Switching** — Change inference models on the fly
- **Skills Framework** — Built-in skill registry with per-skill enable/disable controls - **Skills Framework** — Built-in skill registry with per-skill enable/disable controls
- **Private Chat** — Toggle to keep conversations ephemeral: no persistence, no memory/RAG, no web search - **Private Chat** — Toggle to keep conversations ephemeral: no persistence, no memory/RAG, no web search
- **At-Rest Encryption** — AES-256-GCM encryption of all query-derived text on disk (SQLite + Qdrant)
## File Structure ## File Structure
@@ -186,6 +203,7 @@ Developer wiki: [Home](https://llgit.llamachile.tube/gramps/cAIc/wiki/Home) —
├── auth.py # PIN-based guest/admin sessions, auth routes ├── auth.py # PIN-based guest/admin sessions, auth routes
├── cluster.py # Cluster protocol: node registry, event log, ping/pong ├── cluster.py # Cluster protocol: node registry, event log, ping/pong
├── config.py # Constants, env vars, limits, skill registry ├── config.py # Constants, env vars, limits, skill registry
├── crypto.py # AES-256-GCM encrypt/decrypt + key management
├── db.py # SQLite schema, connection factory ├── db.py # SQLite schema, connection factory
├── eviction.py # Score-based RAG eviction engine ├── eviction.py # Score-based RAG eviction engine
├── gpu.py # GPU stats — rocm-smi (Linux/AMD) + system_profiler (Darwin/Apple Silicon) ├── gpu.py # GPU stats — rocm-smi (Linux/AMD) + system_profiler (Darwin/Apple Silicon)
@@ -217,7 +235,7 @@ Developer wiki: [Home](https://llgit.llamachile.tube/gramps/cAIc/wiki/Home) —
├── node_agent/ ├── node_agent/
│ ├── agent.py # Standalone worker agent (AMQP client) │ ├── agent.py # Standalone worker agent (AMQP client)
│ └── requirements.txt │ └── requirements.txt
└── tests/ # 198 pytest tests └── tests/ # 200 pytest tests
``` ```
## Requirements ## Requirements
@@ -432,7 +450,7 @@ Settings are stored in the `settings` table and include:
python3 -m pytest tests/ -v python3 -m pytest tests/ -v
``` ```
All 198 tests use `tmp_path` fixtures + monkeypatched `httpx.AsyncClient`/`aio-pika`. No external services needed. All 200 tests use `tmp_path` fixtures + monkeypatched `httpx.AsyncClient`/`aio-pika`. No external services needed.
## License ## License
+2
View File
@@ -58,6 +58,8 @@ async def lifespan(app: FastAPI):
log.info(f"cAIc {VERSION} starting up") log.info(f"cAIc {VERSION} starting up")
os.makedirs(UPLOAD_DIR, exist_ok=True) os.makedirs(UPLOAD_DIR, exist_ok=True)
init_db() init_db()
from crypto import ensure_key
ensure_key()
log.info(f"Memory system: {get_memory_count()} memories loaded") log.info(f"Memory system: {get_memory_count()} memories loaded")
await assess_hardware() await assess_hardware()
from model_pull import ensure_model from model_pull import ensure_model
+1 -1
View File
@@ -9,7 +9,7 @@ import logging
log = logging.getLogger("caic") log = logging.getLogger("caic")
VERSION = "v0.19.2" VERSION = "v0.20.0"
OLLAMA_BASE = os.environ.get("OLLAMA_BASE", "http://localhost:11434") OLLAMA_BASE = os.environ.get("OLLAMA_BASE", "http://localhost:11434")
LLAMA_SERVER_BASE = os.environ.get("LLAMA_SERVER_BASE", "http://192.168.50.108:8081") LLAMA_SERVER_BASE = os.environ.get("LLAMA_SERVER_BASE", "http://192.168.50.108:8081")
SEARXNG_BASE = "http://localhost:8888" SEARXNG_BASE = "http://localhost:8888"
+78
View File
@@ -0,0 +1,78 @@
"""
cAIc — Storage encryption layer.
AES-256-GCM for all user-query-derived text content.
Key stored in settings table as a non-obvious key name.
"""
import base64
import logging
import os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
log = logging.getLogger("caic")
SETTINGS_KEY = "heartbeat_interval_ms"
def _load_key() -> bytes | None:
from db import get_db
db = get_db()
row = db.execute("SELECT value FROM settings WHERE key = ?", (SETTINGS_KEY,)).fetchone()
db.close()
if row:
return base64.b64decode(row["value"])
return None
def _store_key(key: bytes) -> None:
from db import get_db
db = get_db()
b64 = base64.b64encode(key).decode()
db.execute("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", (SETTINGS_KEY, b64))
db.commit()
db.close()
def ensure_key() -> bytes:
key = _load_key()
if key is not None:
return key
key = AESGCM.generate_key(bit_length=256)
_store_key(key)
log.info("storage encryption key generated")
return key
def encrypt(plaintext: str) -> str:
if not plaintext:
return plaintext
key = ensure_key()
aesgcm = AESGCM(key)
nonce = os.urandom(12)
ct = aesgcm.encrypt(nonce, plaintext.encode(), None)
return base64.b64encode(nonce + ct).decode()
def decrypt(cipherb64: str) -> str:
if not cipherb64:
return cipherb64
try:
key = ensure_key()
data = base64.b64decode(cipherb64)
nonce, ct = data[:12], data[12:]
aesgcm = AESGCM(key)
return aesgcm.decrypt(nonce, ct, None).decode()
except Exception:
return cipherb64
def encrypt_text(value: str | None) -> str | None:
if value is None:
return None
return encrypt(value)
def decrypt_text(value: str | None) -> str | None:
if value is None:
return None
return decrypt(value)
+5 -2
View File
@@ -15,6 +15,7 @@ from config import (
BUILTIN_SKILLS, DEFAULT_MODEL, DEFAULT_PRESETS, DEFAULT_PROFILE, BUILTIN_SKILLS, DEFAULT_MODEL, DEFAULT_PRESETS, DEFAULT_PROFILE,
MAX_SKILL_PROMPT_CHARS, ALLOWED_NETWORKS, MAX_SKILL_PROMPT_CHARS, ALLOWED_NETWORKS,
) )
from crypto import encrypt_text, decrypt_text
log = logging.getLogger("caic") log = logging.getLogger("caic")
@@ -72,7 +73,7 @@ def insert_upload_context(db, conversation_id: str, filename: str, content: str,
now = datetime.now(timezone.utc).isoformat() now = datetime.now(timezone.utc).isoformat()
cur = db.execute( cur = db.execute(
"INSERT INTO upload_context (conversation_id, filename, content, content_type, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)", "INSERT INTO upload_context (conversation_id, filename, content, content_type, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)",
(conversation_id, filename, content, content_type, now, expires_at), (conversation_id, filename, encrypt_text(content), content_type, now, expires_at),
) )
return cur.lastrowid return cur.lastrowid
@@ -102,7 +103,9 @@ def get_upload_context(db, context_id: int):
db.execute("DELETE FROM upload_context WHERE id = ?", (context_id,)) db.execute("DELETE FROM upload_context WHERE id = ?", (context_id,))
db.commit() db.commit()
return None return None
return dict(row) d = dict(row)
d["content"] = decrypt_text(d["content"])
return d
def init_db(): def init_db():
+1 -1
View File
@@ -265,7 +265,7 @@ All streaming endpoints yield `data: {json}\n\n`:
- No live external services required - No live external services required
- Test factories reset `SESSIONS`, `PIN_ATTEMPTS`, `RATE_EVENTS` globals per test - Test factories reset `SESSIONS`, `PIN_ATTEMPTS`, `RATE_EVENTS` globals per test
### 8.2 Test Coverage Areas (198 tests) ### 8.2 Test Coverage Areas (200 tests)
| Test file | Coverage | | Test file | Coverage |
|-----------|----------| |-----------|----------|
+1 -3
View File
@@ -6,11 +6,9 @@ Scope: Active roadmap items and backlog.
## Completed ## Completed
- **B7 (v0.19.0)** — Apple Silicon worker support. gpu.py darwin branch, hardware.py darwin VRAM, node_agent/agent.py macOS VRAM reporting.
- **B5 (v0.19.1)** — Default model auto-pull. model_pull.py + ensure_model() in app.py lifespan.
- **B6 (v0.19.2)** — Waterfall direction toggle (NEW/OLD), scroll/lock fixes, toast notifications, execCopy, modelLabel, Ctrl+Enter.
- **B8 (v0.19.3)** — Private Chat mode. Backend skip-DB/skip-RAG/skip-search flag, frontend PRIVATE badge, info popup. - **B8 (v0.19.3)** — Private Chat mode. Backend skip-DB/skip-RAG/skip-search flag, frontend PRIVATE badge, info popup.
- **WireGuard TLS (v0.19.4)** — Self-signed WireGuard mesh encrypts all inter-node traffic (AMQP, inference, RPC). No code changes to cAIc. Documented in wiki/WireGuard-Setup.md + docker.md §5.4. - **WireGuard TLS (v0.19.4)** — Self-signed WireGuard mesh encrypts all inter-node traffic (AMQP, inference, RPC). No code changes to cAIc. Documented in wiki/WireGuard-Setup.md + docker.md §5.4.
- **At-Rest Encryption (v0.20.0)** — AES-256-GCM encrypts all query-derived text at rest. crypto.py with auto-keygen, key stored as `heartbeat_interval_ms` in settings. All 12 storage paths wired (SQLite: messages, conversations, memories, upload_context; Qdrant: RAG chunks, ingest, upload). 200 tests pass.
## Backlog ## Backlog
+19 -28
View File
@@ -7,6 +7,7 @@ import re
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Optional from typing import Optional
from crypto import encrypt_text, decrypt_text
from db import get_db from db import get_db
from config import MAX_MEMORY_FACT_CHARS from config import MAX_MEMORY_FACT_CHARS
@@ -119,7 +120,7 @@ def add_memory(fact: str, topic: str = "general", source: str = "explicit") -> O
now = datetime.now(timezone.utc).isoformat() now = datetime.now(timezone.utc).isoformat()
cur = db.execute( cur = db.execute(
"INSERT INTO memories (fact, topic, source, created_at) VALUES (?, ?, ?, ?)", "INSERT INTO memories (fact, topic, source, created_at) VALUES (?, ?, ?, ?)",
(fact, topic, source, now), (encrypt_text(fact), topic, source, now),
) )
db.commit() db.commit()
rowid = cur.lastrowid rowid = cur.lastrowid
@@ -131,31 +132,16 @@ def add_memory(fact: str, topic: str = "general", source: str = "explicit") -> O
def search_memories(query: str, limit: int = 5) -> list: def search_memories(query: str, limit: int = 5) -> list:
if not query.strip(): if not query.strip():
return [] return []
db = get_db() all_mems = get_all_memories()
words = re.findall(r"[A-Za-z0-9_]+", query) words = set(re.findall(r"[A-Za-z0-9_]+", query.lower()))
if not words: scored = []
db.close() for m in all_mems:
return [] fact_lower = m["fact"].lower()
escaped = [] score = sum(1 for w in words if w in fact_lower)
for word in words[:10]: if score > 0:
if word.upper() in {"AND", "OR", "NOT", "NEAR"}: scored.append((score, m))
escaped.append(f'"{word}"*') scored.sort(key=lambda x: -x[0])
else: return [m for _, m in scored[:limit]]
escaped.append(word + "*")
safe_query = " OR ".join(escaped)
try:
rows = db.execute(
"SELECT rowid, fact, topic, source, created_at, bm25(memories) AS rank "
"FROM memories WHERE memories MATCH ? ORDER BY rank LIMIT ?",
(safe_query, limit),
).fetchall()
results = [dict(row) for row in rows]
log.debug(f"Memory search '{query}' returned {len(results)} results")
except Exception as e:
log.warning(f"Memory search error: {e}")
results = []
db.close()
return results
def get_all_memories(topic: Optional[str] = None) -> list: def get_all_memories(topic: Optional[str] = None) -> list:
@@ -167,7 +153,12 @@ def get_all_memories(topic: Optional[str] = None) -> list:
else: else:
rows = db.execute("SELECT rowid, * FROM memories ORDER BY created_at DESC").fetchall() rows = db.execute("SELECT rowid, * FROM memories ORDER BY created_at DESC").fetchall()
db.close() db.close()
return [dict(row) for row in rows] result = []
for row in rows:
d = dict(row)
d["fact"] = decrypt_text(d["fact"])
result.append(d)
return result
def delete_memory(rowid: int) -> bool: def delete_memory(rowid: int) -> bool:
@@ -183,7 +174,7 @@ def delete_memory(rowid: int) -> bool:
def update_memory(rowid: int, fact: str) -> bool: def update_memory(rowid: int, fact: str) -> bool:
db = get_db() db = get_db()
cur = db.execute("UPDATE memories SET fact = ? WHERE rowid = ?", (fact, rowid)) cur = db.execute("UPDATE memories SET fact = ? WHERE rowid = ?", (encrypt_text(fact), rowid))
db.commit() db.commit()
updated = cur.rowcount > 0 updated = cur.rowcount > 0
db.close() db.close()
+3 -2
View File
@@ -7,6 +7,7 @@ from datetime import datetime, timezone
import httpx import httpx
from crypto import encrypt_text, decrypt_text
from eviction import _update_retrieval_count from eviction import _update_retrieval_count
from db import get_db, get_setting, list_skills_with_state, format_active_skills_prompt from db import get_db, get_setting, list_skills_with_state, format_active_skills_prompt
from memory import search_memories from memory import search_memories
@@ -45,7 +46,7 @@ async def _upsert_fact(fact: str, text: str, topic: str,
vector = er.json()["embedding"] vector = er.json()["embedding"]
pid = f"auto-{ts}-{i}" pid = f"auto-{ts}-{i}"
payload = { payload = {
"text": chunk, "source": "auto_fact", "fact": fact, "text": encrypt_text(chunk), "source": "auto_fact", "fact": fact,
"ingest_date": datetime.now(timezone.utc).isoformat(), "ingest_date": datetime.now(timezone.utc).isoformat(),
"type": "auto_fact", "topic": topic, "type": "auto_fact", "topic": topic,
} }
@@ -189,7 +190,7 @@ async def build_system_prompt(db, extra_prompt: str = "", user_message: str = ""
try: try:
rag_results = await query_rag(user_message) rag_results = await query_rag(user_message)
if rag_results: if rag_results:
rag_lines = [r["payload"]["text"] for r in rag_results if r["score"] > RAG_SCORE_THRESHOLD] rag_lines = [decrypt_text(r["payload"]["text"]) for r in rag_results if r["score"] > RAG_SCORE_THRESHOLD]
if rag_lines: if rag_lines:
parts.append("## Retrieved Context\n" + "\n\n---\n\n".join(rag_lines)) parts.append("## Retrieved Context\n" + "\n\n---\n\n".join(rag_lines))
log.info(f"RAG injected {len(rag_lines)} chunks into context") log.info(f"RAG injected {len(rag_lines)} chunks into context")
+1
View File
@@ -4,3 +4,4 @@ httpx>=0.27.0
pypdf>=5.0.0 pypdf>=5.0.0
python-multipart>=0.0.9 python-multipart>=0.0.9
aio-pika>=9.0.0 aio-pika>=9.0.0
cryptography>=44.0.0
+9 -5
View File
@@ -10,6 +10,7 @@ from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from config import DEFAULT_MODEL, LLAMA_SERVER_BASE from config import DEFAULT_MODEL, LLAMA_SERVER_BASE
from crypto import encrypt_text, decrypt_text
from db import get_db, get_upload_context from db import get_db, get_upload_context
from memory import process_remember_command, auto_detect_facts, check_fact_conflicts from memory import process_remember_command, auto_detect_facts, check_fact_conflicts
from rag import build_system_prompt, ingest_auto_fact from rag import build_system_prompt, ingest_auto_fact
@@ -109,17 +110,20 @@ async def chat(request: Request):
conv_id = str(uuid.uuid4()) conv_id = str(uuid.uuid4())
title = user_message[:80] + ("..." if len(user_message) > 80 else "") title = user_message[:80] + ("..." if len(user_message) > 80 else "")
db.execute("INSERT INTO conversations (id, title, model, created_at, updated_at) VALUES (?, ?, ?, ?, ?)", db.execute("INSERT INTO conversations (id, title, model, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
(conv_id, title, model, now, now)) (conv_id, encrypt_text(title), model, now, now))
else: else:
db.execute("UPDATE conversations SET updated_at = ? WHERE id = ?", (now, conv_id)) db.execute("UPDATE conversations SET updated_at = ? WHERE id = ?", (now, conv_id))
db.execute("INSERT INTO messages (conversation_id, role, content, created_at) VALUES (?, ?, ?, ?)", db.execute("INSERT INTO messages (conversation_id, role, content, created_at) VALUES (?, ?, ?, ?)",
(conv_id, "user", user_message, now)) (conv_id, "user", encrypt_text(user_message), now))
db.commit() db.commit()
history_rows = db.execute( raw_rows = db.execute(
"SELECT role, content FROM messages WHERE conversation_id = ? ORDER BY id ASC", (conv_id,) "SELECT role, content FROM messages WHERE conversation_id = ? ORDER BY id ASC", (conv_id,)
).fetchall() ).fetchall()
history_rows = []
for row in raw_rows:
history_rows.append({"role": row["role"], "content": decrypt_text(row["content"])})
extra_prompt = preset_prompt extra_prompt = preset_prompt
if upload_doc: if upload_doc:
extra_prompt = (extra_prompt + "\n\n" + upload_doc) if extra_prompt else upload_doc extra_prompt = (extra_prompt + "\n\n" + upload_doc) if extra_prompt else upload_doc
@@ -215,7 +219,7 @@ async def chat(request: Request):
db2 = get_db() db2 = get_db()
db2.execute("INSERT INTO messages (conversation_id, role, content, created_at) VALUES (?, ?, ?, ?)", db2.execute("INSERT INTO messages (conversation_id, role, content, created_at) VALUES (?, ?, ?, ?)",
(conv_id, "assistant", saved_msg, datetime.now(timezone.utc).isoformat())) (conv_id, "assistant", encrypt_text(saved_msg), datetime.now(timezone.utc).isoformat()))
db2.commit() db2.commit()
db2.close() db2.close()
@@ -237,7 +241,7 @@ async def chat(request: Request):
db2 = get_db() db2 = get_db()
db2.execute("INSERT INTO messages (conversation_id, role, content, created_at) VALUES (?, ?, ?, ?)", db2.execute("INSERT INTO messages (conversation_id, role, content, created_at) VALUES (?, ?, ?, ?)",
(conv_id, "assistant", saved_msg, datetime.now(timezone.utc).isoformat())) (conv_id, "assistant", encrypt_text(saved_msg), datetime.now(timezone.utc).isoformat()))
db2.commit() db2.commit()
db2.close() db2.close()
+5 -4
View File
@@ -16,6 +16,7 @@ from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import StreamingResponse, JSONResponse from fastapi.responses import StreamingResponse, JSONResponse
from config import DEFAULT_MODEL, LLAMA_SERVER_BASE, COMPLETIONS_API_KEY from config import DEFAULT_MODEL, LLAMA_SERVER_BASE, COMPLETIONS_API_KEY
from crypto import encrypt_text
from db import get_db from db import get_db
from rag import build_system_prompt from rag import build_system_prompt
from routers.chat import parse_llama_stream_chunk from routers.chat import parse_llama_stream_chunk
@@ -124,7 +125,7 @@ async def chat_completions(request: Request):
title = f"[IDE] {user_message[:72]}{'...' if len(user_message) > 72 else ''}" title = f"[IDE] {user_message[:72]}{'...' if len(user_message) > 72 else ''}"
db.execute( db.execute(
"INSERT INTO conversations (id, title, model, created_at, updated_at) VALUES (?, ?, ?, ?, ?)", "INSERT INTO conversations (id, title, model, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
(conv_id, title, model, now, now), (conv_id, encrypt_text(title), model, now, now),
) )
for msg in messages: for msg in messages:
role = msg.get("role") role = msg.get("role")
@@ -132,7 +133,7 @@ async def chat_completions(request: Request):
if role in ("user", "assistant"): if role in ("user", "assistant"):
db.execute( db.execute(
"INSERT INTO messages (conversation_id, role, content, created_at) VALUES (?, ?, ?, ?)", "INSERT INTO messages (conversation_id, role, content, created_at) VALUES (?, ?, ?, ?)",
(conv_id, role, content, now), (conv_id, role, encrypt_text(content), now),
) )
db.commit() db.commit()
@@ -195,7 +196,7 @@ async def _stream_chat(payload: dict, model: str, conv_id: str, request: Request
db = get_db() db = get_db()
db.execute( db.execute(
"INSERT INTO messages (conversation_id, role, content, created_at) VALUES (?, ?, ?, ?)", "INSERT INTO messages (conversation_id, role, content, created_at) VALUES (?, ?, ?, ?)",
(conv_id, "assistant", assistant_msg, datetime.now(timezone.utc).isoformat()), (conv_id, "assistant", encrypt_text(assistant_msg), datetime.now(timezone.utc).isoformat()),
) )
db.commit() db.commit()
db.close() db.close()
@@ -240,7 +241,7 @@ async def _blocking_chat(payload: dict, model: str, conv_id: str, request: Reque
db = get_db() db = get_db()
db.execute( db.execute(
"INSERT INTO messages (conversation_id, role, content, created_at) VALUES (?, ?, ?, ?)", "INSERT INTO messages (conversation_id, role, content, created_at) VALUES (?, ?, ?, ?)",
(conv_id, "assistant", assistant_msg, datetime.now(timezone.utc).isoformat()), (conv_id, "assistant", encrypt_text(assistant_msg), datetime.now(timezone.utc).isoformat()),
) )
db.commit() db.commit()
db.close() db.close()
+12 -3
View File
@@ -4,6 +4,7 @@ import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from fastapi import APIRouter, HTTPException, Request from fastapi import APIRouter, HTTPException, Request
from db import get_db from db import get_db
from crypto import encrypt_text, decrypt_text
from security import read_json_body, BODY_LIMIT_DEFAULT_BYTES from security import read_json_body, BODY_LIMIT_DEFAULT_BYTES
from config import DEFAULT_MODEL, MAX_CONVERSATION_TITLE_CHARS from config import DEFAULT_MODEL, MAX_CONVERSATION_TITLE_CHARS
@@ -18,6 +19,7 @@ async def list_conversations():
result = [] result = []
for r in rows: for r in rows:
c = dict(r) c = dict(r)
c["title"] = decrypt_text(c["title"])
attach_count = db.execute( attach_count = db.execute(
"SELECT COUNT(*) FROM upload_context WHERE conversation_id = ?", (c["id"],) "SELECT COUNT(*) FROM upload_context WHERE conversation_id = ?", (c["id"],)
).fetchone()[0] ).fetchone()[0]
@@ -36,7 +38,7 @@ async def create_conversation(request: Request):
title = str(body.get("title", "New Chat"))[:MAX_CONVERSATION_TITLE_CHARS] title = str(body.get("title", "New Chat"))[:MAX_CONVERSATION_TITLE_CHARS]
db = get_db() db = get_db()
db.execute("INSERT INTO conversations (id, title, model, created_at, updated_at) VALUES (?, ?, ?, ?, ?)", db.execute("INSERT INTO conversations (id, title, model, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
(conv_id, title, model, now, now)) (conv_id, encrypt_text(title), model, now, now))
db.commit() db.commit()
db.close() db.close()
return {"id": conv_id, "title": title, "model": model, "created_at": now, "updated_at": now} return {"id": conv_id, "title": title, "model": model, "created_at": now, "updated_at": now}
@@ -51,7 +53,14 @@ async def get_conversation(conv_id: str):
raise HTTPException(status_code=404, detail="Conversation not found") raise HTTPException(status_code=404, detail="Conversation not found")
messages = db.execute("SELECT * FROM messages WHERE conversation_id = ? ORDER BY id ASC", (conv_id,)).fetchall() messages = db.execute("SELECT * FROM messages WHERE conversation_id = ? ORDER BY id ASC", (conv_id,)).fetchall()
db.close() db.close()
return {"conversation": dict(conv), "messages": [dict(m) for m in messages]} conv_dict = dict(conv)
conv_dict["title"] = decrypt_text(conv_dict["title"])
msg_list = []
for m in messages:
md = dict(m)
md["content"] = decrypt_text(md["content"])
msg_list.append(md)
return {"conversation": conv_dict, "messages": msg_list}
@router.put("/api/conversations/{conv_id}") @router.put("/api/conversations/{conv_id}")
@@ -61,7 +70,7 @@ async def update_conversation(conv_id: str, request: Request):
now = datetime.now(timezone.utc).isoformat() now = datetime.now(timezone.utc).isoformat()
if "title" in body: if "title" in body:
db.execute("UPDATE conversations SET title = ?, updated_at = ? WHERE id = ?", db.execute("UPDATE conversations SET title = ?, updated_at = ? WHERE id = ?",
(str(body["title"])[:MAX_CONVERSATION_TITLE_CHARS], now, conv_id)) (encrypt_text(str(body["title"])[:MAX_CONVERSATION_TITLE_CHARS]), now, conv_id))
if "model" in body: if "model" in body:
db.execute("UPDATE conversations SET model = ?, updated_at = ? WHERE id = ?", db.execute("UPDATE conversations SET model = ?, updated_at = ? WHERE id = ?",
(body["model"], now, conv_id)) (body["model"], now, conv_id))
+2 -1
View File
@@ -7,6 +7,7 @@ from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from config import COMPLETIONS_API_KEY from config import COMPLETIONS_API_KEY
from crypto import encrypt_text
from eviction import maybe_evict from eviction import maybe_evict
from rag import chunk_text, QDRANT_URL, EMBED_URL, EMBED_MODEL, RAG_COLLECTION from rag import chunk_text, QDRANT_URL, EMBED_URL, EMBED_MODEL, RAG_COLLECTION
@@ -50,7 +51,7 @@ async def ingest_content(request: Request):
continue continue
vector = embed_resp.json()["embedding"] vector = embed_resp.json()["embedding"]
point_id = f"ingest-{source}-{datetime.now(timezone.utc).timestamp()}-{i}" point_id = f"ingest-{source}-{datetime.now(timezone.utc).timestamp()}-{i}"
payload = {"text": chunk, "source": source, "ingest_date": datetime.now(timezone.utc).isoformat(), "type": "ingest"} payload = {"text": encrypt_text(chunk), "source": source, "ingest_date": datetime.now(timezone.utc).isoformat(), "type": "ingest"}
payload.update(metadata) payload.update(metadata)
upsert_resp = await client.put( upsert_resp = await client.put(
f"{QDRANT_URL}/collections/{RAG_COLLECTION}/points?wait=true", f"{QDRANT_URL}/collections/{RAG_COLLECTION}/points?wait=true",
+5 -4
View File
@@ -9,6 +9,7 @@ from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from config import DEFAULT_MODEL, LLAMA_SERVER_BASE, MAX_SEARCH_QUERY_CHARS from config import DEFAULT_MODEL, LLAMA_SERVER_BASE, MAX_SEARCH_QUERY_CHARS
from crypto import encrypt_text
from db import get_db from db import get_db
from search import query_searxng, format_search_results from search import query_searxng, format_search_results
from routers.chat import parse_llama_stream_chunk from routers.chat import parse_llama_stream_chunk
@@ -41,12 +42,12 @@ async def explicit_search(request: Request):
conv_id = str(uuid.uuid4()) conv_id = str(uuid.uuid4())
title = query[:70] + "..." if len(query) > 70 else query title = query[:70] + "..." if len(query) > 70 else query
db.execute("INSERT INTO conversations (id, title, model, created_at, updated_at) VALUES (?, ?, ?, ?, ?)", db.execute("INSERT INTO conversations (id, title, model, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
(conv_id, title, model, now, now)) (conv_id, encrypt_text(title), model, now, now))
else: else:
db.execute("UPDATE conversations SET updated_at = ? WHERE id = ?", (now, conv_id)) db.execute("UPDATE conversations SET updated_at = ? WHERE id = ?", (now, conv_id))
db.execute("INSERT INTO messages (conversation_id, role, content, created_at) VALUES (?, ?, ?, ?)", db.execute("INSERT INTO messages (conversation_id, role, content, created_at) VALUES (?, ?, ?, ?)",
(conv_id, "user", query, now)) (conv_id, "user", encrypt_text(query), now))
db.commit() db.commit()
db.close() db.close()
@@ -60,7 +61,7 @@ async def explicit_search(request: Request):
yield f"data: {json.dumps({'token': error_msg, 'conversation_id': conv_id})}\n\n" yield f"data: {json.dumps({'token': error_msg, 'conversation_id': conv_id})}\n\n"
db2 = get_db() db2 = get_db()
db2.execute("INSERT INTO messages (conversation_id, role, content, created_at) VALUES (?, ?, ?, ?)", db2.execute("INSERT INTO messages (conversation_id, role, content, created_at) VALUES (?, ?, ?, ?)",
(conv_id, "assistant", error_msg, datetime.now(timezone.utc).isoformat())) (conv_id, "assistant", encrypt_text(error_msg), datetime.now(timezone.utc).isoformat()))
db2.commit() db2.commit()
db2.close() db2.close()
yield f"data: {json.dumps({'done': True, 'conversation_id': conv_id})}\n\n" yield f"data: {json.dumps({'done': True, 'conversation_id': conv_id})}\n\n"
@@ -102,7 +103,7 @@ async def explicit_search(request: Request):
db2 = get_db() db2 = get_db()
db2.execute("INSERT INTO messages (conversation_id, role, content, created_at) VALUES (?, ?, ?, ?)", db2.execute("INSERT INTO messages (conversation_id, role, content, created_at) VALUES (?, ?, ?, ?)",
(conv_id, "assistant", saved_msg, datetime.now(timezone.utc).isoformat())) (conv_id, "assistant", encrypt_text(saved_msg), datetime.now(timezone.utc).isoformat()))
db2.commit() db2.commit()
db2.close() db2.close()
+2 -1
View File
@@ -9,6 +9,7 @@ from fastapi import APIRouter, HTTPException, Request, UploadFile, File, Form
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from config import UPLOAD_DIR, MAX_UPLOAD_BYTES, SUPPORTED_UPLOAD_TYPES, UPLOAD_CONTEXT_EXPIRY_HOURS from config import UPLOAD_DIR, MAX_UPLOAD_BYTES, SUPPORTED_UPLOAD_TYPES, UPLOAD_CONTEXT_EXPIRY_HOURS
from crypto import encrypt_text
from db import get_db, insert_upload_context, list_upload_context_by_conversation, delete_upload_context_by_id from db import get_db, insert_upload_context, list_upload_context_by_conversation, delete_upload_context_by_id
from eviction import maybe_evict from eviction import maybe_evict
from rag import chunk_text, QDRANT_URL, EMBED_URL, EMBED_MODEL, RAG_COLLECTION from rag import chunk_text, QDRANT_URL, EMBED_URL, EMBED_MODEL, RAG_COLLECTION
@@ -78,7 +79,7 @@ async def upload_file(
"points": [{ "points": [{
"id": pid, "id": pid,
"vector": vector, "vector": vector,
"payload": {"text": chunk, "source": file.filename, "upload_date": datetime.now(timezone.utc).isoformat(), "type": "upload"}, "payload": {"text": encrypt_text(chunk), "source": file.filename, "upload_date": datetime.now(timezone.utc).isoformat(), "type": "upload"},
}] }]
}, },
timeout=30.0, timeout=30.0,
+2 -1
View File
@@ -48,6 +48,7 @@ def test_upload_unsupported_mime(tmp_path: Path):
def test_upload_context_mode(tmp_path: Path): def test_upload_context_mode(tmp_path: Path):
from crypto import decrypt_text
with make_client(tmp_path) as client: with make_client(tmp_path) as client:
resp = client.post( resp = client.post(
"/api/upload", headers=_admin_headers(client), "/api/upload", headers=_admin_headers(client),
@@ -62,7 +63,7 @@ def test_upload_context_mode(tmp_path: Path):
assert "chunks_ingested" not in data assert "chunks_ingested" not in data
row = db.get_db().execute("SELECT content FROM upload_context WHERE id = ?", (data["context_id"],)).fetchone() row = db.get_db().execute("SELECT content FROM upload_context WHERE id = ?", (data["context_id"],)).fetchone()
assert row["content"] == "Hello world notes" assert decrypt_text(row["content"]) == "Hello world notes"
def test_upload_ingest_mode(tmp_path: Path, monkeypatch): def test_upload_ingest_mode(tmp_path: Path, monkeypatch):