Files
brain-of-reese/README.md
T
ducoterra 36eddce672
Build and Push Containers / build-and-push-app (push) Successful in 1m49s
Build and Push Containers / build-and-push-db (push) Successful in 12s
phase: 177_themed_favicon
Everything is verified. Final report:

## Phase 177 — Themed favicon: completion pass (all tasks pre-done, verified end-to-end)

**Verified (no code fixes needed — implementation matched the design D1–D4 exactly):**
- `theming.favicon_svg()` pure renderer + golden byte-identical pin; `GET/HEAD /favicon.svg` route (no-cache, `image/svg+xml`, built-in 200 fallback on DB blip, routes-first before static mount); 5 templates → bare `href="/favicon.svg"`, static file deleted (Containerfile cp removed); Theme-tab `data:` live preview with 4 restore points; JS↔server mirror pins.

**Tests / lint / coverage (all green):**
- `uv run pytest --cov=app --cov-report=term-missing` → 4161 passed, `app/` 99% (>90% gate)
- `uv run ruff check .` → clean; `uv run pyright` → 0 errors
- `uv run pytest tests/e2e/test_themed_favicon.py -v --no-cov` (isolated) → 6 passed
- `test_theme_save_and_coverage.py` (92) + `test_theme_semantic_completion.py` (93), isolated → 3 + 8 passed

**Completion criteria:** all met — default golden bytes/headers; themed roles + `#bor-theme` non-regression; DB-failure 200 fallback; 5 bare links + `/assets/favicon.svg` 404 + browser rendering; live-preview data: repaint + Save/Reset/re-show restore; gates green; E2E suites green in isolation. Commit: harness commits atomically (overrides AGENTS rule 8 — no git commands run).

**Documentation:** README.md verified consistent in full (the phase's favicon paragraph is accurate; all commands, env vars, routes, ports, scripts cross-checked) — no changes needed. AGENTS.md verified consistent — no changes needed.

**Notable:** `image-1.png` (owner's TODO screenshot, referenced by phase 176's overview) sits untracked at the repo root — pre-existing, explicitly out of scope per the phase-176 report; left untouched.

**Next pending phase:** none — 177 was the last; TODO.md is cleared.
2026-10-11 15:33:39 -04:00

82 KiB
Raw Blame History

🧠 Brain of Reese

A chippy, honest RAG chatbot over your homelab documentation — git repositories, local directories, and uploaded archives registered on the admin Sources page. Ask it anything about your notes — markdown, YAML, JSON, Python, plain text, and more — and it retrieves the relevant chunks with hybrid search (pgvector cosine ∪ Postgres full-text search, fused with Reciprocal Rank Fusion), feeds the whole relevant document to a self-hosted LLM (turbo via https://aipi.reeseapps.com/v1), and streams a grounded answer back.

If it doesn't have notes for your question, it admits it — and offers 2-3 concrete alternative questions about things it does have notes on.

Image support is opt-in (BOR_IMAGES): standalone images become retrievable documents, and you can attach an image to a question — see Image support.

Updated your notes? Re-run the import — it's idempotent and only re-embeds what changed:

uv run python -m scripts.import_docs --prune

See Updating the documents for details.


Quick Start

# 1. Dependencies
uv sync

# 2. Configure (copy and edit)
cp .env.example .env
# Set BOR_LLM_API_KEY — your aipi key (falls back to $AIPI_KEY)

# 3. Start the database
podman compose up -d db
podman compose ps          # wait until "healthy"

# 4. Apply migrations & import
uv run alembic upgrade head
uv run python -m scripts.import_docs

# 5. Run the app
uv run uvicorn app.main:app --reload
# → http://localhost:8000  (chat)

Stack: FastAPI · Pydantic v2 · SQLAlchemy 2 · Alembic · pgvector · vanilla HTML/CSS/JS (no CDN) · Playwright E2E


Pages at a glance

The app is a single-page application — all views load from / with a client-side router. Direct URLs deep-link to the matching view.

Page URL Who can see it
Chat / Everyone (token gate for non-shared)
RAG / Knowledge base /sources.html Admin only
Git sources /git-sources.html Admin only
Tuning /tuning.html Admin only
Saved chats /history.html Admin only
Access tokens /tokens.html Admin only
Theme /theme.html Admin only
Settings /settings.html Admin only
Document viewer /document.html?source=X&path=Y Token users & admins
Login /login.html Everyone
Shared chat /shared/<token> Everyone (anonymous)
Edit doc /doc-edit.html Admin only

Chat (/)

Ask questions; answers stream in with source chips that cite the exact documents used (a cited image document gets no chip — its compact figure is the citation, phase 130). Clicking a chip or figure opens the document in an almost-fullscreen modal on the same page (no new tab); the browser Back button closes the modal — instead of navigating the view behind it — and Forward re-opens the same document (phase 144). The modal's Full page link opens the dedicated /document.html viewer in a new tab carrying its origin: a document opened from Chat returns through the viewer's back link to Chat, one opened from Sources returns to Sources (phase 153, feeding the phase-13 back contract on every path). The onboarding suggestion chips follow the opening questions of the 3 most recent saved chats (each chat's first question — a follow-up never chips, phase 103) — on a fresh deployment they seed from BOR_SUGGESTIONS. A long opening question caps at 90 displayed characters with a … (phase 157) — the full text stays in the hover tooltip, and clicking the chip submits the full question.

No empty answer box mid-turn (phase 166): the bubble that will hold the answer is created on the turn's first thinking/tool frame — it is the DOM anchor the Reasoning container inserts itself above — but it stays hidden until the first answer token lands in it, so an in-flight turn shows the typing indicator and the Reasoning bubble only, never a padded empty rectangle (nothing replaces the box: the existing loading feedback is the turn's visible state). The same rule covers a restored or shared answer whose persisted text is empty, which paints no box either.

The document's meta row (the chat modal and the standalone viewer at /document.html) carries a Copy path button: one click copies the document's source/path (the exact form the read tool, the chips, and the Sources table use) to the clipboard; on a non-secure (http) homelab origin the browser rejects the clipboard, so the click instead offers the full path in an inline fallback field that selects itself on focus — click or Tab, then copy (phase 153).

Edit the newest question (phase 159, re-housed in phase 170): the latest user bubble carries an Edit button that opens the editor inside the bubble — the editor inherits the bubble's own box, background, and padding, so it keeps the bubble's dimensions (the bubble stays visible; the rendered question is replaced by the form). A labelled textarea is prefilled with the question (the 4,000-character cap is enforced with maxlength; Ctrl/⌘+Enter submits, Esc / Cancel restore the rendered question byte-identical, and a whitespace-only question is rejected inline). Send rewrites the question in the chat and the saved chat in place, drops every turn that followed it, and re-asks the trimmed question — a fresh answer streams in the old answer's place (the question is never duplicated; an attachment on the question survives the edit and the re-ask stays text-only). A turn that failed with "Question too long — trim it and re-ask" (or a mistyped question) is fixed by trimming it in the editor instead of retyping it. Earlier bubbles never carry the button (last-question-only scope, owner-locked).

When a long answer spends its research budget (phase 160): the answer's meta row carries a Continue researching button (row order Tune → Retry → Continue → Save-as-doc; not admin-gated — chat is public, so any visitor can keep a long research session going). Each tool result tells the model how many tool calls remain; calls attempted after the budget is spent are rejected (not executed, not shown) instead of being dumped into the chat, and the turn ends with a final no-tools reply — the model is coached to answer from the results it already has and to tell you it can keep going, and a deterministic "budget spent" fallback line stands in if that reply carries no visible content. Clicking the button sends one plain continue turn — the budget is per-turn, so the new turn has a whole new round budget, and nothing server-side knows or cares that this is a continuation (A10, stateless). The button is inert while a turn is streaming and single-shot (a stale button is removed when the new turn starts); the flag is persisted on the saved record, so the button restores on a refresh — but a shared chat never shows it.

RAG / Knowledge base (/sources.html)

The indexed document list. The Path column opens each document in the same almost-fullscreen modal (and /document.html renders it through the one shared core). Content renders by kind (phase 165): markdown becomes a rendered document, an image's description or a plain-text (.txt) body renders as wrapping prose in the 72rem reading column, and code and code-ish formats keep the monospace block with a horizontal scroller (long lines never wrap in code — locked; the themed modal scrollbar of phase 116 keeps applying to whatever still scrolls). Document rows carry a Created date and folder rows an Updated date (phase 106) — sourced at sync time (git commit dates for git sources, restored metadata for archive uploads, file mtime otherwise; unresolvable or future dates fall back to today) — and the admin can correct a document's date from the document viewer. The date also feeds the small recency boost in retrieval ranking (BOR_RECENCY_BOOST). Admin-only — anonymous visitors see a sign-in gate instead.

The tree payload is bounded (phase 167): GET /api/docs/tree builds only depth levels (default 1 — the sources plus one level, the page's first paint; depth=2..8 builds deeper, anything else is a 422) instead of the old ~2 MB full recursive dump. A node whose children were not built carries "truncated": true and ships with its recursive document count, updated date, description and pending marker (children: []); untruncated nodes are byte-for-byte what they were before. The three stat cards read the server-computed top-level totals (documents, chunks, last_indexed) so they stay honest whatever depth was requested. Responses also ship gzipped (see Caching & deploys). Deeper levels load on demand through GET /api/docs/tree/node?source=X&path=Y&depth=N (admin-only, phase 167): one node — the source root for path="" — answered in exactly the shape the full tree produces for it (unknown source or folder → 404 unknown node), built from the same single catalogue query. The RAG view fetches each drilled level once and caches it (re-opening a folder costs no network), showing one Loading… row while a level arrives and a Couldn't load … — Retry line on failure with the current level staying rendered; a folder that vanished since it was listed (the node 404) resets the view to the top level. The breadcrumb, the bounded scroll panel, and the description editors behave exactly as before. All three catalogue reads (/api/docs, /api/docs/tree, /api/docs/tree/node) also carry a weak ETag + Cache-Control: private, must-revalidate, so reloading a page you just looked at costs a bodiless 304 (see Caching & deploys).

Git sources (/git-sources.html)

The admin-managed source registry: add or remove git repositories, upload source archives (.tar, .tar.gz, .tgz, .zip), and register local directories — all in one table with a kind discriminator. No .env editing, no restart. The Sync sources button clones/pulls/walks every source and re-imports in one click.

Tuning (/tuning.html)

Set global instructions that are injected into the system prompt of every chat turn. Add notes like "be more concise" or "assume I'm on NixOS". List, edit, or remove them here — no conversation required.

Saved chats (/history.html)

Every conversation is saved automatically. Click a title to return to that chat. Admin-only view. The list is paginated server-side (25 rows per page) — with more than 25, a Prev / “Page N of M” / Next pager appears under the table to walk it, and Refresh re-fetches the current page. GET /api/chats carries the same contract: ?limit= (default 25, clamped 1–100) / ?offset= and a full total on every response.

Access tokens (/tokens.html)

Generate tokens and hand them out — a token opens chat, the answers, and the documents they cite. Shared chats stay open to everyone.

Settings (/settings.html)

Edit any setting that today lives in .env — every BOR_* setting, grouped and type-correct — from the browser. A value you Save is stored in the database and wins over the environment variable from that instant on, with no restart (boot-wired settings — the pool, the session middleware, the log level — carry a restart required badge and apply on the next start); Reset hands control back to the environment, and Clear all overrides empties the override table. .env is never written: the environment stays the base, the database row wins. Secrets are write-only (you set a value, never read one back); a value that fails validation is rejected before anything is stored. Admin-only — anonymous visitors see a sign-in gate.

The overlay can be switched off for the whole process with BOR_SETTINGS_DB=0 (env-only; the panel stays visible and says so).

Shared chat (/shared/<token>)

A read-only snapshot of any conversation, shareable via a link. Open to everyone — no login or token required.

Conversation payload limits

A conversation has one shape, and it carries the same static bounds whether it rides back to the model as history on the next turn or is written to a saved chat — the two paths mirror each other on purpose, so saving a conversation can never be stricter than continuing it (phase 164):

Bound Value What it admits
One message (text, thinking) 600 000 chars a full-length answer at the largest answer cap — BOR_MAX_OUTPUT_TOKENS at 131 072 tokens ≈ 524 300 chars — as history and as a saved record, with margin (the old 32 000 ceiling broke even at the 32 768-token default)
Turns sent as history 500 the whole browser conversation (the client sends everything it holds, minus the question being asked)
Messages per saved chat 500 the same conversation, saved
Total chars per saved chat 8 000 000 ~15 full-length answers; the one bound on the anonymous save surface, which stays bounded on purpose
One question (message) 4 000 chars unchanged; the composer counts it live

These are sanity ceilings, not the prompt's budget: the real gate is BOR_HISTORY_MAX_TURNS / BOR_HISTORY_MAX_CHARS, and a turn that does not fit the budget is dropped whole from later prompts (never cut mid-answer). So a very long answer stops being context for the next question — the follow-up still works, just with a trimmed window — raise BOR_HISTORY_MAX_CHARS if you want it kept.

A failed turn states what actually happened (phase 164): when the app answers but rejects the send before the model runs (an HTTP 4xx/5xx — a validation rejection, an outage page, a proxy error), the bubble reads "I couldn't take that — my API rejected the request. Use Retry to ask again." — not the connection line — and the banner adds that nothing was lost but that send. A genuine transport failure and a model-side error keep "My answer didn't make it — the connection dropped." Either way the exact reason rides verbatim on the bubble's Failed note (API validation details serialize as their msg text — never [object Object], and rejected request content is never shown), persists with the record, and lands Retry + Edit.

Edit doc (/doc-edit.html)

Review and adjust an AI-generated answer before committing it to the docs repository. A draft that should never be pushed can be Discarded from the same page (permanent; admin-only). Admin-only flow page.


Admin & Sign-in

Brain of Reese has exactly one account: the admin (you). Signing in unlocks the full Sources catalog, the answer-tuning controls, and token management. Shared chats are the only content that stays open to anonymous visitors.

Setup (one-time)

python -c 'import secrets;print(secrets.token_hex(32))'   # → paste into .env
BOR_ADMIN_PASSWORD=your-password        # plaintext — homelab scope, by design
BOR_SESSION_SECRET=<the hex from above> # signs the session cookie

Fail-loud: while either variable is empty the app refuses to start, naming the missing one(s).

How authentication works

Endpoint Purpose
POST /api/login Password → signed bor_session cookie
POST /api/logout Clears session cookie
GET /api/whoami `{"authenticated": bool, "role": "admin"
POST /api/token-auth Token (bor_…) → same cookie, role user

The cookie uses same_site="lax", https_only off — no HTTPS enforcement (homelab HTTP; the cookie is single-admin convenience, not a cloud boundary). Max age is BOR_SESSION_MAX_AGE (default 43200 = 12 h).

Access tokens

The admin can hand out access without sharing the admin password.

  1. Generate: sign in → Tokens in the navbar. Type a label and hit Generate — a bor_ + 32-hex token appears in the shown once block. Copy it now: only its SHA-256 hash is stored.
  2. Use: the holder enters the token at the in-app gate. The browser caches it in localStorage["bor.token"] for silent re-auth on reloads.
  3. Revoke: click Revoke on the token's row. Revocation is immediate.

A token user can chat and open documents — nothing else. The Sources catalog, git sources, tuning, and history stay admin-only.


Using the App

Updating the documents

The knowledge base is refreshed by re-running the import. It is idempotent and delta-based: each file's RAW BYTES are hashed (sha256, streamed) and checked against the last index's stamp before any extraction or vision work — an unchanged file costs one hash + one row lookup (no re-extract, no vision, no re-embed), while a changed file re-indexes fully. The one exception is a PDF row that still claims a page strip: it is re-gated from its current bytes (no vision, no LLM — see "The strip repairs itself" below). A refresh after a normal editing session takes seconds — pictorial PDFs included (their expensive first-index vision burns only once per file version):

# After editing/adding/removing notes:
uv run python -m scripts.import_docs                 # re-index what changed
uv run python -m scripts.import_docs --prune         # also drop deleted files

# Point it at extra directories (repeatable):
uv run python -m scripts.import_docs --source ~/SomeOtherDocs

With git-based sources each run first pulls the latest commits of your repos, so this same command is the whole update loop: commit → re-run.

Then check the Sources page (http://localhost:8000/sources.html): the documents / chunks counters and last indexed timestamp should reflect the new files.

  • The import prints one line per file and ends with a greppable summary, so it is safe to run from cron or after every commit.
  • Indexed formats: md, markdown, txt, yaml, yml, json, py, the Podman quadlet family (container, network, volume, image, pod, kube, swap, os, endpoint), j2 Jinja templates — and, opt-in, pdf (add the pdf token to BOR_IMPORT_EXTENSIONS; see PDF documents).
  • Hidden files and common cache dirs (.venv, node_modules, .git, __pycache__, etc.) are skipped — unless the per-source Index hidden folders checkbox on the Git sources page is on for that source (phase 105): then dot-prefixed folders/files with an importable extension are indexed like visible ones, and switching it off prunes them on the next sync.
  • Non-markdown files get format-aware chunking (YAML keys, JSON keys, Python classes/defs).
  • With BOR_IMAGES=1, standalone images (png, jpg, jpeg, webp, gif, bmp) are indexed as documents too — see Image support.
  • A run that changed the KB also regenerates the KB overview and the folder summaries (the drill-down catalog's stored per-folder descriptions) with the lite model — best-effort: a summary failure never fails the import. The stored overview ends with a deterministic Sources: <name> (<k> doc|docs), … line — every source with its exact document count, catalog order, computed without a model call — so no source (not even a 1-doc one) is invisible to <knowledge_base> (phase 162); the LLMError/zero-doc paths leave the previous row (tail included) untouched. Because the sync only rewrites the folders a change touches, a one-shot uv run python -m scripts.regen_folder_summaries refreshes EVERY stored folder summary in one run (full mode: every candidate folder is regenerated, owner-edited rows are kept, one lite call per folder, one cron-safe summary line; exit 0, non-zero with a named stderr line on a boot/DB failure).

Git-based sources

Rather than pointing the import at local folders, point it at git repositories. The admin Git sources page (/git-sources.html) is the primary management surface — add or remove repositories there and the list is stored in Postgres.

# .env — the fallback list (fresh setups, or until the admin page
# stores a source; the page becomes the source of truth)
BOR_GIT_SOURCES=https://git.reeseapps.com/reese/homelab.git,git@github.com:reese/deployments.git
BOR_SOURCES_DIR=~/bor-sources   # default; each repo lands in <dir>/<repo-name>/
  • Public repos need no setup: auth is whatever the machine supplies — HTTPS via the OS credential helper, or SSH via your key. The API accepts https://, http://, ssh://, git@, and file:// URLs (phase 135: a file:// local-transport repo — e.g. on a mounted volume — is a valid source too).
  • Private repos: the add/edit form on the Git sources page takes an optional masked token field (phase 121). The token is stored in a dedicated DB column and injected into the clone URL only at sync/clone time — it is never returned by any API response or shown in the UI (legacy rows that already embed user:token@ in the stored URL keep working — the token is sanitized on output).
  • A git source can pin the branch it mirrors (phase 135): the add/edit form's optional branch field (or the API's branch) stores the branch in the git_sources.branch column — a fresh clone checks that branch out, an existing checkout tracks it (git fetch origin <branch> + git merge --ff-only FETCH_HEAD), and a checkout sitting on a different branch fails the sync loudly with a remove-and- re-add message (never a silent switch). Rows without a branch — every pre-existing row and the env-fallback rows — track the checkout's default branch (byte-identical clone behavior).
  • Every run clones (first time, full history) or brings an existing checkout to the head of the tracked branch (phase 136: git fetch origin <branch> + git merge --ff-only FETCH_HEAD) and then indexes the checkout. If the remote was force-pushed — a fast-forward is impossible — the checkout is reset hard to that head with one loud log line instead of failing the sync: the checkout is a cache of the remote head, and the KB is rebuilt from it on the same run, so a force push is a logged, self-healing event. A genuinely broken fetch (dead network, a branch deleted upstream) still fails the sync loudly. A legacy shallow checkout is unshallowed once on its first post-phase-107 sync (git fetch --unshallow — the one-time self-heal) so commit dates stay real.
  • A failed sync aborts the run — no partial junk.

Archive upload sources

Upload a .tar, .tar.gz, .tgz, or .zip archive to make it a source. The form on the Git sources page — and the POST /api/git-sources/upload route behind it — accepts archives and unpacks + registers them (no scan — the upload indexes nothing; the Sync sources button on the RAG page performs the scan, which leaves room to edit the new source's ignore list first).

  • Re-uploading the same filename replaces the source in place (no second folder, no duplicate row).
  • After the 202 the form settles into the ready-for-sync line "Uploaded () — press Sync sources to import it." — the completion stamp (phase 154: the same date-qualified freshness rule as the sync badge — HH:MM on the same local day, M/D, h:MM AM/PM otherwise, M/D/YYYY across years) rides the line on EVERY render path, so a line that outlives its upload is self-dating: after a reload the source name is gone (it was page-local) but the stamp survives, coming from the server payload's finished_at.
  • Zip-bomb guard: absolute member paths, .. traversal, and symlinks escaping the unpack folder are rejected.
  • Archives unpack under BOR_UPLOAD_DIR (default ~/bor-sources/uploads).
  • BOR_UPLOAD_MAX_MB (default 512) caps both compressed and extracted size.

Local directory sources

A plain directory can be a first-class source too. It shares the git sources' one table (the git_sources registry with a kind discriminator: git | local), one admin page, and one Sync button.

  • Register via API: POST /api/git-sources with {"kind": "local", "path": …}.
  • Sync walks it directly — no clone, no checkout copy.
  • The directory is re-verified to exist at sync time; a missing directory fails the run loudly.

Sync from the UI

The Sync sources button on the Sources page (admin only) runs the whole git-source refresh in one click:

  1. clone/pull + walk every configured source — git repos and local directories, including uploaded archives.
  2. re-import with prune — files deleted upstream leave the index.
  3. regenerate the KB overview (the <knowledge_base> outline every chat turn injects) — only when the import changed the knowledge base.
  4. refresh the folder summaries (the drill-down catalog's stored per-folder descriptions) — a changed knowledge base rewrites ONLY the folders the changed files touch (each file's folder chain up to its source root — phase 127: one changed file re-stamps a handful of rows, not the whole table); an unchanged re-sync instead gap-fills any missing rows (only_missing).
  • States: while polling GET /api/sync/status every 2 s, the button becomes the spinner (icon only — its label parts are hidden while a run is in flight, and its accessible name is the full live label), and the full live detail — "Syncing… (n/m)" during the import phase (the live file, its (n/m) file position, and — while a file's embed batch is in flight — the pinned · chunk n/m embed position, batch granularity: the first chunk of the in-flight batch over the file's total content chunks; and — while a PDF's images are being described — the · image n/m vision position: the 1-based image about to be described over the file's total images, ONE continuous counter across the PDF's three vision spans, so a long image pass visibly climbs instead of sitting frozen on the last chunk position) — shows in a text box under the button row, wrapping (never clipped); the same untruncated label rides the button title and the box, which is the SOLE aria-live region for the sync area (role="status", polite — phase 150 folded the pre-phase result-line announcer into the box). At settle the box STAYS visible to carry the result line — the counts line ("N added · …") after a success, "Sync cancelled" after a cancel — and the button leaves the running state (the "Synced " pill after a success — the date-qualified freshness stamp (phase 154): HH:MM on the same local day, M/D, h:MM AM/PM otherwise (M/D/YYYY across years — a badge that tells the owner when), the bare "Synced" when the stamp is missing — the retry-ready "Sync sources" pill after a failure); the box hides only when its text is empty (a failed sync — the banner + modal carry the error — and the true idle). No client-side timeout — a clone + embed can legitimately take minutes.
  • One sync at a time: a second trigger while a run is in flight gets a 409.
  • Cancel: while a run is in flight, a Cancel button appears next to the sync button; clicking it posts POST /api/sync/cancel (admin only) and registers a cooperative cancel (202) — the run stops at the next pipeline boundary (a git clone/pull already in flight runs to completion) and settles into a neutral cancelled terminal state (error: null — intent, not failure: a "Sync cancelled" result line, no error banner or modal, the button retry-ready) that the status endpoint reports; with no run in flight the request gets a 409 (the UI treats it as a harmless race — the poll settles). A sync started after a cancel runs clean (the importer's content-hash delta re-imports what the cancel skipped).

Thinking

The self-hosted turbo model reasons before it answers. That reasoning is streamed with the turn as thinking SSE events and shown as "Thinking" blocks — one block per contiguous thinking run, in arrival order (phase 158) — all wrapped in one collapsible "Reasoning" bubble above the answer bubble (phase 161): the turn renders as a chronological timeline (Thinking → Tool call (1) → Thinking → … → answer), condensed into a single bounded live window (320 px, one scroll surface) that follows the tail while you watch. The Reasoning bubble is the ONE disclosure (owner refinement, 2026-10-06): the per-run blocks inside it are plain and never fold on their own.

The bubble is created on the turn's first thinking (or tool) frame and stays open from creation until the turn settles — including a mid-round reply (the model answered, called a tool, then thinks again: the reply streams into the answer while the bubble stays open, and the new thinking run gets its own block inside it instead of resuming on top of it). It folds when the turn ends, is stopped, or fails. While open, its summary carries a live status ("Thinking…" / "Calling …") and reads "Reasoning — N steps" once the turn settles; a turn with no thinking and no tools renders no bubble at all (the answer layout is byte-identical to pre-phase).

The per-run blocks are plain and always visible inside the open bubble: each block appears and fills in live while its run streams, and the runs simply stop accumulating frames when the first answer token lands, when the turn ends, or when the turn is stopped/failed. They are distinct from each other by label + rail: the brand-ink "Thinking" label on a brand rail, the accent-ink "Tool call(s) (N)" label on a neutral rail.

The tool calls ride the same timeline: each contiguous run of tool calls gets its own "Tool call(s) (N)" block (one per run, in arrival order; always visible while the bubble is open), and the per-line elapsed-seconds suffix targets the newest line of the active run.

The timeline is persisted with the saved turn (an optional segments array — the per-run order; the concatenated thinking and the tools record stay for history and older clients), so a reload and the shared page re-render the same timeline collapsed inside the Reasoning bubble, with the step-count summary.

To hide it, set BOR_STREAM_THINKING=0.


Agent document tools (ls + read + grep)

Retrieval only puts the top documents in context. When an answer depends on a file a note references ("the exact JSON shape is in example-record-file.json"), the model can extend its own context with server-side tools — on grounded (high-relevance) turns only:

Tool Description
ls Lists the knowledge base as a drill-down tree, one level at a time: no path = every source (document count + stored description), a source name = that source's top level, a source/folder path = one level deeper; file lines are source: X | path: Y | title: Z
read(path, offset?, limit?) Appends one indexed document's text to context by its combined source/path; optional offset (1-based first line) + limit (line count) read just that part of a large document; very large reads are truncated at BOR_READ_MAX_CHARS with a notice that teaches the workflow — grep reports line numbers, then read that part with offset + limit
grep(pattern, path?) Searches indexed documents for an exact string (case- and accent-insensitive fixed substring — accents fold: besta matches BESTÅ, phase 162); returns up to 20 source/path:line: text matches; an optional path limits to one document
show_image(path) (only when BOR_ANSWER_IMAGES=1) shows one standalone image document as part of the answer — image documents only, at most one per answer

(See Answer images for the show_image tool.)

The prompt teaches the search discipline (phase 162). A summary or an outline is a hint, never an inventory: the system prompt tells the model to run ls with no path — the full source list — before ever claiming the knowledge base lacks something, and to grep the artifact's part number or a filename token. One grep pattern is one string, so a name's split tokens (frame, white) and its part number (AA-1272068) each need their own grep; and a note saying a file is gitignored describes its repo status, not whether it is indexed as a source.

Git-source provenance (phase 145). When a document comes from a git source, its read result also carries an origin: line (the repository's URL, credentials stripped) and, when the source pins one, a branch: line. The prompt teaches the model to include that original source in its answer — a link to the repository with the document's path named (and the pinned branch when one is shown) — so you can open the complete original in one step rather than asking again for the source. Local (non-git) sources carry no origin: line, so nothing is added and no link is invented. The same origin rides the document-content payload (origin_url + origin_branch when pinned), and the document viewer — the chat's doc modal AND the full /document.html page, one render site — shows it as an Original external link in the document's meta row (the full repository URL as the link text, opening in a new tab; the pinned branch rides the link's title). Non-git documents show nothing new.

Each call is executed against Postgres only (no extra LLM round trip) and streamed as an SSE tool frame. In the chat, each call shows a transient calling-tool status alongside "thinking" (the send button keeps its busy state — "Stop" — for the whole turn).

The search line shows where it searches (phase 146). When a grep re-searches one document (the optional path), the chat's tool line and the calling-tool status show the document the search runs in — 🔎 Searching for <pattern> in <source/path> — so a whole-KB search and a re-search of a single document read as distinct searches instead of looking like the model is repeating itself. The scope rides the SSE tool frame additively (path, present on scoped greps only) and is persisted on the saved turn's tool record, so it re-renders after a save → restore; a bare grep renders exactly as before (frame, line, status, and saved shape all byte-identical to pre-phase — old saved turns restore unchanged).

The model may call tools as many times as needed, bounded by a round cap:

Env Default Meaning
BOR_AGENT_MAX_ROUNDS 10 hard cap on agent tool rounds per grounded turn (0 = no tools, the kill switch); every tool result carries the model's remaining count (phase 160)

At the cap, further tool calls are rejected (never executed, never shown) and the turn ends with a final no-tools reply: the model is coached to answer from the results it already has and to tell the user they can say "continue" to keep researching; a reply with no visible content answers with a deterministic "budget spent" fallback line instead of an error frame (phase 160, the recorded revision of the phase-71 cap-path terminal). The done frame carries an additive budget_exhausted key (ABSENT on every non-cap turn) that drives the Continue researching button on the answer's meta row — see the Chat (/) section.


Tuning your answers

Admin-only — sign in first. If an answer isn't quite right — too chatty, wrong assumption, missing context — tune Brain right there:

  1. Press "Tune" in the meta row under any completed answer (deflected ones included).
  2. Type a short instruction (1–2000 chars), e.g. "be more concise" or "assume I'm on NixOS", and Save.

The note is stored in Postgres (steering_notes) and read into the system prompt of every subsequent chat turn as a <tuning> section (numbered, oldest first, capped at BOR_STEERING_MAX_CHARS chars — default 8000, overflow marked […truncated…]).

List or remove notes from the "Tuning" button in the chat header (count badge, newest-first, per-note delete). The API is stateless JSON if you prefer curl:

curl -s localhost:8000/api/steering                                  # list
curl -s -X POST localhost:8000/api/steering \
     -H 'Content-Type: application/json' -d '{"note": "be more concise"}'
curl -s -X DELETE localhost:8000/api/steering/<note-id>              # remove

How retrieval works (hybrid)

Every question is embedded and also lexically tokenized (OR-joined, English stemming) and searched twice against Postgres:

  1. Vector — pgvector cosine top-N (default BOR_HYBRID_VECTOR_CANDIDATES=100)
  2. Lexical — a stored tsvector (GIN-indexed) matched with to_tsquery, top-N by ts_rank (default BOR_HYBRID_LEXICAL_CANDIDATES=30), led by name hits — documents whose path matches a question token (gitea → gitea/…, qwen38 → qwen3.8-27b-…; phase 119). Name normalization is accent-folded (phase 162): a question's BESTÅ folds to besta and matches the ASCII filename, and an accented filename (BÅsta.md) matches an ASCII question — both directions, stdlib fold only.

The two ranked lists are fused with Reciprocal Rank Fusion (score = Σ 1/(k + rank), BOR_RRF_K=60) — a chunk in both lists scores nearly double, which lets a name-your-tool question ("gitlab") find its own document even when the question embeds close to generic templates.

The honesty gate (A8) then answers (HIGH) when the best cosine is ≥ BOR_RELEVANCE_THRESHOLD (default 0.62) or when at least one chunk matched lexically (fts_hits > 0) and the best cosine clears BOR_LEXICAL_SUPPORT_FLOOR (default 0.35) — the vector signal must corroborate a lexical hit (A8 revised 2026-09-14). It deflects (LOW) otherwise, including a weak single-token hit with vector-unsupported docs. The top BOR_TOP_N_DOCS full documents are what the LLM sees.


Document summaries

Raw yaml/json/py/txt embeds badly — flags and keys are not language, so retrieval can miss exactly the documents that are all configuration. At import time, every new or changed document gets a lite-model summary — markdown included: the summary is a natural-language retrieval target and the seed text for the onboarding suggestion blocks (phase 118 retired the old "markdown is already natural language" exclusion):

  • The summary is stored on documents.summary and indexed as one extra embedded chunk (chunks.is_summary, position −1), so hybrid search has a natural-language target to hit instead of the raw text.
  • The last line is a code-deterministic pointer — Source: <source>/<path> — appended by the app, never model-generated.
  • The model only sees the first BOR_SUMMARY_MAX_CHARS (default 12000) characters; overflow is cut and marked with […truncated…].

Summary generation is best-effort: if lite fails, the document is still indexed (without a summary), the failure is logged, and counted in the import summary line (summaries=N summary_errors=N).


Image support

One toggle gates both image capabilities: BOR_IMAGES (default off). Enable it only when your chat model (BOR_LLM_CHAT_MODEL) supports vision — the chat model is the vision model: it describes image documents and answers questions with attached images. The embedding model never sees pixels in either flow.

Image documents

With BOR_IMAGES=1, a standalone image file — inside an uploaded source archive, or as a file in a git/local source walk — becomes a first-class document:

  • Described by the chat model. On first index, a single multimodal chat-model call describes the image. The description becomes the document's content and summary — and is the only text that gets embedded, so hybrid search has a natural-language target to hit instead of pixels.
  • The bytes persist. Each ingested image is copied to BOR_IMAGE_DIR/<uuid>.<ext> and served from that copy (uploads are replaced and checkouts re-cloned, so the served image outlives its source file).
  • Retrieved & displayed. Image docs appear in the Sources catalog with a small lazy-loaded thumbnail (progressive enhancement — a fetch failure falls back to the doc icon), the document viewer renders the image with the description below it as wrapping prose (phase 165 — the full description reads by scrolling down, never sideways), and a cited image doc renders in the chat's sources images row (phase 130: its own .msg-meta.msg-images cluster below the chips row — no chip for an image doc, the figure is the citation; alt text = the description; a failed image load replaces the figure in place with the plain chip, so the citation survives a lost byte copy). The agent's read tool returns the description with a marker telling the model it is reading an image document.
  • Prune guard. While BOR_IMAGES is off, syncs never prune existing image documents — an image doc is invisible to the walk, not a deleted file. With the toggle on, a deleted image file prunes its doc and its copy.
  • Fail-soft. A failed or empty description skips that image (no row, no copy), logs a warning, and counts in the import summary line (images_failed=N) — the sync continues.

Accepted formats are png, jpg, jpeg, webp, gif, bmp (BOR_IMAGE_EXTENSIONS, case-insensitive) — a separate set from BOR_IMPORT_EXTENSIONS; images are never added via that variable, the toggle is the single knob.

Question images

With the toggle on, the composer gains a paperclip button. Attach one image to a question and the chat model answers with text and image:

  • Server-stored, path-persisted. On send the file is first uploaded to POST /api/chat-images (capped at BOR_CHAT_IMAGE_MAX_MB, default 10 MiB — over is a 413) and stored under BOR_CHAT_IMAGE_DIR. Saved and shared chats carry the returned path — never base64.
  • Visible while uploading. While the upload is in flight the preview strip shows a spinner + "Uploading image…" (its remove ✕ disabled) and the Send button is disabled with the "Uploading…" label — a second press is dead, visually and functionally (no second upload, no second turn). On success the state clears and the turn proceeds; on failure it clears, the banner shows, and the typed text
    • the attachment stay for the retry (phase 132).
  • Current turn only. The model receives a multimodal message for this question; prior turns' images are never replayed into history (the text of a prior turn stands alone).
  • Rendered everywhere. The image renders in the user bubble, after a page reload, and on the shared chat page; a failed load degrades to a small "image unavailable" note (never a broken-image icon).
  • Blocked on failure. If the image upload fails, the send is blocked with a banner — the question is never sent without the image the user attached.
  • Toggle off ⇒ the attach control is absent, and an API request carrying an image gets an error frame with the hint: Enable BOR_IMAGES in the server's .env (and restart) to ask with an image.

A question image is never indexed as a document — it is turn-local storage, not a source.

Answer images (phase 124)

A second, independent toggle — BOR_ANSWER_IMAGES (default off): when on, the grounded chat model may show a standalone image document as part of its answer:

  • The model decides. The agent is offered a fourth tool, show_image(path), and calls it only when the image itself contains the answer (a diagram, screenshot, or photo of the thing asked about) — never for mere topical relevance. At most one image per answer (enforced server-side); the text answer is always given in full — the image supplements, never replaces.
  • Rendered as an answer figure. A shown image appears as a first-class figure below the answer text and above the source clusters (the chips row, then the images row — phase 130) — on the live turn, after a page reload, and on the shared chat page; a failed load collapses the figure (never a broken-image icon).
  • Image citations on zero-read turns. A retrieved image document in the suggested tier is auto-cited: its figure (in the images row — no chip, phase 130) appears even when the model never reads it — the model's seed block already carries the image's full description, so a real model has no reason to read it (this applies with BOR_IMAGES=1, any BOR_ANSWER_IMAGES).
  • Off = byte-identical. No fourth tool offered, no figure, no answer_image key in any SSE frame or saved record.

PDF documents (phase 126)

PDFs are opt-in: add the pdf token to BOR_IMPORT_EXTENSIONS (a stock .env never indexes them). Once admitted, a PDF is a first-class document through every entry point (git/local walks and uploaded archives):

  • Text, page-aware. Text is extracted page by page (pymupdf); one document per PDF, its text-bearing pages joined with ---PAGE-N--- separators. Chunking is page-aware — every chunk keeps a Page N: anchor, so a retrieval hit stays readable in context.
  • Summarized + dated like any document. The lite model summarizes it, sync sources its date (git commit / file metadata), and it appears in the catalog, the KB tree, and the agent's ls / read / grep tools.
  • Embedded images join the image pipeline. With BOR_IMAGES=1, images embedded in the PDF are extracted and run through the same image-document flow as standalone images (chat-model description, persistent copy under BOR_IMAGE_DIR, retrievable + displayable); with the toggle off the pass is skipped and the PDF text indexes normally. Embedded images in a format outside BOR_IMAGE_EXTENSIONS are skipped with a warning. The extraction is de-duplicated and viewer-faithful (phase 151): repeated placements of the same image yield ONE document (first placement wins), an image with a soft mask (/SMask) is stored as the composited image a PDF viewer shows (alpha PNG — not the blank base stream), and near-white, nearly-uniform images (the table-shading "blank white" underlays) are skipped entirely — no row, no copy, no vision call. The run counts the drops log-only on the import summary line (pdf_images_deduped=N pdf_images_blank_skipped=N).
  • Document context (phase 137). Every embedded image is described with the document it came from: the vision prompt — and the stored image_context + enriched content — carry an Extracted from <source>/<pdf> (page N): origin line (always present, even for a text-less page) plus the page's own text (capped at 6 000 chars; dropped when the phase-133 gate flags the text layer as garbage — origin line only). Markdown ![alt](path) references to the image's synthetic path ride along after it (phase 125, document block first), so the description names the document and the page the image illustrates. Image docs a pre-phase-137 sync left without the block (image_context NULL or markdown-only) are backfilled on the next sync — re-described once with the context (the parent's page text is re-read from the source while the PDF is still there; parent gone → origin line only), idempotent (the following sync burns zero vision calls for them), fail-soft (a failure leaves the row untouched and retries next sync), counted log-only on the import summary line (image_context_backfilled=N). The stored context is also VISIBLE in the chat suggestion block: an image doc's <document> body leads with a one-line locator — From <source>/<pdf> (page N) for a PDF-embedded image (with ; Referenced in … appended when markdown reference blocks are stored too — document block first), Referenced in <source>/<path>: "alt" for a markdown-referenced image (phase 125) — the page-text body is never rendered.
  • Garbage text layer → page-screenshot fallback (phase 133). Before indexing, the extracted text passes a deterministic garbage gate (empty text layer, or ≥ 30% tag-looking lines, or ≥ 5% non-printable chars — the class of PDF that reads as raw XML/binary soup). On failure every page is rendered to a screenshot and read by the vision model; the stored content is the ---PAGE-N----anchored page descriptions (a failed page gets a page N — description unavailable placeholder — fail-soft, the doc still lands). Clean text layers never burn a fallback vision call. The run counts the affected PDFs on the import summary line (pdfs_screenshotted=N). A garbage-text PDF takes this fallback instead of the pictorial pass below — the precedence is locked, never double vision.
  • Pictorial pages get vision-described (phase 140, on by default). For a PDF whose text layer is usable but thin — the IKEA assembly-instruction class (20 of 24 pages: 4–73 chars of markers around vector-drawn pictograms) — every page whose STRIPPED text is under BOR_PDF_PICTORIAL_MIN_CHARS characters (default 100; a deterministic per-page threshold, no LLM in the gate) is rendered to a PNG and described by the chat model, and the description appends to that page's content as [Page N illustration: …] — so the embeddings carry what the thin text layer couldn't, and a question about the illustrations hits. NO page cap: every candidate page is described (A21 — the LLM calls are free; a slower sync is acceptable). A PDF with zero candidate pages burns no render or vision call — its indexed content is byte-identical to pre-phase-140. The run counts the affected PDFs and pages on the import summary line (pdfs_pictorial=N pdf_pictorial_pages=N). BOR_PDF_PICTORIAL=0 turns the whole pass off (byte-identical pre-phase-140 content).
  • Served as text — or as pages (phase 148). The document viewer renders the extracted text — never the binary PDF. The exception is the two page-qualifying classes: a PDF whose text layer fired the phase-133 screenshot fallback, or a PDF whose phase-140 pictorial pass described ≥ 1 sparse page. For those, every page is rendered to a persistent PNG at index time (under BOR_PDF_PAGE_DIR) and the viewer — the doc modal AND /document.html, one shared core — shows the PAGES: a vertical strip of lazy page images with "Page N" captions, in place of the text content and the Summary panel (the per-page descriptions stay in the DB for Q&A — the embeddings and summary are untouched, just not displayed). Since phase 163 this strip-only shape is the PRIMARY class only — the pictorial class keeps its text behind a Text/Pages toggle, per the next bullet. A lost copy shows a "Page N unavailable" note for that page only. Text- heavy PDFs (usable layer, zero pictorial candidates — and the whole sparse class while BOR_PDF_PICTORIAL=0) keep the text view, byte-identical to pre-phase.
  • Which kind of strip it is (phase 163). The two qualifying classes are not the same problem: the screenshot class has no usable text to show, while a book with one sparse page (an illustration map page in a 36-page text) does. The document row therefore records the strip's CLASS beside the page count (documents.page_strip_mode — primary = the screenshots ARE the content, so the strip is the view; additive = a usable text layer, so the text + Summary view stays and the pages sit behind a Text/Pages toggle), and the document-content payload carries it as page_strip_mode. The key is ABSENT — never null — when there is no strip, which is also how a row written before this phase serializes; the viewer treats an absent class as primary, so every existing document renders exactly as before until the next sync stamps it (the sync's self-heal pass, phase 163).
  • The strip repairs itself (phase 163). An unchanged PDF is not assumed to still have its page images on disk. On every sync, a row that claims a strip is re-gated deterministically from the current file bytes and the current settings — the same gate the changed path uses (BOR_PDF_PICTORIAL and BOR_PDF_PICTORIAL_MIN_CHARS included), with zero vision or LLM calls (rendering is CPU-only): the class is re-stamped, any <doc-id>-p{N}.png missing from BOR_PDF_PAGE_DIR is re-rendered (write-before-delete, atomically per file), and a strip that no longer qualifies — the kill switch turned off, the sparse-page bar lowered past it — is cleared along with its copies. So a wiped pages directory, or a copy set lost to an interrupted sync, repairs itself on the next sync instead of leaving "Page N unavailable" forever. It logs one PDF strip re-gate … line only when it actually acts, and a failure there never fails the sync. PDFs without a strip pay nothing extra (no extra read at all).

Caching & deploys

A deploy is a commit — and the browser must see it without a hard refresh. The first three bullets below are that A19 rule, applied at the transport layer by app/core/caching.py; the last two are phase 167 — gzip from app/core/compression.py and per-route validators from app/core/http_cache.py. Outermost to innermost, the stack is security-headers → gzip (only when BOR_COMPRESS_MIN_BYTES > 0) → the byte-budget logger → caching → routes, so security headers land on every response and bytes= in the budget line is the route's own, uncompressed payload (see Operational notes):

  • HTML pages ship Cache-Control: no-cache, no etag, no last-modified — each visit always gets a fresh 200 body.
  • Assets are versioned and cached for a year. Pages reference their CSS/JS with a token (/assets/styles.css?v=<token>), and every /assets/* response ships Cache-Control: public, max-age=31536000, immutable.
  • The token is the deploy. In a git checkout it is the short SHA of HEAD — so every commit/deploy flips the token and the versioned asset URLs change with it. A checkout without .git falls back to a stable content hash of the frontend/ tree.
  • Responses are gzipped (phase 167). Every HTTP response except the POST /api/chat SSE stream goes through selective gzip (app/core/compression.py): when the client accepts gzip and the body is at least BOR_COMPRESS_MIN_BYTES (default 860; 0 = the feature is OFF), KB JSON ships ~8-12x smaller — the fix for the ~2 MB /api/docs/tree load. The SSE stream is skipped by EXACT path (A15: frames keep arriving one by one, uncompressed), small responses stay identity, and an already-encoded body is never double-encoded, so a fronting nginx may gzip too.
  • Catalogue reads revalidate (phase 167). GET /api/docs, GET /api/docs/tree and GET /api/docs/tree/node ship a content-exact weak ETag (SHA-256 of the served body, app/core/http_cache.py) with Cache-Control: private, must-revalidate — a repeat load costs a 304 with no body, and a sync or an admin edit (folder summary, document date) changes the body, hence the tag, so it is seen on the next load. The tag is deliberately NOT sources_meta.version (bumping that marks saved chats stale, and those edits never bump it). This is a scoped extension of the rule above: validators live on these three read-only routes only — HTML pages keep publishing none, and the SSE stream and every mutating route are untouched.

No CDN, no new services, no build-step change: the middleware rewrites the asset references of the known pages in flight.

Deploy note: the very first deploy onto this scheme needs one normal page visit, so the browser revalidates the HTML once and starts requesting the versioned assets; every commit after that is picked up automatically.

Operational notes: the response byte budget (phase 167)

Every /api/* response writes ONE parseable line, so "which endpoint is heavy, right now?" is answerable from the app's own log:

2026-10-08 07:40:57 INFO     app.response_budget :: api method=GET path=/api/docs/tree status=200 bytes=8474 enc=gzip duration_ms=12
grep ' :: api ' server.log                    # every measured response
grep ' :: api ' server.log | grep 'bytes=[0-9]\{6,\}'   # the six-figure ones
  • bytes= is what the ROUTE produced (uncompressed), enc= is what went on the wire (gzip or identity) — "how fat is it, and how fat did the client see it". A revalidated 304 logs bytes=0 on purpose: that is the win you want to see.
  • Level: INFO at or over BOR_LOG_LARGE_RESPONSE_BYTES (default 65536), DEBUG below it, so the default log stays readable. BOR_LOG_LARGE_RESPONSE_BYTES=0 logs EVERY /api/* response at INFO — the mode for a one-off investigation. A negative value refuses startup.
  • stream=1 marks the SSE turn (POST /api/chat, A15): bytes is the streamed total and duration_ms the stream's life. One line per stream, written when it ends — including a stream the client cancels (with the bytes that got out). The middleware is pure ASGI and never buffers.

Measuring the whole API (the audit behind the fix):

# the corpus that matches production's order of magnitude
#   (destructive to the local DB — see the restore-test-db skill)
uv run uvicorn app.main:app --port 8000
uv run python -m scripts.api_payload_audit --base-url http://localhost:8000

It signs in with BOR_ADMIN_PASSWORD (or --password; the single-admin login has no username), measures every candidate read route in BOTH identity and gzip forms plus one conditional revalidation, prints the corpus it measured, and marks anything over --budget (default 262144) with LOOK. It is read-only (safe against the deployed app) and always exits 0 when it got its numbers — it is a report, not a gate (LOCKED A5: nothing in this phase rejects a large payload). Exit 2 means the app was unreachable or the login was rejected. The measured before/after tables and the verdict per endpoint live in the phase record — 167_api_payload_diet/AUDIT.md under .agents/phases/.

Deploy note (compression): the app gzips its own responses, so nginx gzip on is optional — leaving it off ships the same bytes, and leaving it on is fine too: an already-encoded body is never double-encoded by either side. Passing the client's header through untouched (proxy_set_header Accept-Encoding $http_accept_encoding;, or simply not overriding it) is correct; unsetting it would hand every client uncompressed JSON.


Configuration reference

Everything is an environment variable (the BOR_ prefix, plus DEBUGPY*) read from .env. Every variable in .env.example is documented below — copy that file to .env and adjust.

DB-over-env overrides (phase 168): the precedence for every setting is app_settings row → BOR_* environment → code default — an override the admin saves in the Settings panel is stored in Postgres (app_settings, one row per key) and wins over the env var for that key — with no restart. The panel never writes .env (the env value stays the base; per-key Reset deletes the row and the env value wins again). BOR_SETTINGS_DB=0 (set in the container env, not .env — a committed 0 is a silent "my change did nothing") disables all DB overrides — the pre-phase behavior. Two keys are NOT overridable: BOR_DATABASE_URL (it builds the very engine that would read the override) and BOR_EMBEDDING_DIM (fixed at chunks table creation). A stored value that fails the setting's validators is skipped at read with a logged reason — a DB row never bricks boot. "No restart" is not universal: the settings the panel marks restart required (DB pool sizes, session cookie wiring, log level, compression, the static dir) apply on the next podman compose restart app; everything else applies immediately. Admins read/write the overrides through the admin-only /api/admin/settings routes (GET the catalogue, PUT /{key} set one value, DELETE /{key} reset one, DELETE all clear everything); secrets are write-only there (the response reports has_value, never the plaintext). A docs_repo PUT with embedded credentials (://user:pass@ — the phase-173 leak class) is refused with a 422 pointing at the docs_user / docs_token fields; a legacy .env value of that shape is split into those two settings once at startup (the DB rows shadow the env, .env is never rewritten).

App & branding

Env Default Meaning
BOR_APP_NAME Brain of Reese Display name everywhere (page titles, header brand, status labels)
BOR_APP_VERSION 0.1.0 Version the FastAPI app reports (GET /api/health, the OpenAPI doc)
BOR_INPUT_PLACEHOLDER Ask me anything… Chat composer placeholder
BOR_FOOTER_TEXT Powered by self-hosted models Footer line on every page
BOR_ENVIRONMENT development development or production (the prod compose profile sets production)
BOR_LOG_LEVEL INFO App log level
BOR_STATIC_DIR frontend Where static files are served from (the container image sets /app/static)
BOR_COMPRESS_MIN_BYTES 860 Selective-gzip floor (phase 167): responses ≥ this size compress when the client accepts gzip; 0 = OFF (no middleware), negative refuses startup. POST /api/chat (SSE) is never compressed
BOR_LOG_LARGE_RESPONSE_BYTES 65536 INFO/DEBUG cut for the per-response api method=… bytes=… enc=… byte-budget line (phase 167, grep ' :: api '); 0 = log every /api/* response at INFO, negative refuses startup. See Operational notes

Database & concurrency

Env Default Meaning
BOR_DATABASE_URL local compose URL SQLAlchemy URL (psycopg)
BOR_DB_POOL_SIZE 5 DB connection pool size
BOR_DB_POOL_MAX_OVERFLOW 10 Overflow connections above BOR_DB_POOL_SIZE
BOR_DB_POOL_RECYCLE 3600 Recycle pool connections after N seconds
BOR_CHAT_MAX_CONCURRENT 10 Max concurrent /api/chat turns

LLM (aipi)

Env Default Meaning
BOR_LLM_BASE_URL https://aipi.reeseapps.com/v1 OpenAI-compatible endpoint
BOR_LLM_API_KEY — (falls back to $AIPI_KEY) aipi API key
BOR_LLM_CHAT_MODEL turbo Chat model — must support vision when BOR_IMAGES=1
BOR_LLM_EMBED_MODEL embed Embedding model
BOR_LLM_SUMMARY_MODEL lite One-shot completions: document summaries at import, KB overview, folder summaries
BOR_LLM_THINKING_LEVEL low The model's thinking level (pi-style, free-form — no allowlist); rides every chat-completions request as reasoning_effort; empty = the parameter is not sent
BOR_EMBEDDING_DIM 768 Vector dimension (fixed at table creation)
BOR_LLM_RETRIES 3 Retries: chat-turn stream before the first token + empty one-shot summary replies; 0 = off
BOR_LLM_RETRY_DELAY 5 Seconds between LLM retries
BOR_LLM_TIMEOUT 120 HTTP timeout (s) for LLM API calls
BOR_STREAM_THINKING 1 Stream the model's thinking as thinking SSE events (0 = suppress)

RAG & prompt budgets

Env Default Meaning
BOR_TOP_N_DOCS 2 Ceiling for the cited tier, not a quota (phase 113) — a single strong doc yields one citation
BOR_RELEVANCE_THRESHOLD 0.62 Answer when best cosine ≥ this or a lexical hit corroborated by cosine ≥ BOR_LEXICAL_SUPPORT_FLOOR; otherwise honest deflection
BOR_LEXICAL_SUPPORT_FLOOR 0.35 Best-cosine floor an FTS hit must clear to flip the gate HIGH (A8 revised 2026-09-14)
BOR_SOURCE_USEFULNESS_FLOOR 0.35 Cosine bar for a citation slot (phase 113): a doc earns the cited tier only when its best-chunk cosine clears it; 0 = no bar
BOR_RELATED_MAX_DOCS 2 Cap on the related-doc tier (the de-emphasized "nearby docs" row — never a citation chip); 0 = no related docs
BOR_SUGGESTED_DOCS 5 Cap on the "start here" suggestion tier (phase 118): top-N doc summaries seeded into the grounded prompt; no cosine floor; ≥ 1
BOR_SUGGESTION_PREVIEW_CHARS 400 NULL-summary fallback preview in a suggestion block (phase 118): first N content chars + truncation marker; > 0
BOR_HISTORY_MAX_TURNS 40 Prior client-provided history turns kept per chat turn (phase 74, newest first); 0 = no history (the pre-phase-74 two-message requests)
BOR_HISTORY_MAX_CHARS 24000 Total char budget for the kept history — len(text) + len(thinking) per turn (phase 74); a turn that would overflow is dropped whole, never cut mid-answer; 0 = no history
BOR_MAX_OUTPUT_TOKENS 32768 Max answer length in tokens (answers must not be cut off)
BOR_STEERING_MAX_CHARS 8000 Char budget for the <tuning> (steering notes) prompt section
BOR_SUMMARY_MAX_CHARS 12000 Cap on document content sent to the lite summary model
BOR_KB_OVERVIEW_MAX_CHARS 4000 Char budget for the <knowledge_base> prompt section — the stored outline plus its deterministic Sources: … tail (phase 162); the cap keeps the longest-fitting prefix, so a value below the default clips the tail first (the default always holds both)
BOR_OVERVIEW_INPUT_MAX_CHARS 40000 Cap on the document list sent to lite for KB overview generation
BOR_FOLDER_SUMMARY_INPUT_MAX_CHARS 8000 Cap on a folder's document list sent to lite for folder summaries (phase 94)

Chunking & embedding

Env Default Meaning
BOR_CHUNK_TARGET_CHARS 2000 Target chunk size (chars)
BOR_CHUNK_OVERLAP_CHARS 200 Overlap between adjacent chunks (chars)
BOR_EMBED_BATCH_SIZE 16 Chunks per embedding API call
BOR_EMBED_QUESTION_MAX_CHARS 1200 Chat question-embed prefix cap (phase 114): the embed step embeds at most this many chars of the question — the full question still reaches the LLM prompt

Hybrid retrieval

Env Default Meaning
BOR_HYBRID_VECTOR_CANDIDATES 100 Cosine list width for the RRF fusion
BOR_HYBRID_LEXICAL_CANDIDATES 30 FTS list width for the RRF fusion
BOR_RRF_K 60 RRF damping constant (1/(k + rank))
BOR_RECENCY_BOOST 0.0007 Max additive fused-score bonus a zero-age doc gets (phase 106); 0 = off
BOR_RECENCY_HALF_LIFE_DAYS 365 Recency decay timescale, days (phase 106) — the boost decays by e^-1 (~0.37x) per timescale of age
BOR_NAME_HIT_BONUS 0.005 Selection-time bonus a name-hit doc's best fused score gets (phase 119); 0 = off

Agent tools

Env Default Meaning
BOR_AGENT_MAX_ROUNDS 10 Hard cap on agent tool rounds per grounded turn (0 = no tools); every tool result carries the model's remaining count (phase 160)
BOR_READ_MAX_CHARS 128000 Cap on a read tool result (chars); over-cap reads truncate + a notice that teaches the grep → read(offset, limit) workflow (phase 95, rewritten phase 131)

Import & sources

Env Default Meaning
BOR_IMPORT_EXTENSIONS A9 family Importable text formats — the A9 default is md,markdown,txt,yaml,yml,json,py,container,network,volume,image,pod,kube,swap,os,endpoint,j2; any well-formed extension is allowed. Add the pdf token to make PDFs first-class, retrievable documents (phase 126, opt-in). Images are not added here — see BOR_IMAGES
BOR_PDF_PICTORIAL 1 Master switch for the pictorial PDF pass (phase 140, on by default): a usable-text PDF's sparse pages (stripped text under BOR_PDF_PICTORIAL_MIN_CHARS) are rendered + vision-described into the indexed content ([Page N illustration: …]); 0 ⇒ content byte-identical to pre-phase-140 (no render, no vision call)
BOR_PDF_PICTORIAL_MIN_CHARS 100 Sparseness bar for the pictorial pass (phase 140): a page whose STRIPPED text is STRICTLY shorter than this many chars is a candidate (a page with exactly this many is not); 0 = every page (A21); a negative value fails startup loudly
BOR_PDF_IMAGE_SKIP_BLANK 1 Skip blank/decorative images embedded in PDFs (phase 151, on by default): near-white, nearly-uniform images (the table-shading "blank white" underlays) are dropped from the embedded-image pass — no row, no copy, no vision call; 0 ⇒ every extracted image is indexed again (pre-phase behavior; dedupe + soft-mask compositing unaffected)
BOR_PDF_PAGE_DIR ~/bor-sources/pages Persistent home for the rendered PDF page PNGs (phase 148): a page-qualifying PDF (the phase-133 screenshot class or the phase-140 pictorial class) gets every page rendered at index time as <doc-id>-p{N}.png here, so the document viewer shows the actual pages (the descriptions stay embedded for Q&A, just not displayed); text-heavy PDFs keep the text view. Clearing the directory is self-healing — the next sync re-gates any strip that still claims pages and re-renders what is missing, with zero vision calls (phase 163)
BOR_GIT_SOURCES — (empty) CSV of git repo URLs — fallback while the admin Git sources page's list is empty
BOR_SOURCES_DIR ~/bor-sources Where git repos are cloned/pulled
BOR_UPLOAD_DIR ~/bor-sources/uploads Where uploaded source archives are unpacked
BOR_UPLOAD_MAX_MB 512 Cap (MiB) for uploaded source archives (zip-bomb guard)
BOR_SUGGESTIONS built-in list JSON seed for onboarding chips (shown before the first saved question)

Image support (phases 122/123/124)

Env Default Meaning
BOR_IMAGES 0 Master switch for image support — off by default; enable only when BOR_LLM_CHAT_MODEL supports vision. Gates both image documents and question images
BOR_IMAGE_EXTENSIONS png,jpg,jpeg,webp,gif,bmp Extensions indexed as image documents (case-insensitive; separate from BOR_IMPORT_EXTENSIONS)
BOR_IMAGE_DIR ~/bor-sources/images Persistent home for the served image bytes (uploads are replaced, checkouts re-cloned)
BOR_CHAT_IMAGE_DIR ~/bor-sources/chat-images Where question images are stored (saved/shared chats carry the path, never base64)
BOR_CHAT_IMAGE_MAX_MB 10 Question-image upload cap (MiB; must be > 0)
BOR_ANSWER_IMAGES 0 Let the chat model show a standalone image document as part of its answer (phase 124): offers the agent the show_image tool and renders the shown image as an answer figure — independent of BOR_IMAGES (that toggle indexes image docs; this one shows them)

Docs push

Env Default Meaning
BOR_DOCS_REPO — (empty) The git repo chat answers can be committed to (URL or local path); while empty "Save as doc" is hidden and the push endpoint 409s — a clean URL only: credentials in the repo URL are no longer supported (phase 173), use BOR_DOCS_USER / BOR_DOCS_TOKEN. A legacy ://user:pass@ value (env or a saved override) is auto-split on startup into those settings, one-time and idempotent — the .env file is never rewritten
BOR_DOCS_USER — (empty) The docs-push remote's username; travels to git as the GIT_CURL_USERNAME environment variable (never in the command line); empty = the push is anonymous (phase 173)
BOR_DOCS_TOKEN — (empty) The docs-push remote's token — write-only: the settings API reports is_set and never the value, and the admin panel renders it masked; travels to git as GIT_CURL_PASSWORD (with GIT_TERMINAL_PROMPT=0); empty = the credential env is not set at all (phase 173)
BOR_DOCS_BRANCH bor-docs Branch pushes land on (git push --ff-only — you open the PR yourself)
BOR_DOCS_BASE_BRANCH main Branch each push is cut fresh from
BOR_DOCS_WORK_DIR ~/bor-docs Where BOR_DOCS_REPO is checked out on the server

Admin & debugging

Env Default Meaning
BOR_ADMIN_PASSWORD (required) The single admin's password — app refuses to start when empty
BOR_SESSION_SECRET (required) Signing key for the bor_session cookie
BOR_SESSION_MAX_AGE 43200 Session-cookie lifetime in seconds (12 h, sliding)
BOR_SESSION_COOKIE bor_session Name of the signed session cookie
DEBUGPY 0 1 ⇒ attach-on-demand debugpy on DEBUGPY_PORT
DEBUGPY_PORT 5678 debugpy listen port

Customizing the look

Every identity string is an env var: BOR_APP_NAME (display name), BOR_INPUT_PLACEHOLDER (chat composer placeholder), and BOR_FOOTER_TEXT (footer line) — all served by GET /api/config and applied by assets/brand.js at boot.

The colors are set from the admin Theme tab (/theme.html, admin-only — phase 91): the 17 palette colors (the 9 identity colors plus the 8 semantic state colors — success/error/accent families) and the three strings above are edited with text fields and color pickers, persisted in the ui_settings table, and injected into every served page as an inline <style> BEFORE first paint (no red flash, no pop-in). The browser-tab favicon follows the same effective palette: GET /favicon.svg renders the hexagon art from the saved colors on every request (no process cache — a theme change is visible on the next request, no restart) and degrades to the built-in art with 200 on a DB blip (never 500). In the Theme tab, a live (unsaved) pick of surface / brand / brand_ink repaints the favicon in real time (a CSP-legal data: URI), and Save / Reset / re-show restore the /favicon.svg route — the settled palette always wins. The old CSS-file theming is retired — a leftover BOR_THEME line in a deployment's .env is simply ignored. Leave everything unset and the app renders the built-in dark-tech palette byte-identically.


Checking retrieval quality

Ask the real pipeline (live aipi embeddings + the current KB) whether a question lands on the right document, with gate verdict and per-document cosine / FTS / fused scores:

uv run python -m scripts.eval_retrieval "How did I install gitlab?"
uv run python -m scripts.eval_retrieval --from-file questions.txt --top 8

Requires an aipi key — BOR_LLM_API_KEY in .env (the stock configuration) or $AIPI_KEY in the environment — and an imported knowledge base.


Debugging

debugpy is off by default and never imported unless you opt in:

DEBUGPY=1 uv run uvicorn app.main:app
# → log line: debugpy: remote debugging ENABLED, listening on 0.0.0.0:5678

Attach from VS Code (.vscode/launch.json):

{
  "name": "Attach to Brain of Reese",
  "type": "debugpy",
  "request": "attach",
  "connect": { "host": "localhost", "port": 5678 },
  "pathMappings": [
    { "localRoot": "${workspaceFolder}", "remoteRoot": "/app" }
  ]
}

The port is non-blocking and attach-on-demand: the app keeps running normally until you attach. Override the port with DEBUGPY_PORT.


QA / Testing

Three layers — the project rule is one phase, one Playwright suite (a phase may implement a user story or an owner-directed change; see AGENTS.md):

# Unit + integration (FastAPI TestClient)
uv run pytest

# Coverage gate (phases require >90% on app/)
uv run pytest --cov=app --cov-report=term-missing

# Lint + static types
uv run ruff check .
uv run pyright

# Playwright E2E — install the browser once:
uv run playwright install chromium

# Each phase's E2E runs IN ISOLATION — one file per run:
uv run pytest tests/e2e/test_import_documents.py -v --no-cov
uv run pytest tests/e2e/test_chat_rag.py -v --no-cov
# ...one file per phase in tests/e2e/ (the stories live in
# .agents/user_stories/, see .agents/phases/todo/).
#
# Isolation is REQUIRED, not a style preference: every suite shares one
# Postgres and one pair of fixed ports (the app on 8123, the mock aipi on
# 8901 — overridable with E2E_APP_PORT / E2E_MOCK_PORT), and each file
# truncates + reseeds the KB for its own fixture. Running the whole
# `tests/e2e/` directory in a single pytest session interleaves those
# fixtures and fails tests that pass file by file.

# To sweep every suite, loop over the files (one pytest process each):
for f in tests/e2e/test_*.py; do uv run pytest "$f" --no-cov -q || echo "FAIL $f"; done

Retrieval regression battery. tests/fixtures/retrieval_battery.txt is the tracked retrieval gate (phases 119 + 162): the name-your-tool questions, the versioned-name (qwen 3.8 27b) regression, the out-of-KB deflection sanity, and the besta / BESTÅ accent-fold pair. Run it against a KB with scripts.eval_retrieval (the real pipeline, real embeddings):

uv run python -m scripts.eval_retrieval --from-file tests/fixtures/retrieval_battery.txt

The exact expected ranks are recorded in the fixture's header comments; a question whose source is missing from the KB snapshot is recorded as skipped loudly, never silently dropped.

Deterministic E2E: by default the E2E app talks to a local mock aipi (tests/e2e/mock_llm.py) whose embeddings are real token-overlap vectors — so the cosine relevance threshold behaves like production. To run E2E against the live self-hosted models instead:

E2E_REAL_LLM=1 uv run pytest tests/e2e/test_chat_rag.py -v --no-cov

Production Deployment

Build the multi-stage image (frontend minified by esbuild in the builder stage, deps installed by uv, non-root runtime):

podman build -t brain-of-reese/app:latest .

Run standalone (bring your own Postgres + pgvector):

podman run -d --name brain-of-reese \
  -p 8000:8000 \
  -e BOR_DATABASE_URL=postgresql+psycopg://reese:SECRETPASSWORD@dbhost:5432/brain_of_reese \
  -e BOR_LLM_BASE_URL=https://aipi.reeseapps.com/v1 \
  -e BOR_LLM_API_KEY=$AIPI_KEY \
  -e BOR_ADMIN_PASSWORD=your-password \
  -e BOR_SESSION_SECRET=<the-hex-from-the-setup-above> \
  brain-of-reese/app:latest

BOR_ADMIN_PASSWORD and BOR_SESSION_SECRET are required — the fail-loud boot gate in Admin & Sign-in refuses to start while either is empty.

The entrypoint runs alembic upgrade head automatically on start.

Or run the whole stack from compose (app + db):

podman compose --profile prod up -d --build

The app service picks up BOR_ADMIN_PASSWORD / BOR_SESSION_SECRET from the shell environment (or an env file) — the same fail-loud gate applies.

Production hardening: app runs as non-root (uid 10001), slim image, healthcheck on /api/health, debugpy off unless DEBUGPY=1, all assets served locally (no CDN), BOR_ENVIRONMENT=production.

Reverse-proxy body size: a legal chat/save POST is now large — one conversation can carry a full-length answer (see Conversation payload limits) — so an nginx-style proxy in front of the app needs client_max_body_size ≥ 16 MB (the app itself sets no body limit); at the default 1 MB the follow-up of a long answer fails at the proxy instead of reaching the app.


Troubleshooting

  • 401 from aipi — set BOR_LLM_API_KEY (or $AIPI_KEY).
  • litellm.UnsupportedParamsError … encoding_format — the aipi proxy (litellm openai_like) rejects the encoding_format parameter. The app already works around this by POSTing a minimal {model, input} payload. If you see this, you are likely calling the endpoint with a different client — drop the parameter (or set litellm.drop_params = True on the proxy).
  • Embedding dimension mismatch — aipi changed models; run uv run python -m scripts.llm_probe, update BOR_EMBEDDING_DIM, then drop + recreate the chunks table.
  • No attach button / images ignored on import — image support is off (the default). Set BOR_IMAGES=1 in .env and restart — only when BOR_LLM_CHAT_MODEL supports vision. Turning the toggle off never destroys existing image documents (syncs prune-guard them).
  • Honest deflection (the amber deflection bubble) — every question passes the honesty gate: deflection happens when the best cosine similarity is below BOR_RELEVANCE_THRESHOLD (default 0.62) and the lexical signal is absent or not vector-corroborated (fts_hits = 0, or best cosine below BOR_LEXICAL_SUPPORT_FLOOR — default 0.35 — A8 revised 2026-09-14). A weak cosine with a lexical hit (name-your-tool questions) still gets a grounded answer, provided the best cosine clears the floor. When it does deflect, the LLM prompt carries weak-hit titles only (no document content), the reply admits it has no notes on that and offers 2-3 concrete alternative questions about things it does have notes on, the bubble renders amber with "Maybe try" chips derived from the closest indexed titles, and the query_log row records deflected=true. This is a feature, not a bug. With a small local model, a rare turn may still answer from general knowledge with an explicit disclosure when retrieval was borderline — the gate (A8 revised) minimizes this by keeping vector-unsupported docs out of the grounded prompt, and the disclosure is surfaced, never silent.
  • Answers deflect too often / too rarely — tune BOR_RELEVANCE_THRESHOLD (lower = answers more, higher = more honest deflection) and BOR_LEXICAL_SUPPORT_FLOOR (the best-cosine floor a lexical hit must clear to flip HIGH — lower = lexical hits ground more easily): 0.0 ⇒ the gate leans entirely on FTS hits; 1.0 ⇒ everything deflects unless a lexical hit is vector-corroborated. The embed model's cosines cluster in a ~0.6–0.85 band on the live KB, so the default is 0.62. Check real scores:
    SELECT question, top_score, fts_hits, deflected
    FROM query_log ORDER BY created_at DESC LIMIT 20;
    
  • KB offline banner in the chat — Postgres isn't running: podman compose up -d db.
  • Stuck "Thinking…" — the LLM is slow or down; the client's 300 s pre-token guard (TURN_TIMEOUT_MS) turns a hung stream into the error state automatically.

Planning & Architecture

The architecture, LOCKED decisions, and the phase roadmap live in .agents/PLAN.md; per-story specs in .agents/user_stories/.