# Memory System Implementation Plan > **For execution:** Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Add a memory system to PicoBot that persists knowledge (facts/preferences) and timeline (compressed conversation summaries) via SQLite+FTS5, unified with the existing ContextCompressor. **Architecture:** Extend existing `Storage` module with a `memories` table + FTS5 virtual table. `MemoryManager` wraps CRUD operations. `ContextCompressor` gets a `MemoryManager` handle to auto-write timeline entries on compression. `SystemPromptBuilder` injects FTS5-retrieved knowledge into every system prompt. Three new agent tools (`memory_store`, `memory_recall`, `memory_forget`) registered in `create_default_tools()`. **Tech Stack:** Rust 2024 edition, sqlx (SQLite + FTS5), serde_json, anyhow, uuid, chrono --- ### Task 1: Memory Type Definitions **Files:** - Create: `src/memory/types.rs` - Modify: `src/lib.rs:14` (add `pub mod memory;`) **Step 1: Write module declaration** Add to `src/lib.rs` after line 11 (`pub mod observability;`): ```rust pub mod memory; ``` **Step 2: Create memory types file** Write `src/memory/types.rs`: ```rust use serde::{Deserialize, Serialize}; /// Memory categories for the memory system. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum MemoryCategory { /// Long-term facts, preferences, patterns, insights (merged fact+insight). Knowledge, /// Conversation summaries produced by context compression. Timeline, } impl MemoryCategory { pub fn as_str(&self) -> &str { match self { Self::Knowledge => "knowledge", Self::Timeline => "timeline", } } pub fn from_str(s: &str) -> Option { match s { "knowledge" => Some(Self::Knowledge), "timeline" => Some(Self::Timeline), _ => None, } } } /// A single memory entry stored in SQLite. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MemoryEntry { pub id: String, /// Semantic identifier (e.g. "user_prefers_rust"). pub key: String, /// The memory content. pub content: String, /// Category: knowledge or timeline. pub category: MemoryCategory, /// Importance score 0.0–1.0 (default 0.5). pub importance: f64, /// Associated session ID (optional). pub session_id: Option, /// RFC 3339 creation timestamp. pub created_at: String, /// RFC 3339 last update timestamp. pub updated_at: String, } /// Result from an LLM consolidation call. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ConsolidationResult { /// New or updated knowledge entries extracted from conversation. pub facts: Vec, /// Summary entry for timeline (formatted as "[YYYY-MM-DD HH:MM] text..."). pub timeline: Option, /// Keys of existing memories that should be invalidated. pub invalidations: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ConsolidationFact { pub key: String, pub content: String, pub importance: f64, } ``` **Step 3: Create module root file** Write `src/memory/mod.rs`: ```rust pub mod types; pub use types::{ConsolidationFact, ConsolidationResult, MemoryCategory, MemoryEntry}; ``` **Step 4: Verify compilation** Run: `cargo build 2>&1 | head -20` Expected: compiles without errors (types module loads). --- ### Task 2: MemoryConfig + Config Extension **Files:** - Modify: `src/config/mod.rs` **Step 1: Read current GatewayConfig struct for context** Already known from prior analysis — `GatewayConfig` has `host`, `port`, `session_ttl_hours`, etc. **Step 2: Add MemoryConfig struct** Insert after the `GatewayConfig` impl block (around line 211): ```rust #[derive(Debug, Clone, Deserialize, Serialize)] pub struct MemoryConfig { /// Master switch for the memory system. #[serde(default)] pub enabled: bool, /// Provider name for consolidation LLM calls (key in `providers`). #[serde(default)] pub consolidation_provider: Option, /// Model name for consolidation LLM calls (key in `models`). #[serde(default)] pub consolidation_model: Option, /// Max knowledge entries injected into system prompt per turn. #[serde(default = "default_recall_limit")] pub recall_limit: usize, /// Number of turns without consolidation before forcing one. #[serde(default = "default_consolidation_turn_threshold")] pub consolidation_turn_threshold: usize, /// Idle minutes before triggering consolidation (for async channels). #[serde(default = "default_idle_consolidation_minutes")] pub idle_consolidation_minutes: u64, /// Days before timeline entries are auto-cleaned. #[serde(default = "default_timeline_retention_days")] pub timeline_retention_days: u64, /// Consecutive consolidation failures before degrading to raw archive. #[serde(default = "default_max_failures_before_degrade")] pub max_failures_before_degrade: usize, } fn default_recall_limit() -> usize { 5 } fn default_consolidation_turn_threshold() -> usize { 3 } fn default_idle_consolidation_minutes() -> u64 { 10 } fn default_timeline_retention_days() -> u64 { 90 } fn default_max_failures_before_degrade() -> usize { 3 } ``` **Step 3: Add `memory` field to Config struct** Add after the `channels` field (around line 50-51 in Config): ```rust #[serde(default)] pub memory: MemoryConfig, ``` **Step 4: Verify compilation** Run: `cargo build 2>&1 | head -20` Expected: compiles. `MemoryConfig` default values are correct. --- ### Task 3: SQLite Schema — memories Table + FTS5 **Files:** - Create: `src/storage/memory.rs` - Modify: `src/storage/mod.rs` **Step 1: Add module declaration in storage/mod.rs** Add after line 4 (`pub mod scheduler;`): ```rust pub mod memory; ``` **Step 2: Add memories table creation to init_schema()** In `src/storage/mod.rs`, after the messages index creation (around line 86), add: ```rust sqlx::query( r#" CREATE TABLE IF NOT EXISTS memories ( id TEXT PRIMARY KEY, key TEXT NOT NULL UNIQUE, content TEXT NOT NULL, category TEXT NOT NULL DEFAULT 'knowledge', importance REAL NOT NULL DEFAULT 0.5, session_id TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ) "#, ) .execute(&self.pool) .await?; // FTS5 virtual table for full-text search on memories sqlx::query( r#" CREATE VIRTUAL TABLE IF NOT EXISTS memory_fts USING fts5( key, content, content=memories, content_rowid=rowid ) "#, ) .execute(&self.pool) .await?; ``` **Step 3: Add last_consolidated_at column to sessions (migration)** After the sessions table creation, add an ALTER TABLE migration: ```rust // Migration: add last_consolidated_at column if not exists sqlx::query( r#" ALTER TABLE sessions ADD COLUMN last_consolidated_at INTEGER "#, ) .execute(&self.pool) .await .ok(); // Ignore error if column already exists ``` **Step 4: Verify compilation** Run: `cargo build 2>&1 | head -20` Expected: compiles. Schema initialization includes memories table and FTS5. --- ### Task 4: Storage CRUD for Memories **Files:** - Modify: `src/storage/memory.rs` **Step 1: Write memory CRUD operations** ```rust use sqlx::{Pool, Sqlite, Row}; use std::path::Path; use crate::memory::{MemoryCategory, MemoryEntry}; use super::StorageError; impl super::Storage { /// Store or update a memory entry (upsert by key). pub async fn upsert_memory(&self, entry: &MemoryEntry) -> Result<(), StorageError> { let category_str = entry.category.as_str(); sqlx::query( r#" INSERT INTO memories (id, key, content, category, importance, session_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(key) DO UPDATE SET content = excluded.content, category = excluded.category, importance = excluded.importance, session_id = excluded.session_id, updated_at = excluded.updated_at "#, ) .bind(&entry.id) .bind(&entry.key) .bind(&entry.content) .bind(category_str) .bind(entry.importance) .bind(&entry.session_id) .bind(&entry.created_at) .bind(&entry.updated_at) .execute(&self.pool) .await?; Ok(()) } /// Delete a memory entry by key. pub async fn delete_memory(&self, key: &str) -> Result<(), StorageError> { sqlx::query("DELETE FROM memories WHERE key = ?") .bind(key) .execute(&self.pool) .await?; Ok(()) } /// Search memories by keyword using FTS5. /// Falls back to LIKE query if FTS5 returns no results. pub async fn search_memories( &self, query: &str, category: Option<&MemoryCategory>, limit: usize, ) -> Result, StorageError> { // Build FTS5 query: wrap each word in quotes and join with OR let fts_query = query .split_whitespace() .map(|w| format!("\"{}\"", w.replace('"', ""))) .collect::>() .join(" OR "); let category_filter = category.map(|c| c.as_str()); // Try FTS5 first let rows = sqlx::query( r#" SELECT m.id, m.key, m.content, m.category, m.importance, m.session_id, m.created_at, m.updated_at FROM memory_fts f JOIN memories m ON f.rowid = m.rowid WHERE memory_fts MATCH ? AND (? IS NULL OR m.category = ?) ORDER BY rank LIMIT ? "#, ) .bind(&fts_query) .bind(category_filter) .bind(category_filter) .bind(limit as i64) .fetch_all(&self.pool) .await?; let mut entries = parse_memory_rows(&rows)?; // Fallback to LIKE if FTS5 returned nothing if entries.is_empty() { let like_pattern = format!("%{}%", query.replace('%', "").replace('_', "")); let rows = sqlx::query( r#" SELECT id, key, content, category, importance, session_id, created_at, updated_at FROM memories WHERE (key LIKE ? OR content LIKE ?) AND (? IS NULL OR category = ?) ORDER BY importance DESC, updated_at DESC LIMIT ? "#, ) .bind(&like_pattern) .bind(&like_pattern) .bind(category_filter) .bind(category_filter) .bind(limit as i64) .fetch_all(&self.pool) .await?; entries = parse_memory_rows(&rows)?; } Ok(entries) } /// Retrieve memories within a time range. pub async fn search_memories_by_time( &self, since: i64, until: i64, category: Option<&MemoryCategory>, limit: usize, ) -> Result, StorageError> { let category_filter = category.map(|c| c.as_str()); let since_str = chrono::DateTime::from_timestamp_millis(since) .unwrap_or_default() .to_rfc3339(); let until_str = chrono::DateTime::from_timestamp_millis(until) .unwrap_or_default() .to_rfc3339(); let rows = sqlx::query( r#" SELECT id, key, content, category, importance, session_id, created_at, updated_at FROM memories WHERE created_at >= ? AND created_at <= ? AND (? IS NULL OR category = ?) ORDER BY created_at DESC LIMIT ? "#, ) .bind(&since_str) .bind(&until_str) .bind(category_filter) .bind(category_filter) .bind(limit as i64) .fetch_all(&self.pool) .await?; parse_memory_rows(&rows) } /// Delete old timeline entries beyond retention period. pub async fn cleanup_old_timelines(&self, retention_days: u64) -> Result { let cutoff = chrono::Utc::now() - chrono::Duration::days(retention_days as i64); let cutoff_str = cutoff.to_rfc3339(); let result = sqlx::query( "DELETE FROM memories WHERE category = 'timeline' AND created_at < ?", ) .bind(&cutoff_str) .execute(&self.pool) .await?; Ok(result.rows_affected()) } } fn parse_memory_rows( rows: &[sqlx::sqlite::SqliteRow], ) -> Result, StorageError> { rows.iter() .map(|row| { Ok(MemoryEntry { id: row.try_get("id")?, key: row.try_get("key")?, content: row.try_get("content")?, category: MemoryCategory::from_str(&row.try_get::("category")?) .unwrap_or(MemoryCategory::Knowledge), importance: row.try_get::("importance")?, session_id: row.try_get::, _>("session_id")?, created_at: row.try_get("created_at")?, updated_at: row.try_get("updated_at")?, }) }) .collect() } ``` **Step 2: Verify compilation** Run: `cargo build 2>&1 | head -30` Expected: compiles. Fix any import errors for `chrono`. --- ### Task 5: MemoryManager API **Files:** - Modify: `src/memory/mod.rs` **Step 1: Write MemoryManager** Replace `src/memory/mod.rs` content: ```rust pub mod types; use std::sync::Arc; use uuid::Uuid; use crate::storage::Storage; pub use types::{ConsolidationFact, ConsolidationResult, MemoryCategory, MemoryEntry}; /// MemoryManager provides high-level memory operations. /// Wraps the Storage SQLite layer with semantic methods. #[derive(Clone)] pub struct MemoryManager { storage: Arc, } impl MemoryManager { pub fn new(storage: Arc) -> Self { Self { storage } } /// Store or update a memory entry. Generates timestamp and UUID. pub async fn store( &self, key: &str, content: &str, category: MemoryCategory, session_id: Option<&str>, importance: Option, ) -> Result<(), crate::storage::StorageError> { let now = chrono::Utc::now().to_rfc3339(); let entry = MemoryEntry { id: Uuid::new_v4().to_string(), key: key.to_string(), content: content.to_string(), category, importance: importance.unwrap_or(0.5), session_id: session_id.map(|s| s.to_string()), created_at: now.clone(), updated_at: now, }; self.storage.upsert_memory(&entry).await } /// Search memories by keyword query. Returns entries sorted by relevance. pub async fn recall( &self, query: &str, limit: usize, category: Option, ) -> Result, crate::storage::StorageError> { self.storage.search_memories(query, category.as_ref(), limit).await } /// Search memories by time range (Unix milliseconds). pub async fn recall_by_time( &self, since: i64, until: i64, limit: usize, category: Option, ) -> Result, crate::storage::StorageError> { self.storage.search_memories_by_time(since, until, category.as_ref(), limit).await } /// Delete a memory entry by key. pub async fn forget(&self, key: &str) -> Result<(), crate::storage::StorageError> { self.storage.delete_memory(key).await } /// Check if the memory system has any entries (for testing/health check). pub async fn is_empty(&self) -> Result { self.recall("*", 1, None).await.map(|r| r.is_empty()) } } ``` **Step 2: Verify compilation** Run: `cargo build 2>&1 | head -20` Expected: compiles. MemoryManager is usable. --- ### Task 6: Session last_consolidated_at Field **Files:** - Modify: `src/session/session.rs` - Modify: `src/storage/session.rs` **Step 1: Add field to Session struct** In `src/session/session.rs`, add to the `Session` struct (after `routing_info`): ```rust /// Timestamp (Unix ms) of the last consolidation. /// Messages before this time have been compressed into memory. pub last_consolidated_at: Option, ``` **Step 2: Initialize in Session constructor** Find the `Session::new()` or `Session::create()` method. Add to its initialization: ```rust last_consolidated_at: None, ``` **Step 3: Update session load/save to include the field** In `src/storage/session.rs`, find the session reading/writing queries and ensure `last_consolidated_at` is included: Read query: add `last_consolidated_at` to SELECT and to the struct mapping. Write query: add `last_consolidated_at` to INSERT/UPDATE. **Step 4: Verify compilation** Run: `cargo build 2>&1 | head -20` Expected: compiles. --- ### Task 7: ContextCompressor Memory Integration **Files:** - Modify: `src/agent/context_compressor.rs` **Step 1: Add memory field to ContextCompressor** Add after `provider` field (around line 51): ```rust /// Memory manager handle (optional). When set, compressed /// context summaries are persisted as timeline memory entries. memory: Option>, ``` **Step 2: Add with_memory() constructor method** Add after `with_config()` method (around line 77): ```rust /// Attach a memory manager to persist compressed summaries. pub fn with_memory(mut self, memory: Arc) -> Self { self.memory = Some(memory); self } ``` **Step 3: Initialize memory field in constructors** In `new()`: ```rust memory: None, ``` In `with_config()`: ```rust memory: None, ``` **Step 4: Write timeline entry during compress_once** In the `compress_once()` method, after generating the summary, add: ```rust // Persist compressed summary as timeline memory entry if let Some(ref mm) = self.memory { let ts = chrono::Utc::now().format("%Y-%m-%d %H:%M").to_string(); let timeline_content = format!("[{}] Compressed {} conversation segments:\n{}", ts, segment_count, summary); let key = format!("ctx_compressed_{}", uuid::Uuid::new_v4()); // Fire-and-forget: don't block compression on memory write let mm_clone = mm.clone(); let session_id = session_id.cloned(); tokio::spawn(async move { if let Err(e) = mm_clone.store( &key, &timeline_content, crate::memory::MemoryCategory::Timeline, session_id.as_deref(), Some(0.3), ).await { tracing::warn!(error = %e, "Failed to store compressed context as timeline"); } }); } ``` Note: this requires `compress_once()` to accept an `Option<&str>` session_id parameter, or the ContextCompressor to track it. **Step 5: Add session_id tracking to ContextCompressor** Add a field for the current session_id that gets updated via a setter: ```rust /// Current session ID for timeline memory writes. session_id: Option, ``` Add setter: ```rust pub fn set_session_id(&mut self, id: Option) { self.session_id = id; } ``` Initialize in constructors: ```rust session_id: None, ``` **Step 6: Verify compilation** Run: `cargo build 2>&1 | head -30` Expected: compiles. Import fixes for `uuid` may be needed. --- ### Task 8: SystemPromptBuilder Memory Context Injection **Files:** - Modify: `src/agent/system_prompt.rs` **Step 1: Add MemoryContextSection** After `CrossChannelSection` impl (before Helper Functions): ```rust /// Injects relevant knowledge memories into the system prompt. pub struct MemoryContextSection { memory: Option>, recall_limit: usize, } impl MemoryContextSection { pub fn new(memory: Option>, recall_limit: usize) -> Self { Self { memory, recall_limit } } } impl PromptSection for MemoryContextSection { fn name(&self) -> &str { "memory_context" } fn build(&self, ctx: &PromptContext<'_>) -> String { let Some(ref memory) = self.memory else { return String::new(); }; // We cannot do async in a sync trait method. // Instead, the caller should pre-fetch memories and pass them via PromptContext. // For now, return empty — memory context is injected before building the prompt. String::new() } } ``` **Step 2: Add memory_context field to PromptContext** ```rust pub struct PromptContext<'a> { pub workspace_dir: &'a Path, pub model_name: &'a str, pub tools: &'a ToolRegistry, pub session_id: Option<&'a str>, /// Pre-fetched memory context string to inject. pub memory_context: Option<&'a str>, } ``` **Step 3: Add MemorySection to default sections** In `SystemPromptBuilder::with_defaults()`, add: ```rust Box::new(MemorySection), ``` **Step 4: Write MemorySection that renders the pre-fetched context** ```rust pub struct MemorySection; impl PromptSection for MemorySection { fn name(&self) -> &str { "memory" } fn build(&self, ctx: &PromptContext<'_>) -> String { match ctx.memory_context { Some(context) if !context.is_empty() => { format!("## 记忆上下文\n\n{}", context) } _ => String::new(), } } } ``` **Step 5: Update build_system_prompt() signature** Add `memory_context: Option<&str>` parameter: ```rust pub fn build_system_prompt( workspace_dir: &Path, model_name: &str, tools: &ToolRegistry, session_id: Option<&str>, memory_context: Option<&str>, ) -> String { let ctx = PromptContext { workspace_dir, model_name, tools, session_id, memory_context, }; SystemPromptBuilder::with_defaults().build(&ctx) } ``` **Step 6: Update callers of build_system_prompt()** In `src/session/session.rs`, find all calls to `build_system_prompt()` and add `None` as the memory_context parameter (will be wired later). **Step 7: Verify compilation** Run: `cargo build 2>&1 | head -30` Expected: compiles. Fix all call sites. --- ### Task 9: Agent Memory Tools **Files:** - Create: `src/tools/memory_store.rs` - Create: `src/tools/memory_recall.rs` - Create: `src/tools/memory_forget.rs` - Modify: `src/tools/mod.rs` **Step 1: Write memory_store tool** `src/tools/memory_store.rs`: ```rust use super::traits::{Tool, ToolResult}; use async_trait::async_trait; use serde_json::json; use std::sync::Arc; use crate::memory::{MemoryCategory, MemoryManager}; pub struct MemoryStoreTool { memory: Arc, } impl MemoryStoreTool { pub fn new(memory: Arc) -> Self { Self { memory } } } #[async_trait] impl Tool for MemoryStoreTool { fn name(&self) -> &str { "memory_store" } fn description(&self) -> &str { "Store a fact, preference, or insight into long-term memory. \ Use this when the user shares important information you should remember. \ Provide a descriptive key (e.g., 'user_prefers_python', 'project_auth_approach') \ and the full content to remember." } fn read_only(&self) -> bool { false } fn parameters_schema(&self) -> serde_json::Value { json!({ "type": "object", "properties": { "key": { "type": "string", "description": "Semantic identifier for this memory (e.g., 'user_language_pref'). Unique key." }, "content": { "type": "string", "description": "The full content of the memory entry." }, "category": { "type": "string", "enum": ["knowledge", "timeline"], "description": "Memory category. Use 'knowledge' for facts/preferences/insights, 'timeline' for conversation summaries." }, "importance": { "type": "number", "description": "Importance score 0.0-1.0. Higher = more important. Use 0.8+ for critical facts, 0.5 for general info." } }, "required": ["key", "content"] }) } async fn execute(&self, args: serde_json::Value) -> anyhow::Result { let key = args.get("key").and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing required parameter: key"))?; let content = args.get("content").and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing required parameter: content"))?; let category = args.get("category") .and_then(|v| v.as_str()) .and_then(MemoryCategory::from_str) .unwrap_or(MemoryCategory::Knowledge); let importance = args.get("importance") .and_then(|v| v.as_f64()); self.memory.store(key, content, category, None, importance).await?; Ok(ToolResult { success: true, output: format!("Memory stored: {}", key), error: None, }) } } ``` **Step 2: Write memory_recall tool** `src/tools/memory_recall.rs`: ```rust use super::traits::{Tool, ToolResult}; use async_trait::async_trait; use serde_json::json; use std::sync::Arc; use crate::memory::{MemoryCategory, MemoryManager}; pub struct MemoryRecallTool { memory: Arc, } impl MemoryRecallTool { pub fn new(memory: Arc) -> Self { Self { memory } } } #[async_trait] impl Tool for MemoryRecallTool { fn name(&self) -> &str { "memory_recall" } fn description(&self) -> &str { "Search and retrieve entries from long-term memory. \ Use this to recall previously stored facts, preferences, or conversation history. \ Supports keyword search and optional time-range filtering." } fn read_only(&self) -> bool { true } fn parameters_schema(&self) -> serde_json::Value { json!({ "type": "object", "properties": { "query": { "type": "string", "description": "Search query — keywords to match against memory keys and content." }, "category": { "type": "string", "enum": ["knowledge", "timeline"], "description": "Filter by memory category. Omit to search all categories." }, "since": { "type": "integer", "description": "Start of time range (Unix milliseconds)." }, "until": { "type": "integer", "description": "End of time range (Unix milliseconds)." }, "limit": { "type": "integer", "description": "Max results to return (default 10)." } }, "required": ["query"] }) } async fn execute(&self, args: serde_json::Value) -> anyhow::Result { let query = args.get("query").and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing required parameter: query"))?; let category = args.get("category") .and_then(|v| v.as_str()) .and_then(MemoryCategory::from_str); let limit = args.get("limit") .and_then(|v| v.as_u64()) .unwrap_or(10) as usize; let entries = if args.get("since").is_some() || args.get("until").is_some() { let since = args.get("since").and_then(|v| v.as_i64()).unwrap_or(0); let until = args.get("until").and_then(|v| v.as_i64()) .unwrap_or(chrono::Utc::now().timestamp_millis()); self.memory.recall_by_time(since, until, limit, category).await? } else { self.memory.recall(query, limit, category).await? }; if entries.is_empty() { return Ok(ToolResult { success: true, output: "No matching memories found.".to_string(), error: None, }); } let formatted = entries.iter() .map(|e| format!("- {} [{}] [importance: {:.1}]: {}", e.key, e.category.as_str(), e.importance, e.content)) .collect::>() .join("\n"); Ok(ToolResult { success: true, output: format!("Found {} memories:\n{}", entries.len(), formatted), error: None, }) } } ``` **Step 3: Write memory_forget tool** `src/tools/memory_forget.rs`: ```rust use super::traits::{Tool, ToolResult}; use async_trait::async_trait; use serde_json::json; use std::sync::Arc; use crate::memory::MemoryManager; pub struct MemoryForgetTool { memory: Arc, } impl MemoryForgetTool { pub fn new(memory: Arc) -> Self { Self { memory } } } #[async_trait] impl Tool for MemoryForgetTool { fn name(&self) -> &str { "memory_forget" } fn description(&self) -> &str { "Delete a memory entry by its key. Use this when information is outdated, \ incorrect, or the user asks to forget something." } fn read_only(&self) -> bool { false } fn parameters_schema(&self) -> serde_json::Value { json!({ "type": "object", "properties": { "key": { "type": "string", "description": "The key of the memory entry to delete." } }, "required": ["key"] }) } async fn execute(&self, args: serde_json::Value) -> anyhow::Result { let key = args.get("key").and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing required parameter: key"))?; self.memory.forget(key).await?; Ok(ToolResult { success: true, output: format!("Memory deleted: {}", key), error: None, }) } } ``` **Step 4: Register tools in create_default_tools()** In `src/tools/mod.rs`, add module declarations: ```rust pub mod memory_store; pub mod memory_recall; pub mod memory_forget; ``` Add pub use: ```rust pub use memory_store::MemoryStoreTool; pub use memory_recall::MemoryRecallTool; pub use memory_forget::MemoryForgetTool; ``` Modify `create_default_tools()` signature to accept `Option>`: ```rust pub fn create_default_tools( skills_loader: Arc, memory: Option>, ) -> ToolRegistry { // ... existing tools ... // Register memory tools if memory system is available if let Some(mm) = memory { registry.register(MemoryStoreTool::new(mm.clone())); registry.register(MemoryRecallTool::new(mm.clone())); registry.register(MemoryForgetTool::new(mm.clone())); } registry } ``` **Step 5: Update callers of create_default_tools()** In `src/session/session.rs`, find the call to `create_default_tools()` and pass `None` for now (will be wired in GatewayState task). **Step 6: Verify compilation** Run: `cargo build 2>&1 | head -30` Expected: compiles. Fix import issues. --- ### Task 10: GatewayState Initialization — Wiring **Files:** - Modify: `src/gateway/mod.rs` **Step 1: Initialize MemoryManager in GatewayState::new()** After the `storage` initialization (around line 56), add: ```rust // Initialize MemoryManager if memory system is enabled let memory_manager = if config.memory.enabled { let mm = Arc::new(crate::memory::MemoryManager::new(storage.clone())); tracing::info!("Memory system enabled"); Some(mm) } else { None }; ``` **Step 2: Pass memory_manager to create_default_tools()** Update the call to `create_default_tools()` in `SessionManager::new()` to accept `memory_manager.clone()`. Note: `SessionManager::new()` currently calls `create_default_tools()` internally. You'll need to propagate the `memory_manager` parameter through `SessionManager::new()`. In `src/session/session.rs`, update `SessionManager::new()`: ```rust impl SessionManager { pub fn new( session_ttl_hours: u32, provider_config: LLMProviderConfig, storage: Arc, bus: Arc, memory_manager: Option>, ) -> Result, SessionError> { // ... existing init ... let skills_loader = Arc::new(SkillsLoader::new()); let tools = create_default_tools(skills_loader, memory_manager.clone()); // Store memory_manager on SessionManager for later use // (add memory_manager field to SessionManager struct) } } ``` **Step 3: Attach MemoryManager to ContextCompressor in Session creation** In the Session creation path within SessionManager, when creating the `ContextCompressor`: ```rust let mut compressor = ContextCompressor::new(provider.clone(), token_limit); if let Some(ref mm) = memory_manager { compressor = compressor.with_memory(mm.clone()); } ``` **Step 4: Wire memory context into system prompt building** In `handle_message()` (session.rs), before building the system prompt: ```rust // Fetch memory context let memory_context = if let Some(ref mm) = memory_manager { match mm.recall(&user_message, 5, Some(MemoryCategory::Knowledge)).await { Ok(entries) if !entries.is_empty() => { Some(entries.iter() .map(|e| format!("- {}: {}", e.key, e.content)) .collect::>() .join("\n")) } _ => None, } } else { None }; let system_prompt = build_system_prompt( &workspace_dir, &model_name, &tools, session_id.as_deref(), memory_context.as_deref(), ); ``` **Step 5: Verify compilation** Run: `cargo build 2>&1 | head -40` Expected: compiles. Fix all wiring issues. This is the most integration-heavy task. --- ### Task 11: Unit Tests **Files:** - Create: `src/memory/types.rs` (add tests module) - Modify: `src/storage/memory.rs` (add tests module) - Modify: `src/memory/mod.rs` (add tests module) **Step 1: Type tests** In `src/memory/types.rs`, add: ```rust #[cfg(test)] mod tests { use super::*; #[test] fn test_memory_category_as_str() { assert_eq!(MemoryCategory::Knowledge.as_str(), "knowledge"); assert_eq!(MemoryCategory::Timeline.as_str(), "timeline"); } #[test] fn test_memory_category_from_str() { assert_eq!(MemoryCategory::from_str("knowledge"), Some(MemoryCategory::Knowledge)); assert_eq!(MemoryCategory::from_str("timeline"), Some(MemoryCategory::Timeline)); assert_eq!(MemoryCategory::from_str("invalid"), None); } } ``` Run: `cargo test --lib memory::types::tests` Expected: 2 tests pass. **Step 2: MemoryManager integration test (with real Storage)** In `src/memory/mod.rs`, add: ```rust #[cfg(test)] mod tests { use super::*; use std::sync::Arc; use tempfile::tempdir; async fn setup_memory_manager() -> (Arc, tempfile::TempDir) { let dir = tempdir().unwrap(); let db_path = dir.path().join("test.db"); let storage = Arc::new(crate::storage::Storage::new(&db_path).await.unwrap()); let mm = Arc::new(MemoryManager::new(storage)); (mm, dir) } #[tokio::test] async fn test_store_and_recall() { let (mm, _dir) = setup_memory_manager().await; mm.store("test_key", "This is a test memory", MemoryCategory::Knowledge, None, Some(0.8)) .await .unwrap(); let results = mm.recall("test memory", 10, None).await.unwrap(); assert_eq!(results.len(), 1); assert_eq!(results[0].key, "test_key"); assert_eq!(results[0].content, "This is a test memory"); assert!((results[0].importance - 0.8).abs() < 0.01); } #[tokio::test] async fn test_upsert_overwrites() { let (mm, _dir) = setup_memory_manager().await; mm.store("dup_key", "original", MemoryCategory::Knowledge, None, None) .await.unwrap(); mm.store("dup_key", "updated", MemoryCategory::Knowledge, None, Some(0.9)) .await.unwrap(); let results = mm.recall("updated", 10, None).await.unwrap(); assert_eq!(results.len(), 1); assert_eq!(results[0].content, "updated"); } #[tokio::test] async fn test_forget() { let (mm, _dir) = setup_memory_manager().await; mm.store("to_delete", "will be deleted", MemoryCategory::Knowledge, None, None) .await.unwrap(); mm.forget("to_delete").await.unwrap(); let results = mm.recall("deleted", 10, None).await.unwrap(); assert!(results.is_empty()); } #[tokio::test] async fn test_category_filter() { let (mm, _dir) = setup_memory_manager().await; mm.store("knowledge_1", "fact content", MemoryCategory::Knowledge, None, None) .await.unwrap(); mm.store("timeline_1", "summary content", MemoryCategory::Timeline, None, None) .await.unwrap(); let know_results = mm.recall("content", 10, Some(MemoryCategory::Knowledge)).await.unwrap(); assert_eq!(know_results.len(), 1); assert_eq!(know_results[0].key, "knowledge_1"); let time_results = mm.recall("content", 10, Some(MemoryCategory::Timeline)).await.unwrap(); assert_eq!(time_results.len(), 1); assert_eq!(time_results[0].key, "timeline_1"); } } ``` Run: `cargo test --lib memory::tests` Expected: 4 tests pass (store+recall, upsert, forget, category filter). **Step 3: SystemPromptBuilder memory_context test** In `src/agent/system_prompt.rs` tests module, add: ```rust #[test] fn test_memory_section_with_context() { let temp_dir = std::env::temp_dir(); let tools = ToolRegistry::new(); let ctx = PromptContext { workspace_dir: &temp_dir, model_name: "test", tools: &tools, session_id: None, memory_context: Some("- user_pref: Prefers Rust"), }; let prompt = SystemPromptBuilder::with_defaults().build(&ctx); assert!(prompt.contains("## 记忆上下文")); assert!(prompt.contains("Prefers Rust")); } #[test] fn test_memory_section_without_context() { let temp_dir = std::env::temp_dir(); let tools = ToolRegistry::new(); let ctx = PromptContext { workspace_dir: &temp_dir, model_name: "test", tools: &tools, session_id: None, memory_context: None, }; let prompt = SystemPromptBuilder::with_defaults().build(&ctx); assert!(!prompt.contains("## 记忆上下文")); } ``` Run: `cargo test --lib agent::system_prompt::tests` Expected: all tests pass including new memory tests. --- ### Task 12: Full Build and Test **Step 1: Full build** Run: `cargo build 2>&1` Expected: zero errors, zero warnings. **Step 2: All unit tests** Run: `cargo test --lib` Expected: all tests pass. **Step 3: Integration tests** Run: `cargo test -- --ignored` (requires test.env with API keys) Expected: existing integration tests still pass. **Step 4: Commit** ```bash git add -A && git commit -m "feat: add memory system with FTS5 search and context compression integration" ```