# 🧠 Brain of Reese A chippy, honest **RAG chatbot** over your `~/Homelab` and `~/Deployments` projects. Point it at your notes β€” markdown, YAML, JSON, Python, plain text β€” ask it anything, 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](#image-support). > **Updated your notes?** Re-run the import β€” it's idempotent and only > re-embeds what changed: > ```bash > uv run python -m scripts.import_docs --prune > ``` > See [Updating the documents](#updating-the-documents) for details. --- ## Quick Start ```bash # 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 | | **Document viewer** | `/document.html?source=X&path=Y` | Token users & admins | | **Login** | `/login.html` | Everyone | | **Shared chat** | `/shared/` | 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 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`. ### RAG / Knowledge base (`/sources.html`) The indexed document list. The *Path* column opens each document in the same **almost-fullscreen modal**. 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. ### 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. ### 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. ### Shared chat (`/shared/`) A read-only snapshot of any conversation, shareable via a link. Open to everyone β€” no login or token required. ### 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 **Discard**ed 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) ```bash python -c 'import secrets;print(secrets.token_hex(32))' # β†’ paste into .env ``` ```env BOR_ADMIN_PASSWORD=your-password # plaintext β€” homelab scope, by design BOR_SESSION_SECRET= # 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"|"user"|"anonymous"}` | | `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 (sha256 per file), so a refresh after a normal editing session takes seconds: ```bash # 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](#pdf-documents-phase-126)). - 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](#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. ### 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 # .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 // ``` - 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 ` + `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 ` + `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). - 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 `` 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: the button shows **Syncing…** with a spinner while polling `GET /api/sync/status` every 2 s. During the import phase the label carries 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); the full untruncated label also rides the button title and the aria-live result line. 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 in a **collapsible "Thinking" block** above the answer bubble: it opens and fills in live while the model thinks, tucks itself away the moment the first answer token lands, and stays click-toggleable afterwards. 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-insensitive fixed substring); 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](#answer-images-phase-124) for the `show_image` tool.) 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 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) | --- ## 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 `` 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: ```bash 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/ # 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`) 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: /` β€” 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/.` 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, 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 `read`s 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. - **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 / (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 `` body leads with a one-line locator β€” `From / (page N)` for a PDF-embedded image (with `; Referenced in …` appended when markdown reference blocks are stored too β€” document block first), `Referenced in /: "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`). - **Served as text.** The document viewer renders the extracted text β€” never the binary PDF. --- ## Caching & deploys A deploy is a commit β€” and the browser must see it **without a hard refresh**. One Starlette middleware (`app/core/caching.py`) applies the rule at the transport layer: - **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=`), 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. 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. --- ## Configuration reference Everything is an environment variable (the `BOR_` prefix, plus `DEBUGPY*`) read from `.env`. **Every variable in [`.env.example`](.env.example) is documented below** β€” copy that file to `.env` and adjust. ### App & branding | Env | Default | Meaning | |-----|---------|---------| | `BOR_APP_NAME` | `Brain of Reese` | Display name everywhere (page titles, header brand, status labels) | | `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`) | ### 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_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 `` (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 `` prompt section | | `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) | | `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_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 | | `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) | | `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 `