LoreBuilder CLI

LoreBuilder is a persistent world you can talk to.

It is a Python CLI that does RAG over Chroma and keeps its chat sessions in SQLite. You can read the code on GitHub or skip ahead and see the proof.

The problem

Most lore tools forget the world the moment you close the session.

I needed lore that stayed put after I walked away. The usual pattern is to dump text into a pile. With luck you can search that pile, and you can talk to it if you paste it back into a chat window, but it is gone the next time you need it. What was missing was a world I could query on purpose, with people inside it I could talk to without rebuilding context by hand every time.

Why I built it

Day to day, I wanted to write a fact and find it later, and I wanted the character I talked with to actually remember what I wrote.

A local CLI was enough for that. I work at a prompt against the world I have stored, and when I need to show someone how the retrieval path works, I can replay a session.

What I built

LoreBuilder is a Python CLI that treats lore like a small product. When you add a fact, it gets embedded and becomes searchable, and the character names in it get pulled out of the text. After that you can talk to one of those characters, and the replies are grounded in the world you just wrote.

You launch it with python app.py, and the work happens at a >> prompt.

Three decisions

These are the three choices I would defend in a design review.

1. World and conversation stay separate.

I keep world lore and conversation in different stores on purpose. Lore goes in Chroma, and chat goes in SQLite with one session per character. Adding lore is a write, while anything the model generates stays a draft until I commit it. I was counting on committed facts outliving a cleared chat, because the failure I care about is a bad turn silently rewriting the world. If yesterday's lore and today's banter live in one blob, you stop knowing which is which.

2. Characters come from the text, not a roster.

On the tape, add_lore introduces Captain Voss, and list_characters then shows Voss and Mary. Those names are pulled from stored lore, including entries that were already in the world (Mary, Ardelune, the council of scholars). Because the cast stays in sync with the writing, I do not maintain a separate character sheet, and a name that is not in the lore is not a character you can chat with. The bet was that the writing should decide who exists.

3. Commands live in a registry instead of a growing REPL.

Commands sit in categories (Lore / Characters / System), and help is generated from the registry. I kept one handler per file and left deprecated modules unregistered. Adding a command means registering it rather than adding another if to a mega-loop, so help stays accurate without me rewriting it by hand. I expected future me (and anyone reading the repo) to judge the CLI by whether help stays true as the list grows. The demo on tape is slightly ahead of the public repo (chat_to / chat_with / reset_chat versus the published chat_as). I leave that gap visible on purpose, because hiding it would make the story cleaner than the code.

Proof

You can replay the same session and watch the retrieval path yourself.

This is a recorded session, and you can replay it with asciinema play -s 4 lorebuilder-demo.cast.

If the video does not play in your browser, you can download the demo.

>> add_lore Captain Voss keeps a brass compass that always points to the last place he broke a promise.
Lore added with id 172161.

>> query_lore compass
Relevant Lore Entries:
Entry 1: Captain Voss keeps a brass compass that always points to the last place he broke a promise.
Entry 2: Mary is on the council of scholars.
Entry 3: The city of Ardelune is ruled by a council of scholars.

>> list_characters
Characters mentioned in world lore (2):
- Mary
- Voss

>> chat_to Voss Why does the compass never point north?
Voss>> Ah, the compass—an artifact of my own making, but with a burden far heavier than mere metal and glass. It never points north because it seeks to remind me of the weight of my oaths.

The rest of the reply stays in character and pulls Ardelune and the council of scholars into the same answer. The model built that answer by retrieving the stored lore, and none of it is a canned line.

The clip ends at a new >> prompt, and the last frames also show exit returning to the shell.

LoreBuilder CLI runtime architecture: Entry through Config and CLI registry into Lore/Chat commands, Core RAG path, LiteLLM, and Storage
The runtime map traces the path from entry to storage. It starts at Entry (python app.py / bootstrap) and runs through Config and the CLI command registry to the Lore and Chat commands. From there it reaches Core (LoreService RAG + LLM write path, NER, fuzzy aliases), then LiteLLM, and it ends at Storage (ChromaDB lore collection, SentenceTransformer embeddings, SQLite chat history).

Why I built it this way

I hold the same boundaries in my iOS work. I want clear ownership and real persistence, and I want a design that a technical peer can judge quickly.

Separate storage means chat cannot rewrite lore, and deriving characters from what is stored keeps the cast honest. The registry is what keeps the CLI maintainable as the command list grows. If I am going to talk to characters, I want them answering from stored facts rather than from a prompt that forgot yesterday's entry.