46 KiB
REMEDIATION_PLAN.md — Vibe Bot "Gold Quality" Roadmap
Date of audit: 2026-08-17 Status: Approved for execution (maintainer confirmed scope decisions in §1)
This document is written for a coding agent. Work through it top to bottom: §1 (scope), then §2 (the audit, for reference), then §3 (the phases, in order).
0. Agent Protocol
Commands (run from the repo root)
uv sync --extra dev # setup (plain `uv sync` already includes ruff+pyright)
uv run pytest vibe_bot/tests/ -v # tests (must run from repo root)
uv run ruff check vibe_bot/ # lint
uv run mypy vibe_bot/ # strict types
uv run pyright vibe_bot/ # strict types
uv run black --check vibe_bot/ # format check
uv run python -m vibe_bot.mainlogs into the real Discord server. Never run it as a smoke test.- A repo-root
.envwith placeholder values is required for imports/tests (see AGENTS.md). - If
uv run <tool>fails withModuleNotFoundError/bad interpreter:rm -rf .venv && uv sync --extra dev. - Baseline at audit time: 156/156 tests pass; all four static gates pass. Two tests
(
test_llama_wrapper.py::test_chat_completion_think,::test_chat_completion_instruct) make live network calls and only pass whenCHAT_ENDPOINTis reachable.
Per-task rules
- Work one task at a time, in the order given within a phase.
- After each task, run the full gate set (pytest + ruff + mypy + pyright + black). All must pass before starting the next task.
- Add/adjust tests as specified in each task. Tests are the safety net — do not delete existing assertions to make a gate pass.
- Check off the task's
[ ]box in this file when its "Done when" criteria are met. - Do not commit unless the maintainer asks. (Suggested: one commit per task, message
P1-T1: <summary>.) - Line numbers cited in §2/§3 are from the audit date and will drift as you edit — treat them as orientation, then locate by symbol name.
Severity key
Critical = bot harms itself/availability · High = data corruption or dead-end UX · Medium = maintainability/cost · Low = polish.
1. Scope Decisions (confirmed with maintainer — do not revisit)
| Topic | Decision | Consequence for this plan |
|---|---|---|
| Multi-user privacy | Private, trusted group. Shared chat history is a feature. Anyone may overwrite any bot; anyone may wipe history. | Findings 1.1, 1.2, 5.1 (authorization) are accepted by design. Only scoped residuals remain: fix the misleading "It may already exist" message, document shared-memory semantics in README, add cost-abuse cooldowns. |
| Structural change | Full restructure approved (§3 Phase 2). | main.py is split into commands/ + services/ + prompts.py; llama_wrapper renamed. |
TTS model files in git (325MB .onnx + 26MB .bin) |
Leave as-is. | Finding 6.3 dropped; no LFS/build-download work. |
2. PART I — THE AUDIT REPORT
State of the Codebase
~5,900 lines across 8 source files + 6 test files. Hygiene is good (strict mypy+pyright clean, ruff/black clean, 156 green tests, env-based secrets, fully parameterized SQL, solid docstrings). The core problems are structural: an async runtime doing almost everything synchronously on the event loop, a data-retention logic bug, unguarded failure paths, and a 1,276-line god module mixing command handlers, LLM orchestration, and 3 KB of prompt constants.
Tally: 2 Critical · 6 High · 9 Medium · 8 Low (some Highs downgraded to accepted-by-design in §1).
Findings
Dimension 1: Code Quality & Correctness
- 1.1 Sev: High (→ accepted, scoped). Custom-bot namespace is global;
INSERT OR REPLACE(database.pycreate_custom_bot) lets any user silently overwrite another's bot; user is told "It may already exist" when it was actually overwritten. → Residual: message must say "updated/replaced". - 1.2 Sev: High (→ accepted).
search_similar_messagesis unscoped; RAG context can include other users' conversations. Intended shared-memory behavior. → Residual: document explicitly in README; do not "fix". - 1.3 Sev: High.
_cleanup_old_messages(database.py): the second DELETE's subquery runs after the first delete mutatedchat_messages, so it removes embeddings for the next-oldest live messages and never the just-deleted ones. Unbounded orphan growth inmessage_embeddings- lost RAG recall.
- 1.4 Sev: Medium. Magic string
WHERE username != 'vibe-bot'(database.pyinsearch_similar_messagesandget_user_history). Silently breaks if the bot's Discord username differs; bot rows leak into user search results. - 1.5 Sev: Medium.
llama_wrapper.embedding():resp.json()sits outside the try block; a non-JSON 2xx body raisesJSONDecodeErrorout ofget_conversation_context, which is called outsidehandle_chat's try → unhandledCommandError, user left hanging. - 1.6 Sev: High. Doodlebob/retcon failure holes:
image_generationcatches onlyopenai.APIConnectionError(4xx/5xx propagate, no user feedback);!retconhas no attachment-count check (empty image list → API error), and emptyimage_b64decodes tob""and an empty file is uploaded with a success message. - 1.7 Sev: Low.
talkforme: int limit validated after the announcement message; the 20-cap never announced; first reply sent unchunked (can exceed Discord's 2000-char limit and abort the conversation). - 1.8 Sev: Low.
!speak--voiceparse usesmessage.rsplit("--voice ", 1); text containing the flag mid-string is corrupted. - 1.9 Sev: Low.
get_recent_messagesandget_custom_botannotatedatetimebut SQLite returnsstr(no row factory). - 1.10 Sev: Low.
handle_chat/_speak_with_botignoreadd_message'sFalsereturn — silent persistence failure.
Dimension 2: Architecture & Structure
- 2.1 Sev: Critical. Synchronous work on the asyncio event loop.
chat_completion_with_toolsisasync defbut wraps a sync OpenAI client;doodlebob/retcon/talkforme/speak/handle_chatcall sync LLM (60s timeout), image (300s timeout), embedding, TTS (CPU), andrequests.getdirectly inside async handlers. One slow!doodlebobcan miss Discord heartbeats and drop the whole bot offline for every user. - 2.2 Sev: High.
main.pyis a 1,276-line god module: command handlers + LLM orchestration + prompt constants + chunking + TTS wiring;bot = commands.Bot(...)at import time makes the module un-importable without full env. - 2.3 Sev: Medium.
config.pyraisesRuntimeErrorat import if env vars are missing;logging.basicConfigis called in three modules (config.py,database.py,main.py) — first wins, rest dead. - 2.4 Sev: Medium.
on_messageconstructs aCustomBotManager(connection + DDL) and runs a fulllist_custom_bots()SELECT on every message, including non-!chatter. - 2.5 Sev: Medium. Inconsistent lifetimes:
get_database()singleton vs per-commandCustomBotManager();ChatDatabase.client(OpenAI) is created but never used (dead code — embeddings go through rawrequests);llama_wrappercreates a newopenai.OpenAIper call (no connection reuse). - 2.6 Sev: Low. Dead config:
COMPLETION_ENDPOINT/_KEY/_MODELare required at import but used nowhere;EMBEDDING_DIMENSIONunused. - 2.7 Sev: Medium. Tool-calling scaffolding (tool-def dict + executor + notifier closures)
copy-pasted between
handle_chatand_speak_with_bot; adding a tool means editing 3+ places.
Dimension 3: Readability & Maintainability
- 3.1 Sev: Medium. Three near-identical chat functions in
llama_wrapper.py(chat_completion/chat_completion_instruct/chat_completion_with_history) differing only byseed=-1and message shape;chat_completionhas zero production callers. - 3.2 Sev: Low. Misleading names:
llama_wrapper(no Llama inside);get_channel_members_implactually returns guild-wide members (Discord has no per-channel membership) and its docstring overpromises;bot_nameloop var inon_messageshadows the modulebot. - 3.3 Sev: Medium. The 1900-char chunking while-loop is copy-pasted five times in
main.py(debug members/whoami/tools, voices, history); two 1000-char mid-word splitters (handle_chat, talkforme) can break words and multi-codepoint emoji. - 3.4 Sev: Medium. Log noise + PII: INFO-level logs per cursor operation in
database.py;logger.info("Chat prompts: %s", prompts)logs full conversation + user profile every message. - 3.5 Sev: Low. 11
type: ignoresuppressions; most hide thattool_call.functionisFunction | Noneand should be narrowed with a guard.
Dimension 4: Documentation
- 4.1 Sev: Medium. README stale: file tree missing
tools.py,build.sh,vibe-bot.container,.gitea/,AGENTS.md; lists.envas a repo file;!debugand!lobotomizeundocumented; RAG description omits the shared-memory (cross-user) behavior. - 4.2 Sev: Medium.
test_config.pyhardcodessys.path.insert(0, "/var/home/ducoterra/Projects/vibe_discord_bots")(two places); passes only via cwd fallback. - 4.3 Sev: Low. Command reference exists in three places (README, docstrings, in-chat
!debug) with no single source of truth.
Dimension 5: Robustness & Error Handling
- 5.1 Sev: High (→ accepted, scoped). No command authorization or rate limiting anywhere.
Accepted for a trusted group; residual: per-user cooldowns on paid commands
(
!doodlebob,!talkforme,!speak) as a cost-abuse guard. - 5.2 Sev: Medium. No input size bounds: personality (min only),
!speaktext,!doodlebobprompt,!talkformetopic. Unbounded personality is also a prompt-injection/cost surface (concatenated verbatim into system prompts). - 5.3 Sev: Medium.
!retconfetches attachment URLs with no allowlist, no size cap, blockingrequests.get. - 5.4 Sev: Medium. No SQLite
WAL/busy_timeout— required once 2.1 is fixed with threads, or concurrent writes fail with "database is locked". - 5.5 Sev: Low. Swallowed failures with silent degradation: TTS skips failing chunks (partial
audio, no user notice);
get_channel_members_implreturns an error string the LLM treats as real roster data.
Dimension 6: Performance & Security
- 6.1 Sev: Medium. RAG retrieval is O(N): loads all embeddings (up to 1000 × 8 KB) into
Python per query, plus one extra
SELECTper candidate for the_responsejoin (N+1). - 6.2 Sev: Medium. Redundant embedding API calls: ~3 per turn, and the bot response embedding is stored but never retrieved (search excludes bot rows). ~⅓ of embedding spend wasted.
- 6.3 Sev: Medium (→ dropped per §1). 351 MB of model binaries committed to git.
- 6.4 Sev: Medium. Dependency hygiene:
mypy(two conflicting versions: main ≥2.1.0, dev extra ≥1.17.0),pytest,pytest-env,types-requestsare runtime deps shipped into the container;[tool.uv] required-environmentspins the lock to linux/x86_64. - 6.5 Sev: Low. CI builds/pushes with no lint/test gate and only on main/release (no PR signal); container runs as root, no healthcheck.
- 6.6 Security positives (no action): no hardcoded secrets (env only,
.env*gitignored, registry creds via Gitea secrets); all SQL parameterized; no XSS surface. Residuals covered by 1.1/1.2 (accepted), 3.4 (PII logging), 5.3 (fetch allowlist).
3. PART II — THE REMEDIATION ROADMAP
Phase order is by dependency and risk: Phase 1 removes the ways the bot harms itself (behavior-preserving fixes), Phase 2 is the restructure on the stabilized codebase, Phase 3 makes the dev loop and CI trustworthy, Phase 4 optimizes the final shapes.
PHASE 1 — Stability & Foundation
Goal: The bot can no longer freeze globally, corrupt retention data, or strand users in unhandled error states. Zero structural changes; behavior-preserving fixes behind the existing test suite.
P1-T1 — SQLite concurrency preparation (finding 5.4)
Prerequisite for P1-T2; land it first.
- File:
vibe_bot/database.py→ChatDatabase._initialize_database(andCustomBotManager._initialize_custom_bots_tableif you want both tables covered). - After connecting, execute:
(WAL is persistent; busy_timeout is per-connection, so set it wherever connections are opened — introduce a small
PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;_connect()helper returning a configured connection and use it in every method instead of rawsqlite3.connect.) - Tests: none required (pragma-only), but keep the suite green.
- Done when: [ ] all
sqlite3.connectcalls indatabase.pygo through the helper; gates green.
P1-T2 — Unblock the event loop (finding 2.1 — THE critical fix)
- Files:
vibe_bot/main.py,vibe_bot/llama_wrapper.py. - Strategy:
asyncio.to_threadfirst (lowest risk, behavior-preserving). True async clients come in Phase 2.- In every async command/handler, wrap blocking calls:
handle_chat:db.get_conversation_context(...),llama_wrapper.chat_completion_with_tools(...)(it isasync defbut its inner client calls are sync — either keepawaiting it after converting its inner.create()calls to run in a thread, or simplest: changechat_completion_with_toolsto a plain sync function andawait asyncio.to_thread(...)it),db.add_message(...)(×2).doodlebob:select_image_layout,chat_completion_instruct(×2: prompt + verify),image_generation.retcon: therequests.getdownload (remove the# noqa: ASYNC210once async viato_thread) andllama_wrapper.image_edit.talkforme: everyllama_wrapper.chat_completion_with_historycall._speak_with_bot/_speak_plain:db.get_conversation_context,chat_completion_with_tools,db.add_message,engine.generate_audio(CPU-bound TTS).
- Keep all function signatures otherwise identical.
- In every async command/handler, wrap blocking calls:
- Tests:
- New test: while a mocked 0.5s LLM call is in flight inside
handle_chat(ordoodlebob), a concurrentasyncio.create_tasksleep completes — i.e. prove the loop is not blocked (assert withasyncio.wait/ timing: the concurrent task's elapsed time < the mocked call time by a wide margin, e.g. concurrent task finishes while the call is still running).
- New test: while a mocked 0.5s LLM call is in flight inside
- Done when: [ ] no direct blocking call (openai sync client,
requests, sqlite, TTS) executes on the event loop thread; new concurrency test passes; gates green.
P1-T3 — Fix _cleanup_old_messages (finding 1.3)
- File:
vibe_bot/database.py→ChatDatabase._cleanup_old_messages. - Rewrite: first
SELECT id, message_id FROM chat_messages ORDER BY timestamp ASC LIMIT ?(where? = count - MAX_HISTORY_MESSAGES); derive the affected message_ids including their_responsecompanions in Python (midandf"{mid}_response"); thenDELETE FROM chat_messages WHERE id IN (...)andDELETE FROM message_embeddings WHERE message_id IN (...)— both from the captured set, in the same transaction (already the case: caller commits). - Tests (in
test_database.py):- Insert
MAX_HISTORY_MESSAGES + 3user+response rows (with embeddings). Call the internal cleanup path viaadd_message. Assert: exactly 3 oldest user rows gone; their embeddings gone; their_responseembeddings gone; the next oldest surviving user row still has its embedding;SELECT COUNT(*) FROM message_embeddings== rows that have a live message.
- Insert
- Done when: [ ] regression test passes; no orphaned embeddings possible after cleanup; gates green.
P1-T4 — Guard embedding() JSON parsing (finding 1.5)
- File:
vibe_bot/llama_wrapper.py→embedding. - Move
data = resp.json()inside thetryand catchValueError(JSONDecodeError's base) alongsiderequests.RequestException→ return[]. - Test: mock
requests.postreturning a 200 with body<html>rate limited</html>→ assertembedding(...) == []and no exception. - Done when: [ ] non-JSON 2xx can't escape
embedding(); test passes; gates green.
P1-T5 — Close doodlebob/retcon failure holes (finding 1.6, part of 5.3)
- Files:
vibe_bot/llama_wrapper.py(image_generation),vibe_bot/main.py(doodlebob,retcon). -
- In
image_generationandimage_edit, catchopenai.OpenAIError(coversAPIStatusError,APIConnectionError,APITimeoutError) → return""(log the error). Callers already handle"".
- In
-
retcon: before callingimage_edit, ifimage_data_listis empty → send "Please attach an image to edit." and return. Afterimage_edit, if result is""→ send "Failed to edit the image." and return. Wrapbase64.b64decodein try/except (binascii.Error) with a user-facing failure message.
-
retcondownload: restrict to Discord CDN hosts (discordcdn.com,discordapp.com,discord.com,discord.media) — reject anything else with a warning log; cap download at 8 MB (stream withstream=True, abort ifContent-Lengthor accumulated bytes exceed cap).
-
doodlebob: afterselect_image_layout/prompt/verify, the existing== ""checks remain; no extra try needed once wrappers swallowOpenAIError.
- Tests:
- retcon with zero attachments → friendly message,
image_editnot called. - retcon with
image_editreturning""→ failure message, no file sent. - retcon with non-Discord attachment URL → not downloaded.
image_generationwith mockedopenai.APIStatusError→ returns"".
- retcon with zero attachments → friendly message,
- Done when: [ ] all four tests pass; no
!retcon/!doodlebobpath ends without a user message on API failure; gates green.
P1-T6 — Replace the 'vibe-bot' magic string with a role column (finding 1.4)
- File:
vibe_bot/database.py. -
- In
_initialize_database, after the existingbot_namemigration pattern, add: ifrolecolumn missing →ALTER TABLE chat_messages ADD COLUMN role TEXT. Backfill in the same migration:UPDATE chat_messages SET role = 'assistant' WHERE message_id LIKE '%_response' AND role IS NULL;UPDATE chat_messages SET role = 'user' WHERE role IS NULL;
- In
-
add_message: acceptrole: str = "user"and store it (callers inmain.pypassrole="assistant"for the bot-response rows).
-
search_similar_messages:WHERE cm.username != 'vibe-bot'→WHERE cm.role = 'user'.get_user_history: same replacement.
-
main.py: update bothadd_messagecall sites (handle_chat, _speak_with_bot) to pass role.
- Tests:
- Fresh DB: add user + response rows, assert
get_user_historyexcludes responses andsearch_similar_messagesonly matches user rows. - Migration: create a legacy row (no role,
message_idending_response) by inserting directly via sqlite, runChatDatabase(), assert role backfilled toassistant.
- Fresh DB: add user + response rows, assert
- Done when: [ ] no
vibe-botstring remains indatabase.py(grep); tests pass; gates green.
P1-T7 — Fix talkforme ordering and chunking (finding 1.7)
- File:
vibe_bot/main.py→talkforme. -
- Move
int(limit)parsing/validation before the "is going to talk" announcement.
- Move
-
- Announce the effective cap:
for {min(limit, talk_limit)} replies.
- Announce the effective cap:
-
- Send the first reply through the same 1000-char loop used for subsequent replies
(extract a local helper now; the global
split_messagearrives in Phase 2).
- Send the first reply through the same 1000-char loop used for subsequent replies
(extract a local helper now; the global
- Tests:
- non-integer limit → usage/error message, no announcement message sent first.
- first reply > 1000 chars → sent in chunks (assert
ctx.sendcall count).
- Done when: [ ] both tests pass; gates green.
P1-T8 — Input size bounds (finding 5.2)
- File:
vibe_bot/main.py(constants nearMIN_BOT_NAME_LENGTH). - Add and enforce with friendly rejection messages:
MAX_PERSONALITY_LENGTH = 1000incustom_bot.MAX_SPEAK_LENGTH = 5000inspeak(check the text part after--voiceparsing).MAX_IMAGE_PROMPT_LENGTH = 2000indoodlebobandretcon.MAX_TOPIC_LENGTH = 500intalkforme.
- Tests: one per bound — over-limit input → rejection message, no LLM/DB call.
- Done when: [ ] four tests pass; gates green.
P1-T9 — Cost-abuse cooldowns (scoped finding 5.1)
- File:
vibe_bot/main.py. - Add
@commands.cooldown(rate, per, type=commands.BucketType.user)(pick sane values, e.g. doodlebob 1/60s, talkforme 1/120s, speak 3/30s) to the three paid commands. - Handle
commands.MaxConcurrency/CooldownRetrygracefully: register aon_command_errorhandler that sends "You're using that too quickly, try again in Ns." forcommands.CooldownRaise. - Test: mock a second immediate invocation → cooldown message, LLM not called.
- Done when: [ ] test passes; gates green.
P1-T10 — Honest overwrite message (scoped finding 1.1)
- File:
vibe_bot/database.py(create_custom_bot),vibe_bot/main.py(custom_bot). - Make
create_custom_botdistinguish: check existence first (SELECT 1 ... WHERE bot_name=?) → return"created" | "replaced" | False.custom_botcommand: on"replaced"send "Custom bot 'X' already existed and has been replaced." (Shared namespace is intended.) - Test: create same name twice → second call reports replaced; both succeed.
- Done when: [ ] test passes; no "It may already exist" string remains; gates green.
P1-T11 — Stop silent TTS partial audio (scoped finding 5.5)
- Files:
vibe_bot/tts.py,vibe_bot/main.py. generate_audio: track failed-chunk count; if any chunk failed, log a warning and either (a) attach a note — simplest: return the audio and let the caller detect via a newpartial: boolattribute on a small result dataclass, or (b) raiseValueErrorlisting the failed chunk count and let the command send "Some audio chunks failed; audio may be incomplete." Choose (b) if the maintainer prefers fail-fast — default to (a): keep the audio, and have the command send a short warning line when partial.- Test: mock
process_chunk_sequentialto fail on chunk 1 of 2 → audio still produced, warning sent (or ValueError raised, matching the chosen behavior). - Done when: [ ] test passes; gates green.
Phase 1 verification & success criteria
Run the full gate set, then:
- Concurrency proof: a slow (mocked ≥60s) LLM call no longer blocks a concurrent task
(P1-T2 test) — the bot survives a slow
!doodlebobglobally. - No orphaned embeddings and correct retention after cleanup (P1-T3 test).
- No command path ends without a user-facing message on API failure (mock 4xx/5xx tests: doodlebob, retcon, handle_chat).
- Full suite green:
uv run pytest vibe_bot/tests/ -v(156 original + new tests). ruff/mypy/pyright/black --checkall pass.
PHASE 2 — Structural Integrity (full restructure)
Goal: Kill the god module and duplicated orchestration; commands become thin adapters over services; lifetimes explicit; true async clients. Do this after Phase 1 gates are green. Order matters: utilities and clients first (P2-T1/T2), services next (P2-T3…T5), command split after (P2-T6), DB layer last (P2-T7) because services depend on it.
P2-T1 — textutil.split_message (finding 3.3 — do first, Phase 1 code already uses it indirectly)
- New file:
vibe_bot/textutil.py. def split_message(text: str, limit: int = 1900) -> list[str]:- Split on newlines first (never break a line mid-text when avoidable); if a single line
exceeds
limit, split it codepoint-safely (Pythonstrslicing is already codepoint-safe, but never split inside a grapheme cluster — useregexpackage's\Xonly if you add the dep; otherwise plain slicing atlimitis acceptable and matches today's behavior — document the choice). - Preserve existing behavior for the 5 call sites; replace all five 1900-char loops in
main.py(debug members/whoami/tools, voices, history) and both 1000-char splitters (handle_chat,talkforme→limit=1000).
- Split on newlines first (never break a line mid-text when avoidable); if a single line
exceeds
- Tests: exact-limit text; text with no newlines; emoji at the split boundary (assert no lone
surrogate/combining char corruption by round-tripping
"".join(chunks) == original); multi-paragraph text respects newline preference. - Done when: [ ]
main.pycontains zero hand-rolled chunking loops;"".join(split_message(t)) == tproperty test passes for a corpus including emoji; gates green.
P2-T2 — llm_client.py: one core, shared async clients (findings 2.1-completion, 3.1, 2.5, 3.2, 3.5)
- New file:
vibe_bot/llm_client.py(deletellama_wrapper.py; update all imports). -
- Shared clients: module-level lazy singletons
get_chat_client(),get_image_gen_client(),get_image_edit_client(),get_embedding_http_session()returningopenai.AsyncOpenAI(base_url=..., api_key=..., max_retries=0 where currently set)instances built once. Delete the per-callopenai.OpenAI(...)constructions.
- Also delete
ChatDatabase.client(dead code) and itsopenaiimport indatabase.py; dropdb.client.close()from thechat_dbtest fixture.
- Shared clients: module-level lazy singletons
-
- One core function:
async def chat_complete(messages: list[ChatCompletionMessageParam], *, model: str, max_tokens: int, seed: int | None = None, tools: ... | None = None, tool_executor: ... | None = None, tool_call_notifier: ... | None = None, max_tool_rounds: int = 5) -> str— contains the (now async) tool loop currently inchat_completion_with_tools. Replace the# type: ignore[union-attr]cluster with a real guard:if tool_call.function is None: continue.
- Keep thin compat wrappers only if it reduces churn:
chat_completion_instruct(system_prompt, user_prompt, ...)andchat_completion_with_history(system_prompt, prompts, ...)become 3-line adapters overchat_complete. Deletechat_completion(test-only caller — port its test tochat_complete).
- One core function:
-
image_generation/image_edit: async (await client.images.generate/edit), catchopenai.OpenAIError→""(carries P1-T5 behavior).
-
embedding: async viahttpx.AsyncClient(addhttpxto deps) or keeprequestsinsideasyncio.to_thread. Preferhttpx.AsyncClientwith a shared session; keep the OpenAI-style and Ollama-style response handling and P1-T4's JSON guard.
-
ToolRegistry(finding 2.7): small class inllm_client.py(ortools.py):
register(name, description, args_schema, impl)— seed it withget_channel_members(schema) →get_channel_members_impl(impl).to_openai_tools() -> list[dict]andasync execute(name, args, channel) -> str.- The notifier ("is looking at the channel members...") becomes a registry-level callback.
- Tests: port all
test_llama_wrapper.pytests totest_llm_client.py(rename module + async awaits); add: client singleton identity test (same object across calls); tool registry execute/dispatch test incl. unknown-tool path; guard test forfunction is Nonetool call. - Done when: [ ]
llama_wrapper.pydeleted; grep shows zeroopenai.OpenAI(per-call constructions; zerotype: ignorein the new client (except the twoimport-untypedintts.py); gates green.
P2-T3 — prompts.py (part of finding 2.2)
- New file:
vibe_bot/prompts.py— move all prompt constants frommain.py:IMAGE_LAYOUT_SYSTEM_PROMPT,IMAGE_PROMPT_SYSTEM_PROMPT_TEMPLATE,IMAGE_PROMPT_VERIFY_SYSTEM_PROMPT, plus the "Keep your responses under 2-3 sentences." suffix and "User Information:" assembly as a helperbuild_system_prompt(personality, user_info) -> str(currently duplicated string inhandle_chatand_speak_with_bot). - Move the layout parsing (
parse_image_layout,VALID_IMAGE_LAYOUTS,DEFAULT_IMAGE_LAYOUT,LAYOUT_SIZES) here too. - Tests: existing prompt-related tests in
test_main.pyre-point atvibe_bot.prompts; add a test thatbuild_system_promptoutput contains personality + user info + length hint. - Done when: [ ]
main.py(pre-split) contains no prompt text; gates green.
P2-T4 — services/ (findings 2.2, 2.4, 2.5, 6.2)
New package vibe_bot/services/ with a dataclass BotContext (or pass explicit args):
services/chat_service.py—ChatService.handle(ctx, bot_name, message, system_prompt, response_prefix): RAG context → prompts →chat_completewith tools (viaToolRegistry) → persist (user rowrole="user", response rowrole="assistant"; log a warning whenadd_messagereturns False — finding 1.10) → chunked reply viasplit_message. Do not embed bot responses (finding 6.2): only user messages get embeddings — remove the response-row embedding by not calling the embedding path for assistant rows (add_message(role="assistant", embed=False)parameter, or a separate non-embedding insert).services/image_service.py—ImageService.generate(ctx, message): layout select (max_tokens=2— Phase 4 item, do it here since we're touching it anyway) → prompt engineering → verify → ETA message → generate → record time → send file.ImageService.edit(ctx, message): the P1-T5-hardened retcon flow.services/speech_service.py—SpeechService.speak(ctx, message): voice-flag parse (replacersplitwith a trailing-anchored regex:r"^(?P<text>.*)\s+--voice\s+(?P<voice>\S+)$"— fixes finding 1.8), voice validation, bot-vs-plain dispatch, TTS viaasyncio.to_thread, language lookup via a precomputedVOICE_LANGUAGES: dict[str, str]built fromVOICES_LISTat import (replaces the per-call list scan).services/conversation_service.py—ConversationService.run(ctx, bot1, bot2, limit, topic): the Phase-1-fixed talkforme loop, usingsplit_message(1000).- Each service: constructor takes the llm client(s),
ChatDatabase,CustomBotManager,TTSEngine | None,ToolRegistry(dependency injection; no module-level singletons inside). - Tests: port
test_main.pycommand tests to service-level tests where the logic moved; keep a thin command-layer test per command proving wiring (mock service, assert called with parsed args). Add: voice-regex tests (flag mid-text is preserved; trailing flag extracted; missing flag → None). - Done when: [ ] four service modules exist; no service imports
discordexcept forctx/channeltype hints underTYPE_CHECKING; gates green.
P2-T5 — app.py + composition root (findings 2.2, 2.3, 2.4, 2.5)
- New file:
vibe_bot/app.py:configure_logging()— the onlylogging.basicConfigcall in the codebase (remove the other two fromconfig.pyanddatabase.py).create_app() -> Appdataclass: buildsChatDatabase,CustomBotManager(singleton — no more per-message construction), LLM clients,ToolRegistry,TTSEngine | None(keep the tolerant init-or-None behavior), and the four services.- Bot construction moves here:
build_bot(app) -> commands.Bot(intents,on_ready, command registration,on_message,on_command_error). on_message: skip bots; if notmessage.content.startswith("!"): returnbefore any DB/manager touch (finding 2.4); then look up the bot-name cache.- Bot-name cache:
app.custom_bots: dict[str, tuple[prompt, creator]]rebuilt fromlist_custom_bots(); invalidated bycustom_bot/delete_custom_botcommands (callapp.invalidate_bot_cache()).
vibe_bot/main.pyshrinks to:def main() -> None: validate_config(); configure_logging(); app = create_app(); build_bot(app).run(DISCORD_TOKEN)+if __name__ == "__main__": main().config.py: split loading from validation — module only loads; adddef validate_config() -> Nonecontaining today'sRuntimeErrorchecks (called frommain()only, so the package becomes importable without full env).- Tests:
test_main.pyreorganized: command tests now callbuild_bot(app)-registered functions or the service layer; themock_discordfixture is replaced by direct service tests (Phase 4 cleanup of leftovers). Import-time behavior test: importingvibe_bot.configwith empty env must NOT raise (onlyvalidate_config()raises). - Done when: [ ]
main.py< 40 lines; grep shows exactly onelogging.basicConfiginvibe_bot/; noCustomBotManager()construction outsideapp.py; gates green.
P2-T6 — commands/ package (completes finding 2.2, 2.6)
- New package
vibe_bot/commands/— one module per group, each exposingdef register(bot: commands.Bot, app: App) -> None:custom_bots.py:custom_bot,list_custom_bots,delete_custom_bot(useapp.bot_cache; keep P1-T10's replaced/created semantics).chat.py: nothing registered (custom-bot chat flows throughon_message→app.services.chat) — keep theon_messagedispatch inapp.pycallingapp.services.chat.handle(...).speech.py:speak,voices(viaSpeechService).images.py:doodlebob,retcon(viaImageService; keep P1-T9 cooldowns).conversation.py:talkforme(viaConversationService).admin.py:lobotomize,debug,history.
config.py: delete deadCOMPLETION_ENDPOINT/COMPLETION_ENDPOINT_KEY/COMPLETION_MODELandEMBEDDING_DIMENSION(finding 2.6); update README env section in Phase 3.tools.py: fixget_channel_members_impldocstring → "guild members" (finding 3.2); keep the@toolstub as the schema source for the registry.- Tests: each command module gets a wiring test (mock service + mock ctx); delete now-dead
fixtures (
mock_env_vars,mock_discordif unused) fromconftest.py. - Done when: [ ] no module in
vibe_bot/exceeds ~400 lines; every command traceable to a single service method; gates green.
P2-T7 — Database layer cleanup (findings 1.9, 6.1, 2.5)
- File:
vibe_bot/database.py. -
- Single-JOIN RAG fetch (6.1): in
search_similar_messages, replace "fetch all embeddings + per-candidate SELECT" with one query:
(correlated subquery avoids a LEFT JOIN fan-out; if a real LEFT JOIN is cleaner, useSELECT cm.message_id, cm.content, (SELECT content FROM chat_messages r WHERE r.message_id = cm.message_id || '_response') AS response, me.embedding FROM chat_messages cm JOIN message_embeddings me ON cm.message_id = me.message_id WHERE cm.role = 'user'LEFT JOIN chat_messages r ON r.message_id = cm.message_id || '_response'). Vectorize the cosine loop: stack blobs into onenp.ndarrayof shape(n, dim)and compute similarities with one matrix op (normalize query vector once; keep the per-vector norm recompute for now — norms get precomputed in Phase 4). - Single-JOIN RAG fetch (6.1): in
-
- Honest types (1.9): register a sqlite
datetimeconverter (sqlite3.register_adapter/detect_typesor a row factory mapping thetimestampcolumn) soget_recent_messages/get_custom_botreally returndatetime; adjust tests that compare strings.
- Honest types (1.9): register a sqlite
-
add_message: addembed: bool = True(assistant rows skip the embedding call — finding 6.2 wiring); keeproleparameter from P1-T6.
-
- Delete the dead
OpenAIimport/client (if P2-T2 didn't already).
- Delete the dead
- Tests: update RAG tests for the JOIN (assert identical results vs old behavior on a seeded DB:
same top-k, same ordering); property: one query executed per search (count with
sqlite3tracing or a wrapper). - Done when: [ ]
search_similar_messagesissues exactly 1 SELECT per call; type annotations match runtime values; gates green.
Phase 2 verification & success criteria
- No module > ~400 lines (
wc -l vibe_bot/**/*.py). main.pyno longer imports anything exceptapp.- Every command testable via mock services without discord module mocking.
- Behavior unchanged: original 156 tests (ported) + new tests all green.
- Strict gates green with net-fewer
type: ignores. - grep: zero
logging.basicConfigoutsideapp.py; zeroopenai.OpenAI(per-call constructions; zero'vibe-bot'indatabase.py; zerollama_wrapperreferences.
PHASE 3 — Developer Experience & Documentation
Goal: Hermetic network-free test suite; CI that gates merges; docs that match reality. Items 3.1–3.4 can start as soon as Phase 2's P2-T6 lands (they're independent of P2-T7).
P3-T1 — Hermetic test suite (AGENTS.md baseline retirement)
- Files:
vibe_bot/tests/test_llm_client.py(ortest_llama_wrapper.pyif renamed later). - Mark the two live tests
@pytest.mark.liveand add topyproject.toml:[tool.pytest.ini_options] addopts = "-m 'not live'"(keepfilterwarningsas-is). Document in the Testing section:uv run pytest -m liveruns them. - Done when: [ ]
rm -rf .venv && uv sync --extra dev && uv run pytest vibe_bot/tests/ -vpasses on a machine with placeholder.envand no network toCHAT_ENDPOINT(verify by pointingCHAT_ENDPOINTat an unroutable address for the test run).
P3-T2 — Test hygiene (finding 4.2)
- Files:
vibe_bot/tests/test_config.py,conftest.py,test_llm_client.py. - Delete both hardcoded
sys.path.insert(0, "/var/home/ducoterra/...")lines; iftest_config.pythen fails on import, addpythonpath = ["."]under[tool.pytest.ini_options]instead. - Delete dead fixtures (
mock_env_varsif still unused) andTEMPDIR. - Fix
test_bot_intents_setto actually assert intents (or rename to match what it asserts). - Done when: [ ] grep for
/var/homeinvibe_bot/tests/returns nothing; suite green.
P3-T3 — Coverage for new surface
- Add tests (in the matching module):
split_message: property test"".join(split_message(t, L)) == tover a corpus including emoji, combining marks, and code spans; exact-multiple-of-limit input.- Voice-flag regex: trailing flag, flag mid-text (must be preserved), no flag, flag with missing value.
- Cleanup regression (P1-T3) — already required, verify present.
rolemigration backfill (P1-T6) — verify present.- Cooldowns (P1-T9): immediate re-invocation → friendly message.
- Service error paths:
ChatServicewith failingchat_complete→ user sees the error message and no rows persisted;ImageService.editwith rejected URL.
- Done when: [ ] all listed tests exist and pass.
P3-T4 — Dependency hygiene (finding 6.4)
- File:
pyproject.toml. - Move to
[dependency-groups] dev(and delete the now-redundant[project.optional-dependencies] dev):mypy(single version pin — drop the conflicting second one),pytest,pytest-env,black,debugpy; keeppyright,ruffwhere they are. - Add
httpxto runtime deps (P2-T2). - Widen or remove
[tool.uv] required-environments(recommend: remove, accept multi-platform lock). uv lockafter changes; verifyuv sync --extra devand the Containerfile'suv sync --lockedboth work (./build.shdry-run optional — do not push images).- Done when: [ ]
uv run python -c "import mypy"fails in a prod-only sync (uv sync --no-dev); container build still passesuv sync --locked; gates green.
P3-T5 — CI gates (finding 6.5)
- File:
.gitea/workflows/build-push.yml(or a newci.yml). - New job
testonpull_request(andpushto main):(Adapt to the Gitea runner flavor already in use; note: portaudio system dep must be present on CI runners —- uses: actions/setup-python@v5 (3.13) - name: Install uv && uv sync --extra dev - run: uv run ruff check vibe_bot/ - run: uv run mypy vibe_bot/ - run: uv run pyright vibe_bot/ - run: uv run black --check vibe_bot/ - run: uv run pytest vibe_bot/tests/ -v # hermetic per P3-T1sudo dnf install portaudioper AGENTS.md, or install in the job; if the runner is apt-based:apt-get install -y portaudio19-dev.) - Make the build/push job depend on
testpassing. - Done when: [ ] a PR with a deliberate type error cannot pass CI (verify locally by simulating
the job steps); merge is blocked on red
test.
P3-T6 — Container hardening (finding 6.5)
- File:
Containerfile. - Add a non-root user:
RUN useradd -m bot && chown -R bot /app…USER bot; ensureDB_PATHdefault dir is writable (vibe-bot.containermounts/db—mkdir -p /db && chown bot /db). - Optional:
HEALTHCHECK— a Discord bot has no HTTP endpoint; use a lightweight liveness probe (e.g. a small asyncio task writing a heartbeat file, checked bytest -f) only if the maintainer wants it; otherwise skip and note why. - Done when: [ ]
./build.shsucceeds;podman run --rm localhost/vibe-bot:latest uv run python -c "print(1)"runs as non-root (verify withpodman run ... id).
P3-T7 — Logging discipline (finding 3.4)
- Files:
vibe_bot/database.py,vibe_bot/services/*. - Per-cursor-operation
logger.info→logger.debug(keep init/migration at INFO). logger.info("Chat prompts: %s", prompts)→ log metadata only:logger.info("chat: bot=%s user=%s context_msgs=%d", ...).- Done when: [ ] no log line can contain message content or user profile data
(spot-check by grepping for
prompts,content,get_user_infoinlogger.calls).
P3-T8 — Documentation (findings 4.1, 4.3)
- Files:
README.md,AGENTS.md. - README:
- Regenerate the file tree from the actual post-Phase-2 layout.
- Document all commands including
!debugand!lobotomize. - Add a "Design notes" subsection stating the intentional semantics: shared memory (any user's history can inform any bot's RAG context), shared bot namespace (creating an existing name replaces it), any member may use any bot.
- Document input limits and cooldowns (P1-T8/P1-T9 values).
- Remove
COMPLETION_*from the env table (P2-T6).
- AGENTS.md: update code map to new layout; retire the fixed gotchas (hardcoded sys.path,
two live tests → now
-m live); keep the portaudio/.env notes. - Done when: [ ] README tree matches
git ls-files(ignore dotfiles/binaries); every@bot.commandname appears in the README; AGENTS.md gotcha list only contains still-true items.
Phase 3 verification & success criteria
- Fresh-clone hermetic run green with no network (P3-T1 command).
- A failing lint/type/test on a PR is blocked in CI (P3-T5).
- Container runs non-root (P3-T6).
- README/AGENTS.md accurate (P3-T8).
- Full gate set green.
PHASE 4 — Optimization & Polish
Goal: Flatten RAG cost at scale, remove per-call allocations, pick off residual nits. Only now — optimizations land in the final shapes.
P4-T1 — Precomputed vector norms (completes finding 6.1)
- File:
vibe_bot/database.py. - Store the L2 norm alongside the embedding (new column
norm REALonmessage_embeddings, backfilled on migration via numpy over existing blobs) sosearch_similar_messagescomputesquery_norm * (Q @ V.T) / normswith one matrix multiply and no per-vector renormalization. - Benchmark script (add
scripts/bench_rag.py, not committed to the package): seed N messages (1k/5k), measure p95 ofget_conversation_context; record before/after numbers in this file next to the task. - Done when: [ ] p95 at 5k ≤ p95 at 1k × 1.2 (flat-ish); suite green. (If
sqlite-vecis considered, only then — default is to stay on pure SQLite.)
P4-T2 — Client reuse verification
- Profile/trace a full
!doodlebob+ chat turn: assert exactly one HTTP session per endpoint (httpx/AsyncOpenAI client identity stable; no newAsyncOpenAI(...)after startup). - Done when: [ ] verified (add a unit test asserting
get_chat_client() is get_chat_client()); no per-call construction in traces.
P4-T3 — Remaining caches & token economy
- Confirm
VOICE_LANGUAGESdict lookup (P2-T4) andapp.bot_cache(P2-T5) are actually used on the hot path (add asserts/measurements in tests if trivial). - Verify layout-selection call uses
max_tokens=2and verify-call a bounded small value (already set in P2-T4 — confirm and measure end-to-enddoodleboblatency before/after; record the number next to this task). - Done when: [ ] latency numbers recorded; no regressions in suite.
P4-T4 — Polish sweep
!retconoutput size: match source aspect ratio instead of hardcoded768x768(revisit the original "keep generation time down" rationale with the configurableIMAGE_GEN_SIZE_*knobs; keep 768 if the endpoint is slow — document the decision inllm_client.pydocstring).- Remove
DEFAULT_VOICE/DEFAULT_SPEEDduplication betweentts.pyandconfig.py(single source inconfig.py;tts.pydefaults reference it). - Delete any leftover
# noqa: ASYNC210(should be gone after P1-T2/P2-T4). - Remove the two
type: ignore[import-untyped]intts.pyifkokoro-tts/soundfilestubs now exist; otherwise keep and add a comment that stubs are upstream-missing. - Sweep for dead code:
get_recent_messages(has callers? if not, delete or keep with a docstring noting it's a utility),deactivate_custom_bot(unused? wire into delete or delete), unused test fixtures. - Done when: [ ] all sub-items checked;
ruffclean (no unused imports warnings suppressed).
Phase 4 verification & success criteria
- RAG p95 flat 1k→5k (P4-T1 numbers recorded).
- Embedding API calls per chat turn == 2 (user + query) — assert via mock call count in a service test.
!doodleboblatency improved vs Phase 1 baseline (numbers recorded).- Full gate set green; no module > ~400 lines; zero dead fixtures/imports.
4. Final Acceptance (end of Phase 4)
uv run pytest vibe_bot/tests/ -vgreen, hermetic (no network).uv run ruff check vibe_bot//mypy/pyright/black --checkall green.- CI green on a PR (lint+test job gates merge).
- Container builds and runs as non-root.
- README + AGENTS.md match the code.
- No Critical or High finding from §2 is open (accepted-by-design items 1.1/1.2/5.1 have their scoped residuals complete and are documented in the README).