Skip to content

msalsas/amanuensis

Repository files navigation

amanuensis

A local-first AI persona that writes under a human's veto. It drafts, you approve, and nothing it can't ground gets published.

A local-first pipeline for running an AI persona on Mastodon and Bluesky. The pilot persona, AlexaPavlova, posts as a sarcastic senior Berlin dev with dry takes on tech news and open source.

Everything runs on a local GPU machine. No cloud LLM calls.

See it live: Mastodon · Bluesky — now disclosed as AI, no longer posting.

Telegram approval card: a generated post with its image and per-platform versions, above approve / regenerate / cancel buttons

Every post is reviewed on a phone before it publishes — approve, regenerate text or image, or cancel.

Status: this was an experiment, not an active product. The code is MIT-licensed and works end-to-end — fork it, learn from it, run your own persona. Issues and PRs may not get a response.


What's interesting here

The hard part wasn't generating text, it was stopping the model from fabricating technical detail. The short version: factual-only source summaries, deterministic cleanup before any LLM judgment, a regex pre-screen in front of an LLM grounding check, titles-only memory, and a human approving every post over Telegram.

Full write-up of the design and what broke along the way: write-up.


Quick start

1. Install dependencies

pip install -e ".[dev]"
cp .env.example .env

2. Start local services

LMStudio

  1. Download LMStudio and load any instruction-tuned model (tested with Mistral-7B-Instruct and similar)
  2. Go to Local Server → start the server on port 1234
  3. Set LMSTUDIO_BASE_URL=http://localhost:1234 in .env

SwarmUI

  1. Install SwarmUI and load the image model + 41ex4_p4v10v4 LoRA
  2. Set SWARMUI_BASE_URL=http://localhost:7801 in .env

3. Set up Telegram

  1. Message @BotFather/newbot → copy the token into TELEGRAM_BOT_TOKEN
  2. Message @userinfobot → copy your numeric ID into TELEGRAM_CHAT_ID
  3. Send any message to your new bot so it can message you back

4. Verify with a dry run

python main_batch.py --dry-run

This fetches real stories and generates posts + images using your local services. Nothing is written to any database and nothing is sent to Telegram. If this prints 8 posts, your local stack is working.

5. Run for real

Add your social credentials to .env (Mastodon and/or Bluesky — both optional, see table below), then open three terminals:

# Terminal 1 — generate today's posts and send to Telegram for approval
python main_batch.py

# Terminal 2 — listen for your Telegram approvals
python main_telegram_listener.py

# Terminal 3 — publish approved posts at their scheduled time
python main_dispatcher.py

Approve posts in Telegram. The dispatcher picks them up and publishes. Done.

For long-running setups, run the three persistent processes under systemd or supervisord so they survive reboots. main_batch.py is a one-shot script — run it via cron or manually each day.


Image model

The persona's images come from a custom LoRA trained on top of Juggernaut XL "Ragnarok" (SDXL), generated through SwarmUI. The LoRA is not included in this repo — it only contains trained deltas, not the base model.

  • LoRA: download from Hugging Face — msalsas/alexa-lora. Trigger word 41ex4_p4v10v4, weight 0.3, generated at 768×1024.
  • Base model: Juggernaut XL "Ragnarok" by RunDiffusion — get it separately, not distributed here.
  • The LoRA was trained on a fully synthetic dataset (images generated with Juggernaut XL); the character is not based on any real person.

To run the alexa profile with images you need both: load Juggernaut XL in SwarmUI and apply this LoRA. See the model card on Hugging Face for the exact prompt format.


Architecture

Adapters (HN, Lobsters, BearBlog, AskHN)
    └── Curator (dedup by URL + title + subreddit, banned-topic filter)
        └── BatchFactory
            ├── Brain (LMStudio → post text + image prompt)
            └── ImageService (SwarmUI → PNG via LoRA)
                └── Scheduler (UTC time windows with jitter)
                    └── QueueService (SQLite)
                        └── TelegramNotifier (photo + approval keyboard)
                            ├── MastodonPublisher (on APPROVE)
                            └── BlueskyPublisher  (on APPROVE)

Reply pipeline (runs in parallel):
    ReplyListener (random poll, 30 min – 2 h per post)
        ├── MastodonCommentFetcher / BlueskyCommentFetcher
        ├── Brain.evaluate_relevance() → skip or draft reply
        ├── Brain.generate_reply()
        └── TelegramNotifier (REPLY_APPROVE / REPLY_CANCEL)
            ├── MastodonPublisher.publish_reply() (on APPROVE)
            └── BlueskyPublisher.publish_reply()  (on APPROVE)

Requirements

  • Python 3.10+
  • LMStudio running locally (OpenAI-compatible API)
  • SwarmUI running locally with the 41ex4_p4v10v4 LoRA loaded
  • A Telegram bot token + chat ID (for the approval workflow)
  • Mastodon and/or Bluesky credentials (for publishing)
  • An OpenWeatherMap API key (free tier, for ambient context in prompts)

Setup

pip install -e ".[dev]"
cp .env.example .env
# Fill in .env with your tokens and service URLs

.env reference

Variable Description
LMSTUDIO_BASE_URL LMStudio API base, e.g. http://localhost:1234
SWARMUI_BASE_URL SwarmUI base, e.g. http://localhost:7801
TELEGRAM_BOT_TOKEN Bot token from @BotFather
TELEGRAM_CHAT_ID Your personal chat ID (use @userinfobot to find it)
ACTIVE_PROFILE Profile slug, default alexa
WEATHER_API_KEY OpenWeatherMap key (free)
ALEXA_MASTODON_ACCESS_TOKEN Mastodon token for the alexa profile
ALEXA_BLUESKY_APP_PASSWORD Bluesky app password for the alexa profile
MASTODON_ACCESS_TOKEN Global fallback Mastodon token (used if no prefixed var found)
MASTODON_INSTANCE_URL Fallback — prefer setting mastodon_instance_url in identity.yaml
BLUESKY_HANDLE Fallback — prefer setting bluesky_handle in identity.yaml
BLUESKY_APP_PASSWORD Global fallback Bluesky app password

Daily workflow

  1. main_batch.py generates 8 posts + images and sends each to Telegram for approval (run via cron).
  2. You APPROVE / REGEN / CANCEL on your phone.
  3. main_dispatcher.py publishes approved posts at their scheduled time (persistent loop).
  4. main_reply_listener.py polls published posts, drafts replies to incoming comments, and sends them back through Telegram for approval (persistent loop).

The three persistent loops (dispatcher, telegram_listener, reply_listener) belong under systemd or supervisord.

Preview without writing anything

python main_batch.py --dry-run

Fetches real stories, generates text and images via local services, prints everything to stdout. No DB writes, no Telegram, no side effects.

python main_dispatcher.py --dry-run

Logs what would be published for each approved post without making any social API calls.

Entry points

Script Purpose
main_batch.py Run once daily — fetches stories, generates posts, saves to memory + queue, notifies Telegram
main_dispatcher.py Persistent loop — publishes APPROVED posts at their scheduled UTC time
main_telegram_listener.py Persistent loop — handles APPROVE / REGEN / CANCEL / REPLY_APPROVE / REPLY_CANCEL callbacks
main_reply_listener.py Persistent loop — polls published posts for new comments, drafts replies, sends for Telegram approval

Slot distribution

Each daily batch generates 8 posts by default (profiles/alexa/identity.yaml):

Category Count Sources
TECH 5 Hacker News, Lobste.rs, BearBlog
PERSONAL 2 Ask HN discussion threads
RAW 1 Internally generated (no source story)

Adding a new profile

mkdir -p profiles/marco/prompts profiles/marco/generated
cp profiles/alexa/identity.yaml profiles/marco/
cp profiles/alexa/prompts/*.j2 profiles/marco/prompts/
# Edit profiles/marco/identity.yaml and the .j2 templates

# Run with the new profile
ACTIVE_PROFILE=marco python main_batch.py --dry-run

The directory name (slug) must match ACTIVE_PROFILE. It is separate from the name field in identity.yaml ("alexa" vs "AlexaPavlova").

Project structure

config/
  schemas.py              # RawStory, Post, ProfileConfig (Pydantic v2)
  settings.py             # Pydantic-settings from .env
core/
  brain.py                # LMStudio calls, text cleaning, truncation
  curator.py              # Dedup by URL + title + subreddit; banned-topic filter
  factory.py              # Orchestrates adapters → curator → brain → image
  scheduler.py            # UTC-aware time windows with random jitter
  profile_loader.py       # Loads profiles/{slug}/identity.yaml
adapters/
  hn_adapter.py           # Hacker News top stories (TECH)
  lobsters_adapter.py     # Lobste.rs hottest (TECH)
  bearblog_adapter.py     # BearBlog Discover RSS (TECH)
  ask_hn_adapter.py       # Ask HN discussion posts via Algolia (PERSONAL)
  reddit_adapter.py       # Reddit (requires OAuth2 credentials; not used by default)
services/
  memory_service.py       # Per-profile SQLite post history + platform IDs
  queue_service.py        # Approval queue (SQLite)
  image_gen.py            # SwarmUI REST client
  notification.py         # Telegram sendPhoto/sendMessage, approval keyboards, long-poll
  comment_service.py      # comments.sqlite: comments, pending_replies, poll_state
social/
  mastodon_publisher.py         # Mastodon REST — publish + publish_reply
  bluesky_publisher.py          # AT Protocol XRPC — publish + publish_reply
  mastodon_comment_fetcher.py   # Fetches replies via /api/v1/statuses/{id}/context
  bluesky_comment_fetcher.py    # Fetches replies via app.bsky.feed.getPostThread
profiles/
  alexa/
    identity.yaml         # Persona config: slots, sources, banned topics, image model
    prompts/*.j2          # Jinja2 templates: system prompt + per-mood + per-platform
    generated/            # Output images (slot_NNN.png)
    memory.sqlite         # Post history injected as context into each prompt
    queue.sqlite          # Approval queue
    comments.sqlite       # Comments, pending replies, poll state

Tests

pytest tests/ -v      # 312 tests, all mocked — no live services required

All HTTP calls (LMStudio, SwarmUI, Telegram, HN, Algolia, etc.) are mocked with respx.

About

A local-first AI persona for Mastodon and Bluesky — drafts by machine, approved by a human, nothing it can't ground gets published.

Topics

Resources

License

Stars

Watchers

Forks

Contributors