Metadata-Version: 2.4
Name: aurumaide
Version: 0.5.0
Summary: Personal AI assistant — Aurum (gold) + Aide (helper)
Author: Heui Jin
License-Expression: MIT
License-File: LICENSE
Classifier: Development Status :: 2 - Pre-Alpha
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.12
Requires-Python: >=3.12
Requires-Dist: anthropic>=0.40
Requires-Dist: google-genai>=1.0
Requires-Dist: markdown-it-py>=3.0
Requires-Dist: mcp>=1.0
Requires-Dist: openai>=1.0
Requires-Dist: prompt-toolkit>=3.0
Requires-Dist: python-dotenv>=1.0
Requires-Dist: pyyaml>=6.0
Requires-Dist: rich>=13.0
Provides-Extra: dev
Requires-Dist: mypy>=1.14; extra == 'dev'
Requires-Dist: pymupdf4llm>=0.0.17; extra == 'dev'
Requires-Dist: pymupdf>=1.24; extra == 'dev'
Requires-Dist: pytest-cov>=6.0; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: ruff>=0.9; extra == 'dev'
Requires-Dist: types-pyyaml>=6.0; extra == 'dev'
Provides-Extra: pdf
Requires-Dist: pymupdf4llm>=0.0.17; extra == 'pdf'
Requires-Dist: pymupdf>=1.24; extra == 'pdf'
Description-Content-Type: text/markdown

# AurumAide

Personal AI assistant — **Aurum** (Latin for *gold*) + **Aide** (helper).

Chats with any number of named backends — Gemini, Claude (Anthropic's
Messages API), and/or any server that speaks the OpenAI Chat Completions API
(Ollama, vLLM, llama.cpp's server, LM Studio, OpenAI itself, ...), local or
cloud, each with its own model — switchable per-message, mid-conversation,
by name.

Every backend can also read, write, and edit files and run shell commands on
the machine aurumaide runs on — see [Tools](#tools) — with **no confirmation
prompt**. This is YOLO by design, in the spirit of
[Mario Zechner's *pi* coding agent](https://mariozechner.at/posts/2025-11-30-pi-coding-agent/):
a small, direct agent that trusts the loop instead of gating every step
behind a permission dialog. Know which backend is active before you use it —
see [Configuring backends](#configuring-backends) — since whichever one
answers a query gets that access.

## Prerequisites

- Python 3.12+
- A [Google AI API key](https://aistudio.google.com/apikey) set in a `.env` file or as an environment variable (`GOOGLE_API_KEY`), needed for the default zero-config Gemini backend
- Optionally, one or more OpenAI-compatible endpoints (URL + model name) configured in `config.json` — see [Configuring backends](#configuring-backends)

## Setup

```bash
uv sync --extra dev
```

`uv.lock` is tracked so everyone (and CI) resolves the exact same
dependency versions. Without [uv](https://docs.astral.sh/uv/), plain pip
works too:

```bash
python -m venv .venv
source .venv/bin/activate   # Linux/macOS
.venv\Scripts\activate      # Windows

pip install -e ".[dev]"
```

## Usage

```
aurumaide [options] [arguments...]
```

Positional arguments are joined by spaces to form the initial query.
Without a query the assistant starts in interactive mode.

### Options

| Option | Description |
|---|---|
| `--one-shot` | Answer the query and exit (requires a query) |
| `--plain` | Disable markdown rendering, even when stdout is a terminal |
| `--disable-tools` | Disable the builtin read/write/edit/bash tools, and skip the default `~/.aurumaide/SYSTEM.md` (see [Tools](#tools)) |
| `--system-prompt-file FILE_PATH` | Read system instructions from `FILE_PATH` instead of the default `~/.aurumaide/SYSTEM.md` (see [System instructions](#system-instructions)) |
| `--no-agents-md` | Don't load the nearest `AGENTS.md` (see [Project instructions](#project-instructions-agentsmd)) |
| `--resume SESSION_ID` | Continue a saved conversation by id (see [Resuming sessions](#resuming-sessions)) |
| `--resume-last` | Continue the most recently used conversation (see [Resuming sessions](#resuming-sessions)) |

Which backend answers, and what backends exist at all, is entirely
config-driven — see [Configuring backends](#configuring-backends) and
[Message conventions](#message-conventions).

### Examples

```bash
# Interactive chat (config.defaultBackend, "gemini" out of the box)
aurumaide

# Ask a question and continue chatting
aurumaide What is the meaning of life

# One-shot: get an answer and exit
aurumaide --one-shot Explain the Python GIL

# Insert a file's contents in place of @<read:PATH> instead of --file
aurumaide --one-shot "Review this diff: @<read:changes.diff>"

# Send a single message to a specific configured backend (see below)
aurumaide "@local hello"

# Pure chat, no filesystem/shell access and no default SYSTEM.md
aurumaide --disable-tools What is the capital of France

# Use a project-specific persona/instructions instead of the global default
aurumaide --system-prompt-file ./project-instructions.md

# Pick up yesterday's conversation right where you left off
aurumaide --resume-last
```

## Configuring backends

Backends live in `~/.aurumaide/config.json` under `backends` — a map from
an **arbitrary name you choose** to its connection details — plus
`defaultBackend`, the name used until an `@name` prefix switches. A
`providers` block holds shared credentials/endpoints by name, so several
backend entries (different models, same account) don't have to repeat an
`apiKey`/`baseUrl`:

```json
{
  "defaultBackend": "gemini",
  "providers": {
    "gemini": { "apiKey": "AIza..." },
    "openai": { "apiKey": "sk-..." },
    "anthropic": { "apiKey": "sk-ant-..." },
    "local": { "baseUrl": "http://192.168.1.155:8000/v1" }
  },
  "backends": {
    "gemini": { "type": "gemini", "provider": "gemini" },
    "local": {
      "type": "openai",
      "model": "qwen3.6-27b",
      "provider": "local"
    },
    "local-thinking": {
      "type": "openai",
      "model": "qwen3.6-35b",
      "provider": "local",
      "enable_thinking": true,
      "preserve_thinking": true
    },
    "gpt5": {
      "type": "openai",
      "model": "gpt-5",
      "provider": "openai"
    },
    "haiku": {
      "type": "anthropic",
      "model": "claude-haiku-4-5",
      "provider": "anthropic"
    },
    "groq": {
      "type": "openai",
      "model": "llama-3.3-70b-versatile",
      "baseUrl": "https://api.groq.com/openai/v1",
      "apiKey": "gsk-..."
    }
  }
}
```

- `type` is `"gemini"`, `"openai"`, or `"anthropic"` — everything else
  (the backend's own name: `"local"`, `"gpt5"`, `"haiku"`, `"groq"` above)
  is free-form, not reserved words. Name backends however makes sense to
  you: by location (`"home-server"`), by provider (`"groq"`), by model
  (`"gpt-4o-mini"`) — the name is only ever used as the `@name` you type to
  reach it.
- Any number of entries can share one `provider` — `gpt5`/`gpt5nano` both
  pointing at `"openai"` above, say — or skip `provider` and set
  `baseUrl`/`apiKey` directly on the entry itself (`groq` above), whichever
  reads clearer for your setup. An entry's own `baseUrl`/`apiKey` always
  wins over its provider's.
- Any number of `"openai"`-type entries can coexist, each pointing at a
  different server and model — mix local and cloud freely.
- A `"gemini"`-type entry needs no fields beyond `type`; a missing
  `apiKey` falls back to its `provider`, then a legacy top-level
  `gemini.apiKey` block in `config.json` if present, then the
  `GOOGLE_API_KEY` env var. `model` falls back the same way (minus the env
  var), then a hardcoded default.
- An `"openai"`-type entry needs `model` and `baseUrl` at minimum (no
  universal default exists across providers) — `baseUrl` falls back to its
  `provider`, then a legacy top-level `openai.baseUrl` block; a missing
  `apiKey` falls back the same way, then `"unused"`, since many local
  servers accept any token.
- An `"openai"`-type entry can also set boolean `enable_thinking`/
  `preserve_thinking` — for reasoning-capable models served through
  vLLM/SGLang (e.g. Qwen3) that key off a `chat_template_kwargs` request
  field rather than a standard API param. `enable_thinking` asks the model
  to reason before answering; `preserve_thinking` (which implies
  `enable_thinking` — no need to set both) additionally asks the server to
  keep prior turns'/tool-calling iterations' reasoning in the rendered
  prompt instead of stripping it, and the reasoning aurumaide already saw
  gets sent back for exactly that purpose. Both default to unset/`false`
  and are per-backend (`local-thinking` above vs. plain `local`), not a
  CLI flag — reasoning streams as its own `💭 Thinking` block, same as
  Gemini's native thought output (see [Seeing tool calls (and thinking) as
  they happen](#seeing-tool-calls-and-thinking-as-they-happen)). Ignored
  for `"gemini"`/`"anthropic"` entries, which have no equivalent hook.
- An `"openai"`-type entry can also set `max_tokens` (cap the reply length —
  worth setting against a local server, which will otherwise happily
  generate until its context window fills), `presence_penalty`, and a
  `sampling` block for everything else the sampler takes:

  ```json
  "local-ocr": {
    "type": "openai",
    "model": "qwen3.6-35b",
    "provider": "local",
    "max_tokens": 8192,
    "presence_penalty": 0.0,
    "sampling": {
      "temperature": 0.7,
      "top_p": 0.8,
      "top_k": 20,
      "min_p": 0.0,
      "dry_multiplier": 0.8
    }
  }
  ```

  `sampling` is an open mapping, not a fixed list of keys: the ones Chat
  Completions names itself (`temperature`, `top_p`, `frequency_penalty`,
  `presence_penalty`, `seed`, `stop`) ride as ordinary request parameters,
  and anything else (`top_k`, `min_p`, `repeat_penalty`, the `dry_*` family,
  whatever a given server invented) goes through `extra_body`. That split
  matters: `top_k` sent as a named parameter is a 400 against OpenAI proper,
  while llama.cpp/Ollama/vLLM accept both. So a `sampling` block full of
  llama.cpp extensions belongs on a local entry, not on your `gpt5` one.

  Three things worth knowing about values here:
  - **`0` is a setting, not an absence.** `"presence_penalty": 0` actively
    cancels a server-side default (llama.cpp bakes `--presence-penalty` into
    a loaded model, so a per-request override is the only way two callers
    sharing that model can sample differently). Leaving the key out inherits
    instead.
  - **`null` means "inherit".** It drops the key from the request rather
    than sending a JSON null, which servers disagree about. This is how you
    cancel one setting that something else defaulted for you — for instance
    a single `dry_*` key the PDF converter sets (see [Converting a PDF to
    markdown](#converting-a-pdf-to-markdown)) — without restating the rest.
  - `presence_penalty` can be given either as its own field or as a
    `sampling` key; the `sampling` one wins.

  All three are per-backend, not CLI flags, and are ignored for
  `"gemini"`/`"anthropic"` entries.
- An `"anthropic"`-type entry needs `model` at minimum; a missing `apiKey`
  falls back to its `provider`, then a legacy top-level `anthropic.apiKey`
  block. Talks to Claude via the [Messages
  API](https://docs.anthropic.com/en/api/messages) — tool calls work the
  same manual-loop way as the `"openai"` backend; Claude's own extended
  thinking isn't requested and never surfaces a `💭 Thinking` block (no
  `enable_thinking`/`preserve_thinking` equivalent here — those are
  `"openai"`-type-only, above).
- If `config.json` predates this section entirely, it's treated as
  `{"defaultBackend": "gemini", "backends": {"gemini": {"type": "gemini"}}}`
  — existing installs keep working unmodified.
- `config.defaultBackend` is checked at startup only for *existence* — that
  it names an entry in `backends` — which fails immediately with a clear
  message, same as any CLI argument error. That check is deliberately just
  a name lookup and builds nothing: importing a backend SDK takes seconds,
  and the interactive prompt has to appear instantly. So a missing or
  malformed *field* inside the entry (an `"openai"` entry with no `model`,
  say) is not caught then — it surfaces the first time that backend is
  actually used, after your prompt is already saved in input history. Any
  other backend, reachable only via `@name`, is likewise checked lazily on
  first selection — an unconfigured `@name` prints a warning and leaves you
  on your current backend rather than crashing the session.

## Tools

Every backend — Gemini, Claude, or any OpenAI-compatible endpoint — gets the
same four builtin tools, always on unless you pass `--disable-tools`:

| Tool | Does |
|---|---|
| `read` | Read a file. Text files come back `cat -n`-style (1-indexed line numbers), capped at 2000 lines and 50KB by default — whichever limit is hit first, with lines never cut off mid-line — pass `offset`/`limit` for more, or override the defaults via config's `read_max_bytes`/`read_max_lines`. A truncated read ends with a hint naming the offset to continue from; if even the first line to read alone exceeds the byte limit, it points at the `bash` tool instead. Recognized image files (jpg, png, gif, webp) come back as an attachment the model can actually see, not as text, and are completely unaffected by any of the above. |
| `write` | Write content to a file, creating parent directories as needed. Creates the file if missing, overwrites if it exists. |
| `edit` | Replace one exact, unique occurrence of `oldText` with `newText` in a file. Errors (surfaced to the model, not raised) if the text isn't found or matches more than once — add more surrounding context to disambiguate. |
| `bash` | Run a shell command and return stdout/stderr/exit code. Runs in the current working directory by default, or in `workingDirectory` if given (must already exist — same error as a failed `cd`). Accepts an optional `timeout` in seconds. Picks its shell once at startup — on Windows, Git Bash if installed, else `pwsh`, else Windows PowerShell, else `cmd.exe`; any other OS uses its default shell. The tool description tells the model which shell it's talking to, so it emits commands in that shell's own syntax. `workingDirectory` is set at the OS process level, not via the shell's own `cd` — sidesteps cmd.exe's cross-drive `cd` quirk (plain `cd` silently no-ops across drive letters; only `cd /d` follows them) entirely. A command whose stdout ends with one or more `@<image:PATH>` tags gets those images attached to the result instead of left as text — see below. |

### Attaching images from a bash command's output

Any command `bash` runs can hand images back the same way a user's own
query does: end its stdout with one or more `@<image:PATH>` tags.

```
Fixed the login bug. @<image:/tmp/before.png> @<image:/tmp/after.png>
```

Those tags are stripped from the result and the images actually attached
to the model's next turn — not left as inert text it can't see. Useful
for a script a [skill](#skills)'s instructions point the model at (a
ticket-fetching skill that downloads screenshot attachments, say), but
works for any command, skill-directed or not. A tag pointing at a
missing file or unsupported extension doesn't fail the call — it's
dropped and reported back as a `Warning: ...` line instead, so the model
can see what went wrong.

### Seeing tool calls (and thinking) as they happen

Every step of a turn streams to the terminal as it happens, each in its
own clearly-labeled block — nothing is hidden or buffered until the end:

```
--- 🔧 Tool call [gemini] ---
Calling bash({'command': 'echo hi'})
----------------

--- ✅ Tool result [gemini] ---
hi

[exit code: 0]
----------------

--- 👇 Response [gemini] ---
The command printed "hi".
----------------
```

If a model surfaces its own reasoning (Gemini's `thought`-flagged output,
or an `"openai"`-type backend with `enable_thinking`/`preserve_thinking`
set — see [Configuring backends](#configuring-backends)), that shows up
the same way under a `💭 Thinking` block. Only the `👇 Response` block's
text is ever added to aurumaide's own displayed conversation
history — tool-call/result blocks are shown for visibility only and
never replayed as if the model had said them. Thinking is the one
exception: with `preserve_thinking` set, the reasoning aurumaide just
displayed *is* sent back to the model on the next turn/tool-calling
iteration — not as chat history, but via the same dedicated
`reasoning_content` request field it arrived on, since that's what
`preserve_thinking` is for.

**There is no confirmation prompt.** A model that decides to call `write`,
`edit`, or `bash` does so immediately — it can overwrite files and run
arbitrary shell commands on the machine aurumaide runs on without asking
first. That's the deliberate trade-off described in the intro: a small,
fast, unattended agent instead of one that stops to ask permission at every
step (see [*pi*](https://mariozechner.at/posts/2025-11-30-pi-coding-agent/)).
The risk is scoped to whichever backend is currently active — check
`config.backends` (below) before pointing aurumaide at a query you don't
want acted on autonomously, and reach for `--disable-tools` for pure Q&A
sessions where no tool access is wanted at all.

`--disable-tools` removes all four tools from every backend for that run
*and* skips loading the default `~/.aurumaide/SYSTEM.md` (see below) — that
file's instructions assume the tools it just removed exist, so loading it
anyway would just confuse the model. An explicit `--system-prompt-file`
still applies even with `--disable-tools`, since a custom prompt may have
nothing to do with tool use.

## Skills

Drop a Claude Code-style skill anywhere aurumaide looks and every backend
can load it via a `skill` tool, the same way the four builtin tools work.
A skill is a directory containing a `SKILL.md`:

```markdown
---
name: haiku-writer
description: Write a haiku about a given topic. Use when the user asks for a haiku or poem.
---

Write a haiku (5-7-5 syllables) about: $ARGUMENTS

Always sign it "- aurum, via haiku-writer skill" at the end.
```

`name` and `description` are required — the description is what the model
sees to decide when this skill is relevant, so make it specific about
what the skill does and when to reach for it. Everything after the
frontmatter is the skill's body: instructions the model follows once it
loads the skill. `$ARGUMENTS` in the body is replaced with whatever the
model passes as the tool's `args` — if the body doesn't mention
`$ARGUMENTS` at all, any `args` given are appended as a note instead.

Skills are discovered from:

- `~/.aurumaide/skills/*/SKILL.md` — available in every project.
- `.aurumaide/skills/*/SKILL.md` under the project root (the nearest
  `.git` root above the current directory, or the current directory
  itself if none is found) — this project only.

A skill defined in both places resolves to the project-level one. A
malformed `SKILL.md` (missing frontmatter, missing `name`/`description`,
invalid YAML) is skipped rather than breaking startup. Skills load
automatically whenever tools are enabled — `--disable-tools` disables
them along with everything else, since a skill can only be reached
through the tool call that loads it.

## System instructions

If `~/.aurumaide/SYSTEM.md` exists, its contents are loaded once at startup
and sent to every configured backend as its system instruction — free-form
prose, not JSON, so it lives outside `config.json`. A missing or
whitespace-only file means no system instruction at all; nothing breaks.

Use `--system-prompt-file FILE_PATH` to read system instructions from a
different file instead — a project-specific persona, a narrower tool-use
policy, whatever fits the session. Unlike the default file, a missing
`--system-prompt-file` path is a hard error (it was asked for by name, so
silently ignoring a typo would hide it rather than surface it).

`--disable-tools` skips the *default* `~/.aurumaide/SYSTEM.md` specifically
— an explicit `--system-prompt-file` is still honored alongside it.

Nothing creates or seeds this file for you — save your own at
`~/.aurumaide/SYSTEM.md` to enable it. A starting point that describes the
builtin tools (see [Tools](#tools)) to the model:

```markdown
You are an expert coding assistant. You help users with coding tasks by
reading files, executing commands, editing code, and writing new files.

Available tools:
- read: Read file contents
- write: Create or overwrite files
- edit: Make surgical edits to files (old text must match exactly)
- bash: Execute shell commands

Guidelines:
- Use bash for file operations like ls, grep, find
- Use read to examine files before editing
- Use edit for precise changes, write only for new files or full rewrites
- Be concise in your responses
```

### Project instructions (`AGENTS.md`)

On top of your personal `SYSTEM.md`, aurumaide also honors
[`AGENTS.md`](https://agents.md) — the open, tool-agnostic convention for
giving an agent project-specific context (build steps, conventions, house
rules). It's plain Markdown with no required schema; write whatever
headings and prose you like.

Starting from the current directory and walking **up**, aurumaide loads
the **first** `AGENTS.md` it finds and appends it to the system
instruction as project context. This is the standard's "closest one takes
precedence" rule in its simplest form: the nearest file wins outright and
any further-up ones are ignored (a subfolder's `AGENTS.md` fully replaces
the repo root's while you're working there — it doesn't merge with it).

- **Not tied to git** — discovery is anchored on your working directory,
  so it works in a repo, a subfolder of one, or a plain directory that
  isn't version-controlled at all. Drop a `~/AGENTS.md` to act as a
  personal catch-all whenever nothing closer exists.
- **Layered, not replacing** — your `SYSTEM.md` persona comes first, the
  project's `AGENTS.md` after it; a direct request in chat still overrides
  both.
- **Kept under `--disable-tools`** — unlike the tool-assuming default
  `SYSTEM.md`, `AGENTS.md` is general project context and stays loaded.
  Pass `--no-agents-md` to skip it for a run.
- Empty/whitespace-only files are skipped (the walk continues upward).

## Message conventions

Several `@`-prefixed constructs can appear in any query — the initial one,
or any follow-up typed at the interactive prompt:

### `@name` — switch backend mid-session

Prefix a message with the name of any backend from `config.backends` to
send *that* message (and only that one) to it, and make it the backend for
every message after until switched again:

```
❓ [gemini] Your question: what's the capital of France?
Paris.

❓ [gemini] Your question: @local and what's 17 * 24?
408.

❓ [local] Your question: was my first question about geography or math?
Geography — you asked about the capital of France.
```

The full conversation's text carries over every switch, so any backend
can answer questions about what was said earlier, even by another one.
Only the plain text of each turn carries over — provider-specific extras
like Gemini's Search grounding or OpenAI's structured tool calls don't have
a common format across APIs, so they aren't replayed after a switch.

Naming a backend that doesn't exist, or one whose config is broken (e.g.
missing `baseUrl`), prints a short warning and leaves you on the backend
you were already using — it won't crash the session.

Typing the bare name with nothing else (e.g. just `@local`) switches the
active backend without sending a message.

### `@<image:PATH>` — attach an image

Append `@<image:PATH>` to the end of a message to attach that image file
(png, jpeg, webp, heic, heif) to the question:

```
❓ [gemini] Your question: what color is this flower? @<image:C:\photos\iris.png>
Purple.
```

Stack multiple tags to attach more than one image to the same question:

```
❓ [gemini] Your question: which one is bigger? @<image:cat.png> @<image:dog.png>
The dog.
```

Duplicate paths (even spelled differently, like `cat.png` and `./cat.png`)
are collapsed to a single attachment.

`PATH` accepts either slash direction (`C:\photos\iris.png` or
`photos/iris.png`), so it works the same typed on Windows or POSIX. The
tag(s) must be the last thing in the message — text after the closing `>`
of the final tag isn't part of it.

Combine both tags to attach an image to a message sent to a specific
backend:

```
aurumaide --one-shot "@local what is this? @<image:cat.png>"
```

A missing file or unsupported extension prints a warning and skips that
turn rather than crashing or sending the question without the image.

### `@<read:PATH>` — insert a file's contents

Write `@<read:PATH>` anywhere in a message to replace it in place with that
file's contents — this is how you hand aurumaide a file's worth of context
without the removed `--file` flag:

```
❓ [gemini] Your question: review this: @<read:diff.patch>
```

### `@<#! shell command #>` — insert a command's output

Write `@<#! shell command #>` anywhere in a message to replace it in place
with that command's **stdout only**:

```
❓ [gemini] Your question: is @<#! git branch --show-current #> the right branch to release from?
```

### `@<#!! shell command #>` — run a command and show its output directly

Two exclamation marks instead of one runs the command and prints its
stdout immediately as its own response block, rather than feeding it to a
backend at all. It's shown preformatted (a markdown code fence, in the
default markdown-rendering terminal mode) so column-aligned output like
`dir`/`ls` survives instead of getting reflowed into a run-on line of prose:

```
❓ [gemini] Your question: @<#!! git status #>

--- 👇 Response [shell] ---
On branch main
nothing to commit, working tree clean
----------------
```

If the tag is the entire message, nothing gets sent to a backend — you get
the command's output and nothing else. Combine it with other text in the
same message and everything besides the tag itself still goes to the model
as usual.

**Both tags run in the same shell the [`bash` tool](#tools) picked at
startup** (Git Bash if installed on Windows, else `pwsh`, else Windows
PowerShell, else `cmd.exe`; whatever the default shell is elsewhere) —
`@<#!...#>`/`@<#!!...#>` and a model's own `bash` tool calls always land in
the same shell on the same machine, so quoting/piping/control-flow only
need to match one syntax. Run `@<#! echo $0 #>` (POSIX shells) or
`@<#! echo %COMSPEC% #>` (cmd.exe) to see which one that is on your machine.

**Both shell tags capture stdout only — non-empty stderr is treated as an
error**, even from a command that exits 0. This is deliberate: stderr
output is usually a warning or diagnostic, not the actual result, and it
shouldn't get silently inserted into a prompt or displayed as if it were.
If a command legitimately needs its stderr captured too, redirect it
yourself with the standard shell syntax:

```
❓ [gemini] Your question: @<#! mycommand 2>&1 #>
```

### Error handling for `@<read:...>`/`@<#!...#>`/`@<#!!...#>`

A missing file, a command that writes to stderr, a command that exits
non-zero, or a command that times out (30s) stops processing immediately:
aurumaide prints the error plus your original, unedited prompt, and sends
nothing to any backend. Fix the tag and resend. Commands already run
before the failing tag keep whatever effect they had — there's no undo for
a shell command that already ran.

## Interactive input

Interactive mode (no `--one-shot`) uses a history-aware, auto-suggesting
line editor when stdin is a real terminal, backed by
[`prompt_toolkit`](https://github.com/prompt-toolkit/python-prompt-toolkit)
(the same library behind IPython and ptpython):

- **Persistent history** — every line you send is saved to
  `~/.aurumaide/input_history` and recalled with the Up/Down arrows,
  across separate `aurumaide` runs, not just within one session. A query
  given on the command line (`aurumaide what is the meaning of life`,
  including with `--one-shot`) is recorded the same way, so it's still
  there to recall next time even if that run never touched the
  interactive prompt.
- **Auto-suggest** — as you type, a greyed-out suggestion completes the
  rest of a matching line from history (fish-shell style); accept it with
  → or End.
- **Paste-safe** — pasting multi-line text lands as one input rather than
  submitting a message per line.
- **Multi-line composition** — plain Enter still submits, same as always;
  press Escape then Enter to insert a literal newline and keep composing
  before sending.

`--plain` disables all of this and falls back to plain `input()` — same
flag that disables markdown rendering on the output side. It's also the
automatic fallback whenever stdin isn't a real terminal (piped input,
`--one-shot`, scripts), and whenever the terminal claims to be interactive
but isn't one `prompt_toolkit` can actually drive (Git Bash/MinTTY on
Windows is a real example of this) — aurumaide falls back to plain input
there rather than crashing.

## Resuming sessions

Every conversation is saved as it happens, so you can pick it up in a
later run. Each turn is appended to `~/.aurumaide/sessions/<id>.jsonl`,
where `<id>` is a 12-character session hash — the filenames in that
directory are exactly the ids you pass to `--resume` (the hash is also
line 1 of each saved markdown log). No flag is needed to *save* — it's
automatic (including for `--one-shot` runs); the flags are for coming
*back*:

```bash
# Continue the most recent conversation (what you'll usually want)
aurumaide --resume-last

# Continue a specific saved conversation by its id
aurumaide --resume 3f9a1c2b7e04

# Resume and send a message in the same breath
aurumaide --resume-last "ok, now add error handling"
```

`--resume-last` continues whichever session you talked to most recently;
`--resume <id>` targets a specific one (it errors if the id doesn't
exist, and `--resume-last` errors if there are no saved sessions). Either
can be combined with a query and with `--one-shot`. The two flags are
mutually exclusive. New turns append to the same file, so a resumed
session keeps growing rather than forking a new transcript.

**What's restored — and what isn't.** Resuming replays the **conversation
text** (your questions and the assistant's answers), which is what the
next reply is conditioned on. It deliberately does *not* restore prior
tool calls, tool results, "thinking" output, or attached images — only
plain text is persisted, the same way only plain text carries across an
`@name` backend switch mid-session. So a resumed session has the
*conversational* memory of what was discussed, not a replay of every
`bash`/`edit` the model previously ran.

> These per-session transcripts are separate from the human-readable
> per-turn markdown logs and the input-line history
> (`~/.aurumaide/input_history`) — different files, different purposes.

## MCP server

Separately from the chat CLI, aurumaide can run as an
[MCP](https://modelcontextprotocol.io/) server exposing Gemini as a tool
to *other* MCP clients (Claude Desktop, another Claude Code session,
etc.) — a `google_ai(query)` tool that answers a question via the Gemini
API and logs the exchange the same way the chat CLI does:

```bash
python -m aurumaide.google.mcp
```

Runs over stdio, so point an MCP client's config at that command rather
than running it directly. It's independent of `config.backends` — it
always talks to Gemini directly via `google-genai`, using the same
`GOOGLE_API_KEY`/`config.json` resolution as the `"gemini"` backend type.

## Converting a PDF to markdown

Also separate from the chat CLI: a PDF → markdown converter that OCRs only
the pages that need it. Needs the `pdf` extra
(`uv sync --extra pdf`, or `pip install -e ".[pdf]"`).

```bash
# Whole document, triaging each page
python -m aurumaide.parsers.pdf scan.pdf out.md

# Just some pages (1-based, matching what's printed on the page)
python -m aurumaide.parsers.pdf scan.pdf out.md --pages 1-5,8,11-13

# What would this cost? Reports the extract/OCR split and why, calling no
# model and writing nothing.
python -m aurumaide.parsers.pdf scan.pdf --dry-run
```

Each page is triaged first. Pages whose text layer is trustworthy go
through `pymupdf4llm`, which recovers real heading levels, rejoined
paragraphs and tables for free and with no tokens; the rest are rendered
to an image and transcribed by a local model. Output is spliced under
`<!-- Page N (extracted|ocr) -->` markers, so which path produced which
page is visible rather than inferred. On a 483-page sample corpus that was
52 model calls instead of 483, with the born-digital pages coming back
*better* than OCR manages.

Useful flags: `--mode` (`auto` triages, `force-ocr` transcribes every page,
`extract-only` never calls a model), `--model` (which configured backend to
use — defaults to a local one), `--attempts` (OCR tries per page before a
looped result is accepted), `--presence-penalty` and
`--temperature`/`--top-p`/`--top-k`/`--min-p`/`--seed`.

**Exit code 3** means "converted, but some pages were truncated by a
repetition loop" — each one is also marked in the output — so a script
sweeping a directory can tell that from clean success without parsing
stdout.

A local model transcribing a dense page can fall into repeating one line
until its context fills, so the converter sends its own sampler settings
per request (the DRY family, which penalises a repeated *sequence* rather
than a repeated *token* — the distinction matters for CJK, where a page
legitimately reuses the same characters constantly). Override any of it per
model via that backend entry's own `sampling` block; see
[Configuring backends](#configuring-backends), including `null` to inherit
a single key back from the server.

## Development

```bash
# Run tests
uv run pytest

# Lint & format
uv run ruff check .
uv run ruff format .

# Type checking
uv run mypy src
```

(Drop `uv run` if you installed with plain pip into an activated venv.)

## License

[MIT](LICENSE)
