- Add botserver/src/basic/keywords/think_kb.rs with structured KB search - Register THINK KB in keywords module and BASIC engine - Add comprehensive documentation in ALWAYS.md and botbook - Include confidence scoring, multi-KB support, and error handling - Add unit tests and example usage script
9.9 KiB
Always-On Memory KB — Implementation Plan
What This Is
The Google always-on-memory-agent runs 3 sub-agents continuously:
- IngestAgent — extracts structured facts from any input (text, files, images, audio, video)
- ConsolidateAgent — runs on a timer, finds connections between memories, generates insights
- QueryAgent — answers questions by synthesizing all memories with citations
The key insight: no vector DB, no embeddings — just an LLM that reads, thinks, and writes structured memory to SQLite. It's active, not passive.
We integrate this as a new KB mode in the existing BASIC keyword system:
USE KB "customer-notes" ALWAYS ON
This turns a KB into a living memory that continuously ingests, consolidates, and is always available in every session for that bot — no USE KB needed per session.
Architecture in GB Context
BASIC script:
USE KB "notes" ALWAYS ON
│
▼
always_on_kb table (bot_id, kb_name, mode=always_on, consolidate_interval_mins)
│
├── IngestWorker (tokio task per bot)
│ watches: /opt/gbo/data/{bot}.gbai/{kb}.gbkb/inbox/
│ on new file → LLM extract → kb_memories table
│
├── ConsolidateWorker (tokio interval per bot)
│ reads: unconsolidated kb_memories
│ LLM finds connections → kb_consolidations table
│
└── QueryEnhancer (in BotOrchestrator::stream_response)
before LLM call → fetch relevant memories + consolidations
inject as system context
Database Migration
File: botserver/migrations/6.2.6-always-on-kb/up.sql
-- Always-on KB configuration per bot
CREATE TABLE IF NOT EXISTS always_on_kb (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
bot_id UUID NOT NULL REFERENCES bots(id) ON DELETE CASCADE,
kb_name TEXT NOT NULL,
consolidate_interval_mins INTEGER NOT NULL DEFAULT 30,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(bot_id, kb_name)
);
-- Individual memory entries (from IngestAgent)
CREATE TABLE IF NOT EXISTS kb_memories (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
bot_id UUID NOT NULL REFERENCES bots(id) ON DELETE CASCADE,
kb_name TEXT NOT NULL,
summary TEXT NOT NULL,
entities JSONB NOT NULL DEFAULT '[]',
topics JSONB NOT NULL DEFAULT '[]',
importance FLOAT NOT NULL DEFAULT 0.5,
source TEXT, -- file path or "api" or "chat"
is_consolidated BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_kb_memories_bot_kb ON kb_memories(bot_id, kb_name);
CREATE INDEX idx_kb_memories_consolidated ON kb_memories(is_consolidated) WHERE is_consolidated = false;
CREATE INDEX idx_kb_memories_importance ON kb_memories(importance DESC);
-- Consolidation insights (from ConsolidateAgent)
CREATE TABLE IF NOT EXISTS kb_consolidations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
bot_id UUID NOT NULL REFERENCES bots(id) ON DELETE CASCADE,
kb_name TEXT NOT NULL,
insight TEXT NOT NULL,
memory_ids JSONB NOT NULL DEFAULT '[]', -- UUIDs of source memories
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_kb_consolidations_bot_kb ON kb_consolidations(bot_id, kb_name);
New BASIC Keyword
File: botserver/src/basic/keywords/always_on_kb.rs
// Registers: USE KB "name" ALWAYS ON
// Syntax: ["USE", "KB", "$expr$", "ALWAYS", "ON"]
pub fn register_always_on_kb_keyword(
engine: &mut Engine,
state: Arc<AppState>,
session: Arc<UserSession>,
) -> Result<(), Box<EvalAltResult>> {
engine.register_custom_syntax(
["USE", "KB", "$expr$", "ALWAYS", "ON"],
true,
move |context, inputs| {
let kb_name = context.eval_expression_tree(&inputs[0])?.to_string();
let bot_id = session.bot_id;
let pool = state.conn.clone();
std::thread::spawn(move || {
let mut conn = pool.get()?;
diesel::sql_query(
"INSERT INTO always_on_kb (bot_id, kb_name) VALUES ($1, $2)
ON CONFLICT (bot_id, kb_name) DO UPDATE SET is_active = true"
)
.bind::<diesel::sql_types::Uuid, _>(bot_id)
.bind::<diesel::sql_types::Text, _>(&kb_name)
.execute(&mut conn)
}).join().ok();
Ok(Dynamic::UNIT)
},
)?;
Ok(())
}
Register in botserver/src/basic/mod.rs alongside register_use_kb_keyword.
AlwaysOnKbService
File: botserver/src/learn/always_on_kb.rs
Three async tasks per active always-on KB:
IngestWorker
// Watches: /opt/gbo/data/{bot_name}.gbai/{kb_name}.gbkb/inbox/
// On new file:
// 1. Read file content (text/pdf/image via existing FileContentExtractor)
// 2. Call LLM: "Extract: summary, entities[], topics[], importance(0-1)"
// 3. INSERT INTO kb_memories
// 4. Move file to /processed/
async fn ingest_worker(bot_id: Uuid, kb_name: String, state: Arc<AppState>)
ConsolidateWorker
// Runs every `consolidate_interval_mins`
// 1. SELECT * FROM kb_memories WHERE bot_id=$1 AND kb_name=$2 AND is_consolidated=false LIMIT 20
// 2. Call LLM: "Find connections and generate insights from these memories: {memories}"
// 3. INSERT INTO kb_consolidations (insight, memory_ids)
// 4. UPDATE kb_memories SET is_consolidated=true WHERE id IN (...)
async fn consolidate_worker(bot_id: Uuid, kb_name: String, interval_mins: u64, state: Arc<AppState>)
QueryEnhancer (inject into BotOrchestrator)
// Called in BotOrchestrator::stream_response before LLM call
// 1. SELECT always_on_kb WHERE bot_id=$1 AND is_active=true
// 2. For each: fetch top-N memories by importance + recent consolidations
// 3. Prepend to system prompt:
// "## Memory Context\n{memories}\n## Insights\n{consolidations}"
pub async fn build_memory_context(bot_id: Uuid, pool: &DbPool) -> String
Service Startup
// In botserver/src/main_module/server.rs, after bootstrap:
// SELECT * FROM always_on_kb WHERE is_active=true
// For each row: tokio::spawn ingest_worker + consolidate_worker
pub async fn start_always_on_kb_workers(state: Arc<AppState>)
HTTP API
File: botserver/src/learn/always_on_kb.rs (add routes)
GET /api/kb/always-on → list all always-on KBs for bot
POST /api/kb/always-on/ingest → { kb_name, text, source } manual ingest
POST /api/kb/always-on/consolidate → { kb_name } trigger manual consolidation
GET /api/kb/always-on/:kb_name/memories → list memories (paginated)
GET /api/kb/always-on/:kb_name/insights → list consolidation insights
DELETE /api/kb/always-on/:kb_name/memory/:id → delete a memory
POST /api/kb/always-on/:kb_name/clear → delete all memories
BotOrchestrator Integration
File: botserver/src/core/bot/mod.rs
In stream_response(), before building the LLM prompt:
// Inject always-on memory context
let memory_ctx = always_on_kb::build_memory_context(bot_id, &state.conn).await;
if !memory_ctx.is_empty() {
system_prompt = format!("{}\n\n{}", memory_ctx, system_prompt);
}
Inbox File Watcher
The IngestWorker uses the existing LocalFileMonitor pattern from botserver/src/drive/local_file_monitor.rs.
Watch path: /opt/gbo/data/{bot_name}.gbai/{kb_name}.gbkb/inbox/
Supported types (reuse existing FileContentExtractor): .txt, .md, .pdf, .json, .csv, .png, .jpg, .mp3, .wav, .mp4
LLM Prompts
Ingest prompt
Extract structured information from this content.
Return JSON: { "summary": "...", "entities": [...], "topics": [...], "importance": 0.0-1.0 }
Content: {content}
Consolidate prompt
You are a memory consolidation agent. Review these memories and find connections.
Return JSON: { "insight": "...", "connected_memory_ids": [...] }
Memories: {memories_json}
Use the existing LlmClient in botserver/src/llm/mod.rs with the bot's configured model.
BASIC Usage Examples
' Enable always-on memory for this bot
USE KB "meeting-notes" ALWAYS ON
' Manual ingest from BASIC (optional)
' (future keyword: ADD MEMORY "text" TO KB "meeting-notes")
' Query is automatic — memories injected into every LLM call
TALK "What did we discuss last week?"
' → LLM sees memory context automatically
File Structure
botserver/
├── migrations/
│ └── 6.2.6-always-on-kb/
│ ├── up.sql ← always_on_kb, kb_memories, kb_consolidations tables
│ └── down.sql
├── src/
│ ├── basic/keywords/
│ │ └── always_on_kb.rs ← USE KB "x" ALWAYS ON keyword
│ ├── learn/
│ │ └── always_on_kb.rs ← IngestWorker, ConsolidateWorker, QueryEnhancer, HTTP API
│ └── main_module/
│ └── server.rs ← start_always_on_kb_workers() on startup
Implementation Order
- Migration (
up.sql) — tables always_on_kb.rskeyword — register syntaxlearn/always_on_kb.rs—build_memory_context()+ HTTP APIIngestWorker+ConsolidateWorkertokio tasksBotOrchestratorintegration — inject memory contextstart_always_on_kb_workers()in server startup- Register keyword in
basic/mod.rs - Botbook doc + i18n keys
Key Differences from Google's Implementation
| Google ADK | GB Implementation |
|---|---|
| Python + ADK framework | Rust + existing AppState |
| SQLite | PostgreSQL (existing pool) |
| Gemini Flash-Lite only | Any configured LLM via LlmClient |
| Standalone process | Embedded tokio tasks in botserver |
| File inbox only | File inbox + HTTP API + future chat auto-ingest |
| Manual query | Auto-injected into every bot LLM call |
| No BASIC integration | USE KB "x" ALWAYS ON keyword |