# Architecture `@fishdivinity/dsh-local-search` is a native Cordis **Host** plugin. It registers five model-facing tools and talks to two local indexes; it does not use MCP and does not run a separate server process. ## Data flow ``` Agent -> local_search_files -> files engine (Everything / es.exe) -> local_search_content -> content engine (Anytxt JSON-RPC) -> local_search_fragments -> content engine.fragments(ref) -> local_search_read -> content engine.read(ref) -> local_search_status -> engine status + configured roots ``` `lib/engines.js` is the provider registry: - files engine: `{ name, search(args), probe(args) }` - content engine: `{ name, search(args), fragments(args), read(args), probe(args) }` `auto` (or an omitted `engine`) resolves the backend configured for that capability — `files.backend` / `content.backend` — so the default is a setting rather than the registry order; an explicit value still resolves by registry name. Tool names and `engine` values stay stable when a backend is added; the registry and the backend module change. Content items carry `engine` plus a self-describing `ref` (`:`) so `local_search_read`/`local_search_fragments` cannot silently resolve a ref against the wrong backend. ## Two outputs, one probe Every backend answers one question about itself through `probe(args)`: `{ state: 'ready', version?, path? }`, `{ state: 'ready', url }`, or `{ state, detail, code, message }` where `state` is one of `missing-tool`, `missing-index`, `down`. `missing-index` exists for backends whose tool is present but whose index is not (a future `updatedb`/Recoll adapter); neither Windows backend can tell that apart today, so neither reports it. `lib/probe.js` only aggregates — it no longer knows any engine — and normalises each answer into the two outputs the plugin keeps apart: - **Model-facing output**: `ok`, `code`, `message`, plus the version and the path/URL of the engine it is actually talking to. `local_search_status` renders exactly that (`formatStatus`) and nothing else. No fix, no hint, no plugin/DSH version, no platform, no four-state name. - **User-facing output**: the four states, the page's fix line (`detail`), versions, roots, probe time. The configuration card, `GET /dsh-local-search/api/status`, `GET /dsh-local-search/api/diagnose` and `docs/troubleshooting.md` are the only places that explain *why*. The split is load-bearing: a model holding `pwsh`, `grep` and `glob` will turn "install `es.exe`" or "start the service" into an environment repair, while the correct move is to fall back to `grep`/`glob` and say the local index is unavailable. Two gates keep it that way: `probeEngines` hands the model-facing shape only when the caller does not pass `detail: true`, and `test/format.test.js` pins that a status object carrying `state` / `detail` / `hint` renders byte-identically. ## Boundaries - **Read-only toward the model**: no write, delete, index-sync, OCR, export, or settings tool is registered. The package's only writes are settings edits the Host validates and persists: the page's own saves, the 0.2 key migration, and the first-run autoconfiguration. - Four read-only HTTP routes (`status`, `describe`, `diagnose`, `locales`) answer the page; none of them writes, spawns a path a request names, or serves CORS headers. - `GET /api/diagnose` is the one human-facing surface that carries machine identity: plugin/DSH versions, `profile`, `shell`, `remote`, OS, node, roots plus the default scope, the configured paths, endpoint, limits and backends, each engine's state with the address it tried, the first-run outcome (including a write error), and probe time. It redacts `os.homedir()` and `DSH_HOME` to `~` before the text leaves the Host; anything else is shown as written, because pasting it into an issue is the user's decision. - `es.exe` is spawned with a fixed argv and the model text after `--`; state-changing switches are unreachable. - Anytxt is called only through `Search`, `GetResult`, `GetFragmentAll`, and `GetRawTextByFID`. - Network access is limited to the configured local Anytxt endpoint. - Results and document excerpts are capped; the plugin never auto-starts Anytxt or Everything. ## Configuration split | File | Owner | Contains | |---|---|---| | `cordis.patch.yml` in this package | plugin author | The bundle patch row and `config: {}` defaults. It must ship with the package. | | profile `cordis.patch.yml` | user/profile | Machine-specific overrides such as `files.everything.esPath`, `scopes.workRoot`, `scopes.personalRoot`. Not in this repo. | | `Config` schema in `lib/schema.js` | plugin | Field names, defaults, validation. `lib/index.js` re-exports it. | A user config override is a profile concern; the package patch must stay neutral so the same tarball works on every machine. ## Config page The browser half (`lib/client.js`) registers **`plugins.item`** — the list the Plugins page shows as its **Official plugins** group — and contributes one card, exactly the way `dsh-github` contributes its GitHub card. The card shows the one-liner (`view: 'summary'`) and opens the plugin's own page (`view: 'page'`) with the form and its save control. The card's `id` is not cosmetic: the page resolves the form with `formFor(item.id)`, and that answers only for an id the Host serves as a settings namespace — the row id this bundle's patch declares. So `id` is `fishdivinity-local-search`; the package name would resolve no form and the page would render its "settings namespace not served" line. Two neighbouring slots do not fit a schema-driven form, and the page code says so: `plugins.bundle.config` is rendered with **no form at all** (it exists for bundles that draw and store their own settings, as `dsh-cost-meter` does), and `plugins.row.config` can host the same form one level deeper, behind the row's configure control. This package uses the card, because it is the surface a user can find. Settings' "Built-in plugins" inventory is read-only and never carries this page. The card's artwork is the page's, not ours: `itemArtwork(id)` resolves against a hard-coded map of the four built-in pages (`shell`, `agent-loop`, `subagent`, `web-search`) and falls back to `PluginArtworkDefault`. No slot option and no manifest field changes it, which is why `dsh-github` shows the same default crystal. The package's own `icon.svg` (a multi-colour gradient, wired through `package.json` `icon` and the `./icon` export) is what the bundle card in Installed plugins, the npm page, and the README show. A deployment that needs its own artwork on that card would have to contribute a sidebar panel instead. The form is two capability sections followed by three plain ones. **File names** and **File contents** each open with a backend selector and show only the selected backend's fields; **Search scopes**, **Limits** and **Startup** are the same whichever backend runs, so they close the page. The Test button sits at the foot of the grid in the section that owns the field marked `probeEndpoint`, which keeps it beside the address it probes instead of letting it drift down the page. `describe` also carries `platform: { os, pathStyle }`. The page uses it to decide how it rewrites a path draft: drive letters and backslashes are normalised only on Windows, so a `/home/me` typed into a profile that later moves to another machine is not silently turned into `\home\me`. `~` is not expanded on the page: the Host expands it for validation and for use, and a save writes back the Host's resolved path, so a stored profile carries an absolute path. A hand-written `~/x` in the profile keeps working because it is expanded where it is used. `test/client.test.js` compares the page's rewriting with the Host's implementation across both path styles. The page's third plain section is a single `Switch`-backed flag, `checkEnginesOnStart`. It controls the **startup health check**, not the first-run configuration: with it on, `lib/index.js` probes once after `apply` (whether or not `autoConfigured` is already set) and publishes the result as `describe.health` (`{ pending, failures, checkedAt, disabled }`) without writing any config. Off means no probe, no card, and `health.disabled: true`; the manual Test button and the first-run pass are unaffected. Failures carry `{ engine, state, detail, code }`, which is what the card needs to pick its fix line. The plugin's own copy ships in two layers. `zh`/`en` stay in `lib/client.js` as a synchronous baseline: `apply` cannot await a request, or a slow route would hold the tools registration hostage, and the first frame (card title, loading line) must not flash the wrong language. Every other language is a `lib/locales/.json` pack of values only, served by `GET /api/locales` and registered only for languages the shell already knows (`ctx.locale.getSnapshot().locales`). There is no `addLanguage`: inventing a language would put an entry in the shell's picker whose only translated surface is this card. `locale/.json` (title and description for the plugin list) is a separate, optional layer. Two more page surfaces belong to 0.4. A **Copy diagnostics** button under the description opens a `Modal` with the `GET /api/diagnose` text in a selectable `