Compare commits
13
Commits
492d8275e7
...
3a404eb161
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a404eb161 | ||
|
|
03d26255c6 | ||
|
|
872a07cee7 | ||
|
|
03bead092c | ||
|
|
5d679f5184 | ||
|
|
6be692d999 | ||
|
|
b855d0aef9 | ||
|
|
bc70ce36e0 | ||
|
|
27b7cb96d5 | ||
|
|
7c6763319b | ||
|
|
6cf1df9bf2 | ||
|
|
6f9e033117 | ||
|
|
02c76ad328 |
+28
-6
@@ -56,7 +56,7 @@ that"* and offers alternatives instead of hallucinating.
|
|||||||
| A6 | Embedding dim | **768** (verified 2026-08-21 against live endpoint via `scripts/llm_probe.py`); configured by `BOR_EMBEDDING_DIM` | User recalled 768 — probe confirmed; dimension is fixed at table creation, so mismatch must fail loudly at import time | LOCKED |
|
| A6 | Embedding dim | **768** (verified 2026-08-21 against live endpoint via `scripts/llm_probe.py`); configured by `BOR_EMBEDDING_DIM` | User recalled 768 — probe confirmed; dimension is fixed at table creation, so mismatch must fail loudly at import time | LOCKED |
|
||||||
| A7 | Retrieval→context | **Hybrid:** cosine top-30 + Postgres FTS top-30 (OR tsquery, `ts_rank`) fused with **RRF (k=60)** → map to parent documents ranked by best fused chunk score → feed the **full text of top-N=2 documents** (deduped) to the LLM | Owner permission 2026-08-21: pure-cosine top-4 missed real docs (gitlab case — best chunk ranked 7th behind vendored-cache junk; score compression 0.41–0.84); the lexical signal finds name-your-tool questions; whole-document context contract preserved. A7 revised 2026-08-24 — matched documents never truncated (owner: "this should never happen"; emergency-valve variant rejected) | LOCKED (revised 2026-08-24) |
|
| A7 | Retrieval→context | **Hybrid:** cosine top-30 + Postgres FTS top-30 (OR tsquery, `ts_rank`) fused with **RRF (k=60)** → map to parent documents ranked by best fused chunk score → feed the **full text of top-N=2 documents** (deduped) to the LLM | Owner permission 2026-08-21: pure-cosine top-4 missed real docs (gitlab case — best chunk ranked 7th behind vendored-cache junk; score compression 0.41–0.84); the lexical signal finds name-your-tool questions; whole-document context contract preserved. A7 revised 2026-08-24 — matched documents never truncated (owner: "this should never happen"; emergency-valve variant rejected) | LOCKED (revised 2026-08-24) |
|
||||||
| A8 | Honesty gate | **Deflection mode** (LLM must open with a variant of *"I haven't done anything like that"* and offer 2–3 alternative questions) when best cosine < `BOR_RELEVANCE_THRESHOLD` **and** no candidate chunk FTS-matches the question; threshold re-tuned for the `embed` model's compressed score range (default **0.62**, calibrated via `scripts/eval_retrieval.py`; the E2E mock uses its own 0.30 calibration via the app fixture) | Owner permission 2026-08-21: at 0.30 the gate never discriminated (measured corpus range 0.41–0.84); the FTS-OR keeps name-your-tool questions honest-positive; deflection product behavior unchanged | LOCKED (revised 2026-08-21) |
|
| A8 | Honesty gate | **Deflection mode** (LLM must open with a variant of *"I haven't done anything like that"* and offer 2–3 alternative questions) when best cosine < `BOR_RELEVANCE_THRESHOLD` **and** no candidate chunk FTS-matches the question; threshold re-tuned for the `embed` model's compressed score range (default **0.62**, calibrated via `scripts/eval_retrieval.py`; the E2E mock uses its own 0.30 calibration via the app fixture) | Owner permission 2026-08-21: at 0.30 the gate never discriminated (measured corpus range 0.41–0.84); the FTS-OR keeps name-your-tool questions honest-positive; deflection product behavior unchanged | LOCKED (revised 2026-08-21) |
|
||||||
| A9 | Content scope | Text formats **`md, markdown, txt, yaml, yml, json, py`** (default, `BOR_IMPORT_EXTENSIONS`), **hidden (dot) directories skipped by default**, plus the exclusion list (`node_modules`, `__pycache__`, `.pytest_cache`, `dist`, `build`, …) | Owner permission 2026-08-21: real notes live in yaml/py/json/txt too; the dot-dir skip removes the ~470 vendored-cache junk docs (`.esphome/.espressif/**`, …) that outranked real content | LOCKED (revised 2026-08-21) |
|
| A9 | Content scope | Text formats **`md, markdown, txt, yaml, yml, json, py`** (default, `BOR_IMPORT_EXTENSIONS`), **hidden (dot) directories skipped by default**, plus the exclusion list (`node_modules`, `__pycache__`, `.pytest_cache`, `dist`, `build`, …) | Owner permission 2026-08-21: real notes live in yaml/py/json/txt too; the dot-dir skip removes the ~470 vendored-cache junk docs (`.esphome/.espressif/**`, …) that outranked real content | LOCKED (revised 2026-08-27) |
|
||||||
| A10 | Auth | **None in v1**; all endpoints stateless under `/api` | Per user (auth later); statelessness keeps the future migration cheap | LOCKED |
|
| A10 | Auth | **None in v1**; all endpoints stateless under `/api` | Per user (auth later); statelessness keeps the future migration cheap | LOCKED |
|
||||||
| A11 | Frontend | Vanilla HTML/CSS/JS in git; **no CDN** — everything served by FastAPI `StaticFiles`; minified by esbuild in the `Containerfile` build stage; system font stack | No external deps at runtime; tiny, auditable surface; mobile-friendly by construction | LOCKED |
|
| A11 | Frontend | Vanilla HTML/CSS/JS in git; **no CDN** — everything served by FastAPI `StaticFiles`; minified by esbuild in the `Containerfile` build stage; system font stack | No external deps at runtime; tiny, auditable surface; mobile-friendly by construction | LOCKED |
|
||||||
| A12 | Aux services | **None in v1** (no Valkey, no SeaweedFS) | No sessions/auth (no store), no uploads (no object storage); add later only if a need appears | LOCKED |
|
| A12 | Aux services | **None in v1** (no Valkey, no SeaweedFS) | No sessions/auth (no store), no uploads (no object storage); add later only if a need appears | LOCKED |
|
||||||
@@ -83,6 +83,13 @@ that"* and offers alternatives instead of hallucinating.
|
|||||||
> `BOR_MAX_CONTEXT_CHARS` is gone. The steering section
|
> `BOR_MAX_CONTEXT_CHARS` is gone. The steering section
|
||||||
> (`BOR_STEERING_MAX_CHARS`, phase 15) keeps its budget and the shared
|
> (`BOR_STEERING_MAX_CHARS`, phase 15) keeps its budget and the shared
|
||||||
> marker.
|
> marker.
|
||||||
|
>
|
||||||
|
> **A9 revision (phase 47, owner permission 2026-08-27):** the format
|
||||||
|
> set extends with the Podman quadlet family (`container, network,
|
||||||
|
> volume, image, pod, kube, swap, os, endpoint`) and `j2` (Jinja
|
||||||
|
> templates) — plain-text chunking (`chunk_text`), owner: `TODO.md`
|
||||||
|
> L10–L11. The narrow-only `BOR_IMPORT_EXTENSIONS` rule and the
|
||||||
|
> hidden-dir/exclusion invariants are unchanged.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -183,11 +190,21 @@ stuck button).
|
|||||||
> gains a second event type — `{"type":"tool","name":"…","argument":…}` —
|
> gains a second event type — `{"type":"tool","name":"…","argument":…}` —
|
||||||
> carrying the model's document tool calls on grounded turns (phase 37:
|
> carrying the model's document tool calls on grounded turns (phase 37:
|
||||||
> `list_documents` / `read_document`, budgeted by `BOR_AGENT_LIST_CALLS`
|
> `list_documents` / `read_document`, budgeted by `BOR_AGENT_LIST_CALLS`
|
||||||
> / `BOR_AGENT_READ_CALLS`; `argument` is `"source/path"` for
|
> / `BOR_AGENT_READ_CALLS` (removed in phase 45 — see the revision note
|
||||||
|
> below); `argument` is `"source/path"` for
|
||||||
> `read_document`, null otherwise). Client rule: render each `tool` frame
|
> `read_document`, null otherwise). Client rule: render each `tool` frame
|
||||||
> as a "calling tool" line/state (task 05); `delta` and `done` shapes are
|
> as a "calling tool" line/state (task 05); `delta` and `done` shapes are
|
||||||
> unchanged — the read document is reflected in `done.sources` instead
|
> unchanged — the read document is reflected in `done.sources` instead
|
||||||
> (deduped) — a recorded extension of A15, not a silent deviation.
|
> (deduped) — a recorded extension of A15, not a silent deviation.
|
||||||
|
>
|
||||||
|
> **SSE revision (phase 45, owner permission 2026-08-27):** the phase-37
|
||||||
|
> per-turn tool budgets are **removed** (owner: "allow the LLM to make
|
||||||
|
> as many tool calls as it wants — `TODO.md` L8): `BOR_AGENT_LIST_CALLS`
|
||||||
|
> / `BOR_AGENT_READ_CALLS` no longer exist; `BOR_AGENT_MAX_ROUNDS`
|
||||||
|
> (default 10) caps the tool rounds and `0` disables the tools
|
||||||
|
> entirely (the pre-phase-37 path). The `tool` event shape and the
|
||||||
|
> `done` shape are unchanged — a recorded revision of the phase-37
|
||||||
|
> note's budget wording, not a silent deviation.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -241,8 +258,11 @@ preceding heading in the text for retrieval quality.
|
|||||||
**Format-aware (A9, revised):** `yaml`/`yml` split on top-level keys and
|
**Format-aware (A9, revised):** `yaml`/`yml` split on top-level keys and
|
||||||
`---` separators (key line kept as anchor); `json` pretty-printed, split on
|
`---` separators (key line kept as anchor); `json` pretty-printed, split on
|
||||||
top-level keys; `py` split on top-level defs/classes (stdlib `ast`);
|
top-level keys; `py` split on top-level defs/classes (stdlib `ast`);
|
||||||
`txt` on paragraphs; markdown unchanged. Every format honors the 1200-char
|
`txt` on paragraphs; markdown unchanged. The quadlet family
|
||||||
hard cap (aipi ~1024-token request limit).
|
(`container, network, volume, image, pod, kube, swap, os, endpoint`)
|
||||||
|
and `j2` (Jinja templates) are plain-text chunked — no format-specific
|
||||||
|
splitter (A9 revised 2026-08-27, phase 47). Every format honors the
|
||||||
|
1200-char hard cap (aipi ~1024-token request limit).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -445,8 +465,10 @@ uv run python -m scripts.llm_probe # sanity: models + dim
|
|||||||
Behavior: sha256 delta per `(source, path)` — unchanged files are skipped
|
Behavior: sha256 delta per `(source, path)` — unchanged files are skipped
|
||||||
(no re-embedding); changed files are re-chunked + re-embedded (chunks
|
(no re-embedding); changed files are re-chunked + re-embedded (chunks
|
||||||
replaced atomically); `--prune` removes docs whose files disappeared or no
|
replaced atomically); `--prune` removes docs whose files disappeared or no
|
||||||
longer match the format filter. Formats per A9 (revised): `md, markdown,
|
longer match the format filter. Formats per A9 (revised 2026-08-27):
|
||||||
txt, yaml, yml, json, py` (`BOR_IMPORT_EXTENSIONS`), hidden (dot)
|
`md, markdown, txt, yaml, yml, json, py`, the quadlet family
|
||||||
|
(`container, network, volume, image, pod, kube, swap, os, endpoint`), and
|
||||||
|
`j2` (plain-text chunked) (`BOR_IMPORT_EXTENSIONS`), hidden (dot)
|
||||||
directories skipped, exclusion list applied. `scripts/eval_retrieval.py`
|
directories skipped, exclusion list applied. `scripts/eval_retrieval.py`
|
||||||
ranks live hybrid results for a question (retrieval tuning).
|
ranks live hybrid results for a question (retrieval tuning).
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Task 01 — Toggle ships hidden, admin-only reveal
|
||||||
|
|
||||||
|
**Phase:** `40_tuning_toggle_flash` · **Source:** `TODO.md:3` — "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."
|
||||||
|
**Story:** `.agent/user_stories/tuning-toggle-flash.md`
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Ship `#steering-toggle` `hidden` in all six pages and unhide it in `header.js` only when whoami says admin — zero flash for anonymous, identical admin UX.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `frontend/index.html`, `frontend/sources.html`, `frontend/document.html`, `frontend/git-sources.html`, `frontend/login.html`, `frontend/tuning.html` — add the `hidden` attribute to the existing `#steering-toggle` `<button>` (the element that ships `aria-expanded="false" aria-controls="steering-panel"`; keep every other attribute, icon, label, and count badge byte-identical). The `#steering-panel` section already ships `hidden` — do not touch it.
|
||||||
|
2. `frontend/assets/header.js` — in `initSharedHeader()`, in the `if (admin)` branch, add `if (steeringToggle) steeringToggle.hidden = false;` **before** `if (steeringPanel) refreshSteering();`. The anonymous branch (`steeringToggle?.remove(); steeringPanel?.remove();`) stays byte-identical. Update the module docstring: the steering controls are now ship-hidden / reveal-for-admin (2026-08-27, `TODO.md` L3), matching the admin-only nav links.
|
||||||
|
3. `tests/unit/test_steering_toggle_visibility.py` (new) — source pins in the house style (regex/substring over the HTML + JS files, see `tests/unit/test_sync_button.py`):
|
||||||
|
- `#steering-toggle` carries `hidden` in **all six** pages;
|
||||||
|
- `header.js` contains the admin unhide (`steeringToggle.hidden = false`) inside `initSharedHeader`;
|
||||||
|
- the anonymous removal (`steeringToggle?.remove()`) is still present;
|
||||||
|
- `#nav-tuning` still ships `hidden` (the contract this phase relies on).
|
||||||
|
4. Grep the existing suites for exact-markup pins of the toggle (`tests/unit/test_shared_header.py`, `tests/unit/test_steering.py`, `tests/e2e/test_global_tuning.py`, `test_steering.py`) and update any assertion that breaks purely because of the new `hidden` attribute — behavior assertions stay.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Unit: the new pin file above; full unit suite green.
|
||||||
|
- Coverage: **>90%** on `app/` (no Python change — TOTAL must be unchanged; run `uv run pytest --cov=app --cov-report=term-missing`).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] All six pages ship `#steering-toggle` with `hidden`; `header.js` reveals for admin and still removes for anonymous.
|
||||||
|
- [ ] `uv run pytest` green; coverage TOTAL unchanged.
|
||||||
|
- [ ] No behavior change in completed work (admin steering flow byte-identical: open/close, count badge, delete, announcer).
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Task 01 — Server core: round cap replaces the budgets (config + loop + unit/integration)
|
||||||
|
|
||||||
|
**Phase:** `45_agent_unlimited_tools` · **Source:** `TODO.md:8` — "Allow the LLM to make as many tool calls as it wants, remove the restrictions, they're causing problems getting correct answers"
|
||||||
|
**Story:** `.agent/user_stories/agent-unlimited-tools.md`
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
One coherent server-side change, landed atomically so the suite is green at the checkpoint: `agent_max_rounds` (`BOR_AGENT_MAX_ROUNDS`, default 10; `0` = no tools) replaces both per-tool budgets in config, the agent loop, and every test that pins them.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `app/config.py` —
|
||||||
|
- **delete** the `agent_list_calls` and `agent_read_calls` fields (with docstrings);
|
||||||
|
- **add** in their place, same "RAG tuning" section:
|
||||||
|
```python
|
||||||
|
#: Hard cap on the agent tool rounds per grounded turn (phase 45,
|
||||||
|
#: revising phase 37's per-tool budgets — owner permission
|
||||||
|
#: 2026-08-27, TODO L8: "allow the LLM to make as many tool calls
|
||||||
|
#: as it wants"). Every tool call the model emits consumes a
|
||||||
|
#: round; at the cap the loop forces one final no-tools answer.
|
||||||
|
#: ``0`` disables the tools entirely — the turn is a single
|
||||||
|
#: request with ``tools=None`` (the pre-phase-37 path — the kill
|
||||||
|
#: switch).
|
||||||
|
agent_max_rounds: int = 10
|
||||||
|
```
|
||||||
|
- optional: a `field_validator` rejecting negative values (note it in the docstring if added).
|
||||||
|
2. `app/rag/agent.py` —
|
||||||
|
- `run_agent`: `max_rounds = settings.agent_max_rounds`; `tools = AGENT_TOOLS if max_rounds > 0 else None` (the kill switch — at 0 the loop makes exactly one request with `tools=None`, byte-identical to the pre-phase-37 path);
|
||||||
|
- delete `list_left` / `read_left` and the budget-driven `tools = None if (list_left == 0 and read_left == 0) else AGENT_TOOLS` transition — `tools` stays `AGENT_TOOLS` while rounds remain;
|
||||||
|
- after each executed call: `rounds += 1`; the existing cap branch becomes the **only** forced-exit: `if rounds >= max_rounds:` → the `logger.warning("agent round cap reached …")` + final `chat_stream(messages, tools=None)` (update the warning text: it is no longer belt-and-braces — it is the cap);
|
||||||
|
- `_execute_tool(db, call, seed_docs, holder)`: drop the `list_left` / `read_left` parameters and the `LIST_EXHAUSTED` / `READ_EXHAUSTED` early returns; keep the `ALREADY_IN_CONTEXT`, `UNKNOWN_TOOL`, `MISSING_READ_ARGS` rejections (non-budget — a repeated rejected call still consumes a *round* in the loop, so a pathological stream is bounded by `max_rounds`); return type simplifies to `str`;
|
||||||
|
- delete the `LIST_EXHAUSTED` / `READ_EXHAUSTED` constants;
|
||||||
|
- `AGENT_TOOLS`: `read_document` description "Add the full content of exactly one more indexed document to your context" → "Add the full content of one more indexed document to your context";
|
||||||
|
- module docstring: the budget paragraph (points 1, 3, 4) rewritten for the round cap (owner revision 2026-08-27, `TODO.md` L8); `run_agent` docstring updated (`seed_docs` note unchanged); `AgentHolder` unchanged (`tool_calls` still counts executed calls — now including re-lists);
|
||||||
|
- the per-call `logger.info("agent tool=… budget list_left=… read_left=…")` line becomes `logger.info("agent tool=%s args=%s round=%d/%d", …)` (or equivalent — the per-turn `tool_calls=N` field in `app/api/chat.py` is untouched).
|
||||||
|
3. `tests/unit/test_agent.py` — **rewrite** the budget tests around the round cap (keep the file's fake-LLM harness):
|
||||||
|
- an always-`list_documents`-calling mock with `agent_max_rounds=3`: exactly 3 tool rounds execute, then one forced `tools=None` request streams the answer; `holder.tool_calls == 3`;
|
||||||
|
- `agent_max_rounds=0`: exactly one request, `tools=None`, no tool lines, `holder.tool_calls == 0` (kill switch);
|
||||||
|
- an always-`read_document`-with-unknown-path mock (every call rejected — `No document at …`): the loop runs to `max_rounds` and forces the final answer (rejections no longer end the loop early via budgets, the cap bounds them);
|
||||||
|
- the existing rejections tests (`Unknown tool.`, `MISSING_READ_ARGS`, `Already in your context.`) keep passing — update their `_settings(...)` calls (`agent_max_rounds=…` instead of the budget kwargs);
|
||||||
|
- a **re-list** test: `list_documents` called twice in one turn executes both (the second returns the catalog again) and counts 2 in `holder.tool_calls`.
|
||||||
|
4. `tests/unit/test_config.py` — default 10; `BOR_AGENT_MAX_ROUNDS=0` / `=5` overrides; (negative validator, if added); delete the old budget assertions.
|
||||||
|
5. `tests/integration/test_chat_api.py` — the `agent_list_calls=0, agent_read_calls=0` fixture kwargs (~line 661) become `agent_max_rounds=0`; any other budget kwarg in the file the same; the tool SSE-event and `done.sources` assertions stay untouched.
|
||||||
|
6. `.env.example` — the two `BOR_AGENT_*_CALLS` lines become one: `# BOR_AGENT_MAX_ROUNDS=10 # hard cap on agent tool rounds per turn (0 = no tools)` (file's optional-setting comment style).
|
||||||
|
7. Grep the repo for `agent_list_calls|agent_read_calls|BOR_AGENT_(LIST|READ)_CALLS|LIST_EXHAUSTED|READ_EXHAUSTED` — zero hits outside `.agent/phases/complete/**` (history).
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Unit + integration: full `uv run pytest` green at this checkpoint (the mock/E2E multi-read flow lands in task 02 — the existing 3-step mock flow still works unmodified, so `test_agent_document_tools.py` E2E is not yet run by the gate).
|
||||||
|
- Coverage: **>90%** on `app/` — `agent.py` + `config.py` fully covered by the rewritten tests.
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] No per-tool budgets anywhere in `app/` or `tests/`; `agent_max_rounds` is the single knob (default 10, `0` = kill switch).
|
||||||
|
- [ ] Re-lists execute; non-budget rejections intact; the cap bounds pathological streams; `tool_calls=N` log field and `tool` SSE event unchanged.
|
||||||
|
- [ ] `uv run pytest` + coverage gate green at this checkpoint.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Task 02 — Mock: deterministic multi-read tool flow
|
||||||
|
|
||||||
|
**Phase:** `45_agent_unlimited_tools` · **Source:** `TODO.md:8` — "Allow the LLM to make as many tool calls as it wants, remove the restrictions, they're causing problems getting correct answers"
|
||||||
|
**Story:** `.agent/user_stories/agent-unlimited-tools.md`
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
The E2E mock gains a deterministic **multi-read** agent flow (list → read #1 → read #2 → answer) so "as many tool calls as it wants" is provable statelessly, without disturbing the existing 3-step flow.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `tests/e2e/mock_llm.py` —
|
||||||
|
- the existing phase-37 flow (documented in the module docstring and `_tool_flow`): marker `TOOLS_TRIGGER` ("use your tools") + `<tools>` system section → step classification **statelessly from the messages**: no tool results yet → `list`; one `tool`-role message with the catalog prefix → `read` (first catalog doc, parsed from the listing via the `rsplit("/", 1)` convention); one `tool`-role message with the `Document <source/path>:` prefix → forced answer.
|
||||||
|
- add a **multi-read variant**: when the user message contains **both** `TOOLS_TRIGGER` and a new marker `MULTI_READ_TRIGGER = "read two documents"`, the classifier reads the *count* of `tool`-role messages whose content starts with `"Document "` (the read-result prefix, `app.rag.agent`'s `_execute_tool` output):
|
||||||
|
- 0 read results (+ no catalog yet) → `list`;
|
||||||
|
- 0 read results (catalog present) → `read` the **first** catalog doc;
|
||||||
|
- 1 read result → `read` the **second** catalog doc (the listing minus the already-read doc — parse the catalog lines the same way the existing read step does, skipping the path already read);
|
||||||
|
- 2 read results → forced answer: the existing answer shape (tail echo) plus a deterministic line naming **both** read paths (e.g. `"I read <path1> and <path2>."` — byte-stable) so the E2E can assert the model actually used both;
|
||||||
|
- the single-read flow (no `MULTI_READ_TRIGGER`) stays byte-identical — the variant must be a strict superset (the existing `test_agent_document_tools.py` E2E keeps passing unmodified).
|
||||||
|
- update the module docstring's tool-flow documentation (the multi-read steps + the marker).
|
||||||
|
2. `uv run pytest` green (mock-only change; the existing 3-step E2E is not run by the unit gate but must stay conceptually intact — the regression run in task 03 proves it).
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Unit: full suite green; if a mock-specific unit test file exists (check `tests/unit/`), add the multi-read classification case there (catalog → read #1 → read #2 → answer) so the new branch is unit-covered; otherwise the E2E (task 03) covers it.
|
||||||
|
- Coverage: **>90%** on `app/` (unchanged — `tests/` only).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] `TOOLS_TRIGGER` + `MULTI_READ_TRIGGER` → deterministic 4-step flow (list, read #1, read #2, answer naming both paths); the 3-step flow is unchanged for marker-less turns.
|
||||||
|
- [ ] Full unit/integration suite green.
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Phase 46 — Mobile hamburger nav
|
||||||
|
|
||||||
|
**Source:** `TODO.md` L9 — "The navbar on mobile is way too squished. Make it a hamburger dropdown menu with a nice animation"
|
||||||
|
**Story:** `.agent/user_stories/mobile-hamburger-nav.md`
|
||||||
|
**Context:** The shared bar (phase 19/34 — the same header block on all six pages) carries brand + up to four text nav pills (Chat, `#nav-sources`, `#nav-git-sources`, `#nav-tuning` — the latter three ship `hidden`, revealed for admin by `header.js`) + four action controls (steering toggle, `#sync-btn`, `#new-chat-btn`, sign in/out). At ≤640px the phase-34/35 squeeze rules (0.72rem pills, 0.05rem gaps) leave a bar that is squished and hard to hit. The fix: on mobile the nav links move into an animated hamburger dropdown; the action pills stay in the bar.
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
At ≤640px the nav links live in a `#nav-toggle`-opened dropdown menu (slide+fade, reduced-motion-still) with comfortable targets and the same auth visibility; at >640px the bar is byte-identical to today.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
- `45_agent_unlimited_tools` (todo) — sequential only (no shared files).
|
||||||
|
- `34_consistent_navbar` / `35_git_sources_admin` (complete) — the six-page bar contract, the admin-only link reveal, and the mobile squeeze rules being superseded.
|
||||||
|
- `07_story_responsive_polish` (complete) — the ≤640px conventions (44px targets, safe areas).
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
1. `01_hamburger_markup_all_pages.md` — `#nav-toggle` + `id="app-nav"` on all six pages; the mobile CSS (dropdown, animation, reduced-motion).
|
||||||
|
2. `02_toggle_behavior.md` — `header.js` open/close behavior (aria, Esc, link-close, resize-close) + source pins.
|
||||||
|
3. `03_hamburger_e2e_and_commit.md` — story E2E suite (mobile + desktop + reduced motion) + regressions + commit.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Unit: new `tests/unit/test_hamburger_nav.py` — `#nav-toggle` (with `aria-controls="app-nav"`, `aria-expanded`, `aria-label="Menu"`) present in **all six** pages and absent-visible on desktop (CSS `display: none` outside the media query); `<nav class="app-nav" id="app-nav">` in all six; the mobile CSS block carries the dropdown rules + `.is-open` state + the 180ms transition + the reduced-motion override; the old nav-pill squeeze rules are gone/superseded; `header.js` carries the toggle binding (click, Esc, delegated link close, matchMedia close).
|
||||||
|
- Coverage: frontend-only — `app/` TOTAL unchanged, >90%.
|
||||||
|
- E2E (mandatory, A16): `tests/e2e/test_mobile_hamburger_nav.py`, run in isolation.
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] 375px: hamburger visible (44px target), inline nav hidden, no horizontal bar overflow; menu opens with animation, closes via link/Esc/outside; anonymous sees only "Chat" in the menu, admin sees all four links.
|
||||||
|
- [ ] `reducedMotion: "reduce"`: no transition, open/close still instant and correct.
|
||||||
|
- [ ] >640px: no hamburger, inline pills exactly as today (phase-34/35 contract intact).
|
||||||
|
- [ ] `uv run pytest` green; coverage TOTAL unchanged.
|
||||||
|
- [ ] `uv run pytest tests/e2e/test_mobile_hamburger_nav.py -v --no-cov` green in isolation (DB up).
|
||||||
|
- [ ] Regression E2E suites green in isolation: `test_nav_consistency.py`, `test_header_consistency.py`, `test_shared_header.py`, `test_responsive_polish.py`, `test_tuning_nav_link.py`.
|
||||||
|
- [ ] `uv run ruff check . && uv run pyright` clean.
|
||||||
|
- [ ] UI Structure Check (AGENTS.md rule 5): the toggle is a labeled 44px button with `aria-expanded`/`aria-controls`; the menu keeps `nav aria-label="Primary"`; focus-visible on the new control; contrast ≥4.5:1; no CDN.
|
||||||
|
- [ ] One `--no-gpg-sign` commit; phase dir moved to `.agent/phases/complete/`.
|
||||||
|
|
||||||
|
## Locked decisions
|
||||||
|
- **Owner-locked (2026-08-27, roadmap A5):** nav links only in the menu; action pills stay in the bar; slide-down + fade 180ms; `prefers-reduced-motion` stills it; the existing ≤640px breakpoint (no new one).
|
||||||
|
- **A11 untouched** — no new assets/CDN; the menu reuses the existing `<nav>` element (no duplicated links, so the whoami reveal keeps working unchanged).
|
||||||
|
- **Phase-34 contract kept** — the same bar on every page; the auth visibility rules apply inside the menu exactly as before.
|
||||||
|
- **A16/A17 honoured** — one story E2E suite, one atomic commit.
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Task 01 — Hamburger markup (six pages) + mobile dropdown CSS
|
||||||
|
|
||||||
|
**Phase:** `46_mobile_hamburger_nav` · **Source:** `TODO.md:9` — "The navbar on mobile is way too squished. Make it a hamburger dropdown menu with a nice animation"
|
||||||
|
**Story:** `.agent/user_stories/mobile-hamburger-nav.md`
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
All six pages carry the identical `#nav-toggle` button + `id="app-nav"`, and the ≤640px stylesheet turns the nav into an animated full-width dropdown — desktop untouched.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. **Markup — all six pages** (`frontend/index.html`, `sources.html`, `document.html`, `git-sources.html`, `login.html`, `tuning.html`), each in its `<header class="app-header">` block:
|
||||||
|
- insert the toggle button **immediately before** the `<nav>`:
|
||||||
|
```html
|
||||||
|
<!-- Phase 46 (owner permission 2026-08-27, `TODO.md` L9): the
|
||||||
|
mobile hamburger — visible ≤640px only (CSS); opens the nav as
|
||||||
|
an animated dropdown. Behavior: assets/header.js. -->
|
||||||
|
<button type="button" class="nav-toggle" id="nav-toggle"
|
||||||
|
aria-expanded="false" aria-controls="app-nav" aria-label="Menu">
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M4 7h16M4 12h16M4 17h16"/></svg>
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
- give the existing nav the id: `<nav class="app-nav" id="app-nav" aria-label="Primary">` (every other attribute/child byte-identical — the links keep their `hidden` attributes; **no links duplicated**).
|
||||||
|
- keep the six pages visually/structurally identical (the phase-34 contract) — the toggle is part of the shared bar block, positioned the same on every page.
|
||||||
|
2. `frontend/assets/styles.css` —
|
||||||
|
- **global (outside media queries):** `.nav-toggle { display: none; }` (desktop: absent);
|
||||||
|
- **inside the existing `@media (max-width: 640px)` block:**
|
||||||
|
- `.nav-toggle { display: inline-flex; align-items: center; justify-content: center; width: 44px; height: 44px; padding: 0; color: var(--ink); background: none; border: 0; border-radius: var(--radius-sm); cursor: pointer; }` + `:focus-visible` inherits the global 3px outline + a hover state matching the other pills (the `.steering-toggle:hover` family);
|
||||||
|
- `.app-nav` becomes the dropdown: `position: absolute; top: 100%; left: 0; right: 0; flex-direction: column; gap: 0; background: var(--surface); border-bottom: 1px solid var(--line); box-shadow: <existing shadow token or 0 8px 24px rgba(0,0,0,.4)>; padding: 0.5rem 0; z-index: <above the header content — check the header's z-index and use header+1>;` — note the containing block is the sticky `.app-header` (`.header-inner` is not positioned), so the menu spans the header's full width, edge to edge — intended on mobile;
|
||||||
|
- **closed state (default):** `visibility: hidden; opacity: 0; transform: translateY(-8px); pointer-events: none; transition: opacity 180ms ease, transform 180ms ease, visibility 0s linear 180ms;`
|
||||||
|
- **open state:** `.app-nav.is-open { visibility: visible; opacity: 1; transform: none; pointer-events: auto; transition: opacity 180ms ease, transform 180ms ease, visibility 0s; }`
|
||||||
|
- **menu rows:** `.app-nav .nav-link { padding: 0.75rem 1.25rem; font-size: 1rem; }` (comfortable 44px+ targets, readable — this **supersedes** the ≤640px pill-squeeze rules for `.nav-link` and `.app-nav` gap in that block: delete/replace the `.nav-link { padding: 0.3rem 0.25rem; font-size: 0.72rem; }` and `.app-nav { gap: 0.05rem; }` rules, keeping the rest of the block);
|
||||||
|
- **reduced motion:** inside the file's existing `@media (prefers-reduced-motion: reduce)` block (the one covering the 640px rules — or a new one after it): `.app-nav { transition: none; }`;
|
||||||
|
- the **900px tablet block is untouched** (inline nav still in use at 641–900px); the action-pill rules in the 640px block are untouched; the header height (`--header-h: 58px`) is untouched.
|
||||||
|
- verify at 360px: brand (clipped clean as today) + hamburger + the four icon action pills fit without horizontal overflow (the old four text pills are gone from the bar — there is now room; if the bar still overflows, the brand ellipsis target absorbs it exactly as before).
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Unit: the new `tests/unit/test_hamburger_nav.py` starts here with the markup + CSS pins (the JS pins land in task 02): toggle markup (attributes) in all six pages; `id="app-nav"` in all six; `.nav-toggle { display: none }` outside media queries; the mobile block carries the dropdown, `.is-open`, the 180ms transition pair, the reduced-motion override, and the superseded squeeze rules are gone; full suite green (behavior not yet wired — the menu is closed by default and CSS-inert without JS, so no E2E regressions at this checkpoint).
|
||||||
|
- Coverage: **>90%** on `app/` (unchanged).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] Six identical toggles + `id="app-nav"`; desktop rendering byte-identical (`.nav-toggle` hidden, nav inline as before).
|
||||||
|
- [ ] Mobile: closed dropdown is invisible and non-interactive; `.is-open` (added by task 02's JS) will be the only opener.
|
||||||
|
- [ ] Full suite green at this checkpoint.
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# Task 02 — Toggle behavior in the shared header module
|
||||||
|
|
||||||
|
**Phase:** `46_mobile_hamburger_nav` · **Source:** `TODO.md:9` — "The navbar on mobile is way too squished. Make it a hamburger dropdown menu with a nice animation"
|
||||||
|
**Story:** `.agent/user_stories/mobile-hamburger-nav.md`
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
The menu opens and closes with correct ARIA state, Esc/link/outside dismissal, and desktop-resize cleanup — one module-owned binding, like the existing sign-out/steering bindings in `header.js`.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `frontend/assets/header.js` — a new module-import binding (same pattern as the sign-out binding: look up at import, guard null-safe, no page-script involvement):
|
||||||
|
```js
|
||||||
|
/* ---------- mobile hamburger (phase 46; module-owned) ----------
|
||||||
|
* ≤640px only (CSS hides the button elsewhere): #nav-toggle opens the
|
||||||
|
* nav as a dropdown (#app-nav .is-open — the animated state, task 01
|
||||||
|
* CSS). One binding for all six pages; a page without either element
|
||||||
|
* is a no-op, like the rest of this module. The nav LINKS keep their
|
||||||
|
* ship-hidden whoami contract (hidden links stay hidden inside the
|
||||||
|
* menu) — this binding only toggles the container. */
|
||||||
|
const navToggle = document.querySelector("#nav-toggle");
|
||||||
|
const appNav = document.querySelector("#app-nav");
|
||||||
|
|
||||||
|
function setNavMenu(open) {
|
||||||
|
if (!appNav || !navToggle) return;
|
||||||
|
appNav.classList.toggle("is-open", open);
|
||||||
|
navToggle.setAttribute("aria-expanded", open ? "true" : "false");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (navToggle && appNav) {
|
||||||
|
navToggle.addEventListener("click", () =>
|
||||||
|
setNavMenu(!appNav.classList.contains("is-open")));
|
||||||
|
// A link click navigates (or closes same-page) — shut the menu.
|
||||||
|
appNav.addEventListener("click", (e) => {
|
||||||
|
if (e.target.closest("a")) setNavMenu(false);
|
||||||
|
});
|
||||||
|
// Esc closes while open (document-level; the menu is the only
|
||||||
|
// document-level overlay this module owns).
|
||||||
|
document.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Escape" && appNav.classList.contains("is-open")) {
|
||||||
|
setNavMenu(false);
|
||||||
|
navToggle.focus(); // focus returns to the opener
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Resize back to desktop: the inline nav reappears — no stale open
|
||||||
|
// state (the .is-open class is scoped by the ≤640px CSS anyway, but
|
||||||
|
// dropping it keeps aria-expanded honest).
|
||||||
|
const mq = window.matchMedia("(max-width: 640px)");
|
||||||
|
const onMqChange = () => { if (!mq.matches) setNavMenu(false); };
|
||||||
|
if (mq.addEventListener) mq.addEventListener("change", onMqChange);
|
||||||
|
else mq.addListener(onMqChange); // older engines, defensive
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- update the module docstring: add the hamburger bullet (phase 46, owner permission 2026-08-27, `TODO.md` L9).
|
||||||
|
2. `tests/unit/test_hamburger_nav.py` — extend (from task 01) with the JS pins: `header.js` contains the `#nav-toggle` binding, `setNavMenu` (or equivalent) syncing **both** `.is-open` and `aria-expanded`, the delegated `a`-click close, the `Escape` close (with focus return), and the `matchMedia("(max-width: 640px)")` change-close; assert the binding is null-safe (`navToggle && appNav` guard).
|
||||||
|
3. `uv run pytest` green at this checkpoint.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Unit: the extended pin file; full suite green.
|
||||||
|
- Coverage: **>90%** on `app/` (unchanged — frontend-only).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] One module-owned binding: click toggles (aria-expanded in sync), Esc closes + refocuses the toggle, a link click closes, desktop resize closes; pages lacking the elements are a no-op.
|
||||||
|
- [ ] The auth visibility contract is untouched — the binding toggles the container only; `hidden` links stay hidden.
|
||||||
|
- [ ] Full suite green at this checkpoint.
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Task 03 — Hamburger E2E + regressions + commit
|
||||||
|
|
||||||
|
**Phase:** `46_mobile_hamburger_nav` · **Source:** `TODO.md:9` — "The navbar on mobile is way too squished. Make it a hamburger dropdown menu with a nice animation"
|
||||||
|
**Story:** `.agent/user_stories/mobile-hamburger-nav.md`
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Prove the full mobile contract in the browser (visibility, contents per auth state, navigation, dismissal, animation, reduced motion, desktop regression) and commit the phase.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `tests/e2e/test_mobile_hamburger_nav.py` (new) — mock-only, DB up. Mobile tests use a 375×812 page (new page per test, or `page.set_viewport_size` — the conftest `page` fixture is 1280×800, so create mobile pages via the `browser` fixture); per the story's Playwright Mapping Rule:
|
||||||
|
- `test_mobile_hamburger_visible_and_bar_roomy` — 375px: `#nav-toggle` visible (box ≥44px in both dimensions), `aria-expanded="false"`, the inline nav links are **not** visible in the bar (menu closed — bounding boxes outside the header band or opacity 0), and no horizontal overflow (`document.documentElement.scrollWidth <= window.innerWidth`);
|
||||||
|
- `test_anonymous_menu_contents` — anonymous at 375px: click `#nav-toggle` → `aria-expanded="true"`, exactly **one** visible link in `#app-nav` ("Chat"); `#nav-sources` / `#nav-git-sources` / `#nav-tuning` remain `hidden` inside the menu;
|
||||||
|
- `test_admin_menu_contents` — login (e2e.auth_helpers) at 375px: open → Chat / Sources / Git sources / Tuning all visible (the whoami reveal works inside the menu);
|
||||||
|
- `test_link_click_navigates_and_closes` — admin at 375px: open, click "Sources" → URL becomes `/sources.html` and on the arrival page the menu is closed (`aria-expanded="false"`, no `.is-open`);
|
||||||
|
- `test_esc_and_outside_close` — open, press `Escape` → closed **and** focus is back on `#nav-toggle`; open again, click a neutral point (e.g. the page footer/main) → closed. (If the outside-click close is not implemented per task 02's contract — it is not: only Esc/link/resize close — assert instead that the menu stays open on an outside click and **note the accepted behavior** in the test docstring; the story's AC 4 lists Esc + link + resize, not backdrop click. Do NOT add a backdrop-close — it is out of the locked scope.)
|
||||||
|
- `test_animation_and_reduced_motion` — motion allowed: computed `transition-duration` on `#app-nav` includes `0.18s` (opacity/transform pair); open → the class/aria flip; `reducedMotion: "reduce"` (new context via the `browser` fixture): computed transition is `none`/`0s` and open/close still works;
|
||||||
|
- `test_desktop_unchanged` (regression) — 1280×800: `#nav-toggle` not visible (`display: none`), the inline nav renders in the bar exactly as before (admin: all four links visible inline).
|
||||||
|
2. Regression pass (isolation runs): `test_nav_consistency.py`, `test_header_consistency.py`, `test_shared_header.py`, `test_responsive_polish.py`, `test_tuning_nav_link.py`.
|
||||||
|
3. `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` TOTAL unchanged; `uv run ruff check . && uv run pyright` clean.
|
||||||
|
4. Commit (Conventional Commits, `--no-gpg-sign`), e.g. `feat(header): hamburger dropdown nav on mobile (owner permission)`, staging this phase's files; move `.agent/phases/todo/46_mobile_hamburger_nav/` → `.agent/phases/complete/`.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- E2E: `uv run pytest tests/e2e/test_mobile_hamburger_nav.py -v --no-cov` green in isolation.
|
||||||
|
- Coverage: **>90%** on `app/` (unchanged — frontend-only phase).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] The story E2E suite passes in isolation (all seven tests); the five regression suites pass in isolation.
|
||||||
|
- [ ] One atomic `--no-gpg-sign` commit; phase dir moved to `.agent/phases/complete/`.
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Task 01 — Config: the ten new formats (allowed + default)
|
||||||
|
|
||||||
|
**Phase:** `47_quadlet_jinja_import` · **Source:** `TODO.md:10–11` — "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"
|
||||||
|
**Story:** `.agent/user_stories/quadlet-jinja-import.md`
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
`app/config.py` allows and imports the ten new formats by default; the env validator keeps rejecting truly unknown extensions.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `app/config.py` —
|
||||||
|
- `_ALLOWED_IMPORT_EXTENSIONS`: add `"container", "network", "volume", "image", "pod", "kube", "swap", "os", "endpoint", "j2"` (with a comment: A9 revised 2026-08-27, owner permission — the full Podman quadlet family + Jinja templates);
|
||||||
|
- `import_extensions` default: `"md,markdown,txt,yaml,yml,json,py,container,network,volume,image,pod,kube,swap,os,endpoint,j2"` (the original seven first, the ten appended — order is cosmetic, the set is what matters; keep the field's docstring and the `mode="after"` validator **unchanged** — it already normalizes/lowercases/dedups and rejects unknowns, so it accepts the new names automatically);
|
||||||
|
- update the module-level comment on `_ALLOWED_IMPORT_EXTENSIONS` (it cites A9 revised 2026-08-21 — append the 2026-08-27 revision).
|
||||||
|
2. `.env.example` — the commented `BOR_IMPORT_EXTENSIONS` line updates to the new default CSV (it currently documents the old default).
|
||||||
|
3. `tests/unit/test_config.py` —
|
||||||
|
- the allowed set contains all seventeen formats;
|
||||||
|
- the default `import_extensions` includes the ten new names (assert each);
|
||||||
|
- `import_extension_set` returns the dotted lowercased set (`.container`, `.j2`, …);
|
||||||
|
- the validator **accepts** a `BOR_IMPORT_EXTENSIONS` containing the new names (e.g. `md,container,j2`) and **still rejects** an unknown one (e.g. `md,xyz`) — the never-widen contract with the widened base set.
|
||||||
|
4. `uv run pytest tests/unit/test_config.py -v` green; full unit suite green (the chunker/importer don't know the new suffixes yet — task 02; no test at this checkpoint imports a new-format file).
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Unit: as above; coverage **>90%** on `app/` (config covered).
|
||||||
|
- No behavior change for existing formats (the default CSV only grows — every previously-imported file still matches).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] Default settings import all seventeen formats; env narrowing/widening rules behave (new names allowed, unknowns rejected).
|
||||||
|
- [ ] `.env.example` documents the new default.
|
||||||
|
- [ ] Full suite green at this checkpoint.
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Task 02 — Chunker dispatch + fixture files
|
||||||
|
|
||||||
|
**Phase:** `47_quadlet_jinja_import` · **Source:** `TODO.md:10–11` — "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"
|
||||||
|
**Story:** `.agent/user_stories/quadlet-jinja-import.md`
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Every new suffix dispatches to plain-text paragraph packing (`chunk_text`), and the fixture tree carries realistic quadlet + jinja files for the unit/integration/E2E layers.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `app/rag/chunker.py` —
|
||||||
|
- `_FORMAT_CHUNKERS`: add the ten entries (owner decision R1: plain-text packing — no TOML/Jinja-aware splitter):
|
||||||
|
```python
|
||||||
|
".container": chunk_text, ".network": chunk_text, ".volume": chunk_text,
|
||||||
|
".image": chunk_text, ".pod": chunk_text, ".kube": chunk_text,
|
||||||
|
".swap": chunk_text, ".os": chunk_text, ".endpoint": chunk_text,
|
||||||
|
".j2": chunk_text,
|
||||||
|
```
|
||||||
|
- update the `_FORMAT_CHUNKERS` comment (currently "A9, revised: md, markdown, txt, yaml, yml, json, py") and the module docstring's per-format list (add: "**container / network / volume / image / pod / kube / swap / os / endpoint / j2** (A9 revised 2026-08-27) — quadlet unit files (TOML) and Jinja templates; plain-text paragraph packing (`chunk_text`) — no format-specific splitter (owner decision).").
|
||||||
|
- `chunk_document`'s unknown-suffix fallback stays as-is (belt-and-braces).
|
||||||
|
2. **Fixture files** (under `tests/fixtures/docs/homelab/` — the tree the import E2E imports; hidden dirs are skipped, so no dot-dirs):
|
||||||
|
- `quadlet/compose.container` — realistic quadlet TOML (≥ ~1 500 chars to exercise sub-splitting past a single paragraph pack): `[Unit]` (Description/Wants), `[Service]` (Restart=always), `[Container]` (Image, Ports, Environment, Network, Volume mounts, Exec), comments. Include a unique sentinel token on its own line, e.g. `# RESE-QUADLET-SENTINEL-77aa`.
|
||||||
|
- `quadlet/lan.network` — small: `[Unit]` + `[Network]` (Driver=bridge, IPAMDriver, Subnets) + sentinel `# RESE-NETWORK-SENTINEL-11bb`.
|
||||||
|
- `quadlet/cache.volume` — small: `[Unit]` + `[Volume]` (Driver, Device) + sentinel `# RESE-VOLUME-SENTINEL-22cc`.
|
||||||
|
- `templates/deploy.j2` — a Jinja snippet with `{% for %}` / `{{ var }}` / `{# comment #}` constructs (realistic: an ansible-style service template) + sentinel `RESE-JINJA-SENTINEL-33dd` (no `#` prefix — it lives in a `{% set %}` line or a comment the importer keeps).
|
||||||
|
- keep the existing fixture files byte-identical (other E2E suites import this tree — `test_import_documents.py` asserts exact chunk counts: **run that suite's expectations check**: the tree grew by 4 files, so the phase-02 import E2E's document/chunk count assertions will change — update `tests/e2e/test_import_documents.py`'s count constants in this task, or fold that update into task 04's E2E work; whichever you choose, the full E2E regression pass in task 04 must be green. Prefer updating the constants here so task 03's integration test and task 04 share the same fixture state.)
|
||||||
|
3. `tests/unit/test_chunker.py` —
|
||||||
|
- dispatch: for **each** of the ten suffixes, `chunk_document(content, "x/<name>.<suffix>")` produces the same chunks as `chunk_text(content, …)` (parametrize over the suffix list);
|
||||||
|
- the `.container` fixture (read the file in the test, house pattern) chunks into ≥2 chunks, every chunk ≤ `HARD_MAX_CHARS`, and the sentinel token survives in some chunk;
|
||||||
|
- the `.j2` fixture chunks; Jinja braces are just text (no special handling — assert a `{{` line appears verbatim in a chunk);
|
||||||
|
- the unknown-suffix fallback is unchanged (a `.whatever` file still chunk-paragraphs).
|
||||||
|
4. `uv run pytest tests/unit/test_chunker.py -v` green; full unit suite green.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Unit: as above; coverage **>90%** on `app/` (chunker dispatch covered).
|
||||||
|
- No behavior change for the seven original formats (their chunker bindings are untouched).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] Ten dispatch entries; docstrings/comments cite the A9 revision; fixtures exist with their sentinels and the ≥1 200-char container file.
|
||||||
|
- [ ] `test_import_documents.py` count constants updated (or explicitly deferred to task 04 — state the choice in the task's completion note).
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Task 03 — Importer parity: walk, delta, prune, titles
|
||||||
|
|
||||||
|
**Phase:** `47_quadlet_jinja_import` · **Source:** `TODO.md:10–11` — "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"
|
||||||
|
**Story:** `.agent/user_stories/quadlet-jinja-import.md`
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Prove the new formats ride the existing import machinery unchanged: the default walk picks them up, delta detection re-imports on change, prune drops them on removal, and titles fall back to the file stem.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `tests/unit/test_importer.py` — extend (house style — the walk function `iter_importable_files` is pure and unit-tested against temp trees):
|
||||||
|
- a temp tree containing one file per new extension (all ten) + one unknown (`.xyz`) + one hidden-dir file (`.esphome/x.container`) + one exclusion (`node_modules/y.container`): the default-extensions walk returns exactly the ten new files — unknown/hidden/excluded filtered;
|
||||||
|
- the seven original extensions still walk (regression in the same test);
|
||||||
|
- title extraction: a `.container` file with no H1 gets the stem title (`extract_title` fallback — via the importer's title path, whatever the house test asserts titles through).
|
||||||
|
2. `tests/integration/` — a new `tests/integration/test_import_quadlet_jinja.py` (or extend `test_importer_e2e.py` if that file's harness fits better — choose and note):
|
||||||
|
- build a temp source dir with a `.container`, a `.volume`, and a `.j2` file (reuse the fixture files' content or small inline variants);
|
||||||
|
- run `import_sources([dir], fake_llm, prune=False)` with the house fake (`tests/fakes.py::FakeEmbedder` — it already implements `embed` + `chat`; give it an `embed_one` delegate if the import path calls one — check the `Embedder` protocol in `app/rag/importer.py` and satisfy exactly what it names):
|
||||||
|
- the three docs land in `documents` (source/path/title — stem titles) with non-zero `chunks` rows;
|
||||||
|
- **delta:** re-run with the `.j2` file's content changed → that doc `updated` (hash changed), the others `unchanged`;
|
||||||
|
- **prune:** delete the `.volume` file, re-run with `prune=True` → pruned count 1, the row gone, its chunks cascade-deleted.
|
||||||
|
- use the existing DB integration harness (the conftest app/db fixtures in `tests/conftest.py` — same pattern as `test_importer_e2e.py`).
|
||||||
|
3. `uv run pytest tests/unit/test_importer.py tests/integration/test_import_quadlet_jinja.py -v` green; full suite green (DB up for the integration part: `podman compose up -d db`).
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Unit + integration as above; coverage **>90%** on `app/` (the importer is unchanged code — the new coverage comes from exercising it with the new formats; if TOTAL dips from task 01's config growth, add asserts — but no `app/` code change is expected in this task).
|
||||||
|
- No `app/` code change expected: if a gap is found (e.g. the walk already accepts any dotted suffix and the config set was the only gate), record that in the task completion note — the tests then prove the gate's location.
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] Default walk indexes the ten new formats; hidden-dir/exclusion/unknown filtering unchanged; stem titles.
|
||||||
|
- [ ] Delta + prune parity for the new formats (integration).
|
||||||
|
- [ ] Full suite green at this checkpoint.
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Phase 48 — Nav rename: "Sources" → "RAG", "Git sources" → "Sources"
|
||||||
|
|
||||||
|
**Source:** owner request (chat, 2026-08-28) — "The 'Git sources' navbar item should be renamed to 'Sources' and the 'Sources' navbar item should be renamed to 'RAG'."
|
||||||
|
**Story:** `.agent/user_stories/nav-sources-rag-rename.md`
|
||||||
|
**Context:** the shared header (phase 19, `frontend/assets/header.js`) ships ONE nav on all six pages — `[Chat, #nav-sources "Sources" → /sources.html, #nav-git-sources "Git sources" → /git-sources.html, #nav-tuning "Tuning"]` — with the two admin-only links ship-hidden and revealed by id once `/api/whoami` says admin (phase 16 contract, phase 34 same-header-everywhere, phase 46 mobile hamburger). Both labels are plain `<a>` text in the six page files; `header.js` toggles only the `hidden` attribute and never reads the label, so the rename is markup-only.
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Relabel the two admin-only nav items across all six pages — the document-catalog link (`#nav-sources`) becomes **"RAG"** and the source-manager link (`#nav-git-sources`) becomes **"Sources"** — with ids, hrefs, order, the ship-hidden/reveal contract, and every other page control (viewer back button, Sync button, page titles/h1s) unchanged.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
- `34_consistent_navbar` / `19_shared_header` (complete) — the same full header block on all six pages and the `header.js` reveal-by-id contract this phase preserves.
|
||||||
|
- `35_git_sources_admin` (complete) — the `#nav-git-sources` link (and the `/git-sources.html` page) it labels.
|
||||||
|
- `46_mobile_hamburger_nav` (complete) — the `LINK_TEXTS` assertion in `test_mobile_hamburger_nav.py` that this phase updates.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
1. `01_nav_label_swap.md` — swap the two label texts in all six page files + the label mentions in `header.js`'s docstring/comments (no logic).
|
||||||
|
2. `02_existing_test_labels.md` — update the two test suites that assert the old label text; leave every other assertion (viewer back button, sync label, h1/title markers) untouched.
|
||||||
|
3. `03_story_e2e_regression_commit.md` — the story E2E (`test_nav_rename_sources.py`), the regression suites in isolation, ruff + pyright, the one `--no-gpg-sign` commit, and the phase-dir move.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Unit/integration: no new backend logic (frontend markup only). The existing no-CDN integration test (`tests/integration/test_api.py::test_html_pages_served_locally_no_cdn`) must still pass — the swap touches no tags or origins (the `/git-sources.html` "Git sources" marker keeps matching the page's unchanged `<h1>`).
|
||||||
|
- Coverage: frontend-only; the `app/` >90% gate is unaffected (unchanged).
|
||||||
|
- E2E (mandatory, A16): `tests/e2e/test_nav_rename_sources.py` — the story gate, run in isolation.
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] All six pages show **"RAG"** on `#nav-sources` (href `/sources.html`) and **"Sources"** on `#nav-git-sources` (href `/git-sources.html`); nav order and `is-active`/`aria-current` placement unchanged; no id/href/`hidden` change.
|
||||||
|
- [ ] Anonymous still sees neither link; the admin sees both (contract preserved, proven by the story E2E + `test_shared_header.py`).
|
||||||
|
- [ ] `uv run pytest` green (the two updated suites included); `uv run pytest --cov=app --cov-report=term-missing` TOTAL unchanged (no backend change).
|
||||||
|
- [ ] Regression E2E suites green in isolation: `test_nav_consistency.py`, `test_shared_header.py`, `test_mobile_hamburger_nav.py`, `test_git_sources_admin.py`, `test_header_consistency.py`, `test_sources_midstream_bug.py`, `test_smoke.py`.
|
||||||
|
- [ ] `uv run pytest tests/e2e/test_nav_rename_sources.py -v --no-cov` green in isolation.
|
||||||
|
- [ ] `uv run ruff check . && uv run pyright` clean (no Python behavior change, but run the gate).
|
||||||
|
- [ ] UI Structure Check (AGENTS.md rule 5): labeled links, landmarks/contrast/focus unchanged; no CDN (rule 6).
|
||||||
|
- [ ] One `--no-gpg-sign` commit staging only this phase's files; `.agent/phases/todo/48_nav_rename_sources/` moved to `.agent/phases/complete/`.
|
||||||
|
|
||||||
|
## Locked decisions
|
||||||
|
- **Label-only rename (owner request 2026-08-28)** — exactly the two nav item texts change. Element ids (`#nav-sources`, `#nav-git-sources`), hrefs, nav order, the ship-hidden/reveal contract, `header.js` behavior, and every other label (the document viewer's "Sources" back button, the "Sync sources" button, page `<title>`/`<h1>` — incl. "Knowledge base" and "Git sources") are OUT of scope. If the owner later wants the page titles/h1s to follow the nav labels, that is a follow-up phase, not this one.
|
||||||
|
- **A11 untouched** — vanilla HTML only, no CDN, no new packages, no new tags.
|
||||||
|
- **A10 untouched** — no endpoint, auth, or `header.js` logic change; the rename rides the existing reveal-by-id path.
|
||||||
|
- **No schema / migration** — purely a markup + test-label change.
|
||||||
|
- **A16 / A17 honoured** — one new story E2E suite + one atomic `--no-gpg-sign` commit.
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Task 01 — Swap the two nav label texts on all six pages
|
||||||
|
|
||||||
|
**Phase:** `48_nav_rename_sources` · **Story:** `.agent/user_stories/nav-sources-rag-rename.md`
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Rename the nav items themselves: `#nav-sources` ("Sources" → **"RAG"**) and `#nav-git-sources` ("Git sources" → **"Sources"") in the shared header markup of all six pages, plus the label mentions in `header.js`'s doc comments. Markup only — no ids, hrefs, classes, logic, or other text changes.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
On **each** of the six page files — `frontend/index.html`, `frontend/sources.html`, `frontend/git-sources.html`, `frontend/tuning.html`, `frontend/document.html`, `frontend/login.html` — make exactly two text swaps inside `<nav class="app-nav" aria-label="Primary">`:
|
||||||
|
|
||||||
|
1. The catalog link — change the visible text only:
|
||||||
|
```html
|
||||||
|
<a href="/sources.html" class="nav-link" id="nav-sources" hidden>Sources</a>
|
||||||
|
```
|
||||||
|
becomes
|
||||||
|
```html
|
||||||
|
<a href="/sources.html" class="nav-link" id="nav-sources" hidden>RAG</a>
|
||||||
|
```
|
||||||
|
(On `sources.html` the link carries `class="nav-link is-active" aria-current="page"` — keep those attributes exactly as they are; swap only `Sources` → `RAG`.)
|
||||||
|
|
||||||
|
2. The manager link — change the visible text only:
|
||||||
|
```html
|
||||||
|
<a href="/git-sources.html" class="nav-link" id="nav-git-sources" hidden>Git sources</a>
|
||||||
|
```
|
||||||
|
becomes
|
||||||
|
```html
|
||||||
|
<a href="/git-sources.html" class="nav-link" id="nav-git-sources" hidden>Sources</a>
|
||||||
|
```
|
||||||
|
(On `git-sources.html` the link carries `class="nav-link is-active" aria-current="page"` — keep them; swap only `Git sources` → `Sources`.)
|
||||||
|
|
||||||
|
3. `frontend/assets/header.js` — **comments only** (the reveal logic is id-based and must stay byte-identical): in the module docstring (the nav-link inventory: `"Sources" (#nav-sources, phase 19), "Git sources" (#nav-git-sources, phase 35)`) and in the inline comment above the `navGitSources` reveal, update the quoted labels to the new ones (`"RAG" (#nav-sources)`, `"Sources" (#nav-git-sources)`). No executable line of `header.js` changes.
|
||||||
|
|
||||||
|
Rules for all edits:
|
||||||
|
- Do **not** touch element ids, `href`s, `hidden` defaults, `class` attributes, `aria-current`, indentation, or any other text on the page (page `<title>`, `<h1>`, the viewer's "Sources" back button, the "Sync sources" button label — all stay).
|
||||||
|
- Do **not** add `is-active` anywhere or move the links — the physical nav order stays Chat, `#nav-sources`, `#nav-git-sources`, `#nav-tuning` (the labels just swap, so it now *reads* Chat, RAG, Sources, Tuning).
|
||||||
|
- The phase-16/19/35 ship-hidden contract is untouched: the links still ship `hidden` and `header.js` still reveals them by id for admins.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- No new unit/integration logic (frontend markup only).
|
||||||
|
- Coverage: frontend-only; `app/` coverage unaffected.
|
||||||
|
- The no-CDN integration test (`tests/integration/test_api.py::test_html_pages_served_locally_no_cdn`) still passes — no tags or origins change; its `("/git-sources.html", "Git sources")` marker still matches that page's unchanged `<h1>Git sources</h1>`.
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] All six pages: `#nav-sources` renders text **RAG** and `#nav-git-sources` renders text **Sources**; ids/hrefs/`hidden`/`is-active` unchanged (a quick `grep -c 'id="nav-sources" hidden>RAG<'` per page returns 1, and `grep -c 'id="nav-git-sources" .*>Sources<'` returns 1).
|
||||||
|
- [ ] `git diff --stat` shows only the six HTML files + `frontend/assets/header.js` (comment-only) changed.
|
||||||
|
- [ ] `uv run pytest tests/unit tests/integration -q` green; `uv run ruff check . && uv run pyright` clean.
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Task 02 — Update the tests that assert the old nav label text
|
||||||
|
|
||||||
|
**Phase:** `48_nav_rename_sources` · **Story:** `.agent/user_stories/nav-sources-rag-rename.md`
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Keep the existing suites honest after the rename: update the (few) assertions and label-describing comments that reference the OLD label texts, without touching any assertion that is about a different control.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
Only these tests assert the old label text (verified by grepping `tests/` for the strings) — update exactly these:
|
||||||
|
|
||||||
|
1. `tests/e2e/test_git_sources_admin.py` — `test_admin_nav_link_on_all_five_pages_and_click_navigates`:
|
||||||
|
- the comment `pointing at the git sources page, labeled "Git sources" — and it` → `labeled "Sources"`;
|
||||||
|
- `expect(link).to_have_text("Git sources")` → `expect(link).to_have_text("Sources")`.
|
||||||
|
- This suite has no assertion on `#nav-sources`'s TEXT (it is id-based everywhere) — do not add one here; the story E2E (task 03) owns the label checks.
|
||||||
|
2. `tests/e2e/test_mobile_hamburger_nav.py` —
|
||||||
|
- `LINK_TEXTS = ("Chat", "Sources", "Git sources", "Tuning")` → `LINK_TEXTS = ("Chat", "RAG", "Sources", "Tuning")` (DOM order unchanged — only the labels swapped);
|
||||||
|
- the comment near `test_…` that reads `Chat / Sources / Git sources / Tuning — i.e. the whoami reveal` → `Chat / RAG / Sources / Tuning …`.
|
||||||
|
3. **Comments-only drift fixes** (no assertion changes) where a suite's docstring/comment quotes the old labels as the link's identity: `tests/e2e/test_shared_header.py` (module docstring line `"the 'Sources' nav link (#nav-sources)"` → `'RAG'`), `tests/e2e/test_nav_consistency.py` (the comment `the Git sources link joined in phase 35` → mention the relabeled link), `tests/e2e/test_header_consistency.py` if it quotes the labels. Where a comment also explains the phase-35 origin, keep that history and only fix the quoted label.
|
||||||
|
|
||||||
|
**Explicitly DO NOT touch** (different controls / different text — they stay green and must stay):
|
||||||
|
- `tests/e2e/test_nav_consistency.py` lines asserting the document viewer's **back button** `span` text "Sources" / "Chat" — that is the viewer's back link (`document.js`), not the nav.
|
||||||
|
- `tests/e2e/test_document_back_navigation.py` — same back button.
|
||||||
|
- `tests/unit/test_document_viewer.py` — the `'Sources' in js` check on the viewer's back-link label.
|
||||||
|
- `tests/e2e/test_nav_consistency.py` `expect(page.locator("#sync-label")).to_have_text("Sync sources")` — the Sync button label is unchanged.
|
||||||
|
- `tests/integration/test_api.py::test_html_pages_served_locally_no_cdn` — the `("/git-sources.html", "Git sources")` marker matches the page's unchanged `<h1>`; leave it.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- No new logic — assertion-text updates only.
|
||||||
|
- Coverage: frontend/test-only; `app/` coverage unaffected.
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] `grep -rn '"Git sources"\|Git sources' tests/e2e/*.py` shows no remaining assertion expecting the old nav label (only phase-history comments, if any, are acceptable — and none should quote it as the current label).
|
||||||
|
- [ ] `uv run pytest tests/unit tests/integration -q` green; `uv run ruff check . && uv run pyright` clean.
|
||||||
|
- [ ] The two updated E2E suites pass in isolation: `uv run pytest tests/e2e/test_git_sources_admin.py -v --no-cov` and `uv run pytest tests/e2e/test_mobile_hamburger_nav.py -v --no-cov` (DB up).
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Task 03 — Story E2E, regression suites, commit, phase move
|
||||||
|
|
||||||
|
**Phase:** `48_nav_rename_sources` · **Story:** `.agent/user_stories/nav-sources-rag-rename.md`
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Prove the renamed nav end to end with the story's dedicated Playwright suite (run in isolation per A16), confirm the surrounding header suites stay green, and land the one atomic `--no-gpg-sign` commit.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. **Create `tests/e2e/test_nav_rename_sources.py`** (one story, one file — follow the conventions of `test_git_sources_admin.py` / `test_nav_consistency.py`: the shared `app_url` / `db_ready` fixtures, the `login(page, app_url, next=…)` helper, desktop viewport 1280×800, `expect` sync-API assertions, a module docstring stating the story + the rename). The six pages under test are `/` (chat), `/sources.html`, `/git-sources.html`, `/tuning.html`, `/login.html`, and `/document.html` (viewer — seed one document row first, reusing the seeding pattern `test_nav_consistency.py` uses for its viewer pass, and wait for `#doc-title` to settle as that file does).
|
||||||
|
|
||||||
|
Test cases:
|
||||||
|
- **admin_labels_on_all_six_pages** — signed-in admin visits each of the six pages; on each: `#nav-sources` is visible, has text exactly `RAG` and href `/sources.html`; `#nav-git-sources` is visible, has text exactly `Sources` and href `/git-sources.html`; the nav DOM order is Chat, RAG, Sources, Tuning (assert the `.app-nav a` text sequence); the current page's link carries `is-active`/`aria-current="page"` (and only it does).
|
||||||
|
- **click_navigates_with_marker** — from the chat page: click `#nav-sources` (label "RAG") → lands on `/sources.html` where `#nav-sources` is the active link; from the chat page: click `#nav-git-sources` (label "Sources") → lands on `/git-sources.html` where `#nav-git-sources` is the active link.
|
||||||
|
- **anonymous_sees_neither** — anonymous (no login): on `/` and `/login.html` both `#nav-sources` and `#nav-git-sources` are present in the DOM but hidden (ship-hidden contract unchanged), `#sign-in-link` visible.
|
||||||
|
- **untouched_controls_stay** — the rename did not leak: on `/sources.html` the Sync button still reads "Sync sources" (`#sync-label`), and on the settled viewer page the back button's span still reads "Sources" (the viewer back link is a different control — regression guard for task 02's do-not-touch list).
|
||||||
|
2. **Run the regression suites in isolation** (DB up, `--no-cov`), fixing nothing unless a test genuinely asserted a renamed nav label (if one does, update it as in task 02 and note it in the commit message):
|
||||||
|
`test_nav_consistency.py`, `test_shared_header.py`, `test_mobile_hamburger_nav.py`, `test_git_sources_admin.py`, `test_header_consistency.py`, `test_sources_midstream_bug.py`, `test_smoke.py`, `test_tuning_nav_link.py`.
|
||||||
|
3. **Full gates**: `uv run pytest` (unit + integration) green; `uv run pytest --cov=app --cov-report=term-missing` with TOTAL unchanged from the pre-phase baseline (frontend-only change); `uv run ruff check . && uv run pyright` clean.
|
||||||
|
4. **UI Structure Check (AGENTS.md rule 5)** — the relabeled links are still labeled `<a>`s inside the semantic `<nav aria-label="Primary">`; landmarks/contrast/focus-visible untouched (label text only); no CDN (rule 6 — the no-CDN integration test covers it).
|
||||||
|
5. **Commit** — one atomic commit staging exactly this phase's files (the six HTML files, `frontend/assets/header.js`, `tests/e2e/test_git_sources_admin.py`, `tests/e2e/test_mobile_hamburger_nav.py`, the comment-only test files from task 02, and the new `tests/e2e/test_nav_rename_sources.py`):
|
||||||
|
`feat(ui): rename nav items — "Sources" becomes "RAG", "Git sources" becomes "Sources"` with a body citing the owner request (2026-08-28) + phase 48. `git commit --no-gpg-sign` (repo also has `commit.gpgsign=false`).
|
||||||
|
6. **Move the phase directory**: `mv .agent/phases/todo/48_nav_rename_sources .agent/phases/complete/` and `git add -f .agent/phases/complete/48_nav_rename_sources` into the SAME commit as step 5 (`.agent/` is gitignored by design — AGENTS.md rule 8), plus the story file `git add -f .agent/user_stories/nav-sources-rag-rename.md`.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Story E2E: `tests/e2e/test_nav_rename_sources.py` green **in isolation**.
|
||||||
|
- Coverage: frontend-only; `app/` >90% gate unaffected (TOTAL unchanged).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] `uv run pytest tests/e2e/test_nav_rename_sources.py -v --no-cov` green in isolation (DB up).
|
||||||
|
- [ ] All eight regression suites green in isolation (commands above).
|
||||||
|
- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` TOTAL unchanged; `uv run ruff check . && uv run pyright` clean.
|
||||||
|
- [ ] One `--no-gpg-sign` commit containing the code/test files, the story file, and the moved phase directory; `.agent/phases/todo/` no longer lists 48.
|
||||||
|
- [ ] No behavior change in completed phases (the suites above are the proof).
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# Phase 49 — Archive upload sources (tarball/zipfile → unpack → scan)
|
||||||
|
|
||||||
|
**Source:** owner request (chat, 2026-08-28) — "The git sources page should remove local directory and should instead accept a tarball or zipfile upload which it will unpack and scan. Note that reuploading the same tarball should not create a new folder, but should unpack and overwrite the previously unpacked content." (Design confirmed by the owner in the same conversation.)
|
||||||
|
**Story:** `.agent/user_stories/archive-upload-sources.md`
|
||||||
|
**Context:** `35_git_sources_admin` (complete) — the `git_sources` table (`id, url, kind, path, added_at`), the admin-only `/api/git-sources` router, and the `/git-sources.html` manager page; `38_local_directory_sources` (complete) — `kind='local'` rows the Sync pipeline and `import_docs` walk directly, plus the page's "Add a local directory" form this phase removes; `32_admin_sync_button` + `41_sync_fail_fast_models` (complete) — the in-process pipeline parts this phase reuses: `check_models` fail-fast, `import_sources(sources, llm, prune=True)` (source name = folder basename, per-file transactions, per-source prune), `regenerate_overview`, and the sync-detail count keys (`files, added, updated, unchanged, pruned, errors, chunks, overview`); `16_admin_auth` (complete) — the `require_admin` router dependency the new route inherits.
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Replace the local-directory form on the Sources page with an **archive upload** form: `POST /api/git-sources/upload` accepts `.tar`/`.tar.gz`/`.tgz`/`.zip`, unpacks it **safely** into `BOR_UPLOAD_DIR/<name>/` (name = filename minus the archive suffix), atomically swaps it in when the name already exists, upserts the `git_sources` row (`kind=local`, no duplicates), and **scans it** — single-source `import_sources(prune=True)` + overview refresh — returning the sync-style counts. Re-uploading the same filename overwrites the previous content in place: one folder, one row, dropped files pruned from the KB.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
- `48_nav_rename_sources` (todo — runs first) — sequential only: it relabels the nav in the same `git-sources.html` this phase edits (keeps the diffs clean).
|
||||||
|
- `35_git_sources_admin` (complete) — the table/API/page this phase extends; the `require_admin` router; the `IntegrityError → 409` backstop pattern (`_commit_new`).
|
||||||
|
- `38_local_directory_sources` (complete) — the `kind=local` rows uploads register; the local form removed; the phase-38 story E2E rewritten in this phase.
|
||||||
|
- `32_admin_sync_button` / `41_sync_fail_fast_models` (complete) — `check_models` + `import_sources` + `regenerate_overview` + the count-key contract the upload response mirrors.
|
||||||
|
- `16_admin_auth` (complete) — admin-only surface (A10 revision).
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
1. `01_settings_and_unpack_utility.md` — `BOR_UPLOAD_DIR` + `BOR_UPLOAD_MAX_MB` settings, `.env.example`, and the new `app/rag/archive_upload.py` (name derivation, safe tar/zip unpack with traversal/symlink/size guards, atomic swap-in) + unit tests.
|
||||||
|
2. `02_upload_api.md` — `python-multipart` dependency + `POST /api/git-sources/upload` (stream-with-cap, one-at-a-time 409, upsert row, fail-fast models, single-source scan, sync-style 200 body, log line) + integration tests incl. re-upload/overwrite and no-partial-state.
|
||||||
|
3. `03_admin_page_upload.md` — the page: local form out, upload form in (§7.4 lifecycle, result line, hint/caption) + the phase-38 story E2E rewritten API-driven.
|
||||||
|
4. `04_story_e2e_docs_commit.md` — the story E2E (`test_archive_upload_sources.py`), README, regression suites in isolation, the one `--no-gpg-sign` commit, and the phase-dir move.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Unit: `tests/unit/test_archive_upload.py` — the name-derivation matrix (suffix stripping incl. double `.tar.gz`; empty/`..`/separator/control-char rejection); safe unpack (valid zip + tar; zip-slip `../`; absolute member; symlink + hardlink escape; device member; extracted-byte cap); `swap_in` (fresh, replace-existing with full content replacement, failure leaves the previous folder intact).
|
||||||
|
- Integration: `tests/integration/test_git_sources_upload.py` — anonymous 403 on the new route; 422 (bad extension, unsafe/empty name, traversal archive, corrupt archive); 413 (compressed cap, via the settings-override pattern of `test_git_sources_api.py`); 409 (second upload while the first is in flight); 200 happy path (real temp tarball, counts correct, row `kind=local` under `upload_dir`, docs in the KB); **re-upload same name** (one row, old folder content fully replaced, dropped file pruned, new file indexed); **failed re-upload leaves the previous folder + row + KB untouched**. The existing `test_git_sources_api.py` / `test_sync_api.py` / `test_import_docs_git.py` suites stay green through the change (the `kind=local` POST contract is untouched).
|
||||||
|
- Coverage: **>90%** on `app/` — the new module + endpoint fully covered.
|
||||||
|
- E2E (mandatory, A16): `tests/e2e/test_archive_upload_sources.py`, run in isolation.
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` >90%.
|
||||||
|
- [ ] Uploading `homelab.tar.gz` via the page: unpacked under `BOR_UPLOAD_DIR/homelab/`, scanned (result line shows the counts), one list row (Local badge, name `homelab`), documents visible on `/sources.html`.
|
||||||
|
- [ ] Re-uploading `homelab.tar.gz` (modified): still exactly one folder and one row; dropped files pruned from the KB; added/changed files indexed.
|
||||||
|
- [ ] Non-archive file → inline 422; oversized → 413; zip-slip/tar-slip archive → 422 with the previous folder/row/KB untouched; second concurrent upload → 409.
|
||||||
|
- [ ] `#local-source-form` is gone from the page; the phase-38 story E2E green in isolation, API-driven; anonymous still gets the gate.
|
||||||
|
- [ ] `uv run pytest tests/e2e/test_archive_upload_sources.py -v --no-cov` green in isolation (DB up).
|
||||||
|
- [ ] Regression E2E suites green in isolation: `test_git_sources_admin.py`, `test_local_directory_sources.py`, `test_sync_button.py`, `test_import_documents.py`, `test_nav_rename_sources.py` (when 48 is complete), `test_smoke.py`.
|
||||||
|
- [ ] README + `.env.example` document the upload (formats, naming, in-place replace, both new settings); ruff + pyright clean.
|
||||||
|
- [ ] UI Structure Check (AGENTS.md rule 5) + no CDN (rule 6).
|
||||||
|
- [ ] One `--no-gpg-sign` commit; phase directory moved to `.agent/phases/complete/`.
|
||||||
|
|
||||||
|
## Locked decisions (owner permission 2026-08-28 — the confirmed design)
|
||||||
|
- **Formats:** `.tar`, `.tar.gz`, `.tgz`, `.zip` only (422 naming the accepted set otherwise).
|
||||||
|
- **Identity & in-place replace:** the source name is the uploaded filename minus the archive suffix (`homelab.tar.gz` → `homelab`, case-sensitive — Linux FS). The name determines the folder under `BOR_UPLOAD_DIR`; re-uploading the same name unpacks to a temp sibling and **renames it over the existing folder** (no missing window; a failed upload never touches the existing folder, row, or KB). **No second folder, no second row** — the `git_sources` row is upserted by `path` (`kind='local'`, reusing the phase-38 discriminator — **no migration, no new table**; A13 honoured).
|
||||||
|
- **New settings:** `BOR_UPLOAD_DIR` (default `~/bor-sources/uploads` — deliberately separate from the git checkouts in `BOR_SOURCES_DIR`) and `BOR_UPLOAD_MAX_MB` (default **512**) capping BOTH the compressed upload and the total extracted bytes (zip-bomb guard).
|
||||||
|
- **The scan is synchronous in the upload request** (owner-confirmed): fail-fast `check_models` (phase 41) → `import_sources([folder], llm, prune=True)` (single source) → `regenerate_overview` when the KB changed → **200** with the sync-detail count keys so the page renders the same "N added · N pruned" line. One upload at a time — 409 while a run is in flight (the phase-32 pattern).
|
||||||
|
- **Unpack safety:** absolute member paths, `..` traversal, symlink/hardlink targets escaping the unpack folder, and device/FIFO members are rejected (422); extracted bytes are counted against the cap while writing.
|
||||||
|
- **Page:** the "Add a local directory" form is **removed**; the `POST /api/git-sources` `kind=local` **API contract is unchanged** (admin can still register a plain directory via the API — no regression; existing Local rows still list/remove, and the Sync button + `import_docs` keep walking them).
|
||||||
|
- **No auto-unwrap** of a single top-level folder — files land in the KB exactly as packed (documented in the hint/README).
|
||||||
|
- **`python-multipart`** is added to the dependencies — FastAPI's required multipart parser for file uploads (an A2 FastAPI implementation detail, not a new architectural anchor; recorded here per AGENTS.md rule 3).
|
||||||
|
- **Boundaries (deliberately out of scope):** page `<title>`/`<h1>` rename (flagged in phase 48); background/202 upload runs (synchronous locked above); deleting the uploaded archive bytes (temp file removed after unpack — only the unpacked content is kept); cross-kind source-name collisions with a git repo of the same folder name (pre-existing importer behavior, unchanged); coordinating an in-flight full Sync with an upload (accepted edge — per-file transactions + per-source-name prune keep the KB consistent).
|
||||||
|
- **A10 / A11 / A16 / A17 honoured** — admin-only surface (no new session state), vanilla frontend (no CDN), one story E2E, one atomic `--no-gpg-sign` commit.
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Task 01 — Settings + safe archive unpack utility
|
||||||
|
|
||||||
|
**Phase:** `49_archive_upload_sources` · **Story:** `.agent/user_stories/archive-upload-sources.md`
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Add the two upload settings and the reusable, unit-tested unpack machinery in a new module `app/rag/archive_upload.py`: archive-name derivation, safe tar/zip extraction (traversal/symlink/device/size guards), and the atomic swap-in that makes re-uploads replace in place without ever exposing a missing or partial folder.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `app/config.py` — add to `Settings` (both env-overridable, documented like `sources_dir`):
|
||||||
|
- `upload_dir: str = "~/bor-sources/uploads"` (→ `BOR_UPLOAD_DIR`) — where uploaded archives are unpacked (one subdirectory per source name). Kept **separate** from `sources_dir` (git checkouts).
|
||||||
|
- `upload_max_mb: int = 512` (→ `BOR_UPLOAD_MAX_MB`) — caps both the compressed upload and the total extracted bytes (zip-bomb guard). Add a validator rejecting `<= 0` (fail loud at startup, the `agent_max_rounds` pattern).
|
||||||
|
2. `.env.example` — document both settings next to `BOR_SOURCES_DIR` (default, meaning, the cap's dual role).
|
||||||
|
3. `app/rag/archive_upload.py` (new module) — pure file-system logic, no FastAPI imports (the API layer maps its exceptions to status codes):
|
||||||
|
- `class ArchiveUploadError(Exception)` — carries a user-safe message (no paths beyond the owner's own upload dir, never secrets).
|
||||||
|
- `ARCHIVE_SUFFIXES: tuple[str, ...] = (".tar.gz", ".tgz", ".zip", ".tar")` (longest-first — `.tar.gz` must strip before `.tar` would).
|
||||||
|
- `archive_source_name(filename: str) -> str` — take the basename (defensively strip any `/` or `\` a client could send), strip ONE trailing archive suffix from `ARCHIVE_SUFFIXES`; the result must be non-empty and not `.`/`..`, contain no path separators or control characters, or raise `ArchiveUploadError` (the API maps to 422). `homelab.tar.gz` → `homelab`; `notes.tgz` → `notes`; `a.zip` → `a`; `x.tar` → `x`; bare `tar.gz` → error (empty stem).
|
||||||
|
- `unpack_archive(archive: Path, target_dir: Path, max_extract_bytes: int) -> None` — extract into `target_dir` (the caller guarantees it does not exist yet and creates it empty):
|
||||||
|
- **zip** (`zipfile`): per member — reject absolute names and any name with a `..` part; resolve the final path and require it to stay within `target_dir`; reject symlink entries (mode bits) and non-file/non-dir entries; write file members while counting bytes — exceeding `max_extract_bytes` raises `ArchiveUploadError` (name the cap, not the archive content).
|
||||||
|
- **tar** (`tarfile.open(mode="r:*")` — handles gz/bz2/xz transparently): per member — the same name/containment checks; reject char/block devices and FIFOs; for symlinks/hardlinks, resolve the link target against the member's directory and reject any target that escapes `target_dir`; write regular files with the byte-counted cap.
|
||||||
|
- Clean up partial state: on any error, remove `target_dir` (shutil.rmtree, ignore missing) so no half-unpacked tree survives.
|
||||||
|
- `swap_in(new_dir: Path, final_dir: Path) -> None` — make `new_dir` become `final_dir` with **no missing window**: if `final_dir` exists, rename it to a same-filesystem sibling `final_dir.with_name(final_dir.name + ".old-" + uuid4().hex)`, rename `new_dir` → `final_dir`, then delete the `.old-` sibling; if it does not exist, just rename. On a rename failure, best-effort restore (`.old-` back, `new_dir` cleaned) and re-raise as `ArchiveUploadError`.
|
||||||
|
4. `tests/unit/test_archive_upload.py` (new) — full coverage of the module:
|
||||||
|
- name derivation: the matrix above incl. `upper.TAR.GZ` (case-sensitive stems preserved — `upper`), `a.tar.gz` double-strip, `..tar.gz` / `..` / `a/b.tar` / `a\tb.zip` / empty-stem rejections.
|
||||||
|
- unpack: a valid zip (nested dir + file) and a valid tar.gz extract byte-identically; zip-slip (`../evil.txt`), absolute member (`/etc/x`), tar symlink escaping (`ln -s /etc/passwd link`), tar hardlink escaping, a char-device member, and the extracted-cap (e.g. cap=10 bytes, 20-byte file) each raise `ArchiveUploadError` AND leave no partial `target_dir` behind.
|
||||||
|
- `swap_in`: fresh (final absent), replace (final's previous content fully gone, new content complete — no interleave), and restore-on-failure (monkeypatch `os.rename` to fail on the second rename → previous folder intact, new dir cleaned, `ArchiveUploadError` raised).
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Unit: `uv run pytest tests/unit/test_archive_upload.py -v` green; the module is fully covered (the >90% gate applies to `app/` — this new module must not drag the TOTAL down; aim for ~100% here).
|
||||||
|
- Coverage: `uv run pytest --cov=app --cov-report=term-missing` — no drop vs. baseline (no existing behavior touched).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] `Settings` exposes `upload_dir` / `upload_max_mb` (defaults as above); `BOR_UPLOAD_MAX_MB=0` or negative fails startup with the validator message.
|
||||||
|
- [ ] `.env.example` documents both settings.
|
||||||
|
- [ ] `app/rag/archive_upload.py` exists with `ArchiveUploadError`, `ARCHIVE_SUFFIXES`, `archive_source_name`, `unpack_archive`, `swap_in`; no FastAPI/DB imports in the module.
|
||||||
|
- [ ] `uv run pytest tests/unit/test_archive_upload.py -v` green; full `uv run pytest tests/unit tests/integration -q` green; `uv run ruff check . && uv run pyright` clean.
|
||||||
|
- [ ] No behavior change in completed phases (no existing file edited beyond config + .env.example).
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Task 02 — `POST /api/git-sources/upload` endpoint
|
||||||
|
|
||||||
|
**Phase:** `49_archive_upload_sources` · **Story:** `.agent/user_stories/archive-upload-sources.md`
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Ship the admin-only upload endpoint: stream the archive with the size cap, unpack it safely to a temp sibling, swap it in atomically, upsert the `git_sources` row (no duplicates), fail-fast check the models, scan the single source (`prune=True`) with the overview refresh, and answer 200 with the sync-style counts. One upload at a time (409).
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `pyproject.toml` — add `python-multipart` to the main `dependencies` list (FastAPI's required multipart parser; the owner-confirmed implementation detail, phase locked decisions), then `uv lock`.
|
||||||
|
2. `app/api/git_sources.py` — extend the existing admin-only router (the `require_admin` dependency covers the new route automatically):
|
||||||
|
- `@router.post("/upload", response_model=UploadOut)` (new `UploadOut` schema in `app/schemas.py`: `source: str` + the sync-detail keys `files, added, updated, unchanged, pruned, errors, chunks: int` and `overview: bool` — same names as `_run_sync`'s `detail` so the page reuses its result-line shape).
|
||||||
|
- Handler (`async def upload_archive(file: UploadFile = File(...)) -> UploadOut`):
|
||||||
|
1. **Name/format gate** — `archive_source_name(file.filename or "")`: `ArchiveUploadError` → **422** with its message (naming the accepted formats when the extension is the problem).
|
||||||
|
2. **One at a time** — module-level `asyncio.Lock` (or a `bool` flag, the phase-32 `_task` spirit): already in flight → **409** `"an upload is already in progress"`.
|
||||||
|
3. **Stream with cap** — read the upload in 1 MiB chunks into `upload_dir / f".{name}.{uuid4().hex}.upload"`; total bytes > `upload_max_mb * 1024 * 1024` → delete the temp file, **413** naming the cap. (Compute `upload_dir` once: `Path(get_settings().upload_dir).expanduser()` — create it with `mkdir(parents=True, exist_ok=True)`.)
|
||||||
|
4. **Unpack to temp sibling** — `unpack_archive(archive, upload_dir / f".{name}.{uuid4().hex}.unpack", same cap)`; `ArchiveUploadError` → delete both temps, **422** (the message is already user-safe). A **completely empty archive** (zero entries) is **422** `"the archive contains no files"`. An archive with only non-A9 files is a **valid replacement**: the swap happens, the scan indexes nothing, and prune removes that source's docs — that is the intended "replace" semantics, do not reject it.
|
||||||
|
5. **Swap in** — `swap_in(temp_unpack, upload_dir / name)`; `ArchiveUploadError` → clean temps, **422** (the previous folder/row/KB are untouched — assert this in the tests).
|
||||||
|
6. **Upsert the row** — `path = str(upload_dir / name)`: a `GitSource` row with `path == path` already exists → leave it (no new row, `added_at` preserved); otherwise create `GitSource(url=path, kind="local", path=path)` (the `url` column is the NOT-NULL location column, phase-38 convention) via the shared `_commit_new` IntegrityError → 409 backstop.
|
||||||
|
7. **Fail-fast models** (phase 41) — `llm = LLMClient()`; `await check_models(llm)`; failure → **503** with the sanitized model-unavailable message (clean up nothing else — the folder/row are already committed, and the next sync/re-upload retries idempotently).
|
||||||
|
8. **Scan** — `summary = await import_sources([upload_dir / name], llm, prune=True)`; `overview = await regenerate_overview(llm) if summary.added + summary.updated > 0 else False`.
|
||||||
|
9. **Log** (PLAN §9, AGENTS.md rule 10) — one INFO line: `upload: name=… file=… bytes_in=… files=… added=… updated=… unchanged=… pruned=… errors=… overview=… total_ms=…`.
|
||||||
|
10. **Respond 200** with `UploadOut(source=name, …counts…, overview=overview)`.
|
||||||
|
- Keep every existing route byte-identical (the `kind=local` POST contract is untouched — the page just stops offering it).
|
||||||
|
3. `tests/integration/test_git_sources_upload.py` (new) — real Postgres + TestClient, following `tests/integration/test_git_sources_api.py`'s conventions (`clean_git_sources`-style TRUNCATE autouse fixture, the monkeypatched `get_settings` pattern to point `upload_dir` at a tmp dir and shrink `upload_max_mb`, the shared `client` / `admin_client` / `db` fixtures). Build real archives in-test with `tarfile`/`zipfile` over tmp fixture files (two `.md` sentinels). Cases:
|
||||||
|
- anonymous `client` → 403 on `/api/git-sources/upload` (same body as the rest of the router).
|
||||||
|
- `admin_client` + `data={"file": ("notes.txt", b"…", "text/plain")}` → **422** (accepted formats named); `("…", …)` with a `..`/empty stem → 422.
|
||||||
|
- oversized (shrink `upload_max_mb` via the settings override, upload a bigger file) → **413**, temp file cleaned up (the upload dir holds no stray `.` files).
|
||||||
|
- zip-slip archive (a member named `../evil.txt`) and a tar-slip symlink archive → **422**; then a prior good upload's folder + row + docs are **untouched** (the no-partial-state locked decision, asserted explicitly).
|
||||||
|
- happy path: `homelab.tar.gz` (2 md files) → **200**, `source="homelab"`, `added=2`, a `git_sources` row exists (`kind=local`, `path` under the tmp `upload_dir`), the unpacked folder exists, `GET /api/docs` lists both files under source `homelab`.
|
||||||
|
- **re-upload, same name, modified archive** (drop one file, add one, change one) → 200; `git_sources` still has exactly **one** row for that path (`added_at` unchanged); the folder contains only the new archive's files; the KB: dropped file **pruned** (pruned ≥ 1), new file added, changed file updated.
|
||||||
|
- corrupt archive (truncated zip bytes) → 422, previous state intact.
|
||||||
|
- 409: hold the in-flight flag (test seam: a module-level `upload_in_progress()` helper or the lock object exposed for tests — pick the smallest seam) → second request → **409**.
|
||||||
|
- the existing `test_git_sources_api.py`, `test_sync_api.py`, `test_import_docs_git.py` suites stay green (run them).
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Integration: all cases above green; the endpoint's every branch (422/413/409/503/200 + both upsert branches + the empty-archive 422) is covered.
|
||||||
|
- Coverage: **>90%** on `app/` (the new handler + schema fully exercised).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] `python-multipart` in `pyproject.toml` `dependencies` + lock file updated; `uv sync` clean.
|
||||||
|
- [ ] `POST /api/git-sources/upload` implements steps 1–10; `git diff app/api/git_sources.py` shows no change to the existing GET/POST/DELETE handlers' behavior.
|
||||||
|
- [ ] `uv run pytest tests/integration/test_git_sources_upload.py -v` green (DB up); `uv run pytest tests/unit tests/integration -q` green; `uv run pytest --cov=app --cov-report=term-missing` >90%.
|
||||||
|
- [ ] `uv run ruff check . && uv run pyright` clean.
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# Task 03 — Sources page: local form out, upload form in (+ phase-38 E2E rewrite)
|
||||||
|
|
||||||
|
**Phase:** `49_archive_upload_sources` · **Story:** `.agent/user_stories/archive-upload-sources.md`
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Rework the manager UI: remove the "Add a local directory" form and replace it with the labeled archive upload form (never-stale button, inline error, result line), update the hint/caption, and — because the form it drives is gone — rewrite the phase-38 story E2E to add local sources via the API instead.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `frontend/git-sources.html`:
|
||||||
|
- **Remove** the whole `#local-source-form` block (the phase-38 form: label, `#local-source-path` input, `#local-source-add` button, `#local-source-error` — including its phase-38 comment).
|
||||||
|
- **Add**, in its place (same `.git-source-error` / never-stale visual language as the git form):
|
||||||
|
```html
|
||||||
|
<!-- Phase 49 (owner permission 2026-08-28): the archive upload form
|
||||||
|
replaces the phase-38 local-directory form — an uploaded
|
||||||
|
.tar/.tar.gz/.tgz/.zip is unpacked under BOR_UPLOAD_DIR and
|
||||||
|
scanned immediately; the same filename replaces the source in
|
||||||
|
place (no new folder, no duplicate row). -->
|
||||||
|
<form id="archive-upload-form">
|
||||||
|
<label for="archive-upload-file">Upload a source archive (.tar, .tar.gz, .tgz, .zip)</label>
|
||||||
|
<input id="archive-upload-file" name="file" type="file"
|
||||||
|
accept=".tar,.tar.gz,.tgz,.zip" required>
|
||||||
|
<button type="submit" id="archive-upload-btn">Upload & scan</button>
|
||||||
|
<p class="git-source-error" id="archive-upload-error" role="alert" hidden></p>
|
||||||
|
<p class="git-source-result" id="archive-upload-result" role="status"
|
||||||
|
aria-live="polite" hidden></p>
|
||||||
|
</form>
|
||||||
|
```
|
||||||
|
(No `Content-Type` concerns: the JS posts a `FormData` and the browser sets the multipart boundary. The visible `<label>` satisfies the WCAG input-label rule for the file control.)
|
||||||
|
- **Update the hint** `#git-sources-hint`: uploads unpack + scan immediately and a same-name re-upload replaces in place; the Sync button still imports git checkouts and local directories together (union prune), and removing a source prunes on the next sync. Keep it one `role="note"` paragraph.
|
||||||
|
- **Update the table `<caption>`** (visually hidden): it reads "git repositories it clones and local directories it walks" — add uploaded archives (unpacked under the upload dir) to the description.
|
||||||
|
- Do **not** touch the page `<title>`/`<h1>` ("Git sources") — out of scope (flagged in phase 48); the nav link already reads "Sources" after phase 48.
|
||||||
|
2. `frontend/assets/styles.css` — one small block near the git-sources page styles: the file input (mono-ish, on-surface, ≥44px touch target, `:focus-visible` 3px outline like the other controls) and the `#archive-upload-result` success line (ink-soft on surface, ≥4.5:1 — reuse the existing palette; `prefers-reduced-motion` already global). Keep it minimal — no new layout regions.
|
||||||
|
3. `frontend/assets/git-sources.js`:
|
||||||
|
- Remove the local-form element refs (`localFormEl`, `pathInput`, `localAddBtn`, `localAddError`) and the second `wireAddForm(…)` call; update the module docstring (the page now wires the git add form + the archive upload).
|
||||||
|
- **Upload wiring** (one new `wireUploadForm`-style submit handler on `#archive-upload-form`), the §7.4 never-stale lifecycle:
|
||||||
|
- submit → `e.preventDefault()`; no file selected → inline error (the input is `required` too — browser prompt first); hide a previous result line; disable `#archive-upload-btn`, label **"Uploading…"**.
|
||||||
|
- `fetch("/api/git-sources/upload", { method: "POST", body: new FormData([["file", file]]) })` — **no** manual `Content-Type` header.
|
||||||
|
- **200**: clear the file input; hide the error; show `#archive-upload-result` with the counts in the sync-result shape (`2 added · 1 updated · 3 unchanged · 1 pruned` — omit zero parts, the `fmtSyncResult` convention from `sources.js`); announce through `#git-sources-announcer` (`"Archive uploaded: …"`); `await loadSources()` (the new/updated row lands with the Local badge; on a re-upload the row simply refreshes — no duplicate).
|
||||||
|
- **non-2xx**: inline the server detail via the existing `apiDetail(r, fallback)` (422 format/name/traversal, 413 size, 409 busy — the server messages are already user-safe); keep the file selection; restore the button (finally block, success AND failure).
|
||||||
|
- **network failure**: the `networkMessage` line, button restored.
|
||||||
|
4. **Rewrite `tests/e2e/test_local_directory_sources.py`** (phase-38 story — the form it drives is gone; its *acceptance* stands):
|
||||||
|
- Replace every form interaction (`page.fill("#local-source-path", …)` + `page.click("#local-source-add")`) with the authenticated API call the page's own JS no longer makes: `r = page.request.post(f"{app_url}/api/git-sources", json={"kind": "local", "path": str(local_dir)})` (the cookie rides the browser context — the established `page.request` pattern, cf. `test_git_sources_admin.py`).
|
||||||
|
- The "missing path → inline 422 naming the path + input kept" test becomes: the API returns **422** whose `detail` names the path (assert on the JSON body); drop the input-value assertions.
|
||||||
|
- Everything else stays: the Local badge on list rows, add/remove lifecycle, Sync importing the local dir (visible via `GET /api/docs` / the page), prune-on-file-deletion after re-sync, anonymous 403s, and the gate.
|
||||||
|
- The module docstring must note the phase-49 rewrite (form → API) so a future reader doesn't "restore" the form.
|
||||||
|
5. **UI Structure Check (AGENTS.md rule 5)** while in there: the new form is inside the existing `<main>` region, labeled, focus-visible, error `role=alert`, result `role=status`; no CDN (rule 6 — the no-CDN integration test re-proves it).
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- No new unit/integration logic (frontend + one E2E rewrite). `app/` coverage unaffected.
|
||||||
|
- The no-CDN integration test still passes (markup only, same-origin).
|
||||||
|
- `uv run pytest tests/e2e/test_local_directory_sources.py -v --no-cov` green in isolation **after** the rewrite (DB up) — this is the proof the form removal caused no regression in the phase-38 story.
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] `#local-source-form` / `#local-source-path` / `#local-source-add` are gone from `frontend/git-sources.html` (and `git-sources.js`); `#archive-upload-form` with file input (accept set), button, `role=alert` error and `role=status` result line is in its place.
|
||||||
|
- [ ] Hint + caption updated; page `<title>`/`<h1>` untouched.
|
||||||
|
- [ ] `tests/e2e/test_local_directory_sources.py` green in isolation, fully API-driven for local adds.
|
||||||
|
- [ ] `uv run pytest tests/unit tests/integration -q` green; `uv run ruff check . && uv run pyright` clean (no Python behavior change expected in `app/` beyond nothing).
|
||||||
|
- [ ] Manual-feel check via the story E2E in task 04 (upload → counts → row) — this task leaves the page in a usable state on its own (independent viability).
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Task 04 — Story E2E, README, regression suites, commit, phase move
|
||||||
|
|
||||||
|
**Phase:** `49_archive_upload_sources` · **Story:** `.agent/user_stories/archive-upload-sources.md`
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Prove the archive-upload story end to end with its dedicated Playwright suite (A16, in isolation), document the feature in the README, confirm no regression in the surrounding suites, and land the one atomic `--no-gpg-sign` commit.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. **Create `tests/e2e/test_archive_upload_sources.py`** (one story, one file — conventions of `test_git_sources_admin.py` / `test_local_directory_sources.py`: shared `app_url` / `db_ready` fixtures, the `login(page, app_url, next=…)` helper, `page.request` for API assertions, desktop viewport 1280×800, sync `expect`). Build real archives in-test with Python's `tarfile` over `tmp_path` fixture files (markdown sentinels, e.g. `ALPHA-…` / `BETA-…` / `GAMMA-…`), named `e2e-upload.tar.gz` (source name will be `e2e-upload`).
|
||||||
|
|
||||||
|
Test cases:
|
||||||
|
- **form_swapped** — signed-in admin on `/git-sources.html`: `#local-source-form` count is 0; `#archive-upload-form` is visible with the file input (`accept` contains the four extensions) and the "Upload & scan" button; the hint mentions unpack/scan + in-place replace.
|
||||||
|
- **upload_scans_and_lists** — `page.set_input_files("#archive-upload-file", tarball_v1)` + click: the button shows "Uploading…" while in flight, then restores; `#archive-upload-result` shows the added count (2); the list gains exactly one row for `e2e-upload` with the **Local** badge; `page.request.get("/api/docs")` lists both sentinel files under source `e2e-upload`; on `/sources.html` (the RAG catalog) the table shows them (the admin sees the content where they expect it).
|
||||||
|
- **reupload_replaces_in_place** — back on the page, upload `tarball_v2` under the **same filename** (`e2e-upload.tar.gz`; v2: `alpha` modified, `beta` removed, `gamma` added): result line shows pruned ≥ 1; the list still has exactly **one** `e2e-upload` row (no duplicate — the row count for that source is invariant); `/api/docs` now shows `gamma` + the changed `alpha` and NOT `beta`.
|
||||||
|
- **bad_file_inline_error** — upload a `.txt` via the file input: `#archive-upload-error` (role=alert) shows the 422 detail naming the accepted formats; the button restores; the list is unchanged; a subsequent good upload still works (the form isn't wedged).
|
||||||
|
- **anonymous_gate** — anonymous on `/git-sources.html`: the gate (`#git-sources-gate`) shows, `#git-sources-content` (and thus the upload form) stays hidden, and `page.request.post(f"{app_url}/api/git-sources/upload", …)` is 403.
|
||||||
|
2. **README** — in the sources section (next to the phase-35/38 admin-sources docs): the upload form (accepted formats), the naming rule (filename minus archive suffix = source/folder name), the in-place replace + prune semantics on re-upload, the unpack destination (`BOR_UPLOAD_DIR`, default `~/bor-sources/uploads`) and the size cap (`BOR_UPLOAD_MAX_MB`, default 512, compressed + extracted), and the note that the local-directory *form* is gone but `POST /api/git-sources` with `kind=local` still works and existing Local rows are unchanged.
|
||||||
|
3. **Run the regression suites in isolation** (DB up, `--no-cov`): `test_git_sources_admin.py`, `test_local_directory_sources.py` (task 03's rewrite), `test_sync_button.py`, `test_import_documents.py`, `test_nav_rename_sources.py` (if phase 48 is complete — otherwise note it as the 48 gate), `test_smoke.py`, `test_shared_header.py`.
|
||||||
|
4. **Full gates**: `uv run pytest` (unit + integration) green; `uv run pytest --cov=app --cov-report=term-missing` **>90%**; `uv run ruff check . && uv run pyright` clean.
|
||||||
|
5. **Commit** — one atomic commit staging exactly this phase's files (config, `.env.example`, `app/rag/archive_upload.py`, `app/api/git_sources.py`, `app/schemas.py`, `pyproject.toml` + lock, the two new test files, the rewritten `tests/e2e/test_local_directory_sources.py`, `frontend/git-sources.html`, `frontend/assets/git-sources.js`, `frontend/assets/styles.css`, README):
|
||||||
|
`feat(sources): upload tarball/zipfile archives as sources — unpack, scan, and replace in place` with a body citing the owner request (2026-08-28) + phase 49. `git commit --no-gpg-sign`.
|
||||||
|
6. **Move the phase directory**: `mv .agent/phases/todo/49_archive_upload_sources .agent/phases/complete/` and `git add -f` the moved directory + the story file `.agent/user_stories/archive-upload-sources.md` into the SAME commit (`.agent/` is gitignored by design — AGENTS.md rule 8).
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Story E2E: `tests/e2e/test_archive_upload_sources.py` green **in isolation**.
|
||||||
|
- Coverage: `app/` >90% (the phase's Testing & Quality bar, re-proven on the final pass).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] `uv run pytest tests/e2e/test_archive_upload_sources.py -v --no-cov` green in isolation (DB up).
|
||||||
|
- [ ] All regression suites green in isolation (list above).
|
||||||
|
- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` >90%; `uv run ruff check . && uv run pyright` clean.
|
||||||
|
- [ ] README + `.env.example` document the upload semantics and both new settings.
|
||||||
|
- [ ] One `--no-gpg-sign` commit containing code, tests, story file, and the moved phase directory; `.agent/phases/todo/` no longer lists 49.
|
||||||
|
- [ ] No behavior change in completed phases (the suites above are the proof — incl. the Sync button and the `kind=local` API contract).
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Phase 40 — Tuning toggle anonymous flash
|
||||||
|
|
||||||
|
**Source:** `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."
|
||||||
|
**Story:** `.agent/user_stories/tuning-toggle-flash.md`
|
||||||
|
**Context:** Phase 15/34 shared header (`frontend/assets/header.js` owns `#steering-toggle` / `#steering-panel` on all six pages; anonymous → `remove()` post-whoami). The admin-only **nav links** already ship `hidden` (phase-19 contract) — the flashing control is the **steering toggle button labeled "Tuning"**, which ships visible in all six pages and is removed only after `/api/whoami` resolves.
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Kill the anonymous flash: the tuning toggle ships `hidden` in every page's markup and is revealed only when whoami says admin (the exact ship-hidden / reveal-for-admin contract the nav links use), so an anonymous user never sees the "Tuning" button — not for a single frame.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
- `39_configurable_brand` (complete; last existing phase) — current header state: full shared bar on all six pages.
|
||||||
|
- `19_shared_header` / `16_admin_auth` / `34_consistent_navbar` (complete) — the `fetchIsAdmin()` gate, the ship-hidden nav contract, and the module-owned steering controls this task modifies.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
1. `01_toggle_ships_hidden.md` — add `hidden` to `#steering-toggle` in all six pages and reveal-for-admin in `header.js`; pin at source level.
|
||||||
|
2. `02_flash_e2e_and_regression.md` — story E2E suite (never-visible-for-anonymous, admin reveal, nav-contract regression) + regression pass + commit.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Unit: new `tests/unit/test_steering_toggle_visibility.py` — `hidden` present on `#steering-toggle` in all six HTML pages; `header.js` unhides for admin (line before `refreshSteering()`) and the anonymous `remove()` path is intact; any existing source-pin test asserting the exact old markup is updated (check `tests/unit/test_shared_header.py`, `test_steering.py`).
|
||||||
|
- Coverage: frontend-only — the `app/` >90% gate is unaffected (must stay unchanged).
|
||||||
|
- E2E (mandatory, A16): `tests/e2e/test_tuning_toggle_flash.py`, run in isolation.
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] Anonymous load of every page: the toggle is never visible (MutationObserver records zero visible frames) and is absent from the DOM after load.
|
||||||
|
- [ ] Admin load: toggle visible, panel opens, count badge correct — admin behavior unchanged.
|
||||||
|
- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` TOTAL unchanged.
|
||||||
|
- [ ] `uv run pytest tests/e2e/test_tuning_toggle_flash.py -v --no-cov` green in isolation.
|
||||||
|
- [ ] Regression E2E suites green in isolation: `test_shared_header.py`, `test_global_tuning.py`, `test_steering.py`, `test_tuning_nav_link.py`, `test_smoke.py`.
|
||||||
|
- [ ] `uv run ruff check . && uv run pyright` clean.
|
||||||
|
- [ ] UI Structure Check (AGENTS.md rule 5): no new focus targets; landmarks/contrast unchanged; no CDN.
|
||||||
|
- [ ] One `--no-gpg-sign` commit; phase dir moved `.agent/phases/todo/` → `.agent/phases/complete/`.
|
||||||
|
|
||||||
|
## Locked decisions
|
||||||
|
- **A10 untouched** — no API change; the fix is pure UI visibility off the existing `/api/whoami` gate.
|
||||||
|
- **A11 untouched** — no new assets, no CDN.
|
||||||
|
- **Phase-16 contract preserved** — anonymous still gets "absent, not hidden" (remove-from-DOM); this phase only removes the pre-whoami flash window.
|
||||||
|
- **A16/A17 honoured** — one story E2E suite, one atomic commit.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Task 02 — Flash E2E + regression + commit
|
||||||
|
|
||||||
|
**Phase:** `40_tuning_toggle_flash` · **Source:** `TODO.md:3` — "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."
|
||||||
|
**Story:** `.agent/user_stories/tuning-toggle-flash.md`
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Prove the flash is gone at the browser level (never visible, not even for a frame) and that the shared-header contract is intact; commit the phase.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `tests/e2e/test_tuning_toggle_flash.py` (new) — mock-only suite (DB up), per the story's Playwright Mapping Rule:
|
||||||
|
- a helper `install_visibility_observer(page)`: `page.add_init_script` a MutationObserver on `document.documentElement` that appends to `window.__tuningVisibleFrames` every time `#steering-toggle` is added/attribute-changed and is both in the DOM **and** not `[hidden]` (check `el.offsetParent !== null` or `!el.hidden`);
|
||||||
|
- `test_anonymous_never_sees_toggle` — load `/` anonymously, wait for network idle + header settle (whoami resolved), assert `window.__tuningVisibleFrames` is empty and `#steering-toggle` is absent from the DOM;
|
||||||
|
- `test_anonymous_other_pages_never_flash` — same on `/sources.html`, `/tuning.html`, `/login.html`;
|
||||||
|
- `test_admin_toggle_revealed_and_working` — `login()` (e2e.auth_helpers), reload `/`, toggle visible + clickable (opens `#steering-panel`, `aria-expanded="true"`), count badge matches the list;
|
||||||
|
- `test_nav_contract_regression` — anonymous: `#nav-sources` / `#nav-git-sources` / `#nav-tuning` stay hidden; admin: revealed.
|
||||||
|
2. Regression pass (isolation runs, per A16): `test_shared_header.py`, `test_global_tuning.py`, `test_steering.py`, `test_tuning_nav_link.py`, `test_smoke.py` — all green; fix only true regressions.
|
||||||
|
3. `uv run pytest` (unit+integration) green; `uv run pytest --cov=app --cov-report=term-missing` TOTAL unchanged; `uv run ruff check . && uv run pyright` clean.
|
||||||
|
4. Commit (Conventional Commits, `--no-gpg-sign`), e.g. `fix(header): ship the tuning toggle hidden — no anonymous flash`, staging this phase's changed files; move `.agent/phases/todo/40_tuning_toggle_flash/` → `.agent/phases/complete/` (force-add per AGENTS.md rule 8 if the history tracks the tree).
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- E2E: `uv run pytest tests/e2e/test_tuning_toggle_flash.py -v --no-cov` green in isolation (DB up: `podman compose up -d db`).
|
||||||
|
- Coverage: **>90%** on `app/` (unchanged — frontend-only phase).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] The story E2E file passes in isolation; the four regression suites pass in isolation.
|
||||||
|
- [ ] One atomic `--no-gpg-sign` commit; phase dir moved to `.agent/phases/complete/`.
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Phase 41 — Sync fails fast + modal when a model is down
|
||||||
|
|
||||||
|
**Source:** `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."
|
||||||
|
**Story:** `.agent/user_stories/sync-model-fail-fast.md`
|
||||||
|
**Context:** `app/api/sync.py::_run_sync` (phase 32/35/38) runs source resolution → git clones → `import_sources` (embeds) → overview (lite) — with a dead LLM endpoint the run discovers it only mid-import, after slow clones. The sync state machine is module-owned by `frontend/assets/header.js` (`applySyncFailure` → button title/aria + `.is-error` + `bor:sync-status` event; the Sources page renders `#sync-error-banner`). No dialog component exists yet.
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
When `embed` or `lite` is unreachable, the sync fails **before any expensive work** with a message naming the model, and the failure is readable in a **modal dialog** on every page that carries `#sync-btn`.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
- `40_tuning_toggle_flash` (todo) — current shared-header state (sequential; no code overlap, but both touch `header.js` — keep this phase's changes confined to the sync section).
|
||||||
|
- `32_admin_sync_button` / `35_git_sources_admin` / `38_local_directory_sources` (complete) — the pipeline, the status contract, and the module-owned button lifecycle this phase extends.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
1. `01_model_probe_fail_fast.md` — `check_models()` probe in `app/rag/llm.py`, called first in `_run_sync`; unit + integration tests.
|
||||||
|
2. `02_sync_error_modal.md` — `header.js` modal (built in JS, all pages) + CSS; source pins.
|
||||||
|
3. `03_model_down_e2e_and_commit.md` — dedicated E2E suite (dead-LLM module app) + phase-32 regressions + commit.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Unit: probe success/failure paths with a fake LLM client (embed-down, lite-down, both up); the sync task's fail-fast ordering (probe before source resolution — assert no clone call happens).
|
||||||
|
- Integration: `POST /api/sync` with a stubbed failing client → `GET /api/sync/status` reaches `failed` with the model-naming error; healthy path regression.
|
||||||
|
- Coverage: **>90%** on `app/` including the new probe code.
|
||||||
|
- E2E (mandatory, A16): `tests/e2e/test_sync_model_down.py`, run in isolation.
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] With a dead LLM endpoint: sync fails within seconds, **before** any clone, error names the unavailable model; the modal shows it; button settles retry-ready.
|
||||||
|
- [ ] Modal contract: `role="alertdialog"`, `aria-modal`, text via `textContent`, close via button / `Esc` / backdrop, focus in-and-out.
|
||||||
|
- [ ] Healthy sync pipeline (clone → import → overview) unchanged — phase-32 suite green in isolation.
|
||||||
|
- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` >90%.
|
||||||
|
- [ ] `uv run pytest tests/e2e/test_sync_model_down.py -v --no-cov` green in isolation (DB up).
|
||||||
|
- [ ] `uv run ruff check . && uv run pyright` clean.
|
||||||
|
- [ ] One `--no-gpg-sign` commit; phase dir moved to `.agent/phases/complete/`.
|
||||||
|
|
||||||
|
## Locked decisions
|
||||||
|
- **A12 untouched** — still in-process, no queue, no new service; the probe is two cheap model calls.
|
||||||
|
- **A10 untouched** — no new endpoint; `/api/sync` + `/api/sync/status` keep their shapes (a model failure is just another `failed` state).
|
||||||
|
- **Phase-32 contract kept** — 2 s poll, 202/409, no client timeout, `bor:sync-status` event, button title/aria affordance, Sources banner (the modal is additive).
|
||||||
|
- **Owner-locked (2026-08-27, roadmap A4):** probe runs **before** git clones; the modal is the primary failure surface on every page.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Task 01 — Model probe: fail fast before any clone
|
||||||
|
|
||||||
|
**Phase:** `41_sync_fail_fast_models` · **Source:** `TODO.md:4` — "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."
|
||||||
|
**Story:** `.agent/user_stories/sync-model-fail-fast.md`
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
The sync run verifies both models it needs (`embed` + the summary `lite`) **first** — before source resolution, before any `clone_or_pull` — and fails the run with a clear, model-naming error when either is unreachable.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `app/rag/llm.py` — add `class ModelUnavailableError(LLMError)` and:
|
||||||
|
```python
|
||||||
|
async def check_models(llm: LLMClient) -> None:
|
||||||
|
"""Verify the models a sync needs (embed + summary) before any
|
||||||
|
expensive work; raise ModelUnavailableError naming the model."""
|
||||||
|
```
|
||||||
|
- `await llm.embed_one("sync model check")` — wrap `EmbeddingError` (and any other exception) in `ModelUnavailableError`: message names the **embedding model** (use `llm.settings.llm_embed_model`, e.g. "The embedding model ('embed') is not available — check the model endpoint and retry.")
|
||||||
|
- `await llm.chat([{"role": "user", "content": "ping"}])` (defaults to `llm_summary_model`) — wrap `LLMError`/other in `ModelUnavailableError` naming the **summary model** (`llm.settings.llm_summary_model`, e.g. "The summary model ('lite') is not available — check the model endpoint and retry.").
|
||||||
|
- Docstring notes the probe is deliberately tiny (one short embedding + one 1-token-scale completion) and that the sync sanitizer downstream still masks any embedded credentials.
|
||||||
|
2. `app/api/sync.py` — in `_run_sync()`, construct `llm = LLMClient()` **before** the DB/source block and call `await check_models(llm)` as the **first** pipeline step (before `effective_sources`, before the clone loop). Update the module docstring's pipeline list (the probe is step 1: "verify `embed` + summary model availability — fail fast before any clone") and renumber. `ModelUnavailableError` falls into the existing `except Exception` → `failed` state with the sanitized error (no special-casing needed — verify the message survives `_sanitize_error` unchanged).
|
||||||
|
3. `tests/unit/test_sync_model_probe.py` (new) — with a fake LLM client (duck-typed `embed_one`/`chat`, see `tests/fakes.py` `FakeEmbedder` for the shape):
|
||||||
|
- both up → `check_models` returns, both methods called;
|
||||||
|
- embed raises → `ModelUnavailableError` mentioning the embed model name, `chat` never called;
|
||||||
|
- chat raises → `ModelUnavailableError` mentioning the summary model name;
|
||||||
|
- message content assertions (model name present, "not available" wording).
|
||||||
|
4. `tests/integration/test_sync_api.py` — extend:
|
||||||
|
- **fail-fast:** monkeypatch `app.api.sync.LLMClient` (or `check_models`) so the probe raises `ModelUnavailableError`; also monkeypatch `clone_or_pull` to *assert it is never called*; `POST /api/sync` → poll `GET /api/sync/status` until terminal → `state == "failed"`, `error` names the model;
|
||||||
|
- **ordering:** a spy on `effective_sources` shows the probe ran before it;
|
||||||
|
- **healthy regression:** the existing success/failure tests stay green (they stub the LLM — the stub must now satisfy the probe: `FakeEmbedder` already implements `chat`; if the existing stub lacks `embed_one`, add it — `FakeEmbedder.embed` exists, so subclass or delegate).
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Unit/integration as above; the probe must be covered (success + both failure modes) to keep `app/` **>90%**.
|
||||||
|
- `uv run pytest tests/unit/test_sync_model_probe.py tests/integration/test_sync_api.py -v` green; full suite green.
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] `check_models` exists, is called first in `_run_sync`, and names the failing model in `ModelUnavailableError`.
|
||||||
|
- [ ] A dead-model sync fails **before** any clone (spy-asserted) with a sanitized, model-naming error in the `failed` state.
|
||||||
|
- [ ] Healthy pipeline behavior unchanged (existing sync integration tests green).
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Task 02 — Sync error modal (module-owned, every page)
|
||||||
|
|
||||||
|
**Phase:** `41_sync_fail_fast_models` · **Source:** `TODO.md:4` — "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."
|
||||||
|
**Story:** `.agent/user_stories/sync-model-fail-fast.md`
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
A readable, accessible modal dialog for sync failures, built by the shared header module (which owns the sync state machine), so every page carrying `#sync-btn` gets it with zero page-markup changes.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `frontend/assets/header.js` — in the sync section, add:
|
||||||
|
- `showSyncModal(error)`: lazily create the dialog **once** and append to `document.body` (module-level `let syncModal = null`):
|
||||||
|
- backdrop `<div class="sync-modal-backdrop">`;
|
||||||
|
- panel `<div class="sync-modal" role="alertdialog" aria-modal="true" aria-labelledby="sync-modal-title" aria-describedby="sync-modal-error">` with an `<h2 id="sync-modal-title">Sync failed</h2>`, a `<p id="sync-modal-error">` whose text is set via **`textContent`** (the sanitized error — XSS-safe, never `innerHTML`), and a `<button type="button" class="sync-modal-close" aria-label="Close error dialog">` (×);
|
||||||
|
- opening: add a `.is-open` class (or remove `hidden`), move focus to the close button, remember `document.activeElement` (expected `#sync-btn`);
|
||||||
|
- closing: reverse (focus returns to the remembered element — `#sync-btn` when present), `Esc` keydown on `document` while open, backdrop click (click on the backdrop element itself, not the panel), and the close button all call the same close function; a second failure while open **updates the error text in place** (no stacking).
|
||||||
|
- call `showSyncModal(status.error)` from `applySyncFailure(status)` **after** the existing button-title/aria/`.is-error` + `emitSyncStatus` lines (those stay byte-identical — the Sources page's `#sync-error-banner` keeps rendering off the event).
|
||||||
|
- null-safe: everything guards on `syncBtn`/`document.body`; pages without `#sync-btn` never create the modal (the function is only reachable from the sync state machine).
|
||||||
|
- Update the module docstring's sync bullet: the failed state now also opens the module-owned error modal (2026-08-27, `TODO.md` L4).
|
||||||
|
2. `frontend/assets/styles.css` — `.sync-modal-backdrop` (fixed, full-viewport, `rgba` dim over the page, `z-index` above the header) + `.sync-modal` (centered panel, max-width ≈28rem, the dark-theme **error palette** from PLAN §7.2: panel on the error-surface `#2d1318` family, text `#fca5a5`-class ink, 1px error border; title in ink, error text ink-soft-on-error-surface ≥4.5:1); open/close via `.is-open` (visibility/opacity, no motion under `prefers-reduced-motion`); the close button keeps the global `:focus-visible` 3px outline; 44px touch floor.
|
||||||
|
3. `tests/unit/test_sync_button.py` — add source pins (house style): `header.js` contains `role="alertdialog"`, the `textContent` assignment of the modal error, the `Esc` close binding, the backdrop-click close, focus return to `#sync-btn`, and the `showSyncModal` call inside `applySyncFailure` (after `emitSyncStatus`); `styles.css` carries the `.sync-modal` rules + the reduced-motion stilling. Update any pin that asserts the exact `applySyncFailure` body.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Unit: the pins above; full suite green (no `app/` change — coverage TOTAL unchanged).
|
||||||
|
- Coverage: **>90%** on `app/` (unchanged).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] `applySyncFailure` opens the modal with the sanitized error; button title/aria + `bor:sync-status` event behavior byte-identical.
|
||||||
|
- [ ] Modal: `role="alertdialog"`, `aria-modal`, labeled, `textContent`-rendered error, close via button/`Esc`/backdrop, focus in-and-out to `#sync-btn`.
|
||||||
|
- [ ] No page HTML changed (the modal is JS-built); no CDN (A11).
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Task 03 — Model-down E2E + regressions + commit
|
||||||
|
|
||||||
|
**Phase:** `41_sync_fail_fast_models` · **Source:** `TODO.md:4` — "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."
|
||||||
|
**Story:** `.agent/user_stories/sync-model-fail-fast.md`
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Prove the whole story in the browser against a **dead model endpoint** — fast failure, readable modal, dismissal, unchanged secondary surfaces, and an untouched healthy pipeline — then commit the phase.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `tests/e2e/test_sync_model_down.py` (new) — mock-only, DB up, git on PATH. Follow the `test_sync_button.py` module-app pattern, but boot **two** module-scoped apps on distinct ports (import `APP_PORT`, `ADMIN_PASSWORD`, `SESSION_SECRET`, `_wait_http` from `e2e.conftest`; use e.g. `APP_PORT + 41` for the dead-model app so the isolated run never clashes with a session app):
|
||||||
|
- **dead-model app** env: `BOR_LLM_BASE_URL=http://127.0.0.1:9/v1` (closed port — instant connection refused), `BOR_GIT_SOURCES=file://<the test_sync_button fixture repo pattern>` (a local `file://` fixture repo, built the same way `test_sync_button.py` does — a (regressed, non-fail-fast) run would therefore spend real time cloning before failing), its own `BOR_SOURCES_DIR` under `tmp_path`;
|
||||||
|
- `test_model_down_fails_fast_with_modal` — admin login, click `#sync-btn`; expect (budget ≤ ~10 s, contrast with the 60 s healthy budget) the button settling retry-ready **and** the modal visible: `role="alertdialog"`, title "Sync failed", error text naming the model ("embedding model" / the model id), `aria-modal="true"`;
|
||||||
|
- `test_modal_dismissal` — one fresh failure, then close via the × button (focus returns to `#sync-btn`); a fresh failure, close via `Esc`; a fresh failure, close via backdrop click;
|
||||||
|
- `test_sync_error_surfaces_unaffected` — after a failure the button keeps `title` + `.is-error`; on `/sources.html` (same dead-model app) the `#sync-error-banner` renders the error off `bor:sync-status`;
|
||||||
|
- `test_healthy_sync_still_succeeds` — a **healthy** module app (same port scheme, `BOR_LLM_BASE_URL` = the session mock like `test_sync_button.py`) runs the full pipeline to "Synced HH:MM" (counts + idempotency as in phase 32) — proves the probe didn't break the happy path.
|
||||||
|
- Module fixture teardown: terminate both apps (the conftest pattern).
|
||||||
|
2. Regression pass (isolation runs): `tests/e2e/test_sync_button.py` (phase 32 — must stay green unmodified), `test_git_sources_admin.py`, `test_local_directory_sources.py`.
|
||||||
|
3. `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` >90%; `uv run ruff check . && uv run pyright` clean.
|
||||||
|
4. Commit (Conventional Commits, `--no-gpg-sign`), e.g. `feat(sync): fail fast with a modal when a model is unavailable`, staging this phase's files; move `.agent/phases/todo/41_sync_fail_fast_models/` → `.agent/phases/complete/`.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- E2E: `uv run pytest tests/e2e/test_sync_model_down.py -v --no-cov` green in isolation.
|
||||||
|
- Coverage: **>90%** on `app/` (the probe code is fully covered by task 01's tests).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] The model-down suite passes in isolation: fast fail before clones, modal with model-naming error, all three dismissal paths, secondary surfaces intact, healthy run unaffected.
|
||||||
|
- [ ] Phase-32/35/38 regression suites green in isolation.
|
||||||
|
- [ ] One atomic `--no-gpg-sign` commit; phase dir moved to `.agent/phases/complete/`.
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Phase 42 — No reply autoscroll
|
||||||
|
|
||||||
|
**Source:** `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."
|
||||||
|
**Story:** `.agent/user_stories/no-reply-autoscroll.md`
|
||||||
|
**Context:** Phase 18 ("follow-the-bottom", owner choice 2026-08-23) added `NEAR_BOTTOM_PX = 200` / `isNearBottom()` / `scrollReveal(wrap, behavior, force)` in `frontend/assets/app.js`: the page auto-scrolls on every `thinking` / `tool` / `delta` frame while the user is within 200px of the bottom. The owner now finds that fighting their own scroll. The gate and the per-frame scrolls are **removed**; scrolling happens only on explicit user intent (submit, restore landing).
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
The chat page never auto-scrolls during a turn. The viewport moves only when the user submits (their message is revealed) or when a persisted conversation is restored (one-shot landing) — both user-initiated.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
- `41_sync_fail_fast_models` (todo) — sequential execution only (no code overlap).
|
||||||
|
- `18_follow_bottom_scroll` (complete) — the code being removed; `14_chat_persistence` (complete) — the restore landing that must survive; `17_thinking_display` / `11_long_answers` (complete) — the thinking window-pin and long-answer behavior this phase must not break.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
1. `01_remove_autofollow.md` — strip the phase-18 gate + per-frame scrolls from `app.js`; rewrite the unit pin for the new contract.
|
||||||
|
2. `02_no_autoscroll_e2e_and_commit.md` — replace the phase-18 E2E with the inverse-contract suite + regressions + commit.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Unit: `tests/unit/test_frontend_scroll.py` **rewritten** — pins the new contract: no `NEAR_BOTTOM_PX` / `isNearBottom` in `app.js`; the scroll helper scrolls unconditionally (smooth / reduced-motion-aware); the user-submit path scrolls; the thinking/tool/delta handlers contain **no** page-scroll call; the restore landing keeps its one-shot forced scroll.
|
||||||
|
- Coverage: frontend-only — `app/` TOTAL unchanged, >90%.
|
||||||
|
- E2E (mandatory, A16): `tests/e2e/test_no_reply_autoscroll.py`, run in isolation. `tests/e2e/test_follow_bottom_scroll.py` is **deleted** (behavior intentionally removed by owner direction 2026-08-27).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] During thinking / tool / answer streaming, `window.scrollY` is stable (±1px) while the viewport is scrolled up.
|
||||||
|
- [ ] Submit still reveals the user's message; reload still lands one-shot on the latest message.
|
||||||
|
- [ ] `uv run pytest` green; coverage TOTAL unchanged.
|
||||||
|
- [ ] `uv run pytest tests/e2e/test_no_reply_autoscroll.py -v --no-cov` green in isolation (DB up).
|
||||||
|
- [ ] Regression E2E suites green in isolation: `test_chat_rag.py`, `test_thinking_display.py`, `test_chat_persistence.py`, `test_long_answers.py`, `test_smoke.py`.
|
||||||
|
- [ ] `uv run ruff check . && uv run pyright` clean.
|
||||||
|
- [ ] One `--no-gpg-sign` commit; phase dir moved to `.agent/phases/complete/`.
|
||||||
|
|
||||||
|
## Locked decisions
|
||||||
|
- **Owner direction (2026-08-27, roadmap A1)** revises the phase-18 owner choice (2026-08-23): follow-the-bottom auto-follow is removed; submit-reveal + restore-landing are kept. Recorded in the story file and the `app.js` docstring (PLAN.md §7.4's Scroll row is a PLAN-side revision to be noted by the owner — this phase does not edit PLAN.md).
|
||||||
|
- **A15 unchanged** — the SSE contract is untouched; this is pure client-side behavior.
|
||||||
|
- **The thinking window's internal pin** (`textEl.scrollTop`, phase 17) is untouched here — phase 43 reworks it separately.
|
||||||
|
- **A16/A17 honoured** — one story E2E suite (replacing the removed one), one atomic commit.
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Task 01 — Remove the auto-follow gate and per-frame scrolls
|
||||||
|
|
||||||
|
**Phase:** `42_no_reply_autoscroll` · **Source:** `TODO.md:5` — "Get rid of the chat reply autoscroll, it's breaking things like making it impossible for the user to scroll while a reply generates."
|
||||||
|
**Story:** `.agent/user_stories/no-reply-autoscroll.md`
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
`app.js` scrolls only on explicit user intent: sending a message and the phase-14 restore landing. No scroll happens anywhere in the streaming path.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `frontend/assets/app.js` —
|
||||||
|
- **Delete** `export const NEAR_BOTTOM_PX = 200` and `function isNearBottom()`.
|
||||||
|
- **Simplify** `scrollReveal(wrap, behavior = SCROLL, force = false)` → an unconditional `wrap.scrollIntoView({ behavior })` (keep the `SCROLL` constant: smooth, `auto` under `prefers-reduced-motion`; keep the "Calm, don't remove" comment). Rename the gate comment block: the phase-18 "follow-the-bottom scroll contract" paragraph is replaced by the new contract — *"No reply autoscroll (owner direction 2026-08-27, `TODO.md` L5): the page never auto-scrolls while a turn streams. The only scroll call sites are the user submit (reveal my message) and the phase-14 restore landing (one-shot, load-time)."*
|
||||||
|
- **`addMessage(who, html, scrollBehavior = SCROLL, force = false)`** → change the signature to `addMessage(who, html, scroll = false)`: the internal `scrollReveal(wrap, scrollBehavior, force)` becomes `if (scroll) scrollReveal(wrap)`. Update the call sites (line numbers are pre-change anchors):
|
||||||
|
- the **user submit** call (`addMessage("user", renderMarkdown(text))`, ~line 896) → `addMessage("user", renderMarkdown(text), true)` (my message must be revealed — the owner-kept behavior);
|
||||||
|
- the **phase-14 restore** calls (~lines 772/775: `addMessage("user", …, "auto", true)` / `addMessage("brain", …, "auto", true)`) → keep the one-shot forced scroll under the new signature (e.g. `addMessage("user", renderMarkdown(m.text), true)` — the "auto" (non-smooth) behavior for the landing is preserved by passing it through if the new signature keeps a behavior param, otherwise the default `SCROLL` is acceptable and must be noted in the docstring);
|
||||||
|
- the brain first-bubble creations in the SSE handlers (`if (!wrap) wrap = addMessage("brain", "")`, ~lines 955/978/995, plus the `"…"` fallback ~1004 and the error fallback ~1046) → `scroll: false` (default) — a streaming turn never scrolls the page;
|
||||||
|
- `addTyping()` (~line 356): the `scrollReveal(wrap)` after `messagesEl.appendChild(wrap)` is **removed** (a typing bubble must not yank the page).
|
||||||
|
- **SSE handlers** — remove the page-scroll calls: in the `thinking` frame drop the `scrollReveal(wrap); // page follows only while pinned (phase 18)` line **but keep** `textEl.scrollTop = textEl.scrollHeight;` (the thinking *window* pin — phase 17, reworked in phase 43); in the `tool` frame drop its `scrollReveal(wrap);`; in the `delta` frame drop its `scrollReveal(wrap);`.
|
||||||
|
- Update the file-top docstring's scroll paragraph (lines ~73–81: "Scroll (phase 18, owner choice…)") to the new contract.
|
||||||
|
2. `tests/unit/test_frontend_scroll.py` — **rewrite** for the new contract (keep the house style — source pins over `app.js`):
|
||||||
|
- `NEAR_BOTTOM_PX` / `isNearBottom` are **absent** from `app.js`;
|
||||||
|
- the scroll helper scrolls unconditionally (no `force`-or-near-bottom condition in its body);
|
||||||
|
- the user-submit `addMessage` call passes the scroll intent; the brain-bubble creation does not;
|
||||||
|
- the `thinking` / `tool` / `delta` handler bodies contain no `scrollReveal` call (the thinking handler's `textEl.scrollTop` pin is still present);
|
||||||
|
- the restore landing still performs its one-shot scroll (pin the marker comment / call);
|
||||||
|
- the `SCROLL` reduced-motion handling is intact.
|
||||||
|
- Delete the now-obsolete phase-18 test functions (the band constant, the gate logic) — do not leave dead pins.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Unit: the rewritten pin file + the full suite green (no `app/` change — coverage TOTAL unchanged).
|
||||||
|
- Coverage: **>90%** on `app/` (unchanged).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] No page scroll happens in the streaming path (grep-verifiable + unit-pinned); submit and restore landing still scroll.
|
||||||
|
- [ ] `uv run pytest` green; the thinking window-pin and all message rendering are byte-identical elsewhere.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Task 02 — No-autoscroll E2E (replaces phase 18) + regressions + commit
|
||||||
|
|
||||||
|
**Phase:** `42_no_reply_autoscroll` · **Source:** `TODO.md:5` — "Get rid of the chat reply autoscroll, it's breaking things like making it impossible for the user to scroll while a reply generates."
|
||||||
|
**Story:** `.agent/user_stories/no-reply-autoscroll.md`
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Prove the inverse of the phase-18 contract in the browser: no streaming autoscroll, submit-reveal and restore-landing intact — then delete the obsolete phase-18 suite and commit.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `tests/e2e/test_no_reply_autoscroll.py` (new) — mock-only, DB up, per the story's Playwright Mapping Rule:
|
||||||
|
- `test_no_autoscroll_during_long_answer` — `LONG_ANSWER_TRIGGER` question (the mock's ~8 s long answer); once the answer starts streaming, `window.evaluate` a scroll up ~2× the answer's height; sample `window.scrollY` across ≥10 frames (and after `done`): stable within 1px;
|
||||||
|
- `test_no_autoscroll_during_thinking` — `THINKING_TRIGGER` question; scroll up during the ~4.5 s thinking stream; viewport stable across chunks (no per-chunk page follow);
|
||||||
|
- `test_submit_reveals_user_message` — in a populated conversation scrolled to the very top, send a question; after send the user's message is in view (its bounding box within the viewport);
|
||||||
|
- `test_restore_landing_one_shot` — settle a conversation (phase-14 persistence), reload; the page lands on the latest message and stays (no further movement while idle);
|
||||||
|
- `test_answer_content_intact` — the long answer completes with sources; a thinking turn persists + restores (collapsed block, phase 17).
|
||||||
|
2. **Delete** `tests/e2e/test_follow_bottom_scroll.py` (its behavior is intentionally removed — owner direction 2026-08-27; the unit pin was rewritten in task 01).
|
||||||
|
3. Regression pass (isolation runs): `test_chat_rag.py`, `test_thinking_display.py`, `test_chat_persistence.py`, `test_long_answers.py`, `test_smoke.py` — all green; fix only true regressions.
|
||||||
|
4. `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` TOTAL unchanged; `uv run ruff check . && uv run pyright` clean.
|
||||||
|
5. Commit (Conventional Commits, `--no-gpg-sign`), e.g. `fix(chat): stop autoscrolling while a reply streams (owner direction)`, staging this phase's files (including the deleted E2E); move `.agent/phases/todo/42_no_reply_autoscroll/` → `.agent/phases/complete/`.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- E2E: `uv run pytest tests/e2e/test_no_reply_autoscroll.py -v --no-cov` green in isolation.
|
||||||
|
- Coverage: **>90%** on `app/` (unchanged — frontend-only phase).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] The new suite passes in isolation; the phase-18 suite is gone; the five regression suites pass in isolation.
|
||||||
|
- [ ] One atomic `--no-gpg-sign` commit; phase dir moved to `.agent/phases/complete/`.
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Phase 43 — Thinking scroll back (user scroll + generate-time autoscroll)
|
||||||
|
|
||||||
|
**Source:** `TODO.md` L7 — "Add scrolling back to the thinking block, but have it autoscroll while thinking content is generating."
|
||||||
|
**Story:** `.agent/user_stories/thinking-scroll-back.md`
|
||||||
|
**Context:** Phase 17 streams reasoning into the collapsible `.thinking` block with a per-chunk bottom-pin (`textEl.scrollTop = textEl.scrollHeight` in the `thinking` SSE handler). Phase 21 (owner choice 2026-08-24) made `.thinking-text` a no-scroll live tail: `overflow-y: hidden` (the JS pin is the sole scroller). The owner now reverses phase 21: the window is user-scrollable again, and the pin becomes **gated** — follow the tail only while the user is pinned near the window's bottom. This is the window-level successor of the phase-18 pattern (the page-level one is removed in phase 42, which runs first and touches the same `thinking` handler line — order matters).
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
The Thinking block follows its live tail while reasoning is generating **and** the user is at the bottom; a scrolled-up user is never yanked down, and returning to the bottom resumes following.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
- `42_no_reply_autoscroll` (todo) — must run **first**: it strips the page-level scroll from the same `thinking` handler; this phase then reworks the window pin in the cleaned-up handler.
|
||||||
|
- `17_thinking_display` (complete) — the block, the pin, the auto-collapse on first delta.
|
||||||
|
- `21_thinking_no_scroll` (complete) — the `overflow-y: hidden` + 320px window being reversed (the 320px clip is kept).
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
1. `01_window_user_scrollable.md` — CSS: `overflow-y: auto` back, comment replaced (owner direction 2026-08-27).
|
||||||
|
2. `02_gated_bottom_pin.md` — `app.js`: `THINKING_NEAR_BOTTOM_PX = 32` + gated pin; unit pin rewritten (phase-21 file replaced).
|
||||||
|
3. `03_thinking_scroll_e2e_and_commit.md` — replace the phase-21 E2E with the new-contract suite + regressions + commit.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Unit: `tests/unit/test_thinking_no_scroll.py` **deleted**, replaced by `tests/unit/test_thinking_scroll.py` — pins: `overflow-y: auto` + `max-height: 320px` in the `.thinking-text` rule; the 2026-08-27 owner-direction comment; `export const THINKING_NEAR_BOTTOM_PX = 32`; the pin is gated on `isThinkingNearBottom(textEl)` (no unconditional pin).
|
||||||
|
- Coverage: frontend-only — `app/` TOTAL unchanged, >90%.
|
||||||
|
- E2E (mandatory, A16): `tests/e2e/test_thinking_scroll.py`, run in isolation. `tests/e2e/test_thinking_no_scroll.py` is **deleted** (behavior intentionally reversed).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] Wheel/drag/keyboard move `.thinking-text` (frozen-tail state); computed `overflow-y: auto`, `max-height: 320px`.
|
||||||
|
- [ ] While pinned at the window bottom: each chunk re-pins to the tail (±1px). Scrolled up: no re-pin across chunks. Return to bottom: following resumes.
|
||||||
|
- [ ] Auto-collapse on first delta, reduced-motion stillness, answer-bubble scroll (phase 11), restored-collapsed block (phase 17) all unchanged.
|
||||||
|
- [ ] `uv run pytest` green; coverage TOTAL unchanged.
|
||||||
|
- [ ] `uv run pytest tests/e2e/test_thinking_scroll.py -v --no-cov` green in isolation (DB up).
|
||||||
|
- [ ] Regression E2E suites green in isolation: `test_thinking_display.py`, `test_chat_persistence.py`, `test_no_reply_autoscroll.py`, `test_smoke.py`.
|
||||||
|
- [ ] `uv run ruff check . && uv run pyright` clean.
|
||||||
|
- [ ] One `--no-gpg-sign` commit; phase dir moved to `.agent/phases/complete/`.
|
||||||
|
|
||||||
|
## Locked decisions
|
||||||
|
- **Owner direction (2026-08-27, roadmap A2)** reverses the phase-21 owner choice (2026-08-24): the window is user-scrollable again; autoscroll only while pinned near the bottom (32px band). The 320px clip is kept (owner-confirmed).
|
||||||
|
- **A15 unchanged** — SSE contract untouched; pure client-side.
|
||||||
|
- **A16/A17 honoured** — one story E2E suite (replacing the removed one), one atomic commit.
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Task 01 — Window user-scrollable again (CSS + unit pin swap)
|
||||||
|
|
||||||
|
**Phase:** `43_thinking_scroll_back` · **Source:** `TODO.md:7` — "Add scrolling back to the thinking block, but have it autoscroll while thinking content is generating."
|
||||||
|
**Story:** `.agent/user_stories/thinking-scroll-back.md`
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Restore user scrolling on the Thinking window — `overflow-y: auto`, 320px clip kept, comment updated — and swap the phase-21 unit pins for the new contract so the suite stays green.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `frontend/assets/styles.css` — in the phase-17/21 thinking section, the `details.thinking .thinking-text` rule:
|
||||||
|
- `overflow-y: hidden;` → `overflow-y: auto;`
|
||||||
|
- replace the phase-21 comment (*"no user scroll back (owner choice 2026-08-24): the window is a live tail only — the phase-17 JS bottom-pin … is the sole scroller"*) with: *"user-scrollable window (owner direction 2026-08-27, `TODO.md` L7): autoscroll follows the live tail only while the user is pinned near the window's bottom — the phase-17 pin, gated in app.js (task 02: `THINKING_NEAR_BOTTOM_PX`); scrolling up pauses the follow, returning to the bottom resumes it."*
|
||||||
|
- `max-height: 320px` and **every other declaration in the rule stay byte-identical**; the tightened `p`/`ul` margins rule and the reduced-motion chevron block are untouched.
|
||||||
|
2. **Delete** `tests/unit/test_thinking_no_scroll.py` (its pins assert the reversed behavior) and create `tests/unit/test_thinking_scroll.py` (house style — source pins, same slicing helpers as the deleted file) with, for now, the CSS contract only:
|
||||||
|
- the `.thinking-text` rule body contains `overflow-y: auto`, `max-height: 320px`, and the 2026-08-27 owner-direction comment (assert `"owner direction 2026-08-27"` and `"TODO.md L7"`);
|
||||||
|
- `overflow-y: hidden` / `overflow-y: scroll` are absent from that rule body;
|
||||||
|
- the phase-17 bottom-pin marker (`textEl.scrollTop = textEl.scrollHeight`) is still present in `app.js` (it becomes gated in task 02 — the pin's existence is asserted now so task 02's diff stays minimal and reviewable).
|
||||||
|
- The JS-gate pins (`THINKING_NEAR_BOTTOM_PX`, `isThinkingNearBottom`, gated call) are added in task 02 — do not assert them yet.
|
||||||
|
3. Check `tests/e2e/test_thinking_no_scroll.py` still passes at this checkpoint: it asserts computed `overflow-y: hidden` — **it will fail** (the behavior is intentionally changed). Per the gate, the phase's E2E replacement is task 03; to keep the per-task gate green, **delete** that E2E file in this task as well (its behavior is reversed; task 03 lands the replacement suite). Note the deletion in the final commit message of task 03.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Unit: `tests/unit/test_thinking_scroll.py` green; full `uv run pytest` green (the deleted E2E file does not run under the unit/integration gate, but the full pytest run must not collect it either — it is gone from the tree).
|
||||||
|
- Coverage: **>90%** on `app/` (unchanged).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] `overflow-y: auto` + 320px clip + new comment in the CSS rule; all other declarations byte-identical.
|
||||||
|
- [ ] Old unit + old E2E phase-21 files deleted; new unit file pins the CSS contract and the surviving pin marker.
|
||||||
|
- [ ] `uv run pytest` green at this checkpoint.
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# Task 02 — Gated bottom pin (follow while pinned)
|
||||||
|
|
||||||
|
**Phase:** `43_thinking_scroll_back` · **Source:** `TODO.md:7` — "Add scrolling back to the thinking block, but have it autoscroll while thinking content is generating."
|
||||||
|
**Story:** `.agent/user_stories/thinking-scroll-back.md`
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
The phase-17 per-chunk pin becomes a **gate**: the window follows the live tail only while the user is near its bottom; a scrolled-up user is never re-pinned; returning to the bottom re-arms the pin automatically.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `frontend/assets/app.js` —
|
||||||
|
- add (near the existing `SCROLL` constant, with the phase-18 comment block already removed by phase 42):
|
||||||
|
```js
|
||||||
|
/* Thinking-window follow-the-tail contract (owner direction
|
||||||
|
* 2026-08-27, `TODO.md` L7): the scratchpad autoscrolls to its live
|
||||||
|
* tail only while the user is pinned near the window's bottom —
|
||||||
|
* the 32px band is the "window bottom in view" threshold. Scrolling
|
||||||
|
* up pauses the follow; returning to the bottom resumes it (the
|
||||||
|
* check runs on every chunk). Exported so the band is unit-pinned
|
||||||
|
* (same pattern as TURN_TIMEOUT_MS). */
|
||||||
|
export const THINKING_NEAR_BOTTOM_PX = 32;
|
||||||
|
|
||||||
|
function isThinkingNearBottom(textEl) {
|
||||||
|
return (
|
||||||
|
textEl.scrollHeight - textEl.scrollTop - textEl.clientHeight <=
|
||||||
|
THINKING_NEAR_BOTTOM_PX
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- in the `thinking` SSE handler, replace the phase-17 block:
|
||||||
|
```js
|
||||||
|
if (block.open) {
|
||||||
|
textEl.scrollTop = textEl.scrollHeight; // pin the stream to the bottom
|
||||||
|
}
|
||||||
|
```
|
||||||
|
(phase 42 already removed the `scrollReveal(wrap)` line there) with the gated pin:
|
||||||
|
```js
|
||||||
|
if (block.open && isThinkingNearBottom(textEl)) {
|
||||||
|
// Follow the live tail only while the user is pinned to the window
|
||||||
|
// bottom (owner direction 2026-08-27); a scrolled-up reader is
|
||||||
|
// never re-pinned — returning to the bottom re-arms the pin.
|
||||||
|
textEl.scrollTop = textEl.scrollHeight;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- everything else in the handler (acc, sawThinking, clearTurnTimeout, ensureThinkingBlock, `textEl.innerHTML = renderMarkdown(thinkingAcc)`) stays byte-identical.
|
||||||
|
2. `tests/unit/test_thinking_scroll.py` — extend (from task 01) with the JS pins:
|
||||||
|
- `app.js` exports `const THINKING_NEAR_BOTTOM_PX = 32`;
|
||||||
|
- `isThinkingNearBottom` computes `scrollHeight - scrollTop - clientHeight <= THINKING_NEAR_BOTTOM_PX`;
|
||||||
|
- the thinking handler's pin is gated — the pin line is preceded by `isThinkingNearBottom(textEl)` in the same `if` (assert the combined condition; assert there is **no** unconditional `if (block.open) { textEl.scrollTop = ... }` left);
|
||||||
|
- `block.open` is still part of the gate (closed blocks never pin);
|
||||||
|
- the restore path renders collapsed blocks (phase 17) — keep the surviving assertion from task 01.
|
||||||
|
3. `uv run pytest` green at this checkpoint (E2E not run by the unit gate; the replacement suite lands in task 03).
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Unit: the extended pin file; full suite green.
|
||||||
|
- Coverage: **>90%** on `app/` (unchanged).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] The pin fires only when the block is open **and** the window is within 32px of its bottom; scrolled-up users are never re-pinned; the gate re-arms on return (by construction — the check runs per chunk).
|
||||||
|
- [ ] `uv run pytest` green at this checkpoint.
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Task 03 — Thinking-scroll E2E (replaces phase 21) + regressions + commit
|
||||||
|
|
||||||
|
**Phase:** `43_thinking_scroll_back` · **Source:** `TODO.md:7` — "Add scrolling back to the thinking block, but have it autoscroll while thinking content is generating."
|
||||||
|
**Story:** `.agent/user_stories/thinking-scroll-back.md`
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Prove the full contract in the browser — user scroll restored, follow-while-pinned, pause-on-scroll-up, resume-on-return, CSS contract, and the phase-11/17 regressions — then commit the phase.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `tests/e2e/test_thinking_scroll.py` (new) — mock-only, DB up. Reuse the phase-21 determinism machinery (`mock_llm.compose_thinking` is already ~2 700 chars ≈ 4.5 s of paced frames, overflowing the 320px window ~2×; the phase-20 hesitation trigger gives a deterministic 4 s frozen-tail state with the block open). Per the story's Playwright Mapping Rule:
|
||||||
|
- `test_thinking_window_user_scrollable` — frozen tail: focus `.thinking-text`, wheel up / `Home` / mouse-drag up → `scrollTop` moves and earlier content is visible;
|
||||||
|
- `test_thinking_window_follows_while_pinned` — live stream: at the window bottom, after the 2nd-to-last and the last chunk the window is pinned to the tail (±1px); the last chunk's text renders inside the visible rectangle;
|
||||||
|
- `test_thinking_window_stops_on_scroll_up` — mid-stream: scroll up ~half the window; over the next ≥5 chunks `scrollTop` stable (±1px);
|
||||||
|
- `test_thinking_window_resumes_on_return` — from the paused state, set `scrollTop` to the bottom; on the next chunk the window is re-pinned to the tail (±1px);
|
||||||
|
- `test_thinking_window_css_contract` — computed `overflow-y: auto`, `max-height: 320px`, `scrollHeight > clientHeight` (real clip);
|
||||||
|
- `test_answer_bubble_still_scrollable` (phase 11) — long answer: page scrolls, bubble overflow untouched;
|
||||||
|
- `test_restored_collapsed_thinking_unaffected` (phase 17) — settled thinking turn reloads collapsed with full text.
|
||||||
|
2. Regression pass (isolation runs): `test_thinking_display.py`, `test_chat_persistence.py`, `test_no_reply_autoscroll.py` (phase 42 — the cleaned `thinking` handler must not have lost the phase-42 contract), `test_smoke.py`.
|
||||||
|
3. `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` TOTAL unchanged; `uv run ruff check . && uv run pyright` clean.
|
||||||
|
4. Commit (Conventional Commits, `--no-gpg-sign`), e.g. `feat(chat): thinking window scrolls again, follows the tail only while pinned`, staging this phase's files **including the two deleted phase-21 test files** (`tests/unit/test_thinking_no_scroll.py`, `tests/e2e/test_thinking_no_scroll.py`) and the new unit + E2E files; move `.agent/phases/todo/43_thinking_scroll_back/` → `.agent/phases/complete/`.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- E2E: `uv run pytest tests/e2e/test_thinking_scroll.py -v --no-cov` green in isolation.
|
||||||
|
- Coverage: **>90%** on `app/` (unchanged — frontend-only phase).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] The new suite passes in isolation (all seven tests); the four regression suites pass in isolation.
|
||||||
|
- [ ] One atomic `--no-gpg-sign` commit covering both deleted and both new test files; phase dir moved to `.agent/phases/complete/`.
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Phase 44 — Markdown tables (chat, viewer, thinking)
|
||||||
|
|
||||||
|
**Source:** `TODO.md` L6 — "Certain markdown formatting isn't working - tables for example don't get rendered as tables in the chat response."
|
||||||
|
**Story:** `.agent/user_stories/markdown-tables.md`
|
||||||
|
**Context:** `frontend/assets/markdown.js` is the shared escape-first renderer (no libs, A11): fence protection → escape → inline transforms (`code`, `**bold**`, `*em*`, h1–h3, lists) → paragraph pass → fence restore. It has **no table support** — GFM pipe tables render as one raw `|`-littered paragraph. The renderer serves the chat answer, the document viewer/modal, and the thinking block, so one change covers all three.
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
GFM pipe tables render as semantic, styled, XSS-safe `<table>` elements everywhere the shared renderer runs, with a horizontal-overflow guard for wide tables.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
- `43_thinking_scroll_back` (todo) — sequential only (the thinking block also renders markdown; no shared-file conflict beyond the renderer itself).
|
||||||
|
- `08_story_dark_tech_theme` (complete) — the palette tokens `.md-table` must use.
|
||||||
|
- `26_document_modal_viewer` / `10_story_document_viewer` (complete) — the second renderer consumer (viewer/modal).
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
1. `01_table_renderer_and_styles.md` — table pass in `markdown.js` + `.md-table` CSS.
|
||||||
|
2. `02_mock_table_trigger.md` — deterministic table answer (incl. a wide table) in `mock_llm.py`.
|
||||||
|
3. `03_tables_e2e_and_commit.md` — unit pins + story E2E suite + regressions + commit.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Unit: new `tests/unit/test_markdown_tables.py` — source pins in the house style (regex over `markdown.js` / `styles.css`): the table-protection pass exists and runs **after** the fence pass and **before** the escape pass; cells are escaped + inline-transformed; output carries `class="md-table"`, `<thead>`, `th scope="col"`, and the `.md-table-wrap` wrapper; `styles.css` has the wrapper overflow rule + table borders + reduced-motion-relevant rules. (Behavior is browser-proven by the E2E; unit pins catch silent regressions without a browser — the established frontend pattern.)
|
||||||
|
- Coverage: frontend-only — `app/` TOTAL unchanged, >90%.
|
||||||
|
- E2E (mandatory, A16): `tests/e2e/test_markdown_tables.py`, run in isolation.
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] A pipe table in a chat answer renders `<div class="md-table-wrap"><table class="md-table">` with `<thead>`/`<tbody>`, `<th scope="col">` headers, correct cell texts; no raw `|---|` in the bubble.
|
||||||
|
- [ ] A wide table scrolls inside its wrapper; the 46rem column does not overflow the page.
|
||||||
|
- [ ] XSS-safe (escaped cells), fences win over tables, lone pipes stay text.
|
||||||
|
- [ ] The document viewer/modal renders the same table for a fixture document containing one.
|
||||||
|
- [ ] `uv run pytest` green; coverage TOTAL unchanged.
|
||||||
|
- [ ] `uv run pytest tests/e2e/test_markdown_tables.py -v --no-cov` green in isolation (DB up).
|
||||||
|
- [ ] Regression E2E suites green in isolation: `test_chat_rag.py`, `test_document_viewer.py`, `test_document_summaries.py`, `test_smoke.py`.
|
||||||
|
- [ ] `uv run ruff check . && uv run pyright` clean.
|
||||||
|
- [ ] One `--no-gpg-sign` commit; phase dir moved to `.agent/phases/complete/`.
|
||||||
|
|
||||||
|
## Locked decisions
|
||||||
|
- **A11 untouched** — still the local ~90-line renderer, no library, no CDN.
|
||||||
|
- **Owner-locked (2026-08-27, roadmap A3):** scope = GFM pipe tables (header + separator + body); links/blockquotes/hr out of scope; alignment colons parsed but rendered left; wide tables get the `overflow-x: auto` wrapper.
|
||||||
|
- **A16/A17 honoured** — one story E2E suite, one atomic commit.
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Task 01 — Table pass in the shared renderer + styles
|
||||||
|
|
||||||
|
**Phase:** `44_markdown_tables` · **Source:** `TODO.md:6` — "Certain markdown formatting isn't working - tables for example don't get rendered as tables in the chat response."
|
||||||
|
**Story:** `.agent/user_stories/markdown-tables.md`
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
`renderMarkdown` turns GFM pipe-table blocks into semantic tables (XSS-safe, inline markdown in cells), wrapped in a horizontal-overflow container, styled in the dark-tech palette.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `frontend/assets/markdown.js` — in `renderMarkdown(md)`, between step 1 (fence protection) and step 2 (escape + inline transforms), add a **table protection pass** using the same placeholder mechanism as the fences:
|
||||||
|
- **Detection** (line-oriented over the fence-protected text): a **table block** starts at a line containing `|` whose **next** line is a **separator** — the separator line consists of ≥1 pipe-separated cells, each matching `^\s*:?-+:?\s*$` (allowing a leading/trailing pipe and inter-cell whitespace). The block then extends over every following line that still contains `|` (the body; zero body rows is a valid table — header only). A maximal such block is one table. Anything else (a single `|` in prose, a separator with no `|`-header line above it, a 1-line "table") is left untouched.
|
||||||
|
- **Extraction:** for each table run, split each line on `|`, drop the leading/trailing empty entries produced by leading/trailing pipes, `trim()` each cell.
|
||||||
|
- **Cell rendering:** each cell goes through the same inline pipeline as the rest of the text — `escapeHtml(cell)` first (XSS-safe, invariant of the renderer), then the inline transforms (`` `code` ``, `**bold**`, `*em*` — the exact same `.replace` chain step 2 uses; factor the inline chain into a small local helper if it makes the cell path cleaner, keeping the whole-text path byte-identical in output).
|
||||||
|
- **Assembly:**
|
||||||
|
```html
|
||||||
|
<div class="md-table-wrap">
|
||||||
|
<table class="md-table">
|
||||||
|
<thead><tr><th scope="col">h1</th>…</tr></thead>
|
||||||
|
<tbody><tr><td>…</td>…</tr>…</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
Rows with fewer cells than the header are padded with empty `<td>`; rows with more are truncated to the header width (defensive — the mock and real answers are well-formed). Alignment colons in the separator are **parsed but ignored** (all cells left — owner decision).
|
||||||
|
- **Placeholders:** reuse the `\u0000CODEn\u0000` array pattern — e.g. push the table HTML into a second array and emit `\u0000TABLEn\u0000`, restored alongside the code blocks in step 4 (update the restore step accordingly; tables inside the protected span are already final HTML — they must not re-enter the paragraph pass, which the placeholder guarantees).
|
||||||
|
- Update the file's header comment (the ~60-line no-CDN renderer now also does tables — 2026-08-27, `TODO.md` L6).
|
||||||
|
2. `frontend/assets/styles.css` — near the markdown/content styling (the chat bubble content rules):
|
||||||
|
- `.md-table-wrap { overflow-x: auto; }` — the wrapper is the scroller;
|
||||||
|
- `.md-table { border-collapse: collapse; width: 100%; font-size: 0.9rem; }`;
|
||||||
|
- `.md-table th, .md-table td { border: 1px solid var(--line); padding: 0.4rem 0.6rem; text-align: left; vertical-align: top; }`;
|
||||||
|
- `.md-table thead th { background: <surface-darker token>; color: var(--ink); }` — pick the existing token that keeps ≥4.5:1 (PLAN §7.2: ink `#e8ebf4` on surface `#121a2e` is 14.5:1 — use the plain surface family, not brand);
|
||||||
|
- ensure the rule set is inside or consistent with the reduced-motion constraints (no animation involved — nothing to still).
|
||||||
|
3. Sanity: run an existing markdown-consuming E2E (e.g. `test_chat_rag.py`) to confirm byte-identical output for non-table content (the inline-chain factor, if done, must not change any existing rendering).
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Unit: `tests/unit/test_markdown_tables.py` (new) source pins per the phase overview (pass ordering, escape-first for cells, output markers, CSS rules). Full suite green.
|
||||||
|
- Coverage: **>90%** on `app/` (unchanged — frontend-only).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] `renderMarkdown` handles the shapes in the story's acceptance criteria 1–4 (table, XSS cell, fence-wins, non-tables stay text) — verifiable via the unit pins now and the E2E in task 03.
|
||||||
|
- [ ] No existing rendering changes for non-table markdown (regression suite from step 3 green).
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Task 02 — Deterministic table answer in the mock
|
||||||
|
|
||||||
|
**Phase:** `44_markdown_tables` · **Source:** `TODO.md:6` — "Certain markdown formatting isn't working - tables for example don't get rendered as tables in the chat response."
|
||||||
|
**Story:** `.agent/user_stories/markdown-tables.md`
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
The E2E mock serves a byte-stable table answer (plus a deliberately wide table and an XSS cell) on demand, following the existing trigger convention.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `tests/e2e/mock_llm.py` —
|
||||||
|
- add `TABLE_TRIGGER = "show me a table"` (same case-insensitive-substring convention as `LONG_ANSWER_TRIGGER` / `THINKING_TRIGGER` / `TOOLS_TRIGGER`);
|
||||||
|
- in `compose_answer(body)`, **before** the default tail-echo branch (and before `DEFLECT_MODE` — a deflection prompt never carries the marker, same reasoning as `SUMMARY_MODE`): when the trigger is in the lowercased user message, return the fixed table answer:
|
||||||
|
```
|
||||||
|
Here's the shape, in a table:
|
||||||
|
|
||||||
|
| Service | Port | Host |
|
||||||
|
|---|---|---|
|
||||||
|
| Caddy | 80 | homelab-gw |
|
||||||
|
| GitLab | 8929 | homelab-git |
|
||||||
|
| ntfy | 2087 | homelab-ntfy |
|
||||||
|
|
||||||
|
<img src=x onerror=alert(1)>
|
||||||
|
|
||||||
|
And the wide one:
|
||||||
|
|
||||||
|
| A very long column header to force overflow | Second column with some padding text | Third column | Fourth | Fifth |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| value-one | value-two | value-three | value-four | value-five |
|
||||||
|
```
|
||||||
|
(The `<img onerror>` line is the XSS assertion's payload — it must survive the mock byte-for-byte so the E2E can prove the renderer neutralizes it; the wide table guarantees `scrollWidth > clientWidth` inside the 46rem column.)
|
||||||
|
- keep the answer a plain grounded response (no `DEFLECT_MODE` interplay): the trigger question is asked against an on-topic fixture so the honesty gate is HIGH in the E2E (the suite asserts non-deflection as part of the table test).
|
||||||
|
2. Update the module docstring's marker list (the file documents every trigger — add the table row).
|
||||||
|
3. `uv run pytest tests/e2e/mock_llm.py-related unit tests` — run `uv run pytest tests/unit -k "mock" tests/integration -x` (or the mock's existing test file, if any — check `tests/` for mock-specific tests) to prove the new branch breaks no existing flow; the full suite is green (the new branch only fires on the marker).
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Unit/integration: full suite green; the new branch is covered by the E2E (task 03) — if a mock-level unit test file exists, add the table case there so the branch is unit-covered too.
|
||||||
|
- Coverage: **>90%** on `app/` (mock lives in `tests/` — the gate is unchanged).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] `TABLE_TRIGGER` returns the fixed table answer (byte-stable), including the XSS line and the wide table; no existing mock behavior changes for marker-less requests.
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Task 03 — Tables E2E + regressions + commit
|
||||||
|
|
||||||
|
**Phase:** `44_markdown_tables` · **Source:** `TODO.md:6` — "Certain markdown formatting isn't working - tables for example don't get rendered as tables in the chat response."
|
||||||
|
**Story:** `.agent/user_stories/markdown-tables.md`
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Prove the table contract in the browser — chat, overflow, XSS, viewer, and the two "not a table" regressions — then commit the phase.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `tests/e2e/test_markdown_tables.py` (new) — mock-only, DB up, per the story's Playwright Mapping Rule:
|
||||||
|
- `test_chat_table_renders` — ask an on-topic question containing `TABLE_TRIGGER` (pick a fixture topic that retrieves HIGH — reuse a question pattern from `test_chat_rag.py`); the brain bubble contains `<div class="md-table-wrap"><table class="md-table">`, a `<thead>` with three `<th scope="col">` (Service/Port/Host), the body cell texts ("Caddy", "8929", …), and **no** `|---|` separator text in the bubble;
|
||||||
|
- `test_wide_table_scrolls` — in the same answer, the wide table's wrapper has `scrollWidth > clientWidth`; horizontal scrolling (wheel/`scrollLeft`) moves it; the page itself has no horizontal overflow (`document.documentElement.scrollWidth <= clientWidth`);
|
||||||
|
- `test_table_xss_safe` — the `<img src=x onerror=…>` line renders as visible text (no `<img>` element inside the bubble; `onerror` can never fire — assert `page.evaluate` found zero injected img nodes and the tag text is present);
|
||||||
|
- `test_viewer_table_renders` — add a fixture document (extend `tests/fixtures/docs/homelab/` with a small `.md` file containing a pipe table — e.g. `tables.md` with a 3×3 table; re-import per the `test_document_documents.py`/`test_import_documents.py` fixture pattern), open it from the Sources table (admin) in the modal; the modal content renders `<table class="md-table">`;
|
||||||
|
- `test_fence_not_a_table` — a question/fixture whose content puts `|`-heavy lines inside a ``` fence (existing fixtures have fenced blocks — pick/extend one) renders `<pre><code>` with no `<table>`;
|
||||||
|
- `test_plain_pipe_stays_text` — an off-trigger grounded answer containing a single `|` in prose (assert via an existing deterministic answer or a minimal new fixture) renders as text, no `<table>`.
|
||||||
|
- Assert non-deflection (`.is-deflected` absent) in the table tests — the honesty gate interplay is part of the contract.
|
||||||
|
2. Regression pass (isolation runs): `test_chat_rag.py`, `test_document_viewer.py`, `test_document_summaries.py` (the renderer is shared — summaries render through it too), `test_smoke.py`.
|
||||||
|
3. `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` TOTAL unchanged; `uv run ruff check . && uv run pyright` clean.
|
||||||
|
4. Commit (Conventional Commits, `--no-gpg-sign`), e.g. `feat(chat): render markdown tables in answers, viewer, and thinking`, staging this phase's files; move `.agent/phases/todo/44_markdown_tables/` → `.agent/phases/complete/`.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- E2E: `uv run pytest tests/e2e/test_markdown_tables.py -v --no-cov` green in isolation.
|
||||||
|
- Coverage: **>90%** on `app/` (unchanged — frontend-only phase).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] The story E2E suite passes in isolation (all six tests); the four regression suites pass in isolation.
|
||||||
|
- [ ] One atomic `--no-gpg-sign` commit; phase dir moved to `.agent/phases/complete/`.
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Phase 45 — Agent makes as many tool calls as it wants
|
||||||
|
|
||||||
|
**Source:** `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"
|
||||||
|
**Story:** `.agent/user_stories/agent-unlimited-tools.md`
|
||||||
|
**Context:** Phase 37 shipped the grounded-turn agent loop (`app/rag/agent.py::run_agent`) with per-turn budgets — `agent_list_calls` / `agent_read_calls` (default 1 each, `BOR_AGENT_LIST_CALLS` / `BOR_AGENT_READ_CALLS`), "budgets-as-kill-switch" locked decision. The exhaustion refusals (`LIST_EXHAUSTED` / `READ_EXHAUSTED`) are where correct multi-document answers die. Owner direction (2026-08-27): remove both budgets; the loop keeps one guard — a configurable **round cap** that also doubles as the no-tools kill switch (`0`).
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
`list_documents` / `read_document` can be called as many times as the model needs (re-lists included), bounded only by `BOR_AGENT_MAX_ROUNDS` (default 10; `0` = no tools, byte-identical to the pre-phase-37 path).
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
- `44_markdown_tables` (todo) — sequential only (no shared files: this phase is `app/` + tests + mock).
|
||||||
|
- `37_agent_document_tools` (complete) — the loop, the `tool` SSE event, the UI tool lines, the per-turn `tool_calls=N` log field, and the phase-37 locked decision being revised.
|
||||||
|
- `31_kb_overview_prompt` (complete) — the `lite` one-shot path is untouched by this phase.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
1. `01_config_round_cap.md` — the server core, atomically: `agent_max_rounds` replaces the budgets in `app/config.py` + `app/rag/agent.py`, unit + integration rewrites, `.env.example` (one task so the per-task gate stays green).
|
||||||
|
2. `02_mock_multi_read_flow.md` — the E2E mock's deterministic multi-read (list → read #1 → read #2 → answer) flow.
|
||||||
|
3. `03_unlimited_tools_e2e_and_commit.md` — story E2E + phase-37 regression + PLAN.md revision note + commit.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Unit: `tests/unit/test_agent.py` rewritten around the round cap (always-calling mock LLM: N tool rounds then a forced `tools=None` final answer; `max_rounds=0` → exactly one request with `tools=None`; rejected-call spam — unknown tool / already-in-context — is bounded by the cap, not by budgets; re-lists execute and count in `tool_calls`); `tests/unit/test_config.py` (default 10, `BOR_AGENT_MAX_ROUNDS` override, `0`, the budget env vars are gone).
|
||||||
|
- Integration: `tests/integration/test_chat_api.py` — the `agent_list_calls=0, agent_read_calls=0` fixtures become `agent_max_rounds=0`; the tool SSE event shape and the `done.sources` extension assertions stay.
|
||||||
|
- Coverage: **>90%** on `app/` — `agent.py` + `config.py` fully covered.
|
||||||
|
- E2E (mandatory, A16): `tests/e2e/test_agent_unlimited_tools.py`, run in isolation.
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] `BOR_AGENT_LIST_CALLS` / `BOR_AGENT_READ_CALLS` are gone (config, `.env.example`, agent, tests); no exhaustion refusal remains.
|
||||||
|
- [ ] A multi-read turn (list + 2 reads) streams three tool lines, answers non-deflected, and `done.sources` lists the retrieved doc(s) + both reads deduped.
|
||||||
|
- [ ] `agent_max_rounds=0` → single `tools=None` request (kill switch); at the cap the loop forces a final no-tools answer (log warning kept).
|
||||||
|
- [ ] `tool` SSE event shape and `tool_calls=N` per-turn log field unchanged.
|
||||||
|
- [ ] `.agent/PLAN.md` carries the phase-37 revision note (owner permission 2026-08-27, `TODO.md` L8) — the only PLAN edit in this phase.
|
||||||
|
- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` >90%.
|
||||||
|
- [ ] `uv run pytest tests/e2e/test_agent_unlimited_tools.py -v --no-cov` green in isolation (DB up).
|
||||||
|
- [ ] Regression E2E suites green in isolation: `test_agent_document_tools.py`, `test_chat_rag.py`, `test_smoke.py`.
|
||||||
|
- [ ] `uv run ruff check . && uv run pyright` clean.
|
||||||
|
- [ ] One `--no-gpg-sign` commit; phase dir moved to `.agent/phases/complete/`.
|
||||||
|
|
||||||
|
## Locked decisions
|
||||||
|
- **Owner-locked revision (2026-08-27, roadmap R2):** the phase-37 "budgets-as-kill-switch" decision is **revised** — both per-tool budgets removed; `BOR_AGENT_MAX_ROUNDS` (default 10) is the only loop guard and the kill switch (`0`). Recorded as a PLAN.md revision note (the established owner-permission pattern, like the A10/A7/A9/A15 notes) — a recorded revision, not a silent deviation (AGENTS.md rule 3).
|
||||||
|
- **A15 extension unchanged** — the `tool` SSE event shape, the `done` shape, and the per-turn log line (`tool_calls=N`) are untouched; the revision note amends the phase-37 note's budget wording only.
|
||||||
|
- **Rejections kept:** `Unknown tool.`, `MISSING_READ_ARGS`, `Already in your context.` (non-budget rejections; the cap bounds their pathological repetition).
|
||||||
|
- **A17 honoured** — one atomic commit.
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Task 03 — Unlimited-tools E2E + PLAN revision note + commit
|
||||||
|
|
||||||
|
**Phase:** `45_agent_unlimited_tools` · **Source:** `TODO.md:8` — "Allow the LLM to make as many tool calls as it wants, remove the restrictions, they're causing problems getting correct answers"
|
||||||
|
**Story:** `.agent/user_stories/agent-unlimited-tools.md`
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Prove the multi-tool turn end to end, record the phase-37 decision revision in PLAN.md, run the regressions, and commit the phase.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `tests/e2e/test_agent_unlimited_tools.py` (new) — mock-only, DB up (the grounded-turn prerequisite: the fixture KB imported, per the `test_agent_document_tools.py` fixture pattern):
|
||||||
|
- `test_multi_read_turn` — a grounded question carrying `TOOLS_TRIGGER` + `MULTI_READ_TRIGGER`: the turn streams **three** tool lines (`.tool-call` rows: one `list_documents` — "is listing documents" — and two `read_document` — "is reading <source/path>") in order, then a final non-deflected answer containing the mock's "I read <path1> and <path2>." line;
|
||||||
|
- `test_done_sources_include_reads` — the source chips under the answer list the retrieval doc(s) **plus both** read documents, deduped (the phase-37 `done.sources` extension contract, now with 2 reads);
|
||||||
|
- `test_relist_allowed` — the listing tool ran without a "No listing budget left" refusal: assert no refusal text anywhere in the bubble/tool lines (the old refusal strings must be gone — grep the app for them is task 01's job; here assert the UI never shows one);
|
||||||
|
- `test_single_tool_flow_regression` (phase 37) — the original 3-step flow (marker without the multi-read trigger) still answers after exactly one read with its single tool pair (this may be a targeted re-assertion; the full suite `test_agent_document_tools.py` runs in the regression pass).
|
||||||
|
2. `.agent/PLAN.md` — **the only PLAN edit in this phase** (owner-locked revision, roadmap R2): in the §4 SSE-revision block, after the phase-37 revision note, add a new note in the established style:
|
||||||
|
> **SSE revision (phase 45, owner permission 2026-08-27):** the phase-37
|
||||||
|
> per-turn tool budgets are **removed** (owner: "allow the LLM to make
|
||||||
|
> as many tool calls as it wants — `TODO.md` L8): `BOR_AGENT_LIST_CALLS`
|
||||||
|
> / `BOR_AGENT_READ_CALLS` no longer exist; `BOR_AGENT_MAX_ROUNDS`
|
||||||
|
> (default 10) caps the tool rounds and `0` disables the tools
|
||||||
|
> entirely (the pre-phase-37 path). The `tool` event shape and the
|
||||||
|
> `done` shape are unchanged — a recorded revision of the phase-37
|
||||||
|
> note's budget wording, not a silent deviation.
|
||||||
|
Also update the phase-37 note's budget clause if it reads as current
|
||||||
|
truth ("budgeted by `BOR_AGENT_LIST_CALLS` / `BOR_AGENT_READ_CALLS`")
|
||||||
|
by appending "(removed in phase 45 — see the revision note below)".
|
||||||
|
Touch **nothing else** in PLAN.md (Protocol B: no roadmap-table edit for appended phases).
|
||||||
|
3. Regression pass (isolation runs): `test_agent_document_tools.py` (phase 37 — must pass **unmodified**), `test_chat_rag.py`, `test_smoke.py`.
|
||||||
|
4. `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` >90%; `uv run ruff check . && uv run pyright` clean.
|
||||||
|
5. Commit (Conventional Commits, `--no-gpg-sign`), e.g. `feat(rag): unbounded agent tool calls behind a round cap (owner revision)`, staging this phase's files **including the force-added `.agent/PLAN.md`** (AGENTS.md rule 8: `git add -f .agent/PLAN.md`) and the phase dir move `.agent/phases/todo/45_agent_unlimited_tools/` → `.agent/phases/complete/`.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- E2E: `uv run pytest tests/e2e/test_agent_unlimited_tools.py -v --no-cov` green in isolation.
|
||||||
|
- Coverage: **>90%** on `app/` (task 01's rewritten tests carry it).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] The multi-read E2E suite passes in isolation; the phase-37 suite passes unmodified in isolation.
|
||||||
|
- [ ] PLAN.md carries the phase-45 revision note (owner permission 2026-08-27) and nothing else changed.
|
||||||
|
- [ ] One atomic `--no-gpg-sign` commit; phase dir moved to `.agent/phases/complete/`.
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Phase 47 — Import quadlet + jinja files
|
||||||
|
|
||||||
|
**Source:** `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"
|
||||||
|
**Story:** `.agent/user_stories/quadlet-jinja-import.md`
|
||||||
|
**Context:** A9 (LOCKED, revised 2026-08-21): default import formats `md, markdown, txt, yaml, yml, json, py`; `app/config.py::_ALLOWED_IMPORT_EXTENSIONS` bounds `BOR_IMPORT_EXTENSIONS` (narrow-only); `app/rag/chunker.py::_FORMAT_CHUNKERS` maps suffix → chunker (unknown suffix → `chunk_text` fallback). The owner-locked revision (2026-08-27, roadmap R1): ten new formats join the allowed **and** default set — the full Podman quadlet family (`container, network, volume, image, pod, kube, swap, os, endpoint`) plus `j2` — chunked as plain text.
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Quadlet unit files and Jinja templates are indexed like any other A9 format: allowed + default in config, dispatched to plain-text chunking, and provable end to end (import → catalog → viewer → retrieval).
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
- `46_mobile_hamburger_nav` (todo) — sequential only (no shared files: this phase is `app/` + `scripts/` + tests + fixtures + docs).
|
||||||
|
- `38_local_directory_sources` / `28_git_based_sources` (complete) — the import path the new formats ride (`import_sources` / sync).
|
||||||
|
- `02_story_import_documents` (complete) — the A9 format machinery (walk, title, delta, prune) the new formats inherit unchanged.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
1. `01_config_formats.md` — allowed + default extension sets, `.env.example`, config unit tests.
|
||||||
|
2. `02_chunker_dispatch_fixtures.md` — chunker dispatch for the ten suffixes + the fixture files.
|
||||||
|
3. `03_importer_integration.md` — importer walk/delta/prune parity + integration test.
|
||||||
|
4. `04_quadlet_e2e_and_docs_commit.md` — story E2E + README + PLAN A9 revision note + commit.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Unit: `test_config.py` (allowed set contains all ten; the default CSV carries them after the original seven; the env validator accepts the new names and still rejects unknown ones; `import_extension_set` dotted form); `test_chunker.py` (dispatch for **every** new suffix → `chunk_text` semantics: a quadlet TOML fixture and a jinja fixture chunk under `HARD_MAX_CHARS`, paragraph packing behaves); `test_importer.py` (a directory walk with the new files indexes them; hidden dirs + exclusions still filter).
|
||||||
|
- Integration: import over a temp tree with quadlet+j2 files → `documents` + `chunks` rows, delta re-import idempotent.
|
||||||
|
- Coverage: **>90%** on `app/` — config/chunker/importer changes fully covered.
|
||||||
|
- E2E (mandatory, A16): `tests/e2e/test_quadlet_jinja_import.py`, run in isolation.
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] A default-extensions import indexes `.container` / `.network` / `.volume` / `.image` / `.pod` / `.kube` / `.swap` / `.os` / `.endpoint` / `.j2` files (fixture-proven); no env configuration needed.
|
||||||
|
- [ ] The Sources table lists them; the viewer shows a `.container` file's TOML content with the stem as title.
|
||||||
|
- [ ] A question containing a `.j2` sentinel is non-deflected with the `.j2` doc as a source chip (A8 FTS-OR honesty gate).
|
||||||
|
- [ ] `BOR_IMPORT_EXTENSIONS` still rejects truly unknown extensions (validator intact).
|
||||||
|
- [ ] README + `.env.example` + PLAN.md A9 revision note record the extended set.
|
||||||
|
- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` >90%.
|
||||||
|
- [ ] `uv run pytest tests/e2e/test_quadlet_jinja_import.py -v --no-cov` green in isolation (DB up).
|
||||||
|
- [ ] Regression E2E suites green in isolation: `test_import_documents.py`, `test_sync_button.py`, `test_git_sources_admin.py`.
|
||||||
|
- [ ] `uv run ruff check . && uv run pyright` clean.
|
||||||
|
- [ ] One `--no-gpg-sign` commit; phase dir moved to `.agent/phases/complete/`.
|
||||||
|
|
||||||
|
## Locked decisions
|
||||||
|
- **Owner-locked revision (2026-08-27, roadmap R1):** A9's format set is **revised** — ten formats added to the allowed + default set (the full quadlet family + `j2`); plain-text chunking (no TOML/Jinja-aware splitters); recorded as a PLAN.md A9 revision note with owner permission — a recorded revision, not a silent deviation (AGENTS.md rule 3).
|
||||||
|
- **A9 invariants kept:** hidden (dot) directories still skipped; the exclusion list unchanged; narrow-only `BOR_IMPORT_EXTENSIONS` validator; sha256 delta / prune unchanged; `HARD_MAX_CHARS` (1200) honored by the `chunk_text` dispatch.
|
||||||
|
- **A17 honoured** — one atomic commit.
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Task 04 — Quadlet/jinja E2E + docs (README, PLAN A9 revision) + commit
|
||||||
|
|
||||||
|
**Phase:** `47_quadlet_jinja_import` · **Source:** `TODO.md:10–11` — "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"
|
||||||
|
**Story:** `.agent/user_stories/quadlet-jinja-import.md`
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Prove the story end to end (import → catalog → Sources table → viewer → FTS retrieval), record the A9 revision in the docs, run the regressions, and commit the phase.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `tests/e2e/test_quadlet_jinja_import.py` (new) — mock-only, DB up, per the story's Playwright Mapping Rule (the import happens out-of-band against the session mock, exactly like `test_import_documents.py`: truncate the KB, `import_sources([FIXTURES], LLMClient(settings))` in a thread — reuse that file's helpers/pattern; the task-02 fixtures are already in the tree):
|
||||||
|
- `test_quadlet_and_jinja_indexed` — after the module import, `GET /api/docs` lists `quadlet/compose.container`, `quadlet/lan.network`, `quadlet/cache.volume`, `templates/deploy.j2`, each with a non-zero chunk count and the stem as title;
|
||||||
|
- `test_sources_table_shows_them` — admin: `/sources.html` renders rows for the four files (path links present, `.doc-link`);
|
||||||
|
- `test_container_content_viewable` — open `compose.container` from the Sources table (modal, phase 26): the content area contains the `[Container]` section text and the `RESE-QUADLET-SENTINEL-77aa` sentinel; the title is the stem (`compose`);
|
||||||
|
- `test_jinja_retrievable_not_deflected` — ask a question containing `RESE-JINJA-SENTINEL-33dd` (the A8 gate: an FTS hit among the candidates keeps it honest-positive — LOW requires **zero** FTS hits): the brain bubble is **not** `.is-deflected` and a source chip names `templates/deploy.j2` (the mock's answer shape is deterministic; the assertion is on the gate + the chips, not the prose).
|
||||||
|
- module fixture: truncate `documents`/`chunks`/`query_log` per test module (the house E2E pattern) and re-import — note: this file's re-import changes the KB for the session; it is run in **isolation** (A16), so no cross-suite interference.
|
||||||
|
2. **Docs:**
|
||||||
|
- `README.md` — wherever the import format list is documented (the "Import & update" section mirrors PLAN §11 / A9), extend it with the ten new formats (the 2026-08-27 A9 revision, plain-text chunking);
|
||||||
|
- `.agent/PLAN.md` — **the only PLAN edit in this phase** (owner-locked revision R1): in the §2 anchors table, the A9 row's decision text gains the extension — append to the A9 row (keep the original wording, mark the revision in the row's notes/status or in a revision note under the table, the established style): "**A9 revision (phase 47, owner permission 2026-08-27):** the format set extends with the Podman quadlet family (`container, network, volume, image, pod, kube, swap, os, endpoint`) and `j2` (Jinja templates) — plain-text chunking (`chunk_text`), owner: `TODO.md` L10–L11. The narrow-only `BOR_IMPORT_EXTENSIONS` rule and the hidden-dir/exclusion invariants are unchanged." Update PLAN §5's chunking-policy format line and §11's workflow line to list the extended set (same note style). Touch **nothing else** in PLAN.md.
|
||||||
|
3. Regression pass (isolation runs): `test_import_documents.py` (task 02's count-constant update must hold — the tree now has four more files), `test_sync_button.py`, `test_git_sources_admin.py`.
|
||||||
|
4. `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` >90%; `uv run ruff check . && uv run pyright` clean.
|
||||||
|
5. Commit (Conventional Commits, `--no-gpg-sign`), e.g. `feat(import): index quadlet unit files and jinja templates (A9 revision)`, staging this phase's files **including the force-added `.agent/PLAN.md`** (AGENTS.md rule 8) and the phase dir move `.agent/phases/todo/47_quadlet_jinja_import/` → `.agent/phases/complete/`.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- E2E: `uv run pytest tests/e2e/test_quadlet_jinja_import.py -v --no-cov` green in isolation.
|
||||||
|
- Coverage: **>90%** on `app/` (tasks 01–03 carry it).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] The story E2E suite passes in isolation (all four tests); the three regression suites pass in isolation.
|
||||||
|
- [ ] README + `.env.example` (task 01) + PLAN.md (A9 row + §5 + §11) record the extended set; no other PLAN change.
|
||||||
|
- [ ] One atomic `--no-gpg-sign` commit; phase dir moved to `.agent/phases/complete/`.
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
# 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).
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
# 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>`.
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
# 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).
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# 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).
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# 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".
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# 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.
|
||||||
+13
-3
@@ -38,11 +38,10 @@ BOR_HYBRID_LEXICAL_CANDIDATES=30 # FTS list width for the fusion
|
|||||||
BOR_RRF_K=60 # Reciprocal Rank Fusion damping constant
|
BOR_RRF_K=60 # Reciprocal Rank Fusion damping constant
|
||||||
|
|
||||||
# --- Agent document tools (phase 37: grounded turns may list + read) ---
|
# --- Agent document tools (phase 37: grounded turns may list + read) ---
|
||||||
BOR_AGENT_LIST_CALLS=1 # per-turn list_documents opportunities (0 disables the tool)
|
# BOR_AGENT_MAX_ROUNDS=10 # hard cap on agent tool rounds per turn (0 = no tools)
|
||||||
BOR_AGENT_READ_CALLS=1 # per-turn read_document opportunities (0 disables the tool)
|
|
||||||
|
|
||||||
# --- Import scope (A9 formats; may only narrow, never widen) ---
|
# --- Import scope (A9 formats; may only narrow, never widen) ---
|
||||||
# BOR_IMPORT_EXTENSIONS=md,markdown,txt,yaml,yml,json,py
|
# BOR_IMPORT_EXTENSIONS=md,markdown,txt,yaml,yml,json,py,container,network,volume,image,pod,kube,swap,os,endpoint,j2
|
||||||
# BOR_SUGGESTIONS=["How is my Kubernetes cluster set up?"] # JSON list of onboarding chips
|
# BOR_SUGGESTIONS=["How is my Kubernetes cluster set up?"] # JSON list of onboarding chips
|
||||||
|
|
||||||
# --- Import sources (git; phase 28, admin-managed since phase 35) ---
|
# --- Import sources (git; phase 28, admin-managed since phase 35) ---
|
||||||
@@ -67,6 +66,17 @@ BOR_AGENT_READ_CALLS=1 # per-turn read_document opportunities (0 disa
|
|||||||
# BOR_GIT_SOURCES=https://github.com/user/homelab.git,https://github.com/user/deployments.git
|
# BOR_GIT_SOURCES=https://github.com/user/homelab.git,https://github.com/user/deployments.git
|
||||||
# BOR_SOURCES_DIR=~/bor-sources
|
# BOR_SOURCES_DIR=~/bor-sources
|
||||||
|
|
||||||
|
# Phase 49: uploaded source archives (the Sources page upload form).
|
||||||
|
# An uploaded .tar / .tar.gz / .tgz / .zip is unpacked to
|
||||||
|
# BOR_UPLOAD_DIR/<name>/ where <name> is the filename minus the archive
|
||||||
|
# suffix (homelab.tar.gz -> homelab/). Re-uploading the same name
|
||||||
|
# replaces the folder's content IN PLACE — one folder, one row, no
|
||||||
|
# missing window; a failed upload never touches the existing folder,
|
||||||
|
# row, or KB. Deliberately separate from BOR_SOURCES_DIR (git checkouts).
|
||||||
|
# BOR_UPLOAD_DIR=~/bor-sources/uploads
|
||||||
|
# BOR_UPLOAD_MAX_MB=512 # caps BOTH the compressed upload and the total
|
||||||
|
# extracted bytes (zip-bomb guard); must be > 0
|
||||||
|
|
||||||
# --- Admin & sign-in (single-admin password login; BOTH required) ---
|
# --- Admin & sign-in (single-admin password login; BOTH required) ---
|
||||||
# The app refuses to start while either is empty (names the missing
|
# The app refuses to start while either is empty (names the missing
|
||||||
# variable(s) — README "Admin & sign-in"). Generate the secret with:
|
# variable(s) — README "Admin & sign-in"). Generate the secret with:
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ uv run python -m scripts.llm_probe # sanity: models + 768-dim check
|
|||||||
uv run python -m scripts.import_docs # import the configured sources (below)
|
uv run python -m scripts.import_docs # import the configured sources (below)
|
||||||
```
|
```
|
||||||
|
|
||||||
Two managed source kinds (one page, one registry) plus a manual override:
|
Managed source kinds (one page, one registry) plus a manual override:
|
||||||
|
|
||||||
- **Git sources** — managed on the **admin Git sources page**
|
- **Git sources** — managed on the **admin Git sources page**
|
||||||
(`/git-sources.html`) and stored in Postgres (see
|
(`/git-sources.html`) and stored in Postgres (see
|
||||||
@@ -73,11 +73,19 @@ Two managed source kinds (one page, one registry) plus a manual override:
|
|||||||
checkouts. While the stored list is empty, the `BOR_GIT_SOURCES` variable
|
checkouts. While the stored list is empty, the `BOR_GIT_SOURCES` variable
|
||||||
in `.env` is the fallback — the moment the page stores a source, the
|
in `.env` is the fallback — the moment the page stores a source, the
|
||||||
variable is ignored.
|
variable is ignored.
|
||||||
|
- **Archive upload sources** (phase 49) — a `.tar`/`.tar.gz`/`.tgz`/`.zip`
|
||||||
|
uploaded on the *same* admin page (see
|
||||||
|
[Archive upload sources](#archive-upload-sources)). The archive is
|
||||||
|
unpacked under `BOR_UPLOAD_DIR/<name>/` and scanned immediately; it is
|
||||||
|
registered as a `kind=local` row, like a local directory.
|
||||||
- **Local directory sources** (phase 38) — an existing, non-git directory
|
- **Local directory sources** (phase 38) — an existing, non-git directory
|
||||||
on the server, registered on the *same* admin page (see
|
on the server, registered on the *same* admin page (see
|
||||||
[Local directory sources](#local-directory-sources)). No clone, no
|
[Local directory sources](#local-directory-sources)). No clone, no
|
||||||
checkout copy: the directory is walked in place. There is **no env var
|
checkout copy: the directory is walked in place. There is **no env var
|
||||||
for local paths** — the DB is the registry.
|
for local paths** — the DB is the registry. Since phase 49 the page's
|
||||||
|
“Add a local directory” form is gone (the archive upload replaced it):
|
||||||
|
a plain directory is registered via `POST /api/git-sources` with
|
||||||
|
`kind=local`; existing Local rows are unchanged.
|
||||||
- **Manual directories** — `--source <path>` (repeatable) imports local
|
- **Manual directories** — `--source <path>` (repeatable) imports local
|
||||||
directories directly and *always wins* over the stored sources (git and
|
directories directly and *always wins* over the stored sources (git and
|
||||||
local) and the env fallback.
|
local) and the env fallback.
|
||||||
@@ -115,16 +123,24 @@ uv run uvicorn app.main:app --reload
|
|||||||
open to everyone).
|
open to everyone).
|
||||||
- **Git sources** (`/git-sources.html`) — the admin-managed source
|
- **Git sources** (`/git-sources.html`) — the admin-managed source
|
||||||
registry: the git repositories the **Sync sources** button clones and
|
registry: the git repositories the **Sync sources** button clones and
|
||||||
indexes, **and** existing local directories it imports directly
|
indexes, uploaded archives it unpacks and scans, **and** existing local
|
||||||
(phase 38 — one table with a `kind` discriminator, one page); **admin-only**
|
directories it imports directly (one table with a `kind` discriminator,
|
||||||
(the same sign-in gate as Sources). Add or remove sources here — no
|
one page); **admin-only** (the same sign-in gate as Sources). Add or
|
||||||
`.env` editing, no restart. A local directory must be an absolute,
|
remove sources here — no `.env` editing, no restart. The **archive
|
||||||
existing directory at add-time (a missing/relative path is rejected
|
upload form** (phase 49) accepts `.tar`, `.tar.gz`, `.tgz`, `.zip`: the
|
||||||
inline, naming the path; so are duplicates); list rows carry a **Git**
|
archive is unpacked under `BOR_UPLOAD_DIR/<name>/` (name = filename
|
||||||
or **Local** badge. Adding/removing does not clone or prune on its
|
minus the archive suffix) and scanned immediately — re-uploading the
|
||||||
own: the Sync button performs that (git + local together, one run,
|
same filename replaces that source **in place** (one folder, one row,
|
||||||
prune over the union), and a removed source's documents leave the index
|
dropped files pruned; see
|
||||||
on the next sync.
|
[Archive upload sources](#archive-upload-sources)). A local directory
|
||||||
|
must be an absolute, existing directory at add-time (a
|
||||||
|
missing/relative path is rejected, naming the path; so are duplicates)
|
||||||
|
— since phase 49 this is an API-only operation (`POST /api/git-sources`
|
||||||
|
with `kind=local`; the page's form was replaced by the upload form). List
|
||||||
|
rows carry a **Git** or **Local** badge. Adding/removing does not clone
|
||||||
|
or prune on its own: the Sync button performs that (git + local together,
|
||||||
|
one run, prune over the union), and a removed source's documents leave
|
||||||
|
the index on the next sync.
|
||||||
|
|
||||||
## Thinking
|
## Thinking
|
||||||
|
|
||||||
@@ -151,7 +167,7 @@ server-side tools — on **grounded** (high-relevance) turns only:
|
|||||||
* **`list_documents`** — lists every indexed document, one
|
* **`list_documents`** — lists every indexed document, one
|
||||||
`source/path — title` line each (the same order as the Sources page);
|
`source/path — title` line each (the same order as the Sources page);
|
||||||
* **`read_document(source, path)`** — appends the **full** text of
|
* **`read_document(source, path)`** — appends the **full** text of
|
||||||
exactly one more indexed document to the context (never truncated).
|
one more indexed document to the context (never truncated).
|
||||||
|
|
||||||
Each call the model requests is executed against Postgres only (no extra
|
Each call the model requests is executed against Postgres only (no extra
|
||||||
LLM round trip) and streamed as an SSE `tool` frame ahead of the answer —
|
LLM round trip) and streamed as an SSE `tool` frame ahead of the answer —
|
||||||
@@ -163,18 +179,18 @@ above the answer, one per call, in order. The tool lines persist with the
|
|||||||
message, so a reloaded conversation re-renders them. The read document is
|
message, so a reloaded conversation re-renders them. The read document is
|
||||||
reflected in the answer's **source chips** and in the `query_log` row.
|
reflected in the answer's **source chips** and in the `query_log` row.
|
||||||
|
|
||||||
The opportunities are budgeted per turn:
|
The tools stay offered for the whole turn — the model may call them as
|
||||||
|
many times as it needs (re-lists included), bounded only by a round cap
|
||||||
|
that stops a pathological infinite loop:
|
||||||
|
|
||||||
| Env | Default | Meaning |
|
| Env | Default | Meaning |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `BOR_AGENT_LIST_CALLS` | `1` | `list_documents` calls per turn (0 disables the tool) |
|
| `BOR_AGENT_MAX_ROUNDS` | `10` | hard cap on agent tool rounds per grounded turn — every call the model emits consumes a round; at the cap the loop forces one final no-tools answer |
|
||||||
| `BOR_AGENT_READ_CALLS` | `1` | `read_document` calls per turn (0 disables the tool) |
|
|
||||||
|
|
||||||
Once both budgets are spent the tools are dropped from the LLM request
|
`BOR_AGENT_MAX_ROUNDS=0` reproduces the pre-agent chat behavior exactly
|
||||||
and the model must answer. `BOR_AGENT_LIST_CALLS=0 BOR_AGENT_READ_CALLS=0`
|
(no `tools` in the request, no `tool` frames) — the kill switch.
|
||||||
reproduces the pre-agent chat behavior exactly (no `tools` in the
|
Deflected turns run no tools at all — the low-relevance path is
|
||||||
request, no `tool` frames). Deflected turns run no tools at all — the
|
unchanged.
|
||||||
low-relevance path is unchanged.
|
|
||||||
|
|
||||||
## Admin & sign-in
|
## Admin & sign-in
|
||||||
|
|
||||||
@@ -293,8 +309,11 @@ embedded.
|
|||||||
added=… updated=… unchanged=… pruned=… chunks=… embed_batches=…
|
added=… updated=… unchanged=… pruned=… chunks=… embed_batches=…
|
||||||
formats=md:203,yaml:267,…`), so it is safe to run from a cron job or
|
formats=md:203,yaml:267,…`), so it is safe to run from a cron job or
|
||||||
after every commit.
|
after every commit.
|
||||||
- Indexed formats (A9): **`md, markdown, txt, yaml, yml, json, py`**
|
- Indexed formats (A9, revised 2026-08-27): **`md, markdown, txt,
|
||||||
(case-insensitive; narrow with `BOR_IMPORT_EXTENSIONS`). Any path with a
|
yaml, yml, json, py`**, the Podman quadlet family (**`container,
|
||||||
|
network, volume, image, pod, kube, swap, os, endpoint`**), and **`j2`**
|
||||||
|
Jinja templates (case-insensitive; narrow with `BOR_IMPORT_EXTENSIONS`).
|
||||||
|
Any path with a
|
||||||
**dot-prefixed component** — hidden files or vendored caches like
|
**dot-prefixed component** — hidden files or vendored caches like
|
||||||
`.esphome/.espressif/**` — is skipped, along with `.venv`,
|
`.esphome/.espressif/**` — is skipped, along with `.venv`,
|
||||||
`node_modules`, `.git`, `__pycache__`, `.pytest_cache`, `dist`, `build`.
|
`node_modules`, `.git`, `__pycache__`, `.pytest_cache`, `dist`, `build`.
|
||||||
@@ -302,7 +321,8 @@ embedded.
|
|||||||
that's how previously imported junk leaves the index.
|
that's how previously imported junk leaves the index.
|
||||||
- Non-markdown files get format-aware chunking (YAML top-level keys /
|
- Non-markdown files get format-aware chunking (YAML top-level keys /
|
||||||
`---` docs, JSON top-level keys, Python top-level defs/classes via
|
`---` docs, JSON top-level keys, Python top-level defs/classes via
|
||||||
stdlib `ast`) and their title comes from the file stem.
|
stdlib `ast`; quadlet unit files and `j2` templates are paragraph-
|
||||||
|
packed as plain text) and their title comes from the file stem.
|
||||||
- Unchanged files are **not re-embedded** — only new/changed ones, so
|
- Unchanged files are **not re-embedded** — only new/changed ones, so
|
||||||
refreshes are cheap.
|
refreshes are cheap.
|
||||||
- After a run that **changed** the knowledge base (at least one document
|
- After a run that **changed** the knowledge base (at least one document
|
||||||
@@ -364,13 +384,17 @@ first-class source too (phase 38). It shares the git sources' **one
|
|||||||
table** (the `git_sources` registry with a `kind` discriminator: `git` |
|
table** (the `git_sources` registry with a `kind` discriminator: `git` |
|
||||||
`local`, migration 0007), **one admin page**, and **one Sync button**:
|
`local`, migration 0007), **one admin page**, and **one Sync button**:
|
||||||
|
|
||||||
- **Add it on the Git sources page** — the “Add a local directory” form
|
- **Register it via the API (phase 49)** — the phase-38 “Add a local
|
||||||
next to the git form. Add-time validation fails loud: the path is
|
directory” form on the Git sources page was replaced by the archive
|
||||||
trimmed, `~` is expanded, and must be an **absolute, existing directory
|
upload form; adding a plain directory is now an **API-only** operation:
|
||||||
on the server** — anything else (missing, relative, a file) is rejected
|
`POST /api/git-sources` with `{"kind": "local", "path": …}`. Add-time
|
||||||
with the path named inline; a duplicate path is rejected the same way.
|
validation fails loud: the path is trimmed, `~` is expanded, and must be
|
||||||
There is **no env var for local paths** — the DB is the local-source
|
an **absolute, existing directory on the server** — anything else
|
||||||
registry (`BOR_GIT_SOURCES` stays a git-only fallback).
|
(missing, relative, a file) is 422 with the path named; a duplicate path
|
||||||
|
is 409 the same way. **Existing Local rows are unchanged**: they still
|
||||||
|
list, remove, and sync exactly as before. There is **no env var for
|
||||||
|
local paths** — the DB is the local-source registry
|
||||||
|
(`BOR_GIT_SOURCES` stays a git-only fallback).
|
||||||
- **Sync walks it directly** — no clone, no checkout copy: each run
|
- **Sync walks it directly** — no clone, no checkout copy: each run
|
||||||
indexes the directory in place (A9 format filter, hidden-dir skip,
|
indexes the directory in place (A9 format filter, hidden-dir skip,
|
||||||
sha256 delta), together with the git checkouts in the **same run**.
|
sha256 delta), together with the git checkouts in the **same run**.
|
||||||
@@ -390,6 +414,48 @@ table** (the `git_sources` registry with a `kind` discriminator: `git` |
|
|||||||
`BOR_GIT_SOURCES` is the git-only fallback; no git rows, no local rows,
|
`BOR_GIT_SOURCES` is the git-only fallback; no git rows, no local rows,
|
||||||
and no env URLs fails loudly ("no sources configured (git or local)").
|
and no env URLs fails loudly ("no sources configured (git or local)").
|
||||||
|
|
||||||
|
### Archive upload sources
|
||||||
|
|
||||||
|
Upload a `.tar`, `.tar.gz`, `.tgz`, or `.zip` archive to make it a
|
||||||
|
source (phase 49, owner permission 2026-08-28). The form on the admin
|
||||||
|
Git sources page — and the `POST /api/git-sources/upload` route behind
|
||||||
|
it — replaced the phase-38 “Add a local directory” form. An uploaded
|
||||||
|
source is registered as a `kind=local` row, so everything local
|
||||||
|
directory sources do (Sync, prune, remove) applies to it:
|
||||||
|
|
||||||
|
- **Accepted formats:** `.tar`, `.tar.gz`, `.tgz`, `.zip` — anything else
|
||||||
|
is 422 naming the accepted set. Unpacking is guarded: absolute member
|
||||||
|
paths, `..` traversal, symlink/hardlink targets escaping the unpack
|
||||||
|
folder, and device/FIFO members are rejected (422), and the total
|
||||||
|
*extracted* bytes count against the size cap (zip-bomb guard). A
|
||||||
|
zero-entry archive is 422; an archive with only non-A9 files is a
|
||||||
|
**valid replacement** (it indexes nothing and prunes the source's
|
||||||
|
previous documents).
|
||||||
|
- **Naming rule:** the source name is the **filename minus the archive
|
||||||
|
suffix** (`homelab.tar.gz` → `homelab`, case-sensitive). The name is
|
||||||
|
both the folder under `BOR_UPLOAD_DIR` and the row's identity; files
|
||||||
|
land in the KB exactly as packed (no auto-unwrap of a single top-level
|
||||||
|
folder).
|
||||||
|
- **In-place replace:** re-uploading the same filename creates **no
|
||||||
|
second folder and no second row** — the new content is unpacked to a
|
||||||
|
temp sibling and atomically renamed over the existing folder (no
|
||||||
|
missing window; a failed upload never touches the existing folder,
|
||||||
|
row, or KB), the row is upserted by path (`kind='local'`,
|
||||||
|
`added_at` preserved), and the source is re-scanned with `prune=True`
|
||||||
|
— files dropped from the archive leave the index in the same request.
|
||||||
|
- **The scan is synchronous in the request:** it fails fast on the
|
||||||
|
models (503 when they are down — the folder/row are already committed,
|
||||||
|
so the next sync or re-upload retries idempotently), then runs the
|
||||||
|
single-source import (embeddings + per-document summaries) and the
|
||||||
|
change-gated KB overview refresh, and answers 200 with the sync-style
|
||||||
|
counts (`added`, `updated`, `unchanged`, `pruned`, …) the page renders
|
||||||
|
as its result line. One upload at a time — a concurrent upload gets
|
||||||
|
409.
|
||||||
|
- **Where + how big:** archives unpack under `BOR_UPLOAD_DIR` (default
|
||||||
|
`~/bor-sources/uploads` — deliberately separate from the git checkouts
|
||||||
|
in `BOR_SOURCES_DIR`); `BOR_UPLOAD_MAX_MB` (default 512) caps **both**
|
||||||
|
the compressed upload and the total extracted bytes.
|
||||||
|
|
||||||
### Sync from the UI
|
### Sync from the UI
|
||||||
|
|
||||||
The **Sync sources** button on the **Sources** page — visible to the
|
The **Sync sources** button on the **Sources** page — visible to the
|
||||||
@@ -400,9 +466,11 @@ git-source refresh in one click, in-process:
|
|||||||
admin-managed `git_sources` table; `BOR_GIT_SOURCES` only while that
|
admin-managed `git_sources` table; `BOR_GIT_SOURCES` only while that
|
||||||
list is empty) through the same `clone_or_pull` the CLI uses (shallow
|
list is empty) through the same `clone_or_pull` the CLI uses (shallow
|
||||||
clone on first run, `git pull --ff-only` afterwards), **and** the
|
clone on first run, `git pull --ff-only` afterwards), **and** the
|
||||||
local directories registered on the same page, walked directly
|
local directories registered on the same page — including uploaded
|
||||||
(re-verified to exist at sync time — a missing directory fails the run
|
archives (their `BOR_UPLOAD_DIR/<name>/` folders are `kind=local`
|
||||||
loudly, naming the path);
|
rows, *Archive upload sources*) — walked directly (re-verified to
|
||||||
|
exist at sync time — a missing directory fails the run loudly, naming
|
||||||
|
the path);
|
||||||
2. **re-import with prune** — the `--prune` equivalent, so files deleted
|
2. **re-import with prune** — the `--prune` equivalent, so files deleted
|
||||||
upstream leave the index (the button is the canonical "mirror the
|
upstream leave the index (the button is the canonical "mirror the
|
||||||
repos" action); the sha256 delta still skips unchanged files, so an
|
repos" action); the sha256 delta still skips unchanged files, so an
|
||||||
@@ -643,11 +711,12 @@ served locally (no CDN), `BOR_ENVIRONMENT=production`.
|
|||||||
| `BOR_HYBRID_VECTOR_CANDIDATES` | `100` | cosine list width for the RRF fusion |
|
| `BOR_HYBRID_VECTOR_CANDIDATES` | `100` | cosine list width for the RRF fusion |
|
||||||
| `BOR_HYBRID_LEXICAL_CANDIDATES` | `30` | FTS list width for the RRF fusion |
|
| `BOR_HYBRID_LEXICAL_CANDIDATES` | `30` | FTS list width for the RRF fusion |
|
||||||
| `BOR_RRF_K` | `60` | RRF damping constant (`1/(k + rank)`) |
|
| `BOR_RRF_K` | `60` | RRF damping constant (`1/(k + rank)`) |
|
||||||
| `BOR_AGENT_LIST_CALLS` | `1` | per-turn `list_documents` tool opportunities on grounded turns (0 disables the tool) |
|
| `BOR_AGENT_MAX_ROUNDS` | `10` | hard cap on agent tool rounds per grounded turn — every call the model emits consumes a round; at the cap the loop forces one final no-tools answer (0 = no tools, the kill switch) |
|
||||||
| `BOR_AGENT_READ_CALLS` | `1` | per-turn `read_document` tool opportunities on grounded turns (0 disables the tool) |
|
| `BOR_IMPORT_EXTENSIONS` | `md,markdown,txt,yaml,yml,json,py,container,network,volume,image,pod,kube,swap,os,endpoint,j2` | csv of importable formats (may only narrow the A9 set) |
|
||||||
| `BOR_IMPORT_EXTENSIONS` | `md,markdown,txt,yaml,yml,json,py` | csv of importable formats (may only narrow the A9 set) |
|
|
||||||
| `BOR_GIT_SOURCES` | — (empty) | csv of git repo URLs — **fallback while the admin Git sources page's list (Postgres `git_sources`) is empty**; the page is the primary management surface (see *Git-based sources*). **Git-only**: local directory sources have no env var — they are registered on the admin page (see *Local directory sources*) |
|
| `BOR_GIT_SOURCES` | — (empty) | csv of git repo URLs — **fallback while the admin Git sources page's list (Postgres `git_sources`) is empty**; the page is the primary management surface (see *Git-based sources*). **Git-only**: local directory sources have no env var — they are registered on the admin page (see *Local directory sources*) |
|
||||||
| `BOR_SOURCES_DIR` | `~/bor-sources` | where the git source repos are cloned/pulled (one subdirectory per repo) |
|
| `BOR_SOURCES_DIR` | `~/bor-sources` | where the git source repos are cloned/pulled (one subdirectory per repo) |
|
||||||
|
| `BOR_UPLOAD_DIR` | `~/bor-sources/uploads` | where uploaded source archives are unpacked — one subdirectory per source name (filename minus the archive suffix); separate from the git checkouts (see *Archive upload sources*) |
|
||||||
|
| `BOR_UPLOAD_MAX_MB` | `512` | cap (MiB) for uploaded source archives — bounds **both** the compressed upload and the total extracted bytes (zip-bomb guard); must be > 0 |
|
||||||
| `BOR_STEERING_MAX_CHARS` | `8000` | char budget for the `<tuning>` (steering notes) prompt section |
|
| `BOR_STEERING_MAX_CHARS` | `8000` | char budget for the `<tuning>` (steering notes) prompt section |
|
||||||
| `BOR_SUMMARY_MAX_CHARS` | `12000` | cap on document content sent to the `lite` summary model at import (see *Document summaries*) |
|
| `BOR_SUMMARY_MAX_CHARS` | `12000` | cap on document content sent to the `lite` summary model at import (see *Document summaries*) |
|
||||||
| `BOR_KB_OVERVIEW_MAX_CHARS` | `4000` | char budget for the `<knowledge_base>` (KB overview) prompt section |
|
| `BOR_KB_OVERVIEW_MAX_CHARS` | `4000` | char budget for the `<knowledge_base>` (KB overview) prompt section |
|
||||||
|
|||||||
+21
-20
@@ -48,26 +48,26 @@ outline (0 when absent) and the per-turn log line records
|
|||||||
``kb_chars=N`` after ``tuning=N`` (PLAN §9 line extension).
|
``kb_chars=N`` after ``tuning=N`` (PLAN §9 line extension).
|
||||||
|
|
||||||
Agent document tools (phase 37, PLAN §4 extension, owner permission
|
Agent document tools (phase 37, PLAN §4 extension, owner permission
|
||||||
2026-08-26): a **grounded** turn (``not plan.deflected``) no longer
|
2026-08-26; phase 45 removed the per-tool budgets — owner permission
|
||||||
|
2026-08-27): a **grounded** turn (``not plan.deflected``) no longer
|
||||||
streams a bare ``chat_stream`` — it runs the agent loop
|
streams a bare ``chat_stream`` — it runs the agent loop
|
||||||
(``app.rag.agent.run_agent``), which offers the model the two
|
(``app.rag.agent.run_agent``), which offers the model the two
|
||||||
server-side tools ``list_documents`` / ``read_document`` while the
|
server-side tools ``list_documents`` / ``read_document`` for the whole
|
||||||
per-turn budgets (``BOR_AGENT_LIST_CALLS`` / ``BOR_AGENT_READ_CALLS``,
|
turn (as many calls as the model wants, re-lists included) until it
|
||||||
default 1 each) last; once both budgets are spent the ``tools`` key is
|
answers or the round cap (``BOR_AGENT_MAX_ROUNDS``, default 10) forces
|
||||||
dropped from the request and the model must answer. Each
|
one final no-tools answer. Each model-requested call streams as an SSE
|
||||||
model-requested call streams as an SSE ``tool`` event —
|
``tool`` event — ``{"type": "tool", "name": …, "argument":
|
||||||
``{"type": "tool", "name": …, "argument": "source/path" | null}`` —
|
"source/path" | null}`` — ahead of the answer's ``delta`` frames.
|
||||||
ahead of the answer's ``delta`` frames. ``done.sources``,
|
``done.sources``, ``query_log.sources`` and the per-turn log line all
|
||||||
``query_log.sources`` and the per-turn log line all report the same
|
report the same combined source list (retrieval docs + the agent's
|
||||||
combined source list (retrieval docs + the agent's read docs, deduped
|
read docs, deduped by ``(source, path)``, order preserved), and the log
|
||||||
by ``(source, path)``, order preserved), and the log line records
|
line records ``tool_calls=N`` after ``thinking_chars=N`` (PLAN §9 line
|
||||||
``tool_calls=N`` after ``thinking_chars=N`` (PLAN §9 line extension —
|
extension — ``N`` counts executed tool calls; rejected calls do not
|
||||||
``N`` counts budget-consuming executions; rejected calls do not
|
count). **Deflected turns keep the direct ``chat_stream`` —
|
||||||
count). **Deflected turns keep the direct ``chat_stream`` — byte-
|
byte-identical to the pre-phase path (A8):** the LOW prompt never
|
||||||
identical to the pre-phase path (A8):** the LOW prompt never carries
|
carries tools, and with ``agent_max_rounds`` at **0** ``run_agent``
|
||||||
tools, and with **both budgets at 0** ``run_agent`` makes exactly one
|
makes exactly one ``tools=None`` request, reproducing the pre-phase
|
||||||
``tools=None`` request, reproducing the pre-phase behavior (budgets-
|
behavior (the kill switch).
|
||||||
as-kill-switch).
|
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -299,8 +299,9 @@ async def chat(
|
|||||||
# bare ``chat_stream`` — its ``ToolCallPiece``s stream as
|
# bare ``chat_stream`` — its ``ToolCallPiece``s stream as
|
||||||
# ``tool`` events ahead of the answer. A deflected turn keeps
|
# ``tool`` events ahead of the answer. A deflected turn keeps
|
||||||
# the direct ``chat_stream`` (byte-identical, A8): the LOW
|
# the direct ``chat_stream`` (byte-identical, A8): the LOW
|
||||||
# prompt never carries tools, and with both budgets at 0
|
# prompt never carries tools, and with
|
||||||
# ``run_agent`` is a single ``tools=None`` request anyway.
|
# ``agent_max_rounds=0`` ``run_agent`` is a single
|
||||||
|
# ``tools=None`` request anyway (the kill switch).
|
||||||
holder = AgentHolder()
|
holder = AgentHolder()
|
||||||
answer_stream: AsyncIterator[StreamPiece | ToolCallPiece]
|
answer_stream: AsyncIterator[StreamPiece | ToolCallPiece]
|
||||||
if plan.deflected:
|
if plan.deflected:
|
||||||
|
|||||||
+227
-8
@@ -17,9 +17,13 @@ Routes: ``GET`` (DB rows oldest-first, or the env list with
|
|||||||
``path``, git rows — and env rows — report ``path: null``), ``POST``
|
``path``, git rows — and env rows — report ``path: null``), ``POST``
|
||||||
(201, validated create; ``kind`` selects the validation: git → exactly
|
(201, validated create; ``kind`` selects the validation: git → exactly
|
||||||
the phase-35 URL contract, local → an existing absolute directory, else
|
the phase-35 URL contract, local → an existing absolute directory, else
|
||||||
422 naming the path), ``DELETE /{source_id}`` (204). The whole router
|
422 naming the path), ``POST /upload`` (phase 49 — admin archive upload:
|
||||||
sits behind :func:`app.core.auth.require_admin` — anonymous callers get
|
``.tar``/``.tar.gz``/``.tgz``/``.zip`` streamed with a size cap, safely
|
||||||
403 on every route.
|
unpacked, atomically swapped in over an existing folder of the same
|
||||||
|
name, row upserted, then the synchronous single-source scan — see
|
||||||
|
:func:`upload_archive`), ``DELETE /{source_id}`` (204). The whole
|
||||||
|
router sits behind :func:`app.core.auth.require_admin` — anonymous
|
||||||
|
callers get 403 on every route.
|
||||||
|
|
||||||
No credential-echo path: git URLs may embed ``user:pass@`` (phase 32's
|
No credential-echo path: git URLs may embed ``user:pass@`` (phase 32's
|
||||||
masking discipline), so every git 409/422 detail is a fixed generic
|
masking discipline), so every git 409/422 detail is a fixed generic
|
||||||
@@ -27,27 +31,47 @@ string that never repeats the submitted URL. Local paths are not
|
|||||||
secrets — the local 422/409 details name the (expanded) path so the
|
secrets — the local 422/409 details name the (expanded) path so the
|
||||||
owner sees exactly which directory failed.
|
owner sees exactly which directory failed.
|
||||||
|
|
||||||
Scope boundary (phase locked decisions): adding or removing a source
|
Scope boundary (phase locked decisions): the CRUD routes do NOT
|
||||||
does NOT clone, import, or prune anything — the existing Sync button
|
clone, import, or prune anything — the existing Sync button performs
|
||||||
performs that (a removal prunes on the next sync, ``prune=True``).
|
that (a removal prunes on the next sync, ``prune=True``). The phase-49
|
||||||
|
upload route is the exception: it unpacks the archive and then scans
|
||||||
|
the single source synchronously in the request (``import_sources``
|
||||||
|
with ``prune=True`` + the change-gated overview refresh) and answers
|
||||||
|
with the sync-style counts.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
import re
|
import re
|
||||||
|
import shutil
|
||||||
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Literal, cast
|
from typing import Literal, cast
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Response
|
from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFile
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.api.sync import _sanitize_error
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
from app.core.auth import require_admin
|
from app.core.auth import require_admin
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models import GitSource
|
from app.models import GitSource
|
||||||
from app.schemas import GitSourceIn, GitSourceList, GitSourceOut, GitSourceRow
|
from app.rag.archive_upload import (
|
||||||
|
ARCHIVE_SUFFIXES,
|
||||||
|
ArchiveUploadError,
|
||||||
|
archive_source_name,
|
||||||
|
swap_in,
|
||||||
|
unpack_archive,
|
||||||
|
)
|
||||||
|
from app.rag.importer import import_sources
|
||||||
|
from app.rag.llm import LLMClient, ModelUnavailableError, check_models
|
||||||
|
from app.rag.overview import regenerate_overview
|
||||||
|
from app.schemas import GitSourceIn, GitSourceList, GitSourceOut, GitSourceRow, UploadOut
|
||||||
|
|
||||||
|
logger = logging.getLogger("app.api.git_sources")
|
||||||
|
|
||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
prefix="/git-sources",
|
prefix="/git-sources",
|
||||||
@@ -55,6 +79,18 @@ router = APIRouter(
|
|||||||
dependencies=[Depends(require_admin)], # phase 16 pattern: admin-only surface
|
dependencies=[Depends(require_admin)], # phase 16 pattern: admin-only surface
|
||||||
)
|
)
|
||||||
|
|
||||||
|
#: One upload at a time (phase 49, task 02 — the phase-32 ``_task``
|
||||||
|
#: spirit): the flag is held from the name gate through the scan
|
||||||
|
#: response. A plain bool, not an ``asyncio.Lock`` — it is checked and
|
||||||
|
#: set with no await in between (a single app loop can never enter
|
||||||
|
#: twice), and it stays correct across requests that run on separate
|
||||||
|
#: event loops (the TestClient convention).
|
||||||
|
_upload_in_progress = False
|
||||||
|
|
||||||
|
#: Streaming read size while counting compressed upload bytes (1 MiB
|
||||||
|
#: chunks — the task-02 cap check granularity).
|
||||||
|
_STREAM_CHUNK = 1 << 20
|
||||||
|
|
||||||
#: Accepted git URL shapes — the trimmed URL must *start* with one of them.
|
#: Accepted git URL shapes — the trimmed URL must *start* with one of them.
|
||||||
#: Covers the phase-28 real URLs (HTTPS + ``git@`` SSH); scp-style
|
#: Covers the phase-28 real URLs (HTTPS + ``git@`` SSH); scp-style
|
||||||
#: ``host:repo`` is deliberately rejected (422). ASSUMPTION (task 02): the
|
#: ``host:repo`` is deliberately rejected (422). ASSUMPTION (task 02): the
|
||||||
@@ -191,6 +227,189 @@ def _create_local_row(payload: GitSourceIn, db: Session) -> GitSource:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/upload", response_model=UploadOut)
|
||||||
|
async def upload_archive(
|
||||||
|
file: UploadFile = File(...), # noqa: B008
|
||||||
|
db: Session = Depends(get_db), # noqa: B008
|
||||||
|
) -> UploadOut:
|
||||||
|
"""Upload a source archive and scan it (phase 49, task 02).
|
||||||
|
|
||||||
|
The scan is **synchronous in the request** (phase locked decisions,
|
||||||
|
owner-confirmed) and mirrors the admin sync pipeline:
|
||||||
|
|
||||||
|
1. name/format gate — only ``.tar``/``.tar.gz``/``.tgz``/``.zip``
|
||||||
|
(422 naming the accepted set) and a safe source name
|
||||||
|
(``archive_source_name`` — its message is the 422 detail);
|
||||||
|
2. one at a time — 409 ``an upload is already in progress``;
|
||||||
|
3. stream the upload in 1 MiB chunks into a dotfile temp with the
|
||||||
|
``upload_max_mb`` cap — 413 naming the cap, temp deleted;
|
||||||
|
4. unpack to a temp sibling (traversal/symlink/device/corrupt/
|
||||||
|
over-cap all 422 with the task-01 user-safe message, temps
|
||||||
|
deleted); a zero-entry archive is 422 ``the archive contains no
|
||||||
|
files`` — an archive with only non-A9 files is a VALID
|
||||||
|
replacement (the scan indexes nothing, prune removes the
|
||||||
|
source's docs);
|
||||||
|
5. atomic swap-in — a same-name re-upload replaces the previous
|
||||||
|
folder in place; a failure leaves the previous folder/row/KB
|
||||||
|
untouched (422);
|
||||||
|
6. upsert the row by ``path`` (``kind='local'``; an existing row is
|
||||||
|
left as-is — ``added_at`` preserved — and the unique index is
|
||||||
|
the 409 backstop);
|
||||||
|
7. fail-fast ``check_models`` — 503 with the sanitized
|
||||||
|
model-unavailable message; the folder/row are already committed,
|
||||||
|
so the next sync/re-upload retries idempotently;
|
||||||
|
8. ``import_sources([folder], llm, prune=True)`` + the change-gated
|
||||||
|
``regenerate_overview``;
|
||||||
|
9. one INFO log line (PLAN §9 / AGENTS.md rule 10);
|
||||||
|
10. 200 with the sync-detail count keys (``UploadOut``).
|
||||||
|
"""
|
||||||
|
started = time.monotonic()
|
||||||
|
settings = get_settings()
|
||||||
|
total = 0
|
||||||
|
|
||||||
|
# 1. Name/format gate — the accepted formats first (the 422 names
|
||||||
|
# them), then the task-01 safe-name derivation. A BARE suffix
|
||||||
|
# ("tar.gz") is an accepted format with no usable stem — it
|
||||||
|
# passes here and gets task-01's "no usable source name" 422.
|
||||||
|
# No upload dir is created for a rejected name.
|
||||||
|
filename = file.filename or ""
|
||||||
|
lowered = filename.lower()
|
||||||
|
if not any(
|
||||||
|
lowered.endswith(suffix) or lowered == suffix.lstrip(".")
|
||||||
|
for suffix in ARCHIVE_SUFFIXES
|
||||||
|
):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail="only .tar, .tar.gz, .tgz or .zip archives are accepted",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
name = archive_source_name(filename)
|
||||||
|
except ArchiveUploadError as e:
|
||||||
|
raise HTTPException(status_code=422, detail=str(e)) from None
|
||||||
|
|
||||||
|
# 2. One at a time — the flag is checked and set with no await
|
||||||
|
# between, so the single app loop can never enter twice.
|
||||||
|
global _upload_in_progress
|
||||||
|
if _upload_in_progress:
|
||||||
|
raise HTTPException(status_code=409, detail="an upload is already in progress")
|
||||||
|
_upload_in_progress = True
|
||||||
|
|
||||||
|
upload_root = Path(settings.upload_dir).expanduser()
|
||||||
|
upload_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
max_bytes = settings.upload_max_mb * 1024 * 1024
|
||||||
|
temp_upload = upload_root / f".{name}.{uuid.uuid4().hex}.upload"
|
||||||
|
temp_unpack = upload_root / f".{name}.{uuid.uuid4().hex}.unpack"
|
||||||
|
try:
|
||||||
|
# 3. Stream with the compressed-size cap — dotfile temps are
|
||||||
|
# hidden from the upload dir's listing.
|
||||||
|
try:
|
||||||
|
with open(temp_upload, "wb") as out:
|
||||||
|
while chunk := await file.read(_STREAM_CHUNK):
|
||||||
|
total += len(chunk)
|
||||||
|
if total > max_bytes:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=413,
|
||||||
|
detail=f"the upload exceeds the {settings.upload_max_mb} MiB limit",
|
||||||
|
)
|
||||||
|
out.write(chunk)
|
||||||
|
except HTTPException:
|
||||||
|
temp_upload.unlink(missing_ok=True)
|
||||||
|
raise
|
||||||
|
# 4. Unpack to a temp sibling; the compressed bytes are no
|
||||||
|
# longer needed once unpacked (phase locked decision: only
|
||||||
|
# the unpacked content is kept).
|
||||||
|
try:
|
||||||
|
unpack_archive(temp_upload, temp_unpack, max_bytes)
|
||||||
|
except ArchiveUploadError as e:
|
||||||
|
temp_upload.unlink(missing_ok=True)
|
||||||
|
shutil.rmtree(temp_unpack, ignore_errors=True)
|
||||||
|
raise HTTPException(status_code=422, detail=str(e)) from None
|
||||||
|
temp_upload.unlink(missing_ok=True)
|
||||||
|
if not any(temp_unpack.iterdir()):
|
||||||
|
# Zero entries = a user error. (Only non-A9 files is NOT an
|
||||||
|
# error — it still has entries and is a valid replacement.)
|
||||||
|
shutil.rmtree(temp_unpack, ignore_errors=True)
|
||||||
|
raise HTTPException(status_code=422, detail="the archive contains no files")
|
||||||
|
# 5. Swap in — a same-name re-upload replaces the previous
|
||||||
|
# folder atomically; a failure leaves it, the row, and the
|
||||||
|
# KB untouched.
|
||||||
|
final_dir = upload_root / name
|
||||||
|
try:
|
||||||
|
swap_in(temp_unpack, final_dir)
|
||||||
|
except ArchiveUploadError as e:
|
||||||
|
shutil.rmtree(temp_unpack, ignore_errors=True)
|
||||||
|
raise HTTPException(status_code=422, detail=str(e)) from None
|
||||||
|
# 6. Upsert the row by path — no duplicates: an existing row is
|
||||||
|
# left exactly as it is (``added_at`` preserved); the unique
|
||||||
|
# index is the 409 backstop for a concurrent insert the
|
||||||
|
# pre-check missed.
|
||||||
|
path = str(final_dir)
|
||||||
|
if db.scalar(select(GitSource).where(GitSource.path == path)) is None:
|
||||||
|
_commit_new(
|
||||||
|
GitSource(url=path, kind="local", path=path),
|
||||||
|
f"a local source with this path already exists: {path}",
|
||||||
|
db,
|
||||||
|
)
|
||||||
|
# Release the request session NOW — the handler never touches
|
||||||
|
# ``db`` again (the scan below uses its own sessions). If the
|
||||||
|
# session stayed open, its uncommitted transaction (the
|
||||||
|
# ``_commit_new`` refresh SELECT) would hold ``git_sources``
|
||||||
|
# locks for the whole scan, and any concurrent TRUNCATE of the
|
||||||
|
# KB tables (the E2E isolation fixtures) would deadlock against
|
||||||
|
# the scan's own document locks — a cycle Postgres cannot see.
|
||||||
|
# ``get_db``'s teardown close() is idempotent.
|
||||||
|
db.close()
|
||||||
|
# 7. Fail-fast models (phase 41) — 503 with the sanitized
|
||||||
|
# message; nothing else is rolled back (the folder/row are
|
||||||
|
# committed and the next sync/re-upload retries idempotently).
|
||||||
|
llm = LLMClient()
|
||||||
|
try:
|
||||||
|
await check_models(llm)
|
||||||
|
except ModelUnavailableError as e:
|
||||||
|
raise HTTPException(status_code=503, detail=_sanitize_error(str(e))) from None
|
||||||
|
# 8. Scan — single source, prune (dropped files leave the KB),
|
||||||
|
# then the change-gated overview refresh (phases 31/32).
|
||||||
|
summary = await import_sources([final_dir], llm, prune=True)
|
||||||
|
overview = False
|
||||||
|
if summary.added + summary.updated > 0:
|
||||||
|
overview = await regenerate_overview(llm)
|
||||||
|
finally:
|
||||||
|
_upload_in_progress = False
|
||||||
|
# No temp may survive any failure path (defensive — each step
|
||||||
|
# already cleans its own; on success both are already gone).
|
||||||
|
temp_upload.unlink(missing_ok=True)
|
||||||
|
shutil.rmtree(temp_unpack, ignore_errors=True)
|
||||||
|
|
||||||
|
# 9. Per-upload log line (PLAN §9 / AGENTS.md rule 10).
|
||||||
|
logger.info(
|
||||||
|
"upload: name=%s file=%s bytes_in=%d files=%d added=%d updated=%d "
|
||||||
|
"unchanged=%d pruned=%d errors=%d overview=%s total_ms=%d",
|
||||||
|
name,
|
||||||
|
filename,
|
||||||
|
total,
|
||||||
|
summary.files,
|
||||||
|
summary.added,
|
||||||
|
summary.updated,
|
||||||
|
summary.unchanged,
|
||||||
|
summary.pruned,
|
||||||
|
summary.errors,
|
||||||
|
overview,
|
||||||
|
round((time.monotonic() - started) * 1000),
|
||||||
|
)
|
||||||
|
# 10. Respond 200 with the sync-style counts.
|
||||||
|
return UploadOut(
|
||||||
|
source=name,
|
||||||
|
files=summary.files,
|
||||||
|
added=summary.added,
|
||||||
|
updated=summary.updated,
|
||||||
|
unchanged=summary.unchanged,
|
||||||
|
pruned=summary.pruned,
|
||||||
|
errors=summary.errors,
|
||||||
|
chunks=summary.chunks,
|
||||||
|
overview=overview,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{source_id}", status_code=204)
|
@router.delete("/{source_id}", status_code=204)
|
||||||
def delete_git_source(
|
def delete_git_source(
|
||||||
source_id: uuid.UUID,
|
source_id: uuid.UUID,
|
||||||
|
|||||||
+14
-6
@@ -15,25 +15,29 @@ stale button state (§7.4 adaptation, phase locked decisions).
|
|||||||
Pipeline (the canonical "mirror the sources" action — phase locked
|
Pipeline (the canonical "mirror the sources" action — phase locked
|
||||||
decisions):
|
decisions):
|
||||||
|
|
||||||
1. resolve the effective sources — the ``git_sources`` DB rows (git
|
1. verify ``embed`` + summary model availability — fail fast before
|
||||||
|
any clone (:func:`app.rag.llm.check_models`, phase 41): a dead
|
||||||
|
model endpoint aborts the run naming the unavailable model, before
|
||||||
|
source resolution or any ``clone_or_pull``;
|
||||||
|
2. resolve the effective sources — the ``git_sources`` DB rows (git
|
||||||
**and** local, phase 38), else the ``BOR_GIT_SOURCES`` fallback
|
**and** local, phase 38), else the ``BOR_GIT_SOURCES`` fallback
|
||||||
(git-only)
|
(git-only)
|
||||||
(:func:`app.rag.git_sources.effective_sources`, shared with the
|
(:func:`app.rag.git_sources.effective_sources`, shared with the
|
||||||
CLI) — empty on both origins (no git rows, no local rows, no env
|
CLI) — empty on both origins (no git rows, no local rows, no env
|
||||||
URLs) fails loudly (``no sources configured (git or local)``)
|
URLs) fails loudly (``no sources configured (git or local)``)
|
||||||
instead of silently importing the legacy local directories;
|
instead of silently importing the legacy local directories;
|
||||||
2. per resolved row: ``kind=git`` → :func:`scripts.git_sync.clone_or_pull`
|
3. per resolved row: ``kind=git`` → :func:`scripts.git_sync.clone_or_pull`
|
||||||
into ``BOR_SOURCES_DIR/<repo-name>/`` (phase 28 — reused, not
|
into ``BOR_SOURCES_DIR/<repo-name>/`` (phase 28 — reused, not
|
||||||
re-implemented); ``kind=local`` → the stored directory, re-verified
|
re-implemented); ``kind=local`` → the stored directory, re-verified
|
||||||
``.is_dir()`` **at sync time** (it may have moved/deleted since
|
``.is_dir()`` **at sync time** (it may have moved/deleted since
|
||||||
add-time) — a missing directory raises ``local source missing:
|
add-time) — a missing directory raises ``local source missing:
|
||||||
<path>``; a failing clone or a missing local dir aborts before any
|
<path>``; a failing clone or a missing local dir aborts before any
|
||||||
import;
|
import;
|
||||||
3. ``import_sources(..., prune=True)`` over the single combined list
|
4. ``import_sources(..., prune=True)`` over the single combined list
|
||||||
(git checkouts + local dirs) — prune so files deleted upstream or
|
(git checkouts + local dirs) — prune so files deleted upstream or
|
||||||
out of a local dir leave the index (pruning covers the union; the
|
out of a local dir leave the index (pruning covers the union; the
|
||||||
CLI's no-prune default is unchanged);
|
CLI's no-prune default is unchanged);
|
||||||
4. when the import changed the KB (added + updated > 0),
|
5. when the import changed the KB (added + updated > 0),
|
||||||
``regenerate_overview`` refreshes the single ``kb_overview`` row
|
``regenerate_overview`` refreshes the single ``kb_overview`` row
|
||||||
(phase 31 trigger, best-effort inside).
|
(phase 31 trigger, best-effort inside).
|
||||||
|
|
||||||
@@ -57,7 +61,7 @@ from app.core.auth import require_admin
|
|||||||
from app.db import SessionLocal
|
from app.db import SessionLocal
|
||||||
from app.rag.git_sources import effective_sources
|
from app.rag.git_sources import effective_sources
|
||||||
from app.rag.importer import ImportSummary, import_sources
|
from app.rag.importer import ImportSummary, import_sources
|
||||||
from app.rag.llm import LLMClient
|
from app.rag.llm import LLMClient, check_models
|
||||||
from app.rag.overview import regenerate_overview
|
from app.rag.overview import regenerate_overview
|
||||||
from scripts.git_sync import GitSyncError, clone_or_pull
|
from scripts.git_sync import GitSyncError, clone_or_pull
|
||||||
from scripts.import_docs import repo_name
|
from scripts.import_docs import repo_name
|
||||||
@@ -153,6 +157,11 @@ async def _run_sync() -> None:
|
|||||||
_status.error = None
|
_status.error = None
|
||||||
try:
|
try:
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
# Step 1 (phase 41): fail fast — verify both models the sync
|
||||||
|
# needs (embed + summary) before source resolution or any
|
||||||
|
# clone. The client is reused for the import + overview below.
|
||||||
|
llm = LLMClient()
|
||||||
|
await check_models(llm)
|
||||||
# The background task has no request session: open a short-lived
|
# The background task has no request session: open a short-lived
|
||||||
# one around the shared phase-35/38 resolver (DB rows of both
|
# one around the shared phase-35/38 resolver (DB rows of both
|
||||||
# kinds win; the BOR_GIT_SOURCES git list is a fallback while
|
# kinds win; the BOR_GIT_SOURCES git list is a fallback while
|
||||||
@@ -189,7 +198,6 @@ async def _run_sync() -> None:
|
|||||||
if not path.is_dir():
|
if not path.is_dir():
|
||||||
raise GitSyncError(f"local source missing: {path}")
|
raise GitSyncError(f"local source missing: {path}")
|
||||||
sources.append(path)
|
sources.append(path)
|
||||||
llm = LLMClient()
|
|
||||||
summary: ImportSummary = await import_sources(sources, llm, prune=True)
|
summary: ImportSummary = await import_sources(sources, llm, prune=True)
|
||||||
overview = False
|
overview = False
|
||||||
if summary.added + summary.updated > 0:
|
if summary.added + summary.updated > 0:
|
||||||
|
|||||||
+54
-12
@@ -11,10 +11,19 @@ from functools import lru_cache
|
|||||||
from pydantic import field_validator
|
from pydantic import field_validator
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
#: The A9 import formats (PLAN anchor A9, revised 2026-08-21).
|
#: The A9 import formats (PLAN anchor A9, revised 2026-08-21; revised
|
||||||
#: ``BOR_IMPORT_EXTENSIONS`` may narrow — but never widen — this set.
|
#: 2026-08-27, owner permission — the full Podman quadlet family
|
||||||
|
#: ``container, network, volume, image, pod, kube, swap, os, endpoint``
|
||||||
|
#: plus Jinja templates ``j2`` join the allowed set, chunked as plain
|
||||||
|
#: text). ``BOR_IMPORT_EXTENSIONS`` may narrow — but never widen — this
|
||||||
|
#: set.
|
||||||
_ALLOWED_IMPORT_EXTENSIONS: frozenset[str] = frozenset(
|
_ALLOWED_IMPORT_EXTENSIONS: frozenset[str] = frozenset(
|
||||||
{"md", "markdown", "txt", "yaml", "yml", "json", "py"}
|
{
|
||||||
|
"md", "markdown", "txt", "yaml", "yml", "json", "py",
|
||||||
|
# A9 revised 2026-08-27 (owner permission): quadlet family + jinja.
|
||||||
|
"container", "network", "volume", "image", "pod",
|
||||||
|
"kube", "swap", "os", "endpoint", "j2",
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -87,13 +96,15 @@ class Settings(BaseSettings):
|
|||||||
#: ``app.rag.overview``). Overflow is cut at the cap and the shared
|
#: ``app.rag.overview``). Overflow is cut at the cap and the shared
|
||||||
#: ``[…truncated…]`` marker is appended (summarizer convention).
|
#: ``[…truncated…]`` marker is appended (summarizer convention).
|
||||||
overview_input_max_chars: int = 40_000
|
overview_input_max_chars: int = 40_000
|
||||||
#: Per-turn opportunities to call the ``list_documents`` agent tool
|
#: Hard cap on the agent tool rounds per grounded turn (phase 45,
|
||||||
#: (phase 37, ``app.rag.agent``); 0 disables the tool entirely
|
#: revising phase 37's per-tool budgets — owner permission
|
||||||
#: (pre-phase behavior with both budgets at 0).
|
#: 2026-08-27, TODO L8: "allow the LLM to make as many tool calls
|
||||||
agent_list_calls: int = 1
|
#: as it wants"). Every tool call the model emits consumes a
|
||||||
#: Per-turn opportunities to call the ``read_document`` agent tool
|
#: round; at the cap the loop forces one final no-tools answer.
|
||||||
#: (phase 37, ``app.rag.agent``); 0 disables the tool entirely.
|
#: ``0`` disables the tools entirely — the turn is a single
|
||||||
agent_read_calls: int = 1
|
#: request with ``tools=None`` (the pre-phase-37 path — the kill
|
||||||
|
#: switch). Negative values are rejected at startup (validator).
|
||||||
|
agent_max_rounds: int = 10
|
||||||
|
|
||||||
# --- Hybrid retrieval (A7, revised 2026-08-21) ---
|
# --- Hybrid retrieval (A7, revised 2026-08-21) ---
|
||||||
# cosine top-N ∪ Postgres FTS top-N, fused with Reciprocal Rank Fusion
|
# cosine top-N ∪ Postgres FTS top-N, fused with Reciprocal Rank Fusion
|
||||||
@@ -125,14 +136,17 @@ class Settings(BaseSettings):
|
|||||||
session_max_age: int = 43_200
|
session_max_age: int = 43_200
|
||||||
session_cookie: str = "bor_session"
|
session_cookie: str = "bor_session"
|
||||||
|
|
||||||
# --- Import scope (A9, revised 2026-08-21) ---
|
# --- Import scope (A9, revised 2026-08-21 and 2026-08-27) ---
|
||||||
# Comma-separated list of lowercased file extensions (no dot) imported
|
# Comma-separated list of lowercased file extensions (no dot) imported
|
||||||
# by ``scripts/import_docs.py``. Hidden (dot) path components are always
|
# by ``scripts/import_docs.py``. Hidden (dot) path components are always
|
||||||
# skipped, plus the importer's exclusion list.
|
# skipped, plus the importer's exclusion list.
|
||||||
# Stored as a raw CSV string (env-native — no JSON) and parsed on demand
|
# Stored as a raw CSV string (env-native — no JSON) and parsed on demand
|
||||||
# via :py:meth:`import_extension_set`. ``mode="after"`` validation runs
|
# via :py:meth:`import_extension_set`. ``mode="after"`` validation runs
|
||||||
# against the raw string so a typo fails loudly at startup.
|
# against the raw string so a typo fails loudly at startup.
|
||||||
import_extensions: str = "md,markdown,txt,yaml,yml,json,py"
|
import_extensions: str = (
|
||||||
|
"md,markdown,txt,yaml,yml,json,py,"
|
||||||
|
"container,network,volume,image,pod,kube,swap,os,endpoint,j2"
|
||||||
|
)
|
||||||
#: List of git repo URLs to clone/pull into ``sources_dir`` before
|
#: List of git repo URLs to clone/pull into ``sources_dir`` before
|
||||||
#: indexing (phase 28); comma-separated, stored raw. Empty means no git
|
#: indexing (phase 28); comma-separated, stored raw. Empty means no git
|
||||||
#: sources — ``import_docs`` then falls back to ``--source`` / the old
|
#: sources — ``import_docs`` then falls back to ``--source`` / the old
|
||||||
@@ -142,6 +156,18 @@ class Settings(BaseSettings):
|
|||||||
#: 28). Stored as a raw string — ``Path.expanduser()`` is applied in
|
#: 28). Stored as a raw string — ``Path.expanduser()`` is applied in
|
||||||
#: the import script, not here.
|
#: the import script, not here.
|
||||||
sources_dir: str = "~/bor-sources"
|
sources_dir: str = "~/bor-sources"
|
||||||
|
#: Where uploaded source archives are unpacked (phase 49) — one
|
||||||
|
#: subdirectory per source name (filename minus the archive suffix).
|
||||||
|
#: Deliberately kept **separate** from ``sources_dir`` (the git
|
||||||
|
#: checkouts). Raw string — ``Path.expanduser()`` is applied by the
|
||||||
|
#: upload endpoint, not here.
|
||||||
|
upload_dir: str = "~/bor-sources/uploads"
|
||||||
|
#: Cap in MiB for uploaded source archives (phase 49): it bounds BOTH
|
||||||
|
#: the compressed upload size and the total extracted bytes (the
|
||||||
|
#: zip-bomb guard). ``<= 0`` would reject every upload — a typo, so
|
||||||
|
#: the validator fails loudly at startup (the ``agent_max_rounds``
|
||||||
|
#: pattern).
|
||||||
|
upload_max_mb: int = 512
|
||||||
|
|
||||||
@field_validator("import_extensions")
|
@field_validator("import_extensions")
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -159,6 +185,22 @@ class Settings(BaseSettings):
|
|||||||
)
|
)
|
||||||
return v
|
return v
|
||||||
|
|
||||||
|
@field_validator("agent_max_rounds")
|
||||||
|
@classmethod
|
||||||
|
def _agent_max_rounds_non_negative(cls, v: int) -> int:
|
||||||
|
"""``0`` is the no-tools kill switch — a negative value is a typo."""
|
||||||
|
if v < 0:
|
||||||
|
raise ValueError("agent_max_rounds must be >= 0 (0 = no tools)")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("upload_max_mb")
|
||||||
|
@classmethod
|
||||||
|
def _upload_max_mb_positive(cls, v: int) -> int:
|
||||||
|
"""``0``/negative would reject every upload — fail loud at startup."""
|
||||||
|
if v <= 0:
|
||||||
|
raise ValueError("upload_max_mb must be > 0 (MiB)")
|
||||||
|
return v
|
||||||
|
|
||||||
# Suggested questions (onboarding + empty state).
|
# Suggested questions (onboarding + empty state).
|
||||||
suggestions: list[str] = [
|
suggestions: list[str] = [
|
||||||
"How is my Kubernetes cluster set up?",
|
"How is my Kubernetes cluster set up?",
|
||||||
|
|||||||
+61
-67
@@ -15,38 +15,42 @@ probe came back "supported".
|
|||||||
Loop contract (one grounded chat turn; the API layer wires this in,
|
Loop contract (one grounded chat turn; the API layer wires this in,
|
||||||
task 04):
|
task 04):
|
||||||
|
|
||||||
1. While budget remains the model is offered the two OpenAI functions in
|
1. The model is offered the two OpenAI functions in :data:`AGENT_TOOLS`
|
||||||
:data:`AGENT_TOOLS`: up to ``settings.agent_list_calls``
|
for the whole turn — phase 45 removed the phase-37 per-tool budgets
|
||||||
(``BOR_AGENT_LIST_CALLS``, default 1) ``list_documents`` calls and up
|
(owner permission 2026-08-27, ``TODO.md`` L8: "allow the LLM to make
|
||||||
to ``settings.agent_read_calls`` (``BOR_AGENT_READ_CALLS``, default 1)
|
as many tool calls as it wants"): ``list_documents`` and
|
||||||
``read_document`` calls. With both budgets at 0 the loop makes exactly
|
``read_document`` can each be called as many times as the model needs,
|
||||||
one request with ``tools=None`` — byte-identical to the pre-phase chat
|
re-lists included. With ``settings.agent_max_rounds``
|
||||||
path (budgets-as-kill-switch, phase 37 locked decision).
|
(``BOR_AGENT_MAX_ROUNDS``, default 10) at 0 the loop makes exactly one
|
||||||
|
request with ``tools=None`` — byte-identical to the pre-phase-37 chat
|
||||||
|
path (the kill switch).
|
||||||
2. Each tool call the model emits is executed server-side against
|
2. Each tool call the model emits is executed server-side against
|
||||||
Postgres only (no LLM, no network): ``list_documents`` returns the
|
Postgres only (no LLM, no network): ``list_documents`` returns the
|
||||||
indexed catalog — one ``source/path — title`` line per document,
|
indexed catalog — one ``source/path — title`` line per document,
|
||||||
``GET /api/docs`` order (uncapped in v1; the UI never shows it, only
|
``GET /api/docs`` order (uncapped in v1; the UI never shows it, only
|
||||||
the model does) — and ``read_document`` returns the document's **full**
|
the model does) — and ``read_document`` returns the document's **full**
|
||||||
content (A7-revised contract: never truncated).
|
content (A7-revised contract: never truncated).
|
||||||
3. Rejected calls consume **no** budget and get a one-line refusal:
|
3. Rejected calls get a one-line refusal and count in nothing
|
||||||
unknown tool name → ``"Unknown tool."``; missing ``source``/``path``
|
(``holder.tool_calls`` tracks executed calls only): unknown tool name
|
||||||
arguments; a document already in context (seed or previously read) →
|
→ ``"Unknown tool."``; missing ``source``/``path`` arguments; a
|
||||||
``"Already in your context."``; an unknown ``source/path`` →
|
document already in context (seed or previously read) → ``"Already in
|
||||||
``"No document at …"``; an exhausted list/read budget → the matching
|
your context."``; an unknown ``source/path`` → ``"No document at …"``.
|
||||||
``"No … budget left"`` refusal.
|
A rejected call still consumes a *round* in the loop, so a
|
||||||
4. Every executed call is appended back to the message history as the
|
pathological stream that keeps emitting rejected calls is bounded by
|
||||||
assistant tool-call message + the tool result, and the model is called
|
the cap (point 4).
|
||||||
again. Once **both** budgets are spent, ``tools`` is dropped from the
|
4. Every call the model emits is appended back to the message history as
|
||||||
request and the model must answer. Belt-and-braces round cap:
|
the assistant tool-call message + the tool result (refusals included),
|
||||||
``max_rounds = 2 + agent_list_calls + agent_read_calls`` (every tool
|
consumes one round, and the model is called again. At the round cap —
|
||||||
round consumes a budget, so the cap only catches pathological streams
|
``max_rounds = settings.agent_max_rounds`` (``BOR_AGENT_MAX_ROUNDS``,
|
||||||
that keep calling rejected tools) — at the cap the loop forces one
|
default 10) — the loop forces one final ``chat_stream(messages,
|
||||||
final ``chat_stream(messages, tools=None)`` and returns.
|
tools=None)`` and returns: the cap is the **only** forced exit
|
||||||
|
(besides "the stream carried no calls"), and it bounds pathological
|
||||||
|
rejected-call streams.
|
||||||
5. A rare stream that carries both content and a tool call keeps the
|
5. A rare stream that carries both content and a tool call keeps the
|
||||||
content (it was already emitted) **and** still runs the tool.
|
content (it was already emitted) **and** still runs the tool.
|
||||||
6. *holder* (an :class:`AgentHolder`) records the read documents and the
|
6. *holder* (an :class:`AgentHolder`) records the read documents and the
|
||||||
number of budget-consuming tool executions; the API layer (task 04)
|
number of executed tool calls (re-lists included); the API layer
|
||||||
reads it after the stream to extend ``done.sources`` /
|
(task 04) reads it after the stream to extend ``done.sources`` /
|
||||||
``query_log.sources`` and the per-turn log line (``tool_calls=N``).
|
``query_log.sources`` and the per-turn log line (``tool_calls=N``).
|
||||||
|
|
||||||
The DB accessors (:func:`list_catalog`, :func:`find_document`) are
|
The DB accessors (:func:`list_catalog`, :func:`find_document`) are
|
||||||
@@ -71,8 +75,9 @@ from app.rag.llm import LLMClient, StreamPiece, ToolCallPiece
|
|||||||
logger = logging.getLogger("app.agent")
|
logger = logging.getLogger("app.agent")
|
||||||
|
|
||||||
#: The two agent tools (phase 37): OpenAI function definitions passed as
|
#: The two agent tools (phase 37): OpenAI function definitions passed as
|
||||||
#: ``tools=AGENT_TOOLS`` to ``chat_stream`` while the per-turn budgets
|
#: ``tools=AGENT_TOOLS`` to ``chat_stream`` for the whole grounded turn —
|
||||||
#: (``BOR_AGENT_LIST_CALLS`` / ``BOR_AGENT_READ_CALLS``) remain.
|
#: phase 45 removed the per-tool budgets; the round cap
|
||||||
|
#: (``BOR_AGENT_MAX_ROUNDS``) is the only bound.
|
||||||
AGENT_TOOLS: list[dict[str, Any]] = [
|
AGENT_TOOLS: list[dict[str, Any]] = [
|
||||||
{
|
{
|
||||||
"type": "function",
|
"type": "function",
|
||||||
@@ -90,7 +95,7 @@ AGENT_TOOLS: list[dict[str, Any]] = [
|
|||||||
"function": {
|
"function": {
|
||||||
"name": "read_document",
|
"name": "read_document",
|
||||||
"description": (
|
"description": (
|
||||||
"Add the full content of exactly one more indexed document "
|
"Add the full content of one more indexed document "
|
||||||
"to your context"
|
"to your context"
|
||||||
),
|
),
|
||||||
"parameters": {
|
"parameters": {
|
||||||
@@ -117,9 +122,9 @@ AGENT_TOOLS: list[dict[str, Any]] = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
#: Tool refusal texts (phase 37): rejected calls consume no budget.
|
#: Tool refusal texts (phase 37): rejected calls count in nothing
|
||||||
LIST_EXHAUSTED = "No listing budget left — answer with what you have."
|
#: (``holder.tool_calls`` tracks executed calls); the round cap bounds
|
||||||
READ_EXHAUSTED = "No reading budget left — answer with what you have."
|
#: their pathological repetition (phase 45).
|
||||||
ALREADY_IN_CONTEXT = "Already in your context."
|
ALREADY_IN_CONTEXT = "Already in your context."
|
||||||
UNKNOWN_TOOL = "Unknown tool."
|
UNKNOWN_TOOL = "Unknown tool."
|
||||||
MISSING_READ_ARGS = "read_document requires string arguments 'source' and 'path'."
|
MISSING_READ_ARGS = "read_document requires string arguments 'source' and 'path'."
|
||||||
@@ -155,8 +160,8 @@ class AgentHolder:
|
|||||||
|
|
||||||
``read_docs``: the documents ``read_document`` added to the context,
|
``read_docs``: the documents ``read_document`` added to the context,
|
||||||
in read order (deduped — re-reading a document appends nothing).
|
in read order (deduped — re-reading a document appends nothing).
|
||||||
``tool_calls``: how many tool executions consumed budget; rejected
|
``tool_calls``: how many tool calls executed (re-lists included);
|
||||||
calls (unknown tool, unknown/missing document, exhausted budget,
|
rejected calls (unknown tool, unknown/missing arguments or document,
|
||||||
already-in-context) do not count. Drives the per-turn log line's
|
already-in-context) do not count. Drives the per-turn log line's
|
||||||
``tool_calls=N`` field (task 04).
|
``tool_calls=N`` field (task 04).
|
||||||
"""
|
"""
|
||||||
@@ -170,47 +175,40 @@ def _execute_tool(
|
|||||||
call: ToolCallPiece,
|
call: ToolCallPiece,
|
||||||
seed_docs: Sequence[Document],
|
seed_docs: Sequence[Document],
|
||||||
holder: AgentHolder,
|
holder: AgentHolder,
|
||||||
list_left: int,
|
) -> str:
|
||||||
read_left: int,
|
|
||||||
) -> tuple[str, int, int]:
|
|
||||||
"""Execute one tool call server-side (DB only).
|
"""Execute one tool call server-side (DB only).
|
||||||
|
|
||||||
Returns ``(result, list_left, read_left)``. Rejected calls consume no
|
Returns the tool result text. A successful call bumps
|
||||||
budget; a successful read appends the :class:`Document` to
|
``holder.tool_calls`` (a successful read also appends the
|
||||||
``holder.read_docs`` and bumps ``holder.tool_calls``.
|
:class:`Document` to ``holder.read_docs``); rejected calls return
|
||||||
|
their refusal line and count in nothing.
|
||||||
"""
|
"""
|
||||||
if call.name == "list_documents":
|
if call.name == "list_documents":
|
||||||
if list_left <= 0:
|
|
||||||
return LIST_EXHAUSTED, list_left, read_left
|
|
||||||
rows = list_catalog(db)
|
rows = list_catalog(db)
|
||||||
listing = f"{len(rows)} documents:\n" + "\n".join(
|
listing = f"{len(rows)} documents:\n" + "\n".join(
|
||||||
f"{source}/{path} — {title}" for source, path, title in rows
|
f"{source}/{path} — {title}" for source, path, title in rows
|
||||||
)
|
)
|
||||||
holder.tool_calls += 1
|
holder.tool_calls += 1
|
||||||
return listing, list_left - 1, read_left
|
return listing
|
||||||
if call.name == "read_document":
|
if call.name == "read_document":
|
||||||
raw_source = call.arguments.get("source")
|
raw_source = call.arguments.get("source")
|
||||||
raw_path = call.arguments.get("path")
|
raw_path = call.arguments.get("path")
|
||||||
source = raw_source.strip() if isinstance(raw_source, str) else ""
|
source = raw_source.strip() if isinstance(raw_source, str) else ""
|
||||||
path = raw_path.strip() if isinstance(raw_path, str) else ""
|
path = raw_path.strip() if isinstance(raw_path, str) else ""
|
||||||
if not source or not path:
|
if not source or not path:
|
||||||
return MISSING_READ_ARGS, list_left, read_left
|
return MISSING_READ_ARGS
|
||||||
known = {(doc.source, doc.path) for doc in (*seed_docs, *holder.read_docs)}
|
known = {(doc.source, doc.path) for doc in (*seed_docs, *holder.read_docs)}
|
||||||
if (source, path) in known:
|
if (source, path) in known:
|
||||||
return ALREADY_IN_CONTEXT, list_left, read_left
|
return ALREADY_IN_CONTEXT
|
||||||
if read_left <= 0:
|
|
||||||
return READ_EXHAUSTED, list_left, read_left
|
|
||||||
doc = find_document(db, source, path)
|
doc = find_document(db, source, path)
|
||||||
if doc is None:
|
if doc is None:
|
||||||
return (
|
return (
|
||||||
f"No document at {source}/{path} — check the list_documents output.",
|
f"No document at {source}/{path} — check the list_documents output."
|
||||||
list_left,
|
|
||||||
read_left,
|
|
||||||
)
|
)
|
||||||
holder.read_docs.append(doc)
|
holder.read_docs.append(doc)
|
||||||
holder.tool_calls += 1
|
holder.tool_calls += 1
|
||||||
return f"Document {source}/{path}:\n{doc.content}", list_left, read_left - 1
|
return f"Document {source}/{path}:\n{doc.content}"
|
||||||
return UNKNOWN_TOOL, list_left, read_left
|
return UNKNOWN_TOOL
|
||||||
|
|
||||||
|
|
||||||
async def run_agent(
|
async def run_agent(
|
||||||
@@ -228,24 +226,23 @@ async def run_agent(
|
|||||||
Every piece (``thinking`` / ``content`` / tool calls) is yielded as it
|
Every piece (``thinking`` / ``content`` / tool calls) is yielded as it
|
||||||
arrives; the API layer (task 04) turns tool-call pieces into SSE
|
arrives; the API layer (task 04) turns tool-call pieces into SSE
|
||||||
``tool`` events. After the loop finishes, *holder* carries the read
|
``tool`` events. After the loop finishes, *holder* carries the read
|
||||||
documents and the budget-consuming tool count.
|
documents and the executed tool-call count (re-lists included).
|
||||||
|
|
||||||
``seed_docs`` are the documents the retrieval already put in context
|
``seed_docs`` are the documents the retrieval already put in context
|
||||||
(they shape the *system_prompt* the caller built); re-reading one of
|
(they shape the *system_prompt* the caller built); re-reading one of
|
||||||
them is rejected as "Already in your context." without spending budget.
|
them is rejected as "Already in your context." — the rejection counts
|
||||||
|
in nothing, but it still consumes a round.
|
||||||
"""
|
"""
|
||||||
messages: list[dict[str, Any]] = [
|
messages: list[dict[str, Any]] = [
|
||||||
{"role": "system", "content": system_prompt},
|
{"role": "system", "content": system_prompt},
|
||||||
{"role": "user", "content": user_message},
|
{"role": "user", "content": user_message},
|
||||||
]
|
]
|
||||||
list_left = settings.agent_list_calls
|
# Phase 45: no per-tool budgets — the tools stay offered for the
|
||||||
read_left = settings.agent_read_calls
|
# whole turn, bounded by the round cap. ``0`` is the no-tools kill
|
||||||
tools: list[dict[str, Any]] | None = AGENT_TOOLS if (list_left or read_left) else None
|
# switch: exactly one request with ``tools=None`` (the pre-phase-37
|
||||||
# Every tool round consumes a budget, so this cap only catches
|
# path).
|
||||||
# pathological streams that keep calling rejected tools (belt and
|
max_rounds = settings.agent_max_rounds
|
||||||
# braces — the budgets already force the answer after
|
tools: list[dict[str, Any]] | None = AGENT_TOOLS if max_rounds > 0 else None
|
||||||
# list + read rounds).
|
|
||||||
max_rounds = 2 + settings.agent_list_calls + settings.agent_read_calls
|
|
||||||
rounds = 0
|
rounds = 0
|
||||||
while True:
|
while True:
|
||||||
calls: list[ToolCallPiece] = []
|
calls: list[ToolCallPiece] = []
|
||||||
@@ -258,15 +255,14 @@ async def run_agent(
|
|||||||
if not calls:
|
if not calls:
|
||||||
return # the answer was streamed
|
return # the answer was streamed
|
||||||
call = calls[0] # a stream can carry several calls; run the first
|
call = calls[0] # a stream can carry several calls; run the first
|
||||||
result, list_left, read_left = _execute_tool(
|
result = _execute_tool(db, call, seed_docs, holder)
|
||||||
db, call, seed_docs, holder, list_left, read_left
|
rounds += 1 # every call the model emits consumes a round
|
||||||
)
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"agent tool=%s args=%s budget list_left=%d read_left=%d",
|
"agent tool=%s args=%s round=%d/%d",
|
||||||
call.name,
|
call.name,
|
||||||
json.dumps(call.arguments, ensure_ascii=False)[:200],
|
json.dumps(call.arguments, ensure_ascii=False)[:200],
|
||||||
list_left,
|
rounds,
|
||||||
read_left,
|
max_rounds,
|
||||||
)
|
)
|
||||||
messages.append(
|
messages.append(
|
||||||
{
|
{
|
||||||
@@ -285,8 +281,6 @@ async def run_agent(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
messages.append({"role": "tool", "tool_call_id": call.id, "content": result})
|
messages.append({"role": "tool", "tool_call_id": call.id, "content": result})
|
||||||
tools = None if (list_left == 0 and read_left == 0) else AGENT_TOOLS
|
|
||||||
rounds += 1
|
|
||||||
if rounds >= max_rounds:
|
if rounds >= max_rounds:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"agent round cap reached (rounds=%d) — forcing a final "
|
"agent round cap reached (rounds=%d) — forcing a final "
|
||||||
|
|||||||
@@ -0,0 +1,272 @@
|
|||||||
|
"""Safe archive unpacking for uploaded sources (phase 49, task 01).
|
||||||
|
|
||||||
|
Pure file-system logic — no FastAPI/DB imports. The API layer
|
||||||
|
(``POST /api/git-sources/upload``, phase 49, task 02) calls these and
|
||||||
|
maps :class:`ArchiveUploadError` to status codes (422).
|
||||||
|
|
||||||
|
The guarantees (phase 49 locked decisions):
|
||||||
|
|
||||||
|
* :func:`archive_source_name` derives the source name from the uploaded
|
||||||
|
filename — ONE trailing archive suffix stripped, longest-first so
|
||||||
|
``homelab.tar.gz`` yields ``homelab`` (never ``homelab.tar``) — and
|
||||||
|
rejects anything that would not be a safe single folder name (the name
|
||||||
|
becomes a directory under ``BOR_UPLOAD_DIR`` and the ``git_sources``
|
||||||
|
row's ``path``).
|
||||||
|
* :func:`unpack_archive` extracts ``.zip`` or ``.tar`` (gz/bz2/xz
|
||||||
|
transparently) into a fresh directory, rejecting absolute member
|
||||||
|
paths, ``..`` traversal, symlink/hardlink targets that escape the
|
||||||
|
unpack directory, and device/FIFO members — and counting every
|
||||||
|
extracted byte against a cap (zip-bomb guard). Any failure removes the
|
||||||
|
partial ``target_dir`` so no half-unpacked tree survives.
|
||||||
|
* :func:`swap_in` makes ``new_dir`` become ``final_dir`` with **no
|
||||||
|
missing window**: the previous folder is renamed to a unique
|
||||||
|
same-filesystem ``.old-`` sibling first, the new folder is renamed
|
||||||
|
into place, then the sibling is deleted. A failed swap restores the
|
||||||
|
previous folder (best effort) and removes ``new_dir``.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import stat
|
||||||
|
import tarfile
|
||||||
|
import uuid
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path, PurePosixPath
|
||||||
|
from typing import IO
|
||||||
|
|
||||||
|
#: Accepted archive suffixes, LONGEST FIRST — ``.tar.gz`` must match
|
||||||
|
#: before ``.tar`` would (phase 49 locked decision: these four formats
|
||||||
|
#: only).
|
||||||
|
ARCHIVE_SUFFIXES: tuple[str, ...] = (".tar.gz", ".tgz", ".zip", ".tar")
|
||||||
|
|
||||||
|
#: A client-supplied filename never legitimately contains a path
|
||||||
|
#: separator — reject rather than strip (defense in depth; the upload
|
||||||
|
#: also carries a browsed-path, which the browser normalizes).
|
||||||
|
_SEPARATOR_RE = re.compile(r"[/\\]")
|
||||||
|
|
||||||
|
#: Windows drive-letter prefix (``C:``) — an absolute member path in a
|
||||||
|
#: cross-platform zip.
|
||||||
|
_DRIVE_RE = re.compile(r"^[A-Za-z]:")
|
||||||
|
|
||||||
|
#: Streaming read size while counting extracted bytes.
|
||||||
|
_CHUNK_SIZE = 1 << 20
|
||||||
|
|
||||||
|
|
||||||
|
class ArchiveUploadError(Exception):
|
||||||
|
"""A user-safe archive/unpack error (the API maps it to a status).
|
||||||
|
|
||||||
|
Messages name the problem — and the cap where relevant — never the
|
||||||
|
archive's content and never any path beyond the owner's own upload
|
||||||
|
directory.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def archive_source_name(filename: str) -> str:
|
||||||
|
"""The source name for an uploaded archive filename.
|
||||||
|
|
||||||
|
The filename is used exactly as sent — a client can never
|
||||||
|
legitimately embed a ``/`` or ``\\``, so a separator is *rejected*
|
||||||
|
(the defensive basename step below is then a no-op, kept for the
|
||||||
|
contract). ONE trailing archive suffix from :data:`ARCHIVE_SUFFIXES`
|
||||||
|
is removed, matched case-insensitively and longest-first:
|
||||||
|
``homelab.tar.gz`` → ``homelab``, ``a.tar.gz`` → ``a`` (a single
|
||||||
|
compound strip, not a double one), ``a.zip.zip`` → ``a.zip``. The
|
||||||
|
stem keeps its original case — the name becomes a folder name on a
|
||||||
|
Linux filesystem (case-sensitive).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ArchiveUploadError: the filename is empty, contains a path
|
||||||
|
separator, or the stripped stem is empty / ``.`` / ``..`` /
|
||||||
|
contains control characters (the API maps to 422).
|
||||||
|
"""
|
||||||
|
if not filename:
|
||||||
|
raise ArchiveUploadError("empty file name")
|
||||||
|
if _SEPARATOR_RE.search(filename):
|
||||||
|
raise ArchiveUploadError("file name contains a path separator")
|
||||||
|
# No separator above, so the basename is the name itself — kept
|
||||||
|
# explicit so the "take the basename" contract lives in one place.
|
||||||
|
base = filename.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
|
||||||
|
lowered = base.lower()
|
||||||
|
for suffix in ARCHIVE_SUFFIXES: # longest first
|
||||||
|
if lowered.endswith(suffix):
|
||||||
|
base = base[: -len(suffix)]
|
||||||
|
break
|
||||||
|
if lowered == suffix.lstrip("."): # bare suffix ("tar.gz") — no stem
|
||||||
|
base = ""
|
||||||
|
break
|
||||||
|
if not base or base in (".", ".."):
|
||||||
|
raise ArchiveUploadError("archive file name has no usable source name")
|
||||||
|
if any(ord(c) < 0x20 or ord(c) == 0x7F for c in base):
|
||||||
|
raise ArchiveUploadError("archive file name contains control characters")
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
def _member_dest(name: str, target: Path) -> Path:
|
||||||
|
"""The destination path for one archive member, or raise.
|
||||||
|
|
||||||
|
Rejects empty names, absolute names (POSIX ``/``, Windows ``\\`` or
|
||||||
|
drive letters), and any ``..`` path component (zip names are
|
||||||
|
``/``-separated; backslash forms are normalized before the check).
|
||||||
|
The final path is resolved against the target (following any symlink
|
||||||
|
an earlier member may have created) and must stay inside ``target``.
|
||||||
|
"""
|
||||||
|
if not name:
|
||||||
|
raise ArchiveUploadError("archive member with an empty name")
|
||||||
|
if name.startswith(("/", "\\")) or _DRIVE_RE.match(name):
|
||||||
|
raise ArchiveUploadError("archive member with an absolute path")
|
||||||
|
if ".." in PurePosixPath(name).parts or ".." in name.replace("\\", "/").split("/"):
|
||||||
|
raise ArchiveUploadError("archive member path traversal")
|
||||||
|
dest = target / name
|
||||||
|
target_resolved = target.resolve()
|
||||||
|
dest_resolved = dest.resolve()
|
||||||
|
if dest_resolved != target_resolved and target_resolved not in dest_resolved.parents:
|
||||||
|
raise ArchiveUploadError("archive member path escapes the unpack directory")
|
||||||
|
return dest
|
||||||
|
|
||||||
|
|
||||||
|
def _link_target_resolved(linkname: str, link_dir: Path, target: Path) -> Path:
|
||||||
|
"""Resolve a symlink/hardlink target against its member's directory.
|
||||||
|
|
||||||
|
The resolved target must stay inside ``target`` — anything else
|
||||||
|
(absolute targets, ``..`` climbs out) is rejected. Returns the
|
||||||
|
resolved path (used directly for hardlinks).
|
||||||
|
"""
|
||||||
|
if not linkname:
|
||||||
|
raise ArchiveUploadError("archive link with an empty target")
|
||||||
|
# An absolute linkname wins over the join (pathlib semantics) and is
|
||||||
|
# then caught by the containment check below.
|
||||||
|
resolved = (link_dir / linkname).resolve()
|
||||||
|
target_resolved = target.resolve()
|
||||||
|
if resolved != target_resolved and target_resolved not in resolved.parents:
|
||||||
|
raise ArchiveUploadError("archive link target escapes the unpack directory")
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
|
def _write_capped(src: IO[bytes], dst: Path, max_extract_bytes: int, total: list[int]) -> None:
|
||||||
|
"""Stream ``src`` to ``dst``, counting into ``total[0]``.
|
||||||
|
|
||||||
|
Raises as soon as the cumulative extracted bytes EXCEED
|
||||||
|
``max_extract_bytes`` (the cap itself is exactly reachable). The
|
||||||
|
error names the cap, not the archive content.
|
||||||
|
"""
|
||||||
|
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(dst, "wb") as out:
|
||||||
|
while chunk := src.read(_CHUNK_SIZE):
|
||||||
|
total[0] += len(chunk)
|
||||||
|
if total[0] > max_extract_bytes:
|
||||||
|
raise ArchiveUploadError(
|
||||||
|
f"archive exceeds the {max_extract_bytes}-byte extraction cap"
|
||||||
|
)
|
||||||
|
out.write(chunk)
|
||||||
|
|
||||||
|
|
||||||
|
def _unpack_zip(archive: Path, target: Path, max_extract_bytes: int) -> None:
|
||||||
|
total: list[int] = [0]
|
||||||
|
with zipfile.ZipFile(archive) as zf:
|
||||||
|
for member in zf.infolist():
|
||||||
|
# The high 16 bits of external_attr are the Unix mode when
|
||||||
|
# present. Modes may be 0 (Windows-made zips), bare permission
|
||||||
|
# bits (CPython ``writestr``: 0o600), or a full mode with the
|
||||||
|
# file-type bits — only the latter can prove a member is a
|
||||||
|
# symlink/device/FIFO, and only those are rejected; entries
|
||||||
|
# without type bits are decided by the member name.
|
||||||
|
mode = member.external_attr >> 16
|
||||||
|
if stat.S_ISLNK(mode):
|
||||||
|
raise ArchiveUploadError("zip archives with symlink entries are not allowed")
|
||||||
|
if mode & 0o170000 and not (stat.S_ISREG(mode) or stat.S_ISDIR(mode)):
|
||||||
|
raise ArchiveUploadError("zip archives with non-regular entries are not allowed")
|
||||||
|
dest = _member_dest(member.filename, target)
|
||||||
|
if member.filename.endswith("/") or (mode and stat.S_ISDIR(mode)):
|
||||||
|
dest.mkdir(parents=True, exist_ok=True)
|
||||||
|
else:
|
||||||
|
with zf.open(member) as src:
|
||||||
|
_write_capped(src, dest, max_extract_bytes, total)
|
||||||
|
|
||||||
|
|
||||||
|
def _unpack_tar(archive: Path, target: Path, max_extract_bytes: int) -> None:
|
||||||
|
# ``r:*`` auto-detects plain/gz/bz2/xz compression.
|
||||||
|
total: list[int] = [0]
|
||||||
|
with tarfile.open(archive, mode="r:*") as tf:
|
||||||
|
for member in tf.getmembers():
|
||||||
|
dest = _member_dest(member.name, target)
|
||||||
|
if member.issym():
|
||||||
|
_link_target_resolved(member.linkname, dest.parent, target)
|
||||||
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
os.symlink(member.linkname, dest)
|
||||||
|
elif member.islnk():
|
||||||
|
resolved = _link_target_resolved(member.linkname, dest.parent, target)
|
||||||
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
os.link(resolved, dest)
|
||||||
|
elif member.isdir():
|
||||||
|
dest.mkdir(parents=True, exist_ok=True)
|
||||||
|
elif member.isreg():
|
||||||
|
src = tf.extractfile(member)
|
||||||
|
if src is None:
|
||||||
|
raise ArchiveUploadError("corrupt archive member")
|
||||||
|
_write_capped(src, dest, max_extract_bytes, total)
|
||||||
|
else: # char/block device, FIFO
|
||||||
|
raise ArchiveUploadError(
|
||||||
|
"tar archives with device or FIFO members are not allowed"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def unpack_archive(archive: Path, target_dir: Path, max_extract_bytes: int) -> None:
|
||||||
|
"""Extract ``archive`` into ``target_dir`` (created empty here).
|
||||||
|
|
||||||
|
``target_dir`` must NOT exist yet — the caller passes a fresh unique
|
||||||
|
path (a temp sibling of the final folder). On ANY failure — bad or
|
||||||
|
corrupt archive, unsafe member, cap exceeded, OS error — the partial
|
||||||
|
``target_dir`` is removed so no half-unpacked tree survives, and the
|
||||||
|
failure is raised as :class:`ArchiveUploadError` (the module's only
|
||||||
|
public exception type).
|
||||||
|
"""
|
||||||
|
if target_dir.exists() or target_dir.is_symlink():
|
||||||
|
raise ArchiveUploadError("the unpack target already exists")
|
||||||
|
target_dir.mkdir(parents=True)
|
||||||
|
try:
|
||||||
|
# Content-sniff the container: a .zip that is really a tar (or a
|
||||||
|
# truncated file) falls through to the tar reader and fails
|
||||||
|
# loudly there instead of half-extracting.
|
||||||
|
if zipfile.is_zipfile(archive):
|
||||||
|
_unpack_zip(archive, target_dir, max_extract_bytes)
|
||||||
|
else:
|
||||||
|
_unpack_tar(archive, target_dir, max_extract_bytes)
|
||||||
|
except Exception as exc:
|
||||||
|
shutil.rmtree(target_dir, ignore_errors=True)
|
||||||
|
if isinstance(exc, ArchiveUploadError):
|
||||||
|
raise
|
||||||
|
raise ArchiveUploadError("could not unpack the archive") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def swap_in(new_dir: Path, final_dir: Path) -> None:
|
||||||
|
"""Atomically make ``new_dir`` become ``final_dir`` (no missing window).
|
||||||
|
|
||||||
|
If ``final_dir`` exists it is first renamed to a unique same-
|
||||||
|
filesystem sibling ``<name>.old-<hex>``, then ``new_dir`` is renamed
|
||||||
|
into place, and the ``.old-`` sibling is deleted (re-uploads replace
|
||||||
|
the previous content in place — one folder, no stale files). If it
|
||||||
|
does not exist, this is a plain rename.
|
||||||
|
|
||||||
|
On a rename failure the previous folder is restored (best effort) and
|
||||||
|
``new_dir`` removed, then the failure is raised as
|
||||||
|
:class:`ArchiveUploadError` — a failed upload never leaves the
|
||||||
|
previous folder, row, or KB in a mixed state.
|
||||||
|
"""
|
||||||
|
old_dir: Path | None = None
|
||||||
|
try:
|
||||||
|
if final_dir.exists():
|
||||||
|
old_dir = final_dir.with_name(final_dir.name + ".old-" + uuid.uuid4().hex)
|
||||||
|
os.rename(final_dir, old_dir)
|
||||||
|
os.rename(new_dir, final_dir)
|
||||||
|
except OSError as exc:
|
||||||
|
if old_dir is not None:
|
||||||
|
try:
|
||||||
|
os.rename(old_dir, final_dir)
|
||||||
|
except OSError:
|
||||||
|
shutil.rmtree(old_dir, ignore_errors=True)
|
||||||
|
shutil.rmtree(new_dir, ignore_errors=True)
|
||||||
|
raise ArchiveUploadError("could not replace the previous folder") from exc
|
||||||
|
if old_dir is not None:
|
||||||
|
shutil.rmtree(old_dir)
|
||||||
+19
-1
@@ -22,6 +22,10 @@ per-format policies:
|
|||||||
module preamble — imports, constants — is its own block); an oversized
|
module preamble — imports, constants — is its own block); an oversized
|
||||||
definition falls back to line packing.
|
definition falls back to line packing.
|
||||||
* **txt** (and any unknown suffix) — paragraph packing.
|
* **txt** (and any unknown suffix) — paragraph packing.
|
||||||
|
* **container / network / volume / image / pod / kube / swap / os /
|
||||||
|
endpoint / j2** (A9 revised 2026-08-27) — quadlet unit files (TOML) and
|
||||||
|
Jinja templates; plain-text paragraph packing (``chunk_text``) — no
|
||||||
|
format-specific splitter (owner decision).
|
||||||
|
|
||||||
Every format honors :data:`HARD_MAX_CHARS` (1200 — the aipi ~1024-token
|
Every format honors :data:`HARD_MAX_CHARS` (1200 — the aipi ~1024-token
|
||||||
request cap) and the target/overlap settings; oversized blocks are split
|
request cap) and the target/overlap settings; oversized blocks are split
|
||||||
@@ -331,7 +335,8 @@ def _normalize_target_overlap(target_chars: int, overlap_chars: int) -> tuple[in
|
|||||||
return target, min(overlap_chars, target - 1)
|
return target, min(overlap_chars, target - 1)
|
||||||
|
|
||||||
|
|
||||||
#: suffix → chunker (A9, revised: md, markdown, txt, yaml, yml, json, py).
|
#: suffix → chunker (A9, revised 2026-08-21 and 2026-08-27: md, markdown,
|
||||||
|
#: txt, yaml, yml, json, py + the quadlet family and j2 — plain text).
|
||||||
_FORMAT_CHUNKERS = {
|
_FORMAT_CHUNKERS = {
|
||||||
".md": chunk_markdown,
|
".md": chunk_markdown,
|
||||||
".markdown": chunk_markdown,
|
".markdown": chunk_markdown,
|
||||||
@@ -340,6 +345,19 @@ _FORMAT_CHUNKERS = {
|
|||||||
".yml": chunk_yaml,
|
".yml": chunk_yaml,
|
||||||
".json": chunk_json,
|
".json": chunk_json,
|
||||||
".py": chunk_python,
|
".py": chunk_python,
|
||||||
|
# A9 revised 2026-08-27 (owner permission): the full Podman quadlet
|
||||||
|
# family + Jinja templates — plain-text paragraph packing, no
|
||||||
|
# TOML/Jinja-aware splitter (owner decision).
|
||||||
|
".container": chunk_text,
|
||||||
|
".network": chunk_text,
|
||||||
|
".volume": chunk_text,
|
||||||
|
".image": chunk_text,
|
||||||
|
".pod": chunk_text,
|
||||||
|
".kube": chunk_text,
|
||||||
|
".swap": chunk_text,
|
||||||
|
".os": chunk_text,
|
||||||
|
".endpoint": chunk_text,
|
||||||
|
".j2": chunk_text,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,15 @@ class LLMError(RuntimeError):
|
|||||||
"""The chat-completions endpoint failed (network, HTTP, or mid-stream)."""
|
"""The chat-completions endpoint failed (network, HTTP, or mid-stream)."""
|
||||||
|
|
||||||
|
|
||||||
|
class ModelUnavailableError(LLMError):
|
||||||
|
"""One of the models a sync needs is unreachable (phase 41 probe).
|
||||||
|
|
||||||
|
Raised by :func:`check_models` when the pre-sync probe finds the
|
||||||
|
embedding or summary model down; the message names the model so the
|
||||||
|
admin can fix the right thing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class StreamPiece:
|
class StreamPiece:
|
||||||
"""One piece of a streamed chat turn (phase 17, PLAN §4 extension).
|
"""One piece of a streamed chat turn (phase 17, PLAN §4 extension).
|
||||||
@@ -408,3 +417,33 @@ class LLMClient:
|
|||||||
raise
|
raise
|
||||||
except Exception as e: # noqa: BLE001 — wrap transport-level failures
|
except Exception as e: # noqa: BLE001 — wrap transport-level failures
|
||||||
raise LLMError(f"chat stream from {self.settings.llm_base_url} failed: {e}") from e
|
raise LLMError(f"chat stream from {self.settings.llm_base_url} failed: {e}") from e
|
||||||
|
|
||||||
|
|
||||||
|
async def check_models(llm: LLMClient) -> None:
|
||||||
|
"""Verify the models a sync needs (embed + summary) before any
|
||||||
|
expensive work; raise ModelUnavailableError naming the model.
|
||||||
|
|
||||||
|
The probe is deliberately tiny — one short embedding
|
||||||
|
(``sync model check``) and one 1-token-scale completion (``ping``)
|
||||||
|
— so a dead endpoint is discovered cheaper than a single git
|
||||||
|
clone. The sync sanitizer downstream (``app.api.sync
|
||||||
|
._sanitize_error``) still masks any credentials embedded in the
|
||||||
|
wrapped error text, so the raw endpoint URL in the original
|
||||||
|
exception is safe to include.
|
||||||
|
"""
|
||||||
|
embed_model = llm.settings.llm_embed_model
|
||||||
|
try:
|
||||||
|
await llm.embed_one("sync model check")
|
||||||
|
except Exception as e: # noqa: BLE001 — wrap EmbeddingError + transport failures
|
||||||
|
raise ModelUnavailableError(
|
||||||
|
f"The embedding model ('{embed_model}') is not available — "
|
||||||
|
f"check the model endpoint and retry. ({e})"
|
||||||
|
) from e
|
||||||
|
summary_model = llm.settings.llm_summary_model
|
||||||
|
try:
|
||||||
|
await llm.chat([{"role": "user", "content": "ping"}])
|
||||||
|
except Exception as e: # noqa: BLE001 — wrap LLMError + transport failures
|
||||||
|
raise ModelUnavailableError(
|
||||||
|
f"The summary model ('{summary_model}') is not available — "
|
||||||
|
f"check the model endpoint and retry. ({e})"
|
||||||
|
) from e
|
||||||
|
|||||||
+7
-7
@@ -26,9 +26,9 @@ prompt is byte-identical to the pre-phase text.
|
|||||||
|
|
||||||
Agent tools (phase 37): the **HIGH** prompt only carries a ``<tools>``
|
Agent tools (phase 37): the **HIGH** prompt only carries a ``<tools>``
|
||||||
section after the ``<documents>`` body — the grounded turn may call the
|
section after the ``<documents>`` body — the grounded turn may call the
|
||||||
server-side ``list_documents`` / ``read_document`` tools (budgeted, see
|
server-side ``list_documents`` / ``read_document`` tools (round-capped,
|
||||||
:mod:`app.rag.agent`). The LOW/deflection prompt never carries it and
|
see :mod:`app.rag.agent`). The LOW/deflection prompt never carries it
|
||||||
stays byte-identical to the pre-phase text.
|
and stays byte-identical to the pre-phase text.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -74,8 +74,8 @@ _KB_INTRO = (
|
|||||||
|
|
||||||
#: The ``<tools>`` instructions section — **HIGH prompt only** (phase 37,
|
#: The ``<tools>`` instructions section — **HIGH prompt only** (phase 37,
|
||||||
#: task 03): a grounded turn may extend its context through the two
|
#: task 03): a grounded turn may extend its context through the two
|
||||||
#: server-side tools (budgets: ``BOR_AGENT_LIST_CALLS`` /
|
#: server-side tools (round cap: ``BOR_AGENT_MAX_ROUNDS``, see
|
||||||
#: ``BOR_AGENT_READ_CALLS``, see :mod:`app.rag.agent`). Appended after
|
#: :mod:`app.rag.agent`). Appended after
|
||||||
#: the mode body (``<documents>``), so the instructions are the last
|
#: the mode body (``<documents>``), so the instructions are the last
|
||||||
#: thing the model reads. The LOW/deflection prompt never carries it —
|
#: thing the model reads. The LOW/deflection prompt never carries it —
|
||||||
#: a deflection has no grounded context to extend — and stays
|
#: a deflection has no grounded context to extend — and stays
|
||||||
@@ -184,8 +184,8 @@ texts of the top documents + the ``<tools>`` instructions (phase 37).
|
|||||||
|
|
||||||
Section order: ``<relevance>`` → ``<knowledge_base>`` → ``<tuning>``
|
Section order: ``<relevance>`` → ``<knowledge_base>`` → ``<tuning>``
|
||||||
→ ``<documents>`` → ``<tools>``; empty steering/overview omit their
|
→ ``<documents>`` → ``<tools>``; empty steering/overview omit their
|
||||||
section. ``<tools>`` is always present in the HIGH prompt (the
|
section. ``<tools>`` is always present in the HIGH prompt (the round
|
||||||
budgets — not the prompt — decide whether the tools are actually
|
cap — not the prompt — decides whether the tools are actually
|
||||||
offered to the model, see :mod:`app.rag.agent`).
|
offered to the model, see :mod:`app.rag.agent`).
|
||||||
"""
|
"""
|
||||||
blocks = [
|
blocks = [
|
||||||
|
|||||||
@@ -260,3 +260,25 @@ class GitSourceList(BaseModel):
|
|||||||
|
|
||||||
sources: list[GitSourceRow]
|
sources: list[GitSourceRow]
|
||||||
from_env: bool
|
from_env: bool
|
||||||
|
|
||||||
|
|
||||||
|
class UploadOut(BaseModel):
|
||||||
|
"""``POST /api/git-sources/upload`` response (phase 49, task 02).
|
||||||
|
|
||||||
|
The uploaded source's name (filename minus the archive suffix) plus
|
||||||
|
the SAME count keys as the admin sync's success ``detail``
|
||||||
|
(``files``, ``added``, ``updated``, ``unchanged``, ``pruned``,
|
||||||
|
``errors``, ``chunks`` — ``app.api.sync._run_sync``) and the
|
||||||
|
``overview`` flag: the Sources page renders the same
|
||||||
|
"N added · N pruned" result line for both.
|
||||||
|
"""
|
||||||
|
|
||||||
|
source: str
|
||||||
|
files: int
|
||||||
|
added: int
|
||||||
|
updated: int
|
||||||
|
unchanged: int
|
||||||
|
pruned: int
|
||||||
|
errors: int
|
||||||
|
chunks: int
|
||||||
|
overview: bool
|
||||||
|
|||||||
+85
-45
@@ -70,15 +70,19 @@
|
|||||||
* (announceSteering()) through the module. Note text is always rendered
|
* (announceSteering()) through the module. Note text is always rendered
|
||||||
* with textContent (XSS-safe) in both places.
|
* with textContent (XSS-safe) in both places.
|
||||||
*
|
*
|
||||||
* Scroll (phase 18, owner choice 2026-08-23): the page auto-scrolls only
|
* No reply autoscroll (owner direction 2026-08-27, TODO.md L5 —
|
||||||
* while the user is pinned to the bottom. NEAR_BOTTOM_PX (200px) covers
|
* revising the phase 18 follow-the-bottom choice): the page NEVER
|
||||||
* the composer zone — the textarea auto-grows to 192px plus the button
|
* auto-scrolls while a turn streams — no thinking, tool, or delta frame
|
||||||
* row — so "the composer is in view" counts as pinned: submitting from
|
* moves the viewport, so scrolling up to read earlier content holds for
|
||||||
* the composer reveals your own message, and the answer follows token by
|
* the rest of the turn. The only scroll call sites are user intent: the
|
||||||
* token while you stay pinned. Once you scroll up to read earlier
|
* submit (your own message is revealed) and the phase-14 restore landing
|
||||||
* content, nothing drags the viewport back down for the rest of the turn
|
* (one-shot, load-time). scrollReveal(wrap) is the one page scroll in
|
||||||
* (thinking or answer). scrollReveal(wrap) is the single scroll gate;
|
* this file — a document-BOTTOM landing, not a message-bottom alignment
|
||||||
* `force` is reserved for the one-shot phase-14 restore landing.
|
* (its comment explains why block:"end" hopped the page up on submit);
|
||||||
|
* addMessage(who, html, scroll) carries the intent. The
|
||||||
|
* thinking block's internal bottom-pin (textEl.scrollTop, phase 17 —
|
||||||
|
* reworked separately in phase 43) pins the block's own clip, not the
|
||||||
|
* page, and is untouched here.
|
||||||
*
|
*
|
||||||
* Document modal (phase 26): a source chip opens the cited document in
|
* Document modal (phase 26): a source chip opens the cited document in
|
||||||
* the almost-fullscreen modal overlay (assets/document-modal.js) on the
|
* the almost-fullscreen modal overlay (assets/document-modal.js) on the
|
||||||
@@ -160,26 +164,44 @@ const reducedMotion =
|
|||||||
typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
|
typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
const SCROLL = reducedMotion ? "auto" : "smooth";
|
const SCROLL = reducedMotion ? "auto" : "smooth";
|
||||||
|
|
||||||
/* Follow-the-bottom scroll contract (phase 18, owner choice
|
/* Thinking-window follow-the-tail contract (owner direction
|
||||||
* 2026-08-23): the page auto-scrolls only while the user is pinned
|
* 2026-08-27, `TODO.md` L7): the scratchpad autoscrolls to its live
|
||||||
* at the bottom — the 200px band covers the composer zone (the
|
* tail only while the user is pinned near the window's bottom —
|
||||||
* textarea auto-grows to 192px + the button row), i.e. "the
|
* the 32px band is the "window bottom in view" threshold. Scrolling
|
||||||
* composer is in view". Exported so the band is unit-pinned (same
|
* up pauses the follow; returning to the bottom resumes it (the
|
||||||
* pattern as TURN_TIMEOUT_MS). */
|
* check runs on every chunk — against the PRE-render geometry: a
|
||||||
export const NEAR_BOTTOM_PX = 200;
|
* post-render reading measures the new chunk's height, not the user's
|
||||||
|
* position, and the follow died at the first \"\n\n\" paragraph break).
|
||||||
|
* Exported so the band is unit-pinned (same pattern as TURN_TIMEOUT_MS). */
|
||||||
|
export const THINKING_NEAR_BOTTOM_PX = 32;
|
||||||
|
|
||||||
function isNearBottom() {
|
function isThinkingNearBottom(textEl) {
|
||||||
const bottom =
|
return (
|
||||||
document.documentElement.scrollHeight - window.scrollY - window.innerHeight;
|
textEl.scrollHeight - textEl.scrollTop - textEl.clientHeight <=
|
||||||
return bottom <= NEAR_BOTTOM_PX;
|
THINKING_NEAR_BOTTOM_PX
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The ONE scroll call site in this file. `force` is used only by
|
/* No reply autoscroll (owner direction 2026-08-27, TODO.md L5 —
|
||||||
* the phase-14 restore landing (one-shot, load-time). */
|
* revising the phase 18 follow-the-bottom choice): the page never
|
||||||
function scrollReveal(wrap, behavior = SCROLL, force = false) {
|
* auto-scrolls while a turn streams. The only scroll call sites are
|
||||||
if (force || isNearBottom()) {
|
* the user submit (reveal my message) and the phase-14 restore
|
||||||
wrap.scrollIntoView({ behavior, block: "end" });
|
* landing (one-shot, load-time). */
|
||||||
}
|
|
||||||
|
/* The ONE page scroll in this file — unconditional (unit-pinned):
|
||||||
|
* scrollReveal scrolls whenever it is called, so a page scroll can only
|
||||||
|
* ever happen from those two user-intent call sites. It lands at the
|
||||||
|
* DOCUMENT BOTTOM, not on the message's own bottom edge: the composer
|
||||||
|
* and footer sit below the message in flow, so the old
|
||||||
|
* scrollIntoView({ block: "end" }) aligned the message's bottom to the
|
||||||
|
* viewport bottom — ABOVE the document bottom — and hopped the page UP
|
||||||
|
* by the composer+footer height on every submit (pushing the composer
|
||||||
|
* below the fold). At the document bottom the revealed message sits in
|
||||||
|
* view with the composer right under it. `wrap` is the revealed
|
||||||
|
* element, kept in the signature so the call sites read as intent. */
|
||||||
|
function scrollReveal(wrap, behavior = SCROLL) {
|
||||||
|
void wrap;
|
||||||
|
window.scrollTo({ top: document.documentElement.scrollHeight, behavior });
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- document viewer link (phase 10; phase 13 adds `back`) ----------
|
/* ---------- document viewer link (phase 10; phase 13 adds `back`) ----------
|
||||||
@@ -339,10 +361,12 @@ const USER_AVATAR =
|
|||||||
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="8" r="3.6"/><path d="M4.8 20.2c.9-3.9 3.8-6 7.2-6s6.3 2.1 7.2 6"/></svg>';
|
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="8" r="3.6"/><path d="M4.8 20.2c.9-3.9 3.8-6 7.2-6s6.3 2.1 7.2 6"/></svg>';
|
||||||
|
|
||||||
/* ---------- messages ----------
|
/* ---------- messages ----------
|
||||||
* Scroll is conditional (phase 18): addMessage reveals through
|
* Scroll is explicit intent (phase 42, no reply autoscroll): addMessage
|
||||||
* scrollReveal — only when the user is pinned to the bottom, or when
|
* scrolls only when the caller passes `scroll = true` — the user submit
|
||||||
* forced (the one-shot phase-14 restore landing). */
|
* (reveal my message) and the phase-14 restore landing. The streaming
|
||||||
function addMessage(who, html, scrollBehavior = SCROLL, force = false) {
|
* path (thinking / tool / delta) creates bubbles with the default
|
||||||
|
* (scroll = false): the page never follows a turn. */
|
||||||
|
function addMessage(who, html, scroll = false) {
|
||||||
if (emptyState) emptyState.hidden = true;
|
if (emptyState) emptyState.hidden = true;
|
||||||
const wrap = document.createElement("div");
|
const wrap = document.createElement("div");
|
||||||
wrap.className = `msg ${who}`;
|
wrap.className = `msg ${who}`;
|
||||||
@@ -352,7 +376,7 @@ function addMessage(who, html, scrollBehavior = SCROLL, force = false) {
|
|||||||
<div class="bubble">${html}</div>
|
<div class="bubble">${html}</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
messagesEl.appendChild(wrap);
|
messagesEl.appendChild(wrap);
|
||||||
scrollReveal(wrap, scrollBehavior, force);
|
if (scroll) scrollReveal(wrap);
|
||||||
return wrap;
|
return wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,7 +394,7 @@ function addTyping() {
|
|||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
messagesEl.appendChild(wrap);
|
messagesEl.appendChild(wrap);
|
||||||
scrollReveal(wrap);
|
// No page scroll (phase 42): a typing bubble must not yank the viewport.
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeTyping() {
|
function removeTyping() {
|
||||||
@@ -765,14 +789,17 @@ export function clearStoredConversation() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderStoredMessage(m) {
|
function renderStoredMessage(m) {
|
||||||
// Phase 18: the restore landing is the only `force`d scroll — one-shot,
|
// Phase-14 restore landing (kept by the phase-42 direction): the
|
||||||
// non-smooth, so a restored conversation lands on its latest message
|
// one-shot load-time scroll — scroll=true so a restored conversation
|
||||||
// (phase-14 behavior preserved) without smooth-scrolling through it.
|
// lands on its latest message. The new addMessage(who, html, scroll)
|
||||||
|
// signature has no per-call behavior override, so the landing rides
|
||||||
|
// the default SCROLL (smooth; "auto" under prefers-reduced-motion)
|
||||||
|
// instead of the old forced "auto" — noted per the phase-42 task.
|
||||||
if (m.who === "user") {
|
if (m.who === "user") {
|
||||||
addMessage("user", renderMarkdown(m.text), "auto", true);
|
addMessage("user", renderMarkdown(m.text), true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const wrap = addMessage("brain", renderMarkdown(m.text), "auto", true);
|
const wrap = addMessage("brain", renderMarkdown(m.text), true);
|
||||||
if (m.thinking) {
|
if (m.thinking) {
|
||||||
// Phase 17: restore the thinking block COLLAPSED above the bubble.
|
// Phase 17: restore the thinking block COLLAPSED above the bubble.
|
||||||
const block = ensureThinkingBlock(wrap);
|
const block = ensureThinkingBlock(wrap);
|
||||||
@@ -893,7 +920,7 @@ async function handleSend(e) {
|
|||||||
const text = input.value.trim();
|
const text = input.value.trim();
|
||||||
if (!text || sendBtn.disabled) return;
|
if (!text || sendBtn.disabled) return;
|
||||||
|
|
||||||
addMessage("user", renderMarkdown(text));
|
addMessage("user", renderMarkdown(text), true); // reveal my message (owner-kept)
|
||||||
// Persistence save point 1: the question is stored the moment it is
|
// Persistence save point 1: the question is stored the moment it is
|
||||||
// sent, so a failed/interrupted turn never loses it.
|
// sent, so a failed/interrupted turn never loses it.
|
||||||
conversation.push({ who: "user", text });
|
conversation.push({ who: "user", text });
|
||||||
@@ -956,10 +983,22 @@ async function handleSend(e) {
|
|||||||
removeTyping(); // the live block replaces the dots as feedback
|
removeTyping(); // the live block replaces the dots as feedback
|
||||||
const block = ensureThinkingBlock(wrap);
|
const block = ensureThinkingBlock(wrap);
|
||||||
const textEl = block.querySelector(".thinking-text");
|
const textEl = block.querySelector(".thinking-text");
|
||||||
|
// Pin state is measured BEFORE the re-render: a chunk taller
|
||||||
|
// than the 32px band — any paragraph break ("\n\n") or a few
|
||||||
|
// lines of text, which is exactly what the real model streams —
|
||||||
|
// grows the window's content below the old bottom, so measuring
|
||||||
|
// the distance AFTER the update reads the chunk's height, not
|
||||||
|
// the user's position, and the follow died at the first
|
||||||
|
// 2-newline gap. Pre-update, the distance is where the user
|
||||||
|
// actually is.
|
||||||
|
const pinned = block.open && isThinkingNearBottom(textEl);
|
||||||
textEl.innerHTML = renderMarkdown(thinkingAcc); // escape-first, XSS-safe
|
textEl.innerHTML = renderMarkdown(thinkingAcc); // escape-first, XSS-safe
|
||||||
if (block.open) {
|
if (pinned) {
|
||||||
textEl.scrollTop = textEl.scrollHeight; // pin the stream to the bottom
|
// Follow the live tail only while the user was pinned to the
|
||||||
scrollReveal(wrap); // page follows only while pinned (phase 18)
|
// window bottom before this chunk (owner direction
|
||||||
|
// 2026-08-27); a scrolled-up reader is never re-pinned —
|
||||||
|
// returning to the bottom re-arms the pin on the next chunk.
|
||||||
|
textEl.scrollTop = textEl.scrollHeight;
|
||||||
}
|
}
|
||||||
} else if (ev.type === "tool") {
|
} else if (ev.type === "tool") {
|
||||||
// Phase 37 (PLAN §4 extension): an agent tool call. The UI
|
// Phase 37 (PLAN §4 extension): an agent tool call. The UI
|
||||||
@@ -988,14 +1027,14 @@ async function handleSend(e) {
|
|||||||
?.setAttribute("aria-label", toolStatus);
|
?.setAttribute("aria-label", toolStatus);
|
||||||
}
|
}
|
||||||
appendToolLine(wrap, name, argument);
|
appendToolLine(wrap, name, argument);
|
||||||
scrollReveal(wrap); // page follows only while pinned (phase 18)
|
// No page scroll (phase 42): tool lines never yank the viewport.
|
||||||
} else if (ev.type === "delta") {
|
} else if (ev.type === "delta") {
|
||||||
acc += ev.text || "";
|
acc += ev.text || "";
|
||||||
if (uiState === UI_STATE.thinking) setUiState(UI_STATE.streaming);
|
if (uiState === UI_STATE.thinking) setUiState(UI_STATE.streaming);
|
||||||
if (!wrap) wrap = addMessage("brain", ""); // first token: live bubble in
|
if (!wrap) wrap = addMessage("brain", ""); // first token: live bubble in
|
||||||
closeThinkingBlock(wrap); // auto-collapse; idempotent, never reopens
|
closeThinkingBlock(wrap); // auto-collapse; idempotent, never reopens
|
||||||
wrap.querySelector(".bubble").innerHTML = renderMarkdown(acc);
|
wrap.querySelector(".bubble").innerHTML = renderMarkdown(acc);
|
||||||
scrollReveal(wrap); // page follows only while pinned (phase 18)
|
// No page scroll (phase 42): the answer never follows the viewport.
|
||||||
} else if (ev.type === "done") {
|
} else if (ev.type === "done") {
|
||||||
sawDone = true;
|
sawDone = true;
|
||||||
closeThinkingBlock(wrap); // the turn is over: settle the block closed
|
closeThinkingBlock(wrap); // the turn is over: settle the block closed
|
||||||
@@ -1062,8 +1101,9 @@ async function handleSend(e) {
|
|||||||
stopThinkingClock();
|
stopThinkingClock();
|
||||||
cancelStream(res); // the reader lock is released — no unhandled rejection
|
cancelStream(res); // the reader lock is released — no unhandled rejection
|
||||||
if (uiState !== UI_STATE.idle) setUiState(UI_STATE.idle);
|
if (uiState !== UI_STATE.idle) setUiState(UI_STATE.idle);
|
||||||
// Phase 18: focus back for the next question, but never move the
|
// Focus back for the next question, but never move the viewport —
|
||||||
// viewport — a user reading earlier content stays where they are.
|
// the page never auto-scrolls (phase 42), so a user reading earlier
|
||||||
|
// content stays where they are.
|
||||||
input.focus({ preventScroll: true });
|
input.focus({ preventScroll: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+136
-43
@@ -1,10 +1,12 @@
|
|||||||
/* Brain of Reese — Git sources admin page (phase 35, task 04;
|
/* Brain of Reese — Git sources admin page (phase 35, task 04;
|
||||||
* local directories, phase 38 task 04).
|
* archive uploads, phase 49 task 03).
|
||||||
*
|
*
|
||||||
* The page module for /git-sources.html: the admin-only manager for the
|
* The page module for /git-sources.html: the admin-only manager for the
|
||||||
* stored source list (git-sources table, phase 35 tasks 01/02) — git
|
* stored source list (git-sources table, phase 35 tasks 01/02) — git
|
||||||
* repo URLs (kind "git") and existing local directories (kind
|
* repo URLs (kind "git") and uploaded archives unpacked under
|
||||||
* "local", phase 38).
|
* BOR_UPLOAD_DIR (kind "local", phase 49; the phase-38
|
||||||
|
* local-directory form is gone — the kind=local API POST is
|
||||||
|
* unchanged, the page just no longer offers it).
|
||||||
* This module is the single owner of the page's behaviour:
|
* This module is the single owner of the page's behaviour:
|
||||||
*
|
*
|
||||||
* • boot — initSharedHeader() (one cached whoami, shared with the
|
* • boot — initSharedHeader() (one cached whoami, shared with the
|
||||||
@@ -22,17 +24,30 @@
|
|||||||
* credentials; phase 32's masking discipline). Non-2xx or a
|
* credentials; phase 32's masking discipline). Non-2xx or a
|
||||||
* network failure renders the role="alert" load error with a
|
* network failure renders the role="alert" load error with a
|
||||||
* retry button — never a stuck page.
|
* retry button — never a stuck page.
|
||||||
* • add — #git-source-form submit → POST /api/git-sources {url};
|
* • add — #git-source-form submit → POST /api/git-sources {url}.
|
||||||
* #local-source-form submit → POST /api/git-sources
|
* The §7.4 never-stale lifecycle (wireAddForm): the button
|
||||||
* {kind: "local", path} (phase 38). ONE §7.4 never-stale
|
* disables + relabels "Adding…" while the request is out,
|
||||||
* lifecycle for both (wireAddForm): the button disables +
|
* re-enables on success AND failure. 201 clears the input,
|
||||||
* relabels "Adding…" while the request is out, re-enables on
|
* reloads the list, and focuses the new row's Remove button
|
||||||
* success AND failure. 201 clears the input, reloads the list,
|
* (a11y); a failure (409 duplicate, 422 validation) shows the
|
||||||
* and focuses the new row's Remove button (a11y); a failure (409
|
* server detail inline under the form (role="alert", 422
|
||||||
* duplicate, 422 validation) shows the server detail inline under
|
* shape-aware like the tuning forms) and keeps the input — the
|
||||||
* the form (role="alert", 422 shape-aware like the tuning forms)
|
* instruction survives. 409/422 details are fixed generic strings
|
||||||
* and keeps the input — the instruction survives. Local 422/409
|
* (credential safety — the URL is never echoed).
|
||||||
* details name the path (paths are not secrets, unlike URLs).
|
* • upload — #archive-upload-form submit (phase 49) → POST
|
||||||
|
* /api/git-sources/upload with a FormData file (NO manual
|
||||||
|
* Content-Type — the browser sets the multipart boundary). The
|
||||||
|
* SAME §7.4 never-stale lifecycle: the button disables +
|
||||||
|
* relabels "Uploading…" while the request is out and is restored
|
||||||
|
* on success AND failure. 200 clears the file input, shows the
|
||||||
|
* sync-style count line ("2 added · 1 pruned" — fmtUploadResult,
|
||||||
|
* sources.js's fmtSyncResult convention) in the role=status
|
||||||
|
* result line, announces "Archive uploaded: …" and reloads the
|
||||||
|
* list (the new/updated row lands with the Local badge; a
|
||||||
|
* re-upload simply refreshes the row — no duplicate). Non-2xx
|
||||||
|
* inlines the server detail (422 format/name/traversal, 413
|
||||||
|
* size, 409 busy — the messages are already user-safe) and KEEPS
|
||||||
|
* the file selection — the fix is one re-pick, not a re-type.
|
||||||
* • remove — a row's Remove button asks window.confirm first
|
* • remove — a row's Remove button asks window.confirm first
|
||||||
* (removal prunes the documents only on the NEXT sync — the
|
* (removal prunes the documents only on the NEXT sync — the
|
||||||
* confirm says so). Cancel → nothing; ok → the row button
|
* confirm says so). Cancel → nothing; ok → the row button
|
||||||
@@ -45,10 +60,12 @@
|
|||||||
* aria-live=polite): the screen-reader confirmation for loads,
|
* aria-live=polite): the screen-reader confirmation for loads,
|
||||||
* adds, and removals.
|
* adds, and removals.
|
||||||
*
|
*
|
||||||
* Scope boundary (phase locked decisions): adding or removing a repo
|
* Scope boundary (phase locked decisions): adding a git repo or
|
||||||
* does NOT clone, import, or prune — the header's Sync sources button
|
* removing a source does NOT clone, import, or prune — the sync
|
||||||
* (module-owned in assets/header.js) performs that; the page's hint
|
* service (server-side) performs that; the page's hint box says so.
|
||||||
* box says so.
|
* The phase-49 upload is the exception: it unpacks and scans the
|
||||||
|
* single source in place, and its response counts render as the
|
||||||
|
* result line.
|
||||||
*
|
*
|
||||||
* The shared header module loads through this script's own relative
|
* The shared header module loads through this script's own relative
|
||||||
* import ("./header.js") — a hoisted import evaluated before this body
|
* import ("./header.js") — a hoisted import evaluated before this body
|
||||||
@@ -65,12 +82,14 @@ const formEl = document.querySelector("#git-source-form");
|
|||||||
const urlInput = document.querySelector("#git-source-url");
|
const urlInput = document.querySelector("#git-source-url");
|
||||||
const addBtn = document.querySelector("#git-source-add");
|
const addBtn = document.querySelector("#git-source-add");
|
||||||
const addError = document.querySelector("#git-source-error");
|
const addError = document.querySelector("#git-source-error");
|
||||||
/* Phase 38: the second add form — "Local directory" (same element
|
/* Phase 49: the archive upload form (replaces the phase-38 local
|
||||||
contract as the git form, own ids). */
|
directory form — same card, a file input instead of a path input).
|
||||||
const localFormEl = document.querySelector("#local-source-form");
|
The response counts render in the role=status result line. */
|
||||||
const pathInput = document.querySelector("#local-source-path");
|
const uploadFormEl = document.querySelector("#archive-upload-form");
|
||||||
const localAddBtn = document.querySelector("#local-source-add");
|
const uploadFileInput = document.querySelector("#archive-upload-file");
|
||||||
const localAddError = document.querySelector("#local-source-error");
|
const uploadBtn = document.querySelector("#archive-upload-btn");
|
||||||
|
const uploadError = document.querySelector("#archive-upload-error");
|
||||||
|
const uploadResult = document.querySelector("#archive-upload-result");
|
||||||
const loadErrorEl = document.querySelector("#git-sources-load-error");
|
const loadErrorEl = document.querySelector("#git-sources-load-error");
|
||||||
const loadErrorText = document.querySelector("#git-sources-load-error-text");
|
const loadErrorText = document.querySelector("#git-sources-load-error-text");
|
||||||
const retryBtn = document.querySelector("#git-sources-retry");
|
const retryBtn = document.querySelector("#git-sources-retry");
|
||||||
@@ -256,18 +275,16 @@ async function removeSource(s, btn, rowError, kindLabel) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- add (POST /api/git-sources) — both forms, one lifecycle
|
/* ---------- add (POST /api/git-sources) — the git form ----------
|
||||||
* (the local form is phase 38) ----------
|
* wireAddForm gives the form the §7.4 never-stale lifecycle: while
|
||||||
* The git form posts {url}; the local form posts {kind:"local",path}.
|
* the request is out the button disables + relabels "Adding…" and
|
||||||
* wireAddForm gives both the §7.4 never-stale lifecycle: while the
|
|
||||||
* request is out the button disables + relabels "Adding…" and
|
|
||||||
* re-enables (idle label restored) on success AND failure. 201 clears
|
* re-enables (idle label restored) on success AND failure. 201 clears
|
||||||
* the input, reloads the list, and focuses the new row's Remove button
|
* the input, reloads the list, and focuses the new row's Remove button
|
||||||
* (a11y); a failure (409 duplicate, 422 validation) shows the server
|
* (a11y); a failure (409 duplicate, 422 validation) shows the server
|
||||||
* detail inline under the form (role="alert", 422 shape-aware via
|
* detail inline under the form (role="alert", 422 shape-aware via
|
||||||
* apiDetail) and keeps the input — the fix is one edit, not a re-type.
|
* apiDetail) and keeps the input — the fix is one edit, not a re-type.
|
||||||
* Git 409/422 details are fixed generic strings (credential safety);
|
* 409/422 details are fixed generic strings (credential safety — the
|
||||||
* local details name the path (not a secret). */
|
* URL is never echoed). */
|
||||||
function wireAddForm(opts) {
|
function wireAddForm(opts) {
|
||||||
const { form, input, btn, error } = opts;
|
const { form, input, btn, error } = opts;
|
||||||
if (!form || !input || !btn) return;
|
if (!form || !input || !btn) return;
|
||||||
@@ -337,18 +354,94 @@ wireAddForm({
|
|||||||
idleLabel: "Add source",
|
idleLabel: "Add source",
|
||||||
});
|
});
|
||||||
|
|
||||||
wireAddForm({
|
/* ---------- upload (POST /api/git-sources/upload) — phase 49 -------
|
||||||
form: localFormEl,
|
* The archive upload form: the file input's selection is posted as
|
||||||
input: pathInput,
|
* FormData (the browser sets the multipart boundary — no manual
|
||||||
btn: localAddBtn,
|
* Content-Type). §7.4 never-stale: "Uploading…" while in flight,
|
||||||
error: localAddError,
|
* restored in the finally block on success AND failure. 200 → the
|
||||||
body: (path) => ({ kind: "local", path }),
|
* input clears, the sync-style counts land in the role=status result
|
||||||
emptyMessage: "Enter a directory path to add.",
|
* line, the announcer confirms, and loadSources() re-renders the row
|
||||||
failMessage: "Could not add the local directory — try again.",
|
* (Local badge; a re-upload refreshes the existing row — no
|
||||||
networkMessage: "Could not add the local directory — is the app reachable?",
|
* duplicate). Non-2xx → the server detail inline (role=alert; 422
|
||||||
addedMessage: "Local source added.",
|
* format/name/traversal, 413 size, 409 busy — user-safe as-is) with
|
||||||
idleLabel: "Add directory",
|
* the file selection KEPT; network failure → the fixed line. */
|
||||||
});
|
|
||||||
|
/* The success line's text — the sync-result shape (sources.js's
|
||||||
|
fmtSyncResult convention): "N added" always leads, then updated /
|
||||||
|
unchanged / pruned — zero parts omitted (unchanged is shown
|
||||||
|
when nothing was added or updated). */
|
||||||
|
function fmtUploadResult(detail) {
|
||||||
|
const d = detail || {};
|
||||||
|
const added = d.added || 0;
|
||||||
|
const updated = d.updated || 0;
|
||||||
|
const parts = [`${added} added`];
|
||||||
|
if (updated > 0) parts.push(`${updated} updated`);
|
||||||
|
if ((d.unchanged || 0) > 0 || (added === 0 && updated === 0)) {
|
||||||
|
parts.push(`${d.unchanged || 0} unchanged`);
|
||||||
|
}
|
||||||
|
if ((d.pruned || 0) > 0) parts.push(`${d.pruned} pruned`);
|
||||||
|
return parts.join(" · ");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uploadFormEl && uploadFileInput && uploadBtn) {
|
||||||
|
uploadFormEl.addEventListener("submit", async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
// Client-side no-file check (the input is `required` too — the
|
||||||
|
// browser's native prompt is the first line, this one the second).
|
||||||
|
const file = uploadFileInput.files && uploadFileInput.files[0];
|
||||||
|
if (!file) {
|
||||||
|
if (uploadError) {
|
||||||
|
uploadError.textContent = "Choose an archive file to upload.";
|
||||||
|
uploadError.hidden = false;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (uploadError) uploadError.hidden = true;
|
||||||
|
if (uploadResult) uploadResult.hidden = true; // a new attempt starts clean
|
||||||
|
uploadBtn.disabled = true; // §7.4: one upload per click
|
||||||
|
uploadBtn.textContent = "Uploading…";
|
||||||
|
try {
|
||||||
|
// Multipart from the form itself (the file input's name is
|
||||||
|
// "file") — the browser sets the boundary; NO manual
|
||||||
|
// Content-Type header.
|
||||||
|
const r = await fetch("/api/git-sources/upload", {
|
||||||
|
method: "POST",
|
||||||
|
body: new FormData(uploadFormEl),
|
||||||
|
});
|
||||||
|
if (r.ok) {
|
||||||
|
let data = {};
|
||||||
|
try {
|
||||||
|
data = await r.json();
|
||||||
|
} catch {
|
||||||
|
/* the body is advisory — the counts line degrades gracefully */
|
||||||
|
}
|
||||||
|
uploadFileInput.value = ""; // 200: the archive is unpacked + scanned
|
||||||
|
if (uploadResult) {
|
||||||
|
uploadResult.textContent = fmtUploadResult(data);
|
||||||
|
uploadResult.hidden = false;
|
||||||
|
}
|
||||||
|
announce(`Archive uploaded: ${data.source || file.name}.`);
|
||||||
|
await loadSources(); // the new/updated row lands (Local badge)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 422 (format/name/traversal), 413 (size), 409 (busy): the server
|
||||||
|
// detail inline, the file selection KEPT — the fix is one
|
||||||
|
// re-pick, not a re-type.
|
||||||
|
if (uploadError) {
|
||||||
|
uploadError.textContent = await apiDetail(r, "Could not upload the archive — try again.");
|
||||||
|
uploadError.hidden = false;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (uploadError) {
|
||||||
|
uploadError.textContent = "Could not upload the archive — is the app reachable?";
|
||||||
|
uploadError.hidden = false;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
uploadBtn.disabled = false; // never stale — success OR failure
|
||||||
|
uploadBtn.textContent = "Upload & scan";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/* After a successful add, focus the new row's Remove button so the
|
/* After a successful add, focus the new row's Remove button so the
|
||||||
keyboard/screen-reader user lands where the new data is. The 201
|
keyboard/screen-reader user lands where the new data is. The 201
|
||||||
|
|||||||
+114
-386
@@ -6,8 +6,8 @@
|
|||||||
*
|
*
|
||||||
* • the Sign in / Sign out auth pair (phase 16, exactly one visible —
|
* • the Sign in / Sign out auth pair (phase 16, exactly one visible —
|
||||||
* decided by /api/whoami at load);
|
* decided by /api/whoami at load);
|
||||||
* • the admin-only nav links — "Sources" (#nav-sources, phase 19),
|
* • the admin-only nav links — "RAG" (#nav-sources, phase 19),
|
||||||
* "Git sources" (#nav-git-sources, phase 35) and "Tuning"
|
* "Sources" (#nav-git-sources, phase 35) and "Tuning"
|
||||||
* (#nav-tuning, phase 29) — phase 19 UX revision
|
* (#nav-tuning, phase 29) — phase 19 UX revision
|
||||||
* (owner permission 2026-08-23): hidden for anonymous on EVERY
|
* (owner permission 2026-08-23): hidden for anonymous on EVERY
|
||||||
* page, revealed for admin. Phase 34 task 03 (owner confirmation
|
* page, revealed for admin. Phase 34 task 03 (owner confirmation
|
||||||
@@ -16,37 +16,42 @@
|
|||||||
* "no nav" bar is gone. The links SHIP hidden in the HTML
|
* "no nav" bar is gone. The links SHIP hidden in the HTML
|
||||||
* (anonymous-safe default — the phase-16 "absent, not hidden"
|
* (anonymous-safe default — the phase-16 "absent, not hidden"
|
||||||
* spirit), so no anonymous user ever sees one for a frame; and
|
* spirit), so no anonymous user ever sees one for a frame; and
|
||||||
* the "Sync sources" button (#sync-btn, phase 32 — every page
|
|
||||||
* from phase 34 task 03) — the same ship-hidden / reveal-for-admin
|
|
||||||
* contract on the SAME cached whoami (one fetch, no extra request);
|
|
||||||
* • the sign-out click binding (POST /api/logout → reload) — moved
|
* • the sign-out click binding (POST /api/logout → reload) — moved
|
||||||
* here from app.js so there is exactly one implementation;
|
* here from app.js so there is exactly one implementation;
|
||||||
* • the steering-notes controls (phase 15, moved here from app.js in
|
* • the mobile hamburger binding (phase 46, owner permission
|
||||||
* phase 34) — the #steering-toggle open/close + the #steering-panel
|
* 2026-08-27, TODO.md L9) — at ≤640px (CSS hides the button
|
||||||
* list (newest-first, textContent-rendered, per-note delete, count
|
* elsewhere) the #nav-toggle button opens the nav as an animated
|
||||||
* badge, the #steering-announcer live region) — so the toggle can
|
* dropdown (#app-nav .is-open — the 180ms slide+fade state from
|
||||||
* sit in every page's header with zero page-script duplication.
|
* task 01's CSS): a click toggles it with aria-expanded kept in
|
||||||
* refreshSteering() / announceSteering() are exported for the chat
|
* sync, a nav link click shuts it (the navigation happens anyway),
|
||||||
* page's per-bubble Tune form (which stays in app.js); anonymous
|
* Esc shuts it and returns focus to the toggle, and resizing back
|
||||||
* visitors get the phase-16 "absent, not hidden" treatment (toggle
|
* to >640px drops the open state (matchMedia change) so
|
||||||
* + panel removed from the DOM, /api/steering never fetched);
|
* aria-expanded stays honest. One binding for all six pages; a
|
||||||
* • the Sync sources state machine (phase 32, moved here from
|
* page without either element is a no-op. The binding toggles
|
||||||
* sources.js in phase 34 task 02) — the §7.4 never-stale lifecycle
|
* ONLY the container — the nav links keep their ship-hidden
|
||||||
* for #sync-btn (idle → running → success | failed): admin-only
|
* whoami contract (hidden links stay hidden inside the menu);
|
||||||
* boot re-attach on the SAME cached whoami (non-admins never poll),
|
* • the steering-notes panel (phase 15, moved here from app.js in
|
||||||
* POST /api/sync (202 start / 409 adopt), the 2 s
|
* phase 34) — the #steering-panel list (newest-first,
|
||||||
* GET /api/sync/status poll (one live timer, NO client-side hard
|
* textContent-rendered, per-note delete) + the #steering-announcer
|
||||||
* timeout — the server state is authoritative). Every state change
|
* live region — so every page that ships the panel markup gets
|
||||||
* dispatches window "bor:sync-status" (detail = the status object)
|
* exactly this behavior with zero page-script duplication. The
|
||||||
* so the Sources page renders its #sync-result line +
|
* navbar #steering-toggle was REMOVED at owner request
|
||||||
* #sync-error-banner off the event; on non-Sources pages the
|
* (2026-08-28) — note management now lives on /tuning.html — so
|
||||||
* failed state is visible in the button's title + aria-label;
|
* the panel ships hidden on every page and is only kept fresh,
|
||||||
|
* never opened from the header. refreshSteering() /
|
||||||
|
* announceSteering() are exported for the chat page's per-bubble
|
||||||
|
* Tune form (which stays in app.js); anonymous visitors get the
|
||||||
|
* phase-16 "absent, not hidden" treatment (panel removed from the
|
||||||
|
* DOM, /api/steering never fetched);
|
||||||
* • the SINGLE New chat binding (phase 34 task 02 — it was
|
* • the SINGLE New chat binding (phase 34 task 02 — it was
|
||||||
* duplicated across app.js / sources.js / tuning.js / document.js):
|
* duplicated across app.js / sources.js / tuning.js / document.js;
|
||||||
* on the chat page (#messages exists) the module dispatches
|
* moved from the navbar to the chat page at owner request):
|
||||||
* window "bor:new-chat" and app.js acts (it owns the in-flight-turn
|
* the button now lives ONLY on the chat page (inside .chat-shell,
|
||||||
* guard + the list reset); on every other page it means "go to the
|
* above #messages). When clicked it dispatches window
|
||||||
* chat, fresh" — clearChatStorage() + navigate to "/";
|
* "bor:new-chat" and app.js acts (it owns the in-flight-turn
|
||||||
|
* guard + the list reset).
|
||||||
|
* • clearChatStorage() — the phase-14 conversation key, for clearing
|
||||||
|
* the conversation when navigating away from the chat page.
|
||||||
* • the sign-in ?next= rewrite (phase 34 task 02) — initSharedHeader
|
* • the sign-in ?next= rewrite (phase 34 task 02) — initSharedHeader
|
||||||
* points #sign-in-link at /login.html?next=<current pathname>
|
* points #sign-in-link at /login.html?next=<current pathname>
|
||||||
* (default "/"), so the admin lands back on the page they signed in
|
* (default "/"), so the admin lands back on the page they signed in
|
||||||
@@ -101,18 +106,16 @@ export async function initSharedHeader() {
|
|||||||
// a query-safe "/…" string (never "//"; ? # and spaces stay
|
// a query-safe "/…" string (never "//"; ? # and spaces stay
|
||||||
// percent-encoded in it), so it rides in next= as-is — the same shape
|
// percent-encoded in it), so it rides in next= as-is — the same shape
|
||||||
// the static fallbacks use (login.js safeNext re-validates it).
|
// the static fallbacks use (login.js safeNext re-validates it).
|
||||||
const signIn = document.querySelector("#sign-in-link");
|
document.querySelectorAll(".sign-in-link").forEach(link => {
|
||||||
if (signIn) {
|
link.hidden = admin;
|
||||||
signIn.hidden = admin;
|
link.href = "/login.html?next=" + (window.location.pathname || "/");
|
||||||
signIn.href = "/login.html?next=" + (window.location.pathname || "/");
|
});
|
||||||
}
|
document.querySelectorAll(".sign-out-btn").forEach(btn => { btn.hidden = !admin; });
|
||||||
const signOut = document.querySelector("#sign-out-btn");
|
|
||||||
if (signOut) signOut.hidden = !admin;
|
|
||||||
const navSources = document.querySelector("#nav-sources");
|
const navSources = document.querySelector("#nav-sources");
|
||||||
if (navSources) navSources.hidden = !admin;
|
if (navSources) navSources.hidden = !admin;
|
||||||
// Phase 35 (owner permission 2026-08-26): the Git sources nav link —
|
// Phase 35 (owner permission 2026-08-26): the Sources nav link —
|
||||||
// admin-only, the same ship-hidden / reveal-for-admin contract as
|
// admin-only, the same ship-hidden / reveal-for-admin contract as
|
||||||
// the Sources link above.
|
// the RAG link above.
|
||||||
const navGitSources = document.querySelector("#nav-git-sources");
|
const navGitSources = document.querySelector("#nav-git-sources");
|
||||||
if (navGitSources) navGitSources.hidden = !admin;
|
if (navGitSources) navGitSources.hidden = !admin;
|
||||||
// Phase 29: the Global Tuning nav link (every page from phase 34
|
// Phase 29: the Global Tuning nav link (every page from phase 34
|
||||||
@@ -120,19 +123,15 @@ export async function initSharedHeader() {
|
|||||||
// contract as the Sources link.
|
// contract as the Sources link.
|
||||||
const navTuning = document.querySelector("#nav-tuning");
|
const navTuning = document.querySelector("#nav-tuning");
|
||||||
if (navTuning) navTuning.hidden = !admin;
|
if (navTuning) navTuning.hidden = !admin;
|
||||||
// Phase 32: the "Sync sources" button — admin-only, revealed on this
|
// Phase 34: the steering panel (phase 15) is module-owned. The
|
||||||
// same cached whoami (anonymous users never see it).
|
// navbar #steering-toggle was removed at owner request (2026-08-28)
|
||||||
if (syncBtn) syncBtn.hidden = !admin;
|
// — the panel ships hidden and is only kept fresh. Admin: refresh
|
||||||
// Phase 34: the steering controls (phase 15) are module-owned. Admin:
|
// the list (fire-and-forget). Anonymous: the panel is REMOVED from
|
||||||
// refresh the list so the count badge is right before the panel is
|
// the DOM entirely — the phase-16 contract says "absent", not just
|
||||||
// ever opened (fire-and-forget, as the chat page did before the move).
|
// hidden — and /api/steering is never fetched.
|
||||||
// Anonymous: the toggle + panel are REMOVED from the DOM entirely —
|
|
||||||
// the phase-16 contract says "absent", not just hidden — and
|
|
||||||
// /api/steering is never fetched.
|
|
||||||
if (admin) {
|
if (admin) {
|
||||||
if (steeringPanel) refreshSteering();
|
if (steeringPanel) refreshSteering();
|
||||||
} else {
|
} else {
|
||||||
steeringToggle?.remove();
|
|
||||||
steeringPanel?.remove();
|
steeringPanel?.remove();
|
||||||
}
|
}
|
||||||
return admin;
|
return admin;
|
||||||
@@ -151,13 +150,14 @@ export function clearChatStorage() {
|
|||||||
|
|
||||||
/* Sign-out binding (phase 16 behavior, now module-owned): runs at module
|
/* Sign-out binding (phase 16 behavior, now module-owned): runs at module
|
||||||
import, so every page that loads header.js gets it exactly once.
|
import, so every page that loads header.js gets it exactly once.
|
||||||
Disable during the call, POST /api/logout (the result is ignored —
|
Binds to all .sign-out-btn elements (bar copy for desktop + mobile
|
||||||
the reload resets the UI either way), then reload so the header
|
dropdown copy for ≤640px). Disable during the call, POST /api/logout
|
||||||
re-resolves to the anonymous state (Sign in back, Sources gone). */
|
(the result is ignored — the reload resets the UI either way), then
|
||||||
const signOutBtn = document.querySelector("#sign-out-btn");
|
reload so the header re-resolves to the anonymous state (Sign in
|
||||||
if (signOutBtn) {
|
back, Sources gone). */
|
||||||
signOutBtn.addEventListener("click", async () => {
|
document.querySelectorAll(".sign-out-btn").forEach(btn => {
|
||||||
signOutBtn.disabled = true;
|
btn.addEventListener("click", async () => {
|
||||||
|
btn.disabled = true;
|
||||||
try {
|
try {
|
||||||
await fetch("/api/logout", { method: "POST" });
|
await fetch("/api/logout", { method: "POST" });
|
||||||
} catch {
|
} catch {
|
||||||
@@ -165,16 +165,58 @@ if (signOutBtn) {
|
|||||||
}
|
}
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ---------- mobile hamburger (phase 46; module-owned) ----------
|
||||||
|
* ≤640px only (CSS hides the button elsewhere): #nav-toggle opens the
|
||||||
|
* nav as a dropdown (#app-nav .is-open — the animated state, task 01
|
||||||
|
* CSS). One binding for all six pages; a page without either element
|
||||||
|
* is a no-op, like the rest of this module. The nav LINKS keep their
|
||||||
|
* ship-hidden whoami contract (hidden links stay hidden inside the
|
||||||
|
* menu) — this binding only toggles the container. */
|
||||||
|
const navToggle = document.querySelector("#nav-toggle");
|
||||||
|
const appNav = document.querySelector("#app-nav");
|
||||||
|
|
||||||
|
function setNavMenu(open) {
|
||||||
|
if (!appNav || !navToggle) return;
|
||||||
|
appNav.classList.toggle("is-open", open);
|
||||||
|
navToggle.setAttribute("aria-expanded", open ? "true" : "false");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (navToggle && appNav) {
|
||||||
|
navToggle.addEventListener("click", () =>
|
||||||
|
setNavMenu(!appNav.classList.contains("is-open")));
|
||||||
|
// A link click navigates (or closes same-page) — shut the menu.
|
||||||
|
appNav.addEventListener("click", (e) => {
|
||||||
|
if (e.target.closest("a")) setNavMenu(false);
|
||||||
|
});
|
||||||
|
// Esc closes while open (document-level — no other modal to fight
|
||||||
|
// for a key).
|
||||||
|
document.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Escape" && appNav.classList.contains("is-open")) {
|
||||||
|
setNavMenu(false);
|
||||||
|
navToggle.focus(); // focus returns to the opener
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Resize back to desktop: the inline nav reappears — no stale open
|
||||||
|
// state (the .is-open class is scoped by the ≤640px CSS anyway, but
|
||||||
|
// dropping it keeps aria-expanded honest).
|
||||||
|
const mq = window.matchMedia("(max-width: 640px)");
|
||||||
|
const onMqChange = () => { if (!mq.matches) setNavMenu(false); };
|
||||||
|
if (mq.addEventListener) mq.addEventListener("change", onMqChange);
|
||||||
|
else mq.addListener(onMqChange); // older engines, defensive
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- steering notes (phase 15; module-owned from phase 34) ----------
|
/* ---------- steering notes (phase 15; module-owned from phase 34) ----------
|
||||||
*
|
*
|
||||||
* The owner's tuning notes steer every future answer: they live in
|
* The owner's tuning notes steer every future answer: they live in
|
||||||
* Postgres (stateless API, A10) and the chat turn reads them into the
|
* Postgres (stateless API, A10) and the chat turn reads them into the
|
||||||
* system prompt. The header panel — toggle, list, per-note delete, count
|
* system prompt. The header panel — list, per-note delete, announcer —
|
||||||
* badge, announcer — is owned by THIS module: every page that ships the
|
* is owned by THIS module: every page that ships the panel markup gets
|
||||||
* panel markup gets exactly this behavior, with zero page-script
|
* exactly this behavior, with zero page-script duplication. The navbar
|
||||||
* duplication. The chat page keeps only its per-bubble Tune form
|
* #steering-toggle was removed at owner request (2026-08-28) — note
|
||||||
|
* management now lives on /tuning.html — so the panel ships hidden and
|
||||||
|
* is only kept fresh. The chat page keeps only its per-bubble Tune form
|
||||||
* (app.js), which refreshes the panel through refreshSteering() and
|
* (app.js), which refreshes the panel through refreshSteering() and
|
||||||
* announces through announceSteering().
|
* announces through announceSteering().
|
||||||
*
|
*
|
||||||
@@ -182,8 +224,6 @@ if (signOutBtn) {
|
|||||||
* that lacks the panel markup is a no-op — the same contract as
|
* that lacks the panel markup is a no-op — the same contract as
|
||||||
* initSharedHeader().
|
* initSharedHeader().
|
||||||
*/
|
*/
|
||||||
const steeringToggle = document.querySelector("#steering-toggle");
|
|
||||||
const steeringCount = document.querySelector("#steering-count");
|
|
||||||
const steeringPanel = document.querySelector("#steering-panel");
|
const steeringPanel = document.querySelector("#steering-panel");
|
||||||
const steeringList = document.querySelector("#steering-list");
|
const steeringList = document.querySelector("#steering-list");
|
||||||
const steeringEmpty = document.querySelector("#steering-empty");
|
const steeringEmpty = document.querySelector("#steering-empty");
|
||||||
@@ -197,10 +237,10 @@ export function announceSteering(message) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Fetch + render the note list (exported — the chat page's per-bubble
|
/* Fetch + render the note list (exported — the chat page's per-bubble
|
||||||
Tune form calls it on save, so the panel + count badge update without
|
Tune form calls it on save, so the panel updates without owning the
|
||||||
owning the fetch itself). Non-2xx (the anonymous 403) or an
|
fetch itself). Non-2xx (the anonymous 403) or an unreachable API
|
||||||
unreachable API render the empty state: count badge 0, the "no notes
|
render the empty state: the "no notes yet" text visible — the safe
|
||||||
yet" text visible — the safe fallback in either case. */
|
fallback in either case. */
|
||||||
export async function refreshSteering() {
|
export async function refreshSteering() {
|
||||||
let notes = [];
|
let notes = [];
|
||||||
try {
|
try {
|
||||||
@@ -215,8 +255,7 @@ export async function refreshSteering() {
|
|||||||
|
|
||||||
/* Newest-first list — the note is ALWAYS rendered with textContent
|
/* Newest-first list — the note is ALWAYS rendered with textContent
|
||||||
(XSS-safe, never innerHTML), a per-note Remove button with a labeled
|
(XSS-safe, never innerHTML), a per-note Remove button with a labeled
|
||||||
aria-label, the empty text toggled on notes.length, and the header
|
aria-label, and the empty text toggled on notes.length. */
|
||||||
count badge. */
|
|
||||||
function renderSteeringPanel(notes) {
|
function renderSteeringPanel(notes) {
|
||||||
if (!steeringList) return;
|
if (!steeringList) return;
|
||||||
steeringList.textContent = "";
|
steeringList.textContent = "";
|
||||||
@@ -238,7 +277,6 @@ function renderSteeringPanel(notes) {
|
|||||||
steeringList.appendChild(li);
|
steeringList.appendChild(li);
|
||||||
}
|
}
|
||||||
if (steeringEmpty) steeringEmpty.hidden = notes.length > 0;
|
if (steeringEmpty) steeringEmpty.hidden = notes.length > 0;
|
||||||
if (steeringCount) steeringCount.textContent = String(notes.length);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Per-note delete: disable the row button (no double-fire), DELETE
|
/* Per-note delete: disable the row button (no double-fire), DELETE
|
||||||
@@ -268,330 +306,20 @@ async function deleteSteeringNote(id, btn) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Open/close the panel, keeping the toggle's aria-expanded in sync —
|
|
||||||
the exact phase-15 chat-page contract (open on click, close on click;
|
|
||||||
the panel itself is a plain region — no Esc / outside-click close in
|
|
||||||
the original, so none here). Re-opening refreshes the list, so notes
|
|
||||||
changed elsewhere (the Tuning page, another tab) show up. */
|
|
||||||
function setSteeringPanel(open) {
|
|
||||||
if (!steeringPanel || !steeringToggle) return;
|
|
||||||
steeringPanel.hidden = !open;
|
|
||||||
steeringToggle.setAttribute("aria-expanded", open ? "true" : "false");
|
|
||||||
}
|
|
||||||
/* Toggle binding (module-owned, like the sign-out binding): runs at
|
|
||||||
module import, so a page with the toggle markup gets exactly one
|
|
||||||
implementation. */
|
|
||||||
if (steeringToggle && steeringPanel) {
|
|
||||||
steeringToggle.addEventListener("click", () => {
|
|
||||||
setSteeringPanel(steeringPanel.hidden);
|
|
||||||
if (!steeringPanel.hidden) refreshSteering(); // refresh when (re)opened
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- New chat (the SINGLE binding — module-owned from phase 34
|
/* ---------- New chat (the SINGLE binding — module-owned from phase 34
|
||||||
* task 02) ----------
|
* task 02; moved from navbar to chat page at owner request) ----------
|
||||||
*
|
*
|
||||||
* The binding used to be duplicated across app.js / sources.js /
|
* The binding used to be duplicated across app.js / sources.js /
|
||||||
* tuning.js / document.js with the same page-kind branch. It lives here
|
* tuning.js / document.js. It lives here exactly once (module import,
|
||||||
* exactly once (module import, like the sign-out binding): on the chat
|
* like the sign-out binding). The button now lives ONLY on the chat
|
||||||
* page (#messages exists) the module dispatches window "bor:new-chat"
|
* page (inside .chat-shell, above #messages), so the click always
|
||||||
* and app.js acts — the chat script owns the in-flight-turn guard and
|
* dispatches "bor:new-chat" — app.js acts (it owns the in-flight-turn
|
||||||
* the rendered-list reset; on every other page "new chat" means go to
|
* guard and the rendered-list reset).
|
||||||
* the chat, fresh: clear the phase-14 conversation key, then navigate
|
|
||||||
* to "/" (its empty state, since the conversation is gone from storage).
|
|
||||||
*/
|
*/
|
||||||
const newChatBtn = document.querySelector("#new-chat-btn");
|
const newChatBtn = document.querySelector("#new-chat-btn");
|
||||||
if (newChatBtn) {
|
if (newChatBtn) {
|
||||||
newChatBtn.addEventListener("click", () => {
|
newChatBtn.addEventListener("click", () => {
|
||||||
if (document.querySelector("#messages")) {
|
window.dispatchEvent(new CustomEvent("bor:new-chat"));
|
||||||
window.dispatchEvent(new CustomEvent("bor:new-chat"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
clearChatStorage();
|
|
||||||
window.location.href = "/";
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- sync sources (phase 32; module-owned from phase 34 task 02) ----------
|
|
||||||
*
|
|
||||||
* The "never stale" lifecycle for the long background sync job, moved
|
|
||||||
* here from sources.js so the SAME #sync-btn markup on ANY page (phase
|
|
||||||
* 34 task 03) behaves identically. The button is the module's; the
|
|
||||||
* Sources page's #sync-result line + #sync-error-banner render off the
|
|
||||||
* "bor:sync-status" event this machine dispatches (sources.js
|
|
||||||
* subscribes):
|
|
||||||
*
|
|
||||||
* idle → click → POST /api/sync
|
|
||||||
* 202 → running (disabled, aria-busy, spinning icon, "Syncing…")
|
|
||||||
* + a 2 s poll of GET /api/sync/status;
|
|
||||||
* 409 → the in-flight run is ADOPTED the same way (one sync
|
|
||||||
* at a time, one poll loop at a time);
|
|
||||||
* success → "Synced HH:MM"; failed → retry-ready "Sync sources"
|
|
||||||
* + the sanitized error in the button's title +
|
|
||||||
* aria-label (on non-Sources pages that IS where the
|
|
||||||
* failure is visible; the Sources banner is the event).
|
|
||||||
*
|
|
||||||
* Boot (admin only — non-admins never poll, the status endpoint is
|
|
||||||
* admin-only): one GET /api/sync/status on the SAME cached whoami —
|
|
||||||
* running re-enters the running state (reload mid-sync), a terminal
|
|
||||||
* state renders its last result. NO client-side hard timeout (phase 32
|
|
||||||
* locked decision): a sync can legitimately outlive the page, so the
|
|
||||||
* 2 s poll is the feedback loop and the server state is authoritative.
|
|
||||||
*
|
|
||||||
* All elements are looked up null-safe: a page that doesn't (yet) carry
|
|
||||||
* the #sync-btn markup is a complete no-op, exactly like the rest of
|
|
||||||
* this module.
|
|
||||||
*/
|
|
||||||
const syncBtn = document.querySelector("#sync-btn");
|
|
||||||
const syncLabel = document.querySelector("#sync-label");
|
|
||||||
const syncIcon = syncBtn ? syncBtn.querySelector(".sync-icon") : null;
|
|
||||||
|
|
||||||
const SYNC_POLL_MS = 2000; // the 2 s status poll (phase 32 contract)
|
|
||||||
let syncPollTimer = null; // at most ONE live poll loop
|
|
||||||
let lastSyncState = null; // the last state emitted on bor:sync-status
|
|
||||||
|
|
||||||
/* The module → page channel: detail is the GET /api/sync/status object
|
|
||||||
(or the synthetic { state: "running" } frame the click path emits
|
|
||||||
before the first poll tick — the Sources handlers only need the
|
|
||||||
state, the next real object carries the full fields). */
|
|
||||||
function emitSyncStatus(status) {
|
|
||||||
lastSyncState = status.state;
|
|
||||||
window.dispatchEvent(new CustomEvent("bor:sync-status", { detail: status }));
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopSyncPolling() {
|
|
||||||
if (syncPollTimer !== null) {
|
|
||||||
clearTimeout(syncPollTimer);
|
|
||||||
syncPollTimer = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The local HH:MM of finished_at — 24-hour, locale-independent, so the
|
|
||||||
* "Synced 14:32" last-result label is deterministic. */
|
|
||||||
function fmtSyncTime(iso) {
|
|
||||||
const d = new Date(iso);
|
|
||||||
if (Number.isNaN(d.getTime())) return "";
|
|
||||||
const pad = (n) => String(n).padStart(2, "0");
|
|
||||||
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The last-result line for the Sources page's #sync-result (aria-live).
|
|
||||||
* "added" is ALWAYS announced (the run's headline term); "updated" /
|
|
||||||
* "pruned" only when they happened (zero terms omitted); "unchanged"
|
|
||||||
* whenever it is non-zero — or whenever nothing was added or updated,
|
|
||||||
* so a no-op re-sync reads "0 added · 1 unchanged" instead of an empty
|
|
||||||
* live region (the story gate's idempotency check). Exported so the
|
|
||||||
* Sources page renders the counts from ONE implementation. */
|
|
||||||
export function fmtSyncResult(detail) {
|
|
||||||
const d = detail || {};
|
|
||||||
const added = d.added || 0;
|
|
||||||
const updated = d.updated || 0;
|
|
||||||
const parts = [`${added} added`];
|
|
||||||
if (updated > 0) parts.push(`${updated} updated`);
|
|
||||||
if ((d.unchanged || 0) > 0 || (added === 0 && updated === 0)) {
|
|
||||||
parts.push(`${d.unchanged || 0} unchanged`);
|
|
||||||
}
|
|
||||||
if ((d.pruned || 0) > 0) parts.push(`${d.pruned} pruned`);
|
|
||||||
return parts.join(" · ");
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The failed-state affordance text for the button's title + aria-label
|
|
||||||
* (non-Sources pages: that is where the failure is visible). The server
|
|
||||||
* already masks any embedded credentials (sync.py _sanitize_error);
|
|
||||||
* here the string is collapsed to a single line and capped so a chatty
|
|
||||||
* git stderr can't bloat the attributes. */
|
|
||||||
function sanitizeSyncError(message) {
|
|
||||||
const text = String(message || "The sync failed.").replace(/\s+/g, " ").trim();
|
|
||||||
return text.length > 200 ? `${text.slice(0, 200)}…` : text;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* §7.4 running state: disabled + aria-busy + spinning icon + the
|
|
||||||
* "Syncing…" label — and a fresh run starts clean: the previous
|
|
||||||
* failure's affordances (title / aria-label / .is-error) come off NOW,
|
|
||||||
* not when the run settles. The button only — the Sources page's
|
|
||||||
* result line / banner clear off the matching "running" event (no
|
|
||||||
* 2 s lag). */
|
|
||||||
function enterSyncRunningState() {
|
|
||||||
if (!syncBtn) return;
|
|
||||||
syncBtn.disabled = true;
|
|
||||||
syncBtn.setAttribute("aria-busy", "true");
|
|
||||||
syncBtn.removeAttribute("title");
|
|
||||||
syncBtn.setAttribute("aria-label", "Sync sources");
|
|
||||||
syncBtn.classList.remove("is-error");
|
|
||||||
if (syncIcon) syncIcon.classList.add("is-spinning");
|
|
||||||
if (syncLabel) syncLabel.textContent = "Syncing…";
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Settle the button back to clickable + un-spun with the given label,
|
|
||||||
* dropping the failed-state affordances (a fresh run starts clean). */
|
|
||||||
function settleSyncButton(label) {
|
|
||||||
if (!syncBtn) return;
|
|
||||||
syncBtn.disabled = false;
|
|
||||||
syncBtn.removeAttribute("aria-busy");
|
|
||||||
syncBtn.removeAttribute("title");
|
|
||||||
syncBtn.setAttribute("aria-label", "Sync sources");
|
|
||||||
syncBtn.classList.remove("is-error");
|
|
||||||
if (syncIcon) syncIcon.classList.remove("is-spinning");
|
|
||||||
if (syncLabel) syncLabel.textContent = label;
|
|
||||||
}
|
|
||||||
|
|
||||||
function applySyncSuccess(status) {
|
|
||||||
const time = fmtSyncTime(status.finished_at);
|
|
||||||
settleSyncButton(time ? `Synced ${time}` : "Synced");
|
|
||||||
emitSyncStatus(status);
|
|
||||||
}
|
|
||||||
|
|
||||||
function applySyncFailure(status) {
|
|
||||||
const error = sanitizeSyncError(status.error);
|
|
||||||
settleSyncButton("Sync sources"); // retry-ready
|
|
||||||
if (syncBtn) {
|
|
||||||
// The failed look: error text in title + aria-label (and the
|
|
||||||
// .is-error class for the non-Sources pages' visible error state).
|
|
||||||
syncBtn.title = error;
|
|
||||||
syncBtn.setAttribute("aria-label", error);
|
|
||||||
syncBtn.classList.add("is-error");
|
|
||||||
}
|
|
||||||
emitSyncStatus(status);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* A run can only vanish with a server restart mid-sync (status resets
|
|
||||||
* to idle — the phase-accepted behavior): retry-ready, no error to
|
|
||||||
* name. Also the post-403 cleanup. */
|
|
||||||
function applySyncIdle(status) {
|
|
||||||
settleSyncButton("Sync sources");
|
|
||||||
emitSyncStatus(status || { state: "idle" });
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The 2 s poll loop — the ONLY feedback timer (no client-side hard
|
|
||||||
* timeout, phase 32 locked decision). One tick at a time (re-scheduled
|
|
||||||
* only while the run is still live, so an in-flight fetch can never
|
|
||||||
* overlap the next tick), and startSyncPolling refuses to run a second
|
|
||||||
* loop (a 409 adoption or a reload never doubles the polling). */
|
|
||||||
function startSyncPolling() {
|
|
||||||
if (syncPollTimer !== null) return;
|
|
||||||
const tick = async () => {
|
|
||||||
let status = null;
|
|
||||||
let notAdmin = false;
|
|
||||||
try {
|
|
||||||
const r = await fetch("/api/sync/status");
|
|
||||||
if (r.status === 403) notAdmin = true;
|
|
||||||
else if (r.ok) status = await r.json();
|
|
||||||
} catch {
|
|
||||||
/* network blip — the next tick retries (no client timeout to trip) */
|
|
||||||
}
|
|
||||||
if (notAdmin) {
|
|
||||||
// Session lost mid-sync: defense in depth — hide the button.
|
|
||||||
stopSyncPolling();
|
|
||||||
if (syncBtn) syncBtn.hidden = true;
|
|
||||||
applySyncIdle();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!status) {
|
|
||||||
syncPollTimer = setTimeout(tick, SYNC_POLL_MS);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (status.state === "success") {
|
|
||||||
stopSyncPolling();
|
|
||||||
applySyncSuccess(status);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (status.state === "failed") {
|
|
||||||
stopSyncPolling();
|
|
||||||
applySyncFailure(status);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (status.state === "idle") {
|
|
||||||
// The run died with a server restart — retry-ready, no banner.
|
|
||||||
stopSyncPolling();
|
|
||||||
applySyncIdle(status);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Still running: keep the button state honest (idempotent) and
|
|
||||||
// re-schedule. No event — the running frame was already emitted
|
|
||||||
// when the state entered (click / boot), and the Sources handlers
|
|
||||||
// are no-ops for repeated running frames anyway.
|
|
||||||
enterSyncRunningState();
|
|
||||||
syncPollTimer = setTimeout(tick, SYNC_POLL_MS);
|
|
||||||
};
|
|
||||||
syncPollTimer = setTimeout(tick, SYNC_POLL_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Click → POST /api/sync. 202 starts the run; 409 adopts the in-flight
|
|
||||||
* one (started elsewhere — e.g. a second tab); 403 hides the button
|
|
||||||
* (defense in depth); anything else names the failure (banner on
|
|
||||||
* Sources via the event, button affordance everywhere). */
|
|
||||||
async function startSync() {
|
|
||||||
let r;
|
|
||||||
try {
|
|
||||||
r = await fetch("/api/sync", { method: "POST" });
|
|
||||||
} catch {
|
|
||||||
applySyncFailure({
|
|
||||||
state: "failed",
|
|
||||||
error: "Could not reach the server to start the sync — try again.",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (r.status === 403) {
|
|
||||||
stopSyncPolling();
|
|
||||||
if (syncBtn) syncBtn.hidden = true;
|
|
||||||
applySyncIdle();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (r.status === 202 || r.status === 409) {
|
|
||||||
enterSyncRunningState();
|
|
||||||
// The synthetic running frame clears the Sources result line /
|
|
||||||
// banner IMMEDIATELY (before the first poll tick, 2 s away) — the
|
|
||||||
// exact sources.js enterRunningState behavior, now event-driven.
|
|
||||||
if (lastSyncState !== "running") emitSyncStatus({ state: "running" });
|
|
||||||
startSyncPolling();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let detail = "";
|
|
||||||
try {
|
|
||||||
detail = (await r.json()).detail || "";
|
|
||||||
} catch {
|
|
||||||
/* non-JSON error body */
|
|
||||||
}
|
|
||||||
applySyncFailure({
|
|
||||||
state: "failed",
|
|
||||||
error: detail || `The server refused to start the sync (${r.status}).`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Load-time re-attach (ADMIN ONLY — non-admins never poll, the status
|
|
||||||
* endpoint is admin-only): a running run re-enters the running state
|
|
||||||
* (the user may have reloaded mid-sync), a terminal run renders its
|
|
||||||
* last result, idle settles nothing visible. Awaits the SAME cached
|
|
||||||
* whoami promise — exactly one /api/whoami per page load, unchanged. */
|
|
||||||
async function initSyncButton() {
|
|
||||||
if (!syncBtn) return;
|
|
||||||
if (!(await fetchIsAdmin())) return; // anonymous: the button stays hidden
|
|
||||||
let status;
|
|
||||||
try {
|
|
||||||
const r = await fetch("/api/sync/status");
|
|
||||||
if (r.status === 403) {
|
|
||||||
syncBtn.hidden = true; // defense in depth
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!r.ok) return;
|
|
||||||
status = await r.json();
|
|
||||||
} catch {
|
|
||||||
return; // network blip — the button stays idle and clickable
|
|
||||||
}
|
|
||||||
if (status.state === "running") {
|
|
||||||
enterSyncRunningState();
|
|
||||||
emitSyncStatus(status);
|
|
||||||
startSyncPolling();
|
|
||||||
} else if (status.state === "success") {
|
|
||||||
applySyncSuccess(status);
|
|
||||||
} else if (status.state === "failed") {
|
|
||||||
applySyncFailure(status);
|
|
||||||
} else {
|
|
||||||
applySyncIdle(status); // idle: settle + the idle frame
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (syncBtn) {
|
|
||||||
syncBtn.addEventListener("click", startSync);
|
|
||||||
initSyncButton(); // re-attach to a running / last sync run (admin only)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -84,7 +84,8 @@ form.addEventListener("submit", async (e) => {
|
|||||||
* this point, so initSharedHeader adds no request and no delay, and
|
* this point, so initSharedHeader adds no request and no delay, and
|
||||||
* the phase-16 redirect itself is unchanged. For anonymous visitors it
|
* the phase-16 redirect itself is unchanged. For anonymous visitors it
|
||||||
* settles the reduced bar: Sign in visible, the admin-only controls
|
* settles the reduced bar: Sign in visible, the admin-only controls
|
||||||
* stay hidden, the steering toggle + panel removed. */
|
* stay hidden, the steering panel removed (the navbar toggle no longer
|
||||||
|
* ships at all — removed at owner request, 2026-08-28). */
|
||||||
(async () => {
|
(async () => {
|
||||||
const admin = await alreadySignedIn();
|
const admin = await alreadySignedIn();
|
||||||
await initSharedHeader();
|
await initSharedHeader();
|
||||||
|
|||||||
+104
-14
@@ -6,7 +6,16 @@
|
|||||||
* content can never inject live HTML/XSS. Classic script on purpose:
|
* content can never inject live HTML/XSS. Classic script on purpose:
|
||||||
* index.html and document.html load it via a plain relative <script src>
|
* index.html and document.html load it via a plain relative <script src>
|
||||||
* and both module scripts (app.js / document.js) call the globals it
|
* and both module scripts (app.js / document.js) call the globals it
|
||||||
* defines. Rendering behavior is unchanged from the original app.js copy.
|
* defines.
|
||||||
|
*
|
||||||
|
* Phase 44 (2026-08-27, TODO.md L6) extended the ~60-line renderer with
|
||||||
|
* one more pass — GFM pipe tables: a table-protection pass (order:
|
||||||
|
* fences → tables → escape) pulls each pipe-table block out as a
|
||||||
|
* \u0000TABLEn\u0000 placeholder, renders it as a semantic, escape-first
|
||||||
|
* <table class="md-table"> inside a horizontal-overflow .md-table-wrap,
|
||||||
|
* and restores it alongside the code blocks in the final step. Fences
|
||||||
|
* win over tables (they are protected first), and everything that is
|
||||||
|
* not a pipe table renders byte-identically to the original app.js copy.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function escapeHtml(s) {
|
function escapeHtml(s) {
|
||||||
@@ -23,17 +32,96 @@ function renderMarkdown(md) {
|
|||||||
return `\u0000CODE${codeBlocks.length - 1}\u0000`;
|
return `\u0000CODE${codeBlocks.length - 1}\u0000`;
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2. Escape everything else, then apply inline + block transforms.
|
// Inline + line-block transforms — the EXACT .replace chain of the
|
||||||
text = escapeHtml(text)
|
// whole-text pass, factored so a table cell gets the identical
|
||||||
.replace(/`([^`\n]+)`/g, "<code>$1</code>")
|
// pipeline. Escape-first is the caller's job (both paths pass
|
||||||
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
|
// already-escaped input), so the chain only ever sees inert text.
|
||||||
.replace(/(^|[\s(])\*([^*\n]+)\*/g, "$1<em>$2</em>")
|
const inline = (s) =>
|
||||||
.replace(/^### (.*)$/gm, "<h4>$1</h4>")
|
s.replace(/`([^`\n]+)`/g, "<code>$1</code>")
|
||||||
.replace(/^## (.*)$/gm, "<h3>$1</h3>")
|
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
|
||||||
.replace(/^# (.*)$/gm, "<h3>$1</h3>")
|
.replace(/(^|[\s(])\*([^*\n]+)\*/g, "$1<em>$2</em>")
|
||||||
.replace(/^\s*[-*] (.*)$/gm, "<li>$1</li>")
|
.replace(/^### (.*)$/gm, "<h4>$1</h4>")
|
||||||
.replace(/(<li>[\s\S]*?<\/li>)(?!\s*<li>)/g, "<ul>$1</ul>")
|
.replace(/^## (.*)$/gm, "<h3>$1</h3>")
|
||||||
.replace(/^\d+\. (.*)$/gm, "<li>$1</li>");
|
.replace(/^# (.*)$/gm, "<h3>$1</h3>")
|
||||||
|
.replace(/^\s*[-*] (.*)$/gm, "<li>$1</li>")
|
||||||
|
.replace(/(<li>[\s\S]*?<\/li>)(?!\s*<li>)/g, "<ul>$1</ul>")
|
||||||
|
.replace(/^\d+\. (.*)$/gm, "<li>$1</li>");
|
||||||
|
|
||||||
|
// 1b. Protect GFM pipe tables (phase 44, 2026-08-27, TODO.md L6).
|
||||||
|
// A table block starts at a line containing "|" whose NEXT line is a
|
||||||
|
// separator (>=1 pipe-separated cells, each ^\s*:?-+:?\s*$; leading/
|
||||||
|
// trailing pipes optional) and extends over every following line that
|
||||||
|
// still contains "|" (body rows — zero is valid: a header-only table).
|
||||||
|
// The maximal run is one table; anything else (a lone "|" in prose, a
|
||||||
|
// separator without a "|" header above it, a 1-line "table") is left
|
||||||
|
// untouched. Runs AFTER the fence pass (fences win over tables) and
|
||||||
|
// BEFORE the escape pass: only the \u0000TABLEn\u0000 placeholder enters
|
||||||
|
// the pipeline, each cell is escaped + inline-transformed here, and
|
||||||
|
// the final HTML is restored in step 4 (it never re-enters the
|
||||||
|
// paragraph pass). Alignment colons in the separator are parsed but
|
||||||
|
// ignored — every cell renders left-aligned (owner decision 2026-08-27).
|
||||||
|
const tables = [];
|
||||||
|
const splitRow = (line) => {
|
||||||
|
const cells = line.split("|");
|
||||||
|
if (line.trimStart().startsWith("|")) cells.shift(); // leading pipe
|
||||||
|
if (line.trimEnd().endsWith("|")) cells.pop(); // trailing pipe
|
||||||
|
return cells.map((c) => c.trim());
|
||||||
|
};
|
||||||
|
const isSeparator = (line) =>
|
||||||
|
line.includes("|") &&
|
||||||
|
splitRow(line).length >= 1 &&
|
||||||
|
splitRow(line).every((c) => /^\s*:?-+:?\s*$/.test(c));
|
||||||
|
const tableLines = text.split("\n");
|
||||||
|
const kept = [];
|
||||||
|
let pos = 0;
|
||||||
|
while (pos < tableLines.length) {
|
||||||
|
const line = tableLines[pos];
|
||||||
|
const startsTable =
|
||||||
|
line.includes("|") &&
|
||||||
|
pos + 1 < tableLines.length &&
|
||||||
|
isSeparator(tableLines[pos + 1]);
|
||||||
|
if (!startsTable) {
|
||||||
|
kept.push(line);
|
||||||
|
pos += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Consume the maximal block: header + separator + "|" body lines.
|
||||||
|
const block = [line, tableLines[pos + 1]];
|
||||||
|
let end = pos + 2;
|
||||||
|
while (end < tableLines.length && tableLines[end].includes("|")) {
|
||||||
|
block.push(tableLines[end]);
|
||||||
|
end += 1;
|
||||||
|
}
|
||||||
|
const header = splitRow(block[0]);
|
||||||
|
const width = header.length;
|
||||||
|
const body = block
|
||||||
|
.slice(2)
|
||||||
|
.map((row) => {
|
||||||
|
const cells = splitRow(row);
|
||||||
|
for (let k = cells.length; k < width; k += 1) cells.push(""); // pad
|
||||||
|
// truncate past the header width, escape first, inline transforms
|
||||||
|
return `<tr>${cells
|
||||||
|
.slice(0, width)
|
||||||
|
.map((c) => `<td>${inline(escapeHtml(c))}</td>`)
|
||||||
|
.join("")}</tr>`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
tables.push(
|
||||||
|
'<div class="md-table-wrap">\n' +
|
||||||
|
'<table class="md-table">\n' +
|
||||||
|
`<thead><tr>${header
|
||||||
|
.map((c) => `<th scope="col">${inline(escapeHtml(c))}</th>`)
|
||||||
|
.join("")}</tr></thead>\n` +
|
||||||
|
(body ? `<tbody>${body}</tbody>\n` : "") +
|
||||||
|
"</table>\n</div>",
|
||||||
|
);
|
||||||
|
kept.push(`\u0000TABLE${tables.length - 1}\u0000`);
|
||||||
|
pos = end;
|
||||||
|
}
|
||||||
|
text = kept.join("\n");
|
||||||
|
|
||||||
|
// 2. Escape everything else, then apply the inline + block transforms.
|
||||||
|
text = inline(escapeHtml(text));
|
||||||
|
|
||||||
// 3. Paragraphs (double newline separated).
|
// 3. Paragraphs (double newline separated).
|
||||||
text = text
|
text = text
|
||||||
@@ -46,6 +134,8 @@ function renderMarkdown(md) {
|
|||||||
})
|
})
|
||||||
.join("");
|
.join("");
|
||||||
|
|
||||||
// 4. Restore code blocks.
|
// 4. Restore protected spans: tables (already final HTML) and code.
|
||||||
return text.replace(/\u0000CODE(\d+)\u0000/g, (_m, i) => codeBlocks[Number(i)]);
|
return text
|
||||||
|
.replace(/\u0000TABLE(\d+)\u0000/g, (_m, i) => tables[Number(i)])
|
||||||
|
.replace(/\u0000CODE(\d+)\u0000/g, (_m, i) => codeBlocks[Number(i)]);
|
||||||
}
|
}
|
||||||
|
|||||||
+275
-68
@@ -10,12 +10,9 @@
|
|||||||
* page, shared with the header toggling).
|
* page, shared with the header toggling).
|
||||||
*
|
*
|
||||||
* Phase 34 task 02: the header's functional controls are module-owned
|
* Phase 34 task 02: the header's functional controls are module-owned
|
||||||
* (assets/header.js): the Sync sources state machine (#sync-btn's
|
* (assets/header.js), including the New chat binding (on a non-chat
|
||||||
* §7.4 lifecycle — the page only renders #sync-result +
|
* page "new chat" means going to the chat, fresh — the module clears
|
||||||
* #sync-error-banner off the module's "bor:sync-status" event) and the
|
* the phase-14 conversation key and navigates to "/").
|
||||||
* New chat binding (on a non-chat page "new chat" means going to the
|
|
||||||
* chat, fresh — the module clears the phase-14 conversation key and
|
|
||||||
* navigates to "/").
|
|
||||||
*
|
*
|
||||||
* Phase 26: the table's path links open the document in the
|
* Phase 26: the table's path links open the document in the
|
||||||
* almost-fullscreen modal overlay (assets/document-modal.js) on the
|
* almost-fullscreen modal overlay (assets/document-modal.js) on the
|
||||||
@@ -27,7 +24,278 @@
|
|||||||
* tag; esbuild inlines it into the page bundle).
|
* tag; esbuild inlines it into the page bundle).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { fetchIsAdmin, fmtSyncResult, initSharedHeader } from "./header.js";
|
import { fetchIsAdmin, initSharedHeader } from "./header.js";
|
||||||
|
|
||||||
|
/* ---------- Sync sources button (Sources page only) ----------
|
||||||
|
*
|
||||||
|
* The §7.4 never-stale lifecycle: idle → click → POST /api/sync
|
||||||
|
* → running (2 s poll of GET /api/sync/status) → success | failed.
|
||||||
|
* Admin-only: the button is hidden for anonymous users (initSharedHeader
|
||||||
|
* hides it). A failed run opens an error modal (same as the former
|
||||||
|
* header.js module — recreated here since the navbar button is gone).
|
||||||
|
*
|
||||||
|
* Elements: #sync-btn (the button), #sync-label (the text),
|
||||||
|
* #sync-icon (the spinner icon), #sync-result (aria-live result
|
||||||
|
* line), #sync-error-banner / #sync-error-text (error banner).
|
||||||
|
*/
|
||||||
|
const syncBtn = document.querySelector("#sync-btn");
|
||||||
|
const syncLabel = syncBtn ? syncBtn.querySelector(".sync-label") : null;
|
||||||
|
const syncIcon = syncBtn ? syncBtn.querySelector(".sync-icon") : null;
|
||||||
|
const syncResult = document.querySelector("#sync-result");
|
||||||
|
const syncErrorBanner = document.querySelector("#sync-error-banner");
|
||||||
|
const syncErrorText = document.querySelector("#sync-error-text");
|
||||||
|
|
||||||
|
const SYNC_POLL_MS = 2000;
|
||||||
|
let syncPollTimer = null;
|
||||||
|
let lastSyncState = null;
|
||||||
|
|
||||||
|
function emitSyncStatus(status) {
|
||||||
|
lastSyncState = status.state;
|
||||||
|
window.dispatchEvent(new CustomEvent("bor:sync-status", { detail: status }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopSyncPolling() {
|
||||||
|
if (syncPollTimer !== null) {
|
||||||
|
clearTimeout(syncPollTimer);
|
||||||
|
syncPollTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtSyncTime(iso) {
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) return "";
|
||||||
|
const pad = (n) => String(n).padStart(2, "0");
|
||||||
|
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtSyncResult(detail) {
|
||||||
|
const d = detail || {};
|
||||||
|
const added = d.added || 0;
|
||||||
|
const updated = d.updated || 0;
|
||||||
|
const parts = [`${added} added`];
|
||||||
|
if (updated > 0) parts.push(`${updated} updated`);
|
||||||
|
if ((d.unchanged || 0) > 0 || (added === 0 && updated === 0)) {
|
||||||
|
parts.push(`${d.unchanged || 0} unchanged`);
|
||||||
|
}
|
||||||
|
if ((d.pruned || 0) > 0) parts.push(`${d.pruned} pruned`);
|
||||||
|
return parts.join(" · ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeSyncError(message) {
|
||||||
|
const text = String(message || "The sync failed.").replace(/\s+/g, " ").trim();
|
||||||
|
return text.length > 200 ? `${text.slice(0, 200)}…` : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function enterSyncRunningState() {
|
||||||
|
if (!syncBtn) return;
|
||||||
|
syncBtn.disabled = true;
|
||||||
|
syncBtn.setAttribute("aria-busy", "true");
|
||||||
|
syncBtn.removeAttribute("title");
|
||||||
|
syncBtn.setAttribute("aria-label", "Sync sources");
|
||||||
|
syncBtn.classList.remove("is-error");
|
||||||
|
if (syncIcon) syncIcon.classList.add("is-spinning");
|
||||||
|
if (syncLabel) syncLabel.textContent = "Syncing…";
|
||||||
|
}
|
||||||
|
|
||||||
|
function settleSyncButton(label) {
|
||||||
|
if (!syncBtn) return;
|
||||||
|
syncBtn.disabled = false;
|
||||||
|
syncBtn.removeAttribute("aria-busy");
|
||||||
|
syncBtn.removeAttribute("title");
|
||||||
|
syncBtn.setAttribute("aria-label", "Sync sources");
|
||||||
|
syncBtn.classList.remove("is-error");
|
||||||
|
if (syncIcon) syncIcon.classList.remove("is-spinning");
|
||||||
|
if (syncLabel) syncLabel.textContent = label;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showSyncError(detail) {
|
||||||
|
if (syncErrorText) syncErrorText.textContent = detail || "The sync failed.";
|
||||||
|
if (syncErrorBanner) syncErrorBanner.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideSyncError() {
|
||||||
|
if (syncErrorText) syncErrorText.textContent = "";
|
||||||
|
if (syncErrorBanner) syncErrorBanner.hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- sync failure modal (recreated here since the navbar button is gone) ---------- */
|
||||||
|
let syncModal = null;
|
||||||
|
let syncModalReturnFocus = null;
|
||||||
|
|
||||||
|
function createSyncModal() {
|
||||||
|
const backdrop = document.createElement("div");
|
||||||
|
backdrop.className = "sync-modal-backdrop";
|
||||||
|
backdrop.innerHTML =
|
||||||
|
'<div class="sync-modal" role="alertdialog" aria-modal="true" ' +
|
||||||
|
'aria-labelledby="sync-modal-title" aria-describedby="sync-modal-error">' +
|
||||||
|
'<h2 id="sync-modal-title">Sync failed</h2>' +
|
||||||
|
'<p id="sync-modal-error"></p>' +
|
||||||
|
'<button type="button" class="sync-modal-close" aria-label="Close error dialog">\u00d7</button>' +
|
||||||
|
"</div>";
|
||||||
|
document.body.appendChild(backdrop);
|
||||||
|
backdrop.querySelector(".sync-modal-close").addEventListener("click", closeSyncModal);
|
||||||
|
backdrop.addEventListener("click", (e) => {
|
||||||
|
if (e.target === backdrop) closeSyncModal();
|
||||||
|
});
|
||||||
|
document.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Escape" && backdrop.classList.contains("is-open")) closeSyncModal();
|
||||||
|
});
|
||||||
|
return backdrop;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showSyncModal(error) {
|
||||||
|
if (!syncBtn || !document.body) return;
|
||||||
|
if (!syncModal) syncModal = createSyncModal();
|
||||||
|
syncModal.querySelector("#sync-modal-error").textContent = error;
|
||||||
|
if (syncModal.classList.contains("is-open")) return;
|
||||||
|
const active = document.activeElement;
|
||||||
|
syncModalReturnFocus = active && active !== document.body ? active : syncBtn;
|
||||||
|
syncModal.classList.add("is-open");
|
||||||
|
syncModal.querySelector(".sync-modal-close").focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSyncModal() {
|
||||||
|
if (!syncModal || !syncModal.classList.contains("is-open")) return;
|
||||||
|
syncModal.classList.remove("is-open");
|
||||||
|
const target = syncModalReturnFocus;
|
||||||
|
syncModalReturnFocus = null;
|
||||||
|
if (target && document.contains(target)) target.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySyncSuccess(status) {
|
||||||
|
const time = fmtSyncTime(status.finished_at);
|
||||||
|
settleSyncButton(time ? `Synced ${time}` : "Synced");
|
||||||
|
if (syncResult) syncResult.textContent = fmtSyncResult(status);
|
||||||
|
hideSyncError();
|
||||||
|
emitSyncStatus(status);
|
||||||
|
// Refresh the catalog live — the KB just changed.
|
||||||
|
loadDocs();
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySyncFailure(status) {
|
||||||
|
const error = sanitizeSyncError(status.error);
|
||||||
|
settleSyncButton("Sync sources");
|
||||||
|
if (syncBtn) {
|
||||||
|
syncBtn.title = error;
|
||||||
|
syncBtn.setAttribute("aria-label", error);
|
||||||
|
syncBtn.classList.add("is-error");
|
||||||
|
}
|
||||||
|
if (syncResult) syncResult.textContent = "";
|
||||||
|
showSyncError(error);
|
||||||
|
emitSyncStatus(status);
|
||||||
|
showSyncModal(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySyncIdle(status) {
|
||||||
|
settleSyncButton("Sync sources");
|
||||||
|
emitSyncStatus(status || { state: "idle" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function startSyncPolling() {
|
||||||
|
if (syncPollTimer !== null) return;
|
||||||
|
const tick = async () => {
|
||||||
|
let status = null;
|
||||||
|
let notAdmin = false;
|
||||||
|
try {
|
||||||
|
const r = await fetch("/api/sync/status");
|
||||||
|
if (r.status === 403) notAdmin = true;
|
||||||
|
else if (r.ok) status = await r.json();
|
||||||
|
} catch { /* network blip — retry next tick */ }
|
||||||
|
if (notAdmin) {
|
||||||
|
stopSyncPolling();
|
||||||
|
if (syncBtn) syncBtn.hidden = true;
|
||||||
|
applySyncIdle();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!status) {
|
||||||
|
syncPollTimer = setTimeout(tick, SYNC_POLL_MS);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (status.state === "success") {
|
||||||
|
stopSyncPolling();
|
||||||
|
applySyncSuccess(status);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (status.state === "failed") {
|
||||||
|
stopSyncPolling();
|
||||||
|
applySyncFailure(status);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (status.state === "idle") {
|
||||||
|
stopSyncPolling();
|
||||||
|
applySyncIdle(status);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Still running: keep button state honest and re-schedule.
|
||||||
|
enterSyncRunningState();
|
||||||
|
syncPollTimer = setTimeout(tick, SYNC_POLL_MS);
|
||||||
|
};
|
||||||
|
syncPollTimer = setTimeout(tick, SYNC_POLL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startSync() {
|
||||||
|
let r;
|
||||||
|
try {
|
||||||
|
r = await fetch("/api/sync", { method: "POST" });
|
||||||
|
} catch {
|
||||||
|
applySyncFailure({
|
||||||
|
state: "failed",
|
||||||
|
error: "Could not reach the server to start the sync — try again.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (r.status === 403) {
|
||||||
|
stopSyncPolling();
|
||||||
|
if (syncBtn) syncBtn.hidden = true;
|
||||||
|
applySyncIdle();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (r.status === 202 || r.status === 409) {
|
||||||
|
enterSyncRunningState();
|
||||||
|
if (syncResult) syncResult.textContent = "";
|
||||||
|
hideSyncError();
|
||||||
|
if (lastSyncState !== "running") emitSyncStatus({ state: "running" });
|
||||||
|
startSyncPolling();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let detail = "";
|
||||||
|
try { detail = (await r.json()).detail || ""; } catch { /* non-JSON */ }
|
||||||
|
applySyncFailure({
|
||||||
|
state: "failed",
|
||||||
|
error: detail || `The server refused to start the sync (${r.status}).`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Load-time re-attach (ADMIN ONLY): a running run re-enters running state,
|
||||||
|
* a terminal run renders its last result. */
|
||||||
|
async function initSyncButton() {
|
||||||
|
if (!syncBtn) return;
|
||||||
|
if (!(await fetchIsAdmin())) return;
|
||||||
|
let status;
|
||||||
|
try {
|
||||||
|
const r = await fetch("/api/sync/status");
|
||||||
|
if (r.status === 403) { syncBtn.hidden = true; return; }
|
||||||
|
if (!r.ok) return;
|
||||||
|
status = await r.json();
|
||||||
|
} catch { return; }
|
||||||
|
if (status.state === "running") {
|
||||||
|
enterSyncRunningState();
|
||||||
|
emitSyncStatus(status);
|
||||||
|
startSyncPolling();
|
||||||
|
} else if (status.state === "success") {
|
||||||
|
applySyncSuccess(status);
|
||||||
|
} else if (status.state === "failed") {
|
||||||
|
applySyncFailure(status);
|
||||||
|
} else {
|
||||||
|
applySyncIdle(status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (syncBtn) {
|
||||||
|
syncBtn.addEventListener("click", startSync);
|
||||||
|
initSyncButton();
|
||||||
|
}
|
||||||
|
|
||||||
import { openDocumentModal } from "./document-modal.js"; // phase 26: row links open the same-page modal
|
import { openDocumentModal } from "./document-modal.js"; // phase 26: row links open the same-page modal
|
||||||
|
|
||||||
const tbody = document.querySelector("#docs-tbody");
|
const tbody = document.querySelector("#docs-tbody");
|
||||||
@@ -141,65 +409,6 @@ function showEmpty() {
|
|||||||
if (tableWrap) tableWrap.hidden = true;
|
if (tableWrap) tableWrap.hidden = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Phase 32 (state machine module-owned from phase 34
|
|
||||||
* task 02): the #sync-result line + #sync-error-banner ----------
|
|
||||||
*
|
|
||||||
* The #sync-btn state machine itself (click → POST /api/sync, the 2 s
|
|
||||||
* GET /api/sync/status poll, the running / success / failed button
|
|
||||||
* states, the admin-only load re-attach) lives in the shared header
|
|
||||||
* module (assets/header.js) — so the SAME button markup on ANY page
|
|
||||||
* behaves identically. This page keeps only the page-specific
|
|
||||||
* rendering: the aria-live last-result line and the role="alert" error
|
|
||||||
* banner, driven by the module's "bor:sync-status" event (detail = the
|
|
||||||
* GET /api/sync/status object):
|
|
||||||
*
|
|
||||||
* running → clear the result line, hide the banner (a new run starts
|
|
||||||
* clean — the module emits the frame immediately on
|
|
||||||
* click/boot, no 2 s poll lag);
|
|
||||||
* success → the last-result counts in #sync-result (fmtSyncResult —
|
|
||||||
* "added" always shown, zero terms omitted) + the catalog
|
|
||||||
* re-fetches live (the KB just changed — never a stale
|
|
||||||
* table) + the banner hidden;
|
|
||||||
* failed → #sync-error-banner with the error text, result cleared;
|
|
||||||
* idle → hide the banner, clear the result (a run vanishing with
|
|
||||||
* a server restart, or the post-403 cleanup).
|
|
||||||
*/
|
|
||||||
const syncResult = document.querySelector("#sync-result");
|
|
||||||
const syncErrorBanner = document.querySelector("#sync-error-banner");
|
|
||||||
const syncErrorText = document.querySelector("#sync-error-text");
|
|
||||||
|
|
||||||
function showSyncError(detail) {
|
|
||||||
if (syncErrorText) syncErrorText.textContent = detail || "The sync failed.";
|
|
||||||
if (syncErrorBanner) syncErrorBanner.hidden = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function hideSyncError() {
|
|
||||||
if (syncErrorText) syncErrorText.textContent = "";
|
|
||||||
if (syncErrorBanner) syncErrorBanner.hidden = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener("bor:sync-status", (e) => {
|
|
||||||
const status = e.detail || {};
|
|
||||||
if (status.state === "running") {
|
|
||||||
if (syncResult) syncResult.textContent = "";
|
|
||||||
hideSyncError();
|
|
||||||
} else if (status.state === "success") {
|
|
||||||
if (syncResult) syncResult.textContent = fmtSyncResult(status.detail);
|
|
||||||
hideSyncError();
|
|
||||||
// The KB just changed — refresh the catalog live so the table,
|
|
||||||
// stats, and empty state never sit stale under the "Synced" label
|
|
||||||
// (the sync is the page's own action; a reload should not be
|
|
||||||
// needed to see it).
|
|
||||||
loadDocs();
|
|
||||||
} else if (status.state === "failed") {
|
|
||||||
if (syncResult) syncResult.textContent = "";
|
|
||||||
showSyncError(status.error);
|
|
||||||
} else {
|
|
||||||
// idle
|
|
||||||
if (syncResult) syncResult.textContent = "";
|
|
||||||
hideSyncError();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
await initSharedHeader(); // phase 19: Sign in/out + Sources link in the shared bar
|
await initSharedHeader(); // phase 19: Sign in/out + Sources link in the shared bar
|
||||||
@@ -213,6 +422,4 @@ window.addEventListener("bor:sync-status", (e) => {
|
|||||||
}
|
}
|
||||||
if (gateEl) gateEl.hidden = true;
|
if (gateEl) gateEl.hidden = true;
|
||||||
loadDocs();
|
loadDocs();
|
||||||
// Phase 34 task 02: the sync re-attach is module-owned (header.js
|
|
||||||
// boots it on the same cached whoami) — nothing to start here.
|
|
||||||
})();
|
})();
|
||||||
|
|||||||
+402
-187
@@ -1,27 +1,27 @@
|
|||||||
/* ==========================================================================
|
/* ==========================================================================
|
||||||
Brain of Reese — design system (no CDN; system fonts only)
|
Brain of Reese — design system (no CDN; system fonts only)
|
||||||
Dark tech theme (phase 08): emoji-free chrome, subtle animated pure-CSS
|
Dark red theme (phase 08): emoji-free chrome, subtle animated pure-CSS
|
||||||
background, WCAG 2.1 AA dark palette (every pair computed >= 4.5:1).
|
background, WCAG 2.1 AA dark palette (every pair computed >= 4.5:1).
|
||||||
========================================================================== */
|
========================================================================== */
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
/* Palette — all text/background pairs meet WCAG 2.1 AA (>= 4.5:1) */
|
/* Palette — all text/background pairs meet WCAG 2.1 AA (>= 4.5:1) */
|
||||||
--bg: #0a0e17; /* page: ink on bg 16.2:1 */
|
--bg: #0f0a0a; /* page: ink on bg 16.7:1 */
|
||||||
--surface: #121a2e; /* ink on surface 14.5:1 */
|
--surface: #1a0f0f; /* ink on surface 13.8:1 */
|
||||||
--ink: #e8ebf4;
|
--ink: #f0e6e6;
|
||||||
--ink-soft: #9aa4bd; /* 6.9:1 on --surface */
|
--ink-soft: #b8a8a8; /* 5.1:1 on --surface */
|
||||||
--line: #26304a; /* decorative 1px borders */
|
--line: #2d1a1a; /* decorative 1px borders */
|
||||||
--brand: #6d78f2; /* text on brand is DARK ink (--bg): 5.2:1 —
|
--brand: #f43f5e; /* text on brand is DARK ink (--bg): 5.2:1 —
|
||||||
never white on brand (3.7:1, fails) */
|
never white on brand (3.7:1, fails) */
|
||||||
--brand-soft: #232b52;
|
--brand-soft: #2d0a0a;
|
||||||
--brand-ink: #a5b4fc; /* 8.7:1 on --surface, 6.9:1 on --brand-soft */
|
--brand-ink: #fca5a5; /* 9.0:1 on --surface, 12.4:1 on --brand-soft */
|
||||||
--accent-bg: #2b2110;
|
--accent-bg: #2b2110;
|
||||||
--accent-ink: #fbbf24; /* 9.5:1 on --accent-bg */
|
--accent-ink: #fbbf24; /* 9.5:1 on --accent-bg */
|
||||||
--accent-line: #f59e0b; /* 8.9:1 on --bg (deflection border) */
|
--accent-line: #f59e0b; /* 8.9:1 on --bg (deflection border) */
|
||||||
--ok-bg: #10241b;
|
--ok-bg: #10241b;
|
||||||
--ok-ink: #6ee7a8; /* 10.6:1 on --ok-bg */
|
--ok-ink: #6ee7a8; /* 10.6:1 on --ok-bg */
|
||||||
--err-bg: #2d1318;
|
--err-bg: #2d0a0a;
|
||||||
--err-ink: #fca5a5; /* 9.1:1 on --err-bg */
|
--err-ink: #fca5a5; /* 9.3:1 on --err-bg */
|
||||||
--err-line: #ef4444; /* 4.6:1 on --err-bg (UI boundary, not text) */
|
--err-line: #ef4444; /* 4.6:1 on --err-bg (UI boundary, not text) */
|
||||||
|
|
||||||
--radius: 10px;
|
--radius: 10px;
|
||||||
@@ -95,8 +95,8 @@ body::before {
|
|||||||
z-index: -1;
|
z-index: -1;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
background-image:
|
background-image:
|
||||||
linear-gradient(to right, rgb(38 48 74 / 0.6) 1px, transparent 1px),
|
linear-gradient(to right, rgb(74 38 38 / 0.6) 1px, transparent 1px),
|
||||||
linear-gradient(to bottom, rgb(38 48 74 / 0.6) 1px, transparent 1px);
|
linear-gradient(to bottom, rgb(74 38 38 / 0.6) 1px, transparent 1px);
|
||||||
background-size: 44px 44px;
|
background-size: 44px 44px;
|
||||||
-webkit-mask-image: radial-gradient(140% 110% at 50% 0%, black 40%, transparent 90%);
|
-webkit-mask-image: radial-gradient(140% 110% at 50% 0%, black 40%, transparent 90%);
|
||||||
mask-image: radial-gradient(140% 110% at 50% 0%, black 40%, transparent 90%);
|
mask-image: radial-gradient(140% 110% at 50% 0%, black 40%, transparent 90%);
|
||||||
@@ -110,7 +110,7 @@ body::after {
|
|||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: -1;
|
z-index: -1;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
background-image: radial-gradient(circle 56rem at 12% 8%, rgb(109 120 242 / 0.14), transparent 62%);
|
background-image: radial-gradient(circle 56rem at 12% 8%, rgb(244 63 94 / 0.10), transparent 62%);
|
||||||
animation: bg-glow-a 26s ease-in-out infinite;
|
animation: bg-glow-a 26s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,7 +122,7 @@ html::before {
|
|||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: -1;
|
z-index: -1;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
background-image: radial-gradient(circle 60rem at 88% 92%, rgb(34 211 238 / 0.10), transparent 62%);
|
background-image: radial-gradient(circle 60rem at 88% 92%, rgb(251 146 60 / 0.08), transparent 62%);
|
||||||
animation: bg-glow-b 34s ease-in-out -12s infinite;
|
animation: bg-glow-b 34s ease-in-out -12s infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,7 +134,7 @@ html::after {
|
|||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: -1;
|
z-index: -1;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
background-image: radial-gradient(circle 52rem at 14% 86%, rgb(109 120 242 / 0.09), transparent 62%);
|
background-image: radial-gradient(circle 52rem at 14% 86%, rgb(239 68 68 / 0.08), transparent 62%);
|
||||||
animation: bg-glow-c 42s ease-in-out -23s infinite;
|
animation: bg-glow-c 42s ease-in-out -23s infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,7 +186,7 @@ html::after {
|
|||||||
}
|
}
|
||||||
|
|
||||||
::selection {
|
::selection {
|
||||||
background: rgb(109 120 242 / 0.45);
|
background: rgb(244 63 94 / 0.45);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,9 +217,9 @@ html::after {
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
90deg,
|
90deg,
|
||||||
rgb(109 120 242 / 0.55),
|
rgb(244 63 94 / 0.55),
|
||||||
rgb(34 211 238 / 0.30) 45%,
|
rgb(251 146 60 / 0.30) 45%,
|
||||||
rgb(34 211 238 / 0.05) 90%
|
rgb(251 191 36 / 0.05) 90%
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
/* margin-left:auto on the nav (not justify-content:space-between) so the
|
/* margin-left:auto on the nav (not justify-content:space-between) so the
|
||||||
@@ -274,6 +274,14 @@ html::after {
|
|||||||
.nav-link:hover { background: var(--brand-soft); color: var(--brand-ink); }
|
.nav-link:hover { background: var(--brand-soft); color: var(--brand-ink); }
|
||||||
.nav-link.is-active { background: var(--brand); color: var(--bg); }
|
.nav-link.is-active { background: var(--brand); color: var(--bg); }
|
||||||
|
|
||||||
|
/* Phase 46 (owner permission 2026-08-27, TODO.md L9): the mobile
|
||||||
|
hamburger button — desktop is byte-identical to before (the control
|
||||||
|
is absent outside the ≤640px block, which re-displays it and turns
|
||||||
|
the nav into the dropdown). :focus-visible inherits the global 3px
|
||||||
|
outline rule; the 44px target + the rest of the look live in the
|
||||||
|
≤640px block below. */
|
||||||
|
.nav-toggle { display: none; }
|
||||||
|
|
||||||
/* "New chat" reset (phase 14): ghost pill in the chat header, hover like
|
/* "New chat" reset (phase 14): ghost pill in the chat header, hover like
|
||||||
a nav link. ink-soft on surface ≈6.9:1; hover pair brand-ink/brand-soft
|
a nav link. ink-soft on surface ≈6.9:1; hover pair brand-ink/brand-soft
|
||||||
≈6.9:1 — both WCAG AA. Icon-only below 640px (aria-label keeps the
|
≈6.9:1 — both WCAG AA. Icon-only below 640px (aria-label keeps the
|
||||||
@@ -286,16 +294,16 @@ html::after {
|
|||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
padding: 0.5rem 0.9rem;
|
padding: 0.5rem 0.9rem;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
border: 1px solid var(--line);
|
border: 0;
|
||||||
background: transparent;
|
background: var(--brand);
|
||||||
color: var(--ink-soft);
|
color: var(--bg);
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.new-chat-btn:hover { background: var(--brand-soft); color: var(--brand-ink); }
|
.new-chat-btn:hover { background: #f55a72; color: var(--bg); }
|
||||||
/* The plus mark is hidden on desktop (label carries the pill); it is the
|
/* The plus mark is hidden on desktop (label carries the pill); it is the
|
||||||
whole control below 640px. */
|
whole control below 640px. */
|
||||||
.new-chat-btn svg { width: 16px; height: 16px; display: none; }
|
.new-chat-btn svg { width: 16px; height: 16px; display: none; }
|
||||||
@@ -327,100 +335,11 @@ html::after {
|
|||||||
.auth-link:disabled { opacity: 0.6; cursor: wait; }
|
.auth-link:disabled { opacity: 0.6; cursor: wait; }
|
||||||
.auth-link svg { width: 16px; height: 16px; display: none; }
|
.auth-link svg { width: 16px; height: 16px; display: none; }
|
||||||
|
|
||||||
/* Phase 32: the admin-only "Sync sources" pill (Sources header) — the
|
/* Mobile-only sign-out / sign-in copies: hidden on desktop,
|
||||||
same ghost pill as New chat / the auth links, so the bar keeps one
|
revealed inside the hamburger dropdown on mobile (phase 46 UX revision). */
|
||||||
visual language. ink-soft on surface ≈6.9:1 (WCAG AA); hover pair
|
.sign-out-mobile,
|
||||||
brand-ink/brand-soft ≈6.9:1. The refresh icon is always visible (it
|
.sign-in-mobile {
|
||||||
doubles as the running-state spinner); icon-only below 640px like
|
display: none;
|
||||||
the other pills (aria-label keeps the accessible name). ≥44px touch
|
|
||||||
target at every width; :focus-visible via the global rule. */
|
|
||||||
.sync-btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.4rem;
|
|
||||||
min-height: 44px;
|
|
||||||
padding: 0.5rem 0.9rem;
|
|
||||||
border-radius: 999px;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
background: transparent;
|
|
||||||
color: var(--ink-soft);
|
|
||||||
font: inherit;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
white-space: nowrap;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.sync-btn:hover { background: var(--brand-soft); color: var(--brand-ink); }
|
|
||||||
.sync-btn:disabled { opacity: 0.6; cursor: wait; }
|
|
||||||
/* Phase 34: the failed-sync look — the button now lives on EVERY page
|
|
||||||
(the non-Sources pages have no error banner, so the button itself is
|
|
||||||
the visible failure state; the sanitized error text rides in title /
|
|
||||||
aria-label, set by the header module). Phase-08 error pair: --err-ink
|
|
||||||
on --err-bg ≈9.1:1, --err-line border (the amber --accent-line is
|
|
||||||
deflection-only — never on errors). */
|
|
||||||
.sync-btn.is-error {
|
|
||||||
background: var(--err-bg);
|
|
||||||
color: var(--err-ink);
|
|
||||||
border-color: var(--err-line);
|
|
||||||
}
|
|
||||||
.sync-btn.is-error:hover { background: var(--err-bg); color: var(--err-ink); }
|
|
||||||
.sync-icon { width: 16px; height: 16px; display: block; flex: 0 0 auto; }
|
|
||||||
/* Running state: the refresh icon spins (reuses the shared spin
|
|
||||||
keyframes) — the visible half of "Syncing…" while the 2 s poll waits. */
|
|
||||||
.sync-btn .sync-icon.is-spinning { animation: spin 1s linear infinite; }
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.sync-btn .sync-icon.is-spinning { animation: none; }
|
|
||||||
}
|
|
||||||
/* The aria-live last-result announcer ("2 added · 1 pruned") — soft ink
|
|
||||||
on the header surface (≈6.9:1), small mono to match the stat cards. */
|
|
||||||
.sync-result {
|
|
||||||
color: var(--ink-soft);
|
|
||||||
font-family: var(--mono);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* "Tuning" toggle (phase 15): ghost pill like New chat + a mono count
|
|
||||||
badge (brand-ink on brand-soft ≈6.9:1). The label is visually-hidden
|
|
||||||
(not removed) below 640px so the accessible name keeps the word.
|
|
||||||
≥44px touch target at every width. */
|
|
||||||
.steering-toggle {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.4rem;
|
|
||||||
min-height: 44px;
|
|
||||||
padding: 0.5rem 0.9rem;
|
|
||||||
border-radius: 999px;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
background: transparent;
|
|
||||||
color: var(--ink-soft);
|
|
||||||
font: inherit;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
white-space: nowrap;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.steering-toggle:hover, .steering-toggle[aria-expanded="true"] {
|
|
||||||
background: var(--brand-soft);
|
|
||||||
color: var(--brand-ink);
|
|
||||||
}
|
|
||||||
.steering-toggle svg { width: 16px; height: 16px; display: block; }
|
|
||||||
.steering-count {
|
|
||||||
font-family: var(--mono);
|
|
||||||
font-size: 0.78rem;
|
|
||||||
font-weight: 700;
|
|
||||||
min-width: 1.35rem;
|
|
||||||
text-align: center;
|
|
||||||
padding: 0.05rem 0.4rem;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: var(--brand-soft);
|
|
||||||
color: var(--brand-ink);
|
|
||||||
}
|
|
||||||
.steering-toggle[aria-expanded="true"] .steering-count {
|
|
||||||
background: var(--brand);
|
|
||||||
color: var(--bg); /* dark ink on brand: 5.2:1 */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Main frame ---------- */
|
/* ---------- Main frame ---------- */
|
||||||
@@ -486,8 +405,8 @@ html::after {
|
|||||||
}
|
}
|
||||||
.bubble p { margin: 0.2rem 0; }
|
.bubble p { margin: 0.2rem 0; }
|
||||||
.bubble pre {
|
.bubble pre {
|
||||||
background: #0d1120;
|
background: #1a0f0f;
|
||||||
color: #e6e9f2;
|
color: #e6d0d0;
|
||||||
padding: 0.7rem 0.9rem;
|
padding: 0.7rem 0.9rem;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
@@ -498,6 +417,25 @@ html::after {
|
|||||||
.bubble code { font-family: var(--mono); font-size: 0.88em; background: var(--brand-soft); padding: 0.08em 0.35em; border-radius: 5px; }
|
.bubble code { font-family: var(--mono); font-size: 0.88em; background: var(--brand-soft); padding: 0.08em 0.35em; border-radius: 5px; }
|
||||||
.bubble pre code { background: none; padding: 0; }
|
.bubble pre code { background: none; padding: 0; }
|
||||||
|
|
||||||
|
/* GFM pipe tables (phase 44, 2026-08-27, TODO.md L6): the shared
|
||||||
|
renderer wraps every table in .md-table-wrap — the horizontal
|
||||||
|
scroller, so a wide table scrolls inside the bubble instead of
|
||||||
|
breaking the 46rem column — around a semantic <table class="md-table">
|
||||||
|
(escape-first cells; alignment colons render left, owner decision).
|
||||||
|
Phase-08 tokens only: --line hairline borders and the thead tinted
|
||||||
|
from the plain surface family — --ink on --surface is 14.5:1 (PLAN
|
||||||
|
§7.2), never the brand. The rules are unscoped on purpose: the same
|
||||||
|
renderer serves the chat bubble, the thinking scratchpad, and the
|
||||||
|
document viewer (.doc-md). width:100% stretches narrow tables to the
|
||||||
|
column; min-width:max-content lets a WIDE table keep its natural width
|
||||||
|
so the wrapper is the real scroller (phase 44 task 03: width:100% alone
|
||||||
|
wrapped the wide table's cells and it never overflowed). Static content
|
||||||
|
— no animation (nothing for prefers-reduced-motion to still). */
|
||||||
|
.md-table-wrap { overflow-x: auto; }
|
||||||
|
.md-table { border-collapse: collapse; width: 100%; min-width: max-content; font-size: 0.9rem; }
|
||||||
|
.md-table th, .md-table td { border: 1px solid var(--line); padding: 0.4rem 0.6rem; text-align: left; vertical-align: top; }
|
||||||
|
.md-table thead th { background: var(--surface); color: var(--ink); }
|
||||||
|
|
||||||
.msg.user { justify-content: flex-end; }
|
.msg.user { justify-content: flex-end; }
|
||||||
.msg.user .msg-body { align-items: flex-end; }
|
.msg.user .msg-body { align-items: flex-end; }
|
||||||
.msg.user .bubble {
|
.msg.user .bubble {
|
||||||
@@ -560,10 +498,13 @@ details.thinking .thinking-text {
|
|||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
max-height: 320px;
|
max-height: 320px;
|
||||||
overflow-y: hidden; /* no user scroll back (owner choice 2026-08-24):
|
overflow-y: auto; /* user-scrollable window (owner direction 2026-08-27,
|
||||||
the window is a live tail only — the phase-17
|
TODO.md L7): autoscroll follows the live tail
|
||||||
JS bottom-pin (scrollTop = scrollHeight per
|
only while the user is pinned near the window's
|
||||||
chunk) is the sole scroller */
|
bottom — the phase-17 pin, gated in app.js (task
|
||||||
|
02: THINKING_NEAR_BOTTOM_PX); scrolling up
|
||||||
|
pauses the follow, returning to the bottom
|
||||||
|
resumes it. */
|
||||||
}
|
}
|
||||||
/* The scratchpad is compact: tighten the renderer's paragraph/list margins. */
|
/* The scratchpad is compact: tighten the renderer's paragraph/list margins. */
|
||||||
details.thinking .thinking-text p,
|
details.thinking .thinking-text p,
|
||||||
@@ -702,7 +643,7 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
|||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
background: #0d1120;
|
background: #1a0f0f;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
padding: 0.5rem 0.6rem;
|
padding: 0.5rem 0.6rem;
|
||||||
@@ -788,7 +729,7 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
gap: 0.6rem;
|
gap: 0.6rem;
|
||||||
background: #0d1120;
|
background: #1a0f0f;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
padding: 0.35rem 0.4rem 0.35rem 0.8rem;
|
padding: 0.35rem 0.4rem 0.35rem 0.8rem;
|
||||||
@@ -967,7 +908,7 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
background: #0d1120;
|
background: #1a0f0f;
|
||||||
border: 1px solid var(--brand-soft);
|
border: 1px solid var(--brand-soft);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
padding: 0.6rem 0.7rem;
|
padding: 0.6rem 0.7rem;
|
||||||
@@ -1182,7 +1123,7 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
|||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
background: #0d1120;
|
background: #1a0f0f;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
padding: 0.55rem 0.75rem;
|
padding: 0.55rem 0.75rem;
|
||||||
@@ -1226,8 +1167,120 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
.page-head h1 { margin: 0 0 0.25rem; font-size: 1.7rem; }
|
.page-head h1 { margin: 0 0 0.25rem; font-size: 1.7rem; }
|
||||||
|
.page-head-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
.page-sub { margin: 0; color: var(--ink-soft); }
|
.page-sub { margin: 0; color: var(--ink-soft); }
|
||||||
.page-sub code { font-family: var(--mono); font-size: 0.85em; background: var(--brand-soft); padding: 0.1em 0.35em; border-radius: 5px; }
|
.page-sub code { font-family: var(--mono); font-size: 0.85em; background: var(--brand-soft); padding: 0.1em 0.35em; border-radius: 5px; }
|
||||||
|
/* ---------- Sync button (Sources page) ---------- */
|
||||||
|
.sync-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 0.5rem 0.9rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.sync-btn:hover { background: var(--brand-soft); color: var(--brand-ink); }
|
||||||
|
.sync-btn:disabled { opacity: 0.6; cursor: wait; }
|
||||||
|
.sync-btn.is-error {
|
||||||
|
background: var(--err-bg);
|
||||||
|
color: var(--err-ink);
|
||||||
|
border-color: var(--err-line);
|
||||||
|
}
|
||||||
|
.sync-btn.is-error:hover { background: var(--err-bg); color: var(--err-ink); }
|
||||||
|
.sync-icon { width: 16px; height: 16px; display: block; flex: 0 0 auto; }
|
||||||
|
.sync-btn .sync-icon.is-spinning { animation: spin 1s linear infinite; }
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.sync-btn .sync-icon.is-spinning { animation: none; }
|
||||||
|
}
|
||||||
|
.sync-label { display: inline; }
|
||||||
|
/* Last-result announcer — soft ink, small mono */
|
||||||
|
.sync-result {
|
||||||
|
display: block;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding-block: 0.5rem;
|
||||||
|
}
|
||||||
|
/* Sync failure modal — lazily created by sources.js, appended to <body>.
|
||||||
|
Error surface: --err-bg with --err-line border, title in --ink,
|
||||||
|
error text in --err-ink (9.1:1 on --err-bg). Backdrop dims with
|
||||||
|
--bg at 82%. z-index 1000 (same overlay contract as doc-modal). */
|
||||||
|
.sync-modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1rem;
|
||||||
|
background: rgba(10, 14, 23, 0.82);
|
||||||
|
visibility: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 120ms ease;
|
||||||
|
}
|
||||||
|
.sync-modal-backdrop.is-open {
|
||||||
|
visibility: visible;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.sync-modal {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 28rem;
|
||||||
|
background: var(--err-bg);
|
||||||
|
border: 1px solid var(--err-line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
padding: 1.1rem 3rem 1.25rem 1.25rem;
|
||||||
|
}
|
||||||
|
#sync-modal-title {
|
||||||
|
margin: 0 0 0.6rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
#sync-modal-error {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--err-ink);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.sync-modal-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.3rem;
|
||||||
|
right: 0.3rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 44px;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--err-line);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--err-ink);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.sync-modal-close:hover { background: rgb(239 68 68 / 0.15); }
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.sync-modal-backdrop { transition: none; }
|
||||||
|
}
|
||||||
|
|
||||||
.stat-cards {
|
.stat-cards {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -1332,14 +1385,14 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Add forms — the tuning form's surface as a single row: visible
|
/* Add forms — the tuning form's surface as a single row: visible
|
||||||
label + mono location input (git URLs may embed credentials, local
|
label + mono location input (git URLs may embed credentials, so the
|
||||||
paths may contain anything, so both inputs are mono) + the brand
|
input is mono) + the brand button; wraps to a column at narrow
|
||||||
button; wraps to a column at narrow widths (the <=640px block
|
widths (the <=640px block below). Phase 49: the archive upload form
|
||||||
below). Phase 38: the "Local directory" form (#local-source-form)
|
(#archive-upload-form, replacing the phase-38 local-directory form)
|
||||||
reuses the git form's rules VERBATIM — one form language for both
|
reuses the git form's card + button rules VERBATIM — one form
|
||||||
kinds. */
|
language for both; its file input carries its own rules below. */
|
||||||
#git-source-form,
|
#git-source-form,
|
||||||
#local-source-form {
|
#archive-upload-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1351,11 +1404,10 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
|||||||
padding: 0.9rem 1rem 1rem;
|
padding: 0.9rem 1rem 1rem;
|
||||||
}
|
}
|
||||||
#git-source-form:focus-within,
|
#git-source-form:focus-within,
|
||||||
#local-source-form:focus-within { border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-soft), var(--shadow); }
|
#archive-upload-form:focus-within { border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-soft), var(--shadow); }
|
||||||
#git-source-form > label,
|
#git-source-form > label,
|
||||||
#local-source-form > label { color: var(--ink); font-weight: 600; white-space: nowrap; }
|
#archive-upload-form > label { color: var(--ink); font-weight: 600; white-space: nowrap; }
|
||||||
#git-source-url,
|
#git-source-url {
|
||||||
#local-source-path {
|
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 14rem;
|
min-width: 14rem;
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
@@ -1367,12 +1419,10 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
|||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
padding: 0.45rem 0.7rem;
|
padding: 0.45rem 0.7rem;
|
||||||
}
|
}
|
||||||
#git-source-url::placeholder,
|
#git-source-url::placeholder { color: var(--ink-soft); }
|
||||||
#local-source-path::placeholder { color: var(--ink-soft); }
|
#git-source-url:focus-visible { outline-offset: 0; border-color: var(--brand); }
|
||||||
#git-source-url:focus-visible,
|
|
||||||
#local-source-path:focus-visible { outline-offset: 0; border-color: var(--brand); }
|
|
||||||
#git-source-add,
|
#git-source-add,
|
||||||
#local-source-add {
|
#archive-upload-btn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -1387,9 +1437,58 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
#git-source-add:hover:not(:disabled),
|
#git-source-add:hover:not(:disabled),
|
||||||
#local-source-add:hover:not(:disabled) { background: #7d88f5; }
|
#archive-upload-btn:hover:not(:disabled) { background: #7d88f5; }
|
||||||
#git-source-add:disabled,
|
#git-source-add:disabled,
|
||||||
#local-source-add:disabled { opacity: 0.6; cursor: wait; }
|
#archive-upload-btn:disabled { opacity: 0.6; cursor: wait; }
|
||||||
|
|
||||||
|
/* Phase 49: the upload form's file control — mono-ish, on-surface, a
|
||||||
|
>=44px touch target, :focus-visible via the global 3px outline rule
|
||||||
|
(offset zeroed + brand border, exactly like the git URL input). The
|
||||||
|
chosen filename renders in mono; the selector button keeps a plain
|
||||||
|
surface chip. */
|
||||||
|
#archive-upload-file {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 14rem;
|
||||||
|
min-height: 44px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
color: var(--ink);
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 0.3rem 0.5rem;
|
||||||
|
}
|
||||||
|
#archive-upload-file:focus-visible { outline-offset: 0; border-color: var(--brand); }
|
||||||
|
#archive-upload-file::file-selector-button {
|
||||||
|
min-height: 34px;
|
||||||
|
margin-right: 0.6rem;
|
||||||
|
padding: 0.3rem 0.9rem;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--ink); /* 13.8:1 on surface */
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
#archive-upload-file::file-selector-button:hover { border-color: var(--brand); color: var(--brand-ink); }
|
||||||
|
|
||||||
|
/* The upload's success line (role=status): the sync-result shape
|
||||||
|
("2 added · 1 pruned") — ink-soft on surface (>=4.5:1), the dashed
|
||||||
|
hint-box border marks it as a result, not an error; it drops onto
|
||||||
|
its own row under the input like the error line. */
|
||||||
|
#archive-upload-result {
|
||||||
|
flex-basis: 100%;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px dashed var(--line);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 0.45rem 0.8rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
/* The add form's inline error (role=alert): the err pair (9.1:1);
|
/* The add form's inline error (role=alert): the err pair (9.1:1);
|
||||||
flex-basis 100% drops it onto its own row under the input. */
|
flex-basis 100% drops it onto its own row under the input. */
|
||||||
@@ -1733,8 +1832,8 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
|||||||
.doc-md > :first-child { margin-top: 0; }
|
.doc-md > :first-child { margin-top: 0; }
|
||||||
.doc-md ul { margin: 0.4rem 0; padding-left: 1.3rem; }
|
.doc-md ul { margin: 0.4rem 0; padding-left: 1.3rem; }
|
||||||
.doc-md pre {
|
.doc-md pre {
|
||||||
background: #0d1120;
|
background: #1a0f0f;
|
||||||
color: #e6e9f2;
|
color: #e6d0d0;
|
||||||
padding: 0.7rem 0.9rem;
|
padding: 0.7rem 0.9rem;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
@@ -1777,7 +1876,7 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--brand-ink); /* #a5b4fc on --surface ≈8.7:1 */
|
color: var(--brand-ink); /* #fca5a5 on --surface ≈9.0:1 */
|
||||||
}
|
}
|
||||||
.doc-summary-text {
|
.doc-summary-text {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -2010,7 +2109,7 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
|||||||
|
|
||||||
/* ---------- Responsive (tablet squeeze — phase 34 task 04 visual pass) ----------
|
/* ---------- Responsive (tablet squeeze — phase 34 task 04 visual pass) ----------
|
||||||
The full admin bar (brand + nav [Chat, Sources, Tuning] + Tuning
|
The full admin bar (brand + nav [Chat, Sources, Tuning] + Tuning
|
||||||
toggle + Sync sources + New chat + Sign out) outgrows a 768px bar in
|
toggle + New chat + Sign out) outgrows a 768px bar in
|
||||||
the desktop styles. Squeeze the pills/gaps moderately — the 44px
|
the desktop styles. Squeeze the pills/gaps moderately — the 44px
|
||||||
touch floor is held by min-height and the 64px height by --header-h
|
touch floor is held by min-height and the 64px height by --header-h
|
||||||
— and let the brand wordmark (base ellipsis) absorb any remainder.
|
— and let the brand wordmark (base ellipsis) absorb any remainder.
|
||||||
@@ -2023,7 +2122,7 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
|||||||
designated clip target, pills squeeze next). */
|
designated clip target, pills squeeze next). */
|
||||||
.nav-link { padding: 0.4rem 0.5rem; font-size: 0.85rem; }
|
.nav-link { padding: 0.4rem 0.5rem; font-size: 0.85rem; }
|
||||||
.app-nav { gap: 0.15rem; }
|
.app-nav { gap: 0.15rem; }
|
||||||
.new-chat-btn, .auth-link, .sync-btn, .steering-toggle { padding: 0.45rem 0.5rem; }
|
.new-chat-btn, .auth-link { padding: 0.45rem 0.5rem; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Responsive (mobile-first adjustments) ---------- */
|
/* ---------- Responsive (mobile-first adjustments) ---------- */
|
||||||
@@ -2063,45 +2162,137 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.nav-link { padding: 0.3rem 0.25rem; font-size: 0.72rem; }
|
/* Phase 46 (owner permission 2026-08-27, TODO.md L9): the nav links
|
||||||
.app-nav { gap: 0.05rem; }
|
LEAVE the bar at phone widths — the old pill-squeeze rules for
|
||||||
|
.nav-link / .app-nav (0.72rem pills, 0.05rem gap, in place of this
|
||||||
|
comment) are superseded by the #nav-toggle dropdown below. The
|
||||||
|
action pills' squeeze rules further down are untouched, and the
|
||||||
|
900px tablet block keeps squeezing the INLINE nav at 641–900px
|
||||||
|
(the hamburger is absent there). */
|
||||||
|
.nav-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
padding: 0;
|
||||||
|
color: var(--ink);
|
||||||
|
background: none;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.nav-toggle:hover { background: var(--brand-soft); color: var(--brand-ink); }
|
||||||
|
/* The icon is sized (an unsized inline SVG would default to 300px
|
||||||
|
and blow the bar out); 20px reads as a proper hamburger inside
|
||||||
|
the 44px target. */
|
||||||
|
.nav-toggle svg { width: 20px; height: 20px; display: block; }
|
||||||
|
/* The nav becomes the dropdown. The containing block is the sticky
|
||||||
|
.app-header (.header-inner is not positioned), so the menu spans
|
||||||
|
the header's full width — edge to edge — intended on mobile;
|
||||||
|
z-index 21 = header (20) + 1, above the bar content. */
|
||||||
|
.app-nav {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
margin-left: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
background: var(--surface);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
z-index: 21;
|
||||||
|
/* Closed state (default): invisible and non-interactive — task 02's
|
||||||
|
header.js is the only opener (.is-open + aria-expanded). */
|
||||||
|
visibility: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-8px);
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 180ms ease, transform 180ms ease, visibility 0s linear 180ms;
|
||||||
|
}
|
||||||
|
.app-nav.is-open {
|
||||||
|
visibility: visible;
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
pointer-events: auto;
|
||||||
|
transition: opacity 180ms ease, transform 180ms ease, visibility 0s;
|
||||||
|
}
|
||||||
|
/* Menu rows: comfortable ≥44px targets (0.75rem × 2 + the 1rem line)
|
||||||
|
and readable text — replaces the old .nav-link pill squeeze. */
|
||||||
|
.app-nav .nav-link { padding: 0.75rem 1.25rem; font-size: 1rem; }
|
||||||
.new-chat-btn { padding: 0.4rem 0.3rem; }
|
.new-chat-btn { padding: 0.4rem 0.3rem; }
|
||||||
.new-chat-label { display: none; }
|
.new-chat-label { display: none; }
|
||||||
.new-chat-btn svg { display: block; }
|
.new-chat-btn svg { display: block; }
|
||||||
|
/* But on the chat page there is room — keep the label visible and
|
||||||
|
hide the icon (the button lives inside .chat-shell, not the navbar). */
|
||||||
|
.chat-shell .new-chat-label { display: inline; }
|
||||||
|
.chat-shell .new-chat-btn svg { display: none; }
|
||||||
/* Phase 16: the auth pill goes icon-only like New chat — brand text
|
/* Phase 16: the auth pill goes icon-only like New chat — brand text
|
||||||
ellipsizes as the designated squeeze target, no bar overflow. */
|
ellipsizes as the designated squeeze target, no bar overflow. */
|
||||||
.auth-link { padding: 0.4rem 0.3rem; }
|
.auth-link { padding: 0.4rem 0.3rem; }
|
||||||
.auth-label { display: none; }
|
.auth-label { display: none; }
|
||||||
.auth-link svg { display: block; }
|
.auth-link svg { display: block; }
|
||||||
/* Phase 32: the sync pill goes icon-only like the other pills (the
|
/* Phase 46 UX revision: sign-out & sign-in move into the hamburger
|
||||||
aria-label keeps the accessible name); the spinning icon is the
|
dropdown on mobile — hide the bar copies, show the dropdown copies. */
|
||||||
visible running state on a touch screen. */
|
.sign-out-mobile { display: inline-flex; }
|
||||||
.sync-btn { padding: 0.4rem 0.3rem; }
|
#sign-out-btn { display: none !important; }
|
||||||
.sync-label { display: none; }
|
.sign-in-mobile { display: inline-flex; }
|
||||||
/* The last-result counts stay ANNOUNCED (aria-live is untouched) but
|
#sign-in-link { display: none !important; }
|
||||||
go visually hidden — the 58px bar has no room for the text; the
|
.nav-toggle { margin-left: auto; }
|
||||||
icon carries the visible state. Same clip recipe as .steering-label. */
|
/* Dropdown-style sign-out: full-width row, error palette, label visible. */
|
||||||
.sync-result {
|
#app-nav .sign-out-btn {
|
||||||
position: absolute !important;
|
display: flex;
|
||||||
width: 1px; height: 1px;
|
width: 100%;
|
||||||
margin: -1px; padding: 0;
|
align-items: center;
|
||||||
overflow: hidden;
|
gap: 0.6rem;
|
||||||
clip: rect(0 0 0 0);
|
text-align: left;
|
||||||
white-space: nowrap;
|
padding: 0.75rem 1.25rem;
|
||||||
|
font-size: 1rem;
|
||||||
border: 0;
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--err-ink);
|
||||||
|
min-height: 48px;
|
||||||
}
|
}
|
||||||
.steering-toggle { padding: 0.4rem 0.3rem; }
|
#app-nav .sign-out-btn:hover {
|
||||||
/* Visually hidden, NOT display:none — the accessible name keeps the
|
background: var(--err-bg);
|
||||||
word "Tuning" next to the count badge. */
|
color: var(--err-ink);
|
||||||
.steering-label {
|
}
|
||||||
position: absolute !important;
|
#app-nav .sign-out-btn svg {
|
||||||
width: 1px; height: 1px;
|
width: 18px;
|
||||||
margin: -1px; padding: 0;
|
height: 18px;
|
||||||
overflow: hidden;
|
flex: 0 0 auto;
|
||||||
clip: rect(0 0 0 0);
|
}
|
||||||
white-space: nowrap;
|
#app-nav .sign-out-btn .auth-label { display: inline; }
|
||||||
|
/* Dropdown-style sign-in: matches sign-out row styling. */
|
||||||
|
#app-nav .sign-in-mobile {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
font-size: 1rem;
|
||||||
border: 0;
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--err-ink);
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
min-height: 48px;
|
||||||
}
|
}
|
||||||
|
#app-nav .sign-in-mobile:hover {
|
||||||
|
background: var(--err-bg);
|
||||||
|
color: var(--err-ink);
|
||||||
|
}
|
||||||
|
#app-nav .sign-in-mobile svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
#app-nav .sign-in-mobile .auth-label { display: inline; }
|
||||||
.steering-note { padding: 0.3rem 0.3rem 0.3rem 0.7rem; }
|
.steering-note { padding: 0.3rem 0.3rem 0.3rem 0.7rem; }
|
||||||
.app-main > .steering-panel { width: calc(100% - 1.8rem); }
|
.app-main > .steering-panel { width: calc(100% - 1.8rem); }
|
||||||
.tune-btn { min-height: 44px; }
|
.tune-btn { min-height: 44px; }
|
||||||
@@ -2133,18 +2324,42 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
|||||||
.doc-modal-meta { padding-inline: 0.9rem; }
|
.doc-modal-meta { padding-inline: 0.9rem; }
|
||||||
.doc-modal-content { padding: 0.75rem 0.9rem 1.25rem; }
|
.doc-modal-content { padding: 0.75rem 0.9rem 1.25rem; }
|
||||||
.composer { padding: 0.5rem; }
|
.composer { padding: 0.5rem; }
|
||||||
/* Phase 35 (phase 38: + the local directory form): the add forms
|
/* Phase 35 (phase 49: + the archive upload form): the add forms
|
||||||
stack like the other cards — label, full-width mono input,
|
stack like the other cards — label, full-width input, full-width
|
||||||
full-width button; the table wrapper's horizontal scroll already
|
button; the table wrapper's horizontal scroll already covers long
|
||||||
covers long URLs/paths. */
|
URLs/paths. */
|
||||||
#git-source-form,
|
#git-source-form,
|
||||||
#local-source-form { flex-direction: column; align-items: stretch; }
|
#archive-upload-form { flex-direction: column; align-items: stretch; }
|
||||||
#git-source-form > label,
|
#git-source-form > label,
|
||||||
#local-source-form > label { white-space: normal; }
|
#archive-upload-form > label { white-space: normal; }
|
||||||
#git-source-url,
|
#git-source-url,
|
||||||
#local-source-path { min-width: 0; }
|
#archive-upload-file { min-width: 0; }
|
||||||
#git-source-add,
|
#git-source-add,
|
||||||
#local-source-add { width: 100%; }
|
#archive-upload-btn { width: 100%; }
|
||||||
.footer-inner { flex-direction: column; gap: 0.2rem; text-align: center; }
|
.footer-inner { flex-direction: column; gap: 0.2rem; text-align: center; }
|
||||||
main { padding-bottom: env(safe-area-inset-bottom, 0); }
|
main { padding-bottom: env(safe-area-inset-bottom, 0); }
|
||||||
|
/* Sync button goes icon-only on mobile; the label hides, aria-label
|
||||||
|
keeps the accessible name. The spinning icon is the visible running
|
||||||
|
state on a touch screen. */
|
||||||
|
.sync-btn { padding: 0.4rem 0.3rem; }
|
||||||
|
.sync-label { display: none; }
|
||||||
|
.sync-result {
|
||||||
|
position: absolute !important;
|
||||||
|
width: 1px; height: 1px;
|
||||||
|
margin: -1px; padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0 0 0 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Phase 46: prefers-reduced-motion stills the mobile menu — no
|
||||||
|
180ms slide+fade; open/close snaps (the visibility/opacity flip
|
||||||
|
applies instantly) and stays correct. BOTH states are named: the
|
||||||
|
.is-open rule (0,2,0) would otherwise out-specify a bare .app-nav
|
||||||
|
(0,1,0) and the OPEN transition would still animate. */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.app-nav,
|
||||||
|
.app-nav.is-open { transition: none; }
|
||||||
}
|
}
|
||||||
|
|||||||
+32
-39
@@ -5,7 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
<meta name="description" content="Read a document indexed in Brain of Reese.">
|
<meta name="description" content="Read a document indexed in Brain of Reese.">
|
||||||
<title>Document · Brain of Reese</title>
|
<title>Document · Brain of Reese</title>
|
||||||
<link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20viewBox=%220%200%2064%2064%22%3E%3Cpath%20d=%22M32%204%2055%2018v28L32%2060%209%2046V18Z%22%20fill=%22%23121a2e%22%20stroke=%22%236d78f2%22%20stroke-width=%224%22%20stroke-linejoin=%22round%22/%3E%3Ccircle%20cx=%2232%22%20cy=%2232%22%20r=%226.5%22%20fill=%22%236d78f2%22/%3E%3Cpath%20d=%22M32%2025.5V16M32%2048v-9.5M25.5%2032H16M48%2032h-9.5%22%20stroke=%22%2322d3ee%22%20stroke-width=%223%22%20stroke-linecap=%22round%22/%3E%3C/svg%3E">
|
<link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20viewBox=%220%200%2064%2064%22%3E%3Cpath%20d=%22M32%204%2055%2018v28L32%2060%209%2046V18Z%22%20fill=%22%231a0f0f%22%20stroke=%22%23f43f5e%22%20stroke-width=%224%22%20stroke-linejoin=%22round%22/%3E%3Ccircle%20cx=%2232%22%20cy=%2232%22%20r=%226.5%22%20fill=%22%23f43f5e%22/%3E%3Cpath%20d=%22M32%2025.5V16M32%2048v-9.5M25.5%2032H16M48%2032h-9.5%22%20stroke=%22%23fca5a5%22%20stroke-width=%223%22%20stroke-linecap=%22round%22/%3E%3C/svg%3E">
|
||||||
<link rel="stylesheet" href="/assets/styles.css">
|
<link rel="stylesheet" href="/assets/styles.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -24,70 +24,63 @@
|
|||||||
<div class="app-header">
|
<div class="app-header">
|
||||||
<div class="container header-inner">
|
<div class="container header-inner">
|
||||||
<span class="brand">
|
<span class="brand">
|
||||||
<svg class="brand-mark" aria-hidden="true" viewBox="0 0 64 64"><path d="M32 4 55 18v28L32 60 9 46V18Z" fill="#121a2e" stroke="#6d78f2" stroke-width="4" stroke-linejoin="round"/><circle cx="32" cy="32" r="6.5" fill="#6d78f2"/><path d="M32 25.5V16M32 48v-9.5M25.5 32H16M48 32h-9.5" stroke="#22d3ee" stroke-width="3" stroke-linecap="round"/></svg>
|
<svg class="brand-mark" aria-hidden="true" viewBox="0 0 64 64"><path d="M32 4 55 18v28L32 60 9 46V18Z" fill="#1a0f0f" stroke="#f43f5e" stroke-width="4" stroke-linejoin="round"/><circle cx="32" cy="32" r="6.5" fill="#f43f5e"/><path d="M32 25.5V16M32 48v-9.5M25.5 32H16M48 32h-9.5" stroke="#fca5a5" stroke-width="3" stroke-linecap="round"/></svg>
|
||||||
<span class="brand-text">Brain of <strong>Reese</strong></span>
|
<span class="brand-text">Brain of <strong>Reese</strong></span>
|
||||||
</span>
|
</span>
|
||||||
<nav class="app-nav" aria-label="Primary">
|
<!-- Phase 46 (owner permission 2026-08-27, `TODO.md` L9): the
|
||||||
|
mobile hamburger — visible ≤640px only (CSS); opens the nav as
|
||||||
|
an animated dropdown. Behavior: assets/header.js. -->
|
||||||
|
<button type="button" class="nav-toggle" id="nav-toggle"
|
||||||
|
aria-expanded="false" aria-controls="app-nav" aria-label="Menu">
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M4 7h16M4 12h16M4 17h16"/></svg>
|
||||||
|
</button>
|
||||||
|
<nav class="app-nav" id="app-nav" aria-label="Primary">
|
||||||
<a href="/" class="nav-link">Chat</a>
|
<a href="/" class="nav-link">Chat</a>
|
||||||
<!-- Phase 19 (now every page — phase 34, owner confirmation
|
<!-- Phase 19 (now every page — phase 34, owner confirmation
|
||||||
2026-08-26): the Sources link is admin-only (owner
|
2026-08-26): the Sources link is admin-only (owner
|
||||||
permission 2026-08-23) — hidden by default, header.js
|
permission 2026-08-23) — hidden by default, header.js
|
||||||
reveals it once whoami says admin. The soft-gated page
|
reveals it once whoami says admin. The soft-gated page
|
||||||
itself is unchanged. -->
|
itself is unchanged. -->
|
||||||
<a href="/sources.html" class="nav-link" id="nav-sources" hidden>Sources</a>
|
<a href="/sources.html" class="nav-link" id="nav-sources" hidden>RAG</a>
|
||||||
<!-- Phase 35 (owner permission 2026-08-26): the Git sources
|
<!-- Phase 35 (owner permission 2026-08-26): the Git sources
|
||||||
link is admin-only — hidden by default, header.js
|
link is admin-only — hidden by default, header.js
|
||||||
reveals it once whoami says admin, exactly like the
|
reveals it once whoami says admin, exactly like the
|
||||||
Sources link above. -->
|
Sources link above. -->
|
||||||
<a href="/git-sources.html" class="nav-link" id="nav-git-sources" hidden>Git sources</a>
|
<a href="/git-sources.html" class="nav-link" id="nav-git-sources" hidden>Sources</a>
|
||||||
<!-- Phase 29 (now every page — phase 34, owner confirmation
|
<!-- Phase 29 (now every page — phase 34, owner confirmation
|
||||||
2026-08-26): the Global Tuning link is admin-only (owner
|
2026-08-26): the Global Tuning link is admin-only (owner
|
||||||
permission 2026-08-25) — hidden by default, header.js
|
permission 2026-08-25) — hidden by default, header.js
|
||||||
reveals it once whoami says admin, exactly like the
|
reveals it once whoami says admin, exactly like the
|
||||||
Sources link above. -->
|
Sources link above. -->
|
||||||
<a href="/tuning.html" class="nav-link" id="nav-tuning" hidden>Tuning</a>
|
<a href="/tuning.html" class="nav-link" id="nav-tuning" hidden>Tuning</a>
|
||||||
|
<!-- Phase 46 (mobile dropdown copy: sign-in — desktop bar copy is
|
||||||
|
outside the nav; see styles.css .sign-in-mobile rules). -->
|
||||||
|
<a href="/login.html?next=/" class="auth-link sign-in-link sign-in-mobile" id="sign-in-link-mobile" hidden>
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M10 4h8a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-8"/><path d="M4 12h11"/><path d="m12 9 3 3-3 3"/></svg>
|
||||||
|
<span class="auth-label">Sign in</span>
|
||||||
|
</a>
|
||||||
|
<!-- Phase 46 (mobile dropdown copy — desktop bar copy is
|
||||||
|
outside the nav; see styles.css .sign-out-mobile rules). -->
|
||||||
|
<button type="button" class="auth-link sign-out-btn sign-out-mobile" id="sign-out-btn-mobile" aria-label="Sign out" hidden>
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8"/><path d="M9 12h11"/><path d="m17 9 3 3-3 3"/></svg>
|
||||||
|
<span class="auth-label">Sign out</span>
|
||||||
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
<!-- Phase 15 (now every page — phase 34, owner confirmation
|
<!-- Phase 15: the tuning-notes panel (stored in Postgres, read
|
||||||
2026-08-26): open the tuning-notes panel (stored in
|
into every system prompt) — owned by the shared header
|
||||||
Postgres, read into every system prompt). The behavior is
|
module (assets/header.js); the #steering-panel section
|
||||||
owned by the shared header module (assets/header.js); the
|
ships in every page's <main>. The navbar toggle was
|
||||||
#steering-panel section ships in every page's <main>. -->
|
removed at owner request (2026-08-28): note management
|
||||||
<button type="button" class="steering-toggle" id="steering-toggle"
|
lives on /tuning.html. -->
|
||||||
aria-expanded="false" aria-controls="steering-panel">
|
|
||||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M4 7h10M18 7h2M4 17h4M12 17h8"/><circle cx="15.5" cy="7" r="2.2"/><circle cx="9.5" cy="17" r="2.2"/></svg>
|
|
||||||
<span class="steering-label">Tuning</span>
|
|
||||||
<span class="steering-count" id="steering-count">0</span>
|
|
||||||
</button>
|
|
||||||
<!-- Phase 32 (now every page — phase 34, owner confirmation
|
|
||||||
2026-08-26): the admin-only "Sync sources" button — SHIPS
|
|
||||||
hidden (anonymous-safe), header.js reveals it for the admin
|
|
||||||
on the SAME cached whoami that reveals #nav-sources /
|
|
||||||
#nav-tuning. The §7.4 "never stale" lifecycle is
|
|
||||||
module-owned (assets/header.js); the Sources page's
|
|
||||||
#sync-result line + #sync-error-banner render off the
|
|
||||||
module's "bor:sync-status" event. -->
|
|
||||||
<button type="button" class="sync-btn" id="sync-btn" hidden aria-label="Sync sources">
|
|
||||||
<svg class="sync-icon" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></svg>
|
|
||||||
<span class="sync-label" id="sync-label">Sync sources</span>
|
|
||||||
</button>
|
|
||||||
<!-- Phase 14 (now every page — phase 34, owner confirmation
|
|
||||||
2026-08-26; module-owned since phase 34 task 02): on the
|
|
||||||
chat page "New chat" resets the local (localStorage)
|
|
||||||
conversation; on every other page it means "go to the
|
|
||||||
chat, fresh" (the module clears the key + navigates). -->
|
|
||||||
<button type="button" class="new-chat-btn" id="new-chat-btn" aria-label="New chat">
|
|
||||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
|
|
||||||
<span class="new-chat-label">New chat</span>
|
|
||||||
</button>
|
|
||||||
<!-- Phase 16: single-admin auth — exactly one of Sign in / Sign
|
<!-- Phase 16: single-admin auth — exactly one of Sign in / Sign
|
||||||
out is visible; /api/whoami decides at load (the shared
|
out is visible; /api/whoami decides at load (the shared
|
||||||
header module). Icon-only below 640px (aria-labels keep the
|
header module). Icon-only below 640px (aria-labels keep the
|
||||||
accessible names). -->
|
accessible names). -->
|
||||||
<a href="/login.html?next=/document.html" class="auth-link" id="sign-in-link" hidden>
|
<a href="/login.html?next=/document.html" class="auth-link sign-in-link" id="sign-in-link" hidden>
|
||||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M10 4h8a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-8"/><path d="M4 12h11"/><path d="m12 9 3 3-3 3"/></svg>
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M10 4h8a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-8"/><path d="M4 12h11"/><path d="m12 9 3 3-3 3"/></svg>
|
||||||
<span class="auth-label">Sign in</span>
|
<span class="auth-label">Sign in</span>
|
||||||
</a>
|
</a>
|
||||||
<button type="button" class="auth-link" id="sign-out-btn" aria-label="Sign out" hidden>
|
<button type="button" class="auth-link sign-out-btn" id="sign-out-btn" aria-label="Sign out" hidden>
|
||||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8"/><path d="M9 12h11"/><path d="m17 9 3 3-3 3"/></svg>
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8"/><path d="M9 12h11"/><path d="m17 9 3 3-3 3"/></svg>
|
||||||
<span class="auth-label">Sign out</span>
|
<span class="auth-label">Sign out</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
+61
-68
@@ -5,7 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
<meta name="description" content="Add and remove the git repositories Brain of Reese syncs and indexes (admin-only).">
|
<meta name="description" content="Add and remove the git repositories Brain of Reese syncs and indexes (admin-only).">
|
||||||
<title>Git sources · Brain of Reese</title>
|
<title>Git sources · Brain of Reese</title>
|
||||||
<link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20viewBox=%220%200%2064%2064%22%3E%3Cpath%20d=%22M32%204%2055%2018v28L32%2060%209%2046V18Z%22%20fill=%22%23121a2e%22%20stroke=%22%236d78f2%22%20stroke-width=%224%22%20stroke-linejoin=%22round%22/%3E%3Ccircle%20cx=%2232%22%20cy=%2232%22%20r=%226.5%22%20fill=%22%236d78f2%22/%3E%3Cpath%20d=%22M32%2025.5V16M32%2048v-9.5M25.5%2032H16M48%2032h-9.5%22%20stroke=%22%2322d3ee%22%20stroke-width=%223%22%20stroke-linecap=%22round%22/%3E%3C/svg%3E">
|
<link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20viewBox=%220%200%2064%2064%22%3E%3Cpath%20d=%22M32%204%2055%2018v28L32%2060%209%2046V18Z%22%20fill=%22%231a0f0f%22%20stroke=%22%23f43f5e%22%20stroke-width=%224%22%20stroke-linejoin=%22round%22/%3E%3Ccircle%20cx=%2232%22%20cy=%2232%22%20r=%226.5%22%20fill=%22%23f43f5e%22/%3E%3Cpath%20d=%22M32%2025.5V16M32%2048v-9.5M25.5%2032H16M48%2032h-9.5%22%20stroke=%22%23fca5a5%22%20stroke-width=%223%22%20stroke-linecap=%22round%22/%3E%3C/svg%3E">
|
||||||
<link rel="stylesheet" href="/assets/styles.css">
|
<link rel="stylesheet" href="/assets/styles.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -21,68 +21,63 @@
|
|||||||
<header class="app-header">
|
<header class="app-header">
|
||||||
<div class="container header-inner">
|
<div class="container header-inner">
|
||||||
<span class="brand">
|
<span class="brand">
|
||||||
<svg class="brand-mark" aria-hidden="true" viewBox="0 0 64 64"><path d="M32 4 55 18v28L32 60 9 46V18Z" fill="#121a2e" stroke="#6d78f2" stroke-width="4" stroke-linejoin="round"/><circle cx="32" cy="32" r="6.5" fill="#6d78f2"/><path d="M32 25.5V16M32 48v-9.5M25.5 32H16M48 32h-9.5" stroke="#22d3ee" stroke-width="3" stroke-linecap="round"/></svg>
|
<svg class="brand-mark" aria-hidden="true" viewBox="0 0 64 64"><path d="M32 4 55 18v28L32 60 9 46V18Z" fill="#1a0f0f" stroke="#f43f5e" stroke-width="4" stroke-linejoin="round"/><circle cx="32" cy="32" r="6.5" fill="#f43f5e"/><path d="M32 25.5V16M32 48v-9.5M25.5 32H16M48 32h-9.5" stroke="#fca5a5" stroke-width="3" stroke-linecap="round"/></svg>
|
||||||
<span class="brand-text">Brain of <strong>Reese</strong></span>
|
<span class="brand-text">Brain of <strong>Reese</strong></span>
|
||||||
</span>
|
</span>
|
||||||
<nav class="app-nav" aria-label="Primary">
|
<!-- Phase 46 (owner permission 2026-08-27, `TODO.md` L9): the
|
||||||
|
mobile hamburger — visible ≤640px only (CSS); opens the nav as
|
||||||
|
an animated dropdown. Behavior: assets/header.js. -->
|
||||||
|
<button type="button" class="nav-toggle" id="nav-toggle"
|
||||||
|
aria-expanded="false" aria-controls="app-nav" aria-label="Menu">
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M4 7h16M4 12h16M4 17h16"/></svg>
|
||||||
|
</button>
|
||||||
|
<nav class="app-nav" id="app-nav" aria-label="Primary">
|
||||||
<a href="/" class="nav-link">Chat</a>
|
<a href="/" class="nav-link">Chat</a>
|
||||||
<!-- Phase 19 (now every page — phase 34, owner confirmation
|
<!-- Phase 19 (now every page — phase 34, owner confirmation
|
||||||
2026-08-26): the Sources link is admin-only (owner
|
2026-08-26): the Sources link is admin-only (owner
|
||||||
permission 2026-08-23) — hidden by default, header.js
|
permission 2026-08-23) — hidden by default, header.js
|
||||||
reveals it once whoami says admin. -->
|
reveals it once whoami says admin. -->
|
||||||
<a href="/sources.html" class="nav-link" id="nav-sources" hidden>Sources</a>
|
<a href="/sources.html" class="nav-link" id="nav-sources" hidden>RAG</a>
|
||||||
<!-- Phase 35 (owner permission 2026-08-26): the Git sources
|
<!-- Phase 35 (owner permission 2026-08-26): the Git sources
|
||||||
link is admin-only — hidden by default, header.js
|
link is admin-only — hidden by default, header.js
|
||||||
reveals it once whoami says admin, exactly like the
|
reveals it once whoami says admin, exactly like the
|
||||||
Sources link above; this page IS the current one, so the
|
Sources link above; this page IS the current one, so the
|
||||||
link carries is-active + aria-current like Tuning on
|
link carries is-active + aria-current like Tuning on
|
||||||
tuning.html. -->
|
tuning.html. -->
|
||||||
<a href="/git-sources.html" class="nav-link is-active" aria-current="page" id="nav-git-sources" hidden>Git sources</a>
|
<a href="/git-sources.html" class="nav-link is-active" aria-current="page" id="nav-git-sources" hidden>Sources</a>
|
||||||
<!-- Phase 29 (now every page — phase 34, owner confirmation
|
<!-- Phase 29 (now every page — phase 34, owner confirmation
|
||||||
2026-08-26): the Global Tuning link is admin-only (owner
|
2026-08-26): the Global Tuning link is admin-only (owner
|
||||||
permission 2026-08-25) — hidden by default, header.js
|
permission 2026-08-25) — hidden by default, header.js
|
||||||
reveals it once whoami says admin. -->
|
reveals it once whoami says admin. -->
|
||||||
<a href="/tuning.html" class="nav-link" id="nav-tuning" hidden>Tuning</a>
|
<a href="/tuning.html" class="nav-link" id="nav-tuning" hidden>Tuning</a>
|
||||||
|
<!-- Phase 46 (mobile dropdown copy: sign-in — desktop bar copy is
|
||||||
|
outside the nav; see styles.css .sign-in-mobile rules). -->
|
||||||
|
<a href="/login.html?next=/" class="auth-link sign-in-link sign-in-mobile" id="sign-in-link-mobile" hidden>
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M10 4h8a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-8"/><path d="M4 12h11"/><path d="m12 9 3 3-3 3"/></svg>
|
||||||
|
<span class="auth-label">Sign in</span>
|
||||||
|
</a>
|
||||||
|
<!-- Phase 46 (mobile dropdown copy — desktop bar copy is
|
||||||
|
outside the nav; see styles.css .sign-out-mobile rules). -->
|
||||||
|
<button type="button" class="auth-link sign-out-btn sign-out-mobile" id="sign-out-btn-mobile" aria-label="Sign out" hidden>
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8"/><path d="M9 12h11"/><path d="m17 9 3 3-3 3"/></svg>
|
||||||
|
<span class="auth-label">Sign out</span>
|
||||||
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
<!-- Phase 15 (now every page — phase 34, owner confirmation
|
<!-- Phase 15: the tuning-notes panel (stored in Postgres, read
|
||||||
2026-08-26): open the tuning-notes panel (stored in
|
into every system prompt) — owned by the shared header
|
||||||
Postgres, read into every system prompt). The behavior is
|
module (assets/header.js); the #steering-panel section
|
||||||
owned by the shared header module (assets/header.js); the
|
ships in every page's <main>. The navbar toggle was
|
||||||
#steering-panel section ships in every page's <main>. -->
|
removed at owner request (2026-08-28): note management
|
||||||
<button type="button" class="steering-toggle" id="steering-toggle"
|
lives on /tuning.html. -->
|
||||||
aria-expanded="false" aria-controls="steering-panel">
|
|
||||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M4 7h10M18 7h2M4 17h4M12 17h8"/><circle cx="15.5" cy="7" r="2.2"/><circle cx="9.5" cy="17" r="2.2"/></svg>
|
|
||||||
<span class="steering-label">Tuning</span>
|
|
||||||
<span class="steering-count" id="steering-count">0</span>
|
|
||||||
</button>
|
|
||||||
<!-- Phase 32 (now every page — phase 34, owner confirmation
|
|
||||||
2026-08-26): the admin-only "Sync sources" button — SHIPS
|
|
||||||
hidden (anonymous-safe), header.js reveals it for the admin
|
|
||||||
on the SAME cached whoami. The §7.4 "never stale" lifecycle
|
|
||||||
is module-owned (assets/header.js). This page's hint box
|
|
||||||
points at it: it is the action that clones the listed repos
|
|
||||||
and prunes the removed ones. -->
|
|
||||||
<button type="button" class="sync-btn" id="sync-btn" hidden aria-label="Sync sources">
|
|
||||||
<svg class="sync-icon" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></svg>
|
|
||||||
<span class="sync-label" id="sync-label">Sync sources</span>
|
|
||||||
</button>
|
|
||||||
<!-- Phase 14 (now every page — phase 34, owner confirmation
|
|
||||||
2026-08-26; module-owned since phase 34 task 02): on a
|
|
||||||
non-chat page "New chat" means "go to the chat, fresh" (the
|
|
||||||
module clears the key + navigates). -->
|
|
||||||
<button type="button" class="new-chat-btn" id="new-chat-btn" aria-label="New chat">
|
|
||||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
|
|
||||||
<span class="new-chat-label">New chat</span>
|
|
||||||
</button>
|
|
||||||
<!-- Phase 16: single-admin auth — exactly one of Sign in / Sign
|
<!-- Phase 16: single-admin auth — exactly one of Sign in / Sign
|
||||||
out is visible; /api/whoami decides at load (the shared
|
out is visible; /api/whoami decides at load (the shared
|
||||||
header module). Icon-only below 640px (aria-labels keep the
|
header module). Icon-only below 640px (aria-labels keep the
|
||||||
accessible names). -->
|
accessible names). -->
|
||||||
<a href="/login.html?next=/git-sources.html" class="auth-link" id="sign-in-link" hidden>
|
<a href="/login.html?next=/git-sources.html" class="auth-link sign-in-link" id="sign-in-link" hidden>
|
||||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M10 4h8a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-8"/><path d="M4 12h11"/><path d="m12 9 3 3-3 3"/></svg>
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M10 4h8a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-8"/><path d="M4 12h11"/><path d="m12 9 3 3-3 3"/></svg>
|
||||||
<span class="auth-label">Sign in</span>
|
<span class="auth-label">Sign in</span>
|
||||||
</a>
|
</a>
|
||||||
<button type="button" class="auth-link" id="sign-out-btn" aria-label="Sign out" hidden>
|
<button type="button" class="auth-link sign-out-btn" id="sign-out-btn" aria-label="Sign out" hidden>
|
||||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8"/><path d="M9 12h11"/><path d="m17 9 3 3-3 3"/></svg>
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8"/><path d="M9 12h11"/><path d="m17 9 3 3-3 3"/></svg>
|
||||||
<span class="auth-label">Sign out</span>
|
<span class="auth-label">Sign out</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -117,7 +112,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<h2 id="git-sources-gate-title">Sign in to manage the git sources</h2>
|
<h2 id="git-sources-gate-title">Sign in to manage the git sources</h2>
|
||||||
<p class="sources-gate-sub">
|
<p class="sources-gate-sub">
|
||||||
The list of repositories the <strong>Sync sources</strong> button
|
The list of repositories cloned and indexed by the sync service
|
||||||
clones and indexes is admin-only. Chat — and any document an
|
clones and indexes is admin-only. Chat — and any document an
|
||||||
answer cites — stays open to everyone.
|
answer cites — stays open to everyone.
|
||||||
</p>
|
</p>
|
||||||
@@ -179,33 +174,29 @@
|
|||||||
<p class="git-source-error" id="git-source-error" role="alert" hidden></p>
|
<p class="git-source-error" id="git-source-error" role="alert" hidden></p>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<!-- Phase 38: the second add form — "Local directory": an
|
<!-- Phase 49 (owner permission 2026-08-28): the archive upload form
|
||||||
existing directory on the server (NOT a git repo), walked
|
replaces the phase-38 local-directory form — an uploaded
|
||||||
directly by Sync / import_docs. The SAME never-stale-button
|
.tar/.tar.gz/.tgz/.zip is unpacked under BOR_UPLOAD_DIR and
|
||||||
+ inline-error pattern as the git form (PLAN §7.4): the
|
scanned immediately; the same filename replaces the source in
|
||||||
button disables + relabels "Adding…" while the POST is out
|
place (no new folder, no duplicate row). The file control is
|
||||||
and recovers on success AND failure; on success the input
|
labeled (visible <label for=…> — WCAG input-label rule); the
|
||||||
clears and the list re-fetches (the new row lands with the
|
button runs the §7.4 never-stale lifecycle ("Uploading…"
|
||||||
Local badge). A missing/relative path 422s with the path
|
while the POST is out, restored on success AND failure);
|
||||||
named inline (paths are not secrets, unlike git URLs). -->
|
non-2xx shows the server detail inline (role=alert), 200
|
||||||
<form id="local-source-form">
|
shows the sync-style counts (role=status). -->
|
||||||
<label for="local-source-path">Add a local directory</label>
|
<form id="archive-upload-form">
|
||||||
<input
|
<label for="archive-upload-file">Upload a source archive (.tar, .tar.gz, .tgz, .zip)</label>
|
||||||
id="local-source-path"
|
<input id="archive-upload-file" name="file" type="file"
|
||||||
name="path"
|
accept=".tar,.tar.gz,.tgz,.zip" required>
|
||||||
type="text"
|
<button type="submit" id="archive-upload-btn">Upload & scan</button>
|
||||||
maxlength="2000"
|
<p class="git-source-error" id="archive-upload-error" role="alert" hidden></p>
|
||||||
autocomplete="off"
|
<p class="git-source-result" id="archive-upload-result" role="status"
|
||||||
placeholder="~/Notes"
|
aria-live="polite" hidden></p>
|
||||||
required
|
|
||||||
>
|
|
||||||
<button type="submit" id="local-source-add">Add directory</button>
|
|
||||||
<p class="git-source-error" id="local-source-error" role="alert" hidden></p>
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="table-wrap" id="git-sources-table-wrap" role="region" aria-label="Sources" tabindex="0">
|
<div class="table-wrap" id="git-sources-table-wrap" role="region" aria-label="Sources" tabindex="0">
|
||||||
<table class="git-sources-table" id="git-sources-table">
|
<table class="git-sources-table" id="git-sources-table">
|
||||||
<caption class="visually-hidden">Sources the Sync button imports — git repositories it clones and local directories it walks</caption>
|
<caption class="visually-hidden">Sources the Sync button imports — git repositories it clones, local directories it walks, and uploaded archives (unpacked under the upload directory)</caption>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col">Source</th>
|
<th scope="col">Source</th>
|
||||||
@@ -224,14 +215,16 @@
|
|||||||
|
|
||||||
<!-- Scope boundary (phase locked decision): adding/removing a
|
<!-- Scope boundary (phase locked decision): adding/removing a
|
||||||
source does NOT clone or prune — the Sync button performs
|
source does NOT clone or prune — the Sync button performs
|
||||||
that. The hint says so (phase 38: git + local together,
|
that; the phase-49 upload is the exception (it unpacks and
|
||||||
files removed from a source pruned). -->
|
scans in place, and a same-name re-upload replaces the
|
||||||
|
source in place). -->
|
||||||
<p class="git-source-hint" id="git-sources-hint" role="note">
|
<p class="git-source-hint" id="git-sources-hint" role="note">
|
||||||
Sync clones/pulls the git repos and imports the local
|
Uploads unpack and scan immediately — re-uploading the same
|
||||||
directories together (files removed from a source are
|
filename replaces that source in place (no new folder, no
|
||||||
pruned). Use the <strong>Sync sources</strong> button in the
|
duplicate row). The Sync button still imports the git
|
||||||
header (or on the Sources page) to run it — removing a source
|
checkouts and local directories together (files removed from
|
||||||
prunes its documents from the index on the next sync.
|
a source are pruned) — removing a source prunes its documents
|
||||||
|
from the index on the next sync.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<!-- Polite live region: the screen-reader confirmation for list
|
<!-- Polite live region: the screen-reader confirmation for list
|
||||||
|
|||||||
+41
-39
@@ -5,7 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
<meta name="description" content="Ask Brain of Reese anything about the homelab and deployments.">
|
<meta name="description" content="Ask Brain of Reese anything about the homelab and deployments.">
|
||||||
<title>Brain of Reese</title>
|
<title>Brain of Reese</title>
|
||||||
<link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20viewBox=%220%200%2064%2064%22%3E%3Cpath%20d=%22M32%204%2055%2018v28L32%2060%209%2046V18Z%22%20fill=%22%23121a2e%22%20stroke=%22%236d78f2%22%20stroke-width=%224%22%20stroke-linejoin=%22round%22/%3E%3Ccircle%20cx=%2232%22%20cy=%2232%22%20r=%226.5%22%20fill=%22%236d78f2%22/%3E%3Cpath%20d=%22M32%2025.5V16M32%2048v-9.5M25.5%2032H16M48%2032h-9.5%22%20stroke=%22%2322d3ee%22%20stroke-width=%223%22%20stroke-linecap=%22round%22/%3E%3C/svg%3E">
|
<link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20viewBox=%220%200%2064%2064%22%3E%3Cpath%20d=%22M32%204%2055%2018v28L32%2060%209%2046V18Z%22%20fill=%22%231a0f0f%22%20stroke=%22%23f43f5e%22%20stroke-width=%224%22%20stroke-linejoin=%22round%22/%3E%3Ccircle%20cx=%2232%22%20cy=%2232%22%20r=%226.5%22%20fill=%22%23f43f5e%22/%3E%3Cpath%20d=%22M32%2025.5V16M32%2048v-9.5M25.5%2032H16M48%2032h-9.5%22%20stroke=%22%23fca5a5%22%20stroke-width=%223%22%20stroke-linecap=%22round%22/%3E%3C/svg%3E">
|
||||||
<link rel="stylesheet" href="/assets/styles.css">
|
<link rel="stylesheet" href="/assets/styles.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -14,70 +14,63 @@
|
|||||||
<header class="app-header">
|
<header class="app-header">
|
||||||
<div class="container header-inner">
|
<div class="container header-inner">
|
||||||
<span class="brand">
|
<span class="brand">
|
||||||
<svg class="brand-mark" aria-hidden="true" viewBox="0 0 64 64"><path d="M32 4 55 18v28L32 60 9 46V18Z" fill="#121a2e" stroke="#6d78f2" stroke-width="4" stroke-linejoin="round"/><circle cx="32" cy="32" r="6.5" fill="#6d78f2"/><path d="M32 25.5V16M32 48v-9.5M25.5 32H16M48 32h-9.5" stroke="#22d3ee" stroke-width="3" stroke-linecap="round"/></svg>
|
<svg class="brand-mark" aria-hidden="true" viewBox="0 0 64 64"><path d="M32 4 55 18v28L32 60 9 46V18Z" fill="#1a0f0f" stroke="#f43f5e" stroke-width="4" stroke-linejoin="round"/><circle cx="32" cy="32" r="6.5" fill="#f43f5e"/><path d="M32 25.5V16M32 48v-9.5M25.5 32H16M48 32h-9.5" stroke="#fca5a5" stroke-width="3" stroke-linecap="round"/></svg>
|
||||||
<span class="brand-text">Brain of <strong>Reese</strong></span>
|
<span class="brand-text">Brain of <strong>Reese</strong></span>
|
||||||
</span>
|
</span>
|
||||||
<nav class="app-nav" aria-label="Primary">
|
<!-- Phase 46 (owner permission 2026-08-27, `TODO.md` L9): the
|
||||||
|
mobile hamburger — visible ≤640px only (CSS); opens the nav as
|
||||||
|
an animated dropdown. Behavior: assets/header.js. -->
|
||||||
|
<button type="button" class="nav-toggle" id="nav-toggle"
|
||||||
|
aria-expanded="false" aria-controls="app-nav" aria-label="Menu">
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M4 7h16M4 12h16M4 17h16"/></svg>
|
||||||
|
</button>
|
||||||
|
<nav class="app-nav" id="app-nav" aria-label="Primary">
|
||||||
<a href="/" class="nav-link is-active" aria-current="page">Chat</a>
|
<a href="/" class="nav-link is-active" aria-current="page">Chat</a>
|
||||||
<!-- Phase 19 (now every page — phase 34, owner confirmation
|
<!-- Phase 19 (now every page — phase 34, owner confirmation
|
||||||
2026-08-26): the Sources link is admin-only (owner
|
2026-08-26): the Sources link is admin-only (owner
|
||||||
permission 2026-08-23) — hidden by default, header.js
|
permission 2026-08-23) — hidden by default, header.js
|
||||||
reveals it once whoami says admin. The soft-gated page
|
reveals it once whoami says admin. The soft-gated page
|
||||||
itself is unchanged. -->
|
itself is unchanged. -->
|
||||||
<a href="/sources.html" class="nav-link" id="nav-sources" hidden>Sources</a>
|
<a href="/sources.html" class="nav-link" id="nav-sources" hidden>RAG</a>
|
||||||
<!-- Phase 35 (owner permission 2026-08-26): the Git sources
|
<!-- Phase 35 (owner permission 2026-08-26): the Git sources
|
||||||
link is admin-only — hidden by default, header.js
|
link is admin-only — hidden by default, header.js
|
||||||
reveals it once whoami says admin, exactly like the
|
reveals it once whoami says admin, exactly like the
|
||||||
Sources link above. -->
|
Sources link above. -->
|
||||||
<a href="/git-sources.html" class="nav-link" id="nav-git-sources" hidden>Git sources</a>
|
<a href="/git-sources.html" class="nav-link" id="nav-git-sources" hidden>Sources</a>
|
||||||
<!-- Phase 29 (now every page — phase 34, owner confirmation
|
<!-- Phase 29 (now every page — phase 34, owner confirmation
|
||||||
2026-08-26): the Global Tuning link is admin-only (owner
|
2026-08-26): the Global Tuning link is admin-only (owner
|
||||||
permission 2026-08-25) — hidden by default, header.js
|
permission 2026-08-25) — hidden by default, header.js
|
||||||
reveals it once whoami says admin, exactly like the
|
reveals it once whoami says admin, exactly like the
|
||||||
Sources link above. -->
|
Sources link above. -->
|
||||||
<a href="/tuning.html" class="nav-link" id="nav-tuning" hidden>Tuning</a>
|
<a href="/tuning.html" class="nav-link" id="nav-tuning" hidden>Tuning</a>
|
||||||
|
<!-- Phase 46 (mobile dropdown copy: sign-in — desktop bar copy is
|
||||||
|
outside the nav; see styles.css .sign-in-mobile rules). -->
|
||||||
|
<a href="/login.html?next=/" class="auth-link sign-in-link sign-in-mobile" id="sign-in-link-mobile" hidden>
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M10 4h8a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-8"/><path d="M4 12h11"/><path d="m12 9 3 3-3 3"/></svg>
|
||||||
|
<span class="auth-label">Sign in</span>
|
||||||
|
</a>
|
||||||
|
<!-- Phase 46 (mobile dropdown copy — desktop bar copy is
|
||||||
|
outside the nav; see styles.css .sign-out-mobile rules). -->
|
||||||
|
<button type="button" class="auth-link sign-out-btn sign-out-mobile" id="sign-out-btn-mobile" aria-label="Sign out" hidden>
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8"/><path d="M9 12h11"/><path d="m17 9 3 3-3 3"/></svg>
|
||||||
|
<span class="auth-label">Sign out</span>
|
||||||
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
<!-- Phase 15 (now every page — phase 34, owner confirmation
|
<!-- Phase 15: the tuning-notes panel (stored in Postgres, read
|
||||||
2026-08-26): open the tuning-notes panel (stored in
|
into every system prompt) — owned by the shared header
|
||||||
Postgres, read into every system prompt). The behavior is
|
module (assets/header.js); the #steering-panel section
|
||||||
owned by the shared header module (assets/header.js); the
|
ships in every page's <main>. The navbar toggle was
|
||||||
#steering-panel section ships in every page's <main>. -->
|
removed at owner request (2026-08-28): note management
|
||||||
<button type="button" class="steering-toggle" id="steering-toggle"
|
lives on /tuning.html. -->
|
||||||
aria-expanded="false" aria-controls="steering-panel">
|
|
||||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M4 7h10M18 7h2M4 17h4M12 17h8"/><circle cx="15.5" cy="7" r="2.2"/><circle cx="9.5" cy="17" r="2.2"/></svg>
|
|
||||||
<span class="steering-label">Tuning</span>
|
|
||||||
<span class="steering-count" id="steering-count">0</span>
|
|
||||||
</button>
|
|
||||||
<!-- Phase 32 (now every page — phase 34, owner confirmation
|
|
||||||
2026-08-26): the admin-only "Sync sources" button — SHIPS
|
|
||||||
hidden (anonymous-safe), header.js reveals it for the admin
|
|
||||||
on the SAME cached whoami that reveals #nav-sources /
|
|
||||||
#nav-tuning. The §7.4 "never stale" lifecycle is
|
|
||||||
module-owned (assets/header.js); the Sources page's
|
|
||||||
#sync-result line + #sync-error-banner render off the
|
|
||||||
module's "bor:sync-status" event. -->
|
|
||||||
<button type="button" class="sync-btn" id="sync-btn" hidden aria-label="Sync sources">
|
|
||||||
<svg class="sync-icon" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></svg>
|
|
||||||
<span class="sync-label" id="sync-label">Sync sources</span>
|
|
||||||
</button>
|
|
||||||
<!-- Phase 14 (now every page — phase 34, owner confirmation
|
|
||||||
2026-08-26; module-owned since phase 34 task 02): on the
|
|
||||||
chat page "New chat" resets the local (localStorage)
|
|
||||||
conversation; on every other page it means "go to the
|
|
||||||
chat, fresh" (the module clears the key + navigates). -->
|
|
||||||
<button type="button" class="new-chat-btn" id="new-chat-btn" aria-label="New chat">
|
|
||||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
|
|
||||||
<span class="new-chat-label">New chat</span>
|
|
||||||
</button>
|
|
||||||
<!-- Phase 16: single-admin auth — exactly one of Sign in / Sign
|
<!-- Phase 16: single-admin auth — exactly one of Sign in / Sign
|
||||||
out is visible; /api/whoami decides at load (the shared
|
out is visible; /api/whoami decides at load (the shared
|
||||||
header module). Icon-only below 640px (aria-labels keep the
|
header module). Icon-only below 640px (aria-labels keep the
|
||||||
accessible names). -->
|
accessible names). -->
|
||||||
<a href="/login.html?next=/sources.html" class="auth-link" id="sign-in-link" hidden>
|
<a href="/login.html?next=/sources.html" class="auth-link sign-in-link" id="sign-in-link" hidden>
|
||||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M10 4h8a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-8"/><path d="M4 12h11"/><path d="m12 9 3 3-3 3"/></svg>
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M10 4h8a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-8"/><path d="M4 12h11"/><path d="m12 9 3 3-3 3"/></svg>
|
||||||
<span class="auth-label">Sign in</span>
|
<span class="auth-label">Sign in</span>
|
||||||
</a>
|
</a>
|
||||||
<button type="button" class="auth-link" id="sign-out-btn" aria-label="Sign out" hidden>
|
<button type="button" class="auth-link sign-out-btn" id="sign-out-btn" aria-label="Sign out" hidden>
|
||||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8"/><path d="M9 12h11"/><path d="m17 9 3 3-3 3"/></svg>
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8"/><path d="M9 12h11"/><path d="m17 9 3 3-3 3"/></svg>
|
||||||
<span class="auth-label">Sign out</span>
|
<span class="auth-label">Sign out</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -107,6 +100,15 @@
|
|||||||
</section>
|
</section>
|
||||||
<p class="visually-hidden" id="steering-announcer" role="status" aria-live="polite" aria-atomic="true"></p>
|
<p class="visually-hidden" id="steering-announcer" role="status" aria-live="polite" aria-atomic="true"></p>
|
||||||
|
|
||||||
|
<!-- Phase 14 (module-owned since phase 34 task 02): "New chat" resets
|
||||||
|
the local (localStorage) conversation and clears the rendered list.
|
||||||
|
The binding lives in header.js — dispatches "bor:new-chat" which
|
||||||
|
app.js acts on (it owns the in-flight-turn guard + list reset). -->
|
||||||
|
<button type="button" class="new-chat-btn" id="new-chat-btn" aria-label="New chat">
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
|
||||||
|
<span class="new-chat-label">New chat</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<section class="messages" id="messages" aria-live="polite" aria-label="Conversation with Brain of Reese">
|
<section class="messages" id="messages" aria-live="polite" aria-label="Conversation with Brain of Reese">
|
||||||
<div class="empty-state" id="empty-state">
|
<div class="empty-state" id="empty-state">
|
||||||
<div class="empty-state-glyph" aria-hidden="true">
|
<div class="empty-state-glyph" aria-hidden="true">
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user