# VibeBot v8 VibeBot is a Limnoria IRC bot whose LLM plugin answers chat, draws, animates, searches, schedules reminders and runs a shared-fiction "verse". These pages cover running and configuring it. # Operator guide # Installation and deployment To ask a chat assistant about setup, give it [llms-full.txt](https://rdrake.github.io/vibebot-v8/llms-full.txt): this whole guide as one Markdown file. [llms.txt](https://rdrake.github.io/vibebot-v8/llms.txt) is the page index. ## Prerequisites You need one of: - **Docker** (recommended): a systemd deployment needs the binary at `/usr/bin/docker`, which the unit calls directly. - **Python 3.12 or newer** with [uv](https://docs.astral.sh/uv/): for manual and development installs. VibeBot is a [Limnoria](https://docs.limnoria.net/) plugin. If you have never used Limnoria before, follow the [Limnoria getting started guide](https://docs.limnoria.net/use/getting_started.html) to create your initial `bot.conf`. You need a working `bot.conf` before proceeding. ## Docker deployment (recommended) Pull the latest image: ``` docker pull ghcr.io/rdrake/vibebot-v8:latest ``` Run the container, mounting your configuration and data directories: ``` docker run --rm --name vibebot \ --user $(id -u):$(id -g) \ --env-file /path/to/config/env \ -v /path/to/config:/config \ -v /var/www/llm:/var/www/llm \ -w /config \ ghcr.io/rdrake/vibebot-v8:latest ``` The `/config` volume holds your `bot.conf` and its `conf/`, `data/`, and `logs/` directories. The `/var/www/llm` mount is optional: add it only when you point `httpRoot` there for an external web server to serve. `httpRoot` is empty by default, which keeps the `@code` and `@draw` output inside `/config` under `data/web/llm/` — see [Configuration → Web and HTTP output](https://rdrake.github.io/vibebot-v8/operator/configuration/#web-and-http-output). `--env-file` is not optional. Provider API keys are read from the environment at call time and have no `bot.conf` equivalent, so without it the bot connects to IRC and every LLM command answers `no API key configured for provider`. Start from `.env.example` in the repository — it lists the four provider keys, the optional ones, and the format rules Docker's `--env-file` parser enforces. See [Configuration → API keys](https://rdrake.github.io/vibebot-v8/operator/configuration/#api-keys). ## systemd service The repository includes a systemd user service for production deployments. The target lives in the repository's `Makefile`, so clone first even if you are deploying the published image: ``` git clone https://github.com/rdrake/vibebot-v8.git cd vibebot-v8 make install-service ``` This copies the unit file and creates the directory layout: | Path | Purpose | | ---------------------------------------- | --------------------------------------------------------------------------------------------- | | `~/.config/systemd/user/vibebot.service` | systemd unit | | `~/.config/vibebot/` | `bot.conf`, the `env` file, and the bot's `conf/`, `data/`, `logs/` and `backup/` directories | The unit runs the Docker image with `~/.config/vibebot` mounted at `/config`, passes the same directory's `env` file to `docker run --env-file`, and pulls the latest image on every start. After installing, complete the setup: 1. Copy your `bot.conf` to `~/.config/vibebot/bot.conf`. 1. Edit `~/.config/vibebot/env`. `make install-service` seeds it from `.env.example`, so it arrives full of placeholders: enter the providers you use and delete the lines for the rest, since a leftover `your-xai-key-here` counts as a real key. Keep to the format rules noted in that file — `NAME=value`, no quotes, no trailing comments, no spaces around `=`, LF line endings. Docker's `--env-file` parser rejects a malformed line by exiting the container on start, and `Restart=always` turns that into a silent crashloop. 1. Enable and start the service: ``` systemctl --user enable vibebot systemctl --user start vibebot loginctl enable-linger $USER # keeps the service running after logout ``` ### Auto-updates Install the update timer to pull new Docker images automatically (checks every 15 minutes): ``` make install-timer ``` Or install both the service and the timer at once: ``` make install-deploy ``` See [Operations](https://rdrake.github.io/vibebot-v8/operator/operations/index.md) for how the two update paths work together. ## Manual and development setup Clone the repository and install dependencies: ``` git clone https://github.com/rdrake/vibebot-v8.git cd vibebot-v8 make install ``` Start the bot: ``` make run ``` This runs `uv run limnoria bot.conf` from the repository root. You need a `bot.conf` in the working directory. For development, also install the pre-commit hooks: ``` make install-hooks ``` Useful development targets: | Target | What it does | | ----------------- | ------------------------------------------------------ | | `make test` | Run the fast test suite with coverage (93% floor) | | `make test-all` | Run every test, including slow ones | | `make lint` | Run `ruff check` | | `make typecheck` | Run `ty check` on both plugins | | `make check` | Lint, format check, typecheck, syntax check, and tests | | `make preflight` | Format, then run the full `check` gate | | `make docs-serve` | Preview this documentation site locally | ## Verifying the installation For systemd deployments, check the service status: ``` systemctl --user status vibebot ``` For Docker, check the running container: ``` docker ps | grep vibebot ``` Once the bot connects to IRC, verify the plugin loaded: ``` @list LLM ``` This lists the available commands (`ask`, `code`, `draw`, and so on). If the plugin is not loaded, load it from IRC: ``` @load LLM ``` # Operations and auto-updates This page covers day-to-day operation of a deployed bot: updates, restarts, logs, configuration changes, and housekeeping. ## How updates reach production Two independent paths keep the running bot current. Both end in the same place: a restart that pulls the newest image. **Path 1: CI push deploy.** A push to `main` runs CI. When CI passes, a second workflow builds the Docker image, pushes it to `ghcr.io/rdrake/vibebot-v8:latest`, and then connects to the production host over SSH with a restricted deploy key. The remote key runs one forced command: `systemctl --user restart vibebot`. The service unit pulls the latest image on start, so the restart deploys the new build. A push that touches only `docs/**`, `*.md`, or `mkdocs.yml` is exempt. CI's `paths-ignore` skips those, and because `docker.yml` triggers on CI success, no image is built and the running bot is never restarted. Documentation still publishes: `pages.yml` has its own trigger on `docs/**` and `mkdocs.yml`. A push that changes only a root-level `*.md` fires nothing at all. **Path 2: updater timer.** `vibebot-updater.timer` fires every 15 minutes. Its service compares the local image digest against the registry and restarts `vibebot` when they differ — including when the bot is stopped, because the local lookup then returns nothing (see [Editing bot.conf safely](#editing-botconf-safely)). This path catches anything the push deploy missed, such as a host that was offline during the push. ``` git push to main → CI → Docker build → GHCR ↓ ↓ SSH restart (immediate) updater timer (≤15 min) ↓ ↓ systemctl --user restart vibebot ↓ ExecStartPre pulls :latest ``` The source code never needs to live on the server. The Docker image has everything; the server holds only configuration and data. ## Files involved | File | Purpose | | ------------------------- | ---------------------------------------------------------- | | `vibebot.service` | Runs the Docker container; pulls the latest image on start | | `vibebot-updater.service` | Compares image digests; restarts the bot when they differ | | `vibebot-updater.timer` | Triggers the update check every 15 minutes | Install all three with `make install-deploy`. See [Installation](https://rdrake.github.io/vibebot-v8/operator/installation/index.md) for the full setup walkthrough. ## Quick reference | Task | Command | | --------------------- | ----------------------------------------------- | | Check status | `systemctl --user status vibebot` | | View logs | `journalctl --user -u vibebot -f` | | View recent logs | `journalctl --user -u vibebot -n 100` | | Manual restart | `systemctl --user restart vibebot` | | Force an update check | `systemctl --user start vibebot-updater` | | Check timer status | `systemctl --user status vibebot-updater.timer` | | Stop the bot | `systemctl --user stop vibebot` | | Start the bot | `systemctl --user start vibebot` | A stopped bot does not stay stopped: the updater timer restarts it within 15 minutes. Stop `vibebot-updater.timer` as well when you need the bot down for longer than that — see below. ## Editing bot.conf safely Limnoria flushes its in-memory registry to `bot.conf` on shutdown. If you edit the file while the bot is running, the shutdown flush overwrites your changes. The updater timer will restart the bot out from under you, so stop it first. `vibebot-updater.service` reads the local digest off the *container* with `docker inspect ... vibebot`, and the container runs `--rm`, so while the bot is stopped that lookup falls back to `none`. It never matches the registry digest, and the timer restarts the bot on its next 15-minute tick — partway through your edit. Always follow this order: ``` systemctl --user stop vibebot-updater.timer systemctl --user stop vibebot # edit ~/.config/vibebot/bot.conf systemctl --user start vibebot systemctl --user start vibebot-updater.timer ``` For single settings, prefer the `@config` command from IRC instead; it changes the live registry and survives restarts. Reserve direct file edits for bulk changes and values that are awkward to type in IRC. ## Log locations | Log | Command | | ---------------- | -------------------------------------- | | Service logs | `journalctl --user -u vibebot` | | Update logs | `journalctl --user -u vibebot-updater` | | Bot message logs | `~/.config/vibebot/logs/messages.log` | Container output is also available through `docker logs vibebot`. ## Housekeeping The repository ships two maintenance scripts: | Script | Purpose | | -------------------------- | ------------------------------------------------------------------------ | | `scripts/prune_backups.py` | Keep the newest `--keep` of Limnoria's rolling `bot.conf.backup.*` files | | `scripts/rotate_logs.py` | Size-based rotation of `messages.log`, keeping the last `--keep` copies | Both default to repository-relative paths, so on a production host give them the real directories: ``` python3 scripts/rotate_logs.py --log ~/.config/vibebot/logs/messages.log --keep 7 python3 scripts/prune_backups.py --dir ~/.config/vibebot/backup --keep 20 ``` Add `--dry-run` to either one to see what it would touch. Run them from cron or a systemd timer on the host if your deployment accumulates large logs or backups. Unlike the bot itself, these two run on the host rather than in the container. Both are stdlib-only, so copying the single file across and running it with `python3` is enough — no clone, no dependency install. ## Troubleshooting ### Service fails to start Check that Docker runs, then read the service log: ``` docker info journalctl --user -u vibebot -n 50 --no-pager docker images | grep vibebot ``` ### Timer is not running ``` systemctl --user list-timers systemctl --user enable --now vibebot-updater.timer ``` ### Updates are not applying Force a check and read the updater log: ``` systemctl --user start vibebot-updater journalctl --user -u vibebot-updater -n 20 --no-pager docker pull ghcr.io/rdrake/vibebot-v8:latest ``` ### Service stops after logout Enable lingering for your user: ``` loginctl enable-linger $USER ``` ### Configuration changes are not applied Restart the service after editing configuration, and remember the stop-edit-start order for `bot.conf`: ``` systemctl --user restart vibebot ``` ## Rolling back a deploy Rollback is manual. A revert push through CI takes 10-20 minutes end to end, and `docker.yml` only builds after `CI` succeeds across a three-version Python matrix (3.12, 3.13, 3.14) — one flaky test blocks the image from ever publishing. When production needs to go back to a known-good build now, pin the previous image directly rather than waiting on a revert. Every image build is tagged with the commit SHA (`type=sha` in `docker.yml`, alongside `:latest`), so every build keeps a permanent `sha-` tag and stays pullable after `:latest` moves past it. 1. **Find the previous image tag.** If the bad build has not been restarted onto yet, read the commit off the still-running container: ``` docker inspect vibebot --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' ``` That prints the full SHA; the tag is `sha-` plus its first seven characters. Do not use `{{.Config.Image}}` — the unit always runs `:latest`, so that only ever prints `:latest`. Once the bad build is live, take the SHA from the GitHub Actions run summary for the last known-good `Build and Push Docker Image` run instead. 1. **Stop the updater first.** `vibebot-updater.service` hardcodes `IMAGE=ghcr.io/rdrake/vibebot-v8:latest` and compares that digest against the running container's every 15 minutes. It cannot undo the pin — the step 3 drop-in wins over the unit's own `Environment=IMAGE` — but a pinned container never matches the `:latest` digest, so the timer restarts `vibebot` on every tick, dropping the bot off IRC each time. ``` systemctl --user stop vibebot-updater.timer ``` 1. **Pin the image with a systemd drop-in.** Overriding `Environment=IMAGE=...` for the `vibebot.service` unit points `ExecStartPre`'s `docker pull ${IMAGE}` and the `docker run` at the pinned tag instead of `:latest`. ``` mkdir -p ~/.config/systemd/user/vibebot.service.d printf '[Service]\nEnvironment=IMAGE=ghcr.io/rdrake/vibebot-v8:sha-\n' \ > ~/.config/systemd/user/vibebot.service.d/override.conf systemctl --user daemon-reload && systemctl --user restart vibebot ``` Replace `` with the short SHA found in step 1. 1. **Roll forward again once the fix ships.** Remove the drop-in and restart the updater timer: ``` rm ~/.config/systemd/user/vibebot.service.d/override.conf systemctl --user daemon-reload && systemctl --user restart vibebot systemctl --user start vibebot-updater.timer ``` ## Credentials in the environment API keys live in environment variables passed to the container via `--env-file` (see [Configuration → API keys](https://rdrake.github.io/vibebot-v8/operator/configuration/#api-keys)). That moves where the credential is visible on the host: - `docker inspect vibebot --format '{{.Config.Env}}'` prints every variable, values included, to anyone in the `docker` group. - `/proc//environ` for the container's process exposes the same values to anything with host-level access to that PID. - Every child process the container spawns inherits the full environment, key values included, whether or not that process needs them. Registry-stored keys were readable by anyone with owner-level `@config` access; the exposure surface has moved from "Limnoria owner capability" to "docker group and host process access." Scope access to the host and the `docker` group accordingly. ## Uninstalling Remove the service and timer: ``` make uninstall-timer make uninstall-service ``` Both targets leave `~/.config/vibebot/` alone: `bot.conf`, the `env` file, and the `conf/`, `data/`, `logs/`, and `backup/` subdirectories are preserved. `data/` holds `LLM.db` (conversations, memories, usage), the per-channel verse databases under `data/verse/`, and — while `httpRoot` is empty — the `@code` and `@draw` output under `data/web/llm/`. Delete that directory only when you are sure you no longer want any of it. If you set `httpRoot`, that output lands there instead, outside `~/.config/vibebot/`; the shipped unit mounts `/var/www/llm` for that case, and removing the config directory leaves it behind. Installs made before September 2026 also have an empty `~/.local/share/vibebot/{conf,data,logs}`. The container never mounted it, so it is safe to remove. # Configuration VibeBot uses Limnoria's registry for all configuration. See the [Limnoria configuration documentation](https://docs.limnoria.net/use/configuration.html) for how the registry works in general. Set values from IRC with: ``` @config plugins.LLM. ``` View a current value by omitting the value. Channel-overridable settings also accept a per-channel form: ``` @config channel #channel plugins.LLM. ``` **Scope** in the tables below means: *channel* settings accept per-channel overrides; *global* settings apply everywhere. A new default in a release does not change a running bot Limnoria writes the whole registry to `bot.conf` when the bot shuts down, so every setting the bot has ever loaded exists there as an explicit line. That line wins over the default shipped in a later release. So when an upgrade changes a **Default** in the tables below, the new value applies to fresh installs only. An existing deployment keeps whatever was persisted, silently and with no error — the feature simply behaves as it did before. To adopt a new default, set it explicitly with `@config` (which writes through the registry), or stop the bot, edit `bot.conf`, and start it again. Editing `bot.conf` while the bot runs does not work: the next shutdown flush overwrites it. Verify by reading back the value with `@config plugins.LLM.` — a green deploy and a quiet log do not prove the new value took effect. ## API keys API keys are **not** part of the Limnoria registry. They come from environment variables, one per provider, read at the point each provider call is made and selected by the provider of the model being called — not by which command or channel triggered the call. The old `assistantApiKey` / `codeApiKey` / `imageApiKey` / `searchApiKey` registry settings were removed, not deprecated: `@config` on them now errors. | Variable | Provider | | ------------------- | ------------------ | | `XAI_API_KEY` | xAI (Grok) | | `GEMINI_API_KEY` | Google Gemini | | `OPENAI_API_KEY` | OpenAI | | `ANTHROPIC_API_KEY` | Anthropic (Claude) | Any other provider LiteLLM recognises — `vertex_ai`, `openrouter`, `azure`, `bedrock`, and so on — resolves to no key from this plugin, so LiteLLM falls back to that provider's own native credentials: Application Default Credentials, IAM, or its own environment variables. With the defaults, a fresh install needs `ANTHROPIC_API_KEY` for chat and `@code`, `XAI_API_KEY` for search and `@draw` (the default `imageModel` is `xai/grok-imagine-image`). ### Running on local models A local server (Ollama, vLLM, LM Studio) takes no key from this plugin. Set its address in the env file and pick models with the matching LiteLLM prefix: | Server | Env variable | Model prefix | | --------- | ------------------------------------------ | --------------------- | | Ollama | `OLLAMA_API_BASE=http://host:11434` | `ollama_chat/` | | vLLM | `HOSTED_VLLM_API_BASE=http://host:8000/v1` | `hosted_vllm/` | | LM Studio | `LM_STUDIO_API_BASE=http://host:1234/v1` | `lm_studio/` | ``` @config channel #chan supybot.plugins.LLM.assistantModel ollama_chat/qwen3:14b ``` Don't use `openai/` with `OPENAI_API_BASE` for a local server. That redirects every OpenAI call in the bot and sends `OPENAI_API_KEY` to your box. The chat path calls tools, so the local model must support function calling. Each role has its own model setting (`assistantModel`, `searchModel`, `codeModel`, `memoryModel`, `verseModel` and the rest under [Model selection](#model-selection)). Any role left on a hosted provider still sends its requests there. That matters when you turn off `privacyOptInOnly`: keep every model a channel uses local, or overheard lines can still reach a hosted provider. ### Opting in to Vertex AI Vertex AI is not the default, but it remains a supported `imageModel` (and general model) choice for anyone who wants it — for example to bill image generation to a separate GCP project. Credentials (ADC, IAM) are enough for authentication, but LiteLLM's Vertex Imagen path separately requires the project and region, and it fails closed with `vertex_project and vertex_location are required for Vertex AI` if they are missing: | Variable | Required | Notes | | ------------------- | -------- | --------------------------------------------------------------------------- | | `VERTEXAI_PROJECT` | Yes | Your GCP project ID | | `VERTEXAI_LOCATION` | Yes | Region, e.g. `us-central1`. `VERTEX_LOCATION` also works as a fallback name | Vertex AI takes no bearer key from this plugin — no `VERTEX_AI_API_KEY` exists; it authenticates through ADC and IAM only. ### Drawing against a self-hosted endpoint `imageApiBase` points `@draw` at any OpenAI-shaped `/v1/images/generations` endpoint — including the same box `@animate` uses, which serves images as well as video. Set it and three things change: the request carries an `api_base`, it authenticates with `ANIMATE_API_KEY` instead of the provider key `imageModel`'s name would imply, and `imageSteps`/`imageSize` ride along in the request body. Leave it empty and nothing changes; the hosted provider path is untouched. ``` @config channel #chan supybot.plugins.LLM.imageApiBase http://host:port/v1 @config channel #chan supybot.plugins.LLM.imageModel openai//path/to/model @config channel #chan supybot.plugins.LLM.imageSteps 8 @config channel #chan supybot.plugins.LLM.imageSize 1024x576 ``` The `openai/` prefix tells LiteLLM which wire format to speak; everything after it is the model name your server expects, which is why a filesystem path with slashes in it is fine. Two things to weigh before switching a busy channel over: - **Steps are the whole latency budget.** Measured against the reference box at 1024x576 on an idle server: 8 steps ≈ 32 seconds, 25 steps ≈ 94 seconds. Compare a hosted provider answering in a few seconds. Raise `drawTimeout` (default 120) to cover whatever you pick, or draws will fall into the stash-and-deliver-later path. - **A busy box is much slower than an idle one.** That server runs jobs concurrently rather than queueing, so a draw submitted while clips are rendering can take well over twice as long, and every other job on the box slows down too. Spend is recorded as `$0.00`, which is accurate for your own hardware — and unlike an unpriced hosted model, it does not log the "no price in `IMAGE_COST_PER_IMAGE`" warning, so that warning still means what it says. ### Falling back when a provider refuses The same endpoint can sit behind a hosted provider instead of replacing it. Set `imageFallbackApiBase` and a refusal on content grounds is redrawn against your own box rather than reaching the user as an error: ``` @config channel #chan supybot.plugins.LLM.imageFallbackApiBase http://host:port/v1 @config channel #chan supybot.plugins.LLM.imageFallbackModel openai//path/to/model @config channel #chan supybot.plugins.LLM.imageFallbackSteps 8 @config channel #chan supybot.plugins.LLM.imageFallbackSize 1024x576 ``` The fallback is sent the **original prompt**, never a safety rewrite. A rewrite exists to talk a filter around, and an endpoint with no filter has nothing to be talked around — so the picture that arrives is the one that was asked for, and it carries no 🔁 marker because nothing was reworded. This interacts with `drawAutoRewriteMax` (default `1`), and the two orderings buy different things: | `drawAutoRewriteMax` | What a refused prompt does | | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `1` (default) | Rewrites once and redraws on the provider. If that is refused too, the fallback draws the original. Fast when it works, but the user gets a softened version of their request, and the rewrite costs a second billed call | | `0` | Skips rewriting and goes straight to the fallback. Slower per refusal (a self-hosted draw is tens of seconds), free, and faithful to what was asked | Refusals are still recorded either way. A recovered draw carries its `blocked_attempts` through to the usage table, so the refusal rate stays measurable, and the fallback logs `image_fallback_served` at WARNING so you can count how often it earns its keep. If the fallback also fails — refused again, misconfigured, or the box is down — the user gets the original refusal message. Which of two backends disappointed them is not something they asked about. ### Video generation `@animate` talks to a self-hosted vLLM video server rather than a LiteLLM provider, so it needs both halves of its own credential and stays off until it has them: | Setting | Where | Notes | | ----------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------- | | `ANIMATE_API_KEY` | Environment | Bearer token for the video server. Covered by log redaction like every other `*_API_KEY` | | `animateApiUrl` | Registry | Base URL, e.g. `http://videohost:14205` — no trailing `/v1`. Empty (the default) disables `@animate` and hides the `generate_video` tool | With either half missing the command returns a configuration error and the tool never reaches the model, so it cannot promise a clip the bot has no way to render. `@animate` never sends the words the user typed. A planner turn rewrites the ask into a script the video server can render, so the usage row records both: `prompt` holds the request and `rendered_prompt` holds what was submitted. Every other command leaves `rendered_prompt` empty. Rows written before this landed have it empty too — the rendered wording was only ever held in `pending_tasks`, which is cleared as each clip is delivered. Copy `.env.example` to `~/.config/vibebot/env` and set the variables there; the systemd unit passes that file to the container with `--env-file` (see [Installation](https://rdrake.github.io/vibebot-v8/operator/installation/index.md)). A model whose provider has no key configured fails with an error naming the missing variable, for example `no API key configured for provider 'xai' (set XAI_API_KEY)`. ## Model selection Models follow [LiteLLM's provider/model format](https://docs.litellm.ai/docs/providers). Model names are validated against LiteLLM's known providers; the bot rejects unrecognised names and suggests alternatives. | Setting | Scope | Default | Description | | ------------------------ | ------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `assistantModel` | channel | `anthropic/claude-haiku-5-5` | All assistant text and tool work: chat, planner, memory, reminders, scheduled tasks. Needs vision support if users paste image URLs | | `refusalFallbackModel` | channel | `xai/grok-4-1-fast-non-reasoning` | Re-runs a chat turn once when its model refuses: a policy refusal that survives its one retry, or a provider refusal error. The fallback gets the original request without the refusal. Empty turns it off. Skipped when the turn already runs on this model, when its provider has no key, or when a tool that changes something (a reminder, an image, a memory) already worked this turn, since the re-run would repeat it. Its cost books at `xai/grok-4.3` rates, the model behind the alias | | `codeModel` | channel | `anthropic/claude-haiku-5-5` | The code-generation one-shot behind `@code` and the `generate_code` tool. The `@code` planner loop itself runs on `assistantModel` | | `imageModel` | channel | `xai/grok-imagine-image` | Model for `@draw` | | `imageApiBase` | channel | empty | Draw against an OpenAI-shaped endpoint of your own instead of the provider `imageModel` names — see [Drawing against a self-hosted endpoint](#drawing-against-a-self-hosted-endpoint) | | `imageSteps` | channel | `0` | Denoising steps for a self-hosted endpoint. `0` lets the server choose. Ignored unless `imageApiBase` is set | | `imageSize` | channel | empty | Output geometry as `WxH` for a self-hosted endpoint. Ignored unless `imageApiBase` is set | | `imageFallbackApiBase` | channel | empty | Endpoint to redraw against when the primary provider refuses a prompt — see [Falling back when a provider refuses](#falling-back-when-a-provider-refuses) | | `imageFallbackModel` | channel | empty | Model for `imageFallbackApiBase`. A plain string, not a validated model name | | `imageFallbackSteps` | channel | `0` | Denoising steps for the fallback endpoint | | `imageFallbackSize` | channel | empty | Output geometry as `WxH` for the fallback endpoint | | `searchModel` | channel | `xai/grok-4.3` | Model for web search and URL fetch. Falls back to `assistantModel`. Only xAI and Gemini models search; any other provider answers from its own knowledge. GitHub links skip it: they are read through GitHub's API. A request can run at most three searches | | `memoryModel` | channel | empty | Model for the memory extraction that runs after every reply. Falls back to `assistantModel`. A flash-lite class model is enough | | `subjectResearchEnabled` | channel | `True` | Research the real people, places and events a `@draw` or `@animate` request names before the planner writes the prompt — see [Subject research](#subject-research) | | `subjectResearchModel` | channel | empty | Model for the subject-research pre-stage. Falls back to `searchModel`, then `assistantModel` | | `verseModel` | channel | empty | Model for verse-mode replies. Falls back to `assistantModel`. Set this when the assistant model is a terse reasoning model that writes poor prose | | `verseCompactionModel` | global | `anthropic/claude-haiku-5-5` | Cheap model for the daily verse compaction job. Unlike the channel model keys this one is a plain string, so a misspelled model name is accepted at `@config` time and only fails when the nightly job runs | Channel overrides let different channels run different models: ``` @config channel #dev plugins.LLM.assistantModel anthropic/claude-sonnet-4-20250514 @config channel #casual plugins.LLM.assistantModel anthropic/claude-haiku-5-5 ``` ### Subject research Image and video generators know nothing by name. `@animate` renders a bare "Churchill" as nothing at all, or as the word printed across the frame, and `@draw` is little better — which is why both commands run a planner turn that rewrites the request before the generator ever sees it. The planner is told to keep the name the user typed and put a description of the subject beside it, but for a real person or a real event it has nothing to build that description from: the verse canon block covers characters the channel invented, not people the world already contains. `subjectResearchEnabled` (on by default) closes that gap with one grounded completion in front of the planner. It identifies the real subjects the request names, looks up how each actually looks, and layers the answer onto the planner's prompt. A request that names nobody real — "a slow aerial shot over a pine forest" — comes back with nothing and adds nothing to the prompt. The cost is one extra call and a few seconds. `@animate` hides that behind a render measured at 135 seconds; `@draw` does not, so `@draw` gets visibly slower with this on. The typing indicator covers the wait. The usage table records research costs under the command name `dossier`, separately from the planner's own row, because the two run on different models. Only the dash-prefixed fact lines the research prompt asks for are kept, and only those that end in a `(source: …)` tag. A model that declines the question, or wraps its answer in commentary, contributes nothing rather than contributing its refusal to the next model's prompt. The source tag guards the other failure: asked about a name that exists nowhere, a grounded model guesses an appearance from the words in it rather than answering `NONE`, and a guess has nothing to cite. The tag is stripped before the line reaches the planner. `subjectResearchModel` picks the model. Left empty it falls back to `searchModel` and then `assistantModel`, which in most deployments means the researcher is a different provider from the planner. Set it when the search model is more reticent about describing real people than the generator downstream is. ## System prompts System prompts define the bot's personality and constraints. Both are channel-overridable. | Setting | Description | | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | | `assistantSystemPrompt` | Personality and constraints for all assistant work. Verse mode layers scene context on top of this prompt rather than replacing it | | `codeSystemPrompt` | Instructions for `@code` output format | Both defaults carry constraints worth keeping: `assistantSystemPrompt` holds replies to one to three lines of plain text with no markdown, and `codeSystemPrompt` keeps the code body in the tool's URL rather than in the channel. Read the shipped text before you replace it — `@config default plugins.LLM.assistantSystemPrompt` — because a replacement drops those constraints with it. Example: give a channel a specialised personality: ``` @config channel #python plugins.LLM.assistantSystemPrompt You are a Python expert. Answer with Python examples. Be concise. ``` ## Verse The verse is the per-channel world model. See [The verse](https://rdrake.github.io/vibebot-v8/operator/verse/index.md) for concepts and operation; this table is the key reference. | Setting | Scope | Default | Description | | -------------------------------- | ------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `verseEnabled` | channel | `False` | Main switch. When off, verse commands are unavailable and no events are recorded | | `verseTriggerRegex` | channel | `\bverse\b` | Case-insensitive pattern that marks a message as a canon reference, alongside entity mentions. A match grounds the reply in canon and, for an avatar holder, turns it into a prose tale. It does not enter roleplay mode; that needs `@rp`. Empty disables the keyword signal | | `verseRoleplayStickyTtlSeconds` | channel | `900` | Sliding inactivity timeout for sticky `@rp on` roleplay. Each in-character turn refreshes it. `0` never expires | | `verseChatRecordEnabled` | channel | `False` | Let an opted-in avatar's ordinary chat turn call `verse_record`. Off keeps canon growing only during roleplay | | `verseEventRetentionDays` | channel | `30` | Events older than this are eligible for compaction. `0` disables compaction for the channel | | `verseAutoEntityRetireDays` | channel | `14` | Days without a mention before auto-created non-player characters (NPCs) retire. `0` disables the sweep. Pinned entities are exempt | | `verseAutoEntityMaxNamesPerCall` | channel | `8` | Cap on the `actors` array in one `verse_record` call | | `verseRosterMaxChars` | channel | `4000` | Character cap on the canon-roster block in the verse system prompt | | `verseStyleExemplars` | channel | `[]` | Curated style exemplars injected into the verse prompt. Populate offline with the taste miner | | `verseReactionCaptureEnabled` | channel | `True` | Capture IRCv3 emoji reactions to verse lines as an offline approval signal | | `verseCompactionDailyAt` | global | `03:00` | Local `HH:MM` time for the daily compaction sweep. Empty falls back to `03:00`. A malformed value does not — it defers the next run by an hour, each time, and logs a warning | | `verseCompactionMinKeepEvents` | global | `20` | Verses with fewer total events are skipped by compaction | ### Storybook These caps cover both illustrated-page paths: the `verse_storybook` tool on verse turns, and the standalone `@story` command. `@story` needs no verse — it is gated like `@draw`, on `llm.draw` plus an authenticated account — so these limits still bind in a channel where `verseEnabled` is `False`. The `verse` prefix is historical; renaming the keys would orphan existing `bot.conf` values. | Setting | Scope | Default | Description | | ------------------------------- | ------- | ------- | --------------------------------------------------------------------------------------------------- | | `verseStorybookEnabled` | channel | `False` | Expose the `verse_storybook` tool on verse turns. It does **not** gate `@story` | | `verseStorybookMaxImages` | channel | `5` | Cap on illustrations per storybook | | `verseStorybookMaxPerTurn` | channel | `1` | Cap on storybooks per verse turn. Tool-only; `@story` does not read it | | `verseStorybookCooldownSeconds` | channel | `300` | Per-account cooldown between storybooks | | `verseStorybookDailyImageCap` | channel | `30` | Registered but **not yet enforced** — there is no per-account daily image count to check it against | | `verseStorybookMaxChars` | channel | `6000` | Character cap on the storybook body | | `verseStorybookImageTimeout` | channel | `45` | Seconds to wait for each illustration | Turning `verseStorybookEnabled` off does not stop image costs on its own, because `@story` still runs. What bounds them today is `verseStorybookMaxImages` per page and the per-account cooldown between pages, not the daily cap. ## Memory | Setting | Scope | Default | Description | | -------------------------- | ------- | ------- | ---------------------------------------------------------------------------------- | | `memoryEnabled` | channel | `True` | Enable automatic memory extraction | | `memoryMaxPerUser` | global | `50` | Cap on durable memories per user | | `memoryCleanupInterval` | global | `3` | Extraction passes between cleanup runs. `0` disables | | `memoryPromotionThreshold` | global | `2` | Mentions a candidate fact needs before promotion. `1` restores single-stage saving | | `memoryCandidateTTLDays` | global | `14` | Days an unreinforced candidate survives. `0` disables pruning | See [Memory promotion](https://rdrake.github.io/vibebot-v8/operator/memory-promotion/index.md) for how the two-stage pipeline works. ## Conversation context | Setting | Scope | Default | Description | | --------------------------- | ------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `contextEnabled` | channel | `True` | Enable conversation context | | `contextMaxMessages` | channel | `20` | Messages kept in per-user history | | `contextTimeoutMinutes` | channel | `5` | Clear context after this much inactivity | | `privacyOptInOnly` | channel | `True` | Send only lines addressed to the bot to the provider. Set `False` to track every channel message as context and let `irc_lookup` read the channel's server history (`kind=history`, up to 100 lines) for "what did I miss". Those lines go to whichever provider serves the channel's models. See [Running on local models](#running-on-local-models) | | `channelContextMaxMessages` | channel | `10` | Messages in the shared channel context | ## Rate limiting Rate limits follow the pattern `{command}{tier}RateLimitCount` and `{command}{tier}RateLimitWindow` for the commands `ask`, `code`, `draw`, `story`, and `animate`. All rate-limit settings are global. See [Rate limiting and security](https://rdrake.github.io/vibebot-v8/operator/rate-limiting-security/index.md) for tiers, defaults, and the observe-only switch `enforceRateLimits`. ## Bot-to-bot loops Two bots that answer each other never get bored, so replies to another bot are capped. | Setting | Scope | Default | Description | | --------------------- | ------- | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `botLoopReplyLimit` | channel | `3` | Consecutive replies to one bot before the bot goes quiet. `0` disables the guard | | `botLoopWindow` | channel | `300` | Seconds of quiet after which that bot's count resets | | `botLoopHostSuffixes` | global | `Bot.AfterNET.Org` | Host suffixes the network reserves for bots. Matched case-insensitively against the sender's host. A sender matched here, or one with the +B mode, cannot address the bot by replying to its lines. A full host works too, for a bot on a user host | A nick counts as a bot only when the network says so, by either of two signals. AfterNET advertises `BOT=B` and marks flagged users in the WHOX status field, which is where the bot reads it — six nicks carry it today, long-running service bots among them. Anyone unflagged is treated as a person and is never capped, and a human *addressing the bot* clears the counts in that channel, so a conversation someone has joined is never cut short. Only addressed lines reset it: what people say during a bot loop is commentary on the noise ("wtf", "jesus"), and treating that as a reset would license three more replies after every such line. The `+B` mode is opt-in, and the bot most likely to loop with yours may not set it — `grok` answers on `Hxz` from `grok.Bot.AfterNET.Org`. So the sender's host counts too: AfterNET only issues `*.Bot.AfterNET.Org` to registered bots, and across a full day of channel logs the only nicks on one were `grok`, `grook5`, and `vibebot` itself. The host is checked first because it is free and decides on the very first line, with no WHO round trip. Because `skipAutoWhoOnJoin` suppresses the WHO on join, the flag is fetched on demand: the first time an unknown nick addresses the bot, a WHO goes out for that nick and the answer is cached for an hour. That first line is always answered — the guard fails open, so the worst case is one extra reply, never a silenced user. A bot that never sets `+B` is invisible to this and will not be capped. ## Web and HTTP output | Setting | Scope | Default | Description | | ------------------------- | ------- | -------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `httpRoot` | global | empty | Filesystem path for output files. Empty keeps them under Limnoria's `data/web/llm/` and serves them from its built-in HTTP server. Setting it **unhooks that server** — an external server such as nginx must serve the path, and `httpUrlBase` must be set to match, or every generated link 404s | | `httpUrlBase` | global | empty | Public base URL for output files. Empty uses Limnoria's `publicUrl` plus `/llm`; if `publicUrl` is also unset the bot falls back to `http://localhost:/llm`, which nobody off the host can open | | `imageUploadUrl` | global | empty | External image host for generated images. Empty stores them locally | | `helpUrl` | global | `https://rdrake.github.io/vibebot-v8/` | URL shown in help output | | `longReplyTeaserMaxChars` | channel | `220` | Characters in the one-line teaser for long replies saved as HTML | | `multilineMaxLines` | channel | `3` | Replies that wrap to at most this many IRC lines go inline as one IRCv3 `draft/multiline` batch instead of a teaser and link. Needs `supybot.protocols.irc.experimentalExtensions`. `0` turns it off | | `fileCleanupAge` | global | `720` | Delete output files older than this many hours (30 days) | | `fileCleanupMax` | global | `1000` | Cap on files kept in the output directory | ### Offloading images to an external host Set `imageUploadUrl` to an uploader that takes a multipart `POST` on the `images[]` field and answers with JSON: ``` {"results": [{"success": true, "filePath": "/img/img_abc123.png"}]} ``` Generated images then live on that host instead of under `httpRoot`, and storybook pages embed the absolute URL. Pages themselves (answers, code, stories) still come from your own HTTP server, so this offloads bandwidth rather than replacing it. Every failure falls back to local storage: endpoint unreachable, upload rejected, image over 10 MB, or a reply naming a URL that is not an image on the configured host. An outage costs a slower draw, nothing more. [paste.boxlabs.uk/img/](https://paste.boxlabs.uk/img/) is an externally operated uploader, the host this contract follows, and it needs no key. Ask the host's owner before pointing the bot at somebody else's uploader. ## Limnoria tool bridge | Setting | Scope | Default | Description | | -------------------------- | ------- | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `bridgeEnabled` | channel | `False` | Expose loaded Limnoria plugin commands to the LLM as a tool | | `bridgeAllowedPlugins` | channel | empty | Plugin allowlist. Empty uses the curated read-safe default set | | `bridgeAllowMutating` | channel | `False` | Expose state-changing commands through the bridge | | `bridgeScheduledTaskLimit` | channel | `5` | Active scheduled LLM tasks per creator per channel. `0` disables scheduling | | `bridgeDebugInChannel` | channel | `False` | Append a bridge-call debug footer to replies | | `ircLookupEnabled` | channel | `True` | Advertise the `irc_lookup` tool (live `LIST`, `NAMES`, `WHOIS`, `WHO`, topic, network info, CTCP) to chat. Its `history` kind also needs `privacyOptInOnly` off. `@channels` and `@names` work regardless | | `memeModel` | channel | `anthropic/claude-haiku-5-5` | Model that picks a template and writes captions when `@meme` (or `make_meme`'s `brief`) names no template the catalog knows. It opens on a short list (the 40 most popular templates plus up to 20 sharing a word with the request, about 5K prompt tokens) and can call a `search_templates` tool up to twice to reach the rest of the catalog. No search: one call, about 5K tokens, 2 s. Each search is another round trip: about 12K tokens and 4 s with one, 19K and 6 s with two. Under $0.003 on Haiku either way. Empty falls back to `assistantModel`; grok-4-1-fast copies the template's example captions instead of writing new ones | | `memeEditModel` | global | `xai/grok-imagine-image` | Image model for the picker's `draw` (and `make_meme`'s). Templates with picture slots get one generated picture per slot (`/v1/images/generations`), masked into the blank; other templates send the captioned meme to `/v1/images/edits`. `xai/` only. About $0.02 per picture, refusals billed too | | `memeEnabled` | channel | `True` | `False` refuses `@meme` and hides the `make_meme` tool from chat | | `memeAliases` | global | `tyrone=yallgot biggums=yallgot chappelle=yallgot` | Extra template names, `name=id` or `name=https://image`. An id target adds the name as a keyword on that template; a URL target adds a two-caption template drawn on that image. Hyphens for spaces in the name | See [Tuning and monitoring](https://rdrake.github.io/vibebot-v8/operator/tuning-monitoring/index.md) for the bridge's security model and the scheduled-task tool. ## Status pages | Setting | Scope | Default | Description | | ---------------------- | ------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `statusPageUrls` | global | `Claude=https://status.claude.com GitHub=https://www.githubstatus.com OpenAI=https://status.openai.com` | Space-separated status pages to poll and announce — both Atlassian Statuspage and incident.io pages work — each written as `Name=url` or as a bare url, which takes its host as its name. Names are 1-32 chars of `[A-Za-z0-9._-]`, case-insensitively unique across this key and `statusQueryablePages`, and are what the model uses to ask about one service. Unusable entries are dropped with a warning and the rest survive; at most 5 are polled | | `statusQueryablePages` | global | empty | Same `Name=url` grammar as `statusPageUrls`, for pages the bot can be **asked** about but never polls or announces — the long tail like Cloudflare or AWS, where polling forever to answer an occasional question is the wrong trade. Fetched lazily on request and cached for 5 minutes, with a failure backoff. An entry naming the same page as a `statusPageUrls` entry is dropped, whatever its name — that page is already reachable, polled, as the fresher entry. At most 20 | | `statusAnnounce` | channel | `False` | Announce incidents from every configured `statusPageUrls` page in this channel as they open and again as they resolve. `statusQueryablePages` pages never announce, no matter how this is set | `check_service_status` is on the model's tool surface whenever either key holds at least one page. An empty `statusPageUrls` no longer removes the tool by itself — only polling and announcing stop; a non-empty `statusQueryablePages` keeps the tool available for on-request lookups. See [Tuning and monitoring](https://rdrake.github.io/vibebot-v8/operator/tuning-monitoring/#status-pages) for the polling rotation, staleness, and the RSS cutover order. ## Aurora | Setting | Scope | Default | Description | | ---------------------- | ------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `auroraEnabled` | channel | `True` | Give the chat model the `aurora` tool: NOAA aurora probability, solar wind, Kp, darkness, and cloud cover for a named place | | `auroraAnnounce` | channel | `False` | Announce G3+ storm watches and sustained storm conditions in this channel. Works without `auroraEnabled` | | `auroraAnnouncePlaces` | channel | empty | Optional space-separated places for this channel's announcements; `_` stands for a space, as in `Sault_Ste._Marie`. Adds cloud cover for them and holds a storm line until one is dark. A place that matches nothing is skipped (warned once in the log) | The tool reads NOAA SWPC (`services.swpc.noaa.gov`) and Open-Meteo (`geocoding-api.open-meteo.com`, `api.open-meteo.com`). No API keys are needed. The tool and the announcer share one cache: solar wind for 2 minutes, aurora probability for 10 minutes, storm alerts for 5 minutes, the Kp forecast for 3 hours, cloud cover for 1 hour per place (a 5-day forecast), and place lookups for 24 hours (misses for 1 hour). A failed fetch is not cached. See [Aurora](https://rdrake.github.io/vibebot-v8/user/aurora/index.md). Announcements go to every channel with `auroraAnnounce` on; set `auroraAnnouncePlaces` to add cloud cover and hold storm alerts until it is dark there. The bot checks every 5 minutes and posts two kinds of line, both fixed templates with no model rewrite: - **Storm watch**: when NOAA's newest geomagnetic storm watch puts a UTC day at G3 or higher. A re-issued watch at the same level is not announced again; a rise (G3 to G4) is. With places set, the line adds "Clouds night of ": the mean over each place's remaining dark hours in the local night that overlaps that UTC day most, or the next night if that one is already over. - **Storm conditions**: once per episode of Bz at or below -10 nT for most of 3 hours. With places set, the line waits until at least one of them is dark, and cloud figures are the mean over the next 3 hours; without places it goes out at once with no cloud figures. A place that matches nothing is skipped (warned once in the log); if a lookup service outage stops every place resolving, the storm line waits for the next check. The first check after a restart, and the first check after a channel turns announcements on, only records what is already in effect; nothing already running is announced. A channel whose send fails is retried on the next check. If no channel announces, nothing is fetched. ## Reminders and scheduled tasks | Setting | Scope | Default | Description | | --------------------- | ------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `pendingTasksEnabled` | channel | `False` | Advertise `set_reminder` and `schedule_llm_task` to the model, so natural-language scheduling works. Off by default: the schemas and their rules cost roughly 1,100 prompt tokens on every completion in the channel | The `@remind` command and the firing of reminders and tasks already created work regardless. This key only controls whether the model can *create* reminders and scheduled tasks from plain language. Listing and cancelling are not on the chat tool surface at any setting: users reach both their reminders and their scheduled tasks with `@remind list`, `del` and `clear`. ## Timeouts and retries When an API call times out, the bot retries in the background for a bounded window. | Setting | Scope | Default | Description | | ------------- | ------ | ------- | ---------------------------------------------------------- | | `timeout` | global | `30` | Timeout for LLM API calls, in seconds | | `drawTimeout` | global | `120` | Timeout for image generation. `0` uses the global timeout | | `askExpiry` | global | `60` | Seconds to keep retrying a timed-out `@ask`. `0` disables | | `codeExpiry` | global | `60` | Seconds to keep retrying a timed-out `@code`. `0` disables | | `drawExpiry` | global | `60` | Seconds to keep retrying a timed-out `@draw`. `0` disables | ## Draw behaviour | Setting | Scope | Default | Description | | -------------------------- | ------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `drawEnabled` | channel | `True` | `False` refuses `@draw` and hides the `generate_image` tool from chat. The tool is also hidden when `imageModel`'s provider has no key | | `drawAutoRewriteMax` | channel | `1` | Automatic prompt rewordings when a safety filter blocks a request. `0` disables. Each rewording costs a second billed image call on top of the refused one, and every recovery observed in prod landed on the first attempt, so raising this mostly buys repeat refusals of a prompt that was never going to pass | | `drawContextMaxAgeSeconds` | channel | `60` | Pass conversation context to draw requests only when the last activity is this recent. `0` always starts fresh | ## Animate behaviour `@animate` renders on a self-hosted video box (see [Video generation](#video-generation) for the credential). Generation is asynchronous: the command submits a job and returns, and the pending-task poller publishes the clip when it lands, so a bot restart mid-render does not lose the video. The box renders one clip at a time, so the queue is capped (`animateMaxPendingPerUser`, `animateMaxPending`) and `@renders` lets users and admins inspect or clear it. | Setting | Scope | Default | Description | | -------------------------- | ------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `animateEnabled` | channel | `True` | `False` refuses `@animate` and hides the `generate_video` tool from chat. Both are off everywhere until the video box is configured | | `animateSteps` | channel | `25` | Denoising steps, and the dominant cost knob — latency is roughly linear in it. Measured on the reference box at 1280x704: a four-second clip took 68s at 25 steps and 171s at 50; at 25 steps a seven-second clip took 135s | | `animateSize` | channel | `1280x704` | Output resolution. Must be a geometry the loaded model supports | | `animateDuration` | channel | `7` | Clip length in seconds. The whole clip is exclusive GPU time, so raising it slows every queued request behind it. Also watch the file size: a 7s clip measured 8.87 MB, leaving about 1 MB under the uploader's 10 MB ceiling, and a clip over it falls back to local storage instead of `imageUploadUrl` | | `animateAudio` | channel | `True` | Generate a soundtrack with the video. `False` requests silent video | | `animateModel` | channel | empty | Model to request. Empty sends no model field, letting a single-model box pick its own — usually right | | `animateFlowShift` | global | `12` | Flow-matching shift for the video sampler | | `animateAudioFlowShift` | global | `3` | Flow-matching shift for the audio track. Ignored when `animateAudio` is off | | `animateTimeout` | global | `60` | Timeout for individual HTTP calls to the video server. **Not** the generation budget — that is `animateExpiry` | | `animateExpiry` | global | `1800` | Seconds to keep polling a submitted job before reporting it expired. Generous on purpose: a queued job can wait a long time before it starts | | `animateMaxPendingPerUser` | global | `2` | How many clips one person may have waiting to render at once. A request past this is refused before anything is submitted. Owner and admin are exempt. `0` disables the per-user cap | | `animateMaxPending` | global | `6` | How many clips may be waiting to render across the whole bot. The box renders one at a time, so this bounds the longest wait anyone is promised. Applies to everyone. `0` disables | | `animateRenderSeconds` | global | `135` | Seconds one clip takes to render at the configured size, duration and steps. Only used to estimate the wait quoted when a clip is queued; measure it on the box after changing `animateDuration` or `animateSteps` | Clips are published to `imageUploadUrl` when one is configured — the reference host accepts MP4 on the same `images[]` field and files it as `vid_*.mp4` — and fall back to local storage otherwise, including when a clip exceeds the 10 MB upload ceiling. ## Other settings | Setting | Scope | Default | Description | | ----------------------- | ------ | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | | `maxPromptLength` | global | `10000` | Longest accepted user prompt, in characters | | `commandPrefixes` | global | `. @` | Space-separated prefixes sanitised in output to prevent IRC command injection. The default covers both the conventional `.` and VibeBot's own `@` | | `databasePath` | global | empty | Path to the SQLite database. Empty uses `data/LLM.db` | | `logLevel` | global | `WARNING` | Plugin log level: `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL` | | `metaMaxSteps` | global | `12` | Cap on tool-call round trips per assistant turn. Stops runaway tool loops | | `maxConcurrentLLMCalls` | global | `16` | Cap on simultaneous outbound LLM calls across all surfaces | | `skipAutoWhoOnJoin` | global | `True` | Skip Limnoria's automatic `WHO` on join when the `account-tag` and `extended-join` capabilities are active | | `enforceRateLimits` | global | `True` | Enforce rate limits. `False` tracks and logs without blocking | # Branch protection (optional) VibeBot's pipeline does not require GitHub branch protection. The upstream repository (`rdrake/vibebot-v8`) is single-maintainer; direct pushes to `main` are normal, and the CI, Docker build, and auto-deploy pipeline is the safety net. This page is for forks and team deployments that want a hard PR gate. ## Recommended ruleset In **Settings → Rules → Rulesets**, add a branch ruleset targeting `main`. **Settings → Branches** works too if you prefer a classic protection rule. Every setting below exists in both editors, except that rulesets express *Do not allow bypassing* as an empty bypass list. | Setting | Value | | ------------------------------------------------ | ---------------------------- | | Require a pull request before merging | Enabled | | Required approvals | 0 (solo) or 1 or more (team) | | Require status checks to pass before merging | Enabled | | Require branches to be up to date before merging | Enabled | | Do not allow bypassing the above settings | Enabled | ## Required status checks Add these checks after the first CI run on a PR; they do not appear in the picker before then: - `check (3.12)` - `check (3.13)` - `check (3.14)` - `lint` - `secrets` The `pull_request` trigger in `ci.yml` is unfiltered, unlike the `push` trigger's `paths-ignore`. Keep it that way: a required check that never reports leaves a docs-only pull request permanently unmergeable. The `docker` job in `ci.yml` is deliberately not required. That job is a PR-only smoke build that never pushes; the production image build lives in `docker.yml`, gated separately on CI success. ## Optional settings | Setting | When to use | | ------------------------------- | ------------------------------------------------------------------- | | Require conversation resolution | Team workflows where review comments must be addressed before merge | | Require signed commits | Hardened deployments; every contributor needs a signing key | | Require linear history | If you prefer rebase or squash merges over merge commits | ## Why the docker check is advisory For PRs, CI runs four jobs: `lint`, `check` (a 3.12/3.13/3.14 matrix), `secrets`, and `docker`. The `docker` job builds the image but never pushes. The production build and push is a separate workflow, `docker.yml`, triggered after CI succeeds on `main`. **Risk accepted:** a PR that breaks the Dockerfile can merge. CI passes, `docker.yml` then fails on `main`, and the previous image keeps serving production until a fix lands. **Why accept it:** Docker builds are slow and the Dockerfile changes rarely. Requiring the check would add latency to every merge to guard against a rare, quickly recoverable failure. ## Verifying the rule 1. Push a commit directly to `main` from a non-bypass account; it should be rejected. 1. Open a PR with a deliberately failing test; the merge button should be unavailable. 1. Fix the test; the merge button should re-enable once checks pass. # Memory promotion The bot's memory system has two stages. New facts arrive as candidates and become durable memories only after the extractor sees them mentioned again. This filters out one-off statements, model hallucinations, and noisy small talk that would otherwise pile up in the memory store. Operators control how many reinforcements a candidate needs before promotion, how long unreinforced candidates survive, and the per-user ceiling on durable memories. ## Why two stages Single-stage extraction promotes every extracted fact directly to memory. That makes the bot remember "I am tired today" and "my actual job is mechanical engineering" with equal weight. Two-stage extraction holds the first kind in a candidate table where it expires unreferenced, while the second kind gets reinforced on the next mention and promoted. The threshold is the main knob between a noisy memory store and one that reflects what users keep saying about themselves. Set it low for trusted users on private channels, higher for large public channels where unmoderated extraction builds up noise. ## Configuration | Setting | Type | Default | Scope | | -------------------------- | -------------------- | ------- | ------- | | `memoryEnabled` | boolean | `True` | channel | | `memoryPromotionThreshold` | positive integer | `2` | global | | `memoryCandidateTTLDays` | non-negative integer | `14` | global | | `memoryMaxPerUser` | positive integer | `50` | global | | `memoryCleanupInterval` | non-negative integer | `3` | global | ### Threshold `memoryPromotionThreshold` sets how many mentions a candidate needs before promotion. The extractor inserts new facts at one mention. A second mention reinforces the row; with the default threshold of `2`, that promotes the candidate to a durable memory. ``` @config plugins.LLM.memoryPromotionThreshold 3 ``` A threshold of `1` collapses the system to single-stage and saves every extracted fact immediately. Use this when migrating from older deployments or when the candidate stage feels too cautious for a small group. ### Candidate TTL `memoryCandidateTTLDays` — TTL is time to live — controls how long an unreinforced candidate survives. Every extraction pass for a user deletes that user's candidates whose `last_seen` is older than the cutoff, so someone who goes quiet keeps their candidates until they speak again. ``` @config plugins.LLM.memoryCandidateTTLDays 30 ``` A TTL of `0` disables pruning. Nothing else prunes the candidate table, so it grows for the life of the database — reasonable only on a small, quiet bot where you want a fact mentioned twice a year to still promote. ### Per-user ceiling `memoryMaxPerUser` caps durable memories per user. The cap is a hard stop, not a rolling window: once a user reaches it, promotion is skipped and the memories already stored stay put. Nothing is evicted to make room. ``` @config plugins.LLM.memoryMaxPerUser 100 ``` The cap applies only to the durable side. Candidates do not count against it. ### Cleanup cadence `memoryCleanupInterval` counts promotions, not extractions. Every pass that promotes at least one candidate to a durable memory bumps a per-user counter; when the counter reaches N it resets and a cleanup pass runs. Cleanup asks the model to drop redundant memories and merge overlapping ones — it needs at least two memories to do anything, and it neither applies the candidate TTL nor trims a user down to `memoryMaxPerUser`. ``` @config plugins.LLM.memoryCleanupInterval 10 ``` Setting it to `0` disables periodic cleanup; durable memories then accumulate unmerged until someone runs `@memories cleanup`. The candidate table is unaffected either way — that is `memoryCandidateTTLDays`. ## What users see Users interact only with durable memories. The candidate stage is invisible to them. - `@memories` lists their durable memories. - `@memories del ` deletes one. - `@memories edit ` rewrites one. - `@memories clear` deletes every durable memory for the calling user. - `@memories cleanup` triggers a one-shot cleanup pass. Candidates have no user-facing surface. Operators with database access can inspect the `memory_candidates` table directly if a fact looks stuck. ## What writes a memory The bot writes memories after these events: - Successful `@ask` and `@code` responses, on the user who asked. Nick-addressed chat and verse turns share the `@ask` path and count as `@ask`; `@draw` and `@story` never extract. - Explicit `save_memory` tool calls, when a user asks the bot to remember something. The candidate path applies only to automatic extraction. Anything the assistant chooses to remember through its own tool skips the candidate stage, so the model can record explicit user preferences in one shot. `save_memory` writes straight to the durable table and does not check `memoryMaxPerUser`, so an explicit "remember this" can carry a user past the ceiling. ## Interaction with other settings | Setting | Interaction | | --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `memoryModel` | Memory extraction calls this model, or `assistantModel` when empty, so that model's provider needs its environment variable set (see [Configuration](https://rdrake.github.io/vibebot-v8/operator/configuration/#api-keys)). Without it, extraction fails silently and no candidates accumulate | | `memoryEnabled` | Channel-scoped. `False` blocks both candidate insertion and durable promotion for that channel's traffic | ## Operational notes - Candidate rows carry `first_seen` and `last_seen` timestamps. A user who repeats a fact keeps moving `last_seen` forward, so the TTL fires only on truly stale candidates. - Reinforcement bumps `mentions` and `last_seen` only; the candidate text is never rewritten. A fact that reappears in different wording still promotes, but it promotes under the wording first recorded. - Lowering `memoryPromotionThreshold` does not retroactively promote old candidates. Promotion happens on the next reinforcement event. - A user at `memoryMaxPerUser` stops being extracted altogether — no model call, no new candidates, no reinforcement, no TTL pruning, and no cleanup pass, because cleanup only fires after a promotion. Recovery is `@memories del `, `@memories cleanup`, or a higher cap. - Raising `memoryMaxPerUser` takes effect on that user's next extraction, since the value is read once per pass. It cannot recover facts skipped while the user sat at the cap; those have to be said again, and any candidate that aged past the TTL meanwhile is dropped on that next pass. # Rate limiting and security ## Rate limiting Rate limits are checked per user and per command. The `enforceRateLimits` setting (global) controls whether limits block requests or only observe them: | Value | Behaviour | | ---------------- | ------------------------------------------------------------------ | | `True` (default) | Requests over the limit are blocked | | `False` | Limits are tracked and logged but not enforced (observe-only mode) | ### Tiers Users fall into one of four tiers, checked from most to least privileged: | Tier | How to qualify | Behaviour | | -------------- | -------------------------------------- | --------------------------- | | Owner or admin | Limnoria `owner` or `admin` capability | Always exempt | | Trusted | Has the `trusted` capability | Uses the `Trusted` settings | | Registered | Authenticated with the network | Uses the base settings | | Unregistered | Not identified | Uses the `Unreg` settings | To grant the `trusted` capability, see the [Limnoria capabilities documentation](https://docs.limnoria.net/use/capabilities.html). ### Setting pattern The rate-limited commands are `ask`, `code`, `draw`, `story`, and `animate`. Each has three setting pairs, one per non-exempt tier: - `{cmd}RateLimitCount` and `{cmd}RateLimitWindow`: registered users. - `{cmd}TrustedRateLimitCount` and `{cmd}TrustedRateLimitWindow`: trusted users. - `{cmd}UnregRateLimitCount` and `{cmd}UnregRateLimitWindow`: unregistered users. A count of `0` blocks nothing; it disables the limit for that command and tier. All rate-limit settings are global. Buckets are keyed by account name for identified users and by nick for everyone else, so an unidentified user can reset an `ask` or `code` bucket by changing nick. `draw` and `story` are unaffected: they refuse to run without an account at all. ### Default limits | Command | Registered | Trusted | Unregistered | | --------- | ----------- | ----------- | ------------ | | `ask` | 15 per 60 s | 15 per 60 s | 15 per 60 s | | `code` | 10 per 60 s | Unlimited | 2 per 60 s | | `draw` | 2 per 300 s | 5 per 60 s | 1 per 3600 s | | `meme` | 6 per 60 s | 12 per 60 s | 2 per 600 s | | `story` | 2 per 300 s | 5 per 60 s | 1 per 3600 s | | `animate` | 2 per 900 s | 4 per 300 s | 1 per 7200 s | `draw`, `meme`, `story`, and `animate` also require an authenticated account regardless of tier, so media generation is always attributable and the unregistered tier is unreachable in practice. It stays a small positive number rather than `0`, because `0` means "no limit" and would hand accountless users unlimited access to the most expensive commands. `animate` is the strictest of the three. A clip is over a minute of exclusive GPU time on a single shared box, and the video server renders one job at a time, so each accepted request delays every later one; the caps are what stops a busy channel booking an hour of queue in a minute. Two queue caps back them up: `animateMaxPendingPerUser` (default 2) and `animateMaxPending` (default 6) refuse a request outright while that many clips are already waiting, whatever the rate-limit window says. Owner and admin skip the per-user cap only. Both live under [Animate behaviour](https://rdrake.github.io/vibebot-v8/operator/configuration/#animate-behaviour). `@rp` has no keys of its own; it draws on the `ask` bucket. ## Security ### Capability-based access control The AI and verse command surfaces require a Limnoria capability: | Capability | Gates | | ---------------- | ------------------------------------------------------------------------------- | | `llm.ask` | `@ask`, `@forget`, `@remind`, and general assistant tool access | | `llm.code` | `@code` and the code-generation tool | | `llm.draw` | `@draw`, `@meme`, `@story`, the image-generation tool, and the `make_meme` tool | | `llm.animate` | `@animate`, `@video`, `@renders`, and the video-generation tool | | `llm.verse` | Verse participation: `@verseopt`, `@rp`, `@verse`, `@look`, `@who` | | `llm.verse.edit` | Canon editing: `@versedit`, `@canon`, the `verse_edit` tool | | `llm.verse.gm` | Game-moderator (GM) operations: `@versedump`, `@versepurge`, `@versecompact` | `@memories`, `@instruct`, `@avatar`, and `@usage` carry no plugin capability; anyone who can talk to the bot can use them on their own data. Their privileged forms — `@memories `, `@memories cleanup `, and the global `@usage` overview by PM — check Limnoria's stock `owner` or `admin` capability instead, as does `@remind admin`. The default is allow, not deny None of the `llm.*` capabilities is registered default-deny, and this deployment ships `supybot.capabilities.default` at its stock value of `True`. Limnoria grants any capability a user neither holds nor anti-holds, so on a fresh install every user — identified or not — passes all six checks above. That includes `llm.verse.gm`, which gates `@versepurge`, the command that deletes a channel's verse database file. Lock the destructive surfaces down before you enable verse. Add the anti-capability globally, then hand it back per account: ``` @config supybot.capabilities [config supybot.capabilities] -llm.verse.gm -llm.verse.edit @admin capability add llm.verse.edit ``` The nested `[config supybot.capabilities]` reads the current list back, so the stock entries survive the write. A capability held explicitly on an account beats the global anti-capability, and the `owner` capability satisfies every check regardless of either. The same pattern restricts `llm.ask`, `llm.code`, and `llm.draw`. See the [Limnoria capabilities documentation](https://docs.limnoria.net/use/capabilities.html). ### URL validation When users include image URLs in `@ask` prompts for vision, the bot validates them: - **Scheme check.** Only `http://` and `https://` URLs are accepted. Schemes such as `javascript:`, `data:`, and `file:` are blocked. - **Path traversal.** URLs containing `..` in the path are rejected. - **Request-forgery protection.** URLs resolving to private, loopback, link-local, or reserved IP addresses are blocked, which prevents server-side request forgery (SSRF) against the bot's network. The check fails closed: if DNS resolution fails, the URL is rejected. - **Extension check.** Only recognised image extensions are accepted: `.jpg`, `.jpeg`, `.png`, `.gif`, `.webp`, `.bmp`. ### Input and output sanitisation Model output is sanitised before it reaches IRC, and incoming text before Limnoria parses it: - **IRC command injection.** Lines starting with a configured command prefix get a leading space, so neither this bot nor another can be driven by model output. `commandPrefixes` defaults to `. @` — the conventional `.` and VibeBot's own `@`; add any others the network uses. - **Wire-structural bytes.** NUL and the CTCP delimiter `\x01` are deleted from every outbound model line, the `ACTION` path included, so model text cannot forge a CTCP. Leaked model sentinels such as `<|eos|>` are stripped in the same pass. - **API key scrubbing.** Error messages are scrubbed of API keys before display. - **HTML sanitisation.** Every page the bot publishes — code pastes, long answers, storybooks, verse dumps — passes through `nh3`, an allowlist-based sanitiser restricted to `http`, `https`, and `mailto` URLs. A second pass drops any `` whose source is neither a bare relative filename nor under `httpUrlBase` or `imageUploadUrl`, so model output cannot embed a tracking pixel or fire a request when the page is opened. - **Inbound control characters.** Incoming PRIVMSG text has its C0 control characters removed, ESC among them, and unbalanced `[` or `]` replaced with full-width equivalents, before Limnoria's tokenizer sees it. ### Prompt injection defences - **Structured system prompts.** Prompts separate instructions from context, with a preamble telling the model to ignore instructions embedded in user-provided content. - **Channel topics as untrusted data.** Topics arrive as user messages, not system messages, because any channel member can set them. Line separators inside a topic are collapsed to spaces so it cannot open a new instruction line, and the text is trimmed to 300 characters. The anti-injection preamble calls the topic out explicitly as an attack surface. - **Input length limit.** `maxPromptLength` (default 10,000 characters) is enforced on image prompts and on the inner code-generation tool call, not on the chat path — `@ask`, `@code`, and nick-addressed text reach the model unmeasured. - **Tool-loop cap.** `metaMaxSteps` (default 12) bounds tool-call round trips per turn, so a prompt cannot drive an unbounded tool loop. ### Verse write safety The model's in-band canon tool, `verse_edit`, is restricted to five operations: `add_entity`, `add_event`, `add_relation`, `set_attribute`, and `update_entity`. It can never delete an entity, retire one, or edit a recorded event. It can, however, rename an entity, rewrite its summary, and overwrite an attribute value, so a hijacked turn can still corrupt canon in place. `set_attribute` refuses reserved lifecycle keys, and both it and `add_relation` refuse retired entities. Destructive operations live behind operator commands with the `llm.verse.edit` and `llm.verse.gm` capabilities, and `@versepurge` adds a two-step token confirmation. See [The verse](https://rdrake.github.io/vibebot-v8/operator/verse/index.md). # Tuning and monitoring ## Choosing models The model surface splits by workload so cost and quality can differ per task. Every model setting below is channel-overridable except `verseCompactionModel`, which is global. | Setting | Used for | | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | `assistantModel` | Chat, the planner loop, bridge tool selection, reminder parsing, memory cleanup, image-prompt rewrites, scheduled tasks | | `memoryModel` | Memory extraction after every reply. Falls back to `assistantModel` when empty | | `codeModel` | The code-generation one-shot behind `@code` and the `generate_code` tool. The `@code` planner loop that calls it runs on `assistantModel` | | `imageModel` | Image generation | | `searchModel` | Web search and URL fetch tools. Falls back to `assistantModel` when empty. GitHub links are read through GitHub's API instead | | `subjectResearchModel` | The subject-research pre-stage in front of the `@draw` and `@animate` planners. Falls back to `searchModel`, then `assistantModel` | | `verseModel` | Verse-mode narration. Falls back to `assistantModel` when empty | | `verseCompactionModel` | The daily verse compaction digest | Guidance from operating this surface: - `assistantModel` needs vision support if users paste image URLs into chat. - Reasoning models make terse, flat narrators. If the assistant model is a reasoning model, point `verseModel` at a non-reasoning model for verse prose. - Compaction is a cheap summarization job; leave `verseCompactionModel` on a flash-lite class model. - Memory extraction runs after every reply and its output is never shown. Put `memoryModel` on a flash-lite class model. On Gemini's free tier each model has its own daily quota, so a different model also keeps extraction from using up the chat model's. - Subject research is a lookup, not a writing job, so a flash-class model is enough. What matters is whether it will describe real people at all: a model that declines contributes nothing and the picture loses its likeness. If dossiers come back empty on obvious subjects, point `subjectResearchModel` at a more permissive model than `searchModel`. Keys are not part of this per-command surface: one environment variable per *provider* covers every model on that provider, regardless of which setting names the model. Point a channel at a different model and, if that model's provider is already configured, no key change is needed. See [Configuration → API keys](https://rdrake.github.io/vibebot-v8/operator/configuration/#api-keys) for the variable names. ``` @config channel #yourchan plugins.LLM.assistantModel anthropic/claude-haiku-5-5 ``` ## Context tuning Conversation context lets the bot follow recent exchanges in a channel. | Setting | Default | Scope | Description | | --------------------------- | ------- | ------- | ------------------------------------------------------------------------------------------------------ | | `contextEnabled` | `True` | channel | Enable conversation context | | `contextMaxMessages` | `20` | channel | Messages kept in per-user history | | `contextTimeoutMinutes` | `5` | channel | Clear context after this much inactivity | | `privacyOptInOnly` | `True` | channel | Send only bot-addressed lines to the provider; `False` tracks every message and enables history lookup | | `channelContextMaxMessages` | `10` | channel | Messages in the shared channel context | The shared channel context is what lets the bot connect a question from one user with a follow-up from another. **Privacy note.** With `privacyOptInOnly` turned off, every message in the channel is sent to third-party LLM providers as context, and the bot can pull up to 100 lines of server history on request. The setting is on by default. Turn it off only where users know and consent. ## Limnoria tool bridge The bridge exposes loaded Limnoria plugin commands to the LLM as two tools: `run_limnoria_command` dispatches one command, and `search_bridge_commands` substring-searches the exposed set by plugin name, command name, argument syntax and description. When the model needs something a stock plugin already does, a ping, the time, a seen lookup, it defers to that plugin instead of guessing. The bridge is opt-in per channel. ### Enabling ``` @config channel #yourchan plugins.LLM.bridgeEnabled True ``` That activates the curated default plugin set. To take full control, set an explicit list; a non-empty value replaces the default: ``` @config channel #yourchan plugins.LLM.bridgeAllowedPlugins Misc Time ``` Names must match Limnoria's exact form (`Misc`, `Time`, `Math`); mismatched names are ignored silently. The bridge can only expose plugins that are already loaded, so `@load` them first. ### Curated default set With `bridgeAllowedPlugins` empty, the bridge exposes: `Misc Time Math Utilities Seen Web Later Note Karma QuoteGrabs RSS DDG Network` Each is pure-read or has its write commands gated by `bridgeAllowMutating`. The list lives in `DEFAULT_ALLOWED_PLUGINS` (`plugins/llm/src/llm/limnoria_bridge.py`). ### Security model Three independent layers limit what the bridge reaches: 1. **Hard-denied plugins**, never bridged regardless of config: `LLM` (recursion), `Owner`, `Admin`, `Config`, `Channel`, `User` (bot, channel, and account management). 1. **Hard-denied commands**, blocked even inside allowlisted plugins: every URL-fetching `Web` read — `fetch`, `location`, `headers`, `doctype`, `size`, `title` — because each hands the bot's network position to a caller-supplied URL; `Utilities.apply` and `Utilities.let` (re-dispatch bypass); `Misc.more` and `Misc.clearmores` (interactive scrollback only). Those six `Web` reads are not writes, so `bridgeAllowMutating` does not gate them, and `Web` ships in the curated default set, so the deny is unconditional. 1. **Limnoria capabilities**, applied per command at dispatch time. Commands guarded by default-deny anti-capabilities are refused unless explicitly granted. ### Write-command gate Commands that change persistent state (offline notes, RSS registrations, karma changes, queued PMs) stay hidden from the LLM by default, even when their plugin is allowlisted. Any hallucinated dispatch of a hidden write returns a denial. Open the gate per channel: ``` @config channel #yourchan plugins.LLM.bridgeAllowMutating True ``` The classification lives in `MUTATING_COMMANDS` (`plugins/llm/src/llm/limnoria_bridge.py`). When the gate is closed and an allowlisted plugin has hidden writes, the tool description tells the model the gate exists. ### Scheduled LLM tasks The `schedule_llm_task` tool schedules a future assistant turn. At fire time the bot replays the creator's IRC identity and runs the prompt on the reminder-action profile, which carries every native tool: search, fetch, draw, code, memory, usage, and pending-task management. It does not carry the bridge — bridged Limnoria commands ride `@ask`, mentions and PMs only. It differs from `set_reminder`: - `set_reminder` delivers fixed text at fire time. "Remind me to switch the laundry at 6." - `schedule_llm_task` runs an LLM turn at fire time. "Every Monday at 9, check my open PRs and tell me which are stale." Use it when the task needs tools when it fires. Operational properties: - `bridgeScheduledTaskLimit` (default 5, channel) caps each creator's active schedules; `0` disables scheduling. Each fire still counts against the creator's `ask` rate-limit bucket. - Schedules bind to the creator's account at create time; the tool refuses unauthenticated callers. - A schedule auto-cancels the first time it fires after its creator loses `llm.ask`: the row is deleted, the reply target gets a one-line notice, and an `INFO` line records `scheduled_llm_task fire: creator lost llm.ask; auto-cancelling`. At the default `WARNING` that notice is the only trace. - A fired task cannot schedule another task (depth cap of 1), which blocks recursion. - Users cannot list or cancel schedules by talking to the bot — the unified pending-task tools are off the chat surface — but `@remind list`, `del` and `clear` reach their own tasks as well as their reminders. Owners use `@remind admin list ` and `@remind admin del ` to reach anyone's. Limnoria's own `@scheduler list` does not show these tasks; they live in the plugin's database. ### Known limitation Plugins that nest sub-leaves under a `Commands` group (notably `RSS`'s `announce add` and friends) are not surfaced by the bridge. Manage those with the native IRC commands. ## HTTP output `@code`, `@draw`, and `@story` save output as files served over HTTP. - **Built-in server (default).** With `httpRoot` empty, files land in Limnoria's web directory (`data/web/llm/`) and its built-in HTTP server serves them. See the [Limnoria HTTP server documentation](https://docs.limnoria.net/use/httpserver.html) for port and public URL setup. - **External server.** Set `httpRoot` to a filesystem path (for example `/var/www/llm`) and `httpUrlBase` to the matching public URL. You serve the directory with nginx, caddy, or similar. | Setting | Default | Description | | ------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------- | | `httpRoot` | empty | Filesystem path for output files. Empty uses the built-in server | | `httpUrlBase` | empty | Public base URL. Empty uses Limnoria's public URL plus `/llm/` | | `longReplyTeaserMaxChars` | `220` | Characters in the one-line teaser when a long reply is saved as HTML and linked | | `multilineMaxLines` | `3` | Replies up to this many IRC lines go inline as one `draft/multiline` batch; longer ones get the teaser and link. `0` turns it off | | `fileCleanupAge` | `720` | Delete output files older than this many hours (30 days) | | `fileCleanupMax` | `1000` | Cap on files kept in the output directory | ## Concurrency Every outbound LLM call, foreground or background, takes a slot from one bounded executor, so a single knob caps how much load reaches a provider no matter which thread started the call. Keeping a slow call off the IRC driver is a separate mechanism: nick-addressed messages are dispatched on their own thread so the driver can flush typing notifications while the call is in flight. | Setting | Default | Scope | Description | | ----------------------- | ------- | ------ | -------------------------------------- | | `maxConcurrentLLMCalls` | `16` | global | Cap on simultaneous outbound LLM calls | Lower it on small hosts or when a provider rate-limits aggressively. The global `@usage` report (admin, by PM) appends an `executor: running/queued/max` field. `queued` counts background submissions only: a foreground command waiting for a slot blocks inside `permit()` before either counter moves, so `16/0/16` does not mean nothing is waiting. `running` pinned at `max` is the signal, not `queued`. ## Status pages The bot polls up to 5 status pages and can announce incidents from them on its own as they open and resolve. It can also be **asked** about up to 20 further pages it never polls or announces — the long tail like Cloudflare or AWS, where polling forever just to answer an occasional question is the wrong trade. Both Atlassian Statuspage and incident.io pages work — incident.io is read through its Atlassian-compatible endpoints, no separate configuration needed. See [Service status](https://rdrake.github.io/vibebot-v8/user/service-status/index.md) for the user-facing behaviour. | Setting | Default | Scope | Description | | ---------------------- | ------------------------------------------------------------------------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `statusPageUrls` | `Claude=https://status.claude.com GitHub=https://www.githubstatus.com OpenAI=https://status.openai.com` | global | Space-separated status pages to poll and announce, each `Name=url` or a bare `scheme://host` that takes its host as its name. Entries that don't parse are dropped with a warning; duplicate names and same-page duplicates (trailing slash, case, default port) collapse to one; at most 5 are polled. The bot polls each `{url}/api/v2/summary.json` to answer status questions and to announce new incidents. Empty stops polling and announcing, but `check_service_status` stays available if `statusQueryablePages` holds anything. | | `statusQueryablePages` | empty | global | Same `Name=url` grammar as `statusPageUrls`, for pages the bot only answers about when asked — never polled, never announced, no incident lifecycle. Fetched on-demand and cached for 5 minutes, with a failure backoff on an unreachable page. At most 20. Names must be unique, case-insensitively, across both keys; a name already used by `statusPageUrls` is dropped with a warning. Same rule for the page itself: an entry pointing at a URL already covered by `statusPageUrls` is dropped, regardless of its name, since that page is already reachable — and fresher — as the polled entry. | | `statusAnnounce` | `False` | channel | Announce incidents from every configured `statusPageUrls` page in this channel as they open and again as they resolve — all-or-nothing per channel, not selectable per source. `statusQueryablePages` pages never announce. Both draw on one budget of six LLM rewrites an hour; over budget the deterministic template still sends. | Sources are polled in rotation inside a single 45-second-per-pass budget on a 2-minute schedule, with a cursor so one slow or failing source can't starve the others — with several sources configured, a given one might wait more than one 2-minute cycle for its turn. A reading older than 10 minutes is reported to the model as stale. `statusQueryablePages` pages take no part in that rotation and carry no staleness state of their own — each is fetched fresh, subject only to its own 5-minute cache, at the moment a question needs it. Turn off the RSS feed first If the channel already announces the same status page through the RSS plugin, remove that first, or every incident is reported twice: ``` @rss announce remove #yourchan @config channel #yourchan plugins.LLM.statusAnnounce True ``` ## Monitoring ### Logs ``` journalctl --user -u vibebot # service logs journalctl --user -u vibebot -f # follow in real time tail -f ~/.config/vibebot/logs/messages.log # bot message log ``` At startup, an `INFO`-level line reports secret redaction coverage, naming which environment variable *names* it covers (never their values): ``` secret redaction: 4 handler(s) filtered, 2 variable(s) covered: GEMINI_API_KEY, XAI_API_KEY ``` That line is the only positive confirmation that redaction is actually installed and live — worth checking after any change that touches logging handlers, given that credentials also sit directly in the container's environment (see [Operations → Credentials in the environment](https://rdrake.github.io/vibebot-v8/operator/operations/#credentials-in-the-environment)). Use `%i`, never `%d`, in log format strings Supybot routes log arguments through `supybot.utils.str.format`, which is not printf. It supports `%s`, `%r`, `%i`, `%f` and `%.3f` — but has **no `%d`**. An unsupported `%d` is left in the output literally and the remaining arguments shift left into whichever slots *are* supported, silently producing a wrong line with no exception and no warning. Because `supybot.log` calls `logging.setLoggerClass` at import, this affects every logger in the process, not just Supybot's own. `test_log_format_specifiers.py` fails the suite on any `%d` in a logging call. ### Per-call log lines Three structured lines record what a call cost, what the history guards removed, and how long a background call waited for a slot. A line emitted from a turn is prefixed with that turn's eight-character request id (`[3f9a1c04] completion_timing op=…`), so one turn can be pulled out of an interleaved log with a single grep. The daily verse compaction runs off a timer with no request id and its lines carry no prefix. | Line | Level | Emitted | Fields | | --------------------------------------------- | --------- | ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `completion_timing` | `WARNING` | Once per model call | `op` (call-site label: `assistant_step_N`, `run_completion_`, `grounded_` — `xai_responses_` instead when the resolved model is an xAI one — `image_generation`, `prompt_rewrite`, `reminder_parse`, `pending_retry`, `status_announce`, `compaction:`), `model`, `msgs`, `msg_chars`, `tools`, `prefix_hash`, `gap_s`, `elapsed_ms`, `prompt_tokens`, `cached_tokens`, `completion_tokens`, `tool_calls`. A failed call ends `result=error error_type=` in place of the token fields | | `history_strip` | `WARNING` | Once per turn that dropped poisoned history; silent when nothing was stripped | `model`, `channel`, `route`, `assistant_turns`, `removed`, then one field per guard that fired: `safety_refusal`, `image_failure`, `tool_complaint`, `degraded`, `repeat`, `verse_denial` | | `prompt_rewrite_fidelity` | `WARNING` | Only when a safety rewording came back padded | `orig_chars`, `new_chars`. Needs both 20% growth and 40 absolute characters, so an ordinary rewording that runs a little long stays silent | | `llm_executor submit` and `llm_executor done` | `INFO` | Once each per background submission | `submit`: `label`, `running`, `queued`, `max`. `done`: `label`, `elapsed_ms`, `queued_ms` | `completion_timing` and `history_strip` sit at `WARNING`, so they are present at the default log level; the `llm_executor` pair needs `logLevel` at `INFO`. Three `completion_timing` variants carry a reduced field set: `op=image_generation` logs `prompt_chars` and `elapsed_ms` only, since an image response carries no message or token shape; `op=xai_responses_` omits `prefix_hash` and `gap_s`; `op=compaction:` logs `elapsed_ms` and the two token counts plus a `cost` field found on no other line. A refused draw that recovered is three `completion_timing` lines under one request id, in order: `op=image_generation` ending `result=error`, then `op=prompt_rewrite`, then a second `op=image_generation` with no `result=error`. Counting those is how to tell whether the rewrite loop is working. Do not grep for `Image generation blocked, attempting auto-rewrite` or `Rewrite attempt N`: both are `INFO`, so they are absent at the default log level, and their absence reads as a loop that never fired. On `completion_timing`, read `gap_s` next to `prefix_hash` when `cached_tokens` is 0: `gap_s` is the seconds since the last call on the same model and cache lane (`-1` on the first call) and is the dominant predictor of a cache hit, so a large gap means a cold cache while a changed `prefix_hash` means the cacheable head of the request moved. ### Log level | Level | Use case | | ------------------- | --------------------------------------------------- | | `WARNING` (default) | Normal operation: warnings and errors only | | `INFO` | Request flow and timing | | `DEBUG` | Verbose tracing, including provider response detail | ``` @config plugins.LLM.logLevel DEBUG ``` ### Usage statistics `@usage` shows API usage for yourself and the channel; the global overview by PM is admin-only. One caveat: verse turns are recorded under the `ask` label, so verse traffic is displayed as `ask` rows in the report. A `@draw` or `@animate` request with subject research enabled writes a `dossier` row alongside the command's own row. That is the research call in front of the planner, and it is booked separately because it usually runs on a different model — see [Subject research](https://rdrake.github.io/vibebot-v8/operator/configuration/#subject-research). A request that named nobody real still writes the row: asking and being told there is nothing to look up costs the same tokens as asking and getting an answer. One draw can write more than one `draw:image` row. Each row is one call to the image provider, so a prompt the filter refused and a rewording of it that succeeded are two rows: the refusal carries `status='content_blocked'` and the exact prompt the filter rejected, the delivered image carries `status='success'`. Counting rows therefore counts calls, not pictures. The bill is split rather than duplicated, so `SUM(cost)` is still the true total. The dollar figures come from LiteLLM's built-in price table, not from any provider billing API. A model LiteLLM has no price for records `$0.0000` and logs `completion_cost failed for model=…`; image models are priced from the two-entry `IMAGE_COST_PER_IMAGE` table in `plugins/llm/src/llm/service.py`. Read `@usage` as a relative signal and reconcile against the provider invoice. ### Storage Reminders, usage statistics, and memories live in one SQLite database: `data/LLM.db` by default, or the path in `databasePath`. Each verse-enabled channel adds its own database under `data/verse/`. ### Common issues **API key not working.** - The error names the missing variable: `no API key configured for provider 'xai' (set XAI_API_KEY)`. Grep logs for `no API key configured` rather than the old "API key not configured" wording, which no longer appears anywhere. - Confirm the key is set in the container, without printing it, by comparing a hash rather than a prefix: ``` docker exec vibebot python3 -c "import hashlib,os;k=os.environ.get('XAI_API_KEY','');print(len(k), hashlib.sha256(k.encode()).hexdigest()[:12])" ``` A length of `0` means the variable is unset or empty. Compare the hash against a known-good value computed the same way, rather than eyeballing the key itself. - Check the key matches the model's provider; a Gemini key does not work with an `anthropic/` model. - Set `logLevel` to `DEBUG` and retry to see the full provider error. **A reply arrived with a 🔁 in front of it.** - The image the user asked for was refused by a content filter, the bot reworded the prompt, and the second attempt landed. The icon says the wording behind the picture moved, so a picture that reads slightly off is explained rather than mysterious. - It is the same mechanism as the 🌐 grounding icon and rides the same path: the draw callback flags the rework, the tool executor latches it for the turn, and the reply prefixes one icon per signal raised. Both can appear together. - The matching usage rows are two, not one: the refusal with `status='content_blocked'` and the delivered image with `status='success'`. **The bot says a request was refused.** - A refusal is not a fault. The provider turned the content down, and the reply says so and asks the user to reword it or pick a different subject. Nothing needs restarting. - The provider's own category for the refusal is deliberately kept out of the channel, because these filters produce false positives and the category reads as an accusation of whoever happened to be talking. It goes to the log instead: grep `refused on content grounds`, which carries the full provider error. - A refusal is not the same event as an outage. Every other failure class now names itself in the reply — a timeout says it timed out, a bad key names the variable — so a generic `Error: Unable to complete` is the one that means look at the logs. - Refusals can arrive on the chat call itself, not just on a draw, in which case no image was ever attempted. The log line is `assistant_completion failed`, and it is one turn, not a stuck bot: successful turns in the same channel either side of it are normal, because the refused message is never written to history. **Bot does not remember previous messages.** - Confirm context is on: `@config channel #yourchannel plugins.LLM.contextEnabled`. - Check `contextTimeoutMinutes`; a short timeout expires context between messages. - Users clear their own context with `@forget`. **No link from `@code` or `@draw`.** - Built-in server: confirm Limnoria's HTTP server is enabled and its public URL is set. - External server: confirm the bot can write to `httpRoot` and that `httpUrlBase` matches what your web server serves. - Check `DEBUG` logs for file-save errors. **A drawn image link 404s, or shows the wrong picture.** The image was never generated. A non-reasoning model will sometimes answer a draw request by writing a plausible image URL instead of calling the `generate_image` tool — either inventing a path outright, or reusing a real URL from an earlier turn, which is harder to spot because the link works and only the picture is wrong. The reliable tell is latency against the logs. A genuine image takes roughly 15–20 seconds and always leaves a matching line: ``` docker logs vibebot 2>&1 | grep "op=image_generation" ``` A reply that arrived in two or three seconds with no corresponding `op=image_generation` entry was fabricated. The bot detects this itself and recovers: any image URL on a host only the bot publishes to (`httpUrlBase` or `imageUploadUrl`) that the current turn did not generate is rejected, the `generate_image` tool is forced, and the turn is retried once. Links to other hosts are left alone, so quoting somebody else's image still works. Look for: ``` assistant_completion: reply invented image URL without calling generate_image; forcing the tool and retrying (1/1) ``` Seeing that line means the guard worked and the user received a real image. If the model still refuses to call the tool on the forced retry, the reply is replaced with a plain failure message rather than a link that does not work. **The bot repeats a stock failure line instead of retrying.** The model imitates its own recent output, so a message like "Image generation failed." left in the conversation history teaches it to answer the *next* request the same way, without calling the tool at all. The bot strips its own past refusals, policy-refusals, collapsed replies, repeated replies, image-failure reports, and tool complaints in any wording from the history before each turn, precisely so they cannot seed the next one. The last of those is the general case, and it also catches a reply that blames a tool the turn never called at all — the wording drifts ("the tool's broken", "tool refused", "still choking on the request") long after the failure that started it. When one is caught mid-turn the bot re-rolls the reply once and logs: ``` assistant_completion: reply blamed a tool that never ran, nudging and retrying (1/1) ``` If a stock phrase does get stuck, run `@forget` in the affected channel: that clears the caller's own thread *and* the channel's shared recent history. Run from PM, or with a channel argument from somewhere else, it clears only the caller's thread and leaves the bad line in the shared window. A bot reply is itself stored as history, so it persists until cleared or aged out. To measure how often the guards fire, grep for `history_strip`: ``` docker logs vibebot 2>&1 | grep history_strip ``` ``` history_strip model=xai/grok-4-1-fast-reasoning channel=#afternet route=chat assistant_turns=14 removed=2 image_failure=1 tool_complaint=1 ``` `assistant_turns` is the denominator within one turn: the bot's own turns visible across the personal thread and the shared channel window before anything was stripped, against `removed` as the numerator. The line is silent on clean turns, so for a rate across a whole log use the `op=assistant_step_1` lines as the turn count: ``` docker logs vibebot 2>&1 | grep -c "op=assistant_step_1" ``` # The verse The verse is a per-channel, persistent world model: a SQLite graph of avatars, places, factions, items, and events that grows out of roleplay in the channel. Opted-in users act through avatars; the bot narrates scenes, records events, and carries canon forward between sessions. This page is the operator reference: enabling the verse, controlling when it fires, curating canon, and running the maintenance surface. ## Enabling the verse The verse is off by default. Enable it per channel: ``` @config channel #yourchan plugins.LLM.verseEnabled True ``` Users then opt in individually with `@verseopt in`. The channel switch and the user opt-in are independent: flipping `verseEnabled` to `False` suspends the verse for everyone without discarding avatars or history. ### Capabilities Three capabilities gate verse access: | Capability | Who needs it | What it unlocks | | ---------------- | --------------------- | ------------------------------------------------------------------------ | | `llm.verse` | Regular users | `@verseopt`, `@rp`, `@verse`, `@look`, `@who`, and verse-routed messages | | `llm.verse.edit` | Canon editors | `@versedit`, `@canon`, and the model's `verse_edit` tool | | `llm.verse.gm` | Game moderators (GMs) | `@versedump`, `@versepurge`, `@versecompact` | These are allow-by-default None of the three is registered default-deny, and `supybot.capabilities.default` ships at `True`, so on a fresh install every user already passes all three — `llm.verse.gm`, and with it `@versepurge`, included. Add the anti-capabilities before you enable the verse: see [capability-based access control](https://rdrake.github.io/vibebot-v8/operator/rate-limiting-security/#capability-based-access-control). Grant them the normal Limnoria way: ``` @admin capability add someone llm.verse @admin capability add editor llm.verse.edit ``` `@capabilities` is the User plugin's read-only lister, not a grant verb: it takes one argument and errors on two. The `llm.verse.edit` capability is checked globally against the caller's account. A channel-scoped grant fails closed: it denies rather than escalates. Grant trusted accounts globally. `@avatar` is the one verse command with no capability check. It only stores a persona string, which does nothing until the user also holds `llm.verse` and opts in. Without `llm.verse`, a user can still talk to the bot in a verse channel; their messages never enter roleplay. Canon *grounding* is not capability-gated, so naming an entity still pulls the facts block into their reply. `@rp` has no rate-limit keys of its own; it draws on the `ask` bucket. ## The canon layer and roleplay mode Canon *retrieval* and roleplay *persona* are two separate things. Retrieval is cheap and happens on any canon mention; the in-character persona is entered explicitly. ### The canon signal A message carries a canon signal when either of these holds: - **Entity reference.** The message names a known active entity other than the speaker's own avatar. Matching is whole-word and alias-aware. - **Trigger keyword.** The message matches `verseTriggerRegex`, a case-insensitive pattern with default `\bverse\b`. Set a channel-specific phrase to give users a deliberate cue. An empty value disables the keyword signal; a malformed pattern is ignored. A canon signal pulls a compact facts block (roster, relations, recent events) into the turn. On `@ask`, and for anyone without an avatar, that is all it does: the reply stays ordinary chat. For an avatar holder speaking ambiently it also promotes *that single turn* to the verse route, so the answer comes back in the avatar's voice — unless the message asks for a picture. What it never does is arm sticky roleplay; see the table below. ### What a signalled message becomes The table covers *ambient* messages: the bot addressed by nick, or a bare `vibebot …` line. The `@ask` command is never ambient: it always takes the chat path, canon-grounded when the message signals canon. | Message | Result | | ------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `@rp ` | One in-character roleplay turn. Needs an avatar; without one it degrades to a canon-grounded chat reply | | Ambient, while `@rp on` is live | Roleplay turn, same as `@rp ` | | Ambient + canon signal + avatar, asking to *illustrate* (`illustrate`, `comic`, `storybook`, `with pictures`) | Illustrated storybook page, when `verseStorybookEnabled` is on and the `@story` cost gates pass (authenticated, `llm.draw`, not on cooldown). With the flag off, the default, this falls through to a prose tale | | Ambient + canon signal, asking to *draw* (`draw`, `sketch`, `paint`, `picture of`) | Canon-grounded chat turn that draws a single image | | Ambient + canon signal + avatar, anything else, questions included | Multi-paragraph prose tale in the avatar's voice. No image; as with every multi-line reply, it arrives as a teaser plus a link | | Ambient + canon signal, no avatar | Ordinary chat reply, grounded in the canon facts | | Question-shaped message with no canon signal | Straight factual answer. The channel's tall-tale overlay is swapped out so real-world questions get real answers | | No canon signal | Ordinary chat | ### Sticky roleplay `@rp on` keeps a caller in character without prefixing every line. Ambient messages become roleplay turns until `@rp off`. The session is keyed by channel plus account, falling back to nick, and expires on a sliding `verseRoleplayStickyTtlSeconds` window (default 900 seconds, `0` never expires). Being per channel, `@rp on` in `#a` does not follow you into `#b`. Every in-character turn refreshes the window, so a session lapses only after silence. Sessions live in memory and are lost on restart. ### Canon written from chat By default canon only grows during roleplay turns. Set `verseChatRecordEnabled` to let an opted-in avatar's ordinary chat turn call `verse_record` as well; the model is nudged to save only a genuinely new durable fact. Off by default because chat volume dwarfs roleplay volume, and canon pollution is hard to undo. ### Going out of character Users bypass the verse for a single message by wrapping it in double parentheses or starting it with `//`: ``` ((just testing the bot, ignore this)) // what's the weather tomorrow? ``` The message takes the normal chat path and nothing is recorded to the verse. On verse-enabled channels the marker is stripped before the chat model sees the text. The marker also suppresses the ambient prose tale and the storybook, so `// illustrate the lads` costs nothing. ## User commands | Command | Effect | | -------------------------------- | ----------------------------------------------------------------------------------------------------------- | | `@verseopt in` / `@verseopt out` | Join the verse with a fresh avatar, or retire it. History is preserved; opting back in creates a new avatar | | `@rp ` | One in-character roleplay turn | | `@rp on` / `@rp off` | Enter or leave sticky roleplay mode | | `@verse` | Where your avatar is: the place name and its description. Needs an avatar; use `@who` for the roster | | `@look [target]` | Describe the current location, or a named entity | | `@who` | Roster of active avatars and their locations | | `@avatar [persona\|clear]` | Set the persona that shapes your avatar. Independent of `@instruct`; affects only the verse | `@avatar` stores the persona in the plugin's own database (the `user_avatar_personas` table in `LLM.db`), not in the Limnoria registry, so it never lands in `bot.conf`. One persona per user, shared by every verse channel. When the caller has an active avatar in the channel, the persona is mirrored onto that avatar's summary, so `@look` reflects the change immediately. ## The store Each channel's verse lives in its own SQLite database under `data/verse/`. The schema has nine tables; the ones an operator meets are: - **entities**: avatars, non-player characters (NPCs), places, factions, and items, each with a kind, name, summary, and status. - **attributes**: key-value details attached to entities. - **relations**: directed, typed links between entities, with optional notes. - **events**: the timeline. Each event links the entities involved, both as a JSON list and through the `event_actor` join table. Any tool that mutates events must handle both linkages. - **entity_alias**: alternate names that entity matching resolves. - **avatar_link**: maps IRC accounts and nicks to avatar entities. ### Canon, pinning, and aging Entities the model creates during play are tagged auto-created. The daily sweep retires auto-created NPCs that go unmentioned for `verseAutoEntityRetireDays` days (default 14, `0` disables). Retirement is a soft status flip and is reversible. Two marks exempt an entity from aging and pull it into every verse prompt: - **Pinned** (`@versedit pin`): part of the always-on canon roster. - **Author-locked** (`@canon lock`): durable canon claimed by an editor. The canon roster block in the verse system prompt is capped at `verseRosterMaxChars` characters (default 4000). Pinning is what makes a character appear in every turn; unpinned entities surface contextually through events and relations that mention them. ### Member-driven worldbuilding Opted-in members can narrate events involving entities other than their own avatar: > vibebot, stinky dan threw a guff grenade at Andrew The assistant calls the `verse_record` tool with `actors=["stinky dan", "Andrew"]`. Names that match existing entities link to them (avatar before NPC before item before place, case-insensitive, retired entities skipped). Unmatched names become auto-created NPCs. The exception is an aged-out auto-created NPC of the same name, which is reactivated instead: a character who returns after the retirement sweep keeps its id, attributes, and history rather than spawning a duplicate. Items and props belong in the recorded summary as prose, not in `actors`. That is an instruction to the model, not a server-side filter: the server only checks the type, drops blanks, and truncates to the cap. A model that lists a weapon as an actor really will get an NPC called `guff grenade`, and `@versedit retire` is the cleanup. `verseAutoEntityMaxNamesPerCall` (default 8) caps the actors array. Raise it for verses with large casts; values past 16 invite entity flooding. ## Editing canon ### `@versedit` `@versedit` hand-edits a channel's verse as operator canon. Writes are immediate and audit-logged with an operator source tag. It requires `llm.verse.edit`. By default the edit applies to the channel where you run the command. A leading `#channel` token overrides that, which also lets you batch edits from a private message without flooding the channel: ``` /query yourbot @versedit #afternet add npc Gurning Gary :: Supply teacher with a twitchy eye @versedit #afternet pin Gurning Gary ``` A `` is either `#` or the name of an *active* entity. Summaries follow a `::` separator. | Verb | Syntax | Effect | | -------------------- | ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | | `add` | `add [:: summary]` | Create an entity. Kinds: `avatar`, `npc`, `place`, `faction`, `item`. Rejects duplicate active names | | `show` | `show ` | Inspect id, kind, status, summary, and attributes | | `desc` | `desc :: ` | Replace the entity summary | | `name` | `name ` | Rename. Rejects duplicate active names | | `set` | `set ` | Set an attribute | | `pin` / `unpin` | `pin ` | Add to or remove from the pinned roster | | `retire` / `restore` | `retire ` | Soft-delete or restore an entity. Name lookup is active-only, so `restore` takes `#`: read it off `@versedump` or off the `retired #` reply | | `relate` | `relate [:: note]` | Add a directed relation; replies with its id | | `unrelate` | `unrelate ` | Delete a relation | | `event` | `event [@id,id,…]` | Add a timeline event, optionally linking entity ids | | `editevent` | `editevent :: ` | Edit an event summary | | `delevent` | `delevent ` | Delete an event | ### `@canon` `@canon lock ` marks a character as author-locked canon; `@canon unlock ` releases it, letting normal aging apply again. `forget` is an exact alias for `unlock`. The name is matched alias-aware, the same way `@look` matches. Requires `llm.verse.edit`. ### The model's `verse_edit` tool Editors also get canon edits in-band: when a user holding `llm.verse.edit` triggers a verse turn, the model can call `verse_edit`. The tool is constructive-only: it can add entities, events, relations, and attributes, and update summaries, but it cannot delete, retire, pin, or rewrite history. Destructive verbs stay with `@versedit`. Authorisation is computed per triggering user; for anyone else the tool refuses as a no-op. ## Style exemplars `verseStyleExemplars` holds a short list of curated lines that show the model what good verse output looks like in this channel. The injector enforces a hard budget: at most 5 lines and 600 characters in total, with sanitisation that drops lines mimicking prompt structure. Curate 3 or 4 short lines. Populate the list offline with the taste miner, which scans channel logs for lines that one designated taste-setter re-pasted or praised. That nick is hard-coded in `taste_mine.py` as a prefix match, so its `_away` and `|away` variants count too, and there is no flag to point it elsewhere. On a channel without that person the miner returns nothing: ``` python -m llm.verse.taste_mine --verse-dir data/verse --channel "#afternet" --out taste_candidates.md ``` Review the candidates by hand, then set the survivors as a JSON list on the channel key. ## Measurement Two signals tell you whether verse output lands: - **Reactions.** With `verseReactionCaptureEnabled` (default on), the bot records IRCv3 emoji reactions to its verse lines into `data/verse/reactions.jsonl`. Capture is measurement-only; the bot never replies to a reaction. - **Landing-rate report.** The offline `taste_report` CLI counts how often that same taste-setter reacts to verse output, with their own message volume in the same logs as the denominator. Log filenames must carry a `YYYY-MM-DD`; the rest are skipped. Pass `--reactions` to append the explicit thumbs section: ``` python -m llm.verse.taste_report --verse-dir data/verse --channel "#afternet" \ --reactions data/verse/reactions.jsonl ``` ## Storybook With `verseStorybookEnabled` on, verse turns gain a `verse_storybook` tool that renders an illustrated page and posts a link. An explicit request ("make a storybook of this", "illustrate that fight") forces the tool call, so explicit asks fire reliably. The `@story ` command generates the same illustrated page on request, in two prompt-inferred modes: a story mode for in-character tales and an explainer mode for accurate illustrated explanations. `@story` requires the `llm.draw` capability and an authenticated account, and it works outside verse mode. The storybook is reserved for pictures people asked for. A plain canon mention produces a prose tale instead, so an illustrated page only comes from `@story`, the `verse_storybook` tool, or — where `verseStorybookEnabled` is on — an ambient mention that carries an explicit illustrate cue. Cost controls: `verseStorybookMaxImages` (default 5), `verseStorybookMaxPerTurn` (1), `verseStorybookCooldownSeconds` (300, per account, shared between the tool and `@story`), `verseStorybookMaxChars` (6000), and `verseStorybookImageTimeout` (45 seconds). Illustrations render concurrently; the page is published through the bot's HTTP output. `verseStorybookDailyImageCap` (30) is registered but **not enforced**: there is no per-account daily image count to check it against. What bounds image cost is `verseStorybookMaxImages` per page and the per-account cooldown between pages; the verse tool path adds `verseStorybookMaxPerTurn` on top, which `@story` does not read. ## GM operations All three commands require `llm.verse.gm`. ### `@versedump [#channel] [--format=json]` Dump the full verse state: entities with attributes, relations, the 200 most recent events, avatar links, entity aliases, and up to 1000 rows of the `proposals` audit trail. The dump publishes to the bot's HTTP pastebin and the reply carries only the URL; if the HTTP server is not configured, the JSON falls back inline. Useful for debugging and for a backup before a purge. ### `@versepurge [#channel] [token]` Permanently delete all verse state for a channel: entities, attributes, relations, events, and avatar links. **This is irreversible**, so it uses two-step confirmation: 1. `@versepurge #chan` issues a one-time 6-character token valid for 60 seconds. 1. `@versepurge #chan ` performs the purge. An expired or wrong token cancels the purge. Tokens are single-use and compared in constant time. ### `@versecompact [#channel]` Run retention compaction for one channel immediately instead of waiting for the daily timer. Defaults to the channel you type it in; name one explicitly from a private message. Unlike the daily pass it does **not** run the entity aging sweep, so its reply carries no `aged N entities` clause. Reports the outcome; see the reference below. ## Compaction Once a day at `verseCompactionDailyAt` (global, default `03:00` local time), the plugin walks every verse-enabled channel and replaces the oldest events past `verseEventRetentionDays` (channel, default 30) with a single lore-digest event written by `verseCompactionModel`. The digest is stamped at compaction time and lists up to 32 involved entities. `verseCompactionMinKeepEvents` (global, default 20) sets a floor: verses with fewer total events are left alone, which keeps small verses from thrashing. ### Drain rate A single pass compacts at most 200 events, a safety cap that keeps one model call inside the cheap model's context window. Consequences: - A backlog of 10,000 over-retention events converges in about 50 daily runs. - A verse producing more than 200 over-retention events per day never converges under the daily cap. Lower `verseEventRetentionDays`, or run `@versecompact` repeatedly; each run drains another 200-event batch. - Realistic avatar-driven verses produce 1 to 10 events per day, so the cap rarely matters. Failures log at ERROR with a traceback and never block the timer; the re-arm runs in a `finally`, so the next day's run retries. ### Outcome reference | State | Meaning | | --------------------- | --------------------------------------------------------- | | `compacted` | Old events were summarised into one digest event | | `skipped_disabled` | `verseEventRetentionDays` is 0 or lower; retention is off | | `skipped_below_floor` | Total events are under `verseCompactionMinKeepEvents` | | `skipped_no_events` | Nothing is older than the retention cutoff | The daily pass's log line also reports aging: `aged N entities (kept M)` counts auto-created NPCs retired by the sweep and those scanned but kept. ## Quality guards Verse output runs through guards that keep long-form roleplay from degrading: - **Denial retry.** Some non-reasoning models refuse fictional premises. A detected refusal triggers one retry with a corrective nudge, and past refusals are stripped from the history each turn so they cannot become self-reinforcing. - **Degradation detection.** One retry when a reply collapses into run-on or looping text: over 90 words per sentence, or under 22% unique words, judged only on replies of 150 words or more. The collapsed turn is stripped from history so it cannot seed the next one. This guard is not verse-specific; it runs on every route. - **Repeat stripping.** The bot's own near-duplicate replies are dropped from verse history too, so a stuck phrase cannot re-imitate itself. - **History window.** Verse history is trimmed to the 10 most recent messages, which limits self-imitation drift. These run automatically; no configuration is required. ## Registry key reference See the verse and storybook tables in [Configuration](https://rdrake.github.io/vibebot-v8/operator/configuration/index.md) for every key, scope, and default in one place. # Plugins # Plugins VibeBot v8 is a [Limnoria](https://docs.limnoria.net/) workspace that ships two first-party plugins. Load either one from IRC with `@load `. | Plugin | What it does | | --------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | | [LLM](https://rdrake.github.io/vibebot-v8/plugins/llm/index.md) | The main AI surface: `@ask`, `@code`, `@draw`, `@story`, memory, reminders, scheduled tasks, the verse roleplay engine, and the Limnoria-as-tools bridge. | | [NickInMiddle](https://rdrake.github.io/vibebot-v8/plugins/nickinmiddle/index.md) | A small `inFilter` plugin that recognises the bot's nick mid-sentence, so Limnoria's normal addressing logic fires. | Both plugins live in the same uv workspace (`plugins//`) and share linting, type checking, and the 93% test coverage floor. ## Stock plugins first The project follows one guiding principle: defer to stock Limnoria plugins wherever possible. The LLM plugin acts as a natural-language shim over them rather than reimplementing their features. When a user asks "have you seen alice?" or "what time is it in Tokyo?", the assistant calls the stock `Seen` or `Time` plugin through the [tool bridge](https://rdrake.github.io/vibebot-v8/reference/bridge-tools/index.md) instead of answering from the model alone. The bridge stays off until an operator sets `bridgeEnabled` for the channel. Once on, it exposes a curated read-safe set — Misc, Time, Math, Utilities, Seen, Web, Later, Note, Karma, QuoteGrabs, RSS, DDG, Network — unless `bridgeAllowedPlugins` names a different selection. # LLM plugin The LLM plugin is VibeBot's main AI surface. It provides natural-language chat, code and image generation, illustrated stories, persistent memory, and reminders. It also runs scheduled agentic tasks, hosts a persistent roleplay world (the verse), and carries a curated bridge that lets the model call other Limnoria plugins as tools. ## Command families | Family | Commands | Notes | | -------------- | -------------------------------------------------------- | ----------------------------------------------------------------------------------- | | Core AI | `@ask`, `@code`, `@draw`, `@meme`, `@story` | Chat with vision, code with HTTP links, images, captioned memes, illustrated pages. | | Memory | `@forget`, `@memories`, `@instruct` | Volatile context, durable facts, persistent instructions. | | Reminders | `@remind`, `@tz` | Natural-language reminders with recurring support, read in your time zone. | | Accounting | `@usage` | Per-account and per-channel API usage. | | Verse (user) | `@verseopt`, `@rp`, `@verse`, `@look`, `@who`, `@avatar` | Opt in, roleplay, inspect the scene, set a persona. | | Verse (editor) | `@canon`, `@versedit` | Curate durable canon. Requires `llm.verse.edit`. | | Verse (GM) | `@versedump`, `@versepurge`, `@versecompact` | Inspect, wipe, and compact a channel's verse. Requires `llm.verse.gm`. | See the [command reference](https://rdrake.github.io/vibebot-v8/reference/commands/index.md) for full syntax and capability requirements, and the [configuration guide](https://rdrake.github.io/vibebot-v8/operator/configuration/index.md) for every registry key. `@ask`, `@code` and `@draw` also work through plain language: mention the bot by name or send it a PM. `@story` does not — an illustrated page comes from the command, or from an explicit illustrate cue in a verse channel with `verseStorybookEnabled` on. Memory, Reminders and Accounting are command-only, with two exceptions — the model can save a memory, and where `pendingTasksEnabled` is on it can set a reminder or schedule a task. Listing and cancelling go through `@remind list`, `del` and `clear`, which cover your reminders and your scheduled tasks alike. [Bridge tools](https://rdrake.github.io/vibebot-v8/reference/bridge-tools/index.md) lists the tool surface each route sees. ## Capabilities Limnoria capabilities gate the AI and verse commands: `llm.ask` (`@ask`, `@forget`), `llm.code`, `llm.draw` (also covers `@story`), `llm.animate` (`@animate`, `@video`, `@renders`), `llm.verse` (`@rp`, `@verseopt`, `@verse`, `@look`, `@who`), `llm.verse.edit` (`@canon`, `@versedit`), and `llm.verse.gm` (`@versedump`, `@versepurge`, `@versecompact`). `@remind` also needs `llm.ask`, since it manages persisted reminders and scheduled tasks. `@memories`, `@instruct`, `@tz`, `@usage`, and `@avatar` carry no capability of their own. The paths that reach past your own data check inline instead: `owner` for `@memories ` and `@remind admin`, `admin` for a bare `@usage` by PM. Owner and admin accounts bypass rate limits. ## Internal layout ``` plugins/llm/src/llm/ ├── plugin.py # IRC command surface + Limnoria glue ├── service.py # LiteLLM calls, sanitisation, output shaping ├── assistant.py # Tool schemas and per-route visibility (function calling) ├── profile.py # Route profiles: model, prompt, overlay, and caps per mode ├── prompts.py # Shared system-prompt fragments ├── executor.py # LLMExecutor: global concurrency cap ├── persistence.py # SQLite store (memories, reminders, schedules, usage) ├── limnoria_bridge.py # Allowlisted "Limnoria as tools" surface ├── statuspage.py # Statuspage v2 polling, parsing, and rendering ├── context.py # Conversation history with TTL ├── config.py # Limnoria registry options ├── apikeys.py # Provider-scoped API keys and secret redaction ├── tracing.py # Per-request IDs threaded through the logs └── verse/ # Verse store, avatar engine, compaction, taste tools ``` ## Concurrency model Every blocking LLM call goes through `LLMExecutor` (in `executor.py`), which combines a `BoundedSemaphore` and a `ThreadPoolExecutor`. This design: - caps total in-flight LLM I/O with `supybot.plugins.LLM.maxConcurrentLLMCalls` (default 16), - keeps the IRC main thread responsive, because no `litellm.*` call can block it directly, and - shares one shutdown gate, so plugin reloads drain in-flight work cleanly. The global `@usage` report — a bare `@usage` by PM, admin only — appends an `executor: running/queued/max` field for tuning. ## Storage Persistent state lives in a single SQLite database managed by `persistence.py`. All writes go through the `_write_txn` context manager, so a failure mid-update rolls back rather than leaving partial state. Tables cover memories, reminders, scheduled tasks, conversation snapshots, and per-account usage. Each verse-enabled channel gets its own SQLite store under `data/verse/`. ## See also - [Memory and instructions](https://rdrake.github.io/vibebot-v8/user/memory/index.md) - [Reminders](https://rdrake.github.io/vibebot-v8/user/reminders/index.md) - [Scheduled tasks](https://rdrake.github.io/vibebot-v8/user/scheduled-tasks/index.md) - [Bridge tools](https://rdrake.github.io/vibebot-v8/reference/bridge-tools/index.md) - [Verse operations](https://rdrake.github.io/vibebot-v8/operator/verse/index.md) - [Tuning and monitoring](https://rdrake.github.io/vibebot-v8/operator/tuning-monitoring/index.md) # NickInMiddle plugin A small `inFilter` plugin that rewrites incoming channel messages that contain the bot's nick in the *middle* of the text, moving the nick to the front. Limnoria's normal addressing logic then recognises the message as addressed, with no core changes. ## Why it exists On AfterNet, and in most casual IRC channels, people naturally write: ``` can you, vibebot, tell me the weather ``` The LLM plugin matches the bot's nick at the start or the end of a message, including inside a `/me` action. NickInMiddle handles the in-the-middle case for ordinary messages, so the bot still responds naturally. `/me` lines are the LLM plugin's job throughout: NickInMiddle skips CTCP because moving the nick to the front would invert the sentence — `asks vibebot for a beer` would become `vibebot asks for a beer`. The LLM plugin cuts the nick out in place instead, leaving `asks for a beer`. Limnoria's own `supybot.reply.whenAddressedBy.nick` and `.nick.atEnd` settings do not decide this: the LLM plugin's `inFilter` tags every non-command line `addressed=''` to keep plain English out of the command tokenizer, so its own matcher is the one that runs. ## What it does For every channel `PRIVMSG`, NickInMiddle: 1. Skips the message unless `enabled` is `True` for that channel and network. 1. Skips PMs and CTCP or ACTION messages. 1. Looks for the bot's nick, or any nick configured under `supybot.reply.whenAddressedBy.nicks`, appearing strictly between two separators (space, tab, comma, colon, semicolon). A trailing `?`, `.`, or `!` comes off before the comparison, so `vibebot?` counts, and matching uses RFC 1459 case folding, so `vibe{bot}` matches `Vibe[bot]`. 1. Rewrites the `PRIVMSG` with the bot's own nick at the front — a configured alias is replaced by the real nick, not carried through — then lets Limnoria dispatch the message normally. Example rewrite, with bot nick `vibebot`: | Before | After | | --------------------------------------- | -------------------------------------- | | `can you, vibebot, tell me the weather` | `vibebot can you, tell me the weather` | The separator before the nick stays where it was; the one after it goes with the nick. ## Loading and configuration ``` @load NickInMiddle ``` `supybot.plugins.NickInMiddle.enabled` defaults to `True`, so loading the plugin is all it takes. It is a channel value, scoped per network and per channel, so turn it off where you don't want it: ``` @config channel #noisy plugins.NickInMiddle.enabled False ``` # Reference # Bridge tools The assistant carries tools that wrap Limnoria's stock plugin commands. When a user asks in natural language ("what time is it in Tokyo?", "have you seen alice?"), the model calls the corresponding Limnoria command directly instead of relying on a custom implementation. This page describes the bridge's gating rules and the knobs operators use to control what the model sees. ## How it works The bridge injects two tools: `run_limnoria_command` and `search_bridge_commands`. `enumerate_commands` in `plugins/llm/src/llm/limnoria_bridge.py` walks `irc.callbacks` each time the pair is built, so the tool surface tracks whatever Limnoria has loaded at that moment. Newly loaded plugins become visible without a bot restart. `run_limnoria_command`'s description carries the whole exposed command list, one line per command with its argument syntax and docstring. When the mutation gate is closed and an allowlisted plugin has writes to hide, the list ends with `(write commands hidden — set bridgeAllowMutating True to expose)`. `search_bridge_commands` substring-searches that same list (`limit` 1 to 25, default 10); it exists because Limnoria's `Misc.apropos` matches command names only, not descriptions. Both tools ride `@ask`, nick-addressed mentions, PMs, and `@rp`/verse turns — everything that reaches `_ask_impl`, which builds the pair unconditionally. `@code`, `@draw`, reminder fires, and scheduled tasks never carry them. The bridge exposes a command when **all** of these hold: 1. `bridgeEnabled` is `True` for the channel (default `False`). 1. The owning plugin is in the operator allowlist (`bridgeAllowedPlugins`), or in the curated `DEFAULT_ALLOWED_PLUGINS` set when the allowlist is empty. 1. The command is **not** in `DENY_COMMANDS`. Examples: `misc more` (interactive pagination) and the `web` fetch-style commands, which the bridge denies as server-side request forgery risks. 1. Either the command is read-only, or `bridgeAllowMutating` is `True` for the channel. 1. The caller holds the Limnoria capability for the command, the same check Limnoria runs for IRC dispatch. ## Default allowlist The curated set applies when `bridgeAllowedPlugins` is empty. Every plugin in the set is either read-only by nature or has its writes gated behind `bridgeAllowMutating`. `Misc`, `Time`, `Math`, `Utilities`, `Seen`, `Web`, `Later`, `Note`, `Karma`, `QuoteGrabs`, `RSS`, `DDG`, `Network` `Network` is in the set for `whois`, `whowas`, `latency` and `uptime` — the "who is alice?" and "are you lagging?" questions. Its connection management (`connect`, `disconnect`, `reconnect`, `authenticate`) and its re-dispatch commands (`command`, `cmdall`) are in `DENY_COMMANDS`, so the model never sees them whatever the caller's capabilities. For the canonical, current list see `DEFAULT_ALLOWED_PLUGINS` in `plugins/llm/src/llm/limnoria_bridge.py`. ## Loading the plugins `bridgeAllowedPlugins` only matters for plugins Limnoria has loaded. Out of the curated set, only `Misc` loads on its own: `alwaysLoadImportant` covers Admin, Channel, Config, Misc, Owner and User, and the bridge hard-denies every one of those but `Misc`. Everything else, `Utilities` included, needs an explicit load as bot owner: ``` @load Utilities @load Time @load Math @load Seen @load Web @load Later @load Note @load Karma @load QuoteGrabs @load RSS @load DDG @load Network ``` Limnoria persists each `load` to `bot.conf` automatically. The bridge picks them up on the next assistant turn, with no bot restart needed. To see the commands a plugin exports, ask the bot: ``` @list Time @help time ``` Whatever shows up under `@list ` is what the model can call, minus anything held back by `DENY_COMMANDS` or `MUTATING_COMMANDS`. ## Mutating commands When `bridgeAllowMutating` is `False`, the default, the bridge filters out commands that change persistent state, send messages on behalf of another user, or read with side effects. Filtering happens at enumeration *and* at dispatch, as a defence-in-depth measure. The full set lives at the top of `limnoria_bridge.py` under `MUTATING_COMMANDS`. To allow mutations on a specific channel: ``` @config channel #yourchan plugins.LLM.bridgeAllowMutating True ``` Operator caveats: - `RSS` mutations can announce feed entries to every channel subscribed to the feed. Enabling mutations in one channel can cause writes elsewhere. - `Karma` `clear`, `dump`, and `load` are destructive against the karma database. - `Factoids` `whatis` counts as mutating because the default `keepRankInfo` writes a usage counter on every read. ## Refused calls A refusal is not silent. Dispatch hands the model a JSON envelope, so it can correct itself or tell the user why. A call that runs returns `{"status": "ok", "reply": ""}`; a refusal returns one of: - `{"error": "denied: write commands disabled"}` — the command is in `MUTATING_COMMANDS` and `bridgeAllowMutating` is `False`. - `{"error": "not permitted: Plugin.command"}` — the caller lacks the Limnoria capability for that command. - `{"error": "denied: Plugin.command"}` — a `DENY_COMMANDS` entry, or a plugin in `DENY_PLUGINS`. - `{"error": "unknown plugin: X"}` or `{"error": "unknown command: Plugin.command"}` — the model invented a name. - `{"error": "command failed"}` — the plugin raised. The exception text goes to the log, not to the model; anything the plugin printed before raising comes back under `partial_output`. - `{"error": ""}` — the argument string failed Limnoria's tokeniser: an unbalanced quote, bracket, or pipe. The message passes through verbatim so the model can correct its own call. ## Live IRC lookups `irc_lookup` is a third per-request tool that rides with the bridge pair whenever `ircLookupEnabled` is on for the channel (the default), bridge or no bridge. It exists because nothing stock answers the first two questions — Limnoria has no `LIST` command at all, and `Channel.nicks` only reads channels the bot has joined — and because a `whois` buried in the bridge's command table is one the model tends to skip; as a named kind on its own tool it gets picked. | `kind` | What it sends | What comes back | | ---------------------------- | -------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `channels` | `LIST` | Up to 25 public channels sorted by user count, each with `name`, `users` and a cleaned `topic`, plus `total`. `target` filters by exact name or glob (`#linux*`). | | `names` | `NAMES ` | `count` and up to 100 nicks with their `@`/`+` prefixes, for any channel the server will show — joined or not. | | `whois` | `WHOIS ` | `user`, `host`, `realname`, `server`, `channels` (with prefixes), `account`, `oper`, `away`, `idle_seconds`, `signon`. The doubled nick asks the user's own server, so idle time comes back. | | `whowas` | `WHOWAS 3` | Up to 3 `entries` for a nick that has left, each with `user`, `host`, `realname`, and where the server says so `server`, `last_seen` and `away`. | | `who` | `WHO ` (WHOX) | `count` and up to 100 `members`, each with `away`, `oper`, `status` (`@`/`%`/`+`), `bot`, `account` (`null` when not logged in), `host` and `realname`. | | `topic` | `TOPIC `, or `LIST` when the bot is not in the channel | `topic`, plus `set_by` and `set_at` for a joined channel. For a channel the bot is not in, the setter is unknown and only public channels answer. | | `network` | `LUSERS`, `VERSION`, `ADMIN` | `users` (user, oper and channel counts), `server` (software and server name) and `admin` (contact lines). | | `ctcp_version` / `ctcp_ping` | CTCP `VERSION` / `PING` to `` | `client`, or `lag_ms`. Many clients ignore CTCP; then `reply` is `null` with a note. | | `history` | `CHATHISTORY LATEST * ` | `lines` from the server's history, newest last, as `HH:MM text` in UTC. `count` is 1–100, default 50. Only the channel the request came from; refused in a PM. | `history` is the "what did I miss" kind. It ships ordinary channel chatter to the provider, so it obeys `privacyOptInOnly`. With that setting on (the default) the schema leaves out the kind, its `count` parameter and every mention of history, and the handler refuses a call that names it anyway without sending anything. It also needs the server to grant `draft/chathistory`, which the bot requests at connect. The server enforces its own visibility rules: a `+s` channel is absent from `LIST` and answers `NAMES` with nothing, exactly as it would for a user typing `/list` or `/names`. The bot adds no privilege. `LIST` is the one expensive request here, so the reply is cached for 60 seconds per network and concurrent askers share one in-flight request. A server that never closes the reply (or answers `RPL_TRYAGAIN`) surfaces as an error to the model after 15 seconds rather than a hung turn. The same code backs the `@channels` and `@names` commands. Every dispatch logs one `irc_lookup: kind=… target=…` line, so "did the model call it?" is a grep. ## Native tools Beyond the bridge, the assistant carries its own tools. Users never call these directly; the model picks them from conversation. Which ones it is offered depends on the route that handled the message: | Route | Entry points | Tools offered | | ------------- | ------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | chat | `@ask`, nick-addressed mentions, PMs | `check_service_status`, `fetch_url`, `generate_code`, `generate_image`, `save_memory`, `search_web`; plus `set_reminder` and `schedule_llm_task` where `pendingTasksEnabled` is on. Six with the shipped defaults; eight when `pendingTasksEnabled` is on; one fewer in each case when both `statusPageUrls` and `statusQueryablePages` are empty. | | verse | `@rp`, a live sticky `@rp` session, the one-shot ambient-prose promotion | `fetch_url`, `generate_code`, `generate_image`, `save_memory`, `search_web` (5) | | code | `@code` | `fetch_url`, `generate_code`, `search_web` (3) | | draw | `@draw` | `generate_image` (1) | | remind_action | reminder and scheduled-task fires | all 21 | `assistant.py` defines 21 tool schemas, but defining one is not advertising it. Thirteen are hidden from chat and verse because each duplicates a command the user can already type: - `list_memories`, `update_memory`, `delete_memory`, `clear_memories`, `cleanup_memories` → `@memories`, `@memories edit `, `@memories delete `, `@memories clear`, `@memories cleanup` - `set_instruction`, `clear_instruction` → `@instruct `, `@instruct clear` - `get_usage`, `get_channel_usage` → `@usage []` - `forget_context` → `@forget []` `list_pending_tasks`, `cancel_pending_task` and `cancel_all_pending_tasks` used to be on this list. They are back in chat: with `set_reminder` visible and the cancel tools hidden, chat answered "cancel my reminder" with a made-up confirmation and the reminder fired anyway. Keeping the advertised surface small is a correctness measure, not tidiness. `xai/grok-4-1-fast-reasoning` starts returning empty completions once the tool count climbs past roughly 25 — four empty-response incidents on 2026-05-10, more than any day in the preceding 30 — and a non-reasoning model asked to pick one tool out of twenty will sometimes pick none and answer from its own invention, which is how a draw request came back with a fabricated image URL on 2026-08-01. Hiding these costs no capability: the handlers still exist, the commands above still work, and memories still arrive on their own through the background extraction pass. `save_memory` stays on the chat surface because it is the one write extraction cannot replace — an explicit "remember this" should stick at once rather than wait for the candidate-reinforcement threshold. Reminder fires keep all 21. A scheduled task runs as its creator with no user present to type a command, so it may legitimately need to tidy up after itself. `set_reminder` and `schedule_llm_task` appear on the chat surface only when `pendingTasksEnabled` is on for the channel, and it defaults to off. Their schemas and prompt rules cost roughly 1,100 prompt tokens on every completion, so channels that never schedule anything should not pay for them. The `@remind` command and already-scheduled fires ignore the gate. The matching list and cancel tools are off the chat surface either way: users list and cancel both kinds with `@remind list`, `@remind del` and `@remind clear`. `check_service_status` covers two tiers of page. `statusPageUrls` entries back the poller's cached snapshot: one entry per page when the model omits `service`, or a single named lookup. `statusQueryablePages` entries are answered only by name, fetched lazily and cached for 5 minutes — they never appear in the omitted-`service` answer. Only `statusPageUrls` pages are named in the tool's description text, in a "Monitored services" sentence, since that sentence describes what an omitted `service` returns; the `service` argument's enum is wider and lists every configured name across both keys, which is how a queryable page gets asked for by name at all. The tool leaves the request only when both keys are empty, rather than shipping a tool that could only answer "not configured". Verse is a strict subset of chat: everything chat hides, plus `set_reminder`, `schedule_llm_task` and `check_service_status`, none of which has an in-character use. The subset relation is asserted by `test_verse_profile_is_strict_subset_of_chat`. On any turn in a verse-enabled channel the model also receives the verse tool set, advertised to every speaker whether or not they have opted in, so the channel's cacheable prompt prefix stays byte-identical across users; calls from a speaker with no avatar land on denial handlers. The set is `verse_act`, `verse_move`, `verse_look`, `verse_recall`, `verse_record`, `verse_edit` (refused unless the caller holds `llm.verse.edit`), and `verse_storybook` (present only when `verseStorybookEnabled` is on). See [verse operations](https://rdrake.github.io/vibebot-v8/operator/verse/index.md). ## Scheduled LLM tasks `schedule_llm_task` lets the assistant create recurring agentic work ("every weekday at 09:00, summarise the news"). The `bridgeScheduledTaskLimit` key (default 5, 0 disables scheduling) caps active schedules per creator in a channel. Each fire still counts against the user's normal `@ask` rate-limit bucket; the cap limits the number of pending schedules, not their cumulative cost. Owners can list and cancel any user's tasks with `@remind admin`. ## Operator config | Setting | Default | Description | | -------------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `bridgeEnabled` | `False` | Primary switch, per channel. `False` removes every bridge tool. | | `bridgeAllowedPlugins` | empty (curated default applies) | Space-separated plugin names. A non-empty value replaces the curated set entirely. Names use camel case to match `cb.name()`. | | `bridgeAllowMutating` | `False` | Per channel. Exposes commands listed in `MUTATING_COMMANDS`. | | `bridgeScheduledTaskLimit` | `5` | Per-creator cap on active `schedule_llm_task` entries. `0` disables scheduling. | | `pendingTasksEnabled` | `False` | Per channel. Advertises `set_reminder` and `schedule_llm_task` on the chat surface, so the model can create reminders and schedules from plain language. Listing and cancelling stay command-only. | | `bridgeDebugInChannel` | `False` | Per channel. Appends a one-line footer listing every bridge call made during the turn, as `Plugin.command (N chars) [ok]`, or `[err:]` when the call was refused. Argument text is never echoed, only its length: bridge arguments are model-generated and can carry secrets. Full arguments go to the DEBUG log. | To add a plugin outside the curated default, list every plugin you want exposed, because the registry value replaces the default rather than extending it. For example, to add `Factoids` and `Dict`: ``` @config plugins.LLM.bridgeAllowedPlugins Misc Time Math Utilities Seen Web Later Note Karma QuoteGrabs RSS DDG Factoids Dict ``` Once a plugin is in `bridgeAllowedPlugins` and loaded into Limnoria, every read-only command it exports becomes available to the model on the next turn. # Command reference Quick reference for every VibeBot command. For walkthroughs and examples, see the [user guide](https://rdrake.github.io/vibebot-v8/user/getting-started/index.md). ## Natural language Most bot features work through plain language. Mention the bot by name in a channel, or send it a PM: ``` VibeBot, what's the weather like on Mars? VibeBot, remember that I deploy on Fridays VibeBot, what do you remember about me? VibeBot, remind me in 2 hours to deploy ``` The bot reaches for tools as it answers: web search, page fetches, code generation, image generation, saving a memory, and — where the operator has configured a status page — a service-status check. Where the [bridge](https://rdrake.github.io/vibebot-v8/reference/bridge-tools/index.md) is enabled, it can also look up and run a Limnoria command. It can recite what it remembers about you, because your stored memories sit in its context on every turn. Listing memories precisely, editing or deleting them, setting an instruction, checking usage, and clearing context are command-only. Natural-language reminders and scheduled tasks need `pendingTasksEnabled` set for the channel; `@remind` works either way. Use the commands below when you want direct, predictable behaviour. ## Core AI commands | Command | Arguments | Description | | -------- | ------------ | -------------------------------------------------------------------------------------------------- | | `@ask` | `` | Ask the AI a question. Supports follow-ups and image URLs. Requires `llm.ask`. | | `@code` | `` | Generate code, served as an HTTP link with syntax highlighting. Requires `llm.code`. | | `@draw` | `` | Generate an image from text. Requires `llm.draw` and an authenticated account. | | `@story` | `` | Generate an illustrated page (tale or explainer) and post a link when ready. Same gate as `@draw`. | ### ask Ask the AI a question. Supports conversation context for follow-up questions and vision for image URLs. ``` @ask What is the capital of France? @ask Describe this: https://example.com/image.jpg @ask And what about Germany? ``` See [AI commands](https://rdrake.github.io/vibebot-v8/user/ai-commands/index.md) for full details. ### code Generate code from your request. The bot saves the code to an HTTP link with syntax highlighting and keeps context, so you can iterate. ``` @code Python function to calculate fibonacci numbers @code Now add memoization to that ``` ### draw Generate an image from a text description. Requires an authenticated account. ``` @draw A sunset over mountains in watercolour style @draw A cyberpunk cityscape at night ``` ### story Generate an illustrated page from your brief and post a link when the page is ready. The bot picks one of two modes from your wording: an in-character illustrated tale, or a concept explainer with labelled diagrams. No verse mode required. One page per account every five minutes by default (`verseStorybookCooldownSeconds`), a cooldown shared with the verse storybook tool. ``` @story an illustrated tale of the crew winning the pub quiz @story explain how photosynthesis works, with diagrams ``` ## Memory commands | Command | Arguments | Description | | ----------- | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `@forget` | `[]` | Clear volatile conversation context: your own thread, plus the channel's shared recent history when run in that channel. Naming another channel clears only your own thread there. Requires `llm.ask`. | | `@memories` | `[ \| del [...] \| edit \| clear \| cleanup []]` | Manage stored facts. `del` takes several ids at once. The `` forms — viewing and cleaning up another user's memories — are owner-only. Needs an authenticated account. | | `@instruct` | `[ \| clear]` | Set a persistent instruction that rides `@ask`, plain mentions, `@rp` and verse turns, `@code`, and reminder fires. `@draw` ignores it. Empty shows the current one. Needs an authenticated account. | ``` @memories @memories del 3 @memories edit 5 corrected fact @memories clear @instruct Respond only in haiku @instruct clear ``` See [memory and instructions](https://rdrake.github.io/vibebot-v8/user/memory/index.md) for full details. ## Reminders | Command | Arguments | Description | | --------- | ---------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `@remind` | `[ \| list \| del [...] \| clear \| admin [...]]` | Natural-language reminders, plus your scheduled tasks in `list`, `del` and `clear`. `del` takes several ids at once. Requires `llm.ask`; the `admin` subcommands are owner-only. | | `@tz` | `[ \| clear]` | Set the IANA time zone reminders read absolute times in, such as `America/Toronto`. Unset, the bot uses your client's CTCP TIME clock, else UTC. Empty shows the current zone. Needs an authenticated account. | Reminders that ask the bot to *do* something (look up, check, fetch, summarise) run as an LLM query at fire time and appear as `[auto]` in `list`. Recurring action work, such as "every weekday at 9 a.m. check the build", becomes a scheduled task. Setting one in plain language needs `pendingTasksEnabled`; listing and cancelling is command-only, and `@remind list`, `del` and `clear` cover your tasks — marked `[task]` — alongside your reminders. ``` @remind in 30 minutes check the build @remind in 2 hours check status of CVE-2026-31431 in Debian @remind list @remind del abc1 @remind admin list someone # owner only ``` See [reminders](https://rdrake.github.io/vibebot-v8/user/reminders/index.md) for caveats: reminders count against the `@ask` rate limit, run with no elevated capabilities at fire time, and recurring chains cap at 50 fires. ## Accounting | Command | Arguments | Description | | -------- | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | | `@usage` | `[ \| #channel]` | Show API usage statistics. A nick queried in a channel is scoped to that channel, account-wide by PM. The global overview by PM is admin-only. | | `@model` | `[]` | Which model answered the bot's last reply in the channel, and how long ago, next to the chat model the channel is set to use. Forgotten on restart. | ``` @usage @usage someone @usage #channel ``` ## IRC lookups | Command | Arguments | Description | | ----------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | | `@channels` | `[--min ] [ \| ]` | The busiest public channels on the network with user counts and topics. A channel name shows that one; `#linux*` filters; `--min` hides small channels. | | `@names` | `[]` | Who is in a channel at this moment, joined or not. Defaults to the current channel. | ``` @channels @channels #linux @channels --min 5 #linux* @names #linux ``` Both answer from a live `LIST` / `NAMES` round trip; `LIST` results are cached for 60 seconds. Secret and private channels are hidden by the server, the same as for anyone typing `/list`. For a single user, the stock `Network` plugin's `@whois ` does the job once it is loaded; in conversation, "who is eck?" reaches the same `WHOIS` through the `irc_lookup` tool. ## Verse commands (user) `@verseopt`, `@verse`, `@look` and `@who` must be typed in a verse-enabled channel and need the `llm.verse` capability. `@rp` needs `llm.verse` too, but answers as ordinary chat when the channel has no verse or you have no avatar. `@avatar` needs neither capability and works anywhere, including PM, but needs an authenticated account. | Command | Arguments | Description | | ----------- | ---------------------- | ---------------------------------------------------------------------------------------------------------- | | `@verseopt` | `` | Opt your avatar in or out of this channel's verse. | | `@rp` | ` \| on \| off` | Speak or act as your avatar. Shares the `@ask` rate-limit bucket. | | `@verse` | | Show your current scene in one line. | | `@look` | `[]` | Describe your scene or a named entity. | | `@who` | | List active avatars and their locations. | | `@avatar` | `[ \| clear]` | Set the persona that shapes your verse avatar. Independent of `@instruct`. Needs an authenticated account. | ### rp `@rp ` takes one in-character turn. `@rp on` makes your plain messages roleplay turns until `@rp off` or a spell of silence; a leading `//` slips a single message out of character. Without an avatar the command still answers, as ordinary chat grounded in canon. ``` @rp Archie kicks the door open and bellows for the lads @rp on @rp off ``` Mentioning canon without `@rp` no longer puts the bot in character. It grounds the reply in canon facts, and, for an avatar holder, answers as an inline prose tale. See [the verse](https://rdrake.github.io/vibebot-v8/operator/verse/#the-canon-layer-and-roleplay-mode). ## Verse commands (editor) These require the `llm.verse.edit` capability, granted globally. `@canon` must be run in the verse-enabled channel itself; `@versedit` takes a leading `#channel`, so it can be run from a PM. | Command | Arguments | Description | | ----------- | ----------------------------------- | ------------------------------------------------- | | `@canon` | ` ` | Mark a character as durable canon, or release it. | | `@versedit` | `[#channel] ` | Edit the verse universe directly. | `@versedit` verbs: `add`, `pin`, `unpin`, `set`, `name`, `desc`, `retire`, `restore`, `relate`, `unrelate`, `event`, `editevent`, `delevent`, and `show`. Entity kinds are `avatar`, `npc`, `place`, `faction`, and `item`. Refer to entities by `#id` or by name, but name lookup only finds active entities — so `restore` needs the `#id`, which `@versedump` and the `retired #` reply both give you. ``` @versedit add npc Headmaster Pringle :: stern keeper of the academy @versedit pin #12 @versedit set #12 mood grumpy @versedit show #12 ``` ## Verse commands (GM) These require the `llm.verse.gm` capability. | Command | Arguments | Description | | --------------- | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `@versedump` | `[#channel] [--format=json]` | Dump the verse state — entities, relations, aliases, avatar links, the 1000 most recent proposals, and the 200 most recent events — to the bot's pastebin, and reply with the link. | | `@versepurge` | `[#channel] []` | Irreversibly wipe a channel's verse. Two-step token confirmation. | | `@versecompact` | `[]` | Run retention compaction for the channel now. Defaults to the channel you type it in; name one explicitly from a private message. | See [verse operations](https://rdrake.github.io/vibebot-v8/operator/verse/index.md) for what compaction does and how to operate the verse. # User guide # AI commands You don't need commands to ask the bot questions. Mention it by name in a channel or send a PM: ``` VibeBot, what causes the northern lights? The northern lights happen when charged particles from the sun collide with gases in Earth's atmosphere... ``` Plain language covers questions, web lookups, images, code, service status, [aurora](https://rdrake.github.io/vibebot-v8/user/aurora/index.md) forecasts, and saving a memory ("remember that I use Debian"). Reminders and scheduled tasks join that list where an operator has enabled `pendingTasksEnabled`. Managing memories, instructions, usage and context is command-only — use the commands below. ## Icons on a reply Two icons can appear at the front of a reply. They are the bot telling you something about how the answer was produced, and both can show at once. | Icon | Meaning | | ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 🌐 | The answer used a live web lookup rather than the model's own knowledge | | 🔁 | A content filter refused the image, so the bot reworded the prompt and tried again. You got a picture, but the wording behind it moved, so it may read a little differently from what you asked for | ______________________________________________________________________ ## `ask` Ask the AI a question. The bot remembers your recent conversation, so follow-up questions work naturally. **Usage:** `@ask ` ``` @ask What is the capital of France? @ask And what's its population? @ask Explain quantum entanglement in plain terms ``` ### Follow-up questions The bot keeps conversation context per user, per channel. Your next `@ask` in the same channel carries the history forward: ``` @ask What's the fastest land animal? → The cheetah, reaching speeds of 113 km/h. @ask How does it compare to the fastest bird? → The peregrine falcon dives at over 380 km/h... ``` Context expires after a few minutes of inactivity. Use `@forget` to clear it sooner. ### Vision Include an image URL in your question and the bot analyses it: ``` @ask What's in this image? https://example.com/photo.jpg @ask https://i.imgur.com/abc123.png Is this a bird or a plane? ``` More than one image URL in a question works too. The bot detects HTTP and HTTPS image links automatically. ### Custom instructions Use `@instruct` to set persistent instructions that shape how `@ask` responds to you. See [Custom instructions](https://rdrake.github.io/vibebot-v8/user/memory/#custom-instructions). ______________________________________________________________________ ## `code` Generate code, delivered as a syntax-highlighted web page. The bot can search the web for current documentation before generating. **Usage:** `@code ` ``` @code Python function to calculate Fibonacci numbers @code JavaScript async fetch with error handling ``` ### Iterating on code Conversation context carries over, so you can refine across requests: ``` @code Python function to sort a list of dictionaries by key → https://bot.example.com/llm/abc123.html @code Now add type hints and handle empty lists → https://bot.example.com/llm/def456.html ``` If the page cannot be saved, the bot answers without a link — ask your operator to check `httpRoot` and `httpUrlBase`. A `@code` request that times out is retried in the background and delivered later, pasted inline if the page still cannot be saved. ______________________________________________________________________ ## `draw` Generate an image from a text description. **Usage:** `@draw ` ``` @draw A sunset over mountains in watercolour style @draw A golden retriever in a field of sunflowers, oil painting ``` `@draw` needs an authenticated account. The bot asks you to log in first if you aren't. The shape follows the request: a poster, a book cover or a standing figure comes out portrait, a scene or a panorama landscape, an icon square. Say "portrait" or "wide" if you want to force it. Name a real person, place or event and the bot looks up what they actually look like before it draws, because the image model knows nobody by name — "Churchill" on its own is a word, not a face. It keeps the name you wrote and adds the appearance beside it, so you get the man rather than a generic figure. This costs a few seconds on every `@draw`, and your operator can turn it off per channel. In a channel with the verse enabled, characters the channel invented are described from canon instead, and canon wins where the two overlap. Image generation applies content safety filters. If a filter blocks your prompt, the bot rewords it and tries again — once by default (`drawAutoRewriteMax`). The reworded version keeps your subject, what it is doing, the setting and the style you asked for, and changes only what the filter is likely to have objected to, so you should still recognise the picture you asked for. A reply carrying a reworded image is marked 🔁. If the reworded version is blocked too, the bot says so and tells you it already tried rewording, because rewording it again yourself is not the move — pick a different subject instead. ______________________________________________________________________ ## `meme` Caption a meme template. Name the template and write the captions, or just say what the meme is about and the bot picks a template and writes them. **Usage:** `@meme