18 KiB
Phase 105 — Per-source hidden-folders toggle: dot-prefixed paths become indexable per input
Source: TODO.md L3 — "hidden dot folders aren't being indexed. There should be a toggle per input (next to the ignores button) to allow indexing hidden .folders."
Story: n/a (TODO-derived — owner roadmap confirmation 2026-09-14).
Context: The single filesystem walk choke point is iter_importable_files (app/rag/importer.py L161-194): it skips any path with a dot-prefixed component — hidden dirs (vendored caches like .esphome/.espressif/**) AND hidden files — plus the well-known EXCLUDED_DIRS (.venv, node_modules, .git, __pycache__, .pytest_cache, dist, build); the extension filter (BOR_IMPORT_EXTENSIONS, the A9 family) then admits the rest. import_sources (L196+) walks every root twice when progress is set (the phase-64 pre-walk for the total denominator uses the EXACT same rules), collects (source, rel) into seen, and _prune (prune=True) deletes every indexed document of the imported sources whose (source, rel) is not in seen — the same mechanism phase 89 uses so newly-ignored files leave the index. Both live import entry points build a per-root ignore_by_root: dict[str, list[str]] map from the git_sources rows in one loop — the Sync button (app/api/sync.py::_run_sync L233-263) and the CLI (scripts/import_docs.py::_resolve_sources L181-226, consumed at L269/L332-334); the map is keyed by str(root) — the root path string exactly as passed to import_sources — with extend-on-collision for shared roots. scripts/load_test_kb.py calls import_sources with defaults (untouched). The archive-upload background run no longer scans (phase 90), so no map is needed there. Sources are the git_sources rows (kind git / local, phase 35/38) managed on the Sources page (/git-sources.html, view module frontend/assets/git-sources.js, skeleton in frontend/index.html #view-git-sources L514+); the per-row "Ignore paths" button (makeRow L357+, ignoreBtn ~L393-408) sits in the actions cell LEFT of Remove, stored rows only (env-fallback rows, id null, get no control — phase 89 A3); non-empty lists render a N ignored text tag in the source cell (.git-source-ignore-count, frontend/assets/styles.css L2686, text + background, never color alone). The admin-only PATCH /api/git-sources/{source_id} (phase 89 A5, app/api/git_sources.py L374-393) today takes the REQUIRED ignore_paths replace list; the read shapes are GitSourceRow (app/schemas.py L508) and GitSourceOut (L490), the create body GitSourceIn (L450, optional ignore_paths). Alembic head is 0018. The KB tree/catalog (phase 97) and the agent tools read the DB, so newly indexed hidden documents appear in them automatically — no change needed.
Objective
Each stored source carries an index-hidden-folders flag, toggled by a per-row checkbox next to the "Ignore paths" button on the Sources page. When ON for a source, the walk no longer skips dot-prefixed components for that source — files inside hidden folders (and hidden files with an importable extension) are indexed, embedded, and summarized exactly like visible files; when OFF (the default for every existing row), behavior is byte-identical to today. EXCLUDED_DIRS stays excluded in both states, the extension filter always applies, and the flag — like the ignore list — takes effect on the next sync, with previously indexed hidden files pruned when it is switched off. All entry points (Sync button, CLI) honor the flag; the API stays admin-only.
Dependencies
104_chip_sizing_question_cap(todo) — pipeline predecessor (execution order) only; no code dependency (this phase touches the importer, the git-sources API, the sync/CLI pipelines, and the Sources view — none of which phase 104's pins reach; its suites must stay green unchanged).89_source_ignore_paths(complete) — the per-root map, the actions-cell control idiom, the count-tag idiom, and thePATCHroute this phase extends.
Design (shared by all tasks — the executor reads this, not the chat)
- Flag semantics (locked, A1).
include_hidden=Truelifts ONLY the dot-prefixed-component skip initer_importable_files: the existing checkany(part.startswith(".") or part in excluded for part in rel.parts)becomes dot-aware only when the flag is False — e.g.any((not include_hidden and part.startswith(".")) or part in excluded for part in rel.parts). Consequences, all deliberate:- Files INSIDE hidden dirs become importable (
.esphome/esp.mdindexed when ON). - Hidden files with an importable extension also become importable (
.notes.md— the dot check covers components, not "the folder of the file", so one rule covers both; the extension filter is the real content gate, and a secret-flavoured file like.envhas no A9 extension and is never indexed). EXCLUDED_DIRS(.venv,node_modules,.git,__pycache__,.pytest_cache,dist,build) are skipped in BOTH states — caches/VCS internals are never content.- The
ignoretuple (phase 89) composes additively with the flag: an ignored prefix still skips a file wheninclude_hidden=True.
- Files INSIDE hidden dirs become importable (
- Storage (task 01).
git_sources.include_hidden— BOOLEAN NOT NULL, server defaultfalse,Mapped[bool](thedocuments.is_summaryBoolean precedent,app/models.pyL136). Alembic0019_git_source_include_hidden.py(revises0018):op.add_column("git_sources", sa.Column("include_hidden", sa.Boolean(), server_default=sa.text("false"), nullable=False)); downgrade drops the column. Existing rows readFalse(A4). - Importer signature (task 02).
iter_importable_files(root, extensions, excluded=EXCLUDED_DIRS, ignore=(), include_hidden: bool = False)— defaultFalsekeeps every existing caller byte-identical; the docstring's skip sentence gains the flag clause.import_sources(sources, llm, *, prune=False, limit=None, session=None, progress=None, ignore_by_root=None, include_hidden_by_root: dict[str, bool] | None = None)— the map is keyed bystr(root)with the SAME keying convention asignore_by_root; an internal_include_hidden_for_root(root, include_hidden_by_root) -> bool(defaultFalse) is the single read point, used by BOTH the phase-64 progress pre-walk and the processing loop, sofiles_totalnever disagrees with the walk.seenis untouched in shape →_pruneprunes hidden documents automatically when the flag flips OFF (A2 — the A9/phase-89 precedent). Module docstring "Scope" paragraph updated.
- API contract (task 03).
- Schemas (
app/schemas.py):GitSourceIn.include_hidden: bool | None = Field(default=None)(create-time, optional — absent → storedFalse);GitSourceOut.include_hidden: bool;GitSourceRow.include_hidden: bool(env-fallback rows reportFalse— no DB row to store a flag on). - The PATCH body model is RENAMED
GitSourceIgnoreIn→GitSourcePatchIn(grep-verified: referenced only inapp/schemas.pyandapp/api/git_sources.py— import L119 +patch_git_sourceL376) and gains:ignore_paths: list[str] | None = Field(default=None)— absent/None = the row's list is unchanged; PRESENT = replace semantics exactly as phase 89 A5 (normalization + the A4 fixed-detail 422s run only when present). Every existing client always sends the list, so their behavior is byte-identical; the toggle's PATCH sends only the bool.include_hidden: bool | None = Field(default=None)— absent/None = unchanged; present = set.- Both absent → 200 no-op (row untouched).
GET /api/git-sources— DB rows report the stored flag; env rowsFalse.POST /api/git-sources— both kinds acceptinclude_hidden; storedbool(payload.include_hidden).PATCH /api/git-sources/{source_id}(existing route, still behindrequire_admin) — applies each PRESENT field independently (404 unknown id unchanged); 200 →GitSourceOut(id, url, added_at, ignore_paths, include_hidden).
- Schemas (
- Callers (task 04).
app/api/sync.py::_run_sync— in the existing per-row loop that buildsignore_by_root(L233-252), buildinclude_hidden_by_root: dict[str, bool]with the SAMEstr(root)keying:include_hidden_by_root[str(root)] = include_hidden_by_root.get(str(root), False) or bool(row.include_hidden)(collision → OR — the mirror of the ignore-map union: if either row says "index hidden", the shared root does). Passinclude_hidden_by_root=…toimport_sources(L263). Module docstring (L36-40) updated.scripts/import_docs.py—_resolve_sourcesreturns the 3-tuple(sources, ignore_by_root, include_hidden_by_root)(manual--source→(sources, {}, {})— manual dirs have no row; env-fallback rows have no flags); the OR-collision rule is the same;mainunpacks (L269) and passes the map (L332-334); docstrings updated (module +_resolve_sourcesL181).scripts/load_test_kb.py— untouched (defaults).
- UI (task 05). Sources page = the
git-sourcesview. Per stored row (s.idtruthy) inmakeRow: a native labeled checkbox in the actions cell, DOM order Hidden · Ignore paths · Remove (the toggle sits next to — left of — the "Ignore paths" button, per the owner's wording; Remove stays last):<label class="git-source-hidden">wrapping<input type="checkbox" class="git-source-hidden-box">+ visible text "Hidden"; the checkbox'saria-labelisIndex hidden folders for ${kindLabel} source: ${value}(setAttribute — never innerHTML;valueis the git URL or local path, credential-safety discipline),checked = s.include_hidden === true; atitleon the label explains in plain words ("When checked, files inside hidden (dot) folders are indexed on the next sync. Caches (.git, node_modules, .venv, …) stay excluded.").- When
s.include_hidden === true, the source cell also shows a "hidden on" text tag (.git-source-hidden-count— the.git-source-ignore-countidiom: text + background, never color alone, WCAG 1.4.1), next to theN ignoredtag. - §7.4 never-stale lifecycle —
toggleHidden(s, box): onchange, the box disables immediately (no double-flip);PATCH /api/git-sources/${s.id}with{"include_hidden": box.checked}; on 200 → clear the error line,await loadSources()(the row re-renders from the server), THENannounce("Hidden folders enabled|disabled for <value>.")(the phase-89 last-announce order — the confirmation lands after the reload's "N sources listed."); on non-2xx or network failure → the server detail (or the canned "Could not reach the server — the setting was not changed.") into the new page-levelrole="alert"line, and the box reverts to the server state (box.checked = s.include_hidden === true) and re-enables — the UI never claims a state the server didn't save. frontend/index.html— one new element after the#git-sources-table-wrapregion:<p class="git-source-error" id="git-sources-hidden-error" role="alert" hidden></p>(reuses the existing.git-source-errorstyling).frontend/assets/styles.css— near the phase-89 block (~L2657-2695):.git-source-hidden(inline-flex, ~44px hit height matching the action buttons, visible label),.git-source-hidden input[type="checkbox"](sized,accent-coloron the brand pair — verify + record the AA ratio in the comment, house style),:disabled(opacity +cursor: wait— the.git-source-remove:disabledidiom), focus ring via the GLOBAL:focus-visiblerule (L146 — no per-control rule needed), and.git-source-hidden-count(copy of the.git-source-ignore-countrule, provenance comment citing phase 105).- Env-fallback rows (
idnull) get no checkbox — the existing "from .env" tag stays (A3).
- NOT touched: the RAG view (
sources.js),app/rag/retriever.py, the chunker, the KB tree/catalog + agent tools (DB-driven — they pick up newly indexed hidden docs for free),app/rag/git_sources.py(clone/pull only, no walk), the upload run (phase 90 — no scan),AGENTS.md,.agents/PLAN.md, any completed phase.
Tasks
01_include_hidden_column.md—git_sources.include_hiddenBOOLEAN column (model + alembic0019) + default/round-trip tests.02_importer_include_hidden.md—iter_importable_files/import_sourcesflag support (walk + progress pre-walk + prune interaction + ignore composition) + unit & integration tests.03_include_hidden_api.md— schemas (In/Out/Row+ theGitSourcePatchInrename with optional fields) + GET/POST/PATCH wiring + integration tests.04_include_hidden_pipelines.md— wire the per-row flag into_run_syncandscripts/import_docs.py+ integration tests.05_hidden_toggle_sources_ui.md— the per-row "Hidden" checkbox on the Sources page (tag + §7.4 lifecycle + error line + a11y) + source-level unit pins.06_e2e_hidden_folders_toggle.md— dedicated Playwright suitetests/e2e/test_hidden_folders_toggle.py(run in isolation), regressions, full gate, atomic commit.
Testing & Quality
- Unit —
tests/unit/test_importer_include_hidden.py(new, task 02):iter_importable_fileson a tmp fixture tree — default OFF pins today's behavior byte-identically (hidden dir + hidden file skipped), ON admits both,EXCLUDED_DIRSskipped in BOTH states,ignoretuple still bites when ON, extension filter unchanged (.envnever indexed); thestr(root)keying + default-False for unlisted roots;tests/unit/test_hidden_folders_toggle.py(new, task 05): source-level pins for the JS/HTML/CSS wiring (house pattern — read the assets as text), incl. the single-source cross-file check that the aria-label template names the source value; task 01's pins extend the existing model/migration test surfaces (a fresh row readsinclude_hidden is False; an explicitTrueround-trips). - Integration —
tests/integration/test_importer_include_hidden.py(new, task 02):import_sourcesagainst a fixture dir — hidden file produces NODocument/Chunkrows by default; WITH the map it is embedded + summarized normally; previously indexed hidden file + flag OFF → pruned on the next run; progresstotalagrees with the walk in both states; sources not in the map behave exactly as before.tests/integration/test_git_sources_api.py(extended, task 03): GET reportsFalsedefault / storedTrue; POST create round-trip; PATCH bool-only, list-only, both, neither (no-op 200), the phase-89 422s unchanged for present lists, 404, anonymous 403.tests/integration/test_sync_api.py(extended, task 04): a local row with a hidden dir — flag False syncs zero hidden docs, True syncs them;tests/integration/test_import_docs_git.py(extended, task 04): the CLI DB-row path with the flag set. - E2E (mandatory, A16) —
tests/e2e/test_hidden_folders_toggle.py(task 06), run in isolation with the DB up:uv run pytest tests/e2e/test_hidden_folders_toggle.py -v --no-cov. - Coverage: >90% on
app/(the validate.sh gate — the importer/API/pipeline additions are fully unit+integration covered).
Completion Criteria
- A stored source (git, local, or uploaded) has a "Hidden" checkbox next to its "Ignore paths" button: flipping it on persists (
PATCH200, the source cell shows the "hidden on" tag,GET /api/git-sourcesround-tripsinclude_hidden: true); the failure path reverts the box and announces the error in arole="alert"line. - A sync (button or CLI) with the flag OFF indexes nothing with a dot-prefixed component (no
documents/chunksrows — the byte-identical default); with the flag ON,.hidden/note.mdis indexed, embedded, and summarized like any visible file and shows up in the KB catalog;EXCLUDED_DIRScontent is excluded in both states. - A2: a previously indexed hidden file is PRUNED from the KB on the next sync after the flag flips OFF (
detail.prunedincrements; the catalog no longer lists it). - API contracts hold: PATCH bool-only / list-only / both / neither; the phase-89 fixed-detail 422s unchanged for present lists; 404 unknown id; anonymous 403 on the route.
- Env-fallback rows render no checkbox (the "from .env" tag stays); the Sources page stays WCAG-clean (visible label, focus-visible, text never color alone).
uv run pytestgreen;uv run pytest --cov=app --cov-report=term-missingTOTAL >90%;uv run pytest tests/e2e/test_hidden_folders_toggle.py -v --no-covgreen in isolation (DB up); regression suitestest_source_ignore_paths.py,test_git_sources_admin.py,test_local_directory_sources.py,test_sync_button.py,test_smoke.pygreen in isolation;uv run ruff check . && uv run pyrightclean.- One
--no-gpg-signcommit; phase dir moved to.agents/phases/complete/by the pipeline gate.
Locked decisions
- A1 — what "on" means (owner-confirmed 2026-09-14). The flag lifts the dot-prefixed-component skip for that source — files inside hidden folders AND hidden files with an importable extension become indexable;
EXCLUDED_DIRS(.venv,node_modules,.git,__pycache__,.pytest_cache,dist,build) stay excluded regardless; the extension filter always applies. - A2 — toggling off prunes (owner-confirmed). Previously indexed hidden files leave the KB on the next sync (the
seen-set prune — the phase-89 A2 / A9 precedent), exactly like newly-ignored files. - A3 — per stored row only (owner-confirmed). Every stored
git_sourcesrow (git, local, uploaded) gets the toggle; env-fallback rows have no DB row and get no control (the phase-89 A3 precedent). - A4 — default off (owner-confirmed).
include_hiddendefaults tofalsefor all existing rows — byte-identical behavior until the owner flips it; the flag takes effect on the NEXT sync (no auto-sync, same as the ignore list). - A5 — control idiom (owner-confirmed). A native labeled checkbox ("Hidden") in the actions cell, left of the "Ignore paths" button (Remove stays last), with a "hidden on" text tag in the source cell when enabled — not a styled switch.
Commit
git add app/ alembic/versions/0019_git_source_include_hidden.py scripts/ frontend/ tests/ TODO.md .agents/phases/ && git commit --no-gpg-sign -m "feat(sources): per-source hidden-folders toggle — dot-prefixed paths are indexable per input"