-
Notifications
You must be signed in to change notification settings - Fork 17
Memory
Kai stores three kinds of information about each operator: rules, facts, and conversation history. Rules and conversation history work the same on every install. Facts are where the design has the most moving parts: a local vector store (semantic memory mode), an operator-curated MEMORY.md file (legacy mode), a toggle between the two, and a one-shot migration path from one to the other.
Kai has three context layers per turn:
-
Rules. Always-on directives that govern Kai's behavior. Universal rules ship in
CLAUDE.md(loaded as the inner Claude's system prompt at session start). Per-operator rules live inPREFERENCES.md(injected with every user message). -
Facts. What Kai knows about you, the project, decisions, episodes. Either retrieved on similarity from a local vector store or read verbatim from a per-operator markdown file. The choice is operator-controlled via
MEMORY_ENABLED. -
Conversation history. Recent messages from the current chat, injected at session start. Stored as JSONL under
DATA_DIR/history/<chat_id>/. Always on.
Kai's inner Claude never auto-edits any of these. It runs in stream-json mode, which bypasses Claude Code's interactive auto-memory. Every layer above is injected explicitly by the bot process.
MEMORY_ENABLED=true (set in /etc/kai/env or via make config) routes facts through the semantic memory subsystem: MEMORY.md is not injected, the vector store is the only fact surface, and Kai's writes go through the /api/memory/add endpoint.
MEMORY_ENABLED=false runs the original design: MEMORY.md is read at session start and injected on every turn; the assistant edits it via Edit and Write tools when it has new information to save; the vector store is dormant.
The toggle is a full switch with no halfway states. On any given install, exactly one fact surface is active.
When toggling between modes, run the migration script first so the vector store contains the operator's existing curated knowledge:
python scripts/migrate-memory-md-to-qdrant.py --user-id <chat_id>This walks the operator's MEMORY.md, splits it into chunks, and writes each chunk to the vector store with source="migration" metadata. Imported content remains identifiable so it can be rolled back as a unit:
python scripts/migrate-memory-md-to-qdrant.py --user-id <chat_id> --rollbackWhen enabled, Kai writes structured facts to a local Qdrant vector store via Mem0. Storage is fully on-host: no external services, no Docker, no open ports.
-
Vector store.
DATA_DIR/memory/qdrant/(Qdrant embedded, single collectionkai_memory). -
Mem0 history database.
DATA_DIR/memory/mem0_history.db(SQLite, used for dedup and history tracking). -
Embedding model.
all-MiniLM-L6-v2from sentence-transformers, 384 dimensions. Cached under~/.cache/huggingface/after first run.
Every row carries user_id = str(chat_id). Cross-user retrieval is impossible: every read and write filters by user.
| Source | What it is | How it gets there |
|---|---|---|
extracted |
Structured fact extracted from a recent conversation | A one-shot extractor runs after each exchange through the user's configured backend (claude, codex, opencode, or goose) and writes facts (with confidence, tags, prompt version, optional confirmation quote) |
episode |
Per-conversation episode summary | A two-stage extractor classifies whether the conversation was episode-worthy, then writes a Sophia-style record (goal, context, approach, outcome, outcome quality, lessons) |
migration |
Operator-curated content imported from MEMORY.md
|
One-shot via the migration script |
"" (legacy) |
Rows from earlier code paths that predate the source-tagging system | Hidden from the /memory UI; left in place rather than purged |
The extracted and episode sources accumulate continuously. migration is a one-shot import. Legacy rows are filtered out of every user-facing surface.
Every user message triggers a semantic search. The pipeline:
- Embed the user's message with the local embedding model.
-
Search Qdrant for the user's slice; fetch up to
MEMORY_SEARCH_LIMITresults. -
Filter by relevance threshold (
MEMORY_SEARCH_FLOOR). Loosely-relevant noise drops out. - Re-rank with source weights. Extracted and episode rows weighted highest, migration slightly lower, legacy or unknown sources weighted down. Weighting affects ranking only; sub-threshold rows are never rescued.
Top hits get formatted with a short provenance tag (fact, episode, legacy) and date, then injected as a labeled context block at the top of the user's message. The block is explicitly marked as context, not instructions.
Tuning knobs (set in /etc/kai/env via make config):
| Variable | Default | Meaning |
|---|---|---|
MEMORY_ENABLED |
false |
Master toggle |
MEMORY_SEARCH_LIMIT |
10 | Top-K returned to the prompt |
MEMORY_SEARCH_FLOOR |
0.3 | Relevance threshold; clearly relevant scores 0.7+, noise below 0.3 |
MEMORY_TOKEN_BUDGET |
2000 | Maximum tokens of memory context per message |
MEMORY_EXTRACTION_TIMEOUT_S |
10 | Per-turn cap on the extraction call |
MEMORY_EMBEDDING_MODEL |
all-MiniLM-L6-v2 |
Override only if you also rebuild the Qdrant collection at the new dimension |
Every retrieval emits a memory.recall log line with timing, hit counts, query and per-hit snippets, and the rejection reason for short-circuit returns.
After each exchange, an asynchronous extraction pipeline runs. It makes a one-shot call through the user's configured backend (all four backends - claude, codex, opencode, and goose - are extraction-eligible; the model comes from the per-role registry) over the user message plus a capped prefix of Kai's response, and emits zero or more structured facts. Each fact carries:
-
text-- the fact in third-person past tense -
confidence-- the extractor's self-reported confidence in[0.5, 1.0] -
tags-- one to four entries from the closed enum:preference,decision,fact,constraint,confirmed_action,project,location,schedule,relationship -
confirmation_quote-- verbatim text from the conversation when the fact records a confirmed action -
prompt_version-- bumped whenever the extractor prompt changes; visible in/memory statsso prompt-version drift is observable
Extraction is time-capped and runs in the background. Failures don't surface to the user; the conversation continues.
A second-stage classifier runs over the same exchange to decide whether the interaction was episode-worthy: clear goal, attempted approach, observable outcome. When it fires, a separate one-shot call produces a Sophia-style record (goal, context, approach, outcome, outcome quality, lessons, actors, tags). Episodes are written with source="episode" and surface in retrieval alongside extracted facts.
In production, episode rate runs around 0.4 per turn, the rate the design targeted: high enough to capture meaningful patterns, low enough not to flood retrieval.
When MEMORY_ENABLED=false, Kai uses the original design:
-
Storage.
DATA_DIR/memory/<chat_id>/MEMORY.mdper operator. Per-user isolation is enforced at the directory level. - Read path. Injected verbatim at session start.
-
Write path. The assistant uses
EditandWritetools when it has new information to save. There is no auto-edit. -
Bootstrap. A fresh chat ID with no
MEMORY.mdyet seeds fromMEMORY.md.exampleon first message.
In this mode, semantic memory is dormant. The Qdrant directory may exist on disk from a prior run or migration but isn't consulted on any read or write path.
The /memory Telegram command surfaces the semantic store directly to operators. Disabled-mode installs return "Memory is not enabled in this install."
| Subcommand | Purpose |
|---|---|
/memory |
Dashboard: one-line count summary plus a utility row of inline buttons (Facts (N), Episodes (N), Stats) |
/memory stats |
Detailed view: per-source totals, tag distribution, confidence histogram, prompt-version breakdown |
/memory search <query> |
Semantic search across the operator's slice, with relevance scores |
/memory help |
Subcommand reference |
Tapping Facts opens a paginated list of extracted and migration rows; tapping Episodes opens the episode list. Each row drills into a detail view where a per-row Forget button deletes a single record after a confirmation prompt. There is no bulk-by-tag forget; deletions are always per-row.
For full details and examples, see Slash Commands.
-
Per-user. Every read and write filters on
user_id = str(chat_id). Operators cannot see each other's memories. - Local. Qdrant runs embedded in the bot process. The only network calls in the memory path are the extractor's LLM call (to whichever provider the user's backend is configured for) and the HuggingFace embedding model download (one-shot on first install).
-
Operator-controlled. Each operator browses, searches, and deletes their own facts via
/memory. The administrativepython -m kai memory purgecommand exists for source-scoped cleanup but requires explicit--yes.
- Self-assessment. A calibrated capability model that aggregates task outcomes over time. Episodes capture per-interaction outcome quality, which is most of the raw signal; the longitudinal aggregation layer is on the roadmap, not built.
-
Slash Commands -- full
/memoryreference. - System Architecture -- how memory fits into the broader message lifecycle.
- Multi-User Setup -- per-user store paths and isolation guarantees.