# REPL commands: `/clear`, `/add-dir`, `/compact`, `/help`

Adds four interactive commands to the REPL loop, following the same pattern as the existing `/exit` and `/quit` commands.

## Motivation

The REPL carries full conversation history across questions. Currently the only way to reset context is to quit and restart. Similarly, `--allow-dir` only works at startup, and context compaction only fires automatically on overflow. These three commands give the user interactive control over context and sandboxing.

## Command behaviors

### `/clear`

Resets the conversation to the initial state: keeps only the leading contiguous `role == "system"` messages and drops everything else (user, assistant, and tool messages). Also fully resets `ThinkingState` by clearing `history`, `branches`, and `note_count`, plus deleting the persisted notes file.

The initial user question is appended by `repl_loop` each iteration, so after `/clear` the next user input becomes the first user message — no special handling needed.

**Output:** Print a confirmation to stderr via `fmt.info()` showing how many messages were dropped.

### `/add-dir <path>`

Adds a directory to `extra_write_roots` at runtime, granting the agent read+write access. Performs the same validation as the startup `--allow-dir` logic:

1. Expand `~` via `Path.expanduser()`
2. Resolve to absolute path via `.resolve()`
3. Reject if not a directory
4. Reject if filesystem root
5. Reject if already in the list (no-op, inform user)
6. Append to `extra_write_roots`

**Output:** Confirmation to stderr via `fmt.info()`.

### `/compact`

Manually triggers context compaction. Runs the same two-stage strategy used on overflow, but proactively:

1. Run `compact_messages()` (truncates large tool results in older turns)
2. Optionally run `drop_middle_turns()` if the user passes `/compact --drop` (drops middle turns, keeping leading block + last 3)

Without `--drop`, only stage 1 runs — this is safer since it doesn't lose information, just truncates verbose tool output.

**Output:** Print before/after token counts to stderr via `fmt.info()`.

### `/help`

Lists all available REPL commands with a short description of each. This is the discoverability entry point — users who don't know the commands exist can type `/help` to see them.

**Output:** Print a formatted command list to stderr via `fmt.info()`:

```
Available commands:
  /help              Show this help message
  /clear             Reset conversation to initial state
  /compact [--drop]  Compress context (--drop removes middle turns)
  /add-dir <path>    Grant read+write access to a directory
  /exit, /quit       Exit the REPL
```

## Implementation

### 1. Command dispatch in `repl_loop()` (agent.py, lines 908-912)

Replace the simple `/exit`/`/quit` check with a command dispatcher. After `line = line.strip()`, check for `/` prefix and route:

```python
line = line.strip()
if not line:
    continue

# REPL commands — only intercept known commands; unknown /foo passes through
if line in ("/exit", "/quit"):
    break

cmd_parts = line.split(None, 1)
cmd = cmd_parts[0].lower()
cmd_arg = cmd_parts[1] if len(cmd_parts) > 1 else ""

if cmd == "/help":
    _repl_help()
    continue
elif cmd == "/clear":
    _repl_clear(messages, thinking_state)
    continue
elif cmd == "/add-dir":
    _repl_add_dir(cmd_arg, extra_write_roots)
    continue
elif cmd == "/compact":
    _repl_compact(messages, tools, context_length, cmd_arg)
    continue

messages.append({"role": "user", "content": line})
```

Only known command names are intercepted. Unrecognized `/`-prefixed input falls through to the model as a normal message. This avoids breaking legitimate prompts that start with `/` (file paths, CLI examples, regex patterns, etc.).

### 2. Helper functions (agent.py, near `repl_loop`)

#### `_repl_help()`

```python
def _repl_help() -> None:
    """Print available REPL commands."""
    fmt.info(
        "Available commands:\n"
        "  /help              Show this help message\n"
        "  /clear             Reset conversation to initial state\n"
        "  /compact [--drop]  Compress context (--drop removes middle turns)\n"
        "  /add-dir <path>    Grant read+write access to a directory\n"
        "  /exit, /quit       Exit the REPL"
    )
```

#### `_repl_clear(messages, thinking_state)`

```python
def _repl_clear(messages: list, thinking_state: ThinkingState) -> None:
    """Clear conversation history, keeping only the leading system messages."""
    # Keep only the contiguous system messages at the front
    leading = []
    for msg in messages:
        role = msg.get("role") if isinstance(msg, dict) else getattr(msg, "role", None)
        if role == "system":
            leading.append(msg)
        else:
            break

    dropped = len(messages) - len(leading)
    messages[:] = leading

    # Fully reset ThinkingState (history, branches, note_count, notes file)
    thinking_state.history.clear()
    thinking_state.branches.clear()
    thinking_state.note_count = 0
    if thinking_state.notes_dir is not None:
        try:
            notes_path = _safe_notes_path(thinking_state.notes_dir)
            notes_path.unlink(missing_ok=True)
        except ValueError:
            pass  # symlink escape — don't touch it

    fmt.info(f"context cleared ({dropped} messages removed)")
```

#### `_repl_add_dir(path_str, extra_write_roots)`

```python
def _repl_add_dir(path_str: str, extra_write_roots: list) -> None:
    """Add a directory to the write-access whitelist."""
    path_str = path_str.strip()
    if not path_str:
        fmt.warning("/add-dir requires a path argument")
        return

    p = Path(path_str).expanduser().resolve()
    if not p.is_dir():
        fmt.warning(f"not a directory: {path_str}")
        return
    if p == Path(p.anchor):
        fmt.warning("cannot add filesystem root")
        return
    if p in extra_write_roots:
        fmt.info(f"already in whitelist: {p}")
        return

    extra_write_roots.append(p)
    fmt.info(f"added to whitelist: {p}")
```

#### `_repl_compact(messages, tools, context_length, arg)`

```python
def _repl_compact(messages: list, tools: list, context_length: int | None, arg: str) -> None:
    """Manually compact conversation context."""
    before = estimate_tokens(messages, tools)

    messages[:] = compact_messages(messages)
    if arg.strip() == "--drop":
        messages[:] = drop_middle_turns(messages)

    after = estimate_tokens(messages, tools)
    saved = before - after
    fmt.info(f"compacted: {before} -> {after} tokens ({saved} saved)")
```

### 3. Tests (tests/test_repl.py)

`fmt.info()` already exists in `fmt.py:126`, so no changes needed there.

Add tests to the existing `TestReplLoop` class:

#### `/help`

- `test_help_prints_commands` — `/help` prints the command list to stderr (via `fmt.info()`) and does not append anything to messages or send anything to the model.

#### `/clear`

- `test_clear_resets_messages` — After sending a question and getting an answer, `/clear` reduces messages to just the system prompt. Next question starts fresh.
- `test_clear_resets_thinking_state` — After `/clear`, `ThinkingState.history` and `branches` are empty and `note_count` is 0.
- `test_clear_deletes_notes_file` — Create a notes file at the expected `.swival/notes.md` path inside `notes_dir`, run `/clear`, verify the file is gone.
- `test_clear_skips_symlink_escape` — Make `.swival` a symlink pointing outside `notes_dir`. Run `/clear`. Verify the target file is **not** deleted (the `_safe_notes_path` guard catches the escape).

#### `/add-dir`

- `test_add_dir_valid` — `/add-dir /tmp/somedir` appends to `extra_write_roots`.
- `test_add_dir_missing_arg` — `/add-dir` with no argument prints a warning (doesn't crash).
- `test_add_dir_nonexistent` — `/add-dir /nonexistent` prints a warning.
- `test_add_dir_duplicate` — Adding the same dir twice doesn't duplicate it.
- `test_add_dir_root_rejected` — `/add-dir /` is rejected.
- `test_add_dir_enables_file_access` — End-to-end: after `/add-dir <extra>`, a `read_file` dispatch call for a file inside `<extra>` succeeds (whereas it would fail without the command). Verifies the plumbing actually works.

#### `/compact`

- `test_compact_truncates_old_results` — Messages with large tool results get truncated.
- `test_compact_drop_flag` — `/compact --drop` additionally drops middle turns.

#### Unknown `/`-prefixed input passes through

- `test_unknown_slash_sent_to_model` — `/foo bar` is appended to messages and sent to the model as a normal user message (not intercepted).

## File changes summary

| File | Change |
|------|--------|
| `agent.py` | Add `_repl_help()`, `_repl_clear()`, `_repl_add_dir()`, `_repl_compact()` helpers. Refactor command handling in `repl_loop()` to dispatch known `/`-commands. Import `_safe_notes_path` from `thinking`. |
| `tests/test_repl.py` | Add test cases for `/help`, `/clear`, `/add-dir`, `/compact`, and `/`-prefixed passthrough. |

## Design decisions

- **Only known commands are intercepted.** Unrecognized `/`-prefixed input (paths, CLI examples, regex) passes through to the model as a normal message. This avoids a behavioral regression where legitimate prompts starting with `/` would be silently dropped.
- **`/add-dir` modifies `extra_write_roots` in place.** Since `extra_write_roots` is a mutable list passed by reference through the call chain, appending to it takes effect immediately for all subsequent tool calls — no plumbing changes needed.
- **`/compact` without `--drop` is conservative.** It only truncates large tool results, preserving all conversation turns. The `--drop` variant is more aggressive and explicitly opt-in.
- **`/clear` keeps only system messages.** The user's original question (if any) is not preserved. This gives a truly clean slate.
- **No new dependencies.** Everything uses existing modules and patterns.
