diff --git a/CLAUDE.md b/CLAUDE.md index 75ad111..91c3f9a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,110 +1,330 @@ -# Slug Code +# Slug Code — Agent & Developer Reference -FOSS Rust rewrite of Claude Code — an AI coding assistant with a pluggable LLM backend targeting OpenAI-compatible APIs (vLLM, Ollama, llama.cpp, OpenAI, etc.). +FOSS Rust rewrite of Claude Code. Pluggable LLM backend targeting OpenAI-compatible APIs (vLLM primary). Binary: `slug`. Package: `slug-code`. Rust 2024 edition. -## Project +Repo layout: `master` = original TypeScript Claude Code source (reference only). `rust-rewrite` = this Rust implementation (all work happens here). -- **Binary:** `slug` -- **Package:** `slug-code` -- **Branch:** `rust-rewrite` (orphan branch — `master` holds original TypeScript source as reference) -- **Working directory:** `/home/bryan/Downloads/claude/src` +--- -## Architecture +## Module Map -| Module | Purpose | -|--------|---------| -| `src/main.rs` | CLI entry (clap), wires everything together | -| `src/provider/` | Provider trait + OpenAI-compatible streaming SSE impl | -| `src/agent/` | Core agent loop: stream → tool exec → repeat, SLUG.md injected per turn | -| `src/tools/` | Tool trait + bash, read, write, edit, glob, grep | -| `src/tui/` | Interactive REPL (basic stdin/stdout — ratatui TUI not yet built) | -| `src/permissions/` | ask/yolo/sandbox/allowEdits + glob allow/deny from settings.json | -| `src/slugmd/` | SLUG.md hierarchy loaded every turn (40K char budget) | -| `src/session/` | JSONL session persistence at ~/.slug/sessions/ | -| `src/hooks/` | 5 lifecycle events, command + prompt hook types | -| `src/compact/` | 3 compaction strategies, /compact command | -| `src/config/` | TOML config + CLI overrides + env vars | - -## Conventions - -- Rust 2024 edition -- No `unwrap()` in non-test code — use `?` or `anyhow` -- No analytics, telemetry, or Anthropic-internal features -- Provider trait is the abstraction boundary — new LLM backends implement it -- Permissions are checked in `agent::execute_with_permission` before any tool runs -- SLUG.md is never stored in `self.messages` — rebuilt from disk every turn - -## Completed Features - -- OpenAI-compatible provider with streaming SSE -- 6 core tools: bash, read, write, edit, glob, grep -- Agent loop with tool use -- Permission system: ask / yolo / sandbox / allowEdits + glob patterns in `~/.slug/settings.json` -- SLUG.md hierarchy (global/project/rules/local) -- Session persistence: `--continue`, `--resume`, `--fork-session` -- Hook system: PreToolUse, PostToolUse, UserPromptSubmit, SessionStart, SessionEnd -- Compaction: ToolResultTrim → Truncate, `/compact` command - -## Remaining Work (Prioritized) - -### High -- Proper ratatui TUI — markdown rendering, scrollback, syntax highlighting -- Retry system — exponential backoff + model fallback on repeated failures -- Clean interrupt — Escape aborts active stream without losing context -- Wire PreToolUse/PostToolUse hooks into agent tool execution -- Concurrent reads / serial writes (tool batching) - -### Medium -- Anthropic API provider (different format from OpenAI) -- Google Gemini provider -- MCP client support -- Subagent parallelism (fork/teammate/worktree models) - -### Low -- Computer use integration -- Session search -- Plugin system beyond hooks - -## Running - -```bash -cargo build -./target/debug/slug --help - -# Connect to local vLLM -slug -e http://localhost:8000/v1 -m qwen2.5-coder - -# Connect to OpenAI -slug -e https://api.openai.com/v1 -k $OPENAI_API_KEY -m gpt-4o - -# Resume last session -slug --continue - -# Sandbox mode (auto-approve everything in current dir) -slug --sandbox . - -# Skip all permissions -slug --yolo +``` +src/ +├── main.rs CLI entry (clap). Wires all modules. Handles session flags. +├── config/mod.rs Config struct + PermissionMode enum. TOML + CLI + env. +├── provider/ +│ ├── mod.rs Provider trait + OpenAIProvider (SSE streaming impl) +│ └── types.rs ChatMessage, Role, ToolCall, StreamEvent, StreamChunk +├── agent/mod.rs Core loop: user input → LLM stream → tool exec → repeat +├── tools/ +│ ├── mod.rs Tool trait + ToolRegistry (HashMap>) +│ ├── bash.rs BashTool — runs via `bash -c`, captures stdout+stderr +│ ├── read.rs ReadTool — reads file with line numbers, offset+limit +│ ├── write.rs WriteTool — writes file, creates parent dirs +│ ├── edit.rs EditTool — exact string replacement, enforces uniqueness +│ ├── glob.rs GlobTool — globwalk, max_depth 20, sorted output +│ └── grep.rs GrepTool — prefers rg, falls back to grep, 250 line cap +├── permissions/mod.rs PermissionHandler. Glob allow/deny. ask/yolo/sandbox/allowEdits. +├── slugmd/mod.rs Loads SLUG.md hierarchy every turn, 40K char budget. +├── session/mod.rs JSONL persistence at ~/.slug/sessions/. SessionManager. +├── hooks/mod.rs HookManager. 5 events, command+prompt types. .slug/hooks.json. +├── compact/mod.rs Compactor. ToolResultTrim→Truncate. /compact command. +└── tui/mod.rs Interactive REPL. Wires agent+session+hooks. Slash commands. ``` -## Settings +--- -Config file: `~/.slug/config.toml` +## Key Types -```toml -endpoint = "http://localhost:8000/v1" -model = "qwen2.5-coder-32b" -max_tokens = 4096 -permission_mode = { type = "ask" } +### provider/types.rs +```rust +ChatMessage { role: Role, content: Option, tool_calls: Option>, tool_call_id: Option } +// Constructors: ChatMessage::system(&str), ::user(&str), ::assistant(Option, Option>), ::tool_result(&str, &str) + +Role: System | User | Assistant | Tool // serde: lowercase + +ToolCall { id: String, call_type: String, function: FunctionCall } +FunctionCall { name: String, arguments: String } // arguments is JSON string + +StreamEvent: Text(String) | ToolCallDelta(ToolCallDelta) | Finish | Done +ToolCallDelta { index: usize, id: Option, name: Option, arguments_delta: Option } ``` -Permission rules: `~/.slug/settings.json` or `.slug/settings.json` +### provider/mod.rs +```rust +trait Provider: Send + Sync { + fn stream_chat(&self, messages: &[ChatMessage], tools: &[ToolDefinition]) + -> Pin> + Send + '_>>; +} +// OpenAIProvider implements Provider. POST /chat/completions with stream:true. +// SSE parsing: buffer bytes → split on \n → strip "data: " prefix → parse StreamChunk. +// Tool calls assembled via ToolCallAccumulator keyed by delta.index. +``` -```json -{ - "permissions": { - "allow": ["Bash(cargo *)", "Bash(git *)", "Edit(src/**)", "Write(src/**)"], - "deny": ["Bash(rm -rf *)", "Bash(sudo *)"] - } +### tools/mod.rs +```rust +trait Tool: Send + Sync { + fn definition(&self) -> ToolDefinition; + fn execute(&self, args: &Value) -> Result; +} +ToolDefinition { name: String, description: String, parameters: Value } // parameters is JSON Schema +// ToolRegistry: HashMap>. execute() returns Result — errors become tool result strings in agent. +``` + +### config/mod.rs +```rust +Config { endpoint, api_key, model, system_prompt, max_tokens: u32, temperature: Option, max_tool_rounds: usize, permission_mode: PermissionMode } +// Defaults: endpoint="http://localhost:8000/v1", model="default", max_tokens=4096, max_tool_rounds=50 +// Load order: TOML file → with_cli_overrides() + +PermissionMode: Ask | Yolo | AllowEdits | Sandbox(String) +// serde: { "type": "ask" } / { "type": "yolo" } / { "type": "allowedits" } / { "type": "sandbox", ... } +``` + +### permissions/mod.rs +```rust +PermissionRequest: Bash { command: &str } | FileWrite { path: &str } | FileEdit { path: &str } +// check() order: deny rules → allow rules → mode fallback +// Glob patterns: "Bash(npm *)", "Edit(src/**)", "Write(tests/**)" +// Settings loaded from ~/.slug/settings.json then .slug/settings.json (merged, project extends global) +// glob_match(pattern, text): ** matches across /, * does not +``` + +### session/mod.rs +```rust +SessionManager { sessions_dir: PathBuf } +Session { session_id: String, messages: Vec } +SessionMeta { session_id, created_at, last_used_at, working_directory, model, summary } +// Storage: ~/.slug/sessions/{id}.jsonl (one ChatMessage per line) + {id}.meta.json +// Methods: create_session(), load_session(&str), save_message(&str, &ChatMessage), list_sessions(), get_latest_session(), fork_session(&str) +``` + +### hooks/mod.rs +```rust +HookEvent: PreToolUse { tool_name, args } | PostToolUse { tool_name, args, result } | UserPromptSubmit { prompt } | SessionStart | SessionEnd +HookAction: Command { command: String } | Prompt { content: String } +HookResult { additional_context: Option, blocked: bool, block_reason: Option } +// HookManager::new() loads ~/.slug/settings.json + .slug/hooks.json +// fire(&HookEvent) → HookResult. Command hooks: sh -c, 30s timeout, non-zero = blocked. +// Config: { "hooks": [{ "event": "PreToolUse", "tool_filter": "bash", "action": { "type": "command", "command": "..." } }] } +``` + +### compact/mod.rs +```rust +CompactionStrategy: Full | Truncate | ToolResultTrim +Compactor { max_context_tokens: usize } // default 200_000 +// estimate_tokens(text) = chars / 4 +// needs_compaction() = estimated > 80% of max +// ToolResultTrim: replace Tool role messages older than last 5 with "[result truncated — N chars]" +// Truncate: keep system prompt + last 10 user-turn groups +// extract_session_memory(): structured summary of task, files, errors, decisions +``` + +### slugmd/mod.rs +```rust +load_slug_context() -> String +// Load order (all optional, concatenated with headers): +// 1. ~/.slug/SLUG.md "# Global Rules" +// 2. ./SLUG.md "# Project Rules" +// 3. .slug/rules/*.md "# Rule: {filename}" (sorted alphabetically) +// 4. ./SLUG.local.md "# Local Notes (private)" +// 40,000 char hard limit — truncates at line boundary with warning appended +// Returns "" if no files exist (no-op path, no overhead) +// NEVER stored in Agent.messages — reloaded from disk on every LLM call +``` + +### agent/mod.rs +```rust +Agent { provider, tools, permissions, messages: Vec, max_rounds: usize } +// new() → seeds messages with [ChatMessage::system(config.system_prompt)] +// new_with_history() → prepends system prompt then extends with prior_messages +// messages() → &[ChatMessage] (used for session saving) +// stream_turn(input, on_text) → streams LLM response, executes tools, loops +// run_once(prompt) → non-interactive, returns final text +// compact() → ToolResultTrim first, then Truncate if still over threshold +// messages_with_slug_context() → [slug_context_system_msg, ...self.messages] (rebuilt each call) +// execute_with_permission(name, args) → checks PermissionHandler before calling ToolRegistry +// Tool execution: read/glob/grep = always allowed. bash/write/edit = permission checked. +``` + +--- + +## Data Flow + +``` +User input + → tui::run() + → hook_mgr.fire(UserPromptSubmit) // can block or inject context + → agent.stream_turn(input, on_text) + → messages.push(ChatMessage::user(input)) + → messages_with_slug_context() // fresh SLUG.md prepended every call + → provider.stream_chat(messages, tool_defs) + → POST /v1/chat/completions (stream: true) + → SSE parse → yield StreamEvent::{Text, ToolCallDelta, Finish, Done} + → on_text(chunk) called for each Text event // prints to stdout in TUI + → ToolCallAccumulators assembled from deltas + → if tool_calls not empty: + → messages.push(assistant msg with tool_calls) + → for each tool_call: + → execute_with_permission(name, args) + → permissions.check(request) // deny→allow→mode + → tools.execute(name, args) // ToolRegistry dispatch + → messages.push(tool_result) + → loop (up to max_tool_rounds) + → if no tool_calls: messages.push(assistant msg), return + → session_mgr.save_message() for each new message +``` + +--- + +## Coding Standards + +### Error Handling +- Use `?` for propagation everywhere. No `unwrap()` outside tests. +- Return `anyhow::Result` from all fallible functions. +- Tool execution errors are caught and returned as the tool result string — never propagate to agent loop. +- Use `anyhow::bail!("message")` for early returns with context. +- Use `anyhow::anyhow!("message")` to construct errors. + +### Async +- All async code uses `tokio`. Runtime is `#[tokio::main]` in main.rs. +- Streams use `async_stream::stream!` macro with explicit `yield Ok(...)` / `yield Err(...)`. +- Never `yield Err` inside `async_stream` using `?` — yield the error explicitly and return. +- Provider `stream_chat` returns `Pin> + Send + '_>>`. + +### Serialization +- All wire types (ChatMessage, ToolCall, SessionMeta) derive `Serialize + Deserialize`. +- `skip_serializing_if = "Option::is_none"` on optional fields to keep JSON clean. +- Tool arguments arrive as `serde_json::Value` — extract with `.as_str()`, `.as_u64()` etc, never index directly. +- Config uses `#[serde(tag = "type", rename_all = "lowercase")]` for PermissionMode enum. + +### Adding a New Tool +1. Create `src/tools/{name}.rs` implementing `Tool` trait. +2. Add `mod {name};` to `src/tools/mod.rs`. +3. Register: `registry.register(Box::new({name}::{Name}Tool));` in `ToolRegistry::new()`. +4. Permission: if mutating (writes files, runs code), add a `PermissionRequest` variant and check in `agent::execute_with_permission`. +5. Write unit tests in the same file under `#[cfg(test)]`. + +### Adding a New Provider +1. Create `src/provider/{name}.rs` implementing `Provider` trait. +2. `pub use` from `src/provider/mod.rs`. +3. Wire into `main.rs` behind a config flag or CLI arg. +4. Must return `Pin> + Send + '_>>`. +5. Normalize provider-specific formats to `StreamEvent` — the agent loop is provider-agnostic. + +--- + +## Testing Standards + +Testing is **non-negotiable**. Every module must have tests. Agents: do not consider a task complete without writing tests. + +### Required Tests Per Module +- **Tools**: Test each tool with valid args, missing args, and error cases (file not found, bad pattern, etc.) +- **Provider**: Test `build_request_body` with and without tools, with and without temperature. +- **Permissions**: Test each PermissionMode, glob pattern matching, deny overrides allow, sandbox path checking. +- **Session**: Test create/save/load round-trip, fork copies messages, list_sessions returns sorted. +- **Hooks**: Test command hook execution, blocked result on non-zero exit, prompt hook injection. +- **Compact**: Test ToolResultTrim preserves last 5, Truncate preserves system prompt + last 10 groups, estimate_tokens. +- **Slugmd**: Test 40K truncation, missing files are skipped, section headers are present. + +### Test Patterns +```rust +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; // use tempfile crate for file-based tests + + #[test] + fn test_edit_tool_not_found() { + let tool = EditTool; + let args = serde_json::json!({ "file_path": "/nonexistent", "old_string": "x", "new_string": "y" }); + assert!(tool.execute(&args).is_err()); + } } ``` +- Use `tempfile::TempDir` for any test touching the filesystem. +- Test tool errors produce `Err`, not panics. +- Test permission glob matching exhaustively — it's a custom implementation. +- Integration tests go in `tests/` at crate root. + +--- + +## Documentation Standards + +Every public item must have a doc comment. Agents: do not submit code without doc comments on public API. + +```rust +/// One-line summary. +/// +/// Longer explanation if the behavior is non-obvious. +/// Include what errors are returned and under what conditions. +pub fn load_slug_context() -> String { ... } +``` + +- `///` for public items (rendered by rustdoc). +- `//` for implementation comments explaining *why*, not *what*. +- Mark unfinished areas with `// TODO(feature): description` not just `// TODO`. +- Module-level `//! ...` doc comments required for every `mod.rs`. + +--- + +## CLI Reference + +``` +slug [OPTIONS] + + -e, --endpoint LLM API base URL [env: SLUG_ENDPOINT] + -k, --api-key API key [env: SLUG_API_KEY] + -m, --model Model name [env: SLUG_MODEL] + -p, --prompt Non-interactive single prompt + -c, --config Config file path + --system-prompt Override system prompt + --yolo Skip all permission prompts + --sandbox Auto-approve ops within directory + --allow-edits Auto-approve file edits in cwd + --continue Resume most recent session + --resume Resume specific session + --fork-session Fork session into new branch +``` + +Config file: `~/.slug/config.toml` +Permission rules: `~/.slug/settings.json` or `.slug/settings.json` +Hooks: `.slug/hooks.json` +SLUG.md files: `~/.slug/SLUG.md`, `./SLUG.md`, `.slug/rules/*.md`, `./SLUG.local.md` +Sessions: `~/.slug/sessions/{id}.jsonl` + +--- + +## What's Not Done Yet (Remaining Work) + +**High priority:** +- Subagent parallelism — fork (shared cache), teammate (file mailbox), worktree (isolated branch) +- Ratatui TUI — proper terminal UI with markdown rendering, scrollback, syntax highlighting +- Retry with exponential backoff — 10 retries, jitter, model fallback on repeated 5xx +- Stream interruption — Escape key aborts active stream cleanly, context preserved +- Wire PreToolUse/PostToolUse hooks into agent::execute_with_permission +- Concurrent tool execution — read/glob/grep run in parallel, bash/write/edit serial + +**Medium priority:** +- MCP client — connect to MCP servers, deferred tool loading +- Computer use (screenshot, click, type) +- Auto-compact trigger when context approaches limit + +**Low priority:** +- Session search / conversation history browser +- Anthropic provider — `/v1/messages` format, different streaming protocol +- Google Gemini provider + +--- + +## Anti-Patterns — Never Do These + +- No `unwrap()` or `expect()` in non-test code. +- Never store SLUG.md content in `Agent.messages` — it must be reloaded every turn. +- Never add analytics, telemetry, or tracking of any kind. +- Never add Anthropic-internal features (feature flags, bridge mode, CCR, Kairos, etc.). +- Do not add backwards-compat shims — just change the code. +- Do not add error handling for impossible cases — trust internal invariants. +- Do not add abstractions for one-time use — three similar lines beat a premature helper. +- Tool errors should return `Err(...)` — the agent catches them and turns them into tool result strings. Don't swallow errors silently. +- Permission checks must happen in `agent::execute_with_permission` — tools themselves are unaware of permissions.