# Plan: Append-Only Response History

## Problem

When the agent completes a task, the final answer goes to stdout and disappears. In long REPL sessions the context gets compacted and older answers are lost entirely. There's no record of what the agent previously concluded, which means neither the user nor the model can refer back to past results.

## Goal

Persist every final response to `.swival/HISTORY.md` inside `base_dir` as an append-only log with timestamps. Tell the model about this file in the system prompt so it can `read_file` it to recall previous answers.

## Design

A small helper function `append_history(base_dir, question, answer)` writes a single timestamped entry to `.swival/HISTORY.md`. It's called after `run_agent_loop()` returns, both in single-shot mode and in the REPL. The helper itself skips empty answers. The file is never truncated or deleted between sessions — it's a persistent, cumulative log.

### Why append-only across sessions?

Notes (`.swival/notes.md`) are wiped on each new session because they hold ephemeral reasoning for a single run. History is different — its value comes from accumulation. A user running the agent repeatedly in the same project directory builds up a timeline of what was asked and answered. The model can scan it for prior context, and the user gets a human-readable audit trail.

### File format

```markdown
---

**2026-02-25 14:32:07** — *What files handle authentication?*

The authentication logic lives in `auth.py` and `middleware.py`. The `auth.py` module defines...

---

**2026-02-25 14:35:22** — *Add rate limiting to the login endpoint*

Done. I added a `RateLimiter` class in `middleware.py` and wired it into...
```

Each entry is:
1. A horizontal rule separator (`---`)
2. A bold timestamp (`%Y-%m-%d %H:%M:%S`) in local time + em dash + italicized question (first 200 chars, truncated with `...` if longer)
3. A blank line
4. The full answer text
5. A trailing blank line

This is valid markdown, renders nicely in any viewer, and is trivially parseable by the model. Horizontal rules make entries visually distinct when the file gets long.

### Size cap

The file is capped at 500KB. Before each write, check the current file size. If it's *at or above* the cap (`>=`), skip the write and log a warning to stderr via `fmt.warning()`. Otherwise, write the entry regardless of its size — even if the single write pushes the file over 500KB. This means the cap is checked *before* writing, not *after*. The effect: the file can exceed 500KB by the size of one entry, but no further entries are added once it reaches the limit. No truncation or rotation — the user can manually trim or delete the file.

500KB is generous (roughly 100+ full answers) while staying well within `read_file`'s 50KB pagination limit. The model can use `offset`/`limit` to read recent entries.

### Not session-isolated

Unlike `notes.md`, the history file is **not** deleted on agent startup. Each run appends to whatever is already there. This is the key distinction.

### Opt-out

A `--no-history` CLI flag disables history writing. No flag to change the path — it's always `.swival/HISTORY.md`.

## Changes

### 1. `agent.py` — New helper + call sites

New function:

```python
def append_history(base_dir: str, question: str, answer: str) -> None:
```

Logic:
- Build path via `_safe_history_path(base_dir)` (see below). This resolves symlinks and verifies the result stays inside `base_dir`, same pattern as `_safe_notes_path` in `thinking.py`. Raises `ValueError` on escape.
- Early return if `answer` is empty (after stripping). The guard lives here in the helper, not at call sites — callers don't need to check.
- `mkdir(parents=True, exist_ok=True)` on the parent directory.
- Check current file size (0 if file doesn't exist). If `current_size >= MAX_HISTORY_SIZE` (500KB), call `fmt.warning("history file at capacity, skipping write")` and return. The `>=` means exactly-at-the-limit is also rejected. Otherwise proceed — the entry is written even if it pushes the file past 500KB.
- Truncate question to 200 chars for the header.
- Format the entry (timestamp via `datetime.now().strftime("%Y-%m-%d %H:%M:%S")`).
- Open with `"a"` and write.
- Catch `OSError` and `ValueError` (from path safety), log via `fmt.warning()` — never crash the agent over history writes.

New helper:

```python
def _safe_history_path(base_dir: str) -> Path:
    """Build history path, verify it resolves inside base_dir."""
    base = Path(base_dir).resolve()
    history_path = (Path(base_dir) / ".swival" / "HISTORY.md").resolve()
    if not history_path.is_relative_to(base):
        raise ValueError(f"history path {history_path} escapes base directory {base}")
    return history_path
```

Call sites (three total — every place `run_agent_loop()` returns an answer). Callers pass the answer unconditionally; `append_history` itself handles the empty-answer check:
- **Single-shot** (`_run_main`, ~line 891): After `run_agent_loop()` returns, call `append_history(base_dir, args.question, answer)`.
- **Initial REPL question** (`_run_main`, ~line 918): When `--repl "question"` is used, the first answer is produced *before* entering `repl_loop()`. Call `append_history(base_dir, args.question, answer)` here too.
- **REPL loop** (`repl_loop`): After each answer from a normal user question, call `append_history(base_dir, line, answer)` where `line` is the user's input text. For `/continue`, use `question="(continued)"` since no new question was asked — the model is just resuming from where it left off.

Pass `no_history` flag through from args. When `True`, skip all `append_history` calls. The REPL's `repl_loop` function gets a `no_history: bool` parameter.

### 2. `agent.py` — CLI argument

Add `--no-history` flag to the argument parser:
```python
parser.add_argument("--no-history", action="store_true",
                    help="Don't write responses to .swival/HISTORY.md")
```

Thread `args.no_history` through to the call sites (single-shot, initial REPL question, and REPL loop).

### 3. `system_prompt.txt` — Tell the model about history

Add a line under the existing `.swival/notes.md` mention in the Tools section, or as a separate note after the Reasoning tools:

```
**History:**
- Previous responses are logged to `.swival/HISTORY.md` with timestamps. Use `read_file` to review what was asked and answered earlier in this project.
```

Keep it short. The model already knows `read_file`; this just tells it the file exists and what's in it.

### 4. Tests — `tests/test_history.py`

New test file. All tests use `tmp_path` fixture as `base_dir`.

**Core behavior:**
- `test_append_creates_file` — Call `append_history` once, verify `.swival/HISTORY.md` exists and contains the timestamp, question, and answer.
- `test_append_multiple_entries` — Call 3 times, verify all 3 entries appear in order with separators.
- `test_entry_format` — Verify the exact markdown format: horizontal rule, bold timestamp, italic question, blank line, answer text.
- `test_timestamp_format` — Verify the timestamp matches `%Y-%m-%d %H:%M:%S` pattern.

**Question truncation:**
- `test_long_question_truncated` — Question over 200 chars is truncated to 200 + `...` in the header.
- `test_short_question_not_truncated` — Question under 200 chars appears in full, no ellipsis.

**Size cap:**
- `test_size_cap_skips_write` — Pre-populate the file past 500KB, then attempt another write. Verify the new entry is not appended and the file is unchanged.
- `test_size_cap_exact_boundary` — Pre-populate the file to exactly 500KB. Verify the next write is skipped (`>=` means at-the-limit is rejected).
- `test_under_cap_writes` — File at 400KB, small entry fits, verify it's appended.
- `test_large_single_entry_written` — File is empty, single entry is 600KB. Verify it's written (cap is checked before writing, not after).

**Path safety:**
- `test_symlink_escape_rejected` — Create a symlink `.swival -> /tmp/elsewhere` inside `base_dir`. Call `append_history`. Verify it does not write to the symlink target and logs a warning.
- `test_safe_path_normal` — Normal `base_dir` with no symlinks. Verify `_safe_history_path` returns the expected resolved path.

**Error handling:**
- `test_write_failure_no_crash` — Monkeypatch `Path.open` to raise `OSError` for the history path. Verify `append_history` returns without raising. Verify warning logged via `fmt.warning`.

**No-op cases:**
- `test_empty_answer_not_written` — Empty and whitespace-only answer strings: `append_history` itself returns early, no file created. The guard lives in the helper.
- `test_creates_swival_dir` — `.swival/` doesn't exist yet, first write creates it.

**Persistence across calls:**
- `test_file_not_deleted_between_calls` — Call `append_history` twice with different base content, verify both entries survive (no truncation/reset).

**`/continue` question attribution:**
- `test_continue_label` — Call `append_history(base_dir, "(continued)", answer)`, verify the entry header shows `*(continued)*`.

**Exhausted answers:**
- `test_exhausted_answer_logged` — An answer from an exhausted run (max turns hit) is still logged normally. The history doesn't distinguish exhausted vs. complete answers — the answer text itself already reflects whether the model finished or was cut off.

**Integration (mock LLM, test the wiring not just the helper):**
- `test_no_history_flag_single_shot` — Run single-shot with `--no-history`, verify no `.swival/HISTORY.md` created.
- `test_no_history_flag_repl` — Same for REPL path.
- `test_initial_repl_question_logged` — Run `--repl "question"` (mock the REPL input to immediately `/exit`), verify the initial answer appears in history.

### 5. No changes needed

- `tools.py` — No new tool. The model reads history with the existing `read_file`.
- `thinking.py` — Session isolation of `notes.md` is unchanged. History has its own lifecycle.
- `edit.py`, `fetch.py`, `fmt.py`, `skills.py` — Not involved.

## Edge cases

- **`.swival/` doesn't exist yet**: Created on first write via `mkdir(parents=True, exist_ok=True)`.
- **Disk write failure**: Caught, logged as warning, never crashes the agent.
- **Multiline answers**: Written as-is. Markdown rendering handles them fine.
- **Answers with markdown**: Written verbatim. Since each entry is separated by `---`, embedded markdown in answers won't conflict with the structure.
- **Concurrent writes**: Within a single process, not an issue — single-threaded agent loop. Two separate swival processes in the same `base_dir` can race and interleave appends. This is not handled; entries may merge but data won't be lost since each write is a single `open("a")` + `write()` call (atomic on most filesystems for reasonable sizes). Acceptable for a diagnostic log.
- **Very large single answer**: Written in full. The cap (`>=`) is checked *before* writing — if the file is already at or over 500KB, new writes are skipped. But if the file is under the cap, the entry is written even if it pushes the file over. This keeps the logic simple and predictable.
- **`.swival` is a symlink**: `_safe_history_path` resolves symlinks and checks the result is inside `base_dir`. If not, raises `ValueError`, caught by `append_history` and logged as a warning.
- **REPL `/clear`**: Resets conversation but does not clear history. History is a project-level artifact, not a session artifact.
- **REPL `/continue`**: The question field is `"(continued)"` since no new question was asked.
- **Exhausted answers**: Logged normally. The history is a record of what the model said, not whether it finished cleanly. If the user cares about that distinction, the answer text itself usually makes it obvious (truncated mid-sentence, or the model explicitly says it ran out of turns).
- **Sensitive content**: The history file lives inside `.swival/` which is typically gitignored. The system prompt does *not* instruct the model to write sensitive data to history — it only tells the model the file exists for reading back prior answers. Users who work with sensitive data should be aware the file exists; `--no-history` disables it.

## Sequence of implementation

1. Add `append_history()` helper in `agent.py`
2. Add `--no-history` CLI flag
3. Wire `append_history` into single-shot and REPL paths
4. Update `system_prompt.txt`
5. Write tests
6. Run tests, fix issues
