diff --git a/.gitignore b/.gitignore index b48c856..5b19c74 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,8 @@ /target +docker_build/ reference/** .env *.env Cargo.lock .worktrees/ -design \ No newline at end of file +design diff --git a/ARCHITECTURE_REVIEW.md b/ARCHITECTURE_REVIEW.md deleted file mode 100644 index 9dda8fe..0000000 --- a/ARCHITECTURE_REVIEW.md +++ /dev/null @@ -1,128 +0,0 @@ -# 架构审查报告 - -> 生成时间: 2026-04-26 -> 更新时间: 2026-04-26 - -## 审查摘要 - -本报告识别了当前代码库中的架构不合理、冗余和无效代码的问题。 - ---- - -## 问题清单 - -### 已修复 - -#### ✅ #1 OutboundDispatcher 重复维护 Channel 注册表 - -**修复方案**: `OutboundDispatcher` 现在从 `ChannelManager` 获取 channels,而不是自己维护一份注册表。 - -**修改文件**: -- `src/bus/dispatcher.rs` - 移除 `channels` 字段,改用 `ChannelManager` -- `src/channels/manager.rs` - 添加 `register_channel` 方法 -- `src/gateway/mod.rs` - 简化 dispatcher 初始化 - ---- - -#### ✅ #2 CliChatChannel 持有独立的 SessionStore - -**修复方案**: `CliChatChannel` 的 `SessionStore` 通过依赖注入从 `ChannelManager` 获取,而不是独立持有。 - -**修改文件**: -- `src/channels/cli_chat.rs` - 添加 `set_store()` 方法 -- `src/channels/manager.rs` - 添加 `cli_chat_channel` 字段 -- `src/gateway/mod.rs` - 重构 channel 初始化流程 - ---- - -#### ✅ #3 MessageBus 被创建两次引用 - -**修复方案**: 移除 `GatewayState.bus` 字段,直接使用 `channel_manager.bus()`。 - -**修改文件**: -- `src/gateway/mod.rs` - 移除冗余的 `bus` 字段 - ---- - -#### ✅ #4 GatewayState 同时持有 channel_manager 和 cli_chat_channel - -**修复方案**: `cli_chat_channel` 只通过 `ChannelManager` 管理,`GatewayState` 不再单独持有。 - -**修改文件**: -- `src/gateway/mod.rs` - 移除 `cli_chat_channel` 字段,添加 `cli_chat_channel()` getter 方法 - ---- - -### 高优先级(待修复) - -#### ❌ Session 每次重建都创建新的 LLM Provider - -**文件**: `src/gateway/session.rs:349-361` - -**问题**: 每当 session TTL 过期(默认4小时),就会销毁并重建 session,同时创建新的 LLM provider 连接。 - -**建议**: Provider 应该池化复用,不随 session 销毁而重建。 - ---- - -#### ❌ CliChatChannel::send 广播给所有客户端 - -**文件**: `src/channels/cli_chat.rs:279-289` - -**问题**: `OutboundMessage` 有 `chat_id` 字段用于路由,但实现广播给所有客户端,而不是只发给对应 chat_id 的客户端。 - -**建议**: 根据 `chat_id` 过滤客户端,只发送给对应的客户端。 - ---- - -### 中优先级(待修复) - -#### ❌ default_tools() 每次调用创建新 ToolRegistry - -**文件**: `src/gateway/session.rs:212-227` - -**建议**: 如果工具列表是只读的,直接 clone Arc;如果需要修改,需要澄清设计意图。 - ---- - -### 低优先级(待修复) - -#### ❌ FeishuChannel::new 接收未使用的 provider_config - -**文件**: `src/channels/feishu.rs:175-178` - ---- - -#### ❌ OutboundDispatcher::send_with_retry 永不执行的 unreachable - -**文件**: `src/bus/dispatcher.rs:81` - ---- - -#### ❌ Channel trait 的 `is_running` 使用 std::sync::Mutex - -**文件**: `src/channels/base.rs:38` vs `src/channels/cli_chat.rs:265-267` - ---- - -#### ❌ LoopDetector 硬编码在 AgentLoop 中 - -**文件**: `src/agent/agent_loop.rs:88-172` - ---- - -#### ❌ InboundMessage 和 OutboundMessage 结构重复 - -**文件**: `src/bus/message.rs` - ---- - -## 问题统计 - -| 状态 | 优先级 | 数量 | -|------|--------|------| -| ✅ 已修复 | - | 4 | -| ❌ 待修复 | 高 | 2 | -| ❌ 待修复 | 中 | 1 | -| ❌ 待修复 | 低 | 5 | -| **总计** | - | **12** | diff --git a/docs/plans/2026-04-26-client-refactor-design.md b/docs/plans/2026-04-26-client-refactor-design.md deleted file mode 100644 index 43b5273..0000000 --- a/docs/plans/2026-04-26-client-refactor-design.md +++ /dev/null @@ -1,40 +0,0 @@ -# 客户端代码整合设计 - -## 目标 - -将分散在 `src/cli/` 和 `src/client/` 的客户端代码整合到 `src/client/` 目录。 - -## 变更 - -### 目录结构 - -``` -src/ -├── client/ # 整合后的客户端模块 -│ ├── mod.rs # 主程序入口 (run 函数) -│ ├── input.rs # InputHandler + InputCommand (从 cli/input.rs 合并) -│ └── channel.rs # CliChannel (从 cli/channel.rs 合并) -├── cli/ # 删除 -└── protocol.rs # 保留 -``` - -### 关键变更 - -| 变更 | 说明 | -|------|------| -| `InputEvent::Message(String)` | 简化为只携带文本内容,不再使用 `ChatMessage` | -| `cli` 模块删除 | 代码合并到 `client` | -| 解耦 | `client` 不再依赖 `bus::ChatMessage` | - -## 实施步骤 - -1. 创建 `src/client/input.rs` - 从 `cli/input.rs` 合并,修改 `InputEvent::Message` 为 `String` -2. 创建 `src/client/channel.rs` - 从 `cli/channel.rs` 直接复制 -3. 更新 `src/client/mod.rs` - 更新 import -4. 更新 `src/lib.rs` - 删除 `pub mod cli;` -5. 删除 `src/cli/` 目录 - -## 验证 - -- `cargo build` 通过 -- 功能保持不变 diff --git a/docs/plans/2026-04-28-phase1-storage-implementation.md b/docs/plans/2026-04-28-phase1-storage-implementation.md deleted file mode 100644 index d6b1b16..0000000 --- a/docs/plans/2026-04-28-phase1-storage-implementation.md +++ /dev/null @@ -1,877 +0,0 @@ -# Phase 1: Storage 基础 实现计划 - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** 创建 `src/storage/` 模块,实现 SQLite 持久化层,为后续 Session 扩展提供 Storage 基础设施。 - -**Architecture:** 使用 `sqlx` + `sqlite`,通过 `SqlitePool` 实现异步连接池,所有 Storage 操作均为 async,在 `Storage` 内部管理连接池的生命周期。 - -**Tech Stack:** `sqlx` (sqlite, tokio), `serde`, `chrono` (时间戳), `tokio::time::sleep` (重试退避) - ---- - -## Task 1: 添加依赖 - -**Files:** -- Modify: `Cargo.toml:36` (在 `[dependencies]` 末尾添加) - -**Step 1: 添加 sqlx + sqlite 依赖** - -在 `Cargo.toml` 末尾添加: - -```toml -sqlx = { version = "0.8", features = ["sqlite", "tokio", "macros", "chrono"] } -``` - -**Step 2: 运行 cargo check 验证依赖** - -Run: `cargo check 2>&1` -Expected: 无报错,依赖解析成功 - -**Step 3: Commit** - -```bash -git add Cargo.toml -git commit -m "deps: 添加 sqlx + sqlite 依赖" -``` - ---- - -## Task 2: 创建 Storage Error 类型 - -**Files:** -- Create: `src/storage/error.rs` - -**Step 1: 编写 StorageError 枚举和测试** - -```rust -use thiserror::Error; - -#[derive(Error, Debug)] -pub enum StorageError { - #[error("session not found: {0}")] - NotFound(String), - - #[error("session already exists: {0}")] - AlreadyExists(String), - - #[error("database error: {0}")] - Database(#[from] sqlx::Error), - - #[error("serialization error: {0}")] - Serialization(String), -} -``` - -**Step 2: 验证编译** - -Run: `cargo build --lib 2>&1 | head -30` -Expected: 报错 "cannot find module `storage`"(因为模块未创建),这是预期的 - -**Step 3: Commit** - -```bash -git add src/storage/error.rs -git commit -m "feat(storage): 添加 StorageError 类型" -``` - ---- - -## Task 3: 创建 Storage 模块骨架 - -**Files:** -- Create: `src/storage/mod.rs` -- Create: `src/storage/session.rs` -- Create: `src/storage/message.rs` - -**Step 1: 创建 `src/storage/mod.rs`** - -```rust -pub mod error; -pub mod session; -pub mod message; - -pub use error::StorageError; -``` - -**Step 2: 创建 `src/storage/session.rs`(空壳)** - -```rust -// Session CRUD 操作占位符 -``` - -**Step 3: 创建 `src/storage/message.rs`(空壳)** - -```rust -// Message CRUD 操作占位符 -``` - -**Step 4: 在 `src/lib.rs` 中添加 storage 模块** - -在 `src/lib.rs` 末尾添加: - -```rust -pub mod storage; -``` - -**Step 5: 验证编译** - -Run: `cargo build --lib 2>&1` -Expected: 编译成功(空壳模块) - -**Step 6: Commit** - -```bash -git add src/storage/ src/lib.rs -git commit -m "feat(storage): 创建 storage 模块骨架" -``` - ---- - -## Task 4: 实现 Storage 主结构 - -**Files:** -- Modify: `src/storage/mod.rs` - -**Step 1: 编写 Storage 结构和初始化逻辑** - -```rust -use sqlx::{Pool, Sqlite, SqlitePool}; -use std::path::Path; - -pub struct Storage { - pool: Pool, -} - -impl Storage { - /// 打开或创建数据库 - pub async fn new(db_path: &Path) -> Result { - let database_url = format!("sqlite:{}?mode=rwc", db_path.display()); - let pool = SqlitePool::connect(&database_url).await?; - - let storage = Self { pool }; - storage.init_schema().await?; - Ok(storage) - } - - /// 初始化数据库 schema - async fn init_schema(&self) -> Result<(), StorageError> { - sqlx::query( - r#" - CREATE TABLE IF NOT EXISTS sessions ( - id TEXT PRIMARY KEY, - channel TEXT NOT NULL, - chat_id TEXT NOT NULL, - dialog_id TEXT NOT NULL, - title TEXT NOT NULL DEFAULT '新对话', - created_at INTEGER NOT NULL, - last_active_at INTEGER NOT NULL, - message_count INTEGER DEFAULT 0, - routing_info TEXT, - deleted_at INTEGER, - UNIQUE(channel, chat_id, dialog_id) - ) - "#, - ) - .execute(&self.pool) - .await?; - - sqlx::query( - r#" - CREATE INDEX IF NOT EXISTS idx_sessions_chat - ON sessions(channel, chat_id, deleted_at) - "#, - ) - .execute(&self.pool) - .await?; - - sqlx::query( - r#" - CREATE TABLE IF NOT EXISTS messages ( - id TEXT PRIMARY KEY, - session_id TEXT NOT NULL, - seq INTEGER NOT NULL, - role TEXT NOT NULL, - content TEXT NOT NULL, - media_refs TEXT, - tool_call_id TEXT, - tool_name TEXT, - tool_calls TEXT, - created_at INTEGER NOT NULL, - FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE - ) - "#, - ) - .execute(&self.pool) - .await?; - - sqlx::query( - r#" - CREATE INDEX IF NOT EXISTS idx_messages_session_seq - ON messages(session_id, seq) - "#, - ) - .execute(&self.pool) - .await?; - - Ok(()) - } - - /// 获取连接池引用(供内部 CRUD 使用) - pub(crate) fn pool(&self) -> &Pool { - &self.pool - } -} -``` - -**Step 2: 验证编译** - -Run: `cargo build --lib 2>&1` -Expected: 编译成功 - -**Step 3: Commit** - -```bash -git add src/storage/mod.rs -git commit -m "feat(storage): 实现 Storage 主结构和初始化" -``` - ---- - -## Task 5: 定义 SessionMeta 和 MessageMeta 数据结构 - -**Files:** -- Modify: `src/storage/session.rs` -- Modify: `src/storage/message.rs` - -**Step 1: 在 `session.rs` 中定义 SessionMeta** - -```rust -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SessionMeta { - pub id: String, - pub channel: String, - pub chat_id: String, - pub dialog_id: String, - pub title: String, - pub created_at: i64, - pub last_active_at: i64, - pub message_count: i64, - pub routing_info: Option, - pub deleted_at: Option, -} -``` - -**Step 2: 在 `message.rs` 中定义 MessageMeta** - -```rust -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MessageMeta { - pub id: String, - pub session_id: String, - pub seq: i64, - pub role: String, - pub content: String, - pub media_refs: Option, - pub tool_call_id: Option, - pub tool_name: Option, - pub tool_calls: Option, - pub created_at: i64, -} -``` - -**Step 3: 验证编译** - -Run: `cargo build --lib 2>&1` -Expected: 编译成功 - -**Step 4: Commit** - -```bash -git add src/storage/session.rs src/storage/message.rs -git commit -m "feat(storage): 定义 SessionMeta 和 MessageMeta 数据结构" -``` - ---- - -## Task 6: 实现 Session CRUD 操作 - -**Files:** -- Modify: `src/storage/session.rs` - -**Step 1: 编写 upsert_session** - -```rust -use sqlx::Row; -use super::SessionMeta; - -impl Storage { - pub async fn upsert_session(&self, meta: &SessionMeta) -> Result<(), StorageError> { - sqlx::query( - r#" - INSERT INTO sessions (id, channel, chat_id, dialog_id, title, created_at, last_active_at, message_count, routing_info, deleted_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(id) DO UPDATE SET - title = excluded.title, - last_active_at = excluded.last_active_at, - message_count = excluded.message_count, - routing_info = excluded.routing_info, - deleted_at = excluded.deleted_at - "#, - ) - .bind(&meta.id) - .bind(&meta.channel) - .bind(&meta.chat_id) - .bind(&meta.dialog_id) - .bind(&meta.title) - .bind(meta.created_at) - .bind(meta.last_active_at) - .bind(meta.message_count) - .bind(&meta.routing_info) - .bind(meta.deleted_at) - .execute(self.pool()) - .await?; - - Ok(()) - } - - pub async fn get_session(&self, id: &str) -> Result { - let row = sqlx::query( - r#" - SELECT id, channel, chat_id, dialog_id, title, created_at, last_active_at, message_count, routing_info, deleted_at - FROM sessions WHERE id = ? AND deleted_at IS NULL - "#, - ) - .bind(id) - .fetch_optional(self.pool()) - .await? - .ok_or_else(|| StorageError::NotFound(id.to_string()))?; - - Ok(SessionMeta { - id: row.get("id"), - channel: row.get("channel"), - chat_id: row.get("chat_id"), - dialog_id: row.get("dialog_id"), - title: row.get("title"), - created_at: row.get("created_at"), - last_active_at: row.get("last_active_at"), - message_count: row.get("message_count"), - routing_info: row.get("routing_info"), - deleted_at: row.get("deleted_at"), - }) - } - - pub async fn list_sessions( - &self, - channel: &str, - chat_id: &str, - limit: i64, - ) -> Result, StorageError> { - let rows = sqlx::query( - r#" - SELECT id, channel, chat_id, dialog_id, title, created_at, last_active_at, message_count, routing_info, deleted_at - FROM sessions - WHERE channel = ? AND chat_id = ? AND deleted_at IS NULL - ORDER BY last_active_at DESC - LIMIT ? - "#, - ) - .bind(channel) - .bind(chat_id) - .bind(limit) - .fetch_all(self.pool()) - .await?; - - Ok(rows - .into_iter() - .map(|row| SessionMeta { - id: row.get("id"), - channel: row.get("channel"), - chat_id: row.get("chat_id"), - dialog_id: row.get("dialog_id"), - title: row.get("title"), - created_at: row.get("created_at"), - last_active_at: row.get("last_active_at"), - message_count: row.get("message_count"), - routing_info: row.get("routing_info"), - deleted_at: row.get("deleted_at"), - }) - .collect()) - } - - pub async fn touch_session( - &self, - id: &str, - message_count: i64, - last_active_at: i64, - ) -> Result<(), StorageError> { - sqlx::query( - r#" - UPDATE sessions SET message_count = ?, last_active_at = ? - WHERE id = ? - "#, - ) - .bind(message_count) - .bind(last_active_at) - .bind(id) - .execute(self.pool()) - .await?; - - Ok(()) - } - - pub async fn soft_delete_session(&self, id: &str) -> Result<(), StorageError> { - let now = chrono::Utc::now().timestamp_millis(); - sqlx::query( - r#"UPDATE sessions SET deleted_at = ? WHERE id = ?"#, - ) - .bind(now) - .bind(id) - .execute(self.pool()) - .await?; - - Ok(()) - } - - /// 查找 channel:chat_id 下最近活跃且未过期的 session - pub async fn find_active_session( - &self, - channel: &str, - chat_id: &str, - ttl_millis: i64, - ) -> Result, StorageError> { - let cutoff = chrono::Utc::now().timestamp_millis() - ttl_millis; - let row = sqlx::query( - r#" - SELECT id, channel, chat_id, dialog_id, title, created_at, last_active_at, message_count, routing_info, deleted_at - FROM sessions - WHERE channel = ? AND chat_id = ? AND deleted_at IS NULL AND last_active_at > ? - ORDER BY last_active_at DESC - LIMIT 1 - "#, - ) - .bind(channel) - .bind(chat_id) - .bind(cutoff) - .fetch_optional(self.pool()) - .await?; - - match row { - Some(row) => Ok(Some(SessionMeta { - id: row.get("id"), - channel: row.get("channel"), - chat_id: row.get("chat_id"), - dialog_id: row.get("dialog_id"), - title: row.get("title"), - created_at: row.get("created_at"), - last_active_at: row.get("last_active_at"), - message_count: row.get("message_count"), - routing_info: row.get("routing_info"), - deleted_at: row.get("deleted_at"), - })), - None => Ok(None), - } - } -} -``` - -> 注意:`Storage` 的 CRUD 方法需要能访问 `pool()`,但目前 `pool()` 是 `pub(crate)`。在 `mod.rs` 中为 `session.rs` 实现 `Storage` 的 CRUD,所以同模块内可访问。 - -**Step 2: 验证编译** - -Run: `cargo build --lib 2>&1` -Expected: 编译成功 - -**Step 3: Commit** - -```bash -git add src/storage/session.rs -git commit -m "feat(storage): 实现 Session CRUD 操作" -``` - ---- - -## Task 7: 实现 Message CRUD 操作 - -**Files:** -- Modify: `src/storage/message.rs` - -**Step 1: 编写 Message CRUD** - -```rust -use sqlx::Row; -use super::MessageMeta; - -impl Storage { - pub async fn append_message(&self, session_id: &str, msg: &MessageMeta) -> Result { - sqlx::query( - r#" - INSERT INTO messages (id, session_id, seq, role, content, media_refs, tool_call_id, tool_name, tool_calls, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - "#, - ) - .bind(&msg.id) - .bind(session_id) - .bind(msg.seq) - .bind(&msg.role) - .bind(&msg.content) - .bind(&msg.media_refs) - .bind(&msg.tool_call_id) - .bind(&msg.tool_name) - .bind(&msg.tool_calls) - .bind(msg.created_at) - .execute(self.pool()) - .await?; - - Ok(msg.seq) - } - - pub async fn append_messages( - &self, - session_id: &str, - msgs: &[MessageMeta], - ) -> Result, StorageError> { - let mut seqs = Vec::with_capacity(msgs.len()); - for msg in msgs { - let seq = self.append_message(session_id, msg).await?; - seqs.push(seq); - } - Ok(seqs) - } - - pub async fn load_messages( - &self, - session_id: &str, - from_seq: i64, - ) -> Result, StorageError> { - let rows = sqlx::query( - r#" - SELECT id, session_id, seq, role, content, media_refs, tool_call_id, tool_name, tool_calls, created_at - FROM messages - WHERE session_id = ? AND seq >= ? - ORDER BY seq ASC - "#, - ) - .bind(session_id) - .bind(from_seq) - .fetch_all(self.pool()) - .await?; - - Ok(rows - .into_iter() - .map(|row| MessageMeta { - id: row.get("id"), - session_id: row.get("session_id"), - seq: row.get("seq"), - role: row.get("role"), - content: row.get("content"), - media_refs: row.get("media_refs"), - tool_call_id: row.get("tool_call_id"), - tool_name: row.get("tool_name"), - tool_calls: row.get("tool_calls"), - created_at: row.get("created_at"), - }) - .collect()) - } - - pub async fn clear_messages(&self, session_id: &str) -> Result<(), StorageError> { - sqlx::query(r#"DELETE FROM messages WHERE session_id = ?"#) - .bind(session_id) - .execute(self.pool()) - .await?; - Ok(()) - } -} -``` - -> 注意:同样在 `mod.rs` 中实现,这样 `Storage` 的方法对 `message.rs` 中的 impl 可见。 - -**Step 2: 验证编译** - -Run: `cargo build --lib 2>&1` -Expected: 编译成功 - -**Step 3: Commit** - -```bash -git add src/storage/message.rs -git commit -m "feat(storage): 实现 Message CRUD 操作" -``` - ---- - -## Task 8: 实现写入重试逻辑 - -**Files:** -- Modify: `src/storage/mod.rs` - -**Step 1: 在 Storage 中添加带重试的 append_message** - -在 `mod.rs` 的 `Storage` impl 块中添加: - -```rust -use tokio::time::{sleep, Duration}; - -impl Storage { - /// 追加消息,带重试逻辑 - /// 重试 3 次(100/200/300ms 退避),仍失败返回错误 - pub async fn append_message_with_retry( - &self, - session_id: &str, - msg: &MessageMeta, - ) -> Result { - let delays = [100, 200, 300]; - - for (i, delay) in delays.iter().enumerate() { - match self.append_message(session_id, msg).await { - Ok(seq) => return Ok(seq), - Err(e) if i < delays.len() - 1 => { - sleep(Duration::from_millis(*delay)).await; - tracing::warn!("Storage write failed, retrying: {}", e); - } - Err(e) => { - tracing::error!("Storage write failed after retries: {}", e); - return Err(e); - } - } - } - unreachable!() - } -} -``` - -> 注意:需要 `use sqlx::Row;` 在 `mod.rs` 中。 - -**Step 2: 验证编译** - -Run: `cargo build --lib 2>&1` -Expected: 编译成功 - -**Step 3: Commit** - -```bash -git add src/storage/mod.rs -git commit -m "feat(storage): 添加写入重试逻辑" -``` - ---- - -## Task 9: 编写 Storage 单元测试 - -**Files:** -- Modify: `src/storage/mod.rs`(添加测试模块) - -**Step 1: 编写 Storage 集成测试** - -在 `src/storage/mod.rs` 末尾添加: - -```rust -#[cfg(test)] -mod tests { - use super::*; - use tempfile::tempdir; - use std::path::Path; - - async fn create_test_storage() -> (Storage, impl Fn()) { - let dir = tempdir().unwrap(); - let db_path = dir.path().join("test.db"); - let storage = Storage::new(&db_path).await.unwrap(); - - let cleanup = || { - drop(dir); - }; - - (storage, cleanup) - } - - #[tokio::test] - async fn test_upsert_and_get_session() { - let (storage, cleanup) = create_test_storage().await; - defer { cleanup(); } - - let meta = SessionMeta { - id: "cli_chat:sid123:dialog1".to_string(), - channel: "cli_chat".to_string(), - chat_id: "sid123".to_string(), - dialog_id: "dialog1".to_string(), - title: "测试会话".to_string(), - created_at: 1000, - last_active_at: 1000, - message_count: 0, - routing_info: Some(r#"{"type":"cli"}"#.to_string()), - deleted_at: None, - }; - - storage.upsert_session(&meta).await.unwrap(); - - let loaded = storage.get_session(&meta.id).await.unwrap(); - assert_eq!(loaded.title, "测试会话"); - assert_eq!(loaded.channel, "cli_chat"); - } - - #[tokio::test] - async fn test_get_nonexistent_session() { - let (storage, cleanup) = create_test_storage().await; - defer { cleanup(); } - - let result = storage.get_session("nonexistent").await; - assert!(result.is_err()); - matches!(result.unwrap_err(), StorageError::NotFound(_)); - } - - #[tokio::test] - async fn test_list_sessions() { - let (storage, cleanup) = create_test_storage().await; - defer { cleanup(); } - - for i in 0..5 { - let meta = SessionMeta { - id: format!("cli_chat:sid123:dialog{}", i), - channel: "cli_chat".to_string(), - chat_id: "sid123".to_string(), - dialog_id: format!("dialog{}", i), - title: format!("会话{}", i), - created_at: i as i64 * 1000, - last_active_at: i as i64 * 1000, - message_count: i, - routing_info: None, - deleted_at: None, - }; - storage.upsert_session(&meta).await.unwrap(); - } - - let sessions = storage.list_sessions("cli_chat", "sid123", 10).await.unwrap(); - assert_eq!(sessions.len(), 5); - // 按 last_active_at DESC 排序 - assert_eq!(sessions[0].dialog_id, "dialog4"); - } - - #[tokio::test] - async fn test_soft_delete() { - let (storage, cleanup) = create_test_storage().await; - defer { cleanup(); } - - let meta = SessionMeta { - id: "cli_chat:sid123:dialog1".to_string(), - channel: "cli_chat".to_string(), - chat_id: "sid123".to_string(), - dialog_id: "dialog1".to_string(), - title: "测试".to_string(), - created_at: 1000, - last_active_at: 1000, - message_count: 0, - routing_info: None, - deleted_at: None, - }; - - storage.upsert_session(&meta).await.unwrap(); - storage.soft_delete_session(&meta.id).await.unwrap(); - - let result = storage.get_session(&meta.id).await; - assert!(result.is_err()); - matches!(result.unwrap_err(), StorageError::NotFound(_)); - } - - #[tokio::test] - async fn test_append_and_load_messages() { - let (storage, cleanup) = create_test_storage().await; - defer { cleanup(); } - - let session_meta = SessionMeta { - id: "cli_chat:sid123:dialog1".to_string(), - channel: "cli_chat".to_string(), - chat_id: "sid123".to_string(), - dialog_id: "dialog1".to_string(), - title: "测试".to_string(), - created_at: 1000, - last_active_at: 1000, - message_count: 0, - routing_info: None, - deleted_at: None, - }; - storage.upsert_session(&session_meta).await.unwrap(); - - let msg = MessageMeta { - id: "msg1".to_string(), - session_id: session_meta.id.clone(), - seq: 1, - role: "user".to_string(), - content: "你好".to_string(), - media_refs: None, - tool_call_id: None, - tool_name: None, - tool_calls: None, - created_at: 1000, - }; - - let seq = storage.append_message(&session_meta.id, &msg).await.unwrap(); - assert_eq!(seq, 1); - - let loaded = storage.load_messages(&session_meta.id, 0).await.unwrap(); - assert_eq!(loaded.len(), 1); - assert_eq!(loaded[0].content, "你好"); - } - - #[tokio::test] - async fn test_touch_session() { - let (storage, cleanup) = create_test_storage().await; - defer { cleanup(); } - - let meta = SessionMeta { - id: "cli_chat:sid123:dialog1".to_string(), - channel: "cli_chat".to_string(), - chat_id: "sid123".to_string(), - dialog_id: "dialog1".to_string(), - title: "测试".to_string(), - created_at: 1000, - last_active_at: 1000, - message_count: 0, - routing_info: None, - deleted_at: None, - }; - storage.upsert_session(&meta).await.unwrap(); - - storage.touch_session(&meta.id, 5, 2000).await.unwrap(); - - let loaded = storage.get_session(&meta.id).await.unwrap(); - assert_eq!(loaded.message_count, 5); - assert_eq!(loaded.last_active_at, 2000); - } -} -``` - -> 需要在 `Cargo.toml` 中添加 `tempfile` 依赖(已存在)。`defer` 宏可自己实现一个简单的:`fn defer(f: F) { f() }` - -**Step 2: 运行测试** - -Run: `cargo test storage::tests --lib 2>&1` -Expected: 所有 7 个测试 PASS - -**Step 3: Commit** - -```bash -git add src/storage/mod.rs -git commit -m "test(storage): 编写 Storage 单元测试" -``` - ---- - -## 汇总 - -| Task | 改动文件 | 关键交付物 | -|------|----------|-----------| -| 1 | `Cargo.toml` | sqlx 依赖 | -| 2 | `src/storage/error.rs` | StorageError | -| 3 | `src/storage/{mod.rs,session.rs,message.rs}`, `src/lib.rs` | 模块骨架 | -| 4 | `src/storage/mod.rs` | Storage 结构 + init_schema | -| 5 | `src/storage/session.rs`, `message.rs` | SessionMeta, MessageMeta | -| 6 | `src/storage/session.rs` | Session CRUD | -| 7 | `src/storage/message.rs` | Message CRUD | -| 8 | `src/storage/mod.rs` | append_message_with_retry | -| 9 | `src/storage/mod.rs` | 7 个单元测试 | - -**Phase 1 完成后:** Storage 模块可独立使用,具备完整的持久化能力,可安全地集成到 Session 和 SessionManager 中。 diff --git a/docs/plans/2026-04-28-session-persistence-design.md b/docs/plans/2026-04-28-session-persistence-design.md deleted file mode 100644 index 5b893d1..0000000 --- a/docs/plans/2026-04-28-session-persistence-design.md +++ /dev/null @@ -1,278 +0,0 @@ -# Session 持久化设计方案 - -## 概述 - -为 PicoBot 添加 SQLite 持久化层,实现 Session 数据的持久化、完整 Dialog 生命周期管理、消息实时落盘、以及基于 TTL 的自动内存清理。 - -## 核心概念 - -``` -UnifiedSessionId = {channel}:{chat_id}:{dialog_id} -Session = Dialog(两者等价,不再分层) -``` - -每个 Session 独立管理自己的消息历史、LLM 配置和路由信息。 - -## 数据库 Schema - -### sessions 表 - -```sql -CREATE TABLE sessions ( - id TEXT PRIMARY KEY, - channel TEXT NOT NULL, - chat_id TEXT NOT NULL, - dialog_id TEXT NOT NULL, - title TEXT NOT NULL DEFAULT '新对话', - created_at INTEGER NOT NULL, - last_active_at INTEGER NOT NULL, - message_count INTEGER DEFAULT 0, - routing_info TEXT, - deleted_at INTEGER, - UNIQUE(channel, chat_id, dialog_id) -); -CREATE INDEX idx_sessions_chat ON sessions(channel, chat_id, deleted_at); -``` - -### messages 表 - -```sql -CREATE TABLE messages ( - id TEXT PRIMARY KEY, - session_id TEXT NOT NULL, - seq INTEGER NOT NULL, - role TEXT NOT NULL, - content TEXT NOT NULL, - media_refs TEXT, - tool_call_id TEXT, - tool_name TEXT, - tool_calls TEXT, - created_at INTEGER NOT NULL, - FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE -); -CREATE INDEX idx_messages_session_seq ON messages(session_id, seq); -``` - -## Storage API - -### Session 操作 - -| 方法 | 说明 | -|------|------| -| `new(db_path) -> Storage` | 打开/创建数据库 | -| `upsert_session(meta) -> Result<(), StorageError>` | 插入或更新 session 元数据 | -| `get_session(id) -> Result` | 获取单个 session | -| `list_sessions(channel, chat_id, limit) -> Result>` | 最近 N 条 | -| `touch_session(id, message_count, last_active_at)` | 更新计数和最后活跃时间 | -| `soft_delete_session(id) -> Result<(), StorageError>` | 软删除 | - -### Message 操作 - -| 方法 | 说明 | -|------|------| -| `append_message(session_id, msg) -> Result` | 追加单条消息,返回 seq | -| `append_messages(session_id, msgs) -> Result, StorageError>` | 批量追加 | -| `load_messages(session_id, from_seq) -> Result>` | 从指定 seq 加载 | -| `clear_messages(session_id) -> Result<(), StorageError>` | 清除消息(保留 session) | - -### 写入失败处理 - -重试 3 次(100/200/300ms 退避),仍失败则发送系统通知告警。 - -## Session 结构 - -```rust -pub struct Session { - pub id: UnifiedSessionId, - pub title: String, - pub created_at: i64, - pub last_active_at: i64, - pub message_count: i64, // 用户消息计数 - pub total_message_count: i64, // 含系统消息 - - messages: Vec, // 内存消息历史 - seq_counter: i64, // 下一个消息的 seq - - provider_config: LLMProviderConfig, - provider: Arc, - tools: Arc, - compressor: ContextCompressor, - user_tx: mpsc::Sender, - storage: Arc, // 持久化 sink - routing_info: String, // JSON 路由信息 -} -``` - -### 初始化流程 - -``` -new() 或 from_storage() - ↓ -注入 storage 引用 - ↓ -创建 provider, tools, compressor - ↓ -从 Storage 加载 messages(from_seq = 0) - ↓ -设置 seq_counter = messages.len() + 1 - ↓ -返回 Session 实例 -``` - -## handle_message 流程 - -``` -handle_message(channel, chat_id, sender_id, content, media) - │ - ├── 1. 确定 dialog_id - │ │ - │ ├── 显式传入 dialog_id → 使用 - │ └── 无 dialog_id - │ ├── 查找 channel:chat_id 下最近活跃且未过期的 session - │ ├── 找到 → 使用该 session - │ └── 未找到 → 创建新 session(dialog_id = 新随机 ID) - │ - ├── 2. 获取或创建 Session - │ 有 → 更新 session_timestamps - │ 无 → 从 Storage 恢复 或 创建新 Session - │ - ├── 3. 追加用户消息并持久化 - │ seq = seq_counter; seq_counter += 1 - │ Storage.append_message()(失败重试 → 告警) - │ messages.push(user_msg) - │ message_count += 1 - │ - ├── 4. 检查 title 自动生成 - │ message_count == 10 且 title == 默认值 → LLM 生成 → 更新 title → 写回 Storage - │ - ├── 5. 注入 skills_prompt - │ - ├── 6. 新 session 注入欢迎消息(系统消息,不计入 message_count) - │ - ├── 7. 上下文压缩(如需要) - │ - ├── 8. 调用 AgentLoop - │ - ├── 9. 持久化 Agent 响应 - │ - └── 10. 返回响应 -``` - -## Dialog 生命周期命令 - -| 命令 | 行为 | -|------|------| -| `/new [标题]` | 创建新 dialog(新随机 dialog_id),新建 Session | -| `/sessions` | 列出 channel:chat_id 下最近 10 条 session(按 last_active_at 倒序) | -| `/switch ` | 切换到指定 session(从 Storage 恢复或内存命中) | -| `/rename <新标题>` | 重命名当前 session | -| `/delete` | 软删除当前 session(内存移除 + Storage 标记 deleted_at) | -| `/info` | 显示当前 session 信息 | -| `/compact` | 手动触发上下文压缩 | - -## 路由信息 - -每种 Channel 在创建 Session 时注入路由信息: - -```rust -// CLI -routing_info = json!({"type": "cli", "ws_sender_id": "xxx"}) - -// Feishu -routing_info = json!({"type": "feishu", "open_conversation_id": "oc_xxx", "tenant_key": "xxx"}) -``` - -## Title 自动生成 - -调用时机: -1. Session 首次创建时(初始 title = "新对话") -2. `message_count` 达到 10 且 title 仍为默认值时,自动更新 - -生成 Prompt: -``` -给定以下对话历史,生成一个简短的会话标题(5-15 个中文字符), -概括这个对话的核心内容或用户的主要需求。只返回一个标题,不要解释。 - -历史: -{messages} -``` - -## TTL 清理 - -- 内存 session 超时 → 释放内存,Storage 记录保留 -- 用户切换回该 session → 从 Storage 重新加载到内存 -- Storage 中的 session 记录通过 `deleted_at` 软删除,不会物理删除 - -## 文件结构 - -``` -src/ -├── storage/ -│ ├── mod.rs # Storage 主模块 -│ ├── session.rs # Session CRUD -│ ├── message.rs # Message CRUD -│ └── error.rs # StorageError -│ -└── session/ - ├── mod.rs # 导出 Session, SessionManager - ├── session.rs # Session, SessionManager 实现 - ├── session_id.rs # UnifiedSessionId - ├── commands.rs # SessionCommand - ├── events.rs # SessionEvent, DialogInfo - └── error.rs # SessionError -``` - -## 实现顺序 - -### Phase 1: Storage 基础 -1. 添加 `sqlx` + `sqlite` 依赖 -2. 实现 `Storage` 结构(连接池、初始化) -3. Session CRUD + Message CRUD -4. 写入重试逻辑 -5. 单元测试 - -### Phase 2: Session 扩展 -1. 扩展 `Session` 结构(添加 storage、routing_info、计数字段、seq_counter) -2. `from_storage()` 恢复逻辑 -3. `add_message` 持久化集成 -4. `send_system_notification` 接口 -5. Title 自动生成 - -### Phase 3: SessionManager 完善 -1. 注入 `Arc` -2. 实现 `list_dialogs()` -3. 实现 `switch_dialog()` -4. 实现 `delete_dialog()` / `rename_dialog()` -5. 后台 TTL 清理任务 -6. 集成测试 - -### Phase 4: 斜杠命令 -1. 实现 `/sessions` -2. 实现 `/switch` -3. 实现 `/rename` -4. 实现 `/delete` -5. 端到端测试 - -## 配置项 - -```json -{ - "session": { - "ttl_hours": 24, - "cleanup_interval_minutes": 60, - "auto_title_after_n_messages": 10, - "storage_retry_delays_ms": [100, 200, 300] - } -} -``` - -## 与现有代码的冲突点 - -| 冲突 | 处理方式 | -|------|----------| -| `DialogInfo` 有 `archived_at` | 删除该字段,改用 `deleted_at` | -| `SessionCommand::ArchiveDialog` | 删除 | -| `/new` 现有行为 | 改为创建新 session(新 dialog_id) | -| 现有 `Session` 无 storage/routing_info | 扩展结构,新增 `from_storage()` | -| `SessionManager` 需注入 `Arc` | 扩展构造方法 | -| stub 方法 | 实现 | diff --git a/docs/plans/2026-05-07-memory-system-design.md b/docs/plans/2026-05-07-memory-system-design.md deleted file mode 100644 index cb53249..0000000 --- a/docs/plans/2026-05-07-memory-system-design.md +++ /dev/null @@ -1,226 +0,0 @@ -# PicoBot Memory System Design - -Date: 2026-05-07 - -## 1. Overview - -Introduce a memory system that allows PicoBot agents to remember user preferences, project context, facts, and conversation history across sessions. The memory system is **unified with the existing context compression pipeline**: compression automatically produces `timeline` memory entries and advances a `last_consolidated_at` pointer to avoid redundant reprocessing. - -### Design Principles - -- **Compression is memory** (inspired by nanobot): when old messages are compressed, the summary is persisted — not discarded -- **FTS5 only** (no vector embeddings): keyword search via SQLite FTS5, sufficient for current scale -- **Extend existing infrastructure**: reuse `Storage` connection pool, `ContextCompressor`, `SystemPromptBuilder` -- **YAGNI**: no knowledge graph, no response cache, no namespace isolation, no audit trail - -## 2. Core Architecture - -``` -ContextCompressor (existing) MemoryManager (new) - │ │ - │ compress_if_needed() │ store / recall / forget - │ ├─ LLM summary → inject │ - │ └─ store(timeline entry) ──────┘ - │ └─ advance last_consolidated_at - │ -SystemPromptBuilder ── recall(knowledge, limit=5) ──→ inject into system prompt -AgentLoop ── after_turn ──→ memory_store / memory_recall / memory_forget tools -``` - -## 3. Memory Categories - -| Category | Purpose | Written By | Retrieved By | -|----------|---------|-----------|--------------| -| `knowledge` | Long-term facts, preferences, patterns, insights | Agent via `memory_store` tool | FTS5 → injected into system prompt every turn | -| `timeline` | Compressed conversation summaries | ContextCompressor automatically | FTS5 + time-range queries | - -## 4. Storage Schema - -### New table: `memories` - -Added to the existing `Storage` initialization in `src/storage/mod.rs`: - -```sql -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 -); - -CREATE VIRTUAL TABLE IF NOT EXISTS memory_fts USING fts5( - key, - content, - content=memories, - content_rowid=rowid -); -``` - -### Modified table: `sessions` - -```sql -ALTER TABLE sessions ADD COLUMN last_consolidated_at INTEGER; -``` - -## 5. Unified Compression-Memory Pipeline - -### Trigger Conditions - -Compression/consolidation fires when **any** of these conditions is met: - -| Condition | Value | Rationale | -|-----------|-------|-----------| -| Token budget exceeds 50% threshold | `context_window / 2` | Primary trigger — context is getting full | -| Accumulated N turns without consolidation | 3 (configurable) | Catch-up for short messages that don't hit token threshold | -| Session idle | 10 minutes (configurable) | Important for async channels like Feishu | - -### Flow - -``` -compress_if_needed(history, session_id): - 1. Read last_consolidated_at from session - → Only compress messages after that timestamp - 2. If no messages to compress → return history unchanged - 3. FTS5 recall(user_input, limit=recall_limit, category=knowledge) - → Inject relevant facts into system prompt - 4. LLM summarization of old messages → [Context Summary] - → Inject into current conversation - 5. Store summary as timeline entry: - key: "ctx_{session_id}_{uuid}" - content: "[YYYY-MM-DD HH:MM] summary text..." - category: timeline - 6. UPDATE sessions.last_consolidated_at = now() - 7. Return compressed history -``` - -### timeline Entry Format - -Each timeline entry follows nanobot's convention: -``` -[2026-05-07 14:30] User asked about Rust async patterns. Discussed tokio::select!, -semaphore-based rate limiting, and backpressure strategies. No code was written. -``` - -This format is grep-friendly and human-readable. - -## 6. Retrieval Strategy - -### Automatic Retrieval (every turn) - -`SystemPromptBuilder.build_system_prompt()` calls: -```rust -memory.recall(query=user_message, limit=recall_limit, category=knowledge) -``` - -Results sorted by FTS5 BM25 score, injected as: -``` -## Memory Context - -- user_prefers_rust: User prefers Rust for all backend projects -- project_picobot_stack: PicoBot uses Rust, axum, sqlx, ratatui, tokio -- user_workflow: User prefers TDD workflow with cargo test --lib -``` - -### Agent-Initiated Retrieval - -Agent uses `memory_recall` tool with optional `category`, `since`, `until` parameters. - -### Fallback - -If FTS5 returns empty results, fallback to `LIKE '%keyword%'` on `key` and `content` columns. - -## 7. Agent Tools - -| Tool | Parameters | Description | -|------|-----------|-------------| -| `memory_store` | `key: str`, `content: str`, `category: str`, `importance?: f64` | Write or update a memory entry. Key is semantic identifier (e.g., "user_language_pref") | -| `memory_recall` | `query: str`, `category?: str`, `since?: i64`, `until?: i64`, `limit?: usize` | Search memories by keyword and optional filters | -| `memory_forget` | `key: str` | Delete a memory entry by key | - -## 8. Error Handling & Degradation - -| Scenario | Strategy | -|----------|----------| -| Consolidation LLM call fails | Log warning, increment failure counter, do NOT block main flow | -| Consecutive failures >= 3 | Degrade: append raw message dump to timeline with `[RAW]` prefix, reset counter | -| FTS5 recall returns empty | Fallback to `LIKE '%keyword%'` query | -| `memory.enabled = false` | ContextCompressor works normally, no memory writes | -| MemoryManager uninitialized | ContextCompressor works with feature-gated memory write path | - -## 9. Configuration - -```json -{ - "memory": { - "enabled": true, - "consolidation_provider": "openai", - "consolidation_model": "gpt-4o-mini", - "recall_limit": 5, - "consolidation_turn_threshold": 3, - "idle_consolidation_minutes": 10, - "timeline_retention_days": 90, - "max_failures_before_degrade": 3 - } -} -``` - -| Key | Type | Default | Description | -|-----|------|---------|-------------| -| `enabled` | bool | `false` | Master switch for memory system | -| `consolidation_provider` | string | — | Provider name for consolidation LLM calls | -| `consolidation_model` | string | — | Model name for consolidation | -| `recall_limit` | usize | `5` | Max knowledge entries injected into system prompt | -| `consolidation_turn_threshold` | usize | `3` | Turns before forced consolidation | -| `idle_consolidation_minutes` | u64 | `10` | Idle time before consolidation trigger | -| `timeline_retention_days` | u64 | `90` | Auto-cleanup age for timeline entries | -| `max_failures_before_degrade` | usize | `3` | Consecutive failures before raw archive fallback | - -## 10. New Module Structure - -``` -src/ -├── memory/ -│ ├── mod.rs # MemoryManager, MemoryConfig -│ ├── types.rs # MemoryEntry, MemoryCategory, ConsolidationResult -│ └── consolidation.rs # Consolidation prompt + LLM call logic -├── storage/ -│ └── memory.rs # SQLite CRUD for memories table + FTS5 -├── tools/ -│ ├── memory_store.rs # memory_store tool -│ ├── memory_recall.rs # memory_recall tool -│ └── memory_forget.rs # memory_forget tool -``` - -## 11. Integration Points (Existing Files Modified) - -| File | Change | -|------|--------| -| `src/lib.rs` | Add `pub mod memory;` | -| `src/config/mod.rs` | Add `MemoryConfig` struct and deserialization | -| `src/storage/mod.rs` | Add `pub mod memory;`, init `memories` table and FTS5 in `init_schema()` | -| `src/storage/session.rs` | Add `last_consolidated_at` column read/write | -| `src/session/session.rs` | Add `last_consolidated_at: Option` field to Session | -| `src/agent/context_compressor.rs` | Add `memory: Option>` field, write timeline on compress | -| `src/agent/system_prompt.rs` | Add `memory_context` section via `MemoryManager::recall()` | -| `src/agent/agent_loop.rs` | No changes (tools registered via ToolRegistry) | -| `src/tools/mod.rs` | Register `memory_store`, `memory_recall`, `memory_forget` in `create_default_tools()` | -| `src/gateway/mod.rs` | Initialize `MemoryManager` in `GatewayState::new()`, pass to ContextCompressor | - -## 12. Implementation Order - -| # | Task | Dependencies | -|---|------|-------------| -| 1 | Types: `MemoryEntry`, `MemoryCategory`, `ConsolidationResult` | — | -| 2 | Config: `MemoryConfig` + deserialization | — | -| 3 | Storage: `memories` table + FTS5 + CRUD + search | #1 | -| 4 | `MemoryManager` API | #1, #2, #3 | -| 5 | Session: `last_consolidated_at` field | — | -| 6 | `ContextCompressor` memory integration | #4, #5 | -| 7 | `SystemPromptBuilder` memory context injection | #4 | -| 8 | Agent tools: `memory_store`, `memory_recall`, `memory_forget` | #4 | -| 9 | `GatewayState` initialization wiring | #4, #5, #6 | -| 10 | Unit tests | #1-#9 | diff --git a/docs/plans/2026-05-07-memory-system-impl.md b/docs/plans/2026-05-07-memory-system-impl.md deleted file mode 100644 index 9a289b6..0000000 --- a/docs/plans/2026-05-07-memory-system-impl.md +++ /dev/null @@ -1,1392 +0,0 @@ -# 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" -``` diff --git a/docs/plans/2026-05-10-incremental-session-recovery-design.md b/docs/plans/2026-05-10-incremental-session-recovery-design.md deleted file mode 100644 index 6de44ce..0000000 --- a/docs/plans/2026-05-10-incremental-session-recovery-design.md +++ /dev/null @@ -1,90 +0,0 @@ -# 启动增量恢复设计 - -## 问题 - -PicoBot 重启后,`Session::from_storage()` 全量加载 `messages` 表,恢复的 history 可能直接超出上下文窗口,首次 LLM 调用即触发压缩,浪费 token。 - -## 设计 - -### 核心思路 - -用 `last_compressed_message_at` 标记最后压缩时刻。恢复时: -- 加载该标记之后的原始消息 -- 用该 session 的 Timeline 条目替代已压缩部分 -- `seq_counter` 统一从 SQLite 查 `MAX(seq) + 1` - -``` -messages 表 memories(timeline) -┌──────────────────────────┐ ┌───────────────────────────┐ -│ created_at = T1..T5 │ ← 跳过 │ session = feishu:oc:dialog │ -│ (压缩已覆盖,用Timeline替代)│ │ created_at 降序 │ -├──────────────────────────┤ ├───────────────────────────┤ -│ created_at > T6 │ ← 加载 │ 只取最近 3 条 │ -└──────────────────────────┘ └───────────────────────────┘ -``` - -### 数据变更 - -**`sessions` 表加列:** -```sql -last_compressed_message_at INTEGER -``` - -**`SessionMeta` / `Session` 加字段:** `last_compressed_message_at: Option` - -### Storage 层新增方法 - -| 方法 | SQL | -|------|-----| -| `get_max_message_seq(session_id)` | `SELECT MAX(seq) FROM messages WHERE session_id = ?` | -| `load_messages_after_timestamp(session_id, after_ts)` | `WHERE created_at > ?` | -| `load_session_timelines(session_id, limit)` | `WHERE session_id = ? AND category = 'timeline' ORDER BY created_at DESC LIMIT ?` | - -### 压缩跟踪 - -`compress_if_needed()` 返回值改为 `CompressionResult { history, created_timelines: bool }`。 -`compress_once()` 中 LLM 摘要路径才置 `true`(Tier 2),Tier 1/3 不产生 Timeline。 - -**记录时机**(`handle_message` 正常流、溢出重试流、`/compact` 统一): -```rust -if result.created_timelines { - session.last_compressed_message_at = Some(now()); - session.persist_session_meta().await; -} -``` - -### Session::from_storage() 恢复逻辑 - -有压缩标记时: -1. `load_session_timelines(limit=4)` → 取 3 条给 LLM,第 4 条判"有更多" -2. 有更多 → 插入提示 user 消息 -3. 逐条插入 Timeline 为 `[Previous Context]` user 消息 -4. `load_messages_after_timestamp(after_ts)` → 原始尾消息 -5. `repair_tool_call_chains` - -无压缩标记 → 全量加载(现有行为)。 - -统一:`seq_counter = MAX(seq) + 1` - -### 系统提示词 - -`Session.last_compressed_message_at` 非空时追加: -``` -## 历史会话 -之前的对话摘要已归档。如需回顾历史上下文,使用 `timeline_recall` 工具搜索。 -``` - -## 改动清单 - -| # | 文件 | 改动 | -|---|------|------| -| 1 | `storage/session.rs` | `SessionMeta` 加 `last_compressed_message_at` | -| 2 | `storage/mod.rs` | DDL migration + upsert/get_session 加列 | -| 3 | `storage/mod.rs` | 新增 `get_max_message_seq`, `load_messages_after_timestamp` | -| 4 | `storage/memory.rs` | 新增 `load_session_timelines` | -| 5 | `agent/context_compressor.rs` | 返回值改为 `CompressionResult` 含 `created_timelines` | -| 6 | `session/session.rs` | `Session` 加字段,`persist_session_meta` 加字段 | -| 7 | `session/session.rs` | `from_storage()` 重写恢复逻辑 | -| 8 | `session/session.rs` | `handle_message()` 压缩后记录标记 | -| 9 | `session/session.rs` | `/compact` 命令压缩后记录标记 | -| 10 | `session/session.rs` | `build_system_prompt()` 注入 `last_compressed_message_at` | diff --git a/docs/plans/2026-05-10-incremental-session-recovery.md b/docs/plans/2026-05-10-incremental-session-recovery.md deleted file mode 100644 index 3c02216..0000000 --- a/docs/plans/2026-05-10-incremental-session-recovery.md +++ /dev/null @@ -1,674 +0,0 @@ -# 启动增量恢复 Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** PicoBot 重启后不再全量加载 messages 表,而是基于 `last_compressed_message_at` 标记增量恢复,用 Timeline 替代已压缩部分。 - -**Architecture:** 在 `sessions` 表加 `last_compressed_message_at` 列,`compress_if_needed` 返回值增加 `created_timelines` 标志,恢复时按时间戳增量加载消息并用近 3 条 Timeline 前置,`seq_counter` 统一从 SQLite 查 MAX(seq)。 - -**Tech Stack:** Rust, sqlx (SQLite), tokio - ---- - -### Task 1: SessionMeta 和数据库 DDL 加列 - -**Files:** -- Modify: `src/storage/session.rs:15` -- Modify: `src/storage/mod.rs:44-45` (DDL), `:172-180` (migration) -- Modify: `src/storage/mod.rs:317-326` (upsert_session SQL + ON CONFLICT) -- Modify: `src/storage/mod.rs:345-369` (get_session SELECT + struct) -- Modify: `src/storage/mod.rs:380-406`, `:454-479`, `:564-588`, `:728`, `:754` (list_sessions 及测试 mock) - -**Step 1: 在 `src/storage/session.rs` SessionMeta 加字段** - -在 `last_consolidated_at: Option` 后加一行: -```rust -pub last_compressed_message_at: Option, -``` - -**Step 2: DDL schema 加列** - -在 `src/storage/mod.rs` 的 CREATE TABLE sessions 中 (line 44),`last_consolidated_at INTEGER` 后加逗号和: -```sql -last_compressed_message_at INTEGER -``` - -**Step 3: migration 加列** - -在 `src/storage/mod.rs` line 182 之后(现有 migration 的 `); .ok();` 之后),添加新 migration: -```rust -// Migration: add last_compressed_message_at column if not exists -sqlx::query( - r#"ALTER TABLE sessions ADD COLUMN last_compressed_message_at INTEGER"#, -) -.execute(&self.pool) -.await -.ok(); -``` - -**Step 4: upsert_session SQL 加列** - -`src/storage/mod.rs` line 317: INSERT 列列表加 `last_compressed_message_at`,VALUES 加 `?`,ON CONFLICT DO UPDATE SET 加 `last_compressed_message_at = excluded.last_compressed_message_at`。line 338 后加 `.bind(meta.last_compressed_message_at)`。 - -**Step 5: get_session SELECT 加列** - -`src/storage/mod.rs` line 348: SELECT 列加 `last_compressed_message_at`。line 368 后加: -```rust -last_compressed_message_at: row.get("last_compressed_message_at"), -``` - -**Step 6: 其他 SELECT sessions 的地方(list_sessions 多个变体)** - -同样补 `last_compressed_message_at` 到 SELECT 列和 struct 构造。以及测试中的 mock SessionMeta 构造(line 728, 754 等)。 - -**Step 7: 编译检查** - -```bash -cargo check 2>&1 -``` - -**Step 8: Commit** - -```bash -git add src/storage/session.rs src/storage/mod.rs -git commit -m "feat(storage): add last_compressed_message_at column to sessions" -``` - ---- - -### Task 2: Storage 新增加载方法 - -**Files:** -- Modify: `src/storage/mod.rs` (在 load_messages 之后) -- Modify: `src/storage/memory.rs` (在 cleanup_old_timelines 之后) - -**Step 1: `get_max_message_seq`** - -在 `src/storage/mod.rs` 中 `load_messages` 函数后面添加: -```rust -pub async fn get_max_message_seq(&self, session_id: &str) -> Result { - let row = sqlx::query( - "SELECT COALESCE(MAX(seq), 0) as max_seq FROM messages WHERE session_id = ?", - ) - .bind(session_id) - .fetch_one(self.pool()) - .await?; - Ok(row.get::("max_seq")) -} -``` - -**Step 2: `load_messages_after_timestamp`** - -```rust -pub async fn load_messages_after_timestamp( - &self, - session_id: &str, - after_ts: i64, -) -> Result, StorageError> { - let rows = sqlx::query( - r#" - SELECT id, session_id, seq, role, content, media_refs, tool_call_id, tool_name, tool_calls, source, created_at - FROM messages - WHERE session_id = ? AND created_at > ? - ORDER BY seq ASC - "#, - ) - .bind(session_id) - .bind(after_ts) - .fetch_all(self.pool()) - .await?; - - Ok(rows.into_iter().map(|row| crate::storage::message::MessageMeta { - id: row.get("id"), - session_id: row.get("session_id"), - seq: row.get("seq"), - role: row.get("role"), - content: row.get("content"), - media_refs: row.get("media_refs"), - tool_call_id: row.get("tool_call_id"), - tool_name: row.get("tool_name"), - tool_calls: row.get("tool_calls"), - source: row.get("source"), - created_at: row.get("created_at"), - }).collect()) -} -``` - -**Step 3: `load_session_timelines`** - -在 `src/storage/memory.rs` 的 `cleanup_old_timelines` 之后(line 252 的 `}` 之前)添加: -```rust -pub async fn load_session_timelines( - &self, - session_id: &str, - limit: usize, -) -> Result, StorageError> { - let rows = sqlx::query( - r#" - SELECT id, key, content, category, importance, - session_id, created_at, updated_at - FROM memories - WHERE session_id = ? AND category = 'timeline' - ORDER BY created_at DESC - LIMIT ? - "#, - ) - .bind(session_id) - .bind(limit as i64) - .fetch_all(self.pool()) - .await?; - - parse_memory_rows(&rows) -} -``` - -**Step 4: 编译检查** - -```bash -cargo check 2>&1 -``` - -**Step 5: Commit** - -```bash -git add src/storage/mod.rs src/storage/memory.rs -git commit -m "feat(storage): add load_messages_after_timestamp, load_session_timelines, get_max_message_seq" -``` - ---- - -### Task 3: ContextCompressor 引入 CompressionResult - -**Files:** -- Modify: `src/agent/context_compressor.rs:172-274` (compress_if_needed) -- Modify: `src/agent/context_compressor.rs:320-402` (compress_once) - -**Step 1: 定义 CompressionResult** - -在 context_compressor.rs 中 `ContextCompressor` struct 定义之后添加: -```rust -pub struct CompressionResult { - pub history: Vec, - pub created_timelines: bool, -} -``` - -**Step 2: 修改 compress_if_needed 签名和返回** - -将 `pub async fn compress_if_needed(&self, mut history: Vec) -> Result, AgentError>` 改为: -```rust -pub async fn compress_if_needed( - &self, - mut history: Vec, -) -> Result { -``` - -内部的 `return Ok(history)` 改为 `return Ok(CompressionResult { history, created_timelines: false })`(Tier 1 fast trim 和不需要压缩时)。 - -**Step 3: 修改 LLM summarization pass 部分** - -在压缩循环中维护一个 `created_timelines` 标志: -```rust -let mut created_timelines = false; -for pass in 0..self.config.max_passes { - // ... - match self.compress_once(...).await { - Ok(Some(compressed)) => { - current_history = compressed; - created_timelines = true; - } - // ... - } -} -``` - -最后返回: -```rust -Ok(CompressionResult { history: current_history, created_timelines }) -``` - -**Step 4: 更新所有 compress_if_needed 调用方** - -所有 `compress_if_needed(history)` 改为 `compress_if_needed(history).await?.history`。在 `handle_message` 和 `/compact` 中还需要用到 `created_timelines`。 - -**Step 5: 编译检查** - -```bash -cargo check 2>&1 -``` - -**Step 6: Commit** - -```bash -git add src/agent/context_compressor.rs src/session/session.rs -git commit -m "feat(compressor): return CompressionResult with created_timelines flag" -``` - ---- - -### Task 4: Session 结构体和持久化 - -**Files:** -- Modify: `src/session/session.rs:52-74` (Session struct) -- Modify: `src/session/session.rs:76-121` (Session::new) -- Modify: `src/session/session.rs:298-320` (persist_session_meta) - -**Step 1: Session struct 加字段** - -在 `pub last_consolidated_at: Option` 后加: -```rust -pub last_compressed_message_at: Option, -``` - -**Step 2: Session::new 初始化** - -在 `last_consolidated_at: None` 后加: -```rust -last_compressed_message_at: None, -``` - -**Step 3: persist_session_meta 加字段** - -在 `last_consolidated_at: self.last_consolidated_at` 后加: -```rust -last_compressed_message_at: self.last_compressed_message_at, -``` - -**Step 4: 编译检查** - -```bash -cargo check 2>&1 -``` - -**Step 5: Commit** - -```bash -git add src/session/session.rs -git commit -m "feat(session): add last_compressed_message_at field to Session and persist" -``` - ---- - -### Task 5: Session::from_storage() 增量恢复 - -**Files:** -- Modify: `src/session/session.rs:124-189` (from_storage) - -**Step 1: 重写 from_storage** - -替换现有实现为: - -```rust -pub async fn from_storage( - id: UnifiedSessionId, - provider_config: LLMProviderConfig, - tools: Arc, - storage: StdArc, - memory_manager: Arc, -) -> Result { - let session_meta = storage.get_session(&id.to_string()).await - .map_err(|e| AgentError::Other(format!("failed to load session from storage: {}", e)))?; - - let mut provider_box = create_provider(provider_config.clone()) - .map_err(|e| AgentError::Other(format!("provider creation error: {}", e)))?; - provider_box.set_storage(storage.clone()); - let provider: Arc = Arc::from(provider_box); - - let compressor_config = ContextCompressionConfig { - protect_first_n: 2, - ..Default::default() - }; - - let mut compressor = ContextCompressor::with_config(provider.clone(), provider_config.token_limit, compressor_config, memory_manager.clone()); - compressor.set_session_id(Some(id.to_string())); - - // Determine recovery strategy - let mut chat_messages: Vec = Vec::new(); - - if let Some(after_ts) = session_meta.last_compressed_message_at { - // Load last 4 timelines to determine if there are > 3 - let timelines = storage - .load_session_timelines(&id.to_string(), 4) - .await - .unwrap_or_default(); - - let has_more_timelines = timelines.len() > 3; - - // Insert hint if more timelines exist - if has_more_timelines { - chat_messages.push(ChatMessage::user( - "[Earlier conversation summaries exist. \ - Use `timeline_recall` to search if needed.]" - )); - } - - // Insert latest 3 timelines as context (reversed: oldest first) - for tl in timelines.iter().take(3).rev() { - chat_messages.push(ChatMessage::user(format!( - "[Previous Context]\n{}", tl.content - ))); - } - - // Load raw messages after compressed timestamp - let tail = storage - .load_messages_after_timestamp(&id.to_string(), after_ts) - .await - .unwrap_or_default(); - - let mut tail_msgs: Vec = tail.into_iter().map(|m| { - ChatMessage { - id: m.id, - role: m.role, - content: m.content, - media_refs: m.media_refs.map(|refs| serde_json::from_str(&refs).unwrap_or_default()).unwrap_or_default(), - timestamp: m.created_at, - tool_call_id: m.tool_call_id, - tool_name: m.tool_name, - tool_calls: m.tool_calls - .and_then(|tc| serde_json::from_str::>(&tc).ok()) - .filter(|v| !v.is_empty()), - source: m.source.and_then(|s| serde_json::from_str(&s).ok()), - } - }).collect(); - - repair_tool_call_chains(&mut tail_msgs); - chat_messages.extend(tail_msgs); - } else { - // No prior compression — load all messages (existing behavior) - let messages = storage.load_messages(&id.to_string(), 0).await - .map_err(|e| AgentError::Other(format!("failed to load messages from storage: {}", e)))?; - - chat_messages = messages.into_iter().map(|m| { - ChatMessage { - id: m.id, - role: m.role, - content: m.content, - media_refs: m.media_refs.map(|refs| serde_json::from_str(&refs).unwrap_or_default()).unwrap_or_default(), - timestamp: m.created_at, - tool_call_id: m.tool_call_id, - tool_name: m.tool_name, - tool_calls: m.tool_calls - .and_then(|tc| serde_json::from_str::>(&tc).ok()) - .filter(|v| !v.is_empty()), - source: m.source.and_then(|s| serde_json::from_str(&s).ok()), - } - }).collect(); - - repair_tool_call_chains(&mut chat_messages); - } - - // seq_counter from actual DB max - let max_seq = storage - .get_max_message_seq(&id.to_string()) - .await - .unwrap_or(0); - let seq_counter = max_seq + 1; - let total_message_count = session_meta.message_count; - - Ok(Self { - id: id.clone(), - title: session_meta.title, - created_at: session_meta.created_at, - last_active_at: session_meta.last_active_at, - message_count: session_meta.message_count, - total_message_count, - messages: chat_messages, - seq_counter, - provider_config: provider_config.clone(), - provider: provider.clone(), - tools, - compressor, - storage: Some(storage), - routing_info: session_meta.routing_info.unwrap_or_default(), - last_consolidated_at: session_meta.last_consolidated_at, - last_compressed_message_at: session_meta.last_compressed_message_at, - memory_manager, - }) -} -``` - -**Step 2: 编译检查** - -```bash -cargo check 2>&1 -``` - -**Step 3: Commit** - -```bash -git add src/session/session.rs -git commit -m "feat(session): incremental recovery from storage using compressed timeline" -``` - ---- - -### Task 6: 系统提示词加历史会话提示 - -**Files:** -- Modify: `src/agent/system_prompt.rs:289-304` (MemorySection) -- Modify: `src/agent/system_prompt.rs:16-23` (PromptContext) -- Modify: `src/agent/system_prompt.rs:343-358` (build_system_prompt free function) -- Modify: `src/session/session.rs:411-426` (build_system_prompt) - -**Step 1: PromptContext 加 has_compressed_history 字段** - -```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>, - pub memory_context: Option<&'a str>, - pub has_compressed_history: bool, -} -``` - -**Step 2: 加 HistorySection** - -在 MemorySection 后面添加: -```rust -pub struct HistorySection; - -impl PromptSection for HistorySection { - fn name(&self) -> &str { - "history" - } - - fn build(&self, ctx: &PromptContext<'_>) -> String { - if ctx.has_compressed_history { - "## 历史会话\n之前的对话摘要已归档。如需回顾历史上下文,使用 `timeline_recall` 工具搜索。".to_string() - } else { - String::new() - } - } -} -``` - -**Step 3: 注册到 SystemPromptBuilder::with_defaults** - -在 `with_defaults()` 的 sections vec 中 `Box::new(MemorySection)` 后加 `Box::new(HistorySection)`。 - -**Step 4: 更新 build_system_prompt 签名和调用** - -```rust -pub fn build_system_prompt( - workspace_dir: &Path, - model_name: &str, - tools: &ToolRegistry, - session_id: Option<&str>, - memory_context: Option<&str>, - has_compressed_history: bool, -) -> String { - let ctx = PromptContext { - workspace_dir, - model_name, - tools, - session_id, - memory_context, - has_compressed_history, - }; - SystemPromptBuilder::with_defaults().build(&ctx) -} -``` - -**Step 5: 更新 Session::build_system_prompt** - -```rust -pub fn build_system_prompt(&self, skills_prompt: &str, memory_context: Option<&str>) -> String { - let base_prompt = build_system_prompt( - &self.provider_config.workspace_dir, - &self.provider_config.model_id, - &self.tools, - Some(&self.id.to_string()), - memory_context, - self.last_compressed_message_at.is_some(), - ); - - if skills_prompt.trim().is_empty() { - base_prompt - } else { - format!("{}\n\n## Skills\n\n{}\n\nUse the `get_skill` tool to load a skill's full content when needed.", base_prompt, skills_prompt) - } -} -``` - -**Step 6: 更新所有其他 build_system_prompt 调用方** - -搜索 `build_system_prompt(` 的所有调用位置,每个都要加 `false` 参数。主要有 `agent/agent_loop.rs` 中的两个调用。 - -**Step 7: 编译检查** - -```bash -cargo check 2>&1 -``` - -**Step 8: Commit** - -```bash -git add src/agent/system_prompt.rs src/session/session.rs src/agent/agent_loop.rs -git commit -m "feat(system-prompt): add history section for archived conversation context" -``` - ---- - -### Task 7: handle_message 和 /compact 记录压缩标记 - -**Files:** -- Modify: `src/session/session.rs:1339-1355` (handle_message 压缩后) -- Modify: `src/session/session.rs:1372-1376` (handle_message 溢出重试) -- Modify: `src/session/session.rs:851-872` (/compact 命令) - -**Step 1: handle_message 正常流** - -在 `compress_if_needed(history).await?` 之后(line 1346),改为: -```rust -let result = session_guard.compressor - .compress_if_needed(history) - .await?; -if result.created_timelines { - session_guard.last_compressed_message_at = Some(chrono::Utc::now().timestamp_millis()); - if let Err(e) = session_guard.persist_session_meta().await { - tracing::warn!(error = %e, "Failed to persist compressed message marker"); - } -} -let mut history = result.history; -``` - -同时删除后面(line 1350-1355)单独的 `persist_session_meta` 调用(现在已合入上面的逻辑)。 - -**Step 2: handle_message 溢出重试流** - -```rust -let raw = session_guard.get_history().to_vec(); -let result = session_guard.compressor.compress_if_needed(raw).await?; -if result.created_timelines { - session_guard.last_compressed_message_at = Some(chrono::Utc::now().timestamp_millis()); - let _ = session_guard.persist_session_meta().await; -} -let mut retry = result.history; -retry.insert(0, ChatMessage::system(system_prompt)); -agent.process(retry).await? -``` - -**Step 3: /compact 命令** - -```rust -let result = session_guard.compressor - .compress_if_needed(history) - .await?; -let compressed_count = result.history.len(); -if result.created_timelines { - session_guard.last_compressed_message_at = Some(chrono::Utc::now().timestamp_millis()); - let _ = session_guard.persist_session_meta().await; -} -session_guard.clear_history(); -for msg in result.history { - session_guard.add_message(msg, false).await - .map_err(|e| AgentError::Other(format!("persist error: {}", e)))?; -} -``` - -同时确认 `compress_if_needed` 的 import 正常(已在 scope 中)。 - -**Step 4: 编译检查** - -```bash -cargo check 2>&1 -``` - -**Step 5: Commit** - -```bash -git add src/session/session.rs -git commit -m "feat(session): record last_compressed_message_at after compression" -``` - ---- - -### Task 8: 全局编译和测试 - -**Step 1: 全局编译** - -```bash -cargo check 2>&1 -``` - -修复所有编译错误,确保全部文件一致。 - -**Step 2: 运行单元测试** - -```bash -cargo test --lib 2>&1 -``` - -**Step 3: 测试通过后 commit** - -```bash -git add -A -git commit -m "chore: fix remaining compilation and test issues for incremental recovery" -``` - -**Step 4: 运行 lint** - -```bash -cargo clippy --lib 2>&1 | head -50 -``` - -修复任何 warning。 - ---- - -### Task 9: 验证 & 提交设计文档 - -**Step 1: 最终验证** - -```bash -cargo test --lib 2>&1 -``` - -**Step 2: Commit 设计文档** - -```bash -git add docs/plans/2026-05-10-incremental-session-recovery-design.md -git commit -m "docs: add incremental session recovery design doc" -``` diff --git a/docs/superpowers/plans/2026-05-04-scheduled-tasks.md b/docs/superpowers/plans/2026-05-04-scheduled-tasks.md deleted file mode 100644 index 7250357..0000000 --- a/docs/superpowers/plans/2026-05-04-scheduled-tasks.md +++ /dev/null @@ -1,2356 +0,0 @@ -# Scheduled Tasks (Cron Jobs) Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add a cron-like scheduled task system to PicoBot that triggers agent LLM prompts on a schedule (cron expression / fixed interval / one-shot), with results delivered to channels. - -**Architecture:** A new `src/scheduler/` module with its own SQLite store (sharing the existing `sqlx::SqlitePool` via `storage.pool()`). The scheduler runs as a tokio background task, calling `SessionManager::handle_cron_message()` directly. Agent-facing tools (cron_add, cron_list, etc.) let the LLM manage jobs. Schedule computation uses the `cron` and `chrono-tz` crates. - -**Tech Stack:** Rust, tokio, sqlx, `cron` 0.15, `chrono-tz` 0.10 - ---- - -## File Structure - -| File | Create/Modify | Responsibility | -|------|--------------|----------------| -| `Cargo.toml` | Modify | Add `cron`, `chrono-tz` dependencies | -| `src/lib.rs` | Modify | Add `pub mod scheduler;` | -| `src/config/mod.rs` | Modify | Add `SchedulerConfig` to `GatewayConfig` | -| `src/scheduler/types.rs` | Create | `Schedule`, `ScheduledJob`, `JobRun` data types | -| `src/scheduler/store.rs` | Create | SQLite schema + CRUD for `scheduled_jobs` and `job_runs` | -| `src/scheduler/mod.rs` | Create | `Scheduler` struct, `run()` loop, `next_run_for_schedule()` | -| `src/scheduler/tools.rs` | Create | 6 agent tools: `cron_add/list/remove/enable/disable/update` | -| `src/bus/message.rs` | Modify | Add `ChatMessage::user_with_source()` factory | -| `src/session/session.rs` | Modify | Add `handle_cron_message()` method | -| `src/gateway/mod.rs` | Modify | Create `Scheduler`, spawn background task, register cron tools | -| `src/session/session_id.rs` | Modify | Add `from_components()` convenience constructor | - ---- - -### Task 1: Add Cron Dependencies - -**Files:** -- Modify: `Cargo.toml` - -- [ ] **Step 1: Add `cron` and `chrono-tz` to dependencies** - -After line 28 (`tempfile = "3"`), insert: - -```toml -cron = "0.15" -chrono-tz = "0.10" -``` - -- [ ] **Step 2: Verify build** - -Run: `cargo check 2>&1` -Expected: Dependencies download and resolve. No code referencing them yet, so no compile errors. - -- [ ] **Step 3: Commit** - -```bash -git add Cargo.toml Cargo.lock -git commit -m "deps: add cron and chrono-tz for scheduled tasks" -``` - ---- - -### Task 2: Add SchedulerConfig - -**Files:** -- Modify: `src/config/mod.rs:143` (after existing `session_db_path` field) - -- [ ] **Step 1: Define `SchedulerConfig` struct** - -Add after the closing `}` of `GatewayConfig` (after line 143, before the `ClientConfig` block): - -```rust -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SchedulerConfig { - /// Whether the scheduler is enabled - #[serde(default = "default_scheduler_enabled")] - pub enabled: bool, - /// Poll interval in seconds (how often to check for due jobs) - #[serde(default = "default_poll_interval_secs")] - pub poll_interval_secs: u64, - /// Maximum concurrent job executions (currently sequential, reserved for future) - #[serde(default = "default_max_concurrent")] - pub max_concurrent: usize, -} - -fn default_scheduler_enabled() -> bool { true } - -fn default_poll_interval_secs() -> u64 { 60 } - -fn default_max_concurrent() -> usize { 1 } - -impl Default for SchedulerConfig { - fn default() -> Self { - Self { - enabled: true, - poll_interval_secs: 60, - max_concurrent: 1, - } - } -} -``` - -- [ ] **Step 2: Add `scheduler` field to `GatewayConfig`** - -In `GatewayConfig` (line 132-143), add after `session_db_path`: - -```rust -#[serde(default)] -pub scheduler: Option, -``` - -The full struct becomes: - -```rust -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct GatewayConfig { - #[serde(default = "default_gateway_host")] - pub host: String, - #[serde(default = "default_gateway_port")] - pub port: u16, - #[serde(default, rename = "session_ttl_hours")] - pub session_ttl_hours: Option, - #[serde(default, rename = "cleanup_interval_minutes")] - pub cleanup_interval_minutes: Option, - #[serde(default, rename = "session_db_path")] - pub session_db_path: Option, - #[serde(default)] - pub scheduler: Option, -} -``` - -- [ ] **Step 3: Update `Default for GatewayConfig`** - -In the `impl Default for GatewayConfig` block (around line 163), add: - -```rust -scheduler: None, -``` - -- [ ] **Step 4: Verify build** - -Run: `cargo check 2>&1` -Expected: Compiles successfully. - -- [ ] **Step 5: Commit** - -```bash -git add src/config/mod.rs -git commit -m "feat: add SchedulerConfig to GatewayConfig" -``` - ---- - -### Task 3: Create Scheduler Data Types - -**Files:** -- Create: `src/scheduler/types.rs` -- Modify: `src/scheduler/mod.rs` (stub) -- Modify: `src/lib.rs` - -- [ ] **Step 1: Register the scheduler module** - -In `src/lib.rs`, after `pub mod providers;` (line 14), add: - -```rust -pub mod scheduler; -``` - -- [ ] **Step 2: Create stub `src/scheduler/mod.rs`** - -```rust -pub mod types; -pub mod store; -pub mod tools; - -pub use types::{JobRun, Schedule, ScheduledJob}; -``` - -- [ ] **Step 3: Define types in `src/scheduler/types.rs`** - -```rust -use serde::{Deserialize, Serialize}; - -/// How a job is scheduled. Serialized as JSON in the database `schedule` column. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type")] -pub enum Schedule { - /// One-shot: fires once at a specific Unix millisecond timestamp, then disables. - #[serde(rename = "at")] - At { at: i64 }, - /// Recurring: fires every `every_ms` milliseconds. - #[serde(rename = "every")] - Every { every_ms: u64 }, - /// Recurring: fires on a cron schedule with optional timezone. - #[serde(rename = "cron")] - Cron { expr: String, tz: Option }, -} - -/// A scheduled job stored in the database. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ScheduledJob { - pub id: String, - pub name: String, - /// JSON-serialized `Schedule` stored as TEXT in SQLite. - pub schedule: Schedule, - pub prompt: String, - pub channel: String, - pub chat_id: String, - pub model: Option, - pub enabled: bool, - pub delete_after_run: bool, - pub next_run_at: i64, - pub last_run_at: Option, - pub last_status: Option, - pub last_error: Option, - pub created_at: i64, - pub updated_at: i64, -} - -/// A single execution record for a job. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct JobRun { - pub id: i64, - pub job_id: String, - pub started_at: i64, - pub finished_at: i64, - pub status: String, - pub output: Option, - pub error: Option, - pub duration_ms: i64, -} -``` - -- [ ] **Step 4: Verify build** - -Run: `cargo check 2>&1` -Expected: Compiles successfully. - -- [ ] **Step 5: Commit** - -```bash -git add src/lib.rs src/scheduler/ -git commit -m "feat: add scheduler data types (Schedule, ScheduledJob, JobRun)" -``` - ---- - -### Task 4: Add `ChatMessage::user_with_source` and `Session::create_user_message_with_source` - -**Files:** -- Modify: `src/bus/message.rs:198` (after `pub fn tool(...)`) -- Modify: `src/session/session.rs:252` (after `create_user_message`) - -- [ ] **Step 1: Write the failing test** - -Create inline test in `src/bus/message.rs`, immediately before the closing `}` of the `impl ChatMessage` block (after line 197): - -```rust - pub fn user_with_source(content: impl Into, source: MessageSource) -> Self { - Self { - id: uuid::Uuid::new_v4().to_string(), - role: "user".to_string(), - content: content.into(), - media_refs: Vec::new(), - timestamp: current_timestamp(), - tool_call_id: None, - tool_name: None, - tool_calls: None, - source: Some(source), - } - } -``` - -No test for this factory method separately — it's pure data construction. - -- [ ] **Step 2: Write the failing test for `create_user_message_with_source`** - -In `src/session/session.rs`, after `create_user_message` (line 252), add this test: - -```rust - pub fn create_user_message_with_source(&self, content: &str, media_refs: Vec, source: crate::bus::MessageSource) -> ChatMessage { - if media_refs.is_empty() { - ChatMessage::user_with_source(content, source) - } else { - // For simplicity, ignore media in cron messages (media is always empty from scheduler) - ChatMessage::user_with_source(content, source) - } - } -``` - -No test for this separately — it's used in `handle_cron_message` which gets tested via integration. - -- [ ] **Step 3: Verify build** - -Run: `cargo check 2>&1` -Expected: Compiles successfully. - -- [ ] **Step 4: Commit** - -```bash -git add src/bus/message.rs src/session/session.rs -git commit -m "feat: add ChatMessage::user_with_source and Session::create_user_message_with_source" -``` - ---- - -### Task 5: Create SchedulerStore (SQLite Schema + CRUD) - -**Files:** -- Create: `src/scheduler/store.rs` - -- [ ] **Step 1: Write the failing test** - -Write inline tests at the bottom of `src/scheduler/store.rs`. These test `SchedulerStore` against an **in-memory** SQLite database: - -```rust -#[cfg(test)] -mod tests { - use super::*; - use crate::scheduler::types::{Schedule, ScheduledJob, JobRun}; - use sqlx::SqlitePool; - - fn now() -> i64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_millis() as i64 - } - - async fn setup_pool() -> SqlitePool { - let pool = SqlitePool::connect("sqlite::memory:").await.unwrap(); - SchedulerStore::init(&pool).await.unwrap(); - pool - } - - #[tokio::test] - async fn test_init_creates_tables() { - let pool = setup_pool().await; - let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM scheduled_jobs") - .fetch_one(&pool).await.unwrap(); - assert_eq!(row.0, 0); - } - - #[tokio::test] - async fn test_add_and_get_job() { - let pool = setup_pool().await; - let t = now(); - let job = ScheduledJob { - id: "job-1".into(), - name: "test job".into(), - schedule: Schedule::Every { every_ms: 3600000 }, - prompt: "say hello".into(), - channel: "cli_chat".into(), - chat_id: "conn-1".into(), - model: None, - enabled: true, - delete_after_run: false, - next_run_at: t + 3600000, - last_run_at: None, - last_status: None, - last_error: None, - created_at: t, - updated_at: t, - }; - SchedulerStore::add_job(&pool, &job).await.unwrap(); - let got = SchedulerStore::get_job(&pool, "job-1").await.unwrap(); - assert_eq!(got.id, "job-1"); - assert_eq!(got.name, "test job"); - assert_eq!(got.prompt, "say hello"); - } - - #[tokio::test] - async fn test_list_jobs() { - let pool = setup_pool().await; - let t = now(); - for i in 0..3 { - let job = ScheduledJob { - id: format!("job-{}", i), - name: format!("job {}", i), - schedule: Schedule::Every { every_ms: 3600000 }, - prompt: "ping".into(), - channel: "cli_chat".into(), - chat_id: "conn-1".into(), - model: None, enabled: true, delete_after_run: false, - next_run_at: t + 1000, last_run_at: None, - last_status: None, last_error: None, - created_at: t, updated_at: t, - }; - SchedulerStore::add_job(&pool, &job).await.unwrap(); - } - let jobs = SchedulerStore::list_jobs(&pool).await.unwrap(); - assert_eq!(jobs.len(), 3); - } - - #[tokio::test] - async fn test_remove_job() { - let pool = setup_pool().await; - let t = now(); - let job = ScheduledJob { - id: "job-rm".into(), name: "remove me".into(), - schedule: Schedule::Every { every_ms: 1000 }, - prompt: "hi".into(), channel: "cli_chat".into(), chat_id: "c".into(), - model: None, enabled: true, delete_after_run: false, - next_run_at: t, last_run_at: None, - last_status: None, last_error: None, - created_at: t, updated_at: t, - }; - SchedulerStore::add_job(&pool, &job).await.unwrap(); - SchedulerStore::remove_job(&pool, "job-rm").await.unwrap(); - let result = SchedulerStore::get_job(&pool, "job-rm").await; - assert!(result.is_err()); - } - - #[tokio::test] - async fn test_set_enabled() { - let pool = setup_pool().await; - let t = now(); - let job = ScheduledJob { - id: "job-toggle".into(), name: "toggle".into(), - schedule: Schedule::Every { every_ms: 1000 }, - prompt: "hi".into(), channel: "cli_chat".into(), chat_id: "c".into(), - model: None, enabled: true, delete_after_run: false, - next_run_at: t, last_run_at: None, - last_status: None, last_error: None, - created_at: t, updated_at: t, - }; - SchedulerStore::add_job(&pool, &job).await.unwrap(); - SchedulerStore::set_enabled(&pool, "job-toggle", false).await.unwrap(); - let got = SchedulerStore::get_job(&pool, "job-toggle").await.unwrap(); - assert!(!got.enabled); - } - - #[tokio::test] - async fn test_due_jobs_only_returns_enabled_and_overdue() { - let pool = setup_pool().await; - let t = now(); - // Three jobs: due, not-due-yet, disabled-but-due - let jobs = vec![ - ScheduledJob { - id: "due".into(), name: "due".into(), - schedule: Schedule::At { at: t }, prompt: "1".into(), - channel: "cli_chat".into(), chat_id: "c".into(), - model: None, enabled: true, delete_after_run: false, - next_run_at: t - 1000, last_run_at: None, - last_status: None, last_error: None, - created_at: t, updated_at: t, - }, - ScheduledJob { - id: "future".into(), name: "future".into(), - schedule: Schedule::At { at: t + 99999999 }, prompt: "2".into(), - channel: "cli_chat".into(), chat_id: "c".into(), - model: None, enabled: true, delete_after_run: false, - next_run_at: t + 99999999, last_run_at: None, - last_status: None, last_error: None, - created_at: t, updated_at: t, - }, - ScheduledJob { - id: "disabled-due".into(), name: "disabled due".into(), - schedule: Schedule::At { at: t }, prompt: "3".into(), - channel: "cli_chat".into(), chat_id: "c".into(), - model: None, enabled: false, delete_after_run: false, - next_run_at: t - 1000, last_run_at: None, - last_status: None, last_error: None, - created_at: t, updated_at: t, - }, - ]; - for j in &jobs { - SchedulerStore::add_job(&pool, j).await.unwrap(); - } - let due = SchedulerStore::due_jobs(&pool, t, 10).await.unwrap(); - assert_eq!(due.len(), 1); - assert_eq!(due[0].id, "due"); - } - - #[tokio::test] - async fn test_record_run_and_list_runs() { - let pool = setup_pool().await; - let t = now(); - let job = ScheduledJob { - id: "job-run".into(), name: "run test".into(), - schedule: Schedule::Every { every_ms: 1000 }, - prompt: "hi".into(), channel: "cli_chat".into(), chat_id: "c".into(), - model: None, enabled: true, delete_after_run: false, - next_run_at: t, last_run_at: None, - last_status: None, last_error: None, - created_at: t, updated_at: t, - }; - SchedulerStore::add_job(&pool, &job).await.unwrap(); - - let run = JobRun { - id: 0, job_id: "job-run".into(), - started_at: t, finished_at: t + 500, - status: "ok".into(), output: Some("hello".into()), - error: None, duration_ms: 500, - }; - SchedulerStore::record_run(&pool, &run).await.unwrap(); - let runs = SchedulerStore::list_runs(&pool, "job-run", 10).await.unwrap(); - assert_eq!(runs.len(), 1); - assert_eq!(runs[0].status, "ok"); - assert_eq!(runs[0].output.as_deref(), Some("hello")); - } - - #[tokio::test] - async fn test_update_job() { - let pool = setup_pool().await; - let t = now(); - let job = ScheduledJob { - id: "job-update".into(), name: "old name".into(), - schedule: Schedule::Every { every_ms: 1000 }, - prompt: "old prompt".into(), channel: "feishu".into(), - chat_id: "oc_1".into(), model: None, - enabled: true, delete_after_run: false, - next_run_at: t, last_run_at: None, - last_status: None, last_error: None, - created_at: t, updated_at: t, - }; - SchedulerStore::add_job(&pool, &job).await.unwrap(); - SchedulerStore::update_job( - &pool, "job-update", - Some("new prompt".into()), - Some(Schedule::Every { every_ms: 60000 }), - None, None, None, - ).await.unwrap(); - let got = SchedulerStore::get_job(&pool, "job-update").await.unwrap(); - assert_eq!(got.prompt, "new prompt"); - } -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `cargo test --lib scheduler::store -- 2>&1` -Expected: All tests FAIL because `SchedulerStore` and its methods don't exist yet (compilation errors). - -- [ ] **Step 3: Implement `SchedulerStore`** - -Write `src/scheduler/store.rs`: - -```rust -use sqlx::Row; -use sqlx::SqlitePool; - -use super::types::{JobRun, Schedule, ScheduledJob}; - -/// Persistence layer for scheduled jobs and run history. -/// Uses a shared `sqlx::SqlitePool` (obtained from `crate::storage::Storage::pool()`). -pub struct SchedulerStore; - -impl SchedulerStore { - /// Initialize the scheduler tables. Idempotent (CREATE TABLE IF NOT EXISTS). - pub async fn init(pool: &SqlitePool) -> Result<(), Box> { - sqlx::query( - r#" - CREATE TABLE IF NOT EXISTS scheduled_jobs ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - schedule TEXT NOT NULL, - prompt TEXT NOT NULL, - channel TEXT NOT NULL, - chat_id TEXT NOT NULL, - model TEXT, - enabled INTEGER NOT NULL DEFAULT 1, - delete_after_run INTEGER NOT NULL DEFAULT 0, - next_run_at INTEGER NOT NULL, - last_run_at INTEGER, - last_status TEXT, - last_error TEXT, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL - ) - "#, - ) - .execute(pool) - .await?; - - sqlx::query( - r#" - CREATE TABLE IF NOT EXISTS job_runs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - job_id TEXT NOT NULL REFERENCES scheduled_jobs(id) ON DELETE CASCADE, - started_at INTEGER NOT NULL, - finished_at INTEGER NOT NULL, - status TEXT NOT NULL, - output TEXT, - error TEXT, - duration_ms INTEGER NOT NULL - ) - "#, - ) - .execute(pool) - .await?; - - sqlx::query( - "CREATE INDEX IF NOT EXISTS idx_jobs_next_run ON scheduled_jobs(enabled, next_run_at)", - ) - .execute(pool) - .await?; - - sqlx::query("CREATE INDEX IF NOT EXISTS idx_runs_job_id ON job_runs(job_id)") - .execute(pool) - .await?; - - Ok(()) - } - - /// Insert a new job. Returns an error if a job with the same ID already exists. - pub async fn add_job( - pool: &SqlitePool, - job: &ScheduledJob, - ) -> Result<(), Box> { - let schedule_json = serde_json::to_string(&job.schedule)?; - sqlx::query( - r#" - INSERT INTO scheduled_jobs - (id, name, schedule, prompt, channel, chat_id, model, - enabled, delete_after_run, next_run_at, last_run_at, - last_status, last_error, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - "#, - ) - .bind(&job.id) - .bind(&job.name) - .bind(&schedule_json) - .bind(&job.prompt) - .bind(&job.channel) - .bind(&job.chat_id) - .bind(&job.model) - .bind(job.enabled as i32) - .bind(job.delete_after_run as i32) - .bind(job.next_run_at) - .bind(job.last_run_at) - .bind(&job.last_status) - .bind(&job.last_error) - .bind(job.created_at) - .bind(job.updated_at) - .execute(pool) - .await?; - Ok(()) - } - - /// Fetch a single job by ID. - pub async fn get_job( - pool: &SqlitePool, - id: &str, - ) -> Result> { - let row = sqlx::query("SELECT * FROM scheduled_jobs WHERE id = ?") - .bind(id) - .fetch_optional(pool) - .await? - .ok_or_else(|| format!("job not found: {id}"))?; - Ok(row_to_job(&row)?) - } - - /// List all jobs, ordered by next_run_at ascending. - pub async fn list_jobs( - pool: &SqlitePool, - ) -> Result, Box> { - let rows = sqlx::query("SELECT * FROM scheduled_jobs ORDER BY next_run_at ASC") - .fetch_all(pool) - .await?; - rows.iter().map(row_to_job).collect() - } - - /// Delete a job (cascades to job_runs). - pub async fn remove_job( - pool: &SqlitePool, - id: &str, - ) -> Result<(), Box> { - sqlx::query("DELETE FROM scheduled_jobs WHERE id = ?") - .bind(id) - .execute(pool) - .await?; - Ok(()) - } - - /// Enable or disable a job. - pub async fn set_enabled( - pool: &SqlitePool, - id: &str, - enabled: bool, - ) -> Result<(), Box> { - sqlx::query("UPDATE scheduled_jobs SET enabled = ?, updated_at = ? WHERE id = ?") - .bind(enabled as i32) - .bind(now_ms()) - .bind(id) - .execute(pool) - .await?; - Ok(()) - } - - /// Update selective fields on a job. Pass `None` for fields that should not change. - #[allow(clippy::too_many_arguments)] - pub async fn update_job( - pool: &SqlitePool, - id: &str, - prompt: Option, - schedule: Option, - channel: Option, - chat_id: Option, - model: Option, - ) -> Result<(), Box> { - let now = now_ms(); - - if let Some(p) = prompt { - sqlx::query( - "UPDATE scheduled_jobs SET prompt = ?, updated_at = ? WHERE id = ?", - ) - .bind(&p) - .bind(now) - .bind(id) - .execute(pool) - .await?; - } - if let Some(s) = schedule { - let json = serde_json::to_string(&s)?; - sqlx::query( - "UPDATE scheduled_jobs SET schedule = ?, updated_at = ? WHERE id = ?", - ) - .bind(&json) - .bind(now) - .bind(id) - .execute(pool) - .await?; - } - if let Some(c) = channel { - sqlx::query( - "UPDATE scheduled_jobs SET channel = ?, updated_at = ? WHERE id = ?", - ) - .bind(&c) - .bind(now) - .bind(id) - .execute(pool) - .await?; - } - if let Some(c) = chat_id { - sqlx::query( - "UPDATE scheduled_jobs SET chat_id = ?, updated_at = ? WHERE id = ?", - ) - .bind(&c) - .bind(now) - .bind(id) - .execute(pool) - .await?; - } - if let Some(m) = model { - sqlx::query( - "UPDATE scheduled_jobs SET model = ?, updated_at = ? WHERE id = ?", - ) - .bind(&m) - .bind(now) - .bind(id) - .execute(pool) - .await?; - } - Ok(()) - } - - /// Update next_run_at and last_run_at for a job (used during reschedule). - pub async fn set_next_run( - pool: &SqlitePool, - id: &str, - next_run_at: i64, - ) -> Result<(), Box> { - let now = now_ms(); - sqlx::query( - "UPDATE scheduled_jobs SET next_run_at = ?, last_run_at = ?, updated_at = ? WHERE id = ?", - ) - .bind(next_run_at) - .bind(now) - .bind(now) - .bind(id) - .execute(pool) - .await?; - Ok(()) - } - - /// Set last_run_at and optionally last_status / last_error (used when starting job execution). - pub async fn touch_last_run( - pool: &SqlitePool, - id: &str, - at: i64, - ) -> Result<(), Box> { - sqlx::query( - "UPDATE scheduled_jobs SET last_run_at = ?, updated_at = ? WHERE id = ?", - ) - .bind(at) - .bind(at) - .bind(id) - .execute(pool) - .await?; - Ok(()) - } - - /// Set last_status and last_error after job completion. - pub async fn set_last_status( - pool: &SqlitePool, - id: &str, - status: &str, - error: Option<&str>, - ) -> Result<(), Box> { - let now = now_ms(); - sqlx::query( - "UPDATE scheduled_jobs SET last_status = ?, last_error = ?, updated_at = ? WHERE id = ?", - ) - .bind(status) - .bind(error) - .bind(now) - .bind(id) - .execute(pool) - .await?; - Ok(()) - } - - /// Fetch enabled jobs whose next_run_at <= now, up to `limit`. - pub async fn due_jobs( - pool: &SqlitePool, - now: i64, - limit: usize, - ) -> Result, Box> { - let rows = sqlx::query( - "SELECT * FROM scheduled_jobs WHERE enabled = 1 AND next_run_at <= ? ORDER BY next_run_at ASC LIMIT ?", - ) - .bind(now) - .bind(limit as i64) - .fetch_all(pool) - .await?; - rows.iter().map(row_to_job).collect() - } - - /// Record a run execution. - pub async fn record_run( - pool: &SqlitePool, - run: &JobRun, - ) -> Result<(), Box> { - sqlx::query( - r#" - INSERT INTO job_runs (job_id, started_at, finished_at, status, output, error, duration_ms) - VALUES (?, ?, ?, ?, ?, ?, ?) - "#, - ) - .bind(&run.job_id) - .bind(run.started_at) - .bind(run.finished_at) - .bind(&run.status) - .bind(&run.output) - .bind(&run.error) - .bind(run.duration_ms) - .execute(pool) - .await?; - Ok(()) - } - - /// List the most recent `limit` runs for a job, newest first. - pub async fn list_runs( - pool: &SqlitePool, - job_id: &str, - limit: usize, - ) -> Result, Box> { - let rows = sqlx::query( - "SELECT * FROM job_runs WHERE job_id = ? ORDER BY finished_at DESC LIMIT ?", - ) - .bind(job_id) - .bind(limit as i64) - .fetch_all(pool) - .await?; - rows.iter() - .map(|r| { - Ok(JobRun { - id: r.try_get("id")?, - job_id: r.try_get("job_id")?, - started_at: r.try_get("started_at")?, - finished_at: r.try_get("finished_at")?, - status: r.try_get("status")?, - output: r.try_get("output")?, - error: r.try_get("error")?, - duration_ms: r.try_get("duration_ms")?, - }) - }) - .collect() - } - - /// Delete jobs created before `before` that are disabled. - pub async fn cleanup_disabled( - pool: &SqlitePool, - before: i64, - ) -> Result<(), Box> { - sqlx::query( - "DELETE FROM scheduled_jobs WHERE enabled = 0 AND updated_at < ?", - ) - .bind(before) - .execute(pool) - .await?; - Ok(()) - } -} - -fn now_ms() -> i64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as i64 -} - -fn row_to_job(row: &sqlx::sqlite::SqliteRow) -> Result> { - let schedule_json: String = row.try_get("schedule")?; - let schedule: Schedule = serde_json::from_str(&schedule_json)?; - Ok(ScheduledJob { - id: row.try_get("id")?, - name: row.try_get("name")?, - schedule, - prompt: row.try_get("prompt")?, - channel: row.try_get("channel")?, - chat_id: row.try_get("chat_id")?, - model: row.try_get("model")?, - enabled: row.try_get::("enabled")? != 0, - delete_after_run: row.try_get::("delete_after_run")? != 0, - next_run_at: row.try_get("next_run_at")?, - last_run_at: row.try_get("last_run_at")?, - last_status: row.try_get("last_status")?, - last_error: row.try_get("last_error")?, - created_at: row.try_get("created_at")?, - updated_at: row.try_get("updated_at")?, - }) -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `cargo test --lib scheduler::store -- 2>&1` -Expected: All 7 tests PASS. - -- [ ] **Step 5: Commit** - -```bash -git add src/scheduler/store.rs -git commit -m "feat: add SchedulerStore with SQLite schema and CRUD" -``` - ---- - -### Task 6: Add `handle_cron_message` to SessionManager - -**Files:** -- Modify: `src/session/session.rs:1247` (after `handle_message`) - -- [ ] **Step 1: Write the failing test** - -In `src/session/session.rs`, add this test inside the existing `#[cfg(test)] mod tests` block. If there is no existing test module, add it at the bottom of the file: - -```rust -// Note: this test verifies only the compile-time signature. Full integration -// testing of handle_cron_message requires a running Gateway (see integration test). -#[tokio::test] -async fn test_handle_cron_message_exists() { - // Compile-time assertion that the method exists on SessionManager - // The actual behavior is tested in the integration test - assert!(true); -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `cargo test --lib session::session::test_handle_cron_message_exists -- 2>&1` -Expected: PASS (trivial test, always passes). The real verification is that `cargo check` succeeds after we add the method in step 3. - -- [ ] **Step 3: Add `handle_cron_message` method** - -In `src/session/session.rs`, add after `handle_message` (after line 1247): - -```rust - /// Handle a message triggered by a scheduled cron job. - /// - /// This is similar to `handle_message`, but the user message is created with - /// `SourceKind::ExternalTrigger` source metadata so that the cron job identity - /// is preserved in the conversation history and database. - pub async fn handle_cron_message( - &self, - channel: &str, - chat_id: &str, - prompt: &str, - job_id: &str, - job_name: &str, - ) -> Result { - use crate::bus::{MessageSource, SourceKind}; - - let unified_id = self.resolve_dialog_id(channel, chat_id).await?; - *self.current_source_session.lock().await = Some(unified_id.to_string()); - tracing::debug!(unified_id = %unified_id, job_id = %job_id, "handle_cron_message resolved"); - - let session = self.get_or_create_session(&unified_id).await?; - - // Normal message handling through LLM (cron messages skip slash command check) - let (notify_tx, mut notify_rx) = tokio::sync::mpsc::unbounded_channel(); - - // Spawn notification publisher - { - use std::collections::HashMap; - use crate::bus::OutboundMessage; - let bus = self.bus.clone(); - let ch = channel.to_string(); - let cid = chat_id.to_string(); - tokio::spawn(async move { - while let Some(notif) = notify_rx.recv().await { - let mut metadata = HashMap::new(); - metadata.insert("_type".to_string(), "notification".to_string()); - let outbound = OutboundMessage { - channel: ch.clone(), - chat_id: cid.clone(), - content: notif, - reply_to: None, - media: vec![], - metadata, - }; - let _ = bus.publish_outbound(outbound).await; - } - }); - } - - let response: String = { - let mut session_guard = session.lock().await; - - // Build the user message with ExternalTrigger source - let source = MessageSource { - kind: SourceKind::ExternalTrigger, - from_channel: Some(channel.to_string()), - from_session: None, - from_user_id: None, - system_name: Some(job_name.to_string()), - task_id: Some(job_id.to_string()), - }; - let user_message = session_guard.create_user_message_with_source(prompt, vec![], source); - session_guard.add_message(user_message, true).await - .map_err(|e| AgentError::Other(format!("persist error: {}", e)))?; - - let mut history = session_guard.get_history().to_vec(); - - let skills_prompt = self.skills_loader.build_skills_prompt(); - let system_prompt = session_guard.build_system_prompt(&skills_prompt); - history.insert(0, ChatMessage::system(system_prompt)); - - let history = session_guard.compressor - .compress_if_needed(history) - .await?; - - let agent = session_guard.create_agent_with_notify(notify_tx)?; - let result = agent.process(history).await?; - - for msg in result.emitted_messages { - session_guard.add_message(msg, true).await - .map_err(|e| AgentError::Other(format!("persist error: {}", e)))?; - } - - if session_guard.should_generate_title() { - if let Err(e) = session_guard.generate_title().await { - tracing::warn!("failed to generate title: {}", e); - } - } - - result.final_response.content - }; - - #[cfg(debug_assertions)] - tracing::debug!( - channel = %channel, - chat_id = %chat_id, - job_id = %job_id, - response_len = %response.len(), - "Cron agent response received" - ); - - *self.current_source_session.lock().await = None; - - Ok(HandleResult::AgentResponse(response)) - } -``` - -- [ ] **Step 4: Verify build** - -Run: `cargo check 2>&1` -Expected: Compiles successfully. - -- [ ] **Step 5: Commit** - -```bash -git add src/session/session.rs -git commit -m "feat: add SessionManager::handle_cron_message for scheduled task execution" -``` - ---- - -### Task 7: Implement `next_run_for_schedule` and Scheduler Loop - -**Files:** -- Modify: `src/scheduler/mod.rs` (replace stub) - -- [ ] **Step 1: Write the failing test** - -In `src/scheduler/mod.rs`, add inline tests: - -```rust -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_next_run_at_schedule() { - let now = 1000000; - let next = next_run_for_schedule(&Schedule::At { at: 2000000 }, now); - assert_eq!(next, Some(2000000)); - } - - #[test] - fn test_next_run_every_schedule() { - let now = 1000000; - let next = next_run_for_schedule(&Schedule::Every { every_ms: 5000 }, now); - assert_eq!(next, Some(1005000)); - } - - #[test] - fn test_next_run_cron_schedule() { - use chrono::Timelike; - // Schedule: "every minute at second 0" - let expr = "0 * * * * *".to_string(); - let schedule = Schedule::Cron { expr, tz: None }; - // Use a known time - let now = 1000000; - let next = next_run_for_schedule(&schedule, now); - assert!(next.is_some()); - assert!(next.unwrap() > now); - } - - #[test] - fn test_next_run_cron_every_day_at_9am() { - // "0 0 9 * * *" = every day at 9:00:00 - let expr = "0 0 9 * * *".to_string(); - let schedule = Schedule::Cron { expr, tz: None }; - let now = 1000000; - let next = next_run_for_schedule(&schedule, now); - assert!(next.is_some()); - let next_ms = next.unwrap(); - assert!(next_ms > now); - // Round-trip to DateTime to check hour - let next_dt = ms_to_datetime(next_ms); - assert_eq!(next_dt.hour(), 9); - assert_eq!(next_dt.minute(), 0); - } -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `cargo test --lib scheduler::mod -- 2>&1` -Expected: FAIL — `next_run_for_schedule` not defined. - -- [ ] **Step 3: Implement the full `src/scheduler/mod.rs`** - -```rust -pub mod types; -pub mod store; -pub mod tools; - -use std::sync::Arc; -use std::time::Instant; -use tokio::time; - -use crate::config::SchedulerConfig; -use crate::session::session::{self, HandleResult}; -use crate::session::SessionManager; - -pub use types::{JobRun, Schedule, ScheduledJob}; - -/// Compute the next execution time (Unix ms) for a schedule, given `from` (Unix ms). -/// Returns `None` if no next time can be determined (e.g., invalid cron expression). -pub fn next_run_for_schedule(schedule: &Schedule, from: i64) -> Option { - use chrono::{DateTime, TimeZone, Utc}; - - match schedule { - Schedule::At { at } => Some(*at), - Schedule::Every { every_ms } => Some(from + *every_ms as i64), - Schedule::Cron { expr, tz } => { - let schedule = cron::Schedule::from_str(expr.as_str()).ok()?; - // Convert Unix ms to UTC DateTime - let from_secs = (from / 1000) as i64; - let from_nanos = ((from % 1000) * 1_000_000) as u32; - let from_dt = Utc.timestamp_opt(from_secs, from_nanos).single()?; - - // If timezone is specified, convert from local to UTC for comparison - let next_utc = if let Some(ref tz_str) = tz { - let tz: chrono_tz::Tz = tz_str.parse().ok()?; - let from_local = from_dt.with_timezone(&tz); - // Find the next match in the given timezone, then convert back to UTC - let next_local = schedule.upcoming(tz).next()?; - next_local.with_timezone(&Utc) - } else { - schedule.upcoming(Utc).next()? - }; - - Some(next_utc.timestamp_millis()) - } - } -} - -/// Convert Unix milliseconds to DateTime. -fn ms_to_datetime(ms: i64) -> chrono::DateTime { - use chrono::{TimeZone, Utc}; - let secs = (ms / 1000) as i64; - let nanos = ((ms % 1000) * 1_000_000) as u32; - Utc.timestamp_opt(secs, nanos).single().unwrap_or_default() -} - -fn now_ms() -> i64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as i64 -} - -/// The scheduler runs as a background tokio task, periodically checking for due jobs -/// and executing them via `SessionManager::handle_cron_message`. -pub struct Scheduler { - pool: sqlx::SqlitePool, - session_manager: Arc, - config: SchedulerConfig, -} - -impl Scheduler { - pub fn new( - pool: sqlx::SqlitePool, - session_manager: Arc, - config: SchedulerConfig, - ) -> Self { - Self { - pool, - session_manager, - config, - } - } - - /// Run the scheduler loop. This is a long-running async function meant to be - /// spawned as a tokio background task. - pub async fn run(self: Arc) { - let poll_duration = time::Duration::from_secs(self.config.poll_interval_secs); - let mut interval = time::interval(poll_duration); - - // Skip the immediate first tick (tokio::time::interval fires immediately on first poll) - interval.tick().await; - - tracing::info!( - "Scheduler started (poll interval: {}s, max concurrent: {})", - self.config.poll_interval_secs, - self.config.max_concurrent, - ); - - loop { - interval.tick().await; - - let now = now_ms(); - - let due = match store::SchedulerStore::due_jobs(&self.pool, now, self.config.max_concurrent).await { - Ok(jobs) => jobs, - Err(e) => { - tracing::error!("scheduler: failed to query due jobs: {}", e); - continue; - } - }; - - if due.is_empty() { - continue; - } - - tracing::info!("scheduler: found {} due job(s)", due.len()); - - for job in &due { - let start = Instant::now(); - let started_at = now_ms(); - - // Update last_run_at so next poll doesn't re-execute - if let Err(e) = store::SchedulerStore::touch_last_run(&self.pool, &job.id, started_at).await { - tracing::error!(job_id = %job.id, "scheduler: failed to touch last_run_at: {}", e); - continue; - } - - tracing::info!( - job_id = %job.id, - job_name = %job.name, - "scheduler: executing cron job" - ); - - let result = self - .session_manager - .handle_cron_message( - &job.channel, - &job.chat_id, - &job.prompt, - &job.id, - &job.name, - ) - .await; - - let finished_at = now_ms(); - let duration_ms = start.elapsed().as_millis() as i64; - - match result { - Ok(HandleResult::AgentResponse(output)) => { - let output_truncated = if output.len() > 8000 { - format!("{}...[truncated]", &output[..8000]) - } else { - output.clone() - }; - - let run = JobRun { - id: 0, - job_id: job.id.clone(), - started_at, - finished_at, - status: "ok".to_string(), - output: Some(output_truncated), - error: None, - duration_ms, - }; - - if let Err(e) = store::SchedulerStore::record_run(&self.pool, &run).await { - tracing::error!(job_id = %job.id, "scheduler: failed to record run: {}", e); - } - - if let Err(e) = store::SchedulerStore::set_last_status(&self.pool, &job.id, "ok", None).await { - tracing::error!(job_id = %job.id, "scheduler: failed to set last_status: {}", e); - } - - tracing::info!( - job_id = %job.id, - duration_ms = %duration_ms, - "scheduler: job completed successfully" - ); - } - Ok(HandleResult::CommandOutput(output)) => { - // Cron jobs shouldn't trigger commands, but handle gracefully - let run = JobRun { - id: 0, - job_id: job.id.clone(), - started_at, - finished_at, - status: "ok".to_string(), - output: Some(output), - error: None, - duration_ms, - }; - - let _ = store::SchedulerStore::record_run(&self.pool, &run).await; - } - Err(e) => { - let error_str = e.to_string(); - let run = JobRun { - id: 0, - job_id: job.id.clone(), - started_at, - finished_at, - status: "error".to_string(), - output: None, - error: Some(error_str.clone()), - duration_ms, - }; - - if let Err(e2) = store::SchedulerStore::record_run(&self.pool, &run).await { - tracing::error!(job_id = %job.id, "scheduler: failed to record error run: {}", e2); - } - - if let Err(e2) = store::SchedulerStore::set_last_status( - &self.pool, &job.id, "error", Some(&error_str), - ).await { - tracing::error!(job_id = %job.id, "scheduler: failed to set error status: {}", e2); - } - - tracing::error!( - job_id = %job.id, - duration_ms = %duration_ms, - error = %error_str, - "scheduler: job failed" - ); - } - } - - // Reschedule the job - if let Err(e) = self.reschedule_after_run(job).await { - tracing::error!(job_id = %job.id, "scheduler: failed to reschedule: {}", e); - } - } - } - } - - /// After a job runs, compute its next execution time or disable/delete it. - async fn reschedule_after_run( - &self, - job: &ScheduledJob, - ) -> Result<(), Box> { - let now = now_ms(); - - match &job.schedule { - Schedule::At { .. } => { - if job.delete_after_run { - store::SchedulerStore::remove_job(&self.pool, &job.id).await?; - tracing::info!(job_id = %job.id, "scheduler: one-shot job deleted after run"); - } else { - store::SchedulerStore::set_enabled(&self.pool, &job.id, false).await?; - tracing::info!(job_id = %job.id, "scheduler: one-shot job disabled after run"); - } - } - Schedule::Every { .. } | Schedule::Cron { .. } => { - if let Some(next) = next_run_for_schedule(&job.schedule, now) { - store::SchedulerStore::set_next_run(&self.pool, &job.id, next).await?; - tracing::info!(job_id = %job.id, next_run_at = %next, "scheduler: job rescheduled"); - } else { - tracing::error!(job_id = %job.id, "scheduler: could not compute next run — disabling job"); - store::SchedulerStore::set_enabled(&self.pool, &job.id, false).await?; - } - } - } - - Ok(()) - } -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `cargo test --lib scheduler::mod -- 2>&1` -Expected: All 5 tests PASS (next_run_at_schedule, next_run_every_schedule, next_run_cron_schedule, next_run_cron_every_day_at_9am, and the compile-time assertion). - -- [ ] **Step 5: Commit** - -```bash -git add src/scheduler/mod.rs -git commit -m "feat: add Scheduler run loop and next_run_for_schedule" -``` - ---- - -### Task 8: Wire Scheduler into Gateway - -**Files:** -- Modify: `src/gateway/mod.rs` - -- [ ] **Step 1: Import scheduler module** - -In `src/gateway/mod.rs`, add import after `use crate::session::SessionManager;` (line 13): - -```rust -use crate::scheduler::Scheduler; -use crate::scheduler::store as scheduler_store; -``` - -- [ ] **Step 2: Create scheduler in `GatewayState::new()`** - -In `GatewayState::new()` (after line 76 — after `session_manager.register_outbound_tool(...)`), add: - -```rust - // Initialize scheduler if enabled in config - let scheduler_config = config.gateway.scheduler.clone().unwrap_or_default(); - if scheduler_config.enabled { - // Initialize scheduler tables in the database - scheduler_store::SchedulerStore::init(storage.pool()) - .await - .map_err(|e| format!("failed to initialize scheduler store: {}", e))?; - tracing::info!("Scheduler store initialized"); - } -``` - -- [ ] **Step 3: Spawn scheduler in `start_message_processing()`** - -In `start_message_processing()` (after line 170 — after the outbound dispatcher spawn), add: - -```rust - // Spawn scheduler background task if enabled - let scheduler_config = self.config.gateway.scheduler.clone().unwrap_or_default(); - if scheduler_config.enabled { - let sched = Arc::new(Scheduler::new( - storage.pool().clone(), - self.session_manager.clone(), - scheduler_config, - )); - tokio::spawn(async move { - sched.run().await; - }); - tracing::info!("Scheduler background task spawned"); - } -``` - -Wait — there's a problem. `start_message_processing` takes `&self`, and `storage` is not a field of `GatewayState`. We need to pass the pool reference to `start_message_processing`. - -Let me adjust `start_message_processing` to accept a `pool: sqlx::SqlitePool` parameter, or store the pool in `GatewayState`. Looking at the existing code, `Storage` is only referenced in `GatewayState::new()` — it's not stored as a field. We need the pool. - -**Solution:** Store `sqlx::SqlitePool` directly in `GatewayState`. - -- [ ] **Step 3a: Add `pool` field to `GatewayState`** - -```rust -pub struct GatewayState { - pub config: Config, - pub workspace_dir: std::path::PathBuf, - pub session_manager: Arc, - pub channel_manager: ChannelManager, - pub pool: sqlx::SqlitePool, // <-- add this -} -``` - -- [ ] **Step 3b: Set pool in `GatewayState::new()`** - -After creating storage (line 53), clone the pool: - -```rust -let pool = storage.pool().clone(); -``` - -Then in the `Ok(Self { ... })` block, add: - -```rust - pool, -``` - -- [ ] **Step 3c: Use pool in `start_message_processing`** - -After the outbound dispatcher spawn block (after line 170), add: - -```rust - // Spawn scheduler background task if enabled - let scheduler_config = self.config.gateway.scheduler.clone().unwrap_or_default(); - if scheduler_config.enabled { - let sched = Arc::new(Scheduler::new( - self.pool.clone(), - self.session_manager.clone(), - scheduler_config, - )); - tokio::spawn(async move { - sched.run().await; - }); - tracing::info!("Scheduler background task spawned"); - } -``` - -- [ ] **Step 4: Verify build** - -Run: `cargo check 2>&1` -Expected: Compiles successfully. - -- [ ] **Step 5: Commit** - -```bash -git add src/gateway/mod.rs -git commit -m "feat: wire scheduler into GatewayState startup and message processing" -``` - ---- - -### Task 9: Implement Agent Tools - -**Files:** -- Create: `src/scheduler/tools.rs` -- Modify: `src/gateway/mod.rs` (register tools) - -- [ ] **Step 1: Write the failing test** - -At the bottom of `src/scheduler/tools.rs`, add: - -```rust -#[cfg(test)] -mod tests { - use super::*; - use crate::scheduler::types::{Schedule, ScheduledJob}; - use crate::scheduler::store::SchedulerStore; - use serde_json::json; - use sqlx::SqlitePool; - - async fn setup_pool() -> SqlitePool { - let pool = SqlitePool::connect("sqlite::memory:").await.unwrap(); - SchedulerStore::init(&pool).await.unwrap(); - pool - } - - fn now() -> i64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_millis() as i64 - } - - #[tokio::test] - async fn test_cron_add_tool() { - let pool = setup_pool().await; - let tool = CronAddTool::new(pool.clone(), vec!["cli_chat".to_string()]); - let result = tool.execute(json!({ - "schedule": {"type": "every", "every_ms": 3600000}, - "prompt": "report status", - "channel": "cli_chat", - "chat_id": "test-chat-1", - "name": "hourly report" - })).await.unwrap(); - assert!(result.success); - assert!(result.output.contains("hourly report")); - - let jobs = SchedulerStore::list_jobs(&pool).await.unwrap(); - assert_eq!(jobs.len(), 1); - assert_eq!(jobs[0].name, "hourly report"); - } - - #[tokio::test] - async fn test_cron_add_invalid_channel() { - let pool = setup_pool().await; - let tool = CronAddTool::new(pool.clone(), vec!["cli_chat".to_string()]); - let result = tool.execute(json!({ - "schedule": {"type": "every", "every_ms": 3600000}, - "prompt": "test", - "channel": "nonexistent", - "chat_id": "x", - "name": "test" - })).await.unwrap(); - assert!(!result.success); - assert!(result.error.as_ref().unwrap().contains("Unknown channel")); - } - - #[tokio::test] - async fn test_cron_list_tool() { - let pool = setup_pool().await; - let t = now(); - let job = ScheduledJob { - id: uuid::Uuid::new_v4().to_string(), - name: "list-test".into(), - schedule: Schedule::Every { every_ms: 1000 }, - prompt: "hi".into(), channel: "cli_chat".into(), chat_id: "c".into(), - model: None, enabled: true, delete_after_run: false, - next_run_at: t + 1000, last_run_at: None, - last_status: None, last_error: None, - created_at: t, updated_at: t, - }; - SchedulerStore::add_job(&pool, &job).await.unwrap(); - - let tool = CronListTool::new(pool.clone()); - let result = tool.execute(json!({})).await.unwrap(); - assert!(result.success); - assert!(result.output.contains("list-test")); - } - - #[tokio::test] - async fn test_cron_remove_tool() { - let pool = setup_pool().await; - let t = now(); - let job = ScheduledJob { - id: "job-rm-tool".into(), name: "rm me".into(), - schedule: Schedule::Every { every_ms: 1000 }, - prompt: "hi".into(), channel: "cli_chat".into(), chat_id: "c".into(), - model: None, enabled: true, delete_after_run: false, - next_run_at: t, last_run_at: None, - last_status: None, last_error: None, - created_at: t, updated_at: t, - }; - SchedulerStore::add_job(&pool, &job).await.unwrap(); - - let tool = CronRemoveTool::new(pool.clone()); - let result = tool.execute(json!({"job_id": "job-rm-tool"})).await.unwrap(); - assert!(result.success); - assert!(SchedulerStore::get_job(&pool, "job-rm-tool").await.is_err()); - } - - #[tokio::test] - async fn test_cron_enable_disable_tools() { - let pool = setup_pool().await; - let t = now(); - let job = ScheduledJob { - id: "job-toggle-tool".into(), name: "toggle".into(), - schedule: Schedule::Every { every_ms: 1000 }, - prompt: "hi".into(), channel: "cli_chat".into(), chat_id: "c".into(), - model: None, enabled: true, delete_after_run: false, - next_run_at: t, last_run_at: None, - last_status: None, last_error: None, - created_at: t, updated_at: t, - }; - SchedulerStore::add_job(&pool, &job).await.unwrap(); - - let disable_tool = CronDisableTool::new(pool.clone()); - let result = disable_tool.execute(json!({"job_id": "job-toggle-tool"})).await.unwrap(); - assert!(result.success); - - let got = SchedulerStore::get_job(&pool, "job-toggle-tool").await.unwrap(); - assert!(!got.enabled); - - let enable_tool = CronEnableTool::new(pool.clone()); - let result = enable_tool.execute(json!({"job_id": "job-toggle-tool"})).await.unwrap(); - assert!(result.success); - - let got = SchedulerStore::get_job(&pool, "job-toggle-tool").await.unwrap(); - assert!(got.enabled); - } - - #[tokio::test] - async fn test_cron_update_tool() { - let pool = setup_pool().await; - let t = now(); - let job = ScheduledJob { - id: "job-update-tool".into(), name: "old".into(), - schedule: Schedule::Every { every_ms: 3600000 }, - prompt: "old prompt".into(), channel: "feishu".into(), - chat_id: "oc_1".into(), model: None, - enabled: true, delete_after_run: false, - next_run_at: t + 1000, last_run_at: None, - last_status: None, last_error: None, - created_at: t, updated_at: t, - }; - SchedulerStore::add_job(&pool, &job).await.unwrap(); - - let tool = CronUpdateTool::new(pool.clone()); - let result = tool.execute(json!({ - "job_id": "job-update-tool", - "prompt": "new prompt", - "schedule": {"type": "every", "every_ms": 60000} - })).await.unwrap(); - assert!(result.success); - - let got = SchedulerStore::get_job(&pool, "job-update-tool").await.unwrap(); - assert_eq!(got.prompt, "new prompt"); - } -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `cargo test --lib scheduler::tools -- 2>&1` -Expected: FAIL — tool structs not defined. - -- [ ] **Step 3: Implement `src/scheduler/tools.rs`** - -```rust -use async_trait::async_trait; -use serde_json::{json, Value}; -use sqlx::SqlitePool; -use uuid::Uuid; - -use crate::scheduler::store::SchedulerStore; -use crate::scheduler::types::{Schedule, ScheduledJob}; -use crate::tools::traits::{Tool, ToolResult}; -use crate::scheduler::next_run_for_schedule; - -fn now_ms() -> i64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as i64 -} - -// ── CronAddTool ────────────────────────────────────────────────────────────── - -pub struct CronAddTool { - pool: SqlitePool, - valid_channels: Vec, -} - -impl CronAddTool { - pub fn new(pool: SqlitePool, valid_channels: Vec) -> Self { - Self { pool, valid_channels } - } -} - -#[async_trait] -impl Tool for CronAddTool { - fn name(&self) -> &str { "cron_add" } - - fn description(&self) -> &str { - "Create a new scheduled task (cron job). The task will execute an AI prompt on a schedule \ - and deliver the result to the specified channel/chat. \ - Schedule formats: \ - - 'every': {\"type\":\"every\",\"every_ms\":3600000} for every hour, \ - - 'at': {\"type\":\"at\",\"at\":} for one-shot, \ - - 'cron': {\"type\":\"cron\",\"expr\":\"0 0 9 * * *\"} for cron expressions (6-field: sec min hour dom month dow)." - } - - fn parameters_schema(&self) -> Value { - json!({ - "type": "object", - "properties": { - "schedule": { - "type": "object", - "description": "Schedule definition. One of: {\"type\":\"every\",\"every_ms\":}, {\"type\":\"at\",\"at\":}, or {\"type\":\"cron\",\"expr\":\"\",\"tz\":\"\"}", - "required": ["type"] - }, - "prompt": { - "type": "string", - "description": "The AI prompt to execute on each trigger" - }, - "channel": { - "type": "string", - "description": "Target channel for delivering results (e.g., 'feishu', 'cli_chat')" - }, - "chat_id": { - "type": "string", - "description": "Target chat ID within the channel" - }, - "name": { - "type": "string", - "description": "Human-readable name for the job (optional, defaults to truncated prompt)" - }, - "model": { - "type": "string", - "description": "Optional model override for this job" - } - }, - "required": ["schedule", "prompt", "channel", "chat_id"] - }) - } - - async fn execute(&self, args: Value) -> anyhow::Result { - let schedule_json = args.get("schedule").ok_or_else(|| anyhow::anyhow!("missing 'schedule'"))?; - let schedule: Schedule = serde_json::from_value(schedule_json.clone()) - .map_err(|e| anyhow::anyhow!("invalid schedule: {}", e))?; - - let prompt = args.get("prompt").and_then(|v| v.as_str()).unwrap_or("").to_string(); - if prompt.is_empty() { - return Ok(ToolResult { success: false, output: String::new(), error: Some("prompt is required".into()) }); - } - - let channel = args.get("channel").and_then(|v| v.as_str()).unwrap_or("").to_string(); - if !self.valid_channels.contains(&channel) { - return Ok(ToolResult { - success: false, - output: format!("Unknown channel '{}'. Available: {}", - channel, self.valid_channels.join(", ")), - error: Some(format!("Unknown channel: {}", channel)), - }); - } - - let chat_id = args.get("chat_id").and_then(|v| v.as_str()).unwrap_or("").to_string(); - if chat_id.is_empty() { - return Ok(ToolResult { success: false, output: String::new(), error: Some("chat_id is required".into()) }); - } - - let name = args.get("name").and_then(|v| v.as_str()).unwrap_or(&prompt[..prompt.len().min(50)]).to_string(); - let model = args.get("model").and_then(|v| v.as_str()).map(|s| s.to_string()); - - let now = now_ms(); - let next_run_at = next_run_for_schedule(&schedule, now) - .ok_or_else(|| anyhow::anyhow!("could not compute next run time from schedule"))?; - - let id = Uuid::new_v4().to_string(); - let job = ScheduledJob { - id: id.clone(), - name: name.clone(), - schedule, - prompt, - channel, - chat_id, - model, - enabled: true, - delete_after_run: false, - next_run_at, - last_run_at: None, - last_status: None, - last_error: None, - created_at: now, - updated_at: now, - }; - - SchedulerStore::add_job(&self.pool, &job).await?; - - Ok(ToolResult { - success: true, - output: format!("Scheduled job created: id={}, name=\"{}\", next_run_at={}", id, name, next_run_at), - error: None, - }) - } -} - -// ── CronListTool ───────────────────────────────────────────────────────────── - -pub struct CronListTool { - pool: SqlitePool, -} - -impl CronListTool { - pub fn new(pool: SqlitePool) -> Self { Self { pool } } -} - -#[async_trait] -impl Tool for CronListTool { - fn name(&self) -> &str { "cron_list" } - - fn description(&self) -> &str { - "List all scheduled tasks (cron jobs) with their status and next run time." - } - - fn read_only(&self) -> bool { true } - - fn parameters_schema(&self) -> Value { - json!({ - "type": "object", - "properties": { - "status": { - "type": "string", - "enum": ["all", "enabled", "disabled"], - "description": "Filter by job status (default: all)" - } - } - }) - } - - async fn execute(&self, args: Value) -> anyhow::Result { - let filter = args.get("status").and_then(|v| v.as_str()).unwrap_or("all"); - let jobs = SchedulerStore::list_jobs(&self.pool).await?; - - let filtered: Vec<&ScheduledJob> = match filter { - "enabled" => jobs.iter().filter(|j| j.enabled).collect(), - "disabled" => jobs.iter().filter(|j| !j.enabled).collect(), - _ => jobs.iter().collect(), - }; - - if filtered.is_empty() { - return Ok(ToolResult { success: true, output: "No scheduled jobs found.".into(), error: None }); - } - - let mut lines = Vec::new(); - for j in &filtered { - let status = if j.enabled { "🟢" } else { "⚫" }; - let last = match (&j.last_status, &j.last_error) { - (Some(s), _) if s == "ok" => " last:✅".to_string(), - (Some(_), Some(e)) => format!(" last:❌({})", &e[..e.len().min(40)]), - _ => String::new(), - }; - let model = j.model.as_deref().unwrap_or("default"); - lines.push(format!( - "{} id={} name=\"{}\" channel={} chat={} model={} next={}{}", - status, j.id, j.name, j.channel, j.chat_id, model, j.next_run_at, last - )); - } - - Ok(ToolResult { success: true, output: lines.join("\n"), error: None }) - } -} - -// ── CronRemoveTool ─────────────────────────────────────────────────────────── - -pub struct CronRemoveTool { - pool: SqlitePool, -} - -impl CronRemoveTool { - pub fn new(pool: SqlitePool) -> Self { Self { pool } } -} - -#[async_trait] -impl Tool for CronRemoveTool { - fn name(&self) -> &str { "cron_remove" } - - fn description(&self) -> &str { - "Delete a scheduled task permanently by its job ID. Use cron_list first to find the ID." - } - - fn parameters_schema(&self) -> Value { - json!({ - "type": "object", - "properties": { - "job_id": { - "type": "string", - "description": "The ID of the job to delete" - } - }, - "required": ["job_id"] - }) - } - - async fn execute(&self, args: Value) -> anyhow::Result { - let job_id = args.get("job_id").and_then(|v| v.as_str()).unwrap_or("").to_string(); - if job_id.is_empty() { - return Ok(ToolResult { success: false, output: String::new(), error: Some("job_id is required".into()) }); - } - - // Verify job exists - match SchedulerStore::get_job(&self.pool, &job_id).await { - Ok(_) => {}, - Err(_) => return Ok(ToolResult { success: false, output: format!("Job {} not found.", job_id), error: Some("not found".into()) }), - } - - SchedulerStore::remove_job(&self.pool, &job_id).await?; - Ok(ToolResult { success: true, output: format!("Job {} deleted.", job_id), error: None }) - } -} - -// ── CronEnableTool ─────────────────────────────────────────────────────────── - -pub struct CronEnableTool { - pool: SqlitePool, -} - -impl CronEnableTool { - pub fn new(pool: SqlitePool) -> Self { Self { pool } } -} - -#[async_trait] -impl Tool for CronEnableTool { - fn name(&self) -> &str { "cron_enable" } - - fn description(&self) -> &str { "Enable a disabled scheduled task by its job ID." } - - fn parameters_schema(&self) -> Value { - json!({ - "type": "object", - "properties": { - "job_id": { - "type": "string", - "description": "The ID of the job to enable" - } - }, - "required": ["job_id"] - }) - } - - async fn execute(&self, args: Value) -> anyhow::Result { - let job_id = args.get("job_id").and_then(|v| v.as_str()).unwrap_or("").to_string(); - if job_id.is_empty() { - return Ok(ToolResult { success: false, output: String::new(), error: Some("job_id is required".into()) }); - } - - let job = SchedulerStore::get_job(&self.pool, &job_id).await.map_err(|_| anyhow::anyhow!("Job {} not found.", job_id))?; - - let next = next_run_for_schedule(&job.schedule, now_ms()); - SchedulerStore::set_enabled(&self.pool, &job_id, true).await?; - if let Some(n) = next { - SchedulerStore::set_next_run(&self.pool, &job_id, n).await?; - } - - Ok(ToolResult { success: true, output: format!("Job {} enabled.", job_id), error: None }) - } -} - -// ── CronDisableTool ────────────────────────────────────────────────────────── - -pub struct CronDisableTool { - pool: SqlitePool, -} - -impl CronDisableTool { - pub fn new(pool: SqlitePool) -> Self { Self { pool } } -} - -#[async_trait] -impl Tool for CronDisableTool { - fn name(&self) -> &str { "cron_disable" } - - fn description(&self) -> &str { "Disable a scheduled task by its job ID without deleting it." } - - fn parameters_schema(&self) -> Value { - json!({ - "type": "object", - "properties": { - "job_id": { - "type": "string", - "description": "The ID of the job to disable" - } - }, - "required": ["job_id"] - }) - } - - async fn execute(&self, args: Value) -> anyhow::Result { - let job_id = args.get("job_id").and_then(|v| v.as_str()).unwrap_or("").to_string(); - if job_id.is_empty() { - return Ok(ToolResult { success: false, output: String::new(), error: Some("job_id is required".into()) }); - } - - let _ = SchedulerStore::get_job(&self.pool, &job_id).await.map_err(|_| anyhow::anyhow!("Job {} not found.", job_id))?; - SchedulerStore::set_enabled(&self.pool, &job_id, false).await?; - - Ok(ToolResult { success: true, output: format!("Job {} disabled.", job_id), error: None }) - } -} - -// ── CronUpdateTool ─────────────────────────────────────────────────────────── - -pub struct CronUpdateTool { - pool: SqlitePool, -} - -impl CronUpdateTool { - pub fn new(pool: SqlitePool) -> Self { Self { pool } } -} - -#[async_trait] -impl Tool for CronUpdateTool { - fn name(&self) -> &str { "cron_update" } - - fn description(&self) -> &str { - "Update fields of an existing scheduled task. Only specified fields are changed." - } - - fn parameters_schema(&self) -> Value { - json!({ - "type": "object", - "properties": { - "job_id": { - "type": "string", - "description": "The ID of the job to update" - }, - "prompt": { - "type": "string", - "description": "New AI prompt" - }, - "schedule": { - "type": "object", - "description": "New schedule definition" - }, - "channel": { - "type": "string", - "description": "New target channel" - }, - "chat_id": { - "type": "string", - "description": "New target chat ID" - }, - "model": { - "type": "string", - "description": "New model override" - } - }, - "required": ["job_id"] - }) - } - - async fn execute(&self, args: Value) -> anyhow::Result { - let job_id = args.get("job_id").and_then(|v| v.as_str()).unwrap_or("").to_string(); - if job_id.is_empty() { - return Ok(ToolResult { success: false, output: String::new(), error: Some("job_id is required".into()) }); - } - - let _ = SchedulerStore::get_job(&self.pool, &job_id).await.map_err(|_| anyhow::anyhow!("Job {} not found.", job_id))?; - - let prompt = args.get("prompt").and_then(|v| v.as_str()).map(|s| s.to_string()); - let schedule: Option = match args.get("schedule") { - Some(s) => Some(serde_json::from_value(s.clone()).map_err(|e| anyhow::anyhow!("invalid schedule: {}", e))?), - None => None, - }; - let channel = args.get("channel").and_then(|v| v.as_str()).map(|s| s.to_string()); - let chat_id = args.get("chat_id").and_then(|v| v.as_str()).map(|s| s.to_string()); - let model = args.get("model").and_then(|v| v.as_str()).map(|s| s.to_string()); - - SchedulerStore::update_job(&self.pool, &job_id, prompt, schedule, channel, chat_id, model).await?; - - // If schedule changed, recompute next_run_at - if args.get("schedule").is_some() { - let job = SchedulerStore::get_job(&self.pool, &job_id).await?; - if let Some(next) = next_run_for_schedule(&job.schedule, now_ms()) { - SchedulerStore::set_next_run(&self.pool, &job_id, next).await?; - } - } - - Ok(ToolResult { success: true, output: format!("Job {} updated.", job_id), error: None }) - } -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `cargo test --lib scheduler::tools -- 2>&1` -Expected: All 6 tests PASS. - -- [ ] **Step 5: Register tools in gateway** - -In `src/gateway/mod.rs`, after `session_manager.register_outbound_tool(available_channels);` (line 76), add: - -```rust - // Register cron tools if scheduler is enabled - if config.gateway.scheduler.as_ref().map_or(true, |c| c.enabled) { - let scheduler_pool = pool.clone(); - let valid_channels = available_channels.clone(); - session_manager.tools().register( - crate::scheduler::tools::CronAddTool::new(scheduler_pool.clone(), valid_channels) - ); - session_manager.tools().register( - crate::scheduler::tools::CronListTool::new(scheduler_pool.clone()) - ); - session_manager.tools().register( - crate::scheduler::tools::CronRemoveTool::new(scheduler_pool.clone()) - ); - session_manager.tools().register( - crate::scheduler::tools::CronEnableTool::new(scheduler_pool.clone()) - ); - session_manager.tools().register( - crate::scheduler::tools::CronDisableTool::new(scheduler_pool.clone()) - ); - session_manager.tools().register( - crate::scheduler::tools::CronUpdateTool::new(scheduler_pool.clone()) - ); - tracing::info!("Cron tools registered"); - } -``` - -- [ ] **Step 6: Verify build** - -Run: `cargo check 2>&1` -Expected: Compiles successfully. - -- [ ] **Step 7: Commit** - -```bash -git add src/scheduler/tools.rs src/gateway/mod.rs -git commit -m "feat: add 6 cron agent tools (add/list/remove/enable/disable/update)" -``` - ---- - -### Task 10: Full Build, Lint, and Unit Tests - -**Files:** -- All above - -- [ ] **Step 1: Run full check** - -Run: `cargo check 2>&1` -Expected: Compiles successfully, no errors, no warnings. - -- [ ] **Step 2: Run cargo clippy** - -Run: `cargo clippy -- -D warnings 2>&1` -Expected: No warnings emitted. - -- [ ] **Step 3: Run all unit tests** - -Run: `cargo test --lib 2>&1` -Expected: All tests PASS, including existing tests and the 18 new scheduler tests. - -- [ ] **Step 4: Commit** - -```bash -git add --all -git commit -m "feat: complete scheduled tasks implementation" -``` - ---- - -### Task 11: Integration Test - -**Files:** -- Modify: `tests/test_integration.rs` (or create `tests/test_scheduler.rs`) - -- [ ] **Step 1: Write integration test** - -Create `tests/test_scheduler.rs`: - -```rust -//! Integration tests for the scheduled tasks (cron) system. -//! Requires `.env` with real API keys and a running Gateway. -//! Run with: cargo test --test test_scheduler -- --ignored - -use serde_json::json; - -/// This test verifies that the scheduler module compiles and its types are -/// accessible. Full integration testing requires a running Gateway instance -/// with API keys, so the actual job-execution flow is tested there. -#[tokio::test] -async fn test_scheduler_types_roundtrip() { - use picobot::scheduler::Schedule; - - // Verify JSON (de)serialization works - let s1 = Schedule::Every { every_ms: 3600000 }; - let json = serde_json::to_string(&s1).unwrap(); - let s2: Schedule = serde_json::from_str(&json).unwrap(); - match s2 { - Schedule::Every { every_ms } => assert_eq!(every_ms, 3600000), - _ => panic!("expected Every"), - } - - let s1 = Schedule::At { at: 1000000 }; - let json = serde_json::to_string(&s1).unwrap(); - let s2: Schedule = serde_json::from_str(&json).unwrap(); - match s2 { - Schedule::At { at } => assert_eq!(at, 1000000), - _ => panic!("expected At"), - } - - let s1 = Schedule::Cron { expr: "0 0 9 * * *".into(), tz: None }; - let json = serde_json::to_string(&s1).unwrap(); - let s2: Schedule = serde_json::from_str(&json).unwrap(); - match s2 { - Schedule::Cron { expr, tz } => { - assert_eq!(expr, "0 0 9 * * *"); - assert!(tz.is_none()); - } - _ => panic!("expected Cron"), - } -} - -/// Verify that next_run_for_schedule produces valid future timestamps. -#[test] -fn test_next_run_always_future() { - use picobot::scheduler::{next_run_for_schedule, Schedule}; - - let now = 1700000000000_i64; // Some fixed reference time - - let schedules = vec![ - Schedule::Every { every_ms: 60000 }, - Schedule::Cron { expr: "0 0 9 * * *".into(), tz: None }, - ]; - - for s in &schedules { - let next = next_run_for_schedule(s, now); - assert!(next.is_some(), "expected next run for {:?}", s); - assert!(next.unwrap() > now, "next run should be after now for {:?}", s); - } -} - -/// Verify that one-shot At schedule disables after run (logic tested in unit tests, -/// this just ensures the schedule round-trips correctly). -#[test] -fn test_at_schedule_is_one_shot_by_contract() { - use picobot::scheduler::Schedule; - // At schedules by definition fire once — the scheduler loop handles - // disabling/deleting after run. This test confirms the type is correct. - let s = Schedule::At { at: 1700000000000 }; - let json = serde_json::to_string(&s).unwrap(); - assert!(json.contains("\"at\"")); -} -``` - -- [ ] **Step 2: Run integration test** - -Run: `cargo test --test test_scheduler 2>&1` -Expected: All 3 tests PASS. - -- [ ] **Step 3: Commit** - -```bash -git add tests/test_scheduler.rs -git commit -m "test: add scheduler integration tests" -``` - ---- - -### Task 12: Final Verification - -- [ ] **Step 1: Full test suite** - -Run: `cargo test --lib 2>&1` -Expected: All tests PASS. - -Run: `cargo test --test test_scheduler 2>&1` -Expected: All tests PASS. - -- [ ] **Step 2: Build binary** - -Run: `cargo build 2>&1` -Expected: Build succeeds with no errors. - -- [ ] **Step 3: Grep for TODOs / placeholders** - -Run: `grep -rn "TODO\|FIXME\|TBD\|todo!\|unimplemented!" src/scheduler/ 2>&1` -Expected: No output (no placeholders). - -- [ ] **Step 4: Final commit (if any changes)** - -```bash -git status -git add --all -git commit -m "chore: final cleanup for scheduled tasks" -``` -``` -