# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Build and Development Commands

```sh
make install      # Install dependencies (uv sync)
make test         # Run all tests: uv run python -m pytest tests/ -v
make lint         # Lint: uv run ruff check swival/ tests/
make format       # Auto-format: uv run ruff format swival/ tests/
make check        # Lint + format check (CI uses this)
make website      # Build docs HTML from docs.md/ → docs/
make dist         # Build wheel
```

Run a single test file:
```sh
uv run python -m pytest tests/test_tools.py -v
```

Run a single test:
```sh
uv run python -m pytest tests/test_tools.py::test_name -v
```

## Architecture

Swival is a CLI coding agent that works with any OpenAI-compatible LLM server. Python 3.13+, no framework, pure Python with minimal dependencies.

**Entry point:** `swival/agent.py:main()` — parses CLI args, loads config, enters the agent loop or REPL mode.

**Core loop** (`agent.py`): Sends messages to the LLM, receives tool calls, dispatches them via `tools.py:dispatch()`, feeds results back. Repeats until the model produces a final text answer or hits max turns.

**Key modules:**
- `agent.py` — Agent loop, context compaction, CLI, history persistence (~3200 lines, the largest file)
- `tools.py` — Tool definitions (OpenAI function-calling format) and dispatch (~1900 lines)
- `session.py` — Public library API (`Session` class, `Result` dataclass). Config lives as plain attributes on `Session`
- `config.py` — Loads/merges config from global (`~/.config/swival/config.toml`) and project (`swival.toml`) files
- `mcp_client.py` — Model Context Protocol client for external tool servers
- `skills.py` — SKILL.md discovery and activation (frontmatter-based reusable workflows)
- `snapshot.py` — Proactive context collapse: save/restore checkpoints, dirty-scope tracking, history injection
- `thinking.py` — Structured multi-step reasoning tool with branching (persists across compaction)
- `todo.py` — Work item tracking (persists across compaction)
- `edit.py` — File editing: three-pass search-and-replace (exact → line-trimmed → unicode-normalized)
- `fetch.py` — URL fetching, HTML-to-markdown conversion
- `report.py` — JSON report generation for benchmarking (timing, token usage, tool stats)
- `reviewer.py` — LLM-as-judge and external reviewer script support
- `fmt.py` — Terminal formatting (rich-based, all diagnostics go to stderr)
- `tracker.py` — File access tracking: read-before-write guard
- `_msg.py` — Message accessor helpers for dict-or-namespace messages (used by `agent.py` and `snapshot.py`)
- `sandbox_agentfs.py` — AgentFS sandbox bootstrap: re-execs Swival inside a copy-on-write overlay

**Public API** (`__init__.py`): Exports `swival.run()` (one-call convenience) and `swival.Session` (multi-turn).

## Key Design Decisions

- **stdout is exclusively the final answer.** All diagnostics, progress, and tool output go to stderr via Rich Console (`stderr=True`). This makes piping work cleanly.
- **Context compaction** is central to the design. Graduated strategies: turn dropping, message summarization, MCP tool pruning. Thinking notes and todo items survive compaction so the agent doesn't lose its plan.
- **Safety by default.** Path resolution with symlink protection, read/write directory guards, command whitelisting. YOLO mode (`--yolo`) disables command restrictions.
- **Provider abstraction** via LiteLLM. Providers: `lmstudio` (auto-discovers loaded model), `huggingface`, `openrouter`, `chatgpt` (OAuth device-code, no API key), `generic` (any OpenAI-compatible server).
- **Token counting** uses tiktoken's `cl100k_base` encoding throughout (approximation for non-OpenAI models).

## Cross-Cutting Conventions

**Tool results are always strings.** Every tool implementation returns `str`. Errors start with `"error:"` — this prefix drives the guardrail system, the `succeeded` flag in reports, and compaction scoring.

**Messages are mutated in place.** `run_agent_loop()` modifies the `messages` list directly. The REPL shares the same list across turns. Compaction replaces contents via `messages[:] = ...` to preserve the reference.

**Message accessor helpers** (in `_msg.py`). Messages can be `dict` or provider response objects. Always use `_msg_get()`, `_msg_role()`, `_msg_content()`, `_msg_tool_calls()`, `_msg_tool_call_id()`, `_msg_name()` to access them — never index directly. Use `_set_msg_content()` to mutate.

**The `_UNSET` sentinel** (defined in `config.py`) distinguishes "user didn't set this CLI flag" from `False`/`None`, enabling config file fallthrough. It only appears in the argparse layer, never in Session or downstream code.

**Config precedence:** CLI flags > project `swival.toml` > global `~/.config/swival/config.toml` > hardcoded defaults.

**State objects share a pattern.** `ThinkingState`, `TodoState`, `SnapshotState`, `FileAccessTracker` all have `reset()` methods (called by REPL `/clear`) and `summary_line()` methods (returns `None` if unused, string otherwise; shown in verbose mode).

**The `.swival/` directory** holds runtime state: `HISTORY.md`, `todo.md`, `repl_history` (prompt-toolkit), `trash/` (soft deletes), `cmd_output_*.txt` (temp large command outputs, auto-deleted after 600s).

## Compaction Pipeline

On `ContextOverflowError`, these run in order:
1. `compact_messages()` — shrinks large tool result strings in older turns (keeps last 2 untouched)
2. `drop_middle_turns()` — score-based turn dropping with optional AI summary. Scoring: errors +3, write/edit +5, think +2, snapshot recap +5
3. `aggressive_drop_turns()` — last resort, keeps only system + last 2 turns

`enforce_mcp_token_budget()` drops entire MCP servers if tool schemas exceed 50% of context.

## Behavioral Nudges

Injected as user messages during the agent loop:
- **Tool-error guardrail:** 2 repeats of the same error → IMPORTANT warning, 3+ → STOP. Tracked per canonical error in `consecutive_errors` dict.
- **Think nudge:** fires once if first write/edit happens without any prior `think` call.
- **Todo reminder:** every 3 turns if remaining todo items exist.
- **Snapshot nudge:** fires once after 5 consecutive read-only turns.

## Provider String Formats (LiteLLM)

- lmstudio: `openai/{model_id}` with `api_base = "{base_url}/v1"`, `api_key = "lm-studio"`
- generic: `openai/{model_id}` with `/v1` auto-appended if missing
- openrouter: `openrouter/{model_id}`
- huggingface: `huggingface/{model_id}` (must include `org/`)
- chatgpt: `chatgpt/{model_id}` (OAuth device-code flow, tokens at `~/.config/litellm/chatgpt/auth.json`)

## MCP Integration

`McpManager` runs a background asyncio event loop in a daemon thread. All public methods are synchronous (via `run_coroutine_threadsafe`). No `asyncio.run()` in main thread.

Tool namespacing: `mcp__{server_name}__{sanitized_tool_name}`. Original name stored as `_mcp_original_name` in the schema dict. Servers marked degraded after any call failure; subsequent calls return error immediately.

## Testing

Tests use pytest with `tmp_path` fixture and mock LLM backends (no real LLM calls). Test files mirror the module structure.

**Mock LLM pattern:**
```python
import types

def _make_message(content=None, tool_calls=None):
    msg = types.SimpleNamespace(content=content, tool_calls=tool_calls, role="assistant")
    return msg

def _simple_llm(*args, **kwargs):
    return _make_message(content="the answer"), "stop"

# monkeypatch.setattr(agent, "call_llm", _simple_llm)
```

**Tool-call mocking:**
```python
tc = types.SimpleNamespace(
    id="tc1",
    function=types.SimpleNamespace(name="read_file", arguments='{"file_path": "x.txt"}'),
)
```

**Direct tool testing:** Internal functions like `_read_file`, `_write_file`, `_edit_file` are imported directly from `tools.py` and called with `tmp_path` as `base_dir`.

**Session API testing:** `Session(base_dir=str(tmp_path), history=False)` with monkeypatched `call_llm`.

**State objects** (`SnapshotState`, `ThinkingState`, `TodoState`) are tested independently without the agent loop.
