# Plan: REPL `/save` and `/restore` commands

Add three REPL commands that give the user manual control over context snapshots,
leveraging the existing `SnapshotState` machinery in `snapshot.py`.

## Commands

| Command | Description |
|---|---|
| `/save [label]` | Set a named checkpoint at the current position |
| `/restore` | Auto-summarize everything since the checkpoint, then collapse it |
| `/unsave` | Cancel the active checkpoint without collapsing |

## Design decisions

- **`/restore` auto-summarizes.** The existing `SnapshotState._restore()` expects
  a `summary` string. When the LLM calls the tool it writes the summary itself;
  when the user triggers `/restore` we make a lightweight LLM call (reusing
  `_call_summarize_llm`) to generate it. If the summarization call fails, fall
  back to a static marker like `compact --drop` does.

- **No `force` needed.** The dirty-scope guard exists to protect the LLM from
  accidentally discarding its own writes. A user explicitly typing `/restore`
  already knows what they're doing — always pass `force=True`.

- **`/unsave` instead of `/cancel`.** `/cancel` could be confused with cancelling
  a running agent turn. `/unsave` is unambiguous.

- **Position tracking without `tool_call_id`.** The snapshot save currently
  anchors to a `tool_call_id`. User-initiated saves happen between turns, not
  inside a tool call. We'll record the current message-list length as the
  checkpoint index instead (`explicit_begin_index`). `_resolve_start` already
  needs a small extension to handle this.

## Implementation

### 1. Extend `SnapshotState` (snapshot.py)

Add an `explicit_begin_index: int | None = None` field in `__init__`, alongside
the existing `explicit_begin_tool_call_id`.

**`save_at_index(label, index)`** — reuses `_save`'s validation and semantics:

- Same label validation (non-empty, length limit).
- Same duplicate-checkpoint guard (`explicit_active` check).
- Same dirty reset and stats increment.
- Sets `explicit_begin_index = index` instead of `explicit_begin_tool_call_id`.

To avoid divergence, refactor: extract the shared validation/state-mutation into
a common helper `_save_common(label)` that both `_save` and `save_at_index`
call, then each sets its own anchor field.

**Clear `explicit_begin_index`** everywhere `explicit_begin_tool_call_id` is
cleared:

- `_restore` (line 209) — already clears tool_call_id, add index.
- `_cancel` (line 301) — same.
- `reset` (line 353) — same.

**Extend `_resolve_start`** to handle the index anchor:

```python
if self.explicit_active:
    if self.explicit_begin_tool_call_id:
        # existing tool_call_id scan ...
    elif self.explicit_begin_index is not None:
        if self._save_generation != self._generation:
            return "error: checkpoint invalidated (context was modified). Use /unsave."
        return self.explicit_begin_index
```

See section 4 for the full stale-index invalidation strategy.

### 2. Handle `_restore` end-boundary for manual mode (snapshot.py)

Current `_restore` finds `end_idx` by scanning backwards for an assistant
message with `tool_calls` — this is the "current turn" boundary that must be
excluded when the LLM itself issued the restore. In manual/REPL mode there is
no current assistant turn to exclude: the entire tail of messages *is* the
scope.

Fix: `restore_with_autosummary` (see below) computes `end_idx = len(messages)`
and passes it to `_restore`. Add an optional `end_idx` parameter to `_restore`:

```python
def _restore(self, summary, messages, force, tool_call_id, *, end_idx=None):
    ...
    if end_idx is None:
        # existing backward-scan logic (tool-call-initiated restore)
        ...
    # else use the caller-provided end_idx directly
```

This is a minimal, backwards-compatible change — all existing callers pass no
`end_idx` and get the old behavior.

### 3. Add `restore_with_autosummary` method (snapshot.py)

New public method:

```python
def restore_with_autosummary(self, messages, summarize_fn) -> str:
```

- Resolves the scope start via `_resolve_start(messages)`.
- Sets `end_idx = len(messages)` (no current-turn exclusion in manual mode).
- Extracts scope messages, formats them as `[role] content` lines (same as
  `summarize_turns`).
- Calls `summarize_fn(text)` to get the summary string.
- If summary is None, uses a static fallback: `"(context collapsed by user)"`.
- Delegates to `_restore(summary, messages, force=True, tool_call_id=None,
  end_idx=end_idx)`.
- Returns a user-facing status string for stderr output.

### 4. Handle stale indices after compaction

A raw message-list index can drift if `compact_messages`, `drop_middle_turns`,
or a tool-initiated snapshot restore mutates the list between `/save` and
`/restore`.

There are two failure classes:
- **Index out of bounds** (`idx > len(messages)`) — compaction shortened the
  list past the saved position. Easy to detect.
- **Silent scope drift** — compaction rewrote or removed messages before the
  saved index while the list stayed long enough. The index is in-bounds but
  now points at the wrong turn.

Strategy: **invalidate on any list mutation.** Add a `_save_generation`
counter to `SnapshotState` that increments whenever `_restore` or `reset`
mutates the message list. Also expose a `invalidate_index_checkpoint()` method
that `_repl_compact` and the auto-compaction paths call after they rewrite
`messages`. When `_resolve_start` uses `explicit_begin_index`, it checks
whether the generation has changed since save-time; if so, it returns an error
telling the user the checkpoint was invalidated.

Concretely:

```python
# In __init__:
self._generation: int = 0
self._save_generation: int | None = None

# In save_at_index:
self._save_generation = self._generation

# In invalidate_index_checkpoint (new public method):
self._generation += 1

# In _resolve_start, index branch:
if self._save_generation != self._generation:
    return "error: checkpoint invalidated (context was modified). Use /unsave."
```

Call `invalidate_index_checkpoint()` from:
- `_repl_compact` (after rewriting messages)
- `_restore` (after splicing messages, for tool-call-id restores that happen
  while an index checkpoint is also active — edge case but correct)
- Any future compaction entry point

This catches both failure classes without fragile index rebasing.

### 5. Wire up REPL commands (agent.py)

In `repl_loop`, add handlers before the fall-through:

```python
elif cmd == "/save":
    label = cmd_arg.strip() or "user-checkpoint"
    _repl_snapshot_save(label, messages, snapshot_state)
    continue
elif cmd == "/restore":
    _repl_snapshot_restore(messages, snapshot_state, ...)
    continue
elif cmd == "/unsave":
    _repl_snapshot_unsave(snapshot_state)
    continue
```

### 6. Helper functions (agent.py, near other `_repl_*` helpers)

**`_repl_snapshot_save(label, messages, snapshot_state)`**
- Guard: `snapshot_state is None` → warning.
- Call `snapshot_state.save_at_index(label, len(messages))`.
- Result is a string — either JSON (success) or a plain `"error: ..."` string
  (validation failures like empty label or duplicate checkpoint). Check with
  `result.startswith("error:")` before attempting JSON parse. Print the
  human-readable outcome via `fmt.info` or `fmt.warning`.

**`_repl_snapshot_restore(messages, snapshot_state, ...llm params...)`**
- Guard: `snapshot_state is None` → warning.
- Guard: no messages beyond system → "nothing to collapse".
- Build a `summarize_fn` closure that calls `_call_summarize_llm` with the
  right LLM parameters (from `_repl_loop_kwargs` / `llm_kwargs`).
- Call `snapshot_state.restore_with_autosummary(messages, summarize_fn)`.
- Print result via `fmt.info`.

**`_repl_snapshot_unsave(snapshot_state)`**
- Guard: `snapshot_state is None` → warning.
- Call `snapshot_state._cancel()`.
- Parse JSON result and print via `fmt.info`.

### 7. Pass LLM params to restore helper

`_repl_snapshot_restore` needs access to `call_llm`, `model_id`, `api_base`,
`api_key`, `top_p`, `seed`, and `provider` to make the summarization call.
These are already in `_repl_loop_kwargs`. The simplest approach: pass the
relevant subset as arguments to `_repl_snapshot_restore`.

`call_llm` is defined at module level, so it's importable directly. The
remaining params (`model_id`, `api_base`, `top_p`, `seed`) come from
`_repl_loop_kwargs`. `api_key` and `provider` come from `llm_kwargs`.

### 8. Update `/help` output

Add the three new commands to `_repl_help()`:

```
  /save [label]    Set a context checkpoint
  /restore         Summarize & collapse since checkpoint
  /unsave          Cancel active checkpoint
```

### 9. Tests

**In `tests/test_snapshot.py`** (extending existing snapshot tests):

- **`test_save_at_index`** — verify `save_at_index` sets `explicit_active`,
  `explicit_label`, `explicit_begin_index`, resets dirty, increments stats.
  Confirm it mirrors `_save` validation (empty label rejected, duplicate
  checkpoint rejected, label length limit).
- **`test_resolve_start_with_index`** — build a message list, save at index N,
  verify `_resolve_start` returns N.
- **`test_resolve_start_stale_generation`** — save at index, call
  `invalidate_index_checkpoint()`, verify `_resolve_start` returns an error
  string even when the list length hasn't changed.
- **`test_restore_with_autosummary`** — mock `summarize_fn`, verify messages
  get collapsed and history is recorded.
- **`test_restore_autosummary_fallback`** — `summarize_fn` returns None,
  verify static fallback summary is used and messages still collapse.
- **`test_restore_manual_end_boundary`** — verify that manual restore
  collapses the entire tail (no assistant-turn exclusion), unlike tool-
  initiated restore.
- **`test_index_cleared_on_cancel`** — call `save_at_index` then `_cancel`,
  verify `explicit_begin_index` is None.
- **`test_index_cleared_on_reset`** — call `save_at_index` then `reset`,
  verify `explicit_begin_index` is None.
- **`test_index_cleared_on_restore`** — call `save_at_index`, then
  `restore_with_autosummary`, verify `explicit_begin_index` is None and
  `explicit_active` is False after successful restore.

**In `tests/test_repl.py`** (extending existing REPL tests):

- **`test_repl_save_command`** — verify `/save foo` routes to
  `_repl_snapshot_save`, sets checkpoint.
- **`test_repl_save_default_label`** — verify `/save` (no arg) uses
  `"user-checkpoint"`.
- **`test_repl_restore_command`** — verify `/restore` routes correctly,
  calls summarize, collapses messages.
- **`test_repl_unsave_command`** — verify `/unsave` clears checkpoint.
- **`test_repl_help_includes_snapshot`** — verify `/help` output mentions
  `/save`, `/restore`, `/unsave`.
- **`test_repl_save_error_plain_string`** — `/save foo` twice (duplicate
  checkpoint) returns a plain `"error: ..."` string, not JSON. Verify the
  helper handles it without a JSON parse error and prints via `fmt.warning`.
  (Note: empty-label error can't happen through the REPL command since `/save`
  defaults to `"user-checkpoint"`, but is covered at the unit level in
  `test_save_at_index`.)
- **`test_repl_save_then_compact_then_restore`** — `/save`, then
  `/compact --drop` shrinks messages, then `/restore` returns a clear
  error about invalidated checkpoint.
- **`test_repl_save_then_autocompact_then_restore`** — `/save`, then
  simulate the auto-compaction path in `run_agent_loop` (the overflow branch
  that calls `compact_messages` / `drop_middle_turns`), then `/restore`
  returns an invalidation error. Confirms the `invalidate_index_checkpoint()`
  hook is wired into the agent-loop compaction path, not just `/compact`.
- **`test_repl_snapshot_state_none`** — verify all three commands print a
  warning when `snapshot_state` is None (graceful degradation).

## Files touched

| File | Change |
|---|---|
| `swival/snapshot.py` | Add `explicit_begin_index`, `_generation`/`_save_generation`, `save_at_index`, `invalidate_index_checkpoint`, `restore_with_autosummary`; refactor shared validation into `_save_common`; add `end_idx` param to `_restore`; extend `_resolve_start` with index path + generation check; clear index in `_restore`/`_cancel`/`reset` |
| `swival/agent.py` | Add `/save`, `/restore`, `/unsave` handlers + helper functions; update `_repl_help`; pass LLM params to restore helper; call `invalidate_index_checkpoint()` from `_repl_compact` and auto-compaction paths |
| `tests/test_snapshot.py` | Tests for `save_at_index`, index-based `_resolve_start`, generation-based stale detection, `restore_with_autosummary`, manual end-boundary, field clearing on cancel/reset/restore |
| `tests/test_repl.py` | Tests for REPL command routing, default label, help output, save→compact→restore error path, None snapshot_state guards |
