From 5aa845af0b1d74d9a0ca5fe9077da19351898c94 Mon Sep 17 00:00:00 2001 From: "chinkan.ai" Date: Fri, 12 Jun 2026 16:40:44 +0800 Subject: [PATCH 01/15] docs: add send-file-from-sandbox design spec --- ...026-06-12-send-file-from-sandbox-design.md | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-12-send-file-from-sandbox-design.md diff --git a/docs/superpowers/specs/2026-06-12-send-file-from-sandbox-design.md b/docs/superpowers/specs/2026-06-12-send-file-from-sandbox-design.md new file mode 100644 index 0000000..29388f6 --- /dev/null +++ b/docs/superpowers/specs/2026-06-12-send-file-from-sandbox-design.md @@ -0,0 +1,87 @@ +# Send File from Sandbox β€” Design + +## Summary + +Add a built-in `send_file` tool that lets the AI agent send files it created in the sandbox directly to the user via Telegram. The agent calls the tool with a file path and optional caption, and the bot delivers the file as a document in the chat. + +## Motivation + +The agent can already create files in the sandbox (`write_file`) but has no way to deliver them to the user. Users must request file contents via `read_file`, which is impractical for binaries, images, or large outputs. A `send_file` tool closes the loop: create β†’ deliver. + +## Design + +### Tool definition + +A new entry in `builtin_tool_definitions()` in `src/tools.rs`: + +- **Name:** `send_file` +- **Description:** "Send a file from the sandbox to the current chat. The file must already exist in the sandbox." +- **Parameters:** + - `path` (string, required) β€” file path, relative to sandbox or absolute within sandbox + - `caption` (string, optional) β€” text caption attached to the document + +### Execution + +Handled in `agent.rs::execute_tool()` as a new match arm, **before** the built-in fallthrough to `tools::execute_builtin_tool()`. + +The tool uses the existing `bot: Arc` on the `Agent` struct to call `send_document()` on the Telegram API. `chat_id` is already available as a parameter to `execute_tool`. + +### Signature change + +`execute_tool()` changes `chat_id: &str` to `chat_id: ChatId` (teloxide type) so the `send_file` arm can pass it directly. The call sites parse the string once before calling. + +### Path validation + +Reuses the existing `validate_sandbox_path()` from `tools.rs`, which is made `pub` to allow access from `agent.rs`. + +### File size limit + +Explicit check: if file exceeds 50 MB (Telegram's per-file limit for the standard Bot API), the tool returns an error to the LLM so it can inform the user. + +### Error handling + +| Scenario | Behaviour | +|----------|-----------| +| File doesn't exist | Error returned to LLM | +| File outside sandbox | `validate_sandbox_path` denies access | +| File > 50 MB | Error with size info | +| Telegram API failure | Error returned to LLM for retry | + +All errors flow back into the agent loop as tool result strings, same as any other built-in tool. + +### Notifier + +A friendly tool name is added in `platform/tool_notifier.rs`: + +```rust +"send_file" => return "πŸ“€ Sending a file".to_string(), +``` + +### User-facing flow + +``` +User: "Can you send me the report you created?" + β†’ LLM: write_file("report.pdf", ...) # create file in sandbox + β†’ LLM: send_file(path="report.pdf", caption="Here's your report") + β†’ agent.rs: validate path β†’ read bytes β†’ bot.send_document() + β†’ File arrives in Telegram chat + β†’ LLM: "File sent successfully!" + text reply +``` + +## Files changed + +| File | Change | +|------|--------| +| `src/tools.rs` | Add `send_file` to `builtin_tool_definitions()` | +| `src/tools.rs` | Make `validate_sandbox_path` `pub` | +| `src/agent.rs` | Change `execute_tool` signature: `chat_id: &str` β†’ `ChatId` | +| `src/agent.rs` | Parse `chat_id` at call sites | +| `src/agent.rs` | Add `"send_file"` arm in `execute_tool()` | +| `src/platform/tool_notifier.rs` | Add friendly name for `"send_file"` | + +## Out of scope + +- Sending multiple files in one tool call (the agent can call `send_file` multiple times) +- Scheduled/automatic file sending (use `schedule_task` + `send_file`) +- Non-Telegram platform support (can be added later with platform abstraction) +- File streaming or chunked upload (Telegram API handles this internally) From 34ed25f9d7006f68a902d57210f9c7870743e28e Mon Sep 17 00:00:00 2001 From: "chinkan.ai" Date: Fri, 12 Jun 2026 16:44:02 +0800 Subject: [PATCH 02/15] =?UTF-8?q?docs:=20address=20spec=20review=20issues?= =?UTF-8?q?=20=E2=80=94=20subagent=20note,=20concrete=20API=20pattern,=20s?= =?UTF-8?q?ize=20check=20order,=20deviation=20note?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-06-12-send-file-from-sandbox-design.md | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/specs/2026-06-12-send-file-from-sandbox-design.md b/docs/superpowers/specs/2026-06-12-send-file-from-sandbox-design.md index 29388f6..5584123 100644 --- a/docs/superpowers/specs/2026-06-12-send-file-from-sandbox-design.md +++ b/docs/superpowers/specs/2026-06-12-send-file-from-sandbox-design.md @@ -22,13 +22,23 @@ A new entry in `builtin_tool_definitions()` in `src/tools.rs`: ### Execution -Handled in `agent.rs::execute_tool()` as a new match arm, **before** the built-in fallthrough to `tools::execute_builtin_tool()`. +Handled in `agent.rs::execute_tool()` as a new match arm, **before** the built-in fallthrough to `tools::execute_builtin_tool()`. Unlike `read_file`/`write_file`, execution lives in `agent.rs` (not `execute_builtin_tool()`) because it needs the Telegram `Bot` instance and a parsed `ChatId`, which aren't available in the stateless `tools.rs` functions. The tool uses the existing `bot: Arc` on the `Agent` struct to call `send_document()` on the Telegram API. `chat_id` is already available as a parameter to `execute_tool`. +Concrete API call pattern: +```rust +let file_name = full_path.file_name().and_then(|n| n.to_str()).unwrap_or("file"); +let input_file = InputFile::memory(bytes).file_name(file_name); +bot.send_document(chat_id, input_file).caption(caption).await?; +``` +Telegram auto-detects MIME from content β€” no explicit MIME type needed. + ### Signature change -`execute_tool()` changes `chat_id: &str` to `chat_id: ChatId` (teloxide type) so the `send_file` arm can pass it directly. The call sites parse the string once before calling. +`execute_tool()` changes `chat_id: &str` to `chat_id: ChatId` (teloxide type) so the `send_file` arm can pass it directly. The two main call sites (sequential tool group, parallel agent tool group) parse the string once before looping. + +The subagent call site (`agent.rs:1788`) passes `""` for both `user_id` and `chat_id` β€” this is safe because subagents use explicit tool whitelists and will never include `send_file` in their available tools, so this code path is never reached for `send_file`. No change needed at that call site. ### Path validation @@ -36,7 +46,7 @@ Reuses the existing `validate_sandbox_path()` from `tools.rs`, which is made `pu ### File size limit -Explicit check: if file exceeds 50 MB (Telegram's per-file limit for the standard Bot API), the tool returns an error to the LLM so it can inform the user. +Check file size **before** reading bytes using `tokio::fs::metadata()` on the validated path. If the file exceeds 50 MB (Telegram's per-file limit for the standard Bot API), the tool returns an error to the LLM so it can inform the user β€” avoids loading a huge file into memory only to reject it. ### Error handling From fad15e5fcb1ae71b76c9f7c09b72e7c4ec997b89 Mon Sep 17 00:00:00 2001 From: "chinkan.ai" Date: Fri, 12 Jun 2026 17:00:59 +0800 Subject: [PATCH 03/15] docs: add send-file-from-sandbox implementation plan --- .../2026-06-12-send-file-from-sandbox.md | 260 ++++++++++++++++++ 1 file changed, 260 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-12-send-file-from-sandbox.md diff --git a/docs/superpowers/plans/2026-06-12-send-file-from-sandbox.md b/docs/superpowers/plans/2026-06-12-send-file-from-sandbox.md new file mode 100644 index 0000000..9cdc427 --- /dev/null +++ b/docs/superpowers/plans/2026-06-12-send-file-from-sandbox.md @@ -0,0 +1,260 @@ +# Send File from Sandbox β€” 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 `send_file` built-in tool so the AI agent can send sandbox files to the Telegram user. + +**Architecture:** Tool defined in `tools.rs` (schema only), executed in `agent.rs::execute_tool()` (needs `Bot` + `ChatId`). Reuses existing `validate_sandbox_path`. Signature change: `chat_id: &str` β†’ `ChatId` on `execute_tool`. + +**Tech Stack:** Rust, teloxide, tokio + +--- + +### Task 1: Tool definition in tools.rs + +**Files:** +- Modify: `src/tools.rs:10` (make `validate_sandbox_path` pub) +- Modify: `src/tools.rs:48-249` (add `send_file` to `builtin_tool_definitions()`) + +- [ ] **Step 1: Make `validate_sandbox_path` pub** + +Change `fn validate_sandbox_path` to `pub fn validate_sandbox_path` at `src/tools.rs:10`. + +- [ ] **Step 2: Add `send_file` to `builtin_tool_definitions()`** + +Insert a new `ToolDefinition` entry (before `plan_create` or after `execute_command` β€” order doesn't matter): + +```rust +ToolDefinition { + tool_type: "function".to_string(), + function: FunctionDefinition { + name: "send_file".to_string(), + description: "Send a file from the sandbox to the current chat. The file must already exist in the sandbox." + .to_string(), + parameters: json!({ + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "The file path (relative to sandbox or absolute within sandbox)" + }, + "caption": { + "type": "string", + "description": "Optional caption for the file" + } + }, + "required": ["path"] + }), + }, +}, +``` + +- [ ] **Step 3: Verify it compiles** + +Run: `cargo check` +Expected: Compiles with no errors. + +- [ ] **Step 4: Commit** + +```bash +git add src/tools.rs +git commit -m "feat: add send_file tool definition and make validate_sandbox_path pub" +``` + +--- + +### Task 2: Execution in agent.rs + +**Files:** +- Modify: `src/agent.rs:325-326` (parse ChatId once) +- Modify: `src/agent.rs:724-792` (parallel call site β€” pass parsed ChatId) +- Modify: `src/agent.rs:794-861` (sequential call site β€” pass parsed ChatId) +- Modify: `src/agent.rs:1824` (change signature) +- Modify: `src/agent.rs:1824-2690` (add `"send_file"` arm) + +- [ ] **Step 1: Parse chat_id once at the top of process_message** + +At `src/agent.rs:326`, after `let chat_id = &incoming.chat_id;`, add: + +```rust +let parsed_chat_id: ChatId = incoming.chat_id.parse::().map(ChatId).unwrap_or(ChatId(0)); +``` + +The `parse::()` works because Telegram chat IDs are numeric (positive for private chats, negative for groups/supergroups). The `ChatId(0)` fallback is safe because `send_file` will fail gracefully on invalid IDs. + +- [ ] **Step 2: Pass parsed_chat_id to execute_tool in parallel agent group** + +At `src/agent.rs:758-759`, change: +```rust +let result = + self.execute_tool(&name, &args, user_id, chat_id).await; +``` +to: +```rust +let result = + self.execute_tool(&name, &args, user_id, parsed_chat_id).await; +``` + +The closure captures `parsed_chat_id` by copy (`ChatId` is `Copy`). + +- [ ] **Step 3: Pass parsed_chat_id in sequential tool group** + +At `src/agent.rs:833`, change: +```rust +let result = self.execute_tool(&name, &args, user_id, chat_id).await; +``` +to: +```rust +let result = self.execute_tool(&name, &args, user_id, parsed_chat_id).await; +``` + +- [ ] **Step 4: Change execute_tool signature** + +At `src/agent.rs:1824`, change: +```rust +async fn execute_tool( + &self, + name: &str, + arguments: &serde_json::Value, + user_id: &str, + chat_id: &str, +) -> String { +``` +to: +```rust +async fn execute_tool( + &self, + name: &str, + arguments: &serde_json::Value, + user_id: &str, + chat_id: ChatId, +) -> String { +``` + +Add `use teloxide::types::ChatId;` to the imports at the top of the file (or reference it fully qualified). + +- [ ] **Step 5: Add send_file arm in execute_tool** + +Add a new arm **before** the fallthrough to `tools::execute_builtin_tool()` (before the `_ =>` arm at line ~2677). Place it after the `"patch_skill"` arm and before the MCP check: + +```rust +"send_file" => { + let path = arguments["path"] + .as_str() + .context("Missing 'path' argument")?; + let caption = arguments + .get("caption") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + let full_path = tools::validate_sandbox_path( + &self.config.sandbox.allowed_directory, + path, + )?; + + // Check file size before reading (Telegram limit: 50 MB) + let metadata = tokio::fs::metadata(&full_path).await + .with_context(|| format!("File not found: {}", full_path.display()))?; + const TG_FILE_LIMIT: u64 = 50 * 1024 * 1024; + if metadata.len() > TG_FILE_LIMIT { + anyhow::bail!( + "File is {} MB β€” exceeds Telegram's 50 MB limit", + metadata.len() / 1024 / 1024 + ); + } + + let bytes = tokio::fs::read(&full_path).await + .with_context(|| format!("Failed to read file: {}", full_path.display()))?; + + let file_name = full_path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("file") + .to_string(); + + let input_file = teloxide::types::InputFile::memory(bytes).file_name(file_name.clone()); + self.bot + .send_document(chat_id, input_file) + .caption(caption) + .await + .with_context(|| "Telegram API failed to send document")?; + + Ok(format!("File '{}' sent successfully.", file_name)) +} +``` + +- [ ] **Step 6: Add ChatId and InputFile imports** + +Add these to the existing imports in `src/agent.rs`: +```rust +use teloxide::types::{ChatId, InputFile}; +``` + +- [ ] **Step 7: Verify it compiles** + +Run: `cargo check` +Expected: Compiles with no errors. + +- [ ] **Step 8: Commit** + +```bash +git add src/agent.rs +git commit -m "feat: add send_file execution in agent.rs execute_tool" +``` + +--- + +### Task 3: Friendly tool name in tool_notifier.rs + +**Files:** +- Modify: `src/platform/tool_notifier.rs:285` + +- [ ] **Step 1: Add friendly name for send_file** + +At `src/platform/tool_notifier.rs`, add a new arm in the `friendly_tool_name` function (near the other built-in tools at ~line 285): + +```rust +"send_file" => return "πŸ“€ Sending a file".to_string(), +``` + +- [ ] **Step 2: Verify it compiles** + +Run: `cargo check` +Expected: Compiles with no errors. + +- [ ] **Step 3: Commit** + +```bash +git add src/platform/tool_notifier.rs +git commit -m "feat: add friendly tool name for send_file" +``` + +--- + +### Task 4: Final build and clippy + +- [ ] **Step 1: Full release build** + +Run: `cargo build --release` +Expected: Builds successfully. + +- [ ] **Step 2: Clippy** + +Run: `cargo clippy -- -D warnings` +Expected: No warnings. + +- [ ] **Step 3: Format** + +Run: `cargo fmt` +Expected: No changes. + +- [ ] **Step 4: Run tests** + +Run: `cargo test` +Expected: All tests pass (existing tool tests, nothing new for send_file yet). + +- [ ] **Step 5: Final commit** + +```bash +git add -A && git commit -m "chore: final cleanup after send_file implementation" +``` From 28d89c50711644c5c0a62b7556c4e33edee01eca Mon Sep 17 00:00:00 2001 From: "chinkan.ai" Date: Fri, 12 Jun 2026 17:06:38 +0800 Subject: [PATCH 04/15] =?UTF-8?q?docs:=20address=20plan=20review=20issues?= =?UTF-8?q?=20=E2=80=94=20async=20Result=20block,=20subagent=20ChatId(0),?= =?UTF-8?q?=20Context=20import,=20optional=20caption,=20tool=20ordering?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-06-12-send-file-from-sandbox.md | 98 +++++++++++-------- 1 file changed, 55 insertions(+), 43 deletions(-) diff --git a/docs/superpowers/plans/2026-06-12-send-file-from-sandbox.md b/docs/superpowers/plans/2026-06-12-send-file-from-sandbox.md index 9cdc427..19b0c21 100644 --- a/docs/superpowers/plans/2026-06-12-send-file-from-sandbox.md +++ b/docs/superpowers/plans/2026-06-12-send-file-from-sandbox.md @@ -22,7 +22,7 @@ Change `fn validate_sandbox_path` to `pub fn validate_sandbox_path` at `src/tool - [ ] **Step 2: Add `send_file` to `builtin_tool_definitions()`** -Insert a new `ToolDefinition` entry (before `plan_create` or after `execute_command` β€” order doesn't matter): +Insert a new `ToolDefinition` entry after `list_files` (around line 105, grouping it with the other file-related tools read/write/list): ```rust ToolDefinition { @@ -131,7 +131,13 @@ async fn execute_tool( ) -> String { ``` -Add `use teloxide::types::ChatId;` to the imports at the top of the file (or reference it fully qualified). +Add `use teloxide::types::ChatId;` to the imports at the top of the file (alongside the existing `use teloxide::Bot;` at line 6). + +Also update the subagent call site at `src/agent.rs:1792` β€” change `""` to `ChatId(0)` to match the new signature: +```rust +"", // agent has no user_id context +ChatId(0), // agent has no chat_id context +``` - [ ] **Step 5: Add send_file arm in execute_tool** @@ -139,54 +145,60 @@ Add a new arm **before** the fallthrough to `tools::execute_builtin_tool()` (bef ```rust "send_file" => { - let path = arguments["path"] - .as_str() - .context("Missing 'path' argument")?; - let caption = arguments - .get("caption") - .and_then(|v| v.as_str()) - .unwrap_or(""); - - let full_path = tools::validate_sandbox_path( - &self.config.sandbox.allowed_directory, - path, - )?; - - // Check file size before reading (Telegram limit: 50 MB) - let metadata = tokio::fs::metadata(&full_path).await - .with_context(|| format!("File not found: {}", full_path.display()))?; - const TG_FILE_LIMIT: u64 = 50 * 1024 * 1024; - if metadata.len() > TG_FILE_LIMIT { - anyhow::bail!( - "File is {} MB β€” exceeds Telegram's 50 MB limit", - metadata.len() / 1024 / 1024 - ); + match async { + let path = arguments["path"] + .as_str() + .context("Missing 'path' argument")?; + let caption = arguments + .get("caption") + .and_then(|v| v.as_str()) + .filter(|c| !c.is_empty()); + + let full_path = tools::validate_sandbox_path( + &self.config.sandbox.allowed_directory, + path, + )?; + + let metadata = tokio::fs::metadata(&full_path).await + .with_context(|| format!("File not found: {}", full_path.display()))?; + const TG_FILE_LIMIT: u64 = 50 * 1024 * 1024; + if metadata.len() > TG_FILE_LIMIT { + anyhow::bail!( + "File is {} MB β€” exceeds Telegram's 50 MB limit", + metadata.len() / 1024 / 1024 + ); + } + + let bytes = tokio::fs::read(&full_path).await + .with_context(|| format!("Failed to read file: {}", full_path.display()))?; + + let file_name = full_path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("file") + .to_string(); + + let input_file = teloxide::types::InputFile::memory(bytes).file_name(file_name.clone()); + let mut req = self.bot.send_document(chat_id, input_file); + if let Some(c) = caption { + req = req.caption(c); + } + req.await + .with_context(|| "Telegram API failed to send document")?; + + Ok(format!("File '{}' sent successfully.", file_name)) + }.await { + Ok(msg) => msg, + Err(e) => format!("Error sending file: {:#}", e), } - - let bytes = tokio::fs::read(&full_path).await - .with_context(|| format!("Failed to read file: {}", full_path.display()))?; - - let file_name = full_path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("file") - .to_string(); - - let input_file = teloxide::types::InputFile::memory(bytes).file_name(file_name.clone()); - self.bot - .send_document(chat_id, input_file) - .caption(caption) - .await - .with_context(|| "Telegram API failed to send document")?; - - Ok(format!("File '{}' sent successfully.", file_name)) } ``` - [ ] **Step 6: Add ChatId and InputFile imports** -Add these to the existing imports in `src/agent.rs`: +Add these alongside the existing imports at the top of `src/agent.rs` (near `use anyhow::Result;` at line 1 and `use teloxide::Bot;` at line 6): ```rust +use anyhow::Context; use teloxide::types::{ChatId, InputFile}; ``` From 13169ca432150fec81f8633f195cb77690f6f63b Mon Sep 17 00:00:00 2001 From: "chinkan.ai" Date: Fri, 12 Jun 2026 17:10:27 +0800 Subject: [PATCH 05/15] =?UTF-8?q?docs:=20fix=20unused=20chat=5Fid=20variab?= =?UTF-8?q?le=20=E2=80=94=20replace=20with=20parsed=5Fchat=5Fid=20only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/superpowers/plans/2026-06-12-send-file-from-sandbox.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-06-12-send-file-from-sandbox.md b/docs/superpowers/plans/2026-06-12-send-file-from-sandbox.md index 19b0c21..ed5be7f 100644 --- a/docs/superpowers/plans/2026-06-12-send-file-from-sandbox.md +++ b/docs/superpowers/plans/2026-06-12-send-file-from-sandbox.md @@ -74,7 +74,7 @@ git commit -m "feat: add send_file tool definition and make validate_sandbox_pat - [ ] **Step 1: Parse chat_id once at the top of process_message** -At `src/agent.rs:326`, after `let chat_id = &incoming.chat_id;`, add: +At `src/agent.rs:326`, replace `let chat_id = &incoming.chat_id;` with: ```rust let parsed_chat_id: ChatId = incoming.chat_id.parse::().map(ChatId).unwrap_or(ChatId(0)); @@ -82,6 +82,8 @@ let parsed_chat_id: ChatId = incoming.chat_id.parse::().map(ChatId).unwrap_ The `parse::()` works because Telegram chat IDs are numeric (positive for private chats, negative for groups/supergroups). The `ChatId(0)` fallback is safe because `send_file` will fail gracefully on invalid IDs. +The old `chat_id: &str` binding is removed since all call sites now use `parsed_chat_id: ChatId`. + - [ ] **Step 2: Pass parsed_chat_id to execute_tool in parallel agent group** At `src/agent.rs:758-759`, change: From dd543765e4b01f7a323d85a7e59f138f1beb960f Mon Sep 17 00:00:00 2001 From: "chinkan.ai" Date: Fri, 12 Jun 2026 17:14:16 +0800 Subject: [PATCH 06/15] feat: add send_file tool definition and make validate_sandbox_path pub --- src/tools.rs | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src/tools.rs b/src/tools.rs index 1297e31..0dbeafd 100644 --- a/src/tools.rs +++ b/src/tools.rs @@ -7,7 +7,7 @@ use crate::llm::{FunctionDefinition, ToolDefinition}; /// Validates that a path is within the allowed sandbox directory. /// Returns the canonicalized path if valid. -fn validate_sandbox_path(sandbox_dir: &Path, requested: &str) -> Result { +pub fn validate_sandbox_path(sandbox_dir: &Path, requested: &str) -> Result { let sandbox_canonical = sandbox_dir .canonicalize() .with_context(|| format!("Sandbox directory not found: {}", sandbox_dir.display()))?; @@ -103,6 +103,28 @@ pub fn builtin_tool_definitions() -> Vec { }), }, }, + ToolDefinition { + tool_type: "function".to_string(), + function: FunctionDefinition { + name: "send_file".to_string(), + description: "Send a file from the sandbox to the current chat. The file must already exist in the sandbox." + .to_string(), + parameters: json!({ + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "The file path (relative to sandbox or absolute within sandbox)" + }, + "caption": { + "type": "string", + "description": "Optional caption for the file" + } + }, + "required": ["path"] + }), + }, + }, ToolDefinition { tool_type: "function".to_string(), function: FunctionDefinition { From 65041068375e15f74740a48d3899f72296e0c665 Mon Sep 17 00:00:00 2001 From: "chinkan.ai" Date: Fri, 12 Jun 2026 17:22:50 +0800 Subject: [PATCH 07/15] feat: add send_file execution in agent.rs execute_tool --- src/agent.rs | 76 ++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 68 insertions(+), 8 deletions(-) diff --git a/src/agent.rs b/src/agent.rs index e7f4c03..8526b35 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -1,8 +1,11 @@ -use anyhow::Result; +use anyhow::{Context, Result}; use std::path::{Path, PathBuf}; use std::sync::{Arc, Weak}; use tracing::{debug, error, info, warn}; +use teloxide::payloads::SendDocumentSetters; +use teloxide::prelude::Requester; +use teloxide::types::{ChatId, InputFile}; use teloxide::Bot; use crate::agent_prompt::{ @@ -323,7 +326,11 @@ impl Agent { ) -> Result { let platform = &incoming.platform; let user_id = &incoming.user_id; - let chat_id = &incoming.chat_id; + let parsed_chat_id: ChatId = incoming + .chat_id + .parse::() + .map(ChatId) + .unwrap_or(ChatId(0)); // Get or create persistent conversation let conversation_id = self @@ -755,8 +762,9 @@ impl Agent { ); } - let result = - self.execute_tool(&name, &args, user_id, chat_id).await; + let result = self + .execute_tool(&name, &args, user_id, parsed_chat_id) + .await; if let Some(ref tx) = tool_event_tx { let success = !result.starts_with("Error"); @@ -830,7 +838,9 @@ impl Agent { }); } - let result = self.execute_tool(&name, &args, user_id, chat_id).await; + let result = self + .execute_tool(&name, &args, user_id, parsed_chat_id) + .await; // Notify tool completion if let Some(ref tx) = tool_event_tx { @@ -1788,8 +1798,8 @@ impl Agent { self.execute_tool( &tool_call.function.name, &arguments, - "", // agent has no user_id context - "", // agent has no chat_id context + "", // agent has no user_id context + ChatId(0), // agent has no chat_id context ) .await } else { @@ -1826,7 +1836,7 @@ impl Agent { name: &str, arguments: &serde_json::Value, user_id: &str, - chat_id: &str, + chat_id: ChatId, ) -> String { match name { "remember" => { @@ -2670,6 +2680,56 @@ impl Agent { Err(e) => format!("Patch failed: {:#}", e), } } + "send_file" => { + match async { + let path = arguments["path"] + .as_str() + .context("Missing 'path' argument")?; + let caption = arguments + .get("caption") + .and_then(|v| v.as_str()) + .filter(|c| !c.is_empty()); + + let full_path = + tools::validate_sandbox_path(&self.config.sandbox.allowed_directory, path)?; + + let metadata = tokio::fs::metadata(&full_path) + .await + .with_context(|| format!("File not found: {}", full_path.display()))?; + const TG_FILE_LIMIT: u64 = 50 * 1024 * 1024; + if metadata.len() > TG_FILE_LIMIT { + anyhow::bail!( + "File is {} MB β€” exceeds Telegram's 50 MB limit", + metadata.len() / 1024 / 1024 + ); + } + + let bytes = tokio::fs::read(&full_path) + .await + .with_context(|| format!("Failed to read file: {}", full_path.display()))?; + + let file_name = full_path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("file") + .to_string(); + + let input_file = InputFile::memory(bytes).file_name(file_name.clone()); + let mut req = self.bot.send_document(chat_id, input_file); + if let Some(c) = caption { + req = req.caption(c); + } + req.await + .with_context(|| "Telegram API failed to send document")?; + + Ok(format!("File '{}' sent successfully.", file_name)) + } + .await + { + Ok(msg) => msg, + Err(e) => format!("Error sending file: {:#}", e), + } + } _ if self.mcp.is_mcp_tool(name) => match self.mcp.call_tool(name, arguments).await { Ok(result) => result, Err(e) => format!("MCP tool error: {}", e), From da1fd03c7dbb197601f3d04be20c5a97d3cdd132 Mon Sep 17 00:00:00 2001 From: "chinkan.ai" Date: Fri, 12 Jun 2026 17:27:34 +0800 Subject: [PATCH 08/15] feat: add friendly tool name for send_file --- src/platform/tool_notifier.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/platform/tool_notifier.rs b/src/platform/tool_notifier.rs index 89a8726..982fd86 100644 --- a/src/platform/tool_notifier.rs +++ b/src/platform/tool_notifier.rs @@ -285,6 +285,7 @@ pub fn friendly_tool_name(name: &str) -> String { "read_file" => return "πŸ“„ Reading a file".to_string(), "write_file" => return "✏️ Writing a file".to_string(), "list_files" => return "πŸ“ Listing files".to_string(), + "send_file" => return "πŸ“€ Sending a file".to_string(), "execute_command" => return "πŸ’» Running a command".to_string(), "schedule_task" => return "πŸ—“οΈ Scheduling a task".to_string(), "list_scheduled_tasks" => return "πŸ—“οΈ Checking scheduled tasks".to_string(), From d8827466d9da1c9f4357808c775f57e848cbaf92 Mon Sep 17 00:00:00 2001 From: "chinkan.ai" Date: Fri, 12 Jun 2026 17:38:57 +0800 Subject: [PATCH 09/15] fix: correct misleading tool names and update README for send_file --- README.md | 11 +++++++++++ src/platform/tool_notifier.rs | 14 +++++++++++--- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index bd18a42..a22125e 100644 --- a/README.md +++ b/README.md @@ -258,6 +258,7 @@ Tools from MCP servers are automatically namespaced as `mcp____ String { "cancel_scheduled_task" => return "πŸ—“οΈ Cancelling a task".to_string(), "invoke_agent" => return "πŸ€– Calling a specialist".to_string(), "plan_create" | "plan_update" | "plan_view" => return "πŸ“‹ Managing plan".to_string(), - "read_skill_file" | "write_skill_file" => return "πŸ“– Reading skill".to_string(), - "reload_skills" | "reload_agents" => return "πŸ”„ Reloading".to_string(), - "read_agent_file" | "write_agent_file" => return "πŸ€– Agent file".to_string(), + "read_skill_file" => return "πŸ“– Reading skill".to_string(), + "write_skill_file" => return "✏️ Writing a skill".to_string(), + "reload_skills" | "reload_agents" | "reload_skills_and_agents" => { + return "πŸ”„ Reloading".to_string() + } + "read_agent_file" => return "πŸ“– Reading agent file".to_string(), + "write_agent_file" => return "✏️ Writing agent file".to_string(), + "spawn_agents" => return "🧬 Spawning subagents".to_string(), + "try_new_tech" => return "πŸ§ͺ Trying new tech".to_string(), + "self_update_to_branch" => return "πŸ”„ Self-updating".to_string(), + "patch_skill" => return "πŸ”§ Patching skill".to_string(), _ => name, }; From 41c4cdf483953f543d1edea82fe70e48a7a45286 Mon Sep 17 00:00:00 2001 From: "chinkan.ai" Date: Sat, 13 Jun 2026 01:37:51 +0800 Subject: [PATCH 10/15] refactor: embed skills/agents via include_dir, remove setup binary - Remove two-layer SkillRegistry (instance + bundled with SkillSource) - Embed skills/ and agents/ at compile time via include_dir crate - Simplify SkillRegistry to single-layer skills map - Remove src/bin/setup.rs and setup.sh (use rustfox --setup instead) - Fix web wizard JS to only emit [skills]/[agents] when user provides values - Fix /update-skills to backup modified files before overwriting - Remove bundled_directory from SkillsConfig/AgentsConfig - Fix all reload handlers to use full registry replacement --- .gitignore | 5 +- Cargo.lock | 1 + Cargo.toml | 16 +- scripts/services/rustfox.service.template | 1 + setup.sh | 19 -- setup/index.html | 8 +- skills-lock.json | 75 ----- src/agent.rs | 316 +++++----------------- src/bin/setup.rs | 19 -- src/config.rs | 26 -- src/learning.rs | 6 +- src/main.rs | 68 +---- src/platform/telegram.rs | 36 +-- src/setup/service.rs | 10 +- src/setup/wizard.rs | 11 +- src/skills/embed.rs | 181 +++++++++++++ src/skills/loader.rs | 17 +- src/skills/mod.rs | 158 +++-------- src/supervisor/classifier.rs | 1 - tests/supervisor_skill_packs.rs | 1 - 20 files changed, 349 insertions(+), 626 deletions(-) delete mode 100755 setup.sh delete mode 100644 skills-lock.json delete mode 100644 src/bin/setup.rs create mode 100644 src/skills/embed.rs diff --git a/.gitignore b/.gitignore index 291801e..15da35b 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,7 @@ rustfox.db* .worktrees/ # Playwright config and cache -.playwright/ \ No newline at end of file +.playwright/ + +# Opencode +.opencode/package-lock.json diff --git a/Cargo.lock b/Cargo.lock index b280be3..e583d95 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2410,6 +2410,7 @@ dependencies = [ "futures", "futures-util", "image", + "include_dir", "infer", "ocrs", "pdf-extract", diff --git a/Cargo.toml b/Cargo.toml index 20d0356..4755cbb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,9 +55,12 @@ pulldown-cmark = "0.12" # SQLite vector search extension sqlite-vec = "0.1" -# Setup wizard web server (used only by src/bin/setup.rs) +# Setup wizard web server (used by rustfox --setup) axum = "0.8" +# Embed bundled skills/agents into the binary for cargo install +include_dir = "0.7" + # OCR (pure Rust, neural-network based) ocrs = "0.12" rten = { version = "0.24", features = ["rten_format"] } @@ -75,7 +78,7 @@ infer = "0.19" # Base64 for vision API content parts and OAuth PKCE helpers base64 = "0.22" -# OAuth 2.0 / PKCE helpers (used only by src/bin/setup.rs) +# OAuth 2.0 / PKCE helpers (used by setup wizard) rand = "0.8" sha2 = "0.10" @@ -84,5 +87,14 @@ regex = "1" # OS home-directory resolution for the persistent home dir (~/.rustfox) dirs = "5" + +[lib] +name = "rustfox" +path = "src/lib.rs" + +[[bin]] +name = "rustfox" +path = "src/main.rs" + [dev-dependencies] tempfile = "3" diff --git a/scripts/services/rustfox.service.template b/scripts/services/rustfox.service.template index daa1909..6198280 100644 --- a/scripts/services/rustfox.service.template +++ b/scripts/services/rustfox.service.template @@ -9,6 +9,7 @@ ExecStart={{RUSTFOX_BIN}} --config {{RUSTFOX_CONFIG}} Restart=on-failure RestartSec=5 Environment=RUSTFOX_HOME={{RUSTFOX_HOME}} +Environment=PATH={{RUSTFOX_PATH}} [Install] WantedBy=default.target diff --git a/setup.sh b/setup.sh deleted file mode 100755 index 6e043c8..0000000 --- a/setup.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/env bash -# RustFox setup wizard entry point. -# -# Usage: -# ./setup.sh # Opens browser-based wizard on http://localhost:8719 -# ./setup.sh --cli # Interactive terminal wizard - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -BINARY="$SCRIPT_DIR/target/release/setup" - -# Build the setup binary if it isn't present or is older than its source. -if [[ ! -f "$BINARY" ]] || [[ "$SCRIPT_DIR/src/bin/setup.rs" -nt "$BINARY" ]] || [[ "$SCRIPT_DIR/setup/index.html" -nt "$BINARY" ]]; then - echo "Building setup wizard…" - cargo build --release --bin setup --manifest-path "$SCRIPT_DIR/Cargo.toml" -fi - -RUSTFOX_ROOT="$SCRIPT_DIR" "$BINARY" "$@" diff --git a/setup/index.html b/setup/index.html index 3bad009..1af8b58 100644 --- a/setup/index.html +++ b/setup/index.html @@ -925,12 +925,8 @@

Notion β€” OAuth 2.0 Setup

'database_path = "' + esc(state.db_path) + '"\n' + 'query_rewriter_enabled = ' + (state.query_rewriter_enabled ? 'true' : 'false') + '\n' + '\n' + - '[skills]\n' + - 'directory = "' + esc(state.skills_dir || 'skills') + '"\n' + - '\n' + - '[agents]\n' + - 'directory = "' + esc(state.agents_dir || 'agents') + '"\n' + - '\n'; + (state.skills_dir ? '[skills]\ndirectory = "' + esc(state.skills_dir) + '"\n\n' : '') + + (state.agents_dir ? '[agents]\ndirectory = "' + esc(state.agents_dir) + '"\n\n' : ''); // [general] β€” emit commented hints for unset fields so the user knows they exist const generalLines = []; diff --git a/skills-lock.json b/skills-lock.json deleted file mode 100644 index 1dac175..0000000 --- a/skills-lock.json +++ /dev/null @@ -1,75 +0,0 @@ -{ - "version": 1, - "skills": { - "brainstorming": { - "source": "/home/kan/workspaces/playground/myplay/superpowers", - "sourceType": "local", - "computedHash": "8df9b47092524833138b58682d03f2e153a242e550d1693657afdbee0cc9cdb0" - }, - "dispatching-parallel-agents": { - "source": "/home/kan/workspaces/playground/myplay/superpowers", - "sourceType": "local", - "computedHash": "e21044ae7496fc285f1df9ff3fb518a3a20bea6f5216c1f59f4987e12c769a0a" - }, - "executing-plans": { - "source": "/home/kan/workspaces/playground/myplay/superpowers", - "sourceType": "local", - "computedHash": "2f709d1a2c7564f8ce6a9bb707e7f5451117ba80f8e8e2302662c29bc61246a6" - }, - "finishing-a-development-branch": { - "source": "/home/kan/workspaces/playground/myplay/superpowers", - "sourceType": "local", - "computedHash": "9edba9a38684c060fdc38290f640e1dc0c37de286723ac9be73bacacf7cd6f3d" - }, - "receiving-code-review": { - "source": "/home/kan/workspaces/playground/myplay/superpowers", - "sourceType": "local", - "computedHash": "2760c85d4f4117b0006e7ba755f4bbd61f8f4c185f347999763c97f507274e30" - }, - "requesting-code-review": { - "source": "/home/kan/workspaces/playground/myplay/superpowers", - "sourceType": "local", - "computedHash": "246e65cd72a7e360987d94e6c61564ce6d71c6361d1784d529d70e4cbbe8522d" - }, - "subagent-driven-development": { - "source": "/home/kan/workspaces/playground/myplay/superpowers", - "sourceType": "local", - "computedHash": "a197f8e5fba5ea59013e1adbaa90dd42db35de8f074feca200c8038de584a83b" - }, - "systematic-debugging": { - "source": "/home/kan/workspaces/playground/myplay/superpowers", - "sourceType": "local", - "computedHash": "72e9ab72627e4fd8ed26a582e82309a98ecdc4f6e1c99418430ac05682c9e91d" - }, - "test-driven-development": { - "source": "/home/kan/workspaces/playground/myplay/superpowers", - "sourceType": "local", - "computedHash": "126f1ebf6ccd414f42544f6e83d8cc5adb089e1108eaffb7c400701e37eecd9f" - }, - "using-git-worktrees": { - "source": "/home/kan/workspaces/playground/myplay/superpowers", - "sourceType": "local", - "computedHash": "52bbb4b6e80918e83e92a1514f3b3757712154c2a8a42de24919e48a794c54fc" - }, - "using-superpowers": { - "source": "/home/kan/workspaces/playground/myplay/superpowers", - "sourceType": "local", - "computedHash": "ed8b3c1277a7c80407b4571bb807a18dd36c16b0acc58fc27b1d7db47d5a8b51" - }, - "verification-before-completion": { - "source": "/home/kan/workspaces/playground/myplay/superpowers", - "sourceType": "local", - "computedHash": "9b446f0c7fe1cfb560b1d34439523b1a76d5f177290007b2c053a1c749a4a8ba" - }, - "writing-plans": { - "source": "/home/kan/workspaces/playground/myplay/superpowers", - "sourceType": "local", - "computedHash": "bfaed11054ad8021c66860244e2d2e0d1eca483ae9f5fbfcb956e99c61f5c3d7" - }, - "writing-skills": { - "source": "/home/kan/workspaces/playground/myplay/superpowers", - "sourceType": "local", - "computedHash": "c0a92e629e39ea91b1f54da67a6aa92723008a8ef5b500c6de078f55485937c7" - } - } -} diff --git a/src/agent.rs b/src/agent.rs index e7f4c03..a17bfd0 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -200,81 +200,29 @@ impl Agent { /// Returns `(skills_count, agents_count)`. pub async fn reload_skills_and_agents(&self) -> (usize, usize) { use crate::skills::loader::load_skills_from_dir; - use crate::skills::SkillSource; - - // Skills: load both layers, merge - let s_instance_dir = self.config.skills.directory.clone(); - let s_bundled_dir = self.config.skills.bundled_directory.clone(); - let s_instance = load_skills_from_dir( - &s_instance_dir, - SkillSource::Instance, - s_instance_dir.clone(), - ) - .await; - let s_bundled = - load_skills_from_dir(&s_bundled_dir, SkillSource::Bundled, s_bundled_dir.clone()).await; - let s_instance_ok = s_instance.is_ok(); - let s_bundled_ok = s_bundled.is_ok(); - - { - let mut skills = self.skills.write().await; - let mut new_base_dirs = std::collections::BTreeMap::new(); - if let Ok(reg) = s_instance { - skills.instance_skills = reg.instance_skills; - for (k, v) in reg.skill_base_dirs { - new_base_dirs.insert(k, v); - } - } - if let Ok(reg) = s_bundled { - skills.bundled_skills = reg.bundled_skills; - for (k, v) in reg.skill_base_dirs { - new_base_dirs.entry(k).or_insert(v); - } - } - if s_instance_ok || s_bundled_ok { - skills.skill_base_dirs.clear(); - skills.skill_base_dirs.extend(new_base_dirs); - } - } - let s_count = self.skills.read().await.len(); - - // Agents: same pattern - let a_instance_dir = self.config.agents.directory.clone(); - let a_bundled_dir = self.config.agents.bundled_directory.clone(); - let a_instance = load_skills_from_dir( - &a_instance_dir, - SkillSource::Instance, - a_instance_dir.clone(), - ) - .await; - let a_bundled = - load_skills_from_dir(&a_bundled_dir, SkillSource::Bundled, a_bundled_dir.clone()).await; - let a_instance_ok = a_instance.is_ok(); - let a_bundled_ok = a_bundled.is_ok(); - - { - let mut agents = self.agents.write().await; - let mut new_base_dirs = std::collections::BTreeMap::new(); - if let Ok(reg) = a_instance { - agents.instance_skills = reg.instance_skills; - for (k, v) in reg.skill_base_dirs { - new_base_dirs.insert(k, v); - } - } - if let Ok(reg) = a_bundled { - agents.bundled_skills = reg.bundled_skills; - for (k, v) in reg.skill_base_dirs { - new_base_dirs.entry(k).or_insert(v); - } - } - if a_instance_ok || a_bundled_ok { - agents.skill_base_dirs.clear(); - agents.skill_base_dirs.extend(new_base_dirs); - } - } - let a_count = self.agents.read().await.len(); - (s_count, a_count) + let skills_dir = self.config.skills.directory.clone(); + let agents_dir = self.config.agents.directory.clone(); + + if let Ok(reg) = load_skills_from_dir(&skills_dir, skills_dir.clone()).await { + let count = reg.len(); + let mut s = self.skills.write().await; + *s = reg; + let a = if let Ok(reg) = load_skills_from_dir(&agents_dir, agents_dir.clone()).await { + let count = reg.len(); + let mut a = self.agents.write().await; + *a = reg; + count + } else { + self.agents.read().await.len() + }; + (count, a) + } else { + ( + self.skills.read().await.len(), + self.agents.read().await.len(), + ) + } } /// Process an incoming message and return the response text @@ -2139,31 +2087,14 @@ impl Agent { Ok(()) => { info!("Skill file written: {}", target.display()); - // After writing, reload just the instance layer + // After writing, reload skills let instance_dir = self.config.skills.directory.clone(); use crate::skills::loader::load_skills_from_dir; - use crate::skills::SkillSource; - if let Ok(new_instance) = load_skills_from_dir( - &instance_dir, - SkillSource::Instance, - instance_dir.clone(), - ) - .await + if let Ok(new_reg) = + load_skills_from_dir(&instance_dir, instance_dir.clone()).await { - let bundled_dir = self.config.skills.bundled_directory.clone(); let mut skills = self.skills.write().await; - skills.instance_skills = new_instance.instance_skills; - skills.skill_base_dirs.clear(); - let bundled_names = - skills.bundled_skills.keys().cloned().collect::>(); - for name in bundled_names { - skills - .skill_base_dirs - .insert(name.clone(), bundled_dir.clone()); - } - for (k, v) in new_instance.skill_base_dirs { - skills.skill_base_dirs.insert(k, v); - } + *skills = new_reg; } format!("Written: {}", target.display()) @@ -2173,50 +2104,18 @@ impl Agent { } "reload_skills" => { use crate::skills::loader::load_skills_from_dir; - use crate::skills::SkillSource; - let instance_dir = self.config.skills.directory.clone(); - let bundled_dir = self.config.skills.bundled_directory.clone(); - - let instance_reg = load_skills_from_dir( - &instance_dir, - SkillSource::Instance, - instance_dir.clone(), - ) - .await; - let bundled_reg = - load_skills_from_dir(&bundled_dir, SkillSource::Bundled, bundled_dir.clone()) - .await; - let instance_ok = instance_reg.is_ok(); - let bundled_ok = bundled_reg.is_ok(); - - let mut skills = self.skills.write().await; - let mut new_base_dirs = std::collections::BTreeMap::new(); - if let Ok(reg) = instance_reg { - skills.instance_skills = reg.instance_skills; - for (k, v) in reg.skill_base_dirs { - new_base_dirs.insert(k, v); + let skills_dir = self.config.skills.directory.clone(); + match load_skills_from_dir(&skills_dir, skills_dir.clone()).await { + Ok(new_reg) => { + let count = new_reg.len(); + let mut skills = self.skills.write().await; + *skills = new_reg; + info!("Skills reloaded: {} skill(s) active", count); + format!("Skills reloaded. {} skill(s) now active.", count) } + Err(e) => format!("Failed to reload skills: {}", e), } - if let Ok(reg) = bundled_reg { - skills.bundled_skills = reg.bundled_skills; - for (k, v) in reg.skill_base_dirs { - new_base_dirs.entry(k).or_insert(v); - } - } - if instance_ok || bundled_ok { - skills.skill_base_dirs.clear(); - skills.skill_base_dirs.extend(new_base_dirs); - } - - let count = skills.len(); - info!( - "Skills reloaded: {} skill(s) active ({:?} instance, {:?} bundled)", - count, - skills.instance_skills.len(), - skills.bundled_skills.len() - ); - format!("Skills reloaded. {} skill(s) now active.", count) } "invoke_agent" => { // Accepts `agent` parameter; falls back to `skill` for compat @@ -2413,31 +2312,14 @@ impl Agent { Ok(()) => { info!("Agent file written: {}", target.display()); - // After writing, reload just the instance layer + // After writing, reload agents let instance_dir = self.config.agents.directory.clone(); use crate::skills::loader::load_skills_from_dir; - use crate::skills::SkillSource; - if let Ok(new_instance) = load_skills_from_dir( - &instance_dir, - SkillSource::Instance, - instance_dir.clone(), - ) - .await + if let Ok(new_reg) = + load_skills_from_dir(&instance_dir, instance_dir.clone()).await { - let bundled_dir = self.config.agents.bundled_directory.clone(); let mut agents = self.agents.write().await; - agents.instance_skills = new_instance.instance_skills; - agents.skill_base_dirs.clear(); - let bundled_names = - agents.bundled_skills.keys().cloned().collect::>(); - for name in bundled_names { - agents - .skill_base_dirs - .insert(name.clone(), bundled_dir.clone()); - } - for (k, v) in new_instance.skill_base_dirs { - agents.skill_base_dirs.insert(k, v); - } + *agents = new_reg; } format!("Written: {}", target.display()) @@ -2447,50 +2329,18 @@ impl Agent { } "reload_agents" => { use crate::skills::loader::load_skills_from_dir; - use crate::skills::SkillSource; - - let instance_dir = self.config.agents.directory.clone(); - let bundled_dir = self.config.agents.bundled_directory.clone(); - let instance_reg = load_skills_from_dir( - &instance_dir, - SkillSource::Instance, - instance_dir.clone(), - ) - .await; - let bundled_reg = - load_skills_from_dir(&bundled_dir, SkillSource::Bundled, bundled_dir.clone()) - .await; - let instance_ok = instance_reg.is_ok(); - let bundled_ok = bundled_reg.is_ok(); - - let mut agents = self.agents.write().await; - let mut new_base_dirs = std::collections::BTreeMap::new(); - if let Ok(reg) = instance_reg { - agents.instance_skills = reg.instance_skills; - for (k, v) in reg.skill_base_dirs { - new_base_dirs.insert(k, v); + let agents_dir = self.config.agents.directory.clone(); + match load_skills_from_dir(&agents_dir, agents_dir.clone()).await { + Ok(new_reg) => { + let count = new_reg.len(); + let mut agents = self.agents.write().await; + *agents = new_reg; + info!("Agents reloaded: {} agent(s) active", count); + format!("Agents reloaded. {} agent(s) now active.", count) } + Err(e) => format!("Failed to reload agents: {}", e), } - if let Ok(reg) = bundled_reg { - agents.bundled_skills = reg.bundled_skills; - for (k, v) in reg.skill_base_dirs { - new_base_dirs.entry(k).or_insert(v); - } - } - if instance_ok || bundled_ok { - agents.skill_base_dirs.clear(); - agents.skill_base_dirs.extend(new_base_dirs); - } - - let count = agents.len(); - info!( - "Agents reloaded: {} agent(s) active ({:?} instance, {:?} bundled)", - count, - agents.instance_skills.len(), - agents.bundled_skills.len() - ); - format!("Agents reloaded. {} agent(s) now active.", count) } "try_new_tech" => { let technology = match arguments["technology"].as_str() { @@ -2897,17 +2747,20 @@ mod tests { } #[test] - fn test_reloads_clear_base_dir_maps_before_repopulation() { + fn test_reloads_replace_registry_not_just_instance_skills() { + // Ensure reload paths use `*registry = new_reg` (full replacement) + // rather than only updating instance_skills while leaving stale bundled entries. let source = include_str!("agent.rs"); - let skills_clear_count = source.matches("skills.skill_base_dirs.clear();").count(); - let agents_clear_count = source.matches("agents.skill_base_dirs.clear();").count(); + // Each reload/write handler should do `*skills = new_reg` or `*agents = new_reg` + let skills_replace = source.matches("*skills = new_reg").count(); + let agents_replace = source.matches("*agents = new_reg").count(); assert!( - skills_clear_count >= 3, - "skills base-dir map must be cleared in all reload/write paths" + skills_replace >= 2, + "all skill reload paths must replace the entire registry: found {skills_replace}" ); assert!( - agents_clear_count >= 3, - "agents base-dir map must be cleared in all reload/write paths" + agents_replace >= 2, + "all agent reload paths must replace the entire registry: found {agents_replace}" ); } @@ -3072,61 +2925,26 @@ mod tests { } #[tokio::test] - async fn test_read_skill_file_checks_instance_before_bundled() { + async fn test_load_skills_from_single_instance_dir() { let dir = tempfile::tempdir().unwrap(); let instance_dir = dir.path().join("instance-skills"); - let bundled_dir = dir.path().join("bundled-skills"); - // Create same-named skill in both dirs with different content tokio::fs::create_dir_all(instance_dir.join("my-skill")) .await .unwrap(); tokio::fs::write(instance_dir.join("my-skill/SKILL.md"), "instance content") .await .unwrap(); - tokio::fs::create_dir_all(bundled_dir.join("my-skill")) - .await - .unwrap(); - tokio::fs::write(bundled_dir.join("my-skill/SKILL.md"), "bundled content") - .await - .unwrap(); - // Load both into registry - let mut registry = crate::skills::SkillRegistry::new(); - let inst = crate::skills::loader::load_skills_from_dir( - &instance_dir, - crate::skills::SkillSource::Instance, - instance_dir.clone(), - ) - .await - .unwrap(); - let bund = crate::skills::loader::load_skills_from_dir( - &bundled_dir, - crate::skills::SkillSource::Bundled, - bundled_dir.clone(), - ) - .await - .unwrap(); - for s in inst.list() { - registry.register( - s.clone(), - crate::skills::SkillSource::Instance, - instance_dir.clone(), - ); - } - for s in bund.list() { - registry.register( - s.clone(), - crate::skills::SkillSource::Bundled, - bundled_dir.clone(), - ); - } + let registry = + crate::skills::loader::load_skills_from_dir(&instance_dir, instance_dir.clone()) + .await + .unwrap(); - // read_skill_file should find instance version - let base_dir = registry.base_dir("my-skill").unwrap(); - let target = base_dir.join("my-skill/SKILL.md"); - let content = tokio::fs::read_to_string(&target).await.unwrap(); - assert_eq!(content, "instance content", "instance must shadow bundled"); + assert_eq!(registry.len(), 1); + let skill = registry.get("my-skill").unwrap(); + assert_eq!(skill.name, "my-skill"); + assert_eq!(skill.description, "instance content"); } #[test] diff --git a/src/bin/setup.rs b/src/bin/setup.rs deleted file mode 100644 index e06b9e6..0000000 --- a/src/bin/setup.rs +++ /dev/null @@ -1,19 +0,0 @@ -//! Thin wrapper β€” delegates to `rustfox::setup::wizard`. -//! -//! Kept for backwards compat with `./setup.sh` and `cargo run --bin setup`. -//! New users should use `rustfox --setup` instead. - -use std::path::PathBuf; - -#[tokio::main] -async fn main() -> anyhow::Result<()> { - let cli = std::env::args().any(|a| a == "--cli"); - let config_dir = match std::env::var("RUSTFOX_CONFIG_PATH") { - Ok(p) => PathBuf::from(p) - .parent() - .map(|d| d.to_path_buf()) - .unwrap_or_else(|| PathBuf::from(".")), - Err(_) => std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), - }; - rustfox::setup::wizard::run(&config_dir, cli).await -} diff --git a/src/config.rs b/src/config.rs index 5c80ba4..3043146 100644 --- a/src/config.rs +++ b/src/config.rs @@ -206,18 +206,12 @@ pub struct MemoryConfig { pub struct SkillsConfig { #[serde(default)] pub directory: PathBuf, - /// Bundled skills directory (read-only templates, default CWD-relative `./skills/`). - #[serde(default = "default_bundled_skills_dir")] - pub bundled_directory: PathBuf, } #[derive(Debug, Deserialize, Clone)] pub struct AgentsConfig { #[serde(default)] pub directory: PathBuf, - /// Bundled agents directory (read-only templates, default CWD-relative `./agents/`). - #[serde(default = "default_bundled_agents_dir")] - pub bundled_directory: PathBuf, } #[derive(Debug, Deserialize, Clone, Default)] @@ -354,14 +348,12 @@ fn default_memory_config() -> MemoryConfig { fn default_skills_config() -> SkillsConfig { SkillsConfig { directory: PathBuf::new(), - bundled_directory: default_bundled_skills_dir(), } } fn default_agents_config() -> AgentsConfig { AgentsConfig { directory: PathBuf::new(), - bundled_directory: default_bundled_agents_dir(), } } @@ -401,14 +393,6 @@ fn default_ocr_config() -> OcrConfig { } } -fn default_bundled_skills_dir() -> PathBuf { - PathBuf::from("skills") -} - -fn default_bundled_agents_dir() -> PathBuf { - PathBuf::from("agents") -} - fn default_true() -> bool { true } @@ -514,16 +498,6 @@ impl Config { }; ensure_dirs(&paths)?; - // Resolve bundled directories relative to CWD (not home) since they - // ship alongside the binary / project root. - let cwd = std::env::current_dir()?; - if !self.skills.bundled_directory.is_absolute() { - self.skills.bundled_directory = cwd.join(&self.skills.bundled_directory); - } - if !self.agents.bundled_directory.is_absolute() { - self.agents.bundled_directory = cwd.join(&self.agents.bundled_directory); - } - self.sandbox.allowed_directory = workspace; self.memory.database_path = database; self.skills.directory = skills; diff --git a/src/learning.rs b/src/learning.rs index ed2900a..555ab17 100644 --- a/src/learning.rs +++ b/src/learning.rs @@ -4,7 +4,7 @@ use tracing::{info, warn}; use crate::llm::{ChatMessage, LlmClient, MessageContent}; use crate::skills::loader::load_skills_from_dir; -use crate::skills::{SkillRegistry, SkillSource}; +use crate::skills::SkillRegistry; /// Minimum number of conversation messages needed before updating the user model. const MIN_MESSAGES_FOR_USER_MODEL: usize = 3; @@ -174,7 +174,7 @@ async fn extract_skill_from_conversation( info!("Written auto-generated skill: {}", skill_path.display()); // Hot-reload skills. - match load_skills_from_dir(skills_dir, SkillSource::Instance, skills_dir.to_path_buf()).await { + match load_skills_from_dir(skills_dir, skills_dir.to_path_buf()).await { Ok(new_registry) => { let count = new_registry.len(); let mut reg = skills.write().await; @@ -268,7 +268,7 @@ pub async fn self_patch_skill( ); // Hot-reload skills. - match load_skills_from_dir(skills_dir, SkillSource::Instance, skills_dir.to_path_buf()).await { + match load_skills_from_dir(skills_dir, skills_dir.to_path_buf()).await { Ok(new_registry) => { let count = new_registry.len(); let mut reg = skills.write().await; diff --git a/src/main.rs b/src/main.rs index 989059e..9b90e7e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,7 +13,7 @@ use rustfox::platform; use rustfox::scheduler::tasks::register_builtin_tasks; use rustfox::scheduler::Scheduler; use rustfox::setup; -use rustfox::skills::{loader::load_skills_from_dir, SkillSource}; +use rustfox::skills::loader::load_skills_from_dir; #[tokio::main] async fn main() -> Result<()> { @@ -112,26 +112,14 @@ async fn main() -> Result<()> { let mut mcp_manager = McpManager::new(); mcp_manager.connect_all(&mcp_server_configs).await; - // Seed bundled skills/agents into the instance home on first run. - // Seed bundled skills/agents into the instance home on first run. - if let Err(e) = rustfox::skills::seed::seed_dir_if_empty( - &config.skills.bundled_directory, - &config.skills.directory, - ) - .await - { + // Seed bundled skills/agents from embedded data into the home directory. + if let Err(e) = rustfox::skills::embed::seed_skills(&config.skills.directory).await { warn!("Skill seeding failed: {e}"); } - if let Err(e) = rustfox::skills::seed::seed_dir_if_empty( - &config.agents.bundled_directory, - &config.agents.directory, - ) - .await - { + if let Err(e) = rustfox::skills::embed::seed_agents(&config.agents.directory).await { warn!("Agent seeding failed: {e}"); } - // Write the home-side lock so /update-skills can diff later (only when seeded - // into the home and a lock does not already exist). + // Write a home-side lock recording content hashes for future diff/audit. if let Some(home) = &config.resolved_home { let seed_lock = |lock_name: &str, dir: &std::path::Path| { let lock_path = home.join(lock_name); @@ -149,48 +137,14 @@ async fn main() -> Result<()> { seed_lock("agents-lock.json", &config.agents.directory); } - // Load skills from instance and bundled directories (instance shadows bundled) - let mut skills = load_skills_from_dir( - &config.skills.directory, - SkillSource::Instance, - config.skills.directory.clone(), - ) - .await?; - let bundled_skills = load_skills_from_dir( - &config.skills.bundled_directory, - SkillSource::Bundled, - config.skills.bundled_directory.clone(), - ) - .await?; - for skill in bundled_skills.list() { - skills.register( - skill.clone(), - SkillSource::Bundled, - config.skills.bundled_directory.clone(), - ); - } + // Load skills from the instance directory. + let skills = + load_skills_from_dir(&config.skills.directory, config.skills.directory.clone()).await?; info!(" Skills: {}", skills.len()); - // Load agents from instance and bundled directories (instance shadows bundled) - let mut agents = load_skills_from_dir( - &config.agents.directory, - SkillSource::Instance, - config.agents.directory.clone(), - ) - .await?; - let bundled_agents = load_skills_from_dir( - &config.agents.bundled_directory, - SkillSource::Bundled, - config.agents.bundled_directory.clone(), - ) - .await?; - for agent in bundled_agents.list() { - agents.register( - agent.clone(), - SkillSource::Bundled, - config.agents.bundled_directory.clone(), - ); - } + // Load agents from the instance directory. + let agents = + load_skills_from_dir(&config.agents.directory, config.agents.directory.clone()).await?; info!(" Agents: {}", agents.len()); // Create ScheduledTaskStore sharing the existing SQLite connection diff --git a/src/platform/telegram.rs b/src/platform/telegram.rs index 1d74f33..45b22ed 100644 --- a/src/platform/telegram.rs +++ b/src/platform/telegram.rs @@ -331,34 +331,20 @@ async fn handle_message(bot: Bot, msg: Message, agent: Arc) -> ResponseRe } if text == "/updateskills" || text == "/update-skills" { - let bundled_skills = agent.config.skills.bundled_directory.clone(); - let bundled_agents = agent.config.agents.bundled_directory.clone(); - let home = agent.config.resolved_home.clone(); - let lock_for = |name: &str| -> std::path::PathBuf { - home.clone() - .map(|h| h.join(name)) - .unwrap_or_else(|| std::path::PathBuf::from(name)) - }; - let mut lines = Vec::new(); - match crate::skills::update::update_skills( - &bundled_skills, - &agent.config.skills.directory, - &lock_for("skills-lock.json"), - ) - .await - { - Ok(r) => lines.push(format!("Skills β€” {}", r.summary())), + + match crate::skills::embed::overwrite_skills(&agent.config.skills.directory).await { + Ok(r) => lines.push(format!( + "Skills β€” {} written, {} backed up.", + r.written, r.backed_up + )), Err(e) => lines.push(format!("Skills update failed: {e}")), } - match crate::skills::update::update_skills( - &bundled_agents, - &agent.config.agents.directory, - &lock_for("agents-lock.json"), - ) - .await - { - Ok(r) => lines.push(format!("Agents β€” {}", r.summary())), + match crate::skills::embed::overwrite_agents(&agent.config.agents.directory).await { + Ok(r) => lines.push(format!( + "Agents β€” {} written, {} backed up.", + r.written, r.backed_up + )), Err(e) => lines.push(format!("Agents update failed: {e}")), } diff --git a/src/setup/service.rs b/src/setup/service.rs index 3bdf8d3..6f448eb 100644 --- a/src/setup/service.rs +++ b/src/setup/service.rs @@ -19,10 +19,12 @@ fn home_dir() -> PathBuf { fn render_template(template: &str, bin_path: &Path) -> String { let home = home_dir(); let config_path = home.join("config.toml"); + let path = std::env::var("PATH").unwrap_or_else(|_| "/usr/local/bin:/usr/bin:/bin".to_string()); template .replace("{{RUSTFOX_BIN}}", &bin_path.to_string_lossy()) .replace("{{RUSTFOX_CONFIG}}", &config_path.to_string_lossy()) .replace("{{RUSTFOX_HOME}}", &home.to_string_lossy()) + .replace("{{RUSTFOX_PATH}}", &path) } pub fn handle(action: Action) -> Result<()> { @@ -374,15 +376,21 @@ mod tests { #[test] fn test_render_template_replaces_placeholders() { - let template = "bin={{RUSTFOX_BIN}}\nconfig={{RUSTFOX_CONFIG}}\nhome={{RUSTFOX_HOME}}\n"; + let template = "bin={{RUSTFOX_BIN}}\nconfig={{RUSTFOX_CONFIG}}\nhome={{RUSTFOX_HOME}}\npath={{RUSTFOX_PATH}}\n"; let bin_path = Path::new("/usr/local/bin/rustfox"); let result = render_template(template, bin_path); assert!(result.contains("/usr/local/bin/rustfox")); assert!(result.contains(".rustfox/config.toml")); assert!(result.contains(".rustfox\n")); + assert!( + result.contains('/'), + "PATH must be populated in rendered template, got: {}", + result + ); assert!(!result.contains("{{RUSTFOX_BIN}}")); assert!(!result.contains("{{RUSTFOX_CONFIG}}")); assert!(!result.contains("{{RUSTFOX_HOME}}")); + assert!(!result.contains("{{RUSTFOX_PATH}}")); } #[test] diff --git a/src/setup/wizard.rs b/src/setup/wizard.rs index 3fc3d86..2379843 100644 --- a/src/setup/wizard.rs +++ b/src/setup/wizard.rs @@ -772,9 +772,6 @@ Be concise and helpful.""" [memory] database_path = "{db_path}" -[skills] -directory = "skills" - [general] {loc_line} "# @@ -1074,9 +1071,11 @@ allowed_directory = "/tmp" } #[test] - fn test_skills_section_present() { + fn test_no_relative_skills_directory() { let out = cfg("t", "1", "k", "m", "/tmp", "db.db", ""); - assert!(out.contains("[skills]")); - assert!(out.contains(r#"directory = "skills""#)); + assert!( + !out.contains(r#"directory = "skills""#), + "Generated config must not hardcode a CWD-relative skills directory" + ); } } diff --git a/src/skills/embed.rs b/src/skills/embed.rs new file mode 100644 index 0000000..973499d --- /dev/null +++ b/src/skills/embed.rs @@ -0,0 +1,181 @@ +use anyhow::{Context, Result}; +use include_dir::{include_dir, Dir}; +use std::path::Path; + +static BUNDLED_SKILLS: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/skills"); +static BUNDLED_AGENTS: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/agents"); + +/// Outcome of an overwrite operation. +#[derive(Debug, Default, PartialEq, Eq)] +pub struct OverwriteReport { + /// Number of files written (new or overwritten). + pub written: usize, + /// Number of existing files that were backed up (contents differed from embedded). + pub backed_up: usize, +} + +/// Seed skills from embedded data into `instance_dir`. +/// Only writes if `instance_dir` is empty or missing. +pub async fn seed_skills(instance: &Path) -> Result { + seed_from_embedded(&BUNDLED_SKILLS, instance).await +} + +/// Seed agents from embedded data into `instance_dir`. +pub async fn seed_agents(instance: &Path) -> Result { + seed_from_embedded(&BUNDLED_AGENTS, instance).await +} + +/// Overwrite skills from embedded data into `instance_dir`, replacing all files. +/// Existing files whose content differs from the embedded version are backed up with a `.bak` suffix. +pub async fn overwrite_skills(instance: &Path) -> Result { + tokio::fs::create_dir_all(instance) + .await + .with_context(|| format!("Failed to create {}", instance.display()))?; + let mut report = OverwriteReport::default(); + write_dir_tree_with_backup(&BUNDLED_SKILLS, instance, &mut report).await?; + Ok(report) +} + +/// Overwrite agents from embedded data into `instance_dir`, replacing all files. +/// Existing files whose content differs from the embedded version are backed up with a `.bak` suffix. +pub async fn overwrite_agents(instance: &Path) -> Result { + tokio::fs::create_dir_all(instance) + .await + .with_context(|| format!("Failed to create {}", instance.display()))?; + let mut report = OverwriteReport::default(); + write_dir_tree_with_backup(&BUNDLED_AGENTS, instance, &mut report).await?; + Ok(report) +} + +async fn seed_from_embedded(embedded: &Dir<'static>, instance: &Path) -> Result { + if instance.is_dir() { + let mut entries = tokio::fs::read_dir(instance).await?; + if entries.next_entry().await?.is_some() { + tracing::info!( + "{} is non-empty; skipping embedded seed", + instance.display() + ); + return Ok(0); + } + } else { + tokio::fs::create_dir_all(instance).await?; + } + + let mut count = 0; + write_dir_tree(embedded, instance, &mut count).await?; + tracing::info!( + "Seeded {} file(s) from embedded data into {}", + count, + instance.display() + ); + Ok(count) +} + +/// Write embedded `Dir` tree under `base`, counting written files in `count`. +/// Uses a Vec of futures to avoid sync recursion. +async fn write_dir_tree(dir: &Dir<'_>, base: &Path, count: &mut usize) -> Result<()> { + let mut futures = Vec::new(); + collect_writes(dir, base, &mut futures); + for f in futures { + let (path, contents) = f?; + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent) + .await + .with_context(|| format!("Failed to create {}", parent.display()))?; + } + tokio::fs::write(&path, &contents) + .await + .with_context(|| format!("Failed to write {}", path.display()))?; + *count += 1; + } + Ok(()) +} + +/// Like `write_dir_tree` but backs up existing files that differ before overwriting. +/// The existing file is renamed to `.bak` if its content differs from the embedded version. +async fn write_dir_tree_with_backup( + dir: &Dir<'_>, + base: &Path, + report: &mut OverwriteReport, +) -> Result<()> { + let mut futures = Vec::new(); + collect_writes(dir, base, &mut futures); + for f in futures { + let (path, contents) = f?; + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent) + .await + .with_context(|| format!("Failed to create {}", parent.display()))?; + } + if path.exists() { + let existing = tokio::fs::read(&path).await?; + if existing != contents { + let backup = path.with_extension("bak"); + tokio::fs::rename(&path, &backup).await.with_context(|| { + format!( + "Failed to backup {} to {}", + path.display(), + backup.display() + ) + })?; + report.backed_up += 1; + } + } + tokio::fs::write(&path, &contents) + .await + .with_context(|| format!("Failed to write {}", path.display()))?; + report.written += 1; + } + Ok(()) +} + +/// Collect (path, contents) pairs from an embedded directory tree. +fn collect_writes( + dir: &Dir<'_>, + base: &Path, + out: &mut Vec)>>, +) { + for file in dir.files() { + let path = base.join(file.path()); + let contents = file.contents().to_vec(); + out.push(Ok((path, contents))); + } + for sub in dir.dirs() { + collect_writes(sub, base, out); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_seed_skills_writes_files() { + let tmp = tempfile::tempdir().unwrap(); + let instance = tmp.path().join("skills"); + let n = seed_skills(&instance).await.unwrap(); + assert!(n > 0, "should seed at least one skill file"); + assert!(instance.join("code-interpreter").join("SKILL.md").exists()); + } + + #[tokio::test] + async fn test_seed_skills_skips_nonempty() { + let tmp = tempfile::tempdir().unwrap(); + let instance = tmp.path().join("skills"); + tokio::fs::create_dir_all(&instance).await.unwrap(); + tokio::fs::write(instance.join("custom.md"), b"custom") + .await + .unwrap(); + + let n = seed_skills(&instance).await.unwrap(); + assert_eq!(n, 0, "should skip when non-empty"); + } + + #[tokio::test] + async fn test_seed_agents_writes_files() { + let tmp = tempfile::tempdir().unwrap(); + let instance = tmp.path().join("agents"); + let n = seed_agents(&instance).await.unwrap(); + assert!(n > 0, "should seed at least one agent file"); + } +} diff --git a/src/skills/loader.rs b/src/skills/loader.rs index a93f34c..b9fc674 100644 --- a/src/skills/loader.rs +++ b/src/skills/loader.rs @@ -2,7 +2,7 @@ use anyhow::{Context, Result}; use std::path::{Path, PathBuf}; use tracing::{info, warn}; -use super::{Skill, SkillRegistry, SkillSource}; +use super::{Skill, SkillRegistry}; /// Load all markdown skill files from a directory. /// @@ -19,11 +19,7 @@ use super::{Skill, SkillRegistry, SkillSource}; /// --- /// # Instructions here... /// ``` -pub async fn load_skills_from_dir( - dir: &Path, - source: SkillSource, - base_dir: PathBuf, -) -> Result { +pub async fn load_skills_from_dir(dir: &Path, base_dir: PathBuf) -> Result { let mut registry = SkillRegistry::new(); if !dir.exists() { @@ -56,7 +52,7 @@ pub async fn load_skills_from_dir( }; match load_skill_file(&skill_path).await { - Ok(skill) => registry.register(skill, source, base_dir.clone()), + Ok(skill) => registry.register(skill, base_dir.clone()), Err(e) => warn!("Failed to load skill from {}: {}", skill_path.display(), e), } } @@ -316,10 +312,9 @@ mod tests { ) .await .unwrap(); - let skills = - load_skills_from_dir(dir.path(), SkillSource::Instance, dir.path().to_path_buf()) - .await - .unwrap(); + let skills = load_skills_from_dir(dir.path(), dir.path().to_path_buf()) + .await + .unwrap(); let s = skills.get("research-pack").unwrap(); assert_eq!(s.supervisor_workflow.as_deref(), Some("research")); assert_eq!(s.supervisor_required_caps, vec!["research".to_string()]); diff --git a/src/skills/mod.rs b/src/skills/mod.rs index 383340e..c38ed38 100644 --- a/src/skills/mod.rs +++ b/src/skills/mod.rs @@ -1,17 +1,10 @@ +pub mod embed; pub mod loader; pub mod seed; pub mod update; use std::collections::HashMap; use std::path::{Path, PathBuf}; -use tracing::info; - -/// Whether a skill was loaded from the instance (custom/writable) or bundled (read-only) directory. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SkillSource { - Instance, - Bundled, -} /// A loaded skill from a markdown file #[derive(Debug, Clone)] @@ -42,14 +35,12 @@ pub struct Skill { /// Registry of all loaded skills. /// -/// Maintains two layers β€” instance (custom/writable) and bundled (read-only templates) β€” -/// with instance names shadowing bundled names on collision. A `skill_base_dirs` map -/// tracks the base directory for each skill so tools like `read_skill_file` can resolve -/// the correct filesystem path. +/// Skills are loaded from the instance directory (under home or configured path). +/// A `skill_base_dirs` map tracks the base directory for each skill so tools like +/// `read_skill_file` can resolve the correct filesystem path. #[derive(Debug, Clone)] pub struct SkillRegistry { - pub instance_skills: HashMap, - pub bundled_skills: HashMap, + pub skills: HashMap, /// Maps skill name β†’ absolute base directory for read_skill_file path resolution. pub skill_base_dirs: HashMap, } @@ -63,35 +54,22 @@ impl Default for SkillRegistry { impl SkillRegistry { pub fn new() -> Self { Self { - instance_skills: HashMap::new(), - bundled_skills: HashMap::new(), + skills: HashMap::new(), skill_base_dirs: HashMap::new(), } } - /// Register a skill with its source and base directory. - pub fn register(&mut self, skill: Skill, source: SkillSource, base_dir: PathBuf) { + /// Register a skill with its base directory. + pub fn register(&mut self, skill: Skill, base_dir: PathBuf) { let name = skill.name.clone(); - match source { - SkillSource::Instance => { - self.instance_skills.insert(name.clone(), skill); - self.skill_base_dirs.insert(name.clone(), base_dir); - } - SkillSource::Bundled => { - self.bundled_skills.insert(name.clone(), skill); - // Bundled path does NOT overwrite an existing instance path - self.skill_base_dirs.entry(name.clone()).or_insert(base_dir); - } - } - info!("Registered skill: {} β€” {:?}", name, source); + self.skills.insert(name.clone(), skill); + self.skill_base_dirs.entry(name).or_insert(base_dir); } - /// Get a skill by name. Instance shadows bundled. + /// Get a skill by name. #[allow(dead_code)] pub fn get(&self, name: &str) -> Option<&Skill> { - self.instance_skills - .get(name) - .or_else(|| self.bundled_skills.get(name)) + self.skills.get(name) } /// Returns the base directory for a skill, used by read_skill_file for path resolution. @@ -99,15 +77,9 @@ impl SkillRegistry { self.skill_base_dirs.get(name).map(|p| p.as_path()) } - /// List all unique skills (instance names shadow bundled). + /// List all skills. pub fn list(&self) -> Vec<&Skill> { - let mut all: Vec<&Skill> = self.instance_skills.values().collect(); - for skill in self.bundled_skills.values() { - if !self.instance_skills.contains_key(&skill.name) { - all.push(skill); - } - } - all + self.skills.values().collect() } /// Build context string for the system prompt (skills directory). @@ -123,19 +95,13 @@ impl SkillRegistry { let mut subagent_section = String::new(); for skill in &unique_skills { - // A skill is treated as a subagent if it has a model explicitly set - // OR if it declares a tools whitelist (meaning it needs sandboxed execution). - // In both cases the LLM invokes it via invoke_agent(); the model used is - // the explicitly set one, or the global default from config when absent. let is_subagent = skill.model.is_some() || !skill.tools.is_empty(); if is_subagent { - // Subagent skill β€” metadata only subagent_section.push_str(&format!( "- **{}**: {}\n Invoke via: `invoke_agent(agent=\"{}\", prompt=\"\")`\n", skill.name, skill.description, skill.name )); } else { - // Instruction skill β€” metadata only + read_skill_file hint (no full body) instruction_lines.push(format!( "- **{}** (instruction): {}. Load with: read_skill_file(skill_name=\"{}\", relative_path=\"SKILL.md\") when relevant.", skill.name, skill.description, skill.name @@ -192,26 +158,19 @@ impl SkillRegistry { } pub fn len(&self) -> usize { - let mut names = std::collections::HashSet::new(); - for name in self.instance_skills.keys() { - names.insert(name); - } - for name in self.bundled_skills.keys() { - names.insert(name); - } - names.len() + self.skills.len() } #[allow(dead_code)] pub fn is_empty(&self) -> bool { - self.instance_skills.is_empty() && self.bundled_skills.is_empty() + self.skills.is_empty() } } #[cfg(test)] mod tests { use super::*; - use std::path::PathBuf; + use std::path::Path; fn make_skill(name: &str, description: &str, content: &str, model: Option<&str>) -> Skill { Skill { @@ -229,84 +188,46 @@ mod tests { } #[test] - fn test_instance_shadows_bundled() { + fn test_register_and_get() { let mut registry = SkillRegistry::new(); registry.register( - make_skill("duplicate", "Instance version", "instance content", None), - SkillSource::Instance, - PathBuf::from("/instance"), - ); - registry.register( - make_skill("duplicate", "Bundled version", "bundled content", None), - SkillSource::Bundled, - PathBuf::from("/bundled"), - ); - - // get() should return instance version - let skill = registry.get("duplicate").unwrap(); - assert_eq!(skill.content, "instance content"); - assert_eq!(skill.description, "Instance version"); - - // base_dir should return instance path - assert_eq!( - registry.base_dir("duplicate").unwrap(), - Path::new("/instance") + make_skill("my-skill", "Does stuff", "content", None), + PathBuf::from("/tmp"), ); - - // list() should only include the instance version (not duplicate) - let names: Vec<&str> = registry.list().iter().map(|s| s.name.as_str()).collect(); - assert_eq!(names, vec!["duplicate"]); + let skill = registry.get("my-skill").unwrap(); + assert_eq!(skill.description, "Does stuff"); + assert_eq!(registry.base_dir("my-skill").unwrap(), Path::new("/tmp")); assert_eq!(registry.len(), 1); } #[test] - fn test_unique_skills_from_both_layers() { + fn test_register_updates_skills_and_base_dir() { let mut registry = SkillRegistry::new(); registry.register( - make_skill("alpha", "Instance only", "", None), - SkillSource::Instance, - PathBuf::from("/instance"), + make_skill("alpha", "Alpha skill", "", None), + PathBuf::from("/first"), ); + // Re-registering with a different path keeps the first (or_insert behaviour) registry.register( - make_skill("beta", "Bundled only", "", None), - SkillSource::Bundled, - PathBuf::from("/bundled"), + make_skill("alpha", "Alpha skill", "updated", None), + PathBuf::from("/second"), ); - - let names: Vec<&str> = registry.list().iter().map(|s| s.name.as_str()).collect(); - assert!(names.contains(&"alpha")); - assert!(names.contains(&"beta")); - assert_eq!(registry.len(), 2); - } - - #[test] - fn test_base_dir_bundled_only() { - let mut registry = SkillRegistry::new(); - registry.register( - make_skill("bundled-only", "Bundled", "", None), - SkillSource::Bundled, - PathBuf::from("/bundled/path"), - ); - - assert_eq!( - registry.base_dir("bundled-only").unwrap(), - Path::new("/bundled/path") - ); - assert!(registry.get("bundled-only").is_some()); + let skill = registry.get("alpha").unwrap(); + assert_eq!(skill.content, "updated"); + assert_eq!(registry.base_dir("alpha").unwrap(), Path::new("/first")); + assert_eq!(registry.len(), 1); } #[test] fn test_build_context_instruction_skill_metadata_only() { - // Instruction skills (no model): metadata only in system prompt; agent loads full content via read_skill_file. let mut registry = SkillRegistry::new(); registry.register( make_skill( "my-skill", "Does things", "# Instructions\nDo this and that.", - None, // no model = instruction skill + None, ), - SkillSource::Instance, PathBuf::from("/tmp/test"), ); let ctx = registry.build_context(); @@ -314,14 +235,12 @@ mod tests { assert!(ctx.contains("Does things")); assert!(ctx.contains("read_skill_file")); assert!(ctx.contains("SKILL.md")); - // Full body must NOT be in context assert!(!ctx.contains("# Instructions")); assert!(!ctx.contains("Do this and that.")); } #[test] fn test_build_context_subagent_skill_injects_metadata_only() { - // Skills with a model field get only name + description + invoke hint let mut registry = SkillRegistry::new(); registry.register( make_skill( @@ -330,15 +249,12 @@ mod tests { "# Super Secret Instructions\nLong style guide...", Some("anthropic/claude-sonnet-4-6"), ), - SkillSource::Instance, PathBuf::from("/tmp/test"), ); let ctx = registry.build_context(); - // Metadata present assert!(ctx.contains("thread-writer")); assert!(ctx.contains("Use when writing Thread posts.")); assert!(ctx.contains("invoke_agent")); - // Body NOT present assert!(!ctx.contains("Super Secret Instructions")); assert!(!ctx.contains("Long style guide")); } @@ -351,7 +267,6 @@ mod tests { #[test] fn test_build_context_mixed_skills() { - // Instruction skill: metadata + read_skill_file hint only (no body). Subagent: metadata only (no body). let mut registry = SkillRegistry::new(); registry.register( make_skill( @@ -360,7 +275,6 @@ mod tests { "Follow these instructions.", None, ), - SkillSource::Instance, PathBuf::from("/tmp/test"), ); registry.register( @@ -370,17 +284,13 @@ mod tests { "Secret subagent body.", Some("some/model"), ), - SkillSource::Instance, PathBuf::from("/tmp/test"), ); let ctx = registry.build_context(); assert!(ctx.contains("You have the following skills available")); assert!(ctx.contains("Available Subagent Skills")); - // Instruction skill: body NOT in context assert!(!ctx.contains("Follow these instructions.")); - // Subagent skill: body NOT in context assert!(!ctx.contains("Secret subagent body.")); - // Both hints present assert!(ctx.contains("read_skill_file")); assert!(ctx.contains("invoke_agent")); } diff --git a/src/supervisor/classifier.rs b/src/supervisor/classifier.rs index 5c1a803..a2f869a 100644 --- a/src/supervisor/classifier.rs +++ b/src/supervisor/classifier.rs @@ -194,7 +194,6 @@ mod tests { supervisor_workflow: Some("research".into()), supervisor_required_caps: vec!["research".into()], }, - crate::skills::SkillSource::Instance, std::path::PathBuf::from("/tmp/test"), ); let c = SkillAwareClassifier::new(HeuristicClassifier, registry); diff --git a/tests/supervisor_skill_packs.rs b/tests/supervisor_skill_packs.rs index 55f8f0a..28bf638 100644 --- a/tests/supervisor_skill_packs.rs +++ b/tests/supervisor_skill_packs.rs @@ -2,7 +2,6 @@ async fn ships_five_supervisor_skill_packs() { let skills = rustfox::skills::loader::load_skills_from_dir( std::path::Path::new("skills"), - rustfox::skills::SkillSource::Bundled, std::path::PathBuf::from("skills"), ) .await From bec2d63a3de0a61f14f328fea49fbb6edc0c9e3d Mon Sep 17 00:00:00 2001 From: "chinkan.ai" Date: Sat, 13 Jun 2026 01:44:02 +0800 Subject: [PATCH 11/15] chore: bump version to 1.0.1 --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 4755cbb..5d2afdc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustfox" -version = "0.1.0" +version = "1.0.1" edition = "2021" [dependencies] From fa024243e35313f0774284c229bec0f2d8a33117 Mon Sep 17 00:00:00 2001 From: "chinkan.ai" Date: Sun, 14 Jun 2026 23:14:11 +0800 Subject: [PATCH 12/15] refactor: unify Available Agents section, extract shared preamble helper - Extract format_listed_section() shared preamble helper used by both build_context() (skills) and the new Available Agents section builder. - Rename build_subagent_context -> build_subagent_lines for accuracy. - Extract format_available_agents_section() pure function with 5 unit tests. - Remove redundant early return in build_agents_context(). - Update invoke_agent tool description to reference unified section. - Fix stale invoke_subagent references in news-fetcher/problem-solver skills. --- CLAUDE.md | 2 +- Cargo.lock | 2 +- config.example.toml | 2 +- setup/index.html | 4 +- skills/news-fetcher/SKILL.md | 2 +- skills/problem-solver/SKILL.md | 4 +- src/agent.rs | 119 +++++++++++++++++++++++++++++--- src/setup/wizard.rs | 4 +- src/skills/mod.rs | 121 +++++++++++++++++++++------------ 9 files changed, 197 insertions(+), 63 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 155cee2..d1743e3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -191,7 +191,7 @@ tags: [tag1, tag2] # optional: for organization 2. The skill is auto-loaded at startup β€” no code changes needed 3. Configure the skills directory in `config.toml`: `[skills] directory = "skills"` -All skills are represented in the system prompt by **metadata only** (name + description). **Instruction skills** (no `model` in frontmatter) have their full content loaded by the agent via `read_skill_file(skill_name="...", relative_path="SKILL.md")` when relevant. **Subagent skills** (`model` set) are invoked via `invoke_subagent(skill="name", prompt="...")`. The orchestration skill teaches the agent when to call which subagent and when to override the model (e.g. `model="anthropic/claude-sonnet-4-6"` for thread-writer-hk). +All skills are represented in the system prompt by **metadata only** (name + description). **Instruction skills** (no `model` in frontmatter) have their full content loaded by the agent via `read_skill_file(skill_name="...", relative_path="SKILL.md")` when relevant. **Subagent skills** (`model` set) are invoked via `invoke_agent(agent="name", prompt="...")`. The orchestration skill teaches the agent when to call which subagent and when to override the model (e.g. `model="anthropic/claude-sonnet-4-6"` for thread-writer-hk). **Subagent tool whitelist:** For subagent skills, the frontmatter `tools:` list must use the **exact** tool names as seen by the agent. MCP tools are named `mcp_{server_name}_{tool_name}` (e.g. `mcp_google-workspace_query_gmail_emails`). These names are logged at startup when MCP servers connect (`MCP server 'X' provides N tools`). A mismatch (e.g. declaring `search_gmail_messages` when the server exposes `query_gmail_emails`) causes the subagent to have no access to that tool. diff --git a/Cargo.lock b/Cargo.lock index e583d95..7f73465 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2398,7 +2398,7 @@ dependencies = [ [[package]] name = "rustfox" -version = "0.1.0" +version = "1.0.1" dependencies = [ "anyhow", "async-trait", diff --git a/config.example.toml b/config.example.toml index 5061781..dc90b6f 100644 --- a/config.example.toml +++ b/config.example.toml @@ -11,7 +11,7 @@ allowed_user_ids = [123456789] # Get your API key from https://openrouter.ai/keys api_key = "YOUR_OPENROUTER_API_KEY" # Model to use (see https://openrouter.ai/models) -model = "moonshotai/kimi-k2.5" +model = "moonshotai/kimi-k2.6" # API base URL (usually no need to change) base_url = "https://openrouter.ai/api/v1" # Alternative using local ollama diff --git a/setup/index.html b/setup/index.html index 1af8b58..828018b 100644 --- a/setup/index.html +++ b/setup/index.html @@ -715,7 +715,7 @@

Notion β€” OAuth 2.0 Setup

telegram_token: '', allowed_user_ids: '', openrouter_key: '', - model: 'moonshotai/kimi-k2.5', + model: 'moonshotai/kimi-k2.6', max_tokens: '4096', system_prompt: DEFAULT_PROMPT, supports_vision: false, @@ -1469,7 +1469,7 @@

πŸ€– Bot & LLM Setup

- +
diff --git a/skills/news-fetcher/SKILL.md b/skills/news-fetcher/SKILL.md index d970cde..a0e8309 100644 --- a/skills/news-fetcher/SKILL.md +++ b/skills/news-fetcher/SKILL.md @@ -1,6 +1,6 @@ --- name: news-fetcher -description: Fetches AI news from Gmail Google Alerts; invoke via invoke_subagent only. Returns date-prioritized list of title and url. +description: Fetches AI news from Gmail Google Alerts; invoke via invoke_agent only. Returns date-prioritized list of title and url. tools: [read_skill_file, mcp_google-workspace_query_gmail_emails] tags: [news, gmail, subagent] --- diff --git a/skills/problem-solver/SKILL.md b/skills/problem-solver/SKILL.md index 26312fa..981a064 100644 --- a/skills/problem-solver/SKILL.md +++ b/skills/problem-solver/SKILL.md @@ -7,7 +7,7 @@ tools: - plan_update - plan_view - read_skill_file - - invoke_subagent + - invoke_agent - read_file - write_file - execute_command @@ -40,7 +40,7 @@ After all steps are done, call `plan_view` to review, then return a concise fina ## Delegation Rules -- Code/scripting/computation β†’ `invoke_subagent(skill="code-interpreter", ...)` +- Code/scripting/computation β†’ `invoke_agent(agent="code-interpreter", ...)` - Memory lookup β†’ `recall` / `search_memory` - File I/O β†’ `read_file` / `write_file` directly diff --git a/src/agent.rs b/src/agent.rs index a17bfd0..6067714 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -19,7 +19,7 @@ use crate::memory::MemoryStore; use crate::platform::IncomingMessage; use crate::scheduler::reminders::ScheduledTaskStore; use crate::scheduler::Scheduler; -use crate::skills::SkillRegistry; +use crate::skills::{format_listed_section, SkillRegistry}; use crate::tools; /// A request dispatched from a fire closure to the background job runner. @@ -59,6 +59,35 @@ struct AdHocTask { tools: Option>, } +/// Build the unified `# Available Agents` section from the two line sources +/// (subagent-style skills and agents directory). Returns `None` when both +/// inputs are empty so the caller can skip the section entirely. The returned +/// string includes the leading `\n\n` separator so it can be appended +/// directly to a prompt that already ends with content. +fn format_available_agents_section(subagent_lines: &str, agent_lines: &str) -> Option { + if subagent_lines.is_empty() && agent_lines.is_empty() { + return None; + } + + let mut section = String::from("\n\n# Available Agents\n\n"); + section.push_str(&format_listed_section( + "agent", + "Delegate these tasks to specialized agents using `invoke_agent`:", + )); + + if !subagent_lines.is_empty() { + section.push_str(subagent_lines); + } + if !subagent_lines.is_empty() && !agent_lines.is_empty() { + section.push('\n'); + } + if !agent_lines.is_empty() { + section.push_str(agent_lines); + } + section.push('\n'); + Some(section) +} + impl Agent { #[allow(clippy::too_many_arguments)] pub fn new( @@ -101,16 +130,18 @@ impl Agent { prompt.push_str("\n\n# Available Skills\n\n"); prompt.push_str(&skill_context); } - drop(skills); // release read lock before further work + // Build unified "Available Agents" section from both subagent skills and agents/ + let subagent_skills = skills.build_subagent_lines(); + drop(skills); let agents = self.agents.read().await; - let agent_context = agents.build_agents_context(); - if !agent_context.is_empty() { - prompt.push_str("\n\n# Available Agents\n\n"); - prompt.push_str(&agent_context); - } + let agent_lines = agents.build_agents_context(); drop(agents); + if let Some(section) = format_available_agents_section(&subagent_skills, &agent_lines) { + prompt.push_str(§ion); + } + // Work Verification Protocol prompt.push_str( "\n\n# Work Verification Protocol\n\n\ @@ -1263,7 +1294,7 @@ impl Agent { name: "invoke_agent".to_string(), description: concat!( "Delegate a task to a named agent running as an isolated agentic loop. ", - "Agents are listed under 'Available Agents' and 'Available Subagent Skills' in the system prompt. ", + "Agents are listed under 'Available Agents' in the system prompt. ", "The agent uses its own model and tool whitelist declared in its frontmatter. ", "Looks up in the agents/ directory first, then falls back to the skills/ directory." ).to_string(), @@ -2988,4 +3019,76 @@ mod tests { let parsed: serde_json::Value = serde_json::from_str(raw).unwrap_or_default(); assert!(!is_compacted_regurgitation(raw, &parsed)); } + + // ---- Available Agents section builder ---- + + #[test] + fn test_format_available_agents_section_both_empty_returns_none() { + let section = format_available_agents_section("", ""); + assert!( + section.is_none(), + "expected None when both inputs are empty" + ); + } + + #[test] + fn test_format_available_agents_section_only_subagent_nonempty() { + let section = format_available_agents_section("- sub line", "").expect("expected Some"); + assert!(section.contains("# Available Agents")); + assert!(section.contains("- sub line")); + assert!(section.contains("All available agents are listed below")); + assert!(section.contains("invoke_agent")); + // No separator needed when only one source is present + assert!(!section.contains("- sub line\n\n")); + } + + #[test] + fn test_format_available_agents_section_only_agents_nonempty() { + let section = format_available_agents_section("", "- agent line").expect("expected Some"); + assert!(section.contains("# Available Agents")); + assert!(section.contains("- agent line")); + assert!(section.contains("All available agents are listed below")); + } + + #[test] + fn test_format_available_agents_section_both_nonempty_merged() { + let section = + format_available_agents_section("- sub line", "- agent line").expect("expected Some"); + + // Header and preamble are present + assert!(section.contains("# Available Agents")); + assert!(section.contains("All available agents are listed below")); + assert!(section.contains("DO NOT try to list agent directories")); + + // Both line sources appear + assert!(section.contains("- sub line")); + assert!(section.contains("- agent line")); + + // Subagent block appears before agent block, separated by at least one newline + let sub_idx = section.find("- sub line").expect("subagent line present"); + let agent_idx = section.find("- agent line").expect("agent line present"); + assert!( + sub_idx < agent_idx, + "subagent lines must precede agent lines" + ); + let between = §ion[sub_idx..agent_idx]; + assert!( + between.contains('\n'), + "expected a newline separator between subagent and agent lines, got {between:?}" + ); + } + + #[test] + fn test_format_available_agents_section_uses_shared_preamble() { + // The preamble should come from `format_listed_section("agent", ...)`. + let section = format_available_agents_section("- sub", "- ag").expect("expected Some"); + let shared = format_listed_section( + "agent", + "Delegate these tasks to specialized agents using `invoke_agent`:", + ); + assert!( + section.contains(&shared), + "section should embed the shared preamble exactly" + ); + } } diff --git a/src/setup/wizard.rs b/src/setup/wizard.rs index 2379843..29b55b4 100644 --- a/src/setup/wizard.rs +++ b/src/setup/wizard.rs @@ -686,8 +686,8 @@ fn run_cli(config_dir: &Path) -> Result<()> { let user_ids = read_line("Allowed user IDs (comma-separated): ")?; let or_key = read_line("OpenRouter API key: ")?; let model = or_default( - read_line("Model [moonshotai/kimi-k2.5]: ")?, - "moonshotai/kimi-k2.5", + read_line("Model [moonshotai/kimi-k2.6]: ")?, + "moonshotai/kimi-k2.6", ); let db_path = or_default(read_line("Memory DB path [rustfox.db]: ")?, "rustfox.db"); let location = read_line("Your location (optional, e.g. Tokyo, Japan): ")?; diff --git a/src/skills/mod.rs b/src/skills/mod.rs index c38ed38..b5635ae 100644 --- a/src/skills/mod.rs +++ b/src/skills/mod.rs @@ -6,6 +6,21 @@ pub mod update; use std::collections::HashMap; use std::path::{Path, PathBuf}; +/// Build the shared preamble for a "listed below" section that warns the LLM +/// not to enumerate files itself. `noun_singular` is used in both the intro +/// ("All available {noun}s are listed below.") and the warning +/// ("DO NOT try to list {noun} directories or files"). `followup` is appended +/// after the preamble and before the listed items. +/// +/// This is shared between `build_context` (skills) and the agents section in +/// `agent.rs::build_system_prompt` to keep the wording consistent. +pub fn format_listed_section(noun_singular: &str, followup: &str) -> String { + format!( + "All available {0}s are listed below. DO NOT try to list {0} directories or files β€” everything you need is documented here.\n\n{1}\n\n", + noun_singular, followup + ) +} + /// A loaded skill from a markdown file #[derive(Debug, Clone)] #[allow(dead_code)] @@ -83,8 +98,7 @@ impl SkillRegistry { } /// Build context string for the system prompt (skills directory). - /// Instruction skills (no model field) are loaded via `read_skill_file` when relevant. - /// Subagent skills (model field set) are invoked via `invoke_agent`. + /// Instruction skills (no model/tools) are loaded via `read_skill_file` when relevant. pub fn build_context(&self) -> String { let unique_skills = self.list(); if unique_skills.is_empty() { @@ -92,16 +106,9 @@ impl SkillRegistry { } let mut instruction_lines = Vec::new(); - let mut subagent_section = String::new(); for skill in &unique_skills { - let is_subagent = skill.model.is_some() || !skill.tools.is_empty(); - if is_subagent { - subagent_section.push_str(&format!( - "- **{}**: {}\n Invoke via: `invoke_agent(agent=\"{}\", prompt=\"\")`\n", - skill.name, skill.description, skill.name - )); - } else { + if skill.model.is_none() && skill.tools.is_empty() { instruction_lines.push(format!( "- **{}** (instruction): {}. Load with: read_skill_file(skill_name=\"{}\", relative_path=\"SKILL.md\") when relevant.", skill.name, skill.description, skill.name @@ -109,39 +116,43 @@ impl SkillRegistry { } } - let mut context = String::new(); - - if !instruction_lines.is_empty() { - context.push_str( - "All available skills are listed below. DO NOT try to list skill directories or files β€” everything you need is documented here.\n\n", - ); - context.push_str( - "When an instruction skill is relevant, load its full instructions with read_skill_file(skill_name=\"\", relative_path=\"SKILL.md\"), then follow them. For subagent skills use invoke_agent.\n\nYou have the following skills available:\n\n", - ); - context.push_str(&instruction_lines.join("\n")); - context.push('\n'); + if instruction_lines.is_empty() { + return String::new(); } - if !subagent_section.is_empty() { - if !instruction_lines.is_empty() { - context.push('\n'); + let mut context = format_listed_section( + "skill", + "When an instruction skill is relevant, load its full instructions with read_skill_file(skill_name=\"\", relative_path=\"SKILL.md\"), then follow them.\n\n\ + You have the following skills available:", + ); + context.push_str(&instruction_lines.join("\n")); + context.push('\n'); + + context + } + + /// Build formatted lines for subagent-style skills (those with model or tools). + /// Returns formatted lines only (no preamble) β€” caller prepends the unified section header. + pub fn build_subagent_lines(&self) -> String { + let unique_skills = self.list(); + let mut lines = Vec::new(); + + for skill in &unique_skills { + if skill.model.is_some() || !skill.tools.is_empty() { + lines.push(format!( + "- **{}**: {}\n Invoke via: `invoke_agent(agent=\"{}\", prompt=\"\")`", + skill.name, skill.description, skill.name + )); } - context.push_str("## Available Subagent Skills\n\n"); - context.push_str("Delegate these tasks using `invoke_agent`:\n\n"); - context.push_str(&subagent_section); } - context + lines.join("\n") } - /// Build context string for the agents directory (agents with their own model/tools). - /// All agents are invoked via `invoke_agent`. + /// Build formatted lines for the agents directory (agents with their own model/tools). + /// Returns formatted lines only (no preamble) β€” caller prepends the unified section header. pub fn build_agents_context(&self) -> String { let unique_agents = self.list(); - if unique_agents.is_empty() { - return String::new(); - } - let mut lines = Vec::new(); for agent in &unique_agents { lines.push(format!( @@ -150,11 +161,7 @@ impl SkillRegistry { )); } - let mut context = - String::from("All available agents are listed below. DO NOT try to list agent directories or files β€” everything you need is documented here.\n\nDelegate these tasks to specialized agents using `invoke_agent`:\n\n"); - context.push_str(&lines.join("\n")); - context.push('\n'); - context + lines.join("\n") } pub fn len(&self) -> usize { @@ -240,7 +247,7 @@ mod tests { } #[test] - fn test_build_context_subagent_skill_injects_metadata_only() { + fn test_build_context_excludes_subagent_skills() { let mut registry = SkillRegistry::new(); registry.register( make_skill( @@ -252,6 +259,22 @@ mod tests { PathBuf::from("/tmp/test"), ); let ctx = registry.build_context(); + assert_eq!(ctx, ""); + } + + #[test] + fn test_build_subagent_context_returns_subagent_skills() { + let mut registry = SkillRegistry::new(); + registry.register( + make_skill( + "thread-writer", + "Use when writing Thread posts.", + "# Super Secret Instructions\nLong style guide...", + Some("anthropic/claude-sonnet-4-6"), + ), + PathBuf::from("/tmp/test"), + ); + let ctx = registry.build_subagent_lines(); assert!(ctx.contains("thread-writer")); assert!(ctx.contains("Use when writing Thread posts.")); assert!(ctx.contains("invoke_agent")); @@ -263,10 +286,12 @@ mod tests { fn test_build_context_empty_registry() { let registry = SkillRegistry::new(); assert_eq!(registry.build_context(), String::new()); + assert_eq!(registry.build_subagent_lines(), String::new()); + assert_eq!(registry.build_agents_context(), String::new()); } #[test] - fn test_build_context_mixed_skills() { + fn test_build_context_instruction_only() { let mut registry = SkillRegistry::new(); registry.register( make_skill( @@ -287,11 +312,17 @@ mod tests { PathBuf::from("/tmp/test"), ); let ctx = registry.build_context(); - assert!(ctx.contains("You have the following skills available")); - assert!(ctx.contains("Available Subagent Skills")); + assert!(ctx.contains("instruction-skill")); + assert!(ctx.contains("An instruction skill")); + assert!(ctx.contains("read_skill_file")); + assert!(!ctx.contains("subagent-skill")); + assert!(!ctx.contains("invoke_agent")); assert!(!ctx.contains("Follow these instructions.")); assert!(!ctx.contains("Secret subagent body.")); - assert!(ctx.contains("read_skill_file")); - assert!(ctx.contains("invoke_agent")); + + let subagent_ctx = registry.build_subagent_lines(); + assert!(subagent_ctx.contains("subagent-skill")); + assert!(subagent_ctx.contains("invoke_agent")); + assert!(!subagent_ctx.contains("instruction-skill")); } } From d72d69188ddd5f57e0854d1ab4a0a8a30adbc936 Mon Sep 17 00:00:00 2001 From: "chinkan.ai" Date: Mon, 15 Jun 2026 16:58:36 +0800 Subject: [PATCH 13/15] docs: redesign README as landing page, split reference and architecture into docs/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README.md: trimmed 449β†’135 lines β€” hero, 8 feature bullets, 4-step quick start, key config, tool overview, architecture link, contributing footer - docs/GUIDE.md (new): full reference β€” all config settings, MCP servers, 7 tool categories, wired commands, skills/agents, advanced features, roadmap, dependencies - docs/ARCHITECTURE.md (new): source tree, data flow diagram, component table, agentic loop explanation - Fixed config key: memory.user_model_path β†’ learning.user_model_path - Added memory tools (remember, recall, search_memory) to tool reference - Supervisor commands correctly marked as 'Planned' (not yet dispatched) - Spec doc reviewed and approved via spec-document-reviewer subagent --- README.md | 425 +++--------------- docs/ARCHITECTURE.md | 103 +++++ docs/GUIDE.md | 341 ++++++++++++++ .../specs/2026-06-15-readme-redesign.md | 217 +++++++++ 4 files changed, 712 insertions(+), 374 deletions(-) create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/GUIDE.md create mode 100644 docs/superpowers/specs/2026-06-15-readme-redesign.md diff --git a/README.md b/README.md index a22125e..5bc0444 100644 --- a/README.md +++ b/README.md @@ -7,40 +7,30 @@ [![CI](https://github.com/chinkan/RustFox/actions/workflows/ci.yml/badge.svg)](https://github.com/chinkan/RustFox/actions) [![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) [![Buy Me a Coffee](https://img.shields.io/badge/buy%20me%20a%20coffee-%E2%98%95-yellow)](https://buymeacoffee.com/chinkan.ai) +[![GitHub Sponsors](https://img.shields.io/badge/GitHub%20Sponsors-%E2%9D%A4-pink?logo=github)](https://github.com/sponsors/chinkan) -A self-hosted, agentic Telegram AI assistant written in Rust, powered by OpenRouter LLM (default: `moonshotai/kimi-k2.6`) with built-in sandboxed tools, scheduling, persistent memory, and MCP server integration. +A self-hosted, agentic Telegram AI assistant written in Rust, powered by OpenRouter LLM with sandboxed tools, MCP server integration, and persistent memory. + +**docs:** [README.md](README.md) Β· [GUIDE.md](docs/GUIDE.md) Β· [ARCHITECTURE.md](docs/ARCHITECTURE.md) + +--- ## Features -- **Telegram Bot** β€” Responds only to configured user IDs -- **OpenRouter LLM** β€” Configurable model (default: `moonshotai/kimi-k2.6`) -- **Built-in Tools** β€” File read/write, directory listing, command execution (sandboxed) -- **Scheduling Tools** β€” Schedule, list, and cancel recurring or one-shot tasks -- **Persistent Memory** β€” SQLite-backed conversation history and knowledge base -- **Vector Embedding Search** β€” Hybrid vector + FTS5 search using `qwen/qwen3-embedding-8b` -- **MCP Integration** β€” Connect any MCP-compatible server to extend capabilities -- **Bot Skills** β€” Folder-based natural-language skill instructions auto-loaded at startup; orchestration and subagent skills (e.g. **daily-news-to-threads**) let the main agent delegate to specialized subagents and override models per task -- **Ad-Hoc Subagents** β€” `spawn_agents` tool lets the LLM spawn subagents with inline system prompts; run multiple subagents concurrently via `tokio::join_all` -- **Agents Layer** β€” Isolated agentic mini-loops in `agents/` with their own model, tool whitelist, and `AGENT.md` instructions; invoked via `invoke_agent`, with `read_agent_file`/`write_agent_file` for file I/O and `reload_agents` for hot-reloading -- **Zero-Trust Verifier** β€” Predefined verifier agent at `agents/verifier/AGENT.md` with read-only sandbox access; checks work output before the main agent finalizes; uses structured PASS/NEEDS_IMPROVEMENT/FAIL evaluation -- **Plan Tools** β€” `plan_create`, `plan_update`, `plan_view` built-in tools let the agent create and manage structured execution plans stored in the sandbox; power the `problem-solver` subagent skill -- **Bundled Subagent Skills** β€” `code-interpreter` (executes and iterates code snippets), `problem-solver` (orchestrates multi-step reasoning), and `verifier` (zero-trust output validation) ship out of the box -- **File & Image Support** β€” Photos and documents (PDF, DOCX, images) are processed via vision API or OCR (`ocrs` pure Rust OCR engine), then injected as multi-modal content or text into the conversation -- **Long-Context RAG** β€” Large document content is chunked, embedded, and retrieved via vector search per user query -- **Streaming Responses** β€” LLM tokens streamed progressively; Telegram message is live-edited as the response arrives -- **Chat History RAG** β€” Semantically relevant past messages are auto-injected into each turn's system prompt using vector search -- **RAG Query Rewriting** β€” Ambiguous follow-up questions are rewritten before vector search for more accurate retrieval -- **Nightly Summarization** β€” LLM-based cron job summarizes long conversations overnight to keep memory efficient -- **Long-Term Memory** β€” Conversations can be soft-archived (searchable but excluded from active context); startup and shutdown notifications -- **Verbose Tool UI** β€” `/verbose` command toggles a live Telegram status message showing tool calls as they run -- **Agentic Loop** β€” Automatic multi-step tool calling until task completion (max iterations configurable, default 25) -- **Per-user Conversations** β€” Independent conversation history per user -- **Persistent Home Directory** β€” All state under `~/.rustfox` (config, DB, skills, agents, workspace); override via `RUSTFOX_HOME` env or `[general].home` config -- **Autopilot Supervisor** β€” Generic autonomous task runner with classification, planning, multi-backend execution, verification, and approval gates; `/supervise` to submit tasks -- **LangSmith Tracing** β€” Optional observability via LangSmith for LLM calls, tool runs, and chain traces -- **Post-task Learning** β€” Auto-extracts reusable skill patterns from completed agentic loops; persists honcho-style user model -- **Skill/Agent Update Engine** β€” Content-hash diffing with lock files; `/update-skills` re-syncs bundled skills/agents with backup of local edits -- **Instance + Bundled Layering** β€” Skills and agents load from two directories (instance shadows bundled); bundled templates ship with the project +| | | +|---|---| +| πŸ€– **AI Agent** | OpenRouter LLM (default: `qwen/qwen3-235b-a22b`), agentic loop with tool calling, configurable max iterations | +| πŸ”§ **Built-in Tools** | File read/write, command execution, file sending, task scheduling β€” all sandboxed | +| 🧩 **MCP Servers** | Connect any MCP-compatible server (Git, Brave Search, GitHub, Filesystem, Threads…) | +| 🧠 **Persistent Memory** | SQLite-backed conversation history, vector embedding search (hybrid + FTS5), RAG | +| 🧬 **Skills & Agents** | Folder-based skill instructions auto-loaded at startup; subagent skills with own model and tool whitelist | +| 🀝 **Agent Layer** | Isolated agentic mini-loops in `agents/` with own model/tools; `invoke_agent`, `spawn_agents`, zero-trust verifier | +| πŸ”„ **Task Scheduling** | Cron and one-shot task scheduler with SQLite persistence | +| πŸ“¦ **Self-Hosting** | Single binary, 2-min setup wizard, background service (systemd/launchd/Windows Service) | + +β†’ Full feature reference: [docs/GUIDE.md](docs/GUIDE.md#advanced-features) + +--- ## Quick Start @@ -48,402 +38,89 @@ A self-hosted, agentic Telegram AI assistant written in Rust, powered by OpenRou **Option A β€” Download a release (recommended)** -Download the latest archive from the [Releases page](https://github.com/chinkan/RustFox/releases) for your platform: - -| Platform | Download | -|----------|----------| -| Linux x86_64 | `rustfox--x86_64-unknown-linux-gnu.tar.gz` | -| Linux ARM64 | `rustfox--aarch64-unknown-linux-gnu.tar.gz` | -| macOS (Apple Silicon) | `rustfox--aarch64-apple-darwin.tar.gz` | -| Windows x86_64 | `rustfox--x86_64-pc-windows-msvc.zip` | - -Extract and run directly: +Download from the [Releases page](https://github.com/chinkan/RustFox/releases): ```bash tar xzf rustfox-*.tar.gz -./rustfox --setup # configure your bot ``` **Option B β€” Build from source** ```bash cargo install --path . --locked -# or -cargo build --release ``` ### 2. Configure -Run the setup wizard β€” it guides you through all required fields and writes `config.toml`: - ```bash -# Browser-based wizard (opens http://localhost:8719) -rustfox --setup +# Browser wizard +./rustfox --setup -# Terminal wizard (no browser required) -rustfox --setup --cli +# Or terminal wizard +./rustfox --setup --cli ``` -The wizard will ask for your: -- Telegram bot token (from [@BotFather](https://t.me/BotFather)) -- Allowed Telegram user IDs (from [@userinfobot](https://t.me/userinfobot)) -- OpenRouter API key (from [openrouter.ai/keys](https://openrouter.ai/keys)) -- Model, storage paths, and optional MCP tools - -> **Manual setup:** Copy `config.example.toml` to `config.toml` and edit it directly if you prefer. +The wizard guides you through: Telegram bot token, allowed user IDs, OpenRouter API key, model, and optional MCP tools. ### 3. Run ```bash rustfox -# or with a custom config path: +# or with a custom config: rustfox --config /path/to/config.toml ``` -### 4. (Optional) Run as a background service - -After configuring, set RustFox to run automatically in the background: - -```bash -# Linux (systemd user service β€” no sudo needed) -rustfox --service install - -# macOS (launchd agent) -rustfox --service install - -# Windows (Windows Service) -rustfox --service install -``` - -Then check status with: +### 4. (Optional) Background service ```bash +rustfox --service install # Linux (systemd), macOS (launchd), or Windows rustfox --service status ``` -## Configuration - -> **Persistent home:** RustFox keeps all state under `~/.rustfox` by default -> (config, database, skills, agents, and a durable `workspace/` sandbox). -> Override with the `RUSTFOX_HOME` environment variable or `[general].home`. -> See [docs/persistent-home-directory.md](docs/persistent-home-directory.md). - -See [`config.example.toml`](config.example.toml) for all options. +--- -### Key Settings +## Configuration | Setting | Description | |---------|-------------| -| `telegram.bot_token` | Telegram Bot API token | -| `telegram.allowed_user_ids` | List of user IDs allowed to use the bot | -| `openrouter.api_key` | OpenRouter API key | -| `openrouter.model` | LLM model ID (default: `moonshotai/kimi-k2.6`) | -| `sandbox.allowed_directory` | Directory for file/command operations | -| `memory.database_path` | SQLite DB path (default: `/rustfox.db`) | -| `memory.user_model_path` | User model file path (default: `/user_model.md`) | -| `memory.query_rewriter_enabled` | Whether RAG query rewriting is on by default | -| `embedding` (optional) | Vector search API config (default model: `qwen/qwen3-embedding-8b`) | -| `skills.directory` | Instance (writable) skill files (default: `/skills/`) | -| `skills.bundled_directory` | Bundled (read-only) skill templates (default: `./skills/`) | -| `agents.directory` | Instance (writable) agent files (default: `/agents/`) | -| `agents.bundled_directory` | Bundled (read-only) agent templates (default: `./agents/`) | -| `mcp_servers` | List of MCP servers to connect | -| `general.home` | Absolute path overriding `~/.rustfox` home root | -| `general.location` | Your location string (under `[general]`), injected into system prompt | -| `agent.max_iterations` | Max agentic loop iterations (default: 25) | -| `langsmith.api_key` | LangSmith API key for LLM observability | -| `learning.skill_extraction_enabled` | Post-task skill extraction on/off | -| `supervisor.default_autonomy_mode` | Supervisor workflow mode: `fast`, `standard`, `rigorous` | - -### MCP Server Configuration - -RustFox supports the [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) β€” an open standard for connecting AI assistants to external tools and data sources. Any MCP-compatible server can be plugged in via `config.toml`. - -#### Prerequisites - -MCP servers are usually distributed as Python packages (run via `uvx`) or npm packages (run via `npx`). - -| Runtime | Install | -|---------|---------| -| `uvx` (Python) | [Install uv](https://docs.astral.sh/uv/getting-started/installation/) β€” `curl -LsSf https://astral.sh/uv/install.sh \| sh` | -| `npx` (Node.js) | [Install Node.js](https://nodejs.org/) β€” comes bundled with npm/npx | - -#### Config Syntax - -Add one `[[mcp_servers]]` block per server in `config.toml`: - -```toml -# Stdio-based server -[[mcp_servers]] -name = "server-name" # used to namespace tools: mcp__ -command = "uvx" # or "npx", or any executable on PATH -args = ["package-name", "optional-arg"] - -# Optional: pass environment variables to the server process -# [mcp_servers.env] -# API_KEY = "your-key-here" - -# HTTP/Streamable HTTP server (omit command for this transport) -# [[mcp_servers]] -# name = "api-server" -# url = "https://api.example.com/mcp" -# auth_token = "bearer-token-here" - -# OAuth 2.0 refresh flow (auto-exchanges refresh_token for new auth_token) -# token_endpoint = "https://api.example.com/oauth/token" -# refresh_token = "your-refresh-token" -# token_expires_at = -``` - -#### Popular MCP Servers - -| Server | Package | Runtime | Notes | -|--------|---------|---------|-------| -| [Git](https://github.com/modelcontextprotocol/servers/tree/main/src/git) | `mcp-server-git` | `uvx` | Read/search git repos | -| [Filesystem](https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem) | `@modelcontextprotocol/server-filesystem` | `npx` | File access outside the sandbox | -| [Brave Search](https://github.com/brave/brave-search-mcp-server) | `@brave/brave-search-mcp-server` | `npx` | Web search (needs [Brave API key](https://brave.com/search/api/)) | -| [GitHub](https://github.com/modelcontextprotocol/servers/tree/main/src/github) | `@modelcontextprotocol/server-github` | `npx` | Issues, PRs, repos | -| [Fetch](https://github.com/modelcontextprotocol/servers/tree/main/src/fetch) | `mcp-server-fetch` | `uvx` | HTTP fetch / web scraping | -| [SQLite](https://github.com/modelcontextprotocol/servers/tree/main/src/sqlite) | `mcp-server-sqlite` | `uvx` | Query local SQLite databases | -| [Puppeteer](https://github.com/modelcontextprotocol/servers/tree/main/src/puppeteer) | `@modelcontextprotocol/server-puppeteer` | `npx` | Browser automation | -| [Threads](https://github.com/baguskto/threads-mcp) | `threads-mcp-server` | `npx` | Publish/manage Meta Threads posts (needs access token) | - -> Find more servers at the [MCP server registry](https://github.com/modelcontextprotocol/servers) and [mcp.so](https://mcp.so/). - -#### Examples - -```toml -# Git β€” inspect repositories -[[mcp_servers]] -name = "git" -command = "uvx" -args = ["mcp-server-git"] - -# Filesystem β€” expose an extra directory to the bot -[[mcp_servers]] -name = "filesystem" -command = "npx" -args = ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/dir"] - -# Brave Search β€” web search (requires API key) -[[mcp_servers]] -name = "brave-search" -command = "npx" -args = ["-y", "@brave/brave-search-mcp-server"] -[mcp_servers.env] -BRAVE_API_KEY = "your-brave-api-key" - -# Meta Threads β€” publish posts and read replies (requires long-lived access token) -# Token setup: Facebook Developers β†’ Create App β†’ add Threads API product β†’ -# request threads_basic / threads_content_publish / threads_manage_replies / -# threads_read_replies β†’ generate token under Threads API β†’ Access Tokens -[[mcp_servers]] -name = "threads" -command = "npx" -args = ["-y", "threads-mcp-server"] -[mcp_servers.env] -THREADS_ACCESS_TOKEN = "your-long-lived-access-token" -``` +| `telegram.bot_token` | Telegram Bot API token (from [@BotFather](https://t.me/BotFather)) | +| `telegram.allowed_user_ids` | Comma-separated user IDs allowed to use the bot | +| `openrouter.api_key` | OpenRouter API key ([openrouter.ai/keys](https://openrouter.ai/keys)) | +| `openrouter.model` | LLM model ID (default: `qwen/qwen3-235b-a22b`) | +| `sandbox.allowed_directory` | Directory for sandboxed file/command operations | +| `mcp_servers` | List of MCP servers to connect (see [GUIDE.md](docs/GUIDE.md#mcp-server-integration)) | -#### Tool Naming +β†’ Full configuration reference: [docs/GUIDE.md](docs/GUIDE.md#configuration) -Tools from MCP servers are automatically namespaced as `mcp__` (e.g. `mcp_git_git_log`). Run `/tools` in the bot to see all registered tools after startup. +--- -## Built-in Tools - -### Core Tools +## Quick Tool Overview | Tool | Description | |------|-------------| -| `read_file` | Read file contents within sandbox | -| `write_file` | Write/create files within sandbox | -| `list_files` | List directory contents within sandbox | +| `read_file` / `write_file` | Read and write files within the sandbox | | `send_file` | Send a file from the sandbox to the current chat | -| `execute_command` | Run shell commands within sandbox directory | - -### Scheduling Tools - -| Tool | Description | -|------|-------------| -| `schedule_task` | Schedule a recurring (cron) or one-shot task with a message | -| `list_scheduled_tasks` | List all active scheduled tasks | -| `cancel_scheduled_task` | Cancel a scheduled task by ID | - -### Skill Tools - -| Tool | Description | -|------|-------------| -| `read_skill_file` | Read a file from a skill's directory (loads skill instructions) | -| `write_skill_file` | Write new or update existing skill files | -| `reload_skills` | Hot-reload the skill registry without restarting the bot | -| `patch_skill` | Patch an existing skill's SKILL.md by appending or replacing content | - -### Agent Tools +| `execute_command` | Run shell commands within the sandbox | +| `schedule_task` | Schedule recurring (cron) or one-shot tasks | +| `invoke_agent` | Run a predefined agent from the `agents/` directory | -| Tool | Description | -|------|-------------| -| `spawn_agents` | Spawn one or more ad-hoc subagents with inline system prompts (supports parallel batch via `tasks` array) | -| `invoke_agent` | Run a predefined agent from the `agents/` directory in an isolated agentic loop | -| `read_agent_file` | Read a file from within an agent's directory | -| `write_agent_file` | Write a file into an agent's directory | -| `reload_agents` | Hot-reload the agent registry without restarting the bot | -| `reload_skills_and_agents` | Reload both registries in one call | - -### Plan Tools - -| Tool | Description | -|------|-------------| -| `plan_create` | Create a new structured execution plan (stored as `.rustfox_plan.json` in the sandbox) | -| `plan_update` | Update a step's status or notes in the current plan | -| `plan_view` | View the current plan and its step statuses | +β†’ Full tool reference: [docs/GUIDE.md](docs/GUIDE.md#built-in-tools) -### Utility Tools - -| Tool | Description | -|------|-------------| -| `try_new_tech` | Run a sandboxed experiment with a new technology or approach (Rust/JS) | -| `self_update_to_branch` | Update the bot to a specific git branch and rebuild | -| `patch_skill` | Patch an existing skill's SKILL.md by appending or replacing content | - -## Bot Commands - -| Command | Description | -|---------|-------------| -| `/start` | Show welcome message with command list | -| `/clear` | Clear conversation history | -| `/tools` | List all available tools | -| `/skills` | List all loaded skills | -| `/verbose` | Toggle live tool call progress display | -| `/query-rewrite` | Toggle RAG query rewriting for memory search | -| `/update-skills` | Re-sync bundled skills/agents (backs up local edits) | -| `/supervise ` | Submit a new supervisor task | -| `/tasks` | List active / recent supervisor tasks | -| `/resume ` | Resume a paused supervisor task | -| `/cancel ` | Cancel a supervisor task | -| `/approve ` | Approve a task that requires approval | -| `/clarify ` | Reply to a clarification prompt | +--- ## Architecture -``` -src/ -β”œβ”€β”€ main.rs # Entry point, config loading, initialization -β”œβ”€β”€ config.rs # TOML configuration parsing (Telegram, OpenRouter, sandbox, memory, skills, agents, langsmith, learning, supervisor) -β”œβ”€β”€ home.rs # Persistent home directory resolution (~/.rustfox) -β”œβ”€β”€ llm.rs # OpenRouter API client with tool calling -β”œβ”€β”€ agent.rs # Agentic loop, tool dispatch, scheduling tools; skills/agents/ layer -β”œβ”€β”€ agent_prompt.rs # Prompt preparation, compaction, recovery nudges, message assembly -β”œβ”€β”€ tools.rs # Built-in tools (file I/O, command execution, plan tools) -β”œβ”€β”€ file_processor/ # File/attachment processing (image OCR/vision, PDF, DOCX) -β”œβ”€β”€ mcp.rs # MCP client manager for external tool servers -β”œβ”€β”€ memory/ # SQLite persistence, vector embeddings, RAG, query rewriter, summarizer -β”œβ”€β”€ scheduler/ # Cron/one-shot task scheduler with DB persistence -β”œβ”€β”€ skills/ # Skill loader, registry, seeding, update engine (loader.rs, mod.rs, seed.rs, update.rs) -β”œβ”€β”€ learning.rs # Post-task skill extraction, user model persistence -β”œβ”€β”€ langsmith.rs # Optional LangSmith observability client -β”œβ”€β”€ supervisor/ # Autopilot v2 generic autonomous task runner -β”‚ β”œβ”€β”€ mod.rs # Supervisor facade (submit, execute_now, pause, resume, state, artifacts) -β”‚ β”œβ”€β”€ task.rs # Task, TaskType, RiskLevel, ExecutionMode, TaskStatus enums -β”‚ β”œβ”€β”€ job.rs # Job, JobType, JobStatus, JobOutput, Evidence -β”‚ β”œβ”€β”€ state.rs # Transition-allowed state machine -β”‚ β”œβ”€β”€ store.rs # CRUD over sup_tasks / sup_jobs / sup_transitions -β”‚ β”œβ”€β”€ intake.rs # IntakeRouter β€” raw text β†’ Task normalization -β”‚ β”œβ”€β”€ classifier.rs # Heuristic / LLM-backed / Skill-aware classifiers -β”‚ β”œβ”€β”€ policy.rs # PolicyEngine β€” auto-execute, clarify, require approval -β”‚ β”œβ”€β”€ planner.rs # Task β†’ Plan with jobs and parallel groups -β”‚ β”œβ”€β”€ workflow.rs # Fast / Standard / Rigorous workflow templates -β”‚ β”œβ”€β”€ orchestrator.rs # Plan executor with fallback + parallel + subjobs -β”‚ β”œβ”€β”€ verification.rs # Evidence-gated verification engine -β”‚ β”œβ”€β”€ artifact.rs # ArtifactManager with secret redaction -β”‚ β”œβ”€β”€ workspace.rs # Per-task git worktree management -β”‚ β”œβ”€β”€ reporter.rs # Human-readable job summary -β”‚ β”œβ”€β”€ redact.rs # Secret scrubber for api_key / password / token / bearer -β”‚ └── backend/ # Backend implementations (reasoning, shell, MCP, claude-code, codex, script) -β”œβ”€β”€ platform/ # Telegram bot handler (telegram.rs + tool_notifier.rs) -β”œβ”€β”€ setup/ # Setup wizard module (mod.rs, wizard.rs, service.rs) -└── utils/ # String utilities, markdown-to-entities conversion - -skills/ # Bundled skills (15+): code-interpreter, problem-solver, coding-assistant, -β”‚ # soul, soul-keeper, memory-manager, creating-skills, creating-agents, -β”‚ # news-fetcher, codebase-gap-analysis, sup-* workflow skills -agents/ # Agent definition files (AGENT.md per agent) -└── verifier/ # Zero-trust verifier (read-only sandbox, structured evaluation) -setup/ # Setup wizard HTML -``` +RustFox runs an agentic loop: user message β†’ LLM (OpenRouter) β†’ tool calls β†’ execute β†’ loop until final response. Tools dispatch to built-in functions, MCP servers, or skill/agent directories. + +β†’ Full architecture with source tree and data flow: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) -## Roadmap - -### Done - -- [x] Telegram bot with user allowlist -- [x] OpenRouter LLM integration with tool calling (agentic loop) -- [x] Built-in sandboxed tools (file read/write, directory listing, command execution) -- [x] MCP server integration for extensible tooling -- [x] Per-user conversation history -- [x] Persistent memory with SQLite -- [x] Vector embedding search (`qwen/qwen3-embedding-8b`) -- [x] Scheduling tools (`schedule_task`, `list_scheduled_tasks`, `cancel_scheduled_task`) -- [x] Bot skills (folder-based, auto-loaded at startup) -- [x] Setup wizard (web UI + CLI) for guided `config.toml` creation -- [x] Agent skill writer (`write_skill_file` tool β€” creates/updates skill files from within the agent) -- [x] Agent skill reload (`reload_skills` tool β€” hot-reloads skill registry without restart) -- [x] Meta Threads MCP integration (setup wizard entry, config example, token setup guide) -- [x] Agents layer (`invoke_agent`, `read_agent_file`, `write_agent_file`, `reload_agents` β€” isolated agentic mini-loops in `agents/` with own model and tool whitelist) -- [x] Plan tools (`plan_create`, `plan_update`, `plan_view` β€” structured execution plans in the sandbox) -- [x] Bundled subagent skills: `code-interpreter` and `problem-solver` -- [x] LLM streaming (SSE token-by-token, live Telegram message edits) -- [x] Chat history RAG (auto-inject relevant past context per turn) -- [x] RAG query rewriting (disambiguates follow-up questions before vector search) -- [x] Nightly conversation summarization (LLM-based cron job) -- [x] Verbose tool UI (`/verbose` command β€” live tool call progress in Telegram) -- [x] Google integration tools (Calendar, Email, Drive) -- [x] Persistent home directory (`~/.rustfox` with env/config override) -- [x] Autopilot v2 supervisor (classification, planning, multi-backend execution, verification) -- [x] LangSmith observability (LLM/tool/chain tracing) -- [x] Post-task skill extraction (auto-learns reusable patterns) -- [x] User model persistence (honcho-style `user_model.md`) -- [x] Skill/agent content hash engine + lock-file re-sync -- [x] Instance + bundled skills/agents layering -- [x] File & image upload support (vision API + OCR + document extraction) -- [x] Long-term memory (soft archive, startup/shutdown notifications) -- [x] Ad-hoc parallel subagents (`spawn_agents` tool) -- [x] Zero-trust verifier (read-only verification agent) -- [x] Context compaction improvements (hard cap, image preservation, retry optimization) -- [x] Multi-platform service setup (`--setup` web/CLI wizard, `--service install/remove/status`) -- [x] Build scripts & CI release workflow (`.tar.gz`, `.zip`, `.deb` per release) -- [x] `send_file` tool β€” agent can send files from sandbox to Telegram user - -### Planned - -- [ ] Event trigger framework (e.g., on email receive) -- [ ] WhatsApp support -- [ ] Webhook mode (in addition to polling) -- [ ] And more… +--- ## Contributing -This project is open source under the [MIT License](LICENSE). Contributions are very welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for how to open issues and submit pull requests. +MIT License. See [CONTRIBUTING.md](CONTRIBUTING.md) for how to open issues and submit PRs. ## Support -If you find RustFox useful, consider supporting the project: - [![Buy Me a Coffee](https://img.shields.io/badge/Buy%20Me%20a%20Coffee-%E2%98%95-yellow?style=for-the-badge&logo=buy-me-a-coffee)](https://buymeacoffee.com/chinkan.ai) - [![GitHub Sponsors](https://img.shields.io/badge/GitHub%20Sponsors-%E2%9D%A4-pink?style=for-the-badge&logo=github)](https://github.com/sponsors/chinkan) - -## Dependencies - -- [teloxide](https://github.com/teloxide/teloxide) β€” Telegram bot framework -- [rmcp](https://github.com/modelcontextprotocol/rust-sdk) β€” Official MCP Rust SDK -- [reqwest](https://github.com/seanmonstar/reqwest) β€” HTTP client for OpenRouter -- [tokio](https://tokio.rs/) β€” Async runtime -- [tokio-cron-scheduler](https://github.com/mvniekerk/tokio-cron-scheduler) β€” Task scheduling -- [pulldown-cmark](https://github.com/pulldown-cmark/pulldown-cmark) β€” Markdown parser (entity-based Telegram formatting) -- [rusqlite](https://github.com/rusqlite/rusqlite) β€” SQLite with FTS5 and `sqlite-vec` -- [axum](https://github.com/tokio-rs/axum) β€” Web server for the setup wizard -- [dirs](https://github.com/soc/dirs-rs) β€” OS home directory resolution -- [sha2](https://github.com/RustCrypto/hashes) β€” SHA-256 hashing for skill/agent update engine -- [regex](https://github.com/rust-lang/regex) β€” Secret redaction in supervisor artifacts - -> **Thanks:** Markdown-to-entities conversion approach inspired by [telegramify-markdown](https://github.com/sudoskys/telegramify-markdown) by sudoskys. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..f29618f --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,103 @@ +# Architecture + +## Source Tree + +``` +src/ +β”œβ”€β”€ main.rs # Entry point, config loading, MCP setup, bot launch +β”œβ”€β”€ config.rs # TOML config parsing (all sections) +β”œβ”€β”€ home.rs # Persistent home directory resolution (~/.rustfox) +β”œβ”€β”€ agent.rs # Agentic loop, tool dispatch, skills/agents layer +β”œβ”€β”€ agent_prompt.rs # Prompt preparation, compaction, recovery nudges +β”œβ”€β”€ tools.rs # Built-in tool definitions + sandbox path validation +β”œβ”€β”€ llm.rs # OpenRouter API client with tool calling +β”œβ”€β”€ mcp.rs # MCP client manager for external tool servers +β”œβ”€β”€ file_processor/ # File/attachment processing (OCR, vision, PDF, DOCX) +β”œβ”€β”€ memory/ # SQLite persistence, vector embeddings, RAG, summarizer +β”œβ”€β”€ scheduler/ # Cron/one-shot task scheduler with DB persistence +β”œβ”€β”€ skills/ # Skill loader, registry, embed/seeding, update engine +β”œβ”€β”€ learning.rs # Post-task skill extraction, user model persistence +β”œβ”€β”€ langsmith.rs # Optional LangSmith observability client +β”œβ”€β”€ supervisor/ # Autopilot v2 β€” autonomous task runner +β”‚ β”œβ”€β”€ mod.rs # Facade (submit, execute_now, pause, resume, state) +β”‚ β”œβ”€β”€ task.rs # Task, TaskType, RiskLevel enums +β”‚ β”œβ”€β”€ job.rs # Job, JobType, JobStatus enums +β”‚ β”œβ”€β”€ state.rs # Transition-allowed state machine +β”‚ β”œβ”€β”€ store.rs # CRUD over sup_tasks / sup_jobs / sup_transitions +β”‚ β”œβ”€β”€ intake.rs # Raw text β†’ Task normalization +β”‚ β”œβ”€β”€ classifier.rs # Heuristic / LLM-backed / Skill-aware classifiers +β”‚ β”œβ”€β”€ policy.rs # PolicyEngine β€” auto-execute, clarify, approve gates +β”‚ β”œβ”€β”€ planner.rs # Task β†’ Plan with parallel job groups +β”‚ β”œβ”€β”€ workflow.rs # Fast / Standard / Rigorous workflow templates +β”‚ β”œβ”€β”€ orchestrator.rs # Plan executor with fallback + parallel + subjobs +β”‚ β”œβ”€β”€ verification.rs # Evidence-gated verification engine +β”‚ β”œβ”€β”€ artifact.rs # ArtifactManager with secret redaction +β”‚ β”œβ”€β”€ workspace.rs # Per-task git worktree management +β”‚ β”œβ”€β”€ reporter.rs # Human-readable job summary +β”‚ β”œβ”€β”€ redact.rs # Secret scrubber for api_key / password / token +β”‚ └── backend/ # Backends (reasoning, shell, MCP, claude-code, codex, script) +β”œβ”€β”€ platform/ # Telegram bot handler + tool notifier +β”œβ”€β”€ setup/ # Setup wizard (web + CLI) + service management +└── utils/ # String utilities, markdown-to-entities conversion + +skills/ # Bundled skills (15+): code-interpreter, problem-solver, +β”‚ # soul, news-fetcher, sup-* workflow packs, etc. +agents/ # Agent definitions (AGENT.md per agent) +└── verifier/ # Zero-trust verifier (read-only sandbox) +setup/ # Setup wizard HTML +``` + +## Data Flow + +``` +User ──Telegram──▢ bot.rs ──▢ Agent.process_message() + β”‚ + β–Ό + LlmClient.chat() + (OpenRouter API) + β”‚ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ β”‚ + Tool call Text reply + β”‚ β”‚ + β–Ό β–Ό + execute_tool() Telegram send + β”‚ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β–Ό β–Ό β–Ό + Built-in MCP tool Skills/Agents + (tools.rs) (mcp.rs) (agent.rs) + β”‚ β”‚ β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό + Result appended to history + β”‚ + β–Ό + Loop back to LLM + (up to max_iterations) +``` + +## Key Components + +| Component | File | Role | +|-----------|------|------| +| **Agent** | `agent.rs` | Orchestrates the agentic loop: calls LLM, dispatches tools, manages conversation state | +| **LlmClient** | `llm.rs` | Stateless HTTP client for OpenRouter `/chat/completions` with tool-calling support | +| **McpManager** | `mcp.rs` | Manages stdio-based MCP child processes; tools namespaced `mcp_{server}_{tool}` | +| **SkillRegistry** | `skills/mod.rs` | Loads and manages skills/agents from the home directory with compile-time embedded fallback | +| **Memory** | `memory/` | SQLite-backed persistence, vector embeddings, hybrid search (FTS5 + vector), query rewriting, summarization | +| **Scheduler** | `scheduler/` | Cron and one-shot task scheduler with DB persistence; supports add/remove/list at runtime | +| **Supervisor** | `supervisor/` | Generic autonomous task runner: intake β†’ classify β†’ plan β†’ execute β†’ verify β†’ report | +| **FileProcessor** | `file_processor/` | Handles image OCR, vision API calls, PDF/DOCX text extraction | + +## Agentic Loop + +The core loop in `Agent::process_message()` (`agent.rs`): + +1. **Prepare** β€” Inject system prompt with skill/agent context, conversation history, and relevant RAG results +2. **Call LLM** β€” Send to OpenRouter with available tool definitions +3. **Check response type**: + - **Tool call(s)** β†’ Execute each tool via `execute_tool()`, append results to conversation, check max iterations, goto step 2 + - **Text response** β†’ Send to user via Telegram, update conversation state, run post-task learning +4. **Error recovery** β€” If LLM returns an error or malformed response, append recovery nudge and retry (up to max iterations) diff --git a/docs/GUIDE.md b/docs/GUIDE.md new file mode 100644 index 0000000..a87c6b2 --- /dev/null +++ b/docs/GUIDE.md @@ -0,0 +1,341 @@ +# RustFox Guide + +- [Configuration](#configuration) +- [MCP Server Integration](#mcp-server-integration) +- [Built-in Tools](#built-in-tools) +- [Bot Commands](#bot-commands) +- [Skills & Agents System](#skills--agents-system) +- [Advanced Features](#advanced-features) +- [Roadmap](#roadmap) +- [Dependencies](#dependencies) + +--- + +## Configuration + +RustFox reads `config.toml` on startup. Copy [`config.example.toml`](../config.example.toml) to get started, or use `rustfox --setup` for the guided wizard. + +### All Settings + +| Section | Setting | Description | Default | +|---------|---------|-------------|---------| +| `[telegram]` | `bot_token` | Telegram Bot API token | β€” | +| | `allowed_user_ids` | Comma-separated whitelist of user IDs | β€” | +| `[openrouter]` | `api_key` | OpenRouter API key | β€” | +| | `model` | LLM model ID | `qwen/qwen3-235b-a22b` | +| | `base_url` | API base URL override | `https://openrouter.ai/api/v1` | +| `[sandbox]` | `allowed_directory` | Directory for sandboxed file/command ops | `/workspace` | +| `[memory]` | `database_path` | SQLite database path | `/rustfox.db` | +| | `user_model_path` | User model file path | `/user_model.md` | +| | `query_rewriter_enabled` | Enable RAG query rewriting | `false` | +| `[embedding]` | `model` | Embedding model for vector search | `qwen/qwen3-embedding-8b` | +| | `dimensions` | Vector dimensions | β€” | +| | `base_url` | Embedding API base URL | β€” | +| | `api_key` | Embedding API key | β€” | +| `[ocr]` | `enabled` | Enable OCR for image processing | `true` | +| `[skills]` | `directory` | Instance skill files directory | `/skills/` | +| `[agents]` | `directory` | Instance agent files directory | `/agents/` | +| `[subagents]` | `default_tools` | Default tool list for subagents | β€” | +| `[[mcp_servers]]` | *(see below)* | MCP server definitions | β€” | +| `[general]` | `home` | Absolute path overriding `~/.rustfox` | β€” | +| | `location` | Your location (injected into system prompt) | β€” | +| `[agent]` | `max_iterations` | Max agentic loop iterations | `25` | +| `[langsmith]` | `api_key` | LangSmith API key for LLM observability | β€” | +| `[learning]` | `skill_extraction_enabled` | Post-task skill extraction | `false` | +| `[supervisor]` | `default_autonomy_mode` | Workflow mode: `fast`, `standard`, `rigorous` | `standard` | + +> Persistent home: All paths resolve relative to `~/.rustfox` by default. +> Override with `RUSTFOX_HOME` env or `[general].home`. +> See [docs/persistent-home-directory.md](persistent-home-directory.md). + +--- + +## MCP Server Integration + +RustFox supports the [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) β€” an open standard for connecting AI assistants to external tools and data sources. + +### Prerequisites + +| Runtime | Install | +|---------|---------| +| `uvx` (Python) | [Install uv](https://docs.astral.sh/uv/getting-started/installation/) | +| `npx` (Node.js) | [Install Node.js](https://nodejs.org/) | + +### Config Syntax + +```toml +# Stdio transport +[[mcp_servers]] +name = "server-name" +command = "uvx" # or "npx", or any executable on PATH +args = ["package-name"] + +# Optional: pass environment variables +[mcp_servers.env] +API_KEY = "your-key-here" + +# HTTP transport (omit command) +# [[mcp_servers]] +# name = "api-server" +# url = "https://api.example.com/mcp" + +# OAuth 2.0 refresh flow +# token_endpoint = "https://api.example.com/oauth/token" +# refresh_token = "your-refresh-token" +# token_expires_at = +``` + +### Popular MCP Servers + +| Server | Package | Runtime | Notes | +|--------|---------|---------|-------| +| [Git](https://github.com/modelcontextprotocol/servers/tree/main/src/git) | `mcp-server-git` | `uvx` | Read/search git repos | +| [Filesystem](https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem) | `@modelcontextprotocol/server-filesystem` | `npx` | File access outside sandbox | +| [Brave Search](https://github.com/brave/brave-search-mcp-server) | `@brave/brave-search-mcp-server` | `npx` | Web search (needs [API key](https://brave.com/search/api/)) | +| [GitHub](https://github.com/modelcontextprotocol/servers/tree/main/src/github) | `@modelcontextprotocol/server-github` | `npx` | Issues, PRs, repos | +| [Fetch](https://github.com/modelcontextprotocol/servers/tree/main/src/fetch) | `mcp-server-fetch` | `uvx` | HTTP fetch / web scraping | +| [SQLite](https://github.com/modelcontextprotocol/servers/tree/main/src/sqlite) | `mcp-server-sqlite` | `uvx` | Query local SQLite databases | +| [Puppeteer](https://github.com/modelcontextprotocol/servers/tree/main/src/puppeteer) | `@modelcontextprotocol/server-puppeteer` | `npx` | Browser automation | +| [Threads](https://github.com/baguskto/threads-mcp) | `threads-mcp-server` | `npx` | Publish/manage Meta Threads posts | + +> Find more at [modelcontextprotocol/servers](https://github.com/modelcontextprotocol/servers) and [mcp.so](https://mcp.so/). + +### Examples + +```toml +# Git +[[mcp_servers]] +name = "git" +command = "uvx" +args = ["mcp-server-git"] + +# Brave Search (requires API key) +[[mcp_servers]] +name = "brave-search" +command = "npx" +args = ["-y", "@brave/brave-search-mcp-server"] +[mcp_servers.env] +BRAVE_API_KEY = "your-brave-api-key" +``` + +### Tool Naming + +MCP tools are namespaced as `mcp__` (e.g. `mcp_git_git_log`). Run `/tools` in the bot to see all registered tools. + +--- + +## Built-in Tools + +### Core Tools + +| Tool | Description | +|------|-------------| +| `read_file` | Read file contents within sandbox | +| `write_file` | Write/create files within sandbox | +| `list_files` | List directory contents within sandbox | +| `send_file` | Send a file from the sandbox to the current chat | +| `execute_command` | Run shell commands within sandbox directory | + +### Memory Tools + +| Tool | Description | +|------|-------------| +| `remember` | Store information in the user's long-term memory | +| `recall` | Query the user's long-term memory (RAG + keyword search) | +| `search_memory` | Search across all conversations with vector similarity | + +### Scheduling Tools + +| Tool | Description | +|------|-------------| +| `schedule_task` | Schedule a recurring (cron) or one-shot task | +| `list_scheduled_tasks` | List all active scheduled tasks | +| `cancel_scheduled_task` | Cancel a scheduled task by ID | + +### Skill Tools + +| Tool | Description | +|------|-------------| +| `read_skill_file` | Read a file from a skill's directory | +| `write_skill_file` | Write new or update existing skill files | +| `patch_skill` | Patch an existing skill's SKILL.md (append/replace content) | +| `reload_skills` | Hot-reload the skill registry without restarting | + +### Agent Tools + +| Tool | Description | +|------|-------------| +| `spawn_agents` | Spawn ad-hoc subagents with inline system prompts (supports parallel batch) | +| `invoke_agent` | Run a predefined agent from `agents/` in an isolated agentic loop | +| `read_agent_file` | Read a file from within an agent's directory | +| `write_agent_file` | Write a file into an agent's directory | +| `reload_agents` | Hot-reload the agent registry | +| `reload_skills_and_agents` | Reload both registries in one call | + +### Plan Tools + +| Tool | Description | +|------|-------------| +| `plan_create` | Create a structured execution plan (`.rustfox_plan.json` in sandbox) | +| `plan_update` | Update a step's status or notes | +| `plan_view` | View the current plan and step statuses | + +### Utility Tools + +| Tool | Description | +|------|-------------| +| `try_new_tech` | Run a sandboxed experiment with a new technology (Rust/JS) | +| `self_update_to_branch` | Update the bot to a specific git branch and rebuild | + +--- + +## Bot Commands + +| Command | Description | Status | +|---------|-------------|--------| +| `/start` | Show welcome message with command list | Active | +| `/clear` | Clear conversation history | Active | +| `/tools` | List all available tools | Active | +| `/skills` | List all loaded skills | Active | +| `/verbose` | Toggle live tool call progress display | Active | +| `/query-rewrite` | Toggle RAG query rewriting for memory search | Active | +| `/update-skills` | Re-sync bundled skills/agents (backs up local edits) | Active | +| `/supervise ` | Submit a new supervisor task | Planned | +| `/tasks` | List active / recent supervisor tasks | Planned | +| `/resume ` | Resume a paused supervisor task | Planned | +| `/cancel ` | Cancel a supervisor task | Planned | +| `/approve ` | Approve a supervisor task | Planned | +| `/clarify ` | Reply to a clarification prompt | Planned | + +--- + +## Skills & Agents System + +### Skills + +Skills are folder-based natural-language instructions loaded at startup and injected into the LLM's system prompt. Each skill has its own folder with a `SKILL.md` file containing YAML frontmatter and instruction body. + +- **Instruction skills** (no `model` in frontmatter): loaded by the agent via `read_skill_file` when relevant +- **Subagent skills** (`model` set): invoked via `invoke_agent` with their own model and tool whitelist + +``` +skills/ + code-interpreter/ + SKILL.md + problem-solver/ + SKILL.md + news-fetcher/ + SKILL.md + ... +``` + +### Agents + +The `agents/` directory contains isolated agentic mini-loops with their own model, tool whitelist, and `AGENT.md` instructions. Invoked via `invoke_agent`. + +``` +agents/ + verifier/ + AGENT.md # Zero-trust verifier (read-only sandbox) +``` + +### Update Engine + +`/update-skills` re-syncs bundled skills/agents from embedded data, backing up locally modified files (`.bak` suffix) before overwriting. + +--- + +## Advanced Features + +### File & Image Processing + +Photos and documents (PDF, DOCX, images) are processed via vision API or OCR (`ocrs` pure Rust OCR engine), then injected as multi-modal content or text into the conversation. + +### RAG & Vector Search + +- Hybrid vector + FTS5 search using `qwen/qwen3-embedding-8b` +- Chat history RAG: semantically relevant past messages are auto-injected each turn +- RAG query rewriting: ambiguous follow-ups are rewritten before vector search +- Long-context RAG: large documents are chunked, embedded, and retrieved per query + +### Nightly Summarization + +LLM-based cron job summarizes long conversations overnight to keep memory efficient. + +### Long-Term Memory + +- Conversations can be soft-archived (searchable but excluded from active context) +- Startup and shutdown notifications +- `remember` / `recall` / `search_memory` tools for persistent user knowledge + +### Streaming Responses + +LLM tokens are streamed progressively; Telegram message is live-edited as the response arrives. + +### Post-Task Learning + +Auto-extracts reusable skill patterns from completed agentic loops and persists a user model (`user_model.md`). + +### Autopilot Supervisor + +Generic autonomous task runner with classification, planning, multi-backend execution, verification, and approval gates. Submit tasks via `/supervise` (backend dispatch incoming). + +### LangSmith Tracing + +Optional observability via LangSmith for LLM calls, tool runs, and chain traces. Configure `[langsmith]` section in `config.toml`. + +--- + +## Roadmap + +### Done + +- [x] Telegram bot with user allowlist +- [x] OpenRouter LLM integration with tool calling (agentic loop) +- [x] Built-in sandboxed tools (file I/O, command execution, file sending, scheduling) +- [x] MCP server integration for extensible tooling +- [x] Per-user conversation history with persistent SQLite +- [x] Vector embedding search + FTS5 hybrid search +- [x] Bot skills (folder-based, auto-loaded) +- [x] Setup wizard (web UI + CLI) for guided config creation +- [x] Agents layer (`invoke_agent`, subagents, zero-trust verifier) +- [x] Plan tools (`plan_create`, `plan_update`, `plan_view`) +- [x] LLM streaming (SSE token-by-token, live Telegram edits) +- [x] Chat history RAG + RAG query rewriting +- [x] Nightly conversation summarization +- [x] Verbose tool UI (`/verbose`) +- [x] File & image upload support (vision API + OCR + document extraction) +- [x] Persistent home directory (`~/.rustfox` with env/config override) +- [x] Autopilot v2 supervisor (classification, planning, multi-backend execution) +- [x] LangSmith observability (LLM/tool/chain tracing) +- [x] Post-task skill extraction + user model persistence +- [x] Multi-platform service setup (`--setup` wizard, `--service` install) +- [x] Build scripts & CI release workflow (`.tar.gz`, `.zip`, `.deb`) +- [x] Ad-hoc parallel subagents (`spawn_agents`) + +### Planned + +- [ ] Event trigger framework (e.g., on email receive) +- [ ] WhatsApp support +- [ ] Webhook mode (in addition to polling) + +--- + +## Dependencies + +| Crate | Purpose | +|-------|---------| +| [teloxide](https://github.com/teloxide/teloxide) | Telegram bot framework | +| [rmcp](https://github.com/modelcontextprotocol/rust-sdk) | MCP Rust SDK | +| [reqwest](https://github.com/seanmonstar/reqwest) | HTTP client for OpenRouter | +| [tokio](https://tokio.rs/) | Async runtime | +| [tokio-cron-scheduler](https://github.com/mvniekerk/tokio-cron-scheduler) | Task scheduling | +| [pulldown-cmark](https://github.com/pulldown-cmark/pulldown-cmark) | Markdown parsing | +| [rusqlite](https://github.com/rusqlite/rusqlite) | SQLite with FTS5 + `sqlite-vec` | +| [axum](https://github.com/tokio-rs/axum) | Web server for setup wizard | +| [dirs](https://github.com/soc/dirs-rs) | OS home directory resolution | +| [sha2](https://github.com/RustCrypto/hashes) | SHA-256 hashing | +| [regex](https://github.com/rust-lang/regex) | Secret redaction | +| [serde](https://github.com/serde-rs/serde) | Serialization | + +> **Thanks:** Markdown-to-entities conversion inspired by [telegramify-markdown](https://github.com/sudoskys/telegramify-markdown). diff --git a/docs/superpowers/specs/2026-06-15-readme-redesign.md b/docs/superpowers/specs/2026-06-15-readme-redesign.md new file mode 100644 index 0000000..fdbdd5b --- /dev/null +++ b/docs/superpowers/specs/2026-06-15-readme-redesign.md @@ -0,0 +1,217 @@ +# README Redesign β€” Split into README.md + docs/ Reference Files + +## Summary + +The current `README.md` (449 lines) is a single-file wall of content that tries +to be everything at once β€” landing page, full config reference, MCP server +guide, architecture docs, roadmap, and dependency list. This spec proposes +splitting it into three focused files following the best-practice patterns +from top open-source projects (LobeHub, zoxide, Rust, Fiber, etc.). + +## Motivation + +- **First-impression fatigue:** All 35 features listed in one bullet block β€” + readers can't find why they care in the first 10 seconds. +- **SEO/AEO dilution:** The first 200 characters contain generic text + ("logo", "badge", "CI") instead of the keyword-dense value proposition. +- **Content density:** MCP server config (60 lines), architecture tree + (40 lines), roadmap (40 lines), and dependency list (15 lines) all compete + for attention with the quick-start path. +- **Maintenance tax:** Every new feature, tool, or config option adds to the + same ever-growing file. + +## Design + +### File Structure + +``` +README.md (~150 lines) β€” Landing page / first impression +docs/GUIDE.md (~300 lines) β€” Full reference: config, tools, commands, advanced +docs/ARCHITECTURE.md (~120 lines) β€” Source tree, data flow, component descriptions +``` + +### README.md β€” Landing Page + +``` +Section Content +──────────────────────────────────────────────────────────────────── +[Hero] Logo (centered), title, subtitle, badges + (CI, License, Buy Coffee, GitHub Sponsors) +[Value Proposition] "A self-hosted, agentic Telegram AI assistant + written in Rust, powered by OpenRouter LLM + with sandboxed tools, MCP integration, and + persistent memory." + (SEO: first 200 chars contain Telegram AI + assistant, Rust, agentic, OpenRouter, MCP, + sandboxed, LLM, self-hosted) + +[Features] 8 hero features selected to highlight + RustFox's unique value (moved from current + 35-bullet list). The remaining features + (streaming, file/image OCR, RAG query + rewriting, long-term memory, nightly + summarization, post-task learning, + supervisor, LangSmith tracing, instance + + bundled layering, etc.) go to GUIDE.md + Advanced Features section. + + πŸ€– AI Agent β€” OpenRouter, agentic loop, tools + πŸ”§ Built-in Tools β€” file I/O, exec, scheduling + 🧩 MCP Servers β€” plug any MCP server + 🧠 Persistent Memory β€” SQLite + vector RAG + 🧬 Skills & Agents β€” folder-based skills + 🀝 Agent Layer β€” isolated subagents with tool + whitelist + πŸ”„ Task Scheduling β€” cron/one-shot + πŸ“¦ Self-Hosting β€” single binary, wizard, service + +[Quick Start] 4 steps with copy-paste code blocks: + 1. Install (release download or cargo install) + 2. Configure (rustfox --setup) + 3. Run (rustfox) + 4. (Optional) Install as background service + (rustfox --service install) + +[Configuration] Key settings table (6 essentials): + bot_token, allowed_user_ids, api_key, model, + sandbox, mcp_servers + β†’ Full reference: docs/GUIDE.md + +[Quick Tool Overview] Brief one-liner table (6 core tools): + read_file, write_file, send_file, + execute_command, schedule_task, invoke_agent + β†’ Full tool reference: docs/GUIDE.md + +[Architecture] ~3 lines + link to docs/ARCHITECTURE.md + +[docs links footer] README.md β”‚ GUIDE.md β”‚ ARCHITECTURE.md + +[Contributing + License] Same as current + +[Support] Buy Coffee + GitHub Sponsors badges +``` + +### docs/GUIDE.md β€” Full Reference + +``` +Section Content +──────────────────────────────────────────────────────────────────── +[H1 + TOC] RustFox Guide with collapsible TOC + +[Configuration] All TOML settings in one table: + telegram, openrouter, sandbox, memory, + embedding, ocr, skills, agents, subagents, + mcp_servers, general, agent, langsmith, + learning, supervisor + Each row: Setting, Description, Default + +[MCP Server Integration] Full guide: + - Prerequisites (uvx, npx) + - Config syntax (stdio, HTTP, OAuth) + - Popular MCP servers table (Git, Brave + Search, GitHub, Fetch, Filesystem, + SQLite, Puppeteer, Threads) + - Complete config examples + - Tool naming convention + +[Built-in Tools] All category tables: + Core, Scheduling, Memory (remember, + recall, search_memory), Skills, Agents, + Plan, Utility + +[Bot Commands] All wired commands table (currently 6: + /start, /clear, /tools, /skills, /verbose, + /query-rewrite). Supervisor commands + (/supervise, /tasks, /resume, /cancel, + /approve, /clarify) are planned β€” mark as + "coming soon" or omit until telegram dispatch + is wired in M7.3 + +[Skills & Agents System] How skills load, subagent vs instruction, + agent layer, update engine, verifier + +[Advanced Features] File processing, RAG, summarization, + post-task learning, supervisor, LangSmith + +[Roadmap] Done + Planned (same as current) + +[Dependencies] Key crate table (same as current) +``` + +### docs/ARCHITECTURE.md + +``` +Section Content +──────────────────────────────────────────────────────────────────── +[H1 + TOC] Architecture + +[Source Tree] Full src/ tree with annotations + (moved from current README) + +[Data Flow] ASCII diagram showing message lifecycle: + User β†’ Telegram β†’ Agent β†’ LLM + ↓ tool call + execute_tool() β†’ built-in | MCP | skills + ↓ result + back to LLM β†’ final response β†’ Telegram + +[Key Components] Brief descriptions: + Agent, LlmClient, McpManager, + SkillRegistry, Memory, Scheduler, + Supervisor, FileProcessor + +[Agentic Loop] 4-step explanation of the loop +``` + +## SEO/AEO Strategy + +- **First 200 characters** contain: "Telegram AI assistant", "Rust", + "agentic", "OpenRouter", "MCP", "sandboxed", "LLM" +- **H1** contains: "RustFox β€” Telegram AI Assistant" (keyword-rich) +- **Feature section** uses natural language an AI answer engine would + surface: "a self-hosted agentic Telegram AI assistant written in Rust" +- **Quick Start** answers common queries: "how to install RustFox", + "how to configure Telegram bot", "how to run RustFox as a service" +- **All external links** use descriptive anchor text (not "click here") + +## Implementation Notes + +- **`learning.user_model_path`** β€” The config key lives under `[learning]`, not `[memory]`. + GUIDE.md must use the correct TOML path. +- **`patch_skill`** β€” Listed in both Skill Tools and Utility Tools in current + README. Keep only in Skill Tools (it operates on skill files). +- **Max iterations default** β€” Config default is 25, not 10. README must say 25. +- **Wired commands only** β€” Only `/start`, `/clear`, `/tools`, `/skills`, + `/verbose`, `/query-rewrite` are wired in the Telegram dispatcher + (`telegram.rs`). `/update-skills` is wired but only documented in README. + Supervisor commands are not yet dispatched β€” GUIDE.md must note this. + +## Content Removed from README.md + +- Full MCP server configuration guide β†’ moved to docs/GUIDE.md +- Exhaustive settings table (was 20+ rows) β†’ trimmed to 6 key settings; + full table β†’ docs/GUIDE.md +- Architecture source tree (was 40 lines) β†’ line-count link to docs/ARCHITECTURE.md +- Full tool tables (was 6 tables) β†’ table of 6 core tools; full tables β†’ docs/GUIDE.md +- Full bot commands table β†’ docs/GUIDE.md +- Roadmap done/planned β†’ docs/GUIDE.md +- Dependencies list β†’ docs/GUIDE.md +- "Thanks" footnote β†’ docs/GUIDE.md + +## Content Preserved in README.md + +- Logo + badges βœ“ +- One-liner value proposition βœ“ (rewritten for SEO) +- Feature list βœ“ (rewritten, concise, emoji-driven) +- Quick Start βœ“ (unchanged structure, tightened) +- Key configuration βœ“ (trimmed to 6 essentials) +- Tool overview βœ“ (trimmed to 6 core, linked to full ref) +- Architecture βœ“ (3-line summary + link) +- Contributing + License βœ“ +- Support badges βœ“ + +## Out of Scope + +- Rewriting or restructuring docs/ that already exist +- Changing `docs/` directory structure (new files only) +- Adding a documentation site or wiki From 40998ca46c3b9de93ffb6a945d6f90b9934872f8 Mon Sep 17 00:00:00 2001 From: "chinkan.ai" Date: Mon, 15 Jun 2026 17:02:04 +0800 Subject: [PATCH 14/15] docs: fix default model references from qwen/qwen3-235b-a22b to moonshotai/kimi-k2.6 The actual default model in config.rs is moonshotai/kimi-k2.6. qwen/qwen3-embedding-8b references are correct (embedding model). --- README.md | 4 ++-- docs/GUIDE.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 5bc0444..ad7fd06 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ A self-hosted, agentic Telegram AI assistant written in Rust, powered by OpenRou | | | |---|---| -| πŸ€– **AI Agent** | OpenRouter LLM (default: `qwen/qwen3-235b-a22b`), agentic loop with tool calling, configurable max iterations | +| πŸ€– **AI Agent** | OpenRouter LLM (default: `moonshotai/kimi-k2.6`), agentic loop with tool calling, configurable max iterations | | πŸ”§ **Built-in Tools** | File read/write, command execution, file sending, task scheduling β€” all sandboxed | | 🧩 **MCP Servers** | Connect any MCP-compatible server (Git, Brave Search, GitHub, Filesystem, Threads…) | | 🧠 **Persistent Memory** | SQLite-backed conversation history, vector embedding search (hybrid + FTS5), RAG | @@ -86,7 +86,7 @@ rustfox --service status | `telegram.bot_token` | Telegram Bot API token (from [@BotFather](https://t.me/BotFather)) | | `telegram.allowed_user_ids` | Comma-separated user IDs allowed to use the bot | | `openrouter.api_key` | OpenRouter API key ([openrouter.ai/keys](https://openrouter.ai/keys)) | -| `openrouter.model` | LLM model ID (default: `qwen/qwen3-235b-a22b`) | +| `openrouter.model` | LLM model ID (default: `moonshotai/kimi-k2.6`) | | `sandbox.allowed_directory` | Directory for sandboxed file/command operations | | `mcp_servers` | List of MCP servers to connect (see [GUIDE.md](docs/GUIDE.md#mcp-server-integration)) | diff --git a/docs/GUIDE.md b/docs/GUIDE.md index a87c6b2..8a3f538 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -22,7 +22,7 @@ RustFox reads `config.toml` on startup. Copy [`config.example.toml`](../config.e | `[telegram]` | `bot_token` | Telegram Bot API token | β€” | | | `allowed_user_ids` | Comma-separated whitelist of user IDs | β€” | | `[openrouter]` | `api_key` | OpenRouter API key | β€” | -| | `model` | LLM model ID | `qwen/qwen3-235b-a22b` | +| | `model` | LLM model ID | `moonshotai/kimi-k2.6` | | | `base_url` | API base URL override | `https://openrouter.ai/api/v1` | | `[sandbox]` | `allowed_directory` | Directory for sandboxed file/command ops | `/workspace` | | `[memory]` | `database_path` | SQLite database path | `/rustfox.db` | From 3033d6b229cd5b42221b54851c37dd1c3ef74039 Mon Sep 17 00:00:00 2001 From: "chinkan.ai" Date: Mon, 15 Jun 2026 17:14:40 +0800 Subject: [PATCH 15/15] docs: add AEO-optimized project description section to README Replaces the one-liner with a structured problem/solution description following Option D per user's choice. --- README.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ad7fd06..19a83d0 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,17 @@ [![Buy Me a Coffee](https://img.shields.io/badge/buy%20me%20a%20coffee-%E2%98%95-yellow)](https://buymeacoffee.com/chinkan.ai) [![GitHub Sponsors](https://img.shields.io/badge/GitHub%20Sponsors-%E2%9D%A4-pink?logo=github)](https://github.com/sponsors/chinkan) -A self-hosted, agentic Telegram AI assistant written in Rust, powered by OpenRouter LLM with sandboxed tools, MCP server integration, and persistent memory. +**What is RustFox?** + +An open-source, self-hosted Telegram AI assistant written in Rust. It solves a simple problem: most AI assistants are locked inside proprietary chat UIs with no access to your files, tools, or schedule. RustFox lives in Telegram β€” your everyday messaging app β€” and acts as a full agentic AI teammate. + +**Why RustFox?** + +Drop a file, ask a question, schedule a task β€” RustFox handles it. Powered by OpenRouter LLM (Kimi K2.6), it runs an agentic loop: receive your message, call sandboxed tools (file I/O, command execution, web search via MCP), and loop until done. It remembers context via SQLite + vector RAG, runs skills and sub-agents, and even verifies its own work. + +**Self-hosted, no cloud dependency.** Single binary. Setup wizard. Runs as systemd/launchd service. `cargo install` and you're running in 2 minutes. + +Star the repo ⭐, fork to contribute, or open an issue for feedback. **docs:** [README.md](README.md) Β· [GUIDE.md](docs/GUIDE.md) Β· [ARCHITECTURE.md](docs/ARCHITECTURE.md)