feat(chat): stop an in-flight answer — Send becomes Stop, the partial is kept and persisted, the model stream is torn down

This commit is contained in:
2026-08-29 17:27:04 -04:00
parent 6bf7f456d4
commit 1a60ecbd8b
186 changed files with 1738 additions and 7796 deletions
-92
View File
@@ -1,92 +0,0 @@
# Story: Admin Sync Button (One-Click Doc Import Sync)
**Phase:** `32_admin_sync_button.md` · **E2E:** `tests/e2e/test_sync_button.py`
## Narrative
As **the admin (owner)**, I don't want to SSH in and hand-run the import
script every time my notes repos move. I want **a button that only I can
see** — on the Sources page — that **triggers a doc import sync by
cloning the relevant repos and then running the import script**, with
live feedback so I always know whether it is running, what it changed,
or why it failed.
- **Given** `BOR_GIT_SOURCES` is set (the git repos) and I am signed in
as the admin
- **When** I click **Sync sources** on the Sources page
- **Then** the app clones/pulls each repo, re-imports with prune (the
`--prune` equivalent — the canonical "mirror the repos" action), and
refreshes the KB overview when anything changed — while the button
reports the whole lifecycle (**Syncing…** → **Synced HH:MM** + counts,
or an error banner naming the failure) and never sits stale or stuck.
Anonymous visitors never see the button, and the sync endpoints answer
them with 403.
## Acceptance criteria
1. **Admin-only visibility.** The button ships `hidden` in
`sources.html` (anonymous-safe) and `header.js` reveals it for the
admin on the SAME cached whoami that reveals `#nav-sources` /
`#nav-tuning` (one fetch, no extra whoami call); anonymous users
never see it.
2. **The sync API (A10 extended, A12 in-process).** `POST /api/sync`
(admin only) starts one background task — `clone_or_pull` each
`BOR_GIT_SOURCES` repo (phase 28, reused) → `import_sources(prune=True)`
→ `regenerate_overview` when the KB changed (phase 31) — and returns
`202`. A second trigger while a run is in flight returns
`409 {"detail": "a sync is already running"}` (one sync at a time).
`GET /api/sync/status` (admin only) reports `idle | running |
success | failed` with ISO-8601 timestamps and the run's `detail` /
`error`. Both endpoints answer anonymous callers with 403.
3. **Loud failure.** An unset/empty `BOR_GIT_SOURCES` fails the sync with
"no git sources configured (BOR_GIT_SOURCES)" (manual `--source` dirs
have no repo to clone); a `GitSyncError` fails the run with git's
stderr (the repo named, credentials masked) before any import.
4. **The §7.4 "never stale" lifecycle.** Click → `202` → button disabled
with **Syncing…** (spinning icon, `aria-busy`) + a 2 s poll of the
status endpoint — the ONLY feedback timer; there is no client-side
hard timeout (a sync can legitimately run for minutes; the server
state is authoritative). Success → enabled, **Synced HH:MM** (local
time of `finished_at`) + the last result in `#sync-result`
(`role="status"` / `aria-live="polite"`; "added" always announced,
zero terms omitted, a no-op run reads `0 added · 1 unchanged`).
Failure → enabled, retry-ready **Sync sources** + the `role="alert"`
banner naming the error. A `409` adopts the in-flight run (never a
second poll loop); a reload mid-sync re-attaches to the running run;
a `403` hides the button (defense in depth).
5. **Idempotent.** Re-syncing an unchanged repo is a fast-forward pull +
sha256 hash skip — nothing re-embedded, the overview left alone
(change-gated), the result `0 added · 1 unchanged`.
6. **Quality gates.** Integration (`tests/integration/test_sync_api.py`):
anonymous 403s; idle → running → success/failed transitions with git +
import + overview mocked; 409 double trigger; `GitSyncError` →
`failed` with the repo named and the import never called; `prune=True`
asserted. Unit (`tests/unit/test_sync_button.py`, frontend-assertion):
the ship-hidden markup, the header reveal, the state machine (2 s
poll, 202/409/403 branches, terminal labels, single-poll guard, no
client timeout), the CSS states (spin + reduced-motion opt-out,
disabled, focus-visible, contrast ≥ 4.5:1, 44 px touch floor).
Coverage `app/` > 90 % (`app/api/sync.py` fully covered).
7. **E2E (this story's gate).** `tests/e2e/test_sync_button.py`, run in
isolation: a real local `file://` git fixture repo (deterministic, no
network — git is a documented environment prerequisite, phase 28) with
the mock LLM proves the admin-only visibility, the full lifecycle
against the REAL clone → import → overview path (including the
idempotent second run and the fresh `kb_overview` row), and the 409
double trigger.
## Playwright Mapping Rule
`tests/e2e/test_sync_button.py` — run in isolation (Chromium +
`podman compose up -d db` + git on PATH; mock LLM, no live aipi). The
module overrides the session app fixture with per-module env
(`BOR_GIT_SOURCES=file://<fixture repo>`, its own `BOR_SOURCES_DIR`) and
truncates the KB tables before each test (the E2E isolation pattern):
1. `test_anonymous_sees_no_button` → AC 1 + 2 (the button never leaves
`hidden`; both endpoints 403).
2. `test_admin_sync_lifecycle` → AC 2 + 4 + 5 (button visible for the
admin; click → **Syncing…** (disabled) → **Synced HH:MM** +
`1 added`; the fixture path `notes/sync-fixture.md` in the Sources
table; the `kb_overview` row fresh and non-empty (DB check); the
idempotent second run → `0 added · 1 unchanged`).
3. `test_double_trigger_409` → AC 2 (second trigger while running → 409
with the exact detail; the single in-flight run still completes).
@@ -1,103 +0,0 @@
# Story: Agent makes as many tool calls as it wants
**Phase:** `45_agent_unlimited_tools` · **Source:** `TODO.md` L8 ·
**E2E:** `tests/e2e/test_agent_unlimited_tools.py`
## Bug report (verbatim, `TODO.md` L8)
> "Allow the LLM to make as many tool calls as it wants, remove the
> restrictions, they're causing problems getting correct answers"
## Narrative
As **the owner**, the phase-37 per-turn budgets — one `list_documents`
call and one `read_document` call — cap answers: complex questions need
several documents, and the "No reading budget left" refusal is where
correct answers die. The per-tool restrictions are being **removed**:
the model may call the tools as many times as it needs, within a single
configurable **round cap** that exists only to stop a pathological
infinite loop (and doubles as the no-tools kill switch).
- **Given** a grounded chat turn (retrieval found relevant docs)
- **When** the model calls `list_documents` / `read_document`
- **Then** every valid call is executed — re-lists included — until the
model answers or the round cap is reached, at which point a final
no-tools answer is forced.
## Acceptance criteria
1. **No per-tool budgets:** `BOR_AGENT_LIST_CALLS` /
`BOR_AGENT_READ_CALLS` are gone from `app/config.py`,
`.env.example`, and the agent loop; the `LIST_EXHAUSTED` /
`READ_EXHAUSTED` refusals no longer exist.
2. **Round cap only:** new `agent_max_rounds`
(`BOR_AGENT_MAX_ROUNDS`, default **10**) counts tool rounds; at the
cap the loop forces one final `tools=None` answer. `0` disables the
tools entirely — the request goes out with `tools=None`,
byte-identical to the pre-phase-37 path (the kill switch survives,
per the owner-locked revision).
3. **Non-budget rejections kept:** unknown tool → `"Unknown tool."`,
missing args → the `MISSING_READ_ARGS` refusal, already-in-context
document → `"Already in your context."` — none of these consume a
round's *budget* (there is none) but the round cap still bounds a
stream that keeps emitting rejected calls.
4. **Everything downstream unchanged:** the `tool` SSE event shape, the
per-turn `tool_calls=N` log field (budget-consuming → now: executed),
`done.sources` extension, and the UI tool lines are untouched.
5. **PLAN recorded:** the phase-37 locked decision
("budgets-as-kill-switch") is revised in `.agent/PLAN.md` with an
owner-permission note (2026-08-27, `TODO.md` L8), following the
established revision-note pattern (phases 16/19/24/37).
## Owner-confirmed (2026-08-27, roadmap R2)
1. **Both budget env vars removed.** New single guard
`BOR_AGENT_MAX_ROUNDS` (default **10** tool rounds, then forced final
answer); **`0` = no-tools kill switch** (pre-phase-37 behavior).
2. **Re-listing is allowed** (a second `list_documents` is a normal
executed call — it even counts in `tool_calls=N`).
## UI Visualization & Structure
- **Config** (`app/config.py`): delete `agent_list_calls` /
`agent_read_calls`; add `agent_max_rounds: int = 10` with a docstring
(0 disables the tools entirely — the loop makes exactly one request
with `tools=None`). `.env.example`: the two `BOR_AGENT_*_CALLS` lines
are replaced by `BOR_AGENT_MAX_ROUNDS=10` with an updated comment.
- **Agent loop** (`app/rag/agent.py`): `run_agent` —
`max_rounds = settings.agent_max_rounds`; `tools = AGENT_TOOLS if
max_rounds > 0 else None`; the loop drops `list_left` / `read_left`
and the budget-driven `tools = None` transition; after each executed
call `rounds += 1` and at `rounds >= max_rounds` the existing forced
final answer path runs (now the *only* exit besides "no calls").
`_execute_tool` loses its budget parameters + the two exhaustion
constants; `AGENT_TOOLS`' `read_document` description drops "exactly
one more"; module/docstrings and the probe reference updated.
`AgentHolder` unchanged (`tool_calls` still counts executed calls).
- **Tests:** `tests/unit/test_agent.py` rewritten around the round cap
(always-listing mock LLM: N rounds then forced answer; `max_rounds=0`
→ single `tools=None` request; rejected-call spam bounded by the cap);
`tests/unit/test_config.py` (default 10, env override, 0);
`tests/integration/test_chat_api.py` budget fixtures →
`agent_max_rounds`; `tests/e2e/mock_llm.py` `_tool_flow` extended: the
existing deterministic 3-step flow stays, plus a multi-read variant
triggered by a marker (e.g. the user message containing
`"read two documents"`) — stateless classification by counting
`Document …:` tool messages (list → read #1 → read #2 → answer).
- **Non-goals:** no per-call cost cap, no streaming change, no new
endpoint.
## Playwright Mapping Rule
**Test Scenario → `tests/e2e/test_agent_unlimited_tools.py`** (mock
LLM; DB up):
1. `test_multi_read_turn` — a grounded question carrying both
`TOOLS_TRIGGER` and the multi-read marker: the turn streams tool
lines for `list_documents` **and two** `read_document` calls, then a
final answer; the bubble is not deflected.
2. `test_done_sources_include_reads` — the final `done` (observed via
the source chips) lists the retrieved doc(s) plus **both** read
documents, deduped.
3. `test_relist_allowed` (unit-level via integration, plus E2E
observable state) — a re-listed catalog does not produce a refusal
line; the UI shows a tool line per executed call.
4. `test_single_tool_flow_regression` (phase 37) — the original
3-step flow still answers with exactly one read (runs against the
unchanged `tests/e2e/test_agent_document_tools.py` in the
regression pass, not duplicated here).
@@ -1,75 +0,0 @@
# Story: Archive upload sources
**Phase:** `49_archive_upload_sources` · **E2E:** `tests/e2e/test_archive_upload_sources.py`
## Narrative
As **the admin (owner)**, the "Add a local directory" form makes me type
server paths — but the directories I want to index often live on *another
machine*. I want to **upload a tarball or zipfile** on the Sources
(`/git-sources.html`) page: the server **unpacks it and scans it** (the
content is indexed, visible in the RAG catalog immediately after the
upload settles). Re-uploading the **same filename** must **replace that
source in place** — same folder, same list row, previous content
overwritten — never a second folder or a duplicate row.
- **Given** I am signed in as admin, on the Sources page, and I have
`homelab.tar.gz` (containing `k3s.md`, `gitlab.md`)
- **When** I upload it
- **Then** it is unpacked to a server folder named after the archive
(`homelab`), imported (added/updated/pruned counts shown), appears in
the source list with the Local badge, and its documents show up in the
RAG catalog (`/sources.html`).
- **Given** I later edit the tarball (drop `gitlab.md`, add `caddy.md`)
and re-upload **`homelab.tar.gz`** (same name)
- **When** the upload settles
- **Then** there is still exactly one `homelab` folder and one list row;
`gitlab.md` is pruned from the index, `caddy.md` is indexed, `k3s.md`
is unchanged.
## Acceptance criteria
1. `POST /api/git-sources/upload` (multipart `file`, admin-only —
anonymous 403 like the rest of the router): accepts
`.tar`, `.tar.gz`, `.tgz`, `.zip` (else 422 naming the accepted set);
derives the source name from the filename minus the archive suffix
(`homelab.tar.gz` → `homelab`); rejects empty/unsafe names (422);
caps compressed upload AND extracted bytes at `BOR_UPLOAD_MAX_MB`
(default 512 — new settings `BOR_UPLOAD_DIR`, default
`~/bor-sources/uploads`, and `BOR_UPLOAD_MAX_MB`); blocks zip-slip /
tar-slip (absolute members, `..`, symlink/hardlink escapes, device
files) with a 422 and **no partial state** — a failed upload never
touches an existing folder, row, or the KB.
2. Successful upload: unpack → atomic swap-in of the folder → the
`git_sources` row is upserted by path (`kind=local`, no new row when
the path exists) → fail-fast model check (phase 41) →
`import_sources([folder], prune=True)` (single source) →
`regenerate_overview` when the KB changed → **200 with the same count
keys as the sync detail** (`files, added, updated, unchanged, pruned,
errors, chunks, overview`); one upload at a time (409 while a run is
in flight — the phase-32 pattern); a per-upload log line (PLAN §9).
3. Re-upload of the same filename replaces the folder's content in place
(temp unpack + rename swap — no missing window) and prunes files that
left the archive; no duplicate folder, no duplicate row.
4. The page: the "Add a local directory" form is **removed**; a labeled
archive upload form (file input `accept=".tar,.tar.gz,.tgz,.zip"`,
inline error `role=alert`, result line `role=status`, never-stale
button per §7.4 showing the counts) takes its place; the hint and
table caption mention upload+scan and in-place replace; existing
Local rows (incl. pre-existing hand-added dirs) still list/remove.
The `POST /api/git-sources` `kind=local` API contract is unchanged
(the capability survives via the API — no regression).
5. The phase-38 story E2E (`test_local_directory_sources.py`) is
rewritten to add local sources via the API (`page.request.post`)
instead of the removed form — its other assertions (Local badge,
sync-import, prune-remove, 422 naming the path) stand.
6. Unit + integration green, `app/` coverage >90%, story E2E green in
isolation, ruff + pyright clean, one `--no-gpg-sign` commit.
## Playwright Mapping Rule
`tests/e2e/test_archive_upload_sources.py` — one story, one file, run in
isolation: the admin uploads a real (test-built) tarball through the
page's file input → counts shown + Local-badged row named after the
archive stem + documents visible via the catalog; re-upload of the same
filename (modified archive) → still one row/folder, dropped file pruned,
new file indexed; a non-archive file gets an inline error; the
local-directory form is absent; anonymous still gets the gate.
-128
View File
@@ -1,128 +0,0 @@
# Story: Animated Background That Actually Animates
> **SUPERSEDED (owner direction 2026-08-25):** the phase-22 motion
> design (grid drift + whole-layer breathe) is superseded by the
> owner's "no movement, only fading light" direction — see
> `.agent/user_stories/background-no-motion.md` (phase
> `25_background_no_motion`). The phase-22 history below is preserved
> as-is; the old E2E suite now pins the phase-25 contract as a
> regression.
**Phase:** `22_background_animation` · **E2E:** `tests/e2e/test_background_animation.py`
## Narrative
As **the owner**, I reported (2026-08-24) that the Brain of Reese
background "just blinks". I want the background the phase-08 design
promised — a slow, seamless grid drift plus a gentle glow breathe — to
be *perceived* as smooth motion in a real browser, so the app feels
alive but calm, not like a broken flicker.
- **Given** the phase-08 pure-CSS background (a 60s grid drift masked
into a small top band + a 14s glow opacity swing)
- **When** the page is observed in a real Chromium viewport
- **Then** both layers visibly and smoothly animate — the grid drift
reads as continuous motion and the glow reads as breathing — with no
blink, no static frame, no jank, no new overflow at 360px, and no
impact on text contrast or interactivity.
## Bug report (verbatim, TODO.md L5)
> Fix background animation not working, just blinking
## Owner-confirmed (2026-08-24, roadmap A3)
1. **Intended effect = the phase-08 design comments:** a seamless 60s
grid drift (one cell per loop) + a 14s eased glow breathing. The fix
serves that design — it is not a redesign.
2. **Pure CSS, zero JS** (phase-08 anchor) — no animation JS, no new
assets, no `filter: blur`.
## Root cause (found in task 01 — `.agent/reports/22_background_animation/`)
Both layers *were* animating and nothing occluded them (occlusion
disproven with a red-line probe) — but:
1. **The grid drift was invisible.** 1px lines at 35% `--line` alpha
blend to a ≈10/12/18-level delta over the page bg, and the radial
mask faded the layer to ~zero past the top ~25% of the viewport. A
0.73px/s drift of that faint texture sits below the perception
threshold (measured ≈1.4/765 mean pixel change over 5s in the grid
zone; ≈0 in the bottom half — no grid there at all).
2. **The glow swing was the only visible motion.** The 0.65↔1.0 opacity
swing (35% amplitude, frame-diff concentrated exactly on the two
glow centers) was 100% of the perceived animation → it read as a
blink/pulse, not breathing.
## Fix (styles.css — pure CSS, zero JS, no blur, palette untouched)
| property | phase 08 | phase 22 |
|---|---|---|
| grid line alpha | 35% `--line` ×2 | **60% `--line` ×2** |
| grid mask | `120% 90% at 50% 0%, black 25%, transparent 78%` | **`140% 110% at 50% 0%, black 40%, transparent 90%`** |
| grid drift | 60s, 0→44px | **unchanged** (one-cell seamless loop) |
| glow opacity swing | 0.65↔1 | **0.85↔1** |
| glow period/scale | 14s ease-in-out alternate, 1↔1.05 | **unchanged** |
Both layers keep `position: fixed; inset: 0; z-index: -1;
pointer-events: none`; `<html>` keeps the `var(--bg)` canvas and
`<body>` stays transparent (the no-occlusion contract); the
`prefers-reduced-motion` stilling block is untouched.
## Acceptance criteria
1. In a real Chromium viewport, both background layers run their
animations (grid drift + glow breathe) and the animation timelines
advance — the background is not a static or paused frame.
2. No blink: the glow reads as breathing (0.85↔1), not a pulse
(0.65↔1); the grid drift reads as smooth continuous motion.
3. Layers stay behind content: `position: fixed`, `z-index: -1`,
`pointer-events: none`, full-viewport `inset: 0`; `<html>` keeps
the page background and `<body>` stays transparent (no occlusion).
4. No new horizontal overflow at 360px (the phase-07 pin).
5. Pure CSS, zero JS, no `filter: blur`, no new assets (phase-08
anchor); WCAG AA palette untouched (the layers carry no text).
6. Regressions green in isolation: `test_dark_tech_theme.py` (pins the
live 60s/14s durations + reduced-motion stilling) and
`test_responsive_polish.py`.
7. Unit + integration green, `app/` coverage ≥90%, story E2E green in
isolation, ruff + pyright clean.
## UI Visualization & Structure
- **Grid layer (`body::before`):** 44px cells, 1px lines at 60% of
`--line` (`rgb(38 48 74 / 0.6)`), radial mask fully visible to 40% of
the radius and faded out by 90%, 60s linear infinite drift of
exactly one cell (0→44px) — seamless loop.
- **Glow layer (`body::after`):** indigo `rgb(109 120 242 / 0.14)`
56rem circle at 12%/8% + cyan `rgb(34 211 238 / 0.10)` 60rem circle
at 88%/92%; 14s ease-in-out infinite alternate, opacity 0.85↔1 +
scale 1↔1.05.
- **Stacking:** the `<html>` canvas (`var(--bg)` = `#0a0e17`) sits
under both `z-index: -1` layers; `<body>` stays transparent and
non-stacking, so nothing can paint over the layers.
- **Motion:** compositor-friendly `background-position` / `opacity` /
`transform` only; no blur filter; `prefers-reduced-motion` stills
both layers (phase-08 guard, unchanged).
## Playwright Mapping Rule
**Test Scenario → `tests/e2e/test_background_animation.py`** (the
layers are CSS pseudo-elements — asserted via computed style + the Web
Animations API; Chromium enumerates pseudo-element CSS animations in
`document.getAnimations()`, not `document.body.getAnimations()`):
1. `test_grid_layer_animation_running` — computed style of
`body::before`: `animationName` = `bg-grid-drift`, timing function
`linear`, iteration count `infinite`; a matching entry in the
document animation list with `playState === "running"`.
2. `test_glow_layer_animation_running` — same for `body::after` with
`bg-glow-breathe`; `playState === "running"`.
3. `test_animations_advance` — sample `currentTime` of both layers,
wait ~500ms, assert both advanced — truly running, not paused
(the headless document animation timeline starts ~1s after load, so
the sample polls until the timeline is alive first).
4. `test_background_layers_contracts` — both pseudo-elements:
`position: fixed`, `z-index: -1`, `pointer-events: none`,
`inset: 0`; `document.documentElement` computed background is the
palette bg (canvas stays on `html`); `document.body` computed
background is `rgba(0, 0, 0, 0)` (no occlusion).
5. `test_no_horizontal_overflow_with_layers` — 360px viewport:
`documentElement.scrollWidth <= clientWidth` (the phase-07 pin,
replicated locally).
-181
View File
@@ -1,181 +0,0 @@
# Story: A Background That No Longer Moves — Only Fading Light
**Phase:** `25_background_no_motion` · **E2E:**
`tests/e2e/test_background_no_motion.py`
**Supersedes:** `.agent/user_stories/background-animation.md`
(phase-22 motion design — history preserved there)
## Narrative
As **the owner**, I reported (2026-08-25, chat) that the phase-22
background "jitters down and to the right every second and it slowly
blinks brighter and darker". I want the background to be smooth,
fluxuating, dimming and brightening — but **not moving** — with
**different bright spots** that slowly fade in and out.
- **Given** the phase-22 animated background (a 60s one-cell grid drift
at 0.73px/s diagonally down-right + a 14s whole-layer glow breathe of
opacity 0.85↔1 with scale 1↔1.05)
- **When** the page is observed in a real Chromium viewport
- **Then** nothing moves — the grid is a static texture and no
background keyframe animates anything but `opacity` — while three
independent bright spots each fade in and out on their own slow,
out-of-phase cycles (26s/34s/42s), so the total light fluxuates
smoothly and irregularly: no jitter, no blink, no static frame, no
new overflow at 360px, and no impact on text contrast or
interactivity.
## Owner report (verbatim, 2026-08-25, chat)
> It should be smooth, fluxuating, dimming and brightening, but not
> moving. Different bright spots should slowly fade in and out.
## Owner direction (2026-08-25)
1. **No movement** — no grid drift, no `scale`/`transform`, no
`background-position` animation, anywhere in the background.
2. **Fluxuating brightness** — overall page brightness varies smoothly
and irregularly (not one synchronized pulse).
3. **Different bright spots** — multiple glow spots, each fading in
and out on its own slow cycle.
4. **The static grid stays** — the owner rejected the grid's *motion*,
not the grid; it remains as a still texture. (If the owner later
wants the grid gone, that is a follow-up, not this phase.)
## Root cause (found from the code + phase-22 measurements)
1. **"Jitters down and to the right every second" = the grid drift.**
`bg-grid-drift` moved the 44px grid 44px per 60s (≈0.73px/s)
diagonally down-right — exactly the reported direction. A 1px grid
line translated sub-pixel by sub-pixel is rasterized with per-frame
stepping/shimmer, not smooth motion: it reads as a once-per-second
jitter. Phase 22 had made that drift *visible* (60% line alpha,
wider mask — measured 1.82/765 mean pixel change over 5s in the
grid zone); that is precisely why it now reads as jitter.
2. **"Slowly blinks brighter and darker" = the whole-layer breathe.**
`bg-glow-breathe` swung the ENTIRE glow layer's opacity 0.85↔1 over
14s (alternate) plus `scale(1)↔scale(1.05)` (a faint zoom). One
synchronized pulse of the whole background reads as a blink; the
owner wants independent spots instead.
Phase 22 served the phase-08 design intent (grid drift + whole-layer
breathe). The owner's 2026-08-25 direction supersedes that **design
intent** — no LOCKED anchor changed (A11 stays pure CSS / zero JS / no
CDN / no new assets; the no-`filter: blur` perf anchor is honored).
## Fix (styles.css — pure CSS, zero JS, no blur, palette untouched)
| property | phase 22 | phase 25 |
|---|---|---|
| grid (`body::before`) | `bg-grid-drift 60s linear infinite` (0→44px) | **static** — no animation, `bg-grid-drift` deleted (44px cells, 60% `--line` 1px lines, widened mask kept) |
| glow layer count | one whole-layer breathe | **three independent spot layers** |
| spot A (`body::after`) | indigo + cyan spots, 14s opacity 0.85↔1 + scale 1↔1.05 | **indigo `rgb(109 120 242 / 0.14)` 56rem at 12%/8%**, `bg-glow-a` **26s** ease-in-out infinite, low opacity **0.25** |
| spot B (`html::before`) | — (cyan shared body::after) | **cyan `rgb(34 211 238 / 0.10)` 60rem at 88%/92%**, `bg-glow-b` **34s** ease-in-out **−12s** infinite, low **0.20** |
| spot C (`html::after`) | — | **indigo `rgb(109 120 242 / 0.09)` 52rem at 14%/86%**, `bg-glow-c` **42s** ease-in-out **−23s** infinite, low **0.15** |
| keyframes | `bg-grid-drift` (background-position), `bg-glow-breathe` (opacity + transform) | **`bg-glow-a/b/c` — opacity only** (0%/100% low → 50% 1) |
| reduced motion | stills `body::before/::after` | stills **all four** layers |
All four layers keep `content: ""; position: fixed; inset: 0;
z-index: -1; pointer-events: none`. `<html>` keeps the `var(--bg)`
canvas and `<body>` stays transparent (the no-occlusion contract):
`html` is the root stacking context, so its `z-index: -1`
pseudo-elements paint above the canvas and below the transparent,
non-stacking `<body>`'s content. The 26/34/42s periods with negative
delays (LCM 4641s) keep the cycles out of phase — the composite
pattern effectively never repeats within a viewing session.
## Acceptance criteria
1. **No movement:** the grid is static (`body::before` computed
`animationName: none`; no `bg-grid-drift` in
`document.getAnimations()`; grid texture still painted) and —
audited in real Chromium via `document.styleSheets` — **no `bg-*`
keyframe animates anything but `opacity`** (the deterministic
no-movement proof).
2. **Three distinct bright spots** (`body::after`, `html::before`,
`html::after`) run distinct slow opacity fades (26s/34s/42s,
ease-in-out, infinite, pairwise distinct, out of phase); all three
timelines advance; the layer's computed opacity AND a clipped
screenshot of the bottom-left glow region measurably change within
a few seconds (a real fade, not a frozen frame).
3. **Contracts hold:** all four layers `position: fixed`, `z-index:
-1`, `pointer-events: none`, full-viewport `inset: 0`; `<html>`
keeps the `var(--bg)` canvas (`rgb(10, 14, 23)`) and `<body>` stays
transparent (`rgba(0, 0, 0, 0)`) — no occlusion.
4. **Reduced motion** stills all four layers
(`animationName: none`), the static grid + spot images remain.
5. No new horizontal overflow at 360px (the phase-07 pin).
6. Pure CSS, zero JS, no `filter: blur`, no new assets (A11 + phase-08
perf anchor); WCAG AA palette untouched (the layers carry no text).
7. Regressions green in isolation:
`tests/e2e/test_background_animation.py` (adapted to the phase-25
contract), `tests/e2e/test_dark_tech_theme.py` (grid static +
26/34/42s spots; reduced motion across all four layers),
`tests/e2e/test_responsive_polish.py`.
8. Unit + integration green, `app/` coverage >90%, story E2E green in
isolation, ruff + pyright clean.
## UI Visualization & Structure
- **Grid layer (`body::before`):** 44px cells, 1px lines at 60% of
`--line` (`rgb(38 48 74 / 0.6)`), widened radial mask
(`140% 110% at 50% 0%, black 40%, transparent 90%`) — a STATIC
texture, no animation.
- **Glow spot A (`body::after`):** indigo `rgb(109 120 242 / 0.14)`
56rem circle at 12%/8% (phase-08 position/color); opacity-only fade
0.25↔1 over 26s ease-in-out.
- **Glow spot B (`html::before`):** cyan `rgb(34 211 238 / 0.10)` 60rem
circle at 88%/92% (phase-08 position/color); fade 0.20↔1 over 34s,
−12s delay.
- **Glow spot C (`html::after`):** indigo `rgb(109 120 242 / 0.09)`
52rem circle at 14%/86%; fade 0.15↔1 over 42s, −23s delay.
- **Stacking / no occlusion:** the `<html>` canvas
(`var(--bg)` = `#0a0e17`) sits under all four `z-index: -1` layers;
`<body>` stays transparent and non-stacking, so nothing can paint
over the layers — verified live, not assumed.
- **Motion:** opacity-only keyframes (compositor-friendly); no
`transform`, no `background-position`, no `filter` anywhere in the
background; `prefers-reduced-motion: reduce` stills all four layers
(the static background remains visible).
## Playwright Mapping Rule
**Test Scenario → `tests/e2e/test_background_no_motion.py`** (the
layers are CSS pseudo-elements — asserted via computed style + the Web
Animations API + a live `document.styleSheets` audit; Chromium
enumerates pseudo-element CSS animations in `document.getAnimations()`,
not `document.body.getAnimations()`, and the `html` pseudo-layers'
computed styles come from
`getComputedStyle(document.documentElement, "::before"/"::after")`):
1. `test_grid_layer_is_static` — computed `animationName` of
`body::before` is `"none"`; no `bg-grid-drift` entry in
`document.getAnimations()`; the grid `backgroundImage` is still
present (the static texture survives).
2. `test_three_glow_layers_run_distinct_fades` — `body::after` →
`bg-glow-a` (26s), `documentElement::before` → `bg-glow-b` (34s),
`documentElement::after` → `bg-glow-c` (42s); each `ease-in-out` +
`infinite`, with a matching `playState === "running"` entry in the
document animation list; the three durations are pairwise distinct.
3. `test_no_motion_properties_in_background_keyframes` — walk
`document.styleSheets`; for every `CSSRule.KEYFRAMES_RULE` whose
name starts with `bg-`, collect the declared property names of
every keyframe frame; the set across all frames is exactly
`{"opacity"}` — the deterministic no-movement proof.
4. `test_glow_timelines_advance` — poll until all three timelines
report `currentTime > 0` (headless Chromium starts the document
timeline ~1s after load), sample all three, wait ~500ms, each
advanced ≥ 200ms.
5. `test_background_light_actually_changes` — (a) the computed opacity
of `body::after` changes by ≥ 0.05 within ~8s (a real fade, not a
frozen frame); (b) two clipped screenshots ~4s apart of the
bottom-left glow region (the `html::after` spot at 14%/86%) differ
in bytes — the light visibly changes while nothing moves.
6. `test_background_layers_contracts` — all four pseudo-layers:
`position: fixed`, `z-index: -1`, `pointer-events: none`,
top/right/bottom/left all `0px`; `documentElement` computed
background is `rgb(10, 14, 23)` (canvas stays on `html`);
`document.body` computed background is `rgba(0, 0, 0, 0)` (no
occlusion).
7. `test_reduced_motion_stills_all_layers` —
`reduced_motion="reduce"` context: all four pseudo-layers report
computed `animationName` `"none"` and still carry a
`backgroundImage`.
8. `test_no_horizontal_overflow_with_layers` — 360px viewport:
`documentElement.scrollWidth <= clientWidth` (the phase-07 pin).
-91
View File
@@ -1,91 +0,0 @@
# Story: Cache Busting (Un-stick the Pages)
**Phase:** `33_cache_busting/` · **E2E:** `tests/e2e/test_cache_busting.py`
## Narrative
As **anyone using Brain of Reese**, I want a new deploy to be **visible
without a hard refresh**. Today the five HTML pages reference their CSS/JS
with no version at all, so the browser keeps serving stale assets long
after the app has moved on — the "the pages are too sticky" report. I want
a deploy (a commit) to change what the browser fetches, automatically,
with no new services and no CDN.
- **Given** the app has been redeployed (a new commit)
- **When** I open — or revisit — any page
- **Then** the page itself is always revalidated (never served from cache
unchecked), its assets are fetched from **versioned URLs**
(`?v=<token>` — the git short SHA of the deploy, so each commit changes
them), and the API — the SSE chat stream in particular — is left
byte-for-byte alone.
## Acceptance criteria
1. **HTML always revalidates.** All five pages (`/`, `/index.html`,
`/sources.html`, `/document.html`, `/login.html`, `/tuning.html`) ship
`Cache-Control: no-cache` (the page is the cheap redirector to the
long-cached assets).
2. **Assets are versioned.** Every local `href`/`src` asset reference on
those pages carries `?v=<token>` — rewritten in flight by one
middleware (a small regex over `href`/`src` `assets/…` refs, leading
slash optional, idempotent: an existing query string or fragment is
never double-tagged, and non-asset references pass through). The token
is non-empty and **stable across requests** within a process (one token
per process, computed once).
3. **Immutable assets.** `/assets/*` responses ship
`Cache-Control: public, max-age=31536000, immutable` — safe precisely
because the URL carries the token (a deploy changes the token, hence
the URL). The unversioned path keeps resolving (the static mount
ignores the query string), so old tabs and direct links still work.
4. **The token is the deploy.** `app/core/caching.py::asset_version()`:
a git checkout (the project root next to `frontend/` has a `.git` —
the homelab reality) → `git rev-parse --short HEAD` with a 5 s timeout
(a deploy must not hang a boot); no `.git`, git missing, non-zero
exit, timeout, or empty output → the first **12 hex chars** of SHA-256
over the sorted `relpath:mtime_ns:size` of every regular file under
`frontend/`; a missing/empty static dir → `"dev"`. Cached with
`functools.cache` (== `lru_cache(maxsize=None)`) — zero per-request
git/file cost. A new commit or a frontend content change flips the
token on the next process start.
5. **The API is untouched.** The middleware touches exactly two response
shapes (the page paths: body rewrite + `no-cache`; `/assets/*`:
header only). Everything else — all of `/api/*`, including the SSE
chat stream — passes through with no header changes and no body read
(SSE keeps the `no-cache` its endpoint sets itself). The no-CDN
integration test stays green (the rewritten references are
same-origin).
6. **Quality gates.** Unit (`tests/unit/test_caching.py`): the token's
git path, fallback path (stable across an unchanged tree, flips on a
touch/modify + `cache_clear()`), failure path (git raising → content
hash, no exception), empty-dir `"dev"`; the pure `rewrite_asset_refs`
(versioned `href`/`src`, no-leading-slash refs, module scripts,
idempotency, `#fragment`/existing-query left alone, non-asset refs
untouched). Integration (`tests/integration/test_api.py`): every page
`no-cache` + versioned references, asset headers, no `cache-control`
injected on `/api/health`, SSE chat tests unchanged and green.
Coverage: `app/core/caching.py` 100 %, `app/` > 90 % (TOTAL ≥
pre-change).
7. **E2E (this story's gate).** `tests/e2e/test_cache_busting.py`, run in
isolation: a real (fresh-profile) Chromium asserts the **wire truth**
— the document responses are `no-cache`, the CSS/JS request URLs the
browser actually makes carry one shared token (matching this
checkout's `asset_version()`), the asset responses are immutable for a
year, and `/api/*` — including a live SSE chat turn that streams
deltas and completes with `done` — is unaffected (mock LLM, no live
aipi; `podman compose up -d db` for the chat check).
## Playwright Mapping Rule
`tests/e2e/test_cache_busting.py` — run in isolation (Chromium +
`podman compose up -d db`; mock LLM via the shared `conftest.py` session
app; no per-module overrides needed):
1. `test_html_pages_are_no_cache_and_versioned` → AC 1 + 2 + 3 (the `/`
document response is `no-cache`; the `styles.css` request URL carries
`?v=<token>` and its response is `immutable` + `max-age=31536000`;
the `app.js` request URL carries the **same** token; the served HTML
carries no unversioned `"/assets/styles.css"` reference).
2. `test_other_pages_share_the_token` → AC 1 + 2 (the `/sources.html` and
`/login.html` document responses are `no-cache`; both pages'
stylesheet requests carry the same token).
3. `test_api_responses_unaffected` → AC 5 (`/api/health` has no
`cache-control` injected — the endpoint's baseline headers only; the
SSE chat POST still streams deltas and completes with `done`).
-131
View File
@@ -1,131 +0,0 @@
# Story: Containerfile Builds the Whole App Again
**Phase:** `23_containerfile_build` · **Gate:** hermetic integration
`tests/integration/test_containerfile_assets.py` + real `podman build`
+ image smoke (no Playwright suite — build/infrastructure phase, per
the phase plan's A16 note)
## Narrative
As **the owner**, I reported (2026-08-24, TODO.md L6) that the
Containerfile build "is not working". I want
`podman build -f Containerfile .` to succeed again — and, more
importantly, for the resulting image to serve the **whole app**: all
four pages (chat, sources, document viewer, login) with their bundled,
minified, local-only assets (No CDN rule, A11), the shared header
module evaluated exactly once per page, and a build contract that
fails loudly when a future page/script/asset outgrows the Containerfile.
- **Given** the 3-stage `Containerfile` (esbuild frontend bundle → uv
python deps → slim runtime serving `/app/static`)
- **When** `podman build -f Containerfile .` runs
- **Then** the build is green and the image serves `/`,
`/sources.html`, `/document.html`, `/login.html` with 200 and every
referenced asset local and minified — with `header.js` inlined into
each page bundle exactly once.
## Bug report (verbatim, TODO.md L6)
> Fix Containerfile build not working
## Verified diagnosis (2026-08-24, phase 23 — not a guess)
1. **Root cause of the build failure:** phase 19 switched the page
scripts to `import … from "/assets/header.js"` (an absolute URL).
esbuild resolves that as the *filesystem* path `/assets/header.js`
and the stage-1 bundle dies:
`✘ [ERROR] Could not resolve "/assets/header.js"`
(reproduced with esbuild **0.25.5**, the exact pinned version, on a
copy of `frontend/` — `repro_esbuild_0.25.5.log`).
2. **Secondary gap (image would be broken even if it built):** stage 1
bundled only `app.js` + `sources.js` and copied only `index.html` +
`sources.html`. Missing from the image: `document.html` +
`login.html` (phases 10/16), `document.js` + `login.js`, and
`markdown.js` (classic script loaded by `index.html` +
`document.html`).
3. **Latent double-evaluation trap:** all four HTML pages also load
`<script type="module" src="/assets/header.js">` directly while the
page script imports it. In dev the browser dedupes (same module
URL) — but in the image the bundled page script already contains the
header code, so shipping a raw `header.js` too would evaluate the
module **twice** (duplicate sign-out listener, double init). The
direct tags are redundant: the page script's `import` is hoisted and
guarantees `header.js` evaluates before the page script's body calls
`initSharedHeader()`, in dev and in the bundle alike.
4. **Second build blocker (found in task 01):** a *local* `npm install`
of esbuild left the binary in `node_modules/.bin`, invisible to the
next `RUN` — `esbuild: not found`. The stage must install globally.
## Owner-confirmed (2026-08-24, roadmap A4)
1. **Relative imports** (`from "./header.js"`) over an esbuild alias —
simpler, verified working, dev-server behavior unchanged (files are
side-by-side).
2. **Remove the four redundant direct `header.js` script tags** (the
design above) rather than ship a raw `header.js` into the image —
single module evaluation, no duplicate listeners.
3. The image must cover **all four pages + all local assets** they
reference — the integration test (task 02) enforces this coverage so
the gap cannot silently reappear.
## Fix
| piece | before | after |
|---|---|---|
| page-script header import | `from "/assets/header.js"` (absolute — esbuild fatal) | **`from "./header.js"`** (relative, bundles cleanly) |
| direct header.js `<script>` tags | 4 (one per page — double-eval trap) | **0** (the hoisted import covers evaluation order) |
| esbuild install | local (binary off the next RUN's PATH) | **`npm install -g esbuild@0.25.5`** (pinned, on PATH) |
| stage-1 bundles | `app.js`, `sources.js` | **`app.js`, `sources.js`, `document.js`, `login.js`** |
| stage-1 minifies | `styles.css` | **`styles.css`**, **`markdown.js`** (no `--bundle` — classic script) |
| stage-1 copies | `index.html`, `sources.html` | **all four pages** into `/out` |
## Acceptance criteria
1. Local esbuild 0.25.5 bundles all four page scripts cleanly.
2. `podman build -f Containerfile .` green (log excerpt in
`.agent/reports/23_containerfile_build/`).
3. Image smoke: container runs (throwaway Postgres 17 + pgvector);
`GET /`, `/sources.html`, `/document.html`, `/login.html` → 200;
`/assets/app.js` minified and contains the header code;
`/assets/markdown.js` 200; no `http(s)://` asset reference in any
served page (No CDN rule).
4. Dev server unchanged in behavior: `test_smoke.py`,
`test_shared_header.py`, `test_chat_persistence.py` green in
isolation.
5. `test_containerfile_assets.py` 6/6 within the full suite;
`uv run pytest` green; `app/` coverage ≥ today's number (>90% gate);
ruff + pyright clean.
## Test mapping table
**Test Scenario → `tests/integration/test_containerfile_assets.py`**
(hermetic — parses `Containerfile` + `frontend/` as text; no podman,
no network, no DB):
1. `test_every_html_page_is_copied_into_stage1` — every
`frontend/*.html` is copied by a stage-1 `cp` into `/out`, and the
copied set is exactly the pages on disk (new page without cp entry
→ fail; cp of a deleted page → fail).
2. `test_every_local_asset_reference_is_produced` — every local
`assets/`/`/assets/` `src=`/`href=` in the pages is produced by a
stage-1 line (esbuild `--outfile=/out/assets/<name>` or a `cp`) —
the missing-`markdown.js`-style gap cannot reappear.
3. `test_page_module_scripts_are_bundled` — the `type="module"` page
scripts referenced by the HTML (basenames) equal the esbuild
`--bundle` inputs in stage 1 (today: `app.js`, `sources.js`,
`document.js`, `login.js`).
4. `test_header_module_is_imported_not_directly_loaded` — no HTML
carries a direct `<script … src="…/header.js">` tag (single-evaluation
design pin, owner-confirmed A4-2) and every page script imports it
relatively (`from "./header.js"` — no absolute form).
5. `test_markdown_js_is_a_produced_classic_script` — `markdown.js` has
a stage-1 minify line **without** `--bundle` and no top-level
`import`/`export` in the source (both sides of the classic-script
assumption pinned).
6. `test_esbuild_stays_pinned` — the frontend stage pins a concrete
`esbuild@X.Y.Z` (no `latest`/`^`/`~`/bare spec — the exact pinned
0.25.5 is what the diagnosis reproduced against).
Complementary source pins live in `tests/unit/test_shared_header.py::
test_header_module_loads_before_the_page_script` (unit) and the real
build + image smoke are recorded in
`.agent/reports/23_containerfile_build/` (task 01).
-54
View File
@@ -1,54 +0,0 @@
# Story: Open Documents in a Modal (Almost-Fullscreen)
**Phase:** `26_document_modal_viewer.md` · **E2E:** `tests/e2e/test_document_viewer.py`
## Narrative
As **a user**, when I click a document source chip (in the chat) or a
document path link (in the Sources table), I want the document to open
**right where I am** — in an almost-fullscreen modal overlay — instead of
navigating away to a new page/tab. I want to read it, then close the modal
and keep going.
- **Given** a document cited in a chat answer (or listed in Sources)
- **When** I click its chip / path link
- **Then** the document opens in an almost-fullscreen modal on the same
page, rendered exactly as the `/document.html` viewer renders it
(markdown in a centered column, other formats in a mono `pre`), with a
close button, Escape-to-close, and backdrop-to-close; and the dedicated
`/document.html` page still works for direct links / no-JS fallback.
## Acceptance criteria
1. Clicking a source chip or a Sources-table path link opens the document
in a modal (`#doc-modal`, `.doc-modal-panel`, ~`96vw × 92vh`) on the
current page — **no new tab, no navigation**.
2. The modal renders the same content as `/document.html`: md/markdown via
the shared escape-first renderer (`.doc-md`), other formats in
`<pre class="doc-raw">`, source/format/path/indexed/chunks meta.
3. The modal closes on the close button, on `Escape`, and on backdrop
click; focus moves into the panel on open and returns on close.
4. The modal honours the dark theme, `prefers-reduced-motion`, no-CDN, and
the ≥4.5:1 contrast / focus-visible a11y rules.
5. The standalone `/document.html` page is unchanged (direct link, back
button, not-found state, XSS-safe rendering).
6. No new packages; vanilla HTML/CSS/JS; the modal reuses the existing
stateless `GET /api/documents/content` endpoint (no backend change).
7. Story E2E green in isolation, no regressions in the document-navigation
/ header / smoke suites, one `--no-gpg-sign` commit.
## Playwright Mapping Rule
**Test Scenario → `tests/e2e/test_document_viewer.py`** (mock LLM, seeded
KB — same harness as the phase-10 suite):
1. `test_source_chip_opens_modal` — chip → modal opens in-page (no popup),
title + `.doc-md` content present, page URL unchanged.
2. `test_sources_row_opens_modal` — Sources path link → modal, yaml in
`<pre.doc-raw>`, mono font.
3. `test_modal_closes_on_button_escape_and_backdrop` — close via button,
backdrop, and `Escape`.
4. `test_modal_focus_and_a11y` — `role="dialog"` + `aria-modal`, focus
inside the panel, close button has an `aria-label`.
5. `test_modal_xss_safe` — hostile md renders escaped, no dialog fires.
6. `test_standalone_page_still_works` — `/document.html` still renders,
not-found, dark theme, no-CDN, a11y frame, ≤736px md column.
7. `test_modal_theme_and_no_cdn` — dark surface; all assets same-origin /
`data:`.
-70
View File
@@ -1,70 +0,0 @@
# Story: Document Summaries (lite-model summaries for non-markdown docs)
**Phase:** `30_document_summaries.md` · **E2E:** `tests/e2e/test_document_summaries.py`
## Narrative
As **any user of Reese**, I ask questions about notes that live in
machine-formatted files (yaml, json, py, txt). Raw flags and keys embed
badly, so retrieval misses those documents. I want a small model to
**analyze every non-markdown document at import time** and store a
natural-language summary with a pointer back to the source. When
retrieval hits a summary, Reese follows the pointer and answers from the
**full source document** — the summary is a retrieval target, never the
answer.
- **Given** a knowledge base containing non-markdown A9 documents (yaml,
yml, json, py, txt)
- **When** I run `import_docs` and then ask a question those documents
answer
- **Then** each non-markdown document carries a `lite`-model summary
(`documents.summary` plus one embedded `is_summary` chunk at position
−1), markdown documents carry neither, and a question whose best match
is a summary chunk gets an answer grounded in the **full source
document**, with the hit counted in the per-turn log line
(`summary_hits=N`).
## Acceptance criteria
1. `BOR_LLM_SUMMARY_MODEL` (default `lite`) + non-streaming
`LLMClient.chat()` — A5 extended (same OpenAI-compatible endpoint),
empty/missing content raises `LLMError` (no silent summaries).
2. Migration 0004: `documents.summary TEXT NULL` +
`chunks.is_summary BOOLEAN NOT NULL DEFAULT FALSE` (reversible,
integration-tested up/down).
3. `app/rag/summarizer.py`: the `SUMMARY_MODE` prompt (document content
capped at `BOR_SUMMARY_MAX_CHARS`, overflow marked with the shared
truncation marker), the lite call, output validation, and the
**code-deterministic** `Source: <source>/<path>` pointer line (never
model-generated).
4. Importer: every non-markdown file gets its summary stored + indexed
(best-effort — a lite failure logs, counts in `summary_errors`, and
still leaves the document fully indexed); markdown files get neither;
re-import replaces the old summary chunk (exactly one at a time).
5. `is_summary` flows through the retriever's vector and lexical
candidate lists and `fuse`; `TurnPlan.summary_hits` counts hit chunks
with `is_summary` whose parent document made the selected top-N
context; the per-turn log line records `summary_hits=N` after
`fts_hits=N` (PLAN §9 extension).
6. A summary hit resolves to its parent through the unchanged
chunk→document mapping — the LLM receives the full source document
(A7 revised: never truncated).
7. `.env.example` + README document `BOR_LLM_SUMMARY_MODEL` /
`BOR_SUMMARY_MAX_CHARS` and the fail-soft behavior; the deterministic
mock LLM answers `SUMMARY_MODE` with a byte-stable 24-token digest.
8. Unit + integration green, `app/` coverage >90%, story E2E green in
isolation, `ruff` + `pyright` clean, one `--no-gpg-sign` commit.
## Playwright Mapping Rule
`tests/e2e/test_document_summaries.py` — one story, one file, run in
isolation. It imports the story-dedicated fixture KB
(`tests/fixtures/summary_kb/`: one yaml document with a tail sentinel +
one markdown control document) through the real importer against the
deterministic mock LLM, asserts the yaml document's import state (one
embedded `is_summary` chunk at position −1 whose text is the byte-stable
mock digest + the deterministic `Source:` pointer), asserts the summary
chunk is the yaml document's best fused chunk, then asks the question
whose best yaml match is that summary chunk and asserts the rendered
answer quotes the document's tail sentinel `RESE-SUMMARY-SENTINEL-7f3a`
(only possible if the full source document — not the summary digest —
reached the LLM), the source chip cites the yaml path, the turn is not
deflected, and the markdown control document carries no summary chunk.
-53
View File
@@ -1,53 +0,0 @@
# Story: Admin Page to Add / Remove Git Sources
**Phase:** `35_git_sources_admin` · **E2E:** `tests/e2e/test_git_sources_admin.py`
## Narrative
As **the admin (owner)**, today I manage the git repos that feed the
knowledge base by editing `BOR_GIT_SOURCES` in `.env` and restarting. I
want a page only I can access where I can **add and remove git
sources** — no env file, no restart — and have the Sync button
(phase 32) and `import_docs` (phase 28) use that stored list.
- **Given** I am signed in as admin
- **When** I open the Git sources page and add a repo URL (or remove
one)
- **Then** the change is stored in Postgres (`git_sources` table) and
the next sync / import uses the stored list — the
`BOR_GIT_SOURCES` env var remains only a fallback while the table is
empty, and phase 32's fail-loud "no git sources configured" is
preserved when both are empty.
## Acceptance criteria
1. Migration 0006: `git_sources` (id UUID PK, url TEXT UNIQUE NOT
NULL, added_at TIMESTAMPTZ) — reversible, integration-tested
up/down (existing migration-test pattern).
2. Admin-only API (phase 16 pattern — 403 `admin only` anonymous):
`GET /api/git-sources` (DB rows in added order; while the table is
empty, the env list with `from_env: true`), `POST /api/git-sources`
{url} (trimmed, 1–500 chars, `https?://` / `ssh://` / `git@`
shape, 409 on duplicate with a detail that never echoes the URL),
`DELETE /api/git-sources/{id}` (204 / 404).
3. `effective_git_sources()` shared by `app/api/sync.py::_run_sync`
and `scripts/import_docs.py`: DB rows win, env fallback only while
the table is empty, fail-loud unchanged when both are empty; the
sync log line records the list origin (`origin=db|env`).
4. `/git-sources.html` — soft-gated like the Sources page (anonymous:
sign-in gate) with the standard header (phase 34) and an
admin-only "Git sources" nav link on all five pages (phase 29
pattern): sources list (mono URL + added date + per-row Remove),
an add form (labeled input + Add, inline error, never-stale button
per §7.4), an env-fallback note while the table is empty, and a
hint that the KB itself updates via the Sync button.
5. `.env.example` + README: `BOR_GIT_SOURCES` documented as the
empty-table fallback; the admin page is the primary management UI.
6. Unit + integration green, `app/` coverage >90%, story E2E green in
isolation, ruff + pyright clean, one `--no-gpg-sign` commit.
## Playwright Mapping Rule
`tests/e2e/test_git_sources_admin.py` — one story, one file, run in
isolation: anonymous gets the sign-in gate (and 403s on the API); the
admin adds/removes sources on the page (the list updates live, invalid
input gets an inline error) and reaches the page via the "Git
sources" nav link on every page.
-40
View File
@@ -1,40 +0,0 @@
# Story: Git-Based Sources (No Hardcoded Directories)
**Phase:** `28_git_based_sources.md` · **E2E:** none (no UI/API change; unit + integration cover the git flow)
## Narrative
As **the admin (owner)**, I don't want the import sources hard-coded to
`~/Homelab` and `~/Deployments`. I want to **specify a list of git
repository URLs** and have `import_docs` **clone or pull** each one into a
dedicated local location and then **index all the code** in them.
- **Given** a list of git URLs and a destination directory
- **When** I run `import_docs`
- **Then** each repo is cloned (first run) or pulled to `ff` (subsequent
runs) into the dedicated directory, and the resulting files are indexed
exactly like any other A9-format directory.
## Acceptance criteria
1. `BOR_GIT_SOURCES` (comma-separated git URLs) + `BOR_SOURCES_DIR`
(default `~/bor-sources`) settings; `git_source_list` parses the CSV.
2. `scripts/git_sync.py::clone_or_pull(url, dest)` — shallow clone when the
dest has no `.git`, `--ff-only` pull otherwise; raises `GitSyncError`
(with stderr) on failure and on missing `git`.
3. `scripts.import_docs` resolves `BOR_GIT_SOURCES` → cloned/pulled local
dirs and passes them to `import_sources`; `--source <path>` overrides
(manual mode wins); a failed sync aborts the run (non-zero exit, no
partial import).
4. The A9 format filter / hidden-dir skip / exclusion list still apply to
the cloned content (the importer's walk is unchanged).
5. `documents.source` = the repo directory name (e.g. `homelab`).
6. `.env.example` + README document both source modes.
7. Unit (git_sync dispatch/errors) + integration (import_docs resolution
with mocked git + import) green; `app/` coverage >90%; one
`--no-gpg-sign` commit.
## Playwright Mapping Rule
No story E2E suite (no `app/` UI/API change). The git flow is covered by
unit tests (`tests/unit/test_git_sync.py`) and an integration test
(`tests/integration/test_import_docs_git.py`) with `clone_or_pull` and
`import_sources` mocked — no network, no `git`, no real embed endpoint.
-51
View File
@@ -1,51 +0,0 @@
# Story: Global Tuning Manager (No Chat Required, Editable)
**Phase:** `27_global_tuning.md` · **E2E:** `tests/e2e/test_global_tuning.py`
## Narrative
As **the admin (owner)**, I want to **manage my global tuning notes**
without having to have a chat conversation first, and I want to be able to
**edit** a note I've already saved — not just create and delete.
- **Given** I'm the admin and I have no chat open yet
- **When** I open the "Tuning" page (from the header)
- **Then** I can add a new note, edit any existing note inline, list all
notes, and delete them — and every note is stored in Postgres and read
into the system prompt of every future turn.
## Acceptance criteria
1. New endpoint `PUT /api/steering/{id}` (200 updated note; 404 unknown;
422 empty/whitespace/over-2000; 403 anonymous) — the router keeps its
existing `require_admin` gate.
2. New page `/tuning.html` (admin-only nav link): a "Add a global tuning
note" form + a list of notes, each with inline **Edit** (textarea +
Save/Cancel) and **Delete**.
3. A note created on `/tuning.html` is persisted and steers future answers
(no chat turn needed to create it; observable in a later answer).
4. Editing a note updates its text in the list and in the `<tuning>`
prompt.
5. Delete removes the note from the list and the prompt.
6. The chat-page "Tune" button and header "Tuning" panel (phase 15) are
unchanged — create + delete still work there.
7. Anonymous users get 403 on the data and on `PUT`; the header "Tuning"
link is hidden for anonymous.
8. UI: landmarks, labeled controls, ≥44px targets, `:focus-visible`,
contrast ≥4.5:1, live-region announcements, Phase-08 tokens, no-CDN.
9. Notes render as text (XSS-safe).
10. Unit + integration green, `app/` coverage >90%, story E2E green in
isolation, one `--no-gpg-sign` commit.
## Playwright Mapping Rule
**Test Scenario → `tests/e2e/test_global_tuning.py`** (mock LLM, seeded
KB — the mock echoes the first tuning note into its answer when `<tuning>`
is present, so prompt injection is observable in the UI):
1. `test_create_note_without_chat` — `/tuning.html`, add a note, it
appears in the list + announcer.
2. `test_edit_note_inline` — Edit → change text → Save → list updated.
3. `test_delete_note` — Delete → row removed, empty state shows.
4. `test_edit_note_steers_answer` — create via `/tuning.html`, then a chat
turn shows the note's marker in the answer.
5. `test_tuning_page_a11y_and_no_cdn` — landmarks, labels, ≥44px,
focus-visible, dark theme, all assets local.
6. `test_anonymous_cannot_manage` — anonymous list empty; `PUT` → 403.
-80
View File
@@ -1,80 +0,0 @@
# Story: KB Overview in the System Prompt (lite-generated knowledge-base outline)
**Phase:** `31_kb_overview_prompt.md` · **E2E:** `tests/e2e/test_kb_overview.py`
## Narrative
As **any user of Reese**, I ask questions without knowing what the
knowledge base actually covers. Retrieval only reveals the answer
documents after it has run, and when it finds nothing the deflection has
no picture of what Reese DOES know. I want the system prompt to carry,
on every turn, a plain-text outline of the **basic categories** of
everything that has been read — so the agent knows roughly what its
knowledge base contains before retrieval returns documents. The outline
is **generated by the `lite` model** from the document catalogue
(source, path, title, first summary line per document) and **stored in
a single row**, so it is regenerated whenever an import changes the
knowledge base — never per chat turn.
- **Given** a knowledge base that was imported (with at least one
document added or updated, or no outline stored yet)
- **When** I ask any question
- **Then** the system prompt of that turn — grounded (HIGH) or
deflected (LOW) — contains a `<knowledge_base>` section with the
stored outline (budgeted by `BOR_KB_OVERVIEW_MAX_CHARS`, overflow
marked with the shared `[…truncated…]` marker), and the per-turn log
line records `kb_chars=N`; when no outline is stored, both prompts
are byte-identical to the pre-phase text.
## Acceptance criteria
1. Migration 0005: single-row `kb_overview` table (`id INTEGER PK
DEFAULT 1`, `content TEXT NOT NULL DEFAULT ''`,
`updated_at TIMESTAMPTZ`) + `KbOverview` model — no other schema
change (reversible, integration-tested up/down).
2. `app/rag/overview.py`: the `KB_OVERVIEW_MODE` prompt builder
(document list capped at `BOR_OVERVIEW_INPUT_MAX_CHARS`, overflow
cut at the cap with the shared truncation marker), `load_kb_overview`
(one indexed PK lookup, `""` when the row is missing/empty), and
`regenerate_overview` (best-effort upsert into the single row with a
fresh UTC `updated_at`, `overview: regenerated docs=… chars=…` log
line; a `lite` failure logs and leaves the previous outline intact;
zero documents → no model call, any existing row untouched).
3. Prompt injection (phase 15 convention): the `<knowledge_base>`
section is budgeted by `BOR_KB_OVERVIEW_MAX_CHARS` (default 4000)
with the shared `[…truncated…]` overflow, ordered
`<relevance>` → `<knowledge_base>` → `<tuning>` → mode body in
**both** the HIGH and LOW prompts; a missing/empty row keeps both
prompts **byte-identical** to the pre-phase text (unit-asserted).
4. Chat turn: `plan_turn` reads the stored row per turn (no per-turn
LLM call) and records `TurnPlan.kb_chars`; the per-turn log line
records `kb_chars=N` after `tuning=N` (PLAN §9 extension).
5. Import trigger: after a run that added/updated at least one document
(or no row exists yet), `import_docs` regenerates the outline with
the same `lite` model (`BOR_LLM_SUMMARY_MODEL` — A5 extended);
unchanged re-imports and `--limit` debug runs never burn a `lite`
call; the summary line ends `overview=updated|skipped|failed` and a
`lite` failure never changes the import's exit code.
6. `.env.example` + README document `BOR_KB_OVERVIEW_MAX_CHARS` /
`BOR_OVERVIEW_INPUT_MAX_CHARS` and the regeneration behavior; the
deterministic mock LLM answers `KB_OVERVIEW_MODE` with a byte-stable
outline (first 8 tokens of the document list) and echoes the
`<knowledge_base>` section's first bullet as `(kb: …)` — the
`(tuning: …)` steering-echo precedent.
7. Unit + integration green, `app/` coverage >90% (TOTAL ≥ pre-change),
story E2E green in isolation, `ruff` + `pyright` clean, one
`--no-gpg-sign` commit.
## Playwright Mapping Rule
`tests/e2e/test_kb_overview.py` — one story, one file, run in
isolation. It seeds the `kb_overview` row directly in the DB (the
import-trigger path is integration-tested) with a recognizable
multi-bullet outline, imports the shared fixture KB, asks an on-topic
question and asserts the rendered brain answer ends with
`(kb: Kubernetes cluster and node maintenance notes)` (the mock's echo
of the injected section's first bullet — only possible if the
`<knowledge_base>` section reached the LLM prompt), asks an off-topic
question and asserts the deflected answer carries the same echo (the
section is in the LOW prompt too), deletes the row and asserts a fresh
answer has no `(kb: …)` suffix (absence end-to-end), and runs the real
`regenerate_overview` against the mock to assert the `KB_OVERVIEW_MODE`
branch's byte-stable outline is stored and echoed by a subsequent turn.
-100
View File
@@ -1,100 +0,0 @@
# Story: Markdown tables in chat (and everywhere the renderer runs)
**Phase:** `44_markdown_tables` · **Source:** `TODO.md` L6 ·
**E2E:** `tests/e2e/test_markdown_tables.py`
## Bug report (verbatim, `TODO.md` L6)
> "Certain markdown formatting isn't working - tables for example don't
> get rendered as tables in the chat response."
## Narrative
As **a user**, when Brain answers with a markdown table (services and
ports, versions, schedules — the concrete specifics the persona is built
around), I expect a real table: aligned columns, borders, readable.
Today the shared renderer (`frontend/assets/markdown.js`, ~60 lines,
no-CDN by A11) has no table support — a pipe table renders as one
paragraph of raw `|` text.
- **Given** Brain's answer (or a document / thinking block) contains a
GFM pipe table
- **When** the renderer runs
- **Then** it renders a semantic `<table>` with a `<thead>` header row
and `<tbody>` body rows, XSS-safe (escape-first, as the rest of the
renderer).
## Acceptance criteria
1. **Pipe tables render:** header row + `|---|` separator row + body
rows → `<table class="md-table">` with `<th scope="col">` header
cells; leading/trailing pipes and in-cell whitespace are handled;
cells keep their inline markdown (bold/em/code).
2. **XSS-safe:** cell content is escaped before any transform — a cell
containing `<img onerror=…>` renders inert (the escape-first
guarantee, same as all other content).
3. **Code fences win:** a `|`-heavy block inside a ``` fence is never
parsed as a table (fence protection runs first, as today).
4. **Non-tables stay put:** a single `|` in prose, a lone separator
without a header, or a 1-line "table" is left as text.
5. **Wide tables:** the table sits in an `overflow-x: auto` wrapper so a
wide table scrolls horizontally instead of breaking the 46rem chat
column (PLAN §7.1).
6. **Shared everywhere:** the same renderer serves the chat answer, the
document viewer, and the thinking block — all three render tables.
7. **Style:** `.md-table` uses the existing dark-tech palette
(PLAN §7.2 tokens, contrast ≥4.5:1); alignment colons in the
separator are parsed but all cells render left-aligned (owner
decision).
## Owner-confirmed (2026-08-27, roadmap A3)
1. **Scope = GFM pipe tables** (header + separator + body rows). Links,
blockquotes, and hr are **not** in scope for this story.
2. **Alignment colons parsed, rendered left.**
3. **Wide tables get a horizontal scroll wrapper** inside the bubble.
## UI Visualization & Structure
- **Renderer** (`frontend/assets/markdown.js`): a table-protection pass
between the existing fence pass and the escape pass — consecutive
lines forming a table (every line contains `|`; line 2 matches the
separator `^\s*\|?(\s*:?-{3,}:?\s*\|)+\s*:?-{3,}:?\s*\|?\s*$`-style
rule with ≥1 cell) are pulled out, each cell is escaped + inline-
transformed, and the block is reinserted as protected HTML (same
`\u0000CODE…\u0000` placeholder mechanism as code fences, or a
sibling placeholder — the existing restore step is the only place
placeholders are re-expanded). Output shape:
`<div class="md-table-wrap"><table class="md-table"><thead><tr>
<th scope="col">…` / `</table></div>`.
- **CSS** (`frontend/assets/styles.css`): `.md-table-wrap
{ overflow-x: auto; }` (the wrapper is the scroller — the table keeps
natural width); `.md-table` border-collapse, `th`/`td` borders from
`--line`, padding ≈0.4rem 0.6rem, `thead` tinted from the surface
palette; fits the 46rem column without a new container.
- **Mock** (`tests/e2e/mock_llm.py`): new `TABLE_TRIGGER` (a substring
like `"show me a table"`) → `compose_answer` returns a fixed
deterministic GFM table answer (checked before the default tail-echo
branch), including one deliberately wide table for the overflow
assertion.
- **Non-goals:** no new library (A11), no CDN, no change to the escape-
first architecture, no table editing in the steering/tuning UI.
## Playwright Mapping Rule
**Test Scenario → `tests/e2e/test_markdown_tables.py`** (mock LLM; DB
up):
1. `test_chat_table_renders` — ask a question containing `TABLE_TRIGGER`;
the brain bubble contains `<table class="md-table">` with a
`<thead>` (header cells as `<th scope="col">`) and the expected
body-cell texts; no raw `|---|` separator text in the bubble.
2. `test_wide_table_scrolls` — the mock's wide table: the bubble's
`.md-table-wrap` has `scrollWidth > clientWidth` and horizontal
wheel/scroll moves it; the 46rem column itself does not overflow the
page.
3. `test_table_xss_safe` — a table whose cell contains an HTML tag
(mock variant or document content) renders the tag as text
(no injected element).
4. `test_viewer_table_renders` (shared renderer) — an indexed fixture
document containing a pipe table opens in the document modal and
renders the same `<table class="md-table">`.
5. `test_fence_not_a_table` (regression) — a fenced code block full of
`|` pipes renders as `<pre><code>`, no `<table>`.
6. `test_plain_pipe_stays_text` (regression) — a prose answer with a
single `|` renders as text, no `<table>`.
-110
View File
@@ -1,110 +0,0 @@
# Story: Mobile hamburger nav
**Phase:** `46_mobile_hamburger_nav` · **Source:** `TODO.md` L9 ·
**E2E:** `tests/e2e/test_mobile_hamburger_nav.py`
## Bug report (verbatim, `TODO.md` L9)
> "The navbar on mobile is way too squished. Make it a hamburger
> dropdown menu with a nice animation"
## Narrative
As **a mobile user**, the 58px header currently crams up to four text
nav pills (Chat / Sources / Git sources / Tuning) next to the brand and
four icon action pills — the phase-34/35 squeeze at 360–375px leaves
0.72rem-font pills that are hard to hit and hard to read. The nav links
move into a **hamburger dropdown menu** on small screens: one
`#nav-toggle` button in the bar, and the links open as an animated
panel below the header with comfortable touch targets.
- **Given** a viewport ≤640px
- **When** I tap the hamburger
- **Then** the nav menu drops down with a short slide+fade animation and
full-size links; tapping a link navigates and closes the menu.
- **Given** a viewport >640px
- **When** the page loads
- **Then** nothing changes — the inline nav pills render exactly as
today.
## Acceptance criteria
1. **Mobile (≤640px):** the inline nav pills are hidden from the bar; a
hamburger button (`#nav-toggle`, `aria-label="Menu"`,
`aria-controls="app-nav"`, `aria-expanded`) appears — 44px touch
target, icon-only.
2. **Menu:** `.app-nav` (now `id="app-nav"`) becomes a dropdown panel
below the header — vertical full-width rows, ≥44px targets, readable
font size; the **auth visibility contract is preserved inside the
menu** (anonymous: only "Chat"; admin: Chat / Sources / Git sources /
Tuning — the same ship-hidden `hidden` attributes the whoami gate
already drives).
3. **Animation:** opening/closing animates (slide-down + fade, ≈180ms);
`prefers-reduced-motion: reduce` stills it (no transition).
4. **Behavior:** toggle flips `aria-expanded`; `Esc` closes while open;
a link click navigates **and** closes the menu; resizing back to
>640px closes it (the inline nav reappears, no stale state).
5. **Bar layout:** the action pills (Tuning toggle, Sync, New chat,
Sign in/out) stay in the bar icon-only — the phase-35 tightest
squeeze rules on `.nav-link` / `.app-nav` gaps are replaced by the
roomier menu; the bar fits 360px with the brand intact or clipped as
today.
6. **All six pages** get the identical toggle + menu (the phase-34
"same bar on every page" contract).
## Owner-confirmed (2026-08-27, roadmap A5)
1. **The hamburger contains the nav links only** (Chat / Sources / Git
sources / Tuning). The action pills stay in the bar.
2. **Animation:** slide-down + fade, 180ms; `prefers-reduced-motion`
stills it.
3. **Breakpoint:** the existing ≤640px mobile block (no new breakpoint).
## UI Visualization & Structure
- **Markup (all six pages — `index.html`, `sources.html`,
`document.html`, `git-sources.html`, `login.html`, `tuning.html`):**
a `#nav-toggle` button inserted before `<nav class="app-nav"
aria-label="Primary">` (which gains `id="app-nav"`); the nav keeps
its existing links and `hidden` attributes byte-identically.
- **CSS** (`frontend/assets/styles.css`, the `@media (max-width: 640px)`
block): `.nav-toggle { display: none }` outside, `display:
inline-flex` + 44px target inside; `.app-nav` becomes the dropdown:
absolute below the header, `flex-direction: column`, surface background
+ bottom border/shadow, full-width row links; closed state
(`visibility: hidden; opacity: 0; transform: translateY(-8px);
pointer-events: none`) → `.is-open` (`visible; opacity: 1; transform:
none`), `transition: opacity/transform 180ms ease`; a
`prefers-reduced-motion` override kills the transition. The old
nav-pill squeeze rules (`.nav-link` 0.72rem, `.app-nav` gap 0.05rem)
are superseded for the menu rows.
- **JS** (`frontend/assets/header.js`, module-import binding like
sign-out): null-safe `#nav-toggle` / `#app-nav` — click toggles
`.is-open` + `aria-expanded`; delegated click on nav links closes it;
`document` keydown `Esc` closes while open; a
`matchMedia("(max-width: 640px)")` change listener closes on
desktop. No other header.js behavior touched.
- **Non-goals:** no change to the 900px tablet rules, the action pills,
the viewer's title bar height, or the no-CDN/A11 constraints.
## Playwright Mapping Rule
**Test Scenario → `tests/e2e/test_mobile_hamburger_nav.py`** (mock
LLM; DB up):
1. `test_mobile_hamburger_visible_and_bar_roomy` — 375×812: `#nav-toggle`
visible with `aria-expanded="false"`; the inline nav links are not
visible in the bar (menu closed); the header has no horizontal
overflow.
2. `test_anonymous_menu_contents` — anonymous: open the menu → exactly
"Chat" is visible; open/close flips `aria-expanded`.
3. `test_admin_menu_contents` — login: open the menu → Chat / Sources /
Git sources / Tuning all visible (auth contract inside the menu).
4. `test_link_click_navigates_and_closes` — open, click "Sources"
(admin): navigates to `/sources.html`, and the menu on the arrival
page is closed.
5. `test_esc_and_backdrop_close` — open, `Esc` closes (aria-expanded
false); open again, click outside the panel closes.
6. `test_animation_and_reduced_motion` — with motion allowed, the menu
has a transition (computed `transition-duration` ≈180ms on the
opacity/transform pair); with `reducedMotion: "reduce"` emulated,
the transition is none/0s and the menu still opens/closes.
7. `test_desktop_unchanged` (regression) — 1280×800: no hamburger,
inline nav pills exactly as before (the phase-34/35 bar contract,
`test_nav_consistency` / `test_header_consistency` pass in the
regression pass).
-57
View File
@@ -1,57 +0,0 @@
# Story: One Navbar on Every Page
**Phase:** `34_consistent_navbar` · **E2E:** `tests/e2e/test_nav_consistency.py`
## Narrative
As **any user (admin or anonymous)**, I move between the Chat, Sources,
Document viewer, Global Tuning, and Sign in pages. Today the header bar
is only similar between pages: the document viewer replaces it with a
back + title bar (every nav button vanishes), the login page misses the
Tuning link, and the functional controls are page-scoped — the Tuning
steering toggle lives on chat only, the Sync sources button on Sources
only — so buttons pop in and out as I switch tabs. I want ONE identical
bar on every page.
- **Given** any of the app's five pages
- **When** I look at the header
- **Then** I see the same controls in the same order — brand, nav
[Chat, Sources, Tuning], Tuning toggle, Sync sources (admin), New
chat, and exactly one of Sign in / Sign out — with none missing
because of which page I happen to be on.
## Acceptance criteria
1. `frontend/assets/header.js` is the single owner of the functional
header controls: the steering toggle + panel logic (moved from
`app.js`, exported `refreshSteering()`), the Sync button state
machine (moved from `sources.js`, dispatching `bor:sync-status`),
one New chat binding (chat page: `bor:new-chat` event; other pages:
clear + navigate), and the sign-in `?next=` rewrite to the current
page. No control logic remains in the page scripts.
2. All five pages (`index.html`, `sources.html`, `document.html`,
`tuning.html`, `login.html`) ship the identical header markup; the
`#steering-panel` section exists on every page (chat: after
`#kb-banner` as today; other pages: first child of `<main>`).
3. The document viewer keeps its back link + title in a second
titlebar row below the standard bar (phase 13 back-target behavior
unchanged); the pinned 64px / 58px height contract applies to the
standard row on every page.
4. The locked A10 UI revision is preserved: admin-only controls
(Sources / Tuning nav links, Sync button) ship hidden and are
revealed only for a signed-in admin — for the admin every button is
active on every page; anonymous visitors get the reduced bar,
identically on every page (owner confirmation 2026-08-26).
5. The chat page's per-bubble Tune button + inline form (phases 15/27)
and the Sources page's sync result line + error banner (phase 32)
keep working exactly as before.
6. `tests/e2e/test_nav_consistency.py` green in isolation;
`test_header_consistency.py` / `test_shared_header.py` updated to the
new viewer contract; regressions green; `app/` coverage >90%
(frontend-only phase — unchanged); ruff + pyright clean; one
`--no-gpg-sign` commit.
## Playwright Mapping Rule
`tests/e2e/test_nav_consistency.py` — one story, one file, run in
isolation. It asserts, per role (admin, anonymous), that the visible
header control inventory is identical on all five pages, and that the
viewer's back link + titlebar row survive the change.
@@ -1,49 +0,0 @@
# Story: Nav rename — "Sources" becomes "RAG", "Git sources" becomes "Sources"
**Phase:** `48_nav_rename_sources` · **E2E:** `tests/e2e/test_nav_rename_sources.py`
## Narrative
As **the admin (owner)**, the two admin-only nav items are confusing:
"Sources" (the document catalog) and "Git sources" (the source manager)
read like the same thing. I want the navbar to say **"RAG"** for the
document catalog page and **"Sources"** for the source manager page, so
the labels say what each page *is*: RAG = the indexed knowledge base you
chat against; Sources = where that knowledge comes from.
- **Given** I am signed in as admin, on any of the six pages (chat,
sources, git-sources, tuning, document viewer, login)
- **When** I look at the primary nav
- **Then** the catalog link (`#nav-sources`, → `/sources.html`) is
labeled **"RAG"** and the manager link (`#nav-git-sources`, →
`/git-sources.html`) is labeled **"Sources"** — same ids, same hrefs,
same order, same admin-only ship-hidden/reveal contract as before.
## Acceptance criteria
1. All six pages (`frontend/index.html`, `sources.html`,
`git-sources.html`, `tuning.html`, `document.html`, `login.html`)
carry the swapped label text on `#nav-sources` ("RAG") and
`#nav-git-sources` ("Sources"). Element ids, `href`s, `hidden`
defaults, `is-active`/`aria-current` markers, and the nav order
(Chat, RAG, Sources, Tuning) are unchanged — this is a label-only
rename, and `header.js` logic (reveal by id) is untouched.
2. Anonymous visitors still see neither link (ship-hidden contract
unchanged); the admin sees both, as before.
3. Existing test assertions on the old label text are updated
(`test_git_sources_admin.py` nav-link text,
`test_mobile_hamburger_nav.py` `LINK_TEXTS`); assertions that are
NOT about these two nav items stay untouched (the document viewer's
"Sources" back button, the "Sync sources" button label, the
`/git-sources.html` page-`<h1>`/`<title>` marker).
4. Story E2E `tests/e2e/test_nav_rename_sources.py` proves: admin sees
"RAG" (→ `/sources.html`) and "Sources" (→ `/git-sources.html`) on
all six pages; clicking each lands on the right page with
`is-active`; anonymous sees both hidden.
5. Frontend-only: unit + integration green, `app/` coverage
unaffected, ruff + pyright clean, no CDN, one `--no-gpg-sign` commit.
## Playwright Mapping Rule
`tests/e2e/test_nav_rename_sources.py` — one story, one file, run in
isolation: the two relabeled nav items' full lifecycle (labels, hrefs,
admin reveal, anonymous hide, click-navigation with the current-page
marker) on the shared header, desktop viewport.
@@ -1,91 +0,0 @@
# Story: No reply autoscroll
**Phase:** `42_no_reply_autoscroll` · **Source:** `TODO.md` L5 ·
**E2E:** `tests/e2e/test_no_reply_autoscroll.py`
## Bug report (verbatim, `TODO.md` L5)
> "Get rid of the chat reply autoscroll, it's breaking things like
> making it impossible for the user to scroll while a reply generates."
## Narrative
As **a user reading a long answer**, I want full control of the
viewport while Brain replies. Today the page auto-scrolls to follow the
stream (phase 18 "follow-the-bottom"): while I'm in the 200px
near-bottom band the page is yanked down on every thinking / tool /
delta frame, which fights my own scrolling mid-answer. The reply
autoscroll is being **removed** — the page only scrolls when I
explicitly cause it.
- **Given** a reply is streaming (thinking, tool calls, or answer text)
- **When** I scroll up to read earlier context
- **Then** the viewport stays exactly where I put it for the rest of the
turn — no frame yanks it back.
## Acceptance criteria
1. **No streaming autoscroll:** during a long thinking stream, a tool
call, and a long answer, the page never auto-scrolls — sampled
`window.scrollY` is stable (within 1px) across frames while the
viewport is away from the bottom.
2. **Submit reveals my message:** sending a question while scrolled up
still scrolls the viewport down so my own message is visible
(user-initiated — kept by owner decision).
3. **Restore landing kept:** reloading a persisted conversation
(phase 14) still lands one-shot on the latest message.
4. **The phase-18 gate is gone:** `NEAR_BOTTOM_PX` /
`isNearBottom()` and the per-frame `scrollReveal` calls in the
thinking / tool / delta handlers are removed from `app.js`; the unit
pin (`tests/unit/test_frontend_scroll.py`) is rewritten for the new
contract (scrolls happen only on submit + restore landing).
5. **Everything else unchanged:** the thinking window's *internal*
bottom-pin (phase 17 — `textEl.scrollTop`, not the page) is untouched
in this phase (phase 43 reworks it separately); message rendering,
persistence, UI states, and the 120 s guard are unchanged.
## Owner-confirmed (2026-08-27, roadmap A1)
1. **"Reply autoscroll" = the phase-18 follow-the-bottom auto-follow on
thinking / tool / delta frames.** Removed.
2. **Kept:** scroll-on-submit (reveal the user's own message) and the
one-shot restore landing on page load.
## UI Visualization & Structure
- **The functional change is in `frontend/assets/app.js` only:**
- delete `export const NEAR_BOTTOM_PX = 200`, `isNearBottom()`, and
the `force`-optional gating in `scrollReveal` — the helper becomes an
unconditional `scrollIntoView` (still smooth, still still under
`prefers-reduced-motion` via the existing `SCROLL` constant);
- `addMessage(...)` gains an explicit "scroll" intent: the **user
submit** path scrolls (shows my message), brain bubble creation and
the typing indicator do **not**;
- the thinking / tool / delta handlers drop their `scrollReveal(wrap)`
calls (the thinking handler keeps its `textEl.scrollTop` window-pin —
phase 17, reworked in phase 43);
- the phase-14 restore landing keeps its one-shot forced scroll;
- module docstrings updated (the phase-18 contract block is replaced
by the new "no reply autoscroll (owner direction 2026-08-27)"
contract).
- **Non-goals:** no new UI element, no "↓ new content" pill (the owner
wants silence, not a substitute affordance), no change to the
composer / safe-area layout.
## Playwright Mapping Rule
**Test Scenario → `tests/e2e/test_no_reply_autoscroll.py`** (mock LLM;
DB up; the phase-18 suite `tests/e2e/test_follow_bottom_scroll.py` is
**deleted** in this phase — its behavior is intentionally removed):
1. `test_no_autoscroll_during_long_answer` — `LONG_ANSWER_TRIGGER`
question; once the answer starts, scroll the viewport up ~2× the
answer height; sample `window.scrollY` across ≥10 streaming frames:
stable within 1px; after `done` the viewport is still where it was.
2. `test_no_autoscroll_during_thinking` — `THINKING_TRIGGER` question;
scroll up during the ~4.5 s thinking stream; the viewport stays
pinned (no per-chunk page follow).
3. `test_submit_reveals_user_message` — scroll to the very top of a
populated conversation, send a question; the viewport ends with the
user's message visible (bottom in view).
4. `test_restore_landing_one_shot` (phase 14 regression) — settle a
conversation, reload; the page lands on the latest message one-shot
and stays there while no stream is active.
5. `test_answer_content_intact` (regression) — the long answer streams
to completion with sources and (for thinking) the collapsed block,
persisted and restorable.
@@ -1,98 +0,0 @@
# Story: Import quadlet + jinja files
**Phase:** `47_quadlet_jinja_import` · **Source:** `TODO.md` L10–L11 ·
**E2E:** `tests/e2e/test_quadlet_jinja_import.py`
## Bug reports (verbatim, `TODO.md` L10–L11)
> "Add \".container\", \".network\", \".volume\" and other quadlet files
> to the list of allowed/parsed files"
> "Add \".j2\" jinja files to the list of allowed/parsed files"
## Narrative
As **the owner**, my homelab notes increasingly live in Podman quadlet
unit files (`.container`, `.network`, `.volume`, …) and Jinja templates
(`.j2`). Neither is in the A9 import format list, so the KB is blind to
exactly the config files I ask questions about. Both families join the
allowed + default import formats and are parsed (chunked) by the
importer.
- **Given** a source directory containing quadlet and/or `.j2` files
- **When** the importer (CLI or sync) runs
- **Then** those files are indexed (chunked, embedded, upserted) like any
other A9-format file, and their content is retrievable.
## Acceptance criteria
1. **Allowed set:** `_ALLOWED_IMPORT_EXTENSIONS` in `app/config.py`
gains `container, network, volume, image, pod, kube, swap, os,
endpoint` (the full Podman quadlet family) and `j2`; a
`BOR_IMPORT_EXTENSIONS` env value may name any of them (the
never-widen validator keeps rejecting truly unknown extensions).
2. **Default set:** the default `import_extensions` CSV includes all ten
new formats after the existing seven — a default import now picks
them up with no env configuration.
3. **Parsing:** `chunker.py` dispatches every new suffix to
plain-text paragraph packing (`chunk_text`) — quadlet files are TOML
unit files and `.j2` files are templates; no format-specific
splitter (owner decision). Every chunk still honors
`HARD_MAX_CHARS` (1200).
4. **Title:** no H1 → `extract_title` falls back to the file stem, as
with other non-markdown formats (no change needed, verified).
5. **Behavior parity:** hidden (dot) directories are still skipped, the
exclusion list is unchanged, sha256 delta detection / prune work
unchanged for the new formats.
6. **Docs:** `.env.example`'s `BOR_IMPORT_EXTENSIONS` comment, the
README's format list, and an **A9 revision note** in
`.agent/PLAN.md` (owner permission 2026-08-27, `TODO.md` L10–L11)
record the extended set.
## Owner-confirmed (2026-08-27, roadmap R1)
1. **Full quadlet family:** `container, network, volume, image, pod,
kube, swap, os, endpoint` — plus `j2`.
2. **Chunked as plain text** — no TOML/Jinja-aware splitting.
3. **A9 is revised** with a PLAN.md revision note (the established
owner-permission pattern).
## UI Visualization & Structure
- **Config** (`app/config.py`): extend the `_ALLOWED_IMPORT_EXTENSIONS`
frozenset + the `import_extensions` default string (comment cites the
A9 revision 2026-08-27). No validator change — it already
normalizes/dedups and rejects unknowns.
- **Chunker** (`app/rag/chunker.py`): ten new `_FORMAT_CHUNKERS` entries
→ `chunk_text`; module docstring's format list updated.
- **Fixtures:** `tests/fixtures/docs/homelab/quadlet/compose.container`
(realistic quadlet TOML: `[Unit]` / `[Service]` / `[Container]`
sections, one unique sentinel token, >1200 chars to exercise
sub-splitting), `…/quadlet/lan.network`, `…/quadlet/cache.volume`,
and `tests/fixtures/docs/homelab/templates/deploy.j2` (Jinja snippet
with `{{ … }}` / `{% … %}` tags + its own sentinel).
- **Tests:** unit (`test_config.py` allowed-set/default/validator;
`test_chunker.py` dispatch for every new suffix + fixture chunking;
`test_importer.py` directory walk picks the new files up);
integration (import_sources over a temp dir with quadlet+j2 files →
documents + chunks rows).
- **Non-goals:** no new DB column, no format badge change (the viewer
shows the extension it already shows), no summary-model changes.
## Playwright Mapping Rule
**Test Scenario → `tests/e2e/test_quadlet_jinja_import.py`** (mock
LLM; DB up):
1. `test_quadlet_and_jinja_indexed` — truncate + import the fixture
tree (the `test_import_documents.py` pattern): `GET /api/docs` lists
the `.container` / `.network` / `.volume` / `.j2` files with
non-zero chunk counts.
2. `test_sources_table_shows_them` (admin) — the Sources table renders
rows for the new files; their path links open the document modal.
3. `test_container_content_viewable` — the viewer modal shows the
`.container` file's TOML content (sentinel token present) with its
stem as the title.
4. `test_jinja_retrievable_not_deflected` — ask a question containing
the `.j2` file's sentinel word: the FTS hit keeps the honesty gate
honest-positive (A8) — the answer bubble is **not**
`.is-deflected` and the source chip names the `.j2` document.
5. `test_default_walk_includes_new_formats` (regression, unit-backed) —
a default-extensions walk over a temp tree with all ten new
extensions indexes every file; hidden directories + the exclusion
list still filter (covered by `test_importer.py` in the regression
pass).
-45
View File
@@ -1,45 +0,0 @@
# Story: Summary + Original Document Together in the Viewer
**Phase:** `36_summary_in_viewer` · **E2E:** `tests/e2e/test_summary_in_viewer.py`
## Narrative
As **any user**, phase 30 gives every non-markdown document a
lite-model summary (stored on `documents.summary`, indexed as an
`is_summary` chunk, used for retrieval). When I open such a document —
from the Sources table, a chat source chip, or a direct URL — I only
see the raw original. I want to see **the summary and the original
document together**.
- **Given** a document that has a summary (phase 30: non-markdown A9
documents)
- **When** I open it in the document viewer (the modal or the full
page)
- **Then** a labeled Summary panel sits above the original content —
both visible at once. Documents without a summary (markdown docs,
pre-phase-30 rows, fail-soft rows) render exactly as before.
## Acceptance criteria
1. `DocContent` gains `summary: str | None`; `GET
/api/documents/content` returns `documents.summary`. The endpoint
stays public + stateless — the phase 16 soft rule (catalog gated,
viewer public) is unchanged.
2. The shared renderer `renderDocument` (`assets/document.js`)
renders the summary panel for **both** surfaces at once (the
full-page viewer + the chat/sources modal); null/empty summary →
no panel; the summary text is written with `textContent` (XSS
contract unchanged).
3. `.doc-summary` styling matches the dark tech theme (phase 08
palette, contrast ≥4.5:1) and reads as a summary, not as document
content.
4. Integration tests: a summarized non-markdown doc returns its
summary; a markdown doc returns null; anonymous access unchanged.
5. Story E2E green in isolation (the `summary_kb` fixture: the
deterministic digest panel + the full original with its tail
sentinel visible together; the markdown control doc → no panel);
regressions green; `app/` coverage >90%; ruff + pyright clean; one
`--no-gpg-sign` commit.
## Playwright Mapping Rule
`tests/e2e/test_summary_in_viewer.py` — one story, one file, run in
isolation.
@@ -1,97 +0,0 @@
# Story: Sync fails fast + modal when a model is down
**Phase:** `41_sync_fail_fast_models` · **Source:** `TODO.md` L4 ·
**E2E:** `tests/e2e/test_sync_model_down.py`
## Bug report (verbatim, `TODO.md` L4)
> "If the embedding or lite model is not accessible the sync button
> should fail fast and there should be a modal error popup explaining
> that the model isn't available."
## Narrative
As **the admin**, when I press "Sync sources" with the aipi models
(`embed` or `lite`) unreachable, I don't want to wait through git clones
and a partial import to discover the KB can't be updated — and a tooltip
on a button is not a readable error. The sync should **fail fast**
(before any expensive work) with a clear "the model isn't available"
message, shown in a **modal dialog** I can read and dismiss.
- **Given** the `embed` or `lite` model endpoint is unreachable
- **When** I press "Sync sources"
- **Then** the run fails within a couple of seconds (before any git
clone), the button settles retry-ready, and a modal dialog explains
which model isn't available.
## Acceptance criteria
1. **Server fail-fast:** `POST /api/sync` with a dead LLM endpoint
reaches `state: "failed"` with a message naming the unavailable model
(embedding first, then summary/lite) **without** cloning any source —
the probe (one small embedding + one tiny completion against
`BOR_LLM_SUMMARY_MODEL`) runs before source resolution and before any
`clone_or_pull`.
2. **Modal:** on a failed sync the page shows a modal error dialog
(`role="alertdialog"`, `aria-modal="true"`) with a title, the
sanitized error text (rendered via `textContent` — XSS-safe), and a
close control; it closes on the close button, `Esc`, or backdrop
click; focus moves into the dialog on open and returns to `#sync-btn`
on close.
3. **Every page:** the modal is built by the shared header module
(`frontend/assets/header.js`), which owns the sync state machine — so
it appears wherever `#sync-btn` exists (all six pages from phase 34).
4. **Existing surfaces kept:** the button's failed-state `title` /
`aria-label` / `.is-error` affordance and the Sources page's
`#sync-error-banner` (via `bor:sync-status`) are unchanged — the modal
is the primary, readable surface.
5. **Success path unchanged:** a healthy model still runs clone → import
→ overview exactly as phase 32/35/38 define it (regression).
## Owner-confirmed (2026-08-27, roadmap A4)
1. **The probe runs before git clones** — the fastest possible failure;
it costs one small embedding request and one ~1-token completion.
2. **The modal is the primary failure surface on every page;** the
button-title affordance and the Sources banner stay as secondary
surfaces.
## UI Visualization & Structure
- **Server** (`app/rag/llm.py`, `app/api/sync.py`): a new
`ModelUnavailableError` (subclass of `LLMError`) + an `async
check_models(llm)` probe: `embed_one("sync model check")` then a tiny
`chat([...])` against the summary model; each failure mode maps to a
message naming the model and that it isn't available (the sync
sanitizer's credential masking still applies downstream). `_run_sync`
calls it first, after `LLMClient()` construction — before
`effective_sources`, before any clone.
- **UI** (`frontend/assets/header.js`, `frontend/assets/styles.css`):
`applySyncFailure(status)` additionally opens `showSyncModal(status)`:
a lazily-created backdrop + `role="alertdialog"` panel appended to
`<body>` (so no page markup changes), error text via `textContent`,
close button + `Esc` + backdrop-click dismissal, focus management as
in AC 2. Styled with the existing dark-theme error palette
(PLAN §7.2: `#fca5a5` on `#2d1318` class, error border), `:focus-visible`
per the global rule, no motion under `prefers-reduced-motion`.
- **Non-goals:** no new endpoint, no retry-from-modal button (the button
itself is retry-ready), no change to the 2 s poll lifecycle.
## Playwright Mapping Rule
**Test Scenario → `tests/e2e/test_sync_model_down.py`** (mock LLM; DB
up). The suite boots its **own module-scoped app** on a distinct port
(conftest pattern used by `test_sync_button.py`) with
`BOR_LLM_BASE_URL=http://127.0.0.1:9/v1` (dead port — connection
refused) and a local `file://` fixture repo as the configured source, so
a (regressed, non-fail-fast) run would spend time cloning before failing:
1. `test_model_down_fails_fast_with_modal` — admin login, click
`#sync-btn`; within a short wall-clock budget (≤ ~10 s, vs the 60 s
generous budget of the healthy-run suite) the button settles
retry-ready **and** the modal is visible with an error naming the
model; assert the dialog role/aria contract.
2. `test_modal_dismissal` — close via button, `Esc`, and backdrop click
(one fresh failure per path); focus returns to `#sync-btn` each time.
3. `test_sync_error_surfaces_unaffected` (phase 32 regression) — after
the failure the button keeps its `title` / `.is-error` affordance; on
`/sources.html` the `#sync-error-banner` still renders off
`bor:sync-status`.
4. `test_healthy_sync_still_succeeds` (phase 32/35 regression) — the
session mock-backed app (or a second healthy module app) still runs
the full clone → import → overview pipeline to "Synced HH:MM".
-99
View File
@@ -1,99 +0,0 @@
# Story: Thinking No Scroll (live-tail Thinking window)
**Phase:** `21_thinking_no_scroll` · **Source:** `TODO.md` L4 ·
**E2E:** `tests/e2e/test_thinking_no_scroll.py`
## Bug report (verbatim, `TODO.md` L4)
> "Disable scroll in the thinking window. Users don't need to scroll back
> through thinking, just see it live."
## Narrative
As **a user**, the Thinking block is a *scratchpad, not a transcript*:
while Brain reasons I just want to watch the newest lines appear — not
scroll back through everything it already thought. The block should
always show the **live tail** of the reasoning stream, in its fixed
320px window, with no way to scroll it back.
- **Given** a turn streams a long reasoning body (longer than the 320px
window)
- **When** I try to scroll back through the window — mouse wheel, mouse
drag, or keyboard — while it streams (or while it sits open before the
first token)
- **Then** the window does not move: it stays glued to the live tail,
pinned there by the phase-17 per-chunk bottom-pin (the sole scroller).
## Acceptance criteria
1. With a long thinking stream, wheel / mouse-drag / keyboard on
`.thinking-text` do **not** move it; the visible content is always the
live tail (pinned at the bottom, within 1px, after each chunk).
2. Computed style of `.thinking-text`: `overflow-y: hidden`,
`max-height: 320px` — the 320px clip stays (owner-confirmed: no
auto-height growth, no "↓ more" affordance).
3. A long **answer** bubble is untouched: it keeps its existing scroll
behavior (phase 11) — phase 21 only changed `.thinking-text`.
4. Restored (collapsed) Thinking blocks are untouched (phase 14/17
restore path): they render collapsed with their text, where overflow
is moot.
5. No new surface: the block keeps its summary chevron, focus-visible
ring, aria contract, and the reduced-motion stillness (styles.css
`prefers-reduced-motion` block).
## Owner-confirmed (2026-08-24, roadmap A2)
1. **Keep the 320px clip** — "just see it live" means the window stays a
fixed 320px viewport showing the newest lines; no auto-height growth,
no "↓ more" affordance.
2. **The answer bubble is untouched** — final answers keep their existing
scroll behavior (phase 11 long answers).
3. **Restored (collapsed) Thinking blocks are untouched** — the phase-14
restore renders them collapsed, where overflow is moot.
## UI Visualization & Structure
- **The whole functional change is one CSS property**
(`frontend/assets/styles.css`, phase-17 thinking section):
`details.thinking .thinking-text` — `overflow-y: auto` →
`overflow-y: hidden` (with the owner-choice comment: *no user scroll
back (owner choice 2026-08-24): the window is a live tail only — the
JS bottom-pin is the sole scroller*). `max-height: 320px` and every
other declaration stay byte-identical.
- **Why this works:** `overflow: hidden` disables *user* scrolling
(wheel/drag/keyboard) but still permits *programmatic* scrolling — so
the phase-17 bottom-pin (`textEl.scrollTop = textEl.scrollHeight` on
every `thinking` chunk, `frontend/assets/app.js`) keeps the window
glued to the live tail with **no JS change**.
- **Non-goals:** no change to the answer bubble, the collapsed restore
state, the summary/chevron, or the auto-collapse on the first delta
(phase 17).
## Playwright Mapping Rule
**Test Scenario → `tests/e2e/test_thinking_no_scroll.py`** (mock LLM;
phase 21 lengthened `mock_llm.compose_thinking` to ~2 700 chars ≈ 4.5s
of paced `reasoning_content` frames so the scratchpad overflows the
320px window; tests 1–2 use the phase-20 `think out loud then hesitate`
trigger — after the stream ends there is a deterministic 4s pre-content
pause with the block still open and no further pin frames, a frozen live
tail where any (regressed) user scroll would persist and be observable):
1. `test_thinking_window_not_user_scrollable` — wait until the window
overflows and the thinking stream ends (text stable; block still
open, pre-token). Assert the pin left it at the tail; then focus
`.thinking-text`, wheel up ×2, `Home` + `ArrowUp`, mouse-drag up:
`scrollTop` must not move (within 1px) and the window must still show
the live tail.
2. `test_thinking_window_tracks_live_tail` — while the stream is live:
once the 2nd-to-last chunk has landed, the window is pinned to the
tail; after the last chunk (held by the 4s pause) it is still pinned,
the last chunk's text (the final text node) renders inside the
visible rectangle, and a hit-test at the box's bottom lands inside
`.thinking-text`.
3. `test_thinking_window_css_contract` — computed style: `overflow-y` is
`hidden`, `max-height` is `320px`, and the clip is real
(`scrollHeight > clientHeight` for the long scratchpad).
4. `test_answer_bubble_still_scrollable` (regression, phase 11) — the
long-answer mock question streams to completion; the answer bubble's
computed `overflow-y` is not `hidden`, the page is scrollable, and
wheel up/down over the answer moves the page scroll both ways.
5. `test_restored_collapsed_thinking_unaffected` (regression, phase 17)
— a settled thinking turn is reloaded; the restored conversation
renders the Thinking block **collapsed** with its full text (matching
the persisted raw text), and the answer bubble is intact.
@@ -1,98 +0,0 @@
# Story: Thinking scroll back (user scroll + generate-time autoscroll)
**Phase:** `43_thinking_scroll_back` · **Source:** `TODO.md` L7 ·
**E2E:** `tests/e2e/test_thinking_scroll.py`
## Bug report (verbatim, `TODO.md` L7)
> "Add scrolling back to the thinking block, but have it autoscroll
> while thinking content is generating."
## Narrative
As **a user watching Brain reason**, the Thinking block should work like
a well-behaved live console: it **follows the tail while the reasoning
is generating** — but the moment I scroll up to re-read an earlier line,
it must **stop yanking me down**, and it must let me scroll the window
freely (phase 21's no-scroll clip is being reversed by owner direction).
- **Given** the Thinking block is streaming reasoning content
- **When** I'm at the bottom of the 320px window
- **Then** each new chunk keeps the window pinned to the live tail.
- **When** I scroll up to read earlier reasoning
- **Then** the window stays where I put it (no more re-pinning);
- **When** I return to the bottom
- **Then** tail-following resumes on the next chunk.
## Acceptance criteria
1. **User scroll restored:** computed `overflow-y` of
`.thinking-text` is `auto`; wheel / mouse-drag / keyboard move the
window (phase 21's `overflow-y: hidden` is gone).
2. **Follow while generating:** with the user pinned at the window's
bottom (within a small near-bottom band — the phase-18 pattern,
now applied to the *window* instead of the page, exported constant
`THINKING_NEAR_BOTTOM_PX = 32`), each streamed chunk re-pins the
window to the tail (within 1px).
3. **Paused on scroll-up:** scrolled up, the window stops being re-pinned
— `scrollTop` stays stable across subsequent chunks (within 1px).
4. **Resumes on return:** scrolling back to the bottom (within the band)
resumes tail-following on the next chunk.
5. **Kept from phases 17/21:** the fixed 320px `max-height` window, the
auto-collapse on the first answer token, the reduced-motion stillness,
and the answer-bubble scroll behavior (phase 11) are all unchanged.
## Owner-confirmed (2026-08-27, roadmap A2)
1. **The 320px window stays** — only the overflow mode and the pinning
logic change.
2. **Follow-the-bottom for the window:** autoscroll only while the user
is pinned near the window's bottom (≈32px band); scroll-up pauses,
return-to-bottom resumes. (The phase-18 page-level band is removed in
phase 42; this is its window-level successor.)
## UI Visualization & Structure
- **CSS** (`frontend/assets/styles.css`): `details.thinking
.thinking-text` — `overflow-y: hidden` → `overflow-y: auto`; the
owner-choice comment is replaced with the 2026-08-27 direction
(user-scrollable window; JS follows the tail only while pinned).
`max-height: 320px` and all other declarations untouched.
- **JS** (`frontend/assets/app.js`, the `thinking` SSE handler):
- new exported `const THINKING_NEAR_BOTTOM_PX = 32` +
`isThinkingNearBottom(textEl)` (`scrollHeight - scrollTop -
clientHeight <= band`);
- the phase-17 unconditional pin
(`textEl.scrollTop = textEl.scrollHeight`) becomes gated:
`if (block.open && isThinkingNearBottom(textEl)) { textEl.scrollTop
= textEl.scrollHeight; }` — a scrolled-up user is never re-pinned,
and returning to the bottom re-arms the pin automatically (the check
runs on every chunk).
- **Non-goals:** no "↓ more" affordance, no auto-height growth, no change
to the summary/chevron, the tool-call lines, or the answer bubble.
## Playwright Mapping Rule
**Test Scenario → `tests/e2e/test_thinking_scroll.py`** (mock LLM; DB
up; phase 21 lengthened `mock_llm.compose_thinking` to ~2 700 chars ≈
4.5 s of paced frames so the scratchpad overflows the 320px window by
~2×, and the phase-20 hesitation trigger gives a deterministic 4 s
frozen-tail state with the block open). The phase-21 suite
`tests/e2e/test_thinking_no_scroll.py` is **deleted** in this phase:
1. `test_thinking_window_user_scrollable` — frozen live tail (4 s
hesitation): focus `.thinking-text`, wheel up, `Home`, mouse-drag up
— `scrollTop` moves; the window shows earlier content.
2. `test_thinking_window_follows_while_pinned` — during the live stream:
with the user at the bottom, after the 2nd-to-last and the last chunk
the window is pinned to the tail (within 1px) and the last chunk's
text renders inside the visible rectangle.
3. `test_thinking_window_stops_on_scroll_up` — mid-stream: scroll up
~half the window; over the next ≥5 chunks `scrollTop` stays stable
(within 1px) — no re-pin.
4. `test_thinking_window_resumes_on_return` — from the paused state,
scroll the window back to its bottom; on the next chunk the window is
re-pinned to the tail (within 1px).
5. `test_thinking_window_css_contract` — computed `overflow-y: auto`,
`max-height: 320px`, and the clip is real (`scrollHeight >
clientHeight` for the long scratchpad).
6. `test_answer_bubble_still_scrollable` (phase 11 regression) — a long
answer: the page scrolls, the bubble's overflow is untouched.
7. `test_restored_collapsed_thinking_unaffected` (phase 17 regression) —
a settled thinking turn reloads as a collapsed block with its full
text.
@@ -1,87 +0,0 @@
# Story: Tuning toggle anonymous flash
**Phase:** `40_tuning_toggle_flash` · **Source:** `TODO.md` L3 ·
**E2E:** `tests/e2e/test_tuning_toggle_flash.py`
## Bug report (verbatim, `TODO.md` L3)
> "Loading the page briefly shows the 'Tuning' button in the header even
> when the user isn't authenticated. Only show that if the user is
> authenticated."
## Narrative
As **an anonymous visitor**, the header must never show admin-only
controls — not even for a frame. Today the tuning-notes toggle
(`#steering-toggle`, the header button labeled **"Tuning"**) ships
*visible* in all six pages' markup and is only removed from the DOM after
`/api/whoami` resolves — so every anonymous page load flashes the button
for the length of the whoami round-trip. The admin-only *nav links*
(`#nav-sources`, `#nav-git-sources`, `#nav-tuning`) already ship `hidden`
(phase-19 "absent, not hidden" contract) and are not the issue.
- **Given** an anonymous visitor loads any page
- **When** the page renders (before `/api/whoami` resolves)
- **Then** no "Tuning" control is ever visible — not for a single frame.
- **Given** a signed-in admin loads any page
- **When** whoami resolves
- **Then** the toggle is revealed (and the note list refreshes, as today).
## Acceptance criteria
1. Anonymous load of **every** page: `#steering-toggle` is never
attached-visible — a MutationObserver installed via `addInitScript`
records zero visible frames of the toggle from first paint to settled
state; after load the toggle is absent from the DOM (the existing
remove-from-DOM behavior).
2. Admin load: the toggle is visible after whoami, `aria-expanded`
works, the count badge refreshes — identical to today's admin
behavior (phase 15/34 contract).
3. No-JS visitors: the toggle is hidden (the control is JS-gated by
design — whoami decides).
4. No regression to the shared-header contract (phase 19/34): nav links
ship hidden, sign-in/sign-out pair, sync button, new-chat binding
unchanged.
## Owner-confirmed (2026-08-27, roadmap A — confirmed with the
conversion interview)
1. **The flashing control is the steering toggle**, not the admin
"Tuning" nav link (which already ships `hidden`) — confirmed by code
inspection: `#steering-toggle` ships visible in all six pages
(`index.html`, `sources.html`, `document.html`, `git-sources.html`,
`login.html`, `tuning.html`) and is removed post-whoami.
2. **Fix = ship `hidden`, reveal for admin, keep anonymous removal** —
the same ship-hidden / reveal-for-admin contract the admin-only nav
links already use; anonymous still gets "absent, not hidden".
## UI Visualization & Structure
- **The whole functional change is one attribute + one JS line:**
- `#steering-toggle` gains `hidden` in all six pages' header markup
(the button element only — the `#steering-panel` region already ships
`hidden`).
- `frontend/assets/header.js` `initSharedHeader()`: in the admin branch,
unhide the toggle (`steeringToggle.hidden = false`) before
`refreshSteering()`; the anonymous branch (`steeringToggle?.remove()`)
is unchanged.
- **Non-goals:** no change to the nav links, the panel, the steering
API, or any other shared-header control.
## Playwright Mapping Rule
**Test Scenario → `tests/e2e/test_tuning_toggle_flash.py`** (mock LLM;
DB up):
1. `test_anonymous_never_sees_toggle` — `addInitScript` a MutationObserver
that records every frame in which `#steering-toggle` exists in the
DOM and is not `[hidden]`; load `/` anonymously; after load assert the
observer recorded **zero** such frames, and the toggle is absent from
the DOM (removed, per the phase-16 contract).
2. `test_anonymous_other_pages_never_flash` — same observer assertion on
`/sources.html`, `/tuning.html`, `/login.html` (the page set the
contract must hold on).
3. `test_admin_toggle_revealed_and_working` — login via
`e2e.auth_helpers.login`; reload `/`; the toggle is visible
(`hidden` removed), clicking opens `#steering-panel`
(`aria-expanded="true"`), and the count badge matches the panel.
4. `test_nav_contract_regression` (phase 19/34) — anonymous: nav links
`#nav-sources` / `#nav-git-sources` / `#nav-tuning` stay hidden and
absent from the visible header; admin: they are revealed — the
ship-hidden contract this phase relies on is intact.
@@ -1,67 +0,0 @@
# Story: Whole-Document Context — a matched document is never truncated
**Phase:** `24_whole_document_context` · **E2E:** `tests/e2e/test_whole_document_context.py`
## Bug report (TODO.md L3–L4, verbatim)
> *"Documents are truncated for some reason? This should never happen"*
>
> *"When the LLM matches a chunk it should get the entire document placed in its context so it can see the whole thing before answering the question"*
## Narrative
As **a user asking about an indexed note**, I want **the entire document
in the LLM's context** whenever retrieval matches one of its chunks — so
the answer is grounded in the whole note, not a silently cut-down version
of it.
- **Given** a question whose chunk matches a document
- **When** the turn assembles context
- **Then** the LLM receives the **entire** parent document — never a
`[…truncated…]`-cut version — so the answer is grounded in the whole
note.
## Owner-confirmed (2026-08-24, roadmap D1–D5)
1. **D1 — no cap at all (revises LOCKED A7):** `select_documents` returns
the full top-N document texts, always. The `max_context_chars` setting
and `BOR_MAX_CONTEXT_CHARS` env var are removed. If a future KB ever
makes the prompt too large for the model, the existing `LLMError` → SSE
`error` path surfaces it loudly — no silent partial context. The
emergency-valve variant (raised cap + warning log) was **explicitly
rejected**.
2. **D2 — `top_n_docs = 2` unchanged** (the TODO is about truncation, not
about how many documents).
3. **D3 — no viewer/import changes** — both already serve full content
(verified diagnosis above).
4. **D4 — E2E evidence via a deterministic mock tail-echo** (repo pattern,
cf. the phase-15 tuning-note echo); the big documents are seeded
directly in the DB inside the E2E test — `tests/fixtures/docs/` must
not grow, because other suites pin `summary.added == 8`.
5. **D5 — no `query_log` schema change** (no new columns, no migration).
## Acceptance criteria
1. No budget parameter in `select_documents` — it never truncates
(`TRUNCATION_MARKER` remains for the steering section only).
2. `BOR_MAX_CONTEXT_CHARS` gone from settings/env/README
(`app/config.py`, `.env.example`, `README.md`).
3. The story E2E's three tests green in isolation
(`uv run pytest tests/e2e/test_whole_document_context.py -v --no-cov`).
4. Steering-note truncation (phase 15, `BOR_STEERING_MAX_CHARS` + shared
marker) unchanged.
5. Unit + integration green, `app/` coverage >90%, one
`--no-gpg-sign` commit.
## Playwright Mapping Rule
**Test Scenario → `tests/e2e/test_whole_document_context.py`** (mock LLM
tail-echo, oversized documents seeded directly in the DB):
1. `test_whole_document_over_old_cap_reaches_llm` — a 30 000-char document
(past the old 24 000 cap): its tail sentinel (last line) is echoed in
the rendered answer, `[…truncated…]` is absent, the source chip renders,
`query_log` row `deflected == False`.
2. `test_second_document_of_over_cap_pair_reaches_llm` — two ~16 000-char
documents (32 000 combined — the exact case the old budget cut): the
second, lower-ranked document's tail sentinel is echoed (doc 1's is not
— it pins the rank order), both source chips render.
3. `test_small_document_path_unchanged` — regression: standard fixtures via
the real importer → grounded answer with the `kubernetes.md` chip, no
`[…truncated…]` (the under-cap path is byte-identical to before).