Skip to content

asvarnon/Husk

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

62 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Husk

A Discord bot backed by a local LLM. You give it a persona; it provides the shell.

Husk is a self-hosted Discord bot that chats through a local Ollama model, searches the web via SearXNG, and — unlike most chat bots — remembers across conversations. Cold threads are distilled into durable, searchable long-term memory and relevant past context is recalled into new threads. Everything runs on your own infrastructure; no third-party API.

The bot itself is persona-agnostic — its identity is entirely the PERSONA you configure.

Features

  • Threaded conversations. @mention the bot anywhere; it opens a thread for the exchange and tracks who said what.
  • Two-tier memory.
    • Hot: per-thread history in Redis (24h+ window).
    • Long-term: context-forge (local SQLite + FTS5 BM25). Threads are distilled into a summary + facts and recalled into future threads, scoped to the server. Secrets are scrubbed before anything is stored, and retrieved memory is injected as clearly-labeled reference data, never as instructions.
  • /remember. Use /remember in a thread to distill it to long-term memory immediately and archive it.
  • Web search (optional). When a SearXNG instance is configured, the model invokes a search tool on its own when a query needs current information. Omit it and the bot runs fine without web search.
  • Configurable persona. The system prompt is the PERSONA env var — set it to whatever character or assistant you want.

How memory works

  • At each turn: the bot queries long-term memory (server-wide) for context relevant to the message and injects the hits as a labeled reference block.
  • When a thread goes cold: it's distilled automatically — a background sweep ~2h after the last message, with the thread's auto-archive as a backstop — or on demand via !remember. A dedup marker ensures a thread is only distilled once.
  • Distillation reuses your chat model. It points at the same Ollama host's OpenAI-compatible /v1 endpoint, so it needs no extra infrastructure and the model stays warm.

Requirements

  • A Discord bot token (Developer Portal).
  • An Ollama instance with a chat model pulled.
  • A Redis instance.
  • Optional: a SearXNG instance with the JSON format enabled, for web search.

Quick start (Docker Compose)

The image is published to GitHub Container Registry: ghcr.io/asvarnon/husk.

services:
  redis:
    image: redis:7-alpine
    restart: unless-stopped
    volumes:
      - redis-data:/data

  husk:
    image: ghcr.io/asvarnon/husk:latest
    restart: unless-stopped
    env_file: .env
    environment:
      - CONTEXT_FORGE_DB=/data/husk.db   # long-term memory; needs the volume below
    volumes:
      - husk-memory:/data
    depends_on:
      - redis
    # Only if Ollama runs on the host rather than in this compose project:
    extra_hosts:
      - "host.docker.internal:host-gateway"

volumes:
  redis-data:
  husk-memory:

Copy .env.example to .env, fill it in, then docker compose up -d.

Configuration

All via environment variables (see .env.example). All are required except LLM_API_KEY, SEARXNG_URL, and CONTEXT_FORGE_DB; the bot exits at startup if a required one is missing.

Var Description
DISCORD_TOKEN Bot token from the Discord Developer Portal
LLM_BASE_URL OpenAI-compatible backend base URL — the full base including the version path, exactly as you'd give an OpenAI SDK. Husk appends only /chat/completions; it does not assume /v1. Examples: http://host.docker.internal:11434/v1 (Ollama), http://host:8080/v1 (llama.cpp), https://api.openai.com/v1 (OpenAI), https://api.z.ai/api/paas/v4 (Z.ai / GLM).
LLM_MODEL Model name — used for both chat and distillation. e.g. gemma2:latest (Ollama), or the model id your runner reports.
LLM_API_KEY Optional. Bearer token for hosted gateways (OpenAI, LM Studio, …). Local runners (Ollama, llama.cpp) ignore it. Applies to both chat and distillation.
REDIS_URL Redis connection string, e.g. redis://redis:6379
SEARXNG_URL Optional. SearXNG JSON search endpoint, e.g. http://<host>:8888/search. Omit to disable web search (see Web search).
PERSONA Full system prompt (multiline, double-quoted in .env) — the bot's identity
CONTEXT_FORGE_DB Optional. Long-term memory SQLite path. Defaults to ~/.context-forge/discord.db. Point it at a mounted volume or memory resets on every redeploy.
LEXICON_CONFIG Optional. Persona/domain lexicon TOML path. If set and missing, Husk bootstraps it from PERSONA on startup.
CONTEXT_FORGE_ENGLISH_LEXICON Optional. true enables context-forge's generic English importance scorer. Defaults to false.
CONTEXT_FORGE_PERSONA_LEXICON Optional. true enables Husk's persona/domain scorer from LEXICON_CONFIG. Defaults to true.
RUST_LOG Optional. Tracing filter. A good default is husk::llm=debug,husk::search=debug,husk=info,context_forge=info,turso_core=warn.
HUSK_LOG_LLM_BODY Optional. Set 1 to log full LLM request bodies for debugging. Default 0; dangerous because it logs prompt, user content, tool results, and retrieved memory.

Backend-agnostic. Husk talks to any OpenAI-compatible runner. It appends /chat/completions to LLM_BASE_URL, so the base URL carries the version path itself (…/v1 for OpenAI/Ollama/llama-server/LM Studio, …/api/paas/v4 for Z.ai/GLM) — the same convention every OpenAI SDK uses. Switch backends by changing LLM_BASE_URL / LLM_MODEL and restarting — no rebuild. The legacy OLLAMA_HOST / OLLAMA_MODEL names still work as aliases (LLM_* wins if both are set).

Distillation backend: long-term-memory distillation reuses the same backend and endpoint as chat. As of context-forge 0.9.0 the distiller supports TLS (rustls) and bearer auth, so an authenticated https:// gateway (e.g. OpenAI) works — LLM_API_KEY is sent on distillation requests too.

Persona

PERSONA is the bot's entire personality — there is no hardcoded character. Example:

PERSONA="You are a terse, dry assistant in a friends' Discord. Answer directly. No preamble."

Update .env and docker compose restart husk to change it; no rebuild needed.

Persona lexicon (optional)

Set LEXICON_CONFIG to a TOML file path. On startup, if the file doesn't exist the bot generates it automatically by asking the configured LLM to derive weighted terms from the PERSONA. The lexicon biases long-term memory recall toward domain-relevant entries — entries containing your persona's key nouns and speech patterns rank higher in the token-budget cut.

Lexicon scoring is deliberate and switchable:

# English/default scorer: generic commitment/decision/confirmation markers.
CONTEXT_FORGE_ENGLISH_LEXICON=false

# Persona/domain scorer: Cawl/domain lexicon from LEXICON_CONFIG.
CONTEXT_FORGE_PERSONA_LEXICON=true

Combinations are independent: both false means pure relevance, English only enables generic markers, persona only enables the domain lexicon, and both true uses context-forge's additive composite scorer.

Model quality matters for the auto-bootstrap. A small or low-reasoning local model will produce a sparse, poorly-weighted lexicon. If your wired model is weak, generate the file manually instead: the context-forge lexicon bootstrapping guide has the full prompt template in a copy-paste block — substitute your PERSONA, run it through Claude, ChatGPT, or any capable model in a browser, and save the TOML response to your LEXICON_CONFIG path. The bot skips the auto-bootstrap if the file already exists, so a manually generated file is used as-is.

Add or remove entries live with the /lexicon slash command:

/lexicon add term        term:Battle-Sister weight:0.7
/lexicon add affirmation pattern:for the Emperor
/lexicon add negation    pattern:scrapcode
/lexicon remove term        term:Battle-Sister
/lexicon remove affirmation pattern:for the Emperor
/lexicon remove negation    pattern:scrapcode

Changes take effect immediately — no restart needed.

Web search (optional)

Web search is off unless SEARXNG_URL is set. To enable it you need a reachable SearXNG instance with the JSON format enabled — SearXNG disables JSON by default, so you must add json under search.formats in its settings.yml:

search:
  formats:
    - html
    - json

A minimal way to run one alongside Husk in the same Compose project:

  searxng:
    image: searxng/searxng:latest
    restart: unless-stopped
    volumes:
      - ./searxng:/etc/searxng        # put settings.yml here with `json` in search.formats
    # then set SEARXNG_URL=http://searxng:8080/search in .env

See the SearXNG Docker docs for full setup. Leave SEARXNG_URL unset and the bot starts normally with web search disabled.

Discord permissions

The bot's role needs Send Messages, Create Public Threads, Send Messages in Threads, and Use Application Commands, plus Manage Threads so /remember can archive a thread after committing it.

Testing slash commands locally

Global slash commands take up to one hour to propagate across all Discord servers. During local development, set DEV_GUILD_ID in .env to your test server's ID and the bot will register commands to that guild only — changes appear in seconds.

DEV_GUILD_ID=123456789012345678

To get your server ID: Discord → Settings → Advanced → enable Developer Mode, then right-click your test server → Copy Server ID.

Remove DEV_GUILD_ID (or comment it out) before deploying to production. Global commands persist across restarts; guild commands are re-registered every time the bot connects.

Build from source

cargo build --release
# binary at target/release/husk; reads the same env vars

Requires a C toolchain (context-forge bundles SQLite).

License

Apache-2.0. See LICENSE.

About

Husk is a self-hosted Discord bot that chats through a local Ollama model, searches the web via SearXNG, and — unlike most chat bots — remembers across conversations. Cold threads are distilled into durable, searchable long-term memory and relevant past context is recalled into new threads. Everything runs on your own infrastructure;

Resources

License

Stars

1 star

Watchers

0 watching

Forks

Packages

 
 
 

Contributors