From e0bccecd5c13841b64d302553e0bf7435f01e064 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Tue, 3 Feb 2026 18:29:36 -0600 Subject: [PATCH] Remove RLM/Duo modes and restore footer scroll --- CHANGELOG.md | 17 + Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 32 +- config.example.toml | 12 - docs/ARCHITECTURE.md | 1 - docs/CONFIGURATION.md | 4 +- docs/MODES.md | 7 +- docs/RLM.md | 54 -- src/commands/config.rs | 5 - src/commands/core.rs | 8 - src/commands/mod.rs | 97 +-- src/commands/rlm.rs | 254 ------- src/commands/session.rs | 2 - src/core/engine.rs | 171 +---- src/core/events.rs | 2 +- src/core/ops.rs | 2 +- src/duo.rs | 802 ---------------------- src/features.rs | 16 - src/hooks.rs | 2 +- src/main.rs | 8 - src/palette.rs | 2 - src/prompts.rs | 18 - src/prompts/base.txt | 8 +- src/prompts/duo.txt | 18 - src/prompts/rlm.txt | 13 - src/rlm.rs | 1317 ------------------------------------- src/settings.rs | 19 +- src/tools/duo.rs | 468 ------------- src/tools/mod.rs | 5 - src/tools/registry.rs | 48 +- src/tools/rlm.rs | 1047 ----------------------------- src/tui/app.rs | 38 +- src/tui/history.rs | 66 +- src/tui/onboarding/mod.rs | 4 +- src/tui/ui.rs | 877 ++---------------------- src/tui/views/mod.rs | 14 +- src/tui/widgets/header.rs | 2 - src/tui/widgets/mod.rs | 12 +- 39 files changed, 126 insertions(+), 5350 deletions(-) delete mode 100644 docs/RLM.md delete mode 100644 src/commands/rlm.rs delete mode 100644 src/duo.rs delete mode 100644 src/prompts/duo.txt delete mode 100644 src/prompts/rlm.txt delete mode 100644 src/rlm.rs delete mode 100644 src/tools/duo.rs delete mode 100644 src/tools/rlm.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 41919565..b2754477 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.3.9] - 2026-02-04 + +### Removed +- RLM mode, tools, and documentation pending a faithful implementation of the MIT RLM design +- Duo mode tools and prompts pending a citable research spec + +### Fixed +- Footer context usage bar remains visible while status toasts are shown + +### Changed +- Updated prompts and docs to reflect the simplified mode/tool surface + +## [0.3.8] - 2026-02-03 + +### Fixed +- Resolve clippy warnings (CI `-D warnings`) in new tool implementations + ## [0.3.7] - 2026-02-03 ### Added diff --git a/Cargo.lock b/Cargo.lock index 76a94726..0d652d60 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -674,7 +674,7 @@ dependencies = [ [[package]] name = "deepseek-tui" -version = "0.3.7" +version = "0.3.9" dependencies = [ "anyhow", "arboard", diff --git a/Cargo.toml b/Cargo.toml index 5faf07bc..06505e10 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "deepseek-tui" -version = "0.3.7" +version = "0.3.9" edition = "2024" description = "Unofficial DeepSeek CLI - Just run 'deepseek' to start chatting" license = "MIT" diff --git a/README.md b/README.md index 34e74e49..fae166ca 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Unofficial terminal UI (TUI) + CLI for the [DeepSeek platform](https://platform. ## โœจ Features -- **Interactive TUI** with multiple modes (Normal, Plan, Agent, YOLO, RLM, Duo) +- **Interactive TUI** with multiple modes (Normal, Plan, Agent, YOLO) - **Comprehensive tool access** โ€“ File operations, shell execution, task management, and sub-agent systems - **File operations**: List directories, read/write/edit files, apply patches, search files with regex - **Shell execution**: Run commands with timeout support, background execution with task management @@ -100,7 +100,7 @@ See `config.example.toml` and `docs/CONFIGURATION.md` for a full reference. ## ๐ŸŽฎ Modes -In the TUI, press `Tab` to cycle modes: **Normal โ†’ Plan โ†’ Agent โ†’ YOLO โ†’ RLM โ†’ Duo โ†’ Normal**. +In the TUI, press `Tab` to cycle modes: **Normal โ†’ Plan โ†’ Agent โ†’ YOLO โ†’ Normal**. | Mode | Description | Approval Behavior | |------|-------------|-------------------| @@ -108,9 +108,6 @@ In the TUI, press `Tab` to cycle modes: **Normal โ†’ Plan โ†’ Agent โ†’ YOLO โ†’ | **Plan** | Designโ€‘first prompting; same approvals as Normal | Manual approval for writes & shell | | **Agent** | Multiโ€‘step tool use; asks before shell | Manual approval for shell, autoโ€‘approve file writes | | **YOLO** | Enables shell + trust + autoโ€‘approves all tools (dangerous) | Autoโ€‘approve all tools | -| **RLM** | Externalized context + REPL helpers; autoโ€‘approves tools (best for large files) | Autoโ€‘approve tools | -| **Duo** | Playerโ€‘coach autocoding with iterative validation (based on g3 paper) | Depends on phase | - Approval behavior is modeโ€‘dependent, but you can also override it at runtime with `/set approval_mode auto|suggest|never`. ## ๐Ÿ› ๏ธ Tools @@ -163,30 +160,6 @@ DeepSeek CLI exposes a comprehensive set of tools to the model across 5 categori - **Skills**: Reusable workflows stored as `SKILL.md` directories. The resolved skills dir prefers workspace-local `.agents/skills`, then `./skills`, then `~/.deepseek/skills`. Use `/skills` and `/skill `. Bootstrap with `deepseek setup --skills` (add `--local` for `./skills`). - **MCP**: Load external tool servers via `~/.deepseek/mcp.json` (supports `servers` and `mcpServers`). MCP tools currently execute without TUI approval prompts, so only enable servers you trust. See `docs/MCP.md`. -## ๐Ÿง  RLM (Reasoning & Largeโ€‘scale Memory) - -RLM mode is designed for "too big for context" tasks: large files, wholeโ€‘doc sweeps, and big pasted blocks. - -- Autoโ€‘switch triggers: "largest file", explicit "RLM", large file requests, and large pastes. -- Shortcut: `/rlm` (or `/aleph`) enters RLM mode directly. -- In **RLM mode**, `/load @path` loads a file into the external context store (outside RLM mode, `/load` loads a saved chat JSON). -- Use `/repl` to enter expression mode (e.g. `search("pattern")`, `lines(1, 80)`). -- Power tools: `rlm_load`, `rlm_exec`, `rlm_status`, `rlm_query`. - -`rlm_query` can be expensive: prefer batching and check `/status` if you're doing lots of subโ€‘queries. - -## ๐Ÿ‘ฅ Duo Mode - -> **Note:** Duo mode is experimental and may not work correctly in all cases. Use with caution. - -Duo mode implements the playerโ€‘coach autocoding paradigm for iterative development with builtโ€‘in validation: - -- **Player**: implements requirements (builder role) -- **Coach**: validates implementation against requirements (critic role) -- Tools: `duo_init`, `duo_player`, `duo_coach`, `duo_advance`, `duo_status` - -Workflow: `init โ†’ player โ†’ coach โ†’ advance โ†’ (repeat until approved)` - ## ๐Ÿ“š Examples ### Interactive chat @@ -278,7 +251,6 @@ Run `deepseek doctor` to confirm sandbox availability. On macOS, ensure - `docs/CONFIGURATION.md` โ€“ Complete configuration reference - `docs/MCP.md` โ€“ Model Context Protocol guide - `docs/ARCHITECTURE.md` โ€“ Project architecture -- `docs/RLM.md` โ€“ RLM mode deepโ€‘dive - `docs/MODES.md` โ€“ Mode comparison and usage - `CONTRIBUTING.md` โ€“ How to contribute to the project diff --git a/config.example.toml b/config.example.toml index f5dc3f01..88c434ad 100644 --- a/config.example.toml +++ b/config.example.toml @@ -54,8 +54,6 @@ subagents = true web_search = true # enables web.run and web_search apply_patch = true mcp = true -rlm = true -duo = true exec_policy = true # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -78,16 +76,6 @@ exponential_base = 2.0 # model = "deepseek-chat" # Model to use for summarization # cache_summary = true # Cache the summary block -# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -# RLM Sandbox Configuration (PLANNED - not yet implemented) -# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -# [rlm] -# max_context_chars = 10000000 # Max characters for context (10MB) -# max_search_results = 100 # Max search results -# default_chunk_size = 2000 # Default chunk size -# default_overlap = 200 # Default chunk overlap -# session_dir = "~/.deepseek/rlm" # Directory for RLM sessions - # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ # Profile Example (for multiple environments) # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 183c2379..7b4ad4f5 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -121,7 +121,6 @@ Responses API (with automatic fallback if needed). - **`utils.rs`** - Common utilities - **`logging.rs`** - Logging infrastructure - **`compaction.rs`** - Context compaction for long conversations -- **`rlm.rs`** - Reflection/reasoning utilities - **`pricing.rs`** - Cost estimation - **`prompts.rs`** - System prompt templates - **`project_doc.rs`** - Project documentation handling diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index e812ec92..6e60a167 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -67,7 +67,7 @@ Common settings keys: - `auto_compact` (on/off) - `show_thinking` (on/off) - `show_tool_details` (on/off) -- `default_mode` (normal, agent, plan, yolo, rlm, duo) +- `default_mode` (normal, agent, plan, yolo) - `max_history` (number of input history entries) - `default_model` (model name override) @@ -113,8 +113,6 @@ subagents = true web_search = true # enables web.run and web_search apply_patch = true mcp = true -rlm = true -duo = true exec_policy = true ``` diff --git a/docs/MODES.md b/docs/MODES.md index 3a0a4362..16800f1b 100644 --- a/docs/MODES.md +++ b/docs/MODES.md @@ -2,18 +2,17 @@ DeepSeek CLI has two related concepts: -- **TUI mode**: what kind of interaction youโ€™re in (Normal/Plan/Agent/YOLO/RLM). +- **TUI mode**: what kind of interaction youโ€™re in (Normal/Plan/Agent/YOLO). - **Approval mode**: how aggressively the UI asks before executing tools. ## TUI Modes -Press `Tab` to cycle: **Normal โ†’ Plan โ†’ Agent โ†’ YOLO โ†’ RLM โ†’ Normal**. +Press `Tab` to cycle: **Normal โ†’ Plan โ†’ Agent โ†’ YOLO โ†’ Normal**. - **Normal**: chat-first. Approvals for file writes, shell, and paid tools. - **Plan**: design-first prompting. Approvals match Normal. - **Agent**: multi-step tool use. Approvals for shell and paid tools (file writes are allowed without a prompt). - **YOLO**: enables shell + trust mode and auto-approves all tools. Use only in trusted repos. -- **RLM**: externalized context store + REPL helpers. Tools are auto-approved (best for large files and long-context work). ## Approval Mode @@ -26,7 +25,7 @@ You can override approval behavior at runtime: ``` - `suggest` (default): uses the per-mode rules above. -- `auto`: auto-approves all tools (similar to YOLO/RLM approval behavior, but without forcing YOLO mode). +- `auto`: auto-approves all tools (similar to YOLO approval behavior, but without forcing YOLO mode). - `never`: blocks any tool that isnโ€™t considered safe/read-only. ## Workspace Boundary and Trust Mode diff --git a/docs/RLM.md b/docs/RLM.md deleted file mode 100644 index a2dbcb2a..00000000 --- a/docs/RLM.md +++ /dev/null @@ -1,54 +0,0 @@ -# RLM Mode - -RLM mode (โ€œRecursive Language Modelโ€ mode) is DeepSeek CLIโ€™s long-context workflow: it stores large context externally (Aleph-style external memory) and provides REPL-like tools to explore and query it without stuffing everything into the modelโ€™s context window. - -If youโ€™re curious about the research inspiration and implementation notes, see: - -- `docs/rlm-paper.txt` -- `docs/rlm_gap_analysis.md` - -## When To Use It - -RLM mode is best for: - -- โ€œAnalyze this large file / docโ€ -- โ€œSummarize the whole repositoryโ€ -- โ€œSearch for every occurrence of X and explain itโ€ -- Big pasted blocks of text - -The UI may auto-switch to RLM for large file requests, โ€œlargest fileโ€, explicit โ€œRLMโ€ requests, and large pastes. - -## How To Use It - -### Switch modes - -- Press `Tab` until you reach **RLM** -- Or type `/rlm` (or `/aleph`) to jump directly into RLM mode - -### Load context - -In RLM mode, `/load` loads external context (in other modes, `/load` loads a saved chat JSON): - -```text -/load @path/to/file.rs -``` - -`@path` is workspace-relative. - -### Inspect and query - -- `/status` shows which contexts are loaded and basic usage totals. -- `/repl` toggles expression input mode. - -Typical REPL helpers include: - -- `lines(1, 80)` (show a slice of the context) -- `search("pattern")` -- `chunk(2000)` (create fixed-size chunks for later querying) - -Under the hood, the model uses tools like `rlm_load`, `rlm_exec`, `rlm_status`, and `rlm_query`. - -## Cost and Safety Notes - -- `rlm_query` can be expensive because it triggers additional model calls. Prefer batching related questions. -- RLM mode auto-approves tools; keep `--workspace` scoped to the repo you want it to access. diff --git a/src/commands/config.rs b/src/commands/config.rs index 4d60bdd9..22a7340d 100644 --- a/src/commands/config.rs +++ b/src/commands/config.rs @@ -125,9 +125,6 @@ pub fn set_config(app: &mut App, args: Option<&str>) -> CommandResult { compaction.model = app.model.clone(); action = Some(AppAction::UpdateCompaction(compaction)); } - "auto_rlm" => { - app.auto_rlm = settings.auto_rlm; - } "show_thinking" | "thinking" => { app.show_thinking = settings.show_thinking; app.mark_history_updated(); @@ -141,8 +138,6 @@ pub fn set_config(app: &mut App, args: Option<&str>) -> CommandResult { "agent" | "normal" => AppMode::Agent, "plan" => AppMode::Plan, "yolo" => AppMode::Yolo, - "rlm" => AppMode::Rlm, - "duo" => AppMode::Duo, _ => AppMode::Agent, }; app.set_mode(mode); diff --git a/src/commands/core.rs b/src/commands/core.rs index d1e17aea..697b4b3f 100644 --- a/src/commands/core.rs +++ b/src/commands/core.rs @@ -160,14 +160,6 @@ pub fn home_dashboard(app: &mut App) -> CommandResult { let _ = writeln!(stats, "Plan mode - Design before implementing"); let _ = writeln!(stats, " Use /plan to create structured checklists"); } - AppMode::Rlm => { - let _ = writeln!(stats, "RLM mode - Recursive language model sandbox"); - let _ = writeln!(stats, " Use /repl to toggle REPL input"); - } - AppMode::Duo => { - let _ = writeln!(stats, "Duo mode - Dialectical autocoding"); - let _ = writeln!(stats, " Player-coach loop for complex tasks"); - } } CommandResult::message(stats) diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 7cb70fa5..864b5a7e 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -10,11 +10,10 @@ mod init; mod note; mod queue; mod review; -pub mod rlm; mod session; mod skills; -use crate::tui::app::{App, AppAction, AppMode}; +use crate::tui::app::{App, AppAction}; /// Result of executing a command #[derive(Debug, Clone)] @@ -150,39 +149,9 @@ pub const COMMANDS: &[CommandInfo] = &[ CommandInfo { name: "load", aliases: &[], - description: "Load session from file (or RLM context in RLM mode)", + description: "Load session from file", usage: "/load [path]", }, - CommandInfo { - name: "rlm", - aliases: &[], - description: "Enter RLM (Aleph) mode and show quickstart", - usage: "/rlm", - }, - CommandInfo { - name: "aleph", - aliases: &[], - description: "Alias for /rlm (external memory quickstart)", - usage: "/aleph", - }, - CommandInfo { - name: "save-session", - aliases: &["save_session"], - description: "Save RLM session to file", - usage: "/save-session [path]", - }, - CommandInfo { - name: "status", - aliases: &[], - description: "Show RLM context status", - usage: "/status", - }, - CommandInfo { - name: "repl", - aliases: &[], - description: "Toggle RLM REPL mode", - usage: "/repl", - }, CommandInfo { name: "compact", aliases: &[], @@ -320,18 +289,7 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult { // Session commands "save" => session::save(app, arg), "sessions" | "resume" => session::sessions(app), - "load" => { - let force_rlm = arg.is_some_and(|raw| raw.trim_start().starts_with('@')); - if app.mode == AppMode::Rlm || force_rlm { - rlm::load(app, arg) - } else { - session::load(app, arg) - } - } - "rlm" | "aleph" => rlm::enter(app), - "save-session" | "save_session" => rlm::save_session(app, arg), - "status" => rlm::status(app), - "repl" => rlm::repl(app), + "load" => session::load(app, arg), "compact" => session::compact(app), "export" => session::export(app, arg), @@ -387,52 +345,5 @@ pub fn commands_matching(prefix: &str) -> Vec<&'static CommandInfo> { #[cfg(test)] mod tests { - use super::execute; - use crate::config::Config; - use crate::tui::app::{App, AppMode, TuiOptions}; - use std::fs; - use std::path::PathBuf; - use tempfile::tempdir; - - fn make_test_app(workspace: PathBuf) -> App { - let skills_dir = workspace.join("skills"); - let _ = fs::create_dir_all(&skills_dir); - let options = TuiOptions { - model: "test-model".to_string(), - workspace, - allow_shell: false, - use_alt_screen: false, - max_subagents: 1, - skills_dir, - memory_path: PathBuf::from("memory.md"), - notes_path: PathBuf::from("notes.txt"), - mcp_config_path: PathBuf::from("mcp.json"), - use_memory: false, - start_in_agent_mode: false, - skip_onboarding: true, - yolo: false, - resume_session_id: None, - }; - App::new(options, &Config::default()) - } - - #[test] - fn load_at_path_uses_rlm_outside_rlm_mode() { - let tmp = tempdir().expect("tempdir"); - let file = tmp.path().join("example.txt"); - fs::write(&file, "hello").expect("write"); - - let mut app = make_test_app(tmp.path().to_path_buf()); - app.mode = AppMode::Normal; - - let result = execute("/load @example.txt", &mut app); - let message = result.message.unwrap_or_default(); - assert!( - message.starts_with("Loaded "), - "expected RLM load message, got: {message}" - ); - - let session = app.rlm_session.lock().expect("lock session"); - assert!(!session.contexts.is_empty()); - } + // No unit tests currently required for command routing. } diff --git a/src/commands/rlm.rs b/src/commands/rlm.rs deleted file mode 100644 index ace59812..00000000 --- a/src/commands/rlm.rs +++ /dev/null @@ -1,254 +0,0 @@ -//! RLM commands for the TUI (load/status/repl/save-session). - -use std::fs; -use std::path::{Path, PathBuf}; - -use crate::rlm::{context_id_from_path, unique_context_id}; -use crate::tui::app::{App, AppMode}; - -use super::CommandResult; - -const DEFAULT_CHUNK_SIZE: usize = 2000; -const DEFAULT_CHUNK_OVERLAP: usize = 200; - -pub fn welcome_message() -> String { - [ - "DeepSeek RLM / Aleph Sandbox", - "Commands: /rlm, /aleph, /load , /repl, /status, /save-session", - "Press Tab to exit RLM mode", - "Use /repl to toggle expression mode (chat is the default)", - "Tip: /load @path forces workspace-relative paths (e.g. @docs/rlm-paper.txt)", - "", - "Expressions:", - " len(ctx)", - " search(\"pattern\")", - " lines(1, 20)", - " chunk(2000, 200)", - " chunk_sections(20000)", - " chunk_auto(20000)", - " vars(), get(\"name\"), set(\"name\", \"value\")", - "", - "Tip: rlm_query auto_chunks runs the same question over chunk_auto slices.", - "Tip: /save-session persists the current RLM session.", - ] - .join("\n") -} - -pub fn overview_message() -> String { - [ - "RLM / Aleph Quickstart", - "Use /rlm or /aleph to enter external-memory mode.", - "Use /load @path to load a file into the RLM context store.", - "Use /status to list contexts and usage totals.", - "Use /repl to toggle expression mode (chat is default).", - "Tip: rlm_query auto_chunks runs the same question over chunk_auto slices.", - ] - .join("\n") -} - -pub fn enter(app: &mut App) -> CommandResult { - if app.mode != AppMode::Rlm { - app.set_mode(AppMode::Rlm); - } - app.rlm_repl_active = false; - CommandResult::message(overview_message()) -} - -pub fn repl(app: &mut App) -> CommandResult { - if app.mode != AppMode::Rlm { - app.set_mode(AppMode::Rlm); - } - if app.rlm_repl_active { - app.rlm_repl_active = false; - return CommandResult::message("Exited RLM REPL mode. Chat is active."); - } - app.rlm_repl_active = true; - CommandResult::message(welcome_message()) -} - -pub fn status(app: &mut App) -> CommandResult { - let session = match app.rlm_session.lock() { - Ok(session) => session, - Err(_) => return CommandResult::error("Failed to access RLM session"), - }; - - if session.contexts.is_empty() { - return CommandResult::message("No RLM contexts loaded. Use /load ."); - } - - let mut lines = Vec::new(); - lines.push("RLM Session".to_string()); - lines.push(format!("Active context: {}", session.active_context)); - lines.push(format!("Loaded contexts: {}", session.contexts.len())); - lines.push(format!( - "Queries: {} | Input tokens: {} | Output tokens: {}", - session.usage.queries, session.usage.input_tokens, session.usage.output_tokens - )); - - let mut ids: Vec<_> = session.contexts.keys().collect(); - ids.sort(); - for id in ids { - if let Some(ctx) = session.contexts.get(id) { - let source = ctx - .source_path - .as_ref() - .map(|s| format!(" (source: {s})")) - .unwrap_or_default(); - let chunk_count = ctx.chunk(DEFAULT_CHUNK_SIZE, DEFAULT_CHUNK_OVERLAP).len(); - let section_count = ctx.chunk_sections(20_000).len(); - lines.push(format!( - "- {id}: {} lines, {} chars, {} chunks, {} sections{source}", - ctx.line_count, ctx.char_count, chunk_count, section_count - )); - if !ctx.variables.is_empty() { - lines.push(format!(" variables: {}", ctx.variables.len())); - } - } - } - - CommandResult::message(lines.join("\n")) -} - -pub fn load(app: &mut App, path: Option<&str>) -> CommandResult { - let Some(raw) = path else { - return CommandResult::error("Usage: /load "); - }; - - let resolved = match resolve_path(app, raw) { - Ok(path) => path, - Err(err) => return CommandResult::error(err), - }; - - let mut session = match app.rlm_session.lock() { - Ok(session) => session, - Err(_) => return CommandResult::error("Failed to access RLM session"), - }; - - let base_id = context_id_from_path(&resolved); - let id = unique_context_id(&session, &base_id); - let (line_count, char_count) = match session.load_file(&id, &resolved) { - Ok(stats) => stats, - Err(err) => { - return CommandResult::error(format!("Failed to load {}: {err}", resolved.display())); - } - }; - - CommandResult::message(format!( - "Loaded {} ({} lines, {} chars)", - resolved.display(), - line_count, - char_count - )) -} - -pub fn save_session(app: &mut App, path: Option<&str>) -> CommandResult { - let save_path = if let Some(p) = path { - PathBuf::from(p) - } else { - let timestamp = chrono::Local::now().format("%Y%m%d_%H%M%S"); - PathBuf::from(format!("rlm_session_{timestamp}.json")) - }; - - let parent_dir = save_path - .parent() - .filter(|p| !p.as_os_str().is_empty()) - .map(std::path::Path::to_path_buf); - if let Some(dir) = parent_dir - && let Err(err) = fs::create_dir_all(&dir) - { - return CommandResult::error(format!( - "Failed to create directory {}: {err}", - dir.display() - )); - } - - let session = match app.rlm_session.lock() { - Ok(session) => session, - Err(_) => return CommandResult::error("Failed to access RLM session"), - }; - let json = match serde_json::to_string_pretty(&*session) { - Ok(json) => json, - Err(err) => return CommandResult::error(format!("Failed to serialize session: {err}")), - }; - - match fs::write(&save_path, json) { - Ok(()) => CommandResult::message(format!("RLM session saved to {}", save_path.display())), - Err(err) => CommandResult::error(format!("Failed to save session: {err}")), - } -} - -fn resolve_path(app: &App, raw: &str) -> Result { - let raw = raw.trim(); - let (raw, force_workspace) = if let Some(stripped) = raw.strip_prefix('@') { - (stripped.trim(), true) - } else { - (raw, false) - }; - if raw.is_empty() { - return Err("Usage: /load (use @ for workspace-relative paths)".to_string()); - } - - let candidate = if force_workspace { - app.workspace.join(raw.trim_start_matches(['/', '\\'])) - } else if Path::new(raw).is_absolute() { - PathBuf::from(raw) - } else { - app.workspace.join(raw) - }; - let canonical = candidate.canonicalize().map_err(|err| { - let mut message = format!("Failed to resolve path {}: {err}", candidate.display()); - if !force_workspace { - message.push_str("\nTip: use /load @path to resolve relative to the workspace."); - } - message - })?; - let workspace_root = app - .workspace - .canonicalize() - .unwrap_or_else(|_| app.workspace.clone()); - if !app.trust_mode && !canonical.starts_with(&workspace_root) { - return Err("Path is outside workspace. Use /trust to allow access.".to_string()); - } - Ok(canonical) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::config::Config; - use crate::tui::app::{App, TuiOptions}; - use std::fs; - - fn make_app(workspace: PathBuf) -> App { - let options = TuiOptions { - model: "test-model".to_string(), - workspace, - allow_shell: false, - use_alt_screen: true, - max_subagents: 1, - skills_dir: PathBuf::from("."), - memory_path: PathBuf::from("memory.md"), - notes_path: PathBuf::from("notes.txt"), - mcp_config_path: PathBuf::from("mcp.json"), - use_memory: false, - start_in_agent_mode: false, - skip_onboarding: false, - yolo: false, - resume_session_id: None, - }; - App::new(options, &Config::default()) - } - - #[test] - fn resolve_path_with_at_prefix_uses_workspace_root() { - let tmp = tempfile::tempdir().expect("tempdir"); - let docs_dir = tmp.path().join("docs"); - fs::create_dir_all(&docs_dir).expect("create docs dir"); - let file_path = docs_dir.join("rlm-paper.txt"); - fs::write(&file_path, "hello").expect("write file"); - - let app = make_app(tmp.path().to_path_buf()); - let resolved = resolve_path(&app, "@/docs/rlm-paper.txt").expect("resolve path with @"); - assert_eq!(resolved, file_path.canonicalize().expect("canonicalize")); - } -} diff --git a/src/commands/session.rs b/src/commands/session.rs index 3941ec44..62d180e4 100644 --- a/src/commands/session.rs +++ b/src/commands/session.rs @@ -157,8 +157,6 @@ pub fn export(app: &mut App, path: Option<&str>) -> CommandResult { let (role, body) = match cell { HistoryCell::User { content } => ("**You:**", content.clone()), HistoryCell::Assistant { content, .. } => ("**Assistant:**", content.clone()), - HistoryCell::Player { content, .. } => ("**Player:**", content.clone()), - HistoryCell::Coach { content, .. } => ("**Coach:**", content.clone()), HistoryCell::System { content } => ("*System:*", content.clone()), HistoryCell::Thinking { content, .. } => ("*Thinking:*", content.clone()), HistoryCell::Tool(tool) => ("**Tool:**", render_tool_cell(tool, 80)), diff --git a/src/core/engine.rs b/src/core/engine.rs index 503c2cc3..88a3d9c9 100644 --- a/src/core/engine.rs +++ b/src/core/engine.rs @@ -7,10 +7,9 @@ //! - Proper cancellation support //! - Tool execution orchestration -use std::fmt::Write; use std::path::PathBuf; use std::pin::pin; -use std::sync::{Arc, Mutex}; +use std::sync::Arc; use std::time::Instant; use anyhow::Result; @@ -26,7 +25,6 @@ use crate::compaction::{ }; use crate::config::Config; use crate::config::DEFAULT_MAX_SUBAGENTS; -use crate::duo::{DuoSession, SharedDuoSession, session_summary as duo_session_summary}; use crate::features::{Feature, Features}; use crate::llm_client::LlmClient; use crate::mcp::McpPool; @@ -34,7 +32,6 @@ use crate::models::{ ContentBlock, ContentBlockStart, Delta, Message, MessageRequest, StreamEvent, Tool, Usage, }; use crate::prompts; -use crate::rlm::{RlmSession, SharedRlmSession, session_summary as rlm_session_summary}; use crate::tools::plan::{SharedPlanState, new_shared_plan_state}; use crate::tools::shell::{SharedShellManager, new_shared_shell_manager}; use crate::tools::spec::{ApprovalRequirement, ToolError, ToolResult}; @@ -75,10 +72,6 @@ pub struct EngineConfig { pub max_subagents: usize, /// Feature flags controlling tool availability. pub features: Features, - /// Shared RLM session state. - pub rlm_session: SharedRlmSession, - /// Shared Duo session state. - pub duo_session: SharedDuoSession, /// Auto-compaction settings for long conversations. pub compaction: CompactionConfig, /// Shared Todo list state. @@ -99,8 +92,6 @@ impl Default for EngineConfig { max_steps: 100, max_subagents: DEFAULT_MAX_SUBAGENTS, features: Features::with_defaults(), - rlm_session: Arc::new(Mutex::new(RlmSession::default())), - duo_session: Arc::new(Mutex::new(DuoSession::new())), compaction: CompactionConfig::default(), todos: new_shared_todo_list(), plan_state: new_shared_plan_state(), @@ -538,8 +529,6 @@ impl Engine { AppMode::Agent, &config.workspace, working_set_summary.as_deref(), - None, - None, ); session.system_prompt = Some(system_prompt); @@ -778,24 +767,6 @@ impl Engine { self.config.trust_mode = trust_mode; // Update system prompt to match the current mode - let rlm_summary = if mode == AppMode::Rlm { - self.config - .rlm_session - .lock() - .ok() - .map(|session| rlm_session_summary(&session)) - } else { - None - }; - let duo_summary = if mode == AppMode::Duo { - self.config - .duo_session - .lock() - .ok() - .map(|s| duo_session_summary(&s)) - } else { - None - }; let working_set_summary = self .session .working_set @@ -804,8 +775,6 @@ impl Engine { mode, &self.config.workspace, working_set_summary.as_deref(), - rlm_summary.as_deref(), - duo_summary.as_deref(), )); // Build tool registry and tool list for the current mode @@ -822,19 +791,8 @@ impl Engine { .with_todo_tool(todo_list.clone()) .with_plan_tool(plan_state.clone()) } else { - let rlm_opt = if self.config.features.enabled(Feature::Rlm) { - Some(self.config.rlm_session.clone()) - } else { - None - }; - ToolRegistryBuilder::new() - .with_agent_tools( - self.session.allow_shell, - rlm_opt, - self.deepseek_client.clone(), - self.session.model.clone(), - ) + .with_agent_tools(self.session.allow_shell) .with_todo_tool(todo_list.clone()) .with_plan_tool(plan_state.clone()) }; @@ -857,33 +815,9 @@ impl Engine { { builder = builder.with_shell_tools(); } - if mode == AppMode::Rlm { - if self.config.features.enabled(Feature::Rlm) { - builder = builder.with_rlm_tools( - self.config.rlm_session.clone(), - self.deepseek_client.clone(), - self.session.model.clone(), - ); - } else { - let _ = self - .tx_event - .send(Event::status("RLM tools are disabled by feature flags")) - .await; - } - } - if mode == AppMode::Duo { - if self.config.features.enabled(Feature::Duo) { - builder = builder.with_duo_tools(self.config.duo_session.clone()); - } else { - let _ = self - .tx_event - .send(Event::status("Duo tools are disabled by feature flags")) - .await; - } - } let tool_registry = match mode { - AppMode::Agent | AppMode::Yolo | AppMode::Rlm | AppMode::Duo => { + AppMode::Agent | AppMode::Yolo => { if self.config.features.enabled(Feature::Subagents) { let runtime = if let Some(client) = self.deepseek_client.clone() { Some(SubAgentRuntime::new( @@ -947,42 +881,6 @@ impl Engine { .with_shell_manager(self.shell_manager.clone()) } - /// Automatically offload large tool results to RLM memory if enabled. - /// Returns either the original content or a pointer to the RLM context. - fn offload_to_rlm_if_needed(&self, tool_name: &str, content: String) -> String { - const OFFLOAD_THRESHOLD: usize = 15_000; - - if !self.config.features.enabled(Feature::Rlm) || content.len() < OFFLOAD_THRESHOLD { - return content; - } - - let mut session = match self.config.rlm_session.lock() { - Ok(s) => s, - Err(_) => return content, - }; - - let context_id = format!( - "auto_{}_{}", - tool_name, - &uuid::Uuid::new_v4().to_string()[..8] - ); - let char_count = content.len(); - let line_count = content.lines().count(); - - session.load_context(&context_id, content, None); - - format!( - "[AUTOMATIC RLM OFFLOAD]\n\ - The output of '{tool_name}' was too large ({char_count} chars, {line_count} lines) \ - and has been moved to RLM memory to preserve your context window.\n\n\ - Context ID: {context_id}\n\n\ - You can explore this data using RLM tools:\n\ - - `rlm_exec(code=\"lines(1, 100)\", context_id=\"{context_id}\")` to see the start\n\ - - `rlm_exec(code=\"search(\\\"pattern\\\")\", context_id=\"{context_id}\")` to search\n\ - - `rlm_query(query=\"...\", context_id=\"{context_id}\")` for deep analysis" - ) - } - async fn ensure_mcp_pool(&mut self) -> Result>, ToolError> { if let Some(pool) = self.mcp_pool.as_ref() { return Ok(Arc::clone(pool)); @@ -1322,49 +1220,6 @@ impl Engine { Ok(result) => { // Only update if we got valid messages (never corrupt state) if !result.messages.is_empty() || self.session.messages.is_empty() { - // Offload removed messages to RLM history if enabled - if self.config.features.enabled(Feature::Rlm) - && !result.removed_messages.is_empty() - { - if let Ok(mut rlm) = self.config.rlm_session.lock() { - let mut history_text = String::new(); - for msg in &result.removed_messages { - let role = if msg.role == "user" { - "User" - } else { - "Assistant" - }; - for block in &msg.content { - match block { - ContentBlock::Text { text, .. } => { - let _ = - writeln!(history_text, "{role}: {text}\n"); - } - ContentBlock::ToolUse { name, input, .. } => { - let _ = writeln!( - history_text, - "{role}: [Used tool: {name}] Input: {input}\n" - ); - } - ContentBlock::ToolResult { content, .. } => { - let _ = writeln!( - history_text, - "Tool result: {content}\n" - ); - } - ContentBlock::Thinking { thinking } => { - let _ = writeln!( - history_text, - "{role} (thinking): {thinking}\n" - ); - } - } - } - } - rlm.append_var("history", history_text); - } - } - self.session.messages = result.messages; self.session.system_prompt = merge_system_prompts( self.session.system_prompt.as_ref(), @@ -1970,9 +1825,7 @@ impl Engine { match outcome.result { Ok(output) => { - let original_content = output.content; - let output_content = - self.offload_to_rlm_if_needed(&outcome.name, original_content); + let output_content = output.content; tool_call.set_result(output_content.clone(), duration); self.session.working_set.observe_tool_call( @@ -2044,20 +1897,6 @@ impl Engine { /// Refresh the system prompt based on current mode and context. fn refresh_system_prompt(&mut self, mode: AppMode) { - let rlm_summary = self - .config - .rlm_session - .lock() - .ok() - .map(|session| rlm_session_summary(&session)); - - let duo_summary = self - .config - .duo_session - .lock() - .ok() - .map(|s| duo_session_summary(&s)); - let working_set_summary = self .session .working_set @@ -2067,8 +1906,6 @@ impl Engine { mode, &self.config.workspace, working_set_summary.as_deref(), - rlm_summary.as_deref(), - duo_summary.as_deref(), )); } } diff --git a/src/core/events.rs b/src/core/events.rs index c3a701ec..3602f1e3 100644 --- a/src/core/events.rs +++ b/src/core/events.rs @@ -57,7 +57,7 @@ pub enum Event { /// The turn is complete (no more tool calls) TurnComplete { usage: Usage }, - // === Sub-Agent Events (for RLM mode) === + // === Sub-Agent Events === /// A sub-agent has been spawned AgentSpawned { id: String, prompt: String }, diff --git a/src/core/ops.rs b/src/core/ops.rs index 40742c32..7ca76ad2 100644 --- a/src/core/ops.rs +++ b/src/core/ops.rs @@ -29,7 +29,7 @@ pub enum Op { /// Deny a tool call that requires permission DenyToolCall { id: String }, - /// Spawn a sub-agent (for RLM mode) + /// Spawn a sub-agent SpawnSubAgent { prompt: String }, /// List current sub-agents and their status diff --git a/src/duo.rs b/src/duo.rs deleted file mode 100644 index 8d7e178e..00000000 --- a/src/duo.rs +++ /dev/null @@ -1,802 +0,0 @@ -//! Duo mode state machine for hegelion's autocoding (player-coach adversarial cooperation). -//! -//! Implements the g3 paper's coach-player paradigm where: -//! - Player: implements requirements (builder role) -//! - Coach: validates implementation against requirements (critic role) -//! -//! The loop continues until the coach approves or max turns are reached. - -use std::collections::HashMap; -use std::sync::{Arc, Mutex}; - -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -// === Phase & Status Enums === - -/// The current phase in the autocoding loop. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum DuoPhase { - /// Session initialized, ready to start player phase - Init, - /// Player is implementing requirements - Player, - /// Coach is validating the implementation - Coach, - /// Coach approved the implementation - Approved, - /// Maximum turns reached without approval - Timeout, -} - -impl std::fmt::Display for DuoPhase { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - DuoPhase::Init => write!(f, "init"), - DuoPhase::Player => write!(f, "player"), - DuoPhase::Coach => write!(f, "coach"), - DuoPhase::Approved => write!(f, "approved"), - DuoPhase::Timeout => write!(f, "timeout"), - } - } -} - -/// The overall status of the autocoding session. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum DuoStatus { - /// Session is actively running - Active, - /// Coach has approved the implementation - Approved, - /// Coach has rejected (used for explicit rejection, not just iteration) - Rejected, - /// Maximum turns exhausted without approval - Timeout, -} - -impl std::fmt::Display for DuoStatus { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - DuoStatus::Active => write!(f, "active"), - DuoStatus::Approved => write!(f, "approved"), - DuoStatus::Rejected => write!(f, "rejected"), - DuoStatus::Timeout => write!(f, "timeout"), - } - } -} - -// === Turn History === - -/// Record of a single turn in the autocoding loop. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TurnRecord { - /// Turn number (1-indexed) - pub turn: u32, - /// The phase this record is for - pub phase: DuoPhase, - /// Summary of what happened (player implementation or coach feedback) - pub summary: String, - /// Quality score from coach (0.0 to 1.0), if applicable - pub quality_score: Option, - /// Timestamp when this turn was recorded - #[serde(default = "chrono::Utc::now")] - pub timestamp: chrono::DateTime, -} - -impl TurnRecord { - /// Create a new turn record. - #[must_use] - pub fn new(turn: u32, phase: DuoPhase, summary: String, quality_score: Option) -> Self { - Self { - turn, - phase, - summary, - quality_score, - timestamp: chrono::Utc::now(), - } - } -} - -// === Main State === - -/// The complete state of a Duo autocoding session. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DuoState { - /// Unique session identifier - pub session_id: String, - /// Optional human-readable session name - pub session_name: Option, - /// The requirements document (source of truth for validation) - pub requirements: String, - /// Current turn number (1-indexed) - pub current_turn: u32, - /// Maximum allowed turns before timeout - pub max_turns: u32, - /// Current phase in the autocoding loop - pub phase: DuoPhase, - /// Overall session status - pub status: DuoStatus, - /// History of all turns - pub turn_history: Vec, - /// Last feedback from the coach (used in next player prompt) - pub last_coach_feedback: Option, - /// Quality scores from each coach review - pub quality_scores: Vec, - /// Threshold score needed for approval (0.0 to 1.0) - pub approval_threshold: f64, - /// Timestamp when session was created - #[serde(default = "chrono::Utc::now")] - pub created_at: chrono::DateTime, - /// Timestamp of last update - #[serde(default = "chrono::Utc::now")] - pub updated_at: chrono::DateTime, -} - -impl DuoState { - /// Create a new Duo session with the given requirements. - /// - /// # Arguments - /// * `requirements` - The requirements document (source of truth) - /// * `session_name` - Optional human-readable name - /// * `max_turns` - Maximum turns before timeout (default: 10) - /// * `approval_threshold` - Score needed for approval (default: 0.9) - #[must_use] - pub fn create( - requirements: String, - session_name: Option, - max_turns: Option, - approval_threshold: Option, - ) -> Self { - let now = chrono::Utc::now(); - Self { - session_id: Uuid::new_v4().to_string(), - session_name, - requirements, - current_turn: 1, - max_turns: max_turns.unwrap_or(10), - phase: DuoPhase::Init, - status: DuoStatus::Active, - turn_history: Vec::new(), - last_coach_feedback: None, - quality_scores: Vec::new(), - approval_threshold: approval_threshold.unwrap_or(0.9), - created_at: now, - updated_at: now, - } - } - - /// Transition from Init or Player phase to Coach phase. - /// - /// Records the player's implementation summary in turn history. - /// - /// # Arguments - /// * `player_summary` - Summary of what the player implemented - /// - /// # Returns - /// `Ok(())` on success, `Err` if not in a valid phase for this transition - pub fn advance_to_coach(&mut self, player_summary: String) -> Result<(), DuoError> { - match self.phase { - DuoPhase::Init | DuoPhase::Player => { - // Record player turn - let record = - TurnRecord::new(self.current_turn, DuoPhase::Player, player_summary, None); - self.turn_history.push(record); - self.phase = DuoPhase::Coach; - self.updated_at = chrono::Utc::now(); - Ok(()) - } - _ => Err(DuoError::InvalidPhaseTransition { - from: self.phase, - to: DuoPhase::Coach, - }), - } - } - - /// Process coach feedback and determine the next phase. - /// - /// # Arguments - /// * `coach_feedback` - The coach's feedback text - /// * `approved` - Whether the coach approved the implementation - /// * `compliance_score` - Optional compliance score (0.0 to 1.0) - /// - /// # Returns - /// `Ok(())` on success, `Err` if not in coach phase - pub fn advance_turn( - &mut self, - coach_feedback: String, - approved: bool, - compliance_score: Option, - ) -> Result<(), DuoError> { - if self.phase != DuoPhase::Coach { - return Err(DuoError::InvalidPhaseTransition { - from: self.phase, - to: DuoPhase::Player, - }); - } - - // Record coach turn - let record = TurnRecord::new( - self.current_turn, - DuoPhase::Coach, - coach_feedback.clone(), - compliance_score, - ); - self.turn_history.push(record); - - // Track quality score if provided - if let Some(score) = compliance_score { - self.quality_scores.push(score); - } - - self.last_coach_feedback = Some(coach_feedback); - self.updated_at = chrono::Utc::now(); - - if approved { - // Coach approved - session complete - self.phase = DuoPhase::Approved; - self.status = DuoStatus::Approved; - } else if self.current_turn >= self.max_turns { - // Max turns reached - timeout - self.phase = DuoPhase::Timeout; - self.status = DuoStatus::Timeout; - } else { - // Continue to next turn - self.current_turn += 1; - self.phase = DuoPhase::Player; - } - - Ok(()) - } - - /// Check if the session is complete (approved or timed out). - #[must_use] - pub fn is_complete(&self) -> bool { - matches!( - self.status, - DuoStatus::Approved | DuoStatus::Rejected | DuoStatus::Timeout - ) - } - - /// Get the number of turns remaining before timeout. - #[must_use] - pub fn turns_remaining(&self) -> u32 { - self.max_turns.saturating_sub(self.current_turn) - } - - /// Get the average quality score across all coach reviews. - #[must_use] - pub fn average_quality_score(&self) -> Option { - if self.quality_scores.is_empty() { - None - } else { - let sum: f64 = self.quality_scores.iter().sum(); - Some(sum / self.quality_scores.len() as f64) - } - } - - /// Generate a human-readable summary of the session state. - #[must_use] - pub fn summary(&self) -> String { - let name = self - .session_name - .as_deref() - .unwrap_or(&self.session_id[..8]); - - let avg_score = self - .average_quality_score() - .map(|s| format!("{:.1}%", s * 100.0)) - .unwrap_or_else(|| "N/A".to_string()); - - let status_icon = match self.status { - DuoStatus::Active => "๐Ÿ”„", - DuoStatus::Approved => "โœ…", - DuoStatus::Rejected => "โŒ", - DuoStatus::Timeout => "โฐ", - }; - - format!( - "{status_icon} Duo Session: {name}\n\ - Phase: {} | Turn: {}/{} | Status: {}\n\ - Avg Quality: {} | Threshold: {:.0}%\n\ - History: {} records", - self.phase, - self.current_turn, - self.max_turns, - self.status, - avg_score, - self.approval_threshold * 100.0, - self.turn_history.len() - ) - } -} - -// === Error Types === - -/// Errors that can occur during Duo session operations. -#[derive(Debug, Clone)] -pub enum DuoError { - /// Invalid phase transition attempted - InvalidPhaseTransition { from: DuoPhase, to: DuoPhase }, - /// Session not found (reserved for future multi-session management) - #[allow(dead_code)] - SessionNotFound { session_id: String }, - /// Session already complete (reserved for future session validation) - #[allow(dead_code)] - SessionAlreadyComplete { session_id: String }, -} - -impl std::fmt::Display for DuoError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - DuoError::InvalidPhaseTransition { from, to } => { - write!(f, "Invalid phase transition from {} to {}", from, to) - } - DuoError::SessionNotFound { session_id } => { - write!(f, "Session not found: {}", session_id) - } - DuoError::SessionAlreadyComplete { session_id } => { - write!(f, "Session already complete: {}", session_id) - } - } - } -} - -impl std::error::Error for DuoError {} - -// === Session Container === - -/// Container for managing multiple Duo sessions. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct DuoSession { - /// The currently active session state - pub active_state: Option, - /// Saved/completed session states indexed by session_id - pub saved_states: HashMap, -} - -impl DuoSession { - /// Create a new empty session container. - #[must_use] - pub fn new() -> Self { - Self { - active_state: None, - saved_states: HashMap::new(), - } - } - - /// Start a new Duo session. - pub fn start_session( - &mut self, - requirements: String, - session_name: Option, - max_turns: Option, - approval_threshold: Option, - ) -> &DuoState { - // Save any existing active session - if let Some(state) = self.active_state.take() { - self.saved_states.insert(state.session_id.clone(), state); - } - - // Create new session - let state = DuoState::create(requirements, session_name, max_turns, approval_threshold); - self.active_state = Some(state); - self.active_state.as_ref().expect("just set active_state") - } - - /// Get the active session state. - #[must_use] - pub fn get_active(&self) -> Option<&DuoState> { - self.active_state.as_ref() - } - - /// Get a mutable reference to the active session state. - pub fn get_active_mut(&mut self) -> Option<&mut DuoState> { - self.active_state.as_mut() - } - - /// Get a saved session by ID (reserved for future multi-session management). - #[must_use] - #[allow(dead_code)] - pub fn get_saved(&self, session_id: &str) -> Option<&DuoState> { - self.saved_states.get(session_id) - } - - /// Save the current active session and clear it (reserved for future session management). - #[allow(dead_code)] - pub fn save_active(&mut self) -> Option { - if let Some(state) = self.active_state.take() { - let id = state.session_id.clone(); - self.saved_states.insert(id.clone(), state); - Some(id) - } else { - None - } - } - - /// Restore a saved session as the active session (reserved for future session management). - #[allow(dead_code)] - pub fn restore_session(&mut self, session_id: &str) -> Result<(), DuoError> { - let state = - self.saved_states - .remove(session_id) - .ok_or_else(|| DuoError::SessionNotFound { - session_id: session_id.to_string(), - })?; - - // Save current active if any - if let Some(current) = self.active_state.take() { - self.saved_states - .insert(current.session_id.clone(), current); - } - - self.active_state = Some(state); - Ok(()) - } - - /// List all session IDs (active and saved, reserved for future session management). - #[must_use] - #[allow(dead_code)] - pub fn list_sessions(&self) -> Vec<&str> { - let mut ids: Vec<&str> = self.saved_states.keys().map(String::as_str).collect(); - if let Some(ref active) = self.active_state { - ids.push(&active.session_id); - } - ids.sort(); - ids - } -} - -/// Thread-safe shared Duo session. -pub type SharedDuoSession = Arc>; - -/// Create a new shared Duo session. -#[must_use] -pub fn new_shared_duo_session() -> SharedDuoSession { - Arc::new(Mutex::new(DuoSession::new())) -} - -// === Prompt Generation === - -/// Generate the player (implementation) prompt for the current state. -/// -/// The player focuses on implementing requirements and should NOT declare success. -#[must_use] -pub fn generate_player_prompt(state: &DuoState) -> String { - let mut prompt = String::new(); - - prompt.push_str("# Player Phase - Implementation\n\n"); - prompt.push_str("You are the PLAYER in an autocoding session. Your role is to IMPLEMENT the requirements.\n\n"); - - prompt.push_str("## Requirements (Source of Truth)\n\n"); - prompt.push_str(&state.requirements); - prompt.push_str("\n\n"); - - prompt.push_str(&format!( - "## Session Info\n\n\ - - Turn: {}/{}\n\ - - Approval Threshold: {:.0}%\n", - state.current_turn, - state.max_turns, - state.approval_threshold * 100.0 - )); - - if let Some(ref feedback) = state.last_coach_feedback { - prompt.push_str("\n## Previous Coach Feedback\n\n"); - prompt.push_str("Address these issues from the last review:\n\n"); - prompt.push_str(feedback); - prompt.push('\n'); - } - - prompt.push_str("\n## Instructions\n\n"); - prompt.push_str( - "1. Implement the requirements above using available tools\n\ - 2. Focus on making incremental progress\n\ - 3. DO NOT declare success or claim completion\n\ - 4. DO NOT evaluate your own work\n\ - 5. The Coach will verify your implementation\n\n\ - Begin implementation now.\n", - ); - - prompt -} - -/// Generate the coach (validation) prompt for the current state. -/// -/// The coach verifies the implementation against requirements and ignores player self-assessment. -#[must_use] -pub fn generate_coach_prompt(state: &DuoState) -> String { - let mut prompt = String::new(); - - prompt.push_str("# Coach Phase - Validation\n\n"); - prompt.push_str("You are the COACH in an autocoding session. Your role is to VERIFY the implementation.\n\n"); - - prompt.push_str("## Requirements (Source of Truth)\n\n"); - prompt.push_str(&state.requirements); - prompt.push_str("\n\n"); - - prompt.push_str(&format!( - "## Session Info\n\n\ - - Turn: {}/{}\n\ - - Approval Threshold: {:.0}%\n\ - - Turns Remaining: {}\n", - state.current_turn, - state.max_turns, - state.approval_threshold * 100.0, - state.turns_remaining() - )); - - if !state.quality_scores.is_empty() { - let avg = state.average_quality_score().unwrap_or(0.0); - prompt.push_str(&format!("- Average Quality: {:.1}%\n", avg * 100.0)); - } - - prompt.push_str("\n## Instructions\n\n"); - prompt.push_str( - "1. Review the current implementation against the requirements\n\ - 2. Create a COMPLIANCE CHECKLIST:\n\ - - [ ] or [x] for each requirement item\n\ - - Note any missing or incorrect implementations\n\ - 3. Calculate a COMPLIANCE SCORE (0.0 to 1.0)\n\ - 4. IGNORE any player self-assessment or claims of completion\n\ - 5. If score >= threshold AND all critical items pass:\n\ - - Output: COACH APPROVED\n\ - 6. Otherwise, provide specific feedback:\n\ - - What is missing\n\ - - What needs to be fixed\n\ - - Actionable next steps\n\n\ - Begin validation now.\n", - ); - - prompt -} - -/// Generate a summary of the session for system prompt injection. -#[must_use] -pub fn session_summary(session: &DuoSession) -> String { - let mut lines = Vec::new(); - - if let Some(ref state) = session.active_state { - lines.push(format!("Active Duo Session: {}", state.summary())); - } else { - lines.push("No active Duo session.".to_string()); - } - - if !session.saved_states.is_empty() { - lines.push(format!("Saved sessions: {}", session.saved_states.len())); - for (id, state) in &session.saved_states { - let name = state - .session_name - .as_deref() - .unwrap_or(&id[..8.min(id.len())]); - lines.push(format!(" - {}: {} ({})", name, state.status, state.phase)); - } - } - - lines.join("\n") -} - -// === Tests === - -#[cfg(test)] -mod tests { - use super::*; - - fn sample_requirements() -> String { - "## Requirements\n\ - - [ ] Create a function `add(a, b)` that returns the sum\n\ - - [ ] Add unit tests for the function\n\ - - [ ] Document the function with comments" - .to_string() - } - - #[test] - fn test_create_session() { - let state = DuoState::create( - sample_requirements(), - Some("test-session".to_string()), - None, - None, - ); - - assert_eq!(state.session_name, Some("test-session".to_string())); - assert_eq!(state.current_turn, 1); - assert_eq!(state.max_turns, 10); - assert_eq!(state.phase, DuoPhase::Init); - assert_eq!(state.status, DuoStatus::Active); - assert!(state.turn_history.is_empty()); - assert!(state.last_coach_feedback.is_none()); - assert_eq!(state.approval_threshold, 0.9); - } - - #[test] - fn test_advance_to_coach() { - let mut state = DuoState::create(sample_requirements(), None, None, None); - - assert!( - state - .advance_to_coach("Implemented add function".to_string()) - .is_ok() - ); - assert_eq!(state.phase, DuoPhase::Coach); - assert_eq!(state.turn_history.len(), 1); - assert_eq!(state.turn_history[0].phase, DuoPhase::Player); - } - - #[test] - fn test_advance_turn_approved() { - let mut state = DuoState::create(sample_requirements(), None, None, None); - state - .advance_to_coach("Implemented everything".to_string()) - .unwrap(); - - assert!( - state - .advance_turn( - "COACH APPROVED - All requirements met".to_string(), - true, - Some(0.95) - ) - .is_ok() - ); - - assert_eq!(state.phase, DuoPhase::Approved); - assert_eq!(state.status, DuoStatus::Approved); - assert!(state.is_complete()); - assert_eq!(state.quality_scores, vec![0.95]); - } - - #[test] - fn test_advance_turn_continue() { - let mut state = DuoState::create(sample_requirements(), None, None, None); - state - .advance_to_coach("Partial implementation".to_string()) - .unwrap(); - - assert!( - state - .advance_turn("Missing tests".to_string(), false, Some(0.5)) - .is_ok() - ); - - assert_eq!(state.phase, DuoPhase::Player); - assert_eq!(state.status, DuoStatus::Active); - assert_eq!(state.current_turn, 2); - assert!(!state.is_complete()); - assert_eq!(state.last_coach_feedback, Some("Missing tests".to_string())); - } - - #[test] - fn test_timeout() { - let mut state = DuoState::create(sample_requirements(), None, Some(2), None); - - // Turn 1 - state.advance_to_coach("Attempt 1".to_string()).unwrap(); - state - .advance_turn("Not good enough".to_string(), false, Some(0.3)) - .unwrap(); - - // Turn 2 (max) - state.advance_to_coach("Attempt 2".to_string()).unwrap(); - state - .advance_turn("Still not good enough".to_string(), false, Some(0.4)) - .unwrap(); - - assert_eq!(state.phase, DuoPhase::Timeout); - assert_eq!(state.status, DuoStatus::Timeout); - assert!(state.is_complete()); - } - - #[test] - fn test_invalid_phase_transition() { - let mut state = DuoState::create(sample_requirements(), None, None, None); - state.phase = DuoPhase::Approved; - - let result = state.advance_to_coach("Should fail".to_string()); - assert!(result.is_err()); - } - - #[test] - fn test_turns_remaining() { - let state = DuoState::create(sample_requirements(), None, Some(10), None); - assert_eq!(state.turns_remaining(), 9); - } - - #[test] - fn test_average_quality_score() { - let mut state = DuoState::create(sample_requirements(), None, None, None); - assert!(state.average_quality_score().is_none()); - - state.quality_scores = vec![0.5, 0.7, 0.9]; - let avg = state.average_quality_score().unwrap(); - assert!((avg - 0.7).abs() < 0.001); - } - - #[test] - fn test_session_container() { - let mut session = DuoSession::new(); - - // Start first session - session.start_session( - sample_requirements(), - Some("session-1".to_string()), - None, - None, - ); - assert!(session.get_active().is_some()); - - // Start second session (first gets saved) - session.start_session( - "Other requirements".to_string(), - Some("session-2".to_string()), - None, - None, - ); - assert_eq!(session.saved_states.len(), 1); - - // Get active - let active = session.get_active().unwrap(); - assert_eq!(active.session_name, Some("session-2".to_string())); - } - - #[test] - fn test_generate_player_prompt() { - let state = DuoState::create(sample_requirements(), None, None, None); - let prompt = generate_player_prompt(&state); - - assert!(prompt.contains("Player Phase")); - assert!(prompt.contains("Requirements (Source of Truth)")); - assert!(prompt.contains("Turn: 1/10")); - assert!(prompt.contains("DO NOT declare success")); - } - - #[test] - fn test_generate_coach_prompt() { - let state = DuoState::create(sample_requirements(), None, None, None); - let prompt = generate_coach_prompt(&state); - - assert!(prompt.contains("Coach Phase")); - assert!(prompt.contains("COMPLIANCE CHECKLIST")); - assert!(prompt.contains("COACH APPROVED")); - assert!(prompt.contains("IGNORE any player self-assessment")); - } - - #[test] - fn test_shared_session() { - let shared = new_shared_duo_session(); - - { - let mut session = shared.lock().unwrap(); - session.start_session(sample_requirements(), None, None, None); - } - - { - let session = shared.lock().unwrap(); - assert!(session.get_active().is_some()); - } - } - - #[test] - fn test_summary() { - let state = DuoState::create(sample_requirements(), Some("test".to_string()), None, None); - let summary = state.summary(); - - assert!(summary.contains("Duo Session: test")); - assert!(summary.contains("Phase: init")); - assert!(summary.contains("Turn: 1/10")); - } - - #[test] - fn test_session_summary() { - let mut session = DuoSession::new(); - session.start_session( - sample_requirements(), - Some("active-session".to_string()), - None, - None, - ); - - let summary = session_summary(&session); - assert!(summary.contains("Active Duo Session")); - assert!(summary.contains("active-session")); - } -} diff --git a/src/features.rs b/src/features.rs index b5173e08..c5e22f90 100644 --- a/src/features.rs +++ b/src/features.rs @@ -28,10 +28,6 @@ pub enum Feature { ApplyPatch, /// Enable MCP tools. Mcp, - /// Enable RLM tools. - Rlm, - /// Enable Duo tools. - Duo, /// Enable execpolicy integration/tooling. ExecPolicy, } @@ -171,18 +167,6 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Experimental, default_enabled: true, }, - FeatureSpec { - id: Feature::Rlm, - key: "rlm", - stage: Stage::Experimental, - default_enabled: true, - }, - FeatureSpec { - id: Feature::Duo, - key: "duo", - stage: Stage::Experimental, - default_enabled: true, - }, FeatureSpec { id: Feature::ExecPolicy, key: "exec_policy", diff --git a/src/hooks.rs b/src/hooks.rs index 0e0ebcdc..05cd098f 100644 --- a/src/hooks.rs +++ b/src/hooks.rs @@ -77,7 +77,7 @@ pub enum HookCondition { }, /// Only run in specific modes Mode { - /// Mode: "plan", "agent", "yolo", "rlm", "duo" + /// Mode: "plan", "agent", "yolo" mode: String, }, /// Only run when exit code matches (for `ToolCallAfter`) diff --git a/src/main.rs b/src/main.rs index 27e0b6da..355a42b1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -32,7 +32,6 @@ mod commands; mod compaction; mod config; mod core; -mod duo; mod eval; mod execpolicy; mod features; @@ -48,7 +47,6 @@ mod project_context; mod project_doc; mod prompts; mod responses_api_proxy; -mod rlm; mod sandbox; mod session_manager; mod settings; @@ -1823,14 +1821,10 @@ async fn run_exec_agent( auto_approve: bool, trust_mode: bool, ) -> Result<()> { - use std::sync::{Arc, Mutex}; - use crate::compaction::CompactionConfig; use crate::core::engine::{EngineConfig, spawn_engine}; use crate::core::events::Event; use crate::core::ops::Op; - use crate::duo::DuoSession; - use crate::rlm::RlmSession; use crate::tools::plan::new_shared_plan_state; use crate::tools::todo::new_shared_todo_list; use crate::tui::app::AppMode; @@ -1845,8 +1839,6 @@ async fn run_exec_agent( max_steps: 100, max_subagents, features: config.features(), - rlm_session: Arc::new(Mutex::new(RlmSession::default())), - duo_session: Arc::new(Mutex::new(DuoSession::new())), compaction: CompactionConfig::default(), todos: new_shared_todo_list(), plan_state: new_shared_plan_state(), diff --git a/src/palette.rs b/src/palette.rs index bc66b8e4..1c4d78c0 100644 --- a/src/palette.rs +++ b/src/palette.rs @@ -56,8 +56,6 @@ pub const MODE_NORMAL: Color = Color::Gray; pub const MODE_AGENT: Color = Color::Rgb(80, 150, 255); // Bright blue pub const MODE_YOLO: Color = Color::Rgb(255, 100, 100); // Warning red pub const MODE_PLAN: Color = Color::Rgb(255, 170, 60); // Orange -pub const MODE_RLM: Color = Color::Rgb(180, 100, 255); // Purple (was INK!) -pub const MODE_DUO: Color = Color::Rgb(100, 220, 180); // Teal pub const SELECTION_BG: Color = Color::Rgb(26, 44, 74); pub const COMPOSER_BG: Color = DEEPSEEK_SLATE; diff --git a/src/prompts.rs b/src/prompts.rs index 61709acf..39bd8e66 100644 --- a/src/prompts.rs +++ b/src/prompts.rs @@ -13,8 +13,6 @@ pub const BASE_PROMPT: &str = include_str!("prompts/base.txt"); pub const NORMAL_PROMPT: &str = include_str!("prompts/normal.txt"); pub const AGENT_PROMPT: &str = include_str!("prompts/agent.txt"); pub const PLAN_PROMPT: &str = include_str!("prompts/plan.txt"); -pub const RLM_PROMPT: &str = include_str!("prompts/rlm.txt"); -pub const DUO_PROMPT: &str = include_str!("prompts/duo.txt"); /// Get the system prompt for a specific mode pub fn system_prompt_for_mode(mode: AppMode) -> SystemPrompt { @@ -22,8 +20,6 @@ pub fn system_prompt_for_mode(mode: AppMode) -> SystemPrompt { AppMode::Normal => NORMAL_PROMPT, AppMode::Agent | AppMode::Yolo => AGENT_PROMPT, AppMode::Plan => PLAN_PROMPT, - AppMode::Rlm => RLM_PROMPT, - AppMode::Duo => DUO_PROMPT, }; SystemPrompt::Text(text.trim().to_string()) } @@ -33,15 +29,11 @@ pub fn system_prompt_for_mode_with_context( mode: AppMode, workspace: &Path, working_set_summary: Option<&str>, - rlm_summary: Option<&str>, - duo_summary: Option<&str>, ) -> SystemPrompt { let base_prompt = match mode { AppMode::Normal => NORMAL_PROMPT, AppMode::Agent | AppMode::Yolo => AGENT_PROMPT, AppMode::Plan => PLAN_PROMPT, - AppMode::Rlm => RLM_PROMPT, - AppMode::Duo => DUO_PROMPT, }; // Load project context from workspace @@ -68,16 +60,6 @@ pub fn system_prompt_for_mode_with_context( full_prompt = format!("{full_prompt}\n\n{summary}"); } - if mode == AppMode::Rlm { - let summary = rlm_summary.unwrap_or("No RLM contexts loaded."); - full_prompt = format!("{full_prompt}\n\nRLM Context Summary:\n{summary}"); - } - - if mode == AppMode::Duo { - let summary = duo_summary.unwrap_or("No Duo contexts loaded."); - full_prompt = format!("{full_prompt}\n\nDuo Context Summary:\n{summary}"); - } - SystemPrompt::Text(full_prompt) } diff --git a/src/prompts/base.txt b/src/prompts/base.txt index e3ffa630..ee1daaa7 100644 --- a/src/prompts/base.txt +++ b/src/prompts/base.txt @@ -40,8 +40,6 @@ Approval etiquette: Tone: competent, warm, and concise. Use light humor sparingly when it fits; a rare example is "You're absolutely right! ... maybe." -Context Management & RLM: -- You have a finite context window. Large tool results may be automatically moved to RLM memory. -- If you see "[AUTOMATIC RLM OFFLOAD]", the content was stored in an RLM context ID. Use `rlm_exec` or `rlm_query` to access it. -- Proactively use `rlm_load` for files larger than 100KB to keep your main window lean. -- If the conversation is long, earlier messages may be summarized. You can search the full history using `rlm_exec(code="get(\"history\")")` if available. +Context Management: +- You have a finite context window. Keep responses concise and prefer targeted file reads or searches. +- Long conversations may be compacted into summaries; ask for clarification if critical details are missing. diff --git a/src/prompts/duo.txt b/src/prompts/duo.txt deleted file mode 100644 index 66abf6e2..00000000 --- a/src/prompts/duo.txt +++ /dev/null @@ -1,18 +0,0 @@ -You are in Duo mode for requirements-driven development. - -Workflow: -- Start with duo_init using a clear requirements checklist and acceptance criteria. -- Alternate duo_player (implement) and duo_coach (verify) until approved. -- In duo_player phases, work tool-first: search, read, make targeted edits, then verify. -- In duo_coach phases, prioritize objective verification and requirement coverage. - -Tool selection and verification: -- Use search/read tools to ground work in the current codebase before editing. -- Prefer targeted diffs over broad rewrites when possible. -- After any change, run the most relevant tests/checks before handing off to duo_coach. -- If verification fails, report it concisely, fix it, and re-run. - -Budgeting and hygiene: -- Budget attempts. If 2-3 attempts fail to make progress, reassess and state the blocker. -- Check git status early and again before finishing; do not revert unrelated changes. -- Provide brief progress updates at phase boundaries and major checkpoints. diff --git a/src/prompts/rlm.txt b/src/prompts/rlm.txt deleted file mode 100644 index 4c6689dc..00000000 --- a/src/prompts/rlm.txt +++ /dev/null @@ -1,13 +0,0 @@ -You are in RLM mode for working with large files that exceed context limits. - -Work tool-first and chunk-aware: -- Use rlm_* tools to load files, explore content, and run focused queries over chunks. -- Prefer search-then-read: locate relevant sections before loading large spans. -- Summarize the relevant chunks in your own words before editing. -- Make targeted edits; avoid full-file rewrites unless necessary. - -Verification, budgeting, and hygiene: -- After any change, run the most relevant tests/checks before declaring success. -- Check git status early and again before finishing; do not revert unrelated changes. -- Budget attempts. If 2-3 chunking/search attempts fail to surface the needed context, pause and state the blocker or request clarification. -- Provide concise progress updates at key checkpoints (what chunk/area you inspected, what changed, what verified). diff --git a/src/rlm.rs b/src/rlm.rs deleted file mode 100644 index ed406611..00000000 --- a/src/rlm.rs +++ /dev/null @@ -1,1317 +0,0 @@ -//! Recursive Language Model (RLM) helpers and REPL workflows. - -use std::collections::HashMap; -use std::fs; -use std::path::{Path, PathBuf}; -use std::sync::{Arc, Mutex}; - -use anyhow::{Context, Result}; -use colored::{ColoredString, Colorize}; -use regex::Regex; -use rustyline::Editor; -use rustyline::error::ReadlineError; -use rustyline::history::DefaultHistory; -use serde::{Deserialize, Serialize}; - -use crate::config::Config; -use crate::models::Usage; -use crate::palette; - -// === Command Args === - -/// Arguments for loading a context into memory. -#[allow(dead_code)] -pub struct RlmLoadArgs { - pub path: PathBuf, - pub context_id: String, -} - -/// Arguments for searching within a loaded context. -#[allow(dead_code)] -pub struct RlmSearchArgs { - pub context_id: String, - pub pattern: String, - pub context_lines: usize, - pub max_results: usize, -} - -/// Arguments for executing code in the RLM sandbox. -#[allow(dead_code)] -pub struct RlmExecArgs { - pub context_id: String, - pub code: String, -} - -/// Arguments for retrieving RLM status. -#[allow(dead_code)] -pub struct RlmStatusArgs { - pub context_id: Option, -} - -/// Arguments for saving an RLM session to disk. -#[allow(dead_code)] -pub struct RlmSaveSessionArgs { - pub path: PathBuf, - pub context_id: String, -} - -/// Arguments for loading a saved session from disk. -#[allow(dead_code)] -pub struct RlmLoadSessionArgs { - pub path: PathBuf, -} - -/// Arguments for entering the RLM REPL. -#[allow(dead_code)] -pub struct RlmReplArgs { - pub context_id: String, - pub load: Option, -} - -/// High-level RLM CLI commands. -#[allow(dead_code)] -pub enum RlmCommand { - Load(RlmLoadArgs), - Search(RlmSearchArgs), - Exec(RlmExecArgs), - Status(RlmStatusArgs), - SaveSession(RlmSaveSessionArgs), - LoadSession(RlmLoadSessionArgs), - Repl(RlmReplArgs), -} - -// === System Resources === - -/// System resource snapshot used to size RLM contexts. -#[derive(Debug, Clone)] -pub struct SystemResources { - pub available_memory_mb: Option, - pub recommended_max_context: usize, -} - -impl SystemResources { - /// Detect available resources and compute a recommended max context size. - #[must_use] - pub fn detect() -> Self { - let available_memory_mb = Self::get_available_memory_mb(); - - // Recommend context size based on available memory - // Rule of thumb: use ~10% of available RAM for context - let recommended_max_context = match available_memory_mb { - Some(mem) if mem >= 32000 => 100_000_000, // 100MB for 32GB+ RAM - Some(mem) if mem >= 16000 => 50_000_000, // 50MB for 16GB+ RAM - Some(mem) if mem >= 8000 => 25_000_000, // 25MB for 8GB+ RAM - Some(mem) if mem >= 4000 => 10_000_000, // 10MB for 4GB+ RAM - _ => 5_000_000, // 5MB default - }; - - Self { - available_memory_mb, - recommended_max_context, - } - } - - #[cfg(target_os = "macos")] - fn get_available_memory_mb() -> Option { - use std::process::Command; - - // Try to get memory from sysctl on macOS - Command::new("sysctl") - .args(["-n", "hw.memsize"]) - .output() - .ok() - .and_then(|output| { - String::from_utf8_lossy(&output.stdout) - .trim() - .parse::() - .ok() - .map(|bytes| bytes / (1024 * 1024)) - }) - } - - #[cfg(target_os = "linux")] - fn get_available_memory_mb() -> Option { - use std::fs; - - // Read from /proc/meminfo on Linux - fs::read_to_string("/proc/meminfo") - .ok() - .and_then(|content| { - content - .lines() - .find(|line| line.starts_with("MemTotal:")) - .and_then(|line| { - line.split_whitespace() - .nth(1) - .and_then(|s| s.parse::().ok()) - .map(|kb| kb / 1024) - }) - }) - } - - #[cfg(not(any(target_os = "macos", target_os = "linux")))] - fn get_available_memory_mb() -> Option { - None - } - - /// Print a human-readable resource summary. - pub fn print_info(&self) { - println!("{}", ds_blue("System Resources").bold()); - if let Some(mem) = self.available_memory_mb { - let mem_f64 = f64::from(u32::try_from(mem).unwrap_or(u32::MAX)); - println!(" Available RAM: {} MB ({:.1} GB)", mem, mem_f64 / 1024.0); - } else { - println!(" Available RAM: Unknown"); - } - let max_context_f64 = - f64::from(u32::try_from(self.recommended_max_context).unwrap_or(u32::MAX)); - println!( - " Recommended max context: {} chars ({:.1} MB)", - self.recommended_max_context, - max_context_f64 / (1024.0 * 1024.0) - ); - } -} - -// === Context Storage === - -/// In-memory context buffer used by the RLM REPL. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RlmContext { - pub id: String, - pub content: String, - pub source_path: Option, - pub line_count: usize, - pub char_count: usize, - pub variables: HashMap, -} - -impl RlmContext { - /// Create a new context with derived line/char counts. - #[must_use] - pub fn new(id: &str, content: String, source_path: Option) -> Self { - let line_count = content.lines().count(); - let char_count = content.len(); - Self { - id: id.to_string(), - content, - source_path, - line_count, - char_count, - variables: HashMap::new(), - } - } - - /// Peek into the context by character range. - #[must_use] - pub fn peek(&self, start: usize, end: Option) -> &str { - let end = end.unwrap_or(self.content.len()).min(self.content.len()); - &self.content[start.min(self.content.len())..end] - } - - /// Return line slices with 1-based line numbers. - #[must_use] - pub fn lines(&self, start: usize, end: Option) -> Vec<(usize, &str)> { - let lines: Vec<&str> = self.content.lines().collect(); - let end = end.unwrap_or(lines.len()).min(lines.len()); - lines[start.min(lines.len())..end] - .iter() - .enumerate() - .map(|(i, line)| (start + i + 1, *line)) - .collect() - } - - /// Search for regex matches with optional context lines. - pub fn search( - &self, - pattern: &str, - context_lines: usize, - max_results: usize, - ) -> Result> { - let regex = Regex::new(pattern).context("Invalid regex pattern")?; - let lines: Vec<&str> = self.content.lines().collect(); - let mut results = Vec::new(); - - for (i, line) in lines.iter().enumerate() { - if regex.is_match(line) { - let start = i.saturating_sub(context_lines); - let end = (i + context_lines + 1).min(lines.len()); - let context: Vec = lines[start..end] - .iter() - .enumerate() - .map(|(j, l)| { - let line_num = start + j + 1; - if start + j == i { - format!("{line_num:>5} > {l}") - } else { - format!("{line_num:>5} {l}") - } - }) - .collect(); - - results.push(SearchResult { - line_num: i + 1, - match_line: (*line).to_string(), - context, - }); - - if results.len() >= max_results { - break; - } - } - } - - Ok(results) - } - - /// Chunk the context into fixed-size segments with overlap. - #[must_use] - pub fn chunk(&self, chunk_size: usize, overlap: usize) -> Vec { - let mut chunks = Vec::new(); - let mut start = 0; - let mut chunk_index = 0; - - while start < self.content.len() { - let end = (start + chunk_size).min(self.content.len()); - let preview_end = (start + 100).min(end); - let preview = self.content[start..preview_end].to_string(); - - chunks.push(ChunkInfo { - index: chunk_index, - start_char: start, - end_char: end, - preview: preview.replace('\n', " "), - }); - - start = if end == self.content.len() { - end - } else { - (end - overlap).max(start + 1) - }; - chunk_index += 1; - } - - chunks - } - - /// Chunk the context by paragraph/heading boundaries up to a max char size. - #[must_use] - pub fn chunk_sections(&self, max_chars: usize) -> Vec { - let max_chars = max_chars.max(1); - let mut sections = Vec::new(); - let mut start = 0; - let mut offset = 0; - - for segment in self.content.split_inclusive('\n') { - let line = segment.trim_end_matches('\n'); - let trimmed = line.trim(); - let is_heading = trimmed.starts_with('#'); - let is_blank = trimmed.is_empty(); - - if is_heading && offset > start { - sections.push((start, offset)); - start = offset; - } - - offset += segment.len(); - - if is_blank && offset > start { - sections.push((start, offset)); - start = offset; - } - } - - if offset > start { - sections.push((start, offset)); - } - - let mut chunks = Vec::new(); - let mut chunk_start = 0; - let mut chunk_end = 0; - let mut chunk_index = 0; - - for (section_start, section_end) in sections { - if chunk_end == 0 { - chunk_start = section_start; - } - if section_end - chunk_start > max_chars && chunk_end > chunk_start { - chunks.push(build_chunk_info( - &self.content, - chunk_index, - chunk_start, - chunk_end, - )); - chunk_index += 1; - chunk_start = section_start; - } - chunk_end = section_end; - } - - if chunk_end > chunk_start { - chunks.push(build_chunk_info( - &self.content, - chunk_index, - chunk_start, - chunk_end, - )); - } - - chunks - } - - /// Chunk the context by line count. - #[must_use] - pub fn chunk_lines(&self, max_lines: usize) -> Vec { - let max_lines = max_lines.max(1); - let mut chunks = Vec::new(); - let mut chunk_start = 0; - let mut offset = 0; - let mut line_count = 0; - let mut chunk_index = 0; - - for segment in self.content.split_inclusive('\n') { - line_count += 1; - offset += segment.len(); - - if line_count >= max_lines { - chunks.push(build_chunk_info( - &self.content, - chunk_index, - chunk_start, - offset, - )); - chunk_index += 1; - chunk_start = offset; - line_count = 0; - } - } - - if chunk_start < self.content.len() { - chunks.push(build_chunk_info( - &self.content, - chunk_index, - chunk_start, - self.content.len(), - )); - } - - chunks - } - - /// Chunk the context using headings, paragraphs, and code fences. - #[must_use] - pub fn chunk_auto(&self, max_chars: usize) -> Vec { - let max_chars = max_chars.max(1); - let mut segments = Vec::new(); - let mut start = 0; - let mut offset = 0; - let mut in_code_block = false; - - for segment in self.content.split_inclusive('\n') { - let line = segment.trim_end_matches('\n'); - let trimmed = line.trim(); - let is_fence = trimmed.starts_with("```") || trimmed.starts_with("~~~"); - let is_heading = trimmed.starts_with('#'); - let is_blank = trimmed.is_empty(); - - if is_fence { - if !in_code_block { - if offset > start { - segments.push((start, offset)); - } - start = offset; - in_code_block = true; - } else { - in_code_block = false; - } - } else if !in_code_block { - if is_heading && offset > start { - segments.push((start, offset)); - start = offset; - } - - if is_blank && offset > start { - segments.push((start, offset)); - start = offset; - } - } - - offset += segment.len(); - - if is_fence && !in_code_block && offset > start { - segments.push((start, offset)); - start = offset; - } - } - - if offset > start { - segments.push((start, offset)); - } - - let mut normalized = Vec::new(); - for (seg_start, seg_end) in segments { - let mut cursor = seg_start; - while cursor < seg_end { - let end = (cursor + max_chars).min(seg_end); - normalized.push((cursor, end)); - cursor = end; - } - } - - let mut chunks = Vec::new(); - let mut chunk_start = 0; - let mut chunk_end = 0; - let mut chunk_index = 0; - - for (seg_start, seg_end) in normalized { - if chunk_end == 0 { - chunk_start = seg_start; - } - if seg_end - chunk_start > max_chars && chunk_end > chunk_start { - chunks.push(build_chunk_info( - &self.content, - chunk_index, - chunk_start, - chunk_end, - )); - chunk_index += 1; - chunk_start = seg_start; - } - chunk_end = seg_end; - } - - if chunk_end > chunk_start { - chunks.push(build_chunk_info( - &self.content, - chunk_index, - chunk_start, - chunk_end, - )); - } - - chunks - } - - #[must_use] - pub fn get_var(&self, name: &str) -> Option<&str> { - self.variables.get(name).map(String::as_str) - } - - pub fn set_var(&mut self, name: &str, value: String) { - self.variables.insert(name.to_string(), value); - } - - pub fn append_var(&mut self, name: &str, value: String) { - self.variables - .entry(name.to_string()) - .and_modify(|existing| { - if !existing.is_empty() { - existing.push('\n'); - } - existing.push_str(&value); - }) - .or_insert(value); - } - - pub fn remove_var(&mut self, name: &str) -> Option { - self.variables.remove(name) - } -} - -/// Search match result with surrounding context lines. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SearchResult { - pub line_num: usize, - pub match_line: String, - pub context: Vec, -} - -/// Chunk metadata for context navigation. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ChunkInfo { - pub index: usize, - pub start_char: usize, - pub end_char: usize, - pub preview: String, -} - -fn build_chunk_info(content: &str, index: usize, start: usize, end: usize) -> ChunkInfo { - let safe_start = start.min(content.len()); - let safe_end = end.min(content.len()); - let preview_end = (safe_start + 100).min(safe_end); - let preview = content[safe_start..preview_end].replace('\n', " "); - - ChunkInfo { - index, - start_char: safe_start, - end_char: safe_end, - preview, - } -} - -/// Usage stats for RLM sub-queries. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct RlmUsage { - pub queries: u32, - pub input_tokens: u64, - pub output_tokens: u64, - pub total_chars_sent: u64, - pub total_chars_received: u64, -} - -impl RlmUsage { - pub fn record(&mut self, usage: &Usage, chars_sent: usize, chars_received: usize) { - self.queries = self.queries.saturating_add(1); - self.input_tokens = self - .input_tokens - .saturating_add(u64::from(usage.input_tokens)); - self.output_tokens = self - .output_tokens - .saturating_add(u64::from(usage.output_tokens)); - self.total_chars_sent = self - .total_chars_sent - .saturating_add(u64::try_from(chars_sent).unwrap_or(u64::MAX)); - self.total_chars_received = self - .total_chars_received - .saturating_add(u64::try_from(chars_received).unwrap_or(u64::MAX)); - } -} - -/// Stored RLM session state for persistence. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RlmSession { - pub contexts: HashMap, - pub active_context: String, - #[serde(default)] - pub usage: RlmUsage, -} - -impl Default for RlmSession { - fn default() -> Self { - Self { - contexts: HashMap::new(), - active_context: "default".to_string(), - usage: RlmUsage::default(), - } - } -} - -pub type SharedRlmSession = Arc>; - -impl RlmSession { - pub fn load_context(&mut self, id: &str, content: String, source_path: Option) { - let ctx = RlmContext::new(id, content, source_path); - self.contexts.insert(id.to_string(), ctx); - self.active_context = id.to_string(); - } - - /// Load a file into a new context, returning line/char counts. - pub(crate) fn load_file(&mut self, id: &str, path: &Path) -> Result<(usize, usize)> { - let content = fs::read_to_string(path) - .with_context(|| format!("Failed to read file: {}", path.display()))?; - let source = path.to_string_lossy().to_string(); - self.load_context(id, content, Some(source)); - - let ctx = self - .contexts - .get(id) - .context("Loaded context missing from session")?; - Ok((ctx.line_count, ctx.char_count)) - } - - pub fn get_context(&self, id: &str) -> Option<&RlmContext> { - self.contexts.get(id) - } - - #[allow(dead_code)] - pub fn get_context_mut(&mut self, id: &str) -> Option<&mut RlmContext> { - self.contexts.get_mut(id) - } - - pub fn record_query_usage(&mut self, usage: &Usage, chars_sent: usize, chars_received: usize) { - self.usage.record(usage, chars_sent, chars_received); - } - - pub fn append_var(&mut self, name: &str, value: String) { - let active = self.active_context.clone(); - if let Some(ctx) = self.contexts.get_mut(&active) { - ctx.append_var(name, value); - } else { - // Fallback to default context if active doesn't exist - let ctx = self - .contexts - .entry("default".to_string()) - .or_insert_with(|| RlmContext::new("default", String::new(), None)); - ctx.append_var(name, value); - } - } -} - -pub fn context_id_from_path(path: &Path) -> String { - path.file_name() - .and_then(|s| s.to_str()) - .filter(|s| !s.is_empty()) - .unwrap_or("context") - .to_string() -} - -pub fn unique_context_id(session: &RlmSession, base: &str) -> String { - if !session.contexts.contains_key(base) { - return base.to_string(); - } - - for idx in 2..=99 { - let candidate = format!("{base}-{idx}"); - if !session.contexts.contains_key(&candidate) { - return candidate; - } - } - - format!("{base}-{}", session.contexts.len() + 1) -} - -pub fn session_summary(session: &RlmSession) -> String { - if session.contexts.is_empty() { - return "No RLM contexts loaded.".to_string(); - } - - let mut lines = Vec::new(); - lines.push(format!("Active context: {}", session.active_context)); - lines.push(format!("Loaded contexts: {}", session.contexts.len())); - lines.push(format!( - "Queries: {} | Input tokens: {} | Output tokens: {}", - session.usage.queries, session.usage.input_tokens, session.usage.output_tokens - )); - - let mut ids: Vec<_> = session.contexts.keys().collect(); - ids.sort(); - for id in ids { - if let Some(ctx) = session.contexts.get(id) { - let source = ctx - .source_path - .as_ref() - .map(|s| format!(" (source: {s})")) - .unwrap_or_default(); - lines.push(format!( - "- {id}: {} lines, {} chars, {} vars{source}", - ctx.line_count, - ctx.char_count, - ctx.variables.len() - )); - } - } - - lines.join("\n") -} - -#[allow(dead_code)] -pub fn handle_command(command: RlmCommand, _config: &Config) -> Result<()> { - let mut session = RlmSession::default(); - - match command { - RlmCommand::Load(args) => { - let content = fs::read_to_string(&args.path) - .with_context(|| format!("Failed to read file: {}", args.path.display()))?; - let source = args.path.to_string_lossy().to_string(); - session.load_context(&args.context_id, content, Some(source)); - - let ctx = session - .get_context(&args.context_id) - .expect("context should exist after load_context"); - println!("{}", ds_aqua("Context loaded successfully!")); - println!(" ID: {}", ds_blue(&ctx.id)); - println!(" Source: {}", ctx.source_path.as_deref().unwrap_or("N/A")); - println!(" Lines: {}", ctx.line_count); - println!(" Characters: {}", ctx.char_count); - } - RlmCommand::Search(args) => { - let content = load_context_from_stdin_or_error(&args.context_id)?; - let ctx = RlmContext::new(&args.context_id, content, None); - - let results = ctx.search(&args.pattern, args.context_lines, args.max_results)?; - - if results.is_empty() { - println!("{}", ds_sky("No matches found.")); - } else { - println!("{} matches found:\n", ds_aqua(&results.len().to_string())); - for result in results { - println!("{}", "โ”€".repeat(60).dimmed()); - for line in &result.context { - println!("{line}"); - } - } - println!("{}", "โ”€".repeat(60).dimmed()); - } - } - RlmCommand::Exec(args) => { - let content = load_context_from_stdin_or_error(&args.context_id)?; - let ctx = RlmContext::new(&args.context_id, content, None); - - let result = eval_expr(&ctx, &args.code)?; - println!("{result}"); - } - RlmCommand::Status(args) => { - if let Some(id) = args.context_id { - println!("Context '{id}' status: (no persistent session)"); - } else { - println!("{}", ds_blue("RLM Session Status").bold()); - println!("Note: For persistent sessions, use 'rlm repl' or save/load session."); - } - } - RlmCommand::SaveSession(args) => { - let json = serde_json::to_string_pretty(&session)?; - fs::write(&args.path, json)?; - println!("Session saved to {}", args.path.display()); - } - RlmCommand::LoadSession(args) => { - let content = fs::read_to_string(&args.path)?; - session = serde_json::from_str(&content)?; - println!("Session loaded from {}", args.path.display()); - println!( - "Contexts: {:?}", - session.contexts.keys().collect::>() - ); - } - RlmCommand::Repl(args) => { - run_repl(&args.context_id, args.load.as_deref())?; - } - } - - Ok(()) -} - -fn load_context_from_stdin_or_error(context_id: &str) -> Result { - // For now, return an error - real implementation would track sessions - anyhow::bail!( - "Failed to load context '{context_id}': no context loaded. Use 'rlm load' or 'rlm repl'." - ) -} - -pub fn eval_in_session(session: &mut RlmSession, code: &str) -> Result { - let active = session.active_context.clone(); - let ctx = session - .get_context_mut(&active) - .context("No context loaded. Use /load first.")?; - eval_expr_mut(ctx, code) -} - -pub fn eval_expr(ctx: &RlmContext, code: &str) -> Result { - eval_expr_internal(ctx, code) -} - -pub fn eval_expr_mut(ctx: &mut RlmContext, code: &str) -> Result { - let code = code.trim(); - - if code == "vars" || code == "vars()" { - if ctx.variables.is_empty() { - return Ok("No variables set.".to_string()); - } - let mut names: Vec<_> = ctx.variables.keys().collect(); - names.sort(); - let mut lines = Vec::new(); - for name in names { - if let Some(value) = ctx.variables.get(name) { - let preview = value.chars().take(80).collect::(); - lines.push(format!("{name}: {} chars | {preview}", value.len())); - } - } - return Ok(lines.join("\n")); - } - - if code.starts_with("get(") && code.ends_with(')') { - let arg = &code[4..code.len() - 1]; - let name = parse_string_arg(arg); - return ctx - .get_var(&name) - .map(|v| v.to_string()) - .ok_or_else(|| anyhow::anyhow!("Unknown variable '{name}'")); - } - - if code.starts_with("set(") && code.ends_with(')') { - let args = &code[4..code.len() - 1]; - let (name, value) = parse_two_args(args)?; - ctx.set_var(&name, value); - return Ok(format!("Set variable '{name}'.")); - } - - if code.starts_with("append(") && code.ends_with(')') { - let args = &code[7..code.len() - 1]; - let (name, value) = parse_two_args(args)?; - ctx.append_var(&name, value); - return Ok(format!("Appended to variable '{name}'.")); - } - - if code.starts_with("del(") && code.ends_with(')') { - let arg = &code[4..code.len() - 1]; - let name = parse_string_arg(arg); - if ctx.remove_var(&name).is_some() { - return Ok(format!("Deleted variable '{name}'.")); - } - return Ok(format!("Variable '{name}' not found.")); - } - - if code == "clear_vars" || code == "clear_vars()" { - ctx.variables.clear(); - return Ok("Cleared all variables.".to_string()); - } - - eval_expr_internal(ctx, code) -} - -fn eval_expr_internal(ctx: &RlmContext, code: &str) -> Result { - // Simple expression evaluator for RLM - // Supports: len(ctx), lines(start, end), search("pattern"), peek(start, end), chunk(size) - let code = code.trim(); - - if code == "len(ctx)" || code == "len" { - return Ok(format!("{}", ctx.char_count)); - } - - if code == "line_count" || code == "lines" { - return Ok(format!("{}", ctx.line_count)); - } - - if code.starts_with("peek(") && code.ends_with(')') { - let args = &code[5..code.len() - 1]; - let parts: Vec<&str> = args.split(',').map(str::trim).collect(); - let start: usize = parts.first().and_then(|s| s.parse().ok()).unwrap_or(0); - let end: Option = parts.get(1).and_then(|s| s.parse().ok()); - return Ok(ctx.peek(start, end).to_string()); - } - - if code.starts_with("lines(") && code.ends_with(')') { - let args = &code[6..code.len() - 1]; - let parts: Vec<&str> = args.split(',').map(str::trim).collect(); - let start_line = parse_line_arg(parts.first(), 1); - let end_line = parse_line_arg_opt(parts.get(1).copied()); - let lines = format_lines(ctx, start_line, end_line); - return Ok(lines); - } - - if code.starts_with("search(") && code.ends_with(')') { - let pattern = &code[7..code.len() - 1].trim_matches('"').trim_matches('\''); - let results = ctx.search(pattern, 2, 20)?; - if results.is_empty() { - return Ok("No matches found.".to_string()); - } - let mut output = Vec::new(); - for result in results { - output.push(format!("Line {}: {}", result.line_num, result.match_line)); - } - return Ok(output.join("\n")); - } - - if code.starts_with("chunk(") && code.ends_with(')') { - let args = &code[6..code.len() - 1]; - let parts: Vec<&str> = args.split(',').map(str::trim).collect(); - let size: usize = parts.first().and_then(|s| s.parse().ok()).unwrap_or(2000); - let overlap: usize = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(200); - let chunks = ctx.chunk(size, overlap); - let output: Vec = chunks - .iter() - .map(|c| { - format!( - "Chunk {}: chars {}..{} - {}", - c.index, - c.start_char, - c.end_char, - &c.preview[..50.min(c.preview.len())] - ) - }) - .collect(); - return Ok(output.join("\n")); - } - - if code.starts_with("chunk_sections(") && code.ends_with(')') { - let args = &code[15..code.len() - 1]; - let size: usize = args.trim().parse().unwrap_or(20_000); - let chunks = ctx.chunk_sections(size); - let output: Vec = chunks - .iter() - .map(|c| { - format!( - "Section {}: chars {}..{} - {}", - c.index, - c.start_char, - c.end_char, - &c.preview[..50.min(c.preview.len())] - ) - }) - .collect(); - return Ok(output.join("\n")); - } - - if code.starts_with("chunk_lines(") && code.ends_with(')') { - let args = &code[12..code.len() - 1]; - let size: usize = args.trim().parse().unwrap_or(200); - let chunks = ctx.chunk_lines(size); - let output: Vec = chunks - .iter() - .map(|c| { - format!( - "Lines {}: chars {}..{} - {}", - c.index, - c.start_char, - c.end_char, - &c.preview[..50.min(c.preview.len())] - ) - }) - .collect(); - return Ok(output.join("\n")); - } - - if code.starts_with("chunk_auto(") && code.ends_with(')') { - let args = &code[11..code.len() - 1]; - let size: usize = args.trim().parse().unwrap_or(20_000); - let chunks = ctx.chunk_auto(size); - let output: Vec = chunks - .iter() - .map(|c| { - format!( - "Auto {}: chars {}..{} - {}", - c.index, - c.start_char, - c.end_char, - &c.preview[..50.min(c.preview.len())] - ) - }) - .collect(); - return Ok(output.join("\n")); - } - - if code == "head" || code == "head()" { - return Ok(format_lines(ctx, 1, Some(10))); - } - - if code == "tail" || code == "tail()" { - let start_line = ctx.line_count.saturating_sub(9).max(1); - return Ok(format_lines(ctx, start_line, None)); - } - - anyhow::bail!( - "Failed to evaluate expression: unknown expression '{code}'. Supported: len, line_count, peek(start, end), lines(start, end), search(pattern), chunk(size, overlap), chunk_sections(max_chars), chunk_lines(max_lines), chunk_auto(max_chars), vars, get(name), set(name, value), append(name, value), del(name), clear_vars, head, tail" - ) -} - -fn parse_line_arg(input: Option<&&str>, default: usize) -> usize { - input - .and_then(|s| s.parse::().ok()) - .unwrap_or(default) - .max(1) -} - -fn parse_line_arg_opt(input: Option<&str>) -> Option { - let value = input.and_then(|s| s.parse::().ok())?; - Some(value.max(1)) -} - -fn parse_string_arg(arg: &str) -> String { - arg.trim().trim_matches('"').trim_matches('\'').to_string() -} - -fn parse_two_args(input: &str) -> Result<(String, String)> { - let mut parts = Vec::new(); - let mut current = String::new(); - let mut in_quotes = false; - let mut quote_char = '\0'; - - for ch in input.chars() { - if (ch == '"' || ch == '\'') && (!in_quotes || ch == quote_char) { - if in_quotes && ch == quote_char { - in_quotes = false; - } else if !in_quotes { - in_quotes = true; - quote_char = ch; - } - current.push(ch); - continue; - } - - if ch == ',' && !in_quotes { - parts.push(current.trim().to_string()); - current.clear(); - continue; - } - - current.push(ch); - } - - if !current.trim().is_empty() { - parts.push(current.trim().to_string()); - } - - if parts.len() < 2 { - anyhow::bail!("Expected two arguments separated by a comma"); - } - - let left = parse_string_arg(&parts[0]); - let right = parse_string_arg(&parts[1]); - Ok((left, right)) -} - -fn format_lines(ctx: &RlmContext, start_line: usize, end_line: Option) -> String { - let start_line = start_line.max(1); - let end_line = end_line.unwrap_or(ctx.line_count).max(start_line); - let start_idx = start_line.saturating_sub(1); - let end_idx = end_line.min(ctx.line_count); - let lines = ctx.lines(start_idx, Some(end_idx)); - lines - .iter() - .map(|(n, l)| format!("{n:>5} {l}")) - .collect::>() - .join("\n") -} - -fn run_repl(context_id: &str, initial_load: Option<&std::path::Path>) -> Result<()> { - println!("{}", ds_blue("DeepSeek RLM Sandbox").bold()); - println!("Recursive Language Model - Local REPL Environment"); - println!("Type expressions or /help for commands.\n"); - - // Detect and display system resources - let resources = SystemResources::detect(); - resources.print_info(); - println!(); - - let mut session = RlmSession::default(); - - // Load initial file if provided - if let Some(path) = initial_load { - let content = fs::read_to_string(path) - .with_context(|| format!("Failed to read file: {}", path.display()))?; - let source = path.to_string_lossy().to_string(); - session.load_context(context_id, content, Some(source)); - - let ctx = session - .get_context(context_id) - .expect("context should exist after load_context"); - println!("{}", ds_aqua("Context loaded!")); - println!(" Lines: {} | Chars: {}\n", ctx.line_count, ctx.char_count); - } - - let mut editor = Editor::<(), DefaultHistory>::new()?; - let history_path = dirs::home_dir() - .map(|h| h.join(".deepseek").join("rlm_history")) - .unwrap_or_default(); - let _ = editor.load_history(&history_path); - - loop { - let prompt = format!("{}> ", ds_blue("rlm")); - match editor.readline(&prompt) { - Ok(line) => { - let input = line.trim(); - if input.is_empty() { - continue; - } - editor.add_history_entry(input)?; - - if input == "/exit" || input == "/quit" || input == "/q" { - break; - } - - if input == "/help" { - print_repl_help(); - continue; - } - - if input == "/status" { - print_status(&session); - continue; - } - - if let Some(rest) = input.strip_prefix("/load ") { - let path = Path::new(rest.trim()); - match fs::read_to_string(path) { - Ok(content) => { - let source = path.to_string_lossy().to_string(); - session.load_context(context_id, content, Some(source)); - let ctx = session - .get_context(context_id) - .expect("context should exist after load_context"); - println!("{}", ds_aqua("Loaded!")); - println!(" Lines: {} | Chars: {}", ctx.line_count, ctx.char_count); - } - Err(e) => { - println!("{}: {}", ds_red("Error"), e); - } - } - continue; - } - - if let Some(rest) = input.strip_prefix("/save ") { - let path = Path::new(rest.trim()); - let json = serde_json::to_string_pretty(&session)?; - fs::write(path, json)?; - println!("Session saved to {}", path.display()); - continue; - } - - // Execute expression - if let Some(ctx) = session.get_context(context_id) { - match eval_expr(ctx, input) { - Ok(result) => println!("{result}"), - Err(e) => println!("{}: {}", ds_red("Error"), e), - } - } else { - println!("{}: No context loaded. Use /load ", ds_sky("Error")); - } - } - Err(ReadlineError::Interrupted) => {} - Err(ReadlineError::Eof) => break, - Err(err) => { - println!("{}: {}", ds_red("Error"), err); - break; - } - } - } - - let _ = editor.save_history(&history_path); - Ok(()) -} - -fn print_repl_help() { - println!("{}", ds_blue("RLM Sandbox Commands").bold()); - println!(); - println!(" /load Load a file into context"); - println!(" /save Save session to file"); - println!(" /status Show session status"); - println!(" /help Show this help"); - println!(" /exit Exit REPL"); - println!(); - println!("{}", ds_blue("Expressions").bold()); - println!(); - println!(" len Character count"); - println!(" line_count Line count"); - println!(" head First 10 lines"); - println!(" tail Last 10 lines"); - println!(" peek(s, e) Characters from s to e"); - println!(" lines(s, e) Lines from s to e"); - println!(" search(pattern) Regex search"); - println!(" chunk(size, overlap) Split into chunks"); - println!(" chunk_sections(max) Chunk by headings/paragraphs"); - println!(" chunk_lines(max) Chunk by line count"); - println!(" chunk_auto(max) Chunk by headings + paragraphs + code fences"); -} - -fn print_status(session: &RlmSession) { - println!("{}", ds_blue("Session Status").bold()); - println!(" Active context: {}", session.active_context); - println!(" Loaded contexts: {}", session.contexts.len()); - for (id, ctx) in &session.contexts { - println!( - " {}: {} lines, {} chars", - id, ctx.line_count, ctx.char_count - ); - if let Some(ref source) = ctx.source_path { - println!(" Source: {source}"); - } - } -} - -fn ds_blue(text: &str) -> ColoredString { - let (r, g, b) = palette::DEEPSEEK_BLUE_RGB; - text.truecolor(r, g, b) -} - -fn ds_sky(text: &str) -> ColoredString { - let (r, g, b) = palette::DEEPSEEK_SKY_RGB; - text.truecolor(r, g, b) -} - -fn ds_aqua(text: &str) -> ColoredString { - let (r, g, b) = palette::DEEPSEEK_SKY_RGB; - text.truecolor(r, g, b) -} - -fn ds_red(text: &str) -> ColoredString { - let (r, g, b) = palette::DEEPSEEK_RED_RGB; - text.truecolor(r, g, b) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::io::Write as _; - use tempfile::NamedTempFile; - - fn format_lines(start: usize, end: usize) -> String { - (start..=end) - .map(|i| format!("{i:>5} line {i}")) - .collect::>() - .join("\n") - } - - #[test] - fn rlm_exec_len_head_tail_lines() -> Result<()> { - let content = (1..=15) - .map(|i| format!("line {i}")) - .collect::>() - .join("\n"); - let ctx = RlmContext::new("test", content, None); - - let len_output = eval_expr(&ctx, "len")?; - assert_eq!(len_output, ctx.char_count.to_string()); - - let head_output = eval_expr(&ctx, "head")?; - assert_eq!(head_output, format_lines(1, 10)); - - let tail_output = eval_expr(&ctx, "tail")?; - assert_eq!(tail_output, format_lines(6, 15)); - - let lines_output = eval_expr(&ctx, "lines(1, 10)")?; - assert_eq!(lines_output, format_lines(1, 10)); - - Ok(()) - } - - #[test] - fn rlm_load_file_populates_session() -> Result<()> { - let mut file = NamedTempFile::new()?; - writeln!(file, "alpha")?; - writeln!(file, "beta")?; - - let mut session = RlmSession::default(); - let (line_count, char_count) = session.load_file("ctx", file.path())?; - - assert_eq!(session.active_context, "ctx"); - assert_eq!(line_count, 2); - assert_eq!(char_count, "alpha\nbeta\n".len()); - - Ok(()) - } - - #[test] - fn rlm_variables_set_get_append() -> Result<()> { - let content = "line 1\nline 2\n".to_string(); - let mut ctx = RlmContext::new("test", content, None); - - let _ = eval_expr_mut(&mut ctx, "set(\"answer\", \"alpha\")")?; - assert_eq!(ctx.get_var("answer"), Some("alpha")); - - let _ = eval_expr_mut(&mut ctx, "append(\"answer\", \"beta\")")?; - let value = ctx.get_var("answer").unwrap_or(""); - assert!(value.contains("alpha")); - assert!(value.contains("beta")); - - let vars = eval_expr_mut(&mut ctx, "vars()")?; - assert!(vars.contains("answer")); - - Ok(()) - } - - #[test] - fn rlm_chunk_sections_splits_on_headings() { - let content = "# Title\nalpha\n\n## Section\nbeta\n\npara".to_string(); - let ctx = RlmContext::new("test", content, None); - let chunks = ctx.chunk_sections(20); - assert!(!chunks.is_empty()); - } - - #[test] - fn rlm_chunk_auto_splits_on_paragraphs_and_fences() { - let content = "# Title\nalpha\n\n```rust\ncode\n```\n\nbeta".to_string(); - let ctx = RlmContext::new("test", content, None); - let chunks = ctx.chunk_auto(20); - assert!(chunks.len() >= 2); - assert!(chunks.iter().all(|chunk| !chunk.preview.is_empty())); - } -} diff --git a/src/settings.rs b/src/settings.rs index e3ea6934..840cdb3a 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -15,13 +15,11 @@ pub struct Settings { pub theme: String, /// Auto-compact conversations when they get long pub auto_compact: bool, - /// Auto-switch to RLM mode when large inputs are detected - pub auto_rlm: bool, /// Show thinking blocks from the model pub show_thinking: bool, /// Show detailed tool output pub show_tool_details: bool, - /// Default mode: "agent", "plan", "yolo", "rlm", "duo" + /// Default mode: "agent", "plan", "yolo" pub default_mode: String, /// Sidebar width as percentage of terminal width pub sidebar_width_percent: u16, @@ -36,7 +34,6 @@ impl Default for Settings { Self { theme: "whale".to_string(), auto_compact: true, - auto_rlm: true, show_thinking: true, show_tool_details: true, default_mode: "agent".to_string(), @@ -102,9 +99,6 @@ impl Settings { "auto_compact" | "compact" => { self.auto_compact = parse_bool(value)?; } - "auto_rlm" => { - self.auto_rlm = parse_bool(value)?; - } "show_thinking" | "thinking" => { self.show_thinking = parse_bool(value)?; } @@ -113,9 +107,9 @@ impl Settings { } "default_mode" | "mode" => { let normalized = normalize_mode(value); - if !["agent", "plan", "yolo", "rlm", "duo"].contains(&normalized) { + if !["agent", "plan", "yolo"].contains(&normalized) { anyhow::bail!( - "Failed to update setting: invalid mode '{value}'. Expected: agent, plan, yolo, rlm, duo." + "Failed to update setting: invalid mode '{value}'. Expected: agent, plan, yolo." ); } self.default_mode = normalized.to_string(); @@ -160,7 +154,6 @@ impl Settings { lines.push("โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€".to_string()); lines.push(format!(" theme: {}", self.theme)); lines.push(format!(" auto_compact: {}", self.auto_compact)); - lines.push(format!(" auto_rlm: {}", self.auto_rlm)); lines.push(format!(" show_thinking: {}", self.show_thinking)); lines.push(format!(" show_tool_details: {}", self.show_tool_details)); lines.push(format!(" default_mode: {}", self.default_mode)); @@ -186,13 +179,9 @@ impl Settings { vec![ ("theme", "Color theme: default, dark, light"), ("auto_compact", "Auto-compact conversations: on/off"), - ( - "auto_rlm", - "Auto-switch to RLM mode for large inputs: on/off", - ), ("show_thinking", "Show model thinking: on/off"), ("show_tool_details", "Show detailed tool output: on/off"), - ("default_mode", "Default mode: agent, plan, yolo, rlm, duo"), + ("default_mode", "Default mode: agent, plan, yolo"), ("sidebar_width", "Sidebar width percentage: 10-50"), ("max_history", "Max input history entries"), ("default_model", "Default model name"), diff --git a/src/tools/duo.rs b/src/tools/duo.rs deleted file mode 100644 index 56ed4a2e..00000000 --- a/src/tools/duo.rs +++ /dev/null @@ -1,468 +0,0 @@ -//! Tools for Duo mode: Player-Coach autocoding workflow. - -use async_trait::async_trait; -use serde_json::{Value, json}; - -use crate::duo::{ - DuoPhase, SharedDuoSession, generate_coach_prompt, generate_player_prompt, session_summary, -}; -use crate::tools::spec::{ - ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, - optional_str, required_str, -}; - -/// Initialize an autocoding session with requirements. -pub struct DuoInitTool { - session: SharedDuoSession, -} - -impl DuoInitTool { - #[must_use] - pub fn new(session: SharedDuoSession) -> Self { - Self { session } - } -} - -#[async_trait] -impl ToolSpec for DuoInitTool { - fn name(&self) -> &'static str { - "duo_init" - } - - fn description(&self) -> &'static str { - "Initialize a Duo autocoding session with requirements. Returns session summary." - } - - fn input_schema(&self) -> Value { - json!({ - "type": "object", - "properties": { - "requirements": { - "type": "string", - "description": "The requirements document (source of truth). Should be structured as a checklist." - }, - "max_turns": { - "type": "integer", - "description": "Maximum turns before timeout (default: 10)" - }, - "session_name": { - "type": "string", - "description": "Optional human-readable session name (e.g., 'auth-feature')" - }, - "approval_threshold": { - "type": "number", - "description": "Minimum compliance score for approval (0-1, default: 0.9)" - } - }, - "required": ["requirements"] - }) - } - - fn capabilities(&self) -> Vec { - vec![ToolCapability::ReadOnly] - } - - fn approval_requirement(&self) -> ApprovalRequirement { - ApprovalRequirement::Auto - } - - async fn execute(&self, input: Value, _context: &ToolContext) -> Result { - let requirements = required_str(&input, "requirements")?; - let max_turns = input - .get("max_turns") - .and_then(|v| v.as_u64()) - .map(|v| v as u32); - let session_name = optional_str(&input, "session_name").map(str::to_string); - let approval_threshold = input.get("approval_threshold").and_then(|v| v.as_f64()); - - let mut session = self - .session - .lock() - .map_err(|_| ToolError::execution_failed("Failed to lock Duo session"))?; - - let state = session.start_session( - requirements.to_string(), - session_name, - max_turns, - approval_threshold, - ); - - let summary = state.summary(); - Ok(ToolResult::success(format!( - "Duo session initialized. Ready for player phase.\n\n{}", - summary - ))) - } -} - -/// Generate the player prompt for implementation. -pub struct DuoPlayerTool { - session: SharedDuoSession, -} - -impl DuoPlayerTool { - #[must_use] - pub fn new(session: SharedDuoSession) -> Self { - Self { session } - } -} - -#[async_trait] -impl ToolSpec for DuoPlayerTool { - fn name(&self) -> &'static str { - "duo_player" - } - - fn description(&self) -> &'static str { - "Generate the player prompt for implementation. Must be in Init or Player phase. Call after implementing to advance to Coach phase." - } - - fn input_schema(&self) -> Value { - json!({ - "type": "object", - "properties": { - "implementation_summary": { - "type": "string", - "description": "Optional summary of implementation work done (recorded in history)" - } - } - }) - } - - fn capabilities(&self) -> Vec { - vec![ToolCapability::ReadOnly] - } - - fn approval_requirement(&self) -> ApprovalRequirement { - ApprovalRequirement::Auto - } - - async fn execute(&self, input: Value, _context: &ToolContext) -> Result { - let implementation_summary = optional_str(&input, "implementation_summary") - .map(str::to_string) - .unwrap_or_else(|| "Implementation in progress".to_string()); - - let mut session = self - .session - .lock() - .map_err(|_| ToolError::execution_failed("Failed to lock Duo session"))?; - - let state = session - .get_active_mut() - .ok_or_else(|| ToolError::invalid_input("No active session. Call duo_init first."))?; - - // Check we're in a valid phase for player - match state.phase { - DuoPhase::Init | DuoPhase::Player => { - // Generate prompt first - let prompt = generate_player_prompt(state); - - // Advance to Coach phase - state - .advance_to_coach(implementation_summary) - .map_err(|e| ToolError::execution_failed(e.to_string()))?; - - Ok(ToolResult::success(format!( - "=== PLAYER PROMPT ===\n\n{}\n\n---\nAdvanced to Coach phase. Use duo_coach for verification.", - prompt - ))) - } - DuoPhase::Coach => Err(ToolError::invalid_input( - "Already in Coach phase. Use duo_coach to get verification prompt.", - )), - DuoPhase::Approved => Err(ToolError::invalid_input( - "Session already approved. Start a new session with duo_init.", - )), - DuoPhase::Timeout => Err(ToolError::invalid_input( - "Session timed out. Start a new session with duo_init.", - )), - } - } -} - -/// Generate the coach prompt for validation. -pub struct DuoCoachTool { - session: SharedDuoSession, -} - -impl DuoCoachTool { - #[must_use] - pub fn new(session: SharedDuoSession) -> Self { - Self { session } - } -} - -#[async_trait] -impl ToolSpec for DuoCoachTool { - fn name(&self) -> &'static str { - "duo_coach" - } - - fn description(&self) -> &'static str { - "Generate the coach prompt for validation. Must be in Coach phase. Does NOT advance state." - } - - fn input_schema(&self) -> Value { - json!({ - "type": "object", - "properties": {} - }) - } - - fn capabilities(&self) -> Vec { - vec![ToolCapability::ReadOnly] - } - - fn approval_requirement(&self) -> ApprovalRequirement { - ApprovalRequirement::Auto - } - - async fn execute( - &self, - _input: Value, - _context: &ToolContext, - ) -> Result { - let session = self - .session - .lock() - .map_err(|_| ToolError::execution_failed("Failed to lock Duo session"))?; - - let state = session - .get_active() - .ok_or_else(|| ToolError::invalid_input("No active session. Call duo_init first."))?; - - if state.phase != DuoPhase::Coach { - return Err(ToolError::invalid_input(format!( - "Expected Coach phase, but current phase is {}. Use duo_player first.", - state.phase - ))); - } - - let prompt = generate_coach_prompt(state); - - Ok(ToolResult::success(format!( - "=== COACH PROMPT ===\n\n{}\n\n---\nAfter verification, use duo_advance with feedback and approval status.", - prompt - ))) - } -} - -/// Advance the session after coach review. -pub struct DuoAdvanceTool { - session: SharedDuoSession, -} - -impl DuoAdvanceTool { - #[must_use] - pub fn new(session: SharedDuoSession) -> Self { - Self { session } - } -} - -#[async_trait] -impl ToolSpec for DuoAdvanceTool { - fn name(&self) -> &'static str { - "duo_advance" - } - - fn description(&self) -> &'static str { - "Advance the session after coach review. Updates turn count and records feedback. Returns new status." - } - - fn input_schema(&self) -> Value { - json!({ - "type": "object", - "properties": { - "feedback": { - "type": "string", - "description": "The coach's feedback text (compliance checklist and actions needed)" - }, - "approved": { - "type": "boolean", - "description": "Whether the coach approved the implementation (look for 'COACH APPROVED')" - }, - "compliance_score": { - "type": "number", - "description": "Optional compliance score (0-1) based on checklist items satisfied" - } - }, - "required": ["feedback", "approved"] - }) - } - - fn capabilities(&self) -> Vec { - vec![ToolCapability::ReadOnly] - } - - fn approval_requirement(&self) -> ApprovalRequirement { - ApprovalRequirement::Auto - } - - async fn execute(&self, input: Value, _context: &ToolContext) -> Result { - let feedback = required_str(&input, "feedback")?; - let approved = input - .get("approved") - .and_then(|v| v.as_bool()) - .ok_or_else(|| ToolError::missing_field("approved"))?; - let compliance_score = input.get("compliance_score").and_then(|v| v.as_f64()); - - let mut session = self - .session - .lock() - .map_err(|_| ToolError::execution_failed("Failed to lock Duo session"))?; - - let state = session - .get_active_mut() - .ok_or_else(|| ToolError::invalid_input("No active session. Call duo_init first."))?; - - if state.phase != DuoPhase::Coach { - return Err(ToolError::invalid_input(format!( - "Expected Coach phase, but current phase is {}", - state.phase - ))); - } - - // Advance the turn - state - .advance_turn(feedback.to_string(), approved, compliance_score) - .map_err(|e| ToolError::execution_failed(e.to_string()))?; - - // Determine status message based on new phase - let status_msg = match state.phase { - DuoPhase::Approved => "๐ŸŽ‰ APPROVED! All requirements verified.", - DuoPhase::Timeout => "โฐ TIMEOUT. Max turns reached without approval.", - DuoPhase::Player => "๐Ÿ”„ Continuing to next player turn...", - _ => "Session updated.", - }; - - let summary = state.summary(); - let mut result = ToolResult::success(format!("{}\n\n{}", status_msg, summary)); - result.metadata = Some(json!({ - "phase": state.phase.to_string(), - "status": state.status.to_string(), - "turn": state.current_turn, - "max_turns": state.max_turns, - "approved": approved, - "compliance_score": compliance_score, - "is_complete": state.is_complete(), - })); - - Ok(result) - } -} - -/// Show the current session status. -pub struct DuoStatusTool { - session: SharedDuoSession, -} - -impl DuoStatusTool { - #[must_use] - pub fn new(session: SharedDuoSession) -> Self { - Self { session } - } -} - -#[async_trait] -impl ToolSpec for DuoStatusTool { - fn name(&self) -> &'static str { - "duo_status" - } - - fn description(&self) -> &'static str { - "Show the current Duo session status including phase, turn count, and requirements." - } - - fn input_schema(&self) -> Value { - json!({ - "type": "object", - "properties": {} - }) - } - - fn capabilities(&self) -> Vec { - vec![ToolCapability::ReadOnly] - } - - fn approval_requirement(&self) -> ApprovalRequirement { - ApprovalRequirement::Auto - } - - async fn execute( - &self, - _input: Value, - _context: &ToolContext, - ) -> Result { - let session = self - .session - .lock() - .map_err(|_| ToolError::execution_failed("Failed to lock Duo session"))?; - - Ok(ToolResult::success(session_summary(&session))) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::duo::new_shared_duo_session; - - #[test] - fn test_duo_init_tool_schema() { - let session = new_shared_duo_session(); - let tool = DuoInitTool::new(session); - - assert_eq!(tool.name(), "duo_init"); - assert_eq!(tool.approval_requirement(), ApprovalRequirement::Auto); - - let schema = tool.input_schema(); - assert!(schema.get("properties").is_some()); - assert!( - schema["required"] - .as_array() - .unwrap() - .contains(&json!("requirements")) - ); - } - - #[test] - fn test_duo_player_tool_schema() { - let session = new_shared_duo_session(); - let tool = DuoPlayerTool::new(session); - - assert_eq!(tool.name(), "duo_player"); - assert_eq!(tool.approval_requirement(), ApprovalRequirement::Auto); - } - - #[test] - fn test_duo_coach_tool_schema() { - let session = new_shared_duo_session(); - let tool = DuoCoachTool::new(session); - - assert_eq!(tool.name(), "duo_coach"); - assert_eq!(tool.approval_requirement(), ApprovalRequirement::Auto); - } - - #[test] - fn test_duo_advance_tool_schema() { - let session = new_shared_duo_session(); - let tool = DuoAdvanceTool::new(session); - - assert_eq!(tool.name(), "duo_advance"); - assert_eq!(tool.approval_requirement(), ApprovalRequirement::Auto); - - let schema = tool.input_schema(); - let required = schema["required"].as_array().unwrap(); - assert!(required.contains(&json!("feedback"))); - assert!(required.contains(&json!("approved"))); - } - - #[test] - fn test_duo_status_tool_schema() { - let session = new_shared_duo_session(); - let tool = DuoStatusTool::new(session); - - assert_eq!(tool.name(), "duo_status"); - assert_eq!(tool.approval_requirement(), ApprovalRequirement::Auto); - } -} diff --git a/src/tools/mod.rs b/src/tools/mod.rs index 4f28d37f..128a5a9f 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -7,7 +7,6 @@ pub mod apply_patch; pub mod calculator; pub mod diagnostics; -pub mod duo; pub mod file; pub mod file_search; pub mod finance; @@ -17,7 +16,6 @@ pub mod plan; pub mod project; pub mod registry; pub mod review; -pub mod rlm; pub mod search; pub mod shell; pub mod spec; @@ -90,6 +88,3 @@ pub use parallel::MultiToolUseParallelTool; // Re-export user input tool/types pub use user_input::{RequestUserInputTool, UserInputAnswer, UserInputRequest, UserInputResponse}; - -// Re-export RLM tools -pub use rlm::{RlmExecTool, RlmLoadTool, RlmQueryTool, RlmStatusTool}; diff --git a/src/tools/registry.rs b/src/tools/registry.rs index 8ae5ee1f..68514574 100644 --- a/src/tools/registry.rs +++ b/src/tools/registry.rs @@ -12,9 +12,7 @@ use std::sync::Arc; use serde_json::Value; use crate::client::DeepSeekClient; -use crate::duo::SharedDuoSession; use crate::models::Tool; -use crate::rlm::SharedRlmSession; use super::spec::{ ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, @@ -383,14 +381,8 @@ impl ToolRegistryBuilder { /// Include all agent tools (file tools + shell + note + search + patch). #[must_use] - pub fn with_agent_tools( - self, - allow_shell: bool, - rlm_session: Option, - client: Option, - model: String, - ) -> Self { - let mut builder = self + pub fn with_agent_tools(self, allow_shell: bool) -> Self { + let builder = self .with_file_tools() .with_note_tool() .with_search_tools() @@ -404,10 +396,6 @@ impl ToolRegistryBuilder { .with_project_tools() .with_test_runner_tool(); - if let Some(session) = rlm_session { - builder = builder.with_rlm_tools(session, client, model); - } - if allow_shell { builder.with_shell_tools() } else { @@ -439,42 +427,12 @@ impl ToolRegistryBuilder { allow_shell: bool, todo_list: super::todo::SharedTodoList, plan_state: super::plan::SharedPlanState, - rlm_session: Option, - client: Option, - model: String, ) -> Self { - self.with_agent_tools(allow_shell, rlm_session, client, model) + self.with_agent_tools(allow_shell) .with_todo_tool(todo_list) .with_plan_tool(plan_state) } - /// Include RLM tools for context execution and sub-queries. - #[must_use] - pub fn with_rlm_tools( - self, - session: SharedRlmSession, - client: Option, - model: String, - ) -> Self { - self.with_tool(Arc::new(super::rlm::RlmExecTool::new(session.clone()))) - .with_tool(Arc::new(super::rlm::RlmLoadTool::new(session.clone()))) - .with_tool(Arc::new(super::rlm::RlmStatusTool::new(session.clone()))) - .with_tool(Arc::new(super::rlm::RlmQueryTool::new( - session, client, model, - ))) - } - - /// Include Duo tools for dialectical autocoding. - #[must_use] - pub fn with_duo_tools(self, session: SharedDuoSession) -> Self { - use super::duo::{DuoAdvanceTool, DuoCoachTool, DuoInitTool, DuoPlayerTool, DuoStatusTool}; - self.with_tool(Arc::new(DuoInitTool::new(session.clone()))) - .with_tool(Arc::new(DuoPlayerTool::new(session.clone()))) - .with_tool(Arc::new(DuoCoachTool::new(session.clone()))) - .with_tool(Arc::new(DuoAdvanceTool::new(session.clone()))) - .with_tool(Arc::new(DuoStatusTool::new(session))) - } - /// Include sub-agent management tools. #[must_use] pub fn with_subagent_tools( diff --git a/src/tools/rlm.rs b/src/tools/rlm.rs deleted file mode 100644 index b2f831e5..00000000 --- a/src/tools/rlm.rs +++ /dev/null @@ -1,1047 +0,0 @@ -//! Tools for RLM mode: evaluating expressions and issuing sub-queries. -//! -//! Implements recursive sub-queries per the RLM paper, with depth limiting -//! to prevent infinite recursion. - -use async_trait::async_trait; -use regex::Regex; -use serde_json::{Value, json}; - -use crate::client::DeepSeekClient; -use crate::llm_client::LlmClient; -use crate::models::{ContentBlock, Message, MessageRequest, SystemPrompt, Tool, Usage}; -use crate::rlm::{ - RlmContext, SharedRlmSession, context_id_from_path, eval_expr_mut, session_summary, - unique_context_id, -}; -use crate::tools::spec::{ - ApprovalRequirement, ToolCapability, ToolError, ToolResult, ToolSpec, optional_str, - optional_u64, required_str, -}; - -const DEFAULT_QUERY_MAX_TOKENS: u32 = 2048; -const MAX_QUERY_MAX_TOKENS: u32 = 8192; -const MAX_EXEC_OUTPUT_CHARS: usize = 12_000; -const MAX_QUERY_CHARS: usize = 400_000; -const DEFAULT_AUTO_CHUNK_MAX_CHARS: usize = 20_000; - -/// Maximum recursion depth for RLM sub-calls (per RLM paper) -const MAX_RECURSION_DEPTH: u32 = 3; -/// Maximum tool iterations within a single sub-call -const MAX_TOOL_ITERATIONS: u32 = 10; - -fn normalize_load_path(raw: &str) -> Result { - let trimmed = raw.trim(); - if trimmed.is_empty() { - return Err(ToolError::invalid_input("Path is required")); - } - - if let Some(stripped) = trimmed.strip_prefix('@') { - let stripped = stripped.trim(); - if stripped.is_empty() { - return Err(ToolError::invalid_input( - "Path is required after '@' prefix", - )); - } - let stripped = stripped.trim_start_matches(['/', '\\']); - if stripped.is_empty() { - return Err(ToolError::invalid_input( - "Path is required after '@' prefix", - )); - } - return Ok(stripped.to_string()); - } - - Ok(trimmed.to_string()) -} - -/// Execute an RLM expression against the current context. -pub struct RlmExecTool { - session: SharedRlmSession, -} - -impl RlmExecTool { - #[must_use] - pub fn new(session: SharedRlmSession) -> Self { - Self { session } - } -} - -#[async_trait] -impl ToolSpec for RlmExecTool { - fn name(&self) -> &'static str { - "rlm_exec" - } - - fn description(&self) -> &'static str { - "Execute an RLM expression against the current context. Supports: len, line_count, lines(), search(), chunk(), chunk_sections(), chunk_lines(), chunk_auto(), vars/get/set/append/del." - } - - fn input_schema(&self) -> Value { - json!({ - "type": "object", - "properties": { - "code": { - "type": "string", - "description": "RLM expression(s) to evaluate" - }, - "context_id": { - "type": "string", - "description": "Optional context id (defaults to active context)" - } - }, - "required": ["code"] - }) - } - - fn capabilities(&self) -> Vec { - vec![ToolCapability::ReadOnly] - } - - fn approval_requirement(&self) -> ApprovalRequirement { - ApprovalRequirement::Auto - } - - async fn execute( - &self, - input: Value, - _context: &crate::tools::spec::ToolContext, - ) -> Result { - let code = required_str(&input, "code")?; - let context_id = optional_str(&input, "context_id").map(str::to_string); - - let mut session = self - .session - .lock() - .map_err(|_| ToolError::execution_failed("Failed to lock RLM session"))?; - - let ctx_id = context_id.unwrap_or_else(|| session.active_context.clone()); - let ctx = session - .get_context_mut(&ctx_id) - .ok_or_else(|| ToolError::invalid_input(format!("Context '{ctx_id}' not loaded")))?; - - let output = - eval_script_mut(ctx, code).map_err(|e| ToolError::execution_failed(e.to_string()))?; - - let truncated = if output.len() > MAX_EXEC_OUTPUT_CHARS { - let snippet = truncate_to_boundary(&output, MAX_EXEC_OUTPUT_CHARS); - format!( - "{}\n\n[output truncated to {} chars]", - snippet, MAX_EXEC_OUTPUT_CHARS - ) - } else { - output - }; - - Ok(ToolResult::success(truncated)) - } -} - -/// Load a file into the shared RLM session. -pub struct RlmLoadTool { - session: SharedRlmSession, -} - -impl RlmLoadTool { - #[must_use] - pub fn new(session: SharedRlmSession) -> Self { - Self { session } - } -} - -#[async_trait] -impl ToolSpec for RlmLoadTool { - fn name(&self) -> &'static str { - "rlm_load" - } - - fn description(&self) -> &'static str { - "Load a file into the RLM context store. Returns the context_id and stats. Use @path for workspace-relative loads." - } - - fn input_schema(&self) -> Value { - json!({ - "type": "object", - "properties": { - "path": { - "type": "string", - "description": "Path to the file to load (prefix with @ for workspace-relative paths)" - }, - "context_id": { - "type": "string", - "description": "Optional context id to reuse (defaults to filename)" - } - }, - "required": ["path"] - }) - } - - fn capabilities(&self) -> Vec { - vec![ToolCapability::ReadOnly] - } - - fn approval_requirement(&self) -> ApprovalRequirement { - ApprovalRequirement::Auto - } - - async fn execute( - &self, - input: Value, - context: &crate::tools::spec::ToolContext, - ) -> Result { - let path = required_str(&input, "path")?; - let normalized = normalize_load_path(path)?; - let context_id = optional_str(&input, "context_id").map(str::to_string); - - let resolved = context.resolve_path(&normalized)?; - let mut session = self - .session - .lock() - .map_err(|_| ToolError::execution_failed("Failed to lock RLM session"))?; - - let base_id = context_id.unwrap_or_else(|| context_id_from_path(&resolved)); - let id = if session.contexts.contains_key(&base_id) { - base_id.clone() - } else { - unique_context_id(&session, &base_id) - }; - - let (line_count, char_count) = session - .load_file(&id, &resolved) - .map_err(|e| ToolError::execution_failed(e.to_string()))?; - - let mut result = ToolResult::success(format!( - "Loaded {} as '{}' ({} lines, {} chars)", - resolved.display(), - id, - line_count, - char_count - )); - result.metadata = Some(json!({ - "context_id": id, - "line_count": line_count, - "char_count": char_count, - "source_path": resolved.to_string_lossy(), - })); - Ok(result) - } -} - -/// Summarize RLM session state. -pub struct RlmStatusTool { - session: SharedRlmSession, -} - -impl RlmStatusTool { - #[must_use] - pub fn new(session: SharedRlmSession) -> Self { - Self { session } - } -} - -#[async_trait] -impl ToolSpec for RlmStatusTool { - fn name(&self) -> &'static str { - "rlm_status" - } - - fn description(&self) -> &'static str { - "Show RLM session status (contexts, usage, variables)." - } - - fn input_schema(&self) -> Value { - json!({ - "type": "object", - "properties": {} - }) - } - - fn capabilities(&self) -> Vec { - vec![ToolCapability::ReadOnly] - } - - fn approval_requirement(&self) -> ApprovalRequirement { - ApprovalRequirement::Auto - } - - async fn execute( - &self, - _input: Value, - _context: &crate::tools::spec::ToolContext, - ) -> Result { - let session = self - .session - .lock() - .map_err(|_| ToolError::execution_failed("Failed to lock RLM session"))?; - Ok(ToolResult::success(session_summary(&session))) - } -} - -/// Execute a sub-query against a chunk of the active context. -/// Supports recursive sub-calls per the RLM paper with depth limiting. -pub struct RlmQueryTool { - session: SharedRlmSession, - client: Option, - model: String, - current_depth: u32, -} - -impl RlmQueryTool { - #[must_use] - pub fn new(session: SharedRlmSession, client: Option, model: String) -> Self { - Self { - session, - client, - model, - current_depth: 0, - } - } - - /// Create a sub-query tool at increased depth for recursive calls - fn with_depth(&self, depth: u32) -> Self { - Self { - session: self.session.clone(), - client: self.client.clone(), - model: self.model.clone(), - current_depth: depth, - } - } - - /// Check if recursive sub-calls are allowed at current depth - fn can_recurse(&self) -> bool { - self.current_depth < MAX_RECURSION_DEPTH - } -} - -#[async_trait] -impl ToolSpec for RlmQueryTool { - fn name(&self) -> &'static str { - "rlm_query" - } - - fn description(&self) -> &'static str { - "Run a focused LLM query over a context slice. Provide line/char range or chunk index; use batch for multiple queries or auto_chunks for chunk_auto batching." - } - - fn input_schema(&self) -> Value { - json!({ - "type": "object", - "properties": { - "query": { "type": "string", "description": "Question to answer about the chunk" }, - "context_id": { "type": "string", "description": "Optional context id (defaults to active context)" }, - "chunk_index": { "type": "integer", "description": "Chunk index from chunk() output" }, - "chunk_size": { "type": "integer", "description": "Chunk size (default: 2000)" }, - "overlap": { "type": "integer", "description": "Chunk overlap (default: 200)" }, - "line_start": { "type": "integer", "description": "Start line (1-based)" }, - "line_end": { "type": "integer", "description": "End line (1-based)" }, - "char_start": { "type": "integer", "description": "Start char offset" }, - "char_end": { "type": "integer", "description": "End char offset" }, - "section_index": { "type": "integer", "description": "Section index from chunk_sections()" }, - "section_size": { "type": "integer", "description": "Section chunk size (default: 20000)" }, - "mode": { "type": "string", "description": "analysis (default) or verify" }, - "store_as": { "type": "string", "description": "Store the FINAL answer in a variable" }, - "max_tokens": { "type": "integer", "description": "Override max tokens for the sub-call" }, - "auto_chunks": { - "type": "boolean", - "description": "Use chunk_auto and run the same query for each chunk" - }, - "auto_max_chars": { - "type": "integer", - "description": "Max chars per auto chunk (default: 20000)" - }, - "auto_max_chunks": { - "type": "integer", - "description": "Optional limit on auto chunk count" - }, - "batch": { - "type": "array", - "description": "Batch multiple queries into a single call", - "items": { - "type": "object", - "properties": { - "query": { "type": "string" }, - "context_id": { "type": "string" }, - "chunk_index": { "type": "integer" }, - "chunk_size": { "type": "integer" }, - "overlap": { "type": "integer" }, - "line_start": { "type": "integer" }, - "line_end": { "type": "integer" }, - "char_start": { "type": "integer" }, - "char_end": { "type": "integer" }, - "section_index": { "type": "integer" }, - "section_size": { "type": "integer" } - }, - "required": ["query"] - } - } - } - }) - } - - fn capabilities(&self) -> Vec { - vec![ToolCapability::Network, ToolCapability::RequiresApproval] - } - - fn approval_requirement(&self) -> ApprovalRequirement { - ApprovalRequirement::Suggest - } - - async fn execute( - &self, - input: Value, - _context: &crate::tools::spec::ToolContext, - ) -> Result { - let Some(client) = self.client.clone() else { - return Err(ToolError::not_available("RLM query requires an API client")); - }; - - let auto_chunks = input - .get("auto_chunks") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - let batch_items = input.get("batch").and_then(|v| v.as_array()); - if auto_chunks && batch_items.is_some() { - return Err(ToolError::invalid_input( - "auto_chunks cannot be combined with batch queries".to_string(), - )); - } - let query = if auto_chunks || batch_items.is_none() { - required_str(&input, "query")?.to_string() - } else { - optional_str(&input, "query").unwrap_or("").to_string() - }; - let context_id = optional_str(&input, "context_id").map(str::to_string); - let mode = optional_str(&input, "mode").unwrap_or("analysis"); - let store_as = optional_str(&input, "store_as").map(str::to_string); - let default_max = default_query_max_tokens(&self.model); - let max_tokens = optional_u64(&input, "max_tokens", u64::from(default_max)) - .clamp(256, u64::from(MAX_QUERY_MAX_TOKENS)) as u32; - let (prompt, used_context_id, batch_count) = if auto_chunks { - let max_chars = optional_u64( - &input, - "auto_max_chars", - DEFAULT_AUTO_CHUNK_MAX_CHARS as u64, - ) as usize; - let max_chunks = optional_u64(&input, "auto_max_chunks", 0) as usize; - let (chunks, ctx_id) = self.extract_auto_chunks(context_id.as_deref(), max_chars)?; - if chunks.is_empty() { - return Err(ToolError::invalid_input( - "No chunks available for auto_chunks".to_string(), - )); - } - if max_chunks > 0 && chunks.len() > max_chunks { - return Err(ToolError::invalid_input(format!( - "auto_chunks produced {} chunks; reduce input or set auto_max_chunks", - chunks.len() - ))); - } - let mut queries = Vec::new(); - let mut total_len = 0usize; - for (idx, chunk) in chunks.iter().enumerate() { - let task = format!( - "TASK {}:\nContext:\n{}\n\nQuestion:\n{}\n", - idx + 1, - chunk, - query - ); - total_len = total_len.saturating_add(task.len()); - if total_len > MAX_QUERY_CHARS { - return Err(ToolError::invalid_input( - "auto_chunks payload too large; lower auto_max_chars or use manual batching" - .to_string(), - )); - } - queries.push(task); - } - ( - format!( - "{}\n\n{}", - rlm_subcall_prompt(mode, true), - queries.join("\n") - ), - ctx_id, - queries.len(), - ) - } else if let Some(items) = batch_items { - if items.is_empty() { - return Err(ToolError::invalid_input( - "Batch must include at least one query".to_string(), - )); - } - let mut queries = Vec::new(); - let mut context_for_batch = context_id.clone(); - for (idx, item) in items.iter().enumerate() { - let item_query = item - .get("query") - .and_then(|v| v.as_str()) - .unwrap_or_default(); - let (chunk, ctx_id) = self.extract_chunk(item, context_for_batch.as_deref())?; - context_for_batch = Some(ctx_id.clone()); - queries.push(format!( - "TASK {}:\nContext:\n{}\n\nQuestion:\n{}\n", - idx + 1, - chunk, - item_query - )); - } - ( - format!( - "{}\n\n{}", - rlm_subcall_prompt(mode, true), - queries.join("\n") - ), - context_for_batch.unwrap_or_else(|| "active".to_string()), - items.len(), - ) - } else { - let (chunk, ctx_id) = self.extract_chunk(&input, context_id.as_deref())?; - ( - format!( - "{}\n\nContext:\n{}\n\nQuestion:\n{}\n", - rlm_subcall_prompt(mode, false), - chunk, - query - ), - ctx_id, - 1, - ) - }; - - if prompt.len() > MAX_QUERY_CHARS { - return Err(ToolError::invalid_input(format!( - "RLM query payload is too large ({} chars). Use smaller chunks or batch less.", - prompt.len() - ))); - } - - // Build tools for recursive sub-calls if depth allows (per RLM paper) - let tools = if self.can_recurse() { - Some(build_rlm_tools_for_subcall()) - } else { - None - }; - - let mut messages = vec![Message { - role: "user".to_string(), - content: vec![ContentBlock::Text { - text: prompt.clone(), - cache_control: None, - }], - }]; - - let mut total_input_tokens = 0u32; - let mut total_output_tokens = 0u32; - let mut iterations = 0u32; - let final_response_text; - - // Agentic loop: handle tool calls recursively - loop { - iterations += 1; - if iterations > MAX_TOOL_ITERATIONS { - return Err(ToolError::execution_failed( - "RLM sub-call exceeded maximum tool iterations", - )); - } - - let request = MessageRequest { - model: self.model.clone(), - messages: messages.clone(), - max_tokens, - system: Some(SystemPrompt::Text(rlm_subcall_system_prompt( - mode, - self.can_recurse(), - ))), - tools: tools.clone(), - tool_choice: None, - metadata: None, - thinking: None, - stream: Some(false), - temperature: None, - top_p: None, - }; - - let response = client - .create_message(request) - .await - .map_err(|e| ToolError::execution_failed(format!("RLM query failed: {e}")))?; - - total_input_tokens += response.usage.input_tokens; - total_output_tokens += response.usage.output_tokens; - - // Check for tool use in response - let tool_uses: Vec<_> = response - .content - .iter() - .filter_map(|block| { - if let ContentBlock::ToolUse { id, name, input } = block { - Some((id.clone(), name.clone(), input.clone())) - } else { - None - } - }) - .collect(); - - if tool_uses.is_empty() { - // No tool calls - extract final response - final_response_text = extract_text(&response.content); - break; - } - - // Execute tool calls and continue conversation - let mut tool_results = Vec::new(); - for (tool_id, tool_name, tool_input) in tool_uses { - let result = self - .execute_recursive_tool(&tool_name, &tool_input, &used_context_id) - .await; - let result_text = match result { - Ok(text) => text, - Err(e) => format!("Error: {e}"), - }; - tool_results.push(ContentBlock::ToolResult { - tool_use_id: tool_id, - content: result_text, - }); - } - - // Add assistant response and tool results to conversation - messages.push(Message { - role: "assistant".to_string(), - content: response.content.clone(), - }); - messages.push(Message { - role: "user".to_string(), - content: tool_results, - }); - } - - self.record_usage( - &used_context_id, - &Usage { - input_tokens: total_input_tokens, - output_tokens: total_output_tokens, - }, - prompt.len(), - final_response_text.len(), - &final_response_text, - store_as, - ); - - let mut result = ToolResult::success(final_response_text); - result.metadata = Some(json!({ - "context_id": used_context_id, - "batch_count": batch_count, - "input_tokens": total_input_tokens, - "output_tokens": total_output_tokens, - "depth": self.current_depth, - "iterations": iterations, - })); - Ok(result) - } -} - -fn eval_script_mut(ctx: &mut RlmContext, code: &str) -> anyhow::Result { - let mut outputs = Vec::new(); - for line in code.lines() { - let trimmed = line.trim(); - if trimmed.is_empty() { - continue; - } - let result = eval_expr_mut(ctx, trimmed)?; - if !result.trim().is_empty() { - outputs.push(result); - } - } - Ok(outputs.join("\n")) -} - -impl RlmQueryTool { - fn extract_chunk( - &self, - input: &Value, - fallback_context_id: Option<&str>, - ) -> Result<(String, String), ToolError> { - let session = self - .session - .lock() - .map_err(|_| ToolError::execution_failed("Failed to lock RLM session"))?; - - let ctx_id = input - .get("context_id") - .and_then(|v| v.as_str()) - .or(fallback_context_id) - .unwrap_or_else(|| session.active_context.as_str()) - .to_string(); - - let ctx = session - .get_context(&ctx_id) - .ok_or_else(|| ToolError::invalid_input(format!("Context '{ctx_id}' not loaded")))?; - - let chunk = if let Some(text) = input.get("text").and_then(|v| v.as_str()) { - text.to_string() - } else if let Some(section_index) = input.get("section_index").and_then(|v| v.as_u64()) { - let section_size = input - .get("section_size") - .and_then(|v| v.as_u64()) - .unwrap_or(20_000) as usize; - let sections = ctx.chunk_sections(section_size); - let idx = usize::try_from(section_index).unwrap_or(0); - let section = sections.get(idx).ok_or_else(|| { - ToolError::invalid_input(format!("Section index {idx} out of range")) - })?; - ctx.peek(section.start_char, Some(section.end_char)) - .to_string() - } else if let Some(chunk_index) = input.get("chunk_index").and_then(|v| v.as_u64()) { - let chunk_size = input - .get("chunk_size") - .and_then(|v| v.as_u64()) - .unwrap_or(2000) as usize; - let overlap = input.get("overlap").and_then(|v| v.as_u64()).unwrap_or(200) as usize; - let chunks = ctx.chunk(chunk_size, overlap); - let idx = usize::try_from(chunk_index).unwrap_or(0); - let chunk = chunks.get(idx).ok_or_else(|| { - ToolError::invalid_input(format!("Chunk index {idx} out of range")) - })?; - ctx.peek(chunk.start_char, Some(chunk.end_char)).to_string() - } else if let Some(start) = input.get("line_start").and_then(|v| v.as_u64()) { - let end = input.get("line_end").and_then(|v| v.as_u64()); - extract_lines(ctx, start as usize, end.map(|v| v as usize)) - } else if let Some(start) = input.get("char_start").and_then(|v| v.as_u64()) { - let end = input.get("char_end").and_then(|v| v.as_u64()); - ctx.peek(start as usize, end.map(|v| v as usize)) - .to_string() - } else { - return Err(ToolError::invalid_input( - "Provide chunk_index, section_index, line_start, or char_start".to_string(), - )); - }; - - Ok((chunk, ctx_id)) - } - - fn extract_auto_chunks( - &self, - fallback_context_id: Option<&str>, - max_chars: usize, - ) -> Result<(Vec, String), ToolError> { - let session = self - .session - .lock() - .map_err(|_| ToolError::execution_failed("Failed to lock RLM session"))?; - - let ctx_id = fallback_context_id - .unwrap_or_else(|| session.active_context.as_str()) - .to_string(); - - let ctx = session - .get_context(&ctx_id) - .ok_or_else(|| ToolError::invalid_input(format!("Context '{ctx_id}' not loaded")))?; - - let chunks = ctx.chunk_auto(max_chars.max(1)); - let mut outputs = Vec::with_capacity(chunks.len()); - for chunk in chunks { - outputs.push(ctx.peek(chunk.start_char, Some(chunk.end_char)).to_string()); - } - - Ok((outputs, ctx_id)) - } - - fn record_usage( - &self, - context_id: &str, - usage: &Usage, - chars_sent: usize, - chars_received: usize, - response_text: &str, - store_as: Option, - ) { - let mut session = match self.session.lock() { - Ok(session) => session, - Err(_) => return, - }; - session.record_query_usage(usage, chars_sent, chars_received); - - let Some(ctx) = session.get_context_mut(context_id) else { - return; - }; - - if let Some(name) = store_as { - let final_answer = - extract_final(response_text).unwrap_or_else(|| response_text.trim().to_string()); - ctx.set_var(&name, final_answer); - } - - for (name, value) in extract_final_vars(response_text) { - ctx.set_var(&name, value); - } - } -} - -impl RlmQueryTool { - /// Execute a tool call from within a recursive sub-call - async fn execute_recursive_tool( - &self, - tool_name: &str, - input: &Value, - context_id: &str, - ) -> Result { - match tool_name { - "rlm_exec" => { - let code = input.get("code").and_then(|v| v.as_str()).ok_or_else(|| { - ToolError::invalid_input("rlm_exec requires 'code' parameter") - })?; - - let ctx_id = input - .get("context_id") - .and_then(|v| v.as_str()) - .unwrap_or(context_id); - - let mut session = self - .session - .lock() - .map_err(|_| ToolError::execution_failed("Failed to lock RLM session"))?; - - let ctx = session.get_context_mut(ctx_id).ok_or_else(|| { - ToolError::invalid_input(format!("Context '{ctx_id}' not loaded")) - })?; - - let output = eval_script_mut(ctx, code) - .map_err(|e| ToolError::execution_failed(e.to_string()))?; - - let truncated = if output.len() > MAX_EXEC_OUTPUT_CHARS { - let snippet = truncate_to_boundary(&output, MAX_EXEC_OUTPUT_CHARS); - format!( - "{}\n\n[output truncated to {} chars]", - snippet, MAX_EXEC_OUTPUT_CHARS - ) - } else { - output - }; - - Ok(truncated) - } - "rlm_query" => { - // Recursive sub-query at increased depth - let sub_tool = self.with_depth(self.current_depth + 1); - - // Build a minimal ToolContext for the recursive call - let dummy_context = crate::tools::spec::ToolContext::new( - std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")), - ); - - let result = sub_tool.execute(input.clone(), &dummy_context).await?; - Ok(result.content) - } - _ => Err(ToolError::not_available(format!( - "Unknown tool in RLM sub-call: {tool_name}" - ))), - } - } -} - -fn extract_text(blocks: &[ContentBlock]) -> String { - let mut output = String::new(); - for block in blocks { - if let ContentBlock::Text { text, .. } = block { - if !output.is_empty() { - output.push('\n'); - } - output.push_str(text); - } - } - output.trim().to_string() -} - -fn extract_lines(ctx: &RlmContext, start: usize, end: Option) -> String { - let start_line = start.max(1); - let end_line = end.unwrap_or(ctx.line_count).max(start_line); - let start_idx = start_line.saturating_sub(1); - let end_idx = end_line.min(ctx.line_count); - ctx.lines(start_idx, Some(end_idx)) - .iter() - .map(|(n, l)| format!("{n:>5} {l}")) - .collect::>() - .join("\n") -} - -fn extract_final(text: &str) -> Option { - let regex = Regex::new(r"(?s)FINAL\s*:?\s*(.+)$").ok()?; - let caps = regex.captures(text)?; - caps.get(1).map(|m| m.as_str().trim().to_string()) -} - -fn extract_final_vars(text: &str) -> Vec<(String, String)> { - let regex = match Regex::new(r"(?m)^FINAL_VAR\(([^)]+)\)\s*:?\s*(.+)$") { - Ok(r) => r, - Err(_) => return Vec::new(), - }; - - regex - .captures_iter(text) - .filter_map(|caps| { - let name = caps.get(1)?.as_str().trim(); - let value = caps.get(2)?.as_str().trim(); - if name.is_empty() || value.is_empty() { - None - } else { - Some((name.to_string(), value.to_string())) - } - }) - .collect() -} - -fn rlm_subcall_prompt(mode: &str, batch: bool) -> &'static str { - match (mode, batch) { - ("verify", true) => "You are verifying answers. Provide a brief check for each task.", - ("verify", false) => { - "You are verifying an answer. Provide a brief check and highlight any issues." - } - (_, true) => { - "Answer each task using only the provided context. Label each response clearly." - } - _ => "Answer the question using only the provided context.", - } -} - -fn rlm_subcall_system_prompt(mode: &str, has_tools: bool) -> String { - let mut prompt = String::from( - "You are an RLM sub-call. Use ONLY the provided context. Respond concisely.\n\n\ -Output format:\n- Use FINAL: for the final response.\n- Use FINAL_VAR(name): to store buffer values if needed.\n", - ); - if has_tools { - prompt.push_str( - "\nYou have access to RLM tools for recursive analysis:\n\ - - rlm_exec: Execute expressions to explore context (lines, search, chunk, etc.)\n\ - - rlm_query: Make recursive sub-queries on specific chunks\n\n\ - Use tools when you need to examine more context or make focused sub-queries.\n", - ); - } - if mode == "verify" { - prompt.push_str("\nVerification mode: check for contradictions or missing evidence."); - } - prompt -} - -/// Build tool definitions for recursive RLM sub-calls -fn build_rlm_tools_for_subcall() -> Vec { - vec![ - Tool { - name: "rlm_exec".to_string(), - description: "Execute RLM expressions: len, line_count, lines(start, end), search(pattern), chunk(size, overlap), chunk_auto(max_chars), get(var), set(var, value)".to_string(), - input_schema: json!({ - "type": "object", - "properties": { - "code": { - "type": "string", - "description": "RLM expression(s) to evaluate" - }, - "context_id": { - "type": "string", - "description": "Optional context id" - } - }, - "required": ["code"] - }), - cache_control: None, - }, - Tool { - name: "rlm_query".to_string(), - description: "Make a recursive sub-query on a specific chunk of context. Use for focused analysis of sections.".to_string(), - input_schema: json!({ - "type": "object", - "properties": { - "query": { - "type": "string", - "description": "Question to answer about the chunk" - }, - "chunk_index": { - "type": "integer", - "description": "Chunk index from chunk() output" - }, - "line_start": { - "type": "integer", - "description": "Start line (1-based)" - }, - "line_end": { - "type": "integer", - "description": "End line (1-based)" - }, - "section_index": { - "type": "integer", - "description": "Section index from chunk_sections()" - } - }, - "required": ["query"] - }), - cache_control: None, - }, - ] -} - -fn truncate_to_boundary(text: &str, max: usize) -> &str { - if text.len() <= max { - return text; - } - let idx = text - .char_indices() - .take_while(|(i, _)| *i <= max) - .last() - .map_or(0, |(i, _)| i); - &text[..idx] -} - -fn default_query_max_tokens(model: &str) -> u32 { - let lower = model.to_lowercase(); - if lower.contains("claude") { - 1024 - } else { - DEFAULT_QUERY_MAX_TOKENS - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::rlm::RlmContext; - - #[test] - fn extract_final_prefers_final_marker() { - let text = "notes\nFINAL: answer here"; - let extracted = extract_final(text).expect("final"); - assert_eq!(extracted, "answer here"); - } - - #[test] - fn extract_final_vars_parses_lines() { - let text = "FINAL_VAR(foo): bar\nFINAL_VAR(baz): qux"; - let vars = extract_final_vars(text); - assert_eq!(vars.len(), 2); - assert!(vars.iter().any(|(k, v)| k == "foo" && v == "bar")); - assert!(vars.iter().any(|(k, v)| k == "baz" && v == "qux")); - } - - #[test] - fn extract_lines_formats_numbers() { - let ctx = RlmContext::new("test", "a\nb\nc".to_string(), None); - let lines = extract_lines(&ctx, 1, Some(2)); - assert!(lines.contains("1 a")); - assert!(lines.contains("2 b")); - } - - #[test] - fn normalize_load_path_accepts_at_prefix() { - let normalized = normalize_load_path("@docs/rlm-paper.txt").expect("normalize"); - assert_eq!(normalized, "docs/rlm-paper.txt"); - } - - #[test] - fn normalize_load_path_strips_leading_separators() { - let normalized = normalize_load_path("@/docs/rlm-paper.txt").expect("normalize"); - assert_eq!(normalized, "docs/rlm-paper.txt"); - } - - #[test] - fn normalize_load_path_rejects_empty() { - assert!(normalize_load_path("@").is_err()); - assert!(normalize_load_path(" ").is_err()); - } -} diff --git a/src/tui/app.rs b/src/tui/app.rs index 0f41152c..5ae52266 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -10,11 +10,9 @@ use thiserror::Error; use crate::compaction::CompactionConfig; use crate::config::{Config, has_api_key, save_api_key}; -use crate::duo::{SharedDuoSession, new_shared_duo_session}; use crate::hooks::{HookContext, HookEvent, HookExecutor, HookResult}; use crate::models::{Message, SystemPrompt}; use crate::palette::{self, UiTheme}; -use crate::rlm::{RlmSession, SharedRlmSession}; use crate::settings::Settings; use crate::tools::plan::{SharedPlanState, new_shared_plan_state}; use crate::tools::subagent::SubAgentResult; @@ -27,7 +25,6 @@ use crate::tui::scrolling::{MouseScrollState, TranscriptScroll}; use crate::tui::selection::TranscriptSelection; use crate::tui::transcript::TranscriptViewCache; use crate::tui::views::ViewStack; -use std::sync::{Arc, Mutex}; use uuid::Uuid; /// Format a nice welcome banner similar to Kimi CLI. @@ -70,8 +67,6 @@ pub enum AppMode { Agent, Yolo, Plan, - Rlm, - Duo, } fn char_count(text: &str) -> usize { @@ -119,8 +114,6 @@ impl AppMode { AppMode::Agent => "AGENT", AppMode::Yolo => "YOLO", AppMode::Plan => "PLAN", - AppMode::Rlm => "RLM", - AppMode::Duo => "DUO", } } @@ -132,8 +125,6 @@ impl AppMode { AppMode::Agent => "Agent mode - autonomous task execution with tools", AppMode::Yolo => "YOLO mode - full tool access without approvals", AppMode::Plan => "Plan mode - design before implementing", - AppMode::Rlm => "RLM mode - recursive language model sandbox", - AppMode::Duo => "Duo mode - dialectical autocoding with player-coach loop", } } } @@ -200,7 +191,6 @@ pub struct App { pub input_history: Vec, pub history_index: Option, pub auto_compact: bool, - pub auto_rlm: bool, pub show_thinking: bool, pub show_tool_details: bool, #[allow(dead_code)] @@ -242,12 +232,6 @@ pub struct App { pub plan_prompt_pending: bool, /// Whether update_plan was called during the current turn pub plan_tool_used_in_turn: bool, - /// RLM sandbox session state - pub rlm_session: SharedRlmSession, - /// Duo mode session state (player-coach autocoding loop) - pub duo_session: SharedDuoSession, - /// Whether RLM REPL input mode is active. - pub rlm_repl_active: bool, /// Todo list for `TodoWriteTool` #[allow(dead_code)] // For future engine integration pub todos: SharedTodoList, @@ -314,14 +298,6 @@ impl QueuedMessage { self.display.clone() } } - - pub fn content_with_query(&self, query: &str) -> String { - if let Some(skill_instruction) = self.skill_instruction.as_ref() { - format!("{skill_instruction}\n\n---\n\nUser request: {query}") - } else { - query.to_string() - } - } } // === Errors === @@ -364,7 +340,6 @@ impl App { let needs_onboarding = !skip_onboarding && (!was_onboarded || needs_api_key); let settings = Settings::load().unwrap_or_else(|_| Settings::default()); let auto_compact = settings.auto_compact; - let auto_rlm = settings.auto_rlm; let show_thinking = settings.show_thinking; let show_tool_details = settings.show_tool_details; let max_input_history = settings.max_input_history; @@ -376,8 +351,6 @@ impl App { "plan" => AppMode::Plan, "agent" | "normal" => AppMode::Agent, "yolo" => AppMode::Yolo, - "rlm" => AppMode::Rlm, - "duo" => AppMode::Duo, _ => AppMode::Agent, }; let initial_mode = if yolo { @@ -445,7 +418,6 @@ impl App { input_history: Vec::new(), history_index: None, auto_compact, - auto_rlm, show_thinking, show_tool_details, compact_threshold: 50000, @@ -484,9 +456,6 @@ impl App { plan_state, plan_prompt_pending: false, plan_tool_used_in_turn: false, - rlm_session: Arc::new(Mutex::new(RlmSession::default())), - duo_session: new_shared_duo_session(), - rlm_repl_active: false, todos: new_shared_todo_list(), tool_log: Vec::new(), session_cost: 0.0, @@ -550,7 +519,6 @@ impl App { } else { ApprovalMode::Suggest }; - self.rlm_repl_active = false; if mode != AppMode::Plan { self.plan_prompt_pending = false; self.plan_tool_used_in_turn = false; @@ -565,14 +533,12 @@ impl App { let _ = self.hooks.execute(HookEvent::ModeChange, &context); } - /// Cycle through modes: Plan โ†’ Agent โ†’ YOLO โ†’ RLM โ†’ Duo โ†’ Plan + /// Cycle through modes: Plan โ†’ Agent โ†’ YOLO โ†’ Plan pub fn cycle_mode(&mut self) { let next = match self.mode { AppMode::Plan => AppMode::Agent, AppMode::Agent => AppMode::Yolo, - AppMode::Yolo => AppMode::Rlm, - AppMode::Rlm => AppMode::Duo, - AppMode::Duo | AppMode::Normal => AppMode::Plan, + AppMode::Yolo | AppMode::Normal => AppMode::Plan, }; self.set_mode(next); } diff --git a/src/tui/history.rs b/src/tui/history.rs index fa49521e..429c48bd 100644 --- a/src/tui/history.rs +++ b/src/tui/history.rs @@ -27,8 +27,6 @@ const TOOL_TEXT_LIMIT: usize = 240; pub enum HistoryCell { User { content: String }, Assistant { content: String, streaming: bool }, - Player { content: String, streaming: bool }, - Coach { content: String, streaming: bool }, System { content: String }, Thinking { content: String, streaming: bool }, Tool(ToolCell), @@ -67,30 +65,6 @@ impl HistoryCell { } lines } - HistoryCell::Player { content, streaming } => { - let mut lines = render_message("Player", content, player_style(), width); - if *streaming { - if let Some(last) = lines.last_mut() { - last.spans.push(Span::styled( - "โ–‹", - Style::default().fg(palette::DEEPSEEK_SKY), - )); - } - } - lines - } - HistoryCell::Coach { content, streaming } => { - let mut lines = render_message("Coach", content, coach_style(), width); - if *streaming { - if let Some(last) = lines.last_mut() { - last.spans.push(Span::styled( - "โ–‹", - Style::default().fg(palette::DEEPSEEK_SKY), - )); - } - } - lines - } HistoryCell::System { content } => { render_message("System", content, system_style(), width) } @@ -148,15 +122,6 @@ impl HistoryCell { /// Convert a message into history cells for rendering. #[must_use] pub fn history_cells_from_message(msg: &Message) -> Vec { - history_cells_from_message_with_mode(msg, None) -} - -/// Convert a message into history cells with optional Duo phase context. -#[must_use] -pub fn history_cells_from_message_with_mode( - msg: &Message, - duo_phase: Option, -) -> Vec { let mut cells = Vec::new(); let mut text_blocks = Vec::new(); let mut thinking_blocks = Vec::new(); @@ -174,23 +139,10 @@ pub fn history_cells_from_message_with_mode( match msg.role.as_str() { "user" => cells.push(HistoryCell::User { content }), "assistant" => { - let cell = match duo_phase { - Some(crate::duo::DuoPhase::Player) | Some(crate::duo::DuoPhase::Init) => { - HistoryCell::Player { - content, - streaming: false, - } - } - Some(crate::duo::DuoPhase::Coach) => HistoryCell::Coach { - content, - streaming: false, - }, - _ => HistoryCell::Assistant { - content, - streaming: false, - }, - }; - cells.push(cell); + cells.push(HistoryCell::Assistant { + content, + streaming: false, + }); } "system" => cells.push(HistoryCell::System { content }), _ => {} @@ -1321,16 +1273,6 @@ fn assistant_style() -> Style { Style::default().fg(palette::DEEPSEEK_SKY) } -fn player_style() -> Style { - Style::default().fg(palette::MODE_DUO) -} - -fn coach_style() -> Style { - Style::default() - .fg(palette::DEEPSEEK_BLUE) - .add_modifier(Modifier::BOLD) -} - fn system_style() -> Style { Style::default().fg(palette::TEXT_MUTED).italic() } diff --git a/src/tui/onboarding/mod.rs b/src/tui/onboarding/mod.rs index 5b2a13bf..8f55247b 100644 --- a/src/tui/onboarding/mod.rs +++ b/src/tui/onboarding/mod.rs @@ -55,9 +55,7 @@ pub fn tips_lines() -> Vec> { .add_modifier(Modifier::BOLD), )), Line::from(""), - Line::from(Span::raw( - " - Tab cycles modes (Plan โ†’ Agent โ†’ YOLO โ†’ RLM โ†’ Duo)", - )), + Line::from(Span::raw(" - Tab cycles modes (Plan โ†’ Agent โ†’ YOLO)")), Line::from(Span::raw(" - Ctrl+R opens the session picker")), Line::from(Span::raw(" - l opens the pager for the last message")), Line::from(Span::raw(" - Ctrl+C cancels or exits")), diff --git a/src/tui/ui.rs b/src/tui/ui.rs index a859c3d5..eb379a3c 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -1,9 +1,8 @@ //! TUI event loop and rendering logic for `DeepSeek` CLI. use std::fmt::Write; -use std::fs; use std::io::{self, Stdout}; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use std::time::Instant; use anyhow::Result; @@ -37,7 +36,6 @@ use crate::hooks::HookEvent; use crate::models::{ContentBlock, Message, SystemPrompt, context_window_for_model}; use crate::palette; use crate::prompts; -use crate::rlm; use crate::session_manager::{ SavedSession, SessionManager, create_saved_session_with_mode, update_session, }; @@ -61,8 +59,8 @@ use super::approval::{ use super::history::{ DiffPreviewCell, ExecCell, ExecSource, ExploringCell, ExploringEntry, GenericToolCell, HistoryCell, McpToolCell, PatchSummaryCell, PlanStep, PlanUpdateCell, ReviewCell, ToolCell, - ToolStatus, ViewImageCell, WebSearchCell, history_cells_from_message_with_mode, - summarize_mcp_output, summarize_tool_args, summarize_tool_output, + ToolStatus, ViewImageCell, WebSearchCell, history_cells_from_message, summarize_mcp_output, + summarize_tool_args, summarize_tool_output, }; use super::views::{HelpView, ModalKind, ViewEvent}; use super::widgets::{ChatWidget, ComposerWidget, HeaderData, HeaderWidget, Renderable}; @@ -70,28 +68,6 @@ use super::widgets::{ChatWidget, ComposerWidget, HeaderData, HeaderWidget, Rende // === Constants === const MAX_QUEUED_PREVIEW: usize = 3; -const AUTO_RLM_MIN_FILE_BYTES: u64 = 200_000; -const AUTO_RLM_HINT_FILE_BYTES: u64 = 50_000; -const AUTO_RLM_PASTE_MIN_CHARS: usize = 15_000; -const AUTO_RLM_PASTE_HINT_CHARS: usize = 5_000; -const AUTO_RLM_PASTE_QUERY_MAX_CHARS: usize = 800; -const AUTO_RLM_PASTE_FIRST_LINE_MAX_CHARS: usize = 200; -const RLM_BUDGET_WARN_QUERIES: u32 = 8; -const RLM_BUDGET_WARN_INPUT_TOKENS: u64 = 60_000; -const RLM_BUDGET_WARN_OUTPUT_TOKENS: u64 = 20_000; -const RLM_BUDGET_HARD_QUERIES: u32 = 16; -const RLM_BUDGET_HARD_INPUT_TOKENS: u64 = 120_000; -const RLM_BUDGET_HARD_OUTPUT_TOKENS: u64 = 40_000; -const AUTO_RLM_MAX_SCAN_ENTRIES: usize = 50_000; -const AUTO_RLM_EXCLUDED_DIRS: &[&str] = &[ - ".git", - "target", - "node_modules", - ".codex", - ".aleph", - "dist", - "build", -]; /// Run the interactive TUI event loop. /// @@ -156,18 +132,8 @@ pub async fn run_tui(config: &Config, options: TuiOptions) -> Result<()> { ), }); - let duo_phase = if app.mode == AppMode::Duo { - app.duo_session - .lock() - .ok() - .and_then(|s| s.active_state.as_ref().map(|st| st.phase)) - } else { - None - }; - for msg in &saved.messages { - app.history - .extend(history_cells_from_message_with_mode(msg, duo_phase)); + app.history.extend(history_cells_from_message(msg)); } app.mark_history_updated(); app.status_message = Some(format!("Resumed session: {}", &saved.metadata.id[..8])); @@ -197,8 +163,6 @@ pub async fn run_tui(config: &Config, options: TuiOptions) -> Result<()> { max_steps: 100, max_subagents: app.max_subagents, features: config.features(), - rlm_session: app.rlm_session.clone(), - duo_session: app.duo_session.clone(), compaction, todos: app.todos.clone(), plan_state: app.plan_state.clone(), @@ -280,45 +244,18 @@ async fn run_event_loop( let index = if let Some(index) = app.streaming_message_index { index } else { - let duo_phase = if app.mode == AppMode::Duo { - app.duo_session - .lock() - .ok() - .and_then(|s| s.active_state.as_ref().map(|st| st.phase)) - } else { - None - }; - - let cell = match duo_phase { - Some(crate::duo::DuoPhase::Player) - | Some(crate::duo::DuoPhase::Init) => HistoryCell::Player { - content: String::new(), - streaming: true, - }, - Some(crate::duo::DuoPhase::Coach) => HistoryCell::Coach { - content: String::new(), - streaming: true, - }, - _ => HistoryCell::Assistant { - content: String::new(), - streaming: true, - }, - }; - - app.add_message(cell); + app.add_message(HistoryCell::Assistant { + content: String::new(), + streaming: true, + }); let index = app.history.len().saturating_sub(1); app.streaming_message_index = Some(index); index }; if let Some(cell) = app.history.get_mut(index) { - match cell { - HistoryCell::Assistant { content, .. } - | HistoryCell::Player { content, .. } - | HistoryCell::Coach { content, .. } => { - content.clone_from(¤t_streaming_text); - } - _ => {} + if let HistoryCell::Assistant { content, .. } = cell { + content.clone_from(¤t_streaming_text); } app.mark_history_updated(); } @@ -888,9 +825,6 @@ async fn run_event_loop( } KeyCode::Tab => { app.cycle_mode(); - if app.mode == AppMode::Rlm { - app.rlm_repl_active = false; - } } // Input handling KeyCode::Char('j') if key.modifiers.contains(KeyModifiers::CONTROL) => { @@ -967,18 +901,6 @@ async fn run_event_loop( continue; } - if app.mode == AppMode::Rlm && app.rlm_repl_active { - if rlm_repl_should_route_to_chat(app, &input) { - app.rlm_repl_active = false; - app.add_message(HistoryCell::System { - content: "RLM REPL paused (no context loaded). Routing to chat so the model can call rlm_load. Use /repl to return.".to_string(), - }); - } else { - handle_rlm_input(app, input); - continue; - } - } - let queued = if let Some(mut draft) = app.queued_draft.take() { draft.display = input; draft @@ -1177,34 +1099,11 @@ async fn dispatch_user_message( // Set immediately to prevent double-dispatch before TurnStarted event arrives. app.is_loading = true; - let override_query = maybe_auto_switch_to_rlm(app, &message.display); - let content = if let Some(query) = override_query.as_deref() { - message.content_with_query(query) - } else { - message.content() - }; - let rlm_summary = if app.mode == AppMode::Rlm { - app.rlm_session - .lock() - .ok() - .map(|session| rlm::session_summary(&session)) - } else { - None - }; - let duo_summary = if app.mode == AppMode::Duo { - app.duo_session - .lock() - .ok() - .map(|s| crate::duo::session_summary(&s)) - } else { - None - }; + let content = message.content(); app.system_prompt = Some(prompts::system_prompt_for_mode_with_context( app.mode, &app.workspace, None, - rlm_summary.as_deref(), - duo_summary.as_deref(), )); app.add_message(HistoryCell::User { content: message.display.clone(), @@ -1333,479 +1232,6 @@ async fn handle_plan_choice( Ok(true) } -fn handle_rlm_input(app: &mut App, input: String) { - if let Some(path) = input.trim().strip_prefix('@') { - let command = format!("/load @{path}"); - let result = commands::execute(&command, app); - if let Some(msg) = result.message { - app.add_message(HistoryCell::System { content: msg }); - } - return; - } - - app.add_message(HistoryCell::User { - content: input.clone(), - }); - - let content = match app.rlm_session.lock() { - Ok(mut session) => match rlm::eval_in_session(&mut session, &input) { - Ok(result) => { - let trimmed = result.trim(); - if trimmed.is_empty() { - "RLM: (no output)".to_string() - } else { - format!("RLM:\n{result}") - } - } - Err(err) => format!("RLM error: {err}"), - }, - Err(_) => "RLM error: failed to access session".to_string(), - }; - - app.add_message(HistoryCell::System { content }); -} - -struct AutoRlmDecision { - source: AutoRlmSource, - reason: String, -} - -enum AutoRlmSource { - File(PathBuf), - Paste { - content: String, - query: Option, - }, - None, -} - -struct AutoRlmLoaded { - context_id: String, - line_count: usize, - char_count: usize, -} - -fn maybe_auto_switch_to_rlm(app: &mut App, input: &str) -> Option { - if !app.auto_rlm { - return None; - } - let already_rlm = app.mode == AppMode::Rlm; - let decision = auto_rlm_decision(app, input, already_rlm)?; - - if !already_rlm { - app.set_mode(AppMode::Rlm); - app.rlm_repl_active = false; - } - - let mut messages = vec![if already_rlm { - format!("Auto-loaded RLM context ({})", decision.reason) - } else { - format!("Auto-switched to RLM mode ({})", decision.reason) - }]; - let mut override_query = None; - - match decision.source { - AutoRlmSource::File(path) => match load_file_into_rlm(app, &path) { - Ok(loaded) => { - messages.push(format!( - "Loaded {} as '{}' ({} lines, {} chars)", - format_load_path(app, &path), - loaded.context_id, - loaded.line_count, - loaded.char_count - )); - override_query = Some(format!( - "{}\n\nUse RLM context '{}' loaded from {}.", - input.trim(), - loaded.context_id, - format_load_path(app, &path) - )); - } - Err(err) => { - messages.push(format!("RLM auto-load failed: {err}")); - } - }, - AutoRlmSource::Paste { content, query } => match load_paste_into_rlm(app, content) { - Ok(loaded) => { - messages.push(format!( - "Loaded pasted content as '{}' ({} lines, {} chars)", - loaded.context_id, loaded.line_count, loaded.char_count - )); - let base_query = query.unwrap_or_else(|| { - "Analyze the pasted content and answer the user request.".to_string() - }); - override_query = Some(format!( - "{base_query}\n\nRLM context: '{}'.", - loaded.context_id - )); - } - Err(err) => { - messages.push(format!("RLM auto-load failed: {err}")); - override_query = Some( - "The user pasted a large block, but auto-loading failed. Ask them to retry /load or paste again." - .to_string(), - ); - } - }, - AutoRlmSource::None => {} - } - - app.add_message(HistoryCell::System { - content: messages.join("\n"), - }); - - override_query -} - -fn auto_rlm_decision(app: &App, input: &str, already_rlm: bool) -> Option { - let input_lower = input.to_lowercase(); - let wants_largest_file = input_lower.contains("largest file") - || input_lower.contains("biggest file") - || input_lower.contains("largest files"); - let explicit_rlm_request = input_lower - .split_whitespace() - .any(|word| word.trim_matches(|c: char| !c.is_ascii_alphanumeric()) == "rlm") - || input_lower.contains("rlm mode"); - let explicit_rlm = already_rlm || explicit_rlm_request; - let has_hint = input_lower.contains("chunk") - || input_lower.contains("chunking") - || input_lower.contains("huge") - || input_lower.contains("massive") - || input_lower.contains("entire repo") - || input_lower.contains("whole repo") - || input_lower.contains("full repo") - || input_lower.contains("whole project") - || input_lower.contains("entire project") - || input_lower.contains("full project") - || explicit_rlm; - - if let Some(decision) = auto_rlm_paste_decision(input, explicit_rlm, has_hint) { - return Some(decision); - } - - if wants_largest_file && let Some((path, size)) = find_largest_file(&app.workspace) { - return Some(AutoRlmDecision { - source: AutoRlmSource::File(path), - reason: format!("requested largest file ({} bytes)", size), - }); - } - - let Some(candidate) = detect_requested_file(input, &app.workspace) else { - if explicit_rlm_request && !already_rlm { - return Some(AutoRlmDecision { - source: AutoRlmSource::None, - reason: "explicit RLM request".to_string(), - }); - } - return None; - }; - if !app.trust_mode { - let workspace_root = app - .workspace - .canonicalize() - .unwrap_or_else(|_| app.workspace.clone()); - let candidate_canonical = candidate - .canonicalize() - .unwrap_or_else(|_| candidate.clone()); - if !candidate_canonical.starts_with(&workspace_root) { - return None; - } - } - let metadata = fs::metadata(&candidate).ok()?; - if !metadata.is_file() { - return None; - } - - let size = metadata.len(); - let min_bytes = if has_hint { - AUTO_RLM_HINT_FILE_BYTES - } else { - AUTO_RLM_MIN_FILE_BYTES - }; - if size < min_bytes && !explicit_rlm { - return None; - } - - let reason = if explicit_rlm_request && !already_rlm { - format!("explicit RLM file request ({} bytes)", size) - } else if already_rlm { - format!("RLM file request ({} bytes)", size) - } else { - format!("large file ({} bytes)", size) - }; - - Some(AutoRlmDecision { - source: AutoRlmSource::File(candidate), - reason, - }) -} - -fn auto_rlm_paste_decision( - input: &str, - explicit_rlm: bool, - has_hint: bool, -) -> Option { - let min_chars = if explicit_rlm || has_hint { - AUTO_RLM_PASTE_HINT_CHARS - } else { - AUTO_RLM_PASTE_MIN_CHARS - }; - - if input.len() < min_chars { - return None; - } - - let (query, content) = split_paste_input(input); - if content.len() < min_chars { - return None; - } - - Some(AutoRlmDecision { - source: AutoRlmSource::Paste { content, query }, - reason: format!("pasted content ({} chars)", input.len()), - }) -} - -fn split_paste_input(input: &str) -> (Option, String) { - let trimmed = input.trim(); - - if let Some(idx) = trimmed.find("```").or_else(|| trimmed.find("~~~")) { - let (prefix, rest) = trimmed.split_at(idx); - let query = clean_query_prefix(prefix); - if !query.is_empty() && query.len() <= AUTO_RLM_PASTE_QUERY_MAX_CHARS { - return (Some(query.to_string()), rest.trim_start().to_string()); - } - } - - if let Some(idx) = trimmed.find("\n\n") { - let (prefix, rest) = trimmed.split_at(idx); - let query = clean_query_prefix(prefix); - if !query.is_empty() && query.len() <= AUTO_RLM_PASTE_QUERY_MAX_CHARS { - return (Some(query.to_string()), rest.trim_start().to_string()); - } - } - - if let Some((first, rest)) = trimmed.split_once('\n') { - let query = clean_query_prefix(first); - if !query.is_empty() && query.len() <= AUTO_RLM_PASTE_FIRST_LINE_MAX_CHARS { - return (Some(query.to_string()), rest.trim_start().to_string()); - } - } - - (None, trimmed.to_string()) -} - -fn clean_query_prefix(prefix: &str) -> &str { - prefix.trim().trim_end_matches(':') -} - -fn load_file_into_rlm(app: &mut App, path: &Path) -> Result { - let mut session = app - .rlm_session - .lock() - .map_err(|_| "Failed to access RLM session".to_string())?; - let base_id = rlm::context_id_from_path(path); - let context_id = rlm::unique_context_id(&session, &base_id); - let (line_count, char_count) = session - .load_file(&context_id, path) - .map_err(|err| err.to_string())?; - Ok(AutoRlmLoaded { - context_id, - line_count, - char_count, - }) -} - -fn load_paste_into_rlm(app: &mut App, content: String) -> Result { - let mut session = app - .rlm_session - .lock() - .map_err(|_| "Failed to access RLM session".to_string())?; - let context_id = rlm::unique_context_id(&session, "paste"); - let line_count = content.lines().count(); - let char_count = content.len(); - session.load_context(&context_id, content, Some("pasted input".to_string())); - Ok(AutoRlmLoaded { - context_id, - line_count, - char_count, - }) -} - -fn detect_requested_file(input: &str, workspace: &Path) -> Option { - if input.to_lowercase().contains("readme") { - let readme = ["README.md", "README", "README.txt"]; - for name in readme { - let candidate = workspace.join(name); - if candidate.is_file() { - return Some(candidate); - } - } - } - - for token in input.split_whitespace() { - let token = trim_token(token); - if token.is_empty() || token.contains("://") { - continue; - } - if !looks_like_path_token(token) { - continue; - } - if let Some(path) = resolve_candidate_path(token, workspace) { - return Some(path); - } - } - - None -} - -fn trim_token(token: &str) -> &str { - token - .trim_start_matches(['(', '[', '{', '"', '\'', '`']) - .trim_end_matches([')', ']', '}', ',', ';', ':', '"', '\'', '`', '.']) -} - -fn looks_like_path_token(token: &str) -> bool { - let lower = token.to_lowercase(); - if lower == "readme" || lower == "readme.md" { - return true; - } - if token.starts_with('@') || token.contains('/') || token.contains('\\') { - return true; - } - matches!( - token.rsplit('.').next(), - Some( - "md" | "txt" - | "rs" - | "toml" - | "json" - | "yaml" - | "yml" - | "py" - | "js" - | "ts" - | "tsx" - | "jsx" - | "go" - | "java" - | "c" - | "h" - | "cpp" - | "log" - ) - ) -} - -fn resolve_candidate_path(token: &str, workspace: &Path) -> Option { - let candidate = if let Some(stripped) = token.strip_prefix('@') { - workspace.join(stripped.trim_start_matches(['/', '\\'])) - } else if Path::new(token).is_absolute() { - PathBuf::from(token) - } else { - workspace.join(token) - }; - - if candidate.is_file() { - return Some(candidate); - } - None -} - -fn find_largest_file(workspace: &Path) -> Option<(PathBuf, u64)> { - let mut stack = vec![workspace.to_path_buf()]; - let mut scanned = 0; - let mut largest: Option<(PathBuf, u64)> = None; - - while let Some(dir) = stack.pop() { - if scanned >= AUTO_RLM_MAX_SCAN_ENTRIES { - break; - } - let Ok(entries) = fs::read_dir(&dir) else { - continue; - }; - for entry in entries.flatten() { - scanned += 1; - if scanned >= AUTO_RLM_MAX_SCAN_ENTRIES { - break; - } - let path = entry.path(); - if path.is_dir() { - if let Some(name) = path.file_name().and_then(|s| s.to_str()) - && AUTO_RLM_EXCLUDED_DIRS.contains(&name) - { - continue; - } - stack.push(path); - } else if path.is_file() { - let Ok(metadata) = entry.metadata() else { - continue; - }; - let size = metadata.len(); - match largest { - Some((_, current)) if size <= current => {} - _ => largest = Some((path, size)), - } - } - } - } - - largest -} - -fn format_load_path(app: &App, path: &Path) -> String { - if let Ok(stripped) = path.strip_prefix(&app.workspace) { - return format!("@{}", stripped.display()); - } - path.display().to_string() -} - -fn looks_like_rlm_expr(input: &str) -> bool { - let trimmed = input.trim(); - if trimmed.is_empty() { - return false; - } - if trimmed.starts_with('/') { - return true; - } - - let token = trimmed - .split(|c: char| c == '(' || c.is_whitespace()) - .next() - .unwrap_or(""); - matches!( - token, - "len" - | "line_count" - | "lines" - | "search" - | "chunk" - | "chunk_sections" - | "chunk_lines" - | "chunk_auto" - | "vars" - | "get" - | "set" - | "append" - | "del" - | "head" - | "tail" - | "peek" - ) -} - -fn rlm_repl_should_route_to_chat(app: &App, input: &str) -> bool { - let trimmed = input.trim(); - if trimmed.is_empty() || looks_like_rlm_expr(trimmed) { - return false; - } - - let Ok(session) = app.rlm_session.lock() else { - return false; - }; - session.contexts.is_empty() -} - fn render(f: &mut Frame, app: &mut App) { let size = f.area(); @@ -1831,7 +1257,7 @@ fn render(f: &mut Frame, app: &mut App) { let status_lines = usize::from(app.is_loading); let status_height = u16::try_from(status_lines + queued_lines + editing_lines).unwrap_or(u16::MAX); - let prompt = prompt_for_mode(app.mode, app.rlm_repl_active); + let prompt = prompt_for_mode(app.mode); let available_height = size .height .saturating_sub(header_height + footer_height + status_height); @@ -2011,18 +1437,8 @@ fn apply_loaded_session(app: &mut App, session: &SavedSession) { app.api_messages.clone_from(&session.messages); app.history.clear(); - let duo_phase = if app.mode == AppMode::Duo { - app.duo_session - .lock() - .ok() - .and_then(|s| s.active_state.as_ref().map(|st| st.phase)) - } else { - None - }; - for msg in &app.api_messages { - app.history - .extend(history_cells_from_message_with_mode(msg, duo_phase)); + app.history.extend(history_cells_from_message(msg)); } app.mark_history_updated(); app.transcript_selection.clear(); @@ -2169,35 +1585,7 @@ fn render_footer(f: &mut Frame, area: Rect, app: &App) { let width = area.width; let available_width = width as usize; - // Status message override (Toast) - if let Some(ref msg) = app.status_message { - let status_span = Span::styled(msg, Style::default().fg(palette::DEEPSEEK_SKY)); - f.render_widget(Paragraph::new(Line::from(vec![status_span])), area); - return; - } - - // 1. Time (Left) - let time_str = Local::now().format("%H:%M").to_string(); - let time_span = Span::styled( - format!("{} ", time_str), - Style::default().fg(palette::TEXT_DIM), - ); - - // 2. Mode (Left) - Lowercase, colored - let mode_str = app.mode.label().to_lowercase(); - let mode_style = mode_badge_style(app.mode); - let mode_span = Span::styled(format!("{} ", mode_str), mode_style); - - // 3. Agent Info (Left) - let model = &app.model; - let status_suffix = if app.is_loading { ", thinking" } else { "" }; - let agent_text = format!("agent ({}{})", model, status_suffix); - let agent_span = Span::styled(agent_text, Style::default().fg(palette::TEXT_DIM)); - - // Left side assembly - let left_spans = vec![time_span, mode_span, agent_span]; - - // 4. Context Progress Bar (Right) + // 1. Context Progress Bar (Right) let percent = get_context_percent_decimal(app); let bar_width = 10; // Width of the progress bar let filled = ((percent / 100.0) * bar_width as f32).round() as usize; @@ -2217,7 +1605,7 @@ fn render_footer(f: &mut Frame, area: Rect, app: &App) { let context_text = format!("[{}{}] {:.0}%", bar_filled, bar_empty, percent); let context_span = Span::styled(context_text, Style::default().fg(bar_color)); - // 5. Right side extras (Scroll, Selection, RLM) - Minimalist + // 2. Right side extras (Scroll, Selection) - Minimalist let mut right_extras = Vec::new(); // Scroll % @@ -2237,23 +1625,49 @@ fn render_footer(f: &mut Frame, area: Rect, app: &App) { )); } - // RLM Badge - if app.mode == AppMode::Rlm { - if let Some((badge, style)) = rlm_usage_badge(app) { - right_extras.push(Span::styled(" ", Style::default())); - right_extras.push(Span::styled(badge, style)); - } - } - // Assemble Right Side // context_span is always last let mut right_spans = right_extras; right_spans.push(Span::raw(" ")); // Space before context right_spans.push(context_span); + let right_width: usize = right_spans.iter().map(|s| s.content.width()).sum(); + + // 3. Left side content (Status toast or standard footer) + let left_spans = if let Some(msg) = app.status_message.as_ref() { + let max_left = available_width + .saturating_sub(right_width) + .saturating_sub(1) + .max(1); + let truncated = truncate_line_to_width(msg, max_left); + vec![Span::styled( + truncated, + Style::default().fg(palette::DEEPSEEK_SKY), + )] + } else { + // Time (Left) + let time_str = Local::now().format("%H:%M").to_string(); + let time_span = Span::styled( + format!("{} ", time_str), + Style::default().fg(palette::TEXT_DIM), + ); + + // Mode (Left) - Lowercase, colored + let mode_str = app.mode.label().to_lowercase(); + let mode_style = mode_badge_style(app.mode); + let mode_span = Span::styled(format!("{} ", mode_str), mode_style); + + // Agent Info (Left) + let model = &app.model; + let status_suffix = if app.is_loading { ", thinking" } else { "" }; + let agent_text = format!("agent ({}{})", model, status_suffix); + let agent_span = Span::styled(agent_text, Style::default().fg(palette::TEXT_DIM)); + + vec![time_span, mode_span, agent_span] + }; + // Calculate Widths let left_width: usize = left_spans.iter().map(|s| s.content.width()).sum(); - let right_width: usize = right_spans.iter().map(|s| s.content.width()).sum(); // Spacer let spacer_width = available_width.saturating_sub(left_width + right_width); @@ -2264,13 +1678,25 @@ fn render_footer(f: &mut Frame, area: Rect, app: &App) { all_spans.extend(right_spans); } else { // Fallback for narrow screens: Drop agent info - let simple_left = vec![ - Span::styled( - format!("{} ", time_str), - Style::default().fg(palette::TEXT_DIM), - ), - Span::styled(format!("{} ", mode_str), mode_style), - ]; + let simple_left = if let Some(msg) = app.status_message.as_ref() { + let max_left = available_width.saturating_sub(10).saturating_sub(1).max(1); + let truncated = truncate_line_to_width(msg, max_left); + vec![Span::styled( + truncated, + Style::default().fg(palette::DEEPSEEK_SKY), + )] + } else { + let time_str = Local::now().format("%H:%M").to_string(); + let mode_str = app.mode.label().to_lowercase(); + let mode_style = mode_badge_style(app.mode); + vec![ + Span::styled( + format!("{} ", time_str), + Style::default().fg(palette::TEXT_DIM), + ), + Span::styled(format!("{} ", mode_str), mode_style), + ] + }; let bar_filled_narrow = "โ–ˆ".repeat(filled.min(5)); let bar_empty_narrow = "โ–‘".repeat(5 - filled.min(5)); let simple_right = vec![Span::styled( @@ -2315,47 +1741,12 @@ fn get_context_percent_decimal(app: &App) -> f32 { } } -fn rlm_usage_badge(app: &App) -> Option<(String, Style)> { - let session = app.rlm_session.lock().ok()?; - let usage = &session.usage; - if usage.queries == 0 { - return None; - } - - let warn = usage.queries >= RLM_BUDGET_WARN_QUERIES - || usage.input_tokens >= RLM_BUDGET_WARN_INPUT_TOKENS - || usage.output_tokens >= RLM_BUDGET_WARN_OUTPUT_TOKENS; - let hard = usage.queries >= RLM_BUDGET_HARD_QUERIES - || usage.input_tokens >= RLM_BUDGET_HARD_INPUT_TOKENS - || usage.output_tokens >= RLM_BUDGET_HARD_OUTPUT_TOKENS; - - let style = if hard { - Style::default() - .fg(palette::STATUS_ERROR) - .add_modifier(Modifier::BOLD) - } else if warn { - Style::default().fg(palette::STATUS_WARNING) - } else { - Style::default().fg(palette::TEXT_MUTED) - }; - - Some(( - format!( - "RLM q:{} in/out:{} /{}", - usage.queries, usage.input_tokens, usage.output_tokens - ), - style, - )) -} - fn mode_color(mode: AppMode) -> Color { match mode { AppMode::Normal => palette::MODE_NORMAL, AppMode::Agent => palette::MODE_AGENT, AppMode::Yolo => palette::MODE_YOLO, AppMode::Plan => palette::MODE_PLAN, - AppMode::Rlm => palette::MODE_RLM, - AppMode::Duo => palette::MODE_DUO, } } @@ -2366,20 +1757,12 @@ fn mode_badge_style(mode: AppMode) -> Style { .add_modifier(Modifier::BOLD) } -fn prompt_for_mode(mode: AppMode, rlm_repl_active: bool) -> &'static str { +fn prompt_for_mode(mode: AppMode) -> &'static str { match mode { AppMode::Normal => "> ", AppMode::Agent => "agent> ", AppMode::Yolo => "yolo> ", AppMode::Plan => "plan> ", - AppMode::Rlm => { - if rlm_repl_active { - "rlm(repl)> " - } else { - "rlm> " - } - } - AppMode::Duo => "duo> ", } } @@ -3433,11 +2816,6 @@ fn exec_is_background(input: &serde_json::Value) -> bool { #[cfg(test)] mod tests { use super::*; - use crate::config::Config; - use crate::tui::app::TuiOptions; - use std::fs; - use std::path::PathBuf; - use tempfile::tempdir; #[test] fn selection_point_from_position_ignores_top_padding() { @@ -3493,121 +2871,6 @@ mod tests { assert_eq!(p1.column, 0); } - fn make_test_app_with_workspace(workspace: PathBuf) -> App { - let options = TuiOptions { - model: "test-model".to_string(), - workspace, - allow_shell: false, - use_alt_screen: true, - max_subagents: 1, - skills_dir: PathBuf::from("."), - memory_path: PathBuf::from("memory.md"), - notes_path: PathBuf::from("notes.txt"), - mcp_config_path: PathBuf::from("mcp.json"), - use_memory: false, - start_in_agent_mode: false, - skip_onboarding: false, - yolo: false, - resume_session_id: None, - }; - App::new(options, &Config::default()) - } - - #[test] - fn looks_like_rlm_expr_detects_known_functions() { - assert!(looks_like_rlm_expr("lines(1, 10)")); - assert!(looks_like_rlm_expr("search(\"foo\")")); - assert!(looks_like_rlm_expr("vars()")); - assert!(!looks_like_rlm_expr("read the README")); - } - - #[test] - fn rlm_repl_routes_to_chat_when_no_context_loaded() { - let app = make_test_app_with_workspace(PathBuf::from(".")); - assert!(rlm_repl_should_route_to_chat( - &app, - "Please read the README" - )); - assert!(!rlm_repl_should_route_to_chat(&app, "lines(1, 5)")); - } - - #[test] - fn rlm_repl_stays_in_repl_when_context_exists() { - let app = make_test_app_with_workspace(PathBuf::from(".")); - { - let mut session = app.rlm_session.lock().expect("lock session"); - session.load_context("ctx", "hello".to_string(), None); - } - assert!(!rlm_repl_should_route_to_chat( - &app, - "Please read the README" - )); - } - - #[test] - fn auto_rlm_detects_large_file() { - let tmp = tempdir().expect("tempdir"); - let big = tmp.path().join("big.txt"); - let content = vec![b'a'; (AUTO_RLM_MIN_FILE_BYTES + 1) as usize]; - fs::write(&big, content).expect("write"); - - let app = make_test_app_with_workspace(tmp.path().to_path_buf()); - let decision = auto_rlm_decision(&app, "analyze big.txt", false).expect("decision"); - assert!(matches!(decision.source, AutoRlmSource::File(path) if path == big)); - } - - #[test] - fn auto_rlm_uses_largest_file_hint() { - let tmp = tempdir().expect("tempdir"); - let small = tmp.path().join("small.txt"); - let big = tmp.path().join("bigger.txt"); - fs::write(&small, b"tiny").expect("write"); - fs::write(&big, b"this is larger").expect("write"); - - let app = make_test_app_with_workspace(tmp.path().to_path_buf()); - let decision = - auto_rlm_decision(&app, "analyze the largest file", false).expect("decision"); - assert!(matches!(decision.source, AutoRlmSource::File(path) if path == big)); - } - - #[test] - fn auto_rlm_triggers_on_explicit_request() { - let tmp = tempdir().expect("tempdir"); - let app = make_test_app_with_workspace(tmp.path().to_path_buf()); - let decision = auto_rlm_decision(&app, "use rlm mode", false).expect("decision"); - assert!(matches!(decision.source, AutoRlmSource::None)); - } - - #[test] - fn auto_rlm_triggers_on_large_paste() { - let tmp = tempdir().expect("tempdir"); - let app = make_test_app_with_workspace(tmp.path().to_path_buf()); - let content = "a".repeat(AUTO_RLM_PASTE_MIN_CHARS + 5); - let input = format!("Summarize this\n\n{content}"); - let decision = auto_rlm_decision(&app, &input, false).expect("decision"); - match decision.source { - AutoRlmSource::Paste { content, query } => { - assert!(content.len() >= AUTO_RLM_PASTE_MIN_CHARS); - assert_eq!(query.as_deref(), Some("Summarize this")); - } - _ => panic!("expected paste decision"), - } - } - - #[test] - fn auto_rlm_is_disabled_when_setting_off() { - let tmp = tempdir().expect("tempdir"); - let mut app = make_test_app_with_workspace(tmp.path().to_path_buf()); - app.auto_rlm = false; - - let content = "a".repeat(AUTO_RLM_PASTE_MIN_CHARS + 5); - let input = format!("Summarize this\n\n{content}"); - let override_query = maybe_auto_switch_to_rlm(&mut app, &input); - - assert!(override_query.is_none()); - assert_ne!(app.mode, AppMode::Rlm); - } - #[test] fn parse_plan_choice_accepts_numbers() { assert_eq!(parse_plan_choice("1"), Some(PlanChoice::ImplementAgent)); diff --git a/src/tui/views/mod.rs b/src/tui/views/mod.rs index b51c4279..6b49e1f8 100644 --- a/src/tui/views/mod.rs +++ b/src/tui/views/mod.rs @@ -284,7 +284,7 @@ impl ModalView for HelpView { "Modes:", Style::default().fg(palette::DEEPSEEK_SKY).bold(), )]), - Line::from(" Tab cycles modes: Plan โ†’ Agent โ†’ YOLO โ†’ RLM โ†’ Duo"), + Line::from(" Tab cycles modes: Plan โ†’ Agent โ†’ YOLO"), Line::from(""), Line::from(vec![Span::styled( "Commands:", @@ -299,18 +299,6 @@ impl ModalView for HelpView { ))); } - help_lines.push(Line::from("")); - help_lines.push(Line::from(vec![Span::styled( - "RLM / Aleph:", - Style::default().fg(palette::DEEPSEEK_SKY).bold(), - )])); - help_lines.push(Line::from(" /rlm or /aleph - Enter external memory mode")); - help_lines.push(Line::from( - " /load @path - Load a file into RLM context", - )); - help_lines.push(Line::from(" /repl - Toggle expression mode")); - help_lines.push(Line::from(" /status - Show contexts and usage")); - help_lines.push(Line::from("")); help_lines.push(Line::from(vec![Span::styled( "Tools:", diff --git a/src/tui/widgets/header.rs b/src/tui/widgets/header.rs index fe2ef83f..cdf04e5f 100644 --- a/src/tui/widgets/header.rs +++ b/src/tui/widgets/header.rs @@ -60,8 +60,6 @@ impl<'a> HeaderWidget<'a> { AppMode::Agent => palette::MODE_AGENT, AppMode::Yolo => palette::MODE_YOLO, AppMode::Plan => palette::MODE_PLAN, - AppMode::Rlm => palette::MODE_RLM, - AppMode::Duo => palette::MODE_DUO, } } diff --git a/src/tui/widgets/mod.rs b/src/tui/widgets/mod.rs index 4b4e8250..bfadbe50 100644 --- a/src/tui/widgets/mod.rs +++ b/src/tui/widgets/mod.rs @@ -5,7 +5,7 @@ pub use header::{HeaderData, HeaderWidget}; pub use renderable::Renderable; use crate::palette; -use crate::tui::app::{App, AppMode}; +use crate::tui::app::App; use crate::tui::approval::{ApprovalRequest, ElevationOption, ElevationRequest, ToolCategory}; use crate::tui::scrolling::TranscriptScroll; use ratatui::{ @@ -128,15 +128,7 @@ impl Renderable for ComposerWidget<'_> { let mut lines = Vec::new(); if self.app.input.is_empty() { - let placeholder = if self.app.mode == AppMode::Rlm { - if self.app.rlm_repl_active { - "Type an RLM expression or /repl to exit..." - } else { - "Ask a question or /repl to enter expression mode..." - } - } else { - "Type a message or /help for commands..." - }; + let placeholder = "Type a message or /help for commands..."; lines.push(Line::from(vec![ Span::styled( self.prompt,