# Plan: Persistent Notes for the Think Tool

> **Status: not implemented.** This plan was written but the notes feature was
> never built. The tool description and CLAUDE.md previously referenced notes
> incorrectly; those references have been corrected. The think tool schema
> has also been revised since this plan was written — `is_revision` was replaced
> with a discriminated `mode` parameter.

## Problem

The `think` tool currently stores all state in memory (`ThinkingState.history`). When the context window fills up and the agent loop compacts or drops older turns (`compact_messages`, `drop_middle_turns`), the model loses access to its earlier reasoning steps, findings, and decisions. There's no way for the model to carry forward important information across context window boundaries.

## Goal

Let the model selectively persist notes to disk during thinking, so it can retrieve them later even after earlier tool results have been compacted away. The model decides what's worth remembering; ephemeral reasoning stays ephemeral.

## Design

Extend the existing `think` tool with an optional `note` parameter. When `note` is provided, the text is appended to a notes file on disk. The model can read the file back with `read_file` at any point.

This is additive — no changes to existing think behavior. The model already knows how to use `read_file`, so retrieval requires zero new tools.

### Why not a separate tool?

A separate `save_note` tool would work, but coupling it with `think` is better because:
- Notes are a natural byproduct of structured reasoning ("I concluded X, saving for later").
- Fewer tool calls — the model can reason and persist in one step.
- The think tool description already tells the model to use it for planning and tracking; notes are a logical extension.

### Session isolation

Notes are isolated per agent run. `ThinkingState` owns the lifecycle:
- On **construction** (when `notes_dir` is set), delete any existing `.swival/notes.md`. This prevents a new run from reading stale notes from a prior session, even before the first note write.
- All writes use append mode (`"a"`). The constructor handles cleanup; writes don't need a first-vs-subsequent distinction.
- The in-memory `note_count` starts at 0 and is authoritative — we never parse the file to recover state.
- `FileNotFoundError` during deletion is silently ignored (the file or `.swival/` may not exist yet — both are fine). Any other `OSError` (permissions, I/O) is raised — if we can't guarantee a clean slate, fail loud rather than let stale notes leak through.

This means: if you run the agent twice in the same `base_dir`, the second run starts clean immediately. No stale reads, no numbering drift.

### File layout

Notes go to `.swival/notes.md` inside `base_dir`. This reuses the existing `.swival/` directory pattern from `run_command` output. Unlike command output files, notes do **not** auto-delete — they persist for the entire agent session.

Format on disk:
```
## Note 1 (thought 3)
<note text>

## Note 2 (thought 7, branch: approach-b)
<note text>
```

Each note gets a header with its sequence number and the thought context (thought number, branch if any). This makes the file scannable when the model reads it back.

## Response schema

When the `note` parameter is provided (non-empty, after stripping), the JSON response always includes these three fields alongside the existing ones:

| Field | Type | When |
|---|---|---|
| `note_saved` | `bool` | Always present when `note` was provided. `true` if written to disk. |
| `notes_file` | `str` | Present when `note_saved` is `true`. Relative path: `".swival/notes.md"`. |
| `note_error` | `str` | Present when `note_saved` is `false`. Machine-readable reason: `"cap_exceeded"`, `"write_failed"`, `"no_notes_dir"`. |

When `note` is absent, empty, or whitespace-only: none of these fields appear (existing behavior unchanged).

Examples:

Success:
```json
{"thought_number": 3, "total_thoughts": 5, "next_thought_needed": true,
 "branches": [], "history_length": 3,
 "note_saved": true, "notes_file": ".swival/notes.md"}
```

Cap exceeded:
```json
{"thought_number": 55, "total_thoughts": 60, "next_thought_needed": true,
 "branches": [], "history_length": 55,
 "note_saved": false, "note_error": "cap_exceeded"}
```

No notes_dir configured:
```json
{"thought_number": 1, "total_thoughts": 3, "next_thought_needed": true,
 "branches": [], "history_length": 1,
 "note_saved": false, "note_error": "no_notes_dir"}
```

## Changes

### 1. `thinking.py` — ThinkingState

- Constructor gets `notes_dir: str | None = None` (default `None` for backward compat). Existing callers (`ThinkingState()`, `ThinkingState(verbose=True)`) keep working.
- New instance fields:
  - `notes_dir: str | None`
  - `note_count: int = 0`
- In `__init__`, when `notes_dir` is not None: delete `.swival/notes.md` if it exists. Catch `FileNotFoundError` only — any other `OSError` propagates (fail-fast if we can't ensure isolation).
- New constants: `MAX_NOTES = 50`, `MAX_NOTE_LENGTH = 5000`.
- In `process()`:
  - After recording the thought entry (existing logic unchanged), handle the `note`:
  - Extract `note = args.get("note", "").strip()`.
  - If `note` is empty: skip, don't add note fields to response.
  - If `note` is non-empty:
    - If `notes_dir is None`: add `note_saved: false, note_error: "no_notes_dir"` to response.
    - If `note_count >= MAX_NOTES`: add `note_saved: false, note_error: "cap_exceeded"` to response.
    - Otherwise: truncate to `MAX_NOTE_LENGTH`, attempt disk write, increment counter.
    - Disk write logic:
      - Build path: `Path(notes_dir) / ".swival" / "notes.md"`.
      - `mkdir(parents=True, exist_ok=True)` on the directory.
      - Open with `"a"` (append). The constructor already deleted any stale file.
      - Write header (`## Note {n} (thought {t})` or `## Note {n} (thought {t}, branch: {b})`) + note text + blank line.
      - On `OSError`: add `note_saved: false, note_error: "write_failed"` to response.
      - On success: add `note_saved: true, notes_file: ".swival/notes.md"` to response.

### 2. `tools.py` — Think tool schema

Add `note` to the think tool's function parameters:
```json
"note": {
    "type": "string",
    "description": "Optional. A concise summary to persist to disk for later retrieval. Use this to save key findings, decisions, or context you'll need after older messages are compacted. Saved to .swival/notes.md — retrieve with read_file."
}
```
No changes to `dispatch()` — the `note` key passes through `args` to `ThinkingState.process()` already.

### 3. `agent.py` — Wiring

Pass `notes_dir=base_dir` when constructing `ThinkingState`:
```python
thinking_state = ThinkingState(verbose=args.verbose, notes_dir=base_dir)
```

### 4. `system_prompt.txt` — Agent instructions

Expand the think tool description to mention notes:
> `think`: Structure your reasoning into numbered steps. Supports revisions, branches, and **persistent notes**. Pass a `note` string to save a concise summary to disk — useful for preserving key findings across long sessions. Retrieve saved notes with `read_file` on `.swival/notes.md`.

### 5. Tests — `tests/test_think.py`

New test class `TestNotes` (all tests use `tempfile.mkdtemp()` as `notes_dir`):

**Core behavior:**
- `test_note_creates_file` — Process a thought with `note`, verify `.swival/notes.md` exists and contains the note text with correct header.
- `test_note_response_fields` — Verify JSON response includes `note_saved: true` and `notes_file: ".swival/notes.md"`.
- `test_multiple_notes_append` — Save 3 notes, verify all 3 appear in the file in order with correct numbering.
- `test_note_with_branch_context` — Save a note from a branched thought, verify the header includes `branch: <id>`.

**Limits:**
- `test_note_truncation` — Note over 5000 chars gets truncated to 5000 in the file.
- `test_note_cap` — After 50 notes, the 51st returns `note_saved: false, note_error: "cap_exceeded"`. The thought itself still records in history.

**No-op cases:**
- `test_no_note_no_file` — Thoughts without `note` don't create `.swival/notes.md`. Response has no `note_saved` field.
- `test_empty_note_ignored` — Empty string and whitespace-only `note` are treated as absent.
- `test_notes_without_notes_dir` — `ThinkingState(notes_dir=None)`: `note` param returns `note_saved: false, note_error: "no_notes_dir"`.

**Session isolation:**
- `test_preexisting_notes_file_cleared_on_init` — Create a `.swival/notes.md` with old content, then construct `ThinkingState(notes_dir=...)`. File should be deleted immediately, before any `process()` call. A subsequent `read_file` on it returns not-found.
- `test_init_cleanup_ignores_missing_file` — Construct `ThinkingState(notes_dir=tmpdir)` when no `.swival/` exists yet. No error raised.
- `test_two_notes_both_present` — Save two notes. Both appear in the file in order.

**Failure modes:**
- `test_disk_write_failure` — Use `monkeypatch` on `pathlib.Path.open` (the actual callable the implementation uses) to raise `OSError` when the notes path is opened, passing through for other paths. Response includes `note_saved: false, note_error: "write_failed"`. Thought still records in history.
- `test_init_unlink_oserror_propagates` — Use `monkeypatch` on `pathlib.Path.unlink` to raise `PermissionError`. Constructing `ThinkingState(notes_dir=...)` should raise, not silently swallow.

**Backward compat:**
- `test_constructor_defaults` — `ThinkingState()` and `ThinkingState(verbose=True)` still work (no required params added).

### 6. No changes needed

- `patch.py` — Not involved.
- `dispatch()` in `tools.py` — Already passes `args` dict through to `ThinkingState.process()`.
- Context compaction in `agent.py` — Notes survive compaction by design (they're on disk, not in message history).

## Edge cases

- **Disk write failure**: Return `note_saved: false, note_error: "write_failed"`. The thought itself still records in memory.
- **`.swival/` doesn't exist yet**: Create it on first note write via `mkdir(parents=True, exist_ok=True)`.
- **Concurrent notes**: Not an issue — single-threaded agent loop, one tool call at a time.
- **Pre-existing notes file**: Deleted in `__init__` when `notes_dir` is set. Stale content is gone before any tool call runs.
- **Notes file size**: Capped at 50 notes x 5000 chars = 250KB max. The model can use `read_file` with `offset`/`limit` to paginate.

## Sequence of implementation

1. Update `tools.py` schema (add `note` param)
2. Update `thinking.py` (persistence logic + session isolation)
3. Update `agent.py` (pass `notes_dir`)
4. Update `system_prompt.txt`
5. Write tests
6. Run tests, fix issues
